You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A friend request announces itself once and then vanishes. If you were riding, or the tab was closed, or browser notifications are off, nothing anywhere tells you someone is waiting — you find out the next time you happen to open /friends.
Every other waiting thing has a persistent mark. This one does not.
what
persistent indicator
room unread
a count badge — Sidebar.svelte:256, unreadCount(room.unread)
There is no notification centre in this app, and this issue does not add one. The sidebar is the notification surface, and friend requests are the one announceable event with no home in it.
What #876 built is a one-shot announcement: friendEvent() (friends.svelte.ts:46) returns "X wants to be friends" on the single refresh where pending_in first appears, diffed against the previous list. Correct as far as it goes — it is an announcement, and an announcement is not a state.
Two changes
1. A count on the sidebar's friends link
MESSAGES friends ②
● David Kneubühler
○ Captain-SVXN
The count is friends.list.filter(f => f.status === 'pending_in').length. Use the existing unreadCount() from $lib/messages/unread-marks so the badge looks like every other badge, and the same visual language carries.
+layout.svelte:60 already calls friends.reload() app-wide, so the count is live on every page without new plumbing.
2. /friends separates what needs you from what does not
— one list holding both pending_in (someone is waiting on you) and pending_out (you are waiting on them), rendered below your accepted friends. The two have opposite meanings and only one is actionable.
/friends
┌ wants to be friends ─────────────────┐ ← pending_in, first, its own section
│ Mike Frei [Accept] [Dismiss]│
│ Ana Rüegg [Accept] [Dismiss]│
└──────────────────────────────────────┘
your friends
David Kneubühler · riding
Captain-SVXN
waiting on them ← pending_out, quiet, last
Uelischmueli · asked 2 days ago
Incoming requests move to the top as their own section; outgoing stay quiet at the bottom. Accept and Dismiss already exist (FriendsPanel.svelte:219) — this only regroups and reorders.
The badge counts open requests, not unseen ones
Visiting /friends does not clear it. Accepting or dismissing does.
2 open requests badge ②
you visit /friends badge ② ← still true: they are still waiting
you accept one badge ①
you dismiss other badge gone
This is deliberately not the unread semantics the other badges use, and the difference is the point: seeing a message is the whole job, seeing a request is not. The badge means "this many people are waiting on you", which stays true until they aren't.
Because it is derived from pending_in and never stored, there is no read state to persist — no table, no endpoint, no migration. The count is a $derived over a list the layout already refreshes.
Acceptance criteria
The sidebar's friends link carries a count of incoming requests, styled like the room and DM badges.
The count updates without a reload when a request arrives or is answered.
Visiting /friends does not change the count; accepting or dismissing does.
Zero open requests shows no badge at all — never a 0.
/friends shows incoming requests first, in their own section, above accepted friends; outgoing sit last and quiet.
pending_in and pending_out are never rendered in the same list again.
Vitest: the count derives correctly, ignores pending_out, and the badge disappears at zero.
make ci green.
Verify it for real
The verify skill with two accounts (?as=): send a request from B, confirm A's sidebar badges without a reload and while A is on a page other than /friends. Visit /friends and confirm the badge stays. Accept, and confirm it clears.
Deliberately not in scope, and why
A notification centre. Deferred on purpose. If one is ever built, this badge is one of its inputs, not a thing it replaces.
Room invites. There is no such object: rooms.go:188 — "members only — the code IS the invite". A room invite is a share link, so nothing is addressed to you and nothing is pending. Nothing to badge.
Trophies, medals, category changes, acceptances. These are news, not tasks. They have no action, so "clears when you act" cannot apply to them, and mixing news into a task count is how a badge stops being read. They keep the announcement path A friend request, and its acceptance, announce themselves #876 gave them.
Related
#1017 (moves this badge onto a real nav item), #1016 (the status these rows show), #1011 (the RSVP decision this excludes), #876 (the announcement this makes persistent), #568 (one announcement path, and unread that reads the same everywhere), #824 (the badge rules), ADR-0020 (the sidebar as a place), ux.md.
A friend request announces itself once and then vanishes. If you were riding, or the tab was closed, or browser notifications are off, nothing anywhere tells you someone is waiting — you find out the next time you happen to open
/friends.Every other waiting thing has a persistent mark. This one does not.
Sidebar.svelte:256,unreadCount(room.unread)Sidebar.svelte:393<a href="/friends" class="hover:text-ink ml-auto normal-case">friends</a>(Sidebar.svelte:376)There is no notification centre in this app, and this issue does not add one. The sidebar is the notification surface, and friend requests are the one announceable event with no home in it.
What #876 built is a one-shot announcement:
friendEvent()(friends.svelte.ts:46) returns"X wants to be friends"on the single refresh wherepending_infirst appears, diffed against the previous list. Correct as far as it goes — it is an announcement, and an announcement is not a state.Two changes
1. A count on the sidebar's friends link
The count is
friends.list.filter(f => f.status === 'pending_in').length. Use the existingunreadCount()from$lib/messages/unread-marksso the badge looks like every other badge, and the same visual language carries.+layout.svelte:60already callsfriends.reload()app-wide, so the count is live on every page without new plumbing.2.
/friendsseparates what needs you from what does notFriendsPanel.svelte:111is:— one list holding both
pending_in(someone is waiting on you) andpending_out(you are waiting on them), rendered below your accepted friends. The two have opposite meanings and only one is actionable.Incoming requests move to the top as their own section; outgoing stay quiet at the bottom. Accept and Dismiss already exist (
FriendsPanel.svelte:219) — this only regroups and reorders.The badge counts open requests, not unseen ones
Visiting
/friendsdoes not clear it. Accepting or dismissing does.This is deliberately not the unread semantics the other badges use, and the difference is the point: seeing a message is the whole job, seeing a request is not. The badge means "this many people are waiting on you", which stays true until they aren't.
Because it is derived from
pending_inand never stored, there is no read state to persist — no table, no endpoint, no migration. The count is a$derivedover a list the layout already refreshes.Acceptance criteria
/friendsdoes not change the count; accepting or dismissing does.0./friendsshows incoming requests first, in their own section, above accepted friends; outgoing sit last and quiet.pending_inandpending_outare never rendered in the same list again.pending_out, and the badge disappears at zero.make cigreen.Verify it for real
The
verifyskill with two accounts (?as=): send a request from B, confirm A's sidebar badges without a reload and while A is on a page other than/friends. Visit/friendsand confirm the badge stays. Accept, and confirm it clears.Deliberately not in scope, and why
rooms.go:188— "members only — the code IS the invite". A room invite is a share link, so nothing is addressed to you and nothing is pending. Nothing to badge.session_rsvpsstores only the affirmative, so "hasn't answered" and "said no" are the same row-less state. See docs(sessions): a planned session cannot tell "hasn't answered" from "said no" — should declining be recorded? #1011, which poses the decision.Related
#1017 (moves this badge onto a real nav item), #1016 (the status these rows show), #1011 (the RSVP decision this excludes), #876 (the announcement this makes persistent), #568 (one announcement path, and unread that reads the same everywhere), #824 (the badge rules), ADR-0020 (the sidebar as a place),
ux.md.