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:
parent
79ce7059c1
commit
f39fe9ed65
14 changed files with 657 additions and 8 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
184
frontend/src/components/FriendsPanel.svelte
Normal file
184
frontend/src/components/FriendsPanel.svelte
Normal 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>
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue