Friends + friends-only visibility + blocking

A fourth visibility level ("Venner") with one-way friendship and
two-way block filtering, plus the table-rebuild migration that drags
older dev DBs forward.

Visibility model:
  - Friendship is directional: (owner, friend) means owner has added
    friend to their list. Owner's friends-only activities become
    visible to friend; friend isn't automatically friends with owner.
  - Blocking is also directional at the DB level (blocker, blocked)
    but is checked SYMMETRICALLY at visibility-resolution time: once
    either user has blocked the other, friends-only content stops
    flowing in either direction. Block does NOT affect public or
    anonymous content — those are open to anyone by definition.
  - "Friends-only" is an access-list visibility, NOT cryptographic.
    The server stores the content in plaintext and serves it only to
    authorised viewers. This is documented honestly in /personvern.

Schema:
  - activities.visibility CHECK gains 'friends' as a fourth value
  - friends(owner_id, friend_id, created_at) — composite PK,
    self-friending blocked by CHECK
  - user_blocks(blocker_id, blocked_id, created_at) — same shape,
    blocking-self also blocked

Migration (server/db.ts):
  - SQLite can't ALTER a CHECK constraint, so the migration detects
    out-of-date DBs by scanning sqlite_master for the literal
    "'friends'" in the activities table's CREATE statement
  - If absent, rebuilds activities via the standard SQLite
    table-copy-drop-rename dance with foreign_keys briefly off
    around the transaction, then runs foreign_key_check to confirm
    no FKs were left orphaned (activity_tags, activity_hearts,
    bookmarks all point at activities). Smoke-tested on the dev DB:
    olemd's user row and moderator/admin flags survived.

Server endpoints (server/friends.ts):
  GET    /api/friends           — my outgoing list
  GET    /api/friends/incoming  — who has added ME
  POST   /api/friends           — add by username (idempotent)
  DELETE /api/friends/:userId   — remove a friend
  GET    /api/friends/blocks    — my blocked-users list
  POST   /api/friends/blocks    — block by user_id (idempotent)
  DELETE /api/friends/blocks/:userId — unblock

Add-by-username (not by email): users must set a username to be
findable. Email stays a private contact identifier.

Activity list filter (server/activities.ts): adds two clauses to the
WHERE — own friends-only, and friends-only owned by a user who has
added me AND there's no block in either direction. Single-activity
GET applies the same check.

Frontend:
  - ActivityForm.svelte gains the "Venner" option
  - ActivityRow.svelte renders a "Venner" badge with a new amber
    vis-badge.friends colour (passes contrast in both themes)
  - FriendsPanel.svelte: add-by-username form, outgoing, incoming
    (with Block button), and blocked (with Unblock button)
  - Profile.svelte mounts FriendsPanel between display fields and
    Eksporter
  - Home.svelte adds a "Venner" section between private and semi

Docs: Personvern.svelte gains a "Venner og blokkering" section
explaining that friends-only is access-list-not-crypto and pointing
the reader at "private" for actually-sensitive content.

26 tests still pass; typecheck clean; build succeeds. Bundle
36.8 KB → 39.1 KB gzipped (FriendsPanel + new server endpoints +
the Personvern prose).
This commit is contained in:
Ole-Morten Duesund 2026-05-25 14:47:20 +02:00
commit f39fe9ed65
14 changed files with 657 additions and 8 deletions

View file

@ -188,6 +188,7 @@
<label for="vis">Synlighet</label>
<select id="vis" bind:value={visibility}>
<option value="private">Privat (ende-til-ende-kryptert)</option>
<option value="friends">Venner (kun de jeg har lagt til)</option>
<option value="semi">Halv-offentlig (uten navn)</option>
<option value="public">Offentlig (med navn)</option>
</select>

View file

