feat(activity): per-viewer archive and hide

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 /<bruker>/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.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 20:19:44 +02:00
commit 6e005fc2d7
8 changed files with 418 additions and 7 deletions

View file

@ -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}
<button type="button" onclick={startEdit}>Rediger</button>
{/if}
{#if session.user}
<button
type="button"
onclick={toggleArchive}
disabled={archiveBusy}
aria-pressed={view.viewer_archived}
title={view.viewer_archived ? 'Hent ut av arkivet' : 'Arkiver — gjem fra hovedlisten, behold for historikk'}
>
{view.viewer_archived ? '📦 Arkivert' : '📦 Arkiver'}
</button>
{/if}
{#if session.user && !isOwner}
<!-- Hide is non-owner only — owners can delete or archive instead. -->
<button
type="button"
onclick={toggleHide}
disabled={hideBusy}
aria-pressed={view.viewer_hidden}
title={view.viewer_hidden ? 'Vis igjen' : 'Gjem — denne appellerer ikke til meg'}
>
{view.viewer_hidden ? '🙈 Skjult' : '🙈 Gjem'}
</button>
{/if}
{#if canDelete}
<button class="danger" type="button" onclick={del}>Slett</button>
{/if}

View file

@ -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}
<div class="row" style="gap: 1rem; margin-top: 0.5rem; font-size: 0.9rem;">
<label class="row" style="gap: 0.35rem;">
<input type="checkbox" bind:checked={showArchived} />
<span class="muted">📦 Vis arkivert</span>
</label>
<label class="row" style="gap: 0.35rem;">
<input type="checkbox" bind:checked={showHidden} />
<span class="muted">🙈 Vis skjult</span>
</label>
</div>
{/if}
{#if showForm}
<div style="margin-top: 1rem;">
<ActivityForm onCreated={onCreated} onCancel={() => (showForm = false)} />

View file

@ -64,7 +64,13 @@ export const api = {
}),
// --- activities -----------------------------------------------------------
listActivities: () => http<Activity[]>('/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<Activity[]>(`/activities${q ? `?${q}` : ''}`);
},
getActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}`),
createActivity: (body: CreateActivityRequest) =>
@ -83,6 +89,14 @@ export const api = {
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'POST' }),
undoneActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/done`, { method: 'DELETE' }),
archiveActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'POST' }),
unarchiveActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/archive`, { method: 'DELETE' }),
hideActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'POST' }),
unhideActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/hide`, { method: 'DELETE' }),
bookmarkActivity: (id: string) =>
http<Activity>(`/activities/${encodeURIComponent(id)}/bookmark`, { method: 'POST' }),
unbookmarkActivity: (id: string) =>

View file

@ -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<string, { count: number; hearted: boolean }>;
done: Map<string, { count: number; done: boolean }>;
bookmarked: Set<string>;
archived: Set<string>;
hidden: Set<string>;
attribution: Map<string, { display: string | null; username: string | null }>;
}
@ -176,9 +194,11 @@ function buildBulkLookups(rows: ActivityRow[], viewerId: string | null): BulkLoo
const hearts = new Map<string, { count: number; hearted: boolean }>();
const done = new Map<string, { count: number; done: boolean }>();
const bookmarked = new Set<string>();
const archived = new Set<string>();
const hidden = new Set<string>();
const attribution = new Map<string, { display: string | null; username: string | null }>();
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<Filing, string> = {
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

View file

@ -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.

View file

@ -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,

View file

@ -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. */

View file

@ -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<Activity>(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<Activity[]>(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<Activity>(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<Activity[]>(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([