diff options
| author | Adam Soltys <[email protected]> | 2026-10-02 07:48:32 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-02 07:48:32 -0700 |
| commit | 686e2d391d3a963535d5deec7043999b9b92b19b (patch) | |
| tree | f44167f4affaf38e63ed1bd637c7509f74a72679 /src/features | |
| parent | 53160e57a1bf65812ddade7e4e71d0c3a75e812c (diff) | |
| download | coinosv3-686e2d391d3a963535d5deec7043999b9b92b19b.tar.gz coinosv3-686e2d391d3a963535d5deec7043999b9b92b19b.zip | |
Profiles show Following and Followers, each a list you can open
Under the bio: "116 Following · 572 Followers". Tapping either opens the
people below it (50 at a time); tapping a person opens their profile.
Follows are the person's kind 3, now read from Primal's cache alongside
the relays (a list kept only on their outbox relays used to read as
nobody). Followers, which no relay counts, come from Primal: the count
from user_profile, the list from user_followers, a first screenful fast
and the rest after, without reordering what is on screen. Every event
taken from Primal is signature-checked in the verify worker first, and
the profiles among them fill names and faces the relays lacked.
Also translates the post-zap "no address" message.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dmcrt9KyNDKVWFKnb8BREo
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 121 |
1 files changed, 116 insertions, 5 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 9e73db3..3e8fce5 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -33,7 +33,7 @@ import { mergeFeedWindow } from '../feed-window.js'; import { createThreadStore } from '../thread-cache.js'; import { createFeedCache, FEED_CACHE_POSTS, FEED_SEED_POSTS } from '../feed-cache.js'; import { PUBLIC_FEED_RELAYS, popularCandidates } from '../popular-feed.js'; -import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl, punkSmallUrl } from '../recipient-search.js'; +import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl, punkSmallUrl, primalCache } from '../recipient-search.js'; import { getNetwork } from '../api.js'; import { decodeBolt11 } from '../ark/lightning.js'; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; @@ -3145,6 +3145,7 @@ export function messagesFeature(ctx) { // for the other direction (a note row tapped on a profile page). ui.profOverThread = !!ui.noteThread; ui.profEdit = null; ui.profEditFilled = false; ui.logoutConfirm = null; ui.profCompose = null; + ui.profPeople = null; render(); try { window.scrollTo(0, 0); } catch {} fetchFullProfile(pk); @@ -4453,10 +4454,17 @@ export function messagesFeature(ctx) { if (f.loading || (f.at && Date.now() - f.at < 600_000)) return; f.loading = true; liveProfileOf(pk); // their name and face for the title - queryOn([...new Set([...(def.relays || []), ...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [3], authors: [pk] }, 6000) - .catch(() => []) - .then((evs) => { - const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0]; + // Their relays and Primal's cache at once: a list kept only on their own + // outbox relays reads as nobody followed. Primal's copy is the signed + // event itself, with the follows' profiles beside it; both are checked + // before anything is believed. + Promise.all([ + queryOn([...new Set([...(def.relays || []), ...PROFILE_RELAYS, ...NOTE_RELAYS])], { kinds: [3], authors: [pk] }, 6000).catch(() => []), + primalCache('contact_list', { pubkey: pk }, 6000).then((evs) => verifiedPrimal(evs, [0, 3])).catch(() => []), + ]) + .then(([relayEvs, primalEvs]) => { + const evs = [...(relayEvs || []), ...primalEvs.filter((e) => e.kind === 3 && e.pubkey === pk)]; + const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; const had = f.at; if (newest || !had) f.authors = newest ? hexList(newest.tags.filter((x) => x[0] === 'p').map((x) => x[1])) : []; f.at = Date.now(); f.loading = false; @@ -4470,6 +4478,107 @@ export function messagesFeature(ctx) { }); } + // ---- follows & followers ------------------------------------------------- + // Whom someone follows is their own kind 3 (ofFollows, above). Who follows + // THEM is everyone else's kind 3 — no relay will count that — so the + // number and the list come from Primal's cache, which indexes the network + // for exactly this. Only pubkeys are taken from it: names and faces still + // come from the relays, as everywhere else. + const socialCounts = new Map(); // pk -> { follows, followers, at, loading } + function loadSocial(pk) { + const cur = socialCounts.get(pk); + if (cur && (cur.loading || Date.now() - (cur.at || 0) < 600_000)) return cur; + socialCounts.set(pk, { ...(cur || {}), loading: true }); + primalCache('user_profile', { pubkey: pk }, 6000).then((evs) => { + let c = {}; + try { c = JSON.parse(evs.find((e) => e.kind === 10000105)?.content || '{}'); } catch {} + const num = (n) => (Number.isFinite(n) && n >= 0 ? n : null); + socialCounts.set(pk, { follows: num(c.follows_count), followers: num(c.followers_count), at: Date.now() }); + }).catch(() => socialCounts.set(pk, { ...(cur || {}), at: Date.now() })).finally(scheduleRepaint); + return socialCounts.get(pk); + } + // Primal's events, signature-checked off the main thread; the profiles + // among them fill names and faces the relays didn't have. + async function verifiedPrimal(evs, kinds) { + const want = (evs || []).filter((e) => kinds.includes(e.kind) && e.sig && /^[0-9a-f]{64}$/.test(e.pubkey || '')); + const ok = await verifyEventsAsync(want); + const good = want.filter((e, i) => ok[i]); + let applied = 0; + for (const e of good) if (e.kind === 0 && applyProfile(e.pubkey, e)) applied++; + if (applied) scheduleRepaint(); + return good; + } + const followersOf = new Map(); // pk -> { pks, at, loading } + function loadFollowers(pk) { + const cur = followersOf.get(pk); + if (cur && (cur.loading || Date.now() - (cur.at || 0) < 600_000)) return cur; + followersOf.set(pk, { pks: cur?.pks || [], loading: true }); + const ask = (limit) => primalCache('user_followers', { pubkey: pk, limit }, 8000) + .then((evs) => verifiedPrimal(evs, [0])).then((evs) => [...new Set(evs.map((e) => e.pubkey))]); + // a first screenful fast, then the long list behind it + ask(60).then((pks) => { + const now = followersOf.get(pk); + if (now?.loading && pks.length > (now.pks || []).length) { followersOf.set(pk, { pks, loading: true }); scheduleRepaint(); } + }).catch(() => {}); + ask(500).then((pks) => { + const now = followersOf.get(pk); + // the people already on screen keep their places; the rest go after + const shown = now?.pks || []; + const seen = new Set(shown); + followersOf.set(pk, { pks: [...shown, ...pks.filter((x) => !seen.has(x))], at: Date.now() }); + }).catch(() => followersOf.set(pk, { pks: followersOf.get(pk)?.pks || [], at: Date.now() })).finally(scheduleRepaint); + return followersOf.get(pk); + } + const compactNum = (n) => { try { return new Intl.NumberFormat(getLang(), { notation: 'compact', maximumFractionDigits: 1 }).format(n); } catch { return String(n); } }; + // "116 Following · 572 Followers" under the bio; each opens its list + // right below, on the same page. + function socialLine(pk) { + const c = loadSocial(pk); + const f = ofFollows.get(pk); + const follows = f?.at ? f.authors.length : c?.follows; + const pick = (which) => { + ui.profPeople = ui.profPeople === which ? null : which; + ui.profPeopleN = 50; + if (which === 'following') loadOfFollows({ of: pk }); + else loadFollowers(pk); + render(); + }; + const btn = (which, n, label) => h('button', { + type: 'button', class: 'social-count' + (ui.profPeople === which ? ' on' : ''), 'aria-expanded': String(ui.profPeople === which), + onClick: () => pick(which), + }, h('b', {}, n == null ? '–' : compactNum(n)), ' ', label); + return h('div', { class: 'row social-line' }, + btn('following', follows, t('profFollowing')), + btn('followers', c?.followers, t('profFollowers'))); + } + function socialList(pk) { + const which = ui.profPeople; + if (!which) return null; + const src = which === 'following' ? ofFollows.get(pk) : followersOf.get(pk); + const all = which === 'following' ? (src?.authors || []) : (src?.pks || []); + const pks = all.filter((x) => !hiddenPk(x)); + const n = ui.profPeopleN || 50; + const loading = !src || src.loading || (which === 'following' && !src.at); + const total = which === 'followers' ? socialCounts.get(pk)?.followers : null; + return h('div', { class: 'col social-list', style: 'gap:6px' }, + loading && !pks.length ? h('div', { class: 'row', style: 'justify-content:center;padding:10px' }, h('span', { class: 'spinner sm' })) : null, + !loading && !pks.length ? h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t(which === 'following' ? 'profFollowsNone' : 'profFollowersNone')) : null, + pks.length ? h('div', { class: 'list' }, ...pks.slice(0, n).map((x) => { + const p = profileOf(x) || {}; + const sub = p.nip05 ? String(p.nip05).replace(/^_@/, '') : (npubOf(x) || '').slice(0, 20) + '…'; + return h('div', { class: 'item chat-thread-row', 'data-key': 'sp:' + x, onClick: () => openProfile(x) }, + avatar(x, 'chat-avatar', false), + h('div', { class: 'col grow', style: 'min-width:0;gap:1px' }, + h('div', { class: 'chat-name' }, displayName(x)), + h('div', { class: 'muted small chat-preview' }, sub))); + })) : null, + pks.length > n ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.profPeopleN = n + 50; render(); } }, t('showMore')) : null, + // Primal hands back the followers it knows best, not always all of them + which === 'followers' && !loading && total && pks.length < total && pks.length <= n + ? h('div', { class: 'small faint', style: 'text-align:center' }, t('profFollowersPartial', { n: compactNum(pks.length), total: compactNum(total) })) + : null); + } + function feedNow() { if (!feedDef(curFeedId)) curFeedId = FOLLOWING; // a feed deleted on another device let c = feedStates.get(curFeedId); @@ -7913,6 +8022,8 @@ export function messagesFeature(ctx) { ? h('p', { class: 'small', style: 'margin:0;white-space:pre-wrap;overflow-wrap:anywhere' }, ...noteBody(about.slice(0, 1000))) : null, + ui.profEdit ? null : socialLine(pk), + ui.profEdit ? null : socialList(pk), // A readable public-ID preview; copying always uses the full npub. // It only steps aside while the (long) edit form is open. ui.profEdit ? null : h('button', { |
