main.mx raw

   1  package main
   2  
   3  import (
   4  	"runtime"
   5  	"crypto/sha256"
   6  	"git.smesh.lol/moxie/pkg/mxutil"
   7  	"git.smesh.lol/musiquay/pkg/protocol"
   8  	"git.smesh.lol/musiquay/web/common/accum"
   9  	"git.smesh.lol/musiquay/web/common/helpers"
  10  	"git.smesh.lol/musiquay/web/common/jsbridge/dom"
  11  	"git.smesh.lol/musiquay/web/common/jsbridge/feed"
  12  	"git.smesh.lol/musiquay/web/common/jsbridge/localstorage"
  13  	"git.smesh.lol/musiquay/web/common/jsbridge/markdown"
  14  	"git.smesh.lol/musiquay/web/common/jsbridge/notif"
  15  	"git.smesh.lol/musiquay/web/common/jsbridge/profile"
  16  	"git.smesh.lol/musiquay/web/common/jsbridge/relayproxy"
  17  	"git.smesh.lol/musiquay/web/common/jsbridge/signer"
  18  	"git.smesh.lol/musiquay/web/common/protocolwire"
  19  	"git.smesh.lol/nostr/pkg/core"
  20  )
  21  
  22  const (
  23  	lsKeyPubkey         = "musiquay-pubkey"
  24  	lsKeyTheme          = "musiquay-theme"
  25  	lsKeyRelayPolicy    = "musiquay-relay-policy"
  26  	lsKeyRelayBlocklist = "musiquay-relay-blocklist"
  27  	lsKeyRelayList      = "musiquay-relays"
  28  	lsKeyBlossomServer  = "musiquay-blossom-server"
  29  	lsKeyInvidiousURL   = "musiquay-invidious-url"
  30  	lsKeyCampionURL     = "musiquay-campion-url"
  31  	lsKeyDevMode        = "musiquay-dev-mode"
  32  	lsKeyRelayRoles     = "musiquay-relay-roles"
  33  )
  34  
  35  // Relay policy bitfield - persisted as decimal int in lsKeyRelayPolicy.
  36  // All flags default to 0 (no policy enforced). Enforcement happens in later
  37  // steps; step 3 only reads and writes the state.
  38  const (
  39  	policyRequireAuth     = 1 << 0 // require NIP-42 support
  40  	policyRequireProtect  = 1 << 1 // require NIP-70 support
  41  	policyBlockPayment    = 1 << 2 // block relays with limitation.payment_required
  42  	policyBlockRestricted = 1 << 3 // block relays with limitation.restricted_writes
  43  )
  44  
  45  const activityLogMax = 500
  46  
  47  type logEntry struct {
  48  	ts      int64
  49  	action  string // "boot", "publish", "reply", "react", "rebroadcast", "subscribe", "relay-ok", "relay-err"
  50  	summary string // one-line title
  51  	detail  string // multi-line detail, can be empty
  52  }
  53  
  54  var (
  55  
  56  	// Profile data from kind 0.
  57  
  58  	// DOM refs that need updating after creation.
  59  	signerBtnEl   dom.Element
  60  
  61  	// Search page state.
  62  	notifRowCBs     accum.Ints // callback IDs registered by current notif rows; released on rebuild
  63  
  64  	// App root - content goes here, not body (snackbar stays outside).
  65  
  66  	// Messaging UI state.
  67  
  68  	// Relay tracking - parallel slices, grown dynamically.
  69  
  70  	// Relay policy (loaded once at startup from localStorage).
  71  
  72  
  73  	// Author name/pic view cache - small, populated from P_RESOLVED and IDB bootstrap.
  74  	// Profile Worker owns canonical data; Shell keeps this for sync DOM reads.
  75  	// Shell-local fetch dedup hints - not authoritative (Profile Worker owns dedup).
  76  	// Empty map reads return false so all guards fire, Profile Worker handles actual dedup.
  77  	// Two-gen rotation: when current exceeds 10000, old is dropped and current becomes old.
  78  	// retryRound names the retry-batch subscriptions; authorSubPK maps an
  79  	// outstanding profile subscription id to its pubkey. Both are used by
  80  	// retryMissingProfiles, reached from the EOSE handler.
  81  
  82  	// User card hover popover.
  83  
  84  
  85  	// QR modal.
  86  
  87  	// Profile page tab state.
  88  	// Own follow/mute lists - Shell's working copy for sync filter and action ops.
  89  	// Profile Worker is authoritative; Shell is updated via P_FOLLOWS/P_MUTES pushes.
  90  	// Profile content/follows/mutes/relays for viewed profiles (small view caches).
  91  
  92  	// Profile page cache - keeps rendered profile DOMs alive.
  93  
  94  	// History/routing.
  95  
  96  	// Embedded nostr: entity resolution.
  97  
  98  	// @-mention autocomplete.
  99  
 100  	// Thread view state.
 101  
 102  	// Reply preview state.
 103  
 104  	// Action button state (reactions/reposts).
 105  
 106  	// Compose editor state.
 107  
 108  	// Emoji picker state.
 109  	emojiBackdrop dom.Element
 110  	emojiAnchor   dom.Element
 111  
 112  	// Cached events for repost/quote content.
 113  
 114  	// Global ready flag - set after showApp finishes all map init + DOM setup.
 115  
 116  	// Activity log state. activityLog is a fixed ring of activityLogMax entries
 117  	// presized on first use, newest at slot (activityLogNext-1) mod activityLogMax.
 118  	// Each entry is a single action with a short summary and optional multi-line
 119  	// detail.
 120  
 121  	// Live-update subscriber callbacks keyed by event ID. The relay info modal
 122  	// registers a callback for its evID on open; the SEEN_ON handler fires the
 123  	// registered callback (if any) after updating eventRelays. Modal must
 124  	// unregister on close. Single registration per evID - second registration
 125  	// replaces the first.
 126  
 127  	// Feed controls.
 128  	refreshBtn      dom.Element
 129  
 130  )
 131  
 132  // ── Rotate-map cache bounds ───────────────────────────────────────────────────
 133  // Each cache group has two generations: current and old. When current exceeds
 134  // its threshold, old is dropped and current becomes old. Lookups check both.
 135  
 136  const (
 137  	rotateEventMax   = 2000
 138  	rotateAuthorMax  = 500
 139  	rotateEmbedMax   = 200
 140  	rotateReplyMax   = 500
 141  	rotateProfileMax = 10
 142  	notifEventsMax   = 500
 143  	rotateFetchedMax = 10000
 144  )
 145  
 146  var (
 147  	// Event data "old" generation.
 148  
 149  	// Author name/pic "old" generation - two-gen rotation so GC can collect stale entries.
 150  
 151  	// Embed "old" generation.
 152  
 153  	// Reply "old" generation.
 154  )
 155  
 156  // rotateEventCaches swaps the event data caches and releases dropped DOM handles.
 157  func rotateEventCaches() {
 158  	var toRelease []int32
 159  	for id, el := range appSt.noteElementsOld {
 160  		if _, ok := appSt.noteElements[id]; !ok {
 161  			toRelease = mxutil.Ensure(toRelease, 1)
 162  			toRelease = push(toRelease, int32(el))
 163  		}
 164  	}
 165  	if len(toRelease) > 0 {
 166  		dom.ReleaseAll(toRelease)
 167  	}
 168  	appSt.eventCacheOld = appSt.eventCache
 169  	appSt.eventCache = map[string]*nostr.Event{}
 170  	appSt.seenEventsOld = appSt.seenEvents
 171  	appSt.seenEvents = map[string]bool{}
 172  	appSt.noteElementsOld = appSt.noteElements
 173  	appSt.noteElements = map[string]dom.Element{}
 174  	appSt.noteReactionMapOld = appSt.noteReactionMap
 175  	appSt.noteReactionMap = map[string]map[string]map[string]bool{}
 176  	appSt.noteRepostCountsOld = appSt.noteRepostCounts
 177  	appSt.noteRepostCounts = map[string]map[string]bool{}
 178  	appSt.noteZapSeenOld = appSt.noteZapSeen
 179  	appSt.noteZapSeen = map[string]map[string]bool{}
 180  	appSt.actionFetchedIDsOld = appSt.actionFetchedIDs
 181  	appSt.actionFetchedIDs = map[string]bool{}
 182  	appSt.eventRelaysOld = appSt.eventRelays
 183  	appSt.eventRelays = map[string][]string{}
 184  	appSt.noteReactionEls = map[string]dom.Element{}
 185  	appSt.noteRepostEls = map[string]dom.Element{}
 186  	appSt.noteZapEls = map[string]dom.Element{}
 187  	appSt.eventCacheCount = 0
 188  }
 189  
 190  // rotateAuthorCaches rotates the Shell name/pic view cache (two-gen for GC).
 191  // Profile Worker owns the canonical data rotation; this only covers Shell's small cache.
 192  func rotateAuthorCaches() {
 193  	appSt.authorNamesOld = appSt.authorNames
 194  	appSt.authorNames = map[string]string{}
 195  	appSt.authorPicsOld = appSt.authorPics
 196  	appSt.authorPics = map[string]string{}
 197  	appSt.authorCacheCount = 0
 198  }
 199  
 200  // rotateEmbedCaches swaps the embed caches.
 201  func rotateEmbedCaches() {
 202  	appSt.embedCallbacksOld = appSt.embedCallbacks
 203  	appSt.embedCallbacks = map[string][]string{}
 204  	appSt.embedRequestedOld = appSt.embedRequested
 205  	appSt.embedRequested = map[string]bool{}
 206  	appSt.embedSubIDsOld = appSt.embedSubIDs
 207  	appSt.embedSubIDs = map[string][]string{}
 208  	appSt.embedCoordCBsOld = appSt.embedCoordCBs
 209  	appSt.embedCoordCBs = map[string][]string{}
 210  	appSt.embedRelayHintsOld = appSt.embedRelayHints
 211  	appSt.embedRelayHints = map[string][]string{}
 212  	appSt.embedCacheCount = 0
 213  }
 214  
 215  // lookupFetchedK0 checks current and old generation.
 216  func lookupFetchedK0(pk string) (ok bool) {
 217  	if appSt.fetchedK0[pk] {
 218  		return true
 219  	}
 220  	return appSt.fetchedK0Old[pk]
 221  }
 222  
 223  // setFetchedK0 sets the entry and rotates if threshold exceeded.
 224  func setFetchedK0(pk string, v bool) {
 225  	appSt.fetchedK0[pk] = v
 226  	if v && len(appSt.fetchedK0) > rotateFetchedMax {
 227  		appSt.fetchedK0Old = appSt.fetchedK0
 228  		appSt.fetchedK0 = map[string]bool{}
 229  	}
 230  }
 231  
 232  // lookupFetchedK10k checks current and old generation.
 233  func lookupFetchedK10k(pk string) (ok bool) {
 234  	if appSt.fetchedK10k[pk] {
 235  		return true
 236  	}
 237  	return appSt.fetchedK10kOld[pk]
 238  }
 239  
 240  // setFetchedK10k sets the entry and rotates if threshold exceeded.
 241  func setFetchedK10k(pk string, v bool) {
 242  	appSt.fetchedK10k[pk] = v
 243  	if v && len(appSt.fetchedK10k) > rotateFetchedMax {
 244  		appSt.fetchedK10kOld = appSt.fetchedK10k
 245  		appSt.fetchedK10k = map[string]bool{}
 246  	}
 247  }
 248  
 249  // rotateReplyCaches swaps the reply caches.
 250  func rotateReplyCaches() {
 251  	appSt.replyCacheOld = appSt.replyCache
 252  	appSt.replyCache = map[string]string{}
 253  	appSt.replyAvatarCacheOld = appSt.replyAvatarCache
 254  	appSt.replyAvatarCache = map[string]string{}
 255  	appSt.replyLineCacheOld = appSt.replyLineCache
 256  	appSt.replyLineCache = map[string]string{}
 257  	appSt.replyAuthorMapOld = appSt.replyAuthorMap
 258  	appSt.replyAuthorMap = map[string]string{}
 259  	appSt.replyNeedNameOld = appSt.replyNeedName
 260  	appSt.replyNeedName = map[string][]dom.Element{}
 261  	appSt.replyHintsOld = appSt.replyHints
 262  	appSt.replyHints = map[string]string{}
 263  	appSt.replyCount = 0
 264  }
 265  
 266  // lookupAuthorName returns the cached display name, checking both generations.
 267  func lookupAuthorName(pk string) (s string, ok3 bool) {
 268  	if namew2, ok2 := appSt.authorNames[pk]; ok2 {
 269  		return namew2, true
 270  	}
 271  	namew1, ok := appSt.authorNamesOld[pk]
 272  	return namew1, ok
 273  }
 274  
 275  // lookupAuthorPic returns the cached avatar URL, checking both generations.
 276  func lookupAuthorPic(pk string) (s string, ok3 bool) {
 277  	if picw2, ok2 := appSt.authorPics[pk]; ok2 {
 278  		return picw2, true
 279  	}
 280  	picw1, ok := appSt.authorPicsOld[pk]
 281  	return picw1, ok
 282  }
 283  
 284  // lookupEvent returns the cached event, checking both generations.
 285  func lookupEvent(id string) (p *nostr.Event, ok3 bool) {
 286  	if evw2, ok2 := appSt.eventCache[id]; ok2 {
 287  		return evw2, true
 288  	}
 289  	evw1, ok := appSt.eventCacheOld[id]
 290  	return evw1, ok
 291  }
 292  
 293  // setEvent stores an event in the cache and rotates if the threshold is reached.
 294  func setEvent(id string, ev *nostr.Event) {
 295  	if _, already := appSt.eventCache[id]; !already {
 296  		appSt.eventCacheCount++
 297  		if appSt.eventCacheCount > rotateEventMax {
 298  			rotateEventCaches()
 299  		}
 300  	}
 301  	appSt.eventCache[id] = ev
 302  }
 303  
 304  // setAuthorName caches a display name, rotating if the view cache exceeds the threshold.
 305  func setAuthorName(pk, name string) {
 306  	if _, already := appSt.authorNames[pk]; !already {
 307  		appSt.authorCacheCount++
 308  		if appSt.authorCacheCount > rotateAuthorMax {
 309  			rotateAuthorCaches()
 310  		}
 311  	}
 312  	appSt.authorNames[pk] = name
 313  }
 314  
 315  // setAuthorPic caches an avatar URL.
 316  func setAuthorPic(pk, pic string) {
 317  	appSt.authorPics[pk] = pic
 318  }
 319  
 320  const orlyRelay = "wss://relay.orly.dev"
 321  
 322  
 323  func isLocalDev() (ok bool) {
 324  	h := dom.Hostname()
 325  	return h == "localhost" || (len(h) > 4 && h[:4] == "127.")
 326  }
 327  
 328  func localRelayURL() (s string) {
 329  	h := dom.Hostname()
 330  	p := dom.Port()
 331  	if p == "" || p == "443" || p == "80" {
 332  		return "wss://" | h
 333  	}
 334  	return "ws://" | h | ":" | p
 335  }
 336  
 337  // appState is the page's mutable state. Package globals are immutable
 338  // outside initialization, so the DOM handles, caches, queues and flags the
 339  // app mutates live in one self-mutating type reached through appSt.
 340  type appState struct {
 341  	aboutLogoEl dom.Element
 342  	aboutPage dom.Element
 343  	actionFetchQueue []string                      // IDs awaiting batch fetch
 344  	actionFetchTimer int32
 345  	actionFetchedIDs map[string]bool               // event IDs already queued for kind 6/7
 346  	actionFetchedIDsOld map[string]bool
 347  	activePage string
 348  	activeProfileNoteSub string
 349  	activityLog []*logEntry
 350  	activityLogNext int32  // total entries ever logged; slot = next mod activityLogMax
 351  	appReady bool
 352  	authorCacheCount int32
 353  	authorNames map[string]string        // pubkey hex -> display name
 354  	authorNamesOld map[string]string
 355  	authorPics map[string]string        // pubkey hex -> avatar URL
 356  	authorPicsOld map[string]string
 357  	authorSubPK map[string]string
 358  	avatarEl dom.Element
 359  	blossomServerURL string
 360  	bootInProgress bool  // true while boot is running; controls logBtn highlight
 361  	bottomBar dom.Element
 362  	broadcastBtnEl dom.Element
 363  	broadcastRow dom.Element
 364  	campionURL string
 365  	composePage dom.Element
 366  	composePreview dom.Element
 367  	composeRelayChecks map[string]bool  // relay URL -> selected for publish
 368  	composeRelayPopEl dom.Element
 369  	composeRelayPopOpen bool
 370  	composeTextarea dom.Element
 371  	contactTs int64  // kind 3 latest timestamp
 372  	contentArea dom.Element  // scroll container
 373  	currentLang string
 374  	defaultRelays []string
 375  	devMode bool
 376  	donSubs map[string]func(string)  // subID -> callback(plain JSON or "")
 377  	editorComposer dom.Element   // inline composer div appended to note
 378  	editorDrafts map[string]string  // "reply:evID" or "quote:evID" -> draft text
 379  	editorMode string        // "reply" or "quote"
 380  	editorNoteEl dom.Element   // the note element containing the composer
 381  	editorOpen bool
 382  	editorPreviewEl dom.Element
 383  	editorPreviewing bool
 384  	editorTargetEv *nostr.Event
 385  	editorTextarea dom.Element
 386  	embedCacheCount int32
 387  	embedCallbacks map[string][]string  // hex event ID -> DOM element IDs awaiting fill
 388  	embedCallbacksOld map[string][]string
 389  	embedCoordCBs map[string][]string  // "kind:pubkey:d" -> DOM element IDs awaiting fill
 390  	embedCoordCBsOld map[string][]string
 391  	embedCoordSubIDs map[string]string    // sub ID -> coord key
 392  	embedRelayHints map[string][]string  // hex event ID -> relay hints from nevent TLV
 393  	embedRelayHintsOld map[string][]string
 394  	embedRequested map[string]bool      // hex event IDs already sent to PROXY
 395  	embedRequestedOld map[string]bool
 396  	embedSubIDs map[string][]string  // sub ID -> list of hex event IDs requested in that sub
 397  	embedSubIDsOld map[string][]string
 398  	emojiAutoEl dom.Element      // emoji autocomplete popup
 399  	emojiAutoOpen bool
 400  	emojiAutoStart int32              // position of the opening ':'
 401  	emojiAutoTA dom.Element      // textarea the emoji popup is attached to
 402  	emojiByName map[string]string
 403  	emojiOpen bool
 404  	emojiOverlay dom.Element
 405  	emojiTargetEv *nostr.Event
 406  	encKeyHex string  // hex symmetric key, sent to SW
 407  	eventCache map[string]*nostr.Event
 408  	eventCacheCount int32
 409  	eventCacheOld map[string]*nostr.Event
 410  	eventCount int32
 411  	eventRelays map[string][]string  // event ID -> relay URLs that delivered it
 412  	eventRelaysOld map[string][]string
 413  	feedBuffer []*nostr.Event  // new events buffered after initial load
 414  	feedContainer dom.Element
 415  	feedDeferTimer int32   // timer for deferred feed subscription
 416  	feedEmptyStreak int32    // consecutive empty EOSE responses
 417  	feedExhausted bool   // true when EOSE returns no new events
 418  	feedHasNew bool            // true when feedBuffer is non-empty (pulse indicator)
 419  	feedInitialLoad bool  // true until ~3s after first EOSE on "feed"
 420  	feedInitialLoadTimer int32   // delays flipping feedInitialLoad=false so late events from slower relays still render directly
 421  	feedLastSubTs int64  // timestamp of last actual subscription
 422  	feedLoader dom.Element
 423  	feedLoading bool   // true while fetching older events
 424  	feedMode string          // "follows", "relays", or relay URL
 425  	feedMoreGot int32
 426  	feedMoreTimer int32
 427  	feedPage dom.Element
 428  	feedPopoverEl dom.Element
 429  	feedPopoverOpen bool
 430  	feedSelectEl dom.Element
 431  	feedSubTimer int32   // throttle timer for subscribeFeed
 432  	feedSubscribed bool  // true once subscribeFeed has been called
 433  	fetchedK0 map[string]bool
 434  	fetchedK0Old map[string]bool
 435  	fetchedK10k map[string]bool
 436  	fetchedK10kOld map[string]bool
 437  	followSet map[string]bool  // O(1) lookup derived from myFollows
 438  	gfExp [512]byte
 439  	gfLog [256]byte
 440  	gfTables bool
 441  	homePage dom.Element  // Musiquay base: empty landing page; the kind-1 forum is parked
 442  	hoverCardDismiss int32
 443  	hoverCardEl dom.Element
 444  	hoverCardHover bool
 445  	hoverCardPK string
 446  	hoverCardParent dom.Element
 447  	hoverCardTimer int32
 448  	idbLoaded bool
 449  	inboxTs int64  // kind 10050 latest timestamp
 450  	invidiousURL string
 451  	isDark bool
 452  	loadMoreBtn dom.Element
 453  	logBtn dom.Element
 454  	logListEl dom.Element
 455  	logPage dom.Element
 456  	logoSVGCache string
 457  	marmotInited bool
 458  	mentionAtPos int32     // character position of the @ in the textarea
 459  	mentionItems []mentionMatch
 460  	mentionPopup dom.Element  // the popup container
 461  	mentionQuery string  // current query text (after @)
 462  	mentionSelIdx int32     // highlighted index in popup
 463  	mentionTA dom.Element  // textarea the popup is attached to
 464  	msgComposeInput dom.Element  // textarea
 465  	msgCurrentConvoID string  // peer pubkey (DM) or groupIDHex (group) of open thread, "" when on list
 466  	msgCurrentKind string  // "dm" or "group"
 467  	msgListContainer dom.Element  // conversation list view
 468  	msgPage dom.Element
 469  	msgThreadContainer dom.Element  // thread view (header + messages + compose)
 470  	msgThreadMessages dom.Element  // scrollable message area
 471  	msgView string       // "list" or "thread"
 472  	muteSet map[string]bool  // O(1) lookup derived from myMutes
 473  	muteTs int64  // kind 10000 latest timestamp
 474  	myFollows []string
 475  	myMutes []string
 476  	nameEl dom.Element
 477  	navPop bool  // true during popstate - suppresses pushState
 478  	noteElements map[string]dom.Element  // event ID -> note DOM element
 479  	noteElementsOld map[string]dom.Element
 480  	noteReactionEls map[string]dom.Element        // event ID -> reaction display row
 481  	noteReactionMap map[string]map[string]map[string]bool  // event ID -> emoji -> pubkey set
 482  	noteReactionMapOld map[string]map[string]map[string]bool
 483  	noteRepostCounts map[string]map[string]bool    // event ID -> pubkey set
 484  	noteRepostCountsOld map[string]map[string]bool
 485  	noteRepostEls map[string]dom.Element        // event ID -> repost counter span
 486  	noteZapEls map[string]dom.Element        // event ID -> zap total chip
 487  	noteZapSeen map[string]map[string]bool    // event ID -> set of 9735 receipt IDs counted
 488  	noteZapSeenOld map[string]map[string]bool
 489  	noteZapTotals map[string]int64              // event ID -> total msats received
 490  	notifBarGroup dom.Element
 491  	notifBuilt bool    // true after first render
 492  	notifContainer dom.Element
 493  	notifDot dom.Element
 494  	notifEmptyEl dom.Element
 495  	notifEmptyStrk int32
 496  	notifEvents []*nostr.Event
 497  	notifExhausted bool
 498  	notifFilter string  // "all", "mentions", "reactions", "zaps"
 499  	notifInitLoad bool
 500  	notifLoadMore dom.Element
 501  	notifLoading bool
 502  	notifMarkBtn dom.Element
 503  	notifMissing []string  // refIDs not in eventCache, fetched after EOSE
 504  	notifMoreGot int32
 505  	notifMoreTimer int32
 506  	notifPage dom.Element
 507  	notifReadTs int64   // watermark: events with CreatedAt > this are unread
 508  	notifRefTimer int32
 509  	notifSeen map[string]bool
 510  	notifSelectEl dom.Element
 511  	notifUnread bool
 512  	nwcConnected map[string]bool        // connID → open
 513  	nwcConns []NwcConn
 514  	nwcPendingQ map[string][]func(ws.Conn, bool)  // queued while connecting
 515  	nwcSockets map[string]ws.Conn     // connID → ws handle
 516  	nwcSubs map[string]*nwcPending  // subID → pending
 517  	oldestFeedTs int64  // oldest created_at in feed - for infinite scroll
 518  	oldestNotifTs int64
 519  	pageTitleEl dom.Element
 520  	pendingDeletes map[string]string    // delete event ID -> target event ID
 521  	pendingK0Name string  // nickname to publish as kind 0 after login
 522  	pendingNotes map[string][]dom.Element  // pubkey hex -> author header divs awaiting profile
 523  	pendingNotifAvatars map[string][]dom.Element  // pubkey hex -> notif avatar imgs awaiting profile
 524  	pendingTsEls []dom.Element  // timestamp divs awaiting relay confirmation
 525  	pendingZapRows map[string][]zapRowPending    // authorPK -> rows awaiting lud16
 526  	popoverEl dom.Element
 527  	popoverOpen bool
 528  	popoverSummary dom.Element
 529  	profileContentCache map[string]string
 530  	profileFollowsCache map[string][]string
 531  	profileMutesCache map[string][]string
 532  	profileName string
 533  	profileNotesSeen map[string]bool
 534  	profilePage dom.Element
 535  	profilePic string
 536  	profileRelaysCache map[string][]string
 537  	profileTab string
 538  	profileTabBtns map[string]dom.Element
 539  	profileTabContent dom.Element
 540  	profileTs int64
 541  	profileViewPK string
 542  	profileWrapOrder []string                           // LRU order (oldest first)
 543  	profileWrapTabCont map[string]dom.Element             // pk -> tab content ref
 544  	profileWrapTabSel map[string]string                  // pk -> selected tab
 545  	profileWrapTabs map[string]map[string]dom.Element  // pk -> tab button map
 546  	profileWrappers map[string]dom.Element             // pk -> wrapper div
 547  	pubhex string
 548  	refreshPulseOn bool
 549  	refreshPulseTimer int32
 550  	refreshSpinAngle int32
 551  	refreshSpinTimer int32
 552  	refreshSpinning bool
 553  	relayBadgeContainers []dom.Element  // capability warning badges, rewritten on NIP-11 fetch
 554  	relayBlocklist []string  // exact-URL blocklist (persisted; kind 10006 publication is future work)
 555  	relayCaps []relayCap     // NIP-11 snapshot (populated async after addRelay)
 556  	relayCapsTs []int64        // unix seconds when each relayCaps entry was fetched
 557  	relayDots []dom.Element
 558  	relayInfoEl dom.Element                        // relay info page wrapper (removed on nav)
 559  	relayLabels []dom.Element
 560  	relayListTs int64  // kind 10002 latest timestamp
 561  	relayPolicyFlags int32       // bitfield of policy* constants
 562  	relayReadBtns []dom.Element  // per-relay "r" toggle button
 563  	relayRole []string       // "both", "read", "write" per relay
 564  	relayRows []dom.Element  // popover row container - used by removeRelayByURL
 565  	relayURLs []string
 566  	relayUserPick []bool         // true = from user's kind 10002
 567  	relayWriteBtns []dom.Element  // per-relay "w" toggle button
 568  	replyAuthorMap map[string]string        // event ID -> author pubkey hex
 569  	replyAuthorMapOld map[string]string
 570  	replyAvatarCache map[string]string        // event ID -> avatar URL of parent author
 571  	replyAvatarCacheOld map[string]string
 572  	replyCache map[string]string        // event ID -> "name: first line"
 573  	replyCacheOld map[string]string
 574  	replyCount int32
 575  	replyHints map[string]string        // event ID -> relay hint URL from e-tag
 576  	replyHintsOld map[string]string
 577  	replyLineCache map[string]string        // event ID -> raw first line (no name)
 578  	replyLineCacheOld map[string]string
 579  	replyNeedName map[string][]dom.Element  // event ID -> preview divs needing name update
 580  	replyNeedNameOld map[string][]dom.Element
 581  	replyPending map[string][]dom.Element  // event ID -> preview divs awaiting fetch
 582  	replyQueue []string                 // event IDs to batch-fetch
 583  	replySweepTimer int32                      // debounced sweep for unfound replies
 584  	replyTimer int32
 585  	retryRound int32
 586  	root dom.Element
 587  	routerInited bool  // guard against duplicate listener registration
 588  	savedScrollTop string      // feed scroll position before thread open
 589  	searchBarActive bool
 590  	searchBtnGlobal dom.Element  // captured for deactivation from anywhere
 591  	searchContainer dom.Element
 592  	searchCurrentQ string
 593  	searchFilterBarEl dom.Element  // sticky bar above results
 594  	searchFollowsOnly bool
 595  	searchInputEl dom.Element
 596  	searchNSFW bool
 597  	searchPage dom.Element
 598  	searchRendered int32   // how many filtered results are currently rendered
 599  	searchResults []*nostr.Event
 600  	searchReturnPage string       // page to restore when user manually closes search
 601  	searchSeen map[string]bool
 602  	searchSortDesc bool  // true = newest first (default)
 603  	searchSubmitBtn dom.Element
 604  	seenEvents map[string]bool          // event ID -> already rendered
 605  	seenEventsOld map[string]bool
 606  	seenOnSubs map[string]func()
 607  	settingsPage dom.Element
 608  	settingsTs int64  // kind 30078 latest timestamp
 609  	sidebarCompose dom.Element
 610  	sidebarFeed dom.Element
 611  	sidebarMsg dom.Element
 612  	sidebarNotif dom.Element
 613  	sidebarSettings dom.Element
 614  	signerContent dom.Element
 615  	signerOpen bool
 616  	signerPanel dom.Element
 617  	signerStatus string  // "none", "locked", "unlocked"
 618  	statusEl dom.Element
 619  	themeBtn dom.Element
 620  	threadActiveSubs []string  // sub IDs to close when opening a new thread
 621  	threadContainer dom.Element
 622  	threadEvents map[string]*nostr.Event
 623  	threadFetchedIDs map[string]bool  // IDs already queried for child replies
 624  	threadFocusID string
 625  	threadFollowUp int32       // follow-up round counter (0 = initial, caps at 3)
 626  	threadLastRendered int32       // event count at last render - skip if unchanged
 627  	threadOpen bool
 628  	threadPage dom.Element
 629  	threadPushedState bool      // true if we pushed history for this thread
 630  	threadRelays []string  // relay URLs used for this thread's fetches
 631  	threadRenderTimer int32
 632  	threadReturnPage string    // page to return to when closing thread
 633  	threadRootID string
 634  	topBackBtn dom.Element
 635  	topBarLeft dom.Element
 636  	topBarRight dom.Element
 637  	userBtn dom.Element
 638  	viewProfileRow dom.Element
 639  	walletActiveID string
 640  	walletBalanceSats int64  // -1 = not fetched, -2 = fetch failed
 641  	walletBtnEl dom.Element
 642  	walletContent dom.Element
 643  	walletEnc string     // "nip04" | "nip44", auto-picked on first get_info
 644  	walletInfoLoaded bool
 645  	walletLud16 string     // cached from get_info
 646  	walletPanel dom.Element
 647  	walletPanelOpen bool
 648  	walletPayResultEl dom.Element
 649  	walletShowAddForm bool
 650  	ytTitleCache map[string]string
 651  	actionSubCounter int32
 652  	donNextSub int32
 653  	embedCounter int32
 654  	nwcNextSubN int32
 655  	profileSubCounter int32
 656  	threadGen int32       // generation counter - reject events from old threads
 657  	threadSubCount int32
 658  }
 659  
 660  var appSt *appState
 661  
 662  func initState() {
 663  	if appSt != nil {
 664  		return
 665  	}
 666  	// appSt and everything it holds live as long as the page: build them in
 667  	// the root arena, not in this call's frame arena which dies on return.
 668  	runtime.SovereignSetArena(runtime.RootArena())
 669  	appSt = &appState{}
 670  	appSt.currentLang = "en"
 671  	appSt.defaultRelays = []string{
 672  	orlyRelay,
 673  	"wss://nostr.wine",
 674  }
 675  	appSt.donSubs = map[string]func(string){}
 676  	appSt.nwcConnected = map[string]bool{}
 677  	appSt.nwcPendingQ = map[string][]func(ws.Conn, bool){}
 678  	appSt.nwcSockets = map[string]ws.Conn{}
 679  	appSt.nwcSubs = map[string]*nwcPending{}
 680  	appSt.walletBalanceSats = -1
 681  	appSt.ytTitleCache = map[string]string{}
 682  	runtime.SovereignRestoreArena(runtime.RootArena())
 683  }
 684  
 685  func initAppGlobals() {
 686  	dnsBlacklist = []string{
 687  			"wss://relay.nostr.band",
 688  			"wss://relay.nostr.band/",
 689  		}
 690  	discoveryRelays = []string{
 691  			"wss://purplepag.es",
 692  			"wss://relay.damus.io",
 693  			"wss://nos.lol",
 694  		}
 695  	migratedRelayTypes = map[string]bool{
 696  			"REQ":              true,
 697  			"CLOSE":            true,
 698  			"EVENT":            true,
 699  			"PUBLISH_TO":       true,
 700  			"PROXY":            true,
 701  			"SET_PUBKEY":       true,
 702  			"SET_WRITE_RELAYS": true,
 703  			"ENC_KEY":          true,
 704  		}
 705  	swTypes = map[string]bool{
 706  			"activate-update": true,
 707  		}
 708  	emojiList = []emojiEntry{
 709  			{"\xf0\x9f\x98\x80", "grinning_face"}, {"\xf0\x9f\x98\x80", "grinning"}, {"\xf0\x9f\x98\x83", "grinning_face_with_big_eyes"}, 
 710  			{"\xf0\x9f\x98\x83", "smiley"}, {"\xf0\x9f\x98\x84", "grinning_face_with_smiling_eyes"}, {"\xf0\x9f\x98\x84", "smile"}, 
 711  			{"\xf0\x9f\x98\x81", "beaming_face_with_smiling_eyes"}, {"\xf0\x9f\x98\x81", "grin"}, {"\xf0\x9f\x98\x86", "grinning_squinting_face"}, 
 712  			{"\xf0\x9f\x98\x86", "laughing"}, {"\xf0\x9f\x98\x85", "grinning_face_with_sweat"}, {"\xf0\x9f\x98\x85", "sweat_smile"}, 
 713  			{"\xf0\x9f\xa4\xa3", "rolling_on_the_floor_laughing"}, {"\xf0\x9f\xa4\xa3", "rofl"}, {"\xf0\x9f\x98\x82", "face_with_tears_of_joy"}, 
 714  			{"\xf0\x9f\x98\x82", "joy"}, {"\xf0\x9f\x99\x82", "slightly_smiling_face"}, {"\xf0\x9f\x99\x82", "slight_smile"}, 
 715  			{"\xf0\x9f\x99\x83", "upside-down_face"}, {"\xf0\x9f\xab\xa0", "melting_face"}, {"\xf0\x9f\x98\x89", "winking_face"}, 
 716  			{"\xf0\x9f\x98\x89", "wink"}, {"\xf0\x9f\x98\x8a", "smiling_face_with_smiling_eyes"}, {"\xf0\x9f\x98\x8a", "blush"}, 
 717  			{"\xf0\x9f\x98\x87", "smiling_face_with_halo"}, {"\xf0\x9f\x98\x87", "innocent"}, {"\xf0\x9f\xa5\xb0", "smiling_face_with_hearts"}, 
 718  			{"\xf0\x9f\x98\x8d", "smiling_face_with_heart-eyes"}, {"\xf0\x9f\x98\x8d", "heart_eyes"}, {"\xf0\x9f\xa4\xa9", "star-struck"}, 
 719  			{"\xf0\x9f\x98\x98", "face_blowing_a_kiss"}, {"\xf0\x9f\x98\x98", "kissing_heart"}, {"\xf0\x9f\x98\x97", "kissing_face"}, 
 720  			{"\xe2\x98\xba\xef\xb8\x8f", "smiling_face"}, {"\xf0\x9f\x98\x9a", "kissing_face_with_closed_eyes"}, {"\xf0\x9f\x98\x99", "kissing_face_with_smiling_eyes"}, 
 721  			{"\xf0\x9f\xa5\xb2", "smiling_face_with_tear"}, {"\xf0\x9f\x98\x8b", "face_savoring_food"}, {"\xf0\x9f\x98\x8b", "yum"}, 
 722  			{"\xf0\x9f\x98\x9b", "face_with_tongue"}, {"\xf0\x9f\x98\x9b", "stuck_out_tongue"}, {"\xf0\x9f\x98\x9c", "winking_face_with_tongue"}, 
 723  			{"\xf0\x9f\x98\x9c", "stuck_out_tongue_winking_eye"}, {"\xf0\x9f\xa4\xaa", "zany_face"}, {"\xf0\x9f\xa4\xaa", "zany"}, 
 724  			{"\xf0\x9f\x98\x9d", "squinting_face_with_tongue"}, {"\xf0\x9f\x98\x9d", "stuck_out_tongue_closed_eyes"}, {"\xf0\x9f\xa4\x91", "money-mouth_face"}, 
 725  			{"\xf0\x9f\xa4\x91", "money_mouth"}, {"\xf0\x9f\xa4\x97", "smiling_face_with_open_hands"}, {"\xf0\x9f\xa4\x97", "hugs"}, 
 726  			{"\xf0\x9f\xa4\xad", "face_with_hand_over_mouth"}, {"\xf0\x9f\xa4\xad", "shushing"}, {"\xf0\x9f\xab\xa2", "face_with_open_eyes_and_hand_over_mouth"}, 
 727  			{"\xf0\x9f\xab\xa3", "face_with_peeking_eye"}, {"\xf0\x9f\xa4\xab", "shushing_face"}, {"\xf0\x9f\xa4\xab", "shush"}, 
 728  			{"\xf0\x9f\xa4\x94", "thinking_face"}, {"\xf0\x9f\xa4\x94", "thinking"}, {"\xf0\x9f\xab\xa1", "saluting_face"}, 
 729  			{"\xf0\x9f\xa4\x90", "zipper-mouth_face"}, {"\xf0\x9f\xa4\x90", "zipper_mouth"}, {"\xf0\x9f\xa4\xa8", "face_with_raised_eyebrow"}, 
 730  			{"\xf0\x9f\xa4\xa8", "raised_eyebrow"}, {"\xf0\x9f\x98\x90", "neutral_face"}, {"\xf0\x9f\x98\x90", "neutral"}, 
 731  			{"\xf0\x9f\x98\x91", "expressionless_face"}, {"\xf0\x9f\x98\x91", "expressionless"}, {"\xf0\x9f\x98\xb6", "face_without_mouth"}, 
 732  			{"\xf0\x9f\x98\xb6", "no_mouth"}, {"\xf0\x9f\xab\xa5", "dotted_line_face"}, {"\xf0\x9f\x98\xb6\xe2\x80\x8d\xf0\x9f\x8c\xab\xef\xb8\x8f", "face_in_clouds"}, 
 733  			{"\xf0\x9f\x98\x8f", "smirking_face"}, {"\xf0\x9f\x98\x8f", "smirk"}, {"\xf0\x9f\x98\x92", "unamused_face"}, 
 734  			{"\xf0\x9f\x98\x92", "unamused"}, {"\xf0\x9f\x99\x84", "face_with_rolling_eyes"}, {"\xf0\x9f\x99\x84", "roll_eyes"}, 
 735  			{"\xf0\x9f\x98\xac", "grimacing_face"}, {"\xf0\x9f\x98\xac", "grimacing"}, {"\xf0\x9f\x98\xae\xe2\x80\x8d\xf0\x9f\x92\xa8", "face_exhaling"}, 
 736  			{"\xf0\x9f\xa4\xa5", "lying_face"}, {"\xf0\x9f\xa4\xa5", "lying"}, {"\xf0\x9f\xab\xa8", "shaking_face"}, 
 737  			{"\xf0\x9f\x99\x82\xe2\x80\x8d\xe2\x86\x94\xef\xb8\x8f", "head_shaking_horizontally"}, {"\xf0\x9f\x99\x82\xe2\x80\x8d\xe2\x86\x95\xef\xb8\x8f", "head_shaking_vertically"}, {"\xf0\x9f\x98\x8c", "relieved_face"}, 
 738  			{"\xf0\x9f\x98\x8c", "relieved"}, {"\xf0\x9f\x98\x94", "pensive_face"}, {"\xf0\x9f\x98\x94", "pensive"}, 
 739  			{"\xf0\x9f\x98\xaa", "sleepy_face"}, {"\xf0\x9f\x98\xaa", "sleepy"}, {"\xf0\x9f\xa4\xa4", "drooling_face"}, 
 740  			{"\xf0\x9f\x98\xb4", "sleeping_face"}, {"\xf0\x9f\x98\xb4", "sleeping"}, {"\xf0\x9f\xab\xa9", "face_with_bags_under_eyes"}, 
 741  			{"\xf0\x9f\x98\xb7", "face_with_medical_mask"}, {"\xf0\x9f\x98\xb7", "mask"}, {"\xf0\x9f\xa4\x92", "face_with_thermometer"}, 
 742  			{"\xf0\x9f\xa4\x92", "thermometer_face"}, {"\xf0\x9f\xa4\x95", "face_with_head-bandage"}, {"\xf0\x9f\xa4\xa2", "nauseated_face"}, 
 743  			{"\xf0\x9f\xa4\xa2", "nauseated"}, {"\xf0\x9f\xa4\xae", "face_vomiting"}, {"\xf0\x9f\xa4\xae", "vomiting"}, 
 744  			{"\xf0\x9f\xa4\xa7", "sneezing_face"}, {"\xf0\x9f\xa4\xa7", "sneezing"}, {"\xf0\x9f\xa5\xb5", "hot_face"}, 
 745  			{"\xf0\x9f\xa5\xb6", "cold_face"}, {"\xf0\x9f\xa5\xb4", "woozy_face"}, {"\xf0\x9f\x98\xb5", "face_with_crossed-out_eyes"}, 
 746  			{"\xf0\x9f\x98\xb5\xe2\x80\x8d\xf0\x9f\x92\xab", "face_with_spiral_eyes"}, {"\xf0\x9f\xa4\xaf", "exploding_head"}, {"\xf0\x9f\xa4\xa0", "cowboy_hat_face"}, 
 747  			{"\xf0\x9f\xa4\xa0", "cowboy"}, {"\xf0\x9f\xa5\xb3", "partying_face"}, {"\xf0\x9f\xa5\xb8", "disguised_face"}, 
 748  			{"\xf0\x9f\x98\x8e", "smiling_face_with_sunglasses"}, {"\xf0\x9f\x98\x8e", "sunglasses"}, {"\xf0\x9f\xa4\x93", "nerd_face"}, 
 749  			{"\xf0\x9f\xa4\x93", "nerd"}, {"\xf0\x9f\xa7\x90", "face_with_monocle"}, {"\xf0\x9f\x98\x95", "confused_face"}, 
 750  			{"\xf0\x9f\x98\x95", "confused"}, {"\xf0\x9f\xab\xa4", "face_with_diagonal_mouth"}, {"\xf0\x9f\x98\x9f", "worried_face"}, 
 751  			{"\xf0\x9f\x98\x9f", "worried"}, {"\xf0\x9f\x99\x81", "slightly_frowning_face"}, {"\xe2\x98\xb9\xef\xb8\x8f", "frowning_face"}, 
 752  			{"\xf0\x9f\x98\xae", "face_with_open_mouth"}, {"\xf0\x9f\x98\xae", "open_mouth"}, {"\xf0\x9f\x98\xaf", "hushed_face"}, 
 753  			{"\xf0\x9f\x98\xaf", "hushed"}, {"\xf0\x9f\x98\xb2", "astonished_face"}, {"\xf0\x9f\x98\xb2", "astonished"}, 
 754  			{"\xf0\x9f\x98\xb3", "flushed_face"}, {"\xf0\x9f\x98\xb3", "flushed"}, {"\xf0\x9f\xa5\xba", "pleading_face"}, 
 755  			{"\xf0\x9f\xa5\xba", "pleading"}, {"\xf0\x9f\xa5\xb9", "face_holding_back_tears"}, {"\xf0\x9f\x98\xa6", "frowning_face_with_open_mouth"}, 
 756  			{"\xf0\x9f\x98\xa7", "anguished_face"}, {"\xf0\x9f\x98\xa8", "fearful_face"}, {"\xf0\x9f\x98\xa8", "fearful"}, 
 757  			{"\xf0\x9f\x98\xb0", "anxious_face_with_sweat"}, {"\xf0\x9f\x98\xb0", "cold_sweat"}, {"\xf0\x9f\x98\xa5", "sad_but_relieved_face"}, 
 758  			{"\xf0\x9f\x98\xa2", "crying_face"}, {"\xf0\x9f\x98\xa2", "cry"}, {"\xf0\x9f\x98\xad", "loudly_crying_face"}, 
 759  			{"\xf0\x9f\x98\xad", "sob"}, {"\xf0\x9f\x98\xb1", "face_screaming_in_fear"}, {"\xf0\x9f\x98\xb1", "scream"}, 
 760  			{"\xf0\x9f\x98\x96", "confounded_face"}, {"\xf0\x9f\x98\xa3", "persevering_face"}, {"\xf0\x9f\x98\xa3", "persevere"}, 
 761  			{"\xf0\x9f\x98\x9e", "disappointed_face"}, {"\xf0\x9f\x98\x9e", "disappointed"}, {"\xf0\x9f\x98\x93", "downcast_face_with_sweat"}, 
 762  			{"\xf0\x9f\x98\xa9", "weary_face"}, {"\xf0\x9f\x98\xa9", "weary"}, {"\xf0\x9f\x98\xab", "tired_face"}, 
 763  			{"\xf0\x9f\x98\xab", "tired"}, {"\xf0\x9f\xa5\xb1", "yawning_face"}, {"\xf0\x9f\xa5\xb1", "yawning"}, 
 764  			{"\xf0\x9f\x98\xa4", "face_with_steam_from_nose"}, {"\xf0\x9f\x98\xa4", "triumph"}, {"\xf0\x9f\x98\xa1", "enraged_face"}, 
 765  			{"\xf0\x9f\x98\xa1", "rage"}, {"\xf0\x9f\x98\xa0", "angry_face"}, {"\xf0\x9f\x98\xa0", "angry"}, 
 766  			{"\xf0\x9f\xa4\xac", "face_with_symbols_on_mouth"}, {"\xf0\x9f\x98\x88", "smiling_face_with_horns"}, {"\xf0\x9f\x91\xbf", "angry_face_with_horns"}, 
 767  			{"\xf0\x9f\x92\x80", "skull"}, {"\xe2\x98\xa0\xef\xb8\x8f", "skull_and_crossbones"}, {"\xe2\x98\xa0\xef\xb8\x8f", "skull_crossbones"}, 
 768  			{"\xf0\x9f\x92\xa9", "pile_of_poo"}, {"\xf0\x9f\x92\xa9", "poop"}, {"\xf0\x9f\xa4\xa1", "clown_face"}, 
 769  			{"\xf0\x9f\xa4\xa1", "clown"}, {"\xf0\x9f\x91\xb9", "ogre"}, {"\xf0\x9f\x91\xba", "goblin"}, 
 770  			{"\xf0\x9f\x91\xbb", "ghost"}, {"\xf0\x9f\x91\xbd", "alien"}, {"\xf0\x9f\x91\xbe", "alien_monster"}, 
 771  			{"\xf0\x9f\xa4\x96", "robot"}, {"\xf0\x9f\x98\xba", "grinning_cat"}, {"\xf0\x9f\x98\xb8", "grinning_cat_with_smiling_eyes"}, 
 772  			{"\xf0\x9f\x98\xb9", "cat_with_tears_of_joy"}, {"\xf0\x9f\x98\xbb", "smiling_cat_with_heart-eyes"}, {"\xf0\x9f\x98\xbc", "cat_with_wry_smile"}, 
 773  			{"\xf0\x9f\x98\xbd", "kissing_cat"}, {"\xf0\x9f\x99\x80", "weary_cat"}, {"\xf0\x9f\x98\xbf", "crying_cat"}, 
 774  			{"\xf0\x9f\x98\xbe", "pouting_cat"}, {"\xf0\x9f\x99\x88", "see-no-evil_monkey"}, {"\xf0\x9f\x99\x89", "hear-no-evil_monkey"}, 
 775  			{"\xf0\x9f\x99\x8a", "speak-no-evil_monkey"}, {"\xf0\x9f\x92\x8c", "love_letter"}, {"\xf0\x9f\x92\x98", "heart_with_arrow"}, 
 776  			{"\xf0\x9f\x92\x9d", "heart_with_ribbon"}, {"\xf0\x9f\x92\x96", "sparkling_heart"}, {"\xf0\x9f\x92\x97", "growing_heart"}, 
 777  			{"\xf0\x9f\x92\x93", "beating_heart"}, {"\xf0\x9f\x92\x9e", "revolving_hearts"}, {"\xf0\x9f\x92\x95", "two_hearts"}, 
 778  			{"\xf0\x9f\x92\x9f", "heart_decoration"}, {"\xe2\x9d\xa3\xef\xb8\x8f", "heart_exclamation"}, {"\xf0\x9f\x92\x94", "broken_heart"}, 
 779  			{"\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x94\xa5", "heart_on_fire"}, {"\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\xa9\xb9", "mending_heart"}, {"\xe2\x9d\xa4\xef\xb8\x8f", "red_heart"}, 
 780  			{"\xe2\x9d\xa4\xef\xb8\x8f", "heart"}, {"\xf0\x9f\xa9\xb7", "pink_heart"}, {"\xf0\x9f\xa7\xa1", "orange_heart"}, 
 781  			{"\xf0\x9f\x92\x9b", "yellow_heart"}, {"\xf0\x9f\x92\x9a", "green_heart"}, {"\xf0\x9f\x92\x99", "blue_heart"}, 
 782  			{"\xf0\x9f\xa9\xb5", "light_blue_heart"}, {"\xf0\x9f\x92\x9c", "purple_heart"}, {"\xf0\x9f\xa4\x8e", "brown_heart"}, 
 783  			{"\xf0\x9f\x96\xa4", "black_heart"}, {"\xf0\x9f\xa9\xb6", "grey_heart"}, {"\xf0\x9f\xa4\x8d", "white_heart"}, 
 784  			{"\xf0\x9f\x92\x8b", "kiss_mark"}, {"\xf0\x9f\x92\xaf", "hundred_points"}, {"\xf0\x9f\x92\xaf", "hundred"}, 
 785  			{"\xf0\x9f\x92\xa2", "anger_symbol"}, {"\xf0\x9f\x92\xa5", "collision"}, {"\xf0\x9f\x92\xab", "dizzy"}, 
 786  			{"\xf0\x9f\x92\xa6", "sweat_droplets"}, {"\xf0\x9f\x92\xa8", "dashing_away"}, {"\xf0\x9f\x95\xb3\xef\xb8\x8f", "hole"}, 
 787  			{"\xf0\x9f\x92\xac", "speech_balloon"}, {"\xf0\x9f\x91\x81\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x97\xa8\xef\xb8\x8f", "eye_in_speech_bubble"}, {"\xf0\x9f\x97\xa8\xef\xb8\x8f", "left_speech_bubble"}, 
 788  			{"\xf0\x9f\x97\xaf\xef\xb8\x8f", "right_anger_bubble"}, {"\xf0\x9f\x92\xad", "thought_balloon"}, {"\xf0\x9f\x92\xa4", "zzz"}, 
 789  			{"\xf0\x9f\x91\x8b", "waving_hand"}, {"\xf0\x9f\x91\x8b", "wave_hand"}, {"\xf0\x9f\xa4\x9a", "raised_back_of_hand"}, 
 790  			{"\xf0\x9f\x96\x90\xef\xb8\x8f", "hand_with_fingers_splayed"}, {"\xe2\x9c\x8b", "raised_hand"}, {"\xf0\x9f\x96\x96", "vulcan_salute"}, 
 791  			{"\xf0\x9f\xab\xb1", "rightwards_hand"}, {"\xf0\x9f\xab\xb2", "leftwards_hand"}, {"\xf0\x9f\xab\xb3", "palm_down_hand"}, 
 792  			{"\xf0\x9f\xab\xb4", "palm_up_hand"}, {"\xf0\x9f\xab\xb7", "leftwards_pushing_hand"}, {"\xf0\x9f\xab\xb8", "rightwards_pushing_hand"}, 
 793  			{"\xf0\x9f\x91\x8c", "ok_hand"}, {"\xf0\x9f\xa4\x8c", "pinched_fingers"}, {"\xf0\x9f\xa4\x8f", "pinching_hand"}, 
 794  			{"\xe2\x9c\x8c\xef\xb8\x8f", "victory_hand"}, {"\xe2\x9c\x8c\xef\xb8\x8f", "v"}, {"\xf0\x9f\xa4\x9e", "crossed_fingers"}, 
 795  			{"\xf0\x9f\xab\xb0", "hand_with_index_finger_and_thumb_crossed"}, {"\xf0\x9f\xa4\x9f", "love-you_gesture"}, {"\xf0\x9f\xa4\x98", "sign_of_the_horns"}, 
 796  			{"\xf0\x9f\xa4\x99", "call_me_hand"}, {"\xf0\x9f\xa4\x99", "call_me"}, {"\xf0\x9f\x91\x88", "backhand_index_pointing_left"}, 
 797  			{"\xf0\x9f\x91\x88", "point_left"}, {"\xf0\x9f\x91\x89", "backhand_index_pointing_right"}, {"\xf0\x9f\x91\x89", "point_right"}, 
 798  			{"\xf0\x9f\x91\x86", "backhand_index_pointing_up"}, {"\xf0\x9f\x91\x86", "point_up"}, {"\xf0\x9f\x96\x95", "middle_finger"}, 
 799  			{"\xf0\x9f\x91\x87", "backhand_index_pointing_down"}, {"\xf0\x9f\x91\x87", "point_down"}, {"\xe2\x98\x9d\xef\xb8\x8f", "index_pointing_up"}, 
 800  			{"\xe2\x98\x9d\xef\xb8\x8f", "point_up_2"}, {"\xf0\x9f\xab\xb5", "index_pointing_at_the_viewer"}, {"\xf0\x9f\x91\x8d", "thumbs_up"}, 
 801  			{"\xf0\x9f\x91\x8d", "thumbsup"}, {"\xf0\x9f\x91\x8e", "thumbs_down"}, {"\xf0\x9f\x91\x8e", "thumbsdown"}, 
 802  			{"\xe2\x9c\x8a", "raised_fist"}, {"\xe2\x9c\x8a", "fist"}, {"\xf0\x9f\x91\x8a", "oncoming_fist"}, 
 803  			{"\xf0\x9f\x91\x8a", "punch"}, {"\xf0\x9f\xa4\x9b", "left-facing_fist"}, {"\xf0\x9f\xa4\x9c", "right-facing_fist"}, 
 804  			{"\xf0\x9f\x91\x8f", "clapping_hands"}, {"\xf0\x9f\x91\x8f", "clap"}, {"\xf0\x9f\x99\x8c", "raising_hands"}, 
 805  			{"\xf0\x9f\x99\x8c", "raised_hands"}, {"\xf0\x9f\xab\xb6", "heart_hands"}, {"\xf0\x9f\x91\x90", "open_hands"}, 
 806  			{"\xf0\x9f\xa4\xb2", "palms_up_together"}, {"\xf0\x9f\xa4\xb2", "palms_up"}, {"\xf0\x9f\xa4\x9d", "handshake"}, 
 807  			{"\xf0\x9f\x99\x8f", "folded_hands"}, {"\xf0\x9f\x99\x8f", "pray"}, {"\xe2\x9c\x8d\xef\xb8\x8f", "writing_hand"}, 
 808  			{"\xf0\x9f\x92\x85", "nail_polish"}, {"\xf0\x9f\x92\x85", "nail_care"}, {"\xf0\x9f\xa4\xb3", "selfie"}, 
 809  			{"\xf0\x9f\x92\xaa", "flexed_biceps"}, {"\xf0\x9f\x92\xaa", "muscle"}, {"\xf0\x9f\xa6\xbe", "mechanical_arm"}, 
 810  			{"\xf0\x9f\xa6\xbf", "mechanical_leg"}, {"\xf0\x9f\xa6\xb5", "leg"}, {"\xf0\x9f\xa6\xb6", "foot"}, 
 811  			{"\xf0\x9f\x91\x82", "ear"}, {"\xf0\x9f\xa6\xbb", "ear_with_hearing_aid"}, {"\xf0\x9f\x91\x83", "nose"}, 
 812  			{"\xf0\x9f\xa7\xa0", "brain"}, {"\xf0\x9f\xab\x80", "anatomical_heart"}, {"\xf0\x9f\xab\x81", "lungs"}, 
 813  			{"\xf0\x9f\xa6\xb7", "tooth"}, {"\xf0\x9f\xa6\xb4", "bone"}, {"\xf0\x9f\x91\x80", "eyes"}, 
 814  			{"\xf0\x9f\x91\x81\xef\xb8\x8f", "eye"}, {"\xf0\x9f\x91\x85", "tongue"}, {"\xf0\x9f\x91\x84", "mouth"}, 
 815  			{"\xf0\x9f\xab\xa6", "biting_lip"}, {"\xf0\x9f\x91\xb6", "baby"}, {"\xf0\x9f\xa7\x92", "child"}, 
 816  			{"\xf0\x9f\x91\xa6", "boy"}, {"\xf0\x9f\x91\xa7", "girl"}, {"\xf0\x9f\xa7\x91", "person"}, 
 817  			{"\xf0\x9f\x91\xb1", "person_blond_hair"}, {"\xf0\x9f\x91\xa8", "man"}, {"\xf0\x9f\xa7\x94", "person_beard"}, 
 818  			{"\xf0\x9f\xa7\x94\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_beard"}, {"\xf0\x9f\xa7\x94\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_beard"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xb0", "man_red_hair"}, 
 819  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xb1", "man_curly_hair"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xb3", "man_white_hair"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xb2", "man_bald"}, 
 820  			{"\xf0\x9f\x91\xa9", "woman"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xb0", "woman_red_hair"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xb0", "person_red_hair"}, 
 821  			{"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xb1", "woman_curly_hair"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xb1", "person_curly_hair"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xb3", "woman_white_hair"}, 
 822  			{"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xb3", "person_white_hair"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xb2", "woman_bald"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xb2", "person_bald"}, 
 823  			{"\xf0\x9f\x91\xb1\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_blond_hair"}, {"\xf0\x9f\x91\xb1\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_blond_hair"}, {"\xf0\x9f\xa7\x93", "older_person"}, 
 824  			{"\xf0\x9f\x91\xb4", "old_man"}, {"\xf0\x9f\x91\xb5", "old_woman"}, {"\xf0\x9f\x99\x8d", "person_frowning"}, 
 825  			{"\xf0\x9f\x99\x8d\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_frowning"}, {"\xf0\x9f\x99\x8d\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_frowning"}, {"\xf0\x9f\x99\x8e", "person_pouting"}, 
 826  			{"\xf0\x9f\x99\x8e\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_pouting"}, {"\xf0\x9f\x99\x8e\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_pouting"}, {"\xf0\x9f\x99\x85", "person_gesturing_no"}, 
 827  			{"\xf0\x9f\x99\x85\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_gesturing_no"}, {"\xf0\x9f\x99\x85\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_gesturing_no"}, {"\xf0\x9f\x99\x86", "person_gesturing_ok"}, 
 828  			{"\xf0\x9f\x99\x86\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_gesturing_ok"}, {"\xf0\x9f\x99\x86\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_gesturing_ok"}, {"\xf0\x9f\x92\x81", "person_tipping_hand"}, 
 829  			{"\xf0\x9f\x92\x81\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_tipping_hand"}, {"\xf0\x9f\x92\x81\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_tipping_hand"}, {"\xf0\x9f\x99\x8b", "person_raising_hand"}, 
 830  			{"\xf0\x9f\x99\x8b\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_raising_hand"}, {"\xf0\x9f\x99\x8b\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_raising_hand"}, {"\xf0\x9f\xa7\x8f", "deaf_person"}, 
 831  			{"\xf0\x9f\xa7\x8f\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "deaf_man"}, {"\xf0\x9f\xa7\x8f\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "deaf_woman"}, {"\xf0\x9f\x99\x87", "person_bowing"}, 
 832  			{"\xf0\x9f\x99\x87\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_bowing"}, {"\xf0\x9f\x99\x87\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_bowing"}, {"\xf0\x9f\xa4\xa6", "person_facepalming"}, 
 833  			{"\xf0\x9f\xa4\xa6\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_facepalming"}, {"\xf0\x9f\xa4\xa6\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_facepalming"}, {"\xf0\x9f\xa4\xb7", "person_shrugging"}, 
 834  			{"\xf0\x9f\xa4\xb7\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_shrugging"}, {"\xf0\x9f\xa4\xb7\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_shrugging"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xe2\x9a\x95\xef\xb8\x8f", "health_worker"}, 
 835  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xe2\x9a\x95\xef\xb8\x8f", "man_health_worker"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9a\x95\xef\xb8\x8f", "woman_health_worker"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8e\x93", "student"}, 
 836  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8e\x93", "man_student"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8e\x93", "woman_student"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8f\xab", "teacher"}, 
 837  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8f\xab", "man_teacher"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8f\xab", "woman_teacher"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xe2\x9a\x96\xef\xb8\x8f", "judge"}, 
 838  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xe2\x9a\x96\xef\xb8\x8f", "man_judge"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9a\x96\xef\xb8\x8f", "woman_judge"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8c\xbe", "farmer"}, 
 839  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8c\xbe", "man_farmer"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8c\xbe", "woman_farmer"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8d\xb3", "cook"}, 
 840  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8d\xb3", "man_cook"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8d\xb3", "woman_cook"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x94\xa7", "mechanic"}, 
 841  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x94\xa7", "man_mechanic"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x94\xa7", "woman_mechanic"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8f\xad", "factory_worker"}, 
 842  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8f\xad", "man_factory_worker"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8f\xad", "woman_factory_worker"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x92\xbc", "office_worker"}, 
 843  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x92\xbc", "man_office_worker"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x92\xbc", "woman_office_worker"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x94\xac", "scientist"}, 
 844  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x94\xac", "man_scientist"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x94\xac", "woman_scientist"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x92\xbb", "technologist"}, 
 845  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x92\xbb", "man_technologist"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x92\xbb", "woman_technologist"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8e\xa4", "singer"}, 
 846  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8e\xa4", "man_singer"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8e\xa4", "woman_singer"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8e\xa8", "artist"}, 
 847  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8e\xa8", "man_artist"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8e\xa8", "woman_artist"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xe2\x9c\x88\xef\xb8\x8f", "pilot"}, 
 848  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xe2\x9c\x88\xef\xb8\x8f", "man_pilot"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9c\x88\xef\xb8\x8f", "woman_pilot"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x9a\x80", "astronaut"}, 
 849  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x9a\x80", "man_astronaut"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x9a\x80", "woman_astronaut"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x9a\x92", "firefighter"}, 
 850  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x9a\x92", "man_firefighter"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x9a\x92", "woman_firefighter"}, {"\xf0\x9f\x91\xae", "police_officer"}, 
 851  			{"\xf0\x9f\x91\xae\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_police_officer"}, {"\xf0\x9f\x91\xae\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_police_officer"}, {"\xf0\x9f\x95\xb5\xef\xb8\x8f", "detective"}, 
 852  			{"\xf0\x9f\x95\xb5\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_detective"}, {"\xf0\x9f\x95\xb5\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_detective"}, {"\xf0\x9f\x92\x82", "guard"}, 
 853  			{"\xf0\x9f\x92\x82\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_guard"}, {"\xf0\x9f\x92\x82\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_guard"}, {"\xf0\x9f\xa5\xb7", "ninja"}, 
 854  			{"\xf0\x9f\x91\xb7", "construction_worker"}, {"\xf0\x9f\x91\xb7\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_construction_worker"}, {"\xf0\x9f\x91\xb7\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_construction_worker"}, 
 855  			{"\xf0\x9f\xab\x85", "person_with_crown"}, {"\xf0\x9f\xa4\xb4", "prince"}, {"\xf0\x9f\x91\xb8", "princess"}, 
 856  			{"\xf0\x9f\x91\xb3", "person_wearing_turban"}, {"\xf0\x9f\x91\xb3\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_wearing_turban"}, {"\xf0\x9f\x91\xb3\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_wearing_turban"}, 
 857  			{"\xf0\x9f\x91\xb2", "person_with_skullcap"}, {"\xf0\x9f\xa7\x95", "woman_with_headscarf"}, {"\xf0\x9f\xa4\xb5", "person_in_tuxedo"}, 
 858  			{"\xf0\x9f\xa4\xb5\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_in_tuxedo"}, {"\xf0\x9f\xa4\xb5\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_in_tuxedo"}, {"\xf0\x9f\x91\xb0", "person_with_veil"}, 
 859  			{"\xf0\x9f\x91\xb0\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_with_veil"}, {"\xf0\x9f\x91\xb0\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_with_veil"}, {"\xf0\x9f\xa4\xb0", "pregnant_woman"}, 
 860  			{"\xf0\x9f\xab\x83", "pregnant_man"}, {"\xf0\x9f\xab\x84", "pregnant_person"}, {"\xf0\x9f\xa4\xb1", "breast-feeding"}, 
 861  			{"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x8d\xbc", "woman_feeding_baby"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x8d\xbc", "man_feeding_baby"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8d\xbc", "person_feeding_baby"}, 
 862  			{"\xf0\x9f\x91\xbc", "baby_angel"}, {"\xf0\x9f\x8e\x85", "santa_claus"}, {"\xf0\x9f\xa4\xb6", "mrs_claus"}, 
 863  			{"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\x8e\x84", "mx_claus"}, {"\xf0\x9f\xa6\xb8", "superhero"}, {"\xf0\x9f\xa6\xb8\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_superhero"}, 
 864  			{"\xf0\x9f\xa6\xb8\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_superhero"}, {"\xf0\x9f\xa6\xb9", "supervillain"}, {"\xf0\x9f\xa6\xb9\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_supervillain"}, 
 865  			{"\xf0\x9f\xa6\xb9\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_supervillain"}, {"\xf0\x9f\xa7\x99", "mage"}, {"\xf0\x9f\xa7\x99\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_mage"}, 
 866  			{"\xf0\x9f\xa7\x99\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_mage"}, {"\xf0\x9f\xa7\x9a", "fairy"}, {"\xf0\x9f\xa7\x9a\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_fairy"}, 
 867  			{"\xf0\x9f\xa7\x9a\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_fairy"}, {"\xf0\x9f\xa7\x9b", "vampire"}, {"\xf0\x9f\xa7\x9b\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_vampire"}, 
 868  			{"\xf0\x9f\xa7\x9b\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_vampire"}, {"\xf0\x9f\xa7\x9c", "merperson"}, {"\xf0\x9f\xa7\x9c\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "merman"}, 
 869  			{"\xf0\x9f\xa7\x9c\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "mermaid"}, {"\xf0\x9f\xa7\x9d", "elf"}, {"\xf0\x9f\xa7\x9d\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_elf"}, 
 870  			{"\xf0\x9f\xa7\x9d\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_elf"}, {"\xf0\x9f\xa7\x9e", "genie"}, {"\xf0\x9f\xa7\x9e\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_genie"}, 
 871  			{"\xf0\x9f\xa7\x9e\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_genie"}, {"\xf0\x9f\xa7\x9f", "zombie"}, {"\xf0\x9f\xa7\x9f\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_zombie"}, 
 872  			{"\xf0\x9f\xa7\x9f\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_zombie"}, {"\xf0\x9f\xa7\x8c", "troll"}, {"\xf0\x9f\x92\x86", "person_getting_massage"}, 
 873  			{"\xf0\x9f\x92\x86\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_getting_massage"}, {"\xf0\x9f\x92\x86\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_getting_massage"}, {"\xf0\x9f\x92\x87", "person_getting_haircut"}, 
 874  			{"\xf0\x9f\x92\x87\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_getting_haircut"}, {"\xf0\x9f\x92\x87\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_getting_haircut"}, {"\xf0\x9f\x9a\xb6", "person_walking"}, 
 875  			{"\xf0\x9f\x9a\xb6\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_walking"}, {"\xf0\x9f\x9a\xb6\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_walking"}, {"\xf0\x9f\x9a\xb6\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "person_walking_facing_right"}, 
 876  			{"\xf0\x9f\x9a\xb6\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "woman_walking_facing_right"}, {"\xf0\x9f\x9a\xb6\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "man_walking_facing_right"}, {"\xf0\x9f\xa7\x8d", "person_standing"}, 
 877  			{"\xf0\x9f\xa7\x8d\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_standing"}, {"\xf0\x9f\xa7\x8d\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_standing"}, {"\xf0\x9f\xa7\x8e", "person_kneeling"}, 
 878  			{"\xf0\x9f\xa7\x8e\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_kneeling"}, {"\xf0\x9f\xa7\x8e\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_kneeling"}, {"\xf0\x9f\xa7\x8e\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "person_kneeling_facing_right"}, 
 879  			{"\xf0\x9f\xa7\x8e\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "woman_kneeling_facing_right"}, {"\xf0\x9f\xa7\x8e\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "man_kneeling_facing_right"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xaf", "person_with_white_cane"}, 
 880  			{"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xaf\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "person_with_white_cane_facing_right"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xaf", "man_with_white_cane"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xaf\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "man_with_white_cane_facing_right"}, 
 881  			{"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xaf", "woman_with_white_cane"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xaf\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "woman_with_white_cane_facing_right"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xbc", "person_in_motorized_wheelchair"}, 
 882  			{"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xbc\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "person_in_motorized_wheelchair_facing_right"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xbc", "man_in_motorized_wheelchair"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xbc\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "man_in_motorized_wheelchair_facing_right"}, 
 883  			{"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xbc", "woman_in_motorized_wheelchair"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xbc\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "woman_in_motorized_wheelchair_facing_right"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xbd", "person_in_manual_wheelchair"}, 
 884  			{"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa6\xbd\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "person_in_manual_wheelchair_facing_right"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xbd", "man_in_manual_wheelchair"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\xa6\xbd\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "man_in_manual_wheelchair_facing_right"}, 
 885  			{"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xbd", "woman_in_manual_wheelchair"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\xa6\xbd\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "woman_in_manual_wheelchair_facing_right"}, {"\xf0\x9f\x8f\x83", "person_running"}, 
 886  			{"\xf0\x9f\x8f\x83\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_running"}, {"\xf0\x9f\x8f\x83\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_running"}, {"\xf0\x9f\x8f\x83\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "person_running_facing_right"}, 
 887  			{"\xf0\x9f\x8f\x83\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "woman_running_facing_right"}, {"\xf0\x9f\x8f\x83\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f\xe2\x80\x8d\xe2\x9e\xa1\xef\xb8\x8f", "man_running_facing_right"}, {"\xf0\x9f\x92\x83", "woman_dancing"}, 
 888  			{"\xf0\x9f\x95\xba", "man_dancing"}, {"\xf0\x9f\x95\xb4\xef\xb8\x8f", "person_in_suit_levitating"}, {"\xf0\x9f\x91\xaf", "people_with_bunny_ears"}, 
 889  			{"\xf0\x9f\x91\xaf\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "men_with_bunny_ears"}, {"\xf0\x9f\x91\xaf\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "women_with_bunny_ears"}, {"\xf0\x9f\xa7\x96", "person_in_steamy_room"}, 
 890  			{"\xf0\x9f\xa7\x96\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_in_steamy_room"}, {"\xf0\x9f\xa7\x96\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_in_steamy_room"}, {"\xf0\x9f\xa7\x97", "person_climbing"}, 
 891  			{"\xf0\x9f\xa7\x97\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_climbing"}, {"\xf0\x9f\xa7\x97\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_climbing"}, {"\xf0\x9f\xa4\xba", "person_fencing"}, 
 892  			{"\xf0\x9f\x8f\x87", "horse_racing"}, {"\xe2\x9b\xb7\xef\xb8\x8f", "skier"}, {"\xf0\x9f\x8f\x82", "snowboarder"}, 
 893  			{"\xf0\x9f\x8f\x8c\xef\xb8\x8f", "person_golfing"}, {"\xf0\x9f\x8f\x8c\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_golfing"}, {"\xf0\x9f\x8f\x8c\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_golfing"}, 
 894  			{"\xf0\x9f\x8f\x84", "person_surfing"}, {"\xf0\x9f\x8f\x84\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_surfing"}, {"\xf0\x9f\x8f\x84\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_surfing"}, 
 895  			{"\xf0\x9f\x9a\xa3", "person_rowing_boat"}, {"\xf0\x9f\x9a\xa3\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_rowing_boat"}, {"\xf0\x9f\x9a\xa3\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_rowing_boat"}, 
 896  			{"\xf0\x9f\x8f\x8a", "person_swimming"}, {"\xf0\x9f\x8f\x8a\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_swimming"}, {"\xf0\x9f\x8f\x8a\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_swimming"}, 
 897  			{"\xe2\x9b\xb9\xef\xb8\x8f", "person_bouncing_ball"}, {"\xe2\x9b\xb9\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_bouncing_ball"}, {"\xe2\x9b\xb9\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_bouncing_ball"}, 
 898  			{"\xf0\x9f\x8f\x8b\xef\xb8\x8f", "person_lifting_weights"}, {"\xf0\x9f\x8f\x8b\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_lifting_weights"}, {"\xf0\x9f\x8f\x8b\xef\xb8\x8f\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_lifting_weights"}, 
 899  			{"\xf0\x9f\x9a\xb4", "person_biking"}, {"\xf0\x9f\x9a\xb4\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_biking"}, {"\xf0\x9f\x9a\xb4\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_biking"}, 
 900  			{"\xf0\x9f\x9a\xb5", "person_mountain_biking"}, {"\xf0\x9f\x9a\xb5\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_mountain_biking"}, {"\xf0\x9f\x9a\xb5\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_mountain_biking"}, 
 901  			{"\xf0\x9f\xa4\xb8", "person_cartwheeling"}, {"\xf0\x9f\xa4\xb8\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_cartwheeling"}, {"\xf0\x9f\xa4\xb8\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_cartwheeling"}, 
 902  			{"\xf0\x9f\xa4\xbc", "people_wrestling"}, {"\xf0\x9f\xa4\xbc\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "men_wrestling"}, {"\xf0\x9f\xa4\xbc\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "women_wrestling"}, 
 903  			{"\xf0\x9f\xa4\xbd", "person_playing_water_polo"}, {"\xf0\x9f\xa4\xbd\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_playing_water_polo"}, {"\xf0\x9f\xa4\xbd\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_playing_water_polo"}, 
 904  			{"\xf0\x9f\xa4\xbe", "person_playing_handball"}, {"\xf0\x9f\xa4\xbe\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_playing_handball"}, {"\xf0\x9f\xa4\xbe\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_playing_handball"}, 
 905  			{"\xf0\x9f\xa4\xb9", "person_juggling"}, {"\xf0\x9f\xa4\xb9\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_juggling"}, {"\xf0\x9f\xa4\xb9\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_juggling"}, 
 906  			{"\xf0\x9f\xa7\x98", "person_in_lotus_position"}, {"\xf0\x9f\xa7\x98\xe2\x80\x8d\xe2\x99\x82\xef\xb8\x8f", "man_in_lotus_position"}, {"\xf0\x9f\xa7\x98\xe2\x80\x8d\xe2\x99\x80\xef\xb8\x8f", "woman_in_lotus_position"}, 
 907  			{"\xf0\x9f\x9b\x80", "person_taking_bath"}, {"\xf0\x9f\x9b\x8c", "person_in_bed"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa4\x9d\xe2\x80\x8d\xf0\x9f\xa7\x91", "people_holding_hands"}, 
 908  			{"\xf0\x9f\x91\xad", "women_holding_hands"}, {"\xf0\x9f\x91\xab", "woman_and_man_holding_hands"}, {"\xf0\x9f\x91\xac", "men_holding_hands"}, 
 909  			{"\xf0\x9f\x92\x8f", "kiss"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x92\x8b\xe2\x80\x8d\xf0\x9f\x91\xa8", "kiss_woman_man"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x92\x8b\xe2\x80\x8d\xf0\x9f\x91\xa8", "kiss_man_man"}, 
 910  			{"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x92\x8b\xe2\x80\x8d\xf0\x9f\x91\xa9", "kiss_woman_woman"}, {"\xf0\x9f\x92\x91", "couple_with_heart"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x91\xa8", "couple_with_heart_woman_man"}, 
 911  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x91\xa8", "couple_with_heart_man_man"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xe2\x9d\xa4\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x91\xa9", "couple_with_heart_woman_woman"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_woman_boy"}, 
 912  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_man_woman_girl"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_woman_girl_boy"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa6\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_woman_boy_boy"}, 
 913  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_man_woman_girl_girl"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_man_boy"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_man_man_girl"}, 
 914  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_man_girl_boy"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa6\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_man_boy_boy"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_man_man_girl_girl"}, 
 915  			{"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_woman_woman_boy"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_woman_woman_girl"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_woman_woman_girl_boy"}, 
 916  			{"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa6\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_woman_woman_boy_boy"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_woman_woman_girl_girl"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_boy"}, 
 917  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa6\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_boy_boy"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_man_girl"}, {"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_man_girl_boy"}, 
 918  			{"\xf0\x9f\x91\xa8\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_man_girl_girl"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_woman_boy"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa6\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_woman_boy_boy"}, 
 919  			{"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_woman_girl"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa6", "family_woman_girl_boy"}, {"\xf0\x9f\x91\xa9\xe2\x80\x8d\xf0\x9f\x91\xa7\xe2\x80\x8d\xf0\x9f\x91\xa7", "family_woman_girl_girl"}, 
 920  			{"\xf0\x9f\x97\xa3\xef\xb8\x8f", "speaking_head"}, {"\xf0\x9f\x91\xa4", "bust_in_silhouette"}, {"\xf0\x9f\x91\xa5", "busts_in_silhouette"}, 
 921  			{"\xf0\x9f\xab\x82", "people_hugging"}, {"\xf0\x9f\x91\xaa", "family"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa7\x92", "family_adult_adult_child"}, 
 922  			{"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa7\x92\xe2\x80\x8d\xf0\x9f\xa7\x92", "family_adult_adult_child_child"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa7\x92", "family_adult_child"}, {"\xf0\x9f\xa7\x91\xe2\x80\x8d\xf0\x9f\xa7\x92\xe2\x80\x8d\xf0\x9f\xa7\x92", "family_adult_child_child"}, 
 923  			{"\xf0\x9f\x91\xa3", "footprints"}, {"\xf0\x9f\xab\x86", "fingerprint"}, {"\xf0\x9f\x90\xb5", "monkey_face"}, 
 924  			{"\xf0\x9f\x90\x92", "monkey"}, {"\xf0\x9f\xa6\x8d", "gorilla"}, {"\xf0\x9f\xa6\xa7", "orangutan"}, 
 925  			{"\xf0\x9f\x90\xb6", "dog_face"}, {"\xf0\x9f\x90\xb6", "dog"}, {"\xf0\x9f\xa6\xae", "guide_dog"}, 
 926  			{"\xf0\x9f\x90\x95\xe2\x80\x8d\xf0\x9f\xa6\xba", "service_dog"}, {"\xf0\x9f\x90\xa9", "poodle"}, {"\xf0\x9f\x90\xba", "wolf"}, 
 927  			{"\xf0\x9f\xa6\x8a", "fox"}, {"\xf0\x9f\xa6\x9d", "raccoon"}, {"\xf0\x9f\x90\xb1", "cat_face"}, 
 928  			{"\xf0\x9f\x90\xb1", "cat"}, {"\xf0\x9f\x90\x88\xe2\x80\x8d\xe2\xac\x9b", "black_cat"}, {"\xf0\x9f\xa6\x81", "lion"}, 
 929  			{"\xf0\x9f\x90\xaf", "tiger_face"}, {"\xf0\x9f\x90\x85", "tiger"}, {"\xf0\x9f\x90\x86", "leopard"}, 
 930  			{"\xf0\x9f\x90\xb4", "horse_face"}, {"\xf0\x9f\xab\x8e", "moose"}, {"\xf0\x9f\xab\x8f", "donkey"}, 
 931  			{"\xf0\x9f\x90\x8e", "horse"}, {"\xf0\x9f\xa6\x84", "unicorn"}, {"\xf0\x9f\xa6\x93", "zebra"}, 
 932  			{"\xf0\x9f\xa6\x8c", "deer"}, {"\xf0\x9f\xa6\xac", "bison"}, {"\xf0\x9f\x90\xae", "cow_face"}, 
 933  			{"\xf0\x9f\x90\x82", "ox"}, {"\xf0\x9f\x90\x83", "water_buffalo"}, {"\xf0\x9f\x90\x84", "cow"}, 
 934  			{"\xf0\x9f\x90\xb7", "pig_face"}, {"\xf0\x9f\x90\x96", "pig"}, {"\xf0\x9f\x90\x97", "boar"}, 
 935  			{"\xf0\x9f\x90\xbd", "pig_nose"}, {"\xf0\x9f\x90\x8f", "ram"}, {"\xf0\x9f\x90\x91", "ewe"}, 
 936  			{"\xf0\x9f\x90\x90", "goat"}, {"\xf0\x9f\x90\xaa", "camel"}, {"\xf0\x9f\x90\xab", "two-hump_camel"}, 
 937  			{"\xf0\x9f\xa6\x99", "llama"}, {"\xf0\x9f\xa6\x92", "giraffe"}, {"\xf0\x9f\x90\x98", "elephant"}, 
 938  			{"\xf0\x9f\xa6\xa3", "mammoth"}, {"\xf0\x9f\xa6\x8f", "rhinoceros"}, {"\xf0\x9f\xa6\x9b", "hippopotamus"}, 
 939  			{"\xf0\x9f\x90\xad", "mouse_face"}, {"\xf0\x9f\x90\x81", "mouse"}, {"\xf0\x9f\x90\x80", "rat"}, 
 940  			{"\xf0\x9f\x90\xb9", "hamster"}, {"\xf0\x9f\x90\xb0", "rabbit_face"}, {"\xf0\x9f\x90\x87", "rabbit"}, 
 941  			{"\xf0\x9f\x90\xbf\xef\xb8\x8f", "chipmunk"}, {"\xf0\x9f\xa6\xab", "beaver"}, {"\xf0\x9f\xa6\x94", "hedgehog"}, 
 942  			{"\xf0\x9f\xa6\x87", "bat"}, {"\xf0\x9f\x90\xbb", "bear"}, {"\xf0\x9f\x90\xbb\xe2\x80\x8d\xe2\x9d\x84\xef\xb8\x8f", "polar_bear"}, 
 943  			{"\xf0\x9f\x90\xa8", "koala"}, {"\xf0\x9f\x90\xbc", "panda"}, {"\xf0\x9f\xa6\xa5", "sloth"}, 
 944  			{"\xf0\x9f\xa6\xa6", "otter"}, {"\xf0\x9f\xa6\xa8", "skunk"}, {"\xf0\x9f\xa6\x98", "kangaroo"}, 
 945  			{"\xf0\x9f\xa6\xa1", "badger"}, {"\xf0\x9f\x90\xbe", "paw_prints"}, {"\xf0\x9f\xa6\x83", "turkey"}, 
 946  			{"\xf0\x9f\x90\x94", "chicken"}, {"\xf0\x9f\x90\x93", "rooster"}, {"\xf0\x9f\x90\xa3", "hatching_chick"}, 
 947  			{"\xf0\x9f\x90\xa4", "baby_chick"}, {"\xf0\x9f\x90\xa5", "front-facing_baby_chick"}, {"\xf0\x9f\x90\xa6", "bird"}, 
 948  			{"\xf0\x9f\x90\xa7", "penguin"}, {"\xf0\x9f\x95\x8a\xef\xb8\x8f", "dove"}, {"\xf0\x9f\xa6\x85", "eagle"}, 
 949  			{"\xf0\x9f\xa6\x86", "duck"}, {"\xf0\x9f\xa6\xa2", "swan"}, {"\xf0\x9f\xa6\x89", "owl"}, 
 950  			{"\xf0\x9f\xa6\xa4", "dodo"}, {"\xf0\x9f\xaa\xb6", "feather"}, {"\xf0\x9f\xa6\xa9", "flamingo"}, 
 951  			{"\xf0\x9f\xa6\x9a", "peacock"}, {"\xf0\x9f\xa6\x9c", "parrot"}, {"\xf0\x9f\xaa\xbd", "wing"}, 
 952  			{"\xf0\x9f\x90\xa6\xe2\x80\x8d\xe2\xac\x9b", "black_bird"}, {"\xf0\x9f\xaa\xbf", "goose"}, {"\xf0\x9f\x90\xa6\xe2\x80\x8d\xf0\x9f\x94\xa5", "phoenix"}, 
 953  			{"\xf0\x9f\x90\xb8", "frog"}, {"\xf0\x9f\x90\x8a", "crocodile"}, {"\xf0\x9f\x90\xa2", "turtle"}, 
 954  			{"\xf0\x9f\xa6\x8e", "lizard"}, {"\xf0\x9f\x90\x8d", "snake"}, {"\xf0\x9f\x90\xb2", "dragon_face"}, 
 955  			{"\xf0\x9f\x90\x89", "dragon"}, {"\xf0\x9f\xa6\x95", "sauropod"}, {"\xf0\x9f\xa6\x96", "t-rex"}, 
 956  			{"\xf0\x9f\x90\xb3", "spouting_whale"}, {"\xf0\x9f\x90\x8b", "whale"}, {"\xf0\x9f\x90\xac", "dolphin"}, 
 957  			{"\xf0\x9f\xa6\xad", "seal"}, {"\xf0\x9f\x90\x9f", "fish"}, {"\xf0\x9f\x90\xa0", "tropical_fish"}, 
 958  			{"\xf0\x9f\x90\xa1", "blowfish"}, {"\xf0\x9f\xa6\x88", "shark"}, {"\xf0\x9f\x90\x99", "octopus"}, 
 959  			{"\xf0\x9f\x90\x9a", "spiral_shell"}, {"\xf0\x9f\xaa\xb8", "coral"}, {"\xf0\x9f\xaa\xbc", "jellyfish"}, 
 960  			{"\xf0\x9f\xa6\x80", "crab"}, {"\xf0\x9f\xa6\x9e", "lobster"}, {"\xf0\x9f\xa6\x90", "shrimp"}, 
 961  			{"\xf0\x9f\xa6\x91", "squid"}, {"\xf0\x9f\xa6\xaa", "oyster"}, {"\xf0\x9f\x90\x8c", "snail"}, 
 962  			{"\xf0\x9f\xa6\x8b", "butterfly"}, {"\xf0\x9f\x90\x9b", "bug"}, {"\xf0\x9f\x90\x9c", "ant"}, 
 963  			{"\xf0\x9f\x90\x9d", "honeybee"}, {"\xf0\x9f\x90\x9d", "bee"}, {"\xf0\x9f\xaa\xb2", "beetle"}, 
 964  			{"\xf0\x9f\x90\x9e", "lady_beetle"}, {"\xf0\x9f\xa6\x97", "cricket"}, {"\xf0\x9f\xaa\xb3", "cockroach"}, 
 965  			{"\xf0\x9f\x95\xb7\xef\xb8\x8f", "spider"}, {"\xf0\x9f\x95\xb8\xef\xb8\x8f", "spider_web"}, {"\xf0\x9f\xa6\x82", "scorpion"}, 
 966  			{"\xf0\x9f\xa6\x9f", "mosquito"}, {"\xf0\x9f\xaa\xb0", "fly"}, {"\xf0\x9f\xaa\xb1", "worm"}, 
 967  			{"\xf0\x9f\xa6\xa0", "microbe"}, {"\xf0\x9f\x92\x90", "bouquet"}, {"\xf0\x9f\x8c\xb8", "cherry_blossom"}, 
 968  			{"\xf0\x9f\x92\xae", "white_flower"}, {"\xf0\x9f\xaa\xb7", "lotus"}, {"\xf0\x9f\x8f\xb5\xef\xb8\x8f", "rosette"}, 
 969  			{"\xf0\x9f\x8c\xb9", "rose"}, {"\xf0\x9f\xa5\x80", "wilted_flower"}, {"\xf0\x9f\x8c\xba", "hibiscus"}, 
 970  			{"\xf0\x9f\x8c\xbb", "sunflower"}, {"\xf0\x9f\x8c\xbc", "blossom"}, {"\xf0\x9f\x8c\xb7", "tulip"}, 
 971  			{"\xf0\x9f\xaa\xbb", "hyacinth"}, {"\xf0\x9f\x8c\xb1", "seedling"}, {"\xf0\x9f\xaa\xb4", "potted_plant"}, 
 972  			{"\xf0\x9f\x8c\xb2", "evergreen_tree"}, {"\xf0\x9f\x8c\xb3", "deciduous_tree"}, {"\xf0\x9f\x8c\xb4", "palm_tree"}, 
 973  			{"\xf0\x9f\x8c\xb5", "cactus"}, {"\xf0\x9f\x8c\xbe", "sheaf_of_rice"}, {"\xf0\x9f\x8c\xbf", "herb"}, 
 974  			{"\xe2\x98\x98\xef\xb8\x8f", "shamrock"}, {"\xf0\x9f\x8d\x80", "four_leaf_clover"}, {"\xf0\x9f\x8d\x81", "maple_leaf"}, 
 975  			{"\xf0\x9f\x8d\x82", "fallen_leaf"}, {"\xf0\x9f\x8d\x83", "leaf_fluttering_in_wind"}, {"\xf0\x9f\xaa\xb9", "empty_nest"}, 
 976  			{"\xf0\x9f\xaa\xba", "nest_with_eggs"}, {"\xf0\x9f\x8d\x84", "mushroom"}, {"\xf0\x9f\xaa\xbe", "leafless_tree"}, 
 977  			{"\xf0\x9f\x8d\x87", "grapes"}, {"\xf0\x9f\x8d\x88", "melon"}, {"\xf0\x9f\x8d\x89", "watermelon"}, 
 978  			{"\xf0\x9f\x8d\x8a", "tangerine"}, {"\xf0\x9f\x8d\x8b", "lemon"}, {"\xf0\x9f\x8d\x8b\xe2\x80\x8d\xf0\x9f\x9f\xa9", "lime"}, 
 979  			{"\xf0\x9f\x8d\x8c", "banana"}, {"\xf0\x9f\x8d\x8d", "pineapple"}, {"\xf0\x9f\xa5\xad", "mango"}, 
 980  			{"\xf0\x9f\x8d\x8e", "red_apple"}, {"\xf0\x9f\x8d\x8f", "green_apple"}, {"\xf0\x9f\x8d\x90", "pear"}, 
 981  			{"\xf0\x9f\x8d\x91", "peach"}, {"\xf0\x9f\x8d\x92", "cherries"}, {"\xf0\x9f\x8d\x93", "strawberry"}, 
 982  			{"\xf0\x9f\xab\x90", "blueberries"}, {"\xf0\x9f\xa5\x9d", "kiwi_fruit"}, {"\xf0\x9f\x8d\x85", "tomato"}, 
 983  			{"\xf0\x9f\xab\x92", "olive"}, {"\xf0\x9f\xa5\xa5", "coconut"}, {"\xf0\x9f\xa5\x91", "avocado"}, 
 984  			{"\xf0\x9f\x8d\x86", "eggplant"}, {"\xf0\x9f\xa5\x94", "potato"}, {"\xf0\x9f\xa5\x95", "carrot"}, 
 985  			{"\xf0\x9f\x8c\xbd", "ear_of_corn"}, {"\xf0\x9f\x8c\xb6\xef\xb8\x8f", "hot_pepper"}, {"\xf0\x9f\xab\x91", "bell_pepper"}, 
 986  			{"\xf0\x9f\xa5\x92", "cucumber"}, {"\xf0\x9f\xa5\xac", "leafy_green"}, {"\xf0\x9f\xa5\xa6", "broccoli"}, 
 987  			{"\xf0\x9f\xa7\x84", "garlic"}, {"\xf0\x9f\xa7\x85", "onion"}, {"\xf0\x9f\xa5\x9c", "peanuts"}, 
 988  			{"\xf0\x9f\xab\x98", "beans"}, {"\xf0\x9f\x8c\xb0", "chestnut"}, {"\xf0\x9f\xab\x9a", "ginger_root"}, 
 989  			{"\xf0\x9f\xab\x9b", "pea_pod"}, {"\xf0\x9f\x8d\x84\xe2\x80\x8d\xf0\x9f\x9f\xab", "brown_mushroom"}, {"\xf0\x9f\xab\x9c", "root_vegetable"}, 
 990  			{"\xf0\x9f\x8d\x9e", "bread"}, {"\xf0\x9f\xa5\x90", "croissant"}, {"\xf0\x9f\xa5\x96", "baguette_bread"}, 
 991  			{"\xf0\x9f\xab\x93", "flatbread"}, {"\xf0\x9f\xa5\xa8", "pretzel"}, {"\xf0\x9f\xa5\xaf", "bagel"}, 
 992  			{"\xf0\x9f\xa5\x9e", "pancakes"}, {"\xf0\x9f\xa7\x87", "waffle"}, {"\xf0\x9f\xa7\x80", "cheese_wedge"}, 
 993  			{"\xf0\x9f\x8d\x96", "meat_on_bone"}, {"\xf0\x9f\x8d\x97", "poultry_leg"}, {"\xf0\x9f\xa5\xa9", "cut_of_meat"}, 
 994  			{"\xf0\x9f\xa5\x93", "bacon"}, {"\xf0\x9f\x8d\x94", "hamburger"}, {"\xf0\x9f\x8d\x9f", "french_fries"}, 
 995  			{"\xf0\x9f\x8d\x95", "pizza"}, {"\xf0\x9f\x8c\xad", "hot_dog"}, {"\xf0\x9f\xa5\xaa", "sandwich"}, 
 996  			{"\xf0\x9f\x8c\xae", "taco"}, {"\xf0\x9f\x8c\xaf", "burrito"}, {"\xf0\x9f\xab\x94", "tamale"}, 
 997  			{"\xf0\x9f\xa5\x99", "stuffed_flatbread"}, {"\xf0\x9f\xa7\x86", "falafel"}, {"\xf0\x9f\xa5\x9a", "egg"}, 
 998  			{"\xf0\x9f\x8d\xb3", "cooking"}, {"\xf0\x9f\xa5\x98", "shallow_pan_of_food"}, {"\xf0\x9f\x8d\xb2", "pot_of_food"}, 
 999  			{"\xf0\x9f\xab\x95", "fondue"}, {"\xf0\x9f\xa5\xa3", "bowl_with_spoon"}, {"\xf0\x9f\xa5\x97", "green_salad"}, 
1000  			{"\xf0\x9f\x8d\xbf", "popcorn"}, {"\xf0\x9f\xa7\x88", "butter"}, {"\xf0\x9f\xa7\x82", "salt"}, 
1001  			{"\xf0\x9f\xa5\xab", "canned_food"}, {"\xf0\x9f\x8d\xb1", "bento_box"}, {"\xf0\x9f\x8d\x98", "rice_cracker"}, 
1002  			{"\xf0\x9f\x8d\x99", "rice_ball"}, {"\xf0\x9f\x8d\x9a", "cooked_rice"}, {"\xf0\x9f\x8d\x9b", "curry_rice"}, 
1003  			{"\xf0\x9f\x8d\x9c", "steaming_bowl"}, {"\xf0\x9f\x8d\x9d", "spaghetti"}, {"\xf0\x9f\x8d\xa0", "roasted_sweet_potato"}, 
1004  			{"\xf0\x9f\x8d\xa2", "oden"}, {"\xf0\x9f\x8d\xa3", "sushi"}, {"\xf0\x9f\x8d\xa4", "fried_shrimp"}, 
1005  			{"\xf0\x9f\x8d\xa5", "fish_cake_with_swirl"}, {"\xf0\x9f\xa5\xae", "moon_cake"}, {"\xf0\x9f\x8d\xa1", "dango"}, 
1006  			{"\xf0\x9f\xa5\x9f", "dumpling"}, {"\xf0\x9f\xa5\xa0", "fortune_cookie"}, {"\xf0\x9f\xa5\xa1", "takeout_box"}, 
1007  			{"\xf0\x9f\x8d\xa6", "soft_ice_cream"}, {"\xf0\x9f\x8d\xa7", "shaved_ice"}, {"\xf0\x9f\x8d\xa8", "ice_cream"}, 
1008  			{"\xf0\x9f\x8d\xa9", "doughnut"}, {"\xf0\x9f\x8d\xaa", "cookie"}, {"\xf0\x9f\x8e\x82", "birthday_cake"}, 
1009  			{"\xf0\x9f\x8d\xb0", "shortcake"}, {"\xf0\x9f\xa7\x81", "cupcake"}, {"\xf0\x9f\xa5\xa7", "pie"}, 
1010  			{"\xf0\x9f\x8d\xab", "chocolate_bar"}, {"\xf0\x9f\x8d\xac", "candy"}, {"\xf0\x9f\x8d\xad", "lollipop"}, 
1011  			{"\xf0\x9f\x8d\xae", "custard"}, {"\xf0\x9f\x8d\xaf", "honey_pot"}, {"\xf0\x9f\x8d\xbc", "baby_bottle"}, 
1012  			{"\xf0\x9f\xa5\x9b", "glass_of_milk"}, {"\xe2\x98\x95", "hot_beverage"}, {"\xe2\x98\x95", "coffee"}, 
1013  			{"\xf0\x9f\xab\x96", "teapot"}, {"\xf0\x9f\x8d\xb5", "teacup_without_handle"}, {"\xf0\x9f\x8d\xb6", "sake"}, 
1014  			{"\xf0\x9f\x8d\xbe", "bottle_with_popping_cork"}, {"\xf0\x9f\x8d\xb7", "wine_glass"}, {"\xf0\x9f\x8d\xb8", "cocktail_glass"}, 
1015  			{"\xf0\x9f\x8d\xb9", "tropical_drink"}, {"\xf0\x9f\x8d\xba", "beer_mug"}, {"\xf0\x9f\x8d\xba", "beer"}, 
1016  			{"\xf0\x9f\x8d\xbb", "clinking_beer_mugs"}, {"\xf0\x9f\xa5\x82", "clinking_glasses"}, {"\xf0\x9f\xa5\x83", "tumbler_glass"}, 
1017  			{"\xf0\x9f\xab\x97", "pouring_liquid"}, {"\xf0\x9f\xa5\xa4", "cup_with_straw"}, {"\xf0\x9f\xa7\x8b", "bubble_tea"}, 
1018  			{"\xf0\x9f\xa7\x83", "beverage_box"}, {"\xf0\x9f\xa7\x89", "mate"}, {"\xf0\x9f\xa7\x8a", "ice"}, 
1019  			{"\xf0\x9f\xa5\xa2", "chopsticks"}, {"\xf0\x9f\x8d\xbd\xef\xb8\x8f", "fork_and_knife_with_plate"}, {"\xf0\x9f\x8d\xb4", "fork_and_knife"}, 
1020  			{"\xf0\x9f\xa5\x84", "spoon"}, {"\xf0\x9f\x94\xaa", "kitchen_knife"}, {"\xf0\x9f\xab\x99", "jar"}, 
1021  			{"\xf0\x9f\x8f\xba", "amphora"}, {"\xf0\x9f\x8c\x8d", "globe_showing_europe-africa"}, {"\xf0\x9f\x8c\x8d", "earth"}, 
1022  			{"\xf0\x9f\x8c\x8e", "globe_showing_americas"}, {"\xf0\x9f\x8c\x8f", "globe_showing_asia-australia"}, {"\xf0\x9f\x8c\x90", "globe_with_meridians"}, 
1023  			{"\xf0\x9f\x97\xba\xef\xb8\x8f", "world_map"}, {"\xf0\x9f\x97\xbe", "map_of_japan"}, {"\xf0\x9f\xa7\xad", "compass"}, 
1024  			{"\xf0\x9f\x8f\x94\xef\xb8\x8f", "snow-capped_mountain"}, {"\xe2\x9b\xb0\xef\xb8\x8f", "mountain"}, {"\xf0\x9f\x8c\x8b", "volcano"}, 
1025  			{"\xf0\x9f\x97\xbb", "mount_fuji"}, {"\xf0\x9f\x8f\x95\xef\xb8\x8f", "camping"}, {"\xf0\x9f\x8f\x96\xef\xb8\x8f", "beach_with_umbrella"}, 
1026  			{"\xf0\x9f\x8f\x9c\xef\xb8\x8f", "desert"}, {"\xf0\x9f\x8f\x9d\xef\xb8\x8f", "desert_island"}, {"\xf0\x9f\x8f\x9e\xef\xb8\x8f", "national_park"}, 
1027  			{"\xf0\x9f\x8f\x9f\xef\xb8\x8f", "stadium"}, {"\xf0\x9f\x8f\x9b\xef\xb8\x8f", "classical_building"}, {"\xf0\x9f\x8f\x97\xef\xb8\x8f", "building_construction"}, 
1028  			{"\xf0\x9f\xa7\xb1", "brick"}, {"\xf0\x9f\xaa\xa8", "rock"}, {"\xf0\x9f\xaa\xb5", "wood"}, 
1029  			{"\xf0\x9f\x9b\x96", "hut"}, {"\xf0\x9f\x8f\x98\xef\xb8\x8f", "houses"}, {"\xf0\x9f\x8f\x9a\xef\xb8\x8f", "derelict_house"}, 
1030  			{"\xf0\x9f\x8f\xa0", "house"}, {"\xf0\x9f\x8f\xa1", "house_with_garden"}, {"\xf0\x9f\x8f\xa2", "office_building"}, 
1031  			{"\xf0\x9f\x8f\xa3", "japanese_post_office"}, {"\xf0\x9f\x8f\xa4", "post_office"}, {"\xf0\x9f\x8f\xa5", "hospital"}, 
1032  			{"\xf0\x9f\x8f\xa6", "bank"}, {"\xf0\x9f\x8f\xa8", "hotel"}, {"\xf0\x9f\x8f\xa9", "love_hotel"}, 
1033  			{"\xf0\x9f\x8f\xaa", "convenience_store"}, {"\xf0\x9f\x8f\xab", "school"}, {"\xf0\x9f\x8f\xac", "department_store"}, 
1034  			{"\xf0\x9f\x8f\xad", "factory"}, {"\xf0\x9f\x8f\xaf", "japanese_castle"}, {"\xf0\x9f\x8f\xb0", "castle"}, 
1035  			{"\xf0\x9f\x92\x92", "wedding"}, {"\xf0\x9f\x97\xbc", "tokyo_tower"}, {"\xf0\x9f\x97\xbd", "statue_of_liberty"}, 
1036  			{"\xe2\x9b\xaa", "church"}, {"\xf0\x9f\x95\x8c", "mosque"}, {"\xf0\x9f\x9b\x95", "hindu_temple"}, 
1037  			{"\xf0\x9f\x95\x8d", "synagogue"}, {"\xe2\x9b\xa9\xef\xb8\x8f", "shinto_shrine"}, {"\xf0\x9f\x95\x8b", "kaaba"}, 
1038  			{"\xe2\x9b\xb2", "fountain"}, {"\xe2\x9b\xba", "tent"}, {"\xf0\x9f\x8c\x81", "foggy"}, 
1039  			{"\xf0\x9f\x8c\x83", "night_with_stars"}, {"\xf0\x9f\x8f\x99\xef\xb8\x8f", "cityscape"}, {"\xf0\x9f\x8c\x84", "sunrise_over_mountains"}, 
1040  			{"\xf0\x9f\x8c\x85", "sunrise"}, {"\xf0\x9f\x8c\x86", "cityscape_at_dusk"}, {"\xf0\x9f\x8c\x87", "sunset"}, 
1041  			{"\xf0\x9f\x8c\x89", "bridge_at_night"}, {"\xe2\x99\xa8\xef\xb8\x8f", "hot_springs"}, {"\xf0\x9f\x8e\xa0", "carousel_horse"}, 
1042  			{"\xf0\x9f\x9b\x9d", "playground_slide"}, {"\xf0\x9f\x8e\xa1", "ferris_wheel"}, {"\xf0\x9f\x8e\xa2", "roller_coaster"}, 
1043  			{"\xf0\x9f\x92\x88", "barber_pole"}, {"\xf0\x9f\x8e\xaa", "circus_tent"}, {"\xf0\x9f\x9a\x82", "locomotive"}, 
1044  			{"\xf0\x9f\x9a\x83", "railway_car"}, {"\xf0\x9f\x9a\x84", "high-speed_train"}, {"\xf0\x9f\x9a\x85", "bullet_train"}, 
1045  			{"\xf0\x9f\x9a\x86", "train"}, {"\xf0\x9f\x9a\x87", "metro"}, {"\xf0\x9f\x9a\x88", "light_rail"}, 
1046  			{"\xf0\x9f\x9a\x89", "station"}, {"\xf0\x9f\x9a\x8a", "tram"}, {"\xf0\x9f\x9a\x9d", "monorail"}, 
1047  			{"\xf0\x9f\x9a\x9e", "mountain_railway"}, {"\xf0\x9f\x9a\x8b", "tram_car"}, {"\xf0\x9f\x9a\x8c", "bus"}, 
1048  			{"\xf0\x9f\x9a\x8d", "oncoming_bus"}, {"\xf0\x9f\x9a\x8e", "trolleybus"}, {"\xf0\x9f\x9a\x90", "minibus"}, 
1049  			{"\xf0\x9f\x9a\x91", "ambulance"}, {"\xf0\x9f\x9a\x92", "fire_engine"}, {"\xf0\x9f\x9a\x93", "police_car"}, 
1050  			{"\xf0\x9f\x9a\x94", "oncoming_police_car"}, {"\xf0\x9f\x9a\x95", "taxi"}, {"\xf0\x9f\x9a\x96", "oncoming_taxi"}, 
1051  			{"\xf0\x9f\x9a\x97", "automobile"}, {"\xf0\x9f\x9a\x98", "oncoming_automobile"}, {"\xf0\x9f\x9a\x99", "sport_utility_vehicle"}, 
1052  			{"\xf0\x9f\x9b\xbb", "pickup_truck"}, {"\xf0\x9f\x9a\x9a", "delivery_truck"}, {"\xf0\x9f\x9a\x9b", "articulated_lorry"}, 
1053  			{"\xf0\x9f\x9a\x9c", "tractor"}, {"\xf0\x9f\x8f\x8e\xef\xb8\x8f", "racing_car"}, {"\xf0\x9f\x8f\x8d\xef\xb8\x8f", "motorcycle"}, 
1054  			{"\xf0\x9f\x9b\xb5", "motor_scooter"}, {"\xf0\x9f\xa6\xbd", "manual_wheelchair"}, {"\xf0\x9f\xa6\xbc", "motorized_wheelchair"}, 
1055  			{"\xf0\x9f\x9b\xba", "auto_rickshaw"}, {"\xf0\x9f\x9a\xb2", "bicycle"}, {"\xf0\x9f\x9b\xb4", "kick_scooter"}, 
1056  			{"\xf0\x9f\x9b\xb9", "skateboard"}, {"\xf0\x9f\x9b\xbc", "roller_skate"}, {"\xf0\x9f\x9a\x8f", "bus_stop"}, 
1057  			{"\xf0\x9f\x9b\xa3\xef\xb8\x8f", "motorway"}, {"\xf0\x9f\x9b\xa4\xef\xb8\x8f", "railway_track"}, {"\xf0\x9f\x9b\xa2\xef\xb8\x8f", "oil_drum"}, 
1058  			{"\xe2\x9b\xbd", "fuel_pump"}, {"\xf0\x9f\x9b\x9e", "wheel"}, {"\xf0\x9f\x9a\xa8", "police_car_light"}, 
1059  			{"\xf0\x9f\x9a\xa5", "horizontal_traffic_light"}, {"\xf0\x9f\x9a\xa6", "vertical_traffic_light"}, {"\xf0\x9f\x9b\x91", "stop_sign"}, 
1060  			{"\xf0\x9f\x9a\xa7", "construction"}, {"\xe2\x9a\x93", "anchor"}, {"\xf0\x9f\x9b\x9f", "ring_buoy"}, 
1061  			{"\xe2\x9b\xb5", "sailboat"}, {"\xf0\x9f\x9b\xb6", "canoe"}, {"\xf0\x9f\x9a\xa4", "speedboat"}, 
1062  			{"\xf0\x9f\x9b\xb3\xef\xb8\x8f", "passenger_ship"}, {"\xe2\x9b\xb4\xef\xb8\x8f", "ferry"}, {"\xf0\x9f\x9b\xa5\xef\xb8\x8f", "motor_boat"}, 
1063  			{"\xf0\x9f\x9a\xa2", "ship"}, {"\xe2\x9c\x88\xef\xb8\x8f", "airplane"}, {"\xf0\x9f\x9b\xa9\xef\xb8\x8f", "small_airplane"}, 
1064  			{"\xf0\x9f\x9b\xab", "airplane_departure"}, {"\xf0\x9f\x9b\xac", "airplane_arrival"}, {"\xf0\x9f\xaa\x82", "parachute"}, 
1065  			{"\xf0\x9f\x92\xba", "seat"}, {"\xf0\x9f\x9a\x81", "helicopter"}, {"\xf0\x9f\x9a\x9f", "suspension_railway"}, 
1066  			{"\xf0\x9f\x9a\xa0", "mountain_cableway"}, {"\xf0\x9f\x9a\xa1", "aerial_tramway"}, {"\xf0\x9f\x9b\xb0\xef\xb8\x8f", "satellite"}, 
1067  			{"\xf0\x9f\x9a\x80", "rocket"}, {"\xf0\x9f\x9b\xb8", "flying_saucer"}, {"\xf0\x9f\x9b\xb8", "ufo"}, 
1068  			{"\xf0\x9f\x9b\x8e\xef\xb8\x8f", "bellhop_bell"}, {"\xf0\x9f\xa7\xb3", "luggage"}, {"\xe2\x8c\x9b", "hourglass_done"}, 
1069  			{"\xe2\x8f\xb3", "hourglass_not_done"}, {"\xe2\x8c\x9a", "watch"}, {"\xe2\x8f\xb0", "alarm_clock"}, 
1070  			{"\xe2\x8f\xb1\xef\xb8\x8f", "stopwatch"}, {"\xe2\x8f\xb2\xef\xb8\x8f", "timer_clock"}, {"\xf0\x9f\x95\xb0\xef\xb8\x8f", "mantelpiece_clock"}, 
1071  			{"\xf0\x9f\x95\x9b", "twelve_oclock"}, {"\xf0\x9f\x95\xa7", "twelve-thirty"}, {"\xf0\x9f\x95\x90", "one_oclock"}, 
1072  			{"\xf0\x9f\x95\x9c", "one-thirty"}, {"\xf0\x9f\x95\x91", "two_oclock"}, {"\xf0\x9f\x95\x9d", "two-thirty"}, 
1073  			{"\xf0\x9f\x95\x92", "three_oclock"}, {"\xf0\x9f\x95\x9e", "three-thirty"}, {"\xf0\x9f\x95\x93", "four_oclock"}, 
1074  			{"\xf0\x9f\x95\x9f", "four-thirty"}, {"\xf0\x9f\x95\x94", "five_oclock"}, {"\xf0\x9f\x95\xa0", "five-thirty"}, 
1075  			{"\xf0\x9f\x95\x95", "six_oclock"}, {"\xf0\x9f\x95\xa1", "six-thirty"}, {"\xf0\x9f\x95\x96", "seven_oclock"}, 
1076  			{"\xf0\x9f\x95\xa2", "seven-thirty"}, {"\xf0\x9f\x95\x97", "eight_oclock"}, {"\xf0\x9f\x95\xa3", "eight-thirty"}, 
1077  			{"\xf0\x9f\x95\x98", "nine_oclock"}, {"\xf0\x9f\x95\xa4", "nine-thirty"}, {"\xf0\x9f\x95\x99", "ten_oclock"}, 
1078  			{"\xf0\x9f\x95\xa5", "ten-thirty"}, {"\xf0\x9f\x95\x9a", "eleven_oclock"}, {"\xf0\x9f\x95\xa6", "eleven-thirty"}, 
1079  			{"\xf0\x9f\x8c\x91", "new_moon"}, {"\xf0\x9f\x8c\x92", "waxing_crescent_moon"}, {"\xf0\x9f\x8c\x93", "first_quarter_moon"}, 
1080  			{"\xf0\x9f\x8c\x94", "waxing_gibbous_moon"}, {"\xf0\x9f\x8c\x95", "full_moon"}, {"\xf0\x9f\x8c\x96", "waning_gibbous_moon"}, 
1081  			{"\xf0\x9f\x8c\x97", "last_quarter_moon"}, {"\xf0\x9f\x8c\x98", "waning_crescent_moon"}, {"\xf0\x9f\x8c\x99", "crescent_moon"}, 
1082  			{"\xf0\x9f\x8c\x9a", "new_moon_face"}, {"\xf0\x9f\x8c\x9b", "first_quarter_moon_face"}, {"\xf0\x9f\x8c\x9c", "last_quarter_moon_face"}, 
1083  			{"\xf0\x9f\x8c\xa1\xef\xb8\x8f", "thermometer"}, {"\xe2\x98\x80\xef\xb8\x8f", "sun"}, {"\xf0\x9f\x8c\x9d", "full_moon_face"}, 
1084  			{"\xf0\x9f\x8c\x9e", "sun_with_face"}, {"\xf0\x9f\xaa\x90", "ringed_planet"}, {"\xe2\xad\x90", "star"}, 
1085  			{"\xf0\x9f\x8c\x9f", "glowing_star"}, {"\xf0\x9f\x8c\x9f", "star2"}, {"\xf0\x9f\x8c\xa0", "shooting_star"}, 
1086  			{"\xf0\x9f\x8c\x8c", "milky_way"}, {"\xe2\x98\x81\xef\xb8\x8f", "cloud"}, {"\xe2\x9b\x85", "sun_behind_cloud"}, 
1087  			{"\xe2\x9b\x88\xef\xb8\x8f", "cloud_with_lightning_and_rain"}, {"\xf0\x9f\x8c\xa4\xef\xb8\x8f", "sun_behind_small_cloud"}, {"\xf0\x9f\x8c\xa5\xef\xb8\x8f", "sun_behind_large_cloud"}, 
1088  			{"\xf0\x9f\x8c\xa6\xef\xb8\x8f", "sun_behind_rain_cloud"}, {"\xf0\x9f\x8c\xa7\xef\xb8\x8f", "cloud_with_rain"}, {"\xf0\x9f\x8c\xa8\xef\xb8\x8f", "cloud_with_snow"}, 
1089  			{"\xf0\x9f\x8c\xa9\xef\xb8\x8f", "cloud_with_lightning"}, {"\xf0\x9f\x8c\xaa\xef\xb8\x8f", "tornado"}, {"\xf0\x9f\x8c\xab\xef\xb8\x8f", "fog"}, 
1090  			{"\xf0\x9f\x8c\xac\xef\xb8\x8f", "wind_face"}, {"\xf0\x9f\x8c\x80", "cyclone"}, {"\xf0\x9f\x8c\x88", "rainbow"}, 
1091  			{"\xf0\x9f\x8c\x82", "closed_umbrella"}, {"\xe2\x98\x82\xef\xb8\x8f", "umbrella"}, {"\xe2\x98\x94", "umbrella_with_rain_drops"}, 
1092  			{"\xe2\x9b\xb1\xef\xb8\x8f", "umbrella_on_ground"}, {"\xe2\x9a\xa1", "high_voltage"}, {"\xe2\x9a\xa1", "zap"}, 
1093  			{"\xe2\x9d\x84\xef\xb8\x8f", "snowflake"}, {"\xe2\x98\x83\xef\xb8\x8f", "snowman"}, {"\xe2\x9b\x84", "snowman_without_snow"}, 
1094  			{"\xe2\x98\x84\xef\xb8\x8f", "comet"}, {"\xf0\x9f\x94\xa5", "fire"}, {"\xf0\x9f\x92\xa7", "droplet"}, 
1095  			{"\xf0\x9f\x8c\x8a", "water_wave"}, {"\xf0\x9f\x8c\x8a", "wave"}, {"\xf0\x9f\x8e\x83", "jack-o-lantern"}, 
1096  			{"\xf0\x9f\x8e\x84", "christmas_tree"}, {"\xf0\x9f\x8e\x86", "fireworks"}, {"\xf0\x9f\x8e\x87", "sparkler"}, 
1097  			{"\xf0\x9f\xa7\xa8", "firecracker"}, {"\xe2\x9c\xa8", "sparkles"}, {"\xf0\x9f\x8e\x88", "balloon"}, 
1098  			{"\xf0\x9f\x8e\x89", "party_popper"}, {"\xf0\x9f\x8e\x89", "tada"}, {"\xf0\x9f\x8e\x8a", "confetti_ball"}, 
1099  			{"\xf0\x9f\x8e\x8a", "confetti"}, {"\xf0\x9f\x8e\x8b", "tanabata_tree"}, {"\xf0\x9f\x8e\x8d", "pine_decoration"}, 
1100  			{"\xf0\x9f\x8e\x8e", "japanese_dolls"}, {"\xf0\x9f\x8e\x8f", "carp_streamer"}, {"\xf0\x9f\x8e\x90", "wind_chime"}, 
1101  			{"\xf0\x9f\x8e\x91", "moon_viewing_ceremony"}, {"\xf0\x9f\xa7\xa7", "red_envelope"}, {"\xf0\x9f\x8e\x80", "ribbon"}, 
1102  			{"\xf0\x9f\x8e\x81", "wrapped_gift"}, {"\xf0\x9f\x8e\x97\xef\xb8\x8f", "reminder_ribbon"}, {"\xf0\x9f\x8e\x9f\xef\xb8\x8f", "admission_tickets"}, 
1103  			{"\xf0\x9f\x8e\xab", "ticket"}, {"\xf0\x9f\x8e\x96\xef\xb8\x8f", "military_medal"}, {"\xf0\x9f\x8f\x86", "trophy"}, 
1104  			{"\xf0\x9f\x8f\x85", "sports_medal"}, {"\xf0\x9f\xa5\x87", "1st_place_medal"}, {"\xf0\x9f\xa5\x88", "2nd_place_medal"}, 
1105  			{"\xf0\x9f\xa5\x89", "3rd_place_medal"}, {"\xe2\x9a\xbd", "soccer_ball"}, {"\xe2\x9a\xbe", "baseball"}, 
1106  			{"\xf0\x9f\xa5\x8e", "softball"}, {"\xf0\x9f\x8f\x80", "basketball"}, {"\xf0\x9f\x8f\x90", "volleyball"}, 
1107  			{"\xf0\x9f\x8f\x88", "american_football"}, {"\xf0\x9f\x8f\x89", "rugby_football"}, {"\xf0\x9f\x8e\xbe", "tennis"}, 
1108  			{"\xf0\x9f\xa5\x8f", "flying_disc"}, {"\xf0\x9f\x8e\xb3", "bowling"}, {"\xf0\x9f\x8f\x8f", "cricket_game"}, 
1109  			{"\xf0\x9f\x8f\x91", "field_hockey"}, {"\xf0\x9f\x8f\x92", "ice_hockey"}, {"\xf0\x9f\xa5\x8d", "lacrosse"}, 
1110  			{"\xf0\x9f\x8f\x93", "ping_pong"}, {"\xf0\x9f\x8f\xb8", "badminton"}, {"\xf0\x9f\xa5\x8a", "boxing_glove"}, 
1111  			{"\xf0\x9f\xa5\x8b", "martial_arts_uniform"}, {"\xf0\x9f\xa5\x85", "goal_net"}, {"\xe2\x9b\xb3", "flag_in_hole"}, 
1112  			{"\xe2\x9b\xb8\xef\xb8\x8f", "ice_skate"}, {"\xf0\x9f\x8e\xa3", "fishing_pole"}, {"\xf0\x9f\xa4\xbf", "diving_mask"}, 
1113  			{"\xf0\x9f\x8e\xbd", "running_shirt"}, {"\xf0\x9f\x8e\xbf", "skis"}, {"\xf0\x9f\x9b\xb7", "sled"}, 
1114  			{"\xf0\x9f\xa5\x8c", "curling_stone"}, {"\xf0\x9f\x8e\xaf", "bullseye"}, {"\xf0\x9f\x8e\xaf", "dart"}, 
1115  			{"\xf0\x9f\xaa\x80", "yo-yo"}, {"\xf0\x9f\xaa\x81", "kite"}, {"\xf0\x9f\x94\xab", "water_pistol"}, 
1116  			{"\xf0\x9f\x8e\xb1", "pool_8_ball"}, {"\xf0\x9f\x94\xae", "crystal_ball"}, {"\xf0\x9f\xaa\x84", "magic_wand"}, 
1117  			{"\xf0\x9f\x8e\xae", "video_game"}, {"\xf0\x9f\x95\xb9\xef\xb8\x8f", "joystick"}, {"\xf0\x9f\x8e\xb0", "slot_machine"}, 
1118  			{"\xf0\x9f\x8e\xb2", "game_die"}, {"\xf0\x9f\xa7\xa9", "puzzle_piece"}, {"\xf0\x9f\xa7\xb8", "teddy_bear"}, 
1119  			{"\xf0\x9f\xaa\x85", "piata"}, {"\xf0\x9f\xaa\xa9", "mirror_ball"}, {"\xf0\x9f\xaa\x86", "nesting_dolls"}, 
1120  			{"\xe2\x99\xa0\xef\xb8\x8f", "spade_suit"}, {"\xe2\x99\xa5\xef\xb8\x8f", "heart_suit"}, {"\xe2\x99\xa6\xef\xb8\x8f", "diamond_suit"}, 
1121  			{"\xe2\x99\xa3\xef\xb8\x8f", "club_suit"}, {"\xe2\x99\x9f\xef\xb8\x8f", "chess_pawn"}, {"\xf0\x9f\x83\x8f", "joker"}, 
1122  			{"\xf0\x9f\x80\x84", "mahjong_red_dragon"}, {"\xf0\x9f\x8e\xb4", "flower_playing_cards"}, {"\xf0\x9f\x8e\xad", "performing_arts"}, 
1123  			{"\xf0\x9f\x96\xbc\xef\xb8\x8f", "framed_picture"}, {"\xf0\x9f\x8e\xa8", "artist_palette"}, {"\xf0\x9f\xa7\xb5", "thread"}, 
1124  			{"\xf0\x9f\xaa\xa1", "sewing_needle"}, {"\xf0\x9f\xa7\xb6", "yarn"}, {"\xf0\x9f\xaa\xa2", "knot"}, 
1125  			{"\xf0\x9f\x91\x93", "glasses"}, {"\xf0\x9f\xa5\xbd", "goggles"}, {"\xf0\x9f\xa5\xbc", "lab_coat"}, 
1126  			{"\xf0\x9f\xa6\xba", "safety_vest"}, {"\xf0\x9f\x91\x94", "necktie"}, {"\xf0\x9f\x91\x95", "t-shirt"}, 
1127  			{"\xf0\x9f\x91\x96", "jeans"}, {"\xf0\x9f\xa7\xa3", "scarf"}, {"\xf0\x9f\xa7\xa4", "gloves"}, 
1128  			{"\xf0\x9f\xa7\xa5", "coat"}, {"\xf0\x9f\xa7\xa6", "socks"}, {"\xf0\x9f\x91\x97", "dress"}, 
1129  			{"\xf0\x9f\x91\x98", "kimono"}, {"\xf0\x9f\xa5\xbb", "sari"}, {"\xf0\x9f\xa9\xb1", "one-piece_swimsuit"}, 
1130  			{"\xf0\x9f\xa9\xb2", "briefs"}, {"\xf0\x9f\xa9\xb3", "shorts"}, {"\xf0\x9f\x91\x99", "bikini"}, 
1131  			{"\xf0\x9f\x91\x9a", "womans_clothes"}, {"\xf0\x9f\xaa\xad", "folding_hand_fan"}, {"\xf0\x9f\x91\x9b", "purse"}, 
1132  			{"\xf0\x9f\x91\x9c", "handbag"}, {"\xf0\x9f\x91\x9d", "clutch_bag"}, {"\xf0\x9f\x9b\x8d\xef\xb8\x8f", "shopping_bags"}, 
1133  			{"\xf0\x9f\x8e\x92", "backpack"}, {"\xf0\x9f\xa9\xb4", "thong_sandal"}, {"\xf0\x9f\x91\x9e", "mans_shoe"}, 
1134  			{"\xf0\x9f\x91\x9f", "running_shoe"}, {"\xf0\x9f\xa5\xbe", "hiking_boot"}, {"\xf0\x9f\xa5\xbf", "flat_shoe"}, 
1135  			{"\xf0\x9f\x91\xa0", "high-heeled_shoe"}, {"\xf0\x9f\x91\xa1", "womans_sandal"}, {"\xf0\x9f\xa9\xb0", "ballet_shoes"}, 
1136  			{"\xf0\x9f\x91\xa2", "womans_boot"}, {"\xf0\x9f\xaa\xae", "hair_pick"}, {"\xf0\x9f\x91\x91", "crown"}, 
1137  			{"\xf0\x9f\x91\x92", "womans_hat"}, {"\xf0\x9f\x8e\xa9", "top_hat"}, {"\xf0\x9f\x8e\x93", "graduation_cap"}, 
1138  			{"\xf0\x9f\xa7\xa2", "billed_cap"}, {"\xf0\x9f\xaa\x96", "military_helmet"}, {"\xe2\x9b\x91\xef\xb8\x8f", "rescue_workers_helmet"}, 
1139  			{"\xf0\x9f\x93\xbf", "prayer_beads"}, {"\xf0\x9f\x92\x84", "lipstick"}, {"\xf0\x9f\x92\x8d", "ring"}, 
1140  			{"\xf0\x9f\x92\x8e", "gem_stone"}, {"\xf0\x9f\x92\x8e", "gem"}, {"\xf0\x9f\x94\x87", "muted_speaker"}, 
1141  			{"\xf0\x9f\x94\x88", "speaker_low_volume"}, {"\xf0\x9f\x94\x89", "speaker_medium_volume"}, {"\xf0\x9f\x94\x8a", "speaker_high_volume"}, 
1142  			{"\xf0\x9f\x93\xa2", "loudspeaker"}, {"\xf0\x9f\x93\xa3", "megaphone"}, {"\xf0\x9f\x93\xaf", "postal_horn"}, 
1143  			{"\xf0\x9f\x94\x94", "bell"}, {"\xf0\x9f\x94\x95", "bell_with_slash"}, {"\xf0\x9f\x8e\xbc", "musical_score"}, 
1144  			{"\xf0\x9f\x8e\xb5", "musical_note"}, {"\xf0\x9f\x8e\xb5", "notes"}, {"\xf0\x9f\x8e\xb6", "musical_notes"}, 
1145  			{"\xf0\x9f\x8e\x99\xef\xb8\x8f", "studio_microphone"}, {"\xf0\x9f\x8e\x9a\xef\xb8\x8f", "level_slider"}, {"\xf0\x9f\x8e\x9b\xef\xb8\x8f", "control_knobs"}, 
1146  			{"\xf0\x9f\x8e\xa4", "microphone"}, {"\xf0\x9f\x8e\xa7", "headphone"}, {"\xf0\x9f\x93\xbb", "radio"}, 
1147  			{"\xf0\x9f\x8e\xb7", "saxophone"}, {"\xf0\x9f\xaa\x97", "accordion"}, {"\xf0\x9f\x8e\xb8", "guitar"}, 
1148  			{"\xf0\x9f\x8e\xb9", "musical_keyboard"}, {"\xf0\x9f\x8e\xba", "trumpet"}, {"\xf0\x9f\x8e\xbb", "violin"}, 
1149  			{"\xf0\x9f\xaa\x95", "banjo"}, {"\xf0\x9f\xa5\x81", "drum"}, {"\xf0\x9f\xaa\x98", "long_drum"}, 
1150  			{"\xf0\x9f\xaa\x87", "maracas"}, {"\xf0\x9f\xaa\x88", "flute"}, {"\xf0\x9f\xaa\x89", "harp"}, 
1151  			{"\xf0\x9f\x93\xb1", "mobile_phone"}, {"\xf0\x9f\x93\xb2", "mobile_phone_with_arrow"}, {"\xe2\x98\x8e\xef\xb8\x8f", "telephone"}, 
1152  			{"\xf0\x9f\x93\x9e", "telephone_receiver"}, {"\xf0\x9f\x93\x9f", "pager"}, {"\xf0\x9f\x93\xa0", "fax_machine"}, 
1153  			{"\xf0\x9f\x94\x8b", "battery"}, {"\xf0\x9f\xaa\xab", "low_battery"}, {"\xf0\x9f\x94\x8c", "electric_plug"}, 
1154  			{"\xf0\x9f\x92\xbb", "laptop"}, {"\xf0\x9f\x96\xa5\xef\xb8\x8f", "desktop_computer"}, {"\xf0\x9f\x96\xa5\xef\xb8\x8f", "desktop"}, 
1155  			{"\xf0\x9f\x96\xa8\xef\xb8\x8f", "printer"}, {"\xe2\x8c\xa8\xef\xb8\x8f", "keyboard"}, {"\xf0\x9f\x96\xb1\xef\xb8\x8f", "computer_mouse"}, 
1156  			{"\xf0\x9f\x96\xb2\xef\xb8\x8f", "trackball"}, {"\xf0\x9f\x92\xbd", "computer_disk"}, {"\xf0\x9f\x92\xbe", "floppy_disk"}, 
1157  			{"\xf0\x9f\x92\xbf", "optical_disk"}, {"\xf0\x9f\x93\x80", "dvd"}, {"\xf0\x9f\xa7\xae", "abacus"}, 
1158  			{"\xf0\x9f\x8e\xa5", "movie_camera"}, {"\xf0\x9f\x8e\x9e\xef\xb8\x8f", "film_frames"}, {"\xf0\x9f\x93\xbd\xef\xb8\x8f", "film_projector"}, 
1159  			{"\xf0\x9f\x8e\xac", "clapper_board"}, {"\xf0\x9f\x8e\xac", "clapper"}, {"\xf0\x9f\x93\xba", "television"}, 
1160  			{"\xf0\x9f\x93\xb7", "camera"}, {"\xf0\x9f\x93\xb8", "camera_with_flash"}, {"\xf0\x9f\x93\xb9", "video_camera"}, 
1161  			{"\xf0\x9f\x93\xbc", "videocassette"}, {"\xf0\x9f\x94\x8d", "magnifying_glass_tilted_left"}, {"\xf0\x9f\x94\x8e", "magnifying_glass_tilted_right"}, 
1162  			{"\xf0\x9f\x95\xaf\xef\xb8\x8f", "candle"}, {"\xf0\x9f\x92\xa1", "light_bulb"}, {"\xf0\x9f\x92\xa1", "bulb"}, 
1163  			{"\xf0\x9f\x94\xa6", "flashlight"}, {"\xf0\x9f\x8f\xae", "red_paper_lantern"}, {"\xf0\x9f\xaa\x94", "diya_lamp"}, 
1164  			{"\xf0\x9f\x93\x94", "notebook_with_decorative_cover"}, {"\xf0\x9f\x93\x95", "closed_book"}, {"\xf0\x9f\x93\x96", "open_book"}, 
1165  			{"\xf0\x9f\x93\x96", "book"}, {"\xf0\x9f\x93\x97", "green_book"}, {"\xf0\x9f\x93\x98", "blue_book"}, 
1166  			{"\xf0\x9f\x93\x99", "orange_book"}, {"\xf0\x9f\x93\x9a", "books"}, {"\xf0\x9f\x93\x93", "notebook"}, 
1167  			{"\xf0\x9f\x93\x92", "ledger"}, {"\xf0\x9f\x93\x83", "page_with_curl"}, {"\xf0\x9f\x93\x9c", "scroll"}, 
1168  			{"\xf0\x9f\x93\x84", "page_facing_up"}, {"\xf0\x9f\x93\xb0", "newspaper"}, {"\xf0\x9f\x97\x9e\xef\xb8\x8f", "rolled-up_newspaper"}, 
1169  			{"\xf0\x9f\x93\x91", "bookmark_tabs"}, {"\xf0\x9f\x94\x96", "bookmark"}, {"\xf0\x9f\x8f\xb7\xef\xb8\x8f", "label"}, 
1170  			{"\xf0\x9f\x92\xb0", "money_bag"}, {"\xf0\x9f\xaa\x99", "coin"}, {"\xf0\x9f\x92\xb4", "yen_banknote"}, 
1171  			{"\xf0\x9f\x92\xb5", "dollar_banknote"}, {"\xf0\x9f\x92\xb6", "euro_banknote"}, {"\xf0\x9f\x92\xb7", "pound_banknote"}, 
1172  			{"\xf0\x9f\x92\xb8", "money_with_wings"}, {"\xf0\x9f\x92\xb3", "credit_card"}, {"\xf0\x9f\xa7\xbe", "receipt"}, 
1173  			{"\xf0\x9f\x92\xb9", "chart_increasing_with_yen"}, {"\xe2\x9c\x89\xef\xb8\x8f", "envelope"}, {"\xf0\x9f\x93\xa7", "e-mail"}, 
1174  			{"\xf0\x9f\x93\xa8", "incoming_envelope"}, {"\xf0\x9f\x93\xa9", "envelope_with_arrow"}, {"\xf0\x9f\x93\xa4", "outbox_tray"}, 
1175  			{"\xf0\x9f\x93\xa5", "inbox_tray"}, {"\xf0\x9f\x93\xa6", "package"}, {"\xf0\x9f\x93\xab", "closed_mailbox_with_raised_flag"}, 
1176  			{"\xf0\x9f\x93\xaa", "closed_mailbox_with_lowered_flag"}, {"\xf0\x9f\x93\xac", "open_mailbox_with_raised_flag"}, {"\xf0\x9f\x93\xad", "open_mailbox_with_lowered_flag"}, 
1177  			{"\xf0\x9f\x93\xae", "postbox"}, {"\xf0\x9f\x97\xb3\xef\xb8\x8f", "ballot_box_with_ballot"}, {"\xe2\x9c\x8f\xef\xb8\x8f", "pencil"}, 
1178  			{"\xe2\x9c\x92\xef\xb8\x8f", "black_nib"}, {"\xf0\x9f\x96\x8b\xef\xb8\x8f", "fountain_pen"}, {"\xf0\x9f\x96\x8a\xef\xb8\x8f", "pen"}, 
1179  			{"\xf0\x9f\x96\x8c\xef\xb8\x8f", "paintbrush"}, {"\xf0\x9f\x96\x8d\xef\xb8\x8f", "crayon"}, {"\xf0\x9f\x93\x9d", "memo"}, 
1180  			{"\xf0\x9f\x92\xbc", "briefcase"}, {"\xf0\x9f\x93\x81", "file_folder"}, {"\xf0\x9f\x93\x82", "open_file_folder"}, 
1181  			{"\xf0\x9f\x97\x82\xef\xb8\x8f", "card_index_dividers"}, {"\xf0\x9f\x93\x85", "calendar"}, {"\xf0\x9f\x93\x86", "tear-off_calendar"}, 
1182  			{"\xf0\x9f\x97\x92\xef\xb8\x8f", "spiral_notepad"}, {"\xf0\x9f\x97\x93\xef\xb8\x8f", "spiral_calendar"}, {"\xf0\x9f\x93\x87", "card_index"}, 
1183  			{"\xf0\x9f\x93\x88", "chart_increasing"}, {"\xf0\x9f\x93\x89", "chart_decreasing"}, {"\xf0\x9f\x93\x8a", "bar_chart"}, 
1184  			{"\xf0\x9f\x93\x8b", "clipboard"}, {"\xf0\x9f\x93\x8c", "pushpin"}, {"\xf0\x9f\x93\x8d", "round_pushpin"}, 
1185  			{"\xf0\x9f\x93\x8e", "paperclip"}, {"\xf0\x9f\x96\x87\xef\xb8\x8f", "linked_paperclips"}, {"\xf0\x9f\x93\x8f", "straight_ruler"}, 
1186  			{"\xf0\x9f\x93\x90", "triangular_ruler"}, {"\xe2\x9c\x82\xef\xb8\x8f", "scissors"}, {"\xf0\x9f\x97\x83\xef\xb8\x8f", "card_file_box"}, 
1187  			{"\xf0\x9f\x97\x84\xef\xb8\x8f", "file_cabinet"}, {"\xf0\x9f\x97\x91\xef\xb8\x8f", "wastebasket"}, {"\xf0\x9f\x94\x92", "locked"}, 
1188  			{"\xf0\x9f\x94\x92", "lock"}, {"\xf0\x9f\x94\x93", "unlocked"}, {"\xf0\x9f\x94\x93", "unlock"}, 
1189  			{"\xf0\x9f\x94\x8f", "locked_with_pen"}, {"\xf0\x9f\x94\x90", "locked_with_key"}, {"\xf0\x9f\x94\x91", "key"}, 
1190  			{"\xf0\x9f\x97\x9d\xef\xb8\x8f", "old_key"}, {"\xf0\x9f\x94\xa8", "hammer"}, {"\xf0\x9f\xaa\x93", "axe"}, 
1191  			{"\xe2\x9b\x8f\xef\xb8\x8f", "pick"}, {"\xe2\x9a\x92\xef\xb8\x8f", "hammer_and_pick"}, {"\xf0\x9f\x9b\xa0\xef\xb8\x8f", "hammer_and_wrench"}, 
1192  			{"\xf0\x9f\x97\xa1\xef\xb8\x8f", "dagger"}, {"\xe2\x9a\x94\xef\xb8\x8f", "crossed_swords"}, {"\xf0\x9f\x92\xa3", "bomb"}, 
1193  			{"\xf0\x9f\xaa\x83", "boomerang"}, {"\xf0\x9f\x8f\xb9", "bow_and_arrow"}, {"\xf0\x9f\x9b\xa1\xef\xb8\x8f", "shield"}, 
1194  			{"\xf0\x9f\xaa\x9a", "carpentry_saw"}, {"\xf0\x9f\x94\xa7", "wrench"}, {"\xf0\x9f\xaa\x9b", "screwdriver"}, 
1195  			{"\xf0\x9f\x94\xa9", "nut_and_bolt"}, {"\xe2\x9a\x99\xef\xb8\x8f", "gear"}, {"\xf0\x9f\x97\x9c\xef\xb8\x8f", "clamp"}, 
1196  			{"\xe2\x9a\x96\xef\xb8\x8f", "balance_scale"}, {"\xf0\x9f\xa6\xaf", "white_cane"}, {"\xf0\x9f\x94\x97", "link"}, 
1197  			{"\xe2\x9b\x93\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x92\xa5", "broken_chain"}, {"\xe2\x9b\x93\xef\xb8\x8f", "chains"}, {"\xf0\x9f\xaa\x9d", "hook"}, 
1198  			{"\xf0\x9f\xa7\xb0", "toolbox"}, {"\xf0\x9f\xa7\xb2", "magnet"}, {"\xf0\x9f\xaa\x9c", "ladder"}, 
1199  			{"\xf0\x9f\xaa\x8f", "shovel"}, {"\xe2\x9a\x97\xef\xb8\x8f", "alembic"}, {"\xf0\x9f\xa7\xaa", "test_tube"}, 
1200  			{"\xf0\x9f\xa7\xab", "petri_dish"}, {"\xf0\x9f\xa7\xac", "dna"}, {"\xf0\x9f\x94\xac", "microscope"}, 
1201  			{"\xf0\x9f\x94\xad", "telescope"}, {"\xf0\x9f\x93\xa1", "satellite_antenna"}, {"\xf0\x9f\x92\x89", "syringe"}, 
1202  			{"\xf0\x9f\xa9\xb8", "drop_of_blood"}, {"\xf0\x9f\x92\x8a", "pill"}, {"\xf0\x9f\xa9\xb9", "adhesive_bandage"}, 
1203  			{"\xf0\x9f\xa9\xbc", "crutch"}, {"\xf0\x9f\xa9\xba", "stethoscope"}, {"\xf0\x9f\xa9\xbb", "x-ray"}, 
1204  			{"\xf0\x9f\x9a\xaa", "door"}, {"\xf0\x9f\x9b\x97", "elevator"}, {"\xf0\x9f\xaa\x9e", "mirror"}, 
1205  			{"\xf0\x9f\xaa\x9f", "window"}, {"\xf0\x9f\x9b\x8f\xef\xb8\x8f", "bed"}, {"\xf0\x9f\x9b\x8b\xef\xb8\x8f", "couch_and_lamp"}, 
1206  			{"\xf0\x9f\xaa\x91", "chair"}, {"\xf0\x9f\x9a\xbd", "toilet"}, {"\xf0\x9f\xaa\xa0", "plunger"}, 
1207  			{"\xf0\x9f\x9a\xbf", "shower"}, {"\xf0\x9f\x9b\x81", "bathtub"}, {"\xf0\x9f\xaa\xa4", "mouse_trap"}, 
1208  			{"\xf0\x9f\xaa\x92", "razor"}, {"\xf0\x9f\xa7\xb4", "lotion_bottle"}, {"\xf0\x9f\xa7\xb7", "safety_pin"}, 
1209  			{"\xf0\x9f\xa7\xb9", "broom"}, {"\xf0\x9f\xa7\xba", "basket"}, {"\xf0\x9f\xa7\xbb", "roll_of_paper"}, 
1210  			{"\xf0\x9f\xaa\xa3", "bucket"}, {"\xf0\x9f\xa7\xbc", "soap"}, {"\xf0\x9f\xab\xa7", "bubbles"}, 
1211  			{"\xf0\x9f\xaa\xa5", "toothbrush"}, {"\xf0\x9f\xa7\xbd", "sponge"}, {"\xf0\x9f\xa7\xaf", "fire_extinguisher"}, 
1212  			{"\xf0\x9f\x9b\x92", "shopping_cart"}, {"\xf0\x9f\x9a\xac", "cigarette"}, {"\xe2\x9a\xb0\xef\xb8\x8f", "coffin"}, 
1213  			{"\xf0\x9f\xaa\xa6", "headstone"}, {"\xe2\x9a\xb1\xef\xb8\x8f", "funeral_urn"}, {"\xf0\x9f\xa7\xbf", "nazar_amulet"}, 
1214  			{"\xf0\x9f\xaa\xac", "hamsa"}, {"\xf0\x9f\x97\xbf", "moai"}, {"\xf0\x9f\xaa\xa7", "placard"}, 
1215  			{"\xf0\x9f\xaa\xaa", "identification_card"}, {"\xf0\x9f\x8f\xa7", "atm_sign"}, {"\xf0\x9f\x9a\xae", "litter_in_bin_sign"}, 
1216  			{"\xf0\x9f\x9a\xb0", "potable_water"}, {"\xe2\x99\xbf", "wheelchair_symbol"}, {"\xf0\x9f\x9a\xb9", "mens_room"}, 
1217  			{"\xf0\x9f\x9a\xba", "womens_room"}, {"\xf0\x9f\x9a\xbb", "restroom"}, {"\xf0\x9f\x9a\xbc", "baby_symbol"}, 
1218  			{"\xf0\x9f\x9a\xbe", "water_closet"}, {"\xf0\x9f\x9b\x82", "passport_control"}, {"\xf0\x9f\x9b\x83", "customs"}, 
1219  			{"\xf0\x9f\x9b\x84", "baggage_claim"}, {"\xf0\x9f\x9b\x85", "left_luggage"}, {"\xe2\x9a\xa0\xef\xb8\x8f", "warning"}, 
1220  			{"\xf0\x9f\x9a\xb8", "children_crossing"}, {"\xe2\x9b\x94", "no_entry"}, {"\xf0\x9f\x9a\xab", "prohibited"}, 
1221  			{"\xf0\x9f\x9a\xb3", "no_bicycles"}, {"\xf0\x9f\x9a\xad", "no_smoking"}, {"\xf0\x9f\x9a\xaf", "no_littering"}, 
1222  			{"\xf0\x9f\x9a\xb1", "non-potable_water"}, {"\xf0\x9f\x9a\xb7", "no_pedestrians"}, {"\xf0\x9f\x93\xb5", "no_mobile_phones"}, 
1223  			{"\xf0\x9f\x94\x9e", "no_one_under_eighteen"}, {"\xe2\x98\xa2\xef\xb8\x8f", "radioactive"}, {"\xe2\x98\xa3\xef\xb8\x8f", "biohazard"}, 
1224  			{"\xe2\xac\x86\xef\xb8\x8f", "up_arrow"}, {"\xe2\x86\x97\xef\xb8\x8f", "up-right_arrow"}, {"\xe2\x9e\xa1\xef\xb8\x8f", "right_arrow"}, 
1225  			{"\xe2\x86\x98\xef\xb8\x8f", "down-right_arrow"}, {"\xe2\xac\x87\xef\xb8\x8f", "down_arrow"}, {"\xe2\x86\x99\xef\xb8\x8f", "down-left_arrow"}, 
1226  			{"\xe2\xac\x85\xef\xb8\x8f", "left_arrow"}, {"\xe2\x86\x96\xef\xb8\x8f", "up-left_arrow"}, {"\xe2\x86\x95\xef\xb8\x8f", "up-down_arrow"}, 
1227  			{"\xe2\x86\x94\xef\xb8\x8f", "left-right_arrow"}, {"\xe2\x86\xa9\xef\xb8\x8f", "right_arrow_curving_left"}, {"\xe2\x86\xaa\xef\xb8\x8f", "left_arrow_curving_right"}, 
1228  			{"\xe2\xa4\xb4\xef\xb8\x8f", "right_arrow_curving_up"}, {"\xe2\xa4\xb5\xef\xb8\x8f", "right_arrow_curving_down"}, {"\xf0\x9f\x94\x83", "clockwise_vertical_arrows"}, 
1229  			{"\xf0\x9f\x94\x84", "counterclockwise_arrows_button"}, {"\xf0\x9f\x94\x99", "back_arrow"}, {"\xf0\x9f\x94\x9a", "end_arrow"}, 
1230  			{"\xf0\x9f\x94\x9b", "on_arrow"}, {"\xf0\x9f\x94\x9c", "soon_arrow"}, {"\xf0\x9f\x94\x9d", "top_arrow"}, 
1231  			{"\xf0\x9f\x9b\x90", "place_of_worship"}, {"\xe2\x9a\x9b\xef\xb8\x8f", "atom_symbol"}, {"\xf0\x9f\x95\x89\xef\xb8\x8f", "om"}, 
1232  			{"\xe2\x9c\xa1\xef\xb8\x8f", "star_of_david"}, {"\xe2\x98\xb8\xef\xb8\x8f", "wheel_of_dharma"}, {"\xe2\x98\xaf\xef\xb8\x8f", "yin_yang"}, 
1233  			{"\xe2\x9c\x9d\xef\xb8\x8f", "latin_cross"}, {"\xe2\x98\xa6\xef\xb8\x8f", "orthodox_cross"}, {"\xe2\x98\xaa\xef\xb8\x8f", "star_and_crescent"}, 
1234  			{"\xe2\x98\xae\xef\xb8\x8f", "peace_symbol"}, {"\xf0\x9f\x95\x8e", "menorah"}, {"\xf0\x9f\x94\xaf", "dotted_six-pointed_star"}, 
1235  			{"\xf0\x9f\xaa\xaf", "khanda"}, {"\xe2\x99\x88", "aries"}, {"\xe2\x99\x89", "taurus"}, 
1236  			{"\xe2\x99\x8a", "gemini"}, {"\xe2\x99\x8b", "cancer"}, {"\xe2\x99\x8c", "leo"}, 
1237  			{"\xe2\x99\x8d", "virgo"}, {"\xe2\x99\x8e", "libra"}, {"\xe2\x99\x8f", "scorpio"}, 
1238  			{"\xe2\x99\x90", "sagittarius"}, {"\xe2\x99\x91", "capricorn"}, {"\xe2\x99\x92", "aquarius"}, 
1239  			{"\xe2\x99\x93", "pisces"}, {"\xe2\x9b\x8e", "ophiuchus"}, {"\xf0\x9f\x94\x80", "shuffle_tracks_button"}, 
1240  			{"\xf0\x9f\x94\x81", "repeat_button"}, {"\xf0\x9f\x94\x82", "repeat_single_button"}, {"\xe2\x96\xb6\xef\xb8\x8f", "play_button"}, 
1241  			{"\xe2\x8f\xa9", "fast-forward_button"}, {"\xe2\x8f\xad\xef\xb8\x8f", "next_track_button"}, {"\xe2\x8f\xaf\xef\xb8\x8f", "play_or_pause_button"}, 
1242  			{"\xe2\x97\x80\xef\xb8\x8f", "reverse_button"}, {"\xe2\x8f\xaa", "fast_reverse_button"}, {"\xe2\x8f\xae\xef\xb8\x8f", "last_track_button"}, 
1243  			{"\xf0\x9f\x94\xbc", "upwards_button"}, {"\xe2\x8f\xab", "fast_up_button"}, {"\xf0\x9f\x94\xbd", "downwards_button"}, 
1244  			{"\xe2\x8f\xac", "fast_down_button"}, {"\xe2\x8f\xb8\xef\xb8\x8f", "pause_button"}, {"\xe2\x8f\xb9\xef\xb8\x8f", "stop_button"}, 
1245  			{"\xe2\x8f\xba\xef\xb8\x8f", "record_button"}, {"\xe2\x8f\x8f\xef\xb8\x8f", "eject_button"}, {"\xf0\x9f\x8e\xa6", "cinema"}, 
1246  			{"\xf0\x9f\x94\x85", "dim_button"}, {"\xf0\x9f\x94\x86", "bright_button"}, {"\xf0\x9f\x93\xb6", "antenna_bars"}, 
1247  			{"\xf0\x9f\x9b\x9c", "wireless"}, {"\xf0\x9f\x93\xb3", "vibration_mode"}, {"\xf0\x9f\x93\xb4", "mobile_phone_off"}, 
1248  			{"\xe2\x99\x80\xef\xb8\x8f", "female_sign"}, {"\xe2\x99\x82\xef\xb8\x8f", "male_sign"}, {"\xe2\x9a\xa7\xef\xb8\x8f", "transgender_symbol"}, 
1249  			{"\xe2\x9c\x96\xef\xb8\x8f", "multiply"}, {"\xe2\x9e\x95", "plus"}, {"\xe2\x9e\x96", "minus"}, 
1250  			{"\xe2\x9e\x97", "divide"}, {"\xf0\x9f\x9f\xb0", "heavy_equals_sign"}, {"\xe2\x99\xbe\xef\xb8\x8f", "infinity"}, 
1251  			{"\xe2\x80\xbc\xef\xb8\x8f", "double_exclamation_mark"}, {"\xe2\x81\x89\xef\xb8\x8f", "exclamation_question_mark"}, {"\xe2\x9d\x93", "red_question_mark"}, 
1252  			{"\xe2\x9d\x93", "question"}, {"\xe2\x9d\x94", "white_question_mark"}, {"\xe2\x9d\x95", "white_exclamation_mark"}, 
1253  			{"\xe2\x9d\x97", "red_exclamation_mark"}, {"\xe2\x9d\x97", "exclamation"}, {"\xe3\x80\xb0\xef\xb8\x8f", "wavy_dash"}, 
1254  			{"\xf0\x9f\x92\xb1", "currency_exchange"}, {"\xf0\x9f\x92\xb2", "heavy_dollar_sign"}, {"\xe2\x9a\x95\xef\xb8\x8f", "medical_symbol"}, 
1255  			{"\xe2\x99\xbb\xef\xb8\x8f", "recycling_symbol"}, {"\xe2\x99\xbb\xef\xb8\x8f", "recycle"}, {"\xe2\x9a\x9c\xef\xb8\x8f", "fleur-de-lis"}, 
1256  			{"\xf0\x9f\x94\xb1", "trident_emblem"}, {"\xf0\x9f\x93\x9b", "name_badge"}, {"\xf0\x9f\x94\xb0", "japanese_symbol_for_beginner"}, 
1257  			{"\xe2\xad\x95", "hollow_red_circle"}, {"\xe2\x9c\x85", "check_mark_button"}, {"\xe2\x9c\x85", "white_check_mark"}, 
1258  			{"\xe2\x98\x91\xef\xb8\x8f", "check_box_with_check"}, {"\xe2\x9c\x94\xef\xb8\x8f", "check_mark"}, {"\xe2\x9d\x8c", "cross_mark"}, 
1259  			{"\xe2\x9d\x8c", "x"}, {"\xe2\x9d\x8e", "cross_mark_button"}, {"\xe2\x9e\xb0", "curly_loop"}, 
1260  			{"\xe2\x9e\xbf", "double_curly_loop"}, {"\xe3\x80\xbd\xef\xb8\x8f", "part_alternation_mark"}, {"\xe2\x9c\xb3\xef\xb8\x8f", "eight-spoked_asterisk"}, 
1261  			{"\xe2\x9c\xb4\xef\xb8\x8f", "eight-pointed_star"}, {"\xe2\x9d\x87\xef\xb8\x8f", "sparkle"}, {"\xc2\xa9\xef\xb8\x8f", "copyright"}, 
1262  			{"\xc2\xae\xef\xb8\x8f", "registered"}, {"\xe2\x84\xa2\xef\xb8\x8f", "trade_mark"}, {"\xf0\x9f\xab\x9f", "splatter"}, 
1263  			{"\x23\xef\xb8\x8f\xe2\x83\xa3", "keycap_"}, {"\x30\xef\xb8\x8f\xe2\x83\xa3", "keycap_0"}, {"\x31\xef\xb8\x8f\xe2\x83\xa3", "keycap_1"}, 
1264  			{"\x32\xef\xb8\x8f\xe2\x83\xa3", "keycap_2"}, {"\x33\xef\xb8\x8f\xe2\x83\xa3", "keycap_3"}, {"\x34\xef\xb8\x8f\xe2\x83\xa3", "keycap_4"}, 
1265  			{"\x35\xef\xb8\x8f\xe2\x83\xa3", "keycap_5"}, {"\x36\xef\xb8\x8f\xe2\x83\xa3", "keycap_6"}, {"\x37\xef\xb8\x8f\xe2\x83\xa3", "keycap_7"}, 
1266  			{"\x38\xef\xb8\x8f\xe2\x83\xa3", "keycap_8"}, {"\x39\xef\xb8\x8f\xe2\x83\xa3", "keycap_9"}, {"\xf0\x9f\x94\x9f", "keycap_10"}, 
1267  			{"\xf0\x9f\x94\xa0", "input_latin_uppercase"}, {"\xf0\x9f\x94\xa1", "input_latin_lowercase"}, {"\xf0\x9f\x94\xa2", "input_numbers"}, 
1268  			{"\xf0\x9f\x94\xa3", "input_symbols"}, {"\xf0\x9f\x94\xa4", "input_latin_letters"}, {"\xf0\x9f\x85\xb0\xef\xb8\x8f", "a_button_blood_type"}, 
1269  			{"\xf0\x9f\x86\x8e", "ab_button_blood_type"}, {"\xf0\x9f\x85\xb1\xef\xb8\x8f", "b_button_blood_type"}, {"\xf0\x9f\x86\x91", "cl_button"}, 
1270  			{"\xf0\x9f\x86\x92", "cool_button"}, {"\xf0\x9f\x86\x93", "free_button"}, {"\xe2\x84\xb9\xef\xb8\x8f", "information"}, 
1271  			{"\xf0\x9f\x86\x94", "id_button"}, {"\xe2\x93\x82\xef\xb8\x8f", "circled_m"}, {"\xf0\x9f\x86\x95", "new_button"}, 
1272  			{"\xf0\x9f\x86\x95", "new"}, {"\xf0\x9f\x86\x96", "ng_button"}, {"\xf0\x9f\x85\xbe\xef\xb8\x8f", "o_button_blood_type"}, 
1273  			{"\xf0\x9f\x86\x97", "ok_button"}, {"\xf0\x9f\x86\x97", "ok"}, {"\xf0\x9f\x85\xbf\xef\xb8\x8f", "p_button"}, 
1274  			{"\xf0\x9f\x86\x98", "sos_button"}, {"\xf0\x9f\x86\x99", "up_button"}, {"\xf0\x9f\x86\x99", "up"}, 
1275  			{"\xf0\x9f\x86\x9a", "vs_button"}, {"\xf0\x9f\x88\x81", "japanese_here_button"}, {"\xf0\x9f\x88\x82\xef\xb8\x8f", "japanese_service_charge_button"}, 
1276  			{"\xf0\x9f\x88\xb7\xef\xb8\x8f", "japanese_monthly_amount_button"}, {"\xf0\x9f\x88\xb6", "japanese_not_free_of_charge_button"}, {"\xf0\x9f\x88\xaf", "japanese_reserved_button"}, 
1277  			{"\xf0\x9f\x89\x90", "japanese_bargain_button"}, {"\xf0\x9f\x88\xb9", "japanese_discount_button"}, {"\xf0\x9f\x88\x9a", "japanese_free_of_charge_button"}, 
1278  			{"\xf0\x9f\x88\xb2", "japanese_prohibited_button"}, {"\xf0\x9f\x89\x91", "japanese_acceptable_button"}, {"\xf0\x9f\x88\xb8", "japanese_application_button"}, 
1279  			{"\xf0\x9f\x88\xb4", "japanese_passing_grade_button"}, {"\xf0\x9f\x88\xb3", "japanese_vacancy_button"}, {"\xe3\x8a\x97\xef\xb8\x8f", "japanese_congratulations_button"}, 
1280  			{"\xe3\x8a\x99\xef\xb8\x8f", "japanese_secret_button"}, {"\xf0\x9f\x88\xba", "japanese_open_for_business_button"}, {"\xf0\x9f\x88\xb5", "japanese_no_vacancy_button"}, 
1281  			{"\xf0\x9f\x94\xb4", "red_circle"}, {"\xf0\x9f\x9f\xa0", "orange_circle"}, {"\xf0\x9f\x9f\xa1", "yellow_circle"}, 
1282  			{"\xf0\x9f\x9f\xa2", "green_circle"}, {"\xf0\x9f\x94\xb5", "blue_circle"}, {"\xf0\x9f\x9f\xa3", "purple_circle"}, 
1283  			{"\xf0\x9f\x9f\xa4", "brown_circle"}, {"\xe2\x9a\xab", "black_circle"}, {"\xe2\x9a\xaa", "white_circle"}, 
1284  			{"\xf0\x9f\x9f\xa5", "red_square"}, {"\xf0\x9f\x9f\xa7", "orange_square"}, {"\xf0\x9f\x9f\xa8", "yellow_square"}, 
1285  			{"\xf0\x9f\x9f\xa9", "green_square"}, {"\xf0\x9f\x9f\xa6", "blue_square"}, {"\xf0\x9f\x9f\xaa", "purple_square"}, 
1286  			{"\xf0\x9f\x9f\xab", "brown_square"}, {"\xe2\xac\x9b", "black_large_square"}, {"\xe2\xac\x9c", "white_large_square"}, 
1287  			{"\xe2\x97\xbc\xef\xb8\x8f", "black_medium_square"}, {"\xe2\x97\xbb\xef\xb8\x8f", "white_medium_square"}, {"\xe2\x97\xbe", "black_medium-small_square"}, 
1288  			{"\xe2\x97\xbd", "white_medium-small_square"}, {"\xe2\x96\xaa\xef\xb8\x8f", "black_small_square"}, {"\xe2\x96\xab\xef\xb8\x8f", "white_small_square"}, 
1289  			{"\xf0\x9f\x94\xb6", "large_orange_diamond"}, {"\xf0\x9f\x94\xb7", "large_blue_diamond"}, {"\xf0\x9f\x94\xb8", "small_orange_diamond"}, 
1290  			{"\xf0\x9f\x94\xb9", "small_blue_diamond"}, {"\xf0\x9f\x94\xba", "red_triangle_pointed_up"}, {"\xf0\x9f\x94\xbb", "red_triangle_pointed_down"}, 
1291  			{"\xf0\x9f\x92\xa0", "diamond_with_a_dot"}, {"\xf0\x9f\x94\x98", "radio_button"}, {"\xf0\x9f\x94\xb3", "white_square_button"}, 
1292  			{"\xf0\x9f\x94\xb2", "black_square_button"}, {"\xf0\x9f\x8f\x81", "chequered_flag"}, {"\xf0\x9f\x9a\xa9", "triangular_flag"}, 
1293  			{"\xf0\x9f\x8e\x8c", "crossed_flags"}, {"\xf0\x9f\x8f\xb4", "black_flag"}, {"\xf0\x9f\x8f\xb3\xef\xb8\x8f", "white_flag"}, 
1294  			{"\xf0\x9f\x8f\xb3\xef\xb8\x8f\xe2\x80\x8d\xf0\x9f\x8c\x88", "rainbow_flag"}, {"\xf0\x9f\x8f\xb3\xef\xb8\x8f\xe2\x80\x8d\xe2\x9a\xa7\xef\xb8\x8f", "transgender_flag"}, {"\xf0\x9f\x8f\xb4\xe2\x80\x8d\xe2\x98\xa0\xef\xb8\x8f", "pirate_flag"}, 
1295  			{"\xf0\x9f\x8f\xb4\xf3\xa0\x81\xa7\xf3\xa0\x81\xa2\xf3\xa0\x81\xa5\xf3\xa0\x81\xae\xf3\xa0\x81\xa7\xf3\xa0\x81\xbf", "flag_england"}, {"\xf0\x9f\x8f\xb4\xf3\xa0\x81\xa7\xf3\xa0\x81\xa2\xf3\xa0\x81\xb3\xf3\xa0\x81\xa3\xf3\xa0\x81\xb4\xf3\xa0\x81\xbf", "flag_scotland"}, {"\xf0\x9f\x8f\xb4\xf3\xa0\x81\xa7\xf3\xa0\x81\xa2\xf3\xa0\x81\xb7\xf3\xa0\x81\xac\xf3\xa0\x81\xb3\xf3\xa0\x81\xbf", "flag_wales"}, 
1296  		}
1297  	langNames = map[string]string{
1298  			"en": "English",
1299  			"ja": "日本語",
1300  			"pt": "Português",
1301  		}
1302  	translations = map[string]map[string]string{
1303  			"en": {
1304  				// Login page.
1305  				"subtitle":        "the digital home for the music scene",
1306  				"login":           "login",
1307  				"requesting":      "requesting...",
1308  				"err_no_id":       "no identity - open signer to add one",
1309  				"language":        "Language",
1310  				"theme":           "Theme",
1311  				"dark":            "dark",
1312  				"light":           "light",
1313  		
1314  				// Top bar / nav.
1315  				"back":        "\u2190 back",
1316  				"feed":        "feed",
1317  				"compose":     "compose",
1318  				"messaging":   "messaging",
1319  				"settings":         "settings",
1320  				"notifications":    "notifications",
1321  				"no_notifications": "no notifications yet",
1322  				"about":            "about",
1323  		
1324  				// Feed.
1325  				"connecting":          "connecting...",
1326  				"loading_translator":  "loading translator...",
1327  				"show_more":       "show more",
1328  				"show_less":       "show less",
1329  				"note_not_found":  "Note not found",
1330  				"reposted":        "reposted",
1331  		
1332  				// Thread view.
1333  				"loading_thread": "loading thread...",
1334  				"thread_empty":   "thread not found",
1335  				"replying_to":      "replying to...",
1336  				"reply_not_found":  "note not found",
1337  		
1338  				// Profile.
1339  				"notes":      "notes",
1340  				"follows":    "follows",
1341  				"relays":     "relays",
1342  				"mutes":      "mutes",
1343  				"no_follows": "no follows data",
1344  				"no_relays":  "no relay data",
1345  				"no_mutes":   "no mutes data",
1346  				"following":  "following",
1347  				"muted":      "muted",
1348  				"copy":       "copy",
1349  				"qr":         "qr",
1350  				"message":    "message",
1351  				"loading":    "loading...",
1352  				"relay_info": "relay info",
1353  				"relay_fail": "failed to fetch relay info",
1354  		
1355  				// Messaging.
1356  				"new_chat":       " |  new chat",
1357  				"no_convos":      "no conversations yet",
1358  				"npub_placeholder": "npub or hex pubkey",
1359  				"go":             "go",
1360  				"msg_placeholder": "message...",
1361  				"send":           "send",
1362  				"dm_notice":      "encrypted DMs require the <b>musiquay signer</b> extension",
1363  				"ratchet":        "ratchet",
1364  				"relay_leak_warn": "some of the selected relays may leak metadata",
1365  		
1366  				// About.
1367  				"developed_by":   "developed by:",
1368  				"tagline":        "Can Youse Paradigm?",
1369  				"about_donkey":   "Every hour you don't zap, a donkey eats another cabbage. You can stop this. \U0001FABE",
1370  		
1371  				// Settings.
1372  				"settings_title": "Settings",
1373  				"lang_label":     "Interface language",
1374  				"theme_label":    "Appearance",
1375  		
1376  				// Relay settings.
1377  				"relays_section":         "Relays",
1378  				"relays_active":          "Active relays",
1379  				"relays_required":        "Required capabilities",
1380  				"relays_req_nip42":       "NIP-42 authentication",
1381  				"relays_req_nip70":       "NIP-70 protected events",
1382  				"relays_block_payment":   "Block payment-required relays",
1383  				"relays_block_restrict":  "Block restricted-write relays",
1384  				"relays_blocklist":       "Blocklist",
1385  				"relays_blocklist_empty": "(none)",
1386  				"relays_no_nip11":        "(no NIP-11)",
1387  		
1388  				// Signer.
1389  				"signer":               "signer",
1390  				"no_signer_backend":    "No signer detected. Install the extension.",
1391  				"no_vault":             "No vault found. Create a new one.",
1392  				"hd_keychain":          "HD Keychain",
1393  				"vault_password":       "Vault password",
1394  				"generate_keychain":    "Generate New Keychain",
1395  				"generating":           "Generating keychain...",
1396  				"create_failed":        "Failed to create vault.",
1397  				"restore_seed":         "Restore from Seed Phrase",
1398  				"seed_placeholder":     "12-word seed phrase",
1399  				"account_placeholder":  "Account number (default: 1)",
1400  				"restore_keychain":     "Restore Keychain",
1401  				"restoring":            "Restoring keychain...",
1402  				"restore_failed":       "Invalid seed phrase or vault creation failed.",
1403  				"deriving_n":           "Deriving identity",
1404  				"import_vault":         "Import Vault File",
1405  				"choose_vault":         "Choose Vault File...",
1406  				"vault_imported":       "Vault imported. Unlock with your password.",
1407  				"invalid_vault":        "Invalid vault file.",
1408  				"write_seed":           "Write Down Your Seed Phrase",
1409  				"seed_warning":         "This is the ONLY way to recover your identities. Write these words down and store them safely. They will not be shown again.",
1410  				"copy_clipboard":       "Copy to Clipboard",
1411  				"copied":               "Copied!",
1412  				"saved_it":             "I've Saved It",
1413  				"vault_locked":         "Vault is locked.",
1414  				"password":             "Password",
1415  				"unlock":               "Unlock",
1416  				"deriving_key":         "Deriving key, this may take a minute...",
1417  				"wrong_password":       "Wrong password.",
1418  				"unlock_failed_kdf":    "Unlock failed - key derivation crashed. See log.",
1419  				"identities":           "Identities",
1420  				"nickname_placeholder": "Nickname for new identity",
1421  				"derive_new":           "Derive New Identity",
1422  				"deriving":             "Deriving...",
1423  				"show_seed":            "Show Seed Phrase",
1424  				"export_vault":           "Export Vault",
1425  				"export_password_prompt": "Choose a password to protect the exported vault:",
1426  				"confirm_password":       "Confirm password",
1427  				"password_required":      "Password is required.",
1428  				"passwords_mismatch":     "Passwords do not match.",
1429  				"export_failed":          "Export failed.",
1430  				"cancel":                 "Cancel",
1431  				"lock_vault":           "Lock Vault",
1432  				"logout":               "Logout",
1433  				"logout_confirm":       "This will erase all keys and log you out. Are you sure?",
1434  				"use":                  "Use",
1435  				"publish":              "Publish",
1436  				"add":                  "Add",
1437  				"edit_profile":         "edit profile",
1438  				"save":                 "Save",
1439  				"profile_name":         "Display name",
1440  				"profile_about":        "About",
1441  				"profile_picture":      "Picture URL",
1442  				"profile_banner":       "Banner URL",
1443  				"profile_nip05":        "NIP-05 (user@domain)",
1444  				"profile_website":      "Website",
1445  				"profile_lud16":        "Lightning address",
1446  				"profile_saved":        "Profile published",
1447  		
1448  				// Wallet (NWC).
1449  				"wallet":                      "Wallet",
1450  				"wallet_no_wallet":            "No wallet connected. Paste a Nostr Wallet Connect URI below.",
1451  				"wallet_add":                  "Add Wallet",
1452  				"wallet_add_another":          "Add another wallet",
1453  				"wallet_add_hint":             "Paste a nostr+walletconnect:// URI from Alby Hub, Mutiny, LNbits, etc.",
1454  				"wallet_alias_placeholder":    "Alias (optional)",
1455  				"wallet_adding":               "Adding...",
1456  				"wallet_add_failed":           "Could not add wallet. Check the URI.",
1457  				"wallet_remove_confirm":       "Remove this wallet connection?",
1458  				"wallet_refresh":              "Refresh",
1459  				"wallet_loading_balance":      "loading...",
1460  				"wallet_sats":                 "sats",
1461  				"wallet_send":                 "Send",
1462  				"wallet_recipient_placeholder": "lnbc... or user@domain",
1463  				"wallet_amount_placeholder":   "Amount (sats, Lightning address only)",
1464  				"wallet_comment_placeholder":  "Comment (optional)",
1465  				"wallet_pay":                  "Pay",
1466  				"wallet_paying":               "Paying...",
1467  				"wallet_pay_ok":               "Paid",
1468  				"wallet_pay_failed":           "Payment failed",
1469  				"wallet_need_amount":          "Amount required for Lightning addresses.",
1470  				"wallet_unknown_recipient":    "Unknown recipient. Use a BOLT11 invoice or lightning address.",
1471  			},
1472  			"ja": {
1473  				"subtitle":        "音楽シーンのためのデジタルホーム",
1474  				"login":           "ログイン",
1475  				"requesting":      "リクエスト中...",
1476  				"err_no_id":       "IDがありません - 署名を開いて追加してください",
1477  				"language":        "言語",
1478  				"theme":           "テーマ",
1479  				"dark":            "ダーク",
1480  				"light":           "ライト",
1481  		
1482  				"back":        "\u2190 戻る",
1483  				"feed":        "フィード",
1484  				"compose":     "作成",
1485  				"messaging":   "メッセージ",
1486  				"settings":         "設定",
1487  				"notifications":    "通知",
1488  				"no_notifications": "通知はまだありません",
1489  				"about":            "概要",
1490  		
1491  				"connecting":          "接続中...",
1492  				"loading_translator":  "翻訳モジュール読み込み中...",
1493  				"show_more":       "もっと見る",
1494  				"show_less":       "閉じる",
1495  				"note_not_found":  "ノートが見つかりません",
1496  				"reposted":        "リポスト",
1497  		
1498  				"loading_thread": "スレッド読み込み中...",
1499  				"thread_empty":   "スレッドが見つかりません",
1500  				"replying_to":      "返信先...",
1501  				"reply_not_found":  "ノートが見つかりません",
1502  		
1503  				"notes":      "ノート",
1504  				"follows":    "フォロー",
1505  				"relays":     "リレー",
1506  				"mutes":      "ミュート",
1507  				"no_follows": "フォローデータなし",
1508  				"no_relays":  "リレーデータなし",
1509  				"no_mutes":   "ミュートデータなし",
1510  				"following":  "フォロー中",
1511  				"muted":      "ミュート中",
1512  				"copy":       "コピー",
1513  				"qr":         "QR",
1514  				"message":    "メッセージ",
1515  				"loading":    "読み込み中...",
1516  				"relay_info": "リレー情報",
1517  				"relay_fail": "リレー情報の取得に失敗しました",
1518  		
1519  				"new_chat":       " |  新しいチャット",
1520  				"no_convos":      "会話がまだありません",
1521  				"npub_placeholder": "npubまたは16進公開鍵",
1522  				"go":             "実行",
1523  				"msg_placeholder": "メッセージ...",
1524  				"send":           "送信",
1525  				"dm_notice":      "暗号化DMには<b>musiquay signer</b>拡張が必要です",
1526  				"ratchet":        "ラチェット",
1527  				"relay_leak_warn": "選択されたリレーの一部がメタデータを漏洩する可能性があります",
1528  		
1529  				"developed_by":   "開発者:",
1530  				"tagline":        "Can Youse Paradigm?",
1531  				"about_donkey":   "zapしないと毎時間ロバがキャベツを食べてしまいます。止められるのはあなたです。\U0001FABE",
1532  		
1533  				"settings_title": "設定",
1534  				"lang_label":     "表示言語",
1535  				"theme_label":    "外観",
1536  		
1537  				"relays_section":         "リレー",
1538  				"relays_active":          "アクティブなリレー",
1539  				"relays_required":        "必須機能",
1540  				"relays_req_nip42":       "NIP-42 認証",
1541  				"relays_req_nip70":       "NIP-70 保護イベント",
1542  				"relays_block_payment":   "有料リレーをブロック",
1543  				"relays_block_restrict":  "書き込み制限リレーをブロック",
1544  				"relays_blocklist":       "ブロックリスト",
1545  				"relays_blocklist_empty": "(なし)",
1546  				"relays_no_nip11":        "(NIP-11なし)",
1547  		
1548  				"signer":               "署名",
1549  				"no_vault":             "金庫が見つかりません。新規作成してください。",
1550  				"hd_keychain":          "HDキーチェーン",
1551  				"vault_password":       "金庫のパスワード",
1552  				"generate_keychain":    "新しいキーチェーンを生成",
1553  				"generating":           "キーチェーン生成中...",
1554  				"create_failed":        "金庫の作成に失敗しました。",
1555  				"restore_seed":         "シードフレーズから復元",
1556  				"seed_placeholder":     "12語のシードフレーズ",
1557  				"account_placeholder":  "アカウント番号(デフォルト: 1)",
1558  				"restore_keychain":     "キーチェーンを復元",
1559  				"restoring":            "キーチェーン復元中...",
1560  				"restore_failed":       "シードフレーズが無効か、金庫の作成に失敗しました。",
1561  				"deriving_n":           "ID導出中",
1562  				"import_vault":         "金庫ファイルをインポート",
1563  				"choose_vault":         "金庫ファイルを選択...",
1564  				"vault_imported":       "金庫をインポートしました。パスワードでロック解除してください。",
1565  				"invalid_vault":        "無効な金庫ファイルです。",
1566  				"write_seed":           "シードフレーズを書き留めてください",
1567  				"seed_warning":         "これがIDを復元する唯一の方法です。これらの単語を書き留めて安全に保管してください。再表示されません。",
1568  				"copy_clipboard":       "クリップボードにコピー",
1569  				"copied":               "コピー済み!",
1570  				"saved_it":             "保存しました",
1571  				"vault_locked":         "金庫はロックされています。",
1572  				"password":             "パスワード",
1573  				"unlock":               "ロック解除",
1574  				"deriving_key":         "鍵を導出中です。しばらくお待ちください...",
1575  				"wrong_password":       "パスワードが違います。",
1576  				"identities":           "ID一覧",
1577  				"nickname_placeholder": "新しいIDのニックネーム",
1578  				"derive_new":           "新しいIDを導出",
1579  				"deriving":             "導出中...",
1580  				"show_seed":            "シードフレーズを表示",
1581  				"export_vault":           "金庫をエクスポート",
1582  				"export_password_prompt": "エクスポートの保護パスワードを入力してください:",
1583  				"confirm_password":       "パスワード確認",
1584  				"password_required":      "パスワードが必要です。",
1585  				"passwords_mismatch":     "パスワードが一致しません。",
1586  				"export_failed":          "エクスポートに失敗しました。",
1587  				"cancel":                 "キャンセル",
1588  				"lock_vault":           "金庫をロック",
1589  				"logout":               "ログアウト",
1590  				"logout_confirm":       "すべての鍵が削除され、ログアウトします。よろしいですか?",
1591  				"use":                  "使用",
1592  				"publish":              "公開",
1593  				"add":                  "追加",
1594  		
1595  				// Wallet (NWC).
1596  				"wallet":                      "ウォレット",
1597  				"wallet_no_wallet":            "ウォレットが接続されていません。下に Nostr Wallet Connect URI を貼り付けてください。",
1598  				"wallet_add":                  "ウォレットを追加",
1599  				"wallet_add_another":          "別のウォレットを追加",
1600  				"wallet_add_hint":             "Alby Hub、Mutiny、LNbits などの nostr+walletconnect:// URI を貼り付けてください。",
1601  				"wallet_alias_placeholder":    "別名(任意)",
1602  				"wallet_adding":               "追加中...",
1603  				"wallet_add_failed":           "ウォレットを追加できませんでした。URI を確認してください。",
1604  				"wallet_remove_confirm":       "このウォレット接続を削除しますか?",
1605  				"wallet_refresh":              "更新",
1606  				"wallet_loading_balance":      "読み込み中...",
1607  				"wallet_sats":                 "sats",
1608  				"wallet_send":                 "送信",
1609  				"wallet_recipient_placeholder": "lnbc... または user@domain",
1610  				"wallet_amount_placeholder":   "金額 (sats、Lightning アドレスのみ)",
1611  				"wallet_comment_placeholder":  "コメント(任意)",
1612  				"wallet_pay":                  "支払う",
1613  				"wallet_paying":               "支払い中...",
1614  				"wallet_pay_ok":               "支払い完了",
1615  				"wallet_pay_failed":           "支払い失敗",
1616  				"wallet_need_amount":          "Lightning アドレスには金額が必要です。",
1617  				"wallet_unknown_recipient":    "不明な受取人。BOLT11 インボイスまたは Lightning アドレスを使用してください。",
1618  			},
1619  			"pt": {
1620  				"subtitle":        "a casa digital da cena musical",
1621  				"login":           "entrar",
1622  				"requesting":      "solicitando...",
1623  				"err_no_id":       "sem identidade - abra o signer para adicionar uma",
1624  				"language":        "Idioma",
1625  				"theme":           "Tema",
1626  				"dark":            "escuro",
1627  				"light":           "claro",
1628  		
1629  				"back":        "\u2190 voltar",
1630  				"feed":        "feed",
1631  				"compose":     "compor",
1632  				"messaging":   "mensagens",
1633  				"settings":         "configurações",
1634  				"notifications":    "notificações",
1635  				"no_notifications": "nenhuma notificação ainda",
1636  				"about":            "sobre",
1637  		
1638  				"connecting":          "conectando...",
1639  				"loading_translator":  "carregando tradutor...",
1640  				"show_more":       "mostrar mais",
1641  				"show_less":       "mostrar menos",
1642  				"note_not_found":  "Nota não encontrada",
1643  				"reposted":        "repostou",
1644  		
1645  				"loading_thread": "carregando thread...",
1646  				"thread_empty":   "thread não encontrada",
1647  				"replying_to":      "respondendo a...",
1648  				"reply_not_found":  "nota não encontrada",
1649  		
1650  				"notes":      "notas",
1651  				"follows":    "seguindo",
1652  				"relays":     "relays",
1653  				"mutes":      "silenciados",
1654  				"no_follows": "sem dados de seguidos",
1655  				"no_relays":  "sem dados de relays",
1656  				"no_mutes":   "sem dados de silenciados",
1657  				"following":  "seguindo",
1658  				"muted":      "silenciados",
1659  				"copy":       "copiar",
1660  				"qr":         "qr",
1661  				"message":    "mensagem",
1662  				"loading":    "carregando...",
1663  				"relay_info": "info do relay",
1664  				"relay_fail": "falha ao buscar info do relay",
1665  		
1666  				"new_chat":       " |  novo chat",
1667  				"no_convos":      "nenhuma conversa ainda",
1668  				"npub_placeholder": "npub ou chave pública hex",
1669  				"go":             "ir",
1670  				"msg_placeholder": "mensagem...",
1671  				"send":           "enviar",
1672  				"dm_notice":      "DMs encriptadas requerem a extensão <b>musiquay signer</b>",
1673  				"ratchet":        "ratchet",
1674  				"relay_leak_warn": "alguns dos relays selecionados podem vazar metadados",
1675  		
1676  				"developed_by":   "desenvolvido por:",
1677  				"tagline":        "Can Youse Paradigm?",
1678  				"about_donkey":   "A cada hora que você não dá zap, um burro come outro repolho. Você pode impedir isso. \U0001FABE",
1679  		
1680  				"settings_title": "Configurações",
1681  				"lang_label":     "Idioma da interface",
1682  				"theme_label":    "Aparência",
1683  		
1684  				"relays_section":         "Relays",
1685  				"relays_active":          "Relays ativos",
1686  				"relays_required":        "Capacidades necessárias",
1687  				"relays_req_nip42":       "NIP-42 autenticação",
1688  				"relays_req_nip70":       "NIP-70 eventos protegidos",
1689  				"relays_block_payment":   "Bloquear relays pagos",
1690  				"relays_block_restrict":  "Bloquear relays com escrita restrita",
1691  				"relays_blocklist":       "Lista de bloqueio",
1692  				"relays_blocklist_empty": "(nenhum)",
1693  				"relays_no_nip11":        "(sem NIP-11)",
1694  		
1695  				"signer":               "assinador",
1696  				"no_vault":             "Nenhum cofre encontrado. Crie um novo.",
1697  				"hd_keychain":          "Chaveiro HD",
1698  				"vault_password":       "Senha do cofre",
1699  				"generate_keychain":    "Gerar Novo Chaveiro",
1700  				"generating":           "Gerando chaveiro...",
1701  				"create_failed":        "Falha ao criar cofre.",
1702  				"restore_seed":         "Restaurar com Frase Semente",
1703  				"seed_placeholder":     "Frase semente de 12 palavras",
1704  				"account_placeholder":  "Número da conta (padrão: 1)",
1705  				"restore_keychain":     "Restaurar Chaveiro",
1706  				"restoring":            "Restaurando chaveiro...",
1707  				"restore_failed":       "Frase semente inválida ou falha ao criar cofre.",
1708  				"deriving_n":           "Derivando identidade",
1709  				"import_vault":         "Importar Arquivo de Cofre",
1710  				"choose_vault":         "Escolher Arquivo de Cofre...",
1711  				"vault_imported":       "Cofre importado. Desbloqueie com sua senha.",
1712  				"invalid_vault":        "Arquivo de cofre inválido.",
1713  				"write_seed":           "Anote Sua Frase Semente",
1714  				"seed_warning":         "Esta é a ÚNICA forma de recuperar suas identidades. Anote estas palavras e guarde-as em segurança. Elas não serão exibidas novamente.",
1715  				"copy_clipboard":       "Copiar para Área de Transferência",
1716  				"copied":               "Copiado!",
1717  				"saved_it":             "Já Salvei",
1718  				"vault_locked":         "Cofre está bloqueado.",
1719  				"password":             "Senha",
1720  				"unlock":               "Desbloquear",
1721  				"deriving_key":         "Derivando chave, isso pode levar um minuto...",
1722  				"wrong_password":       "Senha incorreta.",
1723  				"identities":           "Identidades",
1724  				"nickname_placeholder": "Apelido para nova identidade",
1725  				"derive_new":           "Derivar Nova Identidade",
1726  				"deriving":             "Derivando...",
1727  				"show_seed":            "Mostrar Frase Semente",
1728  				"export_vault":           "Exportar Cofre",
1729  				"export_password_prompt": "Escolha uma senha para proteger o cofre exportado:",
1730  				"confirm_password":       "Confirmar senha",
1731  				"password_required":      "Senha obrigatória.",
1732  				"passwords_mismatch":     "As senhas não coincidem.",
1733  				"export_failed":          "Falha na exportação.",
1734  				"cancel":                 "Cancelar",
1735  				"lock_vault":           "Bloquear Cofre",
1736  				"logout":               "Sair",
1737  				"logout_confirm":       "Isso apagará todas as chaves e encerrará a sessão. Tem certeza?",
1738  				"use":                  "Usar",
1739  				"publish":              "Publicar",
1740  				"add":                  "Adicionar",
1741  		
1742  				// Wallet (NWC).
1743  				"wallet":                      "Carteira",
1744  				"wallet_no_wallet":            "Nenhuma carteira conectada. Cole um URI Nostr Wallet Connect abaixo.",
1745  				"wallet_add":                  "Adicionar Carteira",
1746  				"wallet_add_another":          "Adicionar outra carteira",
1747  				"wallet_add_hint":             "Cole um URI nostr+walletconnect:// do Alby Hub, Mutiny, LNbits, etc.",
1748  				"wallet_alias_placeholder":    "Apelido (opcional)",
1749  				"wallet_adding":               "Adicionando...",
1750  				"wallet_add_failed":           "Não foi possível adicionar a carteira. Verifique o URI.",
1751  				"wallet_remove_confirm":       "Remover esta conexão de carteira?",
1752  				"wallet_refresh":              "Atualizar",
1753  				"wallet_loading_balance":      "carregando...",
1754  				"wallet_sats":                 "sats",
1755  				"wallet_send":                 "Enviar",
1756  				"wallet_recipient_placeholder": "lnbc... ou user@domain",
1757  				"wallet_amount_placeholder":   "Valor (sats, apenas para endereço Lightning)",
1758  				"wallet_comment_placeholder":  "Comentário (opcional)",
1759  				"wallet_pay":                  "Pagar",
1760  				"wallet_paying":               "Pagando...",
1761  				"wallet_pay_ok":               "Pago",
1762  				"wallet_pay_failed":           "Falha no pagamento",
1763  				"wallet_need_amount":          "Valor obrigatório para endereços Lightning.",
1764  				"wallet_unknown_recipient":    "Destinatário desconhecido. Use uma fatura BOLT11 ou endereço Lightning.",
1765  			},
1766  		}
1767  	qrVersions = [41]qrVersionInfo{
1768  			{},
1769  			{26, [4]qrBlockInfo{{1, 7}, {1, 10}, {1, 13}, {1, 17}}},     // 1
1770  			{44, [4]qrBlockInfo{{1, 10}, {1, 16}, {1, 22}, {1, 28}}},    // 2
1771  			{70, [4]qrBlockInfo{{1, 15}, {1, 26}, {2, 18}, {2, 22}}},    // 3
1772  			{100, [4]qrBlockInfo{{1, 20}, {2, 18}, {2, 26}, {4, 16}}},   // 4
1773  			{134, [4]qrBlockInfo{{1, 26}, {2, 24}, {4, 18}, {4, 22}}},   // 5
1774  			{172, [4]qrBlockInfo{{2, 18}, {4, 16}, {4, 24}, {4, 28}}},   // 6
1775  			{196, [4]qrBlockInfo{{2, 20}, {4, 18}, {6, 18}, {5, 26}}},   // 7
1776  			{242, [4]qrBlockInfo{{2, 24}, {4, 22}, {6, 22}, {6, 26}}},   // 8
1777  			{292, [4]qrBlockInfo{{2, 30}, {5, 22}, {8, 20}, {8, 24}}},   // 9
1778  			{346, [4]qrBlockInfo{{4, 18}, {5, 26}, {8, 24}, {8, 28}}},   // 10
1779  			{404, [4]qrBlockInfo{{4, 20}, {5, 30}, {8, 28}, {11, 24}}},  // 11
1780  			{466, [4]qrBlockInfo{{4, 24}, {8, 22}, {10, 26}, {11, 28}}}, // 12
1781  			{532, [4]qrBlockInfo{{4, 26}, {9, 22}, {12, 24}, {16, 22}}}, // 13
1782  			{581, [4]qrBlockInfo{{4, 30}, {9, 24}, {16, 20}, {16, 24}}}, // 14
1783  			{655, [4]qrBlockInfo{{6, 22}, {10, 24}, {12, 30}, {18, 24}}}, // 15
1784  			{733, [4]qrBlockInfo{{6, 24}, {10, 28}, {17, 24}, {16, 30}}}, // 16
1785  			{815, [4]qrBlockInfo{{6, 28}, {11, 28}, {16, 28}, {19, 28}}}, // 17
1786  			{901, [4]qrBlockInfo{{6, 30}, {13, 26}, {18, 28}, {21, 28}}}, // 18
1787  			{991, [4]qrBlockInfo{{7, 28}, {14, 26}, {21, 26}, {25, 26}}}, // 19
1788  			{1085, [4]qrBlockInfo{{8, 28}, {16, 26}, {20, 30}, {25, 28}}}, // 20
1789  			{1156, [4]qrBlockInfo{{8, 28}, {17, 26}, {23, 28}, {25, 30}}}, // 21
1790  			{1258, [4]qrBlockInfo{{9, 28}, {17, 28}, {23, 30}, {34, 24}}}, // 22
1791  			{1364, [4]qrBlockInfo{{9, 30}, {18, 28}, {25, 30}, {30, 30}}}, // 23
1792  			{1474, [4]qrBlockInfo{{10, 30}, {20, 28}, {27, 30}, {32, 30}}}, // 24
1793  			{1588, [4]qrBlockInfo{{12, 26}, {21, 28}, {29, 30}, {35, 30}}}, // 25
1794  			{1706, [4]qrBlockInfo{{12, 28}, {23, 28}, {34, 28}, {37, 30}}}, // 26
1795  			{1828, [4]qrBlockInfo{{12, 30}, {25, 28}, {34, 30}, {40, 30}}}, // 27
1796  			{1921, [4]qrBlockInfo{{13, 30}, {26, 28}, {35, 30}, {42, 30}}}, // 28
1797  			{2051, [4]qrBlockInfo{{14, 30}, {28, 28}, {38, 30}, {45, 30}}}, // 29
1798  			{2185, [4]qrBlockInfo{{15, 30}, {29, 28}, {40, 30}, {48, 30}}}, // 30
1799  			{2323, [4]qrBlockInfo{{16, 30}, {31, 28}, {43, 30}, {51, 30}}}, // 31
1800  			{2465, [4]qrBlockInfo{{17, 30}, {33, 28}, {45, 30}, {54, 30}}}, // 32
1801  			{2611, [4]qrBlockInfo{{18, 30}, {35, 28}, {48, 30}, {57, 30}}}, // 33
1802  			{2761, [4]qrBlockInfo{{19, 30}, {37, 28}, {51, 30}, {60, 30}}}, // 34
1803  			{2876, [4]qrBlockInfo{{19, 30}, {38, 28}, {53, 30}, {63, 30}}}, // 35
1804  			{3034, [4]qrBlockInfo{{20, 30}, {40, 28}, {56, 30}, {66, 30}}}, // 36
1805  			{3196, [4]qrBlockInfo{{21, 30}, {43, 28}, {59, 30}, {70, 30}}}, // 37
1806  			{3362, [4]qrBlockInfo{{22, 30}, {45, 28}, {62, 30}, {74, 30}}}, // 38
1807  			{3532, [4]qrBlockInfo{{24, 30}, {47, 28}, {65, 30}, {77, 30}}}, // 39
1808  			{3706, [4]qrBlockInfo{{25, 30}, {49, 28}, {68, 30}, {81, 30}}}, // 40
1809  		}
1810  	qrAlignPositions = [41][]int32{
1811  			{},                          // 0 unused
1812  			{},                          // 1
1813  			{6, 18},                     // 2
1814  			{6, 22},                     // 3
1815  			{6, 26},                     // 4
1816  			{6, 30},                     // 5
1817  			{6, 34},                     // 6
1818  			{6, 22, 38},                 // 7
1819  			{6, 24, 42},                 // 8
1820  			{6, 26, 46},                 // 9
1821  			{6, 28, 50},                 // 10
1822  			{6, 30, 54},                 // 11
1823  			{6, 32, 58},                 // 12
1824  			{6, 34, 62},                 // 13
1825  			{6, 26, 46, 66},             // 14
1826  			{6, 26, 48, 70},             // 15
1827  			{6, 26, 50, 74},             // 16
1828  			{6, 30, 54, 78},             // 17
1829  			{6, 30, 56, 82},             // 18
1830  			{6, 30, 58, 86},             // 19
1831  			{6, 34, 62, 90},             // 20
1832  			{6, 28, 50, 72, 94},         // 21
1833  			{6, 26, 50, 74, 98},         // 22
1834  			{6, 30, 54, 78, 102},        // 23
1835  			{6, 28, 54, 80, 106},        // 24
1836  			{6, 32, 58, 84, 110},        // 25
1837  			{6, 30, 58, 86, 114},        // 26
1838  			{6, 34, 62, 90, 118},        // 27
1839  			{6, 26, 50, 74, 98, 122},    // 28
1840  			{6, 30, 54, 78, 102, 126},   // 29
1841  			{6, 26, 52, 78, 104, 130},   // 30
1842  			{6, 30, 56, 82, 108, 134},   // 31
1843  			{6, 34, 60, 86, 112, 138},   // 32
1844  			{6, 30, 58, 86, 114, 142},   // 33
1845  			{6, 34, 62, 90, 118, 146},   // 34
1846  			{6, 30, 54, 78, 102, 126, 150}, // 35
1847  			{6, 24, 50, 76, 102, 128, 154}, // 36
1848  			{6, 28, 54, 80, 106, 132, 158}, // 37
1849  			{6, 32, 58, 84, 110, 136, 162}, // 38
1850  			{6, 26, 54, 82, 110, 138, 166}, // 39
1851  			{6, 30, 58, 86, 114, 142, 170}, // 40
1852  		}
1853  }
1854  
1855  func main() {
1856  	initAppGlobals()
1857  
1858  	initState()
1859  	dom.ConsoleLog("starting musiquay " | version)
1860  	dom.FetchText("/__version", func(body string) {
1861  		if body == "" {
1862  			return
1863  		}
1864  		sv := helpers.JsonGetString(body, "v")
1865  		if sv == "" {
1866  			return
1867  		}
1868  		sbase := sv
1869  		for i := 0; i < len(sv); i++ {
1870  			if sv[i] == '+' {
1871  				sbase = sv[:i]
1872  				break
1873  			}
1874  		}
1875  		cbase := version
1876  		if len(cbase) > 0 && cbase[0] == 'v' {
1877  			cbase = cbase[1:]
1878  		}
1879  		if sbase != cbase {
1880  			dom.ConsoleLog("version mismatch: local=" | version | " server=" | sv)
1881  		}
1882  	})
1883  	initLang()
1884  	loadRelayPolicy()
1885  	// Always add the host relay as first fallback - it's the musiquay relay serving this page.
1886  	localRelay := localRelayURL()
1887  	appSt.defaultRelays = []string{localRelay} | appSt.defaultRelays
1888  	dom.ConsoleLog("local relay: " | localRelay)
1889  	appSt.blossomServerURL = localstorage.GetItem(lsKeyBlossomServer)
1890  	appSt.invidiousURL = localstorage.GetItem(lsKeyInvidiousURL)
1891  	appSt.campionURL = localstorage.GetItem(lsKeyCampionURL)
1892  	appSt.devMode = localstorage.GetItem(lsKeyDevMode) == "1"
1893  	themePref := localstorage.GetItem(lsKeyTheme)
1894  	if themePref != "" {
1895  		appSt.isDark = themePref == "dark"
1896  	} else {
1897  		appSt.isDark = true
1898  	}
1899  	htmlEl := dom.QuerySelector("html")
1900  	if appSt.isDark {
1901  		dom.AddClass(htmlEl, "dark")
1902  	} else {
1903  		dom.RemoveClass(htmlEl, "dark")
1904  	}
1905  	appSt.root = dom.GetElementById("app-root")
1906  	dom.SetInnerHTML(appSt.root, "")
1907  	dom.SetAttribute(appSt.root, "data-version", version)
1908  	savedPK := localstorage.GetItem(lsKeyPubkey)
1909  	if savedPK != "" {
1910  		restoreSession(savedPK, 24)
1911  	} else {
1912  		showLogin()
1913  	}
1914  }
1915  
1916  // --- Theme ---
1917  
1918  func toggleTheme() {
1919  	el := dom.QuerySelector("html")
1920  	appSt.isDark = !appSt.isDark
1921  	if appSt.isDark {
1922  		dom.AddClass(el, "dark")
1923  		localstorage.SetItem(lsKeyTheme, "dark")
1924  	} else {
1925  		dom.RemoveClass(el, "dark")
1926  		localstorage.SetItem(lsKeyTheme, "light")
1927  	}
1928  	updateThemeIcon()
1929  }
1930  
1931  // logActivity appends an entry to the activity log, newest first.
1932  //
1933  // The log is a fixed ring presized once to activityLogMax. The obvious form -
1934  // activityLog = []*logEntry{e} | activityLog - allocated a fresh
1935  // activityLogMax+1 entry backing array on EVERY entry and abandoned the
1936  // previous one in whatever arena was current, which at a wasm dispatch entry is
1937  // the root arena. Nothing reclaims a root-arena allocation: arenas release
1938  // whole frames and a package-level slice lives for the process, so a boot that
1939  // logged its few hundred steps leaked a few megabytes and kept leaking for the
1940  // life of the tab. The size is known, so the buffer is presized and written
1941  // through a cursor instead of ever entering the growth regime.
1942  func logActivity(action, summary, detail string) {
1943  	e := &logEntry{
1944  		ts:      dom.NowSeconds(),
1945  		action:  action,
1946  		summary: summary,
1947  		detail:  detail,
1948  	}
1949  	if appSt.activityLog == nil {
1950  		appSt.activityLog = []*logEntry{:activityLogMax}
1951  	}
1952  	appSt.activityLog[appSt.activityLogNext%activityLogMax] = e
1953  	appSt.activityLogNext++
1954  	if appSt.logListEl != 0 {
1955  		prependLogEntry(e)
1956  	}
1957  }
1958  
1959  // activityLogLen is the number of live entries, capped at activityLogMax.
1960  func activityLogLen() (n int32) {
1961  	if appSt.activityLogNext > activityLogMax {
1962  		return activityLogMax
1963  	}
1964  	return appSt.activityLogNext
1965  }
1966  
1967  // activityEntry returns entry i counted from the newest (0 = newest).
1968  func activityEntry(i int32) (e *logEntry) {
1969  	return appSt.activityLog[(appSt.activityLogNext-1-i)%activityLogMax]
1970  }
1971  
1972  // updateLogBtnHighlight sets the log button color: accent while boot is in
1973  // progress, muted otherwise.
1974  func updateLogBtnHighlight() {
1975  	if appSt.logBtn == 0 {
1976  		return
1977  	}
1978  	if appSt.bootInProgress {
1979  		dom.SetStyle(appSt.logBtn, "color", "var(--accent)")
1980  	} else {
1981  		dom.SetStyle(appSt.logBtn, "color", "var(--muted)")
1982  	}
1983  }
1984  
1985  // bootDone marks bootstrap as complete and transitions the UI from the
1986  // activity log page to the feed. Idempotent - extra calls are no-ops.
1987  func bootDone() {
1988  	if !appSt.bootInProgress {
1989  		return
1990  	}
1991  	appSt.bootInProgress = false
1992  	logActivity("boot", "bootstrap complete", "")
1993  	updateLogBtnHighlight()
1994  	if appSt.activePage == "log" {
1995  		switchPage("feed")
1996  	}
1997  }
1998  
1999  // formatHMS returns HH:MM:SS local time for unix seconds ts.
2000  func formatHMS(ts int64) (s string) {
2001  	off := int64(dom.TimezoneOffsetSeconds())
2002  	local := ts + off
2003  	if local < 0 {
2004  		local = 0
2005  	}
2006  	day := local % 86400
2007  	h := day / 3600
2008  	m := (day % 3600) / 60
2009  	sec := day % 60
2010  	pad := func(v int64) string {
2011  		if v < 10 {
2012  			return "0" | i64toa(v)
2013  		}
2014  		return i64toa(v)
2015  	}
2016  	return pad(h) | ":" | pad(m) | ":" | pad(sec)
2017  }
2018  
2019  // prependLogEntry inserts a single log entry row at the top of logListEl.
2020  // The row shows "HH:MM:SS [action] summary" and toggles detail on click.
2021  func prependLogEntry(e *logEntry) {
2022  	row := dom.CreateElement("div")
2023  	dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
2024  	dom.SetStyle(row, "padding", "4px 0")
2025  
2026  	header := dom.CreateElement("div")
2027  	dom.SetStyle(header, "cursor", "pointer")
2028  	dom.SetStyle(header, "display", "flex")
2029  	dom.SetStyle(header, "gap", "8px")
2030  	dom.SetStyle(header, "alignItems", "baseline")
2031  
2032  	tsSpan := dom.CreateElement("span")
2033  	dom.SetTextContent(tsSpan, formatHMS(e.ts))
2034  	dom.SetStyle(tsSpan, "color", "var(--muted)")
2035  	dom.SetStyle(tsSpan, "flexShrink", "0")
2036  	dom.AppendChild(header, tsSpan)
2037  
2038  	actSpan := dom.CreateElement("span")
2039  	dom.SetTextContent(actSpan, "[" | e.action | "]")
2040  	dom.SetStyle(actSpan, "color", "var(--accent)")
2041  	dom.SetStyle(actSpan, "flexShrink", "0")
2042  	dom.AppendChild(header, actSpan)
2043  
2044  	sumSpan := dom.CreateElement("span")
2045  	dom.SetTextContent(sumSpan, e.summary)
2046  	dom.SetStyle(sumSpan, "color", "var(--fg)")
2047  	dom.SetStyle(sumSpan, "overflow", "hidden")
2048  	dom.SetStyle(sumSpan, "textOverflow", "ellipsis")
2049  	dom.SetStyle(sumSpan, "whiteSpace", "nowrap")
2050  	dom.AppendChild(header, sumSpan)
2051  
2052  	dom.AppendChild(row, header)
2053  
2054  	if e.detail != "" {
2055  		detailEl := dom.CreateElement("pre")
2056  		dom.SetTextContent(detailEl, e.detail)
2057  		dom.SetStyle(detailEl, "display", "none")
2058  		dom.SetStyle(detailEl, "margin", "4px 0 0 0")
2059  		dom.SetStyle(detailEl, "padding", "6px 8px")
2060  		dom.SetStyle(detailEl, "background", "var(--bg2)")
2061  		dom.SetStyle(detailEl, "color", "var(--muted)")
2062  		dom.SetStyle(detailEl, "fontSize", "11px")
2063  		dom.SetStyle(detailEl, "whiteSpace", "pre-wrap")
2064  		dom.SetStyle(detailEl, "wordBreak", "break-all")
2065  		dom.SetStyle(detailEl, "borderRadius", "3px")
2066  		dom.AppendChild(row, detailEl)
2067  		open := false
2068  		dom.AddEventListener(header, "click", dom.RegisterCallback(func() {
2069  			open = !open
2070  			if open {
2071  				dom.SetStyle(detailEl, "display", "block")
2072  			} else {
2073  				dom.SetStyle(detailEl, "display", "none")
2074  			}
2075  		}))
2076  	}
2077  
2078  	// Insert at top of list.
2079  	first := dom.FirstChild(appSt.logListEl)
2080  	if first == 0 {
2081  		dom.AppendChild(appSt.logListEl, row)
2082  	} else {
2083  		dom.InsertBefore(appSt.logListEl, row, first)
2084  	}
2085  }
2086  
2087  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>`
2088  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>`
2089  
2090  func updateThemeIcon() {
2091  	if appSt.themeBtn == 0 {
2092  		return // not yet created (login screen)
2093  	}
2094  	if appSt.isDark {
2095  		dom.SetInnerHTML(appSt.themeBtn, svgSun)
2096  	} else {
2097  		dom.SetInnerHTML(appSt.themeBtn, svgMoon)
2098  	}
2099  }
2100  
2101  // --- Login screen ---
2102  
2103  func isMobile() (ok bool) {
2104  	ua := dom.UserAgent()
2105  	n := len(ua)
2106  	for i := 0; i+7 <= n; i++ {
2107  		if ua[i:i+7] == "Android" {
2108  			return true
2109  		}
2110  	}
2111  	for i := 0; i+6 <= n; i++ {
2112  		if ua[i:i+6] == "Mobile" {
2113  			return true
2114  		}
2115  	}
2116  	return false
2117  }
2118  
2119  func showAboutModal() {
2120  	switchPage("about")
2121  }
2122  
2123  // restoreSession resumes the saved identity once the signer worker is up and
2124  // the vault is unlocked. The signer is a worker that boots asynchronously, so
2125  // a single check races it and used to discard the saved pubkey outright,
2126  // leaving the app on the login screen even when the vault was about to be
2127  // usable. The saved key is kept: a later unlock can still resume the session.
2128  func restoreSession(savedPK string, tries int32) {
2129  	if tries <= 0 {
2130  		showLogin()
2131  		return
2132  	}
2133  	initSigner()
2134  	signer.IsInstalled(func(ok bool) {
2135  		if !ok {
2136  			dom.SetTimeout(func() { restoreSession(savedPK, tries-1) }, 250)
2137  			return
2138  		}
2139  		signer.GetVaultStatus(func(status string) {
2140  			if status != "unlocked" {
2141  				dom.SetTimeout(func() { restoreSession(savedPK, tries-1) }, 250)
2142  				return
2143  			}
2144  			signer.SwitchIdentity(savedPK, func(ok bool) {
2145  				if !ok {
2146  					showLogin()
2147  					return
2148  				}
2149  				appSt.pubhex = savedPK
2150  				bootstrapEncKey(func() {
2151  					showApp()
2152  				})
2153  			})
2154  		})
2155  	})
2156  }
2157  
2158  func showLogin() {
2159  	clearChildren(appSt.root)
2160  
2161  	// Allow scrolling on login page (body has overflow:hidden for the app).
2162  	dom.SetStyle(dom.Body(), "overflow", "auto")
2163  
2164  	wrap := dom.CreateElement("div")
2165  	dom.SetStyle(wrap, "display", "flex")
2166  	dom.SetStyle(wrap, "alignItems", "center")
2167  	dom.SetStyle(wrap, "flexDirection", "column")
2168  	dom.SetStyle(wrap, "padding", "48px 16px")
2169  
2170  	// musiquay logo.
2171  	logoDiv := dom.CreateElement("div")
2172  	dom.SetStyle(logoDiv, "width", "180px")
2173  	dom.SetStyle(logoDiv, "height", "180px")
2174  	dom.SetStyle(logoDiv, "marginBottom", "16px")
2175  	dom.SetStyle(logoDiv, "color", "var(--accent)")
2176  	dom.FetchText("/musiquay-logo.svg", func(svg string) {
2177  		dom.SetInnerHTML(logoDiv, svg)
2178  		svgEl := dom.FirstElementChild(logoDiv)
2179  		if svgEl != 0 {
2180  			dom.SetAttribute(svgEl, "width", "100%")
2181  			dom.SetAttribute(svgEl, "height", "100%")
2182  		}
2183  	})
2184  	dom.AppendChild(wrap, logoDiv)
2185  
2186  	// Title.
2187  	h1 := dom.CreateElement("h1")
2188  	dom.SetTextContent(h1, "MUSIQUAY")
2189  	dom.SetStyle(h1, "color", "var(--accent)")
2190  	dom.SetStyle(h1, "fontSize", "48px")
2191  	dom.SetStyle(h1, "marginBottom", "4px")
2192  	dom.AppendChild(wrap, h1)
2193  
2194  	verTag := dom.CreateElement("span")
2195  	dom.SetTextContent(verTag, version)
2196  	dom.SetStyle(verTag, "color", "var(--muted)")
2197  	dom.SetStyle(verTag, "fontSize", "12px")
2198  	dom.AppendChild(wrap, verTag)
2199  
2200  	sub := dom.CreateElement("p")
2201  	dom.SetStyle(sub, "color", "var(--muted)")
2202  	dom.SetStyle(sub, "fontSize", "14px")
2203  	dom.SetStyle(sub, "marginBottom", "32px")
2204  	dom.SetStyle(sub, "textAlign", "center")
2205  	dom.SetTextContent(sub, t("subtitle"))
2206  	dom.AppendChild(wrap, sub)
2207  
2208  	// Show login flow.
2209  	errEl := dom.CreateElement("div")
2210  	dom.SetStyle(errEl, "color", "#e55")
2211  	dom.SetStyle(errEl, "fontSize", "13px")
2212  	dom.SetStyle(errEl, "marginBottom", "12px")
2213  	dom.SetStyle(errEl, "minHeight", "18px")
2214  	dom.AppendChild(wrap, errEl)
2215  
2216  	btn := dom.CreateElement("button")
2217  	dom.SetTextContent(btn, t("login"))
2218  	dom.SetAttribute(btn, "type", "button")
2219  	dom.SetStyle(btn, "padding", "10px 32px")
2220  	dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
2221  	dom.SetStyle(btn, "fontSize", "14px")
2222  	dom.SetStyle(btn, "background", "var(--accent)")
2223  	dom.SetStyle(btn, "color", "#fff")
2224  	dom.SetStyle(btn, "border", "none")
2225  	dom.SetStyle(btn, "borderRadius", "4px")
2226  	dom.SetStyle(btn, "cursor", "pointer")
2227  	dom.AppendChild(wrap, btn)
2228  
2229  
2230  	appendLoginFooter(wrap)
2231  	dom.SetStyle(wrap, "paddingBottom", "48px")
2232  	dom.AppendChild(appSt.root, wrap)
2233  
2234  	buildStatusBar(false)
2235  	initSigner()
2236  
2237  	// Auto-login: if signer is ready with exactly one usable identity, skip the login page.
2238  	signer.IsInstalled(func(ok bool) {
2239  		if !ok {
2240  			return
2241  		}
2242  		signer.GetVaultStatus(func(status string) {
2243  			if status != "unlocked" {
2244  				return
2245  			}
2246  			signer.GetPublicKey(func(hex string) {
2247  				if hex == "" {
2248  					return
2249  				}
2250  				dom.ConsoleLog("[login] auto-login pubkey: " | hex)
2251  				appSt.pubhex = hex
2252  				localstorage.SetItem(lsKeyPubkey, appSt.pubhex)
2253  				clearChildren(appSt.root)
2254  				bootstrapEncKey(func() {
2255  					showApp()
2256  				})
2257  			})
2258  		})
2259  	})
2260  
2261  	cb := dom.RegisterCallback(func() {
2262  		signer.GetVaultStatus(func(status string) {
2263  			if status == "none" || status == "locked" {
2264  				toggleSignerPanel()
2265  				return
2266  			}
2267  			dom.SetTextContent(btn, t("requesting"))
2268  			signer.GetPublicKey(func(hex string) {
2269  				if hex == "" {
2270  					dom.SetTextContent(errEl, t("err_no_id"))
2271  					dom.SetTextContent(btn, t("login"))
2272  					toggleSignerPanel()
2273  					return
2274  				}
2275  				dom.ConsoleLog("[login] manual-login pubkey: " | hex)
2276  				appSt.pubhex = hex
2277  				localstorage.SetItem(lsKeyPubkey, appSt.pubhex)
2278  				clearChildren(appSt.root)
2279  				bootstrapEncKey(func() {
2280  					showApp()
2281  				})
2282  			})
2283  		})
2284  	})
2285  	dom.AddEventListener(btn, "click", cb)
2286  }
2287  
2288  func appendLoginFooter(wrap dom.Element) {
2289  	footer := dom.CreateElement("div")
2290  	dom.SetStyle(footer, "display", "flex")
2291  	dom.SetStyle(footer, "flexDirection", "column")
2292  	dom.SetStyle(footer, "gap", "12px")
2293  	dom.SetStyle(footer, "marginTop", "24px")
2294  	dom.SetStyle(footer, "alignItems", "center")
2295  
2296  	// Language chooser.
2297  	langBox := dom.CreateElement("div")
2298  	dom.SetStyle(langBox, "display", "flex")
2299  	dom.SetStyle(langBox, "alignItems", "center")
2300  	dom.SetStyle(langBox, "gap", "6px")
2301  
2302  	langLabel := dom.CreateElement("span")
2303  	dom.SetTextContent(langLabel, t("language"))
2304  	dom.SetStyle(langLabel, "fontSize", "12px")
2305  	dom.SetStyle(langLabel, "color", "var(--muted)")
2306  	dom.AppendChild(langBox, langLabel)
2307  
2308  	langSel := dom.CreateElement("select")
2309  	dom.SetStyle(langSel, "fontFamily", "'Fira Code', monospace")
2310  	dom.SetStyle(langSel, "fontSize", "12px")
2311  	dom.SetStyle(langSel, "background", "var(--bg2)")
2312  	dom.SetStyle(langSel, "color", "var(--fg)")
2313  	dom.SetStyle(langSel, "border", "1px solid var(--border)")
2314  	dom.SetStyle(langSel, "borderRadius", "4px")
2315  	dom.SetStyle(langSel, "padding", "4px 8px")
2316  	for code, name := range langNames {
2317  		opt := dom.CreateElement("option")
2318  		dom.SetAttribute(opt, "value", code)
2319  		dom.SetTextContent(opt, name)
2320  		if code == appSt.currentLang {
2321  			dom.SetAttribute(opt, "selected", "selected")
2322  		}
2323  		dom.AppendChild(langSel, opt)
2324  	}
2325  	dom.AddEventListener(langSel, "change", dom.RegisterCallback(func() {
2326  		val := dom.GetProperty(langSel, "value")
2327  		setLang(val)
2328  		showLogin() // re-render with new language
2329  	}))
2330  	dom.AppendChild(langBox, langSel)
2331  	dom.AppendChild(footer, langBox)
2332  
2333  	// Theme toggle.
2334  	themeBox := dom.CreateElement("div")
2335  	dom.SetStyle(themeBox, "display", "flex")
2336  	dom.SetStyle(themeBox, "alignItems", "center")
2337  	dom.SetStyle(themeBox, "gap", "6px")
2338  
2339  	themeLabel := dom.CreateElement("span")
2340  	dom.SetTextContent(themeLabel, t("theme"))
2341  	dom.SetStyle(themeLabel, "fontSize", "12px")
2342  	dom.SetStyle(themeLabel, "color", "var(--muted)")
2343  	dom.AppendChild(themeBox, themeLabel)
2344  
2345  	themeToggle := dom.CreateElement("button")
2346  	if appSt.isDark {
2347  		dom.SetTextContent(themeToggle, t("light"))
2348  	} else {
2349  		dom.SetTextContent(themeToggle, t("dark"))
2350  	}
2351  	dom.SetStyle(themeToggle, "fontFamily", "'Fira Code', monospace")
2352  	dom.SetStyle(themeToggle, "fontSize", "12px")
2353  	dom.SetStyle(themeToggle, "background", "var(--bg2)")
2354  	dom.SetStyle(themeToggle, "color", "var(--fg)")
2355  	dom.SetStyle(themeToggle, "border", "1px solid var(--border)")
2356  	dom.SetStyle(themeToggle, "borderRadius", "4px")
2357  	dom.SetStyle(themeToggle, "padding", "4px 12px")
2358  	dom.SetStyle(themeToggle, "cursor", "pointer")
2359  	dom.AddEventListener(themeToggle, "click", dom.RegisterCallback(func() {
2360  		toggleTheme()
2361  		if appSt.isDark {
2362  			dom.SetTextContent(themeToggle, t("light"))
2363  		} else {
2364  			dom.SetTextContent(themeToggle, t("dark"))
2365  		}
2366  	}))
2367  	dom.AppendChild(themeBox, themeToggle)
2368  	dom.AppendChild(footer, themeBox)
2369  
2370  	// Hard refresh button - clears SW cache and reloads.
2371  	optBox := dom.CreateElement("div")
2372  	dom.SetStyle(optBox, "display", "flex")
2373  	dom.SetStyle(optBox, "alignItems", "center")
2374  	dom.SetStyle(optBox, "gap", "4px")
2375  	refreshBtnw1 := dom.CreateElement("button")
2376  	dom.SetTextContent(refreshBtnw1, "\u21bb")
2377  	dom.SetStyle(refreshBtnw1, "fontSize", "14px")
2378  	dom.SetStyle(refreshBtnw1, "background", "transparent")
2379  	dom.SetStyle(refreshBtnw1, "border", "1px solid var(--border)")
2380  	dom.SetStyle(refreshBtnw1, "borderRadius", "4px")
2381  	dom.SetStyle(refreshBtnw1, "color", "var(--muted)")
2382  	dom.SetStyle(refreshBtnw1, "cursor", "pointer")
2383  	dom.SetStyle(refreshBtnw1, "padding", "2px 8px")
2384  	dom.SetStyle(refreshBtnw1, "marginLeft", "4px")
2385  	dom.AddEventListener(refreshBtnw1, "click", dom.RegisterCallback(func() {
2386  		dom.HardRefresh()
2387  	}))
2388  	dom.AppendChild(optBox, refreshBtnw1)
2389  
2390  	dom.AppendChild(footer, optBox)
2391  
2392  	// Dev mode toggle - disables translation worker.
2393  	devBox := dom.CreateElement("div")
2394  	dom.SetStyle(devBox, "display", "flex")
2395  	dom.SetStyle(devBox, "alignItems", "center")
2396  	dom.SetStyle(devBox, "gap", "6px")
2397  
2398  	devLabel := dom.CreateElement("span")
2399  	dom.SetTextContent(devLabel, "dev mode")
2400  	dom.SetStyle(devLabel, "fontSize", "12px")
2401  	dom.SetStyle(devLabel, "color", "var(--muted)")
2402  	dom.AppendChild(devBox, devLabel)
2403  
2404  	devToggle := dom.CreateElement("button")
2405  	if appSt.devMode {
2406  		dom.SetTextContent(devToggle, "on")
2407  		dom.SetStyle(devToggle, "color", "var(--accent)")
2408  	} else {
2409  		dom.SetTextContent(devToggle, "off")
2410  		dom.SetStyle(devToggle, "color", "var(--muted)")
2411  	}
2412  	dom.SetStyle(devToggle, "fontFamily", "'Fira Code', monospace")
2413  	dom.SetStyle(devToggle, "fontSize", "12px")
2414  	dom.SetStyle(devToggle, "background", "var(--bg2)")
2415  	dom.SetStyle(devToggle, "border", "1px solid var(--border)")
2416  	dom.SetStyle(devToggle, "borderRadius", "4px")
2417  	dom.SetStyle(devToggle, "padding", "4px 12px")
2418  	dom.SetStyle(devToggle, "cursor", "pointer")
2419  	dom.AddEventListener(devToggle, "click", dom.RegisterCallback(func() {
2420  		appSt.devMode = !appSt.devMode
2421  		if appSt.devMode {
2422  			localstorage.SetItem(lsKeyDevMode, "1")
2423  			dom.SetTextContent(devToggle, "on")
2424  			dom.SetStyle(devToggle, "color", "var(--accent)")
2425  		} else {
2426  			localstorage.RemoveItem(lsKeyDevMode)
2427  			dom.SetTextContent(devToggle, "off")
2428  			dom.SetStyle(devToggle, "color", "var(--muted)")
2429  		}
2430  	}))
2431  	dom.AppendChild(devBox, devToggle)
2432  	dom.AppendChild(footer, devBox)
2433  
2434  	dom.AppendChild(wrap, footer)
2435  }
2436  
2437  // --- Sidebar ---
2438  
2439  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>`
2440  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>`
2441  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>`
2442  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>`
2443  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>`
2444  
2445  // Action button SVGs - same stroke style as sidebar icons.
2446  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>`
2447  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>`
2448  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>`
2449  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>`
2450  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>`
2451  
2452  func makeSidebarIcon(svgHTML string, active bool) (e dom.Element) {
2453  	btn := dom.CreateElement("button")
2454  	dom.SetStyle(btn, "width", "36px")
2455  	dom.SetStyle(btn, "height", "36px")
2456  	dom.SetStyle(btn, "border", "none")
2457  	dom.SetStyle(btn, "borderRadius", "6px")
2458  	dom.SetStyle(btn, "cursor", "pointer")
2459  	dom.SetStyle(btn, "display", "flex")
2460  	dom.SetStyle(btn, "alignItems", "center")
2461  	dom.SetStyle(btn, "justifyContent", "center")
2462  	dom.SetStyle(btn, "padding", "0")
2463  	dom.SetStyle(btn, "color", "var(--fg)")
2464  	if active {
2465  		dom.SetStyle(btn, "background", "var(--accent)")
2466  		dom.SetStyle(btn, "color", "#fff")
2467  	} else {
2468  		dom.SetStyle(btn, "background", "transparent")
2469  	}
2470  	dom.SetInnerHTML(btn, svgHTML)
2471  	return btn
2472  }
2473  
2474  // teardownPage sends CLOSE for page-specific subscriptions on page exit.
2475  // stopInlineMedia restores the clickable YouTube thumbnail for any active
2476  // Invidious inline embeds. The wrapper's original click handler (registered by
2477  // attachYTLightboxes) is still in place so tapping the thumbnail re-embeds.
2478  func stopInlineMedia(container dom.Element) {
2479  	el := dom.QuerySelectorFrom(container, "[data-inv-ytid]")
2480  	for el != 0 {
2481  		ytID := dom.GetAttribute(el, "data-inv-ytid")
2482  		dom.RemoveAttribute(el, "data-inv-ytid")
2483  
2484  		// Clear iframe + controls.
2485  		dom.ReleaseChildren(el)
2486  		dom.SetInnerHTML(el, "")
2487  
2488  		// Restore the YouTube thumbnail so the user can tap to re-embed.
2489  		if ytID != "" {
2490  			thumbURL := "https://img.youtube.com/vi/" | ytID | "/hqdefault.jpg"
2491  			img := dom.CreateElement("img")
2492  			dom.SetAttribute(img, "referrerpolicy", "no-referrer")
2493  			dom.SetStyle(img, "display", "block")
2494  			dom.SetStyle(img, "maxWidth", "100%")
2495  			dom.SetStyle(img, "borderRadius", "8px")
2496  			dom.SetStyle(img, "objectFit", "contain")
2497  			setMediaSrc(img, thumbURL)
2498  			dom.AppendChild(el, img)
2499  		}
2500  		dom.SetStyle(el, "cursor", "pointer")
2501  
2502  		el = dom.QuerySelectorFrom(container, "[data-inv-ytid]")
2503  	}
2504  }
2505  
2506  func teardownPage(page string) {
2507  	if page == "feed" {
2508  		stopInlineMedia(appSt.feedContainer)
2509  		stopInlineMedia(appSt.threadContainer)
2510  	}
2511  	if page == "search" {
2512  		routeMsg("[\"CLOSE\",\"search\"]")
2513  		appSt.searchSeen = map[string]bool{}
2514  		// Always restore topBarRight and other hidden elements on exit.
2515  		if appSt.searchBarActive {
2516  			deactivateSearchBar(appSt.searchBtnGlobal)
2517  		}
2518  	}
2519  	if page == "notifications" {
2520  		// Cancel pending timers so their closures don't fire after page exit.
2521  		if appSt.notifMoreTimer != 0 {
2522  			dom.ClearTimeout(appSt.notifMoreTimer)
2523  			appSt.notifMoreTimer = 0
2524  		}
2525  		if appSt.notifRefTimer != 0 {
2526  			dom.ClearTimeout(appSt.notifRefTimer)
2527  			appSt.notifRefTimer = 0
2528  		}
2529  		// Release accumulated notif row callbacks to prevent indefinite
2530  		// growth of the Moxie callback table across multiple page visits.
2531  		releaseNotifRowCBs()
2532  		appSt.notifBuilt = false
2533  		// Clear avatar pending queue; the DOM elements are gone.
2534  		for pk := range appSt.pendingNotifAvatars {
2535  			delete(appSt.pendingNotifAvatars, pk)
2536  		}
2537  	}
2538  }
2539  
2540  func switchPage(name string) {
2541  	closeFeedPopover()
2542  	// Always close thread if open, even when re-selecting the feed tab.
2543  	if appSt.threadOpen {
2544  		closeNoteThread()
2545  		if !appSt.navPop {
2546  			dom.ReplaceState("/")
2547  		}
2548  	}
2549  	if name == appSt.activePage {
2550  		return
2551  	}
2552  	teardownPage(appSt.activePage)
2553  	closeProfileNoteSub()
2554  	if appSt.activePage == "profile" {
2555  		appSt.profileViewPK = ""
2556  	}
2557  
2558  	appSt.activePage = name
2559  
2560  	// Ensure top bar is in normal state.
2561  	dom.SetStyle(appSt.topBackBtn, "display", "none")
2562  	dom.SetStyle(appSt.feedSelectEl, "display", "none")
2563  	dom.SetStyle(appSt.pageTitleEl, "display", "none")
2564  	dom.SetStyle(appSt.notifBarGroup, "display", "none")
2565  	if name == "feed" {
2566  		dom.SetStyle(appSt.feedSelectEl, "display", "inline")
2567  	} else if name == "home" {
2568  		// The shell's empty page carries no title.
2569  	} else if name == "notifications" {
2570  		dom.SetStyle(appSt.notifBarGroup, "display", "flex")
2571  	} else {
2572  		dom.SetTextContent(appSt.pageTitleEl, t(name))
2573  		dom.SetStyle(appSt.pageTitleEl, "display", "inline")
2574  	}
2575  
2576  	// Hide all pages.
2577  	dom.SetStyle(appSt.homePage, "display", "none")
2578  	dom.SetStyle(appSt.feedPage, "display", "none")
2579  	dom.SetStyle(appSt.msgPage, "display", "none")
2580  	dom.SetStyle(appSt.profilePage, "display", "none")
2581  	dom.SetStyle(appSt.settingsPage, "display", "none")
2582  	dom.SetStyle(appSt.composePage, "display", "none")
2583  	dom.SetStyle(appSt.aboutPage, "display", "none")
2584  	dom.SetStyle(appSt.notifPage, "display", "none")
2585  	dom.SetStyle(appSt.searchPage, "display", "none")
2586  	if appSt.logPage != 0 {
2587  		dom.SetStyle(appSt.logPage, "display", "none")
2588  	}
2589  
2590  	// Clear sidebar highlights.
2591  	dom.SetStyle(appSt.sidebarCompose, "background", "transparent")
2592  	dom.SetStyle(appSt.sidebarCompose, "color", "var(--fg)")
2593  	dom.SetStyle(appSt.sidebarFeed, "background", "transparent")
2594  	dom.SetStyle(appSt.sidebarFeed, "color", "var(--fg)")
2595  	if appSt.sidebarMsg != 0 {
2596  		dom.SetStyle(appSt.sidebarMsg, "background", "transparent")
2597  		dom.SetStyle(appSt.sidebarMsg, "color", "var(--fg)")
2598  	}
2599  	dom.SetStyle(appSt.sidebarNotif, "background", "transparent")
2600  	dom.SetStyle(appSt.sidebarNotif, "color", "var(--fg)")
2601  	dom.SetStyle(appSt.sidebarSettings, "background", "transparent")
2602  	dom.SetStyle(appSt.sidebarSettings, "color", "var(--fg)")
2603  
2604  	switch name {
2605  	case "home":
2606  		dom.SetStyle(appSt.homePage, "display", "block")
2607  		if !appSt.navPop {
2608  			dom.PushState("/")
2609  		}
2610  	case "compose", "feed", "search":
2611  		// The kind-1 forum pages are parked in this base: compose, feed, note
2612  		// threads and search all land on the empty home page. Their page code
2613  		// (composePage, feedPage, searchPage and the feed worker) stays in the
2614  		// tree for when the forum is ported.
2615  		dom.SetStyle(appSt.homePage, "display", "block")
2616  		if !appSt.navPop {
2617  			dom.PushState("/")
2618  		}
2619  	case "notifications":
2620  		dom.SetStyle(appSt.notifPage, "display", "block")
2621  		dom.SetStyle(appSt.sidebarNotif, "background", "var(--accent)")
2622  		dom.SetStyle(appSt.sidebarNotif, "color", "#fff")
2623  		appSt.notifUnread = false
2624  		dom.SetStyle(appSt.notifDot, "display", "none")
2625  		dom.SetProperty(appSt.contentArea, "scrollTop", "0")
2626  		if !appSt.notifBuilt {
2627  			renderNotifPage()
2628  		}
2629  		if !appSt.navPop {
2630  			dom.PushState("/notifications")
2631  		}
2632  	case "settings":
2633  		dom.SetStyle(appSt.settingsPage, "display", "block")
2634  		dom.SetStyle(appSt.sidebarSettings, "background", "var(--accent)")
2635  		dom.SetStyle(appSt.sidebarSettings, "color", "#fff")
2636  		renderSettings()
2637  		if !appSt.navPop {
2638  			dom.PushState("/settings")
2639  		}
2640  	case "profile":
2641  		dom.SetStyle(appSt.profilePage, "display", "block")
2642  		dom.SetStyle(appSt.pageTitleEl, "display", "none")
2643  		dom.SetStyle(appSt.topBackBtn, "display", "inline")
2644  		// Profile URL is pushed by showProfile, not here.
2645  	case "about":
2646  		dom.SetStyle(appSt.aboutPage, "display", "block")
2647  		if !appSt.navPop {
2648  			dom.PushState("/about")
2649  		}
2650  	case "log":
2651  		dom.SetStyle(appSt.logPage, "display", "block")
2652  		dom.SetTextContent(appSt.pageTitleEl, "activity log")
2653  		dom.SetStyle(appSt.pageTitleEl, "display", "inline")
2654  		if !appSt.navPop {
2655  			dom.PushState("/log")
2656  		}
2657  	case "search":
2658  		dom.SetStyle(appSt.searchPage, "display", "block")
2659  		dom.SetStyle(appSt.pageTitleEl, "display", "none")
2660  		// Restore search input value without activating the bar (topBarRight
2661  		// stays visible). Bar is only activated by explicit user action.
2662  		if appSt.searchCurrentQ != "" && appSt.searchBtnGlobal != 0 {
2663  			dom.SetProperty(appSt.searchInputEl, "value", appSt.searchCurrentQ)
2664  		}
2665  		if !appSt.navPop {
2666  			if appSt.searchCurrentQ != "" {
2667  				dom.PushState("/search?q=" | dom.EncodeURIComponent(appSt.searchCurrentQ))
2668  			} else {
2669  				dom.PushState("/search")
2670  			}
2671  		}
2672  	}
2673  }
2674  
2675  // navigateToPath handles URL-based routing for back/forward and initial load.
2676  // fullPath may include a hash fragment, e.g. "/p/npub1...#follows".
2677  func navigateToPath(fullPath string) {
2678  	path := fullPath
2679  	hashw2 := ""
2680  	for i := 0; i < len(fullPath); i++ {
2681  		if fullPath[i] == '#' {
2682  			path = fullPath[:i]
2683  			hashw2 = fullPath[i+1:]
2684  			break
2685  		}
2686  	}
2687  
2688  	// Musiquay base: every kind-1 forum route (feed and its relay/follows
2689  	// modes, note threads, compose, search) lands on the empty home page. The
2690  	// route parsing and the pages stay in the tree for the port.
2691  	forumPath := path == "/" || path == "" || path == "/feed" ||
2692  		path == "/feed/follows" || path == "/feed/relays" ||
2693  		(len(path) > 12 && path[:12] == "/feed/relay/") ||
2694  		(len(path) > 3 && path[:3] == "/t/") ||
2695  		path == "/compose" || path == "/search" ||
2696  		(len(path) >= 8 && path[:8] == "/search?")
2697  	if forumPath {
2698  		switchPage("home")
2699  	} else if path == "/settings" {
2700  		switchPage("settings")
2701  	} else if path == "/notifications" {
2702  		switchPage("notifications")
2703  	} else if path == "/search" || (len(path) >= 8 && path[:8] == "/search?") {
2704  		// Parse ?q= parameter.
2705  		q := ""
2706  		qi := strIndex(path, "?q=")
2707  		if qi >= 0 {
2708  			q = path[qi+3:]
2709  			// URL-decode the query (basic: replace %20 etc.) - use raw for now.
2710  		}
2711  		if q != "" {
2712  			executeSearch(q)
2713  		} else {
2714  			switchPage("search")
2715  		}
2716  	} else if path == "/about" {
2717  		switchPage("about")
2718  	} else if path == "/log" {
2719  		switchPage("log")
2720  	} else if len(path) > 3 && path[:3] == "/p/" {
2721  		pk := npubToHex(path[3:])
2722  		if pk != "" {
2723  			showProfile(pk)
2724  			if hashw2 != "" {
2725  				selectProfileTab(hashw2, pk)
2726  			}
2727  		}
2728  	}
2729  }
2730  
2731  func npubToHex(npub string) (s string) {
2732  	b := helpers.DecodeNpub(npub)
2733  	if b == nil {
2734  		return ""
2735  	}
2736  	return helpers.HexEncode(b)
2737  }
2738  
2739  func initRouter() {
2740  	if appSt.routerInited {
2741  		return
2742  	}
2743  	appSt.routerInited = true
2744  	dom.OnPopState(func(path string) {
2745  		appSt.navPop = true
2746  		navigateToPath(path)
2747  		appSt.navPop = false
2748  	})
2749  	dom.InterceptInternalLinks(func(path string) {
2750  		navigateToPath(path)
2751  	})
2752  
2753  	// Navigate to initial URL if not root. At the root, show the home page
2754  	// without pushing "/": switchPage pushes the route when navPop is false,
2755  	// and pushing here would replace the URL this router is about to read -
2756  	// every deep link (/settings, /notifications) landed on home.
2757  	path := dom.GetPath()
2758  	if path != "/" && path != "" {
2759  		appSt.navPop = true
2760  		navigateToPath(path)
2761  		appSt.navPop = false
2762  	} else {
2763  		appSt.navPop = true
2764  		switchPage("home")
2765  		appSt.navPop = false
2766  		dom.ReplaceState("/")
2767  	}
2768  }
2769  
2770  func makeProtoBtn(label string) (e dom.Element) {
2771  	btn := dom.CreateElement("button")
2772  	dom.SetTextContent(btn, label)
2773  	dom.SetStyle(btn, "padding", "6px 8px")
2774  	dom.SetStyle(btn, "border", "none")
2775  	dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
2776  	dom.SetStyle(btn, "fontSize", "12px")
2777  	dom.SetStyle(btn, "cursor", "default")
2778  	dom.SetStyle(btn, "background", "transparent")
2779  	dom.SetStyle(btn, "color", "var(--fg)")
2780  	return btn
2781  }
2782  
2783  // notifEventsReset presizes the notification buffer to one slot more than its
2784  // cap so that a push never has to grow it.
2785  //
2786  // The old form grew with push and trimmed by reslicing from either end, so the
2787  // backing array doubled and the old one was abandoned in whatever arena was
2788  // current - the root arena at a wasm dispatch entry, where nothing reclaims it
2789  // (arenas release whole frames and a package-level slice lives for the
2790  // process). A busy relay pushed a fresh notifEventsMax-entry array out every
2791  // notifEventsMax events, for the life of the tab. With one spare slot the push
2792  // fits, the copy-down gives the slot back, and a presized buffer never
2793  // allocates again.
2794  func notifEventsReset() {
2795  	appSt.notifEvents = []*nostr.Event{:0:notifEventsMax + 1}
2796  }
2797  
2798  // notifEventsTrim drops the oldest entries down to notifEventsMax in place.
2799  // The append path keeps the buffer oldest-first, so the oldest are at the front.
2800  func notifEventsTrim() {
2801  	if len(appSt.notifEvents) > notifEventsMax {
2802  		drop := int32(len(appSt.notifEvents)) - notifEventsMax
2803  		for i := int32(0); i < notifEventsMax; i++ {
2804  			appSt.notifEvents[i] = appSt.notifEvents[drop+i]
2805  		}
2806  		appSt.notifEvents = appSt.notifEvents[:notifEventsMax]
2807  	}
2808  }
2809  
2810  // notifEventsPrepend inserts ev at the front in place. This path keeps the
2811  // buffer newest-first, so the entry to drop when it is over the cap is the last
2812  // one - trimming from the front here would discard the entry just inserted. The
2813  // shift is a backward loop rather than copy so it cannot depend on copy's
2814  // overlap behaviour.
2815  func notifEventsPrepend(ev *nostr.Event) {
2816  	if len(appSt.notifEvents) == cap(appSt.notifEvents) {
2817  		appSt.notifEvents = appSt.notifEvents[:len(appSt.notifEvents)-1]
2818  	}
2819  	appSt.notifEvents = appSt.notifEvents[:len(appSt.notifEvents)+1]
2820  	for i := int32(len(appSt.notifEvents)) - 1; i > 0; i-- {
2821  		appSt.notifEvents[i] = appSt.notifEvents[i-1]
2822  	}
2823  	appSt.notifEvents[0] = ev
2824  	if len(appSt.notifEvents) > notifEventsMax {
2825  		appSt.notifEvents = appSt.notifEvents[:notifEventsMax]
2826  	}
2827  }
2828  
2829  // --- Main app ---
2830  
2831  func showApp() {
2832  	dom.SetStyle(dom.Body(), "overflow", "hidden")
2833  	appSt.appReady = false
2834  	appSt.bootInProgress = true
2835  	appSt.activityLog = nil
2836  	appSt.activityLogNext = 0
2837  	appSt.seenOnSubs = map[string]func(){}
2838  	logActivity("boot", "bootstrap start " | version, "pubkey: " | appSt.pubhex)
2839  	// Preload NWC connections so zap button can offer NWC path without
2840  	// requiring a settings-page visit.
2841  	nwcRefreshList(nil)
2842  	// Init profile view caches and filter sets.
2843  	appSt.authorNames = map[string]string{}
2844  	appSt.authorPics = map[string]string{}
2845  	appSt.pendingNotes = map[string][]dom.Element{}
2846  	appSt.pendingNotifAvatars = map[string][]dom.Element{}
2847  	appSt.seenEvents = map[string]bool{}
2848  	appSt.fetchedK0 = map[string]bool{}
2849  	appSt.fetchedK0Old = map[string]bool{}
2850  	appSt.fetchedK10k = map[string]bool{}
2851  	appSt.fetchedK10kOld = map[string]bool{}
2852  	appSt.retryRound = 0
2853  	appSt.authorSubPK = map[string]string{}
2854  	appSt.eventRelays = map[string][]string{}
2855  	appSt.eventRelaysOld = map[string][]string{}
2856  	appSt.pendingDeletes = map[string]string{}
2857  	appSt.noteElements = map[string]dom.Element{}
2858  	appSt.myFollows = nil
2859  	appSt.myMutes = nil
2860  	appSt.followSet = map[string]bool{}
2861  	appSt.muteSet = map[string]bool{}
2862  	appSt.profileContentCache = map[string]string{}
2863  	appSt.profileFollowsCache = map[string][]string{}
2864  	appSt.profileMutesCache = map[string][]string{}
2865  	appSt.profileRelaysCache = map[string][]string{}
2866  	appSt.profileNotesSeen = map[string]bool{}
2867  	appSt.profileTabBtns = map[string]dom.Element{}
2868  	appSt.profileWrappers = map[string]dom.Element{}
2869  	appSt.profileWrapTabCont = map[string]dom.Element{}
2870  	appSt.profileWrapTabs = map[string]map[string]dom.Element{}
2871  	appSt.profileWrapTabSel = map[string]string{}
2872  	appSt.profileWrapOrder = nil
2873  	appSt.embedCallbacks = map[string][]string{}
2874  	appSt.embedRelayHints = map[string][]string{}
2875  	appSt.embedRequested = map[string]bool{}
2876  	appSt.embedSubIDs = map[string][]string{}
2877  	appSt.embedCoordCBs = map[string][]string{}
2878  	appSt.embedCoordSubIDs = map[string]string{}
2879  	appSt.threadEvents = map[string]*nostr.Event{}
2880  	appSt.replyCache = map[string]string{}
2881  	appSt.replyAvatarCache = map[string]string{}
2882  	appSt.replyLineCache = map[string]string{}
2883  	appSt.replyAuthorMap = map[string]string{}
2884  	appSt.replyAuthorMapOld = map[string]string{}
2885  	appSt.replyPending = map[string][]dom.Element{}
2886  	appSt.replyHints = map[string]string{}
2887  	appSt.replyNeedName = map[string][]dom.Element{}
2888  	appSt.replyNeedNameOld = map[string][]dom.Element{}
2889  	appSt.noteRepostCounts = map[string]map[string]bool{}
2890  	appSt.noteReactionMap = map[string]map[string]map[string]bool{}
2891  	appSt.noteReactionEls = map[string]dom.Element{}
2892  	appSt.noteRepostEls = map[string]dom.Element{}
2893  	appSt.noteZapTotals = map[string]int64{}
2894  	appSt.noteZapSeen = map[string]map[string]bool{}
2895  	appSt.noteZapEls = map[string]dom.Element{}
2896  	appSt.pendingZapRows = map[string][]zapRowPending{}
2897  	appSt.actionFetchedIDs = map[string]bool{}
2898  	appSt.eventCache = map[string]*nostr.Event{}
2899  	// Initialize "old" generation maps so range over nil is safe on first rotate.
2900  	appSt.eventCacheOld = map[string]*nostr.Event{}
2901  	appSt.seenEventsOld = map[string]bool{}
2902  	appSt.noteElementsOld = map[string]dom.Element{}
2903  	appSt.noteReactionMapOld = map[string]map[string]map[string]bool{}
2904  	appSt.noteRepostCountsOld = map[string]map[string]bool{}
2905  	appSt.noteZapSeenOld = map[string]map[string]bool{}
2906  	appSt.actionFetchedIDsOld = map[string]bool{}
2907  	appSt.authorNamesOld = map[string]string{}
2908  	appSt.authorPicsOld = map[string]string{}
2909  	appSt.embedCallbacksOld = map[string][]string{}
2910  	appSt.embedRequestedOld = map[string]bool{}
2911  	appSt.embedSubIDsOld = map[string][]string{}
2912  	appSt.embedCoordCBsOld = map[string][]string{}
2913  	appSt.replyCacheOld = map[string]string{}
2914  	appSt.replyAvatarCacheOld = map[string]string{}
2915  	appSt.replyLineCacheOld = map[string]string{}
2916  	appSt.eventCacheCount = 0
2917  	appSt.authorCacheCount = 0
2918  	appSt.embedCacheCount = 0
2919  	appSt.replyCount = 0
2920  	appSt.editorDrafts = map[string]string{}
2921  	appSt.notifSeen = map[string]bool{}
2922  	notifEventsReset()
2923  	releaseNotifRowCBs()
2924  	appSt.notifUnread = false
2925  	appSt.notifBuilt = false
2926  	dom.IDBGet("settings", "notif-read-ts", func(val string) {
2927  		if val != "" {
2928  			appSt.notifReadTs = parseI64(val)
2929  		}
2930  	})
2931  	if appSt.feedMode == "" {
2932  		appSt.feedMode = "follows"
2933  	}
2934  	appSt.feedInitialLoad = true
2935  	appSt.feedBuffer = nil
2936  	initEmoji()
2937  	initMentionPopup()
2938  
2939  	// Set up SW + relay-proxy worker communication. Both deliver into the
2940  	// same dispatch (onSWMessage); message-type prefixes are unique per
2941  	// source so they cannot collide.
2942  	if !appSt.routerInited {
2943  		dom.OnSWMessage(onSWMessage)
2944  		relayproxy.OnMessage(onSWMessage)
2945  		profile.OnMessage(onSWMessage)
2946  		feed.OnMessage(onSWMessage)
2947  		notif.OnMessage(onSWMessage)
2948  	}
2949  	routeMsg("[\"SET_PUBKEY\"," | jstr(appSt.pubhex) | "]")
2950  	sendEncKeyToSW()
2951  
2952  	// Load cached profiles from IndexedDB.
2953  	// Populate name/pic view cache and send loaded pks to Profile Worker.
2954  	var idbLoadedPKs []string
2955  	dom.IDBGetAll("profiles", func(key, val string) {
2956  		name := helpers.JsonGetString(val, "name")
2957  		if len(name) == 0 {
2958  			name = helpers.JsonGetString(val, "display_name")
2959  		}
2960  		pic := helpers.JsonGetString(val, "picture")
2961  		if len(name) > 0 {
2962  			setAuthorName(key, name)
2963  		}
2964  		if len(pic) > 0 {
2965  			setAuthorPic(key, pic)
2966  		}
2967  		appSt.profileContentCache[key] = val
2968  		idbLoadedPKs = mxutil.Ensure(idbLoadedPKs, 1)
2969  		idbLoadedPKs = push(idbLoadedPKs, key)
2970  	}, func() {
2971  		appSt.idbLoaded = true
2972  		// Fill pending note headers rendered before IDB finished.
2973  		for pk, headers := range appSt.pendingNotes {
2974  			name, nameOK := lookupAuthorName(pk)
2975  			if !nameOK || len(name) == 0 {
2976  				continue
2977  			}
2978  			pic, _ := lookupAuthorPic(pk)
2979  			for _, h := range headers {
2980  				updateNoteHeader(h, name, pic)
2981  			}
2982  			delete(appSt.pendingNotes, pk)
2983  		}
2984  		// Tell Profile Worker which pks are already cached so it skips fetching them.
2985  		if len(idbLoadedPKs) > 0 {
2986  			profile.Send(`["P_IDB_LOADED",` | buildJSONStrArr(idbLoadedPKs) | `]`)
2987  			idbLoadedPKs = nil
2988  		}
2989  		// Re-render profile page if opened before IDB finished.
2990  		if appSt.profileViewPK != "" && appSt.activePage == "profile" {
2991  			renderProfilePage(appSt.profileViewPK)
2992  		}
2993  	})
2994  
2995  	// === Top bar ===
2996  	bar := dom.CreateElement("div")
2997  	dom.SetStyle(bar, "display", "flex")
2998  	dom.SetStyle(bar, "alignItems", "center")
2999  	dom.SetStyle(bar, "padding", "8px")
3000  	dom.SetStyle(bar, "height", "48px")
3001  	dom.SetStyle(bar, "boxSizing", "border-box")
3002  	dom.SetStyle(bar, "background", "var(--bg2)")
3003  	dom.SetStyle(bar, "position", "fixed")
3004  	dom.SetStyle(bar, "top", "0")
3005  	dom.SetStyle(bar, "left", "0")
3006  	dom.SetStyle(bar, "right", "0")
3007  	dom.SetStyle(bar, "zIndex", "100")
3008  
3009  	// Left: page title.
3010  	left := dom.CreateElement("div")
3011  	dom.SetStyle(left, "display", "flex")
3012  	dom.SetStyle(left, "alignItems", "center")
3013  	dom.SetStyle(left, "flex", "1")
3014  	dom.SetStyle(left, "minWidth", "0")
3015  
3016  	// Logo in top-left.
3017  	logo := dom.CreateElement("div")
3018  	dom.SetStyle(logo, "width", "32px")
3019  	dom.SetStyle(logo, "height", "32px")
3020  	dom.SetStyle(logo, "flexShrink", "0")
3021  	dom.SetStyle(logo, "color", "var(--accent)")
3022  	dom.SetStyle(logo, "marginRight", "8px")
3023  	dom.FetchText("/musiquay-logo.svg", func(svg string) {
3024  		appSt.logoSVGCache = svg
3025  		dom.SetInnerHTML(logo, svg)
3026  		svgEl := dom.FirstElementChild(logo)
3027  		if svgEl != 0 {
3028  			dom.SetAttribute(svgEl, "width", "100%")
3029  			dom.SetAttribute(svgEl, "height", "100%")
3030  		}
3031  		// Also populate the about page logo if it was built before fetch completed.
3032  		if appSt.aboutLogoEl != 0 {
3033  			dom.SetInnerHTML(appSt.aboutLogoEl, svg)
3034  			al := dom.FirstElementChild(appSt.aboutLogoEl)
3035  			if al != 0 {
3036  				dom.SetAttribute(al, "width", "100%")
3037  				dom.SetAttribute(al, "height", "100%")
3038  			}
3039  		}
3040  	})
3041  	dom.SetStyle(logo, "cursor", "pointer")
3042  	dom.AddEventListener(logo, "click", dom.RegisterCallback(func() {
3043  		showAboutModal()
3044  	}))
3045  	dom.AppendChild(left, logo)
3046  
3047  	appSt.topBackBtn = dom.CreateElement("span")
3048  	dom.SetStyle(appSt.topBackBtn, "display", "none")
3049  	dom.SetStyle(appSt.topBackBtn, "cursor", "pointer")
3050  	dom.SetStyle(appSt.topBackBtn, "color", "var(--accent)")
3051  	dom.SetStyle(appSt.topBackBtn, "fontSize", "18px")
3052  	dom.SetStyle(appSt.topBackBtn, "fontWeight", "bold")
3053  	dom.SetTextContent(appSt.topBackBtn, t("back"))
3054  	dom.AddEventListener(appSt.topBackBtn, "click", dom.RegisterCallback(func() {
3055  		dom.Back()
3056  	}))
3057  	dom.AppendChild(left, appSt.topBackBtn)
3058  
3059  	// Feed selector button (opens popup). Absolute-positioned so it sits at
3060  	// the horizontal center of the top bar, independent of logo/back width.
3061  	appSt.feedSelectEl = dom.CreateElement("button")
3062  	dom.SetStyle(appSt.feedSelectEl, "position", "absolute")
3063  	dom.SetStyle(appSt.feedSelectEl, "left", "50%")
3064  	dom.SetStyle(appSt.feedSelectEl, "top", "50%")
3065  	dom.SetStyle(appSt.feedSelectEl, "transform", "translate(-50%, -50%)")
3066  	dom.SetStyle(appSt.feedSelectEl, "fontSize", "16px")
3067  	dom.SetStyle(appSt.feedSelectEl, "fontWeight", "bold")
3068  	dom.SetStyle(appSt.feedSelectEl, "background", "transparent")
3069  	dom.SetStyle(appSt.feedSelectEl, "border", "none")
3070  	dom.SetStyle(appSt.feedSelectEl, "color", "var(--fg)")
3071  	dom.SetStyle(appSt.feedSelectEl, "cursor", "pointer")
3072  	dom.SetStyle(appSt.feedSelectEl, "outline", "none")
3073  	dom.SetStyle(appSt.feedSelectEl, "maxWidth", "200px")
3074  	dom.SetStyle(appSt.feedSelectEl, "padding", "4px 8px")
3075  	dom.SetStyle(appSt.feedSelectEl, "borderRadius", "4px")
3076  	updateFeedBtnText()
3077  	dom.AddEventListener(appSt.feedSelectEl, "click", dom.RegisterCallback(func() {
3078  		toggleFeedPopover()
3079  	}))
3080  	dom.AppendChild(bar, appSt.feedSelectEl)
3081  
3082  	appSt.notifFilter = "all"
3083  
3084  	appSt.notifBarGroup = dom.CreateElement("div")
3085  	dom.SetStyle(appSt.notifBarGroup, "position", "absolute")
3086  	dom.SetStyle(appSt.notifBarGroup, "left", "50%")
3087  	dom.SetStyle(appSt.notifBarGroup, "top", "50%")
3088  	dom.SetStyle(appSt.notifBarGroup, "transform", "translate(-50%, -50%)")
3089  	dom.SetStyle(appSt.notifBarGroup, "display", "none")
3090  	dom.SetStyle(appSt.notifBarGroup, "alignItems", "center")
3091  	dom.SetStyle(appSt.notifBarGroup, "gap", "8px")
3092  
3093  	appSt.notifSelectEl = dom.CreateElement("select")
3094  	dom.SetStyle(appSt.notifSelectEl, "fontSize", "13px")
3095  	dom.SetStyle(appSt.notifSelectEl, "background", "var(--bg)")
3096  	dom.SetStyle(appSt.notifSelectEl, "border", "1px solid var(--border)")
3097  	dom.SetStyle(appSt.notifSelectEl, "borderRadius", "4px")
3098  	dom.SetStyle(appSt.notifSelectEl, "color", "var(--fg)")
3099  	dom.SetStyle(appSt.notifSelectEl, "cursor", "pointer")
3100  	dom.SetStyle(appSt.notifSelectEl, "outline", "none")
3101  	dom.SetStyle(appSt.notifSelectEl, "padding", "3px 6px")
3102  	dom.SetStyle(appSt.notifSelectEl, "fontFamily", "'Fira Code', monospace")
3103  	for _, opt := range []string{"all", "mentions", "reactions", "zaps"} {
3104  		o := dom.CreateElement("option")
3105  		dom.SetAttribute(o, "value", opt)
3106  		dom.SetTextContent(o, opt)
3107  		dom.AppendChild(appSt.notifSelectEl, o)
3108  	}
3109  	dom.AddEventListener(appSt.notifSelectEl, "change", dom.RegisterCallback(func() {
3110  		appSt.notifFilter = dom.GetProperty(appSt.notifSelectEl, "value")
3111  		rebuildNotifRows()
3112  	}))
3113  	dom.AppendChild(appSt.notifBarGroup, appSt.notifSelectEl)
3114  
3115  	appSt.notifMarkBtn = dom.CreateElement("button")
3116  	dom.SetTextContent(appSt.notifMarkBtn, "read")
3117  	dom.SetStyle(appSt.notifMarkBtn, "fontSize", "12px")
3118  	dom.SetStyle(appSt.notifMarkBtn, "padding", "3px 8px")
3119  	dom.SetStyle(appSt.notifMarkBtn, "border", "1px solid var(--border)")
3120  	dom.SetStyle(appSt.notifMarkBtn, "borderRadius", "4px")
3121  	dom.SetStyle(appSt.notifMarkBtn, "background", "transparent")
3122  	dom.SetStyle(appSt.notifMarkBtn, "color", "var(--fg)")
3123  	dom.SetStyle(appSt.notifMarkBtn, "cursor", "pointer")
3124  	dom.SetStyle(appSt.notifMarkBtn, "fontFamily", "'Fira Code', monospace")
3125  	dom.AddEventListener(appSt.notifMarkBtn, "click", dom.RegisterCallback(func() {
3126  		markNotifsRead()
3127  	}))
3128  	dom.AppendChild(appSt.notifBarGroup, appSt.notifMarkBtn)
3129  	dom.AppendChild(bar, appSt.notifBarGroup)
3130  
3131  	appSt.pageTitleEl = dom.CreateElement("span")
3132  	dom.SetStyle(appSt.pageTitleEl, "fontSize", "18px")
3133  	dom.SetStyle(appSt.pageTitleEl, "fontWeight", "bold")
3134  	dom.SetStyle(appSt.pageTitleEl, "display", "none")
3135  	dom.AppendChild(left, appSt.pageTitleEl)
3136  	appSt.topBarLeft = left
3137  	dom.AppendChild(bar, left)
3138  
3139  	// Right: wallet + signer (gear) icon buttons.
3140  	appSt.topBarRight = dom.CreateElement("div")
3141  	right := appSt.topBarRight
3142  	dom.SetStyle(right, "display", "flex")
3143  	dom.SetStyle(right, "alignItems", "center")
3144  	dom.SetStyle(right, "gap", "8px")
3145  	dom.SetStyle(right, "flex", "1")
3146  	dom.SetStyle(right, "justifyContent", "flex-end")
3147  
3148  	appSt.walletBtnEl = dom.CreateElement("button")
3149  	dom.SetInnerHTML(appSt.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>`)
3150  	dom.SetAttribute(appSt.walletBtnEl, "title", "wallet")
3151  	dom.SetStyle(appSt.walletBtnEl, "background", "transparent")
3152  	dom.SetStyle(appSt.walletBtnEl, "border", "none")
3153  	dom.SetStyle(appSt.walletBtnEl, "color", "var(--muted)")
3154  	dom.SetStyle(appSt.walletBtnEl, "height", "32px")
3155  	dom.SetStyle(appSt.walletBtnEl, "width", "32px")
3156  	dom.SetStyle(appSt.walletBtnEl, "display", "flex")
3157  	dom.SetStyle(appSt.walletBtnEl, "alignItems", "center")
3158  	dom.SetStyle(appSt.walletBtnEl, "justifyContent", "center")
3159  	dom.SetStyle(appSt.walletBtnEl, "cursor", "pointer")
3160  	dom.SetStyle(appSt.walletBtnEl, "borderRadius", "4px")
3161  	dom.AddEventListener(appSt.walletBtnEl, "click", dom.RegisterCallback(func() {
3162  		toggleWalletPanel()
3163  	}))
3164  	// Wallet moves to bottom-bar icon group; refreshBtn lives here in top-right.
3165  	_ = appSt.walletBtnEl
3166  
3167  	dom.AppendChild(bar, right)
3168  
3169  	dom.AppendChild(appSt.root, bar)
3170  
3171  	// === Main layout: content only (icons moved to bottom bar) ===
3172  	mainLayout := dom.CreateElement("div")
3173  	dom.SetStyle(mainLayout, "position", "fixed")
3174  	dom.SetStyle(mainLayout, "top", "48px")
3175  	dom.SetStyle(mainLayout, "bottom", "36px")
3176  	dom.SetStyle(mainLayout, "left", "0")
3177  	dom.SetStyle(mainLayout, "right", "0")
3178  	dom.SetStyle(mainLayout, "display", "flex")
3179  
3180  	appSt.sidebarCompose = makeSidebarIcon(svgCompose, false)
3181  	dom.AddEventListener(appSt.sidebarCompose, "click", dom.RegisterCallback(func() {
3182  		switchPage("compose")
3183  	}))
3184  
3185  	appSt.sidebarFeed = makeSidebarIcon(svgFeed, true)
3186  	dom.AddEventListener(appSt.sidebarFeed, "click", dom.RegisterCallback(func() {
3187  		switchPage("feed")
3188  	}))
3189  
3190  	// Messaging is disabled until the MLS/DM fix lands: no sidebar entry, no
3191  	// messaging route, and relay-proxy MLS routing is off. See initMessaging.
3192  	appSt.sidebarNotif = dom.CreateElement("button")
3193  	dom.SetStyle(appSt.sidebarNotif, "width", "36px")
3194  	dom.SetStyle(appSt.sidebarNotif, "height", "36px")
3195  	dom.SetStyle(appSt.sidebarNotif, "border", "none")
3196  	dom.SetStyle(appSt.sidebarNotif, "borderRadius", "6px")
3197  	dom.SetStyle(appSt.sidebarNotif, "cursor", "pointer")
3198  	dom.SetStyle(appSt.sidebarNotif, "display", "flex")
3199  	dom.SetStyle(appSt.sidebarNotif, "alignItems", "center")
3200  	dom.SetStyle(appSt.sidebarNotif, "justifyContent", "center")
3201  	dom.SetStyle(appSt.sidebarNotif, "padding", "0")
3202  	dom.SetStyle(appSt.sidebarNotif, "background", "transparent")
3203  	dom.SetStyle(appSt.sidebarNotif, "color", "var(--fg)")
3204  	dom.SetStyle(appSt.sidebarNotif, "position", "relative")
3205  	dom.SetInnerHTML(appSt.sidebarNotif, svgBell)
3206  	appSt.notifDot = dom.CreateElement("span")
3207  	dom.SetStyle(appSt.notifDot, "position", "absolute")
3208  	dom.SetStyle(appSt.notifDot, "top", "4px")
3209  	dom.SetStyle(appSt.notifDot, "right", "5px")
3210  	dom.SetStyle(appSt.notifDot, "width", "8px")
3211  	dom.SetStyle(appSt.notifDot, "height", "8px")
3212  	dom.SetStyle(appSt.notifDot, "borderRadius", "50%")
3213  	dom.SetStyle(appSt.notifDot, "background", "var(--accent)")
3214  	dom.SetStyle(appSt.notifDot, "display", "none")
3215  	dom.AppendChild(appSt.sidebarNotif, appSt.notifDot)
3216  	dom.AddEventListener(appSt.sidebarNotif, "click", dom.RegisterCallback(func() {
3217  		switchPage("notifications")
3218  	}))
3219  
3220  	appSt.sidebarSettings = makeSidebarIcon(svgGear, false)
3221  	dom.AddEventListener(appSt.sidebarSettings, "click", dom.RegisterCallback(func() {
3222  		switchPage("settings")
3223  	}))
3224  
3225  	appSt.themeBtn = dom.CreateElement("button")
3226  	dom.SetStyle(appSt.themeBtn, "background", "transparent")
3227  	dom.SetStyle(appSt.themeBtn, "border", "none")
3228  	dom.SetStyle(appSt.themeBtn, "width", "32px")
3229  	dom.SetStyle(appSt.themeBtn, "height", "32px")
3230  	dom.SetStyle(appSt.themeBtn, "cursor", "pointer")
3231  	dom.SetStyle(appSt.themeBtn, "padding", "0")
3232  	dom.SetStyle(appSt.themeBtn, "display", "flex")
3233  	dom.SetStyle(appSt.themeBtn, "alignItems", "center")
3234  	dom.SetStyle(appSt.themeBtn, "justifyContent", "center")
3235  	dom.SetStyle(appSt.themeBtn, "color", "var(--muted)")
3236  	updateThemeIcon()
3237  	dom.AddEventListener(appSt.themeBtn, "click", dom.RegisterCallback(func() {
3238  		toggleTheme()
3239  	}))
3240  
3241  	// Content area.
3242  	appSt.contentArea = dom.CreateElement("div")
3243  	dom.SetAttribute(appSt.contentArea, "id", "content-area")
3244  	dom.SetStyle(appSt.contentArea, "flex", "1")
3245  	dom.SetStyle(appSt.contentArea, "minHeight", "0")
3246  	dom.SetStyle(appSt.contentArea, "overflowY", "auto")
3247  
3248  	// Home page - the shell's empty main area.
3249  	appSt.homePage = dom.CreateElement("div")
3250  	dom.SetAttribute(appSt.homePage, "id", "home-page")
3251  	dom.SetStyle(appSt.homePage, "display", "none")
3252  	dom.SetStyle(appSt.homePage, "padding", "16px")
3253  	dom.SetStyle(appSt.homePage, "maxWidth", "640px")
3254  	dom.SetStyle(appSt.homePage, "margin", "0 auto")
3255  	dom.SetStyle(appSt.homePage, "boxSizing", "border-box")
3256  	dom.AppendChild(appSt.contentArea, appSt.homePage)
3257  
3258  	// Feed page (parked; see homePage above).
3259  	appSt.feedPage = dom.CreateElement("div")
3260  	dom.SetAttribute(appSt.feedPage, "id", "feed-page")
3261  	dom.SetStyle(appSt.feedPage, "display", "none")
3262  	dom.SetStyle(appSt.feedPage, "padding", "16px")
3263  	dom.SetStyle(appSt.feedPage, "maxWidth", "640px")
3264  	dom.SetStyle(appSt.feedPage, "margin", "0 auto")
3265  	dom.SetStyle(appSt.feedPage, "boxSizing", "border-box")
3266  
3267  	// Loading spinner - shown until first feed event arrives.
3268  	appSt.feedLoader = dom.CreateElement("div")
3269  	dom.SetStyle(appSt.feedLoader, "display", "flex")
3270  	dom.SetStyle(appSt.feedLoader, "flexDirection", "column")
3271  	dom.SetStyle(appSt.feedLoader, "alignItems", "center")
3272  	dom.SetStyle(appSt.feedLoader, "justifyContent", "center")
3273  	dom.SetStyle(appSt.feedLoader, "padding", "64px 0")
3274  	loaderImg := dom.CreateElement("div")
3275  	dom.SetStyle(loaderImg, "width", "120px")
3276  	dom.SetStyle(loaderImg, "height", "120px")
3277  	dom.FetchText("/musiquay-loader.svg", func(svg string) {
3278  		dom.SetInnerHTML(loaderImg, svg)
3279  		svgEl := dom.FirstElementChild(loaderImg)
3280  		if svgEl != 0 {
3281  			dom.SetAttribute(svgEl, "width", "100%")
3282  			dom.SetAttribute(svgEl, "height", "100%")
3283  		}
3284  	})
3285  	dom.AppendChild(appSt.feedLoader, loaderImg)
3286  	loaderText := dom.CreateElement("div")
3287  	dom.SetTextContent(loaderText, t("connecting"))
3288  	dom.SetStyle(loaderText, "marginTop", "16px")
3289  	dom.SetStyle(loaderText, "color", "var(--muted)")
3290  	dom.SetStyle(loaderText, "fontSize", "14px")
3291  	dom.AppendChild(appSt.feedLoader, loaderText)
3292  	dom.AppendChild(appSt.feedPage, appSt.feedLoader)
3293  
3294  	// Pull-to-refresh indicator (hidden until user pulls at top).
3295  	pullIndicator := dom.CreateElement("div")
3296  	dom.SetStyle(pullIndicator, "display", "none")
3297  	dom.SetStyle(pullIndicator, "justifyContent", "center")
3298  	dom.SetStyle(pullIndicator, "alignItems", "center")
3299  	dom.SetStyle(pullIndicator, "padding", "12px 0")
3300  	dom.SetStyle(pullIndicator, "color", "var(--muted)")
3301  	dom.SetStyle(pullIndicator, "fontSize", "13px")
3302  	dom.SetTextContent(pullIndicator, "\u21bb refreshing...")
3303  	dom.AppendChild(appSt.feedPage, pullIndicator)
3304  
3305  	appSt.feedContainer = dom.CreateElement("div")
3306  	dom.AppendChild(appSt.feedPage, appSt.feedContainer)
3307  
3308  	// "Load more" button - fallback for infinite scroll.
3309  	appSt.loadMoreBtn = dom.CreateElement("div")
3310  	dom.SetStyle(appSt.loadMoreBtn, "display", "none")
3311  	dom.SetStyle(appSt.loadMoreBtn, "textAlign", "center")
3312  	dom.SetStyle(appSt.loadMoreBtn, "padding", "16px 0")
3313  	loadMoreLink := dom.CreateElement("span")
3314  	dom.SetTextContent(loadMoreLink, "load more")
3315  	dom.SetStyle(loadMoreLink, "cursor", "pointer")
3316  	dom.SetStyle(loadMoreLink, "color", "var(--accent)")
3317  	dom.SetStyle(loadMoreLink, "fontSize", "14px")
3318  	dom.AddEventListener(loadMoreLink, "click", dom.RegisterCallback(func() {
3319  		if !appSt.feedLoading && !appSt.feedExhausted && appSt.oldestFeedTs > 0 {
3320  			dom.SetStyle(appSt.loadMoreBtn, "display", "none")
3321  			loadOlderFeed()
3322  		}
3323  	}))
3324  	dom.AppendChild(appSt.loadMoreBtn, loadMoreLink)
3325  
3326  	// Thread view (hidden overlay within feedPage).
3327  	appSt.threadPage = dom.CreateElement("div")
3328  	dom.SetAttribute(appSt.threadPage, "id", "thread-page")
3329  	dom.SetStyle(appSt.threadPage, "display", "none")
3330  
3331  	appSt.threadContainer = dom.CreateElement("div")
3332  	dom.AppendChild(appSt.threadPage, appSt.threadContainer)
3333  	dom.AppendChild(appSt.feedPage, appSt.threadPage)
3334  
3335  	// Load-more after thread so it sits at the bottom in both views.
3336  	dom.AppendChild(appSt.feedPage, appSt.loadMoreBtn)
3337  
3338  	dom.AppendChild(appSt.contentArea, appSt.feedPage)
3339  
3340  	// Pull-to-refresh: wheel up or touch pull at top triggers feed reload.
3341  	dom.OnPullRefresh(appSt.contentArea, pullIndicator, func() {
3342  		if appSt.activePage == "feed" && !appSt.refreshSpinning {
3343  			onRefreshClick()
3344  			dom.SetTimeout(func() {
3345  				dom.SetStyle(pullIndicator, "display", "none")
3346  			}, 1500)
3347  		} else if appSt.activePage == "notifications" {
3348  			dom.SetProperty(appSt.contentArea, "scrollTop", "0")
3349  			dom.SetTimeout(func() {
3350  				dom.SetStyle(pullIndicator, "display", "none")
3351  			}, 1500)
3352  		}
3353  	})
3354  
3355  	// Infinite scroll - load older events when near bottom.
3356  	dom.AddEventListener(appSt.contentArea, "scroll", dom.RegisterCallback(func() {
3357  		st := dom.GetProperty(appSt.contentArea, "scrollTop")
3358  		ch := dom.GetProperty(appSt.contentArea, "clientHeight")
3359  		sh := dom.GetProperty(appSt.contentArea, "scrollHeight")
3360  		top := parseIntProp(st)
3361  		height := parseIntProp(ch)
3362  		total := parseIntProp(sh)
3363  		nearBottom := top+height >= total-400
3364  
3365  		if appSt.activePage == "feed" {
3366  			if top <= 10 && len(appSt.feedBuffer) > 0 {
3367  				for _, bev := range appSt.feedBuffer {
3368  					renderNote(bev)
3369  					appSt.seenEvents[bev.ID] = true
3370  				}
3371  				appSt.feedBuffer = nil
3372  				appSt.feedHasNew = false
3373  				stopRefreshPulse()
3374  			}
3375  			if appSt.feedLoading || appSt.feedExhausted || appSt.oldestFeedTs == 0 {
3376  				return
3377  			}
3378  			if nearBottom {
3379  				loadOlderFeed()
3380  			}
3381  		} else if appSt.activePage == "notifications" {
3382  			if appSt.notifLoading || appSt.notifExhausted || appSt.oldestNotifTs == 0 {
3383  				return
3384  			}
3385  			if nearBottom {
3386  				loadOlderNotifs()
3387  			}
3388  		} else if appSt.activePage == "search" {
3389  			if nearBottom {
3390  				appendSearchResults()
3391  			}
3392  		}
3393  	}))
3394  
3395  	// Messaging page.
3396  	appSt.msgPage = dom.CreateElement("div")
3397  	dom.SetAttribute(appSt.msgPage, "id", "msg-page")
3398  	dom.SetStyle(appSt.msgPage, "padding", "16px")
3399  	dom.SetStyle(appSt.msgPage, "display", "none")
3400  	dom.SetStyle(appSt.msgPage, "position", "relative")
3401  	dom.SetStyle(appSt.msgPage, "height", "100%")
3402  	dom.SetStyle(appSt.msgPage, "boxSizing", "border-box")
3403  
3404  	// Conversation list view.
3405  	appSt.msgListContainer = dom.CreateElement("div")
3406  	dom.AppendChild(appSt.msgPage, appSt.msgListContainer)
3407  
3408  	// Thread view (hidden by default).
3409  	appSt.msgThreadContainer = dom.CreateElement("div")
3410  	dom.SetStyle(appSt.msgThreadContainer, "display", "none")
3411  	dom.SetStyle(appSt.msgThreadContainer, "flexDirection", "column")
3412  	dom.SetStyle(appSt.msgThreadContainer, "position", "absolute")
3413  	dom.SetStyle(appSt.msgThreadContainer, "top", "0")
3414  	dom.SetStyle(appSt.msgThreadContainer, "left", "0")
3415  	dom.SetStyle(appSt.msgThreadContainer, "right", "0")
3416  	dom.SetStyle(appSt.msgThreadContainer, "bottom", "0")
3417  	dom.SetStyle(appSt.msgThreadContainer, "background", "var(--bg)")
3418  	dom.AppendChild(appSt.msgPage, appSt.msgThreadContainer)
3419  
3420  	appSt.msgView = "list"
3421  
3422  	dom.AppendChild(appSt.contentArea, appSt.msgPage)
3423  
3424  	// Profile page.
3425  	appSt.profilePage = dom.CreateElement("div")
3426  	dom.SetAttribute(appSt.profilePage, "id", "profile-page")
3427  	dom.SetStyle(appSt.profilePage, "display", "none")
3428  	dom.SetStyle(appSt.profilePage, "padding", "16px")
3429  	dom.SetStyle(appSt.profilePage, "maxWidth", "640px")
3430  	dom.SetStyle(appSt.profilePage, "margin", "0 auto")
3431  	dom.SetStyle(appSt.profilePage, "boxSizing", "border-box")
3432  	dom.AppendChild(appSt.contentArea, appSt.profilePage)
3433  
3434  	// Settings page.
3435  	appSt.settingsPage = dom.CreateElement("div")
3436  	dom.SetAttribute(appSt.settingsPage, "id", "settings-page")
3437  	dom.SetStyle(appSt.settingsPage, "display", "none")
3438  	dom.SetStyle(appSt.settingsPage, "padding", "16px")
3439  	dom.SetStyle(appSt.settingsPage, "maxWidth", "640px")
3440  	dom.SetStyle(appSt.settingsPage, "margin", "0 auto")
3441  	dom.SetStyle(appSt.settingsPage, "boxSizing", "border-box")
3442  	dom.AppendChild(appSt.contentArea, appSt.settingsPage)
3443  
3444  	// Compose page.
3445  	appSt.composePage = dom.CreateElement("div")
3446  	dom.SetAttribute(appSt.composePage, "id", "compose-page")
3447  	dom.SetStyle(appSt.composePage, "display", "none")
3448  	dom.SetStyle(appSt.composePage, "padding", "16px")
3449  	dom.SetStyle(appSt.composePage, "maxWidth", "640px")
3450  	dom.SetStyle(appSt.composePage, "margin", "0 auto")
3451  	dom.SetStyle(appSt.composePage, "minHeight", "100%")
3452  	dom.SetStyle(appSt.composePage, "boxSizing", "border-box")
3453  	buildComposePage()
3454  	dom.AppendChild(appSt.contentArea, appSt.composePage)
3455  
3456  	// About page.
3457  	appSt.aboutPage = dom.CreateElement("div")
3458  	dom.SetAttribute(appSt.aboutPage, "id", "about-page")
3459  	dom.SetStyle(appSt.aboutPage, "display", "none")
3460  	dom.SetStyle(appSt.aboutPage, "padding", "24px 16px")
3461  	dom.SetStyle(appSt.aboutPage, "maxWidth", "640px")
3462  	dom.SetStyle(appSt.aboutPage, "margin", "0 auto")
3463  	dom.SetStyle(appSt.aboutPage, "boxSizing", "border-box")
3464  	dom.SetStyle(appSt.aboutPage, "textAlign", "center")
3465  
3466  	aRefresh := dom.CreateElement("button")
3467  	dom.SetTextContent(aRefresh, "hard refresh")
3468  	dom.SetStyle(aRefresh, "fontFamily", "'Fira Code', monospace")
3469  	dom.SetStyle(aRefresh, "fontSize", "13px")
3470  	dom.SetStyle(aRefresh, "background", "transparent")
3471  	dom.SetStyle(aRefresh, "border", "1px solid var(--border)")
3472  	dom.SetStyle(aRefresh, "borderRadius", "4px")
3473  	dom.SetStyle(aRefresh, "color", "var(--muted)")
3474  	dom.SetStyle(aRefresh, "cursor", "pointer")
3475  	dom.SetStyle(aRefresh, "padding", "6px 16px")
3476  	dom.SetStyle(aRefresh, "marginBottom", "16px")
3477  	dom.AddEventListener(aRefresh, "click", dom.RegisterCallback(func() {
3478  		dom.HardRefresh()
3479  	}))
3480  	dom.AppendChild(appSt.aboutPage, aRefresh)
3481  
3482  	appSt.aboutLogoEl = dom.CreateElement("div")
3483  	dom.SetStyle(appSt.aboutLogoEl, "width", "180px")
3484  	dom.SetStyle(appSt.aboutLogoEl, "height", "180px")
3485  	dom.SetStyle(appSt.aboutLogoEl, "margin", "0 auto 16px auto")
3486  	dom.SetStyle(appSt.aboutLogoEl, "color", "var(--accent)")
3487  	if appSt.logoSVGCache != "" {
3488  		dom.SetInnerHTML(appSt.aboutLogoEl, appSt.logoSVGCache)
3489  		aLogoSvg := dom.FirstElementChild(appSt.aboutLogoEl)
3490  		if aLogoSvg != 0 {
3491  			dom.SetAttribute(aLogoSvg, "width", "100%")
3492  			dom.SetAttribute(aLogoSvg, "height", "100%")
3493  		}
3494  	}
3495  	dom.AppendChild(appSt.aboutPage, appSt.aboutLogoEl)
3496  
3497  	aTitle := dom.CreateElement("h2")
3498  	dom.SetTextContent(aTitle, "MUSIQUAY " | version)
3499  	dom.SetStyle(aTitle, "fontSize", "20px")
3500  	dom.SetStyle(aTitle, "margin", "0 0 20px 0")
3501  	dom.SetStyle(aTitle, "color", "var(--accent)")
3502  	dom.AppendChild(appSt.aboutPage, aTitle)
3503  
3504  	aDevLabel := dom.CreateElement("p")
3505  	dom.SetTextContent(aDevLabel, t("developed_by"))
3506  	dom.SetStyle(aDevLabel, "fontSize", "12px")
3507  	dom.SetStyle(aDevLabel, "color", "var(--muted)")
3508  	dom.SetStyle(aDevLabel, "marginBottom", "4px")
3509  	dom.AppendChild(appSt.aboutPage, aDevLabel)
3510  
3511  	aNpub := dom.CreateElement("p")
3512  	dom.SetStyle(aNpub, "fontSize", "11px")
3513  	dom.SetStyle(aNpub, "color", "var(--fg)")
3514  	dom.SetStyle(aNpub, "wordBreak", "break-all")
3515  	dom.SetStyle(aNpub, "marginBottom", "20px")
3516  	dom.SetStyle(aNpub, "fontFamily", "'Fira Code', monospace")
3517  	dom.SetTextContent(aNpub, "npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku")
3518  	dom.AppendChild(appSt.aboutPage, aNpub)
3519  
3520  	aTagline := dom.CreateElement("p")
3521  	dom.SetStyle(aTagline, "fontSize", "16px")
3522  	dom.SetStyle(aTagline, "fontWeight", "bold")
3523  	dom.SetStyle(aTagline, "color", "var(--fg)")
3524  	dom.SetStyle(aTagline, "marginBottom", "12px")
3525  	dom.SetTextContent(aTagline, t("tagline"))
3526  	dom.AppendChild(appSt.aboutPage, aTagline)
3527  
3528  	aFunny := dom.CreateElement("p")
3529  	dom.SetStyle(aFunny, "fontSize", "13px")
3530  	dom.SetStyle(aFunny, "color", "var(--fg)")
3531  	dom.SetStyle(aFunny, "marginBottom", "20px")
3532  	dom.SetStyle(aFunny, "lineHeight", "1.6")
3533  	dom.SetTextContent(aFunny, t("about_donkey"))
3534  	dom.AppendChild(appSt.aboutPage, aFunny)
3535  
3536  	aLud := dom.CreateElement("span")
3537  	dom.SetStyle(aLud, "fontSize", "13px")
3538  	dom.SetStyle(aLud, "color", "var(--accent)")
3539  	dom.SetStyle(aLud, "marginBottom", "24px")
3540  	dom.SetStyle(aLud, "cursor", "pointer")
3541  	dom.SetStyle(aLud, "display", "inline-block")
3542  	dom.SetTextContent(aLud, "\xe2\x9a\xa1 leeringidol57@walletofsatoshi.com")
3543  	dom.AddEventListener(aLud, "click", dom.RegisterCallback(func() {
3544  		showQRModal("leeringidol57@walletofsatoshi.com")
3545  	}))
3546  	dom.AppendChild(appSt.aboutPage, aLud)
3547  
3548  	feliceLink := dom.CreateElement("a")
3549  	dom.SetAttribute(feliceLink, "href", "https://git.smesh.lol/felice/raw/felice.apk")
3550  	dom.SetAttribute(feliceLink, "target", "_blank")
3551  	dom.SetStyle(feliceLink, "display", "block")
3552  	dom.SetStyle(feliceLink, "color", "var(--accent)")
3553  	dom.SetStyle(feliceLink, "fontSize", "13px")
3554  	dom.SetStyle(feliceLink, "marginBottom", "4px")
3555  	dom.SetTextContent(feliceLink, "download felice")
3556  	dom.AppendChild(appSt.aboutPage, feliceLink)
3557  
3558  	feliceDesc := dom.CreateElement("p")
3559  	dom.SetStyle(feliceDesc, "fontSize", "11px")
3560  	dom.SetStyle(feliceDesc, "color", "var(--muted)")
3561  	dom.SetStyle(feliceDesc, "lineHeight", "1.5")
3562  	dom.SetStyle(feliceDesc, "marginBottom", "24px")
3563  	dom.SetStyle(feliceDesc, "maxWidth", "400px")
3564  	dom.SetStyle(feliceDesc, "margin", "0 auto 24px auto")
3565  	dom.SetTextContent(feliceDesc, "fork of fennec-fdroid to use smesh.lol and musiquay signer extension on android without the mozilla security theatre")
3566  	dom.AppendChild(appSt.aboutPage, feliceDesc)
3567  
3568  	dom.AppendChild(appSt.contentArea, appSt.aboutPage)
3569  
3570  	// Activity log page.
3571  	appSt.logPage = dom.CreateElement("div")
3572  	dom.SetAttribute(appSt.logPage, "id", "log-page")
3573  	dom.SetStyle(appSt.logPage, "display", "none")
3574  	dom.SetStyle(appSt.logPage, "padding", "12px")
3575  	dom.SetStyle(appSt.logPage, "maxWidth", "780px")
3576  	dom.SetStyle(appSt.logPage, "margin", "0 auto")
3577  	dom.SetStyle(appSt.logPage, "boxSizing", "border-box")
3578  	dom.SetStyle(appSt.logPage, "fontFamily", "'Fira Code', monospace")
3579  	dom.SetStyle(appSt.logPage, "fontSize", "12px")
3580  
3581  	appSt.logListEl = dom.CreateElement("div")
3582  	dom.AppendChild(appSt.logPage, appSt.logListEl)
3583  	dom.AppendChild(appSt.contentArea, appSt.logPage)
3584  
3585  	// Pre-populate with any entries that accumulated during boot before this DOM was built.
3586  	for i := activityLogLen() - 1; i >= 0; i-- {
3587  		prependLogEntry(activityEntry(i))
3588  	}
3589  
3590  	// Notifications page.
3591  	appSt.notifPage = dom.CreateElement("div")
3592  	dom.SetAttribute(appSt.notifPage, "id", "notif-page")
3593  	dom.SetStyle(appSt.notifPage, "display", "none")
3594  	dom.SetStyle(appSt.notifPage, "padding", "16px")
3595  	dom.SetStyle(appSt.notifPage, "maxWidth", "640px")
3596  	dom.SetStyle(appSt.notifPage, "margin", "0 auto")
3597  	dom.SetStyle(appSt.notifPage, "boxSizing", "border-box")
3598  
3599  	appSt.notifContainer = dom.CreateElement("div")
3600  	dom.AppendChild(appSt.notifPage, appSt.notifContainer)
3601  	dom.AppendChild(appSt.contentArea, appSt.notifPage)
3602  
3603  	// Search page: flex-column so filter bar stays above scrolling results.
3604  	appSt.searchPage = dom.CreateElement("div")
3605  	dom.SetAttribute(appSt.searchPage, "id", "search-page")
3606  	dom.SetStyle(appSt.searchPage, "display", "none")
3607  	dom.SetStyle(appSt.searchPage, "maxWidth", "640px")
3608  	dom.SetStyle(appSt.searchPage, "margin", "0 auto")
3609  	dom.SetStyle(appSt.searchPage, "width", "100%")
3610  	// searchFilterBarEl and searchContainer are appended dynamically by buildSearchFilterBar/executeSearch.
3611  	appSt.searchContainer = dom.CreateElement("div")
3612  	dom.AppendChild(appSt.searchPage, appSt.searchContainer)
3613  	dom.AppendChild(appSt.contentArea, appSt.searchPage)
3614  
3615  	dom.AppendChild(mainLayout, appSt.contentArea)
3616  	dom.AppendChild(appSt.root, mainLayout)
3617  	appSt.activePage = "log"
3618  	dom.SetStyle(appSt.logPage, "display", "block")
3619  
3620  	buildStatusBar(true)
3621  
3622  	// Icon group: fixed 4px gap between icons (same as old sidebar), centered
3623  	// on screen via absolute positioning + translateX(-50%).
3624  	iconGroup := dom.CreateElement("div")
3625  	dom.SetStyle(iconGroup, "position", "absolute")
3626  	dom.SetStyle(iconGroup, "left", "50%")
3627  	dom.SetStyle(iconGroup, "top", "0")
3628  	dom.SetStyle(iconGroup, "bottom", "0")
3629  	dom.SetStyle(iconGroup, "transform", "translateX(-50%)")
3630  	dom.SetStyle(iconGroup, "display", "flex")
3631  	dom.SetStyle(iconGroup, "alignItems", "center")
3632  	dom.SetStyle(iconGroup, "gap", "4px")
3633  	if int32(appSt.userBtn) != 0 {
3634  		dom.AppendChild(iconGroup, appSt.userBtn)
3635  	}
3636  	// The compose and feed buttons are not part of this base build; the
3637  	// elements stay created above so switchPage's highlight resets stay valid.
3638  	dom.AppendChild(iconGroup, appSt.sidebarNotif)
3639  	dom.AppendChild(iconGroup, appSt.sidebarSettings)
3640  	_ = appSt.walletBtnEl
3641  	_ = appSt.themeBtn
3642  	dom.AppendChild(appSt.bottomBar, iconGroup)
3643  	// Activity log button - opens the log page. Highlighted while boot is in progress.
3644  	appSt.logBtn = dom.CreateElement("button")
3645  	dom.SetInnerHTML(appSt.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>`)
3646  	dom.SetAttribute(appSt.logBtn, "title", "activity log")
3647  	dom.SetStyle(appSt.logBtn, "background", "transparent")
3648  	dom.SetStyle(appSt.logBtn, "border", "none")
3649  	dom.SetStyle(appSt.logBtn, "color", "var(--muted)")
3650  	dom.SetStyle(appSt.logBtn, "width", "32px")
3651  	dom.SetStyle(appSt.logBtn, "height", "32px")
3652  	dom.SetStyle(appSt.logBtn, "cursor", "pointer")
3653  	dom.SetStyle(appSt.logBtn, "padding", "0")
3654  	dom.SetStyle(appSt.logBtn, "display", "flex")
3655  	dom.SetStyle(appSt.logBtn, "alignItems", "center")
3656  	dom.SetStyle(appSt.logBtn, "justifyContent", "center")
3657  	dom.AddEventListener(appSt.logBtn, "click", dom.RegisterCallback(func() {
3658  		switchPage("log")
3659  	}))
3660  	dom.AppendChild(appSt.topBarRight, appSt.logBtn)
3661  	updateLogBtnHighlight()
3662  
3663  	// Search button + input row - leftmost item in topBarRight.
3664  	appSt.searchSortDesc = true
3665  	appSt.searchSeen = map[string]bool{}
3666  
3667  	searchBtn := dom.CreateElement("button")
3668  	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>`)
3669  	dom.SetAttribute(searchBtn, "title", "search")
3670  	dom.SetStyle(searchBtn, "background", "transparent")
3671  	dom.SetStyle(searchBtn, "border", "none")
3672  	dom.SetStyle(searchBtn, "color", "var(--muted)")
3673  	dom.SetStyle(searchBtn, "height", "32px")
3674  	dom.SetStyle(searchBtn, "width", "32px")
3675  	dom.SetStyle(searchBtn, "display", "flex")
3676  	dom.SetStyle(searchBtn, "alignItems", "center")
3677  	dom.SetStyle(searchBtn, "justifyContent", "center")
3678  	dom.SetStyle(searchBtn, "cursor", "pointer")
3679  	dom.SetStyle(searchBtn, "borderRadius", "4px")
3680  	dom.SetStyle(searchBtn, "flexShrink", "0")
3681  
3682  	appSt.searchInputEl = dom.CreateElement("input")
3683  	dom.SetAttribute(appSt.searchInputEl, "type", "text")
3684  	dom.SetAttribute(appSt.searchInputEl, "placeholder", "#tag or text search")
3685  	dom.SetStyle(appSt.searchInputEl, "display", "none")
3686  	dom.SetStyle(appSt.searchInputEl, "flex", "1")
3687  	dom.SetStyle(appSt.searchInputEl, "minWidth", "0")
3688  	dom.SetStyle(appSt.searchInputEl, "background", "var(--bg)")
3689  	dom.SetStyle(appSt.searchInputEl, "color", "var(--fg)")
3690  	dom.SetStyle(appSt.searchInputEl, "border", "1px solid var(--border)")
3691  	dom.SetStyle(appSt.searchInputEl, "borderRadius", "4px")
3692  	dom.SetStyle(appSt.searchInputEl, "padding", "4px 8px")
3693  	dom.SetStyle(appSt.searchInputEl, "fontSize", "14px")
3694  	dom.SetStyle(appSt.searchInputEl, "fontFamily", "'Fira Code', monospace")
3695  
3696  	appSt.searchSubmitBtn = dom.CreateElement("button")
3697  	dom.SetTextContent(appSt.searchSubmitBtn, "\xe2\x86\x92") // →
3698  	dom.SetStyle(appSt.searchSubmitBtn, "display", "none")
3699  	dom.SetStyle(appSt.searchSubmitBtn, "background", "var(--accent)")
3700  	dom.SetStyle(appSt.searchSubmitBtn, "color", "#fff")
3701  	dom.SetStyle(appSt.searchSubmitBtn, "border", "none")
3702  	dom.SetStyle(appSt.searchSubmitBtn, "borderRadius", "4px")
3703  	dom.SetStyle(appSt.searchSubmitBtn, "padding", "4px 10px")
3704  	dom.SetStyle(appSt.searchSubmitBtn, "cursor", "pointer")
3705  	dom.SetStyle(appSt.searchSubmitBtn, "fontSize", "16px")
3706  	dom.SetStyle(appSt.searchSubmitBtn, "flexShrink", "0")
3707  
3708  	// 🔍 toggles search: click to open (highlighted), click again to close.
3709  	appSt.searchBtnGlobal = searchBtn
3710  	capturedSearchBtn := searchBtn
3711  	dom.AddEventListener(searchBtn, "click", dom.RegisterCallback(func() {
3712  		if appSt.searchBarActive {
3713  			closeSearch(capturedSearchBtn)
3714  		} else {
3715  			activateSearchBar(capturedSearchBtn)
3716  		}
3717  	}))
3718  
3719  	submitSearch := func() {
3720  		q := dom.GetProperty(appSt.searchInputEl, "value")
3721  		if q != "" {
3722  			executeSearch(q)
3723  		}
3724  	}
3725  	capturedSubmit := submitSearch
3726  	dom.AddEventListener(appSt.searchSubmitBtn, "click", dom.RegisterCallback(capturedSubmit))
3727  	dom.OnKeydown(appSt.searchInputEl, func(key string, prevent func()) {
3728  		if key == "Enter" {
3729  			prevent()
3730  			capturedSubmit()
3731  		} else if key == "Escape" {
3732  			closeSearch(capturedSearchBtn)
3733  		}
3734  	})
3735  
3736  	// Search lives in topBarLeft so it can replace center content when active.
3737  	dom.AppendChild(appSt.topBarLeft, searchBtn)
3738  	dom.AppendChild(appSt.topBarLeft, appSt.searchInputEl)
3739  	dom.AppendChild(appSt.topBarLeft, appSt.searchSubmitBtn)
3740  
3741  	// Load saved relays; fall back to defaults on first run.
3742  	if !loadRelayList() {
3743  		logActivity("boot", "loadRelayList empty, using defaults", "")
3744  		for _, url := range appSt.defaultRelays {
3745  			addRelay(url, false)
3746  		}
3747  	}
3748  	if len(appSt.relayURLs) == 0 {
3749  		logActivity("boot", "relayURLs still empty, forcing defaults", "")
3750  		for _, url := range appSt.defaultRelays {
3751  			addRelay(url, false)
3752  		}
3753  	}
3754  	{
3755  		relayList := ""
3756  		for i, u := range appSt.relayURLs {
3757  			if i > 0 {
3758  				relayList = relayList | "\n"
3759  			}
3760  			relayList = relayList | u
3761  		}
3762  		logActivity("boot", "loaded " | itoa(len(appSt.relayURLs)) | " relays", relayList)
3763  	}
3764  
3765  	// Tell SW about relays and subscribe.
3766  	sendWriteRelays()
3767  	subscribeProfile()
3768  	logActivity("boot", "subscribed to own profile", "kinds: 0,3,10002,10000,10050 for " | appSt.pubhex)
3769  	// Feed subscription is deferred until kind 3 (follow list) arrives,
3770  	// so we don't subscribe with a global filter then immediately
3771  	// resubscribe when the follow list shows up (which clears the feed).
3772  	// Fallback: if no kind 3 arrives within 3s, subscribe anyway.
3773  	appSt.feedDeferTimer = dom.SetTimeout(func() {
3774  		appSt.feedDeferTimer = 0
3775  		if !appSt.feedSubscribed {
3776  			appSt.feedSubscribed = true
3777  			logActivity("boot", "feed defer timeout, subscribing anyway", "no kind 3 in 3s")
3778  			subscribeFeed()
3779  			subscribeNotifications()
3780  		}
3781  		bootDone()
3782  	}, 3000)
3783  
3784  	// Publish any kind 0 queued during identity derivation (relays are now live).
3785  	flushPendingK0()
3786  
3787  	// Wire up browser history navigation.
3788  	initRouter()
3789  
3790  	// Check for signer extension.
3791  	initSigner()
3792  
3793  	// Scroll-to-top floaty button.
3794  	scrollTopBtn := dom.CreateElement("button")
3795  	dom.SetStyle(scrollTopBtn, "position", "fixed")
3796  	dom.SetStyle(scrollTopBtn, "bottom", "48px")
3797  	dom.SetStyle(scrollTopBtn, "right", "12px")
3798  	dom.SetStyle(scrollTopBtn, "width", "36px")
3799  	dom.SetStyle(scrollTopBtn, "height", "36px")
3800  	dom.SetStyle(scrollTopBtn, "borderRadius", "50%")
3801  	dom.SetStyle(scrollTopBtn, "background", "var(--bg2)")
3802  	dom.SetStyle(scrollTopBtn, "color", "var(--fg)")
3803  	dom.SetStyle(scrollTopBtn, "border", "1px solid var(--border)")
3804  	dom.SetStyle(scrollTopBtn, "cursor", "pointer")
3805  	dom.SetStyle(scrollTopBtn, "display", "none")
3806  	dom.SetStyle(scrollTopBtn, "alignItems", "center")
3807  	dom.SetStyle(scrollTopBtn, "justifyContent", "center")
3808  	dom.SetStyle(scrollTopBtn, "zIndex", "99")
3809  	dom.SetStyle(scrollTopBtn, "padding", "0")
3810  	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>`)
3811  	dom.AddEventListener(scrollTopBtn, "click", dom.RegisterCallback(func() {
3812  		dom.SetProperty(appSt.contentArea, "scrollTop", "0")
3813  	}))
3814  	dom.AppendChild(dom.Body(), scrollTopBtn)
3815  
3816  	dom.AddEventListener(appSt.contentArea, "scroll", dom.RegisterCallback(func() {
3817  		st := parseIntProp(dom.GetProperty(appSt.contentArea, "scrollTop"))
3818  		show := st > 300 && (appSt.activePage == "feed" || appSt.activePage == "notifications" || appSt.activePage == "thread")
3819  		if show {
3820  			dom.SetStyle(scrollTopBtn, "display", "inline-flex")
3821  		} else {
3822  			dom.SetStyle(scrollTopBtn, "display", "none")
3823  		}
3824  	}))
3825  
3826  	appSt.appReady = true
3827  }
3828  
3829  // buildStatusBar creates the bottom bar, relay popover, and signer popover.
3830  // withUser=true adds the avatar+name section (main app).
3831  func buildStatusBar(withUser bool) {
3832  	appSt.bottomBar = dom.CreateElement("div")
3833  	dom.SetStyle(appSt.bottomBar, "position", "fixed")
3834  	dom.SetStyle(appSt.bottomBar, "bottom", "0")
3835  	dom.SetStyle(appSt.bottomBar, "left", "0")
3836  	dom.SetStyle(appSt.bottomBar, "right", "0")
3837  	dom.SetStyle(appSt.bottomBar, "height", "36px")
3838  	dom.SetStyle(appSt.bottomBar, "display", "flex")
3839  	dom.SetStyle(appSt.bottomBar, "alignItems", "center")
3840  	dom.SetStyle(appSt.bottomBar, "padding", "0 6px")
3841  	dom.SetStyle(appSt.bottomBar, "gap", "8px")
3842  	dom.SetStyle(appSt.bottomBar, "background", "var(--bg2)")
3843  	dom.SetStyle(appSt.bottomBar, "fontSize", "12px")
3844  	dom.SetStyle(appSt.bottomBar, "color", "var(--fg)")
3845  	dom.SetStyle(appSt.bottomBar, "borderTop", "none")
3846  	dom.SetStyle(appSt.bottomBar, "zIndex", "100")
3847  
3848  	if withUser {
3849  		appSt.userBtn = dom.CreateElement("button")
3850  		dom.SetStyle(appSt.userBtn, "display", "flex")
3851  		dom.SetStyle(appSt.userBtn, "alignItems", "center")
3852  		dom.SetStyle(appSt.userBtn, "justifyContent", "center")
3853  		dom.SetStyle(appSt.userBtn, "padding", "0")
3854  		dom.SetStyle(appSt.userBtn, "background", "transparent")
3855  		dom.SetStyle(appSt.userBtn, "border", "none")
3856  		dom.SetStyle(appSt.userBtn, "borderRadius", "6px")
3857  		dom.SetStyle(appSt.userBtn, "cursor", "pointer")
3858  		dom.SetStyle(appSt.userBtn, "width", "36px")
3859  		dom.SetStyle(appSt.userBtn, "height", "36px")
3860  
3861  		appSt.avatarEl = dom.CreateElement("img")
3862  		dom.SetAttribute(appSt.avatarEl, "referrerpolicy", "no-referrer")
3863  		dom.SetAttribute(appSt.avatarEl, "width", "24")
3864  		dom.SetAttribute(appSt.avatarEl, "height", "24")
3865  		dom.SetStyle(appSt.avatarEl, "borderRadius", "50%")
3866  		dom.SetStyle(appSt.avatarEl, "objectFit", "cover")
3867  		dom.SetStyle(appSt.avatarEl, "display", "block")
3868  		dom.SetAttribute(appSt.avatarEl, "onerror", "this.style.display='none'")
3869  		dom.AppendChild(appSt.userBtn, appSt.avatarEl)
3870  
3871  		// nameEl kept (exists for other code paths) but detached - username
3872  		// no longer appears in the status bar.
3873  		appSt.nameEl = dom.CreateElement("span")
3874  
3875  		dom.AddEventListener(appSt.userBtn, "click", dom.RegisterCallback(func() {
3876  			toggleSignerPanel()
3877  		}))
3878  		// userBtn is appended to iconGroup by showApp.
3879  	}
3880  
3881  	appSt.statusEl = dom.CreateElement("button")
3882  	dom.SetTextContent(appSt.statusEl, t("connecting"))
3883  	dom.SetStyle(appSt.statusEl, "fontFamily", "'Fira Code', monospace")
3884  	dom.SetStyle(appSt.statusEl, "fontSize", "12px")
3885  	dom.SetStyle(appSt.statusEl, "background", "transparent")
3886  	dom.SetStyle(appSt.statusEl, "border", "none")
3887  	dom.SetStyle(appSt.statusEl, "color", "var(--muted)")
3888  	dom.SetStyle(appSt.statusEl, "cursor", "pointer")
3889  	dom.SetStyle(appSt.statusEl, "padding", "4px 8px")
3890  	dom.SetStyle(appSt.statusEl, "borderRadius", "4px")
3891  	dom.SetStyle(appSt.statusEl, "marginLeft", "auto")
3892  	dom.AddEventListener(appSt.statusEl, "click", dom.RegisterCallback(func() {
3893  		togglePopover()
3894  	}))
3895  	// statusEl (relays button) is created but not attached - all relay info is
3896  	// already visible on the settings page.
3897  	_ = appSt.statusEl
3898  
3899  	dom.AppendChild(appSt.root, appSt.bottomBar)
3900  
3901  	// Relay popover (hidden).
3902  	appSt.popoverEl = dom.CreateElement("div")
3903  	dom.SetStyle(appSt.popoverEl, "position", "fixed")
3904  	dom.SetStyle(appSt.popoverEl, "bottom", "37px")
3905  	dom.SetStyle(appSt.popoverEl, "right", "12px")
3906  	dom.SetStyle(appSt.popoverEl, "width", "340px")
3907  	dom.SetStyle(appSt.popoverEl, "maxWidth", "calc(100vw - 24px)")
3908  	dom.SetStyle(appSt.popoverEl, "background", "var(--bg2)")
3909  	dom.SetStyle(appSt.popoverEl, "border", "1px solid var(--border)")
3910  	dom.SetStyle(appSt.popoverEl, "borderRadius", "8px")
3911  	dom.SetStyle(appSt.popoverEl, "padding", "12px 16px")
3912  	dom.SetStyle(appSt.popoverEl, "fontSize", "12px")
3913  	dom.SetStyle(appSt.popoverEl, "display", "none")
3914  	dom.SetStyle(appSt.popoverEl, "zIndex", "99")
3915  
3916  	appSt.popoverSummary = dom.CreateElement("div")
3917  	dom.SetTextContent(appSt.popoverSummary, t("connecting"))
3918  	dom.SetStyle(appSt.popoverSummary, "marginBottom", "8px")
3919  	dom.SetStyle(appSt.popoverSummary, "color", "var(--muted)")
3920  	dom.AppendChild(appSt.popoverEl, appSt.popoverSummary)
3921  
3922  	// Broadcast button - at the bottom of the relay popover.
3923  	appSt.broadcastRow = dom.CreateElement("div")
3924  	dom.SetStyle(appSt.broadcastRow, "borderTop", "1px solid var(--border)")
3925  	dom.SetStyle(appSt.broadcastRow, "marginTop", "8px")
3926  	dom.SetStyle(appSt.broadcastRow, "paddingTop", "8px")
3927  	dom.SetStyle(appSt.broadcastRow, "display", "none")
3928  	appSt.broadcastBtnEl = dom.CreateElement("span")
3929  	dom.SetTextContent(appSt.broadcastBtnEl, "broadcast profile")
3930  	dom.SetStyle(appSt.broadcastBtnEl, "cursor", "pointer")
3931  	dom.SetStyle(appSt.broadcastBtnEl, "color", "var(--accent)")
3932  	dom.SetStyle(appSt.broadcastBtnEl, "fontFamily", "'Fira Code', monospace")
3933  	dom.AddEventListener(appSt.broadcastBtnEl, "click", dom.RegisterCallback(func() {
3934  		if appSt.pubhex == "" {
3935  			return
3936  		}
3937  		dom.SetTextContent(appSt.broadcastBtnEl, "broadcasting...")
3938  		dom.SetStyle(appSt.broadcastBtnEl, "color", "var(--muted)")
3939  		msg := "[\"BROADCAST\"," | jstr(appSt.pubhex) | ",["
3940  		for i, url := range appSt.relayURLs {
3941  			if i > 0 {
3942  				msg = msg | ","
3943  			}
3944  			msg = msg | jstr(url)
3945  		}
3946  		routeMsg(msg | "]]")
3947  	}))
3948  	dom.AppendChild(appSt.broadcastRow, appSt.broadcastBtnEl)
3949  	dom.AppendChild(appSt.popoverEl, appSt.broadcastRow)
3950  	if appSt.pubhex != "" {
3951  		dom.SetStyle(appSt.broadcastRow, "display", "block")
3952  	}
3953  
3954  	// popoverEl (relays popup) is created but not attached - relays are on the
3955  	// settings page. Downstream code still mutates the detached element harmlessly.
3956  	_ = appSt.popoverEl
3957  
3958  	// Signer popover (hidden).
3959  	buildSignerPanel()
3960  	dom.AppendChild(appSt.root, appSt.signerPanel)
3961  
3962  	// Wallet popover (hidden).
3963  	buildWalletPanel()
3964  	// walletPanel is attached by renderSettings() at the bottom of the settings page.
3965  
3966  	// Feed selector popover (hidden).
3967  	appSt.feedPopoverEl = dom.CreateElement("div")
3968  	dom.SetStyle(appSt.feedPopoverEl, "position", "fixed")
3969  	dom.SetStyle(appSt.feedPopoverEl, "top", "48px")
3970  	dom.SetStyle(appSt.feedPopoverEl, "left", "50%")
3971  	dom.SetStyle(appSt.feedPopoverEl, "transform", "translateX(-50%)")
3972  	dom.SetStyle(appSt.feedPopoverEl, "width", "220px")
3973  	dom.SetStyle(appSt.feedPopoverEl, "maxWidth", "calc(100vw - 24px)")
3974  	dom.SetStyle(appSt.feedPopoverEl, "background", "var(--bg2)")
3975  	dom.SetStyle(appSt.feedPopoverEl, "border", "1px solid var(--border)")
3976  	dom.SetStyle(appSt.feedPopoverEl, "borderRadius", "8px")
3977  	dom.SetStyle(appSt.feedPopoverEl, "padding", "8px")
3978  	dom.SetStyle(appSt.feedPopoverEl, "fontSize", "14px")
3979  	dom.SetStyle(appSt.feedPopoverEl, "display", "none")
3980  	dom.SetStyle(appSt.feedPopoverEl, "zIndex", "99")
3981  	dom.AppendChild(appSt.root, appSt.feedPopoverEl)
3982  }
3983  
3984  // relayCap is a parsed NIP-11 Relay Information Document snapshot.
3985  // Values are populated asynchronously after addRelay; fetchedOK=false means
3986  // either the fetch hasn't completed yet or the relay was unreachable.
3987  type relayCap struct {
3988  	name          string
3989  	adminPubkey   string // hex, from NIP-11 "pubkey" field (operator identity)
3990  	supportedNIPs []int32  // NIP-11 "supported_nips" array
3991  	authRequired  bool   // limitation.auth_required
3992  	paymentReq    bool   // limitation.payment_required
3993  	restrictedW   bool   // limitation.restricted_writes
3994  	fetchedOK     bool   // true once a non-empty NIP-11 document has been parsed
3995  }
3996  
3997  // hasNIP reports whether a supported_nips list contains the given NIP number.
3998  func hasNIP(nips []int32, n int32) (ok bool) {
3999  	for _, x := range nips {
4000  		if x == n {
4001  			return true
4002  		}
4003  	}
4004  	return false
4005  }
4006  
4007  // makeRelayBadge creates a small warning pill like "!42" or "!70".
4008  func makeRelayBadge(text string) (e dom.Element) {
4009  	b := dom.CreateElement("span")
4010  	dom.SetTextContent(b, text)
4011  	dom.SetStyle(b, "display", "inline-block")
4012  	dom.SetStyle(b, "background", "#c44")
4013  	dom.SetStyle(b, "color", "#fff")
4014  	dom.SetStyle(b, "padding", "1px 5px")
4015  	dom.SetStyle(b, "borderRadius", "3px")
4016  	dom.SetStyle(b, "fontSize", "10px")
4017  	dom.SetStyle(b, "marginLeft", "4px")
4018  	dom.SetStyle(b, "fontFamily", "'Fira Code', monospace")
4019  	return b
4020  }
4021  
4022  // updateRelayBadges rewrites the badge container for the relay at index i
4023  // based on the current relayCaps[i]. Missing NIP-42 and NIP-70 support each
4024  // produces a warning pill. Called from the NIP-11 fetch callback.
4025  func updateRelayBadges(i int32) {
4026  	if i < 0 || i >= len(appSt.relayBadgeContainers) {
4027  		return
4028  	}
4029  	container := appSt.relayBadgeContainers[i]
4030  	clearChildren(container)
4031  	caps := appSt.relayCaps[i]
4032  	if !hasNIP(caps.supportedNIPs, 42) {
4033  		dom.AppendChild(container, makeRelayBadge("!42"))
4034  	}
4035  	if !hasNIP(caps.supportedNIPs, 70) {
4036  		dom.AppendChild(container, makeRelayBadge("!70"))
4037  	}
4038  	if hasNIP(caps.supportedNIPs, 50) {
4039  		badge := makeRelayBadge("search")
4040  		dom.SetStyle(badge, "background", "rgba(0,180,80,0.15)")
4041  		dom.SetStyle(badge, "color", "#0a0")
4042  		dom.SetStyle(badge, "border", "1px solid rgba(0,180,80,0.3)")
4043  		dom.AppendChild(container, badge)
4044  	}
4045  }
4046  
4047  // parseNIP11Caps parses a NIP-11 Relay Information Document body into a relayCap.
4048  // Returns a zero value (fetchedOK=false) for empty input.
4049  func parseNIP11Caps(body string) (v relayCap) {
4050  	var c relayCap
4051  	if body == "" {
4052  		return c
4053  	}
4054  	c.fetchedOK = true
4055  	c.name = helpers.JsonGetString(body, "name")
4056  	c.adminPubkey = helpers.JsonGetString(body, "pubkey")
4057  	c.supportedNIPs = helpers.JsonGetIntArray(body, "supported_nips")
4058  	lim := helpers.JsonGetValue(body, "limitation")
4059  	if lim != "" {
4060  		c.authRequired = helpers.JsonGetBool(lim, "auth_required")
4061  		c.paymentReq = helpers.JsonGetBool(lim, "payment_required")
4062  		c.restrictedW = helpers.JsonGetBool(lim, "restricted_writes")
4063  	}
4064  	return c
4065  }
4066  
4067  // loadRelayPolicy reads the persisted relay policy bitfield and blocklist from
4068  // localStorage. Called once at startup before any relay logic runs.
4069  func loadRelayPolicy() {
4070  	if s := localstorage.GetItem(lsKeyRelayPolicy); s != "" {
4071  		appSt.relayPolicyFlags = parseIntProp(s)
4072  	}
4073  	if s := localstorage.GetItem(lsKeyRelayBlocklist); s != "" {
4074  		appSt.relayBlocklist = splitLines(s)
4075  	}
4076  }
4077  
4078  // saveRelayPolicy persists relayPolicyFlags and relayBlocklist to localStorage.
4079  // Called from the settings UI whenever the user changes a toggle or the
4080  // blocklist is mutated. Does not publish kind 10006 - that is done explicitly
4081  // by the blocklist mutation helpers.
4082  func saveRelayPolicy() {
4083  	localstorage.SetItem(lsKeyRelayPolicy, itoa(appSt.relayPolicyFlags))
4084  	localstorage.SetItem(lsKeyRelayBlocklist, joinLines(appSt.relayBlocklist))
4085  }
4086  
4087  // saveRelayList persists the current relay URLs and roles to localStorage.
4088  func saveRelayList() {
4089  	localstorage.SetItem(lsKeyRelayList, joinLines(appSt.relayURLs))
4090  	roles := ""
4091  	for i, r := range appSt.relayRole {
4092  		if i > 0 {
4093  			roles = roles | "\n"
4094  		}
4095  		roles = roles | r
4096  	}
4097  	localstorage.SetItem(lsKeyRelayRoles, roles)
4098  }
4099  
4100  // loadRelayList reads persisted relay URLs and roles and adds them.
4101  // Returns true if any were loaded (so defaults can be skipped).
4102  func loadRelayList() (ok bool) {
4103  	s := localstorage.GetItem(lsKeyRelayList)
4104  	if s == "" {
4105  		return false
4106  	}
4107  	urls := splitLines(s)
4108  	roles := splitLines(localstorage.GetItem(lsKeyRelayRoles))
4109  	added := 0
4110  	for i, u := range urls {
4111  		if len(u) == 0 {
4112  			continue
4113  		}
4114  		role := "both"
4115  		if i < len(roles) && (roles[i] == "read" || roles[i] == "write") {
4116  			role = roles[i]
4117  		}
4118  		addRelayWithRole(u, false, role)
4119  		added++
4120  	}
4121  	return added > 0
4122  }
4123  
4124  // isBlocked reports whether url (already normalized) is in relayBlocklist.
4125  var dnsBlacklist []string
4126  
4127  func isBlocked(url string) (ok bool) {
4128  	for _, b := range dnsBlacklist {
4129  		if b == url {
4130  			return true
4131  		}
4132  	}
4133  	for _, b := range appSt.relayBlocklist {
4134  		if b == url {
4135  			return true
4136  		}
4137  	}
4138  	return false
4139  }
4140  
4141  // removeRelayByURL tears down the popover row for url and splices it out of
4142  // all parallel relay slices. No-op if url is not present.
4143  func removeRelayByURL(url string) {
4144  	url = normalizeURL(url)
4145  	for i, u := range appSt.relayURLs {
4146  		if u != url {
4147  			continue
4148  		}
4149  		// Detach popover row.
4150  		dom.RemoveChild(appSt.popoverEl, appSt.relayRows[i])
4151  		// Splice all parallel slices.
4152  		appSt.relayURLs = appSt.relayURLs[:i] | appSt.relayURLs[i+1:]
4153  		appSt.relayRows = appSt.relayRows[:i] | appSt.relayRows[i+1:]
4154  		appSt.relayDots = appSt.relayDots[:i] | appSt.relayDots[i+1:]
4155  		appSt.relayLabels = appSt.relayLabels[:i] | appSt.relayLabels[i+1:]
4156  		appSt.relayBadgeContainers = appSt.relayBadgeContainers[:i] | appSt.relayBadgeContainers[i+1:]
4157  		appSt.relayUserPick = appSt.relayUserPick[:i] | appSt.relayUserPick[i+1:]
4158  		appSt.relayRole = appSt.relayRole[:i] | appSt.relayRole[i+1:]
4159  		appSt.relayReadBtns = appSt.relayReadBtns[:i] | appSt.relayReadBtns[i+1:]
4160  		appSt.relayWriteBtns = appSt.relayWriteBtns[:i] | appSt.relayWriteBtns[i+1:]
4161  		appSt.relayCaps = appSt.relayCaps[:i] | appSt.relayCaps[i+1:]
4162  		appSt.relayCapsTs = appSt.relayCapsTs[:i] | appSt.relayCapsTs[i+1:]
4163  		saveRelayList()
4164  		updateStatus()
4165  		return
4166  	}
4167  }
4168  
4169  // addToBlocklist records url in relayBlocklist, tears down any active row for
4170  // it, persists, and publishes kind 10006. Called only from explicit UI action.
4171  func addToBlocklist(url string) {
4172  	url = normalizeURL(url)
4173  	if isBlocked(url) {
4174  		return
4175  	}
4176  	appSt.relayBlocklist = mxutil.Ensure(appSt.relayBlocklist, 1)
4177  	appSt.relayBlocklist = push(appSt.relayBlocklist, url)
4178  	removeRelayByURL(url)
4179  	saveRelayPolicy()
4180  	publishKind10006()
4181  }
4182  
4183  // removeFromBlocklist unblocks url, persists, and publishes kind 10006. Does
4184  // not auto-re-add the relay - user must add it back explicitly if desired.
4185  func removeFromBlocklist(url string) {
4186  	url = normalizeURL(url)
4187  	for i, b := range appSt.relayBlocklist {
4188  		if b == url {
4189  			appSt.relayBlocklist = appSt.relayBlocklist[:i] | appSt.relayBlocklist[i+1:]
4190  			saveRelayPolicy()
4191  			publishKind10006()
4192  			return
4193  		}
4194  	}
4195  }
4196  
4197  // publishKind10006 signs and publishes a NIP-51 "Blocked Relays" list. Called
4198  // only on explicit blocklist mutation - never from loadRelayPolicy, so merely
4199  // loading musiquay does not re-publish the list.
4200  func publishKind10006() {
4201  	if !signer.HasSigner() || appSt.pubhex == "" {
4202  		return
4203  	}
4204  	// Build tags: [["relay","wss://..."], ...]
4205  	tags := "["
4206  	for i, u := range appSt.relayBlocklist {
4207  		if i > 0 {
4208  			tags = tags | ","
4209  		}
4210  		tags = tags | "[\"relay\"," | jstr(u) | "]"
4211  	}
4212  	tags = tags | "]"
4213  	ts := dom.NowSeconds()
4214  	unsigned := "{\"kind\":10006,\"content\":\"\",\"tags\":" | tags |
4215  		",\"created_at\":" | i64toa(ts) |
4216  		",\"pubkey\":\"" | appSt.pubhex | "\"}"
4217  	signer.SignEvent(unsigned, func(signed string) {
4218  		if signed != "" {
4219  			routeMsg("[\"EVENT\"," | signed | "]")
4220  			dom.ConsoleLog("[blocklist] published kind 10006 with " | itoa(len(appSt.relayBlocklist)) | " entries")
4221  		}
4222  	})
4223  }
4224  
4225  // publishProtocol signs a musiquay protocol payload and publishes it as a bare
4226  // event. The vocabulary renders its own tag list and the envelope is the only
4227  // part built here, so this is one place for every public kind.
4228  //
4229  // The private kinds - the purchase order (3222) and the delivery receipt
4230  // (32218) - never reach this helper: they travel only inside a NIP-59 wrap and
4231  // a relay rejects them bare.
4232  func publishProtocol(kind uint16, tags [][]string, content string) {
4233  	if !signer.HasSigner() || appSt.pubhex == "" {
4234  		return
4235  	}
4236  	dom.ConsoleLog("[proto] publishing kind " | itoa(int32(kind)) | " (" | protocol.KindName(kind) | ")")
4237  	ts := dom.NowSeconds()
4238  	unsigned := protocolwire.UnsignedJSON(kind, protocolwire.TagsJSON(tags), content, appSt.pubhex, ts)
4239  	signer.SignEvent(unsigned, func(signed string) {
4240  		if signed != "" {
4241  			routeMsg("[\"EVENT\"," | signed | "]")
4242  		}
4243  	})
4244  }
4245  
4246  // The named wrappers below are what app code calls; each hands the vocabulary's
4247  // own tag list to the one publish path above.
4248  
4249  func publishTrack(v *protocol.Track) {
4250  	publishProtocol(protocol.KindTrack, v.Tags(), v.Content)
4251  }
4252  
4253  func publishAlbum(v *protocol.Album) {
4254  	publishProtocol(protocol.KindAlbum, v.Tags(), v.Content)
4255  }
4256  
4257  func publishPlaylist(v *protocol.Playlist) {
4258  	publishProtocol(protocol.KindPlaylist, v.Tags(), v.Content)
4259  }
4260  
4261  func publishSetlist(v *protocol.Setlist) {
4262  	publishProtocol(protocol.KindSetlist, v.Tags(), v.Content)
4263  }
4264  
4265  func publishReleaseCredits(v *protocol.ReleaseCredits) {
4266  	publishProtocol(protocol.KindReleaseCredits, v.Tags(), v.Content)
4267  }
4268  
4269  func publishTrackCredits(v *protocol.TrackCredits) {
4270  	publishProtocol(protocol.KindTrackCredits, v.Tags(), v.Content)
4271  }
4272  
4273  func publishEdition(v *protocol.Edition) {
4274  	publishProtocol(protocol.KindEdition, v.Tags(), v.Content)
4275  }
4276  
4277  func publishAccessFilter(v *protocol.AccessFilter) {
4278  	publishProtocol(protocol.KindAccessFilter, v.Tags(), v.Content)
4279  }
4280  
4281  func publishPlayReport(v *protocol.PlayReport) {
4282  	publishProtocol(protocol.KindPlayReport, v.Tags(), v.Content)
4283  }
4284  
4285  func publishCalendarEvent(v *protocol.CalendarEvent) {
4286  	publishProtocol(protocol.KindCalendar, v.Tags(), v.Content)
4287  }
4288  
4289  func publishListing(v *protocol.Listing) {
4290  	publishProtocol(protocol.KindListing, v.Tags(), v.Content)
4291  }
4292  
4293  // The NIP-15 payloads are JSON in content with no tag vocabulary; the only tag
4294  // is the d that makes the event addressable, and it is the payload's id.
4295  func publishStall(v *protocol.Stall) {
4296  	tags := [][]string{[]string{protocol.TagD, v.ID}}
4297  	publishProtocol(protocol.KindStall, tags, v.JSON())
4298  }
4299  
4300  func publishProduct(v *protocol.Product) {
4301  	tags := [][]string{[]string{protocol.TagD, v.ID}}
4302  	publishProtocol(protocol.KindProduct, tags, v.JSON())
4303  }
4304  
4305  // addRelay adds a relay with role "both".
4306  func addRelay(url string, userPick bool) {
4307  	addRelayWithRole(url, userPick, "both")
4308  }
4309  
4310  // applyRelayRoleBtns sets the active/inactive styling on the r/w buttons.
4311  func applyRelayRoleBtns(rBtn, wBtn dom.Element, role string) {
4312  	canRead := role == "both" || role == "read"
4313  	canWrite := role == "both" || role == "write"
4314  	if canRead {
4315  		dom.SetStyle(rBtn, "background", "var(--bg2)")
4316  		dom.SetStyle(rBtn, "color", "var(--fg)")
4317  		dom.SetStyle(rBtn, "borderColor", "var(--fg)")
4318  	} else {
4319  		dom.SetStyle(rBtn, "background", "transparent")
4320  		dom.SetStyle(rBtn, "color", "var(--muted)")
4321  		dom.SetStyle(rBtn, "borderColor", "var(--border)")
4322  	}
4323  	if canWrite {
4324  		dom.SetStyle(wBtn, "background", "var(--bg2)")
4325  		dom.SetStyle(wBtn, "color", "var(--fg)")
4326  		dom.SetStyle(wBtn, "borderColor", "var(--fg)")
4327  	} else {
4328  		dom.SetStyle(wBtn, "background", "transparent")
4329  		dom.SetStyle(wBtn, "color", "var(--muted)")
4330  		dom.SetStyle(wBtn, "borderColor", "var(--border)")
4331  	}
4332  }
4333  
4334  // addRelayWithRole adds a relay to the list and creates its popover row.
4335  // role is "both" (read+write), "read", or "write".
4336  func addRelayWithRole(url string, userPick bool, role string) {
4337  	url = normalizeURL(url)
4338  	// Blocklist guard - never attach a blocked relay, regardless of source.
4339  	if isBlocked(url) {
4340  		return
4341  	}
4342  	// Dedup.
4343  	for i, u := range appSt.relayURLs {
4344  		if u == url {
4345  			if userPick && !appSt.relayUserPick[i] {
4346  				appSt.relayUserPick[i] = true
4347  				dom.SetStyle(appSt.relayLabels[i], "fontWeight", "bold")
4348  			}
4349  			return
4350  		}
4351  	}
4352  
4353  	appSt.relayURLs = mxutil.Ensure(appSt.relayURLs, 1)
4354  	appSt.relayURLs = push(appSt.relayURLs, url)
4355  	appSt.relayUserPick = mxutil.Ensure(appSt.relayUserPick, 1)
4356  	appSt.relayUserPick = push(appSt.relayUserPick, userPick)
4357  	appSt.relayRole = mxutil.Ensure(appSt.relayRole, 1)
4358  	appSt.relayRole = push(appSt.relayRole, role)
4359  	appSt.relayCaps = mxutil.Ensure(appSt.relayCaps, 1)
4360  	appSt.relayCaps = push(appSt.relayCaps, relayCap{})
4361  	appSt.relayCapsTs = mxutil.Ensure(appSt.relayCapsTs, 1)
4362  	appSt.relayCapsTs = push(appSt.relayCapsTs, 0)
4363  	saveRelayList()
4364  
4365  	// Popover row.
4366  	row := dom.CreateElement("div")
4367  	dom.SetStyle(row, "padding", "3px 0")
4368  	dom.SetStyle(row, "display", "flex")
4369  	dom.SetStyle(row, "alignItems", "center")
4370  	dom.SetStyle(row, "gap", "4px")
4371  	appSt.relayRows = mxutil.Ensure(appSt.relayRows, 1)
4372  	appSt.relayRows = push(appSt.relayRows, row)
4373  
4374  	dot := dom.CreateElement("span")
4375  	dom.SetTextContent(dot, "\u25CF")
4376  	dom.SetStyle(dot, "color", "#5b5")
4377  	dom.SetStyle(dot, "flexShrink", "0")
4378  	appSt.relayDots = mxutil.Ensure(appSt.relayDots, 1)
4379  	appSt.relayDots = push(appSt.relayDots, dot)
4380  	dom.AppendChild(row, dot)
4381  
4382  	label := dom.CreateElement("span")
4383  	dom.SetTextContent(label, url)
4384  	dom.SetStyle(label, "flex", "1")
4385  	dom.SetStyle(label, "overflow", "hidden")
4386  	dom.SetStyle(label, "textOverflow", "ellipsis")
4387  	dom.SetStyle(label, "whiteSpace", "nowrap")
4388  	if userPick {
4389  		dom.SetStyle(label, "fontWeight", "bold")
4390  	}
4391  	appSt.relayLabels = mxutil.Ensure(appSt.relayLabels, 1)
4392  	appSt.relayLabels = push(appSt.relayLabels, label)
4393  	dom.AppendChild(row, label)
4394  
4395  	// Separate read / write toggle buttons.
4396  	makeRoleBtn := func(label string) dom.Element {
4397  		b := dom.CreateElement("button")
4398  		dom.SetTextContent(b, label)
4399  		dom.SetStyle(b, "flexShrink", "0")
4400  		dom.SetStyle(b, "fontSize", "10px")
4401  		dom.SetStyle(b, "fontFamily", "'Fira Code', monospace")
4402  		dom.SetStyle(b, "padding", "1px 5px")
4403  		dom.SetStyle(b, "borderRadius", "3px")
4404  		dom.SetStyle(b, "border", "1px solid var(--border)")
4405  		dom.SetStyle(b, "cursor", "pointer")
4406  		return b
4407  	}
4408  	rBtn := makeRoleBtn("r")
4409  	wBtn := makeRoleBtn("w")
4410  	appSt.relayReadBtns = mxutil.Ensure(appSt.relayReadBtns, 1)
4411  	appSt.relayReadBtns = push(appSt.relayReadBtns, rBtn)
4412  	appSt.relayWriteBtns = mxutil.Ensure(appSt.relayWriteBtns, 1)
4413  	appSt.relayWriteBtns = push(appSt.relayWriteBtns, wBtn)
4414  	applyRelayRoleBtns(rBtn, wBtn, role)
4415  
4416  	capturedURL := url
4417  	dom.AddEventListener(rBtn, "click", dom.RegisterCallback(func() {
4418  		for i, u := range appSt.relayURLs {
4419  			if u != capturedURL {
4420  				continue
4421  			}
4422  			switch appSt.relayRole[i] {
4423  			case "both":
4424  				appSt.relayRole[i] = "write"
4425  			case "read":
4426  				appSt.relayRole[i] = "write"
4427  			default: // "write" - re-enable read
4428  				appSt.relayRole[i] = "both"
4429  			}
4430  			applyRelayRoleBtns(appSt.relayReadBtns[i], appSt.relayWriteBtns[i], appSt.relayRole[i])
4431  			saveRelayList()
4432  			sendWriteRelays()
4433  			break
4434  		}
4435  	}))
4436  	dom.AddEventListener(wBtn, "click", dom.RegisterCallback(func() {
4437  		for i, u := range appSt.relayURLs {
4438  			if u != capturedURL {
4439  				continue
4440  			}
4441  			switch appSt.relayRole[i] {
4442  			case "both":
4443  				appSt.relayRole[i] = "read"
4444  			case "write":
4445  				appSt.relayRole[i] = "read"
4446  			default: // "read" - re-enable write
4447  				appSt.relayRole[i] = "both"
4448  			}
4449  			applyRelayRoleBtns(appSt.relayReadBtns[i], appSt.relayWriteBtns[i], appSt.relayRole[i])
4450  			saveRelayList()
4451  			sendWriteRelays()
4452  			break
4453  		}
4454  	}))
4455  	// Empty badge container - populated when NIP-11 fetch completes.
4456  	badgeBox := dom.CreateElement("span")
4457  	dom.SetStyle(badgeBox, "flexShrink", "0")
4458  	appSt.relayBadgeContainers = mxutil.Ensure(appSt.relayBadgeContainers, 1)
4459  	appSt.relayBadgeContainers = push(appSt.relayBadgeContainers, badgeBox)
4460  	dom.AppendChild(row, badgeBox)
4461  
4462  	// r/w buttons at far right.
4463  	dom.SetStyle(rBtn, "marginLeft", "auto")
4464  	dom.AppendChild(row, rBtn)
4465  	dom.AppendChild(row, wBtn)
4466  
4467  	dom.InsertBefore(appSt.popoverEl, row, appSt.broadcastRow)
4468  	updateStatus()
4469  	if appSt.feedSelectEl != 0 {
4470  		populateFeedSelect()
4471  	}
4472  
4473  	// Fetch NIP-11 capability document asynchronously. The callback captures
4474  	// `url` (a fresh parameter per call, not a loop variable) and looks the
4475  	// index up at callback time so it still lands correctly if the list grew.
4476  	httpURL := url
4477  	if len(httpURL) > 6 && httpURL[:6] == "wss://" {
4478  		httpURL = "https://" | httpURL[6:]
4479  	} else if len(httpURL) > 5 && httpURL[:5] == "ws://" {
4480  		httpURL = "http://" | httpURL[5:]
4481  	}
4482  	dom.FetchRelayInfo(httpURL, func(body string) {
4483  		caps := parseNIP11Caps(body)
4484  		for i, u := range appSt.relayURLs {
4485  			if u == url {
4486  				appSt.relayCaps[i] = caps
4487  				appSt.relayCapsTs[i] = dom.NowSeconds()
4488  				updateRelayBadges(i)
4489  				dom.ConsoleLog("[caps] " | url |
4490  					" ok=" | boolStr(caps.fetchedOK) |
4491  					" nip42=" | boolStr(hasNIP(caps.supportedNIPs, 42)) |
4492  					" nip70=" | boolStr(hasNIP(caps.supportedNIPs, 70)) |
4493  					" authReq=" | boolStr(caps.authRequired))
4494  				return
4495  			}
4496  		}
4497  	})
4498  }
4499  
4500  func togglePopover() {
4501  	appSt.popoverOpen = !appSt.popoverOpen
4502  	if appSt.popoverOpen {
4503  		if appSt.signerOpen {
4504  			hideSignerPanel()
4505  		}
4506  		closeFeedPopover()
4507  		dom.SetStyle(appSt.popoverEl, "display", "block")
4508  		dom.SetStyle(appSt.statusEl, "background", "var(--accent)")
4509  		dom.SetStyle(appSt.statusEl, "color", "var(--bg)")
4510  	} else {
4511  		dom.SetStyle(appSt.popoverEl, "display", "none")
4512  		dom.SetStyle(appSt.statusEl, "background", "transparent")
4513  		dom.SetStyle(appSt.statusEl, "color", "var(--muted)")
4514  	}
4515  }
4516  
4517  func subscribeProfile() {
4518  	dom.ConsoleLog("[prof] subscribing for pubkey: " | appSt.pubhex)
4519  	rr := readRelayURLs()
4520  	proxy := []string{:len(discoveryRelays):len(discoveryRelays)+len(rr)}
4521  	copy(proxy, discoveryRelays)
4522  	for _, u := range rr {
4523  		proxy = appendUnique(proxy, u)
4524  	}
4525  	routeMsg(buildProxyMsg("prof",
4526  		"{\"authors\":[" | jstr(appSt.pubhex) | "],\"kinds\":[0,3,10002,10000,10050],\"limit\":8}",
4527  		proxy))
4528  	routeMsg(buildProxyMsg("prof-settings",
4529  		"{\"authors\":[" | jstr(appSt.pubhex) | "],\"kinds\":[30078],\"#d\":[\"musiquay-settings\"],\"limit\":1}",
4530  		proxy))
4531  }
4532  
4533  
4534  func subscribeFeed() {
4535  	// Throttle: collapse rapid calls into one after 2s of quiet.
4536  	// Also enforce 10s cooldown - don't resubscribe if nothing changed.
4537  	appSt.feedSubscribed = true
4538  	if appSt.feedSubTimer != 0 {
4539  		dom.ClearTimeout(appSt.feedSubTimer)
4540  	}
4541  	appSt.feedSubTimer = dom.SetTimeout(func() {
4542  		appSt.feedSubTimer = 0
4543  		now := dom.NowSeconds()
4544  		if appSt.feedLastSubTs > 0 && now-appSt.feedLastSubTs < 10 {
4545  			return
4546  		}
4547  		doSubscribeFeed()
4548  	}, 2000)
4549  }
4550  
4551  func doSubscribeFeed() {
4552  	// Musiquay base: the kind-1 forum feed is not part of this build. The
4553  	// subscription and rendering code below stays for the port.
4554  	return
4555  
4556  	appSt.feedLastSubTs = dom.NowSeconds()
4557  	dom.ConsoleLog("[sub] feed mode=" | appSt.feedMode | " follows=" | itoa(len(appSt.myFollows)))
4558  	appSt.feedExhausted = false
4559  	appSt.feedEmptyStreak = 0
4560  	appSt.feedInitialLoad = true
4561  	if appSt.feedInitialLoadTimer != 0 {
4562  		dom.ClearTimeout(appSt.feedInitialLoadTimer)
4563  		appSt.feedInitialLoadTimer = 0
4564  	}
4565  	appSt.feedBuffer = nil
4566  	appSt.feedHasNew = false
4567  	stopRefreshPulse()
4568  	dom.SetStyle(appSt.loadMoreBtn, "display", "none")
4569  
4570  	// Sync state to Feed Worker, then tell it to subscribe.
4571  	// Feed Worker owns the relay subscriptions via F_SUB/F_CLOSE.
4572  	feed.Send(`["F_SET_MODE",` | jstr(appSt.feedMode) | `]`)
4573  	feed.Send(`["F_SET_RELAYS",` | readRelayURLsJSON() | `]`)
4574  	feed.Send(`["F_SET_PUBKEY",` | jstr(appSt.pubhex) | `]`)
4575  	if len(appSt.myFollows) > 0 {
4576  		feed.Send(`["F_SET_FOLLOWS",` | buildJSONStrArr(appSt.myFollows) | `]`)
4577  	}
4578  	if len(appSt.myMutes) > 0 {
4579  		feed.Send(`["F_SET_MUTES",` | buildJSONStrArr(appSt.myMutes) | `]`)
4580  	}
4581  	feed.Send(`["F_REFRESH"]`)
4582  }
4583  
4584  func buildFeedFilter(limit int32) (s string) {
4585  	if appSt.feedMode == "follows" {
4586  		follows := appSt.myFollows
4587  		if len(follows) > 0 {
4588  			// Always include own pubkey so user's own posts appear in the feed.
4589  			authors := jstr(appSt.pubhex)
4590  			for _, pk := range follows {
4591  				if pk != appSt.pubhex {
4592  					authors = authors | "," | jstr(pk)
4593  				}
4594  			}
4595  			return "{\"kinds\":[1,6,7,1111],\"authors\":[" | authors | "],\"limit\":" | itoa(limit) | "}"
4596  		}
4597  	}
4598  	return "{\"kinds\":[1,6,7,1111],\"limit\":" | itoa(limit) | "}"
4599  }
4600  
4601  func feedPassesFilter(ev *nostr.Event) (ok bool) {
4602  	if appSt.feedMode != "follows" {
4603  		return true
4604  	}
4605  	follows := appSt.myFollows
4606  	if len(follows) == 0 {
4607  		return true
4608  	}
4609  	for _, pk := range follows {
4610  		if pk == ev.PubKey {
4611  			return true
4612  		}
4613  	}
4614  	// Also allow own events.
4615  	return ev.PubKey == appSt.pubhex
4616  }
4617  
4618  func feedRelays() (ss []string) {
4619  	if appSt.feedMode != "" && appSt.feedMode != "follows" && appSt.feedMode != "relays" {
4620  		// Single relay mode.
4621  		return []string{appSt.feedMode}
4622  	}
4623  	return appSt.relayURLs
4624  }
4625  
4626  func feedModeURL() (s string) {
4627  	switch appSt.feedMode {
4628  	case "", "follows":
4629  		return "/feed/follows"
4630  	case "relays":
4631  		return "/feed/relays"
4632  	}
4633  	// Per-relay: strip ws:// or wss:// for the URL path.
4634  	host := stripScheme(appSt.feedMode)
4635  	return "/feed/relay/" | host
4636  }
4637  
4638  func stripScheme(url string) (s string) {
4639  	if len(url) > 6 && url[:6] == "wss://" {
4640  		return url[6:]
4641  	}
4642  	if len(url) > 5 && url[:5] == "ws://" {
4643  		return url[5:]
4644  	}
4645  	return url
4646  }
4647  
4648  
4649  func loadOlderFeed() {
4650  	appSt.feedLoading = true
4651  	appSt.feedMoreGot = 0
4652  	feed.Send(`["F_LOAD_MORE"]`)
4653  	if appSt.feedMoreTimer != 0 {
4654  		dom.ClearTimeout(appSt.feedMoreTimer)
4655  	}
4656  	appSt.feedMoreTimer = dom.SetTimeout(func() {
4657  		appSt.feedMoreTimer = 0
4658  		if appSt.feedLoading {
4659  			dom.ConsoleLog("[loadOlderFeed] timeout - forcing feedLoading=false, got=" | itoa(appSt.feedMoreGot))
4660  			appSt.feedLoading = false
4661  			if appSt.feedMoreGot == 0 {
4662  				appSt.feedEmptyStreak++
4663  				if appSt.feedEmptyStreak >= 3 {
4664  					appSt.feedExhausted = true
4665  				}
4666  			} else {
4667  				appSt.feedEmptyStreak = 0
4668  			}
4669  			if appSt.feedExhausted {
4670  				dom.SetStyle(appSt.loadMoreBtn, "display", "none")
4671  			} else if appSt.oldestFeedTs > 0 {
4672  				dom.SetStyle(appSt.loadMoreBtn, "display", "block")
4673  			}
4674  			updateStatus()
4675  		}
4676  	}, 10000)
4677  }
4678  
4679  func populateFeedSelect() {
4680  	updateFeedBtnText()
4681  	if appSt.feedPopoverEl == 0 {
4682  		return
4683  	}
4684  	clearChildren(appSt.feedPopoverEl)
4685  	if len(appSt.myFollows) > 0 {
4686  		addFeedOption("follows", "follows")
4687  	}
4688  	addFeedOption("relays", "relays")
4689  	for _, u := range appSt.relayURLs {
4690  		addFeedOption(u, stripScheme(u))
4691  	}
4692  }
4693  
4694  func addFeedOption(value, label string) {
4695  	row := dom.CreateElement("div")
4696  	dom.SetStyle(row, "padding", "6px 8px")
4697  	dom.SetStyle(row, "cursor", "pointer")
4698  	dom.SetStyle(row, "borderRadius", "4px")
4699  	if value == appSt.feedMode {
4700  		dom.SetStyle(row, "background", "var(--accent)")
4701  		dom.SetStyle(row, "color", "var(--bg)")
4702  	}
4703  	dom.SetTextContent(row, label)
4704  	v := value
4705  	dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
4706  		appSt.feedMode = v
4707  		updateFeedBtnText()
4708  		dom.PushState(feedModeURL())
4709  		closeFeedPopover()
4710  		refreshFeed()
4711  	}))
4712  	dom.AppendChild(appSt.feedPopoverEl, row)
4713  }
4714  
4715  func updateFeedBtnText() {
4716  	if appSt.feedSelectEl == 0 {
4717  		return
4718  	}
4719  	label := appSt.feedMode
4720  	if label == "follows" && len(appSt.myFollows) == 0 {
4721  		label = "all relays"
4722  	}
4723  	if label != "follows" && label != "relays" && label != "all relays" {
4724  		label = stripScheme(label)
4725  	}
4726  	dom.SetTextContent(appSt.feedSelectEl, label | " \u25BE")
4727  }
4728  
4729  func toggleFeedPopover() {
4730  	if appSt.feedPopoverOpen {
4731  		closeFeedPopover()
4732  		return
4733  	}
4734  	if appSt.signerOpen {
4735  		hideSignerPanel()
4736  	}
4737  	if appSt.popoverOpen {
4738  		togglePopover()
4739  	}
4740  	appSt.feedPopoverOpen = true
4741  	populateFeedSelect()
4742  	dom.SetStyle(appSt.feedPopoverEl, "display", "block")
4743  	dom.SetStyle(appSt.feedSelectEl, "background", "var(--accent)")
4744  	dom.SetStyle(appSt.feedSelectEl, "color", "var(--bg)")
4745  }
4746  
4747  func closeFeedPopover() {
4748  	if !appSt.feedPopoverOpen {
4749  		return
4750  	}
4751  	appSt.feedPopoverOpen = false
4752  	dom.SetStyle(appSt.feedPopoverEl, "display", "none")
4753  	dom.SetStyle(appSt.feedSelectEl, "background", "transparent")
4754  	dom.SetStyle(appSt.feedSelectEl, "color", "var(--fg)")
4755  	updateFeedBtnText()
4756  }
4757  
4758  func onRefreshClick() {
4759  	if appSt.refreshSpinning {
4760  		return
4761  	}
4762  	// Flush buffered new events into the feed.
4763  	if len(appSt.feedBuffer) > 0 {
4764  		for _, ev := range appSt.feedBuffer {
4765  			renderNote(ev)
4766  			appSt.seenEvents[ev.ID] = true
4767  		}
4768  		appSt.feedBuffer = nil
4769  		appSt.feedHasNew = false
4770  		stopRefreshPulse()
4771  	}
4772  	// Scroll to top.
4773  	dom.SetProperty(appSt.contentArea, "scrollTop", "0")
4774  	// Start spin animation.
4775  	startRefreshSpin()
4776  	// Cancel throttle and resubscribe immediately.
4777  	if appSt.feedSubTimer != 0 {
4778  		dom.ClearTimeout(appSt.feedSubTimer)
4779  		appSt.feedSubTimer = 0
4780  	}
4781  	appSt.feedSubscribed = true
4782  	doSubscribeFeed()
4783  }
4784  
4785  func refreshFeed() {
4786  	// Clear feed container.
4787  	clearChildren(appSt.feedContainer)
4788  	appSt.seenEvents = map[string]bool{}
4789  	appSt.noteElements = map[string]dom.Element{}
4790  	appSt.eventCount = 0
4791  	appSt.oldestFeedTs = 0
4792  	appSt.feedExhausted = false
4793  	appSt.feedEmptyStreak = 0
4794  	appSt.feedBuffer = nil
4795  	appSt.feedHasNew = false
4796  	stopRefreshPulse()
4797  	dom.SetStyle(appSt.loadMoreBtn, "display", "none")
4798  	dom.SetProperty(appSt.contentArea, "scrollTop", "0")
4799  	// Cancel any pending throttled sub and fire immediately.
4800  	if appSt.feedSubTimer != 0 {
4801  		dom.ClearTimeout(appSt.feedSubTimer)
4802  		appSt.feedSubTimer = 0
4803  	}
4804  	startRefreshSpin()
4805  	appSt.feedSubscribed = true
4806  	doSubscribeFeed()
4807  }
4808  
4809  
4810  func startRefreshPulse() {
4811  	if refreshBtn == 0 || appSt.refreshPulseTimer != 0 {
4812  		return
4813  	}
4814  	dom.SetStyle(refreshBtn, "color", "var(--accent)")
4815  	appSt.refreshPulseOn = true
4816  	pulseStep()
4817  }
4818  
4819  func pulseStep() {
4820  	if appSt.refreshPulseTimer == -1 || refreshBtn == 0 {
4821  		return
4822  	}
4823  	if appSt.refreshPulseOn {
4824  		dom.SetStyle(refreshBtn, "opacity", "0.4")
4825  	} else {
4826  		dom.SetStyle(refreshBtn, "opacity", "1")
4827  	}
4828  	appSt.refreshPulseOn = !appSt.refreshPulseOn
4829  	appSt.refreshPulseTimer = dom.SetTimeout(func() {
4830  		pulseStep()
4831  	}, 800)
4832  }
4833  
4834  func stopRefreshPulse() {
4835  	if appSt.refreshPulseTimer != 0 {
4836  		dom.ClearTimeout(appSt.refreshPulseTimer)
4837  	}
4838  	appSt.refreshPulseTimer = -1
4839  	dom.SetTimeout(func() {
4840  		appSt.refreshPulseTimer = 0
4841  	}, 0)
4842  	if refreshBtn != 0 {
4843  		dom.SetStyle(refreshBtn, "color", "var(--muted)")
4844  		dom.SetStyle(refreshBtn, "opacity", "1")
4845  	}
4846  }
4847  
4848  
4849  func startRefreshSpin() {
4850  	appSt.refreshSpinning = true
4851  	if refreshBtn == 0 {
4852  		return
4853  	}
4854  	appSt.refreshSpinAngle = 0
4855  	spinStep()
4856  }
4857  
4858  func spinStep() {
4859  	if !appSt.refreshSpinning || refreshBtn == 0 {
4860  		return
4861  	}
4862  	appSt.refreshSpinAngle += 30
4863  	dom.SetStyle(refreshBtn, "transform", "rotate(" | itoa(appSt.refreshSpinAngle) | "deg)")
4864  	appSt.refreshSpinTimer = dom.SetTimeout(func() {
4865  		spinStep()
4866  	}, 50)
4867  }
4868  
4869  func stopRefreshSpin() {
4870  	appSt.refreshSpinning = false
4871  	if appSt.refreshSpinTimer != 0 {
4872  		dom.ClearTimeout(appSt.refreshSpinTimer)
4873  		appSt.refreshSpinTimer = 0
4874  	}
4875  	if refreshBtn != 0 {
4876  		dom.SetStyle(refreshBtn, "transform", "rotate(0deg)")
4877  	}
4878  }
4879  
4880  func trackOldestTs(ev *nostr.Event) {
4881  	if appSt.oldestFeedTs == 0 || ev.CreatedAt < appSt.oldestFeedTs {
4882  		appSt.oldestFeedTs = ev.CreatedAt
4883  	}
4884  }
4885  
4886  func parseIntProp(s string) (n int32) {
4887  	n = 0
4888  	for i := 0; i < len(s); i++ {
4889  		if s[i] >= '0' && s[i] <= '9' {
4890  			n = n*10 + int32(s[i]-'0')
4891  		} else {
4892  			break
4893  		}
4894  	}
4895  	return n
4896  }
4897  
4898  func boolStr(b bool) (s string) {
4899  	if b {
4900  		return "true"
4901  	}
4902  	return "false"
4903  }
4904  
4905  func i64toa(n int64) (s string) {
4906  	if n == 0 {
4907  		return "0"
4908  	}
4909  	var buf [20]byte
4910  	i := len(buf)
4911  	for n > 0 {
4912  		i--
4913  		buf[i] = byte('0' + n%10)
4914  		n /= 10
4915  	}
4916  	return string(buf[i:])
4917  }
4918  
4919  func sendWriteRelays() {
4920  	msg := "[\"SET_WRITE_RELAYS\",["
4921  	first := true
4922  	for i, url := range appSt.relayURLs {
4923  		role := "both"
4924  		if i < len(appSt.relayRole) {
4925  			role = appSt.relayRole[i]
4926  		}
4927  		if role == "read" {
4928  			continue
4929  		}
4930  		if !first {
4931  			msg = msg | ","
4932  		}
4933  		msg = msg | jstr(url)
4934  		first = false
4935  	}
4936  	routeMsg(msg | "]]")
4937  }
4938  
4939  // readRelayURLs returns relay URLs usable for reading (role "both" or "read").
4940  func readRelayURLs() (ss []string) {
4941  	var out []string
4942  	for i, url := range appSt.relayURLs {
4943  		role := "both"
4944  		if i < len(appSt.relayRole) {
4945  			role = appSt.relayRole[i]
4946  		}
4947  		if role != "write" {
4948  			out = mxutil.Ensure(out, 1)
4949  			out = push(out, url)
4950  		}
4951  	}
4952  	return out
4953  }
4954  
4955  func buildProxyMsg(subID, filterJSON string, urls []string) (s string) {
4956  	msg := "[\"PROXY\"," | jstr(subID) | "," | filterJSON | ",["
4957  	for i, url := range urls {
4958  		if i > 0 {
4959  			msg = msg | ","
4960  		}
4961  		msg = msg | jstr(url)
4962  	}
4963  	return msg | "]]"
4964  }
4965  
4966  func jstr(s string) (sv string) {
4967  	return "\"" | jsonEsc(s) | "\""
4968  }
4969  
4970  // scheduleTabRetry schedules a retry for any pending profile fetches after
4971  // the follows/mutes tab renders. Independent of retryRound so it works even
4972  // after the feed's retry budget is exhausted.
4973  func scheduleTabRetry() {
4974  	dom.SetTimeout(func() {
4975  		var missing []string
4976  		for pk := range appSt.pendingNotes {
4977  			if _, ok := appSt.authorNames[pk]; !ok {
4978  				missing = mxutil.Ensure(missing, 1)
4979  				missing = push(missing, pk)
4980  			}
4981  		}
4982  		if len(missing) == 0 {
4983  			return
4984  		}
4985  		for _, pk := range missing {
4986  			setFetchedK0(pk, false)
4987  		}
4988  		for _, pk := range missing {
4989  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
4990  		}
4991  	}, 5000)
4992  }
4993  
4994  // --- SW message handling ---
4995  
4996  func onSWMessage(raw string) {
4997  	if raw == "update-available" {
4998  		routeMsg("activate-update")
4999  		return
5000  	}
5001  	if raw == "reload" {
5002  		dom.LocationReload()
5003  		return
5004  	}
5005  	if !appSt.appReady {
5006  		return
5007  	}
5008  	if len(raw) < 5 || raw[0] != '[' {
5009  		return
5010  	}
5011  	typ, pos := nextStr(raw, 1)
5012  	switch typ {
5013  	case "EVENT":
5014  		subID, pos2 := nextStr(raw, pos)
5015  		evJSON := extractValue(raw, pos2)
5016  		if evJSON == "" {
5017  			return
5018  		}
5019  		ev := nostr.ParseEvent(evJSON)
5020  		if ev == nil {
5021  			return
5022  		}
5023  		dispatchEvent(subID, ev)
5024  	case "EOSE":
5025  		subID, _ := nextStr(raw, pos)
5026  		dispatchEOSE(subID)
5027  	case "SW_LOG":
5028  		msg, _ := nextStr(raw, pos)
5029  		dom.ConsoleLog("[SW] " | msg)
5030  	case "BROADCAST_DONE":
5031  		if appSt.broadcastBtnEl != 0 {
5032  			dom.SetTextContent(appSt.broadcastBtnEl, "broadcast profile \u2713")
5033  			dom.SetStyle(appSt.broadcastBtnEl, "color", "var(--accent)")
5034  			dom.SetTimeout(func() {
5035  				dom.SetTextContent(appSt.broadcastBtnEl, "broadcast profile")
5036  			}, 3000)
5037  		}
5038  	case "MLS_DM_LIST":
5039  		listJSON := extractValue(raw, pos)
5040  		renderConversationList(listJSON)
5041  	case "MLS_DM_HISTORY":
5042  		peer, pos2 := nextStr(raw, pos)
5043  		msgsJSON := extractValue(raw, pos2)
5044  		renderThreadMessages(peer, msgsJSON)
5045  	case "MLS_DM_RECEIVED":
5046  		dmJSON := extractValue(raw, pos)
5047  		handleDMReceived(dmJSON)
5048  	case "MLS_DM_SENT":
5049  		// ok/err from MLS worker on send completion
5050  	case "MLS_UNREAD_COUNT":
5051  		// unread count badge (future step)
5052  	case "MLS_DM_HISTORY_CLEARED":
5053  		peer, _ := nextStr(raw, pos)
5054  		dom.ConsoleLog("[mls] history cleared for " | peer)
5055  	case "MLS_STATUS":
5056  		text, _ := nextStr(raw, pos)
5057  		dom.ConsoleLog("[mls] " | text)
5058  	case "OK":
5059  		okEvID, pos2 := nextStr(raw, pos)
5060  		// Boolean follows: skip comma/space, check for "true".
5061  		for pos2 < len(raw) && (raw[pos2] == ',' || raw[pos2] == ' ') {
5062  			pos2++
5063  		}
5064  		isOK := pos2+4 <= len(raw) && raw[pos2:pos2+4] == "true"
5065  		if isOK {
5066  			if targetID, ok2 := appSt.pendingDeletes[okEvID]; ok2 {
5067  				removeNoteFromDOM(targetID)
5068  				delete(appSt.pendingDeletes, okEvID)
5069  			}
5070  		} else {
5071  			// Reason follows after bool.
5072  			for pos2 < len(raw) && raw[pos2] != '"' {
5073  				pos2++
5074  			}
5075  			reason, _ := nextStr(raw, pos2)
5076  			logActivity("relay-err", okEvID[:12] | ": " | reason, "event " | okEvID | "\nreason: " | reason)
5077  		}
5078  	case "SEEN_ON":
5079  		evID, pos2 := nextStr(raw, pos)
5080  		rURL, _ := nextStr(raw, pos2)
5081  		// Strip trailing slash for dedup.
5082  		if len(rURL) > 0 && rURL[len(rURL)-1] == '/' {
5083  			rURL = rURL[:len(rURL)-1]
5084  		}
5085  		if evID != "" && rURL != "" && appSt.eventRelays != nil {
5086  			existing := appSt.eventRelays[evID]
5087  			found := false
5088  			for _, u := range existing {
5089  				if u == rURL {
5090  					found = true
5091  					break
5092  				}
5093  			}
5094  			if !found {
5095  				appSt.eventRelays[evID] = mxutil.Ensure(existing, 1)
5096  				appSt.eventRelays[evID] = mxutil.Ensure(appSt.eventRelays[evID], 1)
5097  				appSt.eventRelays[evID] = push(appSt.eventRelays[evID], rURL)
5098  				logActivity("seen-on", evID[:12] | " on " | rURL, "event " | evID | "\nrelay " | rURL)
5099  				if cb, ok := appSt.seenOnSubs[evID]; ok && cb != nil {
5100  					cb()
5101  				}
5102  			}
5103  		}
5104  	case "RELAY_PROXY_READY":
5105  		// Relay-proxy worker restarted (WASM fatal recovery). Re-send init
5106  		// state and re-subscribe so the worker is functional again.
5107  		if appSt.pubhex != "" {
5108  			routeMsg("[\"SET_PUBKEY\"," | jstr(appSt.pubhex) | "]")
5109  		}
5110  		appSt.feedSubscribed = false
5111  		resubscribe()
5112  	case "NEED_IDENTITY":
5113  		if appSt.pubhex != "" {
5114  			routeMsg("[\"SET_PUBKEY\"," | jstr(appSt.pubhex) | "]")
5115  		}
5116  		resubscribe()
5117  	case "RESUB":
5118  		resubscribe()
5119  
5120  	case "WORKER_RESTARTED":
5121  		// A domain worker crashed and restarted. Re-provision it with current state.
5122  		which, _ := nextStr(raw, pos)
5123  		if appSt.pubhex == "" {
5124  			return
5125  		}
5126  		if which == "/profile-wasm-host.mjs" {
5127  			profile.Send("[\"P_SET_PUBKEY\"," | jstr(appSt.pubhex) | "]")
5128  			profile.Send("[\"P_SET_RELAYS\"," | readRelayURLsJSON() | "]")
5129  		} else if which == "/feed-wasm-host.mjs" {
5130  			doSubscribeFeed()
5131  			} else if which == "/notif-wasm-host.mjs" {
5132  			subscribeNotifications()
5133  		}
5134  
5135  	// ── Profile Worker responses ─────────────────────────────────────────────
5136  	case "P_RESOLVED":
5137  		pk3, pos2a := nextStr(raw, pos)
5138  		name3, pos3a := nextStr(raw, pos2a)
5139  		pic3, _ := nextStr(raw, pos3a)
5140  		// Update view cache.
5141  		if len(name3) > 0 {
5142  			setAuthorName(pk3, name3)
5143  		}
5144  		if len(pic3) > 0 {
5145  			setAuthorPic(pk3, pic3)
5146  		}
5147  		// Fill pending note headers.
5148  		if len(name3) > 0 {
5149  			if headers, ok2 := appSt.pendingNotes[pk3]; ok2 {
5150  				for _, h := range headers {
5151  					updateNoteHeader(h, name3, pic3)
5152  				}
5153  				delete(appSt.pendingNotes, pk3)
5154  			}
5155  		}
5156  		if len(pic3) > 0 {
5157  			for _, avaEl := range appSt.pendingNotifAvatars[pk3] {
5158  				setMediaSrc(avaEl, pic3)
5159  			}
5160  			delete(appSt.pendingNotifAvatars, pk3)
5161  		}
5162  		if pk3 == appSt.pubhex {
5163  			if len(name3) > 0 { appSt.profileName = name3; dom.SetTextContent(appSt.nameEl, name3) }
5164  			if len(pic3) > 0 { appSt.profilePic = pic3; setMediaSrc(appSt.avatarEl, pic3); dom.SetStyle(appSt.avatarEl, "display", "block") }
5165  		}
5166  		updateReplyPreviewsForAuthor(pk3)
5167  		updateInlineProfileLinks(pk3)
5168  
5169  	case "P_CONTENT":
5170  		pk4, pos2b := nextStr(raw, pos)
5171  		contentJSON, _ := nextStr(raw, pos2b)
5172  		appSt.profileContentCache[pk4] = contentJSON
5173  		lud16 := helpers.JsonGetString(contentJSON, "lud16")
5174  		lud06 := helpers.JsonGetString(contentJSON, "lud06")
5175  		if lud16 != "" || lud06 != "" {
5176  			applyZapButtonsForAuthor(pk4)
5177  		} else {
5178  			delete(appSt.pendingZapRows, pk4)
5179  		}
5180  		// Re-render profile page if it's the viewed profile.
5181  		if appSt.profileViewPK == pk4 && appSt.activePage == "profile" {
5182  			renderProfilePage(pk4)
5183  		}
5184  		// Update own profile name if this is our own kind 0.
5185  		if pk4 == appSt.pubhex {
5186  			name4 := helpers.JsonGetString(contentJSON, "name")
5187  			if name4 == "" { name4 = helpers.JsonGetString(contentJSON, "display_name") }
5188  			if name4 != "" && appSt.profileName != name4 {
5189  				appSt.profileName = name4
5190  				dom.SetTextContent(appSt.nameEl, name4)
5191  			}
5192  		}
5193  		// Persist to IDB.
5194  		if len(contentJSON) > 0 {
5195  			dom.IDBPut("profiles", pk4, contentJSON)
5196  		}
5197  
5198  	case "P_FOLLOWS":
5199  		pkF, pos2c := nextStr(raw, pos)
5200  		followsJSON := extractValue(raw, pos2c)
5201  		pksF := parseStringArrayJSON(followsJSON)
5202  		followsChanged := false
5203  		if pkF == appSt.pubhex {
5204  			if len(pksF) != len(appSt.myFollows) {
5205  				followsChanged = true
5206  			}
5207  			appSt.myFollows = pksF
5208  			appSt.followSet = buildSet(pksF)
5209  			broadcastFollows(pksF)
5210  			if appSt.feedSelectEl != 0 { populateFeedSelect() }
5211  		}
5212  		appSt.profileFollowsCache[pkF] = pksF
5213  		refreshProfileTab(pkF)
5214  		if pkF == appSt.pubhex && appSt.feedSubscribed && appSt.feedMode == "follows" && followsChanged {
5215  			subscribeFeed()
5216  		}
5217  
5218  	case "P_MUTES":
5219  		pkM, pos2d := nextStr(raw, pos)
5220  		mutesJSON := extractValue(raw, pos2d)
5221  		pksM := parseStringArrayJSON(mutesJSON)
5222  		if pkM == appSt.pubhex {
5223  			appSt.myMutes = pksM
5224  			appSt.muteSet = buildSet(pksM)
5225  			broadcastMutes(pksM)
5226  		}
5227  		appSt.profileMutesCache[pkM] = pksM
5228  		refreshProfileTab(pkM)
5229  
5230  	case "P_RELAYS":
5231  		pkR, pos2e := nextStr(raw, pos)
5232  		relaysJSON := extractValue(raw, pos2e)
5233  		appSt.profileRelaysCache[pkR] = parseStringArrayJSON(relaysJSON)
5234  		if appSt.profileViewPK == pkR { refreshProfileTab(pkR) }
5235  
5236  	// ── Feed Worker responses ─────────────────────────────────────────────────
5237  	case "F_RENDER":
5238  		evJSON2 := extractValue(raw, pos)
5239  		if evJSON2 == "" { return }
5240  		fev := nostr.ParseEvent(evJSON2)
5241  		if fev == nil { return }
5242  		// pos+len(evJSON2)+... find 'now' flag
5243  		nowFlag := int64(0)
5244  		for i := pos + len(evJSON2); i < len(raw); i++ {
5245  			c := raw[i]
5246  			if c >= '0' && c <= '9' {
5247  				nowFlag = int64(c - '0')
5248  				break
5249  			}
5250  		}
5251  		setEvent(fev.ID, fev)
5252  		if fev.Kind == 7 {
5253  			handleActionEvent(fev)
5254  			return
5255  		}
5256  		trackOldestTs(fev)
5257  		if nowFlag == 1 {
5258  			if appSt.feedLoader != 0 {
5259  				dom.RemoveChild(appSt.feedPage, appSt.feedLoader)
5260  				appSt.feedLoader = 0
5261  			}
5262  			renderNote(fev)
5263  		} else {
5264  			if appSt.noteElements[fev.ID] != 0 || appSt.seenEvents[fev.ID] {
5265  				return
5266  			}
5267  			appSt.feedBuffer = mxutil.Ensure(appSt.feedBuffer, 1)
5268  			appSt.feedBuffer = push(appSt.feedBuffer, fev)
5269  			if !appSt.feedHasNew {
5270  				appSt.feedHasNew = true
5271  				startRefreshPulse()
5272  			}
5273  		}
5274  	case "PROXY_DROP":
5275  		// The relay-proxy reports a relay frame it could not route to a client
5276  		// subscription instead of dropping it silently.
5277  		subIDd, posD := nextStr(raw, pos)
5278  		whyD, _ := nextStr(raw, posD)
5279  		dom.ConsoleLog("[proxy] dropped frame for " | subIDd | ": " | whyD)
5280  	case "F_EOSE_DONE":
5281  		subID2, _ := nextStr(raw, pos)
5282  		if subID2 == "feed" {
5283  			appSt.feedInitialLoad = false
5284  			updateStatus()
5285  			fetchDeletesForFeed()
5286  		}
5287  	case "F_STATUS":
5288  		// ["F_STATUS", count, exhausted]
5289  		cntStr := nextNum(raw, pos)
5290  		var cnt int64
5291  		for _, c := range cntStr {
5292  			if c >= '0' && c <= '9' { cnt = cnt*10 + int64(c-'0') }
5293  		}
5294  		appSt.eventCount = int32(cnt)
5295  		dom.ConsoleLog("[feed] events=" | itoa(appSt.eventCount))
5296  		// Find exhausted flag after count.
5297  		exStr := ""
5298  		for p2 := pos + len(cntStr); p2 < len(raw); p2++ {
5299  			if raw[p2] >= '0' && raw[p2] <= '9' {
5300  				exStr = nextNum(raw, p2)
5301  				break
5302  			}
5303  		}
5304  		if exStr == "1" {
5305  			appSt.feedExhausted = true
5306  		}
5307  		updateStatus()
5308  		if appSt.feedLoading {
5309  			appSt.feedLoading = false
5310  		}
5311  		if appSt.feedExhausted {
5312  			dom.SetStyle(appSt.loadMoreBtn, "display", "none")
5313  		} else if appSt.oldestFeedTs > 0 {
5314  			dom.SetStyle(appSt.loadMoreBtn, "display", "block")
5315  		}
5316  
5317  	// ── Worker subscription bridge ────────────────────────────────────────────
5318  	// The feed and notif workers own their relay subscriptions and ask the
5319  	// Shell to open and close them. Without these arms the requests were
5320  	// dropped, so no REQ was ever sent for the feed and it stayed empty.
5321  	case "F_SUB", "N_SUB":
5322  		// ["X_SUB", subID, filterJSON, urlsJSON]
5323  		subIDw, posW := nextStr(raw, pos)
5324  		filterW := extractValue(raw, posW)
5325  		if subIDw == "" || filterW == "" { return }
5326  		urlsW := extractValue(raw, posW+len(filterW))
5327  		routeMsg(buildProxyMsg(subIDw, filterW, parseStringArrayJSON(urlsW)))
5328  		dom.ConsoleLog("[sub] " | subIDw)
5329  	case "F_CLOSE", "N_CLOSE":
5330  		subIDx, _ := nextStr(raw, pos)
5331  		if subIDx == "" { return }
5332  		routeMsg(`["CLOSE",` | jstr(subIDx) | `]`)
5333  
5334  	// ── Notif Worker responses ────────────────────────────────────────────────
5335  	case "N_RENDER":
5336  		nEvJSON := extractValue(raw, pos)
5337  		if nEvJSON == "" { return }
5338  		nev := nostr.ParseEvent(nEvJSON)
5339  		if nev == nil { return }
5340  		appSt.notifEvents = push(appSt.notifEvents, nev)
5341  		notifEventsTrim()
5342  		if appSt.notifBuilt && appSt.activePage == "notifications" && notifPassesFilter(nev) {
5343  			row := renderNotifRow(nev)
5344  			// mode: "prepend" = first child, "append" = append
5345  			modeStr := ""
5346  			for i := pos + len(nEvJSON); i < len(raw); i++ {
5347  				if raw[i] == '"' {
5348  					j := i + 1
5349  					for j < len(raw) && raw[j] != '"' { j++ }
5350  					modeStr = raw[i+1:j]
5351  					break
5352  				}
5353  			}
5354  			if modeStr == "prepend" {
5355  				first := dom.FirstElementChild(appSt.notifContainer)
5356  				if first != 0 { dom.InsertBefore(appSt.notifContainer, row, first) } else { dom.AppendChild(appSt.notifContainer, row) }
5357  			} else {
5358  				dom.AppendChild(appSt.notifContainer, row)
5359  			}
5360  		}
5361  	case "N_DOT":
5362  		showStr := nextNum(raw, pos)
5363  		if showStr == "1" { showNotifDot() }
5364  	case "N_STATUS":
5365  		// ["N_STATUS", count, exhausted]
5366  		nCntStr := nextNum(raw, pos)
5367  		nExStr := ""
5368  		for p3 := pos + len(nCntStr); p3 < len(raw); p3++ {
5369  			if raw[p3] >= '0' && raw[p3] <= '9' {
5370  				nExStr = nextNum(raw, p3)
5371  				break
5372  			}
5373  		}
5374  		if nExStr == "1" {
5375  			appSt.notifExhausted = true
5376  		}
5377  		appSt.notifLoading = false
5378  		if appSt.notifExhausted && appSt.notifLoadMore != 0 {
5379  			dom.SetStyle(appSt.notifLoadMore, "display", "none")
5380  		} else if appSt.oldestNotifTs > 0 && appSt.notifLoadMore != 0 {
5381  			dom.SetStyle(appSt.notifLoadMore, "display", "block")
5382  		}
5383  	case "N_EOSE_DONE":
5384  		subIDN, _ := nextStr(raw, pos)
5385  		if subIDN == "ntf" {
5386  			appSt.notifInitLoad = false
5387  			sortNotifEvents()
5388  			if appSt.activePage == "notifications" {
5389  				if appSt.notifBuilt {} else { renderNotifPage() }
5390  			}
5391  			if appSt.oldestNotifTs > 0 && appSt.notifLoadMore != 0 { dom.SetStyle(appSt.notifLoadMore, "display", "block") }
5392  		}
5393  	case "N_FETCH_REFS":
5394  		// Forward to relay-proxy: fetch the referenced events so notif row names can fill in.
5395  		idsJSON := extractValue(raw, pos)
5396  		if idsJSON != "" {
5397  			routeMsg(`["PROXY","ntf-ref",{"ids":` | idsJSON | `,"limit":` | itoa(len(appSt.notifMissing)) | `},` | buildURLsJSON(readRelayURLs()) | `]`)
5398  		}
5399  	case "N_STORE_READ_TS":
5400  		tsStr2 := nextNum(raw, pos)
5401  		var ts2 int64
5402  		for _, c := range tsStr2 { if c >= '0' && c <= '9' { ts2 = ts2*10 + int64(c-'0') } }
5403  		if ts2 > 0 { dom.IDBPut("settings", "notif-read-ts", i64toa(ts2)) }
5404  
5405  	// ── DM Worker responses ───────────────────────────────────────────────────
5406  	case "D_LIST_RESP":
5407  		listJSON2 := extractValue(raw, pos)
5408  		renderConversationList(listJSON2)
5409  	case "D_HISTORY_RESP":
5410  		peerD, pos3 := nextStr(raw, pos)
5411  		msgsD := extractValue(raw, pos3)
5412  		renderThreadMessages(peerD, msgsD)
5413  	case "D_RECEIVED":
5414  		dmJ := extractValue(raw, pos)
5415  		handleDMReceived(dmJ)
5416  	case "D_SENT":
5417  		_, pos4 := nextStr(raw, pos) // peer
5418  		okStr, _ := nextStr(raw, pos4)
5419  		if okStr == "1" || okStr == "true" {
5420  			if len(appSt.pendingTsEls) > 0 {
5421  				dom.SetTextContent(appSt.pendingTsEls[0], formatTime(dom.NowSeconds()))
5422  				appSt.pendingTsEls = appSt.pendingTsEls[1:]
5423  			}
5424  		}
5425  
5426  	// ── Profile Worker retry request ──────────────────────────────────────────
5427  	case "P_RETRY_REQ":
5428  		// Profile Worker is asking for pending pubkeys that still lack a name.
5429  		// Collect from pendingNotes and send back as P_RETRY_PROVIDE.
5430  		var pending []string
5431  		for pk := range appSt.pendingNotes {
5432  			if _, ok := appSt.authorNames[pk]; !ok {
5433  				pending = mxutil.Ensure(pending, 1)
5434  				pending = push(pending, pk)
5435  			}
5436  		}
5437  		if len(pending) > 0 {
5438  			profile.Send(`["P_RETRY_PROVIDE",` | buildJSONStrArr(pending) | `]`)
5439  		}
5440  	}
5441  }
5442  
5443  func resubscribe() {
5444  	sendWriteRelays()
5445  	subscribeProfile()
5446  	subscribeFeed()
5447  	subscribeNotifications()
5448  	// Sync domain workers with current state.
5449  	if appSt.pubhex != "" {
5450  		profile.Send("[\"P_SET_PUBKEY\"," | jstr(appSt.pubhex) | "]")
5451  		profile.Send("[\"P_SET_RELAYS\"," | readRelayURLsJSON() | "]")
5452  	}
5453  	if appSt.activePage == "profile" && appSt.profileTab == "notes" && appSt.profileViewPK != "" {
5454  		renderProfileNotes(appSt.profileViewPK)
5455  	}
5456  }
5457  
5458  func dispatchEvent(subID string, ev *nostr.Event) {
5459  	if subID == "prof" || subID == "prof-settings" {
5460  		handleProfileEvent(ev)
5461  	} else if subID == "feed" || subID == "feed-more" {
5462  		// Route to Feed Worker for dedup/filter/buffer management.
5463  		feed.Send(`["F_EVENT",` | jstr(subID) | `,` | ev.ToJSON() | `]`)
5464  	} else if len(subID) > 3 && subID[:3] == "ap-" {
5465  		// Profile Worker owns all profile data; forward the event for processing.
5466  		// P_RESOLVED/P_CONTENT/P_FOLLOWS/P_MUTES/P_RELAYS come back and Shell handles DOM.
5467  		profile.Send(`["P_EVENT",` | ev.ToJSON() | `]`)
5468  	} else if len(subID) > 3 && subID[:3] == "pn-" {
5469  		if appSt.profileNotesSeen[ev.ID] {
5470  			return
5471  		}
5472  		appSt.profileNotesSeen[ev.ID] = true
5473  		// Dedup against the broad feed sub: relays send the same event on
5474  		// every matching sub on a connection, so a profile-author's new
5475  		// note also arrives on "feed". Marking it here prevents it from
5476  		// being double-rendered into feedContainer later in the session.
5477  		appSt.seenEvents[ev.ID] = true
5478  		renderProfileNote(ev)
5479  	} else if len(subID) > 6 && subID[:6] == "emb-c-" {
5480  		setEvent(ev.ID, ev)
5481  		fillCoordEmbed(ev)
5482  	} else if len(subID) > 7 && subID[:7] == "emb-cr-" {
5483  		setEvent(ev.ID, ev)
5484  		fillCoordEmbed(ev)
5485  	} else if len(subID) > 4 && subID[:4] == "emb-" {
5486  		setEvent(ev.ID, ev)
5487  		fillEmbed(ev)
5488  	} else if len(subID) > 3 && subID[:3] == "rp-" {
5489  		handleReplyPreviewEvent(ev)
5490  	} else if len(subID) > 4 && subID[:4] == "thr-" {
5491  		handleThreadEvent(appSt.threadGen, ev)
5492  	} else if len(subID) > 4 && subID[:4] == "act-" {
5493  		handleActionEvent(ev)
5494  	} else if subID == "ntf" {
5495  		handleNotifEvent(ev)
5496  	} else if subID == "ntf-ref" {
5497  		handleNotifRefEvent(ev)
5498  	} else if subID == "ntf-more" {
5499  		handleNotifMoreEvent(ev)
5500  	} else if subID == "search" {
5501  		handleSearchEvent(ev)
5502  	} else if subID == "del-scan" {
5503  		if ev.Kind == 5 {
5504  			for _, tag := range nostr.TagsGetAll(ev.Tags, "e") {
5505  				targetID := nostr.TagValue(tag)
5506  				if targetID != "" {
5507  					removeNoteFromDOM(targetID)
5508  				}
5509  			}
5510  		}
5511  	}
5512  }
5513  
5514  func dispatchEOSE(subID string) {
5515  	// Route to domain workers first.
5516  	if subID == "feed" || subID == "feed-more" {
5517  		feed.Send(`["F_EOSE",` | jstr(subID) | `]`)
5518  	}
5519  	if subID == "ntf" || subID == "ntf-more" || subID == "ntf-ref" {
5520  		notif.Send(`["N_EOSE",` | jstr(subID) | `]`)
5521  	}
5522  	if (len(subID) > 3 && subID[:3] == "ap-") || (len(subID) > 9 && subID[:9] == "ap-batch-") {
5523  		profile.Send(`["P_EOSE",` | jstr(subID) | `]`)
5524  	}
5525  
5526  	if subID == "feed-more" {
5527  		dom.ConsoleLog("[EOSE] feed-more got=" | itoa(appSt.feedMoreGot))
5528  		if appSt.feedMoreTimer != 0 {
5529  			dom.ClearTimeout(appSt.feedMoreTimer)
5530  			appSt.feedMoreTimer = 0
5531  		}
5532  		appSt.feedLoading = false
5533  		if appSt.feedMoreGot == 0 {
5534  			appSt.feedEmptyStreak++
5535  			if appSt.feedEmptyStreak >= 3 {
5536  				appSt.feedExhausted = true
5537  			}
5538  		} else {
5539  			appSt.feedEmptyStreak = 0
5540  		}
5541  		if !appSt.feedExhausted && appSt.oldestFeedTs > 0 {
5542  			dom.SetStyle(appSt.loadMoreBtn, "display", "block")
5543  		}
5544  		updateStatus()
5545  	} else if subID == "feed" {
5546  		// Don't flip feedInitialLoad immediately. The SW sends EOSE on the
5547  		// FIRST relay's response (often the empty local relay), but slower
5548  		// remote relays keep streaming events for several seconds. Holding the
5549  		// initial-load window open lets those events render directly instead
5550  		// of being trapped in feedBuffer until the user clicks refresh.
5551  		if appSt.feedInitialLoadTimer != 0 {
5552  			dom.ClearTimeout(appSt.feedInitialLoadTimer)
5553  		}
5554  		appSt.feedInitialLoadTimer = dom.SetTimeout(func() {
5555  			appSt.feedInitialLoadTimer = 0
5556  			appSt.feedInitialLoad = false
5557  		}, 3000)
5558  		if appSt.refreshSpinning {
5559  			stopRefreshSpin()
5560  		}
5561  		if appSt.feedLoader != 0 {
5562  			dom.RemoveChild(appSt.feedPage, appSt.feedLoader)
5563  			appSt.feedLoader = 0
5564  		}
5565  		if appSt.oldestFeedTs > 0 {
5566  			dom.SetStyle(appSt.loadMoreBtn, "display", "block")
5567  		}
5568  		updateStatus()
5569  		retryMissingProfiles()
5570  		fetchDeletesForFeed()
5571  	} else if subID == "ntf" {
5572  		dom.ConsoleLog("[EOSE] ntf events=" | itoa(len(appSt.notifEvents)))
5573  		appSt.notifInitLoad = false
5574  		sortNotifEvents()
5575  		if appSt.activePage == "notifications" {
5576  			if appSt.notifBuilt {
5577  				// Events were rendered incrementally during load; skip the
5578  				// clear+rebuild flash. notifEvents is sorted for future renders
5579  				// (filter changes, load-more). The "load more" button appears below.
5580  			} else {
5581  				renderNotifPage()
5582  			}
5583  		}
5584  		if appSt.oldestNotifTs > 0 && appSt.notifLoadMore != 0 {
5585  			dom.SetStyle(appSt.notifLoadMore, "display", "block")
5586  		}
5587  		fetchNotifRefs()
5588  	} else if subID == "ntf-ref" {
5589  		if appSt.notifBuilt && appSt.activePage == "notifications" {
5590  			rebuildNotifRows()
5591  		}
5592  	} else if subID == "ntf-more" {
5593  		dom.ConsoleLog("[EOSE] ntf-more got=" | itoa(appSt.notifMoreGot) | " streak=" | itoa(appSt.notifEmptyStrk))
5594  		if appSt.notifMoreTimer != 0 {
5595  			dom.ClearTimeout(appSt.notifMoreTimer)
5596  			appSt.notifMoreTimer = 0
5597  		}
5598  		appSt.notifLoading = false
5599  		if appSt.notifMoreGot == 0 {
5600  			appSt.notifEmptyStrk++
5601  			if appSt.notifEmptyStrk >= 3 {
5602  				appSt.notifExhausted = true
5603  			}
5604  		} else {
5605  			appSt.notifEmptyStrk = 0
5606  		}
5607  		if !appSt.notifExhausted && appSt.oldestNotifTs > 0 && appSt.notifLoadMore != 0 {
5608  			dom.SetStyle(appSt.notifLoadMore, "display", "block")
5609  		}
5610  		fetchNotifRefs()
5611  	} else if subID == "search" {
5612  		dom.ConsoleLog("[EOSE] search results=" | itoa(len(appSt.searchResults)))
5613  		renderSearchResults()
5614  	} else if subID == "del-scan" {
5615  		routeMsg("[\"CLOSE\",\"del-scan\"]")
5616  	} else if len(subID) > 9 && subID[:9] == "ap-batch-" {
5617  		// Delay CLOSE; Profile Worker handles retry logic via P_EOSE (sent above).
5618  		closeID := subID
5619  		dom.SetTimeout(func() {
5620  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5621  		}, 15000)
5622  	} else if len(subID) > 3 && subID[:3] == "ap-" {
5623  		// Delay CLOSE; Profile Worker handles relay-hint retry via P_EOSE (sent above).
5624  		closeID := subID
5625  		dom.SetTimeout(func() {
5626  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5627  		}, 15000)
5628  	} else if len(subID) > 6 && subID[:6] == "emb-c-" {
5629  		closeID := subID
5630  		dom.SetTimeout(func() {
5631  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5632  			embedCoordEOSECleanup(closeID)
5633  		}, 5000)
5634  	} else if len(subID) > 7 && subID[:7] == "emb-cr-" {
5635  		closeID := subID
5636  		dom.SetTimeout(func() {
5637  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5638  			embedCoordEOSECleanup(closeID)
5639  		}, 5000)
5640  	} else if len(subID) > 4 && subID[:4] == "emb-" {
5641  		closeID := subID
5642  		dom.SetTimeout(func() {
5643  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5644  			embedEOSECleanup(closeID)
5645  		}, 5000)
5646  	} else if len(subID) > 3 && subID[:3] == "rp-" {
5647  		closeID := subID
5648  		dom.SetTimeout(func() {
5649  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5650  		}, 5000)
5651  	} else if len(subID) > 4 && subID[:4] == "thr-" {
5652  		closeID := subID
5653  		dom.SetTimeout(func() {
5654  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5655  		}, 5000)
5656  		if appSt.threadOpen {
5657  			handleThreadEOSE()
5658  		}
5659  	} else if len(subID) > 4 && subID[:4] == "act-" {
5660  		closeID := subID
5661  		dom.SetTimeout(func() {
5662  			routeMsg("[\"CLOSE\"," | jstr(closeID) | "]")
5663  		}, 10000)
5664  	}
5665  }
5666  
5667  // nextNum extracts a bare number from s starting at pos, returning it as a string.
5668  func nextNum(s string, pos int32) (sv string) {
5669  	for pos < len(s) && (s[pos] == ' ' || s[pos] == ',') {
5670  		pos++
5671  	}
5672  	start := pos
5673  	for pos < len(s) && s[pos] >= '0' && s[pos] <= '9' {
5674  		pos++
5675  	}
5676  	return s[start:pos]
5677  }
5678  
5679  // nextStr extracts the next quoted string from s starting at pos.
5680  func nextStr(s string, pos int32) (s2 string, n2 int32) {
5681  	for pos < len(s) && s[pos] != '"' {
5682  		pos++
5683  	}
5684  	if pos >= len(s) {
5685  		return "", pos
5686  	}
5687  	pos++
5688  	var buf []byte
5689  	hasEsc := false
5690  	start := pos
5691  	for pos < len(s) {
5692  		if s[pos] == '\\' && pos+1 < len(s) {
5693  			hasEsc = true
5694  			buf = buf | s[start:pos]
5695  			pos++
5696  			switch s[pos] {
5697  			case '"', '\\', '/':
5698  				buf = mxutil.Ensure(buf, 1)
5699  				buf = push(buf, s[pos])
5700  			case 'n':
5701  				buf = buf | "\n"
5702  			case 't':
5703  				buf = buf | "\t"
5704  			case 'r':
5705  				buf = buf | "\r"
5706  			default:
5707  				buf = buf | "\\" | string([]byte{s[pos]})
5708  			}
5709  			pos++
5710  			start = pos
5711  			continue
5712  		}
5713  		if s[pos] == '"' {
5714  			break
5715  		}
5716  		pos++
5717  	}
5718  	if pos >= len(s) {
5719  		return "", pos
5720  	}
5721  	var val string
5722  	if hasEsc {
5723  		buf = buf | s[start:pos]
5724  		val = string(buf)
5725  	} else {
5726  		val = s[start:pos]
5727  	}
5728  	pos++
5729  	for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') {
5730  		pos++
5731  	}
5732  	return val, pos
5733  }
5734  
5735  // extractValue extracts a JSON object/array value starting at pos.
5736  func extractValue(s string, pos int32) (sv string) {
5737  	for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') {
5738  		pos++
5739  	}
5740  	if pos >= len(s) {
5741  		return ""
5742  	}
5743  	if s[pos] != '{' && s[pos] != '[' {
5744  		return ""
5745  	}
5746  	start := pos
5747  	depth := 0
5748  	for pos < len(s) {
5749  		c := s[pos]
5750  		if c == '{' || c == '[' {
5751  			depth++
5752  		}
5753  		if c == '}' || c == ']' {
5754  			depth--
5755  			if depth == 0 {
5756  				return s[start : pos+1]
5757  			}
5758  		}
5759  		if c == '"' {
5760  			pos++
5761  			for pos < len(s) && s[pos] != '"' {
5762  				if s[pos] == '\\' {
5763  					pos++
5764  				}
5765  				pos++
5766  			}
5767  		}
5768  		pos++
5769  	}
5770  	return s[start:]
5771  }
5772  
5773  // parseStringArrayJSON parses a JSON array of strings like ["a","b","c"].
5774  func parseStringArrayJSON(json string) (ss []string) {
5775  	var out []string
5776  	i := 0
5777  	for i < len(json) && json[i] != '[' { i++ }
5778  	if i >= len(json) { return nil }
5779  	i++
5780  	for {
5781  		for i < len(json) && (json[i] == ' ' || json[i] == ',' || json[i] == '\n') { i++ }
5782  		if i >= len(json) || json[i] == ']' { break }
5783  		if json[i] != '"' { break }
5784  		i++
5785  		start := i
5786  		for i < len(json) && json[i] != '"' {
5787  			if json[i] == '\\' { i++ }
5788  			i++
5789  		}
5790  		if i >= len(json) { break }
5791  		out = mxutil.Ensure(out, 1)
5792  		out = push(out, json[start:i])
5793  		i++
5794  	}
5795  	return out
5796  }
5797  
5798  func handleProfileEvent(ev *nostr.Event) {
5799  	// Forward to Profile Worker for data storage in all cases.
5800  	profile.Send(`["P_EVENT",` | ev.ToJSON() | `]`)
5801  	// Shell handles DOM-immediate effects for the logged-in user's own events.
5802  	switch ev.Kind {
5803  	case 0:
5804  		if ev.CreatedAt <= appSt.profileTs {
5805  			return
5806  		}
5807  		appSt.profileTs = ev.CreatedAt
5808  		// P_CONTENT from Profile Worker will handle profile page re-render + IDB.
5809  		// Update own header directly here for immediate feedback.
5810  		name := helpers.JsonGetString(ev.Content, "name")
5811  		if len(name) == 0 {
5812  			name = helpers.JsonGetString(ev.Content, "display_name")
5813  		}
5814  		pic := helpers.JsonGetString(ev.Content, "picture")
5815  		if len(name) > 0 {
5816  			appSt.profileName = name
5817  			setAuthorName(appSt.pubhex, name)
5818  			dom.SetTextContent(appSt.nameEl, name)
5819  		}
5820  		if len(pic) > 0 {
5821  			appSt.profilePic = pic
5822  			setAuthorPic(appSt.pubhex, pic)
5823  			setMediaSrc(appSt.avatarEl, pic)
5824  			dom.SetStyle(appSt.avatarEl, "display", "block")
5825  		}
5826  	case 3:
5827  		if ev.CreatedAt <= appSt.contactTs {
5828  			return
5829  		}
5830  		appSt.contactTs = ev.CreatedAt
5831  		var pks []string
5832  		for _, tag := range nostr.TagsGetAll(ev.Tags, "p") {
5833  			if v := nostr.TagValue(tag); v != "" {
5834  				pks = mxutil.Ensure(pks, 1)
5835  				pks = push(pks, v)
5836  			}
5837  		}
5838  		prev := appSt.myFollows
5839  		appSt.myFollows = pks
5840  		appSt.followSet = buildSet(pks)
5841  		broadcastFollows(pks)
5842  		for _, pk := range pks {
5843  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
5844  		}
5845  		refreshProfileTab(appSt.pubhex)
5846  		if appSt.feedSelectEl != 0 {
5847  			populateFeedSelect()
5848  		}
5849  		if appSt.feedMode == "follows" {
5850  			if appSt.feedDeferTimer != 0 {
5851  				dom.ClearTimeout(appSt.feedDeferTimer)
5852  				appSt.feedDeferTimer = 0
5853  			}
5854  			if !appSt.feedSubscribed {
5855  				appSt.feedSubscribed = true
5856  				logActivity("boot", "kind 3 received, subscribing feed", itoa(len(pks)) | " follows")
5857  				subscribeFeed()
5858  				subscribeNotifications()
5859  				bootDone()
5860  			} else if len(pks) != len(prev) {
5861  				subscribeFeed()
5862  			}
5863  		}
5864  	case 10000:
5865  		if ev.CreatedAt <= appSt.muteTs {
5866  			return
5867  		}
5868  		appSt.muteTs = ev.CreatedAt
5869  		var pks []string
5870  		for _, tag := range nostr.TagsGetAll(ev.Tags, "p") {
5871  			if v := nostr.TagValue(tag); v != "" {
5872  				pks = mxutil.Ensure(pks, 1)
5873  				pks = push(pks, v)
5874  			}
5875  		}
5876  		appSt.myMutes = pks
5877  		appSt.muteSet = buildSet(pks)
5878  		broadcastMutes(pks)
5879  		refreshProfileTab(appSt.pubhex)
5880  	case 10002:
5881  		if ev.CreatedAt <= appSt.relayListTs {
5882  			return
5883  		}
5884  		appSt.relayListTs = ev.CreatedAt
5885  		// Profile Worker handles relay frequency tracking via P_EVENT.
5886  		for _, tag := range nostr.TagsGetAll(ev.Tags, "r") {
5887  			url := nostr.TagValue(tag)
5888  			if url != "" {
5889  				addRelay(url, true)
5890  			}
5891  		}
5892  		sendWriteRelays()
5893  		// Relay list changed - resubscribe to include new relays.
5894  		// subscribeFeed has its own 2s throttle, so direct call is fine.
5895  		if appSt.feedSubscribed {
5896  			subscribeFeed()
5897  			subscribeNotifications()
5898  		}
5899  	case 10050:
5900  		if ev.CreatedAt <= appSt.inboxTs {
5901  			return
5902  		}
5903  		appSt.inboxTs = ev.CreatedAt
5904  		// DM inbox relay list - stored for future use.
5905  		_ = nostr.TagsGetAll(ev.Tags, "relay")
5906  	case 30078:
5907  		dtag := nostr.TagsGetFirst(ev.Tags, "d")
5908  		if dtag == nil || nostr.TagValue(dtag) != "musiquay-settings" {
5909  			return
5910  		}
5911  		if ev.CreatedAt <= appSt.settingsTs {
5912  			return
5913  		}
5914  		appSt.settingsTs = ev.CreatedAt
5915  		applyAppSettings(ev.Content)
5916  	}
5917  }
5918  
5919  func applyAppSettings(content string) {
5920  	theme := helpers.JsonGetString(content, "theme")
5921  	if theme == "light" && appSt.isDark {
5922  		toggleTheme()
5923  	} else if theme == "dark" && !appSt.isDark {
5924  		toggleTheme()
5925  	}
5926  	blossom := helpers.JsonGetString(content, "blossom")
5927  	if blossom != "" {
5928  		appSt.blossomServerURL = blossom
5929  		localstorage.SetItem(lsKeyBlossomServer, blossom)
5930  	} else {
5931  		appSt.blossomServerURL = ""
5932  		localstorage.RemoveItem(lsKeyBlossomServer)
5933  	}
5934  	clientTag := helpers.JsonGetString(content, "clientTag")
5935  	if clientTag == "0" {
5936  		localstorage.SetItem("musiquay-client-tag", "0")
5937  	} else {
5938  		localstorage.RemoveItem("musiquay-client-tag")
5939  	}
5940  	lang := helpers.JsonGetString(content, "lang")
5941  	if lang != "" && lang != appSt.currentLang {
5942  		setLang(lang)
5943  	}
5944  	inv := helpers.JsonGetString(content, "invidious")
5945  	appSt.invidiousURL = inv
5946  	if inv != "" {
5947  		localstorage.SetItem(lsKeyInvidiousURL, inv)
5948  	} else {
5949  		localstorage.RemoveItem(lsKeyInvidiousURL)
5950  	}
5951  	camp := helpers.JsonGetString(content, "campion")
5952  	appSt.campionURL = camp
5953  	if camp != "" {
5954  		localstorage.SetItem(lsKeyCampionURL, camp)
5955  	} else {
5956  		localstorage.RemoveItem(lsKeyCampionURL)
5957  	}
5958  	// Notification read watermark - sync across devices.
5959  	nrts := helpers.JsonGetValue(content, "notifReadTs")
5960  	if nrts != "" {
5961  		ts := parseI64(nrts)
5962  		if ts > appSt.notifReadTs {
5963  			appSt.notifReadTs = ts
5964  			dom.IDBPut("settings", "notif-read-ts", i64toa(ts))
5965  		}
5966  	}
5967  	// Relay blocklist - merge in entries from the settings event.
5968  	blocklistRaw := helpers.JsonGetValue(content, "relayBlocklist")
5969  	if blocklistRaw != "" {
5970  		imported := parseJSONStringArray(blocklistRaw)
5971  		for _, u := range imported {
5972  			u = normalizeURL(u)
5973  			if u != "" && !isBlocked(u) {
5974  				appSt.relayBlocklist = mxutil.Ensure(appSt.relayBlocklist, 1)
5975  				appSt.relayBlocklist = push(appSt.relayBlocklist, u)
5976  			}
5977  		}
5978  		if len(imported) > 0 {
5979  			saveRelayPolicy()
5980  		}
5981  	}
5982  }
5983  
5984  func saveAppSettings() {
5985  	if appSt.pubhex == "" {
5986  		return
5987  	}
5988  	theme := "dark"
5989  	if !appSt.isDark {
5990  		theme = "light"
5991  	}
5992  	clientTag := "1"
5993  	if localstorage.GetItem("musiquay-client-tag") == "0" {
5994  		clientTag = "0"
5995  	}
5996  	blocklistJSON := "["
5997  	for i, u := range appSt.relayBlocklist {
5998  		if i > 0 {
5999  			blocklistJSON = blocklistJSON | ","
6000  		}
6001  		blocklistJSON = blocklistJSON | jstr(u)
6002  	}
6003  	blocklistJSON = blocklistJSON | "]"
6004  	content := "{" |
6005  		"\"theme\":" | jstr(theme) | "," |
6006  		"\"blossom\":" | jstr(appSt.blossomServerURL) | "," |
6007  		"\"clientTag\":" | jstr(clientTag) | "," |
6008  		"\"lang\":" | jstr(appSt.currentLang) | "," |
6009  		"\"relayBlocklist\":" | blocklistJSON | "," |
6010  		"\"invidious\":" | jstr(appSt.invidiousURL) | "," |
6011  		"\"campion\":" | jstr(appSt.campionURL) | "," |
6012  		"\"notifReadTs\":" | i64toa(appSt.notifReadTs) |
6013  		"}"
6014  	ts := dom.NowSeconds()
6015  	tags := "[[\"d\",\"musiquay-settings\"]]"
6016  	unsigned := "{\"kind\":30078,\"content\":" | jstr(content) |
6017  		",\"tags\":" | tags |
6018  		",\"created_at\":" | i64toa(ts) |
6019  		",\"pubkey\":\"" | appSt.pubhex | "\"}"
6020  	signer.SignEvent(unsigned, func(signed string) {
6021  		if signed == "" {
6022  			return
6023  		}
6024  		appSt.settingsTs = ts
6025  		routeMsg("[\"EVENT\"," | signed | "]")
6026  	})
6027  }
6028  
6029  func updateStatus() {
6030  	dom.SetTextContent(appSt.statusEl, t("relays"))
6031  	dom.SetTextContent(appSt.popoverSummary,
6032  		itoa(len(appSt.relayURLs)) | " relays | " | itoa(appSt.eventCount) | " events")
6033  }
6034  
6035  // --- Feed rendering ---
6036  
6037  func renderNote(ev *nostr.Event) {
6038  	if appSt.noteElements[ev.ID] != 0 || appSt.seenEvents[ev.ID] {
6039  		return
6040  	}
6041  
6042  	repostPK := ""
6043  	repostID := ""
6044  	repostTs := int64(0)
6045  	if ev.Kind == 6 {
6046  		inner := nostr.ParseEvent(ev.Content)
6047  		if inner == nil {
6048  			return
6049  		}
6050  		repostPK = ev.PubKey
6051  		repostID = ev.ID
6052  		repostTs = ev.CreatedAt
6053  		setEvent(inner.ID, inner)
6054  		ev = inner
6055  	}
6056  
6057  	note := dom.CreateElement("div")
6058  	if repostID != "" {
6059  		appSt.noteElements[repostID] = note
6060  	} else {
6061  		appSt.noteElements[ev.ID] = note
6062  	}
6063  	dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
6064  	dom.SetStyle(note, "padding", "12px 0")
6065  	sortTs := ev.CreatedAt
6066  	if repostTs > 0 {
6067  		sortTs = repostTs
6068  	}
6069  	dom.SetProperty(note, "musiquayTs", i64toa(sortTs))
6070  
6071  	if repostPK != "" {
6072  		rpBanner := dom.CreateElement("div")
6073  		dom.SetStyle(rpBanner, "display", "flex")
6074  		dom.SetStyle(rpBanner, "alignItems", "center")
6075  		dom.SetStyle(rpBanner, "gap", "6px")
6076  		dom.SetStyle(rpBanner, "fontSize", "12px")
6077  		dom.SetStyle(rpBanner, "color", "var(--muted)")
6078  		dom.SetStyle(rpBanner, "marginBottom", "6px")
6079  		dom.SetStyle(rpBanner, "cursor", "pointer")
6080  		dom.SetInnerHTML(rpBanner, svgRepost)
6081  		rpName := dom.CreateElement("span")
6082  		if name, ok := appSt.authorNames[repostPK]; ok && name != "" {
6083  			dom.SetTextContent(rpName, name | " " | t("reposted"))
6084  		} else {
6085  			npub := helpers.EncodeNpub(helpers.HexDecode(repostPK))
6086  			short := npub
6087  			if len(npub) > 20 {
6088  				short = npub[:12] | "..." | npub[len(npub)-4:]
6089  			}
6090  			dom.SetTextContent(rpName, short | " " | t("reposted"))
6091  		}
6092  		dom.AppendChild(rpBanner, rpName)
6093  		bannerPK := repostPK
6094  		dom.AddEventListener(rpBanner, "click", dom.RegisterCallback(func() {
6095  			showProfile(bannerPK)
6096  		}))
6097  		dom.AppendChild(note, rpBanner)
6098  	}
6099  
6100  	// Header row: author link (left) + timestamp (right).
6101  	header := dom.CreateElement("div")
6102  	dom.SetStyle(header, "display", "flex")
6103  	dom.SetStyle(header, "alignItems", "center")
6104  	dom.SetStyle(header, "marginBottom", "4px")
6105  	dom.SetStyle(header, "maxWidth", "65ch")
6106  
6107  	// Author link - only covers avatar + name.
6108  	authorLink := dom.CreateElement("div")
6109  	dom.SetStyle(authorLink, "display", "flex")
6110  	dom.SetStyle(authorLink, "alignItems", "center")
6111  	dom.SetStyle(authorLink, "gap", "8px")
6112  	dom.SetStyle(authorLink, "cursor", "pointer")
6113  	headerPK := ev.PubKey
6114  	dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
6115  		showProfile(headerPK)
6116  	}))
6117  
6118  	avatar := dom.CreateElement("img")
6119  	dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
6120  	dom.SetAttribute(avatar, "width", "24")
6121  	dom.SetAttribute(avatar, "height", "24")
6122  	dom.SetStyle(avatar, "borderRadius", "50%")
6123  	dom.SetStyle(avatar, "objectFit", "cover")
6124  	dom.SetStyle(avatar, "flexShrink", "0")
6125  
6126  	nameSpan := dom.CreateElement("span")
6127  	dom.SetStyle(nameSpan, "fontSize", "18px")
6128  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6129  	dom.SetStyle(nameSpan, "fontWeight", "bold")
6130  	dom.SetStyle(nameSpan, "color", "var(--fg)")
6131  
6132  	pk := ev.PubKey
6133  	if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
6134  		setMediaSrc(avatar, pic)
6135  		dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
6136  	} else {
6137  		dom.SetStyle(avatar, "display", "none")
6138  	}
6139  	if name, ok := appSt.authorNames[pk]; ok && name != "" {
6140  		dom.SetTextContent(nameSpan, name)
6141  	} else {
6142  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
6143  		if len(npub) > 20 {
6144  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
6145  		}
6146  	}
6147  
6148  	dom.AppendChild(authorLink, avatar)
6149  	dom.AppendChild(authorLink, nameSpan)
6150  	dom.AppendChild(header, authorLink)
6151  
6152  	appendClientTag(header, ev)
6153  
6154  	// Right side: delete + relay widget + timestamp - right-aligned.
6155  	rightGroup := dom.CreateElement("div")
6156  	dom.SetStyle(rightGroup, "display", "flex")
6157  	dom.SetStyle(rightGroup, "alignItems", "center")
6158  	dom.SetStyle(rightGroup, "gap", "4px")
6159  	dom.SetStyle(rightGroup, "marginLeft", "auto")
6160  
6161  	// Delete button - own notes only.
6162  	if pk == appSt.pubhex {
6163  		delBtn := dom.CreateElement("span")
6164  		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>`)
6165  		dom.SetStyle(delBtn, "cursor", "pointer")
6166  		dom.SetStyle(delBtn, "color", "var(--muted)")
6167  		dom.SetStyle(delBtn, "opacity", "0.5")
6168  		dom.SetStyle(delBtn, "display", "flex")
6169  		dom.SetStyle(delBtn, "alignItems", "center")
6170  		delEvID := ev.ID
6171  		dom.AddEventListener(delBtn, "click", dom.RegisterCallback(func() {
6172  			publishDelete(delEvID)
6173  		}))
6174  		dom.AppendChild(rightGroup, delBtn)
6175  	}
6176  
6177  	rw := relayWidget(ev.ID)
6178  	dom.AppendChild(rightGroup, rw)
6179  	if ev.CreatedAt > 0 {
6180  		tsEl := dom.CreateElement("span")
6181  		dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
6182  		dom.SetStyle(tsEl, "fontSize", "11px")
6183  		dom.SetStyle(tsEl, "color", "var(--muted)")
6184  		dom.SetStyle(tsEl, "cursor", "pointer")
6185  		dom.SetStyle(tsEl, "flexShrink", "0")
6186  		evID := ev.ID
6187  		evRootID := getRootID(ev)
6188  		if evRootID == "" {
6189  			evRootID = evID
6190  		}
6191  		dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
6192  			showNoteThread(evRootID, evID)
6193  		}))
6194  		dom.AppendChild(rightGroup, tsEl)
6195  	}
6196  
6197  	dom.AppendChild(header, rightGroup)
6198  	dom.AppendChild(note, header)
6199  
6200  	// Track author link for update when profile arrives.
6201  	if _, cached := appSt.authorNames[pk]; !cached {
6202  		appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
6203  		appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
6204  		appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], authorLink)
6205  		if !lookupFetchedK0(pk) {
6206  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
6207  		}
6208  	}
6209  
6210  	// Reply preview button.
6211  	addReplyPreview(note, ev)
6212  
6213  	// Content.
6214  	content := dom.CreateElement("div")
6215  	dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6216  	dom.SetStyle(content, "fontSize", "14px")
6217  	dom.SetStyle(content, "lineHeight", "1.5")
6218  	dom.SetStyle(content, "wordBreak", "break-word")
6219  	dom.SetStyle(content, "maxWidth", "65ch")
6220  	dom.SetStyle(content, "overflow", "hidden")
6221  	dom.SetStyle(content, "maxHeight", "18em")
6222  	setHTMLWithMedia(content, renderMarkdown(ev.Content))
6223  	dom.AppendChild(note, content)
6224  
6225  	more := makeShowMore(content)
6226  	dom.AppendChild(note, buildActionRow(ev, note, content, more))
6227  
6228  	// Insert in timestamp order (newest first).
6229  	insertNoteByTs(note, sortTs)
6230  	attachShowMore(content, more)
6231  	resolveEmbeds()
6232  }
6233  
6234  func insertNoteByTs(note dom.Element, ts int64) {
6235  	// Walk children to find insertion point - notes are newest-first.
6236  	child := dom.FirstElementChild(appSt.feedContainer)
6237  	for child != 0 {
6238  		childTs := parseI64(dom.GetProperty(child, "musiquayTs"))
6239  		if ts > childTs {
6240  			dom.InsertBefore(appSt.feedContainer, note, child)
6241  			return
6242  		}
6243  		child = dom.NextSibling(child)
6244  	}
6245  	dom.AppendChild(appSt.feedContainer, note)
6246  }
6247  
6248  func parseI64(s string) (n int64) {
6249  	for i := 0; i < len(s); i++ {
6250  		if s[i] >= '0' && s[i] <= '9' {
6251  			n = n*10 + int64(s[i]-'0')
6252  		} else {
6253  			break
6254  		}
6255  	}
6256  	return n
6257  }
6258  
6259  func appendNote(ev *nostr.Event) {
6260  	if appSt.noteElements[ev.ID] != 0 {
6261  		return
6262  	}
6263  	note, postInsert := buildNoteElement(ev)
6264  	dom.AppendChild(appSt.feedContainer, note)
6265  	postInsert()
6266  	resolveEmbeds()
6267  }
6268  
6269  func buildNoteElement(ev *nostr.Event) (v dom.Element, fn func()) {
6270  	note := dom.CreateElement("div")
6271  	appSt.noteElements[ev.ID] = note
6272  	dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
6273  	dom.SetStyle(note, "padding", "12px 0")
6274  	dom.SetProperty(note, "musiquayTs", i64toa(ev.CreatedAt))
6275  	dom.SetProperty(note, "musiquayPK", ev.PubKey)
6276  
6277  	header := dom.CreateElement("div")
6278  	dom.SetStyle(header, "display", "flex")
6279  	dom.SetStyle(header, "alignItems", "center")
6280  	dom.SetStyle(header, "marginBottom", "4px")
6281  	dom.SetStyle(header, "maxWidth", "65ch")
6282  
6283  	authorLink := dom.CreateElement("div")
6284  	dom.SetStyle(authorLink, "display", "flex")
6285  	dom.SetStyle(authorLink, "alignItems", "center")
6286  	dom.SetStyle(authorLink, "gap", "8px")
6287  	dom.SetStyle(authorLink, "cursor", "pointer")
6288  	headerPK := ev.PubKey
6289  	dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
6290  		showProfile(headerPK)
6291  	}))
6292  
6293  	avatar := dom.CreateElement("img")
6294  	dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
6295  	dom.SetAttribute(avatar, "width", "24")
6296  	dom.SetAttribute(avatar, "height", "24")
6297  	dom.SetStyle(avatar, "borderRadius", "50%")
6298  	dom.SetStyle(avatar, "objectFit", "cover")
6299  	dom.SetStyle(avatar, "flexShrink", "0")
6300  
6301  	nameSpan := dom.CreateElement("span")
6302  	dom.SetStyle(nameSpan, "fontSize", "18px")
6303  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6304  	dom.SetStyle(nameSpan, "fontWeight", "bold")
6305  	dom.SetStyle(nameSpan, "color", "var(--fg)")
6306  
6307  	pk := ev.PubKey
6308  	if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
6309  		setMediaSrc(avatar, pic)
6310  		dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
6311  	} else {
6312  		dom.SetStyle(avatar, "display", "none")
6313  	}
6314  	if name, ok := appSt.authorNames[pk]; ok && name != "" {
6315  		dom.SetTextContent(nameSpan, name)
6316  	} else {
6317  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
6318  		if len(npub) > 20 {
6319  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
6320  		}
6321  	}
6322  
6323  	dom.AppendChild(authorLink, avatar)
6324  	dom.AppendChild(authorLink, nameSpan)
6325  	attachHoverCard(authorLink, pk)
6326  	dom.AppendChild(header, authorLink)
6327  
6328  	appendClientTag(header, ev)
6329  
6330  	// JSON toggle: contentEl and moreEl are set below after those elements are
6331  	// created. The click handler fires long after this function returns, so the
6332  	// closure sees the final values.
6333  	var contentEl dom.Element
6334  	var moreEl dom.Element
6335  	jsonActive := false
6336  	var jsonEl dom.Element
6337  
6338  	// Copy button - to the left of </>; marginLeft:auto pushes both to the right.
6339  	cpBtn := noteCopyBtn()
6340  	capturedCpEv := ev
6341  	dom.AddEventListener(cpBtn, "click", dom.RegisterCallback(func() {
6342  		capturedCpBtn := cpBtn
6343  		text := noteClipText(capturedCpEv)
6344  		dom.WritePrimarySelection(text)
6345  		dom.WriteClipboard(text, func(ok bool) {
6346  			if ok {
6347  				noteCopyFeedback(capturedCpBtn)
6348  			}
6349  		})
6350  	}))
6351  	dom.SetStyle(cpBtn, "marginLeft", "auto")
6352  	dom.AppendChild(header, cpBtn)
6353  
6354  	jsonBtn := dom.CreateElement("span")
6355  	dom.SetTextContent(jsonBtn, "</>")
6356  	dom.SetStyle(jsonBtn, "fontSize", "10px")
6357  	dom.SetStyle(jsonBtn, "fontFamily", "'Fira Code', monospace")
6358  	dom.SetStyle(jsonBtn, "cursor", "pointer")
6359  	dom.SetStyle(jsonBtn, "opacity", "0.6")
6360  	dom.SetStyle(jsonBtn, "marginRight", "8px")
6361  	dom.SetStyle(jsonBtn, "userSelect", "none")
6362  	dom.SetStyle(jsonBtn, "color", "var(--muted)")
6363  	capturedJBtn := jsonBtn
6364  	capturedEv := ev
6365  	capturedNote := note
6366  	dom.AddEventListener(jsonBtn, "click", dom.RegisterCallback(func() {
6367  		if jsonActive {
6368  			dom.SetStyle(contentEl, "display", "")
6369  			dom.SetStyle(moreEl, "display", "none")
6370  			dom.SetStyle(jsonEl, "display", "none")
6371  			dom.SetStyle(capturedJBtn, "opacity", "0.6")
6372  			dom.SetStyle(capturedJBtn, "color", "var(--muted)")
6373  			jsonActive = false
6374  		} else {
6375  			if jsonEl == 0 {
6376  				jsonEl = dom.CreateElement("pre")
6377  				dom.SetStyle(jsonEl, "fontFamily", "'Fira Code', monospace")
6378  				dom.SetStyle(jsonEl, "fontSize", "12px")
6379  				dom.SetStyle(jsonEl, "lineHeight", "1.5")
6380  				dom.SetStyle(jsonEl, "wordBreak", "break-word")
6381  				dom.SetStyle(jsonEl, "whiteSpace", "pre-wrap")
6382  				dom.SetStyle(jsonEl, "maxWidth", "65ch")
6383  				dom.SetStyle(jsonEl, "margin", "0")
6384  				dom.SetStyle(jsonEl, "color", "var(--fg)")
6385  				dom.SetTextContent(jsonEl, prettyNoteJSON(capturedEv))
6386  				dom.InsertBefore(capturedNote, jsonEl, moreEl)
6387  			}
6388  			dom.SetStyle(contentEl, "display", "none")
6389  			dom.SetStyle(moreEl, "display", "none")
6390  			dom.SetStyle(jsonEl, "display", "block")
6391  			dom.SetStyle(capturedJBtn, "opacity", "1")
6392  			dom.SetStyle(capturedJBtn, "color", "var(--accent)")
6393  			jsonActive = true
6394  		}
6395  	}))
6396  	dom.AppendChild(header, jsonBtn)
6397  
6398  	rw2 := relayWidget(ev.ID)
6399  	dom.AppendChild(header, rw2)
6400  	if ev.CreatedAt > 0 {
6401  		tsEl := dom.CreateElement("span")
6402  		dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
6403  		dom.SetStyle(tsEl, "fontSize", "11px")
6404  		dom.SetStyle(tsEl, "color", "var(--muted)")
6405  		dom.SetStyle(tsEl, "cursor", "pointer")
6406  		dom.SetStyle(tsEl, "flexShrink", "0")
6407  		evID := ev.ID
6408  		evRootID := getRootID(ev)
6409  		if evRootID == "" {
6410  			evRootID = evID
6411  		}
6412  		dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
6413  			showNoteThread(evRootID, evID)
6414  		}))
6415  		dom.AppendChild(header, tsEl)
6416  	}
6417  
6418  	dom.AppendChild(note, header)
6419  
6420  	if _, cached := appSt.authorNames[pk]; !cached {
6421  		appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
6422  		appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
6423  		appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], authorLink)
6424  		if !lookupFetchedK0(pk) {
6425  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
6426  		}
6427  	}
6428  
6429  	addReplyPreview(note, ev)
6430  
6431  	content := dom.CreateElement("div")
6432  	dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6433  	dom.SetStyle(content, "fontSize", "14px")
6434  	dom.SetStyle(content, "lineHeight", "1.5")
6435  	dom.SetStyle(content, "wordBreak", "break-word")
6436  	dom.SetStyle(content, "maxWidth", "65ch")
6437  	dom.SetStyle(content, "overflow", "hidden")
6438  	dom.SetStyle(content, "maxHeight", "18em")
6439  	setHTMLWithMedia(content, renderMarkdown(ev.Content))
6440  	dom.AppendChild(note, content)
6441  	contentEl = content
6442  
6443  	more := makeShowMore(content)
6444  	moreEl = more
6445  	dom.AppendChild(note, buildActionRow(ev, note, content, more))
6446  
6447  	return note, func() { attachShowMore(content, more) }
6448  }
6449  
6450  
6451  // topRelays returns the n most frequently seen relay URLs from kind 10002 events.
6452  // topRelays moved to Profile Worker. Shell no longer has relay frequency data.
6453  // Returns nil so callers fall through to relayURLs + discoveryRelays only.
6454  func topRelays(_ int32) (ss []string) { return nil }
6455  
6456  // recordRelayFreq moved to Profile Worker. Shell no longer tracks relay frequency.
6457  
6458  // discoveryRelays are well-known relays that aggregate profile metadata.
6459  // Prioritized first in _proxy lists since they have the highest hit rate.
6460  var discoveryRelays []string
6461  
6462  // buildProxy builds a _proxy relay list for a pubkey.
6463  // Discovery relays first, then author-specific relays if known.
6464  // buildProxy builds a relay list for querying a specific author.
6465  // authorRelays and relayFreq now live in Profile Worker; Shell uses
6466  // profileRelaysCache for the relay hints it has received via P_RELAYS.
6467  func buildProxy(pk string) (ss []string) {
6468  	out := []string{:len(discoveryRelays)}
6469  	copy(out, discoveryRelays)
6470  	for _, u := range readRelayURLs() {
6471  		out = appendUnique(out, u)
6472  	}
6473  	if rels, ok2 := appSt.profileRelaysCache[pk]; ok2 {
6474  		for _, r := range rels {
6475  			out = appendUnique(out, r)
6476  		}
6477  	}
6478  	return out
6479  }
6480  
6481  func appendUnique(list []string, val string) (ss []string) {
6482  	if isBlocked(val) {
6483  		return list
6484  	}
6485  	for _, v := range list {
6486  		if v == val {
6487  			return list
6488  		}
6489  	}
6490  	// push does not grow: a caller's slice that came back at len == cap (a
6491  	// returned view, or a presized list) would panic here.
6492  	list = mxutil.Ensure(list, 1)
6493  	return push(list, val)
6494  }
6495  
6496  // --- Thread view & reply preview ---
6497  
6498  // getReplyID returns the event ID this note is replying to, or "".
6499  // Checks for NIP-10 "reply" marker first, falls back to positional (last e-tag).
6500  func getReplyID(ev *nostr.Event) (s string) {
6501  	id, _ := getReplyIDHint(ev)
6502  	return id
6503  }
6504  
6505  func getReplyIDHint(ev *nostr.Event) (s2 string, s3 string) {
6506  	var etags [][]string
6507  	for _, tw1 := range ev.Tags {
6508  		if len(tw1) >= 2 && tw1[0] == "e" {
6509  			etags = mxutil.Ensure(etags, 1)
6510  			etags = push(etags, tw1)
6511  		}
6512  	}
6513  	if len(etags) == 0 {
6514  		return "", ""
6515  	}
6516  	// Marker-based: look for "reply".
6517  	for _, tv1 := range etags {
6518  		if len(tv1) >= 4 && tv1[3] == "reply" {
6519  			hintw3 := ""
6520  			if len(tv1) >= 3 && len(tv1[2]) > 0 {
6521  				hintw3 = tv1[2]
6522  			}
6523  			return tv1[1], hintw3
6524  		}
6525  	}
6526  	// Positional fallback: last e-tag is the reply target (if >1 e-tags).
6527  	if len(etags) > 1 {
6528  		last := etags[len(etags)-1]
6529  		hintw2 := ""
6530  		if len(last) >= 3 && len(last[2]) > 0 {
6531  			hintw2 = last[2]
6532  		}
6533  		return last[1], hintw2
6534  	}
6535  	// Single e-tag with no marker: it's both root and reply.
6536  	hintw1 := ""
6537  	if len(etags[0]) >= 3 && len(etags[0][2]) > 0 {
6538  		hintw1 = etags[0][2]
6539  	}
6540  	return etags[0][1], hintw1
6541  }
6542  
6543  // getRootID returns the thread root event ID, or "".
6544  func getRootID(ev *nostr.Event) (s string) {
6545  	for _, tw2 := range ev.Tags {
6546  		if len(tw2) >= 4 && tw2[0] == "e" && tw2[3] == "root" {
6547  			return tw2[1]
6548  		}
6549  	}
6550  	// Positional: first e-tag.
6551  	for _, tv2 := range ev.Tags {
6552  		if len(tv2) >= 2 && tv2[0] == "e" {
6553  			return tv2[1]
6554  		}
6555  	}
6556  	return ""
6557  }
6558  
6559  // referencesEvent returns true if ev has any e-tag pointing to the given ID.
6560  func referencesEvent(ev *nostr.Event, id string) (ok bool) {
6561  	for _, tw3 := range ev.Tags {
6562  		if len(tw3) >= 2 && tw3[0] == "e" && tw3[1] == id {
6563  			return true
6564  		}
6565  	}
6566  	return false
6567  }
6568  
6569  func firstLine(s string) (sv string) {
6570  	for i := 0; i < len(s); i++ {
6571  		if s[i] == '\n' {
6572  			if i > 80 {
6573  				return s[:80] | "..."
6574  			}
6575  			return s[:i]
6576  		}
6577  	}
6578  	if len(s) > 80 {
6579  		return s[:80] | "..."
6580  	}
6581  	return s
6582  }
6583  
6584  // appendClientTag adds "via <client>" to a note header if the event has a
6585  // "client" tag. Linkifies http(s) URLs and strips the scheme for display.
6586  func appendClientTag(header dom.Element, ev *nostr.Event) {
6587  	clientTag := nostr.TagsGetFirst(ev.Tags, "client")
6588  	if clientTag == nil {
6589  		return
6590  	}
6591  	val := nostr.TagValue(clientTag)
6592  	if val == "" {
6593  		return
6594  	}
6595  
6596  	wrap := dom.CreateElement("span")
6597  	dom.SetStyle(wrap, "fontStyle", "italic")
6598  	dom.SetStyle(wrap, "fontSize", "11px")
6599  	dom.SetStyle(wrap, "color", "var(--muted)")
6600  	dom.SetStyle(wrap, "marginLeft", "6px")
6601  	dom.SetStyle(wrap, "flexShrink", "0")
6602  
6603  	via := dom.CreateElement("span")
6604  	dom.SetTextContent(via, "via ")
6605  	dom.AppendChild(wrap, via)
6606  
6607  	display := val
6608  	isURL := false
6609  	if len(val) >= 8 && val[:8] == "https://" {
6610  		isURL = true
6611  		display = val[8:]
6612  	} else if len(val) >= 7 && val[:7] == "http://" {
6613  		isURL = true
6614  		display = val[7:]
6615  	}
6616  
6617  	if isURL {
6618  		link := dom.CreateElement("a")
6619  		dom.SetAttribute(link, "href", val)
6620  		dom.SetAttribute(link, "target", "_blank")
6621  		dom.SetAttribute(link, "rel", "noopener noreferrer")
6622  		dom.SetTextContent(link, display)
6623  		dom.SetStyle(link, "color", "var(--muted)")
6624  		dom.SetStyle(link, "textDecoration", "underline")
6625  		dom.AppendChild(wrap, link)
6626  	} else {
6627  		text := dom.CreateElement("span")
6628  		dom.SetTextContent(text, display)
6629  		dom.AppendChild(wrap, text)
6630  	}
6631  
6632  	dom.AppendChild(header, wrap)
6633  }
6634  
6635  func addReplyPreview(note dom.Element, ev *nostr.Event) {
6636  	parentID, hint := getReplyIDHint(ev)
6637  	if parentID == "" {
6638  		return
6639  	}
6640  	if hint != "" && appSt.replyHints != nil {
6641  		appSt.replyHints[parentID] = hint
6642  	}
6643  
6644  	preview := dom.CreateElement("div")
6645  	dom.SetStyle(preview, "display", "flex")
6646  	dom.SetStyle(preview, "alignItems", "center")
6647  	dom.SetStyle(preview, "gap", "4px")
6648  	dom.SetStyle(preview, "fontSize", "14px")
6649  	dom.SetStyle(preview, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
6650  	dom.SetStyle(preview, "lineHeight", "1.5")
6651  	dom.SetStyle(preview, "color", "var(--muted)")
6652  	dom.SetStyle(preview, "borderLeft", "2px solid var(--accent)")
6653  	dom.SetStyle(preview, "paddingLeft", "8px")
6654  	dom.SetStyle(preview, "marginBottom", "4px")
6655  	dom.SetStyle(preview, "cursor", "pointer")
6656  	dom.SetStyle(preview, "overflow", "hidden")
6657  	dom.SetStyle(preview, "maxWidth", "65ch")
6658  
6659  	prevAvatar := dom.CreateElement("img")
6660  	dom.SetAttribute(prevAvatar, "referrerpolicy", "no-referrer")
6661  	dom.SetAttribute(prevAvatar, "width", "14")
6662  	dom.SetAttribute(prevAvatar, "height", "14")
6663  	dom.SetStyle(prevAvatar, "borderRadius", "50%")
6664  	dom.SetStyle(prevAvatar, "objectFit", "cover")
6665  	dom.SetStyle(prevAvatar, "flexShrink", "0")
6666  	dom.SetStyle(prevAvatar, "display", "none")
6667  	dom.AppendChild(preview, prevAvatar)
6668  
6669  	prevText := dom.CreateElement("span")
6670  	dom.SetStyle(prevText, "overflow", "hidden")
6671  	dom.SetStyle(prevText, "whiteSpace", "nowrap")
6672  	dom.SetStyle(prevText, "textOverflow", "ellipsis")
6673  	dom.AppendChild(preview, prevText)
6674  
6675  	text, cached := appSt.replyCache[parentID]
6676  	if !cached {
6677  		text, cached = appSt.replyCacheOld[parentID]
6678  	}
6679  	if cached {
6680  		dom.SetTextContent(prevText, text)
6681  		pic, _ := appSt.replyAvatarCache[parentID]
6682  		if pic == "" {
6683  			pic, _ = appSt.replyAvatarCacheOld[parentID]
6684  		}
6685  		if pic != "" {
6686  			setMediaSrc(prevAvatar, pic)
6687  			dom.SetAttribute(prevAvatar, "onerror", "this.style.display='none'")
6688  			dom.SetStyle(prevAvatar, "display", "block")
6689  		}
6690  	} else {
6691  		dom.SetTextContent(prevText, t("replying_to"))
6692  		appSt.replyPending[parentID] = mxutil.Ensure(appSt.replyPending[parentID], 1)
6693  		appSt.replyPending[parentID] = mxutil.Ensure(appSt.replyPending[parentID], 1)
6694  		appSt.replyPending[parentID] = push(appSt.replyPending[parentID], preview)
6695  		queueReplyFetch(parentID)
6696  	}
6697  
6698  	rootID := getRootID(ev)
6699  	if rootID == "" {
6700  		rootID = parentID
6701  	}
6702  	focusID := parentID
6703  	dom.AddEventListener(preview, "click", dom.RegisterCallback(func() {
6704  		showNoteThread(rootID, focusID)
6705  	}))
6706  
6707  	dom.AppendChild(note, preview)
6708  }
6709  
6710  func queueReplyFetch(id string) {
6711  	appSt.replyQueue = mxutil.Ensure(appSt.replyQueue, 1)
6712  	appSt.replyQueue = push(appSt.replyQueue, id)
6713  	if appSt.replyTimer != 0 {
6714  		dom.ClearTimeout(appSt.replyTimer)
6715  	}
6716  	appSt.replyTimer = dom.SetTimeout(func() {
6717  		appSt.replyTimer = 0
6718  		flushReplyQueue()
6719  	}, 300)
6720  }
6721  
6722  func flushReplyQueue() {
6723  	if len(appSt.replyQueue) == 0 {
6724  		return
6725  	}
6726  	filter := "{\"ids\":["
6727  	var hints []string
6728  	for i, id := range appSt.replyQueue {
6729  		if i > 0 {
6730  			filter = filter | ","
6731  		}
6732  		filter = filter | jstr(id)
6733  		hv := appSt.replyHints[id]
6734  		if hv == "" {
6735  			hv = appSt.replyHintsOld[id]
6736  			if hv != "" {
6737  				hints = mxutil.Ensure(hints, 1)
6738  				hints = push(hints, hv)
6739  				delete(appSt.replyHintsOld, id)
6740  			}
6741  		} else {
6742  			hints = mxutil.Ensure(hints, 1)
6743  			hints = push(hints, hv)
6744  			delete(appSt.replyHints, id)
6745  		}
6746  	}
6747  	filter = filter | "]}"
6748  	appSt.replyQueue = nil
6749  
6750  	// Local REQ checks IDB cache first.
6751  	appSt.threadSubCount++
6752  	reqID := "rp-" | itoa(appSt.threadSubCount)
6753  	routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]")
6754  
6755  	// PROXY fetches from remote relays - use hints + discovery + feed + top relays.
6756  	var urls []string
6757  	seen := map[string]bool{}
6758  	for _, hw1 := range hints {
6759  		if !seen[hw1] {
6760  			seen[hw1] = true
6761  			urls = mxutil.Ensure(urls, 1)
6762  			urls = push(urls, hw1)
6763  		}
6764  	}
6765  	for _, u := range discoveryRelays {
6766  		if !seen[u] {
6767  			seen[u] = true
6768  			urls = mxutil.Ensure(urls, 1)
6769  			urls = push(urls, u)
6770  		}
6771  	}
6772  	for _, u := range appSt.relayURLs {
6773  		if !seen[u] {
6774  			seen[u] = true
6775  			urls = mxutil.Ensure(urls, 1)
6776  			urls = push(urls, u)
6777  		}
6778  	}
6779  	for _, u := range topRelays(8) {
6780  		if !seen[u] {
6781  			seen[u] = true
6782  			urls = mxutil.Ensure(urls, 1)
6783  			urls = push(urls, u)
6784  		}
6785  	}
6786  	appSt.threadSubCount++
6787  	proxyID := "rp-" | itoa(appSt.threadSubCount)
6788  	routeMsg(buildProxyMsg(proxyID, filter, urls))
6789  
6790  	// Reset sweep timer: 20s after last batch sent, mark remaining as not found.
6791  	if appSt.replySweepTimer != 0 {
6792  		dom.ClearTimeout(appSt.replySweepTimer)
6793  	}
6794  	appSt.replySweepTimer = dom.SetTimeout(func() {
6795  		appSt.replySweepTimer = 0
6796  		sweepUnfoundReplies()
6797  	}, 20000)
6798  }
6799  
6800  func handleReplyPreviewEvent(ev *nostr.Event) {
6801  	line := firstLine(ev.Content)
6802  	appSt.replyLineCache[ev.ID] = line
6803  	appSt.replyAuthorMap[ev.ID] = ev.PubKey
6804  
6805  	name := ev.PubKey[:8] | "..."
6806  	nameResolved := false
6807  	if n, ok := appSt.authorNames[ev.PubKey]; ok && n != "" {
6808  		name = n
6809  		nameResolved = true
6810  	}
6811  	text := name | ": " | line
6812  	appSt.replyCache[ev.ID] = text
6813  	appSt.replyCount++
6814  	if appSt.replyCount > rotateReplyMax {
6815  		rotateReplyCaches()
6816  	}
6817  
6818  	pic := ""
6819  	if p, ok := appSt.authorPics[ev.PubKey]; ok && p != "" {
6820  		pic = p
6821  	}
6822  	appSt.replyAvatarCache[ev.ID] = pic
6823  
6824  	// Trigger profile fetch if not cached.
6825  	if !nameResolved {
6826  		if !lookupFetchedK0(ev.PubKey) {
6827  			profile.Send(`["P_RESOLVE",` | jstr(ev.PubKey) | `]`)
6828  		}
6829  	}
6830  
6831  	if divs, ok2 := appSt.replyPending[ev.ID]; ok2 {
6832  		for _, d := range divs {
6833  			fillReplyPreviewDiv(d, text, pic)
6834  		}
6835  		// Track for late name update if unresolved.
6836  		if !nameResolved {
6837  			appSt.replyNeedName[ev.ID] = appSt.replyNeedName[ev.ID] | divs
6838  		}
6839  		delete(appSt.replyPending, ev.ID)
6840  	}
6841  }
6842  
6843  func fillReplyPreviewDiv(d dom.Element, text, pic string) {
6844  	img := dom.FirstChild(d)
6845  	if img == 0 {
6846  		return
6847  	}
6848  	span := dom.NextSibling(img)
6849  	if span != 0 {
6850  		dom.SetTextContent(span, text)
6851  	}
6852  	if pic != "" {
6853  		setMediaSrc(img, pic)
6854  		dom.SetAttribute(img, "onerror", "this.style.display='none'")
6855  		dom.SetStyle(img, "display", "block")
6856  	}
6857  }
6858  
6859  func sweepUnfoundReplies() {
6860  	if len(appSt.replyPending) == 0 {
6861  		return
6862  	}
6863  	notFound := t("reply_not_found")
6864  	for id, divs := range appSt.replyPending {
6865  		for _, d := range divs {
6866  			span := dom.NextSibling(dom.FirstChild(d))
6867  			if span != 0 {
6868  				dom.SetTextContent(span, notFound)
6869  			}
6870  		}
6871  		delete(appSt.replyPending, id)
6872  	}
6873  }
6874  
6875  // updateReplyPreviewsForAuthor is called when a kind 0 profile arrives.
6876  // It updates any reply preview divs that were rendered with a hex pubkey stub.
6877  func updateReplyPreviewsForAuthor(pk string) {
6878  	name, _ := appSt.authorNames[pk]
6879  	pic, _ := appSt.authorPics[pk]
6880  	if name == "" {
6881  		return
6882  	}
6883  
6884  	for eid, apk := range appSt.replyAuthorMap {
6885  		if apk != pk {
6886  			continue
6887  		}
6888  		line := appSt.replyLineCache[eid]
6889  		text := name | ": " | line
6890  		appSt.replyCache[eid] = text
6891  		appSt.replyAvatarCache[eid] = pic
6892  
6893  		if divs, ok2 := appSt.replyNeedName[eid]; ok2 {
6894  			for _, d := range divs {
6895  				fillReplyPreviewDiv(d, text, pic)
6896  			}
6897  			delete(appSt.replyNeedName, eid)
6898  		}
6899  	}
6900  }
6901  
6902  func showNoteThread(rootID, focusID string) {
6903  	// Close old thread subs immediately to prevent stale events leaking in.
6904  	for _, sid := range appSt.threadActiveSubs {
6905  		routeMsg("[\"CLOSE\"," | jstr(sid) | "]")
6906  	}
6907  	appSt.threadActiveSubs = nil
6908  	appSt.threadGen++
6909  
6910  	appSt.threadRootID = rootID
6911  	appSt.threadFocusID = focusID
6912  	appSt.threadEvents = map[string]*nostr.Event{}
6913  	appSt.threadLastRendered = 0
6914  	appSt.threadOpen = true
6915  	appSt.threadFollowUp = 0
6916  	appSt.threadFetchedIDs = map[string]bool{rootID: true}
6917  	appSt.threadReturnPage = appSt.activePage
6918  
6919  	// Save scroll position.
6920  	appSt.savedScrollTop = dom.GetProperty(appSt.contentArea, "scrollTop")
6921  
6922  	// Deactivate search bar so top bar returns to normal while in thread.
6923  	if appSt.searchBarActive {
6924  		deactivateSearchBar(appSt.searchBtnGlobal)
6925  	}
6926  	// Stop any inline media (Invidious iframes) playing in the feed.
6927  	stopInlineMedia(appSt.feedContainer)
6928  
6929  	// Ensure feedPage is visible - threadPage lives inside it.
6930  	if appSt.activePage != "feed" {
6931  		dom.SetStyle(appSt.profilePage, "display", "none")
6932  		dom.SetStyle(appSt.msgPage, "display", "none")
6933  		dom.SetStyle(appSt.settingsPage, "display", "none")
6934  		dom.SetStyle(appSt.notifPage, "display", "none")
6935  		dom.SetStyle(appSt.aboutPage, "display", "none")
6936  		dom.SetStyle(appSt.composePage, "display", "none")
6937  		dom.SetStyle(appSt.searchPage, "display", "none")
6938  		dom.SetStyle(appSt.feedPage, "display", "block")
6939  	}
6940  
6941  	// Defocus all sidebar buttons - thread view is its own context.
6942  	dom.SetStyle(appSt.sidebarFeed, "background", "transparent")
6943  	dom.SetStyle(appSt.sidebarFeed, "color", "var(--fg)")
6944  	dom.SetStyle(appSt.sidebarCompose, "background", "transparent")
6945  	dom.SetStyle(appSt.sidebarCompose, "color", "var(--fg)")
6946  	dom.SetStyle(appSt.sidebarMsg, "background", "transparent")
6947  	dom.SetStyle(appSt.sidebarMsg, "color", "var(--fg)")
6948  	dom.SetStyle(appSt.sidebarNotif, "background", "transparent")
6949  	dom.SetStyle(appSt.sidebarNotif, "color", "var(--fg)")
6950  	dom.SetStyle(appSt.sidebarSettings, "background", "transparent")
6951  	dom.SetStyle(appSt.sidebarSettings, "color", "var(--fg)")
6952  
6953  	// Switch UI.
6954  	dom.SetStyle(appSt.feedContainer, "display", "none")
6955  	dom.SetStyle(appSt.loadMoreBtn, "display", "none")
6956  	dom.SetStyle(appSt.threadPage, "display", "block")
6957  	dom.SetProperty(appSt.contentArea, "scrollTop", "0")
6958  
6959  	// Show back button in top bar, hide everything else.
6960  	closeFeedPopover()
6961  	dom.SetStyle(appSt.topBackBtn, "display", "inline")
6962  	dom.SetStyle(appSt.pageTitleEl, "display", "none")
6963  	dom.SetStyle(appSt.feedSelectEl, "display", "none")
6964  	dom.SetStyle(appSt.notifBarGroup, "display", "none")
6965  
6966  	if !appSt.navPop {
6967  		url := "/t/" | rootID
6968  		if focusID != "" && focusID != rootID {
6969  			url = url | "#" | focusID
6970  		}
6971  		dom.PushState(url)
6972  		appSt.threadPushedState = true
6973  	} else {
6974  		appSt.threadPushedState = false
6975  	}
6976  
6977  	clearChildren(appSt.threadContainer)
6978  
6979  	// Loading indicator.
6980  	loading := dom.CreateElement("div")
6981  	dom.SetTextContent(loading, t("loading_thread"))
6982  	dom.SetStyle(loading, "color", "var(--muted)")
6983  	dom.SetStyle(loading, "fontSize", "14px")
6984  	dom.SetStyle(loading, "padding", "16px 0")
6985  	dom.AppendChild(appSt.threadContainer, loading)
6986  
6987  	// Build wide relay set for thread fetch.
6988  	var thrRelays []string
6989  	thrSeen := map[string]bool{}
6990  	for _, u := range appSt.relayURLs {
6991  		if !thrSeen[u] {
6992  			thrSeen[u] = true
6993  			thrRelays = mxutil.Ensure(thrRelays, 1)
6994  			thrRelays = push(thrRelays, u)
6995  		}
6996  	}
6997  	for _, u := range topRelays(8) {
6998  		if !thrSeen[u] {
6999  			thrSeen[u] = true
7000  			thrRelays = mxutil.Ensure(thrRelays, 1)
7001  			thrRelays = push(thrRelays, u)
7002  		}
7003  	}
7004  
7005  	appSt.threadRelays = thrRelays
7006  
7007  	// Local REQ for cached events.
7008  	appSt.threadSubCount++
7009  	s1 := "thr-" | itoa(appSt.threadSubCount)
7010  	appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7011  	appSt.threadActiveSubs = push(appSt.threadActiveSubs, s1)
7012  	routeMsg("[\"REQ\"," | jstr(s1) | ",{\"ids\":[" | jstr(rootID) | "]}]")
7013  
7014  	appSt.threadSubCount++
7015  	s2 := "thr-" | itoa(appSt.threadSubCount)
7016  	appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7017  	appSt.threadActiveSubs = push(appSt.threadActiveSubs, s2)
7018  	routeMsg("[\"REQ\"," | jstr(s2) | ",{\"#e\":[" | jstr(rootID) | "],\"kinds\":[1]}]")
7019  
7020  	// PROXY for remote relays.
7021  	appSt.threadSubCount++
7022  	s3 := "thr-" | itoa(appSt.threadSubCount)
7023  	appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7024  	appSt.threadActiveSubs = push(appSt.threadActiveSubs, s3)
7025  	routeMsg(buildProxyMsg(s3, "{\"ids\":[" | jstr(rootID) | "]}", thrRelays))
7026  
7027  	appSt.threadSubCount++
7028  	s4 := "thr-" | itoa(appSt.threadSubCount)
7029  	appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7030  	appSt.threadActiveSubs = push(appSt.threadActiveSubs, s4)
7031  	routeMsg(buildProxyMsg(s4, "{\"#e\":[" | jstr(rootID) | "],\"kinds\":[1]}", thrRelays))
7032  
7033  	// Also fetch the focus event directly if different from root.
7034  	if focusID != rootID {
7035  		appSt.threadSubCount++
7036  		s5 := "thr-" | itoa(appSt.threadSubCount)
7037  		appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7038  		appSt.threadActiveSubs = push(appSt.threadActiveSubs, s5)
7039  		routeMsg(buildProxyMsg(s5, "{\"ids\":[" | jstr(focusID) | "]}", thrRelays))
7040  	}
7041  }
7042  
7043  func closeNoteThread() {
7044  	// Close active subs.
7045  	for _, sid := range appSt.threadActiveSubs {
7046  		routeMsg("[\"CLOSE\"," | jstr(sid) | "]")
7047  	}
7048  	appSt.threadActiveSubs = nil
7049  
7050  	appSt.threadOpen = false
7051  	appSt.threadRootID = ""
7052  	appSt.threadFocusID = ""
7053  	appSt.threadRelays = nil
7054  	appSt.threadFetchedIDs = nil
7055  	stopInlineMedia(appSt.threadContainer)
7056  	dom.SetStyle(appSt.threadPage, "display", "none")
7057  
7058  	// Always restore feedContainer - showNoteThread hides it.
7059  	dom.SetStyle(appSt.feedContainer, "display", "block")
7060  	dom.SetStyle(appSt.topBackBtn, "display", "none")
7061  
7062  	// Return to the page we came from.
7063  	ret := appSt.threadReturnPage
7064  	appSt.threadReturnPage = ""
7065  	if ret != "" && ret != "feed" {
7066  		// Force activePage to "" so switchPage doesn't early-return.
7067  		appSt.activePage = ""
7068  		switchPage(ret)
7069  		// If returning to search, re-activate the bar with the current query
7070  		// so the user sees the input and can go back or search again.
7071  		if ret == "search" && appSt.searchBtnGlobal != 0 && !appSt.searchBarActive {
7072  			activateSearchBar(appSt.searchBtnGlobal)
7073  			if appSt.searchCurrentQ != "" {
7074  				dom.SetProperty(appSt.searchInputEl, "value", appSt.searchCurrentQ)
7075  			}
7076  		}
7077  	} else {
7078  		dom.SetStyle(appSt.feedSelectEl, "display", "inline")
7079  		if !appSt.feedExhausted && appSt.oldestFeedTs > 0 {
7080  			dom.SetStyle(appSt.loadMoreBtn, "display", "block")
7081  		}
7082  	}
7083  
7084  	// Restore scroll position.
7085  	if appSt.savedScrollTop != "" {
7086  		dom.SetProperty(appSt.contentArea, "scrollTop", appSt.savedScrollTop)
7087  		appSt.savedScrollTop = ""
7088  	}
7089  }
7090  
7091  func handleThreadEvent(gen int32, ev *nostr.Event) {
7092  	if gen != appSt.threadGen {
7093  		return // stale event from a previous thread
7094  	}
7095  	appSt.threadEvents[ev.ID] = ev
7096  	// Debounced render - 200ms after last event, show what we have.
7097  	if appSt.threadRenderTimer != 0 {
7098  		dom.ClearTimeout(appSt.threadRenderTimer)
7099  	}
7100  	appSt.threadRenderTimer = dom.SetTimeout(func() {
7101  		appSt.threadRenderTimer = 0
7102  		if appSt.threadOpen {
7103  			renderThreadTree()
7104  		}
7105  	}, 200)
7106  }
7107  
7108  func handleThreadEOSE() {
7109  	// Final render pass.
7110  	if appSt.threadRenderTimer != 0 {
7111  		dom.ClearTimeout(appSt.threadRenderTimer)
7112  		appSt.threadRenderTimer = 0
7113  	}
7114  	renderThreadTree()
7115  	threadFetchReplies()
7116  }
7117  
7118  func threadFetchReplies() {
7119  	if appSt.threadFollowUp >= 3 || !appSt.threadOpen {
7120  		return
7121  	}
7122  	// Collect event IDs not yet queried for children.
7123  	var ids []string
7124  	for id := range appSt.threadEvents {
7125  		if !appSt.threadFetchedIDs[id] {
7126  			ids = mxutil.Ensure(ids, 1)
7127  			ids = push(ids, id)
7128  			appSt.threadFetchedIDs[id] = true
7129  		}
7130  	}
7131  	if len(ids) == 0 {
7132  		return
7133  	}
7134  	appSt.threadFollowUp++
7135  
7136  	// Build JSON array of IDs for the #e filter.
7137  	eArr := ""
7138  	for i, id := range ids {
7139  		if i > 0 {
7140  			eArr = eArr | ","
7141  		}
7142  		eArr = eArr | jstr(id)
7143  	}
7144  	filter := "{\"#e\":[" | eArr | "],\"kinds\":[1]}"
7145  
7146  	// Local REQ.
7147  	appSt.threadSubCount++
7148  	s1 := "thr-" | itoa(appSt.threadSubCount)
7149  	appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7150  	appSt.threadActiveSubs = push(appSt.threadActiveSubs, s1)
7151  	routeMsg("[\"REQ\"," | jstr(s1) | "," | filter | "]")
7152  
7153  	// PROXY to remote relays.
7154  	if len(appSt.threadRelays) > 0 {
7155  		appSt.threadSubCount++
7156  		s2 := "thr-" | itoa(appSt.threadSubCount)
7157  		appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1)
7158  		appSt.threadActiveSubs = push(appSt.threadActiveSubs, s2)
7159  		routeMsg(buildProxyMsg(s2, filter, appSt.threadRelays))
7160  	}
7161  }
7162  
7163  func renderThreadTree() {
7164  	n := len(appSt.threadEvents)
7165  	if n == appSt.threadLastRendered {
7166  		return // no new events since last render
7167  	}
7168  	appSt.threadLastRendered = n
7169  
7170  	clearChildren(appSt.threadContainer)
7171  
7172  	if n == 0 {
7173  		empty := dom.CreateElement("div")
7174  		dom.SetTextContent(empty, t("thread_empty"))
7175  		dom.SetStyle(empty, "color", "var(--muted)")
7176  		dom.SetStyle(empty, "padding", "16px 0")
7177  		dom.AppendChild(appSt.threadContainer, empty)
7178  		return
7179  	}
7180  
7181  	// Build parent->children map. If direct parent isn't in the thread,
7182  	// re-parent under root (the event is in-thread if it references root).
7183  	children := map[string][]string{}
7184  	for id, ev := range appSt.threadEvents {
7185  		parentID := getReplyID(ev)
7186  		if parentID == "" || parentID == id {
7187  			continue
7188  		}
7189  		if appSt.threadEvents[parentID] == nil && parentID != appSt.threadRootID {
7190  			// Direct parent not fetched - attach to root if event belongs to thread.
7191  			if referencesEvent(ev, appSt.threadRootID) {
7192  				parentID = appSt.threadRootID
7193  			}
7194  		}
7195  		children[parentID] = mxutil.Ensure(children[parentID], 1)
7196  		children[parentID] = push(children[parentID], id)
7197  	}
7198  
7199  	// Sort children chronologically.
7200  	for pid := range children {
7201  		ids := children[pid]
7202  		sortByCreatedAt(ids)
7203  		children[pid] = ids
7204  	}
7205  
7206  	// Find root - event with no parent in this thread, or threadRootID.
7207  	rootID := appSt.threadRootID
7208  	if _, ok := appSt.threadEvents[rootID]; !ok {
7209  		// Root not fetched; find event with no in-thread parent.
7210  		for id, ev := range appSt.threadEvents {
7211  			parentID := getReplyID(ev)
7212  			if parentID == "" || appSt.threadEvents[parentID] == nil {
7213  				rootID = id
7214  				break
7215  			}
7216  		}
7217  	}
7218  
7219  	// Render tree via DFS, tracking what was rendered.
7220  	rendered := map[string]bool{}
7221  	var renderAt func(id string, depth int32)
7222  	renderAt = func(id string, depth int32) {
7223  		ev, ok := appSt.threadEvents[id]
7224  		if !ok {
7225  			return
7226  		}
7227  		rendered[id] = true
7228  		if isMuted(ev.PubKey) || repliesToMuted(ev) {
7229  			return // skip muted user and entire sub-branch
7230  		}
7231  		if looksLikeJSONSpam(ev.Content) {
7232  			return
7233  		}
7234  		renderThreadNote(ev, depth, id == appSt.threadFocusID)
7235  		for _, childID := range children[id] {
7236  			renderAt(childID, depth+1)
7237  		}
7238  	}
7239  
7240  	// If root is present, start there. Otherwise render all as flat.
7241  	if _, ok2 := appSt.threadEvents[rootID]; ok2 {
7242  		renderAt(rootID, 0)
7243  		// Render any remaining orphans (events not reached by DFS).
7244  		for id := range appSt.threadEvents {
7245  			if !rendered[id] {
7246  				renderAt(id, 1)
7247  			}
7248  		}
7249  	} else {
7250  		for id := range appSt.threadEvents {
7251  			renderAt(id, 0)
7252  		}
7253  	}
7254  
7255  	// Scroll focused note into view (centered vertically).
7256  	if appSt.threadFocusID != "" {
7257  		dom.SetTimeout(func() {
7258  			el := dom.GetElementById("thread-focus")
7259  			if el != 0 {
7260  				top := parseIntProp(dom.GetProperty(el, "offsetTop"))
7261  				vh := parseIntProp(dom.GetProperty(appSt.contentArea, "clientHeight"))
7262  				scroll := top - vh/2
7263  				if scroll < 0 {
7264  					scroll = 0
7265  				}
7266  				dom.SetProperty(appSt.contentArea, "scrollTop", itoa(scroll))
7267  			}
7268  		}, 50)
7269  	}
7270  }
7271  
7272  func sortByCreatedAt(ids []string) {
7273  	for i := 0; i < len(ids); i++ {
7274  		for j := i + 1; j < len(ids); j++ {
7275  			ei := appSt.threadEvents[ids[i]]
7276  			ej := appSt.threadEvents[ids[j]]
7277  			if ei != nil && ej != nil && ei.CreatedAt > ej.CreatedAt {
7278  				ids[i], ids[j] = ids[j], ids[i]
7279  			}
7280  		}
7281  	}
7282  }
7283  
7284  func renderThreadNote(ev *nostr.Event, depth int32, focused bool) {
7285  	note := dom.CreateElement("div")
7286  	dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
7287  	dom.SetStyle(note, "padding", "8px 0")
7288  	dom.SetStyle(note, "marginLeft", itoa(depth*8) | "px")
7289  
7290  	if focused {
7291  		dom.SetStyle(note, "borderLeft", "3px solid var(--accent)")
7292  		dom.SetStyle(note, "paddingLeft", "8px")
7293  		dom.SetStyle(note, "background", "var(--bg2)")
7294  		dom.SetStyle(note, "borderRadius", "4px")
7295  		dom.SetAttribute(note, "id", "thread-focus")
7296  	}
7297  
7298  	// Header.
7299  	header := dom.CreateElement("div")
7300  	dom.SetStyle(header, "display", "flex")
7301  	dom.SetStyle(header, "alignItems", "center")
7302  	dom.SetStyle(header, "marginBottom", "4px")
7303  	dom.SetStyle(header, "maxWidth", "65ch")
7304  
7305  	authorLink := dom.CreateElement("div")
7306  	dom.SetStyle(authorLink, "display", "flex")
7307  	dom.SetStyle(authorLink, "alignItems", "center")
7308  	dom.SetStyle(authorLink, "gap", "6px")
7309  	dom.SetStyle(authorLink, "cursor", "pointer")
7310  	headerPK := ev.PubKey
7311  	dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
7312  		showProfile(headerPK)
7313  	}))
7314  
7315  	avatar := dom.CreateElement("img")
7316  	dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
7317  	dom.SetAttribute(avatar, "width", "20")
7318  	dom.SetAttribute(avatar, "height", "20")
7319  	dom.SetStyle(avatar, "borderRadius", "50%")
7320  	dom.SetStyle(avatar, "objectFit", "cover")
7321  	dom.SetStyle(avatar, "flexShrink", "0")
7322  
7323  	nameSpan := dom.CreateElement("span")
7324  	dom.SetStyle(nameSpan, "fontSize", "14px")
7325  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
7326  	dom.SetStyle(nameSpan, "fontWeight", "bold")
7327  	dom.SetStyle(nameSpan, "color", "var(--fg)")
7328  
7329  	pk := ev.PubKey
7330  	if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
7331  		setMediaSrc(avatar, pic)
7332  		dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
7333  	} else {
7334  		dom.SetStyle(avatar, "display", "none")
7335  	}
7336  	if name, ok := appSt.authorNames[pk]; ok && name != "" {
7337  		dom.SetTextContent(nameSpan, name)
7338  	} else {
7339  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
7340  		if len(npub) > 20 {
7341  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
7342  		}
7343  	}
7344  
7345  	dom.AppendChild(authorLink, avatar)
7346  	dom.AppendChild(authorLink, nameSpan)
7347  	attachHoverCard(authorLink, pk)
7348  	dom.AppendChild(header, authorLink)
7349  
7350  	appendClientTag(header, ev)
7351  
7352  	// Copy button - copies this note and all replies beneath it.
7353  	tCpBtn := noteCopyBtn()
7354  	capturedTCpEv := ev
7355  	capturedTCpBtn := tCpBtn
7356  	dom.AddEventListener(tCpBtn, "click", dom.RegisterCallback(func() {
7357  		evs := collectBranch(capturedTCpEv.ID)
7358  		text := ""
7359  		for _, e := range evs {
7360  			text = text | noteClipText(e)
7361  		}
7362  		dom.WritePrimarySelection(text)
7363  		dom.WriteClipboard(text, func(ok bool) {
7364  			if ok {
7365  				noteCopyFeedback(capturedTCpBtn)
7366  			}
7367  		})
7368  	}))
7369  	dom.SetStyle(tCpBtn, "marginLeft", "auto")
7370  	dom.AppendChild(header, tCpBtn)
7371  
7372  	// JSON toggle </>
7373  	var tContentEl dom.Element
7374  	var tMoreEl dom.Element
7375  	tJsonActive := false
7376  	var tJsonEl dom.Element
7377  	tJsonBtn := dom.CreateElement("span")
7378  	dom.SetTextContent(tJsonBtn, "</>")
7379  	dom.SetStyle(tJsonBtn, "fontSize", "10px")
7380  	dom.SetStyle(tJsonBtn, "fontFamily", "'Fira Code', monospace")
7381  	dom.SetStyle(tJsonBtn, "cursor", "pointer")
7382  	dom.SetStyle(tJsonBtn, "opacity", "0.6")
7383  	dom.SetStyle(tJsonBtn, "marginRight", "8px")
7384  	dom.SetStyle(tJsonBtn, "userSelect", "none")
7385  	dom.SetStyle(tJsonBtn, "color", "var(--muted)")
7386  	capturedTJBtn := tJsonBtn
7387  	capturedTEv := ev
7388  	capturedTNote := note
7389  	dom.AddEventListener(tJsonBtn, "click", dom.RegisterCallback(func() {
7390  		if tJsonActive {
7391  			dom.SetStyle(tContentEl, "display", "")
7392  			dom.SetStyle(tMoreEl, "display", "none")
7393  			dom.SetStyle(tJsonEl, "display", "none")
7394  			dom.SetStyle(capturedTJBtn, "opacity", "0.6")
7395  			dom.SetStyle(capturedTJBtn, "color", "var(--muted)")
7396  			tJsonActive = false
7397  		} else {
7398  			if tJsonEl == 0 {
7399  				tJsonEl = dom.CreateElement("pre")
7400  				dom.SetStyle(tJsonEl, "fontFamily", "'Fira Code', monospace")
7401  				dom.SetStyle(tJsonEl, "fontSize", "12px")
7402  				dom.SetStyle(tJsonEl, "lineHeight", "1.5")
7403  				dom.SetStyle(tJsonEl, "wordBreak", "break-word")
7404  				dom.SetStyle(tJsonEl, "whiteSpace", "pre-wrap")
7405  				dom.SetStyle(tJsonEl, "maxWidth", "65ch")
7406  				dom.SetStyle(tJsonEl, "margin", "0")
7407  				dom.SetStyle(tJsonEl, "color", "var(--fg)")
7408  				dom.SetTextContent(tJsonEl, prettyNoteJSON(capturedTEv))
7409  				dom.InsertBefore(capturedTNote, tJsonEl, tMoreEl)
7410  			}
7411  			dom.SetStyle(tContentEl, "display", "none")
7412  			dom.SetStyle(tMoreEl, "display", "none")
7413  			dom.SetStyle(tJsonEl, "display", "block")
7414  			dom.SetStyle(capturedTJBtn, "opacity", "1")
7415  			dom.SetStyle(capturedTJBtn, "color", "var(--accent)")
7416  			tJsonActive = true
7417  		}
7418  	}))
7419  	dom.AppendChild(header, tJsonBtn)
7420  
7421  	// Relay widget + timestamp.
7422  	rw3 := relayWidget(ev.ID)
7423  	dom.AppendChild(header, rw3)
7424  	if ev.CreatedAt > 0 {
7425  		tsEl := dom.CreateElement("span")
7426  		dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
7427  		dom.SetStyle(tsEl, "fontSize", "11px")
7428  		dom.SetStyle(tsEl, "color", "var(--muted)")
7429  		dom.AppendChild(header, tsEl)
7430  	}
7431  
7432  	dom.AppendChild(note, header)
7433  
7434  	if _, cached := appSt.authorNames[pk]; !cached {
7435  		appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
7436  		appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
7437  		appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], authorLink)
7438  		if !lookupFetchedK0(pk) {
7439  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
7440  		}
7441  	}
7442  
7443  	// Content.
7444  	content := dom.CreateElement("div")
7445  	dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
7446  	dom.SetStyle(content, "fontSize", "13px")
7447  	dom.SetStyle(content, "lineHeight", "1.5")
7448  	dom.SetStyle(content, "wordBreak", "break-word")
7449  	dom.SetStyle(content, "maxWidth", "65ch")
7450  	setHTMLWithMedia(content, renderMarkdown(ev.Content))
7451  	dom.AppendChild(note, content)
7452  	tContentEl = content
7453  
7454  	more := makeShowMore(content)
7455  	tMoreEl = more
7456  	dom.AppendChild(note, buildActionRow(ev, note, content, more))
7457  
7458  	dom.AppendChild(appSt.threadContainer, note)
7459  	resolveEmbeds()
7460  }
7461  
7462  // fetchAuthorProfile fetches kind 0 + kind 10002 for an author via SW PROXY.
7463  // Still used internally; call sites use profile.Send(P_RESOLVE_FORCE) instead.
7464  func fetchAuthorProfile(pk string) {
7465  	if lookupFetchedK0(pk) {
7466  		return
7467  	}
7468  	setFetchedK0(pk, true)
7469  
7470  	appSt.profileSubCounter++
7471  	subID := "ap-" | itoa(appSt.profileSubCounter)
7472  	appSt.authorSubPK[subID] = pk
7473  
7474  	proxyRelays := buildProxy(pk)
7475  	routeMsg(buildProxyMsg(subID,
7476  		"{\"authors\":[" | jstr(pk) | "],\"kinds\":[0,3,10002,10000],\"limit\":6}",
7477  		proxyRelays))
7478  }
7479  
7480  // retryMissingProfiles batches pubkeys that still lack a name into chunked
7481  // PROXY requests through the orly relay. Fetches all metadata kinds so
7482  // relay lists from kind 10002 enable second-hop discovery.
7483  func retryMissingProfiles() {
7484  	var missing []string
7485  	for pk := range appSt.pendingNotes {
7486  		if _, ok := appSt.authorNames[pk]; !ok {
7487  			missing = mxutil.Ensure(missing, 1)
7488  			missing = push(missing, pk)
7489  		}
7490  	}
7491  	if len(missing) == 0 {
7492  		return
7493  	}
7494  
7495  	// Reset fetchedK0 for still-missing profiles so individual re-fetches
7496  	// can fire if new relay info appears from other profiles' kind 10002.
7497  	for _, pk := range missing {
7498  		setFetchedK0(pk, false)
7499  	}
7500  
7501  	// Discovery relays first, then user relays + discovered relays.
7502  	proxy := []string{:len(discoveryRelays)}
7503  	copy(proxy, discoveryRelays)
7504  	for _, u := range appSt.relayURLs {
7505  		proxy = appendUnique(proxy, u)
7506  	}
7507  	top := topRelays(8)
7508  	for _, u := range top {
7509  		proxy = appendUnique(proxy, u)
7510  	}
7511  
7512  	const batchSize = 100
7513  	batchNum := 0
7514  	for i := 0; i < len(missing); i += batchSize {
7515  		end := i + batchSize
7516  		if end > len(missing) {
7517  			end = len(missing)
7518  		}
7519  		chunk := missing[i:end]
7520  		authors := "["
7521  		for j, pk := range chunk {
7522  			if j > 0 {
7523  				authors = authors | ","
7524  			}
7525  			authors = authors | jstr(pk)
7526  		}
7527  		authors = authors | "]"
7528  		subID := "ap-batch-" | itoa(appSt.retryRound) | "-" | itoa(batchNum)
7529  		batchNum++
7530  		routeMsg(buildProxyMsg(subID,
7531  			"{\"authors\":" | authors | ",\"kinds\":[0,10002],\"limit\":" | itoa(len(chunk)*2) | "}",
7532  			proxy))
7533  		// Direct query to feed relays.
7534  		appSt.profileSubCounter++
7535  		routeMsg(buildProxyMsg("ap-d-" | itoa(appSt.profileSubCounter),
7536  			"{\"authors\":" | authors | ",\"kinds\":[0],\"limit\":" | itoa(len(chunk)) | "}",
7537  			appSt.relayURLs))
7538  	}
7539  	appSt.retryRound++
7540  }
7541  
7542  // applyAuthorProfile is no longer called by Shell - Profile Worker owns event
7543  // processing. P_RESOLVED/P_CONTENT handlers in onSWMessage do the DOM updates.
7544  
7545  // updateNoteHeader fills in avatar+name on a note's author header div.
7546  func updateNoteHeader(header dom.Element, name, pic string) {
7547  	// First child is <img>, second is <span>.
7548  	img := dom.FirstChild(header)
7549  	if img == 0 {
7550  		return
7551  	}
7552  	span := dom.NextSibling(img)
7553  	if len(pic) > 0 {
7554  		setMediaSrc(img, pic)
7555  		dom.SetAttribute(img, "onerror", "this.style.display='none'")
7556  		dom.SetStyle(img, "display", "")
7557  	}
7558  	if len(name) > 0 {
7559  		dom.SetTextContent(span, name)
7560  	}
7561  }
7562  
7563  // --- Profile page ---
7564  
7565  func showEditProfileModal() {
7566  	content := appSt.profileContentCache[appSt.pubhex]
7567  	curName := helpers.JsonGetString(content, "name")
7568  	curAbout := helpers.JsonGetString(content, "about")
7569  	curPic := helpers.JsonGetString(content, "picture")
7570  	curBanner := helpers.JsonGetString(content, "banner")
7571  	curNip05 := helpers.JsonGetString(content, "nip05")
7572  	curWebsite := helpers.JsonGetString(content, "website")
7573  	curLud16 := helpers.JsonGetString(content, "lud16")
7574  
7575  	overlay := dom.CreateElement("div")
7576  	dom.SetStyle(overlay, "position", "fixed")
7577  	dom.SetStyle(overlay, "top", "0")
7578  	dom.SetStyle(overlay, "left", "0")
7579  	dom.SetStyle(overlay, "right", "0")
7580  	dom.SetStyle(overlay, "bottom", "0")
7581  	dom.SetStyle(overlay, "background", "rgba(0,0,0,0.6)")
7582  	dom.SetStyle(overlay, "display", "flex")
7583  	dom.SetStyle(overlay, "alignItems", "center")
7584  	dom.SetStyle(overlay, "justifyContent", "center")
7585  	dom.SetStyle(overlay, "zIndex", "2000")
7586  
7587  	modal := dom.CreateElement("div")
7588  	dom.SetStyle(modal, "background", "var(--bg)")
7589  	dom.SetStyle(modal, "border", "1px solid var(--border)")
7590  	dom.SetStyle(modal, "borderRadius", "8px")
7591  	dom.SetStyle(modal, "padding", "20px")
7592  	dom.SetStyle(modal, "width", "400px")
7593  	dom.SetStyle(modal, "maxWidth", "calc(100vw - 32px)")
7594  	dom.SetStyle(modal, "maxHeight", "calc(100vh - 64px)")
7595  	dom.SetStyle(modal, "overflowY", "auto")
7596  
7597  	makeField := func(label, val string, multiline bool) dom.Element {
7598  		wrap := dom.CreateElement("div")
7599  		dom.SetStyle(wrap, "marginBottom", "12px")
7600  		lbl := dom.CreateElement("div")
7601  		dom.SetTextContent(lbl, label)
7602  		dom.SetStyle(lbl, "fontSize", "11px")
7603  		dom.SetStyle(lbl, "color", "var(--muted)")
7604  		dom.SetStyle(lbl, "marginBottom", "4px")
7605  		dom.AppendChild(wrap, lbl)
7606  		var input dom.Element
7607  		if multiline {
7608  			input = dom.CreateElement("textarea")
7609  			dom.SetAttribute(input, "rows", "3")
7610  		} else {
7611  			input = dom.CreateElement("input")
7612  			dom.SetAttribute(input, "type", "text")
7613  		}
7614  		dom.SetStyle(input, "width", "100%")
7615  		dom.SetStyle(input, "padding", "6px 8px")
7616  		dom.SetStyle(input, "background", "var(--bg2)")
7617  		dom.SetStyle(input, "color", "var(--fg)")
7618  		dom.SetStyle(input, "border", "1px solid var(--border)")
7619  		dom.SetStyle(input, "borderRadius", "4px")
7620  		dom.SetStyle(input, "fontFamily", "'Fira Code', monospace")
7621  		dom.SetStyle(input, "fontSize", "13px")
7622  		dom.SetStyle(input, "boxSizing", "border-box")
7623  		dom.SetAttribute(input, "value", val)
7624  		if multiline {
7625  			dom.SetTextContent(input, val)
7626  		}
7627  		dom.AppendChild(wrap, input)
7628  		return wrap
7629  	}
7630  
7631  	nameField := makeField(t("profile_name"), curName, false)
7632  	aboutField := makeField(t("profile_about"), curAbout, true)
7633  	picField := makeField(t("profile_picture"), curPic, false)
7634  	bannerField := makeField(t("profile_banner"), curBanner, false)
7635  	nip05Field := makeField(t("profile_nip05"), curNip05, false)
7636  	websiteField := makeField(t("profile_website"), curWebsite, false)
7637  	lud16Field := makeField(t("profile_lud16"), curLud16, false)
7638  
7639  	addUploadRow := func(field dom.Element) {
7640  		input := dom.NextSibling(dom.FirstChild(field))
7641  		btn := dom.CreateElement("button")
7642  		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>`)
7643  		dom.SetStyle(btn, "padding", "6px 8px")
7644  		dom.SetStyle(btn, "border", "1px solid var(--border)")
7645  		dom.SetStyle(btn, "borderRadius", "4px")
7646  		dom.SetStyle(btn, "background", "transparent")
7647  		dom.SetStyle(btn, "color", "var(--fg)")
7648  		dom.SetStyle(btn, "cursor", "pointer")
7649  		dom.SetStyle(btn, "display", "flex")
7650  		dom.SetStyle(btn, "alignItems", "center")
7651  		dom.SetStyle(btn, "flexShrink", "0")
7652  		dom.AddEventListener(btn, "click", dom.RegisterCallback(func() {
7653  			dom.PickFileBase64("image/*,video/*", func(b64, mime string) {
7654  				if b64 != "" {
7655  					blossomUploadToInput(b64, mime, input)
7656  				}
7657  			})
7658  		}))
7659  		row := dom.CreateElement("div")
7660  		dom.SetStyle(row, "display", "flex")
7661  		dom.SetStyle(row, "gap", "4px")
7662  		dom.SetStyle(input, "flex", "1")
7663  		dom.SetStyle(input, "minWidth", "0")
7664  		dom.InsertBefore(field, row, input)
7665  		dom.AppendChild(row, input)
7666  		dom.AppendChild(row, btn)
7667  	}
7668  
7669  	addUploadRow(picField)
7670  	addUploadRow(bannerField)
7671  
7672  	dom.AppendChild(modal, nameField)
7673  	dom.AppendChild(modal, aboutField)
7674  	dom.AppendChild(modal, picField)
7675  	dom.AppendChild(modal, bannerField)
7676  	dom.AppendChild(modal, nip05Field)
7677  	dom.AppendChild(modal, websiteField)
7678  	dom.AppendChild(modal, lud16Field)
7679  
7680  	// Buttons row.
7681  	btnRow := dom.CreateElement("div")
7682  	dom.SetStyle(btnRow, "display", "flex")
7683  	dom.SetStyle(btnRow, "gap", "8px")
7684  	dom.SetStyle(btnRow, "marginTop", "8px")
7685  
7686  	getInput := func(field dom.Element) dom.Element {
7687  		el := dom.NextSibling(dom.FirstChild(field))
7688  		tag := dom.GetProperty(el, "tagName")
7689  		if tag == "DIV" {
7690  			return dom.FirstChild(el)
7691  		}
7692  		return el
7693  	}
7694  
7695  	saveBtn := dom.CreateElement("button")
7696  	dom.SetTextContent(saveBtn, t("save"))
7697  	dom.SetStyle(saveBtn, "padding", "6px 16px")
7698  	dom.SetStyle(saveBtn, "fontFamily", "'Fira Code', monospace")
7699  	dom.SetStyle(saveBtn, "fontSize", "12px")
7700  	dom.SetStyle(saveBtn, "background", "var(--accent)")
7701  	dom.SetStyle(saveBtn, "color", "#fff")
7702  	dom.SetStyle(saveBtn, "border", "none")
7703  	dom.SetStyle(saveBtn, "borderRadius", "4px")
7704  	dom.SetStyle(saveBtn, "cursor", "pointer")
7705  	dom.AddEventListener(saveBtn, "click", dom.RegisterCallback(func() {
7706  		newName := dom.GetProperty(getInput(nameField), "value")
7707  		newAbout := dom.GetProperty(getInput(aboutField), "value")
7708  		newPic := dom.GetProperty(getInput(picField), "value")
7709  		newBanner := dom.GetProperty(getInput(bannerField), "value")
7710  		newNip05 := dom.GetProperty(getInput(nip05Field), "value")
7711  		newWebsite := dom.GetProperty(getInput(websiteField), "value")
7712  		newLud16 := dom.GetProperty(getInput(lud16Field), "value")
7713  
7714  		k0content := "{" |
7715  			"\"name\":\"" | jsonEsc(newName) | "\"" |
7716  			",\"about\":\"" | jsonEsc(newAbout) | "\"" |
7717  			",\"picture\":\"" | jsonEsc(newPic) | "\"" |
7718  			",\"banner\":\"" | jsonEsc(newBanner) | "\"" |
7719  			",\"nip05\":\"" | jsonEsc(newNip05) | "\"" |
7720  			",\"website\":\"" | jsonEsc(newWebsite) | "\"" |
7721  			",\"lud16\":\"" | jsonEsc(newLud16) | "\"" |
7722  			"}"
7723  
7724  		ts := dom.NowSeconds()
7725  		unsigned := "{\"kind\":0,\"content\":" | jstr(k0content) |
7726  			",\"tags\":[]" |
7727  			",\"created_at\":" | i64toa(ts) |
7728  			",\"pubkey\":\"" | appSt.pubhex | "\"}"
7729  
7730  		hasSigner := "no"
7731  		if signer.HasSigner() {
7732  			hasSigner = "yes"
7733  		}
7734  		dom.ConsoleLog("[profile] signing kind 0, hasSigner=" | hasSigner)
7735  		signer.SignEvent(unsigned, func(signed string) {
7736  			if len(signed) == 0 {
7737  				dom.ConsoleLog("[profile] sign returned empty - check [signer] and [ext.dispatch] logs above")
7738  				dom.Confirm("sign failed - open signer panel, unlock vault, and try again")
7739  				return
7740  			}
7741  			dom.ConsoleLog("[profile] sign OK, posting to SW")
7742  			selected := composeSelectedRelays()
7743  			for _, d := range discoveryRelays {
7744  				selected = appendUnique(selected, d)
7745  			}
7746  			dom.ConsoleLog("[profile] PUBLISH_TO " | itoa(len(selected)) | " relays")
7747  			msg := "[\"PUBLISH_TO\"," | signed | ",["
7748  			for i, u := range selected {
7749  				if i > 0 {
7750  					msg = msg | ","
7751  				}
7752  				msg = msg | jstr(u)
7753  			}
7754  			routeMsg(msg | "]]")
7755  			dom.RemoveChild(dom.Body(), overlay)
7756  			// Update local cache immediately.
7757  			appSt.profileContentCache[appSt.pubhex] = k0content
7758  			if len(newName) > 0 {
7759  				appSt.profileName = newName
7760  				setAuthorName(appSt.pubhex, newName)
7761  				dom.SetTextContent(appSt.nameEl, newName)
7762  			}
7763  			if len(newPic) > 0 {
7764  				appSt.profilePic = newPic
7765  				appSt.authorPics[appSt.pubhex] = newPic
7766  				setMediaSrc(appSt.avatarEl, newPic)
7767  				dom.SetStyle(appSt.avatarEl, "display", "block")
7768  			}
7769  			appSt.profileTs = ts
7770  			renderProfilePage(appSt.pubhex)
7771  		})
7772  	}))
7773  	dom.AppendChild(btnRow, saveBtn)
7774  
7775  	cancelBtn := dom.CreateElement("button")
7776  	dom.SetTextContent(cancelBtn, t("cancel"))
7777  	dom.SetStyle(cancelBtn, "padding", "6px 16px")
7778  	dom.SetStyle(cancelBtn, "fontFamily", "'Fira Code', monospace")
7779  	dom.SetStyle(cancelBtn, "fontSize", "12px")
7780  	dom.SetStyle(cancelBtn, "background", "var(--bg2)")
7781  	dom.SetStyle(cancelBtn, "color", "var(--fg)")
7782  	dom.SetStyle(cancelBtn, "border", "1px solid var(--border)")
7783  	dom.SetStyle(cancelBtn, "borderRadius", "4px")
7784  	dom.SetStyle(cancelBtn, "cursor", "pointer")
7785  	dom.AddEventListener(cancelBtn, "click", dom.RegisterCallback(func() {
7786  		dom.RemoveChild(dom.Body(), overlay)
7787  	}))
7788  	dom.AppendChild(btnRow, cancelBtn)
7789  
7790  	dom.AppendChild(modal, btnRow)
7791  	dom.AppendChild(overlay, modal)
7792  	dom.AppendChild(dom.Body(), overlay)
7793  }
7794  
7795  func showProfile(pk string) {
7796  	if appSt.fetchedK0 == nil {
7797  		return
7798  	}
7799  	// Save current profile's tab state before switching.
7800  	if appSt.profileViewPK != "" && appSt.profileWrappers[appSt.profileViewPK] != 0 {
7801  		appSt.profileWrapTabSel[appSt.profileViewPK] = appSt.profileTab
7802  		appSt.profileWrapTabCont[appSt.profileViewPK] = appSt.profileTabContent
7803  		appSt.profileWrapTabs[appSt.profileViewPK] = appSt.profileTabBtns
7804  	}
7805  
7806  	appSt.profileViewPK = pk
7807  
7808  	// Always fetch fresh metadata in background.
7809  	setFetchedK0(pk, false)
7810  	profile.Send(`["P_RESOLVE_FORCE",` | jstr(pk) | `]`)
7811  
7812  	if !appSt.navPop {
7813  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
7814  		dom.PushState("/p/" | npub)
7815  	}
7816  
7817  	// Hide all cached profile wrappers and remove relay info.
7818  	for _, w := range appSt.profileWrappers {
7819  		dom.SetStyle(w, "display", "none")
7820  	}
7821  	if appSt.relayInfoEl != 0 {
7822  		dom.RemoveChild(appSt.profilePage, appSt.relayInfoEl)
7823  		appSt.relayInfoEl = 0
7824  	}
7825  
7826  	if wrapper, ok2 := appSt.profileWrappers[pk]; ok2 {
7827  		// Cache hit - restore.
7828  		dom.SetStyle(wrapper, "display", "block")
7829  		appSt.profileTabContent = appSt.profileWrapTabCont[pk]
7830  		appSt.profileTabBtns = appSt.profileWrapTabs[pk]
7831  		appSt.profileTab = appSt.profileWrapTabSel[pk]
7832  		profileTouchLRU(pk)
7833  	} else {
7834  		renderProfilePage(pk)
7835  	}
7836  
7837  	appSt.activePage = "" // force switchPage to run
7838  	switchPage("profile")
7839  }
7840  
7841  const profileCacheMax = 8
7842  
7843  func profileTouchLRU(pk string) {
7844  	// Move pk to end (most recent).
7845  	out := []string{:0:len(appSt.profileWrapOrder)}
7846  	for _, p := range appSt.profileWrapOrder {
7847  		if p != pk {
7848  			out = mxutil.Ensure(out, 1)
7849  			out = push(out, p)
7850  		}
7851  	}
7852  	appSt.profileWrapOrder = push(out, pk)
7853  }
7854  
7855  func profileEvictOldest() {
7856  	for len(appSt.profileWrapOrder) > profileCacheMax {
7857  		old := appSt.profileWrapOrder[0]
7858  		appSt.profileWrapOrder = appSt.profileWrapOrder[1:]
7859  		if w, ok2 := appSt.profileWrappers[old]; ok2 {
7860  			dom.RemoveChild(appSt.profilePage, w)
7861  			dom.ReleaseChildren(w)
7862  			dom.ReleaseElement(w)
7863  		}
7864  		delete(appSt.profileWrappers, old)
7865  		delete(appSt.profileWrapTabCont, old)
7866  		delete(appSt.profileWrapTabs, old)
7867  		delete(appSt.profileWrapTabSel, old)
7868  		delete(appSt.profileContentCache, old)
7869  		delete(appSt.profileFollowsCache, old)
7870  		delete(appSt.profileMutesCache, old)
7871  		delete(appSt.profileRelaysCache, old)
7872  	}
7873  }
7874  
7875  func verifyNip05(nip05, pubkeyHex string, badge dom.Element) {
7876  	at := -1
7877  	for i := 0; i < len(nip05); i++ {
7878  		if nip05[i] == '@' {
7879  			at = i
7880  			break
7881  		}
7882  	}
7883  	if at < 1 || at >= len(nip05)-1 {
7884  		dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
7885  		return
7886  	}
7887  	local := nip05[:at]
7888  	domain := nip05[at+1:]
7889  	url := "https://" | domain | "/.well-known/nostr.json?name=" | local
7890  	dom.FetchText(url, func(body string) {
7891  		if body == "" {
7892  			dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
7893  			return
7894  		}
7895  		namesObj := helpers.JsonGetString(body, "names")
7896  		if namesObj == "" {
7897  			// names might be an object not a string - extract manually
7898  			namesStart := -1
7899  			key := "\"names\""
7900  			for i := 0; i < len(body)-len(key); i++ {
7901  				if body[i:i+len(key)] == key {
7902  					namesStart = i + len(key)
7903  					break
7904  				}
7905  			}
7906  			if namesStart < 0 {
7907  				dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f")
7908  				return
7909  			}
7910  			// Skip colon and whitespace to find the object
7911  			for namesStart < len(body) && (body[namesStart] == ':' || body[namesStart] == ' ' || body[namesStart] == '\t') {
7912  				namesStart++
7913  			}
7914  			if namesStart >= len(body) || body[namesStart] != '{' {
7915  				dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f")
7916  				return
7917  			}
7918  			// Find matching brace
7919  			depth := 0
7920  			end := namesStart
7921  			for end < len(body) {
7922  				if body[end] == '{' {
7923  					depth++
7924  				} else if body[end] == '}' {
7925  					depth--
7926  					if depth == 0 {
7927  						end++
7928  						break
7929  					}
7930  				}
7931  				end++
7932  			}
7933  			namesObj = body[namesStart:end]
7934  		}
7935  		got := helpers.JsonGetString(namesObj, local)
7936  		if got == pubkeyHex {
7937  			dom.SetTextContent(badge, "\xe2\x9c\x85") // ✅
7938  		} else {
7939  			dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️
7940  		}
7941  	})
7942  }
7943  
7944  func renderProfilePage(pk string) {
7945  	savedTab := appSt.profileTab
7946  	closeProfileNoteSub()
7947  	appSt.profileNotesSeen = map[string]bool{}
7948  
7949  	// Remove previous wrapper for this pubkey if it exists.
7950  	if old, ok2 := appSt.profileWrappers[pk]; ok2 {
7951  		dom.RemoveChild(appSt.profilePage, old)
7952  	}
7953  
7954  	wrapper := dom.CreateElement("div")
7955  	appSt.profileWrappers[pk] = wrapper
7956  	profileTouchLRU(pk)
7957  	profileEvictOldest()
7958  	dom.AppendChild(appSt.profilePage, wrapper)
7959  
7960  	content := appSt.profileContentCache[pk]
7961  	name := appSt.authorNames[pk]
7962  	pic := appSt.authorPics[pk]
7963  	about := helpers.JsonGetString(content, "about")
7964  	website := helpers.JsonGetString(content, "website")
7965  	nip05 := helpers.JsonGetString(content, "nip05")
7966  	lud16 := helpers.JsonGetString(content, "lud16")
7967  	banner := helpers.JsonGetString(content, "banner")
7968  
7969  	// Banner - full width, 200px, cover.
7970  	if banner != "" {
7971  		bannerEl := dom.CreateElement("img")
7972  		dom.SetAttribute(bannerEl, "referrerpolicy", "no-referrer")
7973  		setMediaSrc(bannerEl, banner)
7974  		dom.SetStyle(bannerEl, "width", "100%")
7975  		dom.SetStyle(bannerEl, "height", "240px")
7976  		dom.SetStyle(bannerEl, "objectFit", "cover")
7977  		dom.SetStyle(bannerEl, "objectPosition", "center 30%")
7978  		dom.SetStyle(bannerEl, "display", "block")
7979  		dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'")
7980  		dom.AppendChild(wrapper, bannerEl)
7981  	}
7982  
7983  	// User info card - glass effect, overlapping banner.
7984  	card := dom.CreateElement("div")
7985  	dom.SetStyle(card, "background", "color-mix(in srgb, var(--bg) 42%, transparent)")
7986  	dom.SetStyle(card, "backdropFilter", "blur(8px)")
7987  	dom.SetStyle(card, "borderRadius", "8px")
7988  	dom.SetStyle(card, "padding", "8px")
7989  	if banner != "" {
7990  		dom.SetStyle(card, "margin", "-24px 16px 0")
7991  	} else {
7992  		dom.SetStyle(card, "margin", "16px")
7993  	}
7994  	dom.SetStyle(card, "position", "relative")
7995  	dom.SetStyle(card, "width", "fit-content")
7996  	dom.SetStyle(card, "maxWidth", "calc(100% - 32px)")
7997  
7998  	// Top row: avatar + info. Wraps on narrow screens so avatar goes above.
7999  	topRow := dom.CreateElement("div")
8000  	dom.SetStyle(topRow, "display", "flex")
8001  	dom.SetStyle(topRow, "gap", "16px")
8002  	dom.SetStyle(topRow, "alignItems", "flex-start")
8003  	dom.SetStyle(topRow, "flexWrap", "wrap")
8004  
8005  	// Compute npub early - needed for avatar QR click and npub row.
8006  	npubBytes := helpers.HexDecode(pk)
8007  	npubStr := helpers.EncodeNpub(npubBytes)
8008  
8009  	if pic != "" {
8010  		av := dom.CreateElement("img")
8011  		dom.SetAttribute(av, "referrerpolicy", "no-referrer")
8012  		setMediaSrc(av, pic)
8013  		dom.SetAttribute(av, "width", "64")
8014  		dom.SetAttribute(av, "height", "64")
8015  		dom.SetStyle(av, "borderRadius", "50%")
8016  		dom.SetStyle(av, "objectFit", "cover")
8017  		dom.SetStyle(av, "flexShrink", "0")
8018  		dom.SetStyle(av, "border", "3px solid var(--bg)")
8019  		dom.SetStyle(av, "cursor", "pointer")
8020  		dom.SetAttribute(av, "onerror", "this.style.display='none'")
8021  		avNpub := npubStr
8022  		dom.AddEventListener(av, "click", dom.RegisterCallback(func() {
8023  			showQRModal(avNpub)
8024  		}))
8025  		dom.AppendChild(topRow, av)
8026  	}
8027  
8028  	info := dom.CreateElement("div")
8029  	dom.SetStyle(info, "minWidth", "200px")
8030  	dom.SetStyle(info, "flex", "1")
8031  	dom.SetStyle(info, "overflow", "hidden")
8032  
8033  	if name != "" {
8034  		nameRow := dom.CreateElement("div")
8035  		dom.SetStyle(nameRow, "display", "flex")
8036  		dom.SetStyle(nameRow, "alignItems", "baseline")
8037  		dom.SetStyle(nameRow, "gap", "8px")
8038  		nameSpan := dom.CreateElement("span")
8039  		dom.SetTextContent(nameSpan, name)
8040  		dom.SetStyle(nameSpan, "fontSize", "20px")
8041  		dom.SetStyle(nameSpan, "fontWeight", "bold")
8042  		dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8043  		dom.SetStyle(nameSpan, "cursor", "pointer")
8044  		nameNpub := npubStr
8045  		dom.AddEventListener(nameSpan, "click", dom.RegisterCallback(func() {
8046  			showQRModal(nameNpub)
8047  		}))
8048  		dom.AppendChild(nameRow, nameSpan)
8049  		// "follows you" badge.
8050  		if pk != appSt.pubhex {
8051  			if theirFollows, ok2 := appSt.profileFollowsCache[pk]; ok2 {
8052  				for _, fpk := range theirFollows {
8053  					if fpk == appSt.pubhex {
8054  						badge := dom.CreateElement("span")
8055  						dom.SetTextContent(badge, "follows you")
8056  						dom.SetStyle(badge, "fontSize", "11px")
8057  						dom.SetStyle(badge, "color", "var(--muted)")
8058  						dom.SetStyle(badge, "background", "var(--bg2)")
8059  						dom.SetStyle(badge, "padding", "2px 6px")
8060  						dom.SetStyle(badge, "borderRadius", "4px")
8061  						dom.AppendChild(nameRow, badge)
8062  						break
8063  					}
8064  				}
8065  			}
8066  		}
8067  		dom.AppendChild(info, nameRow)
8068  	}
8069  
8070  	if nip05 != "" {
8071  		nip05Row := dom.CreateElement("div")
8072  		dom.SetStyle(nip05Row, "display", "flex")
8073  		dom.SetStyle(nip05Row, "alignItems", "center")
8074  		dom.SetStyle(nip05Row, "gap", "4px")
8075  		nip05Text := dom.CreateElement("span")
8076  		dom.SetTextContent(nip05Text, nip05)
8077  		dom.SetStyle(nip05Text, "color", "var(--muted)")
8078  		dom.SetStyle(nip05Text, "fontSize", "13px")
8079  		dom.AppendChild(nip05Row, nip05Text)
8080  		nip05Badge := dom.CreateElement("span")
8081  		dom.SetStyle(nip05Badge, "fontSize", "14px")
8082  		dom.AppendChild(nip05Row, nip05Badge)
8083  		dom.AppendChild(info, nip05Row)
8084  
8085  		// Async NIP-05 validation.
8086  		verifyNip05(nip05, pk, nip05Badge)
8087  	}
8088  
8089  	// npub row: clickable npub text (shows QR) + copy icon.
8090  	npubRow := dom.CreateElement("div")
8091  	dom.SetStyle(npubRow, "display", "flex")
8092  	dom.SetStyle(npubRow, "alignItems", "center")
8093  	dom.SetStyle(npubRow, "gap", "6px")
8094  	dom.SetStyle(npubRow, "marginTop", "2px")
8095  	npubEl := dom.CreateElement("span")
8096  	dom.SetStyle(npubEl, "color", "var(--muted)")
8097  	dom.SetStyle(npubEl, "fontSize", "12px")
8098  	dom.SetStyle(npubEl, "wordBreak", "break-all")
8099  	dom.SetStyle(npubEl, "cursor", "pointer")
8100  	dom.SetTextContent(npubEl, npubStr)
8101  	npubQR := npubStr
8102  	dom.AddEventListener(npubEl, "click", dom.RegisterCallback(func() {
8103  		showQRModal(npubQR)
8104  	}))
8105  	dom.AppendChild(npubRow, npubEl)
8106  	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>`)
8107  	dom.SetAttribute(copyBtn, "data-npub", npubStr)
8108  	dom.SetAttribute(copyBtn, "onclick", "navigator.clipboard.writeText(this.dataset.npub)")
8109  	dom.AppendChild(npubRow, copyBtn)
8110  	dom.AppendChild(info, npubRow)
8111  
8112  	// Website + lightning inline.
8113  	if website != "" || lud16 != "" {
8114  		metaRow := dom.CreateElement("div")
8115  		dom.SetStyle(metaRow, "display", "flex")
8116  		dom.SetStyle(metaRow, "flexWrap", "wrap")
8117  		dom.SetStyle(metaRow, "gap", "4px 12px")
8118  		dom.SetStyle(metaRow, "marginTop", "6px")
8119  		dom.SetStyle(metaRow, "fontSize", "12px")
8120  		if website != "" {
8121  			wEl := dom.CreateElement("span")
8122  			dom.SetStyle(wEl, "color", "var(--accent)")
8123  			dom.SetStyle(wEl, "wordBreak", "break-all")
8124  			dom.SetTextContent(wEl, website)
8125  			dom.AppendChild(metaRow, wEl)
8126  		}
8127  		if lud16 != "" {
8128  			lEl := dom.CreateElement("span")
8129  			dom.SetStyle(lEl, "color", "var(--muted)")
8130  			dom.SetStyle(lEl, "wordBreak", "break-all")
8131  			dom.SetStyle(lEl, "cursor", "pointer")
8132  			dom.SetTextContent(lEl, "\xE2\x9A\xA1 " | lud16)
8133  			capLud := lud16
8134  			dom.AddEventListener(lEl, "click", dom.RegisterCallback(func() {
8135  				showQRModal(capLud)
8136  			}))
8137  			dom.AppendChild(metaRow, lEl)
8138  		}
8139  		dom.AppendChild(info, metaRow)
8140  	}
8141  
8142  	dom.AppendChild(topRow, info)
8143  	dom.AppendChild(card, topRow)
8144  
8145  	// Action buttons - only for other users.
8146  	if pk != appSt.pubhex {
8147  		btnRow := dom.CreateElement("div")
8148  		dom.SetStyle(btnRow, "display", "flex")
8149  		dom.SetStyle(btnRow, "gap", "8px")
8150  		dom.SetStyle(btnRow, "marginTop", "12px")
8151  
8152  		// Follow/unfollow button.
8153  		followBtn := dom.CreateElement("button")
8154  		dom.SetStyle(followBtn, "padding", "6px 16px")
8155  		dom.SetStyle(followBtn, "fontFamily", "'Fira Code', monospace")
8156  		dom.SetStyle(followBtn, "fontSize", "12px")
8157  		dom.SetStyle(followBtn, "borderRadius", "4px")
8158  		dom.SetStyle(followBtn, "cursor", "pointer")
8159  		isFollowingw1 := false
8160  		if len(appSt.myFollows) > 0 {
8161  			for _, fpk := range appSt.myFollows {
8162  				if fpk == pk {
8163  					isFollowingw1 = true
8164  					break
8165  				}
8166  			}
8167  		}
8168  		if isFollowingw1 {
8169  			dom.SetTextContent(followBtn, "followed")
8170  			dom.SetStyle(followBtn, "background", "var(--bg2)")
8171  			dom.SetStyle(followBtn, "color", "var(--fg)")
8172  			dom.SetStyle(followBtn, "border", "1px solid var(--border)")
8173  		} else {
8174  			dom.SetTextContent(followBtn, "follow")
8175  			dom.SetStyle(followBtn, "background", "var(--accent)")
8176  			dom.SetStyle(followBtn, "color", "#fff")
8177  			dom.SetStyle(followBtn, "border", "none")
8178  		}
8179  		dom.AppendChild(btnRow, followBtn)
8180  
8181  
8182  		// Mute button.
8183  		muteBtn := dom.CreateElement("button")
8184  		dom.SetStyle(muteBtn, "padding", "6px 16px")
8185  		dom.SetStyle(muteBtn, "fontFamily", "'Fira Code', monospace")
8186  		dom.SetStyle(muteBtn, "fontSize", "12px")
8187  		dom.SetStyle(muteBtn, "borderRadius", "4px")
8188  		dom.SetStyle(muteBtn, "cursor", "pointer")
8189  		if isMuted(pk) {
8190  			dom.SetTextContent(muteBtn, "muted")
8191  			dom.SetStyle(muteBtn, "background", "#c33")
8192  			dom.SetStyle(muteBtn, "color", "#fff")
8193  			dom.SetStyle(muteBtn, "border", "none")
8194  		} else {
8195  			dom.SetTextContent(muteBtn, "mute")
8196  			dom.SetStyle(muteBtn, "background", "var(--bg2)")
8197  			dom.SetStyle(muteBtn, "color", "var(--fg)")
8198  			dom.SetStyle(muteBtn, "border", "1px solid var(--border)")
8199  		}
8200  
8201  		// Wire up click handlers with cross-references.
8202  		btnPK := pk
8203  		cFollow := followBtn
8204  		cMute := muteBtn
8205  		dom.AddEventListener(followBtn, "click", dom.RegisterCallback(func() {
8206  			toggleFollow(btnPK, cFollow, cMute)
8207  		}))
8208  		dom.AddEventListener(muteBtn, "click", dom.RegisterCallback(func() {
8209  			toggleMute(btnPK, cMute, cFollow)
8210  		}))
8211  		dom.AppendChild(btnRow, muteBtn)
8212  
8213  		dom.AppendChild(card, btnRow)
8214  	} else {
8215  		// Own profile - edit button.
8216  		editRow := dom.CreateElement("div")
8217  		dom.SetStyle(editRow, "marginTop", "12px")
8218  
8219  		editBtn := dom.CreateElement("button")
8220  		dom.SetTextContent(editBtn, t("edit_profile"))
8221  		dom.SetStyle(editBtn, "padding", "6px 16px")
8222  		dom.SetStyle(editBtn, "fontFamily", "'Fira Code', monospace")
8223  		dom.SetStyle(editBtn, "fontSize", "12px")
8224  		dom.SetStyle(editBtn, "background", "var(--bg2)")
8225  		dom.SetStyle(editBtn, "color", "var(--fg)")
8226  		dom.SetStyle(editBtn, "border", "1px solid var(--border)")
8227  		dom.SetStyle(editBtn, "borderRadius", "4px")
8228  		dom.SetStyle(editBtn, "cursor", "pointer")
8229  		dom.AddEventListener(editBtn, "click", dom.RegisterCallback(func() {
8230  			showEditProfileModal()
8231  		}))
8232  		dom.AppendChild(editRow, editBtn)
8233  		dom.AppendChild(card, editRow)
8234  	}
8235  
8236  	dom.AppendChild(wrapper, card)
8237  
8238  	// About/bio - same height-cap + show-more pattern as feed notes.
8239  	if about != "" {
8240  		aboutEl := dom.CreateElement("div")
8241  		dom.SetStyle(aboutEl, "padding", "12px 16px")
8242  		dom.SetStyle(aboutEl, "fontSize", "14px")
8243  		dom.SetStyle(aboutEl, "lineHeight", "1.5")
8244  		dom.SetStyle(aboutEl, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8245  		dom.SetStyle(aboutEl, "wordBreak", "break-word")
8246  		dom.SetStyle(aboutEl, "overflow", "hidden")
8247  		dom.SetStyle(aboutEl, "maxHeight", "18em")
8248  		dom.AppendChild(wrapper, aboutEl)
8249  		setHTMLWithMedia(aboutEl, renderMarkdown(about))
8250  		more := makeShowMore(aboutEl)
8251  		dom.SetStyle(more, "padding", "0 16px 8px")
8252  		dom.AppendChild(wrapper, more)
8253  		attachShowMore(aboutEl, more)
8254  	}
8255  
8256  	// Tab bar.
8257  	tabBar := dom.CreateElement("div")
8258  	dom.SetStyle(tabBar, "display", "flex")
8259  	dom.SetStyle(tabBar, "gap", "0")
8260  	dom.SetStyle(tabBar, "margin", "8px 16px 0")
8261  	dom.SetStyle(tabBar, "border", "1px solid var(--border)")
8262  	dom.SetStyle(tabBar, "borderRadius", "6px")
8263  	dom.SetStyle(tabBar, "overflow", "hidden")
8264  	dom.SetStyle(tabBar, "width", "fit-content")
8265  
8266  	appSt.profileTabBtns = map[string]dom.Element{}
8267  
8268  	// Unrolled - tinyjs range/loop closure aliasing.
8269  	tabNotes := makeProtoBtn(t("notes"))
8270  	dom.SetStyle(tabNotes, "cursor", "pointer")
8271  	appSt.profileTabBtns["notes"] = tabNotes
8272  	tabNotesPK := pk
8273  	dom.AddEventListener(tabNotes, "click", dom.RegisterCallback(func() {
8274  		selectProfileTab("notes", tabNotesPK)
8275  	}))
8276  	dom.AppendChild(tabBar, tabNotes)
8277  
8278  	tabFollows := makeProtoBtn(t("follows"))
8279  	dom.SetStyle(tabFollows, "cursor", "pointer")
8280  	appSt.profileTabBtns["follows"] = tabFollows
8281  	tabFollowsPK := pk
8282  	dom.AddEventListener(tabFollows, "click", dom.RegisterCallback(func() {
8283  		selectProfileTab("follows", tabFollowsPK)
8284  	}))
8285  	dom.AppendChild(tabBar, tabFollows)
8286  
8287  	tabRelays := makeProtoBtn(t("relays"))
8288  	dom.SetStyle(tabRelays, "cursor", "pointer")
8289  	appSt.profileTabBtns["relays"] = tabRelays
8290  	tabRelaysPK := pk
8291  	dom.AddEventListener(tabRelays, "click", dom.RegisterCallback(func() {
8292  		selectProfileTab("relays", tabRelaysPK)
8293  	}))
8294  	dom.AppendChild(tabBar, tabRelays)
8295  
8296  	tabMutes := makeProtoBtn(t("mutes"))
8297  	dom.SetStyle(tabMutes, "cursor", "pointer")
8298  	appSt.profileTabBtns["mutes"] = tabMutes
8299  	tabMutesPK := pk
8300  	dom.AddEventListener(tabMutes, "click", dom.RegisterCallback(func() {
8301  		selectProfileTab("mutes", tabMutesPK)
8302  	}))
8303  	dom.AppendChild(tabBar, tabMutes)
8304  
8305  	dom.AppendChild(wrapper, tabBar)
8306  
8307  	// Tab content container.
8308  	appSt.profileTabContent = dom.CreateElement("div")
8309  	dom.SetStyle(appSt.profileTabContent, "padding", "8px 0")
8310  	dom.AppendChild(wrapper, appSt.profileTabContent)
8311  
8312  	// Restore or default tab.
8313  	appSt.profileTab = ""
8314  	if savedTab != "" {
8315  		selectProfileTab(savedTab, pk)
8316  	} else {
8317  		selectProfileTab("notes", pk)
8318  	}
8319  
8320  	// Save cache refs for this profile.
8321  	appSt.profileWrapTabCont[pk] = appSt.profileTabContent
8322  	appSt.profileWrapTabs[pk] = appSt.profileTabBtns
8323  	appSt.profileWrapTabSel[pk] = appSt.profileTab
8324  
8325  	_ = name // title shown via topBackBtn, not pageTitleEl
8326  }
8327  
8328  func toggleFollow(pk string, btn dom.Element, muteBtn dom.Element) {
8329  	if !signer.HasSigner() || appSt.pubhex == "" {
8330  		return
8331  	}
8332  	found := false
8333  	var newFollows []string
8334  	for _, fpk := range appSt.myFollows {
8335  		if fpk == pk {
8336  			found = true
8337  			continue
8338  		}
8339  		newFollows = mxutil.Ensure(newFollows, 1)
8340  		newFollows = push(newFollows, fpk)
8341  	}
8342  	if !found {
8343  		newFollows = mxutil.Ensure(newFollows, 1)
8344  		newFollows = push(newFollows, pk)
8345  	}
8346  	appSt.myFollows = newFollows
8347  	appSt.followSet = buildSet(newFollows)
8348  
8349  	if found {
8350  		dom.SetTextContent(btn, "follow")
8351  		dom.SetStyle(btn, "background", "var(--accent)")
8352  		dom.SetStyle(btn, "color", "#fff")
8353  		dom.SetStyle(btn, "border", "none")
8354  	} else {
8355  		dom.SetTextContent(btn, "followed")
8356  		dom.SetStyle(btn, "background", "var(--bg2)")
8357  		dom.SetStyle(btn, "color", "var(--fg)")
8358  		dom.SetStyle(btn, "border", "1px solid var(--border)")
8359  		// Following unmutes.
8360  		if isMuted(pk) {
8361  			unmute(pk, muteBtn)
8362  		}
8363  	}
8364  
8365  	// Bump contactTs so stale kind 3 events from slow relays are rejected.
8366  	appSt.contactTs = dom.NowSeconds()
8367  	publishFollowList(newFollows)
8368  	if appSt.feedSelectEl != 0 {
8369  		populateFeedSelect()
8370  	}
8371  	if appSt.feedMode == "follows" {
8372  		refreshFeed()
8373  	}
8374  }
8375  
8376  func publishFollowList(follows []string) {
8377  	tags := "["
8378  	for i, pk := range follows {
8379  		if i > 0 {
8380  			tags = tags | ","
8381  		}
8382  		tags = tags | "[\"p\"," | jstr(pk) | "]"
8383  	}
8384  	tags = tags | "]"
8385  	ts := dom.NowSeconds()
8386  	unsigned := "{\"kind\":3,\"content\":\"\"" |
8387  		",\"tags\":" | tags |
8388  		",\"created_at\":" | i64toa(ts) |
8389  		",\"pubkey\":\"" | appSt.pubhex | "\"}"
8390  	logActivity("follow", "publishing kind 3, " | itoa(len(follows)) | " follows", "calling signer.SignEvent")
8391  	signer.SignEvent(unsigned, func(signed string) {
8392  		if signed == "" {
8393  			logActivity("follow", "FAILED: signer returned empty", "vault locked or signer crashed")
8394  			return
8395  		}
8396  		targets := []string{:len(appSt.relayURLs)}
8397  		copy(targets, appSt.relayURLs)
8398  		for _, d := range discoveryRelays {
8399  			targets = appendUnique(targets, d)
8400  		}
8401  		msg := "[\"PUBLISH_TO\"," | signed | ",["
8402  		relayList := ""
8403  		for i, u := range targets {
8404  			if i > 0 {
8405  				msg = msg | ","
8406  				relayList = relayList | "\n"
8407  			}
8408  			msg = msg | jstr(u)
8409  			relayList = relayList | u
8410  		}
8411  		routeMsg(msg | "]]")
8412  		logActivity("follow", "kind 3 sent to " | itoa(len(targets)) | " relays", "relays:\n" | relayList | "\n\nevent:\n" | signed)
8413  	})
8414  }
8415  
8416  func isMuted(pk string) (ok bool) { return appSt.muteSet[pk] }
8417  
8418  func buildSet(pks []string) (m map[string]bool) {
8419  	s := map[string]bool{}
8420  	for _, pk := range pks {
8421  		s[pk] = true
8422  	}
8423  	return s
8424  }
8425  
8426  // broadcastFollows pushes the current follow list to Feed and Notif workers.
8427  // P_FOLLOWS is outbound from Profile Worker to Shell only; never send it inbound.
8428  func broadcastFollows(pks []string) {
8429  	j := buildJSONStrArr(pks)
8430  	feed.Send(`["F_SET_FOLLOWS",` | j | `]`)
8431  	notif.Send(`["N_SET_FOLLOWS",` | j | `]`)
8432  }
8433  
8434  // broadcastMutes pushes the current mute list to Feed and Notif workers.
8435  func broadcastMutes(pks []string) {
8436  	j := buildJSONStrArr(pks)
8437  	feed.Send(`["F_SET_MUTES",` | j | `]`)
8438  	notif.Send(`["N_SET_MUTES",` | j | `]`)
8439  }
8440  
8441  // buildJSONStrArr builds a JSON array of quoted strings.
8442  func buildJSONStrArr(pks []string) (s string) {
8443  	s = "["
8444  	for i, pk := range pks {
8445  		if i > 0 { s = s | "," }
8446  		s = s | jstr(pk)
8447  	}
8448  	return s | "]"
8449  }
8450  
8451  // buildURLsJSON builds a JSON array of quoted URL strings.
8452  func buildURLsJSON(urls []string) (s string) {
8453  	return buildJSONStrArr(urls)
8454  }
8455  
8456  // repliesToMuted returns true if ev is a reply whose parent "p" tag is a muted user.
8457  func repliesToMuted(ev *nostr.Event) (ok bool) {
8458  	if ev.Kind != 1 {
8459  		return false
8460  	}
8461  	hasE := false
8462  	for _, tag := range ev.Tags {
8463  		if len(tag) >= 2 && tag[0] == "e" {
8464  			hasE = true
8465  			break
8466  		}
8467  	}
8468  	if !hasE {
8469  		return false // not a reply
8470  	}
8471  	for _, tag := range ev.Tags {
8472  		if len(tag) >= 2 && tag[0] == "p" && isMuted(tag[1]) {
8473  			return true
8474  		}
8475  	}
8476  	return false
8477  }
8478  
8479  func toggleMute(pk string, btn dom.Element, followBtn dom.Element) {
8480  	if !signer.HasSigner() || appSt.pubhex == "" {
8481  		return
8482  	}
8483  	found := false
8484  	var newMutes []string
8485  	for _, m := range appSt.myMutes {
8486  		if m == pk {
8487  			found = true
8488  			continue
8489  		}
8490  		newMutes = mxutil.Ensure(newMutes, 1)
8491  		newMutes = push(newMutes, m)
8492  	}
8493  	if !found {
8494  		newMutes = mxutil.Ensure(newMutes, 1)
8495  		newMutes = push(newMutes, pk)
8496  	}
8497  	appSt.myMutes = newMutes
8498  	appSt.muteSet = buildSet(newMutes)
8499  
8500  	if found {
8501  		dom.SetTextContent(btn, "mute")
8502  		dom.SetStyle(btn, "background", "var(--bg2)")
8503  		dom.SetStyle(btn, "color", "var(--fg)")
8504  		dom.SetStyle(btn, "border", "1px solid var(--border)")
8505  		// Re-fetch profile notes now that user is unmuted.
8506  		if appSt.profileViewPK == pk && appSt.profileTab == "notes" && appSt.profileTabContent != 0 {
8507  			clearChildren(appSt.profileTabContent)
8508  			renderProfileNotes(pk)
8509  		}
8510  	} else {
8511  		dom.SetTextContent(btn, "muted")
8512  		dom.SetStyle(btn, "background", "#c33")
8513  		dom.SetStyle(btn, "color", "#fff")
8514  		dom.SetStyle(btn, "border", "none")
8515  		// Muting unfollows.
8516  		if isFollowing(pk) {
8517  			unfollow(pk, followBtn)
8518  		}
8519  		removeMutedNotes(pk)
8520  	}
8521  
8522  	appSt.muteTs = dom.NowSeconds()
8523  	publishMuteList(newMutes)
8524  }
8525  
8526  func unmute(pk string, btn dom.Element) {
8527  	var newMutes []string
8528  	for _, m := range appSt.myMutes {
8529  		if m != pk {
8530  			newMutes = mxutil.Ensure(newMutes, 1)
8531  			newMutes = push(newMutes, m)
8532  		}
8533  	}
8534  	appSt.myMutes = newMutes
8535  	appSt.muteSet = buildSet(newMutes)
8536  	dom.SetTextContent(btn, "mute")
8537  	dom.SetStyle(btn, "background", "var(--bg2)")
8538  	dom.SetStyle(btn, "color", "var(--fg)")
8539  	dom.SetStyle(btn, "border", "1px solid var(--border)")
8540  	appSt.muteTs = dom.NowSeconds()
8541  	publishMuteList(newMutes)
8542  }
8543  
8544  func isFollowing(pk string) (ok bool) { return appSt.followSet[pk] }
8545  
8546  func unfollow(pk string, btn dom.Element) {
8547  	var newFollows []string
8548  	for _, fpk := range appSt.myFollows {
8549  		if fpk != pk {
8550  			newFollows = mxutil.Ensure(newFollows, 1)
8551  			newFollows = push(newFollows, fpk)
8552  		}
8553  	}
8554  	appSt.myFollows = newFollows
8555  	appSt.followSet = buildSet(newFollows)
8556  	dom.SetTextContent(btn, "follow")
8557  	dom.SetStyle(btn, "background", "var(--accent)")
8558  	dom.SetStyle(btn, "color", "#fff")
8559  	dom.SetStyle(btn, "border", "none")
8560  	appSt.contactTs = dom.NowSeconds()
8561  	publishFollowList(newFollows)
8562  }
8563  
8564  func removeMutedNotes(pk string) {
8565  	// Remove from main feed DOM.
8566  	removeChildrenByPK(appSt.feedContainer, pk)
8567  	// Clear profile notes tab if viewing the muted user.
8568  	if appSt.profileViewPK == pk && appSt.profileTab == "notes" && appSt.profileTabContent != 0 {
8569  		closeProfileNoteSub()
8570  		clearChildren(appSt.profileTabContent)
8571  	}
8572  	// Purge from feed buffer so muted notes don't reappear on refresh.
8573  	if len(appSt.feedBuffer) > 0 {
8574  		var kept []*nostr.Event
8575  		for _, ev := range appSt.feedBuffer {
8576  			if ev.PubKey != pk {
8577  				kept = mxutil.Ensure(kept, 1)
8578  				kept = push(kept, ev)
8579  			}
8580  		}
8581  		appSt.feedBuffer = kept
8582  	}
8583  	// Purge from thread cache if a thread is open.
8584  	if appSt.threadOpen {
8585  		for id, ev := range appSt.threadEvents {
8586  			if ev.PubKey == pk {
8587  				delete(appSt.threadEvents, id)
8588  			}
8589  		}
8590  		appSt.threadLastRendered = 0
8591  		renderThreadTree()
8592  	}
8593  }
8594  
8595  func publishMuteList(mutes []string) {
8596  	tags := "["
8597  	for i, pk := range mutes {
8598  		if i > 0 {
8599  			tags = tags | ","
8600  		}
8601  		tags = tags | "[\"p\"," | jstr(pk) | "]"
8602  	}
8603  	tags = tags | "]"
8604  	ts := dom.NowSeconds()
8605  	unsigned := "{\"kind\":10000,\"content\":\"\"" |
8606  		",\"tags\":" | tags |
8607  		",\"created_at\":" | i64toa(ts) |
8608  		",\"pubkey\":\"" | appSt.pubhex | "\"}"
8609  	dom.ConsoleLog("[mute] signing kind 10000 with " | itoa(len(mutes)) | " entries")
8610  	signer.SignEvent(unsigned, func(signed string) {
8611  		if signed == "" {
8612  			dom.ConsoleLog("[mute] sign failed - signer returned empty")
8613  			return
8614  		}
8615  		dom.ConsoleLog("[mute] publishing kind 10000")
8616  		selected := composeSelectedRelays()
8617  		if len(selected) > 0 {
8618  			msg := "[\"PUBLISH_TO\"," | signed | ",["
8619  			for i, u := range selected {
8620  				if i > 0 {
8621  					msg = msg | ","
8622  				}
8623  				msg = msg | jstr(u)
8624  			}
8625  			routeMsg(msg | "]]")
8626  		} else {
8627  			routeMsg("[\"EVENT\"," | signed | "]")
8628  		}
8629  	})
8630  }
8631  
8632  func profileMetaRow(icon, text, link string) (e dom.Element) {
8633  	row := dom.CreateElement("div")
8634  	dom.SetStyle(row, "padding", "4px 0")
8635  	dom.SetStyle(row, "display", "flex")
8636  	dom.SetStyle(row, "alignItems", "center")
8637  	dom.SetStyle(row, "gap", "8px")
8638  
8639  	iconEl := dom.CreateElement("span")
8640  	dom.SetTextContent(iconEl, icon)
8641  	dom.AppendChild(row, iconEl)
8642  
8643  	if link != "" {
8644  		href := link
8645  		if strIndex(href, "://") < 0 {
8646  			href = "https://" | href
8647  		}
8648  		a := dom.CreateElement("a")
8649  		dom.SetAttribute(a, "href", href)
8650  		dom.SetAttribute(a, "target", "_blank")
8651  		dom.SetAttribute(a, "rel", "noopener")
8652  		dom.SetStyle(a, "color", "var(--accent)")
8653  		dom.SetStyle(a, "wordBreak", "break-all")
8654  		dom.SetTextContent(a, text)
8655  		dom.AppendChild(row, a)
8656  	} else {
8657  		span := dom.CreateElement("span")
8658  		dom.SetStyle(span, "color", "var(--fg)")
8659  		dom.SetTextContent(span, text)
8660  		dom.AppendChild(row, span)
8661  	}
8662  	return row
8663  }
8664  
8665  // --- Profile tab functions ---
8666  
8667  func closeProfileNoteSub() {
8668  	if appSt.activeProfileNoteSub != "" {
8669  		routeMsg("[\"CLOSE\"," | jstr(appSt.activeProfileNoteSub) | "]")
8670  		appSt.activeProfileNoteSub = ""
8671  	}
8672  }
8673  
8674  // refreshProfileTab re-renders the active tab if we're viewing this author's profile.
8675  func refreshProfileTab(pk string) {
8676  	if appSt.profileViewPK != pk || appSt.profileTab == "" {
8677  		return
8678  	}
8679  	// Force re-render by clearing current tab and re-selecting.
8680  	saved := appSt.profileTab
8681  	appSt.profileTab = ""
8682  	selectProfileTab(saved, pk)
8683  }
8684  
8685  func selectProfileTab(tab, pk string) {
8686  	if tab == appSt.profileTab {
8687  		return
8688  	}
8689  	closeProfileNoteSub()
8690  	appSt.profileTab = tab
8691  	clearChildren(appSt.profileTabContent)
8692  
8693  	for id, btn := range appSt.profileTabBtns {
8694  		if id == tab {
8695  			dom.SetStyle(btn, "background", "var(--accent)")
8696  			dom.SetStyle(btn, "color", "#fff")
8697  		} else {
8698  			dom.SetStyle(btn, "background", "transparent")
8699  			dom.SetStyle(btn, "color", "var(--fg)")
8700  		}
8701  	}
8702  
8703  	// Update URL hash to reflect active tab.
8704  	if !appSt.navPop && appSt.profileViewPK != "" {
8705  		npub := helpers.EncodeNpub(helpers.HexDecode(appSt.profileViewPK))
8706  		dom.ReplaceState("/p/" | npub | "#" | tab)
8707  	}
8708  
8709  	switch tab {
8710  	case "notes":
8711  		renderProfileNotes(pk)
8712  	case "follows":
8713  		renderProfileFollows(pk)
8714  	case "relays":
8715  		renderProfileRelays(pk)
8716  	case "mutes":
8717  		renderProfileMutes(pk)
8718  	}
8719  }
8720  
8721  func renderProfileNotes(pk string) {
8722  	appSt.profileNotesSeen = map[string]bool{}
8723  	appSt.profileSubCounter++
8724  	subID := "pn-" | itoa(appSt.profileSubCounter)
8725  	appSt.activeProfileNoteSub = subID
8726  	proxyRelays := buildProxy(pk)
8727  	routeMsg(buildProxyMsg(subID,
8728  		"{\"authors\":[" | jstr(pk) | "],\"kinds\":[1],\"limit\":20}",
8729  		proxyRelays))
8730  }
8731  
8732  func renderProfileNote(ev *nostr.Event) {
8733  	if appSt.profileTabContent == 0 || appSt.profileTab != "notes" {
8734  		return
8735  	}
8736  	if isMuted(ev.PubKey) {
8737  		return
8738  	}
8739  	note := dom.CreateElement("div")
8740  	dom.SetStyle(note, "borderBottom", "1px solid var(--border)")
8741  	dom.SetStyle(note, "padding", "12px 16px")
8742  
8743  	// Header row: author link (left) + relay widget + timestamp (right).
8744  	header := dom.CreateElement("div")
8745  	dom.SetStyle(header, "display", "flex")
8746  	dom.SetStyle(header, "alignItems", "center")
8747  	dom.SetStyle(header, "marginBottom", "4px")
8748  	dom.SetStyle(header, "maxWidth", "65ch")
8749  
8750  	authorLink := dom.CreateElement("div")
8751  	dom.SetStyle(authorLink, "display", "flex")
8752  	dom.SetStyle(authorLink, "alignItems", "center")
8753  	dom.SetStyle(authorLink, "gap", "8px")
8754  	dom.SetStyle(authorLink, "cursor", "pointer")
8755  	headerPK := ev.PubKey
8756  	dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() {
8757  		showProfile(headerPK)
8758  	}))
8759  
8760  	avatar := dom.CreateElement("img")
8761  	dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
8762  	dom.SetAttribute(avatar, "width", "24")
8763  	dom.SetAttribute(avatar, "height", "24")
8764  	dom.SetStyle(avatar, "borderRadius", "50%")
8765  	dom.SetStyle(avatar, "objectFit", "cover")
8766  	dom.SetStyle(avatar, "flexShrink", "0")
8767  
8768  	nameSpan := dom.CreateElement("span")
8769  	dom.SetStyle(nameSpan, "fontSize", "18px")
8770  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8771  	dom.SetStyle(nameSpan, "fontWeight", "bold")
8772  	dom.SetStyle(nameSpan, "color", "var(--fg)")
8773  
8774  	pk := ev.PubKey
8775  	if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
8776  		setMediaSrc(avatar, pic)
8777  		dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
8778  	} else {
8779  		dom.SetStyle(avatar, "display", "none")
8780  	}
8781  	if name, ok := appSt.authorNames[pk]; ok && name != "" {
8782  		dom.SetTextContent(nameSpan, name)
8783  	} else {
8784  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
8785  		if len(npub) > 20 {
8786  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
8787  		}
8788  	}
8789  
8790  	dom.AppendChild(authorLink, avatar)
8791  	dom.AppendChild(authorLink, nameSpan)
8792  	dom.AppendChild(header, authorLink)
8793  
8794  	appendClientTag(header, ev)
8795  
8796  	rw := relayWidget(ev.ID)
8797  	dom.SetStyle(rw, "marginLeft", "auto")
8798  	dom.AppendChild(header, rw)
8799  	if ev.CreatedAt > 0 {
8800  		tsEl := dom.CreateElement("span")
8801  		dom.SetTextContent(tsEl, formatTime(ev.CreatedAt))
8802  		dom.SetStyle(tsEl, "fontSize", "11px")
8803  		dom.SetStyle(tsEl, "color", "var(--muted)")
8804  		dom.SetStyle(tsEl, "cursor", "pointer")
8805  		dom.SetStyle(tsEl, "flexShrink", "0")
8806  		evID := ev.ID
8807  		evRootID := getRootID(ev)
8808  		if evRootID == "" {
8809  			evRootID = evID
8810  		}
8811  		dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() {
8812  			showNoteThread(evRootID, evID)
8813  		}))
8814  		dom.AppendChild(header, tsEl)
8815  	}
8816  	dom.AppendChild(note, header)
8817  
8818  	// Track author link for update when profile arrives.
8819  	if _, cached := appSt.authorNames[pk]; !cached {
8820  		appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
8821  		appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
8822  		appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], authorLink)
8823  		if !lookupFetchedK0(pk) {
8824  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
8825  		}
8826  	}
8827  
8828  	// Reply preview.
8829  	addReplyPreview(note, ev)
8830  
8831  	content := dom.CreateElement("div")
8832  	dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8833  	dom.SetStyle(content, "fontSize", "14px")
8834  	dom.SetStyle(content, "lineHeight", "1.5")
8835  	dom.SetStyle(content, "wordBreak", "break-word")
8836  	dom.SetStyle(content, "maxWidth", "65ch")
8837  	dom.SetStyle(content, "overflow", "hidden")
8838  	dom.SetStyle(content, "maxHeight", "18em")
8839  	setHTMLWithMedia(content, renderMarkdown(ev.Content))
8840  	dom.AppendChild(note, content)
8841  
8842  	more := makeShowMore(content)
8843  	dom.AppendChild(note, buildActionRow(ev, note, content, more))
8844  
8845  	dom.AppendChild(appSt.profileTabContent, note)
8846  	attachShowMore(content, more)
8847  	resolveEmbeds()
8848  }
8849  
8850  func renderProfileFollows(pk string) {
8851  	var follows []string
8852  	if pk == appSt.pubhex {
8853  		follows = appSt.myFollows
8854  	} else {
8855  		follows = appSt.profileFollowsCache[pk]
8856  	}
8857  	if len(follows) == 0 {
8858  		empty := dom.CreateElement("div")
8859  		dom.SetTextContent(empty, t("no_follows"))
8860  		dom.SetStyle(empty, "padding", "16px")
8861  		dom.SetStyle(empty, "color", "var(--muted)")
8862  		dom.SetStyle(empty, "fontSize", "13px")
8863  		dom.AppendChild(appSt.profileTabContent, empty)
8864  		return
8865  	}
8866  
8867  	countEl := dom.CreateElement("div")
8868  	dom.SetTextContent(countEl, itoa(len(follows)) | " " | t("following"))
8869  	dom.SetStyle(countEl, "padding", "8px 16px")
8870  	dom.SetStyle(countEl, "color", "var(--muted)")
8871  	dom.SetStyle(countEl, "fontSize", "12px")
8872  	dom.AppendChild(appSt.profileTabContent, countEl)
8873  
8874  	for i := 0; i < len(follows); i++ {
8875  		fpk := follows[i]
8876  		row := makeProfileRow(fpk)
8877  		dom.AppendChild(appSt.profileTabContent, row)
8878  	}
8879  	scheduleTabRetry()
8880  }
8881  
8882  func renderProfileRelays(pk string) {
8883  	relays := appSt.profileRelaysCache[pk]
8884  	if len(relays) == 0 {
8885  		empty := dom.CreateElement("div")
8886  		dom.SetTextContent(empty, t("no_relays"))
8887  		dom.SetStyle(empty, "padding", "16px")
8888  		dom.SetStyle(empty, "color", "var(--muted)")
8889  		dom.SetStyle(empty, "fontSize", "13px")
8890  		dom.AppendChild(appSt.profileTabContent, empty)
8891  		return
8892  	}
8893  
8894  	for i := 0; i < len(relays); i++ {
8895  		rURL := relays[i]
8896  		row := dom.CreateElement("div")
8897  		dom.SetStyle(row, "padding", "10px 16px")
8898  		dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
8899  		dom.SetStyle(row, "cursor", "pointer")
8900  		dom.SetStyle(row, "fontSize", "13px")
8901  
8902  		urlEl := dom.CreateElement("span")
8903  		dom.SetTextContent(urlEl, rURL)
8904  		dom.SetStyle(urlEl, "color", "var(--accent)")
8905  		dom.AppendChild(row, urlEl)
8906  
8907  		clickURL := rURL
8908  		dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
8909  			showRelayInfo(clickURL)
8910  		}))
8911  		dom.AppendChild(appSt.profileTabContent, row)
8912  	}
8913  }
8914  
8915  func renderProfileMutes(pk string) {
8916  	var mutes []string
8917  	if pk == appSt.pubhex {
8918  		mutes = appSt.myMutes
8919  	} else {
8920  		mutes = appSt.profileMutesCache[pk]
8921  	}
8922  	if len(mutes) == 0 {
8923  		empty := dom.CreateElement("div")
8924  		dom.SetTextContent(empty, t("no_mutes"))
8925  		dom.SetStyle(empty, "padding", "16px")
8926  		dom.SetStyle(empty, "color", "var(--muted)")
8927  		dom.SetStyle(empty, "fontSize", "13px")
8928  		dom.AppendChild(appSt.profileTabContent, empty)
8929  		return
8930  	}
8931  
8932  	countEl := dom.CreateElement("div")
8933  	dom.SetTextContent(countEl, itoa(len(mutes)) | " " | t("muted"))
8934  	dom.SetStyle(countEl, "padding", "8px 16px")
8935  	dom.SetStyle(countEl, "color", "var(--muted)")
8936  	dom.SetStyle(countEl, "fontSize", "12px")
8937  	dom.AppendChild(appSt.profileTabContent, countEl)
8938  
8939  	for i := 0; i < len(mutes); i++ {
8940  		mpk := mutes[i]
8941  		row := makeProfileRow(mpk)
8942  		dom.AppendChild(appSt.profileTabContent, row)
8943  	}
8944  	scheduleTabRetry()
8945  }
8946  
8947  func makeProfileRow(pk string) (e dom.Element) {
8948  	row := dom.CreateElement("div")
8949  	dom.SetStyle(row, "display", "flex")
8950  	dom.SetStyle(row, "alignItems", "center")
8951  	dom.SetStyle(row, "gap", "10px")
8952  	dom.SetStyle(row, "padding", "10px 16px")
8953  	dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
8954  	dom.SetStyle(row, "cursor", "pointer")
8955  
8956  	av := dom.CreateElement("img")
8957  	dom.SetAttribute(av, "referrerpolicy", "no-referrer")
8958  	dom.SetAttribute(av, "width", "32")
8959  	dom.SetAttribute(av, "height", "32")
8960  	dom.SetStyle(av, "borderRadius", "50%")
8961  	dom.SetStyle(av, "objectFit", "cover")
8962  	dom.SetStyle(av, "flexShrink", "0")
8963  	if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
8964  		setMediaSrc(av, pic)
8965  	} else {
8966  		dom.SetStyle(av, "display", "none")
8967  	}
8968  	dom.SetAttribute(av, "onerror", "this.style.display='none'")
8969  	dom.AppendChild(row, av)
8970  
8971  	nameSpan := dom.CreateElement("span")
8972  	dom.SetStyle(nameSpan, "fontSize", "14px")
8973  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
8974  	if name, ok := appSt.authorNames[pk]; ok && name != "" {
8975  		dom.SetTextContent(nameSpan, name)
8976  	} else {
8977  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
8978  		if len(npub) > 20 {
8979  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
8980  		}
8981  	}
8982  	dom.AppendChild(row, nameSpan)
8983  
8984  	rowPK := pk
8985  	dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
8986  		showProfile(rowPK)
8987  	}))
8988  
8989  	if _, cached := appSt.authorNames[pk]; !cached {
8990  		appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
8991  		appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], row)
8992  		if !lookupFetchedK0(pk) {
8993  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
8994  		}
8995  	}
8996  
8997  	return row
8998  }
8999  
9000  // --- Relay info page ---
9001  
9002  func showRelayInfo(url string) {
9003  	appSt.profileViewPK = ""
9004  	closeProfileNoteSub()
9005  	// Hide cached profile wrappers but keep them.
9006  	for _, w := range appSt.profileWrappers {
9007  		dom.SetStyle(w, "display", "none")
9008  	}
9009  	// Remove old relay info page if any.
9010  	if appSt.relayInfoEl != 0 {
9011  		dom.RemoveChild(appSt.profilePage, appSt.relayInfoEl)
9012  		appSt.relayInfoEl = 0
9013  	}
9014  	appSt.relayInfoEl = dom.CreateElement("div")
9015  	dom.AppendChild(appSt.profilePage, appSt.relayInfoEl)
9016  
9017  	hdr := dom.CreateElement("div")
9018  	dom.SetStyle(hdr, "display", "flex")
9019  	dom.SetStyle(hdr, "alignItems", "center")
9020  	dom.SetStyle(hdr, "gap", "10px")
9021  	dom.SetStyle(hdr, "padding", "16px")
9022  	dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
9023  
9024  	backBtn := dom.CreateElement("button")
9025  	dom.SetInnerHTML(backBtn, "&#x2190;")
9026  	dom.SetStyle(backBtn, "background", "none")
9027  	dom.SetStyle(backBtn, "border", "none")
9028  	dom.SetStyle(backBtn, "fontSize", "20px")
9029  	dom.SetStyle(backBtn, "cursor", "pointer")
9030  	dom.SetStyle(backBtn, "color", "var(--fg)")
9031  	dom.SetStyle(backBtn, "padding", "0")
9032  	dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() {
9033  		switchPage("feed")
9034  	}))
9035  	dom.AppendChild(hdr, backBtn)
9036  
9037  	urlEl := dom.CreateElement("span")
9038  	dom.SetTextContent(urlEl, url)
9039  	dom.SetStyle(urlEl, "fontWeight", "bold")
9040  	dom.SetStyle(urlEl, "fontSize", "14px")
9041  	dom.SetStyle(urlEl, "wordBreak", "break-all")
9042  	dom.AppendChild(hdr, urlEl)
9043  	dom.AppendChild(appSt.relayInfoEl, hdr)
9044  
9045  	loading := dom.CreateElement("div")
9046  	dom.SetTextContent(loading, t("loading"))
9047  	dom.SetStyle(loading, "padding", "16px")
9048  	dom.SetStyle(loading, "color", "var(--muted)")
9049  	dom.AppendChild(appSt.relayInfoEl, loading)
9050  
9051  	appSt.activePage = ""
9052  	switchPage("profile")
9053  	dom.SetTextContent(appSt.pageTitleEl, t("relay_info"))
9054  
9055  	// Convert wss→https for NIP-11 HTTP fetch.
9056  	httpURL := url
9057  	if len(httpURL) > 6 && httpURL[:6] == "wss://" {
9058  		httpURL = "https://" | httpURL[6:]
9059  	} else if len(httpURL) > 5 && httpURL[:5] == "ws://" {
9060  		httpURL = "http://" | httpURL[5:]
9061  	}
9062  
9063  	dom.FetchRelayInfo(httpURL, func(body string) {
9064  		dom.RemoveChild(appSt.profilePage, loading)
9065  		if body == "" {
9066  			errEl := dom.CreateElement("div")
9067  			dom.SetTextContent(errEl, t("relay_fail"))
9068  			dom.SetStyle(errEl, "padding", "16px")
9069  			dom.SetStyle(errEl, "color", "#e55")
9070  			dom.AppendChild(appSt.relayInfoEl, errEl)
9071  			return
9072  		}
9073  		renderRelayInfoBody(body)
9074  	})
9075  }
9076  
9077  func renderRelayInfoBody(body string) {
9078  	container := dom.CreateElement("div")
9079  	dom.SetStyle(container, "padding", "16px")
9080  
9081  	name := helpers.JsonGetString(body, "name")
9082  	desc := helpers.JsonGetString(body, "description")
9083  	pk := helpers.JsonGetString(body, "pubkey")
9084  	contact := helpers.JsonGetString(body, "contact")
9085  	software := helpers.JsonGetString(body, "software")
9086  	ver := helpers.JsonGetString(body, "version")
9087  
9088  	if name != "" {
9089  		el := dom.CreateElement("div")
9090  		dom.SetTextContent(el, name)
9091  		dom.SetStyle(el, "fontSize", "20px")
9092  		dom.SetStyle(el, "fontWeight", "bold")
9093  		dom.SetStyle(el, "marginBottom", "8px")
9094  		dom.SetStyle(el, "fontFamily", "system-ui, sans-serif")
9095  		dom.AppendChild(container, el)
9096  	}
9097  
9098  	if desc != "" {
9099  		el := dom.CreateElement("div")
9100  		dom.SetStyle(el, "fontSize", "14px")
9101  		dom.SetStyle(el, "lineHeight", "1.5")
9102  		dom.SetStyle(el, "marginBottom", "12px")
9103  		dom.SetStyle(el, "wordBreak", "break-word")
9104  		dom.AppendChild(container, el)
9105  		setHTMLWithMedia(el, renderMarkdown(desc))
9106  	}
9107  
9108  	if contact != "" {
9109  		dom.AppendChild(container, profileMetaRow("@", contact, ""))
9110  	}
9111  	if pk != "" {
9112  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
9113  		short := npub
9114  		if len(short) > 20 {
9115  			short = short[:16] | "..." | short[len(short)-8:]
9116  		}
9117  		dom.AppendChild(container, profileMetaRow("pk", short, ""))
9118  	}
9119  	if software != "" {
9120  		label := software
9121  		if ver != "" {
9122  			label = label | " " | ver
9123  		}
9124  		dom.AppendChild(container, profileMetaRow("sw", label, ""))
9125  	}
9126  
9127  	dom.AppendChild(appSt.relayInfoEl, container)
9128  }
9129  
9130  // --- Messaging ---
9131  
9132  func relayURLsJSON() (s string) {
9133  	msg := "["
9134  	for i, url := range appSt.relayURLs {
9135  		if i > 0 {
9136  			msg = msg | ","
9137  		}
9138  		msg = msg | jstr(url)
9139  	}
9140  	return msg | "]"
9141  }
9142  
9143  func readRelayURLsJSON() (s string) {
9144  	msg := "["
9145  	first := true
9146  	for i, url := range appSt.relayURLs {
9147  		role := "both"
9148  		if i < len(appSt.relayRole) {
9149  			role = appSt.relayRole[i]
9150  		}
9151  		if role == "write" {
9152  			continue
9153  		}
9154  		if !first {
9155  			msg = msg | ","
9156  		}
9157  		msg = msg | jstr(url)
9158  		first = false
9159  	}
9160  	return msg | "]"
9161  }
9162  
9163  func formatTime(ts int64) (s string) {
9164  	if ts == 0 {
9165  		return ""
9166  	}
9167  	d := dom.NowSeconds() - ts
9168  	if d < 0 {
9169  		d = 0
9170  	}
9171  	if d < 60 {
9172  		return "now"
9173  	}
9174  	if d < 3600 {
9175  		return itoa(int32(d/60)) | "m ago"
9176  	}
9177  	if d < 86400 {
9178  		return itoa(int32(d/3600)) | "h ago"
9179  	}
9180  	if d < 2592000 {
9181  		return itoa(int32(d/86400)) | "d ago"
9182  	}
9183  	if d < 31536000 {
9184  		return itoa(int32(d/2592000)) | "mo ago"
9185  	}
9186  	return itoa(int32(d/31536000)) | "y ago"
9187  }
9188  
9189  func dismissHoverCard() {
9190  	if appSt.hoverCardEl != 0 {
9191  		dom.Remove(appSt.hoverCardEl)
9192  		appSt.hoverCardEl = 0
9193  		appSt.hoverCardParent = 0
9194  		appSt.hoverCardPK = ""
9195  		appSt.hoverCardHover = false
9196  	}
9197  	if appSt.hoverCardTimer != 0 {
9198  		dom.ClearTimeout(appSt.hoverCardTimer)
9199  		appSt.hoverCardTimer = 0
9200  	}
9201  	if appSt.hoverCardDismiss != 0 {
9202  		dom.ClearTimeout(appSt.hoverCardDismiss)
9203  		appSt.hoverCardDismiss = 0
9204  	}
9205  }
9206  
9207  func scheduleDismissHoverCard() {
9208  	if appSt.hoverCardDismiss != 0 {
9209  		dom.ClearTimeout(appSt.hoverCardDismiss)
9210  	}
9211  	appSt.hoverCardDismiss = dom.SetTimeout(func() {
9212  		appSt.hoverCardDismiss = 0
9213  		if !appSt.hoverCardHover {
9214  			dismissHoverCard()
9215  		}
9216  	}, 300)
9217  }
9218  
9219  func showHoverCard(pk string, anchor dom.Element) {
9220  	if pk == appSt.pubhex {
9221  		return // don't show hover card for self
9222  	}
9223  	dismissHoverCard()
9224  	appSt.hoverCardPK = pk
9225  
9226  	card := dom.CreateElement("div")
9227  	dom.SetStyle(card, "position", "fixed")
9228  	dom.SetStyle(card, "zIndex", "2000")
9229  	dom.SetStyle(card, "width", "280px")
9230  	dom.SetStyle(card, "maxWidth", "calc(100vw - 16px)")
9231  	dom.SetStyle(card, "background", "var(--bg)")
9232  	dom.SetStyle(card, "border", "1px solid var(--border)")
9233  	dom.SetStyle(card, "borderRadius", "8px")
9234  	dom.SetStyle(card, "boxShadow", "0 4px 16px rgba(0,0,0,0.3)")
9235  	dom.SetStyle(card, "overflow", "hidden")
9236  	dom.SetStyle(card, "fontFamily", "'Fira Code', monospace")
9237  
9238  	// Banner.
9239  	content := appSt.profileContentCache[pk]
9240  	if content != "" {
9241  		banner := helpers.JsonGetString(content, "banner")
9242  		if banner != "" {
9243  			bannerEl := dom.CreateElement("img")
9244  			dom.SetAttribute(bannerEl, "referrerpolicy", "no-referrer")
9245  			setMediaSrc(bannerEl, banner)
9246  			dom.SetStyle(bannerEl, "width", "100%")
9247  			dom.SetStyle(bannerEl, "height", "80px")
9248  			dom.SetStyle(bannerEl, "objectFit", "cover")
9249  			dom.SetStyle(bannerEl, "display", "block")
9250  			dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'")
9251  			dom.AppendChild(card, bannerEl)
9252  		}
9253  	}
9254  
9255  	// Avatar + name row.
9256  	infoRow := dom.CreateElement("div")
9257  	dom.SetStyle(infoRow, "display", "flex")
9258  	dom.SetStyle(infoRow, "alignItems", "center")
9259  	dom.SetStyle(infoRow, "gap", "8px")
9260  	dom.SetStyle(infoRow, "padding", "8px 10px")
9261  
9262  	if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
9263  		av := dom.CreateElement("img")
9264  		dom.SetAttribute(av, "referrerpolicy", "no-referrer")
9265  		setMediaSrc(av, pic)
9266  		dom.SetAttribute(av, "width", "36")
9267  		dom.SetAttribute(av, "height", "36")
9268  		dom.SetStyle(av, "borderRadius", "50%")
9269  		dom.SetStyle(av, "objectFit", "cover")
9270  		dom.SetStyle(av, "flexShrink", "0")
9271  		dom.SetAttribute(av, "onerror", "this.style.display='none'")
9272  		dom.AppendChild(infoRow, av)
9273  	}
9274  
9275  	nameElw1 := dom.CreateElement("span")
9276  	dom.SetStyle(nameElw1, "fontWeight", "bold")
9277  	dom.SetStyle(nameElw1, "fontSize", "13px")
9278  	dom.SetStyle(nameElw1, "color", "var(--fg)")
9279  	if name, ok := appSt.authorNames[pk]; ok && name != "" {
9280  		dom.SetTextContent(nameElw1, name)
9281  	} else {
9282  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
9283  		if len(npub) > 20 {
9284  			dom.SetTextContent(nameElw1, npub[:12] | "..." | npub[len(npub)-4:])
9285  		}
9286  	}
9287  	dom.AppendChild(infoRow, nameElw1)
9288  	dom.AppendChild(card, infoRow)
9289  
9290  	// Button row: follow + mute.
9291  	if signer.HasSigner() && appSt.pubhex != "" {
9292  		btnRow := dom.CreateElement("div")
9293  		dom.SetStyle(btnRow, "display", "flex")
9294  		dom.SetStyle(btnRow, "gap", "6px")
9295  		dom.SetStyle(btnRow, "padding", "0 10px 8px")
9296  
9297  		followBtn := dom.CreateElement("button")
9298  		dom.SetStyle(followBtn, "padding", "4px 10px")
9299  		dom.SetStyle(followBtn, "fontFamily", "'Fira Code', monospace")
9300  		dom.SetStyle(followBtn, "fontSize", "11px")
9301  		dom.SetStyle(followBtn, "borderRadius", "4px")
9302  		dom.SetStyle(followBtn, "cursor", "pointer")
9303  		isFollowingw2 := false
9304  		if len(appSt.myFollows) > 0 {
9305  			for _, fpk := range appSt.myFollows {
9306  				if fpk == pk {
9307  					isFollowingw2 = true
9308  					break
9309  				}
9310  			}
9311  		}
9312  		if isFollowingw2 {
9313  			dom.SetTextContent(followBtn, "followed")
9314  			dom.SetStyle(followBtn, "background", "var(--bg2)")
9315  			dom.SetStyle(followBtn, "color", "var(--fg)")
9316  			dom.SetStyle(followBtn, "border", "1px solid var(--border)")
9317  		} else {
9318  			dom.SetTextContent(followBtn, "follow")
9319  			dom.SetStyle(followBtn, "background", "var(--accent)")
9320  			dom.SetStyle(followBtn, "color", "#fff")
9321  			dom.SetStyle(followBtn, "border", "none")
9322  		}
9323  		dom.AppendChild(btnRow, followBtn)
9324  
9325  		muteBtn := dom.CreateElement("button")
9326  		dom.SetStyle(muteBtn, "padding", "4px 10px")
9327  		dom.SetStyle(muteBtn, "fontFamily", "'Fira Code', monospace")
9328  		dom.SetStyle(muteBtn, "fontSize", "11px")
9329  		dom.SetStyle(muteBtn, "borderRadius", "4px")
9330  		dom.SetStyle(muteBtn, "cursor", "pointer")
9331  		if isMuted(pk) {
9332  			dom.SetTextContent(muteBtn, "muted")
9333  			dom.SetStyle(muteBtn, "background", "#c33")
9334  			dom.SetStyle(muteBtn, "color", "#fff")
9335  			dom.SetStyle(muteBtn, "border", "none")
9336  		} else {
9337  			dom.SetTextContent(muteBtn, "mute")
9338  			dom.SetStyle(muteBtn, "background", "var(--bg2)")
9339  			dom.SetStyle(muteBtn, "color", "var(--fg)")
9340  			dom.SetStyle(muteBtn, "border", "1px solid var(--border)")
9341  		}
9342  
9343  		hcPK := pk
9344  		hcFollow := followBtn
9345  		hcMute := muteBtn
9346  		dom.AddEventListener(followBtn, "click", dom.RegisterCallback(func() {
9347  			toggleFollow(hcPK, hcFollow, hcMute)
9348  		}))
9349  		dom.AddEventListener(muteBtn, "click", dom.RegisterCallback(func() {
9350  			toggleMute(hcPK, hcMute, hcFollow)
9351  		}))
9352  		dom.AppendChild(btnRow, muteBtn)
9353  
9354  		dom.AppendChild(card, btnRow)
9355  	}
9356  
9357  	appSt.hoverCardHover = false
9358  	dom.AddEventListener(card, "mouseenter", dom.RegisterCallback(func() {
9359  		appSt.hoverCardHover = true
9360  		if appSt.hoverCardDismiss != 0 {
9361  			dom.ClearTimeout(appSt.hoverCardDismiss)
9362  			appSt.hoverCardDismiss = 0
9363  		}
9364  	}))
9365  	dom.AddEventListener(card, "mouseleave", dom.RegisterCallback(func() {
9366  		appSt.hoverCardHover = false
9367  		scheduleDismissHoverCard()
9368  	}))
9369  
9370  	appSt.hoverCardEl = card
9371  	appSt.hoverCardParent = dom.Body()
9372  	dom.AppendChild(dom.Body(), card)
9373  	dom.GetBoundingRect(anchor, func(left, top, right, bottom, width, height int32) {
9374  		vw := dom.GetViewportWidth()
9375  		cardW := 280
9376  		if cardW > vw-16 {
9377  			cardW = vw - 16
9378  		}
9379  		x := left
9380  		if x+cardW > vw-8 {
9381  			x = vw - 8 - cardW
9382  		}
9383  		if x < 8 {
9384  			x = 8
9385  		}
9386  		dom.SetStyle(card, "left", itoa(x) | "px")
9387  		dom.SetStyle(card, "top", itoa(bottom) | "px")
9388  	})
9389  }
9390  
9391  func attachHoverCard(authorLink dom.Element, pk string) {
9392  	dom.SetStyle(authorLink, "position", "relative")
9393  	hcPK := pk
9394  	dom.AddEventListener(authorLink, "mouseenter", dom.RegisterCallback(func() {
9395  		if appSt.hoverCardTimer != 0 {
9396  			dom.ClearTimeout(appSt.hoverCardTimer)
9397  		}
9398  		if appSt.hoverCardDismiss != 0 {
9399  			dom.ClearTimeout(appSt.hoverCardDismiss)
9400  			appSt.hoverCardDismiss = 0
9401  		}
9402  		capPK := hcPK
9403  		capAnchor := authorLink
9404  		appSt.hoverCardTimer = dom.SetTimeout(func() {
9405  			appSt.hoverCardTimer = 0
9406  			showHoverCard(capPK, capAnchor)
9407  		}, 400)
9408  	}))
9409  	dom.AddEventListener(authorLink, "mouseleave", dom.RegisterCallback(func() {
9410  		if appSt.hoverCardTimer != 0 {
9411  			dom.ClearTimeout(appSt.hoverCardTimer)
9412  			appSt.hoverCardTimer = 0
9413  		}
9414  		scheduleDismissHoverCard()
9415  	}))
9416  }
9417  
9418  // relayWidget creates a hover tooltip showing which relays delivered a note.
9419  func relayWidget(evID string) (e dom.Element) {
9420  	wrap := dom.CreateElement("span")
9421  	dom.SetStyle(wrap, "position", "relative")
9422  	dom.SetStyle(wrap, "flexShrink", "0")
9423  	dom.SetStyle(wrap, "marginRight", "6px")
9424  
9425  	btn := dom.CreateElement("span")
9426  	dom.SetTextContent(btn, "\xf0\x9f\x93\xa1") // 📡
9427  	dom.SetStyle(btn, "fontSize", "10px")
9428  	dom.SetStyle(btn, "cursor", "pointer")
9429  	dom.SetStyle(btn, "opacity", "0.6")
9430  	dom.AppendChild(wrap, btn)
9431  
9432  	var tip dom.Element
9433  	capturedID := evID
9434  
9435  	dom.AddEventListener(wrap, "click", dom.RegisterCallback(func() {
9436  		showRelayModal(capturedID)
9437  	}))
9438  
9439  	dom.AddEventListener(wrap, "mouseenter", dom.RegisterCallback(func() {
9440  		if tip != 0 {
9441  			return
9442  		}
9443  		urls := appSt.eventRelays[capturedID]
9444  
9445  		tip = dom.CreateElement("div")
9446  		dom.SetStyle(tip, "position", "absolute")
9447  		dom.SetStyle(tip, "top", "22px")
9448  		dom.SetStyle(tip, "right", "0")
9449  		dom.SetStyle(tip, "zIndex", "1000")
9450  		dom.SetStyle(tip, "background", "var(--bg)")
9451  		dom.SetStyle(tip, "border", "1px solid var(--border)")
9452  		dom.SetStyle(tip, "borderRadius", "6px")
9453  		dom.SetStyle(tip, "padding", "6px 10px")
9454  		dom.SetStyle(tip, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)")
9455  		dom.SetStyle(tip, "fontSize", "11px")
9456  		dom.SetStyle(tip, "whiteSpace", "nowrap")
9457  		dom.SetStyle(tip, "fontFamily", "'Fira Code', monospace")
9458  		dom.SetStyle(tip, "pointerEvents", "none")
9459  
9460  		if len(urls) == 0 {
9461  			line := dom.CreateElement("div")
9462  			dom.SetStyle(line, "color", "var(--muted)")
9463  			dom.SetTextContent(line, "(no relay data)")
9464  			dom.AppendChild(tip, line)
9465  		} else {
9466  			for _, u := range urls {
9467  				line := dom.CreateElement("div")
9468  				dom.SetStyle(line, "padding", "1px 0")
9469  				dom.SetStyle(line, "color", "var(--fg)")
9470  				dom.SetTextContent(line, u)
9471  				dom.AppendChild(tip, line)
9472  			}
9473  		}
9474  		dom.AppendChild(wrap, tip)
9475  	}))
9476  
9477  	dom.AddEventListener(wrap, "mouseleave", dom.RegisterCallback(func() {
9478  		if tip != 0 {
9479  			dom.RemoveChild(wrap, tip)
9480  			tip = 0
9481  		}
9482  	}))
9483  
9484  	return wrap
9485  }
9486  
9487  func showRelayModal(evID string) {
9488  	overlay := dom.CreateElement("div")
9489  	dom.SetStyle(overlay, "position", "fixed")
9490  	dom.SetStyle(overlay, "top", "0")
9491  	dom.SetStyle(overlay, "left", "0")
9492  	dom.SetStyle(overlay, "width", "100%")
9493  	dom.SetStyle(overlay, "height", "100%")
9494  	dom.SetStyle(overlay, "background", "rgba(0,0,0,0.5)")
9495  	dom.SetStyle(overlay, "zIndex", "500")
9496  	dom.SetStyle(overlay, "display", "flex")
9497  	dom.SetStyle(overlay, "alignItems", "center")
9498  	dom.SetStyle(overlay, "justifyContent", "center")
9499  
9500  	modal := dom.CreateElement("div")
9501  	dom.SetStyle(modal, "background", "var(--bg)")
9502  	dom.SetStyle(modal, "border", "1px solid var(--border)")
9503  	dom.SetStyle(modal, "borderRadius", "8px")
9504  	dom.SetStyle(modal, "maxWidth", "90vw")
9505  	dom.SetStyle(modal, "maxHeight", "70vh")
9506  	dom.SetStyle(modal, "display", "flex")
9507  	dom.SetStyle(modal, "flexDirection", "column")
9508  	dom.SetStyle(modal, "boxShadow", "0 4px 24px rgba(0,0,0,0.3)")
9509  
9510  	// Header.
9511  	hdr := dom.CreateElement("div")
9512  	dom.SetStyle(hdr, "display", "flex")
9513  	dom.SetStyle(hdr, "alignItems", "center")
9514  	dom.SetStyle(hdr, "justifyContent", "space-between")
9515  	dom.SetStyle(hdr, "padding", "12px 16px")
9516  	dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
9517  
9518  	title := dom.CreateElement("span")
9519  	dom.SetTextContent(title, "note seen on:")
9520  	dom.SetStyle(title, "fontWeight", "bold")
9521  	dom.SetStyle(title, "fontSize", "14px")
9522  	dom.AppendChild(hdr, title)
9523  
9524  	closeBtn := dom.CreateElement("button")
9525  	dom.SetTextContent(closeBtn, "\xc3\x97") // ×
9526  	dom.SetStyle(closeBtn, "background", "transparent")
9527  	dom.SetStyle(closeBtn, "border", "none")
9528  	dom.SetStyle(closeBtn, "fontSize", "20px")
9529  	dom.SetStyle(closeBtn, "cursor", "pointer")
9530  	dom.SetStyle(closeBtn, "color", "var(--muted)")
9531  	dom.SetStyle(closeBtn, "padding", "0 4px")
9532  	dom.AppendChild(hdr, closeBtn)
9533  	dom.AppendChild(modal, hdr)
9534  
9535  	// Scrollable list - rebuilt by renderList() on open and on SEEN_ON updates.
9536  	list := dom.CreateElement("div")
9537  	dom.SetStyle(list, "overflowY", "auto")
9538  	dom.SetStyle(list, "padding", "12px 16px")
9539  	dom.AppendChild(modal, list)
9540  
9541  	renderList := func() {
9542  		dom.ReleaseChildren(list)
9543  		dom.SetInnerHTML(list, "")
9544  		urls := appSt.eventRelays[evID]
9545  		if len(urls) == 0 {
9546  			line := dom.CreateElement("div")
9547  			dom.SetStyle(line, "color", "var(--muted)")
9548  			dom.SetStyle(line, "fontSize", "13px")
9549  			dom.SetTextContent(line, "(no relay data yet - waiting for OK)")
9550  			dom.AppendChild(list, line)
9551  			return
9552  		}
9553  		for _, u := range urls {
9554  			row := dom.CreateElement("div")
9555  			dom.SetStyle(row, "padding", "6px 0")
9556  			dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
9557  			dom.SetStyle(row, "fontSize", "13px")
9558  			dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
9559  			dom.SetStyle(row, "wordBreak", "break-all")
9560  			dom.SetTextContent(row, u)
9561  			dom.AppendChild(list, row)
9562  		}
9563  	}
9564  	renderList()
9565  	appSt.seenOnSubs[evID] = renderList
9566  
9567  	// Rebroadcast button - publish this event to all write relays.
9568  	if appSt.pubhex != "" {
9569  		ev, _ := lookupEvent(evID)
9570  		if ev != nil {
9571  			footer := dom.CreateElement("div")
9572  			dom.SetStyle(footer, "padding", "8px 16px 12px")
9573  			dom.SetStyle(footer, "borderTop", "1px solid var(--border)")
9574  			rbtn := dom.CreateElement("span")
9575  			dom.SetTextContent(rbtn, "rebroadcast to all relays")
9576  			dom.SetStyle(rbtn, "cursor", "pointer")
9577  			dom.SetStyle(rbtn, "color", "var(--accent)")
9578  			dom.SetStyle(rbtn, "fontSize", "13px")
9579  			dom.SetStyle(rbtn, "fontFamily", "'Fira Code', monospace")
9580  			evJSON := ev.ToJSON()
9581  			capturedEvID := ev.ID
9582  			dom.AddEventListener(rbtn, "click", dom.RegisterCallback(func() {
9583  				dom.SetTextContent(rbtn, "sending...")
9584  				dom.SetStyle(rbtn, "color", "var(--muted)")
9585  				dom.SetStyle(rbtn, "cursor", "default")
9586  				msg := "[\"PUBLISH_TO\"," | evJSON | ",["
9587  				relayList := ""
9588  				for i, url := range appSt.relayURLs {
9589  					if i > 0 {
9590  						msg = msg | ","
9591  						relayList = relayList | "\n"
9592  					}
9593  					msg = msg | jstr(url)
9594  					relayList = relayList | url
9595  				}
9596  				routeMsg(msg | "]]")
9597  				logActivity("rebroadcast", capturedEvID[:12] | " to " | itoa(len(appSt.relayURLs)) | " relays", "event " | capturedEvID | "\nrelays:\n" | relayList)
9598  				dom.SetTimeout(func() {
9599  					dom.SetTextContent(rbtn, "sent \u2713")
9600  				}, 500)
9601  			}))
9602  			dom.AppendChild(footer, rbtn)
9603  			dom.AppendChild(modal, footer)
9604  		}
9605  	}
9606  
9607  	dom.AppendChild(overlay, modal)
9608  
9609  	closeFn := dom.RegisterCallback(func() {
9610  		delete(appSt.seenOnSubs, evID)
9611  		dom.RemoveChild(dom.Body(), overlay)
9612  	})
9613  	dom.AddEventListener(closeBtn, "click", closeFn)
9614  	dom.AddSelfEventListener(overlay, "click", closeFn)
9615  
9616  	dom.AppendChild(dom.Body(), overlay)
9617  }
9618  
9619  // --- Emoji data ---
9620  // emojiList is defined in emoji_data.mx (1761 entries from Unicode 16.0).
9621  
9622  type emojiEntry struct {
9623  	emoji string
9624  	name  string
9625  }
9626  
9627  
9628  func initEmoji() {
9629  	appSt.emojiByName = map[string]string{}
9630  	for _, e := range emojiList {
9631  		appSt.emojiByName[":" | e.name | ":"] = e.emoji
9632  	}
9633  }
9634  
9635  // replaceEmojiCodes replaces :name: shortcodes in text with emoji characters.
9636  func replaceEmojiCodes(s string) (sv string) {
9637  	out := ""
9638  	start := 0
9639  	i := 0
9640  	for i < len(s) {
9641  		if s[i] == ':' {
9642  			end := -1
9643  			for j := i + 1; j < len(s) && j < i+30; j++ {
9644  				if s[j] == ':' {
9645  					end = j
9646  					break
9647  				}
9648  				if s[j] == ' ' || s[j] == '\n' {
9649  					break
9650  				}
9651  			}
9652  			if end > 0 {
9653  				code := s[i : end+1]
9654  				if emoji, ok2 := appSt.emojiByName[code]; ok2 {
9655  					out = out | s[start:i] | emoji
9656  					i = end + 1
9657  					start = i
9658  					continue
9659  				}
9660  			}
9661  		}
9662  		i++
9663  	}
9664  	return out | s[start:]
9665  }
9666  
9667  // --- Action button row ---
9668  
9669  type zapRowPending struct {
9670  	row dom.Element
9671  	ev  *nostr.Event
9672  }
9673  
9674  // attachZapButton appends the zap button + total span to the end of an
9675  // action button row, and registers the total element for 9735 updates.
9676  func attachZapButton(row dom.Element, ev *nostr.Event) {
9677  	zapWrap := dom.CreateElement("span")
9678  	dom.SetStyle(zapWrap, "display", "inline-flex")
9679  	dom.SetStyle(zapWrap, "alignItems", "center")
9680  	dom.SetStyle(zapWrap, "gap", "4px")
9681  	zapBtn := actionBtn(svgZap)
9682  	capturedZapEv := ev
9683  	dom.AddEventListener(zapBtn, "click", dom.RegisterCallback(func() {
9684  		showZapModal(capturedZapEv)
9685  	}))
9686  	dom.AppendChild(zapWrap, zapBtn)
9687  	zapTotalEl := dom.CreateElement("span")
9688  	dom.SetStyle(zapTotalEl, "fontSize", "12px")
9689  	dom.SetStyle(zapTotalEl, "color", "var(--muted)")
9690  	dom.SetStyle(zapTotalEl, "whiteSpace", "nowrap")
9691  	if msats, ok := appSt.noteZapTotals[ev.ID]; ok && msats > 0 {
9692  		dom.SetTextContent(zapTotalEl, "\xe2\x9a\xa1 " | i64toa(msats/1000) | " sats")
9693  	}
9694  	dom.AppendChild(zapWrap, zapTotalEl)
9695  	appSt.noteZapEls[ev.ID] = zapTotalEl
9696  	moreEl := dom.QuerySelectorFrom(row, "[data-more]")
9697  	if moreEl != 0 {
9698  		dom.InsertBefore(row, zapWrap, moreEl)
9699  	} else {
9700  		dom.AppendChild(row, zapWrap)
9701  	}
9702  }
9703  
9704  // applyZapButtonsForAuthor injects zap buttons into any action rows that
9705  // were built before this author's lud16 was known.
9706  func applyZapButtonsForAuthor(pk string) {
9707  	pending, ok := appSt.pendingZapRows[pk]
9708  	if !ok {
9709  		return
9710  	}
9711  	delete(appSt.pendingZapRows, pk)
9712  	for _, p := range pending {
9713  		attachZapButton(p.row, p.ev)
9714  	}
9715  }
9716  
9717  func buildActionRow(ev *nostr.Event, noteEl dom.Element, contentEl dom.Element, moreEl dom.Element) (e dom.Element) {
9718  	wrap := dom.CreateElement("div")
9719  	dom.SetStyle(wrap, "marginTop", "8px")
9720  	dom.SetStyle(wrap, "maxWidth", "65ch")
9721  
9722  	// Cache the event for repost/quote content.
9723  	setEvent(ev.ID, ev)
9724  	fillEmbed(ev)
9725  
9726  	// Reaction display - own line above buttons.
9727  	reactDisplay := dom.CreateElement("div")
9728  	dom.SetStyle(reactDisplay, "display", "flex")
9729  	dom.SetStyle(reactDisplay, "flexWrap", "wrap")
9730  	dom.SetStyle(reactDisplay, "alignItems", "center")
9731  	dom.SetStyle(reactDisplay, "gap", "4px")
9732  	dom.SetStyle(reactDisplay, "fontSize", "12px")
9733  	dom.SetStyle(reactDisplay, "marginBottom", "4px")
9734  	if _, ok2 := appSt.noteReactionMap[ev.ID]; ok2 {
9735  		fillReactionDisplay(reactDisplay, ev.ID)
9736  	}
9737  	appSt.noteReactionEls[ev.ID] = reactDisplay
9738  	dom.AppendChild(wrap, reactDisplay)
9739  
9740  	// Button row.
9741  	row := dom.CreateElement("div")
9742  	dom.SetStyle(row, "display", "flex")
9743  	dom.SetStyle(row, "alignItems", "center")
9744  	dom.SetStyle(row, "gap", "16px")
9745  
9746  	// Reply button.
9747  	replyBtn := actionBtn(svgReply)
9748  	capturedEv1 := ev
9749  	capturedNote1 := noteEl
9750  	dom.AddEventListener(replyBtn, "click", dom.RegisterCallback(func() {
9751  		showEditor("reply", capturedEv1, capturedNote1)
9752  	}))
9753  	dom.AppendChild(row, replyBtn)
9754  
9755  	// Quote button.
9756  	quoteBtn := actionBtn(svgQuote)
9757  	capturedEv2 := ev
9758  	capturedNote2 := noteEl
9759  	dom.AddEventListener(quoteBtn, "click", dom.RegisterCallback(func() {
9760  		showEditor("quote", capturedEv2, capturedNote2)
9761  	}))
9762  	dom.AppendChild(row, quoteBtn)
9763  
9764  	// Repost button + counter.
9765  	repostWrap := dom.CreateElement("span")
9766  	dom.SetStyle(repostWrap, "display", "flex")
9767  	dom.SetStyle(repostWrap, "alignItems", "center")
9768  	dom.SetStyle(repostWrap, "gap", "4px")
9769  	repostBtn := actionBtn(svgRepost)
9770  	capturedEv3 := ev
9771  	dom.AddEventListener(repostBtn, "click", dom.RegisterCallback(func() {
9772  		onRepostClick(capturedEv3)
9773  	}))
9774  	dom.AppendChild(repostWrap, repostBtn)
9775  	repostCount := dom.CreateElement("span")
9776  	dom.SetStyle(repostCount, "fontSize", "12px")
9777  	dom.SetStyle(repostCount, "color", "var(--muted)")
9778  	if pks, ok := appSt.noteRepostCounts[ev.ID]; ok && len(pks) > 0 {
9779  		dom.SetTextContent(repostCount, itoa(len(pks)))
9780  	}
9781  	dom.AppendChild(repostWrap, repostCount)
9782  	appSt.noteRepostEls[ev.ID] = repostCount
9783  	dom.AppendChild(row, repostWrap)
9784  
9785  	// React button.
9786  	reactWrap := dom.CreateElement("span")
9787  	dom.SetStyle(reactWrap, "display", "inline-flex")
9788  	dom.SetStyle(reactWrap, "alignItems", "center")
9789  	reactBtn := actionBtn(svgReact)
9790  	capturedEv4 := ev
9791  	capturedWrap := reactWrap
9792  	dom.AddEventListener(reactBtn, "click", dom.RegisterCallback(func() {
9793  		showEmojiPicker(capturedEv4, capturedWrap)
9794  	}))
9795  	dom.AppendChild(reactWrap, reactBtn)
9796  	dom.AppendChild(row, reactWrap)
9797  
9798  	// Zap button - right of react. Only rendered if the author has a
9799  	// lightning address. If the profile isn't cached yet, queue this row so
9800  	// applyAuthorProfile can inject the button when it arrives.
9801  	authorHasLN := false
9802  	if content, ok2 := appSt.profileContentCache[ev.PubKey]; ok2 {
9803  		if helpers.JsonGetString(content, "lud16") != "" || helpers.JsonGetString(content, "lud06") != "" {
9804  			authorHasLN = true
9805  		}
9806  	}
9807  	if authorHasLN {
9808  		attachZapButton(row, ev)
9809  	} else {
9810  		appSt.pendingZapRows[ev.PubKey] = mxutil.Ensure(appSt.pendingZapRows[ev.PubKey], 1)
9811  		appSt.pendingZapRows[ev.PubKey] = mxutil.Ensure(appSt.pendingZapRows[ev.PubKey], 1)
9812  		appSt.pendingZapRows[ev.PubKey] = push(appSt.pendingZapRows[ev.PubKey], zapRowPending{row: row, ev: ev})
9813  	}
9814  
9815  	if moreEl != 0 {
9816  		dom.SetStyle(moreEl, "marginLeft", "auto")
9817  		dom.SetAttribute(moreEl, "data-more", "1")
9818  		dom.AppendChild(row, moreEl)
9819  	}
9820  	dom.AppendChild(wrap, row)
9821  
9822  	// Queue fetch for kind 6/7 if not already fetched.
9823  	queueActionFetch(ev.ID)
9824  
9825  	return wrap
9826  }
9827  
9828  func actionBtn(svgHTML string) (e dom.Element) {
9829  	btn := dom.CreateElement("span")
9830  	dom.SetInnerHTML(btn, svgHTML)
9831  	dom.SetStyle(btn, "cursor", "pointer")
9832  	dom.SetStyle(btn, "display", "inline-flex")
9833  	dom.SetStyle(btn, "alignItems", "center")
9834  	dom.SetStyle(btn, "color", "var(--muted)")
9835  	dom.SetStyle(btn, "opacity", "0.6")
9836  	dom.SetStyle(btn, "userSelect", "none")
9837  	capturedBtn := btn
9838  	dom.AddEventListener(btn, "mouseenter", dom.RegisterCallback(func() {
9839  		dom.SetStyle(capturedBtn, "opacity", "1")
9840  	}))
9841  	dom.AddEventListener(btn, "mouseleave", dom.RegisterCallback(func() {
9842  		dom.SetStyle(capturedBtn, "opacity", "0.5")
9843  	}))
9844  	return btn
9845  }
9846  
9847  // --- Reaction/repost display ---
9848  
9849  func fillReactionDisplay(el dom.Element, evID string) {
9850  	rm, ok := appSt.noteReactionMap[evID]
9851  	if !ok {
9852  		return
9853  	}
9854  	// Sort by count descending (simple selection sort).
9855  	type ec struct {
9856  		emoji string
9857  		count int32
9858  	}
9859  	var sorted []ec
9860  	for e, pks := range rm {
9861  		sorted = mxutil.Ensure(sorted, 1)
9862  		sorted = push(sorted, ec{e, len(pks)})
9863  	}
9864  	for i := 0; i < len(sorted); i++ {
9865  		for j := i + 1; j < len(sorted); j++ {
9866  			if sorted[j].count > sorted[i].count {
9867  				sorted[i], sorted[j] = sorted[j], sorted[i]
9868  			}
9869  		}
9870  	}
9871  	clearChildren(el)
9872  	for i, e := range sorted {
9873  		if i >= 8 {
9874  			more := dom.CreateElement("span")
9875  			dom.SetTextContent(more, "...")
9876  			dom.SetStyle(more, "color", "var(--muted)")
9877  			dom.AppendChild(el, more)
9878  			break
9879  		}
9880  		chip := dom.CreateElement("span")
9881  		dom.SetTextContent(chip, e.emoji | " " | itoa(e.count))
9882  		dom.SetStyle(chip, "whiteSpace", "nowrap")
9883  		dom.AppendChild(el, chip)
9884  	}
9885  }
9886  
9887  // --- Reaction/repost fetching ---
9888  
9889  func queueActionFetch(evID string) {
9890  	if appSt.actionFetchedIDs[evID] {
9891  		return
9892  	}
9893  	appSt.actionFetchedIDs[evID] = true
9894  	appSt.actionFetchQueue = mxutil.Ensure(appSt.actionFetchQueue, 1)
9895  	appSt.actionFetchQueue = push(appSt.actionFetchQueue, evID)
9896  	if appSt.actionFetchTimer != 0 {
9897  		dom.ClearTimeout(appSt.actionFetchTimer)
9898  	}
9899  	appSt.actionFetchTimer = dom.SetTimeout(func() {
9900  		appSt.actionFetchTimer = 0
9901  		flushActionFetch()
9902  	}, 400)
9903  }
9904  
9905  func flushActionFetch() {
9906  	if len(appSt.actionFetchQueue) == 0 {
9907  		return
9908  	}
9909  	queue := appSt.actionFetchQueue
9910  	appSt.actionFetchQueue = nil
9911  
9912  	const batchSize = 20
9913  	for i := 0; i < len(queue); i += batchSize {
9914  		end := i + batchSize
9915  		if end > len(queue) {
9916  			end = len(queue)
9917  		}
9918  		chunk := queue[i:end]
9919  		ids := "["
9920  		for j, id := range chunk {
9921  			if j > 0 {
9922  				ids = ids | ","
9923  			}
9924  			ids = ids | jstr(id)
9925  		}
9926  		ids = ids | "]"
9927  		appSt.actionSubCounter++
9928  		subID := "act-" | itoa(appSt.actionSubCounter)
9929  
9930  		var urls []string
9931  		seen := map[string]bool{}
9932  		for _, u := range appSt.relayURLs {
9933  			if !seen[u] {
9934  				seen[u] = true
9935  				urls = mxutil.Ensure(urls, 1)
9936  				urls = push(urls, u)
9937  			}
9938  		}
9939  		for _, u := range topRelays(4) {
9940  			if !seen[u] {
9941  				seen[u] = true
9942  				urls = mxutil.Ensure(urls, 1)
9943  				urls = push(urls, u)
9944  			}
9945  		}
9946  		routeMsg(buildProxyMsg(subID,
9947  			"{\"kinds\":[6,7,9735],\"#e\":" | ids | ",\"limit\":500}",
9948  			urls))
9949  	}
9950  }
9951  
9952  func handleActionEvent(ev *nostr.Event) {
9953  	if appSt.noteRepostCounts == nil || appSt.noteReactionMap == nil || appSt.noteRepostEls == nil || appSt.noteReactionEls == nil {
9954  		return
9955  	}
9956  	// Find which event this references.
9957  	var targetID string
9958  	for _, tag := range ev.Tags {
9959  		if len(tag) >= 2 && tag[0] == "e" {
9960  			targetID = tag[1]
9961  			break
9962  		}
9963  	}
9964  	if targetID == "" {
9965  		return
9966  	}
9967  
9968  	if ev.Kind == 6 {
9969  		pks := appSt.noteRepostCounts[targetID]
9970  		if pks == nil {
9971  			pks = map[string]bool{}
9972  			appSt.noteRepostCounts[targetID] = pks
9973  		}
9974  		if !pks[ev.PubKey] {
9975  			pks[ev.PubKey] = true
9976  			if el, ok2 := appSt.noteRepostEls[targetID]; ok2 {
9977  				dom.SetTextContent(el, itoa(len(pks)))
9978  			}
9979  		}
9980  	} else if ev.Kind == 9735 {
9981  		// Zap receipt: accumulate msat amount from the bolt11 tag (ground truth).
9982  		seen := appSt.noteZapSeen[targetID]
9983  		if seen == nil {
9984  			seen = map[string]bool{}
9985  			appSt.noteZapSeen[targetID] = seen
9986  		}
9987  		if seen[ev.ID] {
9988  			return
9989  		}
9990  		seen[ev.ID] = true
9991  		var bolt11 string
9992  		for _, tag := range ev.Tags {
9993  			if len(tag) >= 2 && tag[0] == "bolt11" {
9994  				bolt11 = tag[1]
9995  				break
9996  			}
9997  		}
9998  		if bolt11 == "" {
9999  			return
10000  		}
10001  		msats := parseBolt11Msat(bolt11)
10002  		if msats <= 0 {
10003  			return
10004  		}
10005  		cur := int64(0)
10006  		if v, ok2 := appSt.noteZapTotals[targetID]; ok2 {
10007  			cur = v
10008  		}
10009  		cur = cur + msats
10010  		appSt.noteZapTotals[targetID] = cur
10011  		if el, ok2 := appSt.noteZapEls[targetID]; ok2 {
10012  			dom.SetTextContent(el, "\xe2\x9a\xa1 " | i64toa(cur/1000) | " sats")
10013  		}
10014  	} else if ev.Kind == 7 {
10015  		emoji := ev.Content
10016  		if emoji == "" || emoji == " | " {
10017  			emoji = "\xe2\x9d\xa4\xef\xb8\x8f"
10018  		}
10019  		inner := appSt.noteReactionMap[targetID]
10020  		if inner == nil {
10021  			inner = map[string]map[string]bool{}
10022  		}
10023  		pks := inner[emoji]
10024  		if pks == nil {
10025  			pks = map[string]bool{}
10026  		}
10027  		if !pks[ev.PubKey] {
10028  			pks[ev.PubKey] = true
10029  			inner[emoji] = pks
10030  			appSt.noteReactionMap[targetID] = inner
10031  			if el, ok2 := appSt.noteReactionEls[targetID]; ok2 {
10032  				fillReactionDisplay(el, targetID)
10033  			}
10034  		}
10035  	}
10036  }
10037  
10038  // --- Repost ---
10039  
10040  func onRepostClick(ev *nostr.Event) {
10041  	if !signer.HasSigner() || appSt.pubhex == "" {
10042  		return
10043  	}
10044  	if !dom.Confirm("Repost this note?") {
10045  		return
10046  	}
10047  	relay := ""
10048  	if urls, ok := appSt.eventRelays[ev.ID]; ok && len(urls) > 0 {
10049  		relay = urls[0]
10050  	}
10051  	tags := "[[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | "],[\"p\"," | jstr(ev.PubKey) | "]]"
10052  	ts := dom.NowSeconds()
10053  	unsigned := "{\"kind\":6,\"content\":" | jstr(ev.ToJSON()) |
10054  		",\"tags\":" | tags |
10055  		",\"created_at\":" | i64toa(ts) |
10056  		",\"pubkey\":\"" | appSt.pubhex | "\"}"
10057  	signer.SignEvent(unsigned, func(signed string) {
10058  		if signed != "" {
10059  			selected := composeSelectedRelays()
10060  			if len(selected) > 0 {
10061  				msg := "[\"PUBLISH_TO\"," | signed | ",["
10062  				for i, u := range selected {
10063  					if i > 0 {
10064  						msg = msg | ","
10065  					}
10066  					msg = msg | jstr(u)
10067  				}
10068  				routeMsg(msg | "]]")
10069  			} else {
10070  				routeMsg("[\"EVENT\"," | signed | "]")
10071  			}
10072  			pks := appSt.noteRepostCounts[ev.ID]
10073  			if pks == nil {
10074  				pks = map[string]bool{}
10075  				appSt.noteRepostCounts[ev.ID] = pks
10076  			}
10077  			pks[appSt.pubhex] = true
10078  			if el, ok2 := appSt.noteRepostEls[ev.ID]; ok2 {
10079  				dom.SetTextContent(el, itoa(len(pks)))
10080  			}
10081  		}
10082  	})
10083  }
10084  
10085  // --- Compose editor (reply + quote) ---
10086  
10087  func editorDraftKey(mode string, evID string) (s string) {
10088  	return mode | ":" | evID
10089  }
10090  
10091  func showEditor(mode string, ev *nostr.Event, noteEl dom.Element) {
10092  	if !signer.HasSigner() || appSt.pubhex == "" {
10093  		return
10094  	}
10095  	if appSt.editorOpen {
10096  		closeEditor()
10097  	}
10098  	appSt.editorOpen = true
10099  	appSt.editorPreviewing = false
10100  	appSt.editorMode = mode
10101  	appSt.editorTargetEv = ev
10102  	appSt.editorNoteEl = noteEl
10103  
10104  	composer := dom.CreateElement("div")
10105  	dom.SetStyle(composer, "borderTop", "1px solid var(--border)")
10106  	dom.SetStyle(composer, "marginTop", "8px")
10107  	dom.SetStyle(composer, "paddingTop", "8px")
10108  	dom.SetStyle(composer, "maxWidth", "65ch")
10109  	dom.SetStyle(composer, "display", "flex")
10110  	dom.SetStyle(composer, "flexDirection", "column")
10111  	dom.SetStyle(composer, "gap", "8px")
10112  	appSt.editorComposer = composer
10113  
10114  	// Header row: mode label + preview toggle + close.
10115  	headerRow := dom.CreateElement("div")
10116  	dom.SetStyle(headerRow, "display", "flex")
10117  	dom.SetStyle(headerRow, "alignItems", "center")
10118  	dom.SetStyle(headerRow, "gap", "8px")
10119  
10120  	modeLabel := dom.CreateElement("span")
10121  	dom.SetStyle(modeLabel, "fontSize", "13px")
10122  	dom.SetStyle(modeLabel, "fontWeight", "bold")
10123  	dom.SetStyle(modeLabel, "color", "var(--muted)")
10124  	if mode == "reply" {
10125  		dom.SetTextContent(modeLabel, "Reply")
10126  	} else {
10127  		dom.SetTextContent(modeLabel, "Quote")
10128  	}
10129  	dom.AppendChild(headerRow, modeLabel)
10130  
10131  	editorImgBtn := dom.CreateElement("button")
10132  	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>`)
10133  	dom.SetStyle(editorImgBtn, "padding", "2px 6px")
10134  	dom.SetStyle(editorImgBtn, "border", "1px solid var(--border)")
10135  	dom.SetStyle(editorImgBtn, "borderRadius", "4px")
10136  	dom.SetStyle(editorImgBtn, "background", "transparent")
10137  	dom.SetStyle(editorImgBtn, "color", "var(--fg)")
10138  	dom.SetStyle(editorImgBtn, "cursor", "pointer")
10139  	dom.SetStyle(editorImgBtn, "display", "flex")
10140  	dom.SetStyle(editorImgBtn, "alignItems", "center")
10141  	dom.AddEventListener(editorImgBtn, "click", dom.RegisterCallback(func() {
10142  		dom.PickFileBase64("image/*,video/*", func(b64, mime string) {
10143  			if b64 != "" {
10144  				blossomUpload(b64, mime, appSt.editorTextarea)
10145  			}
10146  		})
10147  	}))
10148  	dom.AppendChild(headerRow, editorImgBtn)
10149  
10150  	previewBtn := dom.CreateElement("button")
10151  	dom.SetTextContent(previewBtn, "Preview")
10152  	dom.SetStyle(previewBtn, "padding", "2px 8px")
10153  	dom.SetStyle(previewBtn, "border", "1px solid var(--border)")
10154  	dom.SetStyle(previewBtn, "borderRadius", "4px")
10155  	dom.SetStyle(previewBtn, "background", "transparent")
10156  	dom.SetStyle(previewBtn, "color", "var(--fg)")
10157  	dom.SetStyle(previewBtn, "cursor", "pointer")
10158  	dom.SetStyle(previewBtn, "fontSize", "11px")
10159  	dom.AppendChild(headerRow, previewBtn)
10160  
10161  	closeBtn := dom.CreateElement("span")
10162  	dom.SetTextContent(closeBtn, "\u2715")
10163  	dom.SetStyle(closeBtn, "marginLeft", "auto")
10164  	dom.SetStyle(closeBtn, "cursor", "pointer")
10165  	dom.SetStyle(closeBtn, "color", "var(--muted)")
10166  	dom.SetStyle(closeBtn, "fontSize", "14px")
10167  	dom.AddEventListener(closeBtn, "click", dom.RegisterCallback(func() {
10168  		closeEditor()
10169  	}))
10170  	dom.AppendChild(headerRow, closeBtn)
10171  
10172  	dom.AppendChild(composer, headerRow)
10173  
10174  	ta := dom.CreateElement("textarea")
10175  	dom.SetStyle(ta, "width", "100%")
10176  	dom.SetStyle(ta, "minHeight", "80px")
10177  	dom.SetStyle(ta, "resize", "vertical")
10178  	dom.SetStyle(ta, "overflowY", "auto")
10179  	dom.SetStyle(ta, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
10180  	dom.SetStyle(ta, "fontSize", "14px")
10181  	dom.SetStyle(ta, "padding", "8px")
10182  	dom.SetStyle(ta, "border", "1px solid var(--border)")
10183  	dom.SetStyle(ta, "borderRadius", "4px")
10184  	dom.SetStyle(ta, "background", "var(--bg)")
10185  	dom.SetStyle(ta, "color", "var(--fg)")
10186  	dom.SetStyle(ta, "boxSizing", "border-box")
10187  	dom.SetAttribute(ta, "onkeydown", "if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();this.parentNode.querySelector('[data-publish-btn]').click()}")
10188  	appSt.editorTextarea = ta
10189  
10190  	// Restore draft or set initial content.
10191  	draftKey := editorDraftKey(mode, ev.ID)
10192  	if draft, ok2 := appSt.editorDrafts[draftKey]; ok2 {
10193  		dom.SetProperty(ta, "value", draft)
10194  	} else if mode == "quote" {
10195  		relay := ""
10196  		if urls, ok := appSt.eventRelays[ev.ID]; ok && len(urls) > 0 {
10197  			relay = urls[0]
10198  		}
10199  		var relays []string
10200  		if relay != "" {
10201  			relays = []string{relay}
10202  		}
10203  		nevent := helpers.EncodeNevent(ev.ID, relays, ev.PubKey)
10204  		dom.SetProperty(ta, "value", "nostr:" | nevent | "\n\n")
10205  	}
10206  
10207  	wireEmojiHandler(ta)
10208  	wireBlossomHandlers(ta)
10209  	wireMentionHandler(ta)
10210  	dom.AppendChild(composer, ta)
10211  
10212  	// Markdown preview area (hidden initially).
10213  	prevArea := dom.CreateElement("div")
10214  	dom.SetStyle(prevArea, "display", "none")
10215  	dom.SetStyle(prevArea, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
10216  	dom.SetStyle(prevArea, "fontSize", "14px")
10217  	dom.SetStyle(prevArea, "lineHeight", "1.5")
10218  	dom.SetStyle(prevArea, "padding", "8px")
10219  	dom.SetStyle(prevArea, "border", "1px solid var(--border)")
10220  	dom.SetStyle(prevArea, "borderRadius", "4px")
10221  	dom.SetStyle(prevArea, "minHeight", "80px")
10222  	dom.SetStyle(prevArea, "wordBreak", "break-word")
10223  	dom.SetStyle(prevArea, "overflowY", "auto")
10224  	appSt.editorPreviewEl = prevArea
10225  	dom.AppendChild(composer, prevArea)
10226  
10227  	// Preview toggle.
10228  	capturedTa := ta
10229  	capturedPrev := prevArea
10230  	capturedBtn := previewBtn
10231  	dom.AddEventListener(previewBtn, "click", dom.RegisterCallback(func() {
10232  		appSt.editorPreviewing = !appSt.editorPreviewing
10233  		if appSt.editorPreviewing {
10234  			text := dom.GetProperty(capturedTa, "value")
10235  			text = replaceEmojiCodes(text)
10236  			setHTMLWithMedia(capturedPrev, renderMarkdown(text))
10237  			resolveEmbeds()
10238  			dom.SetStyle(capturedTa, "display", "none")
10239  			dom.SetStyle(capturedPrev, "display", "block")
10240  			dom.SetTextContent(capturedBtn, "Edit")
10241  			dom.SetStyle(capturedBtn, "background", "var(--accent)")
10242  			dom.SetStyle(capturedBtn, "color", "#fff")
10243  			dom.SetStyle(capturedBtn, "border", "1px solid var(--accent)")
10244  		} else {
10245  			dom.SetStyle(capturedTa, "display", "")
10246  			dom.SetStyle(capturedPrev, "display", "none")
10247  			dom.SetTextContent(capturedBtn, "Preview")
10248  			dom.SetStyle(capturedBtn, "background", "transparent")
10249  			dom.SetStyle(capturedBtn, "color", "var(--fg)")
10250  			dom.SetStyle(capturedBtn, "border", "1px solid var(--border)")
10251  		}
10252  	}))
10253  
10254  	// Button row.
10255  	btnRow := dom.CreateElement("div")
10256  	dom.SetStyle(btnRow, "display", "flex")
10257  	dom.SetStyle(btnRow, "justifyContent", "flex-end")
10258  	dom.SetStyle(btnRow, "gap", "8px")
10259  
10260  	pubBtn := dom.CreateElement("button")
10261  	dom.SetTextContent(pubBtn, "Publish")
10262  	dom.SetAttribute(pubBtn, "data-publish-btn", "1")
10263  	dom.SetStyle(pubBtn, "padding", "5px 14px")
10264  	dom.SetStyle(pubBtn, "border", "none")
10265  	dom.SetStyle(pubBtn, "borderRadius", "4px")
10266  	dom.SetStyle(pubBtn, "background", "var(--accent)")
10267  	dom.SetStyle(pubBtn, "color", "#fff")
10268  	dom.SetStyle(pubBtn, "cursor", "pointer")
10269  	dom.SetStyle(pubBtn, "fontWeight", "bold")
10270  	dom.SetStyle(pubBtn, "fontSize", "13px")
10271  	dom.AddEventListener(pubBtn, "click", dom.RegisterCallback(func() {
10272  		publishEditorContent()
10273  	}))
10274  	dom.AppendChild(btnRow, pubBtn)
10275  
10276  	dom.AppendChild(composer, btnRow)
10277  	dom.AppendChild(noteEl, composer)
10278  	dom.Focus(ta)
10279  }
10280  
10281  func closeEditor() {
10282  	// Save draft before closing.
10283  	if appSt.editorTextarea != 0 && appSt.editorTargetEv != nil {
10284  		text := dom.GetProperty(appSt.editorTextarea, "value")
10285  		key := editorDraftKey(appSt.editorMode, appSt.editorTargetEv.ID)
10286  		if text != "" {
10287  			appSt.editorDrafts[key] = text
10288  		} else {
10289  			delete(appSt.editorDrafts, key)
10290  		}
10291  	}
10292  	if appSt.editorComposer != 0 && appSt.editorNoteEl != 0 {
10293  		dom.RemoveChild(appSt.editorNoteEl, appSt.editorComposer)
10294  		appSt.editorComposer = 0
10295  		appSt.editorNoteEl = 0
10296  	}
10297  	appSt.editorOpen = false
10298  	appSt.editorTargetEv = nil
10299  	appSt.editorTextarea = 0
10300  	appSt.editorPreviewEl = 0
10301  	appSt.editorPreviewing = false
10302  	closeEmojiAuto()
10303  	closeMentionPopup()
10304  }
10305  
10306  func publishEditorContent() {
10307  	dom.ConsoleLog("[publish-reply] enter")
10308  	if appSt.editorTargetEv == nil || appSt.editorTextarea == 0 {
10309  		dom.ConsoleLog("[publish-reply] no target or textarea")
10310  		return
10311  	}
10312  	content := dom.GetProperty(appSt.editorTextarea, "value")
10313  	dom.ConsoleLog("[publish-reply] content len=" | itoa(len(content)))
10314  	content = replaceEmojiCodes(content)
10315  	if content == "" {
10316  		dom.ConsoleLog("[publish-reply] empty after emoji replace")
10317  		return
10318  	}
10319  
10320  	ev := appSt.editorTargetEv
10321  	mode := appSt.editorMode
10322  	// Clear draft on publish.
10323  	delete(appSt.editorDrafts, editorDraftKey(mode, ev.ID))
10324  	closeEditor()
10325  
10326  	relay := ""
10327  	if urls, ok := appSt.eventRelays[ev.ID]; ok && len(urls) > 0 {
10328  		relay = urls[0]
10329  	}
10330  
10331  	// Build tags.
10332  	tags := "["
10333  	rootID := getRootID(ev)
10334  
10335  	if mode == "reply" {
10336  		// e-tags: root + reply.
10337  		if rootID != "" && rootID != ev.ID {
10338  			tags = tags | "[\"e\"," | jstr(rootID) | "," | jstr(relay) | ",\"root\"],"
10339  			tags = tags | "[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | ",\"reply\"]"
10340  		} else {
10341  			// Replying to a root note - it becomes the root, our reply points to it.
10342  			tags = tags | "[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | ",\"root\"]"
10343  		}
10344  		// p-tags: collect all users in the chain.
10345  		ptags := map[string]bool{}
10346  		for _, tag := range ev.Tags {
10347  			if len(tag) >= 2 && tag[0] == "p" && tag[1] != appSt.pubhex {
10348  				ptags[tag[1]] = true
10349  			}
10350  		}
10351  		if ev.PubKey != appSt.pubhex {
10352  			ptags[ev.PubKey] = true
10353  		}
10354  		for pk := range ptags {
10355  			tags = tags | ",[\"p\"," | jstr(pk) | "]"
10356  		}
10357  	} else {
10358  		// Quote: q-tag + p-tag for quoted author.
10359  		tags = tags | "[\"q\"," | jstr(ev.ID) | "]"
10360  		if ev.PubKey != appSt.pubhex {
10361  			tags = tags | ",[\"p\"," | jstr(ev.PubKey) | "]"
10362  		}
10363  	}
10364  	// Client tag.
10365  	if localstorage.GetItem("musiquay-client-tag") != "0" {
10366  		tags = tags | ",[\"client\",\"https://smesh.lol\"]"
10367  	}
10368  	tags = tags | "]"
10369  
10370  	ts := dom.NowSeconds()
10371  	unsigned := "{\"kind\":1,\"content\":" | jstr(content) |
10372  		",\"tags\":" | tags |
10373  		",\"created_at\":" | i64toa(ts) |
10374  		",\"pubkey\":\"" | appSt.pubhex | "\"}"
10375  	dom.ConsoleLog("[publish-reply] calling SignEvent")
10376  	signer.SignEvent(unsigned, func(signed string) {
10377  		if signed == "" {
10378  			logActivity("reply", "FAILED: signer error (vault locked?)", unsigned)
10379  			dom.Confirm("publish failed: signer error (vault locked?)")
10380  			return
10381  		}
10382  		// Publish to selected relays (or all if none selected).
10383  		selected := composeSelectedRelays()
10384  		preview := content
10385  		if len(preview) > 80 {
10386  			preview = preview[:80] | "..."
10387  		}
10388  		relayList := ""
10389  		for j, u := range selected {
10390  			if j > 0 {
10391  				relayList = relayList | "\n"
10392  			}
10393  			relayList = relayList | u
10394  		}
10395  		if len(selected) > 0 {
10396  			msg := "[\"PUBLISH_TO\"," | signed | ",["
10397  			for i, u := range selected {
10398  				if i > 0 {
10399  					msg = msg | ","
10400  				}
10401  				msg = msg | jstr(u)
10402  			}
10403  			routeMsg(msg | "]]")
10404  		} else {
10405  			routeMsg("[\"EVENT\"," | signed | "]")
10406  		}
10407  		logActivity("reply", preview, "relays:\n" | relayList | "\n\nevent:\n" | signed)
10408  		published := nostr.ParseEvent(signed)
10409  		if published != nil {
10410  			insertPublishedNote(published)
10411  		}
10412  	})
10413  }
10414  
10415  func publishDelete(targetID string) {
10416  	if !dom.Confirm("Delete this note?") {
10417  		return
10418  	}
10419  	ts := dom.NowSeconds()
10420  	unsigned := "{\"kind\":5,\"content\":\"\"" |
10421  		",\"tags\":[[\"e\"," | jstr(targetID) | "]]" |
10422  		",\"created_at\":" | i64toa(ts) |
10423  		",\"pubkey\":\"" | appSt.pubhex | "\"}"
10424  	signer.SignEvent(unsigned, func(signed string) {
10425  		if signed == "" {
10426  			return
10427  		}
10428  		delEv := nostr.ParseEvent(signed)
10429  		if delEv != nil {
10430  			appSt.pendingDeletes[delEv.ID] = targetID
10431  			delID := delEv.ID
10432  			dom.SetTimeout(func() {
10433  				delete(appSt.pendingDeletes, delID)
10434  			}, 30000)
10435  		}
10436  		routeMsg("[\"EVENT\"," | signed | "]")
10437  	})
10438  }
10439  
10440  func removeNoteFromDOM(evID string) {
10441  	el, ok := appSt.noteElements[evID]
10442  	if !ok {
10443  		return
10444  	}
10445  	dom.Remove(el)
10446  	delete(appSt.noteElements, evID)
10447  	delete(appSt.seenEvents, evID)
10448  }
10449  
10450  func fetchDeletesForFeed() {
10451  	// Collect event IDs from rendered notes. Presize from the map: push stops
10452  	// at the first slice's capacity, so a nil sink trapped once five notes had
10453  	// been rendered.
10454  	ids := []string{:0:len(appSt.noteElements)}
10455  	for id := range appSt.noteElements {
10456  		ids = mxutil.Ensure(ids, 1)
10457  		ids = push(ids, id)
10458  	}
10459  	if len(ids) == 0 {
10460  		return
10461  	}
10462  	// Build a filter for kind 5 events referencing these note IDs.
10463  	eList := jstr(ids[0])
10464  	for i := 1; i < len(ids); i++ {
10465  		eList = eList | "," | jstr(ids[i])
10466  	}
10467  	filter := "{\"kinds\":[5],\"#e\":[" | eList | "]}"
10468  	routeMsg(buildProxyMsg("del-scan", filter, feedRelays()))
10469  }
10470  
10471  func buildComposePage() {
10472  	appSt.composeTextarea = dom.CreateElement("textarea")
10473  	dom.SetAttribute(appSt.composeTextarea, "placeholder", "What's on your mind?")
10474  	dom.SetStyle(appSt.composeTextarea, "width", "100%")
10475  	dom.SetStyle(appSt.composeTextarea, "minHeight", "160px")
10476  	dom.SetStyle(appSt.composeTextarea, "flex", "1")
10477  	dom.SetStyle(appSt.composeTextarea, "padding", "12px")
10478  	dom.SetStyle(appSt.composeTextarea, "fontSize", "15px")
10479  	dom.SetStyle(appSt.composeTextarea, "lineHeight", "1.5")
10480  	dom.SetStyle(appSt.composeTextarea, "border", "1px solid var(--border)")
10481  	dom.SetStyle(appSt.composeTextarea, "borderRadius", "8px")
10482  	dom.SetStyle(appSt.composeTextarea, "background", "var(--bg)")
10483  	dom.SetStyle(appSt.composeTextarea, "color", "var(--fg)")
10484  	dom.SetStyle(appSt.composeTextarea, "resize", "vertical")
10485  	dom.SetStyle(appSt.composeTextarea, "overflowY", "auto")
10486  	dom.SetStyle(appSt.composeTextarea, "boxSizing", "border-box")
10487  	dom.SetStyle(appSt.composeTextarea, "fontFamily", "inherit")
10488  	dom.SetAttribute(appSt.composeTextarea, "onkeydown", "if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();this.parentNode.querySelector('[data-publish-btn]').click()}")
10489  	dom.AppendChild(appSt.composePage, appSt.composeTextarea)
10490  
10491  	// Preview area (hidden by default).
10492  	appSt.composePreview = dom.CreateElement("div")
10493  	dom.SetStyle(appSt.composePreview, "display", "none")
10494  	dom.SetStyle(appSt.composePreview, "padding", "12px")
10495  	dom.SetStyle(appSt.composePreview, "border", "1px solid var(--border)")
10496  	dom.SetStyle(appSt.composePreview, "borderRadius", "8px")
10497  	dom.SetStyle(appSt.composePreview, "marginTop", "8px")
10498  	dom.SetStyle(appSt.composePreview, "lineHeight", "1.5")
10499  	dom.SetStyle(appSt.composePreview, "wordBreak", "break-word")
10500  	dom.AppendChild(appSt.composePage, appSt.composePreview)
10501  
10502  	// Button row.
10503  	btnRow := dom.CreateElement("div")
10504  	dom.SetStyle(btnRow, "display", "flex")
10505  	dom.SetStyle(btnRow, "gap", "8px")
10506  	dom.SetStyle(btnRow, "marginTop", "12px")
10507  	dom.SetStyle(btnRow, "alignItems", "center")
10508  
10509  	// Relay selector button (left side).
10510  	relaySelWrap := dom.CreateElement("div")
10511  	dom.SetStyle(relaySelWrap, "position", "relative")
10512  	dom.SetStyle(relaySelWrap, "display", "flex")
10513  	dom.SetStyle(relaySelWrap, "alignItems", "center")
10514  	dom.SetStyle(relaySelWrap, "gap", "6px")
10515  
10516  	relaySelBtn := dom.CreateElement("button")
10517  	dom.SetStyle(relaySelBtn, "padding", "8px 16px")
10518  	dom.SetStyle(relaySelBtn, "border", "1px solid var(--border)")
10519  	dom.SetStyle(relaySelBtn, "borderRadius", "6px")
10520  	dom.SetStyle(relaySelBtn, "background", "transparent")
10521  	dom.SetStyle(relaySelBtn, "color", "var(--fg)")
10522  	dom.SetStyle(relaySelBtn, "cursor", "pointer")
10523  	updateRelaySelBtn(relaySelBtn, false)
10524  
10525  	composeWarnBadge := makeRelayWarnBadge()
10526  
10527  	appSt.composeRelayPopEl = dom.CreateElement("div")
10528  	dom.SetStyle(appSt.composeRelayPopEl, "display", "none")
10529  	dom.SetStyle(appSt.composeRelayPopEl, "position", "absolute")
10530  	dom.SetStyle(appSt.composeRelayPopEl, "bottom", "100%")
10531  	dom.SetStyle(appSt.composeRelayPopEl, "left", "0")
10532  	dom.SetStyle(appSt.composeRelayPopEl, "marginBottom", "4px")
10533  	dom.SetStyle(appSt.composeRelayPopEl, "background", "var(--bg)")
10534  	dom.SetStyle(appSt.composeRelayPopEl, "border", "1px solid var(--border)")
10535  	dom.SetStyle(appSt.composeRelayPopEl, "borderRadius", "6px")
10536  	dom.SetStyle(appSt.composeRelayPopEl, "padding", "8px")
10537  	dom.SetStyle(appSt.composeRelayPopEl, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)")
10538  	dom.SetStyle(appSt.composeRelayPopEl, "zIndex", "1000")
10539  	dom.SetStyle(appSt.composeRelayPopEl, "minWidth", "200px")
10540  	dom.SetStyle(appSt.composeRelayPopEl, "maxWidth", "calc(100vw - 24px)")
10541  	dom.SetStyle(appSt.composeRelayPopEl, "maxHeight", "200px")
10542  	dom.SetStyle(appSt.composeRelayPopEl, "overflowY", "auto")
10543  
10544  	dom.AddEventListener(relaySelBtn, "click", dom.RegisterCallback(func() {
10545  		if appSt.composeRelayPopOpen {
10546  			dom.SetStyle(appSt.composeRelayPopEl, "display", "none")
10547  			appSt.composeRelayPopOpen = false
10548  			updateRelaySelBtn(relaySelBtn, false)
10549  		} else {
10550  			populateRelayPopover(appSt.composeRelayPopEl, func() {
10551  				updateRelaySelBtn(relaySelBtn, true)
10552  				updateRelayWarnBadge(composeWarnBadge)
10553  			})
10554  			dom.SetStyle(appSt.composeRelayPopEl, "display", "block")
10555  			appSt.composeRelayPopOpen = true
10556  			updateRelaySelBtn(relaySelBtn, true)
10557  		}
10558  	}))
10559  
10560  	dom.AppendChild(relaySelWrap, relaySelBtn)
10561  	dom.AppendChild(relaySelWrap, composeWarnBadge)
10562  	dom.AppendChild(relaySelWrap, appSt.composeRelayPopEl)
10563  	dom.AppendChild(btnRow, relaySelWrap)
10564  
10565  	// Spacer.
10566  	spacer := dom.CreateElement("div")
10567  	dom.SetStyle(spacer, "flex", "1")
10568  	dom.AppendChild(btnRow, spacer)
10569  
10570  	// Image upload button.
10571  	imgBtn := dom.CreateElement("button")
10572  	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>`)
10573  	dom.SetStyle(imgBtn, "padding", "8px 12px")
10574  	dom.SetStyle(imgBtn, "border", "1px solid var(--border)")
10575  	dom.SetStyle(imgBtn, "borderRadius", "6px")
10576  	dom.SetStyle(imgBtn, "background", "transparent")
10577  	dom.SetStyle(imgBtn, "color", "var(--fg)")
10578  	dom.SetStyle(imgBtn, "cursor", "pointer")
10579  	dom.SetStyle(imgBtn, "display", "flex")
10580  	dom.SetStyle(imgBtn, "alignItems", "center")
10581  	dom.AddEventListener(imgBtn, "click", dom.RegisterCallback(func() {
10582  		dom.PickFileBase64("image/*,video/*", func(b64, mime string) {
10583  			if b64 != "" {
10584  				blossomUpload(b64, mime, appSt.composeTextarea)
10585  			}
10586  		})
10587  	}))
10588  	dom.AppendChild(btnRow, imgBtn)
10589  
10590  	// Preview toggle.
10591  	previewBtn := dom.CreateElement("button")
10592  	dom.SetTextContent(previewBtn, "preview")
10593  	dom.SetStyle(previewBtn, "padding", "8px 16px")
10594  	dom.SetStyle(previewBtn, "border", "1px solid var(--border)")
10595  	dom.SetStyle(previewBtn, "borderRadius", "6px")
10596  	dom.SetStyle(previewBtn, "background", "transparent")
10597  	dom.SetStyle(previewBtn, "color", "var(--fg)")
10598  	dom.SetStyle(previewBtn, "cursor", "pointer")
10599  	composePreviewing := false
10600  	dom.AddEventListener(previewBtn, "click", dom.RegisterCallback(func() {
10601  		content := dom.GetProperty(appSt.composeTextarea, "value")
10602  		if !composePreviewing {
10603  			dom.SetStyle(appSt.composePreview, "display", "block")
10604  			dom.SetStyle(appSt.composeTextarea, "display", "none")
10605  			setHTMLWithMedia(appSt.composePreview, renderMarkdown(content))
10606  			dom.SetTextContent(previewBtn, "edit")
10607  			composePreviewing = true
10608  		} else {
10609  			dom.SetStyle(appSt.composePreview, "display", "none")
10610  			dom.SetStyle(appSt.composeTextarea, "display", "block")
10611  			dom.SetTextContent(previewBtn, "preview")
10612  			composePreviewing = false
10613  		}
10614  	}))
10615  	dom.AppendChild(btnRow, previewBtn)
10616  
10617  	// Publish button.
10618  	pubBtn := dom.CreateElement("button")
10619  	dom.SetTextContent(pubBtn, "publish")
10620  	dom.SetAttribute(pubBtn, "data-publish-btn", "1")
10621  	dom.SetStyle(pubBtn, "padding", "8px 20px")
10622  	dom.SetStyle(pubBtn, "border", "none")
10623  	dom.SetStyle(pubBtn, "borderRadius", "6px")
10624  	dom.SetStyle(pubBtn, "background", "var(--accent)")
10625  	dom.SetStyle(pubBtn, "color", "#fff")
10626  	dom.SetStyle(pubBtn, "cursor", "pointer")
10627  	dom.SetStyle(pubBtn, "fontWeight", "bold")
10628  	dom.AddEventListener(pubBtn, "click", dom.RegisterCallback(func() {
10629  		dom.ConsoleLog("[publish-compose] enter")
10630  		content := dom.GetProperty(appSt.composeTextarea, "value")
10631  		dom.ConsoleLog("[publish-compose] content len=" | itoa(len(content)))
10632  		content = replaceEmojiCodes(content)
10633  		if content == "" {
10634  			dom.ConsoleLog("[publish-compose] empty")
10635  			return
10636  		}
10637  		dom.ConsoleLog("[publish-compose] hasSigner=" | boolStr(signer.HasSigner()) | " pubhex=" | appSt.pubhex)
10638  		if !signer.HasSigner() || appSt.pubhex == "" {
10639  			dom.ConsoleLog("[publish-compose] no signer or pubhex")
10640  			return
10641  		}
10642  		ts := dom.NowSeconds()
10643  		tags := "["
10644  		if localstorage.GetItem("musiquay-client-tag") != "0" {
10645  			tags = tags | "[\"client\",\"https://smesh.lol\"]"
10646  		}
10647  		tags = tags | "]"
10648  		unsigned := "{\"kind\":1,\"content\":" | jstr(content) |
10649  			",\"tags\":" | tags |
10650  			",\"created_at\":" | i64toa(ts) |
10651  			",\"pubkey\":\"" | appSt.pubhex | "\"}"
10652  		dom.ConsoleLog("[publish-compose] calling SignEvent")
10653  		capturedContent := content
10654  		signer.SignEvent(unsigned, func(signed string) {
10655  			if signed == "" {
10656  				logActivity("publish", "FAILED: signer error", unsigned)
10657  				return
10658  			}
10659  			// Build relay list from checked relays.
10660  			selected := composeSelectedRelays()
10661  			preview := capturedContent
10662  			if len(preview) > 80 {
10663  				preview = preview[:80] | "..."
10664  			}
10665  			relayList := ""
10666  			for j, u := range selected {
10667  				if j > 0 {
10668  					relayList = relayList | "\n"
10669  				}
10670  				relayList = relayList | u
10671  			}
10672  			if len(selected) > 0 {
10673  				msg := "[\"PUBLISH_TO\"," | signed | ",["
10674  				for i, u := range selected {
10675  					if i > 0 {
10676  						msg = msg | ","
10677  					}
10678  					msg = msg | jstr(u)
10679  				}
10680  				routeMsg(msg | "]]")
10681  			} else {
10682  				routeMsg("[\"EVENT\"," | signed | "]")
10683  			}
10684  			logActivity("publish", preview, "relays:\n" | relayList | "\n\nevent:\n" | signed)
10685  			published := nostr.ParseEvent(signed)
10686  			if published != nil {
10687  				insertPublishedNote(published)
10688  			}
10689  			dom.SetProperty(appSt.composeTextarea, "value", "")
10690  			dom.SetStyle(appSt.composePreview, "display", "none")
10691  			dom.SetStyle(appSt.composeTextarea, "display", "block")
10692  			appSt.composeRelayPopOpen = false
10693  			dom.SetStyle(appSt.composeRelayPopEl, "display", "none")
10694  			dom.SetProperty(appSt.contentArea, "scrollTop", "0")
10695  			switchPage("feed")
10696  		})
10697  	}))
10698  	dom.AppendChild(btnRow, pubBtn)
10699  	dom.AppendChild(appSt.composePage, btnRow)
10700  
10701  	// Emoji autocomplete popup.
10702  	appSt.emojiAutoEl = dom.CreateElement("div")
10703  	dom.SetStyle(appSt.emojiAutoEl, "display", "none")
10704  	dom.SetStyle(appSt.emojiAutoEl, "position", "fixed")
10705  	dom.SetStyle(appSt.emojiAutoEl, "background", "var(--bg)")
10706  	dom.SetStyle(appSt.emojiAutoEl, "border", "1px solid var(--border)")
10707  	dom.SetStyle(appSt.emojiAutoEl, "borderRadius", "6px")
10708  	dom.SetStyle(appSt.emojiAutoEl, "padding", "4px")
10709  	dom.SetStyle(appSt.emojiAutoEl, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)")
10710  	dom.SetStyle(appSt.emojiAutoEl, "zIndex", "2000")
10711  	dom.SetStyle(appSt.emojiAutoEl, "maxHeight", "200px")
10712  	dom.SetStyle(appSt.emojiAutoEl, "overflowY", "auto")
10713  	dom.SetStyle(appSt.emojiAutoEl, "minWidth", "200px")
10714  	dom.AppendChild(dom.Body(), appSt.emojiAutoEl)
10715  
10716  	wireEmojiHandler(appSt.composeTextarea)
10717  	wireBlossomHandlers(appSt.composeTextarea)
10718  	wireMentionHandler(appSt.composeTextarea)
10719  }
10720  
10721  func wireEmojiHandler(ta dom.Element) {
10722  	dom.AddEventListener(ta, "input", dom.RegisterCallback(func() {
10723  		checkEmojiAutocomplete(ta)
10724  	}))
10725  }
10726  
10727  func wireBlossomHandlers(ta dom.Element) {
10728  	upload := func(b64 string, mime string) {
10729  		blossomUpload(b64, mime, ta)
10730  	}
10731  	dom.OnPasteImage(ta, upload)
10732  	dom.OnDropImage(ta, upload)
10733  }
10734  
10735  func blossomUpload(b64data string, mime string, ta dom.Element) {
10736  	if appSt.blossomServerURL == "" {
10737  		dom.ConsoleLog("blossom: no server configured")
10738  		return
10739  	}
10740  	raw := helpers.Base64Decode(b64data)
10741  	if len(raw) == 0 {
10742  		return
10743  	}
10744  	hash := sha256.Sum(raw)
10745  	hashHex := helpers.HexEncode(hash[:])
10746  	ext := ".bin"
10747  	if mime == "image/png" {
10748  		ext = ".png"
10749  	} else if mime == "image/jpeg" || mime == "image/jpg" {
10750  		ext = ".jpg"
10751  	} else if mime == "image/gif" {
10752  		ext = ".gif"
10753  	} else if mime == "image/webp" {
10754  		ext = ".webp"
10755  	} else if mime == "image/svg+xml" {
10756  		ext = ".svg"
10757  	} else if mime == "video/mp4" {
10758  		ext = ".mp4"
10759  	} else if mime == "video/webm" {
10760  		ext = ".webm"
10761  	} else if mime == "video/quicktime" {
10762  		ext = ".mov"
10763  	} else if mime == "video/x-matroska" {
10764  		ext = ".mkv"
10765  	}
10766  	placeholder := "\xe2\x8f\xb3 uploading to " | appSt.blossomServerURL | "..."
10767  	val := dom.GetProperty(ta, "value")
10768  	if len(val) > 0 && val[len(val)-1] != '\n' {
10769  		val = val | "\n"
10770  	}
10771  	dom.SetProperty(ta, "value", val | placeholder)
10772  	ts := dom.NowSeconds()
10773  	expiry := ts + 300
10774  	tags := "[[\"t\",\"upload\"],[\"x\",\"" | hashHex | "\"],[\"expiration\",\"" | i64toa(expiry) | "\"]]"
10775  	unsigned := "{\"kind\":24242,\"content\":\"Upload " | hashHex | ext |
10776  		"\",\"tags\":" | tags |
10777  		",\"created_at\":" | i64toa(ts) |
10778  		",\"pubkey\":\"" | appSt.pubhex | "\"}"
10779  	signer.SignEvent(unsigned, func(signed string) {
10780  		if signed == "" {
10781  			dom.ConsoleLog("blossom: sign failed")
10782  			blossomReplacePlaceholder(ta, placeholder, "")
10783  			return
10784  		}
10785  		authB64 := helpers.Base64Encode([]byte(signed))
10786  		authHeader := "Nostr " | authB64
10787  		url := appSt.blossomServerURL
10788  		if len(url) > 0 && url[len(url)-1] != '/' {
10789  			url = url | "/"
10790  		}
10791  		url = url | "upload"
10792  		dom.FetchPutBlobBase64(url, b64data, mime, authHeader, func(resp string) {
10793  			if resp == "" {
10794  				dom.ConsoleLog("blossom: upload failed")
10795  				blossomReplacePlaceholder(ta, placeholder, "")
10796  				return
10797  			}
10798  			blobURL := helpers.JsonGetString(resp, "url")
10799  			if blobURL == "" {
10800  				blobURL = hashHex | ext
10801  			}
10802  			if len(blobURL) < 4 || blobURL[:4] != "http" {
10803  				base := blossomOrigin(appSt.blossomServerURL)
10804  				if len(blobURL) > 0 && blobURL[0] == '/' {
10805  					blobURL = base | blobURL
10806  				} else {
10807  					blobURL = base | "/" | blobURL
10808  				}
10809  			}
10810  			blossomReplacePlaceholder(ta, placeholder, blobURL)
10811  		})
10812  	})
10813  }
10814  
10815  func blossomOrigin(u string) (s string) {
10816  	slashes := 0
10817  	for i := 0; i < len(u); i++ {
10818  		if u[i] == '/' {
10819  			slashes++
10820  			if slashes == 3 {
10821  				return u[:i]
10822  			}
10823  		}
10824  	}
10825  	return u
10826  }
10827  
10828  func blossomReplacePlaceholder(ta dom.Element, placeholder, replacement string) {
10829  	val := dom.GetProperty(ta, "value")
10830  	idx := -1
10831  	plen := len(placeholder)
10832  	for i := 0; i <= len(val)-plen; i++ {
10833  		if val[i:i+plen] == placeholder {
10834  			idx = i
10835  			break
10836  		}
10837  	}
10838  	if idx >= 0 {
10839  		dom.SetProperty(ta, "value", val[:idx] | replacement | val[idx+plen:])
10840  	} else if replacement != "" {
10841  		if len(val) > 0 && val[len(val)-1] != '\n' {
10842  			val = val | "\n"
10843  		}
10844  		dom.SetProperty(ta, "value", val | replacement | "\n")
10845  	}
10846  }
10847  
10848  func blossomUploadToInput(b64data string, mime string, input dom.Element) {
10849  	if appSt.blossomServerURL == "" {
10850  		return
10851  	}
10852  	raw := helpers.Base64Decode(b64data)
10853  	if len(raw) == 0 {
10854  		return
10855  	}
10856  	hash := sha256.Sum(raw)
10857  	hashHex := helpers.HexEncode(hash[:])
10858  	ext := ".bin"
10859  	if mime == "image/png" {
10860  		ext = ".png"
10861  	} else if mime == "image/jpeg" || mime == "image/jpg" {
10862  		ext = ".jpg"
10863  	} else if mime == "image/gif" {
10864  		ext = ".gif"
10865  	} else if mime == "image/webp" {
10866  		ext = ".webp"
10867  	} else if mime == "image/svg+xml" {
10868  		ext = ".svg"
10869  	} else if mime == "video/mp4" {
10870  		ext = ".mp4"
10871  	} else if mime == "video/webm" {
10872  		ext = ".webm"
10873  	} else if mime == "video/quicktime" {
10874  		ext = ".mov"
10875  	} else if mime == "video/x-matroska" {
10876  		ext = ".mkv"
10877  	}
10878  	dom.SetProperty(input, "value", "\xe2\x8f\xb3 uploading...")
10879  	ts := dom.NowSeconds()
10880  	expiry := ts + 300
10881  	tags := "[[\"t\",\"upload\"],[\"x\",\"" | hashHex | "\"],[\"expiration\",\"" | i64toa(expiry) | "\"]]"
10882  	unsigned := "{\"kind\":24242,\"content\":\"Upload " | hashHex | ext |
10883  		"\",\"tags\":" | tags |
10884  		",\"created_at\":" | i64toa(ts) |
10885  		",\"pubkey\":\"" | appSt.pubhex | "\"}"
10886  	signer.SignEvent(unsigned, func(signed string) {
10887  		if signed == "" {
10888  			dom.SetProperty(input, "value", "")
10889  			return
10890  		}
10891  		authB64 := helpers.Base64Encode([]byte(signed))
10892  		authHeader := "Nostr " | authB64
10893  		url := appSt.blossomServerURL
10894  		if len(url) > 0 && url[len(url)-1] != '/' {
10895  			url = url | "/"
10896  		}
10897  		url = url | "upload"
10898  		dom.FetchPutBlobBase64(url, b64data, mime, authHeader, func(resp string) {
10899  			if resp == "" {
10900  				dom.SetProperty(input, "value", "")
10901  				return
10902  			}
10903  			blobURL := helpers.JsonGetString(resp, "url")
10904  			if blobURL == "" {
10905  				blobURL = hashHex | ext
10906  			}
10907  			if len(blobURL) < 4 || blobURL[:4] != "http" {
10908  				base := blossomOrigin(appSt.blossomServerURL)
10909  				if len(blobURL) > 0 && blobURL[0] == '/' {
10910  					blobURL = base | blobURL
10911  				} else {
10912  					blobURL = base | "/" | blobURL
10913  				}
10914  			}
10915  			dom.SetProperty(input, "value", blobURL)
10916  		})
10917  	})
10918  }
10919  
10920  type mentionMatch struct {
10921  	pk   string
10922  	name string
10923  	pic  string
10924  }
10925  
10926  func mentionSearch(query string) (ss []mentionMatch) {
10927  	q := toLower(query)
10928  	var results []mentionMatch
10929  	for pk, content := range appSt.profileContentCache {
10930  		if len(results) >= 10 {
10931  			break
10932  		}
10933  		name := helpers.JsonGetString(content, "name")
10934  		dname := helpers.JsonGetString(content, "display_name")
10935  		nl := toLower(name)
10936  		dl := toLower(dname)
10937  		if (len(nl) > 0 && strIndex(nl, q) >= 0) || (len(dl) > 0 && strIndex(dl, q) >= 0) {
10938  			label := name
10939  			if len(label) == 0 {
10940  				label = dname
10941  			}
10942  			if len(label) == 0 {
10943  				continue
10944  			}
10945  			pic, _ := appSt.authorPics[pk]
10946  			results = mxutil.Ensure(results, 1)
10947  			results = push(results, mentionMatch{pk: pk, name: label, pic: pic})
10948  		}
10949  	}
10950  	return results
10951  }
10952  
10953  func initMentionPopup() {
10954  	appSt.mentionPopup = dom.CreateElement("div")
10955  	dom.SetStyle(appSt.mentionPopup, "display", "none")
10956  	dom.SetStyle(appSt.mentionPopup, "position", "fixed")
10957  	dom.SetStyle(appSt.mentionPopup, "background", "var(--bg)")
10958  	dom.SetStyle(appSt.mentionPopup, "border", "1px solid var(--border)")
10959  	dom.SetStyle(appSt.mentionPopup, "borderRadius", "6px")
10960  	dom.SetStyle(appSt.mentionPopup, "padding", "4px")
10961  	dom.SetStyle(appSt.mentionPopup, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)")
10962  	dom.SetStyle(appSt.mentionPopup, "zIndex", "3000")
10963  	dom.SetStyle(appSt.mentionPopup, "maxHeight", "200px")
10964  	dom.SetStyle(appSt.mentionPopup, "overflowY", "auto")
10965  	dom.SetStyle(appSt.mentionPopup, "minWidth", "220px")
10966  	dom.AppendChild(dom.Body(), appSt.mentionPopup)
10967  }
10968  
10969  func showMentionPopup(ta dom.Element, atPos int32, query string) {
10970  	matches := mentionSearch(query)
10971  	if len(matches) == 0 {
10972  		closeMentionPopup()
10973  		return
10974  	}
10975  	appSt.mentionTA = ta
10976  	appSt.mentionQuery = query
10977  	appSt.mentionAtPos = atPos
10978  	appSt.mentionItems = matches
10979  	appSt.mentionSelIdx = 0
10980  
10981  	clearChildren(appSt.mentionPopup)
10982  	for i, m := range matches {
10983  		row := dom.CreateElement("div")
10984  		dom.SetStyle(row, "display", "flex")
10985  		dom.SetStyle(row, "alignItems", "center")
10986  		dom.SetStyle(row, "gap", "8px")
10987  		dom.SetStyle(row, "padding", "4px 8px")
10988  		dom.SetStyle(row, "cursor", "pointer")
10989  		dom.SetStyle(row, "borderRadius", "4px")
10990  		dom.SetStyle(row, "fontSize", "13px")
10991  		if i == 0 {
10992  			dom.SetStyle(row, "background", "var(--bg2)")
10993  		}
10994  
10995  		if len(m.pic) > 0 {
10996  			img := dom.CreateElement("img")
10997  			setMediaSrc(img, m.pic)
10998  			dom.SetStyle(img, "width", "24px")
10999  			dom.SetStyle(img, "height", "24px")
11000  			dom.SetStyle(img, "borderRadius", "50%")
11001  			dom.SetStyle(img, "objectFit", "cover")
11002  			dom.SetStyle(img, "flexShrink", "0")
11003  			dom.AppendChild(row, img)
11004  		} else {
11005  			placeholder := dom.CreateElement("div")
11006  			dom.SetStyle(placeholder, "width", "24px")
11007  			dom.SetStyle(placeholder, "height", "24px")
11008  			dom.SetStyle(placeholder, "borderRadius", "50%")
11009  			dom.SetStyle(placeholder, "background", "var(--border)")
11010  			dom.SetStyle(placeholder, "flexShrink", "0")
11011  			dom.AppendChild(row, placeholder)
11012  		}
11013  
11014  		nameSpan := dom.CreateElement("span")
11015  		dom.SetTextContent(nameSpan, m.name)
11016  		dom.AppendChild(row, nameSpan)
11017  
11018  		npubShort := helpers.EncodeNpub(helpers.HexDecode(m.pk))
11019  		if len(npubShort) > 16 {
11020  			npubShort = npubShort[:12] | "..."
11021  		}
11022  		npubSpan := dom.CreateElement("span")
11023  		dom.SetTextContent(npubSpan, npubShort)
11024  		dom.SetStyle(npubSpan, "color", "var(--muted)")
11025  		dom.SetStyle(npubSpan, "fontSize", "11px")
11026  		dom.AppendChild(row, npubSpan)
11027  
11028  		dom.AddEventListener(row, "mouseenter", dom.RegisterCallback(func() {
11029  			dom.SetStyle(row, "background", "var(--bg2)")
11030  		}))
11031  		dom.AddEventListener(row, "mouseleave", dom.RegisterCallback(func() {
11032  			if appSt.mentionSelIdx >= 0 && appSt.mentionSelIdx < len(appSt.mentionItems) {
11033  				dom.SetStyle(row, "background", "transparent")
11034  			}
11035  		}))
11036  
11037  		capturedPK := m.pk
11038  		dom.AddEventListener(row, "mousedown", dom.RegisterCallback(func() {
11039  			insertMention(capturedPK)
11040  		}))
11041  
11042  		dom.AppendChild(appSt.mentionPopup, row)
11043  	}
11044  
11045  	positionMentionPopup(ta)
11046  	dom.SetStyle(appSt.mentionPopup, "display", "block")
11047  }
11048  
11049  func positionMentionPopup(ta dom.Element) {
11050  	dom.GetBoundingRect(ta, func(left, top, right, bottom, width, height int32) {
11051  		vh := dom.GetViewportHeight()
11052  		cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
11053  		val := dom.GetProperty(ta, "value")
11054  		lineH := 22
11055  		charsPerLine := width / 8
11056  		if charsPerLine < 1 {
11057  			charsPerLine = 40
11058  		}
11059  		linesBefore := 0
11060  		col := 0
11061  		for i := 0; i < cur && i < len(val); i++ {
11062  			if val[i] == '\n' {
11063  				linesBefore++
11064  				col = 0
11065  			} else {
11066  				col++
11067  				if col >= charsPerLine {
11068  					linesBefore++
11069  					col = 0
11070  				}
11071  			}
11072  		}
11073  		cursorY := top + 8 + linesBefore*lineH
11074  		popupH := 200
11075  		if cursorY < vh/2 {
11076  			dom.SetStyle(appSt.mentionPopup, "top", itoa(cursorY+lineH) | "px")
11077  			dom.SetStyle(appSt.mentionPopup, "bottom", "auto")
11078  		} else {
11079  			dom.SetStyle(appSt.mentionPopup, "bottom", itoa(vh-cursorY) | "px")
11080  			dom.SetStyle(appSt.mentionPopup, "top", "auto")
11081  		}
11082  		_ = popupH
11083  		xOff := left + col*8
11084  		if xOff > right-220 {
11085  			xOff = right - 220
11086  		}
11087  		if xOff < left {
11088  			xOff = left
11089  		}
11090  		dom.SetStyle(appSt.mentionPopup, "left", itoa(xOff) | "px")
11091  	})
11092  }
11093  
11094  func insertMention(pk string) {
11095  	if appSt.mentionTA == 0 {
11096  		return
11097  	}
11098  	npub := helpers.EncodeNpub(helpers.HexDecode(pk))
11099  	val := dom.GetProperty(appSt.mentionTA, "value")
11100  	cur := parseIntProp(dom.GetProperty(appSt.mentionTA, "selectionStart"))
11101  	replacement := "nostr:" | npub | " "
11102  	before := val[:appSt.mentionAtPos]
11103  	after := val[cur:]
11104  	dom.SetProperty(appSt.mentionTA, "value", before | replacement | after)
11105  	newCur := itoa(len(before) + len(replacement))
11106  	dom.SetProperty(appSt.mentionTA, "selectionStart", newCur)
11107  	dom.SetProperty(appSt.mentionTA, "selectionEnd", newCur)
11108  	closeMentionPopup()
11109  	dom.Focus(appSt.mentionTA)
11110  }
11111  
11112  func closeMentionPopup() {
11113  	dom.SetStyle(appSt.mentionPopup, "display", "none")
11114  	appSt.mentionItems = nil
11115  	appSt.mentionSelIdx = 0
11116  	appSt.mentionTA = 0
11117  }
11118  
11119  func checkMentionAutocomplete(ta dom.Element) {
11120  	val := dom.GetProperty(ta, "value")
11121  	cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
11122  	if cur > len(val) {
11123  		cur = len(val)
11124  	}
11125  	atPos := -1
11126  	for i := cur - 1; i >= 0; i-- {
11127  		c := val[i]
11128  		if c == '@' {
11129  			atPos = i
11130  			break
11131  		}
11132  		if c == ' ' || c == '\n' || c == '\t' {
11133  			break
11134  		}
11135  	}
11136  	if atPos < 0 || cur-atPos < 2 {
11137  		closeMentionPopup()
11138  		return
11139  	}
11140  	if atPos > 0 {
11141  		prev := val[atPos-1]
11142  		if prev != ' ' && prev != '\n' && prev != '\t' {
11143  			closeMentionPopup()
11144  			return
11145  		}
11146  	}
11147  	query := val[atPos+1 : cur]
11148  	showMentionPopup(ta, atPos, query)
11149  }
11150  
11151  func wireMentionHandler(ta dom.Element) {
11152  	dom.AddEventListener(ta, "input", dom.RegisterCallback(func() {
11153  		checkMentionAutocomplete(ta)
11154  	}))
11155  	dom.AddEventListener(ta, "blur", dom.RegisterCallback(func() {
11156  		dom.SetTimeout(func() { closeMentionPopup() }, 200)
11157  	}))
11158  	dom.OnKeydown(ta, func(key string, prevent func()) {
11159  		if appSt.mentionTA == 0 || len(appSt.mentionItems) == 0 {
11160  			if key == "Escape" {
11161  				closeMentionPopup()
11162  			}
11163  			return
11164  		}
11165  		if key == "Escape" {
11166  			closeMentionPopup()
11167  			prevent()
11168  		} else if key == "ArrowDown" {
11169  			appSt.mentionSelIdx++
11170  			if appSt.mentionSelIdx >= len(appSt.mentionItems) {
11171  				appSt.mentionSelIdx = 0
11172  			}
11173  			highlightMentionRow()
11174  			prevent()
11175  		} else if key == "ArrowUp" {
11176  			appSt.mentionSelIdx--
11177  			if appSt.mentionSelIdx < 0 {
11178  				appSt.mentionSelIdx = len(appSt.mentionItems) - 1
11179  			}
11180  			highlightMentionRow()
11181  			prevent()
11182  		} else if key == "Enter" || key == "Tab" {
11183  			insertMention(appSt.mentionItems[appSt.mentionSelIdx].pk)
11184  			prevent()
11185  		}
11186  	})
11187  }
11188  
11189  func highlightMentionRow() {
11190  	child := dom.FirstElementChild(appSt.mentionPopup)
11191  	idx := 0
11192  	for child != 0 {
11193  		if idx == appSt.mentionSelIdx {
11194  			dom.SetStyle(child, "background", "var(--bg2)")
11195  		} else {
11196  			dom.SetStyle(child, "background", "transparent")
11197  		}
11198  		child = dom.NextSibling(child)
11199  		idx++
11200  	}
11201  }
11202  
11203  func checkEmojiAutocomplete(ta dom.Element) {
11204  	val := dom.GetProperty(ta, "value")
11205  	curStr := dom.GetProperty(ta, "selectionStart")
11206  	cur := parseIntProp(curStr)
11207  	if cur > len(val) {
11208  		cur = len(val)
11209  	}
11210  
11211  	searchFrom := cur - 1
11212  	closingColon := false
11213  	if searchFrom >= 0 && val[searchFrom] == ':' {
11214  		closingColon = true
11215  		searchFrom--
11216  	}
11217  	colonPos := -1
11218  	for i := searchFrom; i >= 0; i-- {
11219  		c := val[i]
11220  		if c == ':' {
11221  			colonPos = i
11222  			break
11223  		}
11224  		if c == ' ' || c == '\n' || c == '\t' {
11225  			break
11226  		}
11227  	}
11228  
11229  	if colonPos < 0 || cur-colonPos < 2 {
11230  		closeEmojiAuto()
11231  		return
11232  	}
11233  
11234  	// Extract the text between the opening colon and cursor (excluding closing colon).
11235  	queryEnd := cur
11236  	if closingColon {
11237  		queryEnd = cur - 1
11238  	}
11239  	query := val[colonPos+1 : queryEnd]
11240  
11241  	// If user typed a closing ':', auto-replace if exact match.
11242  	if closingColon && len(query) > 0 {
11243  		code := ":" | query | ":"
11244  		if emoji, ok2 := appSt.emojiByName[code]; ok2 {
11245  			before := val[:colonPos]
11246  			after := val[cur:]
11247  			newVal := before | emoji | after
11248  			dom.SetProperty(ta, "value", newVal)
11249  			newCur := itoa(len(before) + len(emoji))
11250  			dom.SetProperty(ta, "selectionStart", newCur)
11251  			dom.SetProperty(ta, "selectionEnd", newCur)
11252  			closeEmojiAuto()
11253  			return
11254  		}
11255  	}
11256  
11257  	// Filter matching emoji.
11258  	var matches []emojiEntry
11259  	q := toLower(query)
11260  	for _, e := range emojiList {
11261  		if len(matches) >= 10 {
11262  			break
11263  		}
11264  		if len(e.name) >= len(q) && hasPrefix(e.name, q) {
11265  			matches = mxutil.Ensure(matches, 1)
11266  			matches = push(matches, e)
11267  		}
11268  	}
11269  	// Also match substring.
11270  	if len(matches) < 10 {
11271  		for _, e := range emojiList {
11272  			if len(matches) >= 10 {
11273  				break
11274  			}
11275  			if !hasPrefix(e.name, q) && strIndex(e.name, q) >= 0 {
11276  				matches = mxutil.Ensure(matches, 1)
11277  				matches = push(matches, e)
11278  			}
11279  		}
11280  	}
11281  
11282  	if len(matches) == 0 {
11283  		closeEmojiAuto()
11284  		return
11285  	}
11286  
11287  	appSt.emojiAutoOpen = true
11288  	appSt.emojiAutoStart = colonPos
11289  	appSt.emojiAutoTA = ta
11290  	clearChildren(appSt.emojiAutoEl)
11291  
11292  	for _, m := range matches {
11293  		row := dom.CreateElement("div")
11294  		dom.SetStyle(row, "display", "flex")
11295  		dom.SetStyle(row, "alignItems", "center")
11296  		dom.SetStyle(row, "gap", "6px")
11297  		dom.SetStyle(row, "padding", "4px 8px")
11298  		dom.SetStyle(row, "cursor", "pointer")
11299  		dom.SetStyle(row, "borderRadius", "4px")
11300  		dom.SetStyle(row, "fontSize", "13px")
11301  
11302  		emojiSpan := dom.CreateElement("span")
11303  		dom.SetTextContent(emojiSpan, m.emoji)
11304  		dom.SetStyle(emojiSpan, "fontSize", "18px")
11305  		dom.AppendChild(row, emojiSpan)
11306  
11307  		nameSpan := dom.CreateElement("span")
11308  		dom.SetTextContent(nameSpan, ":" | m.name | ":")
11309  		dom.SetStyle(nameSpan, "color", "var(--muted)")
11310  		dom.AppendChild(row, nameSpan)
11311  
11312  		dom.AddEventListener(row, "mouseenter", dom.RegisterCallback(func() {
11313  			dom.SetStyle(row, "background", "var(--bg2)")
11314  		}))
11315  		dom.AddEventListener(row, "mouseleave", dom.RegisterCallback(func() {
11316  			dom.SetStyle(row, "background", "transparent")
11317  		}))
11318  
11319  		capturedEmoji := m.emoji
11320  		dom.AddEventListener(row, "mousedown", dom.RegisterCallback(func() {
11321  			insertEmojiAt(capturedEmoji)
11322  		}))
11323  
11324  		dom.AppendChild(appSt.emojiAutoEl, row)
11325  	}
11326  
11327  	dom.SetStyle(appSt.emojiAutoEl, "display", "block")
11328  	positionEmojiPopup()
11329  }
11330  
11331  func insertEmojiAt(emoji string) {
11332  	ta := appSt.emojiAutoTA
11333  	if ta == 0 {
11334  		return
11335  	}
11336  	val := dom.GetProperty(ta, "value")
11337  	cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
11338  	before := val[:appSt.emojiAutoStart]
11339  	after := val[cur:]
11340  	newVal := before | emoji | after
11341  	dom.SetProperty(ta, "value", newVal)
11342  	newCur := itoa(len(before) + len(emoji))
11343  	dom.SetProperty(ta, "selectionStart", newCur)
11344  	dom.SetProperty(ta, "selectionEnd", newCur)
11345  	closeEmojiAuto()
11346  	dom.Focus(ta)
11347  }
11348  
11349  func closeEmojiAuto() {
11350  	if appSt.emojiAutoOpen {
11351  		dom.SetStyle(appSt.emojiAutoEl, "display", "none")
11352  		appSt.emojiAutoOpen = false
11353  	}
11354  }
11355  
11356  func positionEmojiPopup() {
11357  	ta := appSt.emojiAutoTA
11358  	if ta == 0 {
11359  		return
11360  	}
11361  	dom.GetBoundingRect(ta, func(left, top, right, bottom, width, height int32) {
11362  		vh := dom.GetViewportHeight()
11363  		cur := parseIntProp(dom.GetProperty(ta, "selectionStart"))
11364  		val := dom.GetProperty(ta, "value")
11365  		lineH := 22
11366  		charsPerLine := width / 8
11367  		if charsPerLine < 1 {
11368  			charsPerLine = 40
11369  		}
11370  		linesBefore := 0
11371  		col := 0
11372  		for i := 0; i < cur && i < len(val); i++ {
11373  			if val[i] == '\n' {
11374  				linesBefore++
11375  				col = 0
11376  			} else {
11377  				col++
11378  				if col >= charsPerLine {
11379  					linesBefore++
11380  					col = 0
11381  				}
11382  			}
11383  		}
11384  		cursorY := top + 8 + linesBefore*lineH
11385  		if cursorY < vh/2 {
11386  			dom.SetStyle(appSt.emojiAutoEl, "top", itoa(cursorY+lineH) | "px")
11387  			dom.SetStyle(appSt.emojiAutoEl, "bottom", "auto")
11388  		} else {
11389  			dom.SetStyle(appSt.emojiAutoEl, "bottom", itoa(vh-cursorY) | "px")
11390  			dom.SetStyle(appSt.emojiAutoEl, "top", "auto")
11391  		}
11392  		xOff := left + col*8
11393  		if xOff > right-220 {
11394  			xOff = right - 220
11395  		}
11396  		if xOff < left {
11397  			xOff = left
11398  		}
11399  		dom.SetStyle(appSt.emojiAutoEl, "left", itoa(xOff) | "px")
11400  	})
11401  }
11402  
11403  func populateComposeRelays() {
11404  	populateRelayPopover(appSt.composeRelayPopEl, nil)
11405  }
11406  
11407  func populateRelayPopover(target dom.Element, onChanged func()) {
11408  	renderRows := func() {
11409  		clearChildren(target)
11410  		populateRelayRows(target, onChanged)
11411  		if onChanged != nil {
11412  			onChanged()
11413  		}
11414  	}
11415  	if appSt.composeRelayChecks == nil {
11416  		appSt.composeRelayChecks = map[string]bool{}
11417  		for _, u := range appSt.relayURLs {
11418  			appSt.composeRelayChecks[u] = true
11419  		}
11420  		dom.IDBGet("cache", "compose_relays", func(val string) {
11421  			if val != "" {
11422  				appSt.composeRelayChecks = map[string]bool{}
11423  				start := 0
11424  				for i := 0; i <= len(val); i++ {
11425  					if i == len(val) || val[i] == ',' {
11426  						if i > start {
11427  							appSt.composeRelayChecks[val[start:i]] = true
11428  						}
11429  						start = i + 1
11430  					}
11431  				}
11432  			}
11433  			renderRows()
11434  		})
11435  		return
11436  	}
11437  	renderRows()
11438  }
11439  
11440  func populateRelayRows(target dom.Element, onChanged func()) {
11441  	for idx, u := range appSt.relayURLs {
11442  		row := dom.CreateElement("div")
11443  		dom.SetStyle(row, "display", "flex")
11444  		dom.SetStyle(row, "alignItems", "center")
11445  		dom.SetStyle(row, "gap", "6px")
11446  		dom.SetStyle(row, "padding", "0 4px")
11447  		dom.SetStyle(row, "margin", "2px 0")
11448  		dom.SetStyle(row, "cursor", "pointer")
11449  		dom.SetStyle(row, "fontSize", "12px")
11450  		dom.SetStyle(row, "overflow", "hidden")
11451  
11452  		relayURL := u
11453  		selected := appSt.composeRelayChecks[u]
11454  
11455  		applyRowStyle := func(sel bool) {
11456  			if sel {
11457  				dom.SetStyle(row, "background", "var(--accent)")
11458  				dom.SetStyle(row, "color", "#fff")
11459  			} else {
11460  				dom.SetStyle(row, "background", "transparent")
11461  				dom.SetStyle(row, "color", "var(--fg)")
11462  			}
11463  		}
11464  		applyRowStyle(selected)
11465  
11466  		dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
11467  			appSt.composeRelayChecks[relayURL] = !appSt.composeRelayChecks[relayURL]
11468  			applyRowStyle(appSt.composeRelayChecks[relayURL])
11469  			saveComposeRelaySelection()
11470  			if onChanged != nil {
11471  				onChanged()
11472  			}
11473  		}))
11474  
11475  		label := dom.CreateElement("span")
11476  		dom.SetTextContent(label, stripScheme(u))
11477  		dom.SetStyle(label, "overflow", "hidden")
11478  		dom.SetStyle(label, "textOverflow", "ellipsis")
11479  		dom.SetStyle(label, "whiteSpace", "nowrap")
11480  		dom.SetStyle(label, "flex", "1")
11481  		dom.SetStyle(label, "minWidth", "0")
11482  		dom.AppendChild(row, label)
11483  
11484  		caps := appSt.relayCaps[idx]
11485  		if caps.fetchedOK {
11486  			if !hasNIP(caps.supportedNIPs, 42) {
11487  				dom.AppendChild(row, makeRelayBadge("!42"))
11488  			}
11489  			if !hasNIP(caps.supportedNIPs, 70) {
11490  				dom.AppendChild(row, makeRelayBadge("!70"))
11491  			}
11492  		}
11493  
11494  		dom.AppendChild(target, row)
11495  	}
11496  }
11497  
11498  func selectedRelaysLeaky() (ok bool) {
11499  	if appSt.composeRelayChecks == nil {
11500  		return false
11501  	}
11502  	for i, u := range appSt.relayURLs {
11503  		if !appSt.composeRelayChecks[u] {
11504  			continue
11505  		}
11506  		caps := appSt.relayCaps[i]
11507  		if !caps.fetchedOK {
11508  			continue
11509  		}
11510  		if !hasNIP(caps.supportedNIPs, 42) || !hasNIP(caps.supportedNIPs, 70) {
11511  			return true
11512  		}
11513  	}
11514  	return false
11515  }
11516  
11517  func updateRelaySelBtn(btn dom.Element, open bool) {
11518  	if open {
11519  		dom.SetTextContent(btn, "relays \u2715")
11520  		dom.SetStyle(btn, "background", "var(--accent)")
11521  		dom.SetStyle(btn, "color", "#fff")
11522  	} else {
11523  		dom.SetTextContent(btn, "relays")
11524  		dom.SetStyle(btn, "background", "transparent")
11525  		dom.SetStyle(btn, "color", "var(--fg)")
11526  	}
11527  }
11528  
11529  func updateRelayWarnBadge(badge dom.Element) {
11530  	if selectedRelaysLeaky() {
11531  		dom.SetStyle(badge, "display", "inline-block")
11532  	} else {
11533  		dom.SetStyle(badge, "display", "none")
11534  	}
11535  }
11536  
11537  func makeRelayWarnBadge() (e dom.Element) {
11538  	badge := dom.CreateElement("span")
11539  	dom.SetTextContent(badge, "\u26a0")
11540  	dom.SetStyle(badge, "display", "none")
11541  	dom.SetStyle(badge, "fontSize", "16px")
11542  	dom.SetStyle(badge, "lineHeight", "1")
11543  	dom.SetStyle(badge, "background", "#e67e00")
11544  	dom.SetStyle(badge, "borderRadius", "50%")
11545  	dom.SetStyle(badge, "width", "22px")
11546  	dom.SetStyle(badge, "height", "22px")
11547  	dom.SetStyle(badge, "textAlign", "center")
11548  	dom.SetStyle(badge, "cursor", "default")
11549  	dom.SetAttribute(badge, "title", t("relay_leak_warn"))
11550  	updateRelayWarnBadge(badge)
11551  	return badge
11552  }
11553  
11554  func saveComposeRelaySelection() {
11555  	val := ""
11556  	for u, sel := range appSt.composeRelayChecks {
11557  		if sel {
11558  			if val != "" {
11559  				val = val | ","
11560  			}
11561  			val = val | u
11562  		}
11563  	}
11564  	dom.IDBPut("cache", "compose_relays", val)
11565  }
11566  
11567  func composeSelectedRelays() (ss []string) {
11568  	if appSt.composeRelayChecks == nil {
11569  		return appSt.relayURLs
11570  	}
11571  	var out []string
11572  	for u, sel := range appSt.composeRelayChecks {
11573  		if sel {
11574  			out = mxutil.Ensure(out, 1)
11575  			out = push(out, u)
11576  		}
11577  	}
11578  	// If nothing selected, fall back to all relays.
11579  	if len(out) == 0 {
11580  		return appSt.relayURLs
11581  	}
11582  	return out
11583  }
11584  
11585  func insertPublishedNote(ev *nostr.Event) {
11586  	setEvent(ev.ID, ev)
11587  	fillEmbed(ev)
11588  
11589  	// Thread view: insert and re-render, scrolling new reply into view.
11590  	if appSt.threadOpen {
11591  		appSt.threadEvents[ev.ID] = ev
11592  		appSt.threadFocusID = ev.ID
11593  		dom.ReplaceState("/t/" | appSt.threadRootID | "#" | ev.ID)
11594  		renderThreadTree()
11595  	}
11596  
11597  	// Feed: prepend at top. renderNote must run before seenEvents is set,
11598  	// because renderNote's dedup guard checks seenEvents.
11599  	trackOldestTs(ev)
11600  	renderNote(ev)
11601  	appSt.seenEvents[ev.ID] = true
11602  }
11603  
11604  // --- Emoji picker ---
11605  
11606  func showEmojiPicker(ev *nostr.Event, anchor dom.Element) {
11607  	_ = anchor
11608  	if !signer.HasSigner() || appSt.pubhex == "" {
11609  		return
11610  	}
11611  	if appSt.emojiOpen {
11612  		closeEmojiPicker()
11613  		return
11614  	}
11615  	appSt.emojiOpen = true
11616  	appSt.emojiTargetEv = ev
11617  
11618  	// Full-screen scrim.
11619  	overlay := dom.CreateElement("div")
11620  	dom.SetStyle(overlay, "position", "fixed")
11621  	dom.SetStyle(overlay, "top", "0")
11622  	dom.SetStyle(overlay, "left", "0")
11623  	dom.SetStyle(overlay, "width", "100%")
11624  	dom.SetStyle(overlay, "height", "100%")
11625  	dom.SetStyle(overlay, "background", "rgba(0,0,0,0.5)")
11626  	dom.SetStyle(overlay, "zIndex", "400")
11627  	dom.SetStyle(overlay, "display", "flex")
11628  	dom.SetStyle(overlay, "alignItems", "center")
11629  	dom.SetStyle(overlay, "justifyContent", "center")
11630  	dom.AddSelfEventListener(overlay, "click", dom.RegisterCallback(func() {
11631  		closeEmojiPicker()
11632  	}))
11633  	appSt.emojiOverlay = overlay
11634  
11635  	modal := dom.CreateElement("div")
11636  	dom.SetStyle(modal, "background", "var(--bg)")
11637  	dom.SetStyle(modal, "border", "1px solid var(--border)")
11638  	dom.SetStyle(modal, "borderRadius", "8px")
11639  	dom.SetStyle(modal, "padding", "12px")
11640  	dom.SetStyle(modal, "boxShadow", "0 4px 24px rgba(0,0,0,0.4)")
11641  	dom.SetStyle(modal, "width", "320px")
11642  	dom.SetStyle(modal, "maxWidth", "90vw")
11643  	dom.SetStyle(modal, "maxHeight", "60vh")
11644  	dom.SetStyle(modal, "display", "flex")
11645  	dom.SetStyle(modal, "flexDirection", "column")
11646  	dom.SetStyle(modal, "gap", "8px")
11647  
11648  	// Search input.
11649  	search := dom.CreateElement("input")
11650  	dom.SetAttribute(search, "type", "text")
11651  	dom.SetAttribute(search, "placeholder", "Search emoji...")
11652  	dom.SetStyle(search, "width", "100%")
11653  	dom.SetStyle(search, "padding", "6px 10px")
11654  	dom.SetStyle(search, "border", "1px solid var(--border)")
11655  	dom.SetStyle(search, "borderRadius", "4px")
11656  	dom.SetStyle(search, "background", "var(--bg)")
11657  	dom.SetStyle(search, "color", "var(--fg)")
11658  	dom.SetStyle(search, "fontSize", "14px")
11659  	dom.SetStyle(search, "boxSizing", "border-box")
11660  	dom.SetStyle(search, "flexShrink", "0")
11661  	dom.AppendChild(modal, search)
11662  
11663  	// Emoji grid - scrollable.
11664  	grid := dom.CreateElement("div")
11665  	dom.SetStyle(grid, "display", "flex")
11666  	dom.SetStyle(grid, "flexWrap", "wrap")
11667  	dom.SetStyle(grid, "gap", "2px")
11668  	dom.SetStyle(grid, "overflowY", "auto")
11669  	dom.SetStyle(grid, "flex", "1")
11670  	dom.SetStyle(grid, "minHeight", "0")
11671  	dom.SetStyle(grid, "alignContent", "flex-start")
11672  	dom.SetStyle(grid, "WebkitOverflowScrolling", "touch")
11673  
11674  	capturedEv := ev
11675  	seen := map[string]bool{}
11676  	for _, entry := range emojiList {
11677  		if seen[entry.emoji] {
11678  			continue
11679  		}
11680  		seen[entry.emoji] = true
11681  		cell := dom.CreateElement("span")
11682  		dom.SetTextContent(cell, entry.emoji)
11683  		dom.SetStyle(cell, "fontSize", "22px")
11684  		dom.SetStyle(cell, "cursor", "pointer")
11685  		dom.SetStyle(cell, "padding", "4px")
11686  		dom.SetStyle(cell, "borderRadius", "4px")
11687  		dom.SetStyle(cell, "textAlign", "center")
11688  		dom.SetStyle(cell, "width", "36px")
11689  		dom.SetStyle(cell, "height", "36px")
11690  		dom.SetStyle(cell, "lineHeight", "36px")
11691  		dom.SetStyle(cell, "display", "inline-flex")
11692  		dom.SetStyle(cell, "alignItems", "center")
11693  		dom.SetStyle(cell, "justifyContent", "center")
11694  		dom.SetAttribute(cell, "title", entry.name)
11695  		capturedEmoji := entry.emoji
11696  		dom.AddEventListener(cell, "mouseenter", dom.RegisterCallback(func() {
11697  			dom.SetStyle(cell, "background", "var(--border)")
11698  		}))
11699  		dom.AddEventListener(cell, "mouseleave", dom.RegisterCallback(func() {
11700  			dom.SetStyle(cell, "background", "transparent")
11701  		}))
11702  		dom.AddEventListener(cell, "click", dom.RegisterCallback(func() {
11703  			publishReaction(capturedEv, capturedEmoji)
11704  			closeEmojiPicker()
11705  		}))
11706  		dom.AppendChild(grid, cell)
11707  	}
11708  	dom.AppendChild(modal, grid)
11709  
11710  	// Search filtering - match against all names for each emoji.
11711  	capturedGrid := grid
11712  	dom.AddEventListener(search, "input", dom.RegisterCallback(func() {
11713  		q := toLower(dom.GetProperty(search, "value"))
11714  		child := dom.FirstElementChild(capturedGrid)
11715  		for child != 0 {
11716  			next := dom.NextSibling(child)
11717  			title := dom.GetProperty(child, "title")
11718  			if q == "" || contains(title, q) {
11719  				dom.SetStyle(child, "display", "inline-flex")
11720  			} else {
11721  				dom.SetStyle(child, "display", "none")
11722  			}
11723  			child = next
11724  		}
11725  	}))
11726  
11727  	dom.AppendChild(overlay, modal)
11728  	dom.AppendChild(dom.Body(), overlay)
11729  	dom.Focus(search)
11730  }
11731  
11732  func closeEmojiPicker() {
11733  	if appSt.emojiOverlay != 0 {
11734  		dom.RemoveChild(dom.Body(), appSt.emojiOverlay)
11735  		appSt.emojiOverlay = 0
11736  	}
11737  	appSt.emojiOpen = false
11738  	appSt.emojiTargetEv = nil
11739  }
11740  
11741  func publishReaction(ev *nostr.Event, emoji string) {
11742  	if !signer.HasSigner() || appSt.pubhex == "" {
11743  		return
11744  	}
11745  	relay := ""
11746  	if urls, ok := appSt.eventRelays[ev.ID]; ok && len(urls) > 0 {
11747  		relay = urls[0]
11748  	}
11749  	tags := "[[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | "],[\"p\"," | jstr(ev.PubKey) | "]]"
11750  	ts := dom.NowSeconds()
11751  	unsigned := "{\"kind\":7,\"content\":" | jstr(emoji) |
11752  		",\"tags\":" | tags |
11753  		",\"created_at\":" | i64toa(ts) |
11754  		",\"pubkey\":\"" | appSt.pubhex | "\"}"
11755  	capturedEvID := ev.ID
11756  	capturedEmoji := emoji
11757  	signer.SignEvent(unsigned, func(signed string) {
11758  		if signed != "" {
11759  			selected := composeSelectedRelays()
11760  			if len(selected) > 0 {
11761  				msg := "[\"PUBLISH_TO\"," | signed | ",["
11762  				for i, u := range selected {
11763  					if i > 0 {
11764  						msg = msg | ","
11765  					}
11766  					msg = msg | jstr(u)
11767  				}
11768  				routeMsg(msg | "]]")
11769  				relayList := ""
11770  				for j, u := range selected {
11771  					if j > 0 {
11772  						relayList = relayList | "\n"
11773  					}
11774  					relayList = relayList | u
11775  				}
11776  				logActivity("react", capturedEmoji | " on " | capturedEvID[:12], "relays:\n" | relayList | "\n\nevent:\n" | signed)
11777  			} else {
11778  				routeMsg("[\"EVENT\"," | signed | "]")
11779  				logActivity("react", capturedEmoji | " on " | capturedEvID[:12], "no targeted relays\n\nevent:\n" | signed)
11780  			}
11781  			ri := appSt.noteReactionMap[capturedEvID]
11782  			if ri == nil {
11783  				ri = map[string]map[string]bool{}
11784  			}
11785  			pks := ri[capturedEmoji]
11786  			if pks == nil {
11787  				pks = map[string]bool{}
11788  			}
11789  			pks[appSt.pubhex] = true
11790  			ri[capturedEmoji] = pks
11791  			appSt.noteReactionMap[capturedEvID] = ri
11792  			if el, ok2 := appSt.noteReactionEls[capturedEvID]; ok2 {
11793  				fillReactionDisplay(el, capturedEvID)
11794  			}
11795  		}
11796  	})
11797  }
11798  
11799  func contains(s, sub string) (ok bool) {
11800  	if len(sub) > len(s) {
11801  		return false
11802  	}
11803  	for i := 0; i <= len(s)-len(sub); i++ {
11804  		if s[i:i+len(sub)] == sub {
11805  			return true
11806  		}
11807  	}
11808  	return false
11809  }
11810  
11811  // --- Settings page ---
11812  
11813  func renderSettings() {
11814  	clearChildren(appSt.settingsPage)
11815  
11816  	title := dom.CreateElement("h2")
11817  	dom.SetTextContent(title, t("settings_title"))
11818  	dom.SetStyle(title, "fontSize", "20px")
11819  	dom.SetStyle(title, "marginBottom", "24px")
11820  	dom.AppendChild(appSt.settingsPage, title)
11821  
11822  	// Language selector.
11823  	langRow := dom.CreateElement("div")
11824  	dom.SetStyle(langRow, "display", "flex")
11825  	dom.SetStyle(langRow, "alignItems", "center")
11826  	dom.SetStyle(langRow, "gap", "12px")
11827  	dom.SetStyle(langRow, "marginBottom", "16px")
11828  
11829  	langLabel := dom.CreateElement("span")
11830  	dom.SetTextContent(langLabel, t("lang_label"))
11831  	dom.SetStyle(langLabel, "fontSize", "14px")
11832  	dom.SetStyle(langLabel, "minWidth", "140px")
11833  	dom.AppendChild(langRow, langLabel)
11834  
11835  	langSel := dom.CreateElement("select")
11836  	dom.SetStyle(langSel, "fontFamily", "'Fira Code', monospace")
11837  	dom.SetStyle(langSel, "fontSize", "13px")
11838  	dom.SetStyle(langSel, "background", "var(--bg2)")
11839  	dom.SetStyle(langSel, "color", "var(--fg)")
11840  	dom.SetStyle(langSel, "border", "1px solid var(--border)")
11841  	dom.SetStyle(langSel, "borderRadius", "4px")
11842  	dom.SetStyle(langSel, "padding", "6px 12px")
11843  	for code, name := range langNames {
11844  		opt := dom.CreateElement("option")
11845  		dom.SetAttribute(opt, "value", code)
11846  		dom.SetTextContent(opt, name)
11847  		if code == appSt.currentLang {
11848  			dom.SetAttribute(opt, "selected", "selected")
11849  		}
11850  		dom.AppendChild(langSel, opt)
11851  	}
11852  	dom.AddEventListener(langSel, "change", dom.RegisterCallback(func() {
11853  		val := dom.GetProperty(langSel, "value")
11854  		setLang(val)
11855  		saveAppSettings()
11856  		dom.SetTextContent(appSt.pageTitleEl, t("settings"))
11857  		renderSettings()
11858  	}))
11859  	dom.AppendChild(langRow, langSel)
11860  	dom.AppendChild(appSt.settingsPage, langRow)
11861  
11862  	// Theme selector.
11863  	themeRow := dom.CreateElement("div")
11864  	dom.SetStyle(themeRow, "display", "flex")
11865  	dom.SetStyle(themeRow, "alignItems", "center")
11866  	dom.SetStyle(themeRow, "gap", "12px")
11867  	dom.SetStyle(themeRow, "marginBottom", "16px")
11868  
11869  	themeLabel := dom.CreateElement("span")
11870  	dom.SetTextContent(themeLabel, t("theme_label"))
11871  	dom.SetStyle(themeLabel, "fontSize", "14px")
11872  	dom.SetStyle(themeLabel, "minWidth", "140px")
11873  	dom.AppendChild(themeRow, themeLabel)
11874  
11875  	themeToggle := dom.CreateElement("button")
11876  	if appSt.isDark {
11877  		dom.SetTextContent(themeToggle, t("dark"))
11878  	} else {
11879  		dom.SetTextContent(themeToggle, t("light"))
11880  	}
11881  	dom.SetStyle(themeToggle, "fontFamily", "'Fira Code', monospace")
11882  	dom.SetStyle(themeToggle, "fontSize", "13px")
11883  	dom.SetStyle(themeToggle, "background", "var(--bg2)")
11884  	dom.SetStyle(themeToggle, "color", "var(--fg)")
11885  	dom.SetStyle(themeToggle, "border", "1px solid var(--border)")
11886  	dom.SetStyle(themeToggle, "borderRadius", "4px")
11887  	dom.SetStyle(themeToggle, "padding", "6px 16px")
11888  	dom.SetStyle(themeToggle, "cursor", "pointer")
11889  	dom.AddEventListener(themeToggle, "click", dom.RegisterCallback(func() {
11890  		toggleTheme()
11891  		if appSt.isDark {
11892  			dom.SetTextContent(themeToggle, t("dark"))
11893  		} else {
11894  			dom.SetTextContent(themeToggle, t("light"))
11895  		}
11896  		saveAppSettings()
11897  	}))
11898  	dom.AppendChild(themeRow, themeToggle)
11899  	dom.AppendChild(appSt.settingsPage, themeRow)
11900  
11901  	// Client tag setting.
11902  	clientRow := dom.CreateElement("div")
11903  	dom.SetStyle(clientRow, "display", "flex")
11904  	dom.SetStyle(clientRow, "alignItems", "center")
11905  	dom.SetStyle(clientRow, "gap", "12px")
11906  	dom.SetStyle(clientRow, "marginBottom", "16px")
11907  
11908  	clientLabel := dom.CreateElement("span")
11909  	dom.SetTextContent(clientLabel, "client tag")
11910  	dom.SetStyle(clientLabel, "fontSize", "14px")
11911  	dom.SetStyle(clientLabel, "minWidth", "140px")
11912  	dom.AppendChild(clientRow, clientLabel)
11913  
11914  	clientCb := dom.CreateElement("input")
11915  	dom.SetAttribute(clientCb, "type", "checkbox")
11916  	if localstorage.GetItem("musiquay-client-tag") != "0" {
11917  		dom.SetProperty(clientCb, "checked", "true")
11918  	}
11919  	dom.AddEventListener(clientCb, "change", dom.RegisterCallback(func() {
11920  		checked := dom.GetProperty(clientCb, "checked")
11921  		if checked == "true" {
11922  			localstorage.RemoveItem("musiquay-client-tag")
11923  		} else {
11924  			localstorage.SetItem("musiquay-client-tag", "0")
11925  		}
11926  		saveAppSettings()
11927  	}))
11928  	dom.AppendChild(clientRow, clientCb)
11929  
11930  	clientDesc := dom.CreateElement("span")
11931  	dom.SetTextContent(clientDesc, "add smesh.lol tag to posts")
11932  	dom.SetStyle(clientDesc, "fontSize", "12px")
11933  	dom.SetStyle(clientDesc, "color", "var(--muted)")
11934  	dom.AppendChild(clientRow, clientDesc)
11935  
11936  	dom.AppendChild(appSt.settingsPage, clientRow)
11937  
11938  	// Blossom server URL.
11939  	blossomRow := dom.CreateElement("div")
11940  	dom.SetStyle(blossomRow, "display", "flex")
11941  	dom.SetStyle(blossomRow, "flexWrap", "wrap")
11942  	dom.SetStyle(blossomRow, "alignItems", "center")
11943  	dom.SetStyle(blossomRow, "gap", "12px")
11944  	dom.SetStyle(blossomRow, "marginBottom", "16px")
11945  
11946  	blossomLabel := dom.CreateElement("span")
11947  	dom.SetTextContent(blossomLabel, "blossom server")
11948  	dom.SetStyle(blossomLabel, "fontSize", "14px")
11949  	dom.SetStyle(blossomLabel, "minWidth", "140px")
11950  	dom.AppendChild(blossomRow, blossomLabel)
11951  
11952  	blossomInput := dom.CreateElement("input")
11953  	dom.SetAttribute(blossomInput, "type", "text")
11954  	dom.SetAttribute(blossomInput, "placeholder", "https://blossom.example.com")
11955  	blossomVal := appSt.blossomServerURL
11956  	if blossomVal == "" {
11957  		blossomVal = defaultBlossomURL()
11958  	}
11959  	dom.SetProperty(blossomInput, "value", blossomVal)
11960  	dom.SetStyle(blossomInput, "fontFamily", "'Fira Code', monospace")
11961  	dom.SetStyle(blossomInput, "fontSize", "13px")
11962  	dom.SetStyle(blossomInput, "background", "var(--bg)")
11963  	dom.SetStyle(blossomInput, "color", "var(--fg)")
11964  	dom.SetStyle(blossomInput, "border", "1px solid var(--border)")
11965  	dom.SetStyle(blossomInput, "borderRadius", "4px")
11966  	dom.SetStyle(blossomInput, "padding", "6px 12px")
11967  	dom.SetStyle(blossomInput, "flex", "1")
11968  	dom.SetStyle(blossomInput, "minWidth", "200px")
11969  	dom.AppendChild(blossomRow, blossomInput)
11970  
11971  	blossomSave := dom.CreateElement("button")
11972  	dom.SetTextContent(blossomSave, "save")
11973  	dom.SetStyle(blossomSave, "padding", "6px 16px")
11974  	dom.SetStyle(blossomSave, "borderRadius", "4px")
11975  	dom.SetStyle(blossomSave, "border", "1px solid var(--border)")
11976  	dom.SetStyle(blossomSave, "background", "var(--accent)")
11977  	dom.SetStyle(blossomSave, "color", "var(--bg)")
11978  	dom.SetStyle(blossomSave, "cursor", "pointer")
11979  	dom.SetStyle(blossomSave, "fontSize", "13px")
11980  	dom.AddEventListener(blossomSave, "click", dom.RegisterCallback(func() {
11981  		val := dom.GetProperty(blossomInput, "value")
11982  		appSt.blossomServerURL = val
11983  		if val == "" {
11984  			localstorage.RemoveItem(lsKeyBlossomServer)
11985  		} else {
11986  			localstorage.SetItem(lsKeyBlossomServer, val)
11987  		}
11988  		saveAppSettings()
11989  		dom.SetTextContent(blossomSave, "saved!")
11990  		dom.SetTimeout(func() {
11991  			dom.SetTextContent(blossomSave, "save")
11992  		}, 1500)
11993  	}))
11994  	dom.AppendChild(blossomRow, blossomSave)
11995  	dom.AppendChild(appSt.settingsPage, blossomRow)
11996  
11997  	// Invidious URL setting.
11998  	appendServiceURLSetting(appSt.settingsPage, "invidious server", appSt.invidiousURL,
11999  		defaultServiceURL("invidious"), lsKeyInvidiousURL, func(v string) { appSt.invidiousURL = v },
12000  		"use invidious.smesh.lol")
12001  
12002  	// Campion (Bandcamp player) URL setting.
12003  	appendServiceURLSetting(appSt.settingsPage, "campion (bandcamp)", appSt.campionURL,
12004  		defaultServiceURL("campion"), lsKeyCampionURL, func(v string) { appSt.campionURL = v },
12005  		"use campion.smesh.lol")
12006  
12007  	renderRelaysSettings()
12008  }
12009  
12010  func addRelayURL(url string) {
12011  	addRelay(url, true)
12012  	sendWriteRelays()
12013  	populateFeedSelect()
12014  }
12015  
12016  type relayFreqEntry struct {
12017  	url  string
12018  	freq int32
12019  }
12020  
12021  // sortRelaysByFreq: relayFreq moved to Profile Worker; Shell returns empty.
12022  func sortRelaysByFreq() (ss []relayFreqEntry) { return nil }
12023  
12024  // renderRelaysSettings renders the "Relays" section of the settings page:
12025  // active relays with capability badges, required-capability checkboxes, and
12026  // the blocklist. Called from renderSettings(). Read-only behavior at this
12027  // step: checkboxes persist to localStorage but no enforcement is applied.
12028  // appendServiceURLSetting appends a labelled URL input + "use X" prefill button + save button.
12029  // onSet is called with the new value when saved. prefillLabel is the text on the prefill button.
12030  func appendServiceURLSetting(parent dom.Element, label, current, defaultVal, lsKey string, onSet func(string), prefillLabel string) {
12031  	row := dom.CreateElement("div")
12032  	dom.SetStyle(row, "display", "flex")
12033  	dom.SetStyle(row, "flexWrap", "wrap")
12034  	dom.SetStyle(row, "alignItems", "center")
12035  	dom.SetStyle(row, "gap", "12px")
12036  	dom.SetStyle(row, "marginBottom", "16px")
12037  
12038  	lbl := dom.CreateElement("span")
12039  	dom.SetTextContent(lbl, label)
12040  	dom.SetStyle(lbl, "fontSize", "14px")
12041  	dom.SetStyle(lbl, "minWidth", "160px")
12042  	dom.AppendChild(row, lbl)
12043  
12044  	input := dom.CreateElement("input")
12045  	dom.SetAttribute(input, "type", "text")
12046  	dom.SetAttribute(input, "placeholder", "https://...")
12047  	val := current
12048  	if val == "" && defaultVal != "" {
12049  		val = defaultVal
12050  	}
12051  	dom.SetProperty(input, "value", val)
12052  	dom.SetStyle(input, "fontFamily", "'Fira Code', monospace")
12053  	dom.SetStyle(input, "fontSize", "13px")
12054  	dom.SetStyle(input, "background", "var(--bg)")
12055  	dom.SetStyle(input, "color", "var(--fg)")
12056  	dom.SetStyle(input, "border", "1px solid var(--border)")
12057  	dom.SetStyle(input, "borderRadius", "4px")
12058  	dom.SetStyle(input, "padding", "6px 12px")
12059  	dom.SetStyle(input, "flex", "1")
12060  	dom.SetStyle(input, "minWidth", "200px")
12061  	dom.AppendChild(row, input)
12062  
12063  	if defaultVal != "" {
12064  		prefillBtn := dom.CreateElement("button")
12065  		dom.SetTextContent(prefillBtn, prefillLabel)
12066  		dom.SetStyle(prefillBtn, "padding", "6px 10px")
12067  		dom.SetStyle(prefillBtn, "borderRadius", "4px")
12068  		dom.SetStyle(prefillBtn, "border", "1px solid var(--border)")
12069  		dom.SetStyle(prefillBtn, "background", "transparent")
12070  		dom.SetStyle(prefillBtn, "color", "var(--fg)")
12071  		dom.SetStyle(prefillBtn, "cursor", "pointer")
12072  		dom.SetStyle(prefillBtn, "fontSize", "12px")
12073  		capturedInput := input
12074  		capturedDefault := defaultVal
12075  		dom.AddEventListener(prefillBtn, "click", dom.RegisterCallback(func() {
12076  			dom.SetProperty(capturedInput, "value", capturedDefault)
12077  		}))
12078  		dom.AppendChild(row, prefillBtn)
12079  	}
12080  
12081  	saveBtn := dom.CreateElement("button")
12082  	dom.SetTextContent(saveBtn, "save")
12083  	dom.SetStyle(saveBtn, "padding", "6px 16px")
12084  	dom.SetStyle(saveBtn, "borderRadius", "4px")
12085  	dom.SetStyle(saveBtn, "border", "1px solid var(--border)")
12086  	dom.SetStyle(saveBtn, "background", "var(--accent)")
12087  	dom.SetStyle(saveBtn, "color", "var(--bg)")
12088  	dom.SetStyle(saveBtn, "cursor", "pointer")
12089  	dom.SetStyle(saveBtn, "fontSize", "13px")
12090  	capturedInput2 := input
12091  	capturedLsKey := lsKey
12092  	capturedOnSet := onSet
12093  	capturedSaveBtn := saveBtn
12094  	dom.AddEventListener(saveBtn, "click", dom.RegisterCallback(func() {
12095  		v := dom.GetProperty(capturedInput2, "value")
12096  		capturedOnSet(v)
12097  		if v == "" {
12098  			localstorage.RemoveItem(capturedLsKey)
12099  		} else {
12100  			localstorage.SetItem(capturedLsKey, v)
12101  		}
12102  		saveAppSettings()
12103  		dom.SetTextContent(capturedSaveBtn, "saved!")
12104  		dom.SetTimeout(func() { dom.SetTextContent(capturedSaveBtn, "save") }, 1500)
12105  	}))
12106  	dom.AppendChild(row, saveBtn)
12107  	dom.AppendChild(parent, row)
12108  }
12109  
12110  func renderRelaysSettings() {
12111  	// Section header.
12112  	hdr := dom.CreateElement("h3")
12113  	dom.SetTextContent(hdr, t("relays_section"))
12114  	dom.SetStyle(hdr, "fontSize", "16px")
12115  	dom.SetStyle(hdr, "marginTop", "32px")
12116  	dom.SetStyle(hdr, "marginBottom", "12px")
12117  	dom.AppendChild(appSt.settingsPage, hdr)
12118  
12119  	// --- Required capabilities checkboxes ---
12120  	reqHdr := dom.CreateElement("div")
12121  	dom.SetTextContent(reqHdr, t("relays_required"))
12122  	dom.SetStyle(reqHdr, "fontSize", "13px")
12123  	dom.SetStyle(reqHdr, "color", "var(--muted)")
12124  	dom.SetStyle(reqHdr, "marginBottom", "6px")
12125  	dom.AppendChild(appSt.settingsPage, reqHdr)
12126  
12127  	addPolicyCheckbox := func(label string, bit int32) {
12128  		row := dom.CreateElement("label")
12129  		dom.SetStyle(row, "display", "flex")
12130  		dom.SetStyle(row, "alignItems", "center")
12131  		dom.SetStyle(row, "gap", "8px")
12132  		dom.SetStyle(row, "padding", "4px 0")
12133  		dom.SetStyle(row, "fontSize", "13px")
12134  		dom.SetStyle(row, "cursor", "pointer")
12135  
12136  		cb := dom.CreateElement("input")
12137  		dom.SetAttribute(cb, "type", "checkbox")
12138  		if appSt.relayPolicyFlags&bit != 0 {
12139  			dom.SetAttribute(cb, "checked", "checked")
12140  		}
12141  		dom.AddEventListener(cb, "change", dom.RegisterCallback(func() {
12142  			if dom.GetProperty(cb, "checked") == "true" {
12143  				appSt.relayPolicyFlags = appSt.relayPolicyFlags | bit
12144  			} else {
12145  				appSt.relayPolicyFlags = appSt.relayPolicyFlags &^ bit
12146  			}
12147  			saveRelayPolicy()
12148  		}))
12149  		dom.AppendChild(row, cb)
12150  
12151  		lblEl := dom.CreateElement("span")
12152  		dom.SetTextContent(lblEl, label)
12153  		dom.AppendChild(row, lblEl)
12154  
12155  		dom.AppendChild(appSt.settingsPage, row)
12156  	}
12157  
12158  	addPolicyCheckbox(t("relays_req_nip42"), policyRequireAuth)
12159  	addPolicyCheckbox(t("relays_req_nip70"), policyRequireProtect)
12160  	addPolicyCheckbox(t("relays_block_payment"), policyBlockPayment)
12161  	addPolicyCheckbox(t("relays_block_restrict"), policyBlockRestricted)
12162  
12163  	// --- Active relays list ---
12164  	activeHdr := dom.CreateElement("div")
12165  	dom.SetTextContent(activeHdr, t("relays_active"))
12166  	dom.SetStyle(activeHdr, "fontSize", "13px")
12167  	dom.SetStyle(activeHdr, "color", "var(--muted)")
12168  	dom.SetStyle(activeHdr, "marginTop", "20px")
12169  	dom.SetStyle(activeHdr, "marginBottom", "6px")
12170  	dom.AppendChild(appSt.settingsPage, activeHdr)
12171  
12172  	for i, url := range appSt.relayURLs {
12173  		thisURL := url // capture per-iteration for closures
12174  		row := dom.CreateElement("div")
12175  		dom.SetStyle(row, "padding", "3px 0")
12176  		dom.SetStyle(row, "fontSize", "12px")
12177  		dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
12178  		dom.SetStyle(row, "wordBreak", "break-all")
12179  		dom.SetStyle(row, "display", "flex")
12180  		dom.SetStyle(row, "alignItems", "center")
12181  		dom.SetStyle(row, "gap", "6px")
12182  
12183  		// Block button first so it sits at the left edge.
12184  		blockBtn := dom.CreateElement("button")
12185  		dom.SetTextContent(blockBtn, "\u00D7")
12186  		dom.SetStyle(blockBtn, "background", "transparent")
12187  		dom.SetStyle(blockBtn, "border", "1px solid var(--border)")
12188  		dom.SetStyle(blockBtn, "color", "var(--fg)")
12189  		dom.SetStyle(blockBtn, "cursor", "pointer")
12190  		dom.SetStyle(blockBtn, "fontFamily", "'Fira Code', monospace")
12191  		dom.SetStyle(blockBtn, "fontSize", "11px")
12192  		dom.SetStyle(blockBtn, "width", "18px")
12193  		dom.SetStyle(blockBtn, "height", "18px")
12194  		dom.SetStyle(blockBtn, "padding", "0")
12195  		dom.SetStyle(blockBtn, "lineHeight", "1")
12196  		dom.SetStyle(blockBtn, "flexShrink", "0")
12197  		dom.AddEventListener(blockBtn, "click", dom.RegisterCallback(func() {
12198  			if !dom.Confirm("Block " | thisURL | "?") {
12199  				return
12200  			}
12201  			addToBlocklist(thisURL)
12202  			renderSettings()
12203  		}))
12204  		dom.AppendChild(row, blockBtn)
12205  
12206  		urlSpan := dom.CreateElement("span")
12207  		dom.SetTextContent(urlSpan, thisURL)
12208  		dom.SetStyle(urlSpan, "flex", "1")
12209  		dom.SetStyle(urlSpan, "overflow", "hidden")
12210  		dom.SetStyle(urlSpan, "textOverflow", "ellipsis")
12211  		dom.SetStyle(urlSpan, "whiteSpace", "nowrap")
12212  		if appSt.relayUserPick[i] {
12213  			dom.SetStyle(urlSpan, "fontWeight", "bold")
12214  		}
12215  		dom.AppendChild(row, urlSpan)
12216  
12217  		// Read / write role buttons.
12218  		thisI := i
12219  		makeSettingsRoleBtn := func(label string) dom.Element {
12220  			b := dom.CreateElement("button")
12221  			dom.SetTextContent(b, label)
12222  			dom.SetStyle(b, "flexShrink", "0")
12223  			dom.SetStyle(b, "fontSize", "10px")
12224  			dom.SetStyle(b, "fontFamily", "'Fira Code', monospace")
12225  			dom.SetStyle(b, "padding", "1px 5px")
12226  			dom.SetStyle(b, "borderRadius", "3px")
12227  			dom.SetStyle(b, "border", "1px solid var(--border)")
12228  			dom.SetStyle(b, "cursor", "pointer")
12229  			return b
12230  		}
12231  		srBtn := makeSettingsRoleBtn("r")
12232  		swBtn := makeSettingsRoleBtn("w")
12233  		role := "both"
12234  		if thisI < len(appSt.relayRole) {
12235  			role = appSt.relayRole[thisI]
12236  		}
12237  		applyRelayRoleBtns(srBtn, swBtn, role)
12238  		capturedI := thisI
12239  		dom.AddEventListener(srBtn, "click", dom.RegisterCallback(func() {
12240  			if capturedI >= len(appSt.relayRole) {
12241  				return
12242  			}
12243  			switch appSt.relayRole[capturedI] {
12244  			case "both":
12245  				appSt.relayRole[capturedI] = "write"
12246  			case "read":
12247  				appSt.relayRole[capturedI] = "write"
12248  			default:
12249  				appSt.relayRole[capturedI] = "both"
12250  			}
12251  			if capturedI < len(appSt.relayReadBtns) {
12252  				applyRelayRoleBtns(appSt.relayReadBtns[capturedI], appSt.relayWriteBtns[capturedI], appSt.relayRole[capturedI])
12253  			}
12254  			applyRelayRoleBtns(srBtn, swBtn, appSt.relayRole[capturedI])
12255  			saveRelayList()
12256  			sendWriteRelays()
12257  		}))
12258  		dom.AddEventListener(swBtn, "click", dom.RegisterCallback(func() {
12259  			if capturedI >= len(appSt.relayRole) {
12260  				return
12261  			}
12262  			switch appSt.relayRole[capturedI] {
12263  			case "both":
12264  				appSt.relayRole[capturedI] = "read"
12265  			case "write":
12266  				appSt.relayRole[capturedI] = "read"
12267  			default:
12268  				appSt.relayRole[capturedI] = "both"
12269  			}
12270  			if capturedI < len(appSt.relayReadBtns) {
12271  				applyRelayRoleBtns(appSt.relayReadBtns[capturedI], appSt.relayWriteBtns[capturedI], appSt.relayRole[capturedI])
12272  			}
12273  			applyRelayRoleBtns(srBtn, swBtn, appSt.relayRole[capturedI])
12274  			saveRelayList()
12275  			sendWriteRelays()
12276  		}))
12277  		caps := appSt.relayCaps[i]
12278  		if !hasNIP(caps.supportedNIPs, 42) {
12279  			dom.AppendChild(row, makeRelayBadge("!42"))
12280  		}
12281  		if !hasNIP(caps.supportedNIPs, 70) {
12282  			dom.AppendChild(row, makeRelayBadge("!70"))
12283  		}
12284  		if caps.fetchedOK && hasNIP(caps.supportedNIPs, 50) {
12285  			badge := makeRelayBadge("search")
12286  			dom.SetStyle(badge, "background", "rgba(0,180,80,0.15)")
12287  			dom.SetStyle(badge, "color", "#0a0")
12288  			dom.SetStyle(badge, "border", "1px solid rgba(0,180,80,0.3)")
12289  			dom.AppendChild(row, badge)
12290  		}
12291  		if !caps.fetchedOK {
12292  			noDoc := dom.CreateElement("span")
12293  			dom.SetTextContent(noDoc, t("relays_no_nip11"))
12294  			dom.SetStyle(noDoc, "fontSize", "10px")
12295  			dom.SetStyle(noDoc, "color", "var(--muted)")
12296  			dom.SetStyle(noDoc, "marginLeft", "6px")
12297  			dom.AppendChild(row, noDoc)
12298  		}
12299  
12300  		// r/w buttons pushed to far right.
12301  		dom.SetStyle(srBtn, "marginLeft", "auto")
12302  		dom.AppendChild(row, srBtn)
12303  		dom.AppendChild(row, swBtn)
12304  		dom.AppendChild(appSt.settingsPage, row)
12305  	}
12306  
12307  	// --- Add relay input ---
12308  	addRow := dom.CreateElement("div")
12309  	dom.SetStyle(addRow, "display", "flex")
12310  	dom.SetStyle(addRow, "alignItems", "center")
12311  	dom.SetStyle(addRow, "gap", "6px")
12312  	dom.SetStyle(addRow, "marginTop", "8px")
12313  	dom.SetStyle(addRow, "position", "relative")
12314  
12315  	addInput := dom.CreateElement("input")
12316  	dom.SetAttribute(addInput, "type", "text")
12317  	dom.SetAttribute(addInput, "placeholder", "wss://relay.example.com")
12318  	dom.SetStyle(addInput, "flex", "1")
12319  	dom.SetStyle(addInput, "padding", "6px 8px")
12320  	dom.SetStyle(addInput, "fontSize", "12px")
12321  	dom.SetStyle(addInput, "fontFamily", "'Fira Code', monospace")
12322  	dom.SetStyle(addInput, "background", "var(--bg)")
12323  	dom.SetStyle(addInput, "color", "var(--fg)")
12324  	dom.SetStyle(addInput, "border", "1px solid var(--border)")
12325  	dom.SetStyle(addInput, "borderRadius", "4px")
12326  
12327  	addBtn := dom.CreateElement("button")
12328  	dom.SetTextContent(addBtn, "+")
12329  	dom.SetStyle(addBtn, "padding", "6px 10px")
12330  	dom.SetStyle(addBtn, "background", "var(--accent)")
12331  	dom.SetStyle(addBtn, "color", "#fff")
12332  	dom.SetStyle(addBtn, "border", "none")
12333  	dom.SetStyle(addBtn, "borderRadius", "4px")
12334  	dom.SetStyle(addBtn, "cursor", "pointer")
12335  	dom.SetStyle(addBtn, "fontWeight", "bold")
12336  	dom.AddEventListener(addBtn, "click", dom.RegisterCallback(func() {
12337  		url := dom.GetProperty(addInput, "value")
12338  		if url != "" {
12339  			addRelayURL(url)
12340  			dom.SetProperty(addInput, "value", "")
12341  			renderSettings()
12342  		}
12343  	}))
12344  
12345  	// Suggestion dropdown button.
12346  	sugWrap := dom.CreateElement("div")
12347  	dom.SetStyle(sugWrap, "position", "relative")
12348  
12349  	sugBtn := dom.CreateElement("button")
12350  	dom.SetTextContent(sugBtn, "\u25BE")
12351  	dom.SetStyle(sugBtn, "padding", "6px 8px")
12352  	dom.SetStyle(sugBtn, "background", "var(--bg2)")
12353  	dom.SetStyle(sugBtn, "color", "var(--fg)")
12354  	dom.SetStyle(sugBtn, "border", "1px solid var(--border)")
12355  	dom.SetStyle(sugBtn, "borderRadius", "4px")
12356  	dom.SetStyle(sugBtn, "cursor", "pointer")
12357  
12358  	sugPop := dom.CreateElement("div")
12359  	dom.SetStyle(sugPop, "display", "none")
12360  	dom.SetStyle(sugPop, "position", "absolute")
12361  	dom.SetStyle(sugPop, "bottom", "100%")
12362  	dom.SetStyle(sugPop, "right", "0")
12363  	dom.SetStyle(sugPop, "marginBottom", "4px")
12364  	dom.SetStyle(sugPop, "background", "var(--bg)")
12365  	dom.SetStyle(sugPop, "border", "1px solid var(--border)")
12366  	dom.SetStyle(sugPop, "borderRadius", "6px")
12367  	dom.SetStyle(sugPop, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)")
12368  	dom.SetStyle(sugPop, "zIndex", "1000")
12369  	dom.SetStyle(sugPop, "minWidth", "280px")
12370  	dom.SetStyle(sugPop, "maxHeight", "200px")
12371  	dom.SetStyle(sugPop, "overflowY", "auto")
12372  	dom.SetStyle(sugPop, "padding", "4px")
12373  
12374  	sugOpen := false
12375  	dom.AddEventListener(sugBtn, "click", dom.RegisterCallback(func() {
12376  		if sugOpen {
12377  			dom.SetStyle(sugPop, "display", "none")
12378  			sugOpen = false
12379  			return
12380  		}
12381  		// Build sorted suggestions.
12382  		clearChildren(sugPop)
12383  		sorted := sortRelaysByFreq()
12384  		count := 0
12385  		for _, entry := range sorted {
12386  			u := entry.url
12387  			// Skip already-active relays.
12388  			already := false
12389  			for _, active := range appSt.relayURLs {
12390  				if active == u {
12391  					already = true
12392  					break
12393  				}
12394  			}
12395  			if already {
12396  				continue
12397  			}
12398  			sugRow := dom.CreateElement("div")
12399  			dom.SetStyle(sugRow, "display", "flex")
12400  			dom.SetStyle(sugRow, "alignItems", "center")
12401  			dom.SetStyle(sugRow, "justifyContent", "space-between")
12402  			dom.SetStyle(sugRow, "padding", "4px 8px")
12403  			dom.SetStyle(sugRow, "cursor", "pointer")
12404  			dom.SetStyle(sugRow, "borderRadius", "4px")
12405  			dom.SetStyle(sugRow, "fontSize", "12px")
12406  			dom.SetStyle(sugRow, "fontFamily", "'Fira Code', monospace")
12407  
12408  			urlSpan := dom.CreateElement("span")
12409  			dom.SetTextContent(urlSpan, stripScheme(u))
12410  			dom.AppendChild(sugRow, urlSpan)
12411  
12412  			freqSpan := dom.CreateElement("span")
12413  			dom.SetTextContent(freqSpan, itoa(entry.freq))
12414  			dom.SetStyle(freqSpan, "color", "var(--muted)")
12415  			dom.SetStyle(freqSpan, "fontSize", "10px")
12416  			dom.AppendChild(sugRow, freqSpan)
12417  
12418  			dom.AddEventListener(sugRow, "mouseenter", dom.RegisterCallback(func() {
12419  				dom.SetStyle(sugRow, "background", "var(--bg2)")
12420  			}))
12421  			dom.AddEventListener(sugRow, "mouseleave", dom.RegisterCallback(func() {
12422  				dom.SetStyle(sugRow, "background", "transparent")
12423  			}))
12424  
12425  			capURL := u
12426  			dom.AddEventListener(sugRow, "click", dom.RegisterCallback(func() {
12427  				addRelayURL(capURL)
12428  				dom.SetStyle(sugPop, "display", "none")
12429  				sugOpen = false
12430  				renderSettings()
12431  			}))
12432  			dom.AppendChild(sugPop, sugRow)
12433  			count++
12434  		}
12435  		if count == 0 {
12436  			empty := dom.CreateElement("div")
12437  			dom.SetTextContent(empty, "no suggestions yet")
12438  			dom.SetStyle(empty, "color", "var(--muted)")
12439  			dom.SetStyle(empty, "fontSize", "12px")
12440  			dom.SetStyle(empty, "padding", "8px")
12441  			dom.AppendChild(sugPop, empty)
12442  		}
12443  		dom.SetStyle(sugPop, "display", "block")
12444  		sugOpen = true
12445  	}))
12446  
12447  	dom.AppendChild(sugWrap, sugBtn)
12448  	dom.AppendChild(sugWrap, sugPop)
12449  
12450  	dom.AppendChild(addRow, addInput)
12451  	dom.AppendChild(addRow, addBtn)
12452  	dom.AppendChild(addRow, sugWrap)
12453  	dom.AppendChild(appSt.settingsPage, addRow)
12454  
12455  	// --- Blocklist ---
12456  	blHdr := dom.CreateElement("div")
12457  	dom.SetTextContent(blHdr, t("relays_blocklist"))
12458  	dom.SetStyle(blHdr, "fontSize", "13px")
12459  	dom.SetStyle(blHdr, "color", "var(--muted)")
12460  	dom.SetStyle(blHdr, "marginTop", "20px")
12461  	dom.SetStyle(blHdr, "marginBottom", "6px")
12462  	dom.AppendChild(appSt.settingsPage, blHdr)
12463  
12464  	if len(appSt.relayBlocklist) == 0 {
12465  		empty := dom.CreateElement("div")
12466  		dom.SetTextContent(empty, t("relays_blocklist_empty"))
12467  		dom.SetStyle(empty, "fontSize", "12px")
12468  		dom.SetStyle(empty, "color", "var(--muted)")
12469  		dom.SetStyle(empty, "fontStyle", "italic")
12470  		dom.AppendChild(appSt.settingsPage, empty)
12471  	} else {
12472  		for _, url := range appSt.relayBlocklist {
12473  			thisURL := url // capture per-iteration for closure
12474  			row := dom.CreateElement("div")
12475  			dom.SetStyle(row, "padding", "3px 0")
12476  			dom.SetStyle(row, "fontSize", "12px")
12477  			dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
12478  			dom.SetStyle(row, "wordBreak", "break-all")
12479  			dom.SetStyle(row, "display", "flex")
12480  			dom.SetStyle(row, "alignItems", "center")
12481  			dom.SetStyle(row, "gap", "6px")
12482  
12483  			unblockBtn := dom.CreateElement("button")
12484  			dom.SetTextContent(unblockBtn, "\u00D7")
12485  			dom.SetStyle(unblockBtn, "background", "transparent")
12486  			dom.SetStyle(unblockBtn, "border", "1px solid var(--border)")
12487  			dom.SetStyle(unblockBtn, "color", "var(--fg)")
12488  			dom.SetStyle(unblockBtn, "cursor", "pointer")
12489  			dom.SetStyle(unblockBtn, "fontFamily", "'Fira Code', monospace")
12490  			dom.SetStyle(unblockBtn, "fontSize", "11px")
12491  			dom.SetStyle(unblockBtn, "width", "18px")
12492  			dom.SetStyle(unblockBtn, "height", "18px")
12493  			dom.SetStyle(unblockBtn, "padding", "0")
12494  			dom.SetStyle(unblockBtn, "lineHeight", "1")
12495  			dom.SetStyle(unblockBtn, "flexShrink", "0")
12496  			dom.AddEventListener(unblockBtn, "click", dom.RegisterCallback(func() {
12497  				removeFromBlocklist(thisURL)
12498  				renderSettings()
12499  			}))
12500  			dom.AppendChild(row, unblockBtn)
12501  
12502  			urlSpan := dom.CreateElement("span")
12503  			dom.SetTextContent(urlSpan, thisURL)
12504  			dom.AppendChild(row, urlSpan)
12505  
12506  			dom.AppendChild(appSt.settingsPage, row)
12507  		}
12508  	}
12509  
12510  	// --- Add to blocklist input ---
12511  	blAddRow := dom.CreateElement("div")
12512  	dom.SetStyle(blAddRow, "display", "flex")
12513  	dom.SetStyle(blAddRow, "alignItems", "center")
12514  	dom.SetStyle(blAddRow, "gap", "6px")
12515  	dom.SetStyle(blAddRow, "marginTop", "8px")
12516  	dom.SetStyle(blAddRow, "position", "relative")
12517  
12518  	blAddInput := dom.CreateElement("input")
12519  	dom.SetAttribute(blAddInput, "type", "text")
12520  	dom.SetAttribute(blAddInput, "placeholder", "wss://relay.example.com")
12521  	dom.SetStyle(blAddInput, "flex", "1")
12522  	dom.SetStyle(blAddInput, "padding", "6px 8px")
12523  	dom.SetStyle(blAddInput, "fontSize", "12px")
12524  	dom.SetStyle(blAddInput, "fontFamily", "'Fira Code', monospace")
12525  	dom.SetStyle(blAddInput, "background", "var(--bg)")
12526  	dom.SetStyle(blAddInput, "color", "var(--fg)")
12527  	dom.SetStyle(blAddInput, "border", "1px solid var(--border)")
12528  	dom.SetStyle(blAddInput, "borderRadius", "4px")
12529  
12530  	blAddBtn := dom.CreateElement("button")
12531  	dom.SetTextContent(blAddBtn, "+")
12532  	dom.SetStyle(blAddBtn, "padding", "6px 10px")
12533  	dom.SetStyle(blAddBtn, "background", "var(--accent)")
12534  	dom.SetStyle(blAddBtn, "color", "#fff")
12535  	dom.SetStyle(blAddBtn, "border", "none")
12536  	dom.SetStyle(blAddBtn, "borderRadius", "4px")
12537  	dom.SetStyle(blAddBtn, "cursor", "pointer")
12538  	dom.SetStyle(blAddBtn, "fontWeight", "bold")
12539  	dom.AddEventListener(blAddBtn, "click", dom.RegisterCallback(func() {
12540  		url := dom.GetProperty(blAddInput, "value")
12541  		if url != "" {
12542  			addToBlocklist(url)
12543  			dom.SetProperty(blAddInput, "value", "")
12544  			renderSettings()
12545  		}
12546  	}))
12547  
12548  	// Suggestion dropdown button for blocklist.
12549  	blSugWrap := dom.CreateElement("div")
12550  	dom.SetStyle(blSugWrap, "position", "relative")
12551  
12552  	blSugBtn := dom.CreateElement("button")
12553  	dom.SetTextContent(blSugBtn, "\u25BE")
12554  	dom.SetStyle(blSugBtn, "padding", "6px 8px")
12555  	dom.SetStyle(blSugBtn, "background", "var(--bg2)")
12556  	dom.SetStyle(blSugBtn, "color", "var(--fg)")
12557  	dom.SetStyle(blSugBtn, "border", "1px solid var(--border)")
12558  	dom.SetStyle(blSugBtn, "borderRadius", "4px")
12559  	dom.SetStyle(blSugBtn, "cursor", "pointer")
12560  
12561  	blSugPop := dom.CreateElement("div")
12562  	dom.SetStyle(blSugPop, "display", "none")
12563  	dom.SetStyle(blSugPop, "position", "absolute")
12564  	dom.SetStyle(blSugPop, "bottom", "100%")
12565  	dom.SetStyle(blSugPop, "right", "0")
12566  	dom.SetStyle(blSugPop, "marginBottom", "4px")
12567  	dom.SetStyle(blSugPop, "background", "var(--bg)")
12568  	dom.SetStyle(blSugPop, "border", "1px solid var(--border)")
12569  	dom.SetStyle(blSugPop, "borderRadius", "6px")
12570  	dom.SetStyle(blSugPop, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)")
12571  	dom.SetStyle(blSugPop, "zIndex", "1000")
12572  	dom.SetStyle(blSugPop, "minWidth", "280px")
12573  	dom.SetStyle(blSugPop, "maxHeight", "200px")
12574  	dom.SetStyle(blSugPop, "overflowY", "auto")
12575  	dom.SetStyle(blSugPop, "padding", "4px")
12576  
12577  	blSugOpen := false
12578  	dom.AddEventListener(blSugBtn, "click", dom.RegisterCallback(func() {
12579  		if blSugOpen {
12580  			dom.SetStyle(blSugPop, "display", "none")
12581  			blSugOpen = false
12582  			return
12583  		}
12584  		clearChildren(blSugPop)
12585  		sorted := sortRelaysByFreq()
12586  		count := 0
12587  		for _, entry := range sorted {
12588  			u := entry.url
12589  			if isBlocked(u) {
12590  				continue
12591  			}
12592  			sugRow := dom.CreateElement("div")
12593  			dom.SetStyle(sugRow, "display", "flex")
12594  			dom.SetStyle(sugRow, "alignItems", "center")
12595  			dom.SetStyle(sugRow, "justifyContent", "space-between")
12596  			dom.SetStyle(sugRow, "padding", "4px 8px")
12597  			dom.SetStyle(sugRow, "cursor", "pointer")
12598  			dom.SetStyle(sugRow, "borderRadius", "4px")
12599  			dom.SetStyle(sugRow, "fontSize", "12px")
12600  			dom.SetStyle(sugRow, "fontFamily", "'Fira Code', monospace")
12601  
12602  			urlSpan := dom.CreateElement("span")
12603  			dom.SetTextContent(urlSpan, stripScheme(u))
12604  			dom.AppendChild(sugRow, urlSpan)
12605  
12606  			freqSpan := dom.CreateElement("span")
12607  			dom.SetTextContent(freqSpan, itoa(entry.freq))
12608  			dom.SetStyle(freqSpan, "color", "var(--muted)")
12609  			dom.SetStyle(freqSpan, "fontSize", "10px")
12610  			dom.AppendChild(sugRow, freqSpan)
12611  
12612  			dom.AddEventListener(sugRow, "mouseenter", dom.RegisterCallback(func() {
12613  				dom.SetStyle(sugRow, "background", "var(--bg2)")
12614  			}))
12615  			dom.AddEventListener(sugRow, "mouseleave", dom.RegisterCallback(func() {
12616  				dom.SetStyle(sugRow, "background", "transparent")
12617  			}))
12618  
12619  			capURL := u
12620  			dom.AddEventListener(sugRow, "click", dom.RegisterCallback(func() {
12621  				addToBlocklist(capURL)
12622  				dom.SetStyle(blSugPop, "display", "none")
12623  				blSugOpen = false
12624  				renderSettings()
12625  			}))
12626  			dom.AppendChild(blSugPop, sugRow)
12627  			count++
12628  		}
12629  		if count == 0 {
12630  			empty := dom.CreateElement("div")
12631  			dom.SetTextContent(empty, "no suggestions yet")
12632  			dom.SetStyle(empty, "color", "var(--muted)")
12633  			dom.SetStyle(empty, "fontSize", "12px")
12634  			dom.SetStyle(empty, "padding", "8px")
12635  			dom.AppendChild(blSugPop, empty)
12636  		}
12637  		dom.SetStyle(blSugPop, "display", "block")
12638  		blSugOpen = true
12639  	}))
12640  
12641  	dom.AppendChild(blSugWrap, blSugBtn)
12642  	dom.AppendChild(blSugWrap, blSugPop)
12643  
12644  	dom.AppendChild(blAddRow, blAddInput)
12645  	dom.AppendChild(blAddRow, blAddBtn)
12646  	dom.AppendChild(blAddRow, blSugWrap)
12647  	dom.AppendChild(appSt.settingsPage, blAddRow)
12648  
12649  	// Wallet UI at the very bottom of the settings page.
12650  	if int32(appSt.walletPanel) != 0 {
12651  		dom.AppendChild(appSt.settingsPage, appSt.walletPanel)
12652  		renderWalletUI()
12653  	}
12654  }
12655  
12656  // --- Messaging ---
12657  
12658  // initMessaging is unreachable while messaging is disabled (no sidebar entry,
12659  // no /msg route). Keep it for the MLS/DM fix; re-enable by restoring the
12660  // sidebar button, the router cases, the switchPage case and the mlsWorkerTypes
12661  // routing in routeMsg.
12662  func initMessaging() {
12663  	// Render new-chat button immediately - don't wait for DM_LIST round-trip.
12664  	clearChildren(appSt.msgListContainer)
12665  
12666  	if !signer.HasSigner() {
12667  		notice := dom.CreateElement("div")
12668  		dom.SetStyle(notice, "padding", "24px")
12669  		dom.SetStyle(notice, "textAlign", "center")
12670  		dom.SetStyle(notice, "color", "var(--muted)")
12671  		dom.SetStyle(notice, "fontSize", "13px")
12672  		dom.SetStyle(notice, "lineHeight", "1.6")
12673  		dom.SetInnerHTML(notice, t("dm_notice"))
12674  		dom.AppendChild(appSt.msgListContainer, notice)
12675  		return
12676  	}
12677  
12678  	renderNewChatRow()
12679  
12680  	// Init MLS if not already done + request conversation list.
12681  	if !appSt.marmotInited {
12682  		appSt.marmotInited = true
12683  		routeMsg("[\"MLS_INIT\"," | relayURLsJSON() | "]")
12684  	}
12685  	routeMsg("[\"MLS_DM_LIST\"]")
12686  }
12687  
12688  func renderNewChatRow() {
12689  	row := dom.CreateElement("div")
12690  	dom.SetStyle(row, "display", "flex")
12691  	dom.SetStyle(row, "gap", "6px")
12692  	dom.SetStyle(row, "marginBottom", "8px")
12693  	dom.SetStyle(row, "alignItems", "center")
12694  
12695  	// --- Follows group (default visible): [follows ▾] [+] ---
12696  	followsGroup := dom.CreateElement("div")
12697  	dom.SetStyle(followsGroup, "display", "flex")
12698  	dom.SetStyle(followsGroup, "gap", "6px")
12699  	dom.SetStyle(followsGroup, "alignItems", "center")
12700  
12701  	selWrap := dom.CreateElement("div")
12702  	dom.SetStyle(selWrap, "position", "relative")
12703  
12704  	selBtn := dom.CreateElement("button")
12705  	dom.SetTextContent(selBtn, t("follows") | " \u25BE")
12706  	dom.SetStyle(selBtn, "padding", "8px 12px")
12707  	dom.SetStyle(selBtn, "fontFamily", "'Fira Code', monospace")
12708  	dom.SetStyle(selBtn, "fontSize", "12px")
12709  	dom.SetStyle(selBtn, "background", "var(--bg2)")
12710  	dom.SetStyle(selBtn, "border", "1px solid var(--border)")
12711  	dom.SetStyle(selBtn, "borderRadius", "4px")
12712  	dom.SetStyle(selBtn, "color", "var(--fg)")
12713  	dom.SetStyle(selBtn, "cursor", "pointer")
12714  
12715  	dropdown := dom.CreateElement("div")
12716  	dom.SetStyle(dropdown, "display", "none")
12717  	dom.SetStyle(dropdown, "position", "absolute")
12718  	dom.SetStyle(dropdown, "left", "0")
12719  	dom.SetStyle(dropdown, "top", "100%")
12720  	dom.SetStyle(dropdown, "marginTop", "4px")
12721  	dom.SetStyle(dropdown, "background", "var(--bg)")
12722  	dom.SetStyle(dropdown, "border", "1px solid var(--border)")
12723  	dom.SetStyle(dropdown, "borderRadius", "6px")
12724  	dom.SetStyle(dropdown, "maxHeight", "300px")
12725  	dom.SetStyle(dropdown, "overflowY", "auto")
12726  	dom.SetStyle(dropdown, "minWidth", "200px")
12727  	dom.SetStyle(dropdown, "maxWidth", "calc(100vw - 32px)")
12728  	dom.SetStyle(dropdown, "zIndex", "100")
12729  	dom.SetStyle(dropdown, "boxShadow", "0 4px 12px rgba(0,0,0,0.3)")
12730  
12731  	populateFollowsDropdown(dropdown)
12732  
12733  	dom.SetAttribute(selWrap, "onclick", "event.stopPropagation()")
12734  
12735  	dropOpen := false
12736  	dom.AddEventListener(selBtn, "click", dom.RegisterCallback(func() {
12737  		if !dropOpen {
12738  			populateFollowsDropdown(dropdown)
12739  			dom.SetStyle(dropdown, "display", "block")
12740  			dropOpen = true
12741  		} else {
12742  			dom.SetStyle(dropdown, "display", "none")
12743  			dropOpen = false
12744  		}
12745  	}))
12746  
12747  	dom.AddEventListener(dom.Body(), "click", dom.RegisterCallback(func() {
12748  		dom.SetStyle(dropdown, "display", "none")
12749  		dropOpen = false
12750  	}))
12751  
12752  	dom.AppendChild(selWrap, selBtn)
12753  	dom.AppendChild(selWrap, dropdown)
12754  
12755  	addBtn := dom.CreateElement("button")
12756  	dom.SetTextContent(addBtn, "+")
12757  	dom.SetStyle(addBtn, "padding", "8px 10px")
12758  	dom.SetStyle(addBtn, "fontFamily", "'Fira Code', monospace")
12759  	dom.SetStyle(addBtn, "fontSize", "14px")
12760  	dom.SetStyle(addBtn, "fontWeight", "bold")
12761  	dom.SetStyle(addBtn, "background", "var(--bg2)")
12762  	dom.SetStyle(addBtn, "border", "1px solid var(--border)")
12763  	dom.SetStyle(addBtn, "borderRadius", "4px")
12764  	dom.SetStyle(addBtn, "color", "var(--fg)")
12765  	dom.SetStyle(addBtn, "cursor", "pointer")
12766  	dom.SetStyle(addBtn, "lineHeight", "1")
12767  
12768  	dom.AppendChild(followsGroup, selWrap)
12769  	dom.AppendChild(followsGroup, addBtn)
12770  
12771  	// --- Input group (hidden until + clicked): [input] [go] [×] ---
12772  	inputGroup := dom.CreateElement("div")
12773  	dom.SetStyle(inputGroup, "display", "none")
12774  	dom.SetStyle(inputGroup, "flex", "1")
12775  	dom.SetStyle(inputGroup, "gap", "6px")
12776  	dom.SetStyle(inputGroup, "alignItems", "center")
12777  
12778  	inp := dom.CreateElement("input")
12779  	dom.SetAttribute(inp, "type", "text")
12780  	dom.SetAttribute(inp, "placeholder", t("npub_placeholder"))
12781  	dom.SetStyle(inp, "flex", "1 1 120px")
12782  	dom.SetStyle(inp, "minWidth", "0")
12783  	dom.SetStyle(inp, "padding", "8px")
12784  	dom.SetStyle(inp, "fontFamily", "'Fira Code', monospace")
12785  	dom.SetStyle(inp, "fontSize", "12px")
12786  	dom.SetStyle(inp, "background", "var(--bg)")
12787  	dom.SetStyle(inp, "border", "1px solid var(--border)")
12788  	dom.SetStyle(inp, "borderRadius", "4px")
12789  	dom.SetStyle(inp, "color", "var(--fg)")
12790  
12791  	goBtn := dom.CreateElement("button")
12792  	dom.SetTextContent(goBtn, t("go"))
12793  	dom.SetStyle(goBtn, "padding", "8px 14px")
12794  	dom.SetStyle(goBtn, "fontFamily", "'Fira Code', monospace")
12795  	dom.SetStyle(goBtn, "fontSize", "12px")
12796  	dom.SetStyle(goBtn, "background", "var(--accent)")
12797  	dom.SetStyle(goBtn, "color", "#fff")
12798  	dom.SetStyle(goBtn, "border", "none")
12799  	dom.SetStyle(goBtn, "borderRadius", "4px")
12800  	dom.SetStyle(goBtn, "cursor", "pointer")
12801  	dom.SetStyle(goBtn, "flexShrink", "0")
12802  
12803  	closeBtn := dom.CreateElement("button")
12804  	dom.SetTextContent(closeBtn, "\u00D7")
12805  	dom.SetStyle(closeBtn, "padding", "8px 10px")
12806  	dom.SetStyle(closeBtn, "fontFamily", "'Fira Code', monospace")
12807  	dom.SetStyle(closeBtn, "fontSize", "14px")
12808  	dom.SetStyle(closeBtn, "fontWeight", "bold")
12809  	dom.SetStyle(closeBtn, "background", "var(--bg2)")
12810  	dom.SetStyle(closeBtn, "border", "1px solid var(--border)")
12811  	dom.SetStyle(closeBtn, "borderRadius", "4px")
12812  	dom.SetStyle(closeBtn, "color", "var(--fg)")
12813  	dom.SetStyle(closeBtn, "cursor", "pointer")
12814  	dom.SetStyle(closeBtn, "lineHeight", "1")
12815  
12816  	dom.AppendChild(inputGroup, inp)
12817  	dom.AppendChild(inputGroup, goBtn)
12818  	dom.AppendChild(inputGroup, closeBtn)
12819  
12820  	// Toggle between follows group and input group.
12821  	showInput := func() {
12822  		dom.SetStyle(dropdown, "display", "none")
12823  		dropOpen = false
12824  		dom.SetStyle(followsGroup, "display", "none")
12825  		dom.SetStyle(inputGroup, "display", "flex")
12826  		dom.SetProperty(inp, "value", "")
12827  		dom.Focus(inp)
12828  	}
12829  	showFollows := func() {
12830  		dom.SetStyle(inputGroup, "display", "none")
12831  		dom.SetStyle(followsGroup, "display", "flex")
12832  	}
12833  
12834  	dom.AddEventListener(addBtn, "click", dom.RegisterCallback(showInput))
12835  	dom.AddEventListener(closeBtn, "click", dom.RegisterCallback(showFollows))
12836  
12837  	// Submit handler for input + go button.
12838  	submitNewChat := func() {
12839  		val := dom.GetProperty(inp, "value")
12840  		if val == "" {
12841  			return
12842  		}
12843  		var hexPK string
12844  		if len(val) == 64 {
12845  			hexPK = val
12846  		} else if len(val) > 4 && val[:4] == "npub" {
12847  			decoded := helpers.DecodeNpub(val)
12848  			if decoded == nil {
12849  				return
12850  			}
12851  			hexPK = helpers.HexEncode(decoded)
12852  		} else {
12853  			return
12854  		}
12855  		openThread(hexPK)
12856  	}
12857  
12858  	dom.AddEventListener(goBtn, "click", dom.RegisterCallback(submitNewChat))
12859  	dom.SetAttribute(inp, "onkeydown", "if(event.key==='Enter'){event.preventDefault();this.nextSibling.click()}")
12860  
12861  	dom.AppendChild(row, followsGroup)
12862  	dom.AppendChild(row, inputGroup)
12863  	dom.AppendChild(appSt.msgListContainer, row)
12864  }
12865  
12866  func populateFollowsDropdown(dropdown dom.Element) {
12867  	clearChildren(dropdown)
12868  	pks := appSt.myFollows
12869  	if len(pks) == 0 {
12870  		empty := dom.CreateElement("div")
12871  		dom.SetStyle(empty, "padding", "12px")
12872  		dom.SetStyle(empty, "color", "var(--muted)")
12873  		dom.SetStyle(empty, "fontSize", "12px")
12874  		dom.SetTextContent(empty, t("no_follows"))
12875  		dom.AppendChild(dropdown, empty)
12876  		return
12877  	}
12878  	// Sort by display name (named first, then by name alpha, unnamed last by short hex).
12879  	type entry struct {
12880  		pk   string
12881  		name string
12882  	}
12883  	var named []entry
12884  	var unnamed []entry
12885  	for _, pk := range pks {
12886  		n := appSt.authorNames[pk]
12887  		if n != "" {
12888  			named = mxutil.Ensure(named, 1)
12889  			named = push(named, entry{pk, n})
12890  		} else {
12891  			npub := helpers.EncodeNpub(helpers.HexDecode(pk))
12892  			if len(npub) > 16 {
12893  				npub = npub[:16] | "..."
12894  			}
12895  			unnamed = mxutil.Ensure(unnamed, 1)
12896  			unnamed = push(unnamed, entry{pk, npub})
12897  		}
12898  	}
12899  	// Simple insertion sort by name.
12900  	for i := 1; i < len(named); i++ {
12901  		j := i
12902  		for j > 0 && toLower(named[j].name) < toLower(named[j-1].name) {
12903  			named[j], named[j-1] = named[j-1], named[j]
12904  			j--
12905  		}
12906  	}
12907  	var all []entry
12908  	for _, e := range named {
12909  		all = mxutil.Ensure(all, 1)
12910  		all = push(all, e)
12911  	}
12912  	for _, e := range unnamed {
12913  		all = mxutil.Ensure(all, 1)
12914  		all = push(all, e)
12915  	}
12916  	for _, e := range all {
12917  		item := dom.CreateElement("div")
12918  		dom.SetStyle(item, "padding", "6px 12px")
12919  		dom.SetStyle(item, "fontSize", "13px")
12920  		dom.SetStyle(item, "cursor", "pointer")
12921  		dom.SetStyle(item, "display", "flex")
12922  		dom.SetStyle(item, "alignItems", "center")
12923  		dom.SetStyle(item, "gap", "8px")
12924  
12925  		av := dom.CreateElement("img")
12926  		dom.SetAttribute(av, "referrerpolicy", "no-referrer")
12927  		dom.SetAttribute(av, "width", "24")
12928  		dom.SetAttribute(av, "height", "24")
12929  		dom.SetStyle(av, "borderRadius", "50%")
12930  		dom.SetStyle(av, "objectFit", "cover")
12931  		dom.SetStyle(av, "flexShrink", "0")
12932  		if pic, ok := appSt.authorPics[e.pk]; ok && pic != "" {
12933  			setMediaSrc(av, pic)
12934  		} else {
12935  			dom.SetStyle(av, "background", "var(--bg2)")
12936  		}
12937  		dom.SetAttribute(av, "onerror", "this.style.display='none'")
12938  		dom.AppendChild(item, av)
12939  
12940  		nameSpan := dom.CreateElement("span")
12941  		dom.SetStyle(nameSpan, "overflow", "hidden")
12942  		dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
12943  		dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
12944  		dom.SetTextContent(nameSpan, e.name)
12945  		dom.AppendChild(item, nameSpan)
12946  
12947  		pk := e.pk
12948  		dom.AddEventListener(item, "mouseenter", dom.RegisterCallback(func() {
12949  			dom.SetStyle(item, "background", "var(--bg2)")
12950  		}))
12951  		dom.AddEventListener(item, "mouseleave", dom.RegisterCallback(func() {
12952  			dom.SetStyle(item, "background", "transparent")
12953  		}))
12954  		dom.AddEventListener(item, "click", dom.RegisterCallback(func() {
12955  			dom.SetStyle(dropdown, "display", "none")
12956  			openThread(pk)
12957  		}))
12958  		dom.AppendChild(dropdown, item)
12959  	}
12960  }
12961  
12962  func renderConversationList(listJSON string) {
12963  	if appSt.msgView != "list" {
12964  		return
12965  	}
12966  	clearChildren(appSt.msgListContainer)
12967  	renderNewChatRow()
12968  
12969  	// Parse the list JSON array: [{peer,lastMessage,lastTs,from}, ...]
12970  	if listJSON == "" || listJSON == "[]" {
12971  		empty := dom.CreateElement("div")
12972  		dom.SetStyle(empty, "color", "var(--muted)")
12973  		dom.SetStyle(empty, "textAlign", "center")
12974  		dom.SetStyle(empty, "marginTop", "48px")
12975  		dom.SetTextContent(empty, t("no_convos"))
12976  		dom.AppendChild(appSt.msgListContainer, empty)
12977  		return
12978  	}
12979  
12980  	// Walk the JSON array manually - each element is an object.
12981  	i := 0
12982  	for i < len(listJSON) && listJSON[i] != '[' {
12983  		i++
12984  	}
12985  	i++ // skip '['
12986  	for i < len(listJSON) {
12987  		// Find next object.
12988  		for i < len(listJSON) && listJSON[i] != '{' {
12989  			if listJSON[i] == ']' {
12990  				return
12991  			}
12992  			i++
12993  		}
12994  		if i >= len(listJSON) {
12995  			break
12996  		}
12997  		// Extract the object.
12998  		objStart := i
12999  		depth := 0
13000  		for i < len(listJSON) {
13001  			if listJSON[i] == '{' {
13002  				depth++
13003  			} else if listJSON[i] == '}' {
13004  				depth--
13005  				if depth == 0 {
13006  					i++
13007  					break
13008  				}
13009  			} else if listJSON[i] == '"' {
13010  				i++
13011  				for i < len(listJSON) && listJSON[i] != '"' {
13012  					if listJSON[i] == '\\' {
13013  						i++
13014  					}
13015  					i++
13016  				}
13017  			}
13018  			i++
13019  		}
13020  		obj := listJSON[objStart:i]
13021  
13022  		peer := helpers.JsonGetString(obj, "peer")
13023  		lastMsg := helpers.JsonGetString(obj, "lastMessage")
13024  		lastTs := jsonGetNum(obj, "lastTs")
13025  		if peer == "" {
13026  			continue
13027  		}
13028  
13029  		renderConversationRow(peer, lastMsg, lastTs)
13030  	}
13031  }
13032  
13033  func renderConversationRow(peer, lastMsg string, lastTs int64) {
13034  	row := dom.CreateElement("div")
13035  	dom.SetStyle(row, "display", "flex")
13036  	dom.SetStyle(row, "alignItems", "center")
13037  	dom.SetStyle(row, "gap", "10px")
13038  	dom.SetStyle(row, "padding", "10px 4px")
13039  	dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
13040  	dom.SetStyle(row, "cursor", "pointer")
13041  
13042  	// Avatar.
13043  	av := dom.CreateElement("img")
13044  	dom.SetAttribute(av, "referrerpolicy", "no-referrer")
13045  	dom.SetAttribute(av, "width", "32")
13046  	dom.SetAttribute(av, "height", "32")
13047  	dom.SetStyle(av, "borderRadius", "50%")
13048  	dom.SetStyle(av, "objectFit", "cover")
13049  	dom.SetStyle(av, "flexShrink", "0")
13050  	if pic, ok := appSt.authorPics[peer]; ok && pic != "" {
13051  		setMediaSrc(av, pic)
13052  	} else {
13053  		dom.SetStyle(av, "background", "var(--bg2)")
13054  	}
13055  	dom.SetAttribute(av, "onerror", "this.style.display='none'")
13056  	dom.AppendChild(row, av)
13057  
13058  	// Name + preview column.
13059  	col := dom.CreateElement("div")
13060  	dom.SetStyle(col, "flex", "1")
13061  	dom.SetStyle(col, "minWidth", "0")
13062  
13063  	nameSpan := dom.CreateElement("div")
13064  	dom.SetStyle(nameSpan, "fontSize", "14px")
13065  	dom.SetStyle(nameSpan, "fontWeight", "bold")
13066  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
13067  	dom.SetStyle(nameSpan, "overflow", "hidden")
13068  	dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
13069  	dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
13070  	if name, ok := appSt.authorNames[peer]; ok && name != "" {
13071  		dom.SetTextContent(nameSpan, name)
13072  	} else {
13073  		npub := helpers.EncodeNpub(helpers.HexDecode(peer))
13074  		if len(npub) > 20 {
13075  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
13076  		} else {
13077  			dom.SetTextContent(nameSpan, npub)
13078  		}
13079  	}
13080  	dom.AppendChild(col, nameSpan)
13081  
13082  	preview := dom.CreateElement("div")
13083  	dom.SetStyle(preview, "fontSize", "12px")
13084  	dom.SetStyle(preview, "color", "var(--muted)")
13085  	dom.SetStyle(preview, "overflow", "hidden")
13086  	dom.SetStyle(preview, "textOverflow", "ellipsis")
13087  	dom.SetStyle(preview, "whiteSpace", "nowrap")
13088  	if len(lastMsg) > 80 {
13089  		lastMsg = lastMsg[:80] | "..."
13090  	}
13091  	dom.SetTextContent(preview, lastMsg)
13092  	dom.AppendChild(col, preview)
13093  	dom.AppendChild(row, col)
13094  
13095  	// Timestamp.
13096  	if lastTs > 0 {
13097  		tsSpan := dom.CreateElement("span")
13098  		dom.SetStyle(tsSpan, "fontSize", "11px")
13099  		dom.SetStyle(tsSpan, "color", "var(--muted)")
13100  		dom.SetStyle(tsSpan, "flexShrink", "0")
13101  		dom.SetTextContent(tsSpan, formatTime(lastTs))
13102  		dom.AppendChild(row, tsSpan)
13103  	}
13104  
13105  	rowPeer := peer
13106  	dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
13107  		openThread(rowPeer)
13108  	}))
13109  
13110  	// Lazy profile fetch - batched to avoid 50+ simultaneous subscriptions.
13111  	if _, cached := appSt.authorNames[peer]; !cached && !lookupFetchedK0(peer) {
13112  		profile.Send(`["P_RESOLVE",` | jstr(peer) | `]`)
13113  	}
13114  
13115  	dom.AppendChild(appSt.msgListContainer, row)
13116  }
13117  
13118  func openThread(peer string) {
13119  	appSt.msgCurrentConvoID = peer
13120  	appSt.msgCurrentKind = "dm"
13121  	appSt.msgView = "thread"
13122  
13123  	if !appSt.navPop {
13124  		npub := helpers.EncodeNpub(helpers.HexDecode(peer))
13125  		dom.PushState("/msg/" | npub)
13126  	}
13127  
13128  	// Hide list, show thread.
13129  	dom.SetStyle(appSt.msgListContainer, "display", "none")
13130  	dom.SetStyle(appSt.msgThreadContainer, "display", "flex")
13131  
13132  	// Build thread UI.
13133  	clearChildren(appSt.msgThreadContainer)
13134  
13135  	// Header: back + avatar + name.
13136  	hdr := dom.CreateElement("div")
13137  	dom.SetStyle(hdr, "display", "flex")
13138  	dom.SetStyle(hdr, "alignItems", "center")
13139  	dom.SetStyle(hdr, "gap", "10px")
13140  	dom.SetStyle(hdr, "padding", "12px 16px")
13141  	dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)")
13142  	dom.SetStyle(hdr, "flexShrink", "0")
13143  
13144  	backBtn := dom.CreateElement("button")
13145  	dom.SetInnerHTML(backBtn, "&#x2190;") // ←
13146  	dom.SetStyle(backBtn, "background", "none")
13147  	dom.SetStyle(backBtn, "border", "none")
13148  	dom.SetStyle(backBtn, "fontSize", "20px")
13149  	dom.SetStyle(backBtn, "cursor", "pointer")
13150  	dom.SetStyle(backBtn, "color", "var(--fg)")
13151  	dom.SetStyle(backBtn, "padding", "0")
13152  	dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() {
13153  		closeThread()
13154  	}))
13155  	dom.AppendChild(hdr, backBtn)
13156  
13157  	// Thread header avatar + name - uses same img-then-span structure
13158  	// as note headers so pendingNotes/updateNoteHeader can update them.
13159  	threadHdrInner := dom.CreateElement("div")
13160  	av := dom.CreateElement("img")
13161  	dom.SetAttribute(av, "referrerpolicy", "no-referrer")
13162  	dom.SetAttribute(av, "width", "28")
13163  	dom.SetAttribute(av, "height", "28")
13164  	dom.SetStyle(av, "borderRadius", "50%")
13165  	dom.SetStyle(av, "objectFit", "cover")
13166  	dom.SetStyle(av, "flexShrink", "0")
13167  	if pic, ok := appSt.authorPics[peer]; ok && pic != "" {
13168  		setMediaSrc(av, pic)
13169  	} else {
13170  		dom.SetStyle(av, "display", "none")
13171  	}
13172  	dom.SetAttribute(av, "onerror", "this.style.display='none'")
13173  	dom.AppendChild(threadHdrInner, av)
13174  
13175  	nameSpan := dom.CreateElement("span")
13176  	dom.SetStyle(nameSpan, "fontSize", "15px")
13177  	dom.SetStyle(nameSpan, "fontWeight", "bold")
13178  	dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
13179  	dom.SetStyle(nameSpan, "overflow", "hidden")
13180  	dom.SetStyle(nameSpan, "textOverflow", "ellipsis")
13181  	dom.SetStyle(nameSpan, "whiteSpace", "nowrap")
13182  	dom.SetStyle(nameSpan, "minWidth", "0")
13183  	if name, ok := appSt.authorNames[peer]; ok && len(name) > 0 {
13184  		dom.SetTextContent(nameSpan, name)
13185  	} else {
13186  		npub := helpers.EncodeNpub(helpers.HexDecode(peer))
13187  		if len(npub) > 20 {
13188  			dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:])
13189  		}
13190  	}
13191  	dom.AppendChild(threadHdrInner, nameSpan)
13192  	dom.SetStyle(threadHdrInner, "display", "flex")
13193  	dom.SetStyle(threadHdrInner, "alignItems", "center")
13194  	dom.SetStyle(threadHdrInner, "gap", "10px")
13195  	dom.SetStyle(threadHdrInner, "flexGrow", "1")
13196  	dom.SetStyle(threadHdrInner, "minWidth", "0")
13197  	dom.SetStyle(threadHdrInner, "cursor", "pointer")
13198  	profilePeer := peer
13199  	dom.AddEventListener(threadHdrInner, "click", dom.RegisterCallback(func() {
13200  		showProfile(profilePeer)
13201  	}))
13202  	dom.AppendChild(hdr, threadHdrInner)
13203  
13204  	// Relay selector button.
13205  	dmRelayWrap := dom.CreateElement("div")
13206  	dom.SetStyle(dmRelayWrap, "position", "relative")
13207  	dom.SetStyle(dmRelayWrap, "display", "flex")
13208  	dom.SetStyle(dmRelayWrap, "alignItems", "center")
13209  	dom.SetStyle(dmRelayWrap, "gap", "6px")
13210  
13211  	dmWarnBadge := makeRelayWarnBadge()
13212  
13213  	dmRelayBtn := dom.CreateElement("button")
13214  	dom.SetStyle(dmRelayBtn, "background", "none")
13215  	dom.SetStyle(dmRelayBtn, "border", "1px solid var(--border)")
13216  	dom.SetStyle(dmRelayBtn, "borderRadius", "4px")
13217  	dom.SetStyle(dmRelayBtn, "color", "var(--fg)")
13218  	dom.SetStyle(dmRelayBtn, "cursor", "pointer")
13219  	dom.SetStyle(dmRelayBtn, "fontSize", "11px")
13220  	dom.SetStyle(dmRelayBtn, "padding", "4px 8px")
13221  	dom.SetStyle(dmRelayBtn, "fontFamily", "'Fira Code', monospace")
13222  	updateRelaySelBtn(dmRelayBtn, false)
13223  
13224  	dmRelayPop := dom.CreateElement("div")
13225  	dom.SetStyle(dmRelayPop, "display", "none")
13226  	dom.SetStyle(dmRelayPop, "position", "absolute")
13227  	dom.SetStyle(dmRelayPop, "top", "100%")
13228  	dom.SetStyle(dmRelayPop, "right", "0")
13229  	dom.SetStyle(dmRelayPop, "marginTop", "4px")
13230  	dom.SetStyle(dmRelayPop, "background", "var(--bg)")
13231  	dom.SetStyle(dmRelayPop, "border", "1px solid var(--border)")
13232  	dom.SetStyle(dmRelayPop, "borderRadius", "6px")
13233  	dom.SetStyle(dmRelayPop, "padding", "8px")
13234  	dom.SetStyle(dmRelayPop, "boxShadow", "0 2px 8px rgba(0,0,0,0.2)")
13235  	dom.SetStyle(dmRelayPop, "zIndex", "1000")
13236  	dom.SetStyle(dmRelayPop, "minWidth", "200px")
13237  	dom.SetStyle(dmRelayPop, "maxWidth", "calc(100vw - 24px)")
13238  	dom.SetStyle(dmRelayPop, "maxHeight", "200px")
13239  	dom.SetStyle(dmRelayPop, "overflowY", "auto")
13240  
13241  	dmRelayOpen := false
13242  	dom.AddEventListener(dmRelayBtn, "click", dom.RegisterCallback(func() {
13243  		if dmRelayOpen {
13244  			dom.SetStyle(dmRelayPop, "display", "none")
13245  			dmRelayOpen = false
13246  			updateRelaySelBtn(dmRelayBtn, false)
13247  		} else {
13248  			populateRelayPopover(dmRelayPop, func() {
13249  				updateRelaySelBtn(dmRelayBtn, true)
13250  				updateRelayWarnBadge(dmWarnBadge)
13251  			})
13252  			dom.SetStyle(dmRelayPop, "display", "block")
13253  			dmRelayOpen = true
13254  			updateRelaySelBtn(dmRelayBtn, true)
13255  		}
13256  	}))
13257  
13258  	dom.AppendChild(dmRelayWrap, dmWarnBadge)
13259  	dom.AppendChild(dmRelayWrap, dmRelayBtn)
13260  	dom.AppendChild(dmRelayWrap, dmRelayPop)
13261  	dom.AppendChild(hdr, dmRelayWrap)
13262  
13263  	// Delete + ratchet button.
13264  	ratchetBtn := dom.CreateElement("button")
13265  	dom.SetInnerHTML(ratchetBtn, "&#x1F5D1;") // 🗑
13266  	dom.SetAttribute(ratchetBtn, "title", "Delete messages and rotate keys")
13267  	dom.SetStyle(ratchetBtn, "background", "none")
13268  	dom.SetStyle(ratchetBtn, "border", "none")
13269  	dom.SetStyle(ratchetBtn, "cursor", "pointer")
13270  	dom.SetStyle(ratchetBtn, "fontSize", "16px")
13271  	dom.SetStyle(ratchetBtn, "padding", "4px")
13272  	dom.AddEventListener(ratchetBtn, "click", dom.RegisterCallback(func() {
13273  		if dom.Confirm("Delete all messages and rotate encryption keys?") {
13274  			routeMsg("[\"MLS_RATCHET_DM\"," | jstr(peer) | "]")
13275  			clearChildren(appSt.msgThreadMessages)
13276  		}
13277  	}))
13278  	dom.AppendChild(hdr, ratchetBtn)
13279  
13280  	dom.AppendChild(appSt.msgThreadContainer, hdr)
13281  
13282  	// Track for live update when profile arrives.
13283  	if _, cached := appSt.authorNames[peer]; !cached {
13284  		appSt.pendingNotes[peer] = mxutil.Ensure(appSt.pendingNotes[peer], 1)
13285  		appSt.pendingNotes[peer] = push(appSt.pendingNotes[peer], threadHdrInner)
13286  	}
13287  
13288  	// Message area.
13289  	appSt.msgThreadMessages = dom.CreateElement("div")
13290  	dom.SetStyle(appSt.msgThreadMessages, "flex", "1")
13291  	dom.SetStyle(appSt.msgThreadMessages, "overflowY", "auto")
13292  	dom.SetStyle(appSt.msgThreadMessages, "padding", "12px 16px")
13293  	dom.AppendChild(appSt.msgThreadContainer, appSt.msgThreadMessages)
13294  
13295  	// Compose area.
13296  	compose := dom.CreateElement("div")
13297  	dom.SetStyle(compose, "display", "flex")
13298  	dom.SetStyle(compose, "gap", "8px")
13299  	dom.SetStyle(compose, "padding", "8px 16px")
13300  	dom.SetStyle(compose, "borderTop", "1px solid var(--border)")
13301  	dom.SetStyle(compose, "flexShrink", "0")
13302  
13303  	appSt.msgComposeInput = dom.CreateElement("textarea")
13304  	dom.SetAttribute(appSt.msgComposeInput, "placeholder", t("msg_placeholder"))
13305  	dom.SetStyle(appSt.msgComposeInput, "flex", "1")
13306  	dom.SetStyle(appSt.msgComposeInput, "padding", "8px")
13307  	dom.SetStyle(appSt.msgComposeInput, "fontFamily", "'Fira Code', monospace")
13308  	dom.SetStyle(appSt.msgComposeInput, "fontSize", "13px")
13309  	dom.SetStyle(appSt.msgComposeInput, "background", "var(--bg)")
13310  	dom.SetStyle(appSt.msgComposeInput, "border", "1px solid var(--border)")
13311  	dom.SetStyle(appSt.msgComposeInput, "borderRadius", "4px")
13312  	dom.SetStyle(appSt.msgComposeInput, "color", "var(--fg)")
13313  	dom.SetStyle(appSt.msgComposeInput, "minHeight", "22px")
13314  	dom.SetStyle(appSt.msgComposeInput, "maxHeight", "120px")
13315  	dom.SetStyle(appSt.msgComposeInput, "overflowY", "auto")
13316  	dom.SetAttribute(appSt.msgComposeInput, "onkeydown", "if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.nextSibling.click()}")
13317  	wireBlossomHandlers(appSt.msgComposeInput)
13318  	wireMentionHandler(appSt.msgComposeInput)
13319  	dom.AppendChild(compose, appSt.msgComposeInput)
13320  
13321  	sendBtn := dom.CreateElement("button")
13322  	dom.SetTextContent(sendBtn, t("send"))
13323  	dom.SetStyle(sendBtn, "padding", "8px 16px")
13324  	dom.SetStyle(sendBtn, "fontFamily", "'Fira Code', monospace")
13325  	dom.SetStyle(sendBtn, "fontSize", "13px")
13326  	dom.SetStyle(sendBtn, "background", "var(--accent)")
13327  	dom.SetStyle(sendBtn, "color", "#fff")
13328  	dom.SetStyle(sendBtn, "border", "none")
13329  	dom.SetStyle(sendBtn, "borderRadius", "4px")
13330  	dom.SetStyle(sendBtn, "cursor", "pointer")
13331  	dom.SetStyle(sendBtn, "alignSelf", "flex-end")
13332  	dom.AddEventListener(sendBtn, "click", dom.RegisterCallback(func() {
13333  		sendMessage()
13334  	}))
13335  	dom.AppendChild(compose, sendBtn)
13336  
13337  	dom.AppendChild(appSt.msgThreadContainer, compose)
13338  
13339  	// Fetch profile if metadata is missing - fetchedK0 can be true while
13340  	// the relay response is still in flight (showProfile resets + re-sets it).
13341  	_, nameOK := appSt.authorNames[peer]
13342  	if !nameOK {
13343  		setFetchedK0(peer, false)
13344  		profile.Send(`["P_RESOLVE",` | jstr(peer) | `]`)
13345  	}
13346  
13347  	// Request history.
13348  	routeMsg("[\"MLS_DM_HISTORY\"," | jstr(peer) | ",50,0]")
13349  }
13350  
13351  func closeThread() {
13352  	appSt.msgCurrentConvoID = ""
13353  	appSt.msgCurrentKind = ""
13354  	appSt.msgView = "list"
13355  
13356  	dom.SetStyle(appSt.msgThreadContainer, "display", "none")
13357  	dom.SetStyle(appSt.msgListContainer, "display", "block")
13358  
13359  	if !appSt.navPop {
13360  		dom.PushState("/msg")
13361  	}
13362  
13363  	// Refresh list.
13364  	routeMsg("[\"MLS_DM_LIST\"]")
13365  }
13366  
13367  func renderThreadMessages(peer, msgsJSON string) {
13368  	if peer != appSt.msgCurrentConvoID {
13369  		return
13370  	}
13371  	if msgsJSON == "" || msgsJSON == "[]" {
13372  		return
13373  	}
13374  
13375  	// Parse messages array - each element is a DMRecord object.
13376  	// IDB returns newest-first; collect then reverse for oldest-at-top.
13377  	type dmMsg struct {
13378  		from    string
13379  		content string
13380  		ts      int64
13381  	}
13382  	var msgs []dmMsg
13383  
13384  	i := 0
13385  	for i < len(msgsJSON) && msgsJSON[i] != '[' {
13386  		i++
13387  	}
13388  	i++
13389  	for i < len(msgsJSON) {
13390  		for i < len(msgsJSON) && msgsJSON[i] != '{' {
13391  			if msgsJSON[i] == ']' {
13392  				goto done
13393  			}
13394  			i++
13395  		}
13396  		if i >= len(msgsJSON) {
13397  			break
13398  		}
13399  		objStart := i
13400  		depth := 0
13401  		for i < len(msgsJSON) {
13402  			if msgsJSON[i] == '{' {
13403  				depth++
13404  			} else if msgsJSON[i] == '}' {
13405  				depth--
13406  				if depth == 0 {
13407  					i++
13408  					break
13409  				}
13410  			} else if msgsJSON[i] == '"' {
13411  				i++
13412  				for i < len(msgsJSON) && msgsJSON[i] != '"' {
13413  					if msgsJSON[i] == '\\' {
13414  						i++
13415  					}
13416  					i++
13417  				}
13418  			}
13419  			i++
13420  		}
13421  		obj := msgsJSON[objStart:i]
13422  
13423  		from := helpers.JsonGetString(obj, "from")
13424  		content := helpers.JsonGetString(obj, "content")
13425  		ts := jsonGetNum(obj, "created_at")
13426  		msgs = mxutil.Ensure(msgs, 1)
13427  		msgs = push(msgs, dmMsg{from, content, ts})
13428  	}
13429  done:
13430  
13431  	// Reverse for oldest-first.
13432  	for l, r := 0, len(msgs)-1; l < r; l, r = l+1, r-1 {
13433  		msgs[l], msgs[r] = msgs[r], msgs[l]
13434  	}
13435  
13436  	clearChildren(appSt.msgThreadMessages)
13437  	for _, m := range msgs {
13438  		appendBubble(m.from, m.content, m.ts)
13439  	}
13440  	scrollToBottom()
13441  }
13442  
13443  func appendBubble(from, content string, ts int64) {
13444  	isSent := from == appSt.pubhex
13445  
13446  	wrap := dom.CreateElement("div")
13447  	dom.SetStyle(wrap, "display", "flex")
13448  	dom.SetStyle(wrap, "marginBottom", "6px")
13449  	if isSent {
13450  		dom.SetStyle(wrap, "justifyContent", "flex-end")
13451  	}
13452  
13453  	outer := dom.CreateElement("div")
13454  	dom.SetStyle(outer, "maxWidth", "80%")
13455  	if isSent {
13456  		dom.SetStyle(outer, "textAlign", "right")
13457  	}
13458  
13459  	bubble := dom.CreateElement("div")
13460  	dom.SetStyle(bubble, "display", "inline-block")
13461  	dom.SetStyle(bubble, "padding", "8px 12px")
13462  	dom.SetStyle(bubble, "borderRadius", "12px")
13463  	dom.SetStyle(bubble, "fontSize", "14px")
13464  	dom.SetStyle(bubble, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
13465  	dom.SetStyle(bubble, "lineHeight", "1.4")
13466  	dom.SetStyle(bubble, "wordBreak", "break-word")
13467  	dom.SetStyle(bubble, "textAlign", "left")
13468  	if isSent {
13469  		dom.SetStyle(bubble, "background", "var(--accent)")
13470  		dom.SetStyle(bubble, "color", "#fff")
13471  	} else {
13472  		dom.SetStyle(bubble, "background", "var(--bg2)")
13473  		dom.SetStyle(bubble, "color", "var(--fg)")
13474  	}
13475  	setHTMLWithMedia(bubble, renderMarkdown(content))
13476  
13477  	// Timestamp below bubble.
13478  	tsEl := dom.CreateElement("div")
13479  	dom.SetStyle(tsEl, "fontSize", "10px")
13480  	dom.SetStyle(tsEl, "color", "var(--muted)")
13481  	dom.SetStyle(tsEl, "marginTop", "2px")
13482  	if isSent {
13483  		dom.SetStyle(tsEl, "textAlign", "right")
13484  	}
13485  	dom.SetTextContent(tsEl, formatTime(ts))
13486  	if isSent && ts == 0 {
13487  		appSt.pendingTsEls = mxutil.Ensure(appSt.pendingTsEls, 1)
13488  		appSt.pendingTsEls = push(appSt.pendingTsEls, tsEl)
13489  	}
13490  
13491  	dom.AppendChild(outer, bubble)
13492  	dom.AppendChild(outer, tsEl)
13493  
13494  	// Email quote-reply button for received messages with email headers.
13495  	if !isSent {
13496  		emailFrom, emailSubject, emailBody, isEmail := parseEmailHeaders(content)
13497  		if isEmail {
13498  			replyBtn := dom.CreateElement("div")
13499  			dom.SetStyle(replyBtn, "fontSize", "11px")
13500  			dom.SetStyle(replyBtn, "color", "var(--accent)")
13501  			dom.SetStyle(replyBtn, "cursor", "pointer")
13502  			dom.SetStyle(replyBtn, "marginTop", "2px")
13503  			dom.SetTextContent(replyBtn, "\u21a9 Reply")
13504  			dom.AddEventListener(replyBtn, "click", dom.RegisterCallback(func() {
13505  				quoted := quoteReply(emailFrom, emailSubject, emailBody)
13506  				dom.SetProperty(appSt.msgComposeInput, "value", quoted)
13507  			}))
13508  			dom.AppendChild(outer, replyBtn)
13509  		}
13510  	}
13511  
13512  	dom.AppendChild(wrap, outer)
13513  	dom.AppendChild(appSt.msgThreadMessages, wrap)
13514  }
13515  
13516  func appendSystemBubble(text string) {
13517  	wrap := dom.CreateElement("div")
13518  	dom.SetStyle(wrap, "display", "flex")
13519  	dom.SetStyle(wrap, "justifyContent", "center")
13520  	dom.SetStyle(wrap, "marginBottom", "6px")
13521  
13522  	bubble := dom.CreateElement("div")
13523  	dom.SetStyle(bubble, "maxWidth", "85%")
13524  	dom.SetStyle(bubble, "padding", "8px 12px")
13525  	dom.SetStyle(bubble, "borderRadius", "8px")
13526  	dom.SetStyle(bubble, "fontSize", "12px")
13527  	dom.SetStyle(bubble, "fontFamily", "monospace")
13528  	dom.SetStyle(bubble, "lineHeight", "1.5")
13529  	dom.SetStyle(bubble, "whiteSpace", "pre-wrap")
13530  	dom.SetStyle(bubble, "background", "var(--bg2)")
13531  	dom.SetStyle(bubble, "color", "var(--muted)")
13532  	dom.SetStyle(bubble, "border", "1px solid var(--muted)")
13533  	dom.SetTextContent(bubble, text)
13534  
13535  	dom.AppendChild(wrap, bubble)
13536  	dom.AppendChild(appSt.msgThreadMessages, wrap)
13537  	scrollToBottom()
13538  }
13539  
13540  func scrollToBottom() {
13541  	dom.SetProperty(appSt.msgThreadMessages, "scrollTop", "999999")
13542  }
13543  
13544  func sendMessage() {
13545  	content := dom.GetProperty(appSt.msgComposeInput, "value")
13546  	if content == "" || appSt.msgCurrentConvoID == "" {
13547  		return
13548  	}
13549  
13550  	// Clear input.
13551  	dom.SetProperty(appSt.msgComposeInput, "value", "")
13552  
13553  	if appSt.msgCurrentKind == "group" {
13554  		routeMsg("[\"MLS_SEND_GROUP\"," | jstr(appSt.msgCurrentConvoID) | "," | jstr(content) | "]")
13555  	} else {
13556  		routeMsg("[\"MLS_SEND_DM\"," | jstr(appSt.msgCurrentConvoID) | "," | jstr(content) | "]")
13557  	}
13558  
13559  	// Optimistic render (ts=0 - timestamp not shown for "just sent").
13560  	appendBubble(appSt.pubhex, content, 0)
13561  	scrollToBottom()
13562  }
13563  
13564  func handleDMReceived(dmJSON string) {
13565  	peer := helpers.JsonGetString(dmJSON, "peer")
13566  	from := helpers.JsonGetString(dmJSON, "from")
13567  	content := helpers.JsonGetString(dmJSON, "content")
13568  	ts := jsonGetNum(dmJSON, "created_at")
13569  
13570  	if from != appSt.pubhex {
13571  		showNotifDot()
13572  	}
13573  
13574  	if appSt.msgView == "thread" && peer == appSt.msgCurrentConvoID {
13575  		if from == appSt.pubhex {
13576  			return
13577  		}
13578  		appendBubble(from, content, ts)
13579  		scrollToBottom()
13580  	} else if appSt.msgView == "list" {
13581  		routeMsg("[\"MLS_DM_LIST\"]")
13582  	}
13583  }
13584  
13585  // --- Notifications ---
13586  
13587  func subscribeNotifications() {
13588  	// Musiquay base: notifications are deliberately left empty - nothing is
13589  	// subscribed, so the page renders its empty state. The subscription code
13590  	// stays for when it is wired up.
13591  	return
13592  
13593  	if appSt.pubhex == "" {
13594  		return
13595  	}
13596  	// Reset Shell-side state.
13597  	appSt.oldestNotifTs = 0
13598  	appSt.notifExhausted = false
13599  	appSt.notifEmptyStrk = 0
13600  	appSt.notifInitLoad = true
13601  	notifEventsReset()
13602  	appSt.notifSeen = nil
13603  	// Sync state to Notif Worker, then tell it to subscribe.
13604  	// Notif Worker owns the relay subscriptions via N_SUB/N_CLOSE.
13605  	notif.Send(`["N_SET_PUBKEY",` | jstr(appSt.pubhex) | `]`)
13606  	notif.Send(`["N_SET_RELAYS",` | readRelayURLsJSON() | `]`)
13607  	notif.Send(`["N_READ_TS",` | i64toa(appSt.notifReadTs) | `]`)
13608  	if len(appSt.myMutes) > 0 {
13609  		notif.Send(`["N_SET_MUTES",` | buildJSONStrArr(appSt.myMutes) | `]`)
13610  	}
13611  	notif.Send(`["N_SUBSCRIBE"]`)
13612  }
13613  
13614  func handleNotifEvent(ev *nostr.Event) {
13615  	if appSt.notifSeen == nil {
13616  		appSt.notifSeen = map[string]bool{}
13617  	}
13618  	if appSt.notifSeen[ev.ID] {
13619  		return
13620  	}
13621  	appSt.notifSeen[ev.ID] = true
13622  	// Advance cursor before filters so muted/self events don't stall pagination.
13623  	if appSt.oldestNotifTs == 0 || ev.CreatedAt < appSt.oldestNotifTs {
13624  		appSt.oldestNotifTs = ev.CreatedAt
13625  	}
13626  	self := ev.PubKey == appSt.pubhex
13627  	muted := isMuted(notifSenderPK(ev))
13628  	rtm := repliesToMuted(ev)
13629  	if self || muted || rtm {
13630  		return
13631  	}
13632  	if looksLikeJSONSpam(ev.Content) {
13633  		return
13634  	}
13635  	// Queue missing referenced events for batch fetch after EOSE.
13636  	if ev.Kind != 1 {
13637  		refID := notifRefID(ev)
13638  		if refID != "" {
13639  			if _, ok := lookupEvent(refID); !ok {
13640  				appSt.notifMissing = mxutil.Ensure(appSt.notifMissing, 1)
13641  				appSt.notifMissing = push(appSt.notifMissing, refID)
13642  			}
13643  		}
13644  	}
13645  	// Remove empty-state placeholder on first event.
13646  	if appSt.notifEmptyEl != 0 {
13647  		dom.RemoveChild(appSt.notifContainer, appSt.notifEmptyEl)
13648  		appSt.notifEmptyEl = 0
13649  	}
13650  	if appSt.notifInitLoad {
13651  		appSt.notifEvents = push(appSt.notifEvents, ev)
13652  		notifEventsTrim()
13653  		if appSt.notifBuilt && appSt.activePage == "notifications" && notifPassesFilter(ev) {
13654  			row := renderNotifRow(ev)
13655  			dom.InsertBefore(appSt.notifContainer, row, appSt.notifLoadMore)
13656  		}
13657  	} else {
13658  		notifEventsPrepend(ev)
13659  		if appSt.notifBuilt && appSt.activePage == "notifications" && notifPassesFilter(ev) {
13660  			row := renderNotifRow(ev)
13661  			first := dom.FirstElementChild(appSt.notifContainer)
13662  			if first != 0 {
13663  				dom.InsertBefore(appSt.notifContainer, row, first)
13664  			} else {
13665  				dom.AppendChild(appSt.notifContainer, row)
13666  			}
13667  		}
13668  		if ev.Kind != 1 {
13669  			if appSt.notifRefTimer != 0 {
13670  				dom.ClearTimeout(appSt.notifRefTimer)
13671  			}
13672  			appSt.notifRefTimer = dom.SetTimeout(func() {
13673  				appSt.notifRefTimer = 0
13674  				fetchNotifRefs()
13675  			}, 500)
13676  		}
13677  	}
13678  	maybeShowNotifDot(ev)
13679  }
13680  
13681  func fetchNotifRefs() {
13682  	if len(appSt.notifMissing) == 0 {
13683  		return
13684  	}
13685  	// Dedup against eventCache and already-requested.
13686  	seen := map[string]bool{}
13687  	var ids []string
13688  	for _, id := range appSt.notifMissing {
13689  		if _, ok2 := lookupEvent(id); ok2 {
13690  			continue
13691  		}
13692  		if seen[id] {
13693  			continue
13694  		}
13695  		seen[id] = true
13696  		ids = mxutil.Ensure(ids, 1)
13697  		ids = push(ids, id)
13698  	}
13699  	appSt.notifMissing = nil
13700  	if len(ids) == 0 {
13701  		return
13702  	}
13703  	filter := "{\"ids\":["
13704  	for i, id := range ids {
13705  		if i > 0 {
13706  			filter = filter | ","
13707  		}
13708  		filter = filter | jstr(id)
13709  	}
13710  	filter = filter | "]}"
13711  	routeMsg(buildProxyMsg("ntf-ref", filter, readRelayURLs()))
13712  }
13713  
13714  func handleNotifRefEvent(ev *nostr.Event) {
13715  	setEvent(ev.ID, ev)
13716  	fillEmbed(ev)
13717  }
13718  
13719  func sortNotifEvents() {
13720  	for i := 0; i < len(appSt.notifEvents); i++ {
13721  		for j := i + 1; j < len(appSt.notifEvents); j++ {
13722  			if appSt.notifEvents[j].CreatedAt > appSt.notifEvents[i].CreatedAt {
13723  				appSt.notifEvents[i], appSt.notifEvents[j] = appSt.notifEvents[j], appSt.notifEvents[i]
13724  			}
13725  		}
13726  	}
13727  }
13728  
13729  func insertAtTop(row dom.Element) {
13730  	first := dom.FirstChild(appSt.notifContainer)
13731  	if first != 0 {
13732  		dom.InsertBefore(appSt.notifContainer, row, first)
13733  	} else {
13734  		dom.InsertBefore(appSt.notifContainer, row, appSt.notifLoadMore)
13735  	}
13736  }
13737  
13738  func loadOlderNotifs() {
13739  	appSt.notifLoading = true
13740  	appSt.notifMoreGot = 0
13741  	dom.ConsoleLog("[ntf-more] loading until=" | i64toa(appSt.oldestNotifTs-1))
13742  	filter := "{\"kinds\":[1,6,7,9735],\"#p\":[" | jstr(appSt.pubhex) | "],\"limit\":20,\"until\":" | i64toa(appSt.oldestNotifTs-1) | "}"
13743  	routeMsg(buildProxyMsg("ntf-more", filter, readRelayURLs()))
13744  	if appSt.notifMoreTimer != 0 {
13745  		dom.ClearTimeout(appSt.notifMoreTimer)
13746  	}
13747  	appSt.notifMoreTimer = dom.SetTimeout(func() {
13748  		appSt.notifMoreTimer = 0
13749  		if appSt.notifLoading {
13750  			appSt.notifLoading = false
13751  			if appSt.notifMoreGot == 0 {
13752  				appSt.notifEmptyStrk++
13753  				if appSt.notifEmptyStrk >= 3 {
13754  					appSt.notifExhausted = true
13755  				}
13756  			} else {
13757  				appSt.notifEmptyStrk = 0
13758  			}
13759  			if appSt.notifExhausted {
13760  				dom.SetStyle(appSt.notifLoadMore, "display", "none")
13761  			} else if appSt.oldestNotifTs > 0 {
13762  				dom.SetStyle(appSt.notifLoadMore, "display", "block")
13763  			}
13764  		}
13765  	}, 10000)
13766  }
13767  
13768  func handleNotifMoreEvent(ev *nostr.Event) {
13769  	if appSt.notifSeen == nil {
13770  		appSt.notifSeen = map[string]bool{}
13771  	}
13772  	if appSt.notifSeen[ev.ID] {
13773  		return
13774  	}
13775  	appSt.notifSeen[ev.ID] = true
13776  	// Advance cursor before filters so muted/self events don't stall pagination.
13777  	if appSt.oldestNotifTs == 0 || ev.CreatedAt < appSt.oldestNotifTs {
13778  		appSt.oldestNotifTs = ev.CreatedAt
13779  	}
13780  	if ev.PubKey == appSt.pubhex {
13781  		return
13782  	}
13783  	if isMuted(notifSenderPK(ev)) {
13784  		return
13785  	}
13786  	if repliesToMuted(ev) {
13787  		return
13788  	}
13789  	appSt.notifMoreGot++
13790  	if ev.Kind != 1 {
13791  		refID := notifRefID(ev)
13792  		if refID != "" {
13793  			if _, ok := lookupEvent(refID); !ok {
13794  				appSt.notifMissing = mxutil.Ensure(appSt.notifMissing, 1)
13795  				appSt.notifMissing = push(appSt.notifMissing, refID)
13796  			}
13797  		}
13798  	}
13799  	appSt.notifEvents = push(appSt.notifEvents, ev)
13800  	notifEventsTrim()
13801  	if appSt.activePage == "notifications" && appSt.notifLoadMore != 0 && notifPassesFilter(ev) {
13802  		row := renderNotifRow(ev)
13803  		dom.InsertBefore(appSt.notifContainer, row, appSt.notifLoadMore)
13804  	}
13805  }
13806  
13807  func showNotifDot() {
13808  	if !appSt.notifUnread {
13809  		appSt.notifUnread = true
13810  		dom.SetStyle(appSt.notifDot, "display", "block")
13811  	}
13812  }
13813  
13814  func maybeShowNotifDot(ev *nostr.Event) {
13815  	if ev.CreatedAt > appSt.notifReadTs && appSt.activePage != "notifications" {
13816  		showNotifDot()
13817  	}
13818  }
13819  
13820  func notifPassesFilter(ev *nostr.Event) (ok bool) {
13821  	switch appSt.notifFilter {
13822  	case "mentions":
13823  		return ev.Kind == 1
13824  	case "reactions":
13825  		return ev.Kind == 6 || ev.Kind == 7
13826  	case "zaps":
13827  		return ev.Kind == 9735
13828  	}
13829  	return true
13830  }
13831  
13832  func renderNotifPage() {
13833  	if appSt.notifContainer == 0 {
13834  		return
13835  	}
13836  	appSt.notifBuilt = true
13837  	sortNotifEvents()
13838  	clearChildren(appSt.notifContainer)
13839  
13840  	shown := 0
13841  	for _, ev := range appSt.notifEvents {
13842  		if !notifPassesFilter(ev) {
13843  			continue
13844  		}
13845  		shown++
13846  		row := renderNotifRow(ev)
13847  		dom.AppendChild(appSt.notifContainer, row)
13848  	}
13849  
13850  	if shown == 0 {
13851  		appSt.notifEmptyEl = dom.CreateElement("div")
13852  		dom.SetTextContent(appSt.notifEmptyEl, t("no_notifications"))
13853  		dom.SetStyle(appSt.notifEmptyEl, "color", "var(--muted)")
13854  		dom.SetStyle(appSt.notifEmptyEl, "fontFamily", "'Fira Code', monospace")
13855  		dom.SetStyle(appSt.notifEmptyEl, "fontSize", "13px")
13856  		dom.AppendChild(appSt.notifContainer, appSt.notifEmptyEl)
13857  	} else {
13858  		appSt.notifEmptyEl = 0
13859  	}
13860  
13861  	appSt.notifLoadMore = dom.CreateElement("div")
13862  	dom.SetStyle(appSt.notifLoadMore, "textAlign", "center")
13863  	dom.SetStyle(appSt.notifLoadMore, "padding", "16px 0")
13864  	if appSt.notifExhausted || appSt.oldestNotifTs == 0 {
13865  		dom.SetStyle(appSt.notifLoadMore, "display", "none")
13866  	}
13867  	lnk := dom.CreateElement("span")
13868  	dom.SetTextContent(lnk, "load more")
13869  	dom.SetStyle(lnk, "cursor", "pointer")
13870  	dom.SetStyle(lnk, "color", "var(--accent)")
13871  	dom.SetStyle(lnk, "fontSize", "14px")
13872  	dom.SetStyle(lnk, "fontFamily", "'Fira Code', monospace")
13873  	dom.AddEventListener(lnk, "click", dom.RegisterCallback(func() {
13874  		if !appSt.notifLoading && !appSt.notifExhausted && appSt.oldestNotifTs > 0 {
13875  			dom.SetStyle(appSt.notifLoadMore, "display", "none")
13876  			loadOlderNotifs()
13877  		}
13878  	}))
13879  	dom.AppendChild(appSt.notifLoadMore, lnk)
13880  	dom.AppendChild(appSt.notifContainer, appSt.notifLoadMore)
13881  }
13882  
13883  // releaseNotifRowCBs releases all Moxie callback IDs registered during notif
13884  // row rendering. Called before rebuild and on page exit to prevent the callback
13885  // table from growing without bound across filter changes and page visits.
13886  func releaseNotifRowCBs() {
13887  	for i := int32(0); i < notifRowCBs.Len(); i++ {
13888  		dom.ReleaseCallback(notifRowCBs.At(i))
13889  	}
13890  	notifRowCBs.Reset()
13891  }
13892  
13893  // notifRegCB registers a callback and records its ID for later release.
13894  func notifRegCB(fn func()) (n int32) {
13895  	id := dom.RegisterCallback(fn)
13896  	notifRowCBs.Push(id)
13897  	return id
13898  }
13899  
13900  func rebuildNotifRows() {
13901  	if appSt.notifContainer == 0 {
13902  		return
13903  	}
13904  	releaseNotifRowCBs()
13905  	clearChildren(appSt.notifContainer)
13906  	shown := 0
13907  	for _, ev := range appSt.notifEvents {
13908  		if !notifPassesFilter(ev) {
13909  			continue
13910  		}
13911  		shown++
13912  		dom.AppendChild(appSt.notifContainer, renderNotifRow(ev))
13913  	}
13914  	if shown == 0 {
13915  		appSt.notifEmptyEl = dom.CreateElement("div")
13916  		dom.SetTextContent(appSt.notifEmptyEl, t("no_notifications"))
13917  		dom.SetStyle(appSt.notifEmptyEl, "color", "var(--muted)")
13918  		dom.SetStyle(appSt.notifEmptyEl, "fontFamily", "'Fira Code', monospace")
13919  		dom.SetStyle(appSt.notifEmptyEl, "fontSize", "13px")
13920  		dom.AppendChild(appSt.notifContainer, appSt.notifEmptyEl)
13921  	} else {
13922  		appSt.notifEmptyEl = 0
13923  	}
13924  	if appSt.notifLoadMore != 0 {
13925  		dom.AppendChild(appSt.notifContainer, appSt.notifLoadMore)
13926  	}
13927  }
13928  
13929  func markNotifsRead() {
13930  	if len(appSt.notifEvents) > 0 {
13931  		appSt.notifReadTs = appSt.notifEvents[0].CreatedAt
13932  		dom.IDBPut("settings", "notif-read-ts", i64toa(appSt.notifReadTs))
13933  		// Publish watermark to relay so it syncs to other devices/sessions.
13934  		saveAppSettings()
13935  	}
13936  	appSt.notifUnread = false
13937  	dom.SetStyle(appSt.notifDot, "display", "none")
13938  	el := dom.QuerySelectorFrom(appSt.notifContainer, "[data-notif-new]")
13939  	for el != 0 {
13940  		dom.RemoveAttribute(el, "data-notif-new")
13941  		dom.SetStyle(el, "borderLeft", "none")
13942  		dom.SetStyle(el, "paddingLeft", "0")
13943  		dom.SetStyle(el, "fontWeight", "normal")
13944  		el = dom.QuerySelectorFrom(appSt.notifContainer, "[data-notif-new]")
13945  	}
13946  }
13947  
13948  // zapAmount extracts the sats amount from a kind 9735 zap receipt.
13949  // Checks the "amount" tag first (millisats), then falls back to bolt11.
13950  func zapAmount(ev *nostr.Event) (s string) {
13951  	for _, tag := range ev.Tags {
13952  		if len(tag) >= 2 && tag[0] == "amount" {
13953  			// Value is in millisats - divide by 1000.
13954  			msats := parseI64(tag[1])
13955  			if msats > 0 {
13956  				return i64toa(msats / 1000)
13957  			}
13958  		}
13959  	}
13960  	return ""
13961  }
13962  
13963  // zapSenderPK tries to extract the real sender pubkey from the zap request
13964  // embedded in the "description" tag of a kind 9735 receipt.
13965  func zapSenderPK(ev *nostr.Event) (s string) {
13966  	for _, tag := range ev.Tags {
13967  		if len(tag) >= 2 && tag[0] == "description" {
13968  			desc := tag[1]
13969  			pk := extractJSONField(desc, "pubkey")
13970  			if len(pk) == 64 {
13971  				return pk
13972  			}
13973  		}
13974  	}
13975  	return ""
13976  }
13977  
13978  // extractJSONField finds "key":"value" in a JSON string and returns value.
13979  func extractJSONField(json string, key string) (s string) {
13980  	needle := "\"" | key | "\":\""
13981  	idx := -1
13982  	for i := 0; i+len(needle) <= len(json); i++ {
13983  		if json[i:i+len(needle)] == needle {
13984  			idx = i + len(needle)
13985  			break
13986  		}
13987  	}
13988  	if idx < 0 {
13989  		return ""
13990  	}
13991  	end := idx
13992  	for end < len(json) && json[end] != '"' {
13993  		end++
13994  	}
13995  	return json[idx:end]
13996  }
13997  
13998  func notifRefID(ev *nostr.Event) (s string) {
13999  	for _, tag := range ev.Tags {
14000  		if len(tag) >= 2 && tag[0] == "e" {
14001  			return tag[1]
14002  		}
14003  	}
14004  	return ""
14005  }
14006  
14007  func relativeTime(ts int64) (s string) {
14008  	now := dom.NowSeconds()
14009  	d := now - ts
14010  	if d < 0 {
14011  		d = 0
14012  	}
14013  	if d < 60 {
14014  		return "now"
14015  	}
14016  	if d < 3600 {
14017  		return i64toa(d/60) | "m"
14018  	}
14019  	if d < 86400 {
14020  		return i64toa(d/3600) | "h"
14021  	}
14022  	return i64toa(d/86400) | "d"
14023  }
14024  
14025  func notifSenderPK(ev *nostr.Event) (s string) {
14026  	if ev.Kind == 9735 {
14027  		pk := zapSenderPK(ev)
14028  		if pk != "" {
14029  			return pk
14030  		}
14031  	}
14032  	return ev.PubKey
14033  }
14034  
14035  func renderNotifRow(ev *nostr.Event) (e dom.Element) {
14036  	senderPK := notifSenderPK(ev)
14037  	unread := ev.CreatedAt > appSt.notifReadTs
14038  
14039  	row := dom.CreateElement("div")
14040  	dom.SetStyle(row, "display", "flex")
14041  	dom.SetStyle(row, "alignItems", "flex-start")
14042  	dom.SetStyle(row, "gap", "6px")
14043  	dom.SetStyle(row, "padding", "4px 0")
14044  	dom.SetStyle(row, "cursor", "pointer")
14045  	dom.SetStyle(row, "fontFamily", "'Fira Code', monospace")
14046  	dom.SetStyle(row, "fontSize", "12px")
14047  	if unread {
14048  		dom.SetStyle(row, "borderLeft", "3px solid var(--accent)")
14049  		dom.SetStyle(row, "paddingLeft", "6px")
14050  		dom.SetStyle(row, "fontWeight", "bold")
14051  		dom.SetAttribute(row, "data-notif-new", "1")
14052  	}
14053  
14054  	// Avatar.
14055  	ava := dom.CreateElement("img")
14056  	pic, picOK := appSt.authorPics[senderPK]
14057  	if !picOK || pic == "" {
14058  		pic = "/musiquay-logo.svg"
14059  		profile.Send(`["P_RESOLVE_FORCE",` | jstr(senderPK) | `]`)
14060  		// Register ava so applyAuthorProfile updates it immediately when loaded.
14061  		appSt.pendingNotifAvatars[senderPK] = mxutil.Ensure(appSt.pendingNotifAvatars[senderPK], 1)
14062  		appSt.pendingNotifAvatars[senderPK] = push(appSt.pendingNotifAvatars[senderPK], ava)
14063  	}
14064  	setMediaSrc(ava, pic)
14065  	dom.SetStyle(ava, "width", "24px")
14066  	dom.SetStyle(ava, "height", "24px")
14067  	dom.SetStyle(ava, "borderRadius", "50%")
14068  	dom.SetStyle(ava, "flexShrink", "0")
14069  	dom.SetStyle(ava, "cursor", "pointer")
14070  	dom.SetAttribute(ava, "onclick", "event.stopPropagation()")
14071  	pk := senderPK
14072  	dom.AddEventListener(ava, "click", notifRegCB(func() {
14073  		showProfile(pk)
14074  	}))
14075  	dom.AppendChild(row, ava)
14076  
14077  	// Icon + action label.
14078  	var icon string
14079  	var label string
14080  	switch ev.Kind {
14081  	case 1:
14082  		icon = "\xf0\x9f\x92\xac" // 💬
14083  		label = ""
14084  	case 6:
14085  		icon = "\xf0\x9f\x94\x81" // 🔁
14086  		label = ""
14087  	case 7:
14088  		emoji := ev.Content
14089  		if emoji == "" || emoji == "+" {
14090  			emoji = "\xe2\x9d\xa4\xef\xb8\x8f" // ❤️
14091  		}
14092  		icon = emoji
14093  		label = ""
14094  	case 9735:
14095  		icon = "\xe2\x9a\xa1" // ⚡
14096  		amt := zapAmount(ev)
14097  		if amt != "" {
14098  			label = amt | " sats"
14099  		}
14100  	}
14101  
14102  	iconEl := dom.CreateElement("span")
14103  	dom.SetStyle(iconEl, "flexShrink", "0")
14104  	dom.SetTextContent(iconEl, icon)
14105  	dom.AppendChild(row, iconEl)
14106  
14107  	if label != "" {
14108  		lblEl := dom.CreateElement("span")
14109  		dom.SetStyle(lblEl, "flexShrink", "0")
14110  		dom.SetStyle(lblEl, "color", "var(--accent)")
14111  		dom.SetTextContent(lblEl, label)
14112  		dom.AppendChild(row, lblEl)
14113  	}
14114  
14115  	// Content preview - line-clamped to 10 lines, same font as regular notes.
14116  	refID := notifRefID(ev)
14117  	var previewText string
14118  	if ev.Kind == 1 {
14119  		previewText = ev.Content
14120  	} else if refID != "" {
14121  		if ref, ok := lookupEvent(refID); ok && ref != nil {
14122  			previewText = ref.Content
14123  		}
14124  	}
14125  	prev := dom.CreateElement("span")
14126  	dom.SetStyle(prev, "flex", "1")
14127  	dom.SetStyle(prev, "minWidth", "0")
14128  	dom.SetStyle(prev, "overflow", "hidden")
14129  	dom.SetStyle(prev, "color", "var(--muted)")
14130  	dom.SetStyle(prev, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'")
14131  	dom.SetStyle(prev, "fontSize", "14px")
14132  	dom.SetStyle(prev, "lineHeight", "1.5")
14133  	dom.SetStyle(prev, "whiteSpace", "nowrap")
14134  	dom.SetStyle(prev, "textOverflow", "ellipsis")
14135  	dom.SetTextContent(prev, previewText)
14136  	dom.AppendChild(row, prev)
14137  
14138  	// Relative timestamp, right-aligned.
14139  	tsEl := dom.CreateElement("span")
14140  	dom.SetStyle(tsEl, "flexShrink", "0")
14141  	dom.SetStyle(tsEl, "color", "var(--muted)")
14142  	dom.SetStyle(tsEl, "fontSize", "11px")
14143  	dom.SetTextContent(tsEl, relativeTime(ev.CreatedAt))
14144  	dom.AppendChild(row, tsEl)
14145  
14146  	// Click navigates to the referenced event.
14147  	if refID != "" {
14148  		tid := refID
14149  		dom.AddEventListener(row, "click", notifRegCB(func() {
14150  			rootID := tid
14151  			if ref, ok := lookupEvent(tid); ok && ref != nil {
14152  				if r := getRootID(ref); r != "" {
14153  					rootID = r
14154  				}
14155  			}
14156  			showNoteThread(rootID, tid)
14157  		}))
14158  	}
14159  
14160  	return row
14161  }
14162  
14163  // Wallet panel - implementation lives in wallet.mx.
14164  
14165  // --- Email header parsing for quote-reply ---
14166  
14167  // parseEmailHeaders checks if content looks like a forwarded email and extracts
14168  // From, Subject, and body. Returns isEmail=true if at least From: or Subject: found.
14169  func parseEmailHeaders(content string) (from, subject, body string, isEmail bool) {
14170  	lines := splitLines(content)
14171  	headerEnd := -1
14172  	for i, line := range lines {
14173  		if line == "" {
14174  			headerEnd = i
14175  			break
14176  		}
14177  		if hasPrefix(line, "From: ") {
14178  			from = line[6:]
14179  		} else if hasPrefix(line, "Subject: ") {
14180  			subject = line[9:]
14181  		} else if hasPrefix(line, "To: ") || hasPrefix(line, "Date: ") || hasPrefix(line, "Cc: ") {
14182  			// Known header, continue
14183  		} else if i == 0 {
14184  			return "", "", "", false
14185  		}
14186  	}
14187  	if from == "" && subject == "" {
14188  		return "", "", "", false
14189  	}
14190  	if headerEnd >= 0 && headerEnd+1 < len(lines) {
14191  		body = joinLines(lines[headerEnd+1:])
14192  	}
14193  	return from, subject, body, true
14194  }
14195  
14196  func quoteReply(from, subject, body string) (s string) {
14197  	out := "To: " | from | "\n"
14198  	if subject != "" {
14199  		if !hasPrefix(subject, "Re: ") {
14200  			subject = "Re: " | subject
14201  		}
14202  		out = out | "Subject: " | subject | "\n"
14203  	}
14204  	out = out | "\n\n"
14205  	if body != "" {
14206  		lines := splitLines(body)
14207  		for _, line := range lines {
14208  			out = out | "> " | line | "\n"
14209  		}
14210  	}
14211  	return out
14212  }
14213  
14214  func splitLines(s string) (ss []string) {
14215  	var lines []string
14216  	for {
14217  		idx := strIndex(s, "\n")
14218  		if idx < 0 {
14219  			lines = mxutil.Ensure(lines, 1)
14220  			lines = push(lines, s)
14221  			return lines
14222  		}
14223  		lines = mxutil.Ensure(lines, 1)
14224  		lines = push(lines, s[:idx])
14225  		s = s[idx+1:]
14226  	}
14227  }
14228  
14229  func joinLines(lines []string) (s string) {
14230  	out := ""
14231  	for i, line := range lines {
14232  		if i > 0 {
14233  			out = out | "\n"
14234  		}
14235  		out = out | line
14236  	}
14237  	return out
14238  }
14239  
14240  func hasPrefix(s, prefix string) (ok bool) {
14241  	return len(s) >= len(prefix) && s[:len(prefix)] == prefix
14242  }
14243  
14244  // --- Markdown rendering ---
14245  // All functions use string concatenation and indexOf - no byte-level ops.
14246  // tinyjs compiles Go strings to JS strings (UTF-16); byte indexing corrupts emoji.
14247  
14248  // renderMarkdown converts note text to safe HTML via markdown-it jsbridge.
14249  // Pre-processes nostr entities and emoji shortcodes with placeholders,
14250  // runs markdown-it for commonmark + unicode-aware URL linkification,
14251  // then restores placeholders and converts media links to embeds.
14252  func renderMarkdown(s string) (sv string) {
14253  	// Extract nostr: entities before markdown-it touches the text.
14254  	var nostrSlots []string
14255  	s = extractNostrEntities(s, &nostrSlots)
14256  
14257  	// Extract emoji shortcodes.
14258  	var emojiSlots []string
14259  	s = extractEmojiShortcodes(s, &emojiSlots)
14260  
14261  	// Extract hashtags (#tag) so markdown-it doesn't strip the #.
14262  	var hashSlots []string
14263  	s = extractHashtags(s, &hashSlots)
14264  
14265  	// markdown-it: commonmark, auto-linkify (unicode-aware), breaks.
14266  	s = markdown.Render(s)
14267  
14268  	// Restore hashtag placeholders.
14269  	for i, html := range hashSlots {
14270  		s = strReplace(s, "\x02HASH" | itoa(i) | "\x02", html)
14271  	}
14272  
14273  	// Restore emoji placeholders.
14274  	for i, html := range emojiSlots {
14275  		s = strReplace(s, "\x02EMOJI" | itoa(i) | "\x02", html)
14276  	}
14277  
14278  	// Restore nostr entity placeholders.
14279  	for i, html := range nostrSlots {
14280  		s = strReplace(s, "\x02NOSTR" | itoa(i) | "\x02", html)
14281  	}
14282  
14283  	// Convert <a> tags pointing to image/video URLs into embeds.
14284  	s = convertMediaLinks(s)
14285  
14286  	return s
14287  }
14288  
14289  // extractNostrEntities replaces nostr:... entities with placeholders.
14290  func extractNostrEntities(s string, slots *[]string) (sv string) {
14291  	out := ""
14292  	for {
14293  		idx := strIndex(s, "nostr:")
14294  		if idx < 0 {
14295  			return out | s
14296  		}
14297  		out = out | s[:idx]
14298  		rest := s[idx+6:]
14299  		end := 0
14300  		for end < len(rest) {
14301  			c := rest[end]
14302  			if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
14303  				end++
14304  			} else {
14305  				break
14306  			}
14307  		}
14308  		if end < 10 {
14309  			out = out | "nostr:"
14310  			s = rest
14311  			continue
14312  		}
14313  		entity := rest[:end]
14314  		s = rest[end:]
14315  		html := renderNostrEntity(entity)
14316  		id := len(*slots)
14317  		*slots = push(*slots, html)
14318  		out = out | "\x02NOSTR" | itoa(id) | "\x02"
14319  	}
14320  }
14321  
14322  // renderNostrEntity produces the HTML for a single nostr bech32 entity.
14323  func renderNostrEntity(entity string) (s string) {
14324  	if len(entity) > 6 && entity[:6] == "naddr1" {
14325  		na := helpers.DecodeNaddr(entity)
14326  		if na != nil {
14327  			coord := helpers.NaddrCoordKey(na.Kind, na.Pubkey, na.D)
14328  			for _, r := range na.Relays {
14329  				appSt.embedRelayHints[coord] = mxutil.Ensure(appSt.embedRelayHints[coord], 1)
14330  				appSt.embedRelayHints[coord] = push(appSt.embedRelayHints[coord], r)
14331  			}
14332  			if !lookupFetchedK0(na.Pubkey) {
14333  				profile.Send(`["P_RESOLVE",` | jstr(na.Pubkey) | `]`)
14334  			}
14335  			return makeCoordEmbedPlaceholder(coord, na.Kind, na.Pubkey, na.D)
14336  		}
14337  	} else if len(entity) > 7 && entity[:7] == "nevent1" {
14338  		nev := helpers.DecodeNevent(entity)
14339  		if nev != nil {
14340  			if len(nev.Relays) > 0 {
14341  				appSt.embedRelayHints[nev.ID] = nev.Relays
14342  			}
14343  			return makeEmbedPlaceholder(nev.ID)
14344  		}
14345  	} else if len(entity) > 5 && entity[:5] == "note1" {
14346  		id := helpers.DecodeNote(entity)
14347  		if id != nil {
14348  			return makeEmbedPlaceholder(helpers.HexEncode(id))
14349  		}
14350  	} else if len(entity) > 9 && entity[:9] == "nprofile1" {
14351  		np := helpers.DecodeNprofile(entity)
14352  		if np != nil {
14353  			name := np.Pubkey[:8] | "..."
14354  			if n, ok := appSt.authorNames[np.Pubkey]; ok && n != "" {
14355  				name = n
14356  			} else {
14357  				profile.Send(`["P_RESOLVE",` | jstr(np.Pubkey) | `]`)
14358  			}
14359  			npub := helpers.EncodeNpub(helpers.HexDecode(np.Pubkey))
14360  			return "<a href=\"/p/" | npub | "\" data-pk=\"" | np.Pubkey | "\" style=\"color:var(--accent)\">" | escapeHTML(name) | "</a>"
14361  		}
14362  	} else if len(entity) > 5 && entity[:5] == "npub1" {
14363  		pk := helpers.DecodeNpub(entity)
14364  		if pk != nil {
14365  			hex := helpers.HexEncode(pk)
14366  			name := hex[:8] | "..."
14367  			if n, ok := appSt.authorNames[hex]; ok && n != "" {
14368  				name = n
14369  			} else {
14370  				profile.Send(`["P_RESOLVE",` | jstr(hex) | `]`)
14371  			}
14372  			npub := helpers.EncodeNpub(pk)
14373  			return "<a href=\"/p/" | npub | "\" data-pk=\"" | hex | "\" style=\"color:var(--accent)\">" | escapeHTML(name) | "</a>"
14374  		}
14375  	}
14376  	return "nostr:" | entity
14377  }
14378  
14379  // extractEmojiShortcodes replaces :name: shortcodes with placeholders.
14380  func extractEmojiShortcodes(s string, slots *[]string) (sv string) {
14381  	out := ""
14382  	i := 0
14383  	for i < len(s) {
14384  		if s[i] == ':' {
14385  			end := -1
14386  			valid := true
14387  			for j := i + 1; j < len(s) && j < i+40; j++ {
14388  				c := s[j]
14389  				if c == ':' {
14390  					end = j
14391  					break
14392  				}
14393  				if c == ' ' || c == '\n' || c == '\r' || c == '<' {
14394  					valid = false
14395  					break
14396  				}
14397  				if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
14398  					valid = false
14399  					break
14400  				}
14401  			}
14402  			if valid && end > i+1 {
14403  				name := s[i+1 : end]
14404  				code := ":" | name | ":"
14405  				var html string
14406  				if emoji, ok2 := appSt.emojiByName[code]; ok2 {
14407  					html = emoji
14408  				} else {
14409  					html = "<span style=\"font-size:0;line-height:0\">" | code | "</span>"
14410  				}
14411  				id := len(*slots)
14412  				*slots = push(*slots, html)
14413  				out = out | "\x02EMOJI" | itoa(id) | "\x02"
14414  				i = end + 1
14415  				continue
14416  			}
14417  		}
14418  		b := s[i]
14419  		n := 1
14420  		if b >= 0xF0 {
14421  			n = 4
14422  		} else if b >= 0xE0 {
14423  			n = 3
14424  		} else if b >= 0xC0 {
14425  			n = 2
14426  		}
14427  		if i+n > len(s) {
14428  			n = len(s) - i
14429  		}
14430  		out = out | s[i : i+n]
14431  		i += n
14432  	}
14433  	return out
14434  }
14435  
14436  // convertMediaLinks finds <a> tags with image/video URLs and replaces them.
14437  func convertMediaLinks(s string) (sv string) {
14438  	out := ""
14439  	for {
14440  		idx := strIndex(s, "<a ")
14441  		if idx < 0 {
14442  			return out | s
14443  		}
14444  		out = out | s[:idx]
14445  		s = s[idx:]
14446  		// Find closing </a>.
14447  		endTag := strIndex(s, "</a>")
14448  		if endTag < 0 {
14449  			return out | s
14450  		}
14451  		full := s[:endTag+4]
14452  		s = s[endTag+4:]
14453  		// Extract href value.
14454  		hrefIdx := strIndex(full, "href=\"")
14455  		if hrefIdx < 0 {
14456  			out = out | full
14457  			continue
14458  		}
14459  		hrefStart := hrefIdx + 6
14460  		hrefEnd := strIndex(full[hrefStart:], "\"")
14461  		if hrefEnd < 0 {
14462  			out = out | full
14463  			continue
14464  		}
14465  		href := full[hrefStart : hrefStart+hrefEnd]
14466  		if len(href) > 8 && strIndex(href[8:], "http") >= 0 {
14467  			out = out | full
14468  			continue
14469  		}
14470  		if ytID := youTubeVideoID(href); ytID != "" {
14471  			// Strip surrounding <p> when the YouTube link is the sole paragraph.
14472  			const pOpen = "<p style=\"margin:0 0 1em 0\">"
14473  			if hasSuffix(out, pOpen) {
14474  				out = out[:len(out)-len(pOpen)]
14475  				if hasPrefix(s, "</p>") {
14476  					s = s[4:]
14477  				}
14478  			}
14479  			thumbURL := "https://img.youtube.com/vi/" | ytID | "/hqdefault.jpg"
14480  			// data-invidious-id triggers the Invidious player when clicked.
14481  			// The fallback href still opens YouTube in a new tab.
14482  			out = out | "<div data-invidious-id=\"" | escapeHTMLAttr(ytID) | "\" data-yt-href=\"" | escapeHTMLAttr(href) | "\" style=\"display:block;margin:4px 0;cursor:pointer\">" |
14483  				"<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\">" |
14484  				"<span data-ytid=\"" | escapeHTMLAttr(ytID) | "\" style=\"display:block;font-size:13px;color:var(--accent);word-break:break-word\"></span>" |
14485  				"</div>"
14486  		} else if isBandcampURL(href) {
14487  			// Bandcamp link: show as a plain link with a "▶ campion" button alongside.
14488  			out = out | "<div style=\"margin:4px 0\">" |
14489  				"<a href=\"" | escapeHTMLAttr(href) | "\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent);word-break:break-all\">" | escapeHTMLAttr(href) | "</a>" |
14490  				" <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>" |
14491  				"</div>"
14492  		} else if isImageURL(href) {
14493  			orig := "\" data-orig-url=\"" | escapeHTMLAttr(href) | "\""
14494  			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\">"
14495  		} else if isVideoURL(href) {
14496  			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>"
14497  		} else {
14498  			out = out | full
14499  		}
14500  	}
14501  }
14502  
14503  // truncateByLine cuts `text` at the end of the line containing position `target`.
14504  // Returns (truncated, true) if content was removed. Returns (text, false) otherwise.
14505  // This avoids mid-line cuts where only a few words would remain hidden.
14506  func truncateByLine(text string, target int32) (s string, ok bool) {
14507  	if len(text) <= target {
14508  		return text, false
14509  	}
14510  	cut := target
14511  	for cut < len(text) && text[cut] != '\n' {
14512  		cut++
14513  	}
14514  	if cut >= len(text) {
14515  		return text, false
14516  	}
14517  	return text[:cut], true
14518  }
14519  
14520  // strReplace replaces all occurrences of old with new using indexOf.
14521  func strReplace(s, old, nw string) (result string) {
14522  	out := ""
14523  	for {
14524  		idx := strIndex(s, old)
14525  		if idx < 0 {
14526  			return out | s
14527  		}
14528  		out = out | s[:idx] | nw
14529  		s = s[idx+len(old):]
14530  	}
14531  }
14532  
14533  func isImageURL(url string) (ok bool) {
14534  	u := toLower(url)
14535  	return hasSuffix(u, ".jpg") || hasSuffix(u, ".jpeg") || hasSuffix(u, ".png") ||
14536  		hasSuffix(u, ".gif") || hasSuffix(u, ".webp") || hasSuffix(u, ".svg")
14537  }
14538  
14539  // migratedRelayTypes is the set of message types routed to the relay-proxy
14540  // worker. Anything else still flows to the legacy SW.
14541  //
14542  var migratedRelayTypes map[string]bool
14543  
14544  // swTypes are routed to the Service Worker.
14545  var swTypes map[string]bool
14546  
14547  // routeMsg dispatches a JSON-array message to the appropriate worker.
14548  func routeMsg(msg string) {
14549  	if len(msg) >= 4 && msg[0] == '[' && msg[1] == '"' {
14550  		end := 2
14551  		for end < len(msg) && msg[end] != '"' {
14552  			end++
14553  		}
14554  		typ := msg[2:end]
14555  		if migratedRelayTypes[typ] {
14556  			relayproxy.Send(msg)
14557  			return
14558  		}
14559  		if swTypes[typ] {
14560  			dom.PostToSW(msg)
14561  			return
14562  		}
14563  		dom.ConsoleLog("routeMsg: UNROUTED message type: " | typ)
14564  		return
14565  	}
14566  	if msg == "activate-update" {
14567  		dom.PostToSW(msg)
14568  		return
14569  	}
14570  	n := len(msg)
14571  	if n > 60 { n = 60 }
14572  	dom.ConsoleLog("routeMsg: UNROUTED non-array message: " | msg[:n])
14573  }
14574  
14575  // setHTMLWithMedia sets innerHTML and processes media links and lightboxes.
14576  // makeShowMore creates the show-more/less toggle span (initially hidden).
14577  // attachShowMore must be called after the note is in the DOM to decide
14578  // whether to reveal it based on actual rendered height.
14579  func makeShowMore(content dom.Element) (e dom.Element) {
14580  	more := dom.CreateElement("span")
14581  	dom.SetTextContent(more, t("show_more"))
14582  	dom.SetStyle(more, "color", "var(--accent)")
14583  	dom.SetStyle(more, "cursor", "pointer")
14584  	dom.SetStyle(more, "fontSize", "13px")
14585  	dom.SetStyle(more, "display", "none")
14586  	expanded := false
14587  	dom.AddEventListener(more, "click", dom.RegisterCallback(func() {
14588  		expanded = !expanded
14589  		if expanded {
14590  			dom.SetAttribute(content, "data-expanded", "1")
14591  			dom.SetStyle(content, "overflow", "visible")
14592  			dom.SetStyle(content, "maxHeight", "none")
14593  			dom.SetTextContent(more, t("show_less"))
14594  		} else {
14595  			dom.RemoveAttribute(content, "data-expanded")
14596  			dom.SetStyle(content, "overflow", "hidden")
14597  			dom.SetStyle(content, "maxHeight", "18em")
14598  			dom.SetTextContent(more, t("show_more"))
14599  		}
14600  	}))
14601  	return more
14602  }
14603  
14604  func attachShowMore(content, more dom.Element) {
14605  	c := content
14606  	m := more
14607  	check := func() {
14608  		if dom.GetAttribute(c, "data-expanded") == "1" {
14609  			return
14610  		}
14611  		scrollH := parseI64(dom.GetProperty(c, "scrollHeight"))
14612  		clientH := parseI64(dom.GetProperty(c, "clientHeight"))
14613  		if clientH > 0 && scrollH <= clientH {
14614  			dom.SetStyle(m, "display", "none")
14615  		} else if scrollH > clientH {
14616  			dom.SetStyle(m, "display", "inline-flex")
14617  		}
14618  	}
14619  	dom.ObserveResize(c, dom.RegisterCallback(check))
14620  	dom.SetTimeout(check, 200)
14621  }
14622  
14623  func isBandcampURL(url string) (ok bool) {
14624  	// Matches *.bandcamp.com/* URLs.
14625  	i := strIndex(url, "://")
14626  	if i < 0 {
14627  		return false
14628  	}
14629  	host := url[i+3:]
14630  	slash := strIndex(host, "/")
14631  	if slash >= 0 {
14632  		host = host[:slash]
14633  	}
14634  	return hasSuffix(host, ".bandcamp.com") || host == "bandcamp.com"
14635  }
14636  
14637  func setHTMLWithMedia(rootp6 dom.Element, html string) {
14638  	dom.SetInnerHTML(rootp6, html)
14639  	flushYtTitles(rootp6)
14640  	attachImageLightboxes(rootp6)
14641  	attachYTLightboxes(rootp6)
14642  	attachCampionLinks(rootp6)
14643  	attachHashtagLinks(rootp6)
14644  }
14645  
14646  // ytTitleCache maps YouTube video ID -> fetched title.
14647  
14648  // flushYtTitles finds every [data-ytid] span and fills it with the video title.
14649  func flushYtTitles(rootp5 dom.Element) {
14650  	el := dom.QuerySelectorFrom(rootp5, "[data-ytid]")
14651  	for el != 0 {
14652  		ytID := dom.GetAttribute(el, "data-ytid")
14653  		dom.RemoveAttribute(el, "data-ytid")
14654  		if ytID == "" {
14655  			el = dom.QuerySelectorFrom(rootp5, "[data-ytid]")
14656  			continue
14657  		}
14658  		if title, ok2 := appSt.ytTitleCache[ytID]; ok2 {
14659  			dom.SetTextContent(el, title)
14660  			el = dom.QuerySelectorFrom(rootp5, "[data-ytid]")
14661  			continue
14662  		}
14663  		capturedEl := el
14664  		capturedID := ytID
14665  		oembedURL := "https://www.youtube.com/oembed?url=" |
14666  			"https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D" | ytID |
14667  			"&format=json"
14668  		dom.FetchText(oembedURL, func(body string) {
14669  			title := helpers.JsonGetString(body, "title")
14670  			if title == "" {
14671  				title = capturedID
14672  			}
14673  			appSt.ytTitleCache[capturedID] = title
14674  			dom.SetTextContent(capturedEl, title)
14675  		})
14676  		el = dom.QuerySelectorFrom(rootp5, "[data-ytid]")
14677  	}
14678  }
14679  
14680  // attachImageLightboxes finds every img[data-orig-url] in root, removes the
14681  // attribute (so the loop terminates), and wires a click handler that opens
14682  // the full-screen lightbox. Called from setHTMLWithMedia after innerHTML is set.
14683  func attachImageLightboxes(rootp3 dom.Element) {
14684  	el := dom.QuerySelectorFrom(rootp3, "[data-orig-url]")
14685  	for el != 0 {
14686  		url := dom.GetAttribute(el, "data-orig-url")
14687  		dom.RemoveAttribute(el, "data-orig-url")
14688  		if url != "" {
14689  			capturedURL := url
14690  			dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
14691  				showImageLightbox(capturedURL)
14692  			}))
14693  		}
14694  		el = dom.QuerySelectorFrom(rootp3, "[data-orig-url]")
14695  	}
14696  }
14697  
14698  // attachYTLightboxes wires click handlers on [data-invidious-id] divs.
14699  // Clicking replaces the thumbnail inline with an Invidious embed iframe.
14700  func attachYTLightboxes(rootp4 dom.Element) {
14701  	el := dom.QuerySelectorFrom(rootp4, "[data-invidious-id]")
14702  	for el != 0 {
14703  		ytID := dom.GetAttribute(el, "data-invidious-id")
14704  		ytHref := dom.GetAttribute(el, "data-yt-href")
14705  		dom.RemoveAttribute(el, "data-invidious-id")
14706  		dom.RemoveAttribute(el, "data-yt-href")
14707  		if ytID != "" {
14708  			capturedEl := el
14709  			capturedID := ytID
14710  			capturedHref := ytHref
14711  			dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
14712  				if appSt.invidiousURL != "" {
14713  					inlineInvidiousEmbed(capturedEl, capturedID)
14714  				} else {
14715  					dom.WindowOpen(capturedHref)
14716  				}
14717  			}))
14718  		}
14719  		el = dom.QuerySelectorFrom(rootp4, "[data-invidious-id]")
14720  	}
14721  }
14722  
14723  // inlineInvidiousEmbed replaces the thumbnail wrapper div's content with an
14724  // Invidious embed iframe sized at 16:9, plus a small control row below.
14725  // Stamps data-inv-ytid on the wrapper so stopInlineMedia can restore it.
14726  func inlineInvidiousEmbed(wrapper dom.Element, ytID string) {
14727  	dom.ReleaseChildren(wrapper)
14728  	dom.SetInnerHTML(wrapper, "")
14729  	dom.SetStyle(wrapper, "cursor", "default")
14730  	dom.SetAttribute(wrapper, "data-inv-ytid", ytID)
14731  	dom.SetStyle(wrapper, "maxWidth", "100%")
14732  
14733  	iframe := dom.CreateElement("iframe")
14734  	dom.SetAttribute(iframe, "src", appSt.invidiousURL | "/embed/" | ytID | "?autoplay=1")
14735  	dom.SetAttribute(iframe, "allowfullscreen", "true")
14736  	dom.SetAttribute(iframe, "credentialless", "")
14737  	dom.SetStyle(iframe, "width", "100%")
14738  	dom.SetStyle(iframe, "aspectRatio", "16/9")
14739  	dom.SetStyle(iframe, "border", "none")
14740  	dom.SetStyle(iframe, "display", "block")
14741  	dom.SetStyle(iframe, "borderRadius", "8px")
14742  	dom.SetStyle(iframe, "background", "#000")
14743  	dom.AppendChild(wrapper, iframe)
14744  
14745  	// Control row: "open in invidious ↗" on the right.
14746  	bar := dom.CreateElement("div")
14747  	dom.SetStyle(bar, "display", "flex")
14748  	dom.SetStyle(bar, "justifyContent", "flex-end")
14749  	dom.SetStyle(bar, "marginTop", "2px")
14750  
14751  	link := dom.CreateElement("span")
14752  	dom.SetTextContent(link, "open in invidious \xe2\x86\x97")
14753  	dom.SetStyle(link, "fontSize", "10px")
14754  	dom.SetStyle(link, "color", "var(--accent)")
14755  	dom.SetStyle(link, "cursor", "pointer")
14756  	dom.SetStyle(link, "opacity", "0.7")
14757  	capturedID := ytID
14758  	dom.AddEventListener(link, "click", dom.RegisterCallback(func() {
14759  		dom.WindowOpen(appSt.invidiousURL | "/watch?v=" | capturedID)
14760  	}))
14761  	dom.AppendChild(bar, link)
14762  	dom.AppendChild(wrapper, bar)
14763  
14764  }
14765  
14766  // attachCampionLinks wires click handlers on [data-campion-url] spans.
14767  // Opens Campion in a new tab (window.open) to avoid COEP iframe conflicts.
14768  func attachCampionLinks(rootp1 dom.Element) {
14769  	el := dom.QuerySelectorFrom(rootp1, "[data-campion-url]")
14770  	for el != 0 {
14771  		bcURL := dom.GetAttribute(el, "data-campion-url")
14772  		dom.RemoveAttribute(el, "data-campion-url")
14773  		if bcURL != "" {
14774  			capturedURL := bcURL
14775  			dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
14776  				if appSt.campionURL != "" {
14777  					dom.WindowOpen(appSt.campionURL | "?url=" | dom.EncodeURIComponent(capturedURL))
14778  				} else {
14779  					dom.WindowOpen(capturedURL)
14780  				}
14781  			}))
14782  		}
14783  		el = dom.QuerySelectorFrom(rootp1, "[data-campion-url]")
14784  	}
14785  }
14786  
14787  // zoomStr converts a zoom level in 1/4x steps to a CSS scale() string.
14788  // Level 4 = 1.0x. Level 1 = 0.25x. Level 20 = 5.0x.
14789  func zoomStr(level int32) (s string) {
14790  	whole := level / 4
14791  	frac := (level % 4) * 25
14792  	if frac == 0 {
14793  		return itoa(whole)
14794  	}
14795  	fs := itoa(frac)
14796  	for len(fs) > 1 && fs[len(fs)-1] == '0' {
14797  		fs = fs[:len(fs)-1]
14798  	}
14799  	return itoa(whole) | "." | fs
14800  }
14801  
14802  func zoomCtrlBtn(label string) (e dom.Element) {
14803  	btn := dom.CreateElement("span")
14804  	dom.SetTextContent(btn, label)
14805  	dom.SetStyle(btn, "cursor", "pointer")
14806  	dom.SetStyle(btn, "fontSize", "28px")
14807  	dom.SetStyle(btn, "fontWeight", "bold")
14808  	dom.SetStyle(btn, "color", "#fff")
14809  	dom.SetStyle(btn, "userSelect", "none")
14810  	dom.SetStyle(btn, "padding", "0 6px")
14811  	dom.SetStyle(btn, "lineHeight", "1")
14812  	return btn
14813  }
14814  
14815  // showImageLightbox opens a full-screen modal for the given remote image URL.
14816  // Top-right: zoom [+] [-] [×close] bar. Bottom-left: clipboard button.
14817  // All controls are position:absolute inside the position:fixed scrim.
14818  func showImageLightbox(url string) {
14819  	zoomLevel := 4 // 1.0x
14820  
14821  	scrim := dom.CreateElement("div")
14822  	dom.SetStyle(scrim, "position", "fixed")
14823  	dom.SetStyle(scrim, "inset", "0")
14824  	dom.SetStyle(scrim, "background", "rgba(0,0,0,0.88)")
14825  	dom.SetStyle(scrim, "zIndex", "2000")
14826  	dom.SetStyle(scrim, "display", "flex")
14827  	dom.SetStyle(scrim, "alignItems", "center")
14828  	dom.SetStyle(scrim, "justifyContent", "center")
14829  	dom.SetStyle(scrim, "overflow", "hidden")
14830  
14831  	img := dom.CreateElement("img")
14832  	dom.SetAttribute(img, "referrerpolicy", "no-referrer")
14833  	dom.SetStyle(img, "maxWidth", "90vw")
14834  	dom.SetStyle(img, "maxHeight", "90vh")
14835  	dom.SetStyle(img, "objectFit", "contain")
14836  	dom.SetStyle(img, "display", "block")
14837  	dom.SetStyle(img, "transformOrigin", "center center")
14838  	setMediaSrc(img, url)
14839  
14840  	// Click the image to copy it as a PNG blob to the system clipboard.
14841  	capturedImg2 := img
14842  	dom.AddEventListener(img, "click", dom.RegisterCallback(func() {
14843  		dom.CopyImageToClipboard(capturedImg2, func(ok bool) {
14844  			if ok {
14845  				overlay := dom.CreateElement("div")
14846  				dom.SetTextContent(overlay, "image copied")
14847  				dom.SetStyle(overlay, "position", "absolute")
14848  				dom.SetStyle(overlay, "top", "50%")
14849  				dom.SetStyle(overlay, "left", "50%")
14850  				dom.SetStyle(overlay, "transform", "translate(-50%,-50%)")
14851  				dom.SetStyle(overlay, "background", "rgba(0,0,0,0.75)")
14852  				dom.SetStyle(overlay, "color", "#fff")
14853  				dom.SetStyle(overlay, "padding", "12px 24px")
14854  				dom.SetStyle(overlay, "borderRadius", "8px")
14855  				dom.SetStyle(overlay, "fontSize", "18px")
14856  				dom.SetStyle(overlay, "pointerEvents", "none")
14857  				dom.AppendChild(scrim, overlay)
14858  				capturedOverlay := overlay
14859  				dom.SetTimeout(func() {
14860  					dom.RemoveChild(scrim, capturedOverlay)
14861  				}, 1200)
14862  			}
14863  		})
14864  	}))
14865  	dom.SetStyle(img, "cursor", "copy")
14866  	dom.AppendChild(scrim, img)
14867  
14868  	// Clipboard button - absolute bottom-left, copies the remote URL.
14869  	clipBtn := dom.CreateElement("div")
14870  	dom.SetTextContent(clipBtn, "\xf0\x9f\x93\x8b") // 📋
14871  	dom.SetStyle(clipBtn, "position", "absolute")
14872  	dom.SetStyle(clipBtn, "bottom", "20px")
14873  	dom.SetStyle(clipBtn, "left", "20px")
14874  	dom.SetStyle(clipBtn, "cursor", "pointer")
14875  	dom.SetStyle(clipBtn, "fontSize", "32px")
14876  	dom.SetStyle(clipBtn, "background", "rgba(0,0,0,0.6)")
14877  	dom.SetStyle(clipBtn, "borderRadius", "10px")
14878  	dom.SetStyle(clipBtn, "padding", "8px 12px")
14879  	dom.SetStyle(clipBtn, "lineHeight", "1")
14880  	dom.SetStyle(clipBtn, "userSelect", "none")
14881  	capturedURL := url
14882  	capturedClip := clipBtn
14883  	dom.AddEventListener(clipBtn, "click", dom.RegisterCallback(func() {
14884  		dom.WritePrimarySelection(capturedURL)
14885  		dom.WriteClipboard(capturedURL, func(ok bool) {
14886  			if ok {
14887  				dom.SetTextContent(capturedClip, "\xe2\x9c\x93") // ✓
14888  				dom.SetTimeout(func() {
14889  					dom.SetTextContent(capturedClip, "\xf0\x9f\x93\x8b")
14890  				}, 1500)
14891  			}
14892  		})
14893  	}))
14894  	dom.AppendChild(scrim, clipBtn)
14895  
14896  	// Zoom + close bar - absolute top-right. × closes the lightbox.
14897  	zoomBar := dom.CreateElement("div")
14898  	dom.SetStyle(zoomBar, "position", "absolute")
14899  	dom.SetStyle(zoomBar, "top", "12px")
14900  	dom.SetStyle(zoomBar, "right", "16px")
14901  	dom.SetStyle(zoomBar, "display", "flex")
14902  	dom.SetStyle(zoomBar, "gap", "4px")
14903  	dom.SetStyle(zoomBar, "alignItems", "center")
14904  	dom.SetStyle(zoomBar, "background", "rgba(0,0,0,0.6)")
14905  	dom.SetStyle(zoomBar, "borderRadius", "10px")
14906  	dom.SetStyle(zoomBar, "padding", "6px 12px")
14907  
14908  	capturedImg := img
14909  	applyZoom := func() {
14910  		dom.SetStyle(capturedImg, "transform", "scale(" | zoomStr(zoomLevel) | ")")
14911  	}
14912  
14913  	plusBtn := zoomCtrlBtn("+")
14914  	dom.AddEventListener(plusBtn, "click", dom.RegisterCallback(func() {
14915  		if zoomLevel < 20 {
14916  			zoomLevel++
14917  		}
14918  		applyZoom()
14919  	}))
14920  	dom.AppendChild(zoomBar, plusBtn)
14921  
14922  	minusBtn := zoomCtrlBtn("-")
14923  	dom.AddEventListener(minusBtn, "click", dom.RegisterCallback(func() {
14924  		if zoomLevel > 1 {
14925  			zoomLevel--
14926  		}
14927  		applyZoom()
14928  	}))
14929  	dom.AppendChild(zoomBar, minusBtn)
14930  
14931  	// × closes (replaces separate close button).
14932  	closeBtn := zoomCtrlBtn("\xc3\x97") // ×
14933  	closeFn := dom.RegisterCallback(func() {
14934  		dom.RemoveChild(dom.Body(), scrim)
14935  	})
14936  	dom.AddEventListener(closeBtn, "click", closeFn)
14937  	dom.AppendChild(zoomBar, closeBtn)
14938  
14939  	dom.AppendChild(scrim, zoomBar)
14940  	dom.AddSelfEventListener(scrim, "click", closeFn)
14941  	dom.AppendChild(dom.Body(), scrim)
14942  }
14943  
14944  // noteClipText formats a single event as a markdown section for clipboard.
14945  // Format: ### name (npub) - timestamp\n\ncontent\n\n---\n\n
14946  func noteClipText(ev *nostr.Event) (s string) {
14947  	name := appSt.authorNames[ev.PubKey]
14948  	npub := helpers.EncodeNpub(helpers.HexDecode(ev.PubKey))
14949  	if name == "" {
14950  		if len(npub) > 20 {
14951  			name = npub[:12] | "..." | npub[len(npub)-4:]
14952  		} else {
14953  			name = npub
14954  		}
14955  	}
14956  	ts := formatTime(ev.CreatedAt)
14957  	return "### " | name | " (" | npub | ") - " | ts | "\n\n" | ev.Content | "\n\n---\n\n"
14958  }
14959  
14960  // collectBranch builds the children map from threadEvents and collects the
14961  // event at startID plus all its descendants (DFS, chronological). Used by
14962  // per-note copy buttons in thread view.
14963  func collectBranch(startID string) (ss []*nostr.Event) {
14964  	ch := map[string][]string{}
14965  	for id, ev := range appSt.threadEvents {
14966  		parentID := getReplyID(ev)
14967  		if parentID == "" || parentID == id {
14968  			continue
14969  		}
14970  		if appSt.threadEvents[parentID] == nil && parentID != appSt.threadRootID {
14971  			if referencesEvent(ev, appSt.threadRootID) {
14972  				parentID = appSt.threadRootID
14973  			}
14974  		}
14975  		ch[parentID] = mxutil.Ensure(ch[parentID], 1)
14976  		ch[parentID] = push(ch[parentID], id)
14977  	}
14978  	for pid := range ch {
14979  		sortByCreatedAt(ch[pid])
14980  	}
14981  	var result []*nostr.Event
14982  	var dfs func(id string)
14983  	dfs = func(id string) {
14984  		ev, ok := appSt.threadEvents[id]
14985  		if !ok {
14986  			return
14987  		}
14988  		result = mxutil.Ensure(result, 1)
14989  		result = push(result, ev)
14990  		for _, childID := range ch[id] {
14991  			dfs(childID)
14992  		}
14993  	}
14994  	dfs(startID)
14995  	return result
14996  }
14997  
14998  // noteCopyBtn returns a 📋 span styled like the other header micro-buttons.
14999  func noteCopyBtn() (e dom.Element) {
15000  	btn := dom.CreateElement("span")
15001  	dom.SetTextContent(btn, "\xf0\x9f\x93\x8b") // 📋
15002  	dom.SetStyle(btn, "fontSize", "10px")
15003  	dom.SetStyle(btn, "cursor", "pointer")
15004  	dom.SetStyle(btn, "opacity", "0.6")
15005  	dom.SetStyle(btn, "marginRight", "4px")
15006  	dom.SetStyle(btn, "userSelect", "none")
15007  	capturedBtn := btn
15008  	dom.AddEventListener(btn, "mouseenter", dom.RegisterCallback(func() {
15009  		dom.SetStyle(capturedBtn, "opacity", "1")
15010  	}))
15011  	dom.AddEventListener(btn, "mouseleave", dom.RegisterCallback(func() {
15012  		dom.SetStyle(capturedBtn, "opacity", "0.6")
15013  	}))
15014  	return btn
15015  }
15016  
15017  // noteCopyFeedback briefly shows ✓ on the copy button after a successful write.
15018  func noteCopyFeedback(btn dom.Element) {
15019  	dom.SetTextContent(btn, "\xe2\x9c\x93") // ✓
15020  	dom.SetStyle(btn, "opacity", "1")
15021  	capturedBtn := btn
15022  	dom.SetTimeout(func() {
15023  		dom.SetTextContent(capturedBtn, "\xf0\x9f\x93\x8b")
15024  		dom.SetStyle(capturedBtn, "opacity", "0.6")
15025  	}, 1200)
15026  }
15027  
15028  // prettyNoteJSON returns the event serialised as indented JSON with tabs.
15029  func prettyNoteJSON(ev *nostr.Event) (s string) {
15030  	s = "{\n"
15031  	s = s | "\t\"id\": \"" | ev.ID | "\",\n"
15032  	s = s | "\t\"pubkey\": \"" | ev.PubKey | "\",\n"
15033  	s = s | "\t\"created_at\": " | i64toa(ev.CreatedAt) | ",\n"
15034  	s = s | "\t\"kind\": " | itoa(int32(ev.Kind)) | ",\n"
15035  	s = s | "\t\"tags\": [\n"
15036  	for i, tag := range ev.Tags {
15037  		s = s | "\t\t["
15038  		for j, v := range tag {
15039  			if j > 0 {
15040  				s = s | ", "
15041  			}
15042  			s = s | "\"" | jsonEsc(v) | "\""
15043  		}
15044  		s = s | "]"
15045  		if i < len(ev.Tags)-1 {
15046  			s = s | ","
15047  		}
15048  		s = s | "\n"
15049  	}
15050  	s = s | "\t],\n"
15051  	s = s | "\t\"content\": \"" | jsonEsc(ev.Content) | "\",\n"
15052  	s = s | "\t\"sig\": \"" | ev.Sig | "\"\n"
15053  	s = s | "}"
15054  	return s
15055  }
15056  
15057  // setMediaSrc sets elem's src to the /proxy/... same-origin path for the URL.
15058  // The browser's HTTP cache (Cache-Control: public, max-age=86400 from the
15059  // relay) serves cached images synchronously without any worker round-trip.
15060  func setMediaSrc(elem dom.Element, url string) {
15061  	if len(url) == 0 {
15062  		return
15063  	}
15064  	dom.SetAttribute(elem, "src", mediaProxyPath(url))
15065  }
15066  
15067  // mediaProxyPath converts an absolute URL to a same-origin /proxy/ path.
15068  // Same-origin and data: URLs are returned unchanged.
15069  func mediaProxyPath(url string) (s string) {
15070  	if len(url) == 0 || url[0] == '/' {
15071  		return url
15072  	}
15073  	if len(url) >= 5 && url[:5] == "data:" {
15074  		return url
15075  	}
15076  	if len(url) >= 8 && url[:8] == "https://" {
15077  		return "/proxy/" | url[8:]
15078  	}
15079  	if len(url) >= 7 && url[:7] == "http://" {
15080  		return "/proxy/" | url[7:]
15081  	}
15082  	return url
15083  }
15084  
15085  func isVideoURL(url string) (ok bool) {
15086  	u := toLower(url)
15087  	return hasSuffix(u, ".mp4") || hasSuffix(u, ".webm") || hasSuffix(u, ".mov") ||
15088  		hasSuffix(u, ".ogg") || hasSuffix(u, ".ogv")
15089  }
15090  
15091  // youTubeVideoID extracts the YouTube video ID from a URL.
15092  // Returns "" if the URL is not a YouTube link.
15093  // Handles youtube.com/watch?v=ID, youtu.be/ID, youtube.com/shorts/ID, youtube.com/embed/ID.
15094  func youTubeVideoID(url string) (s string) {
15095  	u := toLower(url)
15096  	// youtu.be/ID
15097  	if hasPrefix(u, "https://youtu.be/") || hasPrefix(u, "http://youtu.be/") {
15098  		rest := url[strIndex(url, "youtu.be/")+9:]
15099  		return youTubeExtractID(rest)
15100  	}
15101  	// youtube.com variants
15102  	if strIndex(u, "youtube.com/") < 0 {
15103  		return ""
15104  	}
15105  	// /shorts/ID
15106  	si := strIndex(u, "/shorts/")
15107  	if si >= 0 {
15108  		rest := url[si+8:]
15109  		return youTubeExtractID(rest)
15110  	}
15111  	// /embed/ID
15112  	ei := strIndex(u, "/embed/")
15113  	if ei >= 0 {
15114  		rest := url[ei+7:]
15115  		return youTubeExtractID(rest)
15116  	}
15117  	// ?v=ID or &v=ID
15118  	vi := strIndex(u, "v=")
15119  	if vi >= 0 {
15120  		rest := url[vi+2:]
15121  		return youTubeExtractID(rest)
15122  	}
15123  	return ""
15124  }
15125  
15126  // youTubeExtractID returns the video ID: the segment up to the first '&', '?', '#', '/' or end.
15127  func youTubeExtractID(s string) (sv string) {
15128  	end := len(s)
15129  	for i := 0; i < len(s); i++ {
15130  		c := s[i]
15131  		if c == '&' || c == '?' || c == '#' || c == '/' {
15132  			end = i
15133  			break
15134  		}
15135  	}
15136  	id := s[:end]
15137  	if len(id) < 5 || len(id) > 20 {
15138  		return ""
15139  	}
15140  	return id
15141  }
15142  
15143  func escapeHTMLAttr(s string) (sv string) {
15144  	out := ""
15145  	start := 0
15146  	for i := 0; i < len(s); i++ {
15147  		var esc string
15148  		switch s[i] {
15149  		case '"':
15150  			esc = "&quot;"
15151  		case '\'':
15152  			esc = "&#39;"
15153  		case '&':
15154  			esc = "&amp;"
15155  		case '<':
15156  			esc = "&lt;"
15157  		case '>':
15158  			esc = "&gt;"
15159  		default:
15160  			continue
15161  		}
15162  		out = out | s[start:i] | esc
15163  		start = i + 1
15164  	}
15165  	return out | s[start:]
15166  }
15167  
15168  func escapeHTML(s string) (sv string) {
15169  	out := ""
15170  	start := 0
15171  	for i := 0; i < len(s); i++ {
15172  		var esc string
15173  		switch s[i] {
15174  		case '&':
15175  			esc = "&amp;"
15176  		case '<':
15177  			esc = "&lt;"
15178  		case '>':
15179  			esc = "&gt;"
15180  		default:
15181  			continue
15182  		}
15183  		out = out | s[start:i] | esc
15184  		start = i + 1
15185  	}
15186  	return out | s[start:]
15187  }
15188  
15189  func hasSuffix(s, suffix string) (ok bool) {
15190  	return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
15191  }
15192  
15193  
15194  func updateInlineProfileLinks(pk string) {
15195  	name := appSt.authorNames[pk]
15196  	if name == "" {
15197  		return
15198  	}
15199  	// Find and update all <a data-pk="..."> links for this pubkey.
15200  	// Loop with QuerySelector since there's no QuerySelectorAll.
15201  	for {
15202  		el := dom.QuerySelector("a[data-pk=\"" | pk | "\"]")
15203  		if el == 0 {
15204  			break
15205  		}
15206  		dom.SetTextContent(el, name)
15207  		// Mark as done so we don't match it again.
15208  		dom.SetAttribute(el, "data-pk", "")
15209  	}
15210  }
15211  
15212  func embedEOSECleanup(subID string) {
15213  	ids, ok := appSt.embedSubIDs[subID]
15214  	if !ok {
15215  		return
15216  	}
15217  	delete(appSt.embedSubIDs, subID)
15218  	for _, hexID := range ids {
15219  		if _, still := appSt.embedCallbacks[hexID]; !still {
15220  			continue
15221  		}
15222  		// Check if any other sub is still pending for this ID.
15223  		otherPending := false
15224  		for _, otherIDs := range appSt.embedSubIDs {
15225  			for _, oid := range otherIDs {
15226  				if oid == hexID {
15227  					otherPending = true
15228  					break
15229  				}
15230  			}
15231  			if otherPending {
15232  				break
15233  			}
15234  		}
15235  		if otherPending {
15236  			continue
15237  		}
15238  		// No subs left - mark as not found.
15239  		delete(appSt.embedRequested, hexID)
15240  		elemIDs := appSt.embedCallbacks[hexID]
15241  		delete(appSt.embedCallbacks, hexID)
15242  		for _, elemID := range elemIDs {
15243  			el := dom.GetElementById(elemID)
15244  			if el == 0 {
15245  				continue
15246  			}
15247  			dom.SetInnerHTML(el, "<em style=\"color:var(--muted,#888)\">" | t("note_not_found") | "</em>")
15248  			dom.SetStyle(el, "opacity", "0.5")
15249  		}
15250  	}
15251  }
15252  
15253  // fillCoordEmbed matches an arriving event to its coord placeholders and fills them.
15254  func fillCoordEmbed(ev *nostr.Event) {
15255  	// Build the coord key from the event's kind, pubkey, and d-tag.
15256  	d := ""
15257  	for _, tag := range ev.Tags {
15258  		if len(tag) >= 2 && tag[0] == "d" {
15259  			d = tag[1]
15260  			break
15261  		}
15262  	}
15263  	coord := helpers.NaddrCoordKey(uint32(ev.Kind), ev.PubKey, d)
15264  	elemIDs, ok := appSt.embedCoordCBs[coord]
15265  	if !ok {
15266  		// Also check old generation.
15267  		elemIDs, ok = appSt.embedCoordCBsOld[coord]
15268  		if !ok {
15269  			return
15270  		}
15271  		delete(appSt.embedCoordCBsOld, coord)
15272  	} else {
15273  		delete(appSt.embedCoordCBs, coord)
15274  	}
15275  
15276  	name := ev.PubKey[:8] | "..."
15277  	if n, ok2 := appSt.authorNames[ev.PubKey]; ok2 && n != "" {
15278  		name = n
15279  	}
15280  	// For kind 30023, use the title tag; otherwise truncate content.
15281  	title := ""
15282  	if ev.Kind == uint32(30023) {
15283  		for _, tag := range ev.Tags {
15284  			if len(tag) >= 2 && tag[0] == "title" {
15285  				title = tag[1]
15286  				break
15287  			}
15288  		}
15289  	}
15290  	text := title
15291  	if text == "" {
15292  		text = ev.Content
15293  		nl := -1
15294  		for i := 0; i < len(text); i++ {
15295  			if text[i] == '\n' {
15296  				nl = i
15297  				break
15298  			}
15299  		}
15300  		if nl >= 0 {
15301  			text = text[:nl]
15302  		}
15303  	}
15304  
15305  	headerHTML := "<div style=\"display:flex;align-items:center;gap:6px;margin-bottom:4px\">"
15306  	if pic, ok2 := appSt.authorPics[ev.PubKey]; ok2 && pic != "" {
15307  		headerHTML = headerHTML | "<img src=\"" | escapeHTMLAttr(mediaProxyPath(pic)) | "\" width=\"16\" height=\"16\" " |
15308  			"style=\"border-radius:50%\" referrerpolicy=\"no-referrer\" onerror=\"this.style.display='none'\">"
15309  	}
15310  	headerHTML = headerHTML | "<strong style=\"font-size:12px\">" | escapeHTML(name) | "</strong></div>"
15311  
15312  	capturedEv := ev
15313  	for _, elemID := range elemIDs {
15314  		el := dom.GetElementById(elemID)
15315  		if el == 0 {
15316  			continue
15317  		}
15318  		clearChildren(el)
15319  		dom.SetStyle(el, "opacity", "1")
15320  		dom.SetStyle(el, "cursor", "pointer")
15321  
15322  		hdr := dom.CreateElement("div")
15323  		dom.SetInnerHTML(hdr, headerHTML)
15324  		dom.AppendChild(el, hdr)
15325  
15326  		body := dom.CreateElement("div")
15327  		dom.SetStyle(body, "fontSize", "13px")
15328  		dom.SetStyle(body, "lineHeight", "1.4")
15329  		dom.SetInnerHTML(body, renderEmbedText(text))
15330  		dom.AppendChild(el, body)
15331  
15332  		thisEl := el
15333  		dom.AddEventListener(thisEl, "click", dom.RegisterCallback(func() {
15334  			showNoteThread(capturedEv.ID, capturedEv.ID)
15335  		}))
15336  	}
15337  }
15338  
15339  // embedCoordEOSECleanup marks unfound coord embeds as not found.
15340  func embedCoordEOSECleanup(subID string) {
15341  	coord, ok := appSt.embedCoordSubIDs[subID]
15342  	if !ok {
15343  		return
15344  	}
15345  	delete(appSt.embedCoordSubIDs, subID)
15346  	// Check if any other coord sub is still pending for this coord.
15347  	for _, other := range appSt.embedCoordSubIDs {
15348  		if other == coord {
15349  			return
15350  		}
15351  	}
15352  	elemIDs, ok := appSt.embedCoordCBs[coord]
15353  	if !ok {
15354  		return
15355  	}
15356  	delete(appSt.embedCoordCBs, coord)
15357  	for _, elemID := range elemIDs {
15358  		el := dom.GetElementById(elemID)
15359  		if el == 0 {
15360  			continue
15361  		}
15362  		dom.SetInnerHTML(el, "<em style=\"color:var(--muted,#888)\">" | t("note_not_found") | "</em>")
15363  		dom.SetStyle(el, "opacity", "0.5")
15364  	}
15365  }
15366  
15367  func makeCoordEmbedPlaceholder(coord string, kind uint32, pubkey, d string) (s string) {
15368  	appSt.embedCounter++
15369  	elemID := "emb-" | itoa(appSt.embedCounter)
15370  	appSt.embedCoordCBs[coord] = mxutil.Ensure(appSt.embedCoordCBs[coord], 1)
15371  	appSt.embedCoordCBs[coord] = push(appSt.embedCoordCBs[coord], elemID)
15372  	label := "Loading..."
15373  	if kind == uint32(30023) {
15374  		label = "Loading article..."
15375  	}
15376  	return "<div id=\"" | elemID | "\" data-coord=\"" | escapeHTMLAttr(coord) | "\" " |
15377  		"style=\"border-left:3px solid var(--accent);margin:8px 0;padding:8px 12px;" |
15378  		"background:var(--bg2,#1a1a2e);border-radius:4px;opacity:0.6;font-size:13px\">" |
15379  		"<em>" | label | "</em></div>"
15380  }
15381  
15382  func makeEmbedPlaceholder(hexID string) (s string) {
15383  	appSt.embedCounter++
15384  	elemID := "emb-" | itoa(appSt.embedCounter)
15385  	appSt.embedCallbacks[hexID] = mxutil.Ensure(appSt.embedCallbacks[hexID], 1)
15386  	appSt.embedCallbacks[hexID] = push(appSt.embedCallbacks[hexID], elemID)
15387  	return "<div id=\"" | elemID | "\" data-eid=\"" | hexID | "\" " |
15388  		"style=\"border-left:3px solid var(--accent);margin:8px 0;padding:8px 12px;" |
15389  		"background:var(--bg2,#1a1a2e);border-radius:4px;opacity:0.6;font-size:13px\">" |
15390  		"<em>Loading note...</em></div>"
15391  }
15392  
15393  func resolveEmbeds() {
15394  	if len(appSt.embedCallbacks) == 0 && len(appSt.embedCoordCBs) == 0 {
15395  		return
15396  	}
15397  	if len(appSt.embedCallbacks) == 0 {
15398  		for coord := range appSt.embedCoordCBs {
15399  			resolveCoordEmbed(coord)
15400  		}
15401  		return
15402  	}
15403  	// Fill any embeds already in cache before requesting.
15404  	for hexID := range appSt.embedCallbacks {
15405  		if ev, ok := lookupEvent(hexID); ok && ev != nil {
15406  			fillEmbed(ev)
15407  		}
15408  	}
15409  	if len(appSt.embedCallbacks) == 0 {
15410  		return
15411  	}
15412  	// Only request IDs we haven't already sent to PROXY.
15413  	// If embedRequested is true but the event isn't in cache, clear the
15414  	// flag so we re-fetch - the event may have been evicted by rotation.
15415  	var ids []string
15416  	for hexID := range appSt.embedCallbacks {
15417  		if appSt.embedRequested[hexID] {
15418  			if _, ok2 := lookupEvent(hexID); ok2 {
15419  				continue
15420  			}
15421  			delete(appSt.embedRequested, hexID)
15422  		}
15423  		ids = mxutil.Ensure(ids, 1)
15424  		ids = push(ids, hexID)
15425  		appSt.embedRequested[hexID] = true
15426  	}
15427  	if len(ids) == 0 {
15428  		return
15429  	}
15430  	filter := "{\"ids\":["
15431  	for i, id := range ids {
15432  		if i > 0 {
15433  			filter = filter | ","
15434  		}
15435  		filter = filter | jstr(id)
15436  	}
15437  	filter = filter | "]}"
15438  
15439  	// REQ checks the local IDB cache.
15440  	appSt.embedCounter++
15441  	reqID := "emb-r-" | itoa(appSt.embedCounter)
15442  	appSt.embedSubIDs[reqID] = ids
15443  	routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]")
15444  
15445  	// PROXY fetches from relays - combine user relays + nevent relay hints.
15446  	seen := map[string]bool{}
15447  	var urls []string
15448  	for _, u := range readRelayURLs() {
15449  		if !seen[u] {
15450  			seen[u] = true
15451  			urls = mxutil.Ensure(urls, 1)
15452  			urls = push(urls, u)
15453  		}
15454  	}
15455  	for _, id := range ids {
15456  		hints := appSt.embedRelayHints[id]
15457  		if len(hints) == 0 {
15458  			hints = appSt.embedRelayHintsOld[id]
15459  			delete(appSt.embedRelayHintsOld, id)
15460  		} else {
15461  			delete(appSt.embedRelayHints, id)
15462  		}
15463  		for _, u := range hints {
15464  			if !seen[u] {
15465  				seen[u] = true
15466  				urls = mxutil.Ensure(urls, 1)
15467  				urls = push(urls, u)
15468  			}
15469  		}
15470  	}
15471  	if len(urls) > 0 {
15472  		proxyID := "emb-p-" | itoa(appSt.embedCounter)
15473  		appSt.embedSubIDs[proxyID] = ids
15474  		routeMsg(buildProxyMsg(proxyID, filter, urls))
15475  	}
15476  
15477  	// Coord embeds: one PROXY sub per pending coord.
15478  	for coord := range appSt.embedCoordCBs {
15479  		resolveCoordEmbed(coord)
15480  	}
15481  }
15482  
15483  // resolveCoordEmbed fires a PROXY fetch for one naddr coordinate.
15484  func resolveCoordEmbed(coord string) {
15485  	// Parse "kind:pubkey:d" - split on first two colons.
15486  	ci := -1
15487  	for i := 0; i < len(coord); i++ {
15488  		if coord[i] == ':' {
15489  			ci = i
15490  			break
15491  		}
15492  	}
15493  	if ci < 0 {
15494  		return
15495  	}
15496  	kindStr := coord[:ci]
15497  	rest := coord[ci+1:]
15498  	pi := -1
15499  	for i := 0; i < len(rest); i++ {
15500  		if rest[i] == ':' {
15501  			pi = i
15502  			break
15503  		}
15504  	}
15505  	if pi < 0 {
15506  		return
15507  	}
15508  	pubkey := rest[:pi]
15509  	d := rest[pi+1:]
15510  
15511  	filter := "{\"kinds\":[" | kindStr | "],\"authors\":[" | jstr(pubkey) | "],\"#d\":[" | jstr(d) | "]}"
15512  
15513  	// Relay hints from the naddr TLV.
15514  	coordRelays := appSt.embedRelayHints[coord]
15515  	if len(coordRelays) > 0 {
15516  		delete(appSt.embedRelayHints, coord)
15517  	} else {
15518  		coordRelays = appSt.embedRelayHintsOld[coord]
15519  		delete(appSt.embedRelayHintsOld, coord)
15520  	}
15521  	var urls []string
15522  	seen := map[string]bool{}
15523  	for _, u := range readRelayURLs() {
15524  		if !seen[u] {
15525  			seen[u] = true
15526  			urls = mxutil.Ensure(urls, 1)
15527  			urls = push(urls, u)
15528  		}
15529  	}
15530  	for _, u := range coordRelays {
15531  		if !seen[u] {
15532  			seen[u] = true
15533  			urls = mxutil.Ensure(urls, 1)
15534  			urls = push(urls, u)
15535  		}
15536  	}
15537  
15538  	appSt.embedCounter++
15539  	if len(urls) > 0 {
15540  		proxyID := "emb-c-" | itoa(appSt.embedCounter)
15541  		appSt.embedCoordSubIDs[proxyID] = coord
15542  		routeMsg(buildProxyMsg(proxyID, filter, urls))
15543  	}
15544  	reqID := "emb-cr-" | itoa(appSt.embedCounter)
15545  	appSt.embedCoordSubIDs[reqID] = coord
15546  	routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]")
15547  }
15548  
15549  func renderEmbedText(s string) (sv string) {
15550  	s = markdown.Render(s)
15551  	s = convertMediaLinks(s)
15552  	return s
15553  }
15554  
15555  func fillEmbed(ev *nostr.Event) {
15556  	elemIDs, okw1 := appSt.embedCallbacks[ev.ID]
15557  	if !okw1 {
15558  		return
15559  	}
15560  	delete(appSt.embedCallbacks, ev.ID)
15561  	appSt.embedCacheCount++
15562  	if appSt.embedCacheCount > rotateEmbedMax {
15563  		rotateEmbedCaches()
15564  	}
15565  
15566  	name := ev.PubKey[:8] | "..."
15567  	if n, okv3 := appSt.authorNames[ev.PubKey]; okv3 && n != "" {
15568  		name = n
15569  	}
15570  	headerHTML := "<div style=\"display:flex;align-items:center;gap:6px;margin-bottom:4px\">"
15571  	if pic, okv2 := appSt.authorPics[ev.PubKey]; okv2 && pic != "" {
15572  		headerHTML = headerHTML | "<img src=\"" | escapeHTMLAttr(mediaProxyPath(pic)) | "\" width=\"16\" height=\"16\" " |
15573  			"style=\"border-radius:50%\" referrerpolicy=\"no-referrer\" onerror=\"this.style.display='none'\">"
15574  	}
15575  	headerHTML = headerHTML | "<strong style=\"font-size:12px\">" | escapeHTML(name) | "</strong></div>"
15576  
15577  	firstNL := -1
15578  	for i := 0; i < len(ev.Content); i++ {
15579  		if ev.Content[i] == '\n' {
15580  			firstNL = i
15581  			break
15582  		}
15583  	}
15584  	truncated := firstNL >= 0 && firstNL < len(ev.Content)-1
15585  	text := ev.Content
15586  	if truncated {
15587  		text = text[:firstNL]
15588  	}
15589  
15590  	embedEvID := ev.ID
15591  	embedRootID := getRootID(ev)
15592  	if embedRootID == "" {
15593  		embedRootID = embedEvID
15594  	}
15595  
15596  	for _, elemID := range elemIDs {
15597  		el := dom.GetElementById(elemID)
15598  		if el == 0 {
15599  			continue
15600  		}
15601  		clearChildren(el)
15602  		dom.SetStyle(el, "opacity", "1")
15603  		dom.SetStyle(el, "cursor", "pointer")
15604  
15605  		hdr := dom.CreateElement("div")
15606  		dom.SetInnerHTML(hdr, headerHTML)
15607  		dom.AppendChild(el, hdr)
15608  
15609  		body := dom.CreateElement("div")
15610  		dom.SetStyle(body, "fontSize", "13px")
15611  		dom.SetStyle(body, "lineHeight", "1.4")
15612  		dom.SetInnerHTML(body, renderEmbedText(text))
15613  		dom.AppendChild(el, body)
15614  
15615  		thisEl := el
15616  		dom.AddEventListener(thisEl, "click", dom.RegisterCallback(func() {
15617  			showNoteThread(embedRootID, embedEvID)
15618  		}))
15619  	}
15620  
15621  	if _, okv1 := appSt.authorNames[ev.PubKey]; !okv1 {
15622  		if !lookupFetchedK0(ev.PubKey) {
15623  			profile.Send(`["P_RESOLVE",` | jstr(ev.PubKey) | `]`)
15624  		}
15625  	}
15626  }
15627  
15628  // jsonGetNum extracts a numeric value for a given key from a JSON object.
15629  func jsonGetNum(s, key string) (n int64) {
15630  	needle := "\"" | key | "\":"
15631  	idx := strIndex(s, needle)
15632  	if idx < 0 {
15633  		return 0
15634  	}
15635  	idx += len(needle)
15636  	// Skip whitespace.
15637  	for idx < len(s) && (s[idx] == ' ' || s[idx] == '\t') {
15638  		idx++
15639  	}
15640  	if idx >= len(s) {
15641  		return 0
15642  	}
15643  	for idx < len(s) && s[idx] >= '0' && s[idx] <= '9' {
15644  		n = n*10 + int64(s[idx]-'0')
15645  		idx++
15646  	}
15647  	return n
15648  }
15649  
15650  // jsonEsc escapes a string for embedding in a JSON value.
15651  // defaultServiceURL derives a service subdomain URL from the current page origin.
15652  // wss://smesh.lol + "invidious" → https://invidious.smesh.lol
15653  func defaultServiceURL(sub string) (s string) {
15654  	local := localRelayURL()
15655  	var host string
15656  	if len(local) >= 6 && local[:6] == "wss://" {
15657  		host = local[6:]
15658  	} else if len(local) >= 5 && local[:5] == "ws://" {
15659  		host = local[5:]
15660  	} else {
15661  		return ""
15662  	}
15663  	// Only derive for non-localhost origins.
15664  	if host == "localhost" || len(host) > 9 && host[:9] == "127.0.0.1" {
15665  		return ""
15666  	}
15667  	return "https://" | sub | "." | host
15668  }
15669  
15670  // defaultBlossomURL derives the blossom server URL from the current page origin.
15671  // wss://smesh.lol → https://smesh.lol/blossom
15672  // --- Search ---
15673  
15674  func activateSearchBar(btn dom.Element) {
15675  	if !appSt.searchBarActive {
15676  		appSt.searchReturnPage = appSt.activePage // remember where to return on manual close
15677  	}
15678  	appSt.searchBarActive = true
15679  	dom.SetStyle(btn, "background", "var(--accent)")
15680  	dom.SetStyle(btn, "color", "#fff")
15681  	dom.SetStyle(btn, "borderRadius", "4px")
15682  	dom.SetStyle(appSt.feedSelectEl, "display", "none")
15683  	dom.SetStyle(appSt.pageTitleEl, "display", "none")
15684  	dom.SetStyle(appSt.notifBarGroup, "display", "none")
15685  	dom.SetStyle(appSt.topBarRight, "display", "none")
15686  	dom.SetStyle(appSt.searchInputEl, "display", "block")
15687  	dom.SetStyle(appSt.searchInputEl, "flex", "1")
15688  	dom.SetStyle(appSt.searchSubmitBtn, "display", "block")
15689  	dom.Focus(appSt.searchInputEl)
15690  }
15691  
15692  // deactivateSearchBar restores the bar appearance only.
15693  // Callers are responsible for any page navigation.
15694  func deactivateSearchBar(btn dom.Element) {
15695  	appSt.searchBarActive = false
15696  	dom.SetStyle(btn, "background", "transparent")
15697  	dom.SetStyle(btn, "color", "var(--muted)")
15698  	dom.SetStyle(btn, "borderRadius", "4px")
15699  	dom.SetStyle(appSt.topBarRight, "display", "flex")
15700  	dom.SetStyle(appSt.searchInputEl, "display", "none")
15701  	dom.SetStyle(appSt.searchInputEl, "flex", "")
15702  	dom.SetStyle(appSt.searchSubmitBtn, "display", "none")
15703  }
15704  
15705  // closeSearch is called when the user explicitly closes search (clicks 🔍 again).
15706  // Deactivates the bar and returns to the previous page.
15707  func closeSearch(btn dom.Element) {
15708  	deactivateSearchBar(btn)
15709  	ret := appSt.searchReturnPage
15710  	if ret == "" || ret == "search" {
15711  		ret = "feed"
15712  	}
15713  	appSt.searchReturnPage = ""
15714  	appSt.activePage = "" // force switchPage to run
15715  	switchPage(ret)
15716  }
15717  
15718  // executeSearch submits a search query: #tag searches by t-tag, other text
15719  // runs NIP-50 full-text search. Switches to the search page and subscribes.
15720  func executeSearch(q string) {
15721  	q = trimStr(q)
15722  	if q == "" {
15723  		return
15724  	}
15725  	appSt.searchCurrentQ = q
15726  	appSt.searchResults = []*nostr.Event{:0}
15727  	appSt.searchSeen = map[string]bool{}
15728  
15729  	switchPage("search")
15730  	// URL reflects the current query.
15731  	dom.ReplaceState("/search?q=" | dom.EncodeURIComponent(q))
15732  
15733  	// Build filter and choose target relays.
15734  	var filter string
15735  	var searchRelayList []string
15736  	if len(q) > 1 && q[0] == '#' {
15737  		// Tag search works on all relays; cap at 5 to limit event burst.
15738  		tag := q[1:]
15739  		filter = "{\"kinds\":[1,1111],\"#t\":[" | jstr(tag) | "],\"limit\":10}"
15740  		searchRelayList = appSt.relayURLs
15741  		if len(searchRelayList) > 5 {
15742  			searchRelayList = searchRelayList[:5]
15743  		}
15744  	} else {
15745  		// Full-text search: only NIP-50 relays, capped at 3 to bound
15746  		// the event burst through the relay-proxy WASM.
15747  		filter = "{\"kinds\":[1,1111],\"search\":" | jstr(q) | ",\"limit\":10}"
15748  		searchRelayList = nip50Relays()
15749  		if len(searchRelayList) > 3 {
15750  			searchRelayList = searchRelayList[:3]
15751  		}
15752  	}
15753  
15754  	// Build filter bar (sticky, above results) then show loading.
15755  	clearChildren(appSt.searchContainer)
15756  	buildSearchFilterBar()
15757  
15758  	loading := dom.CreateElement("div")
15759  	dom.SetStyle(loading, "color", "var(--muted)")
15760  	dom.SetStyle(loading, "fontSize", "14px")
15761  	dom.SetStyle(loading, "padding", "16px")
15762  	dom.SetStyle(loading, "fontFamily", "'Fira Code', monospace")
15763  	if len(searchRelayList) == 0 {
15764  		dom.SetTextContent(loading, "no full-text search relays available \xe2\x80\x94 try #hashtag")
15765  		dom.AppendChild(appSt.searchContainer, loading)
15766  		return
15767  	}
15768  	relayWord := "relays"
15769  	if len(searchRelayList) == 1 {
15770  		relayWord = "relay"
15771  	}
15772  	dom.SetTextContent(loading, "searching " | itoa(len(searchRelayList)) | " " | relayWord | "...")
15773  	dom.AppendChild(appSt.searchContainer, loading)
15774  
15775  	routeMsg("[\"CLOSE\",\"search\"]")
15776  	routeMsg(buildProxyMsg("search", filter, searchRelayList))
15777  }
15778  
15779  func buildSearchFilterBar() {
15780  	// Rebuild the filter bar (recreate buttons so state labels are fresh).
15781  	if appSt.searchFilterBarEl != 0 {
15782  		dom.RemoveChild(appSt.searchPage, appSt.searchFilterBarEl)
15783  	}
15784  	bar := dom.CreateElement("div")
15785  	appSt.searchFilterBarEl = bar
15786  	dom.SetAttribute(bar, "class", "search-filter-bar")
15787  	dom.SetStyle(bar, "display", "flex")
15788  	dom.SetStyle(bar, "gap", "8px")
15789  	dom.SetStyle(bar, "padding", "8px 12px")
15790  	dom.SetStyle(bar, "borderBottom", "1px solid var(--border)")
15791  	dom.SetStyle(bar, "flexWrap", "wrap")
15792  	dom.SetStyle(bar, "alignItems", "center")
15793  	dom.SetStyle(bar, "position", "sticky")
15794  	dom.SetStyle(bar, "top", "0")
15795  	dom.SetStyle(bar, "background", "var(--bg2)")
15796  	dom.SetStyle(bar, "zIndex", "10")
15797  
15798  	followsBtn := searchFilterBtn("follows only", appSt.searchFollowsOnly)
15799  	dom.AddEventListener(followsBtn, "click", dom.RegisterCallback(func() {
15800  		appSt.searchFollowsOnly = !appSt.searchFollowsOnly
15801  		applySearchFilterBtn(followsBtn, appSt.searchFollowsOnly)
15802  		renderSearchResults()
15803  	}))
15804  	dom.AppendChild(bar, followsBtn)
15805  
15806  	sortBtn := searchFilterBtn("newest first", appSt.searchSortDesc)
15807  	dom.SetAttribute(sortBtn, "data-sort", "1")
15808  	dom.AddEventListener(sortBtn, "click", dom.RegisterCallback(func() {
15809  		appSt.searchSortDesc = !appSt.searchSortDesc
15810  		if appSt.searchSortDesc {
15811  			dom.SetTextContent(sortBtn, "newest first")
15812  		} else {
15813  			dom.SetTextContent(sortBtn, "oldest first")
15814  		}
15815  		applySearchFilterBtn(sortBtn, true)
15816  		renderSearchResults()
15817  	}))
15818  	dom.AppendChild(bar, sortBtn)
15819  
15820  	nsfwBtn := searchFilterBtn("nsfw", appSt.searchNSFW)
15821  	dom.AddEventListener(nsfwBtn, "click", dom.RegisterCallback(func() {
15822  		appSt.searchNSFW = !appSt.searchNSFW
15823  		applySearchFilterBtn(nsfwBtn, appSt.searchNSFW)
15824  		renderSearchResults()
15825  	}))
15826  	dom.AppendChild(bar, nsfwBtn)
15827  
15828  	// Insert filter bar BEFORE searchContainer in searchPage.
15829  	dom.InsertBefore(appSt.searchPage, bar, appSt.searchContainer)
15830  }
15831  
15832  func searchFilterBtn(label string, active bool) (e dom.Element) {
15833  	btn := dom.CreateElement("button")
15834  	dom.SetTextContent(btn, label)
15835  	dom.SetStyle(btn, "padding", "4px 12px")
15836  	dom.SetStyle(btn, "borderRadius", "4px")
15837  	dom.SetStyle(btn, "fontSize", "12px")
15838  	dom.SetStyle(btn, "cursor", "pointer")
15839  	dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace")
15840  	applySearchFilterBtn(btn, active)
15841  	return btn
15842  }
15843  
15844  func applySearchFilterBtn(btn dom.Element, active bool) {
15845  	if active {
15846  		dom.SetStyle(btn, "background", "var(--accent)")
15847  		dom.SetStyle(btn, "color", "#fff")
15848  		dom.SetStyle(btn, "border", "none")
15849  	} else {
15850  		dom.SetStyle(btn, "background", "transparent")
15851  		dom.SetStyle(btn, "color", "var(--muted)")
15852  		dom.SetStyle(btn, "border", "1px solid var(--border)")
15853  	}
15854  }
15855  
15856  func handleSearchEvent(ev *nostr.Event) {
15857  	if ev.Kind != 1 && ev.Kind != 1111 {
15858  		return
15859  	}
15860  	if appSt.searchSeen[ev.ID] {
15861  		return
15862  	}
15863  	appSt.searchSeen[ev.ID] = true
15864  	if isMuted(ev.PubKey) {
15865  		return
15866  	}
15867  	if looksLikeJSONSpam(ev.Content) {
15868  		return
15869  	}
15870  	appSt.searchResults = mxutil.Ensure(appSt.searchResults, 1)
15871  	appSt.searchResults = push(appSt.searchResults, ev)
15872  	setEvent(ev.ID, ev)
15873  }
15874  
15875  const searchPageSize = 25 // results rendered per page / scroll load
15876  
15877  // filteredSearchResults returns searchResults with current filters applied and sorted.
15878  func filteredSearchResults() (ss []*nostr.Event) {
15879  	var filtered []*nostr.Event
15880  	for _, ev := range appSt.searchResults {
15881  		if appSt.searchFollowsOnly && !isFollowing(ev.PubKey) {
15882  			continue
15883  		}
15884  		if !appSt.searchNSFW {
15885  			if nostr.TagsGetFirst(ev.Tags, []byte("content-warning")) != nil {
15886  				continue
15887  			}
15888  		}
15889  		filtered = mxutil.Ensure(filtered, 1)
15890  		filtered = push(filtered, ev)
15891  	}
15892  	for i := 0; i < len(filtered); i++ {
15893  		for j := i + 1; j < len(filtered); j++ {
15894  			var swap bool
15895  			if appSt.searchSortDesc {
15896  				swap = filtered[j].CreatedAt > filtered[i].CreatedAt
15897  			} else {
15898  				swap = filtered[j].CreatedAt < filtered[i].CreatedAt
15899  			}
15900  			if swap {
15901  				filtered[i], filtered[j] = filtered[j], filtered[i]
15902  			}
15903  		}
15904  	}
15905  	return filtered
15906  }
15907  
15908  // renderSearchResults rebuilds the result list from scratch, rendering only
15909  // the first searchPageSize results. Remaining results load on scroll.
15910  func renderSearchResults() {
15911  	appSt.searchRendered = 0
15912  	clearChildren(appSt.searchContainer)
15913  
15914  	filtered := filteredSearchResults()
15915  	if len(filtered) == 0 {
15916  		empty := dom.CreateElement("div")
15917  		dom.SetTextContent(empty, "no results")
15918  		dom.SetStyle(empty, "color", "var(--muted)")
15919  		dom.SetStyle(empty, "fontSize", "14px")
15920  		dom.SetStyle(empty, "padding", "16px")
15921  		dom.SetStyle(empty, "fontFamily", "'Fira Code', monospace")
15922  		dom.AppendChild(appSt.searchContainer, empty)
15923  		return
15924  	}
15925  
15926  	end := len(filtered)
15927  	if end > searchPageSize {
15928  		end = searchPageSize
15929  	}
15930  	for i := 0; i < end; i++ {
15931  		dom.AppendChild(appSt.searchContainer, renderSearchResult(filtered[i]))
15932  	}
15933  	appSt.searchRendered = end
15934  }
15935  
15936  // appendSearchResults renders the next batch of results (called on scroll).
15937  func appendSearchResults() {
15938  	filtered := filteredSearchResults()
15939  	if appSt.searchRendered >= len(filtered) {
15940  		return
15941  	}
15942  	end := appSt.searchRendered + searchPageSize
15943  	if end > len(filtered) {
15944  		end = len(filtered)
15945  	}
15946  	for i := appSt.searchRendered; i < end; i++ {
15947  		dom.AppendChild(appSt.searchContainer, renderSearchResult(filtered[i]))
15948  	}
15949  	appSt.searchRendered = end
15950  }
15951  
15952  func renderSearchResult(ev *nostr.Event) (e dom.Element) {
15953  	row := dom.CreateElement("div")
15954  	dom.SetStyle(row, "display", "flex")
15955  	dom.SetStyle(row, "flexDirection", "column")
15956  	dom.SetStyle(row, "padding", "10px 12px")
15957  	dom.SetStyle(row, "borderBottom", "1px solid var(--border)")
15958  	dom.SetStyle(row, "cursor", "pointer")
15959  	dom.SetStyle(row, "gap", "4px")
15960  
15961  	// Header: avatar + name + timestamp.
15962  	header := dom.CreateElement("div")
15963  	dom.SetStyle(header, "display", "flex")
15964  	dom.SetStyle(header, "alignItems", "center")
15965  	dom.SetStyle(header, "gap", "8px")
15966  
15967  	avatar := dom.CreateElement("img")
15968  	dom.SetAttribute(avatar, "width", "20")
15969  	dom.SetAttribute(avatar, "height", "20")
15970  	dom.SetAttribute(avatar, "referrerpolicy", "no-referrer")
15971  	dom.SetStyle(avatar, "borderRadius", "50%")
15972  	dom.SetStyle(avatar, "objectFit", "cover")
15973  	dom.SetStyle(avatar, "flexShrink", "0")
15974  	pk := ev.PubKey
15975  	if pic, ok := appSt.authorPics[pk]; ok && pic != "" {
15976  		setMediaSrc(avatar, pic)
15977  		dom.SetAttribute(avatar, "onerror", "this.style.display='none'")
15978  	} else {
15979  		dom.SetStyle(avatar, "display", "none")
15980  	}
15981  	dom.AppendChild(header, avatar)
15982  
15983  	name := dom.CreateElement("span")
15984  	dom.SetStyle(name, "fontSize", "14px")
15985  	dom.SetStyle(name, "fontWeight", "bold")
15986  	dom.SetStyle(name, "color", "var(--fg)")
15987  	dom.SetStyle(name, "flex", "1")
15988  	dom.SetStyle(name, "overflow", "hidden")
15989  	dom.SetStyle(name, "textOverflow", "ellipsis")
15990  	dom.SetStyle(name, "whiteSpace", "nowrap")
15991  	if n, ok := appSt.authorNames[pk]; ok && n != "" {
15992  		dom.SetTextContent(name, n)
15993  	} else {
15994  		npub := helpers.EncodeNpub(helpers.HexDecode(pk))
15995  		if len(npub) > 20 {
15996  			dom.SetTextContent(name, npub[:12] | "..." | npub[len(npub)-4:])
15997  		}
15998  	}
15999  	dom.AppendChild(header, name)
16000  
16001  	ts := dom.CreateElement("span")
16002  	dom.SetTextContent(ts, formatTime(ev.CreatedAt))
16003  	dom.SetStyle(ts, "fontSize", "11px")
16004  	dom.SetStyle(ts, "color", "var(--muted)")
16005  	dom.SetStyle(ts, "flexShrink", "0")
16006  	dom.AppendChild(header, ts)
16007  	dom.AppendChild(row, header)
16008  
16009  	// Two-line content preview (plain text, no markdown rendering).
16010  	preview := dom.CreateElement("div")
16011  	dom.SetStyle(preview, "fontSize", "13px")
16012  	dom.SetStyle(preview, "color", "var(--fg)")
16013  	dom.SetStyle(preview, "lineHeight", "1.4")
16014  	dom.SetStyle(preview, "overflow", "hidden")
16015  	dom.SetStyle(preview, "display", "-webkit-box")
16016  	dom.SetStyle(preview, "-webkit-line-clamp", "2")
16017  	dom.SetStyle(preview, "-webkit-box-orient", "vertical")
16018  	dom.SetStyle(preview, "wordBreak", "break-word")
16019  	content := ev.Content
16020  	if len(content) > 300 {
16021  		content = content[:300] | "..."
16022  	}
16023  	dom.SetTextContent(preview, content)
16024  	dom.AppendChild(row, preview)
16025  
16026  	// Click opens thread.
16027  	evID := ev.ID
16028  	evRootID := getRootID(ev)
16029  	if evRootID == "" {
16030  		evRootID = evID
16031  	}
16032  	dom.AddEventListener(row, "click", dom.RegisterCallback(func() {
16033  		showNoteThread(evRootID, evID)
16034  	}))
16035  
16036  	if _, cached := appSt.authorNames[pk]; !cached {
16037  		appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1)
16038  		appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], header)
16039  		if !lookupFetchedK0(pk) {
16040  			profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`)
16041  		}
16042  	}
16043  
16044  	return row
16045  }
16046  
16047  // extractHashtags replaces #tag tokens with placeholders so markdown-it
16048  // preserves them. Restored after rendering as clickable search spans.
16049  func extractHashtags(s string, slots *[]string) (sv string) {
16050  	out := ""
16051  	i := 0
16052  	for i < len(s) {
16053  		if s[i] != '#' {
16054  			out = out | string([]byte{s[i]})
16055  			i++
16056  			continue
16057  		}
16058  		// Must be preceded by whitespace/start/newline to be a hashtag.
16059  		if i > 0 {
16060  			prev := s[i-1]
16061  			if prev != ' ' && prev != '\n' && prev != '\t' && prev != '\r' {
16062  				out = out | "#"
16063  				i++
16064  				continue
16065  			}
16066  		}
16067  		// Collect tag characters: letters, digits, underscores.
16068  		j := i + 1
16069  		for j < len(s) {
16070  			c := s[j]
16071  			if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' {
16072  				j++
16073  			} else {
16074  				break
16075  			}
16076  		}
16077  		if j == i+1 {
16078  			// No tag characters after #.
16079  			out = out | "#"
16080  			i++
16081  			continue
16082  		}
16083  		tag := s[i+1 : j]
16084  		idx := len(*slots)
16085  		span := "<span data-search-tag=\"" | escapeHTMLAttr(tag) | "\" style=\"color:var(--accent);cursor:pointer\">#" | tag | "</span>"
16086  		*slots = push(*slots, span)
16087  		out = out | "\x02HASH" | itoa(idx) | "\x02"
16088  		i = j
16089  	}
16090  	return out
16091  }
16092  
16093  // attachHashtagLinks wires click handlers on [data-search-tag] spans.
16094  func attachHashtagLinks(rootp2 dom.Element) {
16095  	el := dom.QuerySelectorFrom(rootp2, "[data-search-tag]")
16096  	for el != 0 {
16097  		tag := dom.GetAttribute(el, "data-search-tag")
16098  		dom.RemoveAttribute(el, "data-search-tag")
16099  		if tag != "" {
16100  			capturedTag := tag
16101  			dom.AddEventListener(el, "click", dom.RegisterCallback(func() {
16102  				dom.SetProperty(appSt.searchInputEl, "value", "#" | capturedTag)
16103  				executeSearch("#" | capturedTag)
16104  			}))
16105  		}
16106  		el = dom.QuerySelectorFrom(rootp2, "[data-search-tag]")
16107  	}
16108  }
16109  
16110  // trimStr trims leading and trailing whitespace from s.
16111  func trimStr(s string) (sv string) {
16112  	start := 0
16113  	for start < len(s) && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') {
16114  		start++
16115  	}
16116  	end := len(s)
16117  	for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') {
16118  		end--
16119  	}
16120  	return s[start:end]
16121  }
16122  
16123  // nip50Relays returns the subset of configured relay URLs that advertise NIP-50
16124  // (full-text search) in their NIP-11 document.
16125  func nip50Relays() (ss []string) {
16126  	var out []string
16127  	for i, url := range appSt.relayURLs {
16128  		if i < len(appSt.relayCaps) && hasNIP(appSt.relayCaps[i].supportedNIPs, 50) {
16129  			out = mxutil.Ensure(out, 1)
16130  			out = push(out, url)
16131  		}
16132  	}
16133  	return out
16134  }
16135  
16136  // looksLikeJSONSpam returns true if the content is a raw JSON object or array
16137  // larger than 200 bytes. Catches protocol spam posted as kind 1 notes.
16138  func looksLikeJSONSpam(content string) (ok bool) {
16139  	i := 0
16140  	for i < len(content) {
16141  		c := content[i]
16142  		if c == ' ' || c == '\n' || c == '\r' || c == '\t' {
16143  			i++
16144  		} else {
16145  			break
16146  		}
16147  	}
16148  	if i >= len(content) {
16149  		return false
16150  	}
16151  	open := content[i]
16152  	if open != '{' && open != '[' {
16153  		return false
16154  	}
16155  	// Scan for matching close bracket (up to 2000 chars).
16156  	limit := len(content)
16157  	if limit > 2000 {
16158  		limit = 2000
16159  	}
16160  	depth := 0
16161  	inStr := false
16162  	j := i
16163  	for j < limit {
16164  		c := content[j]
16165  		if inStr {
16166  			if c == '\\' && j+1 < limit {
16167  				j += 2
16168  				continue
16169  			}
16170  			if c == '"' {
16171  				inStr = false
16172  			}
16173  		} else {
16174  			if c == '"' {
16175  				inStr = true
16176  			} else if c == '{' || c == '[' {
16177  				depth++
16178  			} else if c == '}' || c == ']' {
16179  				depth--
16180  				if depth == 0 {
16181  					return j-i+1 > 200
16182  				}
16183  			}
16184  		}
16185  		j++
16186  	}
16187  	return false
16188  }
16189  
16190  func defaultBlossomURL() (s string) {
16191  	local := localRelayURL()
16192  	var base string
16193  	if len(local) >= 6 && local[:6] == "wss://" {
16194  		base = "https://" | local[6:]
16195  	} else if len(local) >= 5 && local[:5] == "ws://" {
16196  		base = "http://" | local[5:]
16197  	} else {
16198  		return ""
16199  	}
16200  	return base | "/blossom"
16201  }
16202  
16203  // parseJSONStringArray parses a JSON array of strings like ["a","b","c"].
16204  func parseJSONStringArray(s string) (ss []string) {
16205  	var out []string
16206  	i := 0
16207  	for i < len(s) && s[i] != '[' {
16208  		i++
16209  	}
16210  	if i >= len(s) {
16211  		return nil
16212  	}
16213  	i++ // skip '['
16214  	for i < len(s) {
16215  		for i < len(s) && (s[i] == ' ' || s[i] == ',' || s[i] == '\n') {
16216  			i++
16217  		}
16218  		if i >= len(s) || s[i] == ']' {
16219  			break
16220  		}
16221  		if s[i] != '"' {
16222  			break
16223  		}
16224  		i++
16225  		start := i
16226  		for i < len(s) && s[i] != '"' {
16227  			if s[i] == '\\' {
16228  				i++
16229  			}
16230  			i++
16231  		}
16232  		if i <= len(s) {
16233  			out = mxutil.Ensure(out, 1)
16234  			out = push(out, s[start:i])
16235  		}
16236  		if i < len(s) {
16237  			i++ // skip closing '"'
16238  		}
16239  	}
16240  	return out
16241  }
16242  
16243  func jsonEsc(s string) (sv string) {
16244  	s = strReplace(s, "\\", "\\\\")
16245  	s = strReplace(s, "\"", "\\\"")
16246  	s = strReplace(s, "\n", "\\n")
16247  	s = strReplace(s, "\r", "\\r")
16248  	s = strReplace(s, "\t", "\\t")
16249  	return s
16250  }
16251  
16252  // strIndex finds substring in string. Returns -1 if not found.
16253  func strIndex(s, sub string) (n int32) {
16254  	sl := len(sub)
16255  	for i := 0; i <= len(s)-sl; i++ {
16256  		if s[i:i+sl] == sub {
16257  			return i
16258  		}
16259  	}
16260  	return -1
16261  }
16262  
16263  // --- Helpers ---
16264  
16265  // normalizeURL strips trailing slashes and lowercases the scheme+host.
16266  func normalizeURL(u string) (s string) {
16267  	for len(u) > 0 && u[len(u)-1] == '/' {
16268  		u = u[:len(u)-1]
16269  	}
16270  	// Lowercase scheme and host (before first / after ://).
16271  	if len(u) > 6 && u[:6] == "wss://" {
16272  		rest := u[6:]
16273  		slash := strIndex(rest, "/")
16274  		if slash < 0 {
16275  			return u[:6] | toLower(rest)
16276  		}
16277  		return u[:6] | toLower(rest[:slash]) | rest[slash:]
16278  	}
16279  	if len(u) > 5 && u[:5] == "ws://" {
16280  		rest := u[5:]
16281  		slash := strIndex(rest, "/")
16282  		if slash < 0 {
16283  			return u[:5] | toLower(rest)
16284  		}
16285  		return u[:5] | toLower(rest[:slash]) | rest[slash:]
16286  	}
16287  	return u
16288  }
16289  
16290  func toLower(s string) (sv string) {
16291  	b := []byte{:len(s)}
16292  	for i := 0; i < len(s); i++ {
16293  		c := s[i]
16294  		if c >= 'A' && c <= 'Z' {
16295  			c += 32
16296  		}
16297  		b[i] = c
16298  	}
16299  	return string(b)
16300  }
16301  
16302  func showQRModal(npubStr string) {
16303  	svg := qrSVG(npubStr, 5)
16304  	if svg == "" {
16305  		return
16306  	}
16307  	scrim := dom.CreateElement("div")
16308  	dom.SetStyle(scrim, "position", "fixed")
16309  	dom.SetStyle(scrim, "inset", "0")
16310  	dom.SetStyle(scrim, "background", "rgba(0,0,0,0.6)")
16311  	dom.SetStyle(scrim, "display", "flex")
16312  	dom.SetStyle(scrim, "alignItems", "center")
16313  	dom.SetStyle(scrim, "justifyContent", "center")
16314  	dom.SetStyle(scrim, "zIndex", "9999")
16315  	dom.SetStyle(scrim, "cursor", "pointer")
16316  	dom.AddEventListener(scrim, "click", dom.RegisterCallback(func() {
16317  		dom.RemoveChild(dom.Body(), scrim)
16318  	}))
16319  
16320  	card := dom.CreateElement("div")
16321  	dom.SetStyle(card, "background", "white")
16322  	dom.SetStyle(card, "borderRadius", "16px")
16323  	dom.SetStyle(card, "padding", "24px")
16324  	dom.SetStyle(card, "display", "flex")
16325  	dom.SetStyle(card, "flexDirection", "column")
16326  	dom.SetStyle(card, "alignItems", "center")
16327  	dom.SetStyle(card, "cursor", "default")
16328  	dom.SetAttribute(card, "onclick", "event.stopPropagation()")
16329  	dom.SetInnerHTML(card, svg)
16330  
16331  	dom.AppendChild(scrim, card)
16332  	dom.AppendChild(dom.Body(), scrim)
16333  }
16334  
16335  func clearChildren(el dom.Element) {
16336  	dom.ReleaseChildren(el)
16337  	dom.SetInnerHTML(el, "")
16338  }
16339  
16340  func removeChildrenByPK(parent dom.Element, pk string) {
16341  	if parent == 0 {
16342  		return
16343  	}
16344  	var toRemove []dom.Element
16345  	child := dom.FirstElementChild(parent)
16346  	for child != 0 {
16347  		if dom.GetProperty(child, "musiquayPK") == pk {
16348  			toRemove = mxutil.Ensure(toRemove, 1)
16349  			toRemove = push(toRemove, child)
16350  		}
16351  		child = dom.NextSibling(child)
16352  	}
16353  	for _, el := range toRemove {
16354  		dom.RemoveChild(parent, el)
16355  	}
16356  }
16357  
16358  func itoa(n int32) (s string) {
16359  	if n == 0 {
16360  		return "0"
16361  	}
16362  	neg := false
16363  	if n < 0 {
16364  		neg = true
16365  		n = -n
16366  	}
16367  	var b [20]byte
16368  	i := len(b)
16369  	for n > 0 {
16370  		i--
16371  		b[i] = byte('0' + n%10)
16372  		n /= 10
16373  	}
16374  	if neg {
16375  		i--
16376  		b[i] = '-'
16377  	}
16378  	return string(b[i:])
16379  }
16380