@@ -12,6 +12,73 @@ module ViewChat =
1212 open Buttons
1313 open TextFields
1414
15+ let private scrollRef = Fabulous.ViewRef<Avalonia.Controls.ScrollViewer>()
16+ let mutable private lastMsgCount = 0
17+ let mutable private pageSize = 50
18+ let mutable private loadingMore = false
19+ let mutable private isAtBottom = true
20+
21+ let private scrollToEnd () =
22+ Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
23+ match scrollRef.TryValue with
24+ | Some sv -> sv.ScrollToEnd()
25+ | None -> ()
26+ , Avalonia.Threading.DispatcherPriority.Background) |> ignore
27+
28+ let private messageItem m =
29+ let align, cols, col =
30+ match m.Direction with
31+ | Outgoing _ -> HorizontalAlignment.Right, [ Dimension.Stars 3.; Dimension.Stars 7. ], 1
32+ | Incoming -> HorizontalAlignment.Left, [ Dimension.Stars 7.; Dimension.Stars 3. ], 0
33+
34+ let dashed =
35+ match m.Direction with
36+ | Outgoing DeliveryStatus.Sent -> true
37+ | _ -> false
38+
39+ let border =
40+ if dashed then
41+ Rectangle()
42+ .stroke(Avalonia.Media.Colors.Black)
43+ .strokeThickness(4.)
44+ .strokeDashArray([ 4.; 2. ])
45+ .isHitTestVisible(false)
46+ .onLoaded(fun args ->
47+ let rect = args.Source :?> Avalonia.Controls.Shapes.Rectangle
48+ let anim = Avalonia.Animation.Animation()
49+ anim.Duration <- System.TimeSpan.FromSeconds 3.
50+ anim.IterationCount <- Avalonia.Animation.IterationCount.Infinite
51+ anim.Easing <- Avalonia.Animation.Easings.LinearEasing()
52+ let kf1 = Avalonia.Animation.KeyFrame()
53+ kf1.Setters.Add(Avalonia.Styling.Setter(Avalonia.Controls.Shapes.Shape.StrokeDashOffsetProperty, box 0.))
54+ kf1.Cue <- Avalonia.Animation.Cue(0.)
55+ let kf2 = Avalonia.Animation.KeyFrame()
56+ kf2.Setters.Add(Avalonia.Styling.Setter(Avalonia.Controls.Shapes.Shape.StrokeDashOffsetProperty, box 6.))
57+ kf2.Cue <- Avalonia.Animation.Cue(1.)
58+ anim.Children.Add(kf1)
59+ anim.Children.Add(kf2)
60+ anim.RunAsync(rect) |> ignore
61+ DismissError)
62+ else
63+ Rectangle().stroke(Avalonia.Media.Colors.Black).strokeThickness(4.).isHitTestVisible (false)
64+
65+ Grid(cols, [ Dimension.Auto ]) {
66+ Border(
67+ (Grid() {
68+ TextBlock(m.Body)
69+ .fontSize(14.)
70+ .fontFamily(Constants.fontFamily)
71+ .fontWeight(Avalonia.Media.FontWeight.Bold)
72+ .textWrapping(Avalonia.Media.TextWrapping.Wrap)
73+ .padding (8.)
74+
75+ border
76+ }))
77+ .horizontalAlignment(align)
78+ .margin(4.)
79+ .gridColumn (col)
80+ }
81+
1582 let viewChat model pk compose =
1683 let name = contactName model.Contacts pk
1784 let msgs = messagesFor pk model.Messages
@@ -20,8 +87,11 @@ module ViewChat =
2087 (Grid([ Dimension.Auto; Dimension.Star; Dimension.Auto ], [ Dimension.Auto ]) {
2188 (backButton (SetPage Conversations)).gridColumn (0)
2289
23− (textField compose (fun text -> SetPage(Chat(pk, text))))
90+ TextBox(compose, fun text -> SetPage(Chat(pk, text)))
91+ .watermark("Message...")
92+ .fontFamily(Constants.fontFamily)
2493 .verticalAlignment(VerticalAlignment.Stretch)
94+ .verticalContentAlignment(VerticalAlignment.Center)
2595 .margin(8.)
2696 .gridColumn (1)
2797
@@ -35,39 +105,50 @@ module ViewChat =
35105
36106 (viewErrorBanner model.Error).gridRow (1)
37107
38− (if msgs.IsEmpty then
39− AnyView(TextBlock("No messages yet").padding(8.).fontSize (14.))
40− else
41− let layout = Avalonia.Layout.StackLayout()
42− layout.DisableVirtualization <- true
43−
44− AnyView(
45− ScrollViewer(
46− ItemsRepeater(msgs, fun m ->
47− let tick =
48− match m.Direction with
49− | Outgoing DeliveryStatus.Delivered -> " ✓"
50− | Outgoing DeliveryStatus.Sent -> ""
51− | Incoming -> ""
52−
53− let align =
54− match m.Direction with
55− | Incoming -> HorizontalAlignment.Right
56− | Outgoing _ -> HorizontalAlignment.Left
57−
58− Border(
59− TextBlock($"{m.Body}{tick}")
60− .fontSize(14.)
61− .padding (8.))
62− .borderThickness(Avalonia.Thickness(2.))
63− .borderBrush(Avalonia.Media.SolidColorBrush(Avalonia.Media.Colors.Black))
64− .horizontalAlignment(align)
65− .margin (4.))
66− .layout(layout)
67− )
68− .horizontalScrollBarVisibility(Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled)
69− .offset(Avalonia.Vector(0., System.Double.MaxValue))))
70− .gridRow (2)
108+ (let msgView =
109+ if msgs.IsEmpty then
110+ lastMsgCount <- 0
111+ pageSize <- 50
112+ AnyView(TextBlock("No messages yet").padding(8.).fontSize (14.))
113+ else
114+ if msgs.Length <> lastMsgCount then
115+ lastMsgCount <- msgs.Length
116+ if isAtBottom then scrollToEnd ()
117+
118+ let skip = max 0 (msgs.Length - pageSize)
119+ let visible = msgs |> List.skip skip
120+
121+ AnyView(
122+ ScrollViewer(
123+ VStack(0.) {
124+ for m in visible do
125+ messageItem m
126+ })
127+ .reference(scrollRef)
128+ .onLoaded(fun _ ->
129+ pageSize <- 50
130+ loadingMore <- false
131+ isAtBottom <- true
132+ scrollToEnd ()
133+ DismissError)
134+ .onScrollChanged(fun args ->
135+ let sv = args.Source :?> Avalonia.Controls.ScrollViewer
136+ isAtBottom <- sv.Offset.Y + sv.Viewport.Height >= sv.Extent.Height - 20.
137+ if sv.Offset.Y < 50. && not loadingMore && skip > 0 then
138+ loadingMore <- true
139+ let oldHeight = sv.Extent.Height
140+ pageSize <- min msgs.Length (pageSize + 50)
141+ Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(fun () ->
142+ match scrollRef.TryValue with
143+ | Some sv2 ->
144+ let newHeight = sv2.Extent.Height
145+ sv2.Offset <- Avalonia.Vector(0., sv2.Offset.Y + (newHeight - oldHeight))
146+ loadingMore <- false
147+ | None -> loadingMore <- false
148+ , Avalonia.Threading.DispatcherPriority.Background) |> ignore
149+ SetPage(Chat(pk, compose))))
150+
151+ msgView).gridRow (2)
71152 })
72153
73154 Styles.withBottomBar bar content