Commit d8624c214c04
tarball10 files changed+649-42
@@ -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)@@ -519,7 +519,7 @@ async fn rooms_upload(
519519
}520520
521521
/// Ops added after the per-route handlers, taken as the core request JSON. Anything522
-/// 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
}@@ -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.space1271
+ ? [{ 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
+ <UserCard1443
+ 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
<button16341797
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
+ <button2132
+ 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>@@ -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; }@@ -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>@@ -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
- <div300
- 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
+ <button304
+ 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
+ <button328
+ 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); }@@ -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>@@ -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: mine69
+ };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
+}@@ -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
@@ -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
+});