main.go raw

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