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([