package main import ( "runtime" "crypto/sha256" "git.smesh.lol/moxie/pkg/mxutil" "git.smesh.lol/musiquay/pkg/protocol" "git.smesh.lol/musiquay/web/common/accum" "git.smesh.lol/musiquay/web/common/helpers" "git.smesh.lol/musiquay/web/common/jsbridge/dom" "git.smesh.lol/musiquay/web/common/jsbridge/feed" "git.smesh.lol/musiquay/web/common/jsbridge/localstorage" "git.smesh.lol/musiquay/web/common/jsbridge/markdown" "git.smesh.lol/musiquay/web/common/jsbridge/notif" "git.smesh.lol/musiquay/web/common/jsbridge/profile" "git.smesh.lol/musiquay/web/common/jsbridge/relayproxy" "git.smesh.lol/musiquay/web/common/jsbridge/signer" "git.smesh.lol/musiquay/web/common/protocolwire" "git.smesh.lol/nostr/pkg/core" ) const ( lsKeyPubkey = "musiquay-pubkey" lsKeyTheme = "musiquay-theme" lsKeyRelayPolicy = "musiquay-relay-policy" lsKeyRelayBlocklist = "musiquay-relay-blocklist" lsKeyRelayList = "musiquay-relays" lsKeyBlossomServer = "musiquay-blossom-server" lsKeyInvidiousURL = "musiquay-invidious-url" lsKeyCampionURL = "musiquay-campion-url" lsKeyDevMode = "musiquay-dev-mode" lsKeyRelayRoles = "musiquay-relay-roles" ) // Relay policy bitfield - persisted as decimal int in lsKeyRelayPolicy. // All flags default to 0 (no policy enforced). Enforcement happens in later // steps; step 3 only reads and writes the state. const ( policyRequireAuth = 1 << 0 // require NIP-42 support policyRequireProtect = 1 << 1 // require NIP-70 support policyBlockPayment = 1 << 2 // block relays with limitation.payment_required policyBlockRestricted = 1 << 3 // block relays with limitation.restricted_writes ) const activityLogMax = 500 type logEntry struct { ts int64 action string // "boot", "publish", "reply", "react", "rebroadcast", "subscribe", "relay-ok", "relay-err" summary string // one-line title detail string // multi-line detail, can be empty } var ( // Profile data from kind 0. // DOM refs that need updating after creation. signerBtnEl dom.Element // Search page state. notifRowCBs accum.Ints // callback IDs registered by current notif rows; released on rebuild // App root - content goes here, not body (snackbar stays outside). // Messaging UI state. // Relay tracking - parallel slices, grown dynamically. // Relay policy (loaded once at startup from localStorage). // Author name/pic view cache - small, populated from P_RESOLVED and IDB bootstrap. // Profile Worker owns canonical data; Shell keeps this for sync DOM reads. // Shell-local fetch dedup hints - not authoritative (Profile Worker owns dedup). // Empty map reads return false so all guards fire, Profile Worker handles actual dedup. // Two-gen rotation: when current exceeds 10000, old is dropped and current becomes old. // retryRound names the retry-batch subscriptions; authorSubPK maps an // outstanding profile subscription id to its pubkey. Both are used by // retryMissingProfiles, reached from the EOSE handler. // User card hover popover. // QR modal. // Profile page tab state. // Own follow/mute lists - Shell's working copy for sync filter and action ops. // Profile Worker is authoritative; Shell is updated via P_FOLLOWS/P_MUTES pushes. // Profile content/follows/mutes/relays for viewed profiles (small view caches). // Profile page cache - keeps rendered profile DOMs alive. // History/routing. // Embedded nostr: entity resolution. // @-mention autocomplete. // Thread view state. // Reply preview state. // Action button state (reactions/reposts). // Compose editor state. // Emoji picker state. emojiBackdrop dom.Element emojiAnchor dom.Element // Cached events for repost/quote content. // Global ready flag - set after showApp finishes all map init + DOM setup. // Activity log state. activityLog is a fixed ring of activityLogMax entries // presized on first use, newest at slot (activityLogNext-1) mod activityLogMax. // Each entry is a single action with a short summary and optional multi-line // detail. // Live-update subscriber callbacks keyed by event ID. The relay info modal // registers a callback for its evID on open; the SEEN_ON handler fires the // registered callback (if any) after updating eventRelays. Modal must // unregister on close. Single registration per evID - second registration // replaces the first. // Feed controls. refreshBtn dom.Element ) // ── Rotate-map cache bounds ─────────────────────────────────────────────────── // Each cache group has two generations: current and old. When current exceeds // its threshold, old is dropped and current becomes old. Lookups check both. const ( rotateEventMax = 2000 rotateAuthorMax = 500 rotateEmbedMax = 200 rotateReplyMax = 500 rotateProfileMax = 10 notifEventsMax = 500 rotateFetchedMax = 10000 ) var ( // Event data "old" generation. // Author name/pic "old" generation - two-gen rotation so GC can collect stale entries. // Embed "old" generation. // Reply "old" generation. ) // rotateEventCaches swaps the event data caches and releases dropped DOM handles. func rotateEventCaches() { var toRelease []int32 for id, el := range appSt.noteElementsOld { if _, ok := appSt.noteElements[id]; !ok { toRelease = mxutil.Ensure(toRelease, 1) toRelease = push(toRelease, int32(el)) } } if len(toRelease) > 0 { dom.ReleaseAll(toRelease) } appSt.eventCacheOld = appSt.eventCache appSt.eventCache = map[string]*nostr.Event{} appSt.seenEventsOld = appSt.seenEvents appSt.seenEvents = map[string]bool{} appSt.noteElementsOld = appSt.noteElements appSt.noteElements = map[string]dom.Element{} appSt.noteReactionMapOld = appSt.noteReactionMap appSt.noteReactionMap = map[string]map[string]map[string]bool{} appSt.noteRepostCountsOld = appSt.noteRepostCounts appSt.noteRepostCounts = map[string]map[string]bool{} appSt.noteZapSeenOld = appSt.noteZapSeen appSt.noteZapSeen = map[string]map[string]bool{} appSt.actionFetchedIDsOld = appSt.actionFetchedIDs appSt.actionFetchedIDs = map[string]bool{} appSt.eventRelaysOld = appSt.eventRelays appSt.eventRelays = map[string][]string{} appSt.noteReactionEls = map[string]dom.Element{} appSt.noteRepostEls = map[string]dom.Element{} appSt.noteZapEls = map[string]dom.Element{} appSt.eventCacheCount = 0 } // rotateAuthorCaches rotates the Shell name/pic view cache (two-gen for GC). // Profile Worker owns the canonical data rotation; this only covers Shell's small cache. func rotateAuthorCaches() { appSt.authorNamesOld = appSt.authorNames appSt.authorNames = map[string]string{} appSt.authorPicsOld = appSt.authorPics appSt.authorPics = map[string]string{} appSt.authorCacheCount = 0 } // rotateEmbedCaches swaps the embed caches. func rotateEmbedCaches() { appSt.embedCallbacksOld = appSt.embedCallbacks appSt.embedCallbacks = map[string][]string{} appSt.embedRequestedOld = appSt.embedRequested appSt.embedRequested = map[string]bool{} appSt.embedSubIDsOld = appSt.embedSubIDs appSt.embedSubIDs = map[string][]string{} appSt.embedCoordCBsOld = appSt.embedCoordCBs appSt.embedCoordCBs = map[string][]string{} appSt.embedRelayHintsOld = appSt.embedRelayHints appSt.embedRelayHints = map[string][]string{} appSt.embedCacheCount = 0 } // lookupFetchedK0 checks current and old generation. func lookupFetchedK0(pk string) (ok bool) { if appSt.fetchedK0[pk] { return true } return appSt.fetchedK0Old[pk] } // setFetchedK0 sets the entry and rotates if threshold exceeded. func setFetchedK0(pk string, v bool) { appSt.fetchedK0[pk] = v if v && len(appSt.fetchedK0) > rotateFetchedMax { appSt.fetchedK0Old = appSt.fetchedK0 appSt.fetchedK0 = map[string]bool{} } } // lookupFetchedK10k checks current and old generation. func lookupFetchedK10k(pk string) (ok bool) { if appSt.fetchedK10k[pk] { return true } return appSt.fetchedK10kOld[pk] } // setFetchedK10k sets the entry and rotates if threshold exceeded. func setFetchedK10k(pk string, v bool) { appSt.fetchedK10k[pk] = v if v && len(appSt.fetchedK10k) > rotateFetchedMax { appSt.fetchedK10kOld = appSt.fetchedK10k appSt.fetchedK10k = map[string]bool{} } } // rotateReplyCaches swaps the reply caches. func rotateReplyCaches() { appSt.replyCacheOld = appSt.replyCache appSt.replyCache = map[string]string{} appSt.replyAvatarCacheOld = appSt.replyAvatarCache appSt.replyAvatarCache = map[string]string{} appSt.replyLineCacheOld = appSt.replyLineCache appSt.replyLineCache = map[string]string{} appSt.replyAuthorMapOld = appSt.replyAuthorMap appSt.replyAuthorMap = map[string]string{} appSt.replyNeedNameOld = appSt.replyNeedName appSt.replyNeedName = map[string][]dom.Element{} appSt.replyHintsOld = appSt.replyHints appSt.replyHints = map[string]string{} appSt.replyCount = 0 } // lookupAuthorName returns the cached display name, checking both generations. func lookupAuthorName(pk string) (s string, ok3 bool) { if namew2, ok2 := appSt.authorNames[pk]; ok2 { return namew2, true } namew1, ok := appSt.authorNamesOld[pk] return namew1, ok } // lookupAuthorPic returns the cached avatar URL, checking both generations. func lookupAuthorPic(pk string) (s string, ok3 bool) { if picw2, ok2 := appSt.authorPics[pk]; ok2 { return picw2, true } picw1, ok := appSt.authorPicsOld[pk] return picw1, ok } // lookupEvent returns the cached event, checking both generations. func lookupEvent(id string) (p *nostr.Event, ok3 bool) { if evw2, ok2 := appSt.eventCache[id]; ok2 { return evw2, true } evw1, ok := appSt.eventCacheOld[id] return evw1, ok } // setEvent stores an event in the cache and rotates if the threshold is reached. func setEvent(id string, ev *nostr.Event) { if _, already := appSt.eventCache[id]; !already { appSt.eventCacheCount++ if appSt.eventCacheCount > rotateEventMax { rotateEventCaches() } } appSt.eventCache[id] = ev } // setAuthorName caches a display name, rotating if the view cache exceeds the threshold. func setAuthorName(pk, name string) { if _, already := appSt.authorNames[pk]; !already { appSt.authorCacheCount++ if appSt.authorCacheCount > rotateAuthorMax { rotateAuthorCaches() } } appSt.authorNames[pk] = name } // setAuthorPic caches an avatar URL. func setAuthorPic(pk, pic string) { appSt.authorPics[pk] = pic } const orlyRelay = "wss://relay.orly.dev" func isLocalDev() (ok bool) { h := dom.Hostname() return h == "localhost" || (len(h) > 4 && h[:4] == "127.") } func localRelayURL() (s string) { h := dom.Hostname() p := dom.Port() if p == "" || p == "443" || p == "80" { return "wss://" | h } return "ws://" | h | ":" | p } // appState is the page's mutable state. Package globals are immutable // outside initialization, so the DOM handles, caches, queues and flags the // app mutates live in one self-mutating type reached through appSt. type appState struct { aboutLogoEl dom.Element aboutPage dom.Element actionFetchQueue []string // IDs awaiting batch fetch actionFetchTimer int32 actionFetchedIDs map[string]bool // event IDs already queued for kind 6/7 actionFetchedIDsOld map[string]bool activePage string activeProfileNoteSub string activityLog []*logEntry activityLogNext int32 // total entries ever logged; slot = next mod activityLogMax appReady bool authorCacheCount int32 authorNames map[string]string // pubkey hex -> display name authorNamesOld map[string]string authorPics map[string]string // pubkey hex -> avatar URL authorPicsOld map[string]string authorSubPK map[string]string avatarEl dom.Element blossomServerURL string bootInProgress bool // true while boot is running; controls logBtn highlight bottomBar dom.Element broadcastBtnEl dom.Element broadcastRow dom.Element campionURL string composePage dom.Element composePreview dom.Element composeRelayChecks map[string]bool // relay URL -> selected for publish composeRelayPopEl dom.Element composeRelayPopOpen bool composeTextarea dom.Element contactTs int64 // kind 3 latest timestamp contentArea dom.Element // scroll container currentLang string defaultRelays []string devMode bool donSubs map[string]func(string) // subID -> callback(plain JSON or "") editorComposer dom.Element // inline composer div appended to note editorDrafts map[string]string // "reply:evID" or "quote:evID" -> draft text editorMode string // "reply" or "quote" editorNoteEl dom.Element // the note element containing the composer editorOpen bool editorPreviewEl dom.Element editorPreviewing bool editorTargetEv *nostr.Event editorTextarea dom.Element embedCacheCount int32 embedCallbacks map[string][]string // hex event ID -> DOM element IDs awaiting fill embedCallbacksOld map[string][]string embedCoordCBs map[string][]string // "kind:pubkey:d" -> DOM element IDs awaiting fill embedCoordCBsOld map[string][]string embedCoordSubIDs map[string]string // sub ID -> coord key embedRelayHints map[string][]string // hex event ID -> relay hints from nevent TLV embedRelayHintsOld map[string][]string embedRequested map[string]bool // hex event IDs already sent to PROXY embedRequestedOld map[string]bool embedSubIDs map[string][]string // sub ID -> list of hex event IDs requested in that sub embedSubIDsOld map[string][]string emojiAutoEl dom.Element // emoji autocomplete popup emojiAutoOpen bool emojiAutoStart int32 // position of the opening ':' emojiAutoTA dom.Element // textarea the emoji popup is attached to emojiByName map[string]string emojiOpen bool emojiOverlay dom.Element emojiTargetEv *nostr.Event encKeyHex string // hex symmetric key, sent to SW eventCache map[string]*nostr.Event eventCacheCount int32 eventCacheOld map[string]*nostr.Event eventCount int32 eventRelays map[string][]string // event ID -> relay URLs that delivered it eventRelaysOld map[string][]string feedBuffer []*nostr.Event // new events buffered after initial load feedContainer dom.Element feedDeferTimer int32 // timer for deferred feed subscription feedEmptyStreak int32 // consecutive empty EOSE responses feedExhausted bool // true when EOSE returns no new events feedHasNew bool // true when feedBuffer is non-empty (pulse indicator) feedInitialLoad bool // true until ~3s after first EOSE on "feed" feedInitialLoadTimer int32 // delays flipping feedInitialLoad=false so late events from slower relays still render directly feedLastSubTs int64 // timestamp of last actual subscription feedLoader dom.Element feedLoading bool // true while fetching older events feedMode string // "follows", "relays", or relay URL feedMoreGot int32 feedMoreTimer int32 feedPage dom.Element feedPopoverEl dom.Element feedPopoverOpen bool feedSelectEl dom.Element feedSubTimer int32 // throttle timer for subscribeFeed feedSubscribed bool // true once subscribeFeed has been called fetchedK0 map[string]bool fetchedK0Old map[string]bool fetchedK10k map[string]bool fetchedK10kOld map[string]bool followSet map[string]bool // O(1) lookup derived from myFollows gfExp [512]byte gfLog [256]byte gfTables bool homePage dom.Element // Musiquay base: empty landing page; the kind-1 forum is parked hoverCardDismiss int32 hoverCardEl dom.Element hoverCardHover bool hoverCardPK string hoverCardParent dom.Element hoverCardTimer int32 idbLoaded bool inboxTs int64 // kind 10050 latest timestamp invidiousURL string isDark bool loadMoreBtn dom.Element logBtn dom.Element logListEl dom.Element logPage dom.Element logoSVGCache string marmotInited bool mentionAtPos int32 // character position of the @ in the textarea mentionItems []mentionMatch mentionPopup dom.Element // the popup container mentionQuery string // current query text (after @) mentionSelIdx int32 // highlighted index in popup mentionTA dom.Element // textarea the popup is attached to msgComposeInput dom.Element // textarea msgCurrentConvoID string // peer pubkey (DM) or groupIDHex (group) of open thread, "" when on list msgCurrentKind string // "dm" or "group" msgListContainer dom.Element // conversation list view msgPage dom.Element msgThreadContainer dom.Element // thread view (header + messages + compose) msgThreadMessages dom.Element // scrollable message area msgView string // "list" or "thread" muteSet map[string]bool // O(1) lookup derived from myMutes muteTs int64 // kind 10000 latest timestamp myFollows []string myMutes []string nameEl dom.Element navPop bool // true during popstate - suppresses pushState noteElements map[string]dom.Element // event ID -> note DOM element noteElementsOld map[string]dom.Element noteReactionEls map[string]dom.Element // event ID -> reaction display row noteReactionMap map[string]map[string]map[string]bool // event ID -> emoji -> pubkey set noteReactionMapOld map[string]map[string]map[string]bool noteRepostCounts map[string]map[string]bool // event ID -> pubkey set noteRepostCountsOld map[string]map[string]bool noteRepostEls map[string]dom.Element // event ID -> repost counter span noteZapEls map[string]dom.Element // event ID -> zap total chip noteZapSeen map[string]map[string]bool // event ID -> set of 9735 receipt IDs counted noteZapSeenOld map[string]map[string]bool noteZapTotals map[string]int64 // event ID -> total msats received notifBarGroup dom.Element notifBuilt bool // true after first render notifContainer dom.Element notifDot dom.Element notifEmptyEl dom.Element notifEmptyStrk int32 notifEvents []*nostr.Event notifExhausted bool notifFilter string // "all", "mentions", "reactions", "zaps" notifInitLoad bool notifLoadMore dom.Element notifLoading bool notifMarkBtn dom.Element notifMissing []string // refIDs not in eventCache, fetched after EOSE notifMoreGot int32 notifMoreTimer int32 notifPage dom.Element notifReadTs int64 // watermark: events with CreatedAt > this are unread notifRefTimer int32 notifSeen map[string]bool notifSelectEl dom.Element notifUnread bool nwcConnected map[string]bool // connID → open nwcConns []NwcConn nwcPendingQ map[string][]func(ws.Conn, bool) // queued while connecting nwcSockets map[string]ws.Conn // connID → ws handle nwcSubs map[string]*nwcPending // subID → pending oldestFeedTs int64 // oldest created_at in feed - for infinite scroll oldestNotifTs int64 pageTitleEl dom.Element pendingDeletes map[string]string // delete event ID -> target event ID pendingK0Name string // nickname to publish as kind 0 after login pendingNotes map[string][]dom.Element // pubkey hex -> author header divs awaiting profile pendingNotifAvatars map[string][]dom.Element // pubkey hex -> notif avatar imgs awaiting profile pendingTsEls []dom.Element // timestamp divs awaiting relay confirmation pendingZapRows map[string][]zapRowPending // authorPK -> rows awaiting lud16 popoverEl dom.Element popoverOpen bool popoverSummary dom.Element profileContentCache map[string]string profileFollowsCache map[string][]string profileMutesCache map[string][]string profileName string profileNotesSeen map[string]bool profilePage dom.Element profilePic string profileRelaysCache map[string][]string profileTab string profileTabBtns map[string]dom.Element profileTabContent dom.Element profileTs int64 profileViewPK string profileWrapOrder []string // LRU order (oldest first) profileWrapTabCont map[string]dom.Element // pk -> tab content ref profileWrapTabSel map[string]string // pk -> selected tab profileWrapTabs map[string]map[string]dom.Element // pk -> tab button map profileWrappers map[string]dom.Element // pk -> wrapper div pubhex string refreshPulseOn bool refreshPulseTimer int32 refreshSpinAngle int32 refreshSpinTimer int32 refreshSpinning bool relayBadgeContainers []dom.Element // capability warning badges, rewritten on NIP-11 fetch relayBlocklist []string // exact-URL blocklist (persisted; kind 10006 publication is future work) relayCaps []relayCap // NIP-11 snapshot (populated async after addRelay) relayCapsTs []int64 // unix seconds when each relayCaps entry was fetched relayDots []dom.Element relayInfoEl dom.Element // relay info page wrapper (removed on nav) relayLabels []dom.Element relayListTs int64 // kind 10002 latest timestamp relayPolicyFlags int32 // bitfield of policy* constants relayReadBtns []dom.Element // per-relay "r" toggle button relayRole []string // "both", "read", "write" per relay relayRows []dom.Element // popover row container - used by removeRelayByURL relayURLs []string relayUserPick []bool // true = from user's kind 10002 relayWriteBtns []dom.Element // per-relay "w" toggle button replyAuthorMap map[string]string // event ID -> author pubkey hex replyAuthorMapOld map[string]string replyAvatarCache map[string]string // event ID -> avatar URL of parent author replyAvatarCacheOld map[string]string replyCache map[string]string // event ID -> "name: first line" replyCacheOld map[string]string replyCount int32 replyHints map[string]string // event ID -> relay hint URL from e-tag replyHintsOld map[string]string replyLineCache map[string]string // event ID -> raw first line (no name) replyLineCacheOld map[string]string replyNeedName map[string][]dom.Element // event ID -> preview divs needing name update replyNeedNameOld map[string][]dom.Element replyPending map[string][]dom.Element // event ID -> preview divs awaiting fetch replyQueue []string // event IDs to batch-fetch replySweepTimer int32 // debounced sweep for unfound replies replyTimer int32 retryRound int32 root dom.Element routerInited bool // guard against duplicate listener registration savedScrollTop string // feed scroll position before thread open searchBarActive bool searchBtnGlobal dom.Element // captured for deactivation from anywhere searchContainer dom.Element searchCurrentQ string searchFilterBarEl dom.Element // sticky bar above results searchFollowsOnly bool searchInputEl dom.Element searchNSFW bool searchPage dom.Element searchRendered int32 // how many filtered results are currently rendered searchResults []*nostr.Event searchReturnPage string // page to restore when user manually closes search searchSeen map[string]bool searchSortDesc bool // true = newest first (default) searchSubmitBtn dom.Element seenEvents map[string]bool // event ID -> already rendered seenEventsOld map[string]bool seenOnSubs map[string]func() settingsPage dom.Element settingsTs int64 // kind 30078 latest timestamp sidebarCompose dom.Element sidebarFeed dom.Element sidebarMsg dom.Element sidebarNotif dom.Element sidebarSettings dom.Element signerContent dom.Element signerOpen bool signerPanel dom.Element signerStatus string // "none", "locked", "unlocked" statusEl dom.Element themeBtn dom.Element threadActiveSubs []string // sub IDs to close when opening a new thread threadContainer dom.Element threadEvents map[string]*nostr.Event threadFetchedIDs map[string]bool // IDs already queried for child replies threadFocusID string threadFollowUp int32 // follow-up round counter (0 = initial, caps at 3) threadLastRendered int32 // event count at last render - skip if unchanged threadOpen bool threadPage dom.Element threadPushedState bool // true if we pushed history for this thread threadRelays []string // relay URLs used for this thread's fetches threadRenderTimer int32 threadReturnPage string // page to return to when closing thread threadRootID string topBackBtn dom.Element topBarLeft dom.Element topBarRight dom.Element userBtn dom.Element viewProfileRow dom.Element walletActiveID string walletBalanceSats int64 // -1 = not fetched, -2 = fetch failed walletBtnEl dom.Element walletContent dom.Element walletEnc string // "nip04" | "nip44", auto-picked on first get_info walletInfoLoaded bool walletLud16 string // cached from get_info walletPanel dom.Element walletPanelOpen bool walletPayResultEl dom.Element walletShowAddForm bool ytTitleCache map[string]string actionSubCounter int32 donNextSub int32 embedCounter int32 nwcNextSubN int32 profileSubCounter int32 threadGen int32 // generation counter - reject events from old threads threadSubCount int32 } var appSt *appState func initState() { if appSt != nil { return } // appSt and everything it holds live as long as the page: build them in // the root arena, not in this call's frame arena which dies on return. runtime.SovereignSetArena(runtime.RootArena()) appSt = &appState{} appSt.currentLang = "en" appSt.defaultRelays = []string{ orlyRelay, "wss://nostr.wine", } appSt.donSubs = map[string]func(string){} appSt.nwcConnected = map[string]bool{} appSt.nwcPendingQ = map[string][]func(ws.Conn, bool){} appSt.nwcSockets = map[string]ws.Conn{} appSt.nwcSubs = map[string]*nwcPending{} appSt.walletBalanceSats = -1 appSt.ytTitleCache = map[string]string{} runtime.SovereignRestoreArena(runtime.RootArena()) } func initAppGlobals() { dnsBlacklist = []string{ "wss://relay.nostr.band", "wss://relay.nostr.band/", } discoveryRelays = []string{ "wss://purplepag.es", "wss://relay.damus.io", "wss://nos.lol", } migratedRelayTypes = map[string]bool{ "REQ": true, "CLOSE": true, "EVENT": true, "PUBLISH_TO": true, "PROXY": true, "SET_PUBKEY": true, "SET_WRITE_RELAYS": true, "ENC_KEY": true, } swTypes = map[string]bool{ "activate-update": true, } emojiList = []emojiEntry{ {"\xf0\x9f\x98\x80", "grinning_face"}, {"\xf0\x9f\x98\x80", "grinning"}, {"\xf0\x9f\x98\x83", "grinning_face_with_big_eyes"}, {"\xf0\x9f\x98\x83", "smiley"}, {"\xf0\x9f\x98\x84", "grinning_face_with_smiling_eyes"}, {"\xf0\x9f\x98\x84", "smile"}, {"\xf0\x9f\x98\x81", "beaming_face_with_smiling_eyes"}, {"\xf0\x9f\x98\x81", "grin"}, {"\xf0\x9f\x98\x86", "grinning_squinting_face"}, {"\xf0\x9f\x98\x86", "laughing"}, {"\xf0\x9f\x98\x85", "grinning_face_with_sweat"}, {"\xf0\x9f\x98\x85", "sweat_smile"}, {"\xf0\x9f\xa4\xa3", "rolling_on_the_floor_laughing"}, {"\xf0\x9f\xa4\xa3", "rofl"}, {"\xf0\x9f\x98\x82", "face_with_tears_of_joy"}, {"\xf0\x9f\x98\x82", "joy"}, {"\xf0\x9f\x99\x82", "slightly_smiling_face"}, {"\xf0\x9f\x99\x82", "slight_smile"}, {"\xf0\x9f\x99\x83", "upside-down_face"}, {"\xf0\x9f\xab\xa0", "melting_face"}, {"\xf0\x9f\x98\x89", "winking_face"}, {"\xf0\x9f\x98\x89", "wink"}, {"\xf0\x9f\x98\x8a", "smiling_face_with_smiling_eyes"}, {"\xf0\x9f\x98\x8a", "blush"}, {"\xf0\x9f\x98\x87", "smiling_face_with_halo"}, {"\xf0\x9f\x98\x87", "innocent"}, {"\xf0\x9f\xa5\xb0", "smiling_face_with_hearts"}, {"\xf0\x9f\x98\x8d", "smiling_face_with_heart-eyes"}, {"\xf0\x9f\x98\x8d", "heart_eyes"}, {"\xf0\x9f\xa4\xa9", "star-struck"}, {"\xf0\x9f\x98\x98", "face_blowing_a_kiss"}, {"\xf0\x9f\x98\x98", "kissing_heart"}, {"\xf0\x9f\x98\x97", "kissing_face"}, {"\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"}, {"\xf0\x9f\xa5\xb2", "smiling_face_with_tear"}, {"\xf0\x9f\x98\x8b", "face_savoring_food"}, {"\xf0\x9f\x98\x8b", "yum"}, {"\xf0\x9f\x98\x9b", "face_with_tongue"}, {"\xf0\x9f\x98\x9b", "stuck_out_tongue"}, {"\xf0\x9f\x98\x9c", "winking_face_with_tongue"}, {"\xf0\x9f\x98\x9c", "stuck_out_tongue_winking_eye"}, {"\xf0\x9f\xa4\xaa", "zany_face"}, {"\xf0\x9f\xa4\xaa", "zany"}, {"\xf0\x9f\x98\x9d", "squinting_face_with_tongue"}, {"\xf0\x9f\x98\x9d", "stuck_out_tongue_closed_eyes"}, {"\xf0\x9f\xa4\x91", "money-mouth_face"}, {"\xf0\x9f\xa4\x91", "money_mouth"}, {"\xf0\x9f\xa4\x97", "smiling_face_with_open_hands"}, {"\xf0\x9f\xa4\x97", "hugs"}, {"\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"}, {"\xf0\x9f\xab\xa3", "face_with_peeking_eye"}, {"\xf0\x9f\xa4\xab", "shushing_face"}, {"\xf0\x9f\xa4\xab", "shush"}, {"\xf0\x9f\xa4\x94", "thinking_face"}, {"\xf0\x9f\xa4\x94", "thinking"}, {"\xf0\x9f\xab\xa1", "saluting_face"}, {"\xf0\x9f\xa4\x90", "zipper-mouth_face"}, {"\xf0\x9f\xa4\x90", "zipper_mouth"}, {"\xf0\x9f\xa4\xa8", "face_with_raised_eyebrow"}, {"\xf0\x9f\xa4\xa8", "raised_eyebrow"}, {"\xf0\x9f\x98\x90", "neutral_face"}, {"\xf0\x9f\x98\x90", "neutral"}, {"\xf0\x9f\x98\x91", "expressionless_face"}, {"\xf0\x9f\x98\x91", "expressionless"}, {"\xf0\x9f\x98\xb6", "face_without_mouth"}, {"\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"}, {"\xf0\x9f\x98\x8f", "smirking_face"}, {"\xf0\x9f\x98\x8f", "smirk"}, {"\xf0\x9f\x98\x92", "unamused_face"}, {"\xf0\x9f\x98\x92", "unamused"}, {"\xf0\x9f\x99\x84", "face_with_rolling_eyes"}, {"\xf0\x9f\x99\x84", "roll_eyes"}, {"\xf0\x9f\x98\xac", "grimacing_face"}, {"\xf0\x9f\x98\xac", "grimacing"}, {"\xf0\x9f\x98\xae\xe2\x80\x8d\xf0\x9f\x92\xa8", "face_exhaling"}, {"\xf0\x9f\xa4\xa5", "lying_face"}, {"\xf0\x9f\xa4\xa5", "lying"}, {"\xf0\x9f\xab\xa8", "shaking_face"}, {"\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"}, {"\xf0\x9f\x98\x8c", "relieved"}, {"\xf0\x9f\x98\x94", "pensive_face"}, {"\xf0\x9f\x98\x94", "pensive"}, {"\xf0\x9f\x98\xaa", "sleepy_face"}, {"\xf0\x9f\x98\xaa", "sleepy"}, {"\xf0\x9f\xa4\xa4", "drooling_face"}, {"\xf0\x9f\x98\xb4", "sleeping_face"}, {"\xf0\x9f\x98\xb4", "sleeping"}, {"\xf0\x9f\xab\xa9", "face_with_bags_under_eyes"}, {"\xf0\x9f\x98\xb7", "face_with_medical_mask"}, {"\xf0\x9f\x98\xb7", "mask"}, {"\xf0\x9f\xa4\x92", "face_with_thermometer"}, {"\xf0\x9f\xa4\x92", "thermometer_face"}, {"\xf0\x9f\xa4\x95", "face_with_head-bandage"}, {"\xf0\x9f\xa4\xa2", "nauseated_face"}, {"\xf0\x9f\xa4\xa2", "nauseated"}, {"\xf0\x9f\xa4\xae", "face_vomiting"}, {"\xf0\x9f\xa4\xae", "vomiting"}, {"\xf0\x9f\xa4\xa7", "sneezing_face"}, {"\xf0\x9f\xa4\xa7", "sneezing"}, {"\xf0\x9f\xa5\xb5", "hot_face"}, {"\xf0\x9f\xa5\xb6", "cold_face"}, {"\xf0\x9f\xa5\xb4", "woozy_face"}, {"\xf0\x9f\x98\xb5", "face_with_crossed-out_eyes"}, {"\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"}, {"\xf0\x9f\xa4\xa0", "cowboy"}, {"\xf0\x9f\xa5\xb3", "partying_face"}, {"\xf0\x9f\xa5\xb8", "disguised_face"}, {"\xf0\x9f\x98\x8e", "smiling_face_with_sunglasses"}, {"\xf0\x9f\x98\x8e", "sunglasses"}, {"\xf0\x9f\xa4\x93", "nerd_face"}, {"\xf0\x9f\xa4\x93", "nerd"}, {"\xf0\x9f\xa7\x90", "face_with_monocle"}, {"\xf0\x9f\x98\x95", "confused_face"}, {"\xf0\x9f\x98\x95", "confused"}, {"\xf0\x9f\xab\xa4", "face_with_diagonal_mouth"}, {"\xf0\x9f\x98\x9f", "worried_face"}, {"\xf0\x9f\x98\x9f", "worried"}, {"\xf0\x9f\x99\x81", "slightly_frowning_face"}, {"\xe2\x98\xb9\xef\xb8\x8f", "frowning_face"}, {"\xf0\x9f\x98\xae", "face_with_open_mouth"}, {"\xf0\x9f\x98\xae", "open_mouth"}, {"\xf0\x9f\x98\xaf", "hushed_face"}, {"\xf0\x9f\x98\xaf", "hushed"}, {"\xf0\x9f\x98\xb2", "astonished_face"}, {"\xf0\x9f\x98\xb2", "astonished"}, {"\xf0\x9f\x98\xb3", "flushed_face"}, {"\xf0\x9f\x98\xb3", "flushed"}, {"\xf0\x9f\xa5\xba", "pleading_face"}, {"\xf0\x9f\xa5\xba", "pleading"}, {"\xf0\x9f\xa5\xb9", "face_holding_back_tears"}, {"\xf0\x9f\x98\xa6", "frowning_face_with_open_mouth"}, {"\xf0\x9f\x98\xa7", "anguished_face"}, {"\xf0\x9f\x98\xa8", "fearful_face"}, {"\xf0\x9f\x98\xa8", "fearful"}, {"\xf0\x9f\x98\xb0", "anxious_face_with_sweat"}, {"\xf0\x9f\x98\xb0", "cold_sweat"}, {"\xf0\x9f\x98\xa5", "sad_but_relieved_face"}, {"\xf0\x9f\x98\xa2", "crying_face"}, {"\xf0\x9f\x98\xa2", "cry"}, {"\xf0\x9f\x98\xad", "loudly_crying_face"}, {"\xf0\x9f\x98\xad", "sob"}, {"\xf0\x9f\x98\xb1", "face_screaming_in_fear"}, {"\xf0\x9f\x98\xb1", "scream"}, {"\xf0\x9f\x98\x96", "confounded_face"}, {"\xf0\x9f\x98\xa3", "persevering_face"}, {"\xf0\x9f\x98\xa3", "persevere"}, {"\xf0\x9f\x98\x9e", "disappointed_face"}, {"\xf0\x9f\x98\x9e", "disappointed"}, {"\xf0\x9f\x98\x93", "downcast_face_with_sweat"}, {"\xf0\x9f\x98\xa9", "weary_face"}, {"\xf0\x9f\x98\xa9", "weary"}, {"\xf0\x9f\x98\xab", "tired_face"}, {"\xf0\x9f\x98\xab", "tired"}, {"\xf0\x9f\xa5\xb1", "yawning_face"}, {"\xf0\x9f\xa5\xb1", "yawning"}, {"\xf0\x9f\x98\xa4", "face_with_steam_from_nose"}, {"\xf0\x9f\x98\xa4", "triumph"}, {"\xf0\x9f\x98\xa1", "enraged_face"}, {"\xf0\x9f\x98\xa1", "rage"}, {"\xf0\x9f\x98\xa0", "angry_face"}, {"\xf0\x9f\x98\xa0", "angry"}, {"\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"}, {"\xf0\x9f\x92\x80", "skull"}, {"\xe2\x98\xa0\xef\xb8\x8f", "skull_and_crossbones"}, {"\xe2\x98\xa0\xef\xb8\x8f", "skull_crossbones"}, {"\xf0\x9f\x92\xa9", "pile_of_poo"}, {"\xf0\x9f\x92\xa9", "poop"}, {"\xf0\x9f\xa4\xa1", "clown_face"}, {"\xf0\x9f\xa4\xa1", "clown"}, {"\xf0\x9f\x91\xb9", "ogre"}, {"\xf0\x9f\x91\xba", "goblin"}, {"\xf0\x9f\x91\xbb", "ghost"}, {"\xf0\x9f\x91\xbd", "alien"}, {"\xf0\x9f\x91\xbe", "alien_monster"}, {"\xf0\x9f\xa4\x96", "robot"}, {"\xf0\x9f\x98\xba", "grinning_cat"}, {"\xf0\x9f\x98\xb8", "grinning_cat_with_smiling_eyes"}, {"\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"}, {"\xf0\x9f\x98\xbd", "kissing_cat"}, {"\xf0\x9f\x99\x80", "weary_cat"}, {"\xf0\x9f\x98\xbf", "crying_cat"}, {"\xf0\x9f\x98\xbe", "pouting_cat"}, {"\xf0\x9f\x99\x88", "see-no-evil_monkey"}, {"\xf0\x9f\x99\x89", "hear-no-evil_monkey"}, {"\xf0\x9f\x99\x8a", "speak-no-evil_monkey"}, {"\xf0\x9f\x92\x8c", "love_letter"}, {"\xf0\x9f\x92\x98", "heart_with_arrow"}, {"\xf0\x9f\x92\x9d", "heart_with_ribbon"}, {"\xf0\x9f\x92\x96", "sparkling_heart"}, {"\xf0\x9f\x92\x97", "growing_heart"}, {"\xf0\x9f\x92\x93", "beating_heart"}, {"\xf0\x9f\x92\x9e", "revolving_hearts"}, {"\xf0\x9f\x92\x95", "two_hearts"}, {"\xf0\x9f\x92\x9f", "heart_decoration"}, {"\xe2\x9d\xa3\xef\xb8\x8f", "heart_exclamation"}, {"\xf0\x9f\x92\x94", "broken_heart"}, {"\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"}, {"\xe2\x9d\xa4\xef\xb8\x8f", "heart"}, {"\xf0\x9f\xa9\xb7", "pink_heart"}, {"\xf0\x9f\xa7\xa1", "orange_heart"}, {"\xf0\x9f\x92\x9b", "yellow_heart"}, {"\xf0\x9f\x92\x9a", "green_heart"}, {"\xf0\x9f\x92\x99", "blue_heart"}, {"\xf0\x9f\xa9\xb5", "light_blue_heart"}, {"\xf0\x9f\x92\x9c", "purple_heart"}, {"\xf0\x9f\xa4\x8e", "brown_heart"}, {"\xf0\x9f\x96\xa4", "black_heart"}, {"\xf0\x9f\xa9\xb6", "grey_heart"}, {"\xf0\x9f\xa4\x8d", "white_heart"}, {"\xf0\x9f\x92\x8b", "kiss_mark"}, {"\xf0\x9f\x92\xaf", "hundred_points"}, {"\xf0\x9f\x92\xaf", "hundred"}, {"\xf0\x9f\x92\xa2", "anger_symbol"}, {"\xf0\x9f\x92\xa5", "collision"}, {"\xf0\x9f\x92\xab", "dizzy"}, {"\xf0\x9f\x92\xa6", "sweat_droplets"}, {"\xf0\x9f\x92\xa8", "dashing_away"}, {"\xf0\x9f\x95\xb3\xef\xb8\x8f", "hole"}, {"\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"}, {"\xf0\x9f\x97\xaf\xef\xb8\x8f", "right_anger_bubble"}, {"\xf0\x9f\x92\xad", "thought_balloon"}, {"\xf0\x9f\x92\xa4", "zzz"}, {"\xf0\x9f\x91\x8b", "waving_hand"}, {"\xf0\x9f\x91\x8b", "wave_hand"}, {"\xf0\x9f\xa4\x9a", "raised_back_of_hand"}, {"\xf0\x9f\x96\x90\xef\xb8\x8f", "hand_with_fingers_splayed"}, {"\xe2\x9c\x8b", "raised_hand"}, {"\xf0\x9f\x96\x96", "vulcan_salute"}, {"\xf0\x9f\xab\xb1", "rightwards_hand"}, {"\xf0\x9f\xab\xb2", "leftwards_hand"}, {"\xf0\x9f\xab\xb3", "palm_down_hand"}, {"\xf0\x9f\xab\xb4", "palm_up_hand"}, {"\xf0\x9f\xab\xb7", "leftwards_pushing_hand"}, {"\xf0\x9f\xab\xb8", "rightwards_pushing_hand"}, {"\xf0\x9f\x91\x8c", "ok_hand"}, {"\xf0\x9f\xa4\x8c", "pinched_fingers"}, {"\xf0\x9f\xa4\x8f", "pinching_hand"}, {"\xe2\x9c\x8c\xef\xb8\x8f", "victory_hand"}, {"\xe2\x9c\x8c\xef\xb8\x8f", "v"}, {"\xf0\x9f\xa4\x9e", "crossed_fingers"}, {"\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"}, {"\xf0\x9f\xa4\x99", "call_me_hand"}, {"\xf0\x9f\xa4\x99", "call_me"}, {"\xf0\x9f\x91\x88", "backhand_index_pointing_left"}, {"\xf0\x9f\x91\x88", "point_left"}, {"\xf0\x9f\x91\x89", "backhand_index_pointing_right"}, {"\xf0\x9f\x91\x89", "point_right"}, {"\xf0\x9f\x91\x86", "backhand_index_pointing_up"}, {"\xf0\x9f\x91\x86", "point_up"}, {"\xf0\x9f\x96\x95", "middle_finger"}, {"\xf0\x9f\x91\x87", "backhand_index_pointing_down"}, {"\xf0\x9f\x91\x87", "point_down"}, {"\xe2\x98\x9d\xef\xb8\x8f", "index_pointing_up"}, {"\xe2\x98\x9d\xef\xb8\x8f", "point_up_2"}, {"\xf0\x9f\xab\xb5", "index_pointing_at_the_viewer"}, {"\xf0\x9f\x91\x8d", "thumbs_up"}, {"\xf0\x9f\x91\x8d", "thumbsup"}, {"\xf0\x9f\x91\x8e", "thumbs_down"}, {"\xf0\x9f\x91\x8e", "thumbsdown"}, {"\xe2\x9c\x8a", "raised_fist"}, {"\xe2\x9c\x8a", "fist"}, {"\xf0\x9f\x91\x8a", "oncoming_fist"}, {"\xf0\x9f\x91\x8a", "punch"}, {"\xf0\x9f\xa4\x9b", "left-facing_fist"}, {"\xf0\x9f\xa4\x9c", "right-facing_fist"}, {"\xf0\x9f\x91\x8f", "clapping_hands"}, {"\xf0\x9f\x91\x8f", "clap"}, {"\xf0\x9f\x99\x8c", "raising_hands"}, {"\xf0\x9f\x99\x8c", "raised_hands"}, {"\xf0\x9f\xab\xb6", "heart_hands"}, {"\xf0\x9f\x91\x90", "open_hands"}, {"\xf0\x9f\xa4\xb2", "palms_up_together"}, {"\xf0\x9f\xa4\xb2", "palms_up"}, {"\xf0\x9f\xa4\x9d", "handshake"}, {"\xf0\x9f\x99\x8f", "folded_hands"}, {"\xf0\x9f\x99\x8f", "pray"}, {"\xe2\x9c\x8d\xef\xb8\x8f", "writing_hand"}, {"\xf0\x9f\x92\x85", "nail_polish"}, {"\xf0\x9f\x92\x85", "nail_care"}, {"\xf0\x9f\xa4\xb3", "selfie"}, {"\xf0\x9f\x92\xaa", "flexed_biceps"}, {"\xf0\x9f\x92\xaa", "muscle"}, {"\xf0\x9f\xa6\xbe", "mechanical_arm"}, {"\xf0\x9f\xa6\xbf", "mechanical_leg"}, {"\xf0\x9f\xa6\xb5", "leg"}, {"\xf0\x9f\xa6\xb6", "foot"}, {"\xf0\x9f\x91\x82", "ear"}, {"\xf0\x9f\xa6\xbb", "ear_with_hearing_aid"}, {"\xf0\x9f\x91\x83", "nose"}, {"\xf0\x9f\xa7\xa0", "brain"}, {"\xf0\x9f\xab\x80", "anatomical_heart"}, {"\xf0\x9f\xab\x81", "lungs"}, {"\xf0\x9f\xa6\xb7", "tooth"}, {"\xf0\x9f\xa6\xb4", "bone"}, {"\xf0\x9f\x91\x80", "eyes"}, {"\xf0\x9f\x91\x81\xef\xb8\x8f", "eye"}, {"\xf0\x9f\x91\x85", "tongue"}, {"\xf0\x9f\x91\x84", "mouth"}, {"\xf0\x9f\xab\xa6", "biting_lip"}, {"\xf0\x9f\x91\xb6", "baby"}, {"\xf0\x9f\xa7\x92", "child"}, {"\xf0\x9f\x91\xa6", "boy"}, {"\xf0\x9f\x91\xa7", "girl"}, {"\xf0\x9f\xa7\x91", "person"}, {"\xf0\x9f\x91\xb1", "person_blond_hair"}, {"\xf0\x9f\x91\xa8", "man"}, {"\xf0\x9f\xa7\x94", "person_beard"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\xf0\x9f\x91\xb4", "old_man"}, {"\xf0\x9f\x91\xb5", "old_woman"}, {"\xf0\x9f\x99\x8d", "person_frowning"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\xf0\x9f\xab\x85", "person_with_crown"}, {"\xf0\x9f\xa4\xb4", "prince"}, {"\xf0\x9f\x91\xb8", "princess"}, {"\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"}, {"\xf0\x9f\x91\xb2", "person_with_skullcap"}, {"\xf0\x9f\xa7\x95", "woman_with_headscarf"}, {"\xf0\x9f\xa4\xb5", "person_in_tuxedo"}, {"\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"}, {"\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"}, {"\xf0\x9f\xab\x83", "pregnant_man"}, {"\xf0\x9f\xab\x84", "pregnant_person"}, {"\xf0\x9f\xa4\xb1", "breast-feeding"}, {"\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"}, {"\xf0\x9f\x91\xbc", "baby_angel"}, {"\xf0\x9f\x8e\x85", "santa_claus"}, {"\xf0\x9f\xa4\xb6", "mrs_claus"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\xf0\x9f\x8f\x87", "horse_racing"}, {"\xe2\x9b\xb7\xef\xb8\x8f", "skier"}, {"\xf0\x9f\x8f\x82", "snowboarder"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\xf0\x9f\x91\xad", "women_holding_hands"}, {"\xf0\x9f\x91\xab", "woman_and_man_holding_hands"}, {"\xf0\x9f\x91\xac", "men_holding_hands"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\xf0\x9f\x97\xa3\xef\xb8\x8f", "speaking_head"}, {"\xf0\x9f\x91\xa4", "bust_in_silhouette"}, {"\xf0\x9f\x91\xa5", "busts_in_silhouette"}, {"\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"}, {"\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"}, {"\xf0\x9f\x91\xa3", "footprints"}, {"\xf0\x9f\xab\x86", "fingerprint"}, {"\xf0\x9f\x90\xb5", "monkey_face"}, {"\xf0\x9f\x90\x92", "monkey"}, {"\xf0\x9f\xa6\x8d", "gorilla"}, {"\xf0\x9f\xa6\xa7", "orangutan"}, {"\xf0\x9f\x90\xb6", "dog_face"}, {"\xf0\x9f\x90\xb6", "dog"}, {"\xf0\x9f\xa6\xae", "guide_dog"}, {"\xf0\x9f\x90\x95\xe2\x80\x8d\xf0\x9f\xa6\xba", "service_dog"}, {"\xf0\x9f\x90\xa9", "poodle"}, {"\xf0\x9f\x90\xba", "wolf"}, {"\xf0\x9f\xa6\x8a", "fox"}, {"\xf0\x9f\xa6\x9d", "raccoon"}, {"\xf0\x9f\x90\xb1", "cat_face"}, {"\xf0\x9f\x90\xb1", "cat"}, {"\xf0\x9f\x90\x88\xe2\x80\x8d\xe2\xac\x9b", "black_cat"}, {"\xf0\x9f\xa6\x81", "lion"}, {"\xf0\x9f\x90\xaf", "tiger_face"}, {"\xf0\x9f\x90\x85", "tiger"}, {"\xf0\x9f\x90\x86", "leopard"}, {"\xf0\x9f\x90\xb4", "horse_face"}, {"\xf0\x9f\xab\x8e", "moose"}, {"\xf0\x9f\xab\x8f", "donkey"}, {"\xf0\x9f\x90\x8e", "horse"}, {"\xf0\x9f\xa6\x84", "unicorn"}, {"\xf0\x9f\xa6\x93", "zebra"}, {"\xf0\x9f\xa6\x8c", "deer"}, {"\xf0\x9f\xa6\xac", "bison"}, {"\xf0\x9f\x90\xae", "cow_face"}, {"\xf0\x9f\x90\x82", "ox"}, {"\xf0\x9f\x90\x83", "water_buffalo"}, {"\xf0\x9f\x90\x84", "cow"}, {"\xf0\x9f\x90\xb7", "pig_face"}, {"\xf0\x9f\x90\x96", "pig"}, {"\xf0\x9f\x90\x97", "boar"}, {"\xf0\x9f\x90\xbd", "pig_nose"}, {"\xf0\x9f\x90\x8f", "ram"}, {"\xf0\x9f\x90\x91", "ewe"}, {"\xf0\x9f\x90\x90", "goat"}, {"\xf0\x9f\x90\xaa", "camel"}, {"\xf0\x9f\x90\xab", "two-hump_camel"}, {"\xf0\x9f\xa6\x99", "llama"}, {"\xf0\x9f\xa6\x92", "giraffe"}, {"\xf0\x9f\x90\x98", "elephant"}, {"\xf0\x9f\xa6\xa3", "mammoth"}, {"\xf0\x9f\xa6\x8f", "rhinoceros"}, {"\xf0\x9f\xa6\x9b", "hippopotamus"}, {"\xf0\x9f\x90\xad", "mouse_face"}, {"\xf0\x9f\x90\x81", "mouse"}, {"\xf0\x9f\x90\x80", "rat"}, {"\xf0\x9f\x90\xb9", "hamster"}, {"\xf0\x9f\x90\xb0", "rabbit_face"}, {"\xf0\x9f\x90\x87", "rabbit"}, {"\xf0\x9f\x90\xbf\xef\xb8\x8f", "chipmunk"}, {"\xf0\x9f\xa6\xab", "beaver"}, {"\xf0\x9f\xa6\x94", "hedgehog"}, {"\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"}, {"\xf0\x9f\x90\xa8", "koala"}, {"\xf0\x9f\x90\xbc", "panda"}, {"\xf0\x9f\xa6\xa5", "sloth"}, {"\xf0\x9f\xa6\xa6", "otter"}, {"\xf0\x9f\xa6\xa8", "skunk"}, {"\xf0\x9f\xa6\x98", "kangaroo"}, {"\xf0\x9f\xa6\xa1", "badger"}, {"\xf0\x9f\x90\xbe", "paw_prints"}, {"\xf0\x9f\xa6\x83", "turkey"}, {"\xf0\x9f\x90\x94", "chicken"}, {"\xf0\x9f\x90\x93", "rooster"}, {"\xf0\x9f\x90\xa3", "hatching_chick"}, {"\xf0\x9f\x90\xa4", "baby_chick"}, {"\xf0\x9f\x90\xa5", "front-facing_baby_chick"}, {"\xf0\x9f\x90\xa6", "bird"}, {"\xf0\x9f\x90\xa7", "penguin"}, {"\xf0\x9f\x95\x8a\xef\xb8\x8f", "dove"}, {"\xf0\x9f\xa6\x85", "eagle"}, {"\xf0\x9f\xa6\x86", "duck"}, {"\xf0\x9f\xa6\xa2", "swan"}, {"\xf0\x9f\xa6\x89", "owl"}, {"\xf0\x9f\xa6\xa4", "dodo"}, {"\xf0\x9f\xaa\xb6", "feather"}, {"\xf0\x9f\xa6\xa9", "flamingo"}, {"\xf0\x9f\xa6\x9a", "peacock"}, {"\xf0\x9f\xa6\x9c", "parrot"}, {"\xf0\x9f\xaa\xbd", "wing"}, {"\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"}, {"\xf0\x9f\x90\xb8", "frog"}, {"\xf0\x9f\x90\x8a", "crocodile"}, {"\xf0\x9f\x90\xa2", "turtle"}, {"\xf0\x9f\xa6\x8e", "lizard"}, {"\xf0\x9f\x90\x8d", "snake"}, {"\xf0\x9f\x90\xb2", "dragon_face"}, {"\xf0\x9f\x90\x89", "dragon"}, {"\xf0\x9f\xa6\x95", "sauropod"}, {"\xf0\x9f\xa6\x96", "t-rex"}, {"\xf0\x9f\x90\xb3", "spouting_whale"}, {"\xf0\x9f\x90\x8b", "whale"}, {"\xf0\x9f\x90\xac", "dolphin"}, {"\xf0\x9f\xa6\xad", "seal"}, {"\xf0\x9f\x90\x9f", "fish"}, {"\xf0\x9f\x90\xa0", "tropical_fish"}, {"\xf0\x9f\x90\xa1", "blowfish"}, {"\xf0\x9f\xa6\x88", "shark"}, {"\xf0\x9f\x90\x99", "octopus"}, {"\xf0\x9f\x90\x9a", "spiral_shell"}, {"\xf0\x9f\xaa\xb8", "coral"}, {"\xf0\x9f\xaa\xbc", "jellyfish"}, {"\xf0\x9f\xa6\x80", "crab"}, {"\xf0\x9f\xa6\x9e", "lobster"}, {"\xf0\x9f\xa6\x90", "shrimp"}, {"\xf0\x9f\xa6\x91", "squid"}, {"\xf0\x9f\xa6\xaa", "oyster"}, {"\xf0\x9f\x90\x8c", "snail"}, {"\xf0\x9f\xa6\x8b", "butterfly"}, {"\xf0\x9f\x90\x9b", "bug"}, {"\xf0\x9f\x90\x9c", "ant"}, {"\xf0\x9f\x90\x9d", "honeybee"}, {"\xf0\x9f\x90\x9d", "bee"}, {"\xf0\x9f\xaa\xb2", "beetle"}, {"\xf0\x9f\x90\x9e", "lady_beetle"}, {"\xf0\x9f\xa6\x97", "cricket"}, {"\xf0\x9f\xaa\xb3", "cockroach"}, {"\xf0\x9f\x95\xb7\xef\xb8\x8f", "spider"}, {"\xf0\x9f\x95\xb8\xef\xb8\x8f", "spider_web"}, {"\xf0\x9f\xa6\x82", "scorpion"}, {"\xf0\x9f\xa6\x9f", "mosquito"}, {"\xf0\x9f\xaa\xb0", "fly"}, {"\xf0\x9f\xaa\xb1", "worm"}, {"\xf0\x9f\xa6\xa0", "microbe"}, {"\xf0\x9f\x92\x90", "bouquet"}, {"\xf0\x9f\x8c\xb8", "cherry_blossom"}, {"\xf0\x9f\x92\xae", "white_flower"}, {"\xf0\x9f\xaa\xb7", "lotus"}, {"\xf0\x9f\x8f\xb5\xef\xb8\x8f", "rosette"}, {"\xf0\x9f\x8c\xb9", "rose"}, {"\xf0\x9f\xa5\x80", "wilted_flower"}, {"\xf0\x9f\x8c\xba", "hibiscus"}, {"\xf0\x9f\x8c\xbb", "sunflower"}, {"\xf0\x9f\x8c\xbc", "blossom"}, {"\xf0\x9f\x8c\xb7", "tulip"}, {"\xf0\x9f\xaa\xbb", "hyacinth"}, {"\xf0\x9f\x8c\xb1", "seedling"}, {"\xf0\x9f\xaa\xb4", "potted_plant"}, {"\xf0\x9f\x8c\xb2", "evergreen_tree"}, {"\xf0\x9f\x8c\xb3", "deciduous_tree"}, {"\xf0\x9f\x8c\xb4", "palm_tree"}, {"\xf0\x9f\x8c\xb5", "cactus"}, {"\xf0\x9f\x8c\xbe", "sheaf_of_rice"}, {"\xf0\x9f\x8c\xbf", "herb"}, {"\xe2\x98\x98\xef\xb8\x8f", "shamrock"}, {"\xf0\x9f\x8d\x80", "four_leaf_clover"}, {"\xf0\x9f\x8d\x81", "maple_leaf"}, {"\xf0\x9f\x8d\x82", "fallen_leaf"}, {"\xf0\x9f\x8d\x83", "leaf_fluttering_in_wind"}, {"\xf0\x9f\xaa\xb9", "empty_nest"}, {"\xf0\x9f\xaa\xba", "nest_with_eggs"}, {"\xf0\x9f\x8d\x84", "mushroom"}, {"\xf0\x9f\xaa\xbe", "leafless_tree"}, {"\xf0\x9f\x8d\x87", "grapes"}, {"\xf0\x9f\x8d\x88", "melon"}, {"\xf0\x9f\x8d\x89", "watermelon"}, {"\xf0\x9f\x8d\x8a", "tangerine"}, {"\xf0\x9f\x8d\x8b", "lemon"}, {"\xf0\x9f\x8d\x8b\xe2\x80\x8d\xf0\x9f\x9f\xa9", "lime"}, {"\xf0\x9f\x8d\x8c", "banana"}, {"\xf0\x9f\x8d\x8d", "pineapple"}, {"\xf0\x9f\xa5\xad", "mango"}, {"\xf0\x9f\x8d\x8e", "red_apple"}, {"\xf0\x9f\x8d\x8f", "green_apple"}, {"\xf0\x9f\x8d\x90", "pear"}, {"\xf0\x9f\x8d\x91", "peach"}, {"\xf0\x9f\x8d\x92", "cherries"}, {"\xf0\x9f\x8d\x93", "strawberry"}, {"\xf0\x9f\xab\x90", "blueberries"}, {"\xf0\x9f\xa5\x9d", "kiwi_fruit"}, {"\xf0\x9f\x8d\x85", "tomato"}, {"\xf0\x9f\xab\x92", "olive"}, {"\xf0\x9f\xa5\xa5", "coconut"}, {"\xf0\x9f\xa5\x91", "avocado"}, {"\xf0\x9f\x8d\x86", "eggplant"}, {"\xf0\x9f\xa5\x94", "potato"}, {"\xf0\x9f\xa5\x95", "carrot"}, {"\xf0\x9f\x8c\xbd", "ear_of_corn"}, {"\xf0\x9f\x8c\xb6\xef\xb8\x8f", "hot_pepper"}, {"\xf0\x9f\xab\x91", "bell_pepper"}, {"\xf0\x9f\xa5\x92", "cucumber"}, {"\xf0\x9f\xa5\xac", "leafy_green"}, {"\xf0\x9f\xa5\xa6", "broccoli"}, {"\xf0\x9f\xa7\x84", "garlic"}, {"\xf0\x9f\xa7\x85", "onion"}, {"\xf0\x9f\xa5\x9c", "peanuts"}, {"\xf0\x9f\xab\x98", "beans"}, {"\xf0\x9f\x8c\xb0", "chestnut"}, {"\xf0\x9f\xab\x9a", "ginger_root"}, {"\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"}, {"\xf0\x9f\x8d\x9e", "bread"}, {"\xf0\x9f\xa5\x90", "croissant"}, {"\xf0\x9f\xa5\x96", "baguette_bread"}, {"\xf0\x9f\xab\x93", "flatbread"}, {"\xf0\x9f\xa5\xa8", "pretzel"}, {"\xf0\x9f\xa5\xaf", "bagel"}, {"\xf0\x9f\xa5\x9e", "pancakes"}, {"\xf0\x9f\xa7\x87", "waffle"}, {"\xf0\x9f\xa7\x80", "cheese_wedge"}, {"\xf0\x9f\x8d\x96", "meat_on_bone"}, {"\xf0\x9f\x8d\x97", "poultry_leg"}, {"\xf0\x9f\xa5\xa9", "cut_of_meat"}, {"\xf0\x9f\xa5\x93", "bacon"}, {"\xf0\x9f\x8d\x94", "hamburger"}, {"\xf0\x9f\x8d\x9f", "french_fries"}, {"\xf0\x9f\x8d\x95", "pizza"}, {"\xf0\x9f\x8c\xad", "hot_dog"}, {"\xf0\x9f\xa5\xaa", "sandwich"}, {"\xf0\x9f\x8c\xae", "taco"}, {"\xf0\x9f\x8c\xaf", "burrito"}, {"\xf0\x9f\xab\x94", "tamale"}, {"\xf0\x9f\xa5\x99", "stuffed_flatbread"}, {"\xf0\x9f\xa7\x86", "falafel"}, {"\xf0\x9f\xa5\x9a", "egg"}, {"\xf0\x9f\x8d\xb3", "cooking"}, {"\xf0\x9f\xa5\x98", "shallow_pan_of_food"}, {"\xf0\x9f\x8d\xb2", "pot_of_food"}, {"\xf0\x9f\xab\x95", "fondue"}, {"\xf0\x9f\xa5\xa3", "bowl_with_spoon"}, {"\xf0\x9f\xa5\x97", "green_salad"}, {"\xf0\x9f\x8d\xbf", "popcorn"}, {"\xf0\x9f\xa7\x88", "butter"}, {"\xf0\x9f\xa7\x82", "salt"}, {"\xf0\x9f\xa5\xab", "canned_food"}, {"\xf0\x9f\x8d\xb1", "bento_box"}, {"\xf0\x9f\x8d\x98", "rice_cracker"}, {"\xf0\x9f\x8d\x99", "rice_ball"}, {"\xf0\x9f\x8d\x9a", "cooked_rice"}, {"\xf0\x9f\x8d\x9b", "curry_rice"}, {"\xf0\x9f\x8d\x9c", "steaming_bowl"}, {"\xf0\x9f\x8d\x9d", "spaghetti"}, {"\xf0\x9f\x8d\xa0", "roasted_sweet_potato"}, {"\xf0\x9f\x8d\xa2", "oden"}, {"\xf0\x9f\x8d\xa3", "sushi"}, {"\xf0\x9f\x8d\xa4", "fried_shrimp"}, {"\xf0\x9f\x8d\xa5", "fish_cake_with_swirl"}, {"\xf0\x9f\xa5\xae", "moon_cake"}, {"\xf0\x9f\x8d\xa1", "dango"}, {"\xf0\x9f\xa5\x9f", "dumpling"}, {"\xf0\x9f\xa5\xa0", "fortune_cookie"}, {"\xf0\x9f\xa5\xa1", "takeout_box"}, {"\xf0\x9f\x8d\xa6", "soft_ice_cream"}, {"\xf0\x9f\x8d\xa7", "shaved_ice"}, {"\xf0\x9f\x8d\xa8", "ice_cream"}, {"\xf0\x9f\x8d\xa9", "doughnut"}, {"\xf0\x9f\x8d\xaa", "cookie"}, {"\xf0\x9f\x8e\x82", "birthday_cake"}, {"\xf0\x9f\x8d\xb0", "shortcake"}, {"\xf0\x9f\xa7\x81", "cupcake"}, {"\xf0\x9f\xa5\xa7", "pie"}, {"\xf0\x9f\x8d\xab", "chocolate_bar"}, {"\xf0\x9f\x8d\xac", "candy"}, {"\xf0\x9f\x8d\xad", "lollipop"}, {"\xf0\x9f\x8d\xae", "custard"}, {"\xf0\x9f\x8d\xaf", "honey_pot"}, {"\xf0\x9f\x8d\xbc", "baby_bottle"}, {"\xf0\x9f\xa5\x9b", "glass_of_milk"}, {"\xe2\x98\x95", "hot_beverage"}, {"\xe2\x98\x95", "coffee"}, {"\xf0\x9f\xab\x96", "teapot"}, {"\xf0\x9f\x8d\xb5", "teacup_without_handle"}, {"\xf0\x9f\x8d\xb6", "sake"}, {"\xf0\x9f\x8d\xbe", "bottle_with_popping_cork"}, {"\xf0\x9f\x8d\xb7", "wine_glass"}, {"\xf0\x9f\x8d\xb8", "cocktail_glass"}, {"\xf0\x9f\x8d\xb9", "tropical_drink"}, {"\xf0\x9f\x8d\xba", "beer_mug"}, {"\xf0\x9f\x8d\xba", "beer"}, {"\xf0\x9f\x8d\xbb", "clinking_beer_mugs"}, {"\xf0\x9f\xa5\x82", "clinking_glasses"}, {"\xf0\x9f\xa5\x83", "tumbler_glass"}, {"\xf0\x9f\xab\x97", "pouring_liquid"}, {"\xf0\x9f\xa5\xa4", "cup_with_straw"}, {"\xf0\x9f\xa7\x8b", "bubble_tea"}, {"\xf0\x9f\xa7\x83", "beverage_box"}, {"\xf0\x9f\xa7\x89", "mate"}, {"\xf0\x9f\xa7\x8a", "ice"}, {"\xf0\x9f\xa5\xa2", "chopsticks"}, {"\xf0\x9f\x8d\xbd\xef\xb8\x8f", "fork_and_knife_with_plate"}, {"\xf0\x9f\x8d\xb4", "fork_and_knife"}, {"\xf0\x9f\xa5\x84", "spoon"}, {"\xf0\x9f\x94\xaa", "kitchen_knife"}, {"\xf0\x9f\xab\x99", "jar"}, {"\xf0\x9f\x8f\xba", "amphora"}, {"\xf0\x9f\x8c\x8d", "globe_showing_europe-africa"}, {"\xf0\x9f\x8c\x8d", "earth"}, {"\xf0\x9f\x8c\x8e", "globe_showing_americas"}, {"\xf0\x9f\x8c\x8f", "globe_showing_asia-australia"}, {"\xf0\x9f\x8c\x90", "globe_with_meridians"}, {"\xf0\x9f\x97\xba\xef\xb8\x8f", "world_map"}, {"\xf0\x9f\x97\xbe", "map_of_japan"}, {"\xf0\x9f\xa7\xad", "compass"}, {"\xf0\x9f\x8f\x94\xef\xb8\x8f", "snow-capped_mountain"}, {"\xe2\x9b\xb0\xef\xb8\x8f", "mountain"}, {"\xf0\x9f\x8c\x8b", "volcano"}, {"\xf0\x9f\x97\xbb", "mount_fuji"}, {"\xf0\x9f\x8f\x95\xef\xb8\x8f", "camping"}, {"\xf0\x9f\x8f\x96\xef\xb8\x8f", "beach_with_umbrella"}, {"\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"}, {"\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"}, {"\xf0\x9f\xa7\xb1", "brick"}, {"\xf0\x9f\xaa\xa8", "rock"}, {"\xf0\x9f\xaa\xb5", "wood"}, {"\xf0\x9f\x9b\x96", "hut"}, {"\xf0\x9f\x8f\x98\xef\xb8\x8f", "houses"}, {"\xf0\x9f\x8f\x9a\xef\xb8\x8f", "derelict_house"}, {"\xf0\x9f\x8f\xa0", "house"}, {"\xf0\x9f\x8f\xa1", "house_with_garden"}, {"\xf0\x9f\x8f\xa2", "office_building"}, {"\xf0\x9f\x8f\xa3", "japanese_post_office"}, {"\xf0\x9f\x8f\xa4", "post_office"}, {"\xf0\x9f\x8f\xa5", "hospital"}, {"\xf0\x9f\x8f\xa6", "bank"}, {"\xf0\x9f\x8f\xa8", "hotel"}, {"\xf0\x9f\x8f\xa9", "love_hotel"}, {"\xf0\x9f\x8f\xaa", "convenience_store"}, {"\xf0\x9f\x8f\xab", "school"}, {"\xf0\x9f\x8f\xac", "department_store"}, {"\xf0\x9f\x8f\xad", "factory"}, {"\xf0\x9f\x8f\xaf", "japanese_castle"}, {"\xf0\x9f\x8f\xb0", "castle"}, {"\xf0\x9f\x92\x92", "wedding"}, {"\xf0\x9f\x97\xbc", "tokyo_tower"}, {"\xf0\x9f\x97\xbd", "statue_of_liberty"}, {"\xe2\x9b\xaa", "church"}, {"\xf0\x9f\x95\x8c", "mosque"}, {"\xf0\x9f\x9b\x95", "hindu_temple"}, {"\xf0\x9f\x95\x8d", "synagogue"}, {"\xe2\x9b\xa9\xef\xb8\x8f", "shinto_shrine"}, {"\xf0\x9f\x95\x8b", "kaaba"}, {"\xe2\x9b\xb2", "fountain"}, {"\xe2\x9b\xba", "tent"}, {"\xf0\x9f\x8c\x81", "foggy"}, {"\xf0\x9f\x8c\x83", "night_with_stars"}, {"\xf0\x9f\x8f\x99\xef\xb8\x8f", "cityscape"}, {"\xf0\x9f\x8c\x84", "sunrise_over_mountains"}, {"\xf0\x9f\x8c\x85", "sunrise"}, {"\xf0\x9f\x8c\x86", "cityscape_at_dusk"}, {"\xf0\x9f\x8c\x87", "sunset"}, {"\xf0\x9f\x8c\x89", "bridge_at_night"}, {"\xe2\x99\xa8\xef\xb8\x8f", "hot_springs"}, {"\xf0\x9f\x8e\xa0", "carousel_horse"}, {"\xf0\x9f\x9b\x9d", "playground_slide"}, {"\xf0\x9f\x8e\xa1", "ferris_wheel"}, {"\xf0\x9f\x8e\xa2", "roller_coaster"}, {"\xf0\x9f\x92\x88", "barber_pole"}, {"\xf0\x9f\x8e\xaa", "circus_tent"}, {"\xf0\x9f\x9a\x82", "locomotive"}, {"\xf0\x9f\x9a\x83", "railway_car"}, {"\xf0\x9f\x9a\x84", "high-speed_train"}, {"\xf0\x9f\x9a\x85", "bullet_train"}, {"\xf0\x9f\x9a\x86", "train"}, {"\xf0\x9f\x9a\x87", "metro"}, {"\xf0\x9f\x9a\x88", "light_rail"}, {"\xf0\x9f\x9a\x89", "station"}, {"\xf0\x9f\x9a\x8a", "tram"}, {"\xf0\x9f\x9a\x9d", "monorail"}, {"\xf0\x9f\x9a\x9e", "mountain_railway"}, {"\xf0\x9f\x9a\x8b", "tram_car"}, {"\xf0\x9f\x9a\x8c", "bus"}, {"\xf0\x9f\x9a\x8d", "oncoming_bus"}, {"\xf0\x9f\x9a\x8e", "trolleybus"}, {"\xf0\x9f\x9a\x90", "minibus"}, {"\xf0\x9f\x9a\x91", "ambulance"}, {"\xf0\x9f\x9a\x92", "fire_engine"}, {"\xf0\x9f\x9a\x93", "police_car"}, {"\xf0\x9f\x9a\x94", "oncoming_police_car"}, {"\xf0\x9f\x9a\x95", "taxi"}, {"\xf0\x9f\x9a\x96", "oncoming_taxi"}, {"\xf0\x9f\x9a\x97", "automobile"}, {"\xf0\x9f\x9a\x98", "oncoming_automobile"}, {"\xf0\x9f\x9a\x99", "sport_utility_vehicle"}, {"\xf0\x9f\x9b\xbb", "pickup_truck"}, {"\xf0\x9f\x9a\x9a", "delivery_truck"}, {"\xf0\x9f\x9a\x9b", "articulated_lorry"}, {"\xf0\x9f\x9a\x9c", "tractor"}, {"\xf0\x9f\x8f\x8e\xef\xb8\x8f", "racing_car"}, {"\xf0\x9f\x8f\x8d\xef\xb8\x8f", "motorcycle"}, {"\xf0\x9f\x9b\xb5", "motor_scooter"}, {"\xf0\x9f\xa6\xbd", "manual_wheelchair"}, {"\xf0\x9f\xa6\xbc", "motorized_wheelchair"}, {"\xf0\x9f\x9b\xba", "auto_rickshaw"}, {"\xf0\x9f\x9a\xb2", "bicycle"}, {"\xf0\x9f\x9b\xb4", "kick_scooter"}, {"\xf0\x9f\x9b\xb9", "skateboard"}, {"\xf0\x9f\x9b\xbc", "roller_skate"}, {"\xf0\x9f\x9a\x8f", "bus_stop"}, {"\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"}, {"\xe2\x9b\xbd", "fuel_pump"}, {"\xf0\x9f\x9b\x9e", "wheel"}, {"\xf0\x9f\x9a\xa8", "police_car_light"}, {"\xf0\x9f\x9a\xa5", "horizontal_traffic_light"}, {"\xf0\x9f\x9a\xa6", "vertical_traffic_light"}, {"\xf0\x9f\x9b\x91", "stop_sign"}, {"\xf0\x9f\x9a\xa7", "construction"}, {"\xe2\x9a\x93", "anchor"}, {"\xf0\x9f\x9b\x9f", "ring_buoy"}, {"\xe2\x9b\xb5", "sailboat"}, {"\xf0\x9f\x9b\xb6", "canoe"}, {"\xf0\x9f\x9a\xa4", "speedboat"}, {"\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"}, {"\xf0\x9f\x9a\xa2", "ship"}, {"\xe2\x9c\x88\xef\xb8\x8f", "airplane"}, {"\xf0\x9f\x9b\xa9\xef\xb8\x8f", "small_airplane"}, {"\xf0\x9f\x9b\xab", "airplane_departure"}, {"\xf0\x9f\x9b\xac", "airplane_arrival"}, {"\xf0\x9f\xaa\x82", "parachute"}, {"\xf0\x9f\x92\xba", "seat"}, {"\xf0\x9f\x9a\x81", "helicopter"}, {"\xf0\x9f\x9a\x9f", "suspension_railway"}, {"\xf0\x9f\x9a\xa0", "mountain_cableway"}, {"\xf0\x9f\x9a\xa1", "aerial_tramway"}, {"\xf0\x9f\x9b\xb0\xef\xb8\x8f", "satellite"}, {"\xf0\x9f\x9a\x80", "rocket"}, {"\xf0\x9f\x9b\xb8", "flying_saucer"}, {"\xf0\x9f\x9b\xb8", "ufo"}, {"\xf0\x9f\x9b\x8e\xef\xb8\x8f", "bellhop_bell"}, {"\xf0\x9f\xa7\xb3", "luggage"}, {"\xe2\x8c\x9b", "hourglass_done"}, {"\xe2\x8f\xb3", "hourglass_not_done"}, {"\xe2\x8c\x9a", "watch"}, {"\xe2\x8f\xb0", "alarm_clock"}, {"\xe2\x8f\xb1\xef\xb8\x8f", "stopwatch"}, {"\xe2\x8f\xb2\xef\xb8\x8f", "timer_clock"}, {"\xf0\x9f\x95\xb0\xef\xb8\x8f", "mantelpiece_clock"}, {"\xf0\x9f\x95\x9b", "twelve_oclock"}, {"\xf0\x9f\x95\xa7", "twelve-thirty"}, {"\xf0\x9f\x95\x90", "one_oclock"}, {"\xf0\x9f\x95\x9c", "one-thirty"}, {"\xf0\x9f\x95\x91", "two_oclock"}, {"\xf0\x9f\x95\x9d", "two-thirty"}, {"\xf0\x9f\x95\x92", "three_oclock"}, {"\xf0\x9f\x95\x9e", "three-thirty"}, {"\xf0\x9f\x95\x93", "four_oclock"}, {"\xf0\x9f\x95\x9f", "four-thirty"}, {"\xf0\x9f\x95\x94", "five_oclock"}, {"\xf0\x9f\x95\xa0", "five-thirty"}, {"\xf0\x9f\x95\x95", "six_oclock"}, {"\xf0\x9f\x95\xa1", "six-thirty"}, {"\xf0\x9f\x95\x96", "seven_oclock"}, {"\xf0\x9f\x95\xa2", "seven-thirty"}, {"\xf0\x9f\x95\x97", "eight_oclock"}, {"\xf0\x9f\x95\xa3", "eight-thirty"}, {"\xf0\x9f\x95\x98", "nine_oclock"}, {"\xf0\x9f\x95\xa4", "nine-thirty"}, {"\xf0\x9f\x95\x99", "ten_oclock"}, {"\xf0\x9f\x95\xa5", "ten-thirty"}, {"\xf0\x9f\x95\x9a", "eleven_oclock"}, {"\xf0\x9f\x95\xa6", "eleven-thirty"}, {"\xf0\x9f\x8c\x91", "new_moon"}, {"\xf0\x9f\x8c\x92", "waxing_crescent_moon"}, {"\xf0\x9f\x8c\x93", "first_quarter_moon"}, {"\xf0\x9f\x8c\x94", "waxing_gibbous_moon"}, {"\xf0\x9f\x8c\x95", "full_moon"}, {"\xf0\x9f\x8c\x96", "waning_gibbous_moon"}, {"\xf0\x9f\x8c\x97", "last_quarter_moon"}, {"\xf0\x9f\x8c\x98", "waning_crescent_moon"}, {"\xf0\x9f\x8c\x99", "crescent_moon"}, {"\xf0\x9f\x8c\x9a", "new_moon_face"}, {"\xf0\x9f\x8c\x9b", "first_quarter_moon_face"}, {"\xf0\x9f\x8c\x9c", "last_quarter_moon_face"}, {"\xf0\x9f\x8c\xa1\xef\xb8\x8f", "thermometer"}, {"\xe2\x98\x80\xef\xb8\x8f", "sun"}, {"\xf0\x9f\x8c\x9d", "full_moon_face"}, {"\xf0\x9f\x8c\x9e", "sun_with_face"}, {"\xf0\x9f\xaa\x90", "ringed_planet"}, {"\xe2\xad\x90", "star"}, {"\xf0\x9f\x8c\x9f", "glowing_star"}, {"\xf0\x9f\x8c\x9f", "star2"}, {"\xf0\x9f\x8c\xa0", "shooting_star"}, {"\xf0\x9f\x8c\x8c", "milky_way"}, {"\xe2\x98\x81\xef\xb8\x8f", "cloud"}, {"\xe2\x9b\x85", "sun_behind_cloud"}, {"\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"}, {"\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"}, {"\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"}, {"\xf0\x9f\x8c\xac\xef\xb8\x8f", "wind_face"}, {"\xf0\x9f\x8c\x80", "cyclone"}, {"\xf0\x9f\x8c\x88", "rainbow"}, {"\xf0\x9f\x8c\x82", "closed_umbrella"}, {"\xe2\x98\x82\xef\xb8\x8f", "umbrella"}, {"\xe2\x98\x94", "umbrella_with_rain_drops"}, {"\xe2\x9b\xb1\xef\xb8\x8f", "umbrella_on_ground"}, {"\xe2\x9a\xa1", "high_voltage"}, {"\xe2\x9a\xa1", "zap"}, {"\xe2\x9d\x84\xef\xb8\x8f", "snowflake"}, {"\xe2\x98\x83\xef\xb8\x8f", "snowman"}, {"\xe2\x9b\x84", "snowman_without_snow"}, {"\xe2\x98\x84\xef\xb8\x8f", "comet"}, {"\xf0\x9f\x94\xa5", "fire"}, {"\xf0\x9f\x92\xa7", "droplet"}, {"\xf0\x9f\x8c\x8a", "water_wave"}, {"\xf0\x9f\x8c\x8a", "wave"}, {"\xf0\x9f\x8e\x83", "jack-o-lantern"}, {"\xf0\x9f\x8e\x84", "christmas_tree"}, {"\xf0\x9f\x8e\x86", "fireworks"}, {"\xf0\x9f\x8e\x87", "sparkler"}, {"\xf0\x9f\xa7\xa8", "firecracker"}, {"\xe2\x9c\xa8", "sparkles"}, {"\xf0\x9f\x8e\x88", "balloon"}, {"\xf0\x9f\x8e\x89", "party_popper"}, {"\xf0\x9f\x8e\x89", "tada"}, {"\xf0\x9f\x8e\x8a", "confetti_ball"}, {"\xf0\x9f\x8e\x8a", "confetti"}, {"\xf0\x9f\x8e\x8b", "tanabata_tree"}, {"\xf0\x9f\x8e\x8d", "pine_decoration"}, {"\xf0\x9f\x8e\x8e", "japanese_dolls"}, {"\xf0\x9f\x8e\x8f", "carp_streamer"}, {"\xf0\x9f\x8e\x90", "wind_chime"}, {"\xf0\x9f\x8e\x91", "moon_viewing_ceremony"}, {"\xf0\x9f\xa7\xa7", "red_envelope"}, {"\xf0\x9f\x8e\x80", "ribbon"}, {"\xf0\x9f\x8e\x81", "wrapped_gift"}, {"\xf0\x9f\x8e\x97\xef\xb8\x8f", "reminder_ribbon"}, {"\xf0\x9f\x8e\x9f\xef\xb8\x8f", "admission_tickets"}, {"\xf0\x9f\x8e\xab", "ticket"}, {"\xf0\x9f\x8e\x96\xef\xb8\x8f", "military_medal"}, {"\xf0\x9f\x8f\x86", "trophy"}, {"\xf0\x9f\x8f\x85", "sports_medal"}, {"\xf0\x9f\xa5\x87", "1st_place_medal"}, {"\xf0\x9f\xa5\x88", "2nd_place_medal"}, {"\xf0\x9f\xa5\x89", "3rd_place_medal"}, {"\xe2\x9a\xbd", "soccer_ball"}, {"\xe2\x9a\xbe", "baseball"}, {"\xf0\x9f\xa5\x8e", "softball"}, {"\xf0\x9f\x8f\x80", "basketball"}, {"\xf0\x9f\x8f\x90", "volleyball"}, {"\xf0\x9f\x8f\x88", "american_football"}, {"\xf0\x9f\x8f\x89", "rugby_football"}, {"\xf0\x9f\x8e\xbe", "tennis"}, {"\xf0\x9f\xa5\x8f", "flying_disc"}, {"\xf0\x9f\x8e\xb3", "bowling"}, {"\xf0\x9f\x8f\x8f", "cricket_game"}, {"\xf0\x9f\x8f\x91", "field_hockey"}, {"\xf0\x9f\x8f\x92", "ice_hockey"}, {"\xf0\x9f\xa5\x8d", "lacrosse"}, {"\xf0\x9f\x8f\x93", "ping_pong"}, {"\xf0\x9f\x8f\xb8", "badminton"}, {"\xf0\x9f\xa5\x8a", "boxing_glove"}, {"\xf0\x9f\xa5\x8b", "martial_arts_uniform"}, {"\xf0\x9f\xa5\x85", "goal_net"}, {"\xe2\x9b\xb3", "flag_in_hole"}, {"\xe2\x9b\xb8\xef\xb8\x8f", "ice_skate"}, {"\xf0\x9f\x8e\xa3", "fishing_pole"}, {"\xf0\x9f\xa4\xbf", "diving_mask"}, {"\xf0\x9f\x8e\xbd", "running_shirt"}, {"\xf0\x9f\x8e\xbf", "skis"}, {"\xf0\x9f\x9b\xb7", "sled"}, {"\xf0\x9f\xa5\x8c", "curling_stone"}, {"\xf0\x9f\x8e\xaf", "bullseye"}, {"\xf0\x9f\x8e\xaf", "dart"}, {"\xf0\x9f\xaa\x80", "yo-yo"}, {"\xf0\x9f\xaa\x81", "kite"}, {"\xf0\x9f\x94\xab", "water_pistol"}, {"\xf0\x9f\x8e\xb1", "pool_8_ball"}, {"\xf0\x9f\x94\xae", "crystal_ball"}, {"\xf0\x9f\xaa\x84", "magic_wand"}, {"\xf0\x9f\x8e\xae", "video_game"}, {"\xf0\x9f\x95\xb9\xef\xb8\x8f", "joystick"}, {"\xf0\x9f\x8e\xb0", "slot_machine"}, {"\xf0\x9f\x8e\xb2", "game_die"}, {"\xf0\x9f\xa7\xa9", "puzzle_piece"}, {"\xf0\x9f\xa7\xb8", "teddy_bear"}, {"\xf0\x9f\xaa\x85", "piata"}, {"\xf0\x9f\xaa\xa9", "mirror_ball"}, {"\xf0\x9f\xaa\x86", "nesting_dolls"}, {"\xe2\x99\xa0\xef\xb8\x8f", "spade_suit"}, {"\xe2\x99\xa5\xef\xb8\x8f", "heart_suit"}, {"\xe2\x99\xa6\xef\xb8\x8f", "diamond_suit"}, {"\xe2\x99\xa3\xef\xb8\x8f", "club_suit"}, {"\xe2\x99\x9f\xef\xb8\x8f", "chess_pawn"}, {"\xf0\x9f\x83\x8f", "joker"}, {"\xf0\x9f\x80\x84", "mahjong_red_dragon"}, {"\xf0\x9f\x8e\xb4", "flower_playing_cards"}, {"\xf0\x9f\x8e\xad", "performing_arts"}, {"\xf0\x9f\x96\xbc\xef\xb8\x8f", "framed_picture"}, {"\xf0\x9f\x8e\xa8", "artist_palette"}, {"\xf0\x9f\xa7\xb5", "thread"}, {"\xf0\x9f\xaa\xa1", "sewing_needle"}, {"\xf0\x9f\xa7\xb6", "yarn"}, {"\xf0\x9f\xaa\xa2", "knot"}, {"\xf0\x9f\x91\x93", "glasses"}, {"\xf0\x9f\xa5\xbd", "goggles"}, {"\xf0\x9f\xa5\xbc", "lab_coat"}, {"\xf0\x9f\xa6\xba", "safety_vest"}, {"\xf0\x9f\x91\x94", "necktie"}, {"\xf0\x9f\x91\x95", "t-shirt"}, {"\xf0\x9f\x91\x96", "jeans"}, {"\xf0\x9f\xa7\xa3", "scarf"}, {"\xf0\x9f\xa7\xa4", "gloves"}, {"\xf0\x9f\xa7\xa5", "coat"}, {"\xf0\x9f\xa7\xa6", "socks"}, {"\xf0\x9f\x91\x97", "dress"}, {"\xf0\x9f\x91\x98", "kimono"}, {"\xf0\x9f\xa5\xbb", "sari"}, {"\xf0\x9f\xa9\xb1", "one-piece_swimsuit"}, {"\xf0\x9f\xa9\xb2", "briefs"}, {"\xf0\x9f\xa9\xb3", "shorts"}, {"\xf0\x9f\x91\x99", "bikini"}, {"\xf0\x9f\x91\x9a", "womans_clothes"}, {"\xf0\x9f\xaa\xad", "folding_hand_fan"}, {"\xf0\x9f\x91\x9b", "purse"}, {"\xf0\x9f\x91\x9c", "handbag"}, {"\xf0\x9f\x91\x9d", "clutch_bag"}, {"\xf0\x9f\x9b\x8d\xef\xb8\x8f", "shopping_bags"}, {"\xf0\x9f\x8e\x92", "backpack"}, {"\xf0\x9f\xa9\xb4", "thong_sandal"}, {"\xf0\x9f\x91\x9e", "mans_shoe"}, {"\xf0\x9f\x91\x9f", "running_shoe"}, {"\xf0\x9f\xa5\xbe", "hiking_boot"}, {"\xf0\x9f\xa5\xbf", "flat_shoe"}, {"\xf0\x9f\x91\xa0", "high-heeled_shoe"}, {"\xf0\x9f\x91\xa1", "womans_sandal"}, {"\xf0\x9f\xa9\xb0", "ballet_shoes"}, {"\xf0\x9f\x91\xa2", "womans_boot"}, {"\xf0\x9f\xaa\xae", "hair_pick"}, {"\xf0\x9f\x91\x91", "crown"}, {"\xf0\x9f\x91\x92", "womans_hat"}, {"\xf0\x9f\x8e\xa9", "top_hat"}, {"\xf0\x9f\x8e\x93", "graduation_cap"}, {"\xf0\x9f\xa7\xa2", "billed_cap"}, {"\xf0\x9f\xaa\x96", "military_helmet"}, {"\xe2\x9b\x91\xef\xb8\x8f", "rescue_workers_helmet"}, {"\xf0\x9f\x93\xbf", "prayer_beads"}, {"\xf0\x9f\x92\x84", "lipstick"}, {"\xf0\x9f\x92\x8d", "ring"}, {"\xf0\x9f\x92\x8e", "gem_stone"}, {"\xf0\x9f\x92\x8e", "gem"}, {"\xf0\x9f\x94\x87", "muted_speaker"}, {"\xf0\x9f\x94\x88", "speaker_low_volume"}, {"\xf0\x9f\x94\x89", "speaker_medium_volume"}, {"\xf0\x9f\x94\x8a", "speaker_high_volume"}, {"\xf0\x9f\x93\xa2", "loudspeaker"}, {"\xf0\x9f\x93\xa3", "megaphone"}, {"\xf0\x9f\x93\xaf", "postal_horn"}, {"\xf0\x9f\x94\x94", "bell"}, {"\xf0\x9f\x94\x95", "bell_with_slash"}, {"\xf0\x9f\x8e\xbc", "musical_score"}, {"\xf0\x9f\x8e\xb5", "musical_note"}, {"\xf0\x9f\x8e\xb5", "notes"}, {"\xf0\x9f\x8e\xb6", "musical_notes"}, {"\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"}, {"\xf0\x9f\x8e\xa4", "microphone"}, {"\xf0\x9f\x8e\xa7", "headphone"}, {"\xf0\x9f\x93\xbb", "radio"}, {"\xf0\x9f\x8e\xb7", "saxophone"}, {"\xf0\x9f\xaa\x97", "accordion"}, {"\xf0\x9f\x8e\xb8", "guitar"}, {"\xf0\x9f\x8e\xb9", "musical_keyboard"}, {"\xf0\x9f\x8e\xba", "trumpet"}, {"\xf0\x9f\x8e\xbb", "violin"}, {"\xf0\x9f\xaa\x95", "banjo"}, {"\xf0\x9f\xa5\x81", "drum"}, {"\xf0\x9f\xaa\x98", "long_drum"}, {"\xf0\x9f\xaa\x87", "maracas"}, {"\xf0\x9f\xaa\x88", "flute"}, {"\xf0\x9f\xaa\x89", "harp"}, {"\xf0\x9f\x93\xb1", "mobile_phone"}, {"\xf0\x9f\x93\xb2", "mobile_phone_with_arrow"}, {"\xe2\x98\x8e\xef\xb8\x8f", "telephone"}, {"\xf0\x9f\x93\x9e", "telephone_receiver"}, {"\xf0\x9f\x93\x9f", "pager"}, {"\xf0\x9f\x93\xa0", "fax_machine"}, {"\xf0\x9f\x94\x8b", "battery"}, {"\xf0\x9f\xaa\xab", "low_battery"}, {"\xf0\x9f\x94\x8c", "electric_plug"}, {"\xf0\x9f\x92\xbb", "laptop"}, {"\xf0\x9f\x96\xa5\xef\xb8\x8f", "desktop_computer"}, {"\xf0\x9f\x96\xa5\xef\xb8\x8f", "desktop"}, {"\xf0\x9f\x96\xa8\xef\xb8\x8f", "printer"}, {"\xe2\x8c\xa8\xef\xb8\x8f", "keyboard"}, {"\xf0\x9f\x96\xb1\xef\xb8\x8f", "computer_mouse"}, {"\xf0\x9f\x96\xb2\xef\xb8\x8f", "trackball"}, {"\xf0\x9f\x92\xbd", "computer_disk"}, {"\xf0\x9f\x92\xbe", "floppy_disk"}, {"\xf0\x9f\x92\xbf", "optical_disk"}, {"\xf0\x9f\x93\x80", "dvd"}, {"\xf0\x9f\xa7\xae", "abacus"}, {"\xf0\x9f\x8e\xa5", "movie_camera"}, {"\xf0\x9f\x8e\x9e\xef\xb8\x8f", "film_frames"}, {"\xf0\x9f\x93\xbd\xef\xb8\x8f", "film_projector"}, {"\xf0\x9f\x8e\xac", "clapper_board"}, {"\xf0\x9f\x8e\xac", "clapper"}, {"\xf0\x9f\x93\xba", "television"}, {"\xf0\x9f\x93\xb7", "camera"}, {"\xf0\x9f\x93\xb8", "camera_with_flash"}, {"\xf0\x9f\x93\xb9", "video_camera"}, {"\xf0\x9f\x93\xbc", "videocassette"}, {"\xf0\x9f\x94\x8d", "magnifying_glass_tilted_left"}, {"\xf0\x9f\x94\x8e", "magnifying_glass_tilted_right"}, {"\xf0\x9f\x95\xaf\xef\xb8\x8f", "candle"}, {"\xf0\x9f\x92\xa1", "light_bulb"}, {"\xf0\x9f\x92\xa1", "bulb"}, {"\xf0\x9f\x94\xa6", "flashlight"}, {"\xf0\x9f\x8f\xae", "red_paper_lantern"}, {"\xf0\x9f\xaa\x94", "diya_lamp"}, {"\xf0\x9f\x93\x94", "notebook_with_decorative_cover"}, {"\xf0\x9f\x93\x95", "closed_book"}, {"\xf0\x9f\x93\x96", "open_book"}, {"\xf0\x9f\x93\x96", "book"}, {"\xf0\x9f\x93\x97", "green_book"}, {"\xf0\x9f\x93\x98", "blue_book"}, {"\xf0\x9f\x93\x99", "orange_book"}, {"\xf0\x9f\x93\x9a", "books"}, {"\xf0\x9f\x93\x93", "notebook"}, {"\xf0\x9f\x93\x92", "ledger"}, {"\xf0\x9f\x93\x83", "page_with_curl"}, {"\xf0\x9f\x93\x9c", "scroll"}, {"\xf0\x9f\x93\x84", "page_facing_up"}, {"\xf0\x9f\x93\xb0", "newspaper"}, {"\xf0\x9f\x97\x9e\xef\xb8\x8f", "rolled-up_newspaper"}, {"\xf0\x9f\x93\x91", "bookmark_tabs"}, {"\xf0\x9f\x94\x96", "bookmark"}, {"\xf0\x9f\x8f\xb7\xef\xb8\x8f", "label"}, {"\xf0\x9f\x92\xb0", "money_bag"}, {"\xf0\x9f\xaa\x99", "coin"}, {"\xf0\x9f\x92\xb4", "yen_banknote"}, {"\xf0\x9f\x92\xb5", "dollar_banknote"}, {"\xf0\x9f\x92\xb6", "euro_banknote"}, {"\xf0\x9f\x92\xb7", "pound_banknote"}, {"\xf0\x9f\x92\xb8", "money_with_wings"}, {"\xf0\x9f\x92\xb3", "credit_card"}, {"\xf0\x9f\xa7\xbe", "receipt"}, {"\xf0\x9f\x92\xb9", "chart_increasing_with_yen"}, {"\xe2\x9c\x89\xef\xb8\x8f", "envelope"}, {"\xf0\x9f\x93\xa7", "e-mail"}, {"\xf0\x9f\x93\xa8", "incoming_envelope"}, {"\xf0\x9f\x93\xa9", "envelope_with_arrow"}, {"\xf0\x9f\x93\xa4", "outbox_tray"}, {"\xf0\x9f\x93\xa5", "inbox_tray"}, {"\xf0\x9f\x93\xa6", "package"}, {"\xf0\x9f\x93\xab", "closed_mailbox_with_raised_flag"}, {"\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"}, {"\xf0\x9f\x93\xae", "postbox"}, {"\xf0\x9f\x97\xb3\xef\xb8\x8f", "ballot_box_with_ballot"}, {"\xe2\x9c\x8f\xef\xb8\x8f", "pencil"}, {"\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"}, {"\xf0\x9f\x96\x8c\xef\xb8\x8f", "paintbrush"}, {"\xf0\x9f\x96\x8d\xef\xb8\x8f", "crayon"}, {"\xf0\x9f\x93\x9d", "memo"}, {"\xf0\x9f\x92\xbc", "briefcase"}, {"\xf0\x9f\x93\x81", "file_folder"}, {"\xf0\x9f\x93\x82", "open_file_folder"}, {"\xf0\x9f\x97\x82\xef\xb8\x8f", "card_index_dividers"}, {"\xf0\x9f\x93\x85", "calendar"}, {"\xf0\x9f\x93\x86", "tear-off_calendar"}, {"\xf0\x9f\x97\x92\xef\xb8\x8f", "spiral_notepad"}, {"\xf0\x9f\x97\x93\xef\xb8\x8f", "spiral_calendar"}, {"\xf0\x9f\x93\x87", "card_index"}, {"\xf0\x9f\x93\x88", "chart_increasing"}, {"\xf0\x9f\x93\x89", "chart_decreasing"}, {"\xf0\x9f\x93\x8a", "bar_chart"}, {"\xf0\x9f\x93\x8b", "clipboard"}, {"\xf0\x9f\x93\x8c", "pushpin"}, {"\xf0\x9f\x93\x8d", "round_pushpin"}, {"\xf0\x9f\x93\x8e", "paperclip"}, {"\xf0\x9f\x96\x87\xef\xb8\x8f", "linked_paperclips"}, {"\xf0\x9f\x93\x8f", "straight_ruler"}, {"\xf0\x9f\x93\x90", "triangular_ruler"}, {"\xe2\x9c\x82\xef\xb8\x8f", "scissors"}, {"\xf0\x9f\x97\x83\xef\xb8\x8f", "card_file_box"}, {"\xf0\x9f\x97\x84\xef\xb8\x8f", "file_cabinet"}, {"\xf0\x9f\x97\x91\xef\xb8\x8f", "wastebasket"}, {"\xf0\x9f\x94\x92", "locked"}, {"\xf0\x9f\x94\x92", "lock"}, {"\xf0\x9f\x94\x93", "unlocked"}, {"\xf0\x9f\x94\x93", "unlock"}, {"\xf0\x9f\x94\x8f", "locked_with_pen"}, {"\xf0\x9f\x94\x90", "locked_with_key"}, {"\xf0\x9f\x94\x91", "key"}, {"\xf0\x9f\x97\x9d\xef\xb8\x8f", "old_key"}, {"\xf0\x9f\x94\xa8", "hammer"}, {"\xf0\x9f\xaa\x93", "axe"}, {"\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"}, {"\xf0\x9f\x97\xa1\xef\xb8\x8f", "dagger"}, {"\xe2\x9a\x94\xef\xb8\x8f", "crossed_swords"}, {"\xf0\x9f\x92\xa3", "bomb"}, {"\xf0\x9f\xaa\x83", "boomerang"}, {"\xf0\x9f\x8f\xb9", "bow_and_arrow"}, {"\xf0\x9f\x9b\xa1\xef\xb8\x8f", "shield"}, {"\xf0\x9f\xaa\x9a", "carpentry_saw"}, {"\xf0\x9f\x94\xa7", "wrench"}, {"\xf0\x9f\xaa\x9b", "screwdriver"}, {"\xf0\x9f\x94\xa9", "nut_and_bolt"}, {"\xe2\x9a\x99\xef\xb8\x8f", "gear"}, {"\xf0\x9f\x97\x9c\xef\xb8\x8f", "clamp"}, {"\xe2\x9a\x96\xef\xb8\x8f", "balance_scale"}, {"\xf0\x9f\xa6\xaf", "white_cane"}, {"\xf0\x9f\x94\x97", "link"}, {"\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"}, {"\xf0\x9f\xa7\xb0", "toolbox"}, {"\xf0\x9f\xa7\xb2", "magnet"}, {"\xf0\x9f\xaa\x9c", "ladder"}, {"\xf0\x9f\xaa\x8f", "shovel"}, {"\xe2\x9a\x97\xef\xb8\x8f", "alembic"}, {"\xf0\x9f\xa7\xaa", "test_tube"}, {"\xf0\x9f\xa7\xab", "petri_dish"}, {"\xf0\x9f\xa7\xac", "dna"}, {"\xf0\x9f\x94\xac", "microscope"}, {"\xf0\x9f\x94\xad", "telescope"}, {"\xf0\x9f\x93\xa1", "satellite_antenna"}, {"\xf0\x9f\x92\x89", "syringe"}, {"\xf0\x9f\xa9\xb8", "drop_of_blood"}, {"\xf0\x9f\x92\x8a", "pill"}, {"\xf0\x9f\xa9\xb9", "adhesive_bandage"}, {"\xf0\x9f\xa9\xbc", "crutch"}, {"\xf0\x9f\xa9\xba", "stethoscope"}, {"\xf0\x9f\xa9\xbb", "x-ray"}, {"\xf0\x9f\x9a\xaa", "door"}, {"\xf0\x9f\x9b\x97", "elevator"}, {"\xf0\x9f\xaa\x9e", "mirror"}, {"\xf0\x9f\xaa\x9f", "window"}, {"\xf0\x9f\x9b\x8f\xef\xb8\x8f", "bed"}, {"\xf0\x9f\x9b\x8b\xef\xb8\x8f", "couch_and_lamp"}, {"\xf0\x9f\xaa\x91", "chair"}, {"\xf0\x9f\x9a\xbd", "toilet"}, {"\xf0\x9f\xaa\xa0", "plunger"}, {"\xf0\x9f\x9a\xbf", "shower"}, {"\xf0\x9f\x9b\x81", "bathtub"}, {"\xf0\x9f\xaa\xa4", "mouse_trap"}, {"\xf0\x9f\xaa\x92", "razor"}, {"\xf0\x9f\xa7\xb4", "lotion_bottle"}, {"\xf0\x9f\xa7\xb7", "safety_pin"}, {"\xf0\x9f\xa7\xb9", "broom"}, {"\xf0\x9f\xa7\xba", "basket"}, {"\xf0\x9f\xa7\xbb", "roll_of_paper"}, {"\xf0\x9f\xaa\xa3", "bucket"}, {"\xf0\x9f\xa7\xbc", "soap"}, {"\xf0\x9f\xab\xa7", "bubbles"}, {"\xf0\x9f\xaa\xa5", "toothbrush"}, {"\xf0\x9f\xa7\xbd", "sponge"}, {"\xf0\x9f\xa7\xaf", "fire_extinguisher"}, {"\xf0\x9f\x9b\x92", "shopping_cart"}, {"\xf0\x9f\x9a\xac", "cigarette"}, {"\xe2\x9a\xb0\xef\xb8\x8f", "coffin"}, {"\xf0\x9f\xaa\xa6", "headstone"}, {"\xe2\x9a\xb1\xef\xb8\x8f", "funeral_urn"}, {"\xf0\x9f\xa7\xbf", "nazar_amulet"}, {"\xf0\x9f\xaa\xac", "hamsa"}, {"\xf0\x9f\x97\xbf", "moai"}, {"\xf0\x9f\xaa\xa7", "placard"}, {"\xf0\x9f\xaa\xaa", "identification_card"}, {"\xf0\x9f\x8f\xa7", "atm_sign"}, {"\xf0\x9f\x9a\xae", "litter_in_bin_sign"}, {"\xf0\x9f\x9a\xb0", "potable_water"}, {"\xe2\x99\xbf", "wheelchair_symbol"}, {"\xf0\x9f\x9a\xb9", "mens_room"}, {"\xf0\x9f\x9a\xba", "womens_room"}, {"\xf0\x9f\x9a\xbb", "restroom"}, {"\xf0\x9f\x9a\xbc", "baby_symbol"}, {"\xf0\x9f\x9a\xbe", "water_closet"}, {"\xf0\x9f\x9b\x82", "passport_control"}, {"\xf0\x9f\x9b\x83", "customs"}, {"\xf0\x9f\x9b\x84", "baggage_claim"}, {"\xf0\x9f\x9b\x85", "left_luggage"}, {"\xe2\x9a\xa0\xef\xb8\x8f", "warning"}, {"\xf0\x9f\x9a\xb8", "children_crossing"}, {"\xe2\x9b\x94", "no_entry"}, {"\xf0\x9f\x9a\xab", "prohibited"}, {"\xf0\x9f\x9a\xb3", "no_bicycles"}, {"\xf0\x9f\x9a\xad", "no_smoking"}, {"\xf0\x9f\x9a\xaf", "no_littering"}, {"\xf0\x9f\x9a\xb1", "non-potable_water"}, {"\xf0\x9f\x9a\xb7", "no_pedestrians"}, {"\xf0\x9f\x93\xb5", "no_mobile_phones"}, {"\xf0\x9f\x94\x9e", "no_one_under_eighteen"}, {"\xe2\x98\xa2\xef\xb8\x8f", "radioactive"}, {"\xe2\x98\xa3\xef\xb8\x8f", "biohazard"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\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"}, {"\xf0\x9f\x94\x84", "counterclockwise_arrows_button"}, {"\xf0\x9f\x94\x99", "back_arrow"}, {"\xf0\x9f\x94\x9a", "end_arrow"}, {"\xf0\x9f\x94\x9b", "on_arrow"}, {"\xf0\x9f\x94\x9c", "soon_arrow"}, {"\xf0\x9f\x94\x9d", "top_arrow"}, {"\xf0\x9f\x9b\x90", "place_of_worship"}, {"\xe2\x9a\x9b\xef\xb8\x8f", "atom_symbol"}, {"\xf0\x9f\x95\x89\xef\xb8\x8f", "om"}, {"\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"}, {"\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"}, {"\xe2\x98\xae\xef\xb8\x8f", "peace_symbol"}, {"\xf0\x9f\x95\x8e", "menorah"}, {"\xf0\x9f\x94\xaf", "dotted_six-pointed_star"}, {"\xf0\x9f\xaa\xaf", "khanda"}, {"\xe2\x99\x88", "aries"}, {"\xe2\x99\x89", "taurus"}, {"\xe2\x99\x8a", "gemini"}, {"\xe2\x99\x8b", "cancer"}, {"\xe2\x99\x8c", "leo"}, {"\xe2\x99\x8d", "virgo"}, {"\xe2\x99\x8e", "libra"}, {"\xe2\x99\x8f", "scorpio"}, {"\xe2\x99\x90", "sagittarius"}, {"\xe2\x99\x91", "capricorn"}, {"\xe2\x99\x92", "aquarius"}, {"\xe2\x99\x93", "pisces"}, {"\xe2\x9b\x8e", "ophiuchus"}, {"\xf0\x9f\x94\x80", "shuffle_tracks_button"}, {"\xf0\x9f\x94\x81", "repeat_button"}, {"\xf0\x9f\x94\x82", "repeat_single_button"}, {"\xe2\x96\xb6\xef\xb8\x8f", "play_button"}, {"\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"}, {"\xe2\x97\x80\xef\xb8\x8f", "reverse_button"}, {"\xe2\x8f\xaa", "fast_reverse_button"}, {"\xe2\x8f\xae\xef\xb8\x8f", "last_track_button"}, {"\xf0\x9f\x94\xbc", "upwards_button"}, {"\xe2\x8f\xab", "fast_up_button"}, {"\xf0\x9f\x94\xbd", "downwards_button"}, {"\xe2\x8f\xac", "fast_down_button"}, {"\xe2\x8f\xb8\xef\xb8\x8f", "pause_button"}, {"\xe2\x8f\xb9\xef\xb8\x8f", "stop_button"}, {"\xe2\x8f\xba\xef\xb8\x8f", "record_button"}, {"\xe2\x8f\x8f\xef\xb8\x8f", "eject_button"}, {"\xf0\x9f\x8e\xa6", "cinema"}, {"\xf0\x9f\x94\x85", "dim_button"}, {"\xf0\x9f\x94\x86", "bright_button"}, {"\xf0\x9f\x93\xb6", "antenna_bars"}, {"\xf0\x9f\x9b\x9c", "wireless"}, {"\xf0\x9f\x93\xb3", "vibration_mode"}, {"\xf0\x9f\x93\xb4", "mobile_phone_off"}, {"\xe2\x99\x80\xef\xb8\x8f", "female_sign"}, {"\xe2\x99\x82\xef\xb8\x8f", "male_sign"}, {"\xe2\x9a\xa7\xef\xb8\x8f", "transgender_symbol"}, {"\xe2\x9c\x96\xef\xb8\x8f", "multiply"}, {"\xe2\x9e\x95", "plus"}, {"\xe2\x9e\x96", "minus"}, {"\xe2\x9e\x97", "divide"}, {"\xf0\x9f\x9f\xb0", "heavy_equals_sign"}, {"\xe2\x99\xbe\xef\xb8\x8f", "infinity"}, {"\xe2\x80\xbc\xef\xb8\x8f", "double_exclamation_mark"}, {"\xe2\x81\x89\xef\xb8\x8f", "exclamation_question_mark"}, {"\xe2\x9d\x93", "red_question_mark"}, {"\xe2\x9d\x93", "question"}, {"\xe2\x9d\x94", "white_question_mark"}, {"\xe2\x9d\x95", "white_exclamation_mark"}, {"\xe2\x9d\x97", "red_exclamation_mark"}, {"\xe2\x9d\x97", "exclamation"}, {"\xe3\x80\xb0\xef\xb8\x8f", "wavy_dash"}, {"\xf0\x9f\x92\xb1", "currency_exchange"}, {"\xf0\x9f\x92\xb2", "heavy_dollar_sign"}, {"\xe2\x9a\x95\xef\xb8\x8f", "medical_symbol"}, {"\xe2\x99\xbb\xef\xb8\x8f", "recycling_symbol"}, {"\xe2\x99\xbb\xef\xb8\x8f", "recycle"}, {"\xe2\x9a\x9c\xef\xb8\x8f", "fleur-de-lis"}, {"\xf0\x9f\x94\xb1", "trident_emblem"}, {"\xf0\x9f\x93\x9b", "name_badge"}, {"\xf0\x9f\x94\xb0", "japanese_symbol_for_beginner"}, {"\xe2\xad\x95", "hollow_red_circle"}, {"\xe2\x9c\x85", "check_mark_button"}, {"\xe2\x9c\x85", "white_check_mark"}, {"\xe2\x98\x91\xef\xb8\x8f", "check_box_with_check"}, {"\xe2\x9c\x94\xef\xb8\x8f", "check_mark"}, {"\xe2\x9d\x8c", "cross_mark"}, {"\xe2\x9d\x8c", "x"}, {"\xe2\x9d\x8e", "cross_mark_button"}, {"\xe2\x9e\xb0", "curly_loop"}, {"\xe2\x9e\xbf", "double_curly_loop"}, {"\xe3\x80\xbd\xef\xb8\x8f", "part_alternation_mark"}, {"\xe2\x9c\xb3\xef\xb8\x8f", "eight-spoked_asterisk"}, {"\xe2\x9c\xb4\xef\xb8\x8f", "eight-pointed_star"}, {"\xe2\x9d\x87\xef\xb8\x8f", "sparkle"}, {"\xc2\xa9\xef\xb8\x8f", "copyright"}, {"\xc2\xae\xef\xb8\x8f", "registered"}, {"\xe2\x84\xa2\xef\xb8\x8f", "trade_mark"}, {"\xf0\x9f\xab\x9f", "splatter"}, {"\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"}, {"\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"}, {"\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"}, {"\x38\xef\xb8\x8f\xe2\x83\xa3", "keycap_8"}, {"\x39\xef\xb8\x8f\xe2\x83\xa3", "keycap_9"}, {"\xf0\x9f\x94\x9f", "keycap_10"}, {"\xf0\x9f\x94\xa0", "input_latin_uppercase"}, {"\xf0\x9f\x94\xa1", "input_latin_lowercase"}, {"\xf0\x9f\x94\xa2", "input_numbers"}, {"\xf0\x9f\x94\xa3", "input_symbols"}, {"\xf0\x9f\x94\xa4", "input_latin_letters"}, {"\xf0\x9f\x85\xb0\xef\xb8\x8f", "a_button_blood_type"}, {"\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"}, {"\xf0\x9f\x86\x92", "cool_button"}, {"\xf0\x9f\x86\x93", "free_button"}, {"\xe2\x84\xb9\xef\xb8\x8f", "information"}, {"\xf0\x9f\x86\x94", "id_button"}, {"\xe2\x93\x82\xef\xb8\x8f", "circled_m"}, {"\xf0\x9f\x86\x95", "new_button"}, {"\xf0\x9f\x86\x95", "new"}, {"\xf0\x9f\x86\x96", "ng_button"}, {"\xf0\x9f\x85\xbe\xef\xb8\x8f", "o_button_blood_type"}, {"\xf0\x9f\x86\x97", "ok_button"}, {"\xf0\x9f\x86\x97", "ok"}, {"\xf0\x9f\x85\xbf\xef\xb8\x8f", "p_button"}, {"\xf0\x9f\x86\x98", "sos_button"}, {"\xf0\x9f\x86\x99", "up_button"}, {"\xf0\x9f\x86\x99", "up"}, {"\xf0\x9f\x86\x9a", "vs_button"}, {"\xf0\x9f\x88\x81", "japanese_here_button"}, {"\xf0\x9f\x88\x82\xef\xb8\x8f", "japanese_service_charge_button"}, {"\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"}, {"\xf0\x9f\x89\x90", "japanese_bargain_button"}, {"\xf0\x9f\x88\xb9", "japanese_discount_button"}, {"\xf0\x9f\x88\x9a", "japanese_free_of_charge_button"}, {"\xf0\x9f\x88\xb2", "japanese_prohibited_button"}, {"\xf0\x9f\x89\x91", "japanese_acceptable_button"}, {"\xf0\x9f\x88\xb8", "japanese_application_button"}, {"\xf0\x9f\x88\xb4", "japanese_passing_grade_button"}, {"\xf0\x9f\x88\xb3", "japanese_vacancy_button"}, {"\xe3\x8a\x97\xef\xb8\x8f", "japanese_congratulations_button"}, {"\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"}, {"\xf0\x9f\x94\xb4", "red_circle"}, {"\xf0\x9f\x9f\xa0", "orange_circle"}, {"\xf0\x9f\x9f\xa1", "yellow_circle"}, {"\xf0\x9f\x9f\xa2", "green_circle"}, {"\xf0\x9f\x94\xb5", "blue_circle"}, {"\xf0\x9f\x9f\xa3", "purple_circle"}, {"\xf0\x9f\x9f\xa4", "brown_circle"}, {"\xe2\x9a\xab", "black_circle"}, {"\xe2\x9a\xaa", "white_circle"}, {"\xf0\x9f\x9f\xa5", "red_square"}, {"\xf0\x9f\x9f\xa7", "orange_square"}, {"\xf0\x9f\x9f\xa8", "yellow_square"}, {"\xf0\x9f\x9f\xa9", "green_square"}, {"\xf0\x9f\x9f\xa6", "blue_square"}, {"\xf0\x9f\x9f\xaa", "purple_square"}, {"\xf0\x9f\x9f\xab", "brown_square"}, {"\xe2\xac\x9b", "black_large_square"}, {"\xe2\xac\x9c", "white_large_square"}, {"\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"}, {"\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"}, {"\xf0\x9f\x94\xb6", "large_orange_diamond"}, {"\xf0\x9f\x94\xb7", "large_blue_diamond"}, {"\xf0\x9f\x94\xb8", "small_orange_diamond"}, {"\xf0\x9f\x94\xb9", "small_blue_diamond"}, {"\xf0\x9f\x94\xba", "red_triangle_pointed_up"}, {"\xf0\x9f\x94\xbb", "red_triangle_pointed_down"}, {"\xf0\x9f\x92\xa0", "diamond_with_a_dot"}, {"\xf0\x9f\x94\x98", "radio_button"}, {"\xf0\x9f\x94\xb3", "white_square_button"}, {"\xf0\x9f\x94\xb2", "black_square_button"}, {"\xf0\x9f\x8f\x81", "chequered_flag"}, {"\xf0\x9f\x9a\xa9", "triangular_flag"}, {"\xf0\x9f\x8e\x8c", "crossed_flags"}, {"\xf0\x9f\x8f\xb4", "black_flag"}, {"\xf0\x9f\x8f\xb3\xef\xb8\x8f", "white_flag"}, {"\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"}, {"\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"}, } langNames = map[string]string{ "en": "English", "ja": "日本語", "pt": "Português", } translations = map[string]map[string]string{ "en": { // Login page. "subtitle": "the digital home for the music scene", "login": "login", "requesting": "requesting...", "err_no_id": "no identity - open signer to add one", "language": "Language", "theme": "Theme", "dark": "dark", "light": "light", // Top bar / nav. "back": "\u2190 back", "feed": "feed", "compose": "compose", "messaging": "messaging", "settings": "settings", "notifications": "notifications", "no_notifications": "no notifications yet", "about": "about", // Feed. "connecting": "connecting...", "loading_translator": "loading translator...", "show_more": "show more", "show_less": "show less", "note_not_found": "Note not found", "reposted": "reposted", // Thread view. "loading_thread": "loading thread...", "thread_empty": "thread not found", "replying_to": "replying to...", "reply_not_found": "note not found", // Profile. "notes": "notes", "follows": "follows", "relays": "relays", "mutes": "mutes", "no_follows": "no follows data", "no_relays": "no relay data", "no_mutes": "no mutes data", "following": "following", "muted": "muted", "copy": "copy", "qr": "qr", "message": "message", "loading": "loading...", "relay_info": "relay info", "relay_fail": "failed to fetch relay info", // Messaging. "new_chat": " | new chat", "no_convos": "no conversations yet", "npub_placeholder": "npub or hex pubkey", "go": "go", "msg_placeholder": "message...", "send": "send", "dm_notice": "encrypted DMs require the musiquay signer extension", "ratchet": "ratchet", "relay_leak_warn": "some of the selected relays may leak metadata", // About. "developed_by": "developed by:", "tagline": "Can Youse Paradigm?", "about_donkey": "Every hour you don't zap, a donkey eats another cabbage. You can stop this. \U0001FABE", // Settings. "settings_title": "Settings", "lang_label": "Interface language", "theme_label": "Appearance", // Relay settings. "relays_section": "Relays", "relays_active": "Active relays", "relays_required": "Required capabilities", "relays_req_nip42": "NIP-42 authentication", "relays_req_nip70": "NIP-70 protected events", "relays_block_payment": "Block payment-required relays", "relays_block_restrict": "Block restricted-write relays", "relays_blocklist": "Blocklist", "relays_blocklist_empty": "(none)", "relays_no_nip11": "(no NIP-11)", // Signer. "signer": "signer", "no_signer_backend": "No signer detected. Install the extension.", "no_vault": "No vault found. Create a new one.", "hd_keychain": "HD Keychain", "vault_password": "Vault password", "generate_keychain": "Generate New Keychain", "generating": "Generating keychain...", "create_failed": "Failed to create vault.", "restore_seed": "Restore from Seed Phrase", "seed_placeholder": "12-word seed phrase", "account_placeholder": "Account number (default: 1)", "restore_keychain": "Restore Keychain", "restoring": "Restoring keychain...", "restore_failed": "Invalid seed phrase or vault creation failed.", "deriving_n": "Deriving identity", "import_vault": "Import Vault File", "choose_vault": "Choose Vault File...", "vault_imported": "Vault imported. Unlock with your password.", "invalid_vault": "Invalid vault file.", "write_seed": "Write Down Your Seed Phrase", "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.", "copy_clipboard": "Copy to Clipboard", "copied": "Copied!", "saved_it": "I've Saved It", "vault_locked": "Vault is locked.", "password": "Password", "unlock": "Unlock", "deriving_key": "Deriving key, this may take a minute...", "wrong_password": "Wrong password.", "unlock_failed_kdf": "Unlock failed - key derivation crashed. See log.", "identities": "Identities", "nickname_placeholder": "Nickname for new identity", "derive_new": "Derive New Identity", "deriving": "Deriving...", "show_seed": "Show Seed Phrase", "export_vault": "Export Vault", "export_password_prompt": "Choose a password to protect the exported vault:", "confirm_password": "Confirm password", "password_required": "Password is required.", "passwords_mismatch": "Passwords do not match.", "export_failed": "Export failed.", "cancel": "Cancel", "lock_vault": "Lock Vault", "logout": "Logout", "logout_confirm": "This will erase all keys and log you out. Are you sure?", "use": "Use", "publish": "Publish", "add": "Add", "edit_profile": "edit profile", "save": "Save", "profile_name": "Display name", "profile_about": "About", "profile_picture": "Picture URL", "profile_banner": "Banner URL", "profile_nip05": "NIP-05 (user@domain)", "profile_website": "Website", "profile_lud16": "Lightning address", "profile_saved": "Profile published", // Wallet (NWC). "wallet": "Wallet", "wallet_no_wallet": "No wallet connected. Paste a Nostr Wallet Connect URI below.", "wallet_add": "Add Wallet", "wallet_add_another": "Add another wallet", "wallet_add_hint": "Paste a nostr+walletconnect:// URI from Alby Hub, Mutiny, LNbits, etc.", "wallet_alias_placeholder": "Alias (optional)", "wallet_adding": "Adding...", "wallet_add_failed": "Could not add wallet. Check the URI.", "wallet_remove_confirm": "Remove this wallet connection?", "wallet_refresh": "Refresh", "wallet_loading_balance": "loading...", "wallet_sats": "sats", "wallet_send": "Send", "wallet_recipient_placeholder": "lnbc... or user@domain", "wallet_amount_placeholder": "Amount (sats, Lightning address only)", "wallet_comment_placeholder": "Comment (optional)", "wallet_pay": "Pay", "wallet_paying": "Paying...", "wallet_pay_ok": "Paid", "wallet_pay_failed": "Payment failed", "wallet_need_amount": "Amount required for Lightning addresses.", "wallet_unknown_recipient": "Unknown recipient. Use a BOLT11 invoice or lightning address.", }, "ja": { "subtitle": "音楽シーンのためのデジタルホーム", "login": "ログイン", "requesting": "リクエスト中...", "err_no_id": "IDがありません - 署名を開いて追加してください", "language": "言語", "theme": "テーマ", "dark": "ダーク", "light": "ライト", "back": "\u2190 戻る", "feed": "フィード", "compose": "作成", "messaging": "メッセージ", "settings": "設定", "notifications": "通知", "no_notifications": "通知はまだありません", "about": "概要", "connecting": "接続中...", "loading_translator": "翻訳モジュール読み込み中...", "show_more": "もっと見る", "show_less": "閉じる", "note_not_found": "ノートが見つかりません", "reposted": "リポスト", "loading_thread": "スレッド読み込み中...", "thread_empty": "スレッドが見つかりません", "replying_to": "返信先...", "reply_not_found": "ノートが見つかりません", "notes": "ノート", "follows": "フォロー", "relays": "リレー", "mutes": "ミュート", "no_follows": "フォローデータなし", "no_relays": "リレーデータなし", "no_mutes": "ミュートデータなし", "following": "フォロー中", "muted": "ミュート中", "copy": "コピー", "qr": "QR", "message": "メッセージ", "loading": "読み込み中...", "relay_info": "リレー情報", "relay_fail": "リレー情報の取得に失敗しました", "new_chat": " | 新しいチャット", "no_convos": "会話がまだありません", "npub_placeholder": "npubまたは16進公開鍵", "go": "実行", "msg_placeholder": "メッセージ...", "send": "送信", "dm_notice": "暗号化DMにはmusiquay signer拡張が必要です", "ratchet": "ラチェット", "relay_leak_warn": "選択されたリレーの一部がメタデータを漏洩する可能性があります", "developed_by": "開発者:", "tagline": "Can Youse Paradigm?", "about_donkey": "zapしないと毎時間ロバがキャベツを食べてしまいます。止められるのはあなたです。\U0001FABE", "settings_title": "設定", "lang_label": "表示言語", "theme_label": "外観", "relays_section": "リレー", "relays_active": "アクティブなリレー", "relays_required": "必須機能", "relays_req_nip42": "NIP-42 認証", "relays_req_nip70": "NIP-70 保護イベント", "relays_block_payment": "有料リレーをブロック", "relays_block_restrict": "書き込み制限リレーをブロック", "relays_blocklist": "ブロックリスト", "relays_blocklist_empty": "(なし)", "relays_no_nip11": "(NIP-11なし)", "signer": "署名", "no_vault": "金庫が見つかりません。新規作成してください。", "hd_keychain": "HDキーチェーン", "vault_password": "金庫のパスワード", "generate_keychain": "新しいキーチェーンを生成", "generating": "キーチェーン生成中...", "create_failed": "金庫の作成に失敗しました。", "restore_seed": "シードフレーズから復元", "seed_placeholder": "12語のシードフレーズ", "account_placeholder": "アカウント番号(デフォルト: 1)", "restore_keychain": "キーチェーンを復元", "restoring": "キーチェーン復元中...", "restore_failed": "シードフレーズが無効か、金庫の作成に失敗しました。", "deriving_n": "ID導出中", "import_vault": "金庫ファイルをインポート", "choose_vault": "金庫ファイルを選択...", "vault_imported": "金庫をインポートしました。パスワードでロック解除してください。", "invalid_vault": "無効な金庫ファイルです。", "write_seed": "シードフレーズを書き留めてください", "seed_warning": "これがIDを復元する唯一の方法です。これらの単語を書き留めて安全に保管してください。再表示されません。", "copy_clipboard": "クリップボードにコピー", "copied": "コピー済み!", "saved_it": "保存しました", "vault_locked": "金庫はロックされています。", "password": "パスワード", "unlock": "ロック解除", "deriving_key": "鍵を導出中です。しばらくお待ちください...", "wrong_password": "パスワードが違います。", "identities": "ID一覧", "nickname_placeholder": "新しいIDのニックネーム", "derive_new": "新しいIDを導出", "deriving": "導出中...", "show_seed": "シードフレーズを表示", "export_vault": "金庫をエクスポート", "export_password_prompt": "エクスポートの保護パスワードを入力してください:", "confirm_password": "パスワード確認", "password_required": "パスワードが必要です。", "passwords_mismatch": "パスワードが一致しません。", "export_failed": "エクスポートに失敗しました。", "cancel": "キャンセル", "lock_vault": "金庫をロック", "logout": "ログアウト", "logout_confirm": "すべての鍵が削除され、ログアウトします。よろしいですか?", "use": "使用", "publish": "公開", "add": "追加", // Wallet (NWC). "wallet": "ウォレット", "wallet_no_wallet": "ウォレットが接続されていません。下に Nostr Wallet Connect URI を貼り付けてください。", "wallet_add": "ウォレットを追加", "wallet_add_another": "別のウォレットを追加", "wallet_add_hint": "Alby Hub、Mutiny、LNbits などの nostr+walletconnect:// URI を貼り付けてください。", "wallet_alias_placeholder": "別名(任意)", "wallet_adding": "追加中...", "wallet_add_failed": "ウォレットを追加できませんでした。URI を確認してください。", "wallet_remove_confirm": "このウォレット接続を削除しますか?", "wallet_refresh": "更新", "wallet_loading_balance": "読み込み中...", "wallet_sats": "sats", "wallet_send": "送信", "wallet_recipient_placeholder": "lnbc... または user@domain", "wallet_amount_placeholder": "金額 (sats、Lightning アドレスのみ)", "wallet_comment_placeholder": "コメント(任意)", "wallet_pay": "支払う", "wallet_paying": "支払い中...", "wallet_pay_ok": "支払い完了", "wallet_pay_failed": "支払い失敗", "wallet_need_amount": "Lightning アドレスには金額が必要です。", "wallet_unknown_recipient": "不明な受取人。BOLT11 インボイスまたは Lightning アドレスを使用してください。", }, "pt": { "subtitle": "a casa digital da cena musical", "login": "entrar", "requesting": "solicitando...", "err_no_id": "sem identidade - abra o signer para adicionar uma", "language": "Idioma", "theme": "Tema", "dark": "escuro", "light": "claro", "back": "\u2190 voltar", "feed": "feed", "compose": "compor", "messaging": "mensagens", "settings": "configurações", "notifications": "notificações", "no_notifications": "nenhuma notificação ainda", "about": "sobre", "connecting": "conectando...", "loading_translator": "carregando tradutor...", "show_more": "mostrar mais", "show_less": "mostrar menos", "note_not_found": "Nota não encontrada", "reposted": "repostou", "loading_thread": "carregando thread...", "thread_empty": "thread não encontrada", "replying_to": "respondendo a...", "reply_not_found": "nota não encontrada", "notes": "notas", "follows": "seguindo", "relays": "relays", "mutes": "silenciados", "no_follows": "sem dados de seguidos", "no_relays": "sem dados de relays", "no_mutes": "sem dados de silenciados", "following": "seguindo", "muted": "silenciados", "copy": "copiar", "qr": "qr", "message": "mensagem", "loading": "carregando...", "relay_info": "info do relay", "relay_fail": "falha ao buscar info do relay", "new_chat": " | novo chat", "no_convos": "nenhuma conversa ainda", "npub_placeholder": "npub ou chave pública hex", "go": "ir", "msg_placeholder": "mensagem...", "send": "enviar", "dm_notice": "DMs encriptadas requerem a extensão musiquay signer", "ratchet": "ratchet", "relay_leak_warn": "alguns dos relays selecionados podem vazar metadados", "developed_by": "desenvolvido por:", "tagline": "Can Youse Paradigm?", "about_donkey": "A cada hora que você não dá zap, um burro come outro repolho. Você pode impedir isso. \U0001FABE", "settings_title": "Configurações", "lang_label": "Idioma da interface", "theme_label": "Aparência", "relays_section": "Relays", "relays_active": "Relays ativos", "relays_required": "Capacidades necessárias", "relays_req_nip42": "NIP-42 autenticação", "relays_req_nip70": "NIP-70 eventos protegidos", "relays_block_payment": "Bloquear relays pagos", "relays_block_restrict": "Bloquear relays com escrita restrita", "relays_blocklist": "Lista de bloqueio", "relays_blocklist_empty": "(nenhum)", "relays_no_nip11": "(sem NIP-11)", "signer": "assinador", "no_vault": "Nenhum cofre encontrado. Crie um novo.", "hd_keychain": "Chaveiro HD", "vault_password": "Senha do cofre", "generate_keychain": "Gerar Novo Chaveiro", "generating": "Gerando chaveiro...", "create_failed": "Falha ao criar cofre.", "restore_seed": "Restaurar com Frase Semente", "seed_placeholder": "Frase semente de 12 palavras", "account_placeholder": "Número da conta (padrão: 1)", "restore_keychain": "Restaurar Chaveiro", "restoring": "Restaurando chaveiro...", "restore_failed": "Frase semente inválida ou falha ao criar cofre.", "deriving_n": "Derivando identidade", "import_vault": "Importar Arquivo de Cofre", "choose_vault": "Escolher Arquivo de Cofre...", "vault_imported": "Cofre importado. Desbloqueie com sua senha.", "invalid_vault": "Arquivo de cofre inválido.", "write_seed": "Anote Sua Frase Semente", "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.", "copy_clipboard": "Copiar para Área de Transferência", "copied": "Copiado!", "saved_it": "Já Salvei", "vault_locked": "Cofre está bloqueado.", "password": "Senha", "unlock": "Desbloquear", "deriving_key": "Derivando chave, isso pode levar um minuto...", "wrong_password": "Senha incorreta.", "identities": "Identidades", "nickname_placeholder": "Apelido para nova identidade", "derive_new": "Derivar Nova Identidade", "deriving": "Derivando...", "show_seed": "Mostrar Frase Semente", "export_vault": "Exportar Cofre", "export_password_prompt": "Escolha uma senha para proteger o cofre exportado:", "confirm_password": "Confirmar senha", "password_required": "Senha obrigatória.", "passwords_mismatch": "As senhas não coincidem.", "export_failed": "Falha na exportação.", "cancel": "Cancelar", "lock_vault": "Bloquear Cofre", "logout": "Sair", "logout_confirm": "Isso apagará todas as chaves e encerrará a sessão. Tem certeza?", "use": "Usar", "publish": "Publicar", "add": "Adicionar", // Wallet (NWC). "wallet": "Carteira", "wallet_no_wallet": "Nenhuma carteira conectada. Cole um URI Nostr Wallet Connect abaixo.", "wallet_add": "Adicionar Carteira", "wallet_add_another": "Adicionar outra carteira", "wallet_add_hint": "Cole um URI nostr+walletconnect:// do Alby Hub, Mutiny, LNbits, etc.", "wallet_alias_placeholder": "Apelido (opcional)", "wallet_adding": "Adicionando...", "wallet_add_failed": "Não foi possível adicionar a carteira. Verifique o URI.", "wallet_remove_confirm": "Remover esta conexão de carteira?", "wallet_refresh": "Atualizar", "wallet_loading_balance": "carregando...", "wallet_sats": "sats", "wallet_send": "Enviar", "wallet_recipient_placeholder": "lnbc... ou user@domain", "wallet_amount_placeholder": "Valor (sats, apenas para endereço Lightning)", "wallet_comment_placeholder": "Comentário (opcional)", "wallet_pay": "Pagar", "wallet_paying": "Pagando...", "wallet_pay_ok": "Pago", "wallet_pay_failed": "Falha no pagamento", "wallet_need_amount": "Valor obrigatório para endereços Lightning.", "wallet_unknown_recipient": "Destinatário desconhecido. Use uma fatura BOLT11 ou endereço Lightning.", }, } qrVersions = [41]qrVersionInfo{ {}, {26, [4]qrBlockInfo{{1, 7}, {1, 10}, {1, 13}, {1, 17}}}, // 1 {44, [4]qrBlockInfo{{1, 10}, {1, 16}, {1, 22}, {1, 28}}}, // 2 {70, [4]qrBlockInfo{{1, 15}, {1, 26}, {2, 18}, {2, 22}}}, // 3 {100, [4]qrBlockInfo{{1, 20}, {2, 18}, {2, 26}, {4, 16}}}, // 4 {134, [4]qrBlockInfo{{1, 26}, {2, 24}, {4, 18}, {4, 22}}}, // 5 {172, [4]qrBlockInfo{{2, 18}, {4, 16}, {4, 24}, {4, 28}}}, // 6 {196, [4]qrBlockInfo{{2, 20}, {4, 18}, {6, 18}, {5, 26}}}, // 7 {242, [4]qrBlockInfo{{2, 24}, {4, 22}, {6, 22}, {6, 26}}}, // 8 {292, [4]qrBlockInfo{{2, 30}, {5, 22}, {8, 20}, {8, 24}}}, // 9 {346, [4]qrBlockInfo{{4, 18}, {5, 26}, {8, 24}, {8, 28}}}, // 10 {404, [4]qrBlockInfo{{4, 20}, {5, 30}, {8, 28}, {11, 24}}}, // 11 {466, [4]qrBlockInfo{{4, 24}, {8, 22}, {10, 26}, {11, 28}}}, // 12 {532, [4]qrBlockInfo{{4, 26}, {9, 22}, {12, 24}, {16, 22}}}, // 13 {581, [4]qrBlockInfo{{4, 30}, {9, 24}, {16, 20}, {16, 24}}}, // 14 {655, [4]qrBlockInfo{{6, 22}, {10, 24}, {12, 30}, {18, 24}}}, // 15 {733, [4]qrBlockInfo{{6, 24}, {10, 28}, {17, 24}, {16, 30}}}, // 16 {815, [4]qrBlockInfo{{6, 28}, {11, 28}, {16, 28}, {19, 28}}}, // 17 {901, [4]qrBlockInfo{{6, 30}, {13, 26}, {18, 28}, {21, 28}}}, // 18 {991, [4]qrBlockInfo{{7, 28}, {14, 26}, {21, 26}, {25, 26}}}, // 19 {1085, [4]qrBlockInfo{{8, 28}, {16, 26}, {20, 30}, {25, 28}}}, // 20 {1156, [4]qrBlockInfo{{8, 28}, {17, 26}, {23, 28}, {25, 30}}}, // 21 {1258, [4]qrBlockInfo{{9, 28}, {17, 28}, {23, 30}, {34, 24}}}, // 22 {1364, [4]qrBlockInfo{{9, 30}, {18, 28}, {25, 30}, {30, 30}}}, // 23 {1474, [4]qrBlockInfo{{10, 30}, {20, 28}, {27, 30}, {32, 30}}}, // 24 {1588, [4]qrBlockInfo{{12, 26}, {21, 28}, {29, 30}, {35, 30}}}, // 25 {1706, [4]qrBlockInfo{{12, 28}, {23, 28}, {34, 28}, {37, 30}}}, // 26 {1828, [4]qrBlockInfo{{12, 30}, {25, 28}, {34, 30}, {40, 30}}}, // 27 {1921, [4]qrBlockInfo{{13, 30}, {26, 28}, {35, 30}, {42, 30}}}, // 28 {2051, [4]qrBlockInfo{{14, 30}, {28, 28}, {38, 30}, {45, 30}}}, // 29 {2185, [4]qrBlockInfo{{15, 30}, {29, 28}, {40, 30}, {48, 30}}}, // 30 {2323, [4]qrBlockInfo{{16, 30}, {31, 28}, {43, 30}, {51, 30}}}, // 31 {2465, [4]qrBlockInfo{{17, 30}, {33, 28}, {45, 30}, {54, 30}}}, // 32 {2611, [4]qrBlockInfo{{18, 30}, {35, 28}, {48, 30}, {57, 30}}}, // 33 {2761, [4]qrBlockInfo{{19, 30}, {37, 28}, {51, 30}, {60, 30}}}, // 34 {2876, [4]qrBlockInfo{{19, 30}, {38, 28}, {53, 30}, {63, 30}}}, // 35 {3034, [4]qrBlockInfo{{20, 30}, {40, 28}, {56, 30}, {66, 30}}}, // 36 {3196, [4]qrBlockInfo{{21, 30}, {43, 28}, {59, 30}, {70, 30}}}, // 37 {3362, [4]qrBlockInfo{{22, 30}, {45, 28}, {62, 30}, {74, 30}}}, // 38 {3532, [4]qrBlockInfo{{24, 30}, {47, 28}, {65, 30}, {77, 30}}}, // 39 {3706, [4]qrBlockInfo{{25, 30}, {49, 28}, {68, 30}, {81, 30}}}, // 40 } qrAlignPositions = [41][]int32{ {}, // 0 unused {}, // 1 {6, 18}, // 2 {6, 22}, // 3 {6, 26}, // 4 {6, 30}, // 5 {6, 34}, // 6 {6, 22, 38}, // 7 {6, 24, 42}, // 8 {6, 26, 46}, // 9 {6, 28, 50}, // 10 {6, 30, 54}, // 11 {6, 32, 58}, // 12 {6, 34, 62}, // 13 {6, 26, 46, 66}, // 14 {6, 26, 48, 70}, // 15 {6, 26, 50, 74}, // 16 {6, 30, 54, 78}, // 17 {6, 30, 56, 82}, // 18 {6, 30, 58, 86}, // 19 {6, 34, 62, 90}, // 20 {6, 28, 50, 72, 94}, // 21 {6, 26, 50, 74, 98}, // 22 {6, 30, 54, 78, 102}, // 23 {6, 28, 54, 80, 106}, // 24 {6, 32, 58, 84, 110}, // 25 {6, 30, 58, 86, 114}, // 26 {6, 34, 62, 90, 118}, // 27 {6, 26, 50, 74, 98, 122}, // 28 {6, 30, 54, 78, 102, 126}, // 29 {6, 26, 52, 78, 104, 130}, // 30 {6, 30, 56, 82, 108, 134}, // 31 {6, 34, 60, 86, 112, 138}, // 32 {6, 30, 58, 86, 114, 142}, // 33 {6, 34, 62, 90, 118, 146}, // 34 {6, 30, 54, 78, 102, 126, 150}, // 35 {6, 24, 50, 76, 102, 128, 154}, // 36 {6, 28, 54, 80, 106, 132, 158}, // 37 {6, 32, 58, 84, 110, 136, 162}, // 38 {6, 26, 54, 82, 110, 138, 166}, // 39 {6, 30, 58, 86, 114, 142, 170}, // 40 } } func main() { initAppGlobals() initState() dom.ConsoleLog("starting musiquay " | version) dom.FetchText("/__version", func(body string) { if body == "" { return } sv := helpers.JsonGetString(body, "v") if sv == "" { return } sbase := sv for i := 0; i < len(sv); i++ { if sv[i] == '+' { sbase = sv[:i] break } } cbase := version if len(cbase) > 0 && cbase[0] == 'v' { cbase = cbase[1:] } if sbase != cbase { dom.ConsoleLog("version mismatch: local=" | version | " server=" | sv) } }) initLang() loadRelayPolicy() // Always add the host relay as first fallback - it's the musiquay relay serving this page. localRelay := localRelayURL() appSt.defaultRelays = []string{localRelay} | appSt.defaultRelays dom.ConsoleLog("local relay: " | localRelay) appSt.blossomServerURL = localstorage.GetItem(lsKeyBlossomServer) appSt.invidiousURL = localstorage.GetItem(lsKeyInvidiousURL) appSt.campionURL = localstorage.GetItem(lsKeyCampionURL) appSt.devMode = localstorage.GetItem(lsKeyDevMode) == "1" themePref := localstorage.GetItem(lsKeyTheme) if themePref != "" { appSt.isDark = themePref == "dark" } else { appSt.isDark = true } htmlEl := dom.QuerySelector("html") if appSt.isDark { dom.AddClass(htmlEl, "dark") } else { dom.RemoveClass(htmlEl, "dark") } appSt.root = dom.GetElementById("app-root") dom.SetInnerHTML(appSt.root, "") dom.SetAttribute(appSt.root, "data-version", version) savedPK := localstorage.GetItem(lsKeyPubkey) if savedPK != "" { restoreSession(savedPK, 24) } else { showLogin() } } // --- Theme --- func toggleTheme() { el := dom.QuerySelector("html") appSt.isDark = !appSt.isDark if appSt.isDark { dom.AddClass(el, "dark") localstorage.SetItem(lsKeyTheme, "dark") } else { dom.RemoveClass(el, "dark") localstorage.SetItem(lsKeyTheme, "light") } updateThemeIcon() } // logActivity appends an entry to the activity log, newest first. // // The log is a fixed ring presized once to activityLogMax. The obvious form - // activityLog = []*logEntry{e} | activityLog - allocated a fresh // activityLogMax+1 entry backing array on EVERY entry and abandoned the // previous one in whatever arena was current, which at a wasm dispatch entry is // the root arena. Nothing reclaims a root-arena allocation: arenas release // whole frames and a package-level slice lives for the process, so a boot that // logged its few hundred steps leaked a few megabytes and kept leaking for the // life of the tab. The size is known, so the buffer is presized and written // through a cursor instead of ever entering the growth regime. func logActivity(action, summary, detail string) { e := &logEntry{ ts: dom.NowSeconds(), action: action, summary: summary, detail: detail, } if appSt.activityLog == nil { appSt.activityLog = []*logEntry{:activityLogMax} } appSt.activityLog[appSt.activityLogNext%activityLogMax] = e appSt.activityLogNext++ if appSt.logListEl != 0 { prependLogEntry(e) } } // activityLogLen is the number of live entries, capped at activityLogMax. func activityLogLen() (n int32) { if appSt.activityLogNext > activityLogMax { return activityLogMax } return appSt.activityLogNext } // activityEntry returns entry i counted from the newest (0 = newest). func activityEntry(i int32) (e *logEntry) { return appSt.activityLog[(appSt.activityLogNext-1-i)%activityLogMax] } // updateLogBtnHighlight sets the log button color: accent while boot is in // progress, muted otherwise. func updateLogBtnHighlight() { if appSt.logBtn == 0 { return } if appSt.bootInProgress { dom.SetStyle(appSt.logBtn, "color", "var(--accent)") } else { dom.SetStyle(appSt.logBtn, "color", "var(--muted)") } } // bootDone marks bootstrap as complete and transitions the UI from the // activity log page to the feed. Idempotent - extra calls are no-ops. func bootDone() { if !appSt.bootInProgress { return } appSt.bootInProgress = false logActivity("boot", "bootstrap complete", "") updateLogBtnHighlight() if appSt.activePage == "log" { switchPage("feed") } } // formatHMS returns HH:MM:SS local time for unix seconds ts. func formatHMS(ts int64) (s string) { off := int64(dom.TimezoneOffsetSeconds()) local := ts + off if local < 0 { local = 0 } day := local % 86400 h := day / 3600 m := (day % 3600) / 60 sec := day % 60 pad := func(v int64) string { if v < 10 { return "0" | i64toa(v) } return i64toa(v) } return pad(h) | ":" | pad(m) | ":" | pad(sec) } // prependLogEntry inserts a single log entry row at the top of logListEl. // The row shows "HH:MM:SS [action] summary" and toggles detail on click. func prependLogEntry(e *logEntry) { row := dom.CreateElement("div") dom.SetStyle(row, "borderBottom", "1px solid var(--border)") dom.SetStyle(row, "padding", "4px 0") header := dom.CreateElement("div") dom.SetStyle(header, "cursor", "pointer") dom.SetStyle(header, "display", "flex") dom.SetStyle(header, "gap", "8px") dom.SetStyle(header, "alignItems", "baseline") tsSpan := dom.CreateElement("span") dom.SetTextContent(tsSpan, formatHMS(e.ts)) dom.SetStyle(tsSpan, "color", "var(--muted)") dom.SetStyle(tsSpan, "flexShrink", "0") dom.AppendChild(header, tsSpan) actSpan := dom.CreateElement("span") dom.SetTextContent(actSpan, "[" | e.action | "]") dom.SetStyle(actSpan, "color", "var(--accent)") dom.SetStyle(actSpan, "flexShrink", "0") dom.AppendChild(header, actSpan) sumSpan := dom.CreateElement("span") dom.SetTextContent(sumSpan, e.summary) dom.SetStyle(sumSpan, "color", "var(--fg)") dom.SetStyle(sumSpan, "overflow", "hidden") dom.SetStyle(sumSpan, "textOverflow", "ellipsis") dom.SetStyle(sumSpan, "whiteSpace", "nowrap") dom.AppendChild(header, sumSpan) dom.AppendChild(row, header) if e.detail != "" { detailEl := dom.CreateElement("pre") dom.SetTextContent(detailEl, e.detail) dom.SetStyle(detailEl, "display", "none") dom.SetStyle(detailEl, "margin", "4px 0 0 0") dom.SetStyle(detailEl, "padding", "6px 8px") dom.SetStyle(detailEl, "background", "var(--bg2)") dom.SetStyle(detailEl, "color", "var(--muted)") dom.SetStyle(detailEl, "fontSize", "11px") dom.SetStyle(detailEl, "whiteSpace", "pre-wrap") dom.SetStyle(detailEl, "wordBreak", "break-all") dom.SetStyle(detailEl, "borderRadius", "3px") dom.AppendChild(row, detailEl) open := false dom.AddEventListener(header, "click", dom.RegisterCallback(func() { open = !open if open { dom.SetStyle(detailEl, "display", "block") } else { dom.SetStyle(detailEl, "display", "none") } })) } // Insert at top of list. first := dom.FirstChild(appSt.logListEl) if first == 0 { dom.AppendChild(appSt.logListEl, row) } else { dom.InsertBefore(appSt.logListEl, row, first) } } const svgSun = `` const svgMoon = `` func updateThemeIcon() { if appSt.themeBtn == 0 { return // not yet created (login screen) } if appSt.isDark { dom.SetInnerHTML(appSt.themeBtn, svgSun) } else { dom.SetInnerHTML(appSt.themeBtn, svgMoon) } } // --- Login screen --- func isMobile() (ok bool) { ua := dom.UserAgent() n := len(ua) for i := 0; i+7 <= n; i++ { if ua[i:i+7] == "Android" { return true } } for i := 0; i+6 <= n; i++ { if ua[i:i+6] == "Mobile" { return true } } return false } func showAboutModal() { switchPage("about") } // restoreSession resumes the saved identity once the signer worker is up and // the vault is unlocked. The signer is a worker that boots asynchronously, so // a single check races it and used to discard the saved pubkey outright, // leaving the app on the login screen even when the vault was about to be // usable. The saved key is kept: a later unlock can still resume the session. func restoreSession(savedPK string, tries int32) { if tries <= 0 { showLogin() return } initSigner() signer.IsInstalled(func(ok bool) { if !ok { dom.SetTimeout(func() { restoreSession(savedPK, tries-1) }, 250) return } signer.GetVaultStatus(func(status string) { if status != "unlocked" { dom.SetTimeout(func() { restoreSession(savedPK, tries-1) }, 250) return } signer.SwitchIdentity(savedPK, func(ok bool) { if !ok { showLogin() return } appSt.pubhex = savedPK bootstrapEncKey(func() { showApp() }) }) }) }) } func showLogin() { clearChildren(appSt.root) // Allow scrolling on login page (body has overflow:hidden for the app). dom.SetStyle(dom.Body(), "overflow", "auto") wrap := dom.CreateElement("div") dom.SetStyle(wrap, "display", "flex") dom.SetStyle(wrap, "alignItems", "center") dom.SetStyle(wrap, "flexDirection", "column") dom.SetStyle(wrap, "padding", "48px 16px") // musiquay logo. logoDiv := dom.CreateElement("div") dom.SetStyle(logoDiv, "width", "180px") dom.SetStyle(logoDiv, "height", "180px") dom.SetStyle(logoDiv, "marginBottom", "16px") dom.SetStyle(logoDiv, "color", "var(--accent)") dom.FetchText("/musiquay-logo.svg", func(svg string) { dom.SetInnerHTML(logoDiv, svg) svgEl := dom.FirstElementChild(logoDiv) if svgEl != 0 { dom.SetAttribute(svgEl, "width", "100%") dom.SetAttribute(svgEl, "height", "100%") } }) dom.AppendChild(wrap, logoDiv) // Title. h1 := dom.CreateElement("h1") dom.SetTextContent(h1, "MUSIQUAY") dom.SetStyle(h1, "color", "var(--accent)") dom.SetStyle(h1, "fontSize", "48px") dom.SetStyle(h1, "marginBottom", "4px") dom.AppendChild(wrap, h1) verTag := dom.CreateElement("span") dom.SetTextContent(verTag, version) dom.SetStyle(verTag, "color", "var(--muted)") dom.SetStyle(verTag, "fontSize", "12px") dom.AppendChild(wrap, verTag) sub := dom.CreateElement("p") dom.SetStyle(sub, "color", "var(--muted)") dom.SetStyle(sub, "fontSize", "14px") dom.SetStyle(sub, "marginBottom", "32px") dom.SetStyle(sub, "textAlign", "center") dom.SetTextContent(sub, t("subtitle")) dom.AppendChild(wrap, sub) // Show login flow. errEl := dom.CreateElement("div") dom.SetStyle(errEl, "color", "#e55") dom.SetStyle(errEl, "fontSize", "13px") dom.SetStyle(errEl, "marginBottom", "12px") dom.SetStyle(errEl, "minHeight", "18px") dom.AppendChild(wrap, errEl) btn := dom.CreateElement("button") dom.SetTextContent(btn, t("login")) dom.SetAttribute(btn, "type", "button") dom.SetStyle(btn, "padding", "10px 32px") dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(btn, "fontSize", "14px") dom.SetStyle(btn, "background", "var(--accent)") dom.SetStyle(btn, "color", "#fff") dom.SetStyle(btn, "border", "none") dom.SetStyle(btn, "borderRadius", "4px") dom.SetStyle(btn, "cursor", "pointer") dom.AppendChild(wrap, btn) appendLoginFooter(wrap) dom.SetStyle(wrap, "paddingBottom", "48px") dom.AppendChild(appSt.root, wrap) buildStatusBar(false) initSigner() // Auto-login: if signer is ready with exactly one usable identity, skip the login page. signer.IsInstalled(func(ok bool) { if !ok { return } signer.GetVaultStatus(func(status string) { if status != "unlocked" { return } signer.GetPublicKey(func(hex string) { if hex == "" { return } dom.ConsoleLog("[login] auto-login pubkey: " | hex) appSt.pubhex = hex localstorage.SetItem(lsKeyPubkey, appSt.pubhex) clearChildren(appSt.root) bootstrapEncKey(func() { showApp() }) }) }) }) cb := dom.RegisterCallback(func() { signer.GetVaultStatus(func(status string) { if status == "none" || status == "locked" { toggleSignerPanel() return } dom.SetTextContent(btn, t("requesting")) signer.GetPublicKey(func(hex string) { if hex == "" { dom.SetTextContent(errEl, t("err_no_id")) dom.SetTextContent(btn, t("login")) toggleSignerPanel() return } dom.ConsoleLog("[login] manual-login pubkey: " | hex) appSt.pubhex = hex localstorage.SetItem(lsKeyPubkey, appSt.pubhex) clearChildren(appSt.root) bootstrapEncKey(func() { showApp() }) }) }) }) dom.AddEventListener(btn, "click", cb) } func appendLoginFooter(wrap dom.Element) { footer := dom.CreateElement("div") dom.SetStyle(footer, "display", "flex") dom.SetStyle(footer, "flexDirection", "column") dom.SetStyle(footer, "gap", "12px") dom.SetStyle(footer, "marginTop", "24px") dom.SetStyle(footer, "alignItems", "center") // Language chooser. langBox := dom.CreateElement("div") dom.SetStyle(langBox, "display", "flex") dom.SetStyle(langBox, "alignItems", "center") dom.SetStyle(langBox, "gap", "6px") langLabel := dom.CreateElement("span") dom.SetTextContent(langLabel, t("language")) dom.SetStyle(langLabel, "fontSize", "12px") dom.SetStyle(langLabel, "color", "var(--muted)") dom.AppendChild(langBox, langLabel) langSel := dom.CreateElement("select") dom.SetStyle(langSel, "fontFamily", "'Fira Code', monospace") dom.SetStyle(langSel, "fontSize", "12px") dom.SetStyle(langSel, "background", "var(--bg2)") dom.SetStyle(langSel, "color", "var(--fg)") dom.SetStyle(langSel, "border", "1px solid var(--border)") dom.SetStyle(langSel, "borderRadius", "4px") dom.SetStyle(langSel, "padding", "4px 8px") for code, name := range langNames { opt := dom.CreateElement("option") dom.SetAttribute(opt, "value", code) dom.SetTextContent(opt, name) if code == appSt.currentLang { dom.SetAttribute(opt, "selected", "selected") } dom.AppendChild(langSel, opt) } dom.AddEventListener(langSel, "change", dom.RegisterCallback(func() { val := dom.GetProperty(langSel, "value") setLang(val) showLogin() // re-render with new language })) dom.AppendChild(langBox, langSel) dom.AppendChild(footer, langBox) // Theme toggle. themeBox := dom.CreateElement("div") dom.SetStyle(themeBox, "display", "flex") dom.SetStyle(themeBox, "alignItems", "center") dom.SetStyle(themeBox, "gap", "6px") themeLabel := dom.CreateElement("span") dom.SetTextContent(themeLabel, t("theme")) dom.SetStyle(themeLabel, "fontSize", "12px") dom.SetStyle(themeLabel, "color", "var(--muted)") dom.AppendChild(themeBox, themeLabel) themeToggle := dom.CreateElement("button") if appSt.isDark { dom.SetTextContent(themeToggle, t("light")) } else { dom.SetTextContent(themeToggle, t("dark")) } dom.SetStyle(themeToggle, "fontFamily", "'Fira Code', monospace") dom.SetStyle(themeToggle, "fontSize", "12px") dom.SetStyle(themeToggle, "background", "var(--bg2)") dom.SetStyle(themeToggle, "color", "var(--fg)") dom.SetStyle(themeToggle, "border", "1px solid var(--border)") dom.SetStyle(themeToggle, "borderRadius", "4px") dom.SetStyle(themeToggle, "padding", "4px 12px") dom.SetStyle(themeToggle, "cursor", "pointer") dom.AddEventListener(themeToggle, "click", dom.RegisterCallback(func() { toggleTheme() if appSt.isDark { dom.SetTextContent(themeToggle, t("light")) } else { dom.SetTextContent(themeToggle, t("dark")) } })) dom.AppendChild(themeBox, themeToggle) dom.AppendChild(footer, themeBox) // Hard refresh button - clears SW cache and reloads. optBox := dom.CreateElement("div") dom.SetStyle(optBox, "display", "flex") dom.SetStyle(optBox, "alignItems", "center") dom.SetStyle(optBox, "gap", "4px") refreshBtnw1 := dom.CreateElement("button") dom.SetTextContent(refreshBtnw1, "\u21bb") dom.SetStyle(refreshBtnw1, "fontSize", "14px") dom.SetStyle(refreshBtnw1, "background", "transparent") dom.SetStyle(refreshBtnw1, "border", "1px solid var(--border)") dom.SetStyle(refreshBtnw1, "borderRadius", "4px") dom.SetStyle(refreshBtnw1, "color", "var(--muted)") dom.SetStyle(refreshBtnw1, "cursor", "pointer") dom.SetStyle(refreshBtnw1, "padding", "2px 8px") dom.SetStyle(refreshBtnw1, "marginLeft", "4px") dom.AddEventListener(refreshBtnw1, "click", dom.RegisterCallback(func() { dom.HardRefresh() })) dom.AppendChild(optBox, refreshBtnw1) dom.AppendChild(footer, optBox) // Dev mode toggle - disables translation worker. devBox := dom.CreateElement("div") dom.SetStyle(devBox, "display", "flex") dom.SetStyle(devBox, "alignItems", "center") dom.SetStyle(devBox, "gap", "6px") devLabel := dom.CreateElement("span") dom.SetTextContent(devLabel, "dev mode") dom.SetStyle(devLabel, "fontSize", "12px") dom.SetStyle(devLabel, "color", "var(--muted)") dom.AppendChild(devBox, devLabel) devToggle := dom.CreateElement("button") if appSt.devMode { dom.SetTextContent(devToggle, "on") dom.SetStyle(devToggle, "color", "var(--accent)") } else { dom.SetTextContent(devToggle, "off") dom.SetStyle(devToggle, "color", "var(--muted)") } dom.SetStyle(devToggle, "fontFamily", "'Fira Code', monospace") dom.SetStyle(devToggle, "fontSize", "12px") dom.SetStyle(devToggle, "background", "var(--bg2)") dom.SetStyle(devToggle, "border", "1px solid var(--border)") dom.SetStyle(devToggle, "borderRadius", "4px") dom.SetStyle(devToggle, "padding", "4px 12px") dom.SetStyle(devToggle, "cursor", "pointer") dom.AddEventListener(devToggle, "click", dom.RegisterCallback(func() { appSt.devMode = !appSt.devMode if appSt.devMode { localstorage.SetItem(lsKeyDevMode, "1") dom.SetTextContent(devToggle, "on") dom.SetStyle(devToggle, "color", "var(--accent)") } else { localstorage.RemoveItem(lsKeyDevMode) dom.SetTextContent(devToggle, "off") dom.SetStyle(devToggle, "color", "var(--muted)") } })) dom.AppendChild(devBox, devToggle) dom.AppendChild(footer, devBox) dom.AppendChild(wrap, footer) } // --- Sidebar --- const svgCompose = `` const svgFeed = `` const svgChat = `` const svgGear = `` const svgBell = `` // Action button SVGs - same stroke style as sidebar icons. const svgReply = `` const svgQuote = `` const svgRepost = `` const svgReact = `` const svgZap = `` func makeSidebarIcon(svgHTML string, active bool) (e dom.Element) { btn := dom.CreateElement("button") dom.SetStyle(btn, "width", "36px") dom.SetStyle(btn, "height", "36px") dom.SetStyle(btn, "border", "none") dom.SetStyle(btn, "borderRadius", "6px") dom.SetStyle(btn, "cursor", "pointer") dom.SetStyle(btn, "display", "flex") dom.SetStyle(btn, "alignItems", "center") dom.SetStyle(btn, "justifyContent", "center") dom.SetStyle(btn, "padding", "0") dom.SetStyle(btn, "color", "var(--fg)") if active { dom.SetStyle(btn, "background", "var(--accent)") dom.SetStyle(btn, "color", "#fff") } else { dom.SetStyle(btn, "background", "transparent") } dom.SetInnerHTML(btn, svgHTML) return btn } // teardownPage sends CLOSE for page-specific subscriptions on page exit. // stopInlineMedia restores the clickable YouTube thumbnail for any active // Invidious inline embeds. The wrapper's original click handler (registered by // attachYTLightboxes) is still in place so tapping the thumbnail re-embeds. func stopInlineMedia(container dom.Element) { el := dom.QuerySelectorFrom(container, "[data-inv-ytid]") for el != 0 { ytID := dom.GetAttribute(el, "data-inv-ytid") dom.RemoveAttribute(el, "data-inv-ytid") // Clear iframe + controls. dom.ReleaseChildren(el) dom.SetInnerHTML(el, "") // Restore the YouTube thumbnail so the user can tap to re-embed. if ytID != "" { thumbURL := "https://img.youtube.com/vi/" | ytID | "/hqdefault.jpg" img := dom.CreateElement("img") dom.SetAttribute(img, "referrerpolicy", "no-referrer") dom.SetStyle(img, "display", "block") dom.SetStyle(img, "maxWidth", "100%") dom.SetStyle(img, "borderRadius", "8px") dom.SetStyle(img, "objectFit", "contain") setMediaSrc(img, thumbURL) dom.AppendChild(el, img) } dom.SetStyle(el, "cursor", "pointer") el = dom.QuerySelectorFrom(container, "[data-inv-ytid]") } } func teardownPage(page string) { if page == "feed" { stopInlineMedia(appSt.feedContainer) stopInlineMedia(appSt.threadContainer) } if page == "search" { routeMsg("[\"CLOSE\",\"search\"]") appSt.searchSeen = map[string]bool{} // Always restore topBarRight and other hidden elements on exit. if appSt.searchBarActive { deactivateSearchBar(appSt.searchBtnGlobal) } } if page == "notifications" { // Cancel pending timers so their closures don't fire after page exit. if appSt.notifMoreTimer != 0 { dom.ClearTimeout(appSt.notifMoreTimer) appSt.notifMoreTimer = 0 } if appSt.notifRefTimer != 0 { dom.ClearTimeout(appSt.notifRefTimer) appSt.notifRefTimer = 0 } // Release accumulated notif row callbacks to prevent indefinite // growth of the Moxie callback table across multiple page visits. releaseNotifRowCBs() appSt.notifBuilt = false // Clear avatar pending queue; the DOM elements are gone. for pk := range appSt.pendingNotifAvatars { delete(appSt.pendingNotifAvatars, pk) } } } func switchPage(name string) { closeFeedPopover() // Always close thread if open, even when re-selecting the feed tab. if appSt.threadOpen { closeNoteThread() if !appSt.navPop { dom.ReplaceState("/") } } if name == appSt.activePage { return } teardownPage(appSt.activePage) closeProfileNoteSub() if appSt.activePage == "profile" { appSt.profileViewPK = "" } appSt.activePage = name // Ensure top bar is in normal state. dom.SetStyle(appSt.topBackBtn, "display", "none") dom.SetStyle(appSt.feedSelectEl, "display", "none") dom.SetStyle(appSt.pageTitleEl, "display", "none") dom.SetStyle(appSt.notifBarGroup, "display", "none") if name == "feed" { dom.SetStyle(appSt.feedSelectEl, "display", "inline") } else if name == "home" { // The shell's empty page carries no title. } else if name == "notifications" { dom.SetStyle(appSt.notifBarGroup, "display", "flex") } else { dom.SetTextContent(appSt.pageTitleEl, t(name)) dom.SetStyle(appSt.pageTitleEl, "display", "inline") } // Hide all pages. dom.SetStyle(appSt.homePage, "display", "none") dom.SetStyle(appSt.feedPage, "display", "none") dom.SetStyle(appSt.msgPage, "display", "none") dom.SetStyle(appSt.profilePage, "display", "none") dom.SetStyle(appSt.settingsPage, "display", "none") dom.SetStyle(appSt.composePage, "display", "none") dom.SetStyle(appSt.aboutPage, "display", "none") dom.SetStyle(appSt.notifPage, "display", "none") dom.SetStyle(appSt.searchPage, "display", "none") if appSt.logPage != 0 { dom.SetStyle(appSt.logPage, "display", "none") } // Clear sidebar highlights. dom.SetStyle(appSt.sidebarCompose, "background", "transparent") dom.SetStyle(appSt.sidebarCompose, "color", "var(--fg)") dom.SetStyle(appSt.sidebarFeed, "background", "transparent") dom.SetStyle(appSt.sidebarFeed, "color", "var(--fg)") if appSt.sidebarMsg != 0 { dom.SetStyle(appSt.sidebarMsg, "background", "transparent") dom.SetStyle(appSt.sidebarMsg, "color", "var(--fg)") } dom.SetStyle(appSt.sidebarNotif, "background", "transparent") dom.SetStyle(appSt.sidebarNotif, "color", "var(--fg)") dom.SetStyle(appSt.sidebarSettings, "background", "transparent") dom.SetStyle(appSt.sidebarSettings, "color", "var(--fg)") switch name { case "home": dom.SetStyle(appSt.homePage, "display", "block") if !appSt.navPop { dom.PushState("/") } case "compose", "feed", "search": // The kind-1 forum pages are parked in this base: compose, feed, note // threads and search all land on the empty home page. Their page code // (composePage, feedPage, searchPage and the feed worker) stays in the // tree for when the forum is ported. dom.SetStyle(appSt.homePage, "display", "block") if !appSt.navPop { dom.PushState("/") } case "notifications": dom.SetStyle(appSt.notifPage, "display", "block") dom.SetStyle(appSt.sidebarNotif, "background", "var(--accent)") dom.SetStyle(appSt.sidebarNotif, "color", "#fff") appSt.notifUnread = false dom.SetStyle(appSt.notifDot, "display", "none") dom.SetProperty(appSt.contentArea, "scrollTop", "0") if !appSt.notifBuilt { renderNotifPage() } if !appSt.navPop { dom.PushState("/notifications") } case "settings": dom.SetStyle(appSt.settingsPage, "display", "block") dom.SetStyle(appSt.sidebarSettings, "background", "var(--accent)") dom.SetStyle(appSt.sidebarSettings, "color", "#fff") renderSettings() if !appSt.navPop { dom.PushState("/settings") } case "profile": dom.SetStyle(appSt.profilePage, "display", "block") dom.SetStyle(appSt.pageTitleEl, "display", "none") dom.SetStyle(appSt.topBackBtn, "display", "inline") // Profile URL is pushed by showProfile, not here. case "about": dom.SetStyle(appSt.aboutPage, "display", "block") if !appSt.navPop { dom.PushState("/about") } case "log": dom.SetStyle(appSt.logPage, "display", "block") dom.SetTextContent(appSt.pageTitleEl, "activity log") dom.SetStyle(appSt.pageTitleEl, "display", "inline") if !appSt.navPop { dom.PushState("/log") } case "search": dom.SetStyle(appSt.searchPage, "display", "block") dom.SetStyle(appSt.pageTitleEl, "display", "none") // Restore search input value without activating the bar (topBarRight // stays visible). Bar is only activated by explicit user action. if appSt.searchCurrentQ != "" && appSt.searchBtnGlobal != 0 { dom.SetProperty(appSt.searchInputEl, "value", appSt.searchCurrentQ) } if !appSt.navPop { if appSt.searchCurrentQ != "" { dom.PushState("/search?q=" | dom.EncodeURIComponent(appSt.searchCurrentQ)) } else { dom.PushState("/search") } } } } // navigateToPath handles URL-based routing for back/forward and initial load. // fullPath may include a hash fragment, e.g. "/p/npub1...#follows". func navigateToPath(fullPath string) { path := fullPath hashw2 := "" for i := 0; i < len(fullPath); i++ { if fullPath[i] == '#' { path = fullPath[:i] hashw2 = fullPath[i+1:] break } } // Musiquay base: every kind-1 forum route (feed and its relay/follows // modes, note threads, compose, search) lands on the empty home page. The // route parsing and the pages stay in the tree for the port. forumPath := path == "/" || path == "" || path == "/feed" || path == "/feed/follows" || path == "/feed/relays" || (len(path) > 12 && path[:12] == "/feed/relay/") || (len(path) > 3 && path[:3] == "/t/") || path == "/compose" || path == "/search" || (len(path) >= 8 && path[:8] == "/search?") if forumPath { switchPage("home") } else if path == "/settings" { switchPage("settings") } else if path == "/notifications" { switchPage("notifications") } else if path == "/search" || (len(path) >= 8 && path[:8] == "/search?") { // Parse ?q= parameter. q := "" qi := strIndex(path, "?q=") if qi >= 0 { q = path[qi+3:] // URL-decode the query (basic: replace %20 etc.) - use raw for now. } if q != "" { executeSearch(q) } else { switchPage("search") } } else if path == "/about" { switchPage("about") } else if path == "/log" { switchPage("log") } else if len(path) > 3 && path[:3] == "/p/" { pk := npubToHex(path[3:]) if pk != "" { showProfile(pk) if hashw2 != "" { selectProfileTab(hashw2, pk) } } } } func npubToHex(npub string) (s string) { b := helpers.DecodeNpub(npub) if b == nil { return "" } return helpers.HexEncode(b) } func initRouter() { if appSt.routerInited { return } appSt.routerInited = true dom.OnPopState(func(path string) { appSt.navPop = true navigateToPath(path) appSt.navPop = false }) dom.InterceptInternalLinks(func(path string) { navigateToPath(path) }) // Navigate to initial URL if not root. At the root, show the home page // without pushing "/": switchPage pushes the route when navPop is false, // and pushing here would replace the URL this router is about to read - // every deep link (/settings, /notifications) landed on home. path := dom.GetPath() if path != "/" && path != "" { appSt.navPop = true navigateToPath(path) appSt.navPop = false } else { appSt.navPop = true switchPage("home") appSt.navPop = false dom.ReplaceState("/") } } func makeProtoBtn(label string) (e dom.Element) { btn := dom.CreateElement("button") dom.SetTextContent(btn, label) dom.SetStyle(btn, "padding", "6px 8px") dom.SetStyle(btn, "border", "none") dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(btn, "fontSize", "12px") dom.SetStyle(btn, "cursor", "default") dom.SetStyle(btn, "background", "transparent") dom.SetStyle(btn, "color", "var(--fg)") return btn } // notifEventsReset presizes the notification buffer to one slot more than its // cap so that a push never has to grow it. // // The old form grew with push and trimmed by reslicing from either end, so the // backing array doubled and the old one was abandoned in whatever arena was // current - the root arena at a wasm dispatch entry, where nothing reclaims it // (arenas release whole frames and a package-level slice lives for the // process). A busy relay pushed a fresh notifEventsMax-entry array out every // notifEventsMax events, for the life of the tab. With one spare slot the push // fits, the copy-down gives the slot back, and a presized buffer never // allocates again. func notifEventsReset() { appSt.notifEvents = []*nostr.Event{:0:notifEventsMax + 1} } // notifEventsTrim drops the oldest entries down to notifEventsMax in place. // The append path keeps the buffer oldest-first, so the oldest are at the front. func notifEventsTrim() { if len(appSt.notifEvents) > notifEventsMax { drop := int32(len(appSt.notifEvents)) - notifEventsMax for i := int32(0); i < notifEventsMax; i++ { appSt.notifEvents[i] = appSt.notifEvents[drop+i] } appSt.notifEvents = appSt.notifEvents[:notifEventsMax] } } // notifEventsPrepend inserts ev at the front in place. This path keeps the // buffer newest-first, so the entry to drop when it is over the cap is the last // one - trimming from the front here would discard the entry just inserted. The // shift is a backward loop rather than copy so it cannot depend on copy's // overlap behaviour. func notifEventsPrepend(ev *nostr.Event) { if len(appSt.notifEvents) == cap(appSt.notifEvents) { appSt.notifEvents = appSt.notifEvents[:len(appSt.notifEvents)-1] } appSt.notifEvents = appSt.notifEvents[:len(appSt.notifEvents)+1] for i := int32(len(appSt.notifEvents)) - 1; i > 0; i-- { appSt.notifEvents[i] = appSt.notifEvents[i-1] } appSt.notifEvents[0] = ev if len(appSt.notifEvents) > notifEventsMax { appSt.notifEvents = appSt.notifEvents[:notifEventsMax] } } // --- Main app --- func showApp() { dom.SetStyle(dom.Body(), "overflow", "hidden") appSt.appReady = false appSt.bootInProgress = true appSt.activityLog = nil appSt.activityLogNext = 0 appSt.seenOnSubs = map[string]func(){} logActivity("boot", "bootstrap start " | version, "pubkey: " | appSt.pubhex) // Preload NWC connections so zap button can offer NWC path without // requiring a settings-page visit. nwcRefreshList(nil) // Init profile view caches and filter sets. appSt.authorNames = map[string]string{} appSt.authorPics = map[string]string{} appSt.pendingNotes = map[string][]dom.Element{} appSt.pendingNotifAvatars = map[string][]dom.Element{} appSt.seenEvents = map[string]bool{} appSt.fetchedK0 = map[string]bool{} appSt.fetchedK0Old = map[string]bool{} appSt.fetchedK10k = map[string]bool{} appSt.fetchedK10kOld = map[string]bool{} appSt.retryRound = 0 appSt.authorSubPK = map[string]string{} appSt.eventRelays = map[string][]string{} appSt.eventRelaysOld = map[string][]string{} appSt.pendingDeletes = map[string]string{} appSt.noteElements = map[string]dom.Element{} appSt.myFollows = nil appSt.myMutes = nil appSt.followSet = map[string]bool{} appSt.muteSet = map[string]bool{} appSt.profileContentCache = map[string]string{} appSt.profileFollowsCache = map[string][]string{} appSt.profileMutesCache = map[string][]string{} appSt.profileRelaysCache = map[string][]string{} appSt.profileNotesSeen = map[string]bool{} appSt.profileTabBtns = map[string]dom.Element{} appSt.profileWrappers = map[string]dom.Element{} appSt.profileWrapTabCont = map[string]dom.Element{} appSt.profileWrapTabs = map[string]map[string]dom.Element{} appSt.profileWrapTabSel = map[string]string{} appSt.profileWrapOrder = nil appSt.embedCallbacks = map[string][]string{} appSt.embedRelayHints = map[string][]string{} appSt.embedRequested = map[string]bool{} appSt.embedSubIDs = map[string][]string{} appSt.embedCoordCBs = map[string][]string{} appSt.embedCoordSubIDs = map[string]string{} appSt.threadEvents = map[string]*nostr.Event{} appSt.replyCache = map[string]string{} appSt.replyAvatarCache = map[string]string{} appSt.replyLineCache = map[string]string{} appSt.replyAuthorMap = map[string]string{} appSt.replyAuthorMapOld = map[string]string{} appSt.replyPending = map[string][]dom.Element{} appSt.replyHints = map[string]string{} appSt.replyNeedName = map[string][]dom.Element{} appSt.replyNeedNameOld = map[string][]dom.Element{} appSt.noteRepostCounts = map[string]map[string]bool{} appSt.noteReactionMap = map[string]map[string]map[string]bool{} appSt.noteReactionEls = map[string]dom.Element{} appSt.noteRepostEls = map[string]dom.Element{} appSt.noteZapTotals = map[string]int64{} appSt.noteZapSeen = map[string]map[string]bool{} appSt.noteZapEls = map[string]dom.Element{} appSt.pendingZapRows = map[string][]zapRowPending{} appSt.actionFetchedIDs = map[string]bool{} appSt.eventCache = map[string]*nostr.Event{} // Initialize "old" generation maps so range over nil is safe on first rotate. appSt.eventCacheOld = map[string]*nostr.Event{} appSt.seenEventsOld = map[string]bool{} appSt.noteElementsOld = map[string]dom.Element{} appSt.noteReactionMapOld = map[string]map[string]map[string]bool{} appSt.noteRepostCountsOld = map[string]map[string]bool{} appSt.noteZapSeenOld = map[string]map[string]bool{} appSt.actionFetchedIDsOld = map[string]bool{} appSt.authorNamesOld = map[string]string{} appSt.authorPicsOld = map[string]string{} appSt.embedCallbacksOld = map[string][]string{} appSt.embedRequestedOld = map[string]bool{} appSt.embedSubIDsOld = map[string][]string{} appSt.embedCoordCBsOld = map[string][]string{} appSt.replyCacheOld = map[string]string{} appSt.replyAvatarCacheOld = map[string]string{} appSt.replyLineCacheOld = map[string]string{} appSt.eventCacheCount = 0 appSt.authorCacheCount = 0 appSt.embedCacheCount = 0 appSt.replyCount = 0 appSt.editorDrafts = map[string]string{} appSt.notifSeen = map[string]bool{} notifEventsReset() releaseNotifRowCBs() appSt.notifUnread = false appSt.notifBuilt = false dom.IDBGet("settings", "notif-read-ts", func(val string) { if val != "" { appSt.notifReadTs = parseI64(val) } }) if appSt.feedMode == "" { appSt.feedMode = "follows" } appSt.feedInitialLoad = true appSt.feedBuffer = nil initEmoji() initMentionPopup() // Set up SW + relay-proxy worker communication. Both deliver into the // same dispatch (onSWMessage); message-type prefixes are unique per // source so they cannot collide. if !appSt.routerInited { dom.OnSWMessage(onSWMessage) relayproxy.OnMessage(onSWMessage) profile.OnMessage(onSWMessage) feed.OnMessage(onSWMessage) notif.OnMessage(onSWMessage) } routeMsg("[\"SET_PUBKEY\"," | jstr(appSt.pubhex) | "]") sendEncKeyToSW() // Load cached profiles from IndexedDB. // Populate name/pic view cache and send loaded pks to Profile Worker. var idbLoadedPKs []string dom.IDBGetAll("profiles", func(key, val string) { name := helpers.JsonGetString(val, "name") if len(name) == 0 { name = helpers.JsonGetString(val, "display_name") } pic := helpers.JsonGetString(val, "picture") if len(name) > 0 { setAuthorName(key, name) } if len(pic) > 0 { setAuthorPic(key, pic) } appSt.profileContentCache[key] = val idbLoadedPKs = mxutil.Ensure(idbLoadedPKs, 1) idbLoadedPKs = push(idbLoadedPKs, key) }, func() { appSt.idbLoaded = true // Fill pending note headers rendered before IDB finished. for pk, headers := range appSt.pendingNotes { name, nameOK := lookupAuthorName(pk) if !nameOK || len(name) == 0 { continue } pic, _ := lookupAuthorPic(pk) for _, h := range headers { updateNoteHeader(h, name, pic) } delete(appSt.pendingNotes, pk) } // Tell Profile Worker which pks are already cached so it skips fetching them. if len(idbLoadedPKs) > 0 { profile.Send(`["P_IDB_LOADED",` | buildJSONStrArr(idbLoadedPKs) | `]`) idbLoadedPKs = nil } // Re-render profile page if opened before IDB finished. if appSt.profileViewPK != "" && appSt.activePage == "profile" { renderProfilePage(appSt.profileViewPK) } }) // === Top bar === bar := dom.CreateElement("div") dom.SetStyle(bar, "display", "flex") dom.SetStyle(bar, "alignItems", "center") dom.SetStyle(bar, "padding", "8px") dom.SetStyle(bar, "height", "48px") dom.SetStyle(bar, "boxSizing", "border-box") dom.SetStyle(bar, "background", "var(--bg2)") dom.SetStyle(bar, "position", "fixed") dom.SetStyle(bar, "top", "0") dom.SetStyle(bar, "left", "0") dom.SetStyle(bar, "right", "0") dom.SetStyle(bar, "zIndex", "100") // Left: page title. left := dom.CreateElement("div") dom.SetStyle(left, "display", "flex") dom.SetStyle(left, "alignItems", "center") dom.SetStyle(left, "flex", "1") dom.SetStyle(left, "minWidth", "0") // Logo in top-left. logo := dom.CreateElement("div") dom.SetStyle(logo, "width", "32px") dom.SetStyle(logo, "height", "32px") dom.SetStyle(logo, "flexShrink", "0") dom.SetStyle(logo, "color", "var(--accent)") dom.SetStyle(logo, "marginRight", "8px") dom.FetchText("/musiquay-logo.svg", func(svg string) { appSt.logoSVGCache = svg dom.SetInnerHTML(logo, svg) svgEl := dom.FirstElementChild(logo) if svgEl != 0 { dom.SetAttribute(svgEl, "width", "100%") dom.SetAttribute(svgEl, "height", "100%") } // Also populate the about page logo if it was built before fetch completed. if appSt.aboutLogoEl != 0 { dom.SetInnerHTML(appSt.aboutLogoEl, svg) al := dom.FirstElementChild(appSt.aboutLogoEl) if al != 0 { dom.SetAttribute(al, "width", "100%") dom.SetAttribute(al, "height", "100%") } } }) dom.SetStyle(logo, "cursor", "pointer") dom.AddEventListener(logo, "click", dom.RegisterCallback(func() { showAboutModal() })) dom.AppendChild(left, logo) appSt.topBackBtn = dom.CreateElement("span") dom.SetStyle(appSt.topBackBtn, "display", "none") dom.SetStyle(appSt.topBackBtn, "cursor", "pointer") dom.SetStyle(appSt.topBackBtn, "color", "var(--accent)") dom.SetStyle(appSt.topBackBtn, "fontSize", "18px") dom.SetStyle(appSt.topBackBtn, "fontWeight", "bold") dom.SetTextContent(appSt.topBackBtn, t("back")) dom.AddEventListener(appSt.topBackBtn, "click", dom.RegisterCallback(func() { dom.Back() })) dom.AppendChild(left, appSt.topBackBtn) // Feed selector button (opens popup). Absolute-positioned so it sits at // the horizontal center of the top bar, independent of logo/back width. appSt.feedSelectEl = dom.CreateElement("button") dom.SetStyle(appSt.feedSelectEl, "position", "absolute") dom.SetStyle(appSt.feedSelectEl, "left", "50%") dom.SetStyle(appSt.feedSelectEl, "top", "50%") dom.SetStyle(appSt.feedSelectEl, "transform", "translate(-50%, -50%)") dom.SetStyle(appSt.feedSelectEl, "fontSize", "16px") dom.SetStyle(appSt.feedSelectEl, "fontWeight", "bold") dom.SetStyle(appSt.feedSelectEl, "background", "transparent") dom.SetStyle(appSt.feedSelectEl, "border", "none") dom.SetStyle(appSt.feedSelectEl, "color", "var(--fg)") dom.SetStyle(appSt.feedSelectEl, "cursor", "pointer") dom.SetStyle(appSt.feedSelectEl, "outline", "none") dom.SetStyle(appSt.feedSelectEl, "maxWidth", "200px") dom.SetStyle(appSt.feedSelectEl, "padding", "4px 8px") dom.SetStyle(appSt.feedSelectEl, "borderRadius", "4px") updateFeedBtnText() dom.AddEventListener(appSt.feedSelectEl, "click", dom.RegisterCallback(func() { toggleFeedPopover() })) dom.AppendChild(bar, appSt.feedSelectEl) appSt.notifFilter = "all" appSt.notifBarGroup = dom.CreateElement("div") dom.SetStyle(appSt.notifBarGroup, "position", "absolute") dom.SetStyle(appSt.notifBarGroup, "left", "50%") dom.SetStyle(appSt.notifBarGroup, "top", "50%") dom.SetStyle(appSt.notifBarGroup, "transform", "translate(-50%, -50%)") dom.SetStyle(appSt.notifBarGroup, "display", "none") dom.SetStyle(appSt.notifBarGroup, "alignItems", "center") dom.SetStyle(appSt.notifBarGroup, "gap", "8px") appSt.notifSelectEl = dom.CreateElement("select") dom.SetStyle(appSt.notifSelectEl, "fontSize", "13px") dom.SetStyle(appSt.notifSelectEl, "background", "var(--bg)") dom.SetStyle(appSt.notifSelectEl, "border", "1px solid var(--border)") dom.SetStyle(appSt.notifSelectEl, "borderRadius", "4px") dom.SetStyle(appSt.notifSelectEl, "color", "var(--fg)") dom.SetStyle(appSt.notifSelectEl, "cursor", "pointer") dom.SetStyle(appSt.notifSelectEl, "outline", "none") dom.SetStyle(appSt.notifSelectEl, "padding", "3px 6px") dom.SetStyle(appSt.notifSelectEl, "fontFamily", "'Fira Code', monospace") for _, opt := range []string{"all", "mentions", "reactions", "zaps"} { o := dom.CreateElement("option") dom.SetAttribute(o, "value", opt) dom.SetTextContent(o, opt) dom.AppendChild(appSt.notifSelectEl, o) } dom.AddEventListener(appSt.notifSelectEl, "change", dom.RegisterCallback(func() { appSt.notifFilter = dom.GetProperty(appSt.notifSelectEl, "value") rebuildNotifRows() })) dom.AppendChild(appSt.notifBarGroup, appSt.notifSelectEl) appSt.notifMarkBtn = dom.CreateElement("button") dom.SetTextContent(appSt.notifMarkBtn, "read") dom.SetStyle(appSt.notifMarkBtn, "fontSize", "12px") dom.SetStyle(appSt.notifMarkBtn, "padding", "3px 8px") dom.SetStyle(appSt.notifMarkBtn, "border", "1px solid var(--border)") dom.SetStyle(appSt.notifMarkBtn, "borderRadius", "4px") dom.SetStyle(appSt.notifMarkBtn, "background", "transparent") dom.SetStyle(appSt.notifMarkBtn, "color", "var(--fg)") dom.SetStyle(appSt.notifMarkBtn, "cursor", "pointer") dom.SetStyle(appSt.notifMarkBtn, "fontFamily", "'Fira Code', monospace") dom.AddEventListener(appSt.notifMarkBtn, "click", dom.RegisterCallback(func() { markNotifsRead() })) dom.AppendChild(appSt.notifBarGroup, appSt.notifMarkBtn) dom.AppendChild(bar, appSt.notifBarGroup) appSt.pageTitleEl = dom.CreateElement("span") dom.SetStyle(appSt.pageTitleEl, "fontSize", "18px") dom.SetStyle(appSt.pageTitleEl, "fontWeight", "bold") dom.SetStyle(appSt.pageTitleEl, "display", "none") dom.AppendChild(left, appSt.pageTitleEl) appSt.topBarLeft = left dom.AppendChild(bar, left) // Right: wallet + signer (gear) icon buttons. appSt.topBarRight = dom.CreateElement("div") right := appSt.topBarRight dom.SetStyle(right, "display", "flex") dom.SetStyle(right, "alignItems", "center") dom.SetStyle(right, "gap", "8px") dom.SetStyle(right, "flex", "1") dom.SetStyle(right, "justifyContent", "flex-end") appSt.walletBtnEl = dom.CreateElement("button") dom.SetInnerHTML(appSt.walletBtnEl, ``) dom.SetAttribute(appSt.walletBtnEl, "title", "wallet") dom.SetStyle(appSt.walletBtnEl, "background", "transparent") dom.SetStyle(appSt.walletBtnEl, "border", "none") dom.SetStyle(appSt.walletBtnEl, "color", "var(--muted)") dom.SetStyle(appSt.walletBtnEl, "height", "32px") dom.SetStyle(appSt.walletBtnEl, "width", "32px") dom.SetStyle(appSt.walletBtnEl, "display", "flex") dom.SetStyle(appSt.walletBtnEl, "alignItems", "center") dom.SetStyle(appSt.walletBtnEl, "justifyContent", "center") dom.SetStyle(appSt.walletBtnEl, "cursor", "pointer") dom.SetStyle(appSt.walletBtnEl, "borderRadius", "4px") dom.AddEventListener(appSt.walletBtnEl, "click", dom.RegisterCallback(func() { toggleWalletPanel() })) // Wallet moves to bottom-bar icon group; refreshBtn lives here in top-right. _ = appSt.walletBtnEl dom.AppendChild(bar, right) dom.AppendChild(appSt.root, bar) // === Main layout: content only (icons moved to bottom bar) === mainLayout := dom.CreateElement("div") dom.SetStyle(mainLayout, "position", "fixed") dom.SetStyle(mainLayout, "top", "48px") dom.SetStyle(mainLayout, "bottom", "36px") dom.SetStyle(mainLayout, "left", "0") dom.SetStyle(mainLayout, "right", "0") dom.SetStyle(mainLayout, "display", "flex") appSt.sidebarCompose = makeSidebarIcon(svgCompose, false) dom.AddEventListener(appSt.sidebarCompose, "click", dom.RegisterCallback(func() { switchPage("compose") })) appSt.sidebarFeed = makeSidebarIcon(svgFeed, true) dom.AddEventListener(appSt.sidebarFeed, "click", dom.RegisterCallback(func() { switchPage("feed") })) // Messaging is disabled until the MLS/DM fix lands: no sidebar entry, no // messaging route, and relay-proxy MLS routing is off. See initMessaging. appSt.sidebarNotif = dom.CreateElement("button") dom.SetStyle(appSt.sidebarNotif, "width", "36px") dom.SetStyle(appSt.sidebarNotif, "height", "36px") dom.SetStyle(appSt.sidebarNotif, "border", "none") dom.SetStyle(appSt.sidebarNotif, "borderRadius", "6px") dom.SetStyle(appSt.sidebarNotif, "cursor", "pointer") dom.SetStyle(appSt.sidebarNotif, "display", "flex") dom.SetStyle(appSt.sidebarNotif, "alignItems", "center") dom.SetStyle(appSt.sidebarNotif, "justifyContent", "center") dom.SetStyle(appSt.sidebarNotif, "padding", "0") dom.SetStyle(appSt.sidebarNotif, "background", "transparent") dom.SetStyle(appSt.sidebarNotif, "color", "var(--fg)") dom.SetStyle(appSt.sidebarNotif, "position", "relative") dom.SetInnerHTML(appSt.sidebarNotif, svgBell) appSt.notifDot = dom.CreateElement("span") dom.SetStyle(appSt.notifDot, "position", "absolute") dom.SetStyle(appSt.notifDot, "top", "4px") dom.SetStyle(appSt.notifDot, "right", "5px") dom.SetStyle(appSt.notifDot, "width", "8px") dom.SetStyle(appSt.notifDot, "height", "8px") dom.SetStyle(appSt.notifDot, "borderRadius", "50%") dom.SetStyle(appSt.notifDot, "background", "var(--accent)") dom.SetStyle(appSt.notifDot, "display", "none") dom.AppendChild(appSt.sidebarNotif, appSt.notifDot) dom.AddEventListener(appSt.sidebarNotif, "click", dom.RegisterCallback(func() { switchPage("notifications") })) appSt.sidebarSettings = makeSidebarIcon(svgGear, false) dom.AddEventListener(appSt.sidebarSettings, "click", dom.RegisterCallback(func() { switchPage("settings") })) appSt.themeBtn = dom.CreateElement("button") dom.SetStyle(appSt.themeBtn, "background", "transparent") dom.SetStyle(appSt.themeBtn, "border", "none") dom.SetStyle(appSt.themeBtn, "width", "32px") dom.SetStyle(appSt.themeBtn, "height", "32px") dom.SetStyle(appSt.themeBtn, "cursor", "pointer") dom.SetStyle(appSt.themeBtn, "padding", "0") dom.SetStyle(appSt.themeBtn, "display", "flex") dom.SetStyle(appSt.themeBtn, "alignItems", "center") dom.SetStyle(appSt.themeBtn, "justifyContent", "center") dom.SetStyle(appSt.themeBtn, "color", "var(--muted)") updateThemeIcon() dom.AddEventListener(appSt.themeBtn, "click", dom.RegisterCallback(func() { toggleTheme() })) // Content area. appSt.contentArea = dom.CreateElement("div") dom.SetAttribute(appSt.contentArea, "id", "content-area") dom.SetStyle(appSt.contentArea, "flex", "1") dom.SetStyle(appSt.contentArea, "minHeight", "0") dom.SetStyle(appSt.contentArea, "overflowY", "auto") // Home page - the shell's empty main area. appSt.homePage = dom.CreateElement("div") dom.SetAttribute(appSt.homePage, "id", "home-page") dom.SetStyle(appSt.homePage, "display", "none") dom.SetStyle(appSt.homePage, "padding", "16px") dom.SetStyle(appSt.homePage, "maxWidth", "640px") dom.SetStyle(appSt.homePage, "margin", "0 auto") dom.SetStyle(appSt.homePage, "boxSizing", "border-box") dom.AppendChild(appSt.contentArea, appSt.homePage) // Feed page (parked; see homePage above). appSt.feedPage = dom.CreateElement("div") dom.SetAttribute(appSt.feedPage, "id", "feed-page") dom.SetStyle(appSt.feedPage, "display", "none") dom.SetStyle(appSt.feedPage, "padding", "16px") dom.SetStyle(appSt.feedPage, "maxWidth", "640px") dom.SetStyle(appSt.feedPage, "margin", "0 auto") dom.SetStyle(appSt.feedPage, "boxSizing", "border-box") // Loading spinner - shown until first feed event arrives. appSt.feedLoader = dom.CreateElement("div") dom.SetStyle(appSt.feedLoader, "display", "flex") dom.SetStyle(appSt.feedLoader, "flexDirection", "column") dom.SetStyle(appSt.feedLoader, "alignItems", "center") dom.SetStyle(appSt.feedLoader, "justifyContent", "center") dom.SetStyle(appSt.feedLoader, "padding", "64px 0") loaderImg := dom.CreateElement("div") dom.SetStyle(loaderImg, "width", "120px") dom.SetStyle(loaderImg, "height", "120px") dom.FetchText("/musiquay-loader.svg", func(svg string) { dom.SetInnerHTML(loaderImg, svg) svgEl := dom.FirstElementChild(loaderImg) if svgEl != 0 { dom.SetAttribute(svgEl, "width", "100%") dom.SetAttribute(svgEl, "height", "100%") } }) dom.AppendChild(appSt.feedLoader, loaderImg) loaderText := dom.CreateElement("div") dom.SetTextContent(loaderText, t("connecting")) dom.SetStyle(loaderText, "marginTop", "16px") dom.SetStyle(loaderText, "color", "var(--muted)") dom.SetStyle(loaderText, "fontSize", "14px") dom.AppendChild(appSt.feedLoader, loaderText) dom.AppendChild(appSt.feedPage, appSt.feedLoader) // Pull-to-refresh indicator (hidden until user pulls at top). pullIndicator := dom.CreateElement("div") dom.SetStyle(pullIndicator, "display", "none") dom.SetStyle(pullIndicator, "justifyContent", "center") dom.SetStyle(pullIndicator, "alignItems", "center") dom.SetStyle(pullIndicator, "padding", "12px 0") dom.SetStyle(pullIndicator, "color", "var(--muted)") dom.SetStyle(pullIndicator, "fontSize", "13px") dom.SetTextContent(pullIndicator, "\u21bb refreshing...") dom.AppendChild(appSt.feedPage, pullIndicator) appSt.feedContainer = dom.CreateElement("div") dom.AppendChild(appSt.feedPage, appSt.feedContainer) // "Load more" button - fallback for infinite scroll. appSt.loadMoreBtn = dom.CreateElement("div") dom.SetStyle(appSt.loadMoreBtn, "display", "none") dom.SetStyle(appSt.loadMoreBtn, "textAlign", "center") dom.SetStyle(appSt.loadMoreBtn, "padding", "16px 0") loadMoreLink := dom.CreateElement("span") dom.SetTextContent(loadMoreLink, "load more") dom.SetStyle(loadMoreLink, "cursor", "pointer") dom.SetStyle(loadMoreLink, "color", "var(--accent)") dom.SetStyle(loadMoreLink, "fontSize", "14px") dom.AddEventListener(loadMoreLink, "click", dom.RegisterCallback(func() { if !appSt.feedLoading && !appSt.feedExhausted && appSt.oldestFeedTs > 0 { dom.SetStyle(appSt.loadMoreBtn, "display", "none") loadOlderFeed() } })) dom.AppendChild(appSt.loadMoreBtn, loadMoreLink) // Thread view (hidden overlay within feedPage). appSt.threadPage = dom.CreateElement("div") dom.SetAttribute(appSt.threadPage, "id", "thread-page") dom.SetStyle(appSt.threadPage, "display", "none") appSt.threadContainer = dom.CreateElement("div") dom.AppendChild(appSt.threadPage, appSt.threadContainer) dom.AppendChild(appSt.feedPage, appSt.threadPage) // Load-more after thread so it sits at the bottom in both views. dom.AppendChild(appSt.feedPage, appSt.loadMoreBtn) dom.AppendChild(appSt.contentArea, appSt.feedPage) // Pull-to-refresh: wheel up or touch pull at top triggers feed reload. dom.OnPullRefresh(appSt.contentArea, pullIndicator, func() { if appSt.activePage == "feed" && !appSt.refreshSpinning { onRefreshClick() dom.SetTimeout(func() { dom.SetStyle(pullIndicator, "display", "none") }, 1500) } else if appSt.activePage == "notifications" { dom.SetProperty(appSt.contentArea, "scrollTop", "0") dom.SetTimeout(func() { dom.SetStyle(pullIndicator, "display", "none") }, 1500) } }) // Infinite scroll - load older events when near bottom. dom.AddEventListener(appSt.contentArea, "scroll", dom.RegisterCallback(func() { st := dom.GetProperty(appSt.contentArea, "scrollTop") ch := dom.GetProperty(appSt.contentArea, "clientHeight") sh := dom.GetProperty(appSt.contentArea, "scrollHeight") top := parseIntProp(st) height := parseIntProp(ch) total := parseIntProp(sh) nearBottom := top+height >= total-400 if appSt.activePage == "feed" { if top <= 10 && len(appSt.feedBuffer) > 0 { for _, bev := range appSt.feedBuffer { renderNote(bev) appSt.seenEvents[bev.ID] = true } appSt.feedBuffer = nil appSt.feedHasNew = false stopRefreshPulse() } if appSt.feedLoading || appSt.feedExhausted || appSt.oldestFeedTs == 0 { return } if nearBottom { loadOlderFeed() } } else if appSt.activePage == "notifications" { if appSt.notifLoading || appSt.notifExhausted || appSt.oldestNotifTs == 0 { return } if nearBottom { loadOlderNotifs() } } else if appSt.activePage == "search" { if nearBottom { appendSearchResults() } } })) // Messaging page. appSt.msgPage = dom.CreateElement("div") dom.SetAttribute(appSt.msgPage, "id", "msg-page") dom.SetStyle(appSt.msgPage, "padding", "16px") dom.SetStyle(appSt.msgPage, "display", "none") dom.SetStyle(appSt.msgPage, "position", "relative") dom.SetStyle(appSt.msgPage, "height", "100%") dom.SetStyle(appSt.msgPage, "boxSizing", "border-box") // Conversation list view. appSt.msgListContainer = dom.CreateElement("div") dom.AppendChild(appSt.msgPage, appSt.msgListContainer) // Thread view (hidden by default). appSt.msgThreadContainer = dom.CreateElement("div") dom.SetStyle(appSt.msgThreadContainer, "display", "none") dom.SetStyle(appSt.msgThreadContainer, "flexDirection", "column") dom.SetStyle(appSt.msgThreadContainer, "position", "absolute") dom.SetStyle(appSt.msgThreadContainer, "top", "0") dom.SetStyle(appSt.msgThreadContainer, "left", "0") dom.SetStyle(appSt.msgThreadContainer, "right", "0") dom.SetStyle(appSt.msgThreadContainer, "bottom", "0") dom.SetStyle(appSt.msgThreadContainer, "background", "var(--bg)") dom.AppendChild(appSt.msgPage, appSt.msgThreadContainer) appSt.msgView = "list" dom.AppendChild(appSt.contentArea, appSt.msgPage) // Profile page. appSt.profilePage = dom.CreateElement("div") dom.SetAttribute(appSt.profilePage, "id", "profile-page") dom.SetStyle(appSt.profilePage, "display", "none") dom.SetStyle(appSt.profilePage, "padding", "16px") dom.SetStyle(appSt.profilePage, "maxWidth", "640px") dom.SetStyle(appSt.profilePage, "margin", "0 auto") dom.SetStyle(appSt.profilePage, "boxSizing", "border-box") dom.AppendChild(appSt.contentArea, appSt.profilePage) // Settings page. appSt.settingsPage = dom.CreateElement("div") dom.SetAttribute(appSt.settingsPage, "id", "settings-page") dom.SetStyle(appSt.settingsPage, "display", "none") dom.SetStyle(appSt.settingsPage, "padding", "16px") dom.SetStyle(appSt.settingsPage, "maxWidth", "640px") dom.SetStyle(appSt.settingsPage, "margin", "0 auto") dom.SetStyle(appSt.settingsPage, "boxSizing", "border-box") dom.AppendChild(appSt.contentArea, appSt.settingsPage) // Compose page. appSt.composePage = dom.CreateElement("div") dom.SetAttribute(appSt.composePage, "id", "compose-page") dom.SetStyle(appSt.composePage, "display", "none") dom.SetStyle(appSt.composePage, "padding", "16px") dom.SetStyle(appSt.composePage, "maxWidth", "640px") dom.SetStyle(appSt.composePage, "margin", "0 auto") dom.SetStyle(appSt.composePage, "minHeight", "100%") dom.SetStyle(appSt.composePage, "boxSizing", "border-box") buildComposePage() dom.AppendChild(appSt.contentArea, appSt.composePage) // About page. appSt.aboutPage = dom.CreateElement("div") dom.SetAttribute(appSt.aboutPage, "id", "about-page") dom.SetStyle(appSt.aboutPage, "display", "none") dom.SetStyle(appSt.aboutPage, "padding", "24px 16px") dom.SetStyle(appSt.aboutPage, "maxWidth", "640px") dom.SetStyle(appSt.aboutPage, "margin", "0 auto") dom.SetStyle(appSt.aboutPage, "boxSizing", "border-box") dom.SetStyle(appSt.aboutPage, "textAlign", "center") aRefresh := dom.CreateElement("button") dom.SetTextContent(aRefresh, "hard refresh") dom.SetStyle(aRefresh, "fontFamily", "'Fira Code', monospace") dom.SetStyle(aRefresh, "fontSize", "13px") dom.SetStyle(aRefresh, "background", "transparent") dom.SetStyle(aRefresh, "border", "1px solid var(--border)") dom.SetStyle(aRefresh, "borderRadius", "4px") dom.SetStyle(aRefresh, "color", "var(--muted)") dom.SetStyle(aRefresh, "cursor", "pointer") dom.SetStyle(aRefresh, "padding", "6px 16px") dom.SetStyle(aRefresh, "marginBottom", "16px") dom.AddEventListener(aRefresh, "click", dom.RegisterCallback(func() { dom.HardRefresh() })) dom.AppendChild(appSt.aboutPage, aRefresh) appSt.aboutLogoEl = dom.CreateElement("div") dom.SetStyle(appSt.aboutLogoEl, "width", "180px") dom.SetStyle(appSt.aboutLogoEl, "height", "180px") dom.SetStyle(appSt.aboutLogoEl, "margin", "0 auto 16px auto") dom.SetStyle(appSt.aboutLogoEl, "color", "var(--accent)") if appSt.logoSVGCache != "" { dom.SetInnerHTML(appSt.aboutLogoEl, appSt.logoSVGCache) aLogoSvg := dom.FirstElementChild(appSt.aboutLogoEl) if aLogoSvg != 0 { dom.SetAttribute(aLogoSvg, "width", "100%") dom.SetAttribute(aLogoSvg, "height", "100%") } } dom.AppendChild(appSt.aboutPage, appSt.aboutLogoEl) aTitle := dom.CreateElement("h2") dom.SetTextContent(aTitle, "MUSIQUAY " | version) dom.SetStyle(aTitle, "fontSize", "20px") dom.SetStyle(aTitle, "margin", "0 0 20px 0") dom.SetStyle(aTitle, "color", "var(--accent)") dom.AppendChild(appSt.aboutPage, aTitle) aDevLabel := dom.CreateElement("p") dom.SetTextContent(aDevLabel, t("developed_by")) dom.SetStyle(aDevLabel, "fontSize", "12px") dom.SetStyle(aDevLabel, "color", "var(--muted)") dom.SetStyle(aDevLabel, "marginBottom", "4px") dom.AppendChild(appSt.aboutPage, aDevLabel) aNpub := dom.CreateElement("p") dom.SetStyle(aNpub, "fontSize", "11px") dom.SetStyle(aNpub, "color", "var(--fg)") dom.SetStyle(aNpub, "wordBreak", "break-all") dom.SetStyle(aNpub, "marginBottom", "20px") dom.SetStyle(aNpub, "fontFamily", "'Fira Code', monospace") dom.SetTextContent(aNpub, "npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku") dom.AppendChild(appSt.aboutPage, aNpub) aTagline := dom.CreateElement("p") dom.SetStyle(aTagline, "fontSize", "16px") dom.SetStyle(aTagline, "fontWeight", "bold") dom.SetStyle(aTagline, "color", "var(--fg)") dom.SetStyle(aTagline, "marginBottom", "12px") dom.SetTextContent(aTagline, t("tagline")) dom.AppendChild(appSt.aboutPage, aTagline) aFunny := dom.CreateElement("p") dom.SetStyle(aFunny, "fontSize", "13px") dom.SetStyle(aFunny, "color", "var(--fg)") dom.SetStyle(aFunny, "marginBottom", "20px") dom.SetStyle(aFunny, "lineHeight", "1.6") dom.SetTextContent(aFunny, t("about_donkey")) dom.AppendChild(appSt.aboutPage, aFunny) aLud := dom.CreateElement("span") dom.SetStyle(aLud, "fontSize", "13px") dom.SetStyle(aLud, "color", "var(--accent)") dom.SetStyle(aLud, "marginBottom", "24px") dom.SetStyle(aLud, "cursor", "pointer") dom.SetStyle(aLud, "display", "inline-block") dom.SetTextContent(aLud, "\xe2\x9a\xa1 leeringidol57@walletofsatoshi.com") dom.AddEventListener(aLud, "click", dom.RegisterCallback(func() { showQRModal("leeringidol57@walletofsatoshi.com") })) dom.AppendChild(appSt.aboutPage, aLud) feliceLink := dom.CreateElement("a") dom.SetAttribute(feliceLink, "href", "https://git.smesh.lol/felice/raw/felice.apk") dom.SetAttribute(feliceLink, "target", "_blank") dom.SetStyle(feliceLink, "display", "block") dom.SetStyle(feliceLink, "color", "var(--accent)") dom.SetStyle(feliceLink, "fontSize", "13px") dom.SetStyle(feliceLink, "marginBottom", "4px") dom.SetTextContent(feliceLink, "download felice") dom.AppendChild(appSt.aboutPage, feliceLink) feliceDesc := dom.CreateElement("p") dom.SetStyle(feliceDesc, "fontSize", "11px") dom.SetStyle(feliceDesc, "color", "var(--muted)") dom.SetStyle(feliceDesc, "lineHeight", "1.5") dom.SetStyle(feliceDesc, "marginBottom", "24px") dom.SetStyle(feliceDesc, "maxWidth", "400px") dom.SetStyle(feliceDesc, "margin", "0 auto 24px auto") dom.SetTextContent(feliceDesc, "fork of fennec-fdroid to use smesh.lol and musiquay signer extension on android without the mozilla security theatre") dom.AppendChild(appSt.aboutPage, feliceDesc) dom.AppendChild(appSt.contentArea, appSt.aboutPage) // Activity log page. appSt.logPage = dom.CreateElement("div") dom.SetAttribute(appSt.logPage, "id", "log-page") dom.SetStyle(appSt.logPage, "display", "none") dom.SetStyle(appSt.logPage, "padding", "12px") dom.SetStyle(appSt.logPage, "maxWidth", "780px") dom.SetStyle(appSt.logPage, "margin", "0 auto") dom.SetStyle(appSt.logPage, "boxSizing", "border-box") dom.SetStyle(appSt.logPage, "fontFamily", "'Fira Code', monospace") dom.SetStyle(appSt.logPage, "fontSize", "12px") appSt.logListEl = dom.CreateElement("div") dom.AppendChild(appSt.logPage, appSt.logListEl) dom.AppendChild(appSt.contentArea, appSt.logPage) // Pre-populate with any entries that accumulated during boot before this DOM was built. for i := activityLogLen() - 1; i >= 0; i-- { prependLogEntry(activityEntry(i)) } // Notifications page. appSt.notifPage = dom.CreateElement("div") dom.SetAttribute(appSt.notifPage, "id", "notif-page") dom.SetStyle(appSt.notifPage, "display", "none") dom.SetStyle(appSt.notifPage, "padding", "16px") dom.SetStyle(appSt.notifPage, "maxWidth", "640px") dom.SetStyle(appSt.notifPage, "margin", "0 auto") dom.SetStyle(appSt.notifPage, "boxSizing", "border-box") appSt.notifContainer = dom.CreateElement("div") dom.AppendChild(appSt.notifPage, appSt.notifContainer) dom.AppendChild(appSt.contentArea, appSt.notifPage) // Search page: flex-column so filter bar stays above scrolling results. appSt.searchPage = dom.CreateElement("div") dom.SetAttribute(appSt.searchPage, "id", "search-page") dom.SetStyle(appSt.searchPage, "display", "none") dom.SetStyle(appSt.searchPage, "maxWidth", "640px") dom.SetStyle(appSt.searchPage, "margin", "0 auto") dom.SetStyle(appSt.searchPage, "width", "100%") // searchFilterBarEl and searchContainer are appended dynamically by buildSearchFilterBar/executeSearch. appSt.searchContainer = dom.CreateElement("div") dom.AppendChild(appSt.searchPage, appSt.searchContainer) dom.AppendChild(appSt.contentArea, appSt.searchPage) dom.AppendChild(mainLayout, appSt.contentArea) dom.AppendChild(appSt.root, mainLayout) appSt.activePage = "log" dom.SetStyle(appSt.logPage, "display", "block") buildStatusBar(true) // Icon group: fixed 4px gap between icons (same as old sidebar), centered // on screen via absolute positioning + translateX(-50%). iconGroup := dom.CreateElement("div") dom.SetStyle(iconGroup, "position", "absolute") dom.SetStyle(iconGroup, "left", "50%") dom.SetStyle(iconGroup, "top", "0") dom.SetStyle(iconGroup, "bottom", "0") dom.SetStyle(iconGroup, "transform", "translateX(-50%)") dom.SetStyle(iconGroup, "display", "flex") dom.SetStyle(iconGroup, "alignItems", "center") dom.SetStyle(iconGroup, "gap", "4px") if int32(appSt.userBtn) != 0 { dom.AppendChild(iconGroup, appSt.userBtn) } // The compose and feed buttons are not part of this base build; the // elements stay created above so switchPage's highlight resets stay valid. dom.AppendChild(iconGroup, appSt.sidebarNotif) dom.AppendChild(iconGroup, appSt.sidebarSettings) _ = appSt.walletBtnEl _ = appSt.themeBtn dom.AppendChild(appSt.bottomBar, iconGroup) // Activity log button - opens the log page. Highlighted while boot is in progress. appSt.logBtn = dom.CreateElement("button") dom.SetInnerHTML(appSt.logBtn, ``) dom.SetAttribute(appSt.logBtn, "title", "activity log") dom.SetStyle(appSt.logBtn, "background", "transparent") dom.SetStyle(appSt.logBtn, "border", "none") dom.SetStyle(appSt.logBtn, "color", "var(--muted)") dom.SetStyle(appSt.logBtn, "width", "32px") dom.SetStyle(appSt.logBtn, "height", "32px") dom.SetStyle(appSt.logBtn, "cursor", "pointer") dom.SetStyle(appSt.logBtn, "padding", "0") dom.SetStyle(appSt.logBtn, "display", "flex") dom.SetStyle(appSt.logBtn, "alignItems", "center") dom.SetStyle(appSt.logBtn, "justifyContent", "center") dom.AddEventListener(appSt.logBtn, "click", dom.RegisterCallback(func() { switchPage("log") })) dom.AppendChild(appSt.topBarRight, appSt.logBtn) updateLogBtnHighlight() // Search button + input row - leftmost item in topBarRight. appSt.searchSortDesc = true appSt.searchSeen = map[string]bool{} searchBtn := dom.CreateElement("button") dom.SetInnerHTML(searchBtn, ``) dom.SetAttribute(searchBtn, "title", "search") dom.SetStyle(searchBtn, "background", "transparent") dom.SetStyle(searchBtn, "border", "none") dom.SetStyle(searchBtn, "color", "var(--muted)") dom.SetStyle(searchBtn, "height", "32px") dom.SetStyle(searchBtn, "width", "32px") dom.SetStyle(searchBtn, "display", "flex") dom.SetStyle(searchBtn, "alignItems", "center") dom.SetStyle(searchBtn, "justifyContent", "center") dom.SetStyle(searchBtn, "cursor", "pointer") dom.SetStyle(searchBtn, "borderRadius", "4px") dom.SetStyle(searchBtn, "flexShrink", "0") appSt.searchInputEl = dom.CreateElement("input") dom.SetAttribute(appSt.searchInputEl, "type", "text") dom.SetAttribute(appSt.searchInputEl, "placeholder", "#tag or text search") dom.SetStyle(appSt.searchInputEl, "display", "none") dom.SetStyle(appSt.searchInputEl, "flex", "1") dom.SetStyle(appSt.searchInputEl, "minWidth", "0") dom.SetStyle(appSt.searchInputEl, "background", "var(--bg)") dom.SetStyle(appSt.searchInputEl, "color", "var(--fg)") dom.SetStyle(appSt.searchInputEl, "border", "1px solid var(--border)") dom.SetStyle(appSt.searchInputEl, "borderRadius", "4px") dom.SetStyle(appSt.searchInputEl, "padding", "4px 8px") dom.SetStyle(appSt.searchInputEl, "fontSize", "14px") dom.SetStyle(appSt.searchInputEl, "fontFamily", "'Fira Code', monospace") appSt.searchSubmitBtn = dom.CreateElement("button") dom.SetTextContent(appSt.searchSubmitBtn, "\xe2\x86\x92") // → dom.SetStyle(appSt.searchSubmitBtn, "display", "none") dom.SetStyle(appSt.searchSubmitBtn, "background", "var(--accent)") dom.SetStyle(appSt.searchSubmitBtn, "color", "#fff") dom.SetStyle(appSt.searchSubmitBtn, "border", "none") dom.SetStyle(appSt.searchSubmitBtn, "borderRadius", "4px") dom.SetStyle(appSt.searchSubmitBtn, "padding", "4px 10px") dom.SetStyle(appSt.searchSubmitBtn, "cursor", "pointer") dom.SetStyle(appSt.searchSubmitBtn, "fontSize", "16px") dom.SetStyle(appSt.searchSubmitBtn, "flexShrink", "0") // 🔍 toggles search: click to open (highlighted), click again to close. appSt.searchBtnGlobal = searchBtn capturedSearchBtn := searchBtn dom.AddEventListener(searchBtn, "click", dom.RegisterCallback(func() { if appSt.searchBarActive { closeSearch(capturedSearchBtn) } else { activateSearchBar(capturedSearchBtn) } })) submitSearch := func() { q := dom.GetProperty(appSt.searchInputEl, "value") if q != "" { executeSearch(q) } } capturedSubmit := submitSearch dom.AddEventListener(appSt.searchSubmitBtn, "click", dom.RegisterCallback(capturedSubmit)) dom.OnKeydown(appSt.searchInputEl, func(key string, prevent func()) { if key == "Enter" { prevent() capturedSubmit() } else if key == "Escape" { closeSearch(capturedSearchBtn) } }) // Search lives in topBarLeft so it can replace center content when active. dom.AppendChild(appSt.topBarLeft, searchBtn) dom.AppendChild(appSt.topBarLeft, appSt.searchInputEl) dom.AppendChild(appSt.topBarLeft, appSt.searchSubmitBtn) // Load saved relays; fall back to defaults on first run. if !loadRelayList() { logActivity("boot", "loadRelayList empty, using defaults", "") for _, url := range appSt.defaultRelays { addRelay(url, false) } } if len(appSt.relayURLs) == 0 { logActivity("boot", "relayURLs still empty, forcing defaults", "") for _, url := range appSt.defaultRelays { addRelay(url, false) } } { relayList := "" for i, u := range appSt.relayURLs { if i > 0 { relayList = relayList | "\n" } relayList = relayList | u } logActivity("boot", "loaded " | itoa(len(appSt.relayURLs)) | " relays", relayList) } // Tell SW about relays and subscribe. sendWriteRelays() subscribeProfile() logActivity("boot", "subscribed to own profile", "kinds: 0,3,10002,10000,10050 for " | appSt.pubhex) // Feed subscription is deferred until kind 3 (follow list) arrives, // so we don't subscribe with a global filter then immediately // resubscribe when the follow list shows up (which clears the feed). // Fallback: if no kind 3 arrives within 3s, subscribe anyway. appSt.feedDeferTimer = dom.SetTimeout(func() { appSt.feedDeferTimer = 0 if !appSt.feedSubscribed { appSt.feedSubscribed = true logActivity("boot", "feed defer timeout, subscribing anyway", "no kind 3 in 3s") subscribeFeed() subscribeNotifications() } bootDone() }, 3000) // Publish any kind 0 queued during identity derivation (relays are now live). flushPendingK0() // Wire up browser history navigation. initRouter() // Check for signer extension. initSigner() // Scroll-to-top floaty button. scrollTopBtn := dom.CreateElement("button") dom.SetStyle(scrollTopBtn, "position", "fixed") dom.SetStyle(scrollTopBtn, "bottom", "48px") dom.SetStyle(scrollTopBtn, "right", "12px") dom.SetStyle(scrollTopBtn, "width", "36px") dom.SetStyle(scrollTopBtn, "height", "36px") dom.SetStyle(scrollTopBtn, "borderRadius", "50%") dom.SetStyle(scrollTopBtn, "background", "var(--bg2)") dom.SetStyle(scrollTopBtn, "color", "var(--fg)") dom.SetStyle(scrollTopBtn, "border", "1px solid var(--border)") dom.SetStyle(scrollTopBtn, "cursor", "pointer") dom.SetStyle(scrollTopBtn, "display", "none") dom.SetStyle(scrollTopBtn, "alignItems", "center") dom.SetStyle(scrollTopBtn, "justifyContent", "center") dom.SetStyle(scrollTopBtn, "zIndex", "99") dom.SetStyle(scrollTopBtn, "padding", "0") dom.SetInnerHTML(scrollTopBtn, ``) dom.AddEventListener(scrollTopBtn, "click", dom.RegisterCallback(func() { dom.SetProperty(appSt.contentArea, "scrollTop", "0") })) dom.AppendChild(dom.Body(), scrollTopBtn) dom.AddEventListener(appSt.contentArea, "scroll", dom.RegisterCallback(func() { st := parseIntProp(dom.GetProperty(appSt.contentArea, "scrollTop")) show := st > 300 && (appSt.activePage == "feed" || appSt.activePage == "notifications" || appSt.activePage == "thread") if show { dom.SetStyle(scrollTopBtn, "display", "inline-flex") } else { dom.SetStyle(scrollTopBtn, "display", "none") } })) appSt.appReady = true } // buildStatusBar creates the bottom bar, relay popover, and signer popover. // withUser=true adds the avatar+name section (main app). func buildStatusBar(withUser bool) { appSt.bottomBar = dom.CreateElement("div") dom.SetStyle(appSt.bottomBar, "position", "fixed") dom.SetStyle(appSt.bottomBar, "bottom", "0") dom.SetStyle(appSt.bottomBar, "left", "0") dom.SetStyle(appSt.bottomBar, "right", "0") dom.SetStyle(appSt.bottomBar, "height", "36px") dom.SetStyle(appSt.bottomBar, "display", "flex") dom.SetStyle(appSt.bottomBar, "alignItems", "center") dom.SetStyle(appSt.bottomBar, "padding", "0 6px") dom.SetStyle(appSt.bottomBar, "gap", "8px") dom.SetStyle(appSt.bottomBar, "background", "var(--bg2)") dom.SetStyle(appSt.bottomBar, "fontSize", "12px") dom.SetStyle(appSt.bottomBar, "color", "var(--fg)") dom.SetStyle(appSt.bottomBar, "borderTop", "none") dom.SetStyle(appSt.bottomBar, "zIndex", "100") if withUser { appSt.userBtn = dom.CreateElement("button") dom.SetStyle(appSt.userBtn, "display", "flex") dom.SetStyle(appSt.userBtn, "alignItems", "center") dom.SetStyle(appSt.userBtn, "justifyContent", "center") dom.SetStyle(appSt.userBtn, "padding", "0") dom.SetStyle(appSt.userBtn, "background", "transparent") dom.SetStyle(appSt.userBtn, "border", "none") dom.SetStyle(appSt.userBtn, "borderRadius", "6px") dom.SetStyle(appSt.userBtn, "cursor", "pointer") dom.SetStyle(appSt.userBtn, "width", "36px") dom.SetStyle(appSt.userBtn, "height", "36px") appSt.avatarEl = dom.CreateElement("img") dom.SetAttribute(appSt.avatarEl, "referrerpolicy", "no-referrer") dom.SetAttribute(appSt.avatarEl, "width", "24") dom.SetAttribute(appSt.avatarEl, "height", "24") dom.SetStyle(appSt.avatarEl, "borderRadius", "50%") dom.SetStyle(appSt.avatarEl, "objectFit", "cover") dom.SetStyle(appSt.avatarEl, "display", "block") dom.SetAttribute(appSt.avatarEl, "onerror", "this.style.display='none'") dom.AppendChild(appSt.userBtn, appSt.avatarEl) // nameEl kept (exists for other code paths) but detached - username // no longer appears in the status bar. appSt.nameEl = dom.CreateElement("span") dom.AddEventListener(appSt.userBtn, "click", dom.RegisterCallback(func() { toggleSignerPanel() })) // userBtn is appended to iconGroup by showApp. } appSt.statusEl = dom.CreateElement("button") dom.SetTextContent(appSt.statusEl, t("connecting")) dom.SetStyle(appSt.statusEl, "fontFamily", "'Fira Code', monospace") dom.SetStyle(appSt.statusEl, "fontSize", "12px") dom.SetStyle(appSt.statusEl, "background", "transparent") dom.SetStyle(appSt.statusEl, "border", "none") dom.SetStyle(appSt.statusEl, "color", "var(--muted)") dom.SetStyle(appSt.statusEl, "cursor", "pointer") dom.SetStyle(appSt.statusEl, "padding", "4px 8px") dom.SetStyle(appSt.statusEl, "borderRadius", "4px") dom.SetStyle(appSt.statusEl, "marginLeft", "auto") dom.AddEventListener(appSt.statusEl, "click", dom.RegisterCallback(func() { togglePopover() })) // statusEl (relays button) is created but not attached - all relay info is // already visible on the settings page. _ = appSt.statusEl dom.AppendChild(appSt.root, appSt.bottomBar) // Relay popover (hidden). appSt.popoverEl = dom.CreateElement("div") dom.SetStyle(appSt.popoverEl, "position", "fixed") dom.SetStyle(appSt.popoverEl, "bottom", "37px") dom.SetStyle(appSt.popoverEl, "right", "12px") dom.SetStyle(appSt.popoverEl, "width", "340px") dom.SetStyle(appSt.popoverEl, "maxWidth", "calc(100vw - 24px)") dom.SetStyle(appSt.popoverEl, "background", "var(--bg2)") dom.SetStyle(appSt.popoverEl, "border", "1px solid var(--border)") dom.SetStyle(appSt.popoverEl, "borderRadius", "8px") dom.SetStyle(appSt.popoverEl, "padding", "12px 16px") dom.SetStyle(appSt.popoverEl, "fontSize", "12px") dom.SetStyle(appSt.popoverEl, "display", "none") dom.SetStyle(appSt.popoverEl, "zIndex", "99") appSt.popoverSummary = dom.CreateElement("div") dom.SetTextContent(appSt.popoverSummary, t("connecting")) dom.SetStyle(appSt.popoverSummary, "marginBottom", "8px") dom.SetStyle(appSt.popoverSummary, "color", "var(--muted)") dom.AppendChild(appSt.popoverEl, appSt.popoverSummary) // Broadcast button - at the bottom of the relay popover. appSt.broadcastRow = dom.CreateElement("div") dom.SetStyle(appSt.broadcastRow, "borderTop", "1px solid var(--border)") dom.SetStyle(appSt.broadcastRow, "marginTop", "8px") dom.SetStyle(appSt.broadcastRow, "paddingTop", "8px") dom.SetStyle(appSt.broadcastRow, "display", "none") appSt.broadcastBtnEl = dom.CreateElement("span") dom.SetTextContent(appSt.broadcastBtnEl, "broadcast profile") dom.SetStyle(appSt.broadcastBtnEl, "cursor", "pointer") dom.SetStyle(appSt.broadcastBtnEl, "color", "var(--accent)") dom.SetStyle(appSt.broadcastBtnEl, "fontFamily", "'Fira Code', monospace") dom.AddEventListener(appSt.broadcastBtnEl, "click", dom.RegisterCallback(func() { if appSt.pubhex == "" { return } dom.SetTextContent(appSt.broadcastBtnEl, "broadcasting...") dom.SetStyle(appSt.broadcastBtnEl, "color", "var(--muted)") msg := "[\"BROADCAST\"," | jstr(appSt.pubhex) | ",[" for i, url := range appSt.relayURLs { if i > 0 { msg = msg | "," } msg = msg | jstr(url) } routeMsg(msg | "]]") })) dom.AppendChild(appSt.broadcastRow, appSt.broadcastBtnEl) dom.AppendChild(appSt.popoverEl, appSt.broadcastRow) if appSt.pubhex != "" { dom.SetStyle(appSt.broadcastRow, "display", "block") } // popoverEl (relays popup) is created but not attached - relays are on the // settings page. Downstream code still mutates the detached element harmlessly. _ = appSt.popoverEl // Signer popover (hidden). buildSignerPanel() dom.AppendChild(appSt.root, appSt.signerPanel) // Wallet popover (hidden). buildWalletPanel() // walletPanel is attached by renderSettings() at the bottom of the settings page. // Feed selector popover (hidden). appSt.feedPopoverEl = dom.CreateElement("div") dom.SetStyle(appSt.feedPopoverEl, "position", "fixed") dom.SetStyle(appSt.feedPopoverEl, "top", "48px") dom.SetStyle(appSt.feedPopoverEl, "left", "50%") dom.SetStyle(appSt.feedPopoverEl, "transform", "translateX(-50%)") dom.SetStyle(appSt.feedPopoverEl, "width", "220px") dom.SetStyle(appSt.feedPopoverEl, "maxWidth", "calc(100vw - 24px)") dom.SetStyle(appSt.feedPopoverEl, "background", "var(--bg2)") dom.SetStyle(appSt.feedPopoverEl, "border", "1px solid var(--border)") dom.SetStyle(appSt.feedPopoverEl, "borderRadius", "8px") dom.SetStyle(appSt.feedPopoverEl, "padding", "8px") dom.SetStyle(appSt.feedPopoverEl, "fontSize", "14px") dom.SetStyle(appSt.feedPopoverEl, "display", "none") dom.SetStyle(appSt.feedPopoverEl, "zIndex", "99") dom.AppendChild(appSt.root, appSt.feedPopoverEl) } // relayCap is a parsed NIP-11 Relay Information Document snapshot. // Values are populated asynchronously after addRelay; fetchedOK=false means // either the fetch hasn't completed yet or the relay was unreachable. type relayCap struct { name string adminPubkey string // hex, from NIP-11 "pubkey" field (operator identity) supportedNIPs []int32 // NIP-11 "supported_nips" array authRequired bool // limitation.auth_required paymentReq bool // limitation.payment_required restrictedW bool // limitation.restricted_writes fetchedOK bool // true once a non-empty NIP-11 document has been parsed } // hasNIP reports whether a supported_nips list contains the given NIP number. func hasNIP(nips []int32, n int32) (ok bool) { for _, x := range nips { if x == n { return true } } return false } // makeRelayBadge creates a small warning pill like "!42" or "!70". func makeRelayBadge(text string) (e dom.Element) { b := dom.CreateElement("span") dom.SetTextContent(b, text) dom.SetStyle(b, "display", "inline-block") dom.SetStyle(b, "background", "#c44") dom.SetStyle(b, "color", "#fff") dom.SetStyle(b, "padding", "1px 5px") dom.SetStyle(b, "borderRadius", "3px") dom.SetStyle(b, "fontSize", "10px") dom.SetStyle(b, "marginLeft", "4px") dom.SetStyle(b, "fontFamily", "'Fira Code', monospace") return b } // updateRelayBadges rewrites the badge container for the relay at index i // based on the current relayCaps[i]. Missing NIP-42 and NIP-70 support each // produces a warning pill. Called from the NIP-11 fetch callback. func updateRelayBadges(i int32) { if i < 0 || i >= len(appSt.relayBadgeContainers) { return } container := appSt.relayBadgeContainers[i] clearChildren(container) caps := appSt.relayCaps[i] if !hasNIP(caps.supportedNIPs, 42) { dom.AppendChild(container, makeRelayBadge("!42")) } if !hasNIP(caps.supportedNIPs, 70) { dom.AppendChild(container, makeRelayBadge("!70")) } if hasNIP(caps.supportedNIPs, 50) { badge := makeRelayBadge("search") dom.SetStyle(badge, "background", "rgba(0,180,80,0.15)") dom.SetStyle(badge, "color", "#0a0") dom.SetStyle(badge, "border", "1px solid rgba(0,180,80,0.3)") dom.AppendChild(container, badge) } } // parseNIP11Caps parses a NIP-11 Relay Information Document body into a relayCap. // Returns a zero value (fetchedOK=false) for empty input. func parseNIP11Caps(body string) (v relayCap) { var c relayCap if body == "" { return c } c.fetchedOK = true c.name = helpers.JsonGetString(body, "name") c.adminPubkey = helpers.JsonGetString(body, "pubkey") c.supportedNIPs = helpers.JsonGetIntArray(body, "supported_nips") lim := helpers.JsonGetValue(body, "limitation") if lim != "" { c.authRequired = helpers.JsonGetBool(lim, "auth_required") c.paymentReq = helpers.JsonGetBool(lim, "payment_required") c.restrictedW = helpers.JsonGetBool(lim, "restricted_writes") } return c } // loadRelayPolicy reads the persisted relay policy bitfield and blocklist from // localStorage. Called once at startup before any relay logic runs. func loadRelayPolicy() { if s := localstorage.GetItem(lsKeyRelayPolicy); s != "" { appSt.relayPolicyFlags = parseIntProp(s) } if s := localstorage.GetItem(lsKeyRelayBlocklist); s != "" { appSt.relayBlocklist = splitLines(s) } } // saveRelayPolicy persists relayPolicyFlags and relayBlocklist to localStorage. // Called from the settings UI whenever the user changes a toggle or the // blocklist is mutated. Does not publish kind 10006 - that is done explicitly // by the blocklist mutation helpers. func saveRelayPolicy() { localstorage.SetItem(lsKeyRelayPolicy, itoa(appSt.relayPolicyFlags)) localstorage.SetItem(lsKeyRelayBlocklist, joinLines(appSt.relayBlocklist)) } // saveRelayList persists the current relay URLs and roles to localStorage. func saveRelayList() { localstorage.SetItem(lsKeyRelayList, joinLines(appSt.relayURLs)) roles := "" for i, r := range appSt.relayRole { if i > 0 { roles = roles | "\n" } roles = roles | r } localstorage.SetItem(lsKeyRelayRoles, roles) } // loadRelayList reads persisted relay URLs and roles and adds them. // Returns true if any were loaded (so defaults can be skipped). func loadRelayList() (ok bool) { s := localstorage.GetItem(lsKeyRelayList) if s == "" { return false } urls := splitLines(s) roles := splitLines(localstorage.GetItem(lsKeyRelayRoles)) added := 0 for i, u := range urls { if len(u) == 0 { continue } role := "both" if i < len(roles) && (roles[i] == "read" || roles[i] == "write") { role = roles[i] } addRelayWithRole(u, false, role) added++ } return added > 0 } // isBlocked reports whether url (already normalized) is in relayBlocklist. var dnsBlacklist []string func isBlocked(url string) (ok bool) { for _, b := range dnsBlacklist { if b == url { return true } } for _, b := range appSt.relayBlocklist { if b == url { return true } } return false } // removeRelayByURL tears down the popover row for url and splices it out of // all parallel relay slices. No-op if url is not present. func removeRelayByURL(url string) { url = normalizeURL(url) for i, u := range appSt.relayURLs { if u != url { continue } // Detach popover row. dom.RemoveChild(appSt.popoverEl, appSt.relayRows[i]) // Splice all parallel slices. appSt.relayURLs = appSt.relayURLs[:i] | appSt.relayURLs[i+1:] appSt.relayRows = appSt.relayRows[:i] | appSt.relayRows[i+1:] appSt.relayDots = appSt.relayDots[:i] | appSt.relayDots[i+1:] appSt.relayLabels = appSt.relayLabels[:i] | appSt.relayLabels[i+1:] appSt.relayBadgeContainers = appSt.relayBadgeContainers[:i] | appSt.relayBadgeContainers[i+1:] appSt.relayUserPick = appSt.relayUserPick[:i] | appSt.relayUserPick[i+1:] appSt.relayRole = appSt.relayRole[:i] | appSt.relayRole[i+1:] appSt.relayReadBtns = appSt.relayReadBtns[:i] | appSt.relayReadBtns[i+1:] appSt.relayWriteBtns = appSt.relayWriteBtns[:i] | appSt.relayWriteBtns[i+1:] appSt.relayCaps = appSt.relayCaps[:i] | appSt.relayCaps[i+1:] appSt.relayCapsTs = appSt.relayCapsTs[:i] | appSt.relayCapsTs[i+1:] saveRelayList() updateStatus() return } } // addToBlocklist records url in relayBlocklist, tears down any active row for // it, persists, and publishes kind 10006. Called only from explicit UI action. func addToBlocklist(url string) { url = normalizeURL(url) if isBlocked(url) { return } appSt.relayBlocklist = mxutil.Ensure(appSt.relayBlocklist, 1) appSt.relayBlocklist = push(appSt.relayBlocklist, url) removeRelayByURL(url) saveRelayPolicy() publishKind10006() } // removeFromBlocklist unblocks url, persists, and publishes kind 10006. Does // not auto-re-add the relay - user must add it back explicitly if desired. func removeFromBlocklist(url string) { url = normalizeURL(url) for i, b := range appSt.relayBlocklist { if b == url { appSt.relayBlocklist = appSt.relayBlocklist[:i] | appSt.relayBlocklist[i+1:] saveRelayPolicy() publishKind10006() return } } } // publishKind10006 signs and publishes a NIP-51 "Blocked Relays" list. Called // only on explicit blocklist mutation - never from loadRelayPolicy, so merely // loading musiquay does not re-publish the list. func publishKind10006() { if !signer.HasSigner() || appSt.pubhex == "" { return } // Build tags: [["relay","wss://..."], ...] tags := "[" for i, u := range appSt.relayBlocklist { if i > 0 { tags = tags | "," } tags = tags | "[\"relay\"," | jstr(u) | "]" } tags = tags | "]" ts := dom.NowSeconds() unsigned := "{\"kind\":10006,\"content\":\"\",\"tags\":" | tags | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | appSt.pubhex | "\"}" signer.SignEvent(unsigned, func(signed string) { if signed != "" { routeMsg("[\"EVENT\"," | signed | "]") dom.ConsoleLog("[blocklist] published kind 10006 with " | itoa(len(appSt.relayBlocklist)) | " entries") } }) } // publishProtocol signs a musiquay protocol payload and publishes it as a bare // event. The vocabulary renders its own tag list and the envelope is the only // part built here, so this is one place for every public kind. // // The private kinds - the purchase order (3222) and the delivery receipt // (32218) - never reach this helper: they travel only inside a NIP-59 wrap and // a relay rejects them bare. func publishProtocol(kind uint16, tags [][]string, content string) { if !signer.HasSigner() || appSt.pubhex == "" { return } dom.ConsoleLog("[proto] publishing kind " | itoa(int32(kind)) | " (" | protocol.KindName(kind) | ")") ts := dom.NowSeconds() unsigned := protocolwire.UnsignedJSON(kind, protocolwire.TagsJSON(tags), content, appSt.pubhex, ts) signer.SignEvent(unsigned, func(signed string) { if signed != "" { routeMsg("[\"EVENT\"," | signed | "]") } }) } // The named wrappers below are what app code calls; each hands the vocabulary's // own tag list to the one publish path above. func publishTrack(v *protocol.Track) { publishProtocol(protocol.KindTrack, v.Tags(), v.Content) } func publishAlbum(v *protocol.Album) { publishProtocol(protocol.KindAlbum, v.Tags(), v.Content) } func publishPlaylist(v *protocol.Playlist) { publishProtocol(protocol.KindPlaylist, v.Tags(), v.Content) } func publishSetlist(v *protocol.Setlist) { publishProtocol(protocol.KindSetlist, v.Tags(), v.Content) } func publishReleaseCredits(v *protocol.ReleaseCredits) { publishProtocol(protocol.KindReleaseCredits, v.Tags(), v.Content) } func publishTrackCredits(v *protocol.TrackCredits) { publishProtocol(protocol.KindTrackCredits, v.Tags(), v.Content) } func publishEdition(v *protocol.Edition) { publishProtocol(protocol.KindEdition, v.Tags(), v.Content) } func publishAccessFilter(v *protocol.AccessFilter) { publishProtocol(protocol.KindAccessFilter, v.Tags(), v.Content) } func publishPlayReport(v *protocol.PlayReport) { publishProtocol(protocol.KindPlayReport, v.Tags(), v.Content) } func publishCalendarEvent(v *protocol.CalendarEvent) { publishProtocol(protocol.KindCalendar, v.Tags(), v.Content) } func publishListing(v *protocol.Listing) { publishProtocol(protocol.KindListing, v.Tags(), v.Content) } // The NIP-15 payloads are JSON in content with no tag vocabulary; the only tag // is the d that makes the event addressable, and it is the payload's id. func publishStall(v *protocol.Stall) { tags := [][]string{[]string{protocol.TagD, v.ID}} publishProtocol(protocol.KindStall, tags, v.JSON()) } func publishProduct(v *protocol.Product) { tags := [][]string{[]string{protocol.TagD, v.ID}} publishProtocol(protocol.KindProduct, tags, v.JSON()) } // addRelay adds a relay with role "both". func addRelay(url string, userPick bool) { addRelayWithRole(url, userPick, "both") } // applyRelayRoleBtns sets the active/inactive styling on the r/w buttons. func applyRelayRoleBtns(rBtn, wBtn dom.Element, role string) { canRead := role == "both" || role == "read" canWrite := role == "both" || role == "write" if canRead { dom.SetStyle(rBtn, "background", "var(--bg2)") dom.SetStyle(rBtn, "color", "var(--fg)") dom.SetStyle(rBtn, "borderColor", "var(--fg)") } else { dom.SetStyle(rBtn, "background", "transparent") dom.SetStyle(rBtn, "color", "var(--muted)") dom.SetStyle(rBtn, "borderColor", "var(--border)") } if canWrite { dom.SetStyle(wBtn, "background", "var(--bg2)") dom.SetStyle(wBtn, "color", "var(--fg)") dom.SetStyle(wBtn, "borderColor", "var(--fg)") } else { dom.SetStyle(wBtn, "background", "transparent") dom.SetStyle(wBtn, "color", "var(--muted)") dom.SetStyle(wBtn, "borderColor", "var(--border)") } } // addRelayWithRole adds a relay to the list and creates its popover row. // role is "both" (read+write), "read", or "write". func addRelayWithRole(url string, userPick bool, role string) { url = normalizeURL(url) // Blocklist guard - never attach a blocked relay, regardless of source. if isBlocked(url) { return } // Dedup. for i, u := range appSt.relayURLs { if u == url { if userPick && !appSt.relayUserPick[i] { appSt.relayUserPick[i] = true dom.SetStyle(appSt.relayLabels[i], "fontWeight", "bold") } return } } appSt.relayURLs = mxutil.Ensure(appSt.relayURLs, 1) appSt.relayURLs = push(appSt.relayURLs, url) appSt.relayUserPick = mxutil.Ensure(appSt.relayUserPick, 1) appSt.relayUserPick = push(appSt.relayUserPick, userPick) appSt.relayRole = mxutil.Ensure(appSt.relayRole, 1) appSt.relayRole = push(appSt.relayRole, role) appSt.relayCaps = mxutil.Ensure(appSt.relayCaps, 1) appSt.relayCaps = push(appSt.relayCaps, relayCap{}) appSt.relayCapsTs = mxutil.Ensure(appSt.relayCapsTs, 1) appSt.relayCapsTs = push(appSt.relayCapsTs, 0) saveRelayList() // Popover row. row := dom.CreateElement("div") dom.SetStyle(row, "padding", "3px 0") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "4px") appSt.relayRows = mxutil.Ensure(appSt.relayRows, 1) appSt.relayRows = push(appSt.relayRows, row) dot := dom.CreateElement("span") dom.SetTextContent(dot, "\u25CF") dom.SetStyle(dot, "color", "#5b5") dom.SetStyle(dot, "flexShrink", "0") appSt.relayDots = mxutil.Ensure(appSt.relayDots, 1) appSt.relayDots = push(appSt.relayDots, dot) dom.AppendChild(row, dot) label := dom.CreateElement("span") dom.SetTextContent(label, url) dom.SetStyle(label, "flex", "1") dom.SetStyle(label, "overflow", "hidden") dom.SetStyle(label, "textOverflow", "ellipsis") dom.SetStyle(label, "whiteSpace", "nowrap") if userPick { dom.SetStyle(label, "fontWeight", "bold") } appSt.relayLabels = mxutil.Ensure(appSt.relayLabels, 1) appSt.relayLabels = push(appSt.relayLabels, label) dom.AppendChild(row, label) // Separate read / write toggle buttons. makeRoleBtn := func(label string) dom.Element { b := dom.CreateElement("button") dom.SetTextContent(b, label) dom.SetStyle(b, "flexShrink", "0") dom.SetStyle(b, "fontSize", "10px") dom.SetStyle(b, "fontFamily", "'Fira Code', monospace") dom.SetStyle(b, "padding", "1px 5px") dom.SetStyle(b, "borderRadius", "3px") dom.SetStyle(b, "border", "1px solid var(--border)") dom.SetStyle(b, "cursor", "pointer") return b } rBtn := makeRoleBtn("r") wBtn := makeRoleBtn("w") appSt.relayReadBtns = mxutil.Ensure(appSt.relayReadBtns, 1) appSt.relayReadBtns = push(appSt.relayReadBtns, rBtn) appSt.relayWriteBtns = mxutil.Ensure(appSt.relayWriteBtns, 1) appSt.relayWriteBtns = push(appSt.relayWriteBtns, wBtn) applyRelayRoleBtns(rBtn, wBtn, role) capturedURL := url dom.AddEventListener(rBtn, "click", dom.RegisterCallback(func() { for i, u := range appSt.relayURLs { if u != capturedURL { continue } switch appSt.relayRole[i] { case "both": appSt.relayRole[i] = "write" case "read": appSt.relayRole[i] = "write" default: // "write" - re-enable read appSt.relayRole[i] = "both" } applyRelayRoleBtns(appSt.relayReadBtns[i], appSt.relayWriteBtns[i], appSt.relayRole[i]) saveRelayList() sendWriteRelays() break } })) dom.AddEventListener(wBtn, "click", dom.RegisterCallback(func() { for i, u := range appSt.relayURLs { if u != capturedURL { continue } switch appSt.relayRole[i] { case "both": appSt.relayRole[i] = "read" case "write": appSt.relayRole[i] = "read" default: // "read" - re-enable write appSt.relayRole[i] = "both" } applyRelayRoleBtns(appSt.relayReadBtns[i], appSt.relayWriteBtns[i], appSt.relayRole[i]) saveRelayList() sendWriteRelays() break } })) // Empty badge container - populated when NIP-11 fetch completes. badgeBox := dom.CreateElement("span") dom.SetStyle(badgeBox, "flexShrink", "0") appSt.relayBadgeContainers = mxutil.Ensure(appSt.relayBadgeContainers, 1) appSt.relayBadgeContainers = push(appSt.relayBadgeContainers, badgeBox) dom.AppendChild(row, badgeBox) // r/w buttons at far right. dom.SetStyle(rBtn, "marginLeft", "auto") dom.AppendChild(row, rBtn) dom.AppendChild(row, wBtn) dom.InsertBefore(appSt.popoverEl, row, appSt.broadcastRow) updateStatus() if appSt.feedSelectEl != 0 { populateFeedSelect() } // Fetch NIP-11 capability document asynchronously. The callback captures // `url` (a fresh parameter per call, not a loop variable) and looks the // index up at callback time so it still lands correctly if the list grew. httpURL := url if len(httpURL) > 6 && httpURL[:6] == "wss://" { httpURL = "https://" | httpURL[6:] } else if len(httpURL) > 5 && httpURL[:5] == "ws://" { httpURL = "http://" | httpURL[5:] } dom.FetchRelayInfo(httpURL, func(body string) { caps := parseNIP11Caps(body) for i, u := range appSt.relayURLs { if u == url { appSt.relayCaps[i] = caps appSt.relayCapsTs[i] = dom.NowSeconds() updateRelayBadges(i) dom.ConsoleLog("[caps] " | url | " ok=" | boolStr(caps.fetchedOK) | " nip42=" | boolStr(hasNIP(caps.supportedNIPs, 42)) | " nip70=" | boolStr(hasNIP(caps.supportedNIPs, 70)) | " authReq=" | boolStr(caps.authRequired)) return } } }) } func togglePopover() { appSt.popoverOpen = !appSt.popoverOpen if appSt.popoverOpen { if appSt.signerOpen { hideSignerPanel() } closeFeedPopover() dom.SetStyle(appSt.popoverEl, "display", "block") dom.SetStyle(appSt.statusEl, "background", "var(--accent)") dom.SetStyle(appSt.statusEl, "color", "var(--bg)") } else { dom.SetStyle(appSt.popoverEl, "display", "none") dom.SetStyle(appSt.statusEl, "background", "transparent") dom.SetStyle(appSt.statusEl, "color", "var(--muted)") } } func subscribeProfile() { dom.ConsoleLog("[prof] subscribing for pubkey: " | appSt.pubhex) rr := readRelayURLs() proxy := []string{:len(discoveryRelays):len(discoveryRelays)+len(rr)} copy(proxy, discoveryRelays) for _, u := range rr { proxy = appendUnique(proxy, u) } routeMsg(buildProxyMsg("prof", "{\"authors\":[" | jstr(appSt.pubhex) | "],\"kinds\":[0,3,10002,10000,10050],\"limit\":8}", proxy)) routeMsg(buildProxyMsg("prof-settings", "{\"authors\":[" | jstr(appSt.pubhex) | "],\"kinds\":[30078],\"#d\":[\"musiquay-settings\"],\"limit\":1}", proxy)) } func subscribeFeed() { // Throttle: collapse rapid calls into one after 2s of quiet. // Also enforce 10s cooldown - don't resubscribe if nothing changed. appSt.feedSubscribed = true if appSt.feedSubTimer != 0 { dom.ClearTimeout(appSt.feedSubTimer) } appSt.feedSubTimer = dom.SetTimeout(func() { appSt.feedSubTimer = 0 now := dom.NowSeconds() if appSt.feedLastSubTs > 0 && now-appSt.feedLastSubTs < 10 { return } doSubscribeFeed() }, 2000) } func doSubscribeFeed() { // Musiquay base: the kind-1 forum feed is not part of this build. The // subscription and rendering code below stays for the port. return appSt.feedLastSubTs = dom.NowSeconds() dom.ConsoleLog("[sub] feed mode=" | appSt.feedMode | " follows=" | itoa(len(appSt.myFollows))) appSt.feedExhausted = false appSt.feedEmptyStreak = 0 appSt.feedInitialLoad = true if appSt.feedInitialLoadTimer != 0 { dom.ClearTimeout(appSt.feedInitialLoadTimer) appSt.feedInitialLoadTimer = 0 } appSt.feedBuffer = nil appSt.feedHasNew = false stopRefreshPulse() dom.SetStyle(appSt.loadMoreBtn, "display", "none") // Sync state to Feed Worker, then tell it to subscribe. // Feed Worker owns the relay subscriptions via F_SUB/F_CLOSE. feed.Send(`["F_SET_MODE",` | jstr(appSt.feedMode) | `]`) feed.Send(`["F_SET_RELAYS",` | readRelayURLsJSON() | `]`) feed.Send(`["F_SET_PUBKEY",` | jstr(appSt.pubhex) | `]`) if len(appSt.myFollows) > 0 { feed.Send(`["F_SET_FOLLOWS",` | buildJSONStrArr(appSt.myFollows) | `]`) } if len(appSt.myMutes) > 0 { feed.Send(`["F_SET_MUTES",` | buildJSONStrArr(appSt.myMutes) | `]`) } feed.Send(`["F_REFRESH"]`) } func buildFeedFilter(limit int32) (s string) { if appSt.feedMode == "follows" { follows := appSt.myFollows if len(follows) > 0 { // Always include own pubkey so user's own posts appear in the feed. authors := jstr(appSt.pubhex) for _, pk := range follows { if pk != appSt.pubhex { authors = authors | "," | jstr(pk) } } return "{\"kinds\":[1,6,7,1111],\"authors\":[" | authors | "],\"limit\":" | itoa(limit) | "}" } } return "{\"kinds\":[1,6,7,1111],\"limit\":" | itoa(limit) | "}" } func feedPassesFilter(ev *nostr.Event) (ok bool) { if appSt.feedMode != "follows" { return true } follows := appSt.myFollows if len(follows) == 0 { return true } for _, pk := range follows { if pk == ev.PubKey { return true } } // Also allow own events. return ev.PubKey == appSt.pubhex } func feedRelays() (ss []string) { if appSt.feedMode != "" && appSt.feedMode != "follows" && appSt.feedMode != "relays" { // Single relay mode. return []string{appSt.feedMode} } return appSt.relayURLs } func feedModeURL() (s string) { switch appSt.feedMode { case "", "follows": return "/feed/follows" case "relays": return "/feed/relays" } // Per-relay: strip ws:// or wss:// for the URL path. host := stripScheme(appSt.feedMode) return "/feed/relay/" | host } func stripScheme(url string) (s string) { if len(url) > 6 && url[:6] == "wss://" { return url[6:] } if len(url) > 5 && url[:5] == "ws://" { return url[5:] } return url } func loadOlderFeed() { appSt.feedLoading = true appSt.feedMoreGot = 0 feed.Send(`["F_LOAD_MORE"]`) if appSt.feedMoreTimer != 0 { dom.ClearTimeout(appSt.feedMoreTimer) } appSt.feedMoreTimer = dom.SetTimeout(func() { appSt.feedMoreTimer = 0 if appSt.feedLoading { dom.ConsoleLog("[loadOlderFeed] timeout - forcing feedLoading=false, got=" | itoa(appSt.feedMoreGot)) appSt.feedLoading = false if appSt.feedMoreGot == 0 { appSt.feedEmptyStreak++ if appSt.feedEmptyStreak >= 3 { appSt.feedExhausted = true } } else { appSt.feedEmptyStreak = 0 } if appSt.feedExhausted { dom.SetStyle(appSt.loadMoreBtn, "display", "none") } else if appSt.oldestFeedTs > 0 { dom.SetStyle(appSt.loadMoreBtn, "display", "block") } updateStatus() } }, 10000) } func populateFeedSelect() { updateFeedBtnText() if appSt.feedPopoverEl == 0 { return } clearChildren(appSt.feedPopoverEl) if len(appSt.myFollows) > 0 { addFeedOption("follows", "follows") } addFeedOption("relays", "relays") for _, u := range appSt.relayURLs { addFeedOption(u, stripScheme(u)) } } func addFeedOption(value, label string) { row := dom.CreateElement("div") dom.SetStyle(row, "padding", "6px 8px") dom.SetStyle(row, "cursor", "pointer") dom.SetStyle(row, "borderRadius", "4px") if value == appSt.feedMode { dom.SetStyle(row, "background", "var(--accent)") dom.SetStyle(row, "color", "var(--bg)") } dom.SetTextContent(row, label) v := value dom.AddEventListener(row, "click", dom.RegisterCallback(func() { appSt.feedMode = v updateFeedBtnText() dom.PushState(feedModeURL()) closeFeedPopover() refreshFeed() })) dom.AppendChild(appSt.feedPopoverEl, row) } func updateFeedBtnText() { if appSt.feedSelectEl == 0 { return } label := appSt.feedMode if label == "follows" && len(appSt.myFollows) == 0 { label = "all relays" } if label != "follows" && label != "relays" && label != "all relays" { label = stripScheme(label) } dom.SetTextContent(appSt.feedSelectEl, label | " \u25BE") } func toggleFeedPopover() { if appSt.feedPopoverOpen { closeFeedPopover() return } if appSt.signerOpen { hideSignerPanel() } if appSt.popoverOpen { togglePopover() } appSt.feedPopoverOpen = true populateFeedSelect() dom.SetStyle(appSt.feedPopoverEl, "display", "block") dom.SetStyle(appSt.feedSelectEl, "background", "var(--accent)") dom.SetStyle(appSt.feedSelectEl, "color", "var(--bg)") } func closeFeedPopover() { if !appSt.feedPopoverOpen { return } appSt.feedPopoverOpen = false dom.SetStyle(appSt.feedPopoverEl, "display", "none") dom.SetStyle(appSt.feedSelectEl, "background", "transparent") dom.SetStyle(appSt.feedSelectEl, "color", "var(--fg)") updateFeedBtnText() } func onRefreshClick() { if appSt.refreshSpinning { return } // Flush buffered new events into the feed. if len(appSt.feedBuffer) > 0 { for _, ev := range appSt.feedBuffer { renderNote(ev) appSt.seenEvents[ev.ID] = true } appSt.feedBuffer = nil appSt.feedHasNew = false stopRefreshPulse() } // Scroll to top. dom.SetProperty(appSt.contentArea, "scrollTop", "0") // Start spin animation. startRefreshSpin() // Cancel throttle and resubscribe immediately. if appSt.feedSubTimer != 0 { dom.ClearTimeout(appSt.feedSubTimer) appSt.feedSubTimer = 0 } appSt.feedSubscribed = true doSubscribeFeed() } func refreshFeed() { // Clear feed container. clearChildren(appSt.feedContainer) appSt.seenEvents = map[string]bool{} appSt.noteElements = map[string]dom.Element{} appSt.eventCount = 0 appSt.oldestFeedTs = 0 appSt.feedExhausted = false appSt.feedEmptyStreak = 0 appSt.feedBuffer = nil appSt.feedHasNew = false stopRefreshPulse() dom.SetStyle(appSt.loadMoreBtn, "display", "none") dom.SetProperty(appSt.contentArea, "scrollTop", "0") // Cancel any pending throttled sub and fire immediately. if appSt.feedSubTimer != 0 { dom.ClearTimeout(appSt.feedSubTimer) appSt.feedSubTimer = 0 } startRefreshSpin() appSt.feedSubscribed = true doSubscribeFeed() } func startRefreshPulse() { if refreshBtn == 0 || appSt.refreshPulseTimer != 0 { return } dom.SetStyle(refreshBtn, "color", "var(--accent)") appSt.refreshPulseOn = true pulseStep() } func pulseStep() { if appSt.refreshPulseTimer == -1 || refreshBtn == 0 { return } if appSt.refreshPulseOn { dom.SetStyle(refreshBtn, "opacity", "0.4") } else { dom.SetStyle(refreshBtn, "opacity", "1") } appSt.refreshPulseOn = !appSt.refreshPulseOn appSt.refreshPulseTimer = dom.SetTimeout(func() { pulseStep() }, 800) } func stopRefreshPulse() { if appSt.refreshPulseTimer != 0 { dom.ClearTimeout(appSt.refreshPulseTimer) } appSt.refreshPulseTimer = -1 dom.SetTimeout(func() { appSt.refreshPulseTimer = 0 }, 0) if refreshBtn != 0 { dom.SetStyle(refreshBtn, "color", "var(--muted)") dom.SetStyle(refreshBtn, "opacity", "1") } } func startRefreshSpin() { appSt.refreshSpinning = true if refreshBtn == 0 { return } appSt.refreshSpinAngle = 0 spinStep() } func spinStep() { if !appSt.refreshSpinning || refreshBtn == 0 { return } appSt.refreshSpinAngle += 30 dom.SetStyle(refreshBtn, "transform", "rotate(" | itoa(appSt.refreshSpinAngle) | "deg)") appSt.refreshSpinTimer = dom.SetTimeout(func() { spinStep() }, 50) } func stopRefreshSpin() { appSt.refreshSpinning = false if appSt.refreshSpinTimer != 0 { dom.ClearTimeout(appSt.refreshSpinTimer) appSt.refreshSpinTimer = 0 } if refreshBtn != 0 { dom.SetStyle(refreshBtn, "transform", "rotate(0deg)") } } func trackOldestTs(ev *nostr.Event) { if appSt.oldestFeedTs == 0 || ev.CreatedAt < appSt.oldestFeedTs { appSt.oldestFeedTs = ev.CreatedAt } } func parseIntProp(s string) (n int32) { n = 0 for i := 0; i < len(s); i++ { if s[i] >= '0' && s[i] <= '9' { n = n*10 + int32(s[i]-'0') } else { break } } return n } func boolStr(b bool) (s string) { if b { return "true" } return "false" } func i64toa(n int64) (s string) { if n == 0 { return "0" } var buf [20]byte i := len(buf) for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } return string(buf[i:]) } func sendWriteRelays() { msg := "[\"SET_WRITE_RELAYS\",[" first := true for i, url := range appSt.relayURLs { role := "both" if i < len(appSt.relayRole) { role = appSt.relayRole[i] } if role == "read" { continue } if !first { msg = msg | "," } msg = msg | jstr(url) first = false } routeMsg(msg | "]]") } // readRelayURLs returns relay URLs usable for reading (role "both" or "read"). func readRelayURLs() (ss []string) { var out []string for i, url := range appSt.relayURLs { role := "both" if i < len(appSt.relayRole) { role = appSt.relayRole[i] } if role != "write" { out = mxutil.Ensure(out, 1) out = push(out, url) } } return out } func buildProxyMsg(subID, filterJSON string, urls []string) (s string) { msg := "[\"PROXY\"," | jstr(subID) | "," | filterJSON | ",[" for i, url := range urls { if i > 0 { msg = msg | "," } msg = msg | jstr(url) } return msg | "]]" } func jstr(s string) (sv string) { return "\"" | jsonEsc(s) | "\"" } // scheduleTabRetry schedules a retry for any pending profile fetches after // the follows/mutes tab renders. Independent of retryRound so it works even // after the feed's retry budget is exhausted. func scheduleTabRetry() { dom.SetTimeout(func() { var missing []string for pk := range appSt.pendingNotes { if _, ok := appSt.authorNames[pk]; !ok { missing = mxutil.Ensure(missing, 1) missing = push(missing, pk) } } if len(missing) == 0 { return } for _, pk := range missing { setFetchedK0(pk, false) } for _, pk := range missing { profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`) } }, 5000) } // --- SW message handling --- func onSWMessage(raw string) { if raw == "update-available" { routeMsg("activate-update") return } if raw == "reload" { dom.LocationReload() return } if !appSt.appReady { return } if len(raw) < 5 || raw[0] != '[' { return } typ, pos := nextStr(raw, 1) switch typ { case "EVENT": subID, pos2 := nextStr(raw, pos) evJSON := extractValue(raw, pos2) if evJSON == "" { return } ev := nostr.ParseEvent(evJSON) if ev == nil { return } dispatchEvent(subID, ev) case "EOSE": subID, _ := nextStr(raw, pos) dispatchEOSE(subID) case "SW_LOG": msg, _ := nextStr(raw, pos) dom.ConsoleLog("[SW] " | msg) case "BROADCAST_DONE": if appSt.broadcastBtnEl != 0 { dom.SetTextContent(appSt.broadcastBtnEl, "broadcast profile \u2713") dom.SetStyle(appSt.broadcastBtnEl, "color", "var(--accent)") dom.SetTimeout(func() { dom.SetTextContent(appSt.broadcastBtnEl, "broadcast profile") }, 3000) } case "MLS_DM_LIST": listJSON := extractValue(raw, pos) renderConversationList(listJSON) case "MLS_DM_HISTORY": peer, pos2 := nextStr(raw, pos) msgsJSON := extractValue(raw, pos2) renderThreadMessages(peer, msgsJSON) case "MLS_DM_RECEIVED": dmJSON := extractValue(raw, pos) handleDMReceived(dmJSON) case "MLS_DM_SENT": // ok/err from MLS worker on send completion case "MLS_UNREAD_COUNT": // unread count badge (future step) case "MLS_DM_HISTORY_CLEARED": peer, _ := nextStr(raw, pos) dom.ConsoleLog("[mls] history cleared for " | peer) case "MLS_STATUS": text, _ := nextStr(raw, pos) dom.ConsoleLog("[mls] " | text) case "OK": okEvID, pos2 := nextStr(raw, pos) // Boolean follows: skip comma/space, check for "true". for pos2 < len(raw) && (raw[pos2] == ',' || raw[pos2] == ' ') { pos2++ } isOK := pos2+4 <= len(raw) && raw[pos2:pos2+4] == "true" if isOK { if targetID, ok2 := appSt.pendingDeletes[okEvID]; ok2 { removeNoteFromDOM(targetID) delete(appSt.pendingDeletes, okEvID) } } else { // Reason follows after bool. for pos2 < len(raw) && raw[pos2] != '"' { pos2++ } reason, _ := nextStr(raw, pos2) logActivity("relay-err", okEvID[:12] | ": " | reason, "event " | okEvID | "\nreason: " | reason) } case "SEEN_ON": evID, pos2 := nextStr(raw, pos) rURL, _ := nextStr(raw, pos2) // Strip trailing slash for dedup. if len(rURL) > 0 && rURL[len(rURL)-1] == '/' { rURL = rURL[:len(rURL)-1] } if evID != "" && rURL != "" && appSt.eventRelays != nil { existing := appSt.eventRelays[evID] found := false for _, u := range existing { if u == rURL { found = true break } } if !found { appSt.eventRelays[evID] = mxutil.Ensure(existing, 1) appSt.eventRelays[evID] = mxutil.Ensure(appSt.eventRelays[evID], 1) appSt.eventRelays[evID] = push(appSt.eventRelays[evID], rURL) logActivity("seen-on", evID[:12] | " on " | rURL, "event " | evID | "\nrelay " | rURL) if cb, ok := appSt.seenOnSubs[evID]; ok && cb != nil { cb() } } } case "RELAY_PROXY_READY": // Relay-proxy worker restarted (WASM fatal recovery). Re-send init // state and re-subscribe so the worker is functional again. if appSt.pubhex != "" { routeMsg("[\"SET_PUBKEY\"," | jstr(appSt.pubhex) | "]") } appSt.feedSubscribed = false resubscribe() case "NEED_IDENTITY": if appSt.pubhex != "" { routeMsg("[\"SET_PUBKEY\"," | jstr(appSt.pubhex) | "]") } resubscribe() case "RESUB": resubscribe() case "WORKER_RESTARTED": // A domain worker crashed and restarted. Re-provision it with current state. which, _ := nextStr(raw, pos) if appSt.pubhex == "" { return } if which == "/profile-wasm-host.mjs" { profile.Send("[\"P_SET_PUBKEY\"," | jstr(appSt.pubhex) | "]") profile.Send("[\"P_SET_RELAYS\"," | readRelayURLsJSON() | "]") } else if which == "/feed-wasm-host.mjs" { doSubscribeFeed() } else if which == "/notif-wasm-host.mjs" { subscribeNotifications() } // ── Profile Worker responses ───────────────────────────────────────────── case "P_RESOLVED": pk3, pos2a := nextStr(raw, pos) name3, pos3a := nextStr(raw, pos2a) pic3, _ := nextStr(raw, pos3a) // Update view cache. if len(name3) > 0 { setAuthorName(pk3, name3) } if len(pic3) > 0 { setAuthorPic(pk3, pic3) } // Fill pending note headers. if len(name3) > 0 { if headers, ok2 := appSt.pendingNotes[pk3]; ok2 { for _, h := range headers { updateNoteHeader(h, name3, pic3) } delete(appSt.pendingNotes, pk3) } } if len(pic3) > 0 { for _, avaEl := range appSt.pendingNotifAvatars[pk3] { setMediaSrc(avaEl, pic3) } delete(appSt.pendingNotifAvatars, pk3) } if pk3 == appSt.pubhex { if len(name3) > 0 { appSt.profileName = name3; dom.SetTextContent(appSt.nameEl, name3) } if len(pic3) > 0 { appSt.profilePic = pic3; setMediaSrc(appSt.avatarEl, pic3); dom.SetStyle(appSt.avatarEl, "display", "block") } } updateReplyPreviewsForAuthor(pk3) updateInlineProfileLinks(pk3) case "P_CONTENT": pk4, pos2b := nextStr(raw, pos) contentJSON, _ := nextStr(raw, pos2b) appSt.profileContentCache[pk4] = contentJSON lud16 := helpers.JsonGetString(contentJSON, "lud16") lud06 := helpers.JsonGetString(contentJSON, "lud06") if lud16 != "" || lud06 != "" { applyZapButtonsForAuthor(pk4) } else { delete(appSt.pendingZapRows, pk4) } // Re-render profile page if it's the viewed profile. if appSt.profileViewPK == pk4 && appSt.activePage == "profile" { renderProfilePage(pk4) } // Update own profile name if this is our own kind 0. if pk4 == appSt.pubhex { name4 := helpers.JsonGetString(contentJSON, "name") if name4 == "" { name4 = helpers.JsonGetString(contentJSON, "display_name") } if name4 != "" && appSt.profileName != name4 { appSt.profileName = name4 dom.SetTextContent(appSt.nameEl, name4) } } // Persist to IDB. if len(contentJSON) > 0 { dom.IDBPut("profiles", pk4, contentJSON) } case "P_FOLLOWS": pkF, pos2c := nextStr(raw, pos) followsJSON := extractValue(raw, pos2c) pksF := parseStringArrayJSON(followsJSON) followsChanged := false if pkF == appSt.pubhex { if len(pksF) != len(appSt.myFollows) { followsChanged = true } appSt.myFollows = pksF appSt.followSet = buildSet(pksF) broadcastFollows(pksF) if appSt.feedSelectEl != 0 { populateFeedSelect() } } appSt.profileFollowsCache[pkF] = pksF refreshProfileTab(pkF) if pkF == appSt.pubhex && appSt.feedSubscribed && appSt.feedMode == "follows" && followsChanged { subscribeFeed() } case "P_MUTES": pkM, pos2d := nextStr(raw, pos) mutesJSON := extractValue(raw, pos2d) pksM := parseStringArrayJSON(mutesJSON) if pkM == appSt.pubhex { appSt.myMutes = pksM appSt.muteSet = buildSet(pksM) broadcastMutes(pksM) } appSt.profileMutesCache[pkM] = pksM refreshProfileTab(pkM) case "P_RELAYS": pkR, pos2e := nextStr(raw, pos) relaysJSON := extractValue(raw, pos2e) appSt.profileRelaysCache[pkR] = parseStringArrayJSON(relaysJSON) if appSt.profileViewPK == pkR { refreshProfileTab(pkR) } // ── Feed Worker responses ───────────────────────────────────────────────── case "F_RENDER": evJSON2 := extractValue(raw, pos) if evJSON2 == "" { return } fev := nostr.ParseEvent(evJSON2) if fev == nil { return } // pos+len(evJSON2)+... find 'now' flag nowFlag := int64(0) for i := pos + len(evJSON2); i < len(raw); i++ { c := raw[i] if c >= '0' && c <= '9' { nowFlag = int64(c - '0') break } } setEvent(fev.ID, fev) if fev.Kind == 7 { handleActionEvent(fev) return } trackOldestTs(fev) if nowFlag == 1 { if appSt.feedLoader != 0 { dom.RemoveChild(appSt.feedPage, appSt.feedLoader) appSt.feedLoader = 0 } renderNote(fev) } else { if appSt.noteElements[fev.ID] != 0 || appSt.seenEvents[fev.ID] { return } appSt.feedBuffer = mxutil.Ensure(appSt.feedBuffer, 1) appSt.feedBuffer = push(appSt.feedBuffer, fev) if !appSt.feedHasNew { appSt.feedHasNew = true startRefreshPulse() } } case "PROXY_DROP": // The relay-proxy reports a relay frame it could not route to a client // subscription instead of dropping it silently. subIDd, posD := nextStr(raw, pos) whyD, _ := nextStr(raw, posD) dom.ConsoleLog("[proxy] dropped frame for " | subIDd | ": " | whyD) case "F_EOSE_DONE": subID2, _ := nextStr(raw, pos) if subID2 == "feed" { appSt.feedInitialLoad = false updateStatus() fetchDeletesForFeed() } case "F_STATUS": // ["F_STATUS", count, exhausted] cntStr := nextNum(raw, pos) var cnt int64 for _, c := range cntStr { if c >= '0' && c <= '9' { cnt = cnt*10 + int64(c-'0') } } appSt.eventCount = int32(cnt) dom.ConsoleLog("[feed] events=" | itoa(appSt.eventCount)) // Find exhausted flag after count. exStr := "" for p2 := pos + len(cntStr); p2 < len(raw); p2++ { if raw[p2] >= '0' && raw[p2] <= '9' { exStr = nextNum(raw, p2) break } } if exStr == "1" { appSt.feedExhausted = true } updateStatus() if appSt.feedLoading { appSt.feedLoading = false } if appSt.feedExhausted { dom.SetStyle(appSt.loadMoreBtn, "display", "none") } else if appSt.oldestFeedTs > 0 { dom.SetStyle(appSt.loadMoreBtn, "display", "block") } // ── Worker subscription bridge ──────────────────────────────────────────── // The feed and notif workers own their relay subscriptions and ask the // Shell to open and close them. Without these arms the requests were // dropped, so no REQ was ever sent for the feed and it stayed empty. case "F_SUB", "N_SUB": // ["X_SUB", subID, filterJSON, urlsJSON] subIDw, posW := nextStr(raw, pos) filterW := extractValue(raw, posW) if subIDw == "" || filterW == "" { return } urlsW := extractValue(raw, posW+len(filterW)) routeMsg(buildProxyMsg(subIDw, filterW, parseStringArrayJSON(urlsW))) dom.ConsoleLog("[sub] " | subIDw) case "F_CLOSE", "N_CLOSE": subIDx, _ := nextStr(raw, pos) if subIDx == "" { return } routeMsg(`["CLOSE",` | jstr(subIDx) | `]`) // ── Notif Worker responses ──────────────────────────────────────────────── case "N_RENDER": nEvJSON := extractValue(raw, pos) if nEvJSON == "" { return } nev := nostr.ParseEvent(nEvJSON) if nev == nil { return } appSt.notifEvents = push(appSt.notifEvents, nev) notifEventsTrim() if appSt.notifBuilt && appSt.activePage == "notifications" && notifPassesFilter(nev) { row := renderNotifRow(nev) // mode: "prepend" = first child, "append" = append modeStr := "" for i := pos + len(nEvJSON); i < len(raw); i++ { if raw[i] == '"' { j := i + 1 for j < len(raw) && raw[j] != '"' { j++ } modeStr = raw[i+1:j] break } } if modeStr == "prepend" { first := dom.FirstElementChild(appSt.notifContainer) if first != 0 { dom.InsertBefore(appSt.notifContainer, row, first) } else { dom.AppendChild(appSt.notifContainer, row) } } else { dom.AppendChild(appSt.notifContainer, row) } } case "N_DOT": showStr := nextNum(raw, pos) if showStr == "1" { showNotifDot() } case "N_STATUS": // ["N_STATUS", count, exhausted] nCntStr := nextNum(raw, pos) nExStr := "" for p3 := pos + len(nCntStr); p3 < len(raw); p3++ { if raw[p3] >= '0' && raw[p3] <= '9' { nExStr = nextNum(raw, p3) break } } if nExStr == "1" { appSt.notifExhausted = true } appSt.notifLoading = false if appSt.notifExhausted && appSt.notifLoadMore != 0 { dom.SetStyle(appSt.notifLoadMore, "display", "none") } else if appSt.oldestNotifTs > 0 && appSt.notifLoadMore != 0 { dom.SetStyle(appSt.notifLoadMore, "display", "block") } case "N_EOSE_DONE": subIDN, _ := nextStr(raw, pos) if subIDN == "ntf" { appSt.notifInitLoad = false sortNotifEvents() if appSt.activePage == "notifications" { if appSt.notifBuilt {} else { renderNotifPage() } } if appSt.oldestNotifTs > 0 && appSt.notifLoadMore != 0 { dom.SetStyle(appSt.notifLoadMore, "display", "block") } } case "N_FETCH_REFS": // Forward to relay-proxy: fetch the referenced events so notif row names can fill in. idsJSON := extractValue(raw, pos) if idsJSON != "" { routeMsg(`["PROXY","ntf-ref",{"ids":` | idsJSON | `,"limit":` | itoa(len(appSt.notifMissing)) | `},` | buildURLsJSON(readRelayURLs()) | `]`) } case "N_STORE_READ_TS": tsStr2 := nextNum(raw, pos) var ts2 int64 for _, c := range tsStr2 { if c >= '0' && c <= '9' { ts2 = ts2*10 + int64(c-'0') } } if ts2 > 0 { dom.IDBPut("settings", "notif-read-ts", i64toa(ts2)) } // ── DM Worker responses ─────────────────────────────────────────────────── case "D_LIST_RESP": listJSON2 := extractValue(raw, pos) renderConversationList(listJSON2) case "D_HISTORY_RESP": peerD, pos3 := nextStr(raw, pos) msgsD := extractValue(raw, pos3) renderThreadMessages(peerD, msgsD) case "D_RECEIVED": dmJ := extractValue(raw, pos) handleDMReceived(dmJ) case "D_SENT": _, pos4 := nextStr(raw, pos) // peer okStr, _ := nextStr(raw, pos4) if okStr == "1" || okStr == "true" { if len(appSt.pendingTsEls) > 0 { dom.SetTextContent(appSt.pendingTsEls[0], formatTime(dom.NowSeconds())) appSt.pendingTsEls = appSt.pendingTsEls[1:] } } // ── Profile Worker retry request ────────────────────────────────────────── case "P_RETRY_REQ": // Profile Worker is asking for pending pubkeys that still lack a name. // Collect from pendingNotes and send back as P_RETRY_PROVIDE. var pending []string for pk := range appSt.pendingNotes { if _, ok := appSt.authorNames[pk]; !ok { pending = mxutil.Ensure(pending, 1) pending = push(pending, pk) } } if len(pending) > 0 { profile.Send(`["P_RETRY_PROVIDE",` | buildJSONStrArr(pending) | `]`) } } } func resubscribe() { sendWriteRelays() subscribeProfile() subscribeFeed() subscribeNotifications() // Sync domain workers with current state. if appSt.pubhex != "" { profile.Send("[\"P_SET_PUBKEY\"," | jstr(appSt.pubhex) | "]") profile.Send("[\"P_SET_RELAYS\"," | readRelayURLsJSON() | "]") } if appSt.activePage == "profile" && appSt.profileTab == "notes" && appSt.profileViewPK != "" { renderProfileNotes(appSt.profileViewPK) } } func dispatchEvent(subID string, ev *nostr.Event) { if subID == "prof" || subID == "prof-settings" { handleProfileEvent(ev) } else if subID == "feed" || subID == "feed-more" { // Route to Feed Worker for dedup/filter/buffer management. feed.Send(`["F_EVENT",` | jstr(subID) | `,` | ev.ToJSON() | `]`) } else if len(subID) > 3 && subID[:3] == "ap-" { // Profile Worker owns all profile data; forward the event for processing. // P_RESOLVED/P_CONTENT/P_FOLLOWS/P_MUTES/P_RELAYS come back and Shell handles DOM. profile.Send(`["P_EVENT",` | ev.ToJSON() | `]`) } else if len(subID) > 3 && subID[:3] == "pn-" { if appSt.profileNotesSeen[ev.ID] { return } appSt.profileNotesSeen[ev.ID] = true // Dedup against the broad feed sub: relays send the same event on // every matching sub on a connection, so a profile-author's new // note also arrives on "feed". Marking it here prevents it from // being double-rendered into feedContainer later in the session. appSt.seenEvents[ev.ID] = true renderProfileNote(ev) } else if len(subID) > 6 && subID[:6] == "emb-c-" { setEvent(ev.ID, ev) fillCoordEmbed(ev) } else if len(subID) > 7 && subID[:7] == "emb-cr-" { setEvent(ev.ID, ev) fillCoordEmbed(ev) } else if len(subID) > 4 && subID[:4] == "emb-" { setEvent(ev.ID, ev) fillEmbed(ev) } else if len(subID) > 3 && subID[:3] == "rp-" { handleReplyPreviewEvent(ev) } else if len(subID) > 4 && subID[:4] == "thr-" { handleThreadEvent(appSt.threadGen, ev) } else if len(subID) > 4 && subID[:4] == "act-" { handleActionEvent(ev) } else if subID == "ntf" { handleNotifEvent(ev) } else if subID == "ntf-ref" { handleNotifRefEvent(ev) } else if subID == "ntf-more" { handleNotifMoreEvent(ev) } else if subID == "search" { handleSearchEvent(ev) } else if subID == "del-scan" { if ev.Kind == 5 { for _, tag := range nostr.TagsGetAll(ev.Tags, "e") { targetID := nostr.TagValue(tag) if targetID != "" { removeNoteFromDOM(targetID) } } } } } func dispatchEOSE(subID string) { // Route to domain workers first. if subID == "feed" || subID == "feed-more" { feed.Send(`["F_EOSE",` | jstr(subID) | `]`) } if subID == "ntf" || subID == "ntf-more" || subID == "ntf-ref" { notif.Send(`["N_EOSE",` | jstr(subID) | `]`) } if (len(subID) > 3 && subID[:3] == "ap-") || (len(subID) > 9 && subID[:9] == "ap-batch-") { profile.Send(`["P_EOSE",` | jstr(subID) | `]`) } if subID == "feed-more" { dom.ConsoleLog("[EOSE] feed-more got=" | itoa(appSt.feedMoreGot)) if appSt.feedMoreTimer != 0 { dom.ClearTimeout(appSt.feedMoreTimer) appSt.feedMoreTimer = 0 } appSt.feedLoading = false if appSt.feedMoreGot == 0 { appSt.feedEmptyStreak++ if appSt.feedEmptyStreak >= 3 { appSt.feedExhausted = true } } else { appSt.feedEmptyStreak = 0 } if !appSt.feedExhausted && appSt.oldestFeedTs > 0 { dom.SetStyle(appSt.loadMoreBtn, "display", "block") } updateStatus() } else if subID == "feed" { // Don't flip feedInitialLoad immediately. The SW sends EOSE on the // FIRST relay's response (often the empty local relay), but slower // remote relays keep streaming events for several seconds. Holding the // initial-load window open lets those events render directly instead // of being trapped in feedBuffer until the user clicks refresh. if appSt.feedInitialLoadTimer != 0 { dom.ClearTimeout(appSt.feedInitialLoadTimer) } appSt.feedInitialLoadTimer = dom.SetTimeout(func() { appSt.feedInitialLoadTimer = 0 appSt.feedInitialLoad = false }, 3000) if appSt.refreshSpinning { stopRefreshSpin() } if appSt.feedLoader != 0 { dom.RemoveChild(appSt.feedPage, appSt.feedLoader) appSt.feedLoader = 0 } if appSt.oldestFeedTs > 0 { dom.SetStyle(appSt.loadMoreBtn, "display", "block") } updateStatus() retryMissingProfiles() fetchDeletesForFeed() } else if subID == "ntf" { dom.ConsoleLog("[EOSE] ntf events=" | itoa(len(appSt.notifEvents))) appSt.notifInitLoad = false sortNotifEvents() if appSt.activePage == "notifications" { if appSt.notifBuilt { // Events were rendered incrementally during load; skip the // clear+rebuild flash. notifEvents is sorted for future renders // (filter changes, load-more). The "load more" button appears below. } else { renderNotifPage() } } if appSt.oldestNotifTs > 0 && appSt.notifLoadMore != 0 { dom.SetStyle(appSt.notifLoadMore, "display", "block") } fetchNotifRefs() } else if subID == "ntf-ref" { if appSt.notifBuilt && appSt.activePage == "notifications" { rebuildNotifRows() } } else if subID == "ntf-more" { dom.ConsoleLog("[EOSE] ntf-more got=" | itoa(appSt.notifMoreGot) | " streak=" | itoa(appSt.notifEmptyStrk)) if appSt.notifMoreTimer != 0 { dom.ClearTimeout(appSt.notifMoreTimer) appSt.notifMoreTimer = 0 } appSt.notifLoading = false if appSt.notifMoreGot == 0 { appSt.notifEmptyStrk++ if appSt.notifEmptyStrk >= 3 { appSt.notifExhausted = true } } else { appSt.notifEmptyStrk = 0 } if !appSt.notifExhausted && appSt.oldestNotifTs > 0 && appSt.notifLoadMore != 0 { dom.SetStyle(appSt.notifLoadMore, "display", "block") } fetchNotifRefs() } else if subID == "search" { dom.ConsoleLog("[EOSE] search results=" | itoa(len(appSt.searchResults))) renderSearchResults() } else if subID == "del-scan" { routeMsg("[\"CLOSE\",\"del-scan\"]") } else if len(subID) > 9 && subID[:9] == "ap-batch-" { // Delay CLOSE; Profile Worker handles retry logic via P_EOSE (sent above). closeID := subID dom.SetTimeout(func() { routeMsg("[\"CLOSE\"," | jstr(closeID) | "]") }, 15000) } else if len(subID) > 3 && subID[:3] == "ap-" { // Delay CLOSE; Profile Worker handles relay-hint retry via P_EOSE (sent above). closeID := subID dom.SetTimeout(func() { routeMsg("[\"CLOSE\"," | jstr(closeID) | "]") }, 15000) } else if len(subID) > 6 && subID[:6] == "emb-c-" { closeID := subID dom.SetTimeout(func() { routeMsg("[\"CLOSE\"," | jstr(closeID) | "]") embedCoordEOSECleanup(closeID) }, 5000) } else if len(subID) > 7 && subID[:7] == "emb-cr-" { closeID := subID dom.SetTimeout(func() { routeMsg("[\"CLOSE\"," | jstr(closeID) | "]") embedCoordEOSECleanup(closeID) }, 5000) } else if len(subID) > 4 && subID[:4] == "emb-" { closeID := subID dom.SetTimeout(func() { routeMsg("[\"CLOSE\"," | jstr(closeID) | "]") embedEOSECleanup(closeID) }, 5000) } else if len(subID) > 3 && subID[:3] == "rp-" { closeID := subID dom.SetTimeout(func() { routeMsg("[\"CLOSE\"," | jstr(closeID) | "]") }, 5000) } else if len(subID) > 4 && subID[:4] == "thr-" { closeID := subID dom.SetTimeout(func() { routeMsg("[\"CLOSE\"," | jstr(closeID) | "]") }, 5000) if appSt.threadOpen { handleThreadEOSE() } } else if len(subID) > 4 && subID[:4] == "act-" { closeID := subID dom.SetTimeout(func() { routeMsg("[\"CLOSE\"," | jstr(closeID) | "]") }, 10000) } } // nextNum extracts a bare number from s starting at pos, returning it as a string. func nextNum(s string, pos int32) (sv string) { for pos < len(s) && (s[pos] == ' ' || s[pos] == ',') { pos++ } start := pos for pos < len(s) && s[pos] >= '0' && s[pos] <= '9' { pos++ } return s[start:pos] } // nextStr extracts the next quoted string from s starting at pos. func nextStr(s string, pos int32) (s2 string, n2 int32) { for pos < len(s) && s[pos] != '"' { pos++ } if pos >= len(s) { return "", pos } pos++ var buf []byte hasEsc := false start := pos for pos < len(s) { if s[pos] == '\\' && pos+1 < len(s) { hasEsc = true buf = buf | s[start:pos] pos++ switch s[pos] { case '"', '\\', '/': buf = mxutil.Ensure(buf, 1) buf = push(buf, s[pos]) case 'n': buf = buf | "\n" case 't': buf = buf | "\t" case 'r': buf = buf | "\r" default: buf = buf | "\\" | string([]byte{s[pos]}) } pos++ start = pos continue } if s[pos] == '"' { break } pos++ } if pos >= len(s) { return "", pos } var val string if hasEsc { buf = buf | s[start:pos] val = string(buf) } else { val = s[start:pos] } pos++ for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') { pos++ } return val, pos } // extractValue extracts a JSON object/array value starting at pos. func extractValue(s string, pos int32) (sv string) { for pos < len(s) && (s[pos] == ',' || s[pos] == ' ') { pos++ } if pos >= len(s) { return "" } if s[pos] != '{' && s[pos] != '[' { return "" } start := pos depth := 0 for pos < len(s) { c := s[pos] if c == '{' || c == '[' { depth++ } if c == '}' || c == ']' { depth-- if depth == 0 { return s[start : pos+1] } } if c == '"' { pos++ for pos < len(s) && s[pos] != '"' { if s[pos] == '\\' { pos++ } pos++ } } pos++ } return s[start:] } // parseStringArrayJSON parses a JSON array of strings like ["a","b","c"]. func parseStringArrayJSON(json string) (ss []string) { var out []string i := 0 for i < len(json) && json[i] != '[' { i++ } if i >= len(json) { return nil } i++ for { for i < len(json) && (json[i] == ' ' || json[i] == ',' || json[i] == '\n') { i++ } if i >= len(json) || json[i] == ']' { break } if json[i] != '"' { break } i++ start := i for i < len(json) && json[i] != '"' { if json[i] == '\\' { i++ } i++ } if i >= len(json) { break } out = mxutil.Ensure(out, 1) out = push(out, json[start:i]) i++ } return out } func handleProfileEvent(ev *nostr.Event) { // Forward to Profile Worker for data storage in all cases. profile.Send(`["P_EVENT",` | ev.ToJSON() | `]`) // Shell handles DOM-immediate effects for the logged-in user's own events. switch ev.Kind { case 0: if ev.CreatedAt <= appSt.profileTs { return } appSt.profileTs = ev.CreatedAt // P_CONTENT from Profile Worker will handle profile page re-render + IDB. // Update own header directly here for immediate feedback. name := helpers.JsonGetString(ev.Content, "name") if len(name) == 0 { name = helpers.JsonGetString(ev.Content, "display_name") } pic := helpers.JsonGetString(ev.Content, "picture") if len(name) > 0 { appSt.profileName = name setAuthorName(appSt.pubhex, name) dom.SetTextContent(appSt.nameEl, name) } if len(pic) > 0 { appSt.profilePic = pic setAuthorPic(appSt.pubhex, pic) setMediaSrc(appSt.avatarEl, pic) dom.SetStyle(appSt.avatarEl, "display", "block") } case 3: if ev.CreatedAt <= appSt.contactTs { return } appSt.contactTs = ev.CreatedAt var pks []string for _, tag := range nostr.TagsGetAll(ev.Tags, "p") { if v := nostr.TagValue(tag); v != "" { pks = mxutil.Ensure(pks, 1) pks = push(pks, v) } } prev := appSt.myFollows appSt.myFollows = pks appSt.followSet = buildSet(pks) broadcastFollows(pks) for _, pk := range pks { profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`) } refreshProfileTab(appSt.pubhex) if appSt.feedSelectEl != 0 { populateFeedSelect() } if appSt.feedMode == "follows" { if appSt.feedDeferTimer != 0 { dom.ClearTimeout(appSt.feedDeferTimer) appSt.feedDeferTimer = 0 } if !appSt.feedSubscribed { appSt.feedSubscribed = true logActivity("boot", "kind 3 received, subscribing feed", itoa(len(pks)) | " follows") subscribeFeed() subscribeNotifications() bootDone() } else if len(pks) != len(prev) { subscribeFeed() } } case 10000: if ev.CreatedAt <= appSt.muteTs { return } appSt.muteTs = ev.CreatedAt var pks []string for _, tag := range nostr.TagsGetAll(ev.Tags, "p") { if v := nostr.TagValue(tag); v != "" { pks = mxutil.Ensure(pks, 1) pks = push(pks, v) } } appSt.myMutes = pks appSt.muteSet = buildSet(pks) broadcastMutes(pks) refreshProfileTab(appSt.pubhex) case 10002: if ev.CreatedAt <= appSt.relayListTs { return } appSt.relayListTs = ev.CreatedAt // Profile Worker handles relay frequency tracking via P_EVENT. for _, tag := range nostr.TagsGetAll(ev.Tags, "r") { url := nostr.TagValue(tag) if url != "" { addRelay(url, true) } } sendWriteRelays() // Relay list changed - resubscribe to include new relays. // subscribeFeed has its own 2s throttle, so direct call is fine. if appSt.feedSubscribed { subscribeFeed() subscribeNotifications() } case 10050: if ev.CreatedAt <= appSt.inboxTs { return } appSt.inboxTs = ev.CreatedAt // DM inbox relay list - stored for future use. _ = nostr.TagsGetAll(ev.Tags, "relay") case 30078: dtag := nostr.TagsGetFirst(ev.Tags, "d") if dtag == nil || nostr.TagValue(dtag) != "musiquay-settings" { return } if ev.CreatedAt <= appSt.settingsTs { return } appSt.settingsTs = ev.CreatedAt applyAppSettings(ev.Content) } } func applyAppSettings(content string) { theme := helpers.JsonGetString(content, "theme") if theme == "light" && appSt.isDark { toggleTheme() } else if theme == "dark" && !appSt.isDark { toggleTheme() } blossom := helpers.JsonGetString(content, "blossom") if blossom != "" { appSt.blossomServerURL = blossom localstorage.SetItem(lsKeyBlossomServer, blossom) } else { appSt.blossomServerURL = "" localstorage.RemoveItem(lsKeyBlossomServer) } clientTag := helpers.JsonGetString(content, "clientTag") if clientTag == "0" { localstorage.SetItem("musiquay-client-tag", "0") } else { localstorage.RemoveItem("musiquay-client-tag") } lang := helpers.JsonGetString(content, "lang") if lang != "" && lang != appSt.currentLang { setLang(lang) } inv := helpers.JsonGetString(content, "invidious") appSt.invidiousURL = inv if inv != "" { localstorage.SetItem(lsKeyInvidiousURL, inv) } else { localstorage.RemoveItem(lsKeyInvidiousURL) } camp := helpers.JsonGetString(content, "campion") appSt.campionURL = camp if camp != "" { localstorage.SetItem(lsKeyCampionURL, camp) } else { localstorage.RemoveItem(lsKeyCampionURL) } // Notification read watermark - sync across devices. nrts := helpers.JsonGetValue(content, "notifReadTs") if nrts != "" { ts := parseI64(nrts) if ts > appSt.notifReadTs { appSt.notifReadTs = ts dom.IDBPut("settings", "notif-read-ts", i64toa(ts)) } } // Relay blocklist - merge in entries from the settings event. blocklistRaw := helpers.JsonGetValue(content, "relayBlocklist") if blocklistRaw != "" { imported := parseJSONStringArray(blocklistRaw) for _, u := range imported { u = normalizeURL(u) if u != "" && !isBlocked(u) { appSt.relayBlocklist = mxutil.Ensure(appSt.relayBlocklist, 1) appSt.relayBlocklist = push(appSt.relayBlocklist, u) } } if len(imported) > 0 { saveRelayPolicy() } } } func saveAppSettings() { if appSt.pubhex == "" { return } theme := "dark" if !appSt.isDark { theme = "light" } clientTag := "1" if localstorage.GetItem("musiquay-client-tag") == "0" { clientTag = "0" } blocklistJSON := "[" for i, u := range appSt.relayBlocklist { if i > 0 { blocklistJSON = blocklistJSON | "," } blocklistJSON = blocklistJSON | jstr(u) } blocklistJSON = blocklistJSON | "]" content := "{" | "\"theme\":" | jstr(theme) | "," | "\"blossom\":" | jstr(appSt.blossomServerURL) | "," | "\"clientTag\":" | jstr(clientTag) | "," | "\"lang\":" | jstr(appSt.currentLang) | "," | "\"relayBlocklist\":" | blocklistJSON | "," | "\"invidious\":" | jstr(appSt.invidiousURL) | "," | "\"campion\":" | jstr(appSt.campionURL) | "," | "\"notifReadTs\":" | i64toa(appSt.notifReadTs) | "}" ts := dom.NowSeconds() tags := "[[\"d\",\"musiquay-settings\"]]" unsigned := "{\"kind\":30078,\"content\":" | jstr(content) | ",\"tags\":" | tags | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | appSt.pubhex | "\"}" signer.SignEvent(unsigned, func(signed string) { if signed == "" { return } appSt.settingsTs = ts routeMsg("[\"EVENT\"," | signed | "]") }) } func updateStatus() { dom.SetTextContent(appSt.statusEl, t("relays")) dom.SetTextContent(appSt.popoverSummary, itoa(len(appSt.relayURLs)) | " relays | " | itoa(appSt.eventCount) | " events") } // --- Feed rendering --- func renderNote(ev *nostr.Event) { if appSt.noteElements[ev.ID] != 0 || appSt.seenEvents[ev.ID] { return } repostPK := "" repostID := "" repostTs := int64(0) if ev.Kind == 6 { inner := nostr.ParseEvent(ev.Content) if inner == nil { return } repostPK = ev.PubKey repostID = ev.ID repostTs = ev.CreatedAt setEvent(inner.ID, inner) ev = inner } note := dom.CreateElement("div") if repostID != "" { appSt.noteElements[repostID] = note } else { appSt.noteElements[ev.ID] = note } dom.SetStyle(note, "borderBottom", "1px solid var(--border)") dom.SetStyle(note, "padding", "12px 0") sortTs := ev.CreatedAt if repostTs > 0 { sortTs = repostTs } dom.SetProperty(note, "musiquayTs", i64toa(sortTs)) if repostPK != "" { rpBanner := dom.CreateElement("div") dom.SetStyle(rpBanner, "display", "flex") dom.SetStyle(rpBanner, "alignItems", "center") dom.SetStyle(rpBanner, "gap", "6px") dom.SetStyle(rpBanner, "fontSize", "12px") dom.SetStyle(rpBanner, "color", "var(--muted)") dom.SetStyle(rpBanner, "marginBottom", "6px") dom.SetStyle(rpBanner, "cursor", "pointer") dom.SetInnerHTML(rpBanner, svgRepost) rpName := dom.CreateElement("span") if name, ok := appSt.authorNames[repostPK]; ok && name != "" { dom.SetTextContent(rpName, name | " " | t("reposted")) } else { npub := helpers.EncodeNpub(helpers.HexDecode(repostPK)) short := npub if len(npub) > 20 { short = npub[:12] | "..." | npub[len(npub)-4:] } dom.SetTextContent(rpName, short | " " | t("reposted")) } dom.AppendChild(rpBanner, rpName) bannerPK := repostPK dom.AddEventListener(rpBanner, "click", dom.RegisterCallback(func() { showProfile(bannerPK) })) dom.AppendChild(note, rpBanner) } // Header row: author link (left) + timestamp (right). header := dom.CreateElement("div") dom.SetStyle(header, "display", "flex") dom.SetStyle(header, "alignItems", "center") dom.SetStyle(header, "marginBottom", "4px") dom.SetStyle(header, "maxWidth", "65ch") // Author link - only covers avatar + name. authorLink := dom.CreateElement("div") dom.SetStyle(authorLink, "display", "flex") dom.SetStyle(authorLink, "alignItems", "center") dom.SetStyle(authorLink, "gap", "8px") dom.SetStyle(authorLink, "cursor", "pointer") headerPK := ev.PubKey dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() { showProfile(headerPK) })) avatar := dom.CreateElement("img") dom.SetAttribute(avatar, "referrerpolicy", "no-referrer") dom.SetAttribute(avatar, "width", "24") dom.SetAttribute(avatar, "height", "24") dom.SetStyle(avatar, "borderRadius", "50%") dom.SetStyle(avatar, "objectFit", "cover") dom.SetStyle(avatar, "flexShrink", "0") nameSpan := dom.CreateElement("span") dom.SetStyle(nameSpan, "fontSize", "18px") dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(nameSpan, "fontWeight", "bold") dom.SetStyle(nameSpan, "color", "var(--fg)") pk := ev.PubKey if pic, ok := appSt.authorPics[pk]; ok && pic != "" { setMediaSrc(avatar, pic) dom.SetAttribute(avatar, "onerror", "this.style.display='none'") } else { dom.SetStyle(avatar, "display", "none") } if name, ok := appSt.authorNames[pk]; ok && name != "" { dom.SetTextContent(nameSpan, name) } else { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) if len(npub) > 20 { dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:]) } } dom.AppendChild(authorLink, avatar) dom.AppendChild(authorLink, nameSpan) dom.AppendChild(header, authorLink) appendClientTag(header, ev) // Right side: delete + relay widget + timestamp - right-aligned. rightGroup := dom.CreateElement("div") dom.SetStyle(rightGroup, "display", "flex") dom.SetStyle(rightGroup, "alignItems", "center") dom.SetStyle(rightGroup, "gap", "4px") dom.SetStyle(rightGroup, "marginLeft", "auto") // Delete button - own notes only. if pk == appSt.pubhex { delBtn := dom.CreateElement("span") dom.SetInnerHTML(delBtn, ``) dom.SetStyle(delBtn, "cursor", "pointer") dom.SetStyle(delBtn, "color", "var(--muted)") dom.SetStyle(delBtn, "opacity", "0.5") dom.SetStyle(delBtn, "display", "flex") dom.SetStyle(delBtn, "alignItems", "center") delEvID := ev.ID dom.AddEventListener(delBtn, "click", dom.RegisterCallback(func() { publishDelete(delEvID) })) dom.AppendChild(rightGroup, delBtn) } rw := relayWidget(ev.ID) dom.AppendChild(rightGroup, rw) if ev.CreatedAt > 0 { tsEl := dom.CreateElement("span") dom.SetTextContent(tsEl, formatTime(ev.CreatedAt)) dom.SetStyle(tsEl, "fontSize", "11px") dom.SetStyle(tsEl, "color", "var(--muted)") dom.SetStyle(tsEl, "cursor", "pointer") dom.SetStyle(tsEl, "flexShrink", "0") evID := ev.ID evRootID := getRootID(ev) if evRootID == "" { evRootID = evID } dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() { showNoteThread(evRootID, evID) })) dom.AppendChild(rightGroup, tsEl) } dom.AppendChild(header, rightGroup) dom.AppendChild(note, header) // Track author link for update when profile arrives. if _, cached := appSt.authorNames[pk]; !cached { appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1) appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1) appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], authorLink) if !lookupFetchedK0(pk) { profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`) } } // Reply preview button. addReplyPreview(note, ev) // Content. content := dom.CreateElement("div") dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(content, "fontSize", "14px") dom.SetStyle(content, "lineHeight", "1.5") dom.SetStyle(content, "wordBreak", "break-word") dom.SetStyle(content, "maxWidth", "65ch") dom.SetStyle(content, "overflow", "hidden") dom.SetStyle(content, "maxHeight", "18em") setHTMLWithMedia(content, renderMarkdown(ev.Content)) dom.AppendChild(note, content) more := makeShowMore(content) dom.AppendChild(note, buildActionRow(ev, note, content, more)) // Insert in timestamp order (newest first). insertNoteByTs(note, sortTs) attachShowMore(content, more) resolveEmbeds() } func insertNoteByTs(note dom.Element, ts int64) { // Walk children to find insertion point - notes are newest-first. child := dom.FirstElementChild(appSt.feedContainer) for child != 0 { childTs := parseI64(dom.GetProperty(child, "musiquayTs")) if ts > childTs { dom.InsertBefore(appSt.feedContainer, note, child) return } child = dom.NextSibling(child) } dom.AppendChild(appSt.feedContainer, note) } func parseI64(s string) (n int64) { for i := 0; i < len(s); i++ { if s[i] >= '0' && s[i] <= '9' { n = n*10 + int64(s[i]-'0') } else { break } } return n } func appendNote(ev *nostr.Event) { if appSt.noteElements[ev.ID] != 0 { return } note, postInsert := buildNoteElement(ev) dom.AppendChild(appSt.feedContainer, note) postInsert() resolveEmbeds() } func buildNoteElement(ev *nostr.Event) (v dom.Element, fn func()) { note := dom.CreateElement("div") appSt.noteElements[ev.ID] = note dom.SetStyle(note, "borderBottom", "1px solid var(--border)") dom.SetStyle(note, "padding", "12px 0") dom.SetProperty(note, "musiquayTs", i64toa(ev.CreatedAt)) dom.SetProperty(note, "musiquayPK", ev.PubKey) header := dom.CreateElement("div") dom.SetStyle(header, "display", "flex") dom.SetStyle(header, "alignItems", "center") dom.SetStyle(header, "marginBottom", "4px") dom.SetStyle(header, "maxWidth", "65ch") authorLink := dom.CreateElement("div") dom.SetStyle(authorLink, "display", "flex") dom.SetStyle(authorLink, "alignItems", "center") dom.SetStyle(authorLink, "gap", "8px") dom.SetStyle(authorLink, "cursor", "pointer") headerPK := ev.PubKey dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() { showProfile(headerPK) })) avatar := dom.CreateElement("img") dom.SetAttribute(avatar, "referrerpolicy", "no-referrer") dom.SetAttribute(avatar, "width", "24") dom.SetAttribute(avatar, "height", "24") dom.SetStyle(avatar, "borderRadius", "50%") dom.SetStyle(avatar, "objectFit", "cover") dom.SetStyle(avatar, "flexShrink", "0") nameSpan := dom.CreateElement("span") dom.SetStyle(nameSpan, "fontSize", "18px") dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(nameSpan, "fontWeight", "bold") dom.SetStyle(nameSpan, "color", "var(--fg)") pk := ev.PubKey if pic, ok := appSt.authorPics[pk]; ok && pic != "" { setMediaSrc(avatar, pic) dom.SetAttribute(avatar, "onerror", "this.style.display='none'") } else { dom.SetStyle(avatar, "display", "none") } if name, ok := appSt.authorNames[pk]; ok && name != "" { dom.SetTextContent(nameSpan, name) } else { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) if len(npub) > 20 { dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:]) } } dom.AppendChild(authorLink, avatar) dom.AppendChild(authorLink, nameSpan) attachHoverCard(authorLink, pk) dom.AppendChild(header, authorLink) appendClientTag(header, ev) // JSON toggle: contentEl and moreEl are set below after those elements are // created. The click handler fires long after this function returns, so the // closure sees the final values. var contentEl dom.Element var moreEl dom.Element jsonActive := false var jsonEl dom.Element // Copy button - to the left of ; marginLeft:auto pushes both to the right. cpBtn := noteCopyBtn() capturedCpEv := ev dom.AddEventListener(cpBtn, "click", dom.RegisterCallback(func() { capturedCpBtn := cpBtn text := noteClipText(capturedCpEv) dom.WritePrimarySelection(text) dom.WriteClipboard(text, func(ok bool) { if ok { noteCopyFeedback(capturedCpBtn) } }) })) dom.SetStyle(cpBtn, "marginLeft", "auto") dom.AppendChild(header, cpBtn) jsonBtn := dom.CreateElement("span") dom.SetTextContent(jsonBtn, "") dom.SetStyle(jsonBtn, "fontSize", "10px") dom.SetStyle(jsonBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(jsonBtn, "cursor", "pointer") dom.SetStyle(jsonBtn, "opacity", "0.6") dom.SetStyle(jsonBtn, "marginRight", "8px") dom.SetStyle(jsonBtn, "userSelect", "none") dom.SetStyle(jsonBtn, "color", "var(--muted)") capturedJBtn := jsonBtn capturedEv := ev capturedNote := note dom.AddEventListener(jsonBtn, "click", dom.RegisterCallback(func() { if jsonActive { dom.SetStyle(contentEl, "display", "") dom.SetStyle(moreEl, "display", "none") dom.SetStyle(jsonEl, "display", "none") dom.SetStyle(capturedJBtn, "opacity", "0.6") dom.SetStyle(capturedJBtn, "color", "var(--muted)") jsonActive = false } else { if jsonEl == 0 { jsonEl = dom.CreateElement("pre") dom.SetStyle(jsonEl, "fontFamily", "'Fira Code', monospace") dom.SetStyle(jsonEl, "fontSize", "12px") dom.SetStyle(jsonEl, "lineHeight", "1.5") dom.SetStyle(jsonEl, "wordBreak", "break-word") dom.SetStyle(jsonEl, "whiteSpace", "pre-wrap") dom.SetStyle(jsonEl, "maxWidth", "65ch") dom.SetStyle(jsonEl, "margin", "0") dom.SetStyle(jsonEl, "color", "var(--fg)") dom.SetTextContent(jsonEl, prettyNoteJSON(capturedEv)) dom.InsertBefore(capturedNote, jsonEl, moreEl) } dom.SetStyle(contentEl, "display", "none") dom.SetStyle(moreEl, "display", "none") dom.SetStyle(jsonEl, "display", "block") dom.SetStyle(capturedJBtn, "opacity", "1") dom.SetStyle(capturedJBtn, "color", "var(--accent)") jsonActive = true } })) dom.AppendChild(header, jsonBtn) rw2 := relayWidget(ev.ID) dom.AppendChild(header, rw2) if ev.CreatedAt > 0 { tsEl := dom.CreateElement("span") dom.SetTextContent(tsEl, formatTime(ev.CreatedAt)) dom.SetStyle(tsEl, "fontSize", "11px") dom.SetStyle(tsEl, "color", "var(--muted)") dom.SetStyle(tsEl, "cursor", "pointer") dom.SetStyle(tsEl, "flexShrink", "0") evID := ev.ID evRootID := getRootID(ev) if evRootID == "" { evRootID = evID } dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() { showNoteThread(evRootID, evID) })) dom.AppendChild(header, tsEl) } dom.AppendChild(note, header) if _, cached := appSt.authorNames[pk]; !cached { appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1) appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1) appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], authorLink) if !lookupFetchedK0(pk) { profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`) } } addReplyPreview(note, ev) content := dom.CreateElement("div") dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(content, "fontSize", "14px") dom.SetStyle(content, "lineHeight", "1.5") dom.SetStyle(content, "wordBreak", "break-word") dom.SetStyle(content, "maxWidth", "65ch") dom.SetStyle(content, "overflow", "hidden") dom.SetStyle(content, "maxHeight", "18em") setHTMLWithMedia(content, renderMarkdown(ev.Content)) dom.AppendChild(note, content) contentEl = content more := makeShowMore(content) moreEl = more dom.AppendChild(note, buildActionRow(ev, note, content, more)) return note, func() { attachShowMore(content, more) } } // topRelays returns the n most frequently seen relay URLs from kind 10002 events. // topRelays moved to Profile Worker. Shell no longer has relay frequency data. // Returns nil so callers fall through to relayURLs + discoveryRelays only. func topRelays(_ int32) (ss []string) { return nil } // recordRelayFreq moved to Profile Worker. Shell no longer tracks relay frequency. // discoveryRelays are well-known relays that aggregate profile metadata. // Prioritized first in _proxy lists since they have the highest hit rate. var discoveryRelays []string // buildProxy builds a _proxy relay list for a pubkey. // Discovery relays first, then author-specific relays if known. // buildProxy builds a relay list for querying a specific author. // authorRelays and relayFreq now live in Profile Worker; Shell uses // profileRelaysCache for the relay hints it has received via P_RELAYS. func buildProxy(pk string) (ss []string) { out := []string{:len(discoveryRelays)} copy(out, discoveryRelays) for _, u := range readRelayURLs() { out = appendUnique(out, u) } if rels, ok2 := appSt.profileRelaysCache[pk]; ok2 { for _, r := range rels { out = appendUnique(out, r) } } return out } func appendUnique(list []string, val string) (ss []string) { if isBlocked(val) { return list } for _, v := range list { if v == val { return list } } // push does not grow: a caller's slice that came back at len == cap (a // returned view, or a presized list) would panic here. list = mxutil.Ensure(list, 1) return push(list, val) } // --- Thread view & reply preview --- // getReplyID returns the event ID this note is replying to, or "". // Checks for NIP-10 "reply" marker first, falls back to positional (last e-tag). func getReplyID(ev *nostr.Event) (s string) { id, _ := getReplyIDHint(ev) return id } func getReplyIDHint(ev *nostr.Event) (s2 string, s3 string) { var etags [][]string for _, tw1 := range ev.Tags { if len(tw1) >= 2 && tw1[0] == "e" { etags = mxutil.Ensure(etags, 1) etags = push(etags, tw1) } } if len(etags) == 0 { return "", "" } // Marker-based: look for "reply". for _, tv1 := range etags { if len(tv1) >= 4 && tv1[3] == "reply" { hintw3 := "" if len(tv1) >= 3 && len(tv1[2]) > 0 { hintw3 = tv1[2] } return tv1[1], hintw3 } } // Positional fallback: last e-tag is the reply target (if >1 e-tags). if len(etags) > 1 { last := etags[len(etags)-1] hintw2 := "" if len(last) >= 3 && len(last[2]) > 0 { hintw2 = last[2] } return last[1], hintw2 } // Single e-tag with no marker: it's both root and reply. hintw1 := "" if len(etags[0]) >= 3 && len(etags[0][2]) > 0 { hintw1 = etags[0][2] } return etags[0][1], hintw1 } // getRootID returns the thread root event ID, or "". func getRootID(ev *nostr.Event) (s string) { for _, tw2 := range ev.Tags { if len(tw2) >= 4 && tw2[0] == "e" && tw2[3] == "root" { return tw2[1] } } // Positional: first e-tag. for _, tv2 := range ev.Tags { if len(tv2) >= 2 && tv2[0] == "e" { return tv2[1] } } return "" } // referencesEvent returns true if ev has any e-tag pointing to the given ID. func referencesEvent(ev *nostr.Event, id string) (ok bool) { for _, tw3 := range ev.Tags { if len(tw3) >= 2 && tw3[0] == "e" && tw3[1] == id { return true } } return false } func firstLine(s string) (sv string) { for i := 0; i < len(s); i++ { if s[i] == '\n' { if i > 80 { return s[:80] | "..." } return s[:i] } } if len(s) > 80 { return s[:80] | "..." } return s } // appendClientTag adds "via " to a note header if the event has a // "client" tag. Linkifies http(s) URLs and strips the scheme for display. func appendClientTag(header dom.Element, ev *nostr.Event) { clientTag := nostr.TagsGetFirst(ev.Tags, "client") if clientTag == nil { return } val := nostr.TagValue(clientTag) if val == "" { return } wrap := dom.CreateElement("span") dom.SetStyle(wrap, "fontStyle", "italic") dom.SetStyle(wrap, "fontSize", "11px") dom.SetStyle(wrap, "color", "var(--muted)") dom.SetStyle(wrap, "marginLeft", "6px") dom.SetStyle(wrap, "flexShrink", "0") via := dom.CreateElement("span") dom.SetTextContent(via, "via ") dom.AppendChild(wrap, via) display := val isURL := false if len(val) >= 8 && val[:8] == "https://" { isURL = true display = val[8:] } else if len(val) >= 7 && val[:7] == "http://" { isURL = true display = val[7:] } if isURL { link := dom.CreateElement("a") dom.SetAttribute(link, "href", val) dom.SetAttribute(link, "target", "_blank") dom.SetAttribute(link, "rel", "noopener noreferrer") dom.SetTextContent(link, display) dom.SetStyle(link, "color", "var(--muted)") dom.SetStyle(link, "textDecoration", "underline") dom.AppendChild(wrap, link) } else { text := dom.CreateElement("span") dom.SetTextContent(text, display) dom.AppendChild(wrap, text) } dom.AppendChild(header, wrap) } func addReplyPreview(note dom.Element, ev *nostr.Event) { parentID, hint := getReplyIDHint(ev) if parentID == "" { return } if hint != "" && appSt.replyHints != nil { appSt.replyHints[parentID] = hint } preview := dom.CreateElement("div") dom.SetStyle(preview, "display", "flex") dom.SetStyle(preview, "alignItems", "center") dom.SetStyle(preview, "gap", "4px") dom.SetStyle(preview, "fontSize", "14px") dom.SetStyle(preview, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(preview, "lineHeight", "1.5") dom.SetStyle(preview, "color", "var(--muted)") dom.SetStyle(preview, "borderLeft", "2px solid var(--accent)") dom.SetStyle(preview, "paddingLeft", "8px") dom.SetStyle(preview, "marginBottom", "4px") dom.SetStyle(preview, "cursor", "pointer") dom.SetStyle(preview, "overflow", "hidden") dom.SetStyle(preview, "maxWidth", "65ch") prevAvatar := dom.CreateElement("img") dom.SetAttribute(prevAvatar, "referrerpolicy", "no-referrer") dom.SetAttribute(prevAvatar, "width", "14") dom.SetAttribute(prevAvatar, "height", "14") dom.SetStyle(prevAvatar, "borderRadius", "50%") dom.SetStyle(prevAvatar, "objectFit", "cover") dom.SetStyle(prevAvatar, "flexShrink", "0") dom.SetStyle(prevAvatar, "display", "none") dom.AppendChild(preview, prevAvatar) prevText := dom.CreateElement("span") dom.SetStyle(prevText, "overflow", "hidden") dom.SetStyle(prevText, "whiteSpace", "nowrap") dom.SetStyle(prevText, "textOverflow", "ellipsis") dom.AppendChild(preview, prevText) text, cached := appSt.replyCache[parentID] if !cached { text, cached = appSt.replyCacheOld[parentID] } if cached { dom.SetTextContent(prevText, text) pic, _ := appSt.replyAvatarCache[parentID] if pic == "" { pic, _ = appSt.replyAvatarCacheOld[parentID] } if pic != "" { setMediaSrc(prevAvatar, pic) dom.SetAttribute(prevAvatar, "onerror", "this.style.display='none'") dom.SetStyle(prevAvatar, "display", "block") } } else { dom.SetTextContent(prevText, t("replying_to")) appSt.replyPending[parentID] = mxutil.Ensure(appSt.replyPending[parentID], 1) appSt.replyPending[parentID] = mxutil.Ensure(appSt.replyPending[parentID], 1) appSt.replyPending[parentID] = push(appSt.replyPending[parentID], preview) queueReplyFetch(parentID) } rootID := getRootID(ev) if rootID == "" { rootID = parentID } focusID := parentID dom.AddEventListener(preview, "click", dom.RegisterCallback(func() { showNoteThread(rootID, focusID) })) dom.AppendChild(note, preview) } func queueReplyFetch(id string) { appSt.replyQueue = mxutil.Ensure(appSt.replyQueue, 1) appSt.replyQueue = push(appSt.replyQueue, id) if appSt.replyTimer != 0 { dom.ClearTimeout(appSt.replyTimer) } appSt.replyTimer = dom.SetTimeout(func() { appSt.replyTimer = 0 flushReplyQueue() }, 300) } func flushReplyQueue() { if len(appSt.replyQueue) == 0 { return } filter := "{\"ids\":[" var hints []string for i, id := range appSt.replyQueue { if i > 0 { filter = filter | "," } filter = filter | jstr(id) hv := appSt.replyHints[id] if hv == "" { hv = appSt.replyHintsOld[id] if hv != "" { hints = mxutil.Ensure(hints, 1) hints = push(hints, hv) delete(appSt.replyHintsOld, id) } } else { hints = mxutil.Ensure(hints, 1) hints = push(hints, hv) delete(appSt.replyHints, id) } } filter = filter | "]}" appSt.replyQueue = nil // Local REQ checks IDB cache first. appSt.threadSubCount++ reqID := "rp-" | itoa(appSt.threadSubCount) routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]") // PROXY fetches from remote relays - use hints + discovery + feed + top relays. var urls []string seen := map[string]bool{} for _, hw1 := range hints { if !seen[hw1] { seen[hw1] = true urls = mxutil.Ensure(urls, 1) urls = push(urls, hw1) } } for _, u := range discoveryRelays { if !seen[u] { seen[u] = true urls = mxutil.Ensure(urls, 1) urls = push(urls, u) } } for _, u := range appSt.relayURLs { if !seen[u] { seen[u] = true urls = mxutil.Ensure(urls, 1) urls = push(urls, u) } } for _, u := range topRelays(8) { if !seen[u] { seen[u] = true urls = mxutil.Ensure(urls, 1) urls = push(urls, u) } } appSt.threadSubCount++ proxyID := "rp-" | itoa(appSt.threadSubCount) routeMsg(buildProxyMsg(proxyID, filter, urls)) // Reset sweep timer: 20s after last batch sent, mark remaining as not found. if appSt.replySweepTimer != 0 { dom.ClearTimeout(appSt.replySweepTimer) } appSt.replySweepTimer = dom.SetTimeout(func() { appSt.replySweepTimer = 0 sweepUnfoundReplies() }, 20000) } func handleReplyPreviewEvent(ev *nostr.Event) { line := firstLine(ev.Content) appSt.replyLineCache[ev.ID] = line appSt.replyAuthorMap[ev.ID] = ev.PubKey name := ev.PubKey[:8] | "..." nameResolved := false if n, ok := appSt.authorNames[ev.PubKey]; ok && n != "" { name = n nameResolved = true } text := name | ": " | line appSt.replyCache[ev.ID] = text appSt.replyCount++ if appSt.replyCount > rotateReplyMax { rotateReplyCaches() } pic := "" if p, ok := appSt.authorPics[ev.PubKey]; ok && p != "" { pic = p } appSt.replyAvatarCache[ev.ID] = pic // Trigger profile fetch if not cached. if !nameResolved { if !lookupFetchedK0(ev.PubKey) { profile.Send(`["P_RESOLVE",` | jstr(ev.PubKey) | `]`) } } if divs, ok2 := appSt.replyPending[ev.ID]; ok2 { for _, d := range divs { fillReplyPreviewDiv(d, text, pic) } // Track for late name update if unresolved. if !nameResolved { appSt.replyNeedName[ev.ID] = appSt.replyNeedName[ev.ID] | divs } delete(appSt.replyPending, ev.ID) } } func fillReplyPreviewDiv(d dom.Element, text, pic string) { img := dom.FirstChild(d) if img == 0 { return } span := dom.NextSibling(img) if span != 0 { dom.SetTextContent(span, text) } if pic != "" { setMediaSrc(img, pic) dom.SetAttribute(img, "onerror", "this.style.display='none'") dom.SetStyle(img, "display", "block") } } func sweepUnfoundReplies() { if len(appSt.replyPending) == 0 { return } notFound := t("reply_not_found") for id, divs := range appSt.replyPending { for _, d := range divs { span := dom.NextSibling(dom.FirstChild(d)) if span != 0 { dom.SetTextContent(span, notFound) } } delete(appSt.replyPending, id) } } // updateReplyPreviewsForAuthor is called when a kind 0 profile arrives. // It updates any reply preview divs that were rendered with a hex pubkey stub. func updateReplyPreviewsForAuthor(pk string) { name, _ := appSt.authorNames[pk] pic, _ := appSt.authorPics[pk] if name == "" { return } for eid, apk := range appSt.replyAuthorMap { if apk != pk { continue } line := appSt.replyLineCache[eid] text := name | ": " | line appSt.replyCache[eid] = text appSt.replyAvatarCache[eid] = pic if divs, ok2 := appSt.replyNeedName[eid]; ok2 { for _, d := range divs { fillReplyPreviewDiv(d, text, pic) } delete(appSt.replyNeedName, eid) } } } func showNoteThread(rootID, focusID string) { // Close old thread subs immediately to prevent stale events leaking in. for _, sid := range appSt.threadActiveSubs { routeMsg("[\"CLOSE\"," | jstr(sid) | "]") } appSt.threadActiveSubs = nil appSt.threadGen++ appSt.threadRootID = rootID appSt.threadFocusID = focusID appSt.threadEvents = map[string]*nostr.Event{} appSt.threadLastRendered = 0 appSt.threadOpen = true appSt.threadFollowUp = 0 appSt.threadFetchedIDs = map[string]bool{rootID: true} appSt.threadReturnPage = appSt.activePage // Save scroll position. appSt.savedScrollTop = dom.GetProperty(appSt.contentArea, "scrollTop") // Deactivate search bar so top bar returns to normal while in thread. if appSt.searchBarActive { deactivateSearchBar(appSt.searchBtnGlobal) } // Stop any inline media (Invidious iframes) playing in the feed. stopInlineMedia(appSt.feedContainer) // Ensure feedPage is visible - threadPage lives inside it. if appSt.activePage != "feed" { dom.SetStyle(appSt.profilePage, "display", "none") dom.SetStyle(appSt.msgPage, "display", "none") dom.SetStyle(appSt.settingsPage, "display", "none") dom.SetStyle(appSt.notifPage, "display", "none") dom.SetStyle(appSt.aboutPage, "display", "none") dom.SetStyle(appSt.composePage, "display", "none") dom.SetStyle(appSt.searchPage, "display", "none") dom.SetStyle(appSt.feedPage, "display", "block") } // Defocus all sidebar buttons - thread view is its own context. dom.SetStyle(appSt.sidebarFeed, "background", "transparent") dom.SetStyle(appSt.sidebarFeed, "color", "var(--fg)") dom.SetStyle(appSt.sidebarCompose, "background", "transparent") dom.SetStyle(appSt.sidebarCompose, "color", "var(--fg)") dom.SetStyle(appSt.sidebarMsg, "background", "transparent") dom.SetStyle(appSt.sidebarMsg, "color", "var(--fg)") dom.SetStyle(appSt.sidebarNotif, "background", "transparent") dom.SetStyle(appSt.sidebarNotif, "color", "var(--fg)") dom.SetStyle(appSt.sidebarSettings, "background", "transparent") dom.SetStyle(appSt.sidebarSettings, "color", "var(--fg)") // Switch UI. dom.SetStyle(appSt.feedContainer, "display", "none") dom.SetStyle(appSt.loadMoreBtn, "display", "none") dom.SetStyle(appSt.threadPage, "display", "block") dom.SetProperty(appSt.contentArea, "scrollTop", "0") // Show back button in top bar, hide everything else. closeFeedPopover() dom.SetStyle(appSt.topBackBtn, "display", "inline") dom.SetStyle(appSt.pageTitleEl, "display", "none") dom.SetStyle(appSt.feedSelectEl, "display", "none") dom.SetStyle(appSt.notifBarGroup, "display", "none") if !appSt.navPop { url := "/t/" | rootID if focusID != "" && focusID != rootID { url = url | "#" | focusID } dom.PushState(url) appSt.threadPushedState = true } else { appSt.threadPushedState = false } clearChildren(appSt.threadContainer) // Loading indicator. loading := dom.CreateElement("div") dom.SetTextContent(loading, t("loading_thread")) dom.SetStyle(loading, "color", "var(--muted)") dom.SetStyle(loading, "fontSize", "14px") dom.SetStyle(loading, "padding", "16px 0") dom.AppendChild(appSt.threadContainer, loading) // Build wide relay set for thread fetch. var thrRelays []string thrSeen := map[string]bool{} for _, u := range appSt.relayURLs { if !thrSeen[u] { thrSeen[u] = true thrRelays = mxutil.Ensure(thrRelays, 1) thrRelays = push(thrRelays, u) } } for _, u := range topRelays(8) { if !thrSeen[u] { thrSeen[u] = true thrRelays = mxutil.Ensure(thrRelays, 1) thrRelays = push(thrRelays, u) } } appSt.threadRelays = thrRelays // Local REQ for cached events. appSt.threadSubCount++ s1 := "thr-" | itoa(appSt.threadSubCount) appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1) appSt.threadActiveSubs = push(appSt.threadActiveSubs, s1) routeMsg("[\"REQ\"," | jstr(s1) | ",{\"ids\":[" | jstr(rootID) | "]}]") appSt.threadSubCount++ s2 := "thr-" | itoa(appSt.threadSubCount) appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1) appSt.threadActiveSubs = push(appSt.threadActiveSubs, s2) routeMsg("[\"REQ\"," | jstr(s2) | ",{\"#e\":[" | jstr(rootID) | "],\"kinds\":[1]}]") // PROXY for remote relays. appSt.threadSubCount++ s3 := "thr-" | itoa(appSt.threadSubCount) appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1) appSt.threadActiveSubs = push(appSt.threadActiveSubs, s3) routeMsg(buildProxyMsg(s3, "{\"ids\":[" | jstr(rootID) | "]}", thrRelays)) appSt.threadSubCount++ s4 := "thr-" | itoa(appSt.threadSubCount) appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1) appSt.threadActiveSubs = push(appSt.threadActiveSubs, s4) routeMsg(buildProxyMsg(s4, "{\"#e\":[" | jstr(rootID) | "],\"kinds\":[1]}", thrRelays)) // Also fetch the focus event directly if different from root. if focusID != rootID { appSt.threadSubCount++ s5 := "thr-" | itoa(appSt.threadSubCount) appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1) appSt.threadActiveSubs = push(appSt.threadActiveSubs, s5) routeMsg(buildProxyMsg(s5, "{\"ids\":[" | jstr(focusID) | "]}", thrRelays)) } } func closeNoteThread() { // Close active subs. for _, sid := range appSt.threadActiveSubs { routeMsg("[\"CLOSE\"," | jstr(sid) | "]") } appSt.threadActiveSubs = nil appSt.threadOpen = false appSt.threadRootID = "" appSt.threadFocusID = "" appSt.threadRelays = nil appSt.threadFetchedIDs = nil stopInlineMedia(appSt.threadContainer) dom.SetStyle(appSt.threadPage, "display", "none") // Always restore feedContainer - showNoteThread hides it. dom.SetStyle(appSt.feedContainer, "display", "block") dom.SetStyle(appSt.topBackBtn, "display", "none") // Return to the page we came from. ret := appSt.threadReturnPage appSt.threadReturnPage = "" if ret != "" && ret != "feed" { // Force activePage to "" so switchPage doesn't early-return. appSt.activePage = "" switchPage(ret) // If returning to search, re-activate the bar with the current query // so the user sees the input and can go back or search again. if ret == "search" && appSt.searchBtnGlobal != 0 && !appSt.searchBarActive { activateSearchBar(appSt.searchBtnGlobal) if appSt.searchCurrentQ != "" { dom.SetProperty(appSt.searchInputEl, "value", appSt.searchCurrentQ) } } } else { dom.SetStyle(appSt.feedSelectEl, "display", "inline") if !appSt.feedExhausted && appSt.oldestFeedTs > 0 { dom.SetStyle(appSt.loadMoreBtn, "display", "block") } } // Restore scroll position. if appSt.savedScrollTop != "" { dom.SetProperty(appSt.contentArea, "scrollTop", appSt.savedScrollTop) appSt.savedScrollTop = "" } } func handleThreadEvent(gen int32, ev *nostr.Event) { if gen != appSt.threadGen { return // stale event from a previous thread } appSt.threadEvents[ev.ID] = ev // Debounced render - 200ms after last event, show what we have. if appSt.threadRenderTimer != 0 { dom.ClearTimeout(appSt.threadRenderTimer) } appSt.threadRenderTimer = dom.SetTimeout(func() { appSt.threadRenderTimer = 0 if appSt.threadOpen { renderThreadTree() } }, 200) } func handleThreadEOSE() { // Final render pass. if appSt.threadRenderTimer != 0 { dom.ClearTimeout(appSt.threadRenderTimer) appSt.threadRenderTimer = 0 } renderThreadTree() threadFetchReplies() } func threadFetchReplies() { if appSt.threadFollowUp >= 3 || !appSt.threadOpen { return } // Collect event IDs not yet queried for children. var ids []string for id := range appSt.threadEvents { if !appSt.threadFetchedIDs[id] { ids = mxutil.Ensure(ids, 1) ids = push(ids, id) appSt.threadFetchedIDs[id] = true } } if len(ids) == 0 { return } appSt.threadFollowUp++ // Build JSON array of IDs for the #e filter. eArr := "" for i, id := range ids { if i > 0 { eArr = eArr | "," } eArr = eArr | jstr(id) } filter := "{\"#e\":[" | eArr | "],\"kinds\":[1]}" // Local REQ. appSt.threadSubCount++ s1 := "thr-" | itoa(appSt.threadSubCount) appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1) appSt.threadActiveSubs = push(appSt.threadActiveSubs, s1) routeMsg("[\"REQ\"," | jstr(s1) | "," | filter | "]") // PROXY to remote relays. if len(appSt.threadRelays) > 0 { appSt.threadSubCount++ s2 := "thr-" | itoa(appSt.threadSubCount) appSt.threadActiveSubs = mxutil.Ensure(appSt.threadActiveSubs, 1) appSt.threadActiveSubs = push(appSt.threadActiveSubs, s2) routeMsg(buildProxyMsg(s2, filter, appSt.threadRelays)) } } func renderThreadTree() { n := len(appSt.threadEvents) if n == appSt.threadLastRendered { return // no new events since last render } appSt.threadLastRendered = n clearChildren(appSt.threadContainer) if n == 0 { empty := dom.CreateElement("div") dom.SetTextContent(empty, t("thread_empty")) dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "padding", "16px 0") dom.AppendChild(appSt.threadContainer, empty) return } // Build parent->children map. If direct parent isn't in the thread, // re-parent under root (the event is in-thread if it references root). children := map[string][]string{} for id, ev := range appSt.threadEvents { parentID := getReplyID(ev) if parentID == "" || parentID == id { continue } if appSt.threadEvents[parentID] == nil && parentID != appSt.threadRootID { // Direct parent not fetched - attach to root if event belongs to thread. if referencesEvent(ev, appSt.threadRootID) { parentID = appSt.threadRootID } } children[parentID] = mxutil.Ensure(children[parentID], 1) children[parentID] = push(children[parentID], id) } // Sort children chronologically. for pid := range children { ids := children[pid] sortByCreatedAt(ids) children[pid] = ids } // Find root - event with no parent in this thread, or threadRootID. rootID := appSt.threadRootID if _, ok := appSt.threadEvents[rootID]; !ok { // Root not fetched; find event with no in-thread parent. for id, ev := range appSt.threadEvents { parentID := getReplyID(ev) if parentID == "" || appSt.threadEvents[parentID] == nil { rootID = id break } } } // Render tree via DFS, tracking what was rendered. rendered := map[string]bool{} var renderAt func(id string, depth int32) renderAt = func(id string, depth int32) { ev, ok := appSt.threadEvents[id] if !ok { return } rendered[id] = true if isMuted(ev.PubKey) || repliesToMuted(ev) { return // skip muted user and entire sub-branch } if looksLikeJSONSpam(ev.Content) { return } renderThreadNote(ev, depth, id == appSt.threadFocusID) for _, childID := range children[id] { renderAt(childID, depth+1) } } // If root is present, start there. Otherwise render all as flat. if _, ok2 := appSt.threadEvents[rootID]; ok2 { renderAt(rootID, 0) // Render any remaining orphans (events not reached by DFS). for id := range appSt.threadEvents { if !rendered[id] { renderAt(id, 1) } } } else { for id := range appSt.threadEvents { renderAt(id, 0) } } // Scroll focused note into view (centered vertically). if appSt.threadFocusID != "" { dom.SetTimeout(func() { el := dom.GetElementById("thread-focus") if el != 0 { top := parseIntProp(dom.GetProperty(el, "offsetTop")) vh := parseIntProp(dom.GetProperty(appSt.contentArea, "clientHeight")) scroll := top - vh/2 if scroll < 0 { scroll = 0 } dom.SetProperty(appSt.contentArea, "scrollTop", itoa(scroll)) } }, 50) } } func sortByCreatedAt(ids []string) { for i := 0; i < len(ids); i++ { for j := i + 1; j < len(ids); j++ { ei := appSt.threadEvents[ids[i]] ej := appSt.threadEvents[ids[j]] if ei != nil && ej != nil && ei.CreatedAt > ej.CreatedAt { ids[i], ids[j] = ids[j], ids[i] } } } } func renderThreadNote(ev *nostr.Event, depth int32, focused bool) { note := dom.CreateElement("div") dom.SetStyle(note, "borderBottom", "1px solid var(--border)") dom.SetStyle(note, "padding", "8px 0") dom.SetStyle(note, "marginLeft", itoa(depth*8) | "px") if focused { dom.SetStyle(note, "borderLeft", "3px solid var(--accent)") dom.SetStyle(note, "paddingLeft", "8px") dom.SetStyle(note, "background", "var(--bg2)") dom.SetStyle(note, "borderRadius", "4px") dom.SetAttribute(note, "id", "thread-focus") } // Header. header := dom.CreateElement("div") dom.SetStyle(header, "display", "flex") dom.SetStyle(header, "alignItems", "center") dom.SetStyle(header, "marginBottom", "4px") dom.SetStyle(header, "maxWidth", "65ch") authorLink := dom.CreateElement("div") dom.SetStyle(authorLink, "display", "flex") dom.SetStyle(authorLink, "alignItems", "center") dom.SetStyle(authorLink, "gap", "6px") dom.SetStyle(authorLink, "cursor", "pointer") headerPK := ev.PubKey dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() { showProfile(headerPK) })) avatar := dom.CreateElement("img") dom.SetAttribute(avatar, "referrerpolicy", "no-referrer") dom.SetAttribute(avatar, "width", "20") dom.SetAttribute(avatar, "height", "20") dom.SetStyle(avatar, "borderRadius", "50%") dom.SetStyle(avatar, "objectFit", "cover") dom.SetStyle(avatar, "flexShrink", "0") nameSpan := dom.CreateElement("span") dom.SetStyle(nameSpan, "fontSize", "14px") dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(nameSpan, "fontWeight", "bold") dom.SetStyle(nameSpan, "color", "var(--fg)") pk := ev.PubKey if pic, ok := appSt.authorPics[pk]; ok && pic != "" { setMediaSrc(avatar, pic) dom.SetAttribute(avatar, "onerror", "this.style.display='none'") } else { dom.SetStyle(avatar, "display", "none") } if name, ok := appSt.authorNames[pk]; ok && name != "" { dom.SetTextContent(nameSpan, name) } else { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) if len(npub) > 20 { dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:]) } } dom.AppendChild(authorLink, avatar) dom.AppendChild(authorLink, nameSpan) attachHoverCard(authorLink, pk) dom.AppendChild(header, authorLink) appendClientTag(header, ev) // Copy button - copies this note and all replies beneath it. tCpBtn := noteCopyBtn() capturedTCpEv := ev capturedTCpBtn := tCpBtn dom.AddEventListener(tCpBtn, "click", dom.RegisterCallback(func() { evs := collectBranch(capturedTCpEv.ID) text := "" for _, e := range evs { text = text | noteClipText(e) } dom.WritePrimarySelection(text) dom.WriteClipboard(text, func(ok bool) { if ok { noteCopyFeedback(capturedTCpBtn) } }) })) dom.SetStyle(tCpBtn, "marginLeft", "auto") dom.AppendChild(header, tCpBtn) // JSON toggle var tContentEl dom.Element var tMoreEl dom.Element tJsonActive := false var tJsonEl dom.Element tJsonBtn := dom.CreateElement("span") dom.SetTextContent(tJsonBtn, "") dom.SetStyle(tJsonBtn, "fontSize", "10px") dom.SetStyle(tJsonBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(tJsonBtn, "cursor", "pointer") dom.SetStyle(tJsonBtn, "opacity", "0.6") dom.SetStyle(tJsonBtn, "marginRight", "8px") dom.SetStyle(tJsonBtn, "userSelect", "none") dom.SetStyle(tJsonBtn, "color", "var(--muted)") capturedTJBtn := tJsonBtn capturedTEv := ev capturedTNote := note dom.AddEventListener(tJsonBtn, "click", dom.RegisterCallback(func() { if tJsonActive { dom.SetStyle(tContentEl, "display", "") dom.SetStyle(tMoreEl, "display", "none") dom.SetStyle(tJsonEl, "display", "none") dom.SetStyle(capturedTJBtn, "opacity", "0.6") dom.SetStyle(capturedTJBtn, "color", "var(--muted)") tJsonActive = false } else { if tJsonEl == 0 { tJsonEl = dom.CreateElement("pre") dom.SetStyle(tJsonEl, "fontFamily", "'Fira Code', monospace") dom.SetStyle(tJsonEl, "fontSize", "12px") dom.SetStyle(tJsonEl, "lineHeight", "1.5") dom.SetStyle(tJsonEl, "wordBreak", "break-word") dom.SetStyle(tJsonEl, "whiteSpace", "pre-wrap") dom.SetStyle(tJsonEl, "maxWidth", "65ch") dom.SetStyle(tJsonEl, "margin", "0") dom.SetStyle(tJsonEl, "color", "var(--fg)") dom.SetTextContent(tJsonEl, prettyNoteJSON(capturedTEv)) dom.InsertBefore(capturedTNote, tJsonEl, tMoreEl) } dom.SetStyle(tContentEl, "display", "none") dom.SetStyle(tMoreEl, "display", "none") dom.SetStyle(tJsonEl, "display", "block") dom.SetStyle(capturedTJBtn, "opacity", "1") dom.SetStyle(capturedTJBtn, "color", "var(--accent)") tJsonActive = true } })) dom.AppendChild(header, tJsonBtn) // Relay widget + timestamp. rw3 := relayWidget(ev.ID) dom.AppendChild(header, rw3) if ev.CreatedAt > 0 { tsEl := dom.CreateElement("span") dom.SetTextContent(tsEl, formatTime(ev.CreatedAt)) dom.SetStyle(tsEl, "fontSize", "11px") dom.SetStyle(tsEl, "color", "var(--muted)") dom.AppendChild(header, tsEl) } dom.AppendChild(note, header) if _, cached := appSt.authorNames[pk]; !cached { appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1) appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1) appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], authorLink) if !lookupFetchedK0(pk) { profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`) } } // Content. content := dom.CreateElement("div") dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(content, "fontSize", "13px") dom.SetStyle(content, "lineHeight", "1.5") dom.SetStyle(content, "wordBreak", "break-word") dom.SetStyle(content, "maxWidth", "65ch") setHTMLWithMedia(content, renderMarkdown(ev.Content)) dom.AppendChild(note, content) tContentEl = content more := makeShowMore(content) tMoreEl = more dom.AppendChild(note, buildActionRow(ev, note, content, more)) dom.AppendChild(appSt.threadContainer, note) resolveEmbeds() } // fetchAuthorProfile fetches kind 0 + kind 10002 for an author via SW PROXY. // Still used internally; call sites use profile.Send(P_RESOLVE_FORCE) instead. func fetchAuthorProfile(pk string) { if lookupFetchedK0(pk) { return } setFetchedK0(pk, true) appSt.profileSubCounter++ subID := "ap-" | itoa(appSt.profileSubCounter) appSt.authorSubPK[subID] = pk proxyRelays := buildProxy(pk) routeMsg(buildProxyMsg(subID, "{\"authors\":[" | jstr(pk) | "],\"kinds\":[0,3,10002,10000],\"limit\":6}", proxyRelays)) } // retryMissingProfiles batches pubkeys that still lack a name into chunked // PROXY requests through the orly relay. Fetches all metadata kinds so // relay lists from kind 10002 enable second-hop discovery. func retryMissingProfiles() { var missing []string for pk := range appSt.pendingNotes { if _, ok := appSt.authorNames[pk]; !ok { missing = mxutil.Ensure(missing, 1) missing = push(missing, pk) } } if len(missing) == 0 { return } // Reset fetchedK0 for still-missing profiles so individual re-fetches // can fire if new relay info appears from other profiles' kind 10002. for _, pk := range missing { setFetchedK0(pk, false) } // Discovery relays first, then user relays + discovered relays. proxy := []string{:len(discoveryRelays)} copy(proxy, discoveryRelays) for _, u := range appSt.relayURLs { proxy = appendUnique(proxy, u) } top := topRelays(8) for _, u := range top { proxy = appendUnique(proxy, u) } const batchSize = 100 batchNum := 0 for i := 0; i < len(missing); i += batchSize { end := i + batchSize if end > len(missing) { end = len(missing) } chunk := missing[i:end] authors := "[" for j, pk := range chunk { if j > 0 { authors = authors | "," } authors = authors | jstr(pk) } authors = authors | "]" subID := "ap-batch-" | itoa(appSt.retryRound) | "-" | itoa(batchNum) batchNum++ routeMsg(buildProxyMsg(subID, "{\"authors\":" | authors | ",\"kinds\":[0,10002],\"limit\":" | itoa(len(chunk)*2) | "}", proxy)) // Direct query to feed relays. appSt.profileSubCounter++ routeMsg(buildProxyMsg("ap-d-" | itoa(appSt.profileSubCounter), "{\"authors\":" | authors | ",\"kinds\":[0],\"limit\":" | itoa(len(chunk)) | "}", appSt.relayURLs)) } appSt.retryRound++ } // applyAuthorProfile is no longer called by Shell - Profile Worker owns event // processing. P_RESOLVED/P_CONTENT handlers in onSWMessage do the DOM updates. // updateNoteHeader fills in avatar+name on a note's author header div. func updateNoteHeader(header dom.Element, name, pic string) { // First child is , second is . img := dom.FirstChild(header) if img == 0 { return } span := dom.NextSibling(img) if len(pic) > 0 { setMediaSrc(img, pic) dom.SetAttribute(img, "onerror", "this.style.display='none'") dom.SetStyle(img, "display", "") } if len(name) > 0 { dom.SetTextContent(span, name) } } // --- Profile page --- func showEditProfileModal() { content := appSt.profileContentCache[appSt.pubhex] curName := helpers.JsonGetString(content, "name") curAbout := helpers.JsonGetString(content, "about") curPic := helpers.JsonGetString(content, "picture") curBanner := helpers.JsonGetString(content, "banner") curNip05 := helpers.JsonGetString(content, "nip05") curWebsite := helpers.JsonGetString(content, "website") curLud16 := helpers.JsonGetString(content, "lud16") overlay := dom.CreateElement("div") dom.SetStyle(overlay, "position", "fixed") dom.SetStyle(overlay, "top", "0") dom.SetStyle(overlay, "left", "0") dom.SetStyle(overlay, "right", "0") dom.SetStyle(overlay, "bottom", "0") dom.SetStyle(overlay, "background", "rgba(0,0,0,0.6)") dom.SetStyle(overlay, "display", "flex") dom.SetStyle(overlay, "alignItems", "center") dom.SetStyle(overlay, "justifyContent", "center") dom.SetStyle(overlay, "zIndex", "2000") modal := dom.CreateElement("div") dom.SetStyle(modal, "background", "var(--bg)") dom.SetStyle(modal, "border", "1px solid var(--border)") dom.SetStyle(modal, "borderRadius", "8px") dom.SetStyle(modal, "padding", "20px") dom.SetStyle(modal, "width", "400px") dom.SetStyle(modal, "maxWidth", "calc(100vw - 32px)") dom.SetStyle(modal, "maxHeight", "calc(100vh - 64px)") dom.SetStyle(modal, "overflowY", "auto") makeField := func(label, val string, multiline bool) dom.Element { wrap := dom.CreateElement("div") dom.SetStyle(wrap, "marginBottom", "12px") lbl := dom.CreateElement("div") dom.SetTextContent(lbl, label) dom.SetStyle(lbl, "fontSize", "11px") dom.SetStyle(lbl, "color", "var(--muted)") dom.SetStyle(lbl, "marginBottom", "4px") dom.AppendChild(wrap, lbl) var input dom.Element if multiline { input = dom.CreateElement("textarea") dom.SetAttribute(input, "rows", "3") } else { input = dom.CreateElement("input") dom.SetAttribute(input, "type", "text") } dom.SetStyle(input, "width", "100%") dom.SetStyle(input, "padding", "6px 8px") dom.SetStyle(input, "background", "var(--bg2)") dom.SetStyle(input, "color", "var(--fg)") dom.SetStyle(input, "border", "1px solid var(--border)") dom.SetStyle(input, "borderRadius", "4px") dom.SetStyle(input, "fontFamily", "'Fira Code', monospace") dom.SetStyle(input, "fontSize", "13px") dom.SetStyle(input, "boxSizing", "border-box") dom.SetAttribute(input, "value", val) if multiline { dom.SetTextContent(input, val) } dom.AppendChild(wrap, input) return wrap } nameField := makeField(t("profile_name"), curName, false) aboutField := makeField(t("profile_about"), curAbout, true) picField := makeField(t("profile_picture"), curPic, false) bannerField := makeField(t("profile_banner"), curBanner, false) nip05Field := makeField(t("profile_nip05"), curNip05, false) websiteField := makeField(t("profile_website"), curWebsite, false) lud16Field := makeField(t("profile_lud16"), curLud16, false) addUploadRow := func(field dom.Element) { input := dom.NextSibling(dom.FirstChild(field)) btn := dom.CreateElement("button") dom.SetInnerHTML(btn, ``) dom.SetStyle(btn, "padding", "6px 8px") dom.SetStyle(btn, "border", "1px solid var(--border)") dom.SetStyle(btn, "borderRadius", "4px") dom.SetStyle(btn, "background", "transparent") dom.SetStyle(btn, "color", "var(--fg)") dom.SetStyle(btn, "cursor", "pointer") dom.SetStyle(btn, "display", "flex") dom.SetStyle(btn, "alignItems", "center") dom.SetStyle(btn, "flexShrink", "0") dom.AddEventListener(btn, "click", dom.RegisterCallback(func() { dom.PickFileBase64("image/*,video/*", func(b64, mime string) { if b64 != "" { blossomUploadToInput(b64, mime, input) } }) })) row := dom.CreateElement("div") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "gap", "4px") dom.SetStyle(input, "flex", "1") dom.SetStyle(input, "minWidth", "0") dom.InsertBefore(field, row, input) dom.AppendChild(row, input) dom.AppendChild(row, btn) } addUploadRow(picField) addUploadRow(bannerField) dom.AppendChild(modal, nameField) dom.AppendChild(modal, aboutField) dom.AppendChild(modal, picField) dom.AppendChild(modal, bannerField) dom.AppendChild(modal, nip05Field) dom.AppendChild(modal, websiteField) dom.AppendChild(modal, lud16Field) // Buttons row. btnRow := dom.CreateElement("div") dom.SetStyle(btnRow, "display", "flex") dom.SetStyle(btnRow, "gap", "8px") dom.SetStyle(btnRow, "marginTop", "8px") getInput := func(field dom.Element) dom.Element { el := dom.NextSibling(dom.FirstChild(field)) tag := dom.GetProperty(el, "tagName") if tag == "DIV" { return dom.FirstChild(el) } return el } saveBtn := dom.CreateElement("button") dom.SetTextContent(saveBtn, t("save")) dom.SetStyle(saveBtn, "padding", "6px 16px") dom.SetStyle(saveBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(saveBtn, "fontSize", "12px") dom.SetStyle(saveBtn, "background", "var(--accent)") dom.SetStyle(saveBtn, "color", "#fff") dom.SetStyle(saveBtn, "border", "none") dom.SetStyle(saveBtn, "borderRadius", "4px") dom.SetStyle(saveBtn, "cursor", "pointer") dom.AddEventListener(saveBtn, "click", dom.RegisterCallback(func() { newName := dom.GetProperty(getInput(nameField), "value") newAbout := dom.GetProperty(getInput(aboutField), "value") newPic := dom.GetProperty(getInput(picField), "value") newBanner := dom.GetProperty(getInput(bannerField), "value") newNip05 := dom.GetProperty(getInput(nip05Field), "value") newWebsite := dom.GetProperty(getInput(websiteField), "value") newLud16 := dom.GetProperty(getInput(lud16Field), "value") k0content := "{" | "\"name\":\"" | jsonEsc(newName) | "\"" | ",\"about\":\"" | jsonEsc(newAbout) | "\"" | ",\"picture\":\"" | jsonEsc(newPic) | "\"" | ",\"banner\":\"" | jsonEsc(newBanner) | "\"" | ",\"nip05\":\"" | jsonEsc(newNip05) | "\"" | ",\"website\":\"" | jsonEsc(newWebsite) | "\"" | ",\"lud16\":\"" | jsonEsc(newLud16) | "\"" | "}" ts := dom.NowSeconds() unsigned := "{\"kind\":0,\"content\":" | jstr(k0content) | ",\"tags\":[]" | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | appSt.pubhex | "\"}" hasSigner := "no" if signer.HasSigner() { hasSigner = "yes" } dom.ConsoleLog("[profile] signing kind 0, hasSigner=" | hasSigner) signer.SignEvent(unsigned, func(signed string) { if len(signed) == 0 { dom.ConsoleLog("[profile] sign returned empty - check [signer] and [ext.dispatch] logs above") dom.Confirm("sign failed - open signer panel, unlock vault, and try again") return } dom.ConsoleLog("[profile] sign OK, posting to SW") selected := composeSelectedRelays() for _, d := range discoveryRelays { selected = appendUnique(selected, d) } dom.ConsoleLog("[profile] PUBLISH_TO " | itoa(len(selected)) | " relays") msg := "[\"PUBLISH_TO\"," | signed | ",[" for i, u := range selected { if i > 0 { msg = msg | "," } msg = msg | jstr(u) } routeMsg(msg | "]]") dom.RemoveChild(dom.Body(), overlay) // Update local cache immediately. appSt.profileContentCache[appSt.pubhex] = k0content if len(newName) > 0 { appSt.profileName = newName setAuthorName(appSt.pubhex, newName) dom.SetTextContent(appSt.nameEl, newName) } if len(newPic) > 0 { appSt.profilePic = newPic appSt.authorPics[appSt.pubhex] = newPic setMediaSrc(appSt.avatarEl, newPic) dom.SetStyle(appSt.avatarEl, "display", "block") } appSt.profileTs = ts renderProfilePage(appSt.pubhex) }) })) dom.AppendChild(btnRow, saveBtn) cancelBtn := dom.CreateElement("button") dom.SetTextContent(cancelBtn, t("cancel")) dom.SetStyle(cancelBtn, "padding", "6px 16px") dom.SetStyle(cancelBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(cancelBtn, "fontSize", "12px") dom.SetStyle(cancelBtn, "background", "var(--bg2)") dom.SetStyle(cancelBtn, "color", "var(--fg)") dom.SetStyle(cancelBtn, "border", "1px solid var(--border)") dom.SetStyle(cancelBtn, "borderRadius", "4px") dom.SetStyle(cancelBtn, "cursor", "pointer") dom.AddEventListener(cancelBtn, "click", dom.RegisterCallback(func() { dom.RemoveChild(dom.Body(), overlay) })) dom.AppendChild(btnRow, cancelBtn) dom.AppendChild(modal, btnRow) dom.AppendChild(overlay, modal) dom.AppendChild(dom.Body(), overlay) } func showProfile(pk string) { if appSt.fetchedK0 == nil { return } // Save current profile's tab state before switching. if appSt.profileViewPK != "" && appSt.profileWrappers[appSt.profileViewPK] != 0 { appSt.profileWrapTabSel[appSt.profileViewPK] = appSt.profileTab appSt.profileWrapTabCont[appSt.profileViewPK] = appSt.profileTabContent appSt.profileWrapTabs[appSt.profileViewPK] = appSt.profileTabBtns } appSt.profileViewPK = pk // Always fetch fresh metadata in background. setFetchedK0(pk, false) profile.Send(`["P_RESOLVE_FORCE",` | jstr(pk) | `]`) if !appSt.navPop { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) dom.PushState("/p/" | npub) } // Hide all cached profile wrappers and remove relay info. for _, w := range appSt.profileWrappers { dom.SetStyle(w, "display", "none") } if appSt.relayInfoEl != 0 { dom.RemoveChild(appSt.profilePage, appSt.relayInfoEl) appSt.relayInfoEl = 0 } if wrapper, ok2 := appSt.profileWrappers[pk]; ok2 { // Cache hit - restore. dom.SetStyle(wrapper, "display", "block") appSt.profileTabContent = appSt.profileWrapTabCont[pk] appSt.profileTabBtns = appSt.profileWrapTabs[pk] appSt.profileTab = appSt.profileWrapTabSel[pk] profileTouchLRU(pk) } else { renderProfilePage(pk) } appSt.activePage = "" // force switchPage to run switchPage("profile") } const profileCacheMax = 8 func profileTouchLRU(pk string) { // Move pk to end (most recent). out := []string{:0:len(appSt.profileWrapOrder)} for _, p := range appSt.profileWrapOrder { if p != pk { out = mxutil.Ensure(out, 1) out = push(out, p) } } appSt.profileWrapOrder = push(out, pk) } func profileEvictOldest() { for len(appSt.profileWrapOrder) > profileCacheMax { old := appSt.profileWrapOrder[0] appSt.profileWrapOrder = appSt.profileWrapOrder[1:] if w, ok2 := appSt.profileWrappers[old]; ok2 { dom.RemoveChild(appSt.profilePage, w) dom.ReleaseChildren(w) dom.ReleaseElement(w) } delete(appSt.profileWrappers, old) delete(appSt.profileWrapTabCont, old) delete(appSt.profileWrapTabs, old) delete(appSt.profileWrapTabSel, old) delete(appSt.profileContentCache, old) delete(appSt.profileFollowsCache, old) delete(appSt.profileMutesCache, old) delete(appSt.profileRelaysCache, old) } } func verifyNip05(nip05, pubkeyHex string, badge dom.Element) { at := -1 for i := 0; i < len(nip05); i++ { if nip05[i] == '@' { at = i break } } if at < 1 || at >= len(nip05)-1 { dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️ return } local := nip05[:at] domain := nip05[at+1:] url := "https://" | domain | "/.well-known/nostr.json?name=" | local dom.FetchText(url, func(body string) { if body == "" { dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️ return } namesObj := helpers.JsonGetString(body, "names") if namesObj == "" { // names might be an object not a string - extract manually namesStart := -1 key := "\"names\"" for i := 0; i < len(body)-len(key); i++ { if body[i:i+len(key)] == key { namesStart = i + len(key) break } } if namesStart < 0 { dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") return } // Skip colon and whitespace to find the object for namesStart < len(body) && (body[namesStart] == ':' || body[namesStart] == ' ' || body[namesStart] == '\t') { namesStart++ } if namesStart >= len(body) || body[namesStart] != '{' { dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") return } // Find matching brace depth := 0 end := namesStart for end < len(body) { if body[end] == '{' { depth++ } else if body[end] == '}' { depth-- if depth == 0 { end++ break } } end++ } namesObj = body[namesStart:end] } got := helpers.JsonGetString(namesObj, local) if got == pubkeyHex { dom.SetTextContent(badge, "\xe2\x9c\x85") // ✅ } else { dom.SetTextContent(badge, "\xe2\x9a\xa0\xef\xb8\x8f") // ⚠️ } }) } func renderProfilePage(pk string) { savedTab := appSt.profileTab closeProfileNoteSub() appSt.profileNotesSeen = map[string]bool{} // Remove previous wrapper for this pubkey if it exists. if old, ok2 := appSt.profileWrappers[pk]; ok2 { dom.RemoveChild(appSt.profilePage, old) } wrapper := dom.CreateElement("div") appSt.profileWrappers[pk] = wrapper profileTouchLRU(pk) profileEvictOldest() dom.AppendChild(appSt.profilePage, wrapper) content := appSt.profileContentCache[pk] name := appSt.authorNames[pk] pic := appSt.authorPics[pk] about := helpers.JsonGetString(content, "about") website := helpers.JsonGetString(content, "website") nip05 := helpers.JsonGetString(content, "nip05") lud16 := helpers.JsonGetString(content, "lud16") banner := helpers.JsonGetString(content, "banner") // Banner - full width, 200px, cover. if banner != "" { bannerEl := dom.CreateElement("img") dom.SetAttribute(bannerEl, "referrerpolicy", "no-referrer") setMediaSrc(bannerEl, banner) dom.SetStyle(bannerEl, "width", "100%") dom.SetStyle(bannerEl, "height", "240px") dom.SetStyle(bannerEl, "objectFit", "cover") dom.SetStyle(bannerEl, "objectPosition", "center 30%") dom.SetStyle(bannerEl, "display", "block") dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'") dom.AppendChild(wrapper, bannerEl) } // User info card - glass effect, overlapping banner. card := dom.CreateElement("div") dom.SetStyle(card, "background", "color-mix(in srgb, var(--bg) 42%, transparent)") dom.SetStyle(card, "backdropFilter", "blur(8px)") dom.SetStyle(card, "borderRadius", "8px") dom.SetStyle(card, "padding", "8px") if banner != "" { dom.SetStyle(card, "margin", "-24px 16px 0") } else { dom.SetStyle(card, "margin", "16px") } dom.SetStyle(card, "position", "relative") dom.SetStyle(card, "width", "fit-content") dom.SetStyle(card, "maxWidth", "calc(100% - 32px)") // Top row: avatar + info. Wraps on narrow screens so avatar goes above. topRow := dom.CreateElement("div") dom.SetStyle(topRow, "display", "flex") dom.SetStyle(topRow, "gap", "16px") dom.SetStyle(topRow, "alignItems", "flex-start") dom.SetStyle(topRow, "flexWrap", "wrap") // Compute npub early - needed for avatar QR click and npub row. npubBytes := helpers.HexDecode(pk) npubStr := helpers.EncodeNpub(npubBytes) if pic != "" { av := dom.CreateElement("img") dom.SetAttribute(av, "referrerpolicy", "no-referrer") setMediaSrc(av, pic) dom.SetAttribute(av, "width", "64") dom.SetAttribute(av, "height", "64") dom.SetStyle(av, "borderRadius", "50%") dom.SetStyle(av, "objectFit", "cover") dom.SetStyle(av, "flexShrink", "0") dom.SetStyle(av, "border", "3px solid var(--bg)") dom.SetStyle(av, "cursor", "pointer") dom.SetAttribute(av, "onerror", "this.style.display='none'") avNpub := npubStr dom.AddEventListener(av, "click", dom.RegisterCallback(func() { showQRModal(avNpub) })) dom.AppendChild(topRow, av) } info := dom.CreateElement("div") dom.SetStyle(info, "minWidth", "200px") dom.SetStyle(info, "flex", "1") dom.SetStyle(info, "overflow", "hidden") if name != "" { nameRow := dom.CreateElement("div") dom.SetStyle(nameRow, "display", "flex") dom.SetStyle(nameRow, "alignItems", "baseline") dom.SetStyle(nameRow, "gap", "8px") nameSpan := dom.CreateElement("span") dom.SetTextContent(nameSpan, name) dom.SetStyle(nameSpan, "fontSize", "20px") dom.SetStyle(nameSpan, "fontWeight", "bold") dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(nameSpan, "cursor", "pointer") nameNpub := npubStr dom.AddEventListener(nameSpan, "click", dom.RegisterCallback(func() { showQRModal(nameNpub) })) dom.AppendChild(nameRow, nameSpan) // "follows you" badge. if pk != appSt.pubhex { if theirFollows, ok2 := appSt.profileFollowsCache[pk]; ok2 { for _, fpk := range theirFollows { if fpk == appSt.pubhex { badge := dom.CreateElement("span") dom.SetTextContent(badge, "follows you") dom.SetStyle(badge, "fontSize", "11px") dom.SetStyle(badge, "color", "var(--muted)") dom.SetStyle(badge, "background", "var(--bg2)") dom.SetStyle(badge, "padding", "2px 6px") dom.SetStyle(badge, "borderRadius", "4px") dom.AppendChild(nameRow, badge) break } } } } dom.AppendChild(info, nameRow) } if nip05 != "" { nip05Row := dom.CreateElement("div") dom.SetStyle(nip05Row, "display", "flex") dom.SetStyle(nip05Row, "alignItems", "center") dom.SetStyle(nip05Row, "gap", "4px") nip05Text := dom.CreateElement("span") dom.SetTextContent(nip05Text, nip05) dom.SetStyle(nip05Text, "color", "var(--muted)") dom.SetStyle(nip05Text, "fontSize", "13px") dom.AppendChild(nip05Row, nip05Text) nip05Badge := dom.CreateElement("span") dom.SetStyle(nip05Badge, "fontSize", "14px") dom.AppendChild(nip05Row, nip05Badge) dom.AppendChild(info, nip05Row) // Async NIP-05 validation. verifyNip05(nip05, pk, nip05Badge) } // npub row: clickable npub text (shows QR) + copy icon. npubRow := dom.CreateElement("div") dom.SetStyle(npubRow, "display", "flex") dom.SetStyle(npubRow, "alignItems", "center") dom.SetStyle(npubRow, "gap", "6px") dom.SetStyle(npubRow, "marginTop", "2px") npubEl := dom.CreateElement("span") dom.SetStyle(npubEl, "color", "var(--muted)") dom.SetStyle(npubEl, "fontSize", "12px") dom.SetStyle(npubEl, "wordBreak", "break-all") dom.SetStyle(npubEl, "cursor", "pointer") dom.SetTextContent(npubEl, npubStr) npubQR := npubStr dom.AddEventListener(npubEl, "click", dom.RegisterCallback(func() { showQRModal(npubQR) })) dom.AppendChild(npubRow, npubEl) copyBtn := actionBtn(``) dom.SetAttribute(copyBtn, "data-npub", npubStr) dom.SetAttribute(copyBtn, "onclick", "navigator.clipboard.writeText(this.dataset.npub)") dom.AppendChild(npubRow, copyBtn) dom.AppendChild(info, npubRow) // Website + lightning inline. if website != "" || lud16 != "" { metaRow := dom.CreateElement("div") dom.SetStyle(metaRow, "display", "flex") dom.SetStyle(metaRow, "flexWrap", "wrap") dom.SetStyle(metaRow, "gap", "4px 12px") dom.SetStyle(metaRow, "marginTop", "6px") dom.SetStyle(metaRow, "fontSize", "12px") if website != "" { wEl := dom.CreateElement("span") dom.SetStyle(wEl, "color", "var(--accent)") dom.SetStyle(wEl, "wordBreak", "break-all") dom.SetTextContent(wEl, website) dom.AppendChild(metaRow, wEl) } if lud16 != "" { lEl := dom.CreateElement("span") dom.SetStyle(lEl, "color", "var(--muted)") dom.SetStyle(lEl, "wordBreak", "break-all") dom.SetStyle(lEl, "cursor", "pointer") dom.SetTextContent(lEl, "\xE2\x9A\xA1 " | lud16) capLud := lud16 dom.AddEventListener(lEl, "click", dom.RegisterCallback(func() { showQRModal(capLud) })) dom.AppendChild(metaRow, lEl) } dom.AppendChild(info, metaRow) } dom.AppendChild(topRow, info) dom.AppendChild(card, topRow) // Action buttons - only for other users. if pk != appSt.pubhex { btnRow := dom.CreateElement("div") dom.SetStyle(btnRow, "display", "flex") dom.SetStyle(btnRow, "gap", "8px") dom.SetStyle(btnRow, "marginTop", "12px") // Follow/unfollow button. followBtn := dom.CreateElement("button") dom.SetStyle(followBtn, "padding", "6px 16px") dom.SetStyle(followBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(followBtn, "fontSize", "12px") dom.SetStyle(followBtn, "borderRadius", "4px") dom.SetStyle(followBtn, "cursor", "pointer") isFollowingw1 := false if len(appSt.myFollows) > 0 { for _, fpk := range appSt.myFollows { if fpk == pk { isFollowingw1 = true break } } } if isFollowingw1 { dom.SetTextContent(followBtn, "followed") dom.SetStyle(followBtn, "background", "var(--bg2)") dom.SetStyle(followBtn, "color", "var(--fg)") dom.SetStyle(followBtn, "border", "1px solid var(--border)") } else { dom.SetTextContent(followBtn, "follow") dom.SetStyle(followBtn, "background", "var(--accent)") dom.SetStyle(followBtn, "color", "#fff") dom.SetStyle(followBtn, "border", "none") } dom.AppendChild(btnRow, followBtn) // Mute button. muteBtn := dom.CreateElement("button") dom.SetStyle(muteBtn, "padding", "6px 16px") dom.SetStyle(muteBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(muteBtn, "fontSize", "12px") dom.SetStyle(muteBtn, "borderRadius", "4px") dom.SetStyle(muteBtn, "cursor", "pointer") if isMuted(pk) { dom.SetTextContent(muteBtn, "muted") dom.SetStyle(muteBtn, "background", "#c33") dom.SetStyle(muteBtn, "color", "#fff") dom.SetStyle(muteBtn, "border", "none") } else { dom.SetTextContent(muteBtn, "mute") dom.SetStyle(muteBtn, "background", "var(--bg2)") dom.SetStyle(muteBtn, "color", "var(--fg)") dom.SetStyle(muteBtn, "border", "1px solid var(--border)") } // Wire up click handlers with cross-references. btnPK := pk cFollow := followBtn cMute := muteBtn dom.AddEventListener(followBtn, "click", dom.RegisterCallback(func() { toggleFollow(btnPK, cFollow, cMute) })) dom.AddEventListener(muteBtn, "click", dom.RegisterCallback(func() { toggleMute(btnPK, cMute, cFollow) })) dom.AppendChild(btnRow, muteBtn) dom.AppendChild(card, btnRow) } else { // Own profile - edit button. editRow := dom.CreateElement("div") dom.SetStyle(editRow, "marginTop", "12px") editBtn := dom.CreateElement("button") dom.SetTextContent(editBtn, t("edit_profile")) dom.SetStyle(editBtn, "padding", "6px 16px") dom.SetStyle(editBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(editBtn, "fontSize", "12px") dom.SetStyle(editBtn, "background", "var(--bg2)") dom.SetStyle(editBtn, "color", "var(--fg)") dom.SetStyle(editBtn, "border", "1px solid var(--border)") dom.SetStyle(editBtn, "borderRadius", "4px") dom.SetStyle(editBtn, "cursor", "pointer") dom.AddEventListener(editBtn, "click", dom.RegisterCallback(func() { showEditProfileModal() })) dom.AppendChild(editRow, editBtn) dom.AppendChild(card, editRow) } dom.AppendChild(wrapper, card) // About/bio - same height-cap + show-more pattern as feed notes. if about != "" { aboutEl := dom.CreateElement("div") dom.SetStyle(aboutEl, "padding", "12px 16px") dom.SetStyle(aboutEl, "fontSize", "14px") dom.SetStyle(aboutEl, "lineHeight", "1.5") dom.SetStyle(aboutEl, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(aboutEl, "wordBreak", "break-word") dom.SetStyle(aboutEl, "overflow", "hidden") dom.SetStyle(aboutEl, "maxHeight", "18em") dom.AppendChild(wrapper, aboutEl) setHTMLWithMedia(aboutEl, renderMarkdown(about)) more := makeShowMore(aboutEl) dom.SetStyle(more, "padding", "0 16px 8px") dom.AppendChild(wrapper, more) attachShowMore(aboutEl, more) } // Tab bar. tabBar := dom.CreateElement("div") dom.SetStyle(tabBar, "display", "flex") dom.SetStyle(tabBar, "gap", "0") dom.SetStyle(tabBar, "margin", "8px 16px 0") dom.SetStyle(tabBar, "border", "1px solid var(--border)") dom.SetStyle(tabBar, "borderRadius", "6px") dom.SetStyle(tabBar, "overflow", "hidden") dom.SetStyle(tabBar, "width", "fit-content") appSt.profileTabBtns = map[string]dom.Element{} // Unrolled - tinyjs range/loop closure aliasing. tabNotes := makeProtoBtn(t("notes")) dom.SetStyle(tabNotes, "cursor", "pointer") appSt.profileTabBtns["notes"] = tabNotes tabNotesPK := pk dom.AddEventListener(tabNotes, "click", dom.RegisterCallback(func() { selectProfileTab("notes", tabNotesPK) })) dom.AppendChild(tabBar, tabNotes) tabFollows := makeProtoBtn(t("follows")) dom.SetStyle(tabFollows, "cursor", "pointer") appSt.profileTabBtns["follows"] = tabFollows tabFollowsPK := pk dom.AddEventListener(tabFollows, "click", dom.RegisterCallback(func() { selectProfileTab("follows", tabFollowsPK) })) dom.AppendChild(tabBar, tabFollows) tabRelays := makeProtoBtn(t("relays")) dom.SetStyle(tabRelays, "cursor", "pointer") appSt.profileTabBtns["relays"] = tabRelays tabRelaysPK := pk dom.AddEventListener(tabRelays, "click", dom.RegisterCallback(func() { selectProfileTab("relays", tabRelaysPK) })) dom.AppendChild(tabBar, tabRelays) tabMutes := makeProtoBtn(t("mutes")) dom.SetStyle(tabMutes, "cursor", "pointer") appSt.profileTabBtns["mutes"] = tabMutes tabMutesPK := pk dom.AddEventListener(tabMutes, "click", dom.RegisterCallback(func() { selectProfileTab("mutes", tabMutesPK) })) dom.AppendChild(tabBar, tabMutes) dom.AppendChild(wrapper, tabBar) // Tab content container. appSt.profileTabContent = dom.CreateElement("div") dom.SetStyle(appSt.profileTabContent, "padding", "8px 0") dom.AppendChild(wrapper, appSt.profileTabContent) // Restore or default tab. appSt.profileTab = "" if savedTab != "" { selectProfileTab(savedTab, pk) } else { selectProfileTab("notes", pk) } // Save cache refs for this profile. appSt.profileWrapTabCont[pk] = appSt.profileTabContent appSt.profileWrapTabs[pk] = appSt.profileTabBtns appSt.profileWrapTabSel[pk] = appSt.profileTab _ = name // title shown via topBackBtn, not pageTitleEl } func toggleFollow(pk string, btn dom.Element, muteBtn dom.Element) { if !signer.HasSigner() || appSt.pubhex == "" { return } found := false var newFollows []string for _, fpk := range appSt.myFollows { if fpk == pk { found = true continue } newFollows = mxutil.Ensure(newFollows, 1) newFollows = push(newFollows, fpk) } if !found { newFollows = mxutil.Ensure(newFollows, 1) newFollows = push(newFollows, pk) } appSt.myFollows = newFollows appSt.followSet = buildSet(newFollows) if found { dom.SetTextContent(btn, "follow") dom.SetStyle(btn, "background", "var(--accent)") dom.SetStyle(btn, "color", "#fff") dom.SetStyle(btn, "border", "none") } else { dom.SetTextContent(btn, "followed") dom.SetStyle(btn, "background", "var(--bg2)") dom.SetStyle(btn, "color", "var(--fg)") dom.SetStyle(btn, "border", "1px solid var(--border)") // Following unmutes. if isMuted(pk) { unmute(pk, muteBtn) } } // Bump contactTs so stale kind 3 events from slow relays are rejected. appSt.contactTs = dom.NowSeconds() publishFollowList(newFollows) if appSt.feedSelectEl != 0 { populateFeedSelect() } if appSt.feedMode == "follows" { refreshFeed() } } func publishFollowList(follows []string) { tags := "[" for i, pk := range follows { if i > 0 { tags = tags | "," } tags = tags | "[\"p\"," | jstr(pk) | "]" } tags = tags | "]" ts := dom.NowSeconds() unsigned := "{\"kind\":3,\"content\":\"\"" | ",\"tags\":" | tags | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | appSt.pubhex | "\"}" logActivity("follow", "publishing kind 3, " | itoa(len(follows)) | " follows", "calling signer.SignEvent") signer.SignEvent(unsigned, func(signed string) { if signed == "" { logActivity("follow", "FAILED: signer returned empty", "vault locked or signer crashed") return } targets := []string{:len(appSt.relayURLs)} copy(targets, appSt.relayURLs) for _, d := range discoveryRelays { targets = appendUnique(targets, d) } msg := "[\"PUBLISH_TO\"," | signed | ",[" relayList := "" for i, u := range targets { if i > 0 { msg = msg | "," relayList = relayList | "\n" } msg = msg | jstr(u) relayList = relayList | u } routeMsg(msg | "]]") logActivity("follow", "kind 3 sent to " | itoa(len(targets)) | " relays", "relays:\n" | relayList | "\n\nevent:\n" | signed) }) } func isMuted(pk string) (ok bool) { return appSt.muteSet[pk] } func buildSet(pks []string) (m map[string]bool) { s := map[string]bool{} for _, pk := range pks { s[pk] = true } return s } // broadcastFollows pushes the current follow list to Feed and Notif workers. // P_FOLLOWS is outbound from Profile Worker to Shell only; never send it inbound. func broadcastFollows(pks []string) { j := buildJSONStrArr(pks) feed.Send(`["F_SET_FOLLOWS",` | j | `]`) notif.Send(`["N_SET_FOLLOWS",` | j | `]`) } // broadcastMutes pushes the current mute list to Feed and Notif workers. func broadcastMutes(pks []string) { j := buildJSONStrArr(pks) feed.Send(`["F_SET_MUTES",` | j | `]`) notif.Send(`["N_SET_MUTES",` | j | `]`) } // buildJSONStrArr builds a JSON array of quoted strings. func buildJSONStrArr(pks []string) (s string) { s = "[" for i, pk := range pks { if i > 0 { s = s | "," } s = s | jstr(pk) } return s | "]" } // buildURLsJSON builds a JSON array of quoted URL strings. func buildURLsJSON(urls []string) (s string) { return buildJSONStrArr(urls) } // repliesToMuted returns true if ev is a reply whose parent "p" tag is a muted user. func repliesToMuted(ev *nostr.Event) (ok bool) { if ev.Kind != 1 { return false } hasE := false for _, tag := range ev.Tags { if len(tag) >= 2 && tag[0] == "e" { hasE = true break } } if !hasE { return false // not a reply } for _, tag := range ev.Tags { if len(tag) >= 2 && tag[0] == "p" && isMuted(tag[1]) { return true } } return false } func toggleMute(pk string, btn dom.Element, followBtn dom.Element) { if !signer.HasSigner() || appSt.pubhex == "" { return } found := false var newMutes []string for _, m := range appSt.myMutes { if m == pk { found = true continue } newMutes = mxutil.Ensure(newMutes, 1) newMutes = push(newMutes, m) } if !found { newMutes = mxutil.Ensure(newMutes, 1) newMutes = push(newMutes, pk) } appSt.myMutes = newMutes appSt.muteSet = buildSet(newMutes) if found { dom.SetTextContent(btn, "mute") dom.SetStyle(btn, "background", "var(--bg2)") dom.SetStyle(btn, "color", "var(--fg)") dom.SetStyle(btn, "border", "1px solid var(--border)") // Re-fetch profile notes now that user is unmuted. if appSt.profileViewPK == pk && appSt.profileTab == "notes" && appSt.profileTabContent != 0 { clearChildren(appSt.profileTabContent) renderProfileNotes(pk) } } else { dom.SetTextContent(btn, "muted") dom.SetStyle(btn, "background", "#c33") dom.SetStyle(btn, "color", "#fff") dom.SetStyle(btn, "border", "none") // Muting unfollows. if isFollowing(pk) { unfollow(pk, followBtn) } removeMutedNotes(pk) } appSt.muteTs = dom.NowSeconds() publishMuteList(newMutes) } func unmute(pk string, btn dom.Element) { var newMutes []string for _, m := range appSt.myMutes { if m != pk { newMutes = mxutil.Ensure(newMutes, 1) newMutes = push(newMutes, m) } } appSt.myMutes = newMutes appSt.muteSet = buildSet(newMutes) dom.SetTextContent(btn, "mute") dom.SetStyle(btn, "background", "var(--bg2)") dom.SetStyle(btn, "color", "var(--fg)") dom.SetStyle(btn, "border", "1px solid var(--border)") appSt.muteTs = dom.NowSeconds() publishMuteList(newMutes) } func isFollowing(pk string) (ok bool) { return appSt.followSet[pk] } func unfollow(pk string, btn dom.Element) { var newFollows []string for _, fpk := range appSt.myFollows { if fpk != pk { newFollows = mxutil.Ensure(newFollows, 1) newFollows = push(newFollows, fpk) } } appSt.myFollows = newFollows appSt.followSet = buildSet(newFollows) dom.SetTextContent(btn, "follow") dom.SetStyle(btn, "background", "var(--accent)") dom.SetStyle(btn, "color", "#fff") dom.SetStyle(btn, "border", "none") appSt.contactTs = dom.NowSeconds() publishFollowList(newFollows) } func removeMutedNotes(pk string) { // Remove from main feed DOM. removeChildrenByPK(appSt.feedContainer, pk) // Clear profile notes tab if viewing the muted user. if appSt.profileViewPK == pk && appSt.profileTab == "notes" && appSt.profileTabContent != 0 { closeProfileNoteSub() clearChildren(appSt.profileTabContent) } // Purge from feed buffer so muted notes don't reappear on refresh. if len(appSt.feedBuffer) > 0 { var kept []*nostr.Event for _, ev := range appSt.feedBuffer { if ev.PubKey != pk { kept = mxutil.Ensure(kept, 1) kept = push(kept, ev) } } appSt.feedBuffer = kept } // Purge from thread cache if a thread is open. if appSt.threadOpen { for id, ev := range appSt.threadEvents { if ev.PubKey == pk { delete(appSt.threadEvents, id) } } appSt.threadLastRendered = 0 renderThreadTree() } } func publishMuteList(mutes []string) { tags := "[" for i, pk := range mutes { if i > 0 { tags = tags | "," } tags = tags | "[\"p\"," | jstr(pk) | "]" } tags = tags | "]" ts := dom.NowSeconds() unsigned := "{\"kind\":10000,\"content\":\"\"" | ",\"tags\":" | tags | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | appSt.pubhex | "\"}" dom.ConsoleLog("[mute] signing kind 10000 with " | itoa(len(mutes)) | " entries") signer.SignEvent(unsigned, func(signed string) { if signed == "" { dom.ConsoleLog("[mute] sign failed - signer returned empty") return } dom.ConsoleLog("[mute] publishing kind 10000") selected := composeSelectedRelays() if len(selected) > 0 { msg := "[\"PUBLISH_TO\"," | signed | ",[" for i, u := range selected { if i > 0 { msg = msg | "," } msg = msg | jstr(u) } routeMsg(msg | "]]") } else { routeMsg("[\"EVENT\"," | signed | "]") } }) } func profileMetaRow(icon, text, link string) (e dom.Element) { row := dom.CreateElement("div") dom.SetStyle(row, "padding", "4px 0") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "8px") iconEl := dom.CreateElement("span") dom.SetTextContent(iconEl, icon) dom.AppendChild(row, iconEl) if link != "" { href := link if strIndex(href, "://") < 0 { href = "https://" | href } a := dom.CreateElement("a") dom.SetAttribute(a, "href", href) dom.SetAttribute(a, "target", "_blank") dom.SetAttribute(a, "rel", "noopener") dom.SetStyle(a, "color", "var(--accent)") dom.SetStyle(a, "wordBreak", "break-all") dom.SetTextContent(a, text) dom.AppendChild(row, a) } else { span := dom.CreateElement("span") dom.SetStyle(span, "color", "var(--fg)") dom.SetTextContent(span, text) dom.AppendChild(row, span) } return row } // --- Profile tab functions --- func closeProfileNoteSub() { if appSt.activeProfileNoteSub != "" { routeMsg("[\"CLOSE\"," | jstr(appSt.activeProfileNoteSub) | "]") appSt.activeProfileNoteSub = "" } } // refreshProfileTab re-renders the active tab if we're viewing this author's profile. func refreshProfileTab(pk string) { if appSt.profileViewPK != pk || appSt.profileTab == "" { return } // Force re-render by clearing current tab and re-selecting. saved := appSt.profileTab appSt.profileTab = "" selectProfileTab(saved, pk) } func selectProfileTab(tab, pk string) { if tab == appSt.profileTab { return } closeProfileNoteSub() appSt.profileTab = tab clearChildren(appSt.profileTabContent) for id, btn := range appSt.profileTabBtns { if id == tab { dom.SetStyle(btn, "background", "var(--accent)") dom.SetStyle(btn, "color", "#fff") } else { dom.SetStyle(btn, "background", "transparent") dom.SetStyle(btn, "color", "var(--fg)") } } // Update URL hash to reflect active tab. if !appSt.navPop && appSt.profileViewPK != "" { npub := helpers.EncodeNpub(helpers.HexDecode(appSt.profileViewPK)) dom.ReplaceState("/p/" | npub | "#" | tab) } switch tab { case "notes": renderProfileNotes(pk) case "follows": renderProfileFollows(pk) case "relays": renderProfileRelays(pk) case "mutes": renderProfileMutes(pk) } } func renderProfileNotes(pk string) { appSt.profileNotesSeen = map[string]bool{} appSt.profileSubCounter++ subID := "pn-" | itoa(appSt.profileSubCounter) appSt.activeProfileNoteSub = subID proxyRelays := buildProxy(pk) routeMsg(buildProxyMsg(subID, "{\"authors\":[" | jstr(pk) | "],\"kinds\":[1],\"limit\":20}", proxyRelays)) } func renderProfileNote(ev *nostr.Event) { if appSt.profileTabContent == 0 || appSt.profileTab != "notes" { return } if isMuted(ev.PubKey) { return } note := dom.CreateElement("div") dom.SetStyle(note, "borderBottom", "1px solid var(--border)") dom.SetStyle(note, "padding", "12px 16px") // Header row: author link (left) + relay widget + timestamp (right). header := dom.CreateElement("div") dom.SetStyle(header, "display", "flex") dom.SetStyle(header, "alignItems", "center") dom.SetStyle(header, "marginBottom", "4px") dom.SetStyle(header, "maxWidth", "65ch") authorLink := dom.CreateElement("div") dom.SetStyle(authorLink, "display", "flex") dom.SetStyle(authorLink, "alignItems", "center") dom.SetStyle(authorLink, "gap", "8px") dom.SetStyle(authorLink, "cursor", "pointer") headerPK := ev.PubKey dom.AddEventListener(authorLink, "click", dom.RegisterCallback(func() { showProfile(headerPK) })) avatar := dom.CreateElement("img") dom.SetAttribute(avatar, "referrerpolicy", "no-referrer") dom.SetAttribute(avatar, "width", "24") dom.SetAttribute(avatar, "height", "24") dom.SetStyle(avatar, "borderRadius", "50%") dom.SetStyle(avatar, "objectFit", "cover") dom.SetStyle(avatar, "flexShrink", "0") nameSpan := dom.CreateElement("span") dom.SetStyle(nameSpan, "fontSize", "18px") dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(nameSpan, "fontWeight", "bold") dom.SetStyle(nameSpan, "color", "var(--fg)") pk := ev.PubKey if pic, ok := appSt.authorPics[pk]; ok && pic != "" { setMediaSrc(avatar, pic) dom.SetAttribute(avatar, "onerror", "this.style.display='none'") } else { dom.SetStyle(avatar, "display", "none") } if name, ok := appSt.authorNames[pk]; ok && name != "" { dom.SetTextContent(nameSpan, name) } else { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) if len(npub) > 20 { dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:]) } } dom.AppendChild(authorLink, avatar) dom.AppendChild(authorLink, nameSpan) dom.AppendChild(header, authorLink) appendClientTag(header, ev) rw := relayWidget(ev.ID) dom.SetStyle(rw, "marginLeft", "auto") dom.AppendChild(header, rw) if ev.CreatedAt > 0 { tsEl := dom.CreateElement("span") dom.SetTextContent(tsEl, formatTime(ev.CreatedAt)) dom.SetStyle(tsEl, "fontSize", "11px") dom.SetStyle(tsEl, "color", "var(--muted)") dom.SetStyle(tsEl, "cursor", "pointer") dom.SetStyle(tsEl, "flexShrink", "0") evID := ev.ID evRootID := getRootID(ev) if evRootID == "" { evRootID = evID } dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() { showNoteThread(evRootID, evID) })) dom.AppendChild(header, tsEl) } dom.AppendChild(note, header) // Track author link for update when profile arrives. if _, cached := appSt.authorNames[pk]; !cached { appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1) appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1) appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], authorLink) if !lookupFetchedK0(pk) { profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`) } } // Reply preview. addReplyPreview(note, ev) content := dom.CreateElement("div") dom.SetStyle(content, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(content, "fontSize", "14px") dom.SetStyle(content, "lineHeight", "1.5") dom.SetStyle(content, "wordBreak", "break-word") dom.SetStyle(content, "maxWidth", "65ch") dom.SetStyle(content, "overflow", "hidden") dom.SetStyle(content, "maxHeight", "18em") setHTMLWithMedia(content, renderMarkdown(ev.Content)) dom.AppendChild(note, content) more := makeShowMore(content) dom.AppendChild(note, buildActionRow(ev, note, content, more)) dom.AppendChild(appSt.profileTabContent, note) attachShowMore(content, more) resolveEmbeds() } func renderProfileFollows(pk string) { var follows []string if pk == appSt.pubhex { follows = appSt.myFollows } else { follows = appSt.profileFollowsCache[pk] } if len(follows) == 0 { empty := dom.CreateElement("div") dom.SetTextContent(empty, t("no_follows")) dom.SetStyle(empty, "padding", "16px") dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "fontSize", "13px") dom.AppendChild(appSt.profileTabContent, empty) return } countEl := dom.CreateElement("div") dom.SetTextContent(countEl, itoa(len(follows)) | " " | t("following")) dom.SetStyle(countEl, "padding", "8px 16px") dom.SetStyle(countEl, "color", "var(--muted)") dom.SetStyle(countEl, "fontSize", "12px") dom.AppendChild(appSt.profileTabContent, countEl) for i := 0; i < len(follows); i++ { fpk := follows[i] row := makeProfileRow(fpk) dom.AppendChild(appSt.profileTabContent, row) } scheduleTabRetry() } func renderProfileRelays(pk string) { relays := appSt.profileRelaysCache[pk] if len(relays) == 0 { empty := dom.CreateElement("div") dom.SetTextContent(empty, t("no_relays")) dom.SetStyle(empty, "padding", "16px") dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "fontSize", "13px") dom.AppendChild(appSt.profileTabContent, empty) return } for i := 0; i < len(relays); i++ { rURL := relays[i] row := dom.CreateElement("div") dom.SetStyle(row, "padding", "10px 16px") dom.SetStyle(row, "borderBottom", "1px solid var(--border)") dom.SetStyle(row, "cursor", "pointer") dom.SetStyle(row, "fontSize", "13px") urlEl := dom.CreateElement("span") dom.SetTextContent(urlEl, rURL) dom.SetStyle(urlEl, "color", "var(--accent)") dom.AppendChild(row, urlEl) clickURL := rURL dom.AddEventListener(row, "click", dom.RegisterCallback(func() { showRelayInfo(clickURL) })) dom.AppendChild(appSt.profileTabContent, row) } } func renderProfileMutes(pk string) { var mutes []string if pk == appSt.pubhex { mutes = appSt.myMutes } else { mutes = appSt.profileMutesCache[pk] } if len(mutes) == 0 { empty := dom.CreateElement("div") dom.SetTextContent(empty, t("no_mutes")) dom.SetStyle(empty, "padding", "16px") dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "fontSize", "13px") dom.AppendChild(appSt.profileTabContent, empty) return } countEl := dom.CreateElement("div") dom.SetTextContent(countEl, itoa(len(mutes)) | " " | t("muted")) dom.SetStyle(countEl, "padding", "8px 16px") dom.SetStyle(countEl, "color", "var(--muted)") dom.SetStyle(countEl, "fontSize", "12px") dom.AppendChild(appSt.profileTabContent, countEl) for i := 0; i < len(mutes); i++ { mpk := mutes[i] row := makeProfileRow(mpk) dom.AppendChild(appSt.profileTabContent, row) } scheduleTabRetry() } func makeProfileRow(pk string) (e dom.Element) { row := dom.CreateElement("div") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "10px") dom.SetStyle(row, "padding", "10px 16px") dom.SetStyle(row, "borderBottom", "1px solid var(--border)") dom.SetStyle(row, "cursor", "pointer") av := dom.CreateElement("img") dom.SetAttribute(av, "referrerpolicy", "no-referrer") dom.SetAttribute(av, "width", "32") dom.SetAttribute(av, "height", "32") dom.SetStyle(av, "borderRadius", "50%") dom.SetStyle(av, "objectFit", "cover") dom.SetStyle(av, "flexShrink", "0") if pic, ok := appSt.authorPics[pk]; ok && pic != "" { setMediaSrc(av, pic) } else { dom.SetStyle(av, "display", "none") } dom.SetAttribute(av, "onerror", "this.style.display='none'") dom.AppendChild(row, av) nameSpan := dom.CreateElement("span") dom.SetStyle(nameSpan, "fontSize", "14px") dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") if name, ok := appSt.authorNames[pk]; ok && name != "" { dom.SetTextContent(nameSpan, name) } else { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) if len(npub) > 20 { dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:]) } } dom.AppendChild(row, nameSpan) rowPK := pk dom.AddEventListener(row, "click", dom.RegisterCallback(func() { showProfile(rowPK) })) if _, cached := appSt.authorNames[pk]; !cached { appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1) appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], row) if !lookupFetchedK0(pk) { profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`) } } return row } // --- Relay info page --- func showRelayInfo(url string) { appSt.profileViewPK = "" closeProfileNoteSub() // Hide cached profile wrappers but keep them. for _, w := range appSt.profileWrappers { dom.SetStyle(w, "display", "none") } // Remove old relay info page if any. if appSt.relayInfoEl != 0 { dom.RemoveChild(appSt.profilePage, appSt.relayInfoEl) appSt.relayInfoEl = 0 } appSt.relayInfoEl = dom.CreateElement("div") dom.AppendChild(appSt.profilePage, appSt.relayInfoEl) hdr := dom.CreateElement("div") dom.SetStyle(hdr, "display", "flex") dom.SetStyle(hdr, "alignItems", "center") dom.SetStyle(hdr, "gap", "10px") dom.SetStyle(hdr, "padding", "16px") dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)") backBtn := dom.CreateElement("button") dom.SetInnerHTML(backBtn, "←") dom.SetStyle(backBtn, "background", "none") dom.SetStyle(backBtn, "border", "none") dom.SetStyle(backBtn, "fontSize", "20px") dom.SetStyle(backBtn, "cursor", "pointer") dom.SetStyle(backBtn, "color", "var(--fg)") dom.SetStyle(backBtn, "padding", "0") dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() { switchPage("feed") })) dom.AppendChild(hdr, backBtn) urlEl := dom.CreateElement("span") dom.SetTextContent(urlEl, url) dom.SetStyle(urlEl, "fontWeight", "bold") dom.SetStyle(urlEl, "fontSize", "14px") dom.SetStyle(urlEl, "wordBreak", "break-all") dom.AppendChild(hdr, urlEl) dom.AppendChild(appSt.relayInfoEl, hdr) loading := dom.CreateElement("div") dom.SetTextContent(loading, t("loading")) dom.SetStyle(loading, "padding", "16px") dom.SetStyle(loading, "color", "var(--muted)") dom.AppendChild(appSt.relayInfoEl, loading) appSt.activePage = "" switchPage("profile") dom.SetTextContent(appSt.pageTitleEl, t("relay_info")) // Convert wss→https for NIP-11 HTTP fetch. httpURL := url if len(httpURL) > 6 && httpURL[:6] == "wss://" { httpURL = "https://" | httpURL[6:] } else if len(httpURL) > 5 && httpURL[:5] == "ws://" { httpURL = "http://" | httpURL[5:] } dom.FetchRelayInfo(httpURL, func(body string) { dom.RemoveChild(appSt.profilePage, loading) if body == "" { errEl := dom.CreateElement("div") dom.SetTextContent(errEl, t("relay_fail")) dom.SetStyle(errEl, "padding", "16px") dom.SetStyle(errEl, "color", "#e55") dom.AppendChild(appSt.relayInfoEl, errEl) return } renderRelayInfoBody(body) }) } func renderRelayInfoBody(body string) { container := dom.CreateElement("div") dom.SetStyle(container, "padding", "16px") name := helpers.JsonGetString(body, "name") desc := helpers.JsonGetString(body, "description") pk := helpers.JsonGetString(body, "pubkey") contact := helpers.JsonGetString(body, "contact") software := helpers.JsonGetString(body, "software") ver := helpers.JsonGetString(body, "version") if name != "" { el := dom.CreateElement("div") dom.SetTextContent(el, name) dom.SetStyle(el, "fontSize", "20px") dom.SetStyle(el, "fontWeight", "bold") dom.SetStyle(el, "marginBottom", "8px") dom.SetStyle(el, "fontFamily", "system-ui, sans-serif") dom.AppendChild(container, el) } if desc != "" { el := dom.CreateElement("div") dom.SetStyle(el, "fontSize", "14px") dom.SetStyle(el, "lineHeight", "1.5") dom.SetStyle(el, "marginBottom", "12px") dom.SetStyle(el, "wordBreak", "break-word") dom.AppendChild(container, el) setHTMLWithMedia(el, renderMarkdown(desc)) } if contact != "" { dom.AppendChild(container, profileMetaRow("@", contact, "")) } if pk != "" { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) short := npub if len(short) > 20 { short = short[:16] | "..." | short[len(short)-8:] } dom.AppendChild(container, profileMetaRow("pk", short, "")) } if software != "" { label := software if ver != "" { label = label | " " | ver } dom.AppendChild(container, profileMetaRow("sw", label, "")) } dom.AppendChild(appSt.relayInfoEl, container) } // --- Messaging --- func relayURLsJSON() (s string) { msg := "[" for i, url := range appSt.relayURLs { if i > 0 { msg = msg | "," } msg = msg | jstr(url) } return msg | "]" } func readRelayURLsJSON() (s string) { msg := "[" first := true for i, url := range appSt.relayURLs { role := "both" if i < len(appSt.relayRole) { role = appSt.relayRole[i] } if role == "write" { continue } if !first { msg = msg | "," } msg = msg | jstr(url) first = false } return msg | "]" } func formatTime(ts int64) (s string) { if ts == 0 { return "" } d := dom.NowSeconds() - ts if d < 0 { d = 0 } if d < 60 { return "now" } if d < 3600 { return itoa(int32(d/60)) | "m ago" } if d < 86400 { return itoa(int32(d/3600)) | "h ago" } if d < 2592000 { return itoa(int32(d/86400)) | "d ago" } if d < 31536000 { return itoa(int32(d/2592000)) | "mo ago" } return itoa(int32(d/31536000)) | "y ago" } func dismissHoverCard() { if appSt.hoverCardEl != 0 { dom.Remove(appSt.hoverCardEl) appSt.hoverCardEl = 0 appSt.hoverCardParent = 0 appSt.hoverCardPK = "" appSt.hoverCardHover = false } if appSt.hoverCardTimer != 0 { dom.ClearTimeout(appSt.hoverCardTimer) appSt.hoverCardTimer = 0 } if appSt.hoverCardDismiss != 0 { dom.ClearTimeout(appSt.hoverCardDismiss) appSt.hoverCardDismiss = 0 } } func scheduleDismissHoverCard() { if appSt.hoverCardDismiss != 0 { dom.ClearTimeout(appSt.hoverCardDismiss) } appSt.hoverCardDismiss = dom.SetTimeout(func() { appSt.hoverCardDismiss = 0 if !appSt.hoverCardHover { dismissHoverCard() } }, 300) } func showHoverCard(pk string, anchor dom.Element) { if pk == appSt.pubhex { return // don't show hover card for self } dismissHoverCard() appSt.hoverCardPK = pk card := dom.CreateElement("div") dom.SetStyle(card, "position", "fixed") dom.SetStyle(card, "zIndex", "2000") dom.SetStyle(card, "width", "280px") dom.SetStyle(card, "maxWidth", "calc(100vw - 16px)") dom.SetStyle(card, "background", "var(--bg)") dom.SetStyle(card, "border", "1px solid var(--border)") dom.SetStyle(card, "borderRadius", "8px") dom.SetStyle(card, "boxShadow", "0 4px 16px rgba(0,0,0,0.3)") dom.SetStyle(card, "overflow", "hidden") dom.SetStyle(card, "fontFamily", "'Fira Code', monospace") // Banner. content := appSt.profileContentCache[pk] if content != "" { banner := helpers.JsonGetString(content, "banner") if banner != "" { bannerEl := dom.CreateElement("img") dom.SetAttribute(bannerEl, "referrerpolicy", "no-referrer") setMediaSrc(bannerEl, banner) dom.SetStyle(bannerEl, "width", "100%") dom.SetStyle(bannerEl, "height", "80px") dom.SetStyle(bannerEl, "objectFit", "cover") dom.SetStyle(bannerEl, "display", "block") dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'") dom.AppendChild(card, bannerEl) } } // Avatar + name row. infoRow := dom.CreateElement("div") dom.SetStyle(infoRow, "display", "flex") dom.SetStyle(infoRow, "alignItems", "center") dom.SetStyle(infoRow, "gap", "8px") dom.SetStyle(infoRow, "padding", "8px 10px") if pic, ok := appSt.authorPics[pk]; ok && pic != "" { av := dom.CreateElement("img") dom.SetAttribute(av, "referrerpolicy", "no-referrer") setMediaSrc(av, pic) dom.SetAttribute(av, "width", "36") dom.SetAttribute(av, "height", "36") dom.SetStyle(av, "borderRadius", "50%") dom.SetStyle(av, "objectFit", "cover") dom.SetStyle(av, "flexShrink", "0") dom.SetAttribute(av, "onerror", "this.style.display='none'") dom.AppendChild(infoRow, av) } nameElw1 := dom.CreateElement("span") dom.SetStyle(nameElw1, "fontWeight", "bold") dom.SetStyle(nameElw1, "fontSize", "13px") dom.SetStyle(nameElw1, "color", "var(--fg)") if name, ok := appSt.authorNames[pk]; ok && name != "" { dom.SetTextContent(nameElw1, name) } else { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) if len(npub) > 20 { dom.SetTextContent(nameElw1, npub[:12] | "..." | npub[len(npub)-4:]) } } dom.AppendChild(infoRow, nameElw1) dom.AppendChild(card, infoRow) // Button row: follow + mute. if signer.HasSigner() && appSt.pubhex != "" { btnRow := dom.CreateElement("div") dom.SetStyle(btnRow, "display", "flex") dom.SetStyle(btnRow, "gap", "6px") dom.SetStyle(btnRow, "padding", "0 10px 8px") followBtn := dom.CreateElement("button") dom.SetStyle(followBtn, "padding", "4px 10px") dom.SetStyle(followBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(followBtn, "fontSize", "11px") dom.SetStyle(followBtn, "borderRadius", "4px") dom.SetStyle(followBtn, "cursor", "pointer") isFollowingw2 := false if len(appSt.myFollows) > 0 { for _, fpk := range appSt.myFollows { if fpk == pk { isFollowingw2 = true break } } } if isFollowingw2 { dom.SetTextContent(followBtn, "followed") dom.SetStyle(followBtn, "background", "var(--bg2)") dom.SetStyle(followBtn, "color", "var(--fg)") dom.SetStyle(followBtn, "border", "1px solid var(--border)") } else { dom.SetTextContent(followBtn, "follow") dom.SetStyle(followBtn, "background", "var(--accent)") dom.SetStyle(followBtn, "color", "#fff") dom.SetStyle(followBtn, "border", "none") } dom.AppendChild(btnRow, followBtn) muteBtn := dom.CreateElement("button") dom.SetStyle(muteBtn, "padding", "4px 10px") dom.SetStyle(muteBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(muteBtn, "fontSize", "11px") dom.SetStyle(muteBtn, "borderRadius", "4px") dom.SetStyle(muteBtn, "cursor", "pointer") if isMuted(pk) { dom.SetTextContent(muteBtn, "muted") dom.SetStyle(muteBtn, "background", "#c33") dom.SetStyle(muteBtn, "color", "#fff") dom.SetStyle(muteBtn, "border", "none") } else { dom.SetTextContent(muteBtn, "mute") dom.SetStyle(muteBtn, "background", "var(--bg2)") dom.SetStyle(muteBtn, "color", "var(--fg)") dom.SetStyle(muteBtn, "border", "1px solid var(--border)") } hcPK := pk hcFollow := followBtn hcMute := muteBtn dom.AddEventListener(followBtn, "click", dom.RegisterCallback(func() { toggleFollow(hcPK, hcFollow, hcMute) })) dom.AddEventListener(muteBtn, "click", dom.RegisterCallback(func() { toggleMute(hcPK, hcMute, hcFollow) })) dom.AppendChild(btnRow, muteBtn) dom.AppendChild(card, btnRow) } appSt.hoverCardHover = false dom.AddEventListener(card, "mouseenter", dom.RegisterCallback(func() { appSt.hoverCardHover = true if appSt.hoverCardDismiss != 0 { dom.ClearTimeout(appSt.hoverCardDismiss) appSt.hoverCardDismiss = 0 } })) dom.AddEventListener(card, "mouseleave", dom.RegisterCallback(func() { appSt.hoverCardHover = false scheduleDismissHoverCard() })) appSt.hoverCardEl = card appSt.hoverCardParent = dom.Body() dom.AppendChild(dom.Body(), card) dom.GetBoundingRect(anchor, func(left, top, right, bottom, width, height int32) { vw := dom.GetViewportWidth() cardW := 280 if cardW > vw-16 { cardW = vw - 16 } x := left if x+cardW > vw-8 { x = vw - 8 - cardW } if x < 8 { x = 8 } dom.SetStyle(card, "left", itoa(x) | "px") dom.SetStyle(card, "top", itoa(bottom) | "px") }) } func attachHoverCard(authorLink dom.Element, pk string) { dom.SetStyle(authorLink, "position", "relative") hcPK := pk dom.AddEventListener(authorLink, "mouseenter", dom.RegisterCallback(func() { if appSt.hoverCardTimer != 0 { dom.ClearTimeout(appSt.hoverCardTimer) } if appSt.hoverCardDismiss != 0 { dom.ClearTimeout(appSt.hoverCardDismiss) appSt.hoverCardDismiss = 0 } capPK := hcPK capAnchor := authorLink appSt.hoverCardTimer = dom.SetTimeout(func() { appSt.hoverCardTimer = 0 showHoverCard(capPK, capAnchor) }, 400) })) dom.AddEventListener(authorLink, "mouseleave", dom.RegisterCallback(func() { if appSt.hoverCardTimer != 0 { dom.ClearTimeout(appSt.hoverCardTimer) appSt.hoverCardTimer = 0 } scheduleDismissHoverCard() })) } // relayWidget creates a hover tooltip showing which relays delivered a note. func relayWidget(evID string) (e dom.Element) { wrap := dom.CreateElement("span") dom.SetStyle(wrap, "position", "relative") dom.SetStyle(wrap, "flexShrink", "0") dom.SetStyle(wrap, "marginRight", "6px") btn := dom.CreateElement("span") dom.SetTextContent(btn, "\xf0\x9f\x93\xa1") // 📡 dom.SetStyle(btn, "fontSize", "10px") dom.SetStyle(btn, "cursor", "pointer") dom.SetStyle(btn, "opacity", "0.6") dom.AppendChild(wrap, btn) var tip dom.Element capturedID := evID dom.AddEventListener(wrap, "click", dom.RegisterCallback(func() { showRelayModal(capturedID) })) dom.AddEventListener(wrap, "mouseenter", dom.RegisterCallback(func() { if tip != 0 { return } urls := appSt.eventRelays[capturedID] tip = dom.CreateElement("div") dom.SetStyle(tip, "position", "absolute") dom.SetStyle(tip, "top", "22px") dom.SetStyle(tip, "right", "0") dom.SetStyle(tip, "zIndex", "1000") dom.SetStyle(tip, "background", "var(--bg)") dom.SetStyle(tip, "border", "1px solid var(--border)") dom.SetStyle(tip, "borderRadius", "6px") dom.SetStyle(tip, "padding", "6px 10px") dom.SetStyle(tip, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)") dom.SetStyle(tip, "fontSize", "11px") dom.SetStyle(tip, "whiteSpace", "nowrap") dom.SetStyle(tip, "fontFamily", "'Fira Code', monospace") dom.SetStyle(tip, "pointerEvents", "none") if len(urls) == 0 { line := dom.CreateElement("div") dom.SetStyle(line, "color", "var(--muted)") dom.SetTextContent(line, "(no relay data)") dom.AppendChild(tip, line) } else { for _, u := range urls { line := dom.CreateElement("div") dom.SetStyle(line, "padding", "1px 0") dom.SetStyle(line, "color", "var(--fg)") dom.SetTextContent(line, u) dom.AppendChild(tip, line) } } dom.AppendChild(wrap, tip) })) dom.AddEventListener(wrap, "mouseleave", dom.RegisterCallback(func() { if tip != 0 { dom.RemoveChild(wrap, tip) tip = 0 } })) return wrap } func showRelayModal(evID string) { overlay := dom.CreateElement("div") dom.SetStyle(overlay, "position", "fixed") dom.SetStyle(overlay, "top", "0") dom.SetStyle(overlay, "left", "0") dom.SetStyle(overlay, "width", "100%") dom.SetStyle(overlay, "height", "100%") dom.SetStyle(overlay, "background", "rgba(0,0,0,0.5)") dom.SetStyle(overlay, "zIndex", "500") dom.SetStyle(overlay, "display", "flex") dom.SetStyle(overlay, "alignItems", "center") dom.SetStyle(overlay, "justifyContent", "center") modal := dom.CreateElement("div") dom.SetStyle(modal, "background", "var(--bg)") dom.SetStyle(modal, "border", "1px solid var(--border)") dom.SetStyle(modal, "borderRadius", "8px") dom.SetStyle(modal, "maxWidth", "90vw") dom.SetStyle(modal, "maxHeight", "70vh") dom.SetStyle(modal, "display", "flex") dom.SetStyle(modal, "flexDirection", "column") dom.SetStyle(modal, "boxShadow", "0 4px 24px rgba(0,0,0,0.3)") // Header. hdr := dom.CreateElement("div") dom.SetStyle(hdr, "display", "flex") dom.SetStyle(hdr, "alignItems", "center") dom.SetStyle(hdr, "justifyContent", "space-between") dom.SetStyle(hdr, "padding", "12px 16px") dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)") title := dom.CreateElement("span") dom.SetTextContent(title, "note seen on:") dom.SetStyle(title, "fontWeight", "bold") dom.SetStyle(title, "fontSize", "14px") dom.AppendChild(hdr, title) closeBtn := dom.CreateElement("button") dom.SetTextContent(closeBtn, "\xc3\x97") // × dom.SetStyle(closeBtn, "background", "transparent") dom.SetStyle(closeBtn, "border", "none") dom.SetStyle(closeBtn, "fontSize", "20px") dom.SetStyle(closeBtn, "cursor", "pointer") dom.SetStyle(closeBtn, "color", "var(--muted)") dom.SetStyle(closeBtn, "padding", "0 4px") dom.AppendChild(hdr, closeBtn) dom.AppendChild(modal, hdr) // Scrollable list - rebuilt by renderList() on open and on SEEN_ON updates. list := dom.CreateElement("div") dom.SetStyle(list, "overflowY", "auto") dom.SetStyle(list, "padding", "12px 16px") dom.AppendChild(modal, list) renderList := func() { dom.ReleaseChildren(list) dom.SetInnerHTML(list, "") urls := appSt.eventRelays[evID] if len(urls) == 0 { line := dom.CreateElement("div") dom.SetStyle(line, "color", "var(--muted)") dom.SetStyle(line, "fontSize", "13px") dom.SetTextContent(line, "(no relay data yet - waiting for OK)") dom.AppendChild(list, line) return } for _, u := range urls { row := dom.CreateElement("div") dom.SetStyle(row, "padding", "6px 0") dom.SetStyle(row, "borderBottom", "1px solid var(--border)") dom.SetStyle(row, "fontSize", "13px") dom.SetStyle(row, "fontFamily", "'Fira Code', monospace") dom.SetStyle(row, "wordBreak", "break-all") dom.SetTextContent(row, u) dom.AppendChild(list, row) } } renderList() appSt.seenOnSubs[evID] = renderList // Rebroadcast button - publish this event to all write relays. if appSt.pubhex != "" { ev, _ := lookupEvent(evID) if ev != nil { footer := dom.CreateElement("div") dom.SetStyle(footer, "padding", "8px 16px 12px") dom.SetStyle(footer, "borderTop", "1px solid var(--border)") rbtn := dom.CreateElement("span") dom.SetTextContent(rbtn, "rebroadcast to all relays") dom.SetStyle(rbtn, "cursor", "pointer") dom.SetStyle(rbtn, "color", "var(--accent)") dom.SetStyle(rbtn, "fontSize", "13px") dom.SetStyle(rbtn, "fontFamily", "'Fira Code', monospace") evJSON := ev.ToJSON() capturedEvID := ev.ID dom.AddEventListener(rbtn, "click", dom.RegisterCallback(func() { dom.SetTextContent(rbtn, "sending...") dom.SetStyle(rbtn, "color", "var(--muted)") dom.SetStyle(rbtn, "cursor", "default") msg := "[\"PUBLISH_TO\"," | evJSON | ",[" relayList := "" for i, url := range appSt.relayURLs { if i > 0 { msg = msg | "," relayList = relayList | "\n" } msg = msg | jstr(url) relayList = relayList | url } routeMsg(msg | "]]") logActivity("rebroadcast", capturedEvID[:12] | " to " | itoa(len(appSt.relayURLs)) | " relays", "event " | capturedEvID | "\nrelays:\n" | relayList) dom.SetTimeout(func() { dom.SetTextContent(rbtn, "sent \u2713") }, 500) })) dom.AppendChild(footer, rbtn) dom.AppendChild(modal, footer) } } dom.AppendChild(overlay, modal) closeFn := dom.RegisterCallback(func() { delete(appSt.seenOnSubs, evID) dom.RemoveChild(dom.Body(), overlay) }) dom.AddEventListener(closeBtn, "click", closeFn) dom.AddSelfEventListener(overlay, "click", closeFn) dom.AppendChild(dom.Body(), overlay) } // --- Emoji data --- // emojiList is defined in emoji_data.mx (1761 entries from Unicode 16.0). type emojiEntry struct { emoji string name string } func initEmoji() { appSt.emojiByName = map[string]string{} for _, e := range emojiList { appSt.emojiByName[":" | e.name | ":"] = e.emoji } } // replaceEmojiCodes replaces :name: shortcodes in text with emoji characters. func replaceEmojiCodes(s string) (sv string) { out := "" start := 0 i := 0 for i < len(s) { if s[i] == ':' { end := -1 for j := i + 1; j < len(s) && j < i+30; j++ { if s[j] == ':' { end = j break } if s[j] == ' ' || s[j] == '\n' { break } } if end > 0 { code := s[i : end+1] if emoji, ok2 := appSt.emojiByName[code]; ok2 { out = out | s[start:i] | emoji i = end + 1 start = i continue } } } i++ } return out | s[start:] } // --- Action button row --- type zapRowPending struct { row dom.Element ev *nostr.Event } // attachZapButton appends the zap button + total span to the end of an // action button row, and registers the total element for 9735 updates. func attachZapButton(row dom.Element, ev *nostr.Event) { zapWrap := dom.CreateElement("span") dom.SetStyle(zapWrap, "display", "inline-flex") dom.SetStyle(zapWrap, "alignItems", "center") dom.SetStyle(zapWrap, "gap", "4px") zapBtn := actionBtn(svgZap) capturedZapEv := ev dom.AddEventListener(zapBtn, "click", dom.RegisterCallback(func() { showZapModal(capturedZapEv) })) dom.AppendChild(zapWrap, zapBtn) zapTotalEl := dom.CreateElement("span") dom.SetStyle(zapTotalEl, "fontSize", "12px") dom.SetStyle(zapTotalEl, "color", "var(--muted)") dom.SetStyle(zapTotalEl, "whiteSpace", "nowrap") if msats, ok := appSt.noteZapTotals[ev.ID]; ok && msats > 0 { dom.SetTextContent(zapTotalEl, "\xe2\x9a\xa1 " | i64toa(msats/1000) | " sats") } dom.AppendChild(zapWrap, zapTotalEl) appSt.noteZapEls[ev.ID] = zapTotalEl moreEl := dom.QuerySelectorFrom(row, "[data-more]") if moreEl != 0 { dom.InsertBefore(row, zapWrap, moreEl) } else { dom.AppendChild(row, zapWrap) } } // applyZapButtonsForAuthor injects zap buttons into any action rows that // were built before this author's lud16 was known. func applyZapButtonsForAuthor(pk string) { pending, ok := appSt.pendingZapRows[pk] if !ok { return } delete(appSt.pendingZapRows, pk) for _, p := range pending { attachZapButton(p.row, p.ev) } } func buildActionRow(ev *nostr.Event, noteEl dom.Element, contentEl dom.Element, moreEl dom.Element) (e dom.Element) { wrap := dom.CreateElement("div") dom.SetStyle(wrap, "marginTop", "8px") dom.SetStyle(wrap, "maxWidth", "65ch") // Cache the event for repost/quote content. setEvent(ev.ID, ev) fillEmbed(ev) // Reaction display - own line above buttons. reactDisplay := dom.CreateElement("div") dom.SetStyle(reactDisplay, "display", "flex") dom.SetStyle(reactDisplay, "flexWrap", "wrap") dom.SetStyle(reactDisplay, "alignItems", "center") dom.SetStyle(reactDisplay, "gap", "4px") dom.SetStyle(reactDisplay, "fontSize", "12px") dom.SetStyle(reactDisplay, "marginBottom", "4px") if _, ok2 := appSt.noteReactionMap[ev.ID]; ok2 { fillReactionDisplay(reactDisplay, ev.ID) } appSt.noteReactionEls[ev.ID] = reactDisplay dom.AppendChild(wrap, reactDisplay) // Button row. row := dom.CreateElement("div") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "16px") // Reply button. replyBtn := actionBtn(svgReply) capturedEv1 := ev capturedNote1 := noteEl dom.AddEventListener(replyBtn, "click", dom.RegisterCallback(func() { showEditor("reply", capturedEv1, capturedNote1) })) dom.AppendChild(row, replyBtn) // Quote button. quoteBtn := actionBtn(svgQuote) capturedEv2 := ev capturedNote2 := noteEl dom.AddEventListener(quoteBtn, "click", dom.RegisterCallback(func() { showEditor("quote", capturedEv2, capturedNote2) })) dom.AppendChild(row, quoteBtn) // Repost button + counter. repostWrap := dom.CreateElement("span") dom.SetStyle(repostWrap, "display", "flex") dom.SetStyle(repostWrap, "alignItems", "center") dom.SetStyle(repostWrap, "gap", "4px") repostBtn := actionBtn(svgRepost) capturedEv3 := ev dom.AddEventListener(repostBtn, "click", dom.RegisterCallback(func() { onRepostClick(capturedEv3) })) dom.AppendChild(repostWrap, repostBtn) repostCount := dom.CreateElement("span") dom.SetStyle(repostCount, "fontSize", "12px") dom.SetStyle(repostCount, "color", "var(--muted)") if pks, ok := appSt.noteRepostCounts[ev.ID]; ok && len(pks) > 0 { dom.SetTextContent(repostCount, itoa(len(pks))) } dom.AppendChild(repostWrap, repostCount) appSt.noteRepostEls[ev.ID] = repostCount dom.AppendChild(row, repostWrap) // React button. reactWrap := dom.CreateElement("span") dom.SetStyle(reactWrap, "display", "inline-flex") dom.SetStyle(reactWrap, "alignItems", "center") reactBtn := actionBtn(svgReact) capturedEv4 := ev capturedWrap := reactWrap dom.AddEventListener(reactBtn, "click", dom.RegisterCallback(func() { showEmojiPicker(capturedEv4, capturedWrap) })) dom.AppendChild(reactWrap, reactBtn) dom.AppendChild(row, reactWrap) // Zap button - right of react. Only rendered if the author has a // lightning address. If the profile isn't cached yet, queue this row so // applyAuthorProfile can inject the button when it arrives. authorHasLN := false if content, ok2 := appSt.profileContentCache[ev.PubKey]; ok2 { if helpers.JsonGetString(content, "lud16") != "" || helpers.JsonGetString(content, "lud06") != "" { authorHasLN = true } } if authorHasLN { attachZapButton(row, ev) } else { appSt.pendingZapRows[ev.PubKey] = mxutil.Ensure(appSt.pendingZapRows[ev.PubKey], 1) appSt.pendingZapRows[ev.PubKey] = mxutil.Ensure(appSt.pendingZapRows[ev.PubKey], 1) appSt.pendingZapRows[ev.PubKey] = push(appSt.pendingZapRows[ev.PubKey], zapRowPending{row: row, ev: ev}) } if moreEl != 0 { dom.SetStyle(moreEl, "marginLeft", "auto") dom.SetAttribute(moreEl, "data-more", "1") dom.AppendChild(row, moreEl) } dom.AppendChild(wrap, row) // Queue fetch for kind 6/7 if not already fetched. queueActionFetch(ev.ID) return wrap } func actionBtn(svgHTML string) (e dom.Element) { btn := dom.CreateElement("span") dom.SetInnerHTML(btn, svgHTML) dom.SetStyle(btn, "cursor", "pointer") dom.SetStyle(btn, "display", "inline-flex") dom.SetStyle(btn, "alignItems", "center") dom.SetStyle(btn, "color", "var(--muted)") dom.SetStyle(btn, "opacity", "0.6") dom.SetStyle(btn, "userSelect", "none") capturedBtn := btn dom.AddEventListener(btn, "mouseenter", dom.RegisterCallback(func() { dom.SetStyle(capturedBtn, "opacity", "1") })) dom.AddEventListener(btn, "mouseleave", dom.RegisterCallback(func() { dom.SetStyle(capturedBtn, "opacity", "0.5") })) return btn } // --- Reaction/repost display --- func fillReactionDisplay(el dom.Element, evID string) { rm, ok := appSt.noteReactionMap[evID] if !ok { return } // Sort by count descending (simple selection sort). type ec struct { emoji string count int32 } var sorted []ec for e, pks := range rm { sorted = mxutil.Ensure(sorted, 1) sorted = push(sorted, ec{e, len(pks)}) } for i := 0; i < len(sorted); i++ { for j := i + 1; j < len(sorted); j++ { if sorted[j].count > sorted[i].count { sorted[i], sorted[j] = sorted[j], sorted[i] } } } clearChildren(el) for i, e := range sorted { if i >= 8 { more := dom.CreateElement("span") dom.SetTextContent(more, "...") dom.SetStyle(more, "color", "var(--muted)") dom.AppendChild(el, more) break } chip := dom.CreateElement("span") dom.SetTextContent(chip, e.emoji | " " | itoa(e.count)) dom.SetStyle(chip, "whiteSpace", "nowrap") dom.AppendChild(el, chip) } } // --- Reaction/repost fetching --- func queueActionFetch(evID string) { if appSt.actionFetchedIDs[evID] { return } appSt.actionFetchedIDs[evID] = true appSt.actionFetchQueue = mxutil.Ensure(appSt.actionFetchQueue, 1) appSt.actionFetchQueue = push(appSt.actionFetchQueue, evID) if appSt.actionFetchTimer != 0 { dom.ClearTimeout(appSt.actionFetchTimer) } appSt.actionFetchTimer = dom.SetTimeout(func() { appSt.actionFetchTimer = 0 flushActionFetch() }, 400) } func flushActionFetch() { if len(appSt.actionFetchQueue) == 0 { return } queue := appSt.actionFetchQueue appSt.actionFetchQueue = nil const batchSize = 20 for i := 0; i < len(queue); i += batchSize { end := i + batchSize if end > len(queue) { end = len(queue) } chunk := queue[i:end] ids := "[" for j, id := range chunk { if j > 0 { ids = ids | "," } ids = ids | jstr(id) } ids = ids | "]" appSt.actionSubCounter++ subID := "act-" | itoa(appSt.actionSubCounter) var urls []string seen := map[string]bool{} for _, u := range appSt.relayURLs { if !seen[u] { seen[u] = true urls = mxutil.Ensure(urls, 1) urls = push(urls, u) } } for _, u := range topRelays(4) { if !seen[u] { seen[u] = true urls = mxutil.Ensure(urls, 1) urls = push(urls, u) } } routeMsg(buildProxyMsg(subID, "{\"kinds\":[6,7,9735],\"#e\":" | ids | ",\"limit\":500}", urls)) } } func handleActionEvent(ev *nostr.Event) { if appSt.noteRepostCounts == nil || appSt.noteReactionMap == nil || appSt.noteRepostEls == nil || appSt.noteReactionEls == nil { return } // Find which event this references. var targetID string for _, tag := range ev.Tags { if len(tag) >= 2 && tag[0] == "e" { targetID = tag[1] break } } if targetID == "" { return } if ev.Kind == 6 { pks := appSt.noteRepostCounts[targetID] if pks == nil { pks = map[string]bool{} appSt.noteRepostCounts[targetID] = pks } if !pks[ev.PubKey] { pks[ev.PubKey] = true if el, ok2 := appSt.noteRepostEls[targetID]; ok2 { dom.SetTextContent(el, itoa(len(pks))) } } } else if ev.Kind == 9735 { // Zap receipt: accumulate msat amount from the bolt11 tag (ground truth). seen := appSt.noteZapSeen[targetID] if seen == nil { seen = map[string]bool{} appSt.noteZapSeen[targetID] = seen } if seen[ev.ID] { return } seen[ev.ID] = true var bolt11 string for _, tag := range ev.Tags { if len(tag) >= 2 && tag[0] == "bolt11" { bolt11 = tag[1] break } } if bolt11 == "" { return } msats := parseBolt11Msat(bolt11) if msats <= 0 { return } cur := int64(0) if v, ok2 := appSt.noteZapTotals[targetID]; ok2 { cur = v } cur = cur + msats appSt.noteZapTotals[targetID] = cur if el, ok2 := appSt.noteZapEls[targetID]; ok2 { dom.SetTextContent(el, "\xe2\x9a\xa1 " | i64toa(cur/1000) | " sats") } } else if ev.Kind == 7 { emoji := ev.Content if emoji == "" || emoji == " | " { emoji = "\xe2\x9d\xa4\xef\xb8\x8f" } inner := appSt.noteReactionMap[targetID] if inner == nil { inner = map[string]map[string]bool{} } pks := inner[emoji] if pks == nil { pks = map[string]bool{} } if !pks[ev.PubKey] { pks[ev.PubKey] = true inner[emoji] = pks appSt.noteReactionMap[targetID] = inner if el, ok2 := appSt.noteReactionEls[targetID]; ok2 { fillReactionDisplay(el, targetID) } } } } // --- Repost --- func onRepostClick(ev *nostr.Event) { if !signer.HasSigner() || appSt.pubhex == "" { return } if !dom.Confirm("Repost this note?") { return } relay := "" if urls, ok := appSt.eventRelays[ev.ID]; ok && len(urls) > 0 { relay = urls[0] } tags := "[[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | "],[\"p\"," | jstr(ev.PubKey) | "]]" ts := dom.NowSeconds() unsigned := "{\"kind\":6,\"content\":" | jstr(ev.ToJSON()) | ",\"tags\":" | tags | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | appSt.pubhex | "\"}" signer.SignEvent(unsigned, func(signed string) { if signed != "" { selected := composeSelectedRelays() if len(selected) > 0 { msg := "[\"PUBLISH_TO\"," | signed | ",[" for i, u := range selected { if i > 0 { msg = msg | "," } msg = msg | jstr(u) } routeMsg(msg | "]]") } else { routeMsg("[\"EVENT\"," | signed | "]") } pks := appSt.noteRepostCounts[ev.ID] if pks == nil { pks = map[string]bool{} appSt.noteRepostCounts[ev.ID] = pks } pks[appSt.pubhex] = true if el, ok2 := appSt.noteRepostEls[ev.ID]; ok2 { dom.SetTextContent(el, itoa(len(pks))) } } }) } // --- Compose editor (reply + quote) --- func editorDraftKey(mode string, evID string) (s string) { return mode | ":" | evID } func showEditor(mode string, ev *nostr.Event, noteEl dom.Element) { if !signer.HasSigner() || appSt.pubhex == "" { return } if appSt.editorOpen { closeEditor() } appSt.editorOpen = true appSt.editorPreviewing = false appSt.editorMode = mode appSt.editorTargetEv = ev appSt.editorNoteEl = noteEl composer := dom.CreateElement("div") dom.SetStyle(composer, "borderTop", "1px solid var(--border)") dom.SetStyle(composer, "marginTop", "8px") dom.SetStyle(composer, "paddingTop", "8px") dom.SetStyle(composer, "maxWidth", "65ch") dom.SetStyle(composer, "display", "flex") dom.SetStyle(composer, "flexDirection", "column") dom.SetStyle(composer, "gap", "8px") appSt.editorComposer = composer // Header row: mode label + preview toggle + close. headerRow := dom.CreateElement("div") dom.SetStyle(headerRow, "display", "flex") dom.SetStyle(headerRow, "alignItems", "center") dom.SetStyle(headerRow, "gap", "8px") modeLabel := dom.CreateElement("span") dom.SetStyle(modeLabel, "fontSize", "13px") dom.SetStyle(modeLabel, "fontWeight", "bold") dom.SetStyle(modeLabel, "color", "var(--muted)") if mode == "reply" { dom.SetTextContent(modeLabel, "Reply") } else { dom.SetTextContent(modeLabel, "Quote") } dom.AppendChild(headerRow, modeLabel) editorImgBtn := dom.CreateElement("button") dom.SetInnerHTML(editorImgBtn, ``) dom.SetStyle(editorImgBtn, "padding", "2px 6px") dom.SetStyle(editorImgBtn, "border", "1px solid var(--border)") dom.SetStyle(editorImgBtn, "borderRadius", "4px") dom.SetStyle(editorImgBtn, "background", "transparent") dom.SetStyle(editorImgBtn, "color", "var(--fg)") dom.SetStyle(editorImgBtn, "cursor", "pointer") dom.SetStyle(editorImgBtn, "display", "flex") dom.SetStyle(editorImgBtn, "alignItems", "center") dom.AddEventListener(editorImgBtn, "click", dom.RegisterCallback(func() { dom.PickFileBase64("image/*,video/*", func(b64, mime string) { if b64 != "" { blossomUpload(b64, mime, appSt.editorTextarea) } }) })) dom.AppendChild(headerRow, editorImgBtn) previewBtn := dom.CreateElement("button") dom.SetTextContent(previewBtn, "Preview") dom.SetStyle(previewBtn, "padding", "2px 8px") dom.SetStyle(previewBtn, "border", "1px solid var(--border)") dom.SetStyle(previewBtn, "borderRadius", "4px") dom.SetStyle(previewBtn, "background", "transparent") dom.SetStyle(previewBtn, "color", "var(--fg)") dom.SetStyle(previewBtn, "cursor", "pointer") dom.SetStyle(previewBtn, "fontSize", "11px") dom.AppendChild(headerRow, previewBtn) closeBtn := dom.CreateElement("span") dom.SetTextContent(closeBtn, "\u2715") dom.SetStyle(closeBtn, "marginLeft", "auto") dom.SetStyle(closeBtn, "cursor", "pointer") dom.SetStyle(closeBtn, "color", "var(--muted)") dom.SetStyle(closeBtn, "fontSize", "14px") dom.AddEventListener(closeBtn, "click", dom.RegisterCallback(func() { closeEditor() })) dom.AppendChild(headerRow, closeBtn) dom.AppendChild(composer, headerRow) ta := dom.CreateElement("textarea") dom.SetStyle(ta, "width", "100%") dom.SetStyle(ta, "minHeight", "80px") dom.SetStyle(ta, "resize", "vertical") dom.SetStyle(ta, "overflowY", "auto") dom.SetStyle(ta, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(ta, "fontSize", "14px") dom.SetStyle(ta, "padding", "8px") dom.SetStyle(ta, "border", "1px solid var(--border)") dom.SetStyle(ta, "borderRadius", "4px") dom.SetStyle(ta, "background", "var(--bg)") dom.SetStyle(ta, "color", "var(--fg)") dom.SetStyle(ta, "boxSizing", "border-box") dom.SetAttribute(ta, "onkeydown", "if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();this.parentNode.querySelector('[data-publish-btn]').click()}") appSt.editorTextarea = ta // Restore draft or set initial content. draftKey := editorDraftKey(mode, ev.ID) if draft, ok2 := appSt.editorDrafts[draftKey]; ok2 { dom.SetProperty(ta, "value", draft) } else if mode == "quote" { relay := "" if urls, ok := appSt.eventRelays[ev.ID]; ok && len(urls) > 0 { relay = urls[0] } var relays []string if relay != "" { relays = []string{relay} } nevent := helpers.EncodeNevent(ev.ID, relays, ev.PubKey) dom.SetProperty(ta, "value", "nostr:" | nevent | "\n\n") } wireEmojiHandler(ta) wireBlossomHandlers(ta) wireMentionHandler(ta) dom.AppendChild(composer, ta) // Markdown preview area (hidden initially). prevArea := dom.CreateElement("div") dom.SetStyle(prevArea, "display", "none") dom.SetStyle(prevArea, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(prevArea, "fontSize", "14px") dom.SetStyle(prevArea, "lineHeight", "1.5") dom.SetStyle(prevArea, "padding", "8px") dom.SetStyle(prevArea, "border", "1px solid var(--border)") dom.SetStyle(prevArea, "borderRadius", "4px") dom.SetStyle(prevArea, "minHeight", "80px") dom.SetStyle(prevArea, "wordBreak", "break-word") dom.SetStyle(prevArea, "overflowY", "auto") appSt.editorPreviewEl = prevArea dom.AppendChild(composer, prevArea) // Preview toggle. capturedTa := ta capturedPrev := prevArea capturedBtn := previewBtn dom.AddEventListener(previewBtn, "click", dom.RegisterCallback(func() { appSt.editorPreviewing = !appSt.editorPreviewing if appSt.editorPreviewing { text := dom.GetProperty(capturedTa, "value") text = replaceEmojiCodes(text) setHTMLWithMedia(capturedPrev, renderMarkdown(text)) resolveEmbeds() dom.SetStyle(capturedTa, "display", "none") dom.SetStyle(capturedPrev, "display", "block") dom.SetTextContent(capturedBtn, "Edit") dom.SetStyle(capturedBtn, "background", "var(--accent)") dom.SetStyle(capturedBtn, "color", "#fff") dom.SetStyle(capturedBtn, "border", "1px solid var(--accent)") } else { dom.SetStyle(capturedTa, "display", "") dom.SetStyle(capturedPrev, "display", "none") dom.SetTextContent(capturedBtn, "Preview") dom.SetStyle(capturedBtn, "background", "transparent") dom.SetStyle(capturedBtn, "color", "var(--fg)") dom.SetStyle(capturedBtn, "border", "1px solid var(--border)") } })) // Button row. btnRow := dom.CreateElement("div") dom.SetStyle(btnRow, "display", "flex") dom.SetStyle(btnRow, "justifyContent", "flex-end") dom.SetStyle(btnRow, "gap", "8px") pubBtn := dom.CreateElement("button") dom.SetTextContent(pubBtn, "Publish") dom.SetAttribute(pubBtn, "data-publish-btn", "1") dom.SetStyle(pubBtn, "padding", "5px 14px") dom.SetStyle(pubBtn, "border", "none") dom.SetStyle(pubBtn, "borderRadius", "4px") dom.SetStyle(pubBtn, "background", "var(--accent)") dom.SetStyle(pubBtn, "color", "#fff") dom.SetStyle(pubBtn, "cursor", "pointer") dom.SetStyle(pubBtn, "fontWeight", "bold") dom.SetStyle(pubBtn, "fontSize", "13px") dom.AddEventListener(pubBtn, "click", dom.RegisterCallback(func() { publishEditorContent() })) dom.AppendChild(btnRow, pubBtn) dom.AppendChild(composer, btnRow) dom.AppendChild(noteEl, composer) dom.Focus(ta) } func closeEditor() { // Save draft before closing. if appSt.editorTextarea != 0 && appSt.editorTargetEv != nil { text := dom.GetProperty(appSt.editorTextarea, "value") key := editorDraftKey(appSt.editorMode, appSt.editorTargetEv.ID) if text != "" { appSt.editorDrafts[key] = text } else { delete(appSt.editorDrafts, key) } } if appSt.editorComposer != 0 && appSt.editorNoteEl != 0 { dom.RemoveChild(appSt.editorNoteEl, appSt.editorComposer) appSt.editorComposer = 0 appSt.editorNoteEl = 0 } appSt.editorOpen = false appSt.editorTargetEv = nil appSt.editorTextarea = 0 appSt.editorPreviewEl = 0 appSt.editorPreviewing = false closeEmojiAuto() closeMentionPopup() } func publishEditorContent() { dom.ConsoleLog("[publish-reply] enter") if appSt.editorTargetEv == nil || appSt.editorTextarea == 0 { dom.ConsoleLog("[publish-reply] no target or textarea") return } content := dom.GetProperty(appSt.editorTextarea, "value") dom.ConsoleLog("[publish-reply] content len=" | itoa(len(content))) content = replaceEmojiCodes(content) if content == "" { dom.ConsoleLog("[publish-reply] empty after emoji replace") return } ev := appSt.editorTargetEv mode := appSt.editorMode // Clear draft on publish. delete(appSt.editorDrafts, editorDraftKey(mode, ev.ID)) closeEditor() relay := "" if urls, ok := appSt.eventRelays[ev.ID]; ok && len(urls) > 0 { relay = urls[0] } // Build tags. tags := "[" rootID := getRootID(ev) if mode == "reply" { // e-tags: root + reply. if rootID != "" && rootID != ev.ID { tags = tags | "[\"e\"," | jstr(rootID) | "," | jstr(relay) | ",\"root\"]," tags = tags | "[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | ",\"reply\"]" } else { // Replying to a root note - it becomes the root, our reply points to it. tags = tags | "[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | ",\"root\"]" } // p-tags: collect all users in the chain. ptags := map[string]bool{} for _, tag := range ev.Tags { if len(tag) >= 2 && tag[0] == "p" && tag[1] != appSt.pubhex { ptags[tag[1]] = true } } if ev.PubKey != appSt.pubhex { ptags[ev.PubKey] = true } for pk := range ptags { tags = tags | ",[\"p\"," | jstr(pk) | "]" } } else { // Quote: q-tag + p-tag for quoted author. tags = tags | "[\"q\"," | jstr(ev.ID) | "]" if ev.PubKey != appSt.pubhex { tags = tags | ",[\"p\"," | jstr(ev.PubKey) | "]" } } // Client tag. if localstorage.GetItem("musiquay-client-tag") != "0" { tags = tags | ",[\"client\",\"https://smesh.lol\"]" } tags = tags | "]" ts := dom.NowSeconds() unsigned := "{\"kind\":1,\"content\":" | jstr(content) | ",\"tags\":" | tags | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | appSt.pubhex | "\"}" dom.ConsoleLog("[publish-reply] calling SignEvent") signer.SignEvent(unsigned, func(signed string) { if signed == "" { logActivity("reply", "FAILED: signer error (vault locked?)", unsigned) dom.Confirm("publish failed: signer error (vault locked?)") return } // Publish to selected relays (or all if none selected). selected := composeSelectedRelays() preview := content if len(preview) > 80 { preview = preview[:80] | "..." } relayList := "" for j, u := range selected { if j > 0 { relayList = relayList | "\n" } relayList = relayList | u } if len(selected) > 0 { msg := "[\"PUBLISH_TO\"," | signed | ",[" for i, u := range selected { if i > 0 { msg = msg | "," } msg = msg | jstr(u) } routeMsg(msg | "]]") } else { routeMsg("[\"EVENT\"," | signed | "]") } logActivity("reply", preview, "relays:\n" | relayList | "\n\nevent:\n" | signed) published := nostr.ParseEvent(signed) if published != nil { insertPublishedNote(published) } }) } func publishDelete(targetID string) { if !dom.Confirm("Delete this note?") { return } ts := dom.NowSeconds() unsigned := "{\"kind\":5,\"content\":\"\"" | ",\"tags\":[[\"e\"," | jstr(targetID) | "]]" | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | appSt.pubhex | "\"}" signer.SignEvent(unsigned, func(signed string) { if signed == "" { return } delEv := nostr.ParseEvent(signed) if delEv != nil { appSt.pendingDeletes[delEv.ID] = targetID delID := delEv.ID dom.SetTimeout(func() { delete(appSt.pendingDeletes, delID) }, 30000) } routeMsg("[\"EVENT\"," | signed | "]") }) } func removeNoteFromDOM(evID string) { el, ok := appSt.noteElements[evID] if !ok { return } dom.Remove(el) delete(appSt.noteElements, evID) delete(appSt.seenEvents, evID) } func fetchDeletesForFeed() { // Collect event IDs from rendered notes. Presize from the map: push stops // at the first slice's capacity, so a nil sink trapped once five notes had // been rendered. ids := []string{:0:len(appSt.noteElements)} for id := range appSt.noteElements { ids = mxutil.Ensure(ids, 1) ids = push(ids, id) } if len(ids) == 0 { return } // Build a filter for kind 5 events referencing these note IDs. eList := jstr(ids[0]) for i := 1; i < len(ids); i++ { eList = eList | "," | jstr(ids[i]) } filter := "{\"kinds\":[5],\"#e\":[" | eList | "]}" routeMsg(buildProxyMsg("del-scan", filter, feedRelays())) } func buildComposePage() { appSt.composeTextarea = dom.CreateElement("textarea") dom.SetAttribute(appSt.composeTextarea, "placeholder", "What's on your mind?") dom.SetStyle(appSt.composeTextarea, "width", "100%") dom.SetStyle(appSt.composeTextarea, "minHeight", "160px") dom.SetStyle(appSt.composeTextarea, "flex", "1") dom.SetStyle(appSt.composeTextarea, "padding", "12px") dom.SetStyle(appSt.composeTextarea, "fontSize", "15px") dom.SetStyle(appSt.composeTextarea, "lineHeight", "1.5") dom.SetStyle(appSt.composeTextarea, "border", "1px solid var(--border)") dom.SetStyle(appSt.composeTextarea, "borderRadius", "8px") dom.SetStyle(appSt.composeTextarea, "background", "var(--bg)") dom.SetStyle(appSt.composeTextarea, "color", "var(--fg)") dom.SetStyle(appSt.composeTextarea, "resize", "vertical") dom.SetStyle(appSt.composeTextarea, "overflowY", "auto") dom.SetStyle(appSt.composeTextarea, "boxSizing", "border-box") dom.SetStyle(appSt.composeTextarea, "fontFamily", "inherit") dom.SetAttribute(appSt.composeTextarea, "onkeydown", "if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();this.parentNode.querySelector('[data-publish-btn]').click()}") dom.AppendChild(appSt.composePage, appSt.composeTextarea) // Preview area (hidden by default). appSt.composePreview = dom.CreateElement("div") dom.SetStyle(appSt.composePreview, "display", "none") dom.SetStyle(appSt.composePreview, "padding", "12px") dom.SetStyle(appSt.composePreview, "border", "1px solid var(--border)") dom.SetStyle(appSt.composePreview, "borderRadius", "8px") dom.SetStyle(appSt.composePreview, "marginTop", "8px") dom.SetStyle(appSt.composePreview, "lineHeight", "1.5") dom.SetStyle(appSt.composePreview, "wordBreak", "break-word") dom.AppendChild(appSt.composePage, appSt.composePreview) // Button row. btnRow := dom.CreateElement("div") dom.SetStyle(btnRow, "display", "flex") dom.SetStyle(btnRow, "gap", "8px") dom.SetStyle(btnRow, "marginTop", "12px") dom.SetStyle(btnRow, "alignItems", "center") // Relay selector button (left side). relaySelWrap := dom.CreateElement("div") dom.SetStyle(relaySelWrap, "position", "relative") dom.SetStyle(relaySelWrap, "display", "flex") dom.SetStyle(relaySelWrap, "alignItems", "center") dom.SetStyle(relaySelWrap, "gap", "6px") relaySelBtn := dom.CreateElement("button") dom.SetStyle(relaySelBtn, "padding", "8px 16px") dom.SetStyle(relaySelBtn, "border", "1px solid var(--border)") dom.SetStyle(relaySelBtn, "borderRadius", "6px") dom.SetStyle(relaySelBtn, "background", "transparent") dom.SetStyle(relaySelBtn, "color", "var(--fg)") dom.SetStyle(relaySelBtn, "cursor", "pointer") updateRelaySelBtn(relaySelBtn, false) composeWarnBadge := makeRelayWarnBadge() appSt.composeRelayPopEl = dom.CreateElement("div") dom.SetStyle(appSt.composeRelayPopEl, "display", "none") dom.SetStyle(appSt.composeRelayPopEl, "position", "absolute") dom.SetStyle(appSt.composeRelayPopEl, "bottom", "100%") dom.SetStyle(appSt.composeRelayPopEl, "left", "0") dom.SetStyle(appSt.composeRelayPopEl, "marginBottom", "4px") dom.SetStyle(appSt.composeRelayPopEl, "background", "var(--bg)") dom.SetStyle(appSt.composeRelayPopEl, "border", "1px solid var(--border)") dom.SetStyle(appSt.composeRelayPopEl, "borderRadius", "6px") dom.SetStyle(appSt.composeRelayPopEl, "padding", "8px") dom.SetStyle(appSt.composeRelayPopEl, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)") dom.SetStyle(appSt.composeRelayPopEl, "zIndex", "1000") dom.SetStyle(appSt.composeRelayPopEl, "minWidth", "200px") dom.SetStyle(appSt.composeRelayPopEl, "maxWidth", "calc(100vw - 24px)") dom.SetStyle(appSt.composeRelayPopEl, "maxHeight", "200px") dom.SetStyle(appSt.composeRelayPopEl, "overflowY", "auto") dom.AddEventListener(relaySelBtn, "click", dom.RegisterCallback(func() { if appSt.composeRelayPopOpen { dom.SetStyle(appSt.composeRelayPopEl, "display", "none") appSt.composeRelayPopOpen = false updateRelaySelBtn(relaySelBtn, false) } else { populateRelayPopover(appSt.composeRelayPopEl, func() { updateRelaySelBtn(relaySelBtn, true) updateRelayWarnBadge(composeWarnBadge) }) dom.SetStyle(appSt.composeRelayPopEl, "display", "block") appSt.composeRelayPopOpen = true updateRelaySelBtn(relaySelBtn, true) } })) dom.AppendChild(relaySelWrap, relaySelBtn) dom.AppendChild(relaySelWrap, composeWarnBadge) dom.AppendChild(relaySelWrap, appSt.composeRelayPopEl) dom.AppendChild(btnRow, relaySelWrap) // Spacer. spacer := dom.CreateElement("div") dom.SetStyle(spacer, "flex", "1") dom.AppendChild(btnRow, spacer) // Image upload button. imgBtn := dom.CreateElement("button") dom.SetInnerHTML(imgBtn, ``) dom.SetStyle(imgBtn, "padding", "8px 12px") dom.SetStyle(imgBtn, "border", "1px solid var(--border)") dom.SetStyle(imgBtn, "borderRadius", "6px") dom.SetStyle(imgBtn, "background", "transparent") dom.SetStyle(imgBtn, "color", "var(--fg)") dom.SetStyle(imgBtn, "cursor", "pointer") dom.SetStyle(imgBtn, "display", "flex") dom.SetStyle(imgBtn, "alignItems", "center") dom.AddEventListener(imgBtn, "click", dom.RegisterCallback(func() { dom.PickFileBase64("image/*,video/*", func(b64, mime string) { if b64 != "" { blossomUpload(b64, mime, appSt.composeTextarea) } }) })) dom.AppendChild(btnRow, imgBtn) // Preview toggle. previewBtn := dom.CreateElement("button") dom.SetTextContent(previewBtn, "preview") dom.SetStyle(previewBtn, "padding", "8px 16px") dom.SetStyle(previewBtn, "border", "1px solid var(--border)") dom.SetStyle(previewBtn, "borderRadius", "6px") dom.SetStyle(previewBtn, "background", "transparent") dom.SetStyle(previewBtn, "color", "var(--fg)") dom.SetStyle(previewBtn, "cursor", "pointer") composePreviewing := false dom.AddEventListener(previewBtn, "click", dom.RegisterCallback(func() { content := dom.GetProperty(appSt.composeTextarea, "value") if !composePreviewing { dom.SetStyle(appSt.composePreview, "display", "block") dom.SetStyle(appSt.composeTextarea, "display", "none") setHTMLWithMedia(appSt.composePreview, renderMarkdown(content)) dom.SetTextContent(previewBtn, "edit") composePreviewing = true } else { dom.SetStyle(appSt.composePreview, "display", "none") dom.SetStyle(appSt.composeTextarea, "display", "block") dom.SetTextContent(previewBtn, "preview") composePreviewing = false } })) dom.AppendChild(btnRow, previewBtn) // Publish button. pubBtn := dom.CreateElement("button") dom.SetTextContent(pubBtn, "publish") dom.SetAttribute(pubBtn, "data-publish-btn", "1") dom.SetStyle(pubBtn, "padding", "8px 20px") dom.SetStyle(pubBtn, "border", "none") dom.SetStyle(pubBtn, "borderRadius", "6px") dom.SetStyle(pubBtn, "background", "var(--accent)") dom.SetStyle(pubBtn, "color", "#fff") dom.SetStyle(pubBtn, "cursor", "pointer") dom.SetStyle(pubBtn, "fontWeight", "bold") dom.AddEventListener(pubBtn, "click", dom.RegisterCallback(func() { dom.ConsoleLog("[publish-compose] enter") content := dom.GetProperty(appSt.composeTextarea, "value") dom.ConsoleLog("[publish-compose] content len=" | itoa(len(content))) content = replaceEmojiCodes(content) if content == "" { dom.ConsoleLog("[publish-compose] empty") return } dom.ConsoleLog("[publish-compose] hasSigner=" | boolStr(signer.HasSigner()) | " pubhex=" | appSt.pubhex) if !signer.HasSigner() || appSt.pubhex == "" { dom.ConsoleLog("[publish-compose] no signer or pubhex") return } ts := dom.NowSeconds() tags := "[" if localstorage.GetItem("musiquay-client-tag") != "0" { tags = tags | "[\"client\",\"https://smesh.lol\"]" } tags = tags | "]" unsigned := "{\"kind\":1,\"content\":" | jstr(content) | ",\"tags\":" | tags | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | appSt.pubhex | "\"}" dom.ConsoleLog("[publish-compose] calling SignEvent") capturedContent := content signer.SignEvent(unsigned, func(signed string) { if signed == "" { logActivity("publish", "FAILED: signer error", unsigned) return } // Build relay list from checked relays. selected := composeSelectedRelays() preview := capturedContent if len(preview) > 80 { preview = preview[:80] | "..." } relayList := "" for j, u := range selected { if j > 0 { relayList = relayList | "\n" } relayList = relayList | u } if len(selected) > 0 { msg := "[\"PUBLISH_TO\"," | signed | ",[" for i, u := range selected { if i > 0 { msg = msg | "," } msg = msg | jstr(u) } routeMsg(msg | "]]") } else { routeMsg("[\"EVENT\"," | signed | "]") } logActivity("publish", preview, "relays:\n" | relayList | "\n\nevent:\n" | signed) published := nostr.ParseEvent(signed) if published != nil { insertPublishedNote(published) } dom.SetProperty(appSt.composeTextarea, "value", "") dom.SetStyle(appSt.composePreview, "display", "none") dom.SetStyle(appSt.composeTextarea, "display", "block") appSt.composeRelayPopOpen = false dom.SetStyle(appSt.composeRelayPopEl, "display", "none") dom.SetProperty(appSt.contentArea, "scrollTop", "0") switchPage("feed") }) })) dom.AppendChild(btnRow, pubBtn) dom.AppendChild(appSt.composePage, btnRow) // Emoji autocomplete popup. appSt.emojiAutoEl = dom.CreateElement("div") dom.SetStyle(appSt.emojiAutoEl, "display", "none") dom.SetStyle(appSt.emojiAutoEl, "position", "fixed") dom.SetStyle(appSt.emojiAutoEl, "background", "var(--bg)") dom.SetStyle(appSt.emojiAutoEl, "border", "1px solid var(--border)") dom.SetStyle(appSt.emojiAutoEl, "borderRadius", "6px") dom.SetStyle(appSt.emojiAutoEl, "padding", "4px") dom.SetStyle(appSt.emojiAutoEl, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)") dom.SetStyle(appSt.emojiAutoEl, "zIndex", "2000") dom.SetStyle(appSt.emojiAutoEl, "maxHeight", "200px") dom.SetStyle(appSt.emojiAutoEl, "overflowY", "auto") dom.SetStyle(appSt.emojiAutoEl, "minWidth", "200px") dom.AppendChild(dom.Body(), appSt.emojiAutoEl) wireEmojiHandler(appSt.composeTextarea) wireBlossomHandlers(appSt.composeTextarea) wireMentionHandler(appSt.composeTextarea) } func wireEmojiHandler(ta dom.Element) { dom.AddEventListener(ta, "input", dom.RegisterCallback(func() { checkEmojiAutocomplete(ta) })) } func wireBlossomHandlers(ta dom.Element) { upload := func(b64 string, mime string) { blossomUpload(b64, mime, ta) } dom.OnPasteImage(ta, upload) dom.OnDropImage(ta, upload) } func blossomUpload(b64data string, mime string, ta dom.Element) { if appSt.blossomServerURL == "" { dom.ConsoleLog("blossom: no server configured") return } raw := helpers.Base64Decode(b64data) if len(raw) == 0 { return } hash := sha256.Sum(raw) hashHex := helpers.HexEncode(hash[:]) ext := ".bin" if mime == "image/png" { ext = ".png" } else if mime == "image/jpeg" || mime == "image/jpg" { ext = ".jpg" } else if mime == "image/gif" { ext = ".gif" } else if mime == "image/webp" { ext = ".webp" } else if mime == "image/svg+xml" { ext = ".svg" } else if mime == "video/mp4" { ext = ".mp4" } else if mime == "video/webm" { ext = ".webm" } else if mime == "video/quicktime" { ext = ".mov" } else if mime == "video/x-matroska" { ext = ".mkv" } placeholder := "\xe2\x8f\xb3 uploading to " | appSt.blossomServerURL | "..." val := dom.GetProperty(ta, "value") if len(val) > 0 && val[len(val)-1] != '\n' { val = val | "\n" } dom.SetProperty(ta, "value", val | placeholder) ts := dom.NowSeconds() expiry := ts + 300 tags := "[[\"t\",\"upload\"],[\"x\",\"" | hashHex | "\"],[\"expiration\",\"" | i64toa(expiry) | "\"]]" unsigned := "{\"kind\":24242,\"content\":\"Upload " | hashHex | ext | "\",\"tags\":" | tags | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | appSt.pubhex | "\"}" signer.SignEvent(unsigned, func(signed string) { if signed == "" { dom.ConsoleLog("blossom: sign failed") blossomReplacePlaceholder(ta, placeholder, "") return } authB64 := helpers.Base64Encode([]byte(signed)) authHeader := "Nostr " | authB64 url := appSt.blossomServerURL if len(url) > 0 && url[len(url)-1] != '/' { url = url | "/" } url = url | "upload" dom.FetchPutBlobBase64(url, b64data, mime, authHeader, func(resp string) { if resp == "" { dom.ConsoleLog("blossom: upload failed") blossomReplacePlaceholder(ta, placeholder, "") return } blobURL := helpers.JsonGetString(resp, "url") if blobURL == "" { blobURL = hashHex | ext } if len(blobURL) < 4 || blobURL[:4] != "http" { base := blossomOrigin(appSt.blossomServerURL) if len(blobURL) > 0 && blobURL[0] == '/' { blobURL = base | blobURL } else { blobURL = base | "/" | blobURL } } blossomReplacePlaceholder(ta, placeholder, blobURL) }) }) } func blossomOrigin(u string) (s string) { slashes := 0 for i := 0; i < len(u); i++ { if u[i] == '/' { slashes++ if slashes == 3 { return u[:i] } } } return u } func blossomReplacePlaceholder(ta dom.Element, placeholder, replacement string) { val := dom.GetProperty(ta, "value") idx := -1 plen := len(placeholder) for i := 0; i <= len(val)-plen; i++ { if val[i:i+plen] == placeholder { idx = i break } } if idx >= 0 { dom.SetProperty(ta, "value", val[:idx] | replacement | val[idx+plen:]) } else if replacement != "" { if len(val) > 0 && val[len(val)-1] != '\n' { val = val | "\n" } dom.SetProperty(ta, "value", val | replacement | "\n") } } func blossomUploadToInput(b64data string, mime string, input dom.Element) { if appSt.blossomServerURL == "" { return } raw := helpers.Base64Decode(b64data) if len(raw) == 0 { return } hash := sha256.Sum(raw) hashHex := helpers.HexEncode(hash[:]) ext := ".bin" if mime == "image/png" { ext = ".png" } else if mime == "image/jpeg" || mime == "image/jpg" { ext = ".jpg" } else if mime == "image/gif" { ext = ".gif" } else if mime == "image/webp" { ext = ".webp" } else if mime == "image/svg+xml" { ext = ".svg" } else if mime == "video/mp4" { ext = ".mp4" } else if mime == "video/webm" { ext = ".webm" } else if mime == "video/quicktime" { ext = ".mov" } else if mime == "video/x-matroska" { ext = ".mkv" } dom.SetProperty(input, "value", "\xe2\x8f\xb3 uploading...") ts := dom.NowSeconds() expiry := ts + 300 tags := "[[\"t\",\"upload\"],[\"x\",\"" | hashHex | "\"],[\"expiration\",\"" | i64toa(expiry) | "\"]]" unsigned := "{\"kind\":24242,\"content\":\"Upload " | hashHex | ext | "\",\"tags\":" | tags | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | appSt.pubhex | "\"}" signer.SignEvent(unsigned, func(signed string) { if signed == "" { dom.SetProperty(input, "value", "") return } authB64 := helpers.Base64Encode([]byte(signed)) authHeader := "Nostr " | authB64 url := appSt.blossomServerURL if len(url) > 0 && url[len(url)-1] != '/' { url = url | "/" } url = url | "upload" dom.FetchPutBlobBase64(url, b64data, mime, authHeader, func(resp string) { if resp == "" { dom.SetProperty(input, "value", "") return } blobURL := helpers.JsonGetString(resp, "url") if blobURL == "" { blobURL = hashHex | ext } if len(blobURL) < 4 || blobURL[:4] != "http" { base := blossomOrigin(appSt.blossomServerURL) if len(blobURL) > 0 && blobURL[0] == '/' { blobURL = base | blobURL } else { blobURL = base | "/" | blobURL } } dom.SetProperty(input, "value", blobURL) }) }) } type mentionMatch struct { pk string name string pic string } func mentionSearch(query string) (ss []mentionMatch) { q := toLower(query) var results []mentionMatch for pk, content := range appSt.profileContentCache { if len(results) >= 10 { break } name := helpers.JsonGetString(content, "name") dname := helpers.JsonGetString(content, "display_name") nl := toLower(name) dl := toLower(dname) if (len(nl) > 0 && strIndex(nl, q) >= 0) || (len(dl) > 0 && strIndex(dl, q) >= 0) { label := name if len(label) == 0 { label = dname } if len(label) == 0 { continue } pic, _ := appSt.authorPics[pk] results = mxutil.Ensure(results, 1) results = push(results, mentionMatch{pk: pk, name: label, pic: pic}) } } return results } func initMentionPopup() { appSt.mentionPopup = dom.CreateElement("div") dom.SetStyle(appSt.mentionPopup, "display", "none") dom.SetStyle(appSt.mentionPopup, "position", "fixed") dom.SetStyle(appSt.mentionPopup, "background", "var(--bg)") dom.SetStyle(appSt.mentionPopup, "border", "1px solid var(--border)") dom.SetStyle(appSt.mentionPopup, "borderRadius", "6px") dom.SetStyle(appSt.mentionPopup, "padding", "4px") dom.SetStyle(appSt.mentionPopup, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)") dom.SetStyle(appSt.mentionPopup, "zIndex", "3000") dom.SetStyle(appSt.mentionPopup, "maxHeight", "200px") dom.SetStyle(appSt.mentionPopup, "overflowY", "auto") dom.SetStyle(appSt.mentionPopup, "minWidth", "220px") dom.AppendChild(dom.Body(), appSt.mentionPopup) } func showMentionPopup(ta dom.Element, atPos int32, query string) { matches := mentionSearch(query) if len(matches) == 0 { closeMentionPopup() return } appSt.mentionTA = ta appSt.mentionQuery = query appSt.mentionAtPos = atPos appSt.mentionItems = matches appSt.mentionSelIdx = 0 clearChildren(appSt.mentionPopup) for i, m := range matches { row := dom.CreateElement("div") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "8px") dom.SetStyle(row, "padding", "4px 8px") dom.SetStyle(row, "cursor", "pointer") dom.SetStyle(row, "borderRadius", "4px") dom.SetStyle(row, "fontSize", "13px") if i == 0 { dom.SetStyle(row, "background", "var(--bg2)") } if len(m.pic) > 0 { img := dom.CreateElement("img") setMediaSrc(img, m.pic) dom.SetStyle(img, "width", "24px") dom.SetStyle(img, "height", "24px") dom.SetStyle(img, "borderRadius", "50%") dom.SetStyle(img, "objectFit", "cover") dom.SetStyle(img, "flexShrink", "0") dom.AppendChild(row, img) } else { placeholder := dom.CreateElement("div") dom.SetStyle(placeholder, "width", "24px") dom.SetStyle(placeholder, "height", "24px") dom.SetStyle(placeholder, "borderRadius", "50%") dom.SetStyle(placeholder, "background", "var(--border)") dom.SetStyle(placeholder, "flexShrink", "0") dom.AppendChild(row, placeholder) } nameSpan := dom.CreateElement("span") dom.SetTextContent(nameSpan, m.name) dom.AppendChild(row, nameSpan) npubShort := helpers.EncodeNpub(helpers.HexDecode(m.pk)) if len(npubShort) > 16 { npubShort = npubShort[:12] | "..." } npubSpan := dom.CreateElement("span") dom.SetTextContent(npubSpan, npubShort) dom.SetStyle(npubSpan, "color", "var(--muted)") dom.SetStyle(npubSpan, "fontSize", "11px") dom.AppendChild(row, npubSpan) dom.AddEventListener(row, "mouseenter", dom.RegisterCallback(func() { dom.SetStyle(row, "background", "var(--bg2)") })) dom.AddEventListener(row, "mouseleave", dom.RegisterCallback(func() { if appSt.mentionSelIdx >= 0 && appSt.mentionSelIdx < len(appSt.mentionItems) { dom.SetStyle(row, "background", "transparent") } })) capturedPK := m.pk dom.AddEventListener(row, "mousedown", dom.RegisterCallback(func() { insertMention(capturedPK) })) dom.AppendChild(appSt.mentionPopup, row) } positionMentionPopup(ta) dom.SetStyle(appSt.mentionPopup, "display", "block") } func positionMentionPopup(ta dom.Element) { dom.GetBoundingRect(ta, func(left, top, right, bottom, width, height int32) { vh := dom.GetViewportHeight() cur := parseIntProp(dom.GetProperty(ta, "selectionStart")) val := dom.GetProperty(ta, "value") lineH := 22 charsPerLine := width / 8 if charsPerLine < 1 { charsPerLine = 40 } linesBefore := 0 col := 0 for i := 0; i < cur && i < len(val); i++ { if val[i] == '\n' { linesBefore++ col = 0 } else { col++ if col >= charsPerLine { linesBefore++ col = 0 } } } cursorY := top + 8 + linesBefore*lineH popupH := 200 if cursorY < vh/2 { dom.SetStyle(appSt.mentionPopup, "top", itoa(cursorY+lineH) | "px") dom.SetStyle(appSt.mentionPopup, "bottom", "auto") } else { dom.SetStyle(appSt.mentionPopup, "bottom", itoa(vh-cursorY) | "px") dom.SetStyle(appSt.mentionPopup, "top", "auto") } _ = popupH xOff := left + col*8 if xOff > right-220 { xOff = right - 220 } if xOff < left { xOff = left } dom.SetStyle(appSt.mentionPopup, "left", itoa(xOff) | "px") }) } func insertMention(pk string) { if appSt.mentionTA == 0 { return } npub := helpers.EncodeNpub(helpers.HexDecode(pk)) val := dom.GetProperty(appSt.mentionTA, "value") cur := parseIntProp(dom.GetProperty(appSt.mentionTA, "selectionStart")) replacement := "nostr:" | npub | " " before := val[:appSt.mentionAtPos] after := val[cur:] dom.SetProperty(appSt.mentionTA, "value", before | replacement | after) newCur := itoa(len(before) + len(replacement)) dom.SetProperty(appSt.mentionTA, "selectionStart", newCur) dom.SetProperty(appSt.mentionTA, "selectionEnd", newCur) closeMentionPopup() dom.Focus(appSt.mentionTA) } func closeMentionPopup() { dom.SetStyle(appSt.mentionPopup, "display", "none") appSt.mentionItems = nil appSt.mentionSelIdx = 0 appSt.mentionTA = 0 } func checkMentionAutocomplete(ta dom.Element) { val := dom.GetProperty(ta, "value") cur := parseIntProp(dom.GetProperty(ta, "selectionStart")) if cur > len(val) { cur = len(val) } atPos := -1 for i := cur - 1; i >= 0; i-- { c := val[i] if c == '@' { atPos = i break } if c == ' ' || c == '\n' || c == '\t' { break } } if atPos < 0 || cur-atPos < 2 { closeMentionPopup() return } if atPos > 0 { prev := val[atPos-1] if prev != ' ' && prev != '\n' && prev != '\t' { closeMentionPopup() return } } query := val[atPos+1 : cur] showMentionPopup(ta, atPos, query) } func wireMentionHandler(ta dom.Element) { dom.AddEventListener(ta, "input", dom.RegisterCallback(func() { checkMentionAutocomplete(ta) })) dom.AddEventListener(ta, "blur", dom.RegisterCallback(func() { dom.SetTimeout(func() { closeMentionPopup() }, 200) })) dom.OnKeydown(ta, func(key string, prevent func()) { if appSt.mentionTA == 0 || len(appSt.mentionItems) == 0 { if key == "Escape" { closeMentionPopup() } return } if key == "Escape" { closeMentionPopup() prevent() } else if key == "ArrowDown" { appSt.mentionSelIdx++ if appSt.mentionSelIdx >= len(appSt.mentionItems) { appSt.mentionSelIdx = 0 } highlightMentionRow() prevent() } else if key == "ArrowUp" { appSt.mentionSelIdx-- if appSt.mentionSelIdx < 0 { appSt.mentionSelIdx = len(appSt.mentionItems) - 1 } highlightMentionRow() prevent() } else if key == "Enter" || key == "Tab" { insertMention(appSt.mentionItems[appSt.mentionSelIdx].pk) prevent() } }) } func highlightMentionRow() { child := dom.FirstElementChild(appSt.mentionPopup) idx := 0 for child != 0 { if idx == appSt.mentionSelIdx { dom.SetStyle(child, "background", "var(--bg2)") } else { dom.SetStyle(child, "background", "transparent") } child = dom.NextSibling(child) idx++ } } func checkEmojiAutocomplete(ta dom.Element) { val := dom.GetProperty(ta, "value") curStr := dom.GetProperty(ta, "selectionStart") cur := parseIntProp(curStr) if cur > len(val) { cur = len(val) } searchFrom := cur - 1 closingColon := false if searchFrom >= 0 && val[searchFrom] == ':' { closingColon = true searchFrom-- } colonPos := -1 for i := searchFrom; i >= 0; i-- { c := val[i] if c == ':' { colonPos = i break } if c == ' ' || c == '\n' || c == '\t' { break } } if colonPos < 0 || cur-colonPos < 2 { closeEmojiAuto() return } // Extract the text between the opening colon and cursor (excluding closing colon). queryEnd := cur if closingColon { queryEnd = cur - 1 } query := val[colonPos+1 : queryEnd] // If user typed a closing ':', auto-replace if exact match. if closingColon && len(query) > 0 { code := ":" | query | ":" if emoji, ok2 := appSt.emojiByName[code]; ok2 { before := val[:colonPos] after := val[cur:] newVal := before | emoji | after dom.SetProperty(ta, "value", newVal) newCur := itoa(len(before) + len(emoji)) dom.SetProperty(ta, "selectionStart", newCur) dom.SetProperty(ta, "selectionEnd", newCur) closeEmojiAuto() return } } // Filter matching emoji. var matches []emojiEntry q := toLower(query) for _, e := range emojiList { if len(matches) >= 10 { break } if len(e.name) >= len(q) && hasPrefix(e.name, q) { matches = mxutil.Ensure(matches, 1) matches = push(matches, e) } } // Also match substring. if len(matches) < 10 { for _, e := range emojiList { if len(matches) >= 10 { break } if !hasPrefix(e.name, q) && strIndex(e.name, q) >= 0 { matches = mxutil.Ensure(matches, 1) matches = push(matches, e) } } } if len(matches) == 0 { closeEmojiAuto() return } appSt.emojiAutoOpen = true appSt.emojiAutoStart = colonPos appSt.emojiAutoTA = ta clearChildren(appSt.emojiAutoEl) for _, m := range matches { row := dom.CreateElement("div") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "6px") dom.SetStyle(row, "padding", "4px 8px") dom.SetStyle(row, "cursor", "pointer") dom.SetStyle(row, "borderRadius", "4px") dom.SetStyle(row, "fontSize", "13px") emojiSpan := dom.CreateElement("span") dom.SetTextContent(emojiSpan, m.emoji) dom.SetStyle(emojiSpan, "fontSize", "18px") dom.AppendChild(row, emojiSpan) nameSpan := dom.CreateElement("span") dom.SetTextContent(nameSpan, ":" | m.name | ":") dom.SetStyle(nameSpan, "color", "var(--muted)") dom.AppendChild(row, nameSpan) dom.AddEventListener(row, "mouseenter", dom.RegisterCallback(func() { dom.SetStyle(row, "background", "var(--bg2)") })) dom.AddEventListener(row, "mouseleave", dom.RegisterCallback(func() { dom.SetStyle(row, "background", "transparent") })) capturedEmoji := m.emoji dom.AddEventListener(row, "mousedown", dom.RegisterCallback(func() { insertEmojiAt(capturedEmoji) })) dom.AppendChild(appSt.emojiAutoEl, row) } dom.SetStyle(appSt.emojiAutoEl, "display", "block") positionEmojiPopup() } func insertEmojiAt(emoji string) { ta := appSt.emojiAutoTA if ta == 0 { return } val := dom.GetProperty(ta, "value") cur := parseIntProp(dom.GetProperty(ta, "selectionStart")) before := val[:appSt.emojiAutoStart] after := val[cur:] newVal := before | emoji | after dom.SetProperty(ta, "value", newVal) newCur := itoa(len(before) + len(emoji)) dom.SetProperty(ta, "selectionStart", newCur) dom.SetProperty(ta, "selectionEnd", newCur) closeEmojiAuto() dom.Focus(ta) } func closeEmojiAuto() { if appSt.emojiAutoOpen { dom.SetStyle(appSt.emojiAutoEl, "display", "none") appSt.emojiAutoOpen = false } } func positionEmojiPopup() { ta := appSt.emojiAutoTA if ta == 0 { return } dom.GetBoundingRect(ta, func(left, top, right, bottom, width, height int32) { vh := dom.GetViewportHeight() cur := parseIntProp(dom.GetProperty(ta, "selectionStart")) val := dom.GetProperty(ta, "value") lineH := 22 charsPerLine := width / 8 if charsPerLine < 1 { charsPerLine = 40 } linesBefore := 0 col := 0 for i := 0; i < cur && i < len(val); i++ { if val[i] == '\n' { linesBefore++ col = 0 } else { col++ if col >= charsPerLine { linesBefore++ col = 0 } } } cursorY := top + 8 + linesBefore*lineH if cursorY < vh/2 { dom.SetStyle(appSt.emojiAutoEl, "top", itoa(cursorY+lineH) | "px") dom.SetStyle(appSt.emojiAutoEl, "bottom", "auto") } else { dom.SetStyle(appSt.emojiAutoEl, "bottom", itoa(vh-cursorY) | "px") dom.SetStyle(appSt.emojiAutoEl, "top", "auto") } xOff := left + col*8 if xOff > right-220 { xOff = right - 220 } if xOff < left { xOff = left } dom.SetStyle(appSt.emojiAutoEl, "left", itoa(xOff) | "px") }) } func populateComposeRelays() { populateRelayPopover(appSt.composeRelayPopEl, nil) } func populateRelayPopover(target dom.Element, onChanged func()) { renderRows := func() { clearChildren(target) populateRelayRows(target, onChanged) if onChanged != nil { onChanged() } } if appSt.composeRelayChecks == nil { appSt.composeRelayChecks = map[string]bool{} for _, u := range appSt.relayURLs { appSt.composeRelayChecks[u] = true } dom.IDBGet("cache", "compose_relays", func(val string) { if val != "" { appSt.composeRelayChecks = map[string]bool{} start := 0 for i := 0; i <= len(val); i++ { if i == len(val) || val[i] == ',' { if i > start { appSt.composeRelayChecks[val[start:i]] = true } start = i + 1 } } } renderRows() }) return } renderRows() } func populateRelayRows(target dom.Element, onChanged func()) { for idx, u := range appSt.relayURLs { row := dom.CreateElement("div") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "6px") dom.SetStyle(row, "padding", "0 4px") dom.SetStyle(row, "margin", "2px 0") dom.SetStyle(row, "cursor", "pointer") dom.SetStyle(row, "fontSize", "12px") dom.SetStyle(row, "overflow", "hidden") relayURL := u selected := appSt.composeRelayChecks[u] applyRowStyle := func(sel bool) { if sel { dom.SetStyle(row, "background", "var(--accent)") dom.SetStyle(row, "color", "#fff") } else { dom.SetStyle(row, "background", "transparent") dom.SetStyle(row, "color", "var(--fg)") } } applyRowStyle(selected) dom.AddEventListener(row, "click", dom.RegisterCallback(func() { appSt.composeRelayChecks[relayURL] = !appSt.composeRelayChecks[relayURL] applyRowStyle(appSt.composeRelayChecks[relayURL]) saveComposeRelaySelection() if onChanged != nil { onChanged() } })) label := dom.CreateElement("span") dom.SetTextContent(label, stripScheme(u)) dom.SetStyle(label, "overflow", "hidden") dom.SetStyle(label, "textOverflow", "ellipsis") dom.SetStyle(label, "whiteSpace", "nowrap") dom.SetStyle(label, "flex", "1") dom.SetStyle(label, "minWidth", "0") dom.AppendChild(row, label) caps := appSt.relayCaps[idx] if caps.fetchedOK { if !hasNIP(caps.supportedNIPs, 42) { dom.AppendChild(row, makeRelayBadge("!42")) } if !hasNIP(caps.supportedNIPs, 70) { dom.AppendChild(row, makeRelayBadge("!70")) } } dom.AppendChild(target, row) } } func selectedRelaysLeaky() (ok bool) { if appSt.composeRelayChecks == nil { return false } for i, u := range appSt.relayURLs { if !appSt.composeRelayChecks[u] { continue } caps := appSt.relayCaps[i] if !caps.fetchedOK { continue } if !hasNIP(caps.supportedNIPs, 42) || !hasNIP(caps.supportedNIPs, 70) { return true } } return false } func updateRelaySelBtn(btn dom.Element, open bool) { if open { dom.SetTextContent(btn, "relays \u2715") dom.SetStyle(btn, "background", "var(--accent)") dom.SetStyle(btn, "color", "#fff") } else { dom.SetTextContent(btn, "relays") dom.SetStyle(btn, "background", "transparent") dom.SetStyle(btn, "color", "var(--fg)") } } func updateRelayWarnBadge(badge dom.Element) { if selectedRelaysLeaky() { dom.SetStyle(badge, "display", "inline-block") } else { dom.SetStyle(badge, "display", "none") } } func makeRelayWarnBadge() (e dom.Element) { badge := dom.CreateElement("span") dom.SetTextContent(badge, "\u26a0") dom.SetStyle(badge, "display", "none") dom.SetStyle(badge, "fontSize", "16px") dom.SetStyle(badge, "lineHeight", "1") dom.SetStyle(badge, "background", "#e67e00") dom.SetStyle(badge, "borderRadius", "50%") dom.SetStyle(badge, "width", "22px") dom.SetStyle(badge, "height", "22px") dom.SetStyle(badge, "textAlign", "center") dom.SetStyle(badge, "cursor", "default") dom.SetAttribute(badge, "title", t("relay_leak_warn")) updateRelayWarnBadge(badge) return badge } func saveComposeRelaySelection() { val := "" for u, sel := range appSt.composeRelayChecks { if sel { if val != "" { val = val | "," } val = val | u } } dom.IDBPut("cache", "compose_relays", val) } func composeSelectedRelays() (ss []string) { if appSt.composeRelayChecks == nil { return appSt.relayURLs } var out []string for u, sel := range appSt.composeRelayChecks { if sel { out = mxutil.Ensure(out, 1) out = push(out, u) } } // If nothing selected, fall back to all relays. if len(out) == 0 { return appSt.relayURLs } return out } func insertPublishedNote(ev *nostr.Event) { setEvent(ev.ID, ev) fillEmbed(ev) // Thread view: insert and re-render, scrolling new reply into view. if appSt.threadOpen { appSt.threadEvents[ev.ID] = ev appSt.threadFocusID = ev.ID dom.ReplaceState("/t/" | appSt.threadRootID | "#" | ev.ID) renderThreadTree() } // Feed: prepend at top. renderNote must run before seenEvents is set, // because renderNote's dedup guard checks seenEvents. trackOldestTs(ev) renderNote(ev) appSt.seenEvents[ev.ID] = true } // --- Emoji picker --- func showEmojiPicker(ev *nostr.Event, anchor dom.Element) { _ = anchor if !signer.HasSigner() || appSt.pubhex == "" { return } if appSt.emojiOpen { closeEmojiPicker() return } appSt.emojiOpen = true appSt.emojiTargetEv = ev // Full-screen scrim. overlay := dom.CreateElement("div") dom.SetStyle(overlay, "position", "fixed") dom.SetStyle(overlay, "top", "0") dom.SetStyle(overlay, "left", "0") dom.SetStyle(overlay, "width", "100%") dom.SetStyle(overlay, "height", "100%") dom.SetStyle(overlay, "background", "rgba(0,0,0,0.5)") dom.SetStyle(overlay, "zIndex", "400") dom.SetStyle(overlay, "display", "flex") dom.SetStyle(overlay, "alignItems", "center") dom.SetStyle(overlay, "justifyContent", "center") dom.AddSelfEventListener(overlay, "click", dom.RegisterCallback(func() { closeEmojiPicker() })) appSt.emojiOverlay = overlay modal := dom.CreateElement("div") dom.SetStyle(modal, "background", "var(--bg)") dom.SetStyle(modal, "border", "1px solid var(--border)") dom.SetStyle(modal, "borderRadius", "8px") dom.SetStyle(modal, "padding", "12px") dom.SetStyle(modal, "boxShadow", "0 4px 24px rgba(0,0,0,0.4)") dom.SetStyle(modal, "width", "320px") dom.SetStyle(modal, "maxWidth", "90vw") dom.SetStyle(modal, "maxHeight", "60vh") dom.SetStyle(modal, "display", "flex") dom.SetStyle(modal, "flexDirection", "column") dom.SetStyle(modal, "gap", "8px") // Search input. search := dom.CreateElement("input") dom.SetAttribute(search, "type", "text") dom.SetAttribute(search, "placeholder", "Search emoji...") dom.SetStyle(search, "width", "100%") dom.SetStyle(search, "padding", "6px 10px") dom.SetStyle(search, "border", "1px solid var(--border)") dom.SetStyle(search, "borderRadius", "4px") dom.SetStyle(search, "background", "var(--bg)") dom.SetStyle(search, "color", "var(--fg)") dom.SetStyle(search, "fontSize", "14px") dom.SetStyle(search, "boxSizing", "border-box") dom.SetStyle(search, "flexShrink", "0") dom.AppendChild(modal, search) // Emoji grid - scrollable. grid := dom.CreateElement("div") dom.SetStyle(grid, "display", "flex") dom.SetStyle(grid, "flexWrap", "wrap") dom.SetStyle(grid, "gap", "2px") dom.SetStyle(grid, "overflowY", "auto") dom.SetStyle(grid, "flex", "1") dom.SetStyle(grid, "minHeight", "0") dom.SetStyle(grid, "alignContent", "flex-start") dom.SetStyle(grid, "WebkitOverflowScrolling", "touch") capturedEv := ev seen := map[string]bool{} for _, entry := range emojiList { if seen[entry.emoji] { continue } seen[entry.emoji] = true cell := dom.CreateElement("span") dom.SetTextContent(cell, entry.emoji) dom.SetStyle(cell, "fontSize", "22px") dom.SetStyle(cell, "cursor", "pointer") dom.SetStyle(cell, "padding", "4px") dom.SetStyle(cell, "borderRadius", "4px") dom.SetStyle(cell, "textAlign", "center") dom.SetStyle(cell, "width", "36px") dom.SetStyle(cell, "height", "36px") dom.SetStyle(cell, "lineHeight", "36px") dom.SetStyle(cell, "display", "inline-flex") dom.SetStyle(cell, "alignItems", "center") dom.SetStyle(cell, "justifyContent", "center") dom.SetAttribute(cell, "title", entry.name) capturedEmoji := entry.emoji dom.AddEventListener(cell, "mouseenter", dom.RegisterCallback(func() { dom.SetStyle(cell, "background", "var(--border)") })) dom.AddEventListener(cell, "mouseleave", dom.RegisterCallback(func() { dom.SetStyle(cell, "background", "transparent") })) dom.AddEventListener(cell, "click", dom.RegisterCallback(func() { publishReaction(capturedEv, capturedEmoji) closeEmojiPicker() })) dom.AppendChild(grid, cell) } dom.AppendChild(modal, grid) // Search filtering - match against all names for each emoji. capturedGrid := grid dom.AddEventListener(search, "input", dom.RegisterCallback(func() { q := toLower(dom.GetProperty(search, "value")) child := dom.FirstElementChild(capturedGrid) for child != 0 { next := dom.NextSibling(child) title := dom.GetProperty(child, "title") if q == "" || contains(title, q) { dom.SetStyle(child, "display", "inline-flex") } else { dom.SetStyle(child, "display", "none") } child = next } })) dom.AppendChild(overlay, modal) dom.AppendChild(dom.Body(), overlay) dom.Focus(search) } func closeEmojiPicker() { if appSt.emojiOverlay != 0 { dom.RemoveChild(dom.Body(), appSt.emojiOverlay) appSt.emojiOverlay = 0 } appSt.emojiOpen = false appSt.emojiTargetEv = nil } func publishReaction(ev *nostr.Event, emoji string) { if !signer.HasSigner() || appSt.pubhex == "" { return } relay := "" if urls, ok := appSt.eventRelays[ev.ID]; ok && len(urls) > 0 { relay = urls[0] } tags := "[[\"e\"," | jstr(ev.ID) | "," | jstr(relay) | "],[\"p\"," | jstr(ev.PubKey) | "]]" ts := dom.NowSeconds() unsigned := "{\"kind\":7,\"content\":" | jstr(emoji) | ",\"tags\":" | tags | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | appSt.pubhex | "\"}" capturedEvID := ev.ID capturedEmoji := emoji signer.SignEvent(unsigned, func(signed string) { if signed != "" { selected := composeSelectedRelays() if len(selected) > 0 { msg := "[\"PUBLISH_TO\"," | signed | ",[" for i, u := range selected { if i > 0 { msg = msg | "," } msg = msg | jstr(u) } routeMsg(msg | "]]") relayList := "" for j, u := range selected { if j > 0 { relayList = relayList | "\n" } relayList = relayList | u } logActivity("react", capturedEmoji | " on " | capturedEvID[:12], "relays:\n" | relayList | "\n\nevent:\n" | signed) } else { routeMsg("[\"EVENT\"," | signed | "]") logActivity("react", capturedEmoji | " on " | capturedEvID[:12], "no targeted relays\n\nevent:\n" | signed) } ri := appSt.noteReactionMap[capturedEvID] if ri == nil { ri = map[string]map[string]bool{} } pks := ri[capturedEmoji] if pks == nil { pks = map[string]bool{} } pks[appSt.pubhex] = true ri[capturedEmoji] = pks appSt.noteReactionMap[capturedEvID] = ri if el, ok2 := appSt.noteReactionEls[capturedEvID]; ok2 { fillReactionDisplay(el, capturedEvID) } } }) } func contains(s, sub string) (ok bool) { if len(sub) > len(s) { return false } for i := 0; i <= len(s)-len(sub); i++ { if s[i:i+len(sub)] == sub { return true } } return false } // --- Settings page --- func renderSettings() { clearChildren(appSt.settingsPage) title := dom.CreateElement("h2") dom.SetTextContent(title, t("settings_title")) dom.SetStyle(title, "fontSize", "20px") dom.SetStyle(title, "marginBottom", "24px") dom.AppendChild(appSt.settingsPage, title) // Language selector. langRow := dom.CreateElement("div") dom.SetStyle(langRow, "display", "flex") dom.SetStyle(langRow, "alignItems", "center") dom.SetStyle(langRow, "gap", "12px") dom.SetStyle(langRow, "marginBottom", "16px") langLabel := dom.CreateElement("span") dom.SetTextContent(langLabel, t("lang_label")) dom.SetStyle(langLabel, "fontSize", "14px") dom.SetStyle(langLabel, "minWidth", "140px") dom.AppendChild(langRow, langLabel) langSel := dom.CreateElement("select") dom.SetStyle(langSel, "fontFamily", "'Fira Code', monospace") dom.SetStyle(langSel, "fontSize", "13px") dom.SetStyle(langSel, "background", "var(--bg2)") dom.SetStyle(langSel, "color", "var(--fg)") dom.SetStyle(langSel, "border", "1px solid var(--border)") dom.SetStyle(langSel, "borderRadius", "4px") dom.SetStyle(langSel, "padding", "6px 12px") for code, name := range langNames { opt := dom.CreateElement("option") dom.SetAttribute(opt, "value", code) dom.SetTextContent(opt, name) if code == appSt.currentLang { dom.SetAttribute(opt, "selected", "selected") } dom.AppendChild(langSel, opt) } dom.AddEventListener(langSel, "change", dom.RegisterCallback(func() { val := dom.GetProperty(langSel, "value") setLang(val) saveAppSettings() dom.SetTextContent(appSt.pageTitleEl, t("settings")) renderSettings() })) dom.AppendChild(langRow, langSel) dom.AppendChild(appSt.settingsPage, langRow) // Theme selector. themeRow := dom.CreateElement("div") dom.SetStyle(themeRow, "display", "flex") dom.SetStyle(themeRow, "alignItems", "center") dom.SetStyle(themeRow, "gap", "12px") dom.SetStyle(themeRow, "marginBottom", "16px") themeLabel := dom.CreateElement("span") dom.SetTextContent(themeLabel, t("theme_label")) dom.SetStyle(themeLabel, "fontSize", "14px") dom.SetStyle(themeLabel, "minWidth", "140px") dom.AppendChild(themeRow, themeLabel) themeToggle := dom.CreateElement("button") if appSt.isDark { dom.SetTextContent(themeToggle, t("dark")) } else { dom.SetTextContent(themeToggle, t("light")) } dom.SetStyle(themeToggle, "fontFamily", "'Fira Code', monospace") dom.SetStyle(themeToggle, "fontSize", "13px") dom.SetStyle(themeToggle, "background", "var(--bg2)") dom.SetStyle(themeToggle, "color", "var(--fg)") dom.SetStyle(themeToggle, "border", "1px solid var(--border)") dom.SetStyle(themeToggle, "borderRadius", "4px") dom.SetStyle(themeToggle, "padding", "6px 16px") dom.SetStyle(themeToggle, "cursor", "pointer") dom.AddEventListener(themeToggle, "click", dom.RegisterCallback(func() { toggleTheme() if appSt.isDark { dom.SetTextContent(themeToggle, t("dark")) } else { dom.SetTextContent(themeToggle, t("light")) } saveAppSettings() })) dom.AppendChild(themeRow, themeToggle) dom.AppendChild(appSt.settingsPage, themeRow) // Client tag setting. clientRow := dom.CreateElement("div") dom.SetStyle(clientRow, "display", "flex") dom.SetStyle(clientRow, "alignItems", "center") dom.SetStyle(clientRow, "gap", "12px") dom.SetStyle(clientRow, "marginBottom", "16px") clientLabel := dom.CreateElement("span") dom.SetTextContent(clientLabel, "client tag") dom.SetStyle(clientLabel, "fontSize", "14px") dom.SetStyle(clientLabel, "minWidth", "140px") dom.AppendChild(clientRow, clientLabel) clientCb := dom.CreateElement("input") dom.SetAttribute(clientCb, "type", "checkbox") if localstorage.GetItem("musiquay-client-tag") != "0" { dom.SetProperty(clientCb, "checked", "true") } dom.AddEventListener(clientCb, "change", dom.RegisterCallback(func() { checked := dom.GetProperty(clientCb, "checked") if checked == "true" { localstorage.RemoveItem("musiquay-client-tag") } else { localstorage.SetItem("musiquay-client-tag", "0") } saveAppSettings() })) dom.AppendChild(clientRow, clientCb) clientDesc := dom.CreateElement("span") dom.SetTextContent(clientDesc, "add smesh.lol tag to posts") dom.SetStyle(clientDesc, "fontSize", "12px") dom.SetStyle(clientDesc, "color", "var(--muted)") dom.AppendChild(clientRow, clientDesc) dom.AppendChild(appSt.settingsPage, clientRow) // Blossom server URL. blossomRow := dom.CreateElement("div") dom.SetStyle(blossomRow, "display", "flex") dom.SetStyle(blossomRow, "flexWrap", "wrap") dom.SetStyle(blossomRow, "alignItems", "center") dom.SetStyle(blossomRow, "gap", "12px") dom.SetStyle(blossomRow, "marginBottom", "16px") blossomLabel := dom.CreateElement("span") dom.SetTextContent(blossomLabel, "blossom server") dom.SetStyle(blossomLabel, "fontSize", "14px") dom.SetStyle(blossomLabel, "minWidth", "140px") dom.AppendChild(blossomRow, blossomLabel) blossomInput := dom.CreateElement("input") dom.SetAttribute(blossomInput, "type", "text") dom.SetAttribute(blossomInput, "placeholder", "https://blossom.example.com") blossomVal := appSt.blossomServerURL if blossomVal == "" { blossomVal = defaultBlossomURL() } dom.SetProperty(blossomInput, "value", blossomVal) dom.SetStyle(blossomInput, "fontFamily", "'Fira Code', monospace") dom.SetStyle(blossomInput, "fontSize", "13px") dom.SetStyle(blossomInput, "background", "var(--bg)") dom.SetStyle(blossomInput, "color", "var(--fg)") dom.SetStyle(blossomInput, "border", "1px solid var(--border)") dom.SetStyle(blossomInput, "borderRadius", "4px") dom.SetStyle(blossomInput, "padding", "6px 12px") dom.SetStyle(blossomInput, "flex", "1") dom.SetStyle(blossomInput, "minWidth", "200px") dom.AppendChild(blossomRow, blossomInput) blossomSave := dom.CreateElement("button") dom.SetTextContent(blossomSave, "save") dom.SetStyle(blossomSave, "padding", "6px 16px") dom.SetStyle(blossomSave, "borderRadius", "4px") dom.SetStyle(blossomSave, "border", "1px solid var(--border)") dom.SetStyle(blossomSave, "background", "var(--accent)") dom.SetStyle(blossomSave, "color", "var(--bg)") dom.SetStyle(blossomSave, "cursor", "pointer") dom.SetStyle(blossomSave, "fontSize", "13px") dom.AddEventListener(blossomSave, "click", dom.RegisterCallback(func() { val := dom.GetProperty(blossomInput, "value") appSt.blossomServerURL = val if val == "" { localstorage.RemoveItem(lsKeyBlossomServer) } else { localstorage.SetItem(lsKeyBlossomServer, val) } saveAppSettings() dom.SetTextContent(blossomSave, "saved!") dom.SetTimeout(func() { dom.SetTextContent(blossomSave, "save") }, 1500) })) dom.AppendChild(blossomRow, blossomSave) dom.AppendChild(appSt.settingsPage, blossomRow) // Invidious URL setting. appendServiceURLSetting(appSt.settingsPage, "invidious server", appSt.invidiousURL, defaultServiceURL("invidious"), lsKeyInvidiousURL, func(v string) { appSt.invidiousURL = v }, "use invidious.smesh.lol") // Campion (Bandcamp player) URL setting. appendServiceURLSetting(appSt.settingsPage, "campion (bandcamp)", appSt.campionURL, defaultServiceURL("campion"), lsKeyCampionURL, func(v string) { appSt.campionURL = v }, "use campion.smesh.lol") renderRelaysSettings() } func addRelayURL(url string) { addRelay(url, true) sendWriteRelays() populateFeedSelect() } type relayFreqEntry struct { url string freq int32 } // sortRelaysByFreq: relayFreq moved to Profile Worker; Shell returns empty. func sortRelaysByFreq() (ss []relayFreqEntry) { return nil } // renderRelaysSettings renders the "Relays" section of the settings page: // active relays with capability badges, required-capability checkboxes, and // the blocklist. Called from renderSettings(). Read-only behavior at this // step: checkboxes persist to localStorage but no enforcement is applied. // appendServiceURLSetting appends a labelled URL input + "use X" prefill button + save button. // onSet is called with the new value when saved. prefillLabel is the text on the prefill button. func appendServiceURLSetting(parent dom.Element, label, current, defaultVal, lsKey string, onSet func(string), prefillLabel string) { row := dom.CreateElement("div") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "flexWrap", "wrap") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "12px") dom.SetStyle(row, "marginBottom", "16px") lbl := dom.CreateElement("span") dom.SetTextContent(lbl, label) dom.SetStyle(lbl, "fontSize", "14px") dom.SetStyle(lbl, "minWidth", "160px") dom.AppendChild(row, lbl) input := dom.CreateElement("input") dom.SetAttribute(input, "type", "text") dom.SetAttribute(input, "placeholder", "https://...") val := current if val == "" && defaultVal != "" { val = defaultVal } dom.SetProperty(input, "value", val) dom.SetStyle(input, "fontFamily", "'Fira Code', monospace") dom.SetStyle(input, "fontSize", "13px") dom.SetStyle(input, "background", "var(--bg)") dom.SetStyle(input, "color", "var(--fg)") dom.SetStyle(input, "border", "1px solid var(--border)") dom.SetStyle(input, "borderRadius", "4px") dom.SetStyle(input, "padding", "6px 12px") dom.SetStyle(input, "flex", "1") dom.SetStyle(input, "minWidth", "200px") dom.AppendChild(row, input) if defaultVal != "" { prefillBtn := dom.CreateElement("button") dom.SetTextContent(prefillBtn, prefillLabel) dom.SetStyle(prefillBtn, "padding", "6px 10px") dom.SetStyle(prefillBtn, "borderRadius", "4px") dom.SetStyle(prefillBtn, "border", "1px solid var(--border)") dom.SetStyle(prefillBtn, "background", "transparent") dom.SetStyle(prefillBtn, "color", "var(--fg)") dom.SetStyle(prefillBtn, "cursor", "pointer") dom.SetStyle(prefillBtn, "fontSize", "12px") capturedInput := input capturedDefault := defaultVal dom.AddEventListener(prefillBtn, "click", dom.RegisterCallback(func() { dom.SetProperty(capturedInput, "value", capturedDefault) })) dom.AppendChild(row, prefillBtn) } saveBtn := dom.CreateElement("button") dom.SetTextContent(saveBtn, "save") dom.SetStyle(saveBtn, "padding", "6px 16px") dom.SetStyle(saveBtn, "borderRadius", "4px") dom.SetStyle(saveBtn, "border", "1px solid var(--border)") dom.SetStyle(saveBtn, "background", "var(--accent)") dom.SetStyle(saveBtn, "color", "var(--bg)") dom.SetStyle(saveBtn, "cursor", "pointer") dom.SetStyle(saveBtn, "fontSize", "13px") capturedInput2 := input capturedLsKey := lsKey capturedOnSet := onSet capturedSaveBtn := saveBtn dom.AddEventListener(saveBtn, "click", dom.RegisterCallback(func() { v := dom.GetProperty(capturedInput2, "value") capturedOnSet(v) if v == "" { localstorage.RemoveItem(capturedLsKey) } else { localstorage.SetItem(capturedLsKey, v) } saveAppSettings() dom.SetTextContent(capturedSaveBtn, "saved!") dom.SetTimeout(func() { dom.SetTextContent(capturedSaveBtn, "save") }, 1500) })) dom.AppendChild(row, saveBtn) dom.AppendChild(parent, row) } func renderRelaysSettings() { // Section header. hdr := dom.CreateElement("h3") dom.SetTextContent(hdr, t("relays_section")) dom.SetStyle(hdr, "fontSize", "16px") dom.SetStyle(hdr, "marginTop", "32px") dom.SetStyle(hdr, "marginBottom", "12px") dom.AppendChild(appSt.settingsPage, hdr) // --- Required capabilities checkboxes --- reqHdr := dom.CreateElement("div") dom.SetTextContent(reqHdr, t("relays_required")) dom.SetStyle(reqHdr, "fontSize", "13px") dom.SetStyle(reqHdr, "color", "var(--muted)") dom.SetStyle(reqHdr, "marginBottom", "6px") dom.AppendChild(appSt.settingsPage, reqHdr) addPolicyCheckbox := func(label string, bit int32) { row := dom.CreateElement("label") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "8px") dom.SetStyle(row, "padding", "4px 0") dom.SetStyle(row, "fontSize", "13px") dom.SetStyle(row, "cursor", "pointer") cb := dom.CreateElement("input") dom.SetAttribute(cb, "type", "checkbox") if appSt.relayPolicyFlags&bit != 0 { dom.SetAttribute(cb, "checked", "checked") } dom.AddEventListener(cb, "change", dom.RegisterCallback(func() { if dom.GetProperty(cb, "checked") == "true" { appSt.relayPolicyFlags = appSt.relayPolicyFlags | bit } else { appSt.relayPolicyFlags = appSt.relayPolicyFlags &^ bit } saveRelayPolicy() })) dom.AppendChild(row, cb) lblEl := dom.CreateElement("span") dom.SetTextContent(lblEl, label) dom.AppendChild(row, lblEl) dom.AppendChild(appSt.settingsPage, row) } addPolicyCheckbox(t("relays_req_nip42"), policyRequireAuth) addPolicyCheckbox(t("relays_req_nip70"), policyRequireProtect) addPolicyCheckbox(t("relays_block_payment"), policyBlockPayment) addPolicyCheckbox(t("relays_block_restrict"), policyBlockRestricted) // --- Active relays list --- activeHdr := dom.CreateElement("div") dom.SetTextContent(activeHdr, t("relays_active")) dom.SetStyle(activeHdr, "fontSize", "13px") dom.SetStyle(activeHdr, "color", "var(--muted)") dom.SetStyle(activeHdr, "marginTop", "20px") dom.SetStyle(activeHdr, "marginBottom", "6px") dom.AppendChild(appSt.settingsPage, activeHdr) for i, url := range appSt.relayURLs { thisURL := url // capture per-iteration for closures row := dom.CreateElement("div") dom.SetStyle(row, "padding", "3px 0") dom.SetStyle(row, "fontSize", "12px") dom.SetStyle(row, "fontFamily", "'Fira Code', monospace") dom.SetStyle(row, "wordBreak", "break-all") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "6px") // Block button first so it sits at the left edge. blockBtn := dom.CreateElement("button") dom.SetTextContent(blockBtn, "\u00D7") dom.SetStyle(blockBtn, "background", "transparent") dom.SetStyle(blockBtn, "border", "1px solid var(--border)") dom.SetStyle(blockBtn, "color", "var(--fg)") dom.SetStyle(blockBtn, "cursor", "pointer") dom.SetStyle(blockBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(blockBtn, "fontSize", "11px") dom.SetStyle(blockBtn, "width", "18px") dom.SetStyle(blockBtn, "height", "18px") dom.SetStyle(blockBtn, "padding", "0") dom.SetStyle(blockBtn, "lineHeight", "1") dom.SetStyle(blockBtn, "flexShrink", "0") dom.AddEventListener(blockBtn, "click", dom.RegisterCallback(func() { if !dom.Confirm("Block " | thisURL | "?") { return } addToBlocklist(thisURL) renderSettings() })) dom.AppendChild(row, blockBtn) urlSpan := dom.CreateElement("span") dom.SetTextContent(urlSpan, thisURL) dom.SetStyle(urlSpan, "flex", "1") dom.SetStyle(urlSpan, "overflow", "hidden") dom.SetStyle(urlSpan, "textOverflow", "ellipsis") dom.SetStyle(urlSpan, "whiteSpace", "nowrap") if appSt.relayUserPick[i] { dom.SetStyle(urlSpan, "fontWeight", "bold") } dom.AppendChild(row, urlSpan) // Read / write role buttons. thisI := i makeSettingsRoleBtn := func(label string) dom.Element { b := dom.CreateElement("button") dom.SetTextContent(b, label) dom.SetStyle(b, "flexShrink", "0") dom.SetStyle(b, "fontSize", "10px") dom.SetStyle(b, "fontFamily", "'Fira Code', monospace") dom.SetStyle(b, "padding", "1px 5px") dom.SetStyle(b, "borderRadius", "3px") dom.SetStyle(b, "border", "1px solid var(--border)") dom.SetStyle(b, "cursor", "pointer") return b } srBtn := makeSettingsRoleBtn("r") swBtn := makeSettingsRoleBtn("w") role := "both" if thisI < len(appSt.relayRole) { role = appSt.relayRole[thisI] } applyRelayRoleBtns(srBtn, swBtn, role) capturedI := thisI dom.AddEventListener(srBtn, "click", dom.RegisterCallback(func() { if capturedI >= len(appSt.relayRole) { return } switch appSt.relayRole[capturedI] { case "both": appSt.relayRole[capturedI] = "write" case "read": appSt.relayRole[capturedI] = "write" default: appSt.relayRole[capturedI] = "both" } if capturedI < len(appSt.relayReadBtns) { applyRelayRoleBtns(appSt.relayReadBtns[capturedI], appSt.relayWriteBtns[capturedI], appSt.relayRole[capturedI]) } applyRelayRoleBtns(srBtn, swBtn, appSt.relayRole[capturedI]) saveRelayList() sendWriteRelays() })) dom.AddEventListener(swBtn, "click", dom.RegisterCallback(func() { if capturedI >= len(appSt.relayRole) { return } switch appSt.relayRole[capturedI] { case "both": appSt.relayRole[capturedI] = "read" case "write": appSt.relayRole[capturedI] = "read" default: appSt.relayRole[capturedI] = "both" } if capturedI < len(appSt.relayReadBtns) { applyRelayRoleBtns(appSt.relayReadBtns[capturedI], appSt.relayWriteBtns[capturedI], appSt.relayRole[capturedI]) } applyRelayRoleBtns(srBtn, swBtn, appSt.relayRole[capturedI]) saveRelayList() sendWriteRelays() })) caps := appSt.relayCaps[i] if !hasNIP(caps.supportedNIPs, 42) { dom.AppendChild(row, makeRelayBadge("!42")) } if !hasNIP(caps.supportedNIPs, 70) { dom.AppendChild(row, makeRelayBadge("!70")) } if caps.fetchedOK && hasNIP(caps.supportedNIPs, 50) { badge := makeRelayBadge("search") dom.SetStyle(badge, "background", "rgba(0,180,80,0.15)") dom.SetStyle(badge, "color", "#0a0") dom.SetStyle(badge, "border", "1px solid rgba(0,180,80,0.3)") dom.AppendChild(row, badge) } if !caps.fetchedOK { noDoc := dom.CreateElement("span") dom.SetTextContent(noDoc, t("relays_no_nip11")) dom.SetStyle(noDoc, "fontSize", "10px") dom.SetStyle(noDoc, "color", "var(--muted)") dom.SetStyle(noDoc, "marginLeft", "6px") dom.AppendChild(row, noDoc) } // r/w buttons pushed to far right. dom.SetStyle(srBtn, "marginLeft", "auto") dom.AppendChild(row, srBtn) dom.AppendChild(row, swBtn) dom.AppendChild(appSt.settingsPage, row) } // --- Add relay input --- addRow := dom.CreateElement("div") dom.SetStyle(addRow, "display", "flex") dom.SetStyle(addRow, "alignItems", "center") dom.SetStyle(addRow, "gap", "6px") dom.SetStyle(addRow, "marginTop", "8px") dom.SetStyle(addRow, "position", "relative") addInput := dom.CreateElement("input") dom.SetAttribute(addInput, "type", "text") dom.SetAttribute(addInput, "placeholder", "wss://relay.example.com") dom.SetStyle(addInput, "flex", "1") dom.SetStyle(addInput, "padding", "6px 8px") dom.SetStyle(addInput, "fontSize", "12px") dom.SetStyle(addInput, "fontFamily", "'Fira Code', monospace") dom.SetStyle(addInput, "background", "var(--bg)") dom.SetStyle(addInput, "color", "var(--fg)") dom.SetStyle(addInput, "border", "1px solid var(--border)") dom.SetStyle(addInput, "borderRadius", "4px") addBtn := dom.CreateElement("button") dom.SetTextContent(addBtn, "+") dom.SetStyle(addBtn, "padding", "6px 10px") dom.SetStyle(addBtn, "background", "var(--accent)") dom.SetStyle(addBtn, "color", "#fff") dom.SetStyle(addBtn, "border", "none") dom.SetStyle(addBtn, "borderRadius", "4px") dom.SetStyle(addBtn, "cursor", "pointer") dom.SetStyle(addBtn, "fontWeight", "bold") dom.AddEventListener(addBtn, "click", dom.RegisterCallback(func() { url := dom.GetProperty(addInput, "value") if url != "" { addRelayURL(url) dom.SetProperty(addInput, "value", "") renderSettings() } })) // Suggestion dropdown button. sugWrap := dom.CreateElement("div") dom.SetStyle(sugWrap, "position", "relative") sugBtn := dom.CreateElement("button") dom.SetTextContent(sugBtn, "\u25BE") dom.SetStyle(sugBtn, "padding", "6px 8px") dom.SetStyle(sugBtn, "background", "var(--bg2)") dom.SetStyle(sugBtn, "color", "var(--fg)") dom.SetStyle(sugBtn, "border", "1px solid var(--border)") dom.SetStyle(sugBtn, "borderRadius", "4px") dom.SetStyle(sugBtn, "cursor", "pointer") sugPop := dom.CreateElement("div") dom.SetStyle(sugPop, "display", "none") dom.SetStyle(sugPop, "position", "absolute") dom.SetStyle(sugPop, "bottom", "100%") dom.SetStyle(sugPop, "right", "0") dom.SetStyle(sugPop, "marginBottom", "4px") dom.SetStyle(sugPop, "background", "var(--bg)") dom.SetStyle(sugPop, "border", "1px solid var(--border)") dom.SetStyle(sugPop, "borderRadius", "6px") dom.SetStyle(sugPop, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)") dom.SetStyle(sugPop, "zIndex", "1000") dom.SetStyle(sugPop, "minWidth", "280px") dom.SetStyle(sugPop, "maxHeight", "200px") dom.SetStyle(sugPop, "overflowY", "auto") dom.SetStyle(sugPop, "padding", "4px") sugOpen := false dom.AddEventListener(sugBtn, "click", dom.RegisterCallback(func() { if sugOpen { dom.SetStyle(sugPop, "display", "none") sugOpen = false return } // Build sorted suggestions. clearChildren(sugPop) sorted := sortRelaysByFreq() count := 0 for _, entry := range sorted { u := entry.url // Skip already-active relays. already := false for _, active := range appSt.relayURLs { if active == u { already = true break } } if already { continue } sugRow := dom.CreateElement("div") dom.SetStyle(sugRow, "display", "flex") dom.SetStyle(sugRow, "alignItems", "center") dom.SetStyle(sugRow, "justifyContent", "space-between") dom.SetStyle(sugRow, "padding", "4px 8px") dom.SetStyle(sugRow, "cursor", "pointer") dom.SetStyle(sugRow, "borderRadius", "4px") dom.SetStyle(sugRow, "fontSize", "12px") dom.SetStyle(sugRow, "fontFamily", "'Fira Code', monospace") urlSpan := dom.CreateElement("span") dom.SetTextContent(urlSpan, stripScheme(u)) dom.AppendChild(sugRow, urlSpan) freqSpan := dom.CreateElement("span") dom.SetTextContent(freqSpan, itoa(entry.freq)) dom.SetStyle(freqSpan, "color", "var(--muted)") dom.SetStyle(freqSpan, "fontSize", "10px") dom.AppendChild(sugRow, freqSpan) dom.AddEventListener(sugRow, "mouseenter", dom.RegisterCallback(func() { dom.SetStyle(sugRow, "background", "var(--bg2)") })) dom.AddEventListener(sugRow, "mouseleave", dom.RegisterCallback(func() { dom.SetStyle(sugRow, "background", "transparent") })) capURL := u dom.AddEventListener(sugRow, "click", dom.RegisterCallback(func() { addRelayURL(capURL) dom.SetStyle(sugPop, "display", "none") sugOpen = false renderSettings() })) dom.AppendChild(sugPop, sugRow) count++ } if count == 0 { empty := dom.CreateElement("div") dom.SetTextContent(empty, "no suggestions yet") dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "fontSize", "12px") dom.SetStyle(empty, "padding", "8px") dom.AppendChild(sugPop, empty) } dom.SetStyle(sugPop, "display", "block") sugOpen = true })) dom.AppendChild(sugWrap, sugBtn) dom.AppendChild(sugWrap, sugPop) dom.AppendChild(addRow, addInput) dom.AppendChild(addRow, addBtn) dom.AppendChild(addRow, sugWrap) dom.AppendChild(appSt.settingsPage, addRow) // --- Blocklist --- blHdr := dom.CreateElement("div") dom.SetTextContent(blHdr, t("relays_blocklist")) dom.SetStyle(blHdr, "fontSize", "13px") dom.SetStyle(blHdr, "color", "var(--muted)") dom.SetStyle(blHdr, "marginTop", "20px") dom.SetStyle(blHdr, "marginBottom", "6px") dom.AppendChild(appSt.settingsPage, blHdr) if len(appSt.relayBlocklist) == 0 { empty := dom.CreateElement("div") dom.SetTextContent(empty, t("relays_blocklist_empty")) dom.SetStyle(empty, "fontSize", "12px") dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "fontStyle", "italic") dom.AppendChild(appSt.settingsPage, empty) } else { for _, url := range appSt.relayBlocklist { thisURL := url // capture per-iteration for closure row := dom.CreateElement("div") dom.SetStyle(row, "padding", "3px 0") dom.SetStyle(row, "fontSize", "12px") dom.SetStyle(row, "fontFamily", "'Fira Code', monospace") dom.SetStyle(row, "wordBreak", "break-all") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "6px") unblockBtn := dom.CreateElement("button") dom.SetTextContent(unblockBtn, "\u00D7") dom.SetStyle(unblockBtn, "background", "transparent") dom.SetStyle(unblockBtn, "border", "1px solid var(--border)") dom.SetStyle(unblockBtn, "color", "var(--fg)") dom.SetStyle(unblockBtn, "cursor", "pointer") dom.SetStyle(unblockBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(unblockBtn, "fontSize", "11px") dom.SetStyle(unblockBtn, "width", "18px") dom.SetStyle(unblockBtn, "height", "18px") dom.SetStyle(unblockBtn, "padding", "0") dom.SetStyle(unblockBtn, "lineHeight", "1") dom.SetStyle(unblockBtn, "flexShrink", "0") dom.AddEventListener(unblockBtn, "click", dom.RegisterCallback(func() { removeFromBlocklist(thisURL) renderSettings() })) dom.AppendChild(row, unblockBtn) urlSpan := dom.CreateElement("span") dom.SetTextContent(urlSpan, thisURL) dom.AppendChild(row, urlSpan) dom.AppendChild(appSt.settingsPage, row) } } // --- Add to blocklist input --- blAddRow := dom.CreateElement("div") dom.SetStyle(blAddRow, "display", "flex") dom.SetStyle(blAddRow, "alignItems", "center") dom.SetStyle(blAddRow, "gap", "6px") dom.SetStyle(blAddRow, "marginTop", "8px") dom.SetStyle(blAddRow, "position", "relative") blAddInput := dom.CreateElement("input") dom.SetAttribute(blAddInput, "type", "text") dom.SetAttribute(blAddInput, "placeholder", "wss://relay.example.com") dom.SetStyle(blAddInput, "flex", "1") dom.SetStyle(blAddInput, "padding", "6px 8px") dom.SetStyle(blAddInput, "fontSize", "12px") dom.SetStyle(blAddInput, "fontFamily", "'Fira Code', monospace") dom.SetStyle(blAddInput, "background", "var(--bg)") dom.SetStyle(blAddInput, "color", "var(--fg)") dom.SetStyle(blAddInput, "border", "1px solid var(--border)") dom.SetStyle(blAddInput, "borderRadius", "4px") blAddBtn := dom.CreateElement("button") dom.SetTextContent(blAddBtn, "+") dom.SetStyle(blAddBtn, "padding", "6px 10px") dom.SetStyle(blAddBtn, "background", "var(--accent)") dom.SetStyle(blAddBtn, "color", "#fff") dom.SetStyle(blAddBtn, "border", "none") dom.SetStyle(blAddBtn, "borderRadius", "4px") dom.SetStyle(blAddBtn, "cursor", "pointer") dom.SetStyle(blAddBtn, "fontWeight", "bold") dom.AddEventListener(blAddBtn, "click", dom.RegisterCallback(func() { url := dom.GetProperty(blAddInput, "value") if url != "" { addToBlocklist(url) dom.SetProperty(blAddInput, "value", "") renderSettings() } })) // Suggestion dropdown button for blocklist. blSugWrap := dom.CreateElement("div") dom.SetStyle(blSugWrap, "position", "relative") blSugBtn := dom.CreateElement("button") dom.SetTextContent(blSugBtn, "\u25BE") dom.SetStyle(blSugBtn, "padding", "6px 8px") dom.SetStyle(blSugBtn, "background", "var(--bg2)") dom.SetStyle(blSugBtn, "color", "var(--fg)") dom.SetStyle(blSugBtn, "border", "1px solid var(--border)") dom.SetStyle(blSugBtn, "borderRadius", "4px") dom.SetStyle(blSugBtn, "cursor", "pointer") blSugPop := dom.CreateElement("div") dom.SetStyle(blSugPop, "display", "none") dom.SetStyle(blSugPop, "position", "absolute") dom.SetStyle(blSugPop, "bottom", "100%") dom.SetStyle(blSugPop, "right", "0") dom.SetStyle(blSugPop, "marginBottom", "4px") dom.SetStyle(blSugPop, "background", "var(--bg)") dom.SetStyle(blSugPop, "border", "1px solid var(--border)") dom.SetStyle(blSugPop, "borderRadius", "6px") dom.SetStyle(blSugPop, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)") dom.SetStyle(blSugPop, "zIndex", "1000") dom.SetStyle(blSugPop, "minWidth", "280px") dom.SetStyle(blSugPop, "maxHeight", "200px") dom.SetStyle(blSugPop, "overflowY", "auto") dom.SetStyle(blSugPop, "padding", "4px") blSugOpen := false dom.AddEventListener(blSugBtn, "click", dom.RegisterCallback(func() { if blSugOpen { dom.SetStyle(blSugPop, "display", "none") blSugOpen = false return } clearChildren(blSugPop) sorted := sortRelaysByFreq() count := 0 for _, entry := range sorted { u := entry.url if isBlocked(u) { continue } sugRow := dom.CreateElement("div") dom.SetStyle(sugRow, "display", "flex") dom.SetStyle(sugRow, "alignItems", "center") dom.SetStyle(sugRow, "justifyContent", "space-between") dom.SetStyle(sugRow, "padding", "4px 8px") dom.SetStyle(sugRow, "cursor", "pointer") dom.SetStyle(sugRow, "borderRadius", "4px") dom.SetStyle(sugRow, "fontSize", "12px") dom.SetStyle(sugRow, "fontFamily", "'Fira Code', monospace") urlSpan := dom.CreateElement("span") dom.SetTextContent(urlSpan, stripScheme(u)) dom.AppendChild(sugRow, urlSpan) freqSpan := dom.CreateElement("span") dom.SetTextContent(freqSpan, itoa(entry.freq)) dom.SetStyle(freqSpan, "color", "var(--muted)") dom.SetStyle(freqSpan, "fontSize", "10px") dom.AppendChild(sugRow, freqSpan) dom.AddEventListener(sugRow, "mouseenter", dom.RegisterCallback(func() { dom.SetStyle(sugRow, "background", "var(--bg2)") })) dom.AddEventListener(sugRow, "mouseleave", dom.RegisterCallback(func() { dom.SetStyle(sugRow, "background", "transparent") })) capURL := u dom.AddEventListener(sugRow, "click", dom.RegisterCallback(func() { addToBlocklist(capURL) dom.SetStyle(blSugPop, "display", "none") blSugOpen = false renderSettings() })) dom.AppendChild(blSugPop, sugRow) count++ } if count == 0 { empty := dom.CreateElement("div") dom.SetTextContent(empty, "no suggestions yet") dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "fontSize", "12px") dom.SetStyle(empty, "padding", "8px") dom.AppendChild(blSugPop, empty) } dom.SetStyle(blSugPop, "display", "block") blSugOpen = true })) dom.AppendChild(blSugWrap, blSugBtn) dom.AppendChild(blSugWrap, blSugPop) dom.AppendChild(blAddRow, blAddInput) dom.AppendChild(blAddRow, blAddBtn) dom.AppendChild(blAddRow, blSugWrap) dom.AppendChild(appSt.settingsPage, blAddRow) // Wallet UI at the very bottom of the settings page. if int32(appSt.walletPanel) != 0 { dom.AppendChild(appSt.settingsPage, appSt.walletPanel) renderWalletUI() } } // --- Messaging --- // initMessaging is unreachable while messaging is disabled (no sidebar entry, // no /msg route). Keep it for the MLS/DM fix; re-enable by restoring the // sidebar button, the router cases, the switchPage case and the mlsWorkerTypes // routing in routeMsg. func initMessaging() { // Render new-chat button immediately - don't wait for DM_LIST round-trip. clearChildren(appSt.msgListContainer) if !signer.HasSigner() { notice := dom.CreateElement("div") dom.SetStyle(notice, "padding", "24px") dom.SetStyle(notice, "textAlign", "center") dom.SetStyle(notice, "color", "var(--muted)") dom.SetStyle(notice, "fontSize", "13px") dom.SetStyle(notice, "lineHeight", "1.6") dom.SetInnerHTML(notice, t("dm_notice")) dom.AppendChild(appSt.msgListContainer, notice) return } renderNewChatRow() // Init MLS if not already done + request conversation list. if !appSt.marmotInited { appSt.marmotInited = true routeMsg("[\"MLS_INIT\"," | relayURLsJSON() | "]") } routeMsg("[\"MLS_DM_LIST\"]") } func renderNewChatRow() { row := dom.CreateElement("div") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "gap", "6px") dom.SetStyle(row, "marginBottom", "8px") dom.SetStyle(row, "alignItems", "center") // --- Follows group (default visible): [follows ▾] [+] --- followsGroup := dom.CreateElement("div") dom.SetStyle(followsGroup, "display", "flex") dom.SetStyle(followsGroup, "gap", "6px") dom.SetStyle(followsGroup, "alignItems", "center") selWrap := dom.CreateElement("div") dom.SetStyle(selWrap, "position", "relative") selBtn := dom.CreateElement("button") dom.SetTextContent(selBtn, t("follows") | " \u25BE") dom.SetStyle(selBtn, "padding", "8px 12px") dom.SetStyle(selBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(selBtn, "fontSize", "12px") dom.SetStyle(selBtn, "background", "var(--bg2)") dom.SetStyle(selBtn, "border", "1px solid var(--border)") dom.SetStyle(selBtn, "borderRadius", "4px") dom.SetStyle(selBtn, "color", "var(--fg)") dom.SetStyle(selBtn, "cursor", "pointer") dropdown := dom.CreateElement("div") dom.SetStyle(dropdown, "display", "none") dom.SetStyle(dropdown, "position", "absolute") dom.SetStyle(dropdown, "left", "0") dom.SetStyle(dropdown, "top", "100%") dom.SetStyle(dropdown, "marginTop", "4px") dom.SetStyle(dropdown, "background", "var(--bg)") dom.SetStyle(dropdown, "border", "1px solid var(--border)") dom.SetStyle(dropdown, "borderRadius", "6px") dom.SetStyle(dropdown, "maxHeight", "300px") dom.SetStyle(dropdown, "overflowY", "auto") dom.SetStyle(dropdown, "minWidth", "200px") dom.SetStyle(dropdown, "maxWidth", "calc(100vw - 32px)") dom.SetStyle(dropdown, "zIndex", "100") dom.SetStyle(dropdown, "boxShadow", "0 4px 12px rgba(0,0,0,0.3)") populateFollowsDropdown(dropdown) dom.SetAttribute(selWrap, "onclick", "event.stopPropagation()") dropOpen := false dom.AddEventListener(selBtn, "click", dom.RegisterCallback(func() { if !dropOpen { populateFollowsDropdown(dropdown) dom.SetStyle(dropdown, "display", "block") dropOpen = true } else { dom.SetStyle(dropdown, "display", "none") dropOpen = false } })) dom.AddEventListener(dom.Body(), "click", dom.RegisterCallback(func() { dom.SetStyle(dropdown, "display", "none") dropOpen = false })) dom.AppendChild(selWrap, selBtn) dom.AppendChild(selWrap, dropdown) addBtn := dom.CreateElement("button") dom.SetTextContent(addBtn, "+") dom.SetStyle(addBtn, "padding", "8px 10px") dom.SetStyle(addBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(addBtn, "fontSize", "14px") dom.SetStyle(addBtn, "fontWeight", "bold") dom.SetStyle(addBtn, "background", "var(--bg2)") dom.SetStyle(addBtn, "border", "1px solid var(--border)") dom.SetStyle(addBtn, "borderRadius", "4px") dom.SetStyle(addBtn, "color", "var(--fg)") dom.SetStyle(addBtn, "cursor", "pointer") dom.SetStyle(addBtn, "lineHeight", "1") dom.AppendChild(followsGroup, selWrap) dom.AppendChild(followsGroup, addBtn) // --- Input group (hidden until + clicked): [input] [go] [×] --- inputGroup := dom.CreateElement("div") dom.SetStyle(inputGroup, "display", "none") dom.SetStyle(inputGroup, "flex", "1") dom.SetStyle(inputGroup, "gap", "6px") dom.SetStyle(inputGroup, "alignItems", "center") inp := dom.CreateElement("input") dom.SetAttribute(inp, "type", "text") dom.SetAttribute(inp, "placeholder", t("npub_placeholder")) dom.SetStyle(inp, "flex", "1 1 120px") dom.SetStyle(inp, "minWidth", "0") dom.SetStyle(inp, "padding", "8px") dom.SetStyle(inp, "fontFamily", "'Fira Code', monospace") dom.SetStyle(inp, "fontSize", "12px") dom.SetStyle(inp, "background", "var(--bg)") dom.SetStyle(inp, "border", "1px solid var(--border)") dom.SetStyle(inp, "borderRadius", "4px") dom.SetStyle(inp, "color", "var(--fg)") goBtn := dom.CreateElement("button") dom.SetTextContent(goBtn, t("go")) dom.SetStyle(goBtn, "padding", "8px 14px") dom.SetStyle(goBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(goBtn, "fontSize", "12px") dom.SetStyle(goBtn, "background", "var(--accent)") dom.SetStyle(goBtn, "color", "#fff") dom.SetStyle(goBtn, "border", "none") dom.SetStyle(goBtn, "borderRadius", "4px") dom.SetStyle(goBtn, "cursor", "pointer") dom.SetStyle(goBtn, "flexShrink", "0") closeBtn := dom.CreateElement("button") dom.SetTextContent(closeBtn, "\u00D7") dom.SetStyle(closeBtn, "padding", "8px 10px") dom.SetStyle(closeBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(closeBtn, "fontSize", "14px") dom.SetStyle(closeBtn, "fontWeight", "bold") dom.SetStyle(closeBtn, "background", "var(--bg2)") dom.SetStyle(closeBtn, "border", "1px solid var(--border)") dom.SetStyle(closeBtn, "borderRadius", "4px") dom.SetStyle(closeBtn, "color", "var(--fg)") dom.SetStyle(closeBtn, "cursor", "pointer") dom.SetStyle(closeBtn, "lineHeight", "1") dom.AppendChild(inputGroup, inp) dom.AppendChild(inputGroup, goBtn) dom.AppendChild(inputGroup, closeBtn) // Toggle between follows group and input group. showInput := func() { dom.SetStyle(dropdown, "display", "none") dropOpen = false dom.SetStyle(followsGroup, "display", "none") dom.SetStyle(inputGroup, "display", "flex") dom.SetProperty(inp, "value", "") dom.Focus(inp) } showFollows := func() { dom.SetStyle(inputGroup, "display", "none") dom.SetStyle(followsGroup, "display", "flex") } dom.AddEventListener(addBtn, "click", dom.RegisterCallback(showInput)) dom.AddEventListener(closeBtn, "click", dom.RegisterCallback(showFollows)) // Submit handler for input + go button. submitNewChat := func() { val := dom.GetProperty(inp, "value") if val == "" { return } var hexPK string if len(val) == 64 { hexPK = val } else if len(val) > 4 && val[:4] == "npub" { decoded := helpers.DecodeNpub(val) if decoded == nil { return } hexPK = helpers.HexEncode(decoded) } else { return } openThread(hexPK) } dom.AddEventListener(goBtn, "click", dom.RegisterCallback(submitNewChat)) dom.SetAttribute(inp, "onkeydown", "if(event.key==='Enter'){event.preventDefault();this.nextSibling.click()}") dom.AppendChild(row, followsGroup) dom.AppendChild(row, inputGroup) dom.AppendChild(appSt.msgListContainer, row) } func populateFollowsDropdown(dropdown dom.Element) { clearChildren(dropdown) pks := appSt.myFollows if len(pks) == 0 { empty := dom.CreateElement("div") dom.SetStyle(empty, "padding", "12px") dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "fontSize", "12px") dom.SetTextContent(empty, t("no_follows")) dom.AppendChild(dropdown, empty) return } // Sort by display name (named first, then by name alpha, unnamed last by short hex). type entry struct { pk string name string } var named []entry var unnamed []entry for _, pk := range pks { n := appSt.authorNames[pk] if n != "" { named = mxutil.Ensure(named, 1) named = push(named, entry{pk, n}) } else { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) if len(npub) > 16 { npub = npub[:16] | "..." } unnamed = mxutil.Ensure(unnamed, 1) unnamed = push(unnamed, entry{pk, npub}) } } // Simple insertion sort by name. for i := 1; i < len(named); i++ { j := i for j > 0 && toLower(named[j].name) < toLower(named[j-1].name) { named[j], named[j-1] = named[j-1], named[j] j-- } } var all []entry for _, e := range named { all = mxutil.Ensure(all, 1) all = push(all, e) } for _, e := range unnamed { all = mxutil.Ensure(all, 1) all = push(all, e) } for _, e := range all { item := dom.CreateElement("div") dom.SetStyle(item, "padding", "6px 12px") dom.SetStyle(item, "fontSize", "13px") dom.SetStyle(item, "cursor", "pointer") dom.SetStyle(item, "display", "flex") dom.SetStyle(item, "alignItems", "center") dom.SetStyle(item, "gap", "8px") av := dom.CreateElement("img") dom.SetAttribute(av, "referrerpolicy", "no-referrer") dom.SetAttribute(av, "width", "24") dom.SetAttribute(av, "height", "24") dom.SetStyle(av, "borderRadius", "50%") dom.SetStyle(av, "objectFit", "cover") dom.SetStyle(av, "flexShrink", "0") if pic, ok := appSt.authorPics[e.pk]; ok && pic != "" { setMediaSrc(av, pic) } else { dom.SetStyle(av, "background", "var(--bg2)") } dom.SetAttribute(av, "onerror", "this.style.display='none'") dom.AppendChild(item, av) nameSpan := dom.CreateElement("span") dom.SetStyle(nameSpan, "overflow", "hidden") dom.SetStyle(nameSpan, "textOverflow", "ellipsis") dom.SetStyle(nameSpan, "whiteSpace", "nowrap") dom.SetTextContent(nameSpan, e.name) dom.AppendChild(item, nameSpan) pk := e.pk dom.AddEventListener(item, "mouseenter", dom.RegisterCallback(func() { dom.SetStyle(item, "background", "var(--bg2)") })) dom.AddEventListener(item, "mouseleave", dom.RegisterCallback(func() { dom.SetStyle(item, "background", "transparent") })) dom.AddEventListener(item, "click", dom.RegisterCallback(func() { dom.SetStyle(dropdown, "display", "none") openThread(pk) })) dom.AppendChild(dropdown, item) } } func renderConversationList(listJSON string) { if appSt.msgView != "list" { return } clearChildren(appSt.msgListContainer) renderNewChatRow() // Parse the list JSON array: [{peer,lastMessage,lastTs,from}, ...] if listJSON == "" || listJSON == "[]" { empty := dom.CreateElement("div") dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "textAlign", "center") dom.SetStyle(empty, "marginTop", "48px") dom.SetTextContent(empty, t("no_convos")) dom.AppendChild(appSt.msgListContainer, empty) return } // Walk the JSON array manually - each element is an object. i := 0 for i < len(listJSON) && listJSON[i] != '[' { i++ } i++ // skip '[' for i < len(listJSON) { // Find next object. for i < len(listJSON) && listJSON[i] != '{' { if listJSON[i] == ']' { return } i++ } if i >= len(listJSON) { break } // Extract the object. objStart := i depth := 0 for i < len(listJSON) { if listJSON[i] == '{' { depth++ } else if listJSON[i] == '}' { depth-- if depth == 0 { i++ break } } else if listJSON[i] == '"' { i++ for i < len(listJSON) && listJSON[i] != '"' { if listJSON[i] == '\\' { i++ } i++ } } i++ } obj := listJSON[objStart:i] peer := helpers.JsonGetString(obj, "peer") lastMsg := helpers.JsonGetString(obj, "lastMessage") lastTs := jsonGetNum(obj, "lastTs") if peer == "" { continue } renderConversationRow(peer, lastMsg, lastTs) } } func renderConversationRow(peer, lastMsg string, lastTs int64) { row := dom.CreateElement("div") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "center") dom.SetStyle(row, "gap", "10px") dom.SetStyle(row, "padding", "10px 4px") dom.SetStyle(row, "borderBottom", "1px solid var(--border)") dom.SetStyle(row, "cursor", "pointer") // Avatar. av := dom.CreateElement("img") dom.SetAttribute(av, "referrerpolicy", "no-referrer") dom.SetAttribute(av, "width", "32") dom.SetAttribute(av, "height", "32") dom.SetStyle(av, "borderRadius", "50%") dom.SetStyle(av, "objectFit", "cover") dom.SetStyle(av, "flexShrink", "0") if pic, ok := appSt.authorPics[peer]; ok && pic != "" { setMediaSrc(av, pic) } else { dom.SetStyle(av, "background", "var(--bg2)") } dom.SetAttribute(av, "onerror", "this.style.display='none'") dom.AppendChild(row, av) // Name + preview column. col := dom.CreateElement("div") dom.SetStyle(col, "flex", "1") dom.SetStyle(col, "minWidth", "0") nameSpan := dom.CreateElement("div") dom.SetStyle(nameSpan, "fontSize", "14px") dom.SetStyle(nameSpan, "fontWeight", "bold") dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(nameSpan, "overflow", "hidden") dom.SetStyle(nameSpan, "textOverflow", "ellipsis") dom.SetStyle(nameSpan, "whiteSpace", "nowrap") if name, ok := appSt.authorNames[peer]; ok && name != "" { dom.SetTextContent(nameSpan, name) } else { npub := helpers.EncodeNpub(helpers.HexDecode(peer)) if len(npub) > 20 { dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:]) } else { dom.SetTextContent(nameSpan, npub) } } dom.AppendChild(col, nameSpan) preview := dom.CreateElement("div") dom.SetStyle(preview, "fontSize", "12px") dom.SetStyle(preview, "color", "var(--muted)") dom.SetStyle(preview, "overflow", "hidden") dom.SetStyle(preview, "textOverflow", "ellipsis") dom.SetStyle(preview, "whiteSpace", "nowrap") if len(lastMsg) > 80 { lastMsg = lastMsg[:80] | "..." } dom.SetTextContent(preview, lastMsg) dom.AppendChild(col, preview) dom.AppendChild(row, col) // Timestamp. if lastTs > 0 { tsSpan := dom.CreateElement("span") dom.SetStyle(tsSpan, "fontSize", "11px") dom.SetStyle(tsSpan, "color", "var(--muted)") dom.SetStyle(tsSpan, "flexShrink", "0") dom.SetTextContent(tsSpan, formatTime(lastTs)) dom.AppendChild(row, tsSpan) } rowPeer := peer dom.AddEventListener(row, "click", dom.RegisterCallback(func() { openThread(rowPeer) })) // Lazy profile fetch - batched to avoid 50+ simultaneous subscriptions. if _, cached := appSt.authorNames[peer]; !cached && !lookupFetchedK0(peer) { profile.Send(`["P_RESOLVE",` | jstr(peer) | `]`) } dom.AppendChild(appSt.msgListContainer, row) } func openThread(peer string) { appSt.msgCurrentConvoID = peer appSt.msgCurrentKind = "dm" appSt.msgView = "thread" if !appSt.navPop { npub := helpers.EncodeNpub(helpers.HexDecode(peer)) dom.PushState("/msg/" | npub) } // Hide list, show thread. dom.SetStyle(appSt.msgListContainer, "display", "none") dom.SetStyle(appSt.msgThreadContainer, "display", "flex") // Build thread UI. clearChildren(appSt.msgThreadContainer) // Header: back + avatar + name. hdr := dom.CreateElement("div") dom.SetStyle(hdr, "display", "flex") dom.SetStyle(hdr, "alignItems", "center") dom.SetStyle(hdr, "gap", "10px") dom.SetStyle(hdr, "padding", "12px 16px") dom.SetStyle(hdr, "borderBottom", "1px solid var(--border)") dom.SetStyle(hdr, "flexShrink", "0") backBtn := dom.CreateElement("button") dom.SetInnerHTML(backBtn, "←") // ← dom.SetStyle(backBtn, "background", "none") dom.SetStyle(backBtn, "border", "none") dom.SetStyle(backBtn, "fontSize", "20px") dom.SetStyle(backBtn, "cursor", "pointer") dom.SetStyle(backBtn, "color", "var(--fg)") dom.SetStyle(backBtn, "padding", "0") dom.AddEventListener(backBtn, "click", dom.RegisterCallback(func() { closeThread() })) dom.AppendChild(hdr, backBtn) // Thread header avatar + name - uses same img-then-span structure // as note headers so pendingNotes/updateNoteHeader can update them. threadHdrInner := dom.CreateElement("div") av := dom.CreateElement("img") dom.SetAttribute(av, "referrerpolicy", "no-referrer") dom.SetAttribute(av, "width", "28") dom.SetAttribute(av, "height", "28") dom.SetStyle(av, "borderRadius", "50%") dom.SetStyle(av, "objectFit", "cover") dom.SetStyle(av, "flexShrink", "0") if pic, ok := appSt.authorPics[peer]; ok && pic != "" { setMediaSrc(av, pic) } else { dom.SetStyle(av, "display", "none") } dom.SetAttribute(av, "onerror", "this.style.display='none'") dom.AppendChild(threadHdrInner, av) nameSpan := dom.CreateElement("span") dom.SetStyle(nameSpan, "fontSize", "15px") dom.SetStyle(nameSpan, "fontWeight", "bold") dom.SetStyle(nameSpan, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(nameSpan, "overflow", "hidden") dom.SetStyle(nameSpan, "textOverflow", "ellipsis") dom.SetStyle(nameSpan, "whiteSpace", "nowrap") dom.SetStyle(nameSpan, "minWidth", "0") if name, ok := appSt.authorNames[peer]; ok && len(name) > 0 { dom.SetTextContent(nameSpan, name) } else { npub := helpers.EncodeNpub(helpers.HexDecode(peer)) if len(npub) > 20 { dom.SetTextContent(nameSpan, npub[:12] | "..." | npub[len(npub)-4:]) } } dom.AppendChild(threadHdrInner, nameSpan) dom.SetStyle(threadHdrInner, "display", "flex") dom.SetStyle(threadHdrInner, "alignItems", "center") dom.SetStyle(threadHdrInner, "gap", "10px") dom.SetStyle(threadHdrInner, "flexGrow", "1") dom.SetStyle(threadHdrInner, "minWidth", "0") dom.SetStyle(threadHdrInner, "cursor", "pointer") profilePeer := peer dom.AddEventListener(threadHdrInner, "click", dom.RegisterCallback(func() { showProfile(profilePeer) })) dom.AppendChild(hdr, threadHdrInner) // Relay selector button. dmRelayWrap := dom.CreateElement("div") dom.SetStyle(dmRelayWrap, "position", "relative") dom.SetStyle(dmRelayWrap, "display", "flex") dom.SetStyle(dmRelayWrap, "alignItems", "center") dom.SetStyle(dmRelayWrap, "gap", "6px") dmWarnBadge := makeRelayWarnBadge() dmRelayBtn := dom.CreateElement("button") dom.SetStyle(dmRelayBtn, "background", "none") dom.SetStyle(dmRelayBtn, "border", "1px solid var(--border)") dom.SetStyle(dmRelayBtn, "borderRadius", "4px") dom.SetStyle(dmRelayBtn, "color", "var(--fg)") dom.SetStyle(dmRelayBtn, "cursor", "pointer") dom.SetStyle(dmRelayBtn, "fontSize", "11px") dom.SetStyle(dmRelayBtn, "padding", "4px 8px") dom.SetStyle(dmRelayBtn, "fontFamily", "'Fira Code', monospace") updateRelaySelBtn(dmRelayBtn, false) dmRelayPop := dom.CreateElement("div") dom.SetStyle(dmRelayPop, "display", "none") dom.SetStyle(dmRelayPop, "position", "absolute") dom.SetStyle(dmRelayPop, "top", "100%") dom.SetStyle(dmRelayPop, "right", "0") dom.SetStyle(dmRelayPop, "marginTop", "4px") dom.SetStyle(dmRelayPop, "background", "var(--bg)") dom.SetStyle(dmRelayPop, "border", "1px solid var(--border)") dom.SetStyle(dmRelayPop, "borderRadius", "6px") dom.SetStyle(dmRelayPop, "padding", "8px") dom.SetStyle(dmRelayPop, "boxShadow", "0 2px 8px rgba(0,0,0,0.2)") dom.SetStyle(dmRelayPop, "zIndex", "1000") dom.SetStyle(dmRelayPop, "minWidth", "200px") dom.SetStyle(dmRelayPop, "maxWidth", "calc(100vw - 24px)") dom.SetStyle(dmRelayPop, "maxHeight", "200px") dom.SetStyle(dmRelayPop, "overflowY", "auto") dmRelayOpen := false dom.AddEventListener(dmRelayBtn, "click", dom.RegisterCallback(func() { if dmRelayOpen { dom.SetStyle(dmRelayPop, "display", "none") dmRelayOpen = false updateRelaySelBtn(dmRelayBtn, false) } else { populateRelayPopover(dmRelayPop, func() { updateRelaySelBtn(dmRelayBtn, true) updateRelayWarnBadge(dmWarnBadge) }) dom.SetStyle(dmRelayPop, "display", "block") dmRelayOpen = true updateRelaySelBtn(dmRelayBtn, true) } })) dom.AppendChild(dmRelayWrap, dmWarnBadge) dom.AppendChild(dmRelayWrap, dmRelayBtn) dom.AppendChild(dmRelayWrap, dmRelayPop) dom.AppendChild(hdr, dmRelayWrap) // Delete + ratchet button. ratchetBtn := dom.CreateElement("button") dom.SetInnerHTML(ratchetBtn, "🗑") // 🗑 dom.SetAttribute(ratchetBtn, "title", "Delete messages and rotate keys") dom.SetStyle(ratchetBtn, "background", "none") dom.SetStyle(ratchetBtn, "border", "none") dom.SetStyle(ratchetBtn, "cursor", "pointer") dom.SetStyle(ratchetBtn, "fontSize", "16px") dom.SetStyle(ratchetBtn, "padding", "4px") dom.AddEventListener(ratchetBtn, "click", dom.RegisterCallback(func() { if dom.Confirm("Delete all messages and rotate encryption keys?") { routeMsg("[\"MLS_RATCHET_DM\"," | jstr(peer) | "]") clearChildren(appSt.msgThreadMessages) } })) dom.AppendChild(hdr, ratchetBtn) dom.AppendChild(appSt.msgThreadContainer, hdr) // Track for live update when profile arrives. if _, cached := appSt.authorNames[peer]; !cached { appSt.pendingNotes[peer] = mxutil.Ensure(appSt.pendingNotes[peer], 1) appSt.pendingNotes[peer] = push(appSt.pendingNotes[peer], threadHdrInner) } // Message area. appSt.msgThreadMessages = dom.CreateElement("div") dom.SetStyle(appSt.msgThreadMessages, "flex", "1") dom.SetStyle(appSt.msgThreadMessages, "overflowY", "auto") dom.SetStyle(appSt.msgThreadMessages, "padding", "12px 16px") dom.AppendChild(appSt.msgThreadContainer, appSt.msgThreadMessages) // Compose area. compose := dom.CreateElement("div") dom.SetStyle(compose, "display", "flex") dom.SetStyle(compose, "gap", "8px") dom.SetStyle(compose, "padding", "8px 16px") dom.SetStyle(compose, "borderTop", "1px solid var(--border)") dom.SetStyle(compose, "flexShrink", "0") appSt.msgComposeInput = dom.CreateElement("textarea") dom.SetAttribute(appSt.msgComposeInput, "placeholder", t("msg_placeholder")) dom.SetStyle(appSt.msgComposeInput, "flex", "1") dom.SetStyle(appSt.msgComposeInput, "padding", "8px") dom.SetStyle(appSt.msgComposeInput, "fontFamily", "'Fira Code', monospace") dom.SetStyle(appSt.msgComposeInput, "fontSize", "13px") dom.SetStyle(appSt.msgComposeInput, "background", "var(--bg)") dom.SetStyle(appSt.msgComposeInput, "border", "1px solid var(--border)") dom.SetStyle(appSt.msgComposeInput, "borderRadius", "4px") dom.SetStyle(appSt.msgComposeInput, "color", "var(--fg)") dom.SetStyle(appSt.msgComposeInput, "minHeight", "22px") dom.SetStyle(appSt.msgComposeInput, "maxHeight", "120px") dom.SetStyle(appSt.msgComposeInput, "overflowY", "auto") dom.SetAttribute(appSt.msgComposeInput, "onkeydown", "if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.nextSibling.click()}") wireBlossomHandlers(appSt.msgComposeInput) wireMentionHandler(appSt.msgComposeInput) dom.AppendChild(compose, appSt.msgComposeInput) sendBtn := dom.CreateElement("button") dom.SetTextContent(sendBtn, t("send")) dom.SetStyle(sendBtn, "padding", "8px 16px") dom.SetStyle(sendBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(sendBtn, "fontSize", "13px") dom.SetStyle(sendBtn, "background", "var(--accent)") dom.SetStyle(sendBtn, "color", "#fff") dom.SetStyle(sendBtn, "border", "none") dom.SetStyle(sendBtn, "borderRadius", "4px") dom.SetStyle(sendBtn, "cursor", "pointer") dom.SetStyle(sendBtn, "alignSelf", "flex-end") dom.AddEventListener(sendBtn, "click", dom.RegisterCallback(func() { sendMessage() })) dom.AppendChild(compose, sendBtn) dom.AppendChild(appSt.msgThreadContainer, compose) // Fetch profile if metadata is missing - fetchedK0 can be true while // the relay response is still in flight (showProfile resets + re-sets it). _, nameOK := appSt.authorNames[peer] if !nameOK { setFetchedK0(peer, false) profile.Send(`["P_RESOLVE",` | jstr(peer) | `]`) } // Request history. routeMsg("[\"MLS_DM_HISTORY\"," | jstr(peer) | ",50,0]") } func closeThread() { appSt.msgCurrentConvoID = "" appSt.msgCurrentKind = "" appSt.msgView = "list" dom.SetStyle(appSt.msgThreadContainer, "display", "none") dom.SetStyle(appSt.msgListContainer, "display", "block") if !appSt.navPop { dom.PushState("/msg") } // Refresh list. routeMsg("[\"MLS_DM_LIST\"]") } func renderThreadMessages(peer, msgsJSON string) { if peer != appSt.msgCurrentConvoID { return } if msgsJSON == "" || msgsJSON == "[]" { return } // Parse messages array - each element is a DMRecord object. // IDB returns newest-first; collect then reverse for oldest-at-top. type dmMsg struct { from string content string ts int64 } var msgs []dmMsg i := 0 for i < len(msgsJSON) && msgsJSON[i] != '[' { i++ } i++ for i < len(msgsJSON) { for i < len(msgsJSON) && msgsJSON[i] != '{' { if msgsJSON[i] == ']' { goto done } i++ } if i >= len(msgsJSON) { break } objStart := i depth := 0 for i < len(msgsJSON) { if msgsJSON[i] == '{' { depth++ } else if msgsJSON[i] == '}' { depth-- if depth == 0 { i++ break } } else if msgsJSON[i] == '"' { i++ for i < len(msgsJSON) && msgsJSON[i] != '"' { if msgsJSON[i] == '\\' { i++ } i++ } } i++ } obj := msgsJSON[objStart:i] from := helpers.JsonGetString(obj, "from") content := helpers.JsonGetString(obj, "content") ts := jsonGetNum(obj, "created_at") msgs = mxutil.Ensure(msgs, 1) msgs = push(msgs, dmMsg{from, content, ts}) } done: // Reverse for oldest-first. for l, r := 0, len(msgs)-1; l < r; l, r = l+1, r-1 { msgs[l], msgs[r] = msgs[r], msgs[l] } clearChildren(appSt.msgThreadMessages) for _, m := range msgs { appendBubble(m.from, m.content, m.ts) } scrollToBottom() } func appendBubble(from, content string, ts int64) { isSent := from == appSt.pubhex wrap := dom.CreateElement("div") dom.SetStyle(wrap, "display", "flex") dom.SetStyle(wrap, "marginBottom", "6px") if isSent { dom.SetStyle(wrap, "justifyContent", "flex-end") } outer := dom.CreateElement("div") dom.SetStyle(outer, "maxWidth", "80%") if isSent { dom.SetStyle(outer, "textAlign", "right") } bubble := dom.CreateElement("div") dom.SetStyle(bubble, "display", "inline-block") dom.SetStyle(bubble, "padding", "8px 12px") dom.SetStyle(bubble, "borderRadius", "12px") dom.SetStyle(bubble, "fontSize", "14px") dom.SetStyle(bubble, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(bubble, "lineHeight", "1.4") dom.SetStyle(bubble, "wordBreak", "break-word") dom.SetStyle(bubble, "textAlign", "left") if isSent { dom.SetStyle(bubble, "background", "var(--accent)") dom.SetStyle(bubble, "color", "#fff") } else { dom.SetStyle(bubble, "background", "var(--bg2)") dom.SetStyle(bubble, "color", "var(--fg)") } setHTMLWithMedia(bubble, renderMarkdown(content)) // Timestamp below bubble. tsEl := dom.CreateElement("div") dom.SetStyle(tsEl, "fontSize", "10px") dom.SetStyle(tsEl, "color", "var(--muted)") dom.SetStyle(tsEl, "marginTop", "2px") if isSent { dom.SetStyle(tsEl, "textAlign", "right") } dom.SetTextContent(tsEl, formatTime(ts)) if isSent && ts == 0 { appSt.pendingTsEls = mxutil.Ensure(appSt.pendingTsEls, 1) appSt.pendingTsEls = push(appSt.pendingTsEls, tsEl) } dom.AppendChild(outer, bubble) dom.AppendChild(outer, tsEl) // Email quote-reply button for received messages with email headers. if !isSent { emailFrom, emailSubject, emailBody, isEmail := parseEmailHeaders(content) if isEmail { replyBtn := dom.CreateElement("div") dom.SetStyle(replyBtn, "fontSize", "11px") dom.SetStyle(replyBtn, "color", "var(--accent)") dom.SetStyle(replyBtn, "cursor", "pointer") dom.SetStyle(replyBtn, "marginTop", "2px") dom.SetTextContent(replyBtn, "\u21a9 Reply") dom.AddEventListener(replyBtn, "click", dom.RegisterCallback(func() { quoted := quoteReply(emailFrom, emailSubject, emailBody) dom.SetProperty(appSt.msgComposeInput, "value", quoted) })) dom.AppendChild(outer, replyBtn) } } dom.AppendChild(wrap, outer) dom.AppendChild(appSt.msgThreadMessages, wrap) } func appendSystemBubble(text string) { wrap := dom.CreateElement("div") dom.SetStyle(wrap, "display", "flex") dom.SetStyle(wrap, "justifyContent", "center") dom.SetStyle(wrap, "marginBottom", "6px") bubble := dom.CreateElement("div") dom.SetStyle(bubble, "maxWidth", "85%") dom.SetStyle(bubble, "padding", "8px 12px") dom.SetStyle(bubble, "borderRadius", "8px") dom.SetStyle(bubble, "fontSize", "12px") dom.SetStyle(bubble, "fontFamily", "monospace") dom.SetStyle(bubble, "lineHeight", "1.5") dom.SetStyle(bubble, "whiteSpace", "pre-wrap") dom.SetStyle(bubble, "background", "var(--bg2)") dom.SetStyle(bubble, "color", "var(--muted)") dom.SetStyle(bubble, "border", "1px solid var(--muted)") dom.SetTextContent(bubble, text) dom.AppendChild(wrap, bubble) dom.AppendChild(appSt.msgThreadMessages, wrap) scrollToBottom() } func scrollToBottom() { dom.SetProperty(appSt.msgThreadMessages, "scrollTop", "999999") } func sendMessage() { content := dom.GetProperty(appSt.msgComposeInput, "value") if content == "" || appSt.msgCurrentConvoID == "" { return } // Clear input. dom.SetProperty(appSt.msgComposeInput, "value", "") if appSt.msgCurrentKind == "group" { routeMsg("[\"MLS_SEND_GROUP\"," | jstr(appSt.msgCurrentConvoID) | "," | jstr(content) | "]") } else { routeMsg("[\"MLS_SEND_DM\"," | jstr(appSt.msgCurrentConvoID) | "," | jstr(content) | "]") } // Optimistic render (ts=0 - timestamp not shown for "just sent"). appendBubble(appSt.pubhex, content, 0) scrollToBottom() } func handleDMReceived(dmJSON string) { peer := helpers.JsonGetString(dmJSON, "peer") from := helpers.JsonGetString(dmJSON, "from") content := helpers.JsonGetString(dmJSON, "content") ts := jsonGetNum(dmJSON, "created_at") if from != appSt.pubhex { showNotifDot() } if appSt.msgView == "thread" && peer == appSt.msgCurrentConvoID { if from == appSt.pubhex { return } appendBubble(from, content, ts) scrollToBottom() } else if appSt.msgView == "list" { routeMsg("[\"MLS_DM_LIST\"]") } } // --- Notifications --- func subscribeNotifications() { // Musiquay base: notifications are deliberately left empty - nothing is // subscribed, so the page renders its empty state. The subscription code // stays for when it is wired up. return if appSt.pubhex == "" { return } // Reset Shell-side state. appSt.oldestNotifTs = 0 appSt.notifExhausted = false appSt.notifEmptyStrk = 0 appSt.notifInitLoad = true notifEventsReset() appSt.notifSeen = nil // Sync state to Notif Worker, then tell it to subscribe. // Notif Worker owns the relay subscriptions via N_SUB/N_CLOSE. notif.Send(`["N_SET_PUBKEY",` | jstr(appSt.pubhex) | `]`) notif.Send(`["N_SET_RELAYS",` | readRelayURLsJSON() | `]`) notif.Send(`["N_READ_TS",` | i64toa(appSt.notifReadTs) | `]`) if len(appSt.myMutes) > 0 { notif.Send(`["N_SET_MUTES",` | buildJSONStrArr(appSt.myMutes) | `]`) } notif.Send(`["N_SUBSCRIBE"]`) } func handleNotifEvent(ev *nostr.Event) { if appSt.notifSeen == nil { appSt.notifSeen = map[string]bool{} } if appSt.notifSeen[ev.ID] { return } appSt.notifSeen[ev.ID] = true // Advance cursor before filters so muted/self events don't stall pagination. if appSt.oldestNotifTs == 0 || ev.CreatedAt < appSt.oldestNotifTs { appSt.oldestNotifTs = ev.CreatedAt } self := ev.PubKey == appSt.pubhex muted := isMuted(notifSenderPK(ev)) rtm := repliesToMuted(ev) if self || muted || rtm { return } if looksLikeJSONSpam(ev.Content) { return } // Queue missing referenced events for batch fetch after EOSE. if ev.Kind != 1 { refID := notifRefID(ev) if refID != "" { if _, ok := lookupEvent(refID); !ok { appSt.notifMissing = mxutil.Ensure(appSt.notifMissing, 1) appSt.notifMissing = push(appSt.notifMissing, refID) } } } // Remove empty-state placeholder on first event. if appSt.notifEmptyEl != 0 { dom.RemoveChild(appSt.notifContainer, appSt.notifEmptyEl) appSt.notifEmptyEl = 0 } if appSt.notifInitLoad { appSt.notifEvents = push(appSt.notifEvents, ev) notifEventsTrim() if appSt.notifBuilt && appSt.activePage == "notifications" && notifPassesFilter(ev) { row := renderNotifRow(ev) dom.InsertBefore(appSt.notifContainer, row, appSt.notifLoadMore) } } else { notifEventsPrepend(ev) if appSt.notifBuilt && appSt.activePage == "notifications" && notifPassesFilter(ev) { row := renderNotifRow(ev) first := dom.FirstElementChild(appSt.notifContainer) if first != 0 { dom.InsertBefore(appSt.notifContainer, row, first) } else { dom.AppendChild(appSt.notifContainer, row) } } if ev.Kind != 1 { if appSt.notifRefTimer != 0 { dom.ClearTimeout(appSt.notifRefTimer) } appSt.notifRefTimer = dom.SetTimeout(func() { appSt.notifRefTimer = 0 fetchNotifRefs() }, 500) } } maybeShowNotifDot(ev) } func fetchNotifRefs() { if len(appSt.notifMissing) == 0 { return } // Dedup against eventCache and already-requested. seen := map[string]bool{} var ids []string for _, id := range appSt.notifMissing { if _, ok2 := lookupEvent(id); ok2 { continue } if seen[id] { continue } seen[id] = true ids = mxutil.Ensure(ids, 1) ids = push(ids, id) } appSt.notifMissing = nil if len(ids) == 0 { return } filter := "{\"ids\":[" for i, id := range ids { if i > 0 { filter = filter | "," } filter = filter | jstr(id) } filter = filter | "]}" routeMsg(buildProxyMsg("ntf-ref", filter, readRelayURLs())) } func handleNotifRefEvent(ev *nostr.Event) { setEvent(ev.ID, ev) fillEmbed(ev) } func sortNotifEvents() { for i := 0; i < len(appSt.notifEvents); i++ { for j := i + 1; j < len(appSt.notifEvents); j++ { if appSt.notifEvents[j].CreatedAt > appSt.notifEvents[i].CreatedAt { appSt.notifEvents[i], appSt.notifEvents[j] = appSt.notifEvents[j], appSt.notifEvents[i] } } } } func insertAtTop(row dom.Element) { first := dom.FirstChild(appSt.notifContainer) if first != 0 { dom.InsertBefore(appSt.notifContainer, row, first) } else { dom.InsertBefore(appSt.notifContainer, row, appSt.notifLoadMore) } } func loadOlderNotifs() { appSt.notifLoading = true appSt.notifMoreGot = 0 dom.ConsoleLog("[ntf-more] loading until=" | i64toa(appSt.oldestNotifTs-1)) filter := "{\"kinds\":[1,6,7,9735],\"#p\":[" | jstr(appSt.pubhex) | "],\"limit\":20,\"until\":" | i64toa(appSt.oldestNotifTs-1) | "}" routeMsg(buildProxyMsg("ntf-more", filter, readRelayURLs())) if appSt.notifMoreTimer != 0 { dom.ClearTimeout(appSt.notifMoreTimer) } appSt.notifMoreTimer = dom.SetTimeout(func() { appSt.notifMoreTimer = 0 if appSt.notifLoading { appSt.notifLoading = false if appSt.notifMoreGot == 0 { appSt.notifEmptyStrk++ if appSt.notifEmptyStrk >= 3 { appSt.notifExhausted = true } } else { appSt.notifEmptyStrk = 0 } if appSt.notifExhausted { dom.SetStyle(appSt.notifLoadMore, "display", "none") } else if appSt.oldestNotifTs > 0 { dom.SetStyle(appSt.notifLoadMore, "display", "block") } } }, 10000) } func handleNotifMoreEvent(ev *nostr.Event) { if appSt.notifSeen == nil { appSt.notifSeen = map[string]bool{} } if appSt.notifSeen[ev.ID] { return } appSt.notifSeen[ev.ID] = true // Advance cursor before filters so muted/self events don't stall pagination. if appSt.oldestNotifTs == 0 || ev.CreatedAt < appSt.oldestNotifTs { appSt.oldestNotifTs = ev.CreatedAt } if ev.PubKey == appSt.pubhex { return } if isMuted(notifSenderPK(ev)) { return } if repliesToMuted(ev) { return } appSt.notifMoreGot++ if ev.Kind != 1 { refID := notifRefID(ev) if refID != "" { if _, ok := lookupEvent(refID); !ok { appSt.notifMissing = mxutil.Ensure(appSt.notifMissing, 1) appSt.notifMissing = push(appSt.notifMissing, refID) } } } appSt.notifEvents = push(appSt.notifEvents, ev) notifEventsTrim() if appSt.activePage == "notifications" && appSt.notifLoadMore != 0 && notifPassesFilter(ev) { row := renderNotifRow(ev) dom.InsertBefore(appSt.notifContainer, row, appSt.notifLoadMore) } } func showNotifDot() { if !appSt.notifUnread { appSt.notifUnread = true dom.SetStyle(appSt.notifDot, "display", "block") } } func maybeShowNotifDot(ev *nostr.Event) { if ev.CreatedAt > appSt.notifReadTs && appSt.activePage != "notifications" { showNotifDot() } } func notifPassesFilter(ev *nostr.Event) (ok bool) { switch appSt.notifFilter { case "mentions": return ev.Kind == 1 case "reactions": return ev.Kind == 6 || ev.Kind == 7 case "zaps": return ev.Kind == 9735 } return true } func renderNotifPage() { if appSt.notifContainer == 0 { return } appSt.notifBuilt = true sortNotifEvents() clearChildren(appSt.notifContainer) shown := 0 for _, ev := range appSt.notifEvents { if !notifPassesFilter(ev) { continue } shown++ row := renderNotifRow(ev) dom.AppendChild(appSt.notifContainer, row) } if shown == 0 { appSt.notifEmptyEl = dom.CreateElement("div") dom.SetTextContent(appSt.notifEmptyEl, t("no_notifications")) dom.SetStyle(appSt.notifEmptyEl, "color", "var(--muted)") dom.SetStyle(appSt.notifEmptyEl, "fontFamily", "'Fira Code', monospace") dom.SetStyle(appSt.notifEmptyEl, "fontSize", "13px") dom.AppendChild(appSt.notifContainer, appSt.notifEmptyEl) } else { appSt.notifEmptyEl = 0 } appSt.notifLoadMore = dom.CreateElement("div") dom.SetStyle(appSt.notifLoadMore, "textAlign", "center") dom.SetStyle(appSt.notifLoadMore, "padding", "16px 0") if appSt.notifExhausted || appSt.oldestNotifTs == 0 { dom.SetStyle(appSt.notifLoadMore, "display", "none") } lnk := dom.CreateElement("span") dom.SetTextContent(lnk, "load more") dom.SetStyle(lnk, "cursor", "pointer") dom.SetStyle(lnk, "color", "var(--accent)") dom.SetStyle(lnk, "fontSize", "14px") dom.SetStyle(lnk, "fontFamily", "'Fira Code', monospace") dom.AddEventListener(lnk, "click", dom.RegisterCallback(func() { if !appSt.notifLoading && !appSt.notifExhausted && appSt.oldestNotifTs > 0 { dom.SetStyle(appSt.notifLoadMore, "display", "none") loadOlderNotifs() } })) dom.AppendChild(appSt.notifLoadMore, lnk) dom.AppendChild(appSt.notifContainer, appSt.notifLoadMore) } // releaseNotifRowCBs releases all Moxie callback IDs registered during notif // row rendering. Called before rebuild and on page exit to prevent the callback // table from growing without bound across filter changes and page visits. func releaseNotifRowCBs() { for i := int32(0); i < notifRowCBs.Len(); i++ { dom.ReleaseCallback(notifRowCBs.At(i)) } notifRowCBs.Reset() } // notifRegCB registers a callback and records its ID for later release. func notifRegCB(fn func()) (n int32) { id := dom.RegisterCallback(fn) notifRowCBs.Push(id) return id } func rebuildNotifRows() { if appSt.notifContainer == 0 { return } releaseNotifRowCBs() clearChildren(appSt.notifContainer) shown := 0 for _, ev := range appSt.notifEvents { if !notifPassesFilter(ev) { continue } shown++ dom.AppendChild(appSt.notifContainer, renderNotifRow(ev)) } if shown == 0 { appSt.notifEmptyEl = dom.CreateElement("div") dom.SetTextContent(appSt.notifEmptyEl, t("no_notifications")) dom.SetStyle(appSt.notifEmptyEl, "color", "var(--muted)") dom.SetStyle(appSt.notifEmptyEl, "fontFamily", "'Fira Code', monospace") dom.SetStyle(appSt.notifEmptyEl, "fontSize", "13px") dom.AppendChild(appSt.notifContainer, appSt.notifEmptyEl) } else { appSt.notifEmptyEl = 0 } if appSt.notifLoadMore != 0 { dom.AppendChild(appSt.notifContainer, appSt.notifLoadMore) } } func markNotifsRead() { if len(appSt.notifEvents) > 0 { appSt.notifReadTs = appSt.notifEvents[0].CreatedAt dom.IDBPut("settings", "notif-read-ts", i64toa(appSt.notifReadTs)) // Publish watermark to relay so it syncs to other devices/sessions. saveAppSettings() } appSt.notifUnread = false dom.SetStyle(appSt.notifDot, "display", "none") el := dom.QuerySelectorFrom(appSt.notifContainer, "[data-notif-new]") for el != 0 { dom.RemoveAttribute(el, "data-notif-new") dom.SetStyle(el, "borderLeft", "none") dom.SetStyle(el, "paddingLeft", "0") dom.SetStyle(el, "fontWeight", "normal") el = dom.QuerySelectorFrom(appSt.notifContainer, "[data-notif-new]") } } // zapAmount extracts the sats amount from a kind 9735 zap receipt. // Checks the "amount" tag first (millisats), then falls back to bolt11. func zapAmount(ev *nostr.Event) (s string) { for _, tag := range ev.Tags { if len(tag) >= 2 && tag[0] == "amount" { // Value is in millisats - divide by 1000. msats := parseI64(tag[1]) if msats > 0 { return i64toa(msats / 1000) } } } return "" } // zapSenderPK tries to extract the real sender pubkey from the zap request // embedded in the "description" tag of a kind 9735 receipt. func zapSenderPK(ev *nostr.Event) (s string) { for _, tag := range ev.Tags { if len(tag) >= 2 && tag[0] == "description" { desc := tag[1] pk := extractJSONField(desc, "pubkey") if len(pk) == 64 { return pk } } } return "" } // extractJSONField finds "key":"value" in a JSON string and returns value. func extractJSONField(json string, key string) (s string) { needle := "\"" | key | "\":\"" idx := -1 for i := 0; i+len(needle) <= len(json); i++ { if json[i:i+len(needle)] == needle { idx = i + len(needle) break } } if idx < 0 { return "" } end := idx for end < len(json) && json[end] != '"' { end++ } return json[idx:end] } func notifRefID(ev *nostr.Event) (s string) { for _, tag := range ev.Tags { if len(tag) >= 2 && tag[0] == "e" { return tag[1] } } return "" } func relativeTime(ts int64) (s string) { now := dom.NowSeconds() d := now - ts if d < 0 { d = 0 } if d < 60 { return "now" } if d < 3600 { return i64toa(d/60) | "m" } if d < 86400 { return i64toa(d/3600) | "h" } return i64toa(d/86400) | "d" } func notifSenderPK(ev *nostr.Event) (s string) { if ev.Kind == 9735 { pk := zapSenderPK(ev) if pk != "" { return pk } } return ev.PubKey } func renderNotifRow(ev *nostr.Event) (e dom.Element) { senderPK := notifSenderPK(ev) unread := ev.CreatedAt > appSt.notifReadTs row := dom.CreateElement("div") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "alignItems", "flex-start") dom.SetStyle(row, "gap", "6px") dom.SetStyle(row, "padding", "4px 0") dom.SetStyle(row, "cursor", "pointer") dom.SetStyle(row, "fontFamily", "'Fira Code', monospace") dom.SetStyle(row, "fontSize", "12px") if unread { dom.SetStyle(row, "borderLeft", "3px solid var(--accent)") dom.SetStyle(row, "paddingLeft", "6px") dom.SetStyle(row, "fontWeight", "bold") dom.SetAttribute(row, "data-notif-new", "1") } // Avatar. ava := dom.CreateElement("img") pic, picOK := appSt.authorPics[senderPK] if !picOK || pic == "" { pic = "/musiquay-logo.svg" profile.Send(`["P_RESOLVE_FORCE",` | jstr(senderPK) | `]`) // Register ava so applyAuthorProfile updates it immediately when loaded. appSt.pendingNotifAvatars[senderPK] = mxutil.Ensure(appSt.pendingNotifAvatars[senderPK], 1) appSt.pendingNotifAvatars[senderPK] = push(appSt.pendingNotifAvatars[senderPK], ava) } setMediaSrc(ava, pic) dom.SetStyle(ava, "width", "24px") dom.SetStyle(ava, "height", "24px") dom.SetStyle(ava, "borderRadius", "50%") dom.SetStyle(ava, "flexShrink", "0") dom.SetStyle(ava, "cursor", "pointer") dom.SetAttribute(ava, "onclick", "event.stopPropagation()") pk := senderPK dom.AddEventListener(ava, "click", notifRegCB(func() { showProfile(pk) })) dom.AppendChild(row, ava) // Icon + action label. var icon string var label string switch ev.Kind { case 1: icon = "\xf0\x9f\x92\xac" // 💬 label = "" case 6: icon = "\xf0\x9f\x94\x81" // 🔁 label = "" case 7: emoji := ev.Content if emoji == "" || emoji == "+" { emoji = "\xe2\x9d\xa4\xef\xb8\x8f" // ❤️ } icon = emoji label = "" case 9735: icon = "\xe2\x9a\xa1" // ⚡ amt := zapAmount(ev) if amt != "" { label = amt | " sats" } } iconEl := dom.CreateElement("span") dom.SetStyle(iconEl, "flexShrink", "0") dom.SetTextContent(iconEl, icon) dom.AppendChild(row, iconEl) if label != "" { lblEl := dom.CreateElement("span") dom.SetStyle(lblEl, "flexShrink", "0") dom.SetStyle(lblEl, "color", "var(--accent)") dom.SetTextContent(lblEl, label) dom.AppendChild(row, lblEl) } // Content preview - line-clamped to 10 lines, same font as regular notes. refID := notifRefID(ev) var previewText string if ev.Kind == 1 { previewText = ev.Content } else if refID != "" { if ref, ok := lookupEvent(refID); ok && ref != nil { previewText = ref.Content } } prev := dom.CreateElement("span") dom.SetStyle(prev, "flex", "1") dom.SetStyle(prev, "minWidth", "0") dom.SetStyle(prev, "overflow", "hidden") dom.SetStyle(prev, "color", "var(--muted)") dom.SetStyle(prev, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(prev, "fontSize", "14px") dom.SetStyle(prev, "lineHeight", "1.5") dom.SetStyle(prev, "whiteSpace", "nowrap") dom.SetStyle(prev, "textOverflow", "ellipsis") dom.SetTextContent(prev, previewText) dom.AppendChild(row, prev) // Relative timestamp, right-aligned. tsEl := dom.CreateElement("span") dom.SetStyle(tsEl, "flexShrink", "0") dom.SetStyle(tsEl, "color", "var(--muted)") dom.SetStyle(tsEl, "fontSize", "11px") dom.SetTextContent(tsEl, relativeTime(ev.CreatedAt)) dom.AppendChild(row, tsEl) // Click navigates to the referenced event. if refID != "" { tid := refID dom.AddEventListener(row, "click", notifRegCB(func() { rootID := tid if ref, ok := lookupEvent(tid); ok && ref != nil { if r := getRootID(ref); r != "" { rootID = r } } showNoteThread(rootID, tid) })) } return row } // Wallet panel - implementation lives in wallet.mx. // --- Email header parsing for quote-reply --- // parseEmailHeaders checks if content looks like a forwarded email and extracts // From, Subject, and body. Returns isEmail=true if at least From: or Subject: found. func parseEmailHeaders(content string) (from, subject, body string, isEmail bool) { lines := splitLines(content) headerEnd := -1 for i, line := range lines { if line == "" { headerEnd = i break } if hasPrefix(line, "From: ") { from = line[6:] } else if hasPrefix(line, "Subject: ") { subject = line[9:] } else if hasPrefix(line, "To: ") || hasPrefix(line, "Date: ") || hasPrefix(line, "Cc: ") { // Known header, continue } else if i == 0 { return "", "", "", false } } if from == "" && subject == "" { return "", "", "", false } if headerEnd >= 0 && headerEnd+1 < len(lines) { body = joinLines(lines[headerEnd+1:]) } return from, subject, body, true } func quoteReply(from, subject, body string) (s string) { out := "To: " | from | "\n" if subject != "" { if !hasPrefix(subject, "Re: ") { subject = "Re: " | subject } out = out | "Subject: " | subject | "\n" } out = out | "\n\n" if body != "" { lines := splitLines(body) for _, line := range lines { out = out | "> " | line | "\n" } } return out } func splitLines(s string) (ss []string) { var lines []string for { idx := strIndex(s, "\n") if idx < 0 { lines = mxutil.Ensure(lines, 1) lines = push(lines, s) return lines } lines = mxutil.Ensure(lines, 1) lines = push(lines, s[:idx]) s = s[idx+1:] } } func joinLines(lines []string) (s string) { out := "" for i, line := range lines { if i > 0 { out = out | "\n" } out = out | line } return out } func hasPrefix(s, prefix string) (ok bool) { return len(s) >= len(prefix) && s[:len(prefix)] == prefix } // --- Markdown rendering --- // All functions use string concatenation and indexOf - no byte-level ops. // tinyjs compiles Go strings to JS strings (UTF-16); byte indexing corrupts emoji. // renderMarkdown converts note text to safe HTML via markdown-it jsbridge. // Pre-processes nostr entities and emoji shortcodes with placeholders, // runs markdown-it for commonmark + unicode-aware URL linkification, // then restores placeholders and converts media links to embeds. func renderMarkdown(s string) (sv string) { // Extract nostr: entities before markdown-it touches the text. var nostrSlots []string s = extractNostrEntities(s, &nostrSlots) // Extract emoji shortcodes. var emojiSlots []string s = extractEmojiShortcodes(s, &emojiSlots) // Extract hashtags (#tag) so markdown-it doesn't strip the #. var hashSlots []string s = extractHashtags(s, &hashSlots) // markdown-it: commonmark, auto-linkify (unicode-aware), breaks. s = markdown.Render(s) // Restore hashtag placeholders. for i, html := range hashSlots { s = strReplace(s, "\x02HASH" | itoa(i) | "\x02", html) } // Restore emoji placeholders. for i, html := range emojiSlots { s = strReplace(s, "\x02EMOJI" | itoa(i) | "\x02", html) } // Restore nostr entity placeholders. for i, html := range nostrSlots { s = strReplace(s, "\x02NOSTR" | itoa(i) | "\x02", html) } // Convert tags pointing to image/video URLs into embeds. s = convertMediaLinks(s) return s } // extractNostrEntities replaces nostr:... entities with placeholders. func extractNostrEntities(s string, slots *[]string) (sv string) { out := "" for { idx := strIndex(s, "nostr:") if idx < 0 { return out | s } out = out | s[:idx] rest := s[idx+6:] end := 0 for end < len(rest) { c := rest[end] if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { end++ } else { break } } if end < 10 { out = out | "nostr:" s = rest continue } entity := rest[:end] s = rest[end:] html := renderNostrEntity(entity) id := len(*slots) *slots = push(*slots, html) out = out | "\x02NOSTR" | itoa(id) | "\x02" } } // renderNostrEntity produces the HTML for a single nostr bech32 entity. func renderNostrEntity(entity string) (s string) { if len(entity) > 6 && entity[:6] == "naddr1" { na := helpers.DecodeNaddr(entity) if na != nil { coord := helpers.NaddrCoordKey(na.Kind, na.Pubkey, na.D) for _, r := range na.Relays { appSt.embedRelayHints[coord] = mxutil.Ensure(appSt.embedRelayHints[coord], 1) appSt.embedRelayHints[coord] = push(appSt.embedRelayHints[coord], r) } if !lookupFetchedK0(na.Pubkey) { profile.Send(`["P_RESOLVE",` | jstr(na.Pubkey) | `]`) } return makeCoordEmbedPlaceholder(coord, na.Kind, na.Pubkey, na.D) } } else if len(entity) > 7 && entity[:7] == "nevent1" { nev := helpers.DecodeNevent(entity) if nev != nil { if len(nev.Relays) > 0 { appSt.embedRelayHints[nev.ID] = nev.Relays } return makeEmbedPlaceholder(nev.ID) } } else if len(entity) > 5 && entity[:5] == "note1" { id := helpers.DecodeNote(entity) if id != nil { return makeEmbedPlaceholder(helpers.HexEncode(id)) } } else if len(entity) > 9 && entity[:9] == "nprofile1" { np := helpers.DecodeNprofile(entity) if np != nil { name := np.Pubkey[:8] | "..." if n, ok := appSt.authorNames[np.Pubkey]; ok && n != "" { name = n } else { profile.Send(`["P_RESOLVE",` | jstr(np.Pubkey) | `]`) } npub := helpers.EncodeNpub(helpers.HexDecode(np.Pubkey)) return "" | escapeHTML(name) | "" } } else if len(entity) > 5 && entity[:5] == "npub1" { pk := helpers.DecodeNpub(entity) if pk != nil { hex := helpers.HexEncode(pk) name := hex[:8] | "..." if n, ok := appSt.authorNames[hex]; ok && n != "" { name = n } else { profile.Send(`["P_RESOLVE",` | jstr(hex) | `]`) } npub := helpers.EncodeNpub(pk) return "" | escapeHTML(name) | "" } } return "nostr:" | entity } // extractEmojiShortcodes replaces :name: shortcodes with placeholders. func extractEmojiShortcodes(s string, slots *[]string) (sv string) { out := "" i := 0 for i < len(s) { if s[i] == ':' { end := -1 valid := true for j := i + 1; j < len(s) && j < i+40; j++ { c := s[j] if c == ':' { end = j break } if c == ' ' || c == '\n' || c == '\r' || c == '<' { valid = false break } if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') { valid = false break } } if valid && end > i+1 { name := s[i+1 : end] code := ":" | name | ":" var html string if emoji, ok2 := appSt.emojiByName[code]; ok2 { html = emoji } else { html = "" | code | "" } id := len(*slots) *slots = push(*slots, html) out = out | "\x02EMOJI" | itoa(id) | "\x02" i = end + 1 continue } } b := s[i] n := 1 if b >= 0xF0 { n = 4 } else if b >= 0xE0 { n = 3 } else if b >= 0xC0 { n = 2 } if i+n > len(s) { n = len(s) - i } out = out | s[i : i+n] i += n } return out } // convertMediaLinks finds tags with image/video URLs and replaces them. func convertMediaLinks(s string) (sv string) { out := "" for { idx := strIndex(s, ". endTag := strIndex(s, "") if endTag < 0 { return out | s } full := s[:endTag+4] s = s[endTag+4:] // Extract href value. hrefIdx := strIndex(full, "href=\"") if hrefIdx < 0 { out = out | full continue } hrefStart := hrefIdx + 6 hrefEnd := strIndex(full[hrefStart:], "\"") if hrefEnd < 0 { out = out | full continue } href := full[hrefStart : hrefStart+hrefEnd] if len(href) > 8 && strIndex(href[8:], "http") >= 0 { out = out | full continue } if ytID := youTubeVideoID(href); ytID != "" { // Strip surrounding

when the YouTube link is the sole paragraph. const pOpen = "

" if hasSuffix(out, pOpen) { out = out[:len(out)-len(pOpen)] if hasPrefix(s, "

") { s = s[4:] } } thumbURL := "https://img.youtube.com/vi/" | ytID | "/hqdefault.jpg" // data-invidious-id triggers the Invidious player when clicked. // The fallback href still opens YouTube in a new tab. out = out | "
" | "" | "" | "
" } else if isBandcampURL(href) { // Bandcamp link: show as a plain link with a "▶ campion" button alongside. out = out | "
" | "" | escapeHTMLAttr(href) | "" | " \xe2\x96\xb6 campion" | "
" } else if isImageURL(href) { orig := "\" data-orig-url=\"" | escapeHTMLAttr(href) | "\"" out = out | "" } else if isVideoURL(href) { out = out | "" } else { out = out | full } } } // truncateByLine cuts `text` at the end of the line containing position `target`. // Returns (truncated, true) if content was removed. Returns (text, false) otherwise. // This avoids mid-line cuts where only a few words would remain hidden. func truncateByLine(text string, target int32) (s string, ok bool) { if len(text) <= target { return text, false } cut := target for cut < len(text) && text[cut] != '\n' { cut++ } if cut >= len(text) { return text, false } return text[:cut], true } // strReplace replaces all occurrences of old with new using indexOf. func strReplace(s, old, nw string) (result string) { out := "" for { idx := strIndex(s, old) if idx < 0 { return out | s } out = out | s[:idx] | nw s = s[idx+len(old):] } } func isImageURL(url string) (ok bool) { u := toLower(url) return hasSuffix(u, ".jpg") || hasSuffix(u, ".jpeg") || hasSuffix(u, ".png") || hasSuffix(u, ".gif") || hasSuffix(u, ".webp") || hasSuffix(u, ".svg") } // migratedRelayTypes is the set of message types routed to the relay-proxy // worker. Anything else still flows to the legacy SW. // var migratedRelayTypes map[string]bool // swTypes are routed to the Service Worker. var swTypes map[string]bool // routeMsg dispatches a JSON-array message to the appropriate worker. func routeMsg(msg string) { if len(msg) >= 4 && msg[0] == '[' && msg[1] == '"' { end := 2 for end < len(msg) && msg[end] != '"' { end++ } typ := msg[2:end] if migratedRelayTypes[typ] { relayproxy.Send(msg) return } if swTypes[typ] { dom.PostToSW(msg) return } dom.ConsoleLog("routeMsg: UNROUTED message type: " | typ) return } if msg == "activate-update" { dom.PostToSW(msg) return } n := len(msg) if n > 60 { n = 60 } dom.ConsoleLog("routeMsg: UNROUTED non-array message: " | msg[:n]) } // setHTMLWithMedia sets innerHTML and processes media links and lightboxes. // makeShowMore creates the show-more/less toggle span (initially hidden). // attachShowMore must be called after the note is in the DOM to decide // whether to reveal it based on actual rendered height. func makeShowMore(content dom.Element) (e dom.Element) { more := dom.CreateElement("span") dom.SetTextContent(more, t("show_more")) dom.SetStyle(more, "color", "var(--accent)") dom.SetStyle(more, "cursor", "pointer") dom.SetStyle(more, "fontSize", "13px") dom.SetStyle(more, "display", "none") expanded := false dom.AddEventListener(more, "click", dom.RegisterCallback(func() { expanded = !expanded if expanded { dom.SetAttribute(content, "data-expanded", "1") dom.SetStyle(content, "overflow", "visible") dom.SetStyle(content, "maxHeight", "none") dom.SetTextContent(more, t("show_less")) } else { dom.RemoveAttribute(content, "data-expanded") dom.SetStyle(content, "overflow", "hidden") dom.SetStyle(content, "maxHeight", "18em") dom.SetTextContent(more, t("show_more")) } })) return more } func attachShowMore(content, more dom.Element) { c := content m := more check := func() { if dom.GetAttribute(c, "data-expanded") == "1" { return } scrollH := parseI64(dom.GetProperty(c, "scrollHeight")) clientH := parseI64(dom.GetProperty(c, "clientHeight")) if clientH > 0 && scrollH <= clientH { dom.SetStyle(m, "display", "none") } else if scrollH > clientH { dom.SetStyle(m, "display", "inline-flex") } } dom.ObserveResize(c, dom.RegisterCallback(check)) dom.SetTimeout(check, 200) } func isBandcampURL(url string) (ok bool) { // Matches *.bandcamp.com/* URLs. i := strIndex(url, "://") if i < 0 { return false } host := url[i+3:] slash := strIndex(host, "/") if slash >= 0 { host = host[:slash] } return hasSuffix(host, ".bandcamp.com") || host == "bandcamp.com" } func setHTMLWithMedia(rootp6 dom.Element, html string) { dom.SetInnerHTML(rootp6, html) flushYtTitles(rootp6) attachImageLightboxes(rootp6) attachYTLightboxes(rootp6) attachCampionLinks(rootp6) attachHashtagLinks(rootp6) } // ytTitleCache maps YouTube video ID -> fetched title. // flushYtTitles finds every [data-ytid] span and fills it with the video title. func flushYtTitles(rootp5 dom.Element) { el := dom.QuerySelectorFrom(rootp5, "[data-ytid]") for el != 0 { ytID := dom.GetAttribute(el, "data-ytid") dom.RemoveAttribute(el, "data-ytid") if ytID == "" { el = dom.QuerySelectorFrom(rootp5, "[data-ytid]") continue } if title, ok2 := appSt.ytTitleCache[ytID]; ok2 { dom.SetTextContent(el, title) el = dom.QuerySelectorFrom(rootp5, "[data-ytid]") continue } capturedEl := el capturedID := ytID oembedURL := "https://www.youtube.com/oembed?url=" | "https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3D" | ytID | "&format=json" dom.FetchText(oembedURL, func(body string) { title := helpers.JsonGetString(body, "title") if title == "" { title = capturedID } appSt.ytTitleCache[capturedID] = title dom.SetTextContent(capturedEl, title) }) el = dom.QuerySelectorFrom(rootp5, "[data-ytid]") } } // attachImageLightboxes finds every img[data-orig-url] in root, removes the // attribute (so the loop terminates), and wires a click handler that opens // the full-screen lightbox. Called from setHTMLWithMedia after innerHTML is set. func attachImageLightboxes(rootp3 dom.Element) { el := dom.QuerySelectorFrom(rootp3, "[data-orig-url]") for el != 0 { url := dom.GetAttribute(el, "data-orig-url") dom.RemoveAttribute(el, "data-orig-url") if url != "" { capturedURL := url dom.AddEventListener(el, "click", dom.RegisterCallback(func() { showImageLightbox(capturedURL) })) } el = dom.QuerySelectorFrom(rootp3, "[data-orig-url]") } } // attachYTLightboxes wires click handlers on [data-invidious-id] divs. // Clicking replaces the thumbnail inline with an Invidious embed iframe. func attachYTLightboxes(rootp4 dom.Element) { el := dom.QuerySelectorFrom(rootp4, "[data-invidious-id]") for el != 0 { ytID := dom.GetAttribute(el, "data-invidious-id") ytHref := dom.GetAttribute(el, "data-yt-href") dom.RemoveAttribute(el, "data-invidious-id") dom.RemoveAttribute(el, "data-yt-href") if ytID != "" { capturedEl := el capturedID := ytID capturedHref := ytHref dom.AddEventListener(el, "click", dom.RegisterCallback(func() { if appSt.invidiousURL != "" { inlineInvidiousEmbed(capturedEl, capturedID) } else { dom.WindowOpen(capturedHref) } })) } el = dom.QuerySelectorFrom(rootp4, "[data-invidious-id]") } } // inlineInvidiousEmbed replaces the thumbnail wrapper div's content with an // Invidious embed iframe sized at 16:9, plus a small control row below. // Stamps data-inv-ytid on the wrapper so stopInlineMedia can restore it. func inlineInvidiousEmbed(wrapper dom.Element, ytID string) { dom.ReleaseChildren(wrapper) dom.SetInnerHTML(wrapper, "") dom.SetStyle(wrapper, "cursor", "default") dom.SetAttribute(wrapper, "data-inv-ytid", ytID) dom.SetStyle(wrapper, "maxWidth", "100%") iframe := dom.CreateElement("iframe") dom.SetAttribute(iframe, "src", appSt.invidiousURL | "/embed/" | ytID | "?autoplay=1") dom.SetAttribute(iframe, "allowfullscreen", "true") dom.SetAttribute(iframe, "credentialless", "") dom.SetStyle(iframe, "width", "100%") dom.SetStyle(iframe, "aspectRatio", "16/9") dom.SetStyle(iframe, "border", "none") dom.SetStyle(iframe, "display", "block") dom.SetStyle(iframe, "borderRadius", "8px") dom.SetStyle(iframe, "background", "#000") dom.AppendChild(wrapper, iframe) // Control row: "open in invidious ↗" on the right. bar := dom.CreateElement("div") dom.SetStyle(bar, "display", "flex") dom.SetStyle(bar, "justifyContent", "flex-end") dom.SetStyle(bar, "marginTop", "2px") link := dom.CreateElement("span") dom.SetTextContent(link, "open in invidious \xe2\x86\x97") dom.SetStyle(link, "fontSize", "10px") dom.SetStyle(link, "color", "var(--accent)") dom.SetStyle(link, "cursor", "pointer") dom.SetStyle(link, "opacity", "0.7") capturedID := ytID dom.AddEventListener(link, "click", dom.RegisterCallback(func() { dom.WindowOpen(appSt.invidiousURL | "/watch?v=" | capturedID) })) dom.AppendChild(bar, link) dom.AppendChild(wrapper, bar) } // attachCampionLinks wires click handlers on [data-campion-url] spans. // Opens Campion in a new tab (window.open) to avoid COEP iframe conflicts. func attachCampionLinks(rootp1 dom.Element) { el := dom.QuerySelectorFrom(rootp1, "[data-campion-url]") for el != 0 { bcURL := dom.GetAttribute(el, "data-campion-url") dom.RemoveAttribute(el, "data-campion-url") if bcURL != "" { capturedURL := bcURL dom.AddEventListener(el, "click", dom.RegisterCallback(func() { if appSt.campionURL != "" { dom.WindowOpen(appSt.campionURL | "?url=" | dom.EncodeURIComponent(capturedURL)) } else { dom.WindowOpen(capturedURL) } })) } el = dom.QuerySelectorFrom(rootp1, "[data-campion-url]") } } // zoomStr converts a zoom level in 1/4x steps to a CSS scale() string. // Level 4 = 1.0x. Level 1 = 0.25x. Level 20 = 5.0x. func zoomStr(level int32) (s string) { whole := level / 4 frac := (level % 4) * 25 if frac == 0 { return itoa(whole) } fs := itoa(frac) for len(fs) > 1 && fs[len(fs)-1] == '0' { fs = fs[:len(fs)-1] } return itoa(whole) | "." | fs } func zoomCtrlBtn(label string) (e dom.Element) { btn := dom.CreateElement("span") dom.SetTextContent(btn, label) dom.SetStyle(btn, "cursor", "pointer") dom.SetStyle(btn, "fontSize", "28px") dom.SetStyle(btn, "fontWeight", "bold") dom.SetStyle(btn, "color", "#fff") dom.SetStyle(btn, "userSelect", "none") dom.SetStyle(btn, "padding", "0 6px") dom.SetStyle(btn, "lineHeight", "1") return btn } // showImageLightbox opens a full-screen modal for the given remote image URL. // Top-right: zoom [+] [-] [×close] bar. Bottom-left: clipboard button. // All controls are position:absolute inside the position:fixed scrim. func showImageLightbox(url string) { zoomLevel := 4 // 1.0x scrim := dom.CreateElement("div") dom.SetStyle(scrim, "position", "fixed") dom.SetStyle(scrim, "inset", "0") dom.SetStyle(scrim, "background", "rgba(0,0,0,0.88)") dom.SetStyle(scrim, "zIndex", "2000") dom.SetStyle(scrim, "display", "flex") dom.SetStyle(scrim, "alignItems", "center") dom.SetStyle(scrim, "justifyContent", "center") dom.SetStyle(scrim, "overflow", "hidden") img := dom.CreateElement("img") dom.SetAttribute(img, "referrerpolicy", "no-referrer") dom.SetStyle(img, "maxWidth", "90vw") dom.SetStyle(img, "maxHeight", "90vh") dom.SetStyle(img, "objectFit", "contain") dom.SetStyle(img, "display", "block") dom.SetStyle(img, "transformOrigin", "center center") setMediaSrc(img, url) // Click the image to copy it as a PNG blob to the system clipboard. capturedImg2 := img dom.AddEventListener(img, "click", dom.RegisterCallback(func() { dom.CopyImageToClipboard(capturedImg2, func(ok bool) { if ok { overlay := dom.CreateElement("div") dom.SetTextContent(overlay, "image copied") dom.SetStyle(overlay, "position", "absolute") dom.SetStyle(overlay, "top", "50%") dom.SetStyle(overlay, "left", "50%") dom.SetStyle(overlay, "transform", "translate(-50%,-50%)") dom.SetStyle(overlay, "background", "rgba(0,0,0,0.75)") dom.SetStyle(overlay, "color", "#fff") dom.SetStyle(overlay, "padding", "12px 24px") dom.SetStyle(overlay, "borderRadius", "8px") dom.SetStyle(overlay, "fontSize", "18px") dom.SetStyle(overlay, "pointerEvents", "none") dom.AppendChild(scrim, overlay) capturedOverlay := overlay dom.SetTimeout(func() { dom.RemoveChild(scrim, capturedOverlay) }, 1200) } }) })) dom.SetStyle(img, "cursor", "copy") dom.AppendChild(scrim, img) // Clipboard button - absolute bottom-left, copies the remote URL. clipBtn := dom.CreateElement("div") dom.SetTextContent(clipBtn, "\xf0\x9f\x93\x8b") // 📋 dom.SetStyle(clipBtn, "position", "absolute") dom.SetStyle(clipBtn, "bottom", "20px") dom.SetStyle(clipBtn, "left", "20px") dom.SetStyle(clipBtn, "cursor", "pointer") dom.SetStyle(clipBtn, "fontSize", "32px") dom.SetStyle(clipBtn, "background", "rgba(0,0,0,0.6)") dom.SetStyle(clipBtn, "borderRadius", "10px") dom.SetStyle(clipBtn, "padding", "8px 12px") dom.SetStyle(clipBtn, "lineHeight", "1") dom.SetStyle(clipBtn, "userSelect", "none") capturedURL := url capturedClip := clipBtn dom.AddEventListener(clipBtn, "click", dom.RegisterCallback(func() { dom.WritePrimarySelection(capturedURL) dom.WriteClipboard(capturedURL, func(ok bool) { if ok { dom.SetTextContent(capturedClip, "\xe2\x9c\x93") // ✓ dom.SetTimeout(func() { dom.SetTextContent(capturedClip, "\xf0\x9f\x93\x8b") }, 1500) } }) })) dom.AppendChild(scrim, clipBtn) // Zoom + close bar - absolute top-right. × closes the lightbox. zoomBar := dom.CreateElement("div") dom.SetStyle(zoomBar, "position", "absolute") dom.SetStyle(zoomBar, "top", "12px") dom.SetStyle(zoomBar, "right", "16px") dom.SetStyle(zoomBar, "display", "flex") dom.SetStyle(zoomBar, "gap", "4px") dom.SetStyle(zoomBar, "alignItems", "center") dom.SetStyle(zoomBar, "background", "rgba(0,0,0,0.6)") dom.SetStyle(zoomBar, "borderRadius", "10px") dom.SetStyle(zoomBar, "padding", "6px 12px") capturedImg := img applyZoom := func() { dom.SetStyle(capturedImg, "transform", "scale(" | zoomStr(zoomLevel) | ")") } plusBtn := zoomCtrlBtn("+") dom.AddEventListener(plusBtn, "click", dom.RegisterCallback(func() { if zoomLevel < 20 { zoomLevel++ } applyZoom() })) dom.AppendChild(zoomBar, plusBtn) minusBtn := zoomCtrlBtn("-") dom.AddEventListener(minusBtn, "click", dom.RegisterCallback(func() { if zoomLevel > 1 { zoomLevel-- } applyZoom() })) dom.AppendChild(zoomBar, minusBtn) // × closes (replaces separate close button). closeBtn := zoomCtrlBtn("\xc3\x97") // × closeFn := dom.RegisterCallback(func() { dom.RemoveChild(dom.Body(), scrim) }) dom.AddEventListener(closeBtn, "click", closeFn) dom.AppendChild(zoomBar, closeBtn) dom.AppendChild(scrim, zoomBar) dom.AddSelfEventListener(scrim, "click", closeFn) dom.AppendChild(dom.Body(), scrim) } // noteClipText formats a single event as a markdown section for clipboard. // Format: ### name (npub) - timestamp\n\ncontent\n\n---\n\n func noteClipText(ev *nostr.Event) (s string) { name := appSt.authorNames[ev.PubKey] npub := helpers.EncodeNpub(helpers.HexDecode(ev.PubKey)) if name == "" { if len(npub) > 20 { name = npub[:12] | "..." | npub[len(npub)-4:] } else { name = npub } } ts := formatTime(ev.CreatedAt) return "### " | name | " (" | npub | ") - " | ts | "\n\n" | ev.Content | "\n\n---\n\n" } // collectBranch builds the children map from threadEvents and collects the // event at startID plus all its descendants (DFS, chronological). Used by // per-note copy buttons in thread view. func collectBranch(startID string) (ss []*nostr.Event) { ch := map[string][]string{} for id, ev := range appSt.threadEvents { parentID := getReplyID(ev) if parentID == "" || parentID == id { continue } if appSt.threadEvents[parentID] == nil && parentID != appSt.threadRootID { if referencesEvent(ev, appSt.threadRootID) { parentID = appSt.threadRootID } } ch[parentID] = mxutil.Ensure(ch[parentID], 1) ch[parentID] = push(ch[parentID], id) } for pid := range ch { sortByCreatedAt(ch[pid]) } var result []*nostr.Event var dfs func(id string) dfs = func(id string) { ev, ok := appSt.threadEvents[id] if !ok { return } result = mxutil.Ensure(result, 1) result = push(result, ev) for _, childID := range ch[id] { dfs(childID) } } dfs(startID) return result } // noteCopyBtn returns a 📋 span styled like the other header micro-buttons. func noteCopyBtn() (e dom.Element) { btn := dom.CreateElement("span") dom.SetTextContent(btn, "\xf0\x9f\x93\x8b") // 📋 dom.SetStyle(btn, "fontSize", "10px") dom.SetStyle(btn, "cursor", "pointer") dom.SetStyle(btn, "opacity", "0.6") dom.SetStyle(btn, "marginRight", "4px") dom.SetStyle(btn, "userSelect", "none") capturedBtn := btn dom.AddEventListener(btn, "mouseenter", dom.RegisterCallback(func() { dom.SetStyle(capturedBtn, "opacity", "1") })) dom.AddEventListener(btn, "mouseleave", dom.RegisterCallback(func() { dom.SetStyle(capturedBtn, "opacity", "0.6") })) return btn } // noteCopyFeedback briefly shows ✓ on the copy button after a successful write. func noteCopyFeedback(btn dom.Element) { dom.SetTextContent(btn, "\xe2\x9c\x93") // ✓ dom.SetStyle(btn, "opacity", "1") capturedBtn := btn dom.SetTimeout(func() { dom.SetTextContent(capturedBtn, "\xf0\x9f\x93\x8b") dom.SetStyle(capturedBtn, "opacity", "0.6") }, 1200) } // prettyNoteJSON returns the event serialised as indented JSON with tabs. func prettyNoteJSON(ev *nostr.Event) (s string) { s = "{\n" s = s | "\t\"id\": \"" | ev.ID | "\",\n" s = s | "\t\"pubkey\": \"" | ev.PubKey | "\",\n" s = s | "\t\"created_at\": " | i64toa(ev.CreatedAt) | ",\n" s = s | "\t\"kind\": " | itoa(int32(ev.Kind)) | ",\n" s = s | "\t\"tags\": [\n" for i, tag := range ev.Tags { s = s | "\t\t[" for j, v := range tag { if j > 0 { s = s | ", " } s = s | "\"" | jsonEsc(v) | "\"" } s = s | "]" if i < len(ev.Tags)-1 { s = s | "," } s = s | "\n" } s = s | "\t],\n" s = s | "\t\"content\": \"" | jsonEsc(ev.Content) | "\",\n" s = s | "\t\"sig\": \"" | ev.Sig | "\"\n" s = s | "}" return s } // setMediaSrc sets elem's src to the /proxy/... same-origin path for the URL. // The browser's HTTP cache (Cache-Control: public, max-age=86400 from the // relay) serves cached images synchronously without any worker round-trip. func setMediaSrc(elem dom.Element, url string) { if len(url) == 0 { return } dom.SetAttribute(elem, "src", mediaProxyPath(url)) } // mediaProxyPath converts an absolute URL to a same-origin /proxy/ path. // Same-origin and data: URLs are returned unchanged. func mediaProxyPath(url string) (s string) { if len(url) == 0 || url[0] == '/' { return url } if len(url) >= 5 && url[:5] == "data:" { return url } if len(url) >= 8 && url[:8] == "https://" { return "/proxy/" | url[8:] } if len(url) >= 7 && url[:7] == "http://" { return "/proxy/" | url[7:] } return url } func isVideoURL(url string) (ok bool) { u := toLower(url) return hasSuffix(u, ".mp4") || hasSuffix(u, ".webm") || hasSuffix(u, ".mov") || hasSuffix(u, ".ogg") || hasSuffix(u, ".ogv") } // youTubeVideoID extracts the YouTube video ID from a URL. // Returns "" if the URL is not a YouTube link. // Handles youtube.com/watch?v=ID, youtu.be/ID, youtube.com/shorts/ID, youtube.com/embed/ID. func youTubeVideoID(url string) (s string) { u := toLower(url) // youtu.be/ID if hasPrefix(u, "https://youtu.be/") || hasPrefix(u, "http://youtu.be/") { rest := url[strIndex(url, "youtu.be/")+9:] return youTubeExtractID(rest) } // youtube.com variants if strIndex(u, "youtube.com/") < 0 { return "" } // /shorts/ID si := strIndex(u, "/shorts/") if si >= 0 { rest := url[si+8:] return youTubeExtractID(rest) } // /embed/ID ei := strIndex(u, "/embed/") if ei >= 0 { rest := url[ei+7:] return youTubeExtractID(rest) } // ?v=ID or &v=ID vi := strIndex(u, "v=") if vi >= 0 { rest := url[vi+2:] return youTubeExtractID(rest) } return "" } // youTubeExtractID returns the video ID: the segment up to the first '&', '?', '#', '/' or end. func youTubeExtractID(s string) (sv string) { end := len(s) for i := 0; i < len(s); i++ { c := s[i] if c == '&' || c == '?' || c == '#' || c == '/' { end = i break } } id := s[:end] if len(id) < 5 || len(id) > 20 { return "" } return id } func escapeHTMLAttr(s string) (sv string) { out := "" start := 0 for i := 0; i < len(s); i++ { var esc string switch s[i] { case '"': esc = """ case '\'': esc = "'" case '&': esc = "&" case '<': esc = "<" case '>': esc = ">" default: continue } out = out | s[start:i] | esc start = i + 1 } return out | s[start:] } func escapeHTML(s string) (sv string) { out := "" start := 0 for i := 0; i < len(s); i++ { var esc string switch s[i] { case '&': esc = "&" case '<': esc = "<" case '>': esc = ">" default: continue } out = out | s[start:i] | esc start = i + 1 } return out | s[start:] } func hasSuffix(s, suffix string) (ok bool) { return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix } func updateInlineProfileLinks(pk string) { name := appSt.authorNames[pk] if name == "" { return } // Find and update all links for this pubkey. // Loop with QuerySelector since there's no QuerySelectorAll. for { el := dom.QuerySelector("a[data-pk=\"" | pk | "\"]") if el == 0 { break } dom.SetTextContent(el, name) // Mark as done so we don't match it again. dom.SetAttribute(el, "data-pk", "") } } func embedEOSECleanup(subID string) { ids, ok := appSt.embedSubIDs[subID] if !ok { return } delete(appSt.embedSubIDs, subID) for _, hexID := range ids { if _, still := appSt.embedCallbacks[hexID]; !still { continue } // Check if any other sub is still pending for this ID. otherPending := false for _, otherIDs := range appSt.embedSubIDs { for _, oid := range otherIDs { if oid == hexID { otherPending = true break } } if otherPending { break } } if otherPending { continue } // No subs left - mark as not found. delete(appSt.embedRequested, hexID) elemIDs := appSt.embedCallbacks[hexID] delete(appSt.embedCallbacks, hexID) for _, elemID := range elemIDs { el := dom.GetElementById(elemID) if el == 0 { continue } dom.SetInnerHTML(el, "" | t("note_not_found") | "") dom.SetStyle(el, "opacity", "0.5") } } } // fillCoordEmbed matches an arriving event to its coord placeholders and fills them. func fillCoordEmbed(ev *nostr.Event) { // Build the coord key from the event's kind, pubkey, and d-tag. d := "" for _, tag := range ev.Tags { if len(tag) >= 2 && tag[0] == "d" { d = tag[1] break } } coord := helpers.NaddrCoordKey(uint32(ev.Kind), ev.PubKey, d) elemIDs, ok := appSt.embedCoordCBs[coord] if !ok { // Also check old generation. elemIDs, ok = appSt.embedCoordCBsOld[coord] if !ok { return } delete(appSt.embedCoordCBsOld, coord) } else { delete(appSt.embedCoordCBs, coord) } name := ev.PubKey[:8] | "..." if n, ok2 := appSt.authorNames[ev.PubKey]; ok2 && n != "" { name = n } // For kind 30023, use the title tag; otherwise truncate content. title := "" if ev.Kind == uint32(30023) { for _, tag := range ev.Tags { if len(tag) >= 2 && tag[0] == "title" { title = tag[1] break } } } text := title if text == "" { text = ev.Content nl := -1 for i := 0; i < len(text); i++ { if text[i] == '\n' { nl = i break } } if nl >= 0 { text = text[:nl] } } headerHTML := "
" if pic, ok2 := appSt.authorPics[ev.PubKey]; ok2 && pic != "" { headerHTML = headerHTML | "" } headerHTML = headerHTML | "" | escapeHTML(name) | "
" capturedEv := ev for _, elemID := range elemIDs { el := dom.GetElementById(elemID) if el == 0 { continue } clearChildren(el) dom.SetStyle(el, "opacity", "1") dom.SetStyle(el, "cursor", "pointer") hdr := dom.CreateElement("div") dom.SetInnerHTML(hdr, headerHTML) dom.AppendChild(el, hdr) body := dom.CreateElement("div") dom.SetStyle(body, "fontSize", "13px") dom.SetStyle(body, "lineHeight", "1.4") dom.SetInnerHTML(body, renderEmbedText(text)) dom.AppendChild(el, body) thisEl := el dom.AddEventListener(thisEl, "click", dom.RegisterCallback(func() { showNoteThread(capturedEv.ID, capturedEv.ID) })) } } // embedCoordEOSECleanup marks unfound coord embeds as not found. func embedCoordEOSECleanup(subID string) { coord, ok := appSt.embedCoordSubIDs[subID] if !ok { return } delete(appSt.embedCoordSubIDs, subID) // Check if any other coord sub is still pending for this coord. for _, other := range appSt.embedCoordSubIDs { if other == coord { return } } elemIDs, ok := appSt.embedCoordCBs[coord] if !ok { return } delete(appSt.embedCoordCBs, coord) for _, elemID := range elemIDs { el := dom.GetElementById(elemID) if el == 0 { continue } dom.SetInnerHTML(el, "" | t("note_not_found") | "") dom.SetStyle(el, "opacity", "0.5") } } func makeCoordEmbedPlaceholder(coord string, kind uint32, pubkey, d string) (s string) { appSt.embedCounter++ elemID := "emb-" | itoa(appSt.embedCounter) appSt.embedCoordCBs[coord] = mxutil.Ensure(appSt.embedCoordCBs[coord], 1) appSt.embedCoordCBs[coord] = push(appSt.embedCoordCBs[coord], elemID) label := "Loading..." if kind == uint32(30023) { label = "Loading article..." } return "
" | "" | label | "
" } func makeEmbedPlaceholder(hexID string) (s string) { appSt.embedCounter++ elemID := "emb-" | itoa(appSt.embedCounter) appSt.embedCallbacks[hexID] = mxutil.Ensure(appSt.embedCallbacks[hexID], 1) appSt.embedCallbacks[hexID] = push(appSt.embedCallbacks[hexID], elemID) return "
" | "Loading note...
" } func resolveEmbeds() { if len(appSt.embedCallbacks) == 0 && len(appSt.embedCoordCBs) == 0 { return } if len(appSt.embedCallbacks) == 0 { for coord := range appSt.embedCoordCBs { resolveCoordEmbed(coord) } return } // Fill any embeds already in cache before requesting. for hexID := range appSt.embedCallbacks { if ev, ok := lookupEvent(hexID); ok && ev != nil { fillEmbed(ev) } } if len(appSt.embedCallbacks) == 0 { return } // Only request IDs we haven't already sent to PROXY. // If embedRequested is true but the event isn't in cache, clear the // flag so we re-fetch - the event may have been evicted by rotation. var ids []string for hexID := range appSt.embedCallbacks { if appSt.embedRequested[hexID] { if _, ok2 := lookupEvent(hexID); ok2 { continue } delete(appSt.embedRequested, hexID) } ids = mxutil.Ensure(ids, 1) ids = push(ids, hexID) appSt.embedRequested[hexID] = true } if len(ids) == 0 { return } filter := "{\"ids\":[" for i, id := range ids { if i > 0 { filter = filter | "," } filter = filter | jstr(id) } filter = filter | "]}" // REQ checks the local IDB cache. appSt.embedCounter++ reqID := "emb-r-" | itoa(appSt.embedCounter) appSt.embedSubIDs[reqID] = ids routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]") // PROXY fetches from relays - combine user relays + nevent relay hints. seen := map[string]bool{} var urls []string for _, u := range readRelayURLs() { if !seen[u] { seen[u] = true urls = mxutil.Ensure(urls, 1) urls = push(urls, u) } } for _, id := range ids { hints := appSt.embedRelayHints[id] if len(hints) == 0 { hints = appSt.embedRelayHintsOld[id] delete(appSt.embedRelayHintsOld, id) } else { delete(appSt.embedRelayHints, id) } for _, u := range hints { if !seen[u] { seen[u] = true urls = mxutil.Ensure(urls, 1) urls = push(urls, u) } } } if len(urls) > 0 { proxyID := "emb-p-" | itoa(appSt.embedCounter) appSt.embedSubIDs[proxyID] = ids routeMsg(buildProxyMsg(proxyID, filter, urls)) } // Coord embeds: one PROXY sub per pending coord. for coord := range appSt.embedCoordCBs { resolveCoordEmbed(coord) } } // resolveCoordEmbed fires a PROXY fetch for one naddr coordinate. func resolveCoordEmbed(coord string) { // Parse "kind:pubkey:d" - split on first two colons. ci := -1 for i := 0; i < len(coord); i++ { if coord[i] == ':' { ci = i break } } if ci < 0 { return } kindStr := coord[:ci] rest := coord[ci+1:] pi := -1 for i := 0; i < len(rest); i++ { if rest[i] == ':' { pi = i break } } if pi < 0 { return } pubkey := rest[:pi] d := rest[pi+1:] filter := "{\"kinds\":[" | kindStr | "],\"authors\":[" | jstr(pubkey) | "],\"#d\":[" | jstr(d) | "]}" // Relay hints from the naddr TLV. coordRelays := appSt.embedRelayHints[coord] if len(coordRelays) > 0 { delete(appSt.embedRelayHints, coord) } else { coordRelays = appSt.embedRelayHintsOld[coord] delete(appSt.embedRelayHintsOld, coord) } var urls []string seen := map[string]bool{} for _, u := range readRelayURLs() { if !seen[u] { seen[u] = true urls = mxutil.Ensure(urls, 1) urls = push(urls, u) } } for _, u := range coordRelays { if !seen[u] { seen[u] = true urls = mxutil.Ensure(urls, 1) urls = push(urls, u) } } appSt.embedCounter++ if len(urls) > 0 { proxyID := "emb-c-" | itoa(appSt.embedCounter) appSt.embedCoordSubIDs[proxyID] = coord routeMsg(buildProxyMsg(proxyID, filter, urls)) } reqID := "emb-cr-" | itoa(appSt.embedCounter) appSt.embedCoordSubIDs[reqID] = coord routeMsg("[\"REQ\"," | jstr(reqID) | "," | filter | "]") } func renderEmbedText(s string) (sv string) { s = markdown.Render(s) s = convertMediaLinks(s) return s } func fillEmbed(ev *nostr.Event) { elemIDs, okw1 := appSt.embedCallbacks[ev.ID] if !okw1 { return } delete(appSt.embedCallbacks, ev.ID) appSt.embedCacheCount++ if appSt.embedCacheCount > rotateEmbedMax { rotateEmbedCaches() } name := ev.PubKey[:8] | "..." if n, okv3 := appSt.authorNames[ev.PubKey]; okv3 && n != "" { name = n } headerHTML := "
" if pic, okv2 := appSt.authorPics[ev.PubKey]; okv2 && pic != "" { headerHTML = headerHTML | "" } headerHTML = headerHTML | "" | escapeHTML(name) | "
" firstNL := -1 for i := 0; i < len(ev.Content); i++ { if ev.Content[i] == '\n' { firstNL = i break } } truncated := firstNL >= 0 && firstNL < len(ev.Content)-1 text := ev.Content if truncated { text = text[:firstNL] } embedEvID := ev.ID embedRootID := getRootID(ev) if embedRootID == "" { embedRootID = embedEvID } for _, elemID := range elemIDs { el := dom.GetElementById(elemID) if el == 0 { continue } clearChildren(el) dom.SetStyle(el, "opacity", "1") dom.SetStyle(el, "cursor", "pointer") hdr := dom.CreateElement("div") dom.SetInnerHTML(hdr, headerHTML) dom.AppendChild(el, hdr) body := dom.CreateElement("div") dom.SetStyle(body, "fontSize", "13px") dom.SetStyle(body, "lineHeight", "1.4") dom.SetInnerHTML(body, renderEmbedText(text)) dom.AppendChild(el, body) thisEl := el dom.AddEventListener(thisEl, "click", dom.RegisterCallback(func() { showNoteThread(embedRootID, embedEvID) })) } if _, okv1 := appSt.authorNames[ev.PubKey]; !okv1 { if !lookupFetchedK0(ev.PubKey) { profile.Send(`["P_RESOLVE",` | jstr(ev.PubKey) | `]`) } } } // jsonGetNum extracts a numeric value for a given key from a JSON object. func jsonGetNum(s, key string) (n int64) { needle := "\"" | key | "\":" idx := strIndex(s, needle) if idx < 0 { return 0 } idx += len(needle) // Skip whitespace. for idx < len(s) && (s[idx] == ' ' || s[idx] == '\t') { idx++ } if idx >= len(s) { return 0 } for idx < len(s) && s[idx] >= '0' && s[idx] <= '9' { n = n*10 + int64(s[idx]-'0') idx++ } return n } // jsonEsc escapes a string for embedding in a JSON value. // defaultServiceURL derives a service subdomain URL from the current page origin. // wss://smesh.lol + "invidious" → https://invidious.smesh.lol func defaultServiceURL(sub string) (s string) { local := localRelayURL() var host string if len(local) >= 6 && local[:6] == "wss://" { host = local[6:] } else if len(local) >= 5 && local[:5] == "ws://" { host = local[5:] } else { return "" } // Only derive for non-localhost origins. if host == "localhost" || len(host) > 9 && host[:9] == "127.0.0.1" { return "" } return "https://" | sub | "." | host } // defaultBlossomURL derives the blossom server URL from the current page origin. // wss://smesh.lol → https://smesh.lol/blossom // --- Search --- func activateSearchBar(btn dom.Element) { if !appSt.searchBarActive { appSt.searchReturnPage = appSt.activePage // remember where to return on manual close } appSt.searchBarActive = true dom.SetStyle(btn, "background", "var(--accent)") dom.SetStyle(btn, "color", "#fff") dom.SetStyle(btn, "borderRadius", "4px") dom.SetStyle(appSt.feedSelectEl, "display", "none") dom.SetStyle(appSt.pageTitleEl, "display", "none") dom.SetStyle(appSt.notifBarGroup, "display", "none") dom.SetStyle(appSt.topBarRight, "display", "none") dom.SetStyle(appSt.searchInputEl, "display", "block") dom.SetStyle(appSt.searchInputEl, "flex", "1") dom.SetStyle(appSt.searchSubmitBtn, "display", "block") dom.Focus(appSt.searchInputEl) } // deactivateSearchBar restores the bar appearance only. // Callers are responsible for any page navigation. func deactivateSearchBar(btn dom.Element) { appSt.searchBarActive = false dom.SetStyle(btn, "background", "transparent") dom.SetStyle(btn, "color", "var(--muted)") dom.SetStyle(btn, "borderRadius", "4px") dom.SetStyle(appSt.topBarRight, "display", "flex") dom.SetStyle(appSt.searchInputEl, "display", "none") dom.SetStyle(appSt.searchInputEl, "flex", "") dom.SetStyle(appSt.searchSubmitBtn, "display", "none") } // closeSearch is called when the user explicitly closes search (clicks 🔍 again). // Deactivates the bar and returns to the previous page. func closeSearch(btn dom.Element) { deactivateSearchBar(btn) ret := appSt.searchReturnPage if ret == "" || ret == "search" { ret = "feed" } appSt.searchReturnPage = "" appSt.activePage = "" // force switchPage to run switchPage(ret) } // executeSearch submits a search query: #tag searches by t-tag, other text // runs NIP-50 full-text search. Switches to the search page and subscribes. func executeSearch(q string) { q = trimStr(q) if q == "" { return } appSt.searchCurrentQ = q appSt.searchResults = []*nostr.Event{:0} appSt.searchSeen = map[string]bool{} switchPage("search") // URL reflects the current query. dom.ReplaceState("/search?q=" | dom.EncodeURIComponent(q)) // Build filter and choose target relays. var filter string var searchRelayList []string if len(q) > 1 && q[0] == '#' { // Tag search works on all relays; cap at 5 to limit event burst. tag := q[1:] filter = "{\"kinds\":[1,1111],\"#t\":[" | jstr(tag) | "],\"limit\":10}" searchRelayList = appSt.relayURLs if len(searchRelayList) > 5 { searchRelayList = searchRelayList[:5] } } else { // Full-text search: only NIP-50 relays, capped at 3 to bound // the event burst through the relay-proxy WASM. filter = "{\"kinds\":[1,1111],\"search\":" | jstr(q) | ",\"limit\":10}" searchRelayList = nip50Relays() if len(searchRelayList) > 3 { searchRelayList = searchRelayList[:3] } } // Build filter bar (sticky, above results) then show loading. clearChildren(appSt.searchContainer) buildSearchFilterBar() loading := dom.CreateElement("div") dom.SetStyle(loading, "color", "var(--muted)") dom.SetStyle(loading, "fontSize", "14px") dom.SetStyle(loading, "padding", "16px") dom.SetStyle(loading, "fontFamily", "'Fira Code', monospace") if len(searchRelayList) == 0 { dom.SetTextContent(loading, "no full-text search relays available \xe2\x80\x94 try #hashtag") dom.AppendChild(appSt.searchContainer, loading) return } relayWord := "relays" if len(searchRelayList) == 1 { relayWord = "relay" } dom.SetTextContent(loading, "searching " | itoa(len(searchRelayList)) | " " | relayWord | "...") dom.AppendChild(appSt.searchContainer, loading) routeMsg("[\"CLOSE\",\"search\"]") routeMsg(buildProxyMsg("search", filter, searchRelayList)) } func buildSearchFilterBar() { // Rebuild the filter bar (recreate buttons so state labels are fresh). if appSt.searchFilterBarEl != 0 { dom.RemoveChild(appSt.searchPage, appSt.searchFilterBarEl) } bar := dom.CreateElement("div") appSt.searchFilterBarEl = bar dom.SetAttribute(bar, "class", "search-filter-bar") dom.SetStyle(bar, "display", "flex") dom.SetStyle(bar, "gap", "8px") dom.SetStyle(bar, "padding", "8px 12px") dom.SetStyle(bar, "borderBottom", "1px solid var(--border)") dom.SetStyle(bar, "flexWrap", "wrap") dom.SetStyle(bar, "alignItems", "center") dom.SetStyle(bar, "position", "sticky") dom.SetStyle(bar, "top", "0") dom.SetStyle(bar, "background", "var(--bg2)") dom.SetStyle(bar, "zIndex", "10") followsBtn := searchFilterBtn("follows only", appSt.searchFollowsOnly) dom.AddEventListener(followsBtn, "click", dom.RegisterCallback(func() { appSt.searchFollowsOnly = !appSt.searchFollowsOnly applySearchFilterBtn(followsBtn, appSt.searchFollowsOnly) renderSearchResults() })) dom.AppendChild(bar, followsBtn) sortBtn := searchFilterBtn("newest first", appSt.searchSortDesc) dom.SetAttribute(sortBtn, "data-sort", "1") dom.AddEventListener(sortBtn, "click", dom.RegisterCallback(func() { appSt.searchSortDesc = !appSt.searchSortDesc if appSt.searchSortDesc { dom.SetTextContent(sortBtn, "newest first") } else { dom.SetTextContent(sortBtn, "oldest first") } applySearchFilterBtn(sortBtn, true) renderSearchResults() })) dom.AppendChild(bar, sortBtn) nsfwBtn := searchFilterBtn("nsfw", appSt.searchNSFW) dom.AddEventListener(nsfwBtn, "click", dom.RegisterCallback(func() { appSt.searchNSFW = !appSt.searchNSFW applySearchFilterBtn(nsfwBtn, appSt.searchNSFW) renderSearchResults() })) dom.AppendChild(bar, nsfwBtn) // Insert filter bar BEFORE searchContainer in searchPage. dom.InsertBefore(appSt.searchPage, bar, appSt.searchContainer) } func searchFilterBtn(label string, active bool) (e dom.Element) { btn := dom.CreateElement("button") dom.SetTextContent(btn, label) dom.SetStyle(btn, "padding", "4px 12px") dom.SetStyle(btn, "borderRadius", "4px") dom.SetStyle(btn, "fontSize", "12px") dom.SetStyle(btn, "cursor", "pointer") dom.SetStyle(btn, "fontFamily", "'Fira Code', monospace") applySearchFilterBtn(btn, active) return btn } func applySearchFilterBtn(btn dom.Element, active bool) { if active { dom.SetStyle(btn, "background", "var(--accent)") dom.SetStyle(btn, "color", "#fff") dom.SetStyle(btn, "border", "none") } else { dom.SetStyle(btn, "background", "transparent") dom.SetStyle(btn, "color", "var(--muted)") dom.SetStyle(btn, "border", "1px solid var(--border)") } } func handleSearchEvent(ev *nostr.Event) { if ev.Kind != 1 && ev.Kind != 1111 { return } if appSt.searchSeen[ev.ID] { return } appSt.searchSeen[ev.ID] = true if isMuted(ev.PubKey) { return } if looksLikeJSONSpam(ev.Content) { return } appSt.searchResults = mxutil.Ensure(appSt.searchResults, 1) appSt.searchResults = push(appSt.searchResults, ev) setEvent(ev.ID, ev) } const searchPageSize = 25 // results rendered per page / scroll load // filteredSearchResults returns searchResults with current filters applied and sorted. func filteredSearchResults() (ss []*nostr.Event) { var filtered []*nostr.Event for _, ev := range appSt.searchResults { if appSt.searchFollowsOnly && !isFollowing(ev.PubKey) { continue } if !appSt.searchNSFW { if nostr.TagsGetFirst(ev.Tags, []byte("content-warning")) != nil { continue } } filtered = mxutil.Ensure(filtered, 1) filtered = push(filtered, ev) } for i := 0; i < len(filtered); i++ { for j := i + 1; j < len(filtered); j++ { var swap bool if appSt.searchSortDesc { swap = filtered[j].CreatedAt > filtered[i].CreatedAt } else { swap = filtered[j].CreatedAt < filtered[i].CreatedAt } if swap { filtered[i], filtered[j] = filtered[j], filtered[i] } } } return filtered } // renderSearchResults rebuilds the result list from scratch, rendering only // the first searchPageSize results. Remaining results load on scroll. func renderSearchResults() { appSt.searchRendered = 0 clearChildren(appSt.searchContainer) filtered := filteredSearchResults() if len(filtered) == 0 { empty := dom.CreateElement("div") dom.SetTextContent(empty, "no results") dom.SetStyle(empty, "color", "var(--muted)") dom.SetStyle(empty, "fontSize", "14px") dom.SetStyle(empty, "padding", "16px") dom.SetStyle(empty, "fontFamily", "'Fira Code', monospace") dom.AppendChild(appSt.searchContainer, empty) return } end := len(filtered) if end > searchPageSize { end = searchPageSize } for i := 0; i < end; i++ { dom.AppendChild(appSt.searchContainer, renderSearchResult(filtered[i])) } appSt.searchRendered = end } // appendSearchResults renders the next batch of results (called on scroll). func appendSearchResults() { filtered := filteredSearchResults() if appSt.searchRendered >= len(filtered) { return } end := appSt.searchRendered + searchPageSize if end > len(filtered) { end = len(filtered) } for i := appSt.searchRendered; i < end; i++ { dom.AppendChild(appSt.searchContainer, renderSearchResult(filtered[i])) } appSt.searchRendered = end } func renderSearchResult(ev *nostr.Event) (e dom.Element) { row := dom.CreateElement("div") dom.SetStyle(row, "display", "flex") dom.SetStyle(row, "flexDirection", "column") dom.SetStyle(row, "padding", "10px 12px") dom.SetStyle(row, "borderBottom", "1px solid var(--border)") dom.SetStyle(row, "cursor", "pointer") dom.SetStyle(row, "gap", "4px") // Header: avatar + name + timestamp. header := dom.CreateElement("div") dom.SetStyle(header, "display", "flex") dom.SetStyle(header, "alignItems", "center") dom.SetStyle(header, "gap", "8px") avatar := dom.CreateElement("img") dom.SetAttribute(avatar, "width", "20") dom.SetAttribute(avatar, "height", "20") dom.SetAttribute(avatar, "referrerpolicy", "no-referrer") dom.SetStyle(avatar, "borderRadius", "50%") dom.SetStyle(avatar, "objectFit", "cover") dom.SetStyle(avatar, "flexShrink", "0") pk := ev.PubKey if pic, ok := appSt.authorPics[pk]; ok && pic != "" { setMediaSrc(avatar, pic) dom.SetAttribute(avatar, "onerror", "this.style.display='none'") } else { dom.SetStyle(avatar, "display", "none") } dom.AppendChild(header, avatar) name := dom.CreateElement("span") dom.SetStyle(name, "fontSize", "14px") dom.SetStyle(name, "fontWeight", "bold") dom.SetStyle(name, "color", "var(--fg)") dom.SetStyle(name, "flex", "1") dom.SetStyle(name, "overflow", "hidden") dom.SetStyle(name, "textOverflow", "ellipsis") dom.SetStyle(name, "whiteSpace", "nowrap") if n, ok := appSt.authorNames[pk]; ok && n != "" { dom.SetTextContent(name, n) } else { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) if len(npub) > 20 { dom.SetTextContent(name, npub[:12] | "..." | npub[len(npub)-4:]) } } dom.AppendChild(header, name) ts := dom.CreateElement("span") dom.SetTextContent(ts, formatTime(ev.CreatedAt)) dom.SetStyle(ts, "fontSize", "11px") dom.SetStyle(ts, "color", "var(--muted)") dom.SetStyle(ts, "flexShrink", "0") dom.AppendChild(header, ts) dom.AppendChild(row, header) // Two-line content preview (plain text, no markdown rendering). preview := dom.CreateElement("div") dom.SetStyle(preview, "fontSize", "13px") dom.SetStyle(preview, "color", "var(--fg)") dom.SetStyle(preview, "lineHeight", "1.4") dom.SetStyle(preview, "overflow", "hidden") dom.SetStyle(preview, "display", "-webkit-box") dom.SetStyle(preview, "-webkit-line-clamp", "2") dom.SetStyle(preview, "-webkit-box-orient", "vertical") dom.SetStyle(preview, "wordBreak", "break-word") content := ev.Content if len(content) > 300 { content = content[:300] | "..." } dom.SetTextContent(preview, content) dom.AppendChild(row, preview) // Click opens thread. evID := ev.ID evRootID := getRootID(ev) if evRootID == "" { evRootID = evID } dom.AddEventListener(row, "click", dom.RegisterCallback(func() { showNoteThread(evRootID, evID) })) if _, cached := appSt.authorNames[pk]; !cached { appSt.pendingNotes[pk] = mxutil.Ensure(appSt.pendingNotes[pk], 1) appSt.pendingNotes[pk] = push(appSt.pendingNotes[pk], header) if !lookupFetchedK0(pk) { profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`) } } return row } // extractHashtags replaces #tag tokens with placeholders so markdown-it // preserves them. Restored after rendering as clickable search spans. func extractHashtags(s string, slots *[]string) (sv string) { out := "" i := 0 for i < len(s) { if s[i] != '#' { out = out | string([]byte{s[i]}) i++ continue } // Must be preceded by whitespace/start/newline to be a hashtag. if i > 0 { prev := s[i-1] if prev != ' ' && prev != '\n' && prev != '\t' && prev != '\r' { out = out | "#" i++ continue } } // Collect tag characters: letters, digits, underscores. j := i + 1 for j < len(s) { c := s[j] if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' { j++ } else { break } } if j == i+1 { // No tag characters after #. out = out | "#" i++ continue } tag := s[i+1 : j] idx := len(*slots) span := "#" | tag | "" *slots = push(*slots, span) out = out | "\x02HASH" | itoa(idx) | "\x02" i = j } return out } // attachHashtagLinks wires click handlers on [data-search-tag] spans. func attachHashtagLinks(rootp2 dom.Element) { el := dom.QuerySelectorFrom(rootp2, "[data-search-tag]") for el != 0 { tag := dom.GetAttribute(el, "data-search-tag") dom.RemoveAttribute(el, "data-search-tag") if tag != "" { capturedTag := tag dom.AddEventListener(el, "click", dom.RegisterCallback(func() { dom.SetProperty(appSt.searchInputEl, "value", "#" | capturedTag) executeSearch("#" | capturedTag) })) } el = dom.QuerySelectorFrom(rootp2, "[data-search-tag]") } } // trimStr trims leading and trailing whitespace from s. func trimStr(s string) (sv string) { start := 0 for start < len(s) && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') { start++ } end := len(s) for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') { end-- } return s[start:end] } // nip50Relays returns the subset of configured relay URLs that advertise NIP-50 // (full-text search) in their NIP-11 document. func nip50Relays() (ss []string) { var out []string for i, url := range appSt.relayURLs { if i < len(appSt.relayCaps) && hasNIP(appSt.relayCaps[i].supportedNIPs, 50) { out = mxutil.Ensure(out, 1) out = push(out, url) } } return out } // looksLikeJSONSpam returns true if the content is a raw JSON object or array // larger than 200 bytes. Catches protocol spam posted as kind 1 notes. func looksLikeJSONSpam(content string) (ok bool) { i := 0 for i < len(content) { c := content[i] if c == ' ' || c == '\n' || c == '\r' || c == '\t' { i++ } else { break } } if i >= len(content) { return false } open := content[i] if open != '{' && open != '[' { return false } // Scan for matching close bracket (up to 2000 chars). limit := len(content) if limit > 2000 { limit = 2000 } depth := 0 inStr := false j := i for j < limit { c := content[j] if inStr { if c == '\\' && j+1 < limit { j += 2 continue } if c == '"' { inStr = false } } else { if c == '"' { inStr = true } else if c == '{' || c == '[' { depth++ } else if c == '}' || c == ']' { depth-- if depth == 0 { return j-i+1 > 200 } } } j++ } return false } func defaultBlossomURL() (s string) { local := localRelayURL() var base string if len(local) >= 6 && local[:6] == "wss://" { base = "https://" | local[6:] } else if len(local) >= 5 && local[:5] == "ws://" { base = "http://" | local[5:] } else { return "" } return base | "/blossom" } // parseJSONStringArray parses a JSON array of strings like ["a","b","c"]. func parseJSONStringArray(s string) (ss []string) { var out []string i := 0 for i < len(s) && s[i] != '[' { i++ } if i >= len(s) { return nil } i++ // skip '[' for i < len(s) { for i < len(s) && (s[i] == ' ' || s[i] == ',' || s[i] == '\n') { i++ } if i >= len(s) || s[i] == ']' { break } if s[i] != '"' { break } i++ start := i for i < len(s) && s[i] != '"' { if s[i] == '\\' { i++ } i++ } if i <= len(s) { out = mxutil.Ensure(out, 1) out = push(out, s[start:i]) } if i < len(s) { i++ // skip closing '"' } } return out } func jsonEsc(s string) (sv string) { s = strReplace(s, "\\", "\\\\") s = strReplace(s, "\"", "\\\"") s = strReplace(s, "\n", "\\n") s = strReplace(s, "\r", "\\r") s = strReplace(s, "\t", "\\t") return s } // strIndex finds substring in string. Returns -1 if not found. func strIndex(s, sub string) (n int32) { sl := len(sub) for i := 0; i <= len(s)-sl; i++ { if s[i:i+sl] == sub { return i } } return -1 } // --- Helpers --- // normalizeURL strips trailing slashes and lowercases the scheme+host. func normalizeURL(u string) (s string) { for len(u) > 0 && u[len(u)-1] == '/' { u = u[:len(u)-1] } // Lowercase scheme and host (before first / after ://). if len(u) > 6 && u[:6] == "wss://" { rest := u[6:] slash := strIndex(rest, "/") if slash < 0 { return u[:6] | toLower(rest) } return u[:6] | toLower(rest[:slash]) | rest[slash:] } if len(u) > 5 && u[:5] == "ws://" { rest := u[5:] slash := strIndex(rest, "/") if slash < 0 { return u[:5] | toLower(rest) } return u[:5] | toLower(rest[:slash]) | rest[slash:] } return u } func toLower(s string) (sv string) { b := []byte{:len(s)} for i := 0; i < len(s); i++ { c := s[i] if c >= 'A' && c <= 'Z' { c += 32 } b[i] = c } return string(b) } func showQRModal(npubStr string) { svg := qrSVG(npubStr, 5) if svg == "" { return } scrim := dom.CreateElement("div") dom.SetStyle(scrim, "position", "fixed") dom.SetStyle(scrim, "inset", "0") dom.SetStyle(scrim, "background", "rgba(0,0,0,0.6)") dom.SetStyle(scrim, "display", "flex") dom.SetStyle(scrim, "alignItems", "center") dom.SetStyle(scrim, "justifyContent", "center") dom.SetStyle(scrim, "zIndex", "9999") dom.SetStyle(scrim, "cursor", "pointer") dom.AddEventListener(scrim, "click", dom.RegisterCallback(func() { dom.RemoveChild(dom.Body(), scrim) })) card := dom.CreateElement("div") dom.SetStyle(card, "background", "white") dom.SetStyle(card, "borderRadius", "16px") dom.SetStyle(card, "padding", "24px") dom.SetStyle(card, "display", "flex") dom.SetStyle(card, "flexDirection", "column") dom.SetStyle(card, "alignItems", "center") dom.SetStyle(card, "cursor", "default") dom.SetAttribute(card, "onclick", "event.stopPropagation()") dom.SetInnerHTML(card, svg) dom.AppendChild(scrim, card) dom.AppendChild(dom.Body(), scrim) } func clearChildren(el dom.Element) { dom.ReleaseChildren(el) dom.SetInnerHTML(el, "") } func removeChildrenByPK(parent dom.Element, pk string) { if parent == 0 { return } var toRemove []dom.Element child := dom.FirstElementChild(parent) for child != 0 { if dom.GetProperty(child, "musiquayPK") == pk { toRemove = mxutil.Ensure(toRemove, 1) toRemove = push(toRemove, child) } child = dom.NextSibling(child) } for _, el := range toRemove { dom.RemoveChild(parent, el) } } func itoa(n int32) (s string) { if n == 0 { return "0" } neg := false if n < 0 { neg = true n = -n } var b [20]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } if neg { i-- b[i] = '-' } return string(b[i:]) }