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, "&#x2190;")
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, "&#x2190;") // ←
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, "&", "&amp;")
4782  	s = strReplace(s, "<", "&lt;")
4783  	s = strReplace(s, ">", "&gt;")
4784  	s = strReplace(s, "\"", "&quot;")
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 += "&quot;"
4888  		case '\'':
4889  			out += "&#39;"
4890  		case '&':
4891  			out += "&amp;"
4892  		case '<':
4893  			out += "&lt;"
4894  		case '>':
4895  			out += "&gt;"
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 += "&amp;"
4909  		case '<':
4910  			out += "&lt;"
4911  		case '>':
4912  			out += "&gt;"
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, "&", "&amp;")
5077  	s = strReplace(s, "<", "&lt;")
5078  	s = strReplace(s, ">", "&gt;")
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