Commit bcf2234c33f6
tarball4 files changed+95-2
@@ -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
<button20442050
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">@@ -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 ——— */@@ -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) => boolean19
+): 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.direct25
+ ? room.notification_mode === 'MentionsAndKeywordsOnly' ? room.unread_mentions : room.unread_messages + room.unread_mentions26
+ : 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
+}@@ -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
+ ...extra20
+ } 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
+});