main.mx raw

   1  package main
   2  
   3  import (
   4  	"crypto/sha256"
   5  	"git.smesh.lol/smesh/web/common/helpers"
   6  	"git.smesh.lol/smesh/web/common/jsbridge/dom"
   7  	"git.smesh.lol/smesh/web/common/jsbridge/mlsw"
   8  	"git.smesh.lol/smesh/web/common/jsbridge/feed"
   9  	"git.smesh.lol/smesh/web/common/jsbridge/localstorage"
  10  	"git.smesh.lol/smesh/web/common/jsbridge/markdown"
  11  	"git.smesh.lol/smesh/web/common/jsbridge/notif"
  12  	"git.smesh.lol/smesh/web/common/jsbridge/profile"
  13  	"git.smesh.lol/smesh/web/common/jsbridge/relayproxy"
  14  	"git.smesh.lol/smesh/web/common/jsbridge/signer"
  15  	"git.smesh.lol/smesh/web/common/nostr"
  16  )
  17  
  18  const (
  19  	lsKeyPubkey         = "smesh-pubkey"
  20  	lsKeyTheme          = "smesh-theme"
  21  	lsKeyRelayPolicy    = "smesh-relay-policy"
  22  	lsKeyRelayBlocklist = "smesh-relay-blocklist"
  23  	lsKeyRelayList      = "smesh-relays"
  24  	lsKeyBlossomServer  = "smesh-blossom-server"
  25  	lsKeyInvidiousURL   = "smesh-invidious-url"
  26  	lsKeyCampionURL     = "smesh-campion-url"
  27  	lsKeyDevMode        = "smesh-dev-mode"
  28  	lsKeyRelayRoles     = "smesh-relay-roles"
  29  )
  30  
  31  // Relay policy bitfield - persisted as decimal int in lsKeyRelayPolicy.
  32  // All flags default to 0 (no policy enforced). Enforcement happens in later
  33  // steps; step 3 only reads and writes the state.
  34  const (
  35  	policyRequireAuth     = 1 << 0 // require NIP-42 support
  36  	policyRequireProtect  = 1 << 1 // require NIP-70 support
  37  	policyBlockPayment    = 1 << 2 // block relays with limitation.payment_required
  38  	policyBlockRestricted = 1 << 3 // block relays with limitation.restricted_writes
  39  )
  40  
  41  const activityLogMax = 500
  42  
  43  type logEntry struct {
  44  	ts      int64
  45  	action  string // "boot", "publish", "reply", "react", "rebroadcast", "subscribe", "relay-ok", "relay-err"
  46  	summary string // one-line title
  47  	detail  string // multi-line detail, can be empty
  48  }
  49  
  50  var (
  51  	pubhex  string
  52  	isDark  bool
  53  	devMode bool
  54  	blossomServerURL string
  55  	invidiousURL     string
  56  	campionURL       string
  57  
  58  	// Profile data from kind 0.
  59  	profileName string
  60  	profilePic  string
  61  	profileTs   int64
  62  	contactTs   int64 // kind 3 latest timestamp
  63  	muteTs      int64 // kind 10000 latest timestamp
  64  	relayListTs int64 // kind 10002 latest timestamp
  65  	inboxTs     int64 // kind 10050 latest timestamp
  66  	settingsTs  int64 // kind 30078 latest timestamp
  67  
  68  	// DOM refs that need updating after creation.
  69  	avatarEl      dom.Element
  70  	nameEl        dom.Element
  71  	feedContainer dom.Element
  72  	feedLoader    dom.Element
  73  	statusEl      dom.Element
  74  	bottomBar     dom.Element
  75  	topBarRight   dom.Element
  76  	topBarLeft    dom.Element
  77  	userBtn       dom.Element
  78  	popoverEl      dom.Element
  79  	popoverSummary dom.Element
  80  	signerBtnEl   dom.Element
  81  	walletBtnEl     dom.Element
  82  	walletPanel     dom.Element
  83  	walletContent   dom.Element
  84  	walletPanelOpen bool
  85  	themeBtn      dom.Element
  86  	pageTitleEl   dom.Element
  87  	topBackBtn    dom.Element
  88  	feedPage      dom.Element
  89  	loadMoreBtn   dom.Element
  90  	msgPage       dom.Element
  91  	profilePage   dom.Element
  92  	sidebarCompose  dom.Element
  93  	sidebarFeed     dom.Element
  94  	sidebarMsg      dom.Element
  95  	sidebarNotif    dom.Element
  96  	sidebarSettings dom.Element
  97  	notifDot        dom.Element
  98  	notifPage       dom.Element
  99  	notifContainer  dom.Element
 100  
 101  	// Search page state.
 102  	searchPage        dom.Element
 103  	searchContainer   dom.Element
 104  	searchFilterBarEl dom.Element // sticky bar above results
 105  	searchInputEl     dom.Element
 106  	searchSubmitBtn   dom.Element
 107  	searchBtnGlobal   dom.Element // captured for deactivation from anywhere
 108  	searchReturnPage  string      // page to restore when user manually closes search
 109  	searchBarActive   bool
 110  	searchCurrentQ    string
 111  	searchResults     []*nostr.Event
 112  	searchSeen        map[string]bool
 113  	searchFollowsOnly bool
 114  	searchSortDesc    bool // true = newest first (default)
 115  	searchNSFW        bool
 116  	searchRendered    int32  // how many filtered results are currently rendered
 117  	notifLoadMore   dom.Element
 118  	notifEmptyEl    dom.Element
 119  	notifSelectEl   dom.Element
 120  	notifMarkBtn    dom.Element
 121  	notifBarGroup   dom.Element
 122  	notifFilter     string // "all", "mentions", "reactions", "zaps"
 123  	notifUnread     bool
 124  	notifReadTs     int64  // watermark: events with CreatedAt > this are unread
 125  	notifBuilt      bool   // true after first render
 126  	notifEvents     []*nostr.Event
 127  	notifRowCBs     []int32  // callback IDs registered by current notif rows; released on rebuild
 128  	notifSeen       map[string]bool
 129  	oldestNotifTs   int64
 130  	notifLoading    bool
 131  	notifExhausted  bool
 132  	notifEmptyStrk  int32
 133  	notifMoreGot    int32
 134  	notifMoreTimer  int32
 135  	notifRefTimer   int32
 136  	notifInitLoad   bool
 137  	notifMissing    []string // refIDs not in eventCache, fetched after EOSE
 138  	settingsPage    dom.Element
 139  	composePage     dom.Element
 140  	aboutPage       dom.Element
 141  	aboutLogoEl     dom.Element
 142  	composeTextarea    dom.Element
 143  	composePreview     dom.Element
 144  	composeRelayPopEl  dom.Element
 145  	composeRelayPopOpen bool
 146  	composeRelayChecks map[string]bool // relay URL -> selected for publish
 147  	emojiAutoEl        dom.Element     // emoji autocomplete popup
 148  	emojiAutoOpen      bool
 149  	emojiAutoStart     int32             // position of the opening ':'
 150  	emojiAutoTA        dom.Element     // textarea the emoji popup is attached to
 151  	activePage      string
 152  	profileViewPK string
 153  
 154  	// App root - content goes here, not body (snackbar stays outside).
 155  	root dom.Element
 156  
 157  	// Messaging UI state.
 158  	msgListContainer   dom.Element // conversation list view
 159  	msgThreadContainer dom.Element // thread view (header + messages + compose)
 160  	msgThreadMessages  dom.Element // scrollable message area
 161  	msgComposeInput    dom.Element // textarea
 162  	msgCurrentConvoID  string // peer pubkey (DM) or groupIDHex (group) of open thread, "" when on list
 163  	msgCurrentKind     string // "dm" or "group"
 164  	msgView            string      // "list" or "thread"
 165  	marmotInited       bool
 166  	pendingTsEls       []dom.Element // timestamp divs awaiting relay confirmation
 167  
 168  	// Relay tracking - parallel slices, grown dynamically.
 169  	relayURLs            []string
 170  	relayRows            []dom.Element // popover row container - used by removeRelayByURL
 171  	relayDots            []dom.Element
 172  	relayLabels          []dom.Element
 173  	relayBadgeContainers []dom.Element // capability warning badges, rewritten on NIP-11 fetch
 174  	relayReadBtns        []dom.Element // per-relay "r" toggle button
 175  	relayWriteBtns       []dom.Element // per-relay "w" toggle button
 176  	relayUserPick        []bool        // true = from user's kind 10002
 177  	relayRole            []string      // "both", "read", "write" per relay
 178  	relayCaps            []relayCap    // NIP-11 snapshot (populated async after addRelay)
 179  	relayCapsTs          []int64       // unix seconds when each relayCaps entry was fetched
 180  
 181  	// Relay policy (loaded once at startup from localStorage).
 182  	relayPolicyFlags int32      // bitfield of policy* constants
 183  	relayBlocklist   []string // exact-URL blocklist (persisted; kind 10006 publication is future work)
 184  
 185  	eventRelays   map[string][]string // event ID -> relay URLs that delivered it
 186  	pendingDeletes map[string]string   // delete event ID -> target event ID
 187  	noteElements  map[string]dom.Element // event ID -> note DOM element
 188  	eventCount    int32
 189  	popoverOpen   bool
 190  	broadcastRow  dom.Element
 191  	broadcastBtnEl dom.Element
 192  	oldestFeedTs    int64 // oldest created_at in feed - for infinite scroll
 193  	feedLoading     bool  // true while fetching older events
 194  	feedExhausted   bool  // true when EOSE returns no new events
 195  	feedEmptyStreak int32   // consecutive empty EOSE responses
 196  
 197  	// Author name/pic view cache - small, populated from P_RESOLVED and IDB bootstrap.
 198  	// Profile Worker owns canonical data; Shell keeps this for sync DOM reads.
 199  	authorNames   map[string]string       // pubkey hex -> display name
 200  	authorPics    map[string]string       // pubkey hex -> avatar URL
 201  	pendingNotes        map[string][]dom.Element // pubkey hex -> author header divs awaiting profile
 202  	pendingNotifAvatars map[string][]dom.Element // pubkey hex -> notif avatar imgs awaiting profile
 203  	seenEvents   map[string]bool         // event ID -> already rendered
 204  	// Shell-local fetch dedup hints - not authoritative (Profile Worker owns dedup).
 205  	// Empty map reads return false so all guards fire, Profile Worker handles actual dedup.
 206  	// Two-gen rotation: when current exceeds 10000, old is dropped and current becomes old.
 207  	fetchedK0      map[string]bool
 208  	fetchedK0Old   map[string]bool
 209  	fetchedK10k    map[string]bool
 210  	fetchedK10kOld map[string]bool
 211  	// Stub vars for dead profile-fetch functions - referenced in function bodies
 212  	// that are no longer called; kept to satisfy the compiler until cleanup.
 213  	fetchQueue  []string
 214  	fetchTimer  int32
 215  	retryRound  int32
 216  	authorSubPK map[string]string
 217  
 218  	// User card hover popover.
 219  	hoverCardEl     dom.Element
 220  	hoverCardParent dom.Element
 221  	hoverCardPK     string
 222  	hoverCardTimer    int32
 223  	hoverCardDismiss  int32
 224  	hoverCardHover    bool
 225  
 226  	idbLoaded bool
 227  
 228  	// QR modal.
 229  	logoSVGCache string
 230  
 231  	// Profile page tab state.
 232  	profileTab           string
 233  	profileTabContent    dom.Element
 234  	profileTabBtns       map[string]dom.Element
 235  	// Own follow/mute lists - Shell's working copy for sync filter and action ops.
 236  	// Profile Worker is authoritative; Shell is updated via P_FOLLOWS/P_MUTES pushes.
 237  	myFollows  []string
 238  	myMutes    []string
 239  	followSet  map[string]bool // O(1) lookup derived from myFollows
 240  	muteSet    map[string]bool // O(1) lookup derived from myMutes
 241  	// Profile content/follows/mutes/relays for viewed profiles (small view caches).
 242  	profileContentCache map[string]string
 243  	profileFollowsCache map[string][]string
 244  	profileMutesCache   map[string][]string
 245  	profileRelaysCache  map[string][]string
 246  	profileNotesSeen     map[string]bool
 247  	activeProfileNoteSub string
 248  
 249  	// Profile page cache - keeps rendered profile DOMs alive.
 250  	relayInfoEl          dom.Element                       // relay info page wrapper (removed on nav)
 251  	profileWrappers      map[string]dom.Element            // pk -> wrapper div
 252  	profileWrapTabCont   map[string]dom.Element            // pk -> tab content ref
 253  	profileWrapTabs      map[string]map[string]dom.Element // pk -> tab button map
 254  	profileWrapTabSel    map[string]string                 // pk -> selected tab
 255  	profileWrapOrder     []string                          // LRU order (oldest first)
 256  
 257  	// History/routing.
 258  	navPop      bool // true during popstate - suppresses pushState
 259  	routerInited bool // guard against duplicate listener registration
 260  
 261  	// Embedded nostr: entity resolution.
 262  	embedCounter      int32
 263  	embedCallbacks    map[string][]string // hex event ID -> DOM element IDs awaiting fill
 264  	embedRelayHints   map[string][]string // hex event ID -> relay hints from nevent TLV
 265  	embedRequested    map[string]bool     // hex event IDs already sent to PROXY
 266  	embedSubIDs       map[string][]string // sub ID -> list of hex event IDs requested in that sub
 267  	embedCoordCBs     map[string][]string // "kind:pubkey:d" -> DOM element IDs awaiting fill
 268  	embedCoordSubIDs  map[string]string   // sub ID -> coord key
 269  
 270  	// @-mention autocomplete.
 271  	mentionPopup   dom.Element // the popup container
 272  	mentionItems   []mentionMatch
 273  	mentionSelIdx  int32    // highlighted index in popup
 274  	mentionTA      dom.Element // textarea the popup is attached to
 275  	mentionQuery   string // current query text (after @)
 276  	mentionAtPos   int32    // character position of the @ in the textarea
 277  
 278  	// Thread view state.
 279  	threadPage       dom.Element
 280  	threadContainer  dom.Element
 281  	threadRootID     string
 282  	threadFocusID    string
 283  	threadEvents     map[string]*nostr.Event
 284  	threadSubCount   int32
 285  	threadOpen        bool
 286  	threadReturnPage  string   // page to return to when closing thread
 287  	threadPushedState bool     // true if we pushed history for this thread
 288  	threadRenderTimer int32
 289  	threadGen          int32      // generation counter - reject events from old threads
 290  	threadActiveSubs   []string // sub IDs to close when opening a new thread
 291  	threadLastRendered int32      // event count at last render - skip if unchanged
 292  	threadRelays       []string // relay URLs used for this thread's fetches
 293  	threadFollowUp     int32      // follow-up round counter (0 = initial, caps at 3)
 294  	threadFetchedIDs   map[string]bool // IDs already queried for child replies
 295  	contentArea      dom.Element // scroll container
 296  	savedScrollTop   string     // feed scroll position before thread open
 297  
 298  	// Reply preview state.
 299  	replyCache       map[string]string       // event ID -> "name: first line"
 300  	replyAvatarCache map[string]string       // event ID -> avatar URL of parent author
 301  	replyLineCache   map[string]string       // event ID -> raw first line (no name)
 302  	replyAuthorMap   map[string]string       // event ID -> author pubkey hex
 303  	replyPending     map[string][]dom.Element // event ID -> preview divs awaiting fetch
 304  	replyNeedName    map[string][]dom.Element // event ID -> preview divs needing name update
 305  	replyQueue       []string                // event IDs to batch-fetch
 306  	replyHints       map[string]string       // event ID -> relay hint URL from e-tag
 307  	replyTimer       int32
 308  	replySweepTimer  int32                     // debounced sweep for unfound replies
 309  
 310  	// Action button state (reactions/reposts).
 311  	noteRepostCounts map[string]map[string]bool   // event ID -> pubkey set
 312  	noteReactionMap  map[string]map[string]map[string]bool // event ID -> emoji -> pubkey set
 313  	noteReactionEls  map[string]dom.Element       // event ID -> reaction display row
 314  	noteRepostEls    map[string]dom.Element       // event ID -> repost counter span
 315  	noteZapTotals    map[string]int64             // event ID -> total msats received
 316  	noteZapSeen      map[string]map[string]bool   // event ID -> set of 9735 receipt IDs counted
 317  	noteZapEls       map[string]dom.Element       // event ID -> zap total chip
 318  	pendingZapRows   map[string][]zapRowPending   // authorPK -> rows awaiting lud16
 319  	actionFetchedIDs map[string]bool              // event IDs already queued for kind 6/7
 320  	actionFetchQueue []string                     // IDs awaiting batch fetch
 321  	actionFetchTimer int32
 322  	actionSubCounter int32
 323  
 324  	// Compose editor state.
 325  	editorComposer  dom.Element  // inline composer div appended to note
 326  	editorNoteEl    dom.Element  // the note element containing the composer
 327  	editorTextarea  dom.Element
 328  	editorPreviewEl dom.Element
 329  	editorMode      string       // "reply" or "quote"
 330  	editorTargetEv  *nostr.Event
 331  	editorOpen      bool
 332  	editorPreviewing bool
 333  	editorDrafts    map[string]string // "reply:evID" or "quote:evID" -> draft text
 334  
 335  	// Emoji picker state.
 336  	emojiOverlay  dom.Element
 337  	emojiBackdrop dom.Element
 338  	emojiAnchor   dom.Element
 339  	emojiTargetEv *nostr.Event
 340  	emojiOpen     bool
 341  
 342  	// Cached events for repost/quote content.
 343  	eventCache map[string]*nostr.Event
 344  
 345  	// Global ready flag - set after showApp finishes all map init + DOM setup.
 346  	appReady bool
 347  
 348  	// Activity log state. activityLog is append-only (newest first), capped at activityLogMax.
 349  	// Each entry is a single action with a short summary and optional multi-line detail.
 350  	logPage         dom.Element
 351  	logListEl       dom.Element
 352  	logBtn          dom.Element
 353  	activityLog     []*logEntry
 354  	bootInProgress  bool // true while boot is running; controls logBtn highlight
 355  
 356  	// Live-update subscriber callbacks keyed by event ID. The relay info modal
 357  	// registers a callback for its evID on open; the SEEN_ON handler fires the
 358  	// registered callback (if any) after updating eventRelays. Modal must
 359  	// unregister on close. Single registration per evID - second registration
 360  	// replaces the first.
 361  	seenOnSubs map[string]func()
 362  
 363  	// Feed controls.
 364  	refreshBtn      dom.Element
 365  	refreshSpinning bool
 366  	feedInitialLoad      bool // true until ~3s after first EOSE on "feed"
 367  	feedInitialLoadTimer int32  // delays flipping feedInitialLoad=false so late events from slower relays still render directly
 368  	feedSubscribed       bool // true once subscribeFeed has been called
 369  	feedDeferTimer       int32  // timer for deferred feed subscription
 370  	feedSubTimer         int32  // throttle timer for subscribeFeed
 371  	feedBuffer      []*nostr.Event // new events buffered after initial load
 372  	feedHasNew      bool           // true when feedBuffer is non-empty (pulse indicator)
 373  	feedMode        string         // "follows", "relays", or relay URL
 374  	feedSelectEl    dom.Element
 375  	feedPopoverEl   dom.Element
 376  	feedPopoverOpen bool
 377  
 378  )
 379  
 380  // ── Rotate-map cache bounds ───────────────────────────────────────────────────
 381  // Each cache group has two generations: current and old. When current exceeds
 382  // its threshold, old is dropped and current becomes old. Lookups check both.
 383  
 384  const (
 385  	rotateEventMax   = 2000
 386  	rotateAuthorMax  = 500
 387  	rotateEmbedMax   = 200
 388  	rotateReplyMax   = 500
 389  	rotateProfileMax = 10
 390  	notifEventsMax   = 500
 391  	rotateFetchedMax = 10000
 392  )
 393  
 394  var (
 395  	// Event data "old" generation.
 396  	eventCacheOld       map[string]*nostr.Event
 397  	seenEventsOld       map[string]bool
 398  	noteElementsOld     map[string]dom.Element
 399  	noteReactionMapOld  map[string]map[string]map[string]bool
 400  	noteRepostCountsOld map[string]map[string]bool
 401  	noteZapSeenOld      map[string]map[string]bool
 402  	actionFetchedIDsOld map[string]bool
 403  	eventRelaysOld      map[string][]string
 404  	eventCacheCount     int32
 405  
 406  	// Author name/pic "old" generation - two-gen rotation so GC can collect stale entries.
 407  	authorNamesOld map[string]string
 408  	authorPicsOld  map[string]string
 409  	authorCacheCount int32
 410  
 411  	// Embed "old" generation.
 412  	embedCallbacksOld    map[string][]string
 413  	embedRequestedOld    map[string]bool
 414  	embedSubIDsOld       map[string][]string
 415  	embedCoordCBsOld     map[string][]string
 416  	embedRelayHintsOld   map[string][]string
 417  	embedCacheCount      int32
 418  
 419  	// Reply "old" generation.
 420  	replyCacheOld       map[string]string
 421  	replyAvatarCacheOld map[string]string
 422  	replyLineCacheOld   map[string]string
 423  	replyAuthorMapOld   map[string]string
 424  	replyNeedNameOld    map[string][]dom.Element
 425  	replyHintsOld       map[string]string
 426  	replyCount          int32
 427  )
 428  
 429  // rotateEventCaches swaps the event data caches and releases dropped DOM handles.
 430  func rotateEventCaches() {
 431  	var toRelease []int32
 432  	for id, el := range noteElementsOld {
 433  		if _, ok := noteElements[id]; !ok {
 434  			toRelease = append(toRelease, int32(el))
 435  		}
 436  	}
 437  	if len(toRelease) > 0 {
 438  		dom.ReleaseAll(toRelease)
 439  	}
 440  	eventCacheOld = eventCache
 441  	eventCache = map[string]*nostr.Event{}
 442  	seenEventsOld = seenEvents
 443  	seenEvents = map[string]bool{}
 444  	noteElementsOld = noteElements
 445  	noteElements = map[string]dom.Element{}
 446  	noteReactionMapOld = noteReactionMap
 447  	noteReactionMap = map[string]map[string]map[string]bool{}
 448  	noteRepostCountsOld = noteRepostCounts
 449  	noteRepostCounts = map[string]map[string]bool{}
 450  	noteZapSeenOld = noteZapSeen
 451  	noteZapSeen = map[string]map[string]bool{}
 452  	actionFetchedIDsOld = actionFetchedIDs
 453  	actionFetchedIDs = map[string]bool{}
 454  	eventRelaysOld = eventRelays
 455  	eventRelays = map[string][]string{}
 456  	noteReactionEls = map[string]dom.Element{}
 457  	noteRepostEls = map[string]dom.Element{}
 458  	noteZapEls = map[string]dom.Element{}
 459  	eventCacheCount = 0
 460  }
 461  
 462  // rotateAuthorCaches rotates the Shell name/pic view cache (two-gen for GC).
 463  // Profile Worker owns the canonical data rotation; this only covers Shell's small cache.
 464  func rotateAuthorCaches() {
 465  	authorNamesOld = authorNames
 466  	authorNames = map[string]string{}
 467  	authorPicsOld = authorPics
 468  	authorPics = map[string]string{}
 469  	authorCacheCount = 0
 470  }
 471  
 472  // rotateEmbedCaches swaps the embed caches.
 473  func rotateEmbedCaches() {
 474  	embedCallbacksOld = embedCallbacks
 475  	embedCallbacks = map[string][]string{}
 476  	embedRequestedOld = embedRequested
 477  	embedRequested = map[string]bool{}
 478  	embedSubIDsOld = embedSubIDs
 479  	embedSubIDs = map[string][]string{}
 480  	embedCoordCBsOld = embedCoordCBs
 481  	embedCoordCBs = map[string][]string{}
 482  	embedRelayHintsOld = embedRelayHints
 483  	embedRelayHints = map[string][]string{}
 484  	embedCacheCount = 0
 485  }
 486  
 487  // lookupFetchedK0 checks current and old generation.
 488  func lookupFetchedK0(pk string) (ok bool) {
 489  	if fetchedK0[pk] {
 490  		return true
 491  	}
 492  	return fetchedK0Old[pk]
 493  }
 494  
 495  // setFetchedK0 sets the entry and rotates if threshold exceeded.
 496  func setFetchedK0(pk string, v bool) {
 497  	fetchedK0[pk] = v
 498  	if v && len(fetchedK0) > rotateFetchedMax {
 499  		fetchedK0Old = fetchedK0
 500  		fetchedK0 = map[string]bool{}
 501  	}
 502  }
 503  
 504  // lookupFetchedK10k checks current and old generation.
 505  func lookupFetchedK10k(pk string) (ok bool) {
 506  	if fetchedK10k[pk] {
 507  		return true
 508  	}
 509  	return fetchedK10kOld[pk]
 510  }
 511  
 512  // setFetchedK10k sets the entry and rotates if threshold exceeded.
 513  func setFetchedK10k(pk string, v bool) {
 514  	fetchedK10k[pk] = v
 515  	if v && len(fetchedK10k) > rotateFetchedMax {
 516  		fetchedK10kOld = fetchedK10k
 517  		fetchedK10k = map[string]bool{}
 518  	}
 519  }
 520  
 521  // rotateReplyCaches swaps the reply caches.
 522  func rotateReplyCaches() {
 523  	replyCacheOld = replyCache
 524  	replyCache = map[string]string{}
 525  	replyAvatarCacheOld = replyAvatarCache
 526  	replyAvatarCache = map[string]string{}
 527  	replyLineCacheOld = replyLineCache
 528  	replyLineCache = map[string]string{}
 529  	replyAuthorMapOld = replyAuthorMap
 530  	replyAuthorMap = map[string]string{}
 531  	replyNeedNameOld = replyNeedName
 532  	replyNeedName = map[string][]dom.Element{}
 533  	replyHintsOld = replyHints
 534  	replyHints = map[string]string{}
 535  	replyCount = 0
 536  }
 537  
 538  // lookupAuthorName returns the cached display name, checking both generations.
 539  func lookupAuthorName(pk string) (string, bool) {
 540  	if name, ok := authorNames[pk]; ok {
 541  		return name, true
 542  	}
 543  	name, ok := authorNamesOld[pk]
 544  	return name, ok
 545  }
 546  
 547  // lookupAuthorPic returns the cached avatar URL, checking both generations.
 548  func lookupAuthorPic(pk string) (string, bool) {
 549  	if pic, ok := authorPics[pk]; ok {
 550  		return pic, true
 551  	}
 552  	pic, ok := authorPicsOld[pk]
 553  	return pic, ok
 554  }
 555  
 556  // lookupEvent returns the cached event, checking both generations.
 557  func lookupEvent(id string) (*nostr.Event, bool) {
 558  	if ev, ok := eventCache[id]; ok {
 559  		return ev, true
 560  	}
 561  	ev, ok := eventCacheOld[id]
 562  	return ev, ok
 563  }
 564  
 565  // setEvent stores an event in the cache and rotates if the threshold is reached.
 566  func setEvent(id string, ev *nostr.Event) {
 567  	if _, already := eventCache[id]; !already {
 568  		eventCacheCount++
 569  		if eventCacheCount > rotateEventMax {
 570  			rotateEventCaches()
 571  		}
 572  	}
 573  	eventCache[id] = ev
 574  }
 575  
 576  // setAuthorName caches a display name, rotating if the view cache exceeds the threshold.
 577  func setAuthorName(pk, name string) {
 578  	if _, already := authorNames[pk]; !already {
 579  		authorCacheCount++
 580  		if authorCacheCount > rotateAuthorMax {
 581  			rotateAuthorCaches()
 582  		}
 583  	}
 584  	authorNames[pk] = name
 585  }
 586  
 587  // setAuthorPic caches an avatar URL.
 588  func setAuthorPic(pk, pic string) {
 589  	authorPics[pk] = pic
 590  }
 591  
 592  const orlyRelay = "wss://relay.orly.dev"
 593  
 594  var defaultRelays = []string{
 595  	orlyRelay,
 596  	"wss://nostr.wine",
 597  }
 598  
 599  func isLocalDev() (ok bool) {
 600  	h := dom.Hostname()
 601  	return h == "localhost" || (len(h) > 4 && h[:4] == "127.")
 602  }
 603  
 604  func localRelayURL() (s string) {
 605  	h := dom.Hostname()
 606  	p := dom.Port()
 607  	if p == "" || p == "443" || p == "80" {
 608  		return "wss://" | h
 609  	}
 610  	return "ws://" | h | ":" | p
 611  }
 612  
 613  func main() {
 614  	dom.ConsoleLog("starting smesh " | version)
 615  	dom.FetchText("/__version", func(body string) {
 616  		if body == "" {
 617  			return
 618  		}
 619  		sv := helpers.JsonGetString(body, "v")
 620  		if sv == "" {
 621  			return
 622  		}
 623  		sbase := sv
 624  		for i := 0; i < len(sv); i++ {
 625  			if sv[i] == '+' {
 626  				sbase = sv[:i]
 627  				break
 628  			}
 629  		}
 630  		cbase := version
 631  		if len(cbase) > 0 && cbase[0] == 'v' {
 632  			cbase = cbase[1:]
 633  		}
 634  		if sbase != cbase {
 635  			dom.ConsoleLog("version mismatch: local=" | version | " server=" | sv)
 636  		}
 637  	})
 638  	initLang()
 639  	loadRelayPolicy()
 640  	// Always add the host relay as first fallback - it's the smesh relay serving this page.
 641  	localRelay := localRelayURL()
 642  	defaultRelays = []string{localRelay} | defaultRelays
 643  	dom.ConsoleLog("local relay: " | localRelay)
 644  	blossomServerURL = localstorage.GetItem(lsKeyBlossomServer)
 645  	invidiousURL = localstorage.GetItem(lsKeyInvidiousURL)
 646  	campionURL = localstorage.GetItem(lsKeyCampionURL)
 647  	devMode = localstorage.GetItem(lsKeyDevMode) == "1"
 648  	themePref := localstorage.GetItem(lsKeyTheme)
 649  	if themePref != "" {
 650  		isDark = themePref == "dark"
 651  	} else {
 652  		isDark = true
 653  	}
 654  	htmlEl := dom.QuerySelector("html")
 655  	if isDark {
 656  		dom.AddClass(htmlEl, "dark")
 657  	} else {
 658  		dom.RemoveClass(htmlEl, "dark")
 659  	}
 660  	root = dom.GetElementById("app-root")
 661  	dom.SetInnerHTML(root, "")
 662  	dom.SetAttribute(root, "data-version", version)
 663  	savedPK := localstorage.GetItem(lsKeyPubkey)
 664  	if savedPK != "" {
 665  		initSigner()
 666  		signer.IsInstalled(func(ok bool) {
 667  			if !ok {
 668  				localstorage.RemoveItem(lsKeyPubkey)
 669  				showLogin()
 670  				return
 671  			}
 672  			signer.GetVaultStatus(func(status string) {
 673  				if status != "unlocked" {
 674  					localstorage.RemoveItem(lsKeyPubkey)
 675  					showLogin()
 676  					return
 677  				}
 678  				signer.SwitchIdentity(savedPK, func(ok bool) {
 679  					if !ok {
 680  						localstorage.RemoveItem(lsKeyPubkey)
 681  						showLogin()
 682  						return
 683  					}
 684  					pubhex = savedPK
 685  					bootstrapEncKey(func() {
 686  						showApp()
 687  					})
 688  				})
 689  			})
 690  		})
 691  	} else {
 692  		showLogin()
 693  	}
 694  }
 695  
 696  // --- Theme ---
 697  
 698  func toggleTheme() {
 699  	el := dom.QuerySelector("html")
 700  	isDark = !isDark
 701  	if isDark {
 702  		dom.AddClass(el, "dark")
 703  		localstorage.SetItem(lsKeyTheme, "dark")
 704  	} else {
 705  		dom.RemoveClass(el, "dark")
 706  		localstorage.SetItem(lsKeyTheme, "light")
 707  	}
 708  	updateThemeIcon()
 709  }
 710  
 711  // logActivity appends an entry to the activity log. summary is the one-line
 712  // title shown in the list; detail is multi-line text shown when the entry is
 713  // expanded (can be empty). Newest entries first. Caps at activityLogMax.
 714  func logActivity(action, summary, detail string) {
 715  	e := &logEntry{
 716  		ts:      dom.NowSeconds(),
 717  		action:  action,
 718  		summary: summary,
 719  		detail:  detail,
 720  	}
 721  	activityLog = []*logEntry{e} | activityLog
 722  	if len(activityLog) > activityLogMax {
 723  		activityLog = activityLog[:activityLogMax]
 724  	}
 725  	if logListEl != 0 {
 726  		prependLogEntry(e)
 727  	}
 728  }
 729  
 730  // updateLogBtnHighlight sets the log button color: accent while boot is in
 731  // progress, muted otherwise.
 732  func updateLogBtnHighlight() {
 733  	if logBtn == 0 {
 734  		return
 735  	}
 736  	if bootInProgress {
 737  		dom.SetStyle(logBtn, "color", "var(--accent)")
 738  	} else {
 739  		dom.SetStyle(logBtn, "color", "var(--muted)")
 740  	}
 741  }
 742  
 743  // bootDone marks bootstrap as complete and transitions the UI from the
 744  // activity log page to the feed. Idempotent - extra calls are no-ops.
 745  func bootDone() {
 746  	if !bootInProgress {
 747  		return
 748  	}
 749  	bootInProgress = false
 750  	logActivity("boot", "bootstrap complete", "")
 751  	updateLogBtnHighlight()
 752  	if activePage == "log" {
 753  		switchPage("feed")
 754  	}
 755  }
 756  
 757  // formatHMS returns HH:MM:SS local time for unix seconds ts.
 758  func formatHMS(ts int64) (s string) {
 759  	off := int64(dom.TimezoneOffsetSeconds())
 760  	local := ts + off
 761  	if local < 0 {
 762  		local = 0
 763  	}
 764  	day := local % 86400
 765  	h := day / 3600
 766  	m := (day % 3600) / 60
 767  	sec := day % 60
 768  	pad := func(v int64) string {
 769  		if v < 10 {
 770  			return "0" | i64toa(v)
 771  		}
 772  		return i64toa(v)
 773  	}
 774  	return pad(h) | ":" | pad(m) | ":" | pad(sec)
 775  }
 776  
 777  // prependLogEntry inserts a single log entry row at the top of logListEl.
 778  // The row shows "HH:MM:SS [action] summary" and toggles detail on click.
 779  func prependLogEntry(e *logEntry) {
 780  	row := dom.CreateElement("div")
 781  	dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
 782  	dom.SetStyle(row, "padding", "4px 0")
 783  
 784  	header := dom.CreateElement("div")
 785  	dom.SetStyle(header, "cursor", "pointer")
 786  	dom.SetStyle(header, "display", "flex")
 787  	dom.SetStyle(header, "gap", "8px")
 788  	dom.SetStyle(header, "alignItems", "baseline")
 789  
 790  	tsSpan := dom.CreateElement("span")
 791  	dom.SetTextContent(tsSpan, formatHMS(e.ts))
 792  	dom.SetStyle(tsSpan, "color", "var(--muted)")
 793  	dom.SetStyle(tsSpan, "flexShrink", "0")
 794  	dom.AppendChild(header, tsSpan)
 795  
 796  	actSpan := dom.CreateElement("span")
 797  	dom.SetTextContent(actSpan, "[" | e.action | "]")
 798  	dom.SetStyle(actSpan, "color", "var(--accent)")
 799  	dom.SetStyle(actSpan, "flexShrink", "0")
 800  	dom.AppendChild(header, actSpan)
 801  
 802  	sumSpan := dom.CreateElement("span")
 803  	dom.SetTextContent(sumSpan, e.summary)
 804  	dom.SetStyle(sumSpan, "color", "var(--fg)")
 805  	dom.SetStyle(sumSpan, "overflow", "hidden")
 806  	dom.SetStyle(sumSpan, "textOverflow", "ellipsis")
 807  	dom.SetStyle(sumSpan, "whiteSpace", "nowrap")
 808  	dom.AppendChild(header, sumSpan)
 809  
 810  	dom.AppendChild(row, header)
 811  
 812  	if e.detail != "" {
 813  		detailEl := dom.CreateElement("pre")
 814  		dom.SetTextContent(detailEl, e.detail)
 815  		dom.SetStyle(detailEl, "display", "none")
 816  		dom.SetStyle(detailEl, "margin", "4px 0 0 0")
 817  		dom.SetStyle(detailEl, "padding", "6px 8px")
 818  		dom.SetStyle(detailEl, "background", "var(--bg2)")
 819  		dom.SetStyle(detailEl, "color", "var(--muted)")
 820  		dom.SetStyle(detailEl, "fontSize", "11px")
 821  		dom.SetStyle(detailEl, "whiteSpace", "pre-wrap")
 822  		dom.SetStyle(detailEl, "wordBreak", "break-all")
 823  		dom.SetStyle(detailEl, "borderRadius", "3px")
 824  		dom.AppendChild(row, detailEl)
 825  		open := false
 826  		dom.AddEventListener(header, "click", dom.RegisterCallback(func() {
 827  			open = !open
 828  			if open {
 829  				dom.SetStyle(detailEl, "display", "block")
 830  			} else {
 831  				dom.SetStyle(detailEl, "display", "none")
 832  			}
 833  		}))
 834  	}
 835  
 836  	// Insert at top of list.
 837  	first := dom.FirstChild(logListEl)
 838  	if first == 0 {
 839  		dom.AppendChild(logListEl, row)
 840  	} else {
 841  		dom.InsertBefore(logListEl, row, first)
 842  	}
 843  }
 844  
 845  const svgSun = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><circle cx="9" cy="9" r="4" stroke="currentColor" stroke-width="1.5"/><path d="M9 1v2M9 15v2M1 9h2M15 9h2M3.3 3.3l1.4 1.4M13.3 13.3l1.4 1.4M3.3 14.7l1.4-1.4M13.3 4.7l1.4-1.4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`
 846  const svgMoon = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M15.1 10.4A6.5 6.5 0 0 1 7.6 2.9 6.5 6.5 0 1 0 15.1 10.4z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`
 847  
 848  func updateThemeIcon() {
 849  	if themeBtn == 0 {
 850  		return // not yet created (login screen)
 851  	}
 852  	if isDark {
 853  		dom.SetInnerHTML(themeBtn, svgSun)
 854  	} else {
 855  		dom.SetInnerHTML(themeBtn, svgMoon)
 856  	}
 857  }
 858  
 859  // --- Login screen ---
 860  
 861  func isMobile() (ok bool) {
 862  	ua := dom.UserAgent()
 863  	n := len(ua)
 864  	for i := 0; i+7 <= n; i++ {
 865  		if ua[i:i+7] == "Android" {
 866  			return true
 867  		}
 868  	}
 869  	for i := 0; i+6 <= n; i++ {
 870  		if ua[i:i+6] == "Mobile" {
 871  			return true
 872  		}
 873  	}
 874  	return false
 875  }
 876  
 877  func showAboutModal() {
 878  	switchPage("about")
 879  }
 880  
 881  func showLogin() {
 882  	clearChildren(root)
 883  
 884  	// Allow scrolling on login page (body has overflow:hidden for the app).
 885  	dom.SetStyle(dom.Body(), "overflow", "auto")
 886  
 887  	wrap := dom.CreateElement("div")
 888  	dom.SetStyle(wrap, "display", "flex")
 889  	dom.SetStyle(wrap, "alignItems", "center")
 890  	dom.SetStyle(wrap, "flexDirection", "column")
 891  	dom.SetStyle(wrap, "padding", "48px 16px")
 892  
 893  	// Smesh logo.
 894  	logoDiv := dom.CreateElement("div")
 895  	dom.SetStyle(logoDiv, "width", "180px")
 896  	dom.SetStyle(logoDiv, "height", "180px")
 897  	dom.SetStyle(logoDiv, "marginBottom", "16px")
 898  	dom.SetStyle(logoDiv, "color", "var(--accent)")
 899  	dom.FetchText("/smesh-logo.svg", func(svg string) {
 900  		dom.SetInnerHTML(logoDiv, svg)
 901  		svgEl := dom.FirstElementChild(logoDiv)
 902  		if svgEl != 0 {
 903  			dom.SetAttribute(svgEl, "width", "100%")
 904  			dom.SetAttribute(svgEl, "height", "100%")
 905  		}
 906  	})
 907  	dom.AppendChild(wrap, logoDiv)
 908  
 909  	// Title.
 910  	h1 := dom.CreateElement("h1")
 911  	dom.SetTextContent(h1, "S.M.E.S.H.")
 912  	dom.SetStyle(h1, "color", "var(--accent)")
 913  	dom.SetStyle(h1, "fontSize", "48px")
 914  	dom.SetStyle(h1, "marginBottom", "4px")
 915  	dom.AppendChild(wrap, h1)
 916  
 917  	verTag := dom.CreateElement("span")
 918  	dom.SetTextContent(verTag, version)
 919  	dom.SetStyle(verTag, "color", "var(--muted)")
 920  	dom.SetStyle(verTag, "fontSize", "12px")
 921  	dom.AppendChild(wrap, verTag)
 922  
 923  	sub := dom.CreateElement("p")
 924  	dom.SetStyle(sub, "color", "var(--muted)")
 925  	dom.SetStyle(sub, "fontSize", "14px")
 926  	dom.SetStyle(sub, "marginBottom", "32px")
 927  	dom.SetStyle(sub, "textAlign", "center")
 928  	dom.SetTextContent(sub, t("subtitle"))
 929  	dom.AppendChild(wrap, sub)
 930  
 931  	// Show login flow.
 932  	errEl := dom.CreateElement("div")
 933  	dom.SetStyle(errEl, "color", "#e55")
 934  	dom.SetStyle(errEl, "fontSize", "13px")
 935  	dom.SetStyle(errEl, "marginBottom", "12px")
 936  	dom.SetStyle(errEl, "minHeight", "18px")
 937  	dom.AppendChild(wrap, errEl)
 938  
 939  	btn := dom.CreateElement("button")
 940  	dom.SetTextContent(btn, t("login"))
 941  	dom.SetAttribute(btn, "type", "button")
 942  	dom.SetStyle(btn, "padding", "10px 32px")
 943  	dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
 944  	dom.SetStyle(btn, "fontSize", "14px")
 945  	dom.SetStyle(btn, "background", "var(--accent)")
 946  	dom.SetStyle(btn, "color", "#fff")
 947  	dom.SetStyle(btn, "border", "none")
 948  	dom.SetStyle(btn, "borderRadius", "4px")
 949  	dom.SetStyle(btn, "cursor", "pointer")
 950  	dom.AppendChild(wrap, btn)
 951  
 952  
 953  	appendLoginFooter(wrap)
 954  	dom.SetStyle(wrap, "paddingBottom", "48px")
 955  	dom.AppendChild(root, wrap)
 956  
 957  	buildStatusBar(false)
 958  	initSigner()
 959  
 960  	// Auto-login: if signer is ready with exactly one usable identity, skip the login page.
 961  	signer.IsInstalled(func(ok bool) {
 962  		if !ok {
 963  			return
 964  		}
 965  		signer.GetVaultStatus(func(status string) {
 966  			if status != "unlocked" {
 967  				return
 968  			}
 969  			signer.GetPublicKey(func(hex string) {
 970  				if hex == "" {
 971  					return
 972  				}
 973  				dom.ConsoleLog("[login] auto-login pubkey: " | hex)
 974  				pubhex = hex
 975  				localstorage.SetItem(lsKeyPubkey, pubhex)
 976  				clearChildren(root)
 977  				bootstrapEncKey(func() {
 978  					showApp()
 979  				})
 980  			})
 981  		})
 982  	})
 983  
 984  	cb := dom.RegisterCallback(func() {
 985  		signer.GetVaultStatus(func(status string) {
 986  			if status == "none" || status == "locked" {
 987  				toggleSignerPanel()
 988  				return
 989  			}
 990  			dom.SetTextContent(btn, t("requesting"))
 991  			signer.GetPublicKey(func(hex string) {
 992  				if hex == "" {
 993  					dom.SetTextContent(errEl, t("err_no_id"))
 994  					dom.SetTextContent(btn, t("login"))
 995  					toggleSignerPanel()
 996  					return
 997  				}
 998  				dom.ConsoleLog("[login] manual-login pubkey: " | hex)
 999  				pubhex = hex
1000  				localstorage.SetItem(lsKeyPubkey, pubhex)
1001  				clearChildren(root)
1002  				bootstrapEncKey(func() {
1003  					showApp()
1004  				})
1005  			})
1006  		})
1007  	})
1008  	dom.AddEventListener(btn, "click", cb)
1009  }
1010  
1011  func appendLoginFooter(wrap dom.Element) {
1012  	footer := dom.CreateElement("div")
1013  	dom.SetStyle(footer, "display", "flex")
1014  	dom.SetStyle(footer, "flexDirection", "column")
1015  	dom.SetStyle(footer, "gap", "12px")
1016  	dom.SetStyle(footer, "marginTop", "24px")
1017  	dom.SetStyle(footer, "alignItems", "center")
1018  
1019  	// Language chooser.
1020  	langBox := dom.CreateElement("div")
1021  	dom.SetStyle(langBox, "display", "flex")
1022  	dom.SetStyle(langBox, "alignItems", "center")
1023  	dom.SetStyle(langBox, "gap", "6px")
1024  
1025  	langLabel := dom.CreateElement("span")
1026  	dom.SetTextContent(langLabel, t("language"))
1027  	dom.SetStyle(langLabel, "fontSize", "12px")
1028  	dom.SetStyle(langLabel, "color", "var(--muted)")
1029  	dom.AppendChild(langBox, langLabel)
1030  
1031  	langSel := dom.CreateElement("select")
1032  	dom.SetStyle(langSel, "fontFamily", "'Fira Code', monospace")
1033  	dom.SetStyle(langSel, "fontSize", "12px")
1034  	dom.SetStyle(langSel, "background", "var(--bg2)")
1035  	dom.SetStyle(langSel, "color", "var(--fg)")
1036  	dom.SetStyle(langSel, "border", "1px solid var(--border)")
1037  	dom.SetStyle(langSel, "borderRadius", "4px")
1038  	dom.SetStyle(langSel, "padding", "4px 8px")
1039  	for code, name := range langNames {
1040  		opt := dom.CreateElement("option")
1041  		dom.SetAttribute(opt, "value", code)
1042  		dom.SetTextContent(opt, name)
1043  		if code == currentLang {
1044  			dom.SetAttribute(opt, "selected", "selected")
1045  		}
1046  		dom.AppendChild(langSel, opt)
1047  	}
1048  	dom.AddEventListener(langSel, "change", dom.RegisterCallback(func() {
1049  		val := dom.GetProperty(langSel, "value")
1050  		setLang(val)
1051  		showLogin() // re-render with new language
1052  	}))
1053  	dom.AppendChild(langBox, langSel)
1054  	dom.AppendChild(footer, langBox)
1055  
1056  	// Theme toggle.
1057  	themeBox := dom.CreateElement("div")
1058  	dom.SetStyle(themeBox, "display", "flex")
1059  	dom.SetStyle(themeBox, "alignItems", "center")
1060  	dom.SetStyle(themeBox, "gap", "6px")
1061  
1062  	themeLabel := dom.CreateElement("span")
1063  	dom.SetTextContent(themeLabel, t("theme"))
1064  	dom.SetStyle(themeLabel, "fontSize", "12px")
1065  	dom.SetStyle(themeLabel, "color", "var(--muted)")
1066  	dom.AppendChild(themeBox, themeLabel)
1067  
1068  	themeToggle := dom.CreateElement("button")
1069  	if isDark {
1070  		dom.SetTextContent(themeToggle, t("light"))
1071  	} else {
1072  		dom.SetTextContent(themeToggle, t("dark"))
1073  	}
1074  	dom.SetStyle(themeToggle, "fontFamily", "'Fira Code', monospace")
1075  	dom.SetStyle(themeToggle, "fontSize", "12px")
1076  	dom.SetStyle(themeToggle, "background", "var(--bg2)")
1077  	dom.SetStyle(themeToggle, "color", "var(--fg)")
1078  	dom.SetStyle(themeToggle, "border", "1px solid var(--border)")
1079  	dom.SetStyle(themeToggle, "borderRadius", "4px")
1080  	dom.SetStyle(themeToggle, "padding", "4px 12px")
1081  	dom.SetStyle(themeToggle, "cursor", "pointer")
1082  	dom.AddEventListener(themeToggle, "click", dom.RegisterCallback(func() {
1083  		toggleTheme()
1084  		if isDark {
1085  			dom.SetTextContent(themeToggle, t("light"))
1086  		} else {
1087  			dom.SetTextContent(themeToggle, t("dark"))
1088  		}
1089  	}))
1090  	dom.AppendChild(themeBox, themeToggle)
1091  	dom.AppendChild(footer, themeBox)
1092  
1093  	// Hard refresh button - clears SW cache and reloads.
1094  	optBox := dom.CreateElement("div")
1095  	dom.SetStyle(optBox, "display", "flex")
1096  	dom.SetStyle(optBox, "alignItems", "center")
1097  	dom.SetStyle(optBox, "gap", "4px")
1098  	refreshBtn := dom.CreateElement("button")
1099  	dom.SetTextContent(refreshBtn, "\u21bb")
1100  	dom.SetStyle(refreshBtn, "fontSize", "14px")
1101  	dom.SetStyle(refreshBtn, "background", "transparent")
1102  	dom.SetStyle(refreshBtn, "border", "1px solid var(--border)")
1103  	dom.SetStyle(refreshBtn, "borderRadius", "4px")
1104  	dom.SetStyle(refreshBtn, "color", "var(--muted)")
1105  	dom.SetStyle(refreshBtn, "cursor", "pointer")
1106  	dom.SetStyle(refreshBtn, "padding", "2px 8px")
1107  	dom.SetStyle(refreshBtn, "marginLeft", "4px")
1108  	dom.AddEventListener(refreshBtn, "click", dom.RegisterCallback(func() {
1109  		dom.HardRefresh()
1110  	}))
1111  	dom.AppendChild(optBox, refreshBtn)
1112  
1113  	dom.AppendChild(footer, optBox)
1114  
1115  	// Dev mode toggle - disables translation worker.
1116  	devBox := dom.CreateElement("div")
1117  	dom.SetStyle(devBox, "display", "flex")
1118  	dom.SetStyle(devBox, "alignItems", "center")
1119  	dom.SetStyle(devBox, "gap", "6px")
1120  
1121  	devLabel := dom.CreateElement("span")
1122  	dom.SetTextContent(devLabel, "dev mode")
1123  	dom.SetStyle(devLabel, "fontSize", "12px")
1124  	dom.SetStyle(devLabel, "color", "var(--muted)")
1125  	dom.AppendChild(devBox, devLabel)
1126  
1127  	devToggle := dom.CreateElement("button")
1128  	if devMode {
1129  		dom.SetTextContent(devToggle, "on")
1130  		dom.SetStyle(devToggle, "color", "var(--accent)")
1131  	} else {
1132  		dom.SetTextContent(devToggle, "off")
1133  		dom.SetStyle(devToggle, "color", "var(--muted)")
1134  	}
1135  	dom.SetStyle(devToggle, "fontFamily", "'Fira Code', monospace")
1136  	dom.SetStyle(devToggle, "fontSize", "12px")
1137  	dom.SetStyle(devToggle, "background", "var(--bg2)")
1138  	dom.SetStyle(devToggle, "border", "1px solid var(--border)")
1139  	dom.SetStyle(devToggle, "borderRadius", "4px")
1140  	dom.SetStyle(devToggle, "padding", "4px 12px")
1141  	dom.SetStyle(devToggle, "cursor", "pointer")
1142  	dom.AddEventListener(devToggle, "click", dom.RegisterCallback(func() {
1143  		devMode = !devMode
1144  		if devMode {
1145  			localstorage.SetItem(lsKeyDevMode, "1")
1146  			dom.SetTextContent(devToggle, "on")
1147  			dom.SetStyle(devToggle, "color", "var(--accent)")
1148  		} else {
1149  			localstorage.RemoveItem(lsKeyDevMode)
1150  			dom.SetTextContent(devToggle, "off")
1151  			dom.SetStyle(devToggle, "color", "var(--muted)")
1152  		}
1153  	}))
1154  	dom.AppendChild(devBox, devToggle)
1155  	dom.AppendChild(footer, devBox)
1156  
1157  	dom.AppendChild(wrap, footer)
1158  }
1159  
1160  // --- Sidebar ---
1161  
1162  const svgCompose = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M12.3 2.3l3.4 3.4L6 15.4l-4 1 1-4L12.3 2.3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M10.5 4.1l3.4 3.4" stroke="currentColor" stroke-width="1.5"/></svg>`
1163  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>`
1164  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>`
1165  const svgGear = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M7.5 2h3l.4 2.1a5.5 5.5 0 0 1 1.3.7L14.3 4l1.5 2.6-1.7 1.4a5.6 5.6 0 0 1 0 1.5l1.7 1.4-1.5 2.6-2.1-.8a5.5 5.5 0 0 1-1.3.7L10.5 16h-3l-.4-2.1a5.5 5.5 0 0 1-1.3-.7L3.7 14l-1.5-2.6 1.7-1.4a5.6 5.6 0 0 1 0-1.5L2.2 7.1 3.7 4.5l2.1.8a5.5 5.5 0 0 1 1.3-.7L7.5 2z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><circle cx="9" cy="9.3" r="2" stroke="currentColor" stroke-width="1.3"/></svg>`
1166  const svgBell = `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M13.5 6.5a4.5 4.5 0 1 0-9 0c0 5-2 6.5-2 6.5h13s-2-1.5-2-6.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.8 15a1.5 1.5 0 0 0 2.4 0" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`
1167  
1168  // Action button SVGs - same stroke style as sidebar icons.
1169  const svgReply = `<svg width="16" height="16" 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>`
1170  const svgQuote = `<svg width="16" height="16" viewBox="0 0 18 18" fill="none"><path d="M2 3h14v9H10l-2 3-2-3H2z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><circle cx="6" cy="7.5" r="0.8" fill="currentColor"/><circle cx="9" cy="7.5" r="0.8" fill="currentColor"/><circle cx="12" cy="7.5" r="0.8" fill="currentColor"/></svg>`
1171  const svgRepost = `<svg width="16" height="16" viewBox="0 0 18 18" fill="none"><path d="M12.5 3l2.5 2.5-2.5 2.5M5.5 15l-2.5-2.5 2.5-2.5M15 5.5H6M3 12.5h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`
1172  const svgReact = `<svg width="16" height="16" viewBox="0 0 18 18" fill="none"><circle cx="9" cy="9" r="7" stroke="currentColor" stroke-width="1.5"/><circle cx="6.5" cy="7.5" r="0.8" fill="currentColor"/><circle cx="11.5" cy="7.5" r="0.8" fill="currentColor"/><path d="M6 11a3.5 3.5 0 0 0 6 0" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`
1173  const svgZap = `<svg width="16" height="16" viewBox="0 0 18 18" fill="none"><path d="M12 1L4 10h4l-2 7 8-9h-4l2-7z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round" fill="none"/></svg>`
1174  
1175  func makeSidebarIcon(svgHTML string, active bool) (e dom.Element) {
1176  	btn := dom.CreateElement("button")
1177  	dom.SetStyle(btn, "width", "36px")
1178  	dom.SetStyle(btn, "height", "36px")
1179  	dom.SetStyle(btn, "border", "none")
1180  	dom.SetStyle(btn, "borderRadius", "6px")
1181  	dom.SetStyle(btn, "cursor", "pointer")
1182  	dom.SetStyle(btn, "display", "flex")
1183  	dom.SetStyle(btn, "alignItems", "center")
1184  	dom.SetStyle(btn, "justifyContent", "center")
1185  	dom.SetStyle(btn, "padding", "0")
1186  	dom.SetStyle(btn, "color", "var(--fg)")
1187  	if active {
1188  		dom.SetStyle(btn, "background", "var(--accent)")
1189  		dom.SetStyle(btn, "color", "#fff")
1190  	} else {
1191  		dom.SetStyle(btn, "background", "transparent")
1192  	}
1193  	dom.SetInnerHTML(btn, svgHTML)
1194  	return btn
1195  }
1196  
1197  // teardownPage sends CLOSE for page-specific subscriptions on page exit.
1198  // stopInlineMedia restores the clickable YouTube thumbnail for any active
1199  // Invidious inline embeds. The wrapper's original click handler (registered by
1200  // attachYTLightboxes) is still in place so tapping the thumbnail re-embeds.
1201  func stopInlineMedia(container dom.Element) {
1202  	el := dom.QuerySelectorFrom(container, "[data-inv-ytid]")
1203  	for el != 0 {
1204  		ytID := dom.GetAttribute(el, "data-inv-ytid")
1205  		dom.RemoveAttribute(el, "data-inv-ytid")
1206  
1207  		// Clear iframe + controls.
1208  		dom.ReleaseChildren(el)
1209  		dom.SetInnerHTML(el, "")
1210  
1211  		// Restore the YouTube thumbnail so the user can tap to re-embed.
1212  		if ytID != "" {
1213  			thumbURL := "https://img.youtube.com/vi/" | ytID | "/hqdefault.jpg"
1214  			img := dom.CreateElement("img")
1215  			dom.SetAttribute(img, "referrerpolicy", "no-referrer")
1216  			dom.SetStyle(img, "display", "block")
1217  			dom.SetStyle(img, "maxWidth", "100%")
1218  			dom.SetStyle(img, "borderRadius", "8px")
1219  			dom.SetStyle(img, "objectFit", "contain")
1220  			setMediaSrc(img, thumbURL)
1221  			dom.AppendChild(el, img)
1222  		}
1223  		dom.SetStyle(el, "cursor", "pointer")
1224  
1225  		el = dom.QuerySelectorFrom(container, "[data-inv-ytid]")
1226  	}
1227  }
1228  
1229  func teardownPage(page string) {
1230  	if page == "feed" {
1231  		stopInlineMedia(feedContainer)
1232  		stopInlineMedia(threadContainer)
1233  	}
1234  	if page == "search" {
1235  		routeMsg("[\"CLOSE\",\"search\"]")
1236  		searchSeen = map[string]bool{}
1237  		// Always restore topBarRight and other hidden elements on exit.
1238  		if searchBarActive {
1239  			deactivateSearchBar(searchBtnGlobal)
1240  		}
1241  	}
1242  	if page == "notifications" {
1243  		// Cancel pending timers so their closures don't fire after page exit.
1244  		if notifMoreTimer != 0 {
1245  			dom.ClearTimeout(notifMoreTimer)
1246  			notifMoreTimer = 0
1247  		}
1248  		if notifRefTimer != 0 {
1249  			dom.ClearTimeout(notifRefTimer)
1250  			notifRefTimer = 0
1251  		}
1252  		// Release accumulated notif row callbacks to prevent indefinite
1253  		// growth of the Moxie callback table across multiple page visits.
1254  		releaseNotifRowCBs()
1255  		notifBuilt = false
1256  		// Clear avatar pending queue; the DOM elements are gone.
1257  		for pk := range pendingNotifAvatars {
1258  			delete(pendingNotifAvatars, pk)
1259  		}
1260  	}
1261  }
1262  
1263  func switchPage(name string) {
1264  	closeFeedPopover()
1265  	// Always close thread if open, even when re-selecting the feed tab.
1266  	if threadOpen {
1267  		closeNoteThread()
1268  		if !navPop {
1269  			dom.ReplaceState("/")
1270  		}
1271  	}
1272  	if name == activePage {
1273  		return
1274  	}
1275  	teardownPage(activePage)
1276  	closeProfileNoteSub()
1277  	if activePage == "profile" {
1278  		profileViewPK = ""
1279  	}
1280  
1281  	activePage = name
1282  
1283  	// Ensure top bar is in normal state.
1284  	dom.SetStyle(topBackBtn, "display", "none")
1285  	dom.SetStyle(feedSelectEl, "display", "none")
1286  	dom.SetStyle(pageTitleEl, "display", "none")
1287  	dom.SetStyle(notifBarGroup, "display", "none")
1288  	if name == "feed" {
1289  		dom.SetStyle(feedSelectEl, "display", "inline")
1290  	} else if name == "notifications" {
1291  		dom.SetStyle(notifBarGroup, "display", "flex")
1292  	} else {
1293  		dom.SetTextContent(pageTitleEl, t(name))
1294  		dom.SetStyle(pageTitleEl, "display", "inline")
1295  	}
1296  
1297  	// Hide all pages.
1298  	dom.SetStyle(feedPage, "display", "none")
1299  	dom.SetStyle(msgPage, "display", "none")
1300  	dom.SetStyle(profilePage, "display", "none")
1301  	dom.SetStyle(settingsPage, "display", "none")
1302  	dom.SetStyle(composePage, "display", "none")
1303  	dom.SetStyle(aboutPage, "display", "none")
1304  	dom.SetStyle(notifPage, "display", "none")
1305  	dom.SetStyle(searchPage, "display", "none")
1306  	if logPage != 0 {
1307  		dom.SetStyle(logPage, "display", "none")
1308  	}
1309  
1310  	// Clear sidebar highlights.
1311  	dom.SetStyle(sidebarCompose, "background", "transparent")
1312  	dom.SetStyle(sidebarCompose, "color", "var(--fg)")
1313  	dom.SetStyle(sidebarFeed, "background", "transparent")
1314  	dom.SetStyle(sidebarFeed, "color", "var(--fg)")
1315  	dom.SetStyle(sidebarMsg, "background", "transparent")
1316  	dom.SetStyle(sidebarMsg, "color", "var(--fg)")
1317  	dom.SetStyle(sidebarNotif, "background", "transparent")
1318  	dom.SetStyle(sidebarNotif, "color", "var(--fg)")
1319  	dom.SetStyle(sidebarSettings, "background", "transparent")
1320  	dom.SetStyle(sidebarSettings, "color", "var(--fg)")
1321  
1322  	switch name {
1323  	case "compose":
1324  		dom.SetStyle(composePage, "display", "flex")
1325  		dom.SetStyle(composePage, "flexDirection", "column")
1326  		dom.SetStyle(sidebarCompose, "background", "var(--accent)")
1327  		dom.SetStyle(sidebarCompose, "color", "#fff")
1328  		if !navPop {
1329  			dom.PushState("/compose")
1330  		}
1331  	case "feed":
1332  		dom.SetStyle(feedPage, "display", "block")
1333  		dom.SetStyle(sidebarFeed, "background", "var(--accent)")
1334  		dom.SetStyle(sidebarFeed, "color", "#fff")
1335  		if !navPop {
1336  			dom.PushState(feedModeURL())
1337  		}
1338  	case "messaging":
1339  		dom.SetStyle(msgPage, "display", "block")
1340  		dom.SetStyle(sidebarMsg, "background", "var(--accent)")
1341  		dom.SetStyle(sidebarMsg, "color", "#fff")
1342  		initMessaging()
1343  		if !navPop {
1344  			dom.PushState("/msg")
1345  		}
1346  	case "notifications":
1347  		dom.SetStyle(notifPage, "display", "block")
1348  		dom.SetStyle(sidebarNotif, "background", "var(--accent)")
1349  		dom.SetStyle(sidebarNotif, "color", "#fff")
1350  		notifUnread = false
1351  		dom.SetStyle(notifDot, "display", "none")
1352  		dom.SetProperty(contentArea, "scrollTop", "0")
1353  		if !notifBuilt {
1354  			renderNotifPage()
1355  		}
1356  		if !navPop {
1357  			dom.PushState("/notifications")
1358  		}
1359  	case "settings":
1360  		dom.SetStyle(settingsPage, "display", "block")
1361  		dom.SetStyle(sidebarSettings, "background", "var(--accent)")
1362  		dom.SetStyle(sidebarSettings, "color", "#fff")
1363  		renderSettings()
1364  		if !navPop {
1365  			dom.PushState("/settings")
1366  		}
1367  	case "profile":
1368  		dom.SetStyle(profilePage, "display", "block")
1369  		dom.SetStyle(pageTitleEl, "display", "none")
1370  		dom.SetStyle(topBackBtn, "display", "inline")
1371  		// Profile URL is pushed by showProfile, not here.
1372  	case "about":
1373  		dom.SetStyle(aboutPage, "display", "block")
1374  		if !navPop {
1375  			dom.PushState("/about")
1376  		}
1377  	case "log":
1378  		dom.SetStyle(logPage, "display", "block")
1379  		dom.SetTextContent(pageTitleEl, "activity log")
1380  		dom.SetStyle(pageTitleEl, "display", "inline")
1381  		if !navPop {
1382  			dom.PushState("/log")
1383  		}
1384  	case "search":
1385  		dom.SetStyle(searchPage, "display", "block")
1386  		dom.SetStyle(pageTitleEl, "display", "none")
1387  		// Restore search input value without activating the bar (topBarRight
1388  		// stays visible). Bar is only activated by explicit user action.
1389  		if searchCurrentQ != "" && searchBtnGlobal != 0 {
1390  			dom.SetProperty(searchInputEl, "value", searchCurrentQ)
1391  		}
1392  		if !navPop {
1393  			if searchCurrentQ != "" {
1394  				dom.PushState("/search?q=" | dom.EncodeURIComponent(searchCurrentQ))
1395  			} else {
1396  				dom.PushState("/search")
1397  			}
1398  		}
1399  	}
1400  }
1401  
1402  // navigateToPath handles URL-based routing for back/forward and initial load.
1403  // fullPath may include a hash fragment, e.g. "/p/npub1...#follows".
1404  func navigateToPath(fullPath string) {
1405  	path := fullPath
1406  	hash := ""
1407  	for i := 0; i < len(fullPath); i++ {
1408  		if fullPath[i] == '#' {
1409  			path = fullPath[:i]
1410  			hash = fullPath[i+1:]
1411  			break
1412  		}
1413  	}
1414  
1415  	feedPath := false
1416  	newFeedMode := ""
1417  	if path == "/" || path == "/feed" || path == "" || path == "/feed/follows" {
1418  		feedPath = true
1419  		newFeedMode = "follows"
1420  	} else if path == "/feed/relays" {
1421  		feedPath = true
1422  		newFeedMode = "relays"
1423  	} else if len(path) > 12 && path[:12] == "/feed/relay/" {
1424  		feedPath = true
1425  		host := path[12:]
1426  		// Local addresses use ws://, remote use wss://.
1427  		if len(host) > 4 && host[:4] == "127." || len(host) > 10 && host[:10] == "localhost:" {
1428  			newFeedMode = "ws://" | host
1429  		} else {
1430  			newFeedMode = "wss://" | host
1431  		}
1432  	}
1433  	if feedPath {
1434  		modeChanged := newFeedMode != feedMode
1435  		feedMode = newFeedMode
1436  		if feedSelectEl != 0 {
1437  			updateFeedBtnText()
1438  		}
1439  		if threadOpen {
1440  			closeNoteThread()
1441  		}
1442  		switchPage("feed")
1443  		if modeChanged {
1444  			refreshFeed()
1445  		}
1446  	} else if len(path) > 3 && path[:3] == "/t/" {
1447  		switchPage("feed")
1448  		rootID := path[3:]
1449  		if len(rootID) == 64 {
1450  			focusID := rootID
1451  			hash := dom.GetHash()
1452  			if len(hash) == 65 && hash[0] == '#' {
1453  				focusID = hash[1:]
1454  			}
1455  			showNoteThread(rootID, focusID)
1456  		}
1457  	} else if path == "/compose" {
1458  		switchPage("compose")
1459  	} else if path == "/settings" {
1460  		switchPage("settings")
1461  	} else if path == "/msg" {
1462  		switchPage("messaging")
1463  		if msgView == "thread" {
1464  			closeThread()
1465  		}
1466  	} else if len(path) > 5 && path[:5] == "/msg/" {
1467  		pk := npubToHex(path[5:])
1468  		if pk != "" {
1469  			switchPage("messaging")
1470  			openThread(pk)
1471  		}
1472  	} else if path == "/notifications" {
1473  		switchPage("notifications")
1474  	} else if path == "/search" || (len(path) >= 8 && path[:8] == "/search?") {
1475  		// Parse ?q= parameter.
1476  		q := ""
1477  		qi := strIndex(path, "?q=")
1478  		if qi >= 0 {
1479  			q = path[qi+3:]
1480  			// URL-decode the query (basic: replace %20 etc.) - use raw for now.
1481  		}
1482  		if q != "" {
1483  			executeSearch(q)
1484  		} else {
1485  			switchPage("search")
1486  		}
1487  	} else if path == "/about" {
1488  		switchPage("about")
1489  	} else if path == "/log" {
1490  		switchPage("log")
1491  	} else if len(path) > 3 && path[:3] == "/p/" {
1492  		pk := npubToHex(path[3:])
1493  		if pk != "" {
1494  			showProfile(pk)
1495  			if hash != "" {
1496  				selectProfileTab(hash, pk)
1497  			}
1498  		}
1499  	}
1500  }
1501  
1502  func npubToHex(npub string) (s string) {
1503  	b := helpers.DecodeNpub(npub)
1504  	if b == nil {
1505  		return ""
1506  	}
1507  	return helpers.HexEncode(b)
1508  }
1509  
1510  func initRouter() {
1511  	if routerInited {
1512  		return
1513  	}
1514  	routerInited = true
1515  	dom.OnPopState(func(path string) {
1516  		navPop = true
1517  		navigateToPath(path)
1518  		navPop = false
1519  	})
1520  	dom.InterceptInternalLinks(func(path string) {
1521  		navigateToPath(path)
1522  	})
1523  
1524  	// Navigate to initial URL if not root.
1525  	path := dom.GetPath()
1526  	if path != "/" && path != "" {
1527  		navPop = true
1528  		navigateToPath(path)
1529  		navPop = false
1530  	} else {
1531  		dom.ReplaceState(feedModeURL())
1532  	}
1533  }
1534  
1535  func makeProtoBtn(label string) (e dom.Element) {
1536  	btn := dom.CreateElement("button")
1537  	dom.SetTextContent(btn, label)
1538  	dom.SetStyle(btn, "padding", "6px 8px")
1539  	dom.SetStyle(btn, "border", "none")
1540  	dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
1541  	dom.SetStyle(btn, "fontSize", "12px")
1542  	dom.SetStyle(btn, "cursor", "default")
1543  	dom.SetStyle(btn, "background", "transparent")
1544  	dom.SetStyle(btn, "color", "var(--fg)")
1545  	return btn
1546  }
1547  
1548  // --- Main app ---
1549  
1550  func showApp() {
1551  	dom.SetStyle(dom.Body(), "overflow", "hidden")
1552  	appReady = false
1553  	bootInProgress = true
1554  	activityLog = nil
1555  	seenOnSubs = map[string]func(){}
1556  	logActivity("boot", "bootstrap start " | version, "pubkey: " | pubhex)
1557  	// Preload NWC connections so zap button can offer NWC path without
1558  	// requiring a settings-page visit.
1559  	nwcRefreshList(nil)
1560  	// Init profile view caches and filter sets.
1561  	authorNames = map[string]string{}
1562  	authorPics = map[string]string{}
1563  	pendingNotes = map[string][]dom.Element{}
1564  	pendingNotifAvatars = map[string][]dom.Element{}
1565  	seenEvents = map[string]bool{}
1566  	fetchedK0 = map[string]bool{}
1567  	fetchedK0Old = map[string]bool{}
1568  	fetchedK10k = map[string]bool{}
1569  	fetchedK10kOld = map[string]bool{}
1570  	fetchQueue = nil
1571  	fetchTimer = 0
1572  	retryRound = 0
1573  	authorSubPK = map[string]string{}
1574  	eventRelays = map[string][]string{}
1575  	eventRelaysOld = map[string][]string{}
1576  	pendingDeletes = map[string]string{}
1577  	noteElements = map[string]dom.Element{}
1578  	myFollows = nil
1579  	myMutes = nil
1580  	followSet = map[string]bool{}
1581  	muteSet = map[string]bool{}
1582  	profileContentCache = map[string]string{}
1583  	profileFollowsCache = map[string][]string{}
1584  	profileMutesCache = map[string][]string{}
1585  	profileRelaysCache = map[string][]string{}
1586  	profileNotesSeen = map[string]bool{}
1587  	profileTabBtns = map[string]dom.Element{}
1588  	profileWrappers = map[string]dom.Element{}
1589  	profileWrapTabCont = map[string]dom.Element{}
1590  	profileWrapTabs = map[string]map[string]dom.Element{}
1591  	profileWrapTabSel = map[string]string{}
1592  	profileWrapOrder = nil
1593  	embedCallbacks = map[string][]string{}
1594  	embedRelayHints = map[string][]string{}
1595  	embedRequested = map[string]bool{}
1596  	embedSubIDs = map[string][]string{}
1597  	embedCoordCBs = map[string][]string{}
1598  	embedCoordSubIDs = map[string]string{}
1599  	threadEvents = map[string]*nostr.Event{}
1600  	replyCache = map[string]string{}
1601  	replyAvatarCache = map[string]string{}
1602  	replyLineCache = map[string]string{}
1603  	replyAuthorMap = map[string]string{}
1604  	replyAuthorMapOld = map[string]string{}
1605  	replyPending = map[string][]dom.Element{}
1606  	replyHints = map[string]string{}
1607  	replyNeedName = map[string][]dom.Element{}
1608  	replyNeedNameOld = map[string][]dom.Element{}
1609  	noteRepostCounts = map[string]map[string]bool{}
1610  	noteReactionMap = map[string]map[string]map[string]bool{}
1611  	noteReactionEls = map[string]dom.Element{}
1612  	noteRepostEls = map[string]dom.Element{}
1613  	noteZapTotals = map[string]int64{}
1614  	noteZapSeen = map[string]map[string]bool{}
1615  	noteZapEls = map[string]dom.Element{}
1616  	pendingZapRows = map[string][]zapRowPending{}
1617  	actionFetchedIDs = map[string]bool{}
1618  	eventCache = map[string]*nostr.Event{}
1619  	// Initialize "old" generation maps so range over nil is safe on first rotate.
1620  	eventCacheOld = map[string]*nostr.Event{}
1621  	seenEventsOld = map[string]bool{}
1622  	noteElementsOld = map[string]dom.Element{}
1623  	noteReactionMapOld = map[string]map[string]map[string]bool{}
1624  	noteRepostCountsOld = map[string]map[string]bool{}
1625  	noteZapSeenOld = map[string]map[string]bool{}
1626  	actionFetchedIDsOld = map[string]bool{}
1627  	authorNamesOld = map[string]string{}
1628  	authorPicsOld = map[string]string{}
1629  	embedCallbacksOld = map[string][]string{}
1630  	embedRequestedOld = map[string]bool{}
1631  	embedSubIDsOld = map[string][]string{}
1632  	embedCoordCBsOld = map[string][]string{}
1633  	replyCacheOld = map[string]string{}
1634  	replyAvatarCacheOld = map[string]string{}
1635  	replyLineCacheOld = map[string]string{}
1636  	eventCacheCount = 0
1637  	authorCacheCount = 0
1638  	embedCacheCount = 0
1639  	replyCount = 0
1640  	editorDrafts = map[string]string{}
1641  	notifSeen = map[string]bool{}
1642  	notifEvents = []*nostr.Event{:0}
1643  	releaseNotifRowCBs()
1644  	notifUnread = false
1645  	notifBuilt = false
1646  	dom.IDBGet("settings", "notif-read-ts", func(val string) {
1647  		if val != "" {
1648  			notifReadTs = parseI64(val)
1649  		}
1650  	})
1651  	if feedMode == "" {
1652  		feedMode = "follows"
1653  	}
1654  	feedInitialLoad = true
1655  	feedBuffer = nil
1656  	initEmoji()
1657  	initMentionPopup()
1658  
1659  	// Set up SW + relay-proxy worker communication. Both deliver into the
1660  	// same dispatch (onSWMessage); message-type prefixes are unique per
1661  	// source so they cannot collide.
1662  	if !routerInited {
1663  		dom.OnSWMessage(onSWMessage)
1664  		relayproxy.OnMessage(onSWMessage)
1665  		profile.OnMessage(onSWMessage)
1666  		feed.OnMessage(onSWMessage)
1667  		mlsw.OnMessage(onSWMessage)
1668  		notif.OnMessage(onSWMessage)
1669  	}
1670  	routeMsg("[\"SET_PUBKEY\"," | jstr(pubhex) | "]")
1671  	sendEncKeyToSW()
1672  	// Sync pubkey to MLS worker.
1673  	mlsw.Send("[\"M_SET_PUBKEY\"," | jstr(pubhex) | "]")
1674  
1675  	// Load cached profiles from IndexedDB.
1676  	// Populate name/pic view cache and send loaded pks to Profile Worker.
1677  	var idbLoadedPKs []string
1678  	dom.IDBGetAll("profiles", func(key, val string) {
1679  		name := helpers.JsonGetString(val, "name")
1680  		if len(name) == 0 {
1681  			name = helpers.JsonGetString(val, "display_name")
1682  		}
1683  		pic := helpers.JsonGetString(val, "picture")
1684  		if len(name) > 0 {
1685  			setAuthorName(key, name)
1686  		}
1687  		if len(pic) > 0 {
1688  			setAuthorPic(key, pic)
1689  		}
1690  		profileContentCache[key] = val
1691  		idbLoadedPKs = append(idbLoadedPKs, key)
1692  	}, func() {
1693  		idbLoaded = true
1694  		// Fill pending note headers rendered before IDB finished.
1695  		for pk, headers := range pendingNotes {
1696  			name, nameOK := lookupAuthorName(pk)
1697  			if !nameOK || len(name) == 0 {
1698  				continue
1699  			}
1700  			pic, _ := lookupAuthorPic(pk)
1701  			for _, h := range headers {
1702  				updateNoteHeader(h, name, pic)
1703  			}
1704  			delete(pendingNotes, pk)
1705  		}
1706  		// Tell Profile Worker which pks are already cached so it skips fetching them.
1707  		if len(idbLoadedPKs) > 0 {
1708  			profile.Send(`["P_IDB_LOADED",` | buildJSONStrArr(idbLoadedPKs) | `]`)
1709  			idbLoadedPKs = nil
1710  		}
1711  		// Re-render profile page if opened before IDB finished.
1712  		if profileViewPK != "" && activePage == "profile" {
1713  			renderProfilePage(profileViewPK)
1714  		}
1715  	})
1716  
1717  	// === Top bar ===
1718  	bar := dom.CreateElement("div")
1719  	dom.SetStyle(bar, "display", "flex")
1720  	dom.SetStyle(bar, "alignItems", "center")
1721  	dom.SetStyle(bar, "padding", "8px")
1722  	dom.SetStyle(bar, "height", "48px")
1723  	dom.SetStyle(bar, "boxSizing", "border-box")
1724  	dom.SetStyle(bar, "background", "var(--bg2)")
1725  	dom.SetStyle(bar, "position", "fixed")
1726  	dom.SetStyle(bar, "top", "0")
1727  	dom.SetStyle(bar, "left", "0")
1728  	dom.SetStyle(bar, "right", "0")
1729  	dom.SetStyle(bar, "zIndex", "100")
1730  
1731  	// Left: page title.
1732  	left := dom.CreateElement("div")
1733  	dom.SetStyle(left, "display", "flex")
1734  	dom.SetStyle(left, "alignItems", "center")
1735  	dom.SetStyle(left, "flex", "1")
1736  	dom.SetStyle(left, "minWidth", "0")
1737  
1738  	// Logo in top-left.
1739  	logo := dom.CreateElement("div")
1740  	dom.SetStyle(logo, "width", "32px")
1741  	dom.SetStyle(logo, "height", "32px")
1742  	dom.SetStyle(logo, "flexShrink", "0")
1743  	dom.SetStyle(logo, "color", "var(--accent)")
1744  	dom.SetStyle(logo, "marginRight", "8px")
1745  	dom.FetchText("/smesh-logo.svg", func(svg string) {
1746  		logoSVGCache = svg
1747  		dom.SetInnerHTML(logo, svg)
1748  		svgEl := dom.FirstElementChild(logo)
1749  		if svgEl != 0 {
1750  			dom.SetAttribute(svgEl, "width", "100%")
1751  			dom.SetAttribute(svgEl, "height", "100%")
1752  		}
1753  		// Also populate the about page logo if it was built before fetch completed.
1754  		if aboutLogoEl != 0 {
1755  			dom.SetInnerHTML(aboutLogoEl, svg)
1756  			al := dom.FirstElementChild(aboutLogoEl)
1757  			if al != 0 {
1758  				dom.SetAttribute(al, "width", "100%")
1759  				dom.SetAttribute(al, "height", "100%")
1760  			}
1761  		}
1762  	})
1763  	dom.SetStyle(logo, "cursor", "pointer")
1764  	dom.AddEventListener(logo, "click", dom.RegisterCallback(func() {
1765  		showAboutModal()
1766  	}))
1767  	dom.AppendChild(left, logo)
1768  
1769  	topBackBtn = dom.CreateElement("span")
1770  	dom.SetStyle(topBackBtn, "display", "none")
1771  	dom.SetStyle(topBackBtn, "cursor", "pointer")
1772  	dom.SetStyle(topBackBtn, "color", "var(--accent)")
1773  	dom.SetStyle(topBackBtn, "fontSize", "18px")
1774  	dom.SetStyle(topBackBtn, "fontWeight", "bold")
1775  	dom.SetTextContent(topBackBtn, t("back"))
1776  	dom.AddEventListener(topBackBtn, "click", dom.RegisterCallback(func() {
1777  		dom.Back()
1778  	}))
1779  	dom.AppendChild(left, topBackBtn)
1780  
1781  	// Feed selector button (opens popup). Absolute-positioned so it sits at
1782  	// the horizontal center of the top bar, independent of logo/back width.
1783  	feedSelectEl = dom.CreateElement("button")
1784  	dom.SetStyle(feedSelectEl, "position", "absolute")
1785  	dom.SetStyle(feedSelectEl, "left", "50%")
1786  	dom.SetStyle(feedSelectEl, "top", "50%")
1787  	dom.SetStyle(feedSelectEl, "transform", "translate(-50%, -50%)")
1788  	dom.SetStyle(feedSelectEl, "fontSize", "16px")
1789  	dom.SetStyle(feedSelectEl, "fontWeight", "bold")
1790  	dom.SetStyle(feedSelectEl, "background", "transparent")
1791  	dom.SetStyle(feedSelectEl, "border", "none")
1792  	dom.SetStyle(feedSelectEl, "color", "var(--fg)")
1793  	dom.SetStyle(feedSelectEl, "cursor", "pointer")
1794  	dom.SetStyle(feedSelectEl, "outline", "none")
1795  	dom.SetStyle(feedSelectEl, "maxWidth", "200px")
1796  	dom.SetStyle(feedSelectEl, "padding", "4px 8px")
1797  	dom.SetStyle(feedSelectEl, "borderRadius", "4px")
1798  	updateFeedBtnText()
1799  	dom.AddEventListener(feedSelectEl, "click", dom.RegisterCallback(func() {
1800  		toggleFeedPopover()
1801  	}))
1802  	dom.AppendChild(bar, feedSelectEl)
1803  
1804  	notifFilter = "all"
1805  
1806  	notifBarGroup = dom.CreateElement("div")
1807  	dom.SetStyle(notifBarGroup, "position", "absolute")
1808  	dom.SetStyle(notifBarGroup, "left", "50%")
1809  	dom.SetStyle(notifBarGroup, "top", "50%")
1810  	dom.SetStyle(notifBarGroup, "transform", "translate(-50%, -50%)")
1811  	dom.SetStyle(notifBarGroup, "display", "none")
1812  	dom.SetStyle(notifBarGroup, "alignItems", "center")
1813  	dom.SetStyle(notifBarGroup, "gap", "8px")
1814  
1815  	notifSelectEl = dom.CreateElement("select")
1816  	dom.SetStyle(notifSelectEl, "fontSize", "13px")
1817  	dom.SetStyle(notifSelectEl, "background", "var(--bg)")
1818  	dom.SetStyle(notifSelectEl, "border", "1px solid var(--border)")
1819  	dom.SetStyle(notifSelectEl, "borderRadius", "4px")
1820  	dom.SetStyle(notifSelectEl, "color", "var(--fg)")
1821  	dom.SetStyle(notifSelectEl, "cursor", "pointer")
1822  	dom.SetStyle(notifSelectEl, "outline", "none")
1823  	dom.SetStyle(notifSelectEl, "padding", "3px 6px")
1824  	dom.SetStyle(notifSelectEl, "fontFamily", "'Fira Code', monospace")
1825  	for _, opt := range []string{"all", "mentions", "reactions", "zaps"} {
1826  		o := dom.CreateElement("option")
1827  		dom.SetAttribute(o, "value", opt)
1828  		dom.SetTextContent(o, opt)
1829  		dom.AppendChild(notifSelectEl, o)
1830  	}
1831  	dom.AddEventListener(notifSelectEl, "change", dom.RegisterCallback(func() {
1832  		notifFilter = dom.GetProperty(notifSelectEl, "value")
1833  		rebuildNotifRows()
1834  	}))
1835  	dom.AppendChild(notifBarGroup, notifSelectEl)
1836  
1837  	notifMarkBtn = dom.CreateElement("button")
1838  	dom.SetTextContent(notifMarkBtn, "read")
1839  	dom.SetStyle(notifMarkBtn, "fontSize", "12px")
1840  	dom.SetStyle(notifMarkBtn, "padding", "3px 8px")
1841  	dom.SetStyle(notifMarkBtn, "border", "1px solid var(--border)")
1842  	dom.SetStyle(notifMarkBtn, "borderRadius", "4px")
1843  	dom.SetStyle(notifMarkBtn, "background", "transparent")
1844  	dom.SetStyle(notifMarkBtn, "color", "var(--fg)")
1845  	dom.SetStyle(notifMarkBtn, "cursor", "pointer")
1846  	dom.SetStyle(notifMarkBtn, "fontFamily", "'Fira Code', monospace")
1847  	dom.AddEventListener(notifMarkBtn, "click", dom.RegisterCallback(func() {
1848  		markNotifsRead()
1849  	}))
1850  	dom.AppendChild(notifBarGroup, notifMarkBtn)
1851  	dom.AppendChild(bar, notifBarGroup)
1852  
1853  	pageTitleEl = dom.CreateElement("span")
1854  	dom.SetStyle(pageTitleEl, "fontSize", "18px")
1855  	dom.SetStyle(pageTitleEl, "fontWeight", "bold")
1856  	dom.SetStyle(pageTitleEl, "display", "none")
1857  	dom.AppendChild(left, pageTitleEl)
1858  	topBarLeft = left
1859  	dom.AppendChild(bar, left)
1860  
1861  	// Right: wallet + signer (gear) icon buttons.
1862  	topBarRight = dom.CreateElement("div")
1863  	right := topBarRight
1864  	dom.SetStyle(right, "display", "flex")
1865  	dom.SetStyle(right, "alignItems", "center")
1866  	dom.SetStyle(right, "gap", "8px")
1867  	dom.SetStyle(right, "flex", "1")
1868  	dom.SetStyle(right, "justifyContent", "flex-end")
1869  
1870  	walletBtnEl = dom.CreateElement("button")
1871  	dom.SetInnerHTML(walletBtnEl, `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M2.5 6a1.5 1.5 0 0 1 1.5-1.5h9a1.5 1.5 0 0 1 1.5 1.5v7a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2.5 13V6z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M2.5 7.5h11M12 10.5a.5.5 0 1 0 1 0 .5.5 0 0 0-1 0z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`)
1872  	dom.SetAttribute(walletBtnEl, "title", "wallet")
1873  	dom.SetStyle(walletBtnEl, "background", "transparent")
1874  	dom.SetStyle(walletBtnEl, "border", "none")
1875  	dom.SetStyle(walletBtnEl, "color", "var(--muted)")
1876  	dom.SetStyle(walletBtnEl, "height", "32px")
1877  	dom.SetStyle(walletBtnEl, "width", "32px")
1878  	dom.SetStyle(walletBtnEl, "display", "flex")
1879  	dom.SetStyle(walletBtnEl, "alignItems", "center")
1880  	dom.SetStyle(walletBtnEl, "justifyContent", "center")
1881  	dom.SetStyle(walletBtnEl, "cursor", "pointer")
1882  	dom.SetStyle(walletBtnEl, "borderRadius", "4px")
1883  	dom.AddEventListener(walletBtnEl, "click", dom.RegisterCallback(func() {
1884  		toggleWalletPanel()
1885  	}))
1886  	// Wallet moves to bottom-bar icon group; refreshBtn lives here in top-right.
1887  	_ = walletBtnEl
1888  
1889  	dom.AppendChild(bar, right)
1890  
1891  	dom.AppendChild(root, bar)
1892  
1893  	// === Main layout: content only (icons moved to bottom bar) ===
1894  	mainLayout := dom.CreateElement("div")
1895  	dom.SetStyle(mainLayout, "position", "fixed")
1896  	dom.SetStyle(mainLayout, "top", "48px")
1897  	dom.SetStyle(mainLayout, "bottom", "36px")
1898  	dom.SetStyle(mainLayout, "left", "0")
1899  	dom.SetStyle(mainLayout, "right", "0")
1900  	dom.SetStyle(mainLayout, "display", "flex")
1901  
1902  	sidebarCompose = makeSidebarIcon(svgCompose, false)
1903  	dom.AddEventListener(sidebarCompose, "click", dom.RegisterCallback(func() {
1904  		switchPage("compose")
1905  	}))
1906  
1907  	sidebarFeed = makeSidebarIcon(svgFeed, true)
1908  	dom.AddEventListener(sidebarFeed, "click", dom.RegisterCallback(func() {
1909  		switchPage("feed")
1910  	}))
1911  
1912  	sidebarMsg = makeSidebarIcon(svgChat, false)
1913  	dom.AddEventListener(sidebarMsg, "click", dom.RegisterCallback(func() {
1914  		switchPage("messaging")
1915  	}))
1916  
1917  	sidebarNotif = dom.CreateElement("button")
1918  	dom.SetStyle(sidebarNotif, "width", "36px")
1919  	dom.SetStyle(sidebarNotif, "height", "36px")
1920  	dom.SetStyle(sidebarNotif, "border", "none")
1921  	dom.SetStyle(sidebarNotif, "borderRadius", "6px")
1922  	dom.SetStyle(sidebarNotif, "cursor", "pointer")
1923  	dom.SetStyle(sidebarNotif, "display", "flex")
1924  	dom.SetStyle(sidebarNotif, "alignItems", "center")
1925  	dom.SetStyle(sidebarNotif, "justifyContent", "center")
1926  	dom.SetStyle(sidebarNotif, "padding", "0")
1927  	dom.SetStyle(sidebarNotif, "background", "transparent")
1928  	dom.SetStyle(sidebarNotif, "color", "var(--fg)")
1929  	dom.SetStyle(sidebarNotif, "position", "relative")
1930  	dom.SetInnerHTML(sidebarNotif, svgBell)
1931  	notifDot = dom.CreateElement("span")
1932  	dom.SetStyle(notifDot, "position", "absolute")
1933  	dom.SetStyle(notifDot, "top", "4px")
1934  	dom.SetStyle(notifDot, "right", "5px")
1935  	dom.SetStyle(notifDot, "width", "8px")
1936  	dom.SetStyle(notifDot, "height", "8px")
1937  	dom.SetStyle(notifDot, "borderRadius", "50%")
1938  	dom.SetStyle(notifDot, "background", "var(--accent)")
1939  	dom.SetStyle(notifDot, "display", "none")
1940  	dom.AppendChild(sidebarNotif, notifDot)
1941  	dom.AddEventListener(sidebarNotif, "click", dom.RegisterCallback(func() {
1942  		switchPage("notifications")
1943  	}))
1944  
1945  	sidebarSettings = makeSidebarIcon(svgGear, false)
1946  	dom.AddEventListener(sidebarSettings, "click", dom.RegisterCallback(func() {
1947  		switchPage("settings")
1948  	}))
1949  
1950  	themeBtn = dom.CreateElement("button")
1951  	dom.SetStyle(themeBtn, "background", "transparent")
1952  	dom.SetStyle(themeBtn, "border", "none")
1953  	dom.SetStyle(themeBtn, "width", "32px")
1954  	dom.SetStyle(themeBtn, "height", "32px")
1955  	dom.SetStyle(themeBtn, "cursor", "pointer")
1956  	dom.SetStyle(themeBtn, "padding", "0")
1957  	dom.SetStyle(themeBtn, "display", "flex")
1958  	dom.SetStyle(themeBtn, "alignItems", "center")
1959  	dom.SetStyle(themeBtn, "justifyContent", "center")
1960  	dom.SetStyle(themeBtn, "color", "var(--muted)")
1961  	updateThemeIcon()
1962  	dom.AddEventListener(themeBtn, "click", dom.RegisterCallback(func() {
1963  		toggleTheme()
1964  	}))
1965  
1966  	// Content area.
1967  	contentArea = dom.CreateElement("div")
1968  	dom.SetStyle(contentArea, "flex", "1")
1969  	dom.SetStyle(contentArea, "minHeight", "0")
1970  	dom.SetStyle(contentArea, "overflowY", "auto")
1971  
1972  	// Feed page.
1973  	feedPage = dom.CreateElement("div")
1974  	dom.SetStyle(feedPage, "display", "none")
1975  	dom.SetStyle(feedPage, "padding", "16px")
1976  	dom.SetStyle(feedPage, "maxWidth", "640px")
1977  	dom.SetStyle(feedPage, "margin", "0 auto")
1978  	dom.SetStyle(feedPage, "boxSizing", "border-box")
1979  
1980  	// Loading spinner - shown until first feed event arrives.
1981  	feedLoader = dom.CreateElement("div")
1982  	dom.SetStyle(feedLoader, "display", "flex")
1983  	dom.SetStyle(feedLoader, "flexDirection", "column")
1984  	dom.SetStyle(feedLoader, "alignItems", "center")
1985  	dom.SetStyle(feedLoader, "justifyContent", "center")
1986  	dom.SetStyle(feedLoader, "padding", "64px 0")
1987  	loaderImg := dom.CreateElement("div")
1988  	dom.SetStyle(loaderImg, "width", "120px")
1989  	dom.SetStyle(loaderImg, "height", "120px")
1990  	dom.FetchText("/smesh-loader.svg", func(svg string) {
1991  		dom.SetInnerHTML(loaderImg, svg)
1992  		svgEl := dom.FirstElementChild(loaderImg)
1993  		if svgEl != 0 {
1994  			dom.SetAttribute(svgEl, "width", "100%")
1995  			dom.SetAttribute(svgEl, "height", "100%")
1996  		}
1997  	})
1998  	dom.AppendChild(feedLoader, loaderImg)
1999  	loaderText := dom.CreateElement("div")
2000  	dom.SetTextContent(loaderText, t("connecting"))
2001  	dom.SetStyle(loaderText, "marginTop", "16px")
2002  	dom.SetStyle(loaderText, "color", "var(--muted)")
2003  	dom.SetStyle(loaderText, "fontSize", "14px")
2004  	dom.AppendChild(feedLoader, loaderText)
2005  	dom.AppendChild(feedPage, feedLoader)
2006  
2007  	// Pull-to-refresh indicator (hidden until user pulls at top).
2008  	pullIndicator := dom.CreateElement("div")
2009  	dom.SetStyle(pullIndicator, "display", "none")
2010  	dom.SetStyle(pullIndicator, "justifyContent", "center")
2011  	dom.SetStyle(pullIndicator, "alignItems", "center")
2012  	dom.SetStyle(pullIndicator, "padding", "12px 0")
2013  	dom.SetStyle(pullIndicator, "color", "var(--muted)")
2014  	dom.SetStyle(pullIndicator, "fontSize", "13px")
2015  	dom.SetTextContent(pullIndicator, "\u21bb refreshing...")
2016  	dom.AppendChild(feedPage, pullIndicator)
2017  
2018  	feedContainer = dom.CreateElement("div")
2019  	dom.AppendChild(feedPage, feedContainer)
2020  
2021  	// "Load more" button - fallback for infinite scroll.
2022  	loadMoreBtn = dom.CreateElement("div")
2023  	dom.SetStyle(loadMoreBtn, "display", "none")
2024  	dom.SetStyle(loadMoreBtn, "textAlign", "center")
2025  	dom.SetStyle(loadMoreBtn, "padding", "16px 0")
2026  	loadMoreLink := dom.CreateElement("span")
2027  	dom.SetTextContent(loadMoreLink, "load more")
2028  	dom.SetStyle(loadMoreLink, "cursor", "pointer")
2029  	dom.SetStyle(loadMoreLink, "color", "var(--accent)")
2030  	dom.SetStyle(loadMoreLink, "fontSize", "14px")
2031  	dom.AddEventListener(loadMoreLink, "click", dom.RegisterCallback(func() {
2032  		if !feedLoading && !feedExhausted && oldestFeedTs > 0 {
2033  			dom.SetStyle(loadMoreBtn, "display", "none")
2034  			loadOlderFeed()
2035  		}
2036  	}))
2037  	dom.AppendChild(loadMoreBtn, loadMoreLink)
2038  
2039  	// Thread view (hidden overlay within feedPage).
2040  	threadPage = dom.CreateElement("div")
2041  	dom.SetStyle(threadPage, "display", "none")
2042  
2043  	threadContainer = dom.CreateElement("div")
2044  	dom.AppendChild(threadPage, threadContainer)
2045  	dom.AppendChild(feedPage, threadPage)
2046  
2047  	// Load-more after thread so it sits at the bottom in both views.
2048  	dom.AppendChild(feedPage, loadMoreBtn)
2049  
2050  	dom.AppendChild(contentArea, feedPage)
2051  
2052  	// Pull-to-refresh: wheel up or touch pull at top triggers feed reload.
2053  	dom.OnPullRefresh(contentArea, pullIndicator, func() {
2054  		if activePage == "feed" && !refreshSpinning {
2055  			onRefreshClick()
2056  			dom.SetTimeout(func() {
2057  				dom.SetStyle(pullIndicator, "display", "none")
2058  			}, 1500)
2059  		} else if activePage == "notifications" {
2060  			dom.SetProperty(contentArea, "scrollTop", "0")
2061  			dom.SetTimeout(func() {
2062  				dom.SetStyle(pullIndicator, "display", "none")
2063  			}, 1500)
2064  		}
2065  	})
2066  
2067  	// Infinite scroll - load older events when near bottom.
2068  	dom.AddEventListener(contentArea, "scroll", dom.RegisterCallback(func() {
2069  		st := dom.GetProperty(contentArea, "scrollTop")
2070  		ch := dom.GetProperty(contentArea, "clientHeight")
2071  		sh := dom.GetProperty(contentArea, "scrollHeight")
2072  		top := parseIntProp(st)
2073  		height := parseIntProp(ch)
2074  		total := parseIntProp(sh)
2075  		nearBottom := top+height >= total-400
2076  
2077  		if activePage == "feed" {
2078  			if top <= 10 && len(feedBuffer) > 0 {
2079  				for _, bev := range feedBuffer {
2080  					renderNote(bev)
2081  					seenEvents[bev.ID] = true
2082  				}
2083  				feedBuffer = nil
2084  				feedHasNew = false
2085  				stopRefreshPulse()
2086  			}
2087  			if feedLoading || feedExhausted || oldestFeedTs == 0 {
2088  				return
2089  			}
2090  			if nearBottom {
2091  				loadOlderFeed()
2092  			}
2093  		} else if activePage == "notifications" {
2094  			if notifLoading || notifExhausted || oldestNotifTs == 0 {
2095  				return
2096  			}
2097  			if nearBottom {
2098  				loadOlderNotifs()
2099  			}
2100  		} else if activePage == "search" {
2101  			if nearBottom {
2102  				appendSearchResults()
2103  			}
2104  		}
2105  	}))
2106  
2107  	// Messaging page.
2108  	msgPage = dom.CreateElement("div")
2109  	dom.SetStyle(msgPage, "padding", "16px")
2110  	dom.SetStyle(msgPage, "display", "none")
2111  	dom.SetStyle(msgPage, "position", "relative")
2112  	dom.SetStyle(msgPage, "height", "100%")
2113  	dom.SetStyle(msgPage, "boxSizing", "border-box")
2114  
2115  	// Conversation list view.
2116  	msgListContainer = dom.CreateElement("div")
2117  	dom.AppendChild(msgPage, msgListContainer)
2118  
2119  	// Thread view (hidden by default).
2120  	msgThreadContainer = dom.CreateElement("div")
2121  	dom.SetStyle(msgThreadContainer, "display", "none")
2122  	dom.SetStyle(msgThreadContainer, "flexDirection", "column")
2123  	dom.SetStyle(msgThreadContainer, "position", "absolute")
2124  	dom.SetStyle(msgThreadContainer, "top", "0")
2125  	dom.SetStyle(msgThreadContainer, "left", "0")
2126  	dom.SetStyle(msgThreadContainer, "right", "0")
2127  	dom.SetStyle(msgThreadContainer, "bottom", "0")
2128  	dom.SetStyle(msgThreadContainer, "background", "var(--bg)")
2129  	dom.AppendChild(msgPage, msgThreadContainer)
2130  
2131  	msgView = "list"
2132  
2133  	dom.AppendChild(contentArea, msgPage)
2134  
2135  	// Profile page.
2136  	profilePage = dom.CreateElement("div")
2137  	dom.SetStyle(profilePage, "display", "none")
2138  	dom.SetStyle(profilePage, "padding", "16px")
2139  	dom.SetStyle(profilePage, "maxWidth", "640px")
2140  	dom.SetStyle(profilePage, "margin", "0 auto")
2141  	dom.SetStyle(profilePage, "boxSizing", "border-box")
2142  	dom.AppendChild(contentArea, profilePage)
2143  
2144  	// Settings page.
2145  	settingsPage = dom.CreateElement("div")
2146  	dom.SetStyle(settingsPage, "display", "none")
2147  	dom.SetStyle(settingsPage, "padding", "16px")
2148  	dom.SetStyle(settingsPage, "maxWidth", "640px")
2149  	dom.SetStyle(settingsPage, "margin", "0 auto")
2150  	dom.SetStyle(settingsPage, "boxSizing", "border-box")
2151  	dom.AppendChild(contentArea, settingsPage)
2152  
2153  	// Compose page.
2154  	composePage = dom.CreateElement("div")
2155  	dom.SetStyle(composePage, "display", "none")
2156  	dom.SetStyle(composePage, "padding", "16px")
2157  	dom.SetStyle(composePage, "maxWidth", "640px")
2158  	dom.SetStyle(composePage, "margin", "0 auto")
2159  	dom.SetStyle(composePage, "minHeight", "100%")
2160  	dom.SetStyle(composePage, "boxSizing", "border-box")
2161  	buildComposePage()
2162  	dom.AppendChild(contentArea, composePage)
2163  
2164  	// About page.
2165  	aboutPage = dom.CreateElement("div")
2166  	dom.SetStyle(aboutPage, "display", "none")
2167  	dom.SetStyle(aboutPage, "padding", "24px 16px")
2168  	dom.SetStyle(aboutPage, "maxWidth", "640px")
2169  	dom.SetStyle(aboutPage, "margin", "0 auto")
2170  	dom.SetStyle(aboutPage, "boxSizing", "border-box")
2171  	dom.SetStyle(aboutPage, "textAlign", "center")
2172  
2173  	aRefresh := dom.CreateElement("button")
2174  	dom.SetTextContent(aRefresh, "hard refresh")
2175  	dom.SetStyle(aRefresh, "fontFamily", "'Fira Code', monospace")
2176  	dom.SetStyle(aRefresh, "fontSize", "13px")
2177  	dom.SetStyle(aRefresh, "background", "transparent")
2178  	dom.SetStyle(aRefresh, "border", "1px solid var(--border)")
2179  	dom.SetStyle(aRefresh, "borderRadius", "4px")
2180  	dom.SetStyle(aRefresh, "color", "var(--muted)")
2181  	dom.SetStyle(aRefresh, "cursor", "pointer")
2182  	dom.SetStyle(aRefresh, "padding", "6px 16px")
2183  	dom.SetStyle(aRefresh, "marginBottom", "16px")
2184  	dom.AddEventListener(aRefresh, "click", dom.RegisterCallback(func() {
2185  		dom.HardRefresh()
2186  	}))
2187  	dom.AppendChild(aboutPage, aRefresh)
2188  
2189  	aboutLogoEl = dom.CreateElement("div")
2190  	dom.SetStyle(aboutLogoEl, "width", "180px")
2191  	dom.SetStyle(aboutLogoEl, "height", "180px")
2192  	dom.SetStyle(aboutLogoEl, "margin", "0 auto 16px auto")
2193  	dom.SetStyle(aboutLogoEl, "color", "var(--accent)")
2194  	if logoSVGCache != "" {
2195  		dom.SetInnerHTML(aboutLogoEl, logoSVGCache)
2196  		aLogoSvg := dom.FirstElementChild(aboutLogoEl)
2197  		if aLogoSvg != 0 {
2198  			dom.SetAttribute(aLogoSvg, "width", "100%")
2199  			dom.SetAttribute(aLogoSvg, "height", "100%")
2200  		}
2201  	}
2202  	dom.AppendChild(aboutPage, aboutLogoEl)
2203  
2204  	aTitle := dom.CreateElement("h2")
2205  	dom.SetTextContent(aTitle, "S.M.E.S.H. " | version)
2206  	dom.SetStyle(aTitle, "fontSize", "20px")
2207  	dom.SetStyle(aTitle, "margin", "0 0 20px 0")
2208  	dom.SetStyle(aTitle, "color", "var(--accent)")
2209  	dom.AppendChild(aboutPage, aTitle)
2210  
2211  	aDevLabel := dom.CreateElement("p")
2212  	dom.SetTextContent(aDevLabel, t("developed_by"))
2213  	dom.SetStyle(aDevLabel, "fontSize", "12px")
2214  	dom.SetStyle(aDevLabel, "color", "var(--muted)")
2215  	dom.SetStyle(aDevLabel, "marginBottom", "4px")
2216  	dom.AppendChild(aboutPage, aDevLabel)
2217  
2218  	aNpub := dom.CreateElement("p")
2219  	dom.SetStyle(aNpub, "fontSize", "11px")
2220  	dom.SetStyle(aNpub, "color", "var(--fg)")
2221  	dom.SetStyle(aNpub, "wordBreak", "break-all")
2222  	dom.SetStyle(aNpub, "marginBottom", "20px")
2223  	dom.SetStyle(aNpub, "fontFamily", "'Fira Code', monospace")
2224  	dom.SetTextContent(aNpub, "npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku")
2225  	dom.AppendChild(aboutPage, aNpub)
2226  
2227  	aTagline := dom.CreateElement("p")
2228  	dom.SetStyle(aTagline, "fontSize", "16px")
2229  	dom.SetStyle(aTagline, "fontWeight", "bold")
2230  	dom.SetStyle(aTagline, "color", "var(--fg)")
2231  	dom.SetStyle(aTagline, "marginBottom", "12px")
2232  	dom.SetTextContent(aTagline, t("tagline"))
2233  	dom.AppendChild(aboutPage, aTagline)
2234  
2235  	aFunny := dom.CreateElement("p")
2236  	dom.SetStyle(aFunny, "fontSize", "13px")
2237  	dom.SetStyle(aFunny, "color", "var(--fg)")
2238  	dom.SetStyle(aFunny, "marginBottom", "20px")
2239  	dom.SetStyle(aFunny, "lineHeight", "1.6")
2240  	dom.SetTextContent(aFunny, t("about_donkey"))
2241  	dom.AppendChild(aboutPage, aFunny)
2242  
2243  	aLud := dom.CreateElement("span")
2244  	dom.SetStyle(aLud, "fontSize", "13px")
2245  	dom.SetStyle(aLud, "color", "var(--accent)")
2246  	dom.SetStyle(aLud, "marginBottom", "24px")
2247  	dom.SetStyle(aLud, "cursor", "pointer")
2248  	dom.SetStyle(aLud, "display", "inline-block")
2249  	dom.SetTextContent(aLud, "\xe2\x9a\xa1 leeringidol57@walletofsatoshi.com")
2250  	dom.AddEventListener(aLud, "click", dom.RegisterCallback(func() {
2251  		showQRModal("leeringidol57@walletofsatoshi.com")
2252  	}))
2253  	dom.AppendChild(aboutPage, aLud)
2254  
2255  	feliceLink := dom.CreateElement("a")
2256  	dom.SetAttribute(feliceLink, "href", "https://git.smesh.lol/felice/raw/felice.apk")
2257  	dom.SetAttribute(feliceLink, "target", "_blank")
2258  	dom.SetStyle(feliceLink, "display", "block")
2259  	dom.SetStyle(feliceLink, "color", "var(--accent)")
2260  	dom.SetStyle(feliceLink, "fontSize", "13px")
2261  	dom.SetStyle(feliceLink, "marginBottom", "4px")
2262  	dom.SetTextContent(feliceLink, "download felice")
2263  	dom.AppendChild(aboutPage, feliceLink)
2264  
2265  	feliceDesc := dom.CreateElement("p")
2266  	dom.SetStyle(feliceDesc, "fontSize", "11px")
2267  	dom.SetStyle(feliceDesc, "color", "var(--muted)")
2268  	dom.SetStyle(feliceDesc, "lineHeight", "1.5")
2269  	dom.SetStyle(feliceDesc, "marginBottom", "24px")
2270  	dom.SetStyle(feliceDesc, "maxWidth", "400px")
2271  	dom.SetStyle(feliceDesc, "margin", "0 auto 24px auto")
2272  	dom.SetTextContent(feliceDesc, "fork of fennec-fdroid to use smesh.lol and smesh signer extension on android without the mozilla security theatre")
2273  	dom.AppendChild(aboutPage, feliceDesc)
2274  
2275  	dom.AppendChild(contentArea, aboutPage)
2276  
2277  	// Activity log page.
2278  	logPage = dom.CreateElement("div")
2279  	dom.SetStyle(logPage, "display", "none")
2280  	dom.SetStyle(logPage, "padding", "12px")
2281  	dom.SetStyle(logPage, "maxWidth", "780px")
2282  	dom.SetStyle(logPage, "margin", "0 auto")
2283  	dom.SetStyle(logPage, "boxSizing", "border-box")
2284  	dom.SetStyle(logPage, "fontFamily", "'Fira Code', monospace")
2285  	dom.SetStyle(logPage, "fontSize", "12px")
2286  
2287  	logListEl = dom.CreateElement("div")
2288  	dom.AppendChild(logPage, logListEl)
2289  	dom.AppendChild(contentArea, logPage)
2290  
2291  	// Pre-populate with any entries that accumulated during boot before this DOM was built.
2292  	for i := len(activityLog) - 1; i >= 0; i-- {
2293  		prependLogEntry(activityLog[i])
2294  	}
2295  
2296  	// Notifications page.
2297  	notifPage = dom.CreateElement("div")
2298  	dom.SetStyle(notifPage, "display", "none")
2299  	dom.SetStyle(notifPage, "padding", "16px")
2300  	dom.SetStyle(notifPage, "maxWidth", "640px")
2301  	dom.SetStyle(notifPage, "margin", "0 auto")
2302  	dom.SetStyle(notifPage, "boxSizing", "border-box")
2303  
2304  	notifContainer = dom.CreateElement("div")
2305  	dom.AppendChild(notifPage, notifContainer)
2306  	dom.AppendChild(contentArea, notifPage)
2307  
2308  	// Search page: flex-column so filter bar stays above scrolling results.
2309  	searchPage = dom.CreateElement("div")
2310  	dom.SetStyle(searchPage, "display", "none")
2311  	dom.SetStyle(searchPage, "maxWidth", "640px")
2312  	dom.SetStyle(searchPage, "margin", "0 auto")
2313  	dom.SetStyle(searchPage, "width", "100%")
2314  	// searchFilterBarEl and searchContainer are appended dynamically by buildSearchFilterBar/executeSearch.
2315  	searchContainer = dom.CreateElement("div")
2316  	dom.AppendChild(searchPage, searchContainer)
2317  	dom.AppendChild(contentArea, searchPage)
2318  
2319  	dom.AppendChild(mainLayout, contentArea)
2320  	dom.AppendChild(root, mainLayout)
2321  	activePage = "log"
2322  	dom.SetStyle(logPage, "display", "block")
2323  
2324  	buildStatusBar(true)
2325  
2326  	// Icon group: fixed 4px gap between icons (same as old sidebar), centered
2327  	// on screen via absolute positioning + translateX(-50%).
2328  	iconGroup := dom.CreateElement("div")
2329  	dom.SetStyle(iconGroup, "position", "absolute")
2330  	dom.SetStyle(iconGroup, "left", "50%")
2331  	dom.SetStyle(iconGroup, "top", "0")
2332  	dom.SetStyle(iconGroup, "bottom", "0")
2333  	dom.SetStyle(iconGroup, "transform", "translateX(-50%)")
2334  	dom.SetStyle(iconGroup, "display", "flex")
2335  	dom.SetStyle(iconGroup, "alignItems", "center")
2336  	dom.SetStyle(iconGroup, "gap", "4px")
2337  	if int32(userBtn) != 0 {
2338  		dom.AppendChild(iconGroup, userBtn)
2339  	}
2340  	dom.AppendChild(iconGroup, sidebarCompose)
2341  	dom.AppendChild(iconGroup, sidebarFeed)
2342  	dom.AppendChild(iconGroup, sidebarMsg)
2343  	dom.AppendChild(iconGroup, sidebarNotif)
2344  	dom.AppendChild(iconGroup, sidebarSettings)
2345  	_ = walletBtnEl
2346  	_ = themeBtn
2347  	dom.AppendChild(bottomBar, iconGroup)
2348  	// Activity log button - opens the log page. Highlighted while boot is in progress.
2349  	logBtn = dom.CreateElement("button")
2350  	dom.SetInnerHTML(logBtn, `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3-9 4 18 3-9h4"/></svg>`)
2351  	dom.SetAttribute(logBtn, "title", "activity log")
2352  	dom.SetStyle(logBtn, "background", "transparent")
2353  	dom.SetStyle(logBtn, "border", "none")
2354  	dom.SetStyle(logBtn, "color", "var(--muted)")
2355  	dom.SetStyle(logBtn, "width", "32px")
2356  	dom.SetStyle(logBtn, "height", "32px")
2357  	dom.SetStyle(logBtn, "cursor", "pointer")
2358  	dom.SetStyle(logBtn, "padding", "0")
2359  	dom.SetStyle(logBtn, "display", "flex")
2360  	dom.SetStyle(logBtn, "alignItems", "center")
2361  	dom.SetStyle(logBtn, "justifyContent", "center")
2362  	dom.AddEventListener(logBtn, "click", dom.RegisterCallback(func() {
2363  		switchPage("log")
2364  	}))
2365  	dom.AppendChild(topBarRight, logBtn)
2366  	updateLogBtnHighlight()
2367  
2368  	// Search button + input row - leftmost item in topBarRight.
2369  	searchSortDesc = true
2370  	searchSeen = map[string]bool{}
2371  
2372  	searchBtn := dom.CreateElement("button")
2373  	dom.SetInnerHTML(searchBtn, `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="6.5" cy="6.5" r="4" stroke="currentColor" stroke-width="1.5"/><line x1="9.9" y1="9.9" x2="13.5" y2="13.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`)
2374  	dom.SetAttribute(searchBtn, "title", "search")
2375  	dom.SetStyle(searchBtn, "background", "transparent")
2376  	dom.SetStyle(searchBtn, "border", "none")
2377  	dom.SetStyle(searchBtn, "color", "var(--muted)")
2378  	dom.SetStyle(searchBtn, "height", "32px")
2379  	dom.SetStyle(searchBtn, "width", "32px")
2380  	dom.SetStyle(searchBtn, "display", "flex")
2381  	dom.SetStyle(searchBtn, "alignItems", "center")
2382  	dom.SetStyle(searchBtn, "justifyContent", "center")
2383  	dom.SetStyle(searchBtn, "cursor", "pointer")
2384  	dom.SetStyle(searchBtn, "borderRadius", "4px")
2385  	dom.SetStyle(searchBtn, "flexShrink", "0")
2386  
2387  	searchInputEl = dom.CreateElement("input")
2388  	dom.SetAttribute(searchInputEl, "type", "text")
2389  	dom.SetAttribute(searchInputEl, "placeholder", "#tag or text search")
2390  	dom.SetStyle(searchInputEl, "display", "none")
2391  	dom.SetStyle(searchInputEl, "flex", "1")
2392  	dom.SetStyle(searchInputEl, "minWidth", "0")
2393  	dom.SetStyle(searchInputEl, "background", "var(--bg)")
2394  	dom.SetStyle(searchInputEl, "color", "var(--fg)")
2395  	dom.SetStyle(searchInputEl, "border", "1px solid var(--border)")
2396  	dom.SetStyle(searchInputEl, "borderRadius", "4px")
2397  	dom.SetStyle(searchInputEl, "padding", "4px 8px")
2398  	dom.SetStyle(searchInputEl, "fontSize", "14px")
2399  	dom.SetStyle(searchInputEl, "fontFamily", "'Fira Code', monospace")
2400  
2401  	searchSubmitBtn = dom.CreateElement("button")
2402  	dom.SetTextContent(searchSubmitBtn, "\xe2\x86\x92") // →
2403  	dom.SetStyle(searchSubmitBtn, "display", "none")
2404  	dom.SetStyle(searchSubmitBtn, "background", "var(--accent)")
2405  	dom.SetStyle(searchSubmitBtn, "color", "#fff")
2406  	dom.SetStyle(searchSubmitBtn, "border", "none")
2407  	dom.SetStyle(searchSubmitBtn, "borderRadius", "4px")
2408  	dom.SetStyle(searchSubmitBtn, "padding", "4px 10px")
2409  	dom.SetStyle(searchSubmitBtn, "cursor", "pointer")
2410  	dom.SetStyle(searchSubmitBtn, "fontSize", "16px")
2411  	dom.SetStyle(searchSubmitBtn, "flexShrink", "0")
2412  
2413  	// 🔍 toggles search: click to open (highlighted), click again to close.
2414  	searchBtnGlobal = searchBtn
2415  	capturedSearchBtn := searchBtn
2416  	dom.AddEventListener(searchBtn, "click", dom.RegisterCallback(func() {
2417  		if searchBarActive {
2418  			closeSearch(capturedSearchBtn)
2419  		} else {
2420  			activateSearchBar(capturedSearchBtn)
2421  		}
2422  	}))
2423  
2424  	submitSearch := func() {
2425  		q := dom.GetProperty(searchInputEl, "value")
2426  		if q != "" {
2427  			executeSearch(q)
2428  		}
2429  	}
2430  	capturedSubmit := submitSearch
2431  	dom.AddEventListener(searchSubmitBtn, "click", dom.RegisterCallback(capturedSubmit))
2432  	dom.OnKeydown(searchInputEl, func(key string, prevent func()) {
2433  		if key == "Enter" {
2434  			prevent()
2435  			capturedSubmit()
2436  		} else if key == "Escape" {
2437  			closeSearch(capturedSearchBtn)
2438  		}
2439  	})
2440  
2441  	// Search lives in topBarLeft so it can replace center content when active.
2442  	dom.AppendChild(topBarLeft, searchBtn)
2443  	dom.AppendChild(topBarLeft, searchInputEl)
2444  	dom.AppendChild(topBarLeft, searchSubmitBtn)
2445  
2446  	// Load saved relays; fall back to defaults on first run.
2447  	if !loadRelayList() {
2448  		logActivity("boot", "loadRelayList empty, using defaults", "")
2449  		for _, url := range defaultRelays {
2450  			addRelay(url, false)
2451  		}
2452  	}
2453  	if len(relayURLs) == 0 {
2454  		logActivity("boot", "relayURLs still empty, forcing defaults", "")
2455  		for _, url := range defaultRelays {
2456  			addRelay(url, false)
2457  		}
2458  	}
2459  	{
2460  		relayList := ""
2461  		for i, u := range relayURLs {
2462  			if i > 0 {
2463  				relayList = relayList | "\n"
2464  			}
2465  			relayList = relayList | u
2466  		}
2467  		logActivity("boot", "loaded " | itoa(len(relayURLs)) | " relays", relayList)
2468  	}
2469  
2470  	// Tell SW about relays and subscribe.
2471  	sendWriteRelays()
2472  	subscribeProfile()
2473  	logActivity("boot", "subscribed to own profile", "kinds: 0,3,10002,10000,10050 for " | pubhex)
2474  	// Feed subscription is deferred until kind 3 (follow list) arrives,
2475  	// so we don't subscribe with a global filter then immediately
2476  	// resubscribe when the follow list shows up (which clears the feed).
2477  	// Fallback: if no kind 3 arrives within 3s, subscribe anyway.
2478  	feedDeferTimer = dom.SetTimeout(func() {
2479  		feedDeferTimer = 0
2480  		if !feedSubscribed {
2481  			feedSubscribed = true
2482  			logActivity("boot", "feed defer timeout, subscribing anyway", "no kind 3 in 3s")
2483  			subscribeFeed()
2484  			subscribeNotifications()
2485  		}
2486  		bootDone()
2487  	}, 3000)
2488  
2489  	// Publish any kind 0 queued during identity derivation (relays are now live).
2490  	flushPendingK0()
2491  
2492  	// Wire up browser history navigation.
2493  	initRouter()
2494  
2495  	// Check for signer extension.
2496  	initSigner()
2497  
2498  	// Scroll-to-top floaty button.
2499  	scrollTopBtn := dom.CreateElement("button")
2500  	dom.SetStyle(scrollTopBtn, "position", "fixed")
2501  	dom.SetStyle(scrollTopBtn, "bottom", "48px")
2502  	dom.SetStyle(scrollTopBtn, "right", "12px")
2503  	dom.SetStyle(scrollTopBtn, "width", "36px")
2504  	dom.SetStyle(scrollTopBtn, "height", "36px")
2505  	dom.SetStyle(scrollTopBtn, "borderRadius", "50%")
2506  	dom.SetStyle(scrollTopBtn, "background", "var(--bg2)")
2507  	dom.SetStyle(scrollTopBtn, "color", "var(--fg)")
2508  	dom.SetStyle(scrollTopBtn, "border", "1px solid var(--border)")
2509  	dom.SetStyle(scrollTopBtn, "cursor", "pointer")
2510  	dom.SetStyle(scrollTopBtn, "display", "none")
2511  	dom.SetStyle(scrollTopBtn, "alignItems", "center")
2512  	dom.SetStyle(scrollTopBtn, "justifyContent", "center")
2513  	dom.SetStyle(scrollTopBtn, "zIndex", "99")
2514  	dom.SetStyle(scrollTopBtn, "padding", "0")
2515  	dom.SetInnerHTML(scrollTopBtn, `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>`)
2516  	dom.AddEventListener(scrollTopBtn, "click", dom.RegisterCallback(func() {
2517  		dom.SetProperty(contentArea, "scrollTop", "0")
2518  	}))
2519  	dom.AppendChild(dom.Body(), scrollTopBtn)
2520  
2521  	dom.AddEventListener(contentArea, "scroll", dom.RegisterCallback(func() {
2522  		st := parseIntProp(dom.GetProperty(contentArea, "scrollTop"))
2523  		show := st > 300 && (activePage == "feed" || activePage == "notifications" || activePage == "thread")
2524  		if show {
2525  			dom.SetStyle(scrollTopBtn, "display", "inline-flex")
2526  		} else {
2527  			dom.SetStyle(scrollTopBtn, "display", "none")
2528  		}
2529  	}))
2530  
2531  	appReady = true
2532  }
2533  
2534  // buildStatusBar creates the bottom bar, relay popover, and signer popover.
2535  // withUser=true adds the avatar+name section (main app).
2536  func buildStatusBar(withUser bool) {
2537  	bottomBar = dom.CreateElement("div")
2538  	dom.SetStyle(bottomBar, "position", "fixed")
2539  	dom.SetStyle(bottomBar, "bottom", "0")
2540  	dom.SetStyle(bottomBar, "left", "0")
2541  	dom.SetStyle(bottomBar, "right", "0")
2542  	dom.SetStyle(bottomBar, "height", "36px")
2543  	dom.SetStyle(bottomBar, "display", "flex")
2544  	dom.SetStyle(bottomBar, "alignItems", "center")
2545  	dom.SetStyle(bottomBar, "padding", "0 6px")
2546  	dom.SetStyle(bottomBar, "gap", "8px")
2547  	dom.SetStyle(bottomBar, "background", "var(--bg2)")
2548  	dom.SetStyle(bottomBar, "fontSize", "12px")
2549  	dom.SetStyle(bottomBar, "color", "var(--fg)")
2550  	dom.SetStyle(bottomBar, "borderTop", "none")
2551  	dom.SetStyle(bottomBar, "zIndex", "100")
2552  
2553  	if withUser {
2554  		userBtn = dom.CreateElement("button")
2555  		dom.SetStyle(userBtn, "display", "flex")
2556  		dom.SetStyle(userBtn, "alignItems", "center")
2557  		dom.SetStyle(userBtn, "justifyContent", "center")
2558  		dom.SetStyle(userBtn, "padding", "0")
2559  		dom.SetStyle(userBtn, "background", "transparent")
2560  		dom.SetStyle(userBtn, "border", "none")
2561  		dom.SetStyle(userBtn, "borderRadius", "6px")
2562  		dom.SetStyle(userBtn, "cursor", "pointer")
2563  		dom.SetStyle(userBtn, "width", "36px")
2564  		dom.SetStyle(userBtn, "height", "36px")
2565  
2566  		avatarEl = dom.CreateElement("img")
2567  		dom.SetAttribute(avatarEl, "referrerpolicy", "no-referrer")
2568  		dom.SetAttribute(avatarEl, "width", "24")
2569  		dom.SetAttribute(avatarEl, "height", "24")
2570  		dom.SetStyle(avatarEl, "borderRadius", "50%")
2571  		dom.SetStyle(avatarEl, "objectFit", "cover")
2572  		dom.SetStyle(avatarEl, "display", "block")
2573  		dom.SetAttribute(avatarEl, "onerror", "this.style.display='none'")
2574  		dom.AppendChild(userBtn, avatarEl)
2575  
2576  		// nameEl kept (exists for other code paths) but detached - username
2577  		// no longer appears in the status bar.
2578  		nameEl = dom.CreateElement("span")
2579  
2580  		dom.AddEventListener(userBtn, "click", dom.RegisterCallback(func() {
2581  			toggleSignerPanel()
2582  		}))
2583  		// userBtn is appended to iconGroup by showApp.
2584  	}
2585  
2586  	statusEl = dom.CreateElement("button")
2587  	dom.SetTextContent(statusEl, t("connecting"))
2588  	dom.SetStyle(statusEl, "fontFamily", "'Fira Code', monospace")
2589  	dom.SetStyle(statusEl, "fontSize", "12px")
2590  	dom.SetStyle(statusEl, "background", "transparent")
2591  	dom.SetStyle(statusEl, "border", "none")
2592  	dom.SetStyle(statusEl, "color", "var(--muted)")
2593  	dom.SetStyle(statusEl, "cursor", "pointer")
2594  	dom.SetStyle(statusEl, "padding", "4px 8px")
2595  	dom.SetStyle(statusEl, "borderRadius", "4px")
2596  	dom.SetStyle(statusEl, "marginLeft", "auto")
2597  	dom.AddEventListener(statusEl, "click", dom.RegisterCallback(func() {
2598  		togglePopover()
2599  	}))
2600  	// statusEl (relays button) is created but not attached - all relay info is
2601  	// already visible on the settings page.
2602  	_ = statusEl
2603  
2604  	dom.AppendChild(root, bottomBar)
2605  
2606  	// Relay popover (hidden).
2607  	popoverEl = dom.CreateElement("div")
2608  	dom.SetStyle(popoverEl, "position", "fixed")
2609  	dom.SetStyle(popoverEl, "bottom", "37px")
2610  	dom.SetStyle(popoverEl, "right", "12px")
2611  	dom.SetStyle(popoverEl, "width", "340px")
2612  	dom.SetStyle(popoverEl, "maxWidth", "calc(100vw - 24px)")
2613  	dom.SetStyle(popoverEl, "background", "var(--bg2)")
2614  	dom.SetStyle(popoverEl, "border", "1px solid var(--border)")
2615  	dom.SetStyle(popoverEl, "borderRadius", "8px")
2616  	dom.SetStyle(popoverEl, "padding", "12px 16px")
2617  	dom.SetStyle(popoverEl, "fontSize", "12px")
2618  	dom.SetStyle(popoverEl, "display", "none")
2619  	dom.SetStyle(popoverEl, "zIndex", "99")
2620  
2621  	popoverSummary = dom.CreateElement("div")
2622  	dom.SetTextContent(popoverSummary, t("connecting"))
2623  	dom.SetStyle(popoverSummary, "marginBottom", "8px")
2624  	dom.SetStyle(popoverSummary, "color", "var(--muted)")
2625  	dom.AppendChild(popoverEl, popoverSummary)
2626  
2627  	// Broadcast button - at the bottom of the relay popover.
2628  	broadcastRow = dom.CreateElement("div")
2629  	dom.SetStyle(broadcastRow, "borderTop", "1px solid var(--border)")
2630  	dom.SetStyle(broadcastRow, "marginTop", "8px")
2631  	dom.SetStyle(broadcastRow, "paddingTop", "8px")
2632  	dom.SetStyle(broadcastRow, "display", "none")
2633  	broadcastBtnEl = dom.CreateElement("span")
2634  	dom.SetTextContent(broadcastBtnEl, "broadcast profile")
2635  	dom.SetStyle(broadcastBtnEl, "cursor", "pointer")
2636  	dom.SetStyle(broadcastBtnEl, "color", "var(--accent)")
2637  	dom.SetStyle(broadcastBtnEl, "fontFamily", "'Fira Code', monospace")
2638  	dom.AddEventListener(broadcastBtnEl, "click", dom.RegisterCallback(func() {
2639  		if pubhex == "" {
2640  			return
2641  		}
2642  		dom.SetTextContent(broadcastBtnEl, "broadcasting...")
2643  		dom.SetStyle(broadcastBtnEl, "color", "var(--muted)")
2644  		msg := "[\"BROADCAST\"," | jstr(pubhex) | ",["
2645  		for i, url := range relayURLs {
2646  			if i > 0 {
2647  				msg = msg | ","
2648  			}
2649  			msg = msg | jstr(url)
2650  		}
2651  		routeMsg(msg | "]]")
2652  	}))
2653  	dom.AppendChild(broadcastRow, broadcastBtnEl)
2654  	dom.AppendChild(popoverEl, broadcastRow)
2655  	if pubhex != "" {
2656  		dom.SetStyle(broadcastRow, "display", "block")
2657  	}
2658  
2659  	// popoverEl (relays popup) is created but not attached - relays are on the
2660  	// settings page. Downstream code still mutates the detached element harmlessly.
2661  	_ = popoverEl
2662  
2663  	// Signer popover (hidden).
2664  	buildSignerPanel()
2665  	dom.AppendChild(root, signerPanel)
2666  
2667  	// Wallet popover (hidden).
2668  	buildWalletPanel()
2669  	// walletPanel is attached by renderSettings() at the bottom of the settings page.
2670  
2671  	// Feed selector popover (hidden).
2672  	feedPopoverEl = dom.CreateElement("div")
2673  	dom.SetStyle(feedPopoverEl, "position", "fixed")
2674  	dom.SetStyle(feedPopoverEl, "top", "48px")
2675  	dom.SetStyle(feedPopoverEl, "left", "50%")
2676  	dom.SetStyle(feedPopoverEl, "transform", "translateX(-50%)")
2677  	dom.SetStyle(feedPopoverEl, "width", "220px")
2678  	dom.SetStyle(feedPopoverEl, "maxWidth", "calc(100vw - 24px)")
2679  	dom.SetStyle(feedPopoverEl, "background", "var(--bg2)")
2680  	dom.SetStyle(feedPopoverEl, "border", "1px solid var(--border)")
2681  	dom.SetStyle(feedPopoverEl, "borderRadius", "8px")
2682  	dom.SetStyle(feedPopoverEl, "padding", "8px")
2683  	dom.SetStyle(feedPopoverEl, "fontSize", "14px")
2684  	dom.SetStyle(feedPopoverEl, "display", "none")
2685  	dom.SetStyle(feedPopoverEl, "zIndex", "99")
2686  	dom.AppendChild(root, feedPopoverEl)
2687  }
2688  
2689  // relayCap is a parsed NIP-11 Relay Information Document snapshot.
2690  // Values are populated asynchronously after addRelay; fetchedOK=false means
2691  // either the fetch hasn't completed yet or the relay was unreachable.
2692  type relayCap struct {
2693  	name          string
2694  	adminPubkey   string // hex, from NIP-11 "pubkey" field (operator identity)
2695  	supportedNIPs []int32  // NIP-11 "supported_nips" array
2696  	authRequired  bool   // limitation.auth_required
2697  	paymentReq    bool   // limitation.payment_required
2698  	restrictedW   bool   // limitation.restricted_writes
2699  	fetchedOK     bool   // true once a non-empty NIP-11 document has been parsed
2700  }
2701  
2702  // hasNIP reports whether a supported_nips list contains the given NIP number.
2703  func hasNIP(nips []int32, n int32) (ok bool) {
2704  	for _, x := range nips {
2705  		if x == n {
2706  			return true
2707  		}
2708  	}
2709  	return false
2710  }
2711  
2712  // makeRelayBadge creates a small warning pill like "!42" or "!70".
2713  func makeRelayBadge(text string) (e dom.Element) {
2714  	b := dom.CreateElement("span")
2715  	dom.SetTextContent(b, text)
2716  	dom.SetStyle(b, "display", "inline-block")
2717  	dom.SetStyle(b, "background", "#c44")
2718  	dom.SetStyle(b, "color", "#fff")
2719  	dom.SetStyle(b, "padding", "1px 5px")
2720  	dom.SetStyle(b, "borderRadius", "3px")
2721  	dom.SetStyle(b, "fontSize", "10px")
2722  	dom.SetStyle(b, "marginLeft", "4px")
2723  	dom.SetStyle(b, "fontFamily", "'Fira Code', monospace")
2724  	return b
2725  }
2726  
2727  // updateRelayBadges rewrites the badge container for the relay at index i
2728  // based on the current relayCaps[i]. Missing NIP-42 and NIP-70 support each
2729  // produces a warning pill. Called from the NIP-11 fetch callback.
2730  func updateRelayBadges(i int32) {
2731  	if i < 0 || i >= len(relayBadgeContainers) {
2732  		return
2733  	}
2734  	container := relayBadgeContainers[i]
2735  	clearChildren(container)
2736  	caps := relayCaps[i]
2737  	if !hasNIP(caps.supportedNIPs, 42) {
2738  		dom.AppendChild(container, makeRelayBadge("!42"))
2739  	}
2740  	if !hasNIP(caps.supportedNIPs, 70) {
2741  		dom.AppendChild(container, makeRelayBadge("!70"))
2742  	}
2743  	if hasNIP(caps.supportedNIPs, 50) {
2744  		badge := makeRelayBadge("search")
2745  		dom.SetStyle(badge, "background", "rgba(0,180,80,0.15)")
2746  		dom.SetStyle(badge, "color", "#0a0")
2747  		dom.SetStyle(badge, "border", "1px solid rgba(0,180,80,0.3)")
2748  		dom.AppendChild(container, badge)
2749  	}
2750  }
2751  
2752  // parseNIP11Caps parses a NIP-11 Relay Information Document body into a relayCap.
2753  // Returns a zero value (fetchedOK=false) for empty input.
2754  func parseNIP11Caps(body string) (v relayCap) {
2755  	var c relayCap
2756  	if body == "" {
2757  		return c
2758  	}
2759  	c.fetchedOK = true
2760  	c.name = helpers.JsonGetString(body, "name")
2761  	c.adminPubkey = helpers.JsonGetString(body, "pubkey")
2762  	c.supportedNIPs = helpers.JsonGetIntArray(body, "supported_nips")
2763  	lim := helpers.JsonGetValue(body, "limitation")
2764  	if lim != "" {
2765  		c.authRequired = helpers.JsonGetBool(lim, "auth_required")
2766  		c.paymentReq = helpers.JsonGetBool(lim, "payment_required")
2767  		c.restrictedW = helpers.JsonGetBool(lim, "restricted_writes")
2768  	}
2769  	return c
2770  }
2771  
2772  // loadRelayPolicy reads the persisted relay policy bitfield and blocklist from
2773  // localStorage. Called once at startup before any relay logic runs.
2774  func loadRelayPolicy() {
2775  	if s := localstorage.GetItem(lsKeyRelayPolicy); s != "" {
2776  		relayPolicyFlags = parseIntProp(s)
2777  	}
2778  	if s := localstorage.GetItem(lsKeyRelayBlocklist); s != "" {
2779  		relayBlocklist = splitLines(s)
2780  	}
2781  }
2782  
2783  // saveRelayPolicy persists relayPolicyFlags and relayBlocklist to localStorage.
2784  // Called from the settings UI whenever the user changes a toggle or the
2785  // blocklist is mutated. Does not publish kind 10006 - that is done explicitly
2786  // by the blocklist mutation helpers.
2787  func saveRelayPolicy() {
2788  	localstorage.SetItem(lsKeyRelayPolicy, itoa(relayPolicyFlags))
2789  	localstorage.SetItem(lsKeyRelayBlocklist, joinLines(relayBlocklist))
2790  }
2791  
2792  // saveRelayList persists the current relay URLs and roles to localStorage.
2793  func saveRelayList() {
2794  	localstorage.SetItem(lsKeyRelayList, joinLines(relayURLs))
2795  	roles := ""
2796  	for i, r := range relayRole {
2797  		if i > 0 {
2798  			roles = roles | "\n"
2799  		}
2800  		roles = roles | r
2801  	}
2802  	localstorage.SetItem(lsKeyRelayRoles, roles)
2803  }
2804  
2805  // loadRelayList reads persisted relay URLs and roles and adds them.
2806  // Returns true if any were loaded (so defaults can be skipped).
2807  func loadRelayList() (ok bool) {
2808  	s := localstorage.GetItem(lsKeyRelayList)
2809  	if s == "" {
2810  		return false
2811  	}
2812  	urls := splitLines(s)
2813  	roles := splitLines(localstorage.GetItem(lsKeyRelayRoles))
2814  	added := 0
2815  	for i, u := range urls {
2816  		if len(u) == 0 {
2817  			continue
2818  		}
2819  		role := "both"
2820  		if i < len(roles) && (roles[i] == "read" || roles[i] == "write") {
2821  			role = roles[i]
2822  		}
2823  		addRelayWithRole(u, false, role)
2824  		added++
2825  	}
2826  	return added > 0
2827  }
2828  
2829  // isBlocked reports whether url (already normalized) is in relayBlocklist.
2830  var dnsBlacklist = []string{
2831  	"wss://relay.nostr.band",
2832  	"wss://relay.nostr.band/",
2833  }
2834  
2835  func isBlocked(url string) (ok bool) {
2836  	for _, b := range dnsBlacklist {
2837  		if b == url {
2838  			return true
2839  		}
2840  	}
2841  	for _, b := range relayBlocklist {
2842  		if b == url {
2843  			return true
2844  		}
2845  	}
2846  	return false
2847  }
2848  
2849  // removeRelayByURL tears down the popover row for url and splices it out of
2850  // all parallel relay slices. No-op if url is not present.
2851  func removeRelayByURL(url string) {
2852  	url = normalizeURL(url)
2853  	for i, u := range relayURLs {
2854  		if u != url {
2855  			continue
2856  		}
2857  		// Detach popover row.
2858  		dom.RemoveChild(popoverEl, relayRows[i])
2859  		// Splice all parallel slices.
2860  		relayURLs = relayURLs[:i] | relayURLs[i+1:]
2861  		relayRows = relayRows[:i] | relayRows[i+1:]
2862  		relayDots = relayDots[:i] | relayDots[i+1:]
2863  		relayLabels = relayLabels[:i] | relayLabels[i+1:]
2864  		relayBadgeContainers = relayBadgeContainers[:i] | relayBadgeContainers[i+1:]
2865  		relayUserPick = relayUserPick[:i] | relayUserPick[i+1:]
2866  		relayRole = relayRole[:i] | relayRole[i+1:]
2867  		relayReadBtns = relayReadBtns[:i] | relayReadBtns[i+1:]
2868  		relayWriteBtns = relayWriteBtns[:i] | relayWriteBtns[i+1:]
2869  		relayCaps = relayCaps[:i] | relayCaps[i+1:]
2870  		relayCapsTs = relayCapsTs[:i] | relayCapsTs[i+1:]
2871  		saveRelayList()
2872  		updateStatus()
2873  		return
2874  	}
2875  }
2876  
2877  // addToBlocklist records url in relayBlocklist, tears down any active row for
2878  // it, persists, and publishes kind 10006. Called only from explicit UI action.
2879  func addToBlocklist(url string) {
2880  	url = normalizeURL(url)
2881  	if isBlocked(url) {
2882  		return
2883  	}
2884  	relayBlocklist = append(relayBlocklist, url)
2885  	removeRelayByURL(url)
2886  	saveRelayPolicy()
2887  	publishKind10006()
2888  }
2889  
2890  // removeFromBlocklist unblocks url, persists, and publishes kind 10006. Does
2891  // not auto-re-add the relay - user must add it back explicitly if desired.
2892  func removeFromBlocklist(url string) {
2893  	url = normalizeURL(url)
2894  	for i, b := range relayBlocklist {
2895  		if b == url {
2896  			relayBlocklist = relayBlocklist[:i] | relayBlocklist[i+1:]
2897  			saveRelayPolicy()
2898  			publishKind10006()
2899  			return
2900  		}
2901  	}
2902  }
2903  
2904  // publishKind10006 signs and publishes a NIP-51 "Blocked Relays" list. Called
2905  // only on explicit blocklist mutation - never from loadRelayPolicy, so merely
2906  // loading smesh does not re-publish the list.
2907  func publishKind10006() {
2908  	if !signer.HasSigner() || pubhex == "" {
2909  		return
2910  	}
2911  	// Build tags: [["relay","wss://..."], ...]
2912  	tags := "["
2913  	for i, u := range relayBlocklist {
2914  		if i > 0 {
2915  			tags = tags | ","
2916  		}
2917  		tags = tags | "[\"relay\"," | jstr(u) | "]"
2918  	}
2919  	tags = tags | "]"
2920  	ts := dom.NowSeconds()
2921  	unsigned := "{\"kind\":10006,\"content\":\"\",\"tags\":" | tags |
2922  		",\"created_at\":" | i64toa(ts) |
2923  		",\"pubkey\":\"" | pubhex | "\"}"
2924  	signer.SignEvent(unsigned, func(signed string) {
2925  		if signed != "" {
2926  			routeMsg("[\"EVENT\"," | signed | "]")
2927  			dom.ConsoleLog("[blocklist] published kind 10006 with " | itoa(len(relayBlocklist)) | " entries")
2928  		}
2929  	})
2930  }
2931  
2932  // addRelay adds a relay with role "both".
2933  func addRelay(url string, userPick bool) {
2934  	addRelayWithRole(url, userPick, "both")
2935  }
2936  
2937  // applyRelayRoleBtns sets the active/inactive styling on the r/w buttons.
2938  func applyRelayRoleBtns(rBtn, wBtn dom.Element, role string) {
2939  	canRead := role == "both" || role == "read"
2940  	canWrite := role == "both" || role == "write"
2941  	if canRead {
2942  		dom.SetStyle(rBtn, "background", "var(--bg2)")
2943  		dom.SetStyle(rBtn, "color", "var(--fg)")
2944  		dom.SetStyle(rBtn, "borderColor", "var(--fg)")
2945  	} else {
2946  		dom.SetStyle(rBtn, "background", "transparent")
2947  		dom.SetStyle(rBtn, "color", "var(--muted)")
2948  		dom.SetStyle(rBtn, "borderColor", "var(--border)")
2949  	}
2950  	if canWrite {
2951  		dom.SetStyle(wBtn, "background", "var(--bg2)")
2952  		dom.SetStyle(wBtn, "color", "var(--fg)")
2953  		dom.SetStyle(wBtn, "borderColor", "var(--fg)")
2954  	} else {
2955  		dom.SetStyle(wBtn, "background", "transparent")
2956  		dom.SetStyle(wBtn, "color", "var(--muted)")
2957  		dom.SetStyle(wBtn, "borderColor", "var(--border)")
2958  	}
2959  }
2960  
2961  // addRelayWithRole adds a relay to the list and creates its popover row.
2962  // role is "both" (read+write), "read", or "write".
2963  func addRelayWithRole(url string, userPick bool, role string) {
2964  	url = normalizeURL(url)
2965  	// Blocklist guard - never attach a blocked relay, regardless of source.
2966  	if isBlocked(url) {
2967  		return
2968  	}
2969  	// Dedup.
2970  	for i, u := range relayURLs {
2971  		if u == url {
2972  			if userPick && !relayUserPick[i] {
2973  				relayUserPick[i] = true
2974  				dom.SetStyle(relayLabels[i], "fontWeight", "bold")
2975  			}
2976  			return
2977  		}
2978  	}
2979  
2980  	relayURLs = append(relayURLs, url)
2981  	relayUserPick = append(relayUserPick, userPick)
2982  	relayRole = append(relayRole, role)
2983  	relayCaps = append(relayCaps, relayCap{})
2984  	relayCapsTs = append(relayCapsTs, 0)
2985  	saveRelayList()
2986  
2987  	// Popover row.
2988  	row := dom.CreateElement("div")
2989  	dom.SetStyle(row, "padding", "3px 0")
2990  	dom.SetStyle(row, "display", "flex")
2991  	dom.SetStyle(row, "alignItems", "center")
2992  	dom.SetStyle(row, "gap", "4px")
2993  	relayRows = append(relayRows, row)
2994  
2995  	dot := dom.CreateElement("span")
2996  	dom.SetTextContent(dot, "\u25CF")
2997  	dom.SetStyle(dot, "color", "#5b5")
2998  	dom.SetStyle(dot, "flexShrink", "0")
2999  	relayDots = append(relayDots, dot)
3000  	dom.AppendChild(row, dot)
3001  
3002  	label := dom.CreateElement("span")
3003  	dom.SetTextContent(label, url)
3004  	dom.SetStyle(label, "flex", "1")
3005  	dom.SetStyle(label, "overflow", "hidden")
3006  	dom.SetStyle(label, "textOverflow", "ellipsis")
3007  	dom.SetStyle(label, "whiteSpace", "nowrap")
3008  	if userPick {
3009  		dom.SetStyle(label, "fontWeight", "bold")
3010  	}
3011  	relayLabels = append(relayLabels, label)
3012  	dom.AppendChild(row, label)
3013  
3014  	// Separate read / write toggle buttons.
3015  	makeRoleBtn := func(label string) dom.Element {
3016  		b := dom.CreateElement("button")
3017  		dom.SetTextContent(b, label)
3018  		dom.SetStyle(b, "flexShrink", "0")
3019  		dom.SetStyle(b, "fontSize", "10px")
3020  		dom.SetStyle(b, "fontFamily", "'Fira Code', monospace")
3021  		dom.SetStyle(b, "padding", "1px 5px")
3022  		dom.SetStyle(b, "borderRadius", "3px")
3023  		dom.SetStyle(b, "border", "1px solid var(--border)")
3024  		dom.SetStyle(b, "cursor", "pointer")
3025  		return b
3026  	}
3027  	rBtn := makeRoleBtn("r")
3028  	wBtn := makeRoleBtn("w")
3029  	relayReadBtns = append(relayReadBtns, rBtn)
3030  	relayWriteBtns = append(relayWriteBtns, wBtn)
3031  	applyRelayRoleBtns(rBtn, wBtn, role)
3032  
3033  	capturedURL := url
3034  	dom.AddEventListener(rBtn, "click", dom.RegisterCallback(func() {
3035  		for i, u := range relayURLs {
3036  			if u != capturedURL {
3037  				continue
3038  			}
3039  			switch relayRole[i] {
3040  			case "both":
3041  				relayRole[i] = "write"
3042  			case "read":
3043  				relayRole[i] = "write"
3044  			default: // "write" - re-enable read
3045  				relayRole[i] = "both"
3046  			}
3047  			applyRelayRoleBtns(relayReadBtns[i], relayWriteBtns[i], relayRole[i])
3048  			saveRelayList()
3049  			sendWriteRelays()
3050  			break
3051  		}
3052  	}))
3053  	dom.AddEventListener(wBtn, "click", dom.RegisterCallback(func() {
3054  		for i, u := range relayURLs {
3055  			if u != capturedURL {
3056  				continue
3057  			}
3058  			switch relayRole[i] {
3059  			case "both":
3060  				relayRole[i] = "read"
3061  			case "write":
3062  				relayRole[i] = "read"
3063  			default: // "read" - re-enable write
3064  				relayRole[i] = "both"
3065  			}
3066  			applyRelayRoleBtns(relayReadBtns[i], relayWriteBtns[i], relayRole[i])
3067  			saveRelayList()
3068  			sendWriteRelays()
3069  			break
3070  		}
3071  	}))
3072  	// Empty badge container - populated when NIP-11 fetch completes.
3073  	badgeBox := dom.CreateElement("span")
3074  	dom.SetStyle(badgeBox, "flexShrink", "0")
3075  	relayBadgeContainers = append(relayBadgeContainers, badgeBox)
3076  	dom.AppendChild(row, badgeBox)
3077  
3078  	// r/w buttons at far right.
3079  	dom.SetStyle(rBtn, "marginLeft", "auto")
3080  	dom.AppendChild(row, rBtn)
3081  	dom.AppendChild(row, wBtn)
3082  
3083  	dom.InsertBefore(popoverEl, row, broadcastRow)
3084  	updateStatus()
3085  	if feedSelectEl != 0 {
3086  		populateFeedSelect()
3087  	}
3088  
3089  	// Fetch NIP-11 capability document asynchronously. The callback captures
3090  	// `url` (a fresh parameter per call, not a loop variable) and looks the
3091  	// index up at callback time so it still lands correctly if the list grew.
3092  	httpURL := url
3093  	if len(httpURL) > 6 && httpURL[:6] == "wss://" {
3094  		httpURL = "https://" | httpURL[6:]
3095  	} else if len(httpURL) > 5 && httpURL[:5] == "ws://" {
3096  		httpURL = "http://" | httpURL[5:]
3097  	}
3098  	dom.FetchRelayInfo(httpURL, func(body string) {
3099  		caps := parseNIP11Caps(body)
3100  		for i, u := range relayURLs {
3101  			if u == url {
3102  				relayCaps[i] = caps
3103  				relayCapsTs[i] = dom.NowSeconds()
3104  				updateRelayBadges(i)
3105  				dom.ConsoleLog("[caps] " | url |
3106  					" ok=" | boolStr(caps.fetchedOK) |
3107  					" nip42=" | boolStr(hasNIP(caps.supportedNIPs, 42)) |
3108  					" nip70=" | boolStr(hasNIP(caps.supportedNIPs, 70)) |
3109  					" authReq=" | boolStr(caps.authRequired))
3110  				return
3111  			}
3112  		}
3113  	})
3114  }
3115  
3116  func togglePopover() {
3117  	popoverOpen = !popoverOpen
3118  	if popoverOpen {
3119  		if signerOpen {
3120  			hideSignerPanel()
3121  		}
3122  		closeFeedPopover()
3123  		dom.SetStyle(popoverEl, "display", "block")
3124  		dom.SetStyle(statusEl, "background", "var(--accent)")
3125  		dom.SetStyle(statusEl, "color", "var(--bg)")
3126  	} else {
3127  		dom.SetStyle(popoverEl, "display", "none")
3128  		dom.SetStyle(statusEl, "background", "transparent")
3129  		dom.SetStyle(statusEl, "color", "var(--muted)")
3130  	}
3131  }
3132  
3133  func subscribeProfile() {
3134  	dom.ConsoleLog("[prof] subscribing for pubkey: " | pubhex)
3135  	rr := readRelayURLs()
3136  	proxy := []string{:len(discoveryRelays):len(discoveryRelays)+len(rr)}
3137  	copy(proxy, discoveryRelays)
3138  	for _, u := range rr {
3139  		proxy = appendUnique(proxy, u)
3140  	}
3141  	routeMsg(buildProxyMsg("prof",
3142  		"{\"authors\":[" | jstr(pubhex) | "],\"kinds\":[0,3,10002,10000,10050],\"limit\":8}",
3143  		proxy))
3144  	routeMsg(buildProxyMsg("prof-settings",
3145  		"{\"authors\":[" | jstr(pubhex) | "],\"kinds\":[30078],\"#d\":[\"smesh-settings\"],\"limit\":1}",
3146  		proxy))
3147  }
3148  
3149  var feedLastSubTs int64 // timestamp of last actual subscription
3150  
3151  func subscribeFeed() {
3152  	// Throttle: collapse rapid calls into one after 2s of quiet.
3153  	// Also enforce 10s cooldown - don't resubscribe if nothing changed.
3154  	feedSubscribed = true
3155  	if feedSubTimer != 0 {
3156  		dom.ClearTimeout(feedSubTimer)
3157  	}
3158  	feedSubTimer = dom.SetTimeout(func() {
3159  		feedSubTimer = 0
3160  		now := dom.NowSeconds()
3161  		if feedLastSubTs > 0 && now-feedLastSubTs < 10 {
3162  			return
3163  		}
3164  		doSubscribeFeed()
3165  	}, 2000)
3166  }
3167  
3168  func doSubscribeFeed() {
3169  	feedLastSubTs = dom.NowSeconds()
3170  	dom.ConsoleLog("[sub] feed mode=" | feedMode | " follows=" | itoa(len(myFollows)))
3171  	feedExhausted = false
3172  	feedEmptyStreak = 0
3173  	feedInitialLoad = true
3174  	if feedInitialLoadTimer != 0 {
3175  		dom.ClearTimeout(feedInitialLoadTimer)
3176  		feedInitialLoadTimer = 0
3177  	}
3178  	feedBuffer = nil
3179  	feedHasNew = false
3180  	stopRefreshPulse()
3181  	dom.SetStyle(loadMoreBtn, "display", "none")
3182  
3183  	// Sync state to Feed Worker, then tell it to subscribe.
3184  	// Feed Worker owns the relay subscriptions via F_SUB/F_CLOSE.
3185  	feed.Send(`["F_SET_MODE",` | jstr(feedMode) | `]`)
3186  	feed.Send(`["F_SET_RELAYS",` | readRelayURLsJSON() | `]`)
3187  	feed.Send(`["F_SET_PUBKEY",` | jstr(pubhex) | `]`)
3188  	if len(myFollows) > 0 {
3189  		feed.Send(`["F_SET_FOLLOWS",` | buildJSONStrArr(myFollows) | `]`)
3190  	}
3191  	if len(myMutes) > 0 {
3192  		feed.Send(`["F_SET_MUTES",` | buildJSONStrArr(myMutes) | `]`)
3193  	}
3194  	feed.Send(`["F_REFRESH"]`)
3195  }
3196  
3197  func buildFeedFilter(limit int32) (s string) {
3198  	if feedMode == "follows" {
3199  		follows := myFollows
3200  		if len(follows) > 0 {
3201  			// Always include own pubkey so user's own posts appear in the feed.
3202  			authors := jstr(pubhex)
3203  			for _, pk := range follows {
3204  				if pk != pubhex {
3205  					authors = authors | "," | jstr(pk)
3206  				}
3207  			}
3208  			return "{\"kinds\":[1,6,7,1111],\"authors\":[" | authors | "],\"limit\":" | itoa(limit) | "}"
3209  		}
3210  	}
3211  	return "{\"kinds\":[1,6,7,1111],\"limit\":" | itoa(limit) | "}"
3212  }
3213  
3214  func feedPassesFilter(ev *nostr.Event) (ok bool) {
3215  	if feedMode != "follows" {
3216  		return true
3217  	}
3218  	follows := myFollows
3219  	if len(follows) == 0 {
3220  		return true
3221  	}
3222  	for _, pk := range follows {
3223  		if pk == ev.PubKey {
3224  			return true
3225  		}
3226  	}
3227  	// Also allow own events.
3228  	return ev.PubKey == pubhex
3229  }
3230  
3231  func feedRelays() (ss []string) {
3232  	if feedMode != "" && feedMode != "follows" && feedMode != "relays" {
3233  		// Single relay mode.
3234  		return []string{feedMode}
3235  	}
3236  	return relayURLs
3237  }
3238  
3239  func feedModeURL() (s string) {
3240  	switch feedMode {
3241  	case "", "follows":
3242  		return "/feed/follows"
3243  	case "relays":
3244  		return "/feed/relays"
3245  	}
3246  	// Per-relay: strip ws:// or wss:// for the URL path.
3247  	host := stripScheme(feedMode)
3248  	return "/feed/relay/" | host
3249  }
3250  
3251  func stripScheme(url string) (s string) {
3252  	if len(url) > 6 && url[:6] == "wss://" {
3253  		return url[6:]
3254  	}
3255  	if len(url) > 5 && url[:5] == "ws://" {
3256  		return url[5:]
3257  	}
3258  	return url
3259  }
3260  
3261  var feedMoreGot int32
3262  var feedMoreTimer int32
3263  
3264  func loadOlderFeed() {
3265  	feedLoading = true
3266  	feedMoreGot = 0
3267  	feed.Send(`["F_LOAD_MORE"]`)
3268  	if feedMoreTimer != 0 {
3269  		dom.ClearTimeout(feedMoreTimer)
3270  	}
3271  	feedMoreTimer = dom.SetTimeout(func() {
3272  		feedMoreTimer = 0
3273  		if feedLoading {
3274  			dom.ConsoleLog("[loadOlderFeed] timeout - forcing feedLoading=false, got=" | itoa(feedMoreGot))
3275  			feedLoading = false
3276  			if feedMoreGot == 0 {
3277  				feedEmptyStreak++
3278  				if feedEmptyStreak >= 3 {
3279  					feedExhausted = true
3280  				}
3281  			} else {
3282  				feedEmptyStreak = 0
3283  			}
3284  			if feedExhausted {
3285  				dom.SetStyle(loadMoreBtn, "display", "none")
3286  			} else if oldestFeedTs > 0 {
3287  				dom.SetStyle(loadMoreBtn, "display", "block")
3288  			}
3289  			updateStatus()
3290  		}
3291  	}, 10000)
3292  }
3293  
3294  func populateFeedSelect() {
3295  	updateFeedBtnText()
3296  	if feedPopoverEl == 0 {
3297  		return
3298  	}
3299  	clearChildren(feedPopoverEl)
3300  	if len(myFollows) > 0 {
3301  		addFeedOption("follows", "follows")
3302  	}
3303  	addFeedOption("relays", "relays")
3304  	for _, u := range relayURLs {
3305  		addFeedOption(u, stripScheme(u))
3306  	}
3307  }
3308  
3309  func addFeedOption(value, label string) {
3310  	row := dom.CreateElement("div")
3311  	dom.SetStyle(row, "padding", "6px 8px")
3312  	dom.SetStyle(row, "cursor", "pointer")
3313  	dom.SetStyle(row, "borderRadius", "4px")
3314  	if value == feedMode {
3315  		dom.SetStyle(row, "background", "var(--accent)")
3316  		dom.SetStyle(row, "color", "var(--bg)")
3317  	}
3318  	dom.SetTextContent(row, label)
3319  	v := value
3320  	dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
3321  		feedMode = v
3322  		updateFeedBtnText()
3323  		dom.PushState(feedModeURL())
3324  		closeFeedPopover()
3325  		refreshFeed()
3326  	}))
3327  	dom.AppendChild(feedPopoverEl, row)
3328  }
3329  
3330  func updateFeedBtnText() {
3331  	if feedSelectEl == 0 {
3332  		return
3333  	}
3334  	label := feedMode
3335  	if label == "follows" && len(myFollows) == 0 {
3336  		label = "all relays"
3337  	}
3338  	if label != "follows" && label != "relays" && label != "all relays" {
3339  		label = stripScheme(label)
3340  	}
3341  	dom.SetTextContent(feedSelectEl, label | " \u25BE")
3342  }
3343  
3344  func toggleFeedPopover() {
3345  	if feedPopoverOpen {
3346  		closeFeedPopover()
3347  		return
3348  	}
3349  	if signerOpen {
3350  		hideSignerPanel()
3351  	}
3352  	if popoverOpen {
3353  		togglePopover()
3354  	}
3355  	feedPopoverOpen = true
3356  	populateFeedSelect()
3357  	dom.SetStyle(feedPopoverEl, "display", "block")
3358  	dom.SetStyle(feedSelectEl, "background", "var(--accent)")
3359  	dom.SetStyle(feedSelectEl, "color", "var(--bg)")
3360  }
3361  
3362  func closeFeedPopover() {
3363  	if !feedPopoverOpen {
3364  		return
3365  	}
3366  	feedPopoverOpen = false
3367  	dom.SetStyle(feedPopoverEl, "display", "none")
3368  	dom.SetStyle(feedSelectEl, "background", "transparent")
3369  	dom.SetStyle(feedSelectEl, "color", "var(--fg)")
3370  	updateFeedBtnText()
3371  }
3372  
3373  func onRefreshClick() {
3374  	if refreshSpinning {
3375  		return
3376  	}
3377  	// Flush buffered new events into the feed.
3378  	if len(feedBuffer) > 0 {
3379  		for _, ev := range feedBuffer {
3380  			renderNote(ev)
3381  			seenEvents[ev.ID] = true
3382  		}
3383  		feedBuffer = nil
3384  		feedHasNew = false
3385  		stopRefreshPulse()
3386  	}
3387  	// Scroll to top.
3388  	dom.SetProperty(contentArea, "scrollTop", "0")
3389  	// Start spin animation.
3390  	startRefreshSpin()
3391  	// Cancel throttle and resubscribe immediately.
3392  	if feedSubTimer != 0 {
3393  		dom.ClearTimeout(feedSubTimer)
3394  		feedSubTimer = 0
3395  	}
3396  	feedSubscribed = true
3397  	doSubscribeFeed()
3398  }
3399  
3400  func refreshFeed() {
3401  	// Clear feed container.
3402  	clearChildren(feedContainer)
3403  	seenEvents = map[string]bool{}
3404  	noteElements = map[string]dom.Element{}
3405  	eventCount = 0
3406  	oldestFeedTs = 0
3407  	feedExhausted = false
3408  	feedEmptyStreak = 0
3409  	feedBuffer = nil
3410  	feedHasNew = false
3411  	stopRefreshPulse()
3412  	dom.SetStyle(loadMoreBtn, "display", "none")
3413  	dom.SetProperty(contentArea, "scrollTop", "0")
3414  	// Cancel any pending throttled sub and fire immediately.
3415  	if feedSubTimer != 0 {
3416  		dom.ClearTimeout(feedSubTimer)
3417  		feedSubTimer = 0
3418  	}
3419  	startRefreshSpin()
3420  	feedSubscribed = true
3421  	doSubscribeFeed()
3422  }
3423  
3424  var refreshPulseTimer int32
3425  var refreshPulseOn bool
3426  
3427  func startRefreshPulse() {
3428  	if refreshBtn == 0 || refreshPulseTimer != 0 {
3429  		return
3430  	}
3431  	dom.SetStyle(refreshBtn, "color", "var(--accent)")
3432  	refreshPulseOn = true
3433  	pulseStep()
3434  }
3435  
3436  func pulseStep() {
3437  	if refreshPulseTimer == -1 || refreshBtn == 0 {
3438  		return
3439  	}
3440  	if refreshPulseOn {
3441  		dom.SetStyle(refreshBtn, "opacity", "0.4")
3442  	} else {
3443  		dom.SetStyle(refreshBtn, "opacity", "1")
3444  	}
3445  	refreshPulseOn = !refreshPulseOn
3446  	refreshPulseTimer = dom.SetTimeout(func() {
3447  		pulseStep()
3448  	}, 800)
3449  }
3450  
3451  func stopRefreshPulse() {
3452  	if refreshPulseTimer != 0 {
3453  		dom.ClearTimeout(refreshPulseTimer)
3454  	}
3455  	refreshPulseTimer = -1
3456  	dom.SetTimeout(func() {
3457  		refreshPulseTimer = 0
3458  	}, 0)
3459  	if refreshBtn != 0 {
3460  		dom.SetStyle(refreshBtn, "color", "var(--muted)")
3461  		dom.SetStyle(refreshBtn, "opacity", "1")
3462  	}
3463  }
3464  
3465  var refreshSpinAngle int32
3466  var refreshSpinTimer int32
3467  
3468  func startRefreshSpin() {
3469  	refreshSpinning = true
3470  	if refreshBtn == 0 {
3471  		return
3472  	}
3473  	refreshSpinAngle = 0
3474  	spinStep()
3475  }
3476  
3477  func spinStep() {
3478  	if !refreshSpinning || refreshBtn == 0 {
3479  		return
3480  	}
3481  	refreshSpinAngle += 30
3482  	dom.SetStyle(refreshBtn, "transform", "rotate(" | itoa(refreshSpinAngle) | "deg)")
3483  	refreshSpinTimer = dom.SetTimeout(func() {
3484  		spinStep()
3485  	}, 50)
3486  }
3487  
3488  func stopRefreshSpin() {
3489  	refreshSpinning = false
3490  	if refreshSpinTimer != 0 {
3491  		dom.ClearTimeout(refreshSpinTimer)
3492  		refreshSpinTimer = 0
3493  	}
3494  	if refreshBtn != 0 {
3495  		dom.SetStyle(refreshBtn, "transform", "rotate(0deg)")
3496  	}
3497  }
3498  
3499  func trackOldestTs(ev *nostr.Event) {
3500  	if oldestFeedTs == 0 || ev.CreatedAt < oldestFeedTs {
3501  		oldestFeedTs = ev.CreatedAt
3502  	}
3503  }
3504  
3505  func parseIntProp(s string) (n int32) {
3506  	n = 0
3507  	for i := 0; i < len(s); i++ {
3508  		if s[i] >= '0' && s[i] <= '9' {
3509  			n = n*10 + int32(s[i]-'0')
3510  		} else {
3511  			break
3512  		}
3513  	}
3514  	return n
3515  }
3516  
3517  func boolStr(b bool) (s string) {
3518  	if b {
3519  		return "true"
3520  	}
3521  	return "false"
3522  }
3523  
3524  func i64toa(n int64) (s string) {
3525  	if n == 0 {
3526  		return "0"
3527  	}
3528  	var buf [20]byte
3529  	i := len(buf)
3530  	for n > 0 {
3531  		i--
3532  		buf[i] = byte('0' + n%10)
3533  		n /= 10
3534  	}
3535  	return string(buf[i:])
3536  }
3537  
3538  func sendWriteRelays() {
3539  	msg := "[\"SET_WRITE_RELAYS\",["
3540  	first := true
3541  	for i, url := range relayURLs {
3542  		role := "both"
3543  		if i < len(relayRole) {
3544  			role = relayRole[i]
3545  		}
3546  		if role == "read" {
3547  			continue
3548  		}
3549  		if !first {
3550  			msg = msg | ","
3551  		}
3552  		msg = msg | jstr(url)
3553  		first = false
3554  	}
3555  	routeMsg(msg | "]]")
3556  }
3557  
3558  // readRelayURLs returns relay URLs usable for reading (role "both" or "read").
3559  func readRelayURLs() (ss []string) {
3560  	var out []string
3561  	for i, url := range relayURLs {
3562  		role := "both"
3563  		if i < len(relayRole) {
3564  			role = relayRole[i]
3565  		}
3566  		if role != "write" {
3567  			out = append(out, url)
3568  		}
3569  	}
3570  	return out
3571  }
3572  
3573  func buildProxyMsg(subID, filterJSON string, urls []string) (s string) {
3574  	msg := "[\"PROXY\"," | jstr(subID) | "," | filterJSON | ",["
3575  	for i, url := range urls {
3576  		if i > 0 {
3577  			msg = msg | ","
3578  		}
3579  		msg = msg | jstr(url)
3580  	}
3581  	return msg | "]]"
3582  }
3583  
3584  func jstr(s string) (sv string) {
3585  	return "\"" | jsonEsc(s) | "\""
3586  }
3587  
3588  // scheduleTabRetry schedules a retry for any pending profile fetches after
3589  // the follows/mutes tab renders. Independent of retryRound so it works even
3590  // after the feed's retry budget is exhausted.
3591  func scheduleTabRetry() {
3592  	dom.SetTimeout(func() {
3593  		var missing []string
3594  		for pk := range pendingNotes {
3595  			if _, ok := authorNames[pk]; !ok {
3596  				missing = append(missing, pk)
3597  			}
3598  		}
3599  		if len(missing) == 0 {
3600  			return
3601  		}
3602  		for _, pk := range missing {
3603  			setFetchedK0(pk, false)
3604  		}
3605  		for _, pk := range missing {
3606  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
3607  		}
3608  	}, 5000)
3609  }
3610  
3611  // --- SW message handling ---
3612  
3613  func onSWMessage(raw string) {
3614  	if raw == "update-available" {
3615  		routeMsg("activate-update")
3616  		return
3617  	}
3618  	if raw == "reload" {
3619  		dom.LocationReload()
3620  		return
3621  	}
3622  	if !appReady {
3623  		return
3624  	}
3625  	if len(raw) < 5 || raw[0] != '[' {
3626  		return
3627  	}
3628  	typ, pos := nextStr(raw, 1)
3629  	switch typ {
3630  	case "EVENT":
3631  		subID, pos2 := nextStr(raw, pos)
3632  		evJSON := extractValue(raw, pos2)
3633  		if evJSON == "" {
3634  			return
3635  		}
3636  		ev := nostr.ParseEvent(evJSON)
3637  		if ev == nil {
3638  			return
3639  		}
3640  		dispatchEvent(subID, ev)
3641  	case "EOSE":
3642  		subID, _ := nextStr(raw, pos)
3643  		dispatchEOSE(subID)
3644  	case "SW_LOG":
3645  		msg, _ := nextStr(raw, pos)
3646  		dom.ConsoleLog("[SW] " | msg)
3647  	case "BROADCAST_DONE":
3648  		if broadcastBtnEl != 0 {
3649  			dom.SetTextContent(broadcastBtnEl, "broadcast profile \u2713")
3650  			dom.SetStyle(broadcastBtnEl, "color", "var(--accent)")
3651  			dom.SetTimeout(func() {
3652  				dom.SetTextContent(broadcastBtnEl, "broadcast profile")
3653  			}, 3000)
3654  		}
3655  	case "MLS_DM_LIST":
3656  		listJSON := extractValue(raw, pos)
3657  		renderConversationList(listJSON)
3658  	case "MLS_DM_HISTORY":
3659  		peer, pos2 := nextStr(raw, pos)
3660  		msgsJSON := extractValue(raw, pos2)
3661  		renderThreadMessages(peer, msgsJSON)
3662  	case "MLS_DM_RECEIVED":
3663  		dmJSON := extractValue(raw, pos)
3664  		handleDMReceived(dmJSON)
3665  	case "MLS_DM_SENT":
3666  		// ok/err from MLS worker on send completion
3667  	case "MLS_UNREAD_COUNT":
3668  		// unread count badge (future step)
3669  	case "MLS_DM_HISTORY_CLEARED":
3670  		peer, _ := nextStr(raw, pos)
3671  		dom.ConsoleLog("[mls] history cleared for " | peer)
3672  	case "MLS_STATUS":
3673  		text, _ := nextStr(raw, pos)
3674  		dom.ConsoleLog("[mls] " | text)
3675  	case "OK":
3676  		okEvID, pos2 := nextStr(raw, pos)
3677  		// Boolean follows: skip comma/space, check for "true".
3678  		for pos2 < len(raw) && (raw[pos2] == ',' || raw[pos2] == ' ') {
3679  			pos2++
3680  		}
3681  		isOK := pos2+4 <= len(raw) && raw[pos2:pos2+4] == "true"
3682  		if isOK {
3683  			if targetID, ok := pendingDeletes[okEvID]; ok {
3684  				removeNoteFromDOM(targetID)
3685  				delete(pendingDeletes, okEvID)
3686  			}
3687  		} else {
3688  			// Reason follows after bool.
3689  			for pos2 < len(raw) && raw[pos2] != '"' {
3690  				pos2++
3691  			}
3692  			reason, _ := nextStr(raw, pos2)
3693  			logActivity("relay-err", okEvID[:12] | ": " | reason, "event " | okEvID | "\nreason: " | reason)
3694  		}
3695  	case "SEEN_ON":
3696  		evID, pos2 := nextStr(raw, pos)
3697  		rURL, _ := nextStr(raw, pos2)
3698  		// Strip trailing slash for dedup.
3699  		if len(rURL) > 0 && rURL[len(rURL)-1] == '/' {
3700  			rURL = rURL[:len(rURL)-1]
3701  		}
3702  		if evID != "" && rURL != "" && eventRelays != nil {
3703  			existing := eventRelays[evID]
3704  			found := false
3705  			for _, u := range existing {
3706  				if u == rURL {
3707  					found = true
3708  					break
3709  				}
3710  			}
3711  			if !found {
3712  				eventRelays[evID] = append(existing, rURL)
3713  				logActivity("seen-on", evID[:12] | " on " | rURL, "event " | evID | "\nrelay " | rURL)
3714  				if cb, ok := seenOnSubs[evID]; ok && cb != nil {
3715  					cb()
3716  				}
3717  			}
3718  		}
3719  	case "RELAY_PROXY_READY":
3720  		// Relay-proxy worker restarted (WASM fatal recovery). Re-send init
3721  		// state and re-subscribe so the worker is functional again.
3722  		if pubhex != "" {
3723  			routeMsg("[\"SET_PUBKEY\"," | jstr(pubhex) | "]")
3724  		}
3725  		feedSubscribed = false
3726  		resubscribe()
3727  	case "NEED_IDENTITY":
3728  		if pubhex != "" {
3729  			routeMsg("[\"SET_PUBKEY\"," | jstr(pubhex) | "]")
3730  		}
3731  		resubscribe()
3732  	case "RESUB":
3733  		resubscribe()
3734  
3735  	case "WORKER_RESTARTED":
3736  		// A domain worker crashed and restarted. Re-provision it with current state.
3737  		which, _ := nextStr(raw, pos)
3738  		if pubhex == "" {
3739  			return
3740  		}
3741  		if which == "/profile-wasm-host.mjs" {
3742  			profile.Send("[\"P_SET_PUBKEY\"," | jstr(pubhex) | "]")
3743  			profile.Send("[\"P_SET_RELAYS\"," | readRelayURLsJSON() | "]")
3744  		} else if which == "/feed-wasm-host.mjs" {
3745  			doSubscribeFeed()
3746  		} else if which == "/mls-wasm-host.mjs" {
3747  			mlsw.Send("[\"M_SET_PUBKEY\"," | jstr(pubhex) | "]")
3748  		} else if which == "/notif-wasm-host.mjs" {
3749  			subscribeNotifications()
3750  		}
3751  
3752  	// ── Profile Worker responses ─────────────────────────────────────────────
3753  	case "P_RESOLVED":
3754  		pk3, pos2a := nextStr(raw, pos)
3755  		name3, pos3a := nextStr(raw, pos2a)
3756  		pic3, _ := nextStr(raw, pos3a)
3757  		// Update view cache.
3758  		if len(name3) > 0 {
3759  			setAuthorName(pk3, name3)
3760  		}
3761  		if len(pic3) > 0 {
3762  			setAuthorPic(pk3, pic3)
3763  		}
3764  		// Fill pending note headers.
3765  		if len(name3) > 0 {
3766  			if headers, ok := pendingNotes[pk3]; ok {
3767  				for _, h := range headers {
3768  					updateNoteHeader(h, name3, pic3)
3769  				}
3770  				delete(pendingNotes, pk3)
3771  			}
3772  		}
3773  		if len(pic3) > 0 {
3774  			for _, avaEl := range pendingNotifAvatars[pk3] {
3775  				setMediaSrc(avaEl, pic3)
3776  			}
3777  			delete(pendingNotifAvatars, pk3)
3778  		}
3779  		if pk3 == pubhex {
3780  			if len(name3) > 0 { profileName = name3; dom.SetTextContent(nameEl, name3) }
3781  			if len(pic3) > 0 { profilePic = pic3; setMediaSrc(avatarEl, pic3); dom.SetStyle(avatarEl, "display", "block") }
3782  		}
3783  		updateReplyPreviewsForAuthor(pk3)
3784  		updateInlineProfileLinks(pk3)
3785  
3786  	case "P_CONTENT":
3787  		pk4, pos2b := nextStr(raw, pos)
3788  		contentJSON, _ := nextStr(raw, pos2b)
3789  		profileContentCache[pk4] = contentJSON
3790  		lud16 := helpers.JsonGetString(contentJSON, "lud16")
3791  		lud06 := helpers.JsonGetString(contentJSON, "lud06")
3792  		if lud16 != "" || lud06 != "" {
3793  			applyZapButtonsForAuthor(pk4)
3794  		} else {
3795  			delete(pendingZapRows, pk4)
3796  		}
3797  		// Re-render profile page if it's the viewed profile.
3798  		if profileViewPK == pk4 && activePage == "profile" {
3799  			renderProfilePage(pk4)
3800  		}
3801  		// Update own profile name if this is our own kind 0.
3802  		if pk4 == pubhex {
3803  			name4 := helpers.JsonGetString(contentJSON, "name")
3804  			if name4 == "" { name4 = helpers.JsonGetString(contentJSON, "display_name") }
3805  			if name4 != "" && profileName != name4 {
3806  				profileName = name4
3807  				dom.SetTextContent(nameEl, name4)
3808  			}
3809  		}
3810  		// Persist to IDB.
3811  		if len(contentJSON) > 0 {
3812  			dom.IDBPut("profiles", pk4, contentJSON)
3813  		}
3814  
3815  	case "P_FOLLOWS":
3816  		pkF, pos2c := nextStr(raw, pos)
3817  		followsJSON := extractValue(raw, pos2c)
3818  		pksF := parseStringArrayJSON(followsJSON)
3819  		followsChanged := false
3820  		if pkF == pubhex {
3821  			if len(pksF) != len(myFollows) {
3822  				followsChanged = true
3823  			}
3824  			myFollows = pksF
3825  			followSet = buildSet(pksF)
3826  			broadcastFollows(pksF)
3827  			if feedSelectEl != 0 { populateFeedSelect() }
3828  		}
3829  		profileFollowsCache[pkF] = pksF
3830  		refreshProfileTab(pkF)
3831  		if pkF == pubhex && feedSubscribed && feedMode == "follows" && followsChanged {
3832  			subscribeFeed()
3833  		}
3834  
3835  	case "P_MUTES":
3836  		pkM, pos2d := nextStr(raw, pos)
3837  		mutesJSON := extractValue(raw, pos2d)
3838  		pksM := parseStringArrayJSON(mutesJSON)
3839  		if pkM == pubhex {
3840  			myMutes = pksM
3841  			muteSet = buildSet(pksM)
3842  			broadcastMutes(pksM)
3843  		}
3844  		profileMutesCache[pkM] = pksM
3845  		refreshProfileTab(pkM)
3846  
3847  	case "P_RELAYS":
3848  		pkR, pos2e := nextStr(raw, pos)
3849  		relaysJSON := extractValue(raw, pos2e)
3850  		profileRelaysCache[pkR] = parseStringArrayJSON(relaysJSON)
3851  		if profileViewPK == pkR { refreshProfileTab(pkR) }
3852  
3853  	// ── Feed Worker responses ─────────────────────────────────────────────────
3854  	case "F_RENDER":
3855  		evJSON2 := extractValue(raw, pos)
3856  		if evJSON2 == "" { return }
3857  		fev := nostr.ParseEvent(evJSON2)
3858  		if fev == nil { return }
3859  		// pos+len(evJSON2)+... find 'now' flag
3860  		nowFlag := int64(0)
3861  		for i := pos + len(evJSON2); i < len(raw); i++ {
3862  			c := raw[i]
3863  			if c >= '0' && c <= '9' {
3864  				nowFlag = int64(c - '0')
3865  				break
3866  			}
3867  		}
3868  		setEvent(fev.ID, fev)
3869  		if fev.Kind == 7 {
3870  			handleActionEvent(fev)
3871  			return
3872  		}
3873  		trackOldestTs(fev)
3874  		if nowFlag == 1 {
3875  			if feedLoader != 0 {
3876  				dom.RemoveChild(feedPage, feedLoader)
3877  				feedLoader = 0
3878  			}
3879  			renderNote(fev)
3880  		} else {
3881  			if noteElements[fev.ID] != 0 || seenEvents[fev.ID] {
3882  				return
3883  			}
3884  			feedBuffer = append(feedBuffer, fev)
3885  			if !feedHasNew {
3886  				feedHasNew = true
3887  				startRefreshPulse()
3888  			}
3889  		}
3890  	case "F_EOSE_DONE":
3891  		subID2, _ := nextStr(raw, pos)
3892  		if subID2 == "feed" {
3893  			feedInitialLoad = false
3894  			updateStatus()
3895  			fetchDeletesForFeed()
3896  		}
3897  	case "F_STATUS":
3898  		// ["F_STATUS", count, exhausted]
3899  		cntStr := nextNum(raw, pos)
3900  		var cnt int64
3901  		for _, c := range cntStr {
3902  			if c >= '0' && c <= '9' { cnt = cnt*10 + int64(c-'0') }
3903  		}
3904  		eventCount = int32(cnt)
3905  		dom.ConsoleLog("[feed] events=" | itoa(eventCount))
3906  		// Find exhausted flag after count.
3907  		exStr := ""
3908  		for p2 := pos + len(cntStr); p2 < len(raw); p2++ {
3909  			if raw[p2] >= '0' && raw[p2] <= '9' {
3910  				exStr = nextNum(raw, p2)
3911  				break
3912  			}
3913  		}
3914  		if exStr == "1" {
3915  			feedExhausted = true
3916  		}
3917  		updateStatus()
3918  		if feedLoading {
3919  			feedLoading = false
3920  		}
3921  		if feedExhausted {
3922  			dom.SetStyle(loadMoreBtn, "display", "none")
3923  		} else if oldestFeedTs > 0 {
3924  			dom.SetStyle(loadMoreBtn, "display", "block")
3925  		}
3926  
3927  	// ── Notif Worker responses ────────────────────────────────────────────────
3928  	case "N_RENDER":
3929  		nEvJSON := extractValue(raw, pos)
3930  		if nEvJSON == "" { return }
3931  		nev := nostr.ParseEvent(nEvJSON)
3932  		if nev == nil { return }
3933  		notifEvents = append(notifEvents, nev)
3934  		if len(notifEvents) > notifEventsMax { notifEvents = notifEvents[len(notifEvents)-notifEventsMax:] }
3935  		if notifBuilt && activePage == "notifications" && notifPassesFilter(nev) {
3936  			row := renderNotifRow(nev)
3937  			// mode: "prepend" = first child, "append" = append
3938  			modeStr := ""
3939  			for i := pos + len(nEvJSON); i < len(raw); i++ {
3940  				if raw[i] == '"' {
3941  					j := i + 1
3942  					for j < len(raw) && raw[j] != '"' { j++ }
3943  					modeStr = raw[i+1:j]
3944  					break
3945  				}
3946  			}
3947  			if modeStr == "prepend" {
3948  				first := dom.FirstElementChild(notifContainer)
3949  				if first != 0 { dom.InsertBefore(notifContainer, row, first) } else { dom.AppendChild(notifContainer, row) }
3950  			} else {
3951  				dom.AppendChild(notifContainer, row)
3952  			}
3953  		}
3954  	case "N_DOT":
3955  		showStr := nextNum(raw, pos)
3956  		if showStr == "1" { showNotifDot() }
3957  	case "N_STATUS":
3958  		// ["N_STATUS", count, exhausted]
3959  		nCntStr := nextNum(raw, pos)
3960  		nExStr := ""
3961  		for p3 := pos + len(nCntStr); p3 < len(raw); p3++ {
3962  			if raw[p3] >= '0' && raw[p3] <= '9' {
3963  				nExStr = nextNum(raw, p3)
3964  				break
3965  			}
3966  		}
3967  		if nExStr == "1" {
3968  			notifExhausted = true
3969  		}
3970  		notifLoading = false
3971  		if notifExhausted && notifLoadMore != 0 {
3972  			dom.SetStyle(notifLoadMore, "display", "none")
3973  		} else if oldestNotifTs > 0 && notifLoadMore != 0 {
3974  			dom.SetStyle(notifLoadMore, "display", "block")
3975  		}
3976  	case "N_EOSE_DONE":
3977  		subIDN, _ := nextStr(raw, pos)
3978  		if subIDN == "ntf" {
3979  			notifInitLoad = false
3980  			sortNotifEvents()
3981  			if activePage == "notifications" {
3982  				if notifBuilt {} else { renderNotifPage() }
3983  			}
3984  			if oldestNotifTs > 0 && notifLoadMore != 0 { dom.SetStyle(notifLoadMore, "display", "block") }
3985  		}
3986  	case "N_FETCH_REFS":
3987  		// Forward to relay-proxy: fetch the referenced events so notif row names can fill in.
3988  		idsJSON := extractValue(raw, pos)
3989  		if idsJSON != "" {
3990  			routeMsg(`["PROXY","ntf-ref",{"ids":` | idsJSON | `,"limit":` | itoa(len(notifMissing)) | `},` | buildURLsJSON(readRelayURLs()) | `]`)
3991  		}
3992  	case "N_STORE_READ_TS":
3993  		tsStr2 := nextNum(raw, pos)
3994  		var ts2 int64
3995  		for _, c := range tsStr2 { if c >= '0' && c <= '9' { ts2 = ts2*10 + int64(c-'0') } }
3996  		if ts2 > 0 { dom.IDBPut("settings", "notif-read-ts", i64toa(ts2)) }
3997  
3998  	// ── DM Worker responses ───────────────────────────────────────────────────
3999  	case "D_LIST_RESP":
4000  		listJSON2 := extractValue(raw, pos)
4001  		renderConversationList(listJSON2)
4002  	case "D_HISTORY_RESP":
4003  		peerD, pos3 := nextStr(raw, pos)
4004  		msgsD := extractValue(raw, pos3)
4005  		renderThreadMessages(peerD, msgsD)
4006  	case "D_RECEIVED":
4007  		dmJ := extractValue(raw, pos)
4008  		handleDMReceived(dmJ)
4009  	case "D_SENT":
4010  		_, pos4 := nextStr(raw, pos) // peer
4011  		okStr, _ := nextStr(raw, pos4)
4012  		if okStr == "1" || okStr == "true" {
4013  			if len(pendingTsEls) > 0 {
4014  				dom.SetTextContent(pendingTsEls[0], formatTime(dom.NowSeconds()))
4015  				pendingTsEls = pendingTsEls[1:]
4016  			}
4017  		}
4018  
4019  	// ── Profile Worker retry request ──────────────────────────────────────────
4020  	case "P_RETRY_REQ":
4021  		// Profile Worker is asking for pending pubkeys that still lack a name.
4022  		// Collect from pendingNotes and send back as P_RETRY_PROVIDE.
4023  		var pending []string
4024  		for pk := range pendingNotes {
4025  			if _, ok := authorNames[pk]; !ok {
4026  				pending = append(pending, pk)
4027  			}
4028  		}
4029  		if len(pending) > 0 {
4030  			profile.Send(`["P_RETRY_PROVIDE",` | buildJSONStrArr(pending) | `]`)
4031  		}
4032  	}
4033  }
4034  
4035  func resubscribe() {
4036  	sendWriteRelays()
4037  	subscribeProfile()
4038  	subscribeFeed()
4039  	subscribeNotifications()
4040  	// Sync domain workers with current state.
4041  	if pubhex != "" {
4042  		profile.Send("[\"P_SET_PUBKEY\"," | jstr(pubhex) | "]")
4043  		profile.Send("[\"P_SET_RELAYS\"," | readRelayURLsJSON() | "]")
4044  		mlsw.Send("[\"M_SET_PUBKEY\"," | jstr(pubhex) | "]")
4045  	}
4046  	if activePage == "messaging" {
4047  		initMessaging()
4048  	}
4049  	if activePage == "profile" && profileTab == "notes" && profileViewPK != "" {
4050  		renderProfileNotes(profileViewPK)
4051  	}
4052  }
4053  
4054  func dispatchEvent(subID string, ev *nostr.Event) {
4055  	if subID == "prof" || subID == "prof-settings" {
4056  		handleProfileEvent(ev)
4057  	} else if subID == "feed" || subID == "feed-more" {
4058  		// Route to Feed Worker for dedup/filter/buffer management.
4059  		feed.Send(`["F_EVENT",` | jstr(subID) | `,` | ev.ToJSON() | `]`)
4060  	} else if len(subID) > 3 && subID[:3] == "ap-" {
4061  		// Profile Worker owns all profile data; forward the event for processing.
4062  		// P_RESOLVED/P_CONTENT/P_FOLLOWS/P_MUTES/P_RELAYS come back and Shell handles DOM.
4063  		profile.Send(`["P_EVENT",` | ev.ToJSON() | `]`)
4064  	} else if len(subID) > 3 && subID[:3] == "pn-" {
4065  		if profileNotesSeen[ev.ID] {
4066  			return
4067  		}
4068  		profileNotesSeen[ev.ID] = true
4069  		// Dedup against the broad feed sub: relays send the same event on
4070  		// every matching sub on a connection, so a profile-author's new
4071  		// note also arrives on "feed". Marking it here prevents it from
4072  		// being double-rendered into feedContainer later in the session.
4073  		seenEvents[ev.ID] = true
4074  		renderProfileNote(ev)
4075  	} else if len(subID) > 6 && subID[:6] == "emb-c-" {
4076  		setEvent(ev.ID, ev)
4077  		fillCoordEmbed(ev)
4078  	} else if len(subID) > 7 && subID[:7] == "emb-cr-" {
4079  		setEvent(ev.ID, ev)
4080  		fillCoordEmbed(ev)
4081  	} else if len(subID) > 4 && subID[:4] == "emb-" {
4082  		setEvent(ev.ID, ev)
4083  		fillEmbed(ev)
4084  	} else if len(subID) > 3 && subID[:3] == "rp-" {
4085  		handleReplyPreviewEvent(ev)
4086  	} else if len(subID) > 4 && subID[:4] == "thr-" {
4087  		handleThreadEvent(threadGen, ev)
4088  	} else if len(subID) > 4 && subID[:4] == "act-" {
4089  		handleActionEvent(ev)
4090  	} else if subID == "ntf" {
4091  		handleNotifEvent(ev)
4092  	} else if subID == "ntf-ref" {
4093  		handleNotifRefEvent(ev)
4094  	} else if subID == "ntf-more" {
4095  		handleNotifMoreEvent(ev)
4096  	} else if subID == "search" {
4097  		handleSearchEvent(ev)
4098  	} else if subID == "del-scan" {
4099  		if ev.Kind == 5 {
4100  			for _, tag := range ev.Tags.GetAll("e") {
4101  				targetID := tag.Value()
4102  				if targetID != "" {
4103  					removeNoteFromDOM(targetID)
4104  				}
4105  			}
4106  		}
4107  	}
4108  }
4109  
4110  func dispatchEOSE(subID string) {
4111  	// Route to domain workers first.
4112  	if subID == "feed" || subID == "feed-more" {
4113  		feed.Send(`["F_EOSE",` | jstr(subID) | `]`)
4114  	}
4115  	if subID == "ntf" || subID == "ntf-more" || subID == "ntf-ref" {
4116  		notif.Send(`["N_EOSE",` | jstr(subID) | `]`)
4117  	}
4118  	if (len(subID) > 3 && subID[:3] == "ap-") || (len(subID) > 9 && subID[:9] == "ap-batch-") {
4119  		profile.Send(`["P_EOSE",` | jstr(subID) | `]`)
4120  	}
4121  
4122  	if subID == "feed-more" {
4123  		dom.ConsoleLog("[EOSE] feed-more got=" | itoa(feedMoreGot))
4124  		if feedMoreTimer != 0 {
4125  			dom.ClearTimeout(feedMoreTimer)
4126  			feedMoreTimer = 0
4127  		}
4128  		feedLoading = false
4129  		if feedMoreGot == 0 {
4130  			feedEmptyStreak++
4131  			if feedEmptyStreak >= 3 {
4132  				feedExhausted = true
4133  			}
4134  		} else {
4135  			feedEmptyStreak = 0
4136  		}
4137  		if !feedExhausted && oldestFeedTs > 0 {
4138  			dom.SetStyle(loadMoreBtn, "display", "block")
4139  		}
4140  		updateStatus()
4141  	} else if subID == "feed" {
4142  		// Don't flip feedInitialLoad immediately. The SW sends EOSE on the
4143  		// FIRST relay's response (often the empty local relay), but slower
4144  		// remote relays keep streaming events for several seconds. Holding the
4145  		// initial-load window open lets those events render directly instead
4146  		// of being trapped in feedBuffer until the user clicks refresh.
4147  		if feedInitialLoadTimer != 0 {
4148  			dom.ClearTimeout(feedInitialLoadTimer)
4149  		}
4150  		feedInitialLoadTimer = dom.SetTimeout(func() {
4151  			feedInitialLoadTimer = 0
4152  			feedInitialLoad = false
4153  		}, 3000)
4154  		if refreshSpinning {
4155  			stopRefreshSpin()
4156  		}
4157  		if feedLoader != 0 {
4158  			dom.RemoveChild(feedPage, feedLoader)
4159  			feedLoader = 0
4160  		}
4161  		if oldestFeedTs > 0 {
4162  			dom.SetStyle(loadMoreBtn, "display", "block")
4163  		}
4164  		updateStatus()
4165  		retryMissingProfiles()
4166  		fetchDeletesForFeed()
4167  	} else if subID == "ntf" {
4168  		dom.ConsoleLog("[EOSE] ntf events=" | itoa(len(notifEvents)))
4169  		notifInitLoad = false
4170  		sortNotifEvents()
4171  		if activePage == "notifications" {
4172  			if notifBuilt {
4173  				// Events were rendered incrementally during load; skip the
4174  				// clear+rebuild flash. notifEvents is sorted for future renders
4175  				// (filter changes, load-more). The "load more" button appears below.
4176  			} else {
4177  				renderNotifPage()
4178  			}
4179  		}
4180  		if oldestNotifTs > 0 && notifLoadMore != 0 {
4181  			dom.SetStyle(notifLoadMore, "display", "block")
4182  		}
4183  		fetchNotifRefs()
4184  	} else if subID == "ntf-ref" {
4185  		if notifBuilt && activePage == "notifications" {
4186  			rebuildNotifRows()
4187  		}
4188  	} else if subID == "ntf-more" {
4189  		dom.ConsoleLog("[EOSE] ntf-more got=" | itoa(notifMoreGot) | " streak=" | itoa(notifEmptyStrk))
4190  		if notifMoreTimer != 0 {
4191  			dom.ClearTimeout(notifMoreTimer)
4192  			notifMoreTimer = 0
4193  		}
4194  		notifLoading = false
4195  		if notifMoreGot == 0 {
4196  			notifEmptyStrk++
4197  			if notifEmptyStrk >= 3 {
4198  				notifExhausted = true
4199  			}
4200  		} else {
4201  			notifEmptyStrk = 0
4202  		}
4203  		if !notifExhausted && oldestNotifTs > 0 && notifLoadMore != 0 {
4204  			dom.SetStyle(notifLoadMore, "display", "block")
4205  		}
4206  		fetchNotifRefs()
4207  	} else if subID == "search" {
4208  		dom.ConsoleLog("[EOSE] search results=" | itoa(len(searchResults)))
4209  		renderSearchResults()
4210  	} else if subID == "del-scan" {
4211  		routeMsg("[\"CLOSE\",\"del-scan\"]")
4212  	} else if len(subID) > 9 && subID[:9] == "ap-batch-" {
4213  		// Delay CLOSE; Profile Worker handles retry logic via P_EOSE (sent above).
4214  		closeID := subID
4215  		dom.SetTimeout(func() {
4216  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4217  		}, 15000)
4218  	} else if len(subID) > 3 && subID[:3] == "ap-" {
4219  		// Delay CLOSE; Profile Worker handles relay-hint retry via P_EOSE (sent above).
4220  		closeID := subID
4221  		dom.SetTimeout(func() {
4222  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4223  		}, 15000)
4224  	} else if len(subID) > 6 && subID[:6] == "emb-c-" {
4225  		closeID := subID
4226  		dom.SetTimeout(func() {
4227  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4228  			embedCoordEOSECleanup(closeID)
4229  		}, 5000)
4230  	} else if len(subID) > 7 && subID[:7] == "emb-cr-" {
4231  		closeID := subID
4232  		dom.SetTimeout(func() {
4233  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4234  			embedCoordEOSECleanup(closeID)
4235  		}, 5000)
4236  	} else if len(subID) > 4 && subID[:4] == "emb-" {
4237  		closeID := subID
4238  		dom.SetTimeout(func() {
4239  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4240  			embedEOSECleanup(closeID)
4241  		}, 5000)
4242  	} else if len(subID) > 3 && subID[:3] == "rp-" {
4243  		closeID := subID
4244  		dom.SetTimeout(func() {
4245  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4246  		}, 5000)
4247  	} else if len(subID) > 4 && subID[:4] == "thr-" {
4248  		closeID := subID
4249  		dom.SetTimeout(func() {
4250  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4251  		}, 5000)
4252  		if threadOpen {
4253  			handleThreadEOSE()
4254  		}
4255  	} else if len(subID) > 4 && subID[:4] == "act-" {
4256  		closeID := subID
4257  		dom.SetTimeout(func() {
4258  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
4259  		}, 10000)
4260  	}
4261  }
4262  
4263  // nextNum extracts a bare number from s starting at pos, returning it as a string.
4264  func nextNum(s string, pos int32) (sv string) {
4265  	for pos < len(s) && (s[pos] == ' ' || s[pos] == ',') {
4266  		pos++
4267  	}
4268  	start := pos
4269  	for pos < len(s) && s[pos] >= '0' && s[pos] <= '9' {
4270  		pos++
4271  	}
4272  	return s[start:pos]
4273  }
4274  
4275  // nextStr extracts the next quoted string from s starting at pos.
4276  func nextStr(s string, pos int32) (string, int32) {
4277  	for pos < len(s) && s[pos] != '"' {
4278  		pos++
4279  	}
4280  	if pos >= len(s) {
4281  		return "", pos
4282  	}
4283  	pos++
4284  	var buf []byte
4285  	hasEsc := false
4286  	start := pos
4287  	for pos < len(s) {
4288  		if s[pos] == '\\' && pos+1 < len(s) {
4289  			hasEsc = true
4290  			buf = buf | s[start:pos]
4291  			pos++
4292  			switch s[pos] {
4293  			case '"', '\\', '/':
4294  				buf = append(buf, s[pos])
4295  			case 'n':
4296  				buf = append(buf, '\n')
4297  			case 't':
4298  				buf = append(buf, '\t')
4299  			case 'r':
4300  				buf = append(buf, '\r')
4301  			default:
4302  				buf = append(buf, '\\', s[pos])
4303  			}
4304  			pos++
4305  			start = pos
4306  			continue
4307  		}
4308  		if s[pos] == '"' {
4309  			break
4310  		}
4311  		pos++
4312  	}
4313  	if pos >= len(s) {
4314  		return "", pos
4315  	}
4316  	var val string
4317  	if hasEsc {
4318  		buf = buf | s[start:pos]
4319  		val = string(buf)
4320  	} else {
4321  		val = s[start:pos]
4322  	}
4323  	pos++
4324  	for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') {
4325  		pos++
4326  	}
4327  	return val, pos
4328  }
4329  
4330  // extractValue extracts a JSON object/array value starting at pos.
4331  func extractValue(s string, pos int32) (sv string) {
4332  	for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') {
4333  		pos++
4334  	}
4335  	if pos >= len(s) {
4336  		return ""
4337  	}
4338  	if s[pos] != '{' && s[pos] != '[' {
4339  		return ""
4340  	}
4341  	start := pos
4342  	depth := 0
4343  	for pos < len(s) {
4344  		c := s[pos]
4345  		if c == '{' || c == '[' {
4346  			depth++
4347  		}
4348  		if c == '}' || c == ']' {
4349  			depth--
4350  			if depth == 0 {
4351  				return s[start : pos+1]
4352  			}
4353  		}
4354  		if c == '"' {
4355  			pos++
4356  			for pos < len(s) && s[pos] != '"' {
4357  				if s[pos] == '\\' {
4358  					pos++
4359  				}
4360  				pos++
4361  			}
4362  		}
4363  		pos++
4364  	}
4365  	return s[start:]
4366  }
4367  
4368  // parseStringArrayJSON parses a JSON array of strings like ["a","b","c"].
4369  func parseStringArrayJSON(json string) (ss []string) {
4370  	var out []string
4371  	i := 0
4372  	for i < len(json) && json[i] != '[' { i++ }
4373  	if i >= len(json) { return nil }
4374  	i++
4375  	for {
4376  		for i < len(json) && (json[i] == ' ' || json[i] == ',' || json[i] == '\n') { i++ }
4377  		if i >= len(json) || json[i] == ']' { break }
4378  		if json[i] != '"' { break }
4379  		i++
4380  		start := i
4381  		for i < len(json) && json[i] != '"' {
4382  			if json[i] == '\\' { i++ }
4383  			i++
4384  		}
4385  		if i >= len(json) { break }
4386  		out = append(out, json[start:i])
4387  		i++
4388  	}
4389  	return out
4390  }
4391  
4392  func handleProfileEvent(ev *nostr.Event) {
4393  	// Forward to Profile Worker for data storage in all cases.
4394  	profile.Send(`["P_EVENT",` | ev.ToJSON() | `]`)
4395  	// Shell handles DOM-immediate effects for the logged-in user's own events.
4396  	switch ev.Kind {
4397  	case 0:
4398  		if ev.CreatedAt <= profileTs {
4399  			return
4400  		}
4401  		profileTs = ev.CreatedAt
4402  		// P_CONTENT from Profile Worker will handle profile page re-render + IDB.
4403  		// Update own header directly here for immediate feedback.
4404  		name := helpers.JsonGetString(ev.Content, "name")
4405  		if len(name) == 0 {
4406  			name = helpers.JsonGetString(ev.Content, "display_name")
4407  		}
4408  		pic := helpers.JsonGetString(ev.Content, "picture")
4409  		if len(name) > 0 {
4410  			profileName = name
4411  			setAuthorName(pubhex, name)
4412  			dom.SetTextContent(nameEl, name)
4413  		}
4414  		if len(pic) > 0 {
4415  			profilePic = pic
4416  			setAuthorPic(pubhex, pic)
4417  			setMediaSrc(avatarEl, pic)
4418  			dom.SetStyle(avatarEl, "display", "block")
4419  		}
4420  	case 3:
4421  		if ev.CreatedAt <= contactTs {
4422  			return
4423  		}
4424  		contactTs = ev.CreatedAt
4425  		var pks []string
4426  		for _, tag := range ev.Tags.GetAll("p") {
4427  			if v := tag.Value(); v != "" {
4428  				pks = append(pks, v)
4429  			}
4430  		}
4431  		prev := myFollows
4432  		myFollows = pks
4433  		followSet = buildSet(pks)
4434  		broadcastFollows(pks)
4435  		for _, pk := range pks {
4436  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
4437  		}
4438  		refreshProfileTab(pubhex)
4439  		if feedSelectEl != 0 {
4440  			populateFeedSelect()
4441  		}
4442  		if feedMode == "follows" {
4443  			if feedDeferTimer != 0 {
4444  				dom.ClearTimeout(feedDeferTimer)
4445  				feedDeferTimer = 0
4446  			}
4447  			if !feedSubscribed {
4448  				feedSubscribed = true
4449  				logActivity("boot", "kind 3 received, subscribing feed", itoa(len(pks)) | " follows")
4450  				subscribeFeed()
4451  				subscribeNotifications()
4452  				bootDone()
4453  			} else if len(pks) != len(prev) {
4454  				subscribeFeed()
4455  			}
4456  		}
4457  	case 10000:
4458  		if ev.CreatedAt <= muteTs {
4459  			return
4460  		}
4461  		muteTs = ev.CreatedAt
4462  		var pks []string
4463  		for _, tag := range ev.Tags.GetAll("p") {
4464  			if v := tag.Value(); v != "" {
4465  				pks = append(pks, v)
4466  			}
4467  		}
4468  		myMutes = pks
4469  		muteSet = buildSet(pks)
4470  		broadcastMutes(pks)
4471  		refreshProfileTab(pubhex)
4472  	case 10002:
4473  		if ev.CreatedAt <= relayListTs {
4474  			return
4475  		}
4476  		relayListTs = ev.CreatedAt
4477  		// Profile Worker handles relay frequency tracking via P_EVENT.
4478  		for _, tag := range ev.Tags.GetAll("r") {
4479  			url := tag.Value()
4480  			if url != "" {
4481  				addRelay(url, true)
4482  			}
4483  		}
4484  		sendWriteRelays()
4485  		// Relay list changed - resubscribe to include new relays.
4486  		// subscribeFeed has its own 2s throttle, so direct call is fine.
4487  		if feedSubscribed {
4488  			subscribeFeed()
4489  			subscribeNotifications()
4490  		}
4491  	case 10050:
4492  		if ev.CreatedAt <= inboxTs {
4493  			return
4494  		}
4495  		inboxTs = ev.CreatedAt
4496  		// DM inbox relay list - stored for future use.
4497  		_ = ev.Tags.GetAll("relay")
4498  	case 30078:
4499  		dtag := ev.Tags.GetFirst("d")
4500  		if dtag == nil || dtag.Value() != "smesh-settings" {
4501  			return
4502  		}
4503  		if ev.CreatedAt <= settingsTs {
4504  			return
4505  		}
4506  		settingsTs = ev.CreatedAt
4507  		applyAppSettings(ev.Content)
4508  	}
4509  }
4510  
4511  func applyAppSettings(content string) {
4512  	theme := helpers.JsonGetString(content, "theme")
4513  	if theme == "light" && isDark {
4514  		toggleTheme()
4515  	} else if theme == "dark" && !isDark {
4516  		toggleTheme()
4517  	}
4518  	blossom := helpers.JsonGetString(content, "blossom")
4519  	if blossom != "" {
4520  		blossomServerURL = blossom
4521  		localstorage.SetItem(lsKeyBlossomServer, blossom)
4522  	} else {
4523  		blossomServerURL = ""
4524  		localstorage.RemoveItem(lsKeyBlossomServer)
4525  	}
4526  	clientTag := helpers.JsonGetString(content, "clientTag")
4527  	if clientTag == "0" {
4528  		localstorage.SetItem("smesh-client-tag", "0")
4529  	} else {
4530  		localstorage.RemoveItem("smesh-client-tag")
4531  	}
4532  	lang := helpers.JsonGetString(content, "lang")
4533  	if lang != "" && lang != currentLang {
4534  		setLang(lang)
4535  	}
4536  	inv := helpers.JsonGetString(content, "invidious")
4537  	invidiousURL = inv
4538  	if inv != "" {
4539  		localstorage.SetItem(lsKeyInvidiousURL, inv)
4540  	} else {
4541  		localstorage.RemoveItem(lsKeyInvidiousURL)
4542  	}
4543  	camp := helpers.JsonGetString(content, "campion")
4544  	campionURL = camp
4545  	if camp != "" {
4546  		localstorage.SetItem(lsKeyCampionURL, camp)
4547  	} else {
4548  		localstorage.RemoveItem(lsKeyCampionURL)
4549  	}
4550  	// Notification read watermark - sync across devices.
4551  	nrts := helpers.JsonGetValue(content, "notifReadTs")
4552  	if nrts != "" {
4553  		ts := parseI64(nrts)
4554  		if ts > notifReadTs {
4555  			notifReadTs = ts
4556  			dom.IDBPut("settings", "notif-read-ts", i64toa(ts))
4557  		}
4558  	}
4559  	// Relay blocklist - merge in entries from the settings event.
4560  	blocklistRaw := helpers.JsonGetValue(content, "relayBlocklist")
4561  	if blocklistRaw != "" {
4562  		imported := parseJSONStringArray(blocklistRaw)
4563  		for _, u := range imported {
4564  			u = normalizeURL(u)
4565  			if u != "" && !isBlocked(u) {
4566  				relayBlocklist = append(relayBlocklist, u)
4567  			}
4568  		}
4569  		if len(imported) > 0 {
4570  			saveRelayPolicy()
4571  		}
4572  	}
4573  }
4574  
4575  func saveAppSettings() {
4576  	if pubhex == "" {
4577  		return
4578  	}
4579  	theme := "dark"
4580  	if !isDark {
4581  		theme = "light"
4582  	}
4583  	clientTag := "1"
4584  	if localstorage.GetItem("smesh-client-tag") == "0" {
4585  		clientTag = "0"
4586  	}
4587  	blocklistJSON := "["
4588  	for i, u := range relayBlocklist {
4589  		if i > 0 {
4590  			blocklistJSON = blocklistJSON | ","
4591  		}
4592  		blocklistJSON = blocklistJSON | jstr(u)
4593  	}
4594  	blocklistJSON = blocklistJSON | "]"
4595  	content := "{" |
4596  		"\"theme\":" | jstr(theme) | "," |
4597  		"\"blossom\":" | jstr(blossomServerURL) | "," |
4598  		"\"clientTag\":" | jstr(clientTag) | "," |
4599  		"\"lang\":" | jstr(currentLang) | "," |
4600  		"\"relayBlocklist\":" | blocklistJSON | "," |
4601  		"\"invidious\":" | jstr(invidiousURL) | "," |
4602  		"\"campion\":" | jstr(campionURL) | "," |
4603  		"\"notifReadTs\":" | i64toa(notifReadTs) |
4604  		"}"
4605  	ts := dom.NowSeconds()
4606  	tags := "[[\"d\",\"smesh-settings\"]]"
4607  	unsigned := "{\"kind\":30078,\"content\":" | jstr(content) |
4608  		",\"tags\":" | tags |
4609  		",\"created_at\":" | i64toa(ts) |
4610  		",\"pubkey\":\"" | pubhex | "\"}"
4611  	signer.SignEvent(unsigned, func(signed string) {
4612  		if signed == "" {
4613  			return
4614  		}
4615  		settingsTs = ts
4616  		routeMsg("[\"EVENT\"," | signed | "]")
4617  	})
4618  }
4619  
4620  func updateStatus() {
4621  	dom.SetTextContent(statusEl, t("relays"))
4622  	dom.SetTextContent(popoverSummary,
4623  		itoa(len(relayURLs)) | " relays | " | itoa(eventCount) | " events")
4624  }
4625  
4626  // --- Feed rendering ---
4627  
4628  func renderNote(ev *nostr.Event) {
4629  	if noteElements[ev.ID] != 0 || seenEvents[ev.ID] {
4630  		return
4631  	}
4632  
4633  	repostPK := ""
4634  	repostID := ""
4635  	repostTs := int64(0)
4636  	if ev.Kind == 6 {
4637  		inner := nostr.ParseEvent(ev.Content)
4638  		if inner == nil {
4639  			return
4640  		}
4641  		repostPK = ev.PubKey
4642  		repostID = ev.ID
4643  		repostTs = ev.CreatedAt
4644  		setEvent(inner.ID, inner)
4645  		ev = inner
4646  	}
4647  
4648  	note := dom.CreateElement("div")
4649  	if repostID != "" {
4650  		noteElements[repostID] = note
4651  	} else {
4652  		noteElements[ev.ID] = note
4653  	}
4654  	dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
4655  	dom.SetStyle(note, "padding", "12px 0")
4656  	sortTs := ev.CreatedAt
4657  	if repostTs > 0 {
4658  		sortTs = repostTs
4659  	}
4660  	dom.SetProperty(note, "smeshTs", i64toa(sortTs))
4661  
4662  	if repostPK != "" {
4663  		rpBanner := dom.CreateElement("div")
4664  		dom.SetStyle(rpBanner, "display", "flex")
4665  		dom.SetStyle(rpBanner, "alignItems", "center")
4666  		dom.SetStyle(rpBanner, "gap", "6px")
4667  		dom.SetStyle(rpBanner, "fontSize", "12px")
4668  		dom.SetStyle(rpBanner, "color", "var(--muted)")
4669  		dom.SetStyle(rpBanner, "marginBottom", "6px")
4670  		dom.SetStyle(rpBanner, "cursor", "pointer")
4671  		dom.SetInnerHTML(rpBanner, svgRepost)
4672  		rpName := dom.CreateElement("span")
4673  		if name, ok := authorNames[repostPK]; ok && name != "" {
4674  			dom.SetTextContent(rpName, name | " " | t("reposted"))
4675  		} else {
4676  			npub := helpers.EncodeNpub(helpers.HexDecode(repostPK))
4677  			short := npub
4678  			if len(npub) > 20 {
4679  				short = npub[:12] | "..." | npub[len(npub)-4:]
4680  			}
4681  			dom.SetTextContent(rpName, short | " " | t("reposted"))
4682  		}
4683  		dom.AppendChild(rpBanner, rpName)
4684  		bannerPK := repostPK
4685  		dom.AddEventListener(rpBanner, "click", dom.RegisterCallback(func() {
4686  			showProfile(bannerPK)
4687  		}))
4688  		dom.AppendChild(note, rpBanner)
4689  	}
4690  
4691  	// Header row: author link (left) + timestamp (right).
4692  	header := dom.CreateElement("div")
4693  	dom.SetStyle(header, "display", "flex")
4694  	dom.SetStyle(header, "alignItems", "center")
4695  	dom.SetStyle(header, "marginBottom", "4px")
4696  	dom.SetStyle(header, "maxWidth", "65ch")
4697  
4698  	// Author link - only covers avatar + name.
4699  	authorLink := dom.CreateElement("div")
4700  	dom.SetStyle(authorLink, "display", "flex")
4701  	dom.SetStyle(authorLink, "alignItems", "center")
4702  	dom.SetStyle(authorLink, "gap", "8px")
4703  	dom.SetStyle(authorLink, "cursor", "pointer")
4704  	headerPK := ev.PubKey
4705  	dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
4706  		showProfile(headerPK)
4707  	}))
4708  
4709  	avatar := dom.CreateElement("img")
4710  	dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
4711  	dom.SetAttribute(avatar, "width", "24")
4712  	dom.SetAttribute(avatar, "height", "24")
4713  	dom.SetStyle(avatar, "borderRadius", "50%")
4714  	dom.SetStyle(avatar, "objectFit", "cover")
4715  	dom.SetStyle(avatar, "flexShrink", "0")
4716  
4717  	nameSpan := dom.CreateElement("span")
4718  	dom.SetStyle(nameSpan, "fontSize", "18px")
4719  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
4720  	dom.SetStyle(nameSpan, "fontWeight", "bold")
4721  	dom.SetStyle(nameSpan, "color", "var(--fg)")
4722  
4723  	pk := ev.PubKey
4724  	if pic, ok := authorPics[pk]; ok && pic != "" {
4725  		setMediaSrc(avatar, pic)
4726  		dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
4727  	} else {
4728  		dom.SetStyle(avatar, "display", "none")
4729  	}
4730  	if name, ok := authorNames[pk]; ok && name != "" {
4731  		dom.SetTextContent(nameSpan, name)
4732  	} else {
4733  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
4734  		if len(npub) > 20 {
4735  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
4736  		}
4737  	}
4738  
4739  	dom.AppendChild(authorLink, avatar)
4740  	dom.AppendChild(authorLink, nameSpan)
4741  	dom.AppendChild(header, authorLink)
4742  
4743  	appendClientTag(header, ev)
4744  
4745  	// Right side: delete + relay widget + timestamp - right-aligned.
4746  	rightGroup := dom.CreateElement("div")
4747  	dom.SetStyle(rightGroup, "display", "flex")
4748  	dom.SetStyle(rightGroup, "alignItems", "center")
4749  	dom.SetStyle(rightGroup, "gap", "4px")
4750  	dom.SetStyle(rightGroup, "marginLeft", "auto")
4751  
4752  	// Delete button - own notes only.
4753  	if pk == pubhex {
4754  		delBtn := dom.CreateElement("span")
4755  		dom.SetInnerHTML(delBtn, `<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M2 4h10M5 4V2.5A.5.5 0 0 1 5.5 2h3a.5.5 0 0 1 .5.5V4M11 4v7.5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`)
4756  		dom.SetStyle(delBtn, "cursor", "pointer")
4757  		dom.SetStyle(delBtn, "color", "var(--muted)")
4758  		dom.SetStyle(delBtn, "opacity", "0.5")
4759  		dom.SetStyle(delBtn, "display", "flex")
4760  		dom.SetStyle(delBtn, "alignItems", "center")
4761  		delEvID := ev.ID
4762  		dom.AddEventListener(delBtn, "click", dom.RegisterCallback(func() {
4763  			publishDelete(delEvID)
4764  		}))
4765  		dom.AppendChild(rightGroup, delBtn)
4766  	}
4767  
4768  	rw := relayWidget(ev.ID)
4769  	dom.AppendChild(rightGroup, rw)
4770  	if ev.CreatedAt > 0 {
4771  		tsEl := dom.CreateElement("span")
4772  		dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
4773  		dom.SetStyle(tsEl, "fontSize", "11px")
4774  		dom.SetStyle(tsEl, "color", "var(--muted)")
4775  		dom.SetStyle(tsEl, "cursor", "pointer")
4776  		dom.SetStyle(tsEl, "flexShrink", "0")
4777  		evID := ev.ID
4778  		evRootID := getRootID(ev)
4779  		if evRootID == "" {
4780  			evRootID = evID
4781  		}
4782  		dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
4783  			showNoteThread(evRootID, evID)
4784  		}))
4785  		dom.AppendChild(rightGroup, tsEl)
4786  	}
4787  
4788  	dom.AppendChild(header, rightGroup)
4789  	dom.AppendChild(note, header)
4790  
4791  	// Track author link for update when profile arrives.
4792  	if _, cached := authorNames[pk]; !cached {
4793  		pendingNotes[pk] = append(pendingNotes[pk], authorLink)
4794  		if !lookupFetchedK0(pk) {
4795  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
4796  		}
4797  	}
4798  
4799  	// Reply preview button.
4800  	addReplyPreview(note, ev)
4801  
4802  	// Content.
4803  	content := dom.CreateElement("div")
4804  	dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
4805  	dom.SetStyle(content, "fontSize", "14px")
4806  	dom.SetStyle(content, "lineHeight", "1.5")
4807  	dom.SetStyle(content, "wordBreak", "break-word")
4808  	dom.SetStyle(content, "maxWidth", "65ch")
4809  	dom.SetStyle(content, "overflow", "hidden")
4810  	dom.SetStyle(content, "maxHeight", "18em")
4811  	setHTMLWithMedia(content, renderMarkdown(ev.Content))
4812  	dom.AppendChild(note, content)
4813  
4814  	more := makeShowMore(content)
4815  	dom.AppendChild(note, buildActionRow(ev, note, content, more))
4816  
4817  	// Insert in timestamp order (newest first).
4818  	insertNoteByTs(note, sortTs)
4819  	attachShowMore(content, more)
4820  	resolveEmbeds()
4821  }
4822  
4823  func insertNoteByTs(note dom.Element, ts int64) {
4824  	// Walk children to find insertion point - notes are newest-first.
4825  	child := dom.FirstElementChild(feedContainer)
4826  	for child != 0 {
4827  		childTs := parseI64(dom.GetProperty(child, "smeshTs"))
4828  		if ts > childTs {
4829  			dom.InsertBefore(feedContainer, note, child)
4830  			return
4831  		}
4832  		child = dom.NextSibling(child)
4833  	}
4834  	dom.AppendChild(feedContainer, note)
4835  }
4836  
4837  func parseI64(s string) (n int64) {
4838  	for i := 0; i < len(s); i++ {
4839  		if s[i] >= '0' && s[i] <= '9' {
4840  			n = n*10 + int64(s[i]-'0')
4841  		} else {
4842  			break
4843  		}
4844  	}
4845  	return n
4846  }
4847  
4848  func appendNote(ev *nostr.Event) {
4849  	if noteElements[ev.ID] != 0 {
4850  		return
4851  	}
4852  	note, postInsert := buildNoteElement(ev)
4853  	dom.AppendChild(feedContainer, note)
4854  	postInsert()
4855  	resolveEmbeds()
4856  }
4857  
4858  func buildNoteElement(ev *nostr.Event) (dom.Element, func()) {
4859  	note := dom.CreateElement("div")
4860  	noteElements[ev.ID] = note
4861  	dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
4862  	dom.SetStyle(note, "padding", "12px 0")
4863  	dom.SetProperty(note, "smeshTs", i64toa(ev.CreatedAt))
4864  	dom.SetProperty(note, "smeshPK", ev.PubKey)
4865  
4866  	header := dom.CreateElement("div")
4867  	dom.SetStyle(header, "display", "flex")
4868  	dom.SetStyle(header, "alignItems", "center")
4869  	dom.SetStyle(header, "marginBottom", "4px")
4870  	dom.SetStyle(header, "maxWidth", "65ch")
4871  
4872  	authorLink := dom.CreateElement("div")
4873  	dom.SetStyle(authorLink, "display", "flex")
4874  	dom.SetStyle(authorLink, "alignItems", "center")
4875  	dom.SetStyle(authorLink, "gap", "8px")
4876  	dom.SetStyle(authorLink, "cursor", "pointer")
4877  	headerPK := ev.PubKey
4878  	dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
4879  		showProfile(headerPK)
4880  	}))
4881  
4882  	avatar := dom.CreateElement("img")
4883  	dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
4884  	dom.SetAttribute(avatar, "width", "24")
4885  	dom.SetAttribute(avatar, "height", "24")
4886  	dom.SetStyle(avatar, "borderRadius", "50%")
4887  	dom.SetStyle(avatar, "objectFit", "cover")
4888  	dom.SetStyle(avatar, "flexShrink", "0")
4889  
4890  	nameSpan := dom.CreateElement("span")
4891  	dom.SetStyle(nameSpan, "fontSize", "18px")
4892  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
4893  	dom.SetStyle(nameSpan, "fontWeight", "bold")
4894  	dom.SetStyle(nameSpan, "color", "var(--fg)")
4895  
4896  	pk := ev.PubKey
4897  	if pic, ok := authorPics[pk]; ok && pic != "" {
4898  		setMediaSrc(avatar, pic)
4899  		dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
4900  	} else {
4901  		dom.SetStyle(avatar, "display", "none")
4902  	}
4903  	if name, ok := authorNames[pk]; ok && name != "" {
4904  		dom.SetTextContent(nameSpan, name)
4905  	} else {
4906  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
4907  		if len(npub) > 20 {
4908  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
4909  		}
4910  	}
4911  
4912  	dom.AppendChild(authorLink, avatar)
4913  	dom.AppendChild(authorLink, nameSpan)
4914  	attachHoverCard(authorLink, pk)
4915  	dom.AppendChild(header, authorLink)
4916  
4917  	appendClientTag(header, ev)
4918  
4919  	// JSON toggle: contentEl and moreEl are set below after those elements are
4920  	// created. The click handler fires long after this function returns, so the
4921  	// closure sees the final values.
4922  	var contentEl dom.Element
4923  	var moreEl dom.Element
4924  	jsonActive := false
4925  	var jsonEl dom.Element
4926  
4927  	// Copy button - to the left of </>; marginLeft:auto pushes both to the right.
4928  	cpBtn := noteCopyBtn()
4929  	capturedCpEv := ev
4930  	dom.AddEventListener(cpBtn, "click", dom.RegisterCallback(func() {
4931  		capturedCpBtn := cpBtn
4932  		text := noteClipText(capturedCpEv)
4933  		dom.WritePrimarySelection(text)
4934  		dom.WriteClipboard(text, func(ok bool) {
4935  			if ok {
4936  				noteCopyFeedback(capturedCpBtn)
4937  			}
4938  		})
4939  	}))
4940  	dom.SetStyle(cpBtn, "marginLeft", "auto")
4941  	dom.AppendChild(header, cpBtn)
4942  
4943  	jsonBtn := dom.CreateElement("span")
4944  	dom.SetTextContent(jsonBtn, "</>")
4945  	dom.SetStyle(jsonBtn, "fontSize", "10px")
4946  	dom.SetStyle(jsonBtn, "fontFamily", "'Fira Code', monospace")
4947  	dom.SetStyle(jsonBtn, "cursor", "pointer")
4948  	dom.SetStyle(jsonBtn, "opacity", "0.6")
4949  	dom.SetStyle(jsonBtn, "marginRight", "8px")
4950  	dom.SetStyle(jsonBtn, "userSelect", "none")
4951  	dom.SetStyle(jsonBtn, "color", "var(--muted)")
4952  	capturedJBtn := jsonBtn
4953  	capturedEv := ev
4954  	capturedNote := note
4955  	dom.AddEventListener(jsonBtn, "click", dom.RegisterCallback(func() {
4956  		if jsonActive {
4957  			dom.SetStyle(contentEl, "display", "")
4958  			dom.SetStyle(moreEl, "display", "none")
4959  			dom.SetStyle(jsonEl, "display", "none")
4960  			dom.SetStyle(capturedJBtn, "opacity", "0.6")
4961  			dom.SetStyle(capturedJBtn, "color", "var(--muted)")
4962  			jsonActive = false
4963  		} else {
4964  			if jsonEl == 0 {
4965  				jsonEl = dom.CreateElement("pre")
4966  				dom.SetStyle(jsonEl, "fontFamily", "'Fira Code', monospace")
4967  				dom.SetStyle(jsonEl, "fontSize", "12px")
4968  				dom.SetStyle(jsonEl, "lineHeight", "1.5")
4969  				dom.SetStyle(jsonEl, "wordBreak", "break-word")
4970  				dom.SetStyle(jsonEl, "whiteSpace", "pre-wrap")
4971  				dom.SetStyle(jsonEl, "maxWidth", "65ch")
4972  				dom.SetStyle(jsonEl, "margin", "0")
4973  				dom.SetStyle(jsonEl, "color", "var(--fg)")
4974  				dom.SetTextContent(jsonEl, prettyNoteJSON(capturedEv))
4975  				dom.InsertBefore(capturedNote, jsonEl, moreEl)
4976  			}
4977  			dom.SetStyle(contentEl, "display", "none")
4978  			dom.SetStyle(moreEl, "display", "none")
4979  			dom.SetStyle(jsonEl, "display", "block")
4980  			dom.SetStyle(capturedJBtn, "opacity", "1")
4981  			dom.SetStyle(capturedJBtn, "color", "var(--accent)")
4982  			jsonActive = true
4983  		}
4984  	}))
4985  	dom.AppendChild(header, jsonBtn)
4986  
4987  	rw2 := relayWidget(ev.ID)
4988  	dom.AppendChild(header, rw2)
4989  	if ev.CreatedAt > 0 {
4990  		tsEl := dom.CreateElement("span")
4991  		dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
4992  		dom.SetStyle(tsEl, "fontSize", "11px")
4993  		dom.SetStyle(tsEl, "color", "var(--muted)")
4994  		dom.SetStyle(tsEl, "cursor", "pointer")
4995  		dom.SetStyle(tsEl, "flexShrink", "0")
4996  		evID := ev.ID
4997  		evRootID := getRootID(ev)
4998  		if evRootID == "" {
4999  			evRootID = evID
5000  		}
5001  		dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
5002  			showNoteThread(evRootID, evID)
5003  		}))
5004  		dom.AppendChild(header, tsEl)
5005  	}
5006  
5007  	dom.AppendChild(note, header)
5008  
5009  	if _, cached := authorNames[pk]; !cached {
5010  		pendingNotes[pk] = append(pendingNotes[pk], authorLink)
5011  		if !lookupFetchedK0(pk) {
5012  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
5013  		}
5014  	}
5015  
5016  	addReplyPreview(note, ev)
5017  
5018  	content := dom.CreateElement("div")
5019  	dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
5020  	dom.SetStyle(content, "fontSize", "14px")
5021  	dom.SetStyle(content, "lineHeight", "1.5")
5022  	dom.SetStyle(content, "wordBreak", "break-word")
5023  	dom.SetStyle(content, "maxWidth", "65ch")
5024  	dom.SetStyle(content, "overflow", "hidden")
5025  	dom.SetStyle(content, "maxHeight", "18em")
5026  	setHTMLWithMedia(content, renderMarkdown(ev.Content))
5027  	dom.AppendChild(note, content)
5028  	contentEl = content
5029  
5030  	more := makeShowMore(content)
5031  	moreEl = more
5032  	dom.AppendChild(note, buildActionRow(ev, note, content, more))
5033  
5034  	return note, func() { attachShowMore(content, more) }
5035  }
5036  
5037  var profileSubCounter int32
5038  
5039  // topRelays returns the n most frequently seen relay URLs from kind 10002 events.
5040  // topRelays moved to Profile Worker. Shell no longer has relay frequency data.
5041  // Returns nil so callers fall through to relayURLs + discoveryRelays only.
5042  func topRelays(_ int32) (ss []string) { return nil }
5043  
5044  // recordRelayFreq moved to Profile Worker. Shell no longer tracks relay frequency.
5045  
5046  // discoveryRelays are well-known relays that aggregate profile metadata.
5047  // Prioritized first in _proxy lists since they have the highest hit rate.
5048  var discoveryRelays = []string{
5049  	"wss://purplepag.es",
5050  	"wss://relay.damus.io",
5051  	"wss://nos.lol",
5052  }
5053  
5054  // buildProxy builds a _proxy relay list for a pubkey.
5055  // Discovery relays first, then author-specific relays if known.
5056  // buildProxy builds a relay list for querying a specific author.
5057  // authorRelays and relayFreq now live in Profile Worker; Shell uses
5058  // profileRelaysCache for the relay hints it has received via P_RELAYS.
5059  func buildProxy(pk string) (ss []string) {
5060  	out := []string{:len(discoveryRelays)}
5061  	copy(out, discoveryRelays)
5062  	for _, u := range readRelayURLs() {
5063  		out = appendUnique(out, u)
5064  	}
5065  	if rels, ok := profileRelaysCache[pk]; ok {
5066  		for _, r := range rels {
5067  			out = appendUnique(out, r)
5068  		}
5069  	}
5070  	return out
5071  }
5072  
5073  func appendUnique(list []string, val string) (ss []string) {
5074  	if isBlocked(val) {
5075  		return list
5076  	}
5077  	for _, v := range list {
5078  		if v == val {
5079  			return list
5080  		}
5081  	}
5082  	return append(list, val)
5083  }
5084  
5085  // --- Thread view & reply preview ---
5086  
5087  // getReplyID returns the event ID this note is replying to, or "".
5088  // Checks for NIP-10 "reply" marker first, falls back to positional (last e-tag).
5089  func getReplyID(ev *nostr.Event) (s string) {
5090  	id, _ := getReplyIDHint(ev)
5091  	return id
5092  }
5093  
5094  func getReplyIDHint(ev *nostr.Event) (string, string) {
5095  	var etags []nostr.Tag
5096  	for _, t := range ev.Tags {
5097  		if len(t) >= 2 && t[0] == "e" {
5098  			etags = append(etags, t)
5099  		}
5100  	}
5101  	if len(etags) == 0 {
5102  		return "", ""
5103  	}
5104  	// Marker-based: look for "reply".
5105  	for _, t := range etags {
5106  		if len(t) >= 4 && t[3] == "reply" {
5107  			hint := ""
5108  			if len(t) >= 3 && len(t[2]) > 0 {
5109  				hint = t[2]
5110  			}
5111  			return t[1], hint
5112  		}
5113  	}
5114  	// Positional fallback: last e-tag is the reply target (if >1 e-tags).
5115  	if len(etags) > 1 {
5116  		last := etags[len(etags)-1]
5117  		hint := ""
5118  		if len(last) >= 3 && len(last[2]) > 0 {
5119  			hint = last[2]
5120  		}
5121  		return last[1], hint
5122  	}
5123  	// Single e-tag with no marker: it's both root and reply.
5124  	hint := ""
5125  	if len(etags[0]) >= 3 && len(etags[0][2]) > 0 {
5126  		hint = etags[0][2]
5127  	}
5128  	return etags[0][1], hint
5129  }
5130  
5131  // getRootID returns the thread root event ID, or "".
5132  func getRootID(ev *nostr.Event) (s string) {
5133  	for _, t := range ev.Tags {
5134  		if len(t) >= 4 && t[0] == "e" && t[3] == "root" {
5135  			return t[1]
5136  		}
5137  	}
5138  	// Positional: first e-tag.
5139  	for _, t := range ev.Tags {
5140  		if len(t) >= 2 && t[0] == "e" {
5141  			return t[1]
5142  		}
5143  	}
5144  	return ""
5145  }
5146  
5147  // referencesEvent returns true if ev has any e-tag pointing to the given ID.
5148  func referencesEvent(ev *nostr.Event, id string) (ok bool) {
5149  	for _, t := range ev.Tags {
5150  		if len(t) >= 2 && t[0] == "e" && t[1] == id {
5151  			return true
5152  		}
5153  	}
5154  	return false
5155  }
5156  
5157  func firstLine(s string) (sv string) {
5158  	for i := 0; i < len(s); i++ {
5159  		if s[i] == '\n' {
5160  			if i > 80 {
5161  				return s[:80] | "..."
5162  			}
5163  			return s[:i]
5164  		}
5165  	}
5166  	if len(s) > 80 {
5167  		return s[:80] | "..."
5168  	}
5169  	return s
5170  }
5171  
5172  // appendClientTag adds "via <client>" to a note header if the event has a
5173  // "client" tag. Linkifies http(s) URLs and strips the scheme for display.
5174  func appendClientTag(header dom.Element, ev *nostr.Event) {
5175  	clientTag := ev.Tags.GetFirst("client")
5176  	if clientTag == nil {
5177  		return
5178  	}
5179  	val := clientTag.Value()
5180  	if val == "" {
5181  		return
5182  	}
5183  
5184  	wrap := dom.CreateElement("span")
5185  	dom.SetStyle(wrap, "fontStyle", "italic")
5186  	dom.SetStyle(wrap, "fontSize", "11px")
5187  	dom.SetStyle(wrap, "color", "var(--muted)")
5188  	dom.SetStyle(wrap, "marginLeft", "6px")
5189  	dom.SetStyle(wrap, "flexShrink", "0")
5190  
5191  	via := dom.CreateElement("span")
5192  	dom.SetTextContent(via, "via ")
5193  	dom.AppendChild(wrap, via)
5194  
5195  	display := val
5196  	isURL := false
5197  	if len(val) >= 8 && val[:8] == "https://" {
5198  		isURL = true
5199  		display = val[8:]
5200  	} else if len(val) >= 7 && val[:7] == "http://" {
5201  		isURL = true
5202  		display = val[7:]
5203  	}
5204  
5205  	if isURL {
5206  		link := dom.CreateElement("a")
5207  		dom.SetAttribute(link, "href", val)
5208  		dom.SetAttribute(link, "target", "_blank")
5209  		dom.SetAttribute(link, "rel", "noopener noreferrer")
5210  		dom.SetTextContent(link, display)
5211  		dom.SetStyle(link, "color", "var(--muted)")
5212  		dom.SetStyle(link, "textDecoration", "underline")
5213  		dom.AppendChild(wrap, link)
5214  	} else {
5215  		text := dom.CreateElement("span")
5216  		dom.SetTextContent(text, display)
5217  		dom.AppendChild(wrap, text)
5218  	}
5219  
5220  	dom.AppendChild(header, wrap)
5221  }
5222  
5223  func addReplyPreview(note dom.Element, ev *nostr.Event) {
5224  	parentID, hint := getReplyIDHint(ev)
5225  	if parentID == "" {
5226  		return
5227  	}
5228  	if hint != "" && replyHints != nil {
5229  		replyHints[parentID] = hint
5230  	}
5231  
5232  	preview := dom.CreateElement("div")
5233  	dom.SetStyle(preview, "display", "flex")
5234  	dom.SetStyle(preview, "alignItems", "center")
5235  	dom.SetStyle(preview, "gap", "4px")
5236  	dom.SetStyle(preview, "fontSize", "14px")
5237  	dom.SetStyle(preview, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
5238  	dom.SetStyle(preview, "lineHeight", "1.5")
5239  	dom.SetStyle(preview, "color", "var(--muted)")
5240  	dom.SetStyle(preview, "borderLeft", "2px solid var(--accent)")
5241  	dom.SetStyle(preview, "paddingLeft", "8px")
5242  	dom.SetStyle(preview, "marginBottom", "4px")
5243  	dom.SetStyle(preview, "cursor", "pointer")
5244  	dom.SetStyle(preview, "overflow", "hidden")
5245  	dom.SetStyle(preview, "maxWidth", "65ch")
5246  
5247  	prevAvatar := dom.CreateElement("img")
5248  	dom.SetAttribute(prevAvatar, "referrerpolicy", "no-referrer")
5249  	dom.SetAttribute(prevAvatar, "width", "14")
5250  	dom.SetAttribute(prevAvatar, "height", "14")
5251  	dom.SetStyle(prevAvatar, "borderRadius", "50%")
5252  	dom.SetStyle(prevAvatar, "objectFit", "cover")
5253  	dom.SetStyle(prevAvatar, "flexShrink", "0")
5254  	dom.SetStyle(prevAvatar, "display", "none")
5255  	dom.AppendChild(preview, prevAvatar)
5256  
5257  	prevText := dom.CreateElement("span")
5258  	dom.SetStyle(prevText, "overflow", "hidden")
5259  	dom.SetStyle(prevText, "whiteSpace", "nowrap")
5260  	dom.SetStyle(prevText, "textOverflow", "ellipsis")
5261  	dom.AppendChild(preview, prevText)
5262  
5263  	text, cached := replyCache[parentID]
5264  	if !cached {
5265  		text, cached = replyCacheOld[parentID]
5266  	}
5267  	if cached {
5268  		dom.SetTextContent(prevText, text)
5269  		pic, _ := replyAvatarCache[parentID]
5270  		if pic == "" {
5271  			pic, _ = replyAvatarCacheOld[parentID]
5272  		}
5273  		if pic != "" {
5274  			setMediaSrc(prevAvatar, pic)
5275  			dom.SetAttribute(prevAvatar, "onerror", "this.style.display='none'")
5276  			dom.SetStyle(prevAvatar, "display", "block")
5277  		}
5278  	} else {
5279  		dom.SetTextContent(prevText, t("replying_to"))
5280  		replyPending[parentID] = append(replyPending[parentID], preview)
5281  		queueReplyFetch(parentID)
5282  	}
5283  
5284  	rootID := getRootID(ev)
5285  	if rootID == "" {
5286  		rootID = parentID
5287  	}
5288  	focusID := parentID
5289  	dom.AddEventListener(preview, "click", dom.RegisterCallback(func() {
5290  		showNoteThread(rootID, focusID)
5291  	}))
5292  
5293  	dom.AppendChild(note, preview)
5294  }
5295  
5296  func queueReplyFetch(id string) {
5297  	replyQueue = append(replyQueue, id)
5298  	if replyTimer != 0 {
5299  		dom.ClearTimeout(replyTimer)
5300  	}
5301  	replyTimer = dom.SetTimeout(func() {
5302  		replyTimer = 0
5303  		flushReplyQueue()
5304  	}, 300)
5305  }
5306  
5307  func flushReplyQueue() {
5308  	if len(replyQueue) == 0 {
5309  		return
5310  	}
5311  	filter := "{\"ids\":["
5312  	var hints []string
5313  	for i, id := range replyQueue {
5314  		if i > 0 {
5315  			filter = filter | ","
5316  		}
5317  		filter = filter | jstr(id)
5318  		if h := replyHints[id]; h != "" {
5319  			hints = append(hints, h)
5320  			delete(replyHints, id)
5321  		} else if h := replyHintsOld[id]; h != "" {
5322  			hints = append(hints, h)
5323  			delete(replyHintsOld, id)
5324  		}
5325  	}
5326  	filter = filter | "]}"
5327  	replyQueue = nil
5328  
5329  	// Local REQ checks IDB cache first.
5330  	threadSubCount++
5331  	reqID := "rp-" | itoa(threadSubCount)
5332  	routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]")
5333  
5334  	// PROXY fetches from remote relays - use hints + discovery + feed + top relays.
5335  	var urls []string
5336  	seen := map[string]bool{}
5337  	for _, h := range hints {
5338  		if !seen[h] {
5339  			seen[h] = true
5340  			urls = append(urls, h)
5341  		}
5342  	}
5343  	for _, u := range discoveryRelays {
5344  		if !seen[u] {
5345  			seen[u] = true
5346  			urls = append(urls, u)
5347  		}
5348  	}
5349  	for _, u := range relayURLs {
5350  		if !seen[u] {
5351  			seen[u] = true
5352  			urls = append(urls, u)
5353  		}
5354  	}
5355  	for _, u := range topRelays(8) {
5356  		if !seen[u] {
5357  			seen[u] = true
5358  			urls = append(urls, u)
5359  		}
5360  	}
5361  	threadSubCount++
5362  	proxyID := "rp-" | itoa(threadSubCount)
5363  	routeMsg(buildProxyMsg(proxyID, filter, urls))
5364  
5365  	// Reset sweep timer: 20s after last batch sent, mark remaining as not found.
5366  	if replySweepTimer != 0 {
5367  		dom.ClearTimeout(replySweepTimer)
5368  	}
5369  	replySweepTimer = dom.SetTimeout(func() {
5370  		replySweepTimer = 0
5371  		sweepUnfoundReplies()
5372  	}, 20000)
5373  }
5374  
5375  func handleReplyPreviewEvent(ev *nostr.Event) {
5376  	line := firstLine(ev.Content)
5377  	replyLineCache[ev.ID] = line
5378  	replyAuthorMap[ev.ID] = ev.PubKey
5379  
5380  	name := ev.PubKey[:8] | "..."
5381  	nameResolved := false
5382  	if n, ok := authorNames[ev.PubKey]; ok && n != "" {
5383  		name = n
5384  		nameResolved = true
5385  	}
5386  	text := name | ": " | line
5387  	replyCache[ev.ID] = text
5388  	replyCount++
5389  	if replyCount > rotateReplyMax {
5390  		rotateReplyCaches()
5391  	}
5392  
5393  	pic := ""
5394  	if p, ok := authorPics[ev.PubKey]; ok && p != "" {
5395  		pic = p
5396  	}
5397  	replyAvatarCache[ev.ID] = pic
5398  
5399  	// Trigger profile fetch if not cached.
5400  	if !nameResolved {
5401  		if !lookupFetchedK0(ev.PubKey) {
5402  			profile.Send(`["P_RESOLVE",` | jstr(ev.PubKey) | `]`)
5403  		}
5404  	}
5405  
5406  	if divs, ok := replyPending[ev.ID]; ok {
5407  		for _, d := range divs {
5408  			fillReplyPreviewDiv(d, text, pic)
5409  		}
5410  		// Track for late name update if unresolved.
5411  		if !nameResolved {
5412  			replyNeedName[ev.ID] = replyNeedName[ev.ID] | divs
5413  		}
5414  		delete(replyPending, ev.ID)
5415  	}
5416  }
5417  
5418  func fillReplyPreviewDiv(d dom.Element, text, pic string) {
5419  	img := dom.FirstChild(d)
5420  	if img == 0 {
5421  		return
5422  	}
5423  	span := dom.NextSibling(img)
5424  	if span != 0 {
5425  		dom.SetTextContent(span, text)
5426  	}
5427  	if pic != "" {
5428  		setMediaSrc(img, pic)
5429  		dom.SetAttribute(img, "onerror", "this.style.display='none'")
5430  		dom.SetStyle(img, "display", "block")
5431  	}
5432  }
5433  
5434  func sweepUnfoundReplies() {
5435  	if len(replyPending) == 0 {
5436  		return
5437  	}
5438  	notFound := t("reply_not_found")
5439  	for id, divs := range replyPending {
5440  		for _, d := range divs {
5441  			span := dom.NextSibling(dom.FirstChild(d))
5442  			if span != 0 {
5443  				dom.SetTextContent(span, notFound)
5444  			}
5445  		}
5446  		delete(replyPending, id)
5447  	}
5448  }
5449  
5450  // updateReplyPreviewsForAuthor is called when a kind 0 profile arrives.
5451  // It updates any reply preview divs that were rendered with a hex pubkey stub.
5452  func updateReplyPreviewsForAuthor(pk string) {
5453  	name, _ := authorNames[pk]
5454  	pic, _ := authorPics[pk]
5455  	if name == "" {
5456  		return
5457  	}
5458  
5459  	for eid, apk := range replyAuthorMap {
5460  		if apk != pk {
5461  			continue
5462  		}
5463  		line := replyLineCache[eid]
5464  		text := name | ": " | line
5465  		replyCache[eid] = text
5466  		replyAvatarCache[eid] = pic
5467  
5468  		if divs, ok := replyNeedName[eid]; ok {
5469  			for _, d := range divs {
5470  				fillReplyPreviewDiv(d, text, pic)
5471  			}
5472  			delete(replyNeedName, eid)
5473  		}
5474  	}
5475  }
5476  
5477  func showNoteThread(rootID, focusID string) {
5478  	// Close old thread subs immediately to prevent stale events leaking in.
5479  	for _, sid := range threadActiveSubs {
5480  		routeMsg("[\"CLOSE\"," | jstr(sid) | "]")
5481  	}
5482  	threadActiveSubs = nil
5483  	threadGen++
5484  
5485  	threadRootID = rootID
5486  	threadFocusID = focusID
5487  	threadEvents = map[string]*nostr.Event{}
5488  	threadLastRendered = 0
5489  	threadOpen = true
5490  	threadFollowUp = 0
5491  	threadFetchedIDs = map[string]bool{rootID: true}
5492  	threadReturnPage = activePage
5493  
5494  	// Save scroll position.
5495  	savedScrollTop = dom.GetProperty(contentArea, "scrollTop")
5496  
5497  	// Deactivate search bar so top bar returns to normal while in thread.
5498  	if searchBarActive {
5499  		deactivateSearchBar(searchBtnGlobal)
5500  	}
5501  	// Stop any inline media (Invidious iframes) playing in the feed.
5502  	stopInlineMedia(feedContainer)
5503  
5504  	// Ensure feedPage is visible - threadPage lives inside it.
5505  	if activePage != "feed" {
5506  		dom.SetStyle(profilePage, "display", "none")
5507  		dom.SetStyle(msgPage, "display", "none")
5508  		dom.SetStyle(settingsPage, "display", "none")
5509  		dom.SetStyle(notifPage, "display", "none")
5510  		dom.SetStyle(aboutPage, "display", "none")
5511  		dom.SetStyle(composePage, "display", "none")
5512  		dom.SetStyle(searchPage, "display", "none")
5513  		dom.SetStyle(feedPage, "display", "block")
5514  	}
5515  
5516  	// Defocus all sidebar buttons - thread view is its own context.
5517  	dom.SetStyle(sidebarFeed, "background", "transparent")
5518  	dom.SetStyle(sidebarFeed, "color", "var(--fg)")
5519  	dom.SetStyle(sidebarCompose, "background", "transparent")
5520  	dom.SetStyle(sidebarCompose, "color", "var(--fg)")
5521  	dom.SetStyle(sidebarMsg, "background", "transparent")
5522  	dom.SetStyle(sidebarMsg, "color", "var(--fg)")
5523  	dom.SetStyle(sidebarNotif, "background", "transparent")
5524  	dom.SetStyle(sidebarNotif, "color", "var(--fg)")
5525  	dom.SetStyle(sidebarSettings, "background", "transparent")
5526  	dom.SetStyle(sidebarSettings, "color", "var(--fg)")
5527  
5528  	// Switch UI.
5529  	dom.SetStyle(feedContainer, "display", "none")
5530  	dom.SetStyle(loadMoreBtn, "display", "none")
5531  	dom.SetStyle(threadPage, "display", "block")
5532  	dom.SetProperty(contentArea, "scrollTop", "0")
5533  
5534  	// Show back button in top bar, hide everything else.
5535  	closeFeedPopover()
5536  	dom.SetStyle(topBackBtn, "display", "inline")
5537  	dom.SetStyle(pageTitleEl, "display", "none")
5538  	dom.SetStyle(feedSelectEl, "display", "none")
5539  	dom.SetStyle(notifBarGroup, "display", "none")
5540  
5541  	if !navPop {
5542  		url := "/t/" | rootID
5543  		if focusID != "" && focusID != rootID {
5544  			url = url | "#" | focusID
5545  		}
5546  		dom.PushState(url)
5547  		threadPushedState = true
5548  	} else {
5549  		threadPushedState = false
5550  	}
5551  
5552  	clearChildren(threadContainer)
5553  
5554  	// Loading indicator.
5555  	loading := dom.CreateElement("div")
5556  	dom.SetTextContent(loading, t("loading_thread"))
5557  	dom.SetStyle(loading, "color", "var(--muted)")
5558  	dom.SetStyle(loading, "fontSize", "14px")
5559  	dom.SetStyle(loading, "padding", "16px 0")
5560  	dom.AppendChild(threadContainer, loading)
5561  
5562  	// Build wide relay set for thread fetch.
5563  	var thrRelays []string
5564  	thrSeen := map[string]bool{}
5565  	for _, u := range relayURLs {
5566  		if !thrSeen[u] {
5567  			thrSeen[u] = true
5568  			thrRelays = append(thrRelays, u)
5569  		}
5570  	}
5571  	for _, u := range topRelays(8) {
5572  		if !thrSeen[u] {
5573  			thrSeen[u] = true
5574  			thrRelays = append(thrRelays, u)
5575  		}
5576  	}
5577  
5578  	threadRelays = thrRelays
5579  
5580  	// Local REQ for cached events.
5581  	threadSubCount++
5582  	s1 := "thr-" | itoa(threadSubCount)
5583  	threadActiveSubs = append(threadActiveSubs, s1)
5584  	routeMsg("[\"REQ\"," | jstr(s1) | ",{\"ids\":[" | jstr(rootID) | "]}]")
5585  
5586  	threadSubCount++
5587  	s2 := "thr-" | itoa(threadSubCount)
5588  	threadActiveSubs = append(threadActiveSubs, s2)
5589  	routeMsg("[\"REQ\"," | jstr(s2) | ",{\"#e\":[" | jstr(rootID) | "],\"kinds\":[1]}]")
5590  
5591  	// PROXY for remote relays.
5592  	threadSubCount++
5593  	s3 := "thr-" | itoa(threadSubCount)
5594  	threadActiveSubs = append(threadActiveSubs, s3)
5595  	routeMsg(buildProxyMsg(s3, "{\"ids\":[" | jstr(rootID) | "]}", thrRelays))
5596  
5597  	threadSubCount++
5598  	s4 := "thr-" | itoa(threadSubCount)
5599  	threadActiveSubs = append(threadActiveSubs, s4)
5600  	routeMsg(buildProxyMsg(s4, "{\"#e\":[" | jstr(rootID) | "],\"kinds\":[1]}", thrRelays))
5601  
5602  	// Also fetch the focus event directly if different from root.
5603  	if focusID != rootID {
5604  		threadSubCount++
5605  		s5 := "thr-" | itoa(threadSubCount)
5606  		threadActiveSubs = append(threadActiveSubs, s5)
5607  		routeMsg(buildProxyMsg(s5, "{\"ids\":[" | jstr(focusID) | "]}", thrRelays))
5608  	}
5609  }
5610  
5611  func closeNoteThread() {
5612  	// Close active subs.
5613  	for _, sid := range threadActiveSubs {
5614  		routeMsg("[\"CLOSE\"," | jstr(sid) | "]")
5615  	}
5616  	threadActiveSubs = nil
5617  
5618  	threadOpen = false
5619  	threadRootID = ""
5620  	threadFocusID = ""
5621  	threadRelays = nil
5622  	threadFetchedIDs = nil
5623  	stopInlineMedia(threadContainer)
5624  	dom.SetStyle(threadPage, "display", "none")
5625  
5626  	// Always restore feedContainer - showNoteThread hides it.
5627  	dom.SetStyle(feedContainer, "display", "block")
5628  	dom.SetStyle(topBackBtn, "display", "none")
5629  
5630  	// Return to the page we came from.
5631  	ret := threadReturnPage
5632  	threadReturnPage = ""
5633  	if ret != "" && ret != "feed" {
5634  		// Force activePage to "" so switchPage doesn't early-return.
5635  		activePage = ""
5636  		switchPage(ret)
5637  		// If returning to search, re-activate the bar with the current query
5638  		// so the user sees the input and can go back or search again.
5639  		if ret == "search" && searchBtnGlobal != 0 && !searchBarActive {
5640  			activateSearchBar(searchBtnGlobal)
5641  			if searchCurrentQ != "" {
5642  				dom.SetProperty(searchInputEl, "value", searchCurrentQ)
5643  			}
5644  		}
5645  	} else {
5646  		dom.SetStyle(feedSelectEl, "display", "inline")
5647  		if !feedExhausted && oldestFeedTs > 0 {
5648  			dom.SetStyle(loadMoreBtn, "display", "block")
5649  		}
5650  	}
5651  
5652  	// Restore scroll position.
5653  	if savedScrollTop != "" {
5654  		dom.SetProperty(contentArea, "scrollTop", savedScrollTop)
5655  		savedScrollTop = ""
5656  	}
5657  }
5658  
5659  func handleThreadEvent(gen int32, ev *nostr.Event) {
5660  	if gen != threadGen {
5661  		return // stale event from a previous thread
5662  	}
5663  	threadEvents[ev.ID] = ev
5664  	// Debounced render - 200ms after last event, show what we have.
5665  	if threadRenderTimer != 0 {
5666  		dom.ClearTimeout(threadRenderTimer)
5667  	}
5668  	threadRenderTimer = dom.SetTimeout(func() {
5669  		threadRenderTimer = 0
5670  		if threadOpen {
5671  			renderThreadTree()
5672  		}
5673  	}, 200)
5674  }
5675  
5676  func handleThreadEOSE() {
5677  	// Final render pass.
5678  	if threadRenderTimer != 0 {
5679  		dom.ClearTimeout(threadRenderTimer)
5680  		threadRenderTimer = 0
5681  	}
5682  	renderThreadTree()
5683  	threadFetchReplies()
5684  }
5685  
5686  func threadFetchReplies() {
5687  	if threadFollowUp >= 3 || !threadOpen {
5688  		return
5689  	}
5690  	// Collect event IDs not yet queried for children.
5691  	var ids []string
5692  	for id := range threadEvents {
5693  		if !threadFetchedIDs[id] {
5694  			ids = append(ids, id)
5695  			threadFetchedIDs[id] = true
5696  		}
5697  	}
5698  	if len(ids) == 0 {
5699  		return
5700  	}
5701  	threadFollowUp++
5702  
5703  	// Build JSON array of IDs for the #e filter.
5704  	eArr := ""
5705  	for i, id := range ids {
5706  		if i > 0 {
5707  			eArr = eArr | ","
5708  		}
5709  		eArr = eArr | jstr(id)
5710  	}
5711  	filter := "{\"#e\":[" | eArr | "],\"kinds\":[1]}"
5712  
5713  	// Local REQ.
5714  	threadSubCount++
5715  	s1 := "thr-" | itoa(threadSubCount)
5716  	threadActiveSubs = append(threadActiveSubs, s1)
5717  	routeMsg("[\"REQ\"," | jstr(s1) | "," | filter | "]")
5718  
5719  	// PROXY to remote relays.
5720  	if len(threadRelays) > 0 {
5721  		threadSubCount++
5722  		s2 := "thr-" | itoa(threadSubCount)
5723  		threadActiveSubs = append(threadActiveSubs, s2)
5724  		routeMsg(buildProxyMsg(s2, filter, threadRelays))
5725  	}
5726  }
5727  
5728  func renderThreadTree() {
5729  	n := len(threadEvents)
5730  	if n == threadLastRendered {
5731  		return // no new events since last render
5732  	}
5733  	threadLastRendered = n
5734  
5735  	clearChildren(threadContainer)
5736  
5737  	if n == 0 {
5738  		empty := dom.CreateElement("div")
5739  		dom.SetTextContent(empty, t("thread_empty"))
5740  		dom.SetStyle(empty, "color", "var(--muted)")
5741  		dom.SetStyle(empty, "padding", "16px 0")
5742  		dom.AppendChild(threadContainer, empty)
5743  		return
5744  	}
5745  
5746  	// Build parent->children map. If direct parent isn't in the thread,
5747  	// re-parent under root (the event is in-thread if it references root).
5748  	children := map[string][]string{}
5749  	for id, ev := range threadEvents {
5750  		parentID := getReplyID(ev)
5751  		if parentID == "" || parentID == id {
5752  			continue
5753  		}
5754  		if threadEvents[parentID] == nil && parentID != threadRootID {
5755  			// Direct parent not fetched - attach to root if event belongs to thread.
5756  			if referencesEvent(ev, threadRootID) {
5757  				parentID = threadRootID
5758  			}
5759  		}
5760  		children[parentID] = append(children[parentID], id)
5761  	}
5762  
5763  	// Sort children chronologically.
5764  	for pid := range children {
5765  		ids := children[pid]
5766  		sortByCreatedAt(ids)
5767  		children[pid] = ids
5768  	}
5769  
5770  	// Find root - event with no parent in this thread, or threadRootID.
5771  	rootID := threadRootID
5772  	if _, ok := threadEvents[rootID]; !ok {
5773  		// Root not fetched; find event with no in-thread parent.
5774  		for id, ev := range threadEvents {
5775  			parentID := getReplyID(ev)
5776  			if parentID == "" || threadEvents[parentID] == nil {
5777  				rootID = id
5778  				break
5779  			}
5780  		}
5781  	}
5782  
5783  	// Render tree via DFS, tracking what was rendered.
5784  	rendered := map[string]bool{}
5785  	var renderAt func(id string, depth int32)
5786  	renderAt = func(id string, depth int32) {
5787  		ev, ok := threadEvents[id]
5788  		if !ok {
5789  			return
5790  		}
5791  		rendered[id] = true
5792  		if isMuted(ev.PubKey) || repliesToMuted(ev) {
5793  			return // skip muted user and entire sub-branch
5794  		}
5795  		if looksLikeJSONSpam(ev.Content) {
5796  			return
5797  		}
5798  		renderThreadNote(ev, depth, id == threadFocusID)
5799  		for _, childID := range children[id] {
5800  			renderAt(childID, depth+1)
5801  		}
5802  	}
5803  
5804  	// If root is present, start there. Otherwise render all as flat.
5805  	if _, ok := threadEvents[rootID]; ok {
5806  		renderAt(rootID, 0)
5807  		// Render any remaining orphans (events not reached by DFS).
5808  		for id := range threadEvents {
5809  			if !rendered[id] {
5810  				renderAt(id, 1)
5811  			}
5812  		}
5813  	} else {
5814  		for id := range threadEvents {
5815  			renderAt(id, 0)
5816  		}
5817  	}
5818  
5819  	// Scroll focused note into view (centered vertically).
5820  	if threadFocusID != "" {
5821  		dom.SetTimeout(func() {
5822  			el := dom.GetElementById("thread-focus")
5823  			if el != 0 {
5824  				top := parseIntProp(dom.GetProperty(el, "offsetTop"))
5825  				vh := parseIntProp(dom.GetProperty(contentArea, "clientHeight"))
5826  				scroll := top - vh/2
5827  				if scroll < 0 {
5828  					scroll = 0
5829  				}
5830  				dom.SetProperty(contentArea, "scrollTop", itoa(scroll))
5831  			}
5832  		}, 50)
5833  	}
5834  }
5835  
5836  func sortByCreatedAt(ids []string) {
5837  	for i := 0; i < len(ids); i++ {
5838  		for j := i + 1; j < len(ids); j++ {
5839  			ei := threadEvents[ids[i]]
5840  			ej := threadEvents[ids[j]]
5841  			if ei != nil && ej != nil && ei.CreatedAt > ej.CreatedAt {
5842  				ids[i], ids[j] = ids[j], ids[i]
5843  			}
5844  		}
5845  	}
5846  }
5847  
5848  func renderThreadNote(ev *nostr.Event, depth int32, focused bool) {
5849  	note := dom.CreateElement("div")
5850  	dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
5851  	dom.SetStyle(note, "padding", "8px 0")
5852  	dom.SetStyle(note, "marginLeft", itoa(depth*8) | "px")
5853  
5854  	if focused {
5855  		dom.SetStyle(note, "borderLeft", "3px solid var(--accent)")
5856  		dom.SetStyle(note, "paddingLeft", "8px")
5857  		dom.SetStyle(note, "background", "var(--bg2)")
5858  		dom.SetStyle(note, "borderRadius", "4px")
5859  		dom.SetAttribute(note, "id", "thread-focus")
5860  	}
5861  
5862  	// Header.
5863  	header := dom.CreateElement("div")
5864  	dom.SetStyle(header, "display", "flex")
5865  	dom.SetStyle(header, "alignItems", "center")
5866  	dom.SetStyle(header, "marginBottom", "4px")
5867  	dom.SetStyle(header, "maxWidth", "65ch")
5868  
5869  	authorLink := dom.CreateElement("div")
5870  	dom.SetStyle(authorLink, "display", "flex")
5871  	dom.SetStyle(authorLink, "alignItems", "center")
5872  	dom.SetStyle(authorLink, "gap", "6px")
5873  	dom.SetStyle(authorLink, "cursor", "pointer")
5874  	headerPK := ev.PubKey
5875  	dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
5876  		showProfile(headerPK)
5877  	}))
5878  
5879  	avatar := dom.CreateElement("img")
5880  	dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
5881  	dom.SetAttribute(avatar, "width", "20")
5882  	dom.SetAttribute(avatar, "height", "20")
5883  	dom.SetStyle(avatar, "borderRadius", "50%")
5884  	dom.SetStyle(avatar, "objectFit", "cover")
5885  	dom.SetStyle(avatar, "flexShrink", "0")
5886  
5887  	nameSpan := dom.CreateElement("span")
5888  	dom.SetStyle(nameSpan, "fontSize", "14px")
5889  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
5890  	dom.SetStyle(nameSpan, "fontWeight", "bold")
5891  	dom.SetStyle(nameSpan, "color", "var(--fg)")
5892  
5893  	pk := ev.PubKey
5894  	if pic, ok := authorPics[pk]; ok && pic != "" {
5895  		setMediaSrc(avatar, pic)
5896  		dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
5897  	} else {
5898  		dom.SetStyle(avatar, "display", "none")
5899  	}
5900  	if name, ok := authorNames[pk]; ok && name != "" {
5901  		dom.SetTextContent(nameSpan, name)
5902  	} else {
5903  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
5904  		if len(npub) > 20 {
5905  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
5906  		}
5907  	}
5908  
5909  	dom.AppendChild(authorLink, avatar)
5910  	dom.AppendChild(authorLink, nameSpan)
5911  	attachHoverCard(authorLink, pk)
5912  	dom.AppendChild(header, authorLink)
5913  
5914  	appendClientTag(header, ev)
5915  
5916  	// Copy button - copies this note and all replies beneath it.
5917  	tCpBtn := noteCopyBtn()
5918  	capturedTCpEv := ev
5919  	capturedTCpBtn := tCpBtn
5920  	dom.AddEventListener(tCpBtn, "click", dom.RegisterCallback(func() {
5921  		evs := collectBranch(capturedTCpEv.ID)
5922  		text := ""
5923  		for _, e := range evs {
5924  			text = text | noteClipText(e)
5925  		}
5926  		dom.WritePrimarySelection(text)
5927  		dom.WriteClipboard(text, func(ok bool) {
5928  			if ok {
5929  				noteCopyFeedback(capturedTCpBtn)
5930  			}
5931  		})
5932  	}))
5933  	dom.SetStyle(tCpBtn, "marginLeft", "auto")
5934  	dom.AppendChild(header, tCpBtn)
5935  
5936  	// JSON toggle </>
5937  	var tContentEl dom.Element
5938  	var tMoreEl dom.Element
5939  	tJsonActive := false
5940  	var tJsonEl dom.Element
5941  	tJsonBtn := dom.CreateElement("span")
5942  	dom.SetTextContent(tJsonBtn, "</>")
5943  	dom.SetStyle(tJsonBtn, "fontSize", "10px")
5944  	dom.SetStyle(tJsonBtn, "fontFamily", "'Fira Code', monospace")
5945  	dom.SetStyle(tJsonBtn, "cursor", "pointer")
5946  	dom.SetStyle(tJsonBtn, "opacity", "0.6")
5947  	dom.SetStyle(tJsonBtn, "marginRight", "8px")
5948  	dom.SetStyle(tJsonBtn, "userSelect", "none")
5949  	dom.SetStyle(tJsonBtn, "color", "var(--muted)")
5950  	capturedTJBtn := tJsonBtn
5951  	capturedTEv := ev
5952  	capturedTNote := note
5953  	dom.AddEventListener(tJsonBtn, "click", dom.RegisterCallback(func() {
5954  		if tJsonActive {
5955  			dom.SetStyle(tContentEl, "display", "")
5956  			dom.SetStyle(tMoreEl, "display", "none")
5957  			dom.SetStyle(tJsonEl, "display", "none")
5958  			dom.SetStyle(capturedTJBtn, "opacity", "0.6")
5959  			dom.SetStyle(capturedTJBtn, "color", "var(--muted)")
5960  			tJsonActive = false
5961  		} else {
5962  			if tJsonEl == 0 {
5963  				tJsonEl = dom.CreateElement("pre")
5964  				dom.SetStyle(tJsonEl, "fontFamily", "'Fira Code', monospace")
5965  				dom.SetStyle(tJsonEl, "fontSize", "12px")
5966  				dom.SetStyle(tJsonEl, "lineHeight", "1.5")
5967  				dom.SetStyle(tJsonEl, "wordBreak", "break-word")
5968  				dom.SetStyle(tJsonEl, "whiteSpace", "pre-wrap")
5969  				dom.SetStyle(tJsonEl, "maxWidth", "65ch")
5970  				dom.SetStyle(tJsonEl, "margin", "0")
5971  				dom.SetStyle(tJsonEl, "color", "var(--fg)")
5972  				dom.SetTextContent(tJsonEl, prettyNoteJSON(capturedTEv))
5973  				dom.InsertBefore(capturedTNote, tJsonEl, tMoreEl)
5974  			}
5975  			dom.SetStyle(tContentEl, "display", "none")
5976  			dom.SetStyle(tMoreEl, "display", "none")
5977  			dom.SetStyle(tJsonEl, "display", "block")
5978  			dom.SetStyle(capturedTJBtn, "opacity", "1")
5979  			dom.SetStyle(capturedTJBtn, "color", "var(--accent)")
5980  			tJsonActive = true
5981  		}
5982  	}))
5983  	dom.AppendChild(header, tJsonBtn)
5984  
5985  	// Relay widget + timestamp.
5986  	rw3 := relayWidget(ev.ID)
5987  	dom.AppendChild(header, rw3)
5988  	if ev.CreatedAt > 0 {
5989  		tsEl := dom.CreateElement("span")
5990  		dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
5991  		dom.SetStyle(tsEl, "fontSize", "11px")
5992  		dom.SetStyle(tsEl, "color", "var(--muted)")
5993  		dom.AppendChild(header, tsEl)
5994  	}
5995  
5996  	dom.AppendChild(note, header)
5997  
5998  	if _, cached := authorNames[pk]; !cached {
5999  		pendingNotes[pk] = append(pendingNotes[pk], authorLink)
6000  		if !lookupFetchedK0(pk) {
6001  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
6002  		}
6003  	}
6004  
6005  	// Content.
6006  	content := dom.CreateElement("div")
6007  	dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6008  	dom.SetStyle(content, "fontSize", "13px")
6009  	dom.SetStyle(content, "lineHeight", "1.5")
6010  	dom.SetStyle(content, "wordBreak", "break-word")
6011  	dom.SetStyle(content, "maxWidth", "65ch")
6012  	setHTMLWithMedia(content, renderMarkdown(ev.Content))
6013  	dom.AppendChild(note, content)
6014  	tContentEl = content
6015  
6016  	more := makeShowMore(content)
6017  	tMoreEl = more
6018  	dom.AppendChild(note, buildActionRow(ev, note, content, more))
6019  
6020  	dom.AppendChild(threadContainer, note)
6021  	resolveEmbeds()
6022  }
6023  
6024  // fetchAuthorProfile fetches kind 0 + kind 10002 for an author via SW PROXY.
6025  // Still used internally; call sites use profile.Send(P_RESOLVE_FORCE) instead.
6026  func fetchAuthorProfile(pk string) {
6027  	if lookupFetchedK0(pk) {
6028  		return
6029  	}
6030  	setFetchedK0(pk, true)
6031  
6032  	profileSubCounter++
6033  	subID := "ap-" | itoa(profileSubCounter)
6034  	authorSubPK[subID] = pk
6035  
6036  	proxyRelays := buildProxy(pk)
6037  	routeMsg(buildProxyMsg(subID,
6038  		"{\"authors\":[" | jstr(pk) | "],\"kinds\":[0,3,10002,10000],\"limit\":6}",
6039  		proxyRelays))
6040  }
6041  
6042  // queueProfileFetch is now a stub; call sites use profile.Send instead.
6043  func queueProfileFetch(pk string) {
6044  	if lookupFetchedK0(pk) {
6045  		return
6046  	}
6047  	setFetchedK0(pk, true)
6048  	fetchQueue = append(fetchQueue, pk)
6049  	if fetchTimer != 0 {
6050  		dom.ClearTimeout(fetchTimer)
6051  	}
6052  	fetchTimer = dom.SetTimeout(func() {
6053  		fetchTimer = 0
6054  		flushFetchQueue()
6055  	}, 300)
6056  }
6057  
6058  // flushFetchQueue sends all queued pubkeys as chunked batch PROXY requests.
6059  func flushFetchQueue() {
6060  	if len(fetchQueue) == 0 {
6061  		return
6062  	}
6063  	queue := fetchQueue
6064  	fetchQueue = nil
6065  
6066  	proxy := []string{:len(discoveryRelays)}
6067  	copy(proxy, discoveryRelays)
6068  	for _, u := range relayURLs {
6069  		proxy = appendUnique(proxy, u)
6070  	}
6071  	for _, pk := range queue {
6072  		if rels, ok := profileRelaysCache[pk]; ok {
6073  			for _, r := range rels {
6074  				proxy = appendUnique(proxy, r)
6075  			}
6076  		}
6077  	}
6078  	top := topRelays(4)
6079  	for _, r := range top {
6080  		proxy = appendUnique(proxy, r)
6081  	}
6082  
6083  	const batchSize = 100
6084  	for i := 0; i < len(queue); i += batchSize {
6085  		end := i + batchSize
6086  		if end > len(queue) {
6087  			end = len(queue)
6088  		}
6089  		chunk := queue[i:end]
6090  		authors := "["
6091  		for j, pk := range chunk {
6092  			if j > 0 {
6093  				authors = authors | ","
6094  			}
6095  			authors = authors | jstr(pk)
6096  		}
6097  		authors = authors | "]"
6098  		profileSubCounter++
6099  		subID := "ap-batch-q-" | itoa(profileSubCounter)
6100  		routeMsg(buildProxyMsg(subID,
6101  			"{\"authors\":" | authors | ",\"kinds\":[0],\"limit\":" | itoa(len(chunk)) | "}",
6102  			proxy))
6103  		// Also query feed relays directly - they have the kind 1 notes
6104  		// so they almost certainly have kind 0 for the same authors.
6105  		// Uses the SW's existing WebSocket connections, bypasses server proxy.
6106  		profileSubCounter++
6107  		routeMsg(buildProxyMsg("ap-d-" | itoa(profileSubCounter),
6108  			"{\"authors\":" | authors | ",\"kinds\":[0],\"limit\":" | itoa(len(chunk)) | "}",
6109  			relayURLs))
6110  	}
6111  }
6112  
6113  // retryMissingProfiles batches pubkeys that still lack a name into chunked
6114  // PROXY requests through the orly relay. Fetches all metadata kinds so
6115  // relay lists from kind 10002 enable second-hop discovery.
6116  func retryMissingProfiles() {
6117  	var missing []string
6118  	for pk := range pendingNotes {
6119  		if _, ok := authorNames[pk]; !ok {
6120  			missing = append(missing, pk)
6121  		}
6122  	}
6123  	if len(missing) == 0 {
6124  		return
6125  	}
6126  
6127  	// Reset fetchedK0 for still-missing profiles so individual re-fetches
6128  	// can fire if new relay info appears from other profiles' kind 10002.
6129  	for _, pk := range missing {
6130  		setFetchedK0(pk, false)
6131  	}
6132  
6133  	// Discovery relays first, then user relays + discovered relays.
6134  	proxy := []string{:len(discoveryRelays)}
6135  	copy(proxy, discoveryRelays)
6136  	for _, u := range relayURLs {
6137  		proxy = appendUnique(proxy, u)
6138  	}
6139  	top := topRelays(8)
6140  	for _, u := range top {
6141  		proxy = appendUnique(proxy, u)
6142  	}
6143  
6144  	const batchSize = 100
6145  	batchNum := 0
6146  	for i := 0; i < len(missing); i += batchSize {
6147  		end := i + batchSize
6148  		if end > len(missing) {
6149  			end = len(missing)
6150  		}
6151  		chunk := missing[i:end]
6152  		authors := "["
6153  		for j, pk := range chunk {
6154  			if j > 0 {
6155  				authors = authors | ","
6156  			}
6157  			authors = authors | jstr(pk)
6158  		}
6159  		authors = authors | "]"
6160  		subID := "ap-batch-" | itoa(retryRound) | "-" | itoa(batchNum)
6161  		batchNum++
6162  		routeMsg(buildProxyMsg(subID,
6163  			"{\"authors\":" | authors | ",\"kinds\":[0,10002],\"limit\":" | itoa(len(chunk)*2) | "}",
6164  			proxy))
6165  		// Direct query to feed relays.
6166  		profileSubCounter++
6167  		routeMsg(buildProxyMsg("ap-d-" | itoa(profileSubCounter),
6168  			"{\"authors\":" | authors | ",\"kinds\":[0],\"limit\":" | itoa(len(chunk)) | "}",
6169  			relayURLs))
6170  	}
6171  	retryRound++
6172  }
6173  
6174  // applyAuthorProfile is no longer called by Shell - Profile Worker owns event
6175  // processing. P_RESOLVED/P_CONTENT handlers in onSWMessage do the DOM updates.
6176  
6177  // updateNoteHeader fills in avatar+name on a note's author header div.
6178  func updateNoteHeader(header dom.Element, name, pic string) {
6179  	// First child is <img>, second is <span>.
6180  	img := dom.FirstChild(header)
6181  	if img == 0 {
6182  		return
6183  	}
6184  	span := dom.NextSibling(img)
6185  	if len(pic) > 0 {
6186  		setMediaSrc(img, pic)
6187  		dom.SetAttribute(img, "onerror", "this.style.display='none'")
6188  		dom.SetStyle(img, "display", "")
6189  	}
6190  	if len(name) > 0 {
6191  		dom.SetTextContent(span, name)
6192  	}
6193  }
6194  
6195  // --- Profile page ---
6196  
6197  func showEditProfileModal() {
6198  	content := profileContentCache[pubhex]
6199  	curName := helpers.JsonGetString(content, "name")
6200  	curAbout := helpers.JsonGetString(content, "about")
6201  	curPic := helpers.JsonGetString(content, "picture")
6202  	curBanner := helpers.JsonGetString(content, "banner")
6203  	curNip05 := helpers.JsonGetString(content, "nip05")
6204  	curWebsite := helpers.JsonGetString(content, "website")
6205  	curLud16 := helpers.JsonGetString(content, "lud16")
6206  
6207  	overlay := dom.CreateElement("div")
6208  	dom.SetStyle(overlay, "position", "fixed")
6209  	dom.SetStyle(overlay, "top", "0")
6210  	dom.SetStyle(overlay, "left", "0")
6211  	dom.SetStyle(overlay, "right", "0")
6212  	dom.SetStyle(overlay, "bottom", "0")
6213  	dom.SetStyle(overlay, "background", "rgba(0,0,0,0.6)")
6214  	dom.SetStyle(overlay, "display", "flex")
6215  	dom.SetStyle(overlay, "alignItems", "center")
6216  	dom.SetStyle(overlay, "justifyContent", "center")
6217  	dom.SetStyle(overlay, "zIndex", "2000")
6218  
6219  	modal := dom.CreateElement("div")
6220  	dom.SetStyle(modal, "background", "var(--bg)")
6221  	dom.SetStyle(modal, "border", "1px solid var(--border)")
6222  	dom.SetStyle(modal, "borderRadius", "8px")
6223  	dom.SetStyle(modal, "padding", "20px")
6224  	dom.SetStyle(modal, "width", "400px")
6225  	dom.SetStyle(modal, "maxWidth", "calc(100vw - 32px)")
6226  	dom.SetStyle(modal, "maxHeight", "calc(100vh - 64px)")
6227  	dom.SetStyle(modal, "overflowY", "auto")
6228  
6229  	makeField := func(label, val string, multiline bool) dom.Element {
6230  		wrap := dom.CreateElement("div")
6231  		dom.SetStyle(wrap, "marginBottom", "12px")
6232  		lbl := dom.CreateElement("div")
6233  		dom.SetTextContent(lbl, label)
6234  		dom.SetStyle(lbl, "fontSize", "11px")
6235  		dom.SetStyle(lbl, "color", "var(--muted)")
6236  		dom.SetStyle(lbl, "marginBottom", "4px")
6237  		dom.AppendChild(wrap, lbl)
6238  		var input dom.Element
6239  		if multiline {
6240  			input = dom.CreateElement("textarea")
6241  			dom.SetAttribute(input, "rows", "3")
6242  		} else {
6243  			input = dom.CreateElement("input")
6244  			dom.SetAttribute(input, "type", "text")
6245  		}
6246  		dom.SetStyle(input, "width", "100%")
6247  		dom.SetStyle(input, "padding", "6px 8px")
6248  		dom.SetStyle(input, "background", "var(--bg2)")
6249  		dom.SetStyle(input, "color", "var(--fg)")
6250  		dom.SetStyle(input, "border", "1px solid var(--border)")
6251  		dom.SetStyle(input, "borderRadius", "4px")
6252  		dom.SetStyle(input, "fontFamily", "'Fira Code', monospace")
6253  		dom.SetStyle(input, "fontSize", "13px")
6254  		dom.SetStyle(input, "boxSizing", "border-box")
6255  		dom.SetAttribute(input, "value", val)
6256  		if multiline {
6257  			dom.SetTextContent(input, val)
6258  		}
6259  		dom.AppendChild(wrap, input)
6260  		return wrap
6261  	}
6262  
6263  	nameField := makeField(t("profile_name"), curName, false)
6264  	aboutField := makeField(t("profile_about"), curAbout, true)
6265  	picField := makeField(t("profile_picture"), curPic, false)
6266  	bannerField := makeField(t("profile_banner"), curBanner, false)
6267  	nip05Field := makeField(t("profile_nip05"), curNip05, false)
6268  	websiteField := makeField(t("profile_website"), curWebsite, false)
6269  	lud16Field := makeField(t("profile_lud16"), curLud16, false)
6270  
6271  	addUploadRow := func(field dom.Element) {
6272  		input := dom.NextSibling(dom.FirstChild(field))
6273  		btn := dom.CreateElement("button")
6274  		dom.SetInnerHTML(btn, `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 2v8M5 5l3-3 3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2 10v3a1 1 0 001 1h10a1 1 0 001-1v-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`)
6275  		dom.SetStyle(btn, "padding", "6px 8px")
6276  		dom.SetStyle(btn, "border", "1px solid var(--border)")
6277  		dom.SetStyle(btn, "borderRadius", "4px")
6278  		dom.SetStyle(btn, "background", "transparent")
6279  		dom.SetStyle(btn, "color", "var(--fg)")
6280  		dom.SetStyle(btn, "cursor", "pointer")
6281  		dom.SetStyle(btn, "display", "flex")
6282  		dom.SetStyle(btn, "alignItems", "center")
6283  		dom.SetStyle(btn, "flexShrink", "0")
6284  		dom.AddEventListener(btn, "click", dom.RegisterCallback(func() {
6285  			dom.PickFileBase64("image/*,video/*", func(b64, mime string) {
6286  				if b64 != "" {
6287  					blossomUploadToInput(b64, mime, input)
6288  				}
6289  			})
6290  		}))
6291  		row := dom.CreateElement("div")
6292  		dom.SetStyle(row, "display", "flex")
6293  		dom.SetStyle(row, "gap", "4px")
6294  		dom.SetStyle(input, "flex", "1")
6295  		dom.SetStyle(input, "minWidth", "0")
6296  		dom.InsertBefore(field, row, input)
6297  		dom.AppendChild(row, input)
6298  		dom.AppendChild(row, btn)
6299  	}
6300  
6301  	addUploadRow(picField)
6302  	addUploadRow(bannerField)
6303  
6304  	dom.AppendChild(modal, nameField)
6305  	dom.AppendChild(modal, aboutField)
6306  	dom.AppendChild(modal, picField)
6307  	dom.AppendChild(modal, bannerField)
6308  	dom.AppendChild(modal, nip05Field)
6309  	dom.AppendChild(modal, websiteField)
6310  	dom.AppendChild(modal, lud16Field)
6311  
6312  	// Buttons row.
6313  	btnRow := dom.CreateElement("div")
6314  	dom.SetStyle(btnRow, "display", "flex")
6315  	dom.SetStyle(btnRow, "gap", "8px")
6316  	dom.SetStyle(btnRow, "marginTop", "8px")
6317  
6318  	getInput := func(field dom.Element) dom.Element {
6319  		el := dom.NextSibling(dom.FirstChild(field))
6320  		tag := dom.GetProperty(el, "tagName")
6321  		if tag == "DIV" {
6322  			return dom.FirstChild(el)
6323  		}
6324  		return el
6325  	}
6326  
6327  	saveBtn := dom.CreateElement("button")
6328  	dom.SetTextContent(saveBtn, t("save"))
6329  	dom.SetStyle(saveBtn, "padding", "6px 16px")
6330  	dom.SetStyle(saveBtn, "fontFamily", "'Fira Code', monospace")
6331  	dom.SetStyle(saveBtn, "fontSize", "12px")
6332  	dom.SetStyle(saveBtn, "background", "var(--accent)")
6333  	dom.SetStyle(saveBtn, "color", "#fff")
6334  	dom.SetStyle(saveBtn, "border", "none")
6335  	dom.SetStyle(saveBtn, "borderRadius", "4px")
6336  	dom.SetStyle(saveBtn, "cursor", "pointer")
6337  	dom.AddEventListener(saveBtn, "click", dom.RegisterCallback(func() {
6338  		newName := dom.GetProperty(getInput(nameField), "value")
6339  		newAbout := dom.GetProperty(getInput(aboutField), "value")
6340  		newPic := dom.GetProperty(getInput(picField), "value")
6341  		newBanner := dom.GetProperty(getInput(bannerField), "value")
6342  		newNip05 := dom.GetProperty(getInput(nip05Field), "value")
6343  		newWebsite := dom.GetProperty(getInput(websiteField), "value")
6344  		newLud16 := dom.GetProperty(getInput(lud16Field), "value")
6345  
6346  		k0content := "{" |
6347  			"\"name\":\"" | jsonEsc(newName) | "\"" |
6348  			",\"about\":\"" | jsonEsc(newAbout) | "\"" |
6349  			",\"picture\":\"" | jsonEsc(newPic) | "\"" |
6350  			",\"banner\":\"" | jsonEsc(newBanner) | "\"" |
6351  			",\"nip05\":\"" | jsonEsc(newNip05) | "\"" |
6352  			",\"website\":\"" | jsonEsc(newWebsite) | "\"" |
6353  			",\"lud16\":\"" | jsonEsc(newLud16) | "\"" |
6354  			"}"
6355  
6356  		ts := dom.NowSeconds()
6357  		unsigned := "{\"kind\":0,\"content\":" | jstr(k0content) |
6358  			",\"tags\":[]" |
6359  			",\"created_at\":" | i64toa(ts) |
6360  			",\"pubkey\":\"" | pubhex | "\"}"
6361  
6362  		hasSigner := "no"
6363  		if signer.HasSigner() {
6364  			hasSigner = "yes"
6365  		}
6366  		dom.ConsoleLog("[profile] signing kind 0, hasSigner=" | hasSigner)
6367  		signer.SignEvent(unsigned, func(signed string) {
6368  			if len(signed) == 0 {
6369  				dom.ConsoleLog("[profile] sign returned empty - check [signer] and [ext.dispatch] logs above")
6370  				dom.Confirm("sign failed - open signer panel, unlock vault, and try again")
6371  				return
6372  			}
6373  			dom.ConsoleLog("[profile] sign OK, posting to SW")
6374  			selected := composeSelectedRelays()
6375  			for _, d := range discoveryRelays {
6376  				selected = appendUnique(selected, d)
6377  			}
6378  			dom.ConsoleLog("[profile] PUBLISH_TO " | itoa(len(selected)) | " relays")
6379  			msg := "[\"PUBLISH_TO\"," | signed | ",["
6380  			for i, u := range selected {
6381  				if i > 0 {
6382  					msg = msg | ","
6383  				}
6384  				msg = msg | jstr(u)
6385  			}
6386  			routeMsg(msg | "]]")
6387  			dom.RemoveChild(dom.Body(), overlay)
6388  			// Update local cache immediately.
6389  			profileContentCache[pubhex] = k0content
6390  			if len(newName) > 0 {
6391  				profileName = newName
6392  				setAuthorName(pubhex, newName)
6393  				dom.SetTextContent(nameEl, newName)
6394  			}
6395  			if len(newPic) > 0 {
6396  				profilePic = newPic
6397  				authorPics[pubhex] = newPic
6398  				setMediaSrc(avatarEl, newPic)
6399  				dom.SetStyle(avatarEl, "display", "block")
6400  			}
6401  			profileTs = ts
6402  			renderProfilePage(pubhex)
6403  		})
6404  	}))
6405  	dom.AppendChild(btnRow, saveBtn)
6406  
6407  	cancelBtn := dom.CreateElement("button")
6408  	dom.SetTextContent(cancelBtn, t("cancel"))
6409  	dom.SetStyle(cancelBtn, "padding", "6px 16px")
6410  	dom.SetStyle(cancelBtn, "fontFamily", "'Fira Code', monospace")
6411  	dom.SetStyle(cancelBtn, "fontSize", "12px")
6412  	dom.SetStyle(cancelBtn, "background", "var(--bg2)")
6413  	dom.SetStyle(cancelBtn, "color", "var(--fg)")
6414  	dom.SetStyle(cancelBtn, "border", "1px solid var(--border)")
6415  	dom.SetStyle(cancelBtn, "borderRadius", "4px")
6416  	dom.SetStyle(cancelBtn, "cursor", "pointer")
6417  	dom.AddEventListener(cancelBtn, "click", dom.RegisterCallback(func() {
6418  		dom.RemoveChild(dom.Body(), overlay)
6419  	}))
6420  	dom.AppendChild(btnRow, cancelBtn)
6421  
6422  	dom.AppendChild(modal, btnRow)
6423  	dom.AppendChild(overlay, modal)
6424  	dom.AppendChild(dom.Body(), overlay)
6425  }
6426  
6427  func showProfile(pk string) {
6428  	if fetchedK0 == nil {
6429  		return
6430  	}
6431  	// Save current profile's tab state before switching.
6432  	if profileViewPK != "" && profileWrappers[profileViewPK] != 0 {
6433  		profileWrapTabSel[profileViewPK] = profileTab
6434  		profileWrapTabCont[profileViewPK] = profileTabContent
6435  		profileWrapTabs[profileViewPK] = profileTabBtns
6436  	}
6437  
6438  	profileViewPK = pk
6439  
6440  	// Always fetch fresh metadata in background.
6441  	setFetchedK0(pk, false)
6442  	profile.Send(`["P_RESOLVE_FORCE",` | jstr(pk) | `]`)
6443  
6444  	if !navPop {
6445  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
6446  		dom.PushState("/p/" | npub)
6447  	}
6448  
6449  	// Hide all cached profile wrappers and remove relay info.
6450  	for _, w := range profileWrappers {
6451  		dom.SetStyle(w, "display", "none")
6452  	}
6453  	if relayInfoEl != 0 {
6454  		dom.RemoveChild(profilePage, relayInfoEl)
6455  		relayInfoEl = 0
6456  	}
6457  
6458  	if wrapper, ok := profileWrappers[pk]; ok {
6459  		// Cache hit - restore.
6460  		dom.SetStyle(wrapper, "display", "block")
6461  		profileTabContent = profileWrapTabCont[pk]
6462  		profileTabBtns = profileWrapTabs[pk]
6463  		profileTab = profileWrapTabSel[pk]
6464  		profileTouchLRU(pk)
6465  	} else {
6466  		renderProfilePage(pk)
6467  	}
6468  
6469  	activePage = "" // force switchPage to run
6470  	switchPage("profile")
6471  }
6472  
6473  const profileCacheMax = 8
6474  
6475  func profileTouchLRU(pk string) {
6476  	// Move pk to end (most recent).
6477  	out := []string{:0:len(profileWrapOrder)}
6478  	for _, p := range profileWrapOrder {
6479  		if p != pk {
6480  			out = append(out, p)
6481  		}
6482  	}
6483  	profileWrapOrder = append(out, pk)
6484  }
6485  
6486  func profileEvictOldest() {
6487  	for len(profileWrapOrder) > profileCacheMax {
6488  		old := profileWrapOrder[0]
6489  		profileWrapOrder = profileWrapOrder[1:]
6490  		if w, ok := profileWrappers[old]; ok {
6491  			dom.RemoveChild(profilePage, w)
6492  			dom.ReleaseChildren(w)
6493  			dom.ReleaseElement(w)
6494  		}
6495  		delete(profileWrappers, old)
6496  		delete(profileWrapTabCont, old)
6497  		delete(profileWrapTabs, old)
6498  		delete(profileWrapTabSel, old)
6499  		delete(profileContentCache, old)
6500  		delete(profileFollowsCache, old)
6501  		delete(profileMutesCache, old)
6502  		delete(profileRelaysCache, old)
6503  	}
6504  }
6505  
6506  func verifyNip05(nip05, pubkeyHex string, badge dom.Element) {
6507  	at := -1
6508  	for i := 0; i < len(nip05); i++ {
6509  		if nip05[i] == '@' {
6510  			at = i
6511  			break
6512  		}
6513  	}
6514  	if at < 1 || at >= len(nip05)-1 {
6515  		dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
6516  		return
6517  	}
6518  	local := nip05[:at]
6519  	domain := nip05[at+1:]
6520  	url := "https://" | domain | "/.well-known/nostr.json?name=" | local
6521  	dom.FetchText(url, func(body string) {
6522  		if body == "" {
6523  			dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
6524  			return
6525  		}
6526  		namesObj := helpers.JsonGetString(body, "names")
6527  		if namesObj == "" {
6528  			// names might be an object not a string - extract manually
6529  			namesStart := -1
6530  			key := "\"names\""
6531  			for i := 0; i < len(body)-len(key); i++ {
6532  				if body[i:i+len(key)] == key {
6533  					namesStart = i + len(key)
6534  					break
6535  				}
6536  			}
6537  			if namesStart < 0 {
6538  				dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f")
6539  				return
6540  			}
6541  			// Skip colon and whitespace to find the object
6542  			for namesStart < len(body) && (body[namesStart] == ':' || body[namesStart] == ' ' || body[namesStart] == '\t') {
6543  				namesStart++
6544  			}
6545  			if namesStart >= len(body) || body[namesStart] != '{' {
6546  				dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f")
6547  				return
6548  			}
6549  			// Find matching brace
6550  			depth := 0
6551  			end := namesStart
6552  			for end < len(body) {
6553  				if body[end] == '{' {
6554  					depth++
6555  				} else if body[end] == '}' {
6556  					depth--
6557  					if depth == 0 {
6558  						end++
6559  						break
6560  					}
6561  				}
6562  				end++
6563  			}
6564  			namesObj = body[namesStart:end]
6565  		}
6566  		got := helpers.JsonGetString(namesObj, local)
6567  		if got == pubkeyHex {
6568  			dom.SetTextContent(badge, "\xe2\x9c\x85") // ✅
6569  		} else {
6570  			dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
6571  		}
6572  	})
6573  }
6574  
6575  func renderProfilePage(pk string) {
6576  	savedTab := profileTab
6577  	closeProfileNoteSub()
6578  	profileNotesSeen = map[string]bool{}
6579  
6580  	// Remove previous wrapper for this pubkey if it exists.
6581  	if old, ok := profileWrappers[pk]; ok {
6582  		dom.RemoveChild(profilePage, old)
6583  	}
6584  
6585  	wrapper := dom.CreateElement("div")
6586  	profileWrappers[pk] = wrapper
6587  	profileTouchLRU(pk)
6588  	profileEvictOldest()
6589  	dom.AppendChild(profilePage, wrapper)
6590  
6591  	content := profileContentCache[pk]
6592  	name := authorNames[pk]
6593  	pic := authorPics[pk]
6594  	about := helpers.JsonGetString(content, "about")
6595  	website := helpers.JsonGetString(content, "website")
6596  	nip05 := helpers.JsonGetString(content, "nip05")
6597  	lud16 := helpers.JsonGetString(content, "lud16")
6598  	banner := helpers.JsonGetString(content, "banner")
6599  
6600  	// Banner - full width, 200px, cover.
6601  	if banner != "" {
6602  		bannerEl := dom.CreateElement("img")
6603  		dom.SetAttribute(bannerEl, "referrerpolicy", "no-referrer")
6604  		setMediaSrc(bannerEl, banner)
6605  		dom.SetStyle(bannerEl, "width", "100%")
6606  		dom.SetStyle(bannerEl, "height", "240px")
6607  		dom.SetStyle(bannerEl, "objectFit", "cover")
6608  		dom.SetStyle(bannerEl, "objectPosition", "center 30%")
6609  		dom.SetStyle(bannerEl, "display", "block")
6610  		dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'")
6611  		dom.AppendChild(wrapper, bannerEl)
6612  	}
6613  
6614  	// User info card - glass effect, overlapping banner.
6615  	card := dom.CreateElement("div")
6616  	dom.SetStyle(card, "background", "color-mix(in srgb, var(--bg) 42%, transparent)")
6617  	dom.SetStyle(card, "backdropFilter", "blur(8px)")
6618  	dom.SetStyle(card, "borderRadius", "8px")
6619  	dom.SetStyle(card, "padding", "8px")
6620  	if banner != "" {
6621  		dom.SetStyle(card, "margin", "-24px 16px 0")
6622  	} else {
6623  		dom.SetStyle(card, "margin", "16px")
6624  	}
6625  	dom.SetStyle(card, "position", "relative")
6626  	dom.SetStyle(card, "width", "fit-content")
6627  	dom.SetStyle(card, "maxWidth", "calc(100% - 32px)")
6628  
6629  	// Top row: avatar + info. Wraps on narrow screens so avatar goes above.
6630  	topRow := dom.CreateElement("div")
6631  	dom.SetStyle(topRow, "display", "flex")
6632  	dom.SetStyle(topRow, "gap", "16px")
6633  	dom.SetStyle(topRow, "alignItems", "flex-start")
6634  	dom.SetStyle(topRow, "flexWrap", "wrap")
6635  
6636  	// Compute npub early - needed for avatar QR click and npub row.
6637  	npubBytes := helpers.HexDecode(pk)
6638  	npubStr := helpers.EncodeNpub(npubBytes)
6639  
6640  	if pic != "" {
6641  		av := dom.CreateElement("img")
6642  		dom.SetAttribute(av, "referrerpolicy", "no-referrer")
6643  		setMediaSrc(av, pic)
6644  		dom.SetAttribute(av, "width", "64")
6645  		dom.SetAttribute(av, "height", "64")
6646  		dom.SetStyle(av, "borderRadius", "50%")
6647  		dom.SetStyle(av, "objectFit", "cover")
6648  		dom.SetStyle(av, "flexShrink", "0")
6649  		dom.SetStyle(av, "border", "3px solid var(--bg)")
6650  		dom.SetStyle(av, "cursor", "pointer")
6651  		dom.SetAttribute(av, "onerror", "this.style.display='none'")
6652  		avNpub := npubStr
6653  		dom.AddEventListener(av, "click", dom.RegisterCallback(func() {
6654  			showQRModal(avNpub)
6655  		}))
6656  		dom.AppendChild(topRow, av)
6657  	}
6658  
6659  	info := dom.CreateElement("div")
6660  	dom.SetStyle(info, "minWidth", "200px")
6661  	dom.SetStyle(info, "flex", "1")
6662  	dom.SetStyle(info, "overflow", "hidden")
6663  
6664  	if name != "" {
6665  		nameRow := dom.CreateElement("div")
6666  		dom.SetStyle(nameRow, "display", "flex")
6667  		dom.SetStyle(nameRow, "alignItems", "baseline")
6668  		dom.SetStyle(nameRow, "gap", "8px")
6669  		nameSpan := dom.CreateElement("span")
6670  		dom.SetTextContent(nameSpan, name)
6671  		dom.SetStyle(nameSpan, "fontSize", "20px")
6672  		dom.SetStyle(nameSpan, "fontWeight", "bold")
6673  		dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6674  		dom.SetStyle(nameSpan, "cursor", "pointer")
6675  		nameNpub := npubStr
6676  		dom.AddEventListener(nameSpan, "click", dom.RegisterCallback(func() {
6677  			showQRModal(nameNpub)
6678  		}))
6679  		dom.AppendChild(nameRow, nameSpan)
6680  		// "follows you" badge.
6681  		if pk != pubhex {
6682  			if theirFollows, ok := profileFollowsCache[pk]; ok {
6683  				for _, fpk := range theirFollows {
6684  					if fpk == pubhex {
6685  						badge := dom.CreateElement("span")
6686  						dom.SetTextContent(badge, "follows you")
6687  						dom.SetStyle(badge, "fontSize", "11px")
6688  						dom.SetStyle(badge, "color", "var(--muted)")
6689  						dom.SetStyle(badge, "background", "var(--bg2)")
6690  						dom.SetStyle(badge, "padding", "2px 6px")
6691  						dom.SetStyle(badge, "borderRadius", "4px")
6692  						dom.AppendChild(nameRow, badge)
6693  						break
6694  					}
6695  				}
6696  			}
6697  		}
6698  		dom.AppendChild(info, nameRow)
6699  	}
6700  
6701  	if nip05 != "" {
6702  		nip05Row := dom.CreateElement("div")
6703  		dom.SetStyle(nip05Row, "display", "flex")
6704  		dom.SetStyle(nip05Row, "alignItems", "center")
6705  		dom.SetStyle(nip05Row, "gap", "4px")
6706  		nip05Text := dom.CreateElement("span")
6707  		dom.SetTextContent(nip05Text, nip05)
6708  		dom.SetStyle(nip05Text, "color", "var(--muted)")
6709  		dom.SetStyle(nip05Text, "fontSize", "13px")
6710  		dom.AppendChild(nip05Row, nip05Text)
6711  		nip05Badge := dom.CreateElement("span")
6712  		dom.SetStyle(nip05Badge, "fontSize", "14px")
6713  		dom.AppendChild(nip05Row, nip05Badge)
6714  		dom.AppendChild(info, nip05Row)
6715  
6716  		// Async NIP-05 validation.
6717  		verifyNip05(nip05, pk, nip05Badge)
6718  	}
6719  
6720  	// npub row: clickable npub text (shows QR) + copy icon.
6721  	npubRow := dom.CreateElement("div")
6722  	dom.SetStyle(npubRow, "display", "flex")
6723  	dom.SetStyle(npubRow, "alignItems", "center")
6724  	dom.SetStyle(npubRow, "gap", "6px")
6725  	dom.SetStyle(npubRow, "marginTop", "2px")
6726  	npubEl := dom.CreateElement("span")
6727  	dom.SetStyle(npubEl, "color", "var(--muted)")
6728  	dom.SetStyle(npubEl, "fontSize", "12px")
6729  	dom.SetStyle(npubEl, "wordBreak", "break-all")
6730  	dom.SetStyle(npubEl, "cursor", "pointer")
6731  	dom.SetTextContent(npubEl, npubStr)
6732  	npubQR := npubStr
6733  	dom.AddEventListener(npubEl, "click", dom.RegisterCallback(func() {
6734  		showQRModal(npubQR)
6735  	}))
6736  	dom.AppendChild(npubRow, npubEl)
6737  	copyBtn := actionBtn(`<svg width="16" height="16" viewBox="0 0 18 18" fill="none"><rect x="6" y="6" width="9" height="10" rx="1.5" stroke="currentColor" stroke-width="1.5"/><path d="M12 6V4.5A1.5 1.5 0 0010.5 3H4.5A1.5 1.5 0 003 4.5v8A1.5 1.5 0 004.5 14H6" stroke="currentColor" stroke-width="1.5"/></svg>`)
6738  	dom.SetAttribute(copyBtn, "data-npub", npubStr)
6739  	dom.SetAttribute(copyBtn, "onclick", "navigator.clipboard.writeText(this.dataset.npub)")
6740  	dom.AppendChild(npubRow, copyBtn)
6741  	dom.AppendChild(info, npubRow)
6742  
6743  	// Website + lightning inline.
6744  	if website != "" || lud16 != "" {
6745  		metaRow := dom.CreateElement("div")
6746  		dom.SetStyle(metaRow, "display", "flex")
6747  		dom.SetStyle(metaRow, "flexWrap", "wrap")
6748  		dom.SetStyle(metaRow, "gap", "4px 12px")
6749  		dom.SetStyle(metaRow, "marginTop", "6px")
6750  		dom.SetStyle(metaRow, "fontSize", "12px")
6751  		if website != "" {
6752  			wEl := dom.CreateElement("span")
6753  			dom.SetStyle(wEl, "color", "var(--accent)")
6754  			dom.SetStyle(wEl, "wordBreak", "break-all")
6755  			dom.SetTextContent(wEl, website)
6756  			dom.AppendChild(metaRow, wEl)
6757  		}
6758  		if lud16 != "" {
6759  			lEl := dom.CreateElement("span")
6760  			dom.SetStyle(lEl, "color", "var(--muted)")
6761  			dom.SetStyle(lEl, "wordBreak", "break-all")
6762  			dom.SetStyle(lEl, "cursor", "pointer")
6763  			dom.SetTextContent(lEl, "\xE2\x9A\xA1 " | lud16)
6764  			capLud := lud16
6765  			dom.AddEventListener(lEl, "click", dom.RegisterCallback(func() {
6766  				showQRModal(capLud)
6767  			}))
6768  			dom.AppendChild(metaRow, lEl)
6769  		}
6770  		dom.AppendChild(info, metaRow)
6771  	}
6772  
6773  	dom.AppendChild(topRow, info)
6774  	dom.AppendChild(card, topRow)
6775  
6776  	// Action buttons - only for other users.
6777  	if pk != pubhex {
6778  		btnRow := dom.CreateElement("div")
6779  		dom.SetStyle(btnRow, "display", "flex")
6780  		dom.SetStyle(btnRow, "gap", "8px")
6781  		dom.SetStyle(btnRow, "marginTop", "12px")
6782  
6783  		// Follow/unfollow button.
6784  		followBtn := dom.CreateElement("button")
6785  		dom.SetStyle(followBtn, "padding", "6px 16px")
6786  		dom.SetStyle(followBtn, "fontFamily", "'Fira Code', monospace")
6787  		dom.SetStyle(followBtn, "fontSize", "12px")
6788  		dom.SetStyle(followBtn, "borderRadius", "4px")
6789  		dom.SetStyle(followBtn, "cursor", "pointer")
6790  		isFollowing := false
6791  		if len(myFollows) > 0 {
6792  			for _, fpk := range myFollows {
6793  				if fpk == pk {
6794  					isFollowing = true
6795  					break
6796  				}
6797  			}
6798  		}
6799  		if isFollowing {
6800  			dom.SetTextContent(followBtn, "followed")
6801  			dom.SetStyle(followBtn, "background", "var(--bg2)")
6802  			dom.SetStyle(followBtn, "color", "var(--fg)")
6803  			dom.SetStyle(followBtn, "border", "1px solid var(--border)")
6804  		} else {
6805  			dom.SetTextContent(followBtn, "follow")
6806  			dom.SetStyle(followBtn, "background", "var(--accent)")
6807  			dom.SetStyle(followBtn, "color", "#fff")
6808  			dom.SetStyle(followBtn, "border", "none")
6809  		}
6810  		dom.AppendChild(btnRow, followBtn)
6811  
6812  
6813  		// Mute button.
6814  		muteBtn := dom.CreateElement("button")
6815  		dom.SetStyle(muteBtn, "padding", "6px 16px")
6816  		dom.SetStyle(muteBtn, "fontFamily", "'Fira Code', monospace")
6817  		dom.SetStyle(muteBtn, "fontSize", "12px")
6818  		dom.SetStyle(muteBtn, "borderRadius", "4px")
6819  		dom.SetStyle(muteBtn, "cursor", "pointer")
6820  		if isMuted(pk) {
6821  			dom.SetTextContent(muteBtn, "muted")
6822  			dom.SetStyle(muteBtn, "background", "#c33")
6823  			dom.SetStyle(muteBtn, "color", "#fff")
6824  			dom.SetStyle(muteBtn, "border", "none")
6825  		} else {
6826  			dom.SetTextContent(muteBtn, "mute")
6827  			dom.SetStyle(muteBtn, "background", "var(--bg2)")
6828  			dom.SetStyle(muteBtn, "color", "var(--fg)")
6829  			dom.SetStyle(muteBtn, "border", "1px solid var(--border)")
6830  		}
6831  
6832  		// Wire up click handlers with cross-references.
6833  		btnPK := pk
6834  		cFollow := followBtn
6835  		cMute := muteBtn
6836  		dom.AddEventListener(followBtn, "click", dom.RegisterCallback(func() {
6837  			toggleFollow(btnPK, cFollow, cMute)
6838  		}))
6839  		dom.AddEventListener(muteBtn, "click", dom.RegisterCallback(func() {
6840  			toggleMute(btnPK, cMute, cFollow)
6841  		}))
6842  		dom.AppendChild(btnRow, muteBtn)
6843  
6844  		dom.AppendChild(card, btnRow)
6845  	} else {
6846  		// Own profile - edit button.
6847  		editRow := dom.CreateElement("div")
6848  		dom.SetStyle(editRow, "marginTop", "12px")
6849  
6850  		editBtn := dom.CreateElement("button")
6851  		dom.SetTextContent(editBtn, t("edit_profile"))
6852  		dom.SetStyle(editBtn, "padding", "6px 16px")
6853  		dom.SetStyle(editBtn, "fontFamily", "'Fira Code', monospace")
6854  		dom.SetStyle(editBtn, "fontSize", "12px")
6855  		dom.SetStyle(editBtn, "background", "var(--bg2)")
6856  		dom.SetStyle(editBtn, "color", "var(--fg)")
6857  		dom.SetStyle(editBtn, "border", "1px solid var(--border)")
6858  		dom.SetStyle(editBtn, "borderRadius", "4px")
6859  		dom.SetStyle(editBtn, "cursor", "pointer")
6860  		dom.AddEventListener(editBtn, "click", dom.RegisterCallback(func() {
6861  			showEditProfileModal()
6862  		}))
6863  		dom.AppendChild(editRow, editBtn)
6864  		dom.AppendChild(card, editRow)
6865  	}
6866  
6867  	dom.AppendChild(wrapper, card)
6868  
6869  	// About/bio - same height-cap + show-more pattern as feed notes.
6870  	if about != "" {
6871  		aboutEl := dom.CreateElement("div")
6872  		dom.SetStyle(aboutEl, "padding", "12px 16px")
6873  		dom.SetStyle(aboutEl, "fontSize", "14px")
6874  		dom.SetStyle(aboutEl, "lineHeight", "1.5")
6875  		dom.SetStyle(aboutEl, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6876  		dom.SetStyle(aboutEl, "wordBreak", "break-word")
6877  		dom.SetStyle(aboutEl, "overflow", "hidden")
6878  		dom.SetStyle(aboutEl, "maxHeight", "18em")
6879  		dom.AppendChild(wrapper, aboutEl)
6880  		setHTMLWithMedia(aboutEl, renderMarkdown(about))
6881  		more := makeShowMore(aboutEl)
6882  		dom.SetStyle(more, "padding", "0 16px 8px")
6883  		dom.AppendChild(wrapper, more)
6884  		attachShowMore(aboutEl, more)
6885  	}
6886  
6887  	// Tab bar.
6888  	tabBar := dom.CreateElement("div")
6889  	dom.SetStyle(tabBar, "display", "flex")
6890  	dom.SetStyle(tabBar, "gap", "0")
6891  	dom.SetStyle(tabBar, "margin", "8px 16px 0")
6892  	dom.SetStyle(tabBar, "border", "1px solid var(--border)")
6893  	dom.SetStyle(tabBar, "borderRadius", "6px")
6894  	dom.SetStyle(tabBar, "overflow", "hidden")
6895  	dom.SetStyle(tabBar, "width", "fit-content")
6896  
6897  	profileTabBtns = map[string]dom.Element{}
6898  
6899  	// Unrolled - tinyjs range/loop closure aliasing.
6900  	tabNotes := makeProtoBtn(t("notes"))
6901  	dom.SetStyle(tabNotes, "cursor", "pointer")
6902  	profileTabBtns["notes"] = tabNotes
6903  	tabNotesPK := pk
6904  	dom.AddEventListener(tabNotes, "click", dom.RegisterCallback(func() {
6905  		selectProfileTab("notes", tabNotesPK)
6906  	}))
6907  	dom.AppendChild(tabBar, tabNotes)
6908  
6909  	tabFollows := makeProtoBtn(t("follows"))
6910  	dom.SetStyle(tabFollows, "cursor", "pointer")
6911  	profileTabBtns["follows"] = tabFollows
6912  	tabFollowsPK := pk
6913  	dom.AddEventListener(tabFollows, "click", dom.RegisterCallback(func() {
6914  		selectProfileTab("follows", tabFollowsPK)
6915  	}))
6916  	dom.AppendChild(tabBar, tabFollows)
6917  
6918  	tabRelays := makeProtoBtn(t("relays"))
6919  	dom.SetStyle(tabRelays, "cursor", "pointer")
6920  	profileTabBtns["relays"] = tabRelays
6921  	tabRelaysPK := pk
6922  	dom.AddEventListener(tabRelays, "click", dom.RegisterCallback(func() {
6923  		selectProfileTab("relays", tabRelaysPK)
6924  	}))
6925  	dom.AppendChild(tabBar, tabRelays)
6926  
6927  	tabMutes := makeProtoBtn(t("mutes"))
6928  	dom.SetStyle(tabMutes, "cursor", "pointer")
6929  	profileTabBtns["mutes"] = tabMutes
6930  	tabMutesPK := pk
6931  	dom.AddEventListener(tabMutes, "click", dom.RegisterCallback(func() {
6932  		selectProfileTab("mutes", tabMutesPK)
6933  	}))
6934  	dom.AppendChild(tabBar, tabMutes)
6935  
6936  	dom.AppendChild(wrapper, tabBar)
6937  
6938  	// Tab content container.
6939  	profileTabContent = dom.CreateElement("div")
6940  	dom.SetStyle(profileTabContent, "padding", "8px 0")
6941  	dom.AppendChild(wrapper, profileTabContent)
6942  
6943  	// Restore or default tab.
6944  	profileTab = ""
6945  	if savedTab != "" {
6946  		selectProfileTab(savedTab, pk)
6947  	} else {
6948  		selectProfileTab("notes", pk)
6949  	}
6950  
6951  	// Save cache refs for this profile.
6952  	profileWrapTabCont[pk] = profileTabContent
6953  	profileWrapTabs[pk] = profileTabBtns
6954  	profileWrapTabSel[pk] = profileTab
6955  
6956  	_ = name // title shown via topBackBtn, not pageTitleEl
6957  }
6958  
6959  func toggleFollow(pk string, btn dom.Element, muteBtn dom.Element) {
6960  	if !signer.HasSigner() || pubhex == "" {
6961  		return
6962  	}
6963  	found := false
6964  	var newFollows []string
6965  	for _, fpk := range myFollows {
6966  		if fpk == pk {
6967  			found = true
6968  			continue
6969  		}
6970  		newFollows = append(newFollows, fpk)
6971  	}
6972  	if !found {
6973  		newFollows = append(newFollows, pk)
6974  	}
6975  	myFollows = newFollows
6976  	followSet = buildSet(newFollows)
6977  
6978  	if found {
6979  		dom.SetTextContent(btn, "follow")
6980  		dom.SetStyle(btn, "background", "var(--accent)")
6981  		dom.SetStyle(btn, "color", "#fff")
6982  		dom.SetStyle(btn, "border", "none")
6983  	} else {
6984  		dom.SetTextContent(btn, "followed")
6985  		dom.SetStyle(btn, "background", "var(--bg2)")
6986  		dom.SetStyle(btn, "color", "var(--fg)")
6987  		dom.SetStyle(btn, "border", "1px solid var(--border)")
6988  		// Following unmutes.
6989  		if isMuted(pk) {
6990  			unmute(pk, muteBtn)
6991  		}
6992  	}
6993  
6994  	// Bump contactTs so stale kind 3 events from slow relays are rejected.
6995  	contactTs = dom.NowSeconds()
6996  	publishFollowList(newFollows)
6997  	if feedSelectEl != 0 {
6998  		populateFeedSelect()
6999  	}
7000  	if feedMode == "follows" {
7001  		refreshFeed()
7002  	}
7003  }
7004  
7005  func publishFollowList(follows []string) {
7006  	tags := "["
7007  	for i, pk := range follows {
7008  		if i > 0 {
7009  			tags = tags | ","
7010  		}
7011  		tags = tags | "[\"p\"," | jstr(pk) | "]"
7012  	}
7013  	tags = tags | "]"
7014  	ts := dom.NowSeconds()
7015  	unsigned := "{\"kind\":3,\"content\":\"\"" |
7016  		",\"tags\":" | tags |
7017  		",\"created_at\":" | i64toa(ts) |
7018  		",\"pubkey\":\"" | pubhex | "\"}"
7019  	logActivity("follow", "publishing kind 3, " | itoa(len(follows)) | " follows", "calling signer.SignEvent")
7020  	signer.SignEvent(unsigned, func(signed string) {
7021  		if signed == "" {
7022  			logActivity("follow", "FAILED: signer returned empty", "vault locked or signer crashed")
7023  			return
7024  		}
7025  		targets := []string{:len(relayURLs)}
7026  		copy(targets, relayURLs)
7027  		for _, d := range discoveryRelays {
7028  			targets = appendUnique(targets, d)
7029  		}
7030  		msg := "[\"PUBLISH_TO\"," | signed | ",["
7031  		relayList := ""
7032  		for i, u := range targets {
7033  			if i > 0 {
7034  				msg = msg | ","
7035  				relayList = relayList | "\n"
7036  			}
7037  			msg = msg | jstr(u)
7038  			relayList = relayList | u
7039  		}
7040  		routeMsg(msg | "]]")
7041  		logActivity("follow", "kind 3 sent to " | itoa(len(targets)) | " relays", "relays:\n" | relayList | "\n\nevent:\n" | signed)
7042  	})
7043  }
7044  
7045  func isMuted(pk string) (ok bool) { return muteSet[pk] }
7046  
7047  func buildSet(pks []string) (m map[string]bool) {
7048  	s := map[string]bool{}
7049  	for _, pk := range pks {
7050  		s[pk] = true
7051  	}
7052  	return s
7053  }
7054  
7055  // broadcastFollows pushes the current follow list to Feed and Notif workers.
7056  // P_FOLLOWS is outbound from Profile Worker to Shell only; never send it inbound.
7057  func broadcastFollows(pks []string) {
7058  	j := buildJSONStrArr(pks)
7059  	feed.Send(`["F_SET_FOLLOWS",` | j | `]`)
7060  	notif.Send(`["N_SET_FOLLOWS",` | j | `]`)
7061  }
7062  
7063  // broadcastMutes pushes the current mute list to Feed and Notif workers.
7064  func broadcastMutes(pks []string) {
7065  	j := buildJSONStrArr(pks)
7066  	feed.Send(`["F_SET_MUTES",` | j | `]`)
7067  	notif.Send(`["N_SET_MUTES",` | j | `]`)
7068  }
7069  
7070  // buildJSONStrArr builds a JSON array of quoted strings.
7071  func buildJSONStrArr(pks []string) (s string) {
7072  	s = "["
7073  	for i, pk := range pks {
7074  		if i > 0 { s = s | "," }
7075  		s = s | jstr(pk)
7076  	}
7077  	return s | "]"
7078  }
7079  
7080  // buildURLsJSON builds a JSON array of quoted URL strings.
7081  func buildURLsJSON(urls []string) (s string) {
7082  	return buildJSONStrArr(urls)
7083  }
7084  
7085  // repliesToMuted returns true if ev is a reply whose parent "p" tag is a muted user.
7086  func repliesToMuted(ev *nostr.Event) (ok bool) {
7087  	if ev.Kind != 1 {
7088  		return false
7089  	}
7090  	hasE := false
7091  	for _, tag := range ev.Tags {
7092  		if len(tag) >= 2 && tag[0] == "e" {
7093  			hasE = true
7094  			break
7095  		}
7096  	}
7097  	if !hasE {
7098  		return false // not a reply
7099  	}
7100  	for _, tag := range ev.Tags {
7101  		if len(tag) >= 2 && tag[0] == "p" && isMuted(tag[1]) {
7102  			return true
7103  		}
7104  	}
7105  	return false
7106  }
7107  
7108  func toggleMute(pk string, btn dom.Element, followBtn dom.Element) {
7109  	if !signer.HasSigner() || pubhex == "" {
7110  		return
7111  	}
7112  	found := false
7113  	var newMutes []string
7114  	for _, m := range myMutes {
7115  		if m == pk {
7116  			found = true
7117  			continue
7118  		}
7119  		newMutes = append(newMutes, m)
7120  	}
7121  	if !found {
7122  		newMutes = append(newMutes, pk)
7123  	}
7124  	myMutes = newMutes
7125  	muteSet = buildSet(newMutes)
7126  
7127  	if found {
7128  		dom.SetTextContent(btn, "mute")
7129  		dom.SetStyle(btn, "background", "var(--bg2)")
7130  		dom.SetStyle(btn, "color", "var(--fg)")
7131  		dom.SetStyle(btn, "border", "1px solid var(--border)")
7132  		// Re-fetch profile notes now that user is unmuted.
7133  		if profileViewPK == pk && profileTab == "notes" && profileTabContent != 0 {
7134  			clearChildren(profileTabContent)
7135  			renderProfileNotes(pk)
7136  		}
7137  	} else {
7138  		dom.SetTextContent(btn, "muted")
7139  		dom.SetStyle(btn, "background", "#c33")
7140  		dom.SetStyle(btn, "color", "#fff")
7141  		dom.SetStyle(btn, "border", "none")
7142  		// Muting unfollows.
7143  		if isFollowing(pk) {
7144  			unfollow(pk, followBtn)
7145  		}
7146  		removeMutedNotes(pk)
7147  	}
7148  
7149  	muteTs = dom.NowSeconds()
7150  	publishMuteList(newMutes)
7151  }
7152  
7153  func unmute(pk string, btn dom.Element) {
7154  	var newMutes []string
7155  	for _, m := range myMutes {
7156  		if m != pk {
7157  			newMutes = append(newMutes, m)
7158  		}
7159  	}
7160  	myMutes = newMutes
7161  	muteSet = buildSet(newMutes)
7162  	dom.SetTextContent(btn, "mute")
7163  	dom.SetStyle(btn, "background", "var(--bg2)")
7164  	dom.SetStyle(btn, "color", "var(--fg)")
7165  	dom.SetStyle(btn, "border", "1px solid var(--border)")
7166  	muteTs = dom.NowSeconds()
7167  	publishMuteList(newMutes)
7168  }
7169  
7170  func isFollowing(pk string) (ok bool) { return followSet[pk] }
7171  
7172  func unfollow(pk string, btn dom.Element) {
7173  	var newFollows []string
7174  	for _, fpk := range myFollows {
7175  		if fpk != pk {
7176  			newFollows = append(newFollows, fpk)
7177  		}
7178  	}
7179  	myFollows = newFollows
7180  	followSet = buildSet(newFollows)
7181  	dom.SetTextContent(btn, "follow")
7182  	dom.SetStyle(btn, "background", "var(--accent)")
7183  	dom.SetStyle(btn, "color", "#fff")
7184  	dom.SetStyle(btn, "border", "none")
7185  	contactTs = dom.NowSeconds()
7186  	publishFollowList(newFollows)
7187  }
7188  
7189  func removeMutedNotes(pk string) {
7190  	// Remove from main feed DOM.
7191  	removeChildrenByPK(feedContainer, pk)
7192  	// Clear profile notes tab if viewing the muted user.
7193  	if profileViewPK == pk && profileTab == "notes" && profileTabContent != 0 {
7194  		closeProfileNoteSub()
7195  		clearChildren(profileTabContent)
7196  	}
7197  	// Purge from feed buffer so muted notes don't reappear on refresh.
7198  	if len(feedBuffer) > 0 {
7199  		var kept []*nostr.Event
7200  		for _, ev := range feedBuffer {
7201  			if ev.PubKey != pk {
7202  				kept = append(kept, ev)
7203  			}
7204  		}
7205  		feedBuffer = kept
7206  	}
7207  	// Purge from thread cache if a thread is open.
7208  	if threadOpen {
7209  		for id, ev := range threadEvents {
7210  			if ev.PubKey == pk {
7211  				delete(threadEvents, id)
7212  			}
7213  		}
7214  		threadLastRendered = 0
7215  		renderThreadTree()
7216  	}
7217  }
7218  
7219  func publishMuteList(mutes []string) {
7220  	tags := "["
7221  	for i, pk := range mutes {
7222  		if i > 0 {
7223  			tags = tags | ","
7224  		}
7225  		tags = tags | "[\"p\"," | jstr(pk) | "]"
7226  	}
7227  	tags = tags | "]"
7228  	ts := dom.NowSeconds()
7229  	unsigned := "{\"kind\":10000,\"content\":\"\"" |
7230  		",\"tags\":" | tags |
7231  		",\"created_at\":" | i64toa(ts) |
7232  		",\"pubkey\":\"" | pubhex | "\"}"
7233  	dom.ConsoleLog("[mute] signing kind 10000 with " | itoa(len(mutes)) | " entries")
7234  	signer.SignEvent(unsigned, func(signed string) {
7235  		if signed == "" {
7236  			dom.ConsoleLog("[mute] sign failed - signer returned empty")
7237  			return
7238  		}
7239  		dom.ConsoleLog("[mute] publishing kind 10000")
7240  		selected := composeSelectedRelays()
7241  		if len(selected) > 0 {
7242  			msg := "[\"PUBLISH_TO\"," | signed | ",["
7243  			for i, u := range selected {
7244  				if i > 0 {
7245  					msg = msg | ","
7246  				}
7247  				msg = msg | jstr(u)
7248  			}
7249  			routeMsg(msg | "]]")
7250  		} else {
7251  			routeMsg("[\"EVENT\"," | signed | "]")
7252  		}
7253  	})
7254  }
7255  
7256  func profileMetaRow(icon, text, link string) (e dom.Element) {
7257  	row := dom.CreateElement("div")
7258  	dom.SetStyle(row, "padding", "4px 0")
7259  	dom.SetStyle(row, "display", "flex")
7260  	dom.SetStyle(row, "alignItems", "center")
7261  	dom.SetStyle(row, "gap", "8px")
7262  
7263  	iconEl := dom.CreateElement("span")
7264  	dom.SetTextContent(iconEl, icon)
7265  	dom.AppendChild(row, iconEl)
7266  
7267  	if link != "" {
7268  		href := link
7269  		if strIndex(href, "://") < 0 {
7270  			href = "https://" | href
7271  		}
7272  		a := dom.CreateElement("a")
7273  		dom.SetAttribute(a, "href", href)
7274  		dom.SetAttribute(a, "target", "_blank")
7275  		dom.SetAttribute(a, "rel", "noopener")
7276  		dom.SetStyle(a, "color", "var(--accent)")
7277  		dom.SetStyle(a, "wordBreak", "break-all")
7278  		dom.SetTextContent(a, text)
7279  		dom.AppendChild(row, a)
7280  	} else {
7281  		span := dom.CreateElement("span")
7282  		dom.SetStyle(span, "color", "var(--fg)")
7283  		dom.SetTextContent(span, text)
7284  		dom.AppendChild(row, span)
7285  	}
7286  	return row
7287  }
7288  
7289  // --- Profile tab functions ---
7290  
7291  func closeProfileNoteSub() {
7292  	if activeProfileNoteSub != "" {
7293  		routeMsg("[\"CLOSE\"," | jstr(activeProfileNoteSub) | "]")
7294  		activeProfileNoteSub = ""
7295  	}
7296  }
7297  
7298  // refreshProfileTab re-renders the active tab if we're viewing this author's profile.
7299  func refreshProfileTab(pk string) {
7300  	if profileViewPK != pk || profileTab == "" {
7301  		return
7302  	}
7303  	// Force re-render by clearing current tab and re-selecting.
7304  	saved := profileTab
7305  	profileTab = ""
7306  	selectProfileTab(saved, pk)
7307  }
7308  
7309  func selectProfileTab(tab, pk string) {
7310  	if tab == profileTab {
7311  		return
7312  	}
7313  	closeProfileNoteSub()
7314  	profileTab = tab
7315  	clearChildren(profileTabContent)
7316  
7317  	for id, btn := range profileTabBtns {
7318  		if id == tab {
7319  			dom.SetStyle(btn, "background", "var(--accent)")
7320  			dom.SetStyle(btn, "color", "#fff")
7321  		} else {
7322  			dom.SetStyle(btn, "background", "transparent")
7323  			dom.SetStyle(btn, "color", "var(--fg)")
7324  		}
7325  	}
7326  
7327  	// Update URL hash to reflect active tab.
7328  	if !navPop && profileViewPK != "" {
7329  		npub := helpers.EncodeNpub(helpers.HexDecode(profileViewPK))
7330  		dom.ReplaceState("/p/" | npub | "#" | tab)
7331  	}
7332  
7333  	switch tab {
7334  	case "notes":
7335  		renderProfileNotes(pk)
7336  	case "follows":
7337  		renderProfileFollows(pk)
7338  	case "relays":
7339  		renderProfileRelays(pk)
7340  	case "mutes":
7341  		renderProfileMutes(pk)
7342  	}
7343  }
7344  
7345  func renderProfileNotes(pk string) {
7346  	profileNotesSeen = map[string]bool{}
7347  	profileSubCounter++
7348  	subID := "pn-" | itoa(profileSubCounter)
7349  	activeProfileNoteSub = subID
7350  	proxyRelays := buildProxy(pk)
7351  	routeMsg(buildProxyMsg(subID,
7352  		"{\"authors\":[" | jstr(pk) | "],\"kinds\":[1],\"limit\":20}",
7353  		proxyRelays))
7354  }
7355  
7356  func renderProfileNote(ev *nostr.Event) {
7357  	if profileTabContent == 0 || profileTab != "notes" {
7358  		return
7359  	}
7360  	if isMuted(ev.PubKey) {
7361  		return
7362  	}
7363  	note := dom.CreateElement("div")
7364  	dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
7365  	dom.SetStyle(note, "padding", "12px 16px")
7366  
7367  	// Header row: author link (left) + relay widget + timestamp (right).
7368  	header := dom.CreateElement("div")
7369  	dom.SetStyle(header, "display", "flex")
7370  	dom.SetStyle(header, "alignItems", "center")
7371  	dom.SetStyle(header, "marginBottom", "4px")
7372  	dom.SetStyle(header, "maxWidth", "65ch")
7373  
7374  	authorLink := dom.CreateElement("div")
7375  	dom.SetStyle(authorLink, "display", "flex")
7376  	dom.SetStyle(authorLink, "alignItems", "center")
7377  	dom.SetStyle(authorLink, "gap", "8px")
7378  	dom.SetStyle(authorLink, "cursor", "pointer")
7379  	headerPK := ev.PubKey
7380  	dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
7381  		showProfile(headerPK)
7382  	}))
7383  
7384  	avatar := dom.CreateElement("img")
7385  	dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
7386  	dom.SetAttribute(avatar, "width", "24")
7387  	dom.SetAttribute(avatar, "height", "24")
7388  	dom.SetStyle(avatar, "borderRadius", "50%")
7389  	dom.SetStyle(avatar, "objectFit", "cover")
7390  	dom.SetStyle(avatar, "flexShrink", "0")
7391  
7392  	nameSpan := dom.CreateElement("span")
7393  	dom.SetStyle(nameSpan, "fontSize", "18px")
7394  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
7395  	dom.SetStyle(nameSpan, "fontWeight", "bold")
7396  	dom.SetStyle(nameSpan, "color", "var(--fg)")
7397  
7398  	pk := ev.PubKey
7399  	if pic, ok := authorPics[pk]; ok && pic != "" {
7400  		setMediaSrc(avatar, pic)
7401  		dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
7402  	} else {
7403  		dom.SetStyle(avatar, "display", "none")
7404  	}
7405  	if name, ok := authorNames[pk]; ok && name != "" {
7406  		dom.SetTextContent(nameSpan, name)
7407  	} else {
7408  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
7409  		if len(npub) > 20 {
7410  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
7411  		}
7412  	}
7413  
7414  	dom.AppendChild(authorLink, avatar)
7415  	dom.AppendChild(authorLink, nameSpan)
7416  	dom.AppendChild(header, authorLink)
7417  
7418  	appendClientTag(header, ev)
7419  
7420  	rw := relayWidget(ev.ID)
7421  	dom.SetStyle(rw, "marginLeft", "auto")
7422  	dom.AppendChild(header, rw)
7423  	if ev.CreatedAt > 0 {
7424  		tsEl := dom.CreateElement("span")
7425  		dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
7426  		dom.SetStyle(tsEl, "fontSize", "11px")
7427  		dom.SetStyle(tsEl, "color", "var(--muted)")
7428  		dom.SetStyle(tsEl, "cursor", "pointer")
7429  		dom.SetStyle(tsEl, "flexShrink", "0")
7430  		evID := ev.ID
7431  		evRootID := getRootID(ev)
7432  		if evRootID == "" {
7433  			evRootID = evID
7434  		}
7435  		dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
7436  			showNoteThread(evRootID, evID)
7437  		}))
7438  		dom.AppendChild(header, tsEl)
7439  	}
7440  	dom.AppendChild(note, header)
7441  
7442  	// Track author link for update when profile arrives.
7443  	if _, cached := authorNames[pk]; !cached {
7444  		pendingNotes[pk] = append(pendingNotes[pk], authorLink)
7445  		if !lookupFetchedK0(pk) {
7446  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
7447  		}
7448  	}
7449  
7450  	// Reply preview.
7451  	addReplyPreview(note, ev)
7452  
7453  	content := dom.CreateElement("div")
7454  	dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
7455  	dom.SetStyle(content, "fontSize", "14px")
7456  	dom.SetStyle(content, "lineHeight", "1.5")
7457  	dom.SetStyle(content, "wordBreak", "break-word")
7458  	dom.SetStyle(content, "maxWidth", "65ch")
7459  	dom.SetStyle(content, "overflow", "hidden")
7460  	dom.SetStyle(content, "maxHeight", "18em")
7461  	setHTMLWithMedia(content, renderMarkdown(ev.Content))
7462  	dom.AppendChild(note, content)
7463  
7464  	more := makeShowMore(content)
7465  	dom.AppendChild(note, buildActionRow(ev, note, content, more))
7466  
7467  	dom.AppendChild(profileTabContent, note)
7468  	attachShowMore(content, more)
7469  	resolveEmbeds()
7470  }
7471  
7472  func renderProfileFollows(pk string) {
7473  	var follows []string
7474  	if pk == pubhex {
7475  		follows = myFollows
7476  	} else {
7477  		follows = profileFollowsCache[pk]
7478  	}
7479  	if len(follows) == 0 {
7480  		empty := dom.CreateElement("div")
7481  		dom.SetTextContent(empty, t("no_follows"))
7482  		dom.SetStyle(empty, "padding", "16px")
7483  		dom.SetStyle(empty, "color", "var(--muted)")
7484  		dom.SetStyle(empty, "fontSize", "13px")
7485  		dom.AppendChild(profileTabContent, empty)
7486  		return
7487  	}
7488  
7489  	countEl := dom.CreateElement("div")
7490  	dom.SetTextContent(countEl, itoa(len(follows)) | " " | t("following"))
7491  	dom.SetStyle(countEl, "padding", "8px 16px")
7492  	dom.SetStyle(countEl, "color", "var(--muted)")
7493  	dom.SetStyle(countEl, "fontSize", "12px")
7494  	dom.AppendChild(profileTabContent, countEl)
7495  
7496  	for i := 0; i < len(follows); i++ {
7497  		fpk := follows[i]
7498  		row := makeProfileRow(fpk)
7499  		dom.AppendChild(profileTabContent, row)
7500  	}
7501  	scheduleTabRetry()
7502  }
7503  
7504  func renderProfileRelays(pk string) {
7505  	relays := profileRelaysCache[pk]
7506  	if len(relays) == 0 {
7507  		empty := dom.CreateElement("div")
7508  		dom.SetTextContent(empty, t("no_relays"))
7509  		dom.SetStyle(empty, "padding", "16px")
7510  		dom.SetStyle(empty, "color", "var(--muted)")
7511  		dom.SetStyle(empty, "fontSize", "13px")
7512  		dom.AppendChild(profileTabContent, empty)
7513  		return
7514  	}
7515  
7516  	for i := 0; i < len(relays); i++ {
7517  		rURL := relays[i]
7518  		row := dom.CreateElement("div")
7519  		dom.SetStyle(row, "padding", "10px 16px")
7520  		dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
7521  		dom.SetStyle(row, "cursor", "pointer")
7522  		dom.SetStyle(row, "fontSize", "13px")
7523  
7524  		urlEl := dom.CreateElement("span")
7525  		dom.SetTextContent(urlEl, rURL)
7526  		dom.SetStyle(urlEl, "color", "var(--accent)")
7527  		dom.AppendChild(row, urlEl)
7528  
7529  		clickURL := rURL
7530  		dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
7531  			showRelayInfo(clickURL)
7532  		}))
7533  		dom.AppendChild(profileTabContent, row)
7534  	}
7535  }
7536  
7537  func renderProfileMutes(pk string) {
7538  	var mutes []string
7539  	if pk == pubhex {
7540  		mutes = myMutes
7541  	} else {
7542  		mutes = profileMutesCache[pk]
7543  	}
7544  	if len(mutes) == 0 {
7545  		empty := dom.CreateElement("div")
7546  		dom.SetTextContent(empty, t("no_mutes"))
7547  		dom.SetStyle(empty, "padding", "16px")
7548  		dom.SetStyle(empty, "color", "var(--muted)")
7549  		dom.SetStyle(empty, "fontSize", "13px")
7550  		dom.AppendChild(profileTabContent, empty)
7551  		return
7552  	}
7553  
7554  	countEl := dom.CreateElement("div")
7555  	dom.SetTextContent(countEl, itoa(len(mutes)) | " " | t("muted"))
7556  	dom.SetStyle(countEl, "padding", "8px 16px")
7557  	dom.SetStyle(countEl, "color", "var(--muted)")
7558  	dom.SetStyle(countEl, "fontSize", "12px")
7559  	dom.AppendChild(profileTabContent, countEl)
7560  
7561  	for i := 0; i < len(mutes); i++ {
7562  		mpk := mutes[i]
7563  		row := makeProfileRow(mpk)
7564  		dom.AppendChild(profileTabContent, row)
7565  	}
7566  	scheduleTabRetry()
7567  }
7568  
7569  func makeProfileRow(pk string) (e dom.Element) {
7570  	row := dom.CreateElement("div")
7571  	dom.SetStyle(row, "display", "flex")
7572  	dom.SetStyle(row, "alignItems", "center")
7573  	dom.SetStyle(row, "gap", "10px")
7574  	dom.SetStyle(row, "padding", "10px 16px")
7575  	dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
7576  	dom.SetStyle(row, "cursor", "pointer")
7577  
7578  	av := dom.CreateElement("img")
7579  	dom.SetAttribute(av, "referrerpolicy", "no-referrer")
7580  	dom.SetAttribute(av, "width", "32")
7581  	dom.SetAttribute(av, "height", "32")
7582  	dom.SetStyle(av, "borderRadius", "50%")
7583  	dom.SetStyle(av, "objectFit", "cover")
7584  	dom.SetStyle(av, "flexShrink", "0")
7585  	if pic, ok := authorPics[pk]; ok && pic != "" {
7586  		setMediaSrc(av, pic)
7587  	} else {
7588  		dom.SetStyle(av, "display", "none")
7589  	}
7590  	dom.SetAttribute(av, "onerror", "this.style.display='none'")
7591  	dom.AppendChild(row, av)
7592  
7593  	nameSpan := dom.CreateElement("span")
7594  	dom.SetStyle(nameSpan, "fontSize", "14px")
7595  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
7596  	if name, ok := authorNames[pk]; ok && name != "" {
7597  		dom.SetTextContent(nameSpan, name)
7598  	} else {
7599  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
7600  		if len(npub) > 20 {
7601  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
7602  		}
7603  	}
7604  	dom.AppendChild(row, nameSpan)
7605  
7606  	rowPK := pk
7607  	dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
7608  		showProfile(rowPK)
7609  	}))
7610  
7611  	if _, cached := authorNames[pk]; !cached {
7612  		pendingNotes[pk] = append(pendingNotes[pk], row)
7613  		if !lookupFetchedK0(pk) {
7614  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
7615  		}
7616  	}
7617  
7618  	return row
7619  }
7620  
7621  // --- Relay info page ---
7622  
7623  func showRelayInfo(url string) {
7624  	profileViewPK = ""
7625  	closeProfileNoteSub()
7626  	// Hide cached profile wrappers but keep them.
7627  	for _, w := range profileWrappers {
7628  		dom.SetStyle(w, "display", "none")
7629  	}
7630  	// Remove old relay info page if any.
7631  	if relayInfoEl != 0 {
7632  		dom.RemoveChild(profilePage, relayInfoEl)
7633  		relayInfoEl = 0
7634  	}
7635  	relayInfoEl = dom.CreateElement("div")
7636  	dom.AppendChild(profilePage, relayInfoEl)
7637  
7638  	hdr := dom.CreateElement("div")
7639  	dom.SetStyle(hdr, "display", "flex")
7640  	dom.SetStyle(hdr, "alignItems", "center")
7641  	dom.SetStyle(hdr, "gap", "10px")
7642  	dom.SetStyle(hdr, "padding", "16px")
7643  	dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
7644  
7645  	backBtn := dom.CreateElement("button")
7646  	dom.SetInnerHTML(backBtn, "&#x2190;")
7647  	dom.SetStyle(backBtn, "background", "none")
7648  	dom.SetStyle(backBtn, "border", "none")
7649  	dom.SetStyle(backBtn, "fontSize", "20px")
7650  	dom.SetStyle(backBtn, "cursor", "pointer")
7651  	dom.SetStyle(backBtn, "color", "var(--fg)")
7652  	dom.SetStyle(backBtn, "padding", "0")
7653  	dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() {
7654  		switchPage("feed")
7655  	}))
7656  	dom.AppendChild(hdr, backBtn)
7657  
7658  	urlEl := dom.CreateElement("span")
7659  	dom.SetTextContent(urlEl, url)
7660  	dom.SetStyle(urlEl, "fontWeight", "bold")
7661  	dom.SetStyle(urlEl, "fontSize", "14px")
7662  	dom.SetStyle(urlEl, "wordBreak", "break-all")
7663  	dom.AppendChild(hdr, urlEl)
7664  	dom.AppendChild(relayInfoEl, hdr)
7665  
7666  	loading := dom.CreateElement("div")
7667  	dom.SetTextContent(loading, t("loading"))
7668  	dom.SetStyle(loading, "padding", "16px")
7669  	dom.SetStyle(loading, "color", "var(--muted)")
7670  	dom.AppendChild(relayInfoEl, loading)
7671  
7672  	activePage = ""
7673  	switchPage("profile")
7674  	dom.SetTextContent(pageTitleEl, t("relay_info"))
7675  
7676  	// Convert wss→https for NIP-11 HTTP fetch.
7677  	httpURL := url
7678  	if len(httpURL) > 6 && httpURL[:6] == "wss://" {
7679  		httpURL = "https://" | httpURL[6:]
7680  	} else if len(httpURL) > 5 && httpURL[:5] == "ws://" {
7681  		httpURL = "http://" | httpURL[5:]
7682  	}
7683  
7684  	dom.FetchRelayInfo(httpURL, func(body string) {
7685  		dom.RemoveChild(profilePage, loading)
7686  		if body == "" {
7687  			errEl := dom.CreateElement("div")
7688  			dom.SetTextContent(errEl, t("relay_fail"))
7689  			dom.SetStyle(errEl, "padding", "16px")
7690  			dom.SetStyle(errEl, "color", "#e55")
7691  			dom.AppendChild(relayInfoEl, errEl)
7692  			return
7693  		}
7694  		renderRelayInfoBody(body)
7695  	})
7696  }
7697  
7698  func renderRelayInfoBody(body string) {
7699  	container := dom.CreateElement("div")
7700  	dom.SetStyle(container, "padding", "16px")
7701  
7702  	name := helpers.JsonGetString(body, "name")
7703  	desc := helpers.JsonGetString(body, "description")
7704  	pk := helpers.JsonGetString(body, "pubkey")
7705  	contact := helpers.JsonGetString(body, "contact")
7706  	software := helpers.JsonGetString(body, "software")
7707  	ver := helpers.JsonGetString(body, "version")
7708  
7709  	if name != "" {
7710  		el := dom.CreateElement("div")
7711  		dom.SetTextContent(el, name)
7712  		dom.SetStyle(el, "fontSize", "20px")
7713  		dom.SetStyle(el, "fontWeight", "bold")
7714  		dom.SetStyle(el, "marginBottom", "8px")
7715  		dom.SetStyle(el, "fontFamily", "system-ui, sans-serif")
7716  		dom.AppendChild(container, el)
7717  	}
7718  
7719  	if desc != "" {
7720  		el := dom.CreateElement("div")
7721  		dom.SetStyle(el, "fontSize", "14px")
7722  		dom.SetStyle(el, "lineHeight", "1.5")
7723  		dom.SetStyle(el, "marginBottom", "12px")
7724  		dom.SetStyle(el, "wordBreak", "break-word")
7725  		dom.AppendChild(container, el)
7726  		setHTMLWithMedia(el, renderMarkdown(desc))
7727  	}
7728  
7729  	if contact != "" {
7730  		dom.AppendChild(container, profileMetaRow("@", contact, ""))
7731  	}
7732  	if pk != "" {
7733  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
7734  		short := npub
7735  		if len(short) > 20 {
7736  			short = short[:16] | "..." | short[len(short)-8:]
7737  		}
7738  		dom.AppendChild(container, profileMetaRow("pk", short, ""))
7739  	}
7740  	if software != "" {
7741  		label := software
7742  		if ver != "" {
7743  			label = label | " " | ver
7744  		}
7745  		dom.AppendChild(container, profileMetaRow("sw", label, ""))
7746  	}
7747  
7748  	dom.AppendChild(relayInfoEl, container)
7749  }
7750  
7751  // --- Messaging ---
7752  
7753  func relayURLsJSON() (s string) {
7754  	msg := "["
7755  	for i, url := range relayURLs {
7756  		if i > 0 {
7757  			msg = msg | ","
7758  		}
7759  		msg = msg | jstr(url)
7760  	}
7761  	return msg | "]"
7762  }
7763  
7764  func readRelayURLsJSON() (s string) {
7765  	msg := "["
7766  	first := true
7767  	for i, url := range relayURLs {
7768  		role := "both"
7769  		if i < len(relayRole) {
7770  			role = relayRole[i]
7771  		}
7772  		if role == "write" {
7773  			continue
7774  		}
7775  		if !first {
7776  			msg = msg | ","
7777  		}
7778  		msg = msg | jstr(url)
7779  		first = false
7780  	}
7781  	return msg | "]"
7782  }
7783  
7784  func formatTime(ts int64) (s string) {
7785  	if ts == 0 {
7786  		return ""
7787  	}
7788  	d := dom.NowSeconds() - ts
7789  	if d < 0 {
7790  		d = 0
7791  	}
7792  	if d < 60 {
7793  		return "now"
7794  	}
7795  	if d < 3600 {
7796  		return itoa(int32(d/60)) | "m ago"
7797  	}
7798  	if d < 86400 {
7799  		return itoa(int32(d/3600)) | "h ago"
7800  	}
7801  	if d < 2592000 {
7802  		return itoa(int32(d/86400)) | "d ago"
7803  	}
7804  	if d < 31536000 {
7805  		return itoa(int32(d/2592000)) | "mo ago"
7806  	}
7807  	return itoa(int32(d/31536000)) | "y ago"
7808  }
7809  
7810  func dismissHoverCard() {
7811  	if hoverCardEl != 0 {
7812  		dom.Remove(hoverCardEl)
7813  		hoverCardEl = 0
7814  		hoverCardParent = 0
7815  		hoverCardPK = ""
7816  		hoverCardHover = false
7817  	}
7818  	if hoverCardTimer != 0 {
7819  		dom.ClearTimeout(hoverCardTimer)
7820  		hoverCardTimer = 0
7821  	}
7822  	if hoverCardDismiss != 0 {
7823  		dom.ClearTimeout(hoverCardDismiss)
7824  		hoverCardDismiss = 0
7825  	}
7826  }
7827  
7828  func scheduleDismissHoverCard() {
7829  	if hoverCardDismiss != 0 {
7830  		dom.ClearTimeout(hoverCardDismiss)
7831  	}
7832  	hoverCardDismiss = dom.SetTimeout(func() {
7833  		hoverCardDismiss = 0
7834  		if !hoverCardHover {
7835  			dismissHoverCard()
7836  		}
7837  	}, 300)
7838  }
7839  
7840  func showHoverCard(pk string, anchor dom.Element) {
7841  	if pk == pubhex {
7842  		return // don't show hover card for self
7843  	}
7844  	dismissHoverCard()
7845  	hoverCardPK = pk
7846  
7847  	card := dom.CreateElement("div")
7848  	dom.SetStyle(card, "position", "fixed")
7849  	dom.SetStyle(card, "zIndex", "2000")
7850  	dom.SetStyle(card, "width", "280px")
7851  	dom.SetStyle(card, "maxWidth", "calc(100vw - 16px)")
7852  	dom.SetStyle(card, "background", "var(--bg)")
7853  	dom.SetStyle(card, "border", "1px solid var(--border)")
7854  	dom.SetStyle(card, "borderRadius", "8px")
7855  	dom.SetStyle(card, "boxShadow", "0 4px 16px rgba(0,0,0,0.3)")
7856  	dom.SetStyle(card, "overflow", "hidden")
7857  	dom.SetStyle(card, "fontFamily", "'Fira Code', monospace")
7858  
7859  	// Banner.
7860  	content := profileContentCache[pk]
7861  	if content != "" {
7862  		banner := helpers.JsonGetString(content, "banner")
7863  		if banner != "" {
7864  			bannerEl := dom.CreateElement("img")
7865  			dom.SetAttribute(bannerEl, "referrerpolicy", "no-referrer")
7866  			setMediaSrc(bannerEl, banner)
7867  			dom.SetStyle(bannerEl, "width", "100%")
7868  			dom.SetStyle(bannerEl, "height", "80px")
7869  			dom.SetStyle(bannerEl, "objectFit", "cover")
7870  			dom.SetStyle(bannerEl, "display", "block")
7871  			dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'")
7872  			dom.AppendChild(card, bannerEl)
7873  		}
7874  	}
7875  
7876  	// Avatar + name row.
7877  	infoRow := dom.CreateElement("div")
7878  	dom.SetStyle(infoRow, "display", "flex")
7879  	dom.SetStyle(infoRow, "alignItems", "center")
7880  	dom.SetStyle(infoRow, "gap", "8px")
7881  	dom.SetStyle(infoRow, "padding", "8px 10px")
7882  
7883  	if pic, ok := authorPics[pk]; ok && pic != "" {
7884  		av := dom.CreateElement("img")
7885  		dom.SetAttribute(av, "referrerpolicy", "no-referrer")
7886  		setMediaSrc(av, pic)
7887  		dom.SetAttribute(av, "width", "36")
7888  		dom.SetAttribute(av, "height", "36")
7889  		dom.SetStyle(av, "borderRadius", "50%")
7890  		dom.SetStyle(av, "objectFit", "cover")
7891  		dom.SetStyle(av, "flexShrink", "0")
7892  		dom.SetAttribute(av, "onerror", "this.style.display='none'")
7893  		dom.AppendChild(infoRow, av)
7894  	}
7895  
7896  	nameEl := dom.CreateElement("span")
7897  	dom.SetStyle(nameEl, "fontWeight", "bold")
7898  	dom.SetStyle(nameEl, "fontSize", "13px")
7899  	dom.SetStyle(nameEl, "color", "var(--fg)")
7900  	if name, ok := authorNames[pk]; ok && name != "" {
7901  		dom.SetTextContent(nameEl, name)
7902  	} else {
7903  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
7904  		if len(npub) > 20 {
7905  			dom.SetTextContent(nameEl, npub[:12] | "..." | npub[len(npub)-4:])
7906  		}
7907  	}
7908  	dom.AppendChild(infoRow, nameEl)
7909  	dom.AppendChild(card, infoRow)
7910  
7911  	// Button row: follow + mute.
7912  	if signer.HasSigner() && pubhex != "" {
7913  		btnRow := dom.CreateElement("div")
7914  		dom.SetStyle(btnRow, "display", "flex")
7915  		dom.SetStyle(btnRow, "gap", "6px")
7916  		dom.SetStyle(btnRow, "padding", "0 10px 8px")
7917  
7918  		followBtn := dom.CreateElement("button")
7919  		dom.SetStyle(followBtn, "padding", "4px 10px")
7920  		dom.SetStyle(followBtn, "fontFamily", "'Fira Code', monospace")
7921  		dom.SetStyle(followBtn, "fontSize", "11px")
7922  		dom.SetStyle(followBtn, "borderRadius", "4px")
7923  		dom.SetStyle(followBtn, "cursor", "pointer")
7924  		isFollowing := false
7925  		if len(myFollows) > 0 {
7926  			for _, fpk := range myFollows {
7927  				if fpk == pk {
7928  					isFollowing = true
7929  					break
7930  				}
7931  			}
7932  		}
7933  		if isFollowing {
7934  			dom.SetTextContent(followBtn, "followed")
7935  			dom.SetStyle(followBtn, "background", "var(--bg2)")
7936  			dom.SetStyle(followBtn, "color", "var(--fg)")
7937  			dom.SetStyle(followBtn, "border", "1px solid var(--border)")
7938  		} else {
7939  			dom.SetTextContent(followBtn, "follow")
7940  			dom.SetStyle(followBtn, "background", "var(--accent)")
7941  			dom.SetStyle(followBtn, "color", "#fff")
7942  			dom.SetStyle(followBtn, "border", "none")
7943  		}
7944  		dom.AppendChild(btnRow, followBtn)
7945  
7946  		muteBtn := dom.CreateElement("button")
7947  		dom.SetStyle(muteBtn, "padding", "4px 10px")
7948  		dom.SetStyle(muteBtn, "fontFamily", "'Fira Code', monospace")
7949  		dom.SetStyle(muteBtn, "fontSize", "11px")
7950  		dom.SetStyle(muteBtn, "borderRadius", "4px")
7951  		dom.SetStyle(muteBtn, "cursor", "pointer")
7952  		if isMuted(pk) {
7953  			dom.SetTextContent(muteBtn, "muted")
7954  			dom.SetStyle(muteBtn, "background", "#c33")
7955  			dom.SetStyle(muteBtn, "color", "#fff")
7956  			dom.SetStyle(muteBtn, "border", "none")
7957  		} else {
7958  			dom.SetTextContent(muteBtn, "mute")
7959  			dom.SetStyle(muteBtn, "background", "var(--bg2)")
7960  			dom.SetStyle(muteBtn, "color", "var(--fg)")
7961  			dom.SetStyle(muteBtn, "border", "1px solid var(--border)")
7962  		}
7963  
7964  		hcPK := pk
7965  		hcFollow := followBtn
7966  		hcMute := muteBtn
7967  		dom.AddEventListener(followBtn, "click", dom.RegisterCallback(func() {
7968  			toggleFollow(hcPK, hcFollow, hcMute)
7969  		}))
7970  		dom.AddEventListener(muteBtn, "click", dom.RegisterCallback(func() {
7971  			toggleMute(hcPK, hcMute, hcFollow)
7972  		}))
7973  		dom.AppendChild(btnRow, muteBtn)
7974  
7975  		dom.AppendChild(card, btnRow)
7976  	}
7977  
7978  	hoverCardHover = false
7979  	dom.AddEventListener(card, "mouseenter", dom.RegisterCallback(func() {
7980  		hoverCardHover = true
7981  		if hoverCardDismiss != 0 {
7982  			dom.ClearTimeout(hoverCardDismiss)
7983  			hoverCardDismiss = 0
7984  		}
7985  	}))
7986  	dom.AddEventListener(card, "mouseleave", dom.RegisterCallback(func() {
7987  		hoverCardHover = false
7988  		scheduleDismissHoverCard()
7989  	}))
7990  
7991  	hoverCardEl = card
7992  	hoverCardParent = dom.Body()
7993  	dom.AppendChild(dom.Body(), card)
7994  	dom.GetBoundingRect(anchor, func(left, top, right, bottom, width, height int32) {
7995  		vw := dom.GetViewportWidth()
7996  		cardW := 280
7997  		if cardW > vw-16 {
7998  			cardW = vw - 16
7999  		}
8000  		x := left
8001  		if x+cardW > vw-8 {
8002  			x = vw - 8 - cardW
8003  		}
8004  		if x < 8 {
8005  			x = 8
8006  		}
8007  		dom.SetStyle(card, "left", itoa(x) | "px")
8008  		dom.SetStyle(card, "top", itoa(bottom) | "px")
8009  	})
8010  }
8011  
8012  func attachHoverCard(authorLink dom.Element, pk string) {
8013  	dom.SetStyle(authorLink, "position", "relative")
8014  	hcPK := pk
8015  	dom.AddEventListener(authorLink, "mouseenter", dom.RegisterCallback(func() {
8016  		if hoverCardTimer != 0 {
8017  			dom.ClearTimeout(hoverCardTimer)
8018  		}
8019  		if hoverCardDismiss != 0 {
8020  			dom.ClearTimeout(hoverCardDismiss)
8021  			hoverCardDismiss = 0
8022  		}
8023  		capPK := hcPK
8024  		capAnchor := authorLink
8025  		hoverCardTimer = dom.SetTimeout(func() {
8026  			hoverCardTimer = 0
8027  			showHoverCard(capPK, capAnchor)
8028  		}, 400)
8029  	}))
8030  	dom.AddEventListener(authorLink, "mouseleave", dom.RegisterCallback(func() {
8031  		if hoverCardTimer != 0 {
8032  			dom.ClearTimeout(hoverCardTimer)
8033  			hoverCardTimer = 0
8034  		}
8035  		scheduleDismissHoverCard()
8036  	}))
8037  }
8038  
8039  // relayWidget creates a hover tooltip showing which relays delivered a note.
8040  func relayWidget(evID string) (e dom.Element) {
8041  	wrap := dom.CreateElement("span")
8042  	dom.SetStyle(wrap, "position", "relative")
8043  	dom.SetStyle(wrap, "flexShrink", "0")
8044  	dom.SetStyle(wrap, "marginRight", "6px")
8045  
8046  	btn := dom.CreateElement("span")
8047  	dom.SetTextContent(btn, "\xf0\x9f\x93\xa1") // 📡
8048  	dom.SetStyle(btn, "fontSize", "10px")
8049  	dom.SetStyle(btn, "cursor", "pointer")
8050  	dom.SetStyle(btn, "opacity", "0.6")
8051  	dom.AppendChild(wrap, btn)
8052  
8053  	var tip dom.Element
8054  	capturedID := evID
8055  
8056  	dom.AddEventListener(wrap, "click", dom.RegisterCallback(func() {
8057  		showRelayModal(capturedID)
8058  	}))
8059  
8060  	dom.AddEventListener(wrap, "mouseenter", dom.RegisterCallback(func() {
8061  		if tip != 0 {
8062  			return
8063  		}
8064  		urls := eventRelays[capturedID]
8065  
8066  		tip = dom.CreateElement("div")
8067  		dom.SetStyle(tip, "position", "absolute")
8068  		dom.SetStyle(tip, "top", "22px")
8069  		dom.SetStyle(tip, "right", "0")
8070  		dom.SetStyle(tip, "zIndex", "1000")
8071  		dom.SetStyle(tip, "background", "var(--bg)")
8072  		dom.SetStyle(tip, "border", "1px solid var(--border)")
8073  		dom.SetStyle(tip, "borderRadius", "6px")
8074  		dom.SetStyle(tip, "padding", "6px 10px")
8075  		dom.SetStyle(tip, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)")
8076  		dom.SetStyle(tip, "fontSize", "11px")
8077  		dom.SetStyle(tip, "whiteSpace", "nowrap")
8078  		dom.SetStyle(tip, "fontFamily", "'Fira Code', monospace")
8079  		dom.SetStyle(tip, "pointerEvents", "none")
8080  
8081  		if len(urls) == 0 {
8082  			line := dom.CreateElement("div")
8083  			dom.SetStyle(line, "color", "var(--muted)")
8084  			dom.SetTextContent(line, "(no relay data)")
8085  			dom.AppendChild(tip, line)
8086  		} else {
8087  			for _, u := range urls {
8088  				line := dom.CreateElement("div")
8089  				dom.SetStyle(line, "padding", "1px 0")
8090  				dom.SetStyle(line, "color", "var(--fg)")
8091  				dom.SetTextContent(line, u)
8092  				dom.AppendChild(tip, line)
8093  			}
8094  		}
8095  		dom.AppendChild(wrap, tip)
8096  	}))
8097  
8098  	dom.AddEventListener(wrap, "mouseleave", dom.RegisterCallback(func() {
8099  		if tip != 0 {
8100  			dom.RemoveChild(wrap, tip)
8101  			tip = 0
8102  		}
8103  	}))
8104  
8105  	return wrap
8106  }
8107  
8108  func showRelayModal(evID string) {
8109  	overlay := dom.CreateElement("div")
8110  	dom.SetStyle(overlay, "position", "fixed")
8111  	dom.SetStyle(overlay, "top", "0")
8112  	dom.SetStyle(overlay, "left", "0")
8113  	dom.SetStyle(overlay, "width", "100%")
8114  	dom.SetStyle(overlay, "height", "100%")
8115  	dom.SetStyle(overlay, "background", "rgba(0,0,0,0.5)")
8116  	dom.SetStyle(overlay, "zIndex", "500")
8117  	dom.SetStyle(overlay, "display", "flex")
8118  	dom.SetStyle(overlay, "alignItems", "center")
8119  	dom.SetStyle(overlay, "justifyContent", "center")
8120  
8121  	modal := dom.CreateElement("div")
8122  	dom.SetStyle(modal, "background", "var(--bg)")
8123  	dom.SetStyle(modal, "border", "1px solid var(--border)")
8124  	dom.SetStyle(modal, "borderRadius", "8px")
8125  	dom.SetStyle(modal, "maxWidth", "90vw")
8126  	dom.SetStyle(modal, "maxHeight", "70vh")
8127  	dom.SetStyle(modal, "display", "flex")
8128  	dom.SetStyle(modal, "flexDirection", "column")
8129  	dom.SetStyle(modal, "boxShadow", "0 4px 24px rgba(0,0,0,0.3)")
8130  
8131  	// Header.
8132  	hdr := dom.CreateElement("div")
8133  	dom.SetStyle(hdr, "display", "flex")
8134  	dom.SetStyle(hdr, "alignItems", "center")
8135  	dom.SetStyle(hdr, "justifyContent", "space-between")
8136  	dom.SetStyle(hdr, "padding", "12px 16px")
8137  	dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
8138  
8139  	title := dom.CreateElement("span")
8140  	dom.SetTextContent(title, "note seen on:")
8141  	dom.SetStyle(title, "fontWeight", "bold")
8142  	dom.SetStyle(title, "fontSize", "14px")
8143  	dom.AppendChild(hdr, title)
8144  
8145  	closeBtn := dom.CreateElement("button")
8146  	dom.SetTextContent(closeBtn, "\xc3\x97") // ×
8147  	dom.SetStyle(closeBtn, "background", "transparent")
8148  	dom.SetStyle(closeBtn, "border", "none")
8149  	dom.SetStyle(closeBtn, "fontSize", "20px")
8150  	dom.SetStyle(closeBtn, "cursor", "pointer")
8151  	dom.SetStyle(closeBtn, "color", "var(--muted)")
8152  	dom.SetStyle(closeBtn, "padding", "0 4px")
8153  	dom.AppendChild(hdr, closeBtn)
8154  	dom.AppendChild(modal, hdr)
8155  
8156  	// Scrollable list - rebuilt by renderList() on open and on SEEN_ON updates.
8157  	list := dom.CreateElement("div")
8158  	dom.SetStyle(list, "overflowY", "auto")
8159  	dom.SetStyle(list, "padding", "12px 16px")
8160  	dom.AppendChild(modal, list)
8161  
8162  	renderList := func() {
8163  		dom.ReleaseChildren(list)
8164  		dom.SetInnerHTML(list, "")
8165  		urls := eventRelays[evID]
8166  		if len(urls) == 0 {
8167  			line := dom.CreateElement("div")
8168  			dom.SetStyle(line, "color", "var(--muted)")
8169  			dom.SetStyle(line, "fontSize", "13px")
8170  			dom.SetTextContent(line, "(no relay data yet - waiting for OK)")
8171  			dom.AppendChild(list, line)
8172  			return
8173  		}
8174  		for _, u := range urls {
8175  			row := dom.CreateElement("div")
8176  			dom.SetStyle(row, "padding", "6px 0")
8177  			dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
8178  			dom.SetStyle(row, "fontSize", "13px")
8179  			dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
8180  			dom.SetStyle(row, "wordBreak", "break-all")
8181  			dom.SetTextContent(row, u)
8182  			dom.AppendChild(list, row)
8183  		}
8184  	}
8185  	renderList()
8186  	seenOnSubs[evID] = renderList
8187  
8188  	// Rebroadcast button - publish this event to all write relays.
8189  	if pubhex != "" {
8190  		ev, _ := lookupEvent(evID)
8191  		if ev != nil {
8192  			footer := dom.CreateElement("div")
8193  			dom.SetStyle(footer, "padding", "8px 16px 12px")
8194  			dom.SetStyle(footer, "borderTop", "1px solid var(--border)")
8195  			rbtn := dom.CreateElement("span")
8196  			dom.SetTextContent(rbtn, "rebroadcast to all relays")
8197  			dom.SetStyle(rbtn, "cursor", "pointer")
8198  			dom.SetStyle(rbtn, "color", "var(--accent)")
8199  			dom.SetStyle(rbtn, "fontSize", "13px")
8200  			dom.SetStyle(rbtn, "fontFamily", "'Fira Code', monospace")
8201  			evJSON := ev.ToJSON()
8202  			capturedEvID := ev.ID
8203  			dom.AddEventListener(rbtn, "click", dom.RegisterCallback(func() {
8204  				dom.SetTextContent(rbtn, "sending...")
8205  				dom.SetStyle(rbtn, "color", "var(--muted)")
8206  				dom.SetStyle(rbtn, "cursor", "default")
8207  				msg := "[\"PUBLISH_TO\"," | evJSON | ",["
8208  				relayList := ""
8209  				for i, url := range relayURLs {
8210  					if i > 0 {
8211  						msg = msg | ","
8212  						relayList = relayList | "\n"
8213  					}
8214  					msg = msg | jstr(url)
8215  					relayList = relayList | url
8216  				}
8217  				routeMsg(msg | "]]")
8218  				logActivity("rebroadcast", capturedEvID[:12] | " to " | itoa(len(relayURLs)) | " relays", "event " | capturedEvID | "\nrelays:\n" | relayList)
8219  				dom.SetTimeout(func() {
8220  					dom.SetTextContent(rbtn, "sent \u2713")
8221  				}, 500)
8222  			}))
8223  			dom.AppendChild(footer, rbtn)
8224  			dom.AppendChild(modal, footer)
8225  		}
8226  	}
8227  
8228  	dom.AppendChild(overlay, modal)
8229  
8230  	closeFn := dom.RegisterCallback(func() {
8231  		delete(seenOnSubs, evID)
8232  		dom.RemoveChild(dom.Body(), overlay)
8233  	})
8234  	dom.AddEventListener(closeBtn, "click", closeFn)
8235  	dom.AddSelfEventListener(overlay, "click", closeFn)
8236  
8237  	dom.AppendChild(dom.Body(), overlay)
8238  }
8239  
8240  // --- Emoji data ---
8241  // emojiList is defined in emoji_data.mx (1761 entries from Unicode 16.0).
8242  
8243  type emojiEntry struct {
8244  	emoji string
8245  	name  string
8246  }
8247  
8248  var emojiByName map[string]string
8249  
8250  func initEmoji() {
8251  	emojiByName = map[string]string{}
8252  	for _, e := range emojiList {
8253  		emojiByName[":" | e.name | ":"] = e.emoji
8254  	}
8255  }
8256  
8257  // replaceEmojiCodes replaces :name: shortcodes in text with emoji characters.
8258  func replaceEmojiCodes(s string) (sv string) {
8259  	out := ""
8260  	start := 0
8261  	i := 0
8262  	for i < len(s) {
8263  		if s[i] == ':' {
8264  			end := -1
8265  			for j := i + 1; j < len(s) && j < i+30; j++ {
8266  				if s[j] == ':' {
8267  					end = j
8268  					break
8269  				}
8270  				if s[j] == ' ' || s[j] == '\n' {
8271  					break
8272  				}
8273  			}
8274  			if end > 0 {
8275  				code := s[i : end+1]
8276  				if emoji, ok := emojiByName[code]; ok {
8277  					out = out | s[start:i] | emoji
8278  					i = end + 1
8279  					start = i
8280  					continue
8281  				}
8282  			}
8283  		}
8284  		i++
8285  	}
8286  	return out | s[start:]
8287  }
8288  
8289  // --- Action button row ---
8290  
8291  type zapRowPending struct {
8292  	row dom.Element
8293  	ev  *nostr.Event
8294  }
8295  
8296  // attachZapButton appends the zap button + total span to the end of an
8297  // action button row, and registers the total element for 9735 updates.
8298  func attachZapButton(row dom.Element, ev *nostr.Event) {
8299  	zapWrap := dom.CreateElement("span")
8300  	dom.SetStyle(zapWrap, "display", "inline-flex")
8301  	dom.SetStyle(zapWrap, "alignItems", "center")
8302  	dom.SetStyle(zapWrap, "gap", "4px")
8303  	zapBtn := actionBtn(svgZap)
8304  	capturedZapEv := ev
8305  	dom.AddEventListener(zapBtn, "click", dom.RegisterCallback(func() {
8306  		showZapModal(capturedZapEv)
8307  	}))
8308  	dom.AppendChild(zapWrap, zapBtn)
8309  	zapTotalEl := dom.CreateElement("span")
8310  	dom.SetStyle(zapTotalEl, "fontSize", "12px")
8311  	dom.SetStyle(zapTotalEl, "color", "var(--muted)")
8312  	dom.SetStyle(zapTotalEl, "whiteSpace", "nowrap")
8313  	if msats, ok := noteZapTotals[ev.ID]; ok && msats > 0 {
8314  		dom.SetTextContent(zapTotalEl, "\xe2\x9a\xa1 " | i64toa(msats/1000) | " sats")
8315  	}
8316  	dom.AppendChild(zapWrap, zapTotalEl)
8317  	noteZapEls[ev.ID] = zapTotalEl
8318  	moreEl := dom.QuerySelectorFrom(row, "[data-more]")
8319  	if moreEl != 0 {
8320  		dom.InsertBefore(row, zapWrap, moreEl)
8321  	} else {
8322  		dom.AppendChild(row, zapWrap)
8323  	}
8324  }
8325  
8326  // applyZapButtonsForAuthor injects zap buttons into any action rows that
8327  // were built before this author's lud16 was known.
8328  func applyZapButtonsForAuthor(pk string) {
8329  	pending, ok := pendingZapRows[pk]
8330  	if !ok {
8331  		return
8332  	}
8333  	delete(pendingZapRows, pk)
8334  	for _, p := range pending {
8335  		attachZapButton(p.row, p.ev)
8336  	}
8337  }
8338  
8339  func buildActionRow(ev *nostr.Event, noteEl dom.Element, contentEl dom.Element, moreEl dom.Element) (e dom.Element) {
8340  	wrap := dom.CreateElement("div")
8341  	dom.SetStyle(wrap, "marginTop", "8px")
8342  	dom.SetStyle(wrap, "maxWidth", "65ch")
8343  
8344  	// Cache the event for repost/quote content.
8345  	setEvent(ev.ID, ev)
8346  	fillEmbed(ev)
8347  
8348  	// Reaction display - own line above buttons.
8349  	reactDisplay := dom.CreateElement("div")
8350  	dom.SetStyle(reactDisplay, "display", "flex")
8351  	dom.SetStyle(reactDisplay, "flexWrap", "wrap")
8352  	dom.SetStyle(reactDisplay, "alignItems", "center")
8353  	dom.SetStyle(reactDisplay, "gap", "4px")
8354  	dom.SetStyle(reactDisplay, "fontSize", "12px")
8355  	dom.SetStyle(reactDisplay, "marginBottom", "4px")
8356  	if _, ok := noteReactionMap[ev.ID]; ok {
8357  		fillReactionDisplay(reactDisplay, ev.ID)
8358  	}
8359  	noteReactionEls[ev.ID] = reactDisplay
8360  	dom.AppendChild(wrap, reactDisplay)
8361  
8362  	// Button row.
8363  	row := dom.CreateElement("div")
8364  	dom.SetStyle(row, "display", "flex")
8365  	dom.SetStyle(row, "alignItems", "center")
8366  	dom.SetStyle(row, "gap", "16px")
8367  
8368  	// Reply button.
8369  	replyBtn := actionBtn(svgReply)
8370  	capturedEv1 := ev
8371  	capturedNote1 := noteEl
8372  	dom.AddEventListener(replyBtn, "click", dom.RegisterCallback(func() {
8373  		showEditor("reply", capturedEv1, capturedNote1)
8374  	}))
8375  	dom.AppendChild(row, replyBtn)
8376  
8377  	// Quote button.
8378  	quoteBtn := actionBtn(svgQuote)
8379  	capturedEv2 := ev
8380  	capturedNote2 := noteEl
8381  	dom.AddEventListener(quoteBtn, "click", dom.RegisterCallback(func() {
8382  		showEditor("quote", capturedEv2, capturedNote2)
8383  	}))
8384  	dom.AppendChild(row, quoteBtn)
8385  
8386  	// Repost button + counter.
8387  	repostWrap := dom.CreateElement("span")
8388  	dom.SetStyle(repostWrap, "display", "flex")
8389  	dom.SetStyle(repostWrap, "alignItems", "center")
8390  	dom.SetStyle(repostWrap, "gap", "4px")
8391  	repostBtn := actionBtn(svgRepost)
8392  	capturedEv3 := ev
8393  	dom.AddEventListener(repostBtn, "click", dom.RegisterCallback(func() {
8394  		onRepostClick(capturedEv3)
8395  	}))
8396  	dom.AppendChild(repostWrap, repostBtn)
8397  	repostCount := dom.CreateElement("span")
8398  	dom.SetStyle(repostCount, "fontSize", "12px")
8399  	dom.SetStyle(repostCount, "color", "var(--muted)")
8400  	if pks, ok := noteRepostCounts[ev.ID]; ok && len(pks) > 0 {
8401  		dom.SetTextContent(repostCount, itoa(len(pks)))
8402  	}
8403  	dom.AppendChild(repostWrap, repostCount)
8404  	noteRepostEls[ev.ID] = repostCount
8405  	dom.AppendChild(row, repostWrap)
8406  
8407  	// React button.
8408  	reactWrap := dom.CreateElement("span")
8409  	dom.SetStyle(reactWrap, "display", "inline-flex")
8410  	dom.SetStyle(reactWrap, "alignItems", "center")
8411  	reactBtn := actionBtn(svgReact)
8412  	capturedEv4 := ev
8413  	capturedWrap := reactWrap
8414  	dom.AddEventListener(reactBtn, "click", dom.RegisterCallback(func() {
8415  		showEmojiPicker(capturedEv4, capturedWrap)
8416  	}))
8417  	dom.AppendChild(reactWrap, reactBtn)
8418  	dom.AppendChild(row, reactWrap)
8419  
8420  	// Zap button - right of react. Only rendered if the author has a
8421  	// lightning address. If the profile isn't cached yet, queue this row so
8422  	// applyAuthorProfile can inject the button when it arrives.
8423  	authorHasLN := false
8424  	if content, ok := profileContentCache[ev.PubKey]; ok {
8425  		if helpers.JsonGetString(content, "lud16") != "" || helpers.JsonGetString(content, "lud06") != "" {
8426  			authorHasLN = true
8427  		}
8428  	}
8429  	if authorHasLN {
8430  		attachZapButton(row, ev)
8431  	} else {
8432  		pendingZapRows[ev.PubKey] = append(pendingZapRows[ev.PubKey], zapRowPending{row: row, ev: ev})
8433  	}
8434  
8435  	if moreEl != 0 {
8436  		dom.SetStyle(moreEl, "marginLeft", "auto")
8437  		dom.SetAttribute(moreEl, "data-more", "1")
8438  		dom.AppendChild(row, moreEl)
8439  	}
8440  	dom.AppendChild(wrap, row)
8441  
8442  	// Queue fetch for kind 6/7 if not already fetched.
8443  	queueActionFetch(ev.ID)
8444  
8445  	return wrap
8446  }
8447  
8448  func actionBtn(svgHTML string) (e dom.Element) {
8449  	btn := dom.CreateElement("span")
8450  	dom.SetInnerHTML(btn, svgHTML)
8451  	dom.SetStyle(btn, "cursor", "pointer")
8452  	dom.SetStyle(btn, "display", "inline-flex")
8453  	dom.SetStyle(btn, "alignItems", "center")
8454  	dom.SetStyle(btn, "color", "var(--muted)")
8455  	dom.SetStyle(btn, "opacity", "0.6")
8456  	dom.SetStyle(btn, "userSelect", "none")
8457  	capturedBtn := btn
8458  	dom.AddEventListener(btn, "mouseenter", dom.RegisterCallback(func() {
8459  		dom.SetStyle(capturedBtn, "opacity", "1")
8460  	}))
8461  	dom.AddEventListener(btn, "mouseleave", dom.RegisterCallback(func() {
8462  		dom.SetStyle(capturedBtn, "opacity", "0.5")
8463  	}))
8464  	return btn
8465  }
8466  
8467  // --- Reaction/repost display ---
8468  
8469  func fillReactionDisplay(el dom.Element, evID string) {
8470  	rm, ok := noteReactionMap[evID]
8471  	if !ok {
8472  		return
8473  	}
8474  	// Sort by count descending (simple selection sort).
8475  	type ec struct {
8476  		emoji string
8477  		count int32
8478  	}
8479  	var sorted []ec
8480  	for e, pks := range rm {
8481  		sorted = append(sorted, ec{e, len(pks)})
8482  	}
8483  	for i := 0; i < len(sorted); i++ {
8484  		for j := i + 1; j < len(sorted); j++ {
8485  			if sorted[j].count > sorted[i].count {
8486  				sorted[i], sorted[j] = sorted[j], sorted[i]
8487  			}
8488  		}
8489  	}
8490  	clearChildren(el)
8491  	for i, e := range sorted {
8492  		if i >= 8 {
8493  			more := dom.CreateElement("span")
8494  			dom.SetTextContent(more, "...")
8495  			dom.SetStyle(more, "color", "var(--muted)")
8496  			dom.AppendChild(el, more)
8497  			break
8498  		}
8499  		chip := dom.CreateElement("span")
8500  		dom.SetTextContent(chip, e.emoji | " " | itoa(e.count))
8501  		dom.SetStyle(chip, "whiteSpace", "nowrap")
8502  		dom.AppendChild(el, chip)
8503  	}
8504  }
8505  
8506  // --- Reaction/repost fetching ---
8507  
8508  func queueActionFetch(evID string) {
8509  	if actionFetchedIDs[evID] {
8510  		return
8511  	}
8512  	actionFetchedIDs[evID] = true
8513  	actionFetchQueue = append(actionFetchQueue, evID)
8514  	if actionFetchTimer != 0 {
8515  		dom.ClearTimeout(actionFetchTimer)
8516  	}
8517  	actionFetchTimer = dom.SetTimeout(func() {
8518  		actionFetchTimer = 0
8519  		flushActionFetch()
8520  	}, 400)
8521  }
8522  
8523  func flushActionFetch() {
8524  	if len(actionFetchQueue) == 0 {
8525  		return
8526  	}
8527  	queue := actionFetchQueue
8528  	actionFetchQueue = nil
8529  
8530  	const batchSize = 20
8531  	for i := 0; i < len(queue); i += batchSize {
8532  		end := i + batchSize
8533  		if end > len(queue) {
8534  			end = len(queue)
8535  		}
8536  		chunk := queue[i:end]
8537  		ids := "["
8538  		for j, id := range chunk {
8539  			if j > 0 {
8540  				ids = ids | ","
8541  			}
8542  			ids = ids | jstr(id)
8543  		}
8544  		ids = ids | "]"
8545  		actionSubCounter++
8546  		subID := "act-" | itoa(actionSubCounter)
8547  
8548  		var urls []string
8549  		seen := map[string]bool{}
8550  		for _, u := range relayURLs {
8551  			if !seen[u] {
8552  				seen[u] = true
8553  				urls = append(urls, u)
8554  			}
8555  		}
8556  		for _, u := range topRelays(4) {
8557  			if !seen[u] {
8558  				seen[u] = true
8559  				urls = append(urls, u)
8560  			}
8561  		}
8562  		routeMsg(buildProxyMsg(subID,
8563  			"{\"kinds\":[6,7,9735],\"#e\":" | ids | ",\"limit\":500}",
8564  			urls))
8565  	}
8566  }
8567  
8568  func handleActionEvent(ev *nostr.Event) {
8569  	if noteRepostCounts == nil || noteReactionMap == nil || noteRepostEls == nil || noteReactionEls == nil {
8570  		return
8571  	}
8572  	// Find which event this references.
8573  	var targetID string
8574  	for _, tag := range ev.Tags {
8575  		if len(tag) >= 2 && tag[0] == "e" {
8576  			targetID = tag[1]
8577  			break
8578  		}
8579  	}
8580  	if targetID == "" {
8581  		return
8582  	}
8583  
8584  	if ev.Kind == 6 {
8585  		pks := noteRepostCounts[targetID]
8586  		if pks == nil {
8587  			pks = map[string]bool{}
8588  			noteRepostCounts[targetID] = pks
8589  		}
8590  		if !pks[ev.PubKey] {
8591  			pks[ev.PubKey] = true
8592  			if el, ok := noteRepostEls[targetID]; ok {
8593  				dom.SetTextContent(el, itoa(len(pks)))
8594  			}
8595  		}
8596  	} else if ev.Kind == 9735 {
8597  		// Zap receipt: accumulate msat amount from the bolt11 tag (ground truth).
8598  		seen := noteZapSeen[targetID]
8599  		if seen == nil {
8600  			seen = map[string]bool{}
8601  			noteZapSeen[targetID] = seen
8602  		}
8603  		if seen[ev.ID] {
8604  			return
8605  		}
8606  		seen[ev.ID] = true
8607  		var bolt11 string
8608  		for _, tag := range ev.Tags {
8609  			if len(tag) >= 2 && tag[0] == "bolt11" {
8610  				bolt11 = tag[1]
8611  				break
8612  			}
8613  		}
8614  		if bolt11 == "" {
8615  			return
8616  		}
8617  		msats := parseBolt11Msat(bolt11)
8618  		if msats <= 0 {
8619  			return
8620  		}
8621  		cur := int64(0)
8622  		if v, ok := noteZapTotals[targetID]; ok {
8623  			cur = v
8624  		}
8625  		cur = cur + msats
8626  		noteZapTotals[targetID] = cur
8627  		if el, ok := noteZapEls[targetID]; ok {
8628  			dom.SetTextContent(el, "\xe2\x9a\xa1 " | i64toa(cur/1000) | " sats")
8629  		}
8630  	} else if ev.Kind == 7 {
8631  		emoji := ev.Content
8632  		if emoji == "" || emoji == " | " {
8633  			emoji = "\xe2\x9d\xa4\xef\xb8\x8f"
8634  		}
8635  		inner := noteReactionMap[targetID]
8636  		if inner == nil {
8637  			inner = map[string]map[string]bool{}
8638  		}
8639  		pks := inner[emoji]
8640  		if pks == nil {
8641  			pks = map[string]bool{}
8642  		}
8643  		if !pks[ev.PubKey] {
8644  			pks[ev.PubKey] = true
8645  			inner[emoji] = pks
8646  			noteReactionMap[targetID] = inner
8647  			if el, ok := noteReactionEls[targetID]; ok {
8648  				fillReactionDisplay(el, targetID)
8649  			}
8650  		}
8651  	}
8652  }
8653  
8654  // --- Repost ---
8655  
8656  func onRepostClick(ev *nostr.Event) {
8657  	if !signer.HasSigner() || pubhex == "" {
8658  		return
8659  	}
8660  	if !dom.Confirm("Repost this note?") {
8661  		return
8662  	}
8663  	relay := ""
8664  	if urls, ok := eventRelays[ev.ID]; ok && len(urls) > 0 {
8665  		relay = urls[0]
8666  	}
8667  	tags := "[[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | "],[\"p\"," | jstr(ev.PubKey) | "]]"
8668  	ts := dom.NowSeconds()
8669  	unsigned := "{\"kind\":6,\"content\":" | jstr(ev.ToJSON()) |
8670  		",\"tags\":" | tags |
8671  		",\"created_at\":" | i64toa(ts) |
8672  		",\"pubkey\":\"" | pubhex | "\"}"
8673  	signer.SignEvent(unsigned, func(signed string) {
8674  		if signed != "" {
8675  			selected := composeSelectedRelays()
8676  			if len(selected) > 0 {
8677  				msg := "[\"PUBLISH_TO\"," | signed | ",["
8678  				for i, u := range selected {
8679  					if i > 0 {
8680  						msg = msg | ","
8681  					}
8682  					msg = msg | jstr(u)
8683  				}
8684  				routeMsg(msg | "]]")
8685  			} else {
8686  				routeMsg("[\"EVENT\"," | signed | "]")
8687  			}
8688  			pks := noteRepostCounts[ev.ID]
8689  			if pks == nil {
8690  				pks = map[string]bool{}
8691  				noteRepostCounts[ev.ID] = pks
8692  			}
8693  			pks[pubhex] = true
8694  			if el, ok := noteRepostEls[ev.ID]; ok {
8695  				dom.SetTextContent(el, itoa(len(pks)))
8696  			}
8697  		}
8698  	})
8699  }
8700  
8701  // --- Compose editor (reply + quote) ---
8702  
8703  func editorDraftKey(mode string, evID string) (s string) {
8704  	return mode | ":" | evID
8705  }
8706  
8707  func showEditor(mode string, ev *nostr.Event, noteEl dom.Element) {
8708  	if !signer.HasSigner() || pubhex == "" {
8709  		return
8710  	}
8711  	if editorOpen {
8712  		closeEditor()
8713  	}
8714  	editorOpen = true
8715  	editorPreviewing = false
8716  	editorMode = mode
8717  	editorTargetEv = ev
8718  	editorNoteEl = noteEl
8719  
8720  	composer := dom.CreateElement("div")
8721  	dom.SetStyle(composer, "borderTop", "1px solid var(--border)")
8722  	dom.SetStyle(composer, "marginTop", "8px")
8723  	dom.SetStyle(composer, "paddingTop", "8px")
8724  	dom.SetStyle(composer, "maxWidth", "65ch")
8725  	dom.SetStyle(composer, "display", "flex")
8726  	dom.SetStyle(composer, "flexDirection", "column")
8727  	dom.SetStyle(composer, "gap", "8px")
8728  	editorComposer = composer
8729  
8730  	// Header row: mode label + preview toggle + close.
8731  	headerRow := dom.CreateElement("div")
8732  	dom.SetStyle(headerRow, "display", "flex")
8733  	dom.SetStyle(headerRow, "alignItems", "center")
8734  	dom.SetStyle(headerRow, "gap", "8px")
8735  
8736  	modeLabel := dom.CreateElement("span")
8737  	dom.SetStyle(modeLabel, "fontSize", "13px")
8738  	dom.SetStyle(modeLabel, "fontWeight", "bold")
8739  	dom.SetStyle(modeLabel, "color", "var(--muted)")
8740  	if mode == "reply" {
8741  		dom.SetTextContent(modeLabel, "Reply")
8742  	} else {
8743  		dom.SetTextContent(modeLabel, "Quote")
8744  	}
8745  	dom.AppendChild(headerRow, modeLabel)
8746  
8747  	editorImgBtn := dom.CreateElement("button")
8748  	dom.SetInnerHTML(editorImgBtn, `<svg width="14" height="14" viewBox="0 0 18 18" fill="none"><rect x="2" y="3" width="14" height="12" rx="2" stroke="currentColor" stroke-width="1.5"/><circle cx="6.5" cy="7.5" r="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M2 13l4-4 3 3 2-2 5 3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`)
8749  	dom.SetStyle(editorImgBtn, "padding", "2px 6px")
8750  	dom.SetStyle(editorImgBtn, "border", "1px solid var(--border)")
8751  	dom.SetStyle(editorImgBtn, "borderRadius", "4px")
8752  	dom.SetStyle(editorImgBtn, "background", "transparent")
8753  	dom.SetStyle(editorImgBtn, "color", "var(--fg)")
8754  	dom.SetStyle(editorImgBtn, "cursor", "pointer")
8755  	dom.SetStyle(editorImgBtn, "display", "flex")
8756  	dom.SetStyle(editorImgBtn, "alignItems", "center")
8757  	dom.AddEventListener(editorImgBtn, "click", dom.RegisterCallback(func() {
8758  		dom.PickFileBase64("image/*,video/*", func(b64, mime string) {
8759  			if b64 != "" {
8760  				blossomUpload(b64, mime, editorTextarea)
8761  			}
8762  		})
8763  	}))
8764  	dom.AppendChild(headerRow, editorImgBtn)
8765  
8766  	previewBtn := dom.CreateElement("button")
8767  	dom.SetTextContent(previewBtn, "Preview")
8768  	dom.SetStyle(previewBtn, "padding", "2px 8px")
8769  	dom.SetStyle(previewBtn, "border", "1px solid var(--border)")
8770  	dom.SetStyle(previewBtn, "borderRadius", "4px")
8771  	dom.SetStyle(previewBtn, "background", "transparent")
8772  	dom.SetStyle(previewBtn, "color", "var(--fg)")
8773  	dom.SetStyle(previewBtn, "cursor", "pointer")
8774  	dom.SetStyle(previewBtn, "fontSize", "11px")
8775  	dom.AppendChild(headerRow, previewBtn)
8776  
8777  	closeBtn := dom.CreateElement("span")
8778  	dom.SetTextContent(closeBtn, "\u2715")
8779  	dom.SetStyle(closeBtn, "marginLeft", "auto")
8780  	dom.SetStyle(closeBtn, "cursor", "pointer")
8781  	dom.SetStyle(closeBtn, "color", "var(--muted)")
8782  	dom.SetStyle(closeBtn, "fontSize", "14px")
8783  	dom.AddEventListener(closeBtn, "click", dom.RegisterCallback(func() {
8784  		closeEditor()
8785  	}))
8786  	dom.AppendChild(headerRow, closeBtn)
8787  
8788  	dom.AppendChild(composer, headerRow)
8789  
8790  	ta := dom.CreateElement("textarea")
8791  	dom.SetStyle(ta, "width", "100%")
8792  	dom.SetStyle(ta, "minHeight", "80px")
8793  	dom.SetStyle(ta, "resize", "vertical")
8794  	dom.SetStyle(ta, "overflowY", "auto")
8795  	dom.SetStyle(ta, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8796  	dom.SetStyle(ta, "fontSize", "14px")
8797  	dom.SetStyle(ta, "padding", "8px")
8798  	dom.SetStyle(ta, "border", "1px solid var(--border)")
8799  	dom.SetStyle(ta, "borderRadius", "4px")
8800  	dom.SetStyle(ta, "background", "var(--bg)")
8801  	dom.SetStyle(ta, "color", "var(--fg)")
8802  	dom.SetStyle(ta, "boxSizing", "border-box")
8803  	dom.SetAttribute(ta, "onkeydown", "if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();this.parentNode.querySelector('[data-publish-btn]').click()}")
8804  	editorTextarea = ta
8805  
8806  	// Restore draft or set initial content.
8807  	draftKey := editorDraftKey(mode, ev.ID)
8808  	if draft, ok := editorDrafts[draftKey]; ok {
8809  		dom.SetProperty(ta, "value", draft)
8810  	} else if mode == "quote" {
8811  		relay := ""
8812  		if urls, ok := eventRelays[ev.ID]; ok && len(urls) > 0 {
8813  			relay = urls[0]
8814  		}
8815  		var relays []string
8816  		if relay != "" {
8817  			relays = []string{relay}
8818  		}
8819  		nevent := helpers.EncodeNevent(ev.ID, relays, ev.PubKey)
8820  		dom.SetProperty(ta, "value", "nostr:" | nevent | "\n\n")
8821  	}
8822  
8823  	wireEmojiHandler(ta)
8824  	wireBlossomHandlers(ta)
8825  	wireMentionHandler(ta)
8826  	dom.AppendChild(composer, ta)
8827  
8828  	// Markdown preview area (hidden initially).
8829  	prevArea := dom.CreateElement("div")
8830  	dom.SetStyle(prevArea, "display", "none")
8831  	dom.SetStyle(prevArea, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8832  	dom.SetStyle(prevArea, "fontSize", "14px")
8833  	dom.SetStyle(prevArea, "lineHeight", "1.5")
8834  	dom.SetStyle(prevArea, "padding", "8px")
8835  	dom.SetStyle(prevArea, "border", "1px solid var(--border)")
8836  	dom.SetStyle(prevArea, "borderRadius", "4px")
8837  	dom.SetStyle(prevArea, "minHeight", "80px")
8838  	dom.SetStyle(prevArea, "wordBreak", "break-word")
8839  	dom.SetStyle(prevArea, "overflowY", "auto")
8840  	editorPreviewEl = prevArea
8841  	dom.AppendChild(composer, prevArea)
8842  
8843  	// Preview toggle.
8844  	capturedTa := ta
8845  	capturedPrev := prevArea
8846  	capturedBtn := previewBtn
8847  	dom.AddEventListener(previewBtn, "click", dom.RegisterCallback(func() {
8848  		editorPreviewing = !editorPreviewing
8849  		if editorPreviewing {
8850  			text := dom.GetProperty(capturedTa, "value")
8851  			text = replaceEmojiCodes(text)
8852  			setHTMLWithMedia(capturedPrev, renderMarkdown(text))
8853  			resolveEmbeds()
8854  			dom.SetStyle(capturedTa, "display", "none")
8855  			dom.SetStyle(capturedPrev, "display", "block")
8856  			dom.SetTextContent(capturedBtn, "Edit")
8857  			dom.SetStyle(capturedBtn, "background", "var(--accent)")
8858  			dom.SetStyle(capturedBtn, "color", "#fff")
8859  			dom.SetStyle(capturedBtn, "border", "1px solid var(--accent)")
8860  		} else {
8861  			dom.SetStyle(capturedTa, "display", "")
8862  			dom.SetStyle(capturedPrev, "display", "none")
8863  			dom.SetTextContent(capturedBtn, "Preview")
8864  			dom.SetStyle(capturedBtn, "background", "transparent")
8865  			dom.SetStyle(capturedBtn, "color", "var(--fg)")
8866  			dom.SetStyle(capturedBtn, "border", "1px solid var(--border)")
8867  		}
8868  	}))
8869  
8870  	// Button row.
8871  	btnRow := dom.CreateElement("div")
8872  	dom.SetStyle(btnRow, "display", "flex")
8873  	dom.SetStyle(btnRow, "justifyContent", "flex-end")
8874  	dom.SetStyle(btnRow, "gap", "8px")
8875  
8876  	pubBtn := dom.CreateElement("button")
8877  	dom.SetTextContent(pubBtn, "Publish")
8878  	dom.SetAttribute(pubBtn, "data-publish-btn", "1")
8879  	dom.SetStyle(pubBtn, "padding", "5px 14px")
8880  	dom.SetStyle(pubBtn, "border", "none")
8881  	dom.SetStyle(pubBtn, "borderRadius", "4px")
8882  	dom.SetStyle(pubBtn, "background", "var(--accent)")
8883  	dom.SetStyle(pubBtn, "color", "#fff")
8884  	dom.SetStyle(pubBtn, "cursor", "pointer")
8885  	dom.SetStyle(pubBtn, "fontWeight", "bold")
8886  	dom.SetStyle(pubBtn, "fontSize", "13px")
8887  	dom.AddEventListener(pubBtn, "click", dom.RegisterCallback(func() {
8888  		publishEditorContent()
8889  	}))
8890  	dom.AppendChild(btnRow, pubBtn)
8891  
8892  	dom.AppendChild(composer, btnRow)
8893  	dom.AppendChild(noteEl, composer)
8894  	dom.Focus(ta)
8895  }
8896  
8897  func closeEditor() {
8898  	// Save draft before closing.
8899  	if editorTextarea != 0 && editorTargetEv != nil {
8900  		text := dom.GetProperty(editorTextarea, "value")
8901  		key := editorDraftKey(editorMode, editorTargetEv.ID)
8902  		if text != "" {
8903  			editorDrafts[key] = text
8904  		} else {
8905  			delete(editorDrafts, key)
8906  		}
8907  	}
8908  	if editorComposer != 0 && editorNoteEl != 0 {
8909  		dom.RemoveChild(editorNoteEl, editorComposer)
8910  		editorComposer = 0
8911  		editorNoteEl = 0
8912  	}
8913  	editorOpen = false
8914  	editorTargetEv = nil
8915  	editorTextarea = 0
8916  	editorPreviewEl = 0
8917  	editorPreviewing = false
8918  	closeEmojiAuto()
8919  	closeMentionPopup()
8920  }
8921  
8922  func publishEditorContent() {
8923  	dom.ConsoleLog("[publish-reply] enter")
8924  	if editorTargetEv == nil || editorTextarea == 0 {
8925  		dom.ConsoleLog("[publish-reply] no target or textarea")
8926  		return
8927  	}
8928  	content := dom.GetProperty(editorTextarea, "value")
8929  	dom.ConsoleLog("[publish-reply] content len=" | itoa(len(content)))
8930  	content = replaceEmojiCodes(content)
8931  	if content == "" {
8932  		dom.ConsoleLog("[publish-reply] empty after emoji replace")
8933  		return
8934  	}
8935  
8936  	ev := editorTargetEv
8937  	mode := editorMode
8938  	// Clear draft on publish.
8939  	delete(editorDrafts, editorDraftKey(mode, ev.ID))
8940  	closeEditor()
8941  
8942  	relay := ""
8943  	if urls, ok := eventRelays[ev.ID]; ok && len(urls) > 0 {
8944  		relay = urls[0]
8945  	}
8946  
8947  	// Build tags.
8948  	tags := "["
8949  	rootID := getRootID(ev)
8950  
8951  	if mode == "reply" {
8952  		// e-tags: root + reply.
8953  		if rootID != "" && rootID != ev.ID {
8954  			tags = tags | "[\"e\"," | jstr(rootID) | "," | jstr(relay) | ",\"root\"],"
8955  			tags = tags | "[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | ",\"reply\"]"
8956  		} else {
8957  			// Replying to a root note - it becomes the root, our reply points to it.
8958  			tags = tags | "[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | ",\"root\"]"
8959  		}
8960  		// p-tags: collect all users in the chain.
8961  		ptags := map[string]bool{}
8962  		for _, tag := range ev.Tags {
8963  			if len(tag) >= 2 && tag[0] == "p" && tag[1] != pubhex {
8964  				ptags[tag[1]] = true
8965  			}
8966  		}
8967  		if ev.PubKey != pubhex {
8968  			ptags[ev.PubKey] = true
8969  		}
8970  		for pk := range ptags {
8971  			tags = tags | ",[\"p\"," | jstr(pk) | "]"
8972  		}
8973  	} else {
8974  		// Quote: q-tag + p-tag for quoted author.
8975  		tags = tags | "[\"q\"," | jstr(ev.ID) | "]"
8976  		if ev.PubKey != pubhex {
8977  			tags = tags | ",[\"p\"," | jstr(ev.PubKey) | "]"
8978  		}
8979  	}
8980  	// Client tag.
8981  	if localstorage.GetItem("smesh-client-tag") != "0" {
8982  		tags = tags | ",[\"client\",\"https://smesh.lol\"]"
8983  	}
8984  	tags = tags | "]"
8985  
8986  	ts := dom.NowSeconds()
8987  	unsigned := "{\"kind\":1,\"content\":" | jstr(content) |
8988  		",\"tags\":" | tags |
8989  		",\"created_at\":" | i64toa(ts) |
8990  		",\"pubkey\":\"" | pubhex | "\"}"
8991  	dom.ConsoleLog("[publish-reply] calling SignEvent")
8992  	signer.SignEvent(unsigned, func(signed string) {
8993  		if signed == "" {
8994  			logActivity("reply", "FAILED: signer error (vault locked?)", unsigned)
8995  			dom.Confirm("publish failed: signer error (vault locked?)")
8996  			return
8997  		}
8998  		// Publish to selected relays (or all if none selected).
8999  		selected := composeSelectedRelays()
9000  		preview := content
9001  		if len(preview) > 80 {
9002  			preview = preview[:80] | "..."
9003  		}
9004  		relayList := ""
9005  		for j, u := range selected {
9006  			if j > 0 {
9007  				relayList = relayList | "\n"
9008  			}
9009  			relayList = relayList | u
9010  		}
9011  		if len(selected) > 0 {
9012  			msg := "[\"PUBLISH_TO\"," | signed | ",["
9013  			for i, u := range selected {
9014  				if i > 0 {
9015  					msg = msg | ","
9016  				}
9017  				msg = msg | jstr(u)
9018  			}
9019  			routeMsg(msg | "]]")
9020  		} else {
9021  			routeMsg("[\"EVENT\"," | signed | "]")
9022  		}
9023  		logActivity("reply", preview, "relays:\n" | relayList | "\n\nevent:\n" | signed)
9024  		published := nostr.ParseEvent(signed)
9025  		if published != nil {
9026  			insertPublishedNote(published)
9027  		}
9028  	})
9029  }
9030  
9031  func publishDelete(targetID string) {
9032  	if !dom.Confirm("Delete this note?") {
9033  		return
9034  	}
9035  	ts := dom.NowSeconds()
9036  	unsigned := "{\"kind\":5,\"content\":\"\"" |
9037  		",\"tags\":[[\"e\"," | jstr(targetID) | "]]" |
9038  		",\"created_at\":" | i64toa(ts) |
9039  		",\"pubkey\":\"" | pubhex | "\"}"
9040  	signer.SignEvent(unsigned, func(signed string) {
9041  		if signed == "" {
9042  			return
9043  		}
9044  		delEv := nostr.ParseEvent(signed)
9045  		if delEv != nil {
9046  			pendingDeletes[delEv.ID] = targetID
9047  			delID := delEv.ID
9048  			dom.SetTimeout(func() {
9049  				delete(pendingDeletes, delID)
9050  			}, 30000)
9051  		}
9052  		routeMsg("[\"EVENT\"," | signed | "]")
9053  	})
9054  }
9055  
9056  func removeNoteFromDOM(evID string) {
9057  	el, ok := noteElements[evID]
9058  	if !ok {
9059  		return
9060  	}
9061  	dom.Remove(el)
9062  	delete(noteElements, evID)
9063  	delete(seenEvents, evID)
9064  }
9065  
9066  func fetchDeletesForFeed() {
9067  	// Collect event IDs from rendered notes.
9068  	var ids []string
9069  	for id := range noteElements {
9070  		ids = append(ids, id)
9071  	}
9072  	if len(ids) == 0 {
9073  		return
9074  	}
9075  	// Build a filter for kind 5 events referencing these note IDs.
9076  	eList := jstr(ids[0])
9077  	for i := 1; i < len(ids); i++ {
9078  		eList = eList | "," | jstr(ids[i])
9079  	}
9080  	filter := "{\"kinds\":[5],\"#e\":[" | eList | "]}"
9081  	routeMsg(buildProxyMsg("del-scan", filter, feedRelays()))
9082  }
9083  
9084  func buildComposePage() {
9085  	composeTextarea = dom.CreateElement("textarea")
9086  	dom.SetAttribute(composeTextarea, "placeholder", "What's on your mind?")
9087  	dom.SetStyle(composeTextarea, "width", "100%")
9088  	dom.SetStyle(composeTextarea, "minHeight", "160px")
9089  	dom.SetStyle(composeTextarea, "flex", "1")
9090  	dom.SetStyle(composeTextarea, "padding", "12px")
9091  	dom.SetStyle(composeTextarea, "fontSize", "15px")
9092  	dom.SetStyle(composeTextarea, "lineHeight", "1.5")
9093  	dom.SetStyle(composeTextarea, "border", "1px solid var(--border)")
9094  	dom.SetStyle(composeTextarea, "borderRadius", "8px")
9095  	dom.SetStyle(composeTextarea, "background", "var(--bg)")
9096  	dom.SetStyle(composeTextarea, "color", "var(--fg)")
9097  	dom.SetStyle(composeTextarea, "resize", "vertical")
9098  	dom.SetStyle(composeTextarea, "overflowY", "auto")
9099  	dom.SetStyle(composeTextarea, "boxSizing", "border-box")
9100  	dom.SetStyle(composeTextarea, "fontFamily", "inherit")
9101  	dom.SetAttribute(composeTextarea, "onkeydown", "if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();this.parentNode.querySelector('[data-publish-btn]').click()}")
9102  	dom.AppendChild(composePage, composeTextarea)
9103  
9104  	// Preview area (hidden by default).
9105  	composePreview = dom.CreateElement("div")
9106  	dom.SetStyle(composePreview, "display", "none")
9107  	dom.SetStyle(composePreview, "padding", "12px")
9108  	dom.SetStyle(composePreview, "border", "1px solid var(--border)")
9109  	dom.SetStyle(composePreview, "borderRadius", "8px")
9110  	dom.SetStyle(composePreview, "marginTop", "8px")
9111  	dom.SetStyle(composePreview, "lineHeight", "1.5")
9112  	dom.SetStyle(composePreview, "wordBreak", "break-word")
9113  	dom.AppendChild(composePage, composePreview)
9114  
9115  	// Button row.
9116  	btnRow := dom.CreateElement("div")
9117  	dom.SetStyle(btnRow, "display", "flex")
9118  	dom.SetStyle(btnRow, "gap", "8px")
9119  	dom.SetStyle(btnRow, "marginTop", "12px")
9120  	dom.SetStyle(btnRow, "alignItems", "center")
9121  
9122  	// Relay selector button (left side).
9123  	relaySelWrap := dom.CreateElement("div")
9124  	dom.SetStyle(relaySelWrap, "position", "relative")
9125  	dom.SetStyle(relaySelWrap, "display", "flex")
9126  	dom.SetStyle(relaySelWrap, "alignItems", "center")
9127  	dom.SetStyle(relaySelWrap, "gap", "6px")
9128  
9129  	relaySelBtn := dom.CreateElement("button")
9130  	dom.SetStyle(relaySelBtn, "padding", "8px 16px")
9131  	dom.SetStyle(relaySelBtn, "border", "1px solid var(--border)")
9132  	dom.SetStyle(relaySelBtn, "borderRadius", "6px")
9133  	dom.SetStyle(relaySelBtn, "background", "transparent")
9134  	dom.SetStyle(relaySelBtn, "color", "var(--fg)")
9135  	dom.SetStyle(relaySelBtn, "cursor", "pointer")
9136  	updateRelaySelBtn(relaySelBtn, false)
9137  
9138  	composeWarnBadge := makeRelayWarnBadge()
9139  
9140  	composeRelayPopEl = dom.CreateElement("div")
9141  	dom.SetStyle(composeRelayPopEl, "display", "none")
9142  	dom.SetStyle(composeRelayPopEl, "position", "absolute")
9143  	dom.SetStyle(composeRelayPopEl, "bottom", "100%")
9144  	dom.SetStyle(composeRelayPopEl, "left", "0")
9145  	dom.SetStyle(composeRelayPopEl, "marginBottom", "4px")
9146  	dom.SetStyle(composeRelayPopEl, "background", "var(--bg)")
9147  	dom.SetStyle(composeRelayPopEl, "border", "1px solid var(--border)")
9148  	dom.SetStyle(composeRelayPopEl, "borderRadius", "6px")
9149  	dom.SetStyle(composeRelayPopEl, "padding", "8px")
9150  	dom.SetStyle(composeRelayPopEl, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)")
9151  	dom.SetStyle(composeRelayPopEl, "zIndex", "1000")
9152  	dom.SetStyle(composeRelayPopEl, "minWidth", "200px")
9153  	dom.SetStyle(composeRelayPopEl, "maxWidth", "calc(100vw - 24px)")
9154  	dom.SetStyle(composeRelayPopEl, "maxHeight", "200px")
9155  	dom.SetStyle(composeRelayPopEl, "overflowY", "auto")
9156  
9157  	dom.AddEventListener(relaySelBtn, "click", dom.RegisterCallback(func() {
9158  		if composeRelayPopOpen {
9159  			dom.SetStyle(composeRelayPopEl, "display", "none")
9160  			composeRelayPopOpen = false
9161  			updateRelaySelBtn(relaySelBtn, false)
9162  		} else {
9163  			populateRelayPopover(composeRelayPopEl, func() {
9164  				updateRelaySelBtn(relaySelBtn, true)
9165  				updateRelayWarnBadge(composeWarnBadge)
9166  			})
9167  			dom.SetStyle(composeRelayPopEl, "display", "block")
9168  			composeRelayPopOpen = true
9169  			updateRelaySelBtn(relaySelBtn, true)
9170  		}
9171  	}))
9172  
9173  	dom.AppendChild(relaySelWrap, relaySelBtn)
9174  	dom.AppendChild(relaySelWrap, composeWarnBadge)
9175  	dom.AppendChild(relaySelWrap, composeRelayPopEl)
9176  	dom.AppendChild(btnRow, relaySelWrap)
9177  
9178  	// Spacer.
9179  	spacer := dom.CreateElement("div")
9180  	dom.SetStyle(spacer, "flex", "1")
9181  	dom.AppendChild(btnRow, spacer)
9182  
9183  	// Image upload button.
9184  	imgBtn := dom.CreateElement("button")
9185  	dom.SetInnerHTML(imgBtn, `<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><rect x="2" y="3" width="14" height="12" rx="2" stroke="currentColor" stroke-width="1.5"/><circle cx="6.5" cy="7.5" r="1.5" stroke="currentColor" stroke-width="1.2"/><path d="M2 13l4-4 3 3 2-2 5 3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`)
9186  	dom.SetStyle(imgBtn, "padding", "8px 12px")
9187  	dom.SetStyle(imgBtn, "border", "1px solid var(--border)")
9188  	dom.SetStyle(imgBtn, "borderRadius", "6px")
9189  	dom.SetStyle(imgBtn, "background", "transparent")
9190  	dom.SetStyle(imgBtn, "color", "var(--fg)")
9191  	dom.SetStyle(imgBtn, "cursor", "pointer")
9192  	dom.SetStyle(imgBtn, "display", "flex")
9193  	dom.SetStyle(imgBtn, "alignItems", "center")
9194  	dom.AddEventListener(imgBtn, "click", dom.RegisterCallback(func() {
9195  		dom.PickFileBase64("image/*,video/*", func(b64, mime string) {
9196  			if b64 != "" {
9197  				blossomUpload(b64, mime, composeTextarea)
9198  			}
9199  		})
9200  	}))
9201  	dom.AppendChild(btnRow, imgBtn)
9202  
9203  	// Preview toggle.
9204  	previewBtn := dom.CreateElement("button")
9205  	dom.SetTextContent(previewBtn, "preview")
9206  	dom.SetStyle(previewBtn, "padding", "8px 16px")
9207  	dom.SetStyle(previewBtn, "border", "1px solid var(--border)")
9208  	dom.SetStyle(previewBtn, "borderRadius", "6px")
9209  	dom.SetStyle(previewBtn, "background", "transparent")
9210  	dom.SetStyle(previewBtn, "color", "var(--fg)")
9211  	dom.SetStyle(previewBtn, "cursor", "pointer")
9212  	composePreviewing := false
9213  	dom.AddEventListener(previewBtn, "click", dom.RegisterCallback(func() {
9214  		content := dom.GetProperty(composeTextarea, "value")
9215  		if !composePreviewing {
9216  			dom.SetStyle(composePreview, "display", "block")
9217  			dom.SetStyle(composeTextarea, "display", "none")
9218  			setHTMLWithMedia(composePreview, renderMarkdown(content))
9219  			dom.SetTextContent(previewBtn, "edit")
9220  			composePreviewing = true
9221  		} else {
9222  			dom.SetStyle(composePreview, "display", "none")
9223  			dom.SetStyle(composeTextarea, "display", "block")
9224  			dom.SetTextContent(previewBtn, "preview")
9225  			composePreviewing = false
9226  		}
9227  	}))
9228  	dom.AppendChild(btnRow, previewBtn)
9229  
9230  	// Publish button.
9231  	pubBtn := dom.CreateElement("button")
9232  	dom.SetTextContent(pubBtn, "publish")
9233  	dom.SetAttribute(pubBtn, "data-publish-btn", "1")
9234  	dom.SetStyle(pubBtn, "padding", "8px 20px")
9235  	dom.SetStyle(pubBtn, "border", "none")
9236  	dom.SetStyle(pubBtn, "borderRadius", "6px")
9237  	dom.SetStyle(pubBtn, "background", "var(--accent)")
9238  	dom.SetStyle(pubBtn, "color", "#fff")
9239  	dom.SetStyle(pubBtn, "cursor", "pointer")
9240  	dom.SetStyle(pubBtn, "fontWeight", "bold")
9241  	dom.AddEventListener(pubBtn, "click", dom.RegisterCallback(func() {
9242  		dom.ConsoleLog("[publish-compose] enter")
9243  		content := dom.GetProperty(composeTextarea, "value")
9244  		dom.ConsoleLog("[publish-compose] content len=" | itoa(len(content)))
9245  		content = replaceEmojiCodes(content)
9246  		if content == "" {
9247  			dom.ConsoleLog("[publish-compose] empty")
9248  			return
9249  		}
9250  		dom.ConsoleLog("[publish-compose] hasSigner=" | boolStr(signer.HasSigner()) | " pubhex=" | pubhex)
9251  		if !signer.HasSigner() || pubhex == "" {
9252  			dom.ConsoleLog("[publish-compose] no signer or pubhex")
9253  			return
9254  		}
9255  		ts := dom.NowSeconds()
9256  		tags := "["
9257  		if localstorage.GetItem("smesh-client-tag") != "0" {
9258  			tags = tags | "[\"client\",\"https://smesh.lol\"]"
9259  		}
9260  		tags = tags | "]"
9261  		unsigned := "{\"kind\":1,\"content\":" | jstr(content) |
9262  			",\"tags\":" | tags |
9263  			",\"created_at\":" | i64toa(ts) |
9264  			",\"pubkey\":\"" | pubhex | "\"}"
9265  		dom.ConsoleLog("[publish-compose] calling SignEvent")
9266  		capturedContent := content
9267  		signer.SignEvent(unsigned, func(signed string) {
9268  			if signed == "" {
9269  				logActivity("publish", "FAILED: signer error", unsigned)
9270  				return
9271  			}
9272  			// Build relay list from checked relays.
9273  			selected := composeSelectedRelays()
9274  			preview := capturedContent
9275  			if len(preview) > 80 {
9276  				preview = preview[:80] | "..."
9277  			}
9278  			relayList := ""
9279  			for j, u := range selected {
9280  				if j > 0 {
9281  					relayList = relayList | "\n"
9282  				}
9283  				relayList = relayList | u
9284  			}
9285  			if len(selected) > 0 {
9286  				msg := "[\"PUBLISH_TO\"," | signed | ",["
9287  				for i, u := range selected {
9288  					if i > 0 {
9289  						msg = msg | ","
9290  					}
9291  					msg = msg | jstr(u)
9292  				}
9293  				routeMsg(msg | "]]")
9294  			} else {
9295  				routeMsg("[\"EVENT\"," | signed | "]")
9296  			}
9297  			logActivity("publish", preview, "relays:\n" | relayList | "\n\nevent:\n" | signed)
9298  			published := nostr.ParseEvent(signed)
9299  			if published != nil {
9300  				insertPublishedNote(published)
9301  			}
9302  			dom.SetProperty(composeTextarea, "value", "")
9303  			dom.SetStyle(composePreview, "display", "none")
9304  			dom.SetStyle(composeTextarea, "display", "block")
9305  			composeRelayPopOpen = false
9306  			dom.SetStyle(composeRelayPopEl, "display", "none")
9307  			dom.SetProperty(contentArea, "scrollTop", "0")
9308  			switchPage("feed")
9309  		})
9310  	}))
9311  	dom.AppendChild(btnRow, pubBtn)
9312  	dom.AppendChild(composePage, btnRow)
9313  
9314  	// Emoji autocomplete popup.
9315  	emojiAutoEl = dom.CreateElement("div")
9316  	dom.SetStyle(emojiAutoEl, "display", "none")
9317  	dom.SetStyle(emojiAutoEl, "position", "fixed")
9318  	dom.SetStyle(emojiAutoEl, "background", "var(--bg)")
9319  	dom.SetStyle(emojiAutoEl, "border", "1px solid var(--border)")
9320  	dom.SetStyle(emojiAutoEl, "borderRadius", "6px")
9321  	dom.SetStyle(emojiAutoEl, "padding", "4px")
9322  	dom.SetStyle(emojiAutoEl, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)")
9323  	dom.SetStyle(emojiAutoEl, "zIndex", "2000")
9324  	dom.SetStyle(emojiAutoEl, "maxHeight", "200px")
9325  	dom.SetStyle(emojiAutoEl, "overflowY", "auto")
9326  	dom.SetStyle(emojiAutoEl, "minWidth", "200px")
9327  	dom.AppendChild(dom.Body(), emojiAutoEl)
9328  
9329  	wireEmojiHandler(composeTextarea)
9330  	wireBlossomHandlers(composeTextarea)
9331  	wireMentionHandler(composeTextarea)
9332  }
9333  
9334  func wireEmojiHandler(ta dom.Element) {
9335  	dom.AddEventListener(ta, "input", dom.RegisterCallback(func() {
9336  		checkEmojiAutocomplete(ta)
9337  	}))
9338  }
9339  
9340  func wireBlossomHandlers(ta dom.Element) {
9341  	upload := func(b64 string, mime string) {
9342  		blossomUpload(b64, mime, ta)
9343  	}
9344  	dom.OnPasteImage(ta, upload)
9345  	dom.OnDropImage(ta, upload)
9346  }
9347  
9348  func blossomUpload(b64data string, mime string, ta dom.Element) {
9349  	if blossomServerURL == "" {
9350  		dom.ConsoleLog("blossom: no server configured")
9351  		return
9352  	}
9353  	raw := helpers.Base64Decode(b64data)
9354  	if len(raw) == 0 {
9355  		return
9356  	}
9357  	hash := sha256.Sum(raw)
9358  	hashHex := helpers.HexEncode(hash[:])
9359  	ext := ".bin"
9360  	if mime == "image/png" {
9361  		ext = ".png"
9362  	} else if mime == "image/jpeg" || mime == "image/jpg" {
9363  		ext = ".jpg"
9364  	} else if mime == "image/gif" {
9365  		ext = ".gif"
9366  	} else if mime == "image/webp" {
9367  		ext = ".webp"
9368  	} else if mime == "image/svg+xml" {
9369  		ext = ".svg"
9370  	} else if mime == "video/mp4" {
9371  		ext = ".mp4"
9372  	} else if mime == "video/webm" {
9373  		ext = ".webm"
9374  	} else if mime == "video/quicktime" {
9375  		ext = ".mov"
9376  	} else if mime == "video/x-matroska" {
9377  		ext = ".mkv"
9378  	}
9379  	placeholder := "\xe2\x8f\xb3 uploading to " | blossomServerURL | "..."
9380  	val := dom.GetProperty(ta, "value")
9381  	if len(val) > 0 && val[len(val)-1] != '\n' {
9382  		val = val | "\n"
9383  	}
9384  	dom.SetProperty(ta, "value", val | placeholder)
9385  	ts := dom.NowSeconds()
9386  	expiry := ts + 300
9387  	tags := "[[\"t\",\"upload\"],[\"x\",\"" | hashHex | "\"],[\"expiration\",\"" | i64toa(expiry) | "\"]]"
9388  	unsigned := "{\"kind\":24242,\"content\":\"Upload " | hashHex | ext |
9389  		"\",\"tags\":" | tags |
9390  		",\"created_at\":" | i64toa(ts) |
9391  		",\"pubkey\":\"" | pubhex | "\"}"
9392  	signer.SignEvent(unsigned, func(signed string) {
9393  		if signed == "" {
9394  			dom.ConsoleLog("blossom: sign failed")
9395  			blossomReplacePlaceholder(ta, placeholder, "")
9396  			return
9397  		}
9398  		authB64 := helpers.Base64Encode([]byte(signed))
9399  		authHeader := "Nostr " | authB64
9400  		url := blossomServerURL
9401  		if len(url) > 0 && url[len(url)-1] != '/' {
9402  			url = url | "/"
9403  		}
9404  		url = url | "upload"
9405  		dom.FetchPutBlobBase64(url, b64data, mime, authHeader, func(resp string) {
9406  			if resp == "" {
9407  				dom.ConsoleLog("blossom: upload failed")
9408  				blossomReplacePlaceholder(ta, placeholder, "")
9409  				return
9410  			}
9411  			blobURL := helpers.JsonGetString(resp, "url")
9412  			if blobURL == "" {
9413  				blobURL = hashHex | ext
9414  			}
9415  			if len(blobURL) < 4 || blobURL[:4] != "http" {
9416  				base := blossomOrigin(blossomServerURL)
9417  				if len(blobURL) > 0 && blobURL[0] == '/' {
9418  					blobURL = base | blobURL
9419  				} else {
9420  					blobURL = base | "/" | blobURL
9421  				}
9422  			}
9423  			blossomReplacePlaceholder(ta, placeholder, blobURL)
9424  		})
9425  	})
9426  }
9427  
9428  func blossomOrigin(u string) (s string) {
9429  	slashes := 0
9430  	for i := 0; i < len(u); i++ {
9431  		if u[i] == '/' {
9432  			slashes++
9433  			if slashes == 3 {
9434  				return u[:i]
9435  			}
9436  		}
9437  	}
9438  	return u
9439  }
9440  
9441  func blossomReplacePlaceholder(ta dom.Element, placeholder, replacement string) {
9442  	val := dom.GetProperty(ta, "value")
9443  	idx := -1
9444  	plen := len(placeholder)
9445  	for i := 0; i <= len(val)-plen; i++ {
9446  		if val[i:i+plen] == placeholder {
9447  			idx = i
9448  			break
9449  		}
9450  	}
9451  	if idx >= 0 {
9452  		dom.SetProperty(ta, "value", val[:idx] | replacement | val[idx+plen:])
9453  	} else if replacement != "" {
9454  		if len(val) > 0 && val[len(val)-1] != '\n' {
9455  			val = val | "\n"
9456  		}
9457  		dom.SetProperty(ta, "value", val | replacement | "\n")
9458  	}
9459  }
9460  
9461  func blossomUploadToInput(b64data string, mime string, input dom.Element) {
9462  	if blossomServerURL == "" {
9463  		return
9464  	}
9465  	raw := helpers.Base64Decode(b64data)
9466  	if len(raw) == 0 {
9467  		return
9468  	}
9469  	hash := sha256.Sum(raw)
9470  	hashHex := helpers.HexEncode(hash[:])
9471  	ext := ".bin"
9472  	if mime == "image/png" {
9473  		ext = ".png"
9474  	} else if mime == "image/jpeg" || mime == "image/jpg" {
9475  		ext = ".jpg"
9476  	} else if mime == "image/gif" {
9477  		ext = ".gif"
9478  	} else if mime == "image/webp" {
9479  		ext = ".webp"
9480  	} else if mime == "image/svg+xml" {
9481  		ext = ".svg"
9482  	} else if mime == "video/mp4" {
9483  		ext = ".mp4"
9484  	} else if mime == "video/webm" {
9485  		ext = ".webm"
9486  	} else if mime == "video/quicktime" {
9487  		ext = ".mov"
9488  	} else if mime == "video/x-matroska" {
9489  		ext = ".mkv"
9490  	}
9491  	dom.SetProperty(input, "value", "\xe2\x8f\xb3 uploading...")
9492  	ts := dom.NowSeconds()
9493  	expiry := ts + 300
9494  	tags := "[[\"t\",\"upload\"],[\"x\",\"" | hashHex | "\"],[\"expiration\",\"" | i64toa(expiry) | "\"]]"
9495  	unsigned := "{\"kind\":24242,\"content\":\"Upload " | hashHex | ext |
9496  		"\",\"tags\":" | tags |
9497  		",\"created_at\":" | i64toa(ts) |
9498  		",\"pubkey\":\"" | pubhex | "\"}"
9499  	signer.SignEvent(unsigned, func(signed string) {
9500  		if signed == "" {
9501  			dom.SetProperty(input, "value", "")
9502  			return
9503  		}
9504  		authB64 := helpers.Base64Encode([]byte(signed))
9505  		authHeader := "Nostr " | authB64
9506  		url := blossomServerURL
9507  		if len(url) > 0 && url[len(url)-1] != '/' {
9508  			url = url | "/"
9509  		}
9510  		url = url | "upload"
9511  		dom.FetchPutBlobBase64(url, b64data, mime, authHeader, func(resp string) {
9512  			if resp == "" {
9513  				dom.SetProperty(input, "value", "")
9514  				return
9515  			}
9516  			blobURL := helpers.JsonGetString(resp, "url")
9517  			if blobURL == "" {
9518  				blobURL = hashHex | ext
9519  			}
9520  			if len(blobURL) < 4 || blobURL[:4] != "http" {
9521  				base := blossomOrigin(blossomServerURL)
9522  				if len(blobURL) > 0 && blobURL[0] == '/' {
9523  					blobURL = base | blobURL
9524  				} else {
9525  					blobURL = base | "/" | blobURL
9526  				}
9527  			}
9528  			dom.SetProperty(input, "value", blobURL)
9529  		})
9530  	})
9531  }
9532  
9533  type mentionMatch struct {
9534  	pk   string
9535  	name string
9536  	pic  string
9537  }
9538  
9539  func mentionSearch(query string) (ss []mentionMatch) {
9540  	q := toLower(query)
9541  	var results []mentionMatch
9542  	for pk, content := range profileContentCache {
9543  		if len(results) >= 10 {
9544  			break
9545  		}
9546  		name := helpers.JsonGetString(content, "name")
9547  		dname := helpers.JsonGetString(content, "display_name")
9548  		nl := toLower(name)
9549  		dl := toLower(dname)
9550  		if (len(nl) > 0 && strIndex(nl, q) >= 0) || (len(dl) > 0 && strIndex(dl, q) >= 0) {
9551  			label := name
9552  			if len(label) == 0 {
9553  				label = dname
9554  			}
9555  			if len(label) == 0 {
9556  				continue
9557  			}
9558  			pic, _ := authorPics[pk]
9559  			results = append(results, mentionMatch{pk: pk, name: label, pic: pic})
9560  		}
9561  	}
9562  	return results
9563  }
9564  
9565  func initMentionPopup() {
9566  	mentionPopup = dom.CreateElement("div")
9567  	dom.SetStyle(mentionPopup, "display", "none")
9568  	dom.SetStyle(mentionPopup, "position", "fixed")
9569  	dom.SetStyle(mentionPopup, "background", "var(--bg)")
9570  	dom.SetStyle(mentionPopup, "border", "1px solid var(--border)")
9571  	dom.SetStyle(mentionPopup, "borderRadius", "6px")
9572  	dom.SetStyle(mentionPopup, "padding", "4px")
9573  	dom.SetStyle(mentionPopup, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)")
9574  	dom.SetStyle(mentionPopup, "zIndex", "3000")
9575  	dom.SetStyle(mentionPopup, "maxHeight", "200px")
9576  	dom.SetStyle(mentionPopup, "overflowY", "auto")
9577  	dom.SetStyle(mentionPopup, "minWidth", "220px")
9578  	dom.AppendChild(dom.Body(), mentionPopup)
9579  }
9580  
9581  func showMentionPopup(ta dom.Element, atPos int32, query string) {
9582  	matches := mentionSearch(query)
9583  	if len(matches) == 0 {
9584  		closeMentionPopup()
9585  		return
9586  	}
9587  	mentionTA = ta
9588  	mentionQuery = query
9589  	mentionAtPos = atPos
9590  	mentionItems = matches
9591  	mentionSelIdx = 0
9592  
9593  	clearChildren(mentionPopup)
9594  	for i, m := range matches {
9595  		row := dom.CreateElement("div")
9596  		dom.SetStyle(row, "display", "flex")
9597  		dom.SetStyle(row, "alignItems", "center")
9598  		dom.SetStyle(row, "gap", "8px")
9599  		dom.SetStyle(row, "padding", "4px 8px")
9600  		dom.SetStyle(row, "cursor", "pointer")
9601  		dom.SetStyle(row, "borderRadius", "4px")
9602  		dom.SetStyle(row, "fontSize", "13px")
9603  		if i == 0 {
9604  			dom.SetStyle(row, "background", "var(--bg2)")
9605  		}
9606  
9607  		if len(m.pic) > 0 {
9608  			img := dom.CreateElement("img")
9609  			setMediaSrc(img, m.pic)
9610  			dom.SetStyle(img, "width", "24px")
9611  			dom.SetStyle(img, "height", "24px")
9612  			dom.SetStyle(img, "borderRadius", "50%")
9613  			dom.SetStyle(img, "objectFit", "cover")
9614  			dom.SetStyle(img, "flexShrink", "0")
9615  			dom.AppendChild(row, img)
9616  		} else {
9617  			placeholder := dom.CreateElement("div")
9618  			dom.SetStyle(placeholder, "width", "24px")
9619  			dom.SetStyle(placeholder, "height", "24px")
9620  			dom.SetStyle(placeholder, "borderRadius", "50%")
9621  			dom.SetStyle(placeholder, "background", "var(--border)")
9622  			dom.SetStyle(placeholder, "flexShrink", "0")
9623  			dom.AppendChild(row, placeholder)
9624  		}
9625  
9626  		nameSpan := dom.CreateElement("span")
9627  		dom.SetTextContent(nameSpan, m.name)
9628  		dom.AppendChild(row, nameSpan)
9629  
9630  		npubShort := helpers.EncodeNpub(helpers.HexDecode(m.pk))
9631  		if len(npubShort) > 16 {
9632  			npubShort = npubShort[:12] | "..."
9633  		}
9634  		npubSpan := dom.CreateElement("span")
9635  		dom.SetTextContent(npubSpan, npubShort)
9636  		dom.SetStyle(npubSpan, "color", "var(--muted)")
9637  		dom.SetStyle(npubSpan, "fontSize", "11px")
9638  		dom.AppendChild(row, npubSpan)
9639  
9640  		dom.AddEventListener(row, "mouseenter", dom.RegisterCallback(func() {
9641  			dom.SetStyle(row, "background", "var(--bg2)")
9642  		}))
9643  		dom.AddEventListener(row, "mouseleave", dom.RegisterCallback(func() {
9644  			if mentionSelIdx >= 0 && mentionSelIdx < len(mentionItems) {
9645  				dom.SetStyle(row, "background", "transparent")
9646  			}
9647  		}))
9648  
9649  		capturedPK := m.pk
9650  		dom.AddEventListener(row, "mousedown", dom.RegisterCallback(func() {
9651  			insertMention(capturedPK)
9652  		}))
9653  
9654  		dom.AppendChild(mentionPopup, row)
9655  	}
9656  
9657  	positionMentionPopup(ta)
9658  	dom.SetStyle(mentionPopup, "display", "block")
9659  }
9660  
9661  func positionMentionPopup(ta dom.Element) {
9662  	dom.GetBoundingRect(ta, func(left, top, right, bottom, width, height int32) {
9663  		vh := dom.GetViewportHeight()
9664  		cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
9665  		val := dom.GetProperty(ta, "value")
9666  		lineH := 22
9667  		charsPerLine := width / 8
9668  		if charsPerLine < 1 {
9669  			charsPerLine = 40
9670  		}
9671  		linesBefore := 0
9672  		col := 0
9673  		for i := 0; i < cur && i < len(val); i++ {
9674  			if val[i] == '\n' {
9675  				linesBefore++
9676  				col = 0
9677  			} else {
9678  				col++
9679  				if col >= charsPerLine {
9680  					linesBefore++
9681  					col = 0
9682  				}
9683  			}
9684  		}
9685  		cursorY := top + 8 + linesBefore*lineH
9686  		popupH := 200
9687  		if cursorY < vh/2 {
9688  			dom.SetStyle(mentionPopup, "top", itoa(cursorY+lineH) | "px")
9689  			dom.SetStyle(mentionPopup, "bottom", "auto")
9690  		} else {
9691  			dom.SetStyle(mentionPopup, "bottom", itoa(vh-cursorY) | "px")
9692  			dom.SetStyle(mentionPopup, "top", "auto")
9693  		}
9694  		_ = popupH
9695  		xOff := left + col*8
9696  		if xOff > right-220 {
9697  			xOff = right - 220
9698  		}
9699  		if xOff < left {
9700  			xOff = left
9701  		}
9702  		dom.SetStyle(mentionPopup, "left", itoa(xOff) | "px")
9703  	})
9704  }
9705  
9706  func insertMention(pk string) {
9707  	if mentionTA == 0 {
9708  		return
9709  	}
9710  	npub := helpers.EncodeNpub(helpers.HexDecode(pk))
9711  	val := dom.GetProperty(mentionTA, "value")
9712  	cur := parseIntProp(dom.GetProperty(mentionTA, "selectionStart"))
9713  	replacement := "nostr:" | npub | " "
9714  	before := val[:mentionAtPos]
9715  	after := val[cur:]
9716  	dom.SetProperty(mentionTA, "value", before | replacement | after)
9717  	newCur := itoa(len(before) + len(replacement))
9718  	dom.SetProperty(mentionTA, "selectionStart", newCur)
9719  	dom.SetProperty(mentionTA, "selectionEnd", newCur)
9720  	closeMentionPopup()
9721  	dom.Focus(mentionTA)
9722  }
9723  
9724  func closeMentionPopup() {
9725  	dom.SetStyle(mentionPopup, "display", "none")
9726  	mentionItems = nil
9727  	mentionSelIdx = 0
9728  	mentionTA = 0
9729  }
9730  
9731  func checkMentionAutocomplete(ta dom.Element) {
9732  	val := dom.GetProperty(ta, "value")
9733  	cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
9734  	if cur > len(val) {
9735  		cur = len(val)
9736  	}
9737  	atPos := -1
9738  	for i := cur - 1; i >= 0; i-- {
9739  		c := val[i]
9740  		if c == '@' {
9741  			atPos = i
9742  			break
9743  		}
9744  		if c == ' ' || c == '\n' || c == '\t' {
9745  			break
9746  		}
9747  	}
9748  	if atPos < 0 || cur-atPos < 2 {
9749  		closeMentionPopup()
9750  		return
9751  	}
9752  	if atPos > 0 {
9753  		prev := val[atPos-1]
9754  		if prev != ' ' && prev != '\n' && prev != '\t' {
9755  			closeMentionPopup()
9756  			return
9757  		}
9758  	}
9759  	query := val[atPos+1 : cur]
9760  	showMentionPopup(ta, atPos, query)
9761  }
9762  
9763  func wireMentionHandler(ta dom.Element) {
9764  	dom.AddEventListener(ta, "input", dom.RegisterCallback(func() {
9765  		checkMentionAutocomplete(ta)
9766  	}))
9767  	dom.AddEventListener(ta, "blur", dom.RegisterCallback(func() {
9768  		dom.SetTimeout(func() { closeMentionPopup() }, 200)
9769  	}))
9770  	dom.OnKeydown(ta, func(key string, prevent func()) {
9771  		if mentionTA == 0 || len(mentionItems) == 0 {
9772  			if key == "Escape" {
9773  				closeMentionPopup()
9774  			}
9775  			return
9776  		}
9777  		if key == "Escape" {
9778  			closeMentionPopup()
9779  			prevent()
9780  		} else if key == "ArrowDown" {
9781  			mentionSelIdx++
9782  			if mentionSelIdx >= len(mentionItems) {
9783  				mentionSelIdx = 0
9784  			}
9785  			highlightMentionRow()
9786  			prevent()
9787  		} else if key == "ArrowUp" {
9788  			mentionSelIdx--
9789  			if mentionSelIdx < 0 {
9790  				mentionSelIdx = len(mentionItems) - 1
9791  			}
9792  			highlightMentionRow()
9793  			prevent()
9794  		} else if key == "Enter" || key == "Tab" {
9795  			insertMention(mentionItems[mentionSelIdx].pk)
9796  			prevent()
9797  		}
9798  	})
9799  }
9800  
9801  func highlightMentionRow() {
9802  	child := dom.FirstElementChild(mentionPopup)
9803  	idx := 0
9804  	for child != 0 {
9805  		if idx == mentionSelIdx {
9806  			dom.SetStyle(child, "background", "var(--bg2)")
9807  		} else {
9808  			dom.SetStyle(child, "background", "transparent")
9809  		}
9810  		child = dom.NextSibling(child)
9811  		idx++
9812  	}
9813  }
9814  
9815  func checkEmojiAutocomplete(ta dom.Element) {
9816  	val := dom.GetProperty(ta, "value")
9817  	curStr := dom.GetProperty(ta, "selectionStart")
9818  	cur := parseIntProp(curStr)
9819  	if cur > len(val) {
9820  		cur = len(val)
9821  	}
9822  
9823  	searchFrom := cur - 1
9824  	closingColon := false
9825  	if searchFrom >= 0 && val[searchFrom] == ':' {
9826  		closingColon = true
9827  		searchFrom--
9828  	}
9829  	colonPos := -1
9830  	for i := searchFrom; i >= 0; i-- {
9831  		c := val[i]
9832  		if c == ':' {
9833  			colonPos = i
9834  			break
9835  		}
9836  		if c == ' ' || c == '\n' || c == '\t' {
9837  			break
9838  		}
9839  	}
9840  
9841  	if colonPos < 0 || cur-colonPos < 2 {
9842  		closeEmojiAuto()
9843  		return
9844  	}
9845  
9846  	// Extract the text between the opening colon and cursor (excluding closing colon).
9847  	queryEnd := cur
9848  	if closingColon {
9849  		queryEnd = cur - 1
9850  	}
9851  	query := val[colonPos+1 : queryEnd]
9852  
9853  	// If user typed a closing ':', auto-replace if exact match.
9854  	if closingColon && len(query) > 0 {
9855  		code := ":" | query | ":"
9856  		if emoji, ok := emojiByName[code]; ok {
9857  			before := val[:colonPos]
9858  			after := val[cur:]
9859  			newVal := before | emoji | after
9860  			dom.SetProperty(ta, "value", newVal)
9861  			newCur := itoa(len(before) + len(emoji))
9862  			dom.SetProperty(ta, "selectionStart", newCur)
9863  			dom.SetProperty(ta, "selectionEnd", newCur)
9864  			closeEmojiAuto()
9865  			return
9866  		}
9867  	}
9868  
9869  	// Filter matching emoji.
9870  	var matches []emojiEntry
9871  	q := toLower(query)
9872  	for _, e := range emojiList {
9873  		if len(matches) >= 10 {
9874  			break
9875  		}
9876  		if len(e.name) >= len(q) && hasPrefix(e.name, q) {
9877  			matches = append(matches, e)
9878  		}
9879  	}
9880  	// Also match substring.
9881  	if len(matches) < 10 {
9882  		for _, e := range emojiList {
9883  			if len(matches) >= 10 {
9884  				break
9885  			}
9886  			if !hasPrefix(e.name, q) && strIndex(e.name, q) >= 0 {
9887  				matches = append(matches, e)
9888  			}
9889  		}
9890  	}
9891  
9892  	if len(matches) == 0 {
9893  		closeEmojiAuto()
9894  		return
9895  	}
9896  
9897  	emojiAutoOpen = true
9898  	emojiAutoStart = colonPos
9899  	emojiAutoTA = ta
9900  	clearChildren(emojiAutoEl)
9901  
9902  	for _, m := range matches {
9903  		row := dom.CreateElement("div")
9904  		dom.SetStyle(row, "display", "flex")
9905  		dom.SetStyle(row, "alignItems", "center")
9906  		dom.SetStyle(row, "gap", "6px")
9907  		dom.SetStyle(row, "padding", "4px 8px")
9908  		dom.SetStyle(row, "cursor", "pointer")
9909  		dom.SetStyle(row, "borderRadius", "4px")
9910  		dom.SetStyle(row, "fontSize", "13px")
9911  
9912  		emojiSpan := dom.CreateElement("span")
9913  		dom.SetTextContent(emojiSpan, m.emoji)
9914  		dom.SetStyle(emojiSpan, "fontSize", "18px")
9915  		dom.AppendChild(row, emojiSpan)
9916  
9917  		nameSpan := dom.CreateElement("span")
9918  		dom.SetTextContent(nameSpan, ":" | m.name | ":")
9919  		dom.SetStyle(nameSpan, "color", "var(--muted)")
9920  		dom.AppendChild(row, nameSpan)
9921  
9922  		dom.AddEventListener(row, "mouseenter", dom.RegisterCallback(func() {
9923  			dom.SetStyle(row, "background", "var(--bg2)")
9924  		}))
9925  		dom.AddEventListener(row, "mouseleave", dom.RegisterCallback(func() {
9926  			dom.SetStyle(row, "background", "transparent")
9927  		}))
9928  
9929  		capturedEmoji := m.emoji
9930  		dom.AddEventListener(row, "mousedown", dom.RegisterCallback(func() {
9931  			insertEmojiAt(capturedEmoji)
9932  		}))
9933  
9934  		dom.AppendChild(emojiAutoEl, row)
9935  	}
9936  
9937  	dom.SetStyle(emojiAutoEl, "display", "block")
9938  	positionEmojiPopup()
9939  }
9940  
9941  func insertEmojiAt(emoji string) {
9942  	ta := emojiAutoTA
9943  	if ta == 0 {
9944  		return
9945  	}
9946  	val := dom.GetProperty(ta, "value")
9947  	cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
9948  	before := val[:emojiAutoStart]
9949  	after := val[cur:]
9950  	newVal := before | emoji | after
9951  	dom.SetProperty(ta, "value", newVal)
9952  	newCur := itoa(len(before) + len(emoji))
9953  	dom.SetProperty(ta, "selectionStart", newCur)
9954  	dom.SetProperty(ta, "selectionEnd", newCur)
9955  	closeEmojiAuto()
9956  	dom.Focus(ta)
9957  }
9958  
9959  func closeEmojiAuto() {
9960  	if emojiAutoOpen {
9961  		dom.SetStyle(emojiAutoEl, "display", "none")
9962  		emojiAutoOpen = false
9963  	}
9964  }
9965  
9966  func positionEmojiPopup() {
9967  	ta := emojiAutoTA
9968  	if ta == 0 {
9969  		return
9970  	}
9971  	dom.GetBoundingRect(ta, func(left, top, right, bottom, width, height int32) {
9972  		vh := dom.GetViewportHeight()
9973  		cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
9974  		val := dom.GetProperty(ta, "value")
9975  		lineH := 22
9976  		charsPerLine := width / 8
9977  		if charsPerLine < 1 {
9978  			charsPerLine = 40
9979  		}
9980  		linesBefore := 0
9981  		col := 0
9982  		for i := 0; i < cur && i < len(val); i++ {
9983  			if val[i] == '\n' {
9984  				linesBefore++
9985  				col = 0
9986  			} else {
9987  				col++
9988  				if col >= charsPerLine {
9989  					linesBefore++
9990  					col = 0
9991  				}
9992  			}
9993  		}
9994  		cursorY := top + 8 + linesBefore*lineH
9995  		if cursorY < vh/2 {
9996  			dom.SetStyle(emojiAutoEl, "top", itoa(cursorY+lineH) | "px")
9997  			dom.SetStyle(emojiAutoEl, "bottom", "auto")
9998  		} else {
9999  			dom.SetStyle(emojiAutoEl, "bottom", itoa(vh-cursorY) | "px")
10000  			dom.SetStyle(emojiAutoEl, "top", "auto")
10001  		}
10002  		xOff := left + col*8
10003  		if xOff > right-220 {
10004  			xOff = right - 220
10005  		}
10006  		if xOff < left {
10007  			xOff = left
10008  		}
10009  		dom.SetStyle(emojiAutoEl, "left", itoa(xOff) | "px")
10010  	})
10011  }
10012  
10013  func populateComposeRelays() {
10014  	populateRelayPopover(composeRelayPopEl, nil)
10015  }
10016  
10017  func populateRelayPopover(target dom.Element, onChanged func()) {
10018  	renderRows := func() {
10019  		clearChildren(target)
10020  		populateRelayRows(target, onChanged)
10021  		if onChanged != nil {
10022  			onChanged()
10023  		}
10024  	}
10025  	if composeRelayChecks == nil {
10026  		composeRelayChecks = map[string]bool{}
10027  		for _, u := range relayURLs {
10028  			composeRelayChecks[u] = true
10029  		}
10030  		dom.IDBGet("cache", "compose_relays", func(val string) {
10031  			if val != "" {
10032  				composeRelayChecks = map[string]bool{}
10033  				start := 0
10034  				for i := 0; i <= len(val); i++ {
10035  					if i == len(val) || val[i] == ',' {
10036  						if i > start {
10037  							composeRelayChecks[val[start:i]] = true
10038  						}
10039  						start = i + 1
10040  					}
10041  				}
10042  			}
10043  			renderRows()
10044  		})
10045  		return
10046  	}
10047  	renderRows()
10048  }
10049  
10050  func populateRelayRows(target dom.Element, onChanged func()) {
10051  	for idx, u := range relayURLs {
10052  		row := dom.CreateElement("div")
10053  		dom.SetStyle(row, "display", "flex")
10054  		dom.SetStyle(row, "alignItems", "center")
10055  		dom.SetStyle(row, "gap", "6px")
10056  		dom.SetStyle(row, "padding", "0 4px")
10057  		dom.SetStyle(row, "margin", "2px 0")
10058  		dom.SetStyle(row, "cursor", "pointer")
10059  		dom.SetStyle(row, "fontSize", "12px")
10060  		dom.SetStyle(row, "overflow", "hidden")
10061  
10062  		relayURL := u
10063  		selected := composeRelayChecks[u]
10064  
10065  		applyRowStyle := func(sel bool) {
10066  			if sel {
10067  				dom.SetStyle(row, "background", "var(--accent)")
10068  				dom.SetStyle(row, "color", "#fff")
10069  			} else {
10070  				dom.SetStyle(row, "background", "transparent")
10071  				dom.SetStyle(row, "color", "var(--fg)")
10072  			}
10073  		}
10074  		applyRowStyle(selected)
10075  
10076  		dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
10077  			composeRelayChecks[relayURL] = !composeRelayChecks[relayURL]
10078  			applyRowStyle(composeRelayChecks[relayURL])
10079  			saveComposeRelaySelection()
10080  			if onChanged != nil {
10081  				onChanged()
10082  			}
10083  		}))
10084  
10085  		label := dom.CreateElement("span")
10086  		dom.SetTextContent(label, stripScheme(u))
10087  		dom.SetStyle(label, "overflow", "hidden")
10088  		dom.SetStyle(label, "textOverflow", "ellipsis")
10089  		dom.SetStyle(label, "whiteSpace", "nowrap")
10090  		dom.SetStyle(label, "flex", "1")
10091  		dom.SetStyle(label, "minWidth", "0")
10092  		dom.AppendChild(row, label)
10093  
10094  		caps := relayCaps[idx]
10095  		if caps.fetchedOK {
10096  			if !hasNIP(caps.supportedNIPs, 42) {
10097  				dom.AppendChild(row, makeRelayBadge("!42"))
10098  			}
10099  			if !hasNIP(caps.supportedNIPs, 70) {
10100  				dom.AppendChild(row, makeRelayBadge("!70"))
10101  			}
10102  		}
10103  
10104  		dom.AppendChild(target, row)
10105  	}
10106  }
10107  
10108  func selectedRelaysLeaky() (ok bool) {
10109  	if composeRelayChecks == nil {
10110  		return false
10111  	}
10112  	for i, u := range relayURLs {
10113  		if !composeRelayChecks[u] {
10114  			continue
10115  		}
10116  		caps := relayCaps[i]
10117  		if !caps.fetchedOK {
10118  			continue
10119  		}
10120  		if !hasNIP(caps.supportedNIPs, 42) || !hasNIP(caps.supportedNIPs, 70) {
10121  			return true
10122  		}
10123  	}
10124  	return false
10125  }
10126  
10127  func updateRelaySelBtn(btn dom.Element, open bool) {
10128  	if open {
10129  		dom.SetTextContent(btn, "relays \u2715")
10130  		dom.SetStyle(btn, "background", "var(--accent)")
10131  		dom.SetStyle(btn, "color", "#fff")
10132  	} else {
10133  		dom.SetTextContent(btn, "relays")
10134  		dom.SetStyle(btn, "background", "transparent")
10135  		dom.SetStyle(btn, "color", "var(--fg)")
10136  	}
10137  }
10138  
10139  func updateRelayWarnBadge(badge dom.Element) {
10140  	if selectedRelaysLeaky() {
10141  		dom.SetStyle(badge, "display", "inline-block")
10142  	} else {
10143  		dom.SetStyle(badge, "display", "none")
10144  	}
10145  }
10146  
10147  func makeRelayWarnBadge() (e dom.Element) {
10148  	badge := dom.CreateElement("span")
10149  	dom.SetTextContent(badge, "\u26a0")
10150  	dom.SetStyle(badge, "display", "none")
10151  	dom.SetStyle(badge, "fontSize", "16px")
10152  	dom.SetStyle(badge, "lineHeight", "1")
10153  	dom.SetStyle(badge, "background", "#e67e00")
10154  	dom.SetStyle(badge, "borderRadius", "50%")
10155  	dom.SetStyle(badge, "width", "22px")
10156  	dom.SetStyle(badge, "height", "22px")
10157  	dom.SetStyle(badge, "textAlign", "center")
10158  	dom.SetStyle(badge, "cursor", "default")
10159  	dom.SetAttribute(badge, "title", t("relay_leak_warn"))
10160  	updateRelayWarnBadge(badge)
10161  	return badge
10162  }
10163  
10164  func saveComposeRelaySelection() {
10165  	val := ""
10166  	for u, sel := range composeRelayChecks {
10167  		if sel {
10168  			if val != "" {
10169  				val = val | ","
10170  			}
10171  			val = val | u
10172  		}
10173  	}
10174  	dom.IDBPut("cache", "compose_relays", val)
10175  }
10176  
10177  func composeSelectedRelays() (ss []string) {
10178  	if composeRelayChecks == nil {
10179  		return relayURLs
10180  	}
10181  	var out []string
10182  	for u, sel := range composeRelayChecks {
10183  		if sel {
10184  			out = append(out, u)
10185  		}
10186  	}
10187  	// If nothing selected, fall back to all relays.
10188  	if len(out) == 0 {
10189  		return relayURLs
10190  	}
10191  	return out
10192  }
10193  
10194  func insertPublishedNote(ev *nostr.Event) {
10195  	setEvent(ev.ID, ev)
10196  	fillEmbed(ev)
10197  
10198  	// Thread view: insert and re-render, scrolling new reply into view.
10199  	if threadOpen {
10200  		threadEvents[ev.ID] = ev
10201  		threadFocusID = ev.ID
10202  		dom.ReplaceState("/t/" | threadRootID | "#" | ev.ID)
10203  		renderThreadTree()
10204  	}
10205  
10206  	// Feed: prepend at top. renderNote must run before seenEvents is set,
10207  	// because renderNote's dedup guard checks seenEvents.
10208  	trackOldestTs(ev)
10209  	renderNote(ev)
10210  	seenEvents[ev.ID] = true
10211  }
10212  
10213  // --- Emoji picker ---
10214  
10215  func showEmojiPicker(ev *nostr.Event, anchor dom.Element) {
10216  	_ = anchor
10217  	if !signer.HasSigner() || pubhex == "" {
10218  		return
10219  	}
10220  	if emojiOpen {
10221  		closeEmojiPicker()
10222  		return
10223  	}
10224  	emojiOpen = true
10225  	emojiTargetEv = ev
10226  
10227  	// Full-screen scrim.
10228  	overlay := dom.CreateElement("div")
10229  	dom.SetStyle(overlay, "position", "fixed")
10230  	dom.SetStyle(overlay, "top", "0")
10231  	dom.SetStyle(overlay, "left", "0")
10232  	dom.SetStyle(overlay, "width", "100%")
10233  	dom.SetStyle(overlay, "height", "100%")
10234  	dom.SetStyle(overlay, "background", "rgba(0,0,0,0.5)")
10235  	dom.SetStyle(overlay, "zIndex", "400")
10236  	dom.SetStyle(overlay, "display", "flex")
10237  	dom.SetStyle(overlay, "alignItems", "center")
10238  	dom.SetStyle(overlay, "justifyContent", "center")
10239  	dom.AddSelfEventListener(overlay, "click", dom.RegisterCallback(func() {
10240  		closeEmojiPicker()
10241  	}))
10242  	emojiOverlay = overlay
10243  
10244  	modal := dom.CreateElement("div")
10245  	dom.SetStyle(modal, "background", "var(--bg)")
10246  	dom.SetStyle(modal, "border", "1px solid var(--border)")
10247  	dom.SetStyle(modal, "borderRadius", "8px")
10248  	dom.SetStyle(modal, "padding", "12px")
10249  	dom.SetStyle(modal, "boxShadow", "0 4px 24px rgba(0,0,0,0.4)")
10250  	dom.SetStyle(modal, "width", "320px")
10251  	dom.SetStyle(modal, "maxWidth", "90vw")
10252  	dom.SetStyle(modal, "maxHeight", "60vh")
10253  	dom.SetStyle(modal, "display", "flex")
10254  	dom.SetStyle(modal, "flexDirection", "column")
10255  	dom.SetStyle(modal, "gap", "8px")
10256  
10257  	// Search input.
10258  	search := dom.CreateElement("input")
10259  	dom.SetAttribute(search, "type", "text")
10260  	dom.SetAttribute(search, "placeholder", "Search emoji...")
10261  	dom.SetStyle(search, "width", "100%")
10262  	dom.SetStyle(search, "padding", "6px 10px")
10263  	dom.SetStyle(search, "border", "1px solid var(--border)")
10264  	dom.SetStyle(search, "borderRadius", "4px")
10265  	dom.SetStyle(search, "background", "var(--bg)")
10266  	dom.SetStyle(search, "color", "var(--fg)")
10267  	dom.SetStyle(search, "fontSize", "14px")
10268  	dom.SetStyle(search, "boxSizing", "border-box")
10269  	dom.SetStyle(search, "flexShrink", "0")
10270  	dom.AppendChild(modal, search)
10271  
10272  	// Emoji grid - scrollable.
10273  	grid := dom.CreateElement("div")
10274  	dom.SetStyle(grid, "display", "flex")
10275  	dom.SetStyle(grid, "flexWrap", "wrap")
10276  	dom.SetStyle(grid, "gap", "2px")
10277  	dom.SetStyle(grid, "overflowY", "auto")
10278  	dom.SetStyle(grid, "flex", "1")
10279  	dom.SetStyle(grid, "minHeight", "0")
10280  	dom.SetStyle(grid, "alignContent", "flex-start")
10281  	dom.SetStyle(grid, "WebkitOverflowScrolling", "touch")
10282  
10283  	capturedEv := ev
10284  	seen := map[string]bool{}
10285  	for _, entry := range emojiList {
10286  		if seen[entry.emoji] {
10287  			continue
10288  		}
10289  		seen[entry.emoji] = true
10290  		cell := dom.CreateElement("span")
10291  		dom.SetTextContent(cell, entry.emoji)
10292  		dom.SetStyle(cell, "fontSize", "22px")
10293  		dom.SetStyle(cell, "cursor", "pointer")
10294  		dom.SetStyle(cell, "padding", "4px")
10295  		dom.SetStyle(cell, "borderRadius", "4px")
10296  		dom.SetStyle(cell, "textAlign", "center")
10297  		dom.SetStyle(cell, "width", "36px")
10298  		dom.SetStyle(cell, "height", "36px")
10299  		dom.SetStyle(cell, "lineHeight", "36px")
10300  		dom.SetStyle(cell, "display", "inline-flex")
10301  		dom.SetStyle(cell, "alignItems", "center")
10302  		dom.SetStyle(cell, "justifyContent", "center")
10303  		dom.SetAttribute(cell, "title", entry.name)
10304  		capturedEmoji := entry.emoji
10305  		dom.AddEventListener(cell, "mouseenter", dom.RegisterCallback(func() {
10306  			dom.SetStyle(cell, "background", "var(--border)")
10307  		}))
10308  		dom.AddEventListener(cell, "mouseleave", dom.RegisterCallback(func() {
10309  			dom.SetStyle(cell, "background", "transparent")
10310  		}))
10311  		dom.AddEventListener(cell, "click", dom.RegisterCallback(func() {
10312  			publishReaction(capturedEv, capturedEmoji)
10313  			closeEmojiPicker()
10314  		}))
10315  		dom.AppendChild(grid, cell)
10316  	}
10317  	dom.AppendChild(modal, grid)
10318  
10319  	// Search filtering - match against all names for each emoji.
10320  	capturedGrid := grid
10321  	dom.AddEventListener(search, "input", dom.RegisterCallback(func() {
10322  		q := toLower(dom.GetProperty(search, "value"))
10323  		child := dom.FirstElementChild(capturedGrid)
10324  		for child != 0 {
10325  			next := dom.NextSibling(child)
10326  			title := dom.GetProperty(child, "title")
10327  			if q == "" || contains(title, q) {
10328  				dom.SetStyle(child, "display", "inline-flex")
10329  			} else {
10330  				dom.SetStyle(child, "display", "none")
10331  			}
10332  			child = next
10333  		}
10334  	}))
10335  
10336  	dom.AppendChild(overlay, modal)
10337  	dom.AppendChild(dom.Body(), overlay)
10338  	dom.Focus(search)
10339  }
10340  
10341  func closeEmojiPicker() {
10342  	if emojiOverlay != 0 {
10343  		dom.RemoveChild(dom.Body(), emojiOverlay)
10344  		emojiOverlay = 0
10345  	}
10346  	emojiOpen = false
10347  	emojiTargetEv = nil
10348  }
10349  
10350  func publishReaction(ev *nostr.Event, emoji string) {
10351  	if !signer.HasSigner() || pubhex == "" {
10352  		return
10353  	}
10354  	relay := ""
10355  	if urls, ok := eventRelays[ev.ID]; ok && len(urls) > 0 {
10356  		relay = urls[0]
10357  	}
10358  	tags := "[[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | "],[\"p\"," | jstr(ev.PubKey) | "]]"
10359  	ts := dom.NowSeconds()
10360  	unsigned := "{\"kind\":7,\"content\":" | jstr(emoji) |
10361  		",\"tags\":" | tags |
10362  		",\"created_at\":" | i64toa(ts) |
10363  		",\"pubkey\":\"" | pubhex | "\"}"
10364  	capturedEvID := ev.ID
10365  	capturedEmoji := emoji
10366  	signer.SignEvent(unsigned, func(signed string) {
10367  		if signed != "" {
10368  			selected := composeSelectedRelays()
10369  			if len(selected) > 0 {
10370  				msg := "[\"PUBLISH_TO\"," | signed | ",["
10371  				for i, u := range selected {
10372  					if i > 0 {
10373  						msg = msg | ","
10374  					}
10375  					msg = msg | jstr(u)
10376  				}
10377  				routeMsg(msg | "]]")
10378  				relayList := ""
10379  				for j, u := range selected {
10380  					if j > 0 {
10381  						relayList = relayList | "\n"
10382  					}
10383  					relayList = relayList | u
10384  				}
10385  				logActivity("react", capturedEmoji | " on " | capturedEvID[:12], "relays:\n" | relayList | "\n\nevent:\n" | signed)
10386  			} else {
10387  				routeMsg("[\"EVENT\"," | signed | "]")
10388  				logActivity("react", capturedEmoji | " on " | capturedEvID[:12], "no targeted relays\n\nevent:\n" | signed)
10389  			}
10390  			ri := noteReactionMap[capturedEvID]
10391  			if ri == nil {
10392  				ri = map[string]map[string]bool{}
10393  			}
10394  			pks := ri[capturedEmoji]
10395  			if pks == nil {
10396  				pks = map[string]bool{}
10397  			}
10398  			pks[pubhex] = true
10399  			ri[capturedEmoji] = pks
10400  			noteReactionMap[capturedEvID] = ri
10401  			if el, ok := noteReactionEls[capturedEvID]; ok {
10402  				fillReactionDisplay(el, capturedEvID)
10403  			}
10404  		}
10405  	})
10406  }
10407  
10408  func contains(s, sub string) (ok bool) {
10409  	if len(sub) > len(s) {
10410  		return false
10411  	}
10412  	for i := 0; i <= len(s)-len(sub); i++ {
10413  		if s[i:i+len(sub)] == sub {
10414  			return true
10415  		}
10416  	}
10417  	return false
10418  }
10419  
10420  // --- Settings page ---
10421  
10422  func renderSettings() {
10423  	clearChildren(settingsPage)
10424  
10425  	title := dom.CreateElement("h2")
10426  	dom.SetTextContent(title, t("settings_title"))
10427  	dom.SetStyle(title, "fontSize", "20px")
10428  	dom.SetStyle(title, "marginBottom", "24px")
10429  	dom.AppendChild(settingsPage, title)
10430  
10431  	// Language selector.
10432  	langRow := dom.CreateElement("div")
10433  	dom.SetStyle(langRow, "display", "flex")
10434  	dom.SetStyle(langRow, "alignItems", "center")
10435  	dom.SetStyle(langRow, "gap", "12px")
10436  	dom.SetStyle(langRow, "marginBottom", "16px")
10437  
10438  	langLabel := dom.CreateElement("span")
10439  	dom.SetTextContent(langLabel, t("lang_label"))
10440  	dom.SetStyle(langLabel, "fontSize", "14px")
10441  	dom.SetStyle(langLabel, "minWidth", "140px")
10442  	dom.AppendChild(langRow, langLabel)
10443  
10444  	langSel := dom.CreateElement("select")
10445  	dom.SetStyle(langSel, "fontFamily", "'Fira Code', monospace")
10446  	dom.SetStyle(langSel, "fontSize", "13px")
10447  	dom.SetStyle(langSel, "background", "var(--bg2)")
10448  	dom.SetStyle(langSel, "color", "var(--fg)")
10449  	dom.SetStyle(langSel, "border", "1px solid var(--border)")
10450  	dom.SetStyle(langSel, "borderRadius", "4px")
10451  	dom.SetStyle(langSel, "padding", "6px 12px")
10452  	for code, name := range langNames {
10453  		opt := dom.CreateElement("option")
10454  		dom.SetAttribute(opt, "value", code)
10455  		dom.SetTextContent(opt, name)
10456  		if code == currentLang {
10457  			dom.SetAttribute(opt, "selected", "selected")
10458  		}
10459  		dom.AppendChild(langSel, opt)
10460  	}
10461  	dom.AddEventListener(langSel, "change", dom.RegisterCallback(func() {
10462  		val := dom.GetProperty(langSel, "value")
10463  		setLang(val)
10464  		saveAppSettings()
10465  		dom.SetTextContent(pageTitleEl, t("settings"))
10466  		renderSettings()
10467  	}))
10468  	dom.AppendChild(langRow, langSel)
10469  	dom.AppendChild(settingsPage, langRow)
10470  
10471  	// Theme selector.
10472  	themeRow := dom.CreateElement("div")
10473  	dom.SetStyle(themeRow, "display", "flex")
10474  	dom.SetStyle(themeRow, "alignItems", "center")
10475  	dom.SetStyle(themeRow, "gap", "12px")
10476  	dom.SetStyle(themeRow, "marginBottom", "16px")
10477  
10478  	themeLabel := dom.CreateElement("span")
10479  	dom.SetTextContent(themeLabel, t("theme_label"))
10480  	dom.SetStyle(themeLabel, "fontSize", "14px")
10481  	dom.SetStyle(themeLabel, "minWidth", "140px")
10482  	dom.AppendChild(themeRow, themeLabel)
10483  
10484  	themeToggle := dom.CreateElement("button")
10485  	if isDark {
10486  		dom.SetTextContent(themeToggle, t("dark"))
10487  	} else {
10488  		dom.SetTextContent(themeToggle, t("light"))
10489  	}
10490  	dom.SetStyle(themeToggle, "fontFamily", "'Fira Code', monospace")
10491  	dom.SetStyle(themeToggle, "fontSize", "13px")
10492  	dom.SetStyle(themeToggle, "background", "var(--bg2)")
10493  	dom.SetStyle(themeToggle, "color", "var(--fg)")
10494  	dom.SetStyle(themeToggle, "border", "1px solid var(--border)")
10495  	dom.SetStyle(themeToggle, "borderRadius", "4px")
10496  	dom.SetStyle(themeToggle, "padding", "6px 16px")
10497  	dom.SetStyle(themeToggle, "cursor", "pointer")
10498  	dom.AddEventListener(themeToggle, "click", dom.RegisterCallback(func() {
10499  		toggleTheme()
10500  		if isDark {
10501  			dom.SetTextContent(themeToggle, t("dark"))
10502  		} else {
10503  			dom.SetTextContent(themeToggle, t("light"))
10504  		}
10505  		saveAppSettings()
10506  	}))
10507  	dom.AppendChild(themeRow, themeToggle)
10508  	dom.AppendChild(settingsPage, themeRow)
10509  
10510  	// Client tag setting.
10511  	clientRow := dom.CreateElement("div")
10512  	dom.SetStyle(clientRow, "display", "flex")
10513  	dom.SetStyle(clientRow, "alignItems", "center")
10514  	dom.SetStyle(clientRow, "gap", "12px")
10515  	dom.SetStyle(clientRow, "marginBottom", "16px")
10516  
10517  	clientLabel := dom.CreateElement("span")
10518  	dom.SetTextContent(clientLabel, "client tag")
10519  	dom.SetStyle(clientLabel, "fontSize", "14px")
10520  	dom.SetStyle(clientLabel, "minWidth", "140px")
10521  	dom.AppendChild(clientRow, clientLabel)
10522  
10523  	clientCb := dom.CreateElement("input")
10524  	dom.SetAttribute(clientCb, "type", "checkbox")
10525  	if localstorage.GetItem("smesh-client-tag") != "0" {
10526  		dom.SetProperty(clientCb, "checked", "true")
10527  	}
10528  	dom.AddEventListener(clientCb, "change", dom.RegisterCallback(func() {
10529  		checked := dom.GetProperty(clientCb, "checked")
10530  		if checked == "true" {
10531  			localstorage.RemoveItem("smesh-client-tag")
10532  		} else {
10533  			localstorage.SetItem("smesh-client-tag", "0")
10534  		}
10535  		saveAppSettings()
10536  	}))
10537  	dom.AppendChild(clientRow, clientCb)
10538  
10539  	clientDesc := dom.CreateElement("span")
10540  	dom.SetTextContent(clientDesc, "add smesh.lol tag to posts")
10541  	dom.SetStyle(clientDesc, "fontSize", "12px")
10542  	dom.SetStyle(clientDesc, "color", "var(--muted)")
10543  	dom.AppendChild(clientRow, clientDesc)
10544  
10545  	dom.AppendChild(settingsPage, clientRow)
10546  
10547  	// Blossom server URL.
10548  	blossomRow := dom.CreateElement("div")
10549  	dom.SetStyle(blossomRow, "display", "flex")
10550  	dom.SetStyle(blossomRow, "flexWrap", "wrap")
10551  	dom.SetStyle(blossomRow, "alignItems", "center")
10552  	dom.SetStyle(blossomRow, "gap", "12px")
10553  	dom.SetStyle(blossomRow, "marginBottom", "16px")
10554  
10555  	blossomLabel := dom.CreateElement("span")
10556  	dom.SetTextContent(blossomLabel, "blossom server")
10557  	dom.SetStyle(blossomLabel, "fontSize", "14px")
10558  	dom.SetStyle(blossomLabel, "minWidth", "140px")
10559  	dom.AppendChild(blossomRow, blossomLabel)
10560  
10561  	blossomInput := dom.CreateElement("input")
10562  	dom.SetAttribute(blossomInput, "type", "text")
10563  	dom.SetAttribute(blossomInput, "placeholder", "https://blossom.example.com")
10564  	blossomVal := blossomServerURL
10565  	if blossomVal == "" {
10566  		blossomVal = defaultBlossomURL()
10567  	}
10568  	dom.SetProperty(blossomInput, "value", blossomVal)
10569  	dom.SetStyle(blossomInput, "fontFamily", "'Fira Code', monospace")
10570  	dom.SetStyle(blossomInput, "fontSize", "13px")
10571  	dom.SetStyle(blossomInput, "background", "var(--bg)")
10572  	dom.SetStyle(blossomInput, "color", "var(--fg)")
10573  	dom.SetStyle(blossomInput, "border", "1px solid var(--border)")
10574  	dom.SetStyle(blossomInput, "borderRadius", "4px")
10575  	dom.SetStyle(blossomInput, "padding", "6px 12px")
10576  	dom.SetStyle(blossomInput, "flex", "1")
10577  	dom.SetStyle(blossomInput, "minWidth", "200px")
10578  	dom.AppendChild(blossomRow, blossomInput)
10579  
10580  	blossomSave := dom.CreateElement("button")
10581  	dom.SetTextContent(blossomSave, "save")
10582  	dom.SetStyle(blossomSave, "padding", "6px 16px")
10583  	dom.SetStyle(blossomSave, "borderRadius", "4px")
10584  	dom.SetStyle(blossomSave, "border", "1px solid var(--border)")
10585  	dom.SetStyle(blossomSave, "background", "var(--accent)")
10586  	dom.SetStyle(blossomSave, "color", "var(--bg)")
10587  	dom.SetStyle(blossomSave, "cursor", "pointer")
10588  	dom.SetStyle(blossomSave, "fontSize", "13px")
10589  	dom.AddEventListener(blossomSave, "click", dom.RegisterCallback(func() {
10590  		val := dom.GetProperty(blossomInput, "value")
10591  		blossomServerURL = val
10592  		if val == "" {
10593  			localstorage.RemoveItem(lsKeyBlossomServer)
10594  		} else {
10595  			localstorage.SetItem(lsKeyBlossomServer, val)
10596  		}
10597  		saveAppSettings()
10598  		dom.SetTextContent(blossomSave, "saved!")
10599  		dom.SetTimeout(func() {
10600  			dom.SetTextContent(blossomSave, "save")
10601  		}, 1500)
10602  	}))
10603  	dom.AppendChild(blossomRow, blossomSave)
10604  	dom.AppendChild(settingsPage, blossomRow)
10605  
10606  	// Invidious URL setting.
10607  	appendServiceURLSetting(settingsPage, "invidious server", invidiousURL,
10608  		defaultServiceURL("invidious"), lsKeyInvidiousURL, func(v string) { invidiousURL = v },
10609  		"use invidious.smesh.lol")
10610  
10611  	// Campion (Bandcamp player) URL setting.
10612  	appendServiceURLSetting(settingsPage, "campion (bandcamp)", campionURL,
10613  		defaultServiceURL("campion"), lsKeyCampionURL, func(v string) { campionURL = v },
10614  		"use campion.smesh.lol")
10615  
10616  	renderRelaysSettings()
10617  }
10618  
10619  func addRelayURL(url string) {
10620  	addRelay(url, true)
10621  	sendWriteRelays()
10622  	populateFeedSelect()
10623  }
10624  
10625  type relayFreqEntry struct {
10626  	url  string
10627  	freq int32
10628  }
10629  
10630  // sortRelaysByFreq: relayFreq moved to Profile Worker; Shell returns empty.
10631  func sortRelaysByFreq() (ss []relayFreqEntry) { return nil }
10632  
10633  // renderRelaysSettings renders the "Relays" section of the settings page:
10634  // active relays with capability badges, required-capability checkboxes, and
10635  // the blocklist. Called from renderSettings(). Read-only behavior at this
10636  // step: checkboxes persist to localStorage but no enforcement is applied.
10637  // appendServiceURLSetting appends a labelled URL input + "use X" prefill button + save button.
10638  // onSet is called with the new value when saved. prefillLabel is the text on the prefill button.
10639  func appendServiceURLSetting(parent dom.Element, label, current, defaultVal, lsKey string, onSet func(string), prefillLabel string) {
10640  	row := dom.CreateElement("div")
10641  	dom.SetStyle(row, "display", "flex")
10642  	dom.SetStyle(row, "flexWrap", "wrap")
10643  	dom.SetStyle(row, "alignItems", "center")
10644  	dom.SetStyle(row, "gap", "12px")
10645  	dom.SetStyle(row, "marginBottom", "16px")
10646  
10647  	lbl := dom.CreateElement("span")
10648  	dom.SetTextContent(lbl, label)
10649  	dom.SetStyle(lbl, "fontSize", "14px")
10650  	dom.SetStyle(lbl, "minWidth", "160px")
10651  	dom.AppendChild(row, lbl)
10652  
10653  	input := dom.CreateElement("input")
10654  	dom.SetAttribute(input, "type", "text")
10655  	dom.SetAttribute(input, "placeholder", "https://...")
10656  	val := current
10657  	if val == "" && defaultVal != "" {
10658  		val = defaultVal
10659  	}
10660  	dom.SetProperty(input, "value", val)
10661  	dom.SetStyle(input, "fontFamily", "'Fira Code', monospace")
10662  	dom.SetStyle(input, "fontSize", "13px")
10663  	dom.SetStyle(input, "background", "var(--bg)")
10664  	dom.SetStyle(input, "color", "var(--fg)")
10665  	dom.SetStyle(input, "border", "1px solid var(--border)")
10666  	dom.SetStyle(input, "borderRadius", "4px")
10667  	dom.SetStyle(input, "padding", "6px 12px")
10668  	dom.SetStyle(input, "flex", "1")
10669  	dom.SetStyle(input, "minWidth", "200px")
10670  	dom.AppendChild(row, input)
10671  
10672  	if defaultVal != "" {
10673  		prefillBtn := dom.CreateElement("button")
10674  		dom.SetTextContent(prefillBtn, prefillLabel)
10675  		dom.SetStyle(prefillBtn, "padding", "6px 10px")
10676  		dom.SetStyle(prefillBtn, "borderRadius", "4px")
10677  		dom.SetStyle(prefillBtn, "border", "1px solid var(--border)")
10678  		dom.SetStyle(prefillBtn, "background", "transparent")
10679  		dom.SetStyle(prefillBtn, "color", "var(--fg)")
10680  		dom.SetStyle(prefillBtn, "cursor", "pointer")
10681  		dom.SetStyle(prefillBtn, "fontSize", "12px")
10682  		capturedInput := input
10683  		capturedDefault := defaultVal
10684  		dom.AddEventListener(prefillBtn, "click", dom.RegisterCallback(func() {
10685  			dom.SetProperty(capturedInput, "value", capturedDefault)
10686  		}))
10687  		dom.AppendChild(row, prefillBtn)
10688  	}
10689  
10690  	saveBtn := dom.CreateElement("button")
10691  	dom.SetTextContent(saveBtn, "save")
10692  	dom.SetStyle(saveBtn, "padding", "6px 16px")
10693  	dom.SetStyle(saveBtn, "borderRadius", "4px")
10694  	dom.SetStyle(saveBtn, "border", "1px solid var(--border)")
10695  	dom.SetStyle(saveBtn, "background", "var(--accent)")
10696  	dom.SetStyle(saveBtn, "color", "var(--bg)")
10697  	dom.SetStyle(saveBtn, "cursor", "pointer")
10698  	dom.SetStyle(saveBtn, "fontSize", "13px")
10699  	capturedInput2 := input
10700  	capturedLsKey := lsKey
10701  	capturedOnSet := onSet
10702  	capturedSaveBtn := saveBtn
10703  	dom.AddEventListener(saveBtn, "click", dom.RegisterCallback(func() {
10704  		v := dom.GetProperty(capturedInput2, "value")
10705  		capturedOnSet(v)
10706  		if v == "" {
10707  			localstorage.RemoveItem(capturedLsKey)
10708  		} else {
10709  			localstorage.SetItem(capturedLsKey, v)
10710  		}
10711  		saveAppSettings()
10712  		dom.SetTextContent(capturedSaveBtn, "saved!")
10713  		dom.SetTimeout(func() { dom.SetTextContent(capturedSaveBtn, "save") }, 1500)
10714  	}))
10715  	dom.AppendChild(row, saveBtn)
10716  	dom.AppendChild(parent, row)
10717  }
10718  
10719  func renderRelaysSettings() {
10720  	// Section header.
10721  	hdr := dom.CreateElement("h3")
10722  	dom.SetTextContent(hdr, t("relays_section"))
10723  	dom.SetStyle(hdr, "fontSize", "16px")
10724  	dom.SetStyle(hdr, "marginTop", "32px")
10725  	dom.SetStyle(hdr, "marginBottom", "12px")
10726  	dom.AppendChild(settingsPage, hdr)
10727  
10728  	// --- Required capabilities checkboxes ---
10729  	reqHdr := dom.CreateElement("div")
10730  	dom.SetTextContent(reqHdr, t("relays_required"))
10731  	dom.SetStyle(reqHdr, "fontSize", "13px")
10732  	dom.SetStyle(reqHdr, "color", "var(--muted)")
10733  	dom.SetStyle(reqHdr, "marginBottom", "6px")
10734  	dom.AppendChild(settingsPage, reqHdr)
10735  
10736  	addPolicyCheckbox := func(label string, bit int32) {
10737  		row := dom.CreateElement("label")
10738  		dom.SetStyle(row, "display", "flex")
10739  		dom.SetStyle(row, "alignItems", "center")
10740  		dom.SetStyle(row, "gap", "8px")
10741  		dom.SetStyle(row, "padding", "4px 0")
10742  		dom.SetStyle(row, "fontSize", "13px")
10743  		dom.SetStyle(row, "cursor", "pointer")
10744  
10745  		cb := dom.CreateElement("input")
10746  		dom.SetAttribute(cb, "type", "checkbox")
10747  		if relayPolicyFlags&bit != 0 {
10748  			dom.SetAttribute(cb, "checked", "checked")
10749  		}
10750  		dom.AddEventListener(cb, "change", dom.RegisterCallback(func() {
10751  			if dom.GetProperty(cb, "checked") == "true" {
10752  				relayPolicyFlags = relayPolicyFlags | bit
10753  			} else {
10754  				relayPolicyFlags = relayPolicyFlags &^ bit
10755  			}
10756  			saveRelayPolicy()
10757  		}))
10758  		dom.AppendChild(row, cb)
10759  
10760  		lblEl := dom.CreateElement("span")
10761  		dom.SetTextContent(lblEl, label)
10762  		dom.AppendChild(row, lblEl)
10763  
10764  		dom.AppendChild(settingsPage, row)
10765  	}
10766  
10767  	addPolicyCheckbox(t("relays_req_nip42"), policyRequireAuth)
10768  	addPolicyCheckbox(t("relays_req_nip70"), policyRequireProtect)
10769  	addPolicyCheckbox(t("relays_block_payment"), policyBlockPayment)
10770  	addPolicyCheckbox(t("relays_block_restrict"), policyBlockRestricted)
10771  
10772  	// --- Active relays list ---
10773  	activeHdr := dom.CreateElement("div")
10774  	dom.SetTextContent(activeHdr, t("relays_active"))
10775  	dom.SetStyle(activeHdr, "fontSize", "13px")
10776  	dom.SetStyle(activeHdr, "color", "var(--muted)")
10777  	dom.SetStyle(activeHdr, "marginTop", "20px")
10778  	dom.SetStyle(activeHdr, "marginBottom", "6px")
10779  	dom.AppendChild(settingsPage, activeHdr)
10780  
10781  	for i, url := range relayURLs {
10782  		thisURL := url // capture per-iteration for closures
10783  		row := dom.CreateElement("div")
10784  		dom.SetStyle(row, "padding", "3px 0")
10785  		dom.SetStyle(row, "fontSize", "12px")
10786  		dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
10787  		dom.SetStyle(row, "wordBreak", "break-all")
10788  		dom.SetStyle(row, "display", "flex")
10789  		dom.SetStyle(row, "alignItems", "center")
10790  		dom.SetStyle(row, "gap", "6px")
10791  
10792  		// Block button first so it sits at the left edge.
10793  		blockBtn := dom.CreateElement("button")
10794  		dom.SetTextContent(blockBtn, "\u00D7")
10795  		dom.SetStyle(blockBtn, "background", "transparent")
10796  		dom.SetStyle(blockBtn, "border", "1px solid var(--border)")
10797  		dom.SetStyle(blockBtn, "color", "var(--fg)")
10798  		dom.SetStyle(blockBtn, "cursor", "pointer")
10799  		dom.SetStyle(blockBtn, "fontFamily", "'Fira Code', monospace")
10800  		dom.SetStyle(blockBtn, "fontSize", "11px")
10801  		dom.SetStyle(blockBtn, "width", "18px")
10802  		dom.SetStyle(blockBtn, "height", "18px")
10803  		dom.SetStyle(blockBtn, "padding", "0")
10804  		dom.SetStyle(blockBtn, "lineHeight", "1")
10805  		dom.SetStyle(blockBtn, "flexShrink", "0")
10806  		dom.AddEventListener(blockBtn, "click", dom.RegisterCallback(func() {
10807  			if !dom.Confirm("Block " | thisURL | "?") {
10808  				return
10809  			}
10810  			addToBlocklist(thisURL)
10811  			renderSettings()
10812  		}))
10813  		dom.AppendChild(row, blockBtn)
10814  
10815  		urlSpan := dom.CreateElement("span")
10816  		dom.SetTextContent(urlSpan, thisURL)
10817  		dom.SetStyle(urlSpan, "flex", "1")
10818  		dom.SetStyle(urlSpan, "overflow", "hidden")
10819  		dom.SetStyle(urlSpan, "textOverflow", "ellipsis")
10820  		dom.SetStyle(urlSpan, "whiteSpace", "nowrap")
10821  		if relayUserPick[i] {
10822  			dom.SetStyle(urlSpan, "fontWeight", "bold")
10823  		}
10824  		dom.AppendChild(row, urlSpan)
10825  
10826  		// Read / write role buttons.
10827  		thisI := i
10828  		makeSettingsRoleBtn := func(label string) dom.Element {
10829  			b := dom.CreateElement("button")
10830  			dom.SetTextContent(b, label)
10831  			dom.SetStyle(b, "flexShrink", "0")
10832  			dom.SetStyle(b, "fontSize", "10px")
10833  			dom.SetStyle(b, "fontFamily", "'Fira Code', monospace")
10834  			dom.SetStyle(b, "padding", "1px 5px")
10835  			dom.SetStyle(b, "borderRadius", "3px")
10836  			dom.SetStyle(b, "border", "1px solid var(--border)")
10837  			dom.SetStyle(b, "cursor", "pointer")
10838  			return b
10839  		}
10840  		srBtn := makeSettingsRoleBtn("r")
10841  		swBtn := makeSettingsRoleBtn("w")
10842  		role := "both"
10843  		if thisI < len(relayRole) {
10844  			role = relayRole[thisI]
10845  		}
10846  		applyRelayRoleBtns(srBtn, swBtn, role)
10847  		capturedI := thisI
10848  		dom.AddEventListener(srBtn, "click", dom.RegisterCallback(func() {
10849  			if capturedI >= len(relayRole) {
10850  				return
10851  			}
10852  			switch relayRole[capturedI] {
10853  			case "both":
10854  				relayRole[capturedI] = "write"
10855  			case "read":
10856  				relayRole[capturedI] = "write"
10857  			default:
10858  				relayRole[capturedI] = "both"
10859  			}
10860  			if capturedI < len(relayReadBtns) {
10861  				applyRelayRoleBtns(relayReadBtns[capturedI], relayWriteBtns[capturedI], relayRole[capturedI])
10862  			}
10863  			applyRelayRoleBtns(srBtn, swBtn, relayRole[capturedI])
10864  			saveRelayList()
10865  			sendWriteRelays()
10866  		}))
10867  		dom.AddEventListener(swBtn, "click", dom.RegisterCallback(func() {
10868  			if capturedI >= len(relayRole) {
10869  				return
10870  			}
10871  			switch relayRole[capturedI] {
10872  			case "both":
10873  				relayRole[capturedI] = "read"
10874  			case "write":
10875  				relayRole[capturedI] = "read"
10876  			default:
10877  				relayRole[capturedI] = "both"
10878  			}
10879  			if capturedI < len(relayReadBtns) {
10880  				applyRelayRoleBtns(relayReadBtns[capturedI], relayWriteBtns[capturedI], relayRole[capturedI])
10881  			}
10882  			applyRelayRoleBtns(srBtn, swBtn, relayRole[capturedI])
10883  			saveRelayList()
10884  			sendWriteRelays()
10885  		}))
10886  		caps := relayCaps[i]
10887  		if !hasNIP(caps.supportedNIPs, 42) {
10888  			dom.AppendChild(row, makeRelayBadge("!42"))
10889  		}
10890  		if !hasNIP(caps.supportedNIPs, 70) {
10891  			dom.AppendChild(row, makeRelayBadge("!70"))
10892  		}
10893  		if caps.fetchedOK && hasNIP(caps.supportedNIPs, 50) {
10894  			badge := makeRelayBadge("search")
10895  			dom.SetStyle(badge, "background", "rgba(0,180,80,0.15)")
10896  			dom.SetStyle(badge, "color", "#0a0")
10897  			dom.SetStyle(badge, "border", "1px solid rgba(0,180,80,0.3)")
10898  			dom.AppendChild(row, badge)
10899  		}
10900  		if !caps.fetchedOK {
10901  			noDoc := dom.CreateElement("span")
10902  			dom.SetTextContent(noDoc, t("relays_no_nip11"))
10903  			dom.SetStyle(noDoc, "fontSize", "10px")
10904  			dom.SetStyle(noDoc, "color", "var(--muted)")
10905  			dom.SetStyle(noDoc, "marginLeft", "6px")
10906  			dom.AppendChild(row, noDoc)
10907  		}
10908  
10909  		// r/w buttons pushed to far right.
10910  		dom.SetStyle(srBtn, "marginLeft", "auto")
10911  		dom.AppendChild(row, srBtn)
10912  		dom.AppendChild(row, swBtn)
10913  		dom.AppendChild(settingsPage, row)
10914  	}
10915  
10916  	// --- Add relay input ---
10917  	addRow := dom.CreateElement("div")
10918  	dom.SetStyle(addRow, "display", "flex")
10919  	dom.SetStyle(addRow, "alignItems", "center")
10920  	dom.SetStyle(addRow, "gap", "6px")
10921  	dom.SetStyle(addRow, "marginTop", "8px")
10922  	dom.SetStyle(addRow, "position", "relative")
10923  
10924  	addInput := dom.CreateElement("input")
10925  	dom.SetAttribute(addInput, "type", "text")
10926  	dom.SetAttribute(addInput, "placeholder", "wss://relay.example.com")
10927  	dom.SetStyle(addInput, "flex", "1")
10928  	dom.SetStyle(addInput, "padding", "6px 8px")
10929  	dom.SetStyle(addInput, "fontSize", "12px")
10930  	dom.SetStyle(addInput, "fontFamily", "'Fira Code', monospace")
10931  	dom.SetStyle(addInput, "background", "var(--bg)")
10932  	dom.SetStyle(addInput, "color", "var(--fg)")
10933  	dom.SetStyle(addInput, "border", "1px solid var(--border)")
10934  	dom.SetStyle(addInput, "borderRadius", "4px")
10935  
10936  	addBtn := dom.CreateElement("button")
10937  	dom.SetTextContent(addBtn, "+")
10938  	dom.SetStyle(addBtn, "padding", "6px 10px")
10939  	dom.SetStyle(addBtn, "background", "var(--accent)")
10940  	dom.SetStyle(addBtn, "color", "#fff")
10941  	dom.SetStyle(addBtn, "border", "none")
10942  	dom.SetStyle(addBtn, "borderRadius", "4px")
10943  	dom.SetStyle(addBtn, "cursor", "pointer")
10944  	dom.SetStyle(addBtn, "fontWeight", "bold")
10945  	dom.AddEventListener(addBtn, "click", dom.RegisterCallback(func() {
10946  		url := dom.GetProperty(addInput, "value")
10947  		if url != "" {
10948  			addRelayURL(url)
10949  			dom.SetProperty(addInput, "value", "")
10950  			renderSettings()
10951  		}
10952  	}))
10953  
10954  	// Suggestion dropdown button.
10955  	sugWrap := dom.CreateElement("div")
10956  	dom.SetStyle(sugWrap, "position", "relative")
10957  
10958  	sugBtn := dom.CreateElement("button")
10959  	dom.SetTextContent(sugBtn, "\u25BE")
10960  	dom.SetStyle(sugBtn, "padding", "6px 8px")
10961  	dom.SetStyle(sugBtn, "background", "var(--bg2)")
10962  	dom.SetStyle(sugBtn, "color", "var(--fg)")
10963  	dom.SetStyle(sugBtn, "border", "1px solid var(--border)")
10964  	dom.SetStyle(sugBtn, "borderRadius", "4px")
10965  	dom.SetStyle(sugBtn, "cursor", "pointer")
10966  
10967  	sugPop := dom.CreateElement("div")
10968  	dom.SetStyle(sugPop, "display", "none")
10969  	dom.SetStyle(sugPop, "position", "absolute")
10970  	dom.SetStyle(sugPop, "bottom", "100%")
10971  	dom.SetStyle(sugPop, "right", "0")
10972  	dom.SetStyle(sugPop, "marginBottom", "4px")
10973  	dom.SetStyle(sugPop, "background", "var(--bg)")
10974  	dom.SetStyle(sugPop, "border", "1px solid var(--border)")
10975  	dom.SetStyle(sugPop, "borderRadius", "6px")
10976  	dom.SetStyle(sugPop, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)")
10977  	dom.SetStyle(sugPop, "zIndex", "1000")
10978  	dom.SetStyle(sugPop, "minWidth", "280px")
10979  	dom.SetStyle(sugPop, "maxHeight", "200px")
10980  	dom.SetStyle(sugPop, "overflowY", "auto")
10981  	dom.SetStyle(sugPop, "padding", "4px")
10982  
10983  	sugOpen := false
10984  	dom.AddEventListener(sugBtn, "click", dom.RegisterCallback(func() {
10985  		if sugOpen {
10986  			dom.SetStyle(sugPop, "display", "none")
10987  			sugOpen = false
10988  			return
10989  		}
10990  		// Build sorted suggestions.
10991  		clearChildren(sugPop)
10992  		sorted := sortRelaysByFreq()
10993  		count := 0
10994  		for _, entry := range sorted {
10995  			u := entry.url
10996  			// Skip already-active relays.
10997  			already := false
10998  			for _, active := range relayURLs {
10999  				if active == u {
11000  					already = true
11001  					break
11002  				}
11003  			}
11004  			if already {
11005  				continue
11006  			}
11007  			sugRow := dom.CreateElement("div")
11008  			dom.SetStyle(sugRow, "display", "flex")
11009  			dom.SetStyle(sugRow, "alignItems", "center")
11010  			dom.SetStyle(sugRow, "justifyContent", "space-between")
11011  			dom.SetStyle(sugRow, "padding", "4px 8px")
11012  			dom.SetStyle(sugRow, "cursor", "pointer")
11013  			dom.SetStyle(sugRow, "borderRadius", "4px")
11014  			dom.SetStyle(sugRow, "fontSize", "12px")
11015  			dom.SetStyle(sugRow, "fontFamily", "'Fira Code', monospace")
11016  
11017  			urlSpan := dom.CreateElement("span")
11018  			dom.SetTextContent(urlSpan, stripScheme(u))
11019  			dom.AppendChild(sugRow, urlSpan)
11020  
11021  			freqSpan := dom.CreateElement("span")
11022  			dom.SetTextContent(freqSpan, itoa(entry.freq))
11023  			dom.SetStyle(freqSpan, "color", "var(--muted)")
11024  			dom.SetStyle(freqSpan, "fontSize", "10px")
11025  			dom.AppendChild(sugRow, freqSpan)
11026  
11027  			dom.AddEventListener(sugRow, "mouseenter", dom.RegisterCallback(func() {
11028  				dom.SetStyle(sugRow, "background", "var(--bg2)")
11029  			}))
11030  			dom.AddEventListener(sugRow, "mouseleave", dom.RegisterCallback(func() {
11031  				dom.SetStyle(sugRow, "background", "transparent")
11032  			}))
11033  
11034  			capURL := u
11035  			dom.AddEventListener(sugRow, "click", dom.RegisterCallback(func() {
11036  				addRelayURL(capURL)
11037  				dom.SetStyle(sugPop, "display", "none")
11038  				sugOpen = false
11039  				renderSettings()
11040  			}))
11041  			dom.AppendChild(sugPop, sugRow)
11042  			count++
11043  		}
11044  		if count == 0 {
11045  			empty := dom.CreateElement("div")
11046  			dom.SetTextContent(empty, "no suggestions yet")
11047  			dom.SetStyle(empty, "color", "var(--muted)")
11048  			dom.SetStyle(empty, "fontSize", "12px")
11049  			dom.SetStyle(empty, "padding", "8px")
11050  			dom.AppendChild(sugPop, empty)
11051  		}
11052  		dom.SetStyle(sugPop, "display", "block")
11053  		sugOpen = true
11054  	}))
11055  
11056  	dom.AppendChild(sugWrap, sugBtn)
11057  	dom.AppendChild(sugWrap, sugPop)
11058  
11059  	dom.AppendChild(addRow, addInput)
11060  	dom.AppendChild(addRow, addBtn)
11061  	dom.AppendChild(addRow, sugWrap)
11062  	dom.AppendChild(settingsPage, addRow)
11063  
11064  	// --- Blocklist ---
11065  	blHdr := dom.CreateElement("div")
11066  	dom.SetTextContent(blHdr, t("relays_blocklist"))
11067  	dom.SetStyle(blHdr, "fontSize", "13px")
11068  	dom.SetStyle(blHdr, "color", "var(--muted)")
11069  	dom.SetStyle(blHdr, "marginTop", "20px")
11070  	dom.SetStyle(blHdr, "marginBottom", "6px")
11071  	dom.AppendChild(settingsPage, blHdr)
11072  
11073  	if len(relayBlocklist) == 0 {
11074  		empty := dom.CreateElement("div")
11075  		dom.SetTextContent(empty, t("relays_blocklist_empty"))
11076  		dom.SetStyle(empty, "fontSize", "12px")
11077  		dom.SetStyle(empty, "color", "var(--muted)")
11078  		dom.SetStyle(empty, "fontStyle", "italic")
11079  		dom.AppendChild(settingsPage, empty)
11080  	} else {
11081  		for _, url := range relayBlocklist {
11082  			thisURL := url // capture per-iteration for closure
11083  			row := dom.CreateElement("div")
11084  			dom.SetStyle(row, "padding", "3px 0")
11085  			dom.SetStyle(row, "fontSize", "12px")
11086  			dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
11087  			dom.SetStyle(row, "wordBreak", "break-all")
11088  			dom.SetStyle(row, "display", "flex")
11089  			dom.SetStyle(row, "alignItems", "center")
11090  			dom.SetStyle(row, "gap", "6px")
11091  
11092  			unblockBtn := dom.CreateElement("button")
11093  			dom.SetTextContent(unblockBtn, "\u00D7")
11094  			dom.SetStyle(unblockBtn, "background", "transparent")
11095  			dom.SetStyle(unblockBtn, "border", "1px solid var(--border)")
11096  			dom.SetStyle(unblockBtn, "color", "var(--fg)")
11097  			dom.SetStyle(unblockBtn, "cursor", "pointer")
11098  			dom.SetStyle(unblockBtn, "fontFamily", "'Fira Code', monospace")
11099  			dom.SetStyle(unblockBtn, "fontSize", "11px")
11100  			dom.SetStyle(unblockBtn, "width", "18px")
11101  			dom.SetStyle(unblockBtn, "height", "18px")
11102  			dom.SetStyle(unblockBtn, "padding", "0")
11103  			dom.SetStyle(unblockBtn, "lineHeight", "1")
11104  			dom.SetStyle(unblockBtn, "flexShrink", "0")
11105  			dom.AddEventListener(unblockBtn, "click", dom.RegisterCallback(func() {
11106  				removeFromBlocklist(thisURL)
11107  				renderSettings()
11108  			}))
11109  			dom.AppendChild(row, unblockBtn)
11110  
11111  			urlSpan := dom.CreateElement("span")
11112  			dom.SetTextContent(urlSpan, thisURL)
11113  			dom.AppendChild(row, urlSpan)
11114  
11115  			dom.AppendChild(settingsPage, row)
11116  		}
11117  	}
11118  
11119  	// --- Add to blocklist input ---
11120  	blAddRow := dom.CreateElement("div")
11121  	dom.SetStyle(blAddRow, "display", "flex")
11122  	dom.SetStyle(blAddRow, "alignItems", "center")
11123  	dom.SetStyle(blAddRow, "gap", "6px")
11124  	dom.SetStyle(blAddRow, "marginTop", "8px")
11125  	dom.SetStyle(blAddRow, "position", "relative")
11126  
11127  	blAddInput := dom.CreateElement("input")
11128  	dom.SetAttribute(blAddInput, "type", "text")
11129  	dom.SetAttribute(blAddInput, "placeholder", "wss://relay.example.com")
11130  	dom.SetStyle(blAddInput, "flex", "1")
11131  	dom.SetStyle(blAddInput, "padding", "6px 8px")
11132  	dom.SetStyle(blAddInput, "fontSize", "12px")
11133  	dom.SetStyle(blAddInput, "fontFamily", "'Fira Code', monospace")
11134  	dom.SetStyle(blAddInput, "background", "var(--bg)")
11135  	dom.SetStyle(blAddInput, "color", "var(--fg)")
11136  	dom.SetStyle(blAddInput, "border", "1px solid var(--border)")
11137  	dom.SetStyle(blAddInput, "borderRadius", "4px")
11138  
11139  	blAddBtn := dom.CreateElement("button")
11140  	dom.SetTextContent(blAddBtn, "+")
11141  	dom.SetStyle(blAddBtn, "padding", "6px 10px")
11142  	dom.SetStyle(blAddBtn, "background", "var(--accent)")
11143  	dom.SetStyle(blAddBtn, "color", "#fff")
11144  	dom.SetStyle(blAddBtn, "border", "none")
11145  	dom.SetStyle(blAddBtn, "borderRadius", "4px")
11146  	dom.SetStyle(blAddBtn, "cursor", "pointer")
11147  	dom.SetStyle(blAddBtn, "fontWeight", "bold")
11148  	dom.AddEventListener(blAddBtn, "click", dom.RegisterCallback(func() {
11149  		url := dom.GetProperty(blAddInput, "value")
11150  		if url != "" {
11151  			addToBlocklist(url)
11152  			dom.SetProperty(blAddInput, "value", "")
11153  			renderSettings()
11154  		}
11155  	}))
11156  
11157  	// Suggestion dropdown button for blocklist.
11158  	blSugWrap := dom.CreateElement("div")
11159  	dom.SetStyle(blSugWrap, "position", "relative")
11160  
11161  	blSugBtn := dom.CreateElement("button")
11162  	dom.SetTextContent(blSugBtn, "\u25BE")
11163  	dom.SetStyle(blSugBtn, "padding", "6px 8px")
11164  	dom.SetStyle(blSugBtn, "background", "var(--bg2)")
11165  	dom.SetStyle(blSugBtn, "color", "var(--fg)")
11166  	dom.SetStyle(blSugBtn, "border", "1px solid var(--border)")
11167  	dom.SetStyle(blSugBtn, "borderRadius", "4px")
11168  	dom.SetStyle(blSugBtn, "cursor", "pointer")
11169  
11170  	blSugPop := dom.CreateElement("div")
11171  	dom.SetStyle(blSugPop, "display", "none")
11172  	dom.SetStyle(blSugPop, "position", "absolute")
11173  	dom.SetStyle(blSugPop, "bottom", "100%")
11174  	dom.SetStyle(blSugPop, "right", "0")
11175  	dom.SetStyle(blSugPop, "marginBottom", "4px")
11176  	dom.SetStyle(blSugPop, "background", "var(--bg)")
11177  	dom.SetStyle(blSugPop, "border", "1px solid var(--border)")
11178  	dom.SetStyle(blSugPop, "borderRadius", "6px")
11179  	dom.SetStyle(blSugPop, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)")
11180  	dom.SetStyle(blSugPop, "zIndex", "1000")
11181  	dom.SetStyle(blSugPop, "minWidth", "280px")
11182  	dom.SetStyle(blSugPop, "maxHeight", "200px")
11183  	dom.SetStyle(blSugPop, "overflowY", "auto")
11184  	dom.SetStyle(blSugPop, "padding", "4px")
11185  
11186  	blSugOpen := false
11187  	dom.AddEventListener(blSugBtn, "click", dom.RegisterCallback(func() {
11188  		if blSugOpen {
11189  			dom.SetStyle(blSugPop, "display", "none")
11190  			blSugOpen = false
11191  			return
11192  		}
11193  		clearChildren(blSugPop)
11194  		sorted := sortRelaysByFreq()
11195  		count := 0
11196  		for _, entry := range sorted {
11197  			u := entry.url
11198  			if isBlocked(u) {
11199  				continue
11200  			}
11201  			sugRow := dom.CreateElement("div")
11202  			dom.SetStyle(sugRow, "display", "flex")
11203  			dom.SetStyle(sugRow, "alignItems", "center")
11204  			dom.SetStyle(sugRow, "justifyContent", "space-between")
11205  			dom.SetStyle(sugRow, "padding", "4px 8px")
11206  			dom.SetStyle(sugRow, "cursor", "pointer")
11207  			dom.SetStyle(sugRow, "borderRadius", "4px")
11208  			dom.SetStyle(sugRow, "fontSize", "12px")
11209  			dom.SetStyle(sugRow, "fontFamily", "'Fira Code', monospace")
11210  
11211  			urlSpan := dom.CreateElement("span")
11212  			dom.SetTextContent(urlSpan, stripScheme(u))
11213  			dom.AppendChild(sugRow, urlSpan)
11214  
11215  			freqSpan := dom.CreateElement("span")
11216  			dom.SetTextContent(freqSpan, itoa(entry.freq))
11217  			dom.SetStyle(freqSpan, "color", "var(--muted)")
11218  			dom.SetStyle(freqSpan, "fontSize", "10px")
11219  			dom.AppendChild(sugRow, freqSpan)
11220  
11221  			dom.AddEventListener(sugRow, "mouseenter", dom.RegisterCallback(func() {
11222  				dom.SetStyle(sugRow, "background", "var(--bg2)")
11223  			}))
11224  			dom.AddEventListener(sugRow, "mouseleave", dom.RegisterCallback(func() {
11225  				dom.SetStyle(sugRow, "background", "transparent")
11226  			}))
11227  
11228  			capURL := u
11229  			dom.AddEventListener(sugRow, "click", dom.RegisterCallback(func() {
11230  				addToBlocklist(capURL)
11231  				dom.SetStyle(blSugPop, "display", "none")
11232  				blSugOpen = false
11233  				renderSettings()
11234  			}))
11235  			dom.AppendChild(blSugPop, sugRow)
11236  			count++
11237  		}
11238  		if count == 0 {
11239  			empty := dom.CreateElement("div")
11240  			dom.SetTextContent(empty, "no suggestions yet")
11241  			dom.SetStyle(empty, "color", "var(--muted)")
11242  			dom.SetStyle(empty, "fontSize", "12px")
11243  			dom.SetStyle(empty, "padding", "8px")
11244  			dom.AppendChild(blSugPop, empty)
11245  		}
11246  		dom.SetStyle(blSugPop, "display", "block")
11247  		blSugOpen = true
11248  	}))
11249  
11250  	dom.AppendChild(blSugWrap, blSugBtn)
11251  	dom.AppendChild(blSugWrap, blSugPop)
11252  
11253  	dom.AppendChild(blAddRow, blAddInput)
11254  	dom.AppendChild(blAddRow, blAddBtn)
11255  	dom.AppendChild(blAddRow, blSugWrap)
11256  	dom.AppendChild(settingsPage, blAddRow)
11257  
11258  	// Wallet UI at the very bottom of the settings page.
11259  	if int32(walletPanel) != 0 {
11260  		dom.AppendChild(settingsPage, walletPanel)
11261  		renderWalletUI()
11262  	}
11263  }
11264  
11265  // --- Messaging ---
11266  
11267  func initMessaging() {
11268  	// Render new-chat button immediately - don't wait for DM_LIST round-trip.
11269  	clearChildren(msgListContainer)
11270  
11271  	if !signer.HasSigner() {
11272  		notice := dom.CreateElement("div")
11273  		dom.SetStyle(notice, "padding", "24px")
11274  		dom.SetStyle(notice, "textAlign", "center")
11275  		dom.SetStyle(notice, "color", "var(--muted)")
11276  		dom.SetStyle(notice, "fontSize", "13px")
11277  		dom.SetStyle(notice, "lineHeight", "1.6")
11278  		dom.SetInnerHTML(notice, t("dm_notice"))
11279  		dom.AppendChild(msgListContainer, notice)
11280  		return
11281  	}
11282  
11283  	renderNewChatRow()
11284  
11285  	// Init MLS if not already done + request conversation list.
11286  	if !marmotInited {
11287  		marmotInited = true
11288  		routeMsg("[\"MLS_INIT\"," | relayURLsJSON() | "]")
11289  	}
11290  	routeMsg("[\"MLS_DM_LIST\"]")
11291  }
11292  
11293  func renderNewChatRow() {
11294  	row := dom.CreateElement("div")
11295  	dom.SetStyle(row, "display", "flex")
11296  	dom.SetStyle(row, "gap", "6px")
11297  	dom.SetStyle(row, "marginBottom", "8px")
11298  	dom.SetStyle(row, "alignItems", "center")
11299  
11300  	// --- Follows group (default visible): [follows ▾] [+] ---
11301  	followsGroup := dom.CreateElement("div")
11302  	dom.SetStyle(followsGroup, "display", "flex")
11303  	dom.SetStyle(followsGroup, "gap", "6px")
11304  	dom.SetStyle(followsGroup, "alignItems", "center")
11305  
11306  	selWrap := dom.CreateElement("div")
11307  	dom.SetStyle(selWrap, "position", "relative")
11308  
11309  	selBtn := dom.CreateElement("button")
11310  	dom.SetTextContent(selBtn, t("follows") | " \u25BE")
11311  	dom.SetStyle(selBtn, "padding", "8px 12px")
11312  	dom.SetStyle(selBtn, "fontFamily", "'Fira Code', monospace")
11313  	dom.SetStyle(selBtn, "fontSize", "12px")
11314  	dom.SetStyle(selBtn, "background", "var(--bg2)")
11315  	dom.SetStyle(selBtn, "border", "1px solid var(--border)")
11316  	dom.SetStyle(selBtn, "borderRadius", "4px")
11317  	dom.SetStyle(selBtn, "color", "var(--fg)")
11318  	dom.SetStyle(selBtn, "cursor", "pointer")
11319  
11320  	dropdown := dom.CreateElement("div")
11321  	dom.SetStyle(dropdown, "display", "none")
11322  	dom.SetStyle(dropdown, "position", "absolute")
11323  	dom.SetStyle(dropdown, "left", "0")
11324  	dom.SetStyle(dropdown, "top", "100%")
11325  	dom.SetStyle(dropdown, "marginTop", "4px")
11326  	dom.SetStyle(dropdown, "background", "var(--bg)")
11327  	dom.SetStyle(dropdown, "border", "1px solid var(--border)")
11328  	dom.SetStyle(dropdown, "borderRadius", "6px")
11329  	dom.SetStyle(dropdown, "maxHeight", "300px")
11330  	dom.SetStyle(dropdown, "overflowY", "auto")
11331  	dom.SetStyle(dropdown, "minWidth", "200px")
11332  	dom.SetStyle(dropdown, "maxWidth", "calc(100vw - 32px)")
11333  	dom.SetStyle(dropdown, "zIndex", "100")
11334  	dom.SetStyle(dropdown, "boxShadow", "0 4px 12px rgba(0,0,0,0.3)")
11335  
11336  	populateFollowsDropdown(dropdown)
11337  
11338  	dom.SetAttribute(selWrap, "onclick", "event.stopPropagation()")
11339  
11340  	dropOpen := false
11341  	dom.AddEventListener(selBtn, "click", dom.RegisterCallback(func() {
11342  		if !dropOpen {
11343  			populateFollowsDropdown(dropdown)
11344  			dom.SetStyle(dropdown, "display", "block")
11345  			dropOpen = true
11346  		} else {
11347  			dom.SetStyle(dropdown, "display", "none")
11348  			dropOpen = false
11349  		}
11350  	}))
11351  
11352  	dom.AddEventListener(dom.Body(), "click", dom.RegisterCallback(func() {
11353  		dom.SetStyle(dropdown, "display", "none")
11354  		dropOpen = false
11355  	}))
11356  
11357  	dom.AppendChild(selWrap, selBtn)
11358  	dom.AppendChild(selWrap, dropdown)
11359  
11360  	addBtn := dom.CreateElement("button")
11361  	dom.SetTextContent(addBtn, "+")
11362  	dom.SetStyle(addBtn, "padding", "8px 10px")
11363  	dom.SetStyle(addBtn, "fontFamily", "'Fira Code', monospace")
11364  	dom.SetStyle(addBtn, "fontSize", "14px")
11365  	dom.SetStyle(addBtn, "fontWeight", "bold")
11366  	dom.SetStyle(addBtn, "background", "var(--bg2)")
11367  	dom.SetStyle(addBtn, "border", "1px solid var(--border)")
11368  	dom.SetStyle(addBtn, "borderRadius", "4px")
11369  	dom.SetStyle(addBtn, "color", "var(--fg)")
11370  	dom.SetStyle(addBtn, "cursor", "pointer")
11371  	dom.SetStyle(addBtn, "lineHeight", "1")
11372  
11373  	dom.AppendChild(followsGroup, selWrap)
11374  	dom.AppendChild(followsGroup, addBtn)
11375  
11376  	// --- Input group (hidden until + clicked): [input] [go] [×] ---
11377  	inputGroup := dom.CreateElement("div")
11378  	dom.SetStyle(inputGroup, "display", "none")
11379  	dom.SetStyle(inputGroup, "flex", "1")
11380  	dom.SetStyle(inputGroup, "gap", "6px")
11381  	dom.SetStyle(inputGroup, "alignItems", "center")
11382  
11383  	inp := dom.CreateElement("input")
11384  	dom.SetAttribute(inp, "type", "text")
11385  	dom.SetAttribute(inp, "placeholder", t("npub_placeholder"))
11386  	dom.SetStyle(inp, "flex", "1 1 120px")
11387  	dom.SetStyle(inp, "minWidth", "0")
11388  	dom.SetStyle(inp, "padding", "8px")
11389  	dom.SetStyle(inp, "fontFamily", "'Fira Code', monospace")
11390  	dom.SetStyle(inp, "fontSize", "12px")
11391  	dom.SetStyle(inp, "background", "var(--bg)")
11392  	dom.SetStyle(inp, "border", "1px solid var(--border)")
11393  	dom.SetStyle(inp, "borderRadius", "4px")
11394  	dom.SetStyle(inp, "color", "var(--fg)")
11395  
11396  	goBtn := dom.CreateElement("button")
11397  	dom.SetTextContent(goBtn, t("go"))
11398  	dom.SetStyle(goBtn, "padding", "8px 14px")
11399  	dom.SetStyle(goBtn, "fontFamily", "'Fira Code', monospace")
11400  	dom.SetStyle(goBtn, "fontSize", "12px")
11401  	dom.SetStyle(goBtn, "background", "var(--accent)")
11402  	dom.SetStyle(goBtn, "color", "#fff")
11403  	dom.SetStyle(goBtn, "border", "none")
11404  	dom.SetStyle(goBtn, "borderRadius", "4px")
11405  	dom.SetStyle(goBtn, "cursor", "pointer")
11406  	dom.SetStyle(goBtn, "flexShrink", "0")
11407  
11408  	closeBtn := dom.CreateElement("button")
11409  	dom.SetTextContent(closeBtn, "\u00D7")
11410  	dom.SetStyle(closeBtn, "padding", "8px 10px")
11411  	dom.SetStyle(closeBtn, "fontFamily", "'Fira Code', monospace")
11412  	dom.SetStyle(closeBtn, "fontSize", "14px")
11413  	dom.SetStyle(closeBtn, "fontWeight", "bold")
11414  	dom.SetStyle(closeBtn, "background", "var(--bg2)")
11415  	dom.SetStyle(closeBtn, "border", "1px solid var(--border)")
11416  	dom.SetStyle(closeBtn, "borderRadius", "4px")
11417  	dom.SetStyle(closeBtn, "color", "var(--fg)")
11418  	dom.SetStyle(closeBtn, "cursor", "pointer")
11419  	dom.SetStyle(closeBtn, "lineHeight", "1")
11420  
11421  	dom.AppendChild(inputGroup, inp)
11422  	dom.AppendChild(inputGroup, goBtn)
11423  	dom.AppendChild(inputGroup, closeBtn)
11424  
11425  	// Toggle between follows group and input group.
11426  	showInput := func() {
11427  		dom.SetStyle(dropdown, "display", "none")
11428  		dropOpen = false
11429  		dom.SetStyle(followsGroup, "display", "none")
11430  		dom.SetStyle(inputGroup, "display", "flex")
11431  		dom.SetProperty(inp, "value", "")
11432  		dom.Focus(inp)
11433  	}
11434  	showFollows := func() {
11435  		dom.SetStyle(inputGroup, "display", "none")
11436  		dom.SetStyle(followsGroup, "display", "flex")
11437  	}
11438  
11439  	dom.AddEventListener(addBtn, "click", dom.RegisterCallback(showInput))
11440  	dom.AddEventListener(closeBtn, "click", dom.RegisterCallback(showFollows))
11441  
11442  	// Submit handler for input + go button.
11443  	submitNewChat := func() {
11444  		val := dom.GetProperty(inp, "value")
11445  		if val == "" {
11446  			return
11447  		}
11448  		var hexPK string
11449  		if len(val) == 64 {
11450  			hexPK = val
11451  		} else if len(val) > 4 && val[:4] == "npub" {
11452  			decoded := helpers.DecodeNpub(val)
11453  			if decoded == nil {
11454  				return
11455  			}
11456  			hexPK = helpers.HexEncode(decoded)
11457  		} else {
11458  			return
11459  		}
11460  		openThread(hexPK)
11461  	}
11462  
11463  	dom.AddEventListener(goBtn, "click", dom.RegisterCallback(submitNewChat))
11464  	dom.SetAttribute(inp, "onkeydown", "if(event.key==='Enter'){event.preventDefault();this.nextSibling.click()}")
11465  
11466  	dom.AppendChild(row, followsGroup)
11467  	dom.AppendChild(row, inputGroup)
11468  	dom.AppendChild(msgListContainer, row)
11469  }
11470  
11471  func populateFollowsDropdown(dropdown dom.Element) {
11472  	clearChildren(dropdown)
11473  	pks := myFollows
11474  	if len(pks) == 0 {
11475  		empty := dom.CreateElement("div")
11476  		dom.SetStyle(empty, "padding", "12px")
11477  		dom.SetStyle(empty, "color", "var(--muted)")
11478  		dom.SetStyle(empty, "fontSize", "12px")
11479  		dom.SetTextContent(empty, t("no_follows"))
11480  		dom.AppendChild(dropdown, empty)
11481  		return
11482  	}
11483  	// Sort by display name (named first, then by name alpha, unnamed last by short hex).
11484  	type entry struct {
11485  		pk   string
11486  		name string
11487  	}
11488  	var named []entry
11489  	var unnamed []entry
11490  	for _, pk := range pks {
11491  		n := authorNames[pk]
11492  		if n != "" {
11493  			named = append(named, entry{pk, n})
11494  		} else {
11495  			npub := helpers.EncodeNpub(helpers.HexDecode(pk))
11496  			if len(npub) > 16 {
11497  				npub = npub[:16] | "..."
11498  			}
11499  			unnamed = append(unnamed, entry{pk, npub})
11500  		}
11501  	}
11502  	// Simple insertion sort by name.
11503  	for i := 1; i < len(named); i++ {
11504  		j := i
11505  		for j > 0 && toLower(named[j].name) < toLower(named[j-1].name) {
11506  			named[j], named[j-1] = named[j-1], named[j]
11507  			j--
11508  		}
11509  	}
11510  	var all []entry
11511  	for _, e := range named {
11512  		all = append(all, e)
11513  	}
11514  	for _, e := range unnamed {
11515  		all = append(all, e)
11516  	}
11517  	for _, e := range all {
11518  		item := dom.CreateElement("div")
11519  		dom.SetStyle(item, "padding", "6px 12px")
11520  		dom.SetStyle(item, "fontSize", "13px")
11521  		dom.SetStyle(item, "cursor", "pointer")
11522  		dom.SetStyle(item, "display", "flex")
11523  		dom.SetStyle(item, "alignItems", "center")
11524  		dom.SetStyle(item, "gap", "8px")
11525  
11526  		av := dom.CreateElement("img")
11527  		dom.SetAttribute(av, "referrerpolicy", "no-referrer")
11528  		dom.SetAttribute(av, "width", "24")
11529  		dom.SetAttribute(av, "height", "24")
11530  		dom.SetStyle(av, "borderRadius", "50%")
11531  		dom.SetStyle(av, "objectFit", "cover")
11532  		dom.SetStyle(av, "flexShrink", "0")
11533  		if pic, ok := authorPics[e.pk]; ok && pic != "" {
11534  			setMediaSrc(av, pic)
11535  		} else {
11536  			dom.SetStyle(av, "background", "var(--bg2)")
11537  		}
11538  		dom.SetAttribute(av, "onerror", "this.style.display='none'")
11539  		dom.AppendChild(item, av)
11540  
11541  		nameSpan := dom.CreateElement("span")
11542  		dom.SetStyle(nameSpan, "overflow", "hidden")
11543  		dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
11544  		dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
11545  		dom.SetTextContent(nameSpan, e.name)
11546  		dom.AppendChild(item, nameSpan)
11547  
11548  		pk := e.pk
11549  		dom.AddEventListener(item, "mouseenter", dom.RegisterCallback(func() {
11550  			dom.SetStyle(item, "background", "var(--bg2)")
11551  		}))
11552  		dom.AddEventListener(item, "mouseleave", dom.RegisterCallback(func() {
11553  			dom.SetStyle(item, "background", "transparent")
11554  		}))
11555  		dom.AddEventListener(item, "click", dom.RegisterCallback(func() {
11556  			dom.SetStyle(dropdown, "display", "none")
11557  			openThread(pk)
11558  		}))
11559  		dom.AppendChild(dropdown, item)
11560  	}
11561  }
11562  
11563  func renderConversationList(listJSON string) {
11564  	if msgView != "list" {
11565  		return
11566  	}
11567  	clearChildren(msgListContainer)
11568  	renderNewChatRow()
11569  
11570  	// Parse the list JSON array: [{peer,lastMessage,lastTs,from}, ...]
11571  	if listJSON == "" || listJSON == "[]" {
11572  		empty := dom.CreateElement("div")
11573  		dom.SetStyle(empty, "color", "var(--muted)")
11574  		dom.SetStyle(empty, "textAlign", "center")
11575  		dom.SetStyle(empty, "marginTop", "48px")
11576  		dom.SetTextContent(empty, t("no_convos"))
11577  		dom.AppendChild(msgListContainer, empty)
11578  		return
11579  	}
11580  
11581  	// Walk the JSON array manually - each element is an object.
11582  	i := 0
11583  	for i < len(listJSON) && listJSON[i] != '[' {
11584  		i++
11585  	}
11586  	i++ // skip '['
11587  	for i < len(listJSON) {
11588  		// Find next object.
11589  		for i < len(listJSON) && listJSON[i] != '{' {
11590  			if listJSON[i] == ']' {
11591  				return
11592  			}
11593  			i++
11594  		}
11595  		if i >= len(listJSON) {
11596  			break
11597  		}
11598  		// Extract the object.
11599  		objStart := i
11600  		depth := 0
11601  		for i < len(listJSON) {
11602  			if listJSON[i] == '{' {
11603  				depth++
11604  			} else if listJSON[i] == '}' {
11605  				depth--
11606  				if depth == 0 {
11607  					i++
11608  					break
11609  				}
11610  			} else if listJSON[i] == '"' {
11611  				i++
11612  				for i < len(listJSON) && listJSON[i] != '"' {
11613  					if listJSON[i] == '\\' {
11614  						i++
11615  					}
11616  					i++
11617  				}
11618  			}
11619  			i++
11620  		}
11621  		obj := listJSON[objStart:i]
11622  
11623  		peer := helpers.JsonGetString(obj, "peer")
11624  		lastMsg := helpers.JsonGetString(obj, "lastMessage")
11625  		lastTs := jsonGetNum(obj, "lastTs")
11626  		if peer == "" {
11627  			continue
11628  		}
11629  
11630  		renderConversationRow(peer, lastMsg, lastTs)
11631  	}
11632  }
11633  
11634  func renderConversationRow(peer, lastMsg string, lastTs int64) {
11635  	row := dom.CreateElement("div")
11636  	dom.SetStyle(row, "display", "flex")
11637  	dom.SetStyle(row, "alignItems", "center")
11638  	dom.SetStyle(row, "gap", "10px")
11639  	dom.SetStyle(row, "padding", "10px 4px")
11640  	dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
11641  	dom.SetStyle(row, "cursor", "pointer")
11642  
11643  	// Avatar.
11644  	av := dom.CreateElement("img")
11645  	dom.SetAttribute(av, "referrerpolicy", "no-referrer")
11646  	dom.SetAttribute(av, "width", "32")
11647  	dom.SetAttribute(av, "height", "32")
11648  	dom.SetStyle(av, "borderRadius", "50%")
11649  	dom.SetStyle(av, "objectFit", "cover")
11650  	dom.SetStyle(av, "flexShrink", "0")
11651  	if pic, ok := authorPics[peer]; ok && pic != "" {
11652  		setMediaSrc(av, pic)
11653  	} else {
11654  		dom.SetStyle(av, "background", "var(--bg2)")
11655  	}
11656  	dom.SetAttribute(av, "onerror", "this.style.display='none'")
11657  	dom.AppendChild(row, av)
11658  
11659  	// Name + preview column.
11660  	col := dom.CreateElement("div")
11661  	dom.SetStyle(col, "flex", "1")
11662  	dom.SetStyle(col, "minWidth", "0")
11663  
11664  	nameSpan := dom.CreateElement("div")
11665  	dom.SetStyle(nameSpan, "fontSize", "14px")
11666  	dom.SetStyle(nameSpan, "fontWeight", "bold")
11667  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
11668  	dom.SetStyle(nameSpan, "overflow", "hidden")
11669  	dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
11670  	dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
11671  	if name, ok := authorNames[peer]; ok && name != "" {
11672  		dom.SetTextContent(nameSpan, name)
11673  	} else {
11674  		npub := helpers.EncodeNpub(helpers.HexDecode(peer))
11675  		if len(npub) > 20 {
11676  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
11677  		} else {
11678  			dom.SetTextContent(nameSpan, npub)
11679  		}
11680  	}
11681  	dom.AppendChild(col, nameSpan)
11682  
11683  	preview := dom.CreateElement("div")
11684  	dom.SetStyle(preview, "fontSize", "12px")
11685  	dom.SetStyle(preview, "color", "var(--muted)")
11686  	dom.SetStyle(preview, "overflow", "hidden")
11687  	dom.SetStyle(preview, "textOverflow", "ellipsis")
11688  	dom.SetStyle(preview, "whiteSpace", "nowrap")
11689  	if len(lastMsg) > 80 {
11690  		lastMsg = lastMsg[:80] | "..."
11691  	}
11692  	dom.SetTextContent(preview, lastMsg)
11693  	dom.AppendChild(col, preview)
11694  	dom.AppendChild(row, col)
11695  
11696  	// Timestamp.
11697  	if lastTs > 0 {
11698  		tsSpan := dom.CreateElement("span")
11699  		dom.SetStyle(tsSpan, "fontSize", "11px")
11700  		dom.SetStyle(tsSpan, "color", "var(--muted)")
11701  		dom.SetStyle(tsSpan, "flexShrink", "0")
11702  		dom.SetTextContent(tsSpan, formatTime(lastTs))
11703  		dom.AppendChild(row, tsSpan)
11704  	}
11705  
11706  	rowPeer := peer
11707  	dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
11708  		openThread(rowPeer)
11709  	}))
11710  
11711  	// Lazy profile fetch - batched to avoid 50+ simultaneous subscriptions.
11712  	if _, cached := authorNames[peer]; !cached && !lookupFetchedK0(peer) {
11713  		profile.Send(`["P_RESOLVE",` | jstr(peer) | `]`)
11714  	}
11715  
11716  	dom.AppendChild(msgListContainer, row)
11717  }
11718  
11719  func openThread(peer string) {
11720  	msgCurrentConvoID = peer
11721  	msgCurrentKind = "dm"
11722  	msgView = "thread"
11723  
11724  	if !navPop {
11725  		npub := helpers.EncodeNpub(helpers.HexDecode(peer))
11726  		dom.PushState("/msg/" | npub)
11727  	}
11728  
11729  	// Hide list, show thread.
11730  	dom.SetStyle(msgListContainer, "display", "none")
11731  	dom.SetStyle(msgThreadContainer, "display", "flex")
11732  
11733  	// Build thread UI.
11734  	clearChildren(msgThreadContainer)
11735  
11736  	// Header: back + avatar + name.
11737  	hdr := dom.CreateElement("div")
11738  	dom.SetStyle(hdr, "display", "flex")
11739  	dom.SetStyle(hdr, "alignItems", "center")
11740  	dom.SetStyle(hdr, "gap", "10px")
11741  	dom.SetStyle(hdr, "padding", "12px 16px")
11742  	dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
11743  	dom.SetStyle(hdr, "flexShrink", "0")
11744  
11745  	backBtn := dom.CreateElement("button")
11746  	dom.SetInnerHTML(backBtn, "&#x2190;") // ←
11747  	dom.SetStyle(backBtn, "background", "none")
11748  	dom.SetStyle(backBtn, "border", "none")
11749  	dom.SetStyle(backBtn, "fontSize", "20px")
11750  	dom.SetStyle(backBtn, "cursor", "pointer")
11751  	dom.SetStyle(backBtn, "color", "var(--fg)")
11752  	dom.SetStyle(backBtn, "padding", "0")
11753  	dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() {
11754  		closeThread()
11755  	}))
11756  	dom.AppendChild(hdr, backBtn)
11757  
11758  	// Thread header avatar + name - uses same img-then-span structure
11759  	// as note headers so pendingNotes/updateNoteHeader can update them.
11760  	threadHdrInner := dom.CreateElement("div")
11761  	av := dom.CreateElement("img")
11762  	dom.SetAttribute(av, "referrerpolicy", "no-referrer")
11763  	dom.SetAttribute(av, "width", "28")
11764  	dom.SetAttribute(av, "height", "28")
11765  	dom.SetStyle(av, "borderRadius", "50%")
11766  	dom.SetStyle(av, "objectFit", "cover")
11767  	dom.SetStyle(av, "flexShrink", "0")
11768  	if pic, ok := authorPics[peer]; ok && pic != "" {
11769  		setMediaSrc(av, pic)
11770  	} else {
11771  		dom.SetStyle(av, "display", "none")
11772  	}
11773  	dom.SetAttribute(av, "onerror", "this.style.display='none'")
11774  	dom.AppendChild(threadHdrInner, av)
11775  
11776  	nameSpan := dom.CreateElement("span")
11777  	dom.SetStyle(nameSpan, "fontSize", "15px")
11778  	dom.SetStyle(nameSpan, "fontWeight", "bold")
11779  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
11780  	dom.SetStyle(nameSpan, "overflow", "hidden")
11781  	dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
11782  	dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
11783  	dom.SetStyle(nameSpan, "minWidth", "0")
11784  	if name, ok := authorNames[peer]; ok && len(name) > 0 {
11785  		dom.SetTextContent(nameSpan, name)
11786  	} else {
11787  		npub := helpers.EncodeNpub(helpers.HexDecode(peer))
11788  		if len(npub) > 20 {
11789  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
11790  		}
11791  	}
11792  	dom.AppendChild(threadHdrInner, nameSpan)
11793  	dom.SetStyle(threadHdrInner, "display", "flex")
11794  	dom.SetStyle(threadHdrInner, "alignItems", "center")
11795  	dom.SetStyle(threadHdrInner, "gap", "10px")
11796  	dom.SetStyle(threadHdrInner, "flexGrow", "1")
11797  	dom.SetStyle(threadHdrInner, "minWidth", "0")
11798  	dom.SetStyle(threadHdrInner, "cursor", "pointer")
11799  	profilePeer := peer
11800  	dom.AddEventListener(threadHdrInner, "click", dom.RegisterCallback(func() {
11801  		showProfile(profilePeer)
11802  	}))
11803  	dom.AppendChild(hdr, threadHdrInner)
11804  
11805  	// Relay selector button.
11806  	dmRelayWrap := dom.CreateElement("div")
11807  	dom.SetStyle(dmRelayWrap, "position", "relative")
11808  	dom.SetStyle(dmRelayWrap, "display", "flex")
11809  	dom.SetStyle(dmRelayWrap, "alignItems", "center")
11810  	dom.SetStyle(dmRelayWrap, "gap", "6px")
11811  
11812  	dmWarnBadge := makeRelayWarnBadge()
11813  
11814  	dmRelayBtn := dom.CreateElement("button")
11815  	dom.SetStyle(dmRelayBtn, "background", "none")
11816  	dom.SetStyle(dmRelayBtn, "border", "1px solid var(--border)")
11817  	dom.SetStyle(dmRelayBtn, "borderRadius", "4px")
11818  	dom.SetStyle(dmRelayBtn, "color", "var(--fg)")
11819  	dom.SetStyle(dmRelayBtn, "cursor", "pointer")
11820  	dom.SetStyle(dmRelayBtn, "fontSize", "11px")
11821  	dom.SetStyle(dmRelayBtn, "padding", "4px 8px")
11822  	dom.SetStyle(dmRelayBtn, "fontFamily", "'Fira Code', monospace")
11823  	updateRelaySelBtn(dmRelayBtn, false)
11824  
11825  	dmRelayPop := dom.CreateElement("div")
11826  	dom.SetStyle(dmRelayPop, "display", "none")
11827  	dom.SetStyle(dmRelayPop, "position", "absolute")
11828  	dom.SetStyle(dmRelayPop, "top", "100%")
11829  	dom.SetStyle(dmRelayPop, "right", "0")
11830  	dom.SetStyle(dmRelayPop, "marginTop", "4px")
11831  	dom.SetStyle(dmRelayPop, "background", "var(--bg)")
11832  	dom.SetStyle(dmRelayPop, "border", "1px solid var(--border)")
11833  	dom.SetStyle(dmRelayPop, "borderRadius", "6px")
11834  	dom.SetStyle(dmRelayPop, "padding", "8px")
11835  	dom.SetStyle(dmRelayPop, "boxShadow", "0 2px 8px rgba(0,0,0,0.2)")
11836  	dom.SetStyle(dmRelayPop, "zIndex", "1000")
11837  	dom.SetStyle(dmRelayPop, "minWidth", "200px")
11838  	dom.SetStyle(dmRelayPop, "maxWidth", "calc(100vw - 24px)")
11839  	dom.SetStyle(dmRelayPop, "maxHeight", "200px")
11840  	dom.SetStyle(dmRelayPop, "overflowY", "auto")
11841  
11842  	dmRelayOpen := false
11843  	dom.AddEventListener(dmRelayBtn, "click", dom.RegisterCallback(func() {
11844  		if dmRelayOpen {
11845  			dom.SetStyle(dmRelayPop, "display", "none")
11846  			dmRelayOpen = false
11847  			updateRelaySelBtn(dmRelayBtn, false)
11848  		} else {
11849  			populateRelayPopover(dmRelayPop, func() {
11850  				updateRelaySelBtn(dmRelayBtn, true)
11851  				updateRelayWarnBadge(dmWarnBadge)
11852  			})
11853  			dom.SetStyle(dmRelayPop, "display", "block")
11854  			dmRelayOpen = true
11855  			updateRelaySelBtn(dmRelayBtn, true)
11856  		}
11857  	}))
11858  
11859  	dom.AppendChild(dmRelayWrap, dmWarnBadge)
11860  	dom.AppendChild(dmRelayWrap, dmRelayBtn)
11861  	dom.AppendChild(dmRelayWrap, dmRelayPop)
11862  	dom.AppendChild(hdr, dmRelayWrap)
11863  
11864  	// Delete + ratchet button.
11865  	ratchetBtn := dom.CreateElement("button")
11866  	dom.SetInnerHTML(ratchetBtn, "&#x1F5D1;") // 🗑
11867  	dom.SetAttribute(ratchetBtn, "title", "Delete messages and rotate keys")
11868  	dom.SetStyle(ratchetBtn, "background", "none")
11869  	dom.SetStyle(ratchetBtn, "border", "none")
11870  	dom.SetStyle(ratchetBtn, "cursor", "pointer")
11871  	dom.SetStyle(ratchetBtn, "fontSize", "16px")
11872  	dom.SetStyle(ratchetBtn, "padding", "4px")
11873  	dom.AddEventListener(ratchetBtn, "click", dom.RegisterCallback(func() {
11874  		if dom.Confirm("Delete all messages and rotate encryption keys?") {
11875  			routeMsg("[\"MLS_RATCHET_DM\"," | jstr(peer) | "]")
11876  			clearChildren(msgThreadMessages)
11877  		}
11878  	}))
11879  	dom.AppendChild(hdr, ratchetBtn)
11880  
11881  	dom.AppendChild(msgThreadContainer, hdr)
11882  
11883  	// Track for live update when profile arrives.
11884  	if _, cached := authorNames[peer]; !cached {
11885  		pendingNotes[peer] = append(pendingNotes[peer], threadHdrInner)
11886  	}
11887  
11888  	// Message area.
11889  	msgThreadMessages = dom.CreateElement("div")
11890  	dom.SetStyle(msgThreadMessages, "flex", "1")
11891  	dom.SetStyle(msgThreadMessages, "overflowY", "auto")
11892  	dom.SetStyle(msgThreadMessages, "padding", "12px 16px")
11893  	dom.AppendChild(msgThreadContainer, msgThreadMessages)
11894  
11895  	// Compose area.
11896  	compose := dom.CreateElement("div")
11897  	dom.SetStyle(compose, "display", "flex")
11898  	dom.SetStyle(compose, "gap", "8px")
11899  	dom.SetStyle(compose, "padding", "8px 16px")
11900  	dom.SetStyle(compose, "borderTop", "1px solid var(--border)")
11901  	dom.SetStyle(compose, "flexShrink", "0")
11902  
11903  	msgComposeInput = dom.CreateElement("textarea")
11904  	dom.SetAttribute(msgComposeInput, "placeholder", t("msg_placeholder"))
11905  	dom.SetStyle(msgComposeInput, "flex", "1")
11906  	dom.SetStyle(msgComposeInput, "padding", "8px")
11907  	dom.SetStyle(msgComposeInput, "fontFamily", "'Fira Code', monospace")
11908  	dom.SetStyle(msgComposeInput, "fontSize", "13px")
11909  	dom.SetStyle(msgComposeInput, "background", "var(--bg)")
11910  	dom.SetStyle(msgComposeInput, "border", "1px solid var(--border)")
11911  	dom.SetStyle(msgComposeInput, "borderRadius", "4px")
11912  	dom.SetStyle(msgComposeInput, "color", "var(--fg)")
11913  	dom.SetStyle(msgComposeInput, "minHeight", "22px")
11914  	dom.SetStyle(msgComposeInput, "maxHeight", "120px")
11915  	dom.SetStyle(msgComposeInput, "overflowY", "auto")
11916  	dom.SetAttribute(msgComposeInput, "onkeydown", "if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.nextSibling.click()}")
11917  	wireBlossomHandlers(msgComposeInput)
11918  	wireMentionHandler(msgComposeInput)
11919  	dom.AppendChild(compose, msgComposeInput)
11920  
11921  	sendBtn := dom.CreateElement("button")
11922  	dom.SetTextContent(sendBtn, t("send"))
11923  	dom.SetStyle(sendBtn, "padding", "8px 16px")
11924  	dom.SetStyle(sendBtn, "fontFamily", "'Fira Code', monospace")
11925  	dom.SetStyle(sendBtn, "fontSize", "13px")
11926  	dom.SetStyle(sendBtn, "background", "var(--accent)")
11927  	dom.SetStyle(sendBtn, "color", "#fff")
11928  	dom.SetStyle(sendBtn, "border", "none")
11929  	dom.SetStyle(sendBtn, "borderRadius", "4px")
11930  	dom.SetStyle(sendBtn, "cursor", "pointer")
11931  	dom.SetStyle(sendBtn, "alignSelf", "flex-end")
11932  	dom.AddEventListener(sendBtn, "click", dom.RegisterCallback(func() {
11933  		sendMessage()
11934  	}))
11935  	dom.AppendChild(compose, sendBtn)
11936  
11937  	dom.AppendChild(msgThreadContainer, compose)
11938  
11939  	// Fetch profile if metadata is missing - fetchedK0 can be true while
11940  	// the relay response is still in flight (showProfile resets + re-sets it).
11941  	_, nameOK := authorNames[peer]
11942  	if !nameOK {
11943  		setFetchedK0(peer, false)
11944  		profile.Send(`["P_RESOLVE",` | jstr(peer) | `]`)
11945  	}
11946  
11947  	// Request history.
11948  	routeMsg("[\"MLS_DM_HISTORY\"," | jstr(peer) | ",50,0]")
11949  }
11950  
11951  func closeThread() {
11952  	msgCurrentConvoID = ""
11953  	msgCurrentKind = ""
11954  	msgView = "list"
11955  
11956  	dom.SetStyle(msgThreadContainer, "display", "none")
11957  	dom.SetStyle(msgListContainer, "display", "block")
11958  
11959  	if !navPop {
11960  		dom.PushState("/msg")
11961  	}
11962  
11963  	// Refresh list.
11964  	routeMsg("[\"MLS_DM_LIST\"]")
11965  }
11966  
11967  func renderThreadMessages(peer, msgsJSON string) {
11968  	if peer != msgCurrentConvoID {
11969  		return
11970  	}
11971  	if msgsJSON == "" || msgsJSON == "[]" {
11972  		return
11973  	}
11974  
11975  	// Parse messages array - each element is a DMRecord object.
11976  	// IDB returns newest-first; collect then reverse for oldest-at-top.
11977  	type dmMsg struct {
11978  		from    string
11979  		content string
11980  		ts      int64
11981  	}
11982  	var msgs []dmMsg
11983  
11984  	i := 0
11985  	for i < len(msgsJSON) && msgsJSON[i] != '[' {
11986  		i++
11987  	}
11988  	i++
11989  	for i < len(msgsJSON) {
11990  		for i < len(msgsJSON) && msgsJSON[i] != '{' {
11991  			if msgsJSON[i] == ']' {
11992  				goto done
11993  			}
11994  			i++
11995  		}
11996  		if i >= len(msgsJSON) {
11997  			break
11998  		}
11999  		objStart := i
12000  		depth := 0
12001  		for i < len(msgsJSON) {
12002  			if msgsJSON[i] == '{' {
12003  				depth++
12004  			} else if msgsJSON[i] == '}' {
12005  				depth--
12006  				if depth == 0 {
12007  					i++
12008  					break
12009  				}
12010  			} else if msgsJSON[i] == '"' {
12011  				i++
12012  				for i < len(msgsJSON) && msgsJSON[i] != '"' {
12013  					if msgsJSON[i] == '\\' {
12014  						i++
12015  					}
12016  					i++
12017  				}
12018  			}
12019  			i++
12020  		}
12021  		obj := msgsJSON[objStart:i]
12022  
12023  		from := helpers.JsonGetString(obj, "from")
12024  		content := helpers.JsonGetString(obj, "content")
12025  		ts := jsonGetNum(obj, "created_at")
12026  		msgs = append(msgs, dmMsg{from, content, ts})
12027  	}
12028  done:
12029  
12030  	// Reverse for oldest-first.
12031  	for l, r := 0, len(msgs)-1; l < r; l, r = l+1, r-1 {
12032  		msgs[l], msgs[r] = msgs[r], msgs[l]
12033  	}
12034  
12035  	clearChildren(msgThreadMessages)
12036  	for _, m := range msgs {
12037  		appendBubble(m.from, m.content, m.ts)
12038  	}
12039  	scrollToBottom()
12040  }
12041  
12042  func appendBubble(from, content string, ts int64) {
12043  	isSent := from == pubhex
12044  
12045  	wrap := dom.CreateElement("div")
12046  	dom.SetStyle(wrap, "display", "flex")
12047  	dom.SetStyle(wrap, "marginBottom", "6px")
12048  	if isSent {
12049  		dom.SetStyle(wrap, "justifyContent", "flex-end")
12050  	}
12051  
12052  	outer := dom.CreateElement("div")
12053  	dom.SetStyle(outer, "maxWidth", "80%")
12054  	if isSent {
12055  		dom.SetStyle(outer, "textAlign", "right")
12056  	}
12057  
12058  	bubble := dom.CreateElement("div")
12059  	dom.SetStyle(bubble, "display", "inline-block")
12060  	dom.SetStyle(bubble, "padding", "8px 12px")
12061  	dom.SetStyle(bubble, "borderRadius", "12px")
12062  	dom.SetStyle(bubble, "fontSize", "14px")
12063  	dom.SetStyle(bubble, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
12064  	dom.SetStyle(bubble, "lineHeight", "1.4")
12065  	dom.SetStyle(bubble, "wordBreak", "break-word")
12066  	dom.SetStyle(bubble, "textAlign", "left")
12067  	if isSent {
12068  		dom.SetStyle(bubble, "background", "var(--accent)")
12069  		dom.SetStyle(bubble, "color", "#fff")
12070  	} else {
12071  		dom.SetStyle(bubble, "background", "var(--bg2)")
12072  		dom.SetStyle(bubble, "color", "var(--fg)")
12073  	}
12074  	setHTMLWithMedia(bubble, renderMarkdown(content))
12075  
12076  	// Timestamp below bubble.
12077  	tsEl := dom.CreateElement("div")
12078  	dom.SetStyle(tsEl, "fontSize", "10px")
12079  	dom.SetStyle(tsEl, "color", "var(--muted)")
12080  	dom.SetStyle(tsEl, "marginTop", "2px")
12081  	if isSent {
12082  		dom.SetStyle(tsEl, "textAlign", "right")
12083  	}
12084  	dom.SetTextContent(tsEl, formatTime(ts))
12085  	if isSent && ts == 0 {
12086  		pendingTsEls = append(pendingTsEls, tsEl)
12087  	}
12088  
12089  	dom.AppendChild(outer, bubble)
12090  	dom.AppendChild(outer, tsEl)
12091  
12092  	// Email quote-reply button for received messages with email headers.
12093  	if !isSent {
12094  		emailFrom, emailSubject, emailBody, isEmail := parseEmailHeaders(content)
12095  		if isEmail {
12096  			replyBtn := dom.CreateElement("div")
12097  			dom.SetStyle(replyBtn, "fontSize", "11px")
12098  			dom.SetStyle(replyBtn, "color", "var(--accent)")
12099  			dom.SetStyle(replyBtn, "cursor", "pointer")
12100  			dom.SetStyle(replyBtn, "marginTop", "2px")
12101  			dom.SetTextContent(replyBtn, "\u21a9 Reply")
12102  			dom.AddEventListener(replyBtn, "click", dom.RegisterCallback(func() {
12103  				quoted := quoteReply(emailFrom, emailSubject, emailBody)
12104  				dom.SetProperty(msgComposeInput, "value", quoted)
12105  			}))
12106  			dom.AppendChild(outer, replyBtn)
12107  		}
12108  	}
12109  
12110  	dom.AppendChild(wrap, outer)
12111  	dom.AppendChild(msgThreadMessages, wrap)
12112  }
12113  
12114  func appendSystemBubble(text string) {
12115  	wrap := dom.CreateElement("div")
12116  	dom.SetStyle(wrap, "display", "flex")
12117  	dom.SetStyle(wrap, "justifyContent", "center")
12118  	dom.SetStyle(wrap, "marginBottom", "6px")
12119  
12120  	bubble := dom.CreateElement("div")
12121  	dom.SetStyle(bubble, "maxWidth", "85%")
12122  	dom.SetStyle(bubble, "padding", "8px 12px")
12123  	dom.SetStyle(bubble, "borderRadius", "8px")
12124  	dom.SetStyle(bubble, "fontSize", "12px")
12125  	dom.SetStyle(bubble, "fontFamily", "monospace")
12126  	dom.SetStyle(bubble, "lineHeight", "1.5")
12127  	dom.SetStyle(bubble, "whiteSpace", "pre-wrap")
12128  	dom.SetStyle(bubble, "background", "var(--bg2)")
12129  	dom.SetStyle(bubble, "color", "var(--muted)")
12130  	dom.SetStyle(bubble, "border", "1px solid var(--muted)")
12131  	dom.SetTextContent(bubble, text)
12132  
12133  	dom.AppendChild(wrap, bubble)
12134  	dom.AppendChild(msgThreadMessages, wrap)
12135  	scrollToBottom()
12136  }
12137  
12138  func scrollToBottom() {
12139  	dom.SetProperty(msgThreadMessages, "scrollTop", "999999")
12140  }
12141  
12142  func sendMessage() {
12143  	content := dom.GetProperty(msgComposeInput, "value")
12144  	if content == "" || msgCurrentConvoID == "" {
12145  		return
12146  	}
12147  
12148  	// Clear input.
12149  	dom.SetProperty(msgComposeInput, "value", "")
12150  
12151  	if msgCurrentKind == "group" {
12152  		routeMsg("[\"MLS_SEND_GROUP\"," | jstr(msgCurrentConvoID) | "," | jstr(content) | "]")
12153  	} else {
12154  		routeMsg("[\"MLS_SEND_DM\"," | jstr(msgCurrentConvoID) | "," | jstr(content) | "]")
12155  	}
12156  
12157  	// Optimistic render (ts=0 - timestamp not shown for "just sent").
12158  	appendBubble(pubhex, content, 0)
12159  	scrollToBottom()
12160  }
12161  
12162  func handleDMReceived(dmJSON string) {
12163  	peer := helpers.JsonGetString(dmJSON, "peer")
12164  	from := helpers.JsonGetString(dmJSON, "from")
12165  	content := helpers.JsonGetString(dmJSON, "content")
12166  	ts := jsonGetNum(dmJSON, "created_at")
12167  
12168  	if from != pubhex {
12169  		showNotifDot()
12170  	}
12171  
12172  	if msgView == "thread" && peer == msgCurrentConvoID {
12173  		if from == pubhex {
12174  			return
12175  		}
12176  		appendBubble(from, content, ts)
12177  		scrollToBottom()
12178  	} else if msgView == "list" {
12179  		routeMsg("[\"MLS_DM_LIST\"]")
12180  	}
12181  }
12182  
12183  // --- Notifications ---
12184  
12185  func subscribeNotifications() {
12186  	if pubhex == "" {
12187  		return
12188  	}
12189  	// Reset Shell-side state.
12190  	oldestNotifTs = 0
12191  	notifExhausted = false
12192  	notifEmptyStrk = 0
12193  	notifInitLoad = true
12194  	notifEvents = []*nostr.Event{:0}
12195  	notifSeen = nil
12196  	// Sync state to Notif Worker, then tell it to subscribe.
12197  	// Notif Worker owns the relay subscriptions via N_SUB/N_CLOSE.
12198  	notif.Send(`["N_SET_PUBKEY",` | jstr(pubhex) | `]`)
12199  	notif.Send(`["N_SET_RELAYS",` | readRelayURLsJSON() | `]`)
12200  	notif.Send(`["N_READ_TS",` | i64toa(notifReadTs) | `]`)
12201  	if len(myMutes) > 0 {
12202  		notif.Send(`["N_SET_MUTES",` | buildJSONStrArr(myMutes) | `]`)
12203  	}
12204  	notif.Send(`["N_SUBSCRIBE"]`)
12205  }
12206  
12207  func handleNotifEvent(ev *nostr.Event) {
12208  	if notifSeen == nil {
12209  		notifSeen = map[string]bool{}
12210  	}
12211  	if notifSeen[ev.ID] {
12212  		return
12213  	}
12214  	notifSeen[ev.ID] = true
12215  	// Advance cursor before filters so muted/self events don't stall pagination.
12216  	if oldestNotifTs == 0 || ev.CreatedAt < oldestNotifTs {
12217  		oldestNotifTs = ev.CreatedAt
12218  	}
12219  	self := ev.PubKey == pubhex
12220  	muted := isMuted(notifSenderPK(ev))
12221  	rtm := repliesToMuted(ev)
12222  	if self || muted || rtm {
12223  		return
12224  	}
12225  	if looksLikeJSONSpam(ev.Content) {
12226  		return
12227  	}
12228  	// Queue missing referenced events for batch fetch after EOSE.
12229  	if ev.Kind != 1 {
12230  		refID := notifRefID(ev)
12231  		if refID != "" {
12232  			if _, ok := lookupEvent(refID); !ok {
12233  				notifMissing = append(notifMissing, refID)
12234  			}
12235  		}
12236  	}
12237  	// Remove empty-state placeholder on first event.
12238  	if notifEmptyEl != 0 {
12239  		dom.RemoveChild(notifContainer, notifEmptyEl)
12240  		notifEmptyEl = 0
12241  	}
12242  	if notifInitLoad {
12243  		notifEvents = append(notifEvents, ev)
12244  		if len(notifEvents) > notifEventsMax {
12245  			notifEvents = notifEvents[len(notifEvents)-notifEventsMax:]
12246  		}
12247  		if notifBuilt && activePage == "notifications" && notifPassesFilter(ev) {
12248  			row := renderNotifRow(ev)
12249  			dom.InsertBefore(notifContainer, row, notifLoadMore)
12250  		}
12251  	} else {
12252  		notifEvents = []*nostr.Event{ev} | notifEvents
12253  		if len(notifEvents) > notifEventsMax {
12254  			notifEvents = notifEvents[:notifEventsMax]
12255  		}
12256  		if notifBuilt && activePage == "notifications" && notifPassesFilter(ev) {
12257  			row := renderNotifRow(ev)
12258  			first := dom.FirstElementChild(notifContainer)
12259  			if first != 0 {
12260  				dom.InsertBefore(notifContainer, row, first)
12261  			} else {
12262  				dom.AppendChild(notifContainer, row)
12263  			}
12264  		}
12265  		if ev.Kind != 1 {
12266  			if notifRefTimer != 0 {
12267  				dom.ClearTimeout(notifRefTimer)
12268  			}
12269  			notifRefTimer = dom.SetTimeout(func() {
12270  				notifRefTimer = 0
12271  				fetchNotifRefs()
12272  			}, 500)
12273  		}
12274  	}
12275  	maybeShowNotifDot(ev)
12276  }
12277  
12278  func fetchNotifRefs() {
12279  	if len(notifMissing) == 0 {
12280  		return
12281  	}
12282  	// Dedup against eventCache and already-requested.
12283  	seen := map[string]bool{}
12284  	var ids []string
12285  	for _, id := range notifMissing {
12286  		if _, ok := lookupEvent(id); ok {
12287  			continue
12288  		}
12289  		if seen[id] {
12290  			continue
12291  		}
12292  		seen[id] = true
12293  		ids = append(ids, id)
12294  	}
12295  	notifMissing = nil
12296  	if len(ids) == 0 {
12297  		return
12298  	}
12299  	filter := "{\"ids\":["
12300  	for i, id := range ids {
12301  		if i > 0 {
12302  			filter = filter | ","
12303  		}
12304  		filter = filter | jstr(id)
12305  	}
12306  	filter = filter | "]}"
12307  	routeMsg(buildProxyMsg("ntf-ref", filter, readRelayURLs()))
12308  }
12309  
12310  func handleNotifRefEvent(ev *nostr.Event) {
12311  	setEvent(ev.ID, ev)
12312  	fillEmbed(ev)
12313  }
12314  
12315  func sortNotifEvents() {
12316  	for i := 0; i < len(notifEvents); i++ {
12317  		for j := i + 1; j < len(notifEvents); j++ {
12318  			if notifEvents[j].CreatedAt > notifEvents[i].CreatedAt {
12319  				notifEvents[i], notifEvents[j] = notifEvents[j], notifEvents[i]
12320  			}
12321  		}
12322  	}
12323  }
12324  
12325  func insertAtTop(row dom.Element) {
12326  	first := dom.FirstChild(notifContainer)
12327  	if first != 0 {
12328  		dom.InsertBefore(notifContainer, row, first)
12329  	} else {
12330  		dom.InsertBefore(notifContainer, row, notifLoadMore)
12331  	}
12332  }
12333  
12334  func loadOlderNotifs() {
12335  	notifLoading = true
12336  	notifMoreGot = 0
12337  	dom.ConsoleLog("[ntf-more] loading until=" | i64toa(oldestNotifTs-1))
12338  	filter := "{\"kinds\":[1,6,7,9735],\"#p\":[" | jstr(pubhex) | "],\"limit\":20,\"until\":" | i64toa(oldestNotifTs-1) | "}"
12339  	routeMsg(buildProxyMsg("ntf-more", filter, readRelayURLs()))
12340  	if notifMoreTimer != 0 {
12341  		dom.ClearTimeout(notifMoreTimer)
12342  	}
12343  	notifMoreTimer = dom.SetTimeout(func() {
12344  		notifMoreTimer = 0
12345  		if notifLoading {
12346  			notifLoading = false
12347  			if notifMoreGot == 0 {
12348  				notifEmptyStrk++
12349  				if notifEmptyStrk >= 3 {
12350  					notifExhausted = true
12351  				}
12352  			} else {
12353  				notifEmptyStrk = 0
12354  			}
12355  			if notifExhausted {
12356  				dom.SetStyle(notifLoadMore, "display", "none")
12357  			} else if oldestNotifTs > 0 {
12358  				dom.SetStyle(notifLoadMore, "display", "block")
12359  			}
12360  		}
12361  	}, 10000)
12362  }
12363  
12364  func handleNotifMoreEvent(ev *nostr.Event) {
12365  	if notifSeen == nil {
12366  		notifSeen = map[string]bool{}
12367  	}
12368  	if notifSeen[ev.ID] {
12369  		return
12370  	}
12371  	notifSeen[ev.ID] = true
12372  	// Advance cursor before filters so muted/self events don't stall pagination.
12373  	if oldestNotifTs == 0 || ev.CreatedAt < oldestNotifTs {
12374  		oldestNotifTs = ev.CreatedAt
12375  	}
12376  	if ev.PubKey == pubhex {
12377  		return
12378  	}
12379  	if isMuted(notifSenderPK(ev)) {
12380  		return
12381  	}
12382  	if repliesToMuted(ev) {
12383  		return
12384  	}
12385  	notifMoreGot++
12386  	if ev.Kind != 1 {
12387  		refID := notifRefID(ev)
12388  		if refID != "" {
12389  			if _, ok := lookupEvent(refID); !ok {
12390  				notifMissing = append(notifMissing, refID)
12391  			}
12392  		}
12393  	}
12394  	notifEvents = append(notifEvents, ev)
12395  	if len(notifEvents) > notifEventsMax {
12396  		notifEvents = notifEvents[len(notifEvents)-notifEventsMax:]
12397  	}
12398  	if activePage == "notifications" && notifLoadMore != 0 && notifPassesFilter(ev) {
12399  		row := renderNotifRow(ev)
12400  		dom.InsertBefore(notifContainer, row, notifLoadMore)
12401  	}
12402  }
12403  
12404  func showNotifDot() {
12405  	if !notifUnread {
12406  		notifUnread = true
12407  		dom.SetStyle(notifDot, "display", "block")
12408  	}
12409  }
12410  
12411  func maybeShowNotifDot(ev *nostr.Event) {
12412  	if ev.CreatedAt > notifReadTs && activePage != "notifications" {
12413  		showNotifDot()
12414  	}
12415  }
12416  
12417  func notifPassesFilter(ev *nostr.Event) (ok bool) {
12418  	switch notifFilter {
12419  	case "mentions":
12420  		return ev.Kind == 1
12421  	case "reactions":
12422  		return ev.Kind == 6 || ev.Kind == 7
12423  	case "zaps":
12424  		return ev.Kind == 9735
12425  	}
12426  	return true
12427  }
12428  
12429  func renderNotifPage() {
12430  	if notifContainer == 0 {
12431  		return
12432  	}
12433  	notifBuilt = true
12434  	sortNotifEvents()
12435  	clearChildren(notifContainer)
12436  
12437  	shown := 0
12438  	for _, ev := range notifEvents {
12439  		if !notifPassesFilter(ev) {
12440  			continue
12441  		}
12442  		shown++
12443  		row := renderNotifRow(ev)
12444  		dom.AppendChild(notifContainer, row)
12445  	}
12446  
12447  	if shown == 0 {
12448  		notifEmptyEl = dom.CreateElement("div")
12449  		dom.SetTextContent(notifEmptyEl, t("no_notifications"))
12450  		dom.SetStyle(notifEmptyEl, "color", "var(--muted)")
12451  		dom.SetStyle(notifEmptyEl, "fontFamily", "'Fira Code', monospace")
12452  		dom.SetStyle(notifEmptyEl, "fontSize", "13px")
12453  		dom.AppendChild(notifContainer, notifEmptyEl)
12454  	} else {
12455  		notifEmptyEl = 0
12456  	}
12457  
12458  	notifLoadMore = dom.CreateElement("div")
12459  	dom.SetStyle(notifLoadMore, "textAlign", "center")
12460  	dom.SetStyle(notifLoadMore, "padding", "16px 0")
12461  	if notifExhausted || oldestNotifTs == 0 {
12462  		dom.SetStyle(notifLoadMore, "display", "none")
12463  	}
12464  	lnk := dom.CreateElement("span")
12465  	dom.SetTextContent(lnk, "load more")
12466  	dom.SetStyle(lnk, "cursor", "pointer")
12467  	dom.SetStyle(lnk, "color", "var(--accent)")
12468  	dom.SetStyle(lnk, "fontSize", "14px")
12469  	dom.SetStyle(lnk, "fontFamily", "'Fira Code', monospace")
12470  	dom.AddEventListener(lnk, "click", dom.RegisterCallback(func() {
12471  		if !notifLoading && !notifExhausted && oldestNotifTs > 0 {
12472  			dom.SetStyle(notifLoadMore, "display", "none")
12473  			loadOlderNotifs()
12474  		}
12475  	}))
12476  	dom.AppendChild(notifLoadMore, lnk)
12477  	dom.AppendChild(notifContainer, notifLoadMore)
12478  }
12479  
12480  // releaseNotifRowCBs releases all Moxie callback IDs registered during notif
12481  // row rendering. Called before rebuild and on page exit to prevent the callback
12482  // table from growing without bound across filter changes and page visits.
12483  func releaseNotifRowCBs() {
12484  	for _, id := range notifRowCBs {
12485  		dom.ReleaseCallback(id)
12486  	}
12487  	notifRowCBs = nil
12488  }
12489  
12490  // notifRegCB registers a callback and records its ID for later release.
12491  func notifRegCB(fn func()) (n int32) {
12492  	id := dom.RegisterCallback(fn)
12493  	notifRowCBs = append(notifRowCBs, id)
12494  	return id
12495  }
12496  
12497  func rebuildNotifRows() {
12498  	if notifContainer == 0 {
12499  		return
12500  	}
12501  	releaseNotifRowCBs()
12502  	clearChildren(notifContainer)
12503  	shown := 0
12504  	for _, ev := range notifEvents {
12505  		if !notifPassesFilter(ev) {
12506  			continue
12507  		}
12508  		shown++
12509  		dom.AppendChild(notifContainer, renderNotifRow(ev))
12510  	}
12511  	if shown == 0 {
12512  		notifEmptyEl = dom.CreateElement("div")
12513  		dom.SetTextContent(notifEmptyEl, t("no_notifications"))
12514  		dom.SetStyle(notifEmptyEl, "color", "var(--muted)")
12515  		dom.SetStyle(notifEmptyEl, "fontFamily", "'Fira Code', monospace")
12516  		dom.SetStyle(notifEmptyEl, "fontSize", "13px")
12517  		dom.AppendChild(notifContainer, notifEmptyEl)
12518  	} else {
12519  		notifEmptyEl = 0
12520  	}
12521  	if notifLoadMore != 0 {
12522  		dom.AppendChild(notifContainer, notifLoadMore)
12523  	}
12524  }
12525  
12526  func markNotifsRead() {
12527  	if len(notifEvents) > 0 {
12528  		notifReadTs = notifEvents[0].CreatedAt
12529  		dom.IDBPut("settings", "notif-read-ts", i64toa(notifReadTs))
12530  		// Publish watermark to relay so it syncs to other devices/sessions.
12531  		saveAppSettings()
12532  	}
12533  	notifUnread = false
12534  	dom.SetStyle(notifDot, "display", "none")
12535  	el := dom.QuerySelectorFrom(notifContainer, "[data-notif-new]")
12536  	for el != 0 {
12537  		dom.RemoveAttribute(el, "data-notif-new")
12538  		dom.SetStyle(el, "borderLeft", "none")
12539  		dom.SetStyle(el, "paddingLeft", "0")
12540  		dom.SetStyle(el, "fontWeight", "normal")
12541  		el = dom.QuerySelectorFrom(notifContainer, "[data-notif-new]")
12542  	}
12543  }
12544  
12545  // zapAmount extracts the sats amount from a kind 9735 zap receipt.
12546  // Checks the "amount" tag first (millisats), then falls back to bolt11.
12547  func zapAmount(ev *nostr.Event) (s string) {
12548  	for _, tag := range ev.Tags {
12549  		if len(tag) >= 2 && tag[0] == "amount" {
12550  			// Value is in millisats - divide by 1000.
12551  			msats := parseI64(tag[1])
12552  			if msats > 0 {
12553  				return i64toa(msats / 1000)
12554  			}
12555  		}
12556  	}
12557  	return ""
12558  }
12559  
12560  // zapSenderPK tries to extract the real sender pubkey from the zap request
12561  // embedded in the "description" tag of a kind 9735 receipt.
12562  func zapSenderPK(ev *nostr.Event) (s string) {
12563  	for _, tag := range ev.Tags {
12564  		if len(tag) >= 2 && tag[0] == "description" {
12565  			desc := tag[1]
12566  			pk := extractJSONField(desc, "pubkey")
12567  			if len(pk) == 64 {
12568  				return pk
12569  			}
12570  		}
12571  	}
12572  	return ""
12573  }
12574  
12575  // extractJSONField finds "key":"value" in a JSON string and returns value.
12576  func extractJSONField(json string, key string) (s string) {
12577  	needle := "\"" | key | "\":\""
12578  	idx := -1
12579  	for i := 0; i+len(needle) <= len(json); i++ {
12580  		if json[i:i+len(needle)] == needle {
12581  			idx = i + len(needle)
12582  			break
12583  		}
12584  	}
12585  	if idx < 0 {
12586  		return ""
12587  	}
12588  	end := idx
12589  	for end < len(json) && json[end] != '"' {
12590  		end++
12591  	}
12592  	return json[idx:end]
12593  }
12594  
12595  func notifRefID(ev *nostr.Event) (s string) {
12596  	for _, tag := range ev.Tags {
12597  		if len(tag) >= 2 && tag[0] == "e" {
12598  			return tag[1]
12599  		}
12600  	}
12601  	return ""
12602  }
12603  
12604  func relativeTime(ts int64) (s string) {
12605  	now := dom.NowSeconds()
12606  	d := now - ts
12607  	if d < 0 {
12608  		d = 0
12609  	}
12610  	if d < 60 {
12611  		return "now"
12612  	}
12613  	if d < 3600 {
12614  		return i64toa(d/60) | "m"
12615  	}
12616  	if d < 86400 {
12617  		return i64toa(d/3600) | "h"
12618  	}
12619  	return i64toa(d/86400) | "d"
12620  }
12621  
12622  func notifSenderPK(ev *nostr.Event) (s string) {
12623  	if ev.Kind == 9735 {
12624  		pk := zapSenderPK(ev)
12625  		if pk != "" {
12626  			return pk
12627  		}
12628  	}
12629  	return ev.PubKey
12630  }
12631  
12632  func renderNotifRow(ev *nostr.Event) (e dom.Element) {
12633  	senderPK := notifSenderPK(ev)
12634  	unread := ev.CreatedAt > notifReadTs
12635  
12636  	row := dom.CreateElement("div")
12637  	dom.SetStyle(row, "display", "flex")
12638  	dom.SetStyle(row, "alignItems", "flex-start")
12639  	dom.SetStyle(row, "gap", "6px")
12640  	dom.SetStyle(row, "padding", "4px 0")
12641  	dom.SetStyle(row, "cursor", "pointer")
12642  	dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
12643  	dom.SetStyle(row, "fontSize", "12px")
12644  	if unread {
12645  		dom.SetStyle(row, "borderLeft", "3px solid var(--accent)")
12646  		dom.SetStyle(row, "paddingLeft", "6px")
12647  		dom.SetStyle(row, "fontWeight", "bold")
12648  		dom.SetAttribute(row, "data-notif-new", "1")
12649  	}
12650  
12651  	// Avatar.
12652  	ava := dom.CreateElement("img")
12653  	pic, picOK := authorPics[senderPK]
12654  	if !picOK || pic == "" {
12655  		pic = "/smesh-logo.svg"
12656  		profile.Send(`["P_RESOLVE_FORCE",` | jstr(senderPK) | `]`)
12657  		// Register ava so applyAuthorProfile updates it immediately when loaded.
12658  		pendingNotifAvatars[senderPK] = append(pendingNotifAvatars[senderPK], ava)
12659  	}
12660  	setMediaSrc(ava, pic)
12661  	dom.SetStyle(ava, "width", "24px")
12662  	dom.SetStyle(ava, "height", "24px")
12663  	dom.SetStyle(ava, "borderRadius", "50%")
12664  	dom.SetStyle(ava, "flexShrink", "0")
12665  	dom.SetStyle(ava, "cursor", "pointer")
12666  	dom.SetAttribute(ava, "onclick", "event.stopPropagation()")
12667  	pk := senderPK
12668  	dom.AddEventListener(ava, "click", notifRegCB(func() {
12669  		showProfile(pk)
12670  	}))
12671  	dom.AppendChild(row, ava)
12672  
12673  	// Icon + action label.
12674  	var icon string
12675  	var label string
12676  	switch ev.Kind {
12677  	case 1:
12678  		icon = "\xf0\x9f\x92\xac" // 💬
12679  		label = ""
12680  	case 6:
12681  		icon = "\xf0\x9f\x94\x81" // 🔁
12682  		label = ""
12683  	case 7:
12684  		emoji := ev.Content
12685  		if emoji == "" || emoji == "+" {
12686  			emoji = "\xe2\x9d\xa4\xef\xb8\x8f" // ❤️
12687  		}
12688  		icon = emoji
12689  		label = ""
12690  	case 9735:
12691  		icon = "\xe2\x9a\xa1" // ⚡
12692  		amt := zapAmount(ev)
12693  		if amt != "" {
12694  			label = amt | " sats"
12695  		}
12696  	}
12697  
12698  	iconEl := dom.CreateElement("span")
12699  	dom.SetStyle(iconEl, "flexShrink", "0")
12700  	dom.SetTextContent(iconEl, icon)
12701  	dom.AppendChild(row, iconEl)
12702  
12703  	if label != "" {
12704  		lblEl := dom.CreateElement("span")
12705  		dom.SetStyle(lblEl, "flexShrink", "0")
12706  		dom.SetStyle(lblEl, "color", "var(--accent)")
12707  		dom.SetTextContent(lblEl, label)
12708  		dom.AppendChild(row, lblEl)
12709  	}
12710  
12711  	// Content preview - line-clamped to 10 lines, same font as regular notes.
12712  	refID := notifRefID(ev)
12713  	var previewText string
12714  	if ev.Kind == 1 {
12715  		previewText = ev.Content
12716  	} else if refID != "" {
12717  		if ref, ok := lookupEvent(refID); ok && ref != nil {
12718  			previewText = ref.Content
12719  		}
12720  	}
12721  	prev := dom.CreateElement("span")
12722  	dom.SetStyle(prev, "flex", "1")
12723  	dom.SetStyle(prev, "minWidth", "0")
12724  	dom.SetStyle(prev, "overflow", "hidden")
12725  	dom.SetStyle(prev, "color", "var(--muted)")
12726  	dom.SetStyle(prev, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
12727  	dom.SetStyle(prev, "fontSize", "14px")
12728  	dom.SetStyle(prev, "lineHeight", "1.5")
12729  	dom.SetStyle(prev, "whiteSpace", "nowrap")
12730  	dom.SetStyle(prev, "textOverflow", "ellipsis")
12731  	dom.SetTextContent(prev, previewText)
12732  	dom.AppendChild(row, prev)
12733  
12734  	// Relative timestamp, right-aligned.
12735  	tsEl := dom.CreateElement("span")
12736  	dom.SetStyle(tsEl, "flexShrink", "0")
12737  	dom.SetStyle(tsEl, "color", "var(--muted)")
12738  	dom.SetStyle(tsEl, "fontSize", "11px")
12739  	dom.SetTextContent(tsEl, relativeTime(ev.CreatedAt))
12740  	dom.AppendChild(row, tsEl)
12741  
12742  	// Click navigates to the referenced event.
12743  	if refID != "" {
12744  		tid := refID
12745  		dom.AddEventListener(row, "click", notifRegCB(func() {
12746  			rootID := tid
12747  			if ref, ok := lookupEvent(tid); ok && ref != nil {
12748  				if r := getRootID(ref); r != "" {
12749  					rootID = r
12750  				}
12751  			}
12752  			showNoteThread(rootID, tid)
12753  		}))
12754  	}
12755  
12756  	return row
12757  }
12758  
12759  // Wallet panel - implementation lives in wallet.mx.
12760  
12761  // --- Email header parsing for quote-reply ---
12762  
12763  // parseEmailHeaders checks if content looks like a forwarded email and extracts
12764  // From, Subject, and body. Returns isEmail=true if at least From: or Subject: found.
12765  func parseEmailHeaders(content string) (from, subject, body string, isEmail bool) {
12766  	lines := splitLines(content)
12767  	headerEnd := -1
12768  	for i, line := range lines {
12769  		if line == "" {
12770  			headerEnd = i
12771  			break
12772  		}
12773  		if hasPrefix(line, "From: ") {
12774  			from = line[6:]
12775  		} else if hasPrefix(line, "Subject: ") {
12776  			subject = line[9:]
12777  		} else if hasPrefix(line, "To: ") || hasPrefix(line, "Date: ") || hasPrefix(line, "Cc: ") {
12778  			// Known header, continue
12779  		} else if i == 0 {
12780  			return "", "", "", false
12781  		}
12782  	}
12783  	if from == "" && subject == "" {
12784  		return "", "", "", false
12785  	}
12786  	if headerEnd >= 0 && headerEnd+1 < len(lines) {
12787  		body = joinLines(lines[headerEnd+1:])
12788  	}
12789  	return from, subject, body, true
12790  }
12791  
12792  func quoteReply(from, subject, body string) (s string) {
12793  	out := "To: " | from | "\n"
12794  	if subject != "" {
12795  		if !hasPrefix(subject, "Re: ") {
12796  			subject = "Re: " | subject
12797  		}
12798  		out = out | "Subject: " | subject | "\n"
12799  	}
12800  	out = out | "\n\n"
12801  	if body != "" {
12802  		lines := splitLines(body)
12803  		for _, line := range lines {
12804  			out = out | "> " | line | "\n"
12805  		}
12806  	}
12807  	return out
12808  }
12809  
12810  func splitLines(s string) (ss []string) {
12811  	var lines []string
12812  	for {
12813  		idx := strIndex(s, "\n")
12814  		if idx < 0 {
12815  			lines = append(lines, s)
12816  			return lines
12817  		}
12818  		lines = append(lines, s[:idx])
12819  		s = s[idx+1:]
12820  	}
12821  }
12822  
12823  func joinLines(lines []string) (s string) {
12824  	out := ""
12825  	for i, line := range lines {
12826  		if i > 0 {
12827  			out = out | "\n"
12828  		}
12829  		out = out | line
12830  	}
12831  	return out
12832  }
12833  
12834  func hasPrefix(s, prefix string) (ok bool) {
12835  	return len(s) >= len(prefix) && s[:len(prefix)] == prefix
12836  }
12837  
12838  // --- Markdown rendering ---
12839  // All functions use string concatenation and indexOf - no byte-level ops.
12840  // tinyjs compiles Go strings to JS strings (UTF-16); byte indexing corrupts emoji.
12841  
12842  // renderMarkdown converts note text to safe HTML via markdown-it jsbridge.
12843  // Pre-processes nostr entities and emoji shortcodes with placeholders,
12844  // runs markdown-it for commonmark + unicode-aware URL linkification,
12845  // then restores placeholders and converts media links to embeds.
12846  func renderMarkdown(s string) (sv string) {
12847  	// Extract nostr: entities before markdown-it touches the text.
12848  	var nostrSlots []string
12849  	s = extractNostrEntities(s, &nostrSlots)
12850  
12851  	// Extract emoji shortcodes.
12852  	var emojiSlots []string
12853  	s = extractEmojiShortcodes(s, &emojiSlots)
12854  
12855  	// Extract hashtags (#tag) so markdown-it doesn't strip the #.
12856  	var hashSlots []string
12857  	s = extractHashtags(s, &hashSlots)
12858  
12859  	// markdown-it: commonmark, auto-linkify (unicode-aware), breaks.
12860  	s = markdown.Render(s)
12861  
12862  	// Restore hashtag placeholders.
12863  	for i, html := range hashSlots {
12864  		s = strReplace(s, "\x02HASH" | itoa(i) | "\x02", html)
12865  	}
12866  
12867  	// Restore emoji placeholders.
12868  	for i, html := range emojiSlots {
12869  		s = strReplace(s, "\x02EMOJI" | itoa(i) | "\x02", html)
12870  	}
12871  
12872  	// Restore nostr entity placeholders.
12873  	for i, html := range nostrSlots {
12874  		s = strReplace(s, "\x02NOSTR" | itoa(i) | "\x02", html)
12875  	}
12876  
12877  	// Convert <a> tags pointing to image/video URLs into embeds.
12878  	s = convertMediaLinks(s)
12879  
12880  	return s
12881  }
12882  
12883  // extractNostrEntities replaces nostr:... entities with placeholders.
12884  func extractNostrEntities(s string, slots *[]string) (sv string) {
12885  	out := ""
12886  	for {
12887  		idx := strIndex(s, "nostr:")
12888  		if idx < 0 {
12889  			return out | s
12890  		}
12891  		out = out | s[:idx]
12892  		rest := s[idx+6:]
12893  		end := 0
12894  		for end < len(rest) {
12895  			c := rest[end]
12896  			if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
12897  				end++
12898  			} else {
12899  				break
12900  			}
12901  		}
12902  		if end < 10 {
12903  			out = out | "nostr:"
12904  			s = rest
12905  			continue
12906  		}
12907  		entity := rest[:end]
12908  		s = rest[end:]
12909  		html := renderNostrEntity(entity)
12910  		id := len(*slots)
12911  		*slots = append(*slots, html)
12912  		out = out | "\x02NOSTR" | itoa(id) | "\x02"
12913  	}
12914  }
12915  
12916  // renderNostrEntity produces the HTML for a single nostr bech32 entity.
12917  func renderNostrEntity(entity string) (s string) {
12918  	if len(entity) > 6 && entity[:6] == "naddr1" {
12919  		na := helpers.DecodeNaddr(entity)
12920  		if na != nil {
12921  			coord := helpers.NaddrCoordKey(na.Kind, na.Pubkey, na.D)
12922  			for _, r := range na.Relays {
12923  				embedRelayHints[coord] = append(embedRelayHints[coord], r)
12924  			}
12925  			if !lookupFetchedK0(na.Pubkey) {
12926  				profile.Send(`["P_RESOLVE",` | jstr(na.Pubkey) | `]`)
12927  			}
12928  			return makeCoordEmbedPlaceholder(coord, na.Kind, na.Pubkey, na.D)
12929  		}
12930  	} else if len(entity) > 7 && entity[:7] == "nevent1" {
12931  		nev := helpers.DecodeNevent(entity)
12932  		if nev != nil {
12933  			if len(nev.Relays) > 0 {
12934  				embedRelayHints[nev.ID] = nev.Relays
12935  			}
12936  			return makeEmbedPlaceholder(nev.ID)
12937  		}
12938  	} else if len(entity) > 5 && entity[:5] == "note1" {
12939  		id := helpers.DecodeNote(entity)
12940  		if id != nil {
12941  			return makeEmbedPlaceholder(helpers.HexEncode(id))
12942  		}
12943  	} else if len(entity) > 9 && entity[:9] == "nprofile1" {
12944  		np := helpers.DecodeNprofile(entity)
12945  		if np != nil {
12946  			name := np.Pubkey[:8] | "..."
12947  			if n, ok := authorNames[np.Pubkey]; ok && n != "" {
12948  				name = n
12949  			} else {
12950  				profile.Send(`["P_RESOLVE",` | jstr(np.Pubkey) | `]`)
12951  			}
12952  			npub := helpers.EncodeNpub(helpers.HexDecode(np.Pubkey))
12953  			return "<a href=\"/p/" | npub | "\" data-pk=\"" | np.Pubkey | "\" style=\"color:var(--accent)\">" | escapeHTML(name) | "</a>"
12954  		}
12955  	} else if len(entity) > 5 && entity[:5] == "npub1" {
12956  		pk := helpers.DecodeNpub(entity)
12957  		if pk != nil {
12958  			hex := helpers.HexEncode(pk)
12959  			name := hex[:8] | "..."
12960  			if n, ok := authorNames[hex]; ok && n != "" {
12961  				name = n
12962  			} else {
12963  				profile.Send(`["P_RESOLVE",` | jstr(hex) | `]`)
12964  			}
12965  			npub := helpers.EncodeNpub(pk)
12966  			return "<a href=\"/p/" | npub | "\" data-pk=\"" | hex | "\" style=\"color:var(--accent)\">" | escapeHTML(name) | "</a>"
12967  		}
12968  	}
12969  	return "nostr:" | entity
12970  }
12971  
12972  // extractEmojiShortcodes replaces :name: shortcodes with placeholders.
12973  func extractEmojiShortcodes(s string, slots *[]string) (sv string) {
12974  	out := ""
12975  	i := 0
12976  	for i < len(s) {
12977  		if s[i] == ':' {
12978  			end := -1
12979  			valid := true
12980  			for j := i + 1; j < len(s) && j < i+40; j++ {
12981  				c := s[j]
12982  				if c == ':' {
12983  					end = j
12984  					break
12985  				}
12986  				if c == ' ' || c == '\n' || c == '\r' || c == '<' {
12987  					valid = false
12988  					break
12989  				}
12990  				if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
12991  					valid = false
12992  					break
12993  				}
12994  			}
12995  			if valid && end > i+1 {
12996  				name := s[i+1 : end]
12997  				code := ":" | name | ":"
12998  				var html string
12999  				if emoji, ok := emojiByName[code]; ok {
13000  					html = emoji
13001  				} else {
13002  					html = "<span style=\"font-size:0;line-height:0\">" | code | "</span>"
13003  				}
13004  				id := len(*slots)
13005  				*slots = append(*slots, html)
13006  				out = out | "\x02EMOJI" | itoa(id) | "\x02"
13007  				i = end + 1
13008  				continue
13009  			}
13010  		}
13011  		b := s[i]
13012  		n := 1
13013  		if b >= 0xF0 {
13014  			n = 4
13015  		} else if b >= 0xE0 {
13016  			n = 3
13017  		} else if b >= 0xC0 {
13018  			n = 2
13019  		}
13020  		if i+n > len(s) {
13021  			n = len(s) - i
13022  		}
13023  		out = out | s[i : i+n]
13024  		i += n
13025  	}
13026  	return out
13027  }
13028  
13029  // convertMediaLinks finds <a> tags with image/video URLs and replaces them.
13030  func convertMediaLinks(s string) (sv string) {
13031  	out := ""
13032  	for {
13033  		idx := strIndex(s, "<a ")
13034  		if idx < 0 {
13035  			return out | s
13036  		}
13037  		out = out | s[:idx]
13038  		s = s[idx:]
13039  		// Find closing </a>.
13040  		endTag := strIndex(s, "</a>")
13041  		if endTag < 0 {
13042  			return out | s
13043  		}
13044  		full := s[:endTag+4]
13045  		s = s[endTag+4:]
13046  		// Extract href value.
13047  		hrefIdx := strIndex(full, "href=\"")
13048  		if hrefIdx < 0 {
13049  			out = out | full
13050  			continue
13051  		}
13052  		hrefStart := hrefIdx + 6
13053  		hrefEnd := strIndex(full[hrefStart:], "\"")
13054  		if hrefEnd < 0 {
13055  			out = out | full
13056  			continue
13057  		}
13058  		href := full[hrefStart : hrefStart+hrefEnd]
13059  		if len(href) > 8 && strIndex(href[8:], "http") >= 0 {
13060  			out = out | full
13061  			continue
13062  		}
13063  		if ytID := youTubeVideoID(href); ytID != "" {
13064  			// Strip surrounding <p> when the YouTube link is the sole paragraph.
13065  			const pOpen = "<p style=\"margin:0 0 1em 0\">"
13066  			if hasSuffix(out, pOpen) {
13067  				out = out[:len(out)-len(pOpen)]
13068  				if hasPrefix(s, "</p>") {
13069  					s = s[4:]
13070  				}
13071  			}
13072  			thumbURL := "https://img.youtube.com/vi/" | ytID | "/hqdefault.jpg"
13073  			// data-invidious-id triggers the Invidious player when clicked.
13074  			// The fallback href still opens YouTube in a new tab.
13075  			out = out | "<div data-invidious-id=\"" | escapeHTMLAttr(ytID) | "\" data-yt-href=\"" | escapeHTMLAttr(href) | "\" style=\"display:block;margin:4px 0;cursor:pointer\">" |
13076  				"<img src=\"" | escapeHTMLAttr(mediaProxyPath(thumbURL)) | "\" referrerpolicy=\"no-referrer\" style=\"display:block;max-width:67%;max-height:50vh;object-fit:contain;border-radius:8px;margin:0 0 4px 0\" loading=\"lazy\">" |
13077  				"<span data-ytid=\"" | escapeHTMLAttr(ytID) | "\" style=\"display:block;font-size:13px;color:var(--accent);word-break:break-word\"></span>" |
13078  				"</div>"
13079  		} else if isBandcampURL(href) {
13080  			// Bandcamp link: show as a plain link with a "▶ campion" button alongside.
13081  			out = out | "<div style=\"margin:4px 0\">" |
13082  				"<a href=\"" | escapeHTMLAttr(href) | "\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent);word-break:break-all\">" | escapeHTMLAttr(href) | "</a>" |
13083  				" <span data-campion-url=\"" | escapeHTMLAttr(href) | "\" style=\"cursor:pointer;font-size:12px;color:var(--accent);opacity:0.8;white-space:nowrap\">\xe2\x96\xb6 campion</span>" |
13084  				"</div>"
13085  		} else if isImageURL(href) {
13086  			orig := "\" data-orig-url=\"" | escapeHTMLAttr(href) | "\""
13087  			out = out | "<img src=\"" | escapeHTMLAttr(mediaProxyPath(href)) | orig | " referrerpolicy=\"no-referrer\" style=\"display:block;max-width:100%;max-height:80vh;object-fit:contain;border-radius:8px;margin:4px 0;cursor:zoom-in\" loading=\"lazy\">"
13088  		} else if isVideoURL(href) {
13089  			out = out | "<video src=\"" | escapeHTMLAttr(mediaProxyPath(href)) | "\" controls preload=\"none\" referrerpolicy=\"no-referrer\" style=\"display:block;max-width:100%;max-height:80vh;border-radius:8px;margin:4px 0\"></video>"
13090  		} else {
13091  			out = out | full
13092  		}
13093  	}
13094  }
13095  
13096  // truncateByLine cuts `text` at the end of the line containing position `target`.
13097  // Returns (truncated, true) if content was removed. Returns (text, false) otherwise.
13098  // This avoids mid-line cuts where only a few words would remain hidden.
13099  func truncateByLine(text string, target int32) (string, bool) {
13100  	if len(text) <= target {
13101  		return text, false
13102  	}
13103  	cut := target
13104  	for cut < len(text) && text[cut] != '\n' {
13105  		cut++
13106  	}
13107  	if cut >= len(text) {
13108  		return text, false
13109  	}
13110  	return text[:cut], true
13111  }
13112  
13113  // strReplace replaces all occurrences of old with new using indexOf.
13114  func strReplace(s, old, nw string) (result string) {
13115  	out := ""
13116  	for {
13117  		idx := strIndex(s, old)
13118  		if idx < 0 {
13119  			return out | s
13120  		}
13121  		out = out | s[:idx] | nw
13122  		s = s[idx+len(old):]
13123  	}
13124  }
13125  
13126  func isImageURL(url string) (ok bool) {
13127  	u := toLower(url)
13128  	return hasSuffix(u, ".jpg") || hasSuffix(u, ".jpeg") || hasSuffix(u, ".png") ||
13129  		hasSuffix(u, ".gif") || hasSuffix(u, ".webp") || hasSuffix(u, ".svg")
13130  }
13131  
13132  // migratedRelayTypes is the set of message types routed to the relay-proxy
13133  // worker. Anything else still flows to the legacy SW.
13134  //
13135  var migratedRelayTypes = map[string]bool{
13136  	"REQ":              true,
13137  	"CLOSE":            true,
13138  	"EVENT":            true,
13139  	"PUBLISH_TO":       true,
13140  	"PROXY":            true,
13141  	"SET_PUBKEY":       true,
13142  	"SET_WRITE_RELAYS": true,
13143  	"ENC_KEY":          true,
13144  }
13145  
13146  // mlsWorkerTypes are routed to the MLS Worker.
13147  var mlsWorkerTypes = map[string]bool{
13148  	"MLS_INIT":        true,
13149  	"MLS_SEND_DM":     true,
13150  	"MLS_SEND_GROUP":  true,
13151  	"MLS_RATCHET_DM":  true,
13152  	"MLS_DM_LIST":     true,
13153  	"MLS_DM_HISTORY":  true,
13154  }
13155  
13156  // swTypes are routed to the Service Worker.
13157  var swTypes = map[string]bool{
13158  	"activate-update": true,
13159  }
13160  
13161  // routeMsg dispatches a JSON-array message to the appropriate worker.
13162  func routeMsg(msg string) {
13163  	if len(msg) >= 4 && msg[0] == '[' && msg[1] == '"' {
13164  		end := 2
13165  		for end < len(msg) && msg[end] != '"' {
13166  			end++
13167  		}
13168  		typ := msg[2:end]
13169  		if migratedRelayTypes[typ] {
13170  			relayproxy.Send(msg)
13171  			return
13172  		}
13173  		if mlsWorkerTypes[typ] {
13174  			mlsw.Send(msg)
13175  			return
13176  		}
13177  		if swTypes[typ] {
13178  			dom.PostToSW(msg)
13179  			return
13180  		}
13181  		dom.ConsoleLog("routeMsg: UNROUTED message type: " | typ)
13182  		return
13183  	}
13184  	if msg == "activate-update" {
13185  		dom.PostToSW(msg)
13186  		return
13187  	}
13188  	n := len(msg)
13189  	if n > 60 { n = 60 }
13190  	dom.ConsoleLog("routeMsg: UNROUTED non-array message: " | msg[:n])
13191  }
13192  
13193  // setHTMLWithMedia sets innerHTML and processes media links and lightboxes.
13194  // makeShowMore creates the show-more/less toggle span (initially hidden).
13195  // attachShowMore must be called after the note is in the DOM to decide
13196  // whether to reveal it based on actual rendered height.
13197  func makeShowMore(content dom.Element) (e dom.Element) {
13198  	more := dom.CreateElement("span")
13199  	dom.SetTextContent(more, t("show_more"))
13200  	dom.SetStyle(more, "color", "var(--accent)")
13201  	dom.SetStyle(more, "cursor", "pointer")
13202  	dom.SetStyle(more, "fontSize", "13px")
13203  	dom.SetStyle(more, "display", "none")
13204  	expanded := false
13205  	dom.AddEventListener(more, "click", dom.RegisterCallback(func() {
13206  		expanded = !expanded
13207  		if expanded {
13208  			dom.SetAttribute(content, "data-expanded", "1")
13209  			dom.SetStyle(content, "overflow", "visible")
13210  			dom.SetStyle(content, "maxHeight", "none")
13211  			dom.SetTextContent(more, t("show_less"))
13212  		} else {
13213  			dom.RemoveAttribute(content, "data-expanded")
13214  			dom.SetStyle(content, "overflow", "hidden")
13215  			dom.SetStyle(content, "maxHeight", "18em")
13216  			dom.SetTextContent(more, t("show_more"))
13217  		}
13218  	}))
13219  	return more
13220  }
13221  
13222  func attachShowMore(content, more dom.Element) {
13223  	c := content
13224  	m := more
13225  	check := func() {
13226  		if dom.GetAttribute(c, "data-expanded") == "1" {
13227  			return
13228  		}
13229  		scrollH := parseI64(dom.GetProperty(c, "scrollHeight"))
13230  		clientH := parseI64(dom.GetProperty(c, "clientHeight"))
13231  		if clientH > 0 && scrollH <= clientH {
13232  			dom.SetStyle(m, "display", "none")
13233  		} else if scrollH > clientH {
13234  			dom.SetStyle(m, "display", "inline-flex")
13235  		}
13236  	}
13237  	dom.ObserveResize(c, dom.RegisterCallback(check))
13238  	dom.SetTimeout(check, 200)
13239  }
13240  
13241  func isBandcampURL(url string) (ok bool) {
13242  	// Matches *.bandcamp.com/* URLs.
13243  	i := strIndex(url, "://")
13244  	if i < 0 {
13245  		return false
13246  	}
13247  	host := url[i+3:]
13248  	slash := strIndex(host, "/")
13249  	if slash >= 0 {
13250  		host = host[:slash]
13251  	}
13252  	return hasSuffix(host, ".bandcamp.com") || host == "bandcamp.com"
13253  }
13254  
13255  func setHTMLWithMedia(root dom.Element, html string) {
13256  	dom.SetInnerHTML(root, html)
13257  	flushYtTitles(root)
13258  	attachImageLightboxes(root)
13259  	attachYTLightboxes(root)
13260  	attachCampionLinks(root)
13261  	attachHashtagLinks(root)
13262  }
13263  
13264  // ytTitleCache maps YouTube video ID -> fetched title.
13265  var ytTitleCache = map[string]string{}
13266  
13267  // flushYtTitles finds every [data-ytid] span and fills it with the video title.
13268  func flushYtTitles(root dom.Element) {
13269  	el := dom.QuerySelectorFrom(root, "[data-ytid]")
13270  	for el != 0 {
13271  		ytID := dom.GetAttribute(el, "data-ytid")
13272  		dom.RemoveAttribute(el, "data-ytid")
13273  		if ytID == "" {
13274  			el = dom.QuerySelectorFrom(root, "[data-ytid]")
13275  			continue
13276  		}
13277  		if title, ok := ytTitleCache[ytID]; ok {
13278  			dom.SetTextContent(el, title)
13279  			el = dom.QuerySelectorFrom(root, "[data-ytid]")
13280  			continue
13281  		}
13282  		capturedEl := el
13283  		capturedID := ytID
13284  		oembedURL := "https://www.youtube.com/oembed?url=" |
13285  			"https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D" | ytID |
13286  			"&format=json"
13287  		dom.FetchText(oembedURL, func(body string) {
13288  			title := helpers.JsonGetString(body, "title")
13289  			if title == "" {
13290  				title = capturedID
13291  			}
13292  			ytTitleCache[capturedID] = title
13293  			dom.SetTextContent(capturedEl, title)
13294  		})
13295  		el = dom.QuerySelectorFrom(root, "[data-ytid]")
13296  	}
13297  }
13298  
13299  // attachImageLightboxes finds every img[data-orig-url] in root, removes the
13300  // attribute (so the loop terminates), and wires a click handler that opens
13301  // the full-screen lightbox. Called from setHTMLWithMedia after innerHTML is set.
13302  func attachImageLightboxes(root dom.Element) {
13303  	el := dom.QuerySelectorFrom(root, "[data-orig-url]")
13304  	for el != 0 {
13305  		url := dom.GetAttribute(el, "data-orig-url")
13306  		dom.RemoveAttribute(el, "data-orig-url")
13307  		if url != "" {
13308  			capturedURL := url
13309  			dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
13310  				showImageLightbox(capturedURL)
13311  			}))
13312  		}
13313  		el = dom.QuerySelectorFrom(root, "[data-orig-url]")
13314  	}
13315  }
13316  
13317  // attachYTLightboxes wires click handlers on [data-invidious-id] divs.
13318  // Clicking replaces the thumbnail inline with an Invidious embed iframe.
13319  func attachYTLightboxes(root dom.Element) {
13320  	el := dom.QuerySelectorFrom(root, "[data-invidious-id]")
13321  	for el != 0 {
13322  		ytID := dom.GetAttribute(el, "data-invidious-id")
13323  		ytHref := dom.GetAttribute(el, "data-yt-href")
13324  		dom.RemoveAttribute(el, "data-invidious-id")
13325  		dom.RemoveAttribute(el, "data-yt-href")
13326  		if ytID != "" {
13327  			capturedEl := el
13328  			capturedID := ytID
13329  			capturedHref := ytHref
13330  			dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
13331  				if invidiousURL != "" {
13332  					inlineInvidiousEmbed(capturedEl, capturedID)
13333  				} else {
13334  					dom.WindowOpen(capturedHref)
13335  				}
13336  			}))
13337  		}
13338  		el = dom.QuerySelectorFrom(root, "[data-invidious-id]")
13339  	}
13340  }
13341  
13342  // inlineInvidiousEmbed replaces the thumbnail wrapper div's content with an
13343  // Invidious embed iframe sized at 16:9, plus a small control row below.
13344  // Stamps data-inv-ytid on the wrapper so stopInlineMedia can restore it.
13345  func inlineInvidiousEmbed(wrapper dom.Element, ytID string) {
13346  	dom.ReleaseChildren(wrapper)
13347  	dom.SetInnerHTML(wrapper, "")
13348  	dom.SetStyle(wrapper, "cursor", "default")
13349  	dom.SetAttribute(wrapper, "data-inv-ytid", ytID)
13350  	dom.SetStyle(wrapper, "maxWidth", "100%")
13351  
13352  	iframe := dom.CreateElement("iframe")
13353  	dom.SetAttribute(iframe, "src", invidiousURL | "/embed/" | ytID | "?autoplay=1")
13354  	dom.SetAttribute(iframe, "allowfullscreen", "true")
13355  	dom.SetAttribute(iframe, "credentialless", "")
13356  	dom.SetStyle(iframe, "width", "100%")
13357  	dom.SetStyle(iframe, "aspectRatio", "16/9")
13358  	dom.SetStyle(iframe, "border", "none")
13359  	dom.SetStyle(iframe, "display", "block")
13360  	dom.SetStyle(iframe, "borderRadius", "8px")
13361  	dom.SetStyle(iframe, "background", "#000")
13362  	dom.AppendChild(wrapper, iframe)
13363  
13364  	// Control row: "open in invidious ↗" on the right.
13365  	bar := dom.CreateElement("div")
13366  	dom.SetStyle(bar, "display", "flex")
13367  	dom.SetStyle(bar, "justifyContent", "flex-end")
13368  	dom.SetStyle(bar, "marginTop", "2px")
13369  
13370  	link := dom.CreateElement("span")
13371  	dom.SetTextContent(link, "open in invidious \xe2\x86\x97")
13372  	dom.SetStyle(link, "fontSize", "10px")
13373  	dom.SetStyle(link, "color", "var(--accent)")
13374  	dom.SetStyle(link, "cursor", "pointer")
13375  	dom.SetStyle(link, "opacity", "0.7")
13376  	capturedID := ytID
13377  	dom.AddEventListener(link, "click", dom.RegisterCallback(func() {
13378  		dom.WindowOpen(invidiousURL | "/watch?v=" | capturedID)
13379  	}))
13380  	dom.AppendChild(bar, link)
13381  	dom.AppendChild(wrapper, bar)
13382  
13383  }
13384  
13385  // attachCampionLinks wires click handlers on [data-campion-url] spans.
13386  // Opens Campion in a new tab (window.open) to avoid COEP iframe conflicts.
13387  func attachCampionLinks(root dom.Element) {
13388  	el := dom.QuerySelectorFrom(root, "[data-campion-url]")
13389  	for el != 0 {
13390  		bcURL := dom.GetAttribute(el, "data-campion-url")
13391  		dom.RemoveAttribute(el, "data-campion-url")
13392  		if bcURL != "" {
13393  			capturedURL := bcURL
13394  			dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
13395  				if campionURL != "" {
13396  					dom.WindowOpen(campionURL | "?url=" | dom.EncodeURIComponent(capturedURL))
13397  				} else {
13398  					dom.WindowOpen(capturedURL)
13399  				}
13400  			}))
13401  		}
13402  		el = dom.QuerySelectorFrom(root, "[data-campion-url]")
13403  	}
13404  }
13405  
13406  // zoomStr converts a zoom level in 1/4x steps to a CSS scale() string.
13407  // Level 4 = 1.0x. Level 1 = 0.25x. Level 20 = 5.0x.
13408  func zoomStr(level int32) (s string) {
13409  	whole := level / 4
13410  	frac := (level % 4) * 25
13411  	if frac == 0 {
13412  		return itoa(whole)
13413  	}
13414  	fs := itoa(frac)
13415  	for len(fs) > 1 && fs[len(fs)-1] == '0' {
13416  		fs = fs[:len(fs)-1]
13417  	}
13418  	return itoa(whole) | "." | fs
13419  }
13420  
13421  func zoomCtrlBtn(label string) (e dom.Element) {
13422  	btn := dom.CreateElement("span")
13423  	dom.SetTextContent(btn, label)
13424  	dom.SetStyle(btn, "cursor", "pointer")
13425  	dom.SetStyle(btn, "fontSize", "28px")
13426  	dom.SetStyle(btn, "fontWeight", "bold")
13427  	dom.SetStyle(btn, "color", "#fff")
13428  	dom.SetStyle(btn, "userSelect", "none")
13429  	dom.SetStyle(btn, "padding", "0 6px")
13430  	dom.SetStyle(btn, "lineHeight", "1")
13431  	return btn
13432  }
13433  
13434  // showImageLightbox opens a full-screen modal for the given remote image URL.
13435  // Top-right: zoom [+] [-] [×close] bar. Bottom-left: clipboard button.
13436  // All controls are position:absolute inside the position:fixed scrim.
13437  func showImageLightbox(url string) {
13438  	zoomLevel := 4 // 1.0x
13439  
13440  	scrim := dom.CreateElement("div")
13441  	dom.SetStyle(scrim, "position", "fixed")
13442  	dom.SetStyle(scrim, "inset", "0")
13443  	dom.SetStyle(scrim, "background", "rgba(0,0,0,0.88)")
13444  	dom.SetStyle(scrim, "zIndex", "2000")
13445  	dom.SetStyle(scrim, "display", "flex")
13446  	dom.SetStyle(scrim, "alignItems", "center")
13447  	dom.SetStyle(scrim, "justifyContent", "center")
13448  	dom.SetStyle(scrim, "overflow", "hidden")
13449  
13450  	img := dom.CreateElement("img")
13451  	dom.SetAttribute(img, "referrerpolicy", "no-referrer")
13452  	dom.SetStyle(img, "maxWidth", "90vw")
13453  	dom.SetStyle(img, "maxHeight", "90vh")
13454  	dom.SetStyle(img, "objectFit", "contain")
13455  	dom.SetStyle(img, "display", "block")
13456  	dom.SetStyle(img, "transformOrigin", "center center")
13457  	setMediaSrc(img, url)
13458  
13459  	// Click the image to copy it as a PNG blob to the system clipboard.
13460  	capturedImg2 := img
13461  	dom.AddEventListener(img, "click", dom.RegisterCallback(func() {
13462  		dom.CopyImageToClipboard(capturedImg2, func(ok bool) {
13463  			if ok {
13464  				overlay := dom.CreateElement("div")
13465  				dom.SetTextContent(overlay, "image copied")
13466  				dom.SetStyle(overlay, "position", "absolute")
13467  				dom.SetStyle(overlay, "top", "50%")
13468  				dom.SetStyle(overlay, "left", "50%")
13469  				dom.SetStyle(overlay, "transform", "translate(-50%,-50%)")
13470  				dom.SetStyle(overlay, "background", "rgba(0,0,0,0.75)")
13471  				dom.SetStyle(overlay, "color", "#fff")
13472  				dom.SetStyle(overlay, "padding", "12px 24px")
13473  				dom.SetStyle(overlay, "borderRadius", "8px")
13474  				dom.SetStyle(overlay, "fontSize", "18px")
13475  				dom.SetStyle(overlay, "pointerEvents", "none")
13476  				dom.AppendChild(scrim, overlay)
13477  				capturedOverlay := overlay
13478  				dom.SetTimeout(func() {
13479  					dom.RemoveChild(scrim, capturedOverlay)
13480  				}, 1200)
13481  			}
13482  		})
13483  	}))
13484  	dom.SetStyle(img, "cursor", "copy")
13485  	dom.AppendChild(scrim, img)
13486  
13487  	// Clipboard button - absolute bottom-left, copies the remote URL.
13488  	clipBtn := dom.CreateElement("div")
13489  	dom.SetTextContent(clipBtn, "\xf0\x9f\x93\x8b") // 📋
13490  	dom.SetStyle(clipBtn, "position", "absolute")
13491  	dom.SetStyle(clipBtn, "bottom", "20px")
13492  	dom.SetStyle(clipBtn, "left", "20px")
13493  	dom.SetStyle(clipBtn, "cursor", "pointer")
13494  	dom.SetStyle(clipBtn, "fontSize", "32px")
13495  	dom.SetStyle(clipBtn, "background", "rgba(0,0,0,0.6)")
13496  	dom.SetStyle(clipBtn, "borderRadius", "10px")
13497  	dom.SetStyle(clipBtn, "padding", "8px 12px")
13498  	dom.SetStyle(clipBtn, "lineHeight", "1")
13499  	dom.SetStyle(clipBtn, "userSelect", "none")
13500  	capturedURL := url
13501  	capturedClip := clipBtn
13502  	dom.AddEventListener(clipBtn, "click", dom.RegisterCallback(func() {
13503  		dom.WritePrimarySelection(capturedURL)
13504  		dom.WriteClipboard(capturedURL, func(ok bool) {
13505  			if ok {
13506  				dom.SetTextContent(capturedClip, "\xe2\x9c\x93") // ✓
13507  				dom.SetTimeout(func() {
13508  					dom.SetTextContent(capturedClip, "\xf0\x9f\x93\x8b")
13509  				}, 1500)
13510  			}
13511  		})
13512  	}))
13513  	dom.AppendChild(scrim, clipBtn)
13514  
13515  	// Zoom + close bar - absolute top-right. × closes the lightbox.
13516  	zoomBar := dom.CreateElement("div")
13517  	dom.SetStyle(zoomBar, "position", "absolute")
13518  	dom.SetStyle(zoomBar, "top", "12px")
13519  	dom.SetStyle(zoomBar, "right", "16px")
13520  	dom.SetStyle(zoomBar, "display", "flex")
13521  	dom.SetStyle(zoomBar, "gap", "4px")
13522  	dom.SetStyle(zoomBar, "alignItems", "center")
13523  	dom.SetStyle(zoomBar, "background", "rgba(0,0,0,0.6)")
13524  	dom.SetStyle(zoomBar, "borderRadius", "10px")
13525  	dom.SetStyle(zoomBar, "padding", "6px 12px")
13526  
13527  	capturedImg := img
13528  	applyZoom := func() {
13529  		dom.SetStyle(capturedImg, "transform", "scale(" | zoomStr(zoomLevel) | ")")
13530  	}
13531  
13532  	plusBtn := zoomCtrlBtn("+")
13533  	dom.AddEventListener(plusBtn, "click", dom.RegisterCallback(func() {
13534  		if zoomLevel < 20 {
13535  			zoomLevel++
13536  		}
13537  		applyZoom()
13538  	}))
13539  	dom.AppendChild(zoomBar, plusBtn)
13540  
13541  	minusBtn := zoomCtrlBtn("-")
13542  	dom.AddEventListener(minusBtn, "click", dom.RegisterCallback(func() {
13543  		if zoomLevel > 1 {
13544  			zoomLevel--
13545  		}
13546  		applyZoom()
13547  	}))
13548  	dom.AppendChild(zoomBar, minusBtn)
13549  
13550  	// × closes (replaces separate close button).
13551  	closeBtn := zoomCtrlBtn("\xc3\x97") // ×
13552  	closeFn := dom.RegisterCallback(func() {
13553  		dom.RemoveChild(dom.Body(), scrim)
13554  	})
13555  	dom.AddEventListener(closeBtn, "click", closeFn)
13556  	dom.AppendChild(zoomBar, closeBtn)
13557  
13558  	dom.AppendChild(scrim, zoomBar)
13559  	dom.AddSelfEventListener(scrim, "click", closeFn)
13560  	dom.AppendChild(dom.Body(), scrim)
13561  }
13562  
13563  // noteClipText formats a single event as a markdown section for clipboard.
13564  // Format: ### name (npub) - timestamp\n\ncontent\n\n---\n\n
13565  func noteClipText(ev *nostr.Event) (s string) {
13566  	name := authorNames[ev.PubKey]
13567  	npub := helpers.EncodeNpub(helpers.HexDecode(ev.PubKey))
13568  	if name == "" {
13569  		if len(npub) > 20 {
13570  			name = npub[:12] | "..." | npub[len(npub)-4:]
13571  		} else {
13572  			name = npub
13573  		}
13574  	}
13575  	ts := formatTime(ev.CreatedAt)
13576  	return "### " | name | " (" | npub | ") - " | ts | "\n\n" | ev.Content | "\n\n---\n\n"
13577  }
13578  
13579  // collectBranch builds the children map from threadEvents and collects the
13580  // event at startID plus all its descendants (DFS, chronological). Used by
13581  // per-note copy buttons in thread view.
13582  func collectBranch(startID string) (ss []*nostr.Event) {
13583  	ch := map[string][]string{}
13584  	for id, ev := range threadEvents {
13585  		parentID := getReplyID(ev)
13586  		if parentID == "" || parentID == id {
13587  			continue
13588  		}
13589  		if threadEvents[parentID] == nil && parentID != threadRootID {
13590  			if referencesEvent(ev, threadRootID) {
13591  				parentID = threadRootID
13592  			}
13593  		}
13594  		ch[parentID] = append(ch[parentID], id)
13595  	}
13596  	for pid := range ch {
13597  		sortByCreatedAt(ch[pid])
13598  	}
13599  	var result []*nostr.Event
13600  	var dfs func(id string)
13601  	dfs = func(id string) {
13602  		ev, ok := threadEvents[id]
13603  		if !ok {
13604  			return
13605  		}
13606  		result = append(result, ev)
13607  		for _, childID := range ch[id] {
13608  			dfs(childID)
13609  		}
13610  	}
13611  	dfs(startID)
13612  	return result
13613  }
13614  
13615  // noteCopyBtn returns a 📋 span styled like the other header micro-buttons.
13616  func noteCopyBtn() (e dom.Element) {
13617  	btn := dom.CreateElement("span")
13618  	dom.SetTextContent(btn, "\xf0\x9f\x93\x8b") // 📋
13619  	dom.SetStyle(btn, "fontSize", "10px")
13620  	dom.SetStyle(btn, "cursor", "pointer")
13621  	dom.SetStyle(btn, "opacity", "0.6")
13622  	dom.SetStyle(btn, "marginRight", "4px")
13623  	dom.SetStyle(btn, "userSelect", "none")
13624  	capturedBtn := btn
13625  	dom.AddEventListener(btn, "mouseenter", dom.RegisterCallback(func() {
13626  		dom.SetStyle(capturedBtn, "opacity", "1")
13627  	}))
13628  	dom.AddEventListener(btn, "mouseleave", dom.RegisterCallback(func() {
13629  		dom.SetStyle(capturedBtn, "opacity", "0.6")
13630  	}))
13631  	return btn
13632  }
13633  
13634  // noteCopyFeedback briefly shows ✓ on the copy button after a successful write.
13635  func noteCopyFeedback(btn dom.Element) {
13636  	dom.SetTextContent(btn, "\xe2\x9c\x93") // ✓
13637  	dom.SetStyle(btn, "opacity", "1")
13638  	capturedBtn := btn
13639  	dom.SetTimeout(func() {
13640  		dom.SetTextContent(capturedBtn, "\xf0\x9f\x93\x8b")
13641  		dom.SetStyle(capturedBtn, "opacity", "0.6")
13642  	}, 1200)
13643  }
13644  
13645  // prettyNoteJSON returns the event serialised as indented JSON with tabs.
13646  func prettyNoteJSON(ev *nostr.Event) (s string) {
13647  	s = "{\n"
13648  	s = s | "\t\"id\": \"" | ev.ID | "\",\n"
13649  	s = s | "\t\"pubkey\": \"" | ev.PubKey | "\",\n"
13650  	s = s | "\t\"created_at\": " | i64toa(ev.CreatedAt) | ",\n"
13651  	s = s | "\t\"kind\": " | itoa(int32(ev.Kind)) | ",\n"
13652  	s = s | "\t\"tags\": [\n"
13653  	for i, tag := range ev.Tags {
13654  		s = s | "\t\t["
13655  		for j, v := range tag {
13656  			if j > 0 {
13657  				s = s | ", "
13658  			}
13659  			s = s | "\"" | jsonEsc(v) | "\""
13660  		}
13661  		s = s | "]"
13662  		if i < len(ev.Tags)-1 {
13663  			s = s | ","
13664  		}
13665  		s = s | "\n"
13666  	}
13667  	s = s | "\t],\n"
13668  	s = s | "\t\"content\": \"" | jsonEsc(ev.Content) | "\",\n"
13669  	s = s | "\t\"sig\": \"" | ev.Sig | "\"\n"
13670  	s = s | "}"
13671  	return s
13672  }
13673  
13674  // setMediaSrc sets elem's src to the /proxy/... same-origin path for the URL.
13675  // The browser's HTTP cache (Cache-Control: public, max-age=86400 from the
13676  // relay) serves cached images synchronously without any worker round-trip.
13677  func setMediaSrc(elem dom.Element, url string) {
13678  	if len(url) == 0 {
13679  		return
13680  	}
13681  	dom.SetAttribute(elem, "src", mediaProxyPath(url))
13682  }
13683  
13684  // mediaProxyPath converts an absolute URL to a same-origin /proxy/ path.
13685  // Same-origin and data: URLs are returned unchanged.
13686  func mediaProxyPath(url string) (s string) {
13687  	if len(url) == 0 || url[0] == '/' {
13688  		return url
13689  	}
13690  	if len(url) >= 5 && url[:5] == "data:" {
13691  		return url
13692  	}
13693  	if len(url) >= 8 && url[:8] == "https://" {
13694  		return "/proxy/" | url[8:]
13695  	}
13696  	if len(url) >= 7 && url[:7] == "http://" {
13697  		return "/proxy/" | url[7:]
13698  	}
13699  	return url
13700  }
13701  
13702  func isVideoURL(url string) (ok bool) {
13703  	u := toLower(url)
13704  	return hasSuffix(u, ".mp4") || hasSuffix(u, ".webm") || hasSuffix(u, ".mov") ||
13705  		hasSuffix(u, ".ogg") || hasSuffix(u, ".ogv")
13706  }
13707  
13708  // youTubeVideoID extracts the YouTube video ID from a URL.
13709  // Returns "" if the URL is not a YouTube link.
13710  // Handles youtube.com/watch?v=ID, youtu.be/ID, youtube.com/shorts/ID, youtube.com/embed/ID.
13711  func youTubeVideoID(url string) (s string) {
13712  	u := toLower(url)
13713  	// youtu.be/ID
13714  	if hasPrefix(u, "https://youtu.be/") || hasPrefix(u, "http://youtu.be/") {
13715  		rest := url[strIndex(url, "youtu.be/")+9:]
13716  		return youTubeExtractID(rest)
13717  	}
13718  	// youtube.com variants
13719  	if strIndex(u, "youtube.com/") < 0 {
13720  		return ""
13721  	}
13722  	// /shorts/ID
13723  	si := strIndex(u, "/shorts/")
13724  	if si >= 0 {
13725  		rest := url[si+8:]
13726  		return youTubeExtractID(rest)
13727  	}
13728  	// /embed/ID
13729  	ei := strIndex(u, "/embed/")
13730  	if ei >= 0 {
13731  		rest := url[ei+7:]
13732  		return youTubeExtractID(rest)
13733  	}
13734  	// ?v=ID or &v=ID
13735  	vi := strIndex(u, "v=")
13736  	if vi >= 0 {
13737  		rest := url[vi+2:]
13738  		return youTubeExtractID(rest)
13739  	}
13740  	return ""
13741  }
13742  
13743  // youTubeExtractID returns the video ID: the segment up to the first '&', '?', '#', '/' or end.
13744  func youTubeExtractID(s string) (sv string) {
13745  	end := len(s)
13746  	for i := 0; i < len(s); i++ {
13747  		c := s[i]
13748  		if c == '&' || c == '?' || c == '#' || c == '/' {
13749  			end = i
13750  			break
13751  		}
13752  	}
13753  	id := s[:end]
13754  	if len(id) < 5 || len(id) > 20 {
13755  		return ""
13756  	}
13757  	return id
13758  }
13759  
13760  func escapeHTMLAttr(s string) (sv string) {
13761  	out := ""
13762  	start := 0
13763  	for i := 0; i < len(s); i++ {
13764  		var esc string
13765  		switch s[i] {
13766  		case '"':
13767  			esc = "&quot;"
13768  		case '\'':
13769  			esc = "&#39;"
13770  		case '&':
13771  			esc = "&amp;"
13772  		case '<':
13773  			esc = "&lt;"
13774  		case '>':
13775  			esc = "&gt;"
13776  		default:
13777  			continue
13778  		}
13779  		out = out | s[start:i] | esc
13780  		start = i + 1
13781  	}
13782  	return out | s[start:]
13783  }
13784  
13785  func escapeHTML(s string) (sv string) {
13786  	out := ""
13787  	start := 0
13788  	for i := 0; i < len(s); i++ {
13789  		var esc string
13790  		switch s[i] {
13791  		case '&':
13792  			esc = "&amp;"
13793  		case '<':
13794  			esc = "&lt;"
13795  		case '>':
13796  			esc = "&gt;"
13797  		default:
13798  			continue
13799  		}
13800  		out = out | s[start:i] | esc
13801  		start = i + 1
13802  	}
13803  	return out | s[start:]
13804  }
13805  
13806  func hasSuffix(s, suffix string) (ok bool) {
13807  	return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
13808  }
13809  
13810  
13811  func updateInlineProfileLinks(pk string) {
13812  	name := authorNames[pk]
13813  	if name == "" {
13814  		return
13815  	}
13816  	// Find and update all <a data-pk="..."> links for this pubkey.
13817  	// Loop with QuerySelector since there's no QuerySelectorAll.
13818  	for {
13819  		el := dom.QuerySelector("a[data-pk=\"" | pk | "\"]")
13820  		if el == 0 {
13821  			break
13822  		}
13823  		dom.SetTextContent(el, name)
13824  		// Mark as done so we don't match it again.
13825  		dom.SetAttribute(el, "data-pk", "")
13826  	}
13827  }
13828  
13829  func embedEOSECleanup(subID string) {
13830  	ids, ok := embedSubIDs[subID]
13831  	if !ok {
13832  		return
13833  	}
13834  	delete(embedSubIDs, subID)
13835  	for _, hexID := range ids {
13836  		if _, still := embedCallbacks[hexID]; !still {
13837  			continue
13838  		}
13839  		// Check if any other sub is still pending for this ID.
13840  		otherPending := false
13841  		for _, otherIDs := range embedSubIDs {
13842  			for _, oid := range otherIDs {
13843  				if oid == hexID {
13844  					otherPending = true
13845  					break
13846  				}
13847  			}
13848  			if otherPending {
13849  				break
13850  			}
13851  		}
13852  		if otherPending {
13853  			continue
13854  		}
13855  		// No subs left - mark as not found.
13856  		delete(embedRequested, hexID)
13857  		elemIDs := embedCallbacks[hexID]
13858  		delete(embedCallbacks, hexID)
13859  		for _, elemID := range elemIDs {
13860  			el := dom.GetElementById(elemID)
13861  			if el == 0 {
13862  				continue
13863  			}
13864  			dom.SetInnerHTML(el, "<em style=\"color:var(--muted,#888)\">" | t("note_not_found") | "</em>")
13865  			dom.SetStyle(el, "opacity", "0.5")
13866  		}
13867  	}
13868  }
13869  
13870  // fillCoordEmbed matches an arriving event to its coord placeholders and fills them.
13871  func fillCoordEmbed(ev *nostr.Event) {
13872  	// Build the coord key from the event's kind, pubkey, and d-tag.
13873  	d := ""
13874  	for _, tag := range ev.Tags {
13875  		if len(tag) >= 2 && tag[0] == "d" {
13876  			d = tag[1]
13877  			break
13878  		}
13879  	}
13880  	coord := helpers.NaddrCoordKey(uint32(ev.Kind), ev.PubKey, d)
13881  	elemIDs, ok := embedCoordCBs[coord]
13882  	if !ok {
13883  		// Also check old generation.
13884  		elemIDs, ok = embedCoordCBsOld[coord]
13885  		if !ok {
13886  			return
13887  		}
13888  		delete(embedCoordCBsOld, coord)
13889  	} else {
13890  		delete(embedCoordCBs, coord)
13891  	}
13892  
13893  	name := ev.PubKey[:8] | "..."
13894  	if n, ok2 := authorNames[ev.PubKey]; ok2 && n != "" {
13895  		name = n
13896  	}
13897  	// For kind 30023, use the title tag; otherwise truncate content.
13898  	title := ""
13899  	if ev.Kind == uint32(30023) {
13900  		for _, tag := range ev.Tags {
13901  			if len(tag) >= 2 && tag[0] == "title" {
13902  				title = tag[1]
13903  				break
13904  			}
13905  		}
13906  	}
13907  	text := title
13908  	if text == "" {
13909  		text = ev.Content
13910  		nl := -1
13911  		for i := 0; i < len(text); i++ {
13912  			if text[i] == '\n' {
13913  				nl = i
13914  				break
13915  			}
13916  		}
13917  		if nl >= 0 {
13918  			text = text[:nl]
13919  		}
13920  	}
13921  
13922  	headerHTML := "<div style=\"display:flex;align-items:center;gap:6px;margin-bottom:4px\">"
13923  	if pic, ok2 := authorPics[ev.PubKey]; ok2 && pic != "" {
13924  		headerHTML = headerHTML | "<img src=\"" | escapeHTMLAttr(mediaProxyPath(pic)) | "\" width=\"16\" height=\"16\" " |
13925  			"style=\"border-radius:50%\" referrerpolicy=\"no-referrer\" onerror=\"this.style.display='none'\">"
13926  	}
13927  	headerHTML = headerHTML | "<strong style=\"font-size:12px\">" | escapeHTML(name) | "</strong></div>"
13928  
13929  	capturedEv := ev
13930  	for _, elemID := range elemIDs {
13931  		el := dom.GetElementById(elemID)
13932  		if el == 0 {
13933  			continue
13934  		}
13935  		clearChildren(el)
13936  		dom.SetStyle(el, "opacity", "1")
13937  		dom.SetStyle(el, "cursor", "pointer")
13938  
13939  		hdr := dom.CreateElement("div")
13940  		dom.SetInnerHTML(hdr, headerHTML)
13941  		dom.AppendChild(el, hdr)
13942  
13943  		body := dom.CreateElement("div")
13944  		dom.SetStyle(body, "fontSize", "13px")
13945  		dom.SetStyle(body, "lineHeight", "1.4")
13946  		dom.SetInnerHTML(body, renderEmbedText(text))
13947  		dom.AppendChild(el, body)
13948  
13949  		thisEl := el
13950  		dom.AddEventListener(thisEl, "click", dom.RegisterCallback(func() {
13951  			showNoteThread(capturedEv.ID, capturedEv.ID)
13952  		}))
13953  	}
13954  }
13955  
13956  // embedCoordEOSECleanup marks unfound coord embeds as not found.
13957  func embedCoordEOSECleanup(subID string) {
13958  	coord, ok := embedCoordSubIDs[subID]
13959  	if !ok {
13960  		return
13961  	}
13962  	delete(embedCoordSubIDs, subID)
13963  	// Check if any other coord sub is still pending for this coord.
13964  	for _, other := range embedCoordSubIDs {
13965  		if other == coord {
13966  			return
13967  		}
13968  	}
13969  	elemIDs, ok := embedCoordCBs[coord]
13970  	if !ok {
13971  		return
13972  	}
13973  	delete(embedCoordCBs, coord)
13974  	for _, elemID := range elemIDs {
13975  		el := dom.GetElementById(elemID)
13976  		if el == 0 {
13977  			continue
13978  		}
13979  		dom.SetInnerHTML(el, "<em style=\"color:var(--muted,#888)\">" | t("note_not_found") | "</em>")
13980  		dom.SetStyle(el, "opacity", "0.5")
13981  	}
13982  }
13983  
13984  func makeCoordEmbedPlaceholder(coord string, kind uint32, pubkey, d string) (s string) {
13985  	embedCounter++
13986  	elemID := "emb-" | itoa(embedCounter)
13987  	embedCoordCBs[coord] = append(embedCoordCBs[coord], elemID)
13988  	label := "Loading..."
13989  	if kind == uint32(30023) {
13990  		label = "Loading article..."
13991  	}
13992  	return "<div id=\"" | elemID | "\" data-coord=\"" | escapeHTMLAttr(coord) | "\" " |
13993  		"style=\"border-left:3px solid var(--accent);margin:8px 0;padding:8px 12px;" |
13994  		"background:var(--bg2,#1a1a2e);border-radius:4px;opacity:0.6;font-size:13px\">" |
13995  		"<em>" | label | "</em></div>"
13996  }
13997  
13998  func makeEmbedPlaceholder(hexID string) (s string) {
13999  	embedCounter++
14000  	elemID := "emb-" | itoa(embedCounter)
14001  	embedCallbacks[hexID] = append(embedCallbacks[hexID], elemID)
14002  	return "<div id=\"" | elemID | "\" data-eid=\"" | hexID | "\" " |
14003  		"style=\"border-left:3px solid var(--accent);margin:8px 0;padding:8px 12px;" |
14004  		"background:var(--bg2,#1a1a2e);border-radius:4px;opacity:0.6;font-size:13px\">" |
14005  		"<em>Loading note...</em></div>"
14006  }
14007  
14008  func resolveEmbeds() {
14009  	if len(embedCallbacks) == 0 && len(embedCoordCBs) == 0 {
14010  		return
14011  	}
14012  	if len(embedCallbacks) == 0 {
14013  		for coord := range embedCoordCBs {
14014  			resolveCoordEmbed(coord)
14015  		}
14016  		return
14017  	}
14018  	// Fill any embeds already in cache before requesting.
14019  	for hexID := range embedCallbacks {
14020  		if ev, ok := lookupEvent(hexID); ok && ev != nil {
14021  			fillEmbed(ev)
14022  		}
14023  	}
14024  	if len(embedCallbacks) == 0 {
14025  		return
14026  	}
14027  	// Only request IDs we haven't already sent to PROXY.
14028  	// If embedRequested is true but the event isn't in cache, clear the
14029  	// flag so we re-fetch - the event may have been evicted by rotation.
14030  	var ids []string
14031  	for hexID := range embedCallbacks {
14032  		if embedRequested[hexID] {
14033  			if _, ok := lookupEvent(hexID); ok {
14034  				continue
14035  			}
14036  			delete(embedRequested, hexID)
14037  		}
14038  		ids = append(ids, hexID)
14039  		embedRequested[hexID] = true
14040  	}
14041  	if len(ids) == 0 {
14042  		return
14043  	}
14044  	filter := "{\"ids\":["
14045  	for i, id := range ids {
14046  		if i > 0 {
14047  			filter = filter | ","
14048  		}
14049  		filter = filter | jstr(id)
14050  	}
14051  	filter = filter | "]}"
14052  
14053  	// REQ checks the local IDB cache.
14054  	embedCounter++
14055  	reqID := "emb-r-" | itoa(embedCounter)
14056  	embedSubIDs[reqID] = ids
14057  	routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]")
14058  
14059  	// PROXY fetches from relays - combine user relays + nevent relay hints.
14060  	seen := map[string]bool{}
14061  	var urls []string
14062  	for _, u := range readRelayURLs() {
14063  		if !seen[u] {
14064  			seen[u] = true
14065  			urls = append(urls, u)
14066  		}
14067  	}
14068  	for _, id := range ids {
14069  		hints := embedRelayHints[id]
14070  		if len(hints) == 0 {
14071  			hints = embedRelayHintsOld[id]
14072  			delete(embedRelayHintsOld, id)
14073  		} else {
14074  			delete(embedRelayHints, id)
14075  		}
14076  		for _, u := range hints {
14077  			if !seen[u] {
14078  				seen[u] = true
14079  				urls = append(urls, u)
14080  			}
14081  		}
14082  	}
14083  	if len(urls) > 0 {
14084  		proxyID := "emb-p-" | itoa(embedCounter)
14085  		embedSubIDs[proxyID] = ids
14086  		routeMsg(buildProxyMsg(proxyID, filter, urls))
14087  	}
14088  
14089  	// Coord embeds: one PROXY sub per pending coord.
14090  	for coord := range embedCoordCBs {
14091  		resolveCoordEmbed(coord)
14092  	}
14093  }
14094  
14095  // resolveCoordEmbed fires a PROXY fetch for one naddr coordinate.
14096  func resolveCoordEmbed(coord string) {
14097  	// Parse "kind:pubkey:d" - split on first two colons.
14098  	ci := -1
14099  	for i := 0; i < len(coord); i++ {
14100  		if coord[i] == ':' {
14101  			ci = i
14102  			break
14103  		}
14104  	}
14105  	if ci < 0 {
14106  		return
14107  	}
14108  	kindStr := coord[:ci]
14109  	rest := coord[ci+1:]
14110  	pi := -1
14111  	for i := 0; i < len(rest); i++ {
14112  		if rest[i] == ':' {
14113  			pi = i
14114  			break
14115  		}
14116  	}
14117  	if pi < 0 {
14118  		return
14119  	}
14120  	pubkey := rest[:pi]
14121  	d := rest[pi+1:]
14122  
14123  	filter := "{\"kinds\":[" | kindStr | "],\"authors\":[" | jstr(pubkey) | "],\"#d\":[" | jstr(d) | "]}"
14124  
14125  	// Relay hints from the naddr TLV.
14126  	coordRelays := embedRelayHints[coord]
14127  	if len(coordRelays) > 0 {
14128  		delete(embedRelayHints, coord)
14129  	} else {
14130  		coordRelays = embedRelayHintsOld[coord]
14131  		delete(embedRelayHintsOld, coord)
14132  	}
14133  	var urls []string
14134  	seen := map[string]bool{}
14135  	for _, u := range readRelayURLs() {
14136  		if !seen[u] {
14137  			seen[u] = true
14138  			urls = append(urls, u)
14139  		}
14140  	}
14141  	for _, u := range coordRelays {
14142  		if !seen[u] {
14143  			seen[u] = true
14144  			urls = append(urls, u)
14145  		}
14146  	}
14147  
14148  	embedCounter++
14149  	if len(urls) > 0 {
14150  		proxyID := "emb-c-" | itoa(embedCounter)
14151  		embedCoordSubIDs[proxyID] = coord
14152  		routeMsg(buildProxyMsg(proxyID, filter, urls))
14153  	}
14154  	reqID := "emb-cr-" | itoa(embedCounter)
14155  	embedCoordSubIDs[reqID] = coord
14156  	routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]")
14157  }
14158  
14159  func renderEmbedText(s string) (sv string) {
14160  	s = markdown.Render(s)
14161  	s = convertMediaLinks(s)
14162  	return s
14163  }
14164  
14165  func fillEmbed(ev *nostr.Event) {
14166  	elemIDs, ok := embedCallbacks[ev.ID]
14167  	if !ok {
14168  		return
14169  	}
14170  	delete(embedCallbacks, ev.ID)
14171  	embedCacheCount++
14172  	if embedCacheCount > rotateEmbedMax {
14173  		rotateEmbedCaches()
14174  	}
14175  
14176  	name := ev.PubKey[:8] | "..."
14177  	if n, ok := authorNames[ev.PubKey]; ok && n != "" {
14178  		name = n
14179  	}
14180  	headerHTML := "<div style=\"display:flex;align-items:center;gap:6px;margin-bottom:4px\">"
14181  	if pic, ok := authorPics[ev.PubKey]; ok && pic != "" {
14182  		headerHTML = headerHTML | "<img src=\"" | escapeHTMLAttr(mediaProxyPath(pic)) | "\" width=\"16\" height=\"16\" " |
14183  			"style=\"border-radius:50%\" referrerpolicy=\"no-referrer\" onerror=\"this.style.display='none'\">"
14184  	}
14185  	headerHTML = headerHTML | "<strong style=\"font-size:12px\">" | escapeHTML(name) | "</strong></div>"
14186  
14187  	firstNL := -1
14188  	for i := 0; i < len(ev.Content); i++ {
14189  		if ev.Content[i] == '\n' {
14190  			firstNL = i
14191  			break
14192  		}
14193  	}
14194  	truncated := firstNL >= 0 && firstNL < len(ev.Content)-1
14195  	text := ev.Content
14196  	if truncated {
14197  		text = text[:firstNL]
14198  	}
14199  
14200  	embedEvID := ev.ID
14201  	embedRootID := getRootID(ev)
14202  	if embedRootID == "" {
14203  		embedRootID = embedEvID
14204  	}
14205  
14206  	for _, elemID := range elemIDs {
14207  		el := dom.GetElementById(elemID)
14208  		if el == 0 {
14209  			continue
14210  		}
14211  		clearChildren(el)
14212  		dom.SetStyle(el, "opacity", "1")
14213  		dom.SetStyle(el, "cursor", "pointer")
14214  
14215  		hdr := dom.CreateElement("div")
14216  		dom.SetInnerHTML(hdr, headerHTML)
14217  		dom.AppendChild(el, hdr)
14218  
14219  		body := dom.CreateElement("div")
14220  		dom.SetStyle(body, "fontSize", "13px")
14221  		dom.SetStyle(body, "lineHeight", "1.4")
14222  		dom.SetInnerHTML(body, renderEmbedText(text))
14223  		dom.AppendChild(el, body)
14224  
14225  		thisEl := el
14226  		dom.AddEventListener(thisEl, "click", dom.RegisterCallback(func() {
14227  			showNoteThread(embedRootID, embedEvID)
14228  		}))
14229  	}
14230  
14231  	if _, ok := authorNames[ev.PubKey]; !ok {
14232  		if !lookupFetchedK0(ev.PubKey) {
14233  			profile.Send(`["P_RESOLVE",` | jstr(ev.PubKey) | `]`)
14234  		}
14235  	}
14236  }
14237  
14238  // jsonGetNum extracts a numeric value for a given key from a JSON object.
14239  func jsonGetNum(s, key string) (n int64) {
14240  	needle := "\"" | key | "\":"
14241  	idx := strIndex(s, needle)
14242  	if idx < 0 {
14243  		return 0
14244  	}
14245  	idx += len(needle)
14246  	// Skip whitespace.
14247  	for idx < len(s) && (s[idx] == ' ' || s[idx] == '\t') {
14248  		idx++
14249  	}
14250  	if idx >= len(s) {
14251  		return 0
14252  	}
14253  	for idx < len(s) && s[idx] >= '0' && s[idx] <= '9' {
14254  		n = n*10 + int64(s[idx]-'0')
14255  		idx++
14256  	}
14257  	return n
14258  }
14259  
14260  // jsonEsc escapes a string for embedding in a JSON value.
14261  // defaultServiceURL derives a service subdomain URL from the current page origin.
14262  // wss://smesh.lol + "invidious" → https://invidious.smesh.lol
14263  func defaultServiceURL(sub string) (s string) {
14264  	local := localRelayURL()
14265  	var host string
14266  	if len(local) >= 6 && local[:6] == "wss://" {
14267  		host = local[6:]
14268  	} else if len(local) >= 5 && local[:5] == "ws://" {
14269  		host = local[5:]
14270  	} else {
14271  		return ""
14272  	}
14273  	// Only derive for non-localhost origins.
14274  	if host == "localhost" || len(host) > 9 && host[:9] == "127.0.0.1" {
14275  		return ""
14276  	}
14277  	return "https://" | sub | "." | host
14278  }
14279  
14280  // defaultBlossomURL derives the blossom server URL from the current page origin.
14281  // wss://smesh.lol → https://smesh.lol/blossom
14282  // --- Search ---
14283  
14284  func activateSearchBar(btn dom.Element) {
14285  	if !searchBarActive {
14286  		searchReturnPage = activePage // remember where to return on manual close
14287  	}
14288  	searchBarActive = true
14289  	dom.SetStyle(btn, "background", "var(--accent)")
14290  	dom.SetStyle(btn, "color", "#fff")
14291  	dom.SetStyle(btn, "borderRadius", "4px")
14292  	dom.SetStyle(feedSelectEl, "display", "none")
14293  	dom.SetStyle(pageTitleEl, "display", "none")
14294  	dom.SetStyle(notifBarGroup, "display", "none")
14295  	dom.SetStyle(topBarRight, "display", "none")
14296  	dom.SetStyle(searchInputEl, "display", "block")
14297  	dom.SetStyle(searchInputEl, "flex", "1")
14298  	dom.SetStyle(searchSubmitBtn, "display", "block")
14299  	dom.Focus(searchInputEl)
14300  }
14301  
14302  // deactivateSearchBar restores the bar appearance only.
14303  // Callers are responsible for any page navigation.
14304  func deactivateSearchBar(btn dom.Element) {
14305  	searchBarActive = false
14306  	dom.SetStyle(btn, "background", "transparent")
14307  	dom.SetStyle(btn, "color", "var(--muted)")
14308  	dom.SetStyle(btn, "borderRadius", "4px")
14309  	dom.SetStyle(topBarRight, "display", "flex")
14310  	dom.SetStyle(searchInputEl, "display", "none")
14311  	dom.SetStyle(searchInputEl, "flex", "")
14312  	dom.SetStyle(searchSubmitBtn, "display", "none")
14313  }
14314  
14315  // closeSearch is called when the user explicitly closes search (clicks 🔍 again).
14316  // Deactivates the bar and returns to the previous page.
14317  func closeSearch(btn dom.Element) {
14318  	deactivateSearchBar(btn)
14319  	ret := searchReturnPage
14320  	if ret == "" || ret == "search" {
14321  		ret = "feed"
14322  	}
14323  	searchReturnPage = ""
14324  	activePage = "" // force switchPage to run
14325  	switchPage(ret)
14326  }
14327  
14328  // executeSearch submits a search query: #tag searches by t-tag, other text
14329  // runs NIP-50 full-text search. Switches to the search page and subscribes.
14330  func executeSearch(q string) {
14331  	q = trimStr(q)
14332  	if q == "" {
14333  		return
14334  	}
14335  	searchCurrentQ = q
14336  	searchResults = []*nostr.Event{:0}
14337  	searchSeen = map[string]bool{}
14338  
14339  	switchPage("search")
14340  	// URL reflects the current query.
14341  	dom.ReplaceState("/search?q=" | dom.EncodeURIComponent(q))
14342  
14343  	// Build filter and choose target relays.
14344  	var filter string
14345  	var searchRelayList []string
14346  	if len(q) > 1 && q[0] == '#' {
14347  		// Tag search works on all relays; cap at 5 to limit event burst.
14348  		tag := q[1:]
14349  		filter = "{\"kinds\":[1,1111],\"#t\":[" | jstr(tag) | "],\"limit\":10}"
14350  		searchRelayList = relayURLs
14351  		if len(searchRelayList) > 5 {
14352  			searchRelayList = searchRelayList[:5]
14353  		}
14354  	} else {
14355  		// Full-text search: only NIP-50 relays, capped at 3 to bound
14356  		// the event burst through the relay-proxy WASM.
14357  		filter = "{\"kinds\":[1,1111],\"search\":" | jstr(q) | ",\"limit\":10}"
14358  		searchRelayList = nip50Relays()
14359  		if len(searchRelayList) > 3 {
14360  			searchRelayList = searchRelayList[:3]
14361  		}
14362  	}
14363  
14364  	// Build filter bar (sticky, above results) then show loading.
14365  	clearChildren(searchContainer)
14366  	buildSearchFilterBar()
14367  
14368  	loading := dom.CreateElement("div")
14369  	dom.SetStyle(loading, "color", "var(--muted)")
14370  	dom.SetStyle(loading, "fontSize", "14px")
14371  	dom.SetStyle(loading, "padding", "16px")
14372  	dom.SetStyle(loading, "fontFamily", "'Fira Code', monospace")
14373  	if len(searchRelayList) == 0 {
14374  		dom.SetTextContent(loading, "no full-text search relays available \xe2\x80\x94 try #hashtag")
14375  		dom.AppendChild(searchContainer, loading)
14376  		return
14377  	}
14378  	relayWord := "relays"
14379  	if len(searchRelayList) == 1 {
14380  		relayWord = "relay"
14381  	}
14382  	dom.SetTextContent(loading, "searching " | itoa(len(searchRelayList)) | " " | relayWord | "...")
14383  	dom.AppendChild(searchContainer, loading)
14384  
14385  	routeMsg("[\"CLOSE\",\"search\"]")
14386  	routeMsg(buildProxyMsg("search", filter, searchRelayList))
14387  }
14388  
14389  func buildSearchFilterBar() {
14390  	// Rebuild the filter bar (recreate buttons so state labels are fresh).
14391  	if searchFilterBarEl != 0 {
14392  		dom.RemoveChild(searchPage, searchFilterBarEl)
14393  	}
14394  	bar := dom.CreateElement("div")
14395  	searchFilterBarEl = bar
14396  	dom.SetAttribute(bar, "class", "search-filter-bar")
14397  	dom.SetStyle(bar, "display", "flex")
14398  	dom.SetStyle(bar, "gap", "8px")
14399  	dom.SetStyle(bar, "padding", "8px 12px")
14400  	dom.SetStyle(bar, "borderBottom", "1px solid var(--border)")
14401  	dom.SetStyle(bar, "flexWrap", "wrap")
14402  	dom.SetStyle(bar, "alignItems", "center")
14403  	dom.SetStyle(bar, "position", "sticky")
14404  	dom.SetStyle(bar, "top", "0")
14405  	dom.SetStyle(bar, "background", "var(--bg2)")
14406  	dom.SetStyle(bar, "zIndex", "10")
14407  
14408  	followsBtn := searchFilterBtn("follows only", searchFollowsOnly)
14409  	dom.AddEventListener(followsBtn, "click", dom.RegisterCallback(func() {
14410  		searchFollowsOnly = !searchFollowsOnly
14411  		applySearchFilterBtn(followsBtn, searchFollowsOnly)
14412  		renderSearchResults()
14413  	}))
14414  	dom.AppendChild(bar, followsBtn)
14415  
14416  	sortBtn := searchFilterBtn("newest first", searchSortDesc)
14417  	dom.SetAttribute(sortBtn, "data-sort", "1")
14418  	dom.AddEventListener(sortBtn, "click", dom.RegisterCallback(func() {
14419  		searchSortDesc = !searchSortDesc
14420  		if searchSortDesc {
14421  			dom.SetTextContent(sortBtn, "newest first")
14422  		} else {
14423  			dom.SetTextContent(sortBtn, "oldest first")
14424  		}
14425  		applySearchFilterBtn(sortBtn, true)
14426  		renderSearchResults()
14427  	}))
14428  	dom.AppendChild(bar, sortBtn)
14429  
14430  	nsfwBtn := searchFilterBtn("nsfw", searchNSFW)
14431  	dom.AddEventListener(nsfwBtn, "click", dom.RegisterCallback(func() {
14432  		searchNSFW = !searchNSFW
14433  		applySearchFilterBtn(nsfwBtn, searchNSFW)
14434  		renderSearchResults()
14435  	}))
14436  	dom.AppendChild(bar, nsfwBtn)
14437  
14438  	// Insert filter bar BEFORE searchContainer in searchPage.
14439  	dom.InsertBefore(searchPage, bar, searchContainer)
14440  }
14441  
14442  func searchFilterBtn(label string, active bool) (e dom.Element) {
14443  	btn := dom.CreateElement("button")
14444  	dom.SetTextContent(btn, label)
14445  	dom.SetStyle(btn, "padding", "4px 12px")
14446  	dom.SetStyle(btn, "borderRadius", "4px")
14447  	dom.SetStyle(btn, "fontSize", "12px")
14448  	dom.SetStyle(btn, "cursor", "pointer")
14449  	dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
14450  	applySearchFilterBtn(btn, active)
14451  	return btn
14452  }
14453  
14454  func applySearchFilterBtn(btn dom.Element, active bool) {
14455  	if active {
14456  		dom.SetStyle(btn, "background", "var(--accent)")
14457  		dom.SetStyle(btn, "color", "#fff")
14458  		dom.SetStyle(btn, "border", "none")
14459  	} else {
14460  		dom.SetStyle(btn, "background", "transparent")
14461  		dom.SetStyle(btn, "color", "var(--muted)")
14462  		dom.SetStyle(btn, "border", "1px solid var(--border)")
14463  	}
14464  }
14465  
14466  func handleSearchEvent(ev *nostr.Event) {
14467  	if ev.Kind != 1 && ev.Kind != 1111 {
14468  		return
14469  	}
14470  	if searchSeen[ev.ID] {
14471  		return
14472  	}
14473  	searchSeen[ev.ID] = true
14474  	if isMuted(ev.PubKey) {
14475  		return
14476  	}
14477  	if looksLikeJSONSpam(ev.Content) {
14478  		return
14479  	}
14480  	searchResults = append(searchResults, ev)
14481  	setEvent(ev.ID, ev)
14482  }
14483  
14484  const searchPageSize = 25 // results rendered per page / scroll load
14485  
14486  // filteredSearchResults returns searchResults with current filters applied and sorted.
14487  func filteredSearchResults() (ss []*nostr.Event) {
14488  	var filtered []*nostr.Event
14489  	for _, ev := range searchResults {
14490  		if searchFollowsOnly && !isFollowing(ev.PubKey) {
14491  			continue
14492  		}
14493  		if !searchNSFW {
14494  			if ev.Tags.GetFirst([]byte("content-warning")) != nil {
14495  				continue
14496  			}
14497  		}
14498  		filtered = append(filtered, ev)
14499  	}
14500  	for i := 0; i < len(filtered); i++ {
14501  		for j := i + 1; j < len(filtered); j++ {
14502  			var swap bool
14503  			if searchSortDesc {
14504  				swap = filtered[j].CreatedAt > filtered[i].CreatedAt
14505  			} else {
14506  				swap = filtered[j].CreatedAt < filtered[i].CreatedAt
14507  			}
14508  			if swap {
14509  				filtered[i], filtered[j] = filtered[j], filtered[i]
14510  			}
14511  		}
14512  	}
14513  	return filtered
14514  }
14515  
14516  // renderSearchResults rebuilds the result list from scratch, rendering only
14517  // the first searchPageSize results. Remaining results load on scroll.
14518  func renderSearchResults() {
14519  	searchRendered = 0
14520  	clearChildren(searchContainer)
14521  
14522  	filtered := filteredSearchResults()
14523  	if len(filtered) == 0 {
14524  		empty := dom.CreateElement("div")
14525  		dom.SetTextContent(empty, "no results")
14526  		dom.SetStyle(empty, "color", "var(--muted)")
14527  		dom.SetStyle(empty, "fontSize", "14px")
14528  		dom.SetStyle(empty, "padding", "16px")
14529  		dom.SetStyle(empty, "fontFamily", "'Fira Code', monospace")
14530  		dom.AppendChild(searchContainer, empty)
14531  		return
14532  	}
14533  
14534  	end := len(filtered)
14535  	if end > searchPageSize {
14536  		end = searchPageSize
14537  	}
14538  	for i := 0; i < end; i++ {
14539  		dom.AppendChild(searchContainer, renderSearchResult(filtered[i]))
14540  	}
14541  	searchRendered = end
14542  }
14543  
14544  // appendSearchResults renders the next batch of results (called on scroll).
14545  func appendSearchResults() {
14546  	filtered := filteredSearchResults()
14547  	if searchRendered >= len(filtered) {
14548  		return
14549  	}
14550  	end := searchRendered + searchPageSize
14551  	if end > len(filtered) {
14552  		end = len(filtered)
14553  	}
14554  	for i := searchRendered; i < end; i++ {
14555  		dom.AppendChild(searchContainer, renderSearchResult(filtered[i]))
14556  	}
14557  	searchRendered = end
14558  }
14559  
14560  func renderSearchResult(ev *nostr.Event) (e dom.Element) {
14561  	row := dom.CreateElement("div")
14562  	dom.SetStyle(row, "display", "flex")
14563  	dom.SetStyle(row, "flexDirection", "column")
14564  	dom.SetStyle(row, "padding", "10px 12px")
14565  	dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
14566  	dom.SetStyle(row, "cursor", "pointer")
14567  	dom.SetStyle(row, "gap", "4px")
14568  
14569  	// Header: avatar + name + timestamp.
14570  	header := dom.CreateElement("div")
14571  	dom.SetStyle(header, "display", "flex")
14572  	dom.SetStyle(header, "alignItems", "center")
14573  	dom.SetStyle(header, "gap", "8px")
14574  
14575  	avatar := dom.CreateElement("img")
14576  	dom.SetAttribute(avatar, "width", "20")
14577  	dom.SetAttribute(avatar, "height", "20")
14578  	dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
14579  	dom.SetStyle(avatar, "borderRadius", "50%")
14580  	dom.SetStyle(avatar, "objectFit", "cover")
14581  	dom.SetStyle(avatar, "flexShrink", "0")
14582  	pk := ev.PubKey
14583  	if pic, ok := authorPics[pk]; ok && pic != "" {
14584  		setMediaSrc(avatar, pic)
14585  		dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
14586  	} else {
14587  		dom.SetStyle(avatar, "display", "none")
14588  	}
14589  	dom.AppendChild(header, avatar)
14590  
14591  	name := dom.CreateElement("span")
14592  	dom.SetStyle(name, "fontSize", "14px")
14593  	dom.SetStyle(name, "fontWeight", "bold")
14594  	dom.SetStyle(name, "color", "var(--fg)")
14595  	dom.SetStyle(name, "flex", "1")
14596  	dom.SetStyle(name, "overflow", "hidden")
14597  	dom.SetStyle(name, "textOverflow", "ellipsis")
14598  	dom.SetStyle(name, "whiteSpace", "nowrap")
14599  	if n, ok := authorNames[pk]; ok && n != "" {
14600  		dom.SetTextContent(name, n)
14601  	} else {
14602  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
14603  		if len(npub) > 20 {
14604  			dom.SetTextContent(name, npub[:12] | "..." | npub[len(npub)-4:])
14605  		}
14606  	}
14607  	dom.AppendChild(header, name)
14608  
14609  	ts := dom.CreateElement("span")
14610  	dom.SetTextContent(ts, formatTime(ev.CreatedAt))
14611  	dom.SetStyle(ts, "fontSize", "11px")
14612  	dom.SetStyle(ts, "color", "var(--muted)")
14613  	dom.SetStyle(ts, "flexShrink", "0")
14614  	dom.AppendChild(header, ts)
14615  	dom.AppendChild(row, header)
14616  
14617  	// Two-line content preview (plain text, no markdown rendering).
14618  	preview := dom.CreateElement("div")
14619  	dom.SetStyle(preview, "fontSize", "13px")
14620  	dom.SetStyle(preview, "color", "var(--fg)")
14621  	dom.SetStyle(preview, "lineHeight", "1.4")
14622  	dom.SetStyle(preview, "overflow", "hidden")
14623  	dom.SetStyle(preview, "display", "-webkit-box")
14624  	dom.SetStyle(preview, "-webkit-line-clamp", "2")
14625  	dom.SetStyle(preview, "-webkit-box-orient", "vertical")
14626  	dom.SetStyle(preview, "wordBreak", "break-word")
14627  	content := ev.Content
14628  	if len(content) > 300 {
14629  		content = content[:300] | "..."
14630  	}
14631  	dom.SetTextContent(preview, content)
14632  	dom.AppendChild(row, preview)
14633  
14634  	// Click opens thread.
14635  	evID := ev.ID
14636  	evRootID := getRootID(ev)
14637  	if evRootID == "" {
14638  		evRootID = evID
14639  	}
14640  	dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
14641  		showNoteThread(evRootID, evID)
14642  	}))
14643  
14644  	if _, cached := authorNames[pk]; !cached {
14645  		pendingNotes[pk] = append(pendingNotes[pk], header)
14646  		if !lookupFetchedK0(pk) {
14647  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
14648  		}
14649  	}
14650  
14651  	return row
14652  }
14653  
14654  // extractHashtags replaces #tag tokens with placeholders so markdown-it
14655  // preserves them. Restored after rendering as clickable search spans.
14656  func extractHashtags(s string, slots *[]string) (sv string) {
14657  	out := ""
14658  	i := 0
14659  	for i < len(s) {
14660  		if s[i] != '#' {
14661  			out = out | string([]byte{s[i]})
14662  			i++
14663  			continue
14664  		}
14665  		// Must be preceded by whitespace/start/newline to be a hashtag.
14666  		if i > 0 {
14667  			prev := s[i-1]
14668  			if prev != ' ' && prev != '\n' && prev != '\t' && prev != '\r' {
14669  				out = out | "#"
14670  				i++
14671  				continue
14672  			}
14673  		}
14674  		// Collect tag characters: letters, digits, underscores.
14675  		j := i + 1
14676  		for j < len(s) {
14677  			c := s[j]
14678  			if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' {
14679  				j++
14680  			} else {
14681  				break
14682  			}
14683  		}
14684  		if j == i+1 {
14685  			// No tag characters after #.
14686  			out = out | "#"
14687  			i++
14688  			continue
14689  		}
14690  		tag := s[i+1 : j]
14691  		idx := len(*slots)
14692  		span := "<span data-search-tag=\"" | escapeHTMLAttr(tag) | "\" style=\"color:var(--accent);cursor:pointer\">#" | tag | "</span>"
14693  		*slots = append(*slots, span)
14694  		out = out | "\x02HASH" | itoa(idx) | "\x02"
14695  		i = j
14696  	}
14697  	return out
14698  }
14699  
14700  // attachHashtagLinks wires click handlers on [data-search-tag] spans.
14701  func attachHashtagLinks(root dom.Element) {
14702  	el := dom.QuerySelectorFrom(root, "[data-search-tag]")
14703  	for el != 0 {
14704  		tag := dom.GetAttribute(el, "data-search-tag")
14705  		dom.RemoveAttribute(el, "data-search-tag")
14706  		if tag != "" {
14707  			capturedTag := tag
14708  			dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
14709  				dom.SetProperty(searchInputEl, "value", "#" | capturedTag)
14710  				executeSearch("#" | capturedTag)
14711  			}))
14712  		}
14713  		el = dom.QuerySelectorFrom(root, "[data-search-tag]")
14714  	}
14715  }
14716  
14717  // trimStr trims leading and trailing whitespace from s.
14718  func trimStr(s string) (sv string) {
14719  	start := 0
14720  	for start < len(s) && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') {
14721  		start++
14722  	}
14723  	end := len(s)
14724  	for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') {
14725  		end--
14726  	}
14727  	return s[start:end]
14728  }
14729  
14730  // nip50Relays returns the subset of configured relay URLs that advertise NIP-50
14731  // (full-text search) in their NIP-11 document.
14732  func nip50Relays() (ss []string) {
14733  	var out []string
14734  	for i, url := range relayURLs {
14735  		if i < len(relayCaps) && hasNIP(relayCaps[i].supportedNIPs, 50) {
14736  			out = append(out, url)
14737  		}
14738  	}
14739  	return out
14740  }
14741  
14742  // looksLikeJSONSpam returns true if the content is a raw JSON object or array
14743  // larger than 200 bytes. Catches protocol spam posted as kind 1 notes.
14744  func looksLikeJSONSpam(content string) (ok bool) {
14745  	i := 0
14746  	for i < len(content) {
14747  		c := content[i]
14748  		if c == ' ' || c == '\n' || c == '\r' || c == '\t' {
14749  			i++
14750  		} else {
14751  			break
14752  		}
14753  	}
14754  	if i >= len(content) {
14755  		return false
14756  	}
14757  	open := content[i]
14758  	if open != '{' && open != '[' {
14759  		return false
14760  	}
14761  	// Scan for matching close bracket (up to 2000 chars).
14762  	limit := len(content)
14763  	if limit > 2000 {
14764  		limit = 2000
14765  	}
14766  	depth := 0
14767  	inStr := false
14768  	j := i
14769  	for j < limit {
14770  		c := content[j]
14771  		if inStr {
14772  			if c == '\\' && j+1 < limit {
14773  				j += 2
14774  				continue
14775  			}
14776  			if c == '"' {
14777  				inStr = false
14778  			}
14779  		} else {
14780  			if c == '"' {
14781  				inStr = true
14782  			} else if c == '{' || c == '[' {
14783  				depth++
14784  			} else if c == '}' || c == ']' {
14785  				depth--
14786  				if depth == 0 {
14787  					return j-i+1 > 200
14788  				}
14789  			}
14790  		}
14791  		j++
14792  	}
14793  	return false
14794  }
14795  
14796  func defaultBlossomURL() (s string) {
14797  	local := localRelayURL()
14798  	var base string
14799  	if len(local) >= 6 && local[:6] == "wss://" {
14800  		base = "https://" | local[6:]
14801  	} else if len(local) >= 5 && local[:5] == "ws://" {
14802  		base = "http://" | local[5:]
14803  	} else {
14804  		return ""
14805  	}
14806  	return base | "/blossom"
14807  }
14808  
14809  // parseJSONStringArray parses a JSON array of strings like ["a","b","c"].
14810  func parseJSONStringArray(s string) (ss []string) {
14811  	var out []string
14812  	i := 0
14813  	for i < len(s) && s[i] != '[' {
14814  		i++
14815  	}
14816  	if i >= len(s) {
14817  		return nil
14818  	}
14819  	i++ // skip '['
14820  	for i < len(s) {
14821  		for i < len(s) && (s[i] == ' ' || s[i] == ',' || s[i] == '\n') {
14822  			i++
14823  		}
14824  		if i >= len(s) || s[i] == ']' {
14825  			break
14826  		}
14827  		if s[i] != '"' {
14828  			break
14829  		}
14830  		i++
14831  		start := i
14832  		for i < len(s) && s[i] != '"' {
14833  			if s[i] == '\\' {
14834  				i++
14835  			}
14836  			i++
14837  		}
14838  		if i <= len(s) {
14839  			out = append(out, s[start:i])
14840  		}
14841  		if i < len(s) {
14842  			i++ // skip closing '"'
14843  		}
14844  	}
14845  	return out
14846  }
14847  
14848  func jsonEsc(s string) (sv string) {
14849  	s = strReplace(s, "\\", "\\\\")
14850  	s = strReplace(s, "\"", "\\\"")
14851  	s = strReplace(s, "\n", "\\n")
14852  	s = strReplace(s, "\r", "\\r")
14853  	s = strReplace(s, "\t", "\\t")
14854  	return s
14855  }
14856  
14857  // strIndex finds substring in string. Returns -1 if not found.
14858  func strIndex(s, sub string) (n int32) {
14859  	sl := len(sub)
14860  	for i := 0; i <= len(s)-sl; i++ {
14861  		if s[i:i+sl] == sub {
14862  			return i
14863  		}
14864  	}
14865  	return -1
14866  }
14867  
14868  // --- Helpers ---
14869  
14870  // normalizeURL strips trailing slashes and lowercases the scheme+host.
14871  func normalizeURL(u string) (s string) {
14872  	for len(u) > 0 && u[len(u)-1] == '/' {
14873  		u = u[:len(u)-1]
14874  	}
14875  	// Lowercase scheme and host (before first / after ://).
14876  	if len(u) > 6 && u[:6] == "wss://" {
14877  		rest := u[6:]
14878  		slash := strIndex(rest, "/")
14879  		if slash < 0 {
14880  			return u[:6] | toLower(rest)
14881  		}
14882  		return u[:6] | toLower(rest[:slash]) | rest[slash:]
14883  	}
14884  	if len(u) > 5 && u[:5] == "ws://" {
14885  		rest := u[5:]
14886  		slash := strIndex(rest, "/")
14887  		if slash < 0 {
14888  			return u[:5] | toLower(rest)
14889  		}
14890  		return u[:5] | toLower(rest[:slash]) | rest[slash:]
14891  	}
14892  	return u
14893  }
14894  
14895  func toLower(s string) (sv string) {
14896  	b := []byte{:len(s)}
14897  	for i := 0; i < len(s); i++ {
14898  		c := s[i]
14899  		if c >= 'A' && c <= 'Z' {
14900  			c += 32
14901  		}
14902  		b[i] = c
14903  	}
14904  	return string(b)
14905  }
14906  
14907  func showQRModal(npubStr string) {
14908  	svg := qrSVG(npubStr, 5)
14909  	if svg == "" {
14910  		return
14911  	}
14912  	scrim := dom.CreateElement("div")
14913  	dom.SetStyle(scrim, "position", "fixed")
14914  	dom.SetStyle(scrim, "inset", "0")
14915  	dom.SetStyle(scrim, "background", "rgba(0,0,0,0.6)")
14916  	dom.SetStyle(scrim, "display", "flex")
14917  	dom.SetStyle(scrim, "alignItems", "center")
14918  	dom.SetStyle(scrim, "justifyContent", "center")
14919  	dom.SetStyle(scrim, "zIndex", "9999")
14920  	dom.SetStyle(scrim, "cursor", "pointer")
14921  	dom.AddEventListener(scrim, "click", dom.RegisterCallback(func() {
14922  		dom.RemoveChild(dom.Body(), scrim)
14923  	}))
14924  
14925  	card := dom.CreateElement("div")
14926  	dom.SetStyle(card, "background", "white")
14927  	dom.SetStyle(card, "borderRadius", "16px")
14928  	dom.SetStyle(card, "padding", "24px")
14929  	dom.SetStyle(card, "display", "flex")
14930  	dom.SetStyle(card, "flexDirection", "column")
14931  	dom.SetStyle(card, "alignItems", "center")
14932  	dom.SetStyle(card, "cursor", "default")
14933  	dom.SetAttribute(card, "onclick", "event.stopPropagation()")
14934  	dom.SetInnerHTML(card, svg)
14935  
14936  	dom.AppendChild(scrim, card)
14937  	dom.AppendChild(dom.Body(), scrim)
14938  }
14939  
14940  func clearChildren(el dom.Element) {
14941  	dom.ReleaseChildren(el)
14942  	dom.SetInnerHTML(el, "")
14943  }
14944  
14945  func removeChildrenByPK(parent dom.Element, pk string) {
14946  	if parent == 0 {
14947  		return
14948  	}
14949  	var toRemove []dom.Element
14950  	child := dom.FirstElementChild(parent)
14951  	for child != 0 {
14952  		if dom.GetProperty(child, "smeshPK") == pk {
14953  			toRemove = append(toRemove, child)
14954  		}
14955  		child = dom.NextSibling(child)
14956  	}
14957  	for _, el := range toRemove {
14958  		dom.RemoveChild(parent, el)
14959  	}
14960  }
14961  
14962  func itoa(n int32) (s string) {
14963  	if n == 0 {
14964  		return "0"
14965  	}
14966  	neg := false
14967  	if n < 0 {
14968  		neg = true
14969  		n = -n
14970  	}
14971  	var b [20]byte
14972  	i := len(b)
14973  	for n > 0 {
14974  		i--
14975  		b[i] = byte('0' + n%10)
14976  		n /= 10
14977  	}
14978  	if neg {
14979  		i--
14980  		b[i] = '-'
14981  	}
14982  	return string(b[i:])
14983  }
14984