package main import ( "crypto/sha256" "git.smesh.lol/smesh/web/common/helpers" "git.smesh.lol/smesh/web/common/jsbridge/dom" "git.smesh.lol/smesh/web/common/jsbridge/mlsw" "git.smesh.lol/smesh/web/common/jsbridge/feed" "git.smesh.lol/smesh/web/common/jsbridge/localstorage" "git.smesh.lol/smesh/web/common/jsbridge/markdown" "git.smesh.lol/smesh/web/common/jsbridge/notif" "git.smesh.lol/smesh/web/common/jsbridge/profile" "git.smesh.lol/smesh/web/common/jsbridge/relayproxy" "git.smesh.lol/smesh/web/common/jsbridge/signer" "git.smesh.lol/smesh/web/common/nostr" ) const ( lsKeyPubkey = "smesh-pubkey" lsKeyTheme = "smesh-theme" lsKeyRelayPolicy = "smesh-relay-policy" lsKeyRelayBlocklist = "smesh-relay-blocklist" lsKeyRelayList = "smesh-relays" lsKeyBlossomServer = "smesh-blossom-server" lsKeyInvidiousURL = "smesh-invidious-url" lsKeyCampionURL = "smesh-campion-url" lsKeyDevMode = "smesh-dev-mode" lsKeyRelayRoles = "smesh-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 ( pubhex string isDark bool devMode bool blossomServerURL string invidiousURL string campionURL string // Profile data from kind 0. profileName string profilePic string profileTs int64 contactTs int64 // kind 3 latest timestamp muteTs int64 // kind 10000 latest timestamp relayListTs int64 // kind 10002 latest timestamp inboxTs int64 // kind 10050 latest timestamp settingsTs int64 // kind 30078 latest timestamp // DOM refs that need updating after creation. avatarEl dom.Element nameEl dom.Element feedContainer dom.Element feedLoader dom.Element statusEl dom.Element bottomBar dom.Element topBarRight dom.Element topBarLeft dom.Element userBtn dom.Element popoverEl dom.Element popoverSummary dom.Element signerBtnEl dom.Element walletBtnEl dom.Element walletPanel dom.Element walletContent dom.Element walletPanelOpen bool themeBtn dom.Element pageTitleEl dom.Element topBackBtn dom.Element feedPage dom.Element loadMoreBtn dom.Element msgPage dom.Element profilePage dom.Element sidebarCompose dom.Element sidebarFeed dom.Element sidebarMsg dom.Element sidebarNotif dom.Element sidebarSettings dom.Element notifDot dom.Element notifPage dom.Element notifContainer dom.Element // Search page state. searchPage dom.Element searchContainer dom.Element searchFilterBarEl dom.Element // sticky bar above results searchInputEl dom.Element searchSubmitBtn dom.Element searchBtnGlobal dom.Element // captured for deactivation from anywhere searchReturnPage string // page to restore when user manually closes search searchBarActive bool searchCurrentQ string searchResults []*nostr.Event searchSeen map[string]bool searchFollowsOnly bool searchSortDesc bool // true = newest first (default) searchNSFW bool searchRendered int32 // how many filtered results are currently rendered notifLoadMore dom.Element notifEmptyEl dom.Element notifSelectEl dom.Element notifMarkBtn dom.Element notifBarGroup dom.Element notifFilter string // "all", "mentions", "reactions", "zaps" notifUnread bool notifReadTs int64 // watermark: events with CreatedAt > this are unread notifBuilt bool // true after first render notifEvents []*nostr.Event notifRowCBs []int32 // callback IDs registered by current notif rows; released on rebuild notifSeen map[string]bool oldestNotifTs int64 notifLoading bool notifExhausted bool notifEmptyStrk int32 notifMoreGot int32 notifMoreTimer int32 notifRefTimer int32 notifInitLoad bool notifMissing []string // refIDs not in eventCache, fetched after EOSE settingsPage dom.Element composePage dom.Element aboutPage dom.Element aboutLogoEl dom.Element composeTextarea dom.Element composePreview dom.Element composeRelayPopEl dom.Element composeRelayPopOpen bool composeRelayChecks map[string]bool // relay URL -> selected for publish emojiAutoEl dom.Element // emoji autocomplete popup emojiAutoOpen bool emojiAutoStart int32 // position of the opening ':' emojiAutoTA dom.Element // textarea the emoji popup is attached to activePage string profileViewPK string // App root - content goes here, not body (snackbar stays outside). root dom.Element // Messaging UI state. msgListContainer dom.Element // conversation list view msgThreadContainer dom.Element // thread view (header + messages + compose) msgThreadMessages dom.Element // scrollable message area msgComposeInput dom.Element // textarea msgCurrentConvoID string // peer pubkey (DM) or groupIDHex (group) of open thread, "" when on list msgCurrentKind string // "dm" or "group" msgView string // "list" or "thread" marmotInited bool pendingTsEls []dom.Element // timestamp divs awaiting relay confirmation // Relay tracking - parallel slices, grown dynamically. relayURLs []string relayRows []dom.Element // popover row container - used by removeRelayByURL relayDots []dom.Element relayLabels []dom.Element relayBadgeContainers []dom.Element // capability warning badges, rewritten on NIP-11 fetch relayReadBtns []dom.Element // per-relay "r" toggle button relayWriteBtns []dom.Element // per-relay "w" toggle button relayUserPick []bool // true = from user's kind 10002 relayRole []string // "both", "read", "write" per relay relayCaps []relayCap // NIP-11 snapshot (populated async after addRelay) relayCapsTs []int64 // unix seconds when each relayCaps entry was fetched // Relay policy (loaded once at startup from localStorage). relayPolicyFlags int32 // bitfield of policy* constants relayBlocklist []string // exact-URL blocklist (persisted; kind 10006 publication is future work) eventRelays map[string][]string // event ID -> relay URLs that delivered it pendingDeletes map[string]string // delete event ID -> target event ID noteElements map[string]dom.Element // event ID -> note DOM element eventCount int32 popoverOpen bool broadcastRow dom.Element broadcastBtnEl dom.Element oldestFeedTs int64 // oldest created_at in feed - for infinite scroll feedLoading bool // true while fetching older events feedExhausted bool // true when EOSE returns no new events feedEmptyStreak int32 // consecutive empty EOSE responses // 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. authorNames map[string]string // pubkey hex -> display name authorPics map[string]string // pubkey hex -> avatar URL pendingNotes map[string][]dom.Element // pubkey hex -> author header divs awaiting profile pendingNotifAvatars map[string][]dom.Element // pubkey hex -> notif avatar imgs awaiting profile seenEvents map[string]bool // event ID -> already rendered // 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. fetchedK0 map[string]bool fetchedK0Old map[string]bool fetchedK10k map[string]bool fetchedK10kOld map[string]bool // Stub vars for dead profile-fetch functions - referenced in function bodies // that are no longer called; kept to satisfy the compiler until cleanup. fetchQueue []string fetchTimer int32 retryRound int32 authorSubPK map[string]string // User card hover popover. hoverCardEl dom.Element hoverCardParent dom.Element hoverCardPK string hoverCardTimer int32 hoverCardDismiss int32 hoverCardHover bool idbLoaded bool // QR modal. logoSVGCache string // Profile page tab state. profileTab string profileTabContent dom.Element profileTabBtns map[string]dom.Element // 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. myFollows []string myMutes []string followSet map[string]bool // O(1) lookup derived from myFollows muteSet map[string]bool // O(1) lookup derived from myMutes // Profile content/follows/mutes/relays for viewed profiles (small view caches). profileContentCache map[string]string profileFollowsCache map[string][]string profileMutesCache map[string][]string profileRelaysCache map[string][]string profileNotesSeen map[string]bool activeProfileNoteSub string // Profile page cache - keeps rendered profile DOMs alive. relayInfoEl dom.Element // relay info page wrapper (removed on nav) profileWrappers map[string]dom.Element // pk -> wrapper div profileWrapTabCont map[string]dom.Element // pk -> tab content ref profileWrapTabs map[string]map[string]dom.Element // pk -> tab button map profileWrapTabSel map[string]string // pk -> selected tab profileWrapOrder []string // LRU order (oldest first) // History/routing. navPop bool // true during popstate - suppresses pushState routerInited bool // guard against duplicate listener registration // Embedded nostr: entity resolution. embedCounter int32 embedCallbacks map[string][]string // hex event ID -> DOM element IDs awaiting fill embedRelayHints map[string][]string // hex event ID -> relay hints from nevent TLV embedRequested map[string]bool // hex event IDs already sent to PROXY embedSubIDs map[string][]string // sub ID -> list of hex event IDs requested in that sub embedCoordCBs map[string][]string // "kind:pubkey:d" -> DOM element IDs awaiting fill embedCoordSubIDs map[string]string // sub ID -> coord key // @-mention autocomplete. mentionPopup dom.Element // the popup container mentionItems []mentionMatch mentionSelIdx int32 // highlighted index in popup mentionTA dom.Element // textarea the popup is attached to mentionQuery string // current query text (after @) mentionAtPos int32 // character position of the @ in the textarea // Thread view state. threadPage dom.Element threadContainer dom.Element threadRootID string threadFocusID string threadEvents map[string]*nostr.Event threadSubCount int32 threadOpen bool threadReturnPage string // page to return to when closing thread threadPushedState bool // true if we pushed history for this thread threadRenderTimer int32 threadGen int32 // generation counter - reject events from old threads threadActiveSubs []string // sub IDs to close when opening a new thread threadLastRendered int32 // event count at last render - skip if unchanged threadRelays []string // relay URLs used for this thread's fetches threadFollowUp int32 // follow-up round counter (0 = initial, caps at 3) threadFetchedIDs map[string]bool // IDs already queried for child replies contentArea dom.Element // scroll container savedScrollTop string // feed scroll position before thread open // Reply preview state. replyCache map[string]string // event ID -> "name: first line" replyAvatarCache map[string]string // event ID -> avatar URL of parent author replyLineCache map[string]string // event ID -> raw first line (no name) replyAuthorMap map[string]string // event ID -> author pubkey hex replyPending map[string][]dom.Element // event ID -> preview divs awaiting fetch replyNeedName map[string][]dom.Element // event ID -> preview divs needing name update replyQueue []string // event IDs to batch-fetch replyHints map[string]string // event ID -> relay hint URL from e-tag replyTimer int32 replySweepTimer int32 // debounced sweep for unfound replies // Action button state (reactions/reposts). noteRepostCounts map[string]map[string]bool // event ID -> pubkey set noteReactionMap map[string]map[string]map[string]bool // event ID -> emoji -> pubkey set noteReactionEls map[string]dom.Element // event ID -> reaction display row noteRepostEls map[string]dom.Element // event ID -> repost counter span noteZapTotals map[string]int64 // event ID -> total msats received noteZapSeen map[string]map[string]bool // event ID -> set of 9735 receipt IDs counted noteZapEls map[string]dom.Element // event ID -> zap total chip pendingZapRows map[string][]zapRowPending // authorPK -> rows awaiting lud16 actionFetchedIDs map[string]bool // event IDs already queued for kind 6/7 actionFetchQueue []string // IDs awaiting batch fetch actionFetchTimer int32 actionSubCounter int32 // Compose editor state. editorComposer dom.Element // inline composer div appended to note editorNoteEl dom.Element // the note element containing the composer editorTextarea dom.Element editorPreviewEl dom.Element editorMode string // "reply" or "quote" editorTargetEv *nostr.Event editorOpen bool editorPreviewing bool editorDrafts map[string]string // "reply:evID" or "quote:evID" -> draft text // Emoji picker state. emojiOverlay dom.Element emojiBackdrop dom.Element emojiAnchor dom.Element emojiTargetEv *nostr.Event emojiOpen bool // Cached events for repost/quote content. eventCache map[string]*nostr.Event // Global ready flag - set after showApp finishes all map init + DOM setup. appReady bool // Activity log state. activityLog is append-only (newest first), capped at activityLogMax. // Each entry is a single action with a short summary and optional multi-line detail. logPage dom.Element logListEl dom.Element logBtn dom.Element activityLog []*logEntry bootInProgress bool // true while boot is running; controls logBtn highlight // 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. seenOnSubs map[string]func() // Feed controls. refreshBtn dom.Element refreshSpinning bool 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 feedSubscribed bool // true once subscribeFeed has been called feedDeferTimer int32 // timer for deferred feed subscription feedSubTimer int32 // throttle timer for subscribeFeed feedBuffer []*nostr.Event // new events buffered after initial load feedHasNew bool // true when feedBuffer is non-empty (pulse indicator) feedMode string // "follows", "relays", or relay URL feedSelectEl dom.Element feedPopoverEl dom.Element feedPopoverOpen bool ) // ── 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. eventCacheOld map[string]*nostr.Event seenEventsOld map[string]bool noteElementsOld map[string]dom.Element noteReactionMapOld map[string]map[string]map[string]bool noteRepostCountsOld map[string]map[string]bool noteZapSeenOld map[string]map[string]bool actionFetchedIDsOld map[string]bool eventRelaysOld map[string][]string eventCacheCount int32 // Author name/pic "old" generation - two-gen rotation so GC can collect stale entries. authorNamesOld map[string]string authorPicsOld map[string]string authorCacheCount int32 // Embed "old" generation. embedCallbacksOld map[string][]string embedRequestedOld map[string]bool embedSubIDsOld map[string][]string embedCoordCBsOld map[string][]string embedRelayHintsOld map[string][]string embedCacheCount int32 // Reply "old" generation. replyCacheOld map[string]string replyAvatarCacheOld map[string]string replyLineCacheOld map[string]string replyAuthorMapOld map[string]string replyNeedNameOld map[string][]dom.Element replyHintsOld map[string]string replyCount int32 ) // rotateEventCaches swaps the event data caches and releases dropped DOM handles. func rotateEventCaches() { var toRelease []int32 for id, el := range noteElementsOld { if _, ok := noteElements[id]; !ok { toRelease = append(toRelease, int32(el)) } } if len(toRelease) > 0 { dom.ReleaseAll(toRelease) } eventCacheOld = eventCache eventCache = map[string]*nostr.Event{} seenEventsOld = seenEvents seenEvents = map[string]bool{} noteElementsOld = noteElements noteElements = map[string]dom.Element{} noteReactionMapOld = noteReactionMap noteReactionMap = map[string]map[string]map[string]bool{} noteRepostCountsOld = noteRepostCounts noteRepostCounts = map[string]map[string]bool{} noteZapSeenOld = noteZapSeen noteZapSeen = map[string]map[string]bool{} actionFetchedIDsOld = actionFetchedIDs actionFetchedIDs = map[string]bool{} eventRelaysOld = eventRelays eventRelays = map[string][]string{} noteReactionEls = map[string]dom.Element{} noteRepostEls = map[string]dom.Element{} noteZapEls = map[string]dom.Element{} 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() { authorNamesOld = authorNames authorNames = map[string]string{} authorPicsOld = authorPics authorPics = map[string]string{} authorCacheCount = 0 } // rotateEmbedCaches swaps the embed caches. func rotateEmbedCaches() { embedCallbacksOld = embedCallbacks embedCallbacks = map[string][]string{} embedRequestedOld = embedRequested embedRequested = map[string]bool{} embedSubIDsOld = embedSubIDs embedSubIDs = map[string][]string{} embedCoordCBsOld = embedCoordCBs embedCoordCBs = map[string][]string{} embedRelayHintsOld = embedRelayHints embedRelayHints = map[string][]string{} embedCacheCount = 0 } // lookupFetchedK0 checks current and old generation. func lookupFetchedK0(pk string) (ok bool) { if fetchedK0[pk] { return true } return fetchedK0Old[pk] } // setFetchedK0 sets the entry and rotates if threshold exceeded. func setFetchedK0(pk string, v bool) { fetchedK0[pk] = v if v && len(fetchedK0) > rotateFetchedMax { fetchedK0Old = fetchedK0 fetchedK0 = map[string]bool{} } } // lookupFetchedK10k checks current and old generation. func lookupFetchedK10k(pk string) (ok bool) { if fetchedK10k[pk] { return true } return fetchedK10kOld[pk] } // setFetchedK10k sets the entry and rotates if threshold exceeded. func setFetchedK10k(pk string, v bool) { fetchedK10k[pk] = v if v && len(fetchedK10k) > rotateFetchedMax { fetchedK10kOld = fetchedK10k fetchedK10k = map[string]bool{} } } // rotateReplyCaches swaps the reply caches. func rotateReplyCaches() { replyCacheOld = replyCache replyCache = map[string]string{} replyAvatarCacheOld = replyAvatarCache replyAvatarCache = map[string]string{} replyLineCacheOld = replyLineCache replyLineCache = map[string]string{} replyAuthorMapOld = replyAuthorMap replyAuthorMap = map[string]string{} replyNeedNameOld = replyNeedName replyNeedName = map[string][]dom.Element{} replyHintsOld = replyHints replyHints = map[string]string{} replyCount = 0 } // lookupAuthorName returns the cached display name, checking both generations. func lookupAuthorName(pk string) (string, bool) { if name, ok := authorNames[pk]; ok { return name, true } name, ok := authorNamesOld[pk] return name, ok } // lookupAuthorPic returns the cached avatar URL, checking both generations. func lookupAuthorPic(pk string) (string, bool) { if pic, ok := authorPics[pk]; ok { return pic, true } pic, ok := authorPicsOld[pk] return pic, ok } // lookupEvent returns the cached event, checking both generations. func lookupEvent(id string) (*nostr.Event, bool) { if ev, ok := eventCache[id]; ok { return ev, true } ev, ok := eventCacheOld[id] return ev, ok } // setEvent stores an event in the cache and rotates if the threshold is reached. func setEvent(id string, ev *nostr.Event) { if _, already := eventCache[id]; !already { eventCacheCount++ if eventCacheCount > rotateEventMax { rotateEventCaches() } } eventCache[id] = ev } // setAuthorName caches a display name, rotating if the view cache exceeds the threshold. func setAuthorName(pk, name string) { if _, already := authorNames[pk]; !already { authorCacheCount++ if authorCacheCount > rotateAuthorMax { rotateAuthorCaches() } } authorNames[pk] = name } // setAuthorPic caches an avatar URL. func setAuthorPic(pk, pic string) { authorPics[pk] = pic } const orlyRelay = "wss://relay.orly.dev" var defaultRelays = []string{ orlyRelay, "wss://nostr.wine", } 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 } func main() { dom.ConsoleLog("starting smesh " | 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 smesh relay serving this page. localRelay := localRelayURL() defaultRelays = []string{localRelay} | defaultRelays dom.ConsoleLog("local relay: " | localRelay) blossomServerURL = localstorage.GetItem(lsKeyBlossomServer) invidiousURL = localstorage.GetItem(lsKeyInvidiousURL) campionURL = localstorage.GetItem(lsKeyCampionURL) devMode = localstorage.GetItem(lsKeyDevMode) == "1" themePref := localstorage.GetItem(lsKeyTheme) if themePref != "" { isDark = themePref == "dark" } else { isDark = true } htmlEl := dom.QuerySelector("html") if isDark { dom.AddClass(htmlEl, "dark") } else { dom.RemoveClass(htmlEl, "dark") } root = dom.GetElementById("app-root") dom.SetInnerHTML(root, "") dom.SetAttribute(root, "data-version", version) savedPK := localstorage.GetItem(lsKeyPubkey) if savedPK != "" { initSigner() signer.IsInstalled(func(ok bool) { if !ok { localstorage.RemoveItem(lsKeyPubkey) showLogin() return } signer.GetVaultStatus(func(status string) { if status != "unlocked" { localstorage.RemoveItem(lsKeyPubkey) showLogin() return } signer.SwitchIdentity(savedPK, func(ok bool) { if !ok { localstorage.RemoveItem(lsKeyPubkey) showLogin() return } pubhex = savedPK bootstrapEncKey(func() { showApp() }) }) }) }) } else { showLogin() } } // --- Theme --- func toggleTheme() { el := dom.QuerySelector("html") isDark = !isDark if 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. summary is the one-line // title shown in the list; detail is multi-line text shown when the entry is // expanded (can be empty). Newest entries first. Caps at activityLogMax. func logActivity(action, summary, detail string) { e := &logEntry{ ts: dom.NowSeconds(), action: action, summary: summary, detail: detail, } activityLog = []*logEntry{e} | activityLog if len(activityLog) > activityLogMax { activityLog = activityLog[:activityLogMax] } if logListEl != 0 { prependLogEntry(e) } } // updateLogBtnHighlight sets the log button color: accent while boot is in // progress, muted otherwise. func updateLogBtnHighlight() { if logBtn == 0 { return } if bootInProgress { dom.SetStyle(logBtn, "color", "var(--accent)") } else { dom.SetStyle(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 !bootInProgress { return } bootInProgress = false logActivity("boot", "bootstrap complete", "") updateLogBtnHighlight() if 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(logListEl) if first == 0 { dom.AppendChild(logListEl, row) } else { dom.InsertBefore(logListEl, row, first) } } const svgSun = `` const svgMoon = `` func updateThemeIcon() { if themeBtn == 0 { return // not yet created (login screen) } if isDark { dom.SetInnerHTML(themeBtn, svgSun) } else { dom.SetInnerHTML(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") } func showLogin() { clearChildren(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") // Smesh 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("/smesh-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, "S.M.E.S.H.") 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(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) pubhex = hex localstorage.SetItem(lsKeyPubkey, pubhex) clearChildren(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) pubhex = hex localstorage.SetItem(lsKeyPubkey, pubhex) clearChildren(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 == 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 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 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") refreshBtn := dom.CreateElement("button") dom.SetTextContent(refreshBtn, "\u21bb") dom.SetStyle(refreshBtn, "fontSize", "14px") dom.SetStyle(refreshBtn, "background", "transparent") dom.SetStyle(refreshBtn, "border", "1px solid var(--border)") dom.SetStyle(refreshBtn, "borderRadius", "4px") dom.SetStyle(refreshBtn, "color", "var(--muted)") dom.SetStyle(refreshBtn, "cursor", "pointer") dom.SetStyle(refreshBtn, "padding", "2px 8px") dom.SetStyle(refreshBtn, "marginLeft", "4px") dom.AddEventListener(refreshBtn, "click", dom.RegisterCallback(func() { dom.HardRefresh() })) dom.AppendChild(optBox, refreshBtn) 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 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() { devMode = !devMode if 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(feedContainer) stopInlineMedia(threadContainer) } if page == "search" { routeMsg("[\"CLOSE\",\"search\"]") searchSeen = map[string]bool{} // Always restore topBarRight and other hidden elements on exit. if searchBarActive { deactivateSearchBar(searchBtnGlobal) } } if page == "notifications" { // Cancel pending timers so their closures don't fire after page exit. if notifMoreTimer != 0 { dom.ClearTimeout(notifMoreTimer) notifMoreTimer = 0 } if notifRefTimer != 0 { dom.ClearTimeout(notifRefTimer) notifRefTimer = 0 } // Release accumulated notif row callbacks to prevent indefinite // growth of the Moxie callback table across multiple page visits. releaseNotifRowCBs() notifBuilt = false // Clear avatar pending queue; the DOM elements are gone. for pk := range pendingNotifAvatars { delete(pendingNotifAvatars, pk) } } } func switchPage(name string) { closeFeedPopover() // Always close thread if open, even when re-selecting the feed tab. if threadOpen { closeNoteThread() if !navPop { dom.ReplaceState("/") } } if name == activePage { return } teardownPage(activePage) closeProfileNoteSub() if activePage == "profile" { profileViewPK = "" } activePage = name // Ensure top bar is in normal state. dom.SetStyle(topBackBtn, "display", "none") dom.SetStyle(feedSelectEl, "display", "none") dom.SetStyle(pageTitleEl, "display", "none") dom.SetStyle(notifBarGroup, "display", "none") if name == "feed" { dom.SetStyle(feedSelectEl, "display", "inline") } else if name == "notifications" { dom.SetStyle(notifBarGroup, "display", "flex") } else { dom.SetTextContent(pageTitleEl, t(name)) dom.SetStyle(pageTitleEl, "display", "inline") } // Hide all pages. dom.SetStyle(feedPage, "display", "none") dom.SetStyle(msgPage, "display", "none") dom.SetStyle(profilePage, "display", "none") dom.SetStyle(settingsPage, "display", "none") dom.SetStyle(composePage, "display", "none") dom.SetStyle(aboutPage, "display", "none") dom.SetStyle(notifPage, "display", "none") dom.SetStyle(searchPage, "display", "none") if logPage != 0 { dom.SetStyle(logPage, "display", "none") } // Clear sidebar highlights. dom.SetStyle(sidebarCompose, "background", "transparent") dom.SetStyle(sidebarCompose, "color", "var(--fg)") dom.SetStyle(sidebarFeed, "background", "transparent") dom.SetStyle(sidebarFeed, "color", "var(--fg)") dom.SetStyle(sidebarMsg, "background", "transparent") dom.SetStyle(sidebarMsg, "color", "var(--fg)") dom.SetStyle(sidebarNotif, "background", "transparent") dom.SetStyle(sidebarNotif, "color", "var(--fg)") dom.SetStyle(sidebarSettings, "background", "transparent") dom.SetStyle(sidebarSettings, "color", "var(--fg)") switch name { case "compose": dom.SetStyle(composePage, "display", "flex") dom.SetStyle(composePage, "flexDirection", "column") dom.SetStyle(sidebarCompose, "background", "var(--accent)") dom.SetStyle(sidebarCompose, "color", "#fff") if !navPop { dom.PushState("/compose") } case "feed": dom.SetStyle(feedPage, "display", "block") dom.SetStyle(sidebarFeed, "background", "var(--accent)") dom.SetStyle(sidebarFeed, "color", "#fff") if !navPop { dom.PushState(feedModeURL()) } case "messaging": dom.SetStyle(msgPage, "display", "block") dom.SetStyle(sidebarMsg, "background", "var(--accent)") dom.SetStyle(sidebarMsg, "color", "#fff") initMessaging() if !navPop { dom.PushState("/msg") } case "notifications": dom.SetStyle(notifPage, "display", "block") dom.SetStyle(sidebarNotif, "background", "var(--accent)") dom.SetStyle(sidebarNotif, "color", "#fff") notifUnread = false dom.SetStyle(notifDot, "display", "none") dom.SetProperty(contentArea, "scrollTop", "0") if !notifBuilt { renderNotifPage() } if !navPop { dom.PushState("/notifications") } case "settings": dom.SetStyle(settingsPage, "display", "block") dom.SetStyle(sidebarSettings, "background", "var(--accent)") dom.SetStyle(sidebarSettings, "color", "#fff") renderSettings() if !navPop { dom.PushState("/settings") } case "profile": dom.SetStyle(profilePage, "display", "block") dom.SetStyle(pageTitleEl, "display", "none") dom.SetStyle(topBackBtn, "display", "inline") // Profile URL is pushed by showProfile, not here. case "about": dom.SetStyle(aboutPage, "display", "block") if !navPop { dom.PushState("/about") } case "log": dom.SetStyle(logPage, "display", "block") dom.SetTextContent(pageTitleEl, "activity log") dom.SetStyle(pageTitleEl, "display", "inline") if !navPop { dom.PushState("/log") } case "search": dom.SetStyle(searchPage, "display", "block") dom.SetStyle(pageTitleEl, "display", "none") // Restore search input value without activating the bar (topBarRight // stays visible). Bar is only activated by explicit user action. if searchCurrentQ != "" && searchBtnGlobal != 0 { dom.SetProperty(searchInputEl, "value", searchCurrentQ) } if !navPop { if searchCurrentQ != "" { dom.PushState("/search?q=" | dom.EncodeURIComponent(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 hash := "" for i := 0; i < len(fullPath); i++ { if fullPath[i] == '#' { path = fullPath[:i] hash = fullPath[i+1:] break } } feedPath := false newFeedMode := "" if path == "/" || path == "/feed" || path == "" || path == "/feed/follows" { feedPath = true newFeedMode = "follows" } else if path == "/feed/relays" { feedPath = true newFeedMode = "relays" } else if len(path) > 12 && path[:12] == "/feed/relay/" { feedPath = true host := path[12:] // Local addresses use ws://, remote use wss://. if len(host) > 4 && host[:4] == "127." || len(host) > 10 && host[:10] == "localhost:" { newFeedMode = "ws://" | host } else { newFeedMode = "wss://" | host } } if feedPath { modeChanged := newFeedMode != feedMode feedMode = newFeedMode if feedSelectEl != 0 { updateFeedBtnText() } if threadOpen { closeNoteThread() } switchPage("feed") if modeChanged { refreshFeed() } } else if len(path) > 3 && path[:3] == "/t/" { switchPage("feed") rootID := path[3:] if len(rootID) == 64 { focusID := rootID hash := dom.GetHash() if len(hash) == 65 && hash[0] == '#' { focusID = hash[1:] } showNoteThread(rootID, focusID) } } else if path == "/compose" { switchPage("compose") } else if path == "/settings" { switchPage("settings") } else if path == "/msg" { switchPage("messaging") if msgView == "thread" { closeThread() } } else if len(path) > 5 && path[:5] == "/msg/" { pk := npubToHex(path[5:]) if pk != "" { switchPage("messaging") openThread(pk) } } 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 hash != "" { selectProfileTab(hash, pk) } } } } func npubToHex(npub string) (s string) { b := helpers.DecodeNpub(npub) if b == nil { return "" } return helpers.HexEncode(b) } func initRouter() { if routerInited { return } routerInited = true dom.OnPopState(func(path string) { navPop = true navigateToPath(path) navPop = false }) dom.InterceptInternalLinks(func(path string) { navigateToPath(path) }) // Navigate to initial URL if not root. path := dom.GetPath() if path != "/" && path != "" { navPop = true navigateToPath(path) navPop = false } else { dom.ReplaceState(feedModeURL()) } } 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 } // --- Main app --- func showApp() { dom.SetStyle(dom.Body(), "overflow", "hidden") appReady = false bootInProgress = true activityLog = nil seenOnSubs = map[string]func(){} logActivity("boot", "bootstrap start " | version, "pubkey: " | 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. authorNames = map[string]string{} authorPics = map[string]string{} pendingNotes = map[string][]dom.Element{} pendingNotifAvatars = map[string][]dom.Element{} seenEvents = map[string]bool{} fetchedK0 = map[string]bool{} fetchedK0Old = map[string]bool{} fetchedK10k = map[string]bool{} fetchedK10kOld = map[string]bool{} fetchQueue = nil fetchTimer = 0 retryRound = 0 authorSubPK = map[string]string{} eventRelays = map[string][]string{} eventRelaysOld = map[string][]string{} pendingDeletes = map[string]string{} noteElements = map[string]dom.Element{} myFollows = nil myMutes = nil followSet = map[string]bool{} muteSet = map[string]bool{} profileContentCache = map[string]string{} profileFollowsCache = map[string][]string{} profileMutesCache = map[string][]string{} profileRelaysCache = map[string][]string{} profileNotesSeen = map[string]bool{} profileTabBtns = map[string]dom.Element{} profileWrappers = map[string]dom.Element{} profileWrapTabCont = map[string]dom.Element{} profileWrapTabs = map[string]map[string]dom.Element{} profileWrapTabSel = map[string]string{} profileWrapOrder = nil embedCallbacks = map[string][]string{} embedRelayHints = map[string][]string{} embedRequested = map[string]bool{} embedSubIDs = map[string][]string{} embedCoordCBs = map[string][]string{} embedCoordSubIDs = map[string]string{} threadEvents = map[string]*nostr.Event{} replyCache = map[string]string{} replyAvatarCache = map[string]string{} replyLineCache = map[string]string{} replyAuthorMap = map[string]string{} replyAuthorMapOld = map[string]string{} replyPending = map[string][]dom.Element{} replyHints = map[string]string{} replyNeedName = map[string][]dom.Element{} replyNeedNameOld = map[string][]dom.Element{} noteRepostCounts = map[string]map[string]bool{} noteReactionMap = map[string]map[string]map[string]bool{} noteReactionEls = map[string]dom.Element{} noteRepostEls = map[string]dom.Element{} noteZapTotals = map[string]int64{} noteZapSeen = map[string]map[string]bool{} noteZapEls = map[string]dom.Element{} pendingZapRows = map[string][]zapRowPending{} actionFetchedIDs = map[string]bool{} eventCache = map[string]*nostr.Event{} // Initialize "old" generation maps so range over nil is safe on first rotate. eventCacheOld = map[string]*nostr.Event{} seenEventsOld = map[string]bool{} noteElementsOld = map[string]dom.Element{} noteReactionMapOld = map[string]map[string]map[string]bool{} noteRepostCountsOld = map[string]map[string]bool{} noteZapSeenOld = map[string]map[string]bool{} actionFetchedIDsOld = map[string]bool{} authorNamesOld = map[string]string{} authorPicsOld = map[string]string{} embedCallbacksOld = map[string][]string{} embedRequestedOld = map[string]bool{} embedSubIDsOld = map[string][]string{} embedCoordCBsOld = map[string][]string{} replyCacheOld = map[string]string{} replyAvatarCacheOld = map[string]string{} replyLineCacheOld = map[string]string{} eventCacheCount = 0 authorCacheCount = 0 embedCacheCount = 0 replyCount = 0 editorDrafts = map[string]string{} notifSeen = map[string]bool{} notifEvents = []*nostr.Event{:0} releaseNotifRowCBs() notifUnread = false notifBuilt = false dom.IDBGet("settings", "notif-read-ts", func(val string) { if val != "" { notifReadTs = parseI64(val) } }) if feedMode == "" { feedMode = "follows" } feedInitialLoad = true 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 !routerInited { dom.OnSWMessage(onSWMessage) relayproxy.OnMessage(onSWMessage) profile.OnMessage(onSWMessage) feed.OnMessage(onSWMessage) mlsw.OnMessage(onSWMessage) notif.OnMessage(onSWMessage) } routeMsg("[\"SET_PUBKEY\"," | jstr(pubhex) | "]") sendEncKeyToSW() // Sync pubkey to MLS worker. mlsw.Send("[\"M_SET_PUBKEY\"," | jstr(pubhex) | "]") // 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) } profileContentCache[key] = val idbLoadedPKs = append(idbLoadedPKs, key) }, func() { idbLoaded = true // Fill pending note headers rendered before IDB finished. for pk, headers := range pendingNotes { name, nameOK := lookupAuthorName(pk) if !nameOK || len(name) == 0 { continue } pic, _ := lookupAuthorPic(pk) for _, h := range headers { updateNoteHeader(h, name, pic) } delete(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 profileViewPK != "" && activePage == "profile" { renderProfilePage(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("/smesh-logo.svg", func(svg string) { 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 aboutLogoEl != 0 { dom.SetInnerHTML(aboutLogoEl, svg) al := dom.FirstElementChild(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) topBackBtn = dom.CreateElement("span") dom.SetStyle(topBackBtn, "display", "none") dom.SetStyle(topBackBtn, "cursor", "pointer") dom.SetStyle(topBackBtn, "color", "var(--accent)") dom.SetStyle(topBackBtn, "fontSize", "18px") dom.SetStyle(topBackBtn, "fontWeight", "bold") dom.SetTextContent(topBackBtn, t("back")) dom.AddEventListener(topBackBtn, "click", dom.RegisterCallback(func() { dom.Back() })) dom.AppendChild(left, topBackBtn) // Feed selector button (opens popup). Absolute-positioned so it sits at // the horizontal center of the top bar, independent of logo/back width. feedSelectEl = dom.CreateElement("button") dom.SetStyle(feedSelectEl, "position", "absolute") dom.SetStyle(feedSelectEl, "left", "50%") dom.SetStyle(feedSelectEl, "top", "50%") dom.SetStyle(feedSelectEl, "transform", "translate(-50%, -50%)") dom.SetStyle(feedSelectEl, "fontSize", "16px") dom.SetStyle(feedSelectEl, "fontWeight", "bold") dom.SetStyle(feedSelectEl, "background", "transparent") dom.SetStyle(feedSelectEl, "border", "none") dom.SetStyle(feedSelectEl, "color", "var(--fg)") dom.SetStyle(feedSelectEl, "cursor", "pointer") dom.SetStyle(feedSelectEl, "outline", "none") dom.SetStyle(feedSelectEl, "maxWidth", "200px") dom.SetStyle(feedSelectEl, "padding", "4px 8px") dom.SetStyle(feedSelectEl, "borderRadius", "4px") updateFeedBtnText() dom.AddEventListener(feedSelectEl, "click", dom.RegisterCallback(func() { toggleFeedPopover() })) dom.AppendChild(bar, feedSelectEl) notifFilter = "all" notifBarGroup = dom.CreateElement("div") dom.SetStyle(notifBarGroup, "position", "absolute") dom.SetStyle(notifBarGroup, "left", "50%") dom.SetStyle(notifBarGroup, "top", "50%") dom.SetStyle(notifBarGroup, "transform", "translate(-50%, -50%)") dom.SetStyle(notifBarGroup, "display", "none") dom.SetStyle(notifBarGroup, "alignItems", "center") dom.SetStyle(notifBarGroup, "gap", "8px") notifSelectEl = dom.CreateElement("select") dom.SetStyle(notifSelectEl, "fontSize", "13px") dom.SetStyle(notifSelectEl, "background", "var(--bg)") dom.SetStyle(notifSelectEl, "border", "1px solid var(--border)") dom.SetStyle(notifSelectEl, "borderRadius", "4px") dom.SetStyle(notifSelectEl, "color", "var(--fg)") dom.SetStyle(notifSelectEl, "cursor", "pointer") dom.SetStyle(notifSelectEl, "outline", "none") dom.SetStyle(notifSelectEl, "padding", "3px 6px") dom.SetStyle(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(notifSelectEl, o) } dom.AddEventListener(notifSelectEl, "change", dom.RegisterCallback(func() { notifFilter = dom.GetProperty(notifSelectEl, "value") rebuildNotifRows() })) dom.AppendChild(notifBarGroup, notifSelectEl) notifMarkBtn = dom.CreateElement("button") dom.SetTextContent(notifMarkBtn, "read") dom.SetStyle(notifMarkBtn, "fontSize", "12px") dom.SetStyle(notifMarkBtn, "padding", "3px 8px") dom.SetStyle(notifMarkBtn, "border", "1px solid var(--border)") dom.SetStyle(notifMarkBtn, "borderRadius", "4px") dom.SetStyle(notifMarkBtn, "background", "transparent") dom.SetStyle(notifMarkBtn, "color", "var(--fg)") dom.SetStyle(notifMarkBtn, "cursor", "pointer") dom.SetStyle(notifMarkBtn, "fontFamily", "'Fira Code', monospace") dom.AddEventListener(notifMarkBtn, "click", dom.RegisterCallback(func() { markNotifsRead() })) dom.AppendChild(notifBarGroup, notifMarkBtn) dom.AppendChild(bar, notifBarGroup) pageTitleEl = dom.CreateElement("span") dom.SetStyle(pageTitleEl, "fontSize", "18px") dom.SetStyle(pageTitleEl, "fontWeight", "bold") dom.SetStyle(pageTitleEl, "display", "none") dom.AppendChild(left, pageTitleEl) topBarLeft = left dom.AppendChild(bar, left) // Right: wallet + signer (gear) icon buttons. topBarRight = dom.CreateElement("div") right := 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") walletBtnEl = dom.CreateElement("button") dom.SetInnerHTML(walletBtnEl, ``) dom.SetAttribute(walletBtnEl, "title", "wallet") dom.SetStyle(walletBtnEl, "background", "transparent") dom.SetStyle(walletBtnEl, "border", "none") dom.SetStyle(walletBtnEl, "color", "var(--muted)") dom.SetStyle(walletBtnEl, "height", "32px") dom.SetStyle(walletBtnEl, "width", "32px") dom.SetStyle(walletBtnEl, "display", "flex") dom.SetStyle(walletBtnEl, "alignItems", "center") dom.SetStyle(walletBtnEl, "justifyContent", "center") dom.SetStyle(walletBtnEl, "cursor", "pointer") dom.SetStyle(walletBtnEl, "borderRadius", "4px") dom.AddEventListener(walletBtnEl, "click", dom.RegisterCallback(func() { toggleWalletPanel() })) // Wallet moves to bottom-bar icon group; refreshBtn lives here in top-right. _ = walletBtnEl dom.AppendChild(bar, right) dom.AppendChild(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") sidebarCompose = makeSidebarIcon(svgCompose, false) dom.AddEventListener(sidebarCompose, "click", dom.RegisterCallback(func() { switchPage("compose") })) sidebarFeed = makeSidebarIcon(svgFeed, true) dom.AddEventListener(sidebarFeed, "click", dom.RegisterCallback(func() { switchPage("feed") })) sidebarMsg = makeSidebarIcon(svgChat, false) dom.AddEventListener(sidebarMsg, "click", dom.RegisterCallback(func() { switchPage("messaging") })) sidebarNotif = dom.CreateElement("button") dom.SetStyle(sidebarNotif, "width", "36px") dom.SetStyle(sidebarNotif, "height", "36px") dom.SetStyle(sidebarNotif, "border", "none") dom.SetStyle(sidebarNotif, "borderRadius", "6px") dom.SetStyle(sidebarNotif, "cursor", "pointer") dom.SetStyle(sidebarNotif, "display", "flex") dom.SetStyle(sidebarNotif, "alignItems", "center") dom.SetStyle(sidebarNotif, "justifyContent", "center") dom.SetStyle(sidebarNotif, "padding", "0") dom.SetStyle(sidebarNotif, "background", "transparent") dom.SetStyle(sidebarNotif, "color", "var(--fg)") dom.SetStyle(sidebarNotif, "position", "relative") dom.SetInnerHTML(sidebarNotif, svgBell) notifDot = dom.CreateElement("span") dom.SetStyle(notifDot, "position", "absolute") dom.SetStyle(notifDot, "top", "4px") dom.SetStyle(notifDot, "right", "5px") dom.SetStyle(notifDot, "width", "8px") dom.SetStyle(notifDot, "height", "8px") dom.SetStyle(notifDot, "borderRadius", "50%") dom.SetStyle(notifDot, "background", "var(--accent)") dom.SetStyle(notifDot, "display", "none") dom.AppendChild(sidebarNotif, notifDot) dom.AddEventListener(sidebarNotif, "click", dom.RegisterCallback(func() { switchPage("notifications") })) sidebarSettings = makeSidebarIcon(svgGear, false) dom.AddEventListener(sidebarSettings, "click", dom.RegisterCallback(func() { switchPage("settings") })) themeBtn = dom.CreateElement("button") dom.SetStyle(themeBtn, "background", "transparent") dom.SetStyle(themeBtn, "border", "none") dom.SetStyle(themeBtn, "width", "32px") dom.SetStyle(themeBtn, "height", "32px") dom.SetStyle(themeBtn, "cursor", "pointer") dom.SetStyle(themeBtn, "padding", "0") dom.SetStyle(themeBtn, "display", "flex") dom.SetStyle(themeBtn, "alignItems", "center") dom.SetStyle(themeBtn, "justifyContent", "center") dom.SetStyle(themeBtn, "color", "var(--muted)") updateThemeIcon() dom.AddEventListener(themeBtn, "click", dom.RegisterCallback(func() { toggleTheme() })) // Content area. contentArea = dom.CreateElement("div") dom.SetStyle(contentArea, "flex", "1") dom.SetStyle(contentArea, "minHeight", "0") dom.SetStyle(contentArea, "overflowY", "auto") // Feed page. feedPage = dom.CreateElement("div") dom.SetStyle(feedPage, "display", "none") dom.SetStyle(feedPage, "padding", "16px") dom.SetStyle(feedPage, "maxWidth", "640px") dom.SetStyle(feedPage, "margin", "0 auto") dom.SetStyle(feedPage, "boxSizing", "border-box") // Loading spinner - shown until first feed event arrives. feedLoader = dom.CreateElement("div") dom.SetStyle(feedLoader, "display", "flex") dom.SetStyle(feedLoader, "flexDirection", "column") dom.SetStyle(feedLoader, "alignItems", "center") dom.SetStyle(feedLoader, "justifyContent", "center") dom.SetStyle(feedLoader, "padding", "64px 0") loaderImg := dom.CreateElement("div") dom.SetStyle(loaderImg, "width", "120px") dom.SetStyle(loaderImg, "height", "120px") dom.FetchText("/smesh-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(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(feedLoader, loaderText) dom.AppendChild(feedPage, 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(feedPage, pullIndicator) feedContainer = dom.CreateElement("div") dom.AppendChild(feedPage, feedContainer) // "Load more" button - fallback for infinite scroll. loadMoreBtn = dom.CreateElement("div") dom.SetStyle(loadMoreBtn, "display", "none") dom.SetStyle(loadMoreBtn, "textAlign", "center") dom.SetStyle(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 !feedLoading && !feedExhausted && oldestFeedTs > 0 { dom.SetStyle(loadMoreBtn, "display", "none") loadOlderFeed() } })) dom.AppendChild(loadMoreBtn, loadMoreLink) // Thread view (hidden overlay within feedPage). threadPage = dom.CreateElement("div") dom.SetStyle(threadPage, "display", "none") threadContainer = dom.CreateElement("div") dom.AppendChild(threadPage, threadContainer) dom.AppendChild(feedPage, threadPage) // Load-more after thread so it sits at the bottom in both views. dom.AppendChild(feedPage, loadMoreBtn) dom.AppendChild(contentArea, feedPage) // Pull-to-refresh: wheel up or touch pull at top triggers feed reload. dom.OnPullRefresh(contentArea, pullIndicator, func() { if activePage == "feed" && !refreshSpinning { onRefreshClick() dom.SetTimeout(func() { dom.SetStyle(pullIndicator, "display", "none") }, 1500) } else if activePage == "notifications" { dom.SetProperty(contentArea, "scrollTop", "0") dom.SetTimeout(func() { dom.SetStyle(pullIndicator, "display", "none") }, 1500) } }) // Infinite scroll - load older events when near bottom. dom.AddEventListener(contentArea, "scroll", dom.RegisterCallback(func() { st := dom.GetProperty(contentArea, "scrollTop") ch := dom.GetProperty(contentArea, "clientHeight") sh := dom.GetProperty(contentArea, "scrollHeight") top := parseIntProp(st) height := parseIntProp(ch) total := parseIntProp(sh) nearBottom := top+height >= total-400 if activePage == "feed" { if top <= 10 && len(feedBuffer) > 0 { for _, bev := range feedBuffer { renderNote(bev) seenEvents[bev.ID] = true } feedBuffer = nil feedHasNew = false stopRefreshPulse() } if feedLoading || feedExhausted || oldestFeedTs == 0 { return } if nearBottom { loadOlderFeed() } } else if activePage == "notifications" { if notifLoading || notifExhausted || oldestNotifTs == 0 { return } if nearBottom { loadOlderNotifs() } } else if activePage == "search" { if nearBottom { appendSearchResults() } } })) // Messaging page. msgPage = dom.CreateElement("div") dom.SetStyle(msgPage, "padding", "16px") dom.SetStyle(msgPage, "display", "none") dom.SetStyle(msgPage, "position", "relative") dom.SetStyle(msgPage, "height", "100%") dom.SetStyle(msgPage, "boxSizing", "border-box") // Conversation list view. msgListContainer = dom.CreateElement("div") dom.AppendChild(msgPage, msgListContainer) // Thread view (hidden by default). msgThreadContainer = dom.CreateElement("div") dom.SetStyle(msgThreadContainer, "display", "none") dom.SetStyle(msgThreadContainer, "flexDirection", "column") dom.SetStyle(msgThreadContainer, "position", "absolute") dom.SetStyle(msgThreadContainer, "top", "0") dom.SetStyle(msgThreadContainer, "left", "0") dom.SetStyle(msgThreadContainer, "right", "0") dom.SetStyle(msgThreadContainer, "bottom", "0") dom.SetStyle(msgThreadContainer, "background", "var(--bg)") dom.AppendChild(msgPage, msgThreadContainer) msgView = "list" dom.AppendChild(contentArea, msgPage) // Profile page. profilePage = dom.CreateElement("div") dom.SetStyle(profilePage, "display", "none") dom.SetStyle(profilePage, "padding", "16px") dom.SetStyle(profilePage, "maxWidth", "640px") dom.SetStyle(profilePage, "margin", "0 auto") dom.SetStyle(profilePage, "boxSizing", "border-box") dom.AppendChild(contentArea, profilePage) // Settings page. settingsPage = dom.CreateElement("div") dom.SetStyle(settingsPage, "display", "none") dom.SetStyle(settingsPage, "padding", "16px") dom.SetStyle(settingsPage, "maxWidth", "640px") dom.SetStyle(settingsPage, "margin", "0 auto") dom.SetStyle(settingsPage, "boxSizing", "border-box") dom.AppendChild(contentArea, settingsPage) // Compose page. composePage = dom.CreateElement("div") dom.SetStyle(composePage, "display", "none") dom.SetStyle(composePage, "padding", "16px") dom.SetStyle(composePage, "maxWidth", "640px") dom.SetStyle(composePage, "margin", "0 auto") dom.SetStyle(composePage, "minHeight", "100%") dom.SetStyle(composePage, "boxSizing", "border-box") buildComposePage() dom.AppendChild(contentArea, composePage) // About page. aboutPage = dom.CreateElement("div") dom.SetStyle(aboutPage, "display", "none") dom.SetStyle(aboutPage, "padding", "24px 16px") dom.SetStyle(aboutPage, "maxWidth", "640px") dom.SetStyle(aboutPage, "margin", "0 auto") dom.SetStyle(aboutPage, "boxSizing", "border-box") dom.SetStyle(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(aboutPage, aRefresh) aboutLogoEl = dom.CreateElement("div") dom.SetStyle(aboutLogoEl, "width", "180px") dom.SetStyle(aboutLogoEl, "height", "180px") dom.SetStyle(aboutLogoEl, "margin", "0 auto 16px auto") dom.SetStyle(aboutLogoEl, "color", "var(--accent)") if logoSVGCache != "" { dom.SetInnerHTML(aboutLogoEl, logoSVGCache) aLogoSvg := dom.FirstElementChild(aboutLogoEl) if aLogoSvg != 0 { dom.SetAttribute(aLogoSvg, "width", "100%") dom.SetAttribute(aLogoSvg, "height", "100%") } } dom.AppendChild(aboutPage, aboutLogoEl) aTitle := dom.CreateElement("h2") dom.SetTextContent(aTitle, "S.M.E.S.H. " | version) dom.SetStyle(aTitle, "fontSize", "20px") dom.SetStyle(aTitle, "margin", "0 0 20px 0") dom.SetStyle(aTitle, "color", "var(--accent)") dom.AppendChild(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(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(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(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(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(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(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 smesh signer extension on android without the mozilla security theatre") dom.AppendChild(aboutPage, feliceDesc) dom.AppendChild(contentArea, aboutPage) // Activity log page. logPage = dom.CreateElement("div") dom.SetStyle(logPage, "display", "none") dom.SetStyle(logPage, "padding", "12px") dom.SetStyle(logPage, "maxWidth", "780px") dom.SetStyle(logPage, "margin", "0 auto") dom.SetStyle(logPage, "boxSizing", "border-box") dom.SetStyle(logPage, "fontFamily", "'Fira Code', monospace") dom.SetStyle(logPage, "fontSize", "12px") logListEl = dom.CreateElement("div") dom.AppendChild(logPage, logListEl) dom.AppendChild(contentArea, logPage) // Pre-populate with any entries that accumulated during boot before this DOM was built. for i := len(activityLog) - 1; i >= 0; i-- { prependLogEntry(activityLog[i]) } // Notifications page. notifPage = dom.CreateElement("div") dom.SetStyle(notifPage, "display", "none") dom.SetStyle(notifPage, "padding", "16px") dom.SetStyle(notifPage, "maxWidth", "640px") dom.SetStyle(notifPage, "margin", "0 auto") dom.SetStyle(notifPage, "boxSizing", "border-box") notifContainer = dom.CreateElement("div") dom.AppendChild(notifPage, notifContainer) dom.AppendChild(contentArea, notifPage) // Search page: flex-column so filter bar stays above scrolling results. searchPage = dom.CreateElement("div") dom.SetStyle(searchPage, "display", "none") dom.SetStyle(searchPage, "maxWidth", "640px") dom.SetStyle(searchPage, "margin", "0 auto") dom.SetStyle(searchPage, "width", "100%") // searchFilterBarEl and searchContainer are appended dynamically by buildSearchFilterBar/executeSearch. searchContainer = dom.CreateElement("div") dom.AppendChild(searchPage, searchContainer) dom.AppendChild(contentArea, searchPage) dom.AppendChild(mainLayout, contentArea) dom.AppendChild(root, mainLayout) activePage = "log" dom.SetStyle(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(userBtn) != 0 { dom.AppendChild(iconGroup, userBtn) } dom.AppendChild(iconGroup, sidebarCompose) dom.AppendChild(iconGroup, sidebarFeed) dom.AppendChild(iconGroup, sidebarMsg) dom.AppendChild(iconGroup, sidebarNotif) dom.AppendChild(iconGroup, sidebarSettings) _ = walletBtnEl _ = themeBtn dom.AppendChild(bottomBar, iconGroup) // Activity log button - opens the log page. Highlighted while boot is in progress. logBtn = dom.CreateElement("button") dom.SetInnerHTML(logBtn, ``) dom.SetAttribute(logBtn, "title", "activity log") dom.SetStyle(logBtn, "background", "transparent") dom.SetStyle(logBtn, "border", "none") dom.SetStyle(logBtn, "color", "var(--muted)") dom.SetStyle(logBtn, "width", "32px") dom.SetStyle(logBtn, "height", "32px") dom.SetStyle(logBtn, "cursor", "pointer") dom.SetStyle(logBtn, "padding", "0") dom.SetStyle(logBtn, "display", "flex") dom.SetStyle(logBtn, "alignItems", "center") dom.SetStyle(logBtn, "justifyContent", "center") dom.AddEventListener(logBtn, "click", dom.RegisterCallback(func() { switchPage("log") })) dom.AppendChild(topBarRight, logBtn) updateLogBtnHighlight() // Search button + input row - leftmost item in topBarRight. searchSortDesc = true 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") searchInputEl = dom.CreateElement("input") dom.SetAttribute(searchInputEl, "type", "text") dom.SetAttribute(searchInputEl, "placeholder", "#tag or text search") dom.SetStyle(searchInputEl, "display", "none") dom.SetStyle(searchInputEl, "flex", "1") dom.SetStyle(searchInputEl, "minWidth", "0") dom.SetStyle(searchInputEl, "background", "var(--bg)") dom.SetStyle(searchInputEl, "color", "var(--fg)") dom.SetStyle(searchInputEl, "border", "1px solid var(--border)") dom.SetStyle(searchInputEl, "borderRadius", "4px") dom.SetStyle(searchInputEl, "padding", "4px 8px") dom.SetStyle(searchInputEl, "fontSize", "14px") dom.SetStyle(searchInputEl, "fontFamily", "'Fira Code', monospace") searchSubmitBtn = dom.CreateElement("button") dom.SetTextContent(searchSubmitBtn, "\xe2\x86\x92") // → dom.SetStyle(searchSubmitBtn, "display", "none") dom.SetStyle(searchSubmitBtn, "background", "var(--accent)") dom.SetStyle(searchSubmitBtn, "color", "#fff") dom.SetStyle(searchSubmitBtn, "border", "none") dom.SetStyle(searchSubmitBtn, "borderRadius", "4px") dom.SetStyle(searchSubmitBtn, "padding", "4px 10px") dom.SetStyle(searchSubmitBtn, "cursor", "pointer") dom.SetStyle(searchSubmitBtn, "fontSize", "16px") dom.SetStyle(searchSubmitBtn, "flexShrink", "0") // 🔍 toggles search: click to open (highlighted), click again to close. searchBtnGlobal = searchBtn capturedSearchBtn := searchBtn dom.AddEventListener(searchBtn, "click", dom.RegisterCallback(func() { if searchBarActive { closeSearch(capturedSearchBtn) } else { activateSearchBar(capturedSearchBtn) } })) submitSearch := func() { q := dom.GetProperty(searchInputEl, "value") if q != "" { executeSearch(q) } } capturedSubmit := submitSearch dom.AddEventListener(searchSubmitBtn, "click", dom.RegisterCallback(capturedSubmit)) dom.OnKeydown(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(topBarLeft, searchBtn) dom.AppendChild(topBarLeft, searchInputEl) dom.AppendChild(topBarLeft, searchSubmitBtn) // Load saved relays; fall back to defaults on first run. if !loadRelayList() { logActivity("boot", "loadRelayList empty, using defaults", "") for _, url := range defaultRelays { addRelay(url, false) } } if len(relayURLs) == 0 { logActivity("boot", "relayURLs still empty, forcing defaults", "") for _, url := range defaultRelays { addRelay(url, false) } } { relayList := "" for i, u := range relayURLs { if i > 0 { relayList = relayList | "\n" } relayList = relayList | u } logActivity("boot", "loaded " | itoa(len(relayURLs)) | " relays", relayList) } // Tell SW about relays and subscribe. sendWriteRelays() subscribeProfile() logActivity("boot", "subscribed to own profile", "kinds: 0,3,10002,10000,10050 for " | 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. feedDeferTimer = dom.SetTimeout(func() { feedDeferTimer = 0 if !feedSubscribed { 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(contentArea, "scrollTop", "0") })) dom.AppendChild(dom.Body(), scrollTopBtn) dom.AddEventListener(contentArea, "scroll", dom.RegisterCallback(func() { st := parseIntProp(dom.GetProperty(contentArea, "scrollTop")) show := st > 300 && (activePage == "feed" || activePage == "notifications" || activePage == "thread") if show { dom.SetStyle(scrollTopBtn, "display", "inline-flex") } else { dom.SetStyle(scrollTopBtn, "display", "none") } })) 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) { bottomBar = dom.CreateElement("div") dom.SetStyle(bottomBar, "position", "fixed") dom.SetStyle(bottomBar, "bottom", "0") dom.SetStyle(bottomBar, "left", "0") dom.SetStyle(bottomBar, "right", "0") dom.SetStyle(bottomBar, "height", "36px") dom.SetStyle(bottomBar, "display", "flex") dom.SetStyle(bottomBar, "alignItems", "center") dom.SetStyle(bottomBar, "padding", "0 6px") dom.SetStyle(bottomBar, "gap", "8px") dom.SetStyle(bottomBar, "background", "var(--bg2)") dom.SetStyle(bottomBar, "fontSize", "12px") dom.SetStyle(bottomBar, "color", "var(--fg)") dom.SetStyle(bottomBar, "borderTop", "none") dom.SetStyle(bottomBar, "zIndex", "100") if withUser { userBtn = dom.CreateElement("button") dom.SetStyle(userBtn, "display", "flex") dom.SetStyle(userBtn, "alignItems", "center") dom.SetStyle(userBtn, "justifyContent", "center") dom.SetStyle(userBtn, "padding", "0") dom.SetStyle(userBtn, "background", "transparent") dom.SetStyle(userBtn, "border", "none") dom.SetStyle(userBtn, "borderRadius", "6px") dom.SetStyle(userBtn, "cursor", "pointer") dom.SetStyle(userBtn, "width", "36px") dom.SetStyle(userBtn, "height", "36px") avatarEl = dom.CreateElement("img") dom.SetAttribute(avatarEl, "referrerpolicy", "no-referrer") dom.SetAttribute(avatarEl, "width", "24") dom.SetAttribute(avatarEl, "height", "24") dom.SetStyle(avatarEl, "borderRadius", "50%") dom.SetStyle(avatarEl, "objectFit", "cover") dom.SetStyle(avatarEl, "display", "block") dom.SetAttribute(avatarEl, "onerror", "this.style.display='none'") dom.AppendChild(userBtn, avatarEl) // nameEl kept (exists for other code paths) but detached - username // no longer appears in the status bar. nameEl = dom.CreateElement("span") dom.AddEventListener(userBtn, "click", dom.RegisterCallback(func() { toggleSignerPanel() })) // userBtn is appended to iconGroup by showApp. } statusEl = dom.CreateElement("button") dom.SetTextContent(statusEl, t("connecting")) dom.SetStyle(statusEl, "fontFamily", "'Fira Code', monospace") dom.SetStyle(statusEl, "fontSize", "12px") dom.SetStyle(statusEl, "background", "transparent") dom.SetStyle(statusEl, "border", "none") dom.SetStyle(statusEl, "color", "var(--muted)") dom.SetStyle(statusEl, "cursor", "pointer") dom.SetStyle(statusEl, "padding", "4px 8px") dom.SetStyle(statusEl, "borderRadius", "4px") dom.SetStyle(statusEl, "marginLeft", "auto") dom.AddEventListener(statusEl, "click", dom.RegisterCallback(func() { togglePopover() })) // statusEl (relays button) is created but not attached - all relay info is // already visible on the settings page. _ = statusEl dom.AppendChild(root, bottomBar) // Relay popover (hidden). popoverEl = dom.CreateElement("div") dom.SetStyle(popoverEl, "position", "fixed") dom.SetStyle(popoverEl, "bottom", "37px") dom.SetStyle(popoverEl, "right", "12px") dom.SetStyle(popoverEl, "width", "340px") dom.SetStyle(popoverEl, "maxWidth", "calc(100vw - 24px)") dom.SetStyle(popoverEl, "background", "var(--bg2)") dom.SetStyle(popoverEl, "border", "1px solid var(--border)") dom.SetStyle(popoverEl, "borderRadius", "8px") dom.SetStyle(popoverEl, "padding", "12px 16px") dom.SetStyle(popoverEl, "fontSize", "12px") dom.SetStyle(popoverEl, "display", "none") dom.SetStyle(popoverEl, "zIndex", "99") popoverSummary = dom.CreateElement("div") dom.SetTextContent(popoverSummary, t("connecting")) dom.SetStyle(popoverSummary, "marginBottom", "8px") dom.SetStyle(popoverSummary, "color", "var(--muted)") dom.AppendChild(popoverEl, popoverSummary) // Broadcast button - at the bottom of the relay popover. broadcastRow = dom.CreateElement("div") dom.SetStyle(broadcastRow, "borderTop", "1px solid var(--border)") dom.SetStyle(broadcastRow, "marginTop", "8px") dom.SetStyle(broadcastRow, "paddingTop", "8px") dom.SetStyle(broadcastRow, "display", "none") broadcastBtnEl = dom.CreateElement("span") dom.SetTextContent(broadcastBtnEl, "broadcast profile") dom.SetStyle(broadcastBtnEl, "cursor", "pointer") dom.SetStyle(broadcastBtnEl, "color", "var(--accent)") dom.SetStyle(broadcastBtnEl, "fontFamily", "'Fira Code', monospace") dom.AddEventListener(broadcastBtnEl, "click", dom.RegisterCallback(func() { if pubhex == "" { return } dom.SetTextContent(broadcastBtnEl, "broadcasting...") dom.SetStyle(broadcastBtnEl, "color", "var(--muted)") msg := "[\"BROADCAST\"," | jstr(pubhex) | ",[" for i, url := range relayURLs { if i > 0 { msg = msg | "," } msg = msg | jstr(url) } routeMsg(msg | "]]") })) dom.AppendChild(broadcastRow, broadcastBtnEl) dom.AppendChild(popoverEl, broadcastRow) if pubhex != "" { dom.SetStyle(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. _ = popoverEl // Signer popover (hidden). buildSignerPanel() dom.AppendChild(root, signerPanel) // Wallet popover (hidden). buildWalletPanel() // walletPanel is attached by renderSettings() at the bottom of the settings page. // Feed selector popover (hidden). feedPopoverEl = dom.CreateElement("div") dom.SetStyle(feedPopoverEl, "position", "fixed") dom.SetStyle(feedPopoverEl, "top", "48px") dom.SetStyle(feedPopoverEl, "left", "50%") dom.SetStyle(feedPopoverEl, "transform", "translateX(-50%)") dom.SetStyle(feedPopoverEl, "width", "220px") dom.SetStyle(feedPopoverEl, "maxWidth", "calc(100vw - 24px)") dom.SetStyle(feedPopoverEl, "background", "var(--bg2)") dom.SetStyle(feedPopoverEl, "border", "1px solid var(--border)") dom.SetStyle(feedPopoverEl, "borderRadius", "8px") dom.SetStyle(feedPopoverEl, "padding", "8px") dom.SetStyle(feedPopoverEl, "fontSize", "14px") dom.SetStyle(feedPopoverEl, "display", "none") dom.SetStyle(feedPopoverEl, "zIndex", "99") dom.AppendChild(root, 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(relayBadgeContainers) { return } container := relayBadgeContainers[i] clearChildren(container) caps := 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 != "" { relayPolicyFlags = parseIntProp(s) } if s := localstorage.GetItem(lsKeyRelayBlocklist); s != "" { 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(relayPolicyFlags)) localstorage.SetItem(lsKeyRelayBlocklist, joinLines(relayBlocklist)) } // saveRelayList persists the current relay URLs and roles to localStorage. func saveRelayList() { localstorage.SetItem(lsKeyRelayList, joinLines(relayURLs)) roles := "" for i, r := range 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{ "wss://relay.nostr.band", "wss://relay.nostr.band/", } func isBlocked(url string) (ok bool) { for _, b := range dnsBlacklist { if b == url { return true } } for _, b := range 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 relayURLs { if u != url { continue } // Detach popover row. dom.RemoveChild(popoverEl, relayRows[i]) // Splice all parallel slices. relayURLs = relayURLs[:i] | relayURLs[i+1:] relayRows = relayRows[:i] | relayRows[i+1:] relayDots = relayDots[:i] | relayDots[i+1:] relayLabels = relayLabels[:i] | relayLabels[i+1:] relayBadgeContainers = relayBadgeContainers[:i] | relayBadgeContainers[i+1:] relayUserPick = relayUserPick[:i] | relayUserPick[i+1:] relayRole = relayRole[:i] | relayRole[i+1:] relayReadBtns = relayReadBtns[:i] | relayReadBtns[i+1:] relayWriteBtns = relayWriteBtns[:i] | relayWriteBtns[i+1:] relayCaps = relayCaps[:i] | relayCaps[i+1:] relayCapsTs = relayCapsTs[:i] | 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 } relayBlocklist = append(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 relayBlocklist { if b == url { relayBlocklist = relayBlocklist[:i] | 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 smesh does not re-publish the list. func publishKind10006() { if !signer.HasSigner() || pubhex == "" { return } // Build tags: [["relay","wss://..."], ...] tags := "[" for i, u := range 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\":\"" | pubhex | "\"}" signer.SignEvent(unsigned, func(signed string) { if signed != "" { routeMsg("[\"EVENT\"," | signed | "]") dom.ConsoleLog("[blocklist] published kind 10006 with " | itoa(len(relayBlocklist)) | " entries") } }) } // 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 relayURLs { if u == url { if userPick && !relayUserPick[i] { relayUserPick[i] = true dom.SetStyle(relayLabels[i], "fontWeight", "bold") } return } } relayURLs = append(relayURLs, url) relayUserPick = append(relayUserPick, userPick) relayRole = append(relayRole, role) relayCaps = append(relayCaps, relayCap{}) relayCapsTs = append(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") relayRows = append(relayRows, row) dot := dom.CreateElement("span") dom.SetTextContent(dot, "\u25CF") dom.SetStyle(dot, "color", "#5b5") dom.SetStyle(dot, "flexShrink", "0") relayDots = append(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") } relayLabels = append(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") relayReadBtns = append(relayReadBtns, rBtn) relayWriteBtns = append(relayWriteBtns, wBtn) applyRelayRoleBtns(rBtn, wBtn, role) capturedURL := url dom.AddEventListener(rBtn, "click", dom.RegisterCallback(func() { for i, u := range relayURLs { if u != capturedURL { continue } switch relayRole[i] { case "both": relayRole[i] = "write" case "read": relayRole[i] = "write" default: // "write" - re-enable read relayRole[i] = "both" } applyRelayRoleBtns(relayReadBtns[i], relayWriteBtns[i], relayRole[i]) saveRelayList() sendWriteRelays() break } })) dom.AddEventListener(wBtn, "click", dom.RegisterCallback(func() { for i, u := range relayURLs { if u != capturedURL { continue } switch relayRole[i] { case "both": relayRole[i] = "read" case "write": relayRole[i] = "read" default: // "read" - re-enable write relayRole[i] = "both" } applyRelayRoleBtns(relayReadBtns[i], relayWriteBtns[i], relayRole[i]) saveRelayList() sendWriteRelays() break } })) // Empty badge container - populated when NIP-11 fetch completes. badgeBox := dom.CreateElement("span") dom.SetStyle(badgeBox, "flexShrink", "0") relayBadgeContainers = append(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(popoverEl, row, broadcastRow) updateStatus() if 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 relayURLs { if u == url { relayCaps[i] = caps 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() { popoverOpen = !popoverOpen if popoverOpen { if signerOpen { hideSignerPanel() } closeFeedPopover() dom.SetStyle(popoverEl, "display", "block") dom.SetStyle(statusEl, "background", "var(--accent)") dom.SetStyle(statusEl, "color", "var(--bg)") } else { dom.SetStyle(popoverEl, "display", "none") dom.SetStyle(statusEl, "background", "transparent") dom.SetStyle(statusEl, "color", "var(--muted)") } } func subscribeProfile() { dom.ConsoleLog("[prof] subscribing for pubkey: " | 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(pubhex) | "],\"kinds\":[0,3,10002,10000,10050],\"limit\":8}", proxy)) routeMsg(buildProxyMsg("prof-settings", "{\"authors\":[" | jstr(pubhex) | "],\"kinds\":[30078],\"#d\":[\"smesh-settings\"],\"limit\":1}", proxy)) } var feedLastSubTs int64 // timestamp of last actual subscription func subscribeFeed() { // Throttle: collapse rapid calls into one after 2s of quiet. // Also enforce 10s cooldown - don't resubscribe if nothing changed. feedSubscribed = true if feedSubTimer != 0 { dom.ClearTimeout(feedSubTimer) } feedSubTimer = dom.SetTimeout(func() { feedSubTimer = 0 now := dom.NowSeconds() if feedLastSubTs > 0 && now-feedLastSubTs < 10 { return } doSubscribeFeed() }, 2000) } func doSubscribeFeed() { feedLastSubTs = dom.NowSeconds() dom.ConsoleLog("[sub] feed mode=" | feedMode | " follows=" | itoa(len(myFollows))) feedExhausted = false feedEmptyStreak = 0 feedInitialLoad = true if feedInitialLoadTimer != 0 { dom.ClearTimeout(feedInitialLoadTimer) feedInitialLoadTimer = 0 } feedBuffer = nil feedHasNew = false stopRefreshPulse() dom.SetStyle(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(feedMode) | `]`) feed.Send(`["F_SET_RELAYS",` | readRelayURLsJSON() | `]`) feed.Send(`["F_SET_PUBKEY",` | jstr(pubhex) | `]`) if len(myFollows) > 0 { feed.Send(`["F_SET_FOLLOWS",` | buildJSONStrArr(myFollows) | `]`) } if len(myMutes) > 0 { feed.Send(`["F_SET_MUTES",` | buildJSONStrArr(myMutes) | `]`) } feed.Send(`["F_REFRESH"]`) } func buildFeedFilter(limit int32) (s string) { if feedMode == "follows" { follows := myFollows if len(follows) > 0 { // Always include own pubkey so user's own posts appear in the feed. authors := jstr(pubhex) for _, pk := range follows { if pk != 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 feedMode != "follows" { return true } follows := myFollows if len(follows) == 0 { return true } for _, pk := range follows { if pk == ev.PubKey { return true } } // Also allow own events. return ev.PubKey == pubhex } func feedRelays() (ss []string) { if feedMode != "" && feedMode != "follows" && feedMode != "relays" { // Single relay mode. return []string{feedMode} } return relayURLs } func feedModeURL() (s string) { switch feedMode { case "", "follows": return "/feed/follows" case "relays": return "/feed/relays" } // Per-relay: strip ws:// or wss:// for the URL path. host := stripScheme(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 } var feedMoreGot int32 var feedMoreTimer int32 func loadOlderFeed() { feedLoading = true feedMoreGot = 0 feed.Send(`["F_LOAD_MORE"]`) if feedMoreTimer != 0 { dom.ClearTimeout(feedMoreTimer) } feedMoreTimer = dom.SetTimeout(func() { feedMoreTimer = 0 if feedLoading { dom.ConsoleLog("[loadOlderFeed] timeout - forcing feedLoading=false, got=" | itoa(feedMoreGot)) feedLoading = false if feedMoreGot == 0 { feedEmptyStreak++ if feedEmptyStreak >= 3 { feedExhausted = true } } else { feedEmptyStreak = 0 } if feedExhausted { dom.SetStyle(loadMoreBtn, "display", "none") } else if oldestFeedTs > 0 { dom.SetStyle(loadMoreBtn, "display", "block") } updateStatus() } }, 10000) } func populateFeedSelect() { updateFeedBtnText() if feedPopoverEl == 0 { return } clearChildren(feedPopoverEl) if len(myFollows) > 0 { addFeedOption("follows", "follows") } addFeedOption("relays", "relays") for _, u := range 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 == 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() { feedMode = v updateFeedBtnText() dom.PushState(feedModeURL()) closeFeedPopover() refreshFeed() })) dom.AppendChild(feedPopoverEl, row) } func updateFeedBtnText() { if feedSelectEl == 0 { return } label := feedMode if label == "follows" && len(myFollows) == 0 { label = "all relays" } if label != "follows" && label != "relays" && label != "all relays" { label = stripScheme(label) } dom.SetTextContent(feedSelectEl, label | " \u25BE") } func toggleFeedPopover() { if feedPopoverOpen { closeFeedPopover() return } if signerOpen { hideSignerPanel() } if popoverOpen { togglePopover() } feedPopoverOpen = true populateFeedSelect() dom.SetStyle(feedPopoverEl, "display", "block") dom.SetStyle(feedSelectEl, "background", "var(--accent)") dom.SetStyle(feedSelectEl, "color", "var(--bg)") } func closeFeedPopover() { if !feedPopoverOpen { return } feedPopoverOpen = false dom.SetStyle(feedPopoverEl, "display", "none") dom.SetStyle(feedSelectEl, "background", "transparent") dom.SetStyle(feedSelectEl, "color", "var(--fg)") updateFeedBtnText() } func onRefreshClick() { if refreshSpinning { return } // Flush buffered new events into the feed. if len(feedBuffer) > 0 { for _, ev := range feedBuffer { renderNote(ev) seenEvents[ev.ID] = true } feedBuffer = nil feedHasNew = false stopRefreshPulse() } // Scroll to top. dom.SetProperty(contentArea, "scrollTop", "0") // Start spin animation. startRefreshSpin() // Cancel throttle and resubscribe immediately. if feedSubTimer != 0 { dom.ClearTimeout(feedSubTimer) feedSubTimer = 0 } feedSubscribed = true doSubscribeFeed() } func refreshFeed() { // Clear feed container. clearChildren(feedContainer) seenEvents = map[string]bool{} noteElements = map[string]dom.Element{} eventCount = 0 oldestFeedTs = 0 feedExhausted = false feedEmptyStreak = 0 feedBuffer = nil feedHasNew = false stopRefreshPulse() dom.SetStyle(loadMoreBtn, "display", "none") dom.SetProperty(contentArea, "scrollTop", "0") // Cancel any pending throttled sub and fire immediately. if feedSubTimer != 0 { dom.ClearTimeout(feedSubTimer) feedSubTimer = 0 } startRefreshSpin() feedSubscribed = true doSubscribeFeed() } var refreshPulseTimer int32 var refreshPulseOn bool func startRefreshPulse() { if refreshBtn == 0 || refreshPulseTimer != 0 { return } dom.SetStyle(refreshBtn, "color", "var(--accent)") refreshPulseOn = true pulseStep() } func pulseStep() { if refreshPulseTimer == -1 || refreshBtn == 0 { return } if refreshPulseOn { dom.SetStyle(refreshBtn, "opacity", "0.4") } else { dom.SetStyle(refreshBtn, "opacity", "1") } refreshPulseOn = !refreshPulseOn refreshPulseTimer = dom.SetTimeout(func() { pulseStep() }, 800) } func stopRefreshPulse() { if refreshPulseTimer != 0 { dom.ClearTimeout(refreshPulseTimer) } refreshPulseTimer = -1 dom.SetTimeout(func() { refreshPulseTimer = 0 }, 0) if refreshBtn != 0 { dom.SetStyle(refreshBtn, "color", "var(--muted)") dom.SetStyle(refreshBtn, "opacity", "1") } } var refreshSpinAngle int32 var refreshSpinTimer int32 func startRefreshSpin() { refreshSpinning = true if refreshBtn == 0 { return } refreshSpinAngle = 0 spinStep() } func spinStep() { if !refreshSpinning || refreshBtn == 0 { return } refreshSpinAngle += 30 dom.SetStyle(refreshBtn, "transform", "rotate(" | itoa(refreshSpinAngle) | "deg)") refreshSpinTimer = dom.SetTimeout(func() { spinStep() }, 50) } func stopRefreshSpin() { refreshSpinning = false if refreshSpinTimer != 0 { dom.ClearTimeout(refreshSpinTimer) refreshSpinTimer = 0 } if refreshBtn != 0 { dom.SetStyle(refreshBtn, "transform", "rotate(0deg)") } } func trackOldestTs(ev *nostr.Event) { if oldestFeedTs == 0 || ev.CreatedAt < oldestFeedTs { 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 relayURLs { role := "both" if i < len(relayRole) { role = 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 relayURLs { role := "both" if i < len(relayRole) { role = relayRole[i] } if role != "write" { out = append(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 pendingNotes { if _, ok := authorNames[pk]; !ok { missing = append(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 !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 broadcastBtnEl != 0 { dom.SetTextContent(broadcastBtnEl, "broadcast profile \u2713") dom.SetStyle(broadcastBtnEl, "color", "var(--accent)") dom.SetTimeout(func() { dom.SetTextContent(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, ok := pendingDeletes[okEvID]; ok { removeNoteFromDOM(targetID) delete(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 != "" && eventRelays != nil { existing := eventRelays[evID] found := false for _, u := range existing { if u == rURL { found = true break } } if !found { eventRelays[evID] = append(existing, rURL) logActivity("seen-on", evID[:12] | " on " | rURL, "event " | evID | "\nrelay " | rURL) if cb, ok := 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 pubhex != "" { routeMsg("[\"SET_PUBKEY\"," | jstr(pubhex) | "]") } feedSubscribed = false resubscribe() case "NEED_IDENTITY": if pubhex != "" { routeMsg("[\"SET_PUBKEY\"," | jstr(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 pubhex == "" { return } if which == "/profile-wasm-host.mjs" { profile.Send("[\"P_SET_PUBKEY\"," | jstr(pubhex) | "]") profile.Send("[\"P_SET_RELAYS\"," | readRelayURLsJSON() | "]") } else if which == "/feed-wasm-host.mjs" { doSubscribeFeed() } else if which == "/mls-wasm-host.mjs" { mlsw.Send("[\"M_SET_PUBKEY\"," | jstr(pubhex) | "]") } 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, ok := pendingNotes[pk3]; ok { for _, h := range headers { updateNoteHeader(h, name3, pic3) } delete(pendingNotes, pk3) } } if len(pic3) > 0 { for _, avaEl := range pendingNotifAvatars[pk3] { setMediaSrc(avaEl, pic3) } delete(pendingNotifAvatars, pk3) } if pk3 == pubhex { if len(name3) > 0 { profileName = name3; dom.SetTextContent(nameEl, name3) } if len(pic3) > 0 { profilePic = pic3; setMediaSrc(avatarEl, pic3); dom.SetStyle(avatarEl, "display", "block") } } updateReplyPreviewsForAuthor(pk3) updateInlineProfileLinks(pk3) case "P_CONTENT": pk4, pos2b := nextStr(raw, pos) contentJSON, _ := nextStr(raw, pos2b) profileContentCache[pk4] = contentJSON lud16 := helpers.JsonGetString(contentJSON, "lud16") lud06 := helpers.JsonGetString(contentJSON, "lud06") if lud16 != "" || lud06 != "" { applyZapButtonsForAuthor(pk4) } else { delete(pendingZapRows, pk4) } // Re-render profile page if it's the viewed profile. if profileViewPK == pk4 && activePage == "profile" { renderProfilePage(pk4) } // Update own profile name if this is our own kind 0. if pk4 == pubhex { name4 := helpers.JsonGetString(contentJSON, "name") if name4 == "" { name4 = helpers.JsonGetString(contentJSON, "display_name") } if name4 != "" && profileName != name4 { profileName = name4 dom.SetTextContent(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 == pubhex { if len(pksF) != len(myFollows) { followsChanged = true } myFollows = pksF followSet = buildSet(pksF) broadcastFollows(pksF) if feedSelectEl != 0 { populateFeedSelect() } } profileFollowsCache[pkF] = pksF refreshProfileTab(pkF) if pkF == pubhex && feedSubscribed && feedMode == "follows" && followsChanged { subscribeFeed() } case "P_MUTES": pkM, pos2d := nextStr(raw, pos) mutesJSON := extractValue(raw, pos2d) pksM := parseStringArrayJSON(mutesJSON) if pkM == pubhex { myMutes = pksM muteSet = buildSet(pksM) broadcastMutes(pksM) } profileMutesCache[pkM] = pksM refreshProfileTab(pkM) case "P_RELAYS": pkR, pos2e := nextStr(raw, pos) relaysJSON := extractValue(raw, pos2e) profileRelaysCache[pkR] = parseStringArrayJSON(relaysJSON) if 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 feedLoader != 0 { dom.RemoveChild(feedPage, feedLoader) feedLoader = 0 } renderNote(fev) } else { if noteElements[fev.ID] != 0 || seenEvents[fev.ID] { return } feedBuffer = append(feedBuffer, fev) if !feedHasNew { feedHasNew = true startRefreshPulse() } } case "F_EOSE_DONE": subID2, _ := nextStr(raw, pos) if subID2 == "feed" { 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') } } eventCount = int32(cnt) dom.ConsoleLog("[feed] events=" | itoa(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" { feedExhausted = true } updateStatus() if feedLoading { feedLoading = false } if feedExhausted { dom.SetStyle(loadMoreBtn, "display", "none") } else if oldestFeedTs > 0 { dom.SetStyle(loadMoreBtn, "display", "block") } // ── Notif Worker responses ──────────────────────────────────────────────── case "N_RENDER": nEvJSON := extractValue(raw, pos) if nEvJSON == "" { return } nev := nostr.ParseEvent(nEvJSON) if nev == nil { return } notifEvents = append(notifEvents, nev) if len(notifEvents) > notifEventsMax { notifEvents = notifEvents[len(notifEvents)-notifEventsMax:] } if notifBuilt && 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(notifContainer) if first != 0 { dom.InsertBefore(notifContainer, row, first) } else { dom.AppendChild(notifContainer, row) } } else { dom.AppendChild(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" { notifExhausted = true } notifLoading = false if notifExhausted && notifLoadMore != 0 { dom.SetStyle(notifLoadMore, "display", "none") } else if oldestNotifTs > 0 && notifLoadMore != 0 { dom.SetStyle(notifLoadMore, "display", "block") } case "N_EOSE_DONE": subIDN, _ := nextStr(raw, pos) if subIDN == "ntf" { notifInitLoad = false sortNotifEvents() if activePage == "notifications" { if notifBuilt {} else { renderNotifPage() } } if oldestNotifTs > 0 && notifLoadMore != 0 { dom.SetStyle(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(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(pendingTsEls) > 0 { dom.SetTextContent(pendingTsEls[0], formatTime(dom.NowSeconds())) pendingTsEls = 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 pendingNotes { if _, ok := authorNames[pk]; !ok { pending = append(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 pubhex != "" { profile.Send("[\"P_SET_PUBKEY\"," | jstr(pubhex) | "]") profile.Send("[\"P_SET_RELAYS\"," | readRelayURLsJSON() | "]") mlsw.Send("[\"M_SET_PUBKEY\"," | jstr(pubhex) | "]") } if activePage == "messaging" { initMessaging() } if activePage == "profile" && profileTab == "notes" && profileViewPK != "" { renderProfileNotes(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 profileNotesSeen[ev.ID] { return } 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. 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(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 ev.Tags.GetAll("e") { targetID := tag.Value() 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(feedMoreGot)) if feedMoreTimer != 0 { dom.ClearTimeout(feedMoreTimer) feedMoreTimer = 0 } feedLoading = false if feedMoreGot == 0 { feedEmptyStreak++ if feedEmptyStreak >= 3 { feedExhausted = true } } else { feedEmptyStreak = 0 } if !feedExhausted && oldestFeedTs > 0 { dom.SetStyle(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 feedInitialLoadTimer != 0 { dom.ClearTimeout(feedInitialLoadTimer) } feedInitialLoadTimer = dom.SetTimeout(func() { feedInitialLoadTimer = 0 feedInitialLoad = false }, 3000) if refreshSpinning { stopRefreshSpin() } if feedLoader != 0 { dom.RemoveChild(feedPage, feedLoader) feedLoader = 0 } if oldestFeedTs > 0 { dom.SetStyle(loadMoreBtn, "display", "block") } updateStatus() retryMissingProfiles() fetchDeletesForFeed() } else if subID == "ntf" { dom.ConsoleLog("[EOSE] ntf events=" | itoa(len(notifEvents))) notifInitLoad = false sortNotifEvents() if activePage == "notifications" { if 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 oldestNotifTs > 0 && notifLoadMore != 0 { dom.SetStyle(notifLoadMore, "display", "block") } fetchNotifRefs() } else if subID == "ntf-ref" { if notifBuilt && activePage == "notifications" { rebuildNotifRows() } } else if subID == "ntf-more" { dom.ConsoleLog("[EOSE] ntf-more got=" | itoa(notifMoreGot) | " streak=" | itoa(notifEmptyStrk)) if notifMoreTimer != 0 { dom.ClearTimeout(notifMoreTimer) notifMoreTimer = 0 } notifLoading = false if notifMoreGot == 0 { notifEmptyStrk++ if notifEmptyStrk >= 3 { notifExhausted = true } } else { notifEmptyStrk = 0 } if !notifExhausted && oldestNotifTs > 0 && notifLoadMore != 0 { dom.SetStyle(notifLoadMore, "display", "block") } fetchNotifRefs() } else if subID == "search" { dom.ConsoleLog("[EOSE] search results=" | itoa(len(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 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) (string, 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 = append(buf, s[pos]) case 'n': buf = append(buf, '\n') case 't': buf = append(buf, '\t') case 'r': buf = append(buf, '\r') default: buf = append(buf, '\\', 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 = append(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 <= profileTs { return } 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 { profileName = name setAuthorName(pubhex, name) dom.SetTextContent(nameEl, name) } if len(pic) > 0 { profilePic = pic setAuthorPic(pubhex, pic) setMediaSrc(avatarEl, pic) dom.SetStyle(avatarEl, "display", "block") } case 3: if ev.CreatedAt <= contactTs { return } contactTs = ev.CreatedAt var pks []string for _, tag := range ev.Tags.GetAll("p") { if v := tag.Value(); v != "" { pks = append(pks, v) } } prev := myFollows myFollows = pks followSet = buildSet(pks) broadcastFollows(pks) for _, pk := range pks { profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`) } refreshProfileTab(pubhex) if feedSelectEl != 0 { populateFeedSelect() } if feedMode == "follows" { if feedDeferTimer != 0 { dom.ClearTimeout(feedDeferTimer) feedDeferTimer = 0 } if !feedSubscribed { 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 <= muteTs { return } muteTs = ev.CreatedAt var pks []string for _, tag := range ev.Tags.GetAll("p") { if v := tag.Value(); v != "" { pks = append(pks, v) } } myMutes = pks muteSet = buildSet(pks) broadcastMutes(pks) refreshProfileTab(pubhex) case 10002: if ev.CreatedAt <= relayListTs { return } relayListTs = ev.CreatedAt // Profile Worker handles relay frequency tracking via P_EVENT. for _, tag := range ev.Tags.GetAll("r") { url := tag.Value() 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 feedSubscribed { subscribeFeed() subscribeNotifications() } case 10050: if ev.CreatedAt <= inboxTs { return } inboxTs = ev.CreatedAt // DM inbox relay list - stored for future use. _ = ev.Tags.GetAll("relay") case 30078: dtag := ev.Tags.GetFirst("d") if dtag == nil || dtag.Value() != "smesh-settings" { return } if ev.CreatedAt <= settingsTs { return } settingsTs = ev.CreatedAt applyAppSettings(ev.Content) } } func applyAppSettings(content string) { theme := helpers.JsonGetString(content, "theme") if theme == "light" && isDark { toggleTheme() } else if theme == "dark" && !isDark { toggleTheme() } blossom := helpers.JsonGetString(content, "blossom") if blossom != "" { blossomServerURL = blossom localstorage.SetItem(lsKeyBlossomServer, blossom) } else { blossomServerURL = "" localstorage.RemoveItem(lsKeyBlossomServer) } clientTag := helpers.JsonGetString(content, "clientTag") if clientTag == "0" { localstorage.SetItem("smesh-client-tag", "0") } else { localstorage.RemoveItem("smesh-client-tag") } lang := helpers.JsonGetString(content, "lang") if lang != "" && lang != currentLang { setLang(lang) } inv := helpers.JsonGetString(content, "invidious") invidiousURL = inv if inv != "" { localstorage.SetItem(lsKeyInvidiousURL, inv) } else { localstorage.RemoveItem(lsKeyInvidiousURL) } camp := helpers.JsonGetString(content, "campion") 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 > notifReadTs { 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) { relayBlocklist = append(relayBlocklist, u) } } if len(imported) > 0 { saveRelayPolicy() } } } func saveAppSettings() { if pubhex == "" { return } theme := "dark" if !isDark { theme = "light" } clientTag := "1" if localstorage.GetItem("smesh-client-tag") == "0" { clientTag = "0" } blocklistJSON := "[" for i, u := range relayBlocklist { if i > 0 { blocklistJSON = blocklistJSON | "," } blocklistJSON = blocklistJSON | jstr(u) } blocklistJSON = blocklistJSON | "]" content := "{" | "\"theme\":" | jstr(theme) | "," | "\"blossom\":" | jstr(blossomServerURL) | "," | "\"clientTag\":" | jstr(clientTag) | "," | "\"lang\":" | jstr(currentLang) | "," | "\"relayBlocklist\":" | blocklistJSON | "," | "\"invidious\":" | jstr(invidiousURL) | "," | "\"campion\":" | jstr(campionURL) | "," | "\"notifReadTs\":" | i64toa(notifReadTs) | "}" ts := dom.NowSeconds() tags := "[[\"d\",\"smesh-settings\"]]" unsigned := "{\"kind\":30078,\"content\":" | jstr(content) | ",\"tags\":" | tags | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | pubhex | "\"}" signer.SignEvent(unsigned, func(signed string) { if signed == "" { return } settingsTs = ts routeMsg("[\"EVENT\"," | signed | "]") }) } func updateStatus() { dom.SetTextContent(statusEl, t("relays")) dom.SetTextContent(popoverSummary, itoa(len(relayURLs)) | " relays | " | itoa(eventCount) | " events") } // --- Feed rendering --- func renderNote(ev *nostr.Event) { if noteElements[ev.ID] != 0 || 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 != "" { noteElements[repostID] = note } else { 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, "smeshTs", 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 := 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 := authorPics[pk]; ok && pic != "" { setMediaSrc(avatar, pic) dom.SetAttribute(avatar, "onerror", "this.style.display='none'") } else { dom.SetStyle(avatar, "display", "none") } if name, ok := 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 == 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 := authorNames[pk]; !cached { pendingNotes[pk] = append(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(feedContainer) for child != 0 { childTs := parseI64(dom.GetProperty(child, "smeshTs")) if ts > childTs { dom.InsertBefore(feedContainer, note, child) return } child = dom.NextSibling(child) } dom.AppendChild(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 noteElements[ev.ID] != 0 { return } note, postInsert := buildNoteElement(ev) dom.AppendChild(feedContainer, note) postInsert() resolveEmbeds() } func buildNoteElement(ev *nostr.Event) (dom.Element, func()) { note := dom.CreateElement("div") noteElements[ev.ID] = note dom.SetStyle(note, "borderBottom", "1px solid var(--border)") dom.SetStyle(note, "padding", "12px 0") dom.SetProperty(note, "smeshTs", i64toa(ev.CreatedAt)) dom.SetProperty(note, "smeshPK", 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 := authorPics[pk]; ok && pic != "" { setMediaSrc(avatar, pic) dom.SetAttribute(avatar, "onerror", "this.style.display='none'") } else { dom.SetStyle(avatar, "display", "none") } if name, ok := 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 := authorNames[pk]; !cached { pendingNotes[pk] = append(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) } } var profileSubCounter int32 // 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{ "wss://purplepag.es", "wss://relay.damus.io", "wss://nos.lol", } // 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, ok := profileRelaysCache[pk]; ok { 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 } } return append(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) (string, string) { var etags []nostr.Tag for _, t := range ev.Tags { if len(t) >= 2 && t[0] == "e" { etags = append(etags, t) } } if len(etags) == 0 { return "", "" } // Marker-based: look for "reply". for _, t := range etags { if len(t) >= 4 && t[3] == "reply" { hint := "" if len(t) >= 3 && len(t[2]) > 0 { hint = t[2] } return t[1], hint } } // Positional fallback: last e-tag is the reply target (if >1 e-tags). if len(etags) > 1 { last := etags[len(etags)-1] hint := "" if len(last) >= 3 && len(last[2]) > 0 { hint = last[2] } return last[1], hint } // Single e-tag with no marker: it's both root and reply. hint := "" if len(etags[0]) >= 3 && len(etags[0][2]) > 0 { hint = etags[0][2] } return etags[0][1], hint } // getRootID returns the thread root event ID, or "". func getRootID(ev *nostr.Event) (s string) { for _, t := range ev.Tags { if len(t) >= 4 && t[0] == "e" && t[3] == "root" { return t[1] } } // Positional: first e-tag. for _, t := range ev.Tags { if len(t) >= 2 && t[0] == "e" { return t[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 _, t := range ev.Tags { if len(t) >= 2 && t[0] == "e" && t[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 := ev.Tags.GetFirst("client") if clientTag == nil { return } val := clientTag.Value() 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 != "" && replyHints != nil { 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 := replyCache[parentID] if !cached { text, cached = replyCacheOld[parentID] } if cached { dom.SetTextContent(prevText, text) pic, _ := replyAvatarCache[parentID] if pic == "" { pic, _ = 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")) replyPending[parentID] = append(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) { replyQueue = append(replyQueue, id) if replyTimer != 0 { dom.ClearTimeout(replyTimer) } replyTimer = dom.SetTimeout(func() { replyTimer = 0 flushReplyQueue() }, 300) } func flushReplyQueue() { if len(replyQueue) == 0 { return } filter := "{\"ids\":[" var hints []string for i, id := range replyQueue { if i > 0 { filter = filter | "," } filter = filter | jstr(id) if h := replyHints[id]; h != "" { hints = append(hints, h) delete(replyHints, id) } else if h := replyHintsOld[id]; h != "" { hints = append(hints, h) delete(replyHintsOld, id) } } filter = filter | "]}" replyQueue = nil // Local REQ checks IDB cache first. threadSubCount++ reqID := "rp-" | itoa(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 _, h := range hints { if !seen[h] { seen[h] = true urls = append(urls, h) } } for _, u := range discoveryRelays { if !seen[u] { seen[u] = true urls = append(urls, u) } } for _, u := range relayURLs { if !seen[u] { seen[u] = true urls = append(urls, u) } } for _, u := range topRelays(8) { if !seen[u] { seen[u] = true urls = append(urls, u) } } threadSubCount++ proxyID := "rp-" | itoa(threadSubCount) routeMsg(buildProxyMsg(proxyID, filter, urls)) // Reset sweep timer: 20s after last batch sent, mark remaining as not found. if replySweepTimer != 0 { dom.ClearTimeout(replySweepTimer) } replySweepTimer = dom.SetTimeout(func() { replySweepTimer = 0 sweepUnfoundReplies() }, 20000) } func handleReplyPreviewEvent(ev *nostr.Event) { line := firstLine(ev.Content) replyLineCache[ev.ID] = line replyAuthorMap[ev.ID] = ev.PubKey name := ev.PubKey[:8] | "..." nameResolved := false if n, ok := authorNames[ev.PubKey]; ok && n != "" { name = n nameResolved = true } text := name | ": " | line replyCache[ev.ID] = text replyCount++ if replyCount > rotateReplyMax { rotateReplyCaches() } pic := "" if p, ok := authorPics[ev.PubKey]; ok && p != "" { pic = p } 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, ok := replyPending[ev.ID]; ok { for _, d := range divs { fillReplyPreviewDiv(d, text, pic) } // Track for late name update if unresolved. if !nameResolved { replyNeedName[ev.ID] = replyNeedName[ev.ID] | divs } delete(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(replyPending) == 0 { return } notFound := t("reply_not_found") for id, divs := range replyPending { for _, d := range divs { span := dom.NextSibling(dom.FirstChild(d)) if span != 0 { dom.SetTextContent(span, notFound) } } delete(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, _ := authorNames[pk] pic, _ := authorPics[pk] if name == "" { return } for eid, apk := range replyAuthorMap { if apk != pk { continue } line := replyLineCache[eid] text := name | ": " | line replyCache[eid] = text replyAvatarCache[eid] = pic if divs, ok := replyNeedName[eid]; ok { for _, d := range divs { fillReplyPreviewDiv(d, text, pic) } delete(replyNeedName, eid) } } } func showNoteThread(rootID, focusID string) { // Close old thread subs immediately to prevent stale events leaking in. for _, sid := range threadActiveSubs { routeMsg("[\"CLOSE\"," | jstr(sid) | "]") } threadActiveSubs = nil threadGen++ threadRootID = rootID threadFocusID = focusID threadEvents = map[string]*nostr.Event{} threadLastRendered = 0 threadOpen = true threadFollowUp = 0 threadFetchedIDs = map[string]bool{rootID: true} threadReturnPage = activePage // Save scroll position. savedScrollTop = dom.GetProperty(contentArea, "scrollTop") // Deactivate search bar so top bar returns to normal while in thread. if searchBarActive { deactivateSearchBar(searchBtnGlobal) } // Stop any inline media (Invidious iframes) playing in the feed. stopInlineMedia(feedContainer) // Ensure feedPage is visible - threadPage lives inside it. if activePage != "feed" { dom.SetStyle(profilePage, "display", "none") dom.SetStyle(msgPage, "display", "none") dom.SetStyle(settingsPage, "display", "none") dom.SetStyle(notifPage, "display", "none") dom.SetStyle(aboutPage, "display", "none") dom.SetStyle(composePage, "display", "none") dom.SetStyle(searchPage, "display", "none") dom.SetStyle(feedPage, "display", "block") } // Defocus all sidebar buttons - thread view is its own context. dom.SetStyle(sidebarFeed, "background", "transparent") dom.SetStyle(sidebarFeed, "color", "var(--fg)") dom.SetStyle(sidebarCompose, "background", "transparent") dom.SetStyle(sidebarCompose, "color", "var(--fg)") dom.SetStyle(sidebarMsg, "background", "transparent") dom.SetStyle(sidebarMsg, "color", "var(--fg)") dom.SetStyle(sidebarNotif, "background", "transparent") dom.SetStyle(sidebarNotif, "color", "var(--fg)") dom.SetStyle(sidebarSettings, "background", "transparent") dom.SetStyle(sidebarSettings, "color", "var(--fg)") // Switch UI. dom.SetStyle(feedContainer, "display", "none") dom.SetStyle(loadMoreBtn, "display", "none") dom.SetStyle(threadPage, "display", "block") dom.SetProperty(contentArea, "scrollTop", "0") // Show back button in top bar, hide everything else. closeFeedPopover() dom.SetStyle(topBackBtn, "display", "inline") dom.SetStyle(pageTitleEl, "display", "none") dom.SetStyle(feedSelectEl, "display", "none") dom.SetStyle(notifBarGroup, "display", "none") if !navPop { url := "/t/" | rootID if focusID != "" && focusID != rootID { url = url | "#" | focusID } dom.PushState(url) threadPushedState = true } else { threadPushedState = false } clearChildren(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(threadContainer, loading) // Build wide relay set for thread fetch. var thrRelays []string thrSeen := map[string]bool{} for _, u := range relayURLs { if !thrSeen[u] { thrSeen[u] = true thrRelays = append(thrRelays, u) } } for _, u := range topRelays(8) { if !thrSeen[u] { thrSeen[u] = true thrRelays = append(thrRelays, u) } } threadRelays = thrRelays // Local REQ for cached events. threadSubCount++ s1 := "thr-" | itoa(threadSubCount) threadActiveSubs = append(threadActiveSubs, s1) routeMsg("[\"REQ\"," | jstr(s1) | ",{\"ids\":[" | jstr(rootID) | "]}]") threadSubCount++ s2 := "thr-" | itoa(threadSubCount) threadActiveSubs = append(threadActiveSubs, s2) routeMsg("[\"REQ\"," | jstr(s2) | ",{\"#e\":[" | jstr(rootID) | "],\"kinds\":[1]}]") // PROXY for remote relays. threadSubCount++ s3 := "thr-" | itoa(threadSubCount) threadActiveSubs = append(threadActiveSubs, s3) routeMsg(buildProxyMsg(s3, "{\"ids\":[" | jstr(rootID) | "]}", thrRelays)) threadSubCount++ s4 := "thr-" | itoa(threadSubCount) threadActiveSubs = append(threadActiveSubs, s4) routeMsg(buildProxyMsg(s4, "{\"#e\":[" | jstr(rootID) | "],\"kinds\":[1]}", thrRelays)) // Also fetch the focus event directly if different from root. if focusID != rootID { threadSubCount++ s5 := "thr-" | itoa(threadSubCount) threadActiveSubs = append(threadActiveSubs, s5) routeMsg(buildProxyMsg(s5, "{\"ids\":[" | jstr(focusID) | "]}", thrRelays)) } } func closeNoteThread() { // Close active subs. for _, sid := range threadActiveSubs { routeMsg("[\"CLOSE\"," | jstr(sid) | "]") } threadActiveSubs = nil threadOpen = false threadRootID = "" threadFocusID = "" threadRelays = nil threadFetchedIDs = nil stopInlineMedia(threadContainer) dom.SetStyle(threadPage, "display", "none") // Always restore feedContainer - showNoteThread hides it. dom.SetStyle(feedContainer, "display", "block") dom.SetStyle(topBackBtn, "display", "none") // Return to the page we came from. ret := threadReturnPage threadReturnPage = "" if ret != "" && ret != "feed" { // Force activePage to "" so switchPage doesn't early-return. 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" && searchBtnGlobal != 0 && !searchBarActive { activateSearchBar(searchBtnGlobal) if searchCurrentQ != "" { dom.SetProperty(searchInputEl, "value", searchCurrentQ) } } } else { dom.SetStyle(feedSelectEl, "display", "inline") if !feedExhausted && oldestFeedTs > 0 { dom.SetStyle(loadMoreBtn, "display", "block") } } // Restore scroll position. if savedScrollTop != "" { dom.SetProperty(contentArea, "scrollTop", savedScrollTop) savedScrollTop = "" } } func handleThreadEvent(gen int32, ev *nostr.Event) { if gen != threadGen { return // stale event from a previous thread } threadEvents[ev.ID] = ev // Debounced render - 200ms after last event, show what we have. if threadRenderTimer != 0 { dom.ClearTimeout(threadRenderTimer) } threadRenderTimer = dom.SetTimeout(func() { threadRenderTimer = 0 if threadOpen { renderThreadTree() } }, 200) } func handleThreadEOSE() { // Final render pass. if threadRenderTimer != 0 { dom.ClearTimeout(threadRenderTimer) threadRenderTimer = 0 } renderThreadTree() threadFetchReplies() } func threadFetchReplies() { if threadFollowUp >= 3 || !threadOpen { return } // Collect event IDs not yet queried for children. var ids []string for id := range threadEvents { if !threadFetchedIDs[id] { ids = append(ids, id) threadFetchedIDs[id] = true } } if len(ids) == 0 { return } 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. threadSubCount++ s1 := "thr-" | itoa(threadSubCount) threadActiveSubs = append(threadActiveSubs, s1) routeMsg("[\"REQ\"," | jstr(s1) | "," | filter | "]") // PROXY to remote relays. if len(threadRelays) > 0 { threadSubCount++ s2 := "thr-" | itoa(threadSubCount) threadActiveSubs = append(threadActiveSubs, s2) routeMsg(buildProxyMsg(s2, filter, threadRelays)) } } func renderThreadTree() { n := len(threadEvents) if n == threadLastRendered { return // no new events since last render } threadLastRendered = n clearChildren(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(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 threadEvents { parentID := getReplyID(ev) if parentID == "" || parentID == id { continue } if threadEvents[parentID] == nil && parentID != threadRootID { // Direct parent not fetched - attach to root if event belongs to thread. if referencesEvent(ev, threadRootID) { parentID = threadRootID } } children[parentID] = append(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 := threadRootID if _, ok := threadEvents[rootID]; !ok { // Root not fetched; find event with no in-thread parent. for id, ev := range threadEvents { parentID := getReplyID(ev) if parentID == "" || 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 := 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 == threadFocusID) for _, childID := range children[id] { renderAt(childID, depth+1) } } // If root is present, start there. Otherwise render all as flat. if _, ok := threadEvents[rootID]; ok { renderAt(rootID, 0) // Render any remaining orphans (events not reached by DFS). for id := range threadEvents { if !rendered[id] { renderAt(id, 1) } } } else { for id := range threadEvents { renderAt(id, 0) } } // Scroll focused note into view (centered vertically). if threadFocusID != "" { dom.SetTimeout(func() { el := dom.GetElementById("thread-focus") if el != 0 { top := parseIntProp(dom.GetProperty(el, "offsetTop")) vh := parseIntProp(dom.GetProperty(contentArea, "clientHeight")) scroll := top - vh/2 if scroll < 0 { scroll = 0 } dom.SetProperty(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 := threadEvents[ids[i]] ej := 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 := authorPics[pk]; ok && pic != "" { setMediaSrc(avatar, pic) dom.SetAttribute(avatar, "onerror", "this.style.display='none'") } else { dom.SetStyle(avatar, "display", "none") } if name, ok := 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 := authorNames[pk]; !cached { pendingNotes[pk] = append(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(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) profileSubCounter++ subID := "ap-" | itoa(profileSubCounter) authorSubPK[subID] = pk proxyRelays := buildProxy(pk) routeMsg(buildProxyMsg(subID, "{\"authors\":[" | jstr(pk) | "],\"kinds\":[0,3,10002,10000],\"limit\":6}", proxyRelays)) } // queueProfileFetch is now a stub; call sites use profile.Send instead. func queueProfileFetch(pk string) { if lookupFetchedK0(pk) { return } setFetchedK0(pk, true) fetchQueue = append(fetchQueue, pk) if fetchTimer != 0 { dom.ClearTimeout(fetchTimer) } fetchTimer = dom.SetTimeout(func() { fetchTimer = 0 flushFetchQueue() }, 300) } // flushFetchQueue sends all queued pubkeys as chunked batch PROXY requests. func flushFetchQueue() { if len(fetchQueue) == 0 { return } queue := fetchQueue fetchQueue = nil proxy := []string{:len(discoveryRelays)} copy(proxy, discoveryRelays) for _, u := range relayURLs { proxy = appendUnique(proxy, u) } for _, pk := range queue { if rels, ok := profileRelaysCache[pk]; ok { for _, r := range rels { proxy = appendUnique(proxy, r) } } } top := topRelays(4) for _, r := range top { proxy = appendUnique(proxy, r) } const batchSize = 100 for i := 0; i < len(queue); i += batchSize { end := i + batchSize if end > len(queue) { end = len(queue) } chunk := queue[i:end] authors := "[" for j, pk := range chunk { if j > 0 { authors = authors | "," } authors = authors | jstr(pk) } authors = authors | "]" profileSubCounter++ subID := "ap-batch-q-" | itoa(profileSubCounter) routeMsg(buildProxyMsg(subID, "{\"authors\":" | authors | ",\"kinds\":[0],\"limit\":" | itoa(len(chunk)) | "}", proxy)) // Also query feed relays directly - they have the kind 1 notes // so they almost certainly have kind 0 for the same authors. // Uses the SW's existing WebSocket connections, bypasses server proxy. profileSubCounter++ routeMsg(buildProxyMsg("ap-d-" | itoa(profileSubCounter), "{\"authors\":" | authors | ",\"kinds\":[0],\"limit\":" | itoa(len(chunk)) | "}", relayURLs)) } } // 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 pendingNotes { if _, ok := authorNames[pk]; !ok { missing = append(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 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(retryRound) | "-" | itoa(batchNum) batchNum++ routeMsg(buildProxyMsg(subID, "{\"authors\":" | authors | ",\"kinds\":[0,10002],\"limit\":" | itoa(len(chunk)*2) | "}", proxy)) // Direct query to feed relays. profileSubCounter++ routeMsg(buildProxyMsg("ap-d-" | itoa(profileSubCounter), "{\"authors\":" | authors | ",\"kinds\":[0],\"limit\":" | itoa(len(chunk)) | "}", relayURLs)) } 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 := profileContentCache[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\":\"" | 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. profileContentCache[pubhex] = k0content if len(newName) > 0 { profileName = newName setAuthorName(pubhex, newName) dom.SetTextContent(nameEl, newName) } if len(newPic) > 0 { profilePic = newPic authorPics[pubhex] = newPic setMediaSrc(avatarEl, newPic) dom.SetStyle(avatarEl, "display", "block") } profileTs = ts renderProfilePage(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 fetchedK0 == nil { return } // Save current profile's tab state before switching. if profileViewPK != "" && profileWrappers[profileViewPK] != 0 { profileWrapTabSel[profileViewPK] = profileTab profileWrapTabCont[profileViewPK] = profileTabContent profileWrapTabs[profileViewPK] = profileTabBtns } profileViewPK = pk // Always fetch fresh metadata in background. setFetchedK0(pk, false) profile.Send(`["P_RESOLVE_FORCE",` | jstr(pk) | `]`) if !navPop { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) dom.PushState("/p/" | npub) } // Hide all cached profile wrappers and remove relay info. for _, w := range profileWrappers { dom.SetStyle(w, "display", "none") } if relayInfoEl != 0 { dom.RemoveChild(profilePage, relayInfoEl) relayInfoEl = 0 } if wrapper, ok := profileWrappers[pk]; ok { // Cache hit - restore. dom.SetStyle(wrapper, "display", "block") profileTabContent = profileWrapTabCont[pk] profileTabBtns = profileWrapTabs[pk] profileTab = profileWrapTabSel[pk] profileTouchLRU(pk) } else { renderProfilePage(pk) } activePage = "" // force switchPage to run switchPage("profile") } const profileCacheMax = 8 func profileTouchLRU(pk string) { // Move pk to end (most recent). out := []string{:0:len(profileWrapOrder)} for _, p := range profileWrapOrder { if p != pk { out = append(out, p) } } profileWrapOrder = append(out, pk) } func profileEvictOldest() { for len(profileWrapOrder) > profileCacheMax { old := profileWrapOrder[0] profileWrapOrder = profileWrapOrder[1:] if w, ok := profileWrappers[old]; ok { dom.RemoveChild(profilePage, w) dom.ReleaseChildren(w) dom.ReleaseElement(w) } delete(profileWrappers, old) delete(profileWrapTabCont, old) delete(profileWrapTabs, old) delete(profileWrapTabSel, old) delete(profileContentCache, old) delete(profileFollowsCache, old) delete(profileMutesCache, old) delete(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 := profileTab closeProfileNoteSub() profileNotesSeen = map[string]bool{} // Remove previous wrapper for this pubkey if it exists. if old, ok := profileWrappers[pk]; ok { dom.RemoveChild(profilePage, old) } wrapper := dom.CreateElement("div") profileWrappers[pk] = wrapper profileTouchLRU(pk) profileEvictOldest() dom.AppendChild(profilePage, wrapper) content := profileContentCache[pk] name := authorNames[pk] pic := 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 != pubhex { if theirFollows, ok := profileFollowsCache[pk]; ok { for _, fpk := range theirFollows { if fpk == 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 != 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") isFollowing := false if len(myFollows) > 0 { for _, fpk := range myFollows { if fpk == pk { isFollowing = true break } } } if isFollowing { 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") profileTabBtns = map[string]dom.Element{} // Unrolled - tinyjs range/loop closure aliasing. tabNotes := makeProtoBtn(t("notes")) dom.SetStyle(tabNotes, "cursor", "pointer") 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") 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") 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") 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. profileTabContent = dom.CreateElement("div") dom.SetStyle(profileTabContent, "padding", "8px 0") dom.AppendChild(wrapper, profileTabContent) // Restore or default tab. profileTab = "" if savedTab != "" { selectProfileTab(savedTab, pk) } else { selectProfileTab("notes", pk) } // Save cache refs for this profile. profileWrapTabCont[pk] = profileTabContent profileWrapTabs[pk] = profileTabBtns profileWrapTabSel[pk] = profileTab _ = name // title shown via topBackBtn, not pageTitleEl } func toggleFollow(pk string, btn dom.Element, muteBtn dom.Element) { if !signer.HasSigner() || pubhex == "" { return } found := false var newFollows []string for _, fpk := range myFollows { if fpk == pk { found = true continue } newFollows = append(newFollows, fpk) } if !found { newFollows = append(newFollows, pk) } myFollows = newFollows 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. contactTs = dom.NowSeconds() publishFollowList(newFollows) if feedSelectEl != 0 { populateFeedSelect() } if 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\":\"" | 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(relayURLs)} copy(targets, 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 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() || pubhex == "" { return } found := false var newMutes []string for _, m := range myMutes { if m == pk { found = true continue } newMutes = append(newMutes, m) } if !found { newMutes = append(newMutes, pk) } myMutes = newMutes 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 profileViewPK == pk && profileTab == "notes" && profileTabContent != 0 { clearChildren(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) } muteTs = dom.NowSeconds() publishMuteList(newMutes) } func unmute(pk string, btn dom.Element) { var newMutes []string for _, m := range myMutes { if m != pk { newMutes = append(newMutes, m) } } myMutes = newMutes 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)") muteTs = dom.NowSeconds() publishMuteList(newMutes) } func isFollowing(pk string) (ok bool) { return followSet[pk] } func unfollow(pk string, btn dom.Element) { var newFollows []string for _, fpk := range myFollows { if fpk != pk { newFollows = append(newFollows, fpk) } } myFollows = newFollows followSet = buildSet(newFollows) dom.SetTextContent(btn, "follow") dom.SetStyle(btn, "background", "var(--accent)") dom.SetStyle(btn, "color", "#fff") dom.SetStyle(btn, "border", "none") contactTs = dom.NowSeconds() publishFollowList(newFollows) } func removeMutedNotes(pk string) { // Remove from main feed DOM. removeChildrenByPK(feedContainer, pk) // Clear profile notes tab if viewing the muted user. if profileViewPK == pk && profileTab == "notes" && profileTabContent != 0 { closeProfileNoteSub() clearChildren(profileTabContent) } // Purge from feed buffer so muted notes don't reappear on refresh. if len(feedBuffer) > 0 { var kept []*nostr.Event for _, ev := range feedBuffer { if ev.PubKey != pk { kept = append(kept, ev) } } feedBuffer = kept } // Purge from thread cache if a thread is open. if threadOpen { for id, ev := range threadEvents { if ev.PubKey == pk { delete(threadEvents, id) } } 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\":\"" | 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 activeProfileNoteSub != "" { routeMsg("[\"CLOSE\"," | jstr(activeProfileNoteSub) | "]") activeProfileNoteSub = "" } } // refreshProfileTab re-renders the active tab if we're viewing this author's profile. func refreshProfileTab(pk string) { if profileViewPK != pk || profileTab == "" { return } // Force re-render by clearing current tab and re-selecting. saved := profileTab profileTab = "" selectProfileTab(saved, pk) } func selectProfileTab(tab, pk string) { if tab == profileTab { return } closeProfileNoteSub() profileTab = tab clearChildren(profileTabContent) for id, btn := range 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 !navPop && profileViewPK != "" { npub := helpers.EncodeNpub(helpers.HexDecode(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) { profileNotesSeen = map[string]bool{} profileSubCounter++ subID := "pn-" | itoa(profileSubCounter) activeProfileNoteSub = subID proxyRelays := buildProxy(pk) routeMsg(buildProxyMsg(subID, "{\"authors\":[" | jstr(pk) | "],\"kinds\":[1],\"limit\":20}", proxyRelays)) } func renderProfileNote(ev *nostr.Event) { if profileTabContent == 0 || 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 := authorPics[pk]; ok && pic != "" { setMediaSrc(avatar, pic) dom.SetAttribute(avatar, "onerror", "this.style.display='none'") } else { dom.SetStyle(avatar, "display", "none") } if name, ok := 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 := authorNames[pk]; !cached { pendingNotes[pk] = append(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(profileTabContent, note) attachShowMore(content, more) resolveEmbeds() } func renderProfileFollows(pk string) { var follows []string if pk == pubhex { follows = myFollows } else { follows = 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(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(profileTabContent, countEl) for i := 0; i < len(follows); i++ { fpk := follows[i] row := makeProfileRow(fpk) dom.AppendChild(profileTabContent, row) } scheduleTabRetry() } func renderProfileRelays(pk string) { relays := 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(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(profileTabContent, row) } } func renderProfileMutes(pk string) { var mutes []string if pk == pubhex { mutes = myMutes } else { mutes = 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(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(profileTabContent, countEl) for i := 0; i < len(mutes); i++ { mpk := mutes[i] row := makeProfileRow(mpk) dom.AppendChild(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 := 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 := 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 := authorNames[pk]; !cached { pendingNotes[pk] = append(pendingNotes[pk], row) if !lookupFetchedK0(pk) { profile.Send(`["P_RESOLVE",` | jstr(pk) | `]`) } } return row } // --- Relay info page --- func showRelayInfo(url string) { profileViewPK = "" closeProfileNoteSub() // Hide cached profile wrappers but keep them. for _, w := range profileWrappers { dom.SetStyle(w, "display", "none") } // Remove old relay info page if any. if relayInfoEl != 0 { dom.RemoveChild(profilePage, relayInfoEl) relayInfoEl = 0 } relayInfoEl = dom.CreateElement("div") dom.AppendChild(profilePage, 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(relayInfoEl, hdr) loading := dom.CreateElement("div") dom.SetTextContent(loading, t("loading")) dom.SetStyle(loading, "padding", "16px") dom.SetStyle(loading, "color", "var(--muted)") dom.AppendChild(relayInfoEl, loading) activePage = "" switchPage("profile") dom.SetTextContent(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(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(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(relayInfoEl, container) } // --- Messaging --- func relayURLsJSON() (s string) { msg := "[" for i, url := range relayURLs { if i > 0 { msg = msg | "," } msg = msg | jstr(url) } return msg | "]" } func readRelayURLsJSON() (s string) { msg := "[" first := true for i, url := range relayURLs { role := "both" if i < len(relayRole) { role = 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 hoverCardEl != 0 { dom.Remove(hoverCardEl) hoverCardEl = 0 hoverCardParent = 0 hoverCardPK = "" hoverCardHover = false } if hoverCardTimer != 0 { dom.ClearTimeout(hoverCardTimer) hoverCardTimer = 0 } if hoverCardDismiss != 0 { dom.ClearTimeout(hoverCardDismiss) hoverCardDismiss = 0 } } func scheduleDismissHoverCard() { if hoverCardDismiss != 0 { dom.ClearTimeout(hoverCardDismiss) } hoverCardDismiss = dom.SetTimeout(func() { hoverCardDismiss = 0 if !hoverCardHover { dismissHoverCard() } }, 300) } func showHoverCard(pk string, anchor dom.Element) { if pk == pubhex { return // don't show hover card for self } dismissHoverCard() 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 := 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 := 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) } nameEl := dom.CreateElement("span") dom.SetStyle(nameEl, "fontWeight", "bold") dom.SetStyle(nameEl, "fontSize", "13px") dom.SetStyle(nameEl, "color", "var(--fg)") if name, ok := authorNames[pk]; ok && name != "" { dom.SetTextContent(nameEl, name) } else { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) if len(npub) > 20 { dom.SetTextContent(nameEl, npub[:12] | "..." | npub[len(npub)-4:]) } } dom.AppendChild(infoRow, nameEl) dom.AppendChild(card, infoRow) // Button row: follow + mute. if signer.HasSigner() && 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") isFollowing := false if len(myFollows) > 0 { for _, fpk := range myFollows { if fpk == pk { isFollowing = true break } } } if isFollowing { 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) } hoverCardHover = false dom.AddEventListener(card, "mouseenter", dom.RegisterCallback(func() { hoverCardHover = true if hoverCardDismiss != 0 { dom.ClearTimeout(hoverCardDismiss) hoverCardDismiss = 0 } })) dom.AddEventListener(card, "mouseleave", dom.RegisterCallback(func() { hoverCardHover = false scheduleDismissHoverCard() })) hoverCardEl = card 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 hoverCardTimer != 0 { dom.ClearTimeout(hoverCardTimer) } if hoverCardDismiss != 0 { dom.ClearTimeout(hoverCardDismiss) hoverCardDismiss = 0 } capPK := hcPK capAnchor := authorLink hoverCardTimer = dom.SetTimeout(func() { hoverCardTimer = 0 showHoverCard(capPK, capAnchor) }, 400) })) dom.AddEventListener(authorLink, "mouseleave", dom.RegisterCallback(func() { if hoverCardTimer != 0 { dom.ClearTimeout(hoverCardTimer) 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 := 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 := 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() seenOnSubs[evID] = renderList // Rebroadcast button - publish this event to all write relays. if 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 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(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(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 } var emojiByName map[string]string func initEmoji() { emojiByName = map[string]string{} for _, e := range emojiList { 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, ok := emojiByName[code]; ok { 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 := noteZapTotals[ev.ID]; ok && msats > 0 { dom.SetTextContent(zapTotalEl, "\xe2\x9a\xa1 " | i64toa(msats/1000) | " sats") } dom.AppendChild(zapWrap, zapTotalEl) 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 := pendingZapRows[pk] if !ok { return } delete(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 _, ok := noteReactionMap[ev.ID]; ok { fillReactionDisplay(reactDisplay, ev.ID) } 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 := noteRepostCounts[ev.ID]; ok && len(pks) > 0 { dom.SetTextContent(repostCount, itoa(len(pks))) } dom.AppendChild(repostWrap, repostCount) 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, ok := profileContentCache[ev.PubKey]; ok { if helpers.JsonGetString(content, "lud16") != "" || helpers.JsonGetString(content, "lud06") != "" { authorHasLN = true } } if authorHasLN { attachZapButton(row, ev) } else { pendingZapRows[ev.PubKey] = append(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 := 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 = append(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 actionFetchedIDs[evID] { return } actionFetchedIDs[evID] = true actionFetchQueue = append(actionFetchQueue, evID) if actionFetchTimer != 0 { dom.ClearTimeout(actionFetchTimer) } actionFetchTimer = dom.SetTimeout(func() { actionFetchTimer = 0 flushActionFetch() }, 400) } func flushActionFetch() { if len(actionFetchQueue) == 0 { return } queue := actionFetchQueue 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 | "]" actionSubCounter++ subID := "act-" | itoa(actionSubCounter) var urls []string seen := map[string]bool{} for _, u := range relayURLs { if !seen[u] { seen[u] = true urls = append(urls, u) } } for _, u := range topRelays(4) { if !seen[u] { seen[u] = true urls = append(urls, u) } } routeMsg(buildProxyMsg(subID, "{\"kinds\":[6,7,9735],\"#e\":" | ids | ",\"limit\":500}", urls)) } } func handleActionEvent(ev *nostr.Event) { if noteRepostCounts == nil || noteReactionMap == nil || noteRepostEls == nil || 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 := noteRepostCounts[targetID] if pks == nil { pks = map[string]bool{} noteRepostCounts[targetID] = pks } if !pks[ev.PubKey] { pks[ev.PubKey] = true if el, ok := noteRepostEls[targetID]; ok { dom.SetTextContent(el, itoa(len(pks))) } } } else if ev.Kind == 9735 { // Zap receipt: accumulate msat amount from the bolt11 tag (ground truth). seen := noteZapSeen[targetID] if seen == nil { seen = map[string]bool{} 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, ok := noteZapTotals[targetID]; ok { cur = v } cur = cur + msats noteZapTotals[targetID] = cur if el, ok := noteZapEls[targetID]; ok { 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 := 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 noteReactionMap[targetID] = inner if el, ok := noteReactionEls[targetID]; ok { fillReactionDisplay(el, targetID) } } } } // --- Repost --- func onRepostClick(ev *nostr.Event) { if !signer.HasSigner() || pubhex == "" { return } if !dom.Confirm("Repost this note?") { return } relay := "" if urls, ok := 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\":\"" | 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 := noteRepostCounts[ev.ID] if pks == nil { pks = map[string]bool{} noteRepostCounts[ev.ID] = pks } pks[pubhex] = true if el, ok := noteRepostEls[ev.ID]; ok { 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() || pubhex == "" { return } if editorOpen { closeEditor() } editorOpen = true editorPreviewing = false editorMode = mode editorTargetEv = ev 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") 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, 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()}") editorTextarea = ta // Restore draft or set initial content. draftKey := editorDraftKey(mode, ev.ID) if draft, ok := editorDrafts[draftKey]; ok { dom.SetProperty(ta, "value", draft) } else if mode == "quote" { relay := "" if urls, ok := 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") editorPreviewEl = prevArea dom.AppendChild(composer, prevArea) // Preview toggle. capturedTa := ta capturedPrev := prevArea capturedBtn := previewBtn dom.AddEventListener(previewBtn, "click", dom.RegisterCallback(func() { editorPreviewing = !editorPreviewing if 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 editorTextarea != 0 && editorTargetEv != nil { text := dom.GetProperty(editorTextarea, "value") key := editorDraftKey(editorMode, editorTargetEv.ID) if text != "" { editorDrafts[key] = text } else { delete(editorDrafts, key) } } if editorComposer != 0 && editorNoteEl != 0 { dom.RemoveChild(editorNoteEl, editorComposer) editorComposer = 0 editorNoteEl = 0 } editorOpen = false editorTargetEv = nil editorTextarea = 0 editorPreviewEl = 0 editorPreviewing = false closeEmojiAuto() closeMentionPopup() } func publishEditorContent() { dom.ConsoleLog("[publish-reply] enter") if editorTargetEv == nil || editorTextarea == 0 { dom.ConsoleLog("[publish-reply] no target or textarea") return } content := dom.GetProperty(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 := editorTargetEv mode := editorMode // Clear draft on publish. delete(editorDrafts, editorDraftKey(mode, ev.ID)) closeEditor() relay := "" if urls, ok := 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] != pubhex { ptags[tag[1]] = true } } if ev.PubKey != 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 != pubhex { tags = tags | ",[\"p\"," | jstr(ev.PubKey) | "]" } } // Client tag. if localstorage.GetItem("smesh-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\":\"" | 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\":\"" | pubhex | "\"}" signer.SignEvent(unsigned, func(signed string) { if signed == "" { return } delEv := nostr.ParseEvent(signed) if delEv != nil { pendingDeletes[delEv.ID] = targetID delID := delEv.ID dom.SetTimeout(func() { delete(pendingDeletes, delID) }, 30000) } routeMsg("[\"EVENT\"," | signed | "]") }) } func removeNoteFromDOM(evID string) { el, ok := noteElements[evID] if !ok { return } dom.Remove(el) delete(noteElements, evID) delete(seenEvents, evID) } func fetchDeletesForFeed() { // Collect event IDs from rendered notes. var ids []string for id := range noteElements { ids = append(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() { composeTextarea = dom.CreateElement("textarea") dom.SetAttribute(composeTextarea, "placeholder", "What's on your mind?") dom.SetStyle(composeTextarea, "width", "100%") dom.SetStyle(composeTextarea, "minHeight", "160px") dom.SetStyle(composeTextarea, "flex", "1") dom.SetStyle(composeTextarea, "padding", "12px") dom.SetStyle(composeTextarea, "fontSize", "15px") dom.SetStyle(composeTextarea, "lineHeight", "1.5") dom.SetStyle(composeTextarea, "border", "1px solid var(--border)") dom.SetStyle(composeTextarea, "borderRadius", "8px") dom.SetStyle(composeTextarea, "background", "var(--bg)") dom.SetStyle(composeTextarea, "color", "var(--fg)") dom.SetStyle(composeTextarea, "resize", "vertical") dom.SetStyle(composeTextarea, "overflowY", "auto") dom.SetStyle(composeTextarea, "boxSizing", "border-box") dom.SetStyle(composeTextarea, "fontFamily", "inherit") dom.SetAttribute(composeTextarea, "onkeydown", "if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();this.parentNode.querySelector('[data-publish-btn]').click()}") dom.AppendChild(composePage, composeTextarea) // Preview area (hidden by default). composePreview = dom.CreateElement("div") dom.SetStyle(composePreview, "display", "none") dom.SetStyle(composePreview, "padding", "12px") dom.SetStyle(composePreview, "border", "1px solid var(--border)") dom.SetStyle(composePreview, "borderRadius", "8px") dom.SetStyle(composePreview, "marginTop", "8px") dom.SetStyle(composePreview, "lineHeight", "1.5") dom.SetStyle(composePreview, "wordBreak", "break-word") dom.AppendChild(composePage, 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() composeRelayPopEl = dom.CreateElement("div") dom.SetStyle(composeRelayPopEl, "display", "none") dom.SetStyle(composeRelayPopEl, "position", "absolute") dom.SetStyle(composeRelayPopEl, "bottom", "100%") dom.SetStyle(composeRelayPopEl, "left", "0") dom.SetStyle(composeRelayPopEl, "marginBottom", "4px") dom.SetStyle(composeRelayPopEl, "background", "var(--bg)") dom.SetStyle(composeRelayPopEl, "border", "1px solid var(--border)") dom.SetStyle(composeRelayPopEl, "borderRadius", "6px") dom.SetStyle(composeRelayPopEl, "padding", "8px") dom.SetStyle(composeRelayPopEl, "boxShadow", "0 -2px 8px rgba(0,0,0,0.2)") dom.SetStyle(composeRelayPopEl, "zIndex", "1000") dom.SetStyle(composeRelayPopEl, "minWidth", "200px") dom.SetStyle(composeRelayPopEl, "maxWidth", "calc(100vw - 24px)") dom.SetStyle(composeRelayPopEl, "maxHeight", "200px") dom.SetStyle(composeRelayPopEl, "overflowY", "auto") dom.AddEventListener(relaySelBtn, "click", dom.RegisterCallback(func() { if composeRelayPopOpen { dom.SetStyle(composeRelayPopEl, "display", "none") composeRelayPopOpen = false updateRelaySelBtn(relaySelBtn, false) } else { populateRelayPopover(composeRelayPopEl, func() { updateRelaySelBtn(relaySelBtn, true) updateRelayWarnBadge(composeWarnBadge) }) dom.SetStyle(composeRelayPopEl, "display", "block") composeRelayPopOpen = true updateRelaySelBtn(relaySelBtn, true) } })) dom.AppendChild(relaySelWrap, relaySelBtn) dom.AppendChild(relaySelWrap, composeWarnBadge) dom.AppendChild(relaySelWrap, 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, 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(composeTextarea, "value") if !composePreviewing { dom.SetStyle(composePreview, "display", "block") dom.SetStyle(composeTextarea, "display", "none") setHTMLWithMedia(composePreview, renderMarkdown(content)) dom.SetTextContent(previewBtn, "edit") composePreviewing = true } else { dom.SetStyle(composePreview, "display", "none") dom.SetStyle(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(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=" | pubhex) if !signer.HasSigner() || pubhex == "" { dom.ConsoleLog("[publish-compose] no signer or pubhex") return } ts := dom.NowSeconds() tags := "[" if localstorage.GetItem("smesh-client-tag") != "0" { tags = tags | "[\"client\",\"https://smesh.lol\"]" } tags = tags | "]" unsigned := "{\"kind\":1,\"content\":" | jstr(content) | ",\"tags\":" | tags | ",\"created_at\":" | i64toa(ts) | ",\"pubkey\":\"" | 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(composeTextarea, "value", "") dom.SetStyle(composePreview, "display", "none") dom.SetStyle(composeTextarea, "display", "block") composeRelayPopOpen = false dom.SetStyle(composeRelayPopEl, "display", "none") dom.SetProperty(contentArea, "scrollTop", "0") switchPage("feed") }) })) dom.AppendChild(btnRow, pubBtn) dom.AppendChild(composePage, btnRow) // Emoji autocomplete popup. emojiAutoEl = dom.CreateElement("div") dom.SetStyle(emojiAutoEl, "display", "none") dom.SetStyle(emojiAutoEl, "position", "fixed") dom.SetStyle(emojiAutoEl, "background", "var(--bg)") dom.SetStyle(emojiAutoEl, "border", "1px solid var(--border)") dom.SetStyle(emojiAutoEl, "borderRadius", "6px") dom.SetStyle(emojiAutoEl, "padding", "4px") dom.SetStyle(emojiAutoEl, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)") dom.SetStyle(emojiAutoEl, "zIndex", "2000") dom.SetStyle(emojiAutoEl, "maxHeight", "200px") dom.SetStyle(emojiAutoEl, "overflowY", "auto") dom.SetStyle(emojiAutoEl, "minWidth", "200px") dom.AppendChild(dom.Body(), emojiAutoEl) wireEmojiHandler(composeTextarea) wireBlossomHandlers(composeTextarea) wireMentionHandler(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 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 " | 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\":\"" | 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 := 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(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 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\":\"" | pubhex | "\"}" signer.SignEvent(unsigned, func(signed string) { if signed == "" { dom.SetProperty(input, "value", "") return } authB64 := helpers.Base64Encode([]byte(signed)) authHeader := "Nostr " | authB64 url := 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(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 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, _ := authorPics[pk] results = append(results, mentionMatch{pk: pk, name: label, pic: pic}) } } return results } func initMentionPopup() { mentionPopup = dom.CreateElement("div") dom.SetStyle(mentionPopup, "display", "none") dom.SetStyle(mentionPopup, "position", "fixed") dom.SetStyle(mentionPopup, "background", "var(--bg)") dom.SetStyle(mentionPopup, "border", "1px solid var(--border)") dom.SetStyle(mentionPopup, "borderRadius", "6px") dom.SetStyle(mentionPopup, "padding", "4px") dom.SetStyle(mentionPopup, "boxShadow", "0 2px 8px rgba(0,0,0,0.25)") dom.SetStyle(mentionPopup, "zIndex", "3000") dom.SetStyle(mentionPopup, "maxHeight", "200px") dom.SetStyle(mentionPopup, "overflowY", "auto") dom.SetStyle(mentionPopup, "minWidth", "220px") dom.AppendChild(dom.Body(), mentionPopup) } func showMentionPopup(ta dom.Element, atPos int32, query string) { matches := mentionSearch(query) if len(matches) == 0 { closeMentionPopup() return } mentionTA = ta mentionQuery = query mentionAtPos = atPos mentionItems = matches mentionSelIdx = 0 clearChildren(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 mentionSelIdx >= 0 && mentionSelIdx < len(mentionItems) { dom.SetStyle(row, "background", "transparent") } })) capturedPK := m.pk dom.AddEventListener(row, "mousedown", dom.RegisterCallback(func() { insertMention(capturedPK) })) dom.AppendChild(mentionPopup, row) } positionMentionPopup(ta) dom.SetStyle(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(mentionPopup, "top", itoa(cursorY+lineH) | "px") dom.SetStyle(mentionPopup, "bottom", "auto") } else { dom.SetStyle(mentionPopup, "bottom", itoa(vh-cursorY) | "px") dom.SetStyle(mentionPopup, "top", "auto") } _ = popupH xOff := left + col*8 if xOff > right-220 { xOff = right - 220 } if xOff < left { xOff = left } dom.SetStyle(mentionPopup, "left", itoa(xOff) | "px") }) } func insertMention(pk string) { if mentionTA == 0 { return } npub := helpers.EncodeNpub(helpers.HexDecode(pk)) val := dom.GetProperty(mentionTA, "value") cur := parseIntProp(dom.GetProperty(mentionTA, "selectionStart")) replacement := "nostr:" | npub | " " before := val[:mentionAtPos] after := val[cur:] dom.SetProperty(mentionTA, "value", before | replacement | after) newCur := itoa(len(before) + len(replacement)) dom.SetProperty(mentionTA, "selectionStart", newCur) dom.SetProperty(mentionTA, "selectionEnd", newCur) closeMentionPopup() dom.Focus(mentionTA) } func closeMentionPopup() { dom.SetStyle(mentionPopup, "display", "none") mentionItems = nil mentionSelIdx = 0 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 mentionTA == 0 || len(mentionItems) == 0 { if key == "Escape" { closeMentionPopup() } return } if key == "Escape" { closeMentionPopup() prevent() } else if key == "ArrowDown" { mentionSelIdx++ if mentionSelIdx >= len(mentionItems) { mentionSelIdx = 0 } highlightMentionRow() prevent() } else if key == "ArrowUp" { mentionSelIdx-- if mentionSelIdx < 0 { mentionSelIdx = len(mentionItems) - 1 } highlightMentionRow() prevent() } else if key == "Enter" || key == "Tab" { insertMention(mentionItems[mentionSelIdx].pk) prevent() } }) } func highlightMentionRow() { child := dom.FirstElementChild(mentionPopup) idx := 0 for child != 0 { if idx == 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, ok := emojiByName[code]; ok { 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 = append(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 = append(matches, e) } } } if len(matches) == 0 { closeEmojiAuto() return } emojiAutoOpen = true emojiAutoStart = colonPos emojiAutoTA = ta clearChildren(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(emojiAutoEl, row) } dom.SetStyle(emojiAutoEl, "display", "block") positionEmojiPopup() } func insertEmojiAt(emoji string) { ta := emojiAutoTA if ta == 0 { return } val := dom.GetProperty(ta, "value") cur := parseIntProp(dom.GetProperty(ta, "selectionStart")) before := val[: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 emojiAutoOpen { dom.SetStyle(emojiAutoEl, "display", "none") emojiAutoOpen = false } } func positionEmojiPopup() { ta := 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(emojiAutoEl, "top", itoa(cursorY+lineH) | "px") dom.SetStyle(emojiAutoEl, "bottom", "auto") } else { dom.SetStyle(emojiAutoEl, "bottom", itoa(vh-cursorY) | "px") dom.SetStyle(emojiAutoEl, "top", "auto") } xOff := left + col*8 if xOff > right-220 { xOff = right - 220 } if xOff < left { xOff = left } dom.SetStyle(emojiAutoEl, "left", itoa(xOff) | "px") }) } func populateComposeRelays() { populateRelayPopover(composeRelayPopEl, nil) } func populateRelayPopover(target dom.Element, onChanged func()) { renderRows := func() { clearChildren(target) populateRelayRows(target, onChanged) if onChanged != nil { onChanged() } } if composeRelayChecks == nil { composeRelayChecks = map[string]bool{} for _, u := range relayURLs { composeRelayChecks[u] = true } dom.IDBGet("cache", "compose_relays", func(val string) { if val != "" { composeRelayChecks = map[string]bool{} start := 0 for i := 0; i <= len(val); i++ { if i == len(val) || val[i] == ',' { if i > start { composeRelayChecks[val[start:i]] = true } start = i + 1 } } } renderRows() }) return } renderRows() } func populateRelayRows(target dom.Element, onChanged func()) { for idx, u := range 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 := 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() { composeRelayChecks[relayURL] = !composeRelayChecks[relayURL] applyRowStyle(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 := 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 composeRelayChecks == nil { return false } for i, u := range relayURLs { if !composeRelayChecks[u] { continue } caps := 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 composeRelayChecks { if sel { if val != "" { val = val | "," } val = val | u } } dom.IDBPut("cache", "compose_relays", val) } func composeSelectedRelays() (ss []string) { if composeRelayChecks == nil { return relayURLs } var out []string for u, sel := range composeRelayChecks { if sel { out = append(out, u) } } // If nothing selected, fall back to all relays. if len(out) == 0 { return 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 threadOpen { threadEvents[ev.ID] = ev threadFocusID = ev.ID dom.ReplaceState("/t/" | 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) seenEvents[ev.ID] = true } // --- Emoji picker --- func showEmojiPicker(ev *nostr.Event, anchor dom.Element) { _ = anchor if !signer.HasSigner() || pubhex == "" { return } if emojiOpen { closeEmojiPicker() return } emojiOpen = true 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() })) 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 emojiOverlay != 0 { dom.RemoveChild(dom.Body(), emojiOverlay) emojiOverlay = 0 } emojiOpen = false emojiTargetEv = nil } func publishReaction(ev *nostr.Event, emoji string) { if !signer.HasSigner() || pubhex == "" { return } relay := "" if urls, ok := 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\":\"" | 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 := noteReactionMap[capturedEvID] if ri == nil { ri = map[string]map[string]bool{} } pks := ri[capturedEmoji] if pks == nil { pks = map[string]bool{} } pks[pubhex] = true ri[capturedEmoji] = pks noteReactionMap[capturedEvID] = ri if el, ok := noteReactionEls[capturedEvID]; ok { 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(settingsPage) title := dom.CreateElement("h2") dom.SetTextContent(title, t("settings_title")) dom.SetStyle(title, "fontSize", "20px") dom.SetStyle(title, "marginBottom", "24px") dom.AppendChild(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 == 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(pageTitleEl, t("settings")) renderSettings() })) dom.AppendChild(langRow, langSel) dom.AppendChild(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 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 isDark { dom.SetTextContent(themeToggle, t("dark")) } else { dom.SetTextContent(themeToggle, t("light")) } saveAppSettings() })) dom.AppendChild(themeRow, themeToggle) dom.AppendChild(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("smesh-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("smesh-client-tag") } else { localstorage.SetItem("smesh-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(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 := 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") 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(settingsPage, blossomRow) // Invidious URL setting. appendServiceURLSetting(settingsPage, "invidious server", invidiousURL, defaultServiceURL("invidious"), lsKeyInvidiousURL, func(v string) { invidiousURL = v }, "use invidious.smesh.lol") // Campion (Bandcamp player) URL setting. appendServiceURLSetting(settingsPage, "campion (bandcamp)", campionURL, defaultServiceURL("campion"), lsKeyCampionURL, func(v string) { 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(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(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 relayPolicyFlags&bit != 0 { dom.SetAttribute(cb, "checked", "checked") } dom.AddEventListener(cb, "change", dom.RegisterCallback(func() { if dom.GetProperty(cb, "checked") == "true" { relayPolicyFlags = relayPolicyFlags | bit } else { relayPolicyFlags = relayPolicyFlags &^ bit } saveRelayPolicy() })) dom.AppendChild(row, cb) lblEl := dom.CreateElement("span") dom.SetTextContent(lblEl, label) dom.AppendChild(row, lblEl) dom.AppendChild(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(settingsPage, activeHdr) for i, url := range 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 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(relayRole) { role = relayRole[thisI] } applyRelayRoleBtns(srBtn, swBtn, role) capturedI := thisI dom.AddEventListener(srBtn, "click", dom.RegisterCallback(func() { if capturedI >= len(relayRole) { return } switch relayRole[capturedI] { case "both": relayRole[capturedI] = "write" case "read": relayRole[capturedI] = "write" default: relayRole[capturedI] = "both" } if capturedI < len(relayReadBtns) { applyRelayRoleBtns(relayReadBtns[capturedI], relayWriteBtns[capturedI], relayRole[capturedI]) } applyRelayRoleBtns(srBtn, swBtn, relayRole[capturedI]) saveRelayList() sendWriteRelays() })) dom.AddEventListener(swBtn, "click", dom.RegisterCallback(func() { if capturedI >= len(relayRole) { return } switch relayRole[capturedI] { case "both": relayRole[capturedI] = "read" case "write": relayRole[capturedI] = "read" default: relayRole[capturedI] = "both" } if capturedI < len(relayReadBtns) { applyRelayRoleBtns(relayReadBtns[capturedI], relayWriteBtns[capturedI], relayRole[capturedI]) } applyRelayRoleBtns(srBtn, swBtn, relayRole[capturedI]) saveRelayList() sendWriteRelays() })) caps := 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(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 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(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(settingsPage, blHdr) if len(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(settingsPage, empty) } else { for _, url := range 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(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(settingsPage, blAddRow) // Wallet UI at the very bottom of the settings page. if int32(walletPanel) != 0 { dom.AppendChild(settingsPage, walletPanel) renderWalletUI() } } // --- Messaging --- func initMessaging() { // Render new-chat button immediately - don't wait for DM_LIST round-trip. clearChildren(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(msgListContainer, notice) return } renderNewChatRow() // Init MLS if not already done + request conversation list. if !marmotInited { 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(msgListContainer, row) } func populateFollowsDropdown(dropdown dom.Element) { clearChildren(dropdown) pks := 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 := authorNames[pk] if n != "" { named = append(named, entry{pk, n}) } else { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) if len(npub) > 16 { npub = npub[:16] | "..." } unnamed = append(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 = append(all, e) } for _, e := range unnamed { all = append(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 := 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 msgView != "list" { return } clearChildren(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(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 := 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 := 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 := authorNames[peer]; !cached && !lookupFetchedK0(peer) { profile.Send(`["P_RESOLVE",` | jstr(peer) | `]`) } dom.AppendChild(msgListContainer, row) } func openThread(peer string) { msgCurrentConvoID = peer msgCurrentKind = "dm" msgView = "thread" if !navPop { npub := helpers.EncodeNpub(helpers.HexDecode(peer)) dom.PushState("/msg/" | npub) } // Hide list, show thread. dom.SetStyle(msgListContainer, "display", "none") dom.SetStyle(msgThreadContainer, "display", "flex") // Build thread UI. clearChildren(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 := 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 := 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(msgThreadMessages) } })) dom.AppendChild(hdr, ratchetBtn) dom.AppendChild(msgThreadContainer, hdr) // Track for live update when profile arrives. if _, cached := authorNames[peer]; !cached { pendingNotes[peer] = append(pendingNotes[peer], threadHdrInner) } // Message area. msgThreadMessages = dom.CreateElement("div") dom.SetStyle(msgThreadMessages, "flex", "1") dom.SetStyle(msgThreadMessages, "overflowY", "auto") dom.SetStyle(msgThreadMessages, "padding", "12px 16px") dom.AppendChild(msgThreadContainer, 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") msgComposeInput = dom.CreateElement("textarea") dom.SetAttribute(msgComposeInput, "placeholder", t("msg_placeholder")) dom.SetStyle(msgComposeInput, "flex", "1") dom.SetStyle(msgComposeInput, "padding", "8px") dom.SetStyle(msgComposeInput, "fontFamily", "'Fira Code', monospace") dom.SetStyle(msgComposeInput, "fontSize", "13px") dom.SetStyle(msgComposeInput, "background", "var(--bg)") dom.SetStyle(msgComposeInput, "border", "1px solid var(--border)") dom.SetStyle(msgComposeInput, "borderRadius", "4px") dom.SetStyle(msgComposeInput, "color", "var(--fg)") dom.SetStyle(msgComposeInput, "minHeight", "22px") dom.SetStyle(msgComposeInput, "maxHeight", "120px") dom.SetStyle(msgComposeInput, "overflowY", "auto") dom.SetAttribute(msgComposeInput, "onkeydown", "if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.nextSibling.click()}") wireBlossomHandlers(msgComposeInput) wireMentionHandler(msgComposeInput) dom.AppendChild(compose, 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(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 := 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() { msgCurrentConvoID = "" msgCurrentKind = "" msgView = "list" dom.SetStyle(msgThreadContainer, "display", "none") dom.SetStyle(msgListContainer, "display", "block") if !navPop { dom.PushState("/msg") } // Refresh list. routeMsg("[\"MLS_DM_LIST\"]") } func renderThreadMessages(peer, msgsJSON string) { if peer != 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 = append(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(msgThreadMessages) for _, m := range msgs { appendBubble(m.from, m.content, m.ts) } scrollToBottom() } func appendBubble(from, content string, ts int64) { isSent := from == 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 { pendingTsEls = append(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(msgComposeInput, "value", quoted) })) dom.AppendChild(outer, replyBtn) } } dom.AppendChild(wrap, outer) dom.AppendChild(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(msgThreadMessages, wrap) scrollToBottom() } func scrollToBottom() { dom.SetProperty(msgThreadMessages, "scrollTop", "999999") } func sendMessage() { content := dom.GetProperty(msgComposeInput, "value") if content == "" || msgCurrentConvoID == "" { return } // Clear input. dom.SetProperty(msgComposeInput, "value", "") if msgCurrentKind == "group" { routeMsg("[\"MLS_SEND_GROUP\"," | jstr(msgCurrentConvoID) | "," | jstr(content) | "]") } else { routeMsg("[\"MLS_SEND_DM\"," | jstr(msgCurrentConvoID) | "," | jstr(content) | "]") } // Optimistic render (ts=0 - timestamp not shown for "just sent"). appendBubble(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 != pubhex { showNotifDot() } if msgView == "thread" && peer == msgCurrentConvoID { if from == pubhex { return } appendBubble(from, content, ts) scrollToBottom() } else if msgView == "list" { routeMsg("[\"MLS_DM_LIST\"]") } } // --- Notifications --- func subscribeNotifications() { if pubhex == "" { return } // Reset Shell-side state. oldestNotifTs = 0 notifExhausted = false notifEmptyStrk = 0 notifInitLoad = true notifEvents = []*nostr.Event{:0} 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(pubhex) | `]`) notif.Send(`["N_SET_RELAYS",` | readRelayURLsJSON() | `]`) notif.Send(`["N_READ_TS",` | i64toa(notifReadTs) | `]`) if len(myMutes) > 0 { notif.Send(`["N_SET_MUTES",` | buildJSONStrArr(myMutes) | `]`) } notif.Send(`["N_SUBSCRIBE"]`) } func handleNotifEvent(ev *nostr.Event) { if notifSeen == nil { notifSeen = map[string]bool{} } if notifSeen[ev.ID] { return } notifSeen[ev.ID] = true // Advance cursor before filters so muted/self events don't stall pagination. if oldestNotifTs == 0 || ev.CreatedAt < oldestNotifTs { oldestNotifTs = ev.CreatedAt } self := ev.PubKey == 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 { notifMissing = append(notifMissing, refID) } } } // Remove empty-state placeholder on first event. if notifEmptyEl != 0 { dom.RemoveChild(notifContainer, notifEmptyEl) notifEmptyEl = 0 } if notifInitLoad { notifEvents = append(notifEvents, ev) if len(notifEvents) > notifEventsMax { notifEvents = notifEvents[len(notifEvents)-notifEventsMax:] } if notifBuilt && activePage == "notifications" && notifPassesFilter(ev) { row := renderNotifRow(ev) dom.InsertBefore(notifContainer, row, notifLoadMore) } } else { notifEvents = []*nostr.Event{ev} | notifEvents if len(notifEvents) > notifEventsMax { notifEvents = notifEvents[:notifEventsMax] } if notifBuilt && activePage == "notifications" && notifPassesFilter(ev) { row := renderNotifRow(ev) first := dom.FirstElementChild(notifContainer) if first != 0 { dom.InsertBefore(notifContainer, row, first) } else { dom.AppendChild(notifContainer, row) } } if ev.Kind != 1 { if notifRefTimer != 0 { dom.ClearTimeout(notifRefTimer) } notifRefTimer = dom.SetTimeout(func() { notifRefTimer = 0 fetchNotifRefs() }, 500) } } maybeShowNotifDot(ev) } func fetchNotifRefs() { if len(notifMissing) == 0 { return } // Dedup against eventCache and already-requested. seen := map[string]bool{} var ids []string for _, id := range notifMissing { if _, ok := lookupEvent(id); ok { continue } if seen[id] { continue } seen[id] = true ids = append(ids, id) } 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(notifEvents); i++ { for j := i + 1; j < len(notifEvents); j++ { if notifEvents[j].CreatedAt > notifEvents[i].CreatedAt { notifEvents[i], notifEvents[j] = notifEvents[j], notifEvents[i] } } } } func insertAtTop(row dom.Element) { first := dom.FirstChild(notifContainer) if first != 0 { dom.InsertBefore(notifContainer, row, first) } else { dom.InsertBefore(notifContainer, row, notifLoadMore) } } func loadOlderNotifs() { notifLoading = true notifMoreGot = 0 dom.ConsoleLog("[ntf-more] loading until=" | i64toa(oldestNotifTs-1)) filter := "{\"kinds\":[1,6,7,9735],\"#p\":[" | jstr(pubhex) | "],\"limit\":20,\"until\":" | i64toa(oldestNotifTs-1) | "}" routeMsg(buildProxyMsg("ntf-more", filter, readRelayURLs())) if notifMoreTimer != 0 { dom.ClearTimeout(notifMoreTimer) } notifMoreTimer = dom.SetTimeout(func() { notifMoreTimer = 0 if notifLoading { notifLoading = false if notifMoreGot == 0 { notifEmptyStrk++ if notifEmptyStrk >= 3 { notifExhausted = true } } else { notifEmptyStrk = 0 } if notifExhausted { dom.SetStyle(notifLoadMore, "display", "none") } else if oldestNotifTs > 0 { dom.SetStyle(notifLoadMore, "display", "block") } } }, 10000) } func handleNotifMoreEvent(ev *nostr.Event) { if notifSeen == nil { notifSeen = map[string]bool{} } if notifSeen[ev.ID] { return } notifSeen[ev.ID] = true // Advance cursor before filters so muted/self events don't stall pagination. if oldestNotifTs == 0 || ev.CreatedAt < oldestNotifTs { oldestNotifTs = ev.CreatedAt } if ev.PubKey == pubhex { return } if isMuted(notifSenderPK(ev)) { return } if repliesToMuted(ev) { return } notifMoreGot++ if ev.Kind != 1 { refID := notifRefID(ev) if refID != "" { if _, ok := lookupEvent(refID); !ok { notifMissing = append(notifMissing, refID) } } } notifEvents = append(notifEvents, ev) if len(notifEvents) > notifEventsMax { notifEvents = notifEvents[len(notifEvents)-notifEventsMax:] } if activePage == "notifications" && notifLoadMore != 0 && notifPassesFilter(ev) { row := renderNotifRow(ev) dom.InsertBefore(notifContainer, row, notifLoadMore) } } func showNotifDot() { if !notifUnread { notifUnread = true dom.SetStyle(notifDot, "display", "block") } } func maybeShowNotifDot(ev *nostr.Event) { if ev.CreatedAt > notifReadTs && activePage != "notifications" { showNotifDot() } } func notifPassesFilter(ev *nostr.Event) (ok bool) { switch 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 notifContainer == 0 { return } notifBuilt = true sortNotifEvents() clearChildren(notifContainer) shown := 0 for _, ev := range notifEvents { if !notifPassesFilter(ev) { continue } shown++ row := renderNotifRow(ev) dom.AppendChild(notifContainer, row) } if shown == 0 { notifEmptyEl = dom.CreateElement("div") dom.SetTextContent(notifEmptyEl, t("no_notifications")) dom.SetStyle(notifEmptyEl, "color", "var(--muted)") dom.SetStyle(notifEmptyEl, "fontFamily", "'Fira Code', monospace") dom.SetStyle(notifEmptyEl, "fontSize", "13px") dom.AppendChild(notifContainer, notifEmptyEl) } else { notifEmptyEl = 0 } notifLoadMore = dom.CreateElement("div") dom.SetStyle(notifLoadMore, "textAlign", "center") dom.SetStyle(notifLoadMore, "padding", "16px 0") if notifExhausted || oldestNotifTs == 0 { dom.SetStyle(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 !notifLoading && !notifExhausted && oldestNotifTs > 0 { dom.SetStyle(notifLoadMore, "display", "none") loadOlderNotifs() } })) dom.AppendChild(notifLoadMore, lnk) dom.AppendChild(notifContainer, 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 _, id := range notifRowCBs { dom.ReleaseCallback(id) } notifRowCBs = nil } // notifRegCB registers a callback and records its ID for later release. func notifRegCB(fn func()) (n int32) { id := dom.RegisterCallback(fn) notifRowCBs = append(notifRowCBs, id) return id } func rebuildNotifRows() { if notifContainer == 0 { return } releaseNotifRowCBs() clearChildren(notifContainer) shown := 0 for _, ev := range notifEvents { if !notifPassesFilter(ev) { continue } shown++ dom.AppendChild(notifContainer, renderNotifRow(ev)) } if shown == 0 { notifEmptyEl = dom.CreateElement("div") dom.SetTextContent(notifEmptyEl, t("no_notifications")) dom.SetStyle(notifEmptyEl, "color", "var(--muted)") dom.SetStyle(notifEmptyEl, "fontFamily", "'Fira Code', monospace") dom.SetStyle(notifEmptyEl, "fontSize", "13px") dom.AppendChild(notifContainer, notifEmptyEl) } else { notifEmptyEl = 0 } if notifLoadMore != 0 { dom.AppendChild(notifContainer, notifLoadMore) } } func markNotifsRead() { if len(notifEvents) > 0 { notifReadTs = notifEvents[0].CreatedAt dom.IDBPut("settings", "notif-read-ts", i64toa(notifReadTs)) // Publish watermark to relay so it syncs to other devices/sessions. saveAppSettings() } notifUnread = false dom.SetStyle(notifDot, "display", "none") el := dom.QuerySelectorFrom(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(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 > 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 := authorPics[senderPK] if !picOK || pic == "" { pic = "/smesh-logo.svg" profile.Send(`["P_RESOLVE_FORCE",` | jstr(senderPK) | `]`) // Register ava so applyAuthorProfile updates it immediately when loaded. pendingNotifAvatars[senderPK] = append(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 = append(lines, s) return lines } lines = append(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 = append(*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 { embedRelayHints[coord] = append(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 { 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 := 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 := 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, ok := emojiByName[code]; ok { html = emoji } else { html = "" | code | "" } id := len(*slots) *slots = append(*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) (string, 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{ "REQ": true, "CLOSE": true, "EVENT": true, "PUBLISH_TO": true, "PROXY": true, "SET_PUBKEY": true, "SET_WRITE_RELAYS": true, "ENC_KEY": true, } // mlsWorkerTypes are routed to the MLS Worker. var mlsWorkerTypes = map[string]bool{ "MLS_INIT": true, "MLS_SEND_DM": true, "MLS_SEND_GROUP": true, "MLS_RATCHET_DM": true, "MLS_DM_LIST": true, "MLS_DM_HISTORY": true, } // swTypes are routed to the Service Worker. var swTypes = map[string]bool{ "activate-update": true, } // 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 mlsWorkerTypes[typ] { mlsw.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(root dom.Element, html string) { dom.SetInnerHTML(root, html) flushYtTitles(root) attachImageLightboxes(root) attachYTLightboxes(root) attachCampionLinks(root) attachHashtagLinks(root) } // ytTitleCache maps YouTube video ID -> fetched title. var ytTitleCache = map[string]string{} // flushYtTitles finds every [data-ytid] span and fills it with the video title. func flushYtTitles(root dom.Element) { el := dom.QuerySelectorFrom(root, "[data-ytid]") for el != 0 { ytID := dom.GetAttribute(el, "data-ytid") dom.RemoveAttribute(el, "data-ytid") if ytID == "" { el = dom.QuerySelectorFrom(root, "[data-ytid]") continue } if title, ok := ytTitleCache[ytID]; ok { dom.SetTextContent(el, title) el = dom.QuerySelectorFrom(root, "[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 } ytTitleCache[capturedID] = title dom.SetTextContent(capturedEl, title) }) el = dom.QuerySelectorFrom(root, "[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(root dom.Element) { el := dom.QuerySelectorFrom(root, "[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(root, "[data-orig-url]") } } // attachYTLightboxes wires click handlers on [data-invidious-id] divs. // Clicking replaces the thumbnail inline with an Invidious embed iframe. func attachYTLightboxes(root dom.Element) { el := dom.QuerySelectorFrom(root, "[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 invidiousURL != "" { inlineInvidiousEmbed(capturedEl, capturedID) } else { dom.WindowOpen(capturedHref) } })) } el = dom.QuerySelectorFrom(root, "[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", 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(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(root dom.Element) { el := dom.QuerySelectorFrom(root, "[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 campionURL != "" { dom.WindowOpen(campionURL | "?url=" | dom.EncodeURIComponent(capturedURL)) } else { dom.WindowOpen(capturedURL) } })) } el = dom.QuerySelectorFrom(root, "[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 := 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 threadEvents { parentID := getReplyID(ev) if parentID == "" || parentID == id { continue } if threadEvents[parentID] == nil && parentID != threadRootID { if referencesEvent(ev, threadRootID) { parentID = threadRootID } } ch[parentID] = append(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 := threadEvents[id] if !ok { return } result = append(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 := 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 := embedSubIDs[subID] if !ok { return } delete(embedSubIDs, subID) for _, hexID := range ids { if _, still := embedCallbacks[hexID]; !still { continue } // Check if any other sub is still pending for this ID. otherPending := false for _, otherIDs := range 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(embedRequested, hexID) elemIDs := embedCallbacks[hexID] delete(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 := embedCoordCBs[coord] if !ok { // Also check old generation. elemIDs, ok = embedCoordCBsOld[coord] if !ok { return } delete(embedCoordCBsOld, coord) } else { delete(embedCoordCBs, coord) } name := ev.PubKey[:8] | "..." if n, ok2 := 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 := 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 := embedCoordSubIDs[subID] if !ok { return } delete(embedCoordSubIDs, subID) // Check if any other coord sub is still pending for this coord. for _, other := range embedCoordSubIDs { if other == coord { return } } elemIDs, ok := embedCoordCBs[coord] if !ok { return } delete(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) { embedCounter++ elemID := "emb-" | itoa(embedCounter) embedCoordCBs[coord] = append(embedCoordCBs[coord], elemID) label := "Loading..." if kind == uint32(30023) { label = "Loading article..." } return "
" | "" | label | "
" } func makeEmbedPlaceholder(hexID string) (s string) { embedCounter++ elemID := "emb-" | itoa(embedCounter) embedCallbacks[hexID] = append(embedCallbacks[hexID], elemID) return "
" | "Loading note...
" } func resolveEmbeds() { if len(embedCallbacks) == 0 && len(embedCoordCBs) == 0 { return } if len(embedCallbacks) == 0 { for coord := range embedCoordCBs { resolveCoordEmbed(coord) } return } // Fill any embeds already in cache before requesting. for hexID := range embedCallbacks { if ev, ok := lookupEvent(hexID); ok && ev != nil { fillEmbed(ev) } } if len(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 embedCallbacks { if embedRequested[hexID] { if _, ok := lookupEvent(hexID); ok { continue } delete(embedRequested, hexID) } ids = append(ids, hexID) 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. embedCounter++ reqID := "emb-r-" | itoa(embedCounter) 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 = append(urls, u) } } for _, id := range ids { hints := embedRelayHints[id] if len(hints) == 0 { hints = embedRelayHintsOld[id] delete(embedRelayHintsOld, id) } else { delete(embedRelayHints, id) } for _, u := range hints { if !seen[u] { seen[u] = true urls = append(urls, u) } } } if len(urls) > 0 { proxyID := "emb-p-" | itoa(embedCounter) embedSubIDs[proxyID] = ids routeMsg(buildProxyMsg(proxyID, filter, urls)) } // Coord embeds: one PROXY sub per pending coord. for coord := range 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 := embedRelayHints[coord] if len(coordRelays) > 0 { delete(embedRelayHints, coord) } else { coordRelays = embedRelayHintsOld[coord] delete(embedRelayHintsOld, coord) } var urls []string seen := map[string]bool{} for _, u := range readRelayURLs() { if !seen[u] { seen[u] = true urls = append(urls, u) } } for _, u := range coordRelays { if !seen[u] { seen[u] = true urls = append(urls, u) } } embedCounter++ if len(urls) > 0 { proxyID := "emb-c-" | itoa(embedCounter) embedCoordSubIDs[proxyID] = coord routeMsg(buildProxyMsg(proxyID, filter, urls)) } reqID := "emb-cr-" | itoa(embedCounter) 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, ok := embedCallbacks[ev.ID] if !ok { return } delete(embedCallbacks, ev.ID) embedCacheCount++ if embedCacheCount > rotateEmbedMax { rotateEmbedCaches() } name := ev.PubKey[:8] | "..." if n, ok := authorNames[ev.PubKey]; ok && n != "" { name = n } headerHTML := "
" if pic, ok := authorPics[ev.PubKey]; ok && 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 _, ok := authorNames[ev.PubKey]; !ok { 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 !searchBarActive { searchReturnPage = activePage // remember where to return on manual close } searchBarActive = true dom.SetStyle(btn, "background", "var(--accent)") dom.SetStyle(btn, "color", "#fff") dom.SetStyle(btn, "borderRadius", "4px") dom.SetStyle(feedSelectEl, "display", "none") dom.SetStyle(pageTitleEl, "display", "none") dom.SetStyle(notifBarGroup, "display", "none") dom.SetStyle(topBarRight, "display", "none") dom.SetStyle(searchInputEl, "display", "block") dom.SetStyle(searchInputEl, "flex", "1") dom.SetStyle(searchSubmitBtn, "display", "block") dom.Focus(searchInputEl) } // deactivateSearchBar restores the bar appearance only. // Callers are responsible for any page navigation. func deactivateSearchBar(btn dom.Element) { searchBarActive = false dom.SetStyle(btn, "background", "transparent") dom.SetStyle(btn, "color", "var(--muted)") dom.SetStyle(btn, "borderRadius", "4px") dom.SetStyle(topBarRight, "display", "flex") dom.SetStyle(searchInputEl, "display", "none") dom.SetStyle(searchInputEl, "flex", "") dom.SetStyle(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 := searchReturnPage if ret == "" || ret == "search" { ret = "feed" } searchReturnPage = "" 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 } searchCurrentQ = q searchResults = []*nostr.Event{:0} 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 = 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(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(searchContainer, loading) return } relayWord := "relays" if len(searchRelayList) == 1 { relayWord = "relay" } dom.SetTextContent(loading, "searching " | itoa(len(searchRelayList)) | " " | relayWord | "...") dom.AppendChild(searchContainer, loading) routeMsg("[\"CLOSE\",\"search\"]") routeMsg(buildProxyMsg("search", filter, searchRelayList)) } func buildSearchFilterBar() { // Rebuild the filter bar (recreate buttons so state labels are fresh). if searchFilterBarEl != 0 { dom.RemoveChild(searchPage, searchFilterBarEl) } bar := dom.CreateElement("div") 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", searchFollowsOnly) dom.AddEventListener(followsBtn, "click", dom.RegisterCallback(func() { searchFollowsOnly = !searchFollowsOnly applySearchFilterBtn(followsBtn, searchFollowsOnly) renderSearchResults() })) dom.AppendChild(bar, followsBtn) sortBtn := searchFilterBtn("newest first", searchSortDesc) dom.SetAttribute(sortBtn, "data-sort", "1") dom.AddEventListener(sortBtn, "click", dom.RegisterCallback(func() { searchSortDesc = !searchSortDesc if searchSortDesc { dom.SetTextContent(sortBtn, "newest first") } else { dom.SetTextContent(sortBtn, "oldest first") } applySearchFilterBtn(sortBtn, true) renderSearchResults() })) dom.AppendChild(bar, sortBtn) nsfwBtn := searchFilterBtn("nsfw", searchNSFW) dom.AddEventListener(nsfwBtn, "click", dom.RegisterCallback(func() { searchNSFW = !searchNSFW applySearchFilterBtn(nsfwBtn, searchNSFW) renderSearchResults() })) dom.AppendChild(bar, nsfwBtn) // Insert filter bar BEFORE searchContainer in searchPage. dom.InsertBefore(searchPage, bar, 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 searchSeen[ev.ID] { return } searchSeen[ev.ID] = true if isMuted(ev.PubKey) { return } if looksLikeJSONSpam(ev.Content) { return } searchResults = append(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 searchResults { if searchFollowsOnly && !isFollowing(ev.PubKey) { continue } if !searchNSFW { if ev.Tags.GetFirst([]byte("content-warning")) != nil { continue } } filtered = append(filtered, ev) } for i := 0; i < len(filtered); i++ { for j := i + 1; j < len(filtered); j++ { var swap bool if 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() { searchRendered = 0 clearChildren(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(searchContainer, empty) return } end := len(filtered) if end > searchPageSize { end = searchPageSize } for i := 0; i < end; i++ { dom.AppendChild(searchContainer, renderSearchResult(filtered[i])) } searchRendered = end } // appendSearchResults renders the next batch of results (called on scroll). func appendSearchResults() { filtered := filteredSearchResults() if searchRendered >= len(filtered) { return } end := searchRendered + searchPageSize if end > len(filtered) { end = len(filtered) } for i := searchRendered; i < end; i++ { dom.AppendChild(searchContainer, renderSearchResult(filtered[i])) } 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 := 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 := 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 := authorNames[pk]; !cached { pendingNotes[pk] = append(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 = append(*slots, span) out = out | "\x02HASH" | itoa(idx) | "\x02" i = j } return out } // attachHashtagLinks wires click handlers on [data-search-tag] spans. func attachHashtagLinks(root dom.Element) { el := dom.QuerySelectorFrom(root, "[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(searchInputEl, "value", "#" | capturedTag) executeSearch("#" | capturedTag) })) } el = dom.QuerySelectorFrom(root, "[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 relayURLs { if i < len(relayCaps) && hasNIP(relayCaps[i].supportedNIPs, 50) { out = append(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 = append(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, "smeshPK") == pk { toRemove = append(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:]) }