atagen

malice

public
Star0
Watch0

Commit 05a48e9e7942

tarball

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

30 files changed+1012-288
web: polish the layout and add pins, emote and command completion

The account bar now has a fixed slot under the rail and channels and folds to an avatar menu. Panes are resizable and the composer grows with its content. Radii, padding and contrast are tightened across every panel. Typing / or : opens command or emote completion, where :name: of an emoji becomes the emoji and custom pack emotes send as data-mx-emoticon images. A pinned strip under the header lists m.room.pinned_events. The mobile drawer had broken because .app > .sidebar { position: relative } outranked the media query's position: fixed.
Modified file: web/src/App.svelte+357 additions−80 deletions
@@ -36,9 +36,14 @@
3636 uploadAttachment,
3737 uploadAvatar,
3838 uploadRoomAvatar,
39- editMessage
39+ editMessage,
40+ mediaUrl
4041 } from './lib/api';
41- import { isKnownCommand, parseCommand, splitTarget, unescapeCommand } from './lib/commands';
42+ import EmojiText from './lib/EmojiText.svelte';
43+ import { loadEmoji, type EmojiEntry } from './lib/emoji';
44+ import { completeShortcode, customEmotes, emoteQueryAt, rankEmotes, resolveEmotes, type EmoteCandidate, type EmoteQuery } from './lib/emotes';
45+ import { COMMAND_USAGE, commandQueryAt, isKnownCommand, matchCommands, parseCommand, splitTarget, unescapeCommand } from './lib/commands';
46+ import type { CommandName } from './lib/commands';
4247 import type { ParsedCommand } from './lib/commands';
4348 import {
4449 banners,
@@ -58,11 +63,13 @@
5863 sessionUser,
5964 softLogout,
6065 timelines,
66+ pinnedTimelines,
6167 verification
6268 } from './lib/store';
6369 import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';
6470 import type { PendingMessage } from './lib/pending';
6571 import { insideOpenFence } from './lib/markdown';
72+ import { markMentions } from './lib/composer-marks';
6673 import { mentionQueryAt, mentionsFromHtml, rankPeople, resolveMentions } from './lib/mentions';
6774 import type { Mention, MentionQuery } from './lib/mentions';
6875 import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';
@@ -75,7 +82,18 @@
7582 } from './lib/link-previews';
7683 import { notifyTyping, stopTyping } from './lib/typing';
7784 import { formatDay } from './lib/format';
78- import { lastSpaceRooms, lineHeight, markdownEnabled, messageSpacing, navFolded, railFolded } from './lib/appearance';
85+ import {
86+ MEMBERS_WIDTH,
87+ SIDE_WIDTH,
88+ lastSpaceRooms,
89+ lineHeight,
90+ markdownEnabled,
91+ membersWidth,
92+ messageSpacing,
93+ navFolded,
94+ railFolded,
95+ sideWidth
96+ } from './lib/appearance';
7997 import SettingsPanel from './lib/SettingsPanel.svelte';
8098 import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState, contentPreview, senderLabel } from './lib/types';
8199 import type { ImagePack, MemberSummary, OwnProfile, PackImage, Person, QuickReaction, ReplyReference, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
@@ -102,6 +120,12 @@
102120 let stickToBottom = true;
103121 let timelineEl: HTMLElement | null = null;
104122 let composerEl: HTMLTextAreaElement | null = null;
123+ let marksEl: HTMLDivElement | null = null;
124+ $: composerMarks = markMentions(
125+ composer,
126+ ($selectedRoomId && pickedMentions[$selectedRoomId]) || [],
127+ resolveEmotes(composer, roomEmotes).map((e) => `:${e.shortcode}:`)
128+ );
105129 let fileInput: HTMLInputElement | null = null;
106130 let avatarInput: HTMLInputElement | null = null;
107131 let uploadBusy = false;
@@ -145,6 +169,7 @@
145169 }
146170 $: selectedRoom = $rooms.find((r) => r.room_id === $selectedRoomId) ?? null;
147171 $: roomPacks = packsForRoom($imagePacks, $selectedRoomId);
172+ $: roomEmotes = customEmotes(roomPacks);
148173 $: previewsAllowed = selectedRoom !== null && (!selectedRoom.encrypted || $urlPreviewSettings.encrypted_rooms);
149174 $: loadPreview = previewsAllowed && $selectedRoomId ? previewLoader($selectedRoomId) : null;
150175 $: spaceRooms = $rooms.filter((r) => r.space);
@@ -645,7 +670,7 @@
645670 if (image?.previewUrl) URL.revokeObjectURL(image.previewUrl);
646671 });
647672 } else if (attachments.length === 0) {
648- queueSend(roomId, text, reply, mentions, $markdownEnabled);
673+ queueSend(roomId, text, reply, mentions, $markdownEnabled, resolveEmotes(text, roomEmotes));
649674 } else {
650675 const { [roomId]: _sent, ...rest } = staged;
651676 staged = rest;
@@ -685,9 +710,11 @@
685710 try {
686711 switch (name) {
687712 case 'me':
688- case 'notice':
689- await sendMessage(roomId, required('something to say'), null, mentions, $markdownEnabled, name === 'me' ? 'emote' : 'notice');
713+ case 'notice': {
714+ const said = required('something to say');
715+ await sendMessage(roomId, said, null, mentions, $markdownEnabled, name === 'me' ? 'emote' : 'notice', resolveEmotes(said, roomEmotes));
690716 break;
717+ }
691718 case 'msg': {
692719 const { user_id, rest } = target();
693720 const res = (await runCommand({ op: 'direct', user_id })) as { room_id: string };
@@ -795,7 +822,7 @@
795822 for (const attachment of attachments) {
796823 uploadName = attachment.file.name || 'attachment';
797824 try {
798- if (first) await uploadAttachment(roomId, attachment.file, body || null, reply?.event_id ?? null, mentions, $markdownEnabled);
825+ if (first) await uploadAttachment(roomId, attachment.file, body || null, reply?.event_id ?? null, mentions, $markdownEnabled, resolveEmotes(body, roomEmotes));
799826 else await uploadAttachment(roomId, attachment.file);
800827 first = false;
801828 } catch (e) {
@@ -868,7 +895,7 @@
868895 const body = composer.trim();
869896 cancelEdit();
870897 if (!body || body === original.trim()) return;
871- editMessage(roomId, eventId, body, resolveMentions(body, pickedMentions[roomId] ?? []), $markdownEnabled).catch((e) => pushBanner(e instanceof Error ? e.message : 'Edit failed'));
898+ editMessage(roomId, eventId, body, resolveMentions(body, pickedMentions[roomId] ?? []), $markdownEnabled, resolveEmotes(body, roomEmotes)).catch((e) => pushBanner(e instanceof Error ? e.message : 'Edit failed'));
872899 }
873900
874901 function editLastOwn(): boolean {
@@ -889,6 +916,40 @@
889916 composerEl?.focus();
890917 }
891918
919+ let pinnedOpenFor: string | null = null;
920+ let pinsExpanded = false;
921+ $: syncPinned(selectedRoom?.room_id ?? null, (selectedRoom?.pinned_events.length ?? 0) > 0);
922+ function syncPinned(roomId: string | null, hasPins: boolean): void {
923+ const want = hasPins ? roomId : null;
924+ if (want === pinnedOpenFor) return;
925+ if (pinnedOpenFor) runCommand({ op: 'close_pinned', room_id: pinnedOpenFor }).catch(() => undefined);
926+ pinnedOpenFor = want;
927+ pinsExpanded = false;
928+ if (want) runCommand({ op: 'open_pinned', room_id: want }).catch(() => undefined);
929+ }
930+
931+ $: pins = pinnedList(selectedRoom, $pinnedTimelines);
932+ function pinnedList(room: RoomSummary | null, all: Record<string, TimelineItem[]>): TimelineEvent[] {
933+ if (!room || !room.pinned_events.length) return [];
934+ const order = new Map(room.pinned_events.map((id, i) => [id, i]));
935+ return (all[room.room_id] ?? [])
936+ .flatMap((item) => (item.kind === 'event' && item.event.event_id && order.has(item.event.event_id) ? [item.event] : []))
937+ .sort((a, b) => (order.get(b.event_id!) ?? 0) - (order.get(a.event_id!) ?? 0));
938+ }
939+
940+ function setPinned(eventId: string, pinned: boolean): void {
941+ const roomId = $selectedRoomId;
942+ if (!roomId) return;
943+ runCommand({ op: 'set_pinned', room_id: roomId, event_id: eventId, pinned }).catch((e) =>
944+ pushBanner(e instanceof Error ? e.message : 'Could not change the pin')
945+ );
946+ }
947+
948+ function jumpToPin(eventId: string): void {
949+ pinsExpanded = false;
950+ jumpTo(eventId);
951+ }
952+
892953 function jumpTo(eventId: string): void {
893954 const el = timelineEl?.querySelector<HTMLElement>(`[data-event="${CSS.escape(eventId)}"]`);
894955 if (!el) {
@@ -924,8 +985,69 @@
924985 if (stickToBottom) scrollToBottom();
925986 }
926987
988+ let commandQuery: string | null = null;
989+ let commandIndex = 0;
990+ $: commandCandidates = commandQuery === null ? [] : matchCommands(commandQuery);
991+ $: if (commandIndex >= commandCandidates.length) commandIndex = 0;
992+
993+ async function pickCommand(name: CommandName): Promise<void> {
994+ if (!composerEl) return;
995+ const caret = composerEl.selectionStart ?? composer.length;
996+ const rest = composer.slice(caret).replace(/^\S*/, '');
997+ composer = `/${name}${rest.startsWith(' ') ? rest : ` ${rest}`}`;
998+ commandQuery = null;
999+ await tick();
1000+ const at = name.length + 2;
1001+ composerEl.focus();
1002+ composerEl.setSelectionRange(at, at);
1003+ }
1004+
1005+ let emoteQuery: EmoteQuery | null = null;
1006+ let emoteIndex = 0;
1007+ let emojiList: EmojiEntry[] = [];
1008+ $: emoteCandidates = emoteQuery ? rankEmotes(emoteQuery.query, roomEmotes, emojiList) : [];
1009+ $: if (emoteIndex >= emoteCandidates.length) emoteIndex = 0;
1010+
1011+ function ensureEmoji(): void {
1012+ if (emojiList.length) return;
1013+ loadEmoji()
1014+ .then((groups) => (emojiList = groups.flatMap((g) => g.emojis)))
1015+ .catch(() => undefined);
1016+ }
1017+
1018+ async function onComposerInput(): Promise<void> {
1019+ if (composerEl && composer.includes(':')) {
1020+ ensureEmoji();
1021+ const done = completeShortcode(composerEl.value, composerEl.selectionStart ?? composerEl.value.length, emojiList);
1022+ if (done) {
1023+ composer = done.text;
1024+ await tick();
1025+ composerEl.setSelectionRange(done.caret, done.caret);
1026+ }
1027+ }
1028+ updateMentionQuery();
1029+ }
1030+
1031+ async function pickEmote(candidate: EmoteCandidate): Promise<void> {
1032+ if (!emoteQuery || !composerEl) return;
1033+ const caret = composerEl.selectionStart ?? composer.length;
1034+ const inserted = candidate.kind === 'emoji' ? candidate.emoji : `:${candidate.shortcode}: `;
1035+ composer = composer.slice(0, emoteQuery.start) + inserted + composer.slice(caret);
1036+ const at = emoteQuery.start + inserted.length;
1037+ emoteQuery = null;
1038+ await tick();
1039+ composerEl.focus();
1040+ composerEl.setSelectionRange(at, at);
1041+ }
1042+
9271043 function updateMentionQuery(): void {
9281044 const roomId = $selectedRoomId;
1045+ const nextEmote = composerEl ? emoteQueryAt(composerEl.value, composerEl.selectionStart ?? composerEl.value.length) : null;
1046+ if (nextEmote?.start !== emoteQuery?.start) emoteIndex = 0;
1047+ emoteQuery = nextEmote;
1048+ const nextCommand = composerEl ? commandQueryAt(composerEl.value, composerEl.selectionStart ?? composerEl.value.length) : null;
1049+ if (nextCommand !== commandQuery) commandIndex = 0;
1050+ commandQuery = nextCommand;
9291051 const next = composerEl && roomId ? mentionQueryAt(composerEl.value, composerEl.selectionStart ?? composerEl.value.length) : null;
9301052 if (next && !mentionQuery && roomId && !$membersByRoom[roomId]) void loadMembers(roomId);
9311053 if (next?.start !== mentionQuery?.start) mentionIndex = 0;
@@ -950,6 +1072,43 @@
9501072 }
9511073
9521074 function onComposerKey(ev: KeyboardEvent): void {
1075+ if (emoteQuery && emoteCandidates.length > 0) {
1076+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
1077+ ev.preventDefault();
1078+ const step = ev.key === 'ArrowDown' ? 1 : -1;
1079+ emoteIndex = (emoteIndex + step + emoteCandidates.length) % emoteCandidates.length;
1080+ return;
1081+ }
1082+ if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Tab') {
1083+ ev.preventDefault();
1084+ void pickEmote(emoteCandidates[emoteIndex]);
1085+ return;
1086+ }
1087+ if (ev.key === 'Escape') {
1088+ ev.stopPropagation();
1089+ emoteQuery = null;
1090+ return;
1091+ }
1092+ }
1093+ if (commandQuery !== null && commandCandidates.length > 0) {
1094+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
1095+ ev.preventDefault();
1096+ const step = ev.key === 'ArrowDown' ? 1 : -1;
1097+ commandIndex = (commandIndex + step + commandCandidates.length) % commandCandidates.length;
1098+ return;
1099+ }
1100+ const exact = commandCandidates.length === 1 && commandCandidates[0] === commandQuery;
1101+ if (ev.key === 'Tab' || (ev.key === 'Enter' && !ev.shiftKey && !exact)) {
1102+ ev.preventDefault();
1103+ void pickCommand(commandCandidates[commandIndex]);
1104+ return;
1105+ }
1106+ if (ev.key === 'Escape') {
1107+ ev.stopPropagation();
1108+ commandQuery = null;
1109+ return;
1110+ }
1111+ }
9531112 if (mentionQuery && mentionCandidates.length > 0) {
9541113 if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
9551114 ev.preventDefault();
@@ -1152,7 +1311,7 @@
11521311
11531312 let dragSpaceId: string | null = null;
11541313 let dropTarget: { id: string; after: boolean } | null = null;
1155- let menu: { items: MenuItem[]; x: number; y: number } | null = null;
1314+ let menu: { items: MenuItem[]; x: number; y: number; above?: boolean } | null = null;
11561315 let userCard: { userId: string; x: number; y: number; confirm: 'kick' | 'ban' | null } | null = null;
11571316 let levelsByRoom: Record<string, PowerLevels | null> = {};
11581317 let peeking: string | null = null;
@@ -1195,6 +1354,7 @@
11951354 if (!own) items.push({ label: 'Message', action: () => void userAction(userId, { kind: 'message' }) });
11961355 items.push({ label: 'Mention', action: () => void userAction(userId, { kind: 'mention' }) });
11971356 items.push({ label: 'Copy user ID', action: () => copyText(userId) });
1357+ const firstModeration = items.length;
11981358 if (roomLevels && $sessionUser) {
11991359 const can = allowed(roomLevels, $sessionUser, userId);
12001360 const banned = member?.membership === 'Ban';
@@ -1207,9 +1367,10 @@
12071367 if (can.ban && !banned) items.push({ label: 'Ban…', danger: true, action: () => (userCard = { userId, x, y, confirm: 'ban' }) });
12081368 if (can.setLevel) items.push({ label: 'Power level…', action: () => (userCard = { userId, x, y, confirm: null }) });
12091369 }
1370+ if (items[firstModeration]) items[firstModeration] = { ...items[firstModeration], divider: true };
12101371 if (!own) {
12111372 const kind = member?.ignored ? 'unignore' : 'ignore';
1212- items.push({ label: member?.ignored ? 'Unignore' : 'Ignore', action: () => void userAction(userId, { kind }) });
1373+ items.push({ label: member?.ignored ? 'Unignore' : 'Ignore', divider: true, action: () => void userAction(userId, { kind }) });
12131374 }
12141375 return items;
12151376 }
@@ -1281,7 +1442,7 @@
12811442 items.push({ label: room.favourite ? 'Unpin' : room.space ? 'Pin to top' : 'Pin', action: () => void togglePin(room) });
12821443 items.push({ label: 'Copy link', action: () => copyText(`https://matrix.to/#/${room.canonical_alias ?? room.room_id}`) });
12831444 items.push({ label: 'Copy room ID', action: () => copyText(room.room_id) });
1284- if (room.membership === 'Joined') items.push({ label: 'Leave', danger: true, action: () => void leave(room.room_id) });
1445+ if (room.membership === 'Joined') items.push({ label: 'Leave', danger: true, divider: true, action: () => void leave(room.room_id) });
12851446 return items;
12861447 }
12871448
@@ -1376,6 +1537,10 @@
13761537 items.push({ label: 'Copy text', action: () => copyText(body) });
13771538 }
13781539 items.push({ label: 'Copy link', action: () => copyText(`https://matrix.to/#/${roomId}/${eventId}`) });
1540+ if (joined && selectedRoom?.can_pin && content.kind !== 'Redacted') {
1541+ const pinned = selectedRoom.pinned_events.includes(eventId);
1542+ items.push({ label: pinned ? 'Unpin' : 'Pin', action: () => setPinned(eventId, !pinned) });
1543+ }
13791544 if (content.kind === 'Sticker') {
13801545 const body = content.body;
13811546 items.push({ label: 'Clone sticker…', action: () => openCloneStickerMenu(eventId, body, x, y) });
@@ -1396,10 +1561,61 @@
13961561 })
13971562 });
13981563 }
1399- if (!event.own) items.push(...userMenuItems(event.sender.user_id, x, y));
1564+ if (!event.own) {
1565+ const person = userMenuItems(event.sender.user_id, x, y);
1566+ if (person[0]) person[0] = { ...person[0], divider: true };
1567+ items.push(...person);
1568+ }
14001569 menu = { items, x, y };
14011570 }
14021571
1572+ function openAccountMenu(ev: MouseEvent): void {
1573+ const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
1574+ menu = {
1575+ x: rect.left,
1576+ y: rect.top - 6,
1577+ above: true,
1578+ items: [
1579+ { label: 'Security', action: () => (dialog = 'security') },
1580+ { label: 'Settings', action: () => (dialog = 'settings') },
1581+ { label: 'Sticker and emoji packs', action: () => (dialog = 'packs') },
1582+ { label: 'Log out', danger: true, divider: true, action: () => void doLogout() }
1583+ ]
1584+ };
1585+ }
1586+
1587+ let resizing = false;
1588+
1589+ function startResize(ev: PointerEvent, pane: 'side' | 'members'): void {
1590+ if (ev.button !== 0) return;
1591+ ev.preventDefault();
1592+ const handle = ev.currentTarget as HTMLElement;
1593+ handle.setPointerCapture(ev.pointerId);
1594+ const store = pane === 'side' ? sideWidth : membersWidth;
1595+ const range = pane === 'side' ? SIDE_WIDTH : MEMBERS_WIDTH;
1596+ const startX = ev.clientX;
1597+ const startWidth = pane === 'side' ? $sideWidth : $membersWidth;
1598+ resizing = true;
1599+ const move = (e: PointerEvent) => {
1600+ const delta = pane === 'side' ? e.clientX - startX : startX - e.clientX;
1601+ store.set(Math.round(Math.min(range.max, Math.max(range.min, startWidth + delta))));
1602+ };
1603+ const end = () => {
1604+ resizing = false;
1605+ handle.removeEventListener('pointermove', move);
1606+ handle.removeEventListener('pointerup', end);
1607+ handle.removeEventListener('pointercancel', end);
1608+ };
1609+ handle.addEventListener('pointermove', move);
1610+ handle.addEventListener('pointerup', end);
1611+ handle.addEventListener('pointercancel', end);
1612+ }
1613+
1614+ function resetWidth(pane: 'side' | 'members'): void {
1615+ if (pane === 'side') sideWidth.set(SIDE_WIDTH.default);
1616+ else membersWidth.set(MEMBERS_WIDTH.default);
1617+ }
1618+
14031619 function stopPeeking(): void {
14041620 peeking = null;
14051621 queueReadReceipt();
@@ -1494,6 +1710,9 @@
14941710 class:nav-folded={$navFolded && selectedRoom}
14951711 class:rail-folded={$railFolded && selectedRoom}
14961712 class:members-open={selectedRoom && membersOpen}
1713+ class:resizing
1714+ style:--side-open={`${$sideWidth}px`}
1715+ style:--members-open={`${$membersWidth}px`}
14971716 style:--msg-gap={`${$messageSpacing}px`}
14981717 style:--msg-line-height={$lineHeight}
14991718 >
@@ -1648,7 +1867,7 @@
16481867 aria-pressed={selectedSpaceId === null}
16491868 on:click={() => void selectHome()}
16501869 >
1651- <span aria-hidden="true">⌂</span>
1870+ <svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true"><path d="M4 11l8-7 8 7v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
16521871 </button>
16531872 {#each [pinnedRail, otherRail] as group}
16541873 {#if group.length > 0}
@@ -1686,12 +1905,12 @@
16861905 dialog = 'join';
16871906 }}
16881907 >
1689- <span aria-hidden="true">+</span>
1908+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" /></svg>
16901909 </button>
16911910 </nav>
16921911
16931912 {#if menu}
1694- <ContextMenu items={menu.items} x={menu.x} y={menu.y} onClose={() => (menu = null)} />
1913+ <ContextMenu items={menu.items} x={menu.x} y={menu.y} above={menu.above ?? false} onClose={() => (menu = null)} />
16951914 {/if}
16961915 {#if userCard}
16971916 {#key userCard}
@@ -1711,6 +1930,8 @@
17111930 {/if}
17121931
17131932 <aside class="sidebar" class:open={sidebarOpen} aria-label="Rooms">
1933+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1934+ <div class="pane-resize right" title="Drag to resize" on:pointerdown={(e) => startResize(e, 'side')} on:dblclick={() => resetWidth('side')}></div>
17141935 <div class="side-search">
17151936 <label class="visually-hidden" for="room-search">Search rooms</label>
17161937 <input
@@ -1732,31 +1953,12 @@
17321953 </button>
17331954 </div>
17341955 <div class="side-scroll">
1735- {#if selectedSpace}
1736- <div class="space-context">
1737- <MxcAvatar mxc={selectedSpace.avatar} label={selectedSpace.name} size={28} shape="rounded" />
1738- <div class="space-context-text">
1739- <strong>{selectedSpace.name}</strong>
1740- </div>
1741- {#if spaceParents.has(selectedSpace.room_id)}
1742- {@const parentId = spaceParents.get(selectedSpace.room_id) ?? ''}
1743- <button
1744- type="button"
1745- class="icon-btn"
1746- title="Up to the parent space"
1747- aria-label="Up to the parent space"
1748- on:click={() => void selectSpace(parentId)}
1749- >
1750- <span aria-hidden="true">↑</span>
1751- </button>
1752- {/if}
1753- </div>
1754- {:else}
1755- <div class="space-context">
1756- <div class="space-context-text">
1757- <strong>Home</strong>
1758- </div>
1759- </div>
1956+ {#if selectedSpace && spaceParents.has(selectedSpace.room_id)}
1957+ {@const parentId = spaceParents.get(selectedSpace.room_id) ?? ''}
1958+ <button type="button" class="room-row up-row" on:click={() => void selectSpace(parentId)}>
1959+ <span aria-hidden="true">↑</span>
1960+ <span class="room-name">{$rooms.find((r) => r.room_id === parentId)?.name ?? 'Parent space'}</span>
1961+ </button>
17601962 {/if}
17611963 {#if roomsLoading && $rooms.length === 0}
17621964 <p class="side-note">Loading rooms…</p>
@@ -1861,7 +2063,6 @@
18612063 on:contextmenu|preventDefault={(ev) => openRoomMenu(ev, room)}
18622064 >
18632065 <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
1864- <span class="room-hash" aria-hidden="true">#</span>
18652066 <span class="room-name">{room.name}</span>
18662067 {#if roomUnread(room) > 0}
18672068 <span
@@ -1893,7 +2094,6 @@
18932094 on:contextmenu|preventDefault={(ev) => openRoomMenu(ev, room)}
18942095 >
18952096 <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
1896- <span class="room-hash" aria-hidden="true">◇</span>
18972097 <span class="room-name">{room.name}</span>
18982098 </button>
18992099 <PinButton pinned={room.favourite} label={room.name} onToggle={() => void togglePin(room)} />
@@ -1909,38 +2109,31 @@
19092109 {/if}
19102110 </div>
19112111
1912- <div class="side-footer">
1913- <div class="me" title={$sessionUser}>
1914- {#if profileMxc}
1915- <MxcAvatar mxc={profileMxc} label={profile?.display_name ?? $sessionUser} size={30} />
1916- {:else}
1917- <span class="me-avatar" aria-hidden="true">{$sessionUser.slice(1, 2).toUpperCase()}</span>
1918- {/if}
1919- <span class="me-id">{profile?.display_name && profile.display_name !== $sessionUser ? `${profile.display_name} (${$sessionUser})` : $sessionUser}</span>
1920- </div>
1921- <button
1922- type="button"
1923- class="icon-btn"
1924- title="Encryption and verification"
1925- aria-label="Encryption and verification"
1926- on:click={() => (dialog = 'security')}
1927- >
1928- <span aria-hidden="true">🛡</span>
2112+ </aside>
2113+ <div class="account">
2114+ <button type="button" class="me-btn" title={$sessionUser} aria-haspopup="menu" on:click={openAccountMenu}>
2115+ {#if profileMxc}
2116+ <MxcAvatar mxc={profileMxc} label={profile?.display_name ?? $sessionUser} size={32} />
2117+ {:else}
2118+ <span class="me-avatar" aria-hidden="true">{$sessionUser.slice(1, 2).toUpperCase()}</span>
2119+ {/if}
2120+ <span class="me-names">
2121+ <span class="me-name">{profile?.display_name || $sessionUser}</span>
2122+ {#if profile?.display_name && profile.display_name !== $sessionUser}<span class="me-id">{$sessionUser}</span>{/if}
2123+ </span>
2124+ </button>
2125+ <div class="account-actions">
2126+ <button type="button" class="icon-btn" title="Encryption and verification" aria-label="Encryption and verification" on:click={() => (dialog = 'security')}>
2127+ <svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path d="M12 3l8 3v6c0 4.5-3.4 8.2-8 9-4.6-.8-8-4.5-8-9V6z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
19292128 </button>
1930- <button
1931- type="button"
1932- class="icon-btn"
1933- title="Settings"
1934- aria-label="Settings"
1935- on:click={() => (dialog = 'settings')}
1936- >
1937- <span aria-hidden="true">⚙</span>
2129+ <button type="button" class="icon-btn" title="Settings" aria-label="Settings" on:click={() => (dialog = 'settings')}>
2130+ <svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
19382131 </button>
19392132 <button type="button" class="icon-btn" title="Log out" aria-label="Log out" on:click={() => void doLogout()}>
1940- <span aria-hidden="true">⏻</span>
2133+ <svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path d="M12 3v8M6.3 6.3a8 8 0 1 0 11.4 0" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
19412134 </button>
19422135 </div>
1943- </aside>
2136+ </div>
19442137 {#if sidebarOpen}
19452138 <button type="button" class="scrim" aria-label="Close room list" on:click={() => (sidebarOpen = false)}></button>
19462139 {/if}
@@ -1980,8 +2173,45 @@
19802173 </p>
19812174 </div>
19822175 {:else}
1983-
1984-
2176+ {#if pins.length}
2177+ <div class="pinned-strip" class:open={pinsExpanded}>
2178+ <div class="pinned-head">
2179+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M9 3h6l-1 6 4 4v2h-5v6l-1 1-1-1v-6H6v-2l4-4z" fill="currentColor" /></svg>
2180+ <button type="button" class="pinned-latest" title="Jump to pinned message" on:click={() => jumpToPin(pins[0].event_id ?? '')}>
2181+ <strong>{senderLabel(pins[0].sender)}</strong>
2182+ <span>{contentPreview(pins[0].content)}</span>
2183+ </button>
2184+ {#if pins.length > 1}
2185+ <button type="button" class="pinned-count" aria-expanded={pinsExpanded} on:click={() => (pinsExpanded = !pinsExpanded)}>
2186+ {pins.length} pinned
2187+ <svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true"><path d="m6 9 6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" /></svg>
2188+ </button>
2189+ {/if}
2190+ {#if pinsExpanded && selectedRoom.can_pin}
2191+ <button type="button" class="icon-btn" aria-label="Unpin" title="Unpin" on:click={() => setPinned(pins[0].event_id ?? '', false)}>
2192+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="2" /></svg>
2193+ </button>
2194+ {/if}
2195+ </div>
2196+ {#if pinsExpanded}
2197+ <ul class="pinned-list">
2198+ {#each pins.slice(1) as pin (pin.event_id)}
2199+ <li>
2200+ <button type="button" on:click={() => jumpToPin(pin.event_id ?? '')}>
2201+ <strong>{senderLabel(pin.sender)}</strong>
2202+ <span>{contentPreview(pin.content)}</span>
2203+ </button>
2204+ {#if selectedRoom.can_pin}
2205+ <button type="button" class="icon-btn" aria-label="Unpin" title="Unpin" on:click={() => setPinned(pin.event_id ?? '', false)}>
2206+ <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" stroke="currentColor" stroke-width="2" /></svg>
2207+ </button>
2208+ {/if}
2209+ </li>
2210+ {/each}
2211+ </ul>
2212+ {/if}
2213+ </div>
2214+ {/if}
19852215 {#if recoveryBanner}
19862216 <div class="sec-banner warn" role="status">
19872217 <span>Message history is locked</span>
@@ -2148,6 +2378,42 @@
21482378 {/if}
21492379 <form class="composer" class:replying={replyingTo || editing} class:staging={currentStaged.length > 0} on:submit|preventDefault={() => send()}>
21502380 <label class="visually-hidden" for="composer">Message {selectedRoom.name}</label>
2381+ {#if commandQuery !== null && commandCandidates.length > 0}
2382+ <ul class="mention-finder" role="listbox" aria-label="Commands">
2383+ {#each commandCandidates as name, i (name)}
2384+ <li
2385+ role="option"
2386+ aria-selected={i === commandIndex}
2387+ class:active={i === commandIndex}
2388+ on:pointerdown|preventDefault={() => void pickCommand(name)}
2389+ on:pointerenter={() => (commandIndex = i)}
2390+ >
2391+ <span class="mention-name">/{name}</span>
2392+ <span class="mention-id">{COMMAND_USAGE[name]}</span>
2393+ </li>
2394+ {/each}
2395+ </ul>
2396+ {/if}
2397+ {#if emoteQuery && emoteCandidates.length > 0}
2398+ <ul class="mention-finder" role="listbox" aria-label="Emotes">
2399+ {#each emoteCandidates as candidate, i (candidate.kind + candidate.shortcode)}
2400+ <li
2401+ role="option"
2402+ aria-selected={i === emoteIndex}
2403+ class:active={i === emoteIndex}
2404+ on:pointerdown|preventDefault={() => void pickEmote(candidate)}
2405+ on:pointerenter={() => (emoteIndex = i)}
2406+ >
2407+ {#if candidate.kind === 'custom'}
2408+ <img class="finder-emote" src={mediaUrl(candidate.mxc) ?? ''} alt="" />
2409+ {:else}
2410+ <span class="finder-emote"><EmojiText text={candidate.emoji} /></span>
2411+ {/if}
2412+ <span class="mention-name">:{candidate.shortcode}:</span>
2413+ </li>
2414+ {/each}
2415+ </ul>
2416+ {/if}
21512417 {#if mentionQuery && mentionCandidates.length > 0}
21522418 <ul class="mention-finder" role="listbox" aria-label="Mention someone">
21532419 {#each mentionCandidates as person, i (person.user_id)}
@@ -2183,13 +2449,17 @@
21832449 />
21842450 <button
21852451 type="button"
2186- class="ghost-btn"
2187- title="Attach an image or file"
2452+ class="icon-btn"
2453+ class:busy={uploadBusy}
2454+ title={uploadBusy ? `Uploading ${uploadName ?? 'file'}…` : 'Attach an image or file'}
21882455 aria-label="Attach an image or file"
2456+ aria-busy={uploadBusy}
21892457 disabled={!($selectedRoomId && openReady[$selectedRoomId])}
21902458 on:click={pickFiles}
21912459 >
2192- {uploadBusy ? `Uploading ${uploadName ?? 'file'}…` : '📎'}
2460+ <svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
2461+ <path d="M20 11.5l-8.2 8.2a5 5 0 0 1-7-7L13 4.4a3.3 3.3 0 0 1 4.7 4.7l-8.2 8.2a1.7 1.7 0 0 1-2.4-2.4l7.5-7.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
2462+ </svg>
21932463 </button>
21942464 <button
21952465 type="button"
@@ -2200,7 +2470,7 @@
22002470 disabled={!($selectedRoomId && openReady[$selectedRoomId])}
22012471 on:click={() => (stickersOpen = !stickersOpen)}
22022472 >
2203- <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
2473+ <svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
22042474 <path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v7l-7 7H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8" />
22052475 <path d="M13 20v-5a2 2 0 0 1 2-2h5" fill="none" stroke="currentColor" stroke-width="1.8" />
22062476 </svg>
@@ -2219,6 +2489,8 @@
22192489 />
22202490 </div>
22212491 {/if}
2492+ <div class="composer-field">
2493+ <div class="composer-marks" aria-hidden="true" bind:this={marksEl}>{#each composerMarks as part}{#if part.mark}<mark>{part.text}</mark>{:else}{part.text}{/if}{/each}&#8203;</div>
22222494 <textarea
22232495 id="composer"
22242496 bind:this={composerEl}
@@ -2227,22 +2499,25 @@
22272499 placeholder={`Message ${selectedRoom.name}`}
22282500 aria-label={`Message ${selectedRoom.name}. Enter to send, Shift Enter for a new line.`}
22292501 on:keydown={onComposerKey}
2230- on:input={updateMentionQuery}
2502+ on:input={() => void onComposerInput()}
22312503 on:click={updateMentionQuery}
2504+ on:focus={ensureEmoji}
22322505 on:keyup={(e) => (e.key.startsWith('Arrow') && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') || e.key === 'Home' || e.key === 'End' ? updateMentionQuery() : undefined}
2233- on:blur={() => (mentionQuery = null)}
2506+ on:blur={() => ((mentionQuery = null), (commandQuery = null), (emoteQuery = null))}
22342507 on:paste={onComposerPaste}
2508+ on:scroll={() => marksEl && composerEl && (marksEl.scrollTop = composerEl.scrollTop)}
22352509 disabled={!($selectedRoomId && openReady[$selectedRoomId])}
22362510 ></textarea>
2511+ </div>
22372512 <button
22382513 type="button"
2239- class="icon-btn md-toggle"
2514+ class="icon-btn"
22402515 aria-pressed={$markdownEnabled}
22412516 aria-label={$markdownEnabled ? 'Turn markdown off' : 'Turn markdown on'}
22422517 title={$markdownEnabled ? 'Markdown on' : 'Markdown off'}
22432518 on:click={() => markdownEnabled.update((v) => !v)}
22442519 >
2245- <svg viewBox="0 0 24 16" width="22" height="15" aria-hidden="true">
2520+ <svg viewBox="0 0 24 16" width="21" height="14" aria-hidden="true">
22462521 <rect x="1" y="1" width="22" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.6" />
22472522 <path d="M4.5 11.5v-7l2.75 3 2.75-3v7M15.5 4.5v6.5M13 8.5l2.5 2.75L18 8.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />
22482523 </svg>
@@ -2271,6 +2546,8 @@
22712546
22722547 {#if selectedRoom}
22732548 <aside class="members" aria-hidden={!membersOpen} aria-label={`Members of ${selectedRoom.name}`}>
2549+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2550+ <div class="pane-resize left" title="Drag to resize" on:pointerdown={(e) => startResize(e, 'members')} on:dblclick={() => resetWidth('members')}></div>
22742551 <div class="members-head">
22752552 <h2>Members</h2>
22762553 <button type="button" class="icon-btn" aria-label="Close members" on:click={() => (membersOpen = false)}>
@@ -2299,7 +2576,7 @@
22992576 }}
23002577 on:contextmenu|preventDefault={(ev) => void openUser(member.user_id, ev.clientX, ev.clientY, true)}
23012578 >
2302- <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} size={30} />
2579+ <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} seed={member.user_id} size={28} />
23032580 <span class="member-names">
23042581 <span class="member-name">{member.display_name || member.name}</span>
23052582 <span class="member-id">{member.user_id}</span>
Modified file: web/src/app.css+289 additions−120 deletions
@@ -1,13 +1,21 @@
1+:root {
2+ --r-sm: 3px;
3+ --r-md: 4px;
4+ --r-lg: 6px;
5+ --ctl-h: 34px;
6+ --pop: 0.14s cubic-bezier(0.2, 0.8, 0.2, 1);
7+}
8+
19 :root,
210 :root[data-theme='dark'] {
3- --bg: #14151c;
4- --bg-soft: #1b1d27;
5- --panel: #1e2029;
6- --panel-2: #232634;
7- --rail-bg: #101117;
8- --border: #2e3142;
9- --text: #e6e8f0;
10- --muted: #9aa0b4;
11+ --bg: #111218;
12+ --bg-soft: #191a22;
13+ --panel: #16171e;
14+ --panel-2: #22242f;
15+ --rail-bg: #0c0d11;
16+ --border: #2b2e3c;
17+ --text: #ecedf3;
18+ --muted: #a3a8ba;
1119 --accent: #5865f2;
1220 --accent-2: #7c5cff;
1321 --glow: #1c2440;
@@ -98,6 +106,19 @@ html, body, #app {
98106 min-height: 100dvh;
99107 }
100108
109+@keyframes pop-in {
110+ from { opacity: 0; transform: translateY(-4px) scale(0.98); }
111+}
112+@keyframes pop-up {
113+ from { opacity: 0; transform: translateY(4px) scale(0.98); }
114+}
115+@keyframes fade-in {
116+ from { opacity: 0; }
117+}
118+@media (prefers-reduced-motion: reduce) {
119+ :root { --pop: 0s; }
120+}
121+
101122 body {
102123 background: var(--bg);
103124 color: var(--text);
@@ -105,6 +126,9 @@ body {
105126 }
106127
107128 button { font: inherit; }
129+
130+* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
131+::selection { background: color-mix(in srgb, var(--accent) 40%, transparent); }
108132 input, textarea { font: inherit; }
109133
110134 .visually-hidden {
@@ -126,40 +150,56 @@ input, textarea { font: inherit; }
126150 }
127151 .splash-mark {
128152 width: 52px; height: 52px;
129- border-radius: 16px;
153+ border-radius: var(--r-lg);
130154 display: grid; place-items: center;
131155 font-size: 26px; font-weight: 800; color: #fff;
132156 background: linear-gradient(135deg, var(--accent), var(--accent-2));
133157 }
134158
135159 .app {
136- --rail-w: 68px;
137- --side-w: 300px;
160+ --rail-w: 60px;
161+ --side-w: var(--side-open, 260px);
138162 --members-w: 0px;
139163 --pane-ease: 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
140164 display: grid;
141165 grid-template-columns: var(--rail-w) var(--side-w) minmax(0, 1fr) var(--members-w);
142- grid-template-rows: auto minmax(0, 1fr);
166+ grid-template-rows: auto minmax(0, 1fr) auto;
143167 height: 100dvh;
144168 overflow: hidden;
145169 transition: grid-template-columns var(--pane-ease);
146170 }
147171 .app.rail-folded { --rail-w: 0px; }
148172 .app.nav-folded { --side-w: 0px; }
149-.app.members-open { --members-w: 260px; }
173+.app.members-open { --members-w: var(--members-open, 240px); }
174+.app.resizing { transition: none; cursor: col-resize; user-select: none; }
175+.pane-resize {
176+ position: absolute;
177+ top: 0;
178+ bottom: 0;
179+ z-index: 5;
180+ width: 6px;
181+ cursor: col-resize;
182+ min-width: 0 !important;
183+ transition: background var(--pop);
184+}
185+.pane-resize.right { right: 0; width: 5px; }
186+.pane-resize.left { left: 0; width: 5px; }
187+.pane-resize:hover, .app.resizing .pane-resize:active { background: color-mix(in srgb, var(--accent) 45%, transparent); }
150188 .app > .chat-head { grid-column: 1 / -1; grid-row: 1; }
151189 .app > .rail { grid-column: 1; grid-row: 2; }
152190 .app > .sidebar { grid-column: 2; grid-row: 2; }
153-.app > .chat { grid-column: 3; grid-row: 2; }
154-.app > .members { grid-column: 4; grid-row: 2; }
191+.app > .account { grid-column: 1 / 3; grid-row: 3; }
192+.app > .chat { grid-column: 3; grid-row: 2 / 4; }
193+.app > .members { grid-column: 4; grid-row: 2 / 4; }
155194 .app > .rail, .app > .sidebar, .app > .members {
156195 min-width: 0;
157196 transition: visibility 0s linear 0s;
158197 }
159198 .app > .rail { overflow-x: hidden; }
160199 .app > .sidebar, .app > .members { overflow: hidden; }
161-.app > .sidebar > * { min-width: 300px; }
162-.app > .members > * { min-width: 260px; }
200+.app > .sidebar > * { min-width: var(--side-open, 260px); }
201+.app > .members > * { min-width: var(--members-open, 240px); }
202+.app > .sidebar, .app > .members { position: relative; }
163203 .app.rail-folded > .rail, .app.nav-folded > .sidebar, .app:not(.members-open) > .members {
164204 visibility: hidden;
165205 transition: visibility 0s linear 0.22s;
@@ -171,58 +211,51 @@ input, textarea { font: inherit; }
171211 /* ——— Rail ——— */
172212 .rail {
173213 background: var(--rail-bg);
214+ border-right: 1px solid var(--border);
174215 display: flex;
175216 flex-direction: column;
176217 align-items: center;
177- gap: 8px;
178- padding: 12px 0;
218+ gap: 6px;
219+ padding: 10px 0;
179220 overflow-y: auto;
180221 }
181-.rail-sep { width: 32px; height: 2px; border-radius: 2px; background: var(--border); }
222+.rail-sep { flex: none; width: 24px; height: 1px; background: var(--border); }
182223 .rail-btn {
183- width: 46px; height: 46px;
184- border-radius: 50%;
224+ flex: none;
225+ width: 40px; height: 40px;
226+ border-radius: var(--r-lg);
185227 border: none;
186228 background: var(--panel-2);
187229 color: var(--text);
188230 font-size: 19px;
189231 font-weight: 700;
190232 cursor: pointer;
191- transition: border-radius 0.15s, background 0.15s;
233+ transition: background var(--pop), color var(--pop), box-shadow var(--pop);
192234 display: grid;
193235 place-items: center;
194236 overflow: hidden;
195237 padding: 0;
196238 }
197-.rail-btn:hover { border-radius: 16px; background: var(--accent); color: #fff; }
198-.rail-btn.active { border-radius: 16px; background: var(--accent); color: #fff; }
239+.rail-btn:hover { background: var(--accent); color: #fff; }
240+.rail-btn.active { background: var(--accent); color: #fff; }
199241 .rail-btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
200-.rail-space { border-radius: 14px; }
242+.rail-space { border-radius: var(--r-lg); }
201243 .rail-space:hover, .rail-space.active { background: var(--panel-2); }
202-.rail-space.active { box-shadow: 0 0 0 2px var(--rail-bg), 0 0 0 4px var(--accent); }
244+.rail-space.active { box-shadow: 0 0 0 2px var(--rail-bg), 0 0 0 3px var(--accent); }
203245 .rail .rail-space .mxc-avatar, .rail .rail-space .mxc-fallback { width: 100% !important; height: 100% !important; border-radius: inherit; }
204246 .rail-avatar { width: 100%; height: 100%; object-fit: cover; display: block; }
205247 .rail-add { color: var(--muted); }
206248 .rail-add:hover { color: #fff; }
207-.space-context {
208- display: flex;
209- align-items: center;
210- gap: 10px;
211- padding: 10px 8px 4px;
212-}
213-.space-context-text { display: flex; flex-direction: column; min-width: 0; }
214-.space-context-text strong { font-size: 13.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
215-.space-context-text span { font-size: 11.5px; color: var(--muted); }
216249 .sec-banner {
217250 display: flex;
218251 align-items: center;
219252 gap: 10px;
220- margin: 10px 16px 0;
221- padding: 8px 12px;
253+ margin: 8px 12px 0;
254+ padding: 5px 6px 5px 12px;
222255 background: color-mix(in srgb, var(--accent) 14%, transparent);
223256 border: 1px solid var(--accent);
224257 color: var(--text);
225- border-radius: 8px;
258+ border-radius: var(--r-md);
226259 font-size: 12.5px;
227260 }
228261 .sec-banner.warn { background: var(--warn-bg); border-color: var(--warn-border); color: var(--warn-text); }
@@ -231,13 +264,13 @@ input, textarea { font: inherit; }
231264 flex: none;
232265 background: transparent;
233266 border: 1px solid currentColor;
234- border-radius: 6px;
267+ border-radius: var(--r-sm);
235268 color: inherit;
236269 cursor: pointer;
237270 padding: 3px 10px;
238271 font-size: 12.5px;
239272 }
240-.head-room { display: flex; align-items: center; gap: 12px; min-width: 0; }
273+.head-room { display: flex; align-items: center; gap: 10px; min-width: 0; }
241274 .head-room-text { min-width: 0; flex: 1; }
242275 .conn-banner.error { background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-text); }
243276 /* ——— Sidebar ——— */
@@ -250,8 +283,8 @@ input, textarea { font: inherit; }
250283 }
251284 .side-search {
252285 display: flex;
253- gap: 6px;
254- padding: 12px;
286+ gap: 4px;
287+ padding: 8px;
255288 border-bottom: 1px solid var(--border);
256289 }
257290 .side-search input {
@@ -259,18 +292,20 @@ input, textarea { font: inherit; }
259292 min-width: 0;
260293 background: var(--bg-soft);
261294 border: 1px solid var(--border);
262- border-radius: 8px;
295+ border-radius: var(--r-md);
263296 color: var(--text);
264- padding: 7px 10px;
297+ height: var(--ctl-h);
298+ padding: 0 10px;
265299 }
266-.side-search input:focus { outline: 2px solid var(--accent); border-color: var(--accent); }
267-.side-scroll { flex: 1; overflow-y: auto; padding: 8px; min-height: 0; }
300+.side-search input:focus { outline: none; border-color: var(--accent); }
301+.side-scroll { flex: 1; overflow-y: auto; padding: 6px; min-height: 0; }
268302 .side-h {
269- font-size: 11px;
303+ font-size: 10.5px;
304+ font-weight: 650;
270305 text-transform: uppercase;
271306 letter-spacing: 0.08em;
272307 color: var(--muted);
273- margin: 12px 6px 6px;
308+ margin: 12px 8px 4px;
274309 }
275310 .side-h:first-child { margin-top: 4px; }
276311 .room-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
@@ -281,21 +316,24 @@ input, textarea { font: inherit; }
281316 gap: 6px;
282317 background: transparent;
283318 border: none;
284- border-radius: 8px;
285- color: var(--text);
286- padding: 7px 9px;
319+ border-radius: var(--r-md);
320+ color: var(--muted);
321+ padding: 4px 8px;
322+ min-height: 32px;
287323 cursor: pointer;
288324 text-align: left;
289325 font-size: 13.5px;
326+ transition: background var(--pop), color var(--pop);
290327 }
291-.room-row:hover { background: var(--panel-2); }
328+.room-row:hover { background: var(--panel-2); color: var(--text); }
292329 .room-item { display: flex; align-items: center; gap: 2px; }
293330 .room-item > .room-row { flex: 1; min-width: 0; }
294331 .rail-space.drop-before { box-shadow: 0 -6px 0 -3px var(--accent); }
295332 .rail-space.drop-after { box-shadow: 0 6px 0 -3px var(--accent); }
296-.room-row.active { background: color-mix(in srgb, var(--accent) 20%, transparent); }
333+.room-row.active { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--text); box-shadow: inset 2px 0 0 var(--accent); }
334+.room-row:has(.badge) { color: var(--text); font-weight: 600; }
297335 .room-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
298-.room-hash { color: var(--muted); }
336+.up-row { margin-bottom: 4px; }
299337 .room-name {
300338 flex: 1;
301339 min-width: 0;
@@ -305,10 +343,10 @@ input, textarea { font: inherit; }
305343 }
306344 .badge {
307345 flex: none;
308- min-width: 20px;
309- height: 20px;
310- padding: 0 6px;
311- border-radius: 999px;
346+ min-width: 18px;
347+ height: 18px;
348+ padding: 0 5px;
349+ border-radius: var(--r-sm);
312350 background: var(--panel-2);
313351 border: 1px solid var(--border);
314352 color: var(--text);
@@ -322,7 +360,7 @@ input, textarea { font: inherit; }
322360 flex: none;
323361 background: var(--accent);
324362 color: #fff;
325- border-radius: 6px;
363+ border-radius: var(--r-sm);
326364 padding: 2px 10px;
327365 font-size: 12px;
328366 font-weight: 600;
@@ -335,20 +373,41 @@ input, textarea { font: inherit; }
335373 background: var(--panel-2);
336374 border: 1px solid var(--border);
337375 color: var(--text);
338- border-radius: 8px;
376+ border-radius: var(--r-md);
339377 padding: 8px;
340378 cursor: pointer;
341379 }
342380 .side-cta button:hover { border-color: var(--accent); }
343-.side-footer {
381+.account {
344382 display: flex;
345383 align-items: center;
346- gap: 8px;
347- padding: 10px 12px;
384+ gap: 4px;
385+ min-width: 0;
386+ overflow: hidden;
387+ padding: 8px 10px 8px max(9px, calc((var(--rail-w) - 32px) / 2));
348388 border-top: 1px solid var(--border);
349- background: var(--bg-soft);
389+ border-right: 1px solid var(--border);
390+ background: var(--rail-bg);
391+ transition: padding var(--pane-ease);
350392 }
351-.me { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
393+.me-btn {
394+ flex: 1;
395+ min-width: 0;
396+ display: flex;
397+ align-items: center;
398+ gap: 10px;
399+ padding: 0;
400+ border: none;
401+ background: none;
402+ color: var(--text);
403+ text-align: left;
404+ cursor: pointer;
405+}
406+.me-names { min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
407+.me-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
408+.account-actions { flex: none; display: flex; gap: 2px; }
409+.app.nav-folded .account { padding-right: 0; }
410+.app.nav-folded .account .me-names, .app.nav-folded .account-actions { display: none; }
352411 .me-avatar {
353412 flex: none;
354413 width: 30px; height: 30px;
@@ -361,6 +420,7 @@ input, textarea { font: inherit; }
361420 place-items: center;
362421 }
363422 .me-id {
423+ display: block;
364424 min-width: 0;
365425 overflow: hidden;
366426 text-overflow: ellipsis;
@@ -370,15 +430,22 @@ input, textarea { font: inherit; }
370430 }
371431 .icon-btn {
372432 flex: none;
433+ display: inline-grid;
434+ place-items: center;
435+ width: var(--ctl-h);
436+ height: var(--ctl-h);
437+ padding: 0;
373438 background: transparent;
374439 border: 1px solid transparent;
375- border-radius: 8px;
440+ border-radius: var(--r-md);
376441 color: var(--muted);
377442 font-size: 16px;
378- padding: 5px 9px;
443+ line-height: 1;
379444 cursor: pointer;
445+ transition: background var(--pop), color var(--pop);
380446 }
381447 .icon-btn:hover:not(:disabled) { background: var(--panel-2); color: var(--text); }
448+.icon-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
382449 .icon-btn:disabled { opacity: 0.4; cursor: default; }
383450 .scrim { display: none; }
384451
@@ -399,35 +466,38 @@ input, textarea { font: inherit; }
399466 .chat-head {
400467 display: flex;
401468 align-items: center;
402- gap: 10px;
403- padding: 10px 16px;
469+ gap: 6px;
470+ padding: 0 10px 0 max(8px, calc((var(--rail-w) - var(--ctl-h)) / 2));
404471 border-bottom: 1px solid var(--border);
405- background: var(--panel);
406- min-height: 62px;
472+ background: var(--rail-bg);
473+ height: 50px;
407474 }
475+.chat-head .head-title { margin-left: 8px; }
408476 .head-title { flex: 1; min-width: 0; }
409477 .head-title h1 {
410478 margin: 0;
411- font-size: 15.5px;
479+ font-size: 14.5px;
480+ font-weight: 650;
481+ line-height: 1.2;
412482 overflow: hidden;
413483 text-overflow: ellipsis;
414484 white-space: nowrap;
415485 }
416-.head-meta { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
486+.head-meta { display: flex; align-items: center; gap: 6px; margin-top: 1px; }
417487 .pill {
418- font-size: 11px;
488+ font-size: 10.5px;
489+ line-height: 16px;
419490 border: 1px solid var(--border);
420- border-radius: 999px;
421- padding: 0 8px;
491+ border-radius: var(--r-sm);
492+ padding: 0 5px;
422493 color: var(--muted);
423494 }
424-.conn { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
495+.conn { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--muted); }
425496 .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); display: inline-block; }
426497 .dot-online { background: var(--ok); }
427498 .dot-busy { background: var(--warn); }
428499 .dot-off { background: var(--danger); }
429-.head-actions { display: flex; align-items: center; gap: 4px; }
430-.head-actions .icon-btn { display: grid; place-items: center; width: 34px; height: 34px; padding: 0; }
500+.head-actions { display: flex; align-items: center; gap: 2px; }
431501 .head-actions .icon-btn[aria-pressed='true'], .head-actions .icon-btn[aria-expanded='true'] { background: var(--panel-2); color: var(--text); }
432502 .room-menu-wrap { position: relative; }
433503 .room-menu {
@@ -441,13 +511,15 @@ input, textarea { font: inherit; }
441511 padding: 4px;
442512 background: var(--panel-2);
443513 border: 1px solid var(--border);
444- border-radius: 8px;
514+ border-radius: var(--r-md);
445515 box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
516+ transform-origin: top right;
517+ animation: pop-in var(--pop);
446518 }
447519 .room-menu button {
448520 background: transparent;
449521 border: none;
450- border-radius: 6px;
522+ border-radius: var(--r-sm);
451523 color: var(--text);
452524 font: inherit;
453525 font-size: 13.5px;
@@ -460,33 +532,45 @@ input, textarea { font: inherit; }
460532 .room-menu button.danger { color: var(--danger); }
461533
462534 .primary-btn {
535+ display: inline-flex;
536+ align-items: center;
537+ justify-content: center;
538+ gap: 6px;
539+ height: var(--ctl-h);
463540 background: var(--accent);
464541 border: none;
465542 color: #fff;
466543 font-weight: 600;
467- border-radius: 8px;
468- padding: 8px 16px;
544+ border-radius: var(--r-md);
545+ padding: 0 14px;
469546 cursor: pointer;
470- font-size: 13.5px;
547+ font-size: 13px;
548+ transition: filter var(--pop), opacity var(--pop);
471549 }
472-.primary-btn:hover:not(:disabled) { filter: brightness(1.1); }
550+.primary-btn:hover:not(:disabled) { filter: brightness(1.12); }
473551 .primary-btn:disabled { opacity: 0.55; cursor: default; }
474552 .primary-btn.wide { width: 100%; margin-top: 12px; }
475553 .ghost-btn {
554+ display: inline-flex;
555+ align-items: center;
556+ justify-content: center;
557+ gap: 6px;
558+ height: var(--ctl-h);
476559 background: var(--panel-2);
477560 border: 1px solid var(--border);
478561 color: var(--text);
479- border-radius: 8px;
480- padding: 7px 14px;
562+ border-radius: var(--r-md);
563+ padding: 0 12px;
481564 cursor: pointer;
482565 font-size: 13px;
566+ transition: border-color var(--pop), background var(--pop);
483567 }
484568 .ghost-btn:hover:not(:disabled) { border-color: var(--accent); }
485569 .ghost-btn:disabled { opacity: 0.55; cursor: wait; }
486570 .ghost-btn.danger { color: var(--danger); }
487571 .ghost-btn.danger:hover:not(:disabled) { border-color: var(--danger); }
488572
489-.banners { display: flex; flex-direction: column; gap: 6px; padding: 10px 16px 0; }
573+.banners { display: flex; flex-direction: column; gap: 6px; padding: 8px 12px 0; }
490574 .banner {
491575 display: flex;
492576 align-items: center;
@@ -494,9 +578,10 @@ input, textarea { font: inherit; }
494578 background: var(--danger-bg);
495579 border: 1px solid var(--danger-border);
496580 color: var(--danger-text);
497- border-radius: 8px;
498- padding: 8px 12px;
581+ border-radius: var(--r-md);
582+ padding: 6px 10px;
499583 font-size: 13px;
584+ animation: pop-in var(--pop);
500585 }
501586 .banner span { flex: 1; }
502587 .banner button {
@@ -513,14 +598,14 @@ input, textarea { font: inherit; }
513598 background: var(--warn-bg);
514599 border: 1px solid var(--warn-border);
515600 color: var(--warn-text);
516- border-radius: 8px;
601+ border-radius: var(--r-md);
517602 font-size: 12.5px;
518603 }
519604
520605 .timeline {
521606 flex: 1;
522607 overflow-y: auto;
523- padding: 12px 20px 16px;
608+ padding: 8px 14px 10px;
524609 min-height: 0;
525610 }
526611 .timeline-top { display: flex; justify-content: center; padding: 4px 0 12px; }
@@ -561,12 +646,13 @@ input, textarea { font: inherit; }
561646
562647 .composer {
563648 display: flex;
564- gap: 10px;
649+ gap: 4px;
565650 align-items: flex-end;
566- padding: 12px 16px 16px;
651+ padding: 8px 12px;
567652 border-top: 1px solid var(--border);
568653 background: var(--panel);
569654 }
655+.composer > .icon-btn, .composer > .primary-btn { flex: none; }
570656 .reply-bar {
571657 display: flex;
572658 align-items: center;
@@ -583,9 +669,8 @@ input, textarea { font: inherit; }
583669 .reply-bar-text { margin-left: 6px; }
584670 .composer.replying, .composer.staging, .reply-bar + .staged { border-top: none; }
585671 .composer { position: relative; }
586-.sticker-pop { position: absolute; bottom: calc(100% - 6px); left: 16px; z-index: 30; }
587-.sticker-btn { align-self: center; padding: 4px 6px; }
588-.sticker-btn[aria-expanded='true'] { color: var(--accent); }
672+.sticker-pop { position: absolute; bottom: calc(100% - 4px); left: 12px; z-index: 30; animation: pop-up var(--pop); transform-origin: bottom left; }
673+.composer .icon-btn[aria-expanded='true'], .composer .icon-btn[aria-pressed='true'] { color: var(--accent); }
589674 .mention-finder {
590675 position: absolute;
591676 left: 16px;
@@ -597,20 +682,22 @@ input, textarea { font: inherit; }
597682 padding: 4px;
598683 list-style: none;
599684 border: 1px solid var(--border);
600- border-radius: 10px;
685+ border-radius: var(--r-md);
601686 background: var(--panel-2);
602687 box-shadow: 0 8px 24px rgb(0 0 0 / 0.3);
688+ animation: pop-up var(--pop);
603689 }
604690 .mention-finder li {
605691 display: flex;
606692 align-items: center;
607693 gap: 8px;
608694 padding: 5px 8px;
609- border-radius: 6px;
695+ border-radius: var(--r-sm);
610696 cursor: pointer;
611697 font-size: 13px;
612698 }
613699 .mention-finder li.active { background: color-mix(in srgb, var(--accent) 22%, transparent); }
700+.finder-emote { flex: none; display: inline-grid; place-items: center; width: 22px; height: 22px; object-fit: contain; font-size: 15px; }
614701 .mention-name { font-weight: 600; white-space: nowrap; }
615702 .mention-id { min-width: 0; color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
616703 .staged {
@@ -630,7 +717,7 @@ input, textarea { font: inherit; }
630717 width: 84px;
631718 height: 84px;
632719 border: 1px solid var(--border);
633- border-radius: 10px;
720+ border-radius: var(--r-md);
634721 background: var(--bg-soft);
635722 overflow: hidden;
636723 }
@@ -648,24 +735,55 @@ input, textarea { font: inherit; }
648735 background: var(--panel);
649736 font-size: 11px;
650737 }
651-.composer textarea {
738+.composer-field {
739+ position: relative;
652740 flex: 1;
653- resize: none;
741+ min-width: 0;
742+ display: grid;
743+ margin: 0 4px;
654744 background: var(--bg-soft);
655745 border: 1px solid var(--border);
656- border-radius: 10px;
746+ border-radius: var(--r-md);
747+ transition: border-color var(--pop);
748+}
749+.composer-field:focus-within { border-color: var(--accent); }
750+.composer-field > textarea, .composer-field > .composer-marks {
751+ grid-area: 1 / 1;
752+ margin: 0;
753+ border: none;
754+ padding: 7px 10px;
755+ font: inherit;
756+ font-size: 14px;
757+ line-height: 18px;
758+ letter-spacing: normal;
759+ white-space: pre-wrap;
760+ overflow-wrap: anywhere;
761+ min-height: calc(var(--ctl-h) - 2px);
762+ max-height: min(40vh, 280px);
763+}
764+.composer-field > textarea {
765+ resize: none;
766+ background: transparent;
657767 color: var(--text);
658- padding: 10px 12px;
659- min-height: 40px;
660- max-height: 160px;
661- line-height: 1.4;
768+ outline: none;
769+ overflow-y: auto;
770+}
771+.composer-marks {
772+ pointer-events: none;
773+ color: transparent;
774+ overflow: hidden;
775+}
776+.composer-marks mark {
777+ color: transparent;
778+ background: color-mix(in srgb, var(--accent) 26%, transparent);
779+ border-radius: var(--r-sm);
780+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent);
662781 }
663-.composer textarea:focus { outline: 2px solid var(--accent); border-color: var(--accent); }
664782 .join-bar {
665783 display: flex;
666784 align-items: center;
667785 gap: 12px;
668- padding: 12px 16px 16px;
786+ padding: 8px 12px;
669787 border-top: 1px solid var(--border);
670788 background: var(--panel);
671789 }
@@ -688,7 +806,7 @@ input, textarea { font: inherit; }
688806 }
689807 .members-head h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0; }
690808 .member-list { list-style: none; margin: 0; padding: 0 8px 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
691-.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; }
809+.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; }
692810 .member:hover { background: var(--panel-2); }
693811 .member-names { min-width: 0; display: flex; flex-direction: column; }
694812 .member-name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -703,13 +821,18 @@ input, textarea { font: inherit; }
703821 place-items: center;
704822 padding: 20px;
705823 z-index: 50;
824+ animation: fade-in var(--pop);
706825 }
707826 .modal {
708827 width: min(420px, 100%);
828+ max-height: calc(100dvh - 40px);
829+ overflow-y: auto;
709830 background: var(--panel);
710831 border: 1px solid var(--border);
711- border-radius: 14px;
712- padding: 18px;
832+ border-radius: var(--r-lg);
833+ padding: 16px;
834+ box-shadow: 0 16px 48px rgb(0 0 0 / 0.45);
835+ animation: pop-in 0.18s cubic-bezier(0.2, 0.8, 0.2, 1);
713836 }
714837 .modal-tabs { display: flex; gap: 4px; margin-bottom: 14px; }
715838 .modal-tabs button[role='tab'] {
@@ -728,7 +851,7 @@ input, textarea { font: inherit; }
728851 .modal .field input {
729852 background: var(--bg-soft);
730853 border: 1px solid var(--border);
731- border-radius: 8px;
854+ border-radius: var(--r-md);
732855 color: var(--text);
733856 padding: 9px 11px;
734857 font-size: 14px;
@@ -745,6 +868,8 @@ input, textarea { font: inherit; }
745868 transition: none;
746869 }
747870 .app > .rail, .app > .sidebar { visibility: visible !important; }
871+ .account .me-names, .account-actions { display: none; }
872+ .pane-resize { display: none; }
748873 .app > .sidebar { transition: transform var(--pane-ease); }
749874 .app > .sidebar > *, .app > .members > * { min-width: 0; }
750875 .app > .members { transition: transform var(--pane-ease), visibility 0s linear 0s; }
@@ -752,7 +877,7 @@ input, textarea { font: inherit; }
752877 transform: translateX(100%);
753878 transition: transform var(--pane-ease), visibility 0s linear 0.22s;
754879 }
755- .sidebar {
880+ .app > .sidebar {
756881 position: fixed;
757882 top: 0; bottom: 0; left: 60px;
758883 width: min(320px, 85vw);
@@ -760,7 +885,7 @@ input, textarea { font: inherit; }
760885 transform: translateX(calc(-100% - 60px));
761886 box-shadow: 8px 0 32px rgba(0, 0, 0, 0.5);
762887 }
763- .sidebar.open { transform: none; }
888+ .app > .sidebar.open { transform: none; }
764889 .scrim {
765890 display: block;
766891 position: fixed;
@@ -771,7 +896,7 @@ input, textarea { font: inherit; }
771896 z-index: 30;
772897 cursor: pointer;
773898 }
774- .members {
899+ .app > .members {
775900 position: fixed;
776901 top: 0; bottom: 0; right: 0;
777902 width: min(300px, 85vw);
@@ -788,10 +913,54 @@ input, textarea { font: inherit; }
788913
789914 @media (max-width: 640px) {
790915 .app { --rail-w: 52px !important; }
791- .sidebar { left: 52px; transform: translateX(calc(-100% - 52px)); }
916+ .app > .sidebar { left: 52px; transform: translateX(calc(-100% - 52px)); }
792917 .scrim { left: 52px; }
793918 .rail-btn { width: 40px; height: 40px; font-size: 16px; }
794919 .empty-cta { flex-direction: column; }
795920 }
796-.md-toggle { align-self: center; padding: 4px 6px; }
797-.md-toggle[aria-pressed='true'] { color: var(--accent); }
921+
922+.pinned-strip {
923+ flex: none;
924+ border-bottom: 1px solid var(--border);
925+ background: var(--bg-soft);
926+ font-size: 13px;
927+ animation: fade-in var(--pop);
928+}
929+.pinned-head { display: flex; align-items: center; gap: 8px; padding: 0 8px 0 14px; min-height: 36px; color: var(--accent); }
930+.pinned-latest, .pinned-list li > button:first-child {
931+ flex: 1;
932+ min-width: 0;
933+ display: flex;
934+ gap: 6px;
935+ align-items: baseline;
936+ padding: 6px 0;
937+ border: none;
938+ background: none;
939+ color: var(--text);
940+ font: inherit;
941+ text-align: left;
942+ cursor: pointer;
943+}
944+.pinned-latest span, .pinned-list span { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
945+.pinned-latest:hover span, .pinned-list li > button:first-child:hover span { color: var(--text); }
946+.pinned-count {
947+ flex: none;
948+ display: inline-flex;
949+ align-items: center;
950+ gap: 4px;
951+ height: 26px;
952+ padding: 0 8px;
953+ border: 1px solid var(--border);
954+ border-radius: var(--r-sm);
955+ background: var(--panel);
956+ color: var(--muted);
957+ font: inherit;
958+ font-size: 12px;
959+ cursor: pointer;
960+}
961+.pinned-count:hover { color: var(--text); }
962+.pinned-count svg { transition: transform var(--pop); }
963+.pinned-strip.open .pinned-count svg { transform: rotate(180deg); }
964+.pinned-list { list-style: none; margin: 0; padding: 0 8px 6px 36px; max-height: 40vh; overflow-y: auto; animation: pop-in var(--pop); }
965+.pinned-list li { display: flex; align-items: center; gap: 6px; border-top: 1px solid var(--border); }
966+.pinned-strip .icon-btn { width: 26px; height: 26px; }
Modified file: web/src/lib/ContextMenu.svelte+8 additions−3 deletions
@@ -4,6 +4,7 @@
44 action: () => void;
55 danger?: boolean;
66 disabled?: boolean;
7+ divider?: boolean;
78 }
89 </script>
910
@@ -14,6 +15,7 @@
1415 export let x: number;
1516 export let y: number;
1617 export let onClose: () => void;
18+ export let above = false;
1719
1820 const MARGIN = 8;
1921
@@ -25,7 +27,7 @@
2527 await tick();
2628 const rect = menu.getBoundingClientRect();
2729 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));
30+ top = Math.max(MARGIN, Math.min(above ? y - rect.height : y, window.innerHeight - rect.height - MARGIN));
2931 menu.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
3032 });
3133
@@ -50,6 +52,7 @@
5052
5153 <div class="context-menu" role="menu" style={`left:${left}px;top:${top}px`} bind:this={menu}>
5254 {#each items as item}
55+ {#if item.divider}<hr />{/if}
5356 <button type="button" role="menuitem" class:danger={item.danger} disabled={item.disabled} on:click={() => pick(item)}>
5457 {item.label}
5558 </button>
@@ -58,6 +61,7 @@
5861
5962 <style>
6063 .context-menu {
64+ animation: pop-in var(--pop);
6165 position: fixed;
6266 z-index: 90;
6367 min-width: 180px;
@@ -66,13 +70,13 @@
6670 padding: 4px;
6771 background: var(--panel-2);
6872 border: 1px solid var(--border);
69- border-radius: 8px;
73+ border-radius: var(--r-md);
7074 box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
7175 }
7276 button {
7377 background: transparent;
7478 border: none;
75- border-radius: 6px;
79+ border-radius: var(--r-sm);
7680 color: var(--text);
7781 font: inherit;
7882 font-size: 13.5px;
@@ -83,4 +87,5 @@
8387 button:hover:not(:disabled), button:focus-visible { background: var(--hover); outline: none; }
8488 button:disabled { opacity: 0.5; cursor: default; }
8589 .danger { color: var(--danger); }
90+ hr { border: none; border-top: 1px solid var(--border); margin: 4px 2px; }
8691 </style>
Modified file: web/src/lib/ImageLightbox.svelte+2 additions−1 deletions
@@ -66,6 +66,7 @@
6666
6767 <style>
6868 .lightbox {
69+ animation: fade-in var(--pop);
6970 position: fixed;
7071 inset: 0;
7172 z-index: 100;
@@ -95,7 +96,7 @@
9596 max-width: 100%;
9697 max-height: 100%;
9798 object-fit: contain;
98- border-radius: 6px;
99+ border-radius: var(--r-sm);
99100 box-shadow: 0 12px 40px rgb(0 0 0 / 0.5);
100101 cursor: zoom-out;
101102 }
Modified file: web/src/lib/LinkCard.svelte+2 additions−2 deletions
@@ -46,7 +46,7 @@
4646 <style>
4747 .link-card {
4848 display: flex; gap: 10px;
49- border: 1px solid var(--border); border-radius: 8px;
49+ border: 1px solid var(--border); border-radius: var(--r-md);
5050 background: var(--bg-soft); padding: 8px 10px;
5151 text-decoration: none; color: var(--text);
5252 overflow: hidden;
@@ -64,6 +64,6 @@
6464 }
6565 .link-path { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
6666 .link-url { font-size: 11.5px; color: var(--accent); overflow-wrap: anywhere; }
67- .link-image { flex: none; width: 72px; height: 72px; object-fit: cover; border-radius: 6px; }
67+ .link-image { flex: none; width: 72px; height: 72px; object-fit: cover; border-radius: var(--r-sm); }
6868 .banner .link-image { width: 100%; height: auto; max-height: 220px; }
6969 </style>
Modified file: web/src/lib/LoginPanel.svelte+7 additions−7 deletions
@@ -65,7 +65,7 @@
6565 </div>
6666 {/if}
6767 <label class="field">
68- <span>Homeserver (network address — not a space)</span>
68+ <span>Homeserver</span>
6969 <input
7070 type="url"
7171 inputmode="url"
@@ -117,7 +117,7 @@
117117 width: min(400px, 100%);
118118 background: var(--panel);
119119 border: 1px solid var(--border);
120- border-radius: 16px;
120+ border-radius: var(--r-lg);
121121 padding: 28px 26px;
122122 display: flex;
123123 flex-direction: column;
@@ -126,7 +126,7 @@
126126 .login-brand {
127127 width: 44px;
128128 height: 44px;
129- border-radius: 14px;
129+ border-radius: var(--r-lg);
130130 display: grid;
131131 place-items: center;
132132 font-weight: 800;
@@ -139,7 +139,7 @@
139139 background: var(--danger-bg);
140140 border: 1px solid var(--danger-border);
141141 color: var(--danger-text);
142- border-radius: 8px;
142+ border-radius: var(--r-md);
143143 padding: 8px 10px;
144144 font-size: 13px;
145145 margin: 0;
@@ -147,7 +147,7 @@
147147 .login-notice {
148148 background: var(--bg-soft);
149149 border: 1px solid var(--border);
150- border-radius: 8px;
150+ border-radius: var(--r-md);
151151 padding: 10px 12px;
152152 font-size: 12.5px;
153153 color: var(--muted);
@@ -170,7 +170,7 @@
170170 .field input {
171171 background: var(--bg-soft);
172172 border: 1px solid var(--border);
173- border-radius: 8px;
173+ border-radius: var(--r-md);
174174 color: var(--text);
175175 padding: 9px 11px;
176176 font-size: 14px;
@@ -182,7 +182,7 @@
182182 border: none;
183183 color: #fff;
184184 font-weight: 600;
185- border-radius: 9px;
185+ border-radius: var(--r-md);
186186 padding: 10px;
187187 font-size: 14.5px;
188188 cursor: pointer;
Modified file: web/src/lib/MediaAttachmentView.svelte+3 additions−3 deletions
@@ -107,11 +107,11 @@
107107 display: block;
108108 max-width: 100%;
109109 max-height: 320px;
110- border-radius: 8px;
110+ border-radius: var(--r-md);
111111 border: 1px solid var(--border);
112112 background: var(--bg-soft);
113113 }
114- .media-video { display: block; max-width: 100%; max-height: 320px; border-radius: 8px; background: #000; }
114+ .media-video { display: block; max-width: 100%; max-height: 320px; border-radius: var(--r-md); background: #000; }
115115 .media-audio { display: block; width: 100%; max-width: 420px; margin-top: 6px; }
116116 .media-block { margin: 6px 0 2px; max-width: 420px; }
117117 .media-caption { font-size: 12px; color: var(--muted); margin-top: 4px; display: flex; gap: 4px; flex-wrap: wrap; }
@@ -124,7 +124,7 @@
124124 margin: 6px 0 2px;
125125 padding: 6px 12px;
126126 border: 1px solid var(--border);
127- border-radius: 8px;
127+ border-radius: var(--r-md);
128128 background: var(--bg-soft);
129129 max-width: 100%;
130130 }
Modified file: web/src/lib/MessageRow.svelte+21 additions−12 deletions
@@ -45,6 +45,18 @@
4545 const PICKER_HEIGHT = 380;
4646 const PICKER_MARGIN = 8;
4747 const MAX_READERS = 3;
48+ const STATE_LABELS: Record<string, string> = {
49+ 'm.room.pinned_events': 'changed the pinned messages',
50+ 'm.room.power_levels': 'changed the room permissions',
51+ 'm.room.join_rules': 'changed who can join',
52+ 'm.room.history_visibility': 'changed who can read history',
53+ 'm.room.guest_access': 'changed guest access',
54+ 'm.room.canonical_alias': 'changed the room address',
55+ 'm.room.server_acl': 'changed the server access list',
56+ 'm.space.child': 'changed the rooms in this space',
57+ 'm.space.parent': 'changed the parent space',
58+ 'im.ponies.room_emotes': 'updated a sticker pack'
59+ };
4860
4961 let row: HTMLDivElement;
5062 let pickerPop: HTMLDivElement | undefined;
@@ -220,7 +232,7 @@
220232 case 'RoomUpgraded':
221233 return `${displayName} upgraded the room to a new version`;
222234 case 'OtherState':
223- return `${displayName} sent a state event (${content.event_type})`;
235+ return `${displayName} ${STATE_LABELS[content.event_type] ?? `sent a state event (${content.event_type})`}`;
224236 case 'Other':
225237 return `${displayName} sent an event (${content.event_type})`;
226238 default:
@@ -389,10 +401,7 @@
389401 {#if event.content.kind === 'Message'}
390402 {@const m = event.content.message}
391403 {#if stripped.text}
392- <div class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>
393- {#if m.kind === 'Emote'}* {displayName} {/if}<MessageText text={stripped.text} html={m.formatted_body} jumbo={m.kind === 'Text'} {mentions} {self} nameOf={(id) => people[id]?.name ?? null} />
394- {#if m.edited && !showSender}<span class="edited" title="Edited">(edited)</span>{/if}
395- </div>
404+ <div class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>{#if m.kind === 'Emote'}* {displayName} {/if}<MessageText text={stripped.text} html={m.formatted_body} jumbo={m.kind === 'Text'} {mentions} {self} nameOf={(id) => people[id]?.name ?? null} />{#if m.edited && !showSender}<span class="edited" title="Edited">(edited)</span>{/if}</div>
396405 {/if}
397406 {#if m.media}
398407 <MediaAttachmentView attachment={m.media} />
@@ -496,7 +505,7 @@
496505 font-size: 12.5px;
497506 text-align: left;
498507 background: var(--bg-soft);
499- border-radius: 0 6px 6px 0;
508+ border-radius: 0 var(--r-sm) var(--r-sm) 0;
500509 cursor: pointer;
501510 }
502511 .reply-quote:disabled { cursor: default; }
@@ -521,7 +530,7 @@
521530 display: inline-flex; align-items: center; gap: 4px;
522531 background: var(--bg-soft);
523532 border: 1px solid var(--border);
524- border-radius: 999px;
533+ border-radius: var(--r-sm);
525534 padding: 1px 8px;
526535 font-size: 13px;
527536 color: var(--text);
@@ -538,7 +547,7 @@
538547 max-width: 260px;
539548 padding: 6px 9px;
540549 border: 1px solid var(--border);
541- border-radius: 8px;
550+ border-radius: var(--r-md);
542551 background: var(--panel-2);
543552 box-shadow: 0 6px 18px rgb(0 0 0 / 0.3);
544553 color: var(--text);
@@ -558,12 +567,12 @@
558567 .readby :global(.mxc-avatar), .readby :global(.mxc-fallback) { box-shadow: 0 0 0 1.5px var(--bg); }
559568 .readby .more { margin-left: 3px; font-size: 10.5px; color: var(--muted); }
560569 .actions {
561- position: absolute; top: -14px; right: 12px; z-index: 2;
570+ position: absolute; top: -12px; right: 10px; z-index: 2;
562571 display: flex; gap: 2px;
563572 padding: 2px;
564573 background: var(--bg-soft);
565574 border: 1px solid var(--border);
566- border-radius: 8px;
575+ border-radius: var(--r-md);
567576 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
568577 opacity: 0;
569578 pointer-events: none;
@@ -573,7 +582,7 @@
573582 .actions button {
574583 background: transparent;
575584 border: 1px solid transparent;
576- border-radius: 6px;
585+ border-radius: var(--r-sm);
577586 cursor: pointer;
578587 font-size: 14px;
579588 padding: 1px 5px;
@@ -581,5 +590,5 @@
581590 .actions button:hover:not(:disabled) { border-color: var(--border); background: var(--hover); }
582591 .actions button:disabled { opacity: 0.4; cursor: default; }
583592 .emote { width: 1.3em; height: 1.3em; object-fit: contain; vertical-align: middle; }
584- .picker-pop { position: fixed; z-index: 60; width: 320px; max-width: calc(100vw - 16px); }
593+ .picker-pop { position: fixed; z-index: 60; width: 320px; max-width: calc(100vw - 16px); animation: pop-in var(--pop); }
585594 </style>
Modified file: web/src/lib/MessageText.svelte+4 additions−4 deletions
@@ -28,14 +28,14 @@
2828 }
2929 .inline, .rich :global(:not(pre) > code) {
3030 padding: 1px 5px;
31- border-radius: 4px;
31+ border-radius: var(--r-sm);
3232 background: var(--bg-soft);
3333 border: 1px solid var(--border);
3434 }
3535 .block, .rich :global(pre) {
3636 margin: 4px 0;
3737 padding: 8px 10px;
38- border-radius: 6px;
38+ border-radius: var(--r-sm);
3939 background: var(--bg-soft);
4040 border: 1px solid var(--border);
4141 overflow-x: auto;
@@ -44,7 +44,7 @@
4444 }
4545 .mention, .rich :global(.mention) {
4646 padding: 0 4px;
47- border-radius: 4px;
47+ border-radius: var(--r-sm);
4848 font-weight: 600;
4949 color: var(--accent);
5050 background: color-mix(in srgb, var(--accent) 18%, transparent);
@@ -73,7 +73,7 @@
7373 .rich :global(table) { border-collapse: collapse; }
7474 .rich :global(:is(th, td)) { border: 1px solid var(--border); padding: 3px 8px; text-align: left; }
7575 .rich :global(.spoiler) {
76- border-radius: 4px;
76+ border-radius: var(--r-sm);
7777 background: var(--text);
7878 color: transparent;
7979 cursor: pointer;
Modified file: web/src/lib/MxcAvatar.svelte+8 additions−6 deletions
@@ -1,12 +1,13 @@
11 <script lang="ts">
22 import { mediaUrl } from './api';
3- import { initialsOf } from './format';
3+ import { initialsOf, senderHue } from './format';
44
55 export let mxc: string | null;
66 export let label: string;
77 export let size = 34;
88 /** 'circle' for people, 'rounded' for rooms/spaces */
99 export let shape: 'circle' | 'rounded' = 'circle';
10+ export let seed: string | null = null;
1011
1112 $: src = mediaUrl(mxc);
1213
@@ -14,6 +15,7 @@
1415 $: if (mxc) failed = false;
1516 $: px = `${size}px`;
1617 $: fontPx = `${Math.max(10, Math.round(size * 0.36))}px`;
18+ $: hue = senderHue(seed ?? label ?? '#');
1719 </script>
1820
1921 {#if src && !failed}
@@ -30,7 +32,7 @@
3032 on:error={() => (failed = true)}
3133 />
3234 {:else}
33- <span class="mxc-fallback" class:circle={shape === 'circle'} aria-hidden="true" style={`width:${px};height:${px};font-size:${fontPx};`}>
35+ <span class="mxc-fallback" class:circle={shape === 'circle'} aria-hidden="true" style={`width:${px};height:${px};font-size:${fontPx};background:hsl(${hue} 32% 30%);`}>
3436 {initialsOf(label || '#')}
3537 </span>
3638 {/if}
@@ -39,7 +41,7 @@
3941 .mxc-avatar {
4042 flex: none;
4143 object-fit: cover;
42- border-radius: 8px;
44+ border-radius: var(--r-md);
4345 background: var(--bg-soft);
4446 }
4547 .mxc-avatar.circle { border-radius: 50%; }
@@ -47,10 +49,10 @@
4749 flex: none;
4850 display: inline-grid;
4951 place-items: center;
50- border-radius: 8px;
51- background: var(--panel-2);
52- color: var(--text);
52+ border-radius: var(--r-md);
53+ color: #fff;
5354 font-weight: 700;
55+ letter-spacing: 0.02em;
5456 }
5557 .mxc-fallback.circle { border-radius: 50%; }
5658 </style>
Modified file: web/src/lib/PackManager.svelte+5 additions−5 deletions
@@ -339,7 +339,7 @@
339339 gap: 8px;
340340 padding: 6px 8px;
341341 border: none;
342- border-radius: 8px;
342+ border-radius: var(--r-md);
343343 background: transparent;
344344 color: var(--text);
345345 font: inherit;
@@ -354,7 +354,7 @@
354354 .new-pack { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
355355 .editor { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
356356 .meta { display: flex; align-items: center; gap: 10px; }
357- .avatar-btn { padding: 0; border: none; background: none; cursor: pointer; border-radius: 10px; }
357+ .avatar-btn { padding: 0; border: none; background: none; cursor: pointer; border-radius: var(--r-md); }
358358 .name { flex: 1; font-size: 15px; }
359359 .row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
360360 .spacer { flex: 1; }
@@ -362,7 +362,7 @@
362362 input[type='text'], select {
363363 background: var(--bg-soft);
364364 border: 1px solid var(--border);
365- border-radius: 6px;
365+ border-radius: var(--r-sm);
366366 color: var(--text);
367367 padding: 6px 8px;
368368 font: inherit;
@@ -376,7 +376,7 @@
376376 gap: 4px;
377377 padding: 6px;
378378 border: 1px solid var(--border);
379- border-radius: 8px;
379+ border-radius: var(--r-md);
380380 background: var(--bg-soft);
381381 }
382382 .image img { width: 100%; aspect-ratio: 1; object-fit: contain; }
@@ -387,7 +387,7 @@
387387 flex: 1;
388388 padding: 2px 0;
389389 border: 1px solid var(--border);
390- border-radius: 6px;
390+ border-radius: var(--r-sm);
391391 background: transparent;
392392 color: var(--muted);
393393 cursor: pointer;
Modified file: web/src/lib/PinButton.svelte+1 additions−1 deletions
@@ -33,7 +33,7 @@
3333 height: 24px;
3434 padding: 0;
3535 border: none;
36- border-radius: 6px;
36+ border-radius: var(--r-sm);
3737 background: transparent;
3838 color: var(--muted);
3939 cursor: pointer;
Modified file: web/src/lib/ReactionPicker.svelte+11 additions−14 deletions
@@ -180,16 +180,13 @@
180180 {/if}
181181 </div>
182182
183- <div class="foot">
184- {#if customising}
185- <span class="muted">{full ? 'Quick reactions are full. Remove one to add another.' : 'Select emoji to add or remove them.'}</span>
186- {/if}
187- {#if !embedded}
183+ {#if !embedded}
184+ <div class="foot">
188185 <button type="button" class="mode" aria-pressed={customising} on:click={() => (customising = !customising)}>
189186 {customising ? 'Done' : 'Customise quick reactions'}
190187 </button>
191- {/if}
192- </div>
188+ </div>
189+ {/if}
193190 </div>
194191
195192 <style>
@@ -201,32 +198,32 @@
201198 padding: 8px;
202199 background: var(--panel);
203200 border: 1px solid var(--border);
204- border-radius: 10px;
201+ border-radius: var(--r-md);
205202 box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
206203 }
207204 .search {
208205 background: var(--bg-soft);
209206 border: 1px solid var(--border);
210- border-radius: 8px;
207+ border-radius: var(--r-md);
211208 color: var(--text);
212209 padding: 6px 10px;
213210 font-size: 13px;
214211 }
215212 .search:focus-visible { outline: none; border-color: var(--accent); }
216213 .muted { color: var(--muted); font-size: 12px; }
217- .quick { border: 1px solid var(--border); border-radius: 8px; padding: 6px; background: var(--bg-soft); }
214+ .quick { border: 1px solid var(--border); border-radius: var(--r-md); padding: 6px; background: var(--bg-soft); }
218215 .quick-head { display: flex; justify-content: space-between; font-size: 12px; font-weight: 650; margin-bottom: 4px; }
219216 .quick-row { display: flex; flex-wrap: wrap; gap: 4px; min-height: 28px; align-items: center; }
220217 .quick-item {
221218 display: inline-flex; align-items: center; gap: 2px;
222219 padding: 1px 2px 1px 6px;
223- border: 1px solid var(--border); border-radius: 999px;
220+ border: 1px solid var(--border); border-radius: var(--r-sm);
224221 font-size: 16px;
225222 }
226223 .quick-item img { width: 18px; height: 18px; object-fit: contain; }
227224 .remove {
228225 background: transparent; border: none; color: var(--muted);
229- cursor: pointer; font-size: 14px; line-height: 1; padding: 2px 5px; border-radius: 999px;
226+ cursor: pointer; font-size: 14px; line-height: 1; padding: 2px 5px; border-radius: var(--r-sm);
230227 }
231228 .remove:hover { color: var(--danger); }
232229 .scroll { flex: 1; min-height: 0; overflow-y: auto; }
@@ -238,7 +235,7 @@
238235 .cell {
239236 display: grid; place-items: center;
240237 height: 32px; padding: 0;
241- background: transparent; border: 1px solid transparent; border-radius: 6px;
238+ background: transparent; border: 1px solid transparent; border-radius: var(--r-sm);
242239 font-size: 20px; cursor: pointer; color: var(--text);
243240 }
244241 .cell img { width: 24px; height: 24px; object-fit: contain; }
@@ -249,7 +246,7 @@
249246 .foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
250247 .mode {
251248 margin-left: auto;
252- background: transparent; border: 1px solid var(--border); border-radius: 6px;
249+ background: transparent; border: 1px solid var(--border); border-radius: var(--r-sm);
253250 color: var(--text); font-size: 12px; padding: 3px 8px; cursor: pointer;
254251 }
255252 .mode:hover, .mode[aria-pressed='true'] { border-color: var(--accent); }
Modified file: web/src/lib/SecurityPanel.svelte+2 additions−2 deletions
@@ -214,7 +214,7 @@
214214 .sec-note { margin: 8px 0 0; }
215215 .pill.ok { background: color-mix(in srgb, var(--ok) 18%, transparent); border-color: var(--ok); color: var(--ok-text); }
216216 .pill.warn { background: color-mix(in srgb, var(--warn) 15%, transparent); border-color: var(--warn); color: var(--warn); }
217- code { font-size: 12px; background: var(--bg-soft); padding: 1px 6px; border-radius: 6px; }
217+ code { font-size: 12px; background: var(--bg-soft); padding: 1px 6px; border-radius: var(--r-sm); }
218218 .check { display: flex; align-items: center; gap: 8px; font-size: 13px; }
219219 .row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
220220 .emoji-grid {
@@ -223,7 +223,7 @@
223223 }
224224 .emoji-grid li {
225225 display: flex; flex-direction: column; align-items: center; gap: 2px;
226- background: var(--bg-soft); border: 1px solid var(--border); border-radius: 8px; padding: 8px 4px;
226+ background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--r-md); padding: 8px 4px;
227227 }
228228 .emoji { font-size: 24px; }
229229 .emoji-name { font-size: 11px; color: var(--muted); text-align: center; }
Modified file: web/src/lib/SettingsPanel.svelte+2 additions−2 deletions
@@ -141,7 +141,7 @@
141141 padding: 6px 8px;
142142 background: var(--bg-soft);
143143 border: 1px solid var(--border);
144- border-radius: 8px;
144+ border-radius: var(--r-md);
145145 color: var(--text);
146146 font-size: 13px;
147147 cursor: pointer;
@@ -156,5 +156,5 @@
156156 [data-swatch='nord'] .swatch { background: #2e3440; }
157157 .accent-row { margin-top: 10px; font-size: 13px; }
158158 .accent-row label { flex: 1; }
159- .accent-row input[type='color'] { width: 36px; height: 26px; padding: 0; border: 1px solid var(--border); border-radius: 6px; background: none; cursor: pointer; }
159+ .accent-row input[type='color'] { width: 36px; height: 26px; padding: 0; border: 1px solid var(--border); border-radius: var(--r-sm); background: none; cursor: pointer; }
160160 </style>
Modified file: web/src/lib/StickerPicker.svelte+17 additions−9 deletions
@@ -49,7 +49,7 @@
4949 <div class="top">
5050 <input class="search" type="search" placeholder="Search stickers" bind:value={query} bind:this={search} />
5151 <button type="button" class="icon-btn" title="Manage packs" aria-label="Manage packs" on:click={onManage}>
52- <span aria-hidden="true">⚙</span>
52+ <svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
5353 </button>
5454 </div>
5555 {#if withStickers.length > 1}
@@ -98,7 +98,14 @@
9898 </div>
9999 </section>
100100 {:else}
101- <p class="status">{withStickers.length === 0 ? 'No stickers yet' : `No stickers match "${query.trim()}"`}</p>
101+ {#if withStickers.length === 0}
102+ <div class="empty">
103+ <p class="status">No stickers yet</p>
104+ <button type="button" class="primary-btn" on:click={onManage}>Add a pack</button>
105+ </div>
106+ {:else}
107+ <p class="status">No stickers match "{query.trim()}"</p>
108+ {/if}
102109 {/each}
103110 </div>
104111 </div>
@@ -109,13 +116,13 @@
109116 flex-direction: column;
110117 gap: 6px;
111118 width: 360px;
112- height: 420px;
119+ height: auto;
120+ max-height: min(420px, calc(100vh - 120px));
113121 max-width: calc(100vw - 16px);
114- max-height: calc(100vh - 120px);
115122 padding: 8px;
116123 background: var(--panel);
117124 border: 1px solid var(--border);
118- border-radius: 10px;
125+ border-radius: var(--r-md);
119126 box-shadow: 0 6px 24px rgb(0 0 0 / 0.5);
120127 }
121128 .top { display: flex; gap: 6px; }
@@ -123,7 +130,7 @@
123130 flex: 1;
124131 background: var(--bg-soft);
125132 border: 1px solid var(--border);
126- border-radius: 8px;
133+ border-radius: var(--r-md);
127134 color: var(--text);
128135 padding: 6px 10px;
129136 font-size: 13px;
@@ -138,7 +145,7 @@
138145 height: 32px;
139146 padding: 2px 6px;
140147 border: 1px solid transparent;
141- border-radius: 8px;
148+ border-radius: var(--r-md);
142149 background: transparent;
143150 color: var(--muted);
144151 font-size: 12px;
@@ -161,11 +168,12 @@
161168 aspect-ratio: 1;
162169 padding: 4px;
163170 border: 1px solid transparent;
164- border-radius: 8px;
171+ border-radius: var(--r-md);
165172 background: transparent;
166173 cursor: pointer;
167174 }
168175 .cell:hover, .cell:focus-visible { background: var(--hover); border-color: var(--border); outline: none; }
169176 .cell img { width: 100%; height: 100%; object-fit: contain; }
170- .status { color: var(--muted); font-size: 13px; text-align: center; margin-top: 24px; }
177+ .status { color: var(--muted); font-size: 13px; text-align: center; margin: 16px 0 10px; }
178+ .empty { display: flex; flex-direction: column; align-items: center; padding-bottom: 12px; }
171179 </style>
Modified file: web/src/lib/UserCard.svelte+5 additions−4 deletions
@@ -81,7 +81,7 @@
8181
8282 <div class="user-card" role="dialog" aria-label={person.name} style={`left:${left}px;top:${top}px`} bind:this={card}>
8383 <div class="identity">
84- <MxcAvatar mxc={person.avatar} label={person.name} size={64} />
84+ <MxcAvatar mxc={person.avatar} label={person.name} seed={person.user_id} size={56} />
8585 <div class="names">
8686 <strong class="name">{person.name}</strong>
8787 <button type="button" class="user-id" title="Copy user ID" on:click={() => void copyId()}>
@@ -153,6 +153,7 @@
153153
154154 <style>
155155 .user-card {
156+ animation: pop-in var(--pop);
156157 position: fixed;
157158 z-index: 90;
158159 width: 300px;
@@ -163,7 +164,7 @@
163164 padding: 14px;
164165 background: var(--panel-2);
165166 border: 1px solid var(--border);
166- border-radius: 12px;
167+ border-radius: var(--r-md);
167168 box-shadow: 0 12px 32px rgb(0 0 0 / 0.4);
168169 }
169170 .identity { display: flex; gap: 12px; align-items: center; }
@@ -185,7 +186,7 @@
185186 .tag {
186187 font-size: 11px;
187188 padding: 1px 6px;
188- border-radius: 999px;
189+ border-radius: var(--r-sm);
189190 border: 1px solid var(--border);
190191 color: var(--muted);
191192 }
@@ -198,7 +199,7 @@
198199 input {
199200 background: var(--bg-soft);
200201 border: 1px solid var(--border);
201- border-radius: 6px;
202+ border-radius: var(--r-sm);
202203 color: var(--text);
203204 padding: 5px 8px;
204205 font: inherit;
Modified file: web/src/lib/api.ts+9 additions−6 deletions
@@ -1,6 +1,7 @@
11 // Backend-agnostic API surface. Every call forwards to whichever transport
22 // this build was compiled for, hosted (fetch + SSE) or on-device (worker).
33 import type { MemberSummary, RoomSummary } from './types';
4+import type { Emote } from './emotes';
45 import { formatBody } from './markdown';
56 import { mentionedUserIds, type Mention } from './mentions';
67 import { HttpTransport } from './transport-http';
@@ -41,8 +42,9 @@ export const sendMessage = (
4142 reply_to: string | null = null,
4243 mentions: Mention[] = [],
4344 markdown = false,
44- kind: SendKind = 'text'
45-) => transport.sendMessage(room_id, body, formatBody(body, mentions, markdown), reply_to, mentionedUserIds(mentions), kind);
45+ kind: SendKind = 'text',
46+ emotes: Emote[] = []
47+) => transport.sendMessage(room_id, body, formatBody(body, mentions, markdown, emotes), reply_to, mentionedUserIds(mentions), kind);
4648 export const runCommand = (request: CommandRequest) => transport.command(request);
4749 export const uploadAvatar = (file: File) => transport.uploadAvatar(file);
4850 export const uploadRoomAvatar = (room_id: string, file: File) => transport.uploadRoomAvatar(room_id, file);
@@ -65,8 +67,8 @@ export function createRoom(name: string, topic?: string): Promise<{ room_id: str
6567 export const leaveRoom = (room_id: string) => transport.leaveRoom(room_id);
6668 export const reactToMessage = (room_id: string, event_id: string, key: string, shortcode?: string) =>
6769 transport.reactToMessage(room_id, event_id, key, shortcode);
68-export const editMessage = (room_id: string, event_id: string, body: string, mentions: Mention[] = [], markdown = false) =>
69- transport.editMessage(room_id, event_id, body, formatBody(body, mentions, markdown), mentionedUserIds(mentions));
70+export const editMessage = (room_id: string, event_id: string, body: string, mentions: Mention[] = [], markdown = false, emotes: Emote[] = []) =>
71+ transport.editMessage(room_id, event_id, body, formatBody(body, mentions, markdown, emotes), mentionedUserIds(mentions));
7072 export const markRead = (room_id: string, event_id: string) => transport.markRead(room_id, event_id);
7173
7274 export function getMembers(room_id: string): Promise<{ members: MemberSummary[] }> {
@@ -113,11 +115,12 @@ export function uploadAttachment(
113115 caption: string | null = null,
114116 reply_to: string | null = null,
115117 mentions: Mention[] = [],
116- markdown = false
118+ markdown = false,
119+ emotes: Emote[] = []
117120 ): Promise<void> {
118121 return transport.uploadAttachment(room_id, file, {
119122 caption,
120- formatted_caption: caption ? formatBody(caption, mentions, markdown) : null,
123+ formatted_caption: caption ? formatBody(caption, mentions, markdown, emotes) : null,
121124 reply_to,
122125 mentions: mentionedUserIds(mentions)
123126 });
Modified file: web/src/lib/appearance.ts+7 additions
@@ -29,6 +29,13 @@ const isSpacing = (v: unknown): v is number =>
2929 export const navFolded = persisted('malice.navFolded', false, isBool);
3030 export const railFolded = persisted('malice.railFolded', false, isBool);
3131 export const markdownEnabled = persisted('malice.markdown', true, isBool);
32+
33+export const SIDE_WIDTH = { min: 200, max: 480, default: 260 };
34+export const MEMBERS_WIDTH = { min: 200, max: 420, default: 240 };
35+const widthIn = (range: { min: number; max: number }) => (v: unknown): v is number =>
36+ typeof v === 'number' && Number.isFinite(v) && v >= range.min && v <= range.max;
37+export const sideWidth = persisted('malice.sideWidth', SIDE_WIDTH.default, widthIn(SIDE_WIDTH));
38+export const membersWidth = persisted('malice.membersWidth', MEMBERS_WIDTH.default, widthIn(MEMBERS_WIDTH));
3239 export const messageSpacing = persisted('malice.messageSpacing', MESSAGE_SPACING_DEFAULT, isSpacing);
3340
3441 export const LINE_HEIGHT_MIN = 1.1;
Modified file: web/src/lib/commands.ts+33 additions
@@ -72,3 +72,36 @@ export function splitTarget(args: string, people: Person[]): Target {
7272 if (ids.length > 1) return { error: `Several people are called ${best[0].name}, use their @id` };
7373 return { user_id: ids[0], rest: args.slice(bestLength).trim() };
7474 }
75+
76+export const COMMAND_USAGE: Record<CommandName, string> = {
77+ me: '<action>',
78+ notice: '<text>',
79+ msg: '<user> [message]',
80+ join: '<room>',
81+ leave: '',
82+ invite: '<user>',
83+ disinvite: '<user> [reason]',
84+ kick: '<user> [reason]',
85+ ban: '<user> [reason]',
86+ unban: '<user> [reason]',
87+ powerlevel: '<user> <level>',
88+ mute: '<user>',
89+ unmute: '<user>',
90+ ignore: '<user>',
91+ unignore: '<user>',
92+ delete: '[reason]',
93+ roomnick: '[name]',
94+ globalnick: '<name>',
95+ roomavatar: '[mxc]',
96+ globalavatar: '[mxc]'
97+};
98+
99+/** The command name being typed when the caret sits in a leading `/word`. */
100+export function commandQueryAt(text: string, caret: number): string | null {
101+ const match = /^\/([a-z]*)$/i.exec(text.slice(0, caret));
102+ return match ? match[1].toLowerCase() : null;
103+}
104+
105+export function matchCommands(prefix: string): CommandName[] {
106+ return COMMANDS.filter((name) => name.startsWith(prefix));
107+}
Added file: web/src/lib/composer-marks.ts+33 additions
@@ -0,0 +1,33 @@
1+import type { Mention } from './mentions';
2+
3+export interface MarkPart {
4+ text: string;
5+ mark: boolean;
6+}
7+
8+/** The composer text split so picked mention names and emote shortcodes can be highlighted behind the textarea. */
9+export function markMentions(text: string, mentions: Mention[], extra: string[] = []): MarkPart[] {
10+ const names = [...new Set([...mentions.map((m) => m.text), ...extra].filter(Boolean))].sort((a, b) => b.length - a.length);
11+ if (!names.length || !text) return [{ text, mark: false }];
12+ const parts: MarkPart[] = [];
13+ let rest = text;
14+ while (rest) {
15+ let at = -1;
16+ let hit = '';
17+ for (const name of names) {
18+ const i = rest.indexOf(name);
19+ if (i >= 0 && (at < 0 || i < at)) {
20+ at = i;
21+ hit = name;
22+ }
23+ }
24+ if (at < 0) {
25+ parts.push({ text: rest, mark: false });
26+ break;
27+ }
28+ if (at > 0) parts.push({ text: rest.slice(0, at), mark: false });
29+ parts.push({ text: hit, mark: true });
30+ rest = rest.slice(at + hit.length);
31+ }
32+ return parts;
33+}
Added file: web/src/lib/emotes.ts+83 additions
@@ -0,0 +1,83 @@
1+import type { EmojiEntry } from './emoji';
2+import type { ImagePack } from './types';
3+
4+export interface Emote {
5+ shortcode: string;
6+ mxc: string;
7+}
8+
9+export type EmoteCandidate =
10+ | { kind: 'emoji'; emoji: string; shortcode: string }
11+ | { kind: 'custom'; shortcode: string; mxc: string };
12+
13+export interface EmoteQuery {
14+ start: number;
15+ query: string;
16+}
17+
18+const SHORTCODE = /^[\w+-]+$/;
19+
20+export function emoteQueryAt(text: string, caret: number): EmoteQuery | null {
21+ const before = text.slice(0, caret);
22+ const colon = before.lastIndexOf(':');
23+ if (colon < 0 || (colon > 0 && !/\s/.test(before[colon - 1]))) return null;
24+ const query = before.slice(colon + 1);
25+ if (query.length < 2 || query.length > 40 || !SHORTCODE.test(query)) return null;
26+ return { start: colon, query };
27+}
28+
29+export function customEmotes(packs: ImagePack[]): Emote[] {
30+ const seen = new Map<string, Emote>();
31+ for (const pack of packs) {
32+ for (const image of pack.images) {
33+ if (image.usage.length && !image.usage.includes('Emoticon')) continue;
34+ if (!seen.has(image.shortcode)) seen.set(image.shortcode, { shortcode: image.shortcode, mxc: image.media_id });
35+ }
36+ }
37+ return [...seen.values()];
38+}
39+
40+function score(code: string, q: string): number {
41+ const c = code.toLowerCase();
42+ if (c === q) return 0;
43+ if (c.startsWith(q)) return 1;
44+ if (c.split(/[_-]/).some((part) => part.startsWith(q))) return 2;
45+ return c.includes(q) ? 3 : -1;
46+}
47+
48+export function rankEmotes(query: string, customs: Emote[], emoji: EmojiEntry[], limit = 8): EmoteCandidate[] {
49+ const q = query.toLowerCase();
50+ const scored: { candidate: EmoteCandidate; score: number; length: number }[] = [];
51+ for (const emote of customs) {
52+ const s = score(emote.shortcode, q);
53+ if (s >= 0) scored.push({ candidate: { kind: 'custom', ...emote }, score: s, length: emote.shortcode.length });
54+ }
55+ for (const entry of emoji) {
56+ let best: { code: string; s: number } | null = null;
57+ for (const code of entry.shortcodes) {
58+ const s = score(code, q);
59+ if (s >= 0 && (!best || s < best.s || (s === best.s && code.length < best.code.length))) best = { code, s };
60+ }
61+ if (best) scored.push({ candidate: { kind: 'emoji', emoji: entry.emoji, shortcode: best.code }, score: best.s, length: best.code.length });
62+ }
63+ return scored
64+ .sort((a, b) => a.score - b.score || a.length - b.length)
65+ .slice(0, limit)
66+ .map((s) => s.candidate);
67+}
68+
69+/** When the caret sits just after a closing colon of a known emoji shortcode, swap the shortcode for the emoji. */
70+export function completeShortcode(text: string, caret: number, emoji: EmojiEntry[]): { text: string; caret: number } | null {
71+ const match = /(^|\s):([\w+-]+):$/.exec(text.slice(0, caret));
72+ if (!match) return null;
73+ const code = match[2].toLowerCase();
74+ const entry = emoji.find((e) => e.shortcodes.includes(code));
75+ if (!entry) return null;
76+ const start = caret - code.length - 2;
77+ return { text: text.slice(0, start) + entry.emoji + text.slice(caret), caret: start + entry.emoji.length };
78+}
79+
80+export function resolveEmotes(body: string, customs: Emote[]): Emote[] {
81+ const used = new Set([...body.matchAll(/:([\w+-]+):/g)].map((m) => m[1]));
82+ return customs.filter((e) => used.has(e.shortcode));
83+}
Modified file: web/src/lib/markdown.ts+15 additions−2 deletions
@@ -1,4 +1,5 @@
11 import { Marked, type Token, type Tokens } from 'marked';
2+import type { Emote } from './emotes';
23 import type { Mention } from './mentions';
34
45 function escapeHtml(text: string): string {
@@ -18,6 +19,16 @@ function mentionLinker(mentions: Mention[]): (escaped: string) => string {
1819 escaped.replace(pattern, (name) => `<a href="https://matrix.to/#/${escapeHtml(byText.get(name)!)}">${name}</a>`);
1920 }
2021
22+function emoteLinker(emotes: Emote[]): (escaped: string) => string {
23+ if (emotes.length === 0) return (escaped) => escaped;
24+ const byCode = new Map(emotes.map((e) => [e.shortcode, e.mxc]));
25+ return (escaped) =>
26+ escaped.replace(/:([\w+-]+):/g, (whole, code: string) => {
27+ const mxc = byCode.get(code);
28+ return mxc ? `<img data-mx-emoticon src="${escapeHtml(mxc)}" alt="${whole}" title="${whole}" height="32">` : whole;
29+ });
30+}
31+
2132 function textToHtml(text: string, link: (escaped: string) => string): string {
2233 return link(escapeHtml(text)).replace(/\n/g, '<br>');
2334 }
@@ -55,8 +66,10 @@ function markdownToHtml(body: string, link: (escaped: string) => string): string
5566 }
5667
5768 /** Matrix `formatted_body` for a composed message, or null when plain text says it all. */
58-export function formatBody(body: string, mentions: Mention[], markdown: boolean): string | null {
59- const link = mentionLinker(mentions);
69+export function formatBody(body: string, mentions: Mention[], markdown: boolean, emotes: Emote[] = []): string | null {
70+ const mention = mentionLinker(mentions);
71+ const emote = emoteLinker(emotes);
72+ const link = (escaped: string) => emote(mention(escaped));
6073 const html = markdown ? markdownToHtml(body, link) : null;
6174 if (html !== null) return html;
6275 const plain = textToHtml(body, link);
Modified file: web/src/lib/pending.ts+7 additions−3 deletions
@@ -2,6 +2,7 @@ import { get, writable } from 'svelte/store';
22 import { sendMessage } from './api';
33 import { formatBody } from './markdown';
44 import { timelines } from './store';
5+import type { Emote } from './emotes';
56 import type { Mention } from './mentions';
67 import type { ReplyReference, TimelineItem } from './types';
78
@@ -21,6 +22,7 @@ interface PendingRecord extends PendingMessage {
2122 roomId: string;
2223 mentions: Mention[];
2324 markdown: boolean;
25+ emotes: Emote[];
2426 knownIds: Set<string>;
2527 }
2628
@@ -77,7 +79,7 @@ async function attemptSend(record: PendingRecord): Promise<void> {
7779 if (!records.has(record.id)) return;
7880 updateRecord(record.id, { status: 'sending', error: null });
7981 try {
80- await sendMessage(record.roomId, record.body, record.replyTo?.event_id ?? null, record.mentions, record.markdown);
82+ await sendMessage(record.roomId, record.body, record.replyTo?.event_id ?? null, record.mentions, record.markdown, 'text', record.emotes);
8183 } catch (e) {
8284 updateRecord(record.id, { status: 'failed', error: e instanceof Error ? e.message : 'Message could not be sent' });
8385 }
@@ -94,16 +96,18 @@ export function queueSend(
9496 body: string,
9597 replyTo: ReplyReference | null = null,
9698 mentions: Mention[] = [],
97- markdown = false
99+ markdown = false,
100+ emotes: Emote[] = []
98101 ): void {
99102 const record: PendingRecord = {
100103 id: nextId(),
101104 roomId,
102105 body,
103- formatted: formatBody(body, mentions, markdown),
106+ formatted: formatBody(body, mentions, markdown, emotes),
104107 replyTo,
105108 mentions,
106109 markdown,
110+ emotes,
107111 status: 'sending',
108112 error: null,
109113 createdAt: Date.now(),
Modified file: web/src/lib/store.ts+26 additions
@@ -27,6 +27,7 @@ export const softLogout = writable<SoftLogout | null>(null);
2727 export const blocked = writable<boolean>(false);
2828 export const rooms = writable<RoomSummary[]>([]);
2929 export const timelines = writable<Record<string, TimelineItem[]>>({});
30+export const pinnedTimelines = writable<Record<string, TimelineItem[]>>({});
3031 export const connection = writable<string>('connecting');
3132 export const selectedRoomId = writable<string | null>(null);
3233 export const membersByRoom = writable<Record<string, MemberSummary[]>>({});
@@ -55,6 +56,7 @@ export function dismissBanner(id: number): void {
5556 export function resetClientState(): void {
5657 rooms.set([]);
5758 timelines.set({});
59+ pinnedTimelines.set({});
5860 connection.set('connecting');
5961 selectedRoomId.set(null);
6062 membersByRoom.set({});
@@ -96,6 +98,12 @@ function handlePayload(text: string): void {
9698 }
9799 timelines.update((all) => ({ ...all, ...next }));
98100 }
101+ const rawPinned = data['pinned'];
102+ if (isRecord(rawPinned)) {
103+ const next: Record<string, TimelineItem[]> = {};
104+ for (const [roomId, list] of Object.entries(rawPinned)) next[roomId] = parseTimelineList(list);
105+ pinnedTimelines.set(next);
106+ }
99107 if (typeof data['connection'] === 'string') connection.set(data['connection']);
100108 const snapshotEncryption = parseEncryptionState(data['encryption']);
101109 if (snapshotEncryption) encryption.set(snapshotEncryption);
@@ -117,6 +125,24 @@ function handlePayload(text: string): void {
117125 if (typeof roomId === 'string') applyTimelinePayload(roomId, data);
118126 return;
119127 }
128+ if (kind === 'pinned') {
129+ const roomId = data['room_id'];
130+ if (typeof roomId === 'string' && Array.isArray(data['items'])) {
131+ const items = parseTimelineList(data['items']);
132+ pinnedTimelines.update((all) => ({ ...all, [roomId]: items }));
133+ }
134+ return;
135+ }
136+ if (kind === 'pinned_closed') {
137+ const roomId = data['room_id'];
138+ if (typeof roomId === 'string') {
139+ pinnedTimelines.update((all) => {
140+ const { [roomId]: _closed, ...rest } = all;
141+ return rest;
142+ });
143+ }
144+ return;
145+ }
120146 if (kind === 'connection') {
121147 if (typeof data['state'] === 'string') connection.set(data['state']);
122148 return;
Modified file: web/src/lib/transport.ts+2 additions
@@ -36,6 +36,8 @@ export type CommandRequest =
3636 | { op: 'mute'; room_id: string; user_id: string; muted: boolean }
3737 | { op: 'ignore' | 'unignore'; user_id: string }
3838 | { op: 'power_levels'; room_id: string }
39+ | { op: 'open_pinned' | 'close_pinned'; room_id: string }
40+ | { op: 'set_pinned'; room_id: string; event_id: string; pinned: boolean }
3941 | { op: 'send_sticker'; room_id: string; pack_id: string; shortcode: string }
4042 | { op: 'create_room_pack'; room_id: string; state_key: string; display_name: string }
4143 | { op: 'delete_pack'; target: PackTarget }
Modified file: web/src/lib/types.ts+5 additions−1 deletions
@@ -42,6 +42,8 @@ export interface RoomSummary {
4242 children: string[];
4343 space_order: string | null;
4444 can_edit_packs: boolean;
45+ pinned_events: string[];
46+ can_pin: boolean;
4547 }
4648
4749 export interface MemberSummary {
@@ -366,7 +368,9 @@ export function parseRoomSummary(raw: unknown): RoomSummary | null {
366368 parent_spaces: stringList(raw['parent_spaces']),
367369 children: stringList(raw['children']),
368370 space_order: optionalString(raw, 'space_order'),
369- can_edit_packs: raw['can_edit_packs'] === true
371+ can_edit_packs: raw['can_edit_packs'] === true,
372+ pinned_events: stringList(raw['pinned_events']),
373+ can_pin: raw['can_pin'] === true
370374 };
371375 }
372376
Modified file: web/src/sw.ts+1 additions−1 deletions
@@ -7,7 +7,7 @@ import { attachmentContentType } from './lib/media-sniff';
77 const sw = self as unknown as ServiceWorkerGlobalScope & { __MALICE_PRECACHE__: string[] };
88
99 const CACHE_NAME = 'malice-shell-v1';
10-const AVATAR_CACHE = 'malice-avatars-v1';
10+const AVATAR_CACHE = 'malice-avatars-v2';
1111
1212 sw.addEventListener('install', (event) => {
1313 event.waitUntil(
Modified file: web/test/commands.test.ts+9 additions
@@ -27,3 +27,12 @@ test('finds targets by id or the longest display name', () => {
2727 assert.ok('error' in splitTarget('Zed', people));
2828 assert.ok('error' in splitTarget('', people));
2929 });
30+
31+test('a leading slash word opens the command list', async () => {
32+ const { commandQueryAt, matchCommands } = await import('../src/lib/commands.ts');
33+ assert.equal(commandQueryAt('/ki', 3), 'ki');
34+ assert.equal(commandQueryAt('/', 1), '');
35+ assert.equal(commandQueryAt('/kick bob', 9), null);
36+ assert.equal(commandQueryAt('hi /ki', 6), null);
37+ assert.deepEqual(matchCommands('un'), ['unban', 'unmute', 'unignore']);
38+});
Added file: web/test/emotes.test.ts+38 additions
@@ -0,0 +1,38 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { completeShortcode, emoteQueryAt, rankEmotes, resolveEmotes } from '../src/lib/emotes.ts';
4+import { formatBody } from '../src/lib/markdown.ts';
5+
6+const sob = { emoji: '😭', label: 'loudly crying face', tags: [], shortcodes: ['sob'] };
7+const smile = { emoji: '😄', label: 'grinning face with smiling eyes', tags: [], shortcodes: ['smile'] };
8+const blob = { shortcode: 'blob_sob', mxc: 'mxc://example.org/blob' };
9+
10+test('opens the emote query after two shortcode characters at a word start', () => {
11+ assert.deepEqual(emoteQueryAt('so :so', 6), { start: 3, query: 'so' });
12+ assert.equal(emoteQueryAt('so :s', 5), null);
13+ assert.equal(emoteQueryAt('re:so', 5), null);
14+ assert.equal(emoteQueryAt('12:30', 5), null);
15+ assert.equal(emoteQueryAt(':so b', 5), null);
16+});
17+
18+test('ranks prefix matches ahead of word and substring matches', () => {
19+ const names = rankEmotes('sob', [blob], [sob, smile]).map((c) => c.shortcode);
20+ assert.deepEqual(names, ['sob', 'blob_sob']);
21+});
22+
23+test('swaps a closed emoji shortcode for the emoji', () => {
24+ assert.deepEqual(completeShortcode('ugh :sob:', 9, [sob]), { text: 'ugh 😭', caret: 6 });
25+ assert.equal(completeShortcode('ugh :blob_sob:', 14, [sob]), null);
26+ assert.equal(completeShortcode('a:sob:', 6, [sob]), null);
27+});
28+
29+test('sends used custom emotes as emoticon images', () => {
30+ const emotes = resolveEmotes('hi :blob_sob: and :nope:', [blob]);
31+ assert.deepEqual(emotes, [blob]);
32+ assert.equal(
33+ formatBody('hi :blob_sob:', [], false, emotes),
34+ 'hi <img data-mx-emoticon src="mxc://example.org/blob" alt=":blob_sob:" title=":blob_sob:" height="32">'
35+ );
36+ assert.match(formatBody('**hi** :blob_sob:', [], true, emotes) ?? '', /<strong>hi<\/strong> <img data-mx-emoticon/);
37+ assert.equal(formatBody('`:blob_sob:`', [], true, emotes), '<p><code>:blob_sob:</code></p>');
38+});