package main import ( "smesh.lol/web/common/helpers" "smesh.lol/web/common/jsbridge/dom" "smesh.lol/web/common/jsbridge/localstorage" "smesh.lol/web/common/jsbridge/signer" "smesh.lol/web/common/nostr" ) const ( lsKeyPubkey = "smesh-pubkey" lsKeyTheme = "smesh-theme" ) var ( pubkey []byte pubhex string isDark bool // Profile data from kind 0. profileName string profilePic string profileTs int64 // DOM refs that need updating after creation. avatarEl dom.Element nameEl dom.Element feedContainer dom.Element feedLoader dom.Element statusEl dom.Element popoverEl dom.Element themeBtn dom.Element pageTitleEl dom.Element topBackBtn dom.Element feedPage dom.Element msgPage dom.Element profilePage dom.Element sidebarFeed dom.Element sidebarMsg dom.Element sidebarSettings dom.Element settingsPage dom.Element 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 msgCurrentPeer string // hex pubkey of open thread, "" when on list msgView string // "list" or "thread" marmotInited bool pendingTsEls []dom.Element // timestamp divs awaiting relay confirmation // Relay tracking — parallel slices, grown dynamically. relayURLs []string relayDots []dom.Element relayLabels []dom.Element relayUserPick []bool // true = from user's kind 10002 eventCount int popoverOpen bool 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 int // consecutive empty EOSE responses // Author profile cache. authorNames map[string]string // pubkey hex -> display name authorPics map[string]string // pubkey hex -> avatar URL authorContent map[string]string // pubkey hex -> full kind 0 content JSON authorTs map[string]int64 // pubkey hex -> created_at of cached kind 0 authorRelays map[string][]string // pubkey hex -> relay URLs from kind 10002 pendingNotes map[string][]dom.Element // pubkey hex -> author header divs awaiting profile fetchedK0 map[string]bool // pubkey hex -> already tried kind 0 fetch fetchedK10k map[string]bool // pubkey hex -> already tried kind 10002 fetch seenEvents map[string]bool // event ID -> already rendered authorSubPK map[string]string // subID -> pubkey hex for author profile subs // Relay frequency — how many kind 10002 lists include each relay URL. relayFreq map[string]int idbLoaded bool retryRound int // metadata retry round counter retryTimer int // debounce timer for batch retries resubTimer int // debounce timer for relay list → resubscribe fetchQueue []string // pubkeys queued for batch profile fetch fetchTimer int // debounce timer for fetch queue // QR modal. logoSVGCache string // Profile page tab state. profileTab string profileTabContent dom.Element profileTabBtns map[string]dom.Element authorFollows map[string][]string authorMutes map[string][]string profileNotesSeen map[string]bool activeProfileNoteSub string // History/routing. navPop bool // true during popstate — suppresses pushState routerInited bool // guard against duplicate listener registration // Embedded nostr: entity resolution. embedCounter int embedCallbacks map[string][]string // hex event ID -> DOM element IDs awaiting fill embedRelayHints map[string][]string // hex event ID -> relay hints from nevent TLV // Thread view state. threadPage dom.Element threadContainer dom.Element threadRootID string threadFocusID string threadEvents map[string]*nostr.Event threadSubCount int threadOpen bool threadPushedState bool // true if we pushed history for this thread threadRenderTimer int threadGen int // generation counter — reject events from old threads threadActiveSubs []string // sub IDs to close when opening a new thread threadLastRendered int // event count at last render — skip if unchanged 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 replyTimer int ) const orlyRelay = "wss://relay.orly.dev" var defaultRelays = []string{ orlyRelay, "wss://nostr.wine", "wss://nostr.land", } func isLocalDev() bool { h := dom.Hostname() return h == "localhost" || (len(h) > 4 && h[:4] == "127.") } func localRelayURL() 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) initLang() // Always add the host relay as first fallback — it's the smesh relay serving this page. localRelay := localRelayURL() defaultRelays = append([]string{localRelay}, defaultRelays...) dom.ConsoleLog("local relay: " + localRelay) themePref := localstorage.GetItem(lsKeyTheme) if themePref != "" { isDark = themePref == "dark" } else { isDark = dom.PrefersDark() } htmlEl := dom.QuerySelector("html") if isDark { dom.AddClass(htmlEl, "dark") } root = dom.GetElementById("app-root") dom.SetAttribute(root, "data-version", version) stored := localstorage.GetItem(lsKeyPubkey) if stored != "" { pubhex = stored pubkey = helpers.HexDecode(stored) 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() } 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 isFirefox() bool { ua := dom.UserAgent() for i := 0; i+8 <= len(ua); i++ { if ua[i:i+8] == "Firefox/" { return true } } return false } func isMobile() bool { ua := dom.UserAgent() for i := 0; i+6 <= len(ua); i++ { if ua[i:i+6] == "Mobile" || ua[i:i+7] == "Android" { return true } } return false } func showAboutModal() { backdrop := dom.CreateElement("div") dom.SetAttribute(backdrop, "class", "signer-backdrop") card := dom.CreateElement("div") dom.SetAttribute(card, "class", "signer-card") dom.SetStyle(card, "width", "420px") dom.SetStyle(card, "padding", "24px") dom.SetStyle(card, "textAlign", "center") title := dom.CreateElement("h2") dom.SetTextContent(title, "S.M.E.S.H. "+version) dom.SetStyle(title, "fontSize", "20px") dom.SetStyle(title, "marginBottom", "16px") dom.SetStyle(title, "color", "var(--accent)") dom.AppendChild(card, title) devLabel := dom.CreateElement("p") dom.SetTextContent(devLabel, t("developed_by")) dom.SetStyle(devLabel, "fontSize", "12px") dom.SetStyle(devLabel, "color", "var(--muted)") dom.SetStyle(devLabel, "marginBottom", "4px") dom.AppendChild(card, devLabel) npubText := "npub1fjqqy4a93z5zsjwsfxqhc2764kvykfdyttvldkkkdera8dr78vhsmmleku" npubEl := dom.CreateElement("p") dom.SetStyle(npubEl, "fontSize", "11px") dom.SetStyle(npubEl, "color", "var(--fg)") dom.SetStyle(npubEl, "wordBreak", "break-all") dom.SetStyle(npubEl, "marginBottom", "20px") dom.SetStyle(npubEl, "fontFamily", "'Fira Code', monospace") dom.SetTextContent(npubEl, npubText) dom.AppendChild(card, npubEl) tagline := dom.CreateElement("p") dom.SetStyle(tagline, "fontSize", "16px") dom.SetStyle(tagline, "fontWeight", "bold") dom.SetStyle(tagline, "color", "var(--fg)") dom.SetStyle(tagline, "marginBottom", "12px") dom.SetTextContent(tagline, t("tagline")) dom.AppendChild(card, tagline) funny := dom.CreateElement("p") dom.SetStyle(funny, "fontSize", "13px") dom.SetStyle(funny, "color", "var(--fg)") dom.SetStyle(funny, "marginBottom", "20px") dom.SetStyle(funny, "lineHeight", "1.6") dom.SetTextContent(funny, t("about_donkey")) dom.AppendChild(card, funny) lud := dom.CreateElement("p") dom.SetStyle(lud, "fontSize", "13px") dom.SetStyle(lud, "color", "var(--accent)") dom.SetTextContent(lud, "\xe2\x9a\xa1 mlekudev@getalby.com") dom.AppendChild(card, lud) dom.AddSelfEventListener(backdrop, "click", dom.RegisterCallback(func() { dom.RemoveChild(dom.Body(), backdrop) })) dom.AppendChild(backdrop, card) dom.AppendChild(dom.Body(), backdrop) } func showLogin() { clearChildren(root) wrap := dom.CreateElement("div") dom.SetStyle(wrap, "display", "flex") dom.SetStyle(wrap, "alignItems", "center") dom.SetStyle(wrap, "justifyContent", "center") dom.SetStyle(wrap, "height", "100vh") dom.SetStyle(wrap, "flexDirection", "column") // 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.FirstChild(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.SetTextContent(sub, t("subtitle")) dom.AppendChild(wrap, sub) if !isFirefox() { notice := dom.CreateElement("div") dom.SetStyle(notice, "maxWidth", "400px") dom.SetStyle(notice, "textAlign", "center") dom.SetStyle(notice, "padding", "16px 24px") dom.SetStyle(notice, "border", "1px solid var(--border)") dom.SetStyle(notice, "borderRadius", "8px") dom.SetStyle(notice, "background", "var(--bg2)") n1 := dom.CreateElement("p") dom.SetStyle(n1, "fontSize", "14px") dom.SetStyle(n1, "marginBottom", "12px") dom.SetStyle(n1, "color", "var(--fg)") if isMobile() { dom.SetTextContent(n1, t("req_fennec")) } else { dom.SetTextContent(n1, t("req_librewolf")) } dom.AppendChild(notice, n1) dlLink := dom.CreateElement("a") dom.SetAttribute(dlLink, "target", "_blank") dom.SetStyle(dlLink, "color", "var(--accent)") dom.SetStyle(dlLink, "fontSize", "14px") dom.SetStyle(dlLink, "display", "block") dom.SetStyle(dlLink, "marginBottom", "16px") if isMobile() { dom.SetAttribute(dlLink, "href", "https://f-droid.org/packages/org.mozilla.fennec_fdroid/") dom.SetTextContent(dlLink, t("get_fennec")) } else { dom.SetAttribute(dlLink, "href", "https://librewolf.net/installation/") dom.SetTextContent(dlLink, t("dl_librewolf")) } dom.AppendChild(notice, dlLink) why := dom.CreateElement("p") dom.SetStyle(why, "fontSize", "11px") dom.SetStyle(why, "color", "var(--muted)") dom.SetStyle(why, "lineHeight", "1.5") dom.SetTextContent(why, t("chrome_why")) dom.AppendChild(notice, why) dom.AppendChild(wrap, notice) appendLoginFooter(wrap) dom.AppendChild(root, wrap) return } // Firefox — 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) // Only show install link if signer extension is not detected. signer.IsInstalled(func(ok bool) { if !ok { xpiLink := dom.CreateElement("a") dom.SetAttribute(xpiLink, "href", "/smesh-signer.xpi") dom.SetStyle(xpiLink, "display", "block") dom.SetStyle(xpiLink, "marginTop", "12px") dom.SetStyle(xpiLink, "color", "var(--accent)") dom.SetStyle(xpiLink, "fontSize", "13px") dom.SetTextContent(xpiLink, t("install_signer")) dom.AppendChild(wrap, xpiLink) } }) appendLoginFooter(wrap) dom.AppendChild(root, wrap) cb := dom.RegisterCallback(func() { if !signer.HasSigner() { dom.SetTextContent(errEl, t("err_no_ext")) return } signer.GetVaultStatus(func(status string) { if status == "none" || status == "locked" { showSignerModal() 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")) showSignerModal() return } pubhex = hex pubkey = helpers.HexDecode(hex) localstorage.SetItem(lsKeyPubkey, pubhex) clearChildren(root) showApp() }) }) }) dom.AddEventListener(btn, "click", cb) } func appendLoginFooter(wrap dom.Element) { footer := dom.CreateElement("div") dom.SetStyle(footer, "display", "flex") dom.SetStyle(footer, "gap", "16px") 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) dom.AppendChild(wrap, footer) // GeoIP auto-detection — only if user hasn't set a language preference. saved := localstorage.GetItem(lsKeyLang) if saved == "" { dom.FetchText("https://ipapi.co/json/", func(body string) { if body == "" { return } cc := helpers.JsonGetString(body, "country_code") if cc == "" { return } detected := countryToLang(cc) if detected != currentLang { setLang(detected) showLogin() } }) } } // --- Sidebar --- const svgFeed = `` const svgChat = `` const svgGear = `` func makeSidebarIcon(svgHTML string, active bool) 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 } func switchPage(name string) { // Always close thread if open, even when re-selecting the feed tab. if threadOpen { closeNoteThread() dom.ReplaceState("/") } if name == activePage { return } closeProfileNoteSub() if activePage == "profile" { profileViewPK = "" } activePage = name dom.SetTextContent(pageTitleEl, t(name)) // Ensure top bar is in normal state. dom.SetStyle(topBackBtn, "display", "none") 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") // Clear sidebar highlights. dom.SetStyle(sidebarFeed, "background", "transparent") dom.SetStyle(sidebarFeed, "color", "var(--fg)") dom.SetStyle(sidebarMsg, "background", "transparent") dom.SetStyle(sidebarMsg, "color", "var(--fg)") dom.SetStyle(sidebarSettings, "background", "transparent") dom.SetStyle(sidebarSettings, "color", "var(--fg)") switch name { case "feed": dom.SetStyle(feedPage, "display", "block") dom.SetStyle(sidebarFeed, "background", "var(--accent)") dom.SetStyle(sidebarFeed, "color", "#fff") if !navPop { dom.PushState("/") } case "messaging": dom.SetStyle(msgPage, "display", "block") dom.SetStyle(sidebarMsg, "background", "var(--accent)") dom.SetStyle(sidebarMsg, "color", "#fff") dom.PostToSW("[\"PAGE\",\"messaging\"]") initMessaging() if !navPop { dom.PushState("/msg") } case "settings": dom.SetStyle(settingsPage, "display", "block") dom.SetStyle(sidebarSettings, "background", "var(--accent)") dom.SetStyle(sidebarSettings, "color", "#fff") renderSettings() case "profile": dom.SetStyle(profilePage, "display", "block") // Profile URL is pushed by showProfile, not here. } } // 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 } } if path == "/" || path == "/feed" || path == "" { if threadOpen { closeNoteThread() } switchPage("feed") } else if len(path) > 3 && path[:3] == "/t/" { switchPage("feed") // Thread URL: /t/ rootID := path[3:] if len(rootID) == 64 { showNoteThread(rootID, rootID) } } 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 len(path) > 3 && path[:3] == "/p/" { pk := npubToHex(path[3:]) if pk != "" { showProfile(pk) if hash != "" { selectProfileTab(hash, pk) } } } } func npubToHex(npub string) 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 }) // Navigate to initial URL if not root. // Thread URLs (/t/) are ephemeral — refresh always returns to feed. path := dom.GetPath() if len(path) > 3 && path[:3] == "/t/" { dom.ReplaceState("/") } else if path != "/" && path != "" { navPop = true navigateToPath(path) navPop = false } else { dom.ReplaceState("/") } } func makeProtoBtn(label string) dom.Element { btn := dom.CreateElement("button") dom.SetTextContent(btn, label) dom.SetStyle(btn, "padding", "6px 16px") 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() { // Init profile cache maps. authorNames = map[string]string{} authorPics = map[string]string{} authorContent = map[string]string{} authorTs = map[string]int64{} authorRelays = map[string][]string{} pendingNotes = map[string][]dom.Element{} fetchedK0 = map[string]bool{} fetchedK10k = map[string]bool{} relayFreq = map[string]int{} authorSubPK = map[string]string{} seenEvents = map[string]bool{} authorFollows = map[string][]string{} authorMutes = map[string][]string{} profileNotesSeen = map[string]bool{} profileTabBtns = map[string]dom.Element{} embedCallbacks = map[string][]string{} embedRelayHints = map[string][]string{} threadEvents = map[string]*nostr.Event{} replyCache = map[string]string{} replyAvatarCache = map[string]string{} replyLineCache = map[string]string{} replyAuthorMap = map[string]string{} replyPending = map[string][]dom.Element{} replyNeedName = map[string][]dom.Element{} // Set up SW communication (only register once — survives DOM rebuilds). if !routerInited { dom.OnSWMessage(onSWMessage) } dom.PostToSW("[\"SET_PUBKEY\"," + jstr(pubhex) + "]") // Load cached profiles from IndexedDB. dom.IDBGetAll("profiles", func(key, val string) { name := helpers.JsonGetString(val, "name") pic := helpers.JsonGetString(val, "picture") if name != "" { authorNames[key] = name } if pic != "" { authorPics[key] = pic } }, func() { idbLoaded = true // Update note headers rendered before IDB finished loading. for pk, headers := range pendingNotes { name := authorNames[pk] pic := authorPics[pk] if name != "" { for _, h := range headers { updateNoteHeader(h, name, pic) } delete(pendingNotes, pk) fetchedK0[pk] = true // don't fetch, already cached } } }) // === Top bar === bar := dom.CreateElement("div") dom.SetStyle(bar, "display", "flex") dom.SetStyle(bar, "alignItems", "center") dom.SetStyle(bar, "padding", "8px 16px") 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") 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() { closeNoteThread() activePage = "" // force switchPage to run even if already on feed switchPage("feed") })) dom.AppendChild(left, topBackBtn) pageTitleEl = dom.CreateElement("span") dom.SetStyle(pageTitleEl, "fontSize", "18px") dom.SetStyle(pageTitleEl, "fontWeight", "bold") dom.SetTextContent(pageTitleEl, t("feed")) dom.AppendChild(left, pageTitleEl) dom.AppendChild(bar, left) // Center: smesh logo. 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.FetchText("./smesh-logo.svg", func(svg string) { logoSVGCache = svg dom.SetInnerHTML(logo, svg) svgEl := dom.FirstChild(logo) if svgEl != 0 { dom.SetAttribute(svgEl, "width", "100%") dom.SetAttribute(svgEl, "height", "100%") } }) dom.AppendChild(bar, logo) // Right: theme toggle + logout. right := dom.CreateElement("div") 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") 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() })) dom.AppendChild(right, themeBtn) logout := dom.CreateElement("button") dom.SetInnerHTML(logout, ``) dom.SetAttribute(logout, "title", "logout") dom.SetStyle(logout, "background", "transparent") dom.SetStyle(logout, "border", "none") dom.SetStyle(logout, "color", "var(--muted)") dom.SetStyle(logout, "height", "32px") dom.SetStyle(logout, "width", "32px") dom.SetStyle(logout, "display", "flex") dom.SetStyle(logout, "alignItems", "center") dom.SetStyle(logout, "justifyContent", "center") dom.SetStyle(logout, "cursor", "pointer") dom.AddEventListener(logout, "click", dom.RegisterCallback(func() { doLogout() })) dom.AppendChild(right, logout) dom.AppendChild(bar, right) dom.AppendChild(root, bar) // === Main layout: sidebar + content === 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") // Sidebar. sidebar := dom.CreateElement("div") dom.SetStyle(sidebar, "width", "44px") dom.SetStyle(sidebar, "flexShrink", "0") dom.SetStyle(sidebar, "background", "var(--bg2)") dom.SetStyle(sidebar, "display", "flex") dom.SetStyle(sidebar, "flexDirection", "column") dom.SetStyle(sidebar, "alignItems", "center") dom.SetStyle(sidebar, "paddingTop", "8px") dom.SetStyle(sidebar, "gap", "4px") sidebarFeed = makeSidebarIcon(svgFeed, true) dom.AddEventListener(sidebarFeed, "click", dom.RegisterCallback(func() { switchPage("feed") })) dom.AppendChild(sidebar, sidebarFeed) sidebarMsg = makeSidebarIcon(svgChat, false) dom.AddEventListener(sidebarMsg, "click", dom.RegisterCallback(func() { switchPage("messaging") })) dom.AppendChild(sidebar, sidebarMsg) sidebarSettings = makeSidebarIcon(svgGear, false) dom.AddEventListener(sidebarSettings, "click", dom.RegisterCallback(func() { switchPage("settings") })) dom.AppendChild(sidebar, sidebarSettings) dom.AppendChild(mainLayout, sidebar) // Content area. contentArea = dom.CreateElement("div") dom.SetStyle(contentArea, "flex", "1") dom.SetStyle(contentArea, "overflowY", "auto") // Feed page. feedPage = dom.CreateElement("div") dom.SetStyle(feedPage, "padding", "16px") // 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.FirstChild(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) feedContainer = dom.CreateElement("div") dom.AppendChild(feedPage, feedContainer) // 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) dom.AppendChild(contentArea, feedPage) // Infinite scroll — load older events when near bottom. dom.AddEventListener(contentArea, "scroll", dom.RegisterCallback(func() { if activePage != "feed" { return } if feedLoading || feedExhausted || oldestFeedTs == 0 { dom.ConsoleLog("[scroll] skip: loading=" + boolStr(feedLoading) + " exhausted=" + boolStr(feedExhausted) + " oldestTs=" + i64toa(oldestFeedTs)) return } st := dom.GetProperty(contentArea, "scrollTop") ch := dom.GetProperty(contentArea, "clientHeight") sh := dom.GetProperty(contentArea, "scrollHeight") top := parseIntProp(st) height := parseIntProp(ch) total := parseIntProp(sh) if top+height >= total-400 { dom.ConsoleLog("[scroll] TRIGGER loadOlderFeed: top=" + itoa(top) + " height=" + itoa(height) + " total=" + itoa(total)) loadOlderFeed() } })) // 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.AppendChild(contentArea, profilePage) // Settings page. settingsPage = dom.CreateElement("div") dom.SetStyle(settingsPage, "display", "none") dom.SetStyle(settingsPage, "padding", "16px") dom.AppendChild(contentArea, settingsPage) dom.AppendChild(mainLayout, contentArea) dom.AppendChild(root, mainLayout) activePage = "feed" // === Bottom status bar === 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 12px") 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, "zIndex", "100") // Avatar + name in clickable box. userBtn := dom.CreateElement("div") dom.SetStyle(userBtn, "display", "flex") dom.SetStyle(userBtn, "alignItems", "center") dom.SetStyle(userBtn, "gap", "6px") dom.SetStyle(userBtn, "padding", "4px 10px") dom.SetStyle(userBtn, "border", "none") dom.SetStyle(userBtn, "borderRadius", "4px") dom.SetStyle(userBtn, "cursor", "pointer") avatarEl = dom.CreateElement("img") dom.SetAttribute(avatarEl, "referrerpolicy", "no-referrer") dom.SetAttribute(avatarEl, "width", "20") dom.SetAttribute(avatarEl, "height", "20") dom.SetStyle(avatarEl, "borderRadius", "50%") dom.SetStyle(avatarEl, "objectFit", "cover") dom.SetStyle(avatarEl, "display", "none") dom.SetAttribute(avatarEl, "onerror", "this.style.display='none'") dom.AppendChild(userBtn, avatarEl) nameEl = dom.CreateElement("span") dom.SetStyle(nameEl, "fontSize", "12px") dom.SetStyle(nameEl, "fontFamily", "system-ui, sans-serif, 'Noto Color Emoji'") dom.SetStyle(nameEl, "fontWeight", "bold") dom.SetStyle(nameEl, "overflow", "hidden") dom.SetStyle(nameEl, "textOverflow", "ellipsis") dom.SetStyle(nameEl, "whiteSpace", "nowrap") dom.SetStyle(nameEl, "maxWidth", "120px") npubStr := helpers.EncodeNpub(pubkey) if len(npubStr) > 20 { dom.SetTextContent(nameEl, npubStr[:12]+"..."+npubStr[len(npubStr)-4:]) } dom.AppendChild(userBtn, nameEl) dom.AddEventListener(userBtn, "click", dom.RegisterCallback(func() { showProfile(pubhex) })) dom.AppendChild(bottomBar, userBtn) sep := dom.CreateElement("span") dom.SetTextContent(sep, "|") dom.SetStyle(sep, "color", "var(--muted)") dom.AppendChild(bottomBar, sep) statusEl = dom.CreateElement("span") dom.SetTextContent(statusEl, t("connecting")) dom.SetStyle(statusEl, "cursor", "pointer") dom.AppendChild(bottomBar, statusEl) dom.AddEventListener(statusEl, "click", dom.RegisterCallback(func() { togglePopover() })) ver := dom.CreateElement("span") dom.SetTextContent(ver, "S.M.E.S.H. "+version) dom.SetStyle(ver, "position", "absolute") dom.SetStyle(ver, "left", "50%") dom.SetStyle(ver, "transform", "translateX(-50%)") dom.SetStyle(ver, "color", "var(--accent)") dom.SetStyle(ver, "cursor", "pointer") dom.AddEventListener(ver, "click", dom.RegisterCallback(func() { showAboutModal() })) dom.AppendChild(bottomBar, ver) signerBtn := dom.CreateElement("button") dom.SetTextContent(signerBtn, "signer") dom.SetStyle(signerBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(signerBtn, "fontSize", "12px") dom.SetStyle(signerBtn, "background", "transparent") dom.SetStyle(signerBtn, "border", "none") dom.SetStyle(signerBtn, "color", "var(--muted)") dom.SetStyle(signerBtn, "cursor", "pointer") dom.SetStyle(signerBtn, "marginLeft", "auto") dom.AddEventListener(signerBtn, "click", dom.RegisterCallback(func() { showSignerModal() })) dom.AppendChild(bottomBar, signerBtn) dom.AppendChild(root, bottomBar) // === Relay popover (hidden) === popoverEl = dom.CreateElement("div") dom.SetStyle(popoverEl, "position", "fixed") dom.SetStyle(popoverEl, "bottom", "37px") dom.SetStyle(popoverEl, "left", "44px") dom.SetStyle(popoverEl, "right", "0") dom.SetStyle(popoverEl, "background", "var(--bg2)") dom.SetStyle(popoverEl, "borderTop", "1px solid var(--border)") dom.SetStyle(popoverEl, "padding", "12px 16px") dom.SetStyle(popoverEl, "fontSize", "12px") dom.SetStyle(popoverEl, "display", "none") dom.SetStyle(popoverEl, "zIndex", "99") dom.AppendChild(root, popoverEl) // Add default relays. for _, url := range defaultRelays { addRelay(url, false) } // Tell SW about relays and subscribe. sendWriteRelays() subscribeProfile() subscribeFeed() // Publish any kind 0 queued during identity derivation (relays are now live). flushPendingK0() // Wire up browser history navigation. initRouter() // Check for signer extension. initSigner() } // addRelay adds a relay to the list and creates its popover row. // userPick=true means it came from the user's kind 10002 relay list. func addRelay(url string, userPick bool) { url = normalizeURL(url) // 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) // Popover row. row := dom.CreateElement("div") dom.SetStyle(row, "padding", "3px 0") dot := dom.CreateElement("span") dom.SetTextContent(dot, "\u25CF") dom.SetStyle(dot, "color", "#5b5") dom.SetStyle(dot, "marginRight", "8px") relayDots = append(relayDots, dot) dom.AppendChild(row, dot) label := dom.CreateElement("span") dom.SetTextContent(label, url) if userPick { dom.SetStyle(label, "fontWeight", "bold") } relayLabels = append(relayLabels, label) dom.AppendChild(row, label) dom.AppendChild(popoverEl, row) updateStatus() } func togglePopover() { popoverOpen = !popoverOpen if popoverOpen { dom.SetStyle(popoverEl, "display", "block") } else { dom.SetStyle(popoverEl, "display", "none") } } func subscribeProfile() { proxy := []string{:len(discoveryRelays):len(discoveryRelays)+len(relayURLs)} copy(proxy, discoveryRelays) for _, u := range relayURLs { proxy = appendUnique(proxy, u) } dom.PostToSW(buildProxyMsg("prof", "{\"authors\":["+jstr(pubhex)+"],\"kinds\":[0,3,10002,10000,10050],\"limit\":8}", proxy)) } func subscribeFeed() { oldestFeedTs = 0 feedExhausted = false feedEmptyStreak = 0 dom.PostToSW(buildProxyMsg("feed", "{\"kinds\":[1],\"limit\":20}", relayURLs)) } var feedMoreGot int func loadOlderFeed() { feedLoading = true feedMoreGot = 0 filter := "{\"kinds\":[1],\"until\":" + i64toa(oldestFeedTs) + ",\"limit\":20}" dom.PostToSW(buildProxyMsg("feed-more", filter, relayURLs)) } func trackOldestTs(ev *nostr.Event) { if oldestFeedTs == 0 || ev.CreatedAt < oldestFeedTs { oldestFeedTs = ev.CreatedAt } } func parseIntProp(s string) int { n := 0 for i := 0; i < len(s); i++ { if s[i] >= '0' && s[i] <= '9' { n = n*10 + int(s[i]-'0') } else { break } } return n } func boolStr(b bool) string { if b { return "true" } return "false" } func i64toa(n int64) 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\",[" for i, url := range relayURLs { if i > 0 { msg += "," } msg += jstr(url) } dom.PostToSW(msg + "]]") } func buildProxyMsg(subID, filterJSON string, urls []string) string { msg := "[\"PROXY\"," + jstr(subID) + "," + filterJSON + ",[" for i, url := range urls { if i > 0 { msg += "," } msg += jstr(url) } return msg + "]]" } func jstr(s string) 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 { fetchedK0[pk] = false } for _, pk := range missing { queueProfileFetch(pk) } }, 5000) } // --- SW message handling --- func onSWMessage(raw string) { if raw == "update-available" { dom.PostToSW("activate-update") return } if raw == "reload" { dom.LocationReload() 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 "DM_LIST": listJSON := extractValue(raw, pos) renderConversationList(listJSON) case "DM_HISTORY": peer, pos2 := nextStr(raw, pos) msgsJSON := extractValue(raw, pos2) renderThreadMessages(peer, msgsJSON) case "DM_RECEIVED": dmJSON := extractValue(raw, pos) handleDMReceived(dmJSON) case "DM_SENT": tsStr := nextNum(raw, pos) var ts int64 for i := 0; i < len(tsStr); i++ { if tsStr[i] >= '0' && tsStr[i] <= '9' { ts = ts*10 + int64(tsStr[i]-'0') } } if ts > 0 && len(pendingTsEls) > 0 { dom.SetTextContent(pendingTsEls[0], formatTime(ts)) pendingTsEls = pendingTsEls[1:] } case "DM_HISTORY_CLEARED": // Messages already cleared optimistically on ratchet button click. peer, _ := nextStr(raw, pos) dom.ConsoleLog("[mls] history cleared for " + peer) case "MLS_GROUPS": // Store for future use. case "MLS_STATUS": text, _ := nextStr(raw, pos) dom.ConsoleLog("[mls] " + text) case "SW_LOG": origin, pos2 := nextStr(raw, pos) logMsg, _ := nextStr(raw, pos2) dom.ConsoleLog("[" + origin + "] " + logMsg) return case "CRYPTO_REQ": handleCryptoReq(raw, pos) case "NEED_IDENTITY": if pubhex != "" { dom.PostToSW("[\"SET_PUBKEY\"," + jstr(pubhex) + "]") } resubscribe() case "RESUB": resubscribe() } } func resubscribe() { sendWriteRelays() subscribeProfile() subscribeFeed() if activePage == "messaging" { initMessaging() } } func dispatchEvent(subID string, ev *nostr.Event) { if subID == "prof" { handleProfileEvent(ev) } else if subID == "feed" { if seenEvents[ev.ID] { return } seenEvents[ev.ID] = true eventCount++ if feedLoader != 0 { dom.RemoveChild(feedPage, feedLoader) feedLoader = 0 } trackOldestTs(ev) renderNote(ev) } else if subID == "feed-more" { if seenEvents[ev.ID] { return } seenEvents[ev.ID] = true eventCount++ trackOldestTs(ev) feedMoreGot++ appendNote(ev) } else if len(subID) > 3 && subID[:3] == "ap-" { if ev.Kind == 0 { applyAuthorProfile(ev.PubKey, ev) } else if ev.Kind == 3 { var pks []string for _, tag := range ev.Tags.GetAll("p") { if v := tag.Value(); v != "" { pks = append(pks, v) } } authorFollows[ev.PubKey] = pks refreshProfileTab(ev.PubKey) } else if ev.Kind == 10002 { recordRelayFreq(ev) } else if ev.Kind == 10000 { var pks []string for _, tag := range ev.Tags.GetAll("p") { if v := tag.Value(); v != "" { pks = append(pks, v) } } authorMutes[ev.PubKey] = pks refreshProfileTab(ev.PubKey) } } 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) > 4 && subID[:4] == "emb-" { fillEmbed(ev) } else if len(subID) > 3 && subID[:3] == "rp-" { handleReplyPreviewEvent(ev) } else if len(subID) > 4 && subID[:4] == "thr-" { handleThreadEvent(threadGen, ev) } } func dispatchEOSE(subID string) { if subID == "feed-more" { feedLoading = false if feedMoreGot == 0 { feedEmptyStreak++ if feedEmptyStreak >= 3 { feedExhausted = true } } else { feedEmptyStreak = 0 } dom.PostToSW("[\"CLOSE\",\"feed-more\"]") updateStatus() } else if subID == "feed" { if feedLoader != 0 { dom.RemoveChild(feedPage, feedLoader) feedLoader = 0 } updateStatus() retryMissingProfiles() } else if len(subID) > 9 && subID[:9] == "ap-batch-" { // Delay CLOSE: server-side _proxy fan-out to external relays takes 5-15s. // Keep sub alive so late-arriving events flow through pushToMatchingSubs. closeID := subID dom.SetTimeout(func() { dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]") }, 15000) // Debounce: schedule follow-up retry 10s after last batch EOSE (max 3 rounds). if retryRound <= 3 { if retryTimer != 0 { dom.ClearTimeout(retryTimer) } retryTimer = dom.SetTimeout(func() { retryTimer = 0 retryMissingProfiles() }, 10000) } } else if len(subID) > 3 && subID[:3] == "ap-" { closeID := subID dom.SetTimeout(func() { dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]") }, 15000) pk, ok := authorSubPK[subID] if !ok { return } delete(authorSubPK, subID) if _, got := authorNames[pk]; !got { if rels, ok := authorRelays[pk]; ok && len(rels) > 0 && !fetchedK10k[pk] { fetchedK10k[pk] = true fetchedK0[pk] = false fetchAuthorProfile(pk) } } } else if len(subID) > 4 && subID[:4] == "emb-" { closeID := subID dom.SetTimeout(func() { dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]") }, 5000) } else if len(subID) > 3 && subID[:3] == "rp-" { closeID := subID dom.SetTimeout(func() { dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]") }, 5000) } else if len(subID) > 4 && subID[:4] == "thr-" { closeID := subID dom.SetTimeout(func() { dom.PostToSW("[\"CLOSE\"," + jstr(closeID) + "]") }, 5000) if threadOpen { handleThreadEOSE() } } } // handleCryptoReq processes CRYPTO_REQ from the SW, calling the NIP-07 // extension and posting CRYPTO_RESULT back. // Format: ["CRYPTO_REQ", id, "method", "peerPubkey", "data"] func handleCryptoReq(raw string, pos int) { // id is a bare number, not a quoted string. idStr := nextNum(raw, pos) // Skip past the number and comma to find the method string. pos2 := pos for pos2 < len(raw) && raw[pos2] != ',' { pos2++ } pos2++ method, pos3 := nextStr(raw, pos2) peer, pos4 := nextStr(raw, pos3) data, _ := nextStr(raw, pos4) dom.ConsoleLog("crypto: " + method + " #" + idStr) sendResult := func(result, errMsg string) { if errMsg != "" { dom.ConsoleLog("crypto: " + method + " #" + idStr + " ERR=" + errMsg) } else { dom.ConsoleLog("crypto: " + method + " #" + idStr + " OK") } dom.PostToSW("[\"CRYPTO_RESULT\"," + idStr + "," + jstr(result) + "," + jstr(errMsg) + "]") } switch method { case "signEvent": signer.SignEvent(data, func(signed string) { if signed == "" { sendResult("", "sign failed") } else { sendResult(signed, "") } }) case "nip04.decrypt": signer.Nip04Decrypt(peer, data, func(plain string) { if plain == "" { sendResult("", "decrypt failed") } else { sendResult(plain, "") } }) case "nip04.encrypt": signer.Nip04Encrypt(peer, data, func(ct string) { if ct == "" { sendResult("", "encrypt failed") } else { sendResult(ct, "") } }) case "nip44.decrypt": signer.Nip44Decrypt(peer, data, func(plain string) { if plain == "" { sendResult("", "decrypt failed") } else { sendResult(plain, "") } }) case "nip44.encrypt": signer.Nip44Encrypt(peer, data, func(ct string) { if ct == "" { sendResult("", "encrypt failed") } else { sendResult(ct, "") } }) default: sendResult("", "unknown method: "+method) } } // nextNum extracts a bare number from s starting at pos, returning it as a string. func nextNum(s string, pos int) 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 int) (string, int) { 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 = append(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 = append(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 int) 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:] } func handleProfileEvent(ev *nostr.Event) { switch ev.Kind { case 0: if ev.CreatedAt <= profileTs { return } profileTs = ev.CreatedAt authorContent[pubhex] = ev.Content name := helpers.JsonGetString(ev.Content, "name") if name == "" { name = helpers.JsonGetString(ev.Content, "display_name") } pic := helpers.JsonGetString(ev.Content, "picture") if name != "" { profileName = name authorNames[pubhex] = name dom.SetTextContent(nameEl, name) } if pic != "" { profilePic = pic authorPics[pubhex] = pic dom.SetAttribute(avatarEl, "src", pic) dom.SetStyle(avatarEl, "display", "block") } if profileViewPK == pubhex { renderProfilePage(pubhex) } case 3: var pks []string for _, tag := range ev.Tags.GetAll("p") { if v := tag.Value(); v != "" { pks = append(pks, v) } } authorFollows[pubhex] = pks refreshProfileTab(pubhex) case 10000: var pks []string for _, tag := range ev.Tags.GetAll("p") { if v := tag.Value(); v != "" { pks = append(pks, v) } } authorMutes[pubhex] = pks refreshProfileTab(pubhex) case 10002: // NIP-65 relay list — add user's preferred relays. recordRelayFreq(ev) for _, tag := range ev.Tags.GetAll("r") { url := tag.Value() if url != "" { addRelay(url, true) } } sendWriteRelays() // Debounce resubscription — kind 10002 events arrive in bursts. if resubTimer != 0 { dom.ClearTimeout(resubTimer) } resubTimer = dom.SetTimeout(func() { resubTimer = 0 subscribeFeed() }, 2000) case 10050: // DM inbox relay list — stored for future use. _ = ev.Tags.GetAll("relay") } } func updateStatus() { dom.SetTextContent(statusEl, itoa(len(relayURLs))+" relays | "+itoa(eventCount)+" events") } // --- Feed rendering --- func renderNote(ev *nostr.Event) { note := dom.CreateElement("div") dom.SetStyle(note, "borderBottom", "1px solid var(--border)") dom.SetStyle(note, "padding", "12px 0") // 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 != "" { dom.SetAttribute(avatar, "src", 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) // Timestamp — right-aligned, opens thread view on click. 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, "marginLeft", "auto") 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 !fetchedK0[pk] { queueProfileFetch(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") text := ev.Content truncated := len(text) > 500 if truncated { text = text[:500] + "..." } dom.SetInnerHTML(content, renderMarkdown(text)) resolveEmbeds() dom.AppendChild(note, content) if truncated { 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", "inline-block") dom.SetStyle(more, "marginTop", "4px") fullContent := ev.Content expanded := false dom.AddEventListener(more, "click", dom.RegisterCallback(func() { expanded = !expanded if expanded { dom.SetInnerHTML(content, renderMarkdown(fullContent)) resolveEmbeds() dom.SetTextContent(more, t("show_less")) } else { dom.SetInnerHTML(content, renderMarkdown(fullContent[:500]+"...")) resolveEmbeds() dom.SetTextContent(more, t("show_more")) } })) dom.AppendChild(note, more) } // Prepend (newest first). first := dom.FirstChild(feedContainer) if first != 0 { dom.InsertBefore(feedContainer, note, first) } else { dom.AppendChild(feedContainer, note) } } func appendNote(ev *nostr.Event) { note := buildNoteElement(ev) dom.AppendChild(feedContainer, note) } func buildNoteElement(ev *nostr.Event) dom.Element { note := dom.CreateElement("div") dom.SetStyle(note, "borderBottom", "1px solid var(--border)") dom.SetStyle(note, "padding", "12px 0") 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 != "" { dom.SetAttribute(avatar, "src", 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) 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, "marginLeft", "auto") 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 !fetchedK0[pk] { queueProfileFetch(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") text := ev.Content truncated := len(text) > 500 if truncated { text = text[:500] + "..." } dom.SetInnerHTML(content, renderMarkdown(text)) resolveEmbeds() dom.AppendChild(note, content) if truncated { 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", "inline-block") dom.SetStyle(more, "marginTop", "4px") fullContent := ev.Content expanded := false dom.AddEventListener(more, "click", dom.RegisterCallback(func() { expanded = !expanded if expanded { dom.SetInnerHTML(content, renderMarkdown(fullContent)) resolveEmbeds() dom.SetTextContent(more, t("show_less")) } else { dom.SetInnerHTML(content, renderMarkdown(fullContent[:500]+"...")) resolveEmbeds() dom.SetTextContent(more, t("show_more")) } })) dom.AppendChild(note, more) } return note } var profileSubCounter int // topRelays returns the n most frequently seen relay URLs from kind 10002 events. func topRelays(n int) []string { if relayFreq == nil { return nil } // Simple selection sort — n is small. type kv struct { url string count int } var all []kv for url, count := range relayFreq { all = append(all, kv{url, count}) } // Sort descending by count. for i := 0; i < len(all); i++ { for j := i + 1; j < len(all); j++ { if all[j].count > all[i].count { all[i], all[j] = all[j], all[i] } } } var result []string for i := 0; i < len(all) && i < n; i++ { result = append(result, all[i].url) } return result } // recordRelayFreq records relay URLs from a kind 10002 event into the frequency table. func recordRelayFreq(ev *nostr.Event) { tags := ev.Tags.GetAll("r") if tags == nil { return } var urls []string for _, tag := range tags { u := tag.Value() if u != "" { urls = append(urls, u) if _, ok := relayFreq[u]; ok { relayFreq[u] = relayFreq[u] + 1 } else { relayFreq[u] = 1 } } } if len(urls) > 0 { authorRelays[ev.PubKey] = urls } } // 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.nostr.band", "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. func buildProxy(pk string) []string { out := []string{:len(discoveryRelays)} copy(out, discoveryRelays) for _, u := range relayURLs { out = appendUnique(out, u) } if rels, ok := authorRelays[pk]; ok { for _, r := range rels { out = appendUnique(out, r) } } top := topRelays(4) for _, r := range top { out = appendUnique(out, r) } return out } func appendUnique(list []string, val string) []string { 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) 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" { return t[1] } } // Positional fallback: last e-tag is the reply target (if >1 e-tags). if len(etags) > 1 { return etags[len(etags)-1][1] } // Single e-tag with no marker: it's both root and reply. return etags[0][1] } // getRootID returns the thread root event ID, or "". func getRootID(ev *nostr.Event) 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) bool { for _, t := range ev.Tags { if len(t) >= 2 && t[0] == "e" && t[1] == id { return true } } return false } func firstLine(s string) 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 } func addReplyPreview(note dom.Element, ev *nostr.Event) { parentID := getReplyID(ev) if parentID == "" { return } preview := dom.CreateElement("div") dom.SetStyle(preview, "display", "flex") dom.SetStyle(preview, "alignItems", "center") dom.SetStyle(preview, "gap", "4px") dom.SetStyle(preview, "fontSize", "12px") 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) if text, ok := replyCache[parentID]; ok { dom.SetTextContent(prevText, text) if pic, ok := replyAvatarCache[parentID]; ok && pic != "" { dom.SetAttribute(prevAvatar, "src", 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\":[" for i, id := range replyQueue { if i > 0 { filter += "," } filter += jstr(id) } filter += "]}" replyQueue = nil // Local REQ checks IDB cache first. threadSubCount++ reqID := "rp-" + itoa(threadSubCount) dom.PostToSW("[\"REQ\"," + jstr(reqID) + "," + filter + "]") // PROXY fetches from remote relays — use feed relays + top relays. 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(8) { if !seen[u] { seen[u] = true urls = append(urls, u) } } threadSubCount++ proxyID := "rp-" + itoa(threadSubCount) dom.PostToSW(buildProxyMsg(proxyID, filter, urls)) } 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 pic := "" if p, ok := authorPics[ev.PubKey]; ok && p != "" { pic = p } replyAvatarCache[ev.ID] = pic // Trigger profile fetch if not cached. if !nameResolved { if !fetchedK0[ev.PubKey] { queueProfileFetch(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] = append(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 != "" { dom.SetAttribute(img, "src", pic) dom.SetAttribute(img, "onerror", "this.style.display='none'") dom.SetStyle(img, "display", "block") } } // 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 { dom.PostToSW("[\"CLOSE\"," + jstr(sid) + "]") } threadActiveSubs = nil threadGen++ threadRootID = rootID threadFocusID = focusID threadEvents = map[string]*nostr.Event{} threadLastRendered = 0 threadOpen = true // Save scroll position. savedScrollTop = dom.GetProperty(contentArea, "scrollTop") // Switch UI. dom.SetStyle(feedContainer, "display", "none") dom.SetStyle(threadPage, "display", "block") dom.SetProperty(contentArea, "scrollTop", "0") // Show back button in top bar, hide page title. dom.SetStyle(topBackBtn, "display", "inline") dom.SetStyle(pageTitleEl, "display", "none") if !navPop { dom.PushState("/t/" + rootID) 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) } } // Local REQ for cached events. threadSubCount++ s1 := "thr-" + itoa(threadSubCount) threadActiveSubs = append(threadActiveSubs, s1) dom.PostToSW("[\"REQ\"," + jstr(s1) + ",{\"ids\":[" + jstr(rootID) + "]}]") threadSubCount++ s2 := "thr-" + itoa(threadSubCount) threadActiveSubs = append(threadActiveSubs, s2) dom.PostToSW("[\"REQ\"," + jstr(s2) + ",{\"#e\":[" + jstr(rootID) + "],\"kinds\":[1]}]") // PROXY for remote relays. threadSubCount++ s3 := "thr-" + itoa(threadSubCount) threadActiveSubs = append(threadActiveSubs, s3) dom.PostToSW(buildProxyMsg(s3, "{\"ids\":["+jstr(rootID)+"]}", thrRelays)) threadSubCount++ s4 := "thr-" + itoa(threadSubCount) threadActiveSubs = append(threadActiveSubs, s4) dom.PostToSW(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) dom.PostToSW(buildProxyMsg(s5, "{\"ids\":["+jstr(focusID)+"]}", thrRelays)) } } func closeNoteThread() { // Close active subs. for _, sid := range threadActiveSubs { dom.PostToSW("[\"CLOSE\"," + jstr(sid) + "]") } threadActiveSubs = nil threadOpen = false threadRootID = "" threadFocusID = "" dom.SetStyle(threadPage, "display", "none") dom.SetStyle(feedContainer, "display", "block") // Restore top bar. dom.SetStyle(topBackBtn, "display", "none") dom.SetStyle(pageTitleEl, "display", "inline") // Restore scroll position. if savedScrollTop != "" { dom.SetProperty(contentArea, "scrollTop", savedScrollTop) savedScrollTop = "" } } func handleThreadEvent(gen int, 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() } 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 int) renderAt = func(id string, depth int) { ev, ok := threadEvents[id] if !ok { return } rendered[id] = true 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) } } } 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 int, 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") } // 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 != "" { dom.SetAttribute(avatar, "src", 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) // Timestamp. 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, "marginLeft", "auto") dom.AppendChild(header, tsEl) } dom.AppendChild(note, header) if _, cached := authorNames[pk]; !cached { pendingNotes[pk] = append(pendingNotes[pk], authorLink) if !fetchedK0[pk] { queueProfileFetch(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") dom.SetInnerHTML(content, renderMarkdown(ev.Content)) resolveEmbeds() dom.AppendChild(note, content) dom.AppendChild(threadContainer, note) } // fetchAuthorProfile fetches kind 0 + kind 10002 for an author via SW PROXY. func fetchAuthorProfile(pk string) { if fetchedK0[pk] { return } fetchedK0[pk] = true profileSubCounter++ subID := "ap-" + itoa(profileSubCounter) authorSubPK[subID] = pk proxyRelays := buildProxy(pk) dom.PostToSW(buildProxyMsg(subID, "{\"authors\":["+jstr(pk)+"],\"kinds\":[0,3,10002,10000],\"limit\":6}", proxyRelays)) } // queueProfileFetch adds a pubkey to the batch fetch queue with a debounce. // After 300ms of no new additions, flushFetchQueue sends one batched PROXY request. func queueProfileFetch(pk string) { if fetchedK0[pk] { return } fetchedK0[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 := authorRelays[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 += jstr(pk) } authors += "]" profileSubCounter++ subID := "ap-batch-q-" + itoa(profileSubCounter) dom.PostToSW(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++ dom.PostToSW(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 { fetchedK0[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 += jstr(pk) } authors += "]" subID := "ap-batch-" + itoa(retryRound) + "-" + itoa(batchNum) batchNum++ dom.PostToSW(buildProxyMsg(subID, "{\"authors\":"+authors+",\"kinds\":[0,10002],\"limit\":"+itoa(len(chunk)*2)+"}", proxy)) // Direct query to feed relays. profileSubCounter++ dom.PostToSW(buildProxyMsg("ap-d-"+itoa(profileSubCounter), "{\"authors\":"+authors+",\"kinds\":[0],\"limit\":"+itoa(len(chunk))+"}", relayURLs)) } retryRound++ } // applyAuthorProfile updates cache and all pending note headers for a pubkey. func applyAuthorProfile(pk string, ev *nostr.Event) { if ev.CreatedAt <= authorTs[pk] { return } authorTs[pk] = ev.CreatedAt authorContent[pk] = ev.Content name := helpers.JsonGetString(ev.Content, "name") if name == "" { name = helpers.JsonGetString(ev.Content, "display_name") } pic := helpers.JsonGetString(ev.Content, "picture") if name != "" { authorNames[pk] = name } if pic != "" { authorPics[pk] = pic } // Cache to IndexedDB. if name != "" || pic != "" { dom.IDBPut("profiles", pk, "{\"name\":\""+jsonEsc(name)+"\",\"picture\":\""+jsonEsc(pic)+"\"}") } // Update logged-in user's header too. if pk == pubhex { if name != "" { profileName = name dom.SetTextContent(nameEl, name) } if pic != "" { profilePic = pic dom.SetAttribute(avatarEl, "src", pic) dom.SetStyle(avatarEl, "display", "block") } } // Update all pending note headers. if headers, ok := pendingNotes[pk]; ok && name != "" { for _, h := range headers { updateNoteHeader(h, name, pic) } delete(pendingNotes, pk) } // Update reply preview divs that were rendered with hex pubkey stubs. updateReplyPreviewsForAuthor(pk) // Update inline nostr:npub/nprofile links that rendered with truncated hex. updateInlineProfileLinks(pk) // Re-render profile page if viewing this author. if profileViewPK == pk { renderProfilePage(pk) } } // 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 pic != "" { dom.SetAttribute(img, "src", pic) dom.SetAttribute(img, "onerror", "this.style.display='none'") dom.SetStyle(img, "display", "") } if name != "" { dom.SetTextContent(span, name) } } // --- Profile page --- func showProfile(pk string) { profileViewPK = pk // Ensure we have full kind 0 content. If not, fetch it. if _, ok := authorContent[pk]; !ok { fetchedK0[pk] = false fetchAuthorProfile(pk) } // Push history BEFORE rendering. selectProfileTab does ReplaceState during // render, which overwrites the current entry — if we don't push first, that // ReplaceState clobbers the source page (feed/profile) we came from. if !navPop { npub := helpers.EncodeNpub(helpers.HexDecode(pk)) dom.PushState("/p/" + npub) } renderProfilePage(pk) // Use the author's name as page title, fall back to "profile". title := "profile" if name, ok := authorNames[pk]; ok && name != "" { title = name } activePage = "" // force switchPage to run switchPage("profile") dom.SetTextContent(pageTitleEl, title) } 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 clearChildren(profilePage) closeProfileNoteSub() profileNotesSeen = map[string]bool{} content := authorContent[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") dom.SetAttribute(bannerEl, "src", banner) dom.SetStyle(bannerEl, "width", "100%") dom.SetStyle(bannerEl, "height", "240px") dom.SetStyle(bannerEl, "objectFit", "cover") dom.SetStyle(bannerEl, "objectPosition", "center") dom.SetStyle(bannerEl, "display", "block") dom.SetAttribute(bannerEl, "onerror", "this.style.display='none'") dom.AppendChild(profilePage, bannerEl) } // User info card — glass effect, overlapping banner. card := dom.CreateElement("div") dom.SetStyle(card, "background", "color-mix(in srgb, var(--bg) 85%, transparent)") dom.SetStyle(card, "backdropFilter", "blur(8px)") dom.SetStyle(card, "borderRadius", "8px") dom.SetStyle(card, "padding", "16px") if banner != "" { dom.SetStyle(card, "margin", "-48px 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. topRow := dom.CreateElement("div") dom.SetStyle(topRow, "display", "flex") dom.SetStyle(topRow, "gap", "16px") dom.SetStyle(topRow, "alignItems", "flex-start") // 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") dom.SetAttribute(av, "src", 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", "0") dom.SetStyle(info, "flex", "1") dom.SetStyle(info, "overflow", "hidden") if name != "" { nameSpan := dom.CreateElement("div") 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(info, nameSpan) } 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 (full length) with copy + qr buttons. npubRow := dom.CreateElement("div") dom.SetStyle(npubRow, "display", "flex") dom.SetStyle(npubRow, "alignItems", "flex-start") 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.SetTextContent(npubEl, npubStr) dom.AppendChild(npubRow, npubEl) copyBtn := dom.CreateElement("span") dom.SetTextContent(copyBtn, t("copy")) dom.SetStyle(copyBtn, "color", "var(--accent)") dom.SetStyle(copyBtn, "fontSize", "11px") dom.SetStyle(copyBtn, "cursor", "pointer") dom.SetAttribute(copyBtn, "data-npub", npubStr) dom.SetAttribute(copyBtn, "onclick", "var b=this;navigator.clipboard.writeText(b.dataset.npub).then(function(){b.textContent='copied!'});setTimeout(function(){b.textContent='copy'},1500)") dom.AppendChild(npubRow, copyBtn) qrBtn := dom.CreateElement("span") dom.SetTextContent(qrBtn, t("qr")) dom.SetStyle(qrBtn, "color", "var(--accent)") dom.SetStyle(qrBtn, "fontSize", "11px") dom.SetStyle(qrBtn, "cursor", "pointer") npubForQR := npubStr dom.AddEventListener(qrBtn, "click", dom.RegisterCallback(func() { showQRModal(npubForQR) })) dom.AppendChild(npubRow, qrBtn) dom.AppendChild(info, npubRow) // Website + lightning inline. if website != "" || lud16 != "" { metaRow := dom.CreateElement("div") dom.SetStyle(metaRow, "display", "flex") dom.SetStyle(metaRow, "gap", "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.SetTextContent(lEl, "\xE2\x9A\xA1 "+lud16) dom.AppendChild(metaRow, lEl) } dom.AppendChild(info, metaRow) } dom.AppendChild(topRow, info) dom.AppendChild(card, topRow) // Message button — only for other users. if pk != pubhex { msgBtn := dom.CreateElement("button") dom.SetTextContent(msgBtn, t("message")) dom.SetStyle(msgBtn, "padding", "6px 16px") dom.SetStyle(msgBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(msgBtn, "fontSize", "12px") dom.SetStyle(msgBtn, "background", "var(--accent)") dom.SetStyle(msgBtn, "color", "#fff") dom.SetStyle(msgBtn, "border", "none") dom.SetStyle(msgBtn, "borderRadius", "4px") dom.SetStyle(msgBtn, "cursor", "pointer") dom.SetStyle(msgBtn, "marginTop", "12px") peerPK := pk dom.AddEventListener(msgBtn, "click", dom.RegisterCallback(func() { switchPage("messaging") openThread(peerPK) })) dom.AppendChild(card, msgBtn) } dom.AppendChild(profilePage, card) // About/bio. 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") aboutTruncated := len(about) > 300 aboutText := about if aboutTruncated { aboutText = about[:300] + "..." } dom.SetInnerHTML(aboutEl, renderMarkdown(aboutText)) dom.AppendChild(profilePage, aboutEl) if aboutTruncated { 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", "inline-block") dom.SetStyle(more, "padding", "0 16px 8px") aboutExpanded := false dom.AddEventListener(more, "click", dom.RegisterCallback(func() { aboutExpanded = !aboutExpanded if aboutExpanded { dom.SetInnerHTML(aboutEl, renderMarkdown(about)) dom.SetTextContent(more, t("show_less")) } else { dom.SetInnerHTML(aboutEl, renderMarkdown(about[:300]+"...")) dom.SetTextContent(more, t("show_more")) } })) dom.AppendChild(profilePage, more) } } // Tab bar. tabBar := dom.CreateElement("div") dom.SetStyle(tabBar, "display", "flex") dom.SetStyle(tabBar, "gap", "0") dom.SetStyle(tabBar, "margin", "0 16px") dom.SetStyle(tabBar, "border", "1px solid var(--border)") dom.SetStyle(tabBar, "borderRadius", "6px") dom.SetStyle(tabBar, "overflow", "hidden") 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(profilePage, tabBar) // Tab content container. profileTabContent = dom.CreateElement("div") dom.SetStyle(profileTabContent, "padding", "8px 0") dom.AppendChild(profilePage, profileTabContent) // Restore or default tab. profileTab = "" if savedTab != "" { selectProfileTab(savedTab, pk) } else { selectProfileTab("notes", pk) } // Update title. if name != "" && activePage == "profile" { dom.SetTextContent(pageTitleEl, name) } } func profileMetaRow(icon, text, link string) 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 != "" { dom.PostToSW("[\"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) dom.PostToSW(buildProxyMsg(subID, "{\"authors\":["+jstr(pk)+"],\"kinds\":[1],\"limit\":20}", proxyRelays)) } func renderProfileNote(ev *nostr.Event) { if profileTabContent == 0 || profileTab != "notes" { return } note := dom.CreateElement("div") dom.SetStyle(note, "borderBottom", "1px solid var(--border)") dom.SetStyle(note, "padding", "12px 16px") // Timestamp — right-aligned, opens thread view. if ev.CreatedAt > 0 { tsEl := dom.CreateElement("div") dom.SetTextContent(tsEl, formatTime(ev.CreatedAt)) dom.SetStyle(tsEl, "color", "var(--muted)") dom.SetStyle(tsEl, "fontSize", "11px") dom.SetStyle(tsEl, "cursor", "pointer") dom.SetStyle(tsEl, "textAlign", "right") dom.SetStyle(tsEl, "maxWidth", "65ch") dom.SetStyle(tsEl, "marginBottom", "4px") evID := ev.ID evRootID := getRootID(ev) if evRootID == "" { evRootID = evID } dom.AddEventListener(tsEl, "click", dom.RegisterCallback(func() { showNoteThread(evRootID, evID) })) dom.AppendChild(note, tsEl) } // 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") text := ev.Content truncated := len(text) > 500 if truncated { text = text[:500] + "..." } dom.SetInnerHTML(content, renderMarkdown(text)) resolveEmbeds() dom.AppendChild(note, content) if truncated { 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", "inline-block") dom.SetStyle(more, "marginTop", "4px") fullContent := ev.Content expanded := false dom.AddEventListener(more, "click", dom.RegisterCallback(func() { expanded = !expanded if expanded { dom.SetInnerHTML(content, renderMarkdown(fullContent)) resolveEmbeds() dom.SetTextContent(more, t("show_less")) } else { dom.SetInnerHTML(content, renderMarkdown(fullContent[:500]+"...")) resolveEmbeds() dom.SetTextContent(more, t("show_more")) } })) dom.AppendChild(note, more) } dom.AppendChild(profileTabContent, note) } func renderProfileFollows(pk string) { follows, ok := authorFollows[pk] if !ok || 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, ok := authorRelays[pk] if !ok || 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) { mutes, ok := authorMutes[pk] if !ok || 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) 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 != "" { dom.SetAttribute(av, "src", 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 !fetchedK0[pk] { queueProfileFetch(pk) } } return row } // --- Relay info page --- func showRelayInfo(url string) { profileViewPK = "" closeProfileNoteSub() clearChildren(profilePage) 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(profilePage, hdr) loading := dom.CreateElement("div") dom.SetTextContent(loading, t("loading")) dom.SetStyle(loading, "padding", "16px") dom.SetStyle(loading, "color", "var(--muted)") dom.AppendChild(profilePage, 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(profilePage, 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.SetInnerHTML(el, renderMarkdown(desc)) 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) } 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 += " " + ver } dom.AppendChild(container, profileMetaRow("sw", label, "")) } dom.AppendChild(profilePage, container) } // --- Messaging --- func relayURLsJSON() string { msg := "[" for i, url := range relayURLs { if i > 0 { msg += "," } msg += jstr(url) } return msg + "]" } func formatTime(ts int64) string { if ts == 0 { return "" } secs := ts % 86400 h := itoa(int(secs / 3600)) m := itoa(int((secs % 3600) / 60)) if len(h) < 2 { h = "0" + h } if len(m) < 2 { m = "0" + m } return h + ":" + m } // --- 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) // Re-render settings page with new language, update page title. 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")) } })) dom.AppendChild(themeRow, themeToggle) dom.AppendChild(settingsPage, themeRow) } // --- 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 } renderNewChatButton() // Request conversation list from cache (will re-render below the button). dom.PostToSW("[\"DM_LIST\"]") // Init MLS if not already done. publishKP + subscribe auto-bootstrap inside signer. if !marmotInited { marmotInited = true dom.PostToSW("[\"MLS_INIT\"," + relayURLsJSON() + "]") } } func renderNewChatButton() { newBtn := dom.CreateElement("button") dom.SetTextContent(newBtn, t("new_chat")) dom.SetStyle(newBtn, "display", "block") dom.SetStyle(newBtn, "width", "100%") dom.SetStyle(newBtn, "padding", "10px") dom.SetStyle(newBtn, "marginBottom", "8px") dom.SetStyle(newBtn, "fontFamily", "'Fira Code', monospace") dom.SetStyle(newBtn, "fontSize", "13px") dom.SetStyle(newBtn, "background", "var(--bg2)") dom.SetStyle(newBtn, "border", "1px solid var(--border)") dom.SetStyle(newBtn, "borderRadius", "6px") dom.SetStyle(newBtn, "color", "var(--accent)") dom.SetStyle(newBtn, "cursor", "pointer") dom.SetStyle(newBtn, "textAlign", "left") dom.AddEventListener(newBtn, "click", dom.RegisterCallback(func() { showNewChatInput() })) dom.AppendChild(msgListContainer, newBtn) } func renderConversationList(listJSON string) { if msgView != "list" { return } clearChildren(msgListContainer) renderNewChatButton() // 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 != "" { dom.SetAttribute(av, "src", 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 && !fetchedK0[peer] { queueProfileFetch(peer) } dom.AppendChild(msgListContainer, row) } func showNewChatInput() { // Check if input row already exists (first child after button). fc := dom.FirstChild(msgListContainer) if fc != 0 { ns := dom.NextSibling(fc) if ns != 0 { tag := dom.GetProperty(ns, "tagName") if tag == "DIV" { id := dom.GetProperty(ns, "id") if id == "new-chat-row" { return // already showing } } } } inputRow := dom.CreateElement("div") dom.SetAttribute(inputRow, "id", "new-chat-row") dom.SetStyle(inputRow, "display", "flex") dom.SetStyle(inputRow, "gap", "8px") dom.SetStyle(inputRow, "marginBottom", "8px") inp := dom.CreateElement("input") dom.SetAttribute(inp, "type", "text") dom.SetAttribute(inp, "placeholder", t("npub_placeholder")) dom.SetStyle(inp, "flex", "1") 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 16px") 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") submitNewChat := func() { val := dom.GetProperty(inp, "value") if val == "" { return } var hexPK string if len(val) == 64 { // Assume hex pubkey. 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)) // Enter key triggers the "go" button via inline handler. dom.SetAttribute(inp, "onkeydown", "if(event.key==='Enter'){event.preventDefault();this.nextSibling.click()}") dom.AppendChild(inputRow, inp) dom.AppendChild(inputRow, goBtn) // Insert after the "new chat" button. btn := dom.FirstChild(msgListContainer) if btn != 0 { ns := dom.NextSibling(btn) if ns != 0 { dom.InsertBefore(msgListContainer, inputRow, ns) } else { dom.AppendChild(msgListContainer, inputRow) } } else { dom.AppendChild(msgListContainer, inputRow) } } func openThread(peer string) { msgCurrentPeer = peer 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 != "" { dom.SetAttribute(av, "src", 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'") 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:]) } } dom.AppendChild(threadHdrInner, nameSpan) dom.SetStyle(threadHdrInner, "display", "flex") dom.SetStyle(threadHdrInner, "alignItems", "center") dom.SetStyle(threadHdrInner, "gap", "10px") dom.AppendChild(hdr, threadHdrInner) ratchetBtn := dom.CreateElement("button") dom.SetTextContent(ratchetBtn, t("ratchet")) dom.SetStyle(ratchetBtn, "marginLeft", "auto") dom.SetStyle(ratchetBtn, "background", "none") dom.SetStyle(ratchetBtn, "border", "1px solid var(--border)") dom.SetStyle(ratchetBtn, "borderRadius", "4px") dom.SetStyle(ratchetBtn, "color", "var(--fg)") dom.SetStyle(ratchetBtn, "cursor", "pointer") dom.SetStyle(ratchetBtn, "fontSize", "11px") dom.SetStyle(ratchetBtn, "padding", "4px 8px") dom.SetStyle(ratchetBtn, "fontFamily", "'Fira Code', monospace") dom.AddEventListener(ratchetBtn, "click", dom.RegisterCallback(func() { if dom.Confirm("Delete all messages and rotate encryption keys?") { dom.PostToSW("[\"MLS_RATCHET\"," + 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, "rows", "1") 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, "resize", "none") dom.SetAttribute(msgComposeInput, "onkeydown", "if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();this.nextSibling.click()}") 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 needed. if !fetchedK0[peer] { queueProfileFetch(peer) } // Request history. dom.PostToSW("[\"DM_HISTORY\"," + jstr(peer) + ",50,0]") } func closeThread() { msgCurrentPeer = "" msgView = "list" dom.SetStyle(msgThreadContainer, "display", "none") dom.SetStyle(msgListContainer, "display", "block") if !navPop { dom.PushState("/msg") } // Refresh list. dom.PostToSW("[\"DM_LIST\"]") } func renderThreadMessages(peer, msgsJSON string) { if peer != msgCurrentPeer { 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") } bubble := dom.CreateElement("div") dom.SetStyle(bubble, "maxWidth", "75%") 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") 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)") } dom.SetInnerHTML(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) } outer := dom.CreateElement("div") 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 == "" || msgCurrentPeer == "" { return } // Clear input. dom.SetProperty(msgComposeInput, "value", "") dom.PostToSW("[\"MLS_SEND\"," + jstr(msgCurrentPeer) + "," + 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 msgView == "thread" && peer == msgCurrentPeer { // Don't double-render our own sent messages (already optimistic). if from == pubhex { return } appendBubble(from, content, ts) scrollToBottom() } else if msgView == "list" { // Refresh conversation list. dom.PostToSW("[\"DM_LIST\"]") } } // --- Logout --- func doLogout() { // Tell SW to clean up. dom.PostToSW("[\"CLOSE\",\"prof\"]") dom.PostToSW("[\"CLOSE\",\"feed\"]") dom.PostToSW("[\"CLEAR_KEY\"]") pubkey = nil pubhex = "" profileName = "" profilePic = "" profileTs = 0 eventCount = 0 popoverOpen = false marmotInited = false msgCurrentPeer = "" msgView = "list" // Reset relay tracking. relayURLs = nil relayDots = nil relayLabels = nil relayUserPick = nil localstorage.RemoveItem(lsKeyPubkey) clearChildren(root) showLogin() } // --- 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) string { out := "To: " + from + "\n" if subject != "" { if !hasPrefix(subject, "Re: ") { subject = "Re: " + subject } out += "Subject: " + subject + "\n" } out += "\n\n" if body != "" { lines := splitLines(body) for _, line := range lines { out += "> " + line + "\n" } } return out } func splitLines(s string) []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) string { out := "" for i, line := range lines { if i > 0 { out += "\n" } out += line } return out } func hasPrefix(s, prefix string) 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. func renderMarkdown(s string) string { s = strReplace(s, "&", "&") s = strReplace(s, "<", "<") s = strReplace(s, ">", ">") s = strReplace(s, "\"", """) s = wrapDelimited(s, "`", "", "") s = wrapDelimited(s, "**", "", "") s = wrapDelimited(s, "*", "", "") s = autoLinkURLs(s) s = linkifyNostrEntities(s) s = strReplace(s, "\n", "
") return s } // strReplace replaces all occurrences of old with new using indexOf. func strReplace(s, old, nw string) string { out := "" for { idx := strIndex(s, old) if idx < 0 { return out + s } out += s[:idx] + nw s = s[idx+len(old):] } } // wrapDelimited finds matching pairs of delim and wraps content in open/close tags. func wrapDelimited(s, delim, open, close string) string { out := "" for { start := strIndex(s, delim) if start < 0 { return out + s } end := strIndex(s[start+len(delim):], delim) if end < 0 { return out + s } end += start + len(delim) inner := s[start+len(delim) : end] if len(inner) == 0 { out += s[:start+len(delim)] s = s[start+len(delim):] continue } out += s[:start] + open + inner + close s = s[end+len(delim):] } } func autoLinkURLs(s string) string { out := "" for { hi := strIndex(s, "https://") lo := strIndex(s, "http://") idx := -1 if hi >= 0 && (lo < 0 || hi <= lo) { idx = hi } else if lo >= 0 { idx = lo } if idx < 0 { return out + s } out += s[:idx] s = s[idx:] // Find end of URL. end := 0 for end < len(s) { c := s[end : end+1] if c == " " || c == "\n" || c == "\r" || c == "\t" || c == "<" || c == ">" { break } end++ } // Trim trailing punctuation. for end > 0 { c := s[end-1 : end] if c == "." || c == "," || c == ")" || c == ";" { end-- } else { break } } url := s[:end] safeURL := escapeHTMLAttr(url) if isImageURL(url) { out += "" } else { out += "" + escapeHTML(url) + "" } s = s[end:] } } func isImageURL(url string) bool { u := toLower(url) return hasSuffix(u, ".jpg") || hasSuffix(u, ".jpeg") || hasSuffix(u, ".png") || hasSuffix(u, ".gif") || hasSuffix(u, ".webp") } func escapeHTMLAttr(s string) string { out := "" for i := 0; i < len(s); i++ { switch s[i] { case '"': out += """ case '\'': out += "'" case '&': out += "&" case '<': out += "<" case '>': out += ">" default: out += s[i : i+1] } } return out } func escapeHTML(s string) string { out := "" for i := 0; i < len(s); i++ { switch s[i] { case '&': out += "&" case '<': out += "<" case '>': out += ">" default: out += s[i : i+1] } } return out } func hasSuffix(s, suffix string) bool { return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix } // --- Nostr entity embedding --- func linkifyNostrEntities(s string) string { out := "" for { idx := strIndex(s, "nostr:") if idx < 0 { return out + s } out += s[:idx] s = s[idx+6:] // skip "nostr:" // Find end of bech32 entity (lowercase alphanumeric). end := 0 for end < len(s) { c := s[end] if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { end++ } else { break } } if end < 10 { out += "nostr:" continue } entity := s[:end] s = s[end:] if len(entity) > 7 && entity[:7] == "nevent1" { nev := helpers.DecodeNevent(entity) if nev != nil { if len(nev.Relays) > 0 { embedRelayHints[nev.ID] = nev.Relays } out += makeEmbedPlaceholder(nev.ID) continue } } else if len(entity) > 5 && entity[:5] == "note1" { id := helpers.DecodeNote(entity) if id != nil { out += makeEmbedPlaceholder(helpers.HexEncode(id)) continue } } 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 { // Store relay hints so batch fetch uses them. if len(np.Relays) > 0 { authorRelays[np.Pubkey] = np.Relays } queueProfileFetch(np.Pubkey) } npub := helpers.EncodeNpub(helpers.HexDecode(np.Pubkey)) out += "" + escapeHTML(name) + "" continue } } else if len(entity) > 5 && entity[:5] == "npub1" { pk := helpers.DecodeNpub(entity) if pk != nil { hexPK := helpers.HexEncode(pk) name := hexPK[:8] + "..." if n, ok := authorNames[hexPK]; ok && n != "" { name = n } else { queueProfileFetch(hexPK) } out += "" + escapeHTML(name) + "" continue } } out += "nostr:" + entity } } 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 makeEmbedPlaceholder(hexID string) string { embedCounter++ elemID := "emb-" + itoa(embedCounter) embedCallbacks[hexID] = append(embedCallbacks[hexID], elemID) return "
" + "Loading note...
" } func resolveEmbeds() { if len(embedCallbacks) == 0 { return } var ids []string for hexID := range embedCallbacks { ids = append(ids, hexID) } filter := "{\"ids\":[" for i, id := range ids { if i > 0 { filter += "," } filter += jstr(id) } filter += "]}" // REQ checks the local IDB cache. reqID := "emb-r-" + itoa(embedCounter) dom.PostToSW("[\"REQ\"," + jstr(reqID) + "," + filter + "]") // PROXY fetches from relays — combine user relays + nevent relay hints. seen := map[string]bool{} var urls []string for _, u := range relayURLs { if !seen[u] { seen[u] = true urls = append(urls, u) } } for _, id := range ids { for _, u := range embedRelayHints[id] { if !seen[u] { seen[u] = true urls = append(urls, u) } } delete(embedRelayHints, id) } if len(urls) > 0 { proxyID := "emb-p-" + itoa(embedCounter) dom.PostToSW(buildProxyMsg(proxyID, filter, urls)) } } func renderEmbedText(s string) string { s = strReplace(s, "&", "&") s = strReplace(s, "<", "<") s = strReplace(s, ">", ">") s = wrapDelimited(s, "`", "", "") s = autoLinkURLs(s) s = strReplace(s, "\n", "
") return s } func fillEmbed(ev *nostr.Event) { elemIDs, ok := embedCallbacks[ev.ID] if !ok { return } delete(embedCallbacks, ev.ID) 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 += "" + escapeHTML(name) + "
" truncated := len(ev.Content) > 300 text := ev.Content if truncated { text = text[:300] + "..." } embedEvID := ev.ID embedRootID := getRootID(ev) if embedRootID == "" { embedRootID = embedEvID } for _, elemID := range elemIDs { el := dom.GetElementById(elemID) if el == 0 { continue } dom.SetInnerHTML(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) if truncated { 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", "12px") dom.SetStyle(more, "display", "inline-block") dom.SetStyle(more, "marginTop", "2px") fullContent := ev.Content thisBody := body expanded := false dom.AddEventListener(more, "click", dom.RegisterCallback(func() { expanded = !expanded if expanded { dom.SetInnerHTML(thisBody, renderEmbedText(fullContent)) dom.SetTextContent(more, t("show_less")) } else { dom.SetInnerHTML(thisBody, renderEmbedText(fullContent[:300]+"...")) dom.SetTextContent(more, t("show_more")) } })) dom.AppendChild(el, more) } // Click anywhere on embed (except "show more") opens thread view. thisEl := el dom.AddEventListener(thisEl, "click", dom.RegisterCallback(func() { showNoteThread(embedRootID, embedEvID) })) } if _, ok := authorNames[ev.PubKey]; !ok { if !fetchedK0[ev.PubKey] { queueProfileFetch(ev.PubKey) } } } // jsonGetNum extracts a numeric value for a given key from a JSON object. func jsonGetNum(s, key string) 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 } var n int64 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. func jsonEsc(s string) 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) int { 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) 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) 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, "gap", "12px") dom.SetStyle(card, "cursor", "default") dom.SetAttribute(card, "onclick", "event.stopPropagation()") dom.SetInnerHTML(card, svg) label := dom.CreateElement("div") dom.SetStyle(label, "fontSize", "11px") dom.SetStyle(label, "color", "#666") dom.SetStyle(label, "wordBreak", "break-all") dom.SetStyle(label, "textAlign", "center") dom.SetStyle(label, "maxWidth", "205px") dom.SetStyle(label, "fontFamily", "'Fira Code', monospace") dom.SetTextContent(label, npubStr) dom.AppendChild(card, label) dom.AppendChild(scrim, card) dom.AppendChild(dom.Body(), scrim) } func clearChildren(el dom.Element) { dom.SetInnerHTML(el, "") } func itoa(n int) 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:]) }