Commit dfd1feaae855
tarball6 files changed+164-40
@@ -10,7 +10,7 @@
1010
import { clonePackDestinations, editableChoices, shortcodeFor } from './lib/packs';1111
import type { CommandRequest } from './lib/transport';1212
import UserCard, { type UserAction } from './lib/UserCard.svelte';13
- import { allowed, canRedact, isMuted, parsePowerLevels, type PowerLevels } from './lib/permissions';13
+ import { allowed, canRedact, groupMembers, isMuted, parsePowerLevels, type PowerLevels } from './lib/permissions';1414
import { moveBefore, pinnedFirst, sortSpaces } from './lib/rail-order';1515
import ImageLightbox from './lib/ImageLightbox.svelte';1616
import { lightbox } from './lib/lightbox';@@ -202,6 +202,7 @@
202202
$: items = $selectedRoomId ? ($timelines[$selectedRoomId] ?? []) : [];203203
$: currentStaged = $selectedRoomId ? (staged[$selectedRoomId] ?? []) : [];204204
$: members = $selectedRoomId ? ($membersByRoom[$selectedRoomId] ?? []) : [];205
+ $: memberGroups = groupMembers(members, roomLevels);205206
$: people = peopleIn(items, members);206207
$: mentionCandidates = mentionQuery ? rankPeople(Object.values(people), mentionQuery.query, $sessionUser ?? null) : [];207208
$: if (mentionIndex >= mentionCandidates.length) mentionIndex = 0;@@ -451,6 +452,11 @@
451452
sidebarOpen = true;452453
}453454
455
+ function toggleChannelsPane(): void {456
+ if (window.innerWidth <= 1100) sidebarOpen = !sidebarOpen;457
+ else navFolded.update((v) => !v);458
+ }459
+454460
async function selectSpace(spaceId: string): Promise<void> {455461
if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;456462
stopTyping();@@ -1246,7 +1252,10 @@
12461252
12471253
function toggleMembers(): void {12481254
membersOpen = !membersOpen;1249
- if (membersOpen && $selectedRoomId) void loadMembers($selectedRoomId);1255
+ if (membersOpen && $selectedRoomId) {1256
+ void loadMembers($selectedRoomId);1257
+ void loadLevels($selectedRoomId);1258
+ }12501259
}12511260
12521261
async function join(target: string): Promise<void> {@@ -1707,7 +1716,7 @@
17071716
{:else}17081717
<div17091718
class="app"1710
- class:nav-folded={$navFolded && selectedRoom}1719
+ class:nav-folded={$navFolded && (selectedRoom || sidebarOpen)}17111720
class:rail-folded={$railFolded && selectedRoom}17121721
class:members-open={selectedRoom && membersOpen}17131722
class:resizing@@ -1746,7 +1755,7 @@
17461755
aria-pressed={!$navFolded}17471756
aria-label={$navFolded ? 'Show channels' : 'Hide channels'}17481757
title={$navFolded ? 'Show channels' : 'Hide channels'}1749
- on:click={() => navFolded.update((v) => !v)}1758
+ on:click={toggleChannelsPane}17501759
>17511760
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">17521761
<rect x="3" y="4" width="18" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2" />@@ -1931,7 +1940,7 @@
19311940
{/key}19321941
{/if}19331942
1934
- <aside class="sidebar" class:open={sidebarOpen} aria-label="Rooms">1943
+ <aside class="sidebar" class:open={sidebarOpen} class:forced={sidebarOpen && !selectedRoom} aria-label="Rooms">19351944
<!-- svelte-ignore a11y_no_static_element_interactions -->19361945
<div class="pane-resize right" title="Drag to resize" on:pointerdown={(e) => startResize(e, 'side')} on:dblclick={() => resetWidth('side')}></div>19371946
<div class="side-search">@@ -2567,23 +2576,30 @@
25672576
{/if}25682577
{:else}25692578
<ul class="member-list">2570
- {#each members as member (member.user_id)}2571
- <li>2572
- <button2573
- type="button"2574
- class="member"2575
- on:click={(ev) => {2576
- const rect = ev.currentTarget.getBoundingClientRect();2577
- void openUser(member.user_id, rect.left - 308, rect.top, false);2578
- }}2579
- on:contextmenu|preventDefault={(ev) => void openUser(member.user_id, ev.clientX, ev.clientY, true)}2580
- >2581
- <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} seed={member.user_id} size={28} />2582
- <span class="member-names">2583
- <span class="member-name">{member.display_name || member.name}</span>2584
- <span class="member-id">{member.user_id}</span>2585
- </span>2586
- </button>2579
+ {#each memberGroups as group (group.label)}2580
+ <li class="member-group">2581
+ <h3 class="member-group-label">{group.label}<span>{group.members.length}</span></h3>2582
+ <ul class="member-group-list">2583
+ {#each group.members as member (member.user_id)}2584
+ <li>2585
+ <button2586
+ type="button"2587
+ class="member"2588
+ on:click={(ev) => {2589
+ const rect = ev.currentTarget.getBoundingClientRect();2590
+ void openUser(member.user_id, rect.left - 308, rect.top, false);2591
+ }}2592
+ on:contextmenu|preventDefault={(ev) => void openUser(member.user_id, ev.clientX, ev.clientY, true)}2593
+ >2594
+ <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} seed={member.user_id} size={28} />2595
+ <span class="member-names">2596
+ <span class="member-name">{member.display_name || member.name}</span>2597
+ <span class="member-id">{member.user_id}</span>2598
+ </span>2599
+ </button>2600
+ </li>2601
+ {/each}2602
+ </ul>25872603
</li>25882604
{/each}25892605
</ul>@@ -402,15 +402,28 @@ input, textarea { font: inherit; }
402402
color: var(--text);403403
text-align: left;404404
cursor: pointer;405
+ min-height: var(--ctl-h);405406
}406407
.me-names { min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }407408
.me-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }408409
.account-actions { flex: none; display: flex; gap: 2px; }409410
.app.nav-folded .account { padding-right: 0; }410411
.app.nav-folded .account .me-names, .app.nav-folded .account-actions { display: none; }412
+@media (min-width: 1101px) {413
+ .app.nav-folded > .rail { grid-row: 2 / 4; }414
+ .app.nav-folded > .account {415
+ grid-column: 3;416
+ justify-self: start;417
+ z-index: 2;418
+ padding: 8px 0 8px 12px;419
+ border: none;420
+ background: none;421
+ }422
+ .app.nav-folded .composer { padding-left: 48px; }423
+}411424
.me-avatar {412425
flex: none;413
- width: 30px; height: 30px;426
+ width: 32px; height: 32px;414427
border-radius: 50%;415428
background: var(--accent);416429
color: #fff;@@ -801,10 +814,16 @@ input, textarea { font: inherit; }
801814
display: flex;802815
align-items: center;803816
justify-content: space-between;804
- padding: 12px 12px 8px;817
+ padding: 8px 8px 8px 16px;818
+ border-bottom: 1px solid var(--border);805819
}806820
.members-head h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0; }807
-.member-list { list-style: none; margin: 0; padding: 0 8px 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }821
+.member-list { list-style: none; margin: 0; padding: 8px 8px 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }822
+.member-group { list-style: none; }823
+.member-group + .member-group { margin-top: 10px; }824
+.member-group-label { display: flex; align-items: center; justify-content: space-between; margin: 4px 8px 3px; color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }825
+.member-group-label span { font-size: 10px; font-weight: 500; letter-spacing: 0; }826
+.member-group-list { list-style: none; margin: 0; padding: 0; }808827
.member { width: 100%; display: flex; align-items: center; gap: 9px; padding: 6px 8px; border: none; border-radius: var(--r-md); background: transparent; color: var(--text); font: inherit; text-align: left; cursor: pointer; }809828
.member:hover { background: var(--panel-2); }810829
.member-names { min-width: 0; display: flex; flex-direction: column; }@@ -859,11 +878,24 @@ input, textarea { font: inherit; }
859878
860879
.only-mobile { display: none; }861880
881
+@media (min-width: 1101px) {882
+ .app.nav-folded > .sidebar.forced {883
+ position: fixed;884
+ top: 0;885
+ bottom: 0;886
+ left: var(--rail-w);887
+ width: var(--side-open, 260px);888
+ z-index: 40;889
+ visibility: visible;890
+ box-shadow: 8px 0 32px rgba(0, 0, 0, 0.5);891
+ }892
+}893
+862894
@media (max-width: 1100px) {863895
.app {864
- --rail-w: 60px !important;865
- --side-w: 0px !important;866
- --members-w: 0px !important;896
+ --rail-w: 60px;897
+ --side-w: 0px;898
+ --members-w: 0px;867899
transition: none;868900
}869901
.app > .rail, .app > .sidebar { visibility: visible !important; }@@ -910,6 +942,30 @@ input, textarea { font: inherit; }
910942
.composer { padding: 10px 12px 12px; }911943
}912944
945
+@media (min-width: 641px) and (max-width: 1100px) {946
+ .only-mobile { display: none; }947
+ .only-desktop { display: inline-block; }948
+ .app > .sidebar { left: var(--rail-w); transform: translateX(calc(-100% - var(--rail-w))); }949
+ .app > .sidebar.open { transform: none; }950
+ .scrim { left: var(--rail-w); }951
+ .app > .members {952
+ position: relative;953
+ top: auto;954
+ bottom: auto;955
+ right: auto;956
+ width: auto;957
+ z-index: auto;958
+ box-shadow: none;959
+ transform: none;960
+ transition: visibility 0s linear 0.22s;961
+ }962
+ .app > .members > * { min-width: var(--members-open, 240px); }963
+ .app:not(.members-open) > .members {964
+ visibility: hidden;965
+ transition: visibility 0s linear 0.22s;966
+ }967
+}968
+913969
@media (max-width: 640px) {914970
.app { --rail-w: 52px !important; }915971
.app > .sidebar { left: 52px; transform: translateX(calc(-100% - 52px)); }@@ -57,14 +57,16 @@
5757
on:error={() => (fullFailed = true)}5858
/>5959
</a>60
- <figcaption class="media-caption">61
- {#if attachment.caption && attachment.caption !== attachment.filename}62
- <span>{attachment.caption}</span>63
- {:else}64
- <span>{attachment.filename}</span>65
- {/if}66
- {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}67
- </figcaption>60
+ {#if attachment.kind !== 'Sticker'}61
+ <figcaption class="media-caption">62
+ {#if attachment.caption && attachment.caption !== attachment.filename}63
+ <span>{attachment.caption}</span>64
+ {:else}65
+ <span>{attachment.filename}</span>66
+ {/if}67
+ {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}68
+ </figcaption>69
+ {/if}6870
</figure>6971
{:else if previewable && fullFailed}7072
<div class="media-file">@@ -415,10 +415,9 @@
415415
</div>416416
{/if}417417
{:else if event.content.kind === 'Sticker'}418
- {#if event.content.body}<p class="text"><EmojiText text={event.content.body} /></p>{/if}419418
{#if event.content.media}420419
<MediaAttachmentView attachment={event.content.media} />421
- {/if}420
+ {:else if event.content.body}<p class="text"><EmojiText text={event.content.body} /></p>{/if}422421
{:else if event.content.kind === 'Poll'}423422
<p class="text">📊 {event.content.question}</p>424423
{:else}@@ -529,7 +528,7 @@
529528
.system-line { color: var(--muted); font-size: 12.5px; font-style: italic; margin: 1px 0; }530529
.msg.system { opacity: 0.85; --msg-gap: 1.5px; }531530
.msg.system .avatar-spacer { height: 0; padding-top: 0; }532
- .reactions { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }531
+ .reactions { display: flex; flex-wrap: wrap; gap: 4px; margin-top: max(4px, var(--msg-gap, 3px)); }533532
.reaction {534533
display: inline-flex; align-items: center; gap: 4px;535534
background: var(--bg-soft);@@ -84,3 +84,36 @@ export function roleName(level: number): string | null {
8484
if (level >= 50) return 'Moderator';8585
return null;8686
}87
+88
+export interface GroupedMember {89
+ user_id: string;90
+ name: string;91
+ display_name: string | null;92
+ membership: string | { Custom: string };93
+}94
+95
+export interface MemberGroup<T> {96
+ label: string;97
+ members: T[];98
+}99
+100
+const ROLE_ORDER = ['Creator', 'Admin', 'Moderator', 'Members', 'Invited'];101
+102
+/** Joined members by role, highest level first, then invites. */103
+export function groupMembers<T extends GroupedMember>(members: T[], levels: PowerLevels | null): MemberGroup<T>[] {104
+ const level = (m: T) => (levels ? levelOf(levels, m.user_id) : 0);105
+ const label = (m: T) => (m.membership === 'Invited' ? 'Invited' : (roleName(level(m)) ?? 'Members'));106
+ const name = (m: T) => (m.display_name || m.name || m.user_id).toLowerCase();107
+ const sorted = [...members].sort((a, b) => {108
+ const levelDiff = level(b) - level(a);109
+ if (!Number.isNaN(levelDiff) && levelDiff !== 0) return levelDiff;110
+ return name(a).localeCompare(name(b)) || a.user_id.localeCompare(b.user_id);111
+ });112
+ const groups = new Map<string, T[]>();113
+ for (const m of sorted) {114
+ if (m.membership !== 'Joined' && m.membership !== 'Invited') continue;115
+ const key = label(m);116
+ groups.set(key, [...(groups.get(key) ?? []), m]);117
+ }118
+ return ROLE_ORDER.filter((key) => groups.has(key)).map((key) => ({ label: key, members: groups.get(key)! }));119
+}@@ -1,6 +1,6 @@
11
import assert from 'node:assert/strict';22
import { test } from 'node:test';3
-import { allowed, isMuted, levelOf, parsePowerLevels } from '../src/lib/permissions.ts';3
+import { allowed, groupMembers, isMuted, levelOf, parsePowerLevels } from '../src/lib/permissions.ts';44
55
const levels = parsePowerLevels({66
ban: 50,@@ -37,3 +37,21 @@ test('anyone can delete their own messages but others need the redact level', as
3737
assert.equal(canRedact(levels, '@mod:x', '@someone:x'), true);3838
assert.equal(canRedact(levels, '@muted:x', '@muted:x'), false);3939
});40
+41
+test('members group by role, highest first, invites last', () => {42
+ const m = (user_id: string, name: string, membership = 'Joined') => ({ user_id, name, display_name: null, membership });43
+ const groups = groupMembers(44
+ [m('@someone:x', 'zed'), m('@invitee:x', 'ivy', 'Invited'), m('@mod:x', 'mod'), m('@creator:x', 'cr'), m('@admin:x', 'ad'), m('@b:x', 'Abe'), m('@gone:x', 'gone', 'Left')],45
+ levels46
+ );47
+ assert.deepEqual(48
+ groups.map((g) => [g.label, g.members.map((x) => x.user_id)]),49
+ [50
+ ['Creator', ['@creator:x']],51
+ ['Admin', ['@admin:x']],52
+ ['Moderator', ['@mod:x']],53
+ ['Members', ['@b:x', '@someone:x']],54
+ ['Invited', ['@invitee:x']]55
+ ]56
+ );57
+});