@@ -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 ()