on master help sign in
Extract button components into separate module and improve formatting
commitf1f6e116e2295466bfea4623e453ffed25d32079 authoriko <mail@iko.soy> dateSun 12 Apr 2026, 08:33 (6 months ago) parent25ba3b8
7 files changed+379−196
.vscode/settings.json+1 client/App.fs+348−194 client/Buttons.fs+14 client/Skrepka.fsproj+1 client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfo.fs+1−1 client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfoInputs.cache+1−1 dotnet-tools.json+13
.vscode/settings.json+1
@@ -1,2 +1,3 @@
11{
2+ "editor.formatOnSave": true
23}
client/App.fs+348−194
@@ -14,6 +14,7 @@ module App =
1414 open Store
1515 open Crypto
1616 open ApiClient
17+ open Buttons
1718
1819 // ── Domain Types ──
1920
@@ -25,7 +26,10 @@ module App =
2526 | Settings
2627 | EditProfile of displayName: string * bio: string * photo: string
2728
28− type Session = { Url: string; Token: string; Identity: Identity }
29+ type Session =
30+ { Url: string
31+ Token: string
32+ Identity: Identity }
2933
3034 type ConnStatus =
3135 | Offline
@@ -42,7 +46,10 @@ module App =
4246 | DeliveryAck of ackIds: string list
4347 | ProfileMessage of profile: Profile
4448
45− type IncomingEvent = { Sender: string; Timestamp: DateTimeOffset; Envelope: Envelope }
49+ type IncomingEvent =
50+ { Sender: string
51+ Timestamp: DateTimeOffset
52+ Envelope: Envelope }
4653
4754 // ── Model ──
4855
@@ -93,17 +100,20 @@ module App =
93100
94101 // ── Helpers ──
95102
96− let private hexToOb (hex: string) =
97− Crypto.fromHex hex |> Phonemic.toOb
103+ let private hexToOb (hex: string) = Crypto.fromHex hex |> Phonemic.toOb
98104
99105 let private truncKey (hex: string) =
100106 let ob = hexToOb hex
101107 let parts = ob.Split('-')
102− if parts.Length <= 4 then ob
103− else $"{parts.[0]}-{parts.[1]}..{parts.[parts.Length - 2]}-{parts.[parts.Length - 1]}"
108+
109+ if parts.Length <= 4 then
110+ ob
111+ else
112+ $"{parts.[0]}-{parts.[1]}..{parts.[parts.Length - 2]}-{parts.[parts.Length - 1]}"
104113
105114 let private tryParsePubkey (input: string) =
106115 let input = input.Trim()
116+
107117 Phonemic.fromOb input
108118 |> Option.orElseWith (fun () -> Crypto.tryFromHex input)
109119 |> Option.filter (fun bytes -> bytes.Length = 32)
@@ -119,52 +129,90 @@ module App =
119129
120130 let private appendMessage (pk: string) (msg: ChatMessage) (messages: Map<string, ChatMessage list>) =
121131 let existing = messagesFor pk messages
122− if existing |> List.exists (fun m -> m.Id = msg.Id) then messages
123− else Map.add pk (existing @ [ msg ]) messages
132+
133+ if existing |> List.exists (fun m -> m.Id = msg.Id) then
134+ messages
135+ else
136+ Map.add pk (existing @ [ msg ]) messages
124137
125138 let private markDelivered (pk: string) (ackIds: string list) (messages: Map<string, ChatMessage list>) =
126139 let ackSet = Set.ofList ackIds
127− messages |> Map.change pk (Option.map (List.map (fun m ->
128− match m.Direction with
129− | Outgoing _ when Set.contains m.Id ackSet -> { m with Direction = Outgoing DeliveryStatus.Delivered }
130− | _ -> m)))
140+
141+ messages
142+ |> Map.change
143+ pk
144+ (Option.map (
145+ List.map (fun m ->
146+ match m.Direction with
147+ | Outgoing _ when Set.contains m.Id ackSet ->
148+ { m with
149+ Direction = Outgoing DeliveryStatus.Delivered }
150+ | _ -> m)
151+ ))
131152
132153 let private newContact pubkey nickname : Contact =
133− { Pubkey = pubkey; Nickname = nickname; DisplayName = ""; Bio = ""; PhotoBase64 = "" }
154+ { Pubkey = pubkey
155+ Nickname = nickname
156+ DisplayName = ""
157+ Bio = ""
158+ PhotoBase64 = "" }
134159
135160 let private withProfile (p: Profile) (c: Contact) =
136− { c with DisplayName = p.DisplayName; Bio = p.Bio; PhotoBase64 = p.PhotoBase64 }
161+ { c with
162+ DisplayName = p.DisplayName
163+ Bio = p.Bio
164+ PhotoBase64 = p.PhotoBase64 }
137165
138166 let private applyEvent (m: Model) (evt: IncomingEvent) =
139167 match evt.Envelope with
140168 | Envelope.TextMessage(id, body) ->
141169 let message =
142− { Id = id; Body = body; Timestamp = evt.Timestamp; Direction = Incoming }
170+ { Id = id
171+ Body = body
172+ Timestamp = evt.Timestamp
173+ Direction = Incoming }
174+
143175 { m with
144176 Contacts =
145− if Map.containsKey evt.Sender m.Contacts then m.Contacts
146− else Map.add evt.Sender (newContact evt.Sender "") m.Contacts
177+ if Map.containsKey evt.Sender m.Contacts then
178+ m.Contacts
179+ else
180+ Map.add evt.Sender (newContact evt.Sender "") m.Contacts
147181 Messages = m.Messages |> appendMessage evt.Sender message }
148182 | Envelope.DeliveryAck ackIds ->
149− { m with Messages = m.Messages |> markDelivered evt.Sender ackIds }
183+ { m with
184+ Messages = m.Messages |> markDelivered evt.Sender ackIds }
150185 | Envelope.ProfileMessage profile ->
151− { m with Contacts = m.Contacts |> Map.change evt.Sender (Option.map (withProfile profile)) }
186+ { m with
187+ Contacts = m.Contacts |> Map.change evt.Sender (Option.map (withProfile profile)) }
152188
153189 let private trySession model =
154190 match model.Auth with
155− | Identified(id, Online token) -> Some { Url = model.ServerUrl; Token = token; Identity = id }
191+ | Identified(id, Online token) ->
192+ Some
193+ { Url = model.ServerUrl
194+ Token = token
195+ Identity = id }
156196 | _ -> None
157197
158198 let private setConn status model =
159199 match model.Auth with
160− | Identified(id, _) -> { model with Auth = Identified(id, status) }
200+ | Identified(id, _) ->
201+ { model with
202+ Auth = Identified(id, status) }
161203 | NoIdentity -> model
162204
163205 let private saveCmdMsg model =
164− CmdSaveData { Contacts = model.Contacts; Messages = model.Messages; ServerUrl = model.ServerUrl; PollCursor = model.PollCursor }
165−
166− let private connStatusLabel = function
167− | NoIdentity | Identified(_, Offline) -> "OFFLINE"
206+ CmdSaveData
207+ { Contacts = model.Contacts
208+ Messages = model.Messages
209+ ServerUrl = model.ServerUrl
210+ PollCursor = model.PollCursor }
211+
212+ let private connStatusLabel =
213+ function
214+ | NoIdentity
215+ | Identified(_, Offline) -> "OFFLINE"
168216 | Identified(_, Connecting) -> "CONNECTING"
169217 | Identified(_, Online _) -> "ONLINE"
170218
@@ -190,33 +238,53 @@ module App =
190238
191239 | GenIdentity ->
192240 let identity = Crypto.generateIdentity ()
193− { model with Auth = Identified(identity, Offline); Page = Settings },
241+
242+ { model with
243+ Auth = Identified(identity, Offline)
244+ Page = Settings },
194245 [ CmdSaveIdentity identity ]
195246
196247 | SetServerUrl url -> { model with ServerUrl = url }, []
197248
198249 | DoConnect ->
199250 match model.Auth with
200− | Identified(id, _) ->
201− model |> setConn Connecting, [ CmdConnect(model.ServerUrl, id) ]
202− | NoIdentity -> { model with Error = Some "No identity generated" }, []
251+ | Identified(id, _) -> model |> setConn Connecting, [ CmdConnect(model.ServerUrl, id) ]
252+ | NoIdentity ->
253+ { model with
254+ Error = Some "No identity generated" },
255+ []
203256
204257 | AuthOk token ->
205258 match model.Auth with
206259 | Identified(id, Connecting) ->
207− let session = { Url = model.ServerUrl; Token = token; Identity = id }
208− { model with Auth = Identified(id, Online token); Page = Conversations; Error = None; PollStatus = "polling..." },
260+ let session =
261+ { Url = model.ServerUrl
262+ Token = token
263+ Identity = id }
264+
265+ { model with
266+ Auth = Identified(id, Online token)
267+ Page = Conversations
268+ Error = None
269+ PollStatus = "polling..." },
209270 [ CmdPoll(session, model.PollCursor) ]
210271 | _ -> model, []
211272
212− | AuthErr err -> { setConn Offline model with Error = Some err }, []
273+ | AuthErr err ->
274+ { setConn Offline model with
275+ Error = Some err },
276+ []
213277
214− | DoDisconnect -> { setConn Offline model with PollStatus = "" }, []
278+ | DoDisconnect ->
279+ { setConn Offline model with
280+ PollStatus = "" },
281+ []
215282
216283 | DoSend ->
217284 match model.Page, trySession model with
218285 | Chat(pk, compose), Some session when compose <> "" ->
219286 let id = Guid.NewGuid().ToString()
287+
220288 let outMsg =
221289 { Id = id
222290 Body = compose
@@ -241,13 +309,20 @@ module App =
241309 | SendFailed err -> { model with Error = Some err }, []
242310
243311 | PollResult(events, status, newCursor) ->
244− let model' = events |> List.fold applyEvent { model with PollStatus = status; PollCursor = newCursor }
312+ let model' =
313+ events
314+ |> List.fold
315+ applyEvent
316+ { model with
317+ PollStatus = status
318+ PollCursor = newCursor }
245319
246320 model',
247321 [ match trySession model' with
248322 | Some session -> CmdPoll(session, model'.PollCursor)
249323 | None -> ()
250− if not events.IsEmpty then saveCmdMsg model' ]
324+ if not events.IsEmpty then
325+ saveCmdMsg model' ]
251326
252327 | DoSaveContact ->
253328 match model.Page with
@@ -255,6 +330,7 @@ module App =
255330 match tryParsePubkey cpk with
256331 | Some bytes ->
257332 let hex = Crypto.toHex bytes
333+
258334 let contact =
259335 match Map.tryFind hex model.Contacts with
260336 | Some e -> { e with Nickname = cnn }
@@ -266,7 +342,10 @@ module App =
266342 Page = Conversations }
267343
268344 model', [ saveCmdMsg model' ]
269− | None -> { model with Error = Some "Invalid public key" }, []
345+ | None ->
346+ { model with
347+ Error = Some "Invalid public key" },
348+ []
270349 | _ -> model, []
271350
272351 | CopyPubKey ->
@@ -280,14 +359,23 @@ module App =
280359
281360 | PhotoPicked photo ->
282361 match model.Page with
283− | EditProfile(name, bio, _) -> { model with Page = EditProfile(name, bio, photo) }, []
362+ | EditProfile(name, bio, _) ->
363+ { model with
364+ Page = EditProfile(name, bio, photo) },
365+ []
284366 | _ -> model, []
285367
286368 | DoSaveProfile ->
287369 match model.Page with
288370 | EditProfile(name, bio, photo) ->
289− let profile: Profile = { DisplayName = name; Bio = bio; PhotoBase64 = photo }
290− { model with Profile = Some profile; Page = Settings },
371+ let profile: Profile =
372+ { DisplayName = name
373+ Bio = bio
374+ PhotoBase64 = photo }
375+
376+ { model with
377+ Profile = Some profile
378+ Page = Settings },
291379 [ CmdSaveProfile profile
292380 match trySession model with
293381 | Some session ->
@@ -302,13 +390,22 @@ module App =
302390 Auth = Identified(identity, Connecting)
303391 Contacts = data.Contacts
304392 Messages = data.Messages
305− ServerUrl = if data.ServerUrl <> "" then data.ServerUrl else model.ServerUrl
393+ ServerUrl =
394+ if data.ServerUrl <> "" then
395+ data.ServerUrl
396+ else
397+ model.ServerUrl
306398 PollCursor = data.PollCursor
307399 Profile = profile
308400 Page = Conversations }
401+
309402 model', [ CmdConnect(model'.ServerUrl, identity) ]
310403 | StateLoaded(Some identity, None, profile) ->
311− { model with Auth = Identified(identity, Offline); Profile = profile; Page = Settings }, []
404+ { model with
405+ Auth = Identified(identity, Offline)
406+ Profile = profile
407+ Page = Settings },
408+ []
312409 | StateLoaded(None, _, _) -> model, []
313410
314411 // ── mapCmd ──
@@ -323,18 +420,34 @@ module App =
323420 }
324421 |> Async.Start)
325422
326− let private serializeEnvelope = function
423+ let private serializeEnvelope =
424+ function
327425 | Envelope.TextMessage(id, body) ->
328− JsonSerializer.Serialize({| ``type`` = "text"; id = id; body = body |})
426+ JsonSerializer.Serialize(
427+ {| ``type`` = "text"
428+ id = id
429+ body = body |}
430+ )
329431 | Envelope.DeliveryAck ackIds ->
330− JsonSerializer.Serialize({| ``type`` = "delivery.ack"; ack_ids = ackIds |})
432+ JsonSerializer.Serialize(
433+ {| ``type`` = "delivery.ack"
434+ ack_ids = ackIds |}
435+ )
331436 | Envelope.ProfileMessage profile ->
332− JsonSerializer.Serialize({| ``type`` = "profile"; display_name = profile.DisplayName; bio = profile.Bio; photo = profile.PhotoBase64 |})
437+ JsonSerializer.Serialize(
438+ {| ``type`` = "profile"
439+ display_name = profile.DisplayName
440+ bio = profile.Bio
441+ photo = profile.PhotoBase64 |}
442+ )
333443
334444 let private sendEnvelope (session: Session) (recipientHex: string) (envelope: Envelope) =
335445 async {
336446 let payload = serializeEnvelope envelope
337− let blob = Crypto.encrypt session.Identity.PrivKey (Crypto.fromHex recipientHex) payload
447+
448+ let blob =
449+ Crypto.encrypt session.Identity.PrivKey (Crypto.fromHex recipientHex) payload
450+
338451 let ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
339452 do! sendMessage session.Url session.Token recipientHex (Crypto.toHex blob) ts
340453 }
@@ -347,26 +460,41 @@ module App =
347460 let private parseEnvelope (json: string) =
348461 use doc = JsonDocument.Parse(json)
349462 let root = doc.RootElement
463+
350464 match root.GetProperty("type").GetString() with
351− | "text" ->
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()))
353466 | "delivery.ack" ->
354− let ackIds = root.GetProperty("ack_ids").EnumerateArray() |> Seq.map _.GetString() |> Seq.toList
467+ let ackIds =
468+ root.GetProperty("ack_ids").EnumerateArray()
469+ |> Seq.map _.GetString()
470+ |> Seq.toList
471+
355472 Some(Envelope.DeliveryAck ackIds)
356473 | "profile" ->
357474 let displayName = root.GetProperty("display_name").GetString()
358475 let bio = tryGetJsonString root "bio" |> Option.defaultValue ""
359476 let photo = tryGetJsonString root "photo" |> Option.defaultValue ""
360− Some(Envelope.ProfileMessage { DisplayName = displayName; Bio = bio; PhotoBase64 = photo })
477+
478+ Some(
479+ Envelope.ProfileMessage
480+ { DisplayName = displayName
481+ Bio = bio
482+ PhotoBase64 = photo }
483+ )
361484 | _ -> None
362485
363486 let private decryptEvent (privKey: byte[]) (payload: EventPayload) =
364487 try
365488 let blob = Crypto.fromHex payload.EncryptedBlob
489+
366490 match Crypto.decrypt privKey blob with
367491 | Some(plaintext, senderHex) ->
368492 match parseEnvelope plaintext with
369− | Some envelope -> Ok { Sender = senderHex; Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(payload.Timestamp); Envelope = envelope }
493+ | Some envelope ->
494+ Ok
495+ { Sender = senderHex
496+ Timestamp = DateTimeOffset.FromUnixTimeMilliseconds(payload.Timestamp)
497+ Envelope = envelope }
370498 | None -> Error "unknown envelope type"
371499 | None -> Error "decrypt failed"
372500 with ex ->
@@ -375,75 +503,111 @@ module App =
375503 let mapCmd cmdMsg =
376504 match cmdMsg with
377505 | CmdConnect(url, identity) ->
378− asyncCmd (async {
379− try
380− let! token = authenticate url identity
381− return AuthOk token
382− with ex ->
383− return AuthErr ex.Message
384− })
506+ asyncCmd (
507+ async {
508+ try
509+ let! token = authenticate url identity
510+ return AuthOk token
511+ with ex ->
512+ return AuthErr ex.Message
513+ }
514+ )
385515
386516 | CmdSend(session, recipientHex, envelope) ->
387517 Cmd.ofEffect (fun dispatch ->
388518 async {
389− try do! sendEnvelope session recipientHex envelope
390− with ex -> dispatch (SendFailed ex.Message)
391− } |> Async.Start)
519+ try
520+ do! sendEnvelope session recipientHex envelope
521+ with ex ->
522+ dispatch (SendFailed ex.Message)
523+ }
524+ |> Async.Start)
392525
393526 | CmdPoll(session, cursor) ->
394− asyncCmd (async {
395− log $"poll start cursor={cursor}"
396− try
397− let! response = poll session.Url session.Token cursor
398− log $"poll response: {response.Events.Length} events, cursor={response.Cursor}"
399−
400− let ackIds = response.Events |> Array.map _.Id |> Array.toList
401−
402− let results =
403− response.Events
404− |> Array.choose (fun evt ->
405− match evt.EventType with
406− | Message -> Some(decryptEvent session.Identity.PrivKey evt.Payload)
407− | UnknownEvent s -> log $"unknown event type: {s}"; None)
408−
409− let events = results |> Array.choose Result.toOption |> Array.toList
410− let errors = results |> Array.choose (function Error e -> Some e | _ -> None) |> Array.toList
411−
412− for e in errors do log $"decrypt error: {e}"
413−
414− // Fire-and-forget: ack server + send delivery acks
415− if not ackIds.IsEmpty || not events.IsEmpty then
416− async {
417− if not ackIds.IsEmpty then
418− try do! ackMessages session.Url session.Token ackIds
419− with _ -> ()
420− let acksBySender =
421− events
422− |> List.choose (fun evt -> match evt.Envelope with Envelope.TextMessage(id, _) -> Some(evt.Sender, id) | _ -> None)
423− |> List.groupBy fst
424− |> List.map (fun (sender, pairs) -> sender, List.map snd pairs)
425− for sender, msgIds in acksBySender do
426− try do! sendEnvelope session sender (Envelope.DeliveryAck msgIds)
427− with _ -> ()
428− } |> Async.Start
429−
430− if response.Events.Length = 0 then
431− do! Async.Sleep 5000
432−
433− let chatCount = events |> List.sumBy (fun e -> match e.Envelope with Envelope.TextMessage _ -> 1 | _ -> 0)
434− let status =
435− $"evts:{response.Events.Length} msgs:{chatCount} errs:{errors.Length}"
436− + (match List.tryHead errors with Some e -> $" [{e}]" | None -> "")
437− return PollResult(events, status, response.Cursor)
438− with
439− | :? TimeoutException ->
440− log "poll timeout"
441− return PollResult([], "polling...", cursor)
442− | ex ->
443− log $"poll error: {ex.Message}"
444− do! Async.Sleep 3000
445− return PollResult([], $"poll error: {ex.Message}", cursor)
446− })
527+ asyncCmd (
528+ async {
529+ log $"poll start cursor={cursor}"
530+
531+ try
532+ let! response = poll session.Url session.Token cursor
533+ log $"poll response: {response.Events.Length} events, cursor={response.Cursor}"
534+
535+ let ackIds = response.Events |> Array.map _.Id |> Array.toList
536+
537+ let results =
538+ response.Events
539+ |> Array.choose (fun evt ->
540+ match evt.EventType with
541+ | Message -> Some(decryptEvent session.Identity.PrivKey evt.Payload)
542+ | UnknownEvent s ->
543+ log $"unknown event type: {s}"
544+ None)
545+
546+ let events = results |> Array.choose Result.toOption |> Array.toList
547+
548+ let errors =
549+ results
550+ |> Array.choose (function
551+ | Error e -> Some e
552+ | _ -> None)
553+ |> Array.toList
554+
555+ for e in errors do
556+ log $"decrypt error: {e}"
557+
558+ // Fire-and-forget: ack server + send delivery acks
559+ if not ackIds.IsEmpty || not events.IsEmpty then
560+ async {
561+ if not ackIds.IsEmpty then
562+ try
563+ do! ackMessages session.Url session.Token ackIds
564+ with _ ->
565+ ()
566+
567+ let acksBySender =
568+ events
569+ |> List.choose (fun evt ->
570+ match evt.Envelope with
571+ | Envelope.TextMessage(id, _) -> Some(evt.Sender, id)
572+ | _ -> None)
573+ |> List.groupBy fst
574+ |> List.map (fun (sender, pairs) -> sender, List.map snd pairs)
575+
576+ for sender, msgIds in acksBySender do
577+ try
578+ do! sendEnvelope session sender (Envelope.DeliveryAck msgIds)
579+ with _ ->
580+ ()
581+ }
582+ |> Async.Start
583+
584+ if response.Events.Length = 0 then
585+ do! Async.Sleep 5000
586+
587+ let chatCount =
588+ events
589+ |> List.sumBy (fun e ->
590+ match e.Envelope with
591+ | Envelope.TextMessage _ -> 1
592+ | _ -> 0)
593+
594+ let status =
595+ $"evts:{response.Events.Length} msgs:{chatCount} errs:{errors.Length}"
596+ + (match List.tryHead errors with
597+ | Some e -> $" [{e}]"
598+ | None -> "")
599+
600+ return PollResult(events, status, response.Cursor)
601+ with
602+ | :? TimeoutException ->
603+ log "poll timeout"
604+ return PollResult([], "polling...", cursor)
605+ | ex ->
606+ log $"poll error: {ex.Message}"
607+ do! Async.Sleep 3000
608+ return PollResult([], $"poll error: {ex.Message}", cursor)
609+ }
610+ )
447611
448612 | CmdCopyToClipboard text ->
449613 Cmd.ofEffect (fun _ ->
@@ -451,17 +615,17 @@ module App =
451615 |> ignore)
452616
453617 | CmdLoadState ->
454− asyncCmd (async {
455− match! Store.loadIdentity () with
456− | Some id -> return StateLoaded(Some id, Store.loadData (), Store.loadProfile ())
457− | None -> return StateLoaded(None, None, None)
458− })
618+ asyncCmd (
619+ async {
620+ match! Store.loadIdentity () with
621+ | Some id -> return StateLoaded(Some id, Store.loadData (), Store.loadProfile ())
622+ | None -> return StateLoaded(None, None, None)
623+ }
624+ )
459625
460− | CmdSaveIdentity identity ->
461− Cmd.ofEffect (fun _ -> Store.saveIdentity identity |> Async.Start)
626+ | CmdSaveIdentity identity -> Cmd.ofEffect (fun _ -> Store.saveIdentity identity |> Async.Start)
462627
463− | CmdSaveData data ->
464− Cmd.ofEffect (fun _ -> Store.saveData data)
628+ | CmdSaveData data -> Cmd.ofEffect (fun _ -> Store.saveData data)
465629
466630 | CmdPickPhoto ->
467631 Cmd.ofEffect (fun dispatch ->
@@ -469,6 +633,7 @@ module App =
469633 task {
470634 try
471635 let! results = Microsoft.Maui.Media.MediaPicker.Default.PickPhotosAsync()
636+
472637 match results |> Seq.tryHead with
473638 | None -> dispatch (PhotoPicked "")
474639 | Some file ->
@@ -478,10 +643,10 @@ module App =
478643 dispatch (PhotoPicked(Convert.ToBase64String(ms.ToArray())))
479644 with _ ->
480645 dispatch (PhotoPicked "")
481− } |> ignore))
646+ }
647+ |> ignore))
482648
483− | CmdSaveProfile profile ->
484− Cmd.ofEffect (fun _ -> Store.saveProfile profile)
649+ | CmdSaveProfile profile -> Cmd.ofEffect (fun _ -> Store.saveProfile profile)
485650
486651 // ── View ──
487652
@@ -489,7 +654,7 @@ module App =
489654 VStack(spacing = 4.) {
490655 match error with
491656 | Some err ->
492− Label(err).textColor(Colors.Red).font(size = 12.)
657+ Label(err).textColor(Colors.Red).font (size = 12.)
493658 Button("Dismiss", DismissError)
494659 | None -> ()
495660 }
@@ -497,68 +662,65 @@ module App =
497662 let private viewSetup () =
498663 ContentPage(
499664 (VStack(spacing = 24.) {
500− Image("logo.png")
501− .margin(8, 0)
502− Label("Skrepka")
503− .font(size = 32.)
504− .centerTextHorizontal()
505−
506− Label("End-to-end encrypted messaging")
507− .font(size = 16.)
508− .centerTextHorizontal()
509−
510− Button("Generate Identity", GenIdentity)
511− .centerHorizontal()
665+ Image("logo.png").margin(8, 0).maximumWidth (300)
666+
667+ Label("Skrepka").font(size = 32.).centerTextHorizontal ()
668+
669+ Label("End-to-end encrypted messaging").font(size = 16.).centerTextHorizontal ()
670+
671+
672+ button "Generate Identity" GenIdentity
512673 })
513674 .padding(30.)
514− .centerVertical()
675+ .centerVertical ()
515676 )
516677
517678 let private viewSettings model =
518679 ContentPage(
519680 ScrollView(
520681 (VStack(spacing = 16.) {
521− Label("Settings")
522− .font(size = 24.)
523− .centerTextHorizontal()
682+ Label("Settings").font(size = 24.).centerTextHorizontal ()
524683
525684 match model.Auth with
526685 | Identified(id, conn) ->
527686 Label("Your Public Key:")
528687
529− Label(truncKey id.PubKeyHex)
530− .font(size = 14.)
688+ Label(truncKey id.PubKeyHex).font (size = 14.)
531689
532690 Button("Copy Public Key", CopyPubKey)
533691
534− Label("Profile:").font(size = 18.)
692+ Label("Profile:").font (size = 18.)
693+
535694 let displayName, bio, photo =
536695 match model.Profile with
537696 | Some p -> p.DisplayName, p.Bio, p.PhotoBase64
538697 | None -> "", "", ""
698+
539699 if displayName <> "" then
540− Label($"Name: {displayName}").font(size = 14.)
700+ Label($"Name: {displayName}").font (size = 14.)
701+
541702 if bio <> "" then
542− Label($"Bio: {bio}").font(size = 14.).textColor(Colors.DimGray)
703+ Label($"Bio: {bio}").font(size = 14.).textColor (Colors.DimGray)
704+
543705 Button("Edit Profile", SetPage(EditProfile(displayName, bio, photo)))
544706
545707 Label("Server:")
546708
547709 match conn with
548− | Online _ -> Label(model.ServerUrl).font(size = 14.)
710+ | Online _ -> Label(model.ServerUrl).font (size = 14.)
549711 | _ -> Entry(model.ServerUrl, SetServerUrl)
550712
551713 viewErrorBanner model.Error
552714
553715 match conn with
554716 | Offline -> Button("Connect", DoConnect)
555− | Connecting -> Label("Connecting...").centerTextHorizontal()
717+ | Connecting -> Label("Connecting...").centerTextHorizontal ()
556718 | Online _ ->
557719 Button("Disconnect", DoDisconnect)
558720 Button("Go to Conversations", SetPage Conversations)
559721 | NoIdentity -> ()
560722 })
561− .padding(20.)
723+ .padding (20.)
562724 )
563725 )
564726
@@ -567,25 +729,31 @@ module App =
567729 ScrollView(
568730 (VStack(spacing = 8.) {
569731 HStack(spacing = 8.) {
570− Label("Conversations")
571− .font(size = 24.)
732+ Label("Conversations").font (size = 24.)
572733
573734 Button("+", SetPage(AddContact("", "")))
574735 Button("Settings", SetPage Settings)
575736 }
576737
577− let pubPrefix = match model.Auth with Identified(id, _) -> id.PubKeyHex.[..7] | _ -> "?"
578− let pollInfo = if model.PollStatus <> "" then $" | {model.PollStatus}" else ""
738+ let pubPrefix =
739+ match model.Auth with
740+ | Identified(id, _) -> id.PubKeyHex.[..7]
741+ | _ -> "?"
742+
743+ let pollInfo =
744+ if model.PollStatus <> "" then
745+ $" | {model.PollStatus}"
746+ else
747+ ""
748+
579749 Label($"{connStatusLabel model.Auth} [{pubPrefix}]{pollInfo}")
580750 .font(size = 10.)
581− .textColor(Colors.DimGray)
751+ .textColor (Colors.DimGray)
582752
583753 viewErrorBanner model.Error
584754
585755 if model.Contacts.IsEmpty then
586− Label("No contacts yet. Tap + to add one.")
587− .centerTextHorizontal()
588− .textColor(Colors.Gray)
756+ Label("No contacts yet. Tap + to add one.").centerTextHorizontal().textColor (Colors.Gray)
589757
590758 for c in model.Contacts.Values do
591759 let preview =
@@ -596,7 +764,7 @@ module App =
596764
597765 Button($"{contactName model.Contacts c.Pubkey}\n{preview}", SetPage(Chat(c.Pubkey, "")))
598766 })
599− .padding(20.)
767+ .padding (20.)
600768 )
601769 )
602770
@@ -609,17 +777,13 @@ module App =
609777 HStack(spacing = 8.) {
610778 Button("< Back", SetPage Conversations)
611779
612− Label(name)
613− .font(size = 20.)
614− .centerTextHorizontal()
780+ Label(name).font(size = 20.).centerTextHorizontal ()
615781 }
616782
617783 viewErrorBanner model.Error
618784
619785 if msgs.IsEmpty then
620− Label("No messages yet")
621− .padding(8.)
622− .font(size = 14.)
786+ Label("No messages yet").padding(8.).font (size = 14.)
623787 else
624788 for m in msgs do
625789 let prefix, tick =
@@ -627,18 +791,16 @@ module App =
627791 | Outgoing DeliveryStatus.Delivered -> "You: ", " \u2713"
628792 | Outgoing _ -> "You: ", ""
629793 | Incoming -> "", ""
630− Label($"{prefix}{m.Body}{tick}")
631− .padding(8.)
632− .font(size = 14.)
794+
795+ Label($"{prefix}{m.Body}{tick}").padding(8.).font (size = 14.)
633796
634797 HStack(spacing = 8.) {
635− Entry(compose, fun text -> SetPage(Chat(pk, text)))
636− .placeholder("Message...")
798+ Entry(compose, fun text -> SetPage(Chat(pk, text))).placeholder ("Message...")
637799
638800 Button("Send", DoSend)
639801 }
640802 })
641− .padding(12.)
803+ .padding (12.)
642804 )
643805
644806 let private viewAddContact model cpk cnn =
@@ -647,23 +809,20 @@ module App =
647809 HStack(spacing = 8.) {
648810 Button("< Back", SetPage Conversations)
649811
650− Label("Add Contact")
651− .font(size = 20.)
812+ Label("Add Contact").font (size = 20.)
652813 }
653814
654815 viewErrorBanner model.Error
655816
656817 Label("Public Key:")
657− Entry(cpk, fun text -> SetPage(AddContact(text, cnn)))
658− .placeholder("sampel-palnet-...")
818+ Entry(cpk, fun text -> SetPage(AddContact(text, cnn))).placeholder ("sampel-palnet-...")
659819
660820 Label("Nickname:")
661821 Entry(cnn, fun text -> SetPage(AddContact(cpk, text)))
662822
663− Button("Save Contact", DoSaveContact)
664− .centerHorizontal()
823+ Button("Save Contact", DoSaveContact).centerHorizontal ()
665824 })
666− .padding(20.)
825+ .padding (20.)
667826 )
668827
669828 let private viewEditProfile model displayName bio photo =
@@ -672,7 +831,7 @@ module App =
672831 (VStack(spacing = 16.) {
673832 HStack(spacing = 8.) {
674833 Button("< Back", SetPage Settings)
675− Label("Edit Profile").font(size = 20.)
834+ Label("Edit Profile").font (size = 20.)
676835 }
677836
678837 viewErrorBanner model.Error
@@ -681,26 +840,23 @@ module App =
681840 Image(new MemoryStream(Convert.FromBase64String(photo)) :> Stream)
682841 .width(120.)
683842 .height(120.)
684− .centerHorizontal()
843+ .centerHorizontal ()
685844 else
686− Label("No Photo")
687− .font(size = 16.)
688− .centerTextHorizontal()
689− .textColor(Colors.Gray)
845+ Label("No Photo").font(size = 16.).centerTextHorizontal().textColor (Colors.Gray)
690846
691− Button("Change Photo", DoPickPhoto).centerHorizontal()
847+ Button("Change Photo", DoPickPhoto).centerHorizontal ()
692848
693849 Label("Display Name:")
694− Entry(displayName, fun text -> SetPage(EditProfile(text, bio, photo)))
695− .placeholder("Your name")
850+ Entry(displayName, fun text -> SetPage(EditProfile(text, bio, photo))).placeholder ("Your name")
696851
697852 Label("Bio:")
853+
698854 Entry(bio, fun text -> SetPage(EditProfile(displayName, text, photo)))
699− .placeholder("Tell something about yourself")
855+ .placeholder ("Tell something about yourself")
700856
701− Button("Save Profile", DoSaveProfile).centerHorizontal()
857+ Button("Save Profile", DoSaveProfile).centerHorizontal ()
702858 })
703− .padding(20.)
859+ .padding (20.)
704860 )
705861 )
706862
@@ -716,6 +872,4 @@ module App =
716872
717873 Application() { Window(page) }
718874
719− let program =
720− Program.statefulWithCmdMsg init update mapCmd
721− |> Program.withView view
875+ let program = Program.statefulWithCmdMsg init update mapCmd |> Program.withView view
client/Buttons.fsnew file+14
@@ -0,0 +1,14 @@
1+namespace Skrepka
2+
3+open System
4+open System.IO
5+open System.Text.Json
6+open Fabulous
7+open Fabulous.Maui
8+open Microsoft.Maui.Graphics
9+
10+open type Fabulous.Maui.View
11+
12+module Buttons =
13+ let button text msg =
14+ Border(Label(text)).width(4).gestureRecognizers () { TapGestureRecognizer(msg) }
client/Skrepka.fsproj+1
@@ -25,6 +25,7 @@
2525 <Compile Include="ApiClient.fs" />
2626 <Compile Include="Phonemic.fs" />
2727 <Compile Include="Store.fs" />
28+ <Compile Include="Buttons.fs" />
2829 <Compile Include="App.fs" />
2930 <Compile Include="MauiProgram.fs" />
3031 </ItemGroup>
client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfo.fs+1−1
@@ -10,7 +10,7 @@ open System.Reflection
1010[<assembly: System.Reflection.AssemblyCompanyAttribute("Skrepka")>]
1111[<assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")>]
1212[<assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")>]
13−[<assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0+d0650888ccf1bf53219901811eb64b7103e14c67")>]
13+[<assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0+25ba3b89c4bece2e286acdc4b8580b192feecab3")>]
1414[<assembly: System.Reflection.AssemblyProductAttribute("Skrepka")>]
1515[<assembly: System.Reflection.AssemblyTitleAttribute("Skrepka")>]
1616[<assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")>]
client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfoInputs.cache+1−1
@@ -1 +1 @@
1−02b5fa3ae3a2beecc1a0aeab16c12023ba39fb11a33f42458f1f8580fa272b24
1+5d8b947be179b38191ae86d14581cb7ade1a8c6c2068a71f3fb4f519ddb5bdaa
dotnet-tools.jsonnew file+13
@@ -0,0 +1,13 @@
1+{
2+ "version": 1,
3+ "isRoot": true,
4+ "tools": {
5+ "fantomas": {
6+ "version": "7.0.5",
7+ "commands": [
8+ "fantomas"
9+ ],
10+ "rollForward": false
11+ }
12+ }
13+}