From 933e85020c2cc56b511ff47a73943c92ea1d044a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 14 Nov 2026 22:03:23 -0700 Subject: Search inside someone's posts: their profile puts them in the search field, and "franca wildwood" is understood. "i found him by searching franca but then i was wanting some way to search his feed for something … i was wanting to search 'franca wildwood' but knew it wouldn't work … we couldn't just have a clever way of guessing what the user is trying to do?" (Adam, 2026-11-14). No second field: on a profile the header field (and the phone's search page opened from a profile) carries the person as a chip, "Search their posts, or anyone…"; Enter searches only their posts, people still show below for picking someone else, and × or Backspace on the empty field searches everywhere again. Words that begin or end with someone's name (first/last one or two words, matched at the start of a word of their name or address) are offered as "“wildwood” in Franca Yibisi's posts" at the top of the results; when that's someone you follow or whose profile you opened this session, it's the Enter choice (↵), otherwise only a row ("bitcoin wallet" is not Bitcoin Magazine's posts), and never when the whole query is someone's name. The search is a feed like any text search, /feed?q=wildwood&p=npub…: the NIP-50 relays asked with the author, plus the author's own recent posts (home, archive and their own relays, up to four pages of 400 until ten match) checked here word by word, since few people are in a search index; what their profile already loaded shows at once. tools/search-scope-test.js (real relays): chip on fiatjaf's profile, "relay" → only his posts, every one saying it; "fiatjaf relay" guessed and taken by Enter; × and Backspace; phone width. profile-stream, post-search, search-avatar-loading and features-smoke pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VMan6UKGJyuGyZFxddYRxS --- src/app.js | 22 +++-- src/features/messages.js | 206 ++++++++++++++++++++++++++++++++++++++----- src/i18n.js | 4 + src/style.css | 30 +++++++ tools/profile-stream-test.js | 8 +- tools/search-scope-test.js | 96 ++++++++++++++++++++ 6 files changed, 333 insertions(+), 33 deletions(-) create mode 100644 tools/search-scope-test.js diff --git a/src/app.js b/src/app.js index b7580e5..3f1daf3 100644 --- a/src/app.js +++ b/src/app.js @@ -3072,15 +3072,24 @@ function lockBtn() { // Find anyone on nostr from the header — results open their profile. // Wide screens trade the search icon for a field in the header or sticky // wallet sidebar. It drives the search page, which drops its own copy. +// On a profile the field searches that person's posts: their chip sits in +// it (× or Backspace on an empty field searches everywhere again). function headerSearch() { if (!featureHook('userSearchAvailable')) return null; - return h('input', { - type: 'text', class: 'header-search', placeholder: t('searchUsers') + '…', 'aria-label': t('searchUsers'), - value: (ui.userSearch && ui.userSearch.q) || '', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', - onInput: (e) => { clearFeatureNavKeepSearch(); featureHook('userSearchQuery', e.target.value); }, + const chip = featureHook('userSearchScopeChip'); + const input = h('input', { + type: 'text', class: chip ? 'header-search-field' : 'header-search', + placeholder: chip ? t('searchScopedHint') : t('searchUsers') + '…', 'aria-label': t('searchUsers'), + // a profile on screen starts a fresh query; the old one waits behind Back + value: (!ui.profilePk && ui.userSearch && ui.userSearch.q) || '', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + onInput: (e) => { featureHook('userSearchHoldScope'); clearFeatureNavKeepSearch(); featureHook('userSearchQuery', e.target.value); }, // Enter without picking a suggestion: full-text search over notes - onKeydown: (e) => { if (e.key === 'Enter' && !e.isComposing && e.target.value.trim()) { e.preventDefault(); featureHook('userSearchSubmit', e.target.value); e.target.blur(); } }, + onKeydown: (e) => { + if (e.key === 'Enter' && !e.isComposing && e.target.value.trim()) { e.preventDefault(); featureHook('userSearchSubmit', e.target.value); e.target.blur(); } + else if (e.key === 'Backspace' && !e.target.value && chip) { e.preventDefault(); featureHook('userSearchUnscope'); } + }, }); + return chip ? h('div', { class: 'header-search header-search-scoped' }, chip, input) : input; } // Typing in the header field: whatever page is open steps aside for the // results, but the search itself (its query and rows) stays. It used to do @@ -3102,8 +3111,9 @@ function searchBtn() { title: t('searchUsers'), 'aria-label': t('searchUsers'), onClick: () => { + const scope = featureHook('userSearchScopePk'); // from a profile: search inside its person's posts clearFeatureNav(); ui.screen = 'wallet'; - featureHook('openUserSearch'); + featureHook('openUserSearch', scope); render(); setTimeout(() => document.querySelector('.user-search-input')?.focus(), 60); }, diff --git a/src/features/messages.js b/src/features/messages.js index b65b689..b9ef1e6 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -39,7 +39,7 @@ import { createThreadPrefetch } from '../thread-prefetch.js'; import { makeImageThumbnails } from '../image-thumbnails.js'; import { createFeedCache, FEED_CACHE_POSTS, FEED_SEED_POSTS } from '../feed-cache.js'; import { POPULAR_RELAYS, FIREHOSE_RELAYS, popularCandidates } from '../popular-feed.js'; -import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl, punkSmallUrl, primalCache } from '../recipient-search.js'; +import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl, punkSmallUrl, primalCache, searchRecipients, searchable } from '../recipient-search.js'; import { getNetwork } from '../api.js'; import { decodeBolt11 } from '../ark/lightning.js'; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; @@ -4360,6 +4360,7 @@ export function messagesFeature(ctx) { } function openProfile(pk) { rememberPlace('p:' + pk); + viewedProfiles.set(pk, Date.now()); ui.liveStream = null; ui.profilePk = pk; // Opened from inside a thread (an author's avatar/name), the profile @@ -5246,7 +5247,13 @@ export function messagesFeature(ctx) { // A word search (def.search) asks NIP-50 relays for notes and replies // whose text holds it: kind 1 only, replies kept as rows of their own. const feedKinds = (def) => (def && def.search ? [1] : def && def.replies ? REPLY_KINDS : FEED_KINDS); - const feedKeeps = (def, e) => (def && def.search ? e.kind === 1 : def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e)); + // A search within someone's posts reads their own relays too (few people + // are in any search index), so it checks the words itself: every word, + // anywhere in the text, case and accents aside. + const foldText = (x) => String(x || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + const searchWords = (q) => foldText(q).split(/\s+/).map((w) => w.replace(/^#/, '')).filter(Boolean); + const saysWords = (q, e) => { const text = foldText(e.content); return searchWords(q).every((w) => text.includes(w)); }; + const feedKeeps = (def, e) => (def && def.search ? e.kind === 1 && (!(def.authors || []).length || saysWords(def.search, e)) : def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e)); // What a reply answers: the e tag marked 'reply' (NIP-10), else 'root' // (a reply straight to the root), else — the deprecated positional form — // the last e tag; a NIP-22 comment's lowercase e (its parent), else its E. @@ -6541,7 +6548,7 @@ export function messagesFeature(ctx) { c.at = Date.now(); const merge = { newerThan: c.notes.length ? c.notes[0].created_at : Infinity }; if (def.search) { - try { await queryFeed(feedRelaysOr(def, TEXT_SEARCH_RELAYS), { kinds: [1], search: def.search, limit: FEED_PAGE }, merge, c, 5000); } catch {} finally { + try { await searchPass(def, c, merge); } catch {} finally { if (!c.stopped) { c.status = 'ready'; if (c === feed && ui.chatOpen && ui.msgView === 'feed') scheduleRepaint(); } } return; @@ -6583,8 +6590,10 @@ export function messagesFeature(ctx) { const fits = (e) => feedKeeps(def, e) && (((!authors.size || authors.has(e.pubkey)) && (!topics.size || tagged(e, topics))) || (orT.size && tagged(e, orT))); const seen = new Set(), out = []; - for (const o of feedStates.values()) { - for (const e of o.notes) if (!seen.has(e.id) && fits(e) && !hidden(e)) { seen.add(e.id); out.push(e); } + // a search within someone's posts: what their profile already loaded + const pools = [...[...feedStates.values()].map((o) => o.notes), ...(def.search ? [...authors].map((pk) => (notesCache.get(pk) || {}).notes || []) : [])]; + for (const notes of pools) { + for (const e of notes) if (!seen.has(e.id) && fits(e) && !hidden(e)) { seen.add(e.id); out.push(e); } } return out.sort((a, b) => b.created_at - a.created_at).slice(0, FEED_PAGE); } @@ -6695,7 +6704,7 @@ export function messagesFeature(ctx) { const packs = (q.get('pack') || '').split(',').map((x) => parsePackLink(x.trim())).filter(Boolean); const relays = urlRelays(q); const words = (q.get('q') || '').trim(); - if (words) { openTextSearch(words, relays); return true; } + if (words) { openTextSearch(words, relays, authors); return true; } if (!authors.length && !topics.length && !packs.length) { openFirehose(relays); return true; } const sig = JSON.stringify([authors, topics, packs.map(packKey), relays]); let hsh = 5381; for (let i = 0; i < sig.length; i++) hsh = ((hsh * 33) ^ sig.charCodeAt(i)) >>> 0; @@ -6726,7 +6735,7 @@ export function messagesFeature(ctx) { if (def.all) return (def.curated ? '/feed' : '/feed/all') + r; if (def.builtin) return share && mePk() && !def.replies ? '/feed/' + npubOf(mePk()) + r : null; if (def.of) return '/feed/' + npubOf(def.of) + r; - if (def.search) return '/feed?q=' + encodeURIComponent(def.search) + (r ? '&' + r.slice(1) : ''); + if (def.search) return '/feed?q=' + encodeURIComponent(def.search) + ((def.authors || []).length ? '&p=' + def.authors.map(npubOf).join(',') : '') + (r ? '&' + r.slice(1) : ''); const authors = def.authors || [], topics = feedTopics(def), packs = def.packs || []; if (!authors.length && !packs.length && topics.length === 1) return '/feed/t/' + encodeURIComponent(topics[0]) + r; if (!authors.length && !topics.length && packs.length === 1) { @@ -6856,11 +6865,14 @@ export function messagesFeature(ctx) { } // Words typed into search and submitted without picking a suggestion: // the notes and replies that say them, as a feed for this session. - function openTextSearch(q, relays = []) { + // `authors`: only what these people posted ("search Franca's posts"). + function openTextSearch(q, relays = [], authors = []) { q = String(q || '').replace(/\s+/g, ' ').trim().slice(0, 200); if (!q) return; - const id = 'search:' + q.toLowerCase() + (relays.length ? '@' + relays.join(',') : ''); - if (!adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: '\u201c' + q + '\u201d', follows: false, authors: [], packs: [], topics: [], search: q, relays }); + authors = hexList(authors).slice(0, 5); + const id = 'search:' + q.toLowerCase() + (authors.length ? '~' + authors.join(',') : '') + (relays.length ? '@' + relays.join(',') : ''); + const name = '\u201c' + q + '\u201d' + (authors.length ? ' \u00b7 ' + authors.map((pk) => displayName(pk)).join(', ') : ''); + if (!adhocFeeds.has(id)) adhocFeeds.set(id, { id, name, follows: false, authors, packs: [], topics: [], search: q, relays }); showFeed(id); } // Something above the posts is in use — a post being written, the relay @@ -7903,6 +7915,34 @@ export function messagesFeature(ctx) { for (const e of evs || []) if (e && e.created_at && !(c.seenBack <= e.created_at)) c.seenBack = e.created_at; c.sawAny = (c.sawAny || 0) + ((evs || []).length); } + // A word search: the NIP-50 relays, and for a search within someone's + // posts their own recent posts too, read here a page at a time until a + // few match (most people's posts are in no search index at all). + async function searchPass(def, c, merge, extra = {}, limit = FEED_PAGE, relays = null) { + const authors = def.authors || []; + const indexed = queryFeed(relays || feedRelaysOr(def, TEXT_SEARCH_RELAYS), + { kinds: [1], search: def.search, limit, ...(authors.length ? { authors } : {}), ...extra }, merge, c, 5000).catch(() => false); + if (!authors.length) return indexed; + const read = (async () => { + const from = relays || [...new Set([...NOTE_RELAYS, ...PROFILE_ARCHIVE_RELAYS, + ...(await Promise.all(authors.map((pk) => relaysOf(pk).catch(() => [])))).flat()])]; + let until = extra.until, got = false, hits = 0; + for (let page = 0; page < 4 && !c.stopped; page++) { + let oldest = Infinity, n = 0; + await queryStreamOn(from, { kinds: [1], authors, limit: 400, ...(until ? { until } : {}) }, async (ev) => { + if (c.stopped) return; + n++; + if (ev.created_at < oldest) oldest = ev.created_at; + if (saysWords(def.search, ev) && await mergeFeed([ev], merge, c)) { got = true; hits++; } + }, 6000); + if (hits >= 10 || n < 100 || !isFinite(oldest)) break; + until = oldest - 1; + } + return got; + })().catch(() => false); + const [a, b] = await Promise.all([indexed, read]); + return a || b; + } async function queryFeed(relays, filter, merge, c, maxWait = 5000, qualifies = () => true) { let got = false; await queryStreamOn(relays, filter, async (ev) => { @@ -7923,7 +7963,7 @@ export function messagesFeature(ctx) { const tag = topics.length ? { '#t': topics } : {}; let got = false; if (def.curated) return popularPass(extra, merge, c, def); - if (def.search) return queryFeed(relays || feedRelaysOr(def, TEXT_SEARCH_RELAYS), { kinds: [1], search: def.search, limit, ...extra }, merge, c); + if (def.search) return searchPass(def, c, merge, extra, limit, relays); const orT = orTopicsOf(def); const orPass = orT.length ? queryFeed(relays || feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, limit, ...extra }, merge, c).then((x) => { if (x) got = true; }) @@ -17156,25 +17196,132 @@ export function messagesFeature(ctx) { const top = (rows || []).filter((r) => r.pk).slice(0, 2); for (const r of top.reverse()) prefetchProfilePage(r.pk, { first: true }); }); + // ---- searching inside someone's posts -------------------------------- + // "i found him by searching franca but then i was wanting some way to + // search his feed for something … i was wanting to search 'franca + // wildwood' but knew it wouldn't work" (Adam, 2026-11-14). Two ways in, + // no new field: a profile on screen puts its person in the search field as + // a chip (× or Backspace takes it out), and words that start or end with + // someone's name ("franca wildwood") are offered as that person's posts. + const viewedProfiles = new Map(); // pk -> when its profile was opened (this session) + // Whose posts the field searches: the open profile's person (unless taken + // out), else whoever the search page was scoped to. + function searchScope() { + if (ui.profilePk) return ui.searchScopeOff === ui.profilePk ? null : ui.profilePk; + return (ui.userSearch && ui.userSearch.scope) || null; + } + function unscopeSearch() { + if (ui.profilePk) ui.searchScopeOff = ui.profilePk; + if (ui.userSearch) ui.userSearch.scope = null; + if (ui.userSearch) guessScope(ui.userSearch.q || ''); + render(); + setTimeout(() => document.querySelector('.header-search-field, .user-search-input.user-search-main')?.focus(), 30); + } + function scopeChip() { + const pk = searchScope(); + if (!pk) return null; + return h('span', { class: 'search-scope-chip', title: t('searchInPosts', { name: displayName(pk) }) }, + avatar(pk, 'chat-avatar mini', false), + h('span', { class: 'search-scope-name' }, displayName(pk)), + h('button', { type: 'button', class: 'search-scope-x', 'aria-label': t('searchScopeRemove'), onMousedown: (e) => e.preventDefault(), onClick: unscopeSearch }, '\u00d7')); + } + // "franca wildwood": the first or last word or two may be a person. Asked + // of the people search; a hit must have the word at the start of a word of + // its name or address. Someone you follow, or whose profile you opened + // this session, is a strong guess (Enter goes there); anyone else is only + // offered as a row ("bitcoin wallet" is not Bitcoin Magazine's posts). + let guessSeq = 0, guessTimer = null; + function guessScope(q) { + clearTimeout(guessTimer); + const my = ++guessSeq; + const s = ui.userSearch; + if (s) s.guess = null; + const words = String(q || '').trim().split(/\s+/).filter(Boolean); + if (!s || s.scope || words.length < 2 || words.length > 8) return; + const splits = [[words.slice(0, 1), words.slice(1)], [words.slice(-1), words.slice(0, -1)]]; + if (words.length >= 3) splits.push([words.slice(0, 2), words.slice(2)], [words.slice(-2), words.slice(0, -2)]); + guessTimer = setTimeout(async () => { + let best = null; + await Promise.all(splits.map(async ([who, what]) => { + const part = who.join(' ').replace(/^@/, ''); + if (part.length < 3 || !searchable(part)) return; + const rows = await searchRecipients(part).catch(() => []); + const needle = ' ' + foldText(part); + rows.slice(0, 6).forEach((r, i) => { + if (!r.pk) return; + const hay = ' ' + foldText([r.name, r.address, r.nip05].filter(Boolean).join(' ')).replace(/[^\p{L}\p{N}]+/gu, ' '); + if (!hay.includes(needle)) return; + const known = isFollowing(r.pk) || viewedProfiles.has(r.pk); + const score = (known ? 100 : 0) + who.length * 5 + (i === 0 ? 3 : 0) - i; + if (!best || score > best.score) best = { pk: r.pk, text: what.join(' '), score, strong: known }; + }); + })); + if (my !== guessSeq || ui.userSearch !== s || s.q !== q) return; + s.guess = best; + render(); + }, 300); + } + // A guess is off when the whole query is someone's name ("jack dorsey"). + function usableGuess(s) { + const g = s && s.guess; + if (!g || s.scope) return null; + const whole = foldText(s.q).trim(); + if ((s.rows || []).some((r) => foldText(r.name).includes(whole))) return null; + return g; + } + // Where Enter goes: the scoped search, a strong guess, else everywhere. + function submitUserSearch(q) { + q = String(q || '').trim(); + if (!q) return; + const scope = searchScope(); + const s = ui.userSearch; + const g = s && s.q.trim() === q ? usableGuess(s) : null; + if (scope) openTextSearch(q, [], [scope]); + else if (g && g.strong) openTextSearch(g.text, [], [g.pk]); + else openTextSearch(q); + render(); + } + function scopedRows(s) { + const q = s.q.trim(); + if (q.length < 2 || /^(npub1|nprofile1|nevent1|note1|naddr1|nostr:)/i.test(q)) return []; + const scope = searchScope(), g = usableGuess(s); + const row = (pk, words, enter) => h('div', { class: 'item chat-thread-row search-scoped-row', onClick: () => { openTextSearch(words, [], [pk]); render(); } }, + avatar(pk, 'chat-avatar', false), + h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, + h('span', { class: 'chat-name' }, '\u201c' + words + '\u201d'), + h('div', { class: 'muted small' }, t('searchInPosts', { name: displayName(pk) }))), + enter ? h('kbd', { class: 'search-enter-hint' }, '\u21b5') : null); + if (scope) return [row(scope, q, true)]; + if (g) return [row(g.pk, g.text, g.strong)]; + return []; + } + function userSearchScreen() { const s = ui.userSearch; + const scoped = scopedRows(s); return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(!ui.pubProf && wallet.loaded), h('div', { class: 'card col', style: 'gap:10px' }, h('h3', { class: 'user-search-main', style: 'margin:0' }, t('searchUsers')), - h('input', { - type: 'text', class: 'user-search-input user-search-main', placeholder: t('searchUsersHint'), value: s.q, - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', - onInput: (e) => { s.q = e.target.value; userSearcher.update(s.q); }, - onKeydown: (e) => { if (e.key === 'Enter' && !e.isComposing && s.q.trim()) { e.preventDefault(); openTextSearch(s.q); render(); } }, - }), + h('div', { class: 'row gap6 user-search-main', style: 'align-items:center' }, + scopeChip(), + h('input', { + type: 'text', class: 'user-search-input user-search-main grow', placeholder: searchScope() ? t('searchScopedHint') : t('searchUsersHint'), value: s.q, + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + onInput: (e) => { s.q = e.target.value; userSearcher.update(s.q); guessScope(s.q); }, + onKeydown: (e) => { + if (e.key === 'Enter' && !e.isComposing && s.q.trim()) { e.preventDefault(); submitUserSearch(s.q); } + else if (e.key === 'Backspace' && !e.target.value && searchScope()) { e.preventDefault(); unscopeSearch(); } + }, + })), + scoped.length ? h('div', { class: 'list' }, ...scoped) : null, // the words themselves: every note and reply that says them s.q.trim().length >= 2 && !/^(npub1|nprofile1|nevent1|note1|naddr1|nostr:)/i.test(s.q.trim()) ? h('div', { class: 'list' }, h('div', { class: 'item chat-thread-row', onClick: () => { openTextSearch(s.q); render(); } }, h('div', { class: 'chat-avatar fallback' }, '\u{1F50D}'), h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, h('span', { class: 'chat-name' }, '\u201c' + s.q.trim() + '\u201d'), - h('div', { class: 'muted small' }, t('searchPostsRow'))))) + h('div', { class: 'muted small' }, t(scoped.length ? 'searchPostsRowAll' : 'searchPostsRow'))))) : null, // a word is a topic too: posts tagged with it, as a feed (() => { @@ -19216,9 +19363,20 @@ export function messagesFeature(ctx) { // The header magnifier: search anyone on nostr, results open profiles. userSearchAvailable() { return true; }, // the desktop header's search field: typing there IS the search page's query - userSearchSubmit(q) { if (String(q || '').trim()) { openTextSearch(q); render(); } }, + userSearchSubmit(q) { submitUserSearch(q); }, + userSearchScopeChip() { return scopeChip(); }, + userSearchScoped() { return !!searchScope(); }, + userSearchUnscope() { unscopeSearch(); }, + // before the header field's first keystroke takes the profile away + userSearchHoldScope() { + if (!ui.profilePk) return; + if (!ui.userSearch) { ui.userSearch = { q: '', rows: null }; warmSearch(); } + ui.userSearch.scope = ui.searchScopeOff === ui.profilePk ? null : ui.profilePk; + }, userSearchQuery(q) { if (!ui.userSearch) { ui.userSearch = { q: '', rows: null }; warmSearch(); } + // typed from a profile: its person is what the field searches, until taken out + if (ui.profilePk) ui.userSearch.scope = ui.searchScopeOff === ui.profilePk ? null : ui.profilePk; // Typing brings the results forward every time, not only the first: // the search is kept (Back from a profile lands on it), so a second // search typed from a profile opened from the first one updated a @@ -19226,13 +19384,14 @@ export function messagesFeature(ctx) { // Adam 2026-11-14). Back still returns to the profile. if (!isVisitor()) ui.chatOpen = false; ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; - ui.userSearch.q = q; userSearcher.update(q); render(); + ui.userSearch.q = q; userSearcher.update(q); guessScope(q); render(); return true; }, - openUserSearch() { + userSearchScopePk() { return searchScope(); }, + openUserSearch(scope = searchScope()) { if (!isVisitor()) ui.chatOpen = false; // a visitor's feed stays underneath, for Back ui.profilePk = null; - ui.userSearch = { q: '', rows: null }; + ui.userSearch = { q: '', rows: null, scope }; warmSearch(); // the field is about to be typed into — open the pipes return true; }, @@ -19358,8 +19517,9 @@ export function messagesFeature(ctx) { visitorSignIn() { toFrontDoor(); return true; }, visitorSearch() { rememberReturn(); + const scope = searchScope(); // from a profile: search inside its person's posts ui.profilePk = null; ui.noteThread = null; ui.liveStream = null; ui.goLive = null; ui.profOverThread = false; - ui.userSearch = { q: '', rows: null }; + ui.userSearch = { q: '', rows: null, scope }; warmSearch(); render(); return true; diff --git a/src/i18n.js b/src/i18n.js index f4774f6..efe84f6 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -484,6 +484,10 @@ const STR = { profLists: 'Lists', searchTopicRow: 'Posts on this topic', searchPostsRow: 'Posts and replies that say this', + searchPostsRowAll: 'Everywhere: posts and replies that say this', + searchInPosts: "in {name}'s posts", + searchScopedHint: 'Search their posts, or anyone…', + searchScopeRemove: 'Search everywhere', searchTopicFor: 'Posts tagged #{q}', relayLinkOpen: 'See what’s posted on this relay', alertsTitle: 'Notifications', diff --git a/src/style.css b/src/style.css index 55badb6..022ae77 100644 --- a/src/style.css +++ b/src/style.css @@ -2130,3 +2130,33 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut .note-ghosts { animation: ng-breathe 1.4s ease-in-out infinite; } @keyframes ng-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } } @media (prefers-reduced-motion: reduce) { .note-ghosts { animation: none; } } + +/* search within someone's posts: their chip in the field */ +.search-scope-chip { + display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0; max-width: 55%; + padding: 2px 2px 2px 3px; border-radius: 999px; background: var(--chip, rgba(127,127,127,.14)); + font-size: 13px; font-weight: 600; color: var(--ink); +} +.search-scope-chip .chat-avatar.mini { width: 20px; height: 20px; min-width: 20px; font-size: 10px; } +.search-scope-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.search-scope-x { + border: 0; background: none; color: var(--muted); font-size: 16px; line-height: 1; + width: 22px; height: 22px; padding: 0; border-radius: 50%; cursor: pointer; flex-shrink: 0; +} +.search-scope-x:hover { color: var(--ink); background: rgba(127,127,127,.18); } +.header-search.header-search-scoped { + align-items: center; gap: 4px; padding: 3px 6px 3px 4px; + border: 1px solid var(--line-strong); background: var(--paper); +} +.header-search.header-search-scoped:focus-within { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); } +.header-search-scoped > input.header-search-field { + flex: 1; min-width: 0; border: 0; background: none; padding: 5px 4px; font-size: 14px; border-radius: 0; +} +.header-search-scoped > input.header-search-field:focus { outline: none; } +@media (min-width: 900px) { + #app:has(> .app-bottom-nav) .header-search.header-search-scoped { display: flex; } +} +.search-enter-hint { + flex-shrink: 0; font: 600 12px/1 inherit; color: var(--muted); padding: 4px 6px; + border: 1px solid var(--line-strong); border-radius: 6px; +} diff --git a/tools/profile-stream-test.js b/tools/profile-stream-test.js index b7dfb9b..2b426bb 100644 --- a/tools/profile-stream-test.js +++ b/tools/profile-stream-test.js @@ -27,7 +27,7 @@ try { await page.waitForSelector('.user-search-main', { timeout: 10000 }); await page.type('.user-search-main', Q, { delay: 60 }); // the result rows: people (not the "posts that say this" or #topic rows) - const rows = () => page.evaluate(() => [...document.querySelectorAll('.list .item')].filter((r) => !/^[\u{1F50D}#]/u.test(r.textContent.trim())).map((r) => r.textContent.trim().slice(0, 40))); + const rows = () => page.evaluate(() => [...document.querySelectorAll('.list .item:not(.search-scoped-row)')].filter((r) => !/^[\u{1F50D}#]/u.test(r.textContent.trim())).map((r) => r.textContent.trim().slice(0, 40))); let found = []; for (let i = 0; i < 60 && !found.length; i++) { found = await rows(); if (!found.length) await sleep(250); } if (!found.length) console.log(await page.evaluate(() => document.querySelector('.user-search-main')?.closest('.col')?.parentElement?.innerHTML.slice(0, 1500))); @@ -35,7 +35,7 @@ try { const wait = Number(process.env.WAIT || 0); // ms between results and the tap (prefetch's head start) if (wait) await sleep(wait); const t0 = Date.now(); - await page.evaluate(() => [...document.querySelectorAll('.list .item')].filter((r) => !/^[\u{1F50D}#]/u.test(r.textContent.trim()))[0].click()); + await page.evaluate(() => [...document.querySelectorAll('.list .item:not(.search-scoped-row)')].filter((r) => !/^[\u{1F50D}#]/u.test(r.textContent.trim()))[0].click()); let ghostAt = 0, firstAt = 0, rowsAt3 = 0; for (let i = 0; i < 200; i++) { const s = await page.evaluate(() => ({ @@ -57,10 +57,10 @@ try { // Adam 2026-11-14 — they updated a page hidden under the profile) await page.setViewport({ width: 1300, height: 900 }); await sleep(500); - const field = await page.$('.header-search'); + const field = await page.$('input.header-search, .header-search-field'); check('the desktop header has a search field', !!field); if (field) { - await page.evaluate(() => { const f = document.querySelector('.header-search'); f.focus(); f.value = ''; }); + await page.evaluate(() => { const f = document.querySelector('input.header-search, .header-search-field'); f.focus(); f.value = ''; }); await field.type('jack', { delay: 60 }); let again = []; for (let i = 0; i < 80 && !(again.length && again[0] !== found[0]); i++) { again = await rows(); if (!(again.length && again[0] !== found[0])) await sleep(250); } diff --git a/tools/search-scope-test.js b/tools/search-scope-test.js new file mode 100644 index 0000000..122fcd0 --- /dev/null +++ b/tools/search-scope-test.js @@ -0,0 +1,96 @@ +// Searching inside one person's posts (Adam, 2026-11-14: "i found him by +// searching franca but then i was wanting some way to search his feed for +// something … i was wanting to search 'franca wildwood'"). On a profile the +// header field carries the person as a chip and Enter searches their posts; +// words that begin with the name of someone whose profile you opened +// ("fiatjaf relay") are offered, and Enter takes them there. Reads real +// relays (relays: 'read'; nothing is published). +// Run: bun tools/search-scope-test.js (WHO=npub… NAME=fiatjaf WORD=relay) +import puppeteer from './puppeteer.js'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const WHO = process.env.WHO || 'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6'; // fiatjaf +const NAME = process.env.NAME || 'fiatjaf'; +const WORD = process.env.WORD || 'relay'; + +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ relays: 'read', executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e.stack || e).slice(0, 600))); +const until = async (fn, ms = 15000, arg) => { for (let i = 0; i < ms / 200; i++) { const v = await page.evaluate(fn, arg); if (v) return v; await sleep(200); } return null; }; +const feedState = () => page.evaluate((word) => { + const posts = [...document.querySelectorAll('.notes-feed:not(.note-ghosts) > [data-key]')].filter((p) => !/^(hr:|win-)/.test(p.getAttribute('data-key'))); + return { path: location.pathname + location.search, title: document.querySelector('.feed-page h2, .feed-title, .feed-name')?.textContent || '', + n: posts.length, without: posts.filter((p) => !p.textContent.toLowerCase().includes(word)).length }; +}, WORD); +try { + await page.setViewport({ width: 1300, height: 900 }); + await page.goto(server.url.href + WHO, { waitUntil: 'domcontentloaded' }); + check('the profile opens', !!(await until(() => document.querySelector('.prof-tabs')))); + const chip = await until((n) => { const c = document.querySelector('.header-search-scoped .search-scope-chip')?.textContent || ''; return c.toLowerCase().includes(n) && c; }, 15000, NAME.toLowerCase()); + check('the header field carries the person as a chip', !!chip && chip.toLowerCase().includes(NAME.toLowerCase()), chip || ''); + if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT.replace(/(\.png)?$/, '-chip.png'), clip: { x: 0, y: 0, width: 1300, height: 120 } }); + + await page.click('.header-search-field'); + await page.type('.header-search-field', WORD, { delay: 50 }); + const row = await until(() => document.querySelector('.search-scoped-row')?.textContent); + check('the results offer the words in their posts, first', !!row && row.includes(WORD) && /posts/.test(row), row || ''); + const people = await until(() => document.querySelectorAll('.list .item:not(.search-scoped-row)').length > 2); + check('…and still find people', !!people); + await page.keyboard.press('Enter'); + const f1 = await until(async () => document.querySelectorAll('.notes-feed:not(.note-ghosts) > [data-key]').length >= 1 && location.search.includes('p=npub'), 25000); + const s1 = await feedState(); + check('Enter searches only their posts (URL carries the person)', !!f1 && s1.path.includes('q=' + WORD) && s1.path.includes('p=npub'), JSON.stringify(s1)); + check('…every post says the word', s1.n > 0 && s1.without === 0, `${s1.n} posts, ${s1.without} without "${WORD}"`); + const authors = await page.evaluate(() => [...new Set([...document.querySelectorAll('.notes-feed:not(.note-ghosts) > [data-key] .note-author, .notes-feed:not(.note-ghosts) > [data-key] .chat-name')].map((n) => n.textContent.trim()))].slice(0, 5)); + console.log(' authors seen:', authors.join(', ')); + if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT.replace(/(\.png)?$/, '-feed.png') }); + + // the guess: " " from scratch, the profile having been opened + await page.goto(server.url.href + 'feed', { waitUntil: 'domcontentloaded' }); + await sleep(1500); + await page.evaluate((pk) => { /* nothing: a reload forgets the session's profiles */ }, WHO); + check('no chip away from a profile', !(await page.$('.search-scope-chip'))); + // open the profile in this session, then go back and type the combined query + await page.goto(server.url.href + WHO, { waitUntil: 'domcontentloaded' }); + await until(() => document.querySelector('.prof-tabs')); + await page.click('.search-scope-x'); + check('× takes the chip out', !!(await until(() => !document.querySelector('.search-scope-chip')))); + await page.click('input.header-search'); + await page.type('input.header-search', `${NAME} ${WORD}`, { delay: 50 }); + const guess = await until(() => document.querySelector('.search-scoped-row')?.textContent, 15000); + check('"name word" is offered as their posts', !!guess && guess.includes(`“${WORD}”`) && guess.toLowerCase().includes(NAME.toLowerCase()), guess || ''); + check('…as the strong guess (Enter hint)', !!(await page.$('.search-scoped-row .search-enter-hint'))); + await page.keyboard.press('Enter'); + const f2 = await until(() => location.search.includes('p=npub') && location.search.includes('q=' + encodeURIComponent('relay').slice(0, 3)), 15000); + check('Enter takes the guess', !!f2, (await feedState()).path); + + // a query that is someone's whole name is not split + await page.click('input.header-search, .header-search-field'); + await page.evaluate(() => { const f = document.querySelector('input.header-search, .header-search-field'); f.value = ''; }); + await page.type('input.header-search, .header-search-field', 'nobody wordthatmatchesnoone', { delay: 30 }); + await sleep(3000); + check('no guess when no name starts the words', !(await page.$('.search-scoped-row'))); + // a phone: the magnifier on a profile opens search with the chip in it + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href + WHO, { waitUntil: 'domcontentloaded' }); + await until(() => document.querySelector('.prof-tabs')); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.getAttribute('aria-label') === 'Search' && b.offsetParent)?.click()); + const mchip = await until(() => document.querySelector('.user-search-main .search-scope-chip')?.textContent); + check('phone: search from a profile carries the chip', !!mchip, mchip || ''); + await page.type('.user-search-input', WORD, { delay: 40 }); + check('phone: …and offers their posts', !!(await until(() => document.querySelector('.search-scoped-row')))); + await page.click('.user-search-input'); + await page.keyboard.press('End'); + for (let i = 0; i < WORD.length; i++) await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + check('phone: Backspace on the empty field takes the chip out', !!(await until(() => !document.querySelector('.search-scope-chip')))); + if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT.replace(/(\.png)?$/, '-phone.png') }); + check('no page errors', !errs.length, errs.join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(); } +console.log(ok ? '\nPASS' : '\nFAIL'); +process.exit(ok ? 0 : 1); -- cgit v1.2.3