@@ -2,13 +2,14 @@ namespace Skrepka
4open System.IO
4open System.IO
5−open LiteDB
5+open Dapper
6+open Microsoft.Data.Sqlite
7+open Dapper.FSharp.SQLite
7module Store =
9module Store =
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 }
22 [<RequireQualifiedAccess>]
23 [<RequireQualifiedAccess>]
23− type DeliveryStatus = Sent | Delivered
24+ type DeliveryStatus =
28+ type MessageDirection =
25− type MessageDirection = Incoming | Outgoing of DeliveryStatus
30+ | Outgoing of DeliveryStatus
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 }
44+ // DB row types matching table columns
50+ Photo: byte[] option }
58+ Photo: byte[] option }
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 }
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 }
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"
61− let private orEmpty s = s |> Option.ofObj |> Option.defaultValue ""
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) }
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 }
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)
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)
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")
85 Path.Combine(personal, "Skrepka")
90 Path.Combine(personal, "Skrepka")
87 Path.Combine(appData, "Skrepka")
92 Path.Combine(appData, "Skrepka")
88 Directory.CreateDirectory(baseDir) |> ignore
94 Directory.CreateDirectory(baseDir) |> ignore
91− let private openDb () =
92− new LiteDatabase(Path.Combine(appDataDir, "skrepka.db"))
97+ let private dbPath = Path.Combine(appDataDir, "skrepka.db")
99+ let private openConn () =
100+ let conn = new SqliteConnection($"Data Source={dbPath}")
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[])
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
94 let private identityPath = Path.Combine(appDataDir, "identity.key")
120 let private identityPath = Path.Combine(appDataDir, "identity.key")
@@ -98,8 +124,7 @@ module Store =
100 if File.Exists(identityPath) then
126 if File.Exists(identityPath) then
101− let b64 = File.ReadAllText(identityPath)
127+ File.ReadAllText(identityPath)
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 =
118 let loadProfile () : Profile option =
143 let loadProfile () : Profile option =
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>
150+ |> Async.RunSynchronously
124− { DisplayName = orEmpty p.DisplayName
152+ |> Option.map (fun p -> { DisplayName = p.DisplayName; Bio = p.Bio; Photo = p.Photo })
126− Photo = if p.Photo = null then [||] else p.Photo })
127− with _ -> None
153+ with _ ->
129 let saveProfile (profile: Profile) =
156 let saveProfile (profile: Profile) =
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
136− Photo = profile.Photo }
163+ Photo = profile.Photo |> Option.defaultValue null |})
137 |> ignore
164 |> ignore
138− with _ -> ()
165+ with _ ->
140 let loadData () : Data option =
168 let loadData () : Data option =
142− use db = openDb ()
170+ use conn = openConn ()
143− let settings = db.GetCollection<SettingsDoc>("settings").FindById(BsonValue "settings")
171+
173+ select { for s in settingsTable do where (s.Id = "settings") }
174+ |> conn.SelectAsync<SettingsRow>
176+ |> Async.RunSynchronously
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>
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 [||] }
156 |> Map.ofSeq
195 |> Map.ofSeq
158 let messages =
197 let messages =
159− db.GetCollection<MessageDoc>("messages").FindAll()
198+ select { for m in messageTable do selectAll }
199+ |> conn.SelectAsync<MessageRow>
201+ |> Async.RunSynchronously
160 |> Seq.groupBy _.ConversationId
202 |> Seq.groupBy _.ConversationId
161− |> Seq.map (fun (convId, docs) ->
203+ |> Seq.map (fun (convId, rows) ->
206+ |> Seq.map (fun m ->
209+ Timestamp = DateTimeOffset.FromUnixTimeMilliseconds m.TimestampUnix
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
163 |> Map.ofSeq
215 |> Map.ofSeq
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 _ ->
172 let saveData (data: Data) =
224 let saveData (data: Data) =
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")
179 for c in data.Contacts.Values do
228 for c in data.Contacts.Values do
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
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
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 |})
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 _ ->