@@ -81,6 +81,7 @@ module App =
8181 | DoDisconnect
8282 | DoSend
8383 | SendFailed of string
84+ | SendOk
8485 | DoSaveContact
8586 | PollResult of events: DecryptedEvent list * status: PollStatus * cursor: int64
8687 | CopyPubKey
@@ -161,7 +162,7 @@ module App =
161162 | NoIdentity -> model
162163
163164 let private saveCmdMsg model =
164− CmdSaveData { Contacts = model.Contacts; Messages = model.Messages; ServerUrl = model.ServerUrl; PollCursor = model.PollCursor }
165+ CmdSaveData { Contacts = model.Contacts; Messages = model.Messages; ServerUrl = Some model.ServerUrl; PollCursor = model.PollCursor }
165166
166167 let private connStatusLabel = function
167168 | NoIdentity | Identified(_, Offline) -> "OFFLINE"
@@ -245,6 +246,7 @@ module App =
245246 | _ -> model, []
246247
247248 | SendFailed err -> { model with Error = Some err }, []
249+ | SendOk -> model, []
248250
249251 | PollResult(events, status, newCursor) ->
250252 let model' =
@@ -295,7 +297,7 @@ module App =
295297 Auth = Identified(identity, Connecting)
296298 Contacts = data.Contacts
297299 Messages = data.Messages
298− ServerUrl = if data.ServerUrl <> "" then data.ServerUrl else model.ServerUrl
300+ ServerUrl = data.ServerUrl |> Option.defaultValue model.ServerUrl
299301 PollCursor = data.PollCursor
300302 Page = Conversations }
301303 model', [ CmdConnect(model'.ServerUrl, identity) ]
@@ -313,36 +315,65 @@ module App =
313315 }
314316 |> Async.Start)
315317
318+ type private Envelope =
319+ | TextMessage of id: string * body: string
320+ | DeliveryAck of id: string * ackIds: string list
321+
322+ let private serializeEnvelope = function
323+ | TextMessage(id, body) ->
324+ JsonSerializer.Serialize({| ``type`` = "text"; id = id; body = body |})
325+ | DeliveryAck(id, ackIds) ->
326+ JsonSerializer.Serialize({| ``type`` = "delivery.ack"; id = id; ack_ids = ackIds |})
327+
328+ let private parseEnvelope (json: string) =
329+ use doc = JsonDocument.Parse(json)
330+ let root = doc.RootElement
331+ match root.GetProperty("type").GetString() with
332+ | "text" ->
333+ Some(TextMessage(root.GetProperty("id").GetString(), root.GetProperty("body").GetString()))
334+ | "delivery.ack" ->
335+ let ackIds = root.GetProperty("ack_ids").EnumerateArray() |> Seq.map _.GetString() |> Seq.toList
336+ Some(DeliveryAck(root.GetProperty("id").GetString(), ackIds))
337+ | _ -> None
338+
316339 let private decryptEvent (privKey: byte[]) (payload: EventPayload) =
317340 try
318341 let blob = Crypto.fromHex payload.EncryptedBlob
319342
320343 match Crypto.decrypt privKey blob with
321344 | Some(plaintext, senderHex) ->
322− use doc = JsonDocument.Parse(plaintext)
323− let root = doc.RootElement
324−
325− match root.GetProperty("type").GetString() with
326− | "text" ->
345+ match parseEnvelope plaintext with
346+ | Some(TextMessage(id, body)) ->
327347 Ok(IncomingChat
328348 { SenderPubkey = senderHex
329349 Message =
330− { Id = root.GetProperty("id").GetString()
331− Body = root.GetProperty("body").GetString()
350+ { Id = id; Body = body
332351 Timestamp = DateTimeOffset.FromUnixTimeSeconds(payload.Timestamp)
333352 IsOutgoing = false
334353 Status = DeliveryStatus.Delivered } })
335− | "delivery.ack" ->
336− let ackIds =
337− root.GetProperty("ack_ids").EnumerateArray()
338− |> Seq.map _.GetString()
339− |> Seq.toList
354+ | Some(DeliveryAck(_, ackIds)) ->
340355 Ok(IncomingDeliveryAck(senderHex, ackIds))
341− | t -> Error $"unknown envelope type: {t}"
356+ | None -> Error "unknown envelope type"
342357 | None -> Error "decrypt failed"
343358 with ex ->
344359 Error $"parse: {ex.Message}"
345360
361+ let private sendDeliveryAcks (session: Session) (events: DecryptedEvent list) =
362+ async {
363+ let chatsBySender =
364+ events
365+ |> List.choose (function IncomingChat msg -> Some(msg.SenderPubkey, msg.Message.Id) | _ -> None)
366+ |> List.groupBy fst
367+ |> List.map (fun (sender, pairs) -> sender, List.map snd pairs)
368+
369+ for sender, msgIds in chatsBySender do
370+ try
371+ let ackPayload = serializeEnvelope (DeliveryAck(Guid.NewGuid().ToString(), msgIds))
372+ let blob = Crypto.encrypt session.Identity.PrivKey (Crypto.fromHex sender) ackPayload
373+ do! sendMessage session.Url session.Token sender (Crypto.toHex blob) (DateTimeOffset.UtcNow.ToUnixTimeSeconds()) |> Async.Ignore
374+ with _ -> ()
375+ }
376+
346377 let mapCmd cmdMsg =
347378 match cmdMsg with
348379 | CmdConnect(url, identity) ->
@@ -355,24 +386,20 @@ module App =
355386 })
356387
357388 | CmdSend(session, recipientHex, text, messageId) ->
358− Cmd.ofEffect (fun dispatch ->
359− async {
360− try
361− let recipPub = Crypto.fromHex recipientHex
362− let ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
363− let payload = JsonSerializer.Serialize({| ``type`` = "text"; id = messageId; body = text |})
364− let blob = Crypto.encrypt session.Identity.PrivKey recipPub payload
365− let! status = sendMessage session.Url session.Token recipientHex (Crypto.toHex blob) ts
366−
367− match status with
368− | Delivered | Federated | Queued -> ()
369− | Rejected -> dispatch (SendFailed "Message rejected by server")
370− | Unauthorized -> dispatch (SendFailed "Not authorized to deliver message")
371− | UnknownStatus s -> dispatch (SendFailed $"Unexpected status: {s}")
372− with ex ->
373− dispatch (SendFailed $"Send failed: {ex.Message}")
374− }
375− |> Async.Start)
389+ asyncCmd (async {
390+ try
391+ let recipPub = Crypto.fromHex recipientHex
392+ let ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
393+ let payload = serializeEnvelope (TextMessage(messageId, text))
394+ let blob = Crypto.encrypt session.Identity.PrivKey recipPub payload
395+ let! status = sendMessage session.Url session.Token recipientHex (Crypto.toHex blob) ts
396+
397+ match messageStatusToError status with
398+ | Some err -> return SendFailed err
399+ | None -> return SendOk
400+ with ex ->
401+ return SendFailed $"Send failed: {ex.Message}"
402+ })
376403
377404 | CmdPoll(session, cursor) ->
378405 asyncCmd (async {
@@ -396,19 +423,7 @@ module App =
396423 try do! ackMessages session.Url session.Token ackIds
397424 with _ -> ()
398425
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 _ -> ()
426+ do! sendDeliveryAcks session events
412427
413428 let chatCount = events |> List.sumBy (function IncomingChat _ -> 1 | _ -> 0)
414429 let status = Received { Events = response.Events.Length; Messages = chatCount; Errors = errors.Length; FirstError = List.tryHead errors }