@@ -40,8 +40,8 @@ module App =
40 type Page =
40 type Page =
42 | Conversations
42 | Conversations
43− | Chat of pubkey: string
43+ | Chat of pubkey: string * compose: string
44− | AddContact
44+ | AddContact of pubkey: string * nickname: string
45 | Settings
45 | Settings
47 type Session = { Url: string; Token: string; Identity: Identity }
47 type Session = { Url: string; Token: string; Identity: Identity }
@@ -55,6 +55,14 @@ module App =
55 | NoIdentity
55 | NoIdentity
56 | Identified of identity: Identity * conn: ConnStatus
56 | Identified of identity: Identity * conn: ConnStatus
58+ type IncomingMessage = { SenderPubkey: string; Message: ChatMessage }
63+ | Received of events: int * messages: int * errors: int * firstError: string option
64+ | PollError of string
58 // ── Model ──
66 // ── Model ──
60 type Model =
68 type Model =
@@ -62,13 +70,10 @@ module App =
62 Auth: AuthState
70 Auth: AuthState
63 ServerUrl: string
71 ServerUrl: string
64 Contacts: Contact list
72 Contacts: Contact list
65− PollStatus: string
73+ PollStatus: PollStatus
66 Messages: Map<string, ChatMessage list>
74 Messages: Map<string, ChatMessage list>
67 Error: string option
75 Error: string option
68− PollCursor: int64
76+ PollCursor: int64 }
70− ContactPubkey: string
71− ContactNickname: string }
73 // ── Msg ──
78 // ── Msg ──
@@ -92,7 +97,7 @@ module App =
93 | SendFailed of string
98 | SendFailed of string
94 | DoSaveContact
99 | DoSaveContact
95− | PollResult of messages: (string * ChatMessage) list * status: string * cursor: int64
100+ | PollResult of messages: IncomingMessage list * status: PollStatus * cursor: int64
96 | CopyPubKey
101 | CopyPubKey
97 | DismissError
102 | DismissError
98 | StateLoaded of StateLoadResult
103 | StateLoaded of StateLoadResult
@@ -111,12 +116,6 @@ module App =
112 // ── Helpers ──
117 // ── Helpers ──
114− let private nowStr () =
115− DateTimeOffset.UtcNow.ToString("HH:mm:ss")
117− let private truncate maxLen (s: string) =
118− if s.Length > maxLen then s.[..maxLen - 1] + "..." else s
120 let private hexToOb (hex: string) =
119 let private hexToOb (hex: string) =
121 Crypto.fromHex hex |> Phonemic.toOb
120 Crypto.fromHex hex |> Phonemic.toOb
@@ -178,29 +177,35 @@ module App =
178 { Page = Setup
177 { Page = Setup
179 Auth = NoIdentity
178 Auth = NoIdentity
180 ServerUrl = "http://localhost:8080"
179 ServerUrl = "http://localhost:8080"
181− PollStatus = ""
180+ PollStatus = Idle
182 Contacts = []
181 Contacts = []
183 Messages = Map.empty
182 Messages = Map.empty
184 Error = None
183 Error = None
185− PollCursor = 0L
184+ PollCursor = 0L },
188− ContactNickname = "" },
189 [ CmdLoadState ]
185 [ CmdLoadState ]
191 // ── Update ──
187 // ── Update ──
193 let update msg model =
189 let update msg model =
194 match msg with
190 match msg with
195− | SetPage(Chat pk) ->
191+ | SetPage(Chat(pk, _)) ->
196− { model with Page = Chat pk; Compose = "" }, []
192+ { model with Page = Chat(pk, "") }, []
197− | SetPage AddContact ->
193+ | SetPage(AddContact _) ->
198− { model with Page = AddContact; ContactPubkey = ""; ContactNickname = "" }, []
194+ { model with Page = AddContact("", "") }, []
199 | SetPage page -> { model with Page = page }, []
195 | SetPage page -> { model with Page = page }, []
201− | SetCompose text -> { model with Compose = text }, []
197+ | SetCompose text ->
198+ match model.Page with
199+ | Chat(pk, _) -> { model with Page = Chat(pk, text) }, []
202− | SetContactPubkey pk -> { model with ContactPubkey = pk }, []
201+ | SetContactPubkey pk ->
202+ match model.Page with
203+ | AddContact(_, nn) -> { model with Page = AddContact(pk, nn) }, []
203− | SetContactNickname nn -> { model with ContactNickname = nn }, []
205+ | SetContactNickname nn ->
206+ match model.Page with
207+ | AddContact(pk, _) -> { model with Page = AddContact(pk, nn) }, []
205 | GenIdentity -> model, [ CmdGenIdentity ]
210 | GenIdentity -> model, [ CmdGenIdentity ]
@@ -230,21 +235,20 @@ module App =
231 | DoSend ->
236 | DoSend ->
232 match model.Page, trySession model with
237 match model.Page, trySession model with
233− | Chat pk, Some session when model.Compose <> "" ->
238+ | Chat(pk, compose), Some session when compose <> "" ->
234− let text = model.Compose
235 let id = Guid.NewGuid().ToString()
239 let id = Guid.NewGuid().ToString()
236 let outMsg =
240 let outMsg =
237 { Id = id
241 { Id = id
238− Body = text
242+ Body = compose
239 Timestamp = DateTimeOffset.UtcNow
243 Timestamp = DateTimeOffset.UtcNow
240 IsOutgoing = true }
244 IsOutgoing = true }
242 let model' =
246 let model' =
243 { model with
247 { model with
244− Compose = ""
248+ Page = Chat(pk, "")
245 Messages = model.Messages |> appendMessage pk outMsg }
249 Messages = model.Messages |> appendMessage pk outMsg }
247− model', [ CmdSend(session, pk, text, id); saveCmdMsg model' ]
251+ model', [ CmdSend(session, pk, compose, id); saveCmdMsg model' ]
248 | _ -> model, []
252 | _ -> model, []
250 | SendFailed err -> { model with Error = Some err }, []
254 | SendFailed err -> { model with Error = Some err }, []
@@ -253,10 +257,10 @@ module App =
253 let model' =
257 let model' =
255 |> List.fold
259 |> List.fold
256− (fun (m: Model) (fromPubkey, chatMsg) ->
260+ (fun (m: Model) msg ->
258− Contacts = m.Contacts |> addContactIfNew { Pubkey = fromPubkey; Nickname = truncKey fromPubkey }
262+ Contacts = m.Contacts |> addContactIfNew { Pubkey = msg.SenderPubkey; Nickname = truncKey msg.SenderPubkey }
259− Messages = m.Messages |> appendMessage fromPubkey chatMsg })
263+ Messages = m.Messages |> appendMessage msg.SenderPubkey msg.Message })
260 { model with PollStatus = status; PollCursor = newCursor }
264 { model with PollStatus = status; PollCursor = newCursor }
@@ -266,9 +270,8 @@ module App =
266 if not incoming.IsEmpty then saveCmdMsg model' ]
270 if not incoming.IsEmpty then saveCmdMsg model' ]
268 | DoSaveContact ->
272 | DoSaveContact ->
269− let pk, nn = model.ContactPubkey, model.ContactNickname
270 match model.Page with
273 match model.Page with
271− | AddContact when pk <> "" && nn <> "" ->
274+ | AddContact(pk, nn) when pk <> "" && nn <> "" ->
272 match tryParsePubkey pk with
275 match tryParsePubkey pk with
273 | Some bytes ->
276 | Some bytes ->
274 let contact: Contact = { Pubkey = Crypto.toHex bytes; Nickname = nn }
277 let contact: Contact = { Pubkey = Crypto.toHex bytes; Nickname = nn }
@@ -323,11 +326,12 @@ module App =
324 match envelope.Type with
327 match envelope.Type with
325 | Text ->
328 | Text ->
326− Ok(senderHex,
329+ Ok { SenderPubkey = senderHex
327− { Id = envelope.Id
331+ { Id = envelope.Id
328− Body = envelope.Body
332+ Body = envelope.Body
329− Timestamp = DateTimeOffset.FromUnixTimeSeconds(payload.Timestamp)
333+ Timestamp = DateTimeOffset.FromUnixTimeSeconds(payload.Timestamp)
330− IsOutgoing = false })
334+ IsOutgoing = false } }
331 | UnknownEnvelope t -> Error $"unknown envelope type: {t}"
335 | UnknownEnvelope t -> Error $"unknown envelope type: {t}"
332 | None -> Error "decrypt failed"
336 | None -> Error "decrypt failed"
333 with ex ->
337 with ex ->
@@ -388,20 +392,14 @@ module App =
388 if not ackIds.IsEmpty then
392 if not ackIds.IsEmpty then
389 do! ackMessages session.Url session.Token ackIds
393 do! ackMessages session.Url session.Token ackIds
394− $"[{now}] evts:{response.Events.Length} msgs:{messages.Length} errs:{errors.Length}"
395+ let status = Received(response.Events.Length, messages.Length, errors.Length, List.tryHead errors)
395− + (match errors with err :: _ -> $" [{err}]" | [] -> "")
397 return PollResult(messages, status, response.Cursor)
396 return PollResult(messages, status, response.Cursor)
399 | :? TimeoutException ->
398 | :? TimeoutException ->
400− return PollResult([], "polling...", cursor)
399+ return PollResult([], Polling, cursor)
403 do! Async.Sleep 3000
401 do! Async.Sleep 3000
404− return PollResult([], $"[{now}] poll error: {ex.Message}", cursor)
402+ return PollResult([], PollError ex.Message, cursor)
407 | CmdCopyToClipboard text ->
405 | CmdCopyToClipboard text ->
@@ -428,6 +426,14 @@ module App =
429 // ── View ──
427 // ── View ──
429+ let private formatPollStatus = function
431+ | Polling -> "polling..."
432+ | Received(evts, msgs, errs, firstErr) ->
433+ $"evts:{evts} msgs:{msgs} errs:{errs}"
434+ + (match firstErr with Some e -> $" [{e}]" | None -> "")
435+ | PollError msg -> $"poll error: {msg}"
431 let private viewSetup () =
437 let private viewSetup () =
432 ContentPage(
438 ContentPage(
433 (VStack(spacing = 24.) {
439 (VStack(spacing = 24.) {
@@ -495,14 +501,6 @@ module App =
495 | Identified(_, Connecting) -> "CONNECTING"
501 | Identified(_, Connecting) -> "CONNECTING"
496 | Identified(_, Online _) -> "ONLINE"
502 | Identified(_, Online _) -> "ONLINE"
500− |> List.map _.Nickname
501− |> String.concat ", "
504− model.Messages |> Map.values |> Seq.sumBy List.length
506 ContentPage(
504 ContentPage(
507 ScrollView(
505 ScrollView(
508 (VStack(spacing = 8.) {
506 (VStack(spacing = 8.) {
@@ -510,22 +508,19 @@ module App =
510 Label("Conversations")
508 Label("Conversations")
511 .font(size = 24.)
509 .font(size = 24.)
513− Button("+", SetPage AddContact)
511+ Button("+", SetPage(AddContact("", "")))
514 Button("Settings", SetPage Settings)
512 Button("Settings", SetPage Settings)
517− Label($"conn:{connStr} contacts:{model.Contacts.Length} msgs:{totalMsgs}")
519− .textColor(Colors.DimGray)
521− Label($"poll: {model.PollStatus}")
515+ Label($"{connStr} | {formatPollStatus model.PollStatus}")
522 .font(size = 10.)
516 .font(size = 10.)
523 .textColor(Colors.DimGray)
517 .textColor(Colors.DimGray)
525− if contactNames <> "" then
519+ match model.Error with
526− Label($"names: {contactNames}")
520+ | Some err ->
527− .font(size = 10.)
521+ Label(err).textColor(Colors.Red).font(size = 12.)
528− .textColor(Colors.DimGray)
522+ Button("Dismiss", DismissError)
530 if model.Contacts.IsEmpty then
525 if model.Contacts.IsEmpty then
531 Label("No contacts yet. Tap + to add one.")
526 Label("No contacts yet. Tap + to add one.")
@@ -536,16 +531,16 @@ module App =
536 let preview =
531 let preview =
537 messagesFor c.Pubkey model.Messages
532 messagesFor c.Pubkey model.Messages
538 |> List.tryLast
533 |> List.tryLast
539− |> Option.map (fun m -> truncate 40 m.Body)
534+ |> Option.map (fun m -> if m.Body.Length > 40 then m.Body.[..39] + "..." else m.Body)
540 |> Option.defaultValue ""
535 |> Option.defaultValue ""
542− Button($"{c.Nickname}\n{preview}", SetPage(Chat c.Pubkey))
537+ Button($"{c.Nickname}\n{preview}", SetPage(Chat(c.Pubkey, "")))
544 .padding(20.)
539 .padding(20.)
548− let private viewChat model pk =
543+ let private viewChat model pk compose =
549 let name = contactName model.Contacts pk
544 let name = contactName model.Contacts pk
550 let msgs = messagesFor pk model.Messages
545 let msgs = messagesFor pk model.Messages
@@ -563,7 +558,9 @@ module App =
563 | Some err ->
558 | Some err ->
564 Label(err)
559 Label(err)
565 .textColor(Colors.Red)
560 .textColor(Colors.Red)
566− .font(size = 11.)
561+ .font(size = 12.)
563+ Button("Dismiss", DismissError)
567 | None -> ()
564 | None -> ()
569 if msgs.IsEmpty then
566 if msgs.IsEmpty then
@@ -577,7 +574,7 @@ module App =
577 .font(size = 14.)
574 .font(size = 14.)
579 HStack(spacing = 8.) {
576 HStack(spacing = 8.) {
580− Entry(model.Compose, SetCompose)
577+ Entry(compose, SetCompose)
581 .placeholder("Message...")
578 .placeholder("Message...")
583 Button("Send", DoSend)
580 Button("Send", DoSend)
@@ -586,7 +583,7 @@ module App =
586 .padding(12.)
583 .padding(12.)
589− let private viewAddContact model =
586+ let private viewAddContact model pubkey nickname =
590 ContentPage(
587 ContentPage(
591 (VStack(spacing = 16.) {
588 (VStack(spacing = 16.) {
592 HStack(spacing = 8.) {
589 HStack(spacing = 8.) {
@@ -596,12 +593,18 @@ module App =
596 .font(size = 20.)
593 .font(size = 20.)
596+ match model.Error with
598+ Label(err).textColor(Colors.Red).font(size = 12.)
599+ Button("Dismiss", DismissError)
599 Label("Public Key:")
602 Label("Public Key:")
600− Entry(model.ContactPubkey, SetContactPubkey)
603+ Entry(pubkey, SetContactPubkey)
601 .placeholder("sampel-palnet-...")
604 .placeholder("sampel-palnet-...")
603 Label("Nickname:")
606 Label("Nickname:")
604− Entry(model.ContactNickname, SetContactNickname)
607+ Entry(nickname, SetContactNickname)
606 Button("Save Contact", DoSaveContact)
609 Button("Save Contact", DoSaveContact)
607 .centerHorizontal()
610 .centerHorizontal()
@@ -615,8 +618,8 @@ module App =
615 | Setup -> viewSetup ()
618 | Setup -> viewSetup ()
616 | Settings -> viewSettings model
619 | Settings -> viewSettings model
617 | Conversations -> viewConversations model
620 | Conversations -> viewConversations model
618− | Chat pk -> viewChat model pk
621+ | Chat(pk, compose) -> viewChat model pk compose
619− | AddContact -> viewAddContact model
622+ | AddContact(pubkey, nickname) -> viewAddContact model pubkey nickname
621 Application() { Window(page) }
624 Application() { Window(page) }