@ -222,7 +222,13 @@
{activity.title}
</a>
<span class="vis-badge {activity.visibility}">
{activity.visibility === 'semi' ? 'Anonym' : 'Offentlig'}
{#if activity.visibility === 'semi'}
Anonym
{:else if activity.visibility === 'friends'}
Venner
{:else}
Offentlig
{/if}
</span>
</h3>
{#if activity.description}

View file

@ -0,0 +1,184 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, ApiError } from '../lib/api';
import type { FriendEntry, BlockEntry } from '../../../shared/types';
/**
* Friends + blocks panel, mounted inside Profile. Three lists:
* - Outgoing: people I've added (my friends-only audience)
* - Incoming: people who've added me (I see their friends-only content)
* - Blocked: people I've blocked (mutual block on friends-only flow)
*
* Friendship is directional. "A added B" doesn't imply "B added A".
* Blocks are also directional but the server filter applies them
* symmetrically when evaluating friends-only visibility, so blocking is
* effectively bidirectional in practice.
*/
let outgoing: FriendEntry[] = $state([]);
let incoming: FriendEntry[] = $state([]);
let blocked: BlockEntry[] = $state([]);
let loadError: string | null = $state(null);
let loading = $state(true);
// Add-friend form
let addUsername = $state('');
let addBusy = $state(false);
let addError: string | null = $state(null);
onMount(load);
async function load() {
loading = true;
loadError = null;
try {
[outgoing, incoming, blocked] = await Promise.all([
api.listFriends(),
api.listIncomingFriends(),
api.listBlocks(),
]);
} catch {
loadError = 'Kunne ikke laste vennelisten.';
} finally {
loading = false;
}
}
function displayName(e: { display_name: string | null; username: string | null }): string {
return (e.display_name && e.display_name.trim())
|| e.username
|| '(ukjent)';
}
async function addFriend(e: SubmitEvent) {
e.preventDefault();
addError = null;
const name = addUsername.trim().toLowerCase();
if (!name) {
addError = 'Skriv inn et brukernavn.';
return;
}
addBusy = true;
try {
const added = await api.addFriend({ username: name });
// Dedupe: if the friend was already there, the server returned the same
// row; replace by user_id.
outgoing = [added, ...outgoing.filter((f) => f.user_id !== added.user_id)];
addUsername = '';
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
addError = 'Fant ingen bruker med det brukernavnet. (Brukeren må ha satt opp brukernavn for å være søkbar.)';
} else if (err instanceof ApiError && err.status === 400) {
addError = 'Du kan ikke legge til deg selv.';
} else {
addError = 'Klarte ikke å legge til venn.';
}
} finally {
addBusy = false;
}
}
async function removeFriend(f: FriendEntry) {
if (!confirm(`Fjerne ${displayName(f)} fra vennelisten din?`)) return;
try {
await api.removeFriend(f.user_id);
outgoing = outgoing.filter((x) => x.user_id !== f.user_id);
} catch {
loadError = 'Klarte ikke å fjerne venn.';
}
}
async function block(e: FriendEntry) {
if (!confirm(`Blokkere ${displayName(e)}? De vil ikke lenger se aktiviteter du deler med venner.`)) return;
try {
const blockedRow = await api.blockUser(e.user_id);
blocked = [blockedRow, ...blocked.filter((b) => b.user_id !== blockedRow.user_id)];
} catch {
loadError = 'Klarte ikke å blokkere.';
}
}
async function unblock(b: BlockEntry) {
try {
await api.unblockUser(b.user_id);
blocked = blocked.filter((x) => x.user_id !== b.user_id);
} catch {
loadError = 'Klarte ikke å oppheve blokkering.';
}
}
</script>
<section class="card" aria-labelledby="friends-h">
<h3 id="friends-h">Venner</h3>
<p class="muted">
Vennskap er enveis. Du kan se aktiviteter merket «Venner» fra noen som
har lagt deg til, og motsatt. Brukere må ha satt et brukernavn for å
være søkbare.
</p>
{#if loadError}<p class="error" role="alert">{loadError}</p>{/if}
<form onsubmit={addFriend} style="margin-bottom: 1rem;">
<label for="add-friend-u">Legg til ved brukernavn</label>
<div class="row" style="align-items: stretch;">
<input id="add-friend-u" type="text" bind:value={addUsername}
placeholder="brukernavn" pattern="[a-z0-9_-]*"
autocomplete="off" style="flex: 1;" />
<button class="primary" type="submit" disabled={addBusy}>
{addBusy ? 'Legger til …' : 'Legg til'}
</button>
</div>
{#if addError}<p class="error" role="alert" style="margin-top: 0.5rem;">{addError}</p>{/if}
</form>
{#if loading}
<p class="muted">Laster …</p>
{:else}
<h4 style="margin-bottom: 0.25rem;">Vennene mine ({outgoing.length})</h4>
{#if outgoing.length === 0}
<p class="muted">Du har ikke lagt til noen ennå.</p>
{/if}
{#each outgoing as f (f.user_id)}
<article class="card" style="margin: 0.4rem 0; padding: 0.5rem 0.75rem;">
<div class="row" style="justify-content: space-between;">
<span>
{displayName(f)}
{#if f.username}<span class="muted">· @{f.username}</span>{/if}
</span>
<button class="danger" type="button" onclick={() => removeFriend(f)}>Fjern</button>
</div>
</article>
{/each}
<h4 style="margin: 1rem 0 0.25rem;">Har lagt meg til ({incoming.length})</h4>
{#if incoming.length === 0}
<p class="muted">Ingen har lagt deg til ennå.</p>
{/if}
{#each incoming as f (f.user_id)}
<article class="card" style="margin: 0.4rem 0; padding: 0.5rem 0.75rem;">
<div class="row" style="justify-content: space-between;">
<span>
{displayName(f)}
{#if f.username}<span class="muted">· @{f.username}</span>{/if}
</span>
<button class="danger" type="button" onclick={() => block(f)}>Blokker</button>
</div>
</article>
{/each}
{#if blocked.length}
<h4 style="margin: 1rem 0 0.25rem;">Blokkerte ({blocked.length})</h4>
{#each blocked as b (b.user_id)}
<article class="card" style="margin: 0.4rem 0; padding: 0.5rem 0.75rem;">
<div class="row" style="justify-content: space-between;">
<span>
{displayName(b)}
{#if b.username}<span class="muted">· @{b.username}</span>{/if}
</span>
<button type="button" onclick={() => unblock(b)}>Opphev</button>
</div>
</article>
{/each}
{/if}
{/if}
</section>

View file

@ -114,6 +114,7 @@
: filtered.filter((a) => a.visibility !== 'private' && a.viewer_bookmarked),
);
const myPrivate = $derived(filtered.filter((a) => a.visibility === 'private'));
const friends = $derived(filtered.filter((a) => a.visibility === 'friends'));
const semi = $derived(filtered.filter((a) => a.visibility === 'semi'));
const pub = $derived(filtered.filter((a) => a.visibility === 'public'));
</script>
@ -203,6 +204,19 @@
{/each}
{/if}
{#if friends.length}
<h2>Venner</h2>
{#each friends as a (a.id)}
<ActivityRow
activity={a}
privateCleartext={null}
onDeleted={onDeleted}
onEdit={(act) => (editing = act)}
onChanged={onChanged}
/>
{/each}
{/if}
{#if semi.length}
<h2>Anonyme</h2>
{#each semi as a (a.id)}

View file

@ -21,7 +21,7 @@
serveren faktisk ser av det du skriver.
</p>
<h3>De tre synlighetsnivåene</h3>
<h3>De fire synlighetsnivåene</h3>
<ul>
<li>
<strong>Privat:</strong> oppføringen krypteres i nettleseren din før den
@ -29,6 +29,13 @@
databasen, kan lese innholdet. Bare du kan låse den opp — med passordet
ditt eller gjenopprettingskoden.
</li>
<li>
<strong>Venner:</strong> kun synlig for brukere du har lagt til som
venner. Dette er en <em>tilgangsliste</em>, ikke kryptografi — innholdet
lagres i klartekst hos oss, men vi serverer det bare til de du har valgt.
Det betyr at vi (eller noen med databasetilgang) i prinsippet kan lese
det, til forskjell fra «privat». Velg «privat» om innholdet er sensitivt.
</li>
<li>
<strong>Anonym (halv-offentlig):</strong> innholdet er synlig for alle,
men navnet ditt vises ikke ved siden av. Vi lagrer en intern referanse
@ -42,6 +49,27 @@
</li>
</ul>
<h3>Venner og blokkering</h3>
<p>
Vennskap i Vinterliste er enveis. Du kan legge til en bruker som venn
uten at de må godkjenne — det er din egen «hvem får se mine venner-bare-
oppføringer»-liste. Den du legger til, ser i sin tur at du har lagt dem
til, og kan velge å gjøre det samme andre veien om de vil.
</p>
<p>
Brukere må ha satt opp et brukernavn (URL-vennlig kortform) for å være
søkbare. Vi bruker brukernavnet for å finne hverandre — ikke eposten, som
er en kontaktidentifikator vi ikke deler.
</p>
<p>
Hvis noen har lagt deg til som venn, og du heller vil slippe det, kan du
<strong>blokkere</strong> dem. Blokkering virker symmetrisk på
venner-bare-innhold: når én av dere har blokkert den andre, ser ingen av
dere lenger venner-bare-oppføringer fra den andre. Blokkering påvirker
ikke offentlige eller anonyme oppføringer — det som er åpent for alle, er
åpent for alle.
</p>
<h3>Ende-til-ende-kryptering, kort forklart</h3>
<p>
For private oppføringer skjer all kryptering i nettleseren din. Når du

View file

@ -4,6 +4,7 @@
import { changePassword } from '../lib/auth';
import { session } from '../lib/session.svelte';
import { downloadExport } from '../lib/export';
import FriendsPanel from './FriendsPanel.svelte';
import type { InviteEntry } from '../../../shared/types';
interface Props {
@ -216,6 +217,8 @@
</div>
</form>
<FriendsPanel />
<section class="card" aria-labelledby="exp-h">
<h3 id="exp-h">Eksporter</h3>
<p class="muted">

View file

@ -6,6 +6,7 @@ import type {
PublicListResponse, FeedbackSubmitRequest, FeedbackEntry, FeedbackUpdateRequest,
AdminUser, AdminRoleUpdate,
PublicSettings, SettingsUpdateRequest, InviteEntry,
FriendEntry, BlockEntry, AddByUsernameRequest,
} from '../../../shared/types';
const BASE = '/api';
@ -119,4 +120,17 @@ export const api = {
createInvite: () => http<InviteEntry>('/invites', { method: 'POST' }),
cancelInvite: (token: string) =>
http<{ ok: true }>(`/invites/${encodeURIComponent(token)}`, { method: 'DELETE' }),
// --- friends & blocks -----------------------------------------------------
listFriends: () => http<FriendEntry[]>('/friends'),
listIncomingFriends: () => http<FriendEntry[]>('/friends/incoming'),
addFriend: (body: AddByUsernameRequest) =>
http<FriendEntry>('/friends', { method: 'POST', body: JSON.stringify(body) }),
removeFriend: (userId: string) =>
http<{ ok: true }>(`/friends/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
listBlocks: () => http<BlockEntry[]>('/friends/blocks'),
blockUser: (userId: string) =>
http<BlockEntry>('/friends/blocks', { method: 'POST', body: JSON.stringify({ user_id: userId }) }),
unblockUser: (userId: string) =>
http<{ ok: true }>(`/friends/blocks/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
};

View file

@ -33,7 +33,7 @@ interface NormalisedRow {
tags: string[];
loc_label: string | null;
scheduled_at: number | null;
visibility: 'private' | 'semi' | 'public';
visibility: 'private' | 'semi' | 'public' | 'friends';
}
/** Decrypt a private activity into the same shape as semi/public rows. */

View file

@ -192,6 +192,10 @@ nav.top > .row {
.vis-badge.private { background: rgba(31,111,235,0.15); color: var(--accent); }
.vis-badge.semi { background: rgba(127,127,127,0.18); color: var(--muted); }
.vis-badge.public { background: rgba(46,160,67,0.18); color: #2ea043; }
.vis-badge.friends { background: rgba(241,165,40,0.20); color: #b67100; }
@media (prefers-color-scheme: dark) {
.vis-badge.friends { color: #f1a528; }
}
.error { color: var(--danger); margin-top: 0.5rem; }