From 5d82c98970e3fa7ac4153294e0e27a5cb1a7f480 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 5 Oct 2026 00:41:16 -0700 Subject: Custom feeds sync across devices: their own sync slot, newest edit wins, deletions remembered Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YRC6aKhwAud231ot9FPmhi --- src/features/messages.js | 50 +++++++++++++++++++++++++++++++++++++++++++----- src/feed-sync.js | 23 ++++++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 src/feed-sync.js (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index e0c5b4c..c32c1ce 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -52,6 +52,7 @@ import { qrSvg } from '../qr.js'; import { COMMUNITY, EPOCH } from '../community.js'; import COMMUNITY_DIRECTORY from '../community-directory.json'; import { assetUrl } from '../asset-url.js'; +import { mergeFeeds } from '../feed-sync.js'; const DM_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol']; const NOTIFIER = 'https://nwcpush.coinos.io'; const APP_BASE = 'https://v3.coinos.io'; @@ -397,6 +398,38 @@ export function messagesFeature(ctx) { }); } + // Custom feeds sync across devices too (mergeFeeds: newest edit wins, + // deletions remembered). + if (wallet.registerCacheExtension && !wallet._feedsSync) { + wallet._feedsSync = true; + wallet.registerCacheExtension({ + domain: 'feeds', + mergeAlways: true, + save: () => { + const m = wallet.loadFeatureState('messages', {}); + const feeds = m.feeds || [], gone = m.feedsGone || {}; + return feeds.length || Object.keys(gone).length ? { feeds, feedsGone: gone } : {}; + }, + load: (d) => { + if (!Array.isArray(d.feeds) && !d.feedsGone) return; + const m = wallet.loadFeatureState('messages', {}); + const got = mergeFeeds({ feeds: m.feeds, gone: m.feedsGone }, { feeds: d.feeds, gone: d.feedsGone }); + if (!got) return; + m.feeds = got.feeds; m.feedsGone = got.gone; + wallet.saveFeatureState('messages', m); + stCache = null; + try { for (const id of got.touched) feedStates.delete(id); } catch {} // its query may have changed + scheduleRepaint(); + }, + }); + } + // A feed added, edited or deleted here goes out on the next sync. + let feedsPush = null; + const feedsChanged = () => { + clearTimeout(feedsPush); + feedsPush = setTimeout(() => { try { wallet.saveCache(); } catch {} }, 1000); + }; + // Per-conversation dots for the list screen: newer than when we last had // that thread (or any channel of that room) open. const dmUnread = (pk, msgs) => @@ -4198,7 +4231,7 @@ export function messagesFeature(ctx) { STARTER_BITCOIN_PACK ? { id: 'starter-bitcoin', name: 'Bitcoin', follows: false, authors: [], packs: [STARTER_BITCOIN_PACK], topics: [], at } : null, { id: 'starter-gardenstr', name: '#gardenstr', follows: false, authors: [], packs: [], topics: ['gardenstr'], at }, ].filter(Boolean); - for (const f of feeds) if (!s.feeds.some((x) => x.id === f.id)) s.feeds.push(f); + for (const f of feeds) if (!s.feeds.some((x) => x.id === f.id) && !(s.feedsGone || {})[f.id]) s.feeds.push(f); save(s); if (onlyIfNoFollows) { // "New" here means new to coinos, not new to nostr: anyone with a @@ -4727,7 +4760,8 @@ export function messagesFeature(ctx) { for (const [d, ev] of newest) { const gone = (s.listsGone || {})[d]; if (gone && ev.created_at <= gone) continue; // deleted here; a relay still serving it - const cur = s.feeds.find((f) => f.d === d); + // a feed synced from another device before its list link did is that list + const cur = s.feeds.find((f) => f.d === d) || s.feeds.find((f) => !f.d && f.id === d); if (cur && (cur.listAt || 0) >= ev.created_at) continue; const got = packFromEvent(ev); const priv = await privateMembers(ev); @@ -4740,7 +4774,7 @@ export function messagesFeature(ctx) { feedStates.delete(cur ? cur.id : 'list:' + d); // its query changed changed = true; } - if (changed) { save(s); scheduleRepaint(); } + if (changed) { save(s); feedsChanged(); scheduleRepaint(); } })().finally(() => { listsJob = null; }); return listsJob; } @@ -4759,7 +4793,7 @@ export function messagesFeature(ctx) { if (!ok) throw new Error(t('msgSendFailed')); const s = st(); const cur = s.feeds.find((f) => f.id === def.id); - if (cur) { cur.d = d; cur.listAt = created_at; save(s); } + if (cur) { cur.d = d; cur.listAt = created_at; cur.at = Date.now(); save(s); feedsChanged(); } return d; } // NIP-09: ask the relays to drop the list — and remember that we did, so @@ -4832,6 +4866,7 @@ export function messagesFeature(ctx) { def.authors = had ? def.authors.filter((x) => x !== pk) : [...(def.authors || []), pk].slice(0, FEED_AUTHORS_MAX); def.at = Date.now(); save(s); + feedsChanged(); feedStates.delete(def.id); render(); if (def.d) publishFollowSet(def).catch((e) => { if (!(e instanceof NoIdentity)) toast(e.message || String(e)); }); @@ -5275,7 +5310,7 @@ export function messagesFeature(ctx) { for (const r of relays) { try { resetRelay(r); } catch {} } // chosen by hand: tried again even if it was sick if (def.builtin) { const s = st(); s.builtinRelays = { ...(s.builtinRelays || {}), [def.id]: relays }; save(s); } else if (adhocFeeds.has(def.id)) def.relays = relays; - else { const s = st(); s.feeds = s.feeds.map((f) => (f.id === def.id ? { ...f, relays } : f)); save(s); } + else { const s = st(); s.feeds = s.feeds.map((f) => (f.id === def.id ? { ...f, relays, at: Date.now() } : f)); save(s); feedsChanged(); } dropFeedState(def.id); try { wallet.saveFeatureState(feedCacheKey(def.id), []); } catch {} if (curFeedId === def.id) selectFeed(def.id); @@ -10492,6 +10527,7 @@ export function messagesFeature(ctx) { // d, listAt, listContent and priv ride along from the previous copy if (keep) s.feeds = s.feeds.map((f) => (f.id === id ? { ...f, ...def } : f)); else s.feeds.push(def); save(s); + feedsChanged(); if (e.id && adhocFeeds.has(e.id)) adhocFeeds.delete(e.id); feedStates.delete(id); // its query changed: rebuild from scratch ui.feedEdit = null; @@ -10518,6 +10554,7 @@ export function messagesFeature(ctx) { s.feeds.push({ id, name: '', follows: false, authors: [], packs: [], topics: [], of: adhoc.of, ...(adhoc.relays && adhoc.relays.length ? { relays: adhoc.relays } : {}), at: Date.now() }); save(s); + feedsChanged(); } adhocFeeds.delete(adhoc.id); feedStates.delete(adhoc.id); @@ -10528,7 +10565,10 @@ export function messagesFeature(ctx) { const s = st(); const def = s.feeds.find((f) => f.id === id); s.feeds = s.feeds.filter((f) => f.id !== id); + const gone = Object.entries(s.feedsGone || {}).sort((a, b) => b[1] - a[1]).slice(0, 199); + s.feedsGone = Object.fromEntries([[id, Date.now()], ...gone]); save(s); + feedsChanged(); if (def && def.d) deleteFollowSet(def).catch(() => {}); // after ours: it re-reads the state feedStates.delete(id); try { wallet.saveFeatureState(feedCacheKey(id), []); } catch {} diff --git a/src/feed-sync.js b/src/feed-sync.js new file mode 100644 index 0000000..55d5845 --- /dev/null +++ b/src/feed-sync.js @@ -0,0 +1,23 @@ +// Custom feeds across devices: per feed, the newest edit (`at`) wins, and a +// deletion is remembered (gone: { id: ms }) so a device still holding the +// feed doesn't hand it back. Commutative, so snapshots apply in any order. +// Returns null when the remote copy changes nothing. +export function mergeFeeds(local, remote) { + const gone = { ...(local.gone || {}) }; + let changed = false; + for (const [id, t] of Object.entries(remote.gone || {})) + if (typeof t === 'number' && (gone[id] || 0) < t) { gone[id] = t; changed = true; } + const feeds = [...(local.feeds || [])]; + const touched = []; + for (const f of Array.isArray(remote.feeds) ? remote.feeds : []) { + if (!f || typeof f.id !== 'string' || (gone[f.id] || 0) >= (f.at || 0)) continue; + const i = feeds.findIndex((x) => x.id === f.id); + if (i < 0) feeds.push(f); + else if ((f.at || 0) > (feeds[i].at || 0)) feeds[i] = f; + else continue; + touched.push(f.id); changed = true; + } + const kept = feeds.filter((f) => (gone[f.id] || 0) < (f.at || 0)); + if (kept.length !== feeds.length) changed = true; + return changed ? { feeds: kept, gone, touched } : null; +} -- cgit v1.2.3