on master help sign in
Add public key validation with real-time UI feedback on add contact screen
commit1fa6993e9653334e922b64c94d8b5d02b316916f authoriko <mail@iko.soy> dateSat 18 Apr 2026, 23:39 (6 months ago) parentf170fff
4 files changed+81−11
client/Buttons.fs+21−7 client/Phonemic.fs+33 client/TextFields.fs+6 client/ViewAddContact.fs+21−4
client/Buttons.fs+21−7
@@ -34,13 +34,25 @@ module Buttons =
3434 let private borderRect () =
3535 Rectangle().stroke(Colors.Black).strokeThickness (4.)
3636
37− let private textButton fontSize pressOffset restOffset margin priority (text: string) (msg: 'msg) =
38− Component(text) {
37+ let private textButton fontSize pressOffset restOffset margin priority (text: string) (msg: 'msg) disabled =
38+ // Start from the opposite state's offset so the transition animates
39+ let initialOffset = if disabled then restOffset else 0.
40+
41+ Component($"{text}-{disabled}") {
3942 let! pressed = Context.State(false)
40− let offset = if pressed.Current then pressOffset else restOffset
43+ let! animOffset = Context.State(initialOffset)
44+
45+ let target =
46+ if disabled then 0.
47+ elif pressed.Current then pressOffset
48+ else restOffset
49+
50+ do
51+ if animOffset.Current <> target then
52+ Avalonia.Threading.Dispatcher.UIThread.Post(fun () -> animOffset.Set target)
4153
4254 (Grid() {
43− shadowRect offset
55+ shadowRect animOffset.Current
4456
4557 Rectangle()
4658 .fill (
@@ -65,15 +77,17 @@ module Buttons =
6577 borderRect ()
6678 })
6779 .background(Brushes.Transparent)
68− .onPointerPressed(fun _ -> pressed.Set true)
80+ .onPointerPressed(fun _ -> if not disabled then pressed.Set true)
6981 .onPointerReleased(fun _ -> pressed.Set false)
7082 .onPointerExited(fun _ -> pressed.Set false)
7183 .margin(margin)
7284 .onTapped (fun _ -> msg)
7385 }
7486
75− let button priority = textButton 24. 4. 8. 16. priority
76− let smallButton priority = textButton 16. 2. 4. 8. priority
87+ let button priority text msg = textButton 24. 4. 8. 16. priority text msg false
88+ let smallButton priority text msg = textButton 16. 2. 4. 8. priority text msg false
89+ let disabledButton priority text msg = textButton 24. 4. 8. 16. priority text msg true
90+ let disabledSmallButton priority text msg = textButton 16. 2. 4. 8. priority text msg true
7791
7892 let imageButton (img: byte[] option) (msg: 'msg) =
7993 let key =
client/Phonemic.fs+33
@@ -87,6 +87,39 @@ module Phonemic =
8787 |> Array.map (fun pair -> $"{prefixes.[int pair.[0]]}{suffixes.[int pair.[1]]}")
8888 |> String.concat "-"
8989
90+ type ObValidation =
91+ | Empty
92+ | Partial of validPairs: int
93+ | InvalidSyllable of syllable: string
94+ | Valid
95+
96+ let validateOb (s: string) : ObValidation =
97+ let s = s.Trim()
98+
99+ if s = "" then
100+ Empty
101+ else
102+ let parts = s.Split('-', StringSplitOptions.RemoveEmptyEntries)
103+
104+ // Don't flag the trailing part if still being typed
105+ let completeParts =
106+ if parts.Length > 0 && parts.[parts.Length - 1].Length < 6 then
107+ parts.[.. parts.Length - 2]
108+ else
109+ parts
110+
111+ let firstInvalid =
112+ completeParts
113+ |> Array.tryFind (fun sp ->
114+ sp.Length <> 6
115+ || not (Map.containsKey sp.[0..2] prefixMap)
116+ || not (Map.containsKey sp.[3..5] suffixMap))
117+
118+ match firstInvalid with
119+ | Some s -> InvalidSyllable s
120+ | None when completeParts.Length = 16 -> Valid
121+ | None -> Partial completeParts.Length
122+
90123 let fromOb (s: string) : byte[] option =
91124 let pairs = s.Split('-', StringSplitOptions.RemoveEmptyEntries)
92125
client/TextFields.fs+6
@@ -35,6 +35,12 @@ module TextFields =
3535 Rectangle().stroke(Colors.Black).strokeThickness (4.)
3636 }).styles (whiteBg ())
3737
38+ let errorTextField value msg =
39+ (Grid() {
40+ TextBox(value, msg).fontFamily(Constants.fontFamily).verticalAlignment(VerticalAlignment.Stretch).verticalContentAlignment(VerticalAlignment.Center).background(SolidColorBrush(Colors.White))
41+ Rectangle().stroke(Colors.Red).strokeThickness (4.)
42+ }).styles (whiteBg ())
43+
3844 let multilineTextField value msg =
3945 Grid() {
4046 TextBox(value, msg).fontFamily(Constants.fontFamily).acceptsReturn(true).height (150)
client/ViewAddContact.fs+21−4
@@ -19,7 +19,7 @@ module ViewAddContact =
1919 use png = new QRCoder.PngByteQRCode(data)
2020 png.GetGraphic(8, [| 0uy; 0uy; 0uy |], [| 255uy; 255uy; 255uy |])
2121
22− let viewAddContact model cpk =
22+ let viewAddContact model (cpk: string) =
2323 let content =
2424 ScrollViewer(
2525 (VStack(16.) {
@@ -37,11 +37,28 @@ module ViewAddContact =
3737 smallButton Primary "Copy Address" CopyPubKey
3838 | NoIdentity -> ()
3939
40− h2 "Enter someone else's key"
40+ h2 "Enter someone else's address"
4141
42− textField cpk (fun key -> SetPage(AddContact(key)))
42+ let cpkTrimmed: string = cpk.Trim()
4343
44− smallButton Primary "Add" DoSaveContact
44+ let isValid =
45+ Phonemic.fromOb cpkTrimmed
46+ |> Option.orElseWith (fun () -> Crypto.tryFromHex cpkTrimmed)
47+ |> Option.filter (fun bytes -> bytes.Length = 32)
48+ |> Option.isSome
49+
50+ let hasError = cpkTrimmed <> "" && not isValid
51+
52+ if hasError then
53+ errorTextField cpk (fun key -> SetPage(AddContact(key)))
54+ else
55+ textField cpk (fun key -> SetPage(AddContact(key)))
56+
57+
58+ if isValid then
59+ smallButton Primary "Add" DoSaveContact
60+ else
61+ disabledSmallButton Primary "Add" DoSaveContact
4562 })
4663 .margin (20.)
4764 )