main.go raw
1 package main
2
3 import (
4 "common/helpers"
5 "common/jsbridge/dom"
6 "common/jsbridge/localstorage"
7 "common/jsbridge/signer"
8 "common/nostr"
9 )
10
11 const (
12 lsKeyPubkey = "smesh-pubkey"
13 lsKeyTheme = "smesh-theme"
14 )
15
16 var (
17 pubkey []byte
18 pubhex string
19 isDark bool
20
21 // Profile data from kind 0.
22 profileName string
23 profilePic string
24 profileTs int64
25
26 // DOM refs that need updating after creation.
27 avatarEl dom.Element
28 nameEl dom.Element
29 feedContainer dom.Element
30 feedLoader dom.Element
31 statusEl dom.Element
32 popoverEl dom.Element
33 themeBtn dom.Element
34 pageTitleEl dom.Element
35 feedPage dom.Element
36 msgPage dom.Element
37 profilePage dom.Element
38 sidebarFeed dom.Element
39 sidebarMsg dom.Element
40 activePage string
41 profileViewPK string
42
43 // App root — content goes here, not body (snackbar stays outside).
44 root dom.Element
45
46 // Messaging UI state.
47 msgListContainer dom.Element // conversation list view
48 msgThreadContainer dom.Element // thread view (header + messages + compose)
49 msgThreadMessages dom.Element // scrollable message area
50 msgComposeInput dom.Element // textarea
51 msgCurrentPeer string // hex pubkey of open thread, "" when on list
52 msgView string // "list" or "thread"
53 marmotInited bool
54 pendingTsEls []dom.Element // timestamp divs awaiting relay confirmation
55
56 // Relay tracking — parallel slices, grown dynamically.
57 relayURLs []string
58 relayDots []dom.Element
59 relayLabels []dom.Element
60 relayUserPick []bool // true = from user's kind 10002
61
62 eventCount int
63 popoverOpen bool
64
65 // Author profile cache.
66 authorNames map[string]string // pubkey hex -> display name
67 authorPics map[string]string // pubkey hex -> avatar URL
68 authorContent map[string]string // pubkey hex -> full kind 0 content JSON
69 authorTs map[string]int64 // pubkey hex -> created_at of cached kind 0
70 authorRelays map[string][]string // pubkey hex -> relay URLs from kind 10002
71 pendingNotes map[string][]dom.Element // pubkey hex -> author header divs awaiting profile
72 fetchedK0 map[string]bool // pubkey hex -> already tried kind 0 fetch
73 fetchedK10k map[string]bool // pubkey hex -> already tried kind 10002 fetch
74 seenEvents map[string]bool // event ID -> already rendered
75 authorSubPK map[string]string // subID -> pubkey hex for author profile subs
76
77 // Relay frequency — how many kind 10002 lists include each relay URL.
78 relayFreq map[string]int
79 idbLoaded bool
80 retryRound int // metadata retry round counter
81 retryTimer int // debounce timer for batch retries
82 fetchQueue []string // pubkeys queued for batch profile fetch
83 fetchTimer int // debounce timer for fetch queue
84
85 // QR modal.
86 logoSVGCache string
87
88 // Profile page tab state.
89 profileTab string
90 profileTabContent dom.Element
91 profileTabBtns map[string]dom.Element
92 authorFollows map[string][]string
93 authorMutes map[string][]string
94 profileNotesSeen map[string]bool
95 activeProfileNoteSub string
96
97 // History/routing.
98 navPop bool // true during popstate — suppresses pushState
99 )
100
101 const orlyRelay = "wss://relay.orly.dev"
102
103 var defaultRelays = []string{
104 orlyRelay,
105 "wss://nostr.wine",
106 "wss://nostr.land",
107 }
108
109 func isLocalDev() bool {
110 h := dom.Hostname()
111 return h == "localhost" || (len(h) > 4 && h[:4] == "127.")
112 }
113
114 func main() {
115 dom.ConsoleLog("starting smesh " + version)
116 if isLocalDev() {
117 defaultRelays = append(defaultRelays, "ws://localhost:3334")
118 dom.ConsoleLog("dev mode: added local relay ws://localhost:3334")
119 }
120 themePref := localstorage.GetItem(lsKeyTheme)
121 if themePref != "" {
122 isDark = themePref == "dark"
123 } else {
124 isDark = dom.PrefersDark()
125 }
126 if isDark {
127 dom.AddClass(dom.Body(), "dark")
128 }
129 root = dom.GetElementById("app-root")
130 dom.SetAttribute(root, "data-version", version)
131 stored := localstorage.GetItem(lsKeyPubkey)
132 if stored != "" {
133 pubhex = stored
134 pubkey = helpers.HexDecode(stored)
135 showApp()
136 } else {
137 showLogin()
138 }
139 }
140
141 // --- Theme ---
142
143 func toggleTheme() {
144 body := dom.Body()
145 isDark = !isDark
146 if isDark {
147 dom.AddClass(body, "dark")
148 localstorage.SetItem(lsKeyTheme, "dark")
149 } else {
150 dom.RemoveClass(body, "dark")
151 localstorage.SetItem(lsKeyTheme, "light")
152 }
153 updateThemeIcon()
154 }
155
156 func updateThemeIcon() {
157 if isDark {
158 dom.SetInnerHTML(themeBtn, "☀️") // ☀️ emoji sun
159 } else {
160 dom.SetInnerHTML(themeBtn, "🌙") // 🌙
161 }
162 }
163
164 // --- Login screen ---
165
166 func showLogin() {
167 clearChildren(root)
168
169 wrap := dom.CreateElement("div")
170 dom.SetStyle(wrap, "display", "flex")
171 dom.SetStyle(wrap, "alignItems", "center")
172 dom.SetStyle(wrap, "justifyContent", "center")
173 dom.SetStyle(wrap, "height", "100vh")
174 dom.SetStyle(wrap, "flexDirection", "column")
175
176 // Smesh loader animation.
177 loader := dom.CreateElement("div")
178 dom.SetStyle(loader, "width", "180px")
179 dom.SetStyle(loader, "height", "180px")
180 dom.SetStyle(loader, "marginBottom", "16px")
181 dom.FetchText("./smesh-loader.svg", func(svg string) {
182 dom.SetInnerHTML(loader, svg)
183 })
184 dom.AppendChild(wrap, loader)
185
186 // Title.
187 h1 := dom.CreateElement("h1")
188 dom.SetTextContent(h1, "smesh")
189 dom.SetStyle(h1, "color", "var(--accent)")
190 dom.SetStyle(h1, "fontSize", "48px")
191 dom.SetStyle(h1, "marginBottom", "4px")
192 dom.AppendChild(wrap, h1)
193
194 verTag := dom.CreateElement("span")
195 dom.SetTextContent(verTag, version)
196 dom.SetStyle(verTag, "color", "var(--muted)")
197 dom.SetStyle(verTag, "fontSize", "12px")
198 dom.AppendChild(wrap, verTag)
199
200 sub := dom.CreateElement("p")
201 dom.SetTextContent(sub, "nostr client \u2014 tinygo \u2192 javascript")
202 dom.SetStyle(sub, "color", "var(--muted)")
203 dom.SetStyle(sub, "marginBottom", "32px")
204 dom.AppendChild(wrap, sub)
205
206 // Error message.
207 errEl := dom.CreateElement("div")
208 dom.SetStyle(errEl, "color", "#e55")
209 dom.SetStyle(errEl, "fontSize", "13px")
210 dom.SetStyle(errEl, "marginBottom", "12px")
211 dom.SetStyle(errEl, "minHeight", "18px")
212 dom.AppendChild(wrap, errEl)
213
214 // Login button.
215 btn := dom.CreateElement("button")
216 dom.SetTextContent(btn, "login with extension")
217 dom.SetAttribute(btn, "type", "button")
218 dom.SetStyle(btn, "padding", "10px 32px")
219 dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
220 dom.SetStyle(btn, "fontSize", "14px")
221 dom.SetStyle(btn, "background", "var(--accent)")
222 dom.SetStyle(btn, "color", "#000")
223 dom.SetStyle(btn, "border", "none")
224 dom.SetStyle(btn, "borderRadius", "4px")
225 dom.SetStyle(btn, "cursor", "pointer")
226 dom.AppendChild(wrap, btn)
227
228 dom.AppendChild(root, wrap)
229
230 cb := dom.RegisterCallback(func() {
231 if !signer.HasSigner() {
232 dom.SetTextContent(errEl, "install a NIP-07 extension (nos2x, Alby, etc)")
233 return
234 }
235 dom.SetTextContent(btn, "requesting...")
236 signer.GetPublicKey(func(hex string) {
237 if hex == "" {
238 dom.SetTextContent(errEl, "login failed or was denied")
239 dom.SetTextContent(btn, "login with extension")
240 return
241 }
242 pubhex = hex
243 pubkey = helpers.HexDecode(hex)
244 localstorage.SetItem(lsKeyPubkey, pubhex)
245 clearChildren(root)
246 showApp()
247 })
248 })
249 dom.AddEventListener(btn, "click", cb)
250 }
251
252 // --- Sidebar ---
253
254 const svgFeed = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M3 3h12M6 7h9M6 11h9M3 15h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`
255 const svgChat = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M2 3h14v9H10l-2 3-2-3H2z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`
256
257 func makeSidebarIcon(svgHTML string, active bool) dom.Element {
258 btn := dom.CreateElement("button")
259 dom.SetStyle(btn, "width", "36px")
260 dom.SetStyle(btn, "height", "36px")
261 dom.SetStyle(btn, "border", "none")
262 dom.SetStyle(btn, "borderRadius", "6px")
263 dom.SetStyle(btn, "cursor", "pointer")
264 dom.SetStyle(btn, "display", "flex")
265 dom.SetStyle(btn, "alignItems", "center")
266 dom.SetStyle(btn, "justifyContent", "center")
267 dom.SetStyle(btn, "padding", "0")
268 dom.SetStyle(btn, "color", "var(--fg)")
269 if active {
270 dom.SetStyle(btn, "background", "var(--accent)")
271 dom.SetStyle(btn, "color", "#000")
272 } else {
273 dom.SetStyle(btn, "background", "transparent")
274 }
275 dom.SetInnerHTML(btn, svgHTML)
276 return btn
277 }
278
279 func switchPage(name string) {
280 if name == activePage {
281 return
282 }
283 closeProfileNoteSub()
284 if activePage == "profile" {
285 profileViewPK = ""
286 }
287 activePage = name
288 dom.SetTextContent(pageTitleEl, name)
289
290 // Hide all pages.
291 dom.SetStyle(feedPage, "display", "none")
292 dom.SetStyle(msgPage, "display", "none")
293 dom.SetStyle(profilePage, "display", "none")
294
295 // Clear sidebar highlights.
296 dom.SetStyle(sidebarFeed, "background", "transparent")
297 dom.SetStyle(sidebarFeed, "color", "var(--fg)")
298 dom.SetStyle(sidebarMsg, "background", "transparent")
299 dom.SetStyle(sidebarMsg, "color", "var(--fg)")
300
301 switch name {
302 case "feed":
303 dom.SetStyle(feedPage, "display", "block")
304 dom.SetStyle(sidebarFeed, "background", "var(--accent)")
305 dom.SetStyle(sidebarFeed, "color", "#000")
306 if !navPop {
307 dom.PushState("/")
308 }
309 case "messaging":
310 dom.SetStyle(msgPage, "display", "block")
311 dom.SetStyle(sidebarMsg, "background", "var(--accent)")
312 dom.SetStyle(sidebarMsg, "color", "#000")
313 dom.PostToSW("[\"PAGE\",\"messaging\"]")
314 initMessaging()
315 if !navPop {
316 dom.PushState("/msg")
317 }
318 case "profile":
319 dom.SetStyle(profilePage, "display", "block")
320 // Profile URL is pushed by showProfile, not here.
321 }
322 }
323
324 // navigateToPath handles URL-based routing for back/forward and initial load.
325 // fullPath may include a hash fragment, e.g. "/p/npub1...#follows".
326 func navigateToPath(fullPath string) {
327 path := fullPath
328 hash := ""
329 for i := 0; i < len(fullPath); i++ {
330 if fullPath[i] == '#' {
331 path = fullPath[:i]
332 hash = fullPath[i+1:]
333 break
334 }
335 }
336
337 if path == "/" || path == "/feed" || path == "" {
338 switchPage("feed")
339 } else if path == "/msg" {
340 switchPage("messaging")
341 if msgView == "thread" {
342 closeThread()
343 }
344 } else if len(path) > 5 && path[:5] == "/msg/" {
345 pk := npubToHex(path[5:])
346 if pk != "" {
347 switchPage("messaging")
348 openThread(pk)
349 }
350 } else if len(path) > 3 && path[:3] == "/p/" {
351 pk := npubToHex(path[3:])
352 if pk != "" {
353 showProfile(pk)
354 if hash != "" {
355 selectProfileTab(hash, pk)
356 }
357 }
358 }
359 }
360
361 func npubToHex(npub string) string {
362 b := helpers.DecodeNpub(npub)
363 if b == nil {
364 return ""
365 }
366 return helpers.HexEncode(b)
367 }
368
369 func initRouter() {
370 dom.OnPopState(func(path string) {
371 navPop = true
372 navigateToPath(path)
373 navPop = false
374 })
375
376 // Navigate to initial URL if not root.
377 path := dom.GetPath()
378 if path != "/" && path != "" {
379 navPop = true
380 navigateToPath(path)
381 navPop = false
382 } else {
383 dom.ReplaceState("/")
384 }
385 }
386
387 func makeProtoBtn(label string) dom.Element {
388 btn := dom.CreateElement("button")
389 dom.SetTextContent(btn, label)
390 dom.SetStyle(btn, "padding", "6px 16px")
391 dom.SetStyle(btn, "border", "none")
392 dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
393 dom.SetStyle(btn, "fontSize", "12px")
394 dom.SetStyle(btn, "cursor", "default")
395 dom.SetStyle(btn, "background", "transparent")
396 dom.SetStyle(btn, "color", "var(--fg)")
397 return btn
398 }
399
400 // --- Main app ---
401
402 func showApp() {
403 // Init profile cache maps.
404 authorNames = make(map[string]string)
405 authorPics = make(map[string]string)
406 authorContent = make(map[string]string)
407 authorTs = make(map[string]int64)
408 authorRelays = make(map[string][]string)
409 pendingNotes = make(map[string][]dom.Element)
410 fetchedK0 = make(map[string]bool)
411 fetchedK10k = make(map[string]bool)
412 relayFreq = make(map[string]int)
413 authorSubPK = make(map[string]string)
414 seenEvents = make(map[string]bool)
415 authorFollows = make(map[string][]string)
416 authorMutes = make(map[string][]string)
417 profileNotesSeen = make(map[string]bool)
418 profileTabBtns = make(map[string]dom.Element)
419
420 // Set up SW communication.
421 dom.OnSWMessage(onSWMessage)
422 dom.PostToSW("[\"SET_PUBKEY\"," + jstr(pubhex) + "]")
423
424 // Load cached profiles from IndexedDB.
425 dom.IDBGetAll("profiles", func(key, val string) {
426 name := helpers.JsonGetString(val, "name")
427 pic := helpers.JsonGetString(val, "picture")
428 if name != "" {
429 authorNames[key] = name
430 }
431 if pic != "" {
432 authorPics[key] = pic
433 }
434 }, func() {
435 idbLoaded = true
436 // Update note headers rendered before IDB finished loading.
437 for pk, headers := range pendingNotes {
438 name := authorNames[pk]
439 pic := authorPics[pk]
440 if name != "" {
441 for _, h := range headers {
442 updateNoteHeader(h, name, pic)
443 }
444 delete(pendingNotes, pk)
445 fetchedK0[pk] = true // don't fetch, already cached
446 }
447 }
448 })
449
450 // === Top bar ===
451 bar := dom.CreateElement("div")
452 dom.SetStyle(bar, "display", "flex")
453 dom.SetStyle(bar, "alignItems", "center")
454 dom.SetStyle(bar, "padding", "8px 16px")
455 dom.SetStyle(bar, "height", "48px")
456 dom.SetStyle(bar, "boxSizing", "border-box")
457 dom.SetStyle(bar, "background", "var(--bg2)")
458 dom.SetStyle(bar, "position", "fixed")
459 dom.SetStyle(bar, "top", "0")
460 dom.SetStyle(bar, "left", "0")
461 dom.SetStyle(bar, "right", "0")
462 dom.SetStyle(bar, "zIndex", "100")
463
464 // Left: page title.
465 left := dom.CreateElement("div")
466 dom.SetStyle(left, "display", "flex")
467 dom.SetStyle(left, "alignItems", "center")
468 dom.SetStyle(left, "flex", "1")
469 dom.SetStyle(left, "minWidth", "0")
470
471 pageTitleEl = dom.CreateElement("span")
472 dom.SetStyle(pageTitleEl, "fontSize", "18px")
473 dom.SetStyle(pageTitleEl, "fontWeight", "bold")
474 dom.SetTextContent(pageTitleEl, "feed")
475 dom.AppendChild(left, pageTitleEl)
476 dom.AppendChild(bar, left)
477
478 // Center: dendrite logo.
479 logo := dom.CreateElement("div")
480 dom.SetStyle(logo, "width", "32px")
481 dom.SetStyle(logo, "height", "32px")
482 dom.SetStyle(logo, "flexShrink", "0")
483 dom.FetchText("./smesh-loader.svg", func(svg string) {
484 logoSVGCache = svg
485 dom.SetInnerHTML(logo, svg)
486 svgEl := dom.FirstChild(logo)
487 if svgEl != 0 {
488 dom.SetAttribute(svgEl, "width", "100%")
489 dom.SetAttribute(svgEl, "height", "100%")
490 }
491 })
492 dom.AppendChild(bar, logo)
493
494 // Right: theme toggle + logout.
495 right := dom.CreateElement("div")
496 dom.SetStyle(right, "display", "flex")
497 dom.SetStyle(right, "alignItems", "center")
498 dom.SetStyle(right, "gap", "8px")
499 dom.SetStyle(right, "flex", "1")
500 dom.SetStyle(right, "justifyContent", "flex-end")
501
502 themeBtn = dom.CreateElement("button")
503 dom.SetStyle(themeBtn, "background", "transparent")
504 dom.SetStyle(themeBtn, "border", "none")
505 dom.SetStyle(themeBtn, "borderRadius", "50%")
506 dom.SetStyle(themeBtn, "width", "32px")
507 dom.SetStyle(themeBtn, "height", "32px")
508 dom.SetStyle(themeBtn, "fontSize", "16px")
509 dom.SetStyle(themeBtn, "cursor", "pointer")
510 dom.SetStyle(themeBtn, "padding", "0")
511 dom.SetStyle(themeBtn, "display", "flex")
512 dom.SetStyle(themeBtn, "alignItems", "center")
513 dom.SetStyle(themeBtn, "justifyContent", "center")
514 dom.SetStyle(themeBtn, "lineHeight", "1")
515 updateThemeIcon()
516 dom.AddEventListener(themeBtn, "click", dom.RegisterCallback(func() {
517 toggleTheme()
518 }))
519 dom.AppendChild(right, themeBtn)
520
521 logout := dom.CreateElement("button")
522 dom.SetTextContent(logout, "logout")
523 dom.SetStyle(logout, "fontFamily", "'Fira Code', monospace")
524 dom.SetStyle(logout, "fontSize", "12px")
525 dom.SetStyle(logout, "background", "transparent")
526 dom.SetStyle(logout, "border", "none")
527 dom.SetStyle(logout, "color", "var(--fg)")
528 dom.SetStyle(logout, "borderRadius", "4px")
529 dom.SetStyle(logout, "height", "32px")
530 dom.SetStyle(logout, "padding", "0 16px")
531 dom.SetStyle(logout, "cursor", "pointer")
532 dom.AddEventListener(logout, "click", dom.RegisterCallback(func() {
533 doLogout()
534 }))
535 dom.AppendChild(right, logout)
536 dom.AppendChild(bar, right)
537
538 dom.AppendChild(root, bar)
539
540 // === Main layout: sidebar + content ===
541 mainLayout := dom.CreateElement("div")
542 dom.SetStyle(mainLayout, "position", "fixed")
543 dom.SetStyle(mainLayout, "top", "48px")
544 dom.SetStyle(mainLayout, "bottom", "36px")
545 dom.SetStyle(mainLayout, "left", "0")
546 dom.SetStyle(mainLayout, "right", "0")
547 dom.SetStyle(mainLayout, "display", "flex")
548
549 // Sidebar.
550 sidebar := dom.CreateElement("div")
551 dom.SetStyle(sidebar, "width", "44px")
552 dom.SetStyle(sidebar, "flexShrink", "0")
553 dom.SetStyle(sidebar, "background", "var(--bg2)")
554 dom.SetStyle(sidebar, "display", "flex")
555 dom.SetStyle(sidebar, "flexDirection", "column")
556 dom.SetStyle(sidebar, "alignItems", "center")
557 dom.SetStyle(sidebar, "paddingTop", "8px")
558 dom.SetStyle(sidebar, "gap", "4px")
559
560 sidebarFeed = makeSidebarIcon(svgFeed, true)
561 dom.AddEventListener(sidebarFeed, "click", dom.RegisterCallback(func() {
562 switchPage("feed")
563 }))
564 dom.AppendChild(sidebar, sidebarFeed)
565
566 sidebarMsg = makeSidebarIcon(svgChat, false)
567 dom.AddEventListener(sidebarMsg, "click", dom.RegisterCallback(func() {
568 switchPage("messaging")
569 }))
570 dom.AppendChild(sidebar, sidebarMsg)
571
572 dom.AppendChild(mainLayout, sidebar)
573
574 // Content area.
575 contentArea := dom.CreateElement("div")
576 dom.SetStyle(contentArea, "flex", "1")
577 dom.SetStyle(contentArea, "overflowY", "auto")
578
579 // Feed page.
580 feedPage = dom.CreateElement("div")
581 dom.SetStyle(feedPage, "padding", "16px")
582
583 // Loading spinner — shown until first feed event arrives.
584 feedLoader = dom.CreateElement("div")
585 dom.SetStyle(feedLoader, "display", "flex")
586 dom.SetStyle(feedLoader, "flexDirection", "column")
587 dom.SetStyle(feedLoader, "alignItems", "center")
588 dom.SetStyle(feedLoader, "justifyContent", "center")
589 dom.SetStyle(feedLoader, "padding", "64px 0")
590 loaderImg := dom.CreateElement("div")
591 dom.SetStyle(loaderImg, "width", "120px")
592 dom.SetStyle(loaderImg, "height", "120px")
593 dom.FetchText("./smesh-loader.svg", func(svg string) {
594 dom.SetInnerHTML(loaderImg, svg)
595 svgEl := dom.FirstChild(loaderImg)
596 if svgEl != 0 {
597 dom.SetAttribute(svgEl, "width", "100%")
598 dom.SetAttribute(svgEl, "height", "100%")
599 }
600 })
601 dom.AppendChild(feedLoader, loaderImg)
602 loaderText := dom.CreateElement("div")
603 dom.SetTextContent(loaderText, "connecting...")
604 dom.SetStyle(loaderText, "marginTop", "16px")
605 dom.SetStyle(loaderText, "color", "var(--muted)")
606 dom.SetStyle(loaderText, "fontSize", "14px")
607 dom.AppendChild(feedLoader, loaderText)
608 dom.AppendChild(feedPage, feedLoader)
609
610 feedContainer = dom.CreateElement("div")
611 dom.AppendChild(feedPage, feedContainer)
612 dom.AppendChild(contentArea, feedPage)
613
614 // Messaging page.
615 msgPage = dom.CreateElement("div")
616 dom.SetStyle(msgPage, "padding", "16px")
617 dom.SetStyle(msgPage, "display", "none")
618 dom.SetStyle(msgPage, "position", "relative")
619 dom.SetStyle(msgPage, "height", "100%")
620 dom.SetStyle(msgPage, "boxSizing", "border-box")
621
622 // Conversation list view.
623 msgListContainer = dom.CreateElement("div")
624 dom.AppendChild(msgPage, msgListContainer)
625
626 // Thread view (hidden by default).
627 msgThreadContainer = dom.CreateElement("div")
628 dom.SetStyle(msgThreadContainer, "display", "none")
629 dom.SetStyle(msgThreadContainer, "flexDirection", "column")
630 dom.SetStyle(msgThreadContainer, "position", "absolute")
631 dom.SetStyle(msgThreadContainer, "top", "0")
632 dom.SetStyle(msgThreadContainer, "left", "0")
633 dom.SetStyle(msgThreadContainer, "right", "0")
634 dom.SetStyle(msgThreadContainer, "bottom", "0")
635 dom.SetStyle(msgThreadContainer, "background", "var(--bg)")
636 dom.AppendChild(msgPage, msgThreadContainer)
637
638 msgView = "list"
639
640 dom.AppendChild(contentArea, msgPage)
641
642 // Profile page.
643 profilePage = dom.CreateElement("div")
644 dom.SetStyle(profilePage, "display", "none")
645 dom.AppendChild(contentArea, profilePage)
646
647 dom.AppendChild(mainLayout, contentArea)
648 dom.AppendChild(root, mainLayout)
649 activePage = "feed"
650
651 // === Bottom status bar ===
652 bottomBar := dom.CreateElement("div")
653 dom.SetStyle(bottomBar, "position", "fixed")
654 dom.SetStyle(bottomBar, "bottom", "0")
655 dom.SetStyle(bottomBar, "left", "0")
656 dom.SetStyle(bottomBar, "right", "0")
657 dom.SetStyle(bottomBar, "height", "36px")
658 dom.SetStyle(bottomBar, "display", "flex")
659 dom.SetStyle(bottomBar, "alignItems", "center")
660 dom.SetStyle(bottomBar, "padding", "0 12px")
661 dom.SetStyle(bottomBar, "gap", "8px")
662 dom.SetStyle(bottomBar, "background", "var(--bg2)")
663 dom.SetStyle(bottomBar, "fontSize", "12px")
664 dom.SetStyle(bottomBar, "color", "var(--fg)")
665 dom.SetStyle(bottomBar, "zIndex", "100")
666
667 // Avatar + name in clickable box.
668 userBtn := dom.CreateElement("div")
669 dom.SetStyle(userBtn, "display", "flex")
670 dom.SetStyle(userBtn, "alignItems", "center")
671 dom.SetStyle(userBtn, "gap", "6px")
672 dom.SetStyle(userBtn, "padding", "4px 10px")
673 dom.SetStyle(userBtn, "border", "none")
674 dom.SetStyle(userBtn, "borderRadius", "4px")
675 dom.SetStyle(userBtn, "cursor", "pointer")
676
677 avatarEl = dom.CreateElement("img")
678 dom.SetAttribute(avatarEl, "width", "20")
679 dom.SetAttribute(avatarEl, "height", "20")
680 dom.SetStyle(avatarEl, "borderRadius", "50%")
681 dom.SetStyle(avatarEl, "objectFit", "cover")
682 dom.SetStyle(avatarEl, "display", "none")
683 dom.SetAttribute(avatarEl, "onerror", "this.style.display='none'")
684 dom.AppendChild(userBtn, avatarEl)
685
686 nameEl = dom.CreateElement("span")
687 dom.SetStyle(nameEl, "fontSize", "12px")
688 dom.SetStyle(nameEl, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
689 dom.SetStyle(nameEl, "fontWeight", "bold")
690 dom.SetStyle(nameEl, "overflow", "hidden")
691 dom.SetStyle(nameEl, "textOverflow", "ellipsis")
692 dom.SetStyle(nameEl, "whiteSpace", "nowrap")
693 dom.SetStyle(nameEl, "maxWidth", "120px")
694 npubStr := helpers.EncodeNpub(pubkey)
695 if len(npubStr) > 20 {
696 dom.SetTextContent(nameEl, npubStr[:12]+"..."+npubStr[len(npubStr)-4:])
697 }
698 dom.AppendChild(userBtn, nameEl)
699
700 dom.AddEventListener(userBtn, "click", dom.RegisterCallback(func() {
701 showProfile(pubhex)
702 }))
703 dom.AppendChild(bottomBar, userBtn)
704
705 sep := dom.CreateElement("span")
706 dom.SetTextContent(sep, "|")
707 dom.SetStyle(sep, "color", "var(--muted)")
708 dom.AppendChild(bottomBar, sep)
709
710 statusEl = dom.CreateElement("span")
711 dom.SetTextContent(statusEl, "connecting...")
712 dom.SetStyle(statusEl, "cursor", "pointer")
713 dom.AppendChild(bottomBar, statusEl)
714
715 dom.AddEventListener(statusEl, "click", dom.RegisterCallback(func() {
716 togglePopover()
717 }))
718
719 ver := dom.CreateElement("span")
720 dom.SetTextContent(ver, "smesh "+version)
721 dom.SetStyle(ver, "marginLeft", "auto")
722 dom.SetStyle(ver, "color", "var(--accent)")
723 dom.AppendChild(bottomBar, ver)
724
725 dom.AppendChild(root, bottomBar)
726
727 // === Relay popover (hidden) ===
728 popoverEl = dom.CreateElement("div")
729 dom.SetStyle(popoverEl, "position", "fixed")
730 dom.SetStyle(popoverEl, "bottom", "37px")
731 dom.SetStyle(popoverEl, "left", "44px")
732 dom.SetStyle(popoverEl, "right", "0")
733 dom.SetStyle(popoverEl, "background", "var(--bg2)")
734 dom.SetStyle(popoverEl, "borderTop", "1px solid var(--border)")
735 dom.SetStyle(popoverEl, "padding", "12px 16px")
736 dom.SetStyle(popoverEl, "fontSize", "12px")
737 dom.SetStyle(popoverEl, "display", "none")
738 dom.SetStyle(popoverEl, "zIndex", "99")
739 dom.AppendChild(root, popoverEl)
740
741 // Add default relays.
742 for _, url := range defaultRelays {
743 addRelay(url, false)
744 }
745
746 // Tell SW about relays and subscribe.
747 sendWriteRelays()
748 subscribeProfile()
749 subscribeFeed()
750
751 // Wire up browser history navigation.
752 initRouter()
753 }
754
755 // addRelay adds a relay to the list and creates its popover row.
756 // userPick=true means it came from the user's kind 10002 relay list.
757 func addRelay(url string, userPick bool) {
758 url = normalizeURL(url)
759 // Dedup.
760 for i, u := range relayURLs {
761 if u == url {
762 if userPick && !relayUserPick[i] {
763 relayUserPick[i] = true
764 dom.SetStyle(relayLabels[i], "fontWeight", "bold")
765 }
766 return
767 }
768 }
769
770 relayURLs = append(relayURLs, url)
771 relayUserPick = append(relayUserPick, userPick)
772
773 // Popover row.
774 row := dom.CreateElement("div")
775 dom.SetStyle(row, "padding", "3px 0")
776
777 dot := dom.CreateElement("span")
778 dom.SetTextContent(dot, "\u25CF")
779 dom.SetStyle(dot, "color", "#5b5")
780 dom.SetStyle(dot, "marginRight", "8px")
781 relayDots = append(relayDots, dot)
782 dom.AppendChild(row, dot)
783
784 label := dom.CreateElement("span")
785 dom.SetTextContent(label, url)
786 if userPick {
787 dom.SetStyle(label, "fontWeight", "bold")
788 }
789 relayLabels = append(relayLabels, label)
790 dom.AppendChild(row, label)
791
792 dom.AppendChild(popoverEl, row)
793 updateStatus()
794 }
795
796 func togglePopover() {
797 popoverOpen = !popoverOpen
798 if popoverOpen {
799 dom.SetStyle(popoverEl, "display", "block")
800 } else {
801 dom.SetStyle(popoverEl, "display", "none")
802 }
803 }
804
805 func subscribeProfile() {
806 proxy := make([]string, len(discoveryRelays), len(discoveryRelays)+len(relayURLs))
807 copy(proxy, discoveryRelays)
808 for _, u := range relayURLs {
809 proxy = appendUnique(proxy, u)
810 }
811 dom.PostToSW(buildProxyMsg("prof",
812 "{\"authors\":["+jstr(pubhex)+"],\"kinds\":[0,3,10002,10000,10050],\"limit\":8}",
813 proxy))
814 }
815
816 func subscribeFeed() {
817 dom.PostToSW(buildProxyMsg("feed", "{\"kinds\":[1],\"limit\":20}", relayURLs))
818 }
819
820 func sendWriteRelays() {
821 msg := "[\"SET_WRITE_RELAYS\",["
822 for i, url := range relayURLs {
823 if i > 0 {
824 msg += ","
825 }
826 msg += jstr(url)
827 }
828 dom.PostToSW(msg + "]]")
829 }
830
831 func buildProxyMsg(subID, filterJSON string, urls []string) string {
832 msg := "[\"PROXY\"," + jstr(subID) + "," + filterJSON + ",["
833 for i, url := range urls {
834 if i > 0 {
835 msg += ","
836 }
837 msg += jstr(url)
838 }
839 return msg + "]]"
840 }
841
842 func jstr(s string) string {
843 return "\"" + jsonEsc(s) + "\""
844 }
845
846 // scheduleTabRetry schedules a retry for any pending profile fetches after
847 // the follows/mutes tab renders. Independent of retryRound so it works even
848 // after the feed's retry budget is exhausted.
849 func scheduleTabRetry() {
850 dom.SetTimeout(func() {
851 var missing []string
852 for pk := range pendingNotes {
853 if _, ok := authorNames[pk]; !ok {
854 missing = append(missing, pk)
855 }
856 }
857 if len(missing) == 0 {
858 return
859 }
860 for _, pk := range missing {
861 fetchedK0[pk] = false
862 }
863 for _, pk := range missing {
864 queueProfileFetch(pk)
865 }
866 }, 5000)
867 }
868
869 // --- SW message handling ---
870
871 func onSWMessage(raw string) {
872 if raw == "update-available" {
873 dom.PostToSW("activate-update")
874 return
875 }
876 if raw == "reload" {
877 dom.LocationReload()
878 return
879 }
880 if len(raw) < 5 || raw[0] != '[' {
881 return
882 }
883 typ, pos := nextStr(raw, 1)
884 switch typ {
885 case "EVENT":
886 subID, pos2 := nextStr(raw, pos)
887 evJSON := extractValue(raw, pos2)
888 if evJSON == "" {
889 return
890 }
891 ev := nostr.ParseEvent(evJSON)
892 if ev == nil {
893 return
894 }
895 dispatchEvent(subID, ev)
896 case "EOSE":
897 subID, _ := nextStr(raw, pos)
898 dispatchEOSE(subID)
899 case "DM_LIST":
900 listJSON := extractValue(raw, pos)
901 renderConversationList(listJSON)
902 case "DM_HISTORY":
903 peer, pos2 := nextStr(raw, pos)
904 msgsJSON := extractValue(raw, pos2)
905 renderThreadMessages(peer, msgsJSON)
906 case "DM_RECEIVED":
907 dmJSON := extractValue(raw, pos)
908 handleDMReceived(dmJSON)
909 case "DM_SENT":
910 tsStr := nextNum(raw, pos)
911 var ts int64
912 for i := 0; i < len(tsStr); i++ {
913 if tsStr[i] >= '0' && tsStr[i] <= '9' {
914 ts = ts*10 + int64(tsStr[i]-'0')
915 }
916 }
917 if ts > 0 && len(pendingTsEls) > 0 {
918 dom.SetTextContent(pendingTsEls[0], formatTime(ts))
919 pendingTsEls = pendingTsEls[1:]
920 }
921 case "DM_HISTORY_CLEARED":
922 // Messages already cleared optimistically on ratchet button click.
923 peer, _ := nextStr(raw, pos)
924 dom.ConsoleLog("[mls] history cleared for " + peer)
925 case "MLS_GROUPS":
926 // Store for future use.
927 case "MLS_STATUS":
928 text, _ := nextStr(raw, pos)
929 dom.ConsoleLog("[mls] " + text)
930 case "SW_LOG":
931 origin, pos2 := nextStr(raw, pos)
932 logMsg, _ := nextStr(raw, pos2)
933 dom.ConsoleLog("[" + origin + "] " + logMsg)
934 return
935 case "CRYPTO_REQ":
936 handleCryptoReq(raw, pos)
937 case "NEED_IDENTITY":
938 if pubhex != "" {
939 dom.PostToSW("[\"SET_PUBKEY\"," + jstr(pubhex) + "]")
940 }
941 resubscribe()
942 case "RESUB":
943 resubscribe()
944 }
945 }
946
947 func resubscribe() {
948 sendWriteRelays()
949 subscribeProfile()
950 subscribeFeed()
951 if activePage == "messaging" {
952 initMessaging()
953 }
954 }
955
956 func dispatchEvent(subID string, ev *nostr.Event) {
957 if subID == "prof" {
958 handleProfileEvent(ev)
959 } else if subID == "feed" {
960 if seenEvents[ev.ID] {
961 return
962 }
963 seenEvents[ev.ID] = true
964 eventCount++
965 if feedLoader != 0 {
966 dom.RemoveChild(feedPage, feedLoader)
967 feedLoader = 0
968 }
969 renderNote(ev)
970 } else if len(subID) > 3 && subID[:3] == "ap-" {
971 if ev.Kind == 0 {
972 applyAuthorProfile(ev.PubKey, ev)
973 } else if ev.Kind == 3 {
974 var pks []string
975 for _, tag := range ev.Tags.GetAll("p") {
976 if v := tag.Value(); v != "" {
977 pks = append(pks, v)
978 }
979 }
980 authorFollows[ev.PubKey] = pks
981 refreshProfileTab(ev.PubKey)
982 } else if ev.Kind == 10002 {
983 recordRelayFreq(ev)
984 } else if ev.Kind == 10000 {
985 var pks []string
986 for _, tag := range ev.Tags.GetAll("p") {
987 if v := tag.Value(); v != "" {
988 pks = append(pks, v)
989 }
990 }
991 authorMutes[ev.PubKey] = pks
992 refreshProfileTab(ev.PubKey)
993 }
994 } else if len(subID) > 3 && subID[:3] == "pn-" {
995 if profileNotesSeen[ev.ID] {
996 return
997 }
998 profileNotesSeen[ev.ID] = true
999 renderProfileNote(ev)
1000 }
1001 }
1002
1003 func dispatchEOSE(subID string) {
1004 if subID == "feed" {
1005 if feedLoader != 0 {
1006 dom.RemoveChild(feedPage, feedLoader)
1007 feedLoader = 0
1008 }
1009 updateStatus()
1010 retryMissingProfiles()
1011 } else if len(subID) > 9 && subID[:9] == "ap-batch-" {
1012 // Delay CLOSE: server-side _proxy fan-out to external relays takes 5-15s.
1013 // Keep sub alive so late-arriving events flow through pushToMatchingSubs.
1014 closeID := subID
1015 dom.SetTimeout(func() {
1016 dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]")
1017 }, 15000)
1018 // Debounce: schedule follow-up retry 10s after last batch EOSE (max 3 rounds).
1019 if retryRound <= 3 {
1020 if retryTimer != 0 {
1021 dom.ClearTimeout(retryTimer)
1022 }
1023 retryTimer = dom.SetTimeout(func() {
1024 retryTimer = 0
1025 retryMissingProfiles()
1026 }, 10000)
1027 }
1028 } else if len(subID) > 3 && subID[:3] == "ap-" {
1029 closeID := subID
1030 dom.SetTimeout(func() {
1031 dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]")
1032 }, 15000)
1033 pk, ok := authorSubPK[subID]
1034 if !ok {
1035 return
1036 }
1037 delete(authorSubPK, subID)
1038 if _, got := authorNames[pk]; !got {
1039 if rels, ok := authorRelays[pk]; ok && len(rels) > 0 && !fetchedK10k[pk] {
1040 fetchedK10k[pk] = true
1041 fetchedK0[pk] = false
1042 fetchAuthorProfile(pk)
1043 }
1044 }
1045 }
1046 }
1047
1048 // handleCryptoReq processes CRYPTO_REQ from the SW, calling the NIP-07
1049 // extension and posting CRYPTO_RESULT back.
1050 // Format: ["CRYPTO_REQ", id, "method", "peerPubkey", "data"]
1051 func handleCryptoReq(raw string, pos int) {
1052 // id is a bare number, not a quoted string.
1053 idStr := nextNum(raw, pos)
1054 // Skip past the number and comma to find the method string.
1055 pos2 := pos
1056 for pos2 < len(raw) && raw[pos2] != ',' {
1057 pos2++
1058 }
1059 pos2++
1060 method, pos3 := nextStr(raw, pos2)
1061 peer, pos4 := nextStr(raw, pos3)
1062 data, _ := nextStr(raw, pos4)
1063
1064 dom.ConsoleLog("crypto: " + method + " #" + idStr)
1065
1066 sendResult := func(result, errMsg string) {
1067 if errMsg != "" {
1068 dom.ConsoleLog("crypto: " + method + " #" + idStr + " ERR=" + errMsg)
1069 } else {
1070 dom.ConsoleLog("crypto: " + method + " #" + idStr + " OK")
1071 }
1072 dom.PostToSW("[\"CRYPTO_RESULT\"," + idStr + "," + jstr(result) + "," + jstr(errMsg) + "]")
1073 }
1074
1075 switch method {
1076 case "signEvent":
1077 signer.SignEvent(data, func(signed string) {
1078 if signed == "" {
1079 sendResult("", "sign failed")
1080 } else {
1081 sendResult(signed, "")
1082 }
1083 })
1084 case "nip04.decrypt":
1085 signer.Nip04Decrypt(peer, data, func(plain string) {
1086 if plain == "" {
1087 sendResult("", "decrypt failed")
1088 } else {
1089 sendResult(plain, "")
1090 }
1091 })
1092 case "nip04.encrypt":
1093 signer.Nip04Encrypt(peer, data, func(ct string) {
1094 if ct == "" {
1095 sendResult("", "encrypt failed")
1096 } else {
1097 sendResult(ct, "")
1098 }
1099 })
1100 case "nip44.decrypt":
1101 signer.Nip44Decrypt(peer, data, func(plain string) {
1102 if plain == "" {
1103 sendResult("", "decrypt failed")
1104 } else {
1105 sendResult(plain, "")
1106 }
1107 })
1108 case "nip44.encrypt":
1109 signer.Nip44Encrypt(peer, data, func(ct string) {
1110 if ct == "" {
1111 sendResult("", "encrypt failed")
1112 } else {
1113 sendResult(ct, "")
1114 }
1115 })
1116 default:
1117 sendResult("", "unknown method: "+method)
1118 }
1119 }
1120
1121 // nextNum extracts a bare number from s starting at pos, returning it as a string.
1122 func nextNum(s string, pos int) string {
1123 for pos < len(s) && (s[pos] == ' ' || s[pos] == ',') {
1124 pos++
1125 }
1126 start := pos
1127 for pos < len(s) && s[pos] >= '0' && s[pos] <= '9' {
1128 pos++
1129 }
1130 return s[start:pos]
1131 }
1132
1133 // nextStr extracts the next quoted string from s starting at pos.
1134 func nextStr(s string, pos int) (string, int) {
1135 for pos < len(s) && s[pos] != '"' {
1136 pos++
1137 }
1138 if pos >= len(s) {
1139 return "", pos
1140 }
1141 pos++
1142 var buf []byte
1143 hasEsc := false
1144 start := pos
1145 for pos < len(s) {
1146 if s[pos] == '\\' && pos+1 < len(s) {
1147 hasEsc = true
1148 buf = append(buf, s[start:pos]...)
1149 pos++
1150 switch s[pos] {
1151 case '"', '\\', '/':
1152 buf = append(buf, s[pos])
1153 case 'n':
1154 buf = append(buf, '\n')
1155 case 't':
1156 buf = append(buf, '\t')
1157 case 'r':
1158 buf = append(buf, '\r')
1159 default:
1160 buf = append(buf, '\\', s[pos])
1161 }
1162 pos++
1163 start = pos
1164 continue
1165 }
1166 if s[pos] == '"' {
1167 break
1168 }
1169 pos++
1170 }
1171 if pos >= len(s) {
1172 return "", pos
1173 }
1174 var val string
1175 if hasEsc {
1176 buf = append(buf, s[start:pos]...)
1177 val = string(buf)
1178 } else {
1179 val = s[start:pos]
1180 }
1181 pos++
1182 for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') {
1183 pos++
1184 }
1185 return val, pos
1186 }
1187
1188 // extractValue extracts a JSON object/array value starting at pos.
1189 func extractValue(s string, pos int) string {
1190 for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') {
1191 pos++
1192 }
1193 if pos >= len(s) {
1194 return ""
1195 }
1196 if s[pos] != '{' && s[pos] != '[' {
1197 return ""
1198 }
1199 start := pos
1200 depth := 0
1201 for pos < len(s) {
1202 c := s[pos]
1203 if c == '{' || c == '[' {
1204 depth++
1205 }
1206 if c == '}' || c == ']' {
1207 depth--
1208 if depth == 0 {
1209 return s[start : pos+1]
1210 }
1211 }
1212 if c == '"' {
1213 pos++
1214 for pos < len(s) && s[pos] != '"' {
1215 if s[pos] == '\\' {
1216 pos++
1217 }
1218 pos++
1219 }
1220 }
1221 pos++
1222 }
1223 return s[start:]
1224 }
1225
1226 func handleProfileEvent(ev *nostr.Event) {
1227 switch ev.Kind {
1228 case 0:
1229 if ev.CreatedAt <= profileTs {
1230 return
1231 }
1232 profileTs = ev.CreatedAt
1233 authorContent[pubhex] = ev.Content
1234 name := helpers.JsonGetString(ev.Content, "name")
1235 if name == "" {
1236 name = helpers.JsonGetString(ev.Content, "display_name")
1237 }
1238 pic := helpers.JsonGetString(ev.Content, "picture")
1239 if name != "" {
1240 profileName = name
1241 authorNames[pubhex] = name
1242 dom.SetTextContent(nameEl, name)
1243 }
1244 if pic != "" {
1245 profilePic = pic
1246 authorPics[pubhex] = pic
1247 dom.SetAttribute(avatarEl, "src", pic)
1248 dom.SetStyle(avatarEl, "display", "block")
1249 }
1250 if profileViewPK == pubhex {
1251 renderProfilePage(pubhex)
1252 }
1253 case 3:
1254 var pks []string
1255 for _, tag := range ev.Tags.GetAll("p") {
1256 if v := tag.Value(); v != "" {
1257 pks = append(pks, v)
1258 }
1259 }
1260 authorFollows[pubhex] = pks
1261 refreshProfileTab(pubhex)
1262 case 10000:
1263 var pks []string
1264 for _, tag := range ev.Tags.GetAll("p") {
1265 if v := tag.Value(); v != "" {
1266 pks = append(pks, v)
1267 }
1268 }
1269 authorMutes[pubhex] = pks
1270 refreshProfileTab(pubhex)
1271 case 10002:
1272 // NIP-65 relay list — add user's preferred relays.
1273 recordRelayFreq(ev)
1274 for _, tag := range ev.Tags.GetAll("r") {
1275 url := tag.Value()
1276 if url != "" {
1277 addRelay(url, true)
1278 }
1279 }
1280 sendWriteRelays()
1281 subscribeFeed()
1282 case 10050:
1283 // DM inbox relay list — stored for future use.
1284 _ = ev.Tags.GetAll("relay")
1285 }
1286 }
1287
1288 func updateStatus() {
1289 dom.SetTextContent(statusEl,
1290 itoa(len(relayURLs))+" relays | "+itoa(eventCount)+" events")
1291 }
1292
1293 // --- Feed rendering ---
1294
1295 func renderNote(ev *nostr.Event) {
1296 note := dom.CreateElement("div")
1297 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
1298 dom.SetStyle(note, "padding", "12px 0")
1299
1300 // Author header: avatar + name.
1301 header := dom.CreateElement("div")
1302 dom.SetStyle(header, "display", "flex")
1303 dom.SetStyle(header, "alignItems", "center")
1304 dom.SetStyle(header, "gap", "8px")
1305 dom.SetStyle(header, "marginBottom", "4px")
1306 dom.SetStyle(header, "cursor", "pointer")
1307 headerPK := ev.PubKey
1308 dom.AddEventListener(header, "click", dom.RegisterCallback(func() {
1309 showProfile(headerPK)
1310 }))
1311
1312 avatar := dom.CreateElement("img")
1313 dom.SetAttribute(avatar, "width", "24")
1314 dom.SetAttribute(avatar, "height", "24")
1315 dom.SetStyle(avatar, "borderRadius", "50%")
1316 dom.SetStyle(avatar, "objectFit", "cover")
1317 dom.SetStyle(avatar, "flexShrink", "0")
1318
1319 nameSpan := dom.CreateElement("span")
1320 dom.SetStyle(nameSpan, "fontSize", "18px")
1321 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
1322 dom.SetStyle(nameSpan, "fontWeight", "bold")
1323 dom.SetStyle(nameSpan, "color", "var(--fg)")
1324
1325 pk := ev.PubKey
1326 if pic, ok := authorPics[pk]; ok && pic != "" {
1327 dom.SetAttribute(avatar, "src", pic)
1328 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
1329 } else {
1330 dom.SetStyle(avatar, "display", "none")
1331 }
1332 if name, ok := authorNames[pk]; ok && name != "" {
1333 dom.SetTextContent(nameSpan, name)
1334 } else {
1335 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
1336 if len(npub) > 20 {
1337 dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:])
1338 }
1339 }
1340
1341 dom.AppendChild(header, avatar)
1342 dom.AppendChild(header, nameSpan)
1343 dom.AppendChild(note, header)
1344
1345 // Track header for update when profile arrives; trigger fetch if not yet started.
1346 if _, cached := authorNames[pk]; !cached {
1347 pendingNotes[pk] = append(pendingNotes[pk], header)
1348 if !fetchedK0[pk] {
1349 queueProfileFetch(pk)
1350 }
1351 }
1352
1353 // Content.
1354 content := dom.CreateElement("div")
1355 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
1356 dom.SetStyle(content, "fontSize", "14px")
1357 dom.SetStyle(content, "lineHeight", "1.5")
1358 dom.SetStyle(content, "wordBreak", "break-word")
1359 text := ev.Content
1360 truncated := len(text) > 500
1361 if truncated {
1362 text = text[:500] + "..."
1363 }
1364 dom.SetInnerHTML(content, renderMarkdown(text))
1365 dom.AppendChild(note, content)
1366
1367 if truncated {
1368 more := dom.CreateElement("span")
1369 dom.SetTextContent(more, "show more")
1370 dom.SetStyle(more, "color", "var(--accent)")
1371 dom.SetStyle(more, "cursor", "pointer")
1372 dom.SetStyle(more, "fontSize", "13px")
1373 dom.SetStyle(more, "display", "inline-block")
1374 dom.SetStyle(more, "marginTop", "4px")
1375 fullContent := ev.Content
1376 expanded := false
1377 dom.AddEventListener(more, "click", dom.RegisterCallback(func() {
1378 expanded = !expanded
1379 if expanded {
1380 dom.SetInnerHTML(content, renderMarkdown(fullContent))
1381 dom.SetTextContent(more, "show less")
1382 } else {
1383 dom.SetInnerHTML(content, renderMarkdown(fullContent[:500]+"..."))
1384 dom.SetTextContent(more, "show more")
1385 }
1386 }))
1387 dom.AppendChild(note, more)
1388 }
1389
1390 // Prepend (newest first).
1391 first := dom.FirstChild(feedContainer)
1392 if first != 0 {
1393 dom.InsertBefore(feedContainer, note, first)
1394 } else {
1395 dom.AppendChild(feedContainer, note)
1396 }
1397 }
1398
1399 var profileSubCounter int
1400
1401 // topRelays returns the n most frequently seen relay URLs from kind 10002 events.
1402 func topRelays(n int) []string {
1403 if relayFreq == nil {
1404 return nil
1405 }
1406 // Simple selection sort — n is small.
1407 type kv struct {
1408 url string
1409 count int
1410 }
1411 var all []kv
1412 for url, count := range relayFreq {
1413 all = append(all, kv{url, count})
1414 }
1415 // Sort descending by count.
1416 for i := 0; i < len(all); i++ {
1417 for j := i + 1; j < len(all); j++ {
1418 if all[j].count > all[i].count {
1419 all[i], all[j] = all[j], all[i]
1420 }
1421 }
1422 }
1423 var result []string
1424 for i := 0; i < len(all) && i < n; i++ {
1425 result = append(result, all[i].url)
1426 }
1427 return result
1428 }
1429
1430 // recordRelayFreq records relay URLs from a kind 10002 event into the frequency table.
1431 func recordRelayFreq(ev *nostr.Event) {
1432 tags := ev.Tags.GetAll("r")
1433 if tags == nil {
1434 return
1435 }
1436 var urls []string
1437 for _, tag := range tags {
1438 u := tag.Value()
1439 if u != "" {
1440 urls = append(urls, u)
1441 if _, ok := relayFreq[u]; ok {
1442 relayFreq[u] = relayFreq[u] + 1
1443 } else {
1444 relayFreq[u] = 1
1445 }
1446 }
1447 }
1448 if len(urls) > 0 {
1449 authorRelays[ev.PubKey] = urls
1450 }
1451 }
1452
1453 // discoveryRelays are well-known relays that aggregate profile metadata.
1454 // Prioritized first in _proxy lists since they have the highest hit rate.
1455 var discoveryRelays = []string{
1456 "wss://purplepag.es",
1457 "wss://relay.nostr.band",
1458 "wss://relay.damus.io",
1459 "wss://nos.lol",
1460 }
1461
1462 // buildProxy builds a _proxy relay list for a pubkey.
1463 // Discovery relays first, then author-specific relays if known.
1464 func buildProxy(pk string) []string {
1465 out := make([]string, len(discoveryRelays))
1466 copy(out, discoveryRelays)
1467 for _, u := range relayURLs {
1468 out = appendUnique(out, u)
1469 }
1470 if rels, ok := authorRelays[pk]; ok {
1471 for _, r := range rels {
1472 out = appendUnique(out, r)
1473 }
1474 }
1475 top := topRelays(4)
1476 for _, r := range top {
1477 out = appendUnique(out, r)
1478 }
1479 return out
1480 }
1481
1482 func appendUnique(list []string, val string) []string {
1483 for _, v := range list {
1484 if v == val {
1485 return list
1486 }
1487 }
1488 return append(list, val)
1489 }
1490
1491 // fetchAuthorProfile fetches kind 0 + kind 10002 for an author via SW PROXY.
1492 func fetchAuthorProfile(pk string) {
1493 if fetchedK0[pk] {
1494 return
1495 }
1496 fetchedK0[pk] = true
1497
1498 profileSubCounter++
1499 subID := "ap-" + itoa(profileSubCounter)
1500 authorSubPK[subID] = pk
1501
1502 proxyRelays := buildProxy(pk)
1503 dom.PostToSW(buildProxyMsg(subID,
1504 "{\"authors\":["+jstr(pk)+"],\"kinds\":[0,3,10002,10000],\"limit\":6}",
1505 proxyRelays))
1506 }
1507
1508 // queueProfileFetch adds a pubkey to the batch fetch queue with a debounce.
1509 // After 300ms of no new additions, flushFetchQueue sends one batched PROXY request.
1510 func queueProfileFetch(pk string) {
1511 if fetchedK0[pk] {
1512 return
1513 }
1514 fetchedK0[pk] = true
1515 fetchQueue = append(fetchQueue, pk)
1516 if fetchTimer != 0 {
1517 dom.ClearTimeout(fetchTimer)
1518 }
1519 fetchTimer = dom.SetTimeout(func() {
1520 fetchTimer = 0
1521 flushFetchQueue()
1522 }, 300)
1523 }
1524
1525 // flushFetchQueue sends all queued pubkeys as chunked batch PROXY requests.
1526 func flushFetchQueue() {
1527 if len(fetchQueue) == 0 {
1528 return
1529 }
1530 queue := fetchQueue
1531 fetchQueue = nil
1532
1533 proxy := make([]string, len(discoveryRelays))
1534 copy(proxy, discoveryRelays)
1535 for _, u := range relayURLs {
1536 proxy = appendUnique(proxy, u)
1537 }
1538 for _, pk := range queue {
1539 if rels, ok := authorRelays[pk]; ok {
1540 for _, r := range rels {
1541 proxy = appendUnique(proxy, r)
1542 }
1543 }
1544 }
1545 top := topRelays(4)
1546 for _, r := range top {
1547 proxy = appendUnique(proxy, r)
1548 }
1549
1550 const batchSize = 100
1551 for i := 0; i < len(queue); i += batchSize {
1552 end := i + batchSize
1553 if end > len(queue) {
1554 end = len(queue)
1555 }
1556 chunk := queue[i:end]
1557 authors := "["
1558 for j, pk := range chunk {
1559 if j > 0 {
1560 authors += ","
1561 }
1562 authors += jstr(pk)
1563 }
1564 authors += "]"
1565 profileSubCounter++
1566 subID := "ap-batch-q-" + itoa(profileSubCounter)
1567 dom.PostToSW(buildProxyMsg(subID,
1568 "{\"authors\":"+authors+",\"kinds\":[0],\"limit\":"+itoa(len(chunk))+"}",
1569 proxy))
1570 // Also query feed relays directly — they have the kind 1 notes
1571 // so they almost certainly have kind 0 for the same authors.
1572 // Uses the SW's existing WebSocket connections, bypasses server proxy.
1573 profileSubCounter++
1574 dom.PostToSW(buildProxyMsg("ap-d-"+itoa(profileSubCounter),
1575 "{\"authors\":"+authors+",\"kinds\":[0],\"limit\":"+itoa(len(chunk))+"}",
1576 relayURLs))
1577 }
1578 }
1579
1580 // retryMissingProfiles batches pubkeys that still lack a name into chunked
1581 // PROXY requests through the orly relay. Fetches all metadata kinds so
1582 // relay lists from kind 10002 enable second-hop discovery.
1583 func retryMissingProfiles() {
1584 var missing []string
1585 for pk := range pendingNotes {
1586 if _, ok := authorNames[pk]; !ok {
1587 missing = append(missing, pk)
1588 }
1589 }
1590 if len(missing) == 0 {
1591 return
1592 }
1593
1594 // Reset fetchedK0 for still-missing profiles so individual re-fetches
1595 // can fire if new relay info appears from other profiles' kind 10002.
1596 for _, pk := range missing {
1597 fetchedK0[pk] = false
1598 }
1599
1600 // Discovery relays first, then user relays + discovered relays.
1601 proxy := make([]string, len(discoveryRelays))
1602 copy(proxy, discoveryRelays)
1603 for _, u := range relayURLs {
1604 proxy = appendUnique(proxy, u)
1605 }
1606 top := topRelays(8)
1607 for _, u := range top {
1608 proxy = appendUnique(proxy, u)
1609 }
1610
1611 const batchSize = 100
1612 batchNum := 0
1613 for i := 0; i < len(missing); i += batchSize {
1614 end := i + batchSize
1615 if end > len(missing) {
1616 end = len(missing)
1617 }
1618 chunk := missing[i:end]
1619 authors := "["
1620 for j, pk := range chunk {
1621 if j > 0 {
1622 authors += ","
1623 }
1624 authors += jstr(pk)
1625 }
1626 authors += "]"
1627 subID := "ap-batch-" + itoa(retryRound) + "-" + itoa(batchNum)
1628 batchNum++
1629 dom.PostToSW(buildProxyMsg(subID,
1630 "{\"authors\":"+authors+",\"kinds\":[0,10002],\"limit\":"+itoa(len(chunk)*2)+"}",
1631 proxy))
1632 // Direct query to feed relays.
1633 profileSubCounter++
1634 dom.PostToSW(buildProxyMsg("ap-d-"+itoa(profileSubCounter),
1635 "{\"authors\":"+authors+",\"kinds\":[0],\"limit\":"+itoa(len(chunk))+"}",
1636 relayURLs))
1637 }
1638 retryRound++
1639 }
1640
1641 // applyAuthorProfile updates cache and all pending note headers for a pubkey.
1642 func applyAuthorProfile(pk string, ev *nostr.Event) {
1643 if ev.CreatedAt <= authorTs[pk] {
1644 return
1645 }
1646 authorTs[pk] = ev.CreatedAt
1647 authorContent[pk] = ev.Content
1648 name := helpers.JsonGetString(ev.Content, "name")
1649 if name == "" {
1650 name = helpers.JsonGetString(ev.Content, "display_name")
1651 }
1652 pic := helpers.JsonGetString(ev.Content, "picture")
1653 if name != "" {
1654 authorNames[pk] = name
1655 }
1656 if pic != "" {
1657 authorPics[pk] = pic
1658 }
1659
1660 // Cache to IndexedDB.
1661 if name != "" || pic != "" {
1662 dom.IDBPut("profiles", pk, "{\"name\":\""+jsonEsc(name)+"\",\"picture\":\""+jsonEsc(pic)+"\"}")
1663 }
1664
1665 // Update logged-in user's header too.
1666 if pk == pubhex {
1667 if name != "" {
1668 profileName = name
1669 dom.SetTextContent(nameEl, name)
1670 }
1671 if pic != "" {
1672 profilePic = pic
1673 dom.SetAttribute(avatarEl, "src", pic)
1674 dom.SetStyle(avatarEl, "display", "block")
1675 }
1676 }
1677
1678 // Update all pending note headers.
1679 if headers, ok := pendingNotes[pk]; ok && name != "" {
1680 for _, h := range headers {
1681 updateNoteHeader(h, name, pic)
1682 }
1683 delete(pendingNotes, pk)
1684 }
1685
1686 // Re-render profile page if viewing this author.
1687 if profileViewPK == pk {
1688 renderProfilePage(pk)
1689 }
1690 }
1691
1692 // updateNoteHeader fills in avatar+name on a note's author header div.
1693 func updateNoteHeader(header dom.Element, name, pic string) {
1694 // First child is <img>, second is <span>.
1695 img := dom.FirstChild(header)
1696 if img == 0 {
1697 return
1698 }
1699 span := dom.NextSibling(img)
1700 if pic != "" {
1701 dom.SetAttribute(img, "src", pic)
1702 dom.SetAttribute(img, "onerror", "this.style.display='none'")
1703 dom.SetStyle(img, "display", "")
1704 }
1705 if name != "" {
1706 dom.SetTextContent(span, name)
1707 }
1708 }
1709
1710 // --- Profile page ---
1711
1712 func showProfile(pk string) {
1713 profileViewPK = pk
1714
1715 // Ensure we have full kind 0 content. If not, fetch it.
1716 if _, ok := authorContent[pk]; !ok {
1717 fetchedK0[pk] = false
1718 fetchAuthorProfile(pk)
1719 }
1720
1721 renderProfilePage(pk)
1722
1723 // Use the author's name as page title, fall back to "profile".
1724 title := "profile"
1725 if name, ok := authorNames[pk]; ok && name != "" {
1726 title = name
1727 }
1728 activePage = "" // force switchPage to run
1729 switchPage("profile")
1730 dom.SetTextContent(pageTitleEl, title)
1731
1732 if !navPop {
1733 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
1734 dom.PushState("/p/" + npub)
1735 }
1736 }
1737
1738 func verifyNip05(nip05, pubkeyHex string, badge dom.Element) {
1739 at := -1
1740 for i := 0; i < len(nip05); i++ {
1741 if nip05[i] == '@' {
1742 at = i
1743 break
1744 }
1745 }
1746 if at < 1 || at >= len(nip05)-1 {
1747 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
1748 return
1749 }
1750 local := nip05[:at]
1751 domain := nip05[at+1:]
1752 url := "https://" + domain + "/.well-known/nostr.json?name=" + local
1753 dom.FetchText(url, func(body string) {
1754 if body == "" {
1755 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
1756 return
1757 }
1758 namesObj := helpers.JsonGetString(body, "names")
1759 if namesObj == "" {
1760 // names might be an object not a string — extract manually
1761 namesStart := -1
1762 key := "\"names\""
1763 for i := 0; i < len(body)-len(key); i++ {
1764 if body[i:i+len(key)] == key {
1765 namesStart = i + len(key)
1766 break
1767 }
1768 }
1769 if namesStart < 0 {
1770 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f")
1771 return
1772 }
1773 // Skip colon and whitespace to find the object
1774 for namesStart < len(body) && (body[namesStart] == ':' || body[namesStart] == ' ' || body[namesStart] == '\t') {
1775 namesStart++
1776 }
1777 if namesStart >= len(body) || body[namesStart] != '{' {
1778 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f")
1779 return
1780 }
1781 // Find matching brace
1782 depth := 0
1783 end := namesStart
1784 for end < len(body) {
1785 if body[end] == '{' {
1786 depth++
1787 } else if body[end] == '}' {
1788 depth--
1789 if depth == 0 {
1790 end++
1791 break
1792 }
1793 }
1794 end++
1795 }
1796 namesObj = body[namesStart:end]
1797 }
1798 got := helpers.JsonGetString(namesObj, local)
1799 if got == pubkeyHex {
1800 dom.SetTextContent(badge, "\xe2\x9c\x85") // ✅
1801 } else {
1802 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
1803 }
1804 })
1805 }
1806
1807 func renderProfilePage(pk string) {
1808 savedTab := profileTab
1809 clearChildren(profilePage)
1810 closeProfileNoteSub()
1811 profileNotesSeen = make(map[string]bool)
1812
1813 content := authorContent[pk]
1814 name := authorNames[pk]
1815 pic := authorPics[pk]
1816 about := helpers.JsonGetString(content, "about")
1817 website := helpers.JsonGetString(content, "website")
1818 nip05 := helpers.JsonGetString(content, "nip05")
1819 lud16 := helpers.JsonGetString(content, "lud16")
1820 banner := helpers.JsonGetString(content, "banner")
1821
1822 // Banner — full width, 200px, cover.
1823 if banner != "" {
1824 bannerEl := dom.CreateElement("img")
1825 dom.SetAttribute(bannerEl, "src", banner)
1826 dom.SetStyle(bannerEl, "width", "100%")
1827 dom.SetStyle(bannerEl, "height", "240px")
1828 dom.SetStyle(bannerEl, "objectFit", "cover")
1829 dom.SetStyle(bannerEl, "objectPosition", "center")
1830 dom.SetStyle(bannerEl, "display", "block")
1831 dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'")
1832 dom.AppendChild(profilePage, bannerEl)
1833 }
1834
1835 // User info card — glass effect, overlapping banner.
1836 card := dom.CreateElement("div")
1837 dom.SetStyle(card, "background", "color-mix(in srgb, var(--bg) 85%, transparent)")
1838 dom.SetStyle(card, "backdropFilter", "blur(8px)")
1839 dom.SetStyle(card, "borderRadius", "8px")
1840 dom.SetStyle(card, "padding", "16px")
1841 if banner != "" {
1842 dom.SetStyle(card, "margin", "-48px 16px 0")
1843 } else {
1844 dom.SetStyle(card, "margin", "16px")
1845 }
1846 dom.SetStyle(card, "position", "relative")
1847 dom.SetStyle(card, "width", "fit-content")
1848 dom.SetStyle(card, "maxWidth", "calc(100% - 32px)")
1849
1850 // Top row: avatar + info.
1851 topRow := dom.CreateElement("div")
1852 dom.SetStyle(topRow, "display", "flex")
1853 dom.SetStyle(topRow, "gap", "16px")
1854 dom.SetStyle(topRow, "alignItems", "flex-start")
1855
1856 // Compute npub early — needed for avatar QR click and npub row.
1857 npubBytes := helpers.HexDecode(pk)
1858 npubStr := helpers.EncodeNpub(npubBytes)
1859
1860 if pic != "" {
1861 av := dom.CreateElement("img")
1862 dom.SetAttribute(av, "src", pic)
1863 dom.SetAttribute(av, "width", "64")
1864 dom.SetAttribute(av, "height", "64")
1865 dom.SetStyle(av, "borderRadius", "50%")
1866 dom.SetStyle(av, "objectFit", "cover")
1867 dom.SetStyle(av, "flexShrink", "0")
1868 dom.SetStyle(av, "border", "3px solid var(--bg)")
1869 dom.SetStyle(av, "cursor", "pointer")
1870 dom.SetAttribute(av, "onerror", "this.style.display='none'")
1871 avNpub := npubStr
1872 dom.AddEventListener(av, "click", dom.RegisterCallback(func() {
1873 showQRModal(avNpub)
1874 }))
1875 dom.AppendChild(topRow, av)
1876 }
1877
1878 info := dom.CreateElement("div")
1879 dom.SetStyle(info, "minWidth", "0")
1880 dom.SetStyle(info, "flex", "1")
1881 dom.SetStyle(info, "overflow", "hidden")
1882
1883 if name != "" {
1884 nameSpan := dom.CreateElement("div")
1885 dom.SetTextContent(nameSpan, name)
1886 dom.SetStyle(nameSpan, "fontSize", "20px")
1887 dom.SetStyle(nameSpan, "fontWeight", "bold")
1888 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
1889 dom.SetStyle(nameSpan, "cursor", "pointer")
1890 nameNpub := npubStr
1891 dom.AddEventListener(nameSpan, "click", dom.RegisterCallback(func() {
1892 showQRModal(nameNpub)
1893 }))
1894 dom.AppendChild(info, nameSpan)
1895 }
1896
1897 if nip05 != "" {
1898 nip05Row := dom.CreateElement("div")
1899 dom.SetStyle(nip05Row, "display", "flex")
1900 dom.SetStyle(nip05Row, "alignItems", "center")
1901 dom.SetStyle(nip05Row, "gap", "4px")
1902 nip05Text := dom.CreateElement("span")
1903 dom.SetTextContent(nip05Text, nip05)
1904 dom.SetStyle(nip05Text, "color", "var(--muted)")
1905 dom.SetStyle(nip05Text, "fontSize", "13px")
1906 dom.AppendChild(nip05Row, nip05Text)
1907 nip05Badge := dom.CreateElement("span")
1908 dom.SetStyle(nip05Badge, "fontSize", "14px")
1909 dom.AppendChild(nip05Row, nip05Badge)
1910 dom.AppendChild(info, nip05Row)
1911
1912 // Async NIP-05 validation.
1913 verifyNip05(nip05, pk, nip05Badge)
1914 }
1915
1916 // npub (full length) with copy + qr buttons.
1917 npubRow := dom.CreateElement("div")
1918 dom.SetStyle(npubRow, "display", "flex")
1919 dom.SetStyle(npubRow, "alignItems", "flex-start")
1920 dom.SetStyle(npubRow, "gap", "6px")
1921 dom.SetStyle(npubRow, "marginTop", "2px")
1922 npubEl := dom.CreateElement("span")
1923 dom.SetStyle(npubEl, "color", "var(--muted)")
1924 dom.SetStyle(npubEl, "fontSize", "12px")
1925 dom.SetStyle(npubEl, "wordBreak", "break-all")
1926 dom.SetTextContent(npubEl, npubStr)
1927 dom.AppendChild(npubRow, npubEl)
1928 copyBtn := dom.CreateElement("span")
1929 dom.SetTextContent(copyBtn, "copy")
1930 dom.SetStyle(copyBtn, "color", "var(--accent)")
1931 dom.SetStyle(copyBtn, "fontSize", "11px")
1932 dom.SetStyle(copyBtn, "cursor", "pointer")
1933 dom.SetAttribute(copyBtn, "onclick", "navigator.clipboard.writeText('"+npubStr+"').then(()=>{this.textContent='copied!'});setTimeout(()=>{this.textContent='copy'},1500)")
1934 dom.AppendChild(npubRow, copyBtn)
1935 qrBtn := dom.CreateElement("span")
1936 dom.SetTextContent(qrBtn, "qr")
1937 dom.SetStyle(qrBtn, "color", "var(--accent)")
1938 dom.SetStyle(qrBtn, "fontSize", "11px")
1939 dom.SetStyle(qrBtn, "cursor", "pointer")
1940 npubForQR := npubStr
1941 dom.AddEventListener(qrBtn, "click", dom.RegisterCallback(func() {
1942 showQRModal(npubForQR)
1943 }))
1944 dom.AppendChild(npubRow, qrBtn)
1945 dom.AppendChild(info, npubRow)
1946
1947 // Website + lightning inline.
1948 if website != "" || lud16 != "" {
1949 metaRow := dom.CreateElement("div")
1950 dom.SetStyle(metaRow, "display", "flex")
1951 dom.SetStyle(metaRow, "gap", "12px")
1952 dom.SetStyle(metaRow, "marginTop", "6px")
1953 dom.SetStyle(metaRow, "fontSize", "12px")
1954 if website != "" {
1955 wEl := dom.CreateElement("span")
1956 dom.SetStyle(wEl, "color", "var(--accent)")
1957 dom.SetStyle(wEl, "wordBreak", "break-all")
1958 dom.SetTextContent(wEl, website)
1959 dom.AppendChild(metaRow, wEl)
1960 }
1961 if lud16 != "" {
1962 lEl := dom.CreateElement("span")
1963 dom.SetStyle(lEl, "color", "var(--muted)")
1964 dom.SetStyle(lEl, "wordBreak", "break-all")
1965 dom.SetTextContent(lEl, "\xE2\x9A\xA1 "+lud16)
1966 dom.AppendChild(metaRow, lEl)
1967 }
1968 dom.AppendChild(info, metaRow)
1969 }
1970
1971 dom.AppendChild(topRow, info)
1972 dom.AppendChild(card, topRow)
1973
1974 // Message button — only for other users.
1975 if pk != pubhex {
1976 msgBtn := dom.CreateElement("button")
1977 dom.SetTextContent(msgBtn, "message")
1978 dom.SetStyle(msgBtn, "padding", "6px 16px")
1979 dom.SetStyle(msgBtn, "fontFamily", "'Fira Code', monospace")
1980 dom.SetStyle(msgBtn, "fontSize", "12px")
1981 dom.SetStyle(msgBtn, "background", "var(--accent)")
1982 dom.SetStyle(msgBtn, "color", "#000")
1983 dom.SetStyle(msgBtn, "border", "none")
1984 dom.SetStyle(msgBtn, "borderRadius", "4px")
1985 dom.SetStyle(msgBtn, "cursor", "pointer")
1986 dom.SetStyle(msgBtn, "marginTop", "12px")
1987 peerPK := pk
1988 dom.AddEventListener(msgBtn, "click", dom.RegisterCallback(func() {
1989 switchPage("messaging")
1990 openThread(peerPK)
1991 }))
1992 dom.AppendChild(card, msgBtn)
1993 }
1994
1995 dom.AppendChild(profilePage, card)
1996
1997 // About/bio.
1998 if about != "" {
1999 aboutEl := dom.CreateElement("div")
2000 dom.SetStyle(aboutEl, "padding", "12px 16px")
2001 dom.SetStyle(aboutEl, "fontSize", "14px")
2002 dom.SetStyle(aboutEl, "lineHeight", "1.5")
2003 dom.SetStyle(aboutEl, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
2004 dom.SetStyle(aboutEl, "wordBreak", "break-word")
2005 aboutTruncated := len(about) > 300
2006 aboutText := about
2007 if aboutTruncated {
2008 aboutText = about[:300] + "..."
2009 }
2010 dom.SetInnerHTML(aboutEl, renderMarkdown(aboutText))
2011 dom.AppendChild(profilePage, aboutEl)
2012
2013 if aboutTruncated {
2014 more := dom.CreateElement("span")
2015 dom.SetTextContent(more, "show more")
2016 dom.SetStyle(more, "color", "var(--accent)")
2017 dom.SetStyle(more, "cursor", "pointer")
2018 dom.SetStyle(more, "fontSize", "13px")
2019 dom.SetStyle(more, "display", "inline-block")
2020 dom.SetStyle(more, "padding", "0 16px 8px")
2021 aboutExpanded := false
2022 dom.AddEventListener(more, "click", dom.RegisterCallback(func() {
2023 aboutExpanded = !aboutExpanded
2024 if aboutExpanded {
2025 dom.SetInnerHTML(aboutEl, renderMarkdown(about))
2026 dom.SetTextContent(more, "show less")
2027 } else {
2028 dom.SetInnerHTML(aboutEl, renderMarkdown(about[:300]+"..."))
2029 dom.SetTextContent(more, "show more")
2030 }
2031 }))
2032 dom.AppendChild(profilePage, more)
2033 }
2034 }
2035
2036 // Tab bar.
2037 tabBar := dom.CreateElement("div")
2038 dom.SetStyle(tabBar, "display", "flex")
2039 dom.SetStyle(tabBar, "gap", "0")
2040 dom.SetStyle(tabBar, "margin", "0 16px")
2041 dom.SetStyle(tabBar, "border", "1px solid var(--border)")
2042 dom.SetStyle(tabBar, "borderRadius", "6px")
2043 dom.SetStyle(tabBar, "overflow", "hidden")
2044
2045 profileTabBtns = make(map[string]dom.Element)
2046
2047 // Unrolled — tinyjs range/loop closure aliasing.
2048 tabNotes := makeProtoBtn("notes")
2049 dom.SetStyle(tabNotes, "cursor", "pointer")
2050 profileTabBtns["notes"] = tabNotes
2051 tabNotesPK := pk
2052 dom.AddEventListener(tabNotes, "click", dom.RegisterCallback(func() {
2053 selectProfileTab("notes", tabNotesPK)
2054 }))
2055 dom.AppendChild(tabBar, tabNotes)
2056
2057 tabFollows := makeProtoBtn("follows")
2058 dom.SetStyle(tabFollows, "cursor", "pointer")
2059 profileTabBtns["follows"] = tabFollows
2060 tabFollowsPK := pk
2061 dom.AddEventListener(tabFollows, "click", dom.RegisterCallback(func() {
2062 selectProfileTab("follows", tabFollowsPK)
2063 }))
2064 dom.AppendChild(tabBar, tabFollows)
2065
2066 tabRelays := makeProtoBtn("relays")
2067 dom.SetStyle(tabRelays, "cursor", "pointer")
2068 profileTabBtns["relays"] = tabRelays
2069 tabRelaysPK := pk
2070 dom.AddEventListener(tabRelays, "click", dom.RegisterCallback(func() {
2071 selectProfileTab("relays", tabRelaysPK)
2072 }))
2073 dom.AppendChild(tabBar, tabRelays)
2074
2075 tabMutes := makeProtoBtn("mutes")
2076 dom.SetStyle(tabMutes, "cursor", "pointer")
2077 profileTabBtns["mutes"] = tabMutes
2078 tabMutesPK := pk
2079 dom.AddEventListener(tabMutes, "click", dom.RegisterCallback(func() {
2080 selectProfileTab("mutes", tabMutesPK)
2081 }))
2082 dom.AppendChild(tabBar, tabMutes)
2083
2084 dom.AppendChild(profilePage, tabBar)
2085
2086 // Tab content container.
2087 profileTabContent = dom.CreateElement("div")
2088 dom.SetStyle(profileTabContent, "padding", "8px 0")
2089 dom.AppendChild(profilePage, profileTabContent)
2090
2091 // Restore or default tab.
2092 profileTab = ""
2093 if savedTab != "" {
2094 selectProfileTab(savedTab, pk)
2095 } else {
2096 selectProfileTab("notes", pk)
2097 }
2098
2099 // Update title.
2100 if name != "" && activePage == "profile" {
2101 dom.SetTextContent(pageTitleEl, name)
2102 }
2103 }
2104
2105 func profileMetaRow(icon, text, link string) dom.Element {
2106 row := dom.CreateElement("div")
2107 dom.SetStyle(row, "padding", "4px 0")
2108 dom.SetStyle(row, "display", "flex")
2109 dom.SetStyle(row, "alignItems", "center")
2110 dom.SetStyle(row, "gap", "8px")
2111
2112 iconEl := dom.CreateElement("span")
2113 dom.SetTextContent(iconEl, icon)
2114 dom.AppendChild(row, iconEl)
2115
2116 if link != "" {
2117 href := link
2118 if strIndex(href, "://") < 0 {
2119 href = "https://" + href
2120 }
2121 a := dom.CreateElement("a")
2122 dom.SetAttribute(a, "href", href)
2123 dom.SetAttribute(a, "target", "_blank")
2124 dom.SetAttribute(a, "rel", "noopener")
2125 dom.SetStyle(a, "color", "var(--accent)")
2126 dom.SetStyle(a, "wordBreak", "break-all")
2127 dom.SetTextContent(a, text)
2128 dom.AppendChild(row, a)
2129 } else {
2130 span := dom.CreateElement("span")
2131 dom.SetStyle(span, "color", "var(--fg)")
2132 dom.SetTextContent(span, text)
2133 dom.AppendChild(row, span)
2134 }
2135 return row
2136 }
2137
2138 // --- Profile tab functions ---
2139
2140 func closeProfileNoteSub() {
2141 if activeProfileNoteSub != "" {
2142 dom.PostToSW("[\"CLOSE\"," + jstr(activeProfileNoteSub) + "]")
2143 activeProfileNoteSub = ""
2144 }
2145 }
2146
2147 // refreshProfileTab re-renders the active tab if we're viewing this author's profile.
2148 func refreshProfileTab(pk string) {
2149 if profileViewPK != pk || profileTab == "" {
2150 return
2151 }
2152 // Force re-render by clearing current tab and re-selecting.
2153 saved := profileTab
2154 profileTab = ""
2155 selectProfileTab(saved, pk)
2156 }
2157
2158 func selectProfileTab(tab, pk string) {
2159 if tab == profileTab {
2160 return
2161 }
2162 closeProfileNoteSub()
2163 profileTab = tab
2164 clearChildren(profileTabContent)
2165
2166 for id, btn := range profileTabBtns {
2167 if id == tab {
2168 dom.SetStyle(btn, "background", "var(--accent)")
2169 dom.SetStyle(btn, "color", "#000")
2170 } else {
2171 dom.SetStyle(btn, "background", "transparent")
2172 dom.SetStyle(btn, "color", "var(--fg)")
2173 }
2174 }
2175
2176 // Update URL hash to reflect active tab.
2177 if !navPop && profileViewPK != "" {
2178 npub := helpers.EncodeNpub(helpers.HexDecode(profileViewPK))
2179 dom.ReplaceState("/p/" + npub + "#" + tab)
2180 }
2181
2182 switch tab {
2183 case "notes":
2184 renderProfileNotes(pk)
2185 case "follows":
2186 renderProfileFollows(pk)
2187 case "relays":
2188 renderProfileRelays(pk)
2189 case "mutes":
2190 renderProfileMutes(pk)
2191 }
2192 }
2193
2194 func renderProfileNotes(pk string) {
2195 profileNotesSeen = make(map[string]bool)
2196 profileSubCounter++
2197 subID := "pn-" + itoa(profileSubCounter)
2198 activeProfileNoteSub = subID
2199 proxyRelays := buildProxy(pk)
2200 dom.PostToSW(buildProxyMsg(subID,
2201 "{\"authors\":["+jstr(pk)+"],\"kinds\":[1],\"limit\":20}",
2202 proxyRelays))
2203 }
2204
2205 func renderProfileNote(ev *nostr.Event) {
2206 if profileTabContent == 0 || profileTab != "notes" {
2207 return
2208 }
2209 note := dom.CreateElement("div")
2210 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
2211 dom.SetStyle(note, "padding", "12px 16px")
2212
2213 content := dom.CreateElement("div")
2214 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
2215 dom.SetStyle(content, "fontSize", "14px")
2216 dom.SetStyle(content, "lineHeight", "1.5")
2217 dom.SetStyle(content, "wordBreak", "break-word")
2218 text := ev.Content
2219 truncated := len(text) > 500
2220 if truncated {
2221 text = text[:500] + "..."
2222 }
2223 dom.SetInnerHTML(content, renderMarkdown(text))
2224 dom.AppendChild(note, content)
2225
2226 if truncated {
2227 more := dom.CreateElement("span")
2228 dom.SetTextContent(more, "show more")
2229 dom.SetStyle(more, "color", "var(--accent)")
2230 dom.SetStyle(more, "cursor", "pointer")
2231 dom.SetStyle(more, "fontSize", "13px")
2232 dom.SetStyle(more, "display", "inline-block")
2233 dom.SetStyle(more, "marginTop", "4px")
2234 fullContent := ev.Content
2235 expanded := false
2236 dom.AddEventListener(more, "click", dom.RegisterCallback(func() {
2237 expanded = !expanded
2238 if expanded {
2239 dom.SetInnerHTML(content, renderMarkdown(fullContent))
2240 dom.SetTextContent(more, "show less")
2241 } else {
2242 dom.SetInnerHTML(content, renderMarkdown(fullContent[:500]+"..."))
2243 dom.SetTextContent(more, "show more")
2244 }
2245 }))
2246 dom.AppendChild(note, more)
2247 }
2248
2249 // Timestamp.
2250 if ev.CreatedAt > 0 {
2251 ts := dom.CreateElement("div")
2252 dom.SetTextContent(ts, formatTime(ev.CreatedAt))
2253 dom.SetStyle(ts, "color", "var(--muted)")
2254 dom.SetStyle(ts, "fontSize", "12px")
2255 dom.SetStyle(ts, "marginTop", "4px")
2256 dom.AppendChild(note, ts)
2257 }
2258
2259 dom.AppendChild(profileTabContent, note)
2260 }
2261
2262 func renderProfileFollows(pk string) {
2263 follows, ok := authorFollows[pk]
2264 if !ok || len(follows) == 0 {
2265 empty := dom.CreateElement("div")
2266 dom.SetTextContent(empty, "no follows data")
2267 dom.SetStyle(empty, "padding", "16px")
2268 dom.SetStyle(empty, "color", "var(--muted)")
2269 dom.SetStyle(empty, "fontSize", "13px")
2270 dom.AppendChild(profileTabContent, empty)
2271 return
2272 }
2273
2274 countEl := dom.CreateElement("div")
2275 dom.SetTextContent(countEl, itoa(len(follows))+" following")
2276 dom.SetStyle(countEl, "padding", "8px 16px")
2277 dom.SetStyle(countEl, "color", "var(--muted)")
2278 dom.SetStyle(countEl, "fontSize", "12px")
2279 dom.AppendChild(profileTabContent, countEl)
2280
2281 for i := 0; i < len(follows); i++ {
2282 fpk := follows[i]
2283 row := makeProfileRow(fpk)
2284 dom.AppendChild(profileTabContent, row)
2285 }
2286 scheduleTabRetry()
2287 }
2288
2289 func renderProfileRelays(pk string) {
2290 relays, ok := authorRelays[pk]
2291 if !ok || len(relays) == 0 {
2292 empty := dom.CreateElement("div")
2293 dom.SetTextContent(empty, "no relay data")
2294 dom.SetStyle(empty, "padding", "16px")
2295 dom.SetStyle(empty, "color", "var(--muted)")
2296 dom.SetStyle(empty, "fontSize", "13px")
2297 dom.AppendChild(profileTabContent, empty)
2298 return
2299 }
2300
2301 for i := 0; i < len(relays); i++ {
2302 rURL := relays[i]
2303 row := dom.CreateElement("div")
2304 dom.SetStyle(row, "padding", "10px 16px")
2305 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
2306 dom.SetStyle(row, "cursor", "pointer")
2307 dom.SetStyle(row, "fontSize", "13px")
2308
2309 urlEl := dom.CreateElement("span")
2310 dom.SetTextContent(urlEl, rURL)
2311 dom.SetStyle(urlEl, "color", "var(--accent)")
2312 dom.AppendChild(row, urlEl)
2313
2314 clickURL := rURL
2315 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
2316 showRelayInfo(clickURL)
2317 }))
2318 dom.AppendChild(profileTabContent, row)
2319 }
2320 }
2321
2322 func renderProfileMutes(pk string) {
2323 mutes, ok := authorMutes[pk]
2324 if !ok || len(mutes) == 0 {
2325 empty := dom.CreateElement("div")
2326 dom.SetTextContent(empty, "no mutes data")
2327 dom.SetStyle(empty, "padding", "16px")
2328 dom.SetStyle(empty, "color", "var(--muted)")
2329 dom.SetStyle(empty, "fontSize", "13px")
2330 dom.AppendChild(profileTabContent, empty)
2331 return
2332 }
2333
2334 countEl := dom.CreateElement("div")
2335 dom.SetTextContent(countEl, itoa(len(mutes))+" muted")
2336 dom.SetStyle(countEl, "padding", "8px 16px")
2337 dom.SetStyle(countEl, "color", "var(--muted)")
2338 dom.SetStyle(countEl, "fontSize", "12px")
2339 dom.AppendChild(profileTabContent, countEl)
2340
2341 for i := 0; i < len(mutes); i++ {
2342 mpk := mutes[i]
2343 row := makeProfileRow(mpk)
2344 dom.AppendChild(profileTabContent, row)
2345 }
2346 scheduleTabRetry()
2347 }
2348
2349 func makeProfileRow(pk string) dom.Element {
2350 row := dom.CreateElement("div")
2351 dom.SetStyle(row, "display", "flex")
2352 dom.SetStyle(row, "alignItems", "center")
2353 dom.SetStyle(row, "gap", "10px")
2354 dom.SetStyle(row, "padding", "10px 16px")
2355 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
2356 dom.SetStyle(row, "cursor", "pointer")
2357
2358 av := dom.CreateElement("img")
2359 dom.SetAttribute(av, "width", "32")
2360 dom.SetAttribute(av, "height", "32")
2361 dom.SetStyle(av, "borderRadius", "50%")
2362 dom.SetStyle(av, "objectFit", "cover")
2363 dom.SetStyle(av, "flexShrink", "0")
2364 if pic, ok := authorPics[pk]; ok && pic != "" {
2365 dom.SetAttribute(av, "src", pic)
2366 } else {
2367 dom.SetStyle(av, "display", "none")
2368 }
2369 dom.SetAttribute(av, "onerror", "this.style.display='none'")
2370 dom.AppendChild(row, av)
2371
2372 nameSpan := dom.CreateElement("span")
2373 dom.SetStyle(nameSpan, "fontSize", "14px")
2374 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
2375 if name, ok := authorNames[pk]; ok && name != "" {
2376 dom.SetTextContent(nameSpan, name)
2377 } else {
2378 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
2379 if len(npub) > 20 {
2380 dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:])
2381 }
2382 }
2383 dom.AppendChild(row, nameSpan)
2384
2385 rowPK := pk
2386 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
2387 showProfile(rowPK)
2388 }))
2389
2390 if _, cached := authorNames[pk]; !cached {
2391 pendingNotes[pk] = append(pendingNotes[pk], row)
2392 if !fetchedK0[pk] {
2393 queueProfileFetch(pk)
2394 }
2395 }
2396
2397 return row
2398 }
2399
2400 // --- Relay info page ---
2401
2402 func showRelayInfo(url string) {
2403 profileViewPK = ""
2404 closeProfileNoteSub()
2405 clearChildren(profilePage)
2406
2407 hdr := dom.CreateElement("div")
2408 dom.SetStyle(hdr, "display", "flex")
2409 dom.SetStyle(hdr, "alignItems", "center")
2410 dom.SetStyle(hdr, "gap", "10px")
2411 dom.SetStyle(hdr, "padding", "16px")
2412 dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
2413
2414 backBtn := dom.CreateElement("button")
2415 dom.SetInnerHTML(backBtn, "←")
2416 dom.SetStyle(backBtn, "background", "none")
2417 dom.SetStyle(backBtn, "border", "none")
2418 dom.SetStyle(backBtn, "fontSize", "20px")
2419 dom.SetStyle(backBtn, "cursor", "pointer")
2420 dom.SetStyle(backBtn, "color", "var(--fg)")
2421 dom.SetStyle(backBtn, "padding", "0")
2422 dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() {
2423 switchPage("feed")
2424 }))
2425 dom.AppendChild(hdr, backBtn)
2426
2427 urlEl := dom.CreateElement("span")
2428 dom.SetTextContent(urlEl, url)
2429 dom.SetStyle(urlEl, "fontWeight", "bold")
2430 dom.SetStyle(urlEl, "fontSize", "14px")
2431 dom.SetStyle(urlEl, "wordBreak", "break-all")
2432 dom.AppendChild(hdr, urlEl)
2433 dom.AppendChild(profilePage, hdr)
2434
2435 loading := dom.CreateElement("div")
2436 dom.SetTextContent(loading, "loading...")
2437 dom.SetStyle(loading, "padding", "16px")
2438 dom.SetStyle(loading, "color", "var(--muted)")
2439 dom.AppendChild(profilePage, loading)
2440
2441 activePage = ""
2442 switchPage("profile")
2443 dom.SetTextContent(pageTitleEl, "relay info")
2444
2445 // Convert wss→https for NIP-11 HTTP fetch.
2446 httpURL := url
2447 if len(httpURL) > 6 && httpURL[:6] == "wss://" {
2448 httpURL = "https://" + httpURL[6:]
2449 } else if len(httpURL) > 5 && httpURL[:5] == "ws://" {
2450 httpURL = "http://" + httpURL[5:]
2451 }
2452
2453 dom.FetchRelayInfo(httpURL, func(body string) {
2454 dom.RemoveChild(profilePage, loading)
2455 if body == "" {
2456 errEl := dom.CreateElement("div")
2457 dom.SetTextContent(errEl, "failed to fetch relay info")
2458 dom.SetStyle(errEl, "padding", "16px")
2459 dom.SetStyle(errEl, "color", "#e55")
2460 dom.AppendChild(profilePage, errEl)
2461 return
2462 }
2463 renderRelayInfoBody(body)
2464 })
2465 }
2466
2467 func renderRelayInfoBody(body string) {
2468 container := dom.CreateElement("div")
2469 dom.SetStyle(container, "padding", "16px")
2470
2471 name := helpers.JsonGetString(body, "name")
2472 desc := helpers.JsonGetString(body, "description")
2473 pk := helpers.JsonGetString(body, "pubkey")
2474 contact := helpers.JsonGetString(body, "contact")
2475 software := helpers.JsonGetString(body, "software")
2476 ver := helpers.JsonGetString(body, "version")
2477
2478 if name != "" {
2479 el := dom.CreateElement("div")
2480 dom.SetTextContent(el, name)
2481 dom.SetStyle(el, "fontSize", "20px")
2482 dom.SetStyle(el, "fontWeight", "bold")
2483 dom.SetStyle(el, "marginBottom", "8px")
2484 dom.SetStyle(el, "fontFamily", "system-ui, sans-serif")
2485 dom.AppendChild(container, el)
2486 }
2487
2488 if desc != "" {
2489 el := dom.CreateElement("div")
2490 dom.SetInnerHTML(el, renderMarkdown(desc))
2491 dom.SetStyle(el, "fontSize", "14px")
2492 dom.SetStyle(el, "lineHeight", "1.5")
2493 dom.SetStyle(el, "marginBottom", "12px")
2494 dom.SetStyle(el, "wordBreak", "break-word")
2495 dom.AppendChild(container, el)
2496 }
2497
2498 if contact != "" {
2499 dom.AppendChild(container, profileMetaRow("@", contact, ""))
2500 }
2501 if pk != "" {
2502 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
2503 short := npub
2504 if len(short) > 20 {
2505 short = short[:16] + "..." + short[len(short)-8:]
2506 }
2507 dom.AppendChild(container, profileMetaRow("pk", short, ""))
2508 }
2509 if software != "" {
2510 label := software
2511 if ver != "" {
2512 label += " " + ver
2513 }
2514 dom.AppendChild(container, profileMetaRow("sw", label, ""))
2515 }
2516
2517 dom.AppendChild(profilePage, container)
2518 }
2519
2520 // --- Messaging ---
2521
2522 func relayURLsJSON() string {
2523 msg := "["
2524 for i, url := range relayURLs {
2525 if i > 0 {
2526 msg += ","
2527 }
2528 msg += jstr(url)
2529 }
2530 return msg + "]"
2531 }
2532
2533 func formatTime(ts int64) string {
2534 if ts == 0 {
2535 return ""
2536 }
2537 secs := ts % 86400
2538 h := itoa(int(secs / 3600))
2539 m := itoa(int((secs % 3600) / 60))
2540 if len(h) < 2 {
2541 h = "0" + h
2542 }
2543 if len(m) < 2 {
2544 m = "0" + m
2545 }
2546 return h + ":" + m
2547 }
2548
2549 func initMessaging() {
2550 // Render new-chat button immediately — don't wait for DM_LIST round-trip.
2551 clearChildren(msgListContainer)
2552
2553 if !signer.HasSigner() {
2554 notice := dom.CreateElement("div")
2555 dom.SetStyle(notice, "padding", "24px")
2556 dom.SetStyle(notice, "textAlign", "center")
2557 dom.SetStyle(notice, "color", "var(--muted)")
2558 dom.SetStyle(notice, "fontSize", "13px")
2559 dom.SetStyle(notice, "lineHeight", "1.6")
2560 dom.SetInnerHTML(notice, "encrypted DMs require the <b>Smesh Signer</b> extension")
2561 dom.AppendChild(msgListContainer, notice)
2562 return
2563 }
2564
2565 renderNewChatButton()
2566
2567 // Request conversation list from cache (will re-render below the button).
2568 dom.PostToSW("[\"DM_LIST\"]")
2569
2570 // Init MLS if not already done. publishKP + subscribe auto-bootstrap inside signer.
2571 if !marmotInited {
2572 marmotInited = true
2573 dom.PostToSW("[\"MLS_INIT\"," + relayURLsJSON() + "]")
2574 }
2575 }
2576
2577 func renderNewChatButton() {
2578 newBtn := dom.CreateElement("button")
2579 dom.SetTextContent(newBtn, "+ new chat")
2580 dom.SetStyle(newBtn, "display", "block")
2581 dom.SetStyle(newBtn, "width", "100%")
2582 dom.SetStyle(newBtn, "padding", "10px")
2583 dom.SetStyle(newBtn, "marginBottom", "8px")
2584 dom.SetStyle(newBtn, "fontFamily", "'Fira Code', monospace")
2585 dom.SetStyle(newBtn, "fontSize", "13px")
2586 dom.SetStyle(newBtn, "background", "var(--bg2)")
2587 dom.SetStyle(newBtn, "border", "1px solid var(--border)")
2588 dom.SetStyle(newBtn, "borderRadius", "6px")
2589 dom.SetStyle(newBtn, "color", "var(--accent)")
2590 dom.SetStyle(newBtn, "cursor", "pointer")
2591 dom.SetStyle(newBtn, "textAlign", "left")
2592 dom.AddEventListener(newBtn, "click", dom.RegisterCallback(func() {
2593 showNewChatInput()
2594 }))
2595 dom.AppendChild(msgListContainer, newBtn)
2596 }
2597
2598 func renderConversationList(listJSON string) {
2599 if msgView != "list" {
2600 return
2601 }
2602 clearChildren(msgListContainer)
2603 renderNewChatButton()
2604
2605 // Parse the list JSON array: [{peer,lastMessage,lastTs,from}, ...]
2606 if listJSON == "" || listJSON == "[]" {
2607 empty := dom.CreateElement("div")
2608 dom.SetStyle(empty, "color", "var(--muted)")
2609 dom.SetStyle(empty, "textAlign", "center")
2610 dom.SetStyle(empty, "marginTop", "48px")
2611 dom.SetTextContent(empty, "no conversations yet")
2612 dom.AppendChild(msgListContainer, empty)
2613 return
2614 }
2615
2616 // Walk the JSON array manually — each element is an object.
2617 i := 0
2618 for i < len(listJSON) && listJSON[i] != '[' {
2619 i++
2620 }
2621 i++ // skip '['
2622 for i < len(listJSON) {
2623 // Find next object.
2624 for i < len(listJSON) && listJSON[i] != '{' {
2625 if listJSON[i] == ']' {
2626 return
2627 }
2628 i++
2629 }
2630 if i >= len(listJSON) {
2631 break
2632 }
2633 // Extract the object.
2634 objStart := i
2635 depth := 0
2636 for i < len(listJSON) {
2637 if listJSON[i] == '{' {
2638 depth++
2639 } else if listJSON[i] == '}' {
2640 depth--
2641 if depth == 0 {
2642 i++
2643 break
2644 }
2645 } else if listJSON[i] == '"' {
2646 i++
2647 for i < len(listJSON) && listJSON[i] != '"' {
2648 if listJSON[i] == '\\' {
2649 i++
2650 }
2651 i++
2652 }
2653 }
2654 i++
2655 }
2656 obj := listJSON[objStart:i]
2657
2658 peer := helpers.JsonGetString(obj, "peer")
2659 lastMsg := helpers.JsonGetString(obj, "lastMessage")
2660 lastTs := jsonGetNum(obj, "lastTs")
2661 if peer == "" {
2662 continue
2663 }
2664
2665 renderConversationRow(peer, lastMsg, lastTs)
2666 }
2667 }
2668
2669 func renderConversationRow(peer, lastMsg string, lastTs int64) {
2670 row := dom.CreateElement("div")
2671 dom.SetStyle(row, "display", "flex")
2672 dom.SetStyle(row, "alignItems", "center")
2673 dom.SetStyle(row, "gap", "10px")
2674 dom.SetStyle(row, "padding", "10px 4px")
2675 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
2676 dom.SetStyle(row, "cursor", "pointer")
2677
2678 // Avatar.
2679 av := dom.CreateElement("img")
2680 dom.SetAttribute(av, "width", "32")
2681 dom.SetAttribute(av, "height", "32")
2682 dom.SetStyle(av, "borderRadius", "50%")
2683 dom.SetStyle(av, "objectFit", "cover")
2684 dom.SetStyle(av, "flexShrink", "0")
2685 if pic, ok := authorPics[peer]; ok && pic != "" {
2686 dom.SetAttribute(av, "src", pic)
2687 } else {
2688 dom.SetStyle(av, "background", "var(--bg2)")
2689 }
2690 dom.SetAttribute(av, "onerror", "this.style.display='none'")
2691 dom.AppendChild(row, av)
2692
2693 // Name + preview column.
2694 col := dom.CreateElement("div")
2695 dom.SetStyle(col, "flex", "1")
2696 dom.SetStyle(col, "minWidth", "0")
2697
2698 nameSpan := dom.CreateElement("div")
2699 dom.SetStyle(nameSpan, "fontSize", "14px")
2700 dom.SetStyle(nameSpan, "fontWeight", "bold")
2701 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
2702 dom.SetStyle(nameSpan, "overflow", "hidden")
2703 dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
2704 dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
2705 if name, ok := authorNames[peer]; ok && name != "" {
2706 dom.SetTextContent(nameSpan, name)
2707 } else {
2708 npub := helpers.EncodeNpub(helpers.HexDecode(peer))
2709 if len(npub) > 20 {
2710 dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:])
2711 } else {
2712 dom.SetTextContent(nameSpan, npub)
2713 }
2714 }
2715 dom.AppendChild(col, nameSpan)
2716
2717 preview := dom.CreateElement("div")
2718 dom.SetStyle(preview, "fontSize", "12px")
2719 dom.SetStyle(preview, "color", "var(--muted)")
2720 dom.SetStyle(preview, "overflow", "hidden")
2721 dom.SetStyle(preview, "textOverflow", "ellipsis")
2722 dom.SetStyle(preview, "whiteSpace", "nowrap")
2723 if len(lastMsg) > 80 {
2724 lastMsg = lastMsg[:80] + "..."
2725 }
2726 dom.SetTextContent(preview, lastMsg)
2727 dom.AppendChild(col, preview)
2728 dom.AppendChild(row, col)
2729
2730 // Timestamp.
2731 if lastTs > 0 {
2732 tsSpan := dom.CreateElement("span")
2733 dom.SetStyle(tsSpan, "fontSize", "11px")
2734 dom.SetStyle(tsSpan, "color", "var(--muted)")
2735 dom.SetStyle(tsSpan, "flexShrink", "0")
2736 dom.SetTextContent(tsSpan, formatTime(lastTs))
2737 dom.AppendChild(row, tsSpan)
2738 }
2739
2740 rowPeer := peer
2741 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
2742 openThread(rowPeer)
2743 }))
2744
2745 // Lazy profile fetch — batched to avoid 50+ simultaneous subscriptions.
2746 if _, cached := authorNames[peer]; !cached && !fetchedK0[peer] {
2747 queueProfileFetch(peer)
2748 }
2749
2750 dom.AppendChild(msgListContainer, row)
2751 }
2752
2753 func showNewChatInput() {
2754 // Check if input row already exists (first child after button).
2755 fc := dom.FirstChild(msgListContainer)
2756 if fc != 0 {
2757 ns := dom.NextSibling(fc)
2758 if ns != 0 {
2759 tag := dom.GetProperty(ns, "tagName")
2760 if tag == "DIV" {
2761 id := dom.GetProperty(ns, "id")
2762 if id == "new-chat-row" {
2763 return // already showing
2764 }
2765 }
2766 }
2767 }
2768
2769 inputRow := dom.CreateElement("div")
2770 dom.SetAttribute(inputRow, "id", "new-chat-row")
2771 dom.SetStyle(inputRow, "display", "flex")
2772 dom.SetStyle(inputRow, "gap", "8px")
2773 dom.SetStyle(inputRow, "marginBottom", "8px")
2774
2775 inp := dom.CreateElement("input")
2776 dom.SetAttribute(inp, "type", "text")
2777 dom.SetAttribute(inp, "placeholder", "npub or hex pubkey")
2778 dom.SetStyle(inp, "flex", "1")
2779 dom.SetStyle(inp, "padding", "8px")
2780 dom.SetStyle(inp, "fontFamily", "'Fira Code', monospace")
2781 dom.SetStyle(inp, "fontSize", "12px")
2782 dom.SetStyle(inp, "background", "var(--bg)")
2783 dom.SetStyle(inp, "border", "1px solid var(--border)")
2784 dom.SetStyle(inp, "borderRadius", "4px")
2785 dom.SetStyle(inp, "color", "var(--fg)")
2786
2787 goBtn := dom.CreateElement("button")
2788 dom.SetTextContent(goBtn, "go")
2789 dom.SetStyle(goBtn, "padding", "8px 16px")
2790 dom.SetStyle(goBtn, "fontFamily", "'Fira Code', monospace")
2791 dom.SetStyle(goBtn, "fontSize", "12px")
2792 dom.SetStyle(goBtn, "background", "var(--accent)")
2793 dom.SetStyle(goBtn, "color", "#000")
2794 dom.SetStyle(goBtn, "border", "none")
2795 dom.SetStyle(goBtn, "borderRadius", "4px")
2796 dom.SetStyle(goBtn, "cursor", "pointer")
2797
2798 submitNewChat := func() {
2799 val := dom.GetProperty(inp, "value")
2800 if val == "" {
2801 return
2802 }
2803 var hexPK string
2804 if len(val) == 64 {
2805 // Assume hex pubkey.
2806 hexPK = val
2807 } else if len(val) > 4 && val[:4] == "npub" {
2808 decoded := helpers.DecodeNpub(val)
2809 if decoded == nil {
2810 return
2811 }
2812 hexPK = helpers.HexEncode(decoded)
2813 } else {
2814 return
2815 }
2816 openThread(hexPK)
2817 }
2818
2819 dom.AddEventListener(goBtn, "click", dom.RegisterCallback(submitNewChat))
2820 // Enter key triggers the "go" button via inline handler.
2821 dom.SetAttribute(inp, "onkeydown", "if(event.key==='Enter'){event.preventDefault();this.nextSibling.click()}")
2822
2823 dom.AppendChild(inputRow, inp)
2824 dom.AppendChild(inputRow, goBtn)
2825
2826 // Insert after the "new chat" button.
2827 btn := dom.FirstChild(msgListContainer)
2828 if btn != 0 {
2829 ns := dom.NextSibling(btn)
2830 if ns != 0 {
2831 dom.InsertBefore(msgListContainer, inputRow, ns)
2832 } else {
2833 dom.AppendChild(msgListContainer, inputRow)
2834 }
2835 } else {
2836 dom.AppendChild(msgListContainer, inputRow)
2837 }
2838 }
2839
2840 func openThread(peer string) {
2841 msgCurrentPeer = peer
2842 msgView = "thread"
2843
2844 if !navPop {
2845 npub := helpers.EncodeNpub(helpers.HexDecode(peer))
2846 dom.PushState("/msg/" + npub)
2847 }
2848
2849 // Hide list, show thread.
2850 dom.SetStyle(msgListContainer, "display", "none")
2851 dom.SetStyle(msgThreadContainer, "display", "flex")
2852
2853 // Build thread UI.
2854 clearChildren(msgThreadContainer)
2855
2856 // Header: back + avatar + name.
2857 hdr := dom.CreateElement("div")
2858 dom.SetStyle(hdr, "display", "flex")
2859 dom.SetStyle(hdr, "alignItems", "center")
2860 dom.SetStyle(hdr, "gap", "10px")
2861 dom.SetStyle(hdr, "padding", "12px 16px")
2862 dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
2863 dom.SetStyle(hdr, "flexShrink", "0")
2864
2865 backBtn := dom.CreateElement("button")
2866 dom.SetInnerHTML(backBtn, "←") // ←
2867 dom.SetStyle(backBtn, "background", "none")
2868 dom.SetStyle(backBtn, "border", "none")
2869 dom.SetStyle(backBtn, "fontSize", "20px")
2870 dom.SetStyle(backBtn, "cursor", "pointer")
2871 dom.SetStyle(backBtn, "color", "var(--fg)")
2872 dom.SetStyle(backBtn, "padding", "0")
2873 dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() {
2874 closeThread()
2875 }))
2876 dom.AppendChild(hdr, backBtn)
2877
2878 // Thread header avatar + name — uses same img-then-span structure
2879 // as note headers so pendingNotes/updateNoteHeader can update them.
2880 threadHdrInner := dom.CreateElement("div")
2881 av := dom.CreateElement("img")
2882 dom.SetAttribute(av, "width", "28")
2883 dom.SetAttribute(av, "height", "28")
2884 dom.SetStyle(av, "borderRadius", "50%")
2885 dom.SetStyle(av, "objectFit", "cover")
2886 dom.SetStyle(av, "flexShrink", "0")
2887 if pic, ok := authorPics[peer]; ok && pic != "" {
2888 dom.SetAttribute(av, "src", pic)
2889 } else {
2890 dom.SetStyle(av, "display", "none")
2891 }
2892 dom.SetAttribute(av, "onerror", "this.style.display='none'")
2893 dom.AppendChild(threadHdrInner, av)
2894
2895 nameSpan := dom.CreateElement("span")
2896 dom.SetStyle(nameSpan, "fontSize", "15px")
2897 dom.SetStyle(nameSpan, "fontWeight", "bold")
2898 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
2899 if name, ok := authorNames[peer]; ok && name != "" {
2900 dom.SetTextContent(nameSpan, name)
2901 } else {
2902 npub := helpers.EncodeNpub(helpers.HexDecode(peer))
2903 if len(npub) > 20 {
2904 dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:])
2905 }
2906 }
2907 dom.AppendChild(threadHdrInner, nameSpan)
2908 dom.SetStyle(threadHdrInner, "display", "flex")
2909 dom.SetStyle(threadHdrInner, "alignItems", "center")
2910 dom.SetStyle(threadHdrInner, "gap", "10px")
2911 dom.AppendChild(hdr, threadHdrInner)
2912
2913 ratchetBtn := dom.CreateElement("button")
2914 dom.SetTextContent(ratchetBtn, "ratchet")
2915 dom.SetStyle(ratchetBtn, "marginLeft", "auto")
2916 dom.SetStyle(ratchetBtn, "background", "none")
2917 dom.SetStyle(ratchetBtn, "border", "1px solid var(--border)")
2918 dom.SetStyle(ratchetBtn, "borderRadius", "4px")
2919 dom.SetStyle(ratchetBtn, "color", "var(--fg)")
2920 dom.SetStyle(ratchetBtn, "cursor", "pointer")
2921 dom.SetStyle(ratchetBtn, "fontSize", "11px")
2922 dom.SetStyle(ratchetBtn, "padding", "4px 8px")
2923 dom.SetStyle(ratchetBtn, "fontFamily", "'Fira Code', monospace")
2924 dom.AddEventListener(ratchetBtn, "click", dom.RegisterCallback(func() {
2925 if dom.Confirm("Delete all messages and rotate encryption keys?") {
2926 dom.PostToSW("[\"MLS_RATCHET\"," + jstr(peer) + "]")
2927 clearChildren(msgThreadMessages)
2928 }
2929 }))
2930 dom.AppendChild(hdr, ratchetBtn)
2931
2932 dom.AppendChild(msgThreadContainer, hdr)
2933
2934 // Track for live update when profile arrives.
2935 if _, cached := authorNames[peer]; !cached {
2936 pendingNotes[peer] = append(pendingNotes[peer], threadHdrInner)
2937 }
2938
2939 // Message area.
2940 msgThreadMessages = dom.CreateElement("div")
2941 dom.SetStyle(msgThreadMessages, "flex", "1")
2942 dom.SetStyle(msgThreadMessages, "overflowY", "auto")
2943 dom.SetStyle(msgThreadMessages, "padding", "12px 16px")
2944 dom.AppendChild(msgThreadContainer, msgThreadMessages)
2945
2946 // Compose area.
2947 compose := dom.CreateElement("div")
2948 dom.SetStyle(compose, "display", "flex")
2949 dom.SetStyle(compose, "gap", "8px")
2950 dom.SetStyle(compose, "padding", "8px 16px")
2951 dom.SetStyle(compose, "borderTop", "1px solid var(--border)")
2952 dom.SetStyle(compose, "flexShrink", "0")
2953
2954 msgComposeInput = dom.CreateElement("textarea")
2955 dom.SetAttribute(msgComposeInput, "rows", "1")
2956 dom.SetAttribute(msgComposeInput, "placeholder", "message...")
2957 dom.SetStyle(msgComposeInput, "flex", "1")
2958 dom.SetStyle(msgComposeInput, "padding", "8px")
2959 dom.SetStyle(msgComposeInput, "fontFamily", "'Fira Code', monospace")
2960 dom.SetStyle(msgComposeInput, "fontSize", "13px")
2961 dom.SetStyle(msgComposeInput, "background", "var(--bg)")
2962 dom.SetStyle(msgComposeInput, "border", "1px solid var(--border)")
2963 dom.SetStyle(msgComposeInput, "borderRadius", "4px")
2964 dom.SetStyle(msgComposeInput, "color", "var(--fg)")
2965 dom.SetStyle(msgComposeInput, "resize", "none")
2966 dom.SetAttribute(msgComposeInput, "onkeydown", "if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.nextSibling.click()}")
2967 dom.AppendChild(compose, msgComposeInput)
2968
2969 sendBtn := dom.CreateElement("button")
2970 dom.SetTextContent(sendBtn, "send")
2971 dom.SetStyle(sendBtn, "padding", "8px 16px")
2972 dom.SetStyle(sendBtn, "fontFamily", "'Fira Code', monospace")
2973 dom.SetStyle(sendBtn, "fontSize", "13px")
2974 dom.SetStyle(sendBtn, "background", "var(--accent)")
2975 dom.SetStyle(sendBtn, "color", "#000")
2976 dom.SetStyle(sendBtn, "border", "none")
2977 dom.SetStyle(sendBtn, "borderRadius", "4px")
2978 dom.SetStyle(sendBtn, "cursor", "pointer")
2979 dom.SetStyle(sendBtn, "alignSelf", "flex-end")
2980 dom.AddEventListener(sendBtn, "click", dom.RegisterCallback(func() {
2981 sendMessage()
2982 }))
2983 dom.AppendChild(compose, sendBtn)
2984
2985 dom.AppendChild(msgThreadContainer, compose)
2986
2987 // Fetch profile if needed.
2988 if !fetchedK0[peer] {
2989 queueProfileFetch(peer)
2990 }
2991
2992 // Request history.
2993 dom.PostToSW("[\"DM_HISTORY\"," + jstr(peer) + ",50,0]")
2994 }
2995
2996 func closeThread() {
2997 msgCurrentPeer = ""
2998 msgView = "list"
2999
3000 dom.SetStyle(msgThreadContainer, "display", "none")
3001 dom.SetStyle(msgListContainer, "display", "block")
3002
3003 if !navPop {
3004 dom.PushState("/msg")
3005 }
3006
3007 // Refresh list.
3008 dom.PostToSW("[\"DM_LIST\"]")
3009 }
3010
3011 func renderThreadMessages(peer, msgsJSON string) {
3012 if peer != msgCurrentPeer {
3013 return
3014 }
3015 if msgsJSON == "" || msgsJSON == "[]" {
3016 return
3017 }
3018
3019 // Parse messages array — each element is a DMRecord object.
3020 // IDB returns newest-first; collect then reverse for oldest-at-top.
3021 type dmMsg struct {
3022 from string
3023 content string
3024 ts int64
3025 }
3026 var msgs []dmMsg
3027
3028 i := 0
3029 for i < len(msgsJSON) && msgsJSON[i] != '[' {
3030 i++
3031 }
3032 i++
3033 for i < len(msgsJSON) {
3034 for i < len(msgsJSON) && msgsJSON[i] != '{' {
3035 if msgsJSON[i] == ']' {
3036 goto done
3037 }
3038 i++
3039 }
3040 if i >= len(msgsJSON) {
3041 break
3042 }
3043 objStart := i
3044 depth := 0
3045 for i < len(msgsJSON) {
3046 if msgsJSON[i] == '{' {
3047 depth++
3048 } else if msgsJSON[i] == '}' {
3049 depth--
3050 if depth == 0 {
3051 i++
3052 break
3053 }
3054 } else if msgsJSON[i] == '"' {
3055 i++
3056 for i < len(msgsJSON) && msgsJSON[i] != '"' {
3057 if msgsJSON[i] == '\\' {
3058 i++
3059 }
3060 i++
3061 }
3062 }
3063 i++
3064 }
3065 obj := msgsJSON[objStart:i]
3066
3067 from := helpers.JsonGetString(obj, "from")
3068 content := helpers.JsonGetString(obj, "content")
3069 ts := jsonGetNum(obj, "created_at")
3070 msgs = append(msgs, dmMsg{from, content, ts})
3071 }
3072 done:
3073
3074 // Reverse for oldest-first.
3075 for l, r := 0, len(msgs)-1; l < r; l, r = l+1, r-1 {
3076 msgs[l], msgs[r] = msgs[r], msgs[l]
3077 }
3078
3079 clearChildren(msgThreadMessages)
3080 for _, m := range msgs {
3081 appendBubble(m.from, m.content, m.ts)
3082 }
3083 scrollToBottom()
3084 }
3085
3086 func appendBubble(from, content string, ts int64) {
3087 isSent := from == pubhex
3088
3089 wrap := dom.CreateElement("div")
3090 dom.SetStyle(wrap, "display", "flex")
3091 dom.SetStyle(wrap, "marginBottom", "6px")
3092 if isSent {
3093 dom.SetStyle(wrap, "justifyContent", "flex-end")
3094 }
3095
3096 bubble := dom.CreateElement("div")
3097 dom.SetStyle(bubble, "maxWidth", "75%")
3098 dom.SetStyle(bubble, "padding", "8px 12px")
3099 dom.SetStyle(bubble, "borderRadius", "12px")
3100 dom.SetStyle(bubble, "fontSize", "14px")
3101 dom.SetStyle(bubble, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
3102 dom.SetStyle(bubble, "lineHeight", "1.4")
3103 dom.SetStyle(bubble, "wordBreak", "break-word")
3104 if isSent {
3105 dom.SetStyle(bubble, "background", "var(--accent)")
3106 dom.SetStyle(bubble, "color", "#000")
3107 } else {
3108 dom.SetStyle(bubble, "background", "var(--bg2)")
3109 dom.SetStyle(bubble, "color", "var(--fg)")
3110 }
3111 dom.SetInnerHTML(bubble, renderMarkdown(content))
3112
3113 // Timestamp below bubble.
3114 tsEl := dom.CreateElement("div")
3115 dom.SetStyle(tsEl, "fontSize", "10px")
3116 dom.SetStyle(tsEl, "color", "var(--muted)")
3117 dom.SetStyle(tsEl, "marginTop", "2px")
3118 if isSent {
3119 dom.SetStyle(tsEl, "textAlign", "right")
3120 }
3121 dom.SetTextContent(tsEl, formatTime(ts))
3122 if isSent && ts == 0 {
3123 pendingTsEls = append(pendingTsEls, tsEl)
3124 }
3125
3126 outer := dom.CreateElement("div")
3127 dom.AppendChild(outer, bubble)
3128 dom.AppendChild(outer, tsEl)
3129
3130 // Email quote-reply button for received messages with email headers.
3131 if !isSent {
3132 emailFrom, emailSubject, emailBody, isEmail := parseEmailHeaders(content)
3133 if isEmail {
3134 replyBtn := dom.CreateElement("div")
3135 dom.SetStyle(replyBtn, "fontSize", "11px")
3136 dom.SetStyle(replyBtn, "color", "var(--accent)")
3137 dom.SetStyle(replyBtn, "cursor", "pointer")
3138 dom.SetStyle(replyBtn, "marginTop", "2px")
3139 dom.SetTextContent(replyBtn, "\u21a9 Reply")
3140 dom.AddEventListener(replyBtn, "click", dom.RegisterCallback(func() {
3141 quoted := quoteReply(emailFrom, emailSubject, emailBody)
3142 dom.SetProperty(msgComposeInput, "value", quoted)
3143 }))
3144 dom.AppendChild(outer, replyBtn)
3145 }
3146 }
3147
3148 dom.AppendChild(wrap, outer)
3149 dom.AppendChild(msgThreadMessages, wrap)
3150 }
3151
3152 func appendSystemBubble(text string) {
3153 wrap := dom.CreateElement("div")
3154 dom.SetStyle(wrap, "display", "flex")
3155 dom.SetStyle(wrap, "justifyContent", "center")
3156 dom.SetStyle(wrap, "marginBottom", "6px")
3157
3158 bubble := dom.CreateElement("div")
3159 dom.SetStyle(bubble, "maxWidth", "85%")
3160 dom.SetStyle(bubble, "padding", "8px 12px")
3161 dom.SetStyle(bubble, "borderRadius", "8px")
3162 dom.SetStyle(bubble, "fontSize", "12px")
3163 dom.SetStyle(bubble, "fontFamily", "monospace")
3164 dom.SetStyle(bubble, "lineHeight", "1.5")
3165 dom.SetStyle(bubble, "whiteSpace", "pre-wrap")
3166 dom.SetStyle(bubble, "background", "var(--bg2)")
3167 dom.SetStyle(bubble, "color", "var(--muted)")
3168 dom.SetStyle(bubble, "border", "1px solid var(--muted)")
3169 dom.SetTextContent(bubble, text)
3170
3171 dom.AppendChild(wrap, bubble)
3172 dom.AppendChild(msgThreadMessages, wrap)
3173 scrollToBottom()
3174 }
3175
3176 func scrollToBottom() {
3177 dom.SetProperty(msgThreadMessages, "scrollTop", "999999")
3178 }
3179
3180 func sendMessage() {
3181 content := dom.GetProperty(msgComposeInput, "value")
3182 if content == "" || msgCurrentPeer == "" {
3183 return
3184 }
3185
3186 // Clear input.
3187 dom.SetProperty(msgComposeInput, "value", "")
3188
3189 dom.PostToSW("[\"MLS_SEND\"," + jstr(msgCurrentPeer) + "," + jstr(content) + "]")
3190
3191 // Optimistic render (ts=0 — timestamp not shown for "just sent").
3192 appendBubble(pubhex, content, 0)
3193 scrollToBottom()
3194 }
3195
3196 func handleDMReceived(dmJSON string) {
3197 peer := helpers.JsonGetString(dmJSON, "peer")
3198 from := helpers.JsonGetString(dmJSON, "from")
3199 content := helpers.JsonGetString(dmJSON, "content")
3200 ts := jsonGetNum(dmJSON, "created_at")
3201
3202 if msgView == "thread" && peer == msgCurrentPeer {
3203 // Don't double-render our own sent messages (already optimistic).
3204 if from == pubhex {
3205 return
3206 }
3207 appendBubble(from, content, ts)
3208 scrollToBottom()
3209 } else if msgView == "list" {
3210 // Refresh conversation list.
3211 dom.PostToSW("[\"DM_LIST\"]")
3212 }
3213 }
3214
3215 // --- Logout ---
3216
3217 func doLogout() {
3218 // Tell SW to clean up.
3219 dom.PostToSW("[\"CLOSE\",\"prof\"]")
3220 dom.PostToSW("[\"CLOSE\",\"feed\"]")
3221 dom.PostToSW("[\"CLEAR_KEY\"]")
3222
3223 pubkey = nil
3224 pubhex = ""
3225 profileName = ""
3226 profilePic = ""
3227 profileTs = 0
3228 eventCount = 0
3229 popoverOpen = false
3230 marmotInited = false
3231 msgCurrentPeer = ""
3232 msgView = "list"
3233
3234 // Reset relay tracking.
3235 relayURLs = nil
3236 relayDots = nil
3237 relayLabels = nil
3238 relayUserPick = nil
3239
3240 localstorage.RemoveItem(lsKeyPubkey)
3241
3242 clearChildren(root)
3243 showLogin()
3244 }
3245
3246 // --- Email header parsing for quote-reply ---
3247
3248 // parseEmailHeaders checks if content looks like a forwarded email and extracts
3249 // From, Subject, and body. Returns isEmail=true if at least From: or Subject: found.
3250 func parseEmailHeaders(content string) (from, subject, body string, isEmail bool) {
3251 lines := splitLines(content)
3252 headerEnd := -1
3253 for i, line := range lines {
3254 if line == "" {
3255 headerEnd = i
3256 break
3257 }
3258 if hasPrefix(line, "From: ") {
3259 from = line[6:]
3260 } else if hasPrefix(line, "Subject: ") {
3261 subject = line[9:]
3262 } else if hasPrefix(line, "To: ") || hasPrefix(line, "Date: ") || hasPrefix(line, "Cc: ") {
3263 // Known header, continue
3264 } else if i == 0 {
3265 return "", "", "", false
3266 }
3267 }
3268 if from == "" && subject == "" {
3269 return "", "", "", false
3270 }
3271 if headerEnd >= 0 && headerEnd+1 < len(lines) {
3272 body = joinLines(lines[headerEnd+1:])
3273 }
3274 return from, subject, body, true
3275 }
3276
3277 func quoteReply(from, subject, body string) string {
3278 out := "To: " + from + "\n"
3279 if subject != "" {
3280 if !hasPrefix(subject, "Re: ") {
3281 subject = "Re: " + subject
3282 }
3283 out += "Subject: " + subject + "\n"
3284 }
3285 out += "\n\n"
3286 if body != "" {
3287 lines := splitLines(body)
3288 for _, line := range lines {
3289 out += "> " + line + "\n"
3290 }
3291 }
3292 return out
3293 }
3294
3295 func splitLines(s string) []string {
3296 var lines []string
3297 for {
3298 idx := strIndex(s, "\n")
3299 if idx < 0 {
3300 lines = append(lines, s)
3301 return lines
3302 }
3303 lines = append(lines, s[:idx])
3304 s = s[idx+1:]
3305 }
3306 }
3307
3308 func joinLines(lines []string) string {
3309 out := ""
3310 for i, line := range lines {
3311 if i > 0 {
3312 out += "\n"
3313 }
3314 out += line
3315 }
3316 return out
3317 }
3318
3319 func hasPrefix(s, prefix string) bool {
3320 return len(s) >= len(prefix) && s[:len(prefix)] == prefix
3321 }
3322
3323 // --- Markdown rendering ---
3324 // All functions use string concatenation and indexOf — no byte-level ops.
3325 // tinyjs compiles Go strings to JS strings (UTF-16); byte indexing corrupts emoji.
3326
3327 // renderMarkdown converts note text to safe HTML.
3328 func renderMarkdown(s string) string {
3329 s = strReplace(s, "&", "&")
3330 s = strReplace(s, "<", "<")
3331 s = strReplace(s, ">", ">")
3332 s = strReplace(s, "\"", """)
3333 s = wrapDelimited(s, "`", "<code>", "</code>")
3334 s = wrapDelimited(s, "**", "<strong>", "</strong>")
3335 s = wrapDelimited(s, "*", "<em>", "</em>")
3336 s = autoLinkURLs(s)
3337 s = strReplace(s, "\n", "<br>")
3338 return s
3339 }
3340
3341 // strReplace replaces all occurrences of old with new using indexOf.
3342 func strReplace(s, old, nw string) string {
3343 out := ""
3344 for {
3345 idx := strIndex(s, old)
3346 if idx < 0 {
3347 return out + s
3348 }
3349 out += s[:idx] + nw
3350 s = s[idx+len(old):]
3351 }
3352 }
3353
3354 // wrapDelimited finds matching pairs of delim and wraps content in open/close tags.
3355 func wrapDelimited(s, delim, open, close string) string {
3356 out := ""
3357 for {
3358 start := strIndex(s, delim)
3359 if start < 0 {
3360 return out + s
3361 }
3362 end := strIndex(s[start+len(delim):], delim)
3363 if end < 0 {
3364 return out + s
3365 }
3366 end += start + len(delim)
3367 inner := s[start+len(delim) : end]
3368 if len(inner) == 0 {
3369 out += s[:start+len(delim)]
3370 s = s[start+len(delim):]
3371 continue
3372 }
3373 out += s[:start] + open + inner + close
3374 s = s[end+len(delim):]
3375 }
3376 }
3377
3378 func autoLinkURLs(s string) string {
3379 out := ""
3380 for {
3381 hi := strIndex(s, "https://")
3382 lo := strIndex(s, "http://")
3383 idx := -1
3384 if hi >= 0 && (lo < 0 || hi <= lo) {
3385 idx = hi
3386 } else if lo >= 0 {
3387 idx = lo
3388 }
3389 if idx < 0 {
3390 return out + s
3391 }
3392 out += s[:idx]
3393 s = s[idx:]
3394 // Find end of URL.
3395 end := 0
3396 for end < len(s) {
3397 c := s[end : end+1]
3398 if c == " " || c == "\n" || c == "\r" || c == "\t" || c == "<" || c == ">" {
3399 break
3400 }
3401 end++
3402 }
3403 // Trim trailing punctuation.
3404 for end > 0 {
3405 c := s[end-1 : end]
3406 if c == "." || c == "," || c == ")" || c == ";" {
3407 end--
3408 } else {
3409 break
3410 }
3411 }
3412 url := s[:end]
3413 if isImageURL(url) {
3414 out += "<img src=\"" + url + "\" style=\"display:block;max-width:100%;border-radius:8px;margin:4px 0\" loading=\"lazy\">"
3415 } else {
3416 out += "<a href=\"" + url + "\" target=\"_blank\" rel=\"noopener\" style=\"color:var(--accent);word-break:break-all\">" + url + "</a>"
3417 }
3418 s = s[end:]
3419 }
3420 }
3421
3422 func isImageURL(url string) bool {
3423 u := toLower(url)
3424 return hasSuffix(u, ".jpg") || hasSuffix(u, ".jpeg") || hasSuffix(u, ".png") ||
3425 hasSuffix(u, ".gif") || hasSuffix(u, ".webp") || hasSuffix(u, ".svg")
3426 }
3427
3428 func hasSuffix(s, suffix string) bool {
3429 return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
3430 }
3431
3432 // jsonGetNum extracts a numeric value for a given key from a JSON object.
3433 func jsonGetNum(s, key string) int64 {
3434 needle := "\"" + key + "\":"
3435 idx := strIndex(s, needle)
3436 if idx < 0 {
3437 return 0
3438 }
3439 idx += len(needle)
3440 // Skip whitespace.
3441 for idx < len(s) && (s[idx] == ' ' || s[idx] == '\t') {
3442 idx++
3443 }
3444 if idx >= len(s) {
3445 return 0
3446 }
3447 var n int64
3448 for idx < len(s) && s[idx] >= '0' && s[idx] <= '9' {
3449 n = n*10 + int64(s[idx]-'0')
3450 idx++
3451 }
3452 return n
3453 }
3454
3455 // jsonEsc escapes a string for embedding in a JSON value.
3456 func jsonEsc(s string) string {
3457 s = strReplace(s, "\\", "\\\\")
3458 s = strReplace(s, "\"", "\\\"")
3459 s = strReplace(s, "\n", "\\n")
3460 s = strReplace(s, "\r", "\\r")
3461 s = strReplace(s, "\t", "\\t")
3462 return s
3463 }
3464
3465 // strIndex finds substring in string. Returns -1 if not found.
3466 func strIndex(s, sub string) int {
3467 sl := len(sub)
3468 for i := 0; i <= len(s)-sl; i++ {
3469 if s[i:i+sl] == sub {
3470 return i
3471 }
3472 }
3473 return -1
3474 }
3475
3476 // --- Helpers ---
3477
3478 // normalizeURL strips trailing slashes and lowercases the scheme+host.
3479 func normalizeURL(u string) string {
3480 for len(u) > 0 && u[len(u)-1] == '/' {
3481 u = u[:len(u)-1]
3482 }
3483 // Lowercase scheme and host (before first / after ://).
3484 if len(u) > 6 && u[:6] == "wss://" {
3485 rest := u[6:]
3486 slash := strIndex(rest, "/")
3487 if slash < 0 {
3488 return u[:6] + toLower(rest)
3489 }
3490 return u[:6] + toLower(rest[:slash]) + rest[slash:]
3491 }
3492 if len(u) > 5 && u[:5] == "ws://" {
3493 rest := u[5:]
3494 slash := strIndex(rest, "/")
3495 if slash < 0 {
3496 return u[:5] + toLower(rest)
3497 }
3498 return u[:5] + toLower(rest[:slash]) + rest[slash:]
3499 }
3500 return u
3501 }
3502
3503 func toLower(s string) string {
3504 b := make([]byte, len(s))
3505 for i := 0; i < len(s); i++ {
3506 c := s[i]
3507 if c >= 'A' && c <= 'Z' {
3508 c += 32
3509 }
3510 b[i] = c
3511 }
3512 return string(b)
3513 }
3514
3515 func showQRModal(npubStr string) {
3516 svg := qrSVG(npubStr, 280, logoSVGCache)
3517 if svg == "" {
3518 return
3519 }
3520 scrim := dom.CreateElement("div")
3521 dom.SetStyle(scrim, "position", "fixed")
3522 dom.SetStyle(scrim, "inset", "0")
3523 dom.SetStyle(scrim, "background", "rgba(0,0,0,0.6)")
3524 dom.SetStyle(scrim, "display", "flex")
3525 dom.SetStyle(scrim, "alignItems", "center")
3526 dom.SetStyle(scrim, "justifyContent", "center")
3527 dom.SetStyle(scrim, "zIndex", "9999")
3528 dom.SetStyle(scrim, "cursor", "pointer")
3529 dom.AddEventListener(scrim, "click", dom.RegisterCallback(func() {
3530 dom.RemoveChild(dom.Body(), scrim)
3531 }))
3532
3533 card := dom.CreateElement("div")
3534 dom.SetStyle(card, "background", "white")
3535 dom.SetStyle(card, "borderRadius", "16px")
3536 dom.SetStyle(card, "padding", "24px")
3537 dom.SetStyle(card, "display", "flex")
3538 dom.SetStyle(card, "flexDirection", "column")
3539 dom.SetStyle(card, "alignItems", "center")
3540 dom.SetStyle(card, "gap", "12px")
3541 dom.SetStyle(card, "cursor", "default")
3542 dom.SetAttribute(card, "onclick", "event.stopPropagation()")
3543 dom.SetInnerHTML(card, svg)
3544
3545 label := dom.CreateElement("div")
3546 dom.SetStyle(label, "fontSize", "11px")
3547 dom.SetStyle(label, "color", "#666")
3548 dom.SetStyle(label, "wordBreak", "break-all")
3549 dom.SetStyle(label, "textAlign", "center")
3550 dom.SetStyle(label, "maxWidth", "280px")
3551 dom.SetStyle(label, "fontFamily", "'Fira Code', monospace")
3552 dom.SetTextContent(label, npubStr)
3553 dom.AppendChild(card, label)
3554
3555 dom.AppendChild(scrim, card)
3556 dom.AppendChild(dom.Body(), scrim)
3557 }
3558
3559 func clearChildren(el dom.Element) {
3560 dom.SetInnerHTML(el, "")
3561 }
3562
3563 func itoa(n int) string {
3564 if n == 0 {
3565 return "0"
3566 }
3567 neg := false
3568 if n < 0 {
3569 neg = true
3570 n = -n
3571 }
3572 var b [20]byte
3573 i := len(b)
3574 for n > 0 {
3575 i--
3576 b[i] = byte('0' + n%10)
3577 n /= 10
3578 }
3579 if neg {
3580 i--
3581 b[i] = '-'
3582 }
3583 return string(b[i:])
3584 }
3585