// Bookmarks — private, node-local, per viewer.
//
// ⛔ NOT A RECORD, AND NOT FOR THE SAME REASON AS A MUTE. A mute is private because atproto defines it
// that way. A bookmark is private because there is no interoperable lexicon for one that we should be
// writing: inventing `net.favela.bookmark` in a user's public repo would publish their reading list to
// the network forever, in signed records, to solve a problem that is entirely local. If bsky's own
// bookmark lexicon stabilises this can be revisited — as a DELIBERATE choice to publish, not as a
// side-effect of how it was stored.
//
// So: same home as mutes (the appview's own store, a rebuildable cache that is never replicated), same
// per-viewer keying, and a remove is a real delete that leaves nothing behind.
//
// Keyed `<viewerDid>\x00<at-uri>`. The value carries `at` so the list can be shown newest-first — a
// bookmark list ordered by the POST's date would bury something you saved today under an old post you
// just found, which is the opposite of what the feature is for.
const SEP = '\x00'
export function createBookmarkStore (bee) {
if (!bee) throw new Error('createBookmarkStore requires a Hyperbee (sub)')
const key = (viewer, uri) => `${viewer}${SEP}${uri}`
return {
async add (viewer, uri) {
if (!viewer || typeof uri !== 'string' || !uri.startsWith('at://')) return false
await bee.put(key(viewer, uri), { at: Date.now() })
return true
},
async remove (viewer, uri) {
if (!viewer || !uri) return false
try { await bee.del(key(viewer, uri)) } catch { return false }
return true
},
async has (viewer, uri) {
if (!viewer || !uri) return false
try { const r = await bee.get(key(viewer, uri)); return !!(r && r.value) } catch { return false }
},
// Every bookmarked uri for this viewer as a Set — one scan per page render, so the read path does not
// do a lookup per post.
async uris (viewer) {
const out = new Set()
if (!viewer) return out
const p = viewer + SEP
try {
for await (const e of bee.createReadStream({ gte: p, lt: p + '' })) {
const uri = String(e.key).slice(p.length)
if (uri) out.add(uri)
}
} catch { /* an unreadable index must not blank the page */ }
return out
},
// Newest-SAVED first — see the header note on why this is not the post's own date.
async list (viewer, { limit = 50 } = {}) {
const rows = []
if (!viewer) return rows
const p = viewer + SEP
try {
for await (const e of bee.createReadStream({ gte: p, lt: p + '' })) {
rows.push({ uri: String(e.key).slice(p.length), at: (e.value && e.value.at) || 0 })
}
} catch { /* same */ }
rows.sort((a, b) => b.at - a.at)
return rows.slice(0, limit)
}
}
}