@@ -24,7 +24,7 @@ module App =
24 | Chat of pubkey: string * compose: string
24 | Chat of pubkey: string * compose: string
25 | AddContact of pubkey: string * nickname: string
25 | AddContact of pubkey: string * nickname: string
26 | Settings
26 | Settings
27− | EditProfile of displayName: string * bio: string * photo: string option
27+ | EditProfile of displayName: string * bio: string * photo: string
29 type Session = { Url: string; Token: string; Identity: Identity }
29 type Session = { Url: string; Token: string; Identity: Identity }
@@ -40,7 +40,7 @@ module App =
40 type DecryptedEvent =
40 type DecryptedEvent =
41 | IncomingChat of senderPubkey: string * message: ChatMessage
41 | IncomingChat of senderPubkey: string * message: ChatMessage
42 | IncomingDeliveryAck of senderPubkey: string * ackIds: string list
42 | IncomingDeliveryAck of senderPubkey: string * ackIds: string list
43− | IncomingProfile of senderPubkey: string * displayName: string * bio: string * photo: string option
43+ | IncomingProfile of senderPubkey: string * displayName: string * bio: string * photo: string
45 type PollReceived = { Events: int; Messages: int; Errors: int; FirstError: string option }
45 type PollReceived = { Events: int; Messages: int; Errors: int; FirstError: string option }
@@ -56,7 +56,7 @@ module App =
56 { Page: Page
56 { Page: Page
57 Auth: AuthState
57 Auth: AuthState
58 ServerUrl: string
58 ServerUrl: string
59− Contacts: Contact list
59+ Contacts: Map<string, Contact>
60 PollStatus: PollStatus
60 PollStatus: PollStatus
61 Messages: Map<string, ChatMessage list>
61 Messages: Map<string, ChatMessage list>
62 Error: string option
62 Error: string option
@@ -92,7 +92,7 @@ module App =
92 | SetProfileDisplayName of string
92 | SetProfileDisplayName of string
93 | SetProfileBio of string
93 | SetProfileBio of string
94 | DoPickPhoto
94 | DoPickPhoto
95− | PhotoPicked of string option
95+ | PhotoPicked of string
96 | DoSaveProfile
96 | DoSaveProfile
98 // ── CmdMsg ──
98 // ── CmdMsg ──
@@ -130,10 +130,10 @@ module App =
130 |> Option.orElseWith (fun () -> Crypto.tryFromHex input)
130 |> Option.orElseWith (fun () -> Crypto.tryFromHex input)
131 |> Option.filter (fun bytes -> bytes.Length = 32)
131 |> Option.filter (fun bytes -> bytes.Length = 32)
133− let private contactName (contacts: Contact list) (pk: string) =
133+ let private contactName (contacts: Map<string, Contact>) (pk: string) =
134 let fallback = truncKey pk
134 let fallback = truncKey pk
136− |> List.tryFind (fun c -> c.Pubkey = pk)
136+ |> Map.tryFind pk
137 |> Option.map (fun c ->
137 |> Option.map (fun c ->
138 if c.Nickname <> fallback then c.Nickname
138 if c.Nickname <> fallback then c.Nickname
139 elif c.DisplayName <> "" then c.DisplayName
139 elif c.DisplayName <> "" then c.DisplayName
@@ -156,15 +156,6 @@ module App =
156 |> fun updated -> Map.add pk updated messages
156 |> fun updated -> Map.add pk updated messages
157 | None -> messages
157 | None -> messages
159− let private addContactIfNew (contact: Contact) (contacts: Contact list) =
160− if contacts |> List.exists (fun c -> c.Pubkey = contact.Pubkey) then contacts
161− else contacts @ [ contact ]
163− let private upsertContact (contact: Contact) (contacts: Contact list) =
164− match contacts |> List.tryFindIndex (fun c -> c.Pubkey = contact.Pubkey) with
165− | Some i -> contacts |> List.updateAt i contact
166− | None -> contacts @ [ contact ]
168 let private newContact pubkey nickname : Contact =
159 let private newContact pubkey nickname : Contact =
169 { Pubkey = pubkey; Nickname = nickname; DisplayName = ""; Bio = ""; PhotoBase64 = "" }
160 { Pubkey = pubkey; Nickname = nickname; DisplayName = ""; Bio = ""; PhotoBase64 = "" }
@@ -193,7 +184,7 @@ module App =
193 Auth = NoIdentity
184 Auth = NoIdentity
194 ServerUrl = "http://localhost:8080"
185 ServerUrl = "http://localhost:8080"
195 PollStatus = Idle
186 PollStatus = Idle
196− Contacts = []
187+ Contacts = Map.empty
197 Messages = Map.empty
188 Messages = Map.empty
198 Error = None
189 Error = None
199 PollCursor = 0L
190 PollCursor = 0L
@@ -281,18 +272,17 @@ module App =
281 match evt with
272 match evt with
282 | IncomingChat(senderPubkey, message) ->
273 | IncomingChat(senderPubkey, message) ->
276+ if Map.containsKey senderPubkey m.Contacts then m.Contacts
284− Contacts = m.Contacts |> addContactIfNew (newContact senderPubkey (truncKey senderPubkey))
277+ else Map.add senderPubkey (newContact senderPubkey (truncKey senderPubkey)) m.Contacts
285 Messages = m.Messages |> appendMessage senderPubkey message }
278 Messages = m.Messages |> appendMessage senderPubkey message }
286 | IncomingDeliveryAck(senderPubkey, ackIds) ->
279 | IncomingDeliveryAck(senderPubkey, ackIds) ->
287 { m with Messages = m.Messages |> markDelivered senderPubkey ackIds }
280 { m with Messages = m.Messages |> markDelivered senderPubkey ackIds }
288 | IncomingProfile(senderPubkey, displayName, bio, photo) ->
281 | IncomingProfile(senderPubkey, displayName, bio, photo) ->
290 Contacts =
283 Contacts =
291− m.Contacts
284+ m.Contacts |> Map.change senderPubkey (Option.map (fun c ->
292− |> List.map (fun c ->
293− if c.Pubkey = senderPubkey then
294− { c with DisplayName = displayName; Bio = bio; PhotoBase64 = photo |> Option.defaultValue "" }
285+ { c with DisplayName = displayName; Bio = bio; PhotoBase64 = photo })) })
296 { model with PollStatus = status; PollCursor = newCursor }
286 { model with PollStatus = status; PollCursor = newCursor }
@@ -307,15 +297,14 @@ module App =
307 match tryParsePubkey cpk with
297 match tryParsePubkey cpk with
308 | Some bytes ->
298 | Some bytes ->
309 let hex = Crypto.toHex bytes
299 let hex = Crypto.toHex bytes
310− let existing = model.Contacts |> List.tryFind (fun c -> c.Pubkey = hex)
311− let contact: Contact =
300+ let contact =
312− match existing with
301+ match Map.tryFind hex model.Contacts with
313 | Some e -> { e with Nickname = cnn }
302 | Some e -> { e with Nickname = cnn }
314 | None -> newContact hex cnn
303 | None -> newContact hex cnn
316 let model' =
305 let model' =
317 { model with
306 { model with
318− Contacts = model.Contacts |> upsertContact contact
307+ Contacts = Map.add hex contact model.Contacts
319 Page = Conversations }
308 Page = Conversations }
321 model', [ saveCmdMsg model' ]
310 model', [ saveCmdMsg model' ]
@@ -353,7 +342,7 @@ module App =
353 { model with Profile = Some profile; Page = Settings },
342 { model with Profile = Some profile; Page = Settings },
354 [ CmdSaveProfile profile
343 [ CmdSaveProfile profile
355 match trySession model with
344 match trySession model with
356− | Some session -> CmdSendProfileToAll(session, profile, model.Contacts |> List.map _.Pubkey)
345+ | Some session -> CmdSendProfileToAll(session, profile, model.Contacts.Keys |> Seq.toList)
357 | None -> () ]
346 | None -> () ]
358 | _ -> model, []
347 | _ -> model, []
@@ -385,7 +374,7 @@ module App =
385 type private Envelope =
374 type private Envelope =
386 | TextMessage of id: string * body: string
375 | TextMessage of id: string * body: string
387 | DeliveryAck of id: string * ackIds: string list
376 | DeliveryAck of id: string * ackIds: string list
388− | ProfileMessage of id: string * displayName: string * bio: string * photo: string option
377+ | ProfileMessage of id: string * displayName: string * bio: string * photo: string
390 let private serializeEnvelope = function
379 let private serializeEnvelope = function
391 | TextMessage(id, body) ->
380 | TextMessage(id, body) ->
@@ -393,7 +382,7 @@ module App =
393 | DeliveryAck(id, ackIds) ->
382 | DeliveryAck(id, ackIds) ->
394 JsonSerializer.Serialize({| ``type`` = "delivery.ack"; id = id; ack_ids = ackIds |})
383 JsonSerializer.Serialize({| ``type`` = "delivery.ack"; id = id; ack_ids = ackIds |})
395 | ProfileMessage(id, displayName, bio, photo) ->
384 | ProfileMessage(id, displayName, bio, photo) ->
396− JsonSerializer.Serialize({| ``type`` = "profile"; id = id; timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); display_name = displayName; bio = bio; photo = (photo |> Option.defaultValue null) |})
385+ JsonSerializer.Serialize({| ``type`` = "profile"; id = id; timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); display_name = displayName; bio = bio; photo = photo |})
398 let private sendEnvelope (session: Session) (recipientHex: string) (envelope: Envelope) =
387 let private sendEnvelope (session: Session) (recipientHex: string) (envelope: Envelope) =
@@ -420,7 +409,7 @@ module App =
420 | "profile" ->
409 | "profile" ->
421 let displayName = root.GetProperty("display_name").GetString()
410 let displayName = root.GetProperty("display_name").GetString()
422 let bio = tryGetJsonString root "bio" |> Option.defaultValue ""
411 let bio = tryGetJsonString root "bio" |> Option.defaultValue ""
423− let photo = tryGetJsonString root "photo"
412+ let photo = tryGetJsonString root "photo" |> Option.defaultValue ""
424 Some(ProfileMessage(root.GetProperty("id").GetString(), displayName, bio, photo))
413 Some(ProfileMessage(root.GetProperty("id").GetString(), displayName, bio, photo))
425 | _ -> None
414 | _ -> None
@@ -487,17 +476,15 @@ module App =
487 let! response = poll session.Url session.Token cursor
476 let! response = poll session.Url session.Token cursor
488 let ackIds = response.Events |> Array.map _.Id |> Array.toList
477 let ackIds = response.Events |> Array.map _.Id |> Array.toList
490− let events, errors =
479+ let results =
491− Array.foldBack
480+ response.Events
492− (fun (evt: PollEvent) (evts, errs) ->
481+ |> Array.choose (fun evt ->
493− match evt.EventType with
482+ match evt.EventType with
495− match decryptEvent session.Identity.PrivKey evt.Payload with
483+ | Message -> Some(decryptEvent session.Identity.PrivKey evt.Payload)
496− | Ok event -> (event :: evts, errs)
484+ | UnknownEvent _ -> None)
497− | Error err -> (evts, err :: errs)
485+
498− | UnknownEvent _ -> (evts, errs))
486+ let events = results |> Array.choose (function Ok e -> Some e | _ -> None) |> Array.toList
499− response.Events
487+ let errors = results |> Array.choose (function Error e -> Some e | _ -> None) |> Array.toList
502 if not ackIds.IsEmpty then
489 if not ackIds.IsEmpty then
503 try do! ackMessages session.Url session.Token ackIds
490 try do! ackMessages session.Url session.Token ackIds
@@ -544,14 +531,14 @@ module App =
545 let! results = Microsoft.Maui.Media.MediaPicker.Default.PickPhotosAsync() |> Async.AwaitTask
532 let! results = Microsoft.Maui.Media.MediaPicker.Default.PickPhotosAsync() |> Async.AwaitTask
546 match results |> Seq.tryHead with
533 match results |> Seq.tryHead with
547− | None -> return PhotoPicked None
534+ | None -> return PhotoPicked ""
548 | Some file ->
535 | Some file ->
549 use! stream = file.OpenReadAsync() |> Async.AwaitTask
536 use! stream = file.OpenReadAsync() |> Async.AwaitTask
550 use ms = new MemoryStream()
537 use ms = new MemoryStream()
551 do! stream.CopyToAsync(ms) |> Async.AwaitTask
538 do! stream.CopyToAsync(ms) |> Async.AwaitTask
552− return PhotoPicked(Some(Convert.ToBase64String(ms.ToArray())))
539+ return PhotoPicked(Convert.ToBase64String(ms.ToArray()))
553 with _ ->
540 with _ ->
554− return PhotoPicked None
541+ return PhotoPicked ""
557 | CmdSaveProfile profile ->
544 | CmdSaveProfile profile ->
@@ -623,7 +610,7 @@ module App =
623 let displayName, bio, photo =
610 let displayName, bio, photo =
624 match model.Profile with
611 match model.Profile with
625 | Some p -> p.DisplayName, p.Bio, p.PhotoBase64
612 | Some p -> p.DisplayName, p.Bio, p.PhotoBase64
626− | None -> "", "", None
613+ | None -> "", "", ""
627 if displayName <> "" then
614 if displayName <> "" then
628 Label($"Name: {displayName}").font(size = 14.)
615 Label($"Name: {displayName}").font(size = 14.)
629 if bio <> "" then
616 if bio <> "" then
@@ -673,7 +660,7 @@ module App =
673 .centerTextHorizontal()
660 .centerTextHorizontal()
674 .textColor(Colors.Gray)
661 .textColor(Colors.Gray)
676− for c in model.Contacts do
663+ for c in model.Contacts.Values do
677 let preview =
664 let preview =
678 messagesFor c.Pubkey model.Messages
665 messagesFor c.Pubkey model.Messages
679 |> List.tryLast
666 |> List.tryLast
@@ -759,13 +746,12 @@ module App =
760 viewErrorBanner model.Error
747 viewErrorBanner model.Error
762− match photo with
749+ if photo <> "" then
763− | Some p when p <> "" ->
764− Image(new MemoryStream(Convert.FromBase64String(p)) :> Stream)
750+ Image(new MemoryStream(Convert.FromBase64String(photo)) :> Stream)
765 .width(120.)
751 .width(120.)
766 .height(120.)
752 .height(120.)
767 .centerHorizontal()
753 .centerHorizontal()
769 Label("No Photo")
755 Label("No Photo")
770 .font(size = 16.)
756 .font(size = 16.)
771 .centerTextHorizontal()
757 .centerTextHorizontal()