on master help sign in
Extract button components into separate module and improve formatting
f1f6e11iko
.vscode/settings.json+1
@@ -1,2 +1,3 @@
1{
1{
2+ "editor.formatOnSave": true
2}
3}
client/App.fs+348−194
@@ -14,6 +14,7 @@ module App =
14 open Store
14 open Store
15 open Crypto
15 open Crypto
16 open ApiClient
16 open ApiClient
17+ open Buttons
17
18
18 // ── Domain Types ──
19 // ── Domain Types ──
19
20
@@ -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
27
28
29+ type Session =
30+ { Url: string
31+ Token: string
28− type Session = { Url: string; Token: string; Identity: Identity }
32+ Identity: Identity }
29
33
30 type ConnStatus =
34 type ConnStatus =
31 | Offline
35 | Offline
@@ -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
44
48
49+ type IncomingEvent =
50+ { Sender: string
45− type IncomingEvent = { Sender: string; Timestamp: DateTimeOffset; Envelope: Envelope }
51+ Timestamp: DateTimeOffset
52+ Envelope: Envelope }
46
53
47 // ── Model ──
54 // ── Model ──
48
55
@@ -93,17 +100,20 @@ module App =
93
100
94 // ── Helpers ──
101 // ── Helpers ──
95
102
96− let private hexToOb (hex: string) =
97− Crypto.fromHex hex |> Phonemic.toOb
103+ let private hexToOb (hex: string) = Crypto.fromHex hex |> Phonemic.toOb
98
104
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('-')
108+
102− if parts.Length <= 4 then ob
109+ if parts.Length <= 4 then
110+ ob
111+ else
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]}"
104
113
105 let private tryParsePubkey (input: string) =
114 let private tryParsePubkey (input: string) =
106 let input = input.Trim()
115 let input = input.Trim()
116+
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 =
119
129
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
132+
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
134+ messages
135+ else
123− else Map.add pk (existing @ [ msg ]) messages
136+ Map.add pk (existing @ [ msg ]) messages
124
137
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
140+
141+ messages
142+ |> Map.change
143+ pk
144+ (Option.map (
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 ->
148+ { m with
129− | Outgoing _ when Set.contains m.Id ackSet -> { m with Direction = Outgoing DeliveryStatus.Delivered }
149+ Direction = Outgoing DeliveryStatus.Delivered }
130− | _ -> m)))
150+ | _ -> m)
151+ ))
131
152
132 let private newContact pubkey nickname : Contact =
153 let private newContact pubkey nickname : Contact =
154+ { Pubkey = pubkey
133− { Pubkey = pubkey; Nickname = nickname; DisplayName = ""; Bio = ""; PhotoBase64 = "" }
155+ Nickname = nickname
156+ DisplayName = ""
157+ Bio = ""
158+ PhotoBase64 = "" }
134
159
135 let private withProfile (p: Profile) (c: Contact) =
160 let private withProfile (p: Profile) (c: Contact) =
161+ { c with
136− { c with DisplayName = p.DisplayName; Bio = p.Bio; PhotoBase64 = p.PhotoBase64 }
162+ DisplayName = p.DisplayName
163+ Bio = p.Bio
164+ PhotoBase64 = p.PhotoBase64 }
137
165
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 =
170+ { Id = id
171+ Body = body
142− { Id = id; Body = body; Timestamp = evt.Timestamp; Direction = Incoming }
172+ Timestamp = evt.Timestamp
173+ Direction = Incoming }
174+
143 { m with
175 { m with
144 Contacts =
176 Contacts =
145− if Map.containsKey evt.Sender m.Contacts then m.Contacts
177+ if Map.containsKey evt.Sender m.Contacts then
178+ m.Contacts
179+ else
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 ->
183+ { m with
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 ->
186+ { m with
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)) }
152
188
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) ->
192+ Some
193+ { Url = model.ServerUrl
194+ Token = token
195+ Identity = id }
156 | _ -> None
196 | _ -> None
157
197
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, _) ->
201+ { model with
160− | Identified(id, _) -> { model with Auth = Identified(id, status) }
202+ Auth = Identified(id, status) }
161 | NoIdentity -> model
203 | NoIdentity -> model
162
204
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 }
211+
166− let private connStatusLabel = function
212+ let private connStatusLabel =
213+ function
214+ | NoIdentity
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"
170
218
@@ -190,33 +238,53 @@ module App =
190
238
191 | GenIdentity ->
239 | GenIdentity ->
192 let identity = Crypto.generateIdentity ()
240 let identity = Crypto.generateIdentity ()
241+
242+ { model with
193− { model with Auth = Identified(identity, Offline); Page = Settings },
243+ Auth = Identified(identity, Offline)
244+ Page = Settings },
194 [ CmdSaveIdentity identity ]
245 [ CmdSaveIdentity identity ]
195
246
196 | SetServerUrl url -> { model with ServerUrl = url }, []
247 | SetServerUrl url -> { model with ServerUrl = url }, []
197
248
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) ]
252+ | NoIdentity ->
253+ { model with
202− | NoIdentity -> { model with Error = Some "No identity generated" }, []
254+ Error = Some "No identity generated" },
255+ []
203
256
204 | AuthOk token ->
257 | AuthOk token ->
205 match model.Auth with
258 match model.Auth with
206 | Identified(id, Connecting) ->
259 | Identified(id, Connecting) ->
260+ let session =
207− let session = { Url = model.ServerUrl; Token = token; Identity = id }
261+ { Url = model.ServerUrl
262+ Token = token
263+ Identity = id }
264+
265+ { model with
208− { model with Auth = Identified(id, Online token); Page = Conversations; Error = None; PollStatus = "polling..." },
266+ Auth = Identified(id, Online token)
267+ Page = Conversations
268+ Error = None
269+ PollStatus = "polling..." },
209 [ CmdPoll(session, model.PollCursor) ]
270 [ CmdPoll(session, model.PollCursor) ]
210 | _ -> model, []
271 | _ -> model, []
211
272
273+ | AuthErr err ->
212− | AuthErr err -> { setConn Offline model with Error = Some err }, []
274+ { setConn Offline model with
275+ Error = Some err },
276+ []
213
277
278+ | DoDisconnect ->
214− | DoDisconnect -> { setConn Offline model with PollStatus = "" }, []
279+ { setConn Offline model with
280+ PollStatus = "" },
281+ []
215
282
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()
287+
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 }, []
242
310
243 | PollResult(events, status, newCursor) ->
311 | PollResult(events, status, newCursor) ->
312+ let model' =
313+ events
314+ |> List.fold
315+ applyEvent
316+ { model with
317+ PollStatus = status
244− let model' = events |> List.fold applyEvent { model with PollStatus = status; PollCursor = newCursor }
318+ PollCursor = newCursor }
245
319
246 model',
320 model',
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
325+ saveCmdMsg model' ]
251
326
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
333+
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 }
267
343
268 model', [ saveCmdMsg model' ]
344 model', [ saveCmdMsg model' ]
345+ | None ->
346+ { model with
269− | None -> { model with Error = Some "Invalid public key" }, []
347+ Error = Some "Invalid public key" },
348+ []
270 | _ -> model, []
349 | _ -> model, []
271
350
272 | CopyPubKey ->
351 | CopyPubKey ->
@@ -280,14 +359,23 @@ module App =
280
359
281 | PhotoPicked photo ->
360 | PhotoPicked photo ->
282 match model.Page with
361 match model.Page with
362+ | EditProfile(name, bio, _) ->
363+ { model with
283− | EditProfile(name, bio, _) -> { model with Page = EditProfile(name, bio, photo) }, []
364+ Page = EditProfile(name, bio, photo) },
365+ []
284 | _ -> model, []
366 | _ -> model, []
285
367
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
373+ Bio = bio
374+ PhotoBase64 = photo }
375+
376+ { model with
290− { model with Profile = Some profile; Page = Settings },
377+ Profile = Some profile
378+ Page = Settings },
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
393+ ServerUrl =
305− ServerUrl = if data.ServerUrl <> "" then data.ServerUrl else model.ServerUrl
394+ if data.ServerUrl <> "" then
395+ data.ServerUrl
396+ else
397+ model.ServerUrl
306 PollCursor = data.PollCursor
398 PollCursor = data.PollCursor
307 Profile = profile
399 Profile = profile
308 Page = Conversations }
400 Page = Conversations }
401+
309 model', [ CmdConnect(model'.ServerUrl, identity) ]
402 model', [ CmdConnect(model'.ServerUrl, identity) ]
310 | StateLoaded(Some identity, None, profile) ->
403 | StateLoaded(Some identity, None, profile) ->
404+ { model with
311− { model with Auth = Identified(identity, Offline); Profile = profile; Page = Settings }, []
405+ Auth = Identified(identity, Offline)
406+ Profile = profile
407+ Page = Settings },
408+ []
312 | StateLoaded(None, _, _) -> model, []
409 | StateLoaded(None, _, _) -> model, []
313
410
314 // ── mapCmd ──
411 // ── mapCmd ──
@@ -323,18 +420,34 @@ module App =
323 }
420 }
324 |> Async.Start)
421 |> Async.Start)
325
422
326− let private serializeEnvelope = function
423+ let private serializeEnvelope =
424+ function
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"
428+ id = id
429+ body = body |}
430+ )
329 | Envelope.DeliveryAck ackIds ->
431 | Envelope.DeliveryAck ackIds ->
330− JsonSerializer.Serialize({| ``type`` = "delivery.ack"; ack_ids = ackIds |})
432+ JsonSerializer.Serialize(
433+ {| ``type`` = "delivery.ack"
434+ ack_ids = ackIds |}
435+ )
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
440+ bio = profile.Bio
441+ photo = profile.PhotoBase64 |}
442+ )
333
443
334 let private sendEnvelope (session: Session) (recipientHex: string) (envelope: Envelope) =
444 let private sendEnvelope (session: Session) (recipientHex: string) (envelope: Envelope) =
335 async {
445 async {
336 let payload = serializeEnvelope envelope
446 let payload = serializeEnvelope envelope
447+
448+ let blob =
337− let blob = Crypto.encrypt session.Identity.PrivKey (Crypto.fromHex recipientHex) payload
449+ Crypto.encrypt session.Identity.PrivKey (Crypto.fromHex recipientHex) payload
450+
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
340 }
453 }
@@ -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
463+
350 match root.GetProperty("type").GetString() with
464 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()))
353 | "delivery.ack" ->
466 | "delivery.ack" ->
467+ let ackIds =
354− let ackIds = root.GetProperty("ack_ids").EnumerateArray() |> Seq.map _.GetString() |> Seq.toList
468+ root.GetProperty("ack_ids").EnumerateArray()
469+ |> Seq.map _.GetString()
470+ |> Seq.toList
471+
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 ""
477+
478+ Some(
360− Some(Envelope.ProfileMessage { DisplayName = displayName; Bio = bio; PhotoBase64 = photo })
479+ Envelope.ProfileMessage
480+ { DisplayName = displayName
481+ Bio = bio
482+ PhotoBase64 = photo }
483+ )
361 | _ -> None
484 | _ -> None
362
485
363 let private decryptEvent (privKey: byte[]) (payload: EventPayload) =
486 let private decryptEvent (privKey: byte[]) (payload: EventPayload) =
364 try
487 try
365 let blob = Crypto.fromHex payload.EncryptedBlob
488 let blob = Crypto.fromHex payload.EncryptedBlob
489+
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
493+ | Some envelope ->
494+ Ok
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 (
507+ async {
379− try
508+ try
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
384− })
513+ }
514+ )
385
515
386 | CmdSend(session, recipientHex, envelope) ->
516 | CmdSend(session, recipientHex, envelope) ->
387 Cmd.ofEffect (fun dispatch ->
517 Cmd.ofEffect (fun dispatch ->
388 async {
518 async {
519+ try
389− try do! sendEnvelope session recipientHex envelope
520+ do! sendEnvelope session recipientHex envelope
521+ with ex ->
390− with ex -> dispatch (SendFailed ex.Message)
522+ dispatch (SendFailed ex.Message)
523+ }
391− } |> Async.Start)
524+ |> Async.Start)
392
525
393 | CmdPoll(session, cursor) ->
526 | CmdPoll(session, cursor) ->
394− asyncCmd (async {
527+ asyncCmd (
528+ async {
395− log $"poll start cursor={cursor}"
529+ log $"poll start cursor={cursor}"
396− try
530+
531+ try
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}"
399−
534+
400− let ackIds = response.Events |> Array.map _.Id |> Array.toList
535+ let ackIds = response.Events |> Array.map _.Id |> Array.toList
401−
536+
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)
542+ | UnknownEvent s ->
407− | UnknownEvent s -> log $"unknown event type: {s}"; None)
543+ log $"unknown event type: {s}"
408−
544+ None)
545+
409− let events = results |> Array.choose Result.toOption |> Array.toList
546+ let events = results |> Array.choose Result.toOption |> Array.toList
547+
548+ let errors =
549+ results
410− let errors = results |> Array.choose (function Error e -> Some e | _ -> None) |> Array.toList
550+ |> Array.choose (function
411−
551+ | Error e -> Some e
552+ | _ -> None)
553+ |> Array.toList
554+
555+ for e in errors do
412− for e in errors do log $"decrypt error: {e}"
556+ log $"decrypt error: {e}"
413−
557+
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
416− async {
560+ async {
417− if not ackIds.IsEmpty then
561+ if not ackIds.IsEmpty then
562+ try
418− try do! ackMessages session.Url session.Token ackIds
563+ do! ackMessages session.Url session.Token ackIds
419− with _ -> ()
564+ with _ ->
565+ ()
566+
420− let acksBySender =
567+ let acksBySender =
421− events
568+ events
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)
572+ | _ -> None)
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)
575+
425− for sender, msgIds in acksBySender do
576+ for sender, msgIds in acksBySender do
577+ try
426− try do! sendEnvelope session sender (Envelope.DeliveryAck msgIds)
578+ do! sendEnvelope session sender (Envelope.DeliveryAck msgIds)
427− with _ -> ()
579+ with _ ->
580+ ()
581+ }
428− } |> Async.Start
582+ |> Async.Start
429−
583+
430− if response.Events.Length = 0 then
584+ if response.Events.Length = 0 then
431− do! Async.Sleep 5000
585+ do! Async.Sleep 5000
432−
586+
587+ let chatCount =
588+ events
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
592+ | _ -> 0)
593+
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}]"
598+ | None -> "")
599+
437− return PollResult(events, status, response.Cursor)
600+ return PollResult(events, status, response.Cursor)
438− with
601+ with
439− | :? TimeoutException ->
602+ | :? TimeoutException ->
440− log "poll timeout"
603+ log "poll timeout"
441− return PollResult([], "polling...", cursor)
604+ return PollResult([], "polling...", cursor)
442− | ex ->
605+ | ex ->
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)
609+ }
446− })
610+ )
447
611
448 | CmdCopyToClipboard text ->
612 | CmdCopyToClipboard text ->
449 Cmd.ofEffect (fun _ ->
613 Cmd.ofEffect (fun _ ->
@@ -451,17 +615,17 @@ module App =
451 |> ignore)
615 |> ignore)
452
616
453 | CmdLoadState ->
617 | CmdLoadState ->
454− asyncCmd (async {
618+ asyncCmd (
619+ async {
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)
458− })
623+ }
624+ )
459
625
460− | CmdSaveIdentity identity ->
461− Cmd.ofEffect (fun _ -> Store.saveIdentity identity |> Async.Start)
626+ | CmdSaveIdentity identity -> Cmd.ofEffect (fun _ -> Store.saveIdentity identity |> Async.Start)
462
627
463− | CmdSaveData data ->
464− Cmd.ofEffect (fun _ -> Store.saveData data)
628+ | CmdSaveData data -> Cmd.ofEffect (fun _ -> Store.saveData data)
465
629
466 | CmdPickPhoto ->
630 | CmdPickPhoto ->
467 Cmd.ofEffect (fun dispatch ->
631 Cmd.ofEffect (fun dispatch ->
@@ -469,6 +633,7 @@ module App =
469 task {
633 task {
470 try
634 try
471 let! results = Microsoft.Maui.Media.MediaPicker.Default.PickPhotosAsync()
635 let! results = Microsoft.Maui.Media.MediaPicker.Default.PickPhotosAsync()
636+
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 "")
646+ }
481− } |> ignore))
647+ |> ignore))
482
648
483− | CmdSaveProfile profile ->
484− Cmd.ofEffect (fun _ -> Store.saveProfile profile)
649+ | CmdSaveProfile profile -> Cmd.ofEffect (fun _ -> Store.saveProfile profile)
485
650
486 // ── View ──
651 // ── View ──
487
652
@@ -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 -> ()
495 }
660 }
@@ -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)
501− .margin(8, 0)
666+
502− Label("Skrepka")
503− .font(size = 32.)
504− .centerTextHorizontal()
667+ Label("Skrepka").font(size = 32.).centerTextHorizontal ()
505−
668+
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+
509−
510− Button("Generate Identity", GenIdentity)
672+ button "Generate Identity" GenIdentity
511− .centerHorizontal()
512 })
673 })
513 .padding(30.)
674 .padding(30.)
514− .centerVertical()
675+ .centerVertical ()
515 )
676 )
516
677
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.) {
521− Label("Settings")
522− .font(size = 24.)
523− .centerTextHorizontal()
682+ Label("Settings").font(size = 24.).centerTextHorizontal ()
524
683
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:")
528
687
529− Label(truncKey id.PubKeyHex)
688+ Label(truncKey id.PubKeyHex).font (size = 14.)
530− .font(size = 14.)
531
689
532 Button("Copy Public Key", CopyPubKey)
690 Button("Copy Public Key", CopyPubKey)
533
691
534− Label("Profile:").font(size = 18.)
692+ Label("Profile:").font (size = 18.)
693+
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 -> "", "", ""
698+
539 if displayName <> "" then
699 if displayName <> "" then
540− Label($"Name: {displayName}").font(size = 14.)
700+ Label($"Name: {displayName}").font (size = 14.)
701+
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)
704+
543 Button("Edit Profile", SetPage(EditProfile(displayName, bio, photo)))
705 Button("Edit Profile", SetPage(EditProfile(displayName, bio, photo)))
544
706
545 Label("Server:")
707 Label("Server:")
546
708
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)
550
712
551 viewErrorBanner model.Error
713 viewErrorBanner model.Error
552
714
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 -> ()
560 })
722 })
561− .padding(20.)
723+ .padding (20.)
562 )
724 )
563 )
725 )
564
726
@@ -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.)
571− .font(size = 24.)
572
733
573 Button("+", SetPage(AddContact("", "")))
734 Button("+", SetPage(AddContact("", "")))
574 Button("Settings", SetPage Settings)
735 Button("Settings", SetPage Settings)
575 }
736 }
576
737
738+ let pubPrefix =
739+ match model.Auth with
577− let pubPrefix = match model.Auth with Identified(id, _) -> id.PubKeyHex.[..7] | _ -> "?"
740+ | Identified(id, _) -> id.PubKeyHex.[..7]
741+ | _ -> "?"
742+
743+ let pollInfo =
578− let pollInfo = if model.PollStatus <> "" then $" | {model.PollStatus}" else ""
744+ if model.PollStatus <> "" then
745+ $" | {model.PollStatus}"
746+ else
747+ ""
748+
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)
582
752
583 viewErrorBanner model.Error
753 viewErrorBanner model.Error
584
754
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)
589
757
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 =
596
764
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, "")))
598 })
766 })
599− .padding(20.)
767+ .padding (20.)
600 )
768 )
601 )
769 )
602
770
@@ -609,17 +777,13 @@ module App =
609 HStack(spacing = 8.) {
777 HStack(spacing = 8.) {
610 Button("< Back", SetPage Conversations)
778 Button("< Back", SetPage Conversations)
611
779
612− Label(name)
613− .font(size = 20.)
614− .centerTextHorizontal()
780+ Label(name).font(size = 20.).centerTextHorizontal ()
615 }
781 }
616
782
617 viewErrorBanner model.Error
783 viewErrorBanner model.Error
618
784
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.)
621− .padding(8.)
622− .font(size = 14.)
623 else
787 else
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 -> "", ""
794+
630− Label($"{prefix}{m.Body}{tick}")
795+ Label($"{prefix}{m.Body}{tick}").padding(8.).font (size = 14.)
631− .padding(8.)
632− .font(size = 14.)
633
796
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...")
637
799
638 Button("Send", DoSend)
800 Button("Send", DoSend)
639 }
801 }
640 })
802 })
641− .padding(12.)
803+ .padding (12.)
642 )
804 )
643
805
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)
649
811
650− Label("Add Contact")
812+ Label("Add Contact").font (size = 20.)
651− .font(size = 20.)
652 }
813 }
653
814
654 viewErrorBanner model.Error
815 viewErrorBanner model.Error
655
816
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-...")
659
819
660 Label("Nickname:")
820 Label("Nickname:")
661 Entry(cnn, fun text -> SetPage(AddContact(cpk, text)))
821 Entry(cnn, fun text -> SetPage(AddContact(cpk, text)))
662
822
663− Button("Save Contact", DoSaveContact)
823+ Button("Save Contact", DoSaveContact).centerHorizontal ()
664− .centerHorizontal()
665 })
824 })
666− .padding(20.)
825+ .padding (20.)
667 )
826 )
668
827
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.)
676 }
835 }
677
836
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 ()
685 else
844 else
686− Label("No Photo")
687− .font(size = 16.)
688− .centerTextHorizontal()
845+ Label("No Photo").font(size = 16.).centerTextHorizontal().textColor (Colors.Gray)
689− .textColor(Colors.Gray)
690
846
691− Button("Change Photo", DoPickPhoto).centerHorizontal()
847+ Button("Change Photo", DoPickPhoto).centerHorizontal ()
692
848
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")
696
851
697 Label("Bio:")
852 Label("Bio:")
853+
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")
700
856
701− Button("Save Profile", DoSaveProfile).centerHorizontal()
857+ Button("Save Profile", DoSaveProfile).centerHorizontal ()
702 })
858 })
703− .padding(20.)
859+ .padding (20.)
704 )
860 )
705 )
861 )
706
862
@@ -716,6 +872,4 @@ module App =
716
872
717 Application() { Window(page) }
873 Application() { Window(page) }
718
874
719− let program =
720− Program.statefulWithCmdMsg init update mapCmd
875+ let program = Program.statefulWithCmdMsg init update mapCmd |> Program.withView view
721− |> 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 @@
25 <Compile Include="ApiClient.fs" />
25 <Compile Include="ApiClient.fs" />
26 <Compile Include="Phonemic.fs" />
26 <Compile Include="Phonemic.fs" />
27 <Compile Include="Store.fs" />
27 <Compile Include="Store.fs" />
28+ <Compile Include="Buttons.fs" />
28 <Compile Include="App.fs" />
29 <Compile Include="App.fs" />
29 <Compile Include="MauiProgram.fs" />
30 <Compile Include="MauiProgram.fs" />
30 </ItemGroup>
31 </ItemGroup>
client/obj/Debug/net10.0-maccatalyst/maccatalyst-arm64/Skrepka.AssemblyInfo.fs+1−1
@@ -10,7 +10,7 @@ open System.Reflection
10[<assembly: System.Reflection.AssemblyCompanyAttribute("Skrepka")>]
10[<assembly: System.Reflection.AssemblyCompanyAttribute("Skrepka")>]
11[<assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")>]
11[<assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")>]
12[<assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")>]
12[<assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")>]
13−[<assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0+d0650888ccf1bf53219901811eb64b7103e14c67")>]
13+[<assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0+25ba3b89c4bece2e286acdc4b8580b192feecab3")>]
14[<assembly: System.Reflection.AssemblyProductAttribute("Skrepka")>]
14[<assembly: System.Reflection.AssemblyProductAttribute("Skrepka")>]
15[<assembly: System.Reflection.AssemblyTitleAttribute("Skrepka")>]
15[<assembly: System.Reflection.AssemblyTitleAttribute("Skrepka")>]
16[<assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")>]
16[<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+}