@@ -33,15 +33,15 @@ module App =
33 | AddContact
33 | AddContact
34 | Settings
34 | Settings
39− | Online of token: string
41 type Identity = { PrivKey: byte[]; PubKeyHex: string }
36 type Identity = { PrivKey: byte[]; PubKeyHex: string }
43 type Session = { Url: string; Token: string; Identity: Identity }
38 type Session = { Url: string; Token: string; Identity: Identity }
45 // ── Model ──
45 // ── Model ──
47 type Model =
47 type Model =
@@ -72,7 +72,7 @@ module App =
72 | DoDisconnect
72 | DoDisconnect
73 | SetCompose of string
73 | SetCompose of string
75− | Sent of recipient: string * status: string
75+ | Sent of status: string
76 | SetNewPubkey of string
76 | SetNewPubkey of string
77 | SetNewNickname of string
77 | SetNewNickname of string
78 | DoSaveContact
78 | DoSaveContact
@@ -87,7 +87,7 @@ module App =
87 type CmdMsg =
87 type CmdMsg =
88 | CmdGenIdentity
88 | CmdGenIdentity
89 | CmdConnect of url: string * identity: Identity
89 | CmdConnect of url: string * identity: Identity
90− | CmdSend of session: Session * recipientHex: string * text: string
90+ | CmdSend of session: Session * recipientHex: string * text: string * messageId: string
91 | CmdPoll of session: Session * cursor: uint64
91 | CmdPoll of session: Session * cursor: uint64
92 | CmdCopyToClipboard of string
92 | CmdCopyToClipboard of string
@@ -119,8 +119,8 @@ module App =
119 model.Identity |> Option.map _.PubKeyHex |> Option.defaultValue ""
119 model.Identity |> Option.map _.PubKeyHex |> Option.defaultValue ""
121 let private trySession model =
121 let private trySession model =
122− match model.Identity, model.Conn with
122+ match model.Conn with
123− | Some id, Online token -> Some { Url = model.ServerUrl; Token = token; Identity = id }
123+ | Online session -> Some session
124 | _ -> None
124 | _ -> None
126 // ── Init ──
126 // ── Init ──
@@ -164,7 +164,7 @@ module App =
164 match model.Identity with
164 match model.Identity with
165 | Some id ->
165 | Some id ->
166 let session = { Url = model.ServerUrl; Token = token; Identity = id }
166 let session = { Url = model.ServerUrl; Token = token; Identity = id }
167− { model with Conn = Online token; Page = Conversations; Error = None },
167+ { model with Conn = Online session; Page = Conversations; Error = None },
168 [ CmdPoll(session, model.PollCursor) ]
168 [ CmdPoll(session, model.PollCursor) ]
169 | None ->
169 | None ->
170 { model with Error = Some "No identity generated" }, []
170 { model with Error = Some "No identity generated" }, []
@@ -179,8 +179,9 @@ module App =
179 match model.Page, trySession model with
179 match model.Page, trySession model with
180 | Chat pk, Some session when model.ComposeText <> "" ->
180 | Chat pk, Some session when model.ComposeText <> "" ->
181 let text = model.ComposeText
181 let text = model.ComposeText
182+ let id = Guid.NewGuid().ToString()
182 let outMsg =
183 let outMsg =
183− { Id = Guid.NewGuid().ToString()
184+ { Id = id
184 Body = text
185 Body = text
185 Timestamp = DateTimeOffset.UtcNow
186 Timestamp = DateTimeOffset.UtcNow
186 IsOutgoing = true }
187 IsOutgoing = true }
@@ -189,10 +190,10 @@ module App =
189 { model with
190 { model with
190 ComposeText = ""
191 ComposeText = ""
191 Messages = model.Messages |> Map.add pk (msgs @ [ outMsg ]) },
192 Messages = model.Messages |> Map.add pk (msgs @ [ outMsg ]) },
192− [ CmdSend(session, pk, text) ]
193+ [ CmdSend(session, pk, text, id) ]
193 | _ -> model, []
194 | _ -> model, []
195− | Sent(_, status) -> { model with LastSendStatus = status }, []
196+ | Sent status -> { model with LastSendStatus = status }, []
197 | PollResult(incoming, status, newCursor) ->
198 | PollResult(incoming, status, newCursor) ->
198 let model' =
199 let model' =
@@ -312,21 +313,20 @@ module App =
312 return AuthErr ex.Message
313 return AuthErr ex.Message
315− | CmdSend(session, recipientHex, text) ->
316+ | CmdSend(session, recipientHex, text, messageId) ->
316 asyncCmd (async {
317 asyncCmd (async {
318 let pubKey = session.Identity.PrivKey.[32..63]
319 let pubKey = session.Identity.PrivKey.[32..63]
319 let recipPub = Crypto.fromHex recipientHex
320 let recipPub = Crypto.fromHex recipientHex
320− let id = Guid.NewGuid().ToString()
321 let ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
321 let ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
323 let payload =
323 let payload =
324− $"""{{ "type": "text", "id": "{id}", "timestamp": {ts}, "body": {JsonSerializer.Serialize(text)} }}"""
324+ $"""{{ "type": "text", "id": "{messageId}", "timestamp": {ts}, "body": {JsonSerializer.Serialize(text)} }}"""
326 let blob = Crypto.encrypt session.Identity.PrivKey pubKey recipPub payload
326 let blob = Crypto.encrypt session.Identity.PrivKey pubKey recipPub payload
327 let blobHex = Crypto.toHex blob
327 let blobHex = Crypto.toHex blob
328 let! (status, msgId) = ApiClient.sendMessage session.Url session.Token recipientHex blobHex ts
328 let! (status, msgId) = ApiClient.sendMessage session.Url session.Token recipientHex blobHex ts
329− return Sent(recipientHex, $"{status}:{msgId}")
329+ return Sent $"{status}:{msgId}"
330 with ex ->
330 with ex ->
331 return DismissError // TODO: show send errors properly
331 return DismissError // TODO: show send errors properly
@@ -335,29 +335,26 @@ module App =
335 asyncCmd (async {
335 asyncCmd (async {
337 let! (events, newCursor) = ApiClient.poll session.Url session.Token cursor
337 let! (events, newCursor) = ApiClient.poll session.Url session.Token cursor
338− let messages = ResizeArray()
338+ let messages, ackIds, errors =
339− let ackIds = ResizeArray()
339+ events
340− let errors = ResizeArray()
340+ |> List.fold (fun (msgs, acks, errs) (evtId, evtType, payload) ->
342− for (evtId, evtType, payload) in events do
343− if evtType = "message" then
341+ if evtType <> "message" then (msgs, acks, errs)
344− match decryptEvent session.Identity.PrivKey payload with
343+ match decryptEvent session.Identity.PrivKey payload with
345− | Ok msg ->
344+ | Ok msg -> (msg :: msgs, evtId :: acks, errs)
348− | Error err ->
345+ | Error err -> (msgs, acks, err :: errs))
349− errors.Add(err)
346+ ([], [], [])
351− if ackIds.Count > 0 then
348+ if not ackIds.IsEmpty then
352− do! ApiClient.ackMessages session.Url session.Token (Seq.toList ackIds)
349+ do! ApiClient.ackMessages session.Url session.Token ackIds
354 let now = DateTimeOffset.UtcNow.ToString("HH:mm:ss")
351 let now = DateTimeOffset.UtcNow.ToString("HH:mm:ss")
356 let status =
353 let status =
357− $"[{now}] evts:{events.Length} msgs:{messages.Count} errs:{errors.Count}"
354+ $"[{now}] evts:{events.Length} msgs:{messages.Length} errs:{errors.Length}"
358− + (if errors.Count > 0 then $" [{errors.[0]}]" else "")
355+ + (match errors with err :: _ -> $" [{err}]" | [] -> "")
360− return PollResult(Seq.toList messages, status, newCursor)
357+ return PollResult(List.rev messages, status, newCursor)
362 | :? TimeoutException ->
359 | :? TimeoutException ->
363 return PollResult([], "polling...", cursor)
360 return PollResult([], "polling...", cursor)
@@ -411,12 +408,14 @@ module App =
412 Entry(model.ServerUrl, SetServerUrl)
409 Entry(model.ServerUrl, SetServerUrl)
414− if model.Error.IsSome then
411+ match model.Error with
415− Label(model.Error.Value)
412+ | Some err ->
416 .textColor(Colors.Red)
414 .textColor(Colors.Red)
417 .font(size = 12.)
415 .font(size = 12.)
419 Button("Dismiss", DismissError)
417 Button("Dismiss", DismissError)
421 match model.Conn with
420 match model.Conn with
422 | Offline -> Button("Connect", DoConnect)
421 | Offline -> Button("Connect", DoConnect)
@@ -504,10 +503,12 @@ module App =
504 .centerTextHorizontal()
503 .centerTextHorizontal()
507− if model.Error.IsSome then
506+ match model.Error with
508− Label(model.Error.Value)
507+ | Some err ->
509 .textColor(Colors.Red)
509 .textColor(Colors.Red)
510 .font(size = 11.)
510 .font(size = 11.)
512 Label($"[{msgs.Length} message(s), compose: \"{model.ComposeText}\", send: \"{model.LastSendStatus}\"]")
513 Label($"[{msgs.Length} message(s), compose: \"{model.ComposeText}\", send: \"{model.LastSendStatus}\"]")
513 .textColor(Colors.Gray)
514 .textColor(Colors.Gray)