on master help sign in
Migrate persistence to Dapper.FSharp with SQLite, implement photo cropping, and enhance button animations
commitdd6d4a091f99ff15cb454e1150bc7ae2744f1e02 authoriko <mail@iko.soy> dateMon 13 Apr 2026, 21:08 (6 months ago) parentdbe902c
10 files changed+265−109
client/App.fs+30−25 client/Assets/Images/user.png client/Assets/Images/user.svg−1 client/Buttons.fs+32−5 client/Platform/Desktop/Info.plist+24 client/Skrepka.app/Contents/Info.plist+24 client/Skrepka.app/Contents/MacOS/Skrepka+1 client/Skrepka.app/Contents/Resources/AppIcon.icns client/Skrepka.fsproj+15−2 client/Store.fs+139−76
client/App.fs+30−25
@@ -25,7 +25,7 @@ module App =
2525 | Chat of pubkey: string * compose: string
2626 | AddContact of pubkey: string * nickname: string
2727 | Settings
28− | EditProfile of displayName: string * bio: string * photo: byte[]
28+ | EditProfile of displayName: string * bio: string * photo: byte[] option
2929
3030 type Session =
3131 { Url: string
@@ -83,7 +83,7 @@ module App =
8383 | DismissError
8484 | StateLoaded of Identity option * Data option * Profile option
8585 | DoPickPhoto
86− | PhotoPicked of byte[]
86+ | PhotoPicked of byte[] option
8787 | DoSaveProfile
8888
8989 // ── CmdMsg ──
@@ -162,7 +162,7 @@ module App =
162162 { c with
163163 DisplayName = p.DisplayName
164164 Bio = p.Bio
165− Photo = p.Photo }
165+ Photo = p.Photo |> Option.defaultValue [||] }
166166
167167 let private applyEvent (m: Model) (evt: IncomingEvent) =
168168 match evt.Envelope with
@@ -242,7 +242,7 @@ module App =
242242
243243 { model with
244244 Auth = Identified(identity, Offline)
245− Page = EditProfile("", "", [||]) },
245+ Page = EditProfile("", "", None) },
246246 [ CmdSaveIdentity identity ]
247247
248248 | SetServerUrl url -> { model with ServerUrl = url }, []
@@ -431,7 +431,7 @@ module App =
431431 {| ``type`` = "profile"
432432 display_name = profile.DisplayName
433433 bio = profile.Bio
434− photo = Convert.ToBase64String profile.Photo |}
434+ photo = profile.Photo |}
435435 )
436436
437437 let private sendEnvelope (session: Session) (recipientHex: string) (envelope: Envelope) =
@@ -450,6 +450,11 @@ module App =
450450 | true, v when v.ValueKind <> JsonValueKind.Null -> Some(v.GetString())
451451 | _ -> None
452452
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+
453458 let private parseEnvelope (json: string) =
454459 use doc = JsonDocument.Parse(json)
455460 let root = doc.RootElement
@@ -466,13 +471,13 @@ module App =
466471 | "profile" ->
467472 let displayName = root.GetProperty("display_name").GetString()
468473 let bio = tryGetJsonString root "bio" |> Option.defaultValue ""
469− let photo = tryGetJsonString root "photo" |> Option.defaultValue ""
474+ let photo = tryGetJsonBytes root "photo"
470475
471476 Some(
472477 Envelope.ProfileMessage
473478 { DisplayName = displayName
474479 Bio = bio
475− Photo = if photo = "" then [||] else Convert.FromBase64String photo }
480+ Photo = photo }
476481 )
477482 | _ -> None
478483
@@ -650,15 +655,26 @@ module App =
650655 let! files = sp.OpenFilePickerAsync(opts)
651656
652657 match files |> Seq.tryHead with
653− | None -> dispatch (PhotoPicked [||])
658+ | None -> dispatch (PhotoPicked None)
654659 | Some file ->
655660 use! stream = file.OpenReadAsync()
656661 use ms = new MemoryStream()
657662 do! stream.CopyToAsync(ms)
658− dispatch (PhotoPicked(ms.ToArray()))
659− | None -> dispatch (PhotoPicked [||])
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())))
675+ | None -> dispatch (PhotoPicked None)
660676 with _ ->
661− dispatch (PhotoPicked [||])
677+ dispatch (PhotoPicked None)
662678 }
663679 :> System.Threading.Tasks.Task)
664680 |> ignore)
@@ -713,7 +729,7 @@ module App =
713729 let displayName, bio, photo =
714730 match model.Profile with
715731 | Some p -> p.DisplayName, p.Bio, p.Photo
716− | None -> "", "", [||]
732+ | None -> "", "", None
717733
718734 if displayName <> "" then
719735 TextBlock($"Name: {displayName}").fontSize (14.)
@@ -845,7 +861,7 @@ module App =
845861 })
846862 .margin (20.)
847863
848− let private viewEditProfile model displayName bio (photo: byte[]) =
864+ let private viewEditProfile model displayName bio (photo: byte[] option) =
849865 ScrollViewer(
850866 (VStack(16.) {
851867 HStack(8.) {
@@ -855,18 +871,7 @@ module App =
855871
856872 viewErrorBanner model.Error
857873
858− if photo.Length > 0 then
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 ()
874+ imageButton photo DoPickPhoto
870875
871876 TextBlock("Display Name:")
872877 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 =
4646 .fontWeight(FontWeight.Bold)
4747 .center()
4848 .margin (8.)
49+
50+ Rectangle().stroke(Colors.Black).strokeThickness (4.)
4951 })
5052 .background(Brushes.Transparent)
5153 .onPointerPressed(fun _ -> pressed.Set true)
@@ -55,7 +57,12 @@ module Buttons =
5557 }
5658
5759 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) {
5966 let! pressed = Context.State(false)
6067 let offset = if pressed.Current then 4. else 8.
6168
@@ -68,19 +75,39 @@ module Buttons =
6875 Avalonia.Visual.RenderTransformProperty,
6976 TimeSpan.FromMilliseconds animationDuration
7077 )
71− .easing (BackEaseOut())
78+ .easing (easing)
7279 )
7380
74− Rectangle().fill (SolidColorBrush(Constants.accentColor))
81+ Rectangle().fill (SolidColorBrush(Colors.White))
7582
7683 match img with
77− | None -> Image("avares://Skrepka/Assets/Images/user.svg", Stretch.Uniform)
78− | Some i -> Image(new Avalonia.Media.Imaging.Bitmap(new System.IO.MemoryStream(i)), Stretch.Uniform)
84+ | None ->
85+ ViewBox(
86+ (VStack(32) {
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 ->
101+ Image(new Avalonia.Media.Imaging.Bitmap(new System.IO.MemoryStream(i)), Stretch.UniformToFill)
102+ .clipToBounds (true)
79103
104+ Rectangle().stroke(Colors.Black).strokeThickness (4.)
80105 })
81106 .background(Brushes.Transparent)
82107 .onPointerPressed(fun _ -> pressed.Set true)
83108 .onPointerReleased(fun _ -> pressed.Set false)
84109 .onPointerExited(fun _ -> pressed.Set false)
110+ .width(200.)
111+ .height(200.)
85112 .onTapped (fun _ -> msg)
86113 }
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 @@
4343
4444 <ItemGroup Condition="$(AvaloniaPlatform) == 'Desktop'">
4545 <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" />
4748 <PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
4849 </ItemGroup>
4950
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+
5062 <ItemGroup Condition="$(AvaloniaPlatform) == 'iOS'">
5163 <None Include="$(iOSPlatformFolder)Entitlements.plist" />
5264 <None Include="$(iOSPlatformFolder)Info.plist" LogicalName="Info.plist" />
@@ -73,8 +85,9 @@
7385 <PackageReference Include="FSharp.Core" Version="9.0.100" />
7486 <PackageReference Include="Fabulous" Version="3.0.0-pre22" />
7587 <PackageReference Include="Fabulous.Avalonia" Version="3.0.0-pre13" />
76− <PackageReference Include="LiteDB" Version="5.0.21" />
7788 <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" />
7891 </ItemGroup>
7992
8093</Project>
client/Store.fs+139−76
@@ -2,13 +2,14 @@ namespace Skrepka
22
33open System
44open System.IO
5−open LiteDB
5+open Dapper
6+open Microsoft.Data.Sqlite
7+open Dapper.FSharp.SQLite
68
79module Store =
810
9− [<CLIMutable>]
1011 type Contact =
11− { [<BsonId>] Pubkey: string
12+ { Pubkey: string
1213 Nickname: string
1314 DisplayName: string
1415 Bio: string
@@ -17,12 +18,16 @@ module Store =
1718 type Profile =
1819 { DisplayName: string
1920 Bio: string
20− Photo: byte[] }
21+ Photo: byte[] option }
2122
2223 [<RequireQualifiedAccess>]
23− type DeliveryStatus = Sent | Delivered
24+ type DeliveryStatus =
25+ | Sent
26+ | Delivered
2427
25− type MessageDirection = Incoming | Outgoing of DeliveryStatus
28+ type MessageDirection =
29+ | Incoming
30+ | Outgoing of DeliveryStatus
2631
2732 type ChatMessage =
2833 { Id: string
@@ -36,9 +41,25 @@ module Store =
3641 ServerUrl: string
3742 PollCursor: int64 }
3843
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+
3960 [<CLIMutable>]
40− type private MessageDoc =
41− { [<BsonId>] Id: string
61+ type MessageRow =
62+ { Id: string
4263 ConversationId: string
4364 Body: string
4465 TimestampUnix: int64
@@ -46,50 +67,55 @@ module Store =
4667 Status: int }
4768
4869 [<CLIMutable>]
49− type private SettingsDoc =
50− { [<BsonId>] Id: string
70+ type SettingsRow =
71+ { Id: string
5172 ServerUrl: string
5273 PollCursor: int64 }
5374
54− [<CLIMutable>]
55− type private ProfileDoc =
56− { [<BsonId>] Id: string
57− DisplayName: string
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+ let private profileTable = table'<ProfileRow> "profile"
76+ let private contactTable = table'<ContactRow> "contacts"
77+ let private messageTable = table'<MessageRow> "messages"
78+ let private settingsTable = table'<SettingsRow> "settings"
7579
7680 let private appDataDir =
7781 let baseDir =
7882 let appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)
83+
7984 if String.IsNullOrEmpty(appData) then
80− // iOS: use the app's Documents directory
8185 let personal = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
86+
8287 if String.IsNullOrEmpty(personal) then
8388 Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Skrepka")
8489 else
8590 Path.Combine(personal, "Skrepka")
8691 else
8792 Path.Combine(appData, "Skrepka")
93+
8894 Directory.CreateDirectory(baseDir) |> ignore
8995 baseDir
9096
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
93119
94120 let private identityPath = Path.Combine(appDataDir, "identity.key")
95121
@@ -98,8 +124,7 @@ module Store =
98124 return
99125 try
100126 if File.Exists(identityPath) then
101− let b64 = File.ReadAllText(identityPath)
102− b64
127+ File.ReadAllText(identityPath)
103128 |> Option.ofObj
104129 |> Option.filter (fun s -> s <> "")
105130 |> Option.map (Convert.FromBase64String >> Crypto.identityFromPrivKey)
@@ -117,77 +142,115 @@ module Store =
117142
118143 let loadProfile () : Profile option =
119144 try
120− use db = openDb ()
121− db.GetCollection<ProfileDoc>("profile").FindById(BsonValue "me")
122− |> Option.ofObj
123− |> Option.map (fun p ->
124− { DisplayName = orEmpty p.DisplayName
125− Bio = orEmpty p.Bio
126− Photo = if p.Photo = null then [||] else p.Photo })
127− with _ -> None
145+ use conn = openConn ()
146+
147+ select { for p in profileTable do where (p.Id = "me") }
148+ |> conn.SelectAsync<ProfileRow>
149+ |> Async.AwaitTask
150+ |> Async.RunSynchronously
151+ |> Seq.tryHead
152+ |> Option.map (fun p -> { DisplayName = p.DisplayName; Bio = p.Bio; Photo = p.Photo })
153+ with _ ->
154+ None
128155
129156 let saveProfile (profile: Profile) =
130157 try
131− use db = openDb ()
132− db.GetCollection<ProfileDoc>("profile").Upsert
133− { Id = "me"
134− DisplayName = profile.DisplayName
135− Bio = profile.Bio
136− Photo = profile.Photo }
158+ use conn = openConn ()
159+
160+ conn.Execute(
161+ "INSERT OR REPLACE INTO profile (Id, DisplayName, Bio, Photo) VALUES (@Id, @DisplayName, @Bio, @Photo)",
162+ {| Id = "me"; DisplayName = profile.DisplayName; Bio = profile.Bio
163+ Photo = profile.Photo |> Option.defaultValue null |})
137164 |> ignore
138− with _ -> ()
165+ with _ ->
166+ ()
139167
140168 let loadData () : Data option =
141169 try
142− use db = openDb ()
143− let settings = db.GetCollection<SettingsDoc>("settings").FindById(BsonValue "settings")
170+ use conn = openConn ()
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
144178
145179 settings
146− |> Option.ofObj
147− |> Option.map (fun settings ->
180+ |> Option.map (fun s ->
148181 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
150186 |> Seq.map (fun c ->
151− c.Pubkey,
152− { c with
153− DisplayName = orEmpty c.DisplayName
154− Bio = orEmpty c.Bio
155− Photo = if c.Photo = null then [||] else c.Photo })
187+ let contact: Contact =
188+ { Pubkey = c.Pubkey
189+ Nickname = c.Nickname
190+ DisplayName = c.DisplayName
191+ Bio = c.Bio
192+ Photo = c.Photo |> Option.defaultValue [||] }
193+
194+ c.Pubkey, contact)
156195 |> Map.ofSeq
157196
158197 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
160202 |> Seq.groupBy _.ConversationId
161− |> Seq.map (fun (convId, docs) ->
162− convId, docs |> Seq.map toChatMessage |> Seq.sortBy _.Timestamp |> Seq.toList)
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) })
213+ |> Seq.sortBy _.Timestamp
214+ |> Seq.toList)
163215 |> Map.ofSeq
164216
165217 { Contacts = contacts
166218 Messages = messages
167− ServerUrl = settings.ServerUrl |> orEmpty
168− PollCursor = settings.PollCursor })
219+ ServerUrl = s.ServerUrl
220+ PollCursor = s.PollCursor })
169221 with _ ->
170222 None
171223
172224 let saveData (data: Data) =
173225 try
174− use db = openDb ()
175− let contacts = db.GetCollection<Contact>("contacts")
176− let messages = db.GetCollection<MessageDoc>("messages")
177− let settings = db.GetCollection<SettingsDoc>("settings")
226+ use conn = openConn ()
178227
179228 for c in data.Contacts.Values do
180− contacts.Upsert c |> ignore
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 |})
233+ |> ignore
181234
182235 data.Messages
183236 |> Map.iter (fun convId msgs ->
184237 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)
186250
187− settings.Upsert
188− { Id = "settings"
189− ServerUrl = data.ServerUrl
190− PollCursor = data.PollCursor }
251+ conn.Execute(
252+ "INSERT OR REPLACE INTO settings (Id, ServerUrl, PollCursor) VALUES (@Id, @ServerUrl, @PollCursor)",
253+ {| Id = "settings"; ServerUrl = data.ServerUrl; PollCursor = data.PollCursor |})
191254 |> ignore
192255 with _ ->
193256 ()