on master help sign in
Migrate persistence to Dapper.FSharp with SQLite, implement photo cropping, and enhance button animations
dd6d4a0iko
client/App.fs+30−25
@@ -25,7 +25,7 @@ module App =
25 | Chat of pubkey: string * compose: string
25 | Chat of pubkey: string * compose: string
26 | AddContact of pubkey: string * nickname: string
26 | AddContact of pubkey: string * nickname: string
27 | Settings
27 | Settings
28− | EditProfile of displayName: string * bio: string * photo: byte[]
28+ | EditProfile of displayName: string * bio: string * photo: byte[] option
29
29
30 type Session =
30 type Session =
31 { Url: string
31 { Url: string
@@ -83,7 +83,7 @@ module App =
83 | DismissError
83 | DismissError
84 | StateLoaded of Identity option * Data option * Profile option
84 | StateLoaded of Identity option * Data option * Profile option
85 | DoPickPhoto
85 | DoPickPhoto
86− | PhotoPicked of byte[]
86+ | PhotoPicked of byte[] option
87 | DoSaveProfile
87 | DoSaveProfile
88
88
89 // ── CmdMsg ──
89 // ── CmdMsg ──
@@ -162,7 +162,7 @@ module App =
162 { c with
162 { c with
163 DisplayName = p.DisplayName
163 DisplayName = p.DisplayName
164 Bio = p.Bio
164 Bio = p.Bio
165− Photo = p.Photo }
165+ Photo = p.Photo |> Option.defaultValue [||] }
166
166
167 let private applyEvent (m: Model) (evt: IncomingEvent) =
167 let private applyEvent (m: Model) (evt: IncomingEvent) =
168 match evt.Envelope with
168 match evt.Envelope with
@@ -242,7 +242,7 @@ module App =
242
242
243 { model with
243 { model with
244 Auth = Identified(identity, Offline)
244 Auth = Identified(identity, Offline)
245− Page = EditProfile("", "", [||]) },
245+ Page = EditProfile("", "", None) },
246 [ CmdSaveIdentity identity ]
246 [ CmdSaveIdentity identity ]
247
247
248 | SetServerUrl url -> { model with ServerUrl = url }, []
248 | SetServerUrl url -> { model with ServerUrl = url }, []
@@ -431,7 +431,7 @@ module App =
431 {| ``type`` = "profile"
431 {| ``type`` = "profile"
432 display_name = profile.DisplayName
432 display_name = profile.DisplayName
433 bio = profile.Bio
433 bio = profile.Bio
434− photo = Convert.ToBase64String profile.Photo |}
434+ photo = profile.Photo |}
435 )
435 )
436
436
437 let private sendEnvelope (session: Session) (recipientHex: string) (envelope: Envelope) =
437 let private sendEnvelope (session: Session) (recipientHex: string) (envelope: Envelope) =
@@ -450,6 +450,11 @@ module App =
450 | true, v when v.ValueKind <> JsonValueKind.Null -> Some(v.GetString())
450 | true, v when v.ValueKind <> JsonValueKind.Null -> Some(v.GetString())
451 | _ -> None
451 | _ -> None
452
452
453+ let private tryGetJsonBytes (el: JsonElement) (name: string) =
454+ match el.TryGetProperty(name) with
455+ | true, v when v.ValueKind <> JsonValueKind.Null -> Some(v.GetBytesFromBase64())
456+ | _ -> None
457+
453 let private parseEnvelope (json: string) =
458 let private parseEnvelope (json: string) =
454 use doc = JsonDocument.Parse(json)
459 use doc = JsonDocument.Parse(json)
455 let root = doc.RootElement
460 let root = doc.RootElement
@@ -466,13 +471,13 @@ module App =
466 | "profile" ->
471 | "profile" ->
467 let displayName = root.GetProperty("display_name").GetString()
472 let displayName = root.GetProperty("display_name").GetString()
468 let bio = tryGetJsonString root "bio" |> Option.defaultValue ""
473 let bio = tryGetJsonString root "bio" |> Option.defaultValue ""
469− let photo = tryGetJsonString root "photo" |> Option.defaultValue ""
474+ let photo = tryGetJsonBytes root "photo"
470
475
471 Some(
476 Some(
472 Envelope.ProfileMessage
477 Envelope.ProfileMessage
473 { DisplayName = displayName
478 { DisplayName = displayName
474 Bio = bio
479 Bio = bio
475− Photo = if photo = "" then [||] else Convert.FromBase64String photo }
480+ Photo = photo }
476 )
481 )
477 | _ -> None
482 | _ -> None
478
483
@@ -650,15 +655,26 @@ module App =
650 let! files = sp.OpenFilePickerAsync(opts)
655 let! files = sp.OpenFilePickerAsync(opts)
651
656
652 match files |> Seq.tryHead with
657 match files |> Seq.tryHead with
653− | None -> dispatch (PhotoPicked [||])
658+ | None -> dispatch (PhotoPicked None)
654 | Some file ->
659 | Some file ->
655 use! stream = file.OpenReadAsync()
660 use! stream = file.OpenReadAsync()
656 use ms = new MemoryStream()
661 use ms = new MemoryStream()
657 do! stream.CopyToAsync(ms)
662 do! stream.CopyToAsync(ms)
658− dispatch (PhotoPicked(ms.ToArray()))
663+ use skBmp = SkiaSharp.SKBitmap.Decode(ms.ToArray())
664+ let size = min skBmp.Width skBmp.Height
665+ let x = (skBmp.Width - size) / 2
666+ let y = (skBmp.Height - size) / 2
667+ use cropped = new SkiaSharp.SKBitmap(size, size)
668+
669+ skBmp.ExtractSubset(cropped, SkiaSharp.SKRectI(x, y, x + size, y + size))
670+ |> ignore
671+
672+ use img = SkiaSharp.SKImage.FromBitmap(cropped)
673+ use data = img.Encode(SkiaSharp.SKEncodedImageFormat.Png, 90)
674+ dispatch (PhotoPicked(Some(data.ToArray())))
659− | None -> dispatch (PhotoPicked [||])
675+ | None -> dispatch (PhotoPicked None)
660 with _ ->
676 with _ ->
661− dispatch (PhotoPicked [||])
677+ dispatch (PhotoPicked None)
662 }
678 }
663 :> System.Threading.Tasks.Task)
679 :> System.Threading.Tasks.Task)
664 |> ignore)
680 |> ignore)
@@ -713,7 +729,7 @@ module App =
713 let displayName, bio, photo =
729 let displayName, bio, photo =
714 match model.Profile with
730 match model.Profile with
715 | Some p -> p.DisplayName, p.Bio, p.Photo
731 | Some p -> p.DisplayName, p.Bio, p.Photo
716− | None -> "", "", [||]
732+ | None -> "", "", None
717
733
718 if displayName <> "" then
734 if displayName <> "" then
719 TextBlock($"Name: {displayName}").fontSize (14.)
735 TextBlock($"Name: {displayName}").fontSize (14.)
@@ -845,7 +861,7 @@ module App =
845 })
861 })
846 .margin (20.)
862 .margin (20.)
847
863
848− let private viewEditProfile model displayName bio (photo: byte[]) =
864+ let private viewEditProfile model displayName bio (photo: byte[] option) =
849 ScrollViewer(
865 ScrollViewer(
850 (VStack(16.) {
866 (VStack(16.) {
851 HStack(8.) {
867 HStack(8.) {
@@ -855,18 +871,7 @@ module App =
855
871
856 viewErrorBanner model.Error
872 viewErrorBanner model.Error
857
873
858− if photo.Length > 0 then
874+ imageButton photo DoPickPhoto
859− Image(
860− new Avalonia.Media.Imaging.Bitmap(new MemoryStream(photo: byte[])),
861− Avalonia.Media.Stretch.Uniform
862− )
863− .width(120.)
864− .height(120.)
865− .centerHorizontal ()
866− else
867− TextBlock("No Photo").fontSize(16.).centerText().foreground (SolidColorBrush(Colors.Gray))
868−
869− Button("Change Photo", DoPickPhoto).centerHorizontal ()
870
875
871 TextBlock("Display Name:")
876 TextBlock("Display Name:")
872 TextBox(displayName, fun text -> SetPage(EditProfile(text, bio, photo))).watermark ("Your name")
877 TextBox(displayName, fun text -> SetPage(EditProfile(text, bio, photo))).watermark ("Your name")
client/Assets/Images/user.pngnew file

Binary file not shown.

client/Assets/Images/user.svgdeleted−1
@@ -1 +0,0 @@
1−<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M320 312C386.3 312 440 258.3 440 192C440 125.7 386.3 72 320 72C253.7 72 200 125.7 200 192C200 258.3 253.7 312 320 312zM290.3 368C191.8 368 112 447.8 112 546.3C112 562.7 125.3 576 141.7 576L498.3 576C514.7 576 528 562.7 528 546.3C528 447.8 448.2 368 349.7 368L290.3 368z"/></svg>
client/Buttons.fs+32−5
@@ -46,6 +46,8 @@ module Buttons =
46 .fontWeight(FontWeight.Bold)
46 .fontWeight(FontWeight.Bold)
47 .center()
47 .center()
48 .margin (8.)
48 .margin (8.)
49+
50+ Rectangle().stroke(Colors.Black).strokeThickness (4.)
49 })
51 })
50 .background(Brushes.Transparent)
52 .background(Brushes.Transparent)
51 .onPointerPressed(fun _ -> pressed.Set true)
53 .onPointerPressed(fun _ -> pressed.Set true)
@@ -55,7 +57,12 @@ module Buttons =
55 }
57 }
56
58
57 let imageButton (img: byte[] option) (msg: 'msg) =
59 let imageButton (img: byte[] option) (msg: 'msg) =
58− Component(Option.defaultWith (fun _ -> "img") img) {
60+ let key =
61+ match img with
62+ | None -> "img-button"
63+ | Some i -> $"img-button-{hash i}"
64+
65+ Component(key) {
59 let! pressed = Context.State(false)
66 let! pressed = Context.State(false)
60 let offset = if pressed.Current then 4. else 8.
67 let offset = if pressed.Current then 4. else 8.
61
68
@@ -68,19 +75,39 @@ module Buttons =
68 Avalonia.Visual.RenderTransformProperty,
75 Avalonia.Visual.RenderTransformProperty,
69 TimeSpan.FromMilliseconds animationDuration
76 TimeSpan.FromMilliseconds animationDuration
70 )
77 )
71− .easing (BackEaseOut())
78+ .easing (easing)
72 )
79 )
73
80
74− Rectangle().fill (SolidColorBrush(Constants.accentColor))
81+ Rectangle().fill (SolidColorBrush(Colors.White))
75
82
76 match img with
83 match img with
84+ | None ->
85+ ViewBox(
86+ (VStack(32) {
77− | None -> Image("avares://Skrepka/Assets/Images/user.svg", Stretch.Uniform)
87+ Image("avares://Skrepka/Assets/Images/user.png", Stretch.Uniform)
88+ .width(80.)
89+ .margin (0., 20., 0., 0.)
90+
91+ Label("Upload")
92+ .fontFamily(FontFamily("avares://Skrepka/Assets/Fonts#Unbounded"))
93+ .fontWeight(FontWeight.Bold)
94+ .fontSize(24.)
95+ .centerContentHorizontal ()
96+ })
97+ )
98+ .margin (24.)
99+
100+ | Some i ->
78− | Some i -> Image(new Avalonia.Media.Imaging.Bitmap(new System.IO.MemoryStream(i)), Stretch.Uniform)
101+ Image(new Avalonia.Media.Imaging.Bitmap(new System.IO.MemoryStream(i)), Stretch.UniformToFill)
102+ .clipToBounds (true)
79
103
104+ Rectangle().stroke(Colors.Black).strokeThickness (4.)
80 })
105 })
81 .background(Brushes.Transparent)
106 .background(Brushes.Transparent)
82 .onPointerPressed(fun _ -> pressed.Set true)
107 .onPointerPressed(fun _ -> pressed.Set true)
83 .onPointerReleased(fun _ -> pressed.Set false)
108 .onPointerReleased(fun _ -> pressed.Set false)
84 .onPointerExited(fun _ -> pressed.Set false)
109 .onPointerExited(fun _ -> pressed.Set false)
110+ .width(200.)
111+ .height(200.)
85 .onTapped (fun _ -> msg)
112 .onTapped (fun _ -> msg)
86 }
113 }
client/Platform/Desktop/Info.plistnew file+24
@@ -0,0 +1,24 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+<plist version="1.0">
4+<dict>
5+ <key>CFBundleName</key>
6+ <string>Skrepka</string>
7+ <key>CFBundleDisplayName</key>
8+ <string>Skrepka</string>
9+ <key>CFBundleIdentifier</key>
10+ <string>com.skrepka.client</string>
11+ <key>CFBundleVersion</key>
12+ <string>1.0</string>
13+ <key>CFBundleShortVersionString</key>
14+ <string>1.0</string>
15+ <key>CFBundlePackageType</key>
16+ <string>APPL</string>
17+ <key>CFBundleExecutable</key>
18+ <string>Skrepka</string>
19+ <key>CFBundleIconFile</key>
20+ <string>AppIcon</string>
21+ <key>NSHighResolutionCapable</key>
22+ <true/>
23+</dict>
24+</plist>
client/Skrepka.app/Contents/Info.plistnew file+24
@@ -0,0 +1,24 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+<plist version="1.0">
4+<dict>
5+ <key>CFBundleName</key>
6+ <string>Skrepka</string>
7+ <key>CFBundleDisplayName</key>
8+ <string>Skrepka</string>
9+ <key>CFBundleIdentifier</key>
10+ <string>com.skrepka.client</string>
11+ <key>CFBundleVersion</key>
12+ <string>1.0</string>
13+ <key>CFBundleShortVersionString</key>
14+ <string>1.0</string>
15+ <key>CFBundlePackageType</key>
16+ <string>APPL</string>
17+ <key>CFBundleExecutable</key>
18+ <string>Skrepka</string>
19+ <key>CFBundleIconFile</key>
20+ <string>AppIcon</string>
21+ <key>NSHighResolutionCapable</key>
22+ <true/>
23+</dict>
24+</plist>
client/Skrepka.app/Contents/MacOS/Skrepkanew file+1
@@ -0,0 +1 @@
1+../../../Skrepka.dll
client/Skrepka.app/Contents/Resources/AppIcon.icnsnew file

