@@ -14,6 +14,7 @@ module App =
14 open Store
14 open Store
15 open Crypto
15 open Crypto
16 open ApiClient
16 open ApiClient
18 // ── Domain Types ──
19 // ── Domain Types ──
@@ -25,7 +26,10 @@ module App =
25 | Settings
26 | Settings
26 | EditProfile of displayName: string * bio: string * photo: string
27 | EditProfile of displayName: string * bio: string * photo: string
28− type Session = { Url: string; Token: string; Identity: Identity }
32+ Identity: Identity }
30 type ConnStatus =
34 type ConnStatus =
@@ -42,7 +46,10 @@ module App =
42 | DeliveryAck of ackIds: string list
46 | DeliveryAck of ackIds: string list
43 | ProfileMessage of profile: Profile
47 | ProfileMessage of profile: Profile
45− type IncomingEvent = { Sender: string; Timestamp: DateTimeOffset; Envelope: Envelope }
51+ Timestamp: DateTimeOffset
47 // ── Model ──
54 // ── Model ──
@@ -93,17 +100,20 @@ module App =
94 // ── Helpers ──
101 // ── Helpers ──
96− let private hexToOb (hex: string) =
97− Crypto.fromHex hex |> Phonemic.toOb
103+ let private hexToOb (hex: string) = Crypto.fromHex hex |> Phonemic.toOb
99 let private truncKey (hex: string) =
105 let private truncKey (hex: string) =
100 let ob = hexToOb hex
106 let ob = hexToOb hex
101 let parts = ob.Split('-')
107 let parts = ob.Split('-')
102− if parts.Length <= 4 then ob
109+ if parts.Length <= 4 then
103− else $"{parts.[0]}-{parts.[1]}..{parts.[parts.Length - 2]}-{parts.[parts.Length - 1]}"
112+ $"{parts.[0]}-{parts.[1]}..{parts.[parts.Length - 2]}-{parts.[parts.Length - 1]}"
105 let private tryParsePubkey (input: string) =
114 let private tryParsePubkey (input: string) =
106 let input = input.Trim()
115 let input = input.Trim()
107 Phonemic.fromOb input
117 Phonemic.fromOb input
108 |> Option.orElseWith (fun () -> Crypto.tryFromHex input)
118 |> Option.orElseWith (fun () -> Crypto.tryFromHex input)
109 |> Option.filter (fun bytes -> bytes.Length = 32)
119 |> Option.filter (fun bytes -> bytes.Length = 32)
@@ -119,52 +129,90 @@ module App =
120 let private appendMessage (pk: string) (msg: ChatMessage) (messages: Map<string, ChatMessage list>) =
130 let private appendMessage (pk: string) (msg: ChatMessage) (messages: Map<string, ChatMessage list>) =
121 let existing = messagesFor pk messages
131 let existing = messagesFor pk messages
122− if existing |> List.exists (fun m -> m.Id = msg.Id) then messages
133+ if existing |> List.exists (fun m -> m.Id = msg.Id) then
123− else Map.add pk (existing @ [ msg ]) messages
136+ Map.add pk (existing @ [ msg ]) messages
125 let private markDelivered (pk: string) (ackIds: string list) (messages: Map<string, ChatMessage list>) =
138 let private markDelivered (pk: string) (ackIds: string list) (messages: Map<string, ChatMessage list>) =
126 let ackSet = Set.ofList ackIds
139 let ackSet = Set.ofList ackIds
127− messages |> Map.change pk (Option.map (List.map (fun m ->
145+ List.map (fun m ->
128− match m.Direction with
146+ match m.Direction with
147+ | Outgoing _ when Set.contains m.Id ackSet ->
129− | Outgoing _ when Set.contains m.Id ackSet -> { m with Direction = Outgoing DeliveryStatus.Delivered }
149+ Direction = Outgoing DeliveryStatus.Delivered }
130− | _ -> m)))
150+ | _ -> m)
132 let private newContact pubkey nickname : Contact =
153 let private newContact pubkey nickname : Contact =
133− { Pubkey = pubkey; Nickname = nickname; DisplayName = ""; Bio = ""; PhotoBase64 = "" }
155+ Nickname = nickname
135 let private withProfile (p: Profile) (c: Contact) =
160 let private withProfile (p: Profile) (c: Contact) =
136− { c with DisplayName = p.DisplayName; Bio = p.Bio; PhotoBase64 = p.PhotoBase64 }
162+ DisplayName = p.DisplayName
164+ PhotoBase64 = p.PhotoBase64 }
138 let private applyEvent (m: Model) (evt: IncomingEvent) =
166 let private applyEvent (m: Model) (evt: IncomingEvent) =
139 match evt.Envelope with
167 match evt.Envelope with
140 | Envelope.TextMessage(id, body) ->
168 | Envelope.TextMessage(id, body) ->
141 let message =
169 let message =
142− { Id = id; Body = body; Timestamp = evt.Timestamp; Direction = Incoming }
172+ Timestamp = evt.Timestamp
173+ Direction = Incoming }
144 Contacts =
176 Contacts =
145− if Map.containsKey evt.Sender m.Contacts then m.Contacts
177+ if Map.containsKey evt.Sender m.Contacts then
146− else Map.add evt.Sender (newContact evt.Sender "") m.Contacts
180+ Map.add evt.Sender (newContact evt.Sender "") m.Contacts
147 Messages = m.Messages |> appendMessage evt.Sender message }
181 Messages = m.Messages |> appendMessage evt.Sender message }
148 | Envelope.DeliveryAck ackIds ->
182 | Envelope.DeliveryAck ackIds ->
149− { m with Messages = m.Messages |> markDelivered evt.Sender ackIds }
184+ Messages = m.Messages |> markDelivered evt.Sender ackIds }
150 | Envelope.ProfileMessage profile ->
185 | Envelope.ProfileMessage profile ->
151− { m with Contacts = m.Contacts |> Map.change evt.Sender (Option.map (withProfile profile)) }
187+ Contacts = m.Contacts |> Map.change evt.Sender (Option.map (withProfile profile)) }
153 let private trySession model =
189 let private trySession model =
154 match model.Auth with
190 match model.Auth with
155− | Identified(id, Online token) -> Some { Url = model.ServerUrl; Token = token; Identity = id }
191+ | Identified(id, Online token) ->
193+ { Url = model.ServerUrl
156 | _ -> None
196 | _ -> None
158 let private setConn status model =
198 let private setConn status model =
159 match model.Auth with
199 match model.Auth with
200+ | Identified(id, _) ->
160− | Identified(id, _) -> { model with Auth = Identified(id, status) }
202+ Auth = Identified(id, status) }
161 | NoIdentity -> model
203 | NoIdentity -> model
163 let private saveCmdMsg model =
205 let private saveCmdMsg model =
164− CmdSaveData { Contacts = model.Contacts; Messages = model.Messages; ServerUrl = model.ServerUrl; PollCursor = model.PollCursor }
206+ CmdSaveData
165−
207+ { Contacts = model.Contacts
208+ Messages = model.Messages
209+ ServerUrl = model.ServerUrl
210+ PollCursor = model.PollCursor }
166− let private connStatusLabel = function
212+ let private connStatusLabel =
167− | NoIdentity | Identified(_, Offline) -> "OFFLINE"
215+ | Identified(_, Offline) -> "OFFLINE"
168 | Identified(_, Connecting) -> "CONNECTING"
216 | Identified(_, Connecting) -> "CONNECTING"
169 | Identified(_, Online _) -> "ONLINE"
217 | Identified(_, Online _) -> "ONLINE"
@@ -190,33 +238,53 @@ module App =
191 | GenIdentity ->
239 | GenIdentity ->
192 let identity = Crypto.generateIdentity ()
240 let identity = Crypto.generateIdentity ()
193− { model with Auth = Identified(identity, Offline); Page = Settings },
243+ Auth = Identified(identity, Offline)
194 [ CmdSaveIdentity identity ]
245 [ CmdSaveIdentity identity ]
196 | SetServerUrl url -> { model with ServerUrl = url }, []
247 | SetServerUrl url -> { model with ServerUrl = url }, []
198 | DoConnect ->
249 | DoConnect ->
199 match model.Auth with
250 match model.Auth with
200− | Identified(id, _) ->
201− model |> setConn Connecting, [ CmdConnect(model.ServerUrl, id) ]
251+ | Identified(id, _) -> model |> setConn Connecting, [ CmdConnect(model.ServerUrl, id) ]
202− | NoIdentity -> { model with Error = Some "No identity generated" }, []
254+ Error = Some "No identity generated" },
204 | AuthOk token ->
257 | AuthOk token ->
205 match model.Auth with
258 match model.Auth with
206 | Identified(id, Connecting) ->
259 | Identified(id, Connecting) ->
207− let session = { Url = model.ServerUrl; Token = token; Identity = id }
261+ { Url = model.ServerUrl
208− { model with Auth = Identified(id, Online token); Page = Conversations; Error = None; PollStatus = "polling..." },
266+ Auth = Identified(id, Online token)
267+ Page = Conversations
269+ PollStatus = "polling..." },
209 [ CmdPoll(session, model.PollCursor) ]
270 [ CmdPoll(session, model.PollCursor) ]
210 | _ -> model, []
271 | _ -> model, []
212− | AuthErr err -> { setConn Offline model with Error = Some err }, []
274+ { setConn Offline model with
214− | DoDisconnect -> { setConn Offline model with PollStatus = "" }, []
279+ { setConn Offline model with
216 | DoSend ->
283 | DoSend ->
217 match model.Page, trySession model with
284 match model.Page, trySession model with
218 | Chat(pk, compose), Some session when compose <> "" ->
285 | Chat(pk, compose), Some session when compose <> "" ->
219 let id = Guid.NewGuid().ToString()
286 let id = Guid.NewGuid().ToString()
220 let outMsg =
288 let outMsg =
221 { Id = id
289 { Id = id
222 Body = compose
290 Body = compose
@@ -241,13 +309,20 @@ module App =
241 | SendFailed err -> { model with Error = Some err }, []
309 | SendFailed err -> { model with Error = Some err }, []
243 | PollResult(events, status, newCursor) ->
311 | PollResult(events, status, newCursor) ->
244− let model' = events |> List.fold applyEvent { model with PollStatus = status; PollCursor = newCursor }
318+ PollCursor = newCursor }
247 [ match trySession model' with
321 [ match trySession model' with
248 | Some session -> CmdPoll(session, model'.PollCursor)
322 | Some session -> CmdPoll(session, model'.PollCursor)
249 | None -> ()
323 | None -> ()
250− if not events.IsEmpty then saveCmdMsg model' ]
324+ if not events.IsEmpty then
252 | DoSaveContact ->
327 | DoSaveContact ->
253 match model.Page with
328 match model.Page with
@@ -255,6 +330,7 @@ module App =
255 match tryParsePubkey cpk with
330 match tryParsePubkey cpk with
256 | Some bytes ->
331 | Some bytes ->
257 let hex = Crypto.toHex bytes
332 let hex = Crypto.toHex bytes
258 let contact =
334 let contact =
259 match Map.tryFind hex model.Contacts with
335 match Map.tryFind hex model.Contacts with
260 | Some e -> { e with Nickname = cnn }
336 | Some e -> { e with Nickname = cnn }
@@ -266,7 +342,10 @@ module App =
266 Page = Conversations }
342 Page = Conversations }
268 model', [ saveCmdMsg model' ]
344 model', [ saveCmdMsg model' ]
269− | None -> { model with Error = Some "Invalid public key" }, []
347+ Error = Some "Invalid public key" },
270 | _ -> model, []
349 | _ -> model, []
272 | CopyPubKey ->
351 | CopyPubKey ->
@@ -280,14 +359,23 @@ module App =
281 | PhotoPicked photo ->
360 | PhotoPicked photo ->
282 match model.Page with
361 match model.Page with
362+ | EditProfile(name, bio, _) ->
283− | EditProfile(name, bio, _) -> { model with Page = EditProfile(name, bio, photo) }, []
364+ Page = EditProfile(name, bio, photo) },
284 | _ -> model, []
366 | _ -> model, []
286 | DoSaveProfile ->
368 | DoSaveProfile ->
287 match model.Page with
369 match model.Page with
288 | EditProfile(name, bio, photo) ->
370 | EditProfile(name, bio, photo) ->
289− let profile: Profile = { DisplayName = name; Bio = bio; PhotoBase64 = photo }
371+ let profile: Profile =
372+ { DisplayName = name
374+ PhotoBase64 = photo }
290− { model with Profile = Some profile; Page = Settings },
377+ Profile = Some profile
291 [ CmdSaveProfile profile
379 [ CmdSaveProfile profile
292 match trySession model with
380 match trySession model with
293 | Some session ->
381 | Some session ->
@@ -302,13 +390,22 @@ module App =
302 Auth = Identified(identity, Connecting)
390 Auth = Identified(identity, Connecting)
303 Contacts = data.Contacts
391 Contacts = data.Contacts
304 Messages = data.Messages
392 Messages = data.Messages
305− ServerUrl = if data.ServerUrl <> "" then data.ServerUrl else model.ServerUrl
394+ if data.ServerUrl <> "" then
306 PollCursor = data.PollCursor
398 PollCursor = data.PollCursor
307 Profile = profile
399 Profile = profile
308 Page = Conversations }
400 Page = Conversations }
309 model', [ CmdConnect(model'.ServerUrl, identity) ]
402 model', [ CmdConnect(model'.ServerUrl, identity) ]
310 | StateLoaded(Some identity, None, profile) ->
403 | StateLoaded(Some identity, None, profile) ->
311− { model with Auth = Identified(identity, Offline); Profile = profile; Page = Settings }, []
405+ Auth = Identified(identity, Offline)
312 | StateLoaded(None, _, _) -> model, []
409 | StateLoaded(None, _, _) -> model, []
314 // ── mapCmd ──
411 // ── mapCmd ──
@@ -323,18 +420,34 @@ module App =
324 |> Async.Start)
421 |> Async.Start)
326− let private serializeEnvelope = function
423+ let private serializeEnvelope =
327 | Envelope.TextMessage(id, body) ->
425 | Envelope.TextMessage(id, body) ->
328− JsonSerializer.Serialize({| ``type`` = "text"; id = id; body = body |})
426+ JsonSerializer.Serialize(
427+ {| ``type`` = "text"
329 | Envelope.DeliveryAck ackIds ->
431 | Envelope.DeliveryAck ackIds ->
330− JsonSerializer.Serialize({| ``type`` = "delivery.ack"; ack_ids = ackIds |})
432+ JsonSerializer.Serialize(
433+ {| ``type`` = "delivery.ack"
331 | Envelope.ProfileMessage profile ->
436 | Envelope.ProfileMessage profile ->
437+ JsonSerializer.Serialize(
438+ {| ``type`` = "profile"
332− JsonSerializer.Serialize({| ``type`` = "profile"; display_name = profile.DisplayName; bio = profile.Bio; photo = profile.PhotoBase64 |})
439+ display_name = profile.DisplayName
441+ photo = profile.PhotoBase64 |}
334 let private sendEnvelope (session: Session) (recipientHex: string) (envelope: Envelope) =
444 let private sendEnvelope (session: Session) (recipientHex: string) (envelope: Envelope) =
336 let payload = serializeEnvelope envelope
446 let payload = serializeEnvelope envelope
337− let blob = Crypto.encrypt session.Identity.PrivKey (Crypto.fromHex recipientHex) payload
449+ Crypto.encrypt session.Identity.PrivKey (Crypto.fromHex recipientHex) payload
338 let ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
451 let ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
339 do! sendMessage session.Url session.Token recipientHex (Crypto.toHex blob) ts
452 do! sendMessage session.Url session.Token recipientHex (Crypto.toHex blob) ts
@@ -347,26 +460,41 @@ module App =
347 let private parseEnvelope (json: string) =
460 let private parseEnvelope (json: string) =
348 use doc = JsonDocument.Parse(json)
461 use doc = JsonDocument.Parse(json)
349 let root = doc.RootElement
462 let root = doc.RootElement
350 match root.GetProperty("type").GetString() with
464 match root.GetProperty("type").GetString() with
352− Some(Envelope.TextMessage(root.GetProperty("id").GetString(), root.GetProperty("body").GetString()))
465+ | "text" -> Some(Envelope.TextMessage(root.GetProperty("id").GetString(), root.GetProperty("body").GetString()))
353 | "delivery.ack" ->
466 | "delivery.ack" ->
354− let ackIds = root.GetProperty("ack_ids").EnumerateArray() |> Seq.map _.GetString() |> Seq.toList
468+ root.GetProperty("ack_ids").EnumerateArray()
469+ |> Seq.map _.GetString()
355 Some(Envelope.DeliveryAck ackIds)
472 Some(Envelope.DeliveryAck ackIds)
356 | "profile" ->
473 | "profile" ->
357 let displayName = root.GetProperty("display_name").GetString()
474 let displayName = root.GetProperty("display_name").GetString()
358 let bio = tryGetJsonString root "bio" |> Option.defaultValue ""
475 let bio = tryGetJsonString root "bio" |> Option.defaultValue ""
359 let photo = tryGetJsonString root "photo" |> Option.defaultValue ""
476 let photo = tryGetJsonString root "photo" |> Option.defaultValue ""
360− Some(Envelope.ProfileMessage { DisplayName = displayName; Bio = bio; PhotoBase64 = photo })
479+ Envelope.ProfileMessage
480+ { DisplayName = displayName
482+ PhotoBase64 = photo }
361 | _ -> None
484 | _ -> None
363 let private decryptEvent (privKey: byte[]) (payload: EventPayload) =
486 let private decryptEvent (privKey: byte[]) (payload: EventPayload) =
365 let blob = Crypto.fromHex payload.EncryptedBlob
488 let blob = Crypto.fromHex payload.EncryptedBlob
366 match Crypto.decrypt privKey blob with
490 match Crypto.decrypt privKey blob with
367 | Some(plaintext, senderHex) ->
491 | Some(plaintext, senderHex) ->
368 match parseEnvelope plaintext with
492 match parseEnvelope plaintext with
495+ { Sender = senderHex
369− | Some envelope -> Ok { Sender = senderHex; Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(payload.Timestamp); Envelope = envelope }
496+ Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(payload.Timestamp)
497+ Envelope = envelope }
370 | None -> Error "unknown envelope type"
498 | None -> Error "unknown envelope type"
371 | None -> Error "decrypt failed"
499 | None -> Error "decrypt failed"
372 with ex ->
500 with ex ->
@@ -375,75 +503,111 @@ module App =
375 let mapCmd cmdMsg =
503 let mapCmd cmdMsg =
376 match cmdMsg with
504 match cmdMsg with
377 | CmdConnect(url, identity) ->
505 | CmdConnect(url, identity) ->
378− asyncCmd (async {
506+ asyncCmd (
380− let! token = authenticate url identity
509+ let! token = authenticate url identity
381− return AuthOk token
510+ return AuthOk token
382− with ex ->
511+ with ex ->
383− return AuthErr ex.Message
512+ return AuthErr ex.Message
386 | CmdSend(session, recipientHex, envelope) ->
516 | CmdSend(session, recipientHex, envelope) ->
387 Cmd.ofEffect (fun dispatch ->
517 Cmd.ofEffect (fun dispatch ->
389− try do! sendEnvelope session recipientHex envelope
520+ do! sendEnvelope session recipientHex envelope
390− with ex -> dispatch (SendFailed ex.Message)
522+ dispatch (SendFailed ex.Message)
391− } |> Async.Start)
524+ |> Async.Start)
393 | CmdPoll(session, cursor) ->
526 | CmdPoll(session, cursor) ->
394− asyncCmd (async {
527+ asyncCmd (
395− log $"poll start cursor={cursor}"
529+ log $"poll start cursor={cursor}"
397− let! response = poll session.Url session.Token cursor
532+ let! response = poll session.Url session.Token cursor
398− log $"poll response: {response.Events.Length} events, cursor={response.Cursor}"
533+ log $"poll response: {response.Events.Length} events, cursor={response.Cursor}"
400− let ackIds = response.Events |> Array.map _.Id |> Array.toList
535+ let ackIds = response.Events |> Array.map _.Id |> Array.toList
402− let results =
537+ let results =
403− response.Events
538+ response.Events
404− |> Array.choose (fun evt ->
539+ |> Array.choose (fun evt ->
405− match evt.EventType with
540+ match evt.EventType with
406− | Message -> Some(decryptEvent session.Identity.PrivKey evt.Payload)
541+ | Message -> Some(decryptEvent session.Identity.PrivKey evt.Payload)
407− | UnknownEvent s -> log $"unknown event type: {s}"; None)
543+ log $"unknown event type: {s}"
409− let events = results |> Array.choose Result.toOption |> Array.toList
546+ let events = results |> Array.choose Result.toOption |> Array.toList
410− let errors = results |> Array.choose (function Error e -> Some e | _ -> None) |> Array.toList
550+ |> Array.choose (function
411−
551+ | Error e -> Some e
412− for e in errors do log $"decrypt error: {e}"
556+ log $"decrypt error: {e}"
414− // Fire-and-forget: ack server + send delivery acks
558+ // Fire-and-forget: ack server + send delivery acks
415− if not ackIds.IsEmpty || not events.IsEmpty then
559+ if not ackIds.IsEmpty || not events.IsEmpty then
417− if not ackIds.IsEmpty then
561+ if not ackIds.IsEmpty then
418− try do! ackMessages session.Url session.Token ackIds
563+ do! ackMessages session.Url session.Token ackIds
419− with _ -> ()
564+ with _ ->
420− let acksBySender =
567+ let acksBySender =
569+ |> List.choose (fun evt ->
570+ match evt.Envelope with
422− |> List.choose (fun evt -> match evt.Envelope with Envelope.TextMessage(id, _) -> Some(evt.Sender, id) | _ -> None)
571+ | Envelope.TextMessage(id, _) -> Some(evt.Sender, id)
423− |> List.groupBy fst
573+ |> List.groupBy fst
424− |> List.map (fun (sender, pairs) -> sender, List.map snd pairs)
574+ |> List.map (fun (sender, pairs) -> sender, List.map snd pairs)
425− for sender, msgIds in acksBySender do
576+ for sender, msgIds in acksBySender do
426− try do! sendEnvelope session sender (Envelope.DeliveryAck msgIds)
578+ do! sendEnvelope session sender (Envelope.DeliveryAck msgIds)
427− with _ -> ()
579+ with _ ->
428− } |> Async.Start
582+ |> Async.Start
430− if response.Events.Length = 0 then
584+ if response.Events.Length = 0 then
431− do! Async.Sleep 5000
585+ do! Async.Sleep 5000
589+ |> List.sumBy (fun e ->
590+ match e.Envelope with
433− let chatCount = events |> List.sumBy (fun e -> match e.Envelope with Envelope.TextMessage _ -> 1 | _ -> 0)
591+ | Envelope.TextMessage _ -> 1
434− let status =
594+ let status =
435− $"evts:{response.Events.Length} msgs:{chatCount} errs:{errors.Length}"
595+ $"evts:{response.Events.Length} msgs:{chatCount} errs:{errors.Length}"
436− + (match List.tryHead errors with Some e -> $" [{e}]" | None -> "")
596+ + (match List.tryHead errors with
597+ | Some e -> $" [{e}]"
437− return PollResult(events, status, response.Cursor)
600+ return PollResult(events, status, response.Cursor)
439− | :? TimeoutException ->
602+ | :? TimeoutException ->
440− log "poll timeout"
603+ log "poll timeout"
441− return PollResult([], "polling...", cursor)
604+ return PollResult([], "polling...", cursor)
443− log $"poll error: {ex.Message}"
606+ log $"poll error: {ex.Message}"
444− do! Async.Sleep 3000
607+ do! Async.Sleep 3000
445− return PollResult([], $"poll error: {ex.Message}", cursor)
608+ return PollResult([], $"poll error: {ex.Message}", cursor)
448 | CmdCopyToClipboard text ->
612 | CmdCopyToClipboard text ->
449 Cmd.ofEffect (fun _ ->
613 Cmd.ofEffect (fun _ ->
@@ -451,17 +615,17 @@ module App =
451 |> ignore)
615 |> ignore)
453 | CmdLoadState ->
617 | CmdLoadState ->
454− asyncCmd (async {
618+ asyncCmd (
455− match! Store.loadIdentity () with
620+ match! Store.loadIdentity () with
456− | Some id -> return StateLoaded(Some id, Store.loadData (), Store.loadProfile ())
621+ | Some id -> return StateLoaded(Some id, Store.loadData (), Store.loadProfile ())
457− | None -> return StateLoaded(None, None, None)
622+ | None -> return StateLoaded(None, None, None)
460− | CmdSaveIdentity identity ->
461− Cmd.ofEffect (fun _ -> Store.saveIdentity identity |> Async.Start)
626+ | CmdSaveIdentity identity -> Cmd.ofEffect (fun _ -> Store.saveIdentity identity |> Async.Start)
463− | CmdSaveData data ->
464− Cmd.ofEffect (fun _ -> Store.saveData data)
628+ | CmdSaveData data -> Cmd.ofEffect (fun _ -> Store.saveData data)
466 | CmdPickPhoto ->
630 | CmdPickPhoto ->
467 Cmd.ofEffect (fun dispatch ->
631 Cmd.ofEffect (fun dispatch ->
@@ -469,6 +633,7 @@ module App =
471 let! results = Microsoft.Maui.Media.MediaPicker.Default.PickPhotosAsync()
635 let! results = Microsoft.Maui.Media.MediaPicker.Default.PickPhotosAsync()
472 match results |> Seq.tryHead with
637 match results |> Seq.tryHead with
473 | None -> dispatch (PhotoPicked "")
638 | None -> dispatch (PhotoPicked "")
474 | Some file ->
639 | Some file ->
@@ -478,10 +643,10 @@ module App =
478 dispatch (PhotoPicked(Convert.ToBase64String(ms.ToArray())))
643 dispatch (PhotoPicked(Convert.ToBase64String(ms.ToArray())))
479 with _ ->
644 with _ ->
480 dispatch (PhotoPicked "")
645 dispatch (PhotoPicked "")
481− } |> ignore))
647+ |> ignore))
483− | CmdSaveProfile profile ->
484− Cmd.ofEffect (fun _ -> Store.saveProfile profile)
649+ | CmdSaveProfile profile -> Cmd.ofEffect (fun _ -> Store.saveProfile profile)
486 // ── View ──
651 // ── View ──
@@ -489,7 +654,7 @@ module App =
489 VStack(spacing = 4.) {
654 VStack(spacing = 4.) {
490 match error with
655 match error with
491 | Some err ->
656 | Some err ->
492− Label(err).textColor(Colors.Red).font(size = 12.)
657+ Label(err).textColor(Colors.Red).font (size = 12.)
493 Button("Dismiss", DismissError)
658 Button("Dismiss", DismissError)
494 | None -> ()
659 | None -> ()
@@ -497,68 +662,65 @@ module App =
497 let private viewSetup () =
662 let private viewSetup () =
498 ContentPage(
663 ContentPage(
499 (VStack(spacing = 24.) {
664 (VStack(spacing = 24.) {
500− Image("logo.png")
665+ Image("logo.png").margin(8, 0).maximumWidth (300)
504− .centerTextHorizontal()
667+ Label("Skrepka").font(size = 32.).centerTextHorizontal ()
506− Label("End-to-end encrypted messaging")
669+ Label("End-to-end encrypted messaging").font(size = 16.).centerTextHorizontal ()
507− .font(size = 16.)
670+
508− .centerTextHorizontal()
671+
510− Button("Generate Identity", GenIdentity)
672+ button "Generate Identity" GenIdentity
513 .padding(30.)
674 .padding(30.)
514− .centerVertical()
675+ .centerVertical ()
517 let private viewSettings model =
678 let private viewSettings model =
518 ContentPage(
679 ContentPage(
519 ScrollView(
680 ScrollView(
520 (VStack(spacing = 16.) {
681 (VStack(spacing = 16.) {
523− .centerTextHorizontal()
682+ Label("Settings").font(size = 24.).centerTextHorizontal ()
525 match model.Auth with
684 match model.Auth with
526 | Identified(id, conn) ->
685 | Identified(id, conn) ->
527 Label("Your Public Key:")
686 Label("Your Public Key:")
529− Label(truncKey id.PubKeyHex)
688+ Label(truncKey id.PubKeyHex).font (size = 14.)
532 Button("Copy Public Key", CopyPubKey)
690 Button("Copy Public Key", CopyPubKey)
534− Label("Profile:").font(size = 18.)
692+ Label("Profile:").font (size = 18.)
535 let displayName, bio, photo =
694 let displayName, bio, photo =
536 match model.Profile with
695 match model.Profile with
537 | Some p -> p.DisplayName, p.Bio, p.PhotoBase64
696 | Some p -> p.DisplayName, p.Bio, p.PhotoBase64
538 | None -> "", "", ""
697 | None -> "", "", ""
539 if displayName <> "" then
699 if displayName <> "" then
540− Label($"Name: {displayName}").font(size = 14.)
700+ Label($"Name: {displayName}").font (size = 14.)
541 if bio <> "" then
702 if bio <> "" then
542− Label($"Bio: {bio}").font(size = 14.).textColor(Colors.DimGray)
703+ Label($"Bio: {bio}").font(size = 14.).textColor (Colors.DimGray)
543 Button("Edit Profile", SetPage(EditProfile(displayName, bio, photo)))
705 Button("Edit Profile", SetPage(EditProfile(displayName, bio, photo)))
545 Label("Server:")
707 Label("Server:")
547 match conn with
709 match conn with
548− | Online _ -> Label(model.ServerUrl).font(size = 14.)
710+ | Online _ -> Label(model.ServerUrl).font (size = 14.)
549 | _ -> Entry(model.ServerUrl, SetServerUrl)
711 | _ -> Entry(model.ServerUrl, SetServerUrl)
551 viewErrorBanner model.Error
713 viewErrorBanner model.Error
553 match conn with
715 match conn with
554 | Offline -> Button("Connect", DoConnect)
716 | Offline -> Button("Connect", DoConnect)
555− | Connecting -> Label("Connecting...").centerTextHorizontal()
717+ | Connecting -> Label("Connecting...").centerTextHorizontal ()
556 | Online _ ->
718 | Online _ ->
557 Button("Disconnect", DoDisconnect)
719 Button("Disconnect", DoDisconnect)
558 Button("Go to Conversations", SetPage Conversations)
720 Button("Go to Conversations", SetPage Conversations)
559 | NoIdentity -> ()
721 | NoIdentity -> ()
561− .padding(20.)
723+ .padding (20.)
@@ -567,25 +729,31 @@ module App =
567 ScrollView(
729 ScrollView(
568 (VStack(spacing = 8.) {
730 (VStack(spacing = 8.) {
569 HStack(spacing = 8.) {
731 HStack(spacing = 8.) {
570− Label("Conversations")
732+ Label("Conversations").font (size = 24.)
573 Button("+", SetPage(AddContact("", "")))
734 Button("+", SetPage(AddContact("", "")))
574 Button("Settings", SetPage Settings)
735 Button("Settings", SetPage Settings)
739+ match model.Auth with
577− let pubPrefix = match model.Auth with Identified(id, _) -> id.PubKeyHex.[..7] | _ -> "?"
740+ | Identified(id, _) -> id.PubKeyHex.[..7]
578− let pollInfo = if model.PollStatus <> "" then $" | {model.PollStatus}" else ""
744+ if model.PollStatus <> "" then
745+ $" | {model.PollStatus}"
579 Label($"{connStatusLabel model.Auth} [{pubPrefix}]{pollInfo}")
749 Label($"{connStatusLabel model.Auth} [{pubPrefix}]{pollInfo}")
580 .font(size = 10.)
750 .font(size = 10.)
581− .textColor(Colors.DimGray)
751+ .textColor (Colors.DimGray)
583 viewErrorBanner model.Error
753 viewErrorBanner model.Error
585 if model.Contacts.IsEmpty then
755 if model.Contacts.IsEmpty then
586− Label("No contacts yet. Tap + to add one.")
756+ Label("No contacts yet. Tap + to add one.").centerTextHorizontal().textColor (Colors.Gray)
587− .centerTextHorizontal()
588− .textColor(Colors.Gray)
590 for c in model.Contacts.Values do
758 for c in model.Contacts.Values do
591 let preview =
759 let preview =
@@ -596,7 +764,7 @@ module App =
597 Button($"{contactName model.Contacts c.Pubkey}\n{preview}", SetPage(Chat(c.Pubkey, "")))
765 Button($"{contactName model.Contacts c.Pubkey}\n{preview}", SetPage(Chat(c.Pubkey, "")))
599− .padding(20.)
767+ .padding (20.)
@@ -609,17 +777,13 @@ module App =
609 HStack(spacing = 8.) {
777 HStack(spacing = 8.) {
610 Button("< Back", SetPage Conversations)
778 Button("< Back", SetPage Conversations)
614− .centerTextHorizontal()
780+ Label(name).font(size = 20.).centerTextHorizontal ()
617 viewErrorBanner model.Error
783 viewErrorBanner model.Error
619 if msgs.IsEmpty then
785 if msgs.IsEmpty then
620− Label("No messages yet")
786+ Label("No messages yet").padding(8.).font (size = 14.)
624 for m in msgs do
788 for m in msgs do
625 let prefix, tick =
789 let prefix, tick =
@@ -627,18 +791,16 @@ module App =
627 | Outgoing DeliveryStatus.Delivered -> "You: ", " \u2713"
791 | Outgoing DeliveryStatus.Delivered -> "You: ", " \u2713"
628 | Outgoing _ -> "You: ", ""
792 | Outgoing _ -> "You: ", ""
629 | Incoming -> "", ""
793 | Incoming -> "", ""
630− Label($"{prefix}{m.Body}{tick}")
795+ Label($"{prefix}{m.Body}{tick}").padding(8.).font (size = 14.)
634 HStack(spacing = 8.) {
797 HStack(spacing = 8.) {
635− Entry(compose, fun text -> SetPage(Chat(pk, text)))
798+ Entry(compose, fun text -> SetPage(Chat(pk, text))).placeholder ("Message...")
636− .placeholder("Message...")
638 Button("Send", DoSend)
800 Button("Send", DoSend)
641− .padding(12.)
803+ .padding (12.)
644 let private viewAddContact model cpk cnn =
806 let private viewAddContact model cpk cnn =
@@ -647,23 +809,20 @@ module App =
647 HStack(spacing = 8.) {
809 HStack(spacing = 8.) {
648 Button("< Back", SetPage Conversations)
810 Button("< Back", SetPage Conversations)
650− Label("Add Contact")
812+ Label("Add Contact").font (size = 20.)
654 viewErrorBanner model.Error
815 viewErrorBanner model.Error
656 Label("Public Key:")
817 Label("Public Key:")
657− Entry(cpk, fun text -> SetPage(AddContact(text, cnn)))
818+ Entry(cpk, fun text -> SetPage(AddContact(text, cnn))).placeholder ("sampel-palnet-...")
658− .placeholder("sampel-palnet-...")
660 Label("Nickname:")
820 Label("Nickname:")
661 Entry(cnn, fun text -> SetPage(AddContact(cpk, text)))
821 Entry(cnn, fun text -> SetPage(AddContact(cpk, text)))
663− Button("Save Contact", DoSaveContact)
823+ Button("Save Contact", DoSaveContact).centerHorizontal ()
666− .padding(20.)
825+ .padding (20.)
669 let private viewEditProfile model displayName bio photo =
828 let private viewEditProfile model displayName bio photo =
@@ -672,7 +831,7 @@ module App =
672 (VStack(spacing = 16.) {
831 (VStack(spacing = 16.) {
673 HStack(spacing = 8.) {
832 HStack(spacing = 8.) {
674 Button("< Back", SetPage Settings)
833 Button("< Back", SetPage Settings)
675− Label("Edit Profile").font(size = 20.)
834+ Label("Edit Profile").font (size = 20.)
678 viewErrorBanner model.Error
837 viewErrorBanner model.Error
@@ -681,26 +840,23 @@ module App =
681 Image(new MemoryStream(Convert.FromBase64String(photo)) :> Stream)
840 Image(new MemoryStream(Convert.FromBase64String(photo)) :> Stream)
682 .width(120.)
841 .width(120.)
683 .height(120.)
842 .height(120.)
684− .centerHorizontal()
843+ .centerHorizontal ()
688− .centerTextHorizontal()
845+ Label("No Photo").font(size = 16.).centerTextHorizontal().textColor (Colors.Gray)
689− .textColor(Colors.Gray)
691− Button("Change Photo", DoPickPhoto).centerHorizontal()
847+ Button("Change Photo", DoPickPhoto).centerHorizontal ()
693 Label("Display Name:")
849 Label("Display Name:")
694− Entry(displayName, fun text -> SetPage(EditProfile(text, bio, photo)))
850+ Entry(displayName, fun text -> SetPage(EditProfile(text, bio, photo))).placeholder ("Your name")
695− .placeholder("Your name")
697 Label("Bio:")
852 Label("Bio:")
698 Entry(bio, fun text -> SetPage(EditProfile(displayName, text, photo)))
854 Entry(bio, fun text -> SetPage(EditProfile(displayName, text, photo)))
699− .placeholder("Tell something about yourself")
855+ .placeholder ("Tell something about yourself")
701− Button("Save Profile", DoSaveProfile).centerHorizontal()
857+ Button("Save Profile", DoSaveProfile).centerHorizontal ()
703− .padding(20.)
859+ .padding (20.)
@@ -716,6 +872,4 @@ module App =
717 Application() { Window(page) }
873 Application() { Window(page) }
720− Program.statefulWithCmdMsg init update mapCmd
875+ let program = Program.statefulWithCmdMsg init update mapCmd |> Program.withView view
721− |> Program.withView view