@@ -17,31 +17,11 @@ module App =
18 // ── Domain Types ──
18 // ── Domain Types ──
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
27− | s -> UnknownEnvelope s
28− override _.Write(writer, value, _) =
29− writer.WriteStringValue(
32− | UnknownEnvelope s -> s)
35− type private TextEnvelope =
36− { [<JsonPropertyName("type")>] Type: EnvelopeType
37− [<JsonPropertyName("id")>] Id: string
38− [<JsonPropertyName("body")>] Body: string }
40 type Page =
20 type Page =
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
47 type Session = { Url: string; Token: string; Identity: Identity }
27 type Session = { Url: string; Token: string; Identity: Identity }
@@ -57,6 +37,10 @@ module App =
58 type IncomingMessage = { SenderPubkey: string; Message: ChatMessage }
38 type IncomingMessage = { SenderPubkey: string; Message: ChatMessage }
40+ type DecryptedEvent =
41+ | IncomingChat of IncomingMessage
42+ | IncomingDeliveryAck of senderPubkey: string * ackIds: string list
60 type PollReceived = { Events: int; Messages: int; Errors: int; FirstError: string option }
44 type PollReceived = { Events: int; Messages: int; Errors: int; FirstError: string option }
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 }
80− ContactPubkey: string
81− ContactNickname: string }
83 // ── Msg ──
64 // ── Msg ──
@@ -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 =
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 ──
112 type CmdMsg =
92 type CmdMsg =
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
136+ let private markDelivered (pk: string) (ackIds: string list) (messages: Map<string, ChatMessage list>) =
137+ match Map.tryFind pk messages with
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
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
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 }
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 },
197− ContactNickname = "" },
198 [ CmdLoadState ]
182 [ CmdLoadState ]
200 // ── Update ──
184 // ── Update ──
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 }, []
210− | SetCompose text -> { model with Compose = text }, []
190+ | SetCompose text ->
191+ match model.Page with
192+ | Chat(pk, _) -> { model with Page = Chat(pk, text) }, []
211− | SetContactPubkey pk -> { model with ContactPubkey = pk }, []
194+ | SetContactPubkey pk ->
195+ match model.Page with
196+ | AddContact(_, nn) -> { model with Page = AddContact(pk, nn) }, []
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) }, []
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 ]
@@ -239,45 +227,51 @@ module App =
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 }
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 }
255− model', [ CmdSend(session, pk, model.Compose, id); saveCmdMsg model' ]
244+ model', [ CmdSend(session, pk, compose, id); saveCmdMsg model' ]
256 | _ -> model, []
245 | _ -> model, []
258 | SendFailed err -> { model with Error = Some err }, []
247 | SendFailed err -> { model with Error = Some err }, []
260− | PollResult(incoming, status, newCursor) ->
249+ | PollResult(events, status, newCursor) ->
261 let model' =
250 let model' =
263 |> List.fold
252 |> List.fold
264− (fun (m: Model) msg ->
253+ (fun (m: Model) evt ->
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 }
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' ]
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 }
282 let model' =
276 let model' =
283 { model with
277 { model with
@@ -286,8 +280,7 @@ module App =
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" }, []
290− model, []
283+ | _ -> model, []
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 =
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
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 } })
337+ root.GetProperty("ack_ids").EnumerateArray()
338+ |> Seq.map _.GetString()
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}"
344 let mapCmd cmdMsg =
346 let mapCmd cmdMsg =
345 match cmdMsg with
347 match cmdMsg with
347− Cmd.ofEffect (fun dispatch -> dispatch (IdentityReady(Crypto.generateIdentity ())))
349 | CmdConnect(url, identity) ->
348 | CmdConnect(url, identity) ->
350 asyncCmd (async {
349 asyncCmd (async {
@@ -361,7 +360,7 @@ module App =
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
@@ -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
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
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
399+ // Send delivery.ack for received chat messages (not for delivery.ack messages — no infinite loop)
402+ |> List.choose (function IncomingChat msg -> Some(msg.SenderPubkey, msg.Message.Id) | _ -> None)
404+ |> List.map (fun (sender, pairs) -> sender, List.map snd pairs)
406+ for sender, msgIds in chatsBySender do
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
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)
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.)
513− Button("+", SetPage AddContact)
528+ Button("+", SetPage(AddContact("", "")))
514 Button("Settings", SetPage Settings)
529 Button("Settings", SetPage Settings)
@@ -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 ""
535− Button($"{c.Nickname}\n{preview}", SetPage(Chat c.Pubkey))
550+ Button($"{c.Nickname}\n{preview}", SetPage(Chat(c.Pubkey, "")))
537 .padding(20.)
552 .padding(20.)
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
@@ -560,12 +575,13 @@ module App =
560 .font(size = 14.)
575 .font(size = 14.)
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.)
567 HStack(spacing = 8.) {
583 HStack(spacing = 8.) {
568− Entry(model.Compose, SetCompose)
584+ Entry(compose, SetCompose)
569 .placeholder("Message...")
585 .placeholder("Message...")
571 Button("Send", DoSend)
587 Button("Send", DoSend)
@@ -574,7 +590,7 @@ module App =
574 .padding(12.)
590 .padding(12.)
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
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-...")
593 Label("Nickname:")
609 Label("Nickname:")
594− Entry(model.ContactNickname, SetContactNickname)
610+ Entry(cnn, SetContactNickname)
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
611 Application() { Window(page) }
627 Application() { Window(page) }