aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-02 07:48:32 -0700
committerAdam Soltys <[email protected]>2026-10-02 07:48:32 -0700
commit686e2d391d3a963535d5deec7043999b9b92b19b (patch)
treef44167f4affaf38e63ed1bd637c7509f74a72679 /src
parent53160e57a1bf65812ddade7e4e71d0c3a75e812c (diff)
downloadcoinosv3-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')
-rw-r--r--src/features/messages.js121
-rw-r--r--src/i18n.js6
-rw-r--r--src/locales/ar.json9
-rw-r--r--src/locales/bn.json9
-rw-r--r--src/locales/de.json9
-rw-r--r--src/locales/es.json9
-rw-r--r--src/locales/fr.json9
-rw-r--r--src/locales/hi.json9
-rw-r--r--src/locales/id.json9
-rw-r--r--src/locales/ja.json9
-rw-r--r--src/locales/ko.json9
-rw-r--r--src/locales/mr.json9
-rw-r--r--src/locales/pt.json9
-rw-r--r--src/locales/ru.json9
-rw-r--r--src/locales/sw.json9
-rw-r--r--src/locales/ta.json9
-rw-r--r--src/locales/te.json9
-rw-r--r--src/locales/tr.json9
-rw-r--r--src/locales/ur.json9
-rw-r--r--src/locales/vi.json9
-rw-r--r--src/locales/zh.json9
-rw-r--r--src/recipient-search.js12
-rw-r--r--src/style.css10
23 files changed, 292 insertions, 28 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', {
diff --git a/src/i18n.js b/src/i18n.js
index d1d72a7..9ab7c53 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -104,6 +104,12 @@ const STR = {
profUploadPic: 'Upload photo',
profDisplayName: 'Display name',
profPublicKey: 'Nostr public key',
+ profFollowing: 'Following',
+ profFollowers: 'Followers',
+ profFollowsNone: 'Not following anyone yet.',
+ profFollowersNone: 'No followers found.',
+ profFollowersPartial: 'Showing {n} of {total} followers.',
+ showMore: 'Show more',
profFetchFailed: 'Couldn’t load the latest profile.',
profNotFound: 'No profile details found on the available relays.',
profUnameInvalid: 'Usernames can only use letters, numbers, dots, dashes and underscores.',
diff --git a/src/locales/ar.json b/src/locales/ar.json
index 53fda34..c5af5fb 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -863,5 +863,12 @@
"copy": "نسخ",
"switchUnit": "تبديل BTC / sats",
"footerMadeBy": "صُنع بواسطة",
- "footerSourceOn": "الكود المصدري على"
+ "footerSourceOn": "الكود المصدري على",
+ "profFollowing": "يتابع",
+ "profFollowers": "المتابعون",
+ "profFollowsNone": "لا يتابع أحدًا بعد.",
+ "profFollowersNone": "لم يُعثر على متابعين.",
+ "profFollowersPartial": "عرض {n} من {total} متابع.",
+ "showMore": "عرض المزيد",
+ "arkZapNoPostZap": "لا يمكن إرسال zap لهذا المنشور: ليس لدى كاتبه عنوان Lightning ولا عنوان Ark."
}
diff --git a/src/locales/bn.json b/src/locales/bn.json
index 78f6b78..b2f8b5f 100644
--- a/src/locales/bn.json
+++ b/src/locales/bn.json
@@ -863,5 +863,12 @@
"copy": "কপি",
"switchUnit": "BTC / sats পাল্টান",
"footerMadeBy": "তৈরি করেছেন",
- "footerSourceOn": "সোর্স কোড"
+ "footerSourceOn": "সোর্স কোড",
+ "profFollowing": "অনুসরণ করছেন",
+ "profFollowers": "অনুসারী",
+ "profFollowsNone": "এখনও কাউকে অনুসরণ করছেন না।",
+ "profFollowersNone": "কোনো অনুসারী পাওয়া যায়নি।",
+ "profFollowersPartial": "{total} জন অনুসারীর মধ্যে {n} জন দেখানো হচ্ছে।",
+ "showMore": "আরও দেখুন",
+ "arkZapNoPostZap": "এই পোস্টে zap করা যাবে না: লেখকের কোনো Lightning ঠিকানা বা Ark ঠিকানা নেই।"
}
diff --git a/src/locales/de.json b/src/locales/de.json
index 8939a7f..5311a5b 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -863,5 +863,12 @@
"copy": "Kopieren",
"switchUnit": "BTC / sats wechseln",
"footerMadeBy": "gemacht von",
- "footerSourceOn": "Quellcode auf"
+ "footerSourceOn": "Quellcode auf",
+ "profFollowing": "Folgt",
+ "profFollowers": "Follower",
+ "profFollowsNone": "Folgt noch niemandem.",
+ "profFollowersNone": "Keine Follower gefunden.",
+ "profFollowersPartial": "{n} von {total} Followern angezeigt.",
+ "showMore": "Mehr anzeigen",
+ "arkZapNoPostZap": "Dieser Beitrag kann nicht gezappt werden: Der Autor hat weder eine Lightning- noch eine Ark-Adresse."
}
diff --git a/src/locales/es.json b/src/locales/es.json
index ae6e42e..4084357 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -863,5 +863,12 @@
"copy": "Copiar",
"switchUnit": "Cambiar BTC / sats",
"footerMadeBy": "hecho por",
- "footerSourceOn": "código disponible en"
+ "footerSourceOn": "código disponible en",
+ "profFollowing": "Siguiendo",
+ "profFollowers": "Seguidores",
+ "profFollowsNone": "Aún no sigue a nadie.",
+ "profFollowersNone": "No se encontraron seguidores.",
+ "profFollowersPartial": "Mostrando {n} de {total} seguidores.",
+ "showMore": "Mostrar más",
+ "arkZapNoPostZap": "No se puede hacer zap a esta publicación: su autor no tiene dirección Lightning ni dirección Ark."
}
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 31906d2..f8ca6a9 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -863,5 +863,12 @@
"copy": "Copier",
"switchUnit": "Basculer BTC / sats",
"footerMadeBy": "fait par",
- "footerSourceOn": "code source sur"
+ "footerSourceOn": "code source sur",
+ "profFollowing": "Abonnements",
+ "profFollowers": "Abonnés",
+ "profFollowsNone": "Ne suit encore personne.",
+ "profFollowersNone": "Aucun abonné trouvé.",
+ "profFollowersPartial": "{n} abonnés affichés sur {total}.",
+ "showMore": "Afficher plus",
+ "arkZapNoPostZap": "Impossible de zapper cette publication : son auteur n’a ni adresse Lightning ni adresse Ark."
}
diff --git a/src/locales/hi.json b/src/locales/hi.json
index 5e97147..013be87 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -863,5 +863,12 @@
"copy": "कॉपी",
"switchUnit": "BTC / sats बदलें",
"footerMadeBy": "द्वारा बनाया",
- "footerSourceOn": "सोर्स यहाँ"
+ "footerSourceOn": "सोर्स यहाँ",
+ "profFollowing": "फ़ॉलो कर रहे हैं",
+ "profFollowers": "फ़ॉलोअर्स",
+ "profFollowsNone": "अभी तक किसी को फ़ॉलो नहीं कर रहे।",
+ "profFollowersNone": "कोई फ़ॉलोअर नहीं मिला।",
+ "profFollowersPartial": "{total} में से {n} फ़ॉलोअर्स दिखाए जा रहे हैं।",
+ "showMore": "और दिखाएँ",
+ "arkZapNoPostZap": "इस पोस्ट पर zap नहीं कर सकते: लेखक के पास न Lightning पता है, न Ark पता।"
}
diff --git a/src/locales/id.json b/src/locales/id.json
index 52b534f..64bf633 100644
--- a/src/locales/id.json
+++ b/src/locales/id.json
@@ -199,5 +199,12 @@
"clearAllWarn": "⚠ Ini menghapus semua dompet dari perangkat ini. Dompet mana pun yang frasa pemulihan atau kuncinya belum Anda cadangkan akan hilang selamanya.",
"rename": "Ganti nama",
"darkMode": "Mode gelap",
- "lightMode": "Mode terang"
+ "lightMode": "Mode terang",
+ "profFollowing": "Mengikuti",
+ "profFollowers": "Pengikut",
+ "profFollowsNone": "Belum mengikuti siapa pun.",
+ "profFollowersNone": "Tidak ada pengikut yang ditemukan.",
+ "profFollowersPartial": "Menampilkan {n} dari {total} pengikut.",
+ "showMore": "Tampilkan lebih banyak",
+ "arkZapNoPostZap": "Tidak bisa zap postingan ini: penulisnya tidak punya alamat Lightning maupun alamat Ark."
}
diff --git a/src/locales/ja.json b/src/locales/ja.json
index fda7279..ddd6c53 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -863,5 +863,12 @@
"copy": "コピー",
"switchUnit": "BTC / sats を切替",
"footerMadeBy": "作成者:",
- "footerSourceOn": "ソースは"
+ "footerSourceOn": "ソースは",
+ "profFollowing": "フォロー中",
+ "profFollowers": "フォロワー",
+ "profFollowsNone": "まだ誰もフォローしていません。",
+ "profFollowersNone": "フォロワーが見つかりません。",
+ "profFollowersPartial": "{total}人中{n}人のフォロワーを表示しています。",
+ "showMore": "もっと見る",
+ "arkZapNoPostZap": "この投稿にはZapできません:投稿者にLightningアドレスもArkアドレスもありません。"
}
diff --git a/src/locales/ko.json b/src/locales/ko.json
index bfb8e82..cf252d2 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -862,5 +862,12 @@
"copy": "복사",
"switchUnit": "BTC / sats 전환",
"footerMadeBy": "만든 사람:",
- "footerSourceOn": "소스 공개:"
+ "footerSourceOn": "소스 공개:",
+ "profFollowing": "팔로잉",
+ "profFollowers": "팔로워",
+ "profFollowsNone": "아직 아무도 팔로우하지 않습니다.",
+ "profFollowersNone": "팔로워를 찾을 수 없습니다.",
+ "profFollowersPartial": "팔로워 {total}명 중 {n}명을 표시합니다.",
+ "showMore": "더 보기",
+ "arkZapNoPostZap": "이 게시물에 zap할 수 없습니다: 작성자에게 Lightning 주소도 Ark 주소도 없습니다."
}
diff --git a/src/locales/mr.json b/src/locales/mr.json
index 848e626..63c17cf 100644
--- a/src/locales/mr.json
+++ b/src/locales/mr.json
@@ -199,5 +199,12 @@
"clearAllWarn": "⚠ हे या डिव्हाइसवरील सर्व वॉलेट काढून टाकते. ज्या वॉलेटचा रिकव्हरी वाक्यांश किंवा की तुम्ही बॅकअप घेतलेला नाही, तो कायमचा गमावला जाईल.",
"rename": "नाव बदला",
"darkMode": "डार्क मोड",
- "lightMode": "लाइट मोड"
+ "lightMode": "लाइट मोड",
+ "profFollowing": "फॉलो करत आहे",
+ "profFollowers": "फॉलोअर्स",
+ "profFollowsNone": "अद्याप कोणालाही फॉलो करत नाही.",
+ "profFollowersNone": "कोणतेही फॉलोअर्स आढळले नाहीत.",
+ "profFollowersPartial": "{total} पैकी {n} फॉलोअर्स दाखवत आहे.",
+ "showMore": "अधिक दाखवा",
+ "arkZapNoPostZap": "या पोस्टला zap करता येत नाही: लेखकाकडे Lightning पत्ता किंवा Ark पत्ता नाही."
}
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 979b21f..ce07aba 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -863,5 +863,12 @@
"copy": "Copiar",
"switchUnit": "Alternar BTC / sats",
"footerMadeBy": "feito por",
- "footerSourceOn": "código disponível no"
+ "footerSourceOn": "código disponível no",
+ "profFollowing": "Seguindo",
+ "profFollowers": "Seguidores",
+ "profFollowsNone": "Ainda não segue ninguém.",
+ "profFollowersNone": "Nenhum seguidor encontrado.",
+ "profFollowersPartial": "Mostrando {n} de {total} seguidores.",
+ "showMore": "Mostrar mais",
+ "arkZapNoPostZap": "Não é possível dar zap neste post: o autor não tem endereço Lightning nem endereço Ark."
}
diff --git a/src/locales/ru.json b/src/locales/ru.json
index eeac956..5d2fc07 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -863,5 +863,12 @@
"copy": "Копировать",
"switchUnit": "Переключить BTC / sats",
"footerMadeBy": "сделано,",
- "footerSourceOn": "исходный код на"
+ "footerSourceOn": "исходный код на",
+ "profFollowing": "Подписки",
+ "profFollowers": "Подписчики",
+ "profFollowsNone": "Пока ни на кого не подписан.",
+ "profFollowersNone": "Подписчики не найдены.",
+ "profFollowersPartial": "Показано {n} из {total} подписчиков.",
+ "showMore": "Показать ещё",
+ "arkZapNoPostZap": "Нельзя отправить zap на этот пост: у автора нет ни Lightning-адреса, ни Ark-адреса."
}
diff --git a/src/locales/sw.json b/src/locales/sw.json
index ec81506..d41ee46 100644
--- a/src/locales/sw.json
+++ b/src/locales/sw.json
@@ -199,5 +199,12 @@
"clearAllWarn": "⚠ Hii inaondoa pochi zote kwenye kifaa hiki. Pochi yoyote ambayo kifungu chake cha kurejesha au ufunguo haukuhifadhi nakala itapotea milele.",
"rename": "Badilisha jina",
"darkMode": "Hali ya giza",
- "lightMode": "Hali ya mwanga"
+ "lightMode": "Hali ya mwanga",
+ "profFollowing": "Anaowafuata",
+ "profFollowers": "Wafuasi",
+ "profFollowsNone": "Bado hamfuati mtu yeyote.",
+ "profFollowersNone": "Hakuna wafuasi waliopatikana.",
+ "profFollowersPartial": "Inaonyesha wafuasi {n} kati ya {total}.",
+ "showMore": "Onyesha zaidi",
+ "arkZapNoPostZap": "Huwezi kutuma zap kwa chapisho hili: mwandishi wake hana anwani ya Lightning wala anwani ya Ark."
}
diff --git a/src/locales/ta.json b/src/locales/ta.json
index 48b7ed4..3fc58b8 100644
--- a/src/locales/ta.json
+++ b/src/locales/ta.json
@@ -199,5 +199,12 @@
"clearAllWarn": "⚠ இது இந்த சாதனத்திலிருந்து அனைத்து வாலெட்களையும் நீக்குகிறது. மீட்பு சொற்றொடர் அல்லது விசையை நீங்கள் காப்புப் பிரதி எடுக்காத எந்த வாலெட்டும் நிரந்தரமாக இழக்கப்படும்.",
"rename": "பெயர் மாற்று",
"darkMode": "இருண்ட பயன்முறை",
- "lightMode": "ஒளி பயன்முறை"
+ "lightMode": "ஒளி பயன்முறை",
+ "profFollowing": "பின்தொடர்கிறார்",
+ "profFollowers": "பின்தொடர்பவர்கள்",
+ "profFollowsNone": "இன்னும் யாரையும் பின்தொடரவில்லை.",
+ "profFollowersNone": "பின்தொடர்பவர்கள் யாரும் இல்லை.",
+ "profFollowersPartial": "{total} பின்தொடர்பவர்களில் {n} பேர் காட்டப்படுகின்றனர்.",
+ "showMore": "மேலும் காட்டு",
+ "arkZapNoPostZap": "இந்தப் பதிவுக்கு zap செய்ய முடியாது: ஆசிரியரிடம் Lightning முகவரியோ Ark முகவரியோ இல்லை."
}
diff --git a/src/locales/te.json b/src/locales/te.json
index 3872f4d..5b2a31e 100644
--- a/src/locales/te.json
+++ b/src/locales/te.json
@@ -197,5 +197,12 @@
"clearAllWarn": "⚠ ఇది ఈ పరికరం నుండి అన్ని వాలెట్‌లను తొలగిస్తుంది. మీరు రికవరీ పదబంధం లేదా కీని బ్యాకప్ చేయని ఏ వాలెట్ అయినా శాశ్వతంగా పోతుంది.",
"rename": "పేరు మార్చు",
"darkMode": "డార్క్ మోడ్",
- "lightMode": "లైట్ మోడ్"
+ "lightMode": "లైట్ మోడ్",
+ "profFollowing": "ఫాలో అవుతున్నవారు",
+ "profFollowers": "ఫాలోవర్లు",
+ "profFollowsNone": "ఇంకా ఎవరినీ ఫాలో అవ్వడం లేదు.",
+ "profFollowersNone": "ఫాలోవర్లు కనిపించలేదు.",
+ "profFollowersPartial": "{total} ఫాలోవర్లలో {n} మందిని చూపిస్తోంది.",
+ "showMore": "మరిన్ని చూపించు",
+ "arkZapNoPostZap": "ఈ పోస్ట్‌కు zap చేయలేరు: రచయితకు Lightning చిరునామా లేదా Ark చిరునామా లేదు."
}
diff --git a/src/locales/tr.json b/src/locales/tr.json
index 6c1bbe2..62e9049 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -862,5 +862,12 @@
"copy": "Kopyala",
"switchUnit": "BTC / sats değiştir",
"footerMadeBy": "yapan:",
- "footerSourceOn": "kaynak kodu"
+ "footerSourceOn": "kaynak kodu",
+ "profFollowing": "Takip edilen",
+ "profFollowers": "Takipçiler",
+ "profFollowsNone": "Henüz kimseyi takip etmiyor.",
+ "profFollowersNone": "Takipçi bulunamadı.",
+ "profFollowersPartial": "{total} takipçiden {n} tanesi gösteriliyor.",
+ "showMore": "Daha fazla göster",
+ "arkZapNoPostZap": "Bu gönderiye zap yapılamaz: yazarının ne Lightning adresi ne de Ark adresi var."
}
diff --git a/src/locales/ur.json b/src/locales/ur.json
index a1726bd..734df85 100644
--- a/src/locales/ur.json
+++ b/src/locales/ur.json
@@ -199,5 +199,12 @@
"clearAllWarn": "⚠ یہ اس ڈیوائس سے تمام والیٹ ہٹا دیتا ہے۔ جس بھی والیٹ کا ریکوری فقرہ یا کلید آپ نے بیک اپ نہیں کیا، وہ ہمیشہ کے لیے کھو جائے گا۔",
"rename": "نام تبدیل کریں",
"darkMode": "ڈارک موڈ",
- "lightMode": "لائٹ موڈ"
+ "lightMode": "لائٹ موڈ",
+ "profFollowing": "فالو کر رہے ہیں",
+ "profFollowers": "فالوورز",
+ "profFollowsNone": "ابھی کسی کو فالو نہیں کر رہے۔",
+ "profFollowersNone": "کوئی فالوور نہیں ملا۔",
+ "profFollowersPartial": "{total} میں سے {n} فالوورز دکھائے جا رہے ہیں۔",
+ "showMore": "مزید دکھائیں",
+ "arkZapNoPostZap": "اس پوسٹ پر zap نہیں ہو سکتا: مصنف کے پاس نہ Lightning پتہ ہے نہ Ark پتہ۔"
}
diff --git a/src/locales/vi.json b/src/locales/vi.json
index cfba010..afce28e 100644
--- a/src/locales/vi.json
+++ b/src/locales/vi.json
@@ -199,5 +199,12 @@
"clearAllWarn": "⚠ Thao tác này xóa mọi ví khỏi thiết bị này. Bất kỳ ví nào mà bạn chưa sao lưu cụm từ khôi phục hoặc khóa sẽ mất vĩnh viễn.",
"rename": "Đổi tên",
"darkMode": "Chế độ tối",
- "lightMode": "Chế độ sáng"
+ "lightMode": "Chế độ sáng",
+ "profFollowing": "Đang theo dõi",
+ "profFollowers": "Người theo dõi",
+ "profFollowsNone": "Chưa theo dõi ai.",
+ "profFollowersNone": "Không tìm thấy người theo dõi.",
+ "profFollowersPartial": "Đang hiển thị {n} trên {total} người theo dõi.",
+ "showMore": "Xem thêm",
+ "arkZapNoPostZap": "Không thể zap bài này: tác giả không có địa chỉ Lightning hay địa chỉ Ark."
}
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 1ca0f39..ee2234e 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -863,5 +863,12 @@
"copy": "复制",
"switchUnit": "切换 BTC / sats",
"footerMadeBy": "制作者",
- "footerSourceOn": "源代码见"
+ "footerSourceOn": "源代码见",
+ "profFollowing": "关注",
+ "profFollowers": "粉丝",
+ "profFollowsNone": "还没有关注任何人。",
+ "profFollowersNone": "未找到粉丝。",
+ "profFollowersPartial": "显示 {total} 位粉丝中的 {n} 位。",
+ "showMore": "显示更多",
+ "arkZapNoPostZap": "无法打赏此帖子:作者既没有 Lightning 地址也没有 Ark 地址。"
}
diff --git a/src/recipient-search.js b/src/recipient-search.js
index b0a0edc..fdeaeaf 100644
--- a/src/recipient-search.js
+++ b/src/recipient-search.js
@@ -51,21 +51,25 @@ const MAX_RESULTS = 8;
// to cost more than the query itself. Idle for a minute, it closes itself.
let pws = null, pwsN = 0, pwsIdle = null;
const pwsSubs = new Map(); // sub id -> { rows, finish }
-function primalSearch(q, limit = MAX_RESULTS) {
+// Any of Primal's cache calls over the shared socket; resolves with every
+// event it answered (its own stats kinds included) at EOSE or the timeout.
+export function primalCache(cmd, args, timeoutMs = 3500) {
return new Promise((resolve) => {
const sid = 'rs' + ++pwsN;
const rows = [];
const finish = () => { clearTimeout(to); pwsSubs.delete(sid); resolve(rows); };
- const to = setTimeout(finish, 3500);
+ const to = setTimeout(finish, timeoutMs);
pwsSubs.set(sid, { rows, finish });
clearTimeout(pwsIdle);
pwsIdle = setTimeout(() => { try { pws?.close(); } catch {} pws = null; }, 60_000);
- const send = () => { try { pws.send(JSON.stringify(['REQ', sid, { cache: ['user_search', { query: q, limit }] }])); } catch { finish(); } };
+ const send = () => { try { pws.send(JSON.stringify(['REQ', sid, { cache: [cmd, args] }])); } catch { finish(); } };
if (pws && pws.readyState === 1) return send();
if (!ensurePws()) return finish();
pws.addEventListener('open', send, { once: true });
});
}
+const primalSearch = (q, limit = MAX_RESULTS) =>
+ primalCache('user_search', { query: q, limit }).then((evs) => evs.filter((e) => e.kind === 0));
function ensurePws() {
if (pws && pws.readyState <= 1) return true;
@@ -75,7 +79,7 @@ function ensurePws() {
const m = JSON.parse(ev.data);
const sub = pwsSubs.get(m[1]);
if (!sub) return;
- if (m[0] === 'EVENT' && m[2] && m[2].kind === 0) sub.rows.push(m[2]);
+ if (m[0] === 'EVENT' && m[2]) sub.rows.push(m[2]);
if (m[0] === 'EOSE') sub.finish();
} catch {}
};
diff --git a/src/style.css b/src/style.css
index c47d22c..0edb692 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1338,3 +1338,13 @@ body::after {
.emoji-grid .cemoji { font-size: 8px; }
/* quoted replies and the composer's reply bar run small; their emoji follow */
.chat-quote-text .cemoji { height: 1.25em; vertical-align: -0.3em; }
+
+/* "116 Following · 572 Followers" on a profile; each opens its list below */
+.social-line { gap: 16px; align-items: center; }
+.social-count {
+ width: auto; margin: 0; padding: 2px 0; border: 0; background: none; color: var(--muted);
+ font-size: 14px; cursor: pointer; border-bottom: 2px solid transparent; border-radius: 0;
+}
+.social-count b { color: var(--ink); font-weight: 650; }
+.social-count:hover { background: none; color: var(--ink); }
+.social-count.on { color: var(--ink); border-bottom-color: var(--accent); }