main.mx raw
1 package main
2
3 import (
4 "smesh.lol/web/common/helpers"
5 "smesh.lol/web/common/jsbridge/dom"
6 "smesh.lol/web/common/jsbridge/localstorage"
7 "smesh.lol/web/common/jsbridge/signer"
8 "smesh.lol/web/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 topBackBtn dom.Element
36 feedPage dom.Element
37 msgPage dom.Element
38 profilePage dom.Element
39 sidebarFeed dom.Element
40 sidebarMsg dom.Element
41 sidebarSettings dom.Element
42 settingsPage dom.Element
43 activePage string
44 profileViewPK string
45
46 // App root — content goes here, not body (snackbar stays outside).
47 root dom.Element
48
49 // Messaging UI state.
50 msgListContainer dom.Element // conversation list view
51 msgThreadContainer dom.Element // thread view (header + messages + compose)
52 msgThreadMessages dom.Element // scrollable message area
53 msgComposeInput dom.Element // textarea
54 msgCurrentPeer string // hex pubkey of open thread, "" when on list
55 msgView string // "list" or "thread"
56 marmotInited bool
57 pendingTsEls []dom.Element // timestamp divs awaiting relay confirmation
58
59 // Relay tracking — parallel slices, grown dynamically.
60 relayURLs []string
61 relayDots []dom.Element
62 relayLabels []dom.Element
63 relayUserPick []bool // true = from user's kind 10002
64
65 eventCount int
66 popoverOpen bool
67 oldestFeedTs int64 // oldest created_at in feed — for infinite scroll
68 feedLoading bool // true while fetching older events
69 feedExhausted bool // true when EOSE returns no new events
70 feedEmptyStreak int // consecutive empty EOSE responses
71
72 // Author profile cache.
73 authorNames map[string]string // pubkey hex -> display name
74 authorPics map[string]string // pubkey hex -> avatar URL
75 authorContent map[string]string // pubkey hex -> full kind 0 content JSON
76 authorTs map[string]int64 // pubkey hex -> created_at of cached kind 0
77 authorRelays map[string][]string // pubkey hex -> relay URLs from kind 10002
78 pendingNotes map[string][]dom.Element // pubkey hex -> author header divs awaiting profile
79 fetchedK0 map[string]bool // pubkey hex -> already tried kind 0 fetch
80 fetchedK10k map[string]bool // pubkey hex -> already tried kind 10002 fetch
81 seenEvents map[string]bool // event ID -> already rendered
82 authorSubPK map[string]string // subID -> pubkey hex for author profile subs
83
84 // Relay frequency — how many kind 10002 lists include each relay URL.
85 relayFreq map[string]int
86 idbLoaded bool
87 retryRound int // metadata retry round counter
88 retryTimer int // debounce timer for batch retries
89 resubTimer int // debounce timer for relay list → resubscribe
90 fetchQueue []string // pubkeys queued for batch profile fetch
91 fetchTimer int // debounce timer for fetch queue
92
93 // QR modal.
94 logoSVGCache string
95
96 // Profile page tab state.
97 profileTab string
98 profileTabContent dom.Element
99 profileTabBtns map[string]dom.Element
100 authorFollows map[string][]string
101 authorMutes map[string][]string
102 profileNotesSeen map[string]bool
103 activeProfileNoteSub string
104
105 // History/routing.
106 navPop bool // true during popstate — suppresses pushState
107 routerInited bool // guard against duplicate listener registration
108
109 // Embedded nostr: entity resolution.
110 embedCounter int
111 embedCallbacks map[string][]string // hex event ID -> DOM element IDs awaiting fill
112 embedRelayHints map[string][]string // hex event ID -> relay hints from nevent TLV
113
114 // Thread view state.
115 threadPage dom.Element
116 threadContainer dom.Element
117 threadRootID string
118 threadFocusID string
119 threadEvents map[string]*nostr.Event
120 threadSubCount int
121 threadOpen bool
122 threadPushedState bool // true if we pushed history for this thread
123 threadRenderTimer int
124 threadGen int // generation counter — reject events from old threads
125 threadActiveSubs []string // sub IDs to close when opening a new thread
126 threadLastRendered int // event count at last render — skip if unchanged
127 contentArea dom.Element // scroll container
128 savedScrollTop string // feed scroll position before thread open
129
130 // Reply preview state.
131 replyCache map[string]string // event ID -> "name: first line"
132 replyAvatarCache map[string]string // event ID -> avatar URL of parent author
133 replyLineCache map[string]string // event ID -> raw first line (no name)
134 replyAuthorMap map[string]string // event ID -> author pubkey hex
135 replyPending map[string][]dom.Element // event ID -> preview divs awaiting fetch
136 replyNeedName map[string][]dom.Element // event ID -> preview divs needing name update
137 replyQueue []string // event IDs to batch-fetch
138 replyTimer int
139 )
140
141 const orlyRelay = "wss://relay.orly.dev"
142
143 var defaultRelays = []string{
144 orlyRelay,
145 "wss://nostr.wine",
146 "wss://nostr.land",
147 }
148
149 func isLocalDev() bool {
150 h := dom.Hostname()
151 return h == "localhost" || (len(h) > 4 && h[:4] == "127.")
152 }
153
154 func localRelayURL() string {
155 h := dom.Hostname()
156 p := dom.Port()
157 if p == "" || p == "443" || p == "80" {
158 return "wss://" + h
159 }
160 return "ws://" + h + ":" + p
161 }
162
163 func main() {
164 dom.ConsoleLog("starting smesh " + version)
165 initLang()
166 // Always add the host relay as first fallback — it's the smesh relay serving this page.
167 localRelay := localRelayURL()
168 defaultRelays = append([]string{localRelay}, defaultRelays...)
169 dom.ConsoleLog("local relay: " + localRelay)
170 themePref := localstorage.GetItem(lsKeyTheme)
171 if themePref != "" {
172 isDark = themePref == "dark"
173 } else {
174 isDark = dom.PrefersDark()
175 }
176 htmlEl := dom.QuerySelector("html")
177 if isDark {
178 dom.AddClass(htmlEl, "dark")
179 }
180 root = dom.GetElementById("app-root")
181 dom.SetAttribute(root, "data-version", version)
182 stored := localstorage.GetItem(lsKeyPubkey)
183 if stored != "" {
184 pubhex = stored
185 pubkey = helpers.HexDecode(stored)
186 showApp()
187 } else {
188 showLogin()
189 }
190 }
191
192 // --- Theme ---
193
194 func toggleTheme() {
195 el := dom.QuerySelector("html")
196 isDark = !isDark
197 if isDark {
198 dom.AddClass(el, "dark")
199 localstorage.SetItem(lsKeyTheme, "dark")
200 } else {
201 dom.RemoveClass(el, "dark")
202 localstorage.SetItem(lsKeyTheme, "light")
203 }
204 updateThemeIcon()
205 }
206
207 const svgSun = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><circle cx="9" cy="9" r="4" stroke="currentColor" stroke-width="1.5"/><path d="M9 1v2M9 15v2M1 9h2M15 9h2M3.3 3.3l1.4 1.4M13.3 13.3l1.4 1.4M3.3 14.7l1.4-1.4M13.3 4.7l1.4-1.4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`
208 const svgMoon = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M15.1 10.4A6.5 6.5 0 0 1 7.6 2.9 6.5 6.5 0 1 0 15.1 10.4z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`
209
210 func updateThemeIcon() {
211 if themeBtn == 0 {
212 return // not yet created (login screen)
213 }
214 if isDark {
215 dom.SetInnerHTML(themeBtn, svgSun)
216 } else {
217 dom.SetInnerHTML(themeBtn, svgMoon)
218 }
219 }
220
221 // --- Login screen ---
222
223 func isFirefox() bool {
224 ua := dom.UserAgent()
225 for i := 0; i+8 <= len(ua); i++ {
226 if ua[i:i+8] == "Firefox/" {
227 return true
228 }
229 }
230 return false
231 }
232
233 func isMobile() bool {
234 ua := dom.UserAgent()
235 for i := 0; i+6 <= len(ua); i++ {
236 if ua[i:i+6] == "Mobile" || ua[i:i+7] == "Android" {
237 return true
238 }
239 }
240 return false
241 }
242
243 func showAboutModal() {
244 backdrop := dom.CreateElement("div")
245 dom.SetAttribute(backdrop, "class", "signer-backdrop")
246
247 card := dom.CreateElement("div")
248 dom.SetAttribute(card, "class", "signer-card")
249 dom.SetStyle(card, "width", "420px")
250 dom.SetStyle(card, "padding", "24px")
251 dom.SetStyle(card, "textAlign", "center")
252
253 title := dom.CreateElement("h2")
254 dom.SetTextContent(title, "S.M.E.S.H. "+version)
255 dom.SetStyle(title, "fontSize", "20px")
256 dom.SetStyle(title, "marginBottom", "16px")
257 dom.SetStyle(title, "color", "var(--accent)")
258 dom.AppendChild(card, title)
259
260 devLabel := dom.CreateElement("p")
261 dom.SetTextContent(devLabel, t("developed_by"))
262 dom.SetStyle(devLabel, "fontSize", "12px")
263 dom.SetStyle(devLabel, "color", "var(--muted)")
264 dom.SetStyle(devLabel, "marginBottom", "4px")
265 dom.AppendChild(card, devLabel)
266
267 npubText := "npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku"
268 npubEl := dom.CreateElement("p")
269 dom.SetStyle(npubEl, "fontSize", "11px")
270 dom.SetStyle(npubEl, "color", "var(--fg)")
271 dom.SetStyle(npubEl, "wordBreak", "break-all")
272 dom.SetStyle(npubEl, "marginBottom", "20px")
273 dom.SetStyle(npubEl, "fontFamily", "'Fira Code', monospace")
274 dom.SetTextContent(npubEl, npubText)
275 dom.AppendChild(card, npubEl)
276
277 tagline := dom.CreateElement("p")
278 dom.SetStyle(tagline, "fontSize", "16px")
279 dom.SetStyle(tagline, "fontWeight", "bold")
280 dom.SetStyle(tagline, "color", "var(--fg)")
281 dom.SetStyle(tagline, "marginBottom", "12px")
282 dom.SetTextContent(tagline, t("tagline"))
283 dom.AppendChild(card, tagline)
284
285 funny := dom.CreateElement("p")
286 dom.SetStyle(funny, "fontSize", "13px")
287 dom.SetStyle(funny, "color", "var(--fg)")
288 dom.SetStyle(funny, "marginBottom", "20px")
289 dom.SetStyle(funny, "lineHeight", "1.6")
290 dom.SetTextContent(funny, t("about_donkey"))
291 dom.AppendChild(card, funny)
292
293 lud := dom.CreateElement("p")
294 dom.SetStyle(lud, "fontSize", "13px")
295 dom.SetStyle(lud, "color", "var(--accent)")
296 dom.SetTextContent(lud, "\xe2\x9a\xa1 mlekudev@getalby.com")
297 dom.AppendChild(card, lud)
298
299 dom.AddSelfEventListener(backdrop, "click", dom.RegisterCallback(func() {
300 dom.RemoveChild(dom.Body(), backdrop)
301 }))
302
303 dom.AppendChild(backdrop, card)
304 dom.AppendChild(dom.Body(), backdrop)
305 }
306
307 func showLogin() {
308 clearChildren(root)
309
310 wrap := dom.CreateElement("div")
311 dom.SetStyle(wrap, "display", "flex")
312 dom.SetStyle(wrap, "alignItems", "center")
313 dom.SetStyle(wrap, "justifyContent", "center")
314 dom.SetStyle(wrap, "height", "100vh")
315 dom.SetStyle(wrap, "flexDirection", "column")
316
317 // Smesh logo.
318 logoDiv := dom.CreateElement("div")
319 dom.SetStyle(logoDiv, "width", "180px")
320 dom.SetStyle(logoDiv, "height", "180px")
321 dom.SetStyle(logoDiv, "marginBottom", "16px")
322 dom.SetStyle(logoDiv, "color", "var(--accent)")
323 dom.FetchText("./smesh-logo.svg", func(svg string) {
324 dom.SetInnerHTML(logoDiv, svg)
325 svgEl := dom.FirstChild(logoDiv)
326 if svgEl != 0 {
327 dom.SetAttribute(svgEl, "width", "100%")
328 dom.SetAttribute(svgEl, "height", "100%")
329 }
330 })
331 dom.AppendChild(wrap, logoDiv)
332
333 // Title.
334 h1 := dom.CreateElement("h1")
335 dom.SetTextContent(h1, "S.M.E.S.H.")
336 dom.SetStyle(h1, "color", "var(--accent)")
337 dom.SetStyle(h1, "fontSize", "48px")
338 dom.SetStyle(h1, "marginBottom", "4px")
339 dom.AppendChild(wrap, h1)
340
341 verTag := dom.CreateElement("span")
342 dom.SetTextContent(verTag, version)
343 dom.SetStyle(verTag, "color", "var(--muted)")
344 dom.SetStyle(verTag, "fontSize", "12px")
345 dom.AppendChild(wrap, verTag)
346
347 sub := dom.CreateElement("p")
348 dom.SetStyle(sub, "color", "var(--muted)")
349 dom.SetStyle(sub, "fontSize", "14px")
350 dom.SetStyle(sub, "marginBottom", "32px")
351 dom.SetTextContent(sub, t("subtitle"))
352 dom.AppendChild(wrap, sub)
353
354 if !isFirefox() {
355 notice := dom.CreateElement("div")
356 dom.SetStyle(notice, "maxWidth", "400px")
357 dom.SetStyle(notice, "textAlign", "center")
358 dom.SetStyle(notice, "padding", "16px 24px")
359 dom.SetStyle(notice, "border", "1px solid var(--border)")
360 dom.SetStyle(notice, "borderRadius", "8px")
361 dom.SetStyle(notice, "background", "var(--bg2)")
362
363 n1 := dom.CreateElement("p")
364 dom.SetStyle(n1, "fontSize", "14px")
365 dom.SetStyle(n1, "marginBottom", "12px")
366 dom.SetStyle(n1, "color", "var(--fg)")
367 if isMobile() {
368 dom.SetTextContent(n1, t("req_fennec"))
369 } else {
370 dom.SetTextContent(n1, t("req_librewolf"))
371 }
372 dom.AppendChild(notice, n1)
373
374 dlLink := dom.CreateElement("a")
375 dom.SetAttribute(dlLink, "target", "_blank")
376 dom.SetStyle(dlLink, "color", "var(--accent)")
377 dom.SetStyle(dlLink, "fontSize", "14px")
378 dom.SetStyle(dlLink, "display", "block")
379 dom.SetStyle(dlLink, "marginBottom", "16px")
380 if isMobile() {
381 dom.SetAttribute(dlLink, "href", "https://f-droid.org/packages/org.mozilla.fennec_fdroid/")
382 dom.SetTextContent(dlLink, t("get_fennec"))
383 } else {
384 dom.SetAttribute(dlLink, "href", "https://librewolf.net/installation/")
385 dom.SetTextContent(dlLink, t("dl_librewolf"))
386 }
387 dom.AppendChild(notice, dlLink)
388
389 why := dom.CreateElement("p")
390 dom.SetStyle(why, "fontSize", "11px")
391 dom.SetStyle(why, "color", "var(--muted)")
392 dom.SetStyle(why, "lineHeight", "1.5")
393 dom.SetTextContent(why, t("chrome_why"))
394 dom.AppendChild(notice, why)
395
396 dom.AppendChild(wrap, notice)
397 appendLoginFooter(wrap)
398 dom.AppendChild(root, wrap)
399 return
400 }
401
402 // Firefox — show login flow.
403 errEl := dom.CreateElement("div")
404 dom.SetStyle(errEl, "color", "#e55")
405 dom.SetStyle(errEl, "fontSize", "13px")
406 dom.SetStyle(errEl, "marginBottom", "12px")
407 dom.SetStyle(errEl, "minHeight", "18px")
408 dom.AppendChild(wrap, errEl)
409
410 btn := dom.CreateElement("button")
411 dom.SetTextContent(btn, t("login"))
412 dom.SetAttribute(btn, "type", "button")
413 dom.SetStyle(btn, "padding", "10px 32px")
414 dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
415 dom.SetStyle(btn, "fontSize", "14px")
416 dom.SetStyle(btn, "background", "var(--accent)")
417 dom.SetStyle(btn, "color", "#fff")
418 dom.SetStyle(btn, "border", "none")
419 dom.SetStyle(btn, "borderRadius", "4px")
420 dom.SetStyle(btn, "cursor", "pointer")
421 dom.AppendChild(wrap, btn)
422
423 // Only show install link if signer extension is not detected.
424 signer.IsInstalled(func(ok bool) {
425 if !ok {
426 xpiLink := dom.CreateElement("a")
427 dom.SetAttribute(xpiLink, "href", "/smesh-signer.xpi")
428 dom.SetStyle(xpiLink, "display", "block")
429 dom.SetStyle(xpiLink, "marginTop", "12px")
430 dom.SetStyle(xpiLink, "color", "var(--accent)")
431 dom.SetStyle(xpiLink, "fontSize", "13px")
432 dom.SetTextContent(xpiLink, t("install_signer"))
433 dom.AppendChild(wrap, xpiLink)
434 }
435 })
436
437 appendLoginFooter(wrap)
438 dom.AppendChild(root, wrap)
439
440 cb := dom.RegisterCallback(func() {
441 if !signer.HasSigner() {
442 dom.SetTextContent(errEl, t("err_no_ext"))
443 return
444 }
445 signer.GetVaultStatus(func(status string) {
446 if status == "none" || status == "locked" {
447 showSignerModal()
448 return
449 }
450 dom.SetTextContent(btn, t("requesting"))
451 signer.GetPublicKey(func(hex string) {
452 if hex == "" {
453 dom.SetTextContent(errEl, t("err_no_id"))
454 dom.SetTextContent(btn, t("login"))
455 showSignerModal()
456 return
457 }
458 pubhex = hex
459 pubkey = helpers.HexDecode(hex)
460 localstorage.SetItem(lsKeyPubkey, pubhex)
461 clearChildren(root)
462 showApp()
463 })
464 })
465 })
466 dom.AddEventListener(btn, "click", cb)
467 }
468
469 func appendLoginFooter(wrap dom.Element) {
470 footer := dom.CreateElement("div")
471 dom.SetStyle(footer, "display", "flex")
472 dom.SetStyle(footer, "gap", "16px")
473 dom.SetStyle(footer, "marginTop", "24px")
474 dom.SetStyle(footer, "alignItems", "center")
475
476 // Language chooser.
477 langBox := dom.CreateElement("div")
478 dom.SetStyle(langBox, "display", "flex")
479 dom.SetStyle(langBox, "alignItems", "center")
480 dom.SetStyle(langBox, "gap", "6px")
481
482 langLabel := dom.CreateElement("span")
483 dom.SetTextContent(langLabel, t("language"))
484 dom.SetStyle(langLabel, "fontSize", "12px")
485 dom.SetStyle(langLabel, "color", "var(--muted)")
486 dom.AppendChild(langBox, langLabel)
487
488 langSel := dom.CreateElement("select")
489 dom.SetStyle(langSel, "fontFamily", "'Fira Code', monospace")
490 dom.SetStyle(langSel, "fontSize", "12px")
491 dom.SetStyle(langSel, "background", "var(--bg2)")
492 dom.SetStyle(langSel, "color", "var(--fg)")
493 dom.SetStyle(langSel, "border", "1px solid var(--border)")
494 dom.SetStyle(langSel, "borderRadius", "4px")
495 dom.SetStyle(langSel, "padding", "4px 8px")
496 for code, name := range langNames {
497 opt := dom.CreateElement("option")
498 dom.SetAttribute(opt, "value", code)
499 dom.SetTextContent(opt, name)
500 if code == currentLang {
501 dom.SetAttribute(opt, "selected", "selected")
502 }
503 dom.AppendChild(langSel, opt)
504 }
505 dom.AddEventListener(langSel, "change", dom.RegisterCallback(func() {
506 val := dom.GetProperty(langSel, "value")
507 setLang(val)
508 showLogin() // re-render with new language
509 }))
510 dom.AppendChild(langBox, langSel)
511 dom.AppendChild(footer, langBox)
512
513 // Theme toggle.
514 themeBox := dom.CreateElement("div")
515 dom.SetStyle(themeBox, "display", "flex")
516 dom.SetStyle(themeBox, "alignItems", "center")
517 dom.SetStyle(themeBox, "gap", "6px")
518
519 themeLabel := dom.CreateElement("span")
520 dom.SetTextContent(themeLabel, t("theme"))
521 dom.SetStyle(themeLabel, "fontSize", "12px")
522 dom.SetStyle(themeLabel, "color", "var(--muted)")
523 dom.AppendChild(themeBox, themeLabel)
524
525 themeToggle := dom.CreateElement("button")
526 if isDark {
527 dom.SetTextContent(themeToggle, t("light"))
528 } else {
529 dom.SetTextContent(themeToggle, t("dark"))
530 }
531 dom.SetStyle(themeToggle, "fontFamily", "'Fira Code', monospace")
532 dom.SetStyle(themeToggle, "fontSize", "12px")
533 dom.SetStyle(themeToggle, "background", "var(--bg2)")
534 dom.SetStyle(themeToggle, "color", "var(--fg)")
535 dom.SetStyle(themeToggle, "border", "1px solid var(--border)")
536 dom.SetStyle(themeToggle, "borderRadius", "4px")
537 dom.SetStyle(themeToggle, "padding", "4px 12px")
538 dom.SetStyle(themeToggle, "cursor", "pointer")
539 dom.AddEventListener(themeToggle, "click", dom.RegisterCallback(func() {
540 toggleTheme()
541 if isDark {
542 dom.SetTextContent(themeToggle, t("light"))
543 } else {
544 dom.SetTextContent(themeToggle, t("dark"))
545 }
546 }))
547 dom.AppendChild(themeBox, themeToggle)
548 dom.AppendChild(footer, themeBox)
549
550 dom.AppendChild(wrap, footer)
551
552 // GeoIP auto-detection — only if user hasn't set a language preference.
553 saved := localstorage.GetItem(lsKeyLang)
554 if saved == "" {
555 dom.FetchText("https://ipapi.co/json/", func(body string) {
556 if body == "" {
557 return
558 }
559 cc := helpers.JsonGetString(body, "country_code")
560 if cc == "" {
561 return
562 }
563 detected := countryToLang(cc)
564 if detected != currentLang {
565 setLang(detected)
566 showLogin()
567 }
568 })
569 }
570 }
571
572 // --- Sidebar ---
573
574 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>`
575 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>`
576 const svgGear = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M7.5 2h3l.4 2.1a5.5 5.5 0 0 1 1.3.7L14.3 4l1.5 2.6-1.7 1.4a5.6 5.6 0 0 1 0 1.5l1.7 1.4-1.5 2.6-2.1-.8a5.5 5.5 0 0 1-1.3.7L10.5 16h-3l-.4-2.1a5.5 5.5 0 0 1-1.3-.7L3.7 14l-1.5-2.6 1.7-1.4a5.6 5.6 0 0 1 0-1.5L2.2 7.1 3.7 4.5l2.1.8a5.5 5.5 0 0 1 1.3-.7L7.5 2z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><circle cx="9" cy="9.3" r="2" stroke="currentColor" stroke-width="1.3"/></svg>`
577
578 func makeSidebarIcon(svgHTML string, active bool) dom.Element {
579 btn := dom.CreateElement("button")
580 dom.SetStyle(btn, "width", "36px")
581 dom.SetStyle(btn, "height", "36px")
582 dom.SetStyle(btn, "border", "none")
583 dom.SetStyle(btn, "borderRadius", "6px")
584 dom.SetStyle(btn, "cursor", "pointer")
585 dom.SetStyle(btn, "display", "flex")
586 dom.SetStyle(btn, "alignItems", "center")
587 dom.SetStyle(btn, "justifyContent", "center")
588 dom.SetStyle(btn, "padding", "0")
589 dom.SetStyle(btn, "color", "var(--fg)")
590 if active {
591 dom.SetStyle(btn, "background", "var(--accent)")
592 dom.SetStyle(btn, "color", "#fff")
593 } else {
594 dom.SetStyle(btn, "background", "transparent")
595 }
596 dom.SetInnerHTML(btn, svgHTML)
597 return btn
598 }
599
600 func switchPage(name string) {
601 // Always close thread if open, even when re-selecting the feed tab.
602 if threadOpen {
603 closeNoteThread()
604 dom.ReplaceState("/")
605 }
606 if name == activePage {
607 return
608 }
609 closeProfileNoteSub()
610 if activePage == "profile" {
611 profileViewPK = ""
612 }
613
614 activePage = name
615 dom.SetTextContent(pageTitleEl, t(name))
616
617 // Ensure top bar is in normal state.
618 dom.SetStyle(topBackBtn, "display", "none")
619 dom.SetStyle(pageTitleEl, "display", "inline")
620
621 // Hide all pages.
622 dom.SetStyle(feedPage, "display", "none")
623 dom.SetStyle(msgPage, "display", "none")
624 dom.SetStyle(profilePage, "display", "none")
625 dom.SetStyle(settingsPage, "display", "none")
626
627 // Clear sidebar highlights.
628 dom.SetStyle(sidebarFeed, "background", "transparent")
629 dom.SetStyle(sidebarFeed, "color", "var(--fg)")
630 dom.SetStyle(sidebarMsg, "background", "transparent")
631 dom.SetStyle(sidebarMsg, "color", "var(--fg)")
632 dom.SetStyle(sidebarSettings, "background", "transparent")
633 dom.SetStyle(sidebarSettings, "color", "var(--fg)")
634
635 switch name {
636 case "feed":
637 dom.SetStyle(feedPage, "display", "block")
638 dom.SetStyle(sidebarFeed, "background", "var(--accent)")
639 dom.SetStyle(sidebarFeed, "color", "#fff")
640 if !navPop {
641 dom.PushState("/")
642 }
643 case "messaging":
644 dom.SetStyle(msgPage, "display", "block")
645 dom.SetStyle(sidebarMsg, "background", "var(--accent)")
646 dom.SetStyle(sidebarMsg, "color", "#fff")
647 dom.PostToSW("[\"PAGE\",\"messaging\"]")
648 initMessaging()
649 if !navPop {
650 dom.PushState("/msg")
651 }
652 case "settings":
653 dom.SetStyle(settingsPage, "display", "block")
654 dom.SetStyle(sidebarSettings, "background", "var(--accent)")
655 dom.SetStyle(sidebarSettings, "color", "#fff")
656 renderSettings()
657 case "profile":
658 dom.SetStyle(profilePage, "display", "block")
659 // Profile URL is pushed by showProfile, not here.
660 }
661 }
662
663 // navigateToPath handles URL-based routing for back/forward and initial load.
664 // fullPath may include a hash fragment, e.g. "/p/npub1...#follows".
665 func navigateToPath(fullPath string) {
666 path := fullPath
667 hash := ""
668 for i := 0; i < len(fullPath); i++ {
669 if fullPath[i] == '#' {
670 path = fullPath[:i]
671 hash = fullPath[i+1:]
672 break
673 }
674 }
675
676 if path == "/" || path == "/feed" || path == "" {
677 if threadOpen {
678 closeNoteThread()
679 }
680 switchPage("feed")
681 } else if len(path) > 3 && path[:3] == "/t/" {
682 switchPage("feed")
683 // Thread URL: /t/<rootID>
684 rootID := path[3:]
685 if len(rootID) == 64 {
686 showNoteThread(rootID, rootID)
687 }
688 } else if path == "/msg" {
689 switchPage("messaging")
690 if msgView == "thread" {
691 closeThread()
692 }
693 } else if len(path) > 5 && path[:5] == "/msg/" {
694 pk := npubToHex(path[5:])
695 if pk != "" {
696 switchPage("messaging")
697 openThread(pk)
698 }
699 } else if len(path) > 3 && path[:3] == "/p/" {
700 pk := npubToHex(path[3:])
701 if pk != "" {
702 showProfile(pk)
703 if hash != "" {
704 selectProfileTab(hash, pk)
705 }
706 }
707 }
708 }
709
710 func npubToHex(npub string) string {
711 b := helpers.DecodeNpub(npub)
712 if b == nil {
713 return ""
714 }
715 return helpers.HexEncode(b)
716 }
717
718 func initRouter() {
719 if routerInited {
720 return
721 }
722 routerInited = true
723 dom.OnPopState(func(path string) {
724 navPop = true
725 navigateToPath(path)
726 navPop = false
727 })
728
729 // Navigate to initial URL if not root.
730 // Thread URLs (/t/) are ephemeral — refresh always returns to feed.
731 path := dom.GetPath()
732 if len(path) > 3 && path[:3] == "/t/" {
733 dom.ReplaceState("/")
734 } else if path != "/" && path != "" {
735 navPop = true
736 navigateToPath(path)
737 navPop = false
738 } else {
739 dom.ReplaceState("/")
740 }
741 }
742
743 func makeProtoBtn(label string) dom.Element {
744 btn := dom.CreateElement("button")
745 dom.SetTextContent(btn, label)
746 dom.SetStyle(btn, "padding", "6px 16px")
747 dom.SetStyle(btn, "border", "none")
748 dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
749 dom.SetStyle(btn, "fontSize", "12px")
750 dom.SetStyle(btn, "cursor", "default")
751 dom.SetStyle(btn, "background", "transparent")
752 dom.SetStyle(btn, "color", "var(--fg)")
753 return btn
754 }
755
756 // --- Main app ---
757
758 func showApp() {
759 // Init profile cache maps.
760 authorNames = map[string]string{}
761 authorPics = map[string]string{}
762 authorContent = map[string]string{}
763 authorTs = map[string]int64{}
764 authorRelays = map[string][]string{}
765 pendingNotes = map[string][]dom.Element{}
766 fetchedK0 = map[string]bool{}
767 fetchedK10k = map[string]bool{}
768 relayFreq = map[string]int{}
769 authorSubPK = map[string]string{}
770 seenEvents = map[string]bool{}
771 authorFollows = map[string][]string{}
772 authorMutes = map[string][]string{}
773 profileNotesSeen = map[string]bool{}
774 profileTabBtns = map[string]dom.Element{}
775 embedCallbacks = map[string][]string{}
776 embedRelayHints = map[string][]string{}
777 threadEvents = map[string]*nostr.Event{}
778 replyCache = map[string]string{}
779 replyAvatarCache = map[string]string{}
780 replyLineCache = map[string]string{}
781 replyAuthorMap = map[string]string{}
782 replyPending = map[string][]dom.Element{}
783 replyNeedName = map[string][]dom.Element{}
784
785 // Set up SW communication (only register once — survives DOM rebuilds).
786 if !routerInited {
787 dom.OnSWMessage(onSWMessage)
788 }
789 dom.PostToSW("[\"SET_PUBKEY\"," + jstr(pubhex) + "]")
790
791 // Load cached profiles from IndexedDB.
792 dom.IDBGetAll("profiles", func(key, val string) {
793 name := helpers.JsonGetString(val, "name")
794 pic := helpers.JsonGetString(val, "picture")
795 if name != "" {
796 authorNames[key] = name
797 }
798 if pic != "" {
799 authorPics[key] = pic
800 }
801 }, func() {
802 idbLoaded = true
803 // Update note headers rendered before IDB finished loading.
804 for pk, headers := range pendingNotes {
805 name := authorNames[pk]
806 pic := authorPics[pk]
807 if name != "" {
808 for _, h := range headers {
809 updateNoteHeader(h, name, pic)
810 }
811 delete(pendingNotes, pk)
812 fetchedK0[pk] = true // don't fetch, already cached
813 }
814 }
815 })
816
817 // === Top bar ===
818 bar := dom.CreateElement("div")
819 dom.SetStyle(bar, "display", "flex")
820 dom.SetStyle(bar, "alignItems", "center")
821 dom.SetStyle(bar, "padding", "8px 16px")
822 dom.SetStyle(bar, "height", "48px")
823 dom.SetStyle(bar, "boxSizing", "border-box")
824 dom.SetStyle(bar, "background", "var(--bg2)")
825 dom.SetStyle(bar, "position", "fixed")
826 dom.SetStyle(bar, "top", "0")
827 dom.SetStyle(bar, "left", "0")
828 dom.SetStyle(bar, "right", "0")
829 dom.SetStyle(bar, "zIndex", "100")
830
831 // Left: page title.
832 left := dom.CreateElement("div")
833 dom.SetStyle(left, "display", "flex")
834 dom.SetStyle(left, "alignItems", "center")
835 dom.SetStyle(left, "flex", "1")
836 dom.SetStyle(left, "minWidth", "0")
837
838 topBackBtn = dom.CreateElement("span")
839 dom.SetStyle(topBackBtn, "display", "none")
840 dom.SetStyle(topBackBtn, "cursor", "pointer")
841 dom.SetStyle(topBackBtn, "color", "var(--accent)")
842 dom.SetStyle(topBackBtn, "fontSize", "18px")
843 dom.SetStyle(topBackBtn, "fontWeight", "bold")
844 dom.SetTextContent(topBackBtn, t("back"))
845 dom.AddEventListener(topBackBtn, "click", dom.RegisterCallback(func() {
846 closeNoteThread()
847 activePage = "" // force switchPage to run even if already on feed
848 switchPage("feed")
849 }))
850 dom.AppendChild(left, topBackBtn)
851
852 pageTitleEl = dom.CreateElement("span")
853 dom.SetStyle(pageTitleEl, "fontSize", "18px")
854 dom.SetStyle(pageTitleEl, "fontWeight", "bold")
855 dom.SetTextContent(pageTitleEl, t("feed"))
856 dom.AppendChild(left, pageTitleEl)
857 dom.AppendChild(bar, left)
858
859 // Center: smesh logo.
860 logo := dom.CreateElement("div")
861 dom.SetStyle(logo, "width", "32px")
862 dom.SetStyle(logo, "height", "32px")
863 dom.SetStyle(logo, "flexShrink", "0")
864 dom.SetStyle(logo, "color", "var(--accent)")
865 dom.FetchText("./smesh-logo.svg", func(svg string) {
866 logoSVGCache = svg
867 dom.SetInnerHTML(logo, svg)
868 svgEl := dom.FirstChild(logo)
869 if svgEl != 0 {
870 dom.SetAttribute(svgEl, "width", "100%")
871 dom.SetAttribute(svgEl, "height", "100%")
872 }
873 })
874 dom.AppendChild(bar, logo)
875
876 // Right: theme toggle + logout.
877 right := dom.CreateElement("div")
878 dom.SetStyle(right, "display", "flex")
879 dom.SetStyle(right, "alignItems", "center")
880 dom.SetStyle(right, "gap", "8px")
881 dom.SetStyle(right, "flex", "1")
882 dom.SetStyle(right, "justifyContent", "flex-end")
883
884 themeBtn = dom.CreateElement("button")
885 dom.SetStyle(themeBtn, "background", "transparent")
886 dom.SetStyle(themeBtn, "border", "none")
887 dom.SetStyle(themeBtn, "width", "32px")
888 dom.SetStyle(themeBtn, "height", "32px")
889 dom.SetStyle(themeBtn, "cursor", "pointer")
890 dom.SetStyle(themeBtn, "padding", "0")
891 dom.SetStyle(themeBtn, "display", "flex")
892 dom.SetStyle(themeBtn, "alignItems", "center")
893 dom.SetStyle(themeBtn, "justifyContent", "center")
894 dom.SetStyle(themeBtn, "color", "var(--muted)")
895 updateThemeIcon()
896 dom.AddEventListener(themeBtn, "click", dom.RegisterCallback(func() {
897 toggleTheme()
898 }))
899 dom.AppendChild(right, themeBtn)
900
901 logout := dom.CreateElement("button")
902 dom.SetInnerHTML(logout, `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M7 2H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h3M12 13l4-4-4-4M16 9H7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`)
903 dom.SetAttribute(logout, "title", "logout")
904 dom.SetStyle(logout, "background", "transparent")
905 dom.SetStyle(logout, "border", "none")
906 dom.SetStyle(logout, "color", "var(--muted)")
907 dom.SetStyle(logout, "height", "32px")
908 dom.SetStyle(logout, "width", "32px")
909 dom.SetStyle(logout, "display", "flex")
910 dom.SetStyle(logout, "alignItems", "center")
911 dom.SetStyle(logout, "justifyContent", "center")
912 dom.SetStyle(logout, "cursor", "pointer")
913 dom.AddEventListener(logout, "click", dom.RegisterCallback(func() {
914 doLogout()
915 }))
916 dom.AppendChild(right, logout)
917 dom.AppendChild(bar, right)
918
919 dom.AppendChild(root, bar)
920
921 // === Main layout: sidebar + content ===
922 mainLayout := dom.CreateElement("div")
923 dom.SetStyle(mainLayout, "position", "fixed")
924 dom.SetStyle(mainLayout, "top", "48px")
925 dom.SetStyle(mainLayout, "bottom", "36px")
926 dom.SetStyle(mainLayout, "left", "0")
927 dom.SetStyle(mainLayout, "right", "0")
928 dom.SetStyle(mainLayout, "display", "flex")
929
930 // Sidebar.
931 sidebar := dom.CreateElement("div")
932 dom.SetStyle(sidebar, "width", "44px")
933 dom.SetStyle(sidebar, "flexShrink", "0")
934 dom.SetStyle(sidebar, "background", "var(--bg2)")
935 dom.SetStyle(sidebar, "display", "flex")
936 dom.SetStyle(sidebar, "flexDirection", "column")
937 dom.SetStyle(sidebar, "alignItems", "center")
938 dom.SetStyle(sidebar, "paddingTop", "8px")
939 dom.SetStyle(sidebar, "gap", "4px")
940
941 sidebarFeed = makeSidebarIcon(svgFeed, true)
942 dom.AddEventListener(sidebarFeed, "click", dom.RegisterCallback(func() {
943 switchPage("feed")
944 }))
945 dom.AppendChild(sidebar, sidebarFeed)
946
947 sidebarMsg = makeSidebarIcon(svgChat, false)
948 dom.AddEventListener(sidebarMsg, "click", dom.RegisterCallback(func() {
949 switchPage("messaging")
950 }))
951 dom.AppendChild(sidebar, sidebarMsg)
952
953 sidebarSettings = makeSidebarIcon(svgGear, false)
954 dom.AddEventListener(sidebarSettings, "click", dom.RegisterCallback(func() {
955 switchPage("settings")
956 }))
957 dom.AppendChild(sidebar, sidebarSettings)
958
959 dom.AppendChild(mainLayout, sidebar)
960
961 // Content area.
962 contentArea = dom.CreateElement("div")
963 dom.SetStyle(contentArea, "flex", "1")
964 dom.SetStyle(contentArea, "overflowY", "auto")
965
966 // Feed page.
967 feedPage = dom.CreateElement("div")
968 dom.SetStyle(feedPage, "padding", "16px")
969
970 // Loading spinner — shown until first feed event arrives.
971 feedLoader = dom.CreateElement("div")
972 dom.SetStyle(feedLoader, "display", "flex")
973 dom.SetStyle(feedLoader, "flexDirection", "column")
974 dom.SetStyle(feedLoader, "alignItems", "center")
975 dom.SetStyle(feedLoader, "justifyContent", "center")
976 dom.SetStyle(feedLoader, "padding", "64px 0")
977 loaderImg := dom.CreateElement("div")
978 dom.SetStyle(loaderImg, "width", "120px")
979 dom.SetStyle(loaderImg, "height", "120px")
980 dom.FetchText("./smesh-loader.svg", func(svg string) {
981 dom.SetInnerHTML(loaderImg, svg)
982 svgEl := dom.FirstChild(loaderImg)
983 if svgEl != 0 {
984 dom.SetAttribute(svgEl, "width", "100%")
985 dom.SetAttribute(svgEl, "height", "100%")
986 }
987 })
988 dom.AppendChild(feedLoader, loaderImg)
989 loaderText := dom.CreateElement("div")
990 dom.SetTextContent(loaderText, t("connecting"))
991 dom.SetStyle(loaderText, "marginTop", "16px")
992 dom.SetStyle(loaderText, "color", "var(--muted)")
993 dom.SetStyle(loaderText, "fontSize", "14px")
994 dom.AppendChild(feedLoader, loaderText)
995 dom.AppendChild(feedPage, feedLoader)
996
997 feedContainer = dom.CreateElement("div")
998 dom.AppendChild(feedPage, feedContainer)
999
1000 // Thread view (hidden overlay within feedPage).
1001 threadPage = dom.CreateElement("div")
1002 dom.SetStyle(threadPage, "display", "none")
1003
1004 threadContainer = dom.CreateElement("div")
1005 dom.AppendChild(threadPage, threadContainer)
1006 dom.AppendChild(feedPage, threadPage)
1007
1008 dom.AppendChild(contentArea, feedPage)
1009
1010 // Infinite scroll — load older events when near bottom.
1011 dom.AddEventListener(contentArea, "scroll", dom.RegisterCallback(func() {
1012 if activePage != "feed" {
1013 return
1014 }
1015 if feedLoading || feedExhausted || oldestFeedTs == 0 {
1016 dom.ConsoleLog("[scroll] skip: loading=" + boolStr(feedLoading) +
1017 " exhausted=" + boolStr(feedExhausted) +
1018 " oldestTs=" + i64toa(oldestFeedTs))
1019 return
1020 }
1021 st := dom.GetProperty(contentArea, "scrollTop")
1022 ch := dom.GetProperty(contentArea, "clientHeight")
1023 sh := dom.GetProperty(contentArea, "scrollHeight")
1024 top := parseIntProp(st)
1025 height := parseIntProp(ch)
1026 total := parseIntProp(sh)
1027 if top+height >= total-400 {
1028 dom.ConsoleLog("[scroll] TRIGGER loadOlderFeed: top=" + itoa(top) +
1029 " height=" + itoa(height) + " total=" + itoa(total))
1030 loadOlderFeed()
1031 }
1032 }))
1033
1034 // Messaging page.
1035 msgPage = dom.CreateElement("div")
1036 dom.SetStyle(msgPage, "padding", "16px")
1037 dom.SetStyle(msgPage, "display", "none")
1038 dom.SetStyle(msgPage, "position", "relative")
1039 dom.SetStyle(msgPage, "height", "100%")
1040 dom.SetStyle(msgPage, "boxSizing", "border-box")
1041
1042 // Conversation list view.
1043 msgListContainer = dom.CreateElement("div")
1044 dom.AppendChild(msgPage, msgListContainer)
1045
1046 // Thread view (hidden by default).
1047 msgThreadContainer = dom.CreateElement("div")
1048 dom.SetStyle(msgThreadContainer, "display", "none")
1049 dom.SetStyle(msgThreadContainer, "flexDirection", "column")
1050 dom.SetStyle(msgThreadContainer, "position", "absolute")
1051 dom.SetStyle(msgThreadContainer, "top", "0")
1052 dom.SetStyle(msgThreadContainer, "left", "0")
1053 dom.SetStyle(msgThreadContainer, "right", "0")
1054 dom.SetStyle(msgThreadContainer, "bottom", "0")
1055 dom.SetStyle(msgThreadContainer, "background", "var(--bg)")
1056 dom.AppendChild(msgPage, msgThreadContainer)
1057
1058 msgView = "list"
1059
1060 dom.AppendChild(contentArea, msgPage)
1061
1062 // Profile page.
1063 profilePage = dom.CreateElement("div")
1064 dom.SetStyle(profilePage, "display", "none")
1065 dom.AppendChild(contentArea, profilePage)
1066
1067 // Settings page.
1068 settingsPage = dom.CreateElement("div")
1069 dom.SetStyle(settingsPage, "display", "none")
1070 dom.SetStyle(settingsPage, "padding", "16px")
1071 dom.AppendChild(contentArea, settingsPage)
1072
1073 dom.AppendChild(mainLayout, contentArea)
1074 dom.AppendChild(root, mainLayout)
1075 activePage = "feed"
1076
1077 // === Bottom status bar ===
1078 bottomBar := dom.CreateElement("div")
1079 dom.SetStyle(bottomBar, "position", "fixed")
1080 dom.SetStyle(bottomBar, "bottom", "0")
1081 dom.SetStyle(bottomBar, "left", "0")
1082 dom.SetStyle(bottomBar, "right", "0")
1083 dom.SetStyle(bottomBar, "height", "36px")
1084 dom.SetStyle(bottomBar, "display", "flex")
1085 dom.SetStyle(bottomBar, "alignItems", "center")
1086 dom.SetStyle(bottomBar, "padding", "0 12px")
1087 dom.SetStyle(bottomBar, "gap", "8px")
1088 dom.SetStyle(bottomBar, "background", "var(--bg2)")
1089 dom.SetStyle(bottomBar, "fontSize", "12px")
1090 dom.SetStyle(bottomBar, "color", "var(--fg)")
1091 dom.SetStyle(bottomBar, "zIndex", "100")
1092
1093 // Avatar + name in clickable box.
1094 userBtn := dom.CreateElement("div")
1095 dom.SetStyle(userBtn, "display", "flex")
1096 dom.SetStyle(userBtn, "alignItems", "center")
1097 dom.SetStyle(userBtn, "gap", "6px")
1098 dom.SetStyle(userBtn, "padding", "4px 10px")
1099 dom.SetStyle(userBtn, "border", "none")
1100 dom.SetStyle(userBtn, "borderRadius", "4px")
1101 dom.SetStyle(userBtn, "cursor", "pointer")
1102
1103 avatarEl = dom.CreateElement("img")
1104 dom.SetAttribute(avatarEl, "referrerpolicy", "no-referrer")
1105 dom.SetAttribute(avatarEl, "width", "20")
1106 dom.SetAttribute(avatarEl, "height", "20")
1107 dom.SetStyle(avatarEl, "borderRadius", "50%")
1108 dom.SetStyle(avatarEl, "objectFit", "cover")
1109 dom.SetStyle(avatarEl, "display", "none")
1110 dom.SetAttribute(avatarEl, "onerror", "this.style.display='none'")
1111 dom.AppendChild(userBtn, avatarEl)
1112
1113 nameEl = dom.CreateElement("span")
1114 dom.SetStyle(nameEl, "fontSize", "12px")
1115 dom.SetStyle(nameEl, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
1116 dom.SetStyle(nameEl, "fontWeight", "bold")
1117 dom.SetStyle(nameEl, "overflow", "hidden")
1118 dom.SetStyle(nameEl, "textOverflow", "ellipsis")
1119 dom.SetStyle(nameEl, "whiteSpace", "nowrap")
1120 dom.SetStyle(nameEl, "maxWidth", "120px")
1121 npubStr := helpers.EncodeNpub(pubkey)
1122 if len(npubStr) > 20 {
1123 dom.SetTextContent(nameEl, npubStr[:12]+"..."+npubStr[len(npubStr)-4:])
1124 }
1125 dom.AppendChild(userBtn, nameEl)
1126
1127 dom.AddEventListener(userBtn, "click", dom.RegisterCallback(func() {
1128 showProfile(pubhex)
1129 }))
1130 dom.AppendChild(bottomBar, userBtn)
1131
1132 sep := dom.CreateElement("span")
1133 dom.SetTextContent(sep, "|")
1134 dom.SetStyle(sep, "color", "var(--muted)")
1135 dom.AppendChild(bottomBar, sep)
1136
1137 statusEl = dom.CreateElement("span")
1138 dom.SetTextContent(statusEl, t("connecting"))
1139 dom.SetStyle(statusEl, "cursor", "pointer")
1140 dom.AppendChild(bottomBar, statusEl)
1141
1142 dom.AddEventListener(statusEl, "click", dom.RegisterCallback(func() {
1143 togglePopover()
1144 }))
1145
1146 ver := dom.CreateElement("span")
1147 dom.SetTextContent(ver, "S.M.E.S.H. "+version)
1148 dom.SetStyle(ver, "position", "absolute")
1149 dom.SetStyle(ver, "left", "50%")
1150 dom.SetStyle(ver, "transform", "translateX(-50%)")
1151 dom.SetStyle(ver, "color", "var(--accent)")
1152 dom.SetStyle(ver, "cursor", "pointer")
1153 dom.AddEventListener(ver, "click", dom.RegisterCallback(func() {
1154 showAboutModal()
1155 }))
1156 dom.AppendChild(bottomBar, ver)
1157
1158 signerBtn := dom.CreateElement("button")
1159 dom.SetTextContent(signerBtn, "signer")
1160 dom.SetStyle(signerBtn, "fontFamily", "'Fira Code', monospace")
1161 dom.SetStyle(signerBtn, "fontSize", "12px")
1162 dom.SetStyle(signerBtn, "background", "transparent")
1163 dom.SetStyle(signerBtn, "border", "none")
1164 dom.SetStyle(signerBtn, "color", "var(--muted)")
1165 dom.SetStyle(signerBtn, "cursor", "pointer")
1166 dom.SetStyle(signerBtn, "marginLeft", "auto")
1167 dom.AddEventListener(signerBtn, "click", dom.RegisterCallback(func() {
1168 showSignerModal()
1169 }))
1170 dom.AppendChild(bottomBar, signerBtn)
1171
1172 dom.AppendChild(root, bottomBar)
1173
1174 // === Relay popover (hidden) ===
1175 popoverEl = dom.CreateElement("div")
1176 dom.SetStyle(popoverEl, "position", "fixed")
1177 dom.SetStyle(popoverEl, "bottom", "37px")
1178 dom.SetStyle(popoverEl, "left", "44px")
1179 dom.SetStyle(popoverEl, "right", "0")
1180 dom.SetStyle(popoverEl, "background", "var(--bg2)")
1181 dom.SetStyle(popoverEl, "borderTop", "1px solid var(--border)")
1182 dom.SetStyle(popoverEl, "padding", "12px 16px")
1183 dom.SetStyle(popoverEl, "fontSize", "12px")
1184 dom.SetStyle(popoverEl, "display", "none")
1185 dom.SetStyle(popoverEl, "zIndex", "99")
1186 dom.AppendChild(root, popoverEl)
1187
1188 // Add default relays.
1189 for _, url := range defaultRelays {
1190 addRelay(url, false)
1191 }
1192
1193 // Tell SW about relays and subscribe.
1194 sendWriteRelays()
1195 subscribeProfile()
1196 subscribeFeed()
1197
1198 // Publish any kind 0 queued during identity derivation (relays are now live).
1199 flushPendingK0()
1200
1201 // Wire up browser history navigation.
1202 initRouter()
1203
1204 // Check for signer extension.
1205 initSigner()
1206 }
1207
1208 // addRelay adds a relay to the list and creates its popover row.
1209 // userPick=true means it came from the user's kind 10002 relay list.
1210 func addRelay(url string, userPick bool) {
1211 url = normalizeURL(url)
1212 // Dedup.
1213 for i, u := range relayURLs {
1214 if u == url {
1215 if userPick && !relayUserPick[i] {
1216 relayUserPick[i] = true
1217 dom.SetStyle(relayLabels[i], "fontWeight", "bold")
1218 }
1219 return
1220 }
1221 }
1222
1223 relayURLs = append(relayURLs, url)
1224 relayUserPick = append(relayUserPick, userPick)
1225
1226 // Popover row.
1227 row := dom.CreateElement("div")
1228 dom.SetStyle(row, "padding", "3px 0")
1229
1230 dot := dom.CreateElement("span")
1231 dom.SetTextContent(dot, "\u25CF")
1232 dom.SetStyle(dot, "color", "#5b5")
1233 dom.SetStyle(dot, "marginRight", "8px")
1234 relayDots = append(relayDots, dot)
1235 dom.AppendChild(row, dot)
1236
1237 label := dom.CreateElement("span")
1238 dom.SetTextContent(label, url)
1239 if userPick {
1240 dom.SetStyle(label, "fontWeight", "bold")
1241 }
1242 relayLabels = append(relayLabels, label)
1243 dom.AppendChild(row, label)
1244
1245 dom.AppendChild(popoverEl, row)
1246 updateStatus()
1247 }
1248
1249 func togglePopover() {
1250 popoverOpen = !popoverOpen
1251 if popoverOpen {
1252 dom.SetStyle(popoverEl, "display", "block")
1253 } else {
1254 dom.SetStyle(popoverEl, "display", "none")
1255 }
1256 }
1257
1258 func subscribeProfile() {
1259 proxy := []string{:len(discoveryRelays):len(discoveryRelays)+len(relayURLs)}
1260 copy(proxy, discoveryRelays)
1261 for _, u := range relayURLs {
1262 proxy = appendUnique(proxy, u)
1263 }
1264 dom.PostToSW(buildProxyMsg("prof",
1265 "{\"authors\":["+jstr(pubhex)+"],\"kinds\":[0,3,10002,10000,10050],\"limit\":8}",
1266 proxy))
1267 }
1268
1269 func subscribeFeed() {
1270 oldestFeedTs = 0
1271 feedExhausted = false
1272 feedEmptyStreak = 0
1273 dom.PostToSW(buildProxyMsg("feed", "{\"kinds\":[1],\"limit\":20}", relayURLs))
1274 }
1275
1276 var feedMoreGot int
1277
1278 func loadOlderFeed() {
1279 feedLoading = true
1280 feedMoreGot = 0
1281 filter := "{\"kinds\":[1],\"until\":" + i64toa(oldestFeedTs) + ",\"limit\":20}"
1282 dom.PostToSW(buildProxyMsg("feed-more", filter, relayURLs))
1283 }
1284
1285 func trackOldestTs(ev *nostr.Event) {
1286 if oldestFeedTs == 0 || ev.CreatedAt < oldestFeedTs {
1287 oldestFeedTs = ev.CreatedAt
1288 }
1289 }
1290
1291 func parseIntProp(s string) int {
1292 n := 0
1293 for i := 0; i < len(s); i++ {
1294 if s[i] >= '0' && s[i] <= '9' {
1295 n = n*10 + int(s[i]-'0')
1296 } else {
1297 break
1298 }
1299 }
1300 return n
1301 }
1302
1303 func boolStr(b bool) string {
1304 if b {
1305 return "true"
1306 }
1307 return "false"
1308 }
1309
1310 func i64toa(n int64) string {
1311 if n == 0 {
1312 return "0"
1313 }
1314 var buf [20]byte
1315 i := len(buf)
1316 for n > 0 {
1317 i--
1318 buf[i] = byte('0' + n%10)
1319 n /= 10
1320 }
1321 return string(buf[i:])
1322 }
1323
1324 func sendWriteRelays() {
1325 msg := "[\"SET_WRITE_RELAYS\",["
1326 for i, url := range relayURLs {
1327 if i > 0 {
1328 msg += ","
1329 }
1330 msg += jstr(url)
1331 }
1332 dom.PostToSW(msg + "]]")
1333 }
1334
1335 func buildProxyMsg(subID, filterJSON string, urls []string) string {
1336 msg := "[\"PROXY\"," + jstr(subID) + "," + filterJSON + ",["
1337 for i, url := range urls {
1338 if i > 0 {
1339 msg += ","
1340 }
1341 msg += jstr(url)
1342 }
1343 return msg + "]]"
1344 }
1345
1346 func jstr(s string) string {
1347 return "\"" + jsonEsc(s) + "\""
1348 }
1349
1350 // scheduleTabRetry schedules a retry for any pending profile fetches after
1351 // the follows/mutes tab renders. Independent of retryRound so it works even
1352 // after the feed's retry budget is exhausted.
1353 func scheduleTabRetry() {
1354 dom.SetTimeout(func() {
1355 var missing []string
1356 for pk := range pendingNotes {
1357 if _, ok := authorNames[pk]; !ok {
1358 missing = append(missing, pk)
1359 }
1360 }
1361 if len(missing) == 0 {
1362 return
1363 }
1364 for _, pk := range missing {
1365 fetchedK0[pk] = false
1366 }
1367 for _, pk := range missing {
1368 queueProfileFetch(pk)
1369 }
1370 }, 5000)
1371 }
1372
1373 // --- SW message handling ---
1374
1375 func onSWMessage(raw string) {
1376 if raw == "update-available" {
1377 dom.PostToSW("activate-update")
1378 return
1379 }
1380 if raw == "reload" {
1381 dom.LocationReload()
1382 return
1383 }
1384 if len(raw) < 5 || raw[0] != '[' {
1385 return
1386 }
1387 typ, pos := nextStr(raw, 1)
1388 switch typ {
1389 case "EVENT":
1390 subID, pos2 := nextStr(raw, pos)
1391 evJSON := extractValue(raw, pos2)
1392 if evJSON == "" {
1393 return
1394 }
1395 ev := nostr.ParseEvent(evJSON)
1396 if ev == nil {
1397 return
1398 }
1399 dispatchEvent(subID, ev)
1400 case "EOSE":
1401 subID, _ := nextStr(raw, pos)
1402 dispatchEOSE(subID)
1403 case "DM_LIST":
1404 listJSON := extractValue(raw, pos)
1405 renderConversationList(listJSON)
1406 case "DM_HISTORY":
1407 peer, pos2 := nextStr(raw, pos)
1408 msgsJSON := extractValue(raw, pos2)
1409 renderThreadMessages(peer, msgsJSON)
1410 case "DM_RECEIVED":
1411 dmJSON := extractValue(raw, pos)
1412 handleDMReceived(dmJSON)
1413 case "DM_SENT":
1414 tsStr := nextNum(raw, pos)
1415 var ts int64
1416 for i := 0; i < len(tsStr); i++ {
1417 if tsStr[i] >= '0' && tsStr[i] <= '9' {
1418 ts = ts*10 + int64(tsStr[i]-'0')
1419 }
1420 }
1421 if ts > 0 && len(pendingTsEls) > 0 {
1422 dom.SetTextContent(pendingTsEls[0], formatTime(ts))
1423 pendingTsEls = pendingTsEls[1:]
1424 }
1425 case "DM_HISTORY_CLEARED":
1426 // Messages already cleared optimistically on ratchet button click.
1427 peer, _ := nextStr(raw, pos)
1428 dom.ConsoleLog("[mls] history cleared for " + peer)
1429 case "MLS_GROUPS":
1430 // Store for future use.
1431 case "MLS_STATUS":
1432 text, _ := nextStr(raw, pos)
1433 dom.ConsoleLog("[mls] " + text)
1434 case "SW_LOG":
1435 origin, pos2 := nextStr(raw, pos)
1436 logMsg, _ := nextStr(raw, pos2)
1437 dom.ConsoleLog("[" + origin + "] " + logMsg)
1438 return
1439 case "CRYPTO_REQ":
1440 handleCryptoReq(raw, pos)
1441 case "NEED_IDENTITY":
1442 if pubhex != "" {
1443 dom.PostToSW("[\"SET_PUBKEY\"," + jstr(pubhex) + "]")
1444 }
1445 resubscribe()
1446 case "RESUB":
1447 resubscribe()
1448 }
1449 }
1450
1451 func resubscribe() {
1452 sendWriteRelays()
1453 subscribeProfile()
1454 subscribeFeed()
1455 if activePage == "messaging" {
1456 initMessaging()
1457 }
1458 }
1459
1460 func dispatchEvent(subID string, ev *nostr.Event) {
1461 if subID == "prof" {
1462 handleProfileEvent(ev)
1463 } else if subID == "feed" {
1464 if seenEvents[ev.ID] {
1465 return
1466 }
1467 seenEvents[ev.ID] = true
1468 eventCount++
1469 if feedLoader != 0 {
1470 dom.RemoveChild(feedPage, feedLoader)
1471 feedLoader = 0
1472 }
1473 trackOldestTs(ev)
1474 renderNote(ev)
1475 } else if subID == "feed-more" {
1476 if seenEvents[ev.ID] {
1477 return
1478 }
1479 seenEvents[ev.ID] = true
1480 eventCount++
1481 trackOldestTs(ev)
1482 feedMoreGot++
1483 appendNote(ev)
1484 } else if len(subID) > 3 && subID[:3] == "ap-" {
1485 if ev.Kind == 0 {
1486 applyAuthorProfile(ev.PubKey, ev)
1487 } else if ev.Kind == 3 {
1488 var pks []string
1489 for _, tag := range ev.Tags.GetAll("p") {
1490 if v := tag.Value(); v != "" {
1491 pks = append(pks, v)
1492 }
1493 }
1494 authorFollows[ev.PubKey] = pks
1495 refreshProfileTab(ev.PubKey)
1496 } else if ev.Kind == 10002 {
1497 recordRelayFreq(ev)
1498 } else if ev.Kind == 10000 {
1499 var pks []string
1500 for _, tag := range ev.Tags.GetAll("p") {
1501 if v := tag.Value(); v != "" {
1502 pks = append(pks, v)
1503 }
1504 }
1505 authorMutes[ev.PubKey] = pks
1506 refreshProfileTab(ev.PubKey)
1507 }
1508 } else if len(subID) > 3 && subID[:3] == "pn-" {
1509 if profileNotesSeen[ev.ID] {
1510 return
1511 }
1512 profileNotesSeen[ev.ID] = true
1513 // Dedup against the broad feed sub: relays send the same event on
1514 // every matching sub on a connection, so a profile-author's new
1515 // note also arrives on "feed". Marking it here prevents it from
1516 // being double-rendered into feedContainer later in the session.
1517 seenEvents[ev.ID] = true
1518 renderProfileNote(ev)
1519 } else if len(subID) > 4 && subID[:4] == "emb-" {
1520 fillEmbed(ev)
1521 } else if len(subID) > 3 && subID[:3] == "rp-" {
1522 handleReplyPreviewEvent(ev)
1523 } else if len(subID) > 4 && subID[:4] == "thr-" {
1524 handleThreadEvent(threadGen, ev)
1525 }
1526 }
1527
1528 func dispatchEOSE(subID string) {
1529 if subID == "feed-more" {
1530 feedLoading = false
1531 if feedMoreGot == 0 {
1532 feedEmptyStreak++
1533 if feedEmptyStreak >= 3 {
1534 feedExhausted = true
1535 }
1536 } else {
1537 feedEmptyStreak = 0
1538 }
1539 dom.PostToSW("[\"CLOSE\",\"feed-more\"]")
1540 updateStatus()
1541 } else if subID == "feed" {
1542 if feedLoader != 0 {
1543 dom.RemoveChild(feedPage, feedLoader)
1544 feedLoader = 0
1545 }
1546 updateStatus()
1547 retryMissingProfiles()
1548 } else if len(subID) > 9 && subID[:9] == "ap-batch-" {
1549 // Delay CLOSE: server-side _proxy fan-out to external relays takes 5-15s.
1550 // Keep sub alive so late-arriving events flow through pushToMatchingSubs.
1551 closeID := subID
1552 dom.SetTimeout(func() {
1553 dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]")
1554 }, 15000)
1555 // Debounce: schedule follow-up retry 10s after last batch EOSE (max 3 rounds).
1556 if retryRound <= 3 {
1557 if retryTimer != 0 {
1558 dom.ClearTimeout(retryTimer)
1559 }
1560 retryTimer = dom.SetTimeout(func() {
1561 retryTimer = 0
1562 retryMissingProfiles()
1563 }, 10000)
1564 }
1565 } else if len(subID) > 3 && subID[:3] == "ap-" {
1566 closeID := subID
1567 dom.SetTimeout(func() {
1568 dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]")
1569 }, 15000)
1570 pk, ok := authorSubPK[subID]
1571 if !ok {
1572 return
1573 }
1574 delete(authorSubPK, subID)
1575 if _, got := authorNames[pk]; !got {
1576 if rels, ok := authorRelays[pk]; ok && len(rels) > 0 && !fetchedK10k[pk] {
1577 fetchedK10k[pk] = true
1578 fetchedK0[pk] = false
1579 fetchAuthorProfile(pk)
1580 }
1581 }
1582 } else if len(subID) > 4 && subID[:4] == "emb-" {
1583 closeID := subID
1584 dom.SetTimeout(func() {
1585 dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]")
1586 }, 5000)
1587 } else if len(subID) > 3 && subID[:3] == "rp-" {
1588 closeID := subID
1589 dom.SetTimeout(func() {
1590 dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]")
1591 }, 5000)
1592 } else if len(subID) > 4 && subID[:4] == "thr-" {
1593 closeID := subID
1594 dom.SetTimeout(func() {
1595 dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]")
1596 }, 5000)
1597 if threadOpen {
1598 handleThreadEOSE()
1599 }
1600 }
1601 }
1602
1603 // handleCryptoReq processes CRYPTO_REQ from the SW, calling the NIP-07
1604 // extension and posting CRYPTO_RESULT back.
1605 // Format: ["CRYPTO_REQ", id, "method", "peerPubkey", "data"]
1606 func handleCryptoReq(raw string, pos int) {
1607 // id is a bare number, not a quoted string.
1608 idStr := nextNum(raw, pos)
1609 // Skip past the number and comma to find the method string.
1610 pos2 := pos
1611 for pos2 < len(raw) && raw[pos2] != ',' {
1612 pos2++
1613 }
1614 pos2++
1615 method, pos3 := nextStr(raw, pos2)
1616 peer, pos4 := nextStr(raw, pos3)
1617 data, _ := nextStr(raw, pos4)
1618
1619 dom.ConsoleLog("crypto: " + method + " #" + idStr)
1620
1621 sendResult := func(result, errMsg string) {
1622 if errMsg != "" {
1623 dom.ConsoleLog("crypto: " + method + " #" + idStr + " ERR=" + errMsg)
1624 } else {
1625 dom.ConsoleLog("crypto: " + method + " #" + idStr + " OK")
1626 }
1627 dom.PostToSW("[\"CRYPTO_RESULT\"," + idStr + "," + jstr(result) + "," + jstr(errMsg) + "]")
1628 }
1629
1630 switch method {
1631 case "signEvent":
1632 signer.SignEvent(data, func(signed string) {
1633 if signed == "" {
1634 sendResult("", "sign failed")
1635 } else {
1636 sendResult(signed, "")
1637 }
1638 })
1639 case "nip04.decrypt":
1640 signer.Nip04Decrypt(peer, data, func(plain string) {
1641 if plain == "" {
1642 sendResult("", "decrypt failed")
1643 } else {
1644 sendResult(plain, "")
1645 }
1646 })
1647 case "nip04.encrypt":
1648 signer.Nip04Encrypt(peer, data, func(ct string) {
1649 if ct == "" {
1650 sendResult("", "encrypt failed")
1651 } else {
1652 sendResult(ct, "")
1653 }
1654 })
1655 case "nip44.decrypt":
1656 signer.Nip44Decrypt(peer, data, func(plain string) {
1657 if plain == "" {
1658 sendResult("", "decrypt failed")
1659 } else {
1660 sendResult(plain, "")
1661 }
1662 })
1663 case "nip44.encrypt":
1664 signer.Nip44Encrypt(peer, data, func(ct string) {
1665 if ct == "" {
1666 sendResult("", "encrypt failed")
1667 } else {
1668 sendResult(ct, "")
1669 }
1670 })
1671 default:
1672 sendResult("", "unknown method: "+method)
1673 }
1674 }
1675
1676 // nextNum extracts a bare number from s starting at pos, returning it as a string.
1677 func nextNum(s string, pos int) string {
1678 for pos < len(s) && (s[pos] == ' ' || s[pos] == ',') {
1679 pos++
1680 }
1681 start := pos
1682 for pos < len(s) && s[pos] >= '0' && s[pos] <= '9' {
1683 pos++
1684 }
1685 return s[start:pos]
1686 }
1687
1688 // nextStr extracts the next quoted string from s starting at pos.
1689 func nextStr(s string, pos int) (string, int) {
1690 for pos < len(s) && s[pos] != '"' {
1691 pos++
1692 }
1693 if pos >= len(s) {
1694 return "", pos
1695 }
1696 pos++
1697 var buf []byte
1698 hasEsc := false
1699 start := pos
1700 for pos < len(s) {
1701 if s[pos] == '\\' && pos+1 < len(s) {
1702 hasEsc = true
1703 buf = append(buf, s[start:pos]...)
1704 pos++
1705 switch s[pos] {
1706 case '"', '\\', '/':
1707 buf = append(buf, s[pos])
1708 case 'n':
1709 buf = append(buf, '\n')
1710 case 't':
1711 buf = append(buf, '\t')
1712 case 'r':
1713 buf = append(buf, '\r')
1714 default:
1715 buf = append(buf, '\\', s[pos])
1716 }
1717 pos++
1718 start = pos
1719 continue
1720 }
1721 if s[pos] == '"' {
1722 break
1723 }
1724 pos++
1725 }
1726 if pos >= len(s) {
1727 return "", pos
1728 }
1729 var val string
1730 if hasEsc {
1731 buf = append(buf, s[start:pos]...)
1732 val = string(buf)
1733 } else {
1734 val = s[start:pos]
1735 }
1736 pos++
1737 for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') {
1738 pos++
1739 }
1740 return val, pos
1741 }
1742
1743 // extractValue extracts a JSON object/array value starting at pos.
1744 func extractValue(s string, pos int) string {
1745 for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') {
1746 pos++
1747 }
1748 if pos >= len(s) {
1749 return ""
1750 }
1751 if s[pos] != '{' && s[pos] != '[' {
1752 return ""
1753 }
1754 start := pos
1755 depth := 0
1756 for pos < len(s) {
1757 c := s[pos]
1758 if c == '{' || c == '[' {
1759 depth++
1760 }
1761 if c == '}' || c == ']' {
1762 depth--
1763 if depth == 0 {
1764 return s[start : pos+1]
1765 }
1766 }
1767 if c == '"' {
1768 pos++
1769 for pos < len(s) && s[pos] != '"' {
1770 if s[pos] == '\\' {
1771 pos++
1772 }
1773 pos++
1774 }
1775 }
1776 pos++
1777 }
1778 return s[start:]
1779 }
1780
1781 func handleProfileEvent(ev *nostr.Event) {
1782 switch ev.Kind {
1783 case 0:
1784 if ev.CreatedAt <= profileTs {
1785 return
1786 }
1787 profileTs = ev.CreatedAt
1788 authorContent[pubhex] = ev.Content
1789 name := helpers.JsonGetString(ev.Content, "name")
1790 if name == "" {
1791 name = helpers.JsonGetString(ev.Content, "display_name")
1792 }
1793 pic := helpers.JsonGetString(ev.Content, "picture")
1794 if name != "" {
1795 profileName = name
1796 authorNames[pubhex] = name
1797 dom.SetTextContent(nameEl, name)
1798 }
1799 if pic != "" {
1800 profilePic = pic
1801 authorPics[pubhex] = pic
1802 dom.SetAttribute(avatarEl, "src", pic)
1803 dom.SetStyle(avatarEl, "display", "block")
1804 }
1805 if profileViewPK == pubhex {
1806 renderProfilePage(pubhex)
1807 }
1808 case 3:
1809 var pks []string
1810 for _, tag := range ev.Tags.GetAll("p") {
1811 if v := tag.Value(); v != "" {
1812 pks = append(pks, v)
1813 }
1814 }
1815 authorFollows[pubhex] = pks
1816 refreshProfileTab(pubhex)
1817 case 10000:
1818 var pks []string
1819 for _, tag := range ev.Tags.GetAll("p") {
1820 if v := tag.Value(); v != "" {
1821 pks = append(pks, v)
1822 }
1823 }
1824 authorMutes[pubhex] = pks
1825 refreshProfileTab(pubhex)
1826 case 10002:
1827 // NIP-65 relay list — add user's preferred relays.
1828 recordRelayFreq(ev)
1829 for _, tag := range ev.Tags.GetAll("r") {
1830 url := tag.Value()
1831 if url != "" {
1832 addRelay(url, true)
1833 }
1834 }
1835 sendWriteRelays()
1836 // Debounce resubscription — kind 10002 events arrive in bursts.
1837 if resubTimer != 0 {
1838 dom.ClearTimeout(resubTimer)
1839 }
1840 resubTimer = dom.SetTimeout(func() {
1841 resubTimer = 0
1842 subscribeFeed()
1843 }, 2000)
1844 case 10050:
1845 // DM inbox relay list — stored for future use.
1846 _ = ev.Tags.GetAll("relay")
1847 }
1848 }
1849
1850 func updateStatus() {
1851 dom.SetTextContent(statusEl,
1852 itoa(len(relayURLs))+" relays | "+itoa(eventCount)+" events")
1853 }
1854
1855 // --- Feed rendering ---
1856
1857 func renderNote(ev *nostr.Event) {
1858 note := dom.CreateElement("div")
1859 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
1860 dom.SetStyle(note, "padding", "12px 0")
1861
1862 // Header row: author link (left) + timestamp (right).
1863 header := dom.CreateElement("div")
1864 dom.SetStyle(header, "display", "flex")
1865 dom.SetStyle(header, "alignItems", "center")
1866 dom.SetStyle(header, "marginBottom", "4px")
1867 dom.SetStyle(header, "maxWidth", "65ch")
1868
1869 // Author link — only covers avatar + name.
1870 authorLink := dom.CreateElement("div")
1871 dom.SetStyle(authorLink, "display", "flex")
1872 dom.SetStyle(authorLink, "alignItems", "center")
1873 dom.SetStyle(authorLink, "gap", "8px")
1874 dom.SetStyle(authorLink, "cursor", "pointer")
1875 headerPK := ev.PubKey
1876 dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
1877 showProfile(headerPK)
1878 }))
1879
1880 avatar := dom.CreateElement("img")
1881 dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
1882 dom.SetAttribute(avatar, "width", "24")
1883 dom.SetAttribute(avatar, "height", "24")
1884 dom.SetStyle(avatar, "borderRadius", "50%")
1885 dom.SetStyle(avatar, "objectFit", "cover")
1886 dom.SetStyle(avatar, "flexShrink", "0")
1887
1888 nameSpan := dom.CreateElement("span")
1889 dom.SetStyle(nameSpan, "fontSize", "18px")
1890 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
1891 dom.SetStyle(nameSpan, "fontWeight", "bold")
1892 dom.SetStyle(nameSpan, "color", "var(--fg)")
1893
1894 pk := ev.PubKey
1895 if pic, ok := authorPics[pk]; ok && pic != "" {
1896 dom.SetAttribute(avatar, "src", pic)
1897 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
1898 } else {
1899 dom.SetStyle(avatar, "display", "none")
1900 }
1901 if name, ok := authorNames[pk]; ok && name != "" {
1902 dom.SetTextContent(nameSpan, name)
1903 } else {
1904 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
1905 if len(npub) > 20 {
1906 dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:])
1907 }
1908 }
1909
1910 dom.AppendChild(authorLink, avatar)
1911 dom.AppendChild(authorLink, nameSpan)
1912 dom.AppendChild(header, authorLink)
1913
1914 // Timestamp — right-aligned, opens thread view on click.
1915 if ev.CreatedAt > 0 {
1916 tsEl := dom.CreateElement("span")
1917 dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
1918 dom.SetStyle(tsEl, "fontSize", "11px")
1919 dom.SetStyle(tsEl, "color", "var(--muted)")
1920 dom.SetStyle(tsEl, "marginLeft", "auto")
1921 dom.SetStyle(tsEl, "cursor", "pointer")
1922 dom.SetStyle(tsEl, "flexShrink", "0")
1923 evID := ev.ID
1924 evRootID := getRootID(ev)
1925 if evRootID == "" {
1926 evRootID = evID
1927 }
1928 dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
1929 showNoteThread(evRootID, evID)
1930 }))
1931 dom.AppendChild(header, tsEl)
1932 }
1933
1934 dom.AppendChild(note, header)
1935
1936 // Track author link for update when profile arrives.
1937 if _, cached := authorNames[pk]; !cached {
1938 pendingNotes[pk] = append(pendingNotes[pk], authorLink)
1939 if !fetchedK0[pk] {
1940 queueProfileFetch(pk)
1941 }
1942 }
1943
1944 // Reply preview button.
1945 addReplyPreview(note, ev)
1946
1947 // Content.
1948 content := dom.CreateElement("div")
1949 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
1950 dom.SetStyle(content, "fontSize", "14px")
1951 dom.SetStyle(content, "lineHeight", "1.5")
1952 dom.SetStyle(content, "wordBreak", "break-word")
1953 dom.SetStyle(content, "maxWidth", "65ch")
1954 text := ev.Content
1955 truncated := len(text) > 500
1956 if truncated {
1957 text = text[:500] + "..."
1958 }
1959 dom.SetInnerHTML(content, renderMarkdown(text))
1960 resolveEmbeds()
1961 dom.AppendChild(note, content)
1962
1963 if truncated {
1964 more := dom.CreateElement("span")
1965 dom.SetTextContent(more, t("show_more"))
1966 dom.SetStyle(more, "color", "var(--accent)")
1967 dom.SetStyle(more, "cursor", "pointer")
1968 dom.SetStyle(more, "fontSize", "13px")
1969 dom.SetStyle(more, "display", "inline-block")
1970 dom.SetStyle(more, "marginTop", "4px")
1971 fullContent := ev.Content
1972 expanded := false
1973 dom.AddEventListener(more, "click", dom.RegisterCallback(func() {
1974 expanded = !expanded
1975 if expanded {
1976 dom.SetInnerHTML(content, renderMarkdown(fullContent))
1977 resolveEmbeds()
1978 dom.SetTextContent(more, t("show_less"))
1979 } else {
1980 dom.SetInnerHTML(content, renderMarkdown(fullContent[:500]+"..."))
1981 resolveEmbeds()
1982 dom.SetTextContent(more, t("show_more"))
1983 }
1984 }))
1985 dom.AppendChild(note, more)
1986 }
1987
1988 // Prepend (newest first).
1989 first := dom.FirstChild(feedContainer)
1990 if first != 0 {
1991 dom.InsertBefore(feedContainer, note, first)
1992 } else {
1993 dom.AppendChild(feedContainer, note)
1994 }
1995 }
1996
1997 func appendNote(ev *nostr.Event) {
1998 note := buildNoteElement(ev)
1999 dom.AppendChild(feedContainer, note)
2000 }
2001
2002 func buildNoteElement(ev *nostr.Event) dom.Element {
2003 note := dom.CreateElement("div")
2004 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
2005 dom.SetStyle(note, "padding", "12px 0")
2006
2007 header := dom.CreateElement("div")
2008 dom.SetStyle(header, "display", "flex")
2009 dom.SetStyle(header, "alignItems", "center")
2010 dom.SetStyle(header, "marginBottom", "4px")
2011 dom.SetStyle(header, "maxWidth", "65ch")
2012
2013 authorLink := dom.CreateElement("div")
2014 dom.SetStyle(authorLink, "display", "flex")
2015 dom.SetStyle(authorLink, "alignItems", "center")
2016 dom.SetStyle(authorLink, "gap", "8px")
2017 dom.SetStyle(authorLink, "cursor", "pointer")
2018 headerPK := ev.PubKey
2019 dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
2020 showProfile(headerPK)
2021 }))
2022
2023 avatar := dom.CreateElement("img")
2024 dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
2025 dom.SetAttribute(avatar, "width", "24")
2026 dom.SetAttribute(avatar, "height", "24")
2027 dom.SetStyle(avatar, "borderRadius", "50%")
2028 dom.SetStyle(avatar, "objectFit", "cover")
2029 dom.SetStyle(avatar, "flexShrink", "0")
2030
2031 nameSpan := dom.CreateElement("span")
2032 dom.SetStyle(nameSpan, "fontSize", "18px")
2033 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
2034 dom.SetStyle(nameSpan, "fontWeight", "bold")
2035 dom.SetStyle(nameSpan, "color", "var(--fg)")
2036
2037 pk := ev.PubKey
2038 if pic, ok := authorPics[pk]; ok && pic != "" {
2039 dom.SetAttribute(avatar, "src", pic)
2040 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
2041 } else {
2042 dom.SetStyle(avatar, "display", "none")
2043 }
2044 if name, ok := authorNames[pk]; ok && name != "" {
2045 dom.SetTextContent(nameSpan, name)
2046 } else {
2047 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
2048 if len(npub) > 20 {
2049 dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:])
2050 }
2051 }
2052
2053 dom.AppendChild(authorLink, avatar)
2054 dom.AppendChild(authorLink, nameSpan)
2055 dom.AppendChild(header, authorLink)
2056
2057 if ev.CreatedAt > 0 {
2058 tsEl := dom.CreateElement("span")
2059 dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
2060 dom.SetStyle(tsEl, "fontSize", "11px")
2061 dom.SetStyle(tsEl, "color", "var(--muted)")
2062 dom.SetStyle(tsEl, "marginLeft", "auto")
2063 dom.SetStyle(tsEl, "cursor", "pointer")
2064 dom.SetStyle(tsEl, "flexShrink", "0")
2065 evID := ev.ID
2066 evRootID := getRootID(ev)
2067 if evRootID == "" {
2068 evRootID = evID
2069 }
2070 dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
2071 showNoteThread(evRootID, evID)
2072 }))
2073 dom.AppendChild(header, tsEl)
2074 }
2075
2076 dom.AppendChild(note, header)
2077
2078 if _, cached := authorNames[pk]; !cached {
2079 pendingNotes[pk] = append(pendingNotes[pk], authorLink)
2080 if !fetchedK0[pk] {
2081 queueProfileFetch(pk)
2082 }
2083 }
2084
2085 addReplyPreview(note, ev)
2086
2087 content := dom.CreateElement("div")
2088 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
2089 dom.SetStyle(content, "fontSize", "14px")
2090 dom.SetStyle(content, "lineHeight", "1.5")
2091 dom.SetStyle(content, "wordBreak", "break-word")
2092 dom.SetStyle(content, "maxWidth", "65ch")
2093 text := ev.Content
2094 truncated := len(text) > 500
2095 if truncated {
2096 text = text[:500] + "..."
2097 }
2098 dom.SetInnerHTML(content, renderMarkdown(text))
2099 resolveEmbeds()
2100 dom.AppendChild(note, content)
2101
2102 if truncated {
2103 more := dom.CreateElement("span")
2104 dom.SetTextContent(more, t("show_more"))
2105 dom.SetStyle(more, "color", "var(--accent)")
2106 dom.SetStyle(more, "cursor", "pointer")
2107 dom.SetStyle(more, "fontSize", "13px")
2108 dom.SetStyle(more, "display", "inline-block")
2109 dom.SetStyle(more, "marginTop", "4px")
2110 fullContent := ev.Content
2111 expanded := false
2112 dom.AddEventListener(more, "click", dom.RegisterCallback(func() {
2113 expanded = !expanded
2114 if expanded {
2115 dom.SetInnerHTML(content, renderMarkdown(fullContent))
2116 resolveEmbeds()
2117 dom.SetTextContent(more, t("show_less"))
2118 } else {
2119 dom.SetInnerHTML(content, renderMarkdown(fullContent[:500]+"..."))
2120 resolveEmbeds()
2121 dom.SetTextContent(more, t("show_more"))
2122 }
2123 }))
2124 dom.AppendChild(note, more)
2125 }
2126
2127 return note
2128 }
2129
2130 var profileSubCounter int
2131
2132 // topRelays returns the n most frequently seen relay URLs from kind 10002 events.
2133 func topRelays(n int) []string {
2134 if relayFreq == nil {
2135 return nil
2136 }
2137 // Simple selection sort — n is small.
2138 type kv struct {
2139 url string
2140 count int
2141 }
2142 var all []kv
2143 for url, count := range relayFreq {
2144 all = append(all, kv{url, count})
2145 }
2146 // Sort descending by count.
2147 for i := 0; i < len(all); i++ {
2148 for j := i + 1; j < len(all); j++ {
2149 if all[j].count > all[i].count {
2150 all[i], all[j] = all[j], all[i]
2151 }
2152 }
2153 }
2154 var result []string
2155 for i := 0; i < len(all) && i < n; i++ {
2156 result = append(result, all[i].url)
2157 }
2158 return result
2159 }
2160
2161 // recordRelayFreq records relay URLs from a kind 10002 event into the frequency table.
2162 func recordRelayFreq(ev *nostr.Event) {
2163 tags := ev.Tags.GetAll("r")
2164 if tags == nil {
2165 return
2166 }
2167 var urls []string
2168 for _, tag := range tags {
2169 u := tag.Value()
2170 if u != "" {
2171 urls = append(urls, u)
2172 if _, ok := relayFreq[u]; ok {
2173 relayFreq[u] = relayFreq[u] + 1
2174 } else {
2175 relayFreq[u] = 1
2176 }
2177 }
2178 }
2179 if len(urls) > 0 {
2180 authorRelays[ev.PubKey] = urls
2181 }
2182 }
2183
2184 // discoveryRelays are well-known relays that aggregate profile metadata.
2185 // Prioritized first in _proxy lists since they have the highest hit rate.
2186 var discoveryRelays = []string{
2187 "wss://purplepag.es",
2188 "wss://relay.nostr.band",
2189 "wss://relay.damus.io",
2190 "wss://nos.lol",
2191 }
2192
2193 // buildProxy builds a _proxy relay list for a pubkey.
2194 // Discovery relays first, then author-specific relays if known.
2195 func buildProxy(pk string) []string {
2196 out := []string{:len(discoveryRelays)}
2197 copy(out, discoveryRelays)
2198 for _, u := range relayURLs {
2199 out = appendUnique(out, u)
2200 }
2201 if rels, ok := authorRelays[pk]; ok {
2202 for _, r := range rels {
2203 out = appendUnique(out, r)
2204 }
2205 }
2206 top := topRelays(4)
2207 for _, r := range top {
2208 out = appendUnique(out, r)
2209 }
2210 return out
2211 }
2212
2213 func appendUnique(list []string, val string) []string {
2214 for _, v := range list {
2215 if v == val {
2216 return list
2217 }
2218 }
2219 return append(list, val)
2220 }
2221
2222 // --- Thread view & reply preview ---
2223
2224 // getReplyID returns the event ID this note is replying to, or "".
2225 // Checks for NIP-10 "reply" marker first, falls back to positional (last e-tag).
2226 func getReplyID(ev *nostr.Event) string {
2227 var etags []nostr.Tag
2228 for _, t := range ev.Tags {
2229 if len(t) >= 2 && t[0] == "e" {
2230 etags = append(etags, t)
2231 }
2232 }
2233 if len(etags) == 0 {
2234 return ""
2235 }
2236 // Marker-based: look for "reply".
2237 for _, t := range etags {
2238 if len(t) >= 4 && t[3] == "reply" {
2239 return t[1]
2240 }
2241 }
2242 // Positional fallback: last e-tag is the reply target (if >1 e-tags).
2243 if len(etags) > 1 {
2244 return etags[len(etags)-1][1]
2245 }
2246 // Single e-tag with no marker: it's both root and reply.
2247 return etags[0][1]
2248 }
2249
2250 // getRootID returns the thread root event ID, or "".
2251 func getRootID(ev *nostr.Event) string {
2252 for _, t := range ev.Tags {
2253 if len(t) >= 4 && t[0] == "e" && t[3] == "root" {
2254 return t[1]
2255 }
2256 }
2257 // Positional: first e-tag.
2258 for _, t := range ev.Tags {
2259 if len(t) >= 2 && t[0] == "e" {
2260 return t[1]
2261 }
2262 }
2263 return ""
2264 }
2265
2266 // referencesEvent returns true if ev has any e-tag pointing to the given ID.
2267 func referencesEvent(ev *nostr.Event, id string) bool {
2268 for _, t := range ev.Tags {
2269 if len(t) >= 2 && t[0] == "e" && t[1] == id {
2270 return true
2271 }
2272 }
2273 return false
2274 }
2275
2276 func firstLine(s string) string {
2277 for i := 0; i < len(s); i++ {
2278 if s[i] == '\n' {
2279 if i > 80 {
2280 return s[:80] + "..."
2281 }
2282 return s[:i]
2283 }
2284 }
2285 if len(s) > 80 {
2286 return s[:80] + "..."
2287 }
2288 return s
2289 }
2290
2291 func addReplyPreview(note dom.Element, ev *nostr.Event) {
2292 parentID := getReplyID(ev)
2293 if parentID == "" {
2294 return
2295 }
2296
2297 preview := dom.CreateElement("div")
2298 dom.SetStyle(preview, "display", "flex")
2299 dom.SetStyle(preview, "alignItems", "center")
2300 dom.SetStyle(preview, "gap", "4px")
2301 dom.SetStyle(preview, "fontSize", "12px")
2302 dom.SetStyle(preview, "color", "var(--muted)")
2303 dom.SetStyle(preview, "borderLeft", "2px solid var(--accent)")
2304 dom.SetStyle(preview, "paddingLeft", "8px")
2305 dom.SetStyle(preview, "marginBottom", "4px")
2306 dom.SetStyle(preview, "cursor", "pointer")
2307 dom.SetStyle(preview, "overflow", "hidden")
2308 dom.SetStyle(preview, "maxWidth", "65ch")
2309
2310 prevAvatar := dom.CreateElement("img")
2311 dom.SetAttribute(prevAvatar, "referrerpolicy", "no-referrer")
2312 dom.SetAttribute(prevAvatar, "width", "14")
2313 dom.SetAttribute(prevAvatar, "height", "14")
2314 dom.SetStyle(prevAvatar, "borderRadius", "50%")
2315 dom.SetStyle(prevAvatar, "objectFit", "cover")
2316 dom.SetStyle(prevAvatar, "flexShrink", "0")
2317 dom.SetStyle(prevAvatar, "display", "none")
2318 dom.AppendChild(preview, prevAvatar)
2319
2320 prevText := dom.CreateElement("span")
2321 dom.SetStyle(prevText, "overflow", "hidden")
2322 dom.SetStyle(prevText, "whiteSpace", "nowrap")
2323 dom.SetStyle(prevText, "textOverflow", "ellipsis")
2324 dom.AppendChild(preview, prevText)
2325
2326 if text, ok := replyCache[parentID]; ok {
2327 dom.SetTextContent(prevText, text)
2328 if pic, ok := replyAvatarCache[parentID]; ok && pic != "" {
2329 dom.SetAttribute(prevAvatar, "src", pic)
2330 dom.SetAttribute(prevAvatar, "onerror", "this.style.display='none'")
2331 dom.SetStyle(prevAvatar, "display", "block")
2332 }
2333 } else {
2334 dom.SetTextContent(prevText, t("replying_to"))
2335 replyPending[parentID] = append(replyPending[parentID], preview)
2336 queueReplyFetch(parentID)
2337 }
2338
2339 rootID := getRootID(ev)
2340 if rootID == "" {
2341 rootID = parentID
2342 }
2343 focusID := parentID
2344 dom.AddEventListener(preview, "click", dom.RegisterCallback(func() {
2345 showNoteThread(rootID, focusID)
2346 }))
2347
2348 dom.AppendChild(note, preview)
2349 }
2350
2351 func queueReplyFetch(id string) {
2352 replyQueue = append(replyQueue, id)
2353 if replyTimer != 0 {
2354 dom.ClearTimeout(replyTimer)
2355 }
2356 replyTimer = dom.SetTimeout(func() {
2357 replyTimer = 0
2358 flushReplyQueue()
2359 }, 300)
2360 }
2361
2362 func flushReplyQueue() {
2363 if len(replyQueue) == 0 {
2364 return
2365 }
2366 filter := "{\"ids\":["
2367 for i, id := range replyQueue {
2368 if i > 0 {
2369 filter += ","
2370 }
2371 filter += jstr(id)
2372 }
2373 filter += "]}"
2374 replyQueue = nil
2375
2376 // Local REQ checks IDB cache first.
2377 threadSubCount++
2378 reqID := "rp-" + itoa(threadSubCount)
2379 dom.PostToSW("[\"REQ\"," + jstr(reqID) + "," + filter + "]")
2380
2381 // PROXY fetches from remote relays — use feed relays + top relays.
2382 var urls []string
2383 seen := map[string]bool{}
2384 for _, u := range relayURLs {
2385 if !seen[u] {
2386 seen[u] = true
2387 urls = append(urls, u)
2388 }
2389 }
2390 for _, u := range topRelays(8) {
2391 if !seen[u] {
2392 seen[u] = true
2393 urls = append(urls, u)
2394 }
2395 }
2396 threadSubCount++
2397 proxyID := "rp-" + itoa(threadSubCount)
2398 dom.PostToSW(buildProxyMsg(proxyID, filter, urls))
2399 }
2400
2401 func handleReplyPreviewEvent(ev *nostr.Event) {
2402 line := firstLine(ev.Content)
2403 replyLineCache[ev.ID] = line
2404 replyAuthorMap[ev.ID] = ev.PubKey
2405
2406 name := ev.PubKey[:8] + "..."
2407 nameResolved := false
2408 if n, ok := authorNames[ev.PubKey]; ok && n != "" {
2409 name = n
2410 nameResolved = true
2411 }
2412 text := name + ": " + line
2413 replyCache[ev.ID] = text
2414
2415 pic := ""
2416 if p, ok := authorPics[ev.PubKey]; ok && p != "" {
2417 pic = p
2418 }
2419 replyAvatarCache[ev.ID] = pic
2420
2421 // Trigger profile fetch if not cached.
2422 if !nameResolved {
2423 if !fetchedK0[ev.PubKey] {
2424 queueProfileFetch(ev.PubKey)
2425 }
2426 }
2427
2428 if divs, ok := replyPending[ev.ID]; ok {
2429 for _, d := range divs {
2430 fillReplyPreviewDiv(d, text, pic)
2431 }
2432 // Track for late name update if unresolved.
2433 if !nameResolved {
2434 replyNeedName[ev.ID] = append(replyNeedName[ev.ID], divs...)
2435 }
2436 delete(replyPending, ev.ID)
2437 }
2438 }
2439
2440 func fillReplyPreviewDiv(d dom.Element, text, pic string) {
2441 img := dom.FirstChild(d)
2442 if img == 0 {
2443 return
2444 }
2445 span := dom.NextSibling(img)
2446 if span != 0 {
2447 dom.SetTextContent(span, text)
2448 }
2449 if pic != "" {
2450 dom.SetAttribute(img, "src", pic)
2451 dom.SetAttribute(img, "onerror", "this.style.display='none'")
2452 dom.SetStyle(img, "display", "block")
2453 }
2454 }
2455
2456 // updateReplyPreviewsForAuthor is called when a kind 0 profile arrives.
2457 // It updates any reply preview divs that were rendered with a hex pubkey stub.
2458 func updateReplyPreviewsForAuthor(pk string) {
2459 name, _ := authorNames[pk]
2460 pic, _ := authorPics[pk]
2461 if name == "" {
2462 return
2463 }
2464
2465 for eid, apk := range replyAuthorMap {
2466 if apk != pk {
2467 continue
2468 }
2469 line := replyLineCache[eid]
2470 text := name + ": " + line
2471 replyCache[eid] = text
2472 replyAvatarCache[eid] = pic
2473
2474 if divs, ok := replyNeedName[eid]; ok {
2475 for _, d := range divs {
2476 fillReplyPreviewDiv(d, text, pic)
2477 }
2478 delete(replyNeedName, eid)
2479 }
2480 }
2481 }
2482
2483 func showNoteThread(rootID, focusID string) {
2484 // Close old thread subs immediately to prevent stale events leaking in.
2485 for _, sid := range threadActiveSubs {
2486 dom.PostToSW("[\"CLOSE\"," + jstr(sid) + "]")
2487 }
2488 threadActiveSubs = nil
2489 threadGen++
2490
2491 threadRootID = rootID
2492 threadFocusID = focusID
2493 threadEvents = map[string]*nostr.Event{}
2494 threadLastRendered = 0
2495 threadOpen = true
2496
2497 // Save scroll position.
2498 savedScrollTop = dom.GetProperty(contentArea, "scrollTop")
2499
2500 // Switch UI.
2501 dom.SetStyle(feedContainer, "display", "none")
2502 dom.SetStyle(threadPage, "display", "block")
2503 dom.SetProperty(contentArea, "scrollTop", "0")
2504
2505 // Show back button in top bar, hide page title.
2506 dom.SetStyle(topBackBtn, "display", "inline")
2507 dom.SetStyle(pageTitleEl, "display", "none")
2508
2509 if !navPop {
2510 dom.PushState("/t/" + rootID)
2511 threadPushedState = true
2512 } else {
2513 threadPushedState = false
2514 }
2515
2516 clearChildren(threadContainer)
2517
2518 // Loading indicator.
2519 loading := dom.CreateElement("div")
2520 dom.SetTextContent(loading, t("loading_thread"))
2521 dom.SetStyle(loading, "color", "var(--muted)")
2522 dom.SetStyle(loading, "fontSize", "14px")
2523 dom.SetStyle(loading, "padding", "16px 0")
2524 dom.AppendChild(threadContainer, loading)
2525
2526 // Build wide relay set for thread fetch.
2527 var thrRelays []string
2528 thrSeen := map[string]bool{}
2529 for _, u := range relayURLs {
2530 if !thrSeen[u] {
2531 thrSeen[u] = true
2532 thrRelays = append(thrRelays, u)
2533 }
2534 }
2535 for _, u := range topRelays(8) {
2536 if !thrSeen[u] {
2537 thrSeen[u] = true
2538 thrRelays = append(thrRelays, u)
2539 }
2540 }
2541
2542 // Local REQ for cached events.
2543 threadSubCount++
2544 s1 := "thr-" + itoa(threadSubCount)
2545 threadActiveSubs = append(threadActiveSubs, s1)
2546 dom.PostToSW("[\"REQ\"," + jstr(s1) + ",{\"ids\":[" + jstr(rootID) + "]}]")
2547
2548 threadSubCount++
2549 s2 := "thr-" + itoa(threadSubCount)
2550 threadActiveSubs = append(threadActiveSubs, s2)
2551 dom.PostToSW("[\"REQ\"," + jstr(s2) + ",{\"#e\":[" + jstr(rootID) + "],\"kinds\":[1]}]")
2552
2553 // PROXY for remote relays.
2554 threadSubCount++
2555 s3 := "thr-" + itoa(threadSubCount)
2556 threadActiveSubs = append(threadActiveSubs, s3)
2557 dom.PostToSW(buildProxyMsg(s3, "{\"ids\":["+jstr(rootID)+"]}", thrRelays))
2558
2559 threadSubCount++
2560 s4 := "thr-" + itoa(threadSubCount)
2561 threadActiveSubs = append(threadActiveSubs, s4)
2562 dom.PostToSW(buildProxyMsg(s4, "{\"#e\":["+jstr(rootID)+"],\"kinds\":[1]}", thrRelays))
2563
2564 // Also fetch the focus event directly if different from root.
2565 if focusID != rootID {
2566 threadSubCount++
2567 s5 := "thr-" + itoa(threadSubCount)
2568 threadActiveSubs = append(threadActiveSubs, s5)
2569 dom.PostToSW(buildProxyMsg(s5, "{\"ids\":["+jstr(focusID)+"]}", thrRelays))
2570 }
2571 }
2572
2573 func closeNoteThread() {
2574 // Close active subs.
2575 for _, sid := range threadActiveSubs {
2576 dom.PostToSW("[\"CLOSE\"," + jstr(sid) + "]")
2577 }
2578 threadActiveSubs = nil
2579
2580 threadOpen = false
2581 threadRootID = ""
2582 threadFocusID = ""
2583 dom.SetStyle(threadPage, "display", "none")
2584 dom.SetStyle(feedContainer, "display", "block")
2585
2586 // Restore top bar.
2587 dom.SetStyle(topBackBtn, "display", "none")
2588 dom.SetStyle(pageTitleEl, "display", "inline")
2589
2590 // Restore scroll position.
2591 if savedScrollTop != "" {
2592 dom.SetProperty(contentArea, "scrollTop", savedScrollTop)
2593 savedScrollTop = ""
2594 }
2595 }
2596
2597 func handleThreadEvent(gen int, ev *nostr.Event) {
2598 if gen != threadGen {
2599 return // stale event from a previous thread
2600 }
2601 threadEvents[ev.ID] = ev
2602 // Debounced render — 200ms after last event, show what we have.
2603 if threadRenderTimer != 0 {
2604 dom.ClearTimeout(threadRenderTimer)
2605 }
2606 threadRenderTimer = dom.SetTimeout(func() {
2607 threadRenderTimer = 0
2608 if threadOpen {
2609 renderThreadTree()
2610 }
2611 }, 200)
2612 }
2613
2614 func handleThreadEOSE() {
2615 // Final render pass.
2616 if threadRenderTimer != 0 {
2617 dom.ClearTimeout(threadRenderTimer)
2618 threadRenderTimer = 0
2619 }
2620 renderThreadTree()
2621 }
2622
2623 func renderThreadTree() {
2624 n := len(threadEvents)
2625 if n == threadLastRendered {
2626 return // no new events since last render
2627 }
2628 threadLastRendered = n
2629
2630 clearChildren(threadContainer)
2631
2632 if n == 0 {
2633 empty := dom.CreateElement("div")
2634 dom.SetTextContent(empty, t("thread_empty"))
2635 dom.SetStyle(empty, "color", "var(--muted)")
2636 dom.SetStyle(empty, "padding", "16px 0")
2637 dom.AppendChild(threadContainer, empty)
2638 return
2639 }
2640
2641 // Build parent->children map. If direct parent isn't in the thread,
2642 // re-parent under root (the event is in-thread if it references root).
2643 children := map[string][]string{}
2644 for id, ev := range threadEvents {
2645 parentID := getReplyID(ev)
2646 if parentID == "" || parentID == id {
2647 continue
2648 }
2649 if threadEvents[parentID] == nil && parentID != threadRootID {
2650 // Direct parent not fetched — attach to root if event belongs to thread.
2651 if referencesEvent(ev, threadRootID) {
2652 parentID = threadRootID
2653 }
2654 }
2655 children[parentID] = append(children[parentID], id)
2656 }
2657
2658 // Sort children chronologically.
2659 for pid := range children {
2660 ids := children[pid]
2661 sortByCreatedAt(ids)
2662 children[pid] = ids
2663 }
2664
2665 // Find root — event with no parent in this thread, or threadRootID.
2666 rootID := threadRootID
2667 if _, ok := threadEvents[rootID]; !ok {
2668 // Root not fetched; find event with no in-thread parent.
2669 for id, ev := range threadEvents {
2670 parentID := getReplyID(ev)
2671 if parentID == "" || threadEvents[parentID] == nil {
2672 rootID = id
2673 break
2674 }
2675 }
2676 }
2677
2678 // Render tree via DFS, tracking what was rendered.
2679 rendered := map[string]bool{}
2680 var renderAt func(id string, depth int)
2681 renderAt = func(id string, depth int) {
2682 ev, ok := threadEvents[id]
2683 if !ok {
2684 return
2685 }
2686 rendered[id] = true
2687 renderThreadNote(ev, depth, id == threadFocusID)
2688 for _, childID := range children[id] {
2689 renderAt(childID, depth+1)
2690 }
2691 }
2692
2693 // If root is present, start there. Otherwise render all as flat.
2694 if _, ok := threadEvents[rootID]; ok {
2695 renderAt(rootID, 0)
2696 // Render any remaining orphans (events not reached by DFS).
2697 for id := range threadEvents {
2698 if !rendered[id] {
2699 renderAt(id, 1)
2700 }
2701 }
2702 } else {
2703 for id := range threadEvents {
2704 renderAt(id, 0)
2705 }
2706 }
2707 }
2708
2709 func sortByCreatedAt(ids []string) {
2710 for i := 0; i < len(ids); i++ {
2711 for j := i + 1; j < len(ids); j++ {
2712 ei := threadEvents[ids[i]]
2713 ej := threadEvents[ids[j]]
2714 if ei != nil && ej != nil && ei.CreatedAt > ej.CreatedAt {
2715 ids[i], ids[j] = ids[j], ids[i]
2716 }
2717 }
2718 }
2719 }
2720
2721 func renderThreadNote(ev *nostr.Event, depth int, focused bool) {
2722 note := dom.CreateElement("div")
2723 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
2724 dom.SetStyle(note, "padding", "8px 0")
2725 dom.SetStyle(note, "marginLeft", itoa(depth*8)+"px")
2726
2727 if focused {
2728 dom.SetStyle(note, "borderLeft", "3px solid var(--accent)")
2729 dom.SetStyle(note, "paddingLeft", "8px")
2730 dom.SetStyle(note, "background", "var(--bg2)")
2731 dom.SetStyle(note, "borderRadius", "4px")
2732 }
2733
2734 // Header.
2735 header := dom.CreateElement("div")
2736 dom.SetStyle(header, "display", "flex")
2737 dom.SetStyle(header, "alignItems", "center")
2738 dom.SetStyle(header, "marginBottom", "4px")
2739 dom.SetStyle(header, "maxWidth", "65ch")
2740
2741 authorLink := dom.CreateElement("div")
2742 dom.SetStyle(authorLink, "display", "flex")
2743 dom.SetStyle(authorLink, "alignItems", "center")
2744 dom.SetStyle(authorLink, "gap", "6px")
2745 dom.SetStyle(authorLink, "cursor", "pointer")
2746 headerPK := ev.PubKey
2747 dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
2748 showProfile(headerPK)
2749 }))
2750
2751 avatar := dom.CreateElement("img")
2752 dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
2753 dom.SetAttribute(avatar, "width", "20")
2754 dom.SetAttribute(avatar, "height", "20")
2755 dom.SetStyle(avatar, "borderRadius", "50%")
2756 dom.SetStyle(avatar, "objectFit", "cover")
2757 dom.SetStyle(avatar, "flexShrink", "0")
2758
2759 nameSpan := dom.CreateElement("span")
2760 dom.SetStyle(nameSpan, "fontSize", "14px")
2761 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
2762 dom.SetStyle(nameSpan, "fontWeight", "bold")
2763 dom.SetStyle(nameSpan, "color", "var(--fg)")
2764
2765 pk := ev.PubKey
2766 if pic, ok := authorPics[pk]; ok && pic != "" {
2767 dom.SetAttribute(avatar, "src", pic)
2768 dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
2769 } else {
2770 dom.SetStyle(avatar, "display", "none")
2771 }
2772 if name, ok := authorNames[pk]; ok && name != "" {
2773 dom.SetTextContent(nameSpan, name)
2774 } else {
2775 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
2776 if len(npub) > 20 {
2777 dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:])
2778 }
2779 }
2780
2781 dom.AppendChild(authorLink, avatar)
2782 dom.AppendChild(authorLink, nameSpan)
2783 dom.AppendChild(header, authorLink)
2784
2785 // Timestamp.
2786 if ev.CreatedAt > 0 {
2787 tsEl := dom.CreateElement("span")
2788 dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
2789 dom.SetStyle(tsEl, "fontSize", "11px")
2790 dom.SetStyle(tsEl, "color", "var(--muted)")
2791 dom.SetStyle(tsEl, "marginLeft", "auto")
2792 dom.AppendChild(header, tsEl)
2793 }
2794
2795 dom.AppendChild(note, header)
2796
2797 if _, cached := authorNames[pk]; !cached {
2798 pendingNotes[pk] = append(pendingNotes[pk], authorLink)
2799 if !fetchedK0[pk] {
2800 queueProfileFetch(pk)
2801 }
2802 }
2803
2804 // Content.
2805 content := dom.CreateElement("div")
2806 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
2807 dom.SetStyle(content, "fontSize", "13px")
2808 dom.SetStyle(content, "lineHeight", "1.5")
2809 dom.SetStyle(content, "wordBreak", "break-word")
2810 dom.SetStyle(content, "maxWidth", "65ch")
2811 dom.SetInnerHTML(content, renderMarkdown(ev.Content))
2812 resolveEmbeds()
2813 dom.AppendChild(note, content)
2814
2815 dom.AppendChild(threadContainer, note)
2816 }
2817
2818 // fetchAuthorProfile fetches kind 0 + kind 10002 for an author via SW PROXY.
2819 func fetchAuthorProfile(pk string) {
2820 if fetchedK0[pk] {
2821 return
2822 }
2823 fetchedK0[pk] = true
2824
2825 profileSubCounter++
2826 subID := "ap-" + itoa(profileSubCounter)
2827 authorSubPK[subID] = pk
2828
2829 proxyRelays := buildProxy(pk)
2830 dom.PostToSW(buildProxyMsg(subID,
2831 "{\"authors\":["+jstr(pk)+"],\"kinds\":[0,3,10002,10000],\"limit\":6}",
2832 proxyRelays))
2833 }
2834
2835 // queueProfileFetch adds a pubkey to the batch fetch queue with a debounce.
2836 // After 300ms of no new additions, flushFetchQueue sends one batched PROXY request.
2837 func queueProfileFetch(pk string) {
2838 if fetchedK0[pk] {
2839 return
2840 }
2841 fetchedK0[pk] = true
2842 fetchQueue = append(fetchQueue, pk)
2843 if fetchTimer != 0 {
2844 dom.ClearTimeout(fetchTimer)
2845 }
2846 fetchTimer = dom.SetTimeout(func() {
2847 fetchTimer = 0
2848 flushFetchQueue()
2849 }, 300)
2850 }
2851
2852 // flushFetchQueue sends all queued pubkeys as chunked batch PROXY requests.
2853 func flushFetchQueue() {
2854 if len(fetchQueue) == 0 {
2855 return
2856 }
2857 queue := fetchQueue
2858 fetchQueue = nil
2859
2860 proxy := []string{:len(discoveryRelays)}
2861 copy(proxy, discoveryRelays)
2862 for _, u := range relayURLs {
2863 proxy = appendUnique(proxy, u)
2864 }
2865 for _, pk := range queue {
2866 if rels, ok := authorRelays[pk]; ok {
2867 for _, r := range rels {
2868 proxy = appendUnique(proxy, r)
2869 }
2870 }
2871 }
2872 top := topRelays(4)
2873 for _, r := range top {
2874 proxy = appendUnique(proxy, r)
2875 }
2876
2877 const batchSize = 100
2878 for i := 0; i < len(queue); i += batchSize {
2879 end := i + batchSize
2880 if end > len(queue) {
2881 end = len(queue)
2882 }
2883 chunk := queue[i:end]
2884 authors := "["
2885 for j, pk := range chunk {
2886 if j > 0 {
2887 authors += ","
2888 }
2889 authors += jstr(pk)
2890 }
2891 authors += "]"
2892 profileSubCounter++
2893 subID := "ap-batch-q-" + itoa(profileSubCounter)
2894 dom.PostToSW(buildProxyMsg(subID,
2895 "{\"authors\":"+authors+",\"kinds\":[0],\"limit\":"+itoa(len(chunk))+"}",
2896 proxy))
2897 // Also query feed relays directly — they have the kind 1 notes
2898 // so they almost certainly have kind 0 for the same authors.
2899 // Uses the SW's existing WebSocket connections, bypasses server proxy.
2900 profileSubCounter++
2901 dom.PostToSW(buildProxyMsg("ap-d-"+itoa(profileSubCounter),
2902 "{\"authors\":"+authors+",\"kinds\":[0],\"limit\":"+itoa(len(chunk))+"}",
2903 relayURLs))
2904 }
2905 }
2906
2907 // retryMissingProfiles batches pubkeys that still lack a name into chunked
2908 // PROXY requests through the orly relay. Fetches all metadata kinds so
2909 // relay lists from kind 10002 enable second-hop discovery.
2910 func retryMissingProfiles() {
2911 var missing []string
2912 for pk := range pendingNotes {
2913 if _, ok := authorNames[pk]; !ok {
2914 missing = append(missing, pk)
2915 }
2916 }
2917 if len(missing) == 0 {
2918 return
2919 }
2920
2921 // Reset fetchedK0 for still-missing profiles so individual re-fetches
2922 // can fire if new relay info appears from other profiles' kind 10002.
2923 for _, pk := range missing {
2924 fetchedK0[pk] = false
2925 }
2926
2927 // Discovery relays first, then user relays + discovered relays.
2928 proxy := []string{:len(discoveryRelays)}
2929 copy(proxy, discoveryRelays)
2930 for _, u := range relayURLs {
2931 proxy = appendUnique(proxy, u)
2932 }
2933 top := topRelays(8)
2934 for _, u := range top {
2935 proxy = appendUnique(proxy, u)
2936 }
2937
2938 const batchSize = 100
2939 batchNum := 0
2940 for i := 0; i < len(missing); i += batchSize {
2941 end := i + batchSize
2942 if end > len(missing) {
2943 end = len(missing)
2944 }
2945 chunk := missing[i:end]
2946 authors := "["
2947 for j, pk := range chunk {
2948 if j > 0 {
2949 authors += ","
2950 }
2951 authors += jstr(pk)
2952 }
2953 authors += "]"
2954 subID := "ap-batch-" + itoa(retryRound) + "-" + itoa(batchNum)
2955 batchNum++
2956 dom.PostToSW(buildProxyMsg(subID,
2957 "{\"authors\":"+authors+",\"kinds\":[0,10002],\"limit\":"+itoa(len(chunk)*2)+"}",
2958 proxy))
2959 // Direct query to feed relays.
2960 profileSubCounter++
2961 dom.PostToSW(buildProxyMsg("ap-d-"+itoa(profileSubCounter),
2962 "{\"authors\":"+authors+",\"kinds\":[0],\"limit\":"+itoa(len(chunk))+"}",
2963 relayURLs))
2964 }
2965 retryRound++
2966 }
2967
2968 // applyAuthorProfile updates cache and all pending note headers for a pubkey.
2969 func applyAuthorProfile(pk string, ev *nostr.Event) {
2970 if ev.CreatedAt <= authorTs[pk] {
2971 return
2972 }
2973 authorTs[pk] = ev.CreatedAt
2974 authorContent[pk] = ev.Content
2975 name := helpers.JsonGetString(ev.Content, "name")
2976 if name == "" {
2977 name = helpers.JsonGetString(ev.Content, "display_name")
2978 }
2979 pic := helpers.JsonGetString(ev.Content, "picture")
2980 if name != "" {
2981 authorNames[pk] = name
2982 }
2983 if pic != "" {
2984 authorPics[pk] = pic
2985 }
2986
2987 // Cache to IndexedDB.
2988 if name != "" || pic != "" {
2989 dom.IDBPut("profiles", pk, "{\"name\":\""+jsonEsc(name)+"\",\"picture\":\""+jsonEsc(pic)+"\"}")
2990 }
2991
2992 // Update logged-in user's header too.
2993 if pk == pubhex {
2994 if name != "" {
2995 profileName = name
2996 dom.SetTextContent(nameEl, name)
2997 }
2998 if pic != "" {
2999 profilePic = pic
3000 dom.SetAttribute(avatarEl, "src", pic)
3001 dom.SetStyle(avatarEl, "display", "block")
3002 }
3003 }
3004
3005 // Update all pending note headers.
3006 if headers, ok := pendingNotes[pk]; ok && name != "" {
3007 for _, h := range headers {
3008 updateNoteHeader(h, name, pic)
3009 }
3010 delete(pendingNotes, pk)
3011 }
3012
3013 // Update reply preview divs that were rendered with hex pubkey stubs.
3014 updateReplyPreviewsForAuthor(pk)
3015
3016 // Update inline nostr:npub/nprofile links that rendered with truncated hex.
3017 updateInlineProfileLinks(pk)
3018
3019 // Re-render profile page if viewing this author.
3020 if profileViewPK == pk {
3021 renderProfilePage(pk)
3022 }
3023 }
3024
3025 // updateNoteHeader fills in avatar+name on a note's author header div.
3026 func updateNoteHeader(header dom.Element, name, pic string) {
3027 // First child is <img>, second is <span>.
3028 img := dom.FirstChild(header)
3029 if img == 0 {
3030 return
3031 }
3032 span := dom.NextSibling(img)
3033 if pic != "" {
3034 dom.SetAttribute(img, "src", pic)
3035 dom.SetAttribute(img, "onerror", "this.style.display='none'")
3036 dom.SetStyle(img, "display", "")
3037 }
3038 if name != "" {
3039 dom.SetTextContent(span, name)
3040 }
3041 }
3042
3043 // --- Profile page ---
3044
3045 func showProfile(pk string) {
3046 profileViewPK = pk
3047
3048 // Ensure we have full kind 0 content. If not, fetch it.
3049 if _, ok := authorContent[pk]; !ok {
3050 fetchedK0[pk] = false
3051 fetchAuthorProfile(pk)
3052 }
3053
3054 // Push history BEFORE rendering. selectProfileTab does ReplaceState during
3055 // render, which overwrites the current entry — if we don't push first, that
3056 // ReplaceState clobbers the source page (feed/profile) we came from.
3057 if !navPop {
3058 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
3059 dom.PushState("/p/" + npub)
3060 }
3061
3062 renderProfilePage(pk)
3063
3064 // Use the author's name as page title, fall back to "profile".
3065 title := "profile"
3066 if name, ok := authorNames[pk]; ok && name != "" {
3067 title = name
3068 }
3069 activePage = "" // force switchPage to run
3070 switchPage("profile")
3071 dom.SetTextContent(pageTitleEl, title)
3072 }
3073
3074 func verifyNip05(nip05, pubkeyHex string, badge dom.Element) {
3075 at := -1
3076 for i := 0; i < len(nip05); i++ {
3077 if nip05[i] == '@' {
3078 at = i
3079 break
3080 }
3081 }
3082 if at < 1 || at >= len(nip05)-1 {
3083 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
3084 return
3085 }
3086 local := nip05[:at]
3087 domain := nip05[at+1:]
3088 url := "https://" + domain + "/.well-known/nostr.json?name=" + local
3089 dom.FetchText(url, func(body string) {
3090 if body == "" {
3091 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
3092 return
3093 }
3094 namesObj := helpers.JsonGetString(body, "names")
3095 if namesObj == "" {
3096 // names might be an object not a string — extract manually
3097 namesStart := -1
3098 key := "\"names\""
3099 for i := 0; i < len(body)-len(key); i++ {
3100 if body[i:i+len(key)] == key {
3101 namesStart = i + len(key)
3102 break
3103 }
3104 }
3105 if namesStart < 0 {
3106 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f")
3107 return
3108 }
3109 // Skip colon and whitespace to find the object
3110 for namesStart < len(body) && (body[namesStart] == ':' || body[namesStart] == ' ' || body[namesStart] == '\t') {
3111 namesStart++
3112 }
3113 if namesStart >= len(body) || body[namesStart] != '{' {
3114 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f")
3115 return
3116 }
3117 // Find matching brace
3118 depth := 0
3119 end := namesStart
3120 for end < len(body) {
3121 if body[end] == '{' {
3122 depth++
3123 } else if body[end] == '}' {
3124 depth--
3125 if depth == 0 {
3126 end++
3127 break
3128 }
3129 }
3130 end++
3131 }
3132 namesObj = body[namesStart:end]
3133 }
3134 got := helpers.JsonGetString(namesObj, local)
3135 if got == pubkeyHex {
3136 dom.SetTextContent(badge, "\xe2\x9c\x85") // ✅
3137 } else {
3138 dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
3139 }
3140 })
3141 }
3142
3143 func renderProfilePage(pk string) {
3144 savedTab := profileTab
3145 clearChildren(profilePage)
3146 closeProfileNoteSub()
3147 profileNotesSeen = map[string]bool{}
3148
3149 content := authorContent[pk]
3150 name := authorNames[pk]
3151 pic := authorPics[pk]
3152 about := helpers.JsonGetString(content, "about")
3153 website := helpers.JsonGetString(content, "website")
3154 nip05 := helpers.JsonGetString(content, "nip05")
3155 lud16 := helpers.JsonGetString(content, "lud16")
3156 banner := helpers.JsonGetString(content, "banner")
3157
3158 // Banner — full width, 200px, cover.
3159 if banner != "" {
3160 bannerEl := dom.CreateElement("img")
3161 dom.SetAttribute(bannerEl, "referrerpolicy", "no-referrer")
3162 dom.SetAttribute(bannerEl, "src", banner)
3163 dom.SetStyle(bannerEl, "width", "100%")
3164 dom.SetStyle(bannerEl, "height", "240px")
3165 dom.SetStyle(bannerEl, "objectFit", "cover")
3166 dom.SetStyle(bannerEl, "objectPosition", "center")
3167 dom.SetStyle(bannerEl, "display", "block")
3168 dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'")
3169 dom.AppendChild(profilePage, bannerEl)
3170 }
3171
3172 // User info card — glass effect, overlapping banner.
3173 card := dom.CreateElement("div")
3174 dom.SetStyle(card, "background", "color-mix(in srgb, var(--bg) 85%, transparent)")
3175 dom.SetStyle(card, "backdropFilter", "blur(8px)")
3176 dom.SetStyle(card, "borderRadius", "8px")
3177 dom.SetStyle(card, "padding", "16px")
3178 if banner != "" {
3179 dom.SetStyle(card, "margin", "-48px 16px 0")
3180 } else {
3181 dom.SetStyle(card, "margin", "16px")
3182 }
3183 dom.SetStyle(card, "position", "relative")
3184 dom.SetStyle(card, "width", "fit-content")
3185 dom.SetStyle(card, "maxWidth", "calc(100% - 32px)")
3186
3187 // Top row: avatar + info.
3188 topRow := dom.CreateElement("div")
3189 dom.SetStyle(topRow, "display", "flex")
3190 dom.SetStyle(topRow, "gap", "16px")
3191 dom.SetStyle(topRow, "alignItems", "flex-start")
3192
3193 // Compute npub early — needed for avatar QR click and npub row.
3194 npubBytes := helpers.HexDecode(pk)
3195 npubStr := helpers.EncodeNpub(npubBytes)
3196
3197 if pic != "" {
3198 av := dom.CreateElement("img")
3199 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
3200 dom.SetAttribute(av, "src", pic)
3201 dom.SetAttribute(av, "width", "64")
3202 dom.SetAttribute(av, "height", "64")
3203 dom.SetStyle(av, "borderRadius", "50%")
3204 dom.SetStyle(av, "objectFit", "cover")
3205 dom.SetStyle(av, "flexShrink", "0")
3206 dom.SetStyle(av, "border", "3px solid var(--bg)")
3207 dom.SetStyle(av, "cursor", "pointer")
3208 dom.SetAttribute(av, "onerror", "this.style.display='none'")
3209 avNpub := npubStr
3210 dom.AddEventListener(av, "click", dom.RegisterCallback(func() {
3211 showQRModal(avNpub)
3212 }))
3213 dom.AppendChild(topRow, av)
3214 }
3215
3216 info := dom.CreateElement("div")
3217 dom.SetStyle(info, "minWidth", "0")
3218 dom.SetStyle(info, "flex", "1")
3219 dom.SetStyle(info, "overflow", "hidden")
3220
3221 if name != "" {
3222 nameSpan := dom.CreateElement("div")
3223 dom.SetTextContent(nameSpan, name)
3224 dom.SetStyle(nameSpan, "fontSize", "20px")
3225 dom.SetStyle(nameSpan, "fontWeight", "bold")
3226 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
3227 dom.SetStyle(nameSpan, "cursor", "pointer")
3228 nameNpub := npubStr
3229 dom.AddEventListener(nameSpan, "click", dom.RegisterCallback(func() {
3230 showQRModal(nameNpub)
3231 }))
3232 dom.AppendChild(info, nameSpan)
3233 }
3234
3235 if nip05 != "" {
3236 nip05Row := dom.CreateElement("div")
3237 dom.SetStyle(nip05Row, "display", "flex")
3238 dom.SetStyle(nip05Row, "alignItems", "center")
3239 dom.SetStyle(nip05Row, "gap", "4px")
3240 nip05Text := dom.CreateElement("span")
3241 dom.SetTextContent(nip05Text, nip05)
3242 dom.SetStyle(nip05Text, "color", "var(--muted)")
3243 dom.SetStyle(nip05Text, "fontSize", "13px")
3244 dom.AppendChild(nip05Row, nip05Text)
3245 nip05Badge := dom.CreateElement("span")
3246 dom.SetStyle(nip05Badge, "fontSize", "14px")
3247 dom.AppendChild(nip05Row, nip05Badge)
3248 dom.AppendChild(info, nip05Row)
3249
3250 // Async NIP-05 validation.
3251 verifyNip05(nip05, pk, nip05Badge)
3252 }
3253
3254 // npub (full length) with copy + qr buttons.
3255 npubRow := dom.CreateElement("div")
3256 dom.SetStyle(npubRow, "display", "flex")
3257 dom.SetStyle(npubRow, "alignItems", "flex-start")
3258 dom.SetStyle(npubRow, "gap", "6px")
3259 dom.SetStyle(npubRow, "marginTop", "2px")
3260 npubEl := dom.CreateElement("span")
3261 dom.SetStyle(npubEl, "color", "var(--muted)")
3262 dom.SetStyle(npubEl, "fontSize", "12px")
3263 dom.SetStyle(npubEl, "wordBreak", "break-all")
3264 dom.SetTextContent(npubEl, npubStr)
3265 dom.AppendChild(npubRow, npubEl)
3266 copyBtn := dom.CreateElement("span")
3267 dom.SetTextContent(copyBtn, t("copy"))
3268 dom.SetStyle(copyBtn, "color", "var(--accent)")
3269 dom.SetStyle(copyBtn, "fontSize", "11px")
3270 dom.SetStyle(copyBtn, "cursor", "pointer")
3271 dom.SetAttribute(copyBtn, "data-npub", npubStr)
3272 dom.SetAttribute(copyBtn, "onclick", "var b=this;navigator.clipboard.writeText(b.dataset.npub).then(function(){b.textContent='copied!'});setTimeout(function(){b.textContent='copy'},1500)")
3273 dom.AppendChild(npubRow, copyBtn)
3274 qrBtn := dom.CreateElement("span")
3275 dom.SetTextContent(qrBtn, t("qr"))
3276 dom.SetStyle(qrBtn, "color", "var(--accent)")
3277 dom.SetStyle(qrBtn, "fontSize", "11px")
3278 dom.SetStyle(qrBtn, "cursor", "pointer")
3279 npubForQR := npubStr
3280 dom.AddEventListener(qrBtn, "click", dom.RegisterCallback(func() {
3281 showQRModal(npubForQR)
3282 }))
3283 dom.AppendChild(npubRow, qrBtn)
3284 dom.AppendChild(info, npubRow)
3285
3286 // Website + lightning inline.
3287 if website != "" || lud16 != "" {
3288 metaRow := dom.CreateElement("div")
3289 dom.SetStyle(metaRow, "display", "flex")
3290 dom.SetStyle(metaRow, "gap", "12px")
3291 dom.SetStyle(metaRow, "marginTop", "6px")
3292 dom.SetStyle(metaRow, "fontSize", "12px")
3293 if website != "" {
3294 wEl := dom.CreateElement("span")
3295 dom.SetStyle(wEl, "color", "var(--accent)")
3296 dom.SetStyle(wEl, "wordBreak", "break-all")
3297 dom.SetTextContent(wEl, website)
3298 dom.AppendChild(metaRow, wEl)
3299 }
3300 if lud16 != "" {
3301 lEl := dom.CreateElement("span")
3302 dom.SetStyle(lEl, "color", "var(--muted)")
3303 dom.SetStyle(lEl, "wordBreak", "break-all")
3304 dom.SetTextContent(lEl, "\xE2\x9A\xA1 "+lud16)
3305 dom.AppendChild(metaRow, lEl)
3306 }
3307 dom.AppendChild(info, metaRow)
3308 }
3309
3310 dom.AppendChild(topRow, info)
3311 dom.AppendChild(card, topRow)
3312
3313 // Message button — only for other users.
3314 if pk != pubhex {
3315 msgBtn := dom.CreateElement("button")
3316 dom.SetTextContent(msgBtn, t("message"))
3317 dom.SetStyle(msgBtn, "padding", "6px 16px")
3318 dom.SetStyle(msgBtn, "fontFamily", "'Fira Code', monospace")
3319 dom.SetStyle(msgBtn, "fontSize", "12px")
3320 dom.SetStyle(msgBtn, "background", "var(--accent)")
3321 dom.SetStyle(msgBtn, "color", "#fff")
3322 dom.SetStyle(msgBtn, "border", "none")
3323 dom.SetStyle(msgBtn, "borderRadius", "4px")
3324 dom.SetStyle(msgBtn, "cursor", "pointer")
3325 dom.SetStyle(msgBtn, "marginTop", "12px")
3326 peerPK := pk
3327 dom.AddEventListener(msgBtn, "click", dom.RegisterCallback(func() {
3328 switchPage("messaging")
3329 openThread(peerPK)
3330 }))
3331 dom.AppendChild(card, msgBtn)
3332 }
3333
3334 dom.AppendChild(profilePage, card)
3335
3336 // About/bio.
3337 if about != "" {
3338 aboutEl := dom.CreateElement("div")
3339 dom.SetStyle(aboutEl, "padding", "12px 16px")
3340 dom.SetStyle(aboutEl, "fontSize", "14px")
3341 dom.SetStyle(aboutEl, "lineHeight", "1.5")
3342 dom.SetStyle(aboutEl, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
3343 dom.SetStyle(aboutEl, "wordBreak", "break-word")
3344 aboutTruncated := len(about) > 300
3345 aboutText := about
3346 if aboutTruncated {
3347 aboutText = about[:300] + "..."
3348 }
3349 dom.SetInnerHTML(aboutEl, renderMarkdown(aboutText))
3350 dom.AppendChild(profilePage, aboutEl)
3351
3352 if aboutTruncated {
3353 more := dom.CreateElement("span")
3354 dom.SetTextContent(more, t("show_more"))
3355 dom.SetStyle(more, "color", "var(--accent)")
3356 dom.SetStyle(more, "cursor", "pointer")
3357 dom.SetStyle(more, "fontSize", "13px")
3358 dom.SetStyle(more, "display", "inline-block")
3359 dom.SetStyle(more, "padding", "0 16px 8px")
3360 aboutExpanded := false
3361 dom.AddEventListener(more, "click", dom.RegisterCallback(func() {
3362 aboutExpanded = !aboutExpanded
3363 if aboutExpanded {
3364 dom.SetInnerHTML(aboutEl, renderMarkdown(about))
3365 dom.SetTextContent(more, t("show_less"))
3366 } else {
3367 dom.SetInnerHTML(aboutEl, renderMarkdown(about[:300]+"..."))
3368 dom.SetTextContent(more, t("show_more"))
3369 }
3370 }))
3371 dom.AppendChild(profilePage, more)
3372 }
3373 }
3374
3375 // Tab bar.
3376 tabBar := dom.CreateElement("div")
3377 dom.SetStyle(tabBar, "display", "flex")
3378 dom.SetStyle(tabBar, "gap", "0")
3379 dom.SetStyle(tabBar, "margin", "0 16px")
3380 dom.SetStyle(tabBar, "border", "1px solid var(--border)")
3381 dom.SetStyle(tabBar, "borderRadius", "6px")
3382 dom.SetStyle(tabBar, "overflow", "hidden")
3383
3384 profileTabBtns = map[string]dom.Element{}
3385
3386 // Unrolled — tinyjs range/loop closure aliasing.
3387 tabNotes := makeProtoBtn(t("notes"))
3388 dom.SetStyle(tabNotes, "cursor", "pointer")
3389 profileTabBtns["notes"] = tabNotes
3390 tabNotesPK := pk
3391 dom.AddEventListener(tabNotes, "click", dom.RegisterCallback(func() {
3392 selectProfileTab("notes", tabNotesPK)
3393 }))
3394 dom.AppendChild(tabBar, tabNotes)
3395
3396 tabFollows := makeProtoBtn(t("follows"))
3397 dom.SetStyle(tabFollows, "cursor", "pointer")
3398 profileTabBtns["follows"] = tabFollows
3399 tabFollowsPK := pk
3400 dom.AddEventListener(tabFollows, "click", dom.RegisterCallback(func() {
3401 selectProfileTab("follows", tabFollowsPK)
3402 }))
3403 dom.AppendChild(tabBar, tabFollows)
3404
3405 tabRelays := makeProtoBtn(t("relays"))
3406 dom.SetStyle(tabRelays, "cursor", "pointer")
3407 profileTabBtns["relays"] = tabRelays
3408 tabRelaysPK := pk
3409 dom.AddEventListener(tabRelays, "click", dom.RegisterCallback(func() {
3410 selectProfileTab("relays", tabRelaysPK)
3411 }))
3412 dom.AppendChild(tabBar, tabRelays)
3413
3414 tabMutes := makeProtoBtn(t("mutes"))
3415 dom.SetStyle(tabMutes, "cursor", "pointer")
3416 profileTabBtns["mutes"] = tabMutes
3417 tabMutesPK := pk
3418 dom.AddEventListener(tabMutes, "click", dom.RegisterCallback(func() {
3419 selectProfileTab("mutes", tabMutesPK)
3420 }))
3421 dom.AppendChild(tabBar, tabMutes)
3422
3423 dom.AppendChild(profilePage, tabBar)
3424
3425 // Tab content container.
3426 profileTabContent = dom.CreateElement("div")
3427 dom.SetStyle(profileTabContent, "padding", "8px 0")
3428 dom.AppendChild(profilePage, profileTabContent)
3429
3430 // Restore or default tab.
3431 profileTab = ""
3432 if savedTab != "" {
3433 selectProfileTab(savedTab, pk)
3434 } else {
3435 selectProfileTab("notes", pk)
3436 }
3437
3438 // Update title.
3439 if name != "" && activePage == "profile" {
3440 dom.SetTextContent(pageTitleEl, name)
3441 }
3442 }
3443
3444 func profileMetaRow(icon, text, link string) dom.Element {
3445 row := dom.CreateElement("div")
3446 dom.SetStyle(row, "padding", "4px 0")
3447 dom.SetStyle(row, "display", "flex")
3448 dom.SetStyle(row, "alignItems", "center")
3449 dom.SetStyle(row, "gap", "8px")
3450
3451 iconEl := dom.CreateElement("span")
3452 dom.SetTextContent(iconEl, icon)
3453 dom.AppendChild(row, iconEl)
3454
3455 if link != "" {
3456 href := link
3457 if strIndex(href, "://") < 0 {
3458 href = "https://" + href
3459 }
3460 a := dom.CreateElement("a")
3461 dom.SetAttribute(a, "href", href)
3462 dom.SetAttribute(a, "target", "_blank")
3463 dom.SetAttribute(a, "rel", "noopener")
3464 dom.SetStyle(a, "color", "var(--accent)")
3465 dom.SetStyle(a, "wordBreak", "break-all")
3466 dom.SetTextContent(a, text)
3467 dom.AppendChild(row, a)
3468 } else {
3469 span := dom.CreateElement("span")
3470 dom.SetStyle(span, "color", "var(--fg)")
3471 dom.SetTextContent(span, text)
3472 dom.AppendChild(row, span)
3473 }
3474 return row
3475 }
3476
3477 // --- Profile tab functions ---
3478
3479 func closeProfileNoteSub() {
3480 if activeProfileNoteSub != "" {
3481 dom.PostToSW("[\"CLOSE\"," + jstr(activeProfileNoteSub) + "]")
3482 activeProfileNoteSub = ""
3483 }
3484 }
3485
3486 // refreshProfileTab re-renders the active tab if we're viewing this author's profile.
3487 func refreshProfileTab(pk string) {
3488 if profileViewPK != pk || profileTab == "" {
3489 return
3490 }
3491 // Force re-render by clearing current tab and re-selecting.
3492 saved := profileTab
3493 profileTab = ""
3494 selectProfileTab(saved, pk)
3495 }
3496
3497 func selectProfileTab(tab, pk string) {
3498 if tab == profileTab {
3499 return
3500 }
3501 closeProfileNoteSub()
3502 profileTab = tab
3503 clearChildren(profileTabContent)
3504
3505 for id, btn := range profileTabBtns {
3506 if id == tab {
3507 dom.SetStyle(btn, "background", "var(--accent)")
3508 dom.SetStyle(btn, "color", "#fff")
3509 } else {
3510 dom.SetStyle(btn, "background", "transparent")
3511 dom.SetStyle(btn, "color", "var(--fg)")
3512 }
3513 }
3514
3515 // Update URL hash to reflect active tab.
3516 if !navPop && profileViewPK != "" {
3517 npub := helpers.EncodeNpub(helpers.HexDecode(profileViewPK))
3518 dom.ReplaceState("/p/" + npub + "#" + tab)
3519 }
3520
3521 switch tab {
3522 case "notes":
3523 renderProfileNotes(pk)
3524 case "follows":
3525 renderProfileFollows(pk)
3526 case "relays":
3527 renderProfileRelays(pk)
3528 case "mutes":
3529 renderProfileMutes(pk)
3530 }
3531 }
3532
3533 func renderProfileNotes(pk string) {
3534 profileNotesSeen = map[string]bool{}
3535 profileSubCounter++
3536 subID := "pn-" + itoa(profileSubCounter)
3537 activeProfileNoteSub = subID
3538 proxyRelays := buildProxy(pk)
3539 dom.PostToSW(buildProxyMsg(subID,
3540 "{\"authors\":["+jstr(pk)+"],\"kinds\":[1],\"limit\":20}",
3541 proxyRelays))
3542 }
3543
3544 func renderProfileNote(ev *nostr.Event) {
3545 if profileTabContent == 0 || profileTab != "notes" {
3546 return
3547 }
3548 note := dom.CreateElement("div")
3549 dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
3550 dom.SetStyle(note, "padding", "12px 16px")
3551
3552 // Timestamp — right-aligned, opens thread view.
3553 if ev.CreatedAt > 0 {
3554 tsEl := dom.CreateElement("div")
3555 dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
3556 dom.SetStyle(tsEl, "color", "var(--muted)")
3557 dom.SetStyle(tsEl, "fontSize", "11px")
3558 dom.SetStyle(tsEl, "cursor", "pointer")
3559 dom.SetStyle(tsEl, "textAlign", "right")
3560 dom.SetStyle(tsEl, "maxWidth", "65ch")
3561 dom.SetStyle(tsEl, "marginBottom", "4px")
3562 evID := ev.ID
3563 evRootID := getRootID(ev)
3564 if evRootID == "" {
3565 evRootID = evID
3566 }
3567 dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
3568 showNoteThread(evRootID, evID)
3569 }))
3570 dom.AppendChild(note, tsEl)
3571 }
3572
3573 // Reply preview.
3574 addReplyPreview(note, ev)
3575
3576 content := dom.CreateElement("div")
3577 dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
3578 dom.SetStyle(content, "fontSize", "14px")
3579 dom.SetStyle(content, "lineHeight", "1.5")
3580 dom.SetStyle(content, "wordBreak", "break-word")
3581 dom.SetStyle(content, "maxWidth", "65ch")
3582 text := ev.Content
3583 truncated := len(text) > 500
3584 if truncated {
3585 text = text[:500] + "..."
3586 }
3587 dom.SetInnerHTML(content, renderMarkdown(text))
3588 resolveEmbeds()
3589 dom.AppendChild(note, content)
3590
3591 if truncated {
3592 more := dom.CreateElement("span")
3593 dom.SetTextContent(more, t("show_more"))
3594 dom.SetStyle(more, "color", "var(--accent)")
3595 dom.SetStyle(more, "cursor", "pointer")
3596 dom.SetStyle(more, "fontSize", "13px")
3597 dom.SetStyle(more, "display", "inline-block")
3598 dom.SetStyle(more, "marginTop", "4px")
3599 fullContent := ev.Content
3600 expanded := false
3601 dom.AddEventListener(more, "click", dom.RegisterCallback(func() {
3602 expanded = !expanded
3603 if expanded {
3604 dom.SetInnerHTML(content, renderMarkdown(fullContent))
3605 resolveEmbeds()
3606 dom.SetTextContent(more, t("show_less"))
3607 } else {
3608 dom.SetInnerHTML(content, renderMarkdown(fullContent[:500]+"..."))
3609 resolveEmbeds()
3610 dom.SetTextContent(more, t("show_more"))
3611 }
3612 }))
3613 dom.AppendChild(note, more)
3614 }
3615
3616 dom.AppendChild(profileTabContent, note)
3617 }
3618
3619 func renderProfileFollows(pk string) {
3620 follows, ok := authorFollows[pk]
3621 if !ok || len(follows) == 0 {
3622 empty := dom.CreateElement("div")
3623 dom.SetTextContent(empty, t("no_follows"))
3624 dom.SetStyle(empty, "padding", "16px")
3625 dom.SetStyle(empty, "color", "var(--muted)")
3626 dom.SetStyle(empty, "fontSize", "13px")
3627 dom.AppendChild(profileTabContent, empty)
3628 return
3629 }
3630
3631 countEl := dom.CreateElement("div")
3632 dom.SetTextContent(countEl, itoa(len(follows))+" "+t("following"))
3633 dom.SetStyle(countEl, "padding", "8px 16px")
3634 dom.SetStyle(countEl, "color", "var(--muted)")
3635 dom.SetStyle(countEl, "fontSize", "12px")
3636 dom.AppendChild(profileTabContent, countEl)
3637
3638 for i := 0; i < len(follows); i++ {
3639 fpk := follows[i]
3640 row := makeProfileRow(fpk)
3641 dom.AppendChild(profileTabContent, row)
3642 }
3643 scheduleTabRetry()
3644 }
3645
3646 func renderProfileRelays(pk string) {
3647 relays, ok := authorRelays[pk]
3648 if !ok || len(relays) == 0 {
3649 empty := dom.CreateElement("div")
3650 dom.SetTextContent(empty, t("no_relays"))
3651 dom.SetStyle(empty, "padding", "16px")
3652 dom.SetStyle(empty, "color", "var(--muted)")
3653 dom.SetStyle(empty, "fontSize", "13px")
3654 dom.AppendChild(profileTabContent, empty)
3655 return
3656 }
3657
3658 for i := 0; i < len(relays); i++ {
3659 rURL := relays[i]
3660 row := dom.CreateElement("div")
3661 dom.SetStyle(row, "padding", "10px 16px")
3662 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
3663 dom.SetStyle(row, "cursor", "pointer")
3664 dom.SetStyle(row, "fontSize", "13px")
3665
3666 urlEl := dom.CreateElement("span")
3667 dom.SetTextContent(urlEl, rURL)
3668 dom.SetStyle(urlEl, "color", "var(--accent)")
3669 dom.AppendChild(row, urlEl)
3670
3671 clickURL := rURL
3672 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
3673 showRelayInfo(clickURL)
3674 }))
3675 dom.AppendChild(profileTabContent, row)
3676 }
3677 }
3678
3679 func renderProfileMutes(pk string) {
3680 mutes, ok := authorMutes[pk]
3681 if !ok || len(mutes) == 0 {
3682 empty := dom.CreateElement("div")
3683 dom.SetTextContent(empty, t("no_mutes"))
3684 dom.SetStyle(empty, "padding", "16px")
3685 dom.SetStyle(empty, "color", "var(--muted)")
3686 dom.SetStyle(empty, "fontSize", "13px")
3687 dom.AppendChild(profileTabContent, empty)
3688 return
3689 }
3690
3691 countEl := dom.CreateElement("div")
3692 dom.SetTextContent(countEl, itoa(len(mutes))+" "+t("muted"))
3693 dom.SetStyle(countEl, "padding", "8px 16px")
3694 dom.SetStyle(countEl, "color", "var(--muted)")
3695 dom.SetStyle(countEl, "fontSize", "12px")
3696 dom.AppendChild(profileTabContent, countEl)
3697
3698 for i := 0; i < len(mutes); i++ {
3699 mpk := mutes[i]
3700 row := makeProfileRow(mpk)
3701 dom.AppendChild(profileTabContent, row)
3702 }
3703 scheduleTabRetry()
3704 }
3705
3706 func makeProfileRow(pk string) dom.Element {
3707 row := dom.CreateElement("div")
3708 dom.SetStyle(row, "display", "flex")
3709 dom.SetStyle(row, "alignItems", "center")
3710 dom.SetStyle(row, "gap", "10px")
3711 dom.SetStyle(row, "padding", "10px 16px")
3712 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
3713 dom.SetStyle(row, "cursor", "pointer")
3714
3715 av := dom.CreateElement("img")
3716 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
3717 dom.SetAttribute(av, "width", "32")
3718 dom.SetAttribute(av, "height", "32")
3719 dom.SetStyle(av, "borderRadius", "50%")
3720 dom.SetStyle(av, "objectFit", "cover")
3721 dom.SetStyle(av, "flexShrink", "0")
3722 if pic, ok := authorPics[pk]; ok && pic != "" {
3723 dom.SetAttribute(av, "src", pic)
3724 } else {
3725 dom.SetStyle(av, "display", "none")
3726 }
3727 dom.SetAttribute(av, "onerror", "this.style.display='none'")
3728 dom.AppendChild(row, av)
3729
3730 nameSpan := dom.CreateElement("span")
3731 dom.SetStyle(nameSpan, "fontSize", "14px")
3732 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
3733 if name, ok := authorNames[pk]; ok && name != "" {
3734 dom.SetTextContent(nameSpan, name)
3735 } else {
3736 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
3737 if len(npub) > 20 {
3738 dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:])
3739 }
3740 }
3741 dom.AppendChild(row, nameSpan)
3742
3743 rowPK := pk
3744 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
3745 showProfile(rowPK)
3746 }))
3747
3748 if _, cached := authorNames[pk]; !cached {
3749 pendingNotes[pk] = append(pendingNotes[pk], row)
3750 if !fetchedK0[pk] {
3751 queueProfileFetch(pk)
3752 }
3753 }
3754
3755 return row
3756 }
3757
3758 // --- Relay info page ---
3759
3760 func showRelayInfo(url string) {
3761 profileViewPK = ""
3762 closeProfileNoteSub()
3763 clearChildren(profilePage)
3764
3765 hdr := dom.CreateElement("div")
3766 dom.SetStyle(hdr, "display", "flex")
3767 dom.SetStyle(hdr, "alignItems", "center")
3768 dom.SetStyle(hdr, "gap", "10px")
3769 dom.SetStyle(hdr, "padding", "16px")
3770 dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
3771
3772 backBtn := dom.CreateElement("button")
3773 dom.SetInnerHTML(backBtn, "←")
3774 dom.SetStyle(backBtn, "background", "none")
3775 dom.SetStyle(backBtn, "border", "none")
3776 dom.SetStyle(backBtn, "fontSize", "20px")
3777 dom.SetStyle(backBtn, "cursor", "pointer")
3778 dom.SetStyle(backBtn, "color", "var(--fg)")
3779 dom.SetStyle(backBtn, "padding", "0")
3780 dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() {
3781 switchPage("feed")
3782 }))
3783 dom.AppendChild(hdr, backBtn)
3784
3785 urlEl := dom.CreateElement("span")
3786 dom.SetTextContent(urlEl, url)
3787 dom.SetStyle(urlEl, "fontWeight", "bold")
3788 dom.SetStyle(urlEl, "fontSize", "14px")
3789 dom.SetStyle(urlEl, "wordBreak", "break-all")
3790 dom.AppendChild(hdr, urlEl)
3791 dom.AppendChild(profilePage, hdr)
3792
3793 loading := dom.CreateElement("div")
3794 dom.SetTextContent(loading, t("loading"))
3795 dom.SetStyle(loading, "padding", "16px")
3796 dom.SetStyle(loading, "color", "var(--muted)")
3797 dom.AppendChild(profilePage, loading)
3798
3799 activePage = ""
3800 switchPage("profile")
3801 dom.SetTextContent(pageTitleEl, t("relay_info"))
3802
3803 // Convert wss→https for NIP-11 HTTP fetch.
3804 httpURL := url
3805 if len(httpURL) > 6 && httpURL[:6] == "wss://" {
3806 httpURL = "https://" + httpURL[6:]
3807 } else if len(httpURL) > 5 && httpURL[:5] == "ws://" {
3808 httpURL = "http://" + httpURL[5:]
3809 }
3810
3811 dom.FetchRelayInfo(httpURL, func(body string) {
3812 dom.RemoveChild(profilePage, loading)
3813 if body == "" {
3814 errEl := dom.CreateElement("div")
3815 dom.SetTextContent(errEl, t("relay_fail"))
3816 dom.SetStyle(errEl, "padding", "16px")
3817 dom.SetStyle(errEl, "color", "#e55")
3818 dom.AppendChild(profilePage, errEl)
3819 return
3820 }
3821 renderRelayInfoBody(body)
3822 })
3823 }
3824
3825 func renderRelayInfoBody(body string) {
3826 container := dom.CreateElement("div")
3827 dom.SetStyle(container, "padding", "16px")
3828
3829 name := helpers.JsonGetString(body, "name")
3830 desc := helpers.JsonGetString(body, "description")
3831 pk := helpers.JsonGetString(body, "pubkey")
3832 contact := helpers.JsonGetString(body, "contact")
3833 software := helpers.JsonGetString(body, "software")
3834 ver := helpers.JsonGetString(body, "version")
3835
3836 if name != "" {
3837 el := dom.CreateElement("div")
3838 dom.SetTextContent(el, name)
3839 dom.SetStyle(el, "fontSize", "20px")
3840 dom.SetStyle(el, "fontWeight", "bold")
3841 dom.SetStyle(el, "marginBottom", "8px")
3842 dom.SetStyle(el, "fontFamily", "system-ui, sans-serif")
3843 dom.AppendChild(container, el)
3844 }
3845
3846 if desc != "" {
3847 el := dom.CreateElement("div")
3848 dom.SetInnerHTML(el, renderMarkdown(desc))
3849 dom.SetStyle(el, "fontSize", "14px")
3850 dom.SetStyle(el, "lineHeight", "1.5")
3851 dom.SetStyle(el, "marginBottom", "12px")
3852 dom.SetStyle(el, "wordBreak", "break-word")
3853 dom.AppendChild(container, el)
3854 }
3855
3856 if contact != "" {
3857 dom.AppendChild(container, profileMetaRow("@", contact, ""))
3858 }
3859 if pk != "" {
3860 npub := helpers.EncodeNpub(helpers.HexDecode(pk))
3861 short := npub
3862 if len(short) > 20 {
3863 short = short[:16] + "..." + short[len(short)-8:]
3864 }
3865 dom.AppendChild(container, profileMetaRow("pk", short, ""))
3866 }
3867 if software != "" {
3868 label := software
3869 if ver != "" {
3870 label += " " + ver
3871 }
3872 dom.AppendChild(container, profileMetaRow("sw", label, ""))
3873 }
3874
3875 dom.AppendChild(profilePage, container)
3876 }
3877
3878 // --- Messaging ---
3879
3880 func relayURLsJSON() string {
3881 msg := "["
3882 for i, url := range relayURLs {
3883 if i > 0 {
3884 msg += ","
3885 }
3886 msg += jstr(url)
3887 }
3888 return msg + "]"
3889 }
3890
3891 func formatTime(ts int64) string {
3892 if ts == 0 {
3893 return ""
3894 }
3895 secs := ts % 86400
3896 h := itoa(int(secs / 3600))
3897 m := itoa(int((secs % 3600) / 60))
3898 if len(h) < 2 {
3899 h = "0" + h
3900 }
3901 if len(m) < 2 {
3902 m = "0" + m
3903 }
3904 return h + ":" + m
3905 }
3906
3907 // --- Settings page ---
3908
3909 func renderSettings() {
3910 clearChildren(settingsPage)
3911
3912 title := dom.CreateElement("h2")
3913 dom.SetTextContent(title, t("settings_title"))
3914 dom.SetStyle(title, "fontSize", "20px")
3915 dom.SetStyle(title, "marginBottom", "24px")
3916 dom.AppendChild(settingsPage, title)
3917
3918 // Language selector.
3919 langRow := dom.CreateElement("div")
3920 dom.SetStyle(langRow, "display", "flex")
3921 dom.SetStyle(langRow, "alignItems", "center")
3922 dom.SetStyle(langRow, "gap", "12px")
3923 dom.SetStyle(langRow, "marginBottom", "16px")
3924
3925 langLabel := dom.CreateElement("span")
3926 dom.SetTextContent(langLabel, t("lang_label"))
3927 dom.SetStyle(langLabel, "fontSize", "14px")
3928 dom.SetStyle(langLabel, "minWidth", "140px")
3929 dom.AppendChild(langRow, langLabel)
3930
3931 langSel := dom.CreateElement("select")
3932 dom.SetStyle(langSel, "fontFamily", "'Fira Code', monospace")
3933 dom.SetStyle(langSel, "fontSize", "13px")
3934 dom.SetStyle(langSel, "background", "var(--bg2)")
3935 dom.SetStyle(langSel, "color", "var(--fg)")
3936 dom.SetStyle(langSel, "border", "1px solid var(--border)")
3937 dom.SetStyle(langSel, "borderRadius", "4px")
3938 dom.SetStyle(langSel, "padding", "6px 12px")
3939 for code, name := range langNames {
3940 opt := dom.CreateElement("option")
3941 dom.SetAttribute(opt, "value", code)
3942 dom.SetTextContent(opt, name)
3943 if code == currentLang {
3944 dom.SetAttribute(opt, "selected", "selected")
3945 }
3946 dom.AppendChild(langSel, opt)
3947 }
3948 dom.AddEventListener(langSel, "change", dom.RegisterCallback(func() {
3949 val := dom.GetProperty(langSel, "value")
3950 setLang(val)
3951 // Re-render settings page with new language, update page title.
3952 dom.SetTextContent(pageTitleEl, t("settings"))
3953 renderSettings()
3954 }))
3955 dom.AppendChild(langRow, langSel)
3956 dom.AppendChild(settingsPage, langRow)
3957
3958 // Theme selector.
3959 themeRow := dom.CreateElement("div")
3960 dom.SetStyle(themeRow, "display", "flex")
3961 dom.SetStyle(themeRow, "alignItems", "center")
3962 dom.SetStyle(themeRow, "gap", "12px")
3963 dom.SetStyle(themeRow, "marginBottom", "16px")
3964
3965 themeLabel := dom.CreateElement("span")
3966 dom.SetTextContent(themeLabel, t("theme_label"))
3967 dom.SetStyle(themeLabel, "fontSize", "14px")
3968 dom.SetStyle(themeLabel, "minWidth", "140px")
3969 dom.AppendChild(themeRow, themeLabel)
3970
3971 themeToggle := dom.CreateElement("button")
3972 if isDark {
3973 dom.SetTextContent(themeToggle, t("dark"))
3974 } else {
3975 dom.SetTextContent(themeToggle, t("light"))
3976 }
3977 dom.SetStyle(themeToggle, "fontFamily", "'Fira Code', monospace")
3978 dom.SetStyle(themeToggle, "fontSize", "13px")
3979 dom.SetStyle(themeToggle, "background", "var(--bg2)")
3980 dom.SetStyle(themeToggle, "color", "var(--fg)")
3981 dom.SetStyle(themeToggle, "border", "1px solid var(--border)")
3982 dom.SetStyle(themeToggle, "borderRadius", "4px")
3983 dom.SetStyle(themeToggle, "padding", "6px 16px")
3984 dom.SetStyle(themeToggle, "cursor", "pointer")
3985 dom.AddEventListener(themeToggle, "click", dom.RegisterCallback(func() {
3986 toggleTheme()
3987 if isDark {
3988 dom.SetTextContent(themeToggle, t("dark"))
3989 } else {
3990 dom.SetTextContent(themeToggle, t("light"))
3991 }
3992 }))
3993 dom.AppendChild(themeRow, themeToggle)
3994 dom.AppendChild(settingsPage, themeRow)
3995 }
3996
3997 // --- Messaging ---
3998
3999 func initMessaging() {
4000 // Render new-chat button immediately — don't wait for DM_LIST round-trip.
4001 clearChildren(msgListContainer)
4002
4003 if !signer.HasSigner() {
4004 notice := dom.CreateElement("div")
4005 dom.SetStyle(notice, "padding", "24px")
4006 dom.SetStyle(notice, "textAlign", "center")
4007 dom.SetStyle(notice, "color", "var(--muted)")
4008 dom.SetStyle(notice, "fontSize", "13px")
4009 dom.SetStyle(notice, "lineHeight", "1.6")
4010 dom.SetInnerHTML(notice, t("dm_notice"))
4011 dom.AppendChild(msgListContainer, notice)
4012 return
4013 }
4014
4015 renderNewChatButton()
4016
4017 // Request conversation list from cache (will re-render below the button).
4018 dom.PostToSW("[\"DM_LIST\"]")
4019
4020 // Init MLS if not already done. publishKP + subscribe auto-bootstrap inside signer.
4021 if !marmotInited {
4022 marmotInited = true
4023 dom.PostToSW("[\"MLS_INIT\"," + relayURLsJSON() + "]")
4024 }
4025 }
4026
4027 func renderNewChatButton() {
4028 newBtn := dom.CreateElement("button")
4029 dom.SetTextContent(newBtn, t("new_chat"))
4030 dom.SetStyle(newBtn, "display", "block")
4031 dom.SetStyle(newBtn, "width", "100%")
4032 dom.SetStyle(newBtn, "padding", "10px")
4033 dom.SetStyle(newBtn, "marginBottom", "8px")
4034 dom.SetStyle(newBtn, "fontFamily", "'Fira Code', monospace")
4035 dom.SetStyle(newBtn, "fontSize", "13px")
4036 dom.SetStyle(newBtn, "background", "var(--bg2)")
4037 dom.SetStyle(newBtn, "border", "1px solid var(--border)")
4038 dom.SetStyle(newBtn, "borderRadius", "6px")
4039 dom.SetStyle(newBtn, "color", "var(--accent)")
4040 dom.SetStyle(newBtn, "cursor", "pointer")
4041 dom.SetStyle(newBtn, "textAlign", "left")
4042 dom.AddEventListener(newBtn, "click", dom.RegisterCallback(func() {
4043 showNewChatInput()
4044 }))
4045 dom.AppendChild(msgListContainer, newBtn)
4046 }
4047
4048 func renderConversationList(listJSON string) {
4049 if msgView != "list" {
4050 return
4051 }
4052 clearChildren(msgListContainer)
4053 renderNewChatButton()
4054
4055 // Parse the list JSON array: [{peer,lastMessage,lastTs,from}, ...]
4056 if listJSON == "" || listJSON == "[]" {
4057 empty := dom.CreateElement("div")
4058 dom.SetStyle(empty, "color", "var(--muted)")
4059 dom.SetStyle(empty, "textAlign", "center")
4060 dom.SetStyle(empty, "marginTop", "48px")
4061 dom.SetTextContent(empty, t("no_convos"))
4062 dom.AppendChild(msgListContainer, empty)
4063 return
4064 }
4065
4066 // Walk the JSON array manually — each element is an object.
4067 i := 0
4068 for i < len(listJSON) && listJSON[i] != '[' {
4069 i++
4070 }
4071 i++ // skip '['
4072 for i < len(listJSON) {
4073 // Find next object.
4074 for i < len(listJSON) && listJSON[i] != '{' {
4075 if listJSON[i] == ']' {
4076 return
4077 }
4078 i++
4079 }
4080 if i >= len(listJSON) {
4081 break
4082 }
4083 // Extract the object.
4084 objStart := i
4085 depth := 0
4086 for i < len(listJSON) {
4087 if listJSON[i] == '{' {
4088 depth++
4089 } else if listJSON[i] == '}' {
4090 depth--
4091 if depth == 0 {
4092 i++
4093 break
4094 }
4095 } else if listJSON[i] == '"' {
4096 i++
4097 for i < len(listJSON) && listJSON[i] != '"' {
4098 if listJSON[i] == '\\' {
4099 i++
4100 }
4101 i++
4102 }
4103 }
4104 i++
4105 }
4106 obj := listJSON[objStart:i]
4107
4108 peer := helpers.JsonGetString(obj, "peer")
4109 lastMsg := helpers.JsonGetString(obj, "lastMessage")
4110 lastTs := jsonGetNum(obj, "lastTs")
4111 if peer == "" {
4112 continue
4113 }
4114
4115 renderConversationRow(peer, lastMsg, lastTs)
4116 }
4117 }
4118
4119 func renderConversationRow(peer, lastMsg string, lastTs int64) {
4120 row := dom.CreateElement("div")
4121 dom.SetStyle(row, "display", "flex")
4122 dom.SetStyle(row, "alignItems", "center")
4123 dom.SetStyle(row, "gap", "10px")
4124 dom.SetStyle(row, "padding", "10px 4px")
4125 dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
4126 dom.SetStyle(row, "cursor", "pointer")
4127
4128 // Avatar.
4129 av := dom.CreateElement("img")
4130 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
4131 dom.SetAttribute(av, "width", "32")
4132 dom.SetAttribute(av, "height", "32")
4133 dom.SetStyle(av, "borderRadius", "50%")
4134 dom.SetStyle(av, "objectFit", "cover")
4135 dom.SetStyle(av, "flexShrink", "0")
4136 if pic, ok := authorPics[peer]; ok && pic != "" {
4137 dom.SetAttribute(av, "src", pic)
4138 } else {
4139 dom.SetStyle(av, "background", "var(--bg2)")
4140 }
4141 dom.SetAttribute(av, "onerror", "this.style.display='none'")
4142 dom.AppendChild(row, av)
4143
4144 // Name + preview column.
4145 col := dom.CreateElement("div")
4146 dom.SetStyle(col, "flex", "1")
4147 dom.SetStyle(col, "minWidth", "0")
4148
4149 nameSpan := dom.CreateElement("div")
4150 dom.SetStyle(nameSpan, "fontSize", "14px")
4151 dom.SetStyle(nameSpan, "fontWeight", "bold")
4152 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
4153 dom.SetStyle(nameSpan, "overflow", "hidden")
4154 dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
4155 dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
4156 if name, ok := authorNames[peer]; ok && name != "" {
4157 dom.SetTextContent(nameSpan, name)
4158 } else {
4159 npub := helpers.EncodeNpub(helpers.HexDecode(peer))
4160 if len(npub) > 20 {
4161 dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:])
4162 } else {
4163 dom.SetTextContent(nameSpan, npub)
4164 }
4165 }
4166 dom.AppendChild(col, nameSpan)
4167
4168 preview := dom.CreateElement("div")
4169 dom.SetStyle(preview, "fontSize", "12px")
4170 dom.SetStyle(preview, "color", "var(--muted)")
4171 dom.SetStyle(preview, "overflow", "hidden")
4172 dom.SetStyle(preview, "textOverflow", "ellipsis")
4173 dom.SetStyle(preview, "whiteSpace", "nowrap")
4174 if len(lastMsg) > 80 {
4175 lastMsg = lastMsg[:80] + "..."
4176 }
4177 dom.SetTextContent(preview, lastMsg)
4178 dom.AppendChild(col, preview)
4179 dom.AppendChild(row, col)
4180
4181 // Timestamp.
4182 if lastTs > 0 {
4183 tsSpan := dom.CreateElement("span")
4184 dom.SetStyle(tsSpan, "fontSize", "11px")
4185 dom.SetStyle(tsSpan, "color", "var(--muted)")
4186 dom.SetStyle(tsSpan, "flexShrink", "0")
4187 dom.SetTextContent(tsSpan, formatTime(lastTs))
4188 dom.AppendChild(row, tsSpan)
4189 }
4190
4191 rowPeer := peer
4192 dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
4193 openThread(rowPeer)
4194 }))
4195
4196 // Lazy profile fetch — batched to avoid 50+ simultaneous subscriptions.
4197 if _, cached := authorNames[peer]; !cached && !fetchedK0[peer] {
4198 queueProfileFetch(peer)
4199 }
4200
4201 dom.AppendChild(msgListContainer, row)
4202 }
4203
4204 func showNewChatInput() {
4205 // Check if input row already exists (first child after button).
4206 fc := dom.FirstChild(msgListContainer)
4207 if fc != 0 {
4208 ns := dom.NextSibling(fc)
4209 if ns != 0 {
4210 tag := dom.GetProperty(ns, "tagName")
4211 if tag == "DIV" {
4212 id := dom.GetProperty(ns, "id")
4213 if id == "new-chat-row" {
4214 return // already showing
4215 }
4216 }
4217 }
4218 }
4219
4220 inputRow := dom.CreateElement("div")
4221 dom.SetAttribute(inputRow, "id", "new-chat-row")
4222 dom.SetStyle(inputRow, "display", "flex")
4223 dom.SetStyle(inputRow, "gap", "8px")
4224 dom.SetStyle(inputRow, "marginBottom", "8px")
4225
4226 inp := dom.CreateElement("input")
4227 dom.SetAttribute(inp, "type", "text")
4228 dom.SetAttribute(inp, "placeholder", t("npub_placeholder"))
4229 dom.SetStyle(inp, "flex", "1")
4230 dom.SetStyle(inp, "padding", "8px")
4231 dom.SetStyle(inp, "fontFamily", "'Fira Code', monospace")
4232 dom.SetStyle(inp, "fontSize", "12px")
4233 dom.SetStyle(inp, "background", "var(--bg)")
4234 dom.SetStyle(inp, "border", "1px solid var(--border)")
4235 dom.SetStyle(inp, "borderRadius", "4px")
4236 dom.SetStyle(inp, "color", "var(--fg)")
4237
4238 goBtn := dom.CreateElement("button")
4239 dom.SetTextContent(goBtn, t("go"))
4240 dom.SetStyle(goBtn, "padding", "8px 16px")
4241 dom.SetStyle(goBtn, "fontFamily", "'Fira Code', monospace")
4242 dom.SetStyle(goBtn, "fontSize", "12px")
4243 dom.SetStyle(goBtn, "background", "var(--accent)")
4244 dom.SetStyle(goBtn, "color", "#fff")
4245 dom.SetStyle(goBtn, "border", "none")
4246 dom.SetStyle(goBtn, "borderRadius", "4px")
4247 dom.SetStyle(goBtn, "cursor", "pointer")
4248
4249 submitNewChat := func() {
4250 val := dom.GetProperty(inp, "value")
4251 if val == "" {
4252 return
4253 }
4254 var hexPK string
4255 if len(val) == 64 {
4256 // Assume hex pubkey.
4257 hexPK = val
4258 } else if len(val) > 4 && val[:4] == "npub" {
4259 decoded := helpers.DecodeNpub(val)
4260 if decoded == nil {
4261 return
4262 }
4263 hexPK = helpers.HexEncode(decoded)
4264 } else {
4265 return
4266 }
4267 openThread(hexPK)
4268 }
4269
4270 dom.AddEventListener(goBtn, "click", dom.RegisterCallback(submitNewChat))
4271 // Enter key triggers the "go" button via inline handler.
4272 dom.SetAttribute(inp, "onkeydown", "if(event.key==='Enter'){event.preventDefault();this.nextSibling.click()}")
4273
4274 dom.AppendChild(inputRow, inp)
4275 dom.AppendChild(inputRow, goBtn)
4276
4277 // Insert after the "new chat" button.
4278 btn := dom.FirstChild(msgListContainer)
4279 if btn != 0 {
4280 ns := dom.NextSibling(btn)
4281 if ns != 0 {
4282 dom.InsertBefore(msgListContainer, inputRow, ns)
4283 } else {
4284 dom.AppendChild(msgListContainer, inputRow)
4285 }
4286 } else {
4287 dom.AppendChild(msgListContainer, inputRow)
4288 }
4289 }
4290
4291 func openThread(peer string) {
4292 msgCurrentPeer = peer
4293 msgView = "thread"
4294
4295 if !navPop {
4296 npub := helpers.EncodeNpub(helpers.HexDecode(peer))
4297 dom.PushState("/msg/" + npub)
4298 }
4299
4300 // Hide list, show thread.
4301 dom.SetStyle(msgListContainer, "display", "none")
4302 dom.SetStyle(msgThreadContainer, "display", "flex")
4303
4304 // Build thread UI.
4305 clearChildren(msgThreadContainer)
4306
4307 // Header: back + avatar + name.
4308 hdr := dom.CreateElement("div")
4309 dom.SetStyle(hdr, "display", "flex")
4310 dom.SetStyle(hdr, "alignItems", "center")
4311 dom.SetStyle(hdr, "gap", "10px")
4312 dom.SetStyle(hdr, "padding", "12px 16px")
4313 dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
4314 dom.SetStyle(hdr, "flexShrink", "0")
4315
4316 backBtn := dom.CreateElement("button")
4317 dom.SetInnerHTML(backBtn, "←") // ←
4318 dom.SetStyle(backBtn, "background", "none")
4319 dom.SetStyle(backBtn, "border", "none")
4320 dom.SetStyle(backBtn, "fontSize", "20px")
4321 dom.SetStyle(backBtn, "cursor", "pointer")
4322 dom.SetStyle(backBtn, "color", "var(--fg)")
4323 dom.SetStyle(backBtn, "padding", "0")
4324 dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() {
4325 closeThread()
4326 }))
4327 dom.AppendChild(hdr, backBtn)
4328
4329 // Thread header avatar + name — uses same img-then-span structure
4330 // as note headers so pendingNotes/updateNoteHeader can update them.
4331 threadHdrInner := dom.CreateElement("div")
4332 av := dom.CreateElement("img")
4333 dom.SetAttribute(av, "referrerpolicy", "no-referrer")
4334 dom.SetAttribute(av, "width", "28")
4335 dom.SetAttribute(av, "height", "28")
4336 dom.SetStyle(av, "borderRadius", "50%")
4337 dom.SetStyle(av, "objectFit", "cover")
4338 dom.SetStyle(av, "flexShrink", "0")
4339 if pic, ok := authorPics[peer]; ok && pic != "" {
4340 dom.SetAttribute(av, "src", pic)
4341 } else {
4342 dom.SetStyle(av, "display", "none")
4343 }
4344 dom.SetAttribute(av, "onerror", "this.style.display='none'")
4345 dom.AppendChild(threadHdrInner, av)
4346
4347 nameSpan := dom.CreateElement("span")
4348 dom.SetStyle(nameSpan, "fontSize", "15px")
4349 dom.SetStyle(nameSpan, "fontWeight", "bold")
4350 dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
4351 if name, ok := authorNames[peer]; ok && name != "" {
4352 dom.SetTextContent(nameSpan, name)
4353 } else {
4354 npub := helpers.EncodeNpub(helpers.HexDecode(peer))
4355 if len(npub) > 20 {
4356 dom.SetTextContent(nameSpan, npub[:12]+"..."+npub[len(npub)-4:])
4357 }
4358 }
4359 dom.AppendChild(threadHdrInner, nameSpan)
4360 dom.SetStyle(threadHdrInner, "display", "flex")
4361 dom.SetStyle(threadHdrInner, "alignItems", "center")
4362 dom.SetStyle(threadHdrInner, "gap", "10px")
4363 dom.AppendChild(hdr, threadHdrInner)
4364
4365 ratchetBtn := dom.CreateElement("button")
4366 dom.SetTextContent(ratchetBtn, t("ratchet"))
4367 dom.SetStyle(ratchetBtn, "marginLeft", "auto")
4368 dom.SetStyle(ratchetBtn, "background", "none")
4369 dom.SetStyle(ratchetBtn, "border", "1px solid var(--border)")
4370 dom.SetStyle(ratchetBtn, "borderRadius", "4px")
4371 dom.SetStyle(ratchetBtn, "color", "var(--fg)")
4372 dom.SetStyle(ratchetBtn, "cursor", "pointer")
4373 dom.SetStyle(ratchetBtn, "fontSize", "11px")
4374 dom.SetStyle(ratchetBtn, "padding", "4px 8px")
4375 dom.SetStyle(ratchetBtn, "fontFamily", "'Fira Code', monospace")
4376 dom.AddEventListener(ratchetBtn, "click", dom.RegisterCallback(func() {
4377 if dom.Confirm("Delete all messages and rotate encryption keys?") {
4378 dom.PostToSW("[\"MLS_RATCHET\"," + jstr(peer) + "]")
4379 clearChildren(msgThreadMessages)
4380 }
4381 }))
4382 dom.AppendChild(hdr, ratchetBtn)
4383
4384 dom.AppendChild(msgThreadContainer, hdr)
4385
4386 // Track for live update when profile arrives.
4387 if _, cached := authorNames[peer]; !cached {
4388 pendingNotes[peer] = append(pendingNotes[peer], threadHdrInner)
4389 }
4390
4391 // Message area.
4392 msgThreadMessages = dom.CreateElement("div")
4393 dom.SetStyle(msgThreadMessages, "flex", "1")
4394 dom.SetStyle(msgThreadMessages, "overflowY", "auto")
4395 dom.SetStyle(msgThreadMessages, "padding", "12px 16px")
4396 dom.AppendChild(msgThreadContainer, msgThreadMessages)
4397
4398 // Compose area.
4399 compose := dom.CreateElement("div")
4400 dom.SetStyle(compose, "display", "flex")
4401 dom.SetStyle(compose, "gap", "8px")
4402 dom.SetStyle(compose, "padding", "8px 16px")
4403 dom.SetStyle(compose, "borderTop", "1px solid var(--border)")
4404 dom.SetStyle(compose, "flexShrink", "0")
4405
4406 msgComposeInput = dom.CreateElement("textarea")
4407 dom.SetAttribute(msgComposeInput, "rows", "1")
4408 dom.SetAttribute(msgComposeInput, "placeholder", t("msg_placeholder"))
4409 dom.SetStyle(msgComposeInput, "flex", "1")
4410 dom.SetStyle(msgComposeInput, "padding", "8px")
4411 dom.SetStyle(msgComposeInput, "fontFamily", "'Fira Code', monospace")
4412 dom.SetStyle(msgComposeInput, "fontSize", "13px")
4413 dom.SetStyle(msgComposeInput, "background", "var(--bg)")
4414 dom.SetStyle(msgComposeInput, "border", "1px solid var(--border)")
4415 dom.SetStyle(msgComposeInput, "borderRadius", "4px")
4416 dom.SetStyle(msgComposeInput, "color", "var(--fg)")
4417 dom.SetStyle(msgComposeInput, "resize", "none")
4418 dom.SetAttribute(msgComposeInput, "onkeydown", "if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.nextSibling.click()}")
4419 dom.AppendChild(compose, msgComposeInput)
4420
4421 sendBtn := dom.CreateElement("button")
4422 dom.SetTextContent(sendBtn, t("send"))
4423 dom.SetStyle(sendBtn, "padding", "8px 16px")
4424 dom.SetStyle(sendBtn, "fontFamily", "'Fira Code', monospace")
4425 dom.SetStyle(sendBtn, "fontSize", "13px")
4426 dom.SetStyle(sendBtn, "background", "var(--accent)")
4427 dom.SetStyle(sendBtn, "color", "#fff")
4428 dom.SetStyle(sendBtn, "border", "none")
4429 dom.SetStyle(sendBtn, "borderRadius", "4px")
4430 dom.SetStyle(sendBtn, "cursor", "pointer")
4431 dom.SetStyle(sendBtn, "alignSelf", "flex-end")
4432 dom.AddEventListener(sendBtn, "click", dom.RegisterCallback(func() {
4433 sendMessage()
4434 }))
4435 dom.AppendChild(compose, sendBtn)
4436
4437 dom.AppendChild(msgThreadContainer, compose)
4438
4439 // Fetch profile if needed.
4440 if !fetchedK0[peer] {
4441 queueProfileFetch(peer)
4442 }
4443
4444 // Request history.
4445 dom.PostToSW("[\"DM_HISTORY\"," + jstr(peer) + ",50,0]")
4446 }
4447
4448 func closeThread() {
4449 msgCurrentPeer = ""
4450 msgView = "list"
4451
4452 dom.SetStyle(msgThreadContainer, "display", "none")
4453 dom.SetStyle(msgListContainer, "display", "block")
4454
4455 if !navPop {
4456 dom.PushState("/msg")
4457 }
4458
4459 // Refresh list.
4460 dom.PostToSW("[\"DM_LIST\"]")
4461 }
4462
4463 func renderThreadMessages(peer, msgsJSON string) {
4464 if peer != msgCurrentPeer {
4465 return
4466 }
4467 if msgsJSON == "" || msgsJSON == "[]" {
4468 return
4469 }
4470
4471 // Parse messages array — each element is a DMRecord object.
4472 // IDB returns newest-first; collect then reverse for oldest-at-top.
4473 type dmMsg struct {
4474 from string
4475 content string
4476 ts int64
4477 }
4478 var msgs []dmMsg
4479
4480 i := 0
4481 for i < len(msgsJSON) && msgsJSON[i] != '[' {
4482 i++
4483 }
4484 i++
4485 for i < len(msgsJSON) {
4486 for i < len(msgsJSON) && msgsJSON[i] != '{' {
4487 if msgsJSON[i] == ']' {
4488 goto done
4489 }
4490 i++
4491 }
4492 if i >= len(msgsJSON) {
4493 break
4494 }
4495 objStart := i
4496 depth := 0
4497 for i < len(msgsJSON) {
4498 if msgsJSON[i] == '{' {
4499 depth++
4500 } else if msgsJSON[i] == '}' {
4501 depth--
4502 if depth == 0 {
4503 i++
4504 break
4505 }
4506 } else if msgsJSON[i] == '"' {
4507 i++
4508 for i < len(msgsJSON) && msgsJSON[i] != '"' {
4509 if msgsJSON[i] == '\\' {
4510 i++
4511 }
4512 i++
4513 }
4514 }
4515 i++
4516 }
4517 obj := msgsJSON[objStart:i]
4518
4519 from := helpers.JsonGetString(obj, "from")
4520 content := helpers.JsonGetString(obj, "content")
4521 ts := jsonGetNum(obj, "created_at")
4522 msgs = append(msgs, dmMsg{from, content, ts})
4523 }
4524 done:
4525
4526 // Reverse for oldest-first.
4527 for l, r := 0, len(msgs)-1; l < r; l, r = l+1, r-1 {
4528 msgs[l], msgs[r] = msgs[r], msgs[l]
4529 }
4530
4531 clearChildren(msgThreadMessages)
4532 for _, m := range msgs {
4533 appendBubble(m.from, m.content, m.ts)
4534 }
4535 scrollToBottom()
4536 }
4537
4538 func appendBubble(from, content string, ts int64) {
4539 isSent := from == pubhex
4540
4541 wrap := dom.CreateElement("div")
4542 dom.SetStyle(wrap, "display", "flex")
4543 dom.SetStyle(wrap, "marginBottom", "6px")
4544 if isSent {
4545 dom.SetStyle(wrap, "justifyContent", "flex-end")
4546 }
4547
4548 bubble := dom.CreateElement("div")
4549 dom.SetStyle(bubble, "maxWidth", "75%")
4550 dom.SetStyle(bubble, "padding", "8px 12px")
4551 dom.SetStyle(bubble, "borderRadius", "12px")
4552 dom.SetStyle(bubble, "fontSize", "14px")
4553 dom.SetStyle(bubble, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
4554 dom.SetStyle(bubble, "lineHeight", "1.4")
4555 dom.SetStyle(bubble, "wordBreak", "break-word")
4556 if isSent {
4557 dom.SetStyle(bubble, "background", "var(--accent)")
4558 dom.SetStyle(bubble, "color", "#fff")
4559 } else {
4560 dom.SetStyle(bubble, "background", "var(--bg2)")
4561 dom.SetStyle(bubble, "color", "var(--fg)")
4562 }
4563 dom.SetInnerHTML(bubble, renderMarkdown(content))
4564
4565 // Timestamp below bubble.
4566 tsEl := dom.CreateElement("div")
4567 dom.SetStyle(tsEl, "fontSize", "10px")
4568 dom.SetStyle(tsEl, "color", "var(--muted)")
4569 dom.SetStyle(tsEl, "marginTop", "2px")
4570 if isSent {
4571 dom.SetStyle(tsEl, "textAlign", "right")
4572 }
4573 dom.SetTextContent(tsEl, formatTime(ts))
4574 if isSent && ts == 0 {
4575 pendingTsEls = append(pendingTsEls, tsEl)
4576 }
4577
4578 outer := dom.CreateElement("div")
4579 dom.AppendChild(outer, bubble)
4580 dom.AppendChild(outer, tsEl)
4581
4582 // Email quote-reply button for received messages with email headers.
4583 if !isSent {
4584 emailFrom, emailSubject, emailBody, isEmail := parseEmailHeaders(content)
4585 if isEmail {
4586 replyBtn := dom.CreateElement("div")
4587 dom.SetStyle(replyBtn, "fontSize", "11px")
4588 dom.SetStyle(replyBtn, "color", "var(--accent)")
4589 dom.SetStyle(replyBtn, "cursor", "pointer")
4590 dom.SetStyle(replyBtn, "marginTop", "2px")
4591 dom.SetTextContent(replyBtn, "\u21a9 Reply")
4592 dom.AddEventListener(replyBtn, "click", dom.RegisterCallback(func() {
4593 quoted := quoteReply(emailFrom, emailSubject, emailBody)
4594 dom.SetProperty(msgComposeInput, "value", quoted)
4595 }))
4596 dom.AppendChild(outer, replyBtn)
4597 }
4598 }
4599
4600 dom.AppendChild(wrap, outer)
4601 dom.AppendChild(msgThreadMessages, wrap)
4602 }
4603
4604 func appendSystemBubble(text string) {
4605 wrap := dom.CreateElement("div")
4606 dom.SetStyle(wrap, "display", "flex")
4607 dom.SetStyle(wrap, "justifyContent", "center")
4608 dom.SetStyle(wrap, "marginBottom", "6px")
4609
4610 bubble := dom.CreateElement("div")
4611 dom.SetStyle(bubble, "maxWidth", "85%")
4612 dom.SetStyle(bubble, "padding", "8px 12px")
4613 dom.SetStyle(bubble, "borderRadius", "8px")
4614 dom.SetStyle(bubble, "fontSize", "12px")
4615 dom.SetStyle(bubble, "fontFamily", "monospace")
4616 dom.SetStyle(bubble, "lineHeight", "1.5")
4617 dom.SetStyle(bubble, "whiteSpace", "pre-wrap")
4618 dom.SetStyle(bubble, "background", "var(--bg2)")
4619 dom.SetStyle(bubble, "color", "var(--muted)")
4620 dom.SetStyle(bubble, "border", "1px solid var(--muted)")
4621 dom.SetTextContent(bubble, text)
4622
4623 dom.AppendChild(wrap, bubble)
4624 dom.AppendChild(msgThreadMessages, wrap)
4625 scrollToBottom()
4626 }
4627
4628 func scrollToBottom() {
4629 dom.SetProperty(msgThreadMessages, "scrollTop", "999999")
4630 }
4631
4632 func sendMessage() {
4633 content := dom.GetProperty(msgComposeInput, "value")
4634 if content == "" || msgCurrentPeer == "" {
4635 return
4636 }
4637
4638 // Clear input.
4639 dom.SetProperty(msgComposeInput, "value", "")
4640
4641 dom.PostToSW("[\"MLS_SEND\"," + jstr(msgCurrentPeer) + "," + jstr(content) + "]")
4642
4643 // Optimistic render (ts=0 — timestamp not shown for "just sent").
4644 appendBubble(pubhex, content, 0)
4645 scrollToBottom()
4646 }
4647
4648 func handleDMReceived(dmJSON string) {
4649 peer := helpers.JsonGetString(dmJSON, "peer")
4650 from := helpers.JsonGetString(dmJSON, "from")
4651 content := helpers.JsonGetString(dmJSON, "content")
4652 ts := jsonGetNum(dmJSON, "created_at")
4653
4654 if msgView == "thread" && peer == msgCurrentPeer {
4655 // Don't double-render our own sent messages (already optimistic).
4656 if from == pubhex {
4657 return
4658 }
4659 appendBubble(from, content, ts)
4660 scrollToBottom()
4661 } else if msgView == "list" {
4662 // Refresh conversation list.
4663 dom.PostToSW("[\"DM_LIST\"]")
4664 }
4665 }
4666
4667 // --- Logout ---
4668
4669 func doLogout() {
4670 // Tell SW to clean up.
4671 dom.PostToSW("[\"CLOSE\",\"prof\"]")
4672 dom.PostToSW("[\"CLOSE\",\"feed\"]")
4673 dom.PostToSW("[\"CLEAR_KEY\"]")
4674
4675 pubkey = nil
4676 pubhex = ""
4677 profileName = ""
4678 profilePic = ""
4679 profileTs = 0
4680 eventCount = 0
4681 popoverOpen = false
4682 marmotInited = false
4683 msgCurrentPeer = ""
4684 msgView = "list"
4685
4686 // Reset relay tracking.
4687 relayURLs = nil
4688 relayDots = nil
4689 relayLabels = nil
4690 relayUserPick = nil
4691
4692 localstorage.RemoveItem(lsKeyPubkey)
4693
4694 clearChildren(root)
4695 showLogin()
4696 }
4697
4698 // --- Email header parsing for quote-reply ---
4699
4700 // parseEmailHeaders checks if content looks like a forwarded email and extracts
4701 // From, Subject, and body. Returns isEmail=true if at least From: or Subject: found.
4702 func parseEmailHeaders(content string) (from, subject, body string, isEmail bool) {
4703 lines := splitLines(content)
4704 headerEnd := -1
4705 for i, line := range lines {
4706 if line == "" {
4707 headerEnd = i
4708 break
4709 }
4710 if hasPrefix(line, "From: ") {
4711 from = line[6:]
4712 } else if hasPrefix(line, "Subject: ") {
4713 subject = line[9:]
4714 } else if hasPrefix(line, "To: ") || hasPrefix(line, "Date: ") || hasPrefix(line, "Cc: ") {
4715 // Known header, continue
4716 } else if i == 0 {
4717 return "", "", "", false
4718 }
4719 }
4720 if from == "" && subject == "" {
4721 return "", "", "", false
4722 }
4723 if headerEnd >= 0 && headerEnd+1 < len(lines) {
4724 body = joinLines(lines[headerEnd+1:])
4725 }
4726 return from, subject, body, true
4727 }
4728
4729 func quoteReply(from, subject, body string) string {
4730 out := "To: " + from + "\n"
4731 if subject != "" {
4732 if !hasPrefix(subject, "Re: ") {
4733 subject = "Re: " + subject
4734 }
4735 out += "Subject: " + subject + "\n"
4736 }
4737 out += "\n\n"
4738 if body != "" {
4739 lines := splitLines(body)
4740 for _, line := range lines {
4741 out += "> " + line + "\n"
4742 }
4743 }
4744 return out
4745 }
4746
4747 func splitLines(s string) []string {
4748 var lines []string
4749 for {
4750 idx := strIndex(s, "\n")
4751 if idx < 0 {
4752 lines = append(lines, s)
4753 return lines
4754 }
4755 lines = append(lines, s[:idx])
4756 s = s[idx+1:]
4757 }
4758 }
4759
4760 func joinLines(lines []string) string {
4761 out := ""
4762 for i, line := range lines {
4763 if i > 0 {
4764 out += "\n"
4765 }
4766 out += line
4767 }
4768 return out
4769 }
4770
4771 func hasPrefix(s, prefix string) bool {
4772 return len(s) >= len(prefix) && s[:len(prefix)] == prefix
4773 }
4774
4775 // --- Markdown rendering ---
4776 // All functions use string concatenation and indexOf — no byte-level ops.
4777 // tinyjs compiles Go strings to JS strings (UTF-16); byte indexing corrupts emoji.
4778
4779 // renderMarkdown converts note text to safe HTML.
4780 func renderMarkdown(s string) string {
4781 s = strReplace(s, "&", "&")
4782 s = strReplace(s, "<", "<")
4783 s = strReplace(s, ">", ">")
4784 s = strReplace(s, "\"", """)
4785 s = wrapDelimited(s, "`", "<code>", "</code>")
4786 s = wrapDelimited(s, "**", "<strong>", "</strong>")
4787 s = wrapDelimited(s, "*", "<em>", "</em>")
4788 s = autoLinkURLs(s)
4789 s = linkifyNostrEntities(s)
4790 s = strReplace(s, "\n", "<br>")
4791 return s
4792 }
4793
4794 // strReplace replaces all occurrences of old with new using indexOf.
4795 func strReplace(s, old, nw string) string {
4796 out := ""
4797 for {
4798 idx := strIndex(s, old)
4799 if idx < 0 {
4800 return out + s
4801 }
4802 out += s[:idx] + nw
4803 s = s[idx+len(old):]
4804 }
4805 }
4806
4807 // wrapDelimited finds matching pairs of delim and wraps content in open/close tags.
4808 func wrapDelimited(s, delim, open, close string) string {
4809 out := ""
4810 for {
4811 start := strIndex(s, delim)
4812 if start < 0 {
4813 return out + s
4814 }
4815 end := strIndex(s[start+len(delim):], delim)
4816 if end < 0 {
4817 return out + s
4818 }
4819 end += start + len(delim)
4820 inner := s[start+len(delim) : end]
4821 if len(inner) == 0 {
4822 out += s[:start+len(delim)]
4823 s = s[start+len(delim):]
4824 continue
4825 }
4826 out += s[:start] + open + inner + close
4827 s = s[end+len(delim):]
4828 }
4829 }
4830
4831 func autoLinkURLs(s string) string {
4832 out := ""
4833 for {
4834 hi := strIndex(s, "https://")
4835 lo := strIndex(s, "http://")
4836 idx := -1
4837 if hi >= 0 && (lo < 0 || hi <= lo) {
4838 idx = hi
4839 } else if lo >= 0 {
4840 idx = lo
4841 }
4842 if idx < 0 {
4843 return out + s
4844 }
4845 out += s[:idx]
4846 s = s[idx:]
4847 // Find end of URL.
4848 end := 0
4849 for end < len(s) {
4850 c := s[end : end+1]
4851 if c == " " || c == "\n" || c == "\r" || c == "\t" || c == "<" || c == ">" {
4852 break
4853 }
4854 end++
4855 }
4856 // Trim trailing punctuation.
4857 for end > 0 {
4858 c := s[end-1 : end]
4859 if c == "." || c == "," || c == ")" || c == ";" {
4860 end--
4861 } else {
4862 break
4863 }
4864 }
4865 url := s[:end]
4866 safeURL := escapeHTMLAttr(url)
4867 if isImageURL(url) {
4868 out += "<img src=\"" + safeURL + "\" referrerpolicy=\"no-referrer\" style=\"display:block;max-width:100%;max-height:80vh;object-fit:contain;border-radius:8px;margin:4px 0\" loading=\"lazy\">"
4869 } else {
4870 out += "<a href=\"" + safeURL + "\" target=\"_blank\" rel=\"noopener\" style=\"color:var(--accent);word-break:break-all\">" + escapeHTML(url) + "</a>"
4871 }
4872 s = s[end:]
4873 }
4874 }
4875
4876 func isImageURL(url string) bool {
4877 u := toLower(url)
4878 return hasSuffix(u, ".jpg") || hasSuffix(u, ".jpeg") || hasSuffix(u, ".png") ||
4879 hasSuffix(u, ".gif") || hasSuffix(u, ".webp")
4880 }
4881
4882 func escapeHTMLAttr(s string) string {
4883 out := ""
4884 for i := 0; i < len(s); i++ {
4885 switch s[i] {
4886 case '"':
4887 out += """
4888 case '\'':
4889 out += "'"
4890 case '&':
4891 out += "&"
4892 case '<':
4893 out += "<"
4894 case '>':
4895 out += ">"
4896 default:
4897 out += s[i : i+1]
4898 }
4899 }
4900 return out
4901 }
4902
4903 func escapeHTML(s string) string {
4904 out := ""
4905 for i := 0; i < len(s); i++ {
4906 switch s[i] {
4907 case '&':
4908 out += "&"
4909 case '<':
4910 out += "<"
4911 case '>':
4912 out += ">"
4913 default:
4914 out += s[i : i+1]
4915 }
4916 }
4917 return out
4918 }
4919
4920 func hasSuffix(s, suffix string) bool {
4921 return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
4922 }
4923
4924 // --- Nostr entity embedding ---
4925
4926 func linkifyNostrEntities(s string) string {
4927 out := ""
4928 for {
4929 idx := strIndex(s, "nostr:")
4930 if idx < 0 {
4931 return out + s
4932 }
4933 out += s[:idx]
4934 s = s[idx+6:] // skip "nostr:"
4935 // Find end of bech32 entity (lowercase alphanumeric).
4936 end := 0
4937 for end < len(s) {
4938 c := s[end]
4939 if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
4940 end++
4941 } else {
4942 break
4943 }
4944 }
4945 if end < 10 {
4946 out += "nostr:"
4947 continue
4948 }
4949 entity := s[:end]
4950 s = s[end:]
4951
4952 if len(entity) > 7 && entity[:7] == "nevent1" {
4953 nev := helpers.DecodeNevent(entity)
4954 if nev != nil {
4955 if len(nev.Relays) > 0 {
4956 embedRelayHints[nev.ID] = nev.Relays
4957 }
4958 out += makeEmbedPlaceholder(nev.ID)
4959 continue
4960 }
4961 } else if len(entity) > 5 && entity[:5] == "note1" {
4962 id := helpers.DecodeNote(entity)
4963 if id != nil {
4964 out += makeEmbedPlaceholder(helpers.HexEncode(id))
4965 continue
4966 }
4967 } else if len(entity) > 9 && entity[:9] == "nprofile1" {
4968 np := helpers.DecodeNprofile(entity)
4969 if np != nil {
4970 name := np.Pubkey[:8] + "..."
4971 if n, ok := authorNames[np.Pubkey]; ok && n != "" {
4972 name = n
4973 } else {
4974 // Store relay hints so batch fetch uses them.
4975 if len(np.Relays) > 0 {
4976 authorRelays[np.Pubkey] = np.Relays
4977 }
4978 queueProfileFetch(np.Pubkey)
4979 }
4980 npub := helpers.EncodeNpub(helpers.HexDecode(np.Pubkey))
4981 out += "<a href=\"/p/" + npub + "\" data-pk=\"" + np.Pubkey + "\" style=\"color:var(--accent)\">" + escapeHTML(name) + "</a>"
4982 continue
4983 }
4984 } else if len(entity) > 5 && entity[:5] == "npub1" {
4985 pk := helpers.DecodeNpub(entity)
4986 if pk != nil {
4987 hexPK := helpers.HexEncode(pk)
4988 name := hexPK[:8] + "..."
4989 if n, ok := authorNames[hexPK]; ok && n != "" {
4990 name = n
4991 } else {
4992 queueProfileFetch(hexPK)
4993 }
4994 out += "<a href=\"/p/" + entity + "\" data-pk=\"" + hexPK + "\" style=\"color:var(--accent)\">" + escapeHTML(name) + "</a>"
4995 continue
4996 }
4997 }
4998 out += "nostr:" + entity
4999 }
5000 }
5001
5002 func updateInlineProfileLinks(pk string) {
5003 name := authorNames[pk]
5004 if name == "" {
5005 return
5006 }
5007 // Find and update all <a data-pk="..."> links for this pubkey.
5008 // Loop with QuerySelector since there's no QuerySelectorAll.
5009 for {
5010 el := dom.QuerySelector("a[data-pk=\"" + pk + "\"]")
5011 if el == 0 {
5012 break
5013 }
5014 dom.SetTextContent(el, name)
5015 // Mark as done so we don't match it again.
5016 dom.SetAttribute(el, "data-pk", "")
5017 }
5018 }
5019
5020 func makeEmbedPlaceholder(hexID string) string {
5021 embedCounter++
5022 elemID := "emb-" + itoa(embedCounter)
5023 embedCallbacks[hexID] = append(embedCallbacks[hexID], elemID)
5024 return "<div id=\"" + elemID + "\" data-eid=\"" + hexID + "\" " +
5025 "style=\"border-left:3px solid var(--accent);margin:8px 0;padding:8px 12px;" +
5026 "background:var(--bg2,#1a1a2e);border-radius:4px;opacity:0.6;font-size:13px\">" +
5027 "<em>Loading note...</em></div>"
5028 }
5029
5030 func resolveEmbeds() {
5031 if len(embedCallbacks) == 0 {
5032 return
5033 }
5034 var ids []string
5035 for hexID := range embedCallbacks {
5036 ids = append(ids, hexID)
5037 }
5038 filter := "{\"ids\":["
5039 for i, id := range ids {
5040 if i > 0 {
5041 filter += ","
5042 }
5043 filter += jstr(id)
5044 }
5045 filter += "]}"
5046
5047 // REQ checks the local IDB cache.
5048 reqID := "emb-r-" + itoa(embedCounter)
5049 dom.PostToSW("[\"REQ\"," + jstr(reqID) + "," + filter + "]")
5050
5051 // PROXY fetches from relays — combine user relays + nevent relay hints.
5052 seen := map[string]bool{}
5053 var urls []string
5054 for _, u := range relayURLs {
5055 if !seen[u] {
5056 seen[u] = true
5057 urls = append(urls, u)
5058 }
5059 }
5060 for _, id := range ids {
5061 for _, u := range embedRelayHints[id] {
5062 if !seen[u] {
5063 seen[u] = true
5064 urls = append(urls, u)
5065 }
5066 }
5067 delete(embedRelayHints, id)
5068 }
5069 if len(urls) > 0 {
5070 proxyID := "emb-p-" + itoa(embedCounter)
5071 dom.PostToSW(buildProxyMsg(proxyID, filter, urls))
5072 }
5073 }
5074
5075 func renderEmbedText(s string) string {
5076 s = strReplace(s, "&", "&")
5077 s = strReplace(s, "<", "<")
5078 s = strReplace(s, ">", ">")
5079 s = wrapDelimited(s, "`", "<code>", "</code>")
5080 s = autoLinkURLs(s)
5081 s = strReplace(s, "\n", "<br>")
5082 return s
5083 }
5084
5085 func fillEmbed(ev *nostr.Event) {
5086 elemIDs, ok := embedCallbacks[ev.ID]
5087 if !ok {
5088 return
5089 }
5090 delete(embedCallbacks, ev.ID)
5091
5092 name := ev.PubKey[:8] + "..."
5093 if n, ok := authorNames[ev.PubKey]; ok && n != "" {
5094 name = n
5095 }
5096 headerHTML := "<div style=\"display:flex;align-items:center;gap:6px;margin-bottom:4px\">"
5097 if pic, ok := authorPics[ev.PubKey]; ok && pic != "" {
5098 headerHTML += "<img src=\"" + escapeHTMLAttr(pic) + "\" width=\"16\" height=\"16\" " +
5099 "style=\"border-radius:50%\" referrerpolicy=\"no-referrer\" onerror=\"this.style.display='none'\">"
5100 }
5101 headerHTML += "<strong style=\"font-size:12px\">" + escapeHTML(name) + "</strong></div>"
5102
5103 truncated := len(ev.Content) > 300
5104 text := ev.Content
5105 if truncated {
5106 text = text[:300] + "..."
5107 }
5108
5109 embedEvID := ev.ID
5110 embedRootID := getRootID(ev)
5111 if embedRootID == "" {
5112 embedRootID = embedEvID
5113 }
5114
5115 for _, elemID := range elemIDs {
5116 el := dom.GetElementById(elemID)
5117 if el == 0 {
5118 continue
5119 }
5120 dom.SetInnerHTML(el, "")
5121 dom.SetStyle(el, "opacity", "1")
5122 dom.SetStyle(el, "cursor", "pointer")
5123
5124 hdr := dom.CreateElement("div")
5125 dom.SetInnerHTML(hdr, headerHTML)
5126 dom.AppendChild(el, hdr)
5127
5128 body := dom.CreateElement("div")
5129 dom.SetStyle(body, "fontSize", "13px")
5130 dom.SetStyle(body, "lineHeight", "1.4")
5131 dom.SetInnerHTML(body, renderEmbedText(text))
5132 dom.AppendChild(el, body)
5133
5134 if truncated {
5135 more := dom.CreateElement("span")
5136 dom.SetTextContent(more, t("show_more"))
5137 dom.SetStyle(more, "color", "var(--accent)")
5138 dom.SetStyle(more, "cursor", "pointer")
5139 dom.SetStyle(more, "fontSize", "12px")
5140 dom.SetStyle(more, "display", "inline-block")
5141 dom.SetStyle(more, "marginTop", "2px")
5142 fullContent := ev.Content
5143 thisBody := body
5144 expanded := false
5145 dom.AddEventListener(more, "click", dom.RegisterCallback(func() {
5146 expanded = !expanded
5147 if expanded {
5148 dom.SetInnerHTML(thisBody, renderEmbedText(fullContent))
5149 dom.SetTextContent(more, t("show_less"))
5150 } else {
5151 dom.SetInnerHTML(thisBody, renderEmbedText(fullContent[:300]+"..."))
5152 dom.SetTextContent(more, t("show_more"))
5153 }
5154 }))
5155 dom.AppendChild(el, more)
5156 }
5157
5158 // Click anywhere on embed (except "show more") opens thread view.
5159 thisEl := el
5160 dom.AddEventListener(thisEl, "click", dom.RegisterCallback(func() {
5161 showNoteThread(embedRootID, embedEvID)
5162 }))
5163 }
5164
5165 if _, ok := authorNames[ev.PubKey]; !ok {
5166 if !fetchedK0[ev.PubKey] {
5167 queueProfileFetch(ev.PubKey)
5168 }
5169 }
5170 }
5171
5172 // jsonGetNum extracts a numeric value for a given key from a JSON object.
5173 func jsonGetNum(s, key string) int64 {
5174 needle := "\"" + key + "\":"
5175 idx := strIndex(s, needle)
5176 if idx < 0 {
5177 return 0
5178 }
5179 idx += len(needle)
5180 // Skip whitespace.
5181 for idx < len(s) && (s[idx] == ' ' || s[idx] == '\t') {
5182 idx++
5183 }
5184 if idx >= len(s) {
5185 return 0
5186 }
5187 var n int64
5188 for idx < len(s) && s[idx] >= '0' && s[idx] <= '9' {
5189 n = n*10 + int64(s[idx]-'0')
5190 idx++
5191 }
5192 return n
5193 }
5194
5195 // jsonEsc escapes a string for embedding in a JSON value.
5196 func jsonEsc(s string) string {
5197 s = strReplace(s, "\\", "\\\\")
5198 s = strReplace(s, "\"", "\\\"")
5199 s = strReplace(s, "\n", "\\n")
5200 s = strReplace(s, "\r", "\\r")
5201 s = strReplace(s, "\t", "\\t")
5202 return s
5203 }
5204
5205 // strIndex finds substring in string. Returns -1 if not found.
5206 func strIndex(s, sub string) int {
5207 sl := len(sub)
5208 for i := 0; i <= len(s)-sl; i++ {
5209 if s[i:i+sl] == sub {
5210 return i
5211 }
5212 }
5213 return -1
5214 }
5215
5216 // --- Helpers ---
5217
5218 // normalizeURL strips trailing slashes and lowercases the scheme+host.
5219 func normalizeURL(u string) string {
5220 for len(u) > 0 && u[len(u)-1] == '/' {
5221 u = u[:len(u)-1]
5222 }
5223 // Lowercase scheme and host (before first / after ://).
5224 if len(u) > 6 && u[:6] == "wss://" {
5225 rest := u[6:]
5226 slash := strIndex(rest, "/")
5227 if slash < 0 {
5228 return u[:6] + toLower(rest)
5229 }
5230 return u[:6] + toLower(rest[:slash]) + rest[slash:]
5231 }
5232 if len(u) > 5 && u[:5] == "ws://" {
5233 rest := u[5:]
5234 slash := strIndex(rest, "/")
5235 if slash < 0 {
5236 return u[:5] + toLower(rest)
5237 }
5238 return u[:5] + toLower(rest[:slash]) + rest[slash:]
5239 }
5240 return u
5241 }
5242
5243 func toLower(s string) string {
5244 b := []byte{:len(s)}
5245 for i := 0; i < len(s); i++ {
5246 c := s[i]
5247 if c >= 'A' && c <= 'Z' {
5248 c += 32
5249 }
5250 b[i] = c
5251 }
5252 return string(b)
5253 }
5254
5255 func showQRModal(npubStr string) {
5256 dom.ConsoleLog("showQRModal called with: " + npubStr)
5257 svg := qrSVG(npubStr, 280, logoSVGCache)
5258 dom.ConsoleLog("qrSVG returned len=" + itoa(len(svg)))
5259 if svg == "" {
5260 dom.ConsoleLog("QR SVG empty, returning")
5261 return
5262 }
5263 scrim := dom.CreateElement("div")
5264 dom.SetStyle(scrim, "position", "fixed")
5265 dom.SetStyle(scrim, "inset", "0")
5266 dom.SetStyle(scrim, "background", "rgba(0,0,0,0.6)")
5267 dom.SetStyle(scrim, "display", "flex")
5268 dom.SetStyle(scrim, "alignItems", "center")
5269 dom.SetStyle(scrim, "justifyContent", "center")
5270 dom.SetStyle(scrim, "zIndex", "9999")
5271 dom.SetStyle(scrim, "cursor", "pointer")
5272 dom.AddEventListener(scrim, "click", dom.RegisterCallback(func() {
5273 dom.RemoveChild(dom.Body(), scrim)
5274 }))
5275
5276 card := dom.CreateElement("div")
5277 dom.SetStyle(card, "background", "white")
5278 dom.SetStyle(card, "borderRadius", "16px")
5279 dom.SetStyle(card, "padding", "24px")
5280 dom.SetStyle(card, "display", "flex")
5281 dom.SetStyle(card, "flexDirection", "column")
5282 dom.SetStyle(card, "alignItems", "center")
5283 dom.SetStyle(card, "gap", "12px")
5284 dom.SetStyle(card, "cursor", "default")
5285 dom.SetAttribute(card, "onclick", "event.stopPropagation()")
5286 dom.SetInnerHTML(card, svg)
5287
5288 label := dom.CreateElement("div")
5289 dom.SetStyle(label, "fontSize", "11px")
5290 dom.SetStyle(label, "color", "#666")
5291 dom.SetStyle(label, "wordBreak", "break-all")
5292 dom.SetStyle(label, "textAlign", "center")
5293 dom.SetStyle(label, "maxWidth", "280px")
5294 dom.SetStyle(label, "fontFamily", "'Fira Code', monospace")
5295 dom.SetTextContent(label, npubStr)
5296 dom.AppendChild(card, label)
5297
5298 dom.AppendChild(scrim, card)
5299 dom.AppendChild(dom.Body(), scrim)
5300 }
5301
5302 func clearChildren(el dom.Element) {
5303 dom.SetInnerHTML(el, "")
5304 }
5305
5306 func itoa(n int) string {
5307 if n == 0 {
5308 return "0"
5309 }
5310 neg := false
5311 if n < 0 {
5312 neg = true
5313 n = -n
5314 }
5315 var b [20]byte
5316 i := len(b)
5317 for n > 0 {
5318 i--
5319 b[i] = byte('0' + n%10)
5320 n /= 10
5321 }
5322 if neg {
5323 i--
5324 b[i] = '-'
5325 }
5326 return string(b[i:])
5327 }
5328