on master help sign in
Improve type safety: use MessageDirection discriminated union, simplify polling

Replace ChatMessage's IsOutgoing bool + Status fields with MessageDirection ADT (Incoming | Outgoing DeliveryStatus) to encode message state at the type level. Consolidate ack and profile delivery logic into polling loop. Move logo to resources.

commit25ba3b89c4bece2e286acdc4b8580b192feecab3 authoriko <mail@iko.soy> dateSun 12 Apr 2026, 08:27 (6 months ago) parentd065088
6 files changed+58−59
README.md+4 client/App.fs+40−51 logo.png → client/Resources/Images/logo.png client/Store.fs+12−6 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
README.md+4
@@ -1,5 +1,9 @@
11# Skrepka
22
3+<p align="center">
4+ <img src="client/Resources/Images/logo.png" width="300px" align="center">
5+</p>
6+
37Decentralized, end-to-end encrypted messaging with no accounts and no home servers. See [PROTOCOL.md](PROTOCOL.md) for the full specification.
48
59## Install the relay server
client/App.fs+40−51
@@ -70,7 +70,7 @@ module App =
7070 | DoSend
7171 | SendFailed of string
7272 | DoSaveContact
73− | PollResult of events: IncomingEvent list * ackIds: string list * status: string * cursor: int64
73+ | PollResult of events: IncomingEvent list * status: string * cursor: int64
7474 | CopyPubKey
7575 | DismissError
7676 | StateLoaded of Identity option * Data option * Profile option
@@ -84,14 +84,12 @@ module App =
8484 | CmdConnect of url: string * identity: Identity
8585 | CmdSend of session: Session * recipientHex: string * envelope: Envelope
8686 | CmdPoll of session: Session * cursor: int64
87− | CmdAckAndDeliver of session: Session * ackIds: string list * events: IncomingEvent list
8887 | CmdCopyToClipboard of string
8988 | CmdLoadState
9089 | CmdSaveIdentity of Identity
9190 | CmdSaveData of Data
9291 | CmdPickPhoto
9392 | CmdSaveProfile of Profile
94− | CmdSendProfileToAll of session: Session * Profile * recipientHexes: string list
9593
9694 // ── Helpers ──
9795
@@ -127,7 +125,9 @@ module App =
127125 let private markDelivered (pk: string) (ackIds: string list) (messages: Map<string, ChatMessage list>) =
128126 let ackSet = Set.ofList ackIds
129127 messages |> Map.change pk (Option.map (List.map (fun m ->
130− if m.IsOutgoing && Set.contains m.Id ackSet then { m with Status = DeliveryStatus.Delivered } else m)))
128+ match m.Direction with
129+ | Outgoing _ when Set.contains m.Id ackSet -> { m with Direction = Outgoing DeliveryStatus.Delivered }
130+ | _ -> m)))
131131
132132 let private newContact pubkey nickname : Contact =
133133 { Pubkey = pubkey; Nickname = nickname; DisplayName = ""; Bio = ""; PhotoBase64 = "" }
@@ -139,8 +139,7 @@ module App =
139139 match evt.Envelope with
140140 | Envelope.TextMessage(id, body) ->
141141 let message =
142− { Id = id; Body = body; Timestamp = evt.Timestamp
143− IsOutgoing = false; Status = DeliveryStatus.Delivered }
142+ { Id = id; Body = body; Timestamp = evt.Timestamp; Direction = Incoming }
144143 { m with
145144 Contacts =
146145 if Map.containsKey evt.Sender m.Contacts then m.Contacts
@@ -222,8 +221,7 @@ module App =
222221 { Id = id
223222 Body = compose
224223 Timestamp = DateTimeOffset.UtcNow
225− IsOutgoing = true
226− Status = DeliveryStatus.Sent }
224+ Direction = Outgoing DeliveryStatus.Sent }
227225
228226 let isFirstInteraction = model.Messages |> messagesFor pk |> List.isEmpty
229227
@@ -235,22 +233,19 @@ module App =
235233 model',
236234 [ CmdSend(session, pk, Envelope.TextMessage(id, compose))
237235 saveCmdMsg model'
238− match model.Profile, isFirstInteraction with
239− | Some profile, true -> CmdSendProfileToAll(session, profile, [ pk ])
236+ match model.Profile with
237+ | Some profile when isFirstInteraction -> CmdSend(session, pk, Envelope.ProfileMessage profile)
240238 | _ -> () ]
241239 | _ -> model, []
242240
243241 | SendFailed err -> { model with Error = Some err }, []
244242
245− | PollResult(events, ackIds, status, newCursor) ->
243+ | PollResult(events, status, newCursor) ->
246244 let model' = events |> List.fold applyEvent { model with PollStatus = status; PollCursor = newCursor }
247245
248246 model',
249247 [ match trySession model' with
250− | Some session ->
251− if not ackIds.IsEmpty || not events.IsEmpty then
252− CmdAckAndDeliver(session, ackIds, events)
253− CmdPoll(session, model'.PollCursor)
248+ | Some session -> CmdPoll(session, model'.PollCursor)
254249 | None -> ()
255250 if not events.IsEmpty then saveCmdMsg model' ]
256251
@@ -295,7 +290,9 @@ module App =
295290 { model with Profile = Some profile; Page = Settings },
296291 [ CmdSaveProfile profile
297292 match trySession model with
298− | Some session -> CmdSendProfileToAll(session, profile, model.Contacts.Keys |> Seq.toList)
293+ | Some session ->
294+ for pk in model.Contacts.Keys do
295+ CmdSend(session, pk, Envelope.ProfileMessage profile)
299296 | None -> () ]
300297 | _ -> model, []
301298
@@ -375,19 +372,6 @@ module App =
375372 with ex ->
376373 Error $"parse: {ex.Message}"
377374
378− let private sendDeliveryAcks (session: Session) (events: IncomingEvent list) =
379− async {
380− let chatsBySender =
381− events
382− |> List.choose (fun evt -> match evt.Envelope with Envelope.TextMessage(id, _) -> Some(evt.Sender, id) | _ -> None)
383− |> List.groupBy fst
384− |> List.map (fun (sender, pairs) -> sender, List.map snd pairs)
385−
386− for sender, msgIds in chatsBySender do
387− try do! sendEnvelope session sender (Envelope.DeliveryAck msgIds)
388− with _ -> ()
389− }
390−
391375 let mapCmd cmdMsg =
392376 match cmdMsg with
393377 | CmdConnect(url, identity) ->
@@ -427,6 +411,22 @@ module App =
427411
428412 for e in errors do log $"decrypt error: {e}"
429413
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+
430430 if response.Events.Length = 0 then
431431 do! Async.Sleep 5000
432432
@@ -434,26 +434,17 @@ module App =
434434 let status =
435435 $"evts:{response.Events.Length} msgs:{chatCount} errs:{errors.Length}"
436436 + (match List.tryHead errors with Some e -> $" [{e}]" | None -> "")
437− return PollResult(events, ackIds, status, response.Cursor)
437+ return PollResult(events, status, response.Cursor)
438438 with
439439 | :? TimeoutException ->
440440 log "poll timeout"
441− return PollResult([], [], "polling...", cursor)
441+ return PollResult([], "polling...", cursor)
442442 | ex ->
443443 log $"poll error: {ex.Message}"
444444 do! Async.Sleep 3000
445− return PollResult([], [], $"poll error: {ex.Message}", cursor)
445+ return PollResult([], $"poll error: {ex.Message}", cursor)
446446 })
447447
448− | CmdAckAndDeliver(session, ackIds, events) ->
449− Cmd.ofEffect (fun _ ->
450− async {
451− if not ackIds.IsEmpty then
452− try do! ackMessages session.Url session.Token ackIds
453− with _ -> ()
454− do! sendDeliveryAcks session events
455− } |> Async.Start)
456−
457448 | CmdCopyToClipboard text ->
458449 Cmd.ofEffect (fun _ ->
459450 Microsoft.Maui.ApplicationModel.DataTransfer.Clipboard.Default.SetTextAsync(text)
@@ -492,14 +483,6 @@ module App =
492483 | CmdSaveProfile profile ->
493484 Cmd.ofEffect (fun _ -> Store.saveProfile profile)
494485
495− | CmdSendProfileToAll(session, profile, recipientHexes) ->
496− Cmd.ofEffect (fun _ ->
497− async {
498− for recipientHex in recipientHexes do
499− try do! sendEnvelope session recipientHex (Envelope.ProfileMessage profile)
500− with _ -> ()
501− } |> Async.Start)
502−
503486 // ── View ──
504487
505488 let private viewErrorBanner (error: string option) =
@@ -514,6 +497,8 @@ module App =
514497 let private viewSetup () =
515498 ContentPage(
516499 (VStack(spacing = 24.) {
500+ Image("logo.png")
501+ .margin(8, 0)
517502 Label("Skrepka")
518503 .font(size = 32.)
519504 .centerTextHorizontal()
@@ -637,8 +622,12 @@ module App =
637622 .font(size = 14.)
638623 else
639624 for m in msgs do
640− let tick = if m.IsOutgoing && m.Status = DeliveryStatus.Delivered then " \u2713" else ""
641− Label((if m.IsOutgoing then $"You: {m.Body}" else m.Body) + tick)
625+ let prefix, tick =
626+ match m.Direction with
627+ | Outgoing DeliveryStatus.Delivered -> "You: ", " \u2713"
628+ | Outgoing _ -> "You: ", ""
629+ | Incoming -> "", ""
630+ Label($"{prefix}{m.Body}{tick}")
642631 .padding(8.)
643632 .font(size = 14.)
644633
logo.png → client/Resources/Images/logo.pngrenamed

Binary file not shown.

client/Store.fs+12−6
@@ -23,12 +23,13 @@ module Store =
2323 [<RequireQualifiedAccess>]
2424 type DeliveryStatus = Sent | Delivered
2525
26+ type MessageDirection = Incoming | Outgoing of DeliveryStatus
27+
2628 type ChatMessage =
2729 { Id: string
2830 Body: string
2931 Timestamp: DateTimeOffset
30− IsOutgoing: bool
31− Status: DeliveryStatus }
32+ Direction: MessageDirection }
3233
3334 type Data =
3435 { Contacts: Map<string, Contact>
@@ -61,12 +62,17 @@ module Store =
6162 let private orEmpty s = s |> Option.ofObj |> Option.defaultValue ""
6263
6364 let private toChatMessage (d: MessageDoc) : ChatMessage =
64− { Id = d.Id; Body = d.Body; Timestamp = DateTimeOffset.FromUnixTimeMilliseconds d.TimestampUnix; IsOutgoing = d.IsOutgoing
65− Status = match d.Status with 1 -> DeliveryStatus.Delivered | _ -> DeliveryStatus.Sent }
65+ { Id = d.Id; Body = d.Body; Timestamp = DateTimeOffset.FromUnixTimeMilliseconds d.TimestampUnix
66+ Direction = if not d.IsOutgoing then Incoming else Outgoing(match d.Status with 1 -> DeliveryStatus.Delivered | _ -> DeliveryStatus.Sent) }
6667
6768 let private toMessageDoc convId (m: ChatMessage) : MessageDoc =
68− { Id = m.Id; ConversationId = convId; Body = m.Body; TimestampUnix = m.Timestamp.ToUnixTimeMilliseconds(); IsOutgoing = m.IsOutgoing
69− Status = match m.Status with DeliveryStatus.Delivered -> 1 | DeliveryStatus.Sent -> 0 }
69+ let isOutgoing, status =
70+ match m.Direction with
71+ | Incoming -> false, 0
72+ | Outgoing DeliveryStatus.Delivered -> true, 1
73+ | Outgoing DeliveryStatus.Sent -> true, 0
74+ { Id = m.Id; ConversationId = convId; Body = m.Body; TimestampUnix = m.Timestamp.ToUnixTimeMilliseconds()
75+ IsOutgoing = isOutgoing; Status = status }
7076
7177 let private openDb () =
7278 new LiteDatabase(Path.Combine(FileSystem.AppDataDirectory, "skrepka.db"))
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+c59feb8711edf67b505121d342f66ed8334f3286")>]
13+[<assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0+d0650888ccf1bf53219901811eb64b7103e14c67")>]
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−abd5c34920c568fe50524fa8c3fb57422b4cfa4ad533e1e6507750c8f7cc0873
1+02b5fa3ae3a2beecc1a0aeab16c12023ba39fb11a33f42458f1f8580fa272b24