@@ -15,110 +15,16 @@ module App =
15 open Store
15 open Store
16 open Crypto
16 open Crypto
17 open ApiClient
17 open ApiClient
18− open Buttons
18+ open AppTypes
19− open TextFields
19+ open ViewSetup
20− open Styles
20+ open ViewSettings
21− open Avalonia.Layout
21+ open ViewConversations
23− // ── Domain Types ──
23+ open ViewAddContact
24−
24+ open ViewEditProfile
28− | Chat of pubkey: string * compose: string
29− | AddContact of pubkey: string * nickname: string
31− | EditProfile of displayName: string * bio: string * photo: byte[] option
41− | Online of token: string
45− | Identified of identity: Identity * conn: ConnStatus
47− [<RequireQualifiedAccess>]
49− | TextMessage of id: string * body: string
50− | DeliveryAck of ackIds: string list
51− | ProfileMessage of profile: Profile
55− Timestamp: DateTimeOffset
64− Contacts: Map<string, Contact>
66− Messages: Map<string, ChatMessage list>
69− Profile: Profile option
70− FlushingOutbox: bool }
77− | SetServerUrl of string
84− | PollResult of events: IncomingEvent list * status: string * cursor: int64
87− | StateLoaded of Identity option * Data option * Profile option
89− | PhotoPicked of byte[] option
92− | FlushResult of sent: bool
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
104− | CmdSaveIdentity of Identity
105− | CmdSaveData of Data
107− | CmdSaveProfile of Profile
109 // ── Helpers ──
26 // ── Helpers ──
111− let private hexToOb (hex: string) = Crypto.fromHex hex |> Phonemic.toOb
113− let private truncKey (hex: string) =
114− let ob = hexToOb hex
115− let parts = ob.Split('-')
117− if parts.Length <= 4 then
120− $"{parts.[0]}-{parts.[1]}..{parts.[parts.Length - 2]}-{parts.[parts.Length - 1]}"
122 let private tryParsePubkey (input: string) =
28 let private tryParsePubkey (input: string) =
123 let input = input.Trim()
29 let input = input.Trim()
@@ -126,15 +32,6 @@ module App =
126 |> Option.orElseWith (fun () -> Crypto.tryFromHex input)
32 |> Option.orElseWith (fun () -> Crypto.tryFromHex input)
127 |> Option.filter (fun bytes -> bytes.Length = 32)
33 |> Option.filter (fun bytes -> bytes.Length = 32)
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
135− let private messagesFor (pk: string) (messages: Map<string, ChatMessage list>) =
136− messages |> Map.tryFind pk |> Option.defaultValue []
138 let private appendMessage (pk: string) (msg: ChatMessage) (messages: Map<string, ChatMessage list>) =
35 let private appendMessage (pk: string) (msg: ChatMessage) (messages: Map<string, ChatMessage list>) =
139 let existing = messagesFor pk messages
36 let existing = messagesFor pk messages
@@ -210,19 +107,12 @@ module App =
210 Auth = Identified(id, status) }
107 Auth = Identified(id, status) }
211 | NoIdentity -> model
108 | NoIdentity -> model
213− let private saveCmdMsg model =
110+ let private saveCmdMsg model : CmdMsg =
214 CmdSaveData
111 CmdSaveData
215− { Contacts = model.Contacts
112+ ({ Contacts = model.Contacts
216− Messages = model.Messages
113+ Messages = model.Messages
217− ServerUrl = model.ServerUrl
114+ ServerUrl = model.ServerUrl
218− PollCursor = model.PollCursor }
115+ PollCursor = model.PollCursor } : Data)
220− let private connStatusLabel =
223− | Identified(_, Offline) -> "OFFLINE"
224− | Identified(_, Connecting) -> "CONNECTING"
225− | Identified(_, Online _) -> "ONLINE"
227 // ── Init ──
117 // ── Init ──
@@ -763,255 +653,6 @@ module App =
764 // ── View ──
654 // ── View ──
766− let private viewErrorBanner (error: string option) =
770− TextBlock(err).foreground(SolidColorBrush(Colors.Red)).fontSize (12.)
771− Button("Dismiss", DismissError)
775− let private viewSetup () =
777− Image("avares://Skrepka/Assets/Images/logo.png", Avalonia.Media.Stretch.Uniform)
778− .margin(8., 0., 8., 0.)
781− TextBlock("Skrepka")
783− .fontFamily(FontFamily("avares://Skrepka/Assets/Fonts#Unbounded"))
784− .fontWeight(FontWeight.Bold)
787− TextBlock("").height (50.)
789− button Primary "Next" GenIdentity
794− let private viewSettings model =
797− TextBlock("Settings").fontSize(24.).centerText ()
799− match model.Auth with
800− | Identified(id, conn) ->
801− TextBlock("Your Public Key:")
803− TextBlock(truncKey id.PubKeyHex).fontSize (14.)
805− Button("Copy Public Key", CopyPubKey)
807− TextBlock("Profile:").fontSize (18.)
809− let displayName, bio, photo =
810− match model.Profile with
811− | Some p -> p.DisplayName, p.Bio, p.Photo
812− | None -> "", "", None
814− if displayName <> "" then
815− TextBlock($"Name: {displayName}").fontSize (14.)
818− TextBlock($"Bio: {bio}").fontSize(14.).foreground (SolidColorBrush(Colors.DimGray))
820− Button("Edit Profile", SetPage(EditProfile(displayName, bio, photo)))
822− TextBlock("Server:")
825− | Online _ -> TextBlock(model.ServerUrl).fontSize (14.)
826− | _ -> TextBox(model.ServerUrl, SetServerUrl)
828− viewErrorBanner model.Error
831− | Offline -> Button("Connect", DoConnect)
832− | Connecting -> TextBlock("Connecting...").centerText ()
834− Button("Disconnect", DoDisconnect)
835− Button("Go to Conversations", SetPage Conversations)
841− let private viewConversations model =
842− let contacts = model.Contacts.Values |> Seq.toList
844− (Grid([], [ Dimension.Star; Dimension.Auto ]) {
846− if contacts.IsEmpty then
847− TextBlock("No contacts yet. Tap + to add one.")
849− .foreground(SolidColorBrush(Colors.Gray))
852− if not contacts.IsEmpty then
856− let name = contactName model.Contacts c.Pubkey
859− messagesFor c.Pubkey model.Messages
861− |> Option.map (fun m -> if m.Body.Length > 40 then m.Body.[..39] + "..." else m.Body)
862− |> Option.defaultValue ""
869− | None -> Image("avares://Skrepka/Assets/Images/user.png", Stretch.Uniform)
872− new Avalonia.Media.Imaging.Bitmap(new System.IO.MemoryStream(i)),
873− Stretch.UniformToFill
878− .clipToBounds (true)
880− Rectangle().stroke(Colors.Black).strokeThickness (4.)
885− .fontFamily(Constants.fontFamily)
886− .fontWeight(FontWeight.Bold)
889− if preview <> "" then
890− TextBlock(preview).fontSize(12.).fontWeight (FontWeight.Bold)
896− .borderThickness(Avalonia.Thickness(0., 0., 0., 4.))
897− .borderBrush(SolidColorBrush(Colors.Black))
898− .background(Colors.Transparent)
899− .onTapped (fun _ -> SetPage(Chat(c.Pubkey, "")))
901− .styles(noListBoxPadding ())
902− .background(Colors.White)
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)
912− .horizontalAlignment (HorizontalAlignment.Stretch)
914− .borderThickness(Avalonia.Thickness(0., 4., 0., 0.))
915− .borderBrush(SolidColorBrush(Colors.Black))
916− .background(Constants.accentColor)
921− let private viewChat model pk compose =
922− let name = contactName model.Contacts pk
923− let msgs = messagesFor pk model.Messages
927− Button("< Back", SetPage Conversations)
929− TextBlock(name).fontSize(20.).centerVertical ()
932− viewErrorBanner model.Error
936− if msgs.IsEmpty then
937− TextBlock("No messages yet").padding(8.).fontSize (14.)
941− match m.Direction with
942− | Outgoing DeliveryStatus.Delivered -> "You: ", " \u2713"
943− | Outgoing DeliveryStatus.Sent -> "You: ", ""
944− | Incoming -> "", ""
946− TextBlock($"{prefix}{m.Body}{tick}").padding(8.).fontSize (14.)
951− TextBox(compose, fun text -> SetPage(Chat(pk, text)))
952− .watermark("Message...")
953− .horizontalAlignment (Avalonia.Layout.HorizontalAlignment.Stretch)
955− Button("Send", DoSend)
960− let private viewAddContact model cpk cnn =
963− Button("< Back", SetPage Conversations)
965− TextBlock("Add Contact").fontSize (20.)
968− viewErrorBanner model.Error
970− TextBlock("Public Key:")
971− TextBox(cpk, fun text -> SetPage(AddContact(text, cnn))).watermark ("sampel-palnet-...")
973− TextBlock("Nickname:")
974− TextBox(cnn, fun text -> SetPage(AddContact(cpk, text)))
976− Button("Save Contact", DoSaveContact).centerHorizontal ()
980− let private viewEditProfile model displayName bio (photo: byte[] option) =
981− (Grid([], [ Dimension.Star; Dimension.Auto ]) {
984− viewErrorBanner model.Error
986− imageButton photo DoPickPhoto
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)
992− TextBlock("Bio").fontFamily(Constants.fontFamily).fontWeight(FontWeight.Bold).margin (0, 0, 0, -8)
994− (multilineTextField bio (fun text -> SetPage(EditProfile(displayName, text, photo))))
995− .margin (0, 0, 0, 8)
1004− (button Secondary "back" (SetPage Settings)).margin (8.)
1006− (button Secondary "save" DoSaveProfile).margin (8.)
1008− .background (Constants.accentColor)
1010− .borderThickness(Avalonia.Thickness(0., 4., 0., 0.))
1011− .borderBrush(SolidColorBrush(Colors.Black))
1015 let private pageContent model =
656 let private pageContent model =
1016 let page =
657 let page =
1017 match model.Page with
658 match model.Page with