From 6e005fc2d795648cd859663853ce1f277b74c4b1 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 20:19:44 +0200 Subject: [PATCH] feat(activity): per-viewer archive and hide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new per-viewer flags on activities, mirroring the heart/done shape: - ARCHIVE: any viewer (incl. the owner) can archive a row. Out of sight by default but the permalink still resolves. For owners, archive also filters the row out of their //liste public page β€” "I'm done sharing this." - HIDE: only non-owners. "This doesn't appeal to me." Endpoint returns 400 cannot_hide_own when the owner tries it (they should delete or archive instead). Both default-filtered from GET /api/activities. Opt-in via query params with three modes each: exclude (default), include (1), only. ?archived=1&hidden=1 includes both; ?archived=only shows just the archive. The list endpoint composes the SQL filters per-viewer (anonymous viewers have no rows in either table β†’ no-ops). Schema: two new tables user_archived_activities and user_hidden_activities, both composite-PK on (user_id, activity_id), cascade on both refs. Same shape as the existing engagement tables. Types: viewer_archived + viewer_hidden on every Activity variant. viewer_hidden on private is always false (the endpoint refuses); docstring on the type explains why. Bulk lookups extended so the list endpoint stays at constant queries. Single-row paths get per-row viewerArchived/viewerHidden helpers. fetchRowForViewer keeps preserving the viewer's custom sort_position on toggles. UI: two new buttons in ActivityRow's action row β€” "πŸ“¦ Arkiver" / "πŸ“¦ Arkivert" (everyone) and "πŸ™ˆ Gjem" / "πŸ™ˆ Skjult" (non-owners only). Two new checkboxes near the search field on /hjem: "πŸ“¦ Vis arkivert" and "πŸ™ˆ Vis skjult". Toggling either re-fetches from the server because the filtering is server-side. When an archive/hide flips the row out of the current view, Home.svelte triggers a refetch so the row disappears in real time. Public-list endpoint (/api/users/:bruker/list) also filters out the owner's own archived rows β€” consistent with "archive means filed away from active view." Tests: 3 new in engagement.test.ts β€” viewer archives + per-viewer isolation, owner's archive filters their public list, hide refuses on own row + ?hidden=only path. Suite goes 102 β†’ 105. --- frontend/src/components/ActivityRow.svelte | 67 +++++++++ frontend/src/components/Home.svelte | 47 +++++- frontend/src/lib/api.ts | 16 ++- server/activities.ts | 157 ++++++++++++++++++++- server/db.ts | 18 +++ server/users.ts | 11 ++ shared/types.ts | 16 +++ tests/engagement.test.ts | 93 ++++++++++++ 8 files changed, 418 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte index a550dce..34420f0 100644 --- a/frontend/src/components/ActivityRow.svelte +++ b/frontend/src/components/ActivityRow.svelte @@ -179,6 +179,50 @@ } } + // --- Archive / Hide ----------------------------------------------------- + // Two per-viewer flags. Archive applies to anyone (including the owner); + // hide applies only to non-owners. Both opt the row out of the default + // listing β€” the user has to toggle "Vis arkiv" / "Vis skjulte" to see + // them again. + let archiveBusy = $state(false); + let hideBusy = $state(false); + + async function toggleArchive() { + if (!session.user || archiveBusy) return; + archiveBusy = true; + const wasArchived = view.viewer_archived; + localOverride = { ...view, viewer_archived: !wasArchived }; + try { + const updated = wasArchived + ? await api.unarchiveActivity(view.id) + : await api.archiveActivity(view.id); + localOverride = updated; + onChanged?.(updated); + } catch { + localOverride = null; + } finally { + archiveBusy = false; + } + } + + async function toggleHide() { + if (!session.user || hideBusy) return; + hideBusy = true; + const wasHidden = view.viewer_hidden; + localOverride = { ...view, viewer_hidden: !wasHidden }; + try { + const updated = wasHidden + ? await api.unhideActivity(view.id) + : await api.hideActivity(view.id); + localOverride = updated; + onChanged?.(updated); + } catch { + localOverride = null; + } finally { + hideBusy = false; + } + } + // --- Bookmarks ----------------------------------------------------------- let bookmarkBusy = $state(false); async function toggleBookmark() { @@ -374,6 +418,29 @@ {#if canEdit && onEdit} {/if} + {#if session.user} + + {/if} + {#if session.user && !isOwner} + + + {/if} {#if canDelete} {/if} diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte index 7985fd7..afcf990 100644 --- a/frontend/src/components/Home.svelte +++ b/frontend/src/components/Home.svelte @@ -22,12 +22,28 @@ let error: string | null = $state(null); let query = $state(''); - onMount(load); + // Two toggles control what shape of list we ask the server for: + // - default: active rows only (excludes archived AND hidden) + // - showArchived: include archived rows mixed in with active + // - showHidden: include hidden rows mixed in + // The user can flip these independently. Anonymous (publicOnly) viewers + // never see the toggles since they have no archive/hide state. + let showArchived = $state(false); + let showHidden = $state(false); + + onMount(() => load()); async function load() { loading = true; try { - activities = await api.listActivities(); + activities = await api.listActivities( + publicOnly + ? undefined + : { + ...(showArchived ? { archived: '1' as const } : {}), + ...(showHidden ? { hidden: '1' as const } : {}), + }, + ); } catch (e) { error = 'Kunne ikke laste oppfΓΈringer.'; } finally { @@ -35,6 +51,14 @@ } } + // Re-fetch from the server when the toggles flip β€” the WHERE clause is + // server-side so we can't filter the existing array client-side. + $effect(() => { + void showArchived; + void showHidden; + if (!loading) load(); + }); + function onCreated(a: Activity) { activities = [a, ...activities]; showForm = false; @@ -46,7 +70,13 @@ } function onChanged(a: Activity) { + // When a row's archived/hidden state flips, it may no longer belong in + // the current view (toggles are off β†’ archived/hidden rows disappear). + // Reload to get the authoritative list from the server. + const needsRefetch = + (a.viewer_archived && !showArchived) || (a.viewer_hidden && !showHidden); activities = activities.map((x) => (x.id === a.id ? a : x)); + if (needsRefetch) load(); } function onDeleted(id: string) { @@ -201,6 +231,19 @@ aria-label="SΓΈk i aktiviteter" /> + {#if !publicOnly && session.user} +
+ + +
+ {/if} + {#if showForm}
(showForm = false)} /> diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 523e1ba..56d7442 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -64,7 +64,13 @@ export const api = { }), // --- activities ----------------------------------------------------------- - listActivities: () => http('/activities'), + listActivities: (opts?: { archived?: '1' | 'only'; hidden?: '1' | 'only' }) => { + const qs = new URLSearchParams(); + if (opts?.archived) qs.set('archived', opts.archived); + if (opts?.hidden) qs.set('hidden', opts.hidden); + const q = qs.toString(); + return http(`/activities${q ? `?${q}` : ''}`); + }, getActivity: (id: string) => http(`/activities/${encodeURIComponent(id)}`), createActivity: (body: CreateActivityRequest) => @@ -83,6 +89,14 @@ export const api = { http(`/activities/${encodeURIComponent(id)}/done`, { method: 'POST' }), undoneActivity: (id: string) => http(`/activities/${encodeURIComponent(id)}/done`, { method: 'DELETE' }), + archiveActivity: (id: string) => + http(`/activities/${encodeURIComponent(id)}/archive`, { method: 'POST' }), + unarchiveActivity: (id: string) => + http(`/activities/${encodeURIComponent(id)}/archive`, { method: 'DELETE' }), + hideActivity: (id: string) => + http(`/activities/${encodeURIComponent(id)}/hide`, { method: 'POST' }), + unhideActivity: (id: string) => + http(`/activities/${encodeURIComponent(id)}/hide`, { method: 'DELETE' }), bookmarkActivity: (id: string) => http(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }), unbookmarkActivity: (id: string) => diff --git a/server/activities.ts b/server/activities.ts index 2d8238b..892e8ce 100644 --- a/server/activities.ts +++ b/server/activities.ts @@ -79,6 +79,22 @@ function viewerBookmarked(activityId: string, viewerId: string | null): boolean .get(activityId, viewerId); } +/** Does the viewer have an archive entry on this activity? */ +function viewerArchived(activityId: string, viewerId: string | null): boolean { + if (!viewerId) return false; + return !!getDb() + .prepare('SELECT 1 FROM user_archived_activities WHERE activity_id = ? AND user_id = ?') + .get(activityId, viewerId); +} + +/** Does the viewer have a hide entry on this activity? */ +function viewerHidden(activityId: string, viewerId: string | null): boolean { + if (!viewerId) return false; + return !!getDb() + .prepare('SELECT 1 FROM user_hidden_activities WHERE activity_id = ? AND user_id = ?') + .get(activityId, viewerId); +} + /** Done-count + viewer-done lookup for a single activity. */ function doneFor(activityId: string, viewerId: string | null): { count: number; done: boolean } { const db = getDb(); @@ -166,6 +182,8 @@ interface BulkLookups { hearts: Map; done: Map; bookmarked: Set; + archived: Set; + hidden: Set; attribution: Map; } @@ -176,9 +194,11 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo const hearts = new Map(); const done = new Map(); const bookmarked = new Set(); + const archived = new Set(); + const hidden = new Set(); const attribution = new Map(); - if (ids.length === 0) return { tags, hearts, done, bookmarked, attribution }; + if (ids.length === 0) return { tags, hearts, done, bookmarked, archived, hidden, attribution }; const ph = ids.map(() => '?').join(','); const heartCounts = db @@ -231,6 +251,22 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo `) .all(...ids, viewerId) as { activity_id: string }[]; for (const r of bm) bookmarked.add(r.activity_id); + + const ar = db + .prepare(` + SELECT activity_id FROM user_archived_activities + WHERE activity_id IN (${ph}) AND user_id = ? + `) + .all(...ids, viewerId) as { activity_id: string }[]; + for (const r of ar) archived.add(r.activity_id); + + const hd = db + .prepare(` + SELECT activity_id FROM user_hidden_activities + WHERE activity_id IN (${ph}) AND user_id = ? + `) + .all(...ids, viewerId) as { activity_id: string }[]; + for (const r of hd) hidden.add(r.activity_id); } const ownerIds = [...new Set(rows.map((r) => r.owner_id))]; @@ -256,7 +292,7 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo } } - return { tags, hearts, done, bookmarked, attribution }; + return { tags, hearts, done, bookmarked, archived, hidden, attribution }; } function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups): Activity { @@ -269,6 +305,11 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups const done = bulk ? (bulk.done.get(row.id) ?? { count: 0, done: false }) : doneFor(row.id, viewerId); + // Archive applies to every viewer; hide applies only to non-owners. For + // a private row that's only ever visible to its owner, hide is always + // false (the endpoint refuses it anyway). + const archived = bulk ? bulk.archived.has(row.id) : viewerArchived(row.id, viewerId); + const hidden = bulk ? bulk.hidden.has(row.id) : viewerHidden(row.id, viewerId); if (row.visibility === 'private') { const a: ActivityPrivate = { id: row.id, @@ -282,6 +323,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups viewer_bookmarked: false, done_count: done.count, viewer_done: done.done, + viewer_archived: archived, + viewer_hidden: false, sort_position: sortPos, created_at: row.created_at, updated_at: row.updated_at, @@ -314,6 +357,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups viewer_bookmarked: bookmarked, done_count: done.count, viewer_done: done.done, + viewer_archived: archived, + viewer_hidden: hidden, sort_position: sortPos, created_at: row.created_at, updated_at: row.updated_at, @@ -347,6 +392,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups viewer_bookmarked: bookmarked, done_count: done.count, viewer_done: done.done, + viewer_archived: archived, + viewer_hidden: hidden, sort_position: sortPos, created_at: row.created_at, updated_at: row.updated_at, @@ -370,6 +417,8 @@ function serialize(row: ActivityRow, viewerId: string | null, bulk?: BulkLookups viewer_bookmarked: bookmarked, done_count: done.count, viewer_done: done.done, + viewer_archived: archived, + viewer_hidden: hidden, sort_position: sortPos, created_at: row.created_at, updated_at: row.updated_at, @@ -407,8 +456,20 @@ activitiesRoutes.get('/', (c) => { const viewerId = currentUserId(c); const db = getDb(); - const params: string[] = []; - let where = `visibility IN ('public','semi')`; + // Inclusion flags for archived / hidden rows. Modes: + // ?archived=0 (default) -- exclude archived + // ?archived=1 -- include archived (still mixed with the rest) + // ?archived=only -- ONLY archived + // Same scheme for ?hidden=... + // Anonymous viewers don't have either flag stored so these are no-ops for them. + type Mode = 'exclude' | 'include' | 'only'; + const parseMode = (q?: string): Mode => + q === '1' ? 'include' : q === 'only' ? 'only' : 'exclude'; + const archivedMode = parseMode(c.req.query('archived')); + const hiddenMode = parseMode(c.req.query('hidden')); + + const params: (string | number)[] = []; + let where = `(visibility IN ('public','semi')`; if (viewerId) { // Own private: where += ` OR (visibility = 'private' AND owner_id = ?)`; @@ -433,6 +494,28 @@ activitiesRoutes.get('/', (c) => { `; params.push(viewerId, viewerId, viewerId); } + where += `)`; + + // Per-viewer archive / hide filters. Only meaningful for an authenticated + // viewer (anonymous viewers have no rows in either table). + if (viewerId) { + const archivedExists = `EXISTS (SELECT 1 FROM user_archived_activities WHERE activity_id = activities.id AND user_id = ?)`; + const hiddenExists = `EXISTS (SELECT 1 FROM user_hidden_activities WHERE activity_id = activities.id AND user_id = ?)`; + if (archivedMode === 'exclude') { + where += ` AND NOT ${archivedExists}`; + params.push(viewerId); + } else if (archivedMode === 'only') { + where += ` AND ${archivedExists}`; + params.push(viewerId); + } + if (hiddenMode === 'exclude') { + where += ` AND NOT ${hiddenExists}`; + params.push(viewerId); + } else if (hiddenMode === 'only') { + where += ` AND ${hiddenExists}`; + params.push(viewerId); + } + } // Effective ordering: if the viewer has a per-row sort position, use it; // otherwise fall back to -created_at so new activities (no sort row yet) @@ -735,6 +818,72 @@ function toggleDone(c: AppContext, op: 'add' | 'remove') { activitiesRoutes.post('/:id/done', requireAuth, (c) => toggleDone(c, 'add')); activitiesRoutes.delete('/:id/done', requireAuth, (c) => toggleDone(c, 'remove')); +/** + * Archive (anyone) / hide (non-owner only). Same skeleton as toggleDone: + * - Visibility-aware existence check (404 for rows the viewer can't see). + * - For hide, the owner gets 400 cannot_hide_own β€” they should delete or + * archive instead. + * - INSERT OR IGNORE / DELETE for idempotency. + */ +type Filing = 'archive' | 'hide'; +const FILING_TABLES: Record = { + archive: 'user_archived_activities', + hide: 'user_hidden_activities', +}; + +function toggleFiling(c: AppContext, kind: Filing, op: 'add' | 'remove') { + const userId = c.get('userId'); + const id = c.req.param('id'); + if (!id) return c.json({ error: 'not_found' }, 404); + const db = getDb(); + + const row = db + .prepare('SELECT visibility, owner_id FROM activities WHERE id = ?') + .get(id) as { visibility: Visibility; owner_id: string } | null; + if (!row) return c.json({ error: 'not_found' }, 404); + + // Same visibility check as toggleDone β€” hidden rows return 404, not 403. + if (row.visibility === 'private' && row.owner_id !== userId) { + return c.json({ error: 'not_found' }, 404); + } + if (row.visibility === 'friends' && row.owner_id !== userId) { + const isFriend = !!db + .prepare('SELECT 1 FROM friends WHERE owner_id = ? AND friend_id = ?') + .get(row.owner_id, userId); + if (!isFriend) return c.json({ error: 'not_found' }, 404); + const blocked = !!db + .prepare(` + SELECT 1 FROM user_blocks + WHERE (blocker_id = ? AND blocked_id = ?) + OR (blocker_id = ? AND blocked_id = ?) + `) + .get(row.owner_id, userId, userId, row.owner_id); + if (blocked) return c.json({ error: 'not_found' }, 404); + } + + // Hiding your own row makes no sense β€” you can delete or archive instead. + if (kind === 'hide' && row.owner_id === userId) { + return c.json({ error: 'cannot_hide_own' }, 400); + } + + const table = FILING_TABLES[kind]; + if (op === 'add') { + db.prepare( + `INSERT OR IGNORE INTO ${table} (user_id, activity_id, created_at) VALUES (?, ?, ?)`, + ).run(userId, id, Date.now()); + } else { + db.prepare(`DELETE FROM ${table} WHERE user_id = ? AND activity_id = ?`).run(userId, id); + } + + const refreshed = fetchRowForViewer(id, userId) as ActivityRow; + return c.json(serialize(refreshed, userId)); +} + +activitiesRoutes.post('/:id/archive', requireAuth, (c) => toggleFiling(c, 'archive', 'add')); +activitiesRoutes.delete('/:id/archive', requireAuth, (c) => toggleFiling(c, 'archive', 'remove')); +activitiesRoutes.post('/:id/hide', requireAuth, (c) => toggleFiling(c, 'hide', 'add')); +activitiesRoutes.delete('/:id/hide', requireAuth, (c) => toggleFiling(c, 'hide', 'remove')); + // --- DELETE /api/activities/:id --------------------------------------------- // Authz: // - private: owner only. Other users can't even see private rows, so diff --git a/server/db.ts b/server/db.ts index 614ceb1..3acd0cd 100644 --- a/server/db.ts +++ b/server/db.ts @@ -131,6 +131,24 @@ const SCHEMA_STATEMENTS: readonly string[] = [ PRIMARY KEY (activity_id, user_id) )`, `CREATE INDEX IF NOT EXISTS activity_done_user_idx ON activity_done(user_id)`, + // Per-viewer "archived" flag. Any viewer (incl. the owner) can archive an + // activity to remove it from their default list while keeping the row + // intact for history. Archived rows are still permalinked. + `CREATE TABLE IF NOT EXISTS user_archived_activities ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, activity_id) + )`, + // Per-viewer "hidden" flag. Only non-owners can hide a row β€” the owner + // already has delete. Semantics: "this doesn't appeal to me, get it out + // of my feed." Default-filtered like archived. + `CREATE TABLE IF NOT EXISTS user_hidden_activities ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + activity_id TEXT NOT NULL REFERENCES activities(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, activity_id) + )`, // Bookmarks: logged-in users can save public/semi activities to their own // dashboard. Same shape as hearts: composite PK on (user, activity), one // row per bookmark. CASCADE on the activity so deletes clean up. diff --git a/server/users.ts b/server/users.ts index a327f87..71478dc 100644 --- a/server/users.ts +++ b/server/users.ts @@ -41,12 +41,20 @@ usersRoutes.get('/:username/list', (c) => { return c.json({ error: 'not_found' }, 404); } + // The owner's archive intent extends to their public list β€” archived + // rows disappear from "their published winter list" too. There's no + // logged-in viewer here (this endpoint is for the anonymous public), + // so we only need to filter rows the OWNER has archived. const rows = db .prepare(` SELECT id, owner_id, title, description, scheduled_at, loc_label, loc_lat, loc_lng, created_at, updated_at FROM activities WHERE owner_id = ? AND visibility = 'public' + AND NOT EXISTS ( + SELECT 1 FROM user_archived_activities + WHERE activity_id = activities.id AND user_id = activities.owner_id + ) ORDER BY created_at DESC `) .all(user.id) as ActivityRow[]; @@ -94,6 +102,9 @@ usersRoutes.get('/:username/list', (c) => { viewer_bookmarked: false, done_count: doneCounts.get(r.id) ?? 0, viewer_done: false, + // No logged-in viewer β†’ can't have personal archive/hide state. + viewer_archived: false, + viewer_hidden: false, // No personal sort here β€” anonymous view always sorts by recency. sort_position: -r.created_at, created_at: r.created_at, diff --git a/shared/types.ts b/shared/types.ts index 21634d7..e83fd61 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -223,6 +223,13 @@ export interface ActivityPublic { done_count: number; /** True when the authenticated viewer has marked this activity done. */ viewer_done: boolean; + /** True when the authenticated viewer has archived this activity for + * themselves. Default-filtered from the main and public lists; the + * client opts in via ?archived=1 to see them. Owner-archiving works too. */ + viewer_archived: boolean; + /** True when the authenticated viewer has hidden this activity. Only + * non-owners can hide. Default-filtered from lists; opt-in via ?hidden=1. */ + viewer_hidden: boolean; /** Effective sort position for THIS viewer: a custom value if they've * dragged this row, otherwise -created_at so unsorted/new rows float * to the top. Used by the client to compute drop-target midpoints. */ @@ -250,6 +257,8 @@ export interface ActivitySemi { viewer_bookmarked: boolean; done_count: number; viewer_done: boolean; + viewer_archived: boolean; + viewer_hidden: boolean; /** Effective sort position for THIS viewer: a custom value if they've * dragged this row, otherwise -created_at so unsorted/new rows float * to the top. Used by the client to compute drop-target midpoints. */ @@ -275,6 +284,11 @@ export interface ActivityPrivate { // for private rows. viewer_done reflects the owner's own state. done_count: number; viewer_done: boolean; + // Private rows are owner-only, so viewer_hidden is always false (the + // hide endpoint refuses on your own activities). viewer_archived is the + // owner archiving their own private todo. + viewer_archived: boolean; + viewer_hidden: boolean; /** Effective sort position for THIS viewer: a custom value if they've * dragged this row, otherwise -created_at so unsorted/new rows float * to the top. Used by the client to compute drop-target midpoints. */ @@ -307,6 +321,8 @@ export interface ActivityFriends { viewer_bookmarked: boolean; done_count: number; viewer_done: boolean; + viewer_archived: boolean; + viewer_hidden: boolean; /** Effective sort position for THIS viewer: a custom value if they've * dragged this row, otherwise -created_at so unsorted/new rows float * to the top. Used by the client to compute drop-target midpoints. */ diff --git a/tests/engagement.test.ts b/tests/engagement.test.ts index fc2a1d1..632ed8e 100644 --- a/tests/engagement.test.ts +++ b/tests/engagement.test.ts @@ -188,6 +188,99 @@ describe('"gjort" (done) marks', () => { }); }); +describe('archive + hide (per-viewer)', () => { + ttest('viewer can archive any visibility they see; default list excludes archived', async () => { + const [owner, viewer] = await Promise.all([ + signupAndGetCookie(ctx, 'arch-owner@test.invalid'), + signupAndGetCookie(ctx, 'arch-viewer@test.invalid'), + ]); + const pub = await createActivity(ctx, owner.cookie, { + visibility: 'public', title: 'arch-pub', tags: [], + }); + + // Default list includes it. + let list = await listActivities(ctx, viewer.cookie); + expect(list.find((a) => a.id === pub.id)).toBeTruthy(); + + // Archive it. + const archived = await reqJson(ctx, 'POST', `/api/activities/${pub.id}/archive`, { + cookie: viewer.cookie, + }); + expect(archived.viewer_archived).toBe(true); + + // Disappears from the default list. + list = await listActivities(ctx, viewer.cookie); + expect(list.find((a) => a.id === pub.id)).toBeUndefined(); + + // ?archived=1 brings it back. + const withArch = await reqJson(ctx, 'GET', '/api/activities?archived=1', { + cookie: viewer.cookie, + }); + expect(withArch.find((a) => a.id === pub.id)).toBeTruthy(); + + // Other viewer is unaffected β€” archive is per-viewer. + const otherList = await listActivities(ctx, owner.cookie); + expect(otherList.find((a) => a.id === pub.id)?.viewer_archived).toBe(false); + }); + + ttest('owner can archive their own row (own public list filters it too)', async () => { + const owner = await signupAndGetCookie(ctx, 'arch-self@test.invalid'); + // Owner needs a username + public_list_enabled to test the public-list filter. + await reqJson(ctx, 'PATCH', '/api/auth/profile', { + cookie: owner.cookie, + body: { username: 'archself', public_list_enabled: true, display_name: 'Arch Self' }, + }); + const pub = await createActivity(ctx, owner.cookie, { + visibility: 'public', title: 'arch-own-pub', tags: [], + }); + + // Visible on the owner's public list initially. + const before = await reqJson<{ activities: Activity[] }>(ctx, 'GET', '/api/users/archself/list'); + expect(before.activities.find((a) => a.id === pub.id)).toBeTruthy(); + + // Owner archives β†’ drops off their public list. + await req(ctx, 'POST', `/api/activities/${pub.id}/archive`, { cookie: owner.cookie }); + const after = await reqJson<{ activities: Activity[] }>(ctx, 'GET', '/api/users/archself/list'); + expect(after.activities.find((a) => a.id === pub.id)).toBeUndefined(); + }); + + ttest('hide refuses on owner\'s own row; works on others\'; per-viewer', async () => { + const [owner, viewer] = await Promise.all([ + signupAndGetCookie(ctx, 'hide-owner@test.invalid'), + signupAndGetCookie(ctx, 'hide-viewer@test.invalid'), + ]); + const pub = await createActivity(ctx, owner.cookie, { + visibility: 'public', title: 'hide-test', tags: [], + }); + + // Owner trying to hide their own row β†’ 400. + const ownerHide = await req(ctx, 'POST', `/api/activities/${pub.id}/hide`, { + cookie: owner.cookie, + }); + expect(ownerHide.status).toBe(400); + + // Non-owner viewer can hide it. + const hidden = await reqJson(ctx, 'POST', `/api/activities/${pub.id}/hide`, { + cookie: viewer.cookie, + }); + expect(hidden.viewer_hidden).toBe(true); + + // Disappears from the viewer's default list… + const list = await listActivities(ctx, viewer.cookie); + expect(list.find((a) => a.id === pub.id)).toBeUndefined(); + + // …but ?hidden=only surfaces just the hidden ones. + const only = await reqJson(ctx, 'GET', '/api/activities?hidden=only', { + cookie: viewer.cookie, + }); + expect(only.find((a) => a.id === pub.id)).toBeTruthy(); + + // Owner's view is unaffected. + const ownerList = await listActivities(ctx, owner.cookie); + expect(ownerList.find((a) => a.id === pub.id)).toBeTruthy(); + }); +}); + describe('bookmarks', () => { ttest('toggle, idempotent, refused on private', async () => { const [owner, viewer, otherViewer] = await Promise.all([