@@ -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