@@ -43,6 +43,15 @@ module App =
43 | Connecting
43 | Connecting
44 | Online of Session
44 | Online of Session
46+ type PollResultData =
47+ { Messages: IncomingMessage list
52+ { Identity: Identity option
53+ Data: Store.DataDto option }
46 // ── Model ──
55 // ── Model ──
48 type Model =
57 type Model =
@@ -78,11 +87,11 @@ module App =
78 | SetNewNickname of string
87 | SetNewNickname of string
79 | DoSaveContact
88 | DoSaveContact
80 | DoDeleteContact of string
89 | DoDeleteContact of string
81− | PollResult of messages: IncomingMessage list * status: string * cursor: uint64
90+ | PollResult of PollResultData
83 | CopyPubKey
92 | CopyPubKey
84 | DismissError
93 | DismissError
85− | StateLoaded of Identity option * Store.DataDto option
94+ | StateLoaded of LoadedState
87 // ── CmdMsg ──
96 // ── CmdMsg ──
@@ -110,6 +119,9 @@ module App =
110 let private messagesFor (pk: string) (messages: Map<string, ChatMessage list>) =
119 let private messagesFor (pk: string) (messages: Map<string, ChatMessage list>) =
111 messages |> Map.tryFind pk |> Option.defaultValue []
120 messages |> Map.tryFind pk |> Option.defaultValue []
122+ let private appendMessage (pk: string) (msg: ChatMessage) (messages: Map<string, ChatMessage list>) =
123+ messages |> Map.add pk (messagesFor pk messages @ [ msg ])
113 let private addContactIfNew (contact: Contact) (contacts: Contact list) =
125 let private addContactIfNew (contact: Contact) (contacts: Contact list) =
114 if contacts |> List.exists (fun c -> c.Pubkey = contact.Pubkey) then contacts
126 if contacts |> List.exists (fun c -> c.Pubkey = contact.Pubkey) then contacts
115 else contacts @ [ contact ]
127 else contacts @ [ contact ]
@@ -194,26 +206,23 @@ module App =
194 Body = text
206 Body = text
195 Timestamp = DateTimeOffset.UtcNow
207 Timestamp = DateTimeOffset.UtcNow
196 IsOutgoing = true }
208 IsOutgoing = true }
197− let msgs = messagesFor pk model.Messages
199 let model' =
210 let model' =
200 { model with
211 { model with
201 ComposeText = ""
212 ComposeText = ""
202− Messages = model.Messages |> Map.add pk (msgs @ [ outMsg ]) }
213+ Messages = model.Messages |> appendMessage pk outMsg }
204 model', CmdSend(session, pk, text, id) :: saveCmd model'
215 model', CmdSend(session, pk, text, id) :: saveCmd model'
205 | _ -> model, []
216 | _ -> model, []
207 | Sent status -> { model with LastSendStatus = status }, []
218 | Sent status -> { model with LastSendStatus = status }, []
209− | PollResult(incoming, status, newCursor) ->
220+ | PollResult { Messages = incoming; Status = status; Cursor = newCursor } ->
210 let model' =
221 let model' =
212 |> List.fold
223 |> List.fold
213 (fun m msg ->
224 (fun m msg ->
214− let msgs = messagesFor msg.FromPubkey m.Messages
225+ if messagesFor msg.FromPubkey m.Messages |> List.exists (fun x -> x.Id = msg.MessageId) then
216− if msgs |> List.exists (fun x -> x.Id = msg.MessageId) then
219 let chatMsg =
228 let chatMsg =
@@ -224,7 +233,7 @@ module App =
226 Contacts = m.Contacts |> addContactIfNew { Pubkey = msg.FromPubkey; Nickname = truncKey msg.FromPubkey }
235 Contacts = m.Contacts |> addContactIfNew { Pubkey = msg.FromPubkey; Nickname = truncKey msg.FromPubkey }
227− Messages = m.Messages |> Map.add msg.FromPubkey (msgs @ [ chatMsg ]) })
236+ Messages = m.Messages |> appendMessage msg.FromPubkey chatMsg })
228 { model with PollStatus = status; PollCursor = newCursor }
237 { model with PollStatus = status; PollCursor = newCursor }
230 let cmds =
239 let cmds =
@@ -273,10 +282,12 @@ module App =
274 | DismissError -> { model with Error = None }, []
283 | DismissError -> { model with Error = None }, []
276− | StateLoaded(identityOpt, dataOpt) ->
285+ | StateLoaded { Identity = identityOpt; Data = dataOpt } ->
277 match identityOpt with
286 match identityOpt with
278 | None -> model, []
287 | None -> model, []
279 | Some identity ->
288 | Some identity ->
289+ let model' = { model with Identity = Some identity; Conn = Connecting }
280 let model' =
291 let model' =
281 match dataOpt with
292 match dataOpt with
282 | Some data ->
293 | Some data ->
@@ -302,22 +313,14 @@ module App =
302 acc |> Map.add pk msgs)
313 acc |> Map.add pk msgs)
303 Map.empty
314 Map.empty
305− { model with
316+ { model' with
306− Identity = Some identity
307 Contacts = contacts
317 Contacts = contacts
308 Messages = messages
318 Messages = messages
310− if data.ServerUrl <> "" then data.ServerUrl
319+ ServerUrl = if data.ServerUrl <> "" then data.ServerUrl else model.ServerUrl
311− else model.ServerUrl
312 PollCursor = data.PollCursor
320 PollCursor = data.PollCursor
313− Page = Conversations
321+ Page = Conversations }
318− Identity = Some identity
319− Page = Settings
323+ | None -> { model' with Page = Settings }
322 model', [ CmdConnect(model'.ServerUrl, identity) ]
325 model', [ CmdConnect(model'.ServerUrl, identity) ]
@@ -366,12 +369,12 @@ module App =
367 let! challenge = ApiClient.requestChallenge url identity.PubKeyHex
370 let! challenge = ApiClient.requestChallenge url identity.PubKeyHex
368 let sigHex = Crypto.signChallenge identity.PrivKey challenge
371 let sigHex = Crypto.signChallenge identity.PrivKey challenge
369− let! (token, _) = ApiClient.verify url identity.PubKeyHex challenge sigHex
372+ let! result = ApiClient.verify url identity.PubKeyHex challenge sigHex
371− if String.IsNullOrEmpty(token) then
374+ if String.IsNullOrEmpty(result.Token) then
372 return AuthErr "Authentication rejected by server"
375 return AuthErr "Authentication rejected by server"
374− return AuthOk token
377+ return AuthOk result.Token
375 with ex ->
378 with ex ->
376 return AuthErr ex.Message
379 return AuthErr ex.Message
@@ -379,17 +382,16 @@ module App =
379 | CmdSend(session, recipientHex, text, messageId) ->
382 | CmdSend(session, recipientHex, text, messageId) ->
380 asyncCmd (async {
383 asyncCmd (async {
382− let pubKey = Crypto.fromHex session.Identity.PubKeyHex
383 let recipPub = Crypto.fromHex recipientHex
385 let recipPub = Crypto.fromHex recipientHex
384 let ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
386 let ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
386 let payload =
388 let payload =
387 $"""{{ "type": "text", "id": "{messageId}", "timestamp": {ts}, "body": {JsonSerializer.Serialize(text)} }}"""
389 $"""{{ "type": "text", "id": "{messageId}", "timestamp": {ts}, "body": {JsonSerializer.Serialize(text)} }}"""
389− let blob = Crypto.encrypt session.Identity.PrivKey pubKey recipPub payload
391+ let blob = Crypto.encrypt session.Identity.PrivKey recipPub payload
390 let blobHex = Crypto.toHex blob
392 let blobHex = Crypto.toHex blob
391− let! (status, msgId) = ApiClient.sendMessage session.Url session.Token recipientHex blobHex ts
393+ let! result = ApiClient.sendMessage session.Url session.Token recipientHex blobHex ts
392− return Sent $"{status}:{msgId}"
394+ return Sent $"{result.Status}:{result.MessageId}"
393 with ex ->
395 with ex ->
394 return DismissError // TODO: show send errors properly
396 return DismissError // TODO: show send errors properly
@@ -397,15 +399,13 @@ module App =
397 | CmdPoll(session, cursor) ->
399 | CmdPoll(session, cursor) ->
398 asyncCmd (async {
400 asyncCmd (async {
400− let! (events, newCursor) = ApiClient.poll session.Url session.Token cursor
402+ let! response = ApiClient.poll session.Url session.Token cursor
401 let messages, ackIds, errors =
403 let messages, ackIds, errors =
402− events
404+ response.Events
403 |> List.fold (fun (msgs, acks, errs) evt ->
405 |> List.fold (fun (msgs, acks, errs) evt ->
404− if evt.EventType <> "message" then (msgs, acks, errs)
406− match decryptEvent session.Identity.PrivKey evt.Payload with
406+ match decryptEvent session.Identity.PrivKey evt.Payload with
407− | Ok msg -> (msg :: msgs, evt.Id :: acks, errs)
407+ | Ok msg -> (msg :: msgs, evt.Id :: acks, errs)
408− | Error err -> (msgs, acks, err :: errs))
408+ | Error err -> (msgs, acks, err :: errs))
409 ([], [], [])
409 ([], [], [])
411 if not ackIds.IsEmpty then
411 if not ackIds.IsEmpty then
@@ -414,17 +414,17 @@ module App =
414 let now = DateTimeOffset.UtcNow.ToString("HH:mm:ss")
414 let now = DateTimeOffset.UtcNow.ToString("HH:mm:ss")
416 let status =
416 let status =
417− $"[{now}] evts:{events.Length} msgs:{messages.Length} errs:{errors.Length}"
417+ $"[{now}] evts:{response.Events.Length} msgs:{messages.Length} errs:{errors.Length}"
418 + (match errors with err :: _ -> $" [{err}]" | [] -> "")
418 + (match errors with err :: _ -> $" [{err}]" | [] -> "")
420− return PollResult(List.rev messages, status, newCursor)
420+ return PollResult { Messages = List.rev messages; Status = status; Cursor = response.Cursor }
422 | :? TimeoutException ->
422 | :? TimeoutException ->
423− return PollResult([], "polling...", cursor)
423+ return PollResult { Messages = []; Status = "polling..."; Cursor = cursor }
425 let now = DateTimeOffset.UtcNow.ToString("HH:mm:ss")
425 let now = DateTimeOffset.UtcNow.ToString("HH:mm:ss")
426 do! Async.Sleep 3000
426 do! Async.Sleep 3000
427− return PollResult([], $"[{now}] poll error: {ex.Message}", cursor)
427+ return PollResult { Messages = []; Status = $"[{now}] poll error: {ex.Message}"; Cursor = cursor }
430 | CmdCopyToClipboard text ->
430 | CmdCopyToClipboard text ->
@@ -436,7 +436,7 @@ module App =
436 asyncCmd (async {
436 asyncCmd (async {
437 let! identity = Store.loadIdentity ()
437 let! identity = Store.loadIdentity ()
438 let data = Store.loadData ()
438 let data = Store.loadData ()
439− return StateLoaded(identity, data)
439+ return StateLoaded { Identity = identity; Data = data }
442 | CmdSaveIdentity identity ->
442 | CmdSaveIdentity identity ->