diff options
| -rw-r--r-- | src/features/messages.js | 121 | ||||
| -rw-r--r-- | src/i18n.js | 6 | ||||
| -rw-r--r-- | src/locales/ar.json | 9 | ||||
| -rw-r--r-- | src/locales/bn.json | 9 | ||||
| -rw-r--r-- | src/locales/de.json | 9 | ||||
| -rw-r--r-- | src/locales/es.json | 9 | ||||
| -rw-r--r-- | src/locales/fr.json | 9 | ||||
| -rw-r--r-- | src/locales/hi.json | 9 | ||||
| -rw-r--r-- | src/locales/id.json | 9 | ||||
| -rw-r--r-- | src/locales/ja.json | 9 | ||||
| -rw-r--r-- | src/locales/ko.json | 9 | ||||
| -rw-r--r-- | src/locales/mr.json | 9 | ||||
| -rw-r--r-- | src/locales/pt.json | 9 | ||||
| -rw-r--r-- | src/locales/ru.json | 9 | ||||
| -rw-r--r-- | src/locales/sw.json | 9 | ||||
| -rw-r--r-- | src/locales/ta.json | 9 | ||||
| -rw-r--r-- | src/locales/te.json | 9 | ||||
| -rw-r--r-- | src/locales/tr.json | 9 | ||||
| -rw-r--r-- | src/locales/ur.json | 9 | ||||
| -rw-r--r-- | src/locales/vi.json | 9 | ||||
| -rw-r--r-- | src/locales/zh.json | 9 | ||||
| -rw-r--r-- | src/recipient-search.js | 12 | ||||
| -rw-r--r-- | src/style.css | 10 |
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); } |
