atagen

malice

public
Star0
Watch0

Commit dfd1feaae855

tarball

atagen <boss@atagen.co>2026-10-24 11:16 UTC

6 files changed+164-40
web: group members and fix responsive panes
Modified file: web/src/App.svelte+38 additions−22 deletions
@@ -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 <div
17091718 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- <button
2573- 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+ <button
2586+ 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>
Modified file: web/src/app.css+62 additions−6 deletions
@@ -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)); }
Modified file: web/src/lib/MediaAttachmentView.svelte+10 additions−8 deletions
@@ -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">
Modified file: web/src/lib/MessageRow.svelte+2 additions−3 deletions
@@ -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);
Modified file: web/src/lib/permissions.ts+33 additions
@@ -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+}
Modified file: web/test/permissions.test.ts+19 additions−1 deletions
@@ -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+ levels
46+ );
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+});