diff --git a/causestarter/README.md b/causestarter/README.md index 68e6eb78..9d85da6c 100644 --- a/causestarter/README.md +++ b/causestarter/README.md @@ -283,6 +283,7 @@ Then **restart Grok** so MCP tools load. | `cause-view-strip` | Union / conjunction counts over selected statements | | `plank-in-totals-N` | Include/exclude the Nth statement from those totals (view only) | | `view-count-any` / `view-count-all` / `view-count-none-disagreed` | The counts themselves | +| `cause-keep-on-device` / `cause-remove-from-device` | Bookmark / remove a published cause you do not organize | On **localhost**, Connect only lists Hardhat accounts (no MetaMask). Use **Hardhat #0** for funded local txs. diff --git a/causestarter/TODO.md b/causestarter/TODO.md index 0a2e1b4b..336a8cde 100644 --- a/causestarter/TODO.md +++ b/causestarter/TODO.md @@ -21,7 +21,7 @@ open **if they stay listed here**. - [ ] Safety filter is MVP/heuristic + LLM policy text — not legal-grade; version/align with operator/legal specs later. - [ ] Unpublished draft state still in `localStorage` only — multi-device recovery of *drafts* later (published rosters are on chain; published *bookmarks* follow the wallet `bookmarked-causes` ref). -- [ ] Cause bookmarks: Playwright for keep/remove + reconnect hydrating from `bookmarked-causes`. Union-sync can resurrect a bookmark removed on another device (no tombstones). Hydrated list cards may show plank CIDs as text until the cause page is opened. Bookmarking is a public wallet `updateRef` with only the list-page disclaimer. +- [ ] Cause bookmarks: `bookmarked-causes` is a public wallet `updateRef`; the only user-facing warning is the Causes list-page disclaimer. Keep/remove, reconnect hydrate, and tombstoned union-sync (a later keep can restore) are in place; Playwright covers keep/remove + reconnect. - [ ] Statement `bookmarks` ref is still reserved infrastructure only — no CauseStarter (or main `ui`) surface for remembering a statement without signing it. - [ ] No Privy path / full parity with main `ui` wallet story yet. - [ ] Product: how CauseStarter ranks vs other domains in nav/marketing once it’s “the main thing.” diff --git a/causestarter/e2e/bookmarks.spec.ts b/causestarter/e2e/bookmarks.spec.ts new file mode 100644 index 00000000..b73f7b52 --- /dev/null +++ b/causestarter/e2e/bookmarks.spec.ts @@ -0,0 +1,138 @@ +import { expect, test, type Page } from '@playwright/test' + +function appPath(path: string): string { + const hashMode = process.env.CAUSESTARTER_HASH_ROUTING !== '0' + if (!hashMode) return path + const normalized = path.startsWith('/') ? path : `/${path}` + return `/#${normalized === '/' ? '/' : normalized}` +} + +async function connectHardhat(page: Page, account: number) { + const shellWallet = page.getByRole('banner').getByTestId('wallet-connect-button') + await shellWallet.click() + await expect(page.getByTestId('wallet-account-menu')).toBeVisible() + await page.getByTestId(`wallet-hardhat-${account}`).click() + await expect(shellWallet).toContainText(`Hardhat #${account}`, { + timeout: 15_000, + }) +} + +async function startCause(page: Page) { + await page.getByTestId('nav-causes').click() + await page.getByTestId('causes-start-cause').click() + await expect(page.getByTestId('cause-detail-page')).toBeVisible({ timeout: 10_000 }) +} + +async function clearBrowserStorage(page: Page) { + await page.evaluate(() => { + try { + localStorage.clear() + sessionStorage.clear() + } catch { + // ignore + } + }) +} + +function documentHasSlug(value: string, slug: string, present: boolean): boolean { + try { + const parsed = JSON.parse(value) as { causes?: Array<{ slug?: string }>; removed?: Array<{ slug?: string }> } + const causes = parsed.causes?.some((row) => row.slug === slug) ?? false + const removed = parsed.removed?.some((row) => row.slug === slug) ?? false + return present ? causes && !removed : removed && !causes + } catch { + return false + } +} + +async function waitForWalletBookmarkWrite(page: Page, slug: string, present: boolean) { + const indexerUrl = process.env.INDEXER_URL ?? 'http://localhost:42069' + const deadline = Date.now() + 60_000 + while (Date.now() < deadline) { + const res = await page.request.post(`${indexerUrl}/graphql`, { + data: { + query: `{ mutableRefss(where: { name: "bookmarked-causes" }, limit: 20) { items { value } } }`, + }, + }).catch(() => null) + const body = res && res.ok() + ? await res.json() as { data?: { mutableRefss?: { items?: Array<{ value: string }> } } } + : null + const items = body?.data?.mutableRefss?.items ?? [] + if (items.some((item) => documentHasSlug(item.value, slug, present))) return + await page.waitForTimeout(1_000) + } + // Indexer GraphQL may lag or be down; the reconnect assertions still check the outcome. + await page.waitForTimeout(15_000) +} + +test.describe('Cause bookmarks', () => { + test.beforeEach(async ({ page }) => { + await page.goto(appPath('/')) + await clearBrowserStorage(page) + await page.goto(appPath('/')) + }) + + test('keeps and removes a published cause, and hydrates after reconnect', async ({ page }) => { + test.setTimeout(240_000) + await connectHardhat(page, 0) + await startCause(page) + + await page.getByTestId('cause-add-plank').click() + await page.getByTestId('plank-text-0').fill( + 'Every Oak Street block has working streetlights by June.', + ) + await page.getByTestId('plank-publish-0').click() + await expect(page.getByTestId('plank-row-published')).toHaveCount(1, { timeout: 60_000 }) + + const title = `Bookmarked Oak Street ${Date.now()}` + const slug = `bookmarked-oak-${Date.now()}` + await page.getByTestId('roster-title').fill(title) + await page.getByTestId('roster-summary').fill('Neighbors organizing for working streetlights.') + await page.getByTestId('roster-slug').fill(slug) + await page.getByTestId('roster-publish-anyway').click() + await expect(page).toHaveURL(new RegExp(`/cause/0x[0-9a-f]{40}/${slug}$`), { + timeout: 60_000, + }) + const stableUrl = page.url() + + await clearBrowserStorage(page) + await page.goto(stableUrl) + await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible({ + timeout: 30_000, + }) + await connectHardhat(page, 1) + + await page.getByTestId('cause-keep-on-device').click() + await expect(page.getByTestId('cause-remove-from-device')).toBeVisible({ timeout: 10_000 }) + await waitForWalletBookmarkWrite(page, slug, true) + + await page.getByTestId('nav-causes').click() + await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible({ + timeout: 30_000, + }) + + await clearBrowserStorage(page) + await page.goto(appPath('/causes')) + await connectHardhat(page, 1) + await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible({ + timeout: 45_000, + }) + + await page.getByRole('heading', { name: title, exact: true }).click() + await expect(page.getByTestId('cause-remove-from-device')).toBeVisible({ timeout: 30_000 }) + await page.getByTestId('cause-remove-from-device').click() + await expect(page.getByTestId('cause-keep-on-device')).toBeVisible() + await waitForWalletBookmarkWrite(page, slug, false) + + await page.getByTestId('nav-causes').click() + await expect(page.getByRole('heading', { name: title, exact: true })).toHaveCount(0) + + await clearBrowserStorage(page) + await page.goto(appPath('/causes')) + await connectHardhat(page, 1) + await expect(page.getByRole('heading', { name: 'Causes', exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: title, exact: true })).toHaveCount(0, { + timeout: 45_000, + }) + }) +}) diff --git a/causestarter/src/lib/causeBookmarks.test.ts b/causestarter/src/lib/causeBookmarks.test.ts index 0c589f37..648f0a0f 100644 --- a/causestarter/src/lib/causeBookmarks.test.ts +++ b/causestarter/src/lib/causeBookmarks.test.ts @@ -1,12 +1,22 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { + mergeBookmarkDocuments, mergeBookmarkIds, + parseCauseBookmarkDocument, parseCauseBookmarkList, + rememberBookmarkKept, + rememberBookmarkRemoved, + sameBookmarkDocument, sameBookmarkList, + serializeCauseBookmarkDocument, serializeCauseBookmarkList, } from './causeBookmarks' describe('causeBookmarks', () => { + afterEach(() => { + localStorage.clear() + }) + it('round-trips published cause identities and ignores statement-shaped lists', () => { const ids = [ { owner: '0xAbC0000000000000000000000000000000000001', slug: 'safer-nights' }, @@ -16,6 +26,8 @@ describe('causeBookmarks', () => { const encoded = serializeCauseBookmarkList(ids) expect(encoded).not.toContain('bafy') expect(JSON.parse(encoded).causes).toHaveLength(2) + expect(JSON.parse(encoded).removed).toEqual([]) + expect(JSON.parse(encoded).version).toBe(2) const parsed = parseCauseBookmarkList(encoded) expect(parsed).toEqual([ @@ -27,6 +39,18 @@ describe('causeBookmarks', () => { expect(parseCauseBookmarkList(null)).toBeNull() }) + it('reads version-1 wallet documents as empty tombstone lists', () => { + const v1 = JSON.stringify({ + version: 1, + causes: [{ owner: '0xabc0000000000000000000000000000000000001', slug: 'safer-nights' }], + }) + expect(parseCauseBookmarkDocument(v1)).toEqual({ + version: 1, + causes: [{ owner: '0xabc0000000000000000000000000000000000001', slug: 'safer-nights' }], + removed: [], + }) + }) + it('unions lists without mixing keys', () => { const a = [{ owner: '0xabc0000000000000000000000000000000000001', slug: 'one' }] const b = [{ owner: '0xABC0000000000000000000000000000000000001', slug: 'one' }, { owner: '0x0000000000000000000000000000000000000002', slug: 'two' }] @@ -36,5 +60,102 @@ describe('causeBookmarks', () => { ]) expect(sameBookmarkList(a, b)).toBe(false) expect(sameBookmarkList(mergeBookmarkIds(a, b), b)).toBe(true) + expect(sameBookmarkList( + [{ owner: '0xabc0000000000000000000000000000000000001', slug: 'one', updatedAt: '2026-01-01T00:00:00.000Z' }], + [{ owner: '0xabc0000000000000000000000000000000000001', slug: 'one', updatedAt: '2026-02-01T00:00:00.000Z' }], + )).toBe(false) + }) + + it('lets a later tombstone beat a stale keep, and a later keep restore it', () => { + const owner = '0xabc0000000000000000000000000000000000001' + const earlier = mergeBookmarkDocuments( + { + version: 1, + causes: [{ owner, slug: 'safer-nights', updatedAt: '2026-01-01T00:00:00.000Z' }], + removed: [], + }, + { + version: 2, + causes: [], + removed: [{ owner, slug: 'safer-nights', updatedAt: '2026-02-01T00:00:00.000Z' }], + }, + ) + expect(earlier.causes).toEqual([]) + expect(earlier.removed).toEqual([ + { owner, slug: 'safer-nights', updatedAt: '2026-02-01T00:00:00.000Z' }, + ]) + + const restored = mergeBookmarkDocuments(earlier, { + version: 2, + causes: [{ owner, slug: 'safer-nights', updatedAt: '2026-03-01T00:00:00.000Z' }], + removed: [], + }) + expect(restored.causes).toEqual([ + { owner, slug: 'safer-nights', updatedAt: '2026-03-01T00:00:00.000Z' }, + ]) + expect(restored.removed).toEqual([]) + expect(sameBookmarkDocument(earlier, restored)).toBe(false) + }) + + it('prefers a tombstone when keep and remove share a stamp', () => { + const owner = '0xabc0000000000000000000000000000000000001' + const at = '2026-04-01T00:00:00.000Z' + const merged = mergeBookmarkDocuments( + { version: 2, causes: [{ owner, slug: 'safer-nights', updatedAt: at }], removed: [] }, + { version: 2, causes: [], removed: [{ owner, slug: 'safer-nights', updatedAt: at }] }, + ) + expect(merged.causes).toEqual([]) + expect(merged.removed[0]?.slug).toBe('safer-nights') + }) + + it('tracks local tombstones across keep and remove', () => { + const id = { owner: '0xabc0000000000000000000000000000000000001', slug: 'safer-nights' } + rememberBookmarkRemoved(id, '2026-05-01T00:00:00.000Z') + expect(parseCauseBookmarkDocument(serializeCauseBookmarkDocument({ + version: 2, + causes: [], + removed: [{ ...id, updatedAt: '2026-05-01T00:00:00.000Z' }], + }))?.removed).toHaveLength(1) + rememberBookmarkKept(id) + const encoded = serializeCauseBookmarkList([id]) + expect(JSON.parse(encoded).removed).toEqual([]) + }) + + it('does not let a later cause-draft clock beat a tombstone', () => { + const owner = '0xabc0000000000000000000000000000000000001' + const merged = mergeBookmarkDocuments( + { + version: 2, + causes: [], + removed: [{ owner, slug: 'safer-nights', updatedAt: '2026-02-01T00:00:00.000Z' }], + }, + { + version: 2, + causes: [{ owner, slug: 'safer-nights' }], + removed: [], + }, + ) + expect(merged.causes).toEqual([]) + expect(merged.removed[0]?.updatedAt).toBe('2026-02-01T00:00:00.000Z') + }) + + it('lets an explicit later keep restore a tombstoned identity', () => { + const id = { owner: '0xabc0000000000000000000000000000000000001', slug: 'safer-nights' } + rememberBookmarkRemoved(id, '2026-02-01T00:00:00.000Z') + rememberBookmarkKept(id, '2026-03-01T00:00:00.000Z') + const merged = mergeBookmarkDocuments( + { + version: 2, + causes: [], + removed: [{ ...id, updatedAt: '2026-02-01T00:00:00.000Z' }], + }, + { + version: 2, + causes: [{ ...id, updatedAt: '2026-03-01T00:00:00.000Z' }], + removed: [], + }, + ) + expect(merged.causes[0]?.updatedAt).toBe('2026-03-01T00:00:00.000Z') + expect(merged.removed).toEqual([]) }) }) diff --git a/causestarter/src/lib/causeBookmarks.ts b/causestarter/src/lib/causeBookmarks.ts index 2e5afc34..e0e81bda 100644 --- a/causestarter/src/lib/causeBookmarks.ts +++ b/causestarter/src/lib/causeBookmarks.ts @@ -3,6 +3,10 @@ * * Distinct from statement bookmarks (`bookmarks`), which are statement CIDs. * Unpublished drafts stay in localStorage only. + * + * The ref is last-write-wins JSON. `removed` is a tombstone list so a stale + * device that still has a keep cannot union-sync a deletion back onto the + * wallet. A later keep for the same identity drops that tombstone. */ import { MutableRefUpdaterAbi } from '@commonality/sdk/abis' @@ -13,93 +17,248 @@ import { type MutableRefUpdaterContract, } from '@commonality/sdk/mutable-refs' import type { WriteClients } from '@commonality/sdk/utils' -import { bookmarkCause, listCauses, publishedBookmarkIds, type CauseDraft } from './causeStore' -import { loadRosterDocument, resolveRosterCid } from './causeRoster' +import { bookmarkCause, listCauses, publishedBookmarkIds, unbookmarkCause, type CauseDraft } from './causeStore' +import { applyPlankTexts, loadPlankTexts, loadRosterDocument, resolveRosterCid } from './causeRoster' import { getRuntimeConfigValue } from './runtimeConfig' export const CAUSE_BOOKMARKS_REF = 'bookmarked-causes' -export const CAUSE_BOOKMARKS_SCHEMA_VERSION = 1 as const +export const CAUSE_BOOKMARKS_SCHEMA_VERSION = 2 as const +const REMOVED_STORAGE_KEY = 'causestarter.bookmark-removed.v1' +const KEPT_STORAGE_KEY = 'causestarter.bookmark-kept.v1' export interface CauseBookmarkId { owner: string slug: string + updatedAt?: string +} + +export interface CauseBookmarkDocument { + version: number + causes: CauseBookmarkId[] + removed: CauseBookmarkId[] } export function bookmarkKey(id: CauseBookmarkId): string { return `${id.owner.toLowerCase()}:${id.slug}` } -export function parseCauseBookmarkList(value: string | null | undefined): CauseBookmarkId[] | null { +function stampMs(id: CauseBookmarkId): number { + if (!id.updatedAt) return 0 + const ms = Date.parse(id.updatedAt) + return Number.isFinite(ms) ? ms : 0 +} + +function normalizeId(id: CauseBookmarkId, fallbackStamp?: string): CauseBookmarkId | null { + const owner = id.owner.toLowerCase() + const slug = id.slug + if (!/^0x[0-9a-f]{40}$/.test(owner) || !slug) return null + const updatedAt = id.updatedAt && Number.isFinite(Date.parse(id.updatedAt)) + ? id.updatedAt + : fallbackStamp + return updatedAt ? { owner, slug, updatedAt } : { owner, slug } +} + +function parseIdList(value: unknown, fallbackStamp?: string): CauseBookmarkId[] { + if (!Array.isArray(value)) return [] + const seen = new Set() + const ids: CauseBookmarkId[] = [] + for (const item of value) { + if (!item || typeof item !== 'object') continue + const parsed = normalizeId(item as CauseBookmarkId, fallbackStamp) + if (!parsed) continue + const key = bookmarkKey(parsed) + if (seen.has(key)) continue + seen.add(key) + ids.push(parsed) + } + return ids +} + +function canUseStorage(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' +} + +function readStoredIds(key: string): CauseBookmarkId[] { + if (!canUseStorage()) return [] + try { + return parseIdList(JSON.parse(window.localStorage.getItem(key) ?? '[]')) + } catch { + return [] + } +} + +function writeStoredIds(key: string, ids: CauseBookmarkId[]): void { + if (!canUseStorage()) return + window.localStorage.setItem(key, JSON.stringify(ids)) +} + +export function readLocalBookmarkRemovals(): CauseBookmarkId[] { + return readStoredIds(REMOVED_STORAGE_KEY) +} + +export function readLocalBookmarkKeeps(): CauseBookmarkId[] { + return readStoredIds(KEPT_STORAGE_KEY) +} + +function writeLocalBookmarkRemovals(ids: CauseBookmarkId[]): void { + writeStoredIds(REMOVED_STORAGE_KEY, ids) +} + +function writeLocalBookmarkKeeps(ids: CauseBookmarkId[]): void { + writeStoredIds(KEPT_STORAGE_KEY, ids) +} + +function dropStoredId(ids: CauseBookmarkId[], id: CauseBookmarkId): CauseBookmarkId[] { + const key = bookmarkKey(id) + return ids.filter((row) => bookmarkKey(row) !== key) +} + +export function rememberBookmarkRemoved(id: CauseBookmarkId, at = new Date().toISOString()): void { + const next = normalizeId({ ...id, updatedAt: at }) + if (!next) return + writeLocalBookmarkRemovals([...dropStoredId(readLocalBookmarkRemovals(), next), next]) + writeLocalBookmarkKeeps(dropStoredId(readLocalBookmarkKeeps(), next)) +} + +export function rememberBookmarkKept(id: CauseBookmarkId, at = new Date().toISOString()): void { + const next = normalizeId({ ...id, updatedAt: at }) + if (!next) return + writeLocalBookmarkKeeps([...dropStoredId(readLocalBookmarkKeeps(), next), next]) + writeLocalBookmarkRemovals(dropStoredId(readLocalBookmarkRemovals(), next)) +} + +export function parseCauseBookmarkDocument(value: string | null | undefined): CauseBookmarkDocument | null { if (value == null) return null const trimmed = value.trim() - if (!trimmed) return [] + if (!trimmed) { + return { version: CAUSE_BOOKMARKS_SCHEMA_VERSION, causes: [], removed: [] } + } try { const parsed = JSON.parse(trimmed) as unknown - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return [] - const record = parsed as { version?: unknown; causes?: unknown } - if (!Array.isArray(record.causes)) return [] - const seen = new Set() - const ids: CauseBookmarkId[] = [] - for (const item of record.causes) { - if (!item || typeof item !== 'object') continue - const owner = String((item as { owner?: unknown }).owner ?? '').toLowerCase() - const slug = String((item as { slug?: unknown }).slug ?? '') - if (!/^0x[0-9a-f]{40}$/.test(owner) || !slug) continue - const key = `${owner}:${slug}` - if (seen.has(key)) continue - seen.add(key) - ids.push({ owner, slug }) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { version: CAUSE_BOOKMARKS_SCHEMA_VERSION, causes: [], removed: [] } + } + const record = parsed as { version?: unknown; causes?: unknown; removed?: unknown } + return { + version: typeof record.version === 'number' ? record.version : CAUSE_BOOKMARKS_SCHEMA_VERSION, + causes: parseIdList(record.causes), + removed: parseIdList(record.removed), } - return ids } catch { - return [] + return { version: CAUSE_BOOKMARKS_SCHEMA_VERSION, causes: [], removed: [] } } } -export function serializeCauseBookmarkList(ids: CauseBookmarkId[]): string { - const seen = new Set() - const causes: CauseBookmarkId[] = [] - for (const id of ids) { - const owner = id.owner.toLowerCase() - if (!id.slug || seen.has(`${owner}:${id.slug}`)) continue - seen.add(`${owner}:${id.slug}`) - causes.push({ owner, slug: id.slug }) - } +export function parseCauseBookmarkList(value: string | null | undefined): CauseBookmarkId[] | null { + const document = parseCauseBookmarkDocument(value) + return document ? document.causes : null +} + +export function serializeCauseBookmarkDocument(document: CauseBookmarkDocument): string { return JSON.stringify({ version: CAUSE_BOOKMARKS_SCHEMA_VERSION, - causes, + causes: mergeBookmarkIds(document.causes), + removed: mergeBookmarkIds(document.removed), + }) +} + +export function serializeCauseBookmarkList(ids: CauseBookmarkId[]): string { + return serializeCauseBookmarkDocument({ + version: CAUSE_BOOKMARKS_SCHEMA_VERSION, + causes: ids, + removed: [], }) } export function mergeBookmarkIds( ...lists: Array ): CauseBookmarkId[] { - const seen = new Set() - const merged: CauseBookmarkId[] = [] + const byKey = new Map() for (const list of lists) { for (const id of list) { - const key = bookmarkKey(id) - if (seen.has(key)) continue - seen.add(key) - merged.push({ owner: id.owner.toLowerCase(), slug: id.slug }) + const next = normalizeId(id) + if (!next) continue + const key = bookmarkKey(next) + const existing = byKey.get(key) + if (!existing || stampMs(next) >= stampMs(existing)) byKey.set(key, next) } } - return merged + return [...byKey.values()] +} + +/** Equal stamps prefer remove so a keep cannot undo a same-instant delete. */ +export function mergeBookmarkDocuments( + ...documents: Array +): CauseBookmarkDocument { + type Kind = 'keep' | 'remove' + const byKey = new Map() + + const consider = (id: CauseBookmarkId, kind: Kind) => { + const next = normalizeId(id) + if (!next) return + const key = bookmarkKey(next) + const existing = byKey.get(key) + if (!existing) { + byKey.set(key, { id: next, kind }) + return + } + const nextMs = stampMs(next) + const existingMs = stampMs(existing.id) + if (nextMs > existingMs || (nextMs === existingMs && kind === 'remove')) { + byKey.set(key, { id: next, kind }) + } + } + + for (const document of documents) { + if (!document) continue + for (const id of document.causes) consider(id, 'keep') + for (const id of document.removed) consider(id, 'remove') + } + + const causes: CauseBookmarkId[] = [] + const removed: CauseBookmarkId[] = [] + for (const row of byKey.values()) { + if (row.kind === 'remove') removed.push(row.id) + else causes.push(row.id) + } + return { version: CAUSE_BOOKMARKS_SCHEMA_VERSION, causes, removed } } export function sameBookmarkList(a: readonly CauseBookmarkId[], b: readonly CauseBookmarkId[]): boolean { if (a.length !== b.length) return false - const keys = new Set(a.map(bookmarkKey)) - return b.every((id) => keys.has(bookmarkKey(id))) + const stamps = new Map(a.map((id) => [bookmarkKey(id), stampMs(id)])) + return b.every((id) => stamps.get(bookmarkKey(id)) === stampMs(id)) } -export async function readCauseBookmarkList( +export function sameBookmarkDocument(a: CauseBookmarkDocument, b: CauseBookmarkDocument): boolean { + return sameBookmarkList(a.causes, b.causes) && sameBookmarkList(a.removed, b.removed) +} + +export function localBookmarkDocument(): CauseBookmarkDocument { + const keepByKey = new Map(readLocalBookmarkKeeps().map((id) => [bookmarkKey(id), id])) + const causes = publishedBookmarkIds().map((id) => keepByKey.get(bookmarkKey(id)) ?? normalizeId(id) ?? id) + return { + version: CAUSE_BOOKMARKS_SCHEMA_VERSION, + causes, + removed: readLocalBookmarkRemovals(), + } +} + +export async function readCauseBookmarkDocument( machinery: SDKMachinery, address: string, -): Promise { +): Promise { const ref = await getUserRef(machinery, address, CAUSE_BOOKMARKS_REF) if (!ref) return null - return parseCauseBookmarkList(ref.value) + return parseCauseBookmarkDocument(ref.value) +} + +export async function readCauseBookmarkList( + machinery: SDKMachinery, + address: string, +): Promise { + const document = await readCauseBookmarkDocument(machinery, address) + return document ? document.causes : null } function mutableRefContract(): MutableRefUpdaterContract | null { @@ -108,13 +267,37 @@ function mutableRefContract(): MutableRefUpdaterContract | null { return { address, abi: MutableRefUpdaterAbi } } -export async function writeCauseBookmarkList( +export async function writeCauseBookmarkDocument( clients: WriteClients, - ids: CauseBookmarkId[], + document: CauseBookmarkDocument, ): Promise { const contract = mutableRefContract() if (!contract) throw new Error('MutableRefUpdater is not configured') - await updateRef(clients, contract, CAUSE_BOOKMARKS_REF, serializeCauseBookmarkList(ids)) + await updateRef(clients, contract, CAUSE_BOOKMARKS_REF, serializeCauseBookmarkDocument(document)) +} + +export async function writeCauseBookmarkList( + clients: WriteClients, + ids: CauseBookmarkId[], +): Promise { + await writeCauseBookmarkDocument(clients, { + version: CAUSE_BOOKMARKS_SCHEMA_VERSION, + causes: ids, + removed: readLocalBookmarkRemovals(), + }) +} + +/** Merge this device's keeps/tombstones with the wallet document, then write. */ +export async function persistCauseBookmarks( + machinery: SDKMachinery, + address: string, + clients: WriteClients, +): Promise { + const remote = await readCauseBookmarkDocument(machinery, address) + const merged = mergeBookmarkDocuments(remote, localBookmarkDocument()) + writeLocalBookmarkRemovals(merged.removed) + writeLocalBookmarkKeeps(merged.causes) + await writeCauseBookmarkDocument(clients, merged) } export async function hydrateCauseBookmark( @@ -122,60 +305,79 @@ export async function hydrateCauseBookmark( id: CauseBookmarkId, ): Promise { const owner = id.owner.toLowerCase() - const now = new Date().toISOString() + const stamp = id.updatedAt && Number.isFinite(Date.parse(id.updatedAt)) + ? id.updatedAt + : new Date().toISOString() const stub: CauseDraft = { id: `remote:${owner}:${id.slug}`, planks: [], slug: id.slug, founderAddress: owner, - createdAt: now, - updatedAt: now, + createdAt: stamp, + updatedAt: stamp, } try { const rosterCid = await resolveRosterCid(machinery, owner, id.slug) if (!rosterCid) return bookmarkCause(stub) const loaded = await loadRosterDocument(machinery, rosterCid) if (!loaded) return bookmarkCause({ ...stub, rosterCid }) - return bookmarkCause({ - ...stub, - rosterCid, - title: loaded.fields.title, - summary: loaded.fields.summary, - planks: loaded.fields.plankCids.map((cid) => ({ + const texts = await loadPlankTexts(machinery, loaded.fields.plankCids) + const planks = applyPlankTexts( + loaded.fields.plankCids.map((cid) => ({ id: `plank:${cid}`, text: cid, origin: 'user' as const, cid, })), + texts, + ) + return bookmarkCause({ + ...stub, + rosterCid, + title: loaded.fields.title, + summary: loaded.fields.summary, + planks, }) } catch { return bookmarkCause(stub) } } +function dropLocalBookmark(id: CauseBookmarkId): void { + const existing = listCauses().find( + (cause) => cause.slug === id.slug && cause.founderAddress?.toLowerCase() === id.owner.toLowerCase(), + ) + if (existing) unbookmarkCause(existing) +} + /** * Union the wallet ref with local published keeps, hydrate missing rows, - * and push the union if the ref is missing or behind local. + * drop locally cached rows that a tombstone still covers, and push the + * merged document if the ref is missing or behind local. */ export async function syncCauseBookmarks( machinery: SDKMachinery, address: string, clients?: WriteClients | null, ): Promise { - const remote = await readCauseBookmarkList(machinery, address) - const local = publishedBookmarkIds() - const merged = remote == null ? local : mergeBookmarkIds(remote, local) + const remote = await readCauseBookmarkDocument(machinery, address) + const merged = mergeBookmarkDocuments(remote, localBookmarkDocument()) + + writeLocalBookmarkRemovals(merged.removed) + writeLocalBookmarkKeeps(merged.causes) + + for (const id of merged.removed) dropLocalBookmark(id) - for (const id of merged) { + for (const id of merged.causes) { const existing = publishedBookmarkIds().find( (row) => row.owner === id.owner && row.slug === id.slug, ) if (!existing) await hydrateCauseBookmark(machinery, id) } - if (clients && (remote == null ? merged.length > 0 : !sameBookmarkList(remote, merged))) { + if (clients && (remote == null ? merged.causes.length + merged.removed.length > 0 : !sameBookmarkDocument(remote, merged))) { try { - await writeCauseBookmarkList(clients, merged) + await writeCauseBookmarkDocument(clients, merged) } catch (err) { console.warn('syncCauseBookmarks: could not write wallet list', err) } diff --git a/causestarter/src/pages/CauseDetailPage.tsx b/causestarter/src/pages/CauseDetailPage.tsx index 60dbc106..9c4026a9 100644 --- a/causestarter/src/pages/CauseDetailPage.tsx +++ b/causestarter/src/pages/CauseDetailPage.tsx @@ -31,7 +31,7 @@ import { SafetyRejectionDialog } from '../components/SafetyRejectionDialog' import { bookmarkCause, causeFundingPath, causeLeaderboardPath, causePath, causeTitle, findCauseByStable, getCause, hasPublishedRoster, isCauseBookmarked, isLive, markPlankPublished, - markRosterPublished, newPlank, publishedBookmarkIds, publishedPlanks, realPlanks, + markRosterPublished, newPlank, publishedPlanks, realPlanks, unbookmarkCause, unpublishedPlanks, updateCause, type CauseDraft, type CausePlank, type SafetyState, } from '../lib/causeStore' @@ -46,7 +46,11 @@ import { previewRosterCid, publishRoster, resolveRosterCid, rosterFieldsFromCause, stableCausePath, validateSlug, type RosterCoherenceBadge, } from '../lib/causeRoster' -import { writeCauseBookmarkList } from '../lib/causeBookmarks' +import { + persistCauseBookmarks, + rememberBookmarkKept, + rememberBookmarkRemoved, +} from '../lib/causeBookmarks' import { publishPlank } from '../lib/publishPlank' import { useMachinery } from '../lib/useMachinery' import { useWriteClients } from '../lib/useWriteClients' @@ -317,23 +321,28 @@ export function CauseDetailPage() { const [shareCopiedOpen, setShareCopiedOpen] = useState(false) const persistWalletBookmarks = useCallback(async () => { - if (!writeClients) return + if (!writeClients || !address) return try { - await writeCauseBookmarkList(writeClients, publishedBookmarkIds()) + await persistCauseBookmarks(machinery, address, writeClients) } catch (err) { console.warn('Could not update wallet cause bookmarks', err) } - }, [writeClients]) + }, [writeClients, address, machinery]) const keepThisCause = useCallback(() => { if (!cause || isOrganizer || !cause.founderAddress || !cause.slug) return - setCause(bookmarkCause(cause)) + const saved = bookmarkCause(cause) + rememberBookmarkKept({ owner: saved.founderAddress!, slug: saved.slug! }) + setCause(saved) void persistWalletBookmarks() setBookmarkUndoOpen(false) }, [cause, isOrganizer, persistWalletBookmarks]) const handleRemoveFromDevice = () => { if (!cause || isOrganizer) return + if (cause.founderAddress && cause.slug) { + rememberBookmarkRemoved({ owner: cause.founderAddress, slug: cause.slug }) + } unbookmarkCause(cause) setCause({ ...cause }) void persistWalletBookmarks() diff --git a/specs/tech/subsystems/mutable-refs/README.md b/specs/tech/subsystems/mutable-refs/README.md index a0d0a1fe..9c55fcc7 100644 --- a/specs/tech/subsystems/mutable-refs/README.md +++ b/specs/tech/subsystems/mutable-refs/README.md @@ -72,7 +72,7 @@ When using refs to store lists (e.g., `created-statements`), the ref value is an - **`created-statements`**: Tracks statements a user has created (for re-discovery). Written automatically by the statement-creation flow via `addToCreatedStatements()`. Used to populate the "Statements I've Created" section of a user's profile page. - **`bookmarks`**: Statement CIDs the user chose to remember without (or before) signing. Do not store causes here. -- **`bookmarked-causes`**: Published CauseStarter causes the user chose to keep. Value is a last-write-wins JSON list of `{ owner, slug }` identities, not statement CIDs. Unpublished drafts stay off this ref. +- **`bookmarked-causes`**: Published CauseStarter causes the user chose to keep. Value is last-write-wins JSON `{ version, causes, removed }`. `causes` are `{ owner, slug, updatedAt? }` identities, not statement CIDs. `removed` is a tombstone list so a stale device cannot union a deletion back onto the wallet. Version 1 documents (causes only) still parse. Unpublished drafts stay off this ref. Other ref names are possible (favorites, drafts, etc.) — the system is fully generic.