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

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