diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 206 |
1 files changed, 183 insertions, 23 deletions
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; |