Binary file not shown.

client/Skrepka.fsproj+15−2
@@ -43,10 +43,22 @@
43
43
44 <ItemGroup Condition="$(AvaloniaPlatform) == 'Desktop'">
44 <ItemGroup Condition="$(AvaloniaPlatform) == 'Desktop'">
45 <Compile Include="$(DesktopPlatformFolder)Program.fs" />
45 <Compile Include="$(DesktopPlatformFolder)Program.fs" />
46− <Content Include="$(DesktopPlatformFolder)AppIcon.icns" CopyToOutputDirectory="PreserveNewest" />
46+ <None Include="$(DesktopPlatformFolder)AppIcon.icns" />
47+ <None Include="$(DesktopPlatformFolder)Info.plist" />
47 <PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
48 <PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
48 </ItemGroup>
49 </ItemGroup>
49
50
51+ <Target Name="CreateMacAppBundle" AfterTargets="Build" Condition="$(AvaloniaPlatform) == 'Desktop' And $([MSBuild]::IsOSPlatform('osx'))">
52+ <PropertyGroup>
53+ <_AppBundle>$(TargetDir)Skrepka.app</_AppBundle>
54+ </PropertyGroup>
55+ <MakeDir Directories="$(_AppBundle)/Contents/MacOS" />
56+ <MakeDir Directories="$(_AppBundle)/Contents/Resources" />
57+ <Copy SourceFiles="$(DesktopPlatformFolder)Info.plist" DestinationFolder="$(_AppBundle)/Contents" />
58+ <Copy SourceFiles="$(DesktopPlatformFolder)AppIcon.icns" DestinationFiles="$(_AppBundle)/Contents/Resources/AppIcon.icns" />
59+ <Exec Command="ln -sf '../../../$(AssemblyName)' '$(_AppBundle)/Contents/MacOS/Skrepka'" />
60+ </Target>
61+
50 <ItemGroup Condition="$(AvaloniaPlatform) == 'iOS'">
62 <ItemGroup Condition="$(AvaloniaPlatform) == 'iOS'">
51 <None Include="$(iOSPlatformFolder)Entitlements.plist" />
63 <None Include="$(iOSPlatformFolder)Entitlements.plist" />
52 <None Include="$(iOSPlatformFolder)Info.plist" LogicalName="Info.plist" />
64 <None Include="$(iOSPlatformFolder)Info.plist" LogicalName="Info.plist" />
@@ -73,8 +85,9 @@
73 <PackageReference Include="FSharp.Core" Version="9.0.100" />
85 <PackageReference Include="FSharp.Core" Version="9.0.100" />
74 <PackageReference Include="Fabulous" Version="3.0.0-pre22" />
86 <PackageReference Include="Fabulous" Version="3.0.0-pre22" />
75 <PackageReference Include="Fabulous.Avalonia" Version="3.0.0-pre13" />
87 <PackageReference Include="Fabulous.Avalonia" Version="3.0.0-pre13" />
76− <PackageReference Include="LiteDB" Version="5.0.21" />
77 <PackageReference Include="Sodium.Core" Version="1.4.0" />
88 <PackageReference Include="Sodium.Core" Version="1.4.0" />
89+ <PackageReference Include="Dapper.FSharp" Version="4.5.1" />
90+ <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
78 </ItemGroup>
91 </ItemGroup>
79
92
80</Project>
93</Project>
client/Store.fs+139−76
@@ -2,13 +2,14 @@ namespace Skrepka
2
2
3open System
3open System
4open System.IO
4open System.IO
5−open LiteDB
5+open Dapper
6+open Microsoft.Data.Sqlite
7+open Dapper.FSharp.SQLite
6
8
7module Store =
9module Store =
8
10
9− [<CLIMutable>]
10 type Contact =
11 type Contact =
11− { [<BsonId>] Pubkey: string
12+ { Pubkey: string
12 Nickname: string
13 Nickname: string
13 DisplayName: string
14 DisplayName: string
14 Bio: string
15 Bio: string
@@ -17,12 +18,16 @@ module Store =
17 type Profile =
18 type Profile =
18 { DisplayName: string
19 { DisplayName: string
19 Bio: string
20 Bio: string
20− Photo: byte[] }
21+ Photo: byte[] option }
21
22
22 [<RequireQualifiedAccess>]
23 [<RequireQualifiedAccess>]
23− type DeliveryStatus = Sent | Delivered
24+ type DeliveryStatus =
25+ | Sent
26+ | Delivered
24
27
28+ type MessageDirection =
29+ | Incoming
25− type MessageDirection = Incoming | Outgoing of DeliveryStatus
30+ | Outgoing of DeliveryStatus
26
31
27 type ChatMessage =
32 type ChatMessage =
28 { Id: string
33 { Id: string
@@ -36,9 +41,25 @@ module Store =
36 ServerUrl: string
41 ServerUrl: string
37 PollCursor: int64 }
42 PollCursor: int64 }
38
43
44+ // DB row types matching table columns
45+ [<CLIMutable>]
46+ type ProfileRow =
47+ { Id: string
48+ DisplayName: string
49+ Bio: string
50+ Photo: byte[] option }
51+
52+ [<CLIMutable>]
53+ type ContactRow =
54+ { Pubkey: string
55+ Nickname: string
56+ DisplayName: string
57+ Bio: string
58+ Photo: byte[] option }
59+
39 [<CLIMutable>]
60 [<CLIMutable>]
40− type private MessageDoc =
61+ type MessageRow =
41− { [<BsonId>] Id: string
62+ { Id: string
42 ConversationId: string
63 ConversationId: string
43 Body: string
64 Body: string
44 TimestampUnix: int64
65 TimestampUnix: int64
@@ -46,50 +67,55 @@ module Store =
46 Status: int }
67 Status: int }
47
68
48 [<CLIMutable>]
69 [<CLIMutable>]
49− type private SettingsDoc =
70+ type SettingsRow =
50− { [<BsonId>] Id: string
71+ { Id: string
51 ServerUrl: string
72 ServerUrl: string
52 PollCursor: int64 }
73 PollCursor: int64 }
53
74
54− [<CLIMutable>]
75+ let private profileTable = table'<ProfileRow> "profile"
55− type private ProfileDoc =
76+ let private contactTable = table'<ContactRow> "contacts"
56− { [<BsonId>] Id: string
77+ let private messageTable = table'<MessageRow> "messages"
57− DisplayName: string
78+ let private settingsTable = table'<SettingsRow> "settings"
58− Bio: string
59− Photo: byte[] }
60−
61− let private orEmpty s = s |> Option.ofObj |> Option.defaultValue ""
62−
63− let private toChatMessage (d: MessageDoc) : ChatMessage =
64− { Id = d.Id; Body = d.Body; Timestamp = DateTimeOffset.FromUnixTimeMilliseconds d.TimestampUnix
65− Direction = if not d.IsOutgoing then Incoming else Outgoing(match d.Status with 1 -> DeliveryStatus.Delivered | _ -> DeliveryStatus.Sent) }
66−
67− let private toMessageDoc convId (m: ChatMessage) : MessageDoc =
68− let isOutgoing, status =
69− match m.Direction with
70− | Incoming -> false, 0
71− | Outgoing DeliveryStatus.Delivered -> true, 1
72− | Outgoing DeliveryStatus.Sent -> true, 0
73− { Id = m.Id; ConversationId = convId; Body = m.Body; TimestampUnix = m.Timestamp.ToUnixTimeMilliseconds()
74− IsOutgoing = isOutgoing; Status = status }
75
79
76 let private appDataDir =
80 let private appDataDir =
77 let baseDir =
81 let baseDir =
78 let appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)
82 let appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)
83+
79 if String.IsNullOrEmpty(appData) then
84 if String.IsNullOrEmpty(appData) then
80− // iOS: use the app's Documents directory
81 let personal = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
85 let personal = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
86+
82 if String.IsNullOrEmpty(personal) then
87 if String.IsNullOrEmpty(personal) then
83 Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Skrepka")
88 Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Skrepka")
84 else
89 else
85 Path.Combine(personal, "Skrepka")
90 Path.Combine(personal, "Skrepka")
86 else
91 else
87 Path.Combine(appData, "Skrepka")
92 Path.Combine(appData, "Skrepka")
93+
88 Directory.CreateDirectory(baseDir) |> ignore
94 Directory.CreateDirectory(baseDir) |> ignore
89 baseDir
95 baseDir
90
96
91− let private openDb () =
92− new LiteDatabase(Path.Combine(appDataDir, "skrepka.db"))
97+ let private dbPath = Path.Combine(appDataDir, "skrepka.db")
98+
99+ let private openConn () =
100+ let conn = new SqliteConnection($"Data Source={dbPath}")
101+ conn.Open()
102+ conn
103+
104+ type private ByteArrayOptionHandler() =
105+ inherit SqlMapper.TypeHandler<byte[] option>()
106+ override _.SetValue(p, value) =
107+ p.Value <- match value with Some v -> box v | None -> box DBNull.Value
108+ override _.Parse(value) =
109+ if value = null || value :? DBNull then None else Some(value :?> byte[])
110+
111+ do
112+ OptionTypes.register ()
113+ SqlMapper.AddTypeHandler(ByteArrayOptionHandler())
114+ use conn = openConn ()
115+ conn.Execute("CREATE TABLE IF NOT EXISTS profile (Id TEXT PRIMARY KEY, DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)") |> ignore
116+ conn.Execute("CREATE TABLE IF NOT EXISTS contacts (Pubkey TEXT PRIMARY KEY, Nickname TEXT NOT NULL DEFAULT '', DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)") |> ignore
117+ conn.Execute("CREATE TABLE IF NOT EXISTS messages (Id TEXT PRIMARY KEY, ConversationId TEXT NOT NULL, Body TEXT NOT NULL DEFAULT '', TimestampUnix INTEGER NOT NULL, IsOutgoing INTEGER NOT NULL DEFAULT 0, Status INTEGER NOT NULL DEFAULT 0)") |> ignore
118+ conn.Execute("CREATE TABLE IF NOT EXISTS settings (Id TEXT PRIMARY KEY, ServerUrl TEXT NOT NULL DEFAULT '', PollCursor INTEGER NOT NULL DEFAULT 0)") |> ignore
93
119
94 let private identityPath = Path.Combine(appDataDir, "identity.key")
120 let private identityPath = Path.Combine(appDataDir, "identity.key")
95
121
@@ -98,8 +124,7 @@ module Store =
98 return
124 return
99 try
125 try
100 if File.Exists(identityPath) then
126 if File.Exists(identityPath) then
101− let b64 = File.ReadAllText(identityPath)
127+ File.ReadAllText(identityPath)
102− b64
103 |> Option.ofObj
128 |> Option.ofObj
104 |> Option.filter (fun s -> s <> "")
129 |> Option.filter (fun s -> s <> "")
105 |> Option.map (Convert.FromBase64String >> Crypto.identityFromPrivKey)
130 |> Option.map (Convert.FromBase64String >> Crypto.identityFromPrivKey)
@@ -117,77 +142,115 @@ module Store =
117
142
118 let loadProfile () : Profile option =
143 let loadProfile () : Profile option =
119 try
144 try
120− use db = openDb ()
145+ use conn = openConn ()
121− db.GetCollection<ProfileDoc>("profile").FindById(BsonValue "me")
146+
122− |> Option.ofObj
147+ select { for p in profileTable do where (p.Id = "me") }
123− |> Option.map (fun p ->
148+ |> conn.SelectAsync<ProfileRow>
149+ |> Async.AwaitTask
150+ |> Async.RunSynchronously
151+ |> Seq.tryHead
124− { DisplayName = orEmpty p.DisplayName
152+ |> Option.map (fun p -> { DisplayName = p.DisplayName; Bio = p.Bio; Photo = p.Photo })
125− Bio = orEmpty p.Bio
126− Photo = if p.Photo = null then [||] else p.Photo })
127− with _ -> None
153+ with _ ->
154+ None
128
155
129 let saveProfile (profile: Profile) =
156 let saveProfile (profile: Profile) =
130 try
157 try
131− use db = openDb ()
158+ use conn = openConn ()
132− db.GetCollection<ProfileDoc>("profile").Upsert
159+
133− { Id = "me"
160+ conn.Execute(
161+ "INSERT OR REPLACE INTO profile (Id, DisplayName, Bio, Photo) VALUES (@Id, @DisplayName, @Bio, @Photo)",
134− DisplayName = profile.DisplayName
162+ {| Id = "me"; DisplayName = profile.DisplayName; Bio = profile.Bio
135− Bio = profile.Bio
136− Photo = profile.Photo }
163+ Photo = profile.Photo |> Option.defaultValue null |})
137 |> ignore
164 |> ignore
138− with _ -> ()
165+ with _ ->
166+ ()
139
167
140 let loadData () : Data option =
168 let loadData () : Data option =
141 try
169 try
142− use db = openDb ()
170+ use conn = openConn ()
143− let settings = db.GetCollection<SettingsDoc>("settings").FindById(BsonValue "settings")
171+
172+ let settings =
173+ select { for s in settingsTable do where (s.Id = "settings") }
174+ |> conn.SelectAsync<SettingsRow>
175+ |> Async.AwaitTask
176+ |> Async.RunSynchronously
177+ |> Seq.tryHead
144
178
145 settings
179 settings
146− |> Option.ofObj
180+ |> Option.map (fun s ->
147− |> Option.map (fun settings ->
148 let contacts =
181 let contacts =
149− db.GetCollection<Contact>("contacts").FindAll()
182+ select { for c in contactTable do selectAll }
183+ |> conn.SelectAsync<ContactRow>
184+ |> Async.AwaitTask
185+ |> Async.RunSynchronously
150 |> Seq.map (fun c ->
186 |> Seq.map (fun c ->
151− c.Pubkey,
187+ let contact: Contact =
152− { c with
188+ { Pubkey = c.Pubkey
189+ Nickname = c.Nickname
153− DisplayName = orEmpty c.DisplayName
190+ DisplayName = c.DisplayName
154− Bio = orEmpty c.Bio
191+ Bio = c.Bio
155− Photo = if c.Photo = null then [||] else c.Photo })
192+ Photo = c.Photo |> Option.defaultValue [||] }
193+
194+ c.Pubkey, contact)
156 |> Map.ofSeq
195 |> Map.ofSeq
157
196
158 let messages =
197 let messages =
159− db.GetCollection<MessageDoc>("messages").FindAll()
198+ select { for m in messageTable do selectAll }
199+ |> conn.SelectAsync<MessageRow>
200+ |> Async.AwaitTask
201+ |> Async.RunSynchronously
160 |> Seq.groupBy _.ConversationId
202 |> Seq.groupBy _.ConversationId
161− |> Seq.map (fun (convId, docs) ->
203+ |> Seq.map (fun (convId, rows) ->
204+ convId,
205+ rows
206+ |> Seq.map (fun m ->
207+ { Id = m.Id
208+ Body = m.Body
209+ Timestamp = DateTimeOffset.FromUnixTimeMilliseconds m.TimestampUnix
210+ Direction =
211+ if not m.IsOutgoing then Incoming
212+ else Outgoing(if m.Status = 1 then DeliveryStatus.Delivered else DeliveryStatus.Sent) })
162− convId, docs |> Seq.map toChatMessage |> Seq.sortBy _.Timestamp |> Seq.toList)
213+ |> Seq.sortBy _.Timestamp
214+ |> Seq.toList)
163 |> Map.ofSeq
215 |> Map.ofSeq
164
216
165 { Contacts = contacts
217 { Contacts = contacts
166 Messages = messages
218 Messages = messages
167− ServerUrl = settings.ServerUrl |> orEmpty
219+ ServerUrl = s.ServerUrl
168− PollCursor = settings.PollCursor })
220+ PollCursor = s.PollCursor })
169 with _ ->
221 with _ ->
170 None
222 None
171
223
172 let saveData (data: Data) =
224 let saveData (data: Data) =
173 try
225 try
174− use db = openDb ()
226+ use conn = openConn ()
175− let contacts = db.GetCollection<Contact>("contacts")
176− let messages = db.GetCollection<MessageDoc>("messages")
177− let settings = db.GetCollection<SettingsDoc>("settings")
178
227
179 for c in data.Contacts.Values do
228 for c in data.Contacts.Values do
229+ conn.Execute(
230+ "INSERT OR REPLACE INTO contacts (Pubkey, Nickname, DisplayName, Bio, Photo) VALUES (@Pubkey, @Nickname, @DisplayName, @Bio, @Photo)",
231+ {| Pubkey = c.Pubkey; Nickname = c.Nickname; DisplayName = c.DisplayName; Bio = c.Bio
232+ Photo = if c.Photo = null || c.Photo.Length = 0 then null else c.Photo |})
180− contacts.Upsert c |> ignore
233+ |> ignore
181
234
182 data.Messages
235 data.Messages
183 |> Map.iter (fun convId msgs ->
236 |> Map.iter (fun convId msgs ->
184 for m in msgs do
237 for m in msgs do
185− messages.Upsert(toMessageDoc convId m) |> ignore)
238+ let isOutgoing, status =
239+ match m.Direction with
240+ | Incoming -> false, 0
241+ | Outgoing DeliveryStatus.Delivered -> true, 1
242+ | Outgoing DeliveryStatus.Sent -> true, 0
243+
244+ conn.Execute(
245+ "INSERT OR REPLACE INTO messages (Id, ConversationId, Body, TimestampUnix, IsOutgoing, Status) VALUES (@Id, @ConversationId, @Body, @TimestampUnix, @IsOutgoing, @Status)",
246+ {| Id = m.Id; ConversationId = convId; Body = m.Body
247+ TimestampUnix = m.Timestamp.ToUnixTimeMilliseconds()
248+ IsOutgoing = isOutgoing; Status = status |})
249+ |> ignore)
186
250
187− settings.Upsert
251+ conn.Execute(
188− { Id = "settings"
252+ "INSERT OR REPLACE INTO settings (Id, ServerUrl, PollCursor) VALUES (@Id, @ServerUrl, @PollCursor)",
189− ServerUrl = data.ServerUrl
190− PollCursor = data.PollCursor }
253+ {| Id = "settings"; ServerUrl = data.ServerUrl; PollCursor = data.PollCursor |})
191 |> ignore
254 |> ignore
192 with _ ->
255 with _ ->
193 ()
256 ()