on master help sign in
Animate delivery status with opacity transitions; simplify server utilities with takeRelation
commit71b07d1adc606d0933f07797ba77bee3dd6472ab authoriko <mail@iko.soy> dateTue 21 Apr 2026, 22:38 (6 months ago) parent531de4a
2 files changed+66−76
client/ViewChat.fs+56−33 server.knot+10−43
client/ViewChat.fs+56−33
@@ -13,22 +13,34 @@ module ViewChat =
1313 open Buttons
1414 open TextFields
1515
16− let private marchingAntsStyle =
17− let s = Avalonia.Styling.Style(fun sel -> sel.OfType<Avalonia.Controls.Shapes.Rectangle>())
18− let anim = Avalonia.Animation.Animation()
19− anim.Duration <- System.TimeSpan.FromSeconds 3.
20− anim.IterationCount <- Avalonia.Animation.IterationCount.Infinite
21− anim.Easing <- Avalonia.Animation.Easings.LinearEasing()
22− let kf1 = Avalonia.Animation.KeyFrame()
23− kf1.Setters.Add(Avalonia.Styling.Setter(Avalonia.Controls.Shapes.Shape.StrokeDashOffsetProperty, box 0.))
24− kf1.Cue <- Avalonia.Animation.Cue(0.)
25− let kf2 = Avalonia.Animation.KeyFrame()
26− kf2.Setters.Add(Avalonia.Styling.Setter(Avalonia.Controls.Shapes.Shape.StrokeDashOffsetProperty, box 6.))
27− kf2.Cue <- Avalonia.Animation.Cue(1.)
28− anim.Children.Add(kf1)
29− anim.Children.Add(kf2)
30− s.Animations.Add(anim)
31− s :> Avalonia.Styling.IStyle
16+ let private ensureMarchingAntsStyle =
17+ lazy
18+ (let s =
19+ Avalonia.Styling.Style(fun sel ->
20+ sel.OfType<Avalonia.Controls.Shapes.Rectangle>().Class("marching"))
21+
22+ let anim = Avalonia.Animation.Animation()
23+ anim.Duration <- System.TimeSpan.FromSeconds 3.
24+ anim.IterationCount <- Avalonia.Animation.IterationCount.Infinite
25+ anim.Easing <- Avalonia.Animation.Easings.LinearEasing()
26+ let kf1 = Avalonia.Animation.KeyFrame()
27+
28+ kf1.Setters.Add(
29+ Avalonia.Styling.Setter(Avalonia.Controls.Shapes.Shape.StrokeDashOffsetProperty, box 0.)
30+ )
31+
32+ kf1.Cue <- Avalonia.Animation.Cue(0.)
33+ let kf2 = Avalonia.Animation.KeyFrame()
34+
35+ kf2.Setters.Add(
36+ Avalonia.Styling.Setter(Avalonia.Controls.Shapes.Shape.StrokeDashOffsetProperty, box 6.)
37+ )
38+
39+ kf2.Cue <- Avalonia.Animation.Cue(1.)
40+ anim.Children.Add(kf1)
41+ anim.Children.Add(kf2)
42+ s.Animations.Add(anim)
43+ Avalonia.Application.Current.Styles.Add(s))
3244
3345 let private scrollRef = Fabulous.ViewRef<Avalonia.Controls.ScrollViewer>()
3446 let mutable private lastMsgCount = 0
@@ -43,28 +55,15 @@ module ViewChat =
4355 | None -> ()
4456 , Avalonia.Threading.DispatcherPriority.Background) |> ignore
4557
58+ let private opacityTransition =
59+ DoubleTransition(Avalonia.Visual.OpacityProperty, System.TimeSpan.FromMilliseconds 500.)
60+
4661 let private messageItem m =
4762 let align, cols, col =
4863 match m.Direction with
4964 | Outgoing _ -> HorizontalAlignment.Right, [ Dimension.Stars 3.; Dimension.Stars 7. ], 1
5065 | Incoming -> HorizontalAlignment.Left, [ Dimension.Stars 7.; Dimension.Stars 3. ], 0
5166
52− let dashed =
53− match m.Direction with
54− | Outgoing DeliveryStatus.Sent -> true
55− | _ -> false
56−
57− let border =
58− if dashed then
59− Rectangle()
60− .stroke(Avalonia.Media.Colors.Black)
61− .strokeThickness(4.)
62− .strokeDashArray([ 4.; 2. ])
63− .isHitTestVisible(false)
64− .style(marchingAntsStyle)
65− else
66− Rectangle().stroke(Avalonia.Media.Colors.Black).strokeThickness(4.).isHitTestVisible (false)
67−
6867 Grid(cols, [ Dimension.Auto ]) {
6968 Border(
7069 (Grid() {
@@ -75,7 +74,31 @@ module ViewChat =
7574 .textWrapping(Avalonia.Media.TextWrapping.Wrap)
7675 .padding (8.)
7776
78− border
77+ match m.Direction with
78+ | Outgoing status ->
79+ ensureMarchingAntsStyle.Force()
80+ let isSent = status = DeliveryStatus.Sent
81+
82+ Rectangle()
83+ .stroke(Avalonia.Media.Colors.Black)
84+ .strokeThickness(4.)
85+ .isHitTestVisible(false)
86+ .opacity(if isSent then 0. else 1.)
87+ .transition (opacityTransition)
88+
89+ Rectangle()
90+ .stroke(Avalonia.Media.Colors.Black)
91+ .strokeThickness(4.)
92+ .strokeDashArray([ 4.; 2. ])
93+ .isHitTestVisible(false)
94+ .opacity(if isSent then 1. else 0.)
95+ .classes("marching")
96+ .transition (opacityTransition)
97+ | Incoming ->
98+ Rectangle()
99+ .stroke(Avalonia.Media.Colors.Black)
100+ .strokeThickness(4.)
101+ .isHitTestVisible (false)
79102 }))
80103 .horizontalAlignment(align)
81104 .margin(4.)
server.knot+10−43
@@ -438,9 +438,6 @@ isQueued = \ds -> case ds of DsQueued {} -> True {}; _ -> False {}
438438nextSeqTs : Timestamp -> Timestamp -> Timestamp
439439nextSeqTs = \t seqCounter -> if t > seqCounter then t else seqCounter + 1
440440
441−takeN : [a] -> Int -> [a]
442−takeN = \items n -> (fold (\acc e -> if acc.n >= n then acc else {n: acc.n + 1, items: union acc.items [e]}) {n: 0, items: []} items).items
443−
444441dedupMessageIds : [MessageIdHex] -> [MessageIdHex]
445442dedupMessageIds = \messageIds ->
446443 fold (\acc mid ->
@@ -448,39 +445,11 @@ dedupMessageIds = \messageIds ->
448445 else if any acc (\a -> a == mid) then acc
449446 else union acc [mid]) [] messageIds
450447
451−boundedSmallest = \items n getKey ->
452− (fold (\acc a ->
453− let k = getKey a in
454− if acc.size < n
455− then
456− let newHeap = union acc.heap [a] in
457− let newSize = count newHeap in
458− if newSize > acc.size
459− then {size: newSize, heap: newHeap, heapMax: Just {value: case acc.heapMax of
460− Nothing {} -> k
461− Just {value: v} -> max k v
462− }}
463− else acc
464− else case acc.heapMax of
465− Nothing {} -> acc
466− Just {value: maxVal} ->
467− if k < maxVal
468− then
469− let removed = fold (\r x ->
470− if not r.done && getKey x == maxVal
471− then {done: True {}, items: r.items}
472− else {done: r.done, items: union r.items [x]}) {done: False {}, items: []} acc.heap in
473− let newHeap = union removed.items [a] in
474− let newMax = fold (\best x -> Just {value: case best of
475− Nothing {} -> getKey x
476− Just {value: v} -> max (getKey x) v
477− }) Nothing {} newHeap in
478− {size: acc.size, heap: newHeap, heapMax: newMax}
479− else acc) {size: 0, heap: [], heapMax: Nothing {}} items).heap
480−
481448evictOldestAckedIds = \items n ->
482449 if n <= 0 then items
483− else diff items (boundedSmallest items n (\a -> a.ackedAt))
450+ else do
451+ ii <- items
452+ diff items (takeRelation n ( sortBy (\a -> a.ackedAt) items))
484453
485454safeCompareLoop : Text -> Text -> Int -> Int -> Int
486455safeCompareLoop = \s1 s2 n result ->
@@ -553,7 +522,7 @@ capGossipPresence = \args ->
553522 let updatesOnlyAfterOffline = removeOffline updatesOnlyMerged args.trustedOffline args.t in
554523 let reclaimSlots = maxPresenceEntries - count updatesOnlyAfterOffline in
555524 let newEntries = do e <- args.trustedDeduped; where none args.presence (\p -> samePubkeyServer p e); yield e in
556− let reclaimedEntries = takeN newEntries reclaimSlots in
525+ let reclaimedEntries = takeRelation reclaimSlots newEntries in
557526 {finalPresence: union updatesOnlyAfterOffline reclaimedEntries,
558527 appliedOnline: union
559528 (do e <- updatesOnly; where none args.presence (\p -> samePubkeyServer p e && p.expiresAt >= e.expiresAt); yield (pubkeyServer e))
@@ -751,7 +720,7 @@ forwardQueuedMessages = \targets t -> do
751720 where none forwards (\f -> f.messageId == m.messageId && f.toServer == target.server)
752721 yield {messageId: m.messageId, toServer: target.server, createdAt: t,
753722 toKey: m.toKey, encryptedBlob: m.encryptedBlob, timestamp: m.timestamp}
754− let capped = takeN candidates (maxForwardsEntries - count forwards)
723+ let capped = takeRelation (maxForwardsEntries - count forwards) candidates
755724 let updatedForwards = union forwards capped
756725 set *forwards = updatedForwards
757726 yield capped
@@ -832,11 +801,10 @@ pollOnce = \token cursor startWakeup -> do
832801 atomic do
833802 wakeup <- *pollWakeup
834803 allMessages <- *messages
835− let matchingMsgs = do
804+ let limitedMsgs = takeRelation maxPollEvents (sortBy (\m -> m.receivedAt) (do
836805 m <- allMessages
837806 where m.toKey == pubkey && m.receivedAt >= t - messageMaxAge && m.receivedAt > clampedCursor
838− yield m
839− let limitedMsgs = boundedSmallest matchingMsgs maxPollEvents (\m -> m.receivedAt)
807+ yield m))
840808 if count limitedMsgs > 0
841809 then
842810 let evts = do
@@ -945,15 +913,14 @@ serve = \req -> case req of
945913 seqCounter <- *seqCounter
946914 let seqTs = nextSeqTs t seqCounter
947915 set *seqCounter = seqTs
948− let updatedMessages = union messages
916+ set *messages = union messages
949917 [{messageId: msgId, toKey: to,
950918 encryptedBlob: encryptedBlob, timestamp: clampedTs, originServer: "", receivedAt: seqTs}]
951− set *messages = updatedMessages
952919 let toPresence = do p <- presence; where p.pubkey == to && p.expiresAt > t; yield p
953920 let localOnline = any toPresence (\p -> isLocalServer p.server)
954921 let remoteServers = collectPeerServers toPresence (\p -> p.server)
955922 forwards <- *forwards
956− let forwardServers = takeN remoteServers (maxForwardsEntries - count forwards)
923+ let forwardServers = takeRelation (maxForwardsEntries - count forwards) remoteServers
957924 let shouldForward = federationEnabled && count forwardServers > 0
958925 let delivery = if localOnline then DsDelivered {}
959926 else if shouldForward then DsFederated {}
@@ -1004,7 +971,7 @@ serve = \req -> case req of
1004971 ackedIds <- *ackedIds
1005972 let combinedAckedIds = union ackedIds ackedFederated
1006973 let evictedEntries = let excessCount = count combinedAckedIds - maxAckedIds in
1007− if excessCount <= 0 then [] else boundedSmallest combinedAckedIds excessCount (\a -> a.ackedAt)
974+ if excessCount <= 0 then [] else takeRelation excessCount (sortBy (\a -> a.ackedAt) combinedAckedIds)
1008975 let remainingAckedIds = diff combinedAckedIds evictedEntries
1009976 set *ackedIds = remainingAckedIds
1010977 yield {pr: pr, ack: AckDone {pendingAcks: ackedFederated, evictedEntries: evictedEntries}})