on master help sign in
Improve client type safety and simplify state management: embed page state into Page DU, add delivery status tracking, inline identity generation, make ServerUrl non-optional
57cf03biko
PROTOCOL.md+5−10
@@ -375,14 +375,9 @@ The client makes a blocking GET request. The server holds the connection open un
375 "type": "message",
375 "type": "message",
376 "data": { "to": "...", "from": "...", "encrypted_blob": "...", "timestamp": 1679000000 },
376 "data": { "to": "...", "from": "...", "encrypted_blob": "...", "timestamp": 1679000000 },
377 "id": "evt_001"
377 "id": "evt_001"
378− },
379− {
380− "type": "ack",
381− "data": { "message_ids": ["msg_abc123"] },
382− "id": "evt_002"
383 }
378 }
384 ],
379 ],
385− "cursor": "evt_002"
380+ "cursor": "evt_001"
386}
381}
387```
382```
388
383
@@ -396,7 +391,6 @@ The client makes a blocking GET request. The server holds the connection open un
396| Type | Data |
391| Type | Data |
397|------------|-------------------------------------|
392|------------|-------------------------------------|
398| `message` | Envelope JSON (see §4) |
393| `message` | Envelope JSON (see §4) |
399−| `ack` | `{ "message_ids": ["id1", "id2"] }`|
400
394
401#### `POST /messages` — Send a message
395#### `POST /messages` — Send a message
402
396
@@ -438,7 +432,7 @@ The server fills in `from` from the authenticated session.
438{ "ok": true }
432{ "ok": true }
439```
433```
440
434
441−The server deletes acknowledged messages from its queue and forwards the ACK to the sender's server.
435+The server deletes acknowledged messages from its queue. If the message arrived via federation, the ACK is forwarded to the origin server so it can also clean up. This is a server-side queue management operation — delivery notification to the sender is handled by encrypted `delivery.ack` messages (see §4).
442
436
443#### `GET /lookup/:pubkey` — Look up a user's presence
437#### `GET /lookup/:pubkey` — Look up a user's presence
444
438
@@ -629,10 +623,11 @@ All messages have a **30-day TTL**, enforced in two places:
6295. Server C gossips: ONLINE { bob_pubkey, server_c }
6235. Server C gossips: ONLINE { bob_pubkey, server_c }
6306. Alice's server sees the gossip, forwards the queued message to Server C.
6246. Alice's server sees the gossip, forwards the queued message to Server C.
6317. Server C delivers to Bob on his next poll.
6257. Server C delivers to Bob on his next poll.
632−8. Bob's client sends ACK to Server C.
626+8. Bob's client sends ACK to Server C (queue cleanup).
6339. Server C forwards ACK to Alice's server.
6279. Server C forwards ACK to Alice's server.
63410. Alice's server deletes the blob.
62810. Alice's server deletes the blob.
635−11. Next time Alice connects, she receives delivery confirmation.
629+11. Bob's client sends an encrypted `delivery.ack` message to Alice.
630+12. When Alice receives it, she marks the message as delivered.
636```
631```
637
632
638Alice does not need to stay online. Her server handles delivery independently.
633Alice does not need to stay online. Her server handles delivery independently.
client/ApiClient.fs+1−3
@@ -13,19 +13,17 @@ module ApiClient =
13 type EventPayload = { EncryptedBlob: string; Timestamp: int64 }
13 type EventPayload = { EncryptedBlob: string; Timestamp: int64 }
14
14
15 [<JsonConverter(typeof<EventTypeConverter>)>]
15 [<JsonConverter(typeof<EventTypeConverter>)>]
16− type EventType = Message | Ack | UnknownEvent of string
16+ type EventType = Message | UnknownEvent of string
17 and private EventTypeConverter() =
17 and private EventTypeConverter() =
18 inherit JsonConverter<EventType>()
18 inherit JsonConverter<EventType>()
19 override _.Read(reader, _, _) =
19 override _.Read(reader, _, _) =
20 match reader.GetString() with
20 match reader.GetString() with
21 | "message" -> Message
21 | "message" -> Message
22− | "ack" -> Ack
23 | s -> UnknownEvent s
22 | s -> UnknownEvent s
24 override _.Write(writer, value, _) =
23 override _.Write(writer, value, _) =
25 writer.WriteStringValue(
24 writer.WriteStringValue(
26 match value with
25 match value with
27 | Message -> "message"
26 | Message -> "message"
28− | Ack -> "ack"
29 | UnknownEvent s -> s)
27 | UnknownEvent s -> s)
30
28
31 [<CLIMutable>]
29 [<CLIMutable>]
client/App.fs+112−96
@@ -17,31 +17,11 @@ module App =
17
17
18 // ── Domain Types ──
18 // ── Domain Types ──
19
19
20− [<JsonConverter(typeof<EnvelopeTypeConverter>)>]
21− type private EnvelopeType = Text | UnknownEnvelope of string
22− and private EnvelopeTypeConverter() =
23− inherit JsonConverter<EnvelopeType>()
24− override _.Read(reader, _, _) =
25− match reader.GetString() with
26− | "text" -> Text
27− | s -> UnknownEnvelope s
28− override _.Write(writer, value, _) =
29− writer.WriteStringValue(
30− match value with
31− | Text -> "text"
32− | UnknownEnvelope s -> s)
33−
34− [<CLIMutable>]
35− type private TextEnvelope =
36− { [<JsonPropertyName("type")>] Type: EnvelopeType
37− [<JsonPropertyName("id")>] Id: string
38− [<JsonPropertyName("body")>] Body: string }
39−
40 type Page =
20 type Page =
41 | Setup
21 | Setup
42 | Conversations
22 | Conversations
43− | Chat of pubkey: string
23+ | Chat of pubkey: string * compose: string
44− | AddContact
24+ | AddContact of pubkey: string * nickname: string
45 | Settings
25 | Settings
46
26
47 type Session = { Url: string; Token: string; Identity: Identity }
27 type Session = { Url: string; Token: string; Identity: Identity }
@@ -57,6 +37,10 @@ module App =
57
37
58 type IncomingMessage = { SenderPubkey: string; Message: ChatMessage }
38 type IncomingMessage = { SenderPubkey: string; Message: ChatMessage }
59
39
40+ type DecryptedEvent =
41+ | IncomingChat of IncomingMessage
42+ | IncomingDeliveryAck of senderPubkey: string * ackIds: string list
43+
60 type PollReceived = { Events: int; Messages: int; Errors: int; FirstError: string option }
44 type PollReceived = { Events: int; Messages: int; Errors: int; FirstError: string option }
61
45
62 type PollStatus =
46 type PollStatus =
@@ -75,10 +59,7 @@ module App =
75 PollStatus: PollStatus
59 PollStatus: PollStatus
76 Messages: Map<string, ChatMessage list>
60 Messages: Map<string, ChatMessage list>
77 Error: string option
61 Error: string option
78− PollCursor: int64
62+ PollCursor: int64 }
79− Compose: string
80− ContactPubkey: string
81− ContactNickname: string }
82
63
83 // ── Msg ──
64 // ── Msg ──
84
65
@@ -93,7 +74,6 @@ module App =
93 | SetContactPubkey of string
74 | SetContactPubkey of string
94 | SetContactNickname of string
75 | SetContactNickname of string
95 | GenIdentity
76 | GenIdentity
96− | IdentityReady of Identity
97 | SetServerUrl of string
77 | SetServerUrl of string
98 | DoConnect
78 | DoConnect
99 | AuthOk of string
79 | AuthOk of string
@@ -102,7 +82,7 @@ module App =
102 | DoSend
82 | DoSend
103 | SendFailed of string
83 | SendFailed of string
104 | DoSaveContact
84 | DoSaveContact
105− | PollResult of messages: IncomingMessage list * status: PollStatus * cursor: int64
85+ | PollResult of events: DecryptedEvent list * status: PollStatus * cursor: int64
106 | CopyPubKey
86 | CopyPubKey
107 | DismissError
87 | DismissError
108 | StateLoaded of StateLoadResult
88 | StateLoaded of StateLoadResult
@@ -110,7 +90,6 @@ module App =
110 // ── CmdMsg ──
90 // ── CmdMsg ──
111
91
112 type CmdMsg =
92 type CmdMsg =
113− | CmdGenIdentity
114 | CmdConnect of url: string * identity: Identity
93 | CmdConnect of url: string * identity: Identity
115 | CmdSend of session: Session * recipientHex: string * text: string * messageId: string
94 | CmdSend of session: Session * recipientHex: string * text: string * messageId: string
116 | CmdPoll of session: Session * cursor: int64
95 | CmdPoll of session: Session * cursor: int64
@@ -154,6 +133,14 @@ module App =
154 if existing |> List.exists (fun m -> m.Id = msg.Id) then messages
133 if existing |> List.exists (fun m -> m.Id = msg.Id) then messages
155 else Map.add pk (existing @ [ msg ]) messages
134 else Map.add pk (existing @ [ msg ]) messages
156
135
136+ let private markDelivered (pk: string) (ackIds: string list) (messages: Map<string, ChatMessage list>) =
137+ match Map.tryFind pk messages with
138+ | Some msgs ->
139+ msgs
140+ |> List.map (fun m -> if m.IsOutgoing && List.contains m.Id ackIds then { m with Status = DeliveryStatus.Delivered } else m)
141+ |> fun updated -> Map.add pk updated messages
142+ | None -> messages
143+
157 let private addContactIfNew (contact: Contact) (contacts: Contact list) =
144 let private addContactIfNew (contact: Contact) (contacts: Contact list) =
158 if contacts |> List.exists (fun c -> c.Pubkey = contact.Pubkey) then contacts
145 if contacts |> List.exists (fun c -> c.Pubkey = contact.Pubkey) then contacts
159 else contacts @ [ contact ]
146 else contacts @ [ contact ]
@@ -174,7 +161,7 @@ module App =
174 | NoIdentity -> model
161 | NoIdentity -> model
175
162
176 let private saveCmdMsg model =
163 let private saveCmdMsg model =
177− CmdSaveData { Contacts = model.Contacts; Messages = model.Messages; ServerUrl = Some model.ServerUrl; PollCursor = model.PollCursor }
164+ CmdSaveData { Contacts = model.Contacts; Messages = model.Messages; ServerUrl = model.ServerUrl; PollCursor = model.PollCursor }
178
165
179 let private connStatusLabel = function
166 let private connStatusLabel = function
180 | NoIdentity | Identified(_, Offline) -> "OFFLINE"
167 | NoIdentity | Identified(_, Offline) -> "OFFLINE"
@@ -191,29 +178,30 @@ module App =
191 Contacts = []
178 Contacts = []
192 Messages = Map.empty
179 Messages = Map.empty
193 Error = None
180 Error = None
194− PollCursor = 0L
181+ PollCursor = 0L },
195− Compose = ""
196− ContactPubkey = ""
197− ContactNickname = "" },
198 [ CmdLoadState ]
182 [ CmdLoadState ]
199
183
200 // ── Update ──
184 // ── Update ──
201
185
202 let update msg model =
186 let update msg model =
203 match msg with
187 match msg with
204− | SetPage(Chat pk) ->
205− { model with Page = Chat pk; Compose = "" }, []
206− | SetPage AddContact ->
207− { model with Page = AddContact; ContactPubkey = ""; ContactNickname = "" }, []
208 | SetPage page -> { model with Page = page }, []
188 | SetPage page -> { model with Page = page }, []
209
189
210− | SetCompose text -> { model with Compose = text }, []
190+ | SetCompose text ->
191+ match model.Page with
192+ | Chat(pk, _) -> { model with Page = Chat(pk, text) }, []
193+ | _ -> model, []
211− | SetContactPubkey pk -> { model with ContactPubkey = pk }, []
194+ | SetContactPubkey pk ->
195+ match model.Page with
196+ | AddContact(_, nn) -> { model with Page = AddContact(pk, nn) }, []
197+ | _ -> model, []
212− | SetContactNickname nn -> { model with ContactNickname = nn }, []
198+ | SetContactNickname nn ->
213−
199+ match model.Page with
214− | GenIdentity -> model, [ CmdGenIdentity ]
200+ | AddContact(pk, _) -> { model with Page = AddContact(pk, nn) }, []
201+ | _ -> model, []
215
202
216− | IdentityReady identity ->
203+ | GenIdentity ->
204+ let identity = Crypto.generateIdentity ()
217 { model with Auth = Identified(identity, Offline); Page = Settings },
205 { model with Auth = Identified(identity, Offline); Page = Settings },
218 [ CmdSaveIdentity identity ]
206 [ CmdSaveIdentity identity ]
219
207
@@ -239,45 +227,51 @@ module App =
239
227
240 | DoSend ->
228 | DoSend ->
241 match model.Page, trySession model with
229 match model.Page, trySession model with
242− | Chat pk, Some session when model.Compose <> "" ->
230+ | Chat(pk, compose), Some session when compose <> "" ->
243 let id = Guid.NewGuid().ToString()
231 let id = Guid.NewGuid().ToString()
244 let outMsg =
232 let outMsg =
245 { Id = id
233 { Id = id
246− Body = model.Compose
234+ Body = compose
247 Timestamp = DateTimeOffset.UtcNow
235 Timestamp = DateTimeOffset.UtcNow
248− IsOutgoing = true }
236+ IsOutgoing = true
237+ Status = DeliveryStatus.Sent }
249
238
250 let model' =
239 let model' =
251 { model with
240 { model with
252− Compose = ""
241+ Page = Chat(pk, "")
253 Messages = model.Messages |> appendMessage pk outMsg }
242 Messages = model.Messages |> appendMessage pk outMsg }
254
243
255− model', [ CmdSend(session, pk, model.Compose, id); saveCmdMsg model' ]
244+ model', [ CmdSend(session, pk, compose, id); saveCmdMsg model' ]
256 | _ -> model, []
245 | _ -> model, []
257
246
258 | SendFailed err -> { model with Error = Some err }, []
247 | SendFailed err -> { model with Error = Some err }, []
259
248
260− | PollResult(incoming, status, newCursor) ->
249+ | PollResult(events, status, newCursor) ->
261 let model' =
250 let model' =
262− incoming
251+ events
263 |> List.fold
252 |> List.fold
264− (fun (m: Model) msg ->
253+ (fun (m: Model) evt ->
254+ match evt with
255+ | IncomingChat msg ->
265− { m with
256+ { m with
266− Contacts = m.Contacts |> addContactIfNew { Pubkey = msg.SenderPubkey; Nickname = truncKey msg.SenderPubkey }
257+ Contacts = m.Contacts |> addContactIfNew { Pubkey = msg.SenderPubkey; Nickname = truncKey msg.SenderPubkey }
267− Messages = m.Messages |> appendMessage msg.SenderPubkey msg.Message })
258+ Messages = m.Messages |> appendMessage msg.SenderPubkey msg.Message }
259+ | IncomingDeliveryAck(senderPubkey, ackIds) ->
260+ { m with Messages = m.Messages |> markDelivered senderPubkey ackIds })
268 { model with PollStatus = status; PollCursor = newCursor }
261 { model with PollStatus = status; PollCursor = newCursor }
269
262
270 model',
263 model',
271 [ match trySession model' with
264 [ match trySession model' with
272 | Some session -> CmdPoll(session, model'.PollCursor)
265 | Some session -> CmdPoll(session, model'.PollCursor)
273 | None -> ()
266 | None -> ()
274− if not incoming.IsEmpty then saveCmdMsg model' ]
267+ if not events.IsEmpty then saveCmdMsg model' ]
275
268
276 | DoSaveContact ->
269 | DoSaveContact ->
277− if model.ContactPubkey <> "" && model.ContactNickname <> "" then
270+ match model.Page with
271+ | AddContact(cpk, cnn) when cpk <> "" && cnn <> "" ->
278− match tryParsePubkey model.ContactPubkey with
272+ match tryParsePubkey cpk with
279 | Some bytes ->
273 | Some bytes ->
280− let contact: Contact = { Pubkey = Crypto.toHex bytes; Nickname = model.ContactNickname }
274+ let contact: Contact = { Pubkey = Crypto.toHex bytes; Nickname = cnn }
281
275
282 let model' =
276 let model' =
283 { model with
277 { model with
@@ -286,8 +280,7 @@ module App =
286
280
287 model', [ saveCmdMsg model' ]
281 model', [ saveCmdMsg model' ]
288 | None -> { model with Error = Some "Invalid public key" }, []
282 | None -> { model with Error = Some "Invalid public key" }, []
289− else
290− model, []
283+ | _ -> model, []
291
284
292 | CopyPubKey ->
285 | CopyPubKey ->
293 match model.Auth with
286 match model.Auth with
@@ -302,7 +295,7 @@ module App =
302 Auth = Identified(identity, Connecting)
295 Auth = Identified(identity, Connecting)
303 Contacts = data.Contacts
296 Contacts = data.Contacts
304 Messages = data.Messages
297 Messages = data.Messages
305− ServerUrl = data.ServerUrl |> Option.defaultValue model.ServerUrl
298+ ServerUrl = if data.ServerUrl <> "" then data.ServerUrl else model.ServerUrl
306 PollCursor = data.PollCursor
299 PollCursor = data.PollCursor
307 Page = Conversations }
300 Page = Conversations }
308 model', [ CmdConnect(model'.ServerUrl, identity) ]
301 model', [ CmdConnect(model'.ServerUrl, identity) ]
@@ -326,26 +319,32 @@ module App =
326
319
327 match Crypto.decrypt privKey blob with
320 match Crypto.decrypt privKey blob with
328 | Some(plaintext, senderHex) ->
321 | Some(plaintext, senderHex) ->
329− let envelope = JsonSerializer.Deserialize<TextEnvelope>(plaintext)
322+ use doc = JsonDocument.Parse(plaintext)
330−
323+ let root = doc.RootElement
331− match envelope.Type with
324+
332− | Text ->
325+ match root.GetProperty("type").GetString() with
326+ | "text" ->
327+ Ok(IncomingChat
333− Ok { SenderPubkey = senderHex
328+ { SenderPubkey = senderHex
334− Message =
329+ Message =
335− { Id = envelope.Id
330+ { Id = root.GetProperty("id").GetString()
336− Body = envelope.Body
331+ Body = root.GetProperty("body").GetString()
337− Timestamp = DateTimeOffset.FromUnixTimeSeconds(payload.Timestamp)
332+ Timestamp = DateTimeOffset.FromUnixTimeSeconds(payload.Timestamp)
338− IsOutgoing = false } }
333+ IsOutgoing = false
334+ Status = DeliveryStatus.Delivered } })
335+ | "delivery.ack" ->
336+ let ackIds =
337+ root.GetProperty("ack_ids").EnumerateArray()
338+ |> Seq.map _.GetString()
339+ |> Seq.toList
340+ Ok(IncomingDeliveryAck(senderHex, ackIds))
339− | UnknownEnvelope t -> Error $"unknown envelope type: {t}"
341+ | t -> Error $"unknown envelope type: {t}"
340 | None -> Error "decrypt failed"
342 | None -> Error "decrypt failed"
341 with ex ->
343 with ex ->
342 Error $"parse: {ex.Message}"
344 Error $"parse: {ex.Message}"
343
345
344 let mapCmd cmdMsg =
346 let mapCmd cmdMsg =
345 match cmdMsg with
347 match cmdMsg with
346− | CmdGenIdentity ->
347− Cmd.ofEffect (fun dispatch -> dispatch (IdentityReady(Crypto.generateIdentity ())))
348−
349 | CmdConnect(url, identity) ->
348 | CmdConnect(url, identity) ->
350 asyncCmd (async {
349 asyncCmd (async {
351 try
350 try
@@ -361,7 +360,7 @@ module App =
361 try
360 try
362 let recipPub = Crypto.fromHex recipientHex
361 let recipPub = Crypto.fromHex recipientHex
363 let ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
362 let ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
364− let payload = JsonSerializer.Serialize<TextEnvelope>({ Type = Text; Id = messageId; Body = text })
363+ let payload = JsonSerializer.Serialize({| ``type`` = "text"; id = messageId; body = text |})
365 let blob = Crypto.encrypt session.Identity.PrivKey recipPub payload
364 let blob = Crypto.encrypt session.Identity.PrivKey recipPub payload
366 let! status = sendMessage session.Url session.Token recipientHex (Crypto.toHex blob) ts
365 let! status = sendMessage session.Url session.Token recipientHex (Crypto.toHex blob) ts
367
366
@@ -381,23 +380,39 @@ module App =
381 let! response = poll session.Url session.Token cursor
380 let! response = poll session.Url session.Token cursor
382 let ackIds = response.Events |> Array.map _.Id |> Array.toList
381 let ackIds = response.Events |> Array.map _.Id |> Array.toList
383
382
384− let messages, errors =
383+ let events, errors =
385 Array.foldBack
384 Array.foldBack
386− (fun (evt: PollEvent) (msgs, errs) ->
385+ (fun (evt: PollEvent) (evts, errs) ->
387 match evt.EventType with
386 match evt.EventType with
388 | Message ->
387 | Message ->
389 match decryptEvent session.Identity.PrivKey evt.Payload with
388 match decryptEvent session.Identity.PrivKey evt.Payload with
390− | Ok msg -> (msg :: msgs, errs)
389+ | Ok event -> (event :: evts, errs)
391− | Error err -> (msgs, err :: errs)
390+ | Error err -> (evts, err :: errs)
392− | Ack | UnknownEvent _ -> (msgs, errs))
391+ | UnknownEvent _ -> (evts, errs))
393 response.Events
392 response.Events
394 ([], [])
393 ([], [])
395
394
396 if not ackIds.IsEmpty then
395 if not ackIds.IsEmpty then
397− do! ackMessages session.Url session.Token ackIds
396+ try do! ackMessages session.Url session.Token ackIds
398−
397+ with _ -> ()
398+
399+ // Send delivery.ack for received chat messages (not for delivery.ack messages — no infinite loop)
400+ let chatsBySender =
401+ events
402+ |> List.choose (function IncomingChat msg -> Some(msg.SenderPubkey, msg.Message.Id) | _ -> None)
403+ |> List.groupBy fst
404+ |> List.map (fun (sender, pairs) -> sender, List.map snd pairs)
405+
406+ for sender, msgIds in chatsBySender do
407+ try
408+ let ackPayload = JsonSerializer.Serialize({| ``type`` = "delivery.ack"; id = Guid.NewGuid().ToString(); ack_ids = msgIds |})
409+ let blob = Crypto.encrypt session.Identity.PrivKey (Crypto.fromHex sender) ackPayload
410+ do! sendMessage session.Url session.Token sender (Crypto.toHex blob) (DateTimeOffset.UtcNow.ToUnixTimeSeconds()) |> Async.Ignore
411+ with _ -> ()
412+
413+ let chatCount = events |> List.sumBy (function IncomingChat _ -> 1 | _ -> 0)
399− let status = Received { Events = response.Events.Length; Messages = messages.Length; Errors = errors.Length; FirstError = List.tryHead errors }
414+ let status = Received { Events = response.Events.Length; Messages = chatCount; Errors = errors.Length; FirstError = List.tryHead errors }
400− return PollResult(messages, status, response.Cursor)
415+ return PollResult(events, status, response.Cursor)
401 with
416 with
402 | :? TimeoutException ->
417 | :? TimeoutException ->
403 return PollResult([], Polling, cursor)
418 return PollResult([], Polling, cursor)
@@ -510,7 +525,7 @@ module App =
510 Label("Conversations")
525 Label("Conversations")
511 .font(size = 24.)
526 .font(size = 24.)
512
527
513− Button("+", SetPage AddContact)
528+ Button("+", SetPage(AddContact("", "")))
514 Button("Settings", SetPage Settings)
529 Button("Settings", SetPage Settings)
515 }
530 }
516
531
@@ -532,13 +547,13 @@ module App =
532 |> Option.map (fun m -> if m.Body.Length > 40 then m.Body.[..39] + "..." else m.Body)
547 |> Option.map (fun m -> if m.Body.Length > 40 then m.Body.[..39] + "..." else m.Body)
533 |> Option.defaultValue ""
548 |> Option.defaultValue ""
534
549
535− Button($"{c.Nickname}\n{preview}", SetPage(Chat c.Pubkey))
550+ Button($"{c.Nickname}\n{preview}", SetPage(Chat(c.Pubkey, "")))
536 })
551 })
537 .padding(20.)
552 .padding(20.)
538 )
553 )
539 )
554 )
540
555
541− let private viewChat model pk =
556+ let private viewChat model pk compose =
542 let name = contactName model.Contacts pk
557 let name = contactName model.Contacts pk
543 let msgs = messagesFor pk model.Messages
558 let msgs = messagesFor pk model.Messages
544
559
@@ -560,12 +575,13 @@ module App =
560 .font(size = 14.)
575 .font(size = 14.)
561 else
576 else
562 for m in msgs do
577 for m in msgs do
578+ let tick = if m.IsOutgoing && m.Status = DeliveryStatus.Delivered then " \u2713" else ""
563− Label(if m.IsOutgoing then $"You: {m.Body}" else m.Body)
579+ Label((if m.IsOutgoing then $"You: {m.Body}" else m.Body) + tick)
564 .padding(8.)
580 .padding(8.)
565 .font(size = 14.)
581 .font(size = 14.)
566
582
567 HStack(spacing = 8.) {
583 HStack(spacing = 8.) {
568− Entry(model.Compose, SetCompose)
584+ Entry(compose, SetCompose)
569 .placeholder("Message...")
585 .placeholder("Message...")
570
586
571 Button("Send", DoSend)
587 Button("Send", DoSend)
@@ -574,7 +590,7 @@ module App =
574 .padding(12.)
590 .padding(12.)
575 )
591 )
576
592
577− let private viewAddContact model =
593+ let private viewAddContact model cpk cnn =
578 ContentPage(
594 ContentPage(
579 (VStack(spacing = 16.) {
595 (VStack(spacing = 16.) {
580 HStack(spacing = 8.) {
596 HStack(spacing = 8.) {
@@ -587,11 +603,11 @@ module App =
587 viewErrorBanner model.Error
603 viewErrorBanner model.Error
588
604
589 Label("Public Key:")
605 Label("Public Key:")
590− Entry(model.ContactPubkey, SetContactPubkey)
606+ Entry(cpk, SetContactPubkey)
591 .placeholder("sampel-palnet-...")
607 .placeholder("sampel-palnet-...")
592
608
593 Label("Nickname:")
609 Label("Nickname:")
594− Entry(model.ContactNickname, SetContactNickname)
610+ Entry(cnn, SetContactNickname)
595
611
596 Button("Save Contact", DoSaveContact)
612 Button("Save Contact", DoSaveContact)
597 .centerHorizontal()
613 .centerHorizontal()
@@ -605,8 +621,8 @@ module App =
605 | Setup -> viewSetup ()
621 | Setup -> viewSetup ()
606 | Settings -> viewSettings model
622 | Settings -> viewSettings model
607 | Conversations -> viewConversations model
623 | Conversations -> viewConversations model
608− | Chat pk -> viewChat model pk
624+ | Chat(pk, compose) -> viewChat model pk compose
609− | AddContact -> viewAddContact model
625+ | AddContact(pk, nn) -> viewAddContact model pk nn
610
626
611 Application() { Window(page) }
627 Application() { Window(page) }
612
628
client/Store.fs+14−7
@@ -12,16 +12,20 @@ module Store =
12 { [<BsonId>] Pubkey: string
12 { [<BsonId>] Pubkey: string
13 Nickname: string }
13 Nickname: string }
14
14
15+ [<RequireQualifiedAccess>]
16+ type DeliveryStatus = Sent | Delivered
17+
15 type ChatMessage =
18 type ChatMessage =
16 { Id: string
19 { Id: string
17 Body: string
20 Body: string
18 Timestamp: DateTimeOffset
21 Timestamp: DateTimeOffset
19− IsOutgoing: bool }
22+ IsOutgoing: bool
23+ Status: DeliveryStatus }
20
24
21 type Data =
25 type Data =
22 { Contacts: Contact list
26 { Contacts: Contact list
23 Messages: Map<string, ChatMessage list>
27 Messages: Map<string, ChatMessage list>
24− ServerUrl: string option
28+ ServerUrl: string
25 PollCursor: int64 }
29 PollCursor: int64 }
26
30
27 [<CLIMutable>]
31 [<CLIMutable>]
@@ -30,7 +34,8 @@ module Store =
30 ConversationId: string
34 ConversationId: string
31 Body: string
35 Body: string
32 TimestampUnix: int64
36 TimestampUnix: int64
33− IsOutgoing: bool }
37+ IsOutgoing: bool
38+ Status: int }
34
39
35 [<CLIMutable>]
40 [<CLIMutable>]
36 type private SettingsDoc =
41 type private SettingsDoc =
@@ -39,10 +44,12 @@ module Store =
39 PollCursor: int64 }
44 PollCursor: int64 }
40
45
41 let private toChatMessage (d: MessageDoc) : ChatMessage =
46 let private toChatMessage (d: MessageDoc) : ChatMessage =
42− { Id = d.Id; Body = d.Body; Timestamp = DateTimeOffset.FromUnixTimeSeconds d.TimestampUnix; IsOutgoing = d.IsOutgoing }
47+ { Id = d.Id; Body = d.Body; Timestamp = DateTimeOffset.FromUnixTimeSeconds d.TimestampUnix; IsOutgoing = d.IsOutgoing
48+ Status = if d.Status = 1 then DeliveryStatus.Delivered else DeliveryStatus.Sent }
43
49
44 let private toMessageDoc convId (m: ChatMessage) : MessageDoc =
50 let private toMessageDoc convId (m: ChatMessage) : MessageDoc =
45− { Id = m.Id; ConversationId = convId; Body = m.Body; TimestampUnix = m.Timestamp.ToUnixTimeSeconds(); IsOutgoing = m.IsOutgoing }
51+ { Id = m.Id; ConversationId = convId; Body = m.Body; TimestampUnix = m.Timestamp.ToUnixTimeSeconds(); IsOutgoing = m.IsOutgoing
52+ Status = match m.Status with DeliveryStatus.Delivered -> 1 | DeliveryStatus.Sent -> 0 }
46
53
47 let private openDb () =
54 let private openDb () =
48 new LiteDatabase(Path.Combine(FileSystem.AppDataDirectory, "skrepka.db"))
55 new LiteDatabase(Path.Combine(FileSystem.AppDataDirectory, "skrepka.db"))
@@ -82,7 +89,7 @@ module Store =
82
89
83 { Contacts = contacts
90 { Contacts = contacts
84 Messages = messages
91 Messages = messages
85− ServerUrl = settings.ServerUrl |> Option.ofObj |> Option.filter ((<>) "")
92+ ServerUrl = settings.ServerUrl |> Option.ofObj |> Option.defaultValue ""
86 PollCursor = settings.PollCursor })
93 PollCursor = settings.PollCursor })
87 with _ ->
94 with _ ->
88 None
95 None
@@ -104,7 +111,7 @@ module Store =
104
111
105 settings.Upsert
112 settings.Upsert
106 { Id = "settings"
113 { Id = "settings"
107− ServerUrl = data.ServerUrl |> Option.defaultValue ""
114+ ServerUrl = data.ServerUrl
108 PollCursor = data.PollCursor }
115 PollCursor = data.PollCursor }
109 |> ignore
116 |> ignore
110 with _ ->
117 with _ ->
client/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.dll

Binary file not shown.

client/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.pdb

Binary file not shown.

client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfo.fs+1−1
@@ -10,7 +10,7 @@ open System.Reflection
10[<assembly: System.Reflection.AssemblyCompanyAttribute("Skrepka")>]
10[<assembly: System.Reflection.AssemblyCompanyAttribute("Skrepka")>]
11[<assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")>]
11[<assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")>]
12[<assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")>]
12[<assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")>]
13−[<assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0+d854ebf639ac29bd51a1d44c5399687b837f58fc")>]
13+[<assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0+476d6fff9f9f011c32feb31bb758bb823b925727")>]
14[<assembly: System.Reflection.AssemblyProductAttribute("Skrepka")>]
14[<assembly: System.Reflection.AssemblyProductAttribute("Skrepka")>]
15[<assembly: System.Reflection.AssemblyTitleAttribute("Skrepka")>]
15[<assembly: System.Reflection.AssemblyTitleAttribute("Skrepka")>]
16[<assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")>]
16[<assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")>]
client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfoInputs.cache+1−1
@@ -1 +1 @@
1−b52bc94b6f6cb2bd6b5ac284a29c07d1c93f8dcad7b50bfe9a5014f20c640ed2
1+550dc360b827f626a376d9130941cd662e759b45c0cedce7f4830d75a313325f
client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.dll

Binary file not shown.

client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.pdb

Binary file not shown.

client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.sourcelink.json+1−1
@@ -1 +1 @@
1−{"documents":{"/Users/iko/Developer/skrepka/*":"https://raw.githubusercontent.com/ilyakooo0/skrepka/d854ebf639ac29bd51a1d44c5399687b837f58fc/*"}}
1+{"documents":{"/Users/iko/Developer/skrepka/*":"https://raw.githubusercontent.com/ilyakooo0/skrepka/476d6fff9f9f011c32feb31bb758bb823b925727/*"}}
client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/ref/Skrepka.dll

Binary file not shown.

client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/refint/Skrepka.dll

Binary file not shown.