atagen

malice

public
Star0
Watch0

Commit d8624c214c04

tarball

atagen <boss@atagen.co>2026-10-24 08:27 UTC

10 files changed+649-42
web: add user cards, context menus for people and rooms, and peek

The card and menus read room power levels so they only offer actions the spec would let you take, since you can act only on users strictly below your own level and creators outrank everyone in v12 rooms. Peek opens a room while holding back read receipts and typing notifications until you send something there.
Modified file: crates/malice-core/src/request.rs+9 additions
@@ -224,6 +224,9 @@ pub enum Request {
224224 Ignore {
225225 user_id: String,
226226 },
227+ PowerLevels {
228+ room_id: String,
229+ },
227230 Unignore {
228231 user_id: String,
229232 },
@@ -717,6 +720,12 @@ pub async fn dispatch(
717720 .await?;
718721 Ok(Reply::Done)
719722 }
723+ Request::PowerLevels { room_id } => {
724+ let levels = handle.room_power_levels(&parse_room_id(&room_id)?).await?;
725+ let value = serde_json::to_value(levels)
726+ .map_err(|_| CoreError::bad_request("power levels are not valid json"))?;
727+ Ok(Reply::Json(value))
728+ }
720729 Request::Ignore { user_id } => {
721730 handle.ignore_user(parse_user_id(&user_id)?).await?;
722731 Ok(Reply::Done)
Modified file: crates/malice-server/src/routes.rs+2 additions−1 deletions
@@ -519,7 +519,7 @@ async fn rooms_upload(
519519 }
520520
521521 /// Ops added after the per-route handlers, taken as the core request JSON. Anything
522-/// carrying bytes or reading state stays on its own route.
522+/// carrying bytes stays on its own route.
523523 async fn post_command(
524524 State(state): State<AppState>,
525525 headers: HeaderMap,
@@ -548,6 +548,7 @@ async fn post_command(
548548 | Request::Mute { .. }
549549 | Request::Ignore { .. }
550550 | Request::Unignore { .. }
551+ | Request::PowerLevels { .. }
551552 ) {
552553 return ApiError::bad_request("op is not available as a command").into_response();
553554 }
Modified file: web/src/App.svelte+198 additions−24 deletions
@@ -4,6 +4,9 @@
44 import MessageRow from './lib/MessageRow.svelte';
55 import MxcAvatar from './lib/MxcAvatar.svelte';
66 import PinButton from './lib/PinButton.svelte';
7+ import ContextMenu, { type MenuItem } from './lib/ContextMenu.svelte';
8+ import UserCard, { type UserAction } from './lib/UserCard.svelte';
9+ import { allowed, isMuted, parsePowerLevels, type PowerLevels } from './lib/permissions';
710 import { moveBefore, pinnedFirst, sortSpaces } from './lib/rail-order';
811 import ImageLightbox from './lib/ImageLightbox.svelte';
912 import { lightbox } from './lib/lightbox';
@@ -453,7 +456,8 @@
453456 }
454457 }
455458
456- async function selectRoom(roomId: string): Promise<void> {
459+ async function selectRoom(roomId: string, peek = false): Promise<void> {
460+ peeking = peek ? roomId : null;
457461 if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;
458462 if (roomId === $selectedRoomId && openReady[roomId]) {
459463 sidebarOpen = false;
@@ -570,7 +574,7 @@
570574
571575 async function sendReadReceipt(): Promise<void> {
572576 const roomId = $selectedRoomId;
573- if (!roomId) return;
577+ if (!roomId || roomId === peeking) return;
574578 const list = $timelines[roomId] ?? [];
575579 for (let i = list.length - 1; i >= 0; i -= 1) {
576580 const entry = list[i];
@@ -642,6 +646,7 @@
642646 staged = rest;
643647 void sendAttachments(roomId, text, reply, mentions, attachments);
644648 }
649+ if (peeking === roomId) stopPeeking();
645650 composerEl?.focus();
646651 }
647652
@@ -901,7 +906,7 @@
901906 typedRoom = roomId;
902907 if (!roomId || !ready[roomId] || !text.trim()) {
903908 stopTyping();
904- } else if (edited) {
909+ } else if (edited && roomId !== peeking) {
905910 notifyTyping(roomId);
906911 }
907912 }
@@ -1115,8 +1120,7 @@
11151120 }
11161121 }
11171122
1118- async function leave(): Promise<void> {
1119- const roomId = $selectedRoomId;
1123+ async function leave(roomId: string | null = $selectedRoomId): Promise<void> {
11201124 if (!roomId || leaveBusy) return;
11211125 leaveBusy = true;
11221126 stopTyping();
@@ -1143,11 +1147,149 @@
11431147
11441148 let dragSpaceId: string | null = null;
11451149 let dropTarget: { id: string; after: boolean } | null = null;
1146- let railMenu: { room: RoomSummary; x: number; y: number } | null = null;
1147- let railMenuEl: HTMLDivElement | null = null;
1150+ let menu: { items: MenuItem[]; x: number; y: number } | null = null;
1151+ let userCard: { userId: string; x: number; y: number; confirm: 'kick' | 'ban' | null } | null = null;
1152+ let levelsByRoom: Record<string, PowerLevels | null> = {};
1153+ let peeking: string | null = null;
1154+
1155+ $: roomLevels = $selectedRoomId ? (levelsByRoom[$selectedRoomId] ?? null) : null;
1156+
1157+ function personFor(userId: string): Person {
1158+ return people[userId] ?? { user_id: userId, name: userId, avatar: avatarByUser[userId] ?? null };
1159+ }
1160+
1161+ function memberFor(userId: string): MemberSummary | null {
1162+ return members.find((m) => m.user_id === userId) ?? null;
1163+ }
1164+
1165+ async function loadLevels(roomId: string): Promise<void> {
1166+ try {
1167+ const levels = parsePowerLevels(await runCommand({ op: 'power_levels', room_id: roomId }));
1168+ levelsByRoom = { ...levelsByRoom, [roomId]: levels };
1169+ } catch {
1170+ levelsByRoom = { ...levelsByRoom, [roomId]: levelsByRoom[roomId] ?? null };
1171+ }
1172+ }
1173+
1174+ async function openUser(userId: string, x: number, y: number, asMenu: boolean): Promise<void> {
1175+ const roomId = $selectedRoomId;
1176+ if (roomId && !(roomId in levelsByRoom)) await loadLevels(roomId);
1177+ else if (roomId) void loadLevels(roomId);
1178+ if (asMenu) menu = { items: userMenuItems(userId, x, y), x, y };
1179+ else userCard = { userId, x, y, confirm: null };
1180+ }
1181+
1182+ function copyText(text: string): void {
1183+ navigator.clipboard.writeText(text).catch(() => pushBanner('Could not copy to the clipboard'));
1184+ }
1185+
1186+ function userMenuItems(userId: string, x: number, y: number): MenuItem[] {
1187+ const own = userId === $sessionUser;
1188+ const member = memberFor(userId);
1189+ const items: MenuItem[] = [];
1190+ if (!own) items.push({ label: 'Message', action: () => void userAction(userId, { kind: 'message' }) });
1191+ items.push({ label: 'Mention', action: () => void userAction(userId, { kind: 'mention' }) });
1192+ items.push({ label: 'Copy user ID', action: () => copyText(userId) });
1193+ if (roomLevels && $sessionUser) {
1194+ const can = allowed(roomLevels, $sessionUser, userId);
1195+ const banned = member?.membership === 'Ban';
1196+ if (can.setLevel && !own) {
1197+ const muted = isMuted(roomLevels, userId);
1198+ items.push({ label: muted ? 'Unmute' : 'Mute', action: () => void userAction(userId, { kind: muted ? 'unmute' : 'mute' }) });
1199+ }
1200+ if (can.kick && !banned) items.push({ label: 'Kick…', danger: true, action: () => (userCard = { userId, x, y, confirm: 'kick' }) });
1201+ if (can.ban && banned) items.push({ label: 'Unban', action: () => void userAction(userId, { kind: 'unban' }) });
1202+ if (can.ban && !banned) items.push({ label: 'Ban…', danger: true, action: () => (userCard = { userId, x, y, confirm: 'ban' }) });
1203+ if (can.setLevel) items.push({ label: 'Power level…', action: () => (userCard = { userId, x, y, confirm: null }) });
1204+ }
1205+ if (!own) {
1206+ const kind = member?.ignored ? 'unignore' : 'ignore';
1207+ items.push({ label: member?.ignored ? 'Unignore' : 'Ignore', action: () => void userAction(userId, { kind }) });
1208+ }
1209+ return items;
1210+ }
1211+
1212+ async function insertMention(person: Person): Promise<void> {
1213+ const roomId = $selectedRoomId;
1214+ if (!roomId) return;
1215+ const lead = composer && !/\s$/.test(composer) ? ' ' : '';
1216+ composer = `${composer}${lead}${person.name} `;
1217+ pickedMentions = {
1218+ ...pickedMentions,
1219+ [roomId]: [...(pickedMentions[roomId] ?? []), { user_id: person.user_id, text: person.name }]
1220+ };
1221+ await tick();
1222+ composerEl?.focus();
1223+ composerEl?.setSelectionRange(composer.length, composer.length);
1224+ }
1225+
1226+ async function userAction(userId: string, action: UserAction): Promise<void> {
1227+ const roomId = $selectedRoomId;
1228+ try {
1229+ switch (action.kind) {
1230+ case 'message': {
1231+ const res = (await runCommand({ op: 'direct', user_id: userId })) as { room_id: string };
1232+ await refreshRooms();
1233+ await selectRoom(res.room_id);
1234+ return;
1235+ }
1236+ case 'mention':
1237+ await insertMention(personFor(userId));
1238+ return;
1239+ case 'ignore':
1240+ case 'unignore':
1241+ await runCommand({ op: action.kind, user_id: userId });
1242+ if (roomId) void loadMembers(roomId);
1243+ return;
1244+ }
1245+ if (!roomId) return;
1246+ switch (action.kind) {
1247+ case 'kick':
1248+ case 'ban':
1249+ await runCommand({ op: action.kind, room_id: roomId, user_id: userId, reason: action.reason });
1250+ break;
1251+ case 'unban':
1252+ await runCommand({ op: 'unban', room_id: roomId, user_id: userId, reason: null });
1253+ break;
1254+ case 'mute':
1255+ case 'unmute':
1256+ await runCommand({ op: 'mute', room_id: roomId, user_id: userId, muted: action.kind === 'mute' });
1257+ break;
1258+ case 'power':
1259+ await runCommand({ op: 'set_power_level', room_id: roomId, user_id: userId, level: action.level });
1260+ break;
1261+ }
1262+ void loadLevels(roomId);
1263+ void loadMembers(roomId);
1264+ } catch (e) {
1265+ pushBanner(e instanceof Error ? e.message : 'That action failed');
1266+ }
1267+ }
1268+
1269+ function roomMenuItems(room: RoomSummary): MenuItem[] {
1270+ const items: MenuItem[] = room.space
1271+ ? [{ label: 'Open', action: () => void selectSpace(room.room_id) }]
1272+ : [
1273+ { label: 'Open', action: () => void selectRoom(room.room_id) },
1274+ { label: 'Peek', action: () => void selectRoom(room.room_id, true) }
1275+ ];
1276+ items.push({ label: room.favourite ? 'Unpin' : room.space ? 'Pin to top' : 'Pin', action: () => void togglePin(room) });
1277+ items.push({ label: 'Copy link', action: () => copyText(`https://matrix.to/#/${room.canonical_alias ?? room.room_id}`) });
1278+ items.push({ label: 'Copy room ID', action: () => copyText(room.room_id) });
1279+ if (room.membership === 'Joined') items.push({ label: 'Leave', danger: true, action: () => void leave(room.room_id) });
1280+ return items;
1281+ }
1282+
1283+ function openRoomMenu(ev: MouseEvent, room: RoomSummary): void {
1284+ menu = { items: roomMenuItems(room), x: ev.clientX, y: ev.clientY };
1285+ }
1286+
1287+ function stopPeeking(): void {
1288+ peeking = null;
1289+ queueReadReceipt();
1290+ }
11481291
11491292 async function togglePin(room: RoomSummary): Promise<void> {
1150- railMenu = null;
11511293 try {
11521294 await runCommand({ op: 'set_favourite', room_id: room.room_id, favourite: !room.favourite });
11531295 } catch (e) {
@@ -1200,13 +1342,11 @@
12001342
12011343 function closeRoomMenuOutside(ev: PointerEvent): void {
12021344 if (roomMenuOpen && !roomMenuEl?.contains(ev.target as Node)) roomMenuOpen = false;
1203- if (railMenu && !railMenuEl?.contains(ev.target as Node)) railMenu = null;
12041345 }
12051346
12061347 function onKeydown(ev: KeyboardEvent): void {
12071348 if (ev.key === 'Escape') {
12081349 if (roomMenuOpen) roomMenuOpen = false;
1209- else if (railMenu) railMenu = null;
12101350 else if (dialog) dialog = null;
12111351 else if (membersOpen && window.innerWidth < 1100) membersOpen = false;
12121352 else if (sidebarOpen) sidebarOpen = false;
@@ -1270,7 +1410,7 @@
12701410 aria-pressed={selectedSpaceId === space.room_id}
12711411 draggable="true"
12721412 on:click={() => void selectSpace(space.room_id)}
1273- on:contextmenu|preventDefault={(ev) => (railMenu = { room: space, x: ev.clientX, y: ev.clientY })}
1413+ on:contextmenu|preventDefault={(ev) => openRoomMenu(ev, space)}
12741414 on:dragstart={(ev) => startRailDrag(ev, space.room_id)}
12751415 on:dragover={(ev) => overRailSpace(ev, space)}
12761416 on:dragleave={() => dropTarget?.id === space.room_id && (dropTarget = null)}
@@ -1293,13 +1433,24 @@
12931433 </button>
12941434 </nav>
12951435
1296- {#if railMenu}
1297- {@const menuRoom = railMenu.room}
1298- <div class="room-menu rail-menu" role="menu" style={`left:${railMenu.x}px;top:${railMenu.y}px`} bind:this={railMenuEl}>
1299- <button type="button" role="menuitem" on:click={() => void togglePin(menuRoom)}>
1300- {menuRoom.favourite ? 'Unpin' : 'Pin to top'}
1301- </button>
1302- </div>
1436+ {#if menu}
1437+ <ContextMenu items={menu.items} x={menu.x} y={menu.y} onClose={() => (menu = null)} />
1438+ {/if}
1439+ {#if userCard}
1440+ {#key userCard}
1441+ {@const cardUser = userCard.userId}
1442+ <UserCard
1443+ person={personFor(cardUser)}
1444+ member={memberFor(cardUser)}
1445+ levels={roomLevels}
1446+ self={$sessionUser ?? null}
1447+ x={userCard.x}
1448+ y={userCard.y}
1449+ confirm={userCard.confirm}
1450+ onAction={(action) => void userAction(cardUser, action)}
1451+ onClose={() => (userCard = null)}
1452+ />
1453+ {/key}
13031454 {/if}
13041455
13051456 <aside class="sidebar" class:open={sidebarOpen} aria-label="Rooms">
@@ -1383,6 +1534,7 @@
13831534 class="room-row"
13841535 class:active={$selectedRoomId === room.room_id}
13851536 on:click={() => void selectRoom(room.room_id)}
1537+ on:contextmenu|preventDefault={(ev) => openRoomMenu(ev, room)}
13861538 >
13871539 <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
13881540 <span class="room-name">{room.name}</span>
@@ -1418,6 +1570,7 @@
14181570 class:active={$selectedRoomId === room.room_id}
14191571 aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
14201572 on:click={() => void selectRoom(room.room_id)}
1573+ on:contextmenu|preventDefault={(ev) => openRoomMenu(ev, room)}
14211574 >
14221575 <MxcAvatar mxc={room.avatar} label={room.name} size={26} />
14231576 <span class="room-name">{room.name}</span>
@@ -1448,6 +1601,7 @@
14481601 class:active={$selectedRoomId === room.room_id}
14491602 aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
14501603 on:click={() => void selectRoom(room.room_id)}
1604+ on:contextmenu|preventDefault={(ev) => openRoomMenu(ev, room)}
14511605 >
14521606 <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
14531607 <span class="room-hash" aria-hidden="true">#</span>
@@ -1479,6 +1633,7 @@
14791633 class:active={selectedSpaceId === room.room_id}
14801634 aria-label={`Show channels in space ${room.name}`}
14811635 on:click={() => void selectSpace(room.room_id)}
1636+ on:contextmenu|preventDefault={(ev) => openRoomMenu(ev, room)}
14821637 >
14831638 <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
14841639 <span class="room-hash" aria-hidden="true">◇</span>
@@ -1629,6 +1784,14 @@
16291784 </div>
16301785 </div>
16311786 <div class="head-actions">
1787+ {#if peeking === selectedRoom.room_id}
1788+ <button type="button" class="icon-btn" aria-pressed="true" aria-label="Stop peeking" title="Stop peeking" on:click={stopPeeking}>
1789+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
1790+ <path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12z" fill="none" stroke="currentColor" stroke-width="2" />
1791+ <circle cx="12" cy="12" r="3" fill="currentColor" />
1792+ </svg>
1793+ </button>
1794+ {/if}
16321795 {#if selectedRoom.membership !== 'Joined'}
16331796 <button
16341797 type="button"
@@ -1768,6 +1931,7 @@
17681931 onQuickChange={(list) => void changeQuickReactions(list)}
17691932 {people}
17701933 self={$sessionUser ?? null}
1934+ onUser={(userId, x, y, asMenu) => void openUser(userId, x, y, asMenu)}
17711935 onReply={selectedRoom.membership === 'Joined' ? startReply : null}
17721936 onJump={jumpTo}
17731937 onEdit={selectedRoom.membership === 'Joined' ? startEdit : null}
@@ -1963,12 +2127,22 @@
19632127 {:else}
19642128 <ul class="member-list">
19652129 {#each members as member (member.user_id)}
1966- <li class="member">
1967- <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} size={30} />
1968- <span class="member-names">
1969- <span class="member-name">{member.display_name || member.name}</span>
1970- <span class="member-id">{member.user_id}</span>
1971- </span>
2130+ <li>
2131+ <button
2132+ type="button"
2133+ class="member"
2134+ on:click={(ev) => {
2135+ const rect = ev.currentTarget.getBoundingClientRect();
2136+ void openUser(member.user_id, rect.left - 308, rect.top, false);
2137+ }}
2138+ on:contextmenu|preventDefault={(ev) => void openUser(member.user_id, ev.clientX, ev.clientY, true)}
2139+ >
2140+ <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} size={30} />
2141+ <span class="member-names">
2142+ <span class="member-name">{member.display_name || member.name}</span>
2143+ <span class="member-id">{member.user_id}</span>
2144+ </span>
2145+ </button>
19722146 </li>
19732147 {/each}
19742148 </ul>
Modified file: web/src/app.css+1 additions−2 deletions
@@ -291,7 +291,6 @@ input, textarea { font: inherit; }
291291 .room-item > .room-row { flex: 1; min-width: 0; }
292292 .rail-space.drop-before { box-shadow: 0 -6px 0 -3px var(--accent); }
293293 .rail-space.drop-after { box-shadow: 0 6px 0 -3px var(--accent); }
294-.rail-menu { position: fixed; top: 0; right: auto; z-index: 70; }
295294 .room-row.active { background: color-mix(in srgb, var(--accent) 20%, transparent); }
296295 .room-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
297296 .room-hash { color: var(--muted); }
@@ -684,7 +683,7 @@ input, textarea { font: inherit; }
684683 }
685684 .members-head h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0; }
686685 .member-list { list-style: none; margin: 0; padding: 0 8px 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
687-.member { display: flex; align-items: center; gap: 9px; padding: 6px 8px; border-radius: 8px; }
686+.member { width: 100%; display: flex; align-items: center; gap: 9px; padding: 6px 8px; border: none; border-radius: 8px; background: transparent; color: var(--text); font: inherit; text-align: left; cursor: pointer; }
688687 .member:hover { background: var(--panel-2); }
689688 .member-names { min-width: 0; display: flex; flex-direction: column; }
690689 .member-name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
Added file: web/src/lib/ContextMenu.svelte+86 additions
@@ -0,0 +1,86 @@
1+<script lang="ts" context="module">
2+ export interface MenuItem {
3+ label: string;
4+ action: () => void;
5+ danger?: boolean;
6+ disabled?: boolean;
7+ }
8+</script>
9+
10+<script lang="ts">
11+ import { onMount, tick } from 'svelte';
12+
13+ export let items: MenuItem[];
14+ export let x: number;
15+ export let y: number;
16+ export let onClose: () => void;
17+
18+ const MARGIN = 8;
19+
20+ let menu: HTMLDivElement;
21+ let left = x;
22+ let top = y;
23+
24+ onMount(async () => {
25+ await tick();
26+ const rect = menu.getBoundingClientRect();
27+ left = Math.max(MARGIN, Math.min(x, window.innerWidth - rect.width - MARGIN));
28+ top = Math.max(MARGIN, Math.min(y, window.innerHeight - rect.height - MARGIN));
29+ menu.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
30+ });
31+
32+ function pick(item: MenuItem): void {
33+ onClose();
34+ item.action();
35+ }
36+
37+ function outside(ev: PointerEvent): void {
38+ if (!menu.contains(ev.target as Node)) onClose();
39+ }
40+
41+ function key(ev: KeyboardEvent): void {
42+ if (ev.key === 'Escape') {
43+ ev.stopPropagation();
44+ onClose();
45+ }
46+ }
47+</script>
48+
49+<svelte:window on:pointerdown|capture={outside} on:keydown|capture={key} on:resize={onClose} on:blur={onClose} />
50+
51+<div class="context-menu" role="menu" style={`left:${left}px;top:${top}px`} bind:this={menu}>
52+ {#each items as item}
53+ <button type="button" role="menuitem" class:danger={item.danger} disabled={item.disabled} on:click={() => pick(item)}>
54+ {item.label}
55+ </button>
56+ {/each}
57+</div>
58+
59+<style>
60+ .context-menu {
61+ position: fixed;
62+ z-index: 90;
63+ min-width: 180px;
64+ display: flex;
65+ flex-direction: column;
66+ padding: 4px;
67+ background: var(--panel-2);
68+ border: 1px solid var(--border);
69+ border-radius: 8px;
70+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
71+ }
72+ button {
73+ background: transparent;
74+ border: none;
75+ border-radius: 6px;
76+ color: var(--text);
77+ font: inherit;
78+ font-size: 13.5px;
79+ text-align: left;
80+ padding: 7px 10px;
81+ cursor: pointer;
82+ }
83+ button:hover:not(:disabled), button:focus-visible { background: var(--hover); outline: none; }
84+ button:disabled { opacity: 0.5; cursor: default; }
85+ .danger { color: var(--danger); }
86+</style>
Modified file: web/src/lib/MessageRow.svelte+34 additions−14 deletions
@@ -31,6 +31,13 @@
3131 export let editing = false;
3232 export let showSent = true;
3333 export let self: string | null = null;
34+ export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
35+
36+ function openUser(ev: MouseEvent, menu: boolean): void {
37+ if (!onUser) return;
38+ const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
39+ onUser(event.sender.user_id, menu ? ev.clientX : rect.left, menu ? ev.clientY : rect.bottom + 4, menu);
40+ }
3441
3542 const PICKER_WIDTH = 320;
3643 const PICKER_HEIGHT = 380;
@@ -293,17 +300,22 @@
293300 {/if}
294301 {/if}
295302 {#if showSender}
296- {#if senderAvatar}
297- <MxcAvatar mxc={senderAvatar} label={name} size={34} />
298- {:else}
299- <div
300- class="avatar"
301- aria-hidden="true"
302- style={`background:hsl(${senderHue(event.sender.user_id)} 35% 32%)`}
303- >
304- {initialsOf(name)}
305- </div>
306- {/if}
303+ <button
304+ type="button"
305+ class="user-hit"
306+ tabindex="-1"
307+ aria-hidden="true"
308+ on:click|stopPropagation={(ev) => openUser(ev, false)}
309+ on:contextmenu|preventDefault|stopPropagation={(ev) => openUser(ev, true)}
310+ >
311+ {#if senderAvatar}
312+ <MxcAvatar mxc={senderAvatar} label={name} size={34} />
313+ {:else}
314+ <span class="avatar" style={`background:hsl(${senderHue(event.sender.user_id)} 35% 32%)`}>
315+ {initialsOf(name)}
316+ </span>
317+ {/if}
318+ </button>
307319 {:else}
308320 <div class="avatar-spacer" aria-hidden="true">
309321 <span class="hover-time">{formatClock(event.timestamp_ms)}</span>
@@ -312,9 +324,15 @@
312324 <div class="msg-body">
313325 {#if showSender}
314326 <div class="msg-head">
315- <span class="sender" style={`color:hsl(${senderHue(event.sender.user_id)} 70% var(--name-lightness))`}>
327+ <button
328+ type="button"
329+ class="sender"
330+ style={`color:hsl(${senderHue(event.sender.user_id)} 70% var(--name-lightness))`}
331+ on:click|stopPropagation={(ev) => openUser(ev, false)}
332+ on:contextmenu|preventDefault|stopPropagation={(ev) => openUser(ev, true)}
333+ >
316334 {displayName}
317- </span>
335+ </button>
318336 <time class="time" datetime={new Date(event.timestamp_ms).toISOString()}>
319337 {formatDay(event.timestamp_ms)} {formatClock(event.timestamp_ms)}
320338 </time>
@@ -435,7 +453,9 @@
435453 .hover-time { opacity: 0; font-size: 10.5px; color: var(--muted); }
436454 .msg-body { min-width: 0; flex: 1; }
437455 .msg-head { display: flex; align-items: baseline; gap: 8px; }
438- .sender { font-weight: 650; font-size: 13.5px; }
456+ .sender { font-weight: 650; font-size: 13.5px; padding: 0; border: none; background: none; font-family: inherit; cursor: pointer; }
457+ .sender:hover { text-decoration: underline; }
458+ .user-hit { flex: none; align-self: flex-start; padding: 0; border: none; background: none; border-radius: 50%; cursor: pointer; }
439459 .time, .edited { color: var(--muted); font-size: 11.5px; }
440460 .text .edited { margin-left: 6px; }
441461 .msg.editing { background: color-mix(in srgb, var(--accent) 10%, transparent); }
Added file: web/src/lib/UserCard.svelte+209 additions
@@ -0,0 +1,209 @@
1+<script lang="ts" context="module">
2+ export type UserAction =
3+ | { kind: 'message' | 'mention' | 'unban' | 'mute' | 'unmute' | 'ignore' | 'unignore' }
4+ | { kind: 'kick' | 'ban'; reason: string | null }
5+ | { kind: 'power'; level: number };
6+</script>
7+
8+<script lang="ts">
9+ import { onMount, tick } from 'svelte';
10+ import MxcAvatar from './MxcAvatar.svelte';
11+ import { allowed, isMuted, levelOf, roleName, type PowerLevels } from './permissions';
12+ import type { MemberSummary, Person } from './types';
13+
14+ export let person: Person;
15+ export let member: MemberSummary | null = null;
16+ export let levels: PowerLevels | null = null;
17+ export let self: string | null = null;
18+ export let x: number;
19+ export let y: number;
20+ export let confirm: 'kick' | 'ban' | null = null;
21+ export let onAction: (action: UserAction) => void;
22+ export let onClose: () => void;
23+
24+ const MARGIN = 8;
25+
26+ let card: HTMLDivElement;
27+ let left = x;
28+ let top = y;
29+ let reason = '';
30+ let copied = false;
31+
32+ $: own = person.user_id === self;
33+ $: level = levels ? levelOf(levels, person.user_id) : null;
34+ $: role = level !== null ? roleName(level) : null;
35+ $: muted = levels ? isMuted(levels, person.user_id) : false;
36+ $: can = levels && self ? allowed(levels, self, person.user_id) : null;
37+ $: banned = member?.membership === 'Ban';
38+ $: powerInput = level !== null && Number.isFinite(level) ? String(level) : '';
39+
40+ onMount(async () => {
41+ await tick();
42+ const rect = card.getBoundingClientRect();
43+ left = Math.max(MARGIN, Math.min(x, window.innerWidth - rect.width - MARGIN));
44+ top = Math.max(MARGIN, Math.min(y, window.innerHeight - rect.height - MARGIN));
45+ });
46+
47+ function act(action: UserAction): void {
48+ onClose();
49+ onAction(action);
50+ }
51+
52+ function setPower(): void {
53+ const value = Number(powerInput);
54+ if (!Number.isInteger(value) || value === level) return;
55+ act({ kind: 'power', level: value });
56+ }
57+
58+ async function copyId(): Promise<void> {
59+ try {
60+ await navigator.clipboard.writeText(person.user_id);
61+ copied = true;
62+ window.setTimeout(() => (copied = false), 1500);
63+ } catch {
64+ copied = false;
65+ }
66+ }
67+
68+ function outside(ev: PointerEvent): void {
69+ if (!card.contains(ev.target as Node)) onClose();
70+ }
71+
72+ function key(ev: KeyboardEvent): void {
73+ if (ev.key !== 'Escape') return;
74+ ev.stopPropagation();
75+ if (confirm) confirm = null;
76+ else onClose();
77+ }
78+</script>
79+
80+<svelte:window on:pointerdown|capture={outside} on:keydown|capture={key} on:resize={onClose} />
81+
82+<div class="user-card" role="dialog" aria-label={person.name} style={`left:${left}px;top:${top}px`} bind:this={card}>
83+ <div class="identity">
84+ <MxcAvatar mxc={person.avatar} label={person.name} size={64} />
85+ <div class="names">
86+ <strong class="name">{person.name}</strong>
87+ <button type="button" class="user-id" title="Copy user ID" on:click={() => void copyId()}>
88+ {copied ? 'Copied' : person.user_id}
89+ </button>
90+ <div class="tags">
91+ {#if role}<span class="tag">{role}</span>{/if}
92+ {#if level !== null && Number.isFinite(level) && level !== 0}<span class="tag">Power {level}</span>{/if}
93+ {#if muted}<span class="tag warn">Muted</span>{/if}
94+ {#if banned}<span class="tag danger">Banned</span>{/if}
95+ {#if member?.ignored}<span class="tag">Ignored</span>{/if}
96+ </div>
97+ </div>
98+ </div>
99+
100+ {#if confirm}
101+ <form class="confirm" on:submit|preventDefault={() => confirm && act({ kind: confirm, reason: reason.trim() || null })}>
102+ <!-- svelte-ignore a11y_autofocus -->
103+ <input type="text" placeholder="Reason" bind:value={reason} autofocus />
104+ <div class="row">
105+ <button type="button" class="ghost-btn" on:click={() => (confirm = null)}>Cancel</button>
106+ <button type="submit" class="ghost-btn danger">{confirm === 'kick' ? 'Kick' : 'Ban'} {person.name}</button>
107+ </div>
108+ </form>
109+ {:else}
110+ <div class="row">
111+ {#if !own}
112+ <button type="button" class="ghost-btn" on:click={() => act({ kind: 'message' })}>Message</button>
113+ {/if}
114+ <button type="button" class="ghost-btn" on:click={() => act({ kind: 'mention' })}>Mention</button>
115+ </div>
116+
117+ {#if can && (can.kick || can.ban || can.setLevel)}
118+ <div class="row">
119+ {#if can.setLevel && !own}
120+ <button type="button" class="ghost-btn" on:click={() => act({ kind: muted ? 'unmute' : 'mute' })}>
121+ {muted ? 'Unmute' : 'Mute'}
122+ </button>
123+ {/if}
124+ {#if can.kick && !banned && member && member.membership !== 'Leave'}
125+ <button type="button" class="ghost-btn danger" on:click={() => (confirm = 'kick')}>Kick</button>
126+ {/if}
127+ {#if can.ban}
128+ {#if banned}
129+ <button type="button" class="ghost-btn" on:click={() => act({ kind: 'unban' })}>Unban</button>
130+ {:else}
131+ <button type="button" class="ghost-btn danger" on:click={() => (confirm = 'ban')}>Ban</button>
132+ {/if}
133+ {/if}
134+ </div>
135+ {#if can.setLevel}
136+ <form class="row power" on:submit|preventDefault={setPower}>
137+ <label for="power-level">Power level</label>
138+ <input id="power-level" type="number" step="1" max={Number.isFinite(can.maxLevel) ? can.maxLevel : undefined} bind:value={powerInput} />
139+ <button type="submit" class="ghost-btn" disabled={powerInput === '' || Number(powerInput) === level}>Set</button>
140+ </form>
141+ {/if}
142+ {/if}
143+
144+ {#if !own}
145+ <div class="row">
146+ <button type="button" class="ghost-btn subtle" on:click={() => act({ kind: member?.ignored ? 'unignore' : 'ignore' })}>
147+ {member?.ignored ? 'Unignore' : 'Ignore'}
148+ </button>
149+ </div>
150+ {/if}
151+ {/if}
152+</div>
153+
154+<style>
155+ .user-card {
156+ position: fixed;
157+ z-index: 90;
158+ width: 300px;
159+ max-width: calc(100vw - 16px);
160+ display: flex;
161+ flex-direction: column;
162+ gap: 10px;
163+ padding: 14px;
164+ background: var(--panel-2);
165+ border: 1px solid var(--border);
166+ border-radius: 12px;
167+ box-shadow: 0 12px 32px rgb(0 0 0 / 0.4);
168+ }
169+ .identity { display: flex; gap: 12px; align-items: center; }
170+ .names { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
171+ .name { font-size: 16px; overflow-wrap: anywhere; }
172+ .user-id {
173+ padding: 0;
174+ border: none;
175+ background: none;
176+ color: var(--muted);
177+ font: inherit;
178+ font-size: 12.5px;
179+ text-align: left;
180+ cursor: copy;
181+ overflow-wrap: anywhere;
182+ }
183+ .user-id:hover { color: var(--text); }
184+ .tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
185+ .tag {
186+ font-size: 11px;
187+ padding: 1px 6px;
188+ border-radius: 999px;
189+ border: 1px solid var(--border);
190+ color: var(--muted);
191+ }
192+ .tag.warn { color: var(--warn-text); border-color: var(--warn-border); }
193+ .tag.danger { color: var(--danger); border-color: var(--danger-border); }
194+ .row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
195+ .row > :global(.ghost-btn) { flex: 1; }
196+ .power label { font-size: 12.5px; color: var(--muted); }
197+ .power input { width: 80px; flex: 1; }
198+ input {
199+ background: var(--bg-soft);
200+ border: 1px solid var(--border);
201+ border-radius: 6px;
202+ color: var(--text);
203+ padding: 5px 8px;
204+ font: inherit;
205+ font-size: 13px;
206+ }
207+ .confirm { display: flex; flex-direction: column; gap: 8px; }
208+ .subtle { color: var(--muted); }
209+</style>
Added file: web/src/lib/permissions.ts+77 additions
@@ -0,0 +1,77 @@
1+export interface PowerLevels {
2+ ban: number;
3+ kick: number;
4+ events_default: number;
5+ state_default: number;
6+ users_default: number;
7+ users: Map<string, number>;
8+ events: Map<string, number>;
9+ creators: Set<string>;
10+}
11+
12+function num(raw: Record<string, unknown>, key: string, fallback: number): number {
13+ const value = raw[key];
14+ return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
15+}
16+
17+function pairs(raw: unknown): Map<string, number> {
18+ const out = new Map<string, number>();
19+ if (!Array.isArray(raw)) return out;
20+ for (const entry of raw) {
21+ if (Array.isArray(entry) && typeof entry[0] === 'string' && typeof entry[1] === 'number') out.set(entry[0], entry[1]);
22+ }
23+ return out;
24+}
25+
26+export function parsePowerLevels(raw: unknown): PowerLevels | null {
27+ if (typeof raw !== 'object' || raw === null) return null;
28+ const o = raw as Record<string, unknown>;
29+ return {
30+ ban: num(o, 'ban', 50),
31+ kick: num(o, 'kick', 50),
32+ events_default: num(o, 'events_default', 0),
33+ state_default: num(o, 'state_default', 50),
34+ users_default: num(o, 'users_default', 0),
35+ users: pairs(o['users']),
36+ events: pairs(o['events']),
37+ creators: new Set(Array.isArray(o['creators']) ? o['creators'].filter((c): c is string => typeof c === 'string') : [])
38+ };
39+}
40+
41+export function levelOf(levels: PowerLevels, userId: string): number {
42+ if (levels.creators.has(userId)) return Infinity;
43+ return levels.users.get(userId) ?? levels.users_default;
44+}
45+
46+export function isMuted(levels: PowerLevels, userId: string): boolean {
47+ const send = Math.min(levels.events_default, levels.events.get('m.room.message') ?? Infinity);
48+ return levelOf(levels, userId) < send;
49+}
50+
51+export interface Allowed {
52+ kick: boolean;
53+ ban: boolean;
54+ setLevel: boolean;
55+ maxLevel: number;
56+}
57+
58+/** What `self` may do to `target`, per the spec's rule that you can only act on users below you. */
59+export function allowed(levels: PowerLevels, self: string, target: string): Allowed {
60+ const mine = levelOf(levels, self);
61+ const theirs = levelOf(levels, target);
62+ const outranks = self !== target && mine > theirs;
63+ const plEvent = levels.events.get('m.room.power_levels') ?? levels.state_default;
64+ return {
65+ kick: outranks && mine >= levels.kick,
66+ ban: outranks && mine >= levels.ban,
67+ setLevel: (outranks || self === target) && mine >= plEvent,
68+ maxLevel: mine
69+ };
70+}
71+
72+export function roleName(level: number): string | null {
73+ if (level === Infinity) return 'Creator';
74+ if (level >= 100) return 'Admin';
75+ if (level >= 50) return 'Moderator';
76+ return null;
77+}
Modified file: web/src/lib/transport.ts+2 additions−1 deletions
@@ -34,7 +34,8 @@ export type CommandRequest =
3434 | { op: 'set_room_display_name'; room_id: string; name: string | null }
3535 | { op: 'set_room_avatar'; room_id: string; mxc: string | null }
3636 | { op: 'mute'; room_id: string; user_id: string; muted: boolean }
37- | { op: 'ignore' | 'unignore'; user_id: string };
37+ | { op: 'ignore' | 'unignore'; user_id: string }
38+ | { op: 'power_levels'; room_id: string };
3839
3940 export type SendKind = 'text' | 'emote' | 'notice';
4041
Added file: web/test/permissions.test.ts+31 additions
@@ -0,0 +1,31 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { allowed, isMuted, levelOf, parsePowerLevels } from '../src/lib/permissions.ts';
4+
5+const levels = parsePowerLevels({
6+ ban: 50,
7+ kick: 50,
8+ events_default: 0,
9+ state_default: 50,
10+ users_default: 0,
11+ users: [['@mod:x', 50], ['@admin:x', 100], ['@muted:x', -1]],
12+ events: [['m.room.power_levels', 100]],
13+ creators: ['@creator:x']
14+})!;
15+
16+test('levels come from users, the default, or creator rank', () => {
17+ assert.equal(levelOf(levels, '@admin:x'), 100);
18+ assert.equal(levelOf(levels, '@someone:x'), 0);
19+ assert.equal(levelOf(levels, '@creator:x'), Infinity);
20+ assert.equal(isMuted(levels, '@muted:x'), true);
21+ assert.equal(isMuted(levels, '@someone:x'), false);
22+});
23+
24+test('you can only act on people below you', () => {
25+ assert.deepEqual(allowed(levels, '@mod:x', '@someone:x'), { kick: true, ban: true, setLevel: false, maxLevel: 50 });
26+ assert.equal(allowed(levels, '@mod:x', '@admin:x').kick, false);
27+ assert.equal(allowed(levels, '@mod:x', '@mod:x').kick, false);
28+ assert.equal(allowed(levels, '@admin:x', '@mod:x').setLevel, true);
29+ assert.equal(allowed(levels, '@admin:x', '@creator:x').ban, false);
30+ assert.equal(allowed(levels, '@creator:x', '@admin:x').setLevel, true);
31+});