atagen

malice

public
Star0
Watch0

Commit bcf2234c33f6

tarball

atagen <boss@atagen.co>2026-10-27 03:55 UTC

4 files changed+95-2
web: badge the folded spaces and channels toggles for DMs and mentions

With both panes folded, a DM or ping elsewhere gave no sign at all. Each unread DM or mention counts on the toggle that reaches its room in the fewest clicks, channels when the room is in the shown list, spaces otherwise.
Modified file: web/src/App.svelte+11 additions−2 deletions
@@ -1,6 +1,6 @@
11 <script lang="ts">
22 import { onMount, tick } from 'svelte';
3- import { fade, fly } from 'svelte/transition';
3+ import { fade, fly, scale } from 'svelte/transition';
44 import LoginPanel from './lib/LoginPanel.svelte';
55 import MentionsInbox from './lib/MentionsInbox.svelte';
66 import MutedIcon from './lib/MutedIcon.svelte';
@@ -104,6 +104,7 @@
104104 import { disablePush } from './lib/push';
105105 import { setRoomMode, setSpaceMuted, spaceMutedRooms, startMuteSync } from './lib/mute';
106106 import { isRoomMuted, mutingSpace, type MutedSpaces } from './lib/mute-model';
107+ import { paneBadges } from './lib/pane-badges';
107108 import { loadMutedSpaces, mutedSpaces } from './lib/muted-spaces';
108109 import {
109110 linkPreviews,
@@ -245,6 +246,8 @@
245246 $: groupRooms = pinnedFirst(visibleRooms.filter((r) => !r.direct && !r.space && r.membership !== 'Invited'));
246247 $: spaceList = pinnedFirst(visibleRooms.filter((r) => r.space && r.membership !== 'Invited'));
247248 $: channelRooms = baseRooms.filter((r) => !r.space && r.membership !== 'Invited');
249+ $: railShut = $railFolded && !!(selectedRoom || mentionsOpen || searchOpen);
250+ $: paneBadge = paneBadges($rooms, new Set(channelRooms.map((r) => r.room_id)), $selectedRoomId, (r) => isRoomMuted(r, $spaceMutedRooms));
248251 $: if ($sessionUser) {
249252 loadDismissals($sessionUser);
250253 void protectStorage($sessionUser);
@@ -2006,7 +2009,7 @@
20062009 class="app"
20072010 in:fade={{ duration: motionDuration(220) }}
20082011 class:nav-folded={$navFolded}
2009- class:rail-folded={$railFolded && (selectedRoom || mentionsOpen || searchOpen)}
2012+ class:rail-folded={railShut}
20102013 class:members-open={membersShown}
20112014 class:thread-open={threadShown}
20122015 class:resizing
@@ -2039,6 +2042,9 @@
20392042 <circle cx="7" cy="13" r="1.4" fill="currentColor" />
20402043 <circle cx="7" cy="17" r="1.4" fill="currentColor" />
20412044 </svg>
2045+ {#if railShut && paneBadge.rail.count > 0}
2046+ <span class="badge pane-badge" class:mention={paneBadge.rail.mention} transition:scale={{ duration: motionDuration(140) }}>{paneBadge.rail.count > 99 ? '99+' : paneBadge.rail.count}</span>
2047+ {/if}
20422048 </button>
20432049 <button
20442050 type="button"
@@ -2052,6 +2058,9 @@
20522058 <rect x="3" y="4" width="18" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
20532059 <path d="M10 4v16" stroke="currentColor" stroke-width="2" />
20542060 </svg>
2061+ {#if $navFolded && paneBadge.channels.count > 0}
2062+ <span class="badge pane-badge" class:mention={paneBadge.channels.mention} transition:scale={{ duration: motionDuration(140) }}>{paneBadge.channels.count > 99 ? '99+' : paneBadge.channels.count}</span>
2063+ {/if}
20552064 </button>
20562065 {#if selectedRoom}
20572066 <div class="head-title">
Modified file: web/src/app.css+2 additions
@@ -527,6 +527,8 @@ input, textarea { font: inherit; }
527527 .icon-btn:hover:not(:disabled) { background: var(--panel-2); color: var(--text); }
528528 .icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
529529 .icon-btn:disabled { opacity: 0.4; cursor: default; }
530+.pane-toggle { position: relative; }
531+.pane-badge { position: absolute; top: -4px; right: -6px; min-width: 16px; height: 16px; padding: 0 4px; font-size: 10px; pointer-events: none; }
530532 .scrim { display: none; }
531533
532534 /* ——— Chat ——— */
Added file: web/src/lib/pane-badges.ts+33 additions
@@ -0,0 +1,33 @@
1+import type { RoomSummary } from './types';
2+
3+export interface PaneBadge {
4+ count: number;
5+ mention: boolean;
6+}
7+
8+export interface PaneBadges {
9+ rail: PaneBadge;
10+ channels: PaneBadge;
11+}
12+
13+/** DMs and mentions, each counted on whichever folded pane reaches its room in the fewest clicks. */
14+export function paneBadges(
15+ rooms: RoomSummary[],
16+ channelIds: Set<string>,
17+ openRoomId: string | null,
18+ muted: (room: RoomSummary) => boolean
19+): PaneBadges {
20+ const rail = { count: 0, mention: false };
21+ const channels = { count: 0, mention: false };
22+ for (const room of rooms) {
23+ if (room.space || room.membership !== 'Joined' || room.room_id === openRoomId || muted(room)) continue;
24+ const count = room.direct
25+ ? room.notification_mode === 'MentionsAndKeywordsOnly' ? room.unread_mentions : room.unread_messages + room.unread_mentions
26+ : room.unread_mentions;
27+ if (count === 0) continue;
28+ const badge = channelIds.has(room.room_id) ? channels : rail;
29+ badge.count += count;
30+ badge.mention ||= room.unread_mentions > 0;
31+ }
32+ return { rail, channels };
33+}
Added file: web/test/pane-badges.test.ts+49 additions
@@ -0,0 +1,49 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { paneBadges } from '../src/lib/pane-badges.ts';
4+import type { RoomSummary } from '../src/lib/types.ts';
5+
6+function room(id: string, extra: Partial<RoomSummary> = {}): RoomSummary {
7+ return {
8+ room_id: id,
9+ name: id,
10+ membership: 'Joined',
11+ space: false,
12+ direct: false,
13+ unread_messages: 0,
14+ unread_mentions: 0,
15+ notification_mode: 'AllMessages',
16+ notification_mode_custom: false,
17+ parent_spaces: [],
18+ children: [],
19+ ...extra
20+ } as RoomSummary;
21+}
22+
23+const isMuted = (r: RoomSummary) => r.notification_mode === 'Mute';
24+
25+const rooms = [
26+ room('!a', { unread_messages: 5, unread_mentions: 1 }),
27+ room('!b', { unread_messages: 4, unread_mentions: 2 }),
28+ room('!quiet', { unread_messages: 9 }),
29+ room('!dm', { direct: true, unread_messages: 3 }),
30+ room('!muted', { unread_mentions: 4, notification_mode: 'Mute' }),
31+ room('!left', { unread_mentions: 4, membership: 'Left' })
32+];
33+
34+test('mentions in the shown space go on channels, others and DMs on the rail', () => {
35+ const badges = paneBadges(rooms, new Set(['!a', '!quiet']), null, isMuted);
36+ assert.deepEqual(badges.channels, { count: 1, mention: true });
37+ assert.deepEqual(badges.rail, { count: 5, mention: true });
38+});
39+
40+test('home lists every room, so a DM goes on channels', () => {
41+ const badges = paneBadges(rooms, new Set(rooms.map((r) => r.room_id)), null, isMuted);
42+ assert.deepEqual(badges.channels, { count: 6, mention: true });
43+ assert.deepEqual(badges.rail, { count: 0, mention: false });
44+});
45+
46+test('the open room, muted rooms and plain unread channels count for nothing', () => {
47+ const badges = paneBadges(rooms, new Set(), '!b', (r) => isMuted(r) || r.room_id === '!a');
48+ assert.deepEqual(badges.rail, { count: 3, mention: false });
49+});