on master help sign in
Extract views and types into dedicated modules
commit9eb5982ec35ecf97752e72c3fdd5592abb4c1bde authoriko <mail@iko.soy> dateFri 17 Apr 2026, 18:22 (6 months ago) parent79c6f03
9 files changed+463−371
client/App.fs+12−371 client/AppTypes.fs+138 client/Skrepka.fsproj+7 client/ViewAddContact.fs+29 client/ViewChat.fs+49 client/ViewConversations.fs+94 client/ViewEditProfile.fs+47 client/ViewSettings.fs+57 client/ViewSetup.fs+30
client/App.fs+12−371
@@ -15,110 +15,16 @@ module App =
1515 open Store
1616 open Crypto
1717 open ApiClient
18− open Buttons
19− open TextFields
20− open Styles
21− open Avalonia.Layout
22−
23− // ── Domain Types ──
24−
25− type Page =
26− | Setup
27− | Conversations
28− | Chat of pubkey: string * compose: string
29− | AddContact of pubkey: string * nickname: string
30− | Settings
31− | EditProfile of displayName: string * bio: string * photo: byte[] option
32−
33− type Session =
34− { Url: string
35− Token: string
36− Identity: Identity }
37−
38− type ConnStatus =
39− | Offline
40− | Connecting
41− | Online of token: string
42−
43− type AuthState =
44− | NoIdentity
45− | Identified of identity: Identity * conn: ConnStatus
46−
47− [<RequireQualifiedAccess>]
48− type Envelope =
49− | TextMessage of id: string * body: string
50− | DeliveryAck of ackIds: string list
51− | ProfileMessage of profile: Profile
52−
53− type IncomingEvent =
54− { Sender: string
55− Timestamp: DateTimeOffset
56− Envelope: Envelope }
57−
58− // ── Model ──
59−
60− type Model =
61− { Page: Page
62− Auth: AuthState
63− ServerUrl: string
64− Contacts: Map<string, Contact>
65− PollStatus: string
66− Messages: Map<string, ChatMessage list>
67− Error: string option
68− PollCursor: int64
69− Profile: Profile option
70− FlushingOutbox: bool }
71−
72− // ── Msg ──
73−
74− type Msg =
75− | SetPage of Page
76− | GenIdentity
77− | SetServerUrl of string
78− | DoConnect
79− | AuthOk of string
80− | AuthErr of string
81− | DoDisconnect
82− | DoSend
83− | DoSaveContact
84− | PollResult of events: IncomingEvent list * status: string * cursor: int64
85− | CopyPubKey
86− | DismissError
87− | StateLoaded of Identity option * Data option * Profile option
88− | DoPickPhoto
89− | PhotoPicked of byte[] option
90− | DoSaveProfile
91− | StartFlush
92− | FlushResult of sent: bool
93− | Search of string
94−
95− // ── CmdMsg ──
96−
97− type CmdMsg =
98− | CmdConnect of url: string * identity: Identity
99− | CmdEnqueue of recipientHex: string * envelope: Envelope
100− | CmdFlushOutbox of session: Session
101− | CmdPoll of session: Session * cursor: int64
102− | CmdCopyToClipboard of string
103− | CmdLoadState
104− | CmdSaveIdentity of Identity
105− | CmdSaveData of Data
106− | CmdPickPhoto
107− | CmdSaveProfile of Profile
18+ open AppTypes
19+ open ViewSetup
20+ open ViewSettings
21+ open ViewConversations
22+ open ViewChat
23+ open ViewAddContact
24+ open ViewEditProfile
10825
10926 // ── Helpers ──
11027
111− let private hexToOb (hex: string) = Crypto.fromHex hex |> Phonemic.toOb
112−
113− let private truncKey (hex: string) =
114− let ob = hexToOb hex
115− let parts = ob.Split('-')
116−
117− if parts.Length <= 4 then
118− ob
119− else
120− $"{parts.[0]}-{parts.[1]}..{parts.[parts.Length - 2]}-{parts.[parts.Length - 1]}"
121−
12228 let private tryParsePubkey (input: string) =
12329 let input = input.Trim()
12430
@@ -126,15 +32,6 @@ module App =
12632 |> Option.orElseWith (fun () -> Crypto.tryFromHex input)
12733 |> Option.filter (fun bytes -> bytes.Length = 32)
12834
129− let private contactName (contacts: Map<string, Contact>) (pk: string) =
130− match Map.tryFind pk contacts with
131− | Some c when c.Nickname <> "" -> c.Nickname
132− | Some c when c.DisplayName <> "" -> c.DisplayName
133− | _ -> truncKey pk
134−
135− let private messagesFor (pk: string) (messages: Map<string, ChatMessage list>) =
136− messages |> Map.tryFind pk |> Option.defaultValue []
137−
13835 let private appendMessage (pk: string) (msg: ChatMessage) (messages: Map<string, ChatMessage list>) =
13936 let existing = messagesFor pk messages
14037
@@ -210,19 +107,12 @@ module App =
210107 Auth = Identified(id, status) }
211108 | NoIdentity -> model
212109
213− let private saveCmdMsg model =
110+ let private saveCmdMsg model : CmdMsg =
214111 CmdSaveData
215− { Contacts = model.Contacts
216− Messages = model.Messages
217− ServerUrl = model.ServerUrl
218− PollCursor = model.PollCursor }
219−
220− let private connStatusLabel =
221− function
222− | NoIdentity
223− | Identified(_, Offline) -> "OFFLINE"
224− | Identified(_, Connecting) -> "CONNECTING"
225− | Identified(_, Online _) -> "ONLINE"
112+ ({ Contacts = model.Contacts
113+ Messages = model.Messages
114+ ServerUrl = model.ServerUrl
115+ PollCursor = model.PollCursor } : Data)
226116
227117 // ── Init ──
228118
@@ -763,255 +653,6 @@ module App =
763653
764654 // ── View ──
765655
766− let private viewErrorBanner (error: string option) =
767− VStack(4.) {
768− match error with
769− | Some err ->
770− TextBlock(err).foreground(SolidColorBrush(Colors.Red)).fontSize (12.)
771− Button("Dismiss", DismissError)
772− | None -> ()
773− }
774−
775− let private viewSetup () =
776− (VStack(24.) {
777− Image("avares://Skrepka/Assets/Images/logo.png", Avalonia.Media.Stretch.Uniform)
778− .margin(8., 0., 8., 0.)
779− .maxWidth (300.)
780−
781− TextBlock("Skrepka")
782− .fontSize(32.)
783− .fontFamily(FontFamily("avares://Skrepka/Assets/Fonts#Unbounded"))
784− .fontWeight(FontWeight.Bold)
785− .centerText ()
786−
787− TextBlock("").height (50.)
788−
789− button Primary "Next" GenIdentity
790− })
791− .margin(30.)
792− .centerVertical ()
793−
794− let private viewSettings model =
795− ScrollViewer(
796− (VStack(16.) {
797− TextBlock("Settings").fontSize(24.).centerText ()
798−
799− match model.Auth with
800− | Identified(id, conn) ->
801− TextBlock("Your Public Key:")
802−
803− TextBlock(truncKey id.PubKeyHex).fontSize (14.)
804−
805− Button("Copy Public Key", CopyPubKey)
806−
807− TextBlock("Profile:").fontSize (18.)
808−
809− let displayName, bio, photo =
810− match model.Profile with
811− | Some p -> p.DisplayName, p.Bio, p.Photo
812− | None -> "", "", None
813−
814− if displayName <> "" then
815− TextBlock($"Name: {displayName}").fontSize (14.)
816−
817− if bio <> "" then
818− TextBlock($"Bio: {bio}").fontSize(14.).foreground (SolidColorBrush(Colors.DimGray))
819−
820− Button("Edit Profile", SetPage(EditProfile(displayName, bio, photo)))
821−
822− TextBlock("Server:")
823−
824− match conn with
825− | Online _ -> TextBlock(model.ServerUrl).fontSize (14.)
826− | _ -> TextBox(model.ServerUrl, SetServerUrl)
827−
828− viewErrorBanner model.Error
829−
830− match conn with
831− | Offline -> Button("Connect", DoConnect)
832− | Connecting -> TextBlock("Connecting...").centerText ()
833− | Online _ ->
834− Button("Disconnect", DoDisconnect)
835− Button("Go to Conversations", SetPage Conversations)
836− | NoIdentity -> ()
837− })
838− .margin (20.)
839− )
840−
841− let private viewConversations model =
842− let contacts = model.Contacts.Values |> Seq.toList
843−
844− (Grid([], [ Dimension.Star; Dimension.Auto ]) {
845−
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− (Grid([ Dimension.Auto; Dimension.Star; Dimension.Auto ], []) {
907− (smallImageButton None (SetPage Settings)).gridColumn (0)
908− (textField "" Search).margin(0.).margin(8.).gridColumn (1)
909− (smallTextButton "+" (SetPage(AddContact("", "")))).gridColumn (2)
910− })
911− .margin(20.)
912− .horizontalAlignment (HorizontalAlignment.Stretch)
913− )
914− .borderThickness(Avalonia.Thickness(0., 4., 0., 0.))
915− .borderBrush(SolidColorBrush(Colors.Black))
916− .background(Constants.accentColor)
917− .gridRow (1)
918− })
919− .margin (0.)
920−
921− let private viewChat model pk compose =
922− let name = contactName model.Contacts pk
923− let msgs = messagesFor pk model.Messages
924−
925− (VStack(8.) {
926− HStack(8.) {
927− Button("< Back", SetPage Conversations)
928−
929− TextBlock(name).fontSize(20.).centerVertical ()
930− }
931−
932− viewErrorBanner model.Error
933−
934− ScrollViewer(
935− VStack(4.) {
936− if msgs.IsEmpty then
937− TextBlock("No messages yet").padding(8.).fontSize (14.)
938− else
939− for m in msgs do
940− let prefix, tick =
941− match m.Direction with
942− | Outgoing DeliveryStatus.Delivered -> "You: ", " \u2713"
943− | Outgoing DeliveryStatus.Sent -> "You: ", ""
944− | Incoming -> "", ""
945−
946− TextBlock($"{prefix}{m.Body}{tick}").padding(8.).fontSize (14.)
947− }
948− )
949−
950− HStack(8.) {
951− TextBox(compose, fun text -> SetPage(Chat(pk, text)))
952− .watermark("Message...")
953− .horizontalAlignment (Avalonia.Layout.HorizontalAlignment.Stretch)
954−
955− Button("Send", DoSend)
956− }
957− })
958− .margin (12.)
959−
960− let private viewAddContact model cpk cnn =
961− (VStack(16.) {
962− HStack(8.) {
963− Button("< Back", SetPage Conversations)
964−
965− TextBlock("Add Contact").fontSize (20.)
966− }
967−
968− viewErrorBanner model.Error
969−
970− TextBlock("Public Key:")
971− TextBox(cpk, fun text -> SetPage(AddContact(text, cnn))).watermark ("sampel-palnet-...")
972−
973− TextBlock("Nickname:")
974− TextBox(cnn, fun text -> SetPage(AddContact(cpk, text)))
975−
976− Button("Save Contact", DoSaveContact).centerHorizontal ()
977− })
978− .margin (20.)
979−
980− let private viewEditProfile model displayName bio (photo: byte[] option) =
981− (Grid([], [ Dimension.Star; Dimension.Auto ]) {
982− ScrollViewer(
983− (VStack(16.) {
984− viewErrorBanner model.Error
985−
986− imageButton photo DoPickPhoto
987−
988− TextBlock("Name").fontFamily(Constants.fontFamily).fontWeight(FontWeight.Bold).margin (0, 24, 0, -8)
989− (textField displayName (fun text -> SetPage(EditProfile(text, bio, photo)))).margin (0, 0, 0, 8)
990−
991−
992− TextBlock("Bio").fontFamily(Constants.fontFamily).fontWeight(FontWeight.Bold).margin (0, 0, 0, -8)
993−
994− (multilineTextField bio (fun text -> SetPage(EditProfile(displayName, text, photo))))
995− .margin (0, 0, 0, 8)
996− })
997− .margin (20.)
998− )
999− .gridRow (0)
1000−
1001− Border(
1002−
1003− (HStack(8.) {
1004− (button Secondary "back" (SetPage Settings)).margin (8.)
1005−
1006− (button Secondary "save" DoSaveProfile).margin (8.)
1007− })
1008− .background (Constants.accentColor)
1009− )
1010− .borderThickness(Avalonia.Thickness(0., 4., 0., 0.))
1011− .borderBrush(SolidColorBrush(Colors.Black))
1012− .gridRow (1)
1013− })
1014−
1015656 let private pageContent model =
1016657 let page =
1017658 match model.Page with
client/AppTypes.fsnew file+138
@@ -0,0 +1,138 @@
1+namespace Skrepka
2+
3+open System
4+open Avalonia.Media
5+open Fabulous.Avalonia
6+
7+open type Fabulous.Avalonia.View
8+
9+module AppTypes =
10+
11+ open Store
12+ open Crypto
13+
14+ // ── Domain Types ──
15+
16+ type Page =
17+ | Setup
18+ | Conversations
19+ | Chat of pubkey: string * compose: string
20+ | AddContact of pubkey: string * nickname: string
21+ | Settings
22+ | EditProfile of displayName: string * bio: string * photo: byte[] option
23+
24+ type Session =
25+ { Url: string
26+ Token: string
27+ Identity: Identity }
28+
29+ type ConnStatus =
30+ | Offline
31+ | Connecting
32+ | Online of token: string
33+
34+ type AuthState =
35+ | NoIdentity
36+ | Identified of identity: Identity * conn: ConnStatus
37+
38+ [<RequireQualifiedAccess>]
39+ type Envelope =
40+ | TextMessage of id: string * body: string
41+ | DeliveryAck of ackIds: string list
42+ | ProfileMessage of profile: Profile
43+
44+ type IncomingEvent =
45+ { Sender: string
46+ Timestamp: DateTimeOffset
47+ Envelope: Envelope }
48+
49+ // ── Model ──
50+
51+ type Model =
52+ { Page: Page
53+ Auth: AuthState
54+ ServerUrl: string
55+ Contacts: Map<string, Contact>
56+ PollStatus: string
57+ Messages: Map<string, ChatMessage list>
58+ Error: string option
59+ PollCursor: int64
60+ Profile: Profile option
61+ FlushingOutbox: bool }
62+
63+ // ── Msg ──
64+
65+ type Msg =
66+ | SetPage of Page
67+ | GenIdentity
68+ | SetServerUrl of string
69+ | DoConnect
70+ | AuthOk of string
71+ | AuthErr of string
72+ | DoDisconnect
73+ | DoSend
74+ | DoSaveContact
75+ | PollResult of events: IncomingEvent list * status: string * cursor: int64
76+ | CopyPubKey
77+ | DismissError
78+ | StateLoaded of Identity option * Data option * Profile option
79+ | DoPickPhoto
80+ | PhotoPicked of byte[] option
81+ | DoSaveProfile
82+ | StartFlush
83+ | FlushResult of sent: bool
84+ | Search of string
85+
86+ // ── CmdMsg ──
87+
88+ type CmdMsg =
89+ | CmdConnect of url: string * identity: Identity
90+ | CmdEnqueue of recipientHex: string * envelope: Envelope
91+ | CmdFlushOutbox of session: Session
92+ | CmdPoll of session: Session * cursor: int64
93+ | CmdCopyToClipboard of string
94+ | CmdLoadState
95+ | CmdSaveIdentity of Identity
96+ | CmdSaveData of Data
97+ | CmdPickPhoto
98+ | CmdSaveProfile of Profile
99+
100+ // ── Shared Helpers ──
101+
102+ let hexToOb (hex: string) = Crypto.fromHex hex |> Phonemic.toOb
103+
104+ let truncKey (hex: string) =
105+ let ob = hexToOb hex
106+ let parts = ob.Split('-')
107+
108+ if parts.Length <= 4 then
109+ ob
110+ else
111+ $"{parts.[0]}-{parts.[1]}..{parts.[parts.Length - 2]}-{parts.[parts.Length - 1]}"
112+
113+ let contactName (contacts: Map<string, Contact>) (pk: string) =
114+ match Map.tryFind pk contacts with
115+ | Some c when c.Nickname <> "" -> c.Nickname
116+ | Some c when c.DisplayName <> "" -> c.DisplayName
117+ | _ -> truncKey pk
118+
119+ let messagesFor (pk: string) (messages: Map<string, ChatMessage list>) =
120+ messages |> Map.tryFind pk |> Option.defaultValue []
121+
122+ let connStatusLabel =
123+ function
124+ | NoIdentity
125+ | Identified(_, Offline) -> "OFFLINE"
126+ | Identified(_, Connecting) -> "CONNECTING"
127+ | Identified(_, Online _) -> "ONLINE"
128+
129+ // ── Shared View ──
130+
131+ let viewErrorBanner (error: string option) =
132+ VStack(4.) {
133+ match error with
134+ | Some err ->
135+ TextBlock(err).foreground(SolidColorBrush(Colors.Red)).fontSize (12.)
136+ Button("Dismiss", DismissError)
137+ | None -> ()
138+ }
client/Skrepka.fsproj+7
@@ -37,6 +37,13 @@
3737 <Compile Include="TextFields.fs" />
3838 <Compile Include="Buttons.fs" />
3939 <Compile Include="$(iOSPlatformFolder)PhotoPicker.fs" Condition="$(AvaloniaPlatform) == 'iOS'" />
40+ <Compile Include="AppTypes.fs" />
41+ <Compile Include="ViewSetup.fs" />
42+ <Compile Include="ViewSettings.fs" />
43+ <Compile Include="ViewConversations.fs" />
44+ <Compile Include="ViewChat.fs" />
45+ <Compile Include="ViewAddContact.fs" />
46+ <Compile Include="ViewEditProfile.fs" />
4047 <Compile Include="App.fs" />
4148 </ItemGroup>
4249
client/ViewAddContact.fsnew file+29
@@ -0,0 +1,29 @@
1+namespace Skrepka
2+
3+open Fabulous.Avalonia
4+
5+open type Fabulous.Avalonia.View
6+
7+module ViewAddContact =
8+
9+ open AppTypes
10+
11+ let viewAddContact model cpk cnn =
12+ (VStack(16.) {
13+ HStack(8.) {
14+ Button("< Back", SetPage Conversations)
15+
16+ TextBlock("Add Contact").fontSize (20.)
17+ }
18+
19+ viewErrorBanner model.Error
20+
21+ TextBlock("Public Key:")
22+ TextBox(cpk, fun text -> SetPage(AddContact(text, cnn))).watermark ("sampel-palnet-...")
23+
24+ TextBlock("Nickname:")
25+ TextBox(cnn, fun text -> SetPage(AddContact(cpk, text)))
26+
27+ Button("Save Contact", DoSaveContact).centerHorizontal ()
28+ })
29+ .margin (20.)
client/ViewChat.fsnew file+49
@@ -0,0 +1,49 @@
1+namespace Skrepka
2+
3+open Fabulous.Avalonia
4+
5+open type Fabulous.Avalonia.View
6+
7+module ViewChat =
8+
9+ open Store
10+ open AppTypes
11+
12+ let viewChat model pk compose =
13+ let name = contactName model.Contacts pk
14+ let msgs = messagesFor pk model.Messages
15+
16+ (VStack(8.) {
17+ HStack(8.) {
18+ Button("< Back", SetPage Conversations)
19+
20+ TextBlock(name).fontSize(20.).centerVertical ()
21+ }
22+
23+ viewErrorBanner model.Error
24+
25+ ScrollViewer(
26+ VStack(4.) {
27+ if msgs.IsEmpty then
28+ TextBlock("No messages yet").padding(8.).fontSize (14.)
29+ else
30+ for m in msgs do
31+ let prefix, tick =
32+ match m.Direction with
33+ | Outgoing DeliveryStatus.Delivered -> "You: ", " \u2713"
34+ | Outgoing DeliveryStatus.Sent -> "You: ", ""
35+ | Incoming -> "", ""
36+
37+ TextBlock($"{prefix}{m.Body}{tick}").padding(8.).fontSize (14.)
38+ }
39+ )
40+
41+ HStack(8.) {
42+ TextBox(compose, fun text -> SetPage(Chat(pk, text)))
43+ .watermark("Message...")
44+ .horizontalAlignment (Avalonia.Layout.HorizontalAlignment.Stretch)
45+
46+ Button("Send", DoSend)
47+ }
48+ })
49+ .margin (12.)
client/ViewConversations.fsnew file+94
@@ -0,0 +1,94 @@
1+namespace Skrepka
2+
3+open Avalonia.Media
4+open Fabulous.Avalonia
5+
6+open type Fabulous.Avalonia.View
7+
8+module ViewConversations =
9+
10+ open AppTypes
11+ open Buttons
12+ open TextFields
13+ open Styles
14+ open Avalonia.Layout
15+
16+ let viewConversations model =
17+ let contacts = model.Contacts.Values |> Seq.toList
18+
19+ (Grid([], [ Dimension.Star; Dimension.Auto ]) {
20+
21+ if contacts.IsEmpty then
22+ TextBlock("No contacts yet. Tap + to add one.")
23+ .centerText()
24+ .foreground(SolidColorBrush(Colors.Gray))
25+ .gridRow (1)
26+
27+ if not contacts.IsEmpty then
28+ ListBox(
29+ contacts,
30+ fun c ->
31+ let name = contactName model.Contacts c.Pubkey
32+
33+ let preview =
34+ messagesFor c.Pubkey model.Messages
35+ |> List.tryLast
36+ |> Option.map (fun m -> if m.Body.Length > 40 then m.Body.[..39] + "..." else m.Body)
37+ |> Option.defaultValue ""
38+
39+ Border(
40+ (HStack(8.) {
41+
42+ Grid() {
43+ (match c.Photo with
44+ | None -> Image("avares://Skrepka/Assets/Images/user.png", Stretch.Uniform)
45+ | Some i ->
46+ Image(
47+ new Avalonia.Media.Imaging.Bitmap(new System.IO.MemoryStream(i)),
48+ Stretch.UniformToFill
49+ ))
50+ .width(16.)
51+ .height(16.)
52+ .margin(8.)
53+ .clipToBounds (true)
54+
55+ Rectangle().stroke(Colors.Black).strokeThickness (4.)
56+ }
57+
58+ VStack(2.) {
59+ TextBlock(name)
60+ .fontFamily(Constants.fontFamily)
61+ .fontWeight(FontWeight.Bold)
62+ .fontSize (16.)
63+
64+ if preview <> "" then
65+ TextBlock(preview).fontSize(12.).fontWeight (FontWeight.Bold)
66+ }
67+
68+ })
69+ .margin (8.)
70+ )
71+ .borderThickness(Avalonia.Thickness(0., 0., 0., 4.))
72+ .borderBrush(SolidColorBrush(Colors.Black))
73+ .background(Colors.Transparent)
74+ .onTapped (fun _ -> SetPage(Chat(c.Pubkey, "")))
75+ )
76+ .styles(noListBoxPadding ())
77+ .background(Colors.White)
78+ .gridRow (0)
79+
80+ Border(
81+ (Grid([ Dimension.Auto; Dimension.Star; Dimension.Auto ], []) {
82+ (smallImageButton None (SetPage Settings)).gridColumn (0)
83+ (textField "" Search).margin(0.).margin(8.).gridColumn (1)
84+ (smallTextButton "+" (SetPage(AddContact("", "")))).gridColumn (2)
85+ })
86+ .margin(20.)
87+ .horizontalAlignment (HorizontalAlignment.Stretch)
88+ )
89+ .borderThickness(Avalonia.Thickness(0., 4., 0., 0.))
90+ .borderBrush(SolidColorBrush(Colors.Black))
91+ .background(Constants.accentColor)
92+ .gridRow (1)
93+ })
94+ .margin (0.)
client/ViewEditProfile.fsnew file+47
@@ -0,0 +1,47 @@
1+namespace Skrepka
2+
3+open Avalonia.Media
4+open Fabulous.Avalonia
5+
6+open type Fabulous.Avalonia.View
7+
8+module ViewEditProfile =
9+
10+ open AppTypes
11+ open Buttons
12+ open TextFields
13+
14+ let viewEditProfile model displayName bio (photo: byte[] option) =
15+ (Grid([], [ Dimension.Star; Dimension.Auto ]) {
16+ ScrollViewer(
17+ (VStack(16.) {
18+ viewErrorBanner model.Error
19+
20+ imageButton photo DoPickPhoto
21+
22+ TextBlock("Name").fontFamily(Constants.fontFamily).fontWeight(FontWeight.Bold).margin (0, 24, 0, -8)
23+ (textField displayName (fun text -> SetPage(EditProfile(text, bio, photo)))).margin (0, 0, 0, 8)
24+
25+
26+ TextBlock("Bio").fontFamily(Constants.fontFamily).fontWeight(FontWeight.Bold).margin (0, 0, 0, -8)
27+
28+ (multilineTextField bio (fun text -> SetPage(EditProfile(displayName, text, photo))))
29+ .margin (0, 0, 0, 8)
30+ })
31+ .margin (20.)
32+ )
33+ .gridRow (0)
34+
35+ Border(
36+
37+ (HStack(8.) {
38+ (button Secondary "back" (SetPage Settings)).margin (8.)
39+
40+ (button Secondary "save" DoSaveProfile).margin (8.)
41+ })
42+ .background (Constants.accentColor)
43+ )
44+ .borderThickness(Avalonia.Thickness(0., 4., 0., 0.))
45+ .borderBrush(SolidColorBrush(Colors.Black))
46+ .gridRow (1)
47+ })
client/ViewSettings.fsnew file+57
@@ -0,0 +1,57 @@
1+namespace Skrepka
2+
3+open Avalonia.Media
4+open Fabulous.Avalonia
5+
6+open type Fabulous.Avalonia.View
7+
8+module ViewSettings =
9+
10+ open AppTypes
11+
12+ let viewSettings model =
13+ ScrollViewer(
14+ (VStack(16.) {
15+ TextBlock("Settings").fontSize(24.).centerText ()
16+
17+ match model.Auth with
18+ | Identified(id, conn) ->
19+ TextBlock("Your Public Key:")
20+
21+ TextBlock(truncKey id.PubKeyHex).fontSize (14.)
22+
23+ Button("Copy Public Key", CopyPubKey)
24+
25+ TextBlock("Profile:").fontSize (18.)
26+
27+ let displayName, bio, photo =
28+ match model.Profile with
29+ | Some p -> p.DisplayName, p.Bio, p.Photo
30+ | None -> "", "", None
31+
32+ if displayName <> "" then
33+ TextBlock($"Name: {displayName}").fontSize (14.)
34+
35+ if bio <> "" then
36+ TextBlock($"Bio: {bio}").fontSize(14.).foreground (SolidColorBrush(Colors.DimGray))
37+
38+ Button("Edit Profile", SetPage(EditProfile(displayName, bio, photo)))
39+
40+ TextBlock("Server:")
41+
42+ match conn with
43+ | Online _ -> TextBlock(model.ServerUrl).fontSize (14.)
44+ | _ -> TextBox(model.ServerUrl, SetServerUrl)
45+
46+ viewErrorBanner model.Error
47+
48+ match conn with
49+ | Offline -> Button("Connect", DoConnect)
50+ | Connecting -> TextBlock("Connecting...").centerText ()
51+ | Online _ ->
52+ Button("Disconnect", DoDisconnect)
53+ Button("Go to Conversations", SetPage Conversations)
54+ | NoIdentity -> ()
55+ })
56+ .margin (20.)
57+ )
client/ViewSetup.fsnew file+30
@@ -0,0 +1,30 @@
1+namespace Skrepka
2+
3+open Avalonia.Media
4+open Fabulous.Avalonia
5+
6+open type Fabulous.Avalonia.View
7+
8+module ViewSetup =
9+
10+ open AppTypes
11+ open Buttons
12+
13+ let viewSetup () =
14+ (VStack(24.) {
15+ Image("avares://Skrepka/Assets/Images/logo.png", Avalonia.Media.Stretch.Uniform)
16+ .margin(8., 0., 8., 0.)
17+ .maxWidth (300.)
18+
19+ TextBlock("Skrepka")
20+ .fontSize(32.)
21+ .fontFamily(FontFamily("avares://Skrepka/Assets/Fonts#Unbounded"))
22+ .fontWeight(FontWeight.Bold)
23+ .centerText ()
24+
25+ TextBlock("").height (50.)
26+
27+ button Primary "Next" GenIdentity
28+ })
29+ .margin(30.)
30+ .centerVertical ()