on master help sign in
Refactor conversations UI with ListBox layout and add small button components
commit5aa2735e69c6071b9006c61bdf8f6be999920cd6 authoriko <mail@iko.soy> dateWed 15 Apr 2026, 23:48 (6 months ago) parentb10cab7
5 files changed+312−76
client/App.fs+97−45 client/Buttons.fs+87 client/Skrepka.fsproj+1 client/Store.fs+99−31 client/Styles.fs+28
client/App.fs+97−45
@@ -17,6 +17,8 @@ module App =
1717 open ApiClient
1818 open Buttons
1919 open TextFields
20+ open Styles
21+ open Avalonia.Layout
2022
2123 // ── Domain Types ──
2224
@@ -88,6 +90,7 @@ module App =
8890 | DoSaveProfile
8991 | StartFlush
9092 | FlushResult of sent: bool
93+ | Search of string
9194
9295 // ── CmdMsg ──
9396
@@ -160,13 +163,13 @@ module App =
160163 Nickname = nickname
161164 DisplayName = ""
162165 Bio = ""
163− Photo = [||] }
166+ Photo = None }
164167
165168 let private withProfile (p: Profile) (c: Contact) =
166169 { c with
167170 DisplayName = p.DisplayName
168171 Bio = p.Bio
169− Photo = p.Photo |> Option.defaultValue [||] }
172+ Photo = p.Photo }
170173
171174 let private applyEvent (m: Model) (evt: IncomingEvent) =
172175 match evt.Envelope with
@@ -836,48 +839,95 @@ module App =
836839 )
837840
838841 let private viewConversations model =
839− ScrollViewer(
840− (VStack(8.) {
841− HStack(8.) {
842− TextBlock("Conversations").fontSize (24.)
843−
844− Button("+", SetPage(AddContact("", "")))
845− Button("Settings", SetPage Settings)
846− }
847−
848− let pubPrefix =
849− match model.Auth with
850− | Identified(id, _) -> id.PubKeyHex.[..7]
851− | _ -> "?"
852−
853− let pollInfo =
854− if model.PollStatus <> "" then
855− $" | {model.PollStatus}"
856− else
857− ""
858−
859− TextBlock($"{connStatusLabel model.Auth} [{pubPrefix}]{pollInfo}")
860− .fontSize(10.)
861− .foreground (SolidColorBrush(Colors.DimGray))
842+ let contacts = model.Contacts.Values |> Seq.toList
862843
863− viewErrorBanner model.Error
844+ (Grid([], [ Dimension.Star; Dimension.Auto ]) {
864845
865− if model.Contacts.IsEmpty then
866− TextBlock("No contacts yet. Tap + to add one.")
867− .centerText()
868− .foreground (SolidColorBrush(Colors.Gray))
846+ if contacts.IsEmpty then
847+ TextBlock("No contacts yet. Tap + to add one.")
848+ .centerText()
849+ .foreground(SolidColorBrush(Colors.Gray))
850+ .gridRow (1)
851+
852+ if not contacts.IsEmpty then
853+ ListBox(
854+ contacts,
855+ fun c ->
856+ let name = contactName model.Contacts c.Pubkey
857+
858+ let preview =
859+ messagesFor c.Pubkey model.Messages
860+ |> List.tryLast
861+ |> Option.map (fun m -> if m.Body.Length > 40 then m.Body.[..39] + "..." else m.Body)
862+ |> Option.defaultValue ""
863+
864+ Border(
865+ (HStack(8.) {
866+
867+ Grid() {
868+ (match c.Photo with
869+ | None -> Image("avares://Skrepka/Assets/Images/user.png", Stretch.Uniform)
870+ | Some i ->
871+ Image(
872+ new Avalonia.Media.Imaging.Bitmap(new System.IO.MemoryStream(i)),
873+ Stretch.UniformToFill
874+ ))
875+ .width(16.)
876+ .height(16.)
877+ .margin(8.)
878+ .clipToBounds (true)
879+
880+ Rectangle().stroke(Colors.Black).strokeThickness (4.)
881+ }
882+
883+ VStack(2.) {
884+ TextBlock(name)
885+ .fontFamily(Constants.fontFamily)
886+ .fontWeight(FontWeight.Bold)
887+ .fontSize (16.)
888+
889+ if preview <> "" then
890+ TextBlock(preview).fontSize(12.).fontWeight (FontWeight.Bold)
891+ }
892+
893+ })
894+ .margin (8.)
895+ )
896+ .borderThickness(Avalonia.Thickness(0., 0., 0., 4.))
897+ .borderBrush(SolidColorBrush(Colors.Black))
898+ .background(Colors.Transparent)
899+ .onTapped (fun _ -> SetPage(Chat(c.Pubkey, "")))
900+ )
901+ .styles(noListBoxPadding ())
902+ .background(Colors.White)
903+ .gridRow (0)
904+
905+ Border(
906+
907+ (Grid([ Dimension.Auto; Dimension.Star; Dimension.Auto ], []) {
908+ // HStack(8.) {
909+ // TextBlock("Conversations").fontSize (24.)
910+
911+ // Button("+", SetPage(AddContact("", "")))
912+ // Button("Settings", SetPage Settings)
913+ // }
914+
915+ (smallImageButton None (SetPage Settings)).gridColumn (0)
916+
917+ (textField "" Search).margin(0.).margin(8.).gridColumn (1)
918+ (smallTextButton "+" (SetPage(AddContact("", "")))).gridColumn (2)
869919
870− for c in model.Contacts.Values do
871− let preview =
872− messagesFor c.Pubkey model.Messages
873− |> List.tryLast
874− |> Option.map (fun m -> if m.Body.Length > 40 then m.Body.[..39] + "..." else m.Body)
875− |> Option.defaultValue ""
920+ })
921+ .margin(20.)
922+ .horizontalAlignment (HorizontalAlignment.Stretch)
923+ )
924+ .borderThickness(Avalonia.Thickness(0., 4., 0., 0.))
925+ .borderBrush(SolidColorBrush(Colors.Black))
926+ .background(Constants.accentColor)
927+ .gridRow (1)
876928
877− Button($"{contactName model.Contacts c.Pubkey}\n{preview}", SetPage(Chat(c.Pubkey, "")))
878− })
879− .margin (20.)
880− )
929+ })
930+ .margin (0.)
881931
882932 let private viewChat model pk compose =
883933 let name = contactName model.Contacts pk
@@ -959,15 +1009,17 @@ module App =
9591009 )
9601010 .gridRow (0)
9611011
962− (Grid() {
963− Rectangle().stroke(SolidColorBrush(Colors.Black)).strokeThickness(4.).fill (Constants.accentColor)
1012+ Border(
9641013
965− HStack(8.) {
1014+ (HStack(8.) {
9661015 (button Secondary "back" (SetPage Settings)).margin (8.)
9671016
9681017 (button Secondary "save" DoSaveProfile).margin (8.)
969− }
970− })
1018+ })
1019+ .background (Constants.accentColor)
1020+ )
1021+ .borderThickness(Avalonia.Thickness(0., 4., 0., 0.))
1022+ .borderBrush(SolidColorBrush(Colors.Black))
9711023 .gridRow (1)
9721024 })
9731025
client/Buttons.fs+87
@@ -122,3 +122,90 @@ module Buttons =
122122 .margin(16.)
123123 .onTapped (fun _ -> msg)
124124 }
125+
126+ let smallImageButton (img: byte[] option) (msg: 'msg) =
127+ let key =
128+ match img with
129+ | None -> "img-button"
130+ | Some i -> $"img-button-{hash i}"
131+
132+ Component(key) {
133+ let! pressed = Context.State(false)
134+ let offset = if pressed.Current then 2. else 4.
135+
136+ (Grid() {
137+ Rectangle()
138+ .fill(SolidColorBrush(Colors.Black))
139+ .renderTransform(translate offset offset)
140+ .transition (
141+ TransformOperationsTransition(
142+ Avalonia.Visual.RenderTransformProperty,
143+ TimeSpan.FromMilliseconds Constants.animationDuration
144+ )
145+ .easing (Constants.easing)
146+ )
147+
148+ Rectangle().fill (SolidColorBrush(Colors.White))
149+
150+ match img with
151+ | None ->
152+ ViewBox(
153+ Image("avares://Skrepka/Assets/Images/user.png", Stretch.Uniform)
154+ .width(32.)
155+ .height(32.)
156+ .margin (8.)
157+ )
158+
159+ | Some i ->
160+ Image(new Avalonia.Media.Imaging.Bitmap(new System.IO.MemoryStream(i)), Stretch.UniformToFill)
161+ .width(32.)
162+ .height(32.)
163+ .clipToBounds (true)
164+
165+ Rectangle().stroke(Colors.Black).strokeThickness (4.)
166+ })
167+ .background(Brushes.Transparent)
168+ .onPointerPressed(fun _ -> pressed.Set true)
169+ .onPointerReleased(fun _ -> pressed.Set false)
170+ .onPointerExited(fun _ -> pressed.Set false)
171+ .margin(8.)
172+ .onTapped (fun _ -> msg)
173+ }
174+
175+ let smallTextButton (text: string) (msg: 'msg) =
176+ Component($"small-button-{text}") {
177+ let! pressed = Context.State(false)
178+ let offset = if pressed.Current then 2. else 4.
179+
180+ (Grid() {
181+ Rectangle()
182+ .fill(SolidColorBrush(Colors.Black))
183+ .renderTransform(translate offset offset)
184+ .transition (
185+ TransformOperationsTransition(
186+ Avalonia.Visual.RenderTransformProperty,
187+ TimeSpan.FromMilliseconds Constants.animationDuration
188+ )
189+ .easing (Constants.easing)
190+ )
191+
192+ Rectangle().fill (SolidColorBrush(Colors.White))
193+
194+ TextBlock(text)
195+ .fontFamily(Constants.fontFamily)
196+ .fontWeight(FontWeight.Bold)
197+ .fontSize(28.)
198+ .textAlignment(TextAlignment.Center)
199+ .center ()
200+
201+ Rectangle().stroke(Colors.Black).strokeThickness (4.)
202+ })
203+ .width(48.)
204+ .height(48.)
205+ .background(Brushes.Transparent)
206+ .onPointerPressed(fun _ -> pressed.Set true)
207+ .onPointerReleased(fun _ -> pressed.Set false)
208+ .onPointerExited(fun _ -> pressed.Set false)
209+ .margin(8.)
210+ .onTapped (fun _ -> msg)
211+ }
client/Skrepka.fsproj+1
@@ -29,6 +29,7 @@
2929
3030 <ItemGroup>
3131 <Compile Include="Constants.fs" />
32+ <Compile Include="Styles.fs" />
3233 <Compile Include="Crypto.fs" />
3334 <Compile Include="ApiClient.fs" />
3435 <Compile Include="Phonemic.fs" />
client/Store.fs+99−31
@@ -13,7 +13,7 @@ module Store =
1313 Nickname: string
1414 DisplayName: string
1515 Bio: string
16− Photo: byte[] }
16+ Photo: byte[] option }
1717
1818 type Profile =
1919 { DisplayName: string
@@ -111,20 +111,48 @@ module Store =
111111
112112 type private ByteArrayOptionHandler() =
113113 inherit SqlMapper.TypeHandler<byte[] option>()
114+
114115 override _.SetValue(p, value) =
115− p.Value <- match value with Some v -> box v | None -> box DBNull.Value
116+ p.Value <-
117+ match value with
118+ | Some v -> box v
119+ | None -> box DBNull.Value
120+
116121 override _.Parse(value) =
117− if value = null || value :? DBNull then None else Some(value :?> byte[])
122+ if value = null || value :? DBNull then
123+ None
124+ else
125+ Some(value :?> byte[])
118126
119127 do
120128 OptionTypes.register ()
121129 SqlMapper.AddTypeHandler(ByteArrayOptionHandler())
122130 use conn = openConn ()
123− conn.Execute("CREATE TABLE IF NOT EXISTS profile (Id TEXT PRIMARY KEY, DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)") |> ignore
124− conn.Execute("CREATE TABLE IF NOT EXISTS contacts (Pubkey TEXT PRIMARY KEY, Nickname TEXT NOT NULL DEFAULT '', DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)") |> ignore
125− conn.Execute("CREATE TABLE IF NOT EXISTS messages (Id TEXT PRIMARY KEY, ConversationId TEXT NOT NULL, Body TEXT NOT NULL DEFAULT '', TimestampUnix INTEGER NOT NULL, IsOutgoing INTEGER NOT NULL DEFAULT 0, Status INTEGER NOT NULL DEFAULT 0)") |> ignore
126− conn.Execute("CREATE TABLE IF NOT EXISTS settings (Id TEXT PRIMARY KEY, ServerUrl TEXT NOT NULL DEFAULT '', PollCursor INTEGER NOT NULL DEFAULT 0)") |> ignore
127− conn.Execute("CREATE TABLE IF NOT EXISTS outbox (Id INTEGER PRIMARY KEY AUTOINCREMENT, RecipientHex TEXT NOT NULL, EnvelopeJson TEXT NOT NULL, CreatedAt INTEGER NOT NULL)") |> ignore
131+
132+ conn.Execute(
133+ "CREATE TABLE IF NOT EXISTS profile (Id TEXT PRIMARY KEY, DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)"
134+ )
135+ |> ignore
136+
137+ conn.Execute(
138+ "CREATE TABLE IF NOT EXISTS contacts (Pubkey TEXT PRIMARY KEY, Nickname TEXT NOT NULL DEFAULT '', DisplayName TEXT NOT NULL DEFAULT '', Bio TEXT NOT NULL DEFAULT '', Photo BLOB)"
139+ )
140+ |> ignore
141+
142+ conn.Execute(
143+ "CREATE TABLE IF NOT EXISTS messages (Id TEXT PRIMARY KEY, ConversationId TEXT NOT NULL, Body TEXT NOT NULL DEFAULT '', TimestampUnix INTEGER NOT NULL, IsOutgoing INTEGER NOT NULL DEFAULT 0, Status INTEGER NOT NULL DEFAULT 0)"
144+ )
145+ |> ignore
146+
147+ conn.Execute(
148+ "CREATE TABLE IF NOT EXISTS settings (Id TEXT PRIMARY KEY, ServerUrl TEXT NOT NULL DEFAULT '', PollCursor INTEGER NOT NULL DEFAULT 0)"
149+ )
150+ |> ignore
151+
152+ conn.Execute(
153+ "CREATE TABLE IF NOT EXISTS outbox (Id INTEGER PRIMARY KEY AUTOINCREMENT, RecipientHex TEXT NOT NULL, EnvelopeJson TEXT NOT NULL, CreatedAt INTEGER NOT NULL)"
154+ )
155+ |> ignore
128156
129157 let private identityPath = Path.Combine(appDataDir, "identity.key")
130158
@@ -153,12 +181,18 @@ module Store =
153181 try
154182 use conn = openConn ()
155183
156− select { for p in profileTable do where (p.Id = "me") }
184+ select {
185+ for p in profileTable do
186+ where (p.Id = "me")
187+ }
157188 |> conn.SelectAsync<ProfileRow>
158189 |> Async.AwaitTask
159190 |> Async.RunSynchronously
160191 |> Seq.tryHead
161− |> Option.map (fun p -> { DisplayName = p.DisplayName; Bio = p.Bio; Photo = p.Photo })
192+ |> Option.map (fun p ->
193+ { DisplayName = p.DisplayName
194+ Bio = p.Bio
195+ Photo = p.Photo })
162196 with _ ->
163197 None
164198
@@ -168,8 +202,11 @@ module Store =
168202
169203 conn.Execute(
170204 "INSERT OR REPLACE INTO profile (Id, DisplayName, Bio, Photo) VALUES (@Id, @DisplayName, @Bio, @Photo)",
171− {| Id = "me"; DisplayName = profile.DisplayName; Bio = profile.Bio
172− Photo = profile.Photo |> Option.defaultValue null |})
205+ {| Id = "me"
206+ DisplayName = profile.DisplayName
207+ Bio = profile.Bio
208+ Photo = profile.Photo |> Option.defaultValue null |}
209+ )
173210 |> ignore
174211 with _ ->
175212 ()
@@ -179,7 +216,10 @@ module Store =
179216 use conn = openConn ()
180217
181218 let settings =
182− select { for s in settingsTable do where (s.Id = "settings") }
219+ select {
220+ for s in settingsTable do
221+ where (s.Id = "settings")
222+ }
183223 |> conn.SelectAsync<SettingsRow>
184224 |> Async.AwaitTask
185225 |> Async.RunSynchronously
@@ -188,7 +228,10 @@ module Store =
188228 settings
189229 |> Option.map (fun s ->
190230 let contacts =
191− select { for c in contactTable do selectAll }
231+ select {
232+ for c in contactTable do
233+ selectAll
234+ }
192235 |> conn.SelectAsync<ContactRow>
193236 |> Async.AwaitTask
194237 |> Async.RunSynchronously
@@ -198,13 +241,16 @@ module Store =
198241 Nickname = c.Nickname
199242 DisplayName = c.DisplayName
200243 Bio = c.Bio
201− Photo = c.Photo |> Option.defaultValue [||] }
244+ Photo = c.Photo }
202245
203246 c.Pubkey, contact)
204247 |> Map.ofSeq
205248
206249 let messages =
207− select { for m in messageTable do selectAll }
250+ select {
251+ for m in messageTable do
252+ selectAll
253+ }
208254 |> conn.SelectAsync<MessageRow>
209255 |> Async.AwaitTask
210256 |> Async.RunSynchronously
@@ -217,8 +263,15 @@ module Store =
217263 Body = m.Body
218264 Timestamp = DateTimeOffset.FromUnixTimeMilliseconds m.TimestampUnix
219265 Direction =
220− if not m.IsOutgoing then Incoming
221− else Outgoing(if m.Status = 1 then DeliveryStatus.Delivered else DeliveryStatus.Sent) })
266+ if not m.IsOutgoing then
267+ Incoming
268+ else
269+ Outgoing(
270+ if m.Status = 1 then
271+ DeliveryStatus.Delivered
272+ else
273+ DeliveryStatus.Sent
274+ ) })
222275 |> Seq.sortBy _.Timestamp
223276 |> Seq.toList)
224277 |> Map.ofSeq
@@ -237,8 +290,12 @@ module Store =
237290 for c in data.Contacts.Values do
238291 conn.Execute(
239292 "INSERT OR REPLACE INTO contacts (Pubkey, Nickname, DisplayName, Bio, Photo) VALUES (@Pubkey, @Nickname, @DisplayName, @Bio, @Photo)",
240− {| Pubkey = c.Pubkey; Nickname = c.Nickname; DisplayName = c.DisplayName; Bio = c.Bio
241− Photo = if c.Photo = null || c.Photo.Length = 0 then null else c.Photo |})
293+ {| Pubkey = c.Pubkey
294+ Nickname = c.Nickname
295+ DisplayName = c.DisplayName
296+ Bio = c.Bio
297+ Photo = c.Photo |}
298+ )
242299 |> ignore
243300
244301 data.Messages
@@ -252,28 +309,37 @@ module Store =
252309
253310 conn.Execute(
254311 "INSERT OR REPLACE INTO messages (Id, ConversationId, Body, TimestampUnix, IsOutgoing, Status) VALUES (@Id, @ConversationId, @Body, @TimestampUnix, @IsOutgoing, @Status)",
255− {| Id = m.Id; ConversationId = convId; Body = m.Body
312+ {| Id = m.Id
313+ ConversationId = convId
314+ Body = m.Body
256315 TimestampUnix = m.Timestamp.ToUnixTimeMilliseconds()
257− IsOutgoing = isOutgoing; Status = status |})
316+ IsOutgoing = isOutgoing
317+ Status = status |}
318+ )
258319 |> ignore)
259320
260321 conn.Execute(
261322 "INSERT OR REPLACE INTO settings (Id, ServerUrl, PollCursor) VALUES (@Id, @ServerUrl, @PollCursor)",
262− {| Id = "settings"; ServerUrl = data.ServerUrl; PollCursor = data.PollCursor |})
323+ {| Id = "settings"
324+ ServerUrl = data.ServerUrl
325+ PollCursor = data.PollCursor |}
326+ )
263327 |> ignore
264328 with _ ->
265329 ()
266330
267331 let enqueueOutbox (recipientHex: string) (envelopeJson: string) =
268332 use conn = openConn ()
333+
269334 insert {
270335 for o in outboxTable do
271− value
272− { Id = 0L
273− RecipientHex = recipientHex
274− EnvelopeJson = envelopeJson
275− CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }
276− excludeColumn o.Id
336+ value
337+ { Id = 0L
338+ RecipientHex = recipientHex
339+ EnvelopeJson = envelopeJson
340+ CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }
341+
342+ excludeColumn o.Id
277343 }
278344 |> conn.InsertAsync
279345 |> Async.AwaitTask
@@ -282,9 +348,10 @@ module Store =
282348
283349 let peekOutbox () : OutboxRow option =
284350 use conn = openConn ()
351+
285352 select {
286353 for o in outboxTable do
287− orderBy o.Id
354+ orderBy o.Id
288355 }
289356 |> conn.SelectAsync<OutboxRow>
290357 |> Async.AwaitTask
@@ -293,9 +360,10 @@ module Store =
293360
294361 let dequeueOutbox (id: int64) =
295362 use conn = openConn ()
363+
296364 delete {
297365 for o in outboxTable do
298− where (o.Id = id)
366+ where (o.Id = id)
299367 }
300368 |> conn.DeleteAsync
301369 |> Async.AwaitTask
client/Styles.fsnew file+28
@@ -0,0 +1,28 @@
1+namespace Skrepka
2+
3+
4+module Styles =
5+ open Avalonia
6+ open Avalonia.Controls
7+ open Avalonia.Controls.Presenters
8+ open Avalonia.Styling
9+
10+ let style (selector: Selector -> Selector) (setters: Setter list) =
11+ let s = Style(selector)
12+
13+ for setter in setters do
14+ s.Setters.Add(setter)
15+
16+ s :> IStyle
17+
18+ let noListBoxPadding () =
19+ [ style
20+ _.OfType<ListBoxItem>()
21+ [ Setter(ListBoxItem.PaddingProperty, Thickness(0.))
22+ Setter(ListBoxItem.MinHeightProperty, box 0.) ]
23+ style
24+ (_.OfType<ListBoxItem>()
25+ .Template()
26+ .OfType<ContentPresenter>()
27+ .Name("PART_ContentPresenter"))
28+ [ Setter(ContentPresenter.MarginProperty, Thickness(0.)) ] ]