aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-27 22:34:33 -0700
committerAdam Soltys <[email protected]>2026-09-27 22:34:33 -0700
commit3b3ccf7bcab9bf5344714fb8722667a49a06e53f (patch)
tree4dc6324a5e908a7b117fe98b439f7f4842a666eb /src
parentd7a3171968ba1a09f46cd15b04f2dae21a99aee2 (diff)
downloadcoinosv3-3b3ccf7bcab9bf5344714fb8722667a49a06e53f.tar.gz
coinosv3-3b3ccf7bcab9bf5344714fb8722667a49a06e53f.zip
The default public feed is Popular, not the firehose: what people reacted to, in your language, with the machine posts gone.
A brand-new post has no reactions yet (of 73 fresh English posts on the four relays, one had two), so Popular starts from the other end: the latest reactions, reposts and zaps, counted per post by distinct people, then the posts two or more people touched. Older pages walk the reactions back in time; new ones arrive by a two-minute poll. At the door, JSON and other machine output are dropped, and so is anything not in the reader's language (script first, then a vote of function words among the Latin-script languages the app speaks; unknown passes). The raw firehose stays at /feed/all. A language selector sits under every feed, and changing it rebuilds the curated ones. A tall video scrolled past used to let go of its media at once and collapse, shifting the page under a thumb; it now waits a moment and holds its height until the clip is back. A tap on a full-screen picture closes it. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VhUKyXKtsJ67QAFfb7QR9D
Diffstat (limited to 'src')
-rw-r--r--src/app.js5
-rw-r--r--src/features/messages.js108
-rw-r--r--src/i18n.js2
-rw-r--r--src/lang-guess.js94
4 files changed, 196 insertions, 13 deletions
diff --git a/src/app.js b/src/app.js
index 5d6eb40..d31c75c 100644
--- a/src/app.js
+++ b/src/app.js
@@ -557,7 +557,7 @@ function imageViewer() {
return h('div', {
class: 'lightbox', onClick: close,
role: 'dialog', 'aria-modal': 'true',
- }, h('img', { src: ui.lightbox, alt: '', onClick: (e) => e.stopPropagation() }),
+ }, h('img', { src: ui.lightbox, alt: '', onClick: (e) => { e.stopPropagation(); close(); } }), // a tap on the picture closes it too: the × is small and far
// stop the bubble: the backdrop closes too, and two pops walk out of the room
h('button', { class: 'lightbox-x', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); close(); } }, '\u00d7'));
}
@@ -2821,6 +2821,7 @@ function languagePicker() {
setLang(code);
await loadLocale(code); // fetch the locale's strings before re-rendering
applyDir();
+ featureHook('langChanged');
render();
},
},
@@ -5574,7 +5575,7 @@ const ctx = {
wallet.saveFeatureState('prefs', p);
try { wallet.saveCache(); } catch {}
},
- brandHeader, activeAccount, setAccounts: (list) => { accounts = list; },
+ brandHeader, languagePicker, activeAccount, setAccounts: (list) => { accounts = list; },
getAccounts: () => accounts,
claimTargets, enterWallet, activateAccount, commitAccount,
// cross-feature calls (e.g. gifts asking the ark feature for ark-gift
diff --git a/src/features/messages.js b/src/features/messages.js
index d6307cb..118f29a 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -38,7 +38,8 @@ import { decodeBolt11 } from '../ark/lightning.js';
import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
import { sha256 } from '@noble/hashes/sha256';
import { base64urlnopad } from '@scure/base';
-import { t } from '../i18n.js';
+import { t, getLang } from '../i18n.js';
+import { inLanguage, isMachinePost } from '../lang-guess.js';
import { animateZap, warmZapSound } from '../zap-animation.js';
import { SIGNER_SILENT } from '../dm.js';
@@ -3801,7 +3802,7 @@ export function messagesFeature(ctx) {
const br = st().builtinRelays || {};
return [
{ id: FOLLOWING, name: t('feedFollowingName'), follows: true, builtin: true, relays: br[FOLLOWING] || [] },
- { id: EVERYTHING, name: t('feedFirehose'), follows: false, authors: [], packs: [], topics: [], all: true, builtin: true, relays: br[EVERYTHING] || [] },
+ { id: EVERYTHING, name: t('feedPopular'), follows: false, authors: [], packs: [], topics: [], all: true, curated: true, builtin: true, relays: br[EVERYTHING] || [] },
...st().feeds];
};
function feedDef(id = curFeedId) {
@@ -3930,6 +3931,46 @@ export function messagesFeature(ctx) {
try { const url = new URL(x); if (!/^wss?:$/.test(url.protocol) || !url.hostname) return null; return url.href.replace(/\/$/, ''); } catch { return null; }
};
const feedRelays = (def) => (def && Array.isArray(def.relays) && def.relays.length ? def.relays : null);
+ // ---- curation for the public firehose --------------------------------
+ // Everything a relay carries is mostly noise: machine output, every
+ // language at once, posts nobody read. Popular keeps a post when it reads
+ // as prose in the reader's language and at least two people reacted,
+ // replied, reposted or zapped it — one batch ask per page, remembered.
+ // A brand-new post has no reactions yet (of 73 fresh English posts on the
+ // four relays, one had two), so Popular starts from the other end: the
+ // latest reactions, reposts and zaps, counted per post by distinct
+ // people, then the posts enough people touched. Older pages walk the
+ // reactions back in time. The prose and language tests run at the door.
+ const POPULAR_MIN = 2;
+ const POPULAR_ASK = 600; // reactions per round
+ const curateFeed = (evs) => { const lang = getLang(); return evs.filter((e) => !isMachinePost(e.content) && inLanguage(e.content, lang)); };
+ async function popularPass(extra, merge, c, def) {
+ const relays = feedRelaysOr(def, FIREHOSE_RELAYS);
+ const wide = [...new Set([...relays, ...NOTE_RELAYS])];
+ let until = extra.until != null && c.popUntil ? c.popUntil - 1 : undefined;
+ let got = false;
+ for (let round = 0; round < 3 && !c.stopped; round++) {
+ const re = await queryOn(wide, { kinds: [6, 7, 9735], limit: POPULAR_ASK, ...(until ? { until } : {}) }, 4500).catch(() => []);
+ if (!re.length) break;
+ const counts = new Map();
+ for (const r of re) {
+ const who = r.kind === 9735 ? ((r.tags.find((x) => x[0] === 'P') || [])[1] || r.pubkey) : r.pubkey;
+ for (const x of r.tags) if (x[0] === 'e' && /^[0-9a-f]{64}$/.test(x[1] || '')) (counts.get(x[1]) || counts.set(x[1], new Set()).get(x[1])).add(who);
+ }
+ const oldest = Math.min(...re.map((r) => r.created_at));
+ c.popUntil = c.popUntil && until ? Math.min(c.popUntil, oldest) : (c.popUntil || oldest);
+ until = oldest - 1;
+ const ids = [...counts.entries()].filter(([, s]) => s.size >= POPULAR_MIN).sort((a, b) => b[1].size - a[1].size).slice(0, 80).map(([id]) => id);
+ if (!ids.length) continue;
+ const asks = [];
+ for (let i = 0; i < ids.length; i += 40) asks.push(queryOn(wide, { ids: ids.slice(i, i + 40) }, 4500).catch(() => []));
+ const posts = (await Promise.all(asks)).flat().filter((e) => e && e.kind === 1 && (counts.get(e.id) || new Set()).size - (counts.get(e.id)?.has(e.pubkey) ? 1 : 0) >= POPULAR_MIN);
+ if (c.stopped) return got;
+ if (await mergeFeed(posts, merge, c)) { got = true; scheduleRepaint(); }
+ if (got && round >= 0 && (c.notes.length >= FEED_PAGE || extra.until != null)) break;
+ }
+ return got;
+ }
const feedRelaysOr = (def, usual) => feedRelays(def) || usual;
const feedCacheKey = (id) => (id === FOLLOWING ? FEED_CACHE : FEED_CACHE + ':' + id);
const feedDisk = () => typeof wallet.featureStateKey === 'function'
@@ -4203,6 +4244,12 @@ export function messagesFeature(ctx) {
const authors = feedAuthors(def).slice(0, REQ_AUTHORS), topics = feedTopics(def);
if (!authors.length && !topics.length && !(def && def.all)) { c.status = 'ready'; return; }
c.at = Date.now();
+ if (def && def.curated) {
+ try { await popularPass({}, {}, c, def); } catch {} finally {
+ if (!c.stopped) { c.status = 'ready'; if (c === feed && ui.chatOpen && ui.msgView === 'feed') { scheduleRepaint(); watchFeed(); } }
+ }
+ return;
+ }
try {
const events = await queryOn(feedRelaysOr(def, def && def.all ? FIREHOSE_RELAYS : NOTE_RELAYS), { kinds: [1], limit: FEED_PAGE,
...(authors.length ? { authors } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000);
@@ -4288,10 +4335,13 @@ export function messagesFeature(ctx) {
// Any mix of people, packs and topics, carried in the URL itself.
const urlRelays = (q) => [...new Set((q.get('r') || '').split(',').map(normRelay).filter(Boolean))].slice(0, 12);
// Plain /feed: every post the coinos relay carries (or the relays named).
- function openFirehose(readFrom = []) {
- if (!readFrom.length && !isVisitor()) { showFeed(EVERYTHING); return; }
- const id = 'all' + (readFrom.length ? '@' + readFrom.join(',') : ':visitor');
- if (!adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: t('feedFirehose'), follows: false, authors: [], packs: [], topics: [], all: true, relays: readFrom });
+ // /feed is Popular: everything the relays carry, curated (no machine
+ // posts, the reader's language, and only what someone reacted to).
+ // /feed/all is the same relays raw.
+ function openFirehose(readFrom = [], { raw = false } = {}) {
+ if (!raw && !readFrom.length && !isVisitor()) { showFeed(EVERYTHING); return; }
+ const id = (raw ? 'raw' : 'all') + (readFrom.length ? '@' + readFrom.join(',') : ':visitor');
+ if (!adhocFeeds.has(id)) adhocFeeds.set(id, { id, name: raw ? t('feedFirehose') : t('feedPopular'), follows: false, authors: [], packs: [], topics: [], all: true, curated: !raw, relays: readFrom });
showFeed(id);
}
function openQueryFeed(q) {
@@ -4312,6 +4362,7 @@ export function messagesFeature(ctx) {
const readFrom = urlRelays(q);
for (const r of readFrom) { try { resetRelay(r); } catch {} } // named in the link: tried even if it was sick
if (!path) return openQueryFeed(q);
+ if (/^all$/i.test(path)) { openFirehose(readFrom, { raw: true }); return true; }
if (/^t\//i.test(path)) { const tag = normTopic(decodeURIComponent(path.slice(2))); if (!tag) return false; openTopicFeed(tag, readFrom); return true; }
const pk = parseNostrPubkey(path);
if (pk) { let relays = []; try { relays = parseNostrRef(path.toLowerCase())?.relays || []; } catch {} openFeedOf(pk, relays, readFrom); return true; }
@@ -4325,7 +4376,7 @@ export function messagesFeature(ctx) {
function feedPath(def, { share = false } = {}) {
if (!def) return null;
const r = feedRelays(def) ? '?r=' + encodeURIComponent(feedRelays(def).join(',')) : '';
- if (def.all) return '/feed' + r;
+ if (def.all) return (def.curated ? '/feed' : '/feed/all') + r;
if (def.builtin) return share && mePk() ? '/feed/' + npubOf(mePk()) + r : null;
if (def.of) return '/feed/' + npubOf(def.of) + r;
const authors = def.authors || [], topics = feedTopics(def), packs = def.packs || [];
@@ -4619,9 +4670,16 @@ export function messagesFeature(ctx) {
if (!staged) feedStaged.set(c, staged = new Set());
const known = new Set([...c.notes, ...(c.catchup || []), ...(c.deferred || [])].map((e) => e.id));
for (const e of evs || []) noteForSpam(e);
- const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !hidden(e)
+ let add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !hidden(e)
&& !known.has(e.id) && !staged.has(e.id) && known.add(e.id) && staged.add(e.id));
if (!add.length) return false;
+ const def = feedDef(c.id);
+ if (def && def.curated) {
+ const kept = curateFeed(add);
+ for (const e of add) if (!kept.includes(e)) staged.delete(e.id);
+ add = kept;
+ if (!add.length) return false;
+ }
if (c.booting) await c.boot;
if (c.stopped) return false;
await notesReady(add);
@@ -4698,11 +4756,13 @@ export function messagesFeature(ctx) {
const authors = feedAuthors(def), topics = feedTopics(def);
const tag = topics.length ? { '#t': topics } : {};
let got = false;
+ if (def.curated) return popularPass(extra, merge, c, def);
if (!authors.length) {
// topics alone: nobody's outbox to read, so the wide relays, one ask;
// the firehose asks its relays for everything
if (!topics.length && !def.all) return false;
- const evs = await queryOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: [1], ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
+ const relays = feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS);
+ const evs = await queryOn(relays, { kinds: [1], ...tag, limit: FEED_LIMIT, ...extra }, 5000).catch(() => []);
if (await mergeFeed(evs, merge, c)) { got = true; scheduleRepaint(); }
return got;
}
@@ -4883,6 +4943,7 @@ export function messagesFeature(ctx) {
const topics = feedTopics(def), tag = topics.length ? { '#t': topics } : {};
const on = (ev) => { mergeFeed([ev], { live: true }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); };
const authors = feedAuthors(def);
+ if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; }
if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: [1], ...tag, since }, on)); return; }
const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors);
for (const { relays, authors: a } of plan)
@@ -5021,12 +5082,28 @@ export function messagesFeature(ctx) {
// from the start. A clip the reader unmuted keeps its place instead.
watchPlayer(v,
() => {
- if (!v.getAttribute('src') && !v.getAttribute('data-lazy-src')) v.setAttribute('src', url);
+ box._inView = true;
+ clearTimeout(box._release);
+ if (!v.getAttribute('src') && !v.getAttribute('data-lazy-src')) {
+ v.setAttribute('src', url);
+ v.addEventListener('loadedmetadata', () => { v.style.minHeight = ''; }, { once: true });
+ }
if (v.paused) v.play().then(() => { box._skipMorph = true; }).catch(() => {});
},
() => {
+ box._inView = false;
if (!v.paused) v.pause();
- if (v.muted && box._skipMorph) { try { v.removeAttribute('src'); v.load(); } catch {} }
+ if (!(v.muted && box._skipMorph)) return;
+ // Let go a moment later, and hold the box at its height meanwhile:
+ // an instant release collapsed a tall clip the reader had just
+ // scrolled past, and the page jumped under their thumb.
+ clearTimeout(box._release);
+ box._release = setTimeout(() => {
+ if (box._inView || !v.isConnected) return;
+ const hgt = v.getBoundingClientRect().height;
+ if (hgt > 0) v.style.minHeight = hgt + 'px';
+ try { v.removeAttribute('src'); v.load(); } catch {}
+ }, 2500);
});
return box;
}
@@ -7420,6 +7497,9 @@ export function messagesFeature(ctx) {
: c.end && visible.length && !(c.win && c.win.bottom) && c.shown >= visible.length
? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd'))
: null,
+ // the reader's language, right where a curated feed is judged by it
+ ctx.languagePicker ? h('div', { class: 'row gap6 feed-lang', 'data-key': 'feed-lang', style: 'justify-content:center;align-items:center;padding:6px 0 2px' },
+ h('span', { class: 'small muted' }, t('feedLangLabel')), ctx.languagePicker()) : null,
...noteOverlays());
}
@@ -8912,6 +8992,12 @@ export function messagesFeature(ctx) {
identitySignedInNew() { setTimeout(() => seedNewIdentity({ onlyIfNoFollows: true }), 0); return true; },
openProfile(pk) { openProfile(pk); return true; },
openFeedOf(pk) { openFeedOf(pk); return true; },
+ // a new language: the curated feeds were judged in the old one
+ langChanged() {
+ for (const c of [...feedStates.values()]) { const d = feedDef(c.id); if (d && d.curated) dropFeedState(c.id); }
+ if (feedDef(curFeedId)?.curated) selectFeed(curFeedId);
+ return true;
+ },
navPath(snap) {
if (!snap || !snap.chatOpen || snap.msgView !== 'feed' || snap.profilePk || snap.noteThread || snap.userSearch) return null;
const def = feedDef(snap.feedId || curFeedId);
diff --git a/src/i18n.js b/src/i18n.js
index 51811e6..fead210 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -251,6 +251,8 @@ const STR = {
feedPackName: 'Follow pack',
profTheirFeed: 'Their timeline',
feedFirehose: 'Everything',
+ feedPopular: 'Popular',
+ feedLangLabel: 'Language',
feedRelays: 'Relays',
feedRelaysHint: 'Posts are read from these relays. What the feed asks for stays the same.',
feedRelayUsual: 'usual',
diff --git a/src/lang-guess.js b/src/lang-guess.js
new file mode 100644
index 0000000..f688cac
--- /dev/null
+++ b/src/lang-guess.js
@@ -0,0 +1,94 @@
+// A rough guess at the language a post is written in, cheap enough to run
+// on every post of a public feed. Script first (a post in Han or Arabic
+// letters is not English), then, among the Latin-script languages the app
+// speaks, a handful of function words each. Short or ambiguous text is
+// "unknown", and unknown is never held against a post.
+const SCRIPTS = [
+ ['latin', /[A-Za-zÀ-ɏḀ-ỿ]/g],
+ ['cyrillic', /[Ѐ-ӿ]/g],
+ ['arabic', /[؀-ۿݐ-ݿ]/g],
+ ['devanagari', /[ऀ-ॿ]/g],
+ ['bengali', /[ঀ-৿]/g],
+ ['tamil', /[஀-௿]/g],
+ ['telugu', /[ఀ-౿]/g],
+ ['kana', /[぀-ヿ]/g],
+ ['han', /[一-鿿㐀-䶿]/g],
+ ['hangul', /[가-힯ᄀ-ᇿ]/g],
+];
+const LANG_SCRIPT = {
+ en: 'latin', es: 'latin', fr: 'latin', de: 'latin', pt: 'latin', id: 'latin', sw: 'latin', tr: 'latin', vi: 'latin',
+ ru: 'cyrillic', ar: 'arabic', ur: 'arabic', hi: 'devanagari', mr: 'devanagari', bn: 'bengali', ta: 'tamil', te: 'telugu',
+ ja: 'kana', ko: 'hangul', zh: 'han',
+};
+const STOP = {
+ en: 'the and is to of in that it for you with this on are be have not was but they'.split(' '),
+ es: 'el la de que y en los es un una por con para las del se no como más pero'.split(' '),
+ fr: 'le la les de des et est un une pour que qui dans pas sur avec ce il nous vous'.split(' '),
+ de: 'der die und das ist nicht ein eine ich zu mit den auf für sich dem des auch wir'.split(' '),
+ pt: 'o a de que e do da em um uma para com não os as se mais por isso você'.split(' '),
+ id: 'yang dan di ini itu dengan untuk tidak dari ada saya akan ke juga bisa kita sudah atau kalau aku'.split(' '),
+ tr: 've bir bu için ile de da çok ne gibi daha ama var ben sen mi değil kadar her olan'.split(' '),
+ vi: 'và của là có không được một trong cho những này với các để người đã tôi khi cũng như'.split(' '),
+ sw: 'na ya wa kwa ni za la katika hii kuwa kama hata lakini sana yake wote sasa tu huo hapa'.split(' '),
+};
+const STOPSETS = Object.fromEntries(Object.entries(STOP).map(([k, v]) => [k, new Set(v)]));
+
+// The text a reader actually reads: no links, no nostr references, no tags.
+export const proseOf = (text) => String(text || '')
+ .replace(/https?:\/\/\S+/g, ' ').replace(/nostr:[a-z0-9]+/gi, ' ').replace(/#[\p{L}\p{N}_]+/gu, ' ');
+
+export function scriptOf(text) {
+ let best = null, bestN = 0, total = 0;
+ for (const [name, re] of SCRIPTS) {
+ const n = (text.match(re) || []).length;
+ total += n;
+ if (n > bestN) { best = name; bestN = n; }
+ }
+ return total < 3 ? null : best;
+}
+
+// A language code from the app's list, or null when it cannot tell.
+export function guessLang(text) {
+ const prose = proseOf(text);
+ const script = scriptOf(prose);
+ if (!script) return null;
+ if (script === 'kana') return 'ja';
+ if (script === 'han') return 'zh';
+ if (script !== 'latin') {
+ const langs = Object.entries(LANG_SCRIPT).filter(([, s]) => s === script).map(([l]) => l);
+ return langs.length === 1 ? langs[0] : null; // ar/ur, hi/mr share a script: unknown between them
+ }
+ const words = prose.toLowerCase().match(/[\p{L}']+/gu) || [];
+ if (words.length < 4) return null;
+ const score = {};
+ for (const w of words) for (const [l, set] of Object.entries(STOPSETS)) if (set.has(w)) score[l] = (score[l] || 0) + 1;
+ const ranked = Object.entries(score).sort((a, b) => b[1] - a[1]);
+ if (!ranked.length || ranked[0][1] < 2) return null;
+ if (ranked[1] && ranked[0][1] < ranked[1][1] * 1.5) return null;
+ return ranked[0][0];
+}
+
+// Does a post read in this language, as far as we can tell? Unknown passes;
+// a wrong script or a clear other language fails.
+export function inLanguage(text, lang) {
+ const want = LANG_SCRIPT[lang];
+ if (!want) return true;
+ const prose = proseOf(text);
+ const script = scriptOf(prose);
+ if (!script) return true; // nothing readable to judge (a picture post, an emoji)
+ if (script !== want) return false;
+ if (want === 'han' && lang === 'zh' && /[぀-ヿ]/.test(prose)) return false; // kana means Japanese
+ const g = guessLang(prose);
+ return g === null || g === lang;
+}
+
+// Machine output dressed as a post: JSON, a hash dump, a wall of symbols.
+export function isMachinePost(text) {
+ const s = String(text || '').trim();
+ if (!s) return false;
+ if (/^[\[{]/.test(s)) { try { JSON.parse(s); return true; } catch {} }
+ const prose = proseOf(s).replace(/\s+/g, '');
+ if (prose.length < 24) return false;
+ const letters = (prose.match(/\p{L}/gu) || []).length;
+ return letters / prose.length < 0.4;
+}