7open Microsoft.Maui.Graphics
9open type Fabulous.Maui.View
15 type Contact = { Pubkey: string; Nickname: string }
20 Timestamp: DateTimeOffset
33 | Online of token: string
39 PrivKey: byte[] option
43 Contacts: Contact list
45 Messages: Map<string, ChatMessage list>
49 Error: string option }
56 | IdentityReady of byte[] * byte[]
57 | SetServerUrl of string
62 | SetCompose of string
64 | Sent of string * string
65 | SetNewPubkey of string
66 | SetNewNickname of string
68 | DoDeleteContact of string
69 | PollResult of (string * string * int64 * string) list * string
78 | CmdConnect of url: string * pubHex: string * privKey: byte[]
87 | CmdPoll of url: string * token: string * privKey: byte[]
88 | CmdCopyToClipboard of string
92 let private truncKey (hex: string) =
93 if hex.Length > 16 then hex.[..15] + "..." else hex
95 let private contactName (contacts: Contact list) (pk: string) =
97 |> List.tryFind (fun c -> c.Pubkey = pk)
98 |> Option.map _.Nickname
99 |> Option.defaultValue (truncKey pk)
107 ServerUrl = "http://localhost:8080"
120 let update msg model =
122 | Nav page -> { model with Page = page }, []
124 | GenIdentity -> model, [ CmdGenIdentity ]
126 | IdentityReady(priv, pub) ->
129 PubKeyHex = Crypto.toHex pub
133 | SetServerUrl url -> { model with ServerUrl = url }, []
136 match model.PrivKey with
138 { model with Conn = Connecting }, [ CmdConnect(model.ServerUrl, model.PubKeyHex, priv) ]
139 | None -> { model with Error = Some "No identity generated" }, []
146 [ CmdPoll(model.ServerUrl, token, model.PrivKey.Value) ]
148 | AuthErr err -> { model with Conn = Offline; Error = Some err }, []
150 | DoDisconnect -> { model with Conn = Offline }, []
152 | SetCompose text -> { model with ComposeText = text }, []
155 match model.Page, model.Conn, model.PrivKey with
156 | Chat pk, Online token, Some priv when model.ComposeText <> "" ->
157 let text = model.ComposeText
159 { Id = Guid.NewGuid().ToString()
161 Timestamp = DateTimeOffset.UtcNow
163 let msgs = model.Messages |> Map.tryFind pk |> Option.defaultValue []
167 Messages = model.Messages |> Map.add pk (msgs @ [ outMsg ]) },
168 [ CmdSend(model.ServerUrl, token, pk, priv, model.PubKeyHex, pk, text) ]
171 | Sent _ -> model, []
173 | PollResult(incoming, status) ->
177 (fun m (fromPk, body, ts, id) ->
178 let msgs = m.Messages |> Map.tryFind fromPk |> Option.defaultValue []
180 if msgs |> List.exists (fun msg -> msg.Id = id) then
186 Timestamp = DateTimeOffset.FromUnixTimeSeconds(ts)
190 if m.Contacts |> List.exists (fun c -> c.Pubkey = fromPk) then
193 m.Contacts @ [ { Pubkey = fromPk; Nickname = truncKey fromPk } ]
197 Messages = m.Messages |> Map.add fromPk (msgs @ [ newMsg ]) })
198 { model with PollStatus = status }
200 match model'.Conn with
201 | Online token -> model', [ CmdPoll(model'.ServerUrl, token, model'.PrivKey.Value) ]
205 match model.Conn, model.PrivKey with
206 | Online token, Some priv -> model, [ CmdPoll(model.ServerUrl, token, priv) ]
209 | SetNewPubkey pk -> { model with NewPubkey = pk }, []
210 | SetNewNickname nn -> { model with NewNickname = nn }, []
213 if model.NewPubkey <> "" && model.NewNickname <> "" then
215 { Pubkey = model.NewPubkey
216 Nickname = model.NewNickname }
219 if model.Contacts |> List.exists (fun c -> c.Pubkey = model.NewPubkey) then
221 |> List.map (fun c ->
222 if c.Pubkey = model.NewPubkey then contact else c)
224 model.Contacts @ [ contact ]
230 Page = Conversations },
235 | DoDeleteContact pk ->
237 Contacts = model.Contacts |> List.filter (fun c -> c.Pubkey <> pk) },
240 | CopyPubKey -> model, [ CmdCopyToClipboard model.PubKeyHex ]
242 | DismissError -> { model with Error = None }, []
246 let private asyncCmd (op: Async<Msg>) : Cmd<Msg> =
247 Cmd.ofEffect (fun dispatch ->
248 System.Threading.Tasks.Task.Run(Func<System.Threading.Tasks.Task>(fun () ->
250 let! msg = Async.StartAsTask op
259 let priv, pub = Crypto.generateIdentity ()
260 return IdentityReady(priv, pub)
263 | CmdConnect(url, pubHex, privKey) ->
266 let! challenge = ApiClient.requestChallenge url pubHex
267 let sigHex = Crypto.signChallenge privKey challenge
268 let! (token, _) = ApiClient.verify url pubHex challenge sigHex
270 if String.IsNullOrEmpty(token) then
271 return AuthErr "Authentication rejected by server"
275 return AuthErr ex.Message
278 | CmdSend(url, token, toHex, privKey, pubKeyHex, recipPubHex, text) ->
281 let pubKey = Crypto.fromHex pubKeyHex
282 let recipPub = Crypto.fromHex recipPubHex
283 let id = Guid.NewGuid().ToString()
284 let ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
287 $"""{{ "type": "text", "id": "{id}", "timestamp": {ts}, "body": {JsonSerializer.Serialize(text)} }}"""
289 let blob = Crypto.encrypt privKey pubKey recipPub payload
290 let blobHex = Crypto.toHex blob
291 let! (_, msgId) = ApiClient.sendMessage url token toHex blobHex ts
292 return Sent(toHex, msgId)
294 return DismissError // TODO: show send errors properly
297 | CmdPoll(url, token, privKey) ->
300 let! (events, _) = ApiClient.poll url token
301 let messages = ResizeArray()
302 let ackIds = ResizeArray()
303 let errors = ResizeArray()
305 for (evtId, evtType, payload) in events do
306 if evtType = "message" then
308 let doc = JsonDocument.Parse(payload)
309 let blobHex = doc.RootElement.GetProperty("encryptedBlob").GetString()
310 let ts = doc.RootElement.GetProperty("timestamp").GetInt64()
311 let blob = Crypto.fromHex blobHex
313 match Crypto.decrypt privKey blob with
314 | Some(plaintext, senderHex) ->
315 let ptDoc = JsonDocument.Parse(plaintext)
316 let ptRoot = ptDoc.RootElement
318 if ptRoot.GetProperty("type").GetString() = "text" then
319 let msgId = ptRoot.GetProperty("id").GetString()
320 let body = ptRoot.GetProperty("body").GetString()
321 messages.Add((senderHex, body, ts, msgId))
325 errors.Add("decrypt failed")
327 errors.Add($"parse: {ex.Message}")
329 if ackIds.Count > 0 then
330 do! ApiClient.ackMessages url token (Seq.toList ackIds)
332 let now = DateTimeOffset.UtcNow.ToString("HH:mm:ss")
335 $"[{now}] evts:{events.Length} msgs:{messages.Count} errs:{errors.Count}"
336 + (if errors.Count > 0 then $" [{errors.[0]}]" else "")
338 return PollResult(Seq.toList messages, status)
340 | :? System.Threading.Tasks.TaskCanceledException ->
341 // Normal timeout from long poll — just retry
342 return PollResult([], "polling...")
344 let now = DateTimeOffset.UtcNow.ToString("HH:mm:ss")
346 return PollResult([], $"[{now}] poll error: {ex.Message}")
349 | CmdCopyToClipboard text ->
350 Cmd.ofEffect (fun _ ->
351 Microsoft.Maui.ApplicationModel.DataTransfer.Clipboard.Default.SetTextAsync(text)
356 let private viewSetup () =
358 (VStack(spacing = 24.) {
361 .centerTextHorizontal()
363 Label("End-to-end encrypted messaging")
365 .centerTextHorizontal()
367 Button("Generate Identity", GenIdentity)
374 let private viewSettings model =
377 (VStack(spacing = 16.) {
380 .centerTextHorizontal()
382 Label("Your Public Key:")
384 Label(model.PubKeyHex)
387 Button("Copy Public Key", CopyPubKey)
391 Entry(model.ServerUrl, SetServerUrl)
393 if model.Error.IsSome then
394 Label(model.Error.Value)
395 .textColor(Colors.Red)
398 Button("Dismiss", DismissError)
400 match model.Conn with
401 | Offline -> Button("Connect", DoConnect)
402 | Connecting -> Label("Connecting...").centerTextHorizontal()
404 Button("Disconnect", DoDisconnect)
405 Button("Go to Conversations", Nav Conversations)
411 let private viewConversations model =
413 match model.Conn with
414 | Offline -> "OFFLINE"
415 | Connecting -> "CONNECTING"
416 | Online _ -> "ONLINE"
420 |> List.map _.Nickname
421 |> String.concat ", "
424 model.Messages |> Map.fold (fun acc _ msgs -> acc + List.length msgs) 0
428 (VStack(spacing = 8.) {
429 HStack(spacing = 8.) {
430 Label("Conversations")
433 Button("+", Nav AddContact)
434 Button("Settings", Nav Settings)
437 Label($"conn:{connStr} contacts:{model.Contacts.Length} msgs:{totalMsgs}")
439 .textColor(Colors.DimGray)
441 Label($"poll: {model.PollStatus}")
443 .textColor(Colors.DimGray)
445 if contactNames <> "" then
446 Label($"names: {contactNames}")
448 .textColor(Colors.DimGray)
450 if model.Contacts.IsEmpty then
451 Label("No contacts yet. Tap + to add one.")
452 .centerTextHorizontal()
453 .textColor(Colors.Gray)
455 for c in model.Contacts do
458 |> Map.tryFind c.Pubkey
459 |> Option.bind List.tryLast
461 |> Option.defaultValue ""
464 if lastMsg.Length > 40 then lastMsg.[..39] + "..." else lastMsg
466 Button($"{c.Nickname}\n{preview}", Nav(Chat c.Pubkey))
472 let private viewChat model pk =
473 let name = contactName model.Contacts pk
474 let msgs = model.Messages |> Map.tryFind pk |> Option.defaultValue []
477 (VStack(spacing = 8.) {
478 HStack(spacing = 8.) {
479 Button("< Back", Nav Conversations)
483 .centerTextHorizontal()
486 if model.Error.IsSome then
487 Label(model.Error.Value)
488 .textColor(Colors.Red)
491 Label($"[{msgs.Length} message(s), compose: \"{model.ComposeText}\"]")
492 .textColor(Colors.Gray)
497 |> List.map (fun m ->
498 if m.IsOutgoing then $"You: {m.Body}" else m.Body)
499 |> String.concat "\n"
501 Label(if allMessages = "" then "No messages yet" else allMessages)
505 HStack(spacing = 8.) {
506 Entry(model.ComposeText, SetCompose)
507 .placeholder("Message...")
509 Button("Send", DoSend)
515 let private viewAddContact model =
517 (VStack(spacing = 16.) {
518 HStack(spacing = 8.) {
519 Button("< Back", Nav Conversations)
525 Label("Public Key (hex):")
526 Entry(model.NewPubkey, SetNewPubkey)
529 Entry(model.NewNickname, SetNewNickname)
531 Button("Save Contact", DoSaveContact)
539 match model.Page with
540 | Setup -> viewSetup ()
541 | Settings -> viewSettings model
542 | Conversations -> viewConversations model
543 | Chat pk -> viewChat model pk
544 | AddContact -> viewAddContact model
546 Application() { Window(page) }
549 Program.statefulWithCmdMsg init update mapCmd
550 |> Program.withView view
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list