Commit 05a48e9e7942
tarball30 files changed+1012-288
@@ -36,9 +36,14 @@
3636
uploadAttachment,3737
uploadAvatar,3838
uploadRoomAvatar,39
- editMessage39
+ editMessage,40
+ mediaUrl4041
} 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
verification6268
} 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
+ sideWidth96
+ } 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:resizing1714
+ 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
- <button1744
- 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
- <button1922
- 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
- <button1931
- 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} pinned2187
+ <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
+ <li2385
+ 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
+ <li2401
+ 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
<button21852451
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
<button21952465
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}​</div>22222494
<textarea22232495
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
<button22382513
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>@@ -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; }@@ -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>@@ -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
}@@ -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>@@ -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
<input7070
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;@@ -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
}@@ -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>@@ -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;@@ -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>@@ -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;@@ -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;@@ -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); }@@ -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; }@@ -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>@@ -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>@@ -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;@@ -1,6 +1,7 @@
11
// Backend-agnostic API surface. Every call forwards to whichever transport22
// 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 = false118
+ 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
});@@ -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;@@ -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
+}@@ -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
+}@@ -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 scored64
+ .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
+}@@ -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);@@ -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 = false99
+ 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(),@@ -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;@@ -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 }@@ -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'] === true371
+ can_edit_packs: raw['can_edit_packs'] === true,372
+ pinned_events: stringList(raw['pinned_events']),373
+ can_pin: raw['can_pin'] === true370374
};371375
}372376
@@ -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(@@ -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
+});@@ -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
+});