aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js22
-rw-r--r--src/features/messages.js206
-rw-r--r--src/i18n.js4
-rw-r--r--src/style.css30
-rw-r--r--tools/profile-stream-test.js8
-rw-r--r--tools/search-scope-test.js96
6 files changed, 333 insertions, 33 deletions
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: "<name> <word>" 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);