atagen

malice

public
Star0
Watch0

Commit dda384a360e0

tarball

atagen <boss@atagen.co>2026-10-25 10:46 UTC

10 files changed+822-584
threads: share the room composer and let the thread sit beside members

The thread pane had its own bare textarea, so mentions, emotes, markdown marks, attachments, paste and edit-last were missing in threads. The composer is now one component used by the room and by threads, with thread uploads carried through Upload's thread_root. Stickers stay room-only until macltok's send_sticker takes a TimelineId. The thread gets its own grid column instead of borrowing the members slot. Both panes show side by side from 1300px, and below that the last one opened covers the other without closing it, so closing members brings the thread back.
Modified file: crates/malice-core/src/request.rs+5 additions
@@ -102,6 +102,8 @@ pub enum Request {
102102 reply_to: Option<String>,
103103 #[serde(default)]
104104 mentions: Vec<String>,
105+ #[serde(default)]
106+ thread_root: Option<String>,
105107 #[serde(skip)]
106108 bytes: Vec<u8>,
107109 },
@@ -572,8 +574,10 @@ pub async fn dispatch(
572574 formatted_caption,
573575 reply_to,
574576 mentions,
577+ thread_root,
575578 bytes,
576579 } => {
580+ let timeline = timeline_for(&room_id, thread_root.as_deref())?;
577581 let room_id = parse_room_id(&room_id)?;
578582 if bytes.is_empty() || bytes.len() > 20 * 1024 * 1024 {
579583 return Err(CoreError::bad_request(
@@ -587,6 +591,7 @@ pub async fn dispatch(
587591 return Err(CoreError::bad_request("caption is too large"));
588592 }
589593 let mut upload = AttachmentUpload::new(room_id, filename, mimetype, bytes);
594+ upload.timeline_id = timeline;
590595 upload.caption = caption.map(|text| match formatted_caption {
591596 Some(html) => RichText::html(text, html),
592597 None => RichText::plain(text),
Modified file: crates/malice-server/src/routes.rs+8 additions
@@ -475,6 +475,7 @@ async fn rooms_upload(
475475 let mut caption = None;
476476 let mut formatted_caption = None;
477477 let mut reply_to = None;
478+ let mut thread_root = None;
478479 let mut mentions = Vec::new();
479480 let mut file = None;
480481 loop {
@@ -508,6 +509,12 @@ async fn rooms_upload(
508509 Err(_) => return ApiError::bad_request("invalid reply_to").into_response(),
509510 };
510511 }
512+ Some("thread_root") if thread_root.is_none() => {
513+ thread_root = match field.text().await {
514+ Ok(value) => Some(value),
515+ Err(_) => return ApiError::bad_request("invalid thread_root").into_response(),
516+ };
517+ }
511518 Some("mentions") => match field.text().await {
512519 Ok(value) => mentions.push(value),
513520 Err(_) => return ApiError::bad_request("invalid mention").into_response(),
@@ -549,6 +556,7 @@ async fn rooms_upload(
549556 formatted_caption,
550557 reply_to,
551558 mentions,
559+ thread_root,
552560 bytes: bytes.to_vec(),
553561 },
554562 )
Modified file: web/src/App.svelte+140 additions−499 deletions
@@ -7,11 +7,12 @@
77 import MxcAvatar from './lib/MxcAvatar.svelte';
88 import PinButton from './lib/PinButton.svelte';
99 import ThreadPane from './lib/ThreadPane.svelte';
10+ import Composer from './lib/Composer.svelte';
11+ import { releaseStaged, stageFiles as stageAttachments, type StagedAttachment, type StickerHandlers } from './lib/staging';
1012 import RoomThreads from './lib/RoomThreads.svelte';
1113 import { loadPinnedThreads, openThread, showThread, threadsByRoom } from './lib/threads';
1214 import { threadMatches } from './lib/thread-index';
1315 import ContextMenu, { type MenuItem } from './lib/ContextMenu.svelte';
14- import StickerPicker from './lib/StickerPicker.svelte';
1516 import PackManager from './lib/PackManager.svelte';
1617 import PackCloneDialog from './lib/PackCloneDialog.svelte';
1718 import type { CloneSource } from './lib/pack-clone';
@@ -48,14 +49,11 @@
4849 uploadAttachment,
4950 uploadAvatar,
5051 uploadRoomAvatar,
51- editMessage,
52- mediaUrl
52+ editMessage
5353 } from './lib/api';
5454 import EmojiText from './lib/EmojiText.svelte';
55- import { loadEmoji, type EmojiEntry } from './lib/emoji';
56- import { completeShortcode, customEmotes, emoteQueryAt, rankEmotes, resolveEmotes, type EmoteCandidate, type EmoteQuery } from './lib/emotes';
57- import { COMMAND_USAGE, commandQueryAt, isKnownCommand, matchCommands, parseCommand, splitTarget, unescapeCommand } from './lib/commands';
58- import type { CommandName } from './lib/commands';
55+ import { customEmotes, resolveEmotes } from './lib/emotes';
56+ import { isKnownCommand, parseCommand, splitTarget, unescapeCommand } from './lib/commands';
5957 import type { ParsedCommand } from './lib/commands';
6058 import {
6159 banners,
@@ -84,12 +82,9 @@
8482 } from './lib/store';
8583 import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';
8684 import type { PendingMessage } from './lib/pending';
87- import { insideOpenFence } from './lib/markdown';
8885 import { TimelineViewport } from './lib/timeline-viewport';
89- import ComposerText from './lib/ComposerText.svelte';
90- import { markMentions } from './lib/composer-marks';
91- import { mentionQueryAt, mentionsFromHtml, rankPeople, resolveMentions } from './lib/mentions';
92- import type { Mention, MentionQuery } from './lib/mentions';
86+ import { mentionsFromHtml, resolveMentions } from './lib/mentions';
87+ import type { Mention } from './lib/mentions';
9388 import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';
9489 import { loadKeywords } from './lib/keywords';
9590 import {
@@ -126,7 +121,6 @@
126121 let membersOpen = false;
127122 let replyingTo: ReplyReference | null = null;
128123 let dialog: 'join' | 'create' | 'security' | 'settings' | 'packs' | null = null;
129- let stickersOpen = false;
130124 let cloneSource: CloneSource | null = null;
131125 let joinTarget = '';
132126 let joinBusy = false;
@@ -149,30 +143,14 @@
149143 let stickToBottom = true;
150144 let timelineEl: HTMLElement | null = null;
151145 let timelineViewport: TimelineViewport | null = null;
152- let composerEl: HTMLTextAreaElement | null = null;
153- let marksEl: HTMLDivElement | null = null;
154- let composerComposing = false;
155- $: composerMarks = markMentions(
156- composer,
157- ($selectedRoomId && pickedMentions[$selectedRoomId]) || [],
158- resolveEmotes(composer, roomEmotes).map((e) => `:${e.shortcode}:`)
159- );
160- let fileInput: HTMLInputElement | null = null;
146+ let composerRef: Composer | null = null;
161147 let avatarInput: HTMLInputElement | null = null;
162148 let uploadBusy = false;
163149 let uploadName: string | null = null;
164- interface StagedAttachment {
165- id: number;
166- file: File;
167- previewUrl: string | null;
168- }
169150 let staged: Record<string, StagedAttachment[]> = {};
170151 let editing: { roomId: string; eventId: string; original: string; stash: string } | null = null;
171152 /** Everyone picked from the finder per room; only those whose name is still in the text get linked on send. */
172153 let pickedMentions: Record<string, Mention[]> = {};
173- let mentionQuery: MentionQuery | null = null;
174- let mentionIndex = 0;
175- let nextStagedId = 1;
176154 const WARM_ROOM_LIMIT = 8;
177155 /** Rooms left open after switching away so coming back is instant, least recently viewed first. */
178156 let warmRooms: string[] = [];
@@ -247,8 +225,6 @@
247225 $: members = $selectedRoomId ? ($membersByRoom[$selectedRoomId] ?? []) : [];
248226 $: memberGroups = groupMembers(members, roomLevels);
249227 $: people = peopleIn(items, members);
250- $: mentionCandidates = mentionQuery ? rankPeople(Object.values(people), mentionQuery.query, $sessionUser ?? null) : [];
251- $: if (mentionIndex >= mentionCandidates.length) mentionIndex = 0;
252228 $: hasStart = items.some((i) => i.kind === 'start');
253229 $: grouped = buildGroups(items);
254230 $: lastSentId = lastOwnSent(items);
@@ -521,7 +497,7 @@
521497 openFailed = {};
522498 warmRooms = [];
523499 pickedMentions = {};
524- mentionQuery = null;
500+ composerRef?.closeFinders();
525501 clearAllStaged();
526502 avatarByUser = {};
527503 resetPending();
@@ -594,7 +570,7 @@
594570 selectedRoomId.set(null);
595571 replyingTo = null;
596572 editing = null;
597- mentionQuery = null;
573+ composerRef?.closeFinders();
598574 peeking = null;
599575 membersOpen = false;
600576 sidebarOpen = false;
@@ -659,7 +635,7 @@
659635 composer = drafts[roomId] ?? '';
660636 replyingTo = null;
661637 editing = null;
662- mentionQuery = null;
638+ composerRef?.closeFinders();
663639 stickToBottom = true;
664640 lastReadKey = '';
665641 sidebarOpen = false;
@@ -669,7 +645,7 @@
669645 await tick();
670646 scrollToBottom();
671647 queueReadReceipt();
672- composerEl?.focus();
648+ composerRef?.focus();
673649 return;
674650 }
675651 if (openFailed[roomId]) {
@@ -691,7 +667,7 @@
691667 await tick();
692668 scrollToBottom();
693669 queueReadReceipt();
694- composerEl?.focus();
670+ composerRef?.focus();
695671 }
696672
697673 function touchWarmRoom(roomId: string): void {
@@ -720,22 +696,6 @@
720696 timelineViewport?.releaseScroll();
721697 }
722698
723- function syncComposer(node: HTMLTextAreaElement) {
724- const sync = () => {
725- if (!marksEl) return;
726- marksEl.style.width = `${node.clientWidth}px`;
727- marksEl.scrollTop = node.scrollTop;
728- marksEl.scrollLeft = node.scrollLeft;
729- };
730- const observer = new ResizeObserver(sync);
731- observer.observe(node);
732- node.addEventListener('scroll', sync);
733- return { destroy: () => {
734- observer.disconnect();
735- node.removeEventListener('scroll', sync);
736- } };
737- }
738-
739699 function onTimelineKey(e: KeyboardEvent): void {
740700 if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(e.key)) noteUserScroll();
741701 }
@@ -845,7 +805,7 @@
845805 const text = unescapeCommand(body);
846806 const mentions = resolveMentions(text, pickedMentions[roomId] ?? []);
847807 pickedMentions = { ...pickedMentions, [roomId]: [] };
848- mentionQuery = null;
808+ composerRef?.closeFinders();
849809 if (command) {
850810 const image = attachments[0] ?? null;
851811 if (image) {
@@ -863,7 +823,7 @@
863823 void sendAttachments(roomId, text, reply, mentions, attachments);
864824 }
865825 if (peeking === roomId) stopPeeking();
866- composerEl?.focus();
826+ composerRef?.focus();
867827 }
868828
869829 function lastOwnEventId(): string | null {
@@ -1030,18 +990,10 @@
1030990 function stageFiles(files: File[]): void {
1031991 const roomId = $selectedRoomId;
1032992 if (!roomId || !openReady[roomId]) return;
1033- const accepted: StagedAttachment[] = [];
1034- for (const file of files) {
1035- if (file.size === 0 || file.size > 20 * 1024 * 1024) {
1036- pushBanner(`${file.name || 'File'} must be between 1 byte and 20 MiB.`);
1037- continue;
1038- }
1039- const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
1040- accepted.push({ id: nextStagedId++, file, previewUrl });
1041- }
993+ const accepted = stageAttachments(files, pushBanner);
1042994 if (accepted.length === 0) return;
1043995 staged = { ...staged, [roomId]: [...(staged[roomId] ?? []), ...accepted] };
1044- composerEl?.focus();
996+ composerRef?.focus();
1045997 }
1046998
1047999 function unstage(roomId: string, id: number): void {
@@ -1052,9 +1004,7 @@
10521004 }
10531005
10541006 function clearAllStaged(): void {
1055- for (const list of Object.values(staged)) {
1056- for (const attachment of list) if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
1057- }
1007+ for (const list of Object.values(staged)) releaseStaged(list);
10581008 staged = {};
10591009 }
10601010
@@ -1070,14 +1020,14 @@
10701020 }
10711021 composer = body;
10721022 replyingTo = null;
1073- composerEl?.focus();
1023+ composerRef?.focus();
10741024 }
10751025
10761026 function cancelEdit(): void {
10771027 if (!editing) return;
10781028 composer = editing.stash;
10791029 editing = null;
1080- composerEl?.focus();
1030+ composerRef?.focus();
10811031 }
10821032
10831033 function submitEdit(): void {
@@ -1104,7 +1054,7 @@
11041054 function startReply(target: ReplyReference): void {
11051055 if (editing) cancelEdit();
11061056 replyingTo = target;
1107- composerEl?.focus();
1057+ composerRef?.focus();
11081058 }
11091059
11101060 let pinnedOpenFor: string | null = null;
@@ -1203,184 +1153,6 @@
12031153 else timelineViewport.restore();
12041154 }
12051155
1206- let commandQuery: string | null = null;
1207- let commandIndex = 0;
1208- $: commandCandidates = commandQuery === null ? [] : matchCommands(commandQuery);
1209- $: if (commandIndex >= commandCandidates.length) commandIndex = 0;
1210-
1211- async function pickCommand(name: CommandName): Promise<void> {
1212- if (!composerEl) return;
1213- const caret = composerEl.selectionStart ?? composer.length;
1214- const rest = composer.slice(caret).replace(/^\S*/, '');
1215- composer = `/${name}${rest.startsWith(' ') ? rest : ` ${rest}`}`;
1216- commandQuery = null;
1217- await tick();
1218- const at = name.length + 2;
1219- composerEl.focus();
1220- composerEl.setSelectionRange(at, at);
1221- }
1222-
1223- let emoteQuery: EmoteQuery | null = null;
1224- let emoteIndex = 0;
1225- let emojiList: EmojiEntry[] = [];
1226- $: emoteCandidates = emoteQuery ? rankEmotes(emoteQuery.query, roomEmotes, emojiList) : [];
1227- $: if (emoteIndex >= emoteCandidates.length) emoteIndex = 0;
1228-
1229- function ensureEmoji(): void {
1230- if (emojiList.length) return;
1231- loadEmoji()
1232- .then((groups) => (emojiList = groups.flatMap((g) => g.emojis)))
1233- .catch(() => undefined);
1234- }
1235-
1236- async function onComposerInput(): Promise<void> {
1237- if (composerEl && composer.includes(':')) {
1238- ensureEmoji();
1239- const done = completeShortcode(composerEl.value, composerEl.selectionStart ?? composerEl.value.length, emojiList);
1240- if (done) {
1241- composer = done.text;
1242- await tick();
1243- composerEl.setSelectionRange(done.caret, done.caret);
1244- }
1245- }
1246- updateMentionQuery();
1247- }
1248-
1249- async function pickEmote(candidate: EmoteCandidate): Promise<void> {
1250- if (!emoteQuery || !composerEl) return;
1251- const caret = composerEl.selectionStart ?? composer.length;
1252- const inserted = candidate.kind === 'emoji' ? candidate.emoji : `:${candidate.shortcode}: `;
1253- composer = composer.slice(0, emoteQuery.start) + inserted + composer.slice(caret);
1254- const at = emoteQuery.start + inserted.length;
1255- emoteQuery = null;
1256- await tick();
1257- composerEl.focus();
1258- composerEl.setSelectionRange(at, at);
1259- }
1260-
1261- function updateMentionQuery(): void {
1262- const roomId = $selectedRoomId;
1263- const nextEmote = composerEl ? emoteQueryAt(composerEl.value, composerEl.selectionStart ?? composerEl.value.length) : null;
1264- if (nextEmote?.start !== emoteQuery?.start) emoteIndex = 0;
1265- emoteQuery = nextEmote;
1266- const nextCommand = composerEl ? commandQueryAt(composerEl.value, composerEl.selectionStart ?? composerEl.value.length) : null;
1267- if (nextCommand !== commandQuery) commandIndex = 0;
1268- commandQuery = nextCommand;
1269- const next = composerEl && roomId ? mentionQueryAt(composerEl.value, composerEl.selectionStart ?? composerEl.value.length) : null;
1270- if (next && !mentionQuery && roomId && !$membersByRoom[roomId]) void loadMembers(roomId);
1271- if (next?.start !== mentionQuery?.start) mentionIndex = 0;
1272- mentionQuery = next;
1273- }
1274-
1275- async function pickMention(person: Person): Promise<void> {
1276- const roomId = $selectedRoomId;
1277- if (!mentionQuery || !roomId || !composerEl) return;
1278- const caret = composerEl.selectionStart ?? composer.length;
1279- const inserted = `${person.name} `;
1280- composer = composer.slice(0, mentionQuery.start) + inserted + composer.slice(caret);
1281- const at = mentionQuery.start + inserted.length;
1282- pickedMentions = {
1283- ...pickedMentions,
1284- [roomId]: [...(pickedMentions[roomId] ?? []), { user_id: person.user_id, text: person.name }]
1285- };
1286- mentionQuery = null;
1287- await tick();
1288- composerEl.focus();
1289- composerEl.setSelectionRange(at, at);
1290- }
1291-
1292- function onComposerKey(ev: KeyboardEvent): void {
1293- if (ev.isComposing || composerComposing) return;
1294- if (emoteQuery && emoteCandidates.length > 0) {
1295- if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
1296- ev.preventDefault();
1297- const step = ev.key === 'ArrowDown' ? 1 : -1;
1298- emoteIndex = (emoteIndex + step + emoteCandidates.length) % emoteCandidates.length;
1299- return;
1300- }
1301- if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Tab') {
1302- ev.preventDefault();
1303- void pickEmote(emoteCandidates[emoteIndex]);
1304- return;
1305- }
1306- if (ev.key === 'Escape') {
1307- ev.stopPropagation();
1308- emoteQuery = null;
1309- return;
1310- }
1311- }
1312- if (commandQuery !== null && commandCandidates.length > 0) {
1313- if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
1314- ev.preventDefault();
1315- const step = ev.key === 'ArrowDown' ? 1 : -1;
1316- commandIndex = (commandIndex + step + commandCandidates.length) % commandCandidates.length;
1317- return;
1318- }
1319- const exact = commandCandidates.length === 1 && commandCandidates[0] === commandQuery;
1320- if (ev.key === 'Tab' || (ev.key === 'Enter' && !ev.shiftKey && !exact)) {
1321- ev.preventDefault();
1322- void pickCommand(commandCandidates[commandIndex]);
1323- return;
1324- }
1325- if (ev.key === 'Escape') {
1326- ev.stopPropagation();
1327- commandQuery = null;
1328- return;
1329- }
1330- }
1331- if (mentionQuery && mentionCandidates.length > 0) {
1332- if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
1333- ev.preventDefault();
1334- const step = ev.key === 'ArrowDown' ? 1 : -1;
1335- mentionIndex = (mentionIndex + step + mentionCandidates.length) % mentionCandidates.length;
1336- return;
1337- }
1338- if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Tab') {
1339- ev.preventDefault();
1340- void pickMention(mentionCandidates[mentionIndex]);
1341- return;
1342- }
1343- if (ev.key === 'Escape') {
1344- ev.stopPropagation();
1345- mentionQuery = null;
1346- return;
1347- }
1348- }
1349- if (ev.key === 'Enter' && !ev.shiftKey && !insideOpenFence(composer, composerEl?.selectionStart ?? composer.length)) {
1350- ev.preventDefault();
1351- send();
1352- } else if (ev.key === 'Escape' && editing) {
1353- ev.stopPropagation();
1354- cancelEdit();
1355- } else if (ev.key === 'Escape' && replyingTo) {
1356- ev.stopPropagation();
1357- replyingTo = null;
1358- } else if (ev.key === 'ArrowUp' && !composer && !editing && editLastOwn()) {
1359- ev.preventDefault();
1360- }
1361- }
1362-
1363- function pickFiles(): void {
1364- const roomId = $selectedRoomId;
1365- if (!roomId || !openReady[roomId]) return;
1366- fileInput?.click();
1367- }
1368-
1369- function onFileChosen(): void {
1370- stageFiles(Array.from(fileInput?.files ?? []));
1371- if (fileInput) fileInput.value = '';
1372- }
1373-
1374- function onComposerPaste(ev: ClipboardEvent): void {
1375- const data = ev.clipboardData;
1376- // Office apps put a rendered image next to the copied text, so only take the image when there is no plain text.
1377- if (!data || data.types.includes('text/plain')) return;
1378- const images = Array.from(data.files).filter((f) => f.type.startsWith('image/'));
1379- if (images.length === 0) return;
1380- ev.preventDefault();
1381- stageFiles(images);
1382- }
1383-
13841156 async function react(eventId: string, key: string, shortcode?: string): Promise<void> {
13851157 const roomId = $selectedRoomId;
13861158 if (!roomId || !eventId || reacting) return;
@@ -1495,10 +1267,16 @@
14951267 $: threadView = $openThread && $openThread.room_id === $selectedRoomId ? $openThread : null;
14961268 $: if ($openThread && $openThread.room_id !== $selectedRoomId) void showThread(null);
14971269 let threadFolds: Record<string, boolean> = {};
1270+ const BOTH_PANES_MIN_WIDTH = 1300;
1271+ let viewportWidth = typeof window === 'undefined' ? BOTH_PANES_MIN_WIDTH : window.innerWidth;
1272+ let rightFront: 'thread' | 'members' = 'members';
1273+ $: bothPanesFit = viewportWidth >= BOTH_PANES_MIN_WIDTH;
1274+ $: threadShown = !!threadView && (bothPanesFit || !membersOpen || rightFront === 'thread');
1275+ $: membersShown = !!selectedRoom && membersOpen && (bothPanesFit || !threadView || rightFront === 'members');
14981276
14991277 function openThreadHere(rootEventId: string): void {
15001278 if (!$selectedRoomId) return;
1501- membersOpen = false;
1279+ rightFront = 'thread';
15021280 void showThread({ room_id: $selectedRoomId, root_event_id: rootEventId });
15031281 }
15041282
@@ -1513,8 +1291,12 @@
15131291 }
15141292
15151293 function toggleMembers(): void {
1516- membersOpen = !membersOpen;
1517- if (membersOpen) void showThread(null);
1294+ if (membersShown) {
1295+ membersOpen = false;
1296+ } else {
1297+ membersOpen = true;
1298+ rightFront = 'members';
1299+ }
15181300 if (membersOpen && $selectedRoomId) {
15191301 void loadMembers($selectedRoomId);
15201302 void loadLevels($selectedRoomId);
@@ -1656,9 +1438,7 @@
16561438 ...pickedMentions,
16571439 [roomId]: [...(pickedMentions[roomId] ?? []), { user_id: person.user_id, text: person.name }]
16581440 };
1659- await tick();
1660- composerEl?.focus();
1661- composerEl?.setSelectionRange(composer.length, composer.length);
1441+ await composerRef?.focusEnd();
16621442 }
16631443
16641444 async function userAction(userId: string, action: UserAction): Promise<void> {
@@ -1726,8 +1506,17 @@
17261506 return $rooms.find((r) => r.room_id === roomId)?.name ?? roomId;
17271507 }
17281508
1509+ function roomStickers(roomId: string): StickerHandlers {
1510+ return {
1511+ onSend: (pack, image) => void sendSticker(roomId, pack, image),
1512+ onClone: (pack, image, x, y) => openCloneImageMenu(pack, image, x, y),
1513+ onClonePack: (pack, x, y) => openClonePackMenu(pack, x, y),
1514+ onManage: () => (dialog = 'packs'),
1515+ roomName
1516+ };
1517+ }
1518+
17291519 async function sendSticker(roomId: string, pack: ImagePack, image: PackImage): Promise<void> {
1730- stickersOpen = false;
17311520 stickToBottom = true;
17321521 try {
17331522 await runCommand({ op: 'send_sticker', room_id: roomId, pack_id: pack.id, shortcode: image.shortcode });
@@ -1747,13 +1536,11 @@
17471536 }
17481537
17491538 function openCloneImageMenu(pack: ImagePack, image: PackImage, x: number, y: number): void {
1750- stickersOpen = false;
17511539 menu = null;
17521540 cloneSource = { kind: 'image', pack, image };
17531541 }
17541542
17551543 function openClonePackMenu(pack: ImagePack, x: number, y: number): void {
1756- stickersOpen = false;
17571544 menu = null;
17581545 cloneSource = { kind: 'pack', pack };
17591546 }
@@ -1936,13 +1723,15 @@
19361723 if (ev.key === 'Escape') {
19371724 if (roomMenuOpen) roomMenuOpen = false;
19381725 else if (dialog) dialog = null;
1939- else if (membersOpen && window.innerWidth < 1100) membersOpen = false;
1726+ else if (membersShown && window.innerWidth < 1100) membersOpen = false;
1727+ else if (threadShown && window.innerWidth < 1100) void showThread(null);
19401728 else if (sidebarOpen) sidebarOpen = false;
19411729 }
19421730 }
19431731 </script>
19441732
19451733 <svelte:window
1734+ bind:innerWidth={viewportWidth}
19461735 on:keydown={onKeydown}
19471736 on:pointerdown={closeRoomMenuOutside}
19481737 />
@@ -1965,10 +1754,12 @@
19651754 in:fade={{ duration: motionDuration(220) }}
19661755 class:nav-folded={$navFolded}
19671756 class:rail-folded={$railFolded && selectedRoom}
1968- class:members-open={selectedRoom && (membersOpen || threadView)}
1757+ class:members-open={membersShown}
1758+ class:thread-open={threadShown}
19691759 class:resizing
19701760 style:--side-open={`${$sideWidth}px`}
1971- style:--members-open={`${threadView ? $threadWidth : $membersWidth}px`}
1761+ style:--members-open={`${$membersWidth}px`}
1762+ style:--thread-open={`${$threadWidth}px`}
19721763 style:--msg-gap={`${$messageSpacing}px`}
19731764 style:--msg-line-height={$lineHeight}
19741765 >
@@ -2070,8 +1861,8 @@
20701861 <button
20711862 type="button"
20721863 class="icon-btn"
2073- aria-pressed={membersOpen}
2074- aria-label={membersOpen ? 'Hide members' : 'Show members'}
1864+ aria-pressed={membersShown}
1865+ aria-label={membersShown ? 'Hide members' : 'Show members'}
20751866 title="Members"
20761867 on:click={toggleMembers}
20771868 >
@@ -2691,196 +2482,42 @@
26912482 </div>
26922483
26932484 {#if selectedRoom.membership === 'Joined'}
2694- {#if editing}
2695- <div class="reply-bar" role="status">
2696- <span class="reply-bar-label">
2697- Editing message
2698- <span class="reply-bar-text"><EmojiText text={editing.original} /></span>
2699- </span>
2700- <button type="button" class="icon-btn" aria-label="Cancel edit" title="Cancel edit (Esc)" on:click={cancelEdit}>
2701- <span aria-hidden="true">✕</span>
2702- </button>
2703- </div>
2704- {:else if replyingTo && replyingTo.details.kind === 'Ready'}
2705- <div class="reply-bar" role="status">
2706- <span class="reply-bar-label">
2707- Replying to <strong><EmojiText text={people[replyingTo.details.sender.user_id]?.name ?? senderLabel(replyingTo.details.sender)} /></strong>
2708- <span class="reply-bar-text"><EmojiText text={contentPreview(replyingTo.details.content)} /></span>
2709- </span>
2710- <button type="button" class="icon-btn" aria-label="Cancel reply" title="Cancel reply (Esc)" on:click={() => (replyingTo = null)}>
2711- <span aria-hidden="true">✕</span>
2712- </button>
2713- </div>
2714- {/if}
2715- {#if $selectedRoomId && currentStaged.length > 0}
2716- {@const stagedRoom = $selectedRoomId}
2717- <ul class="staged" aria-label="Attachments to send">
2718- {#each currentStaged as attachment (attachment.id)}
2719- <li class="staged-item">
2720- {#if attachment.previewUrl}
2721- <img src={attachment.previewUrl} alt={attachment.file.name || 'Pasted image'} />
2722- {:else}
2723- <span class="staged-name"><EmojiText text={attachment.file.name || 'attachment'} /></span>
2724- {/if}
2725- <button
2726- type="button"
2727- class="icon-btn staged-remove"
2728- aria-label={`Remove ${attachment.file.name || 'attachment'}`}
2729- title="Remove"
2730- on:click={() => unstage(stagedRoom, attachment.id)}
2731- >
2732- <span aria-hidden="true">✕</span>
2733- </button>
2734- </li>
2735- {/each}
2736- </ul>
2737- {/if}
2738- <form class="composer" class:replying={replyingTo || editing} class:staging={currentStaged.length > 0} on:submit|preventDefault={() => send()}>
2739- <label class="visually-hidden" for="composer">Message {selectedRoom.name}</label>
2740- <input
2741- type="file"
2742- multiple
2743- bind:this={fileInput}
2744- class="visually-hidden"
2745- aria-label="Attach a file"
2746- on:change={() => void onFileChosen()}
2747- />
2748- <input
2749- type="file"
2750- accept="image/*"
2751- bind:this={avatarInput}
2752- class="visually-hidden"
2753- aria-label="Choose a new avatar"
2754- on:change={() => void onAvatarChosen()}
2755- />
2756- <button
2757- type="button"
2758- class="icon-btn"
2759- class:busy={uploadBusy}
2760- title={uploadBusy ? `Uploading ${uploadName ?? 'file'}…` : 'Attach an image or file'}
2761- aria-label="Attach an image or file"
2762- aria-busy={uploadBusy}
2763- disabled={!($selectedRoomId && openReady[$selectedRoomId])}
2764- on:click={pickFiles}
2765- >
2766- <svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
2767- <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" />
2768- </svg>
2769- </button>
2770- <button
2771- type="button"
2772- class="icon-btn sticker-btn"
2773- title="Stickers"
2774- aria-label="Stickers"
2775- aria-expanded={stickersOpen}
2776- disabled={!($selectedRoomId && openReady[$selectedRoomId])}
2777- on:click={() => (stickersOpen = !stickersOpen)}
2778- >
2779- <svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
2780- <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" />
2781- <path d="M13 20v-5a2 2 0 0 1 2-2h5" fill="none" stroke="currentColor" stroke-width="1.8" />
2782- </svg>
2783- </button>
2784- {#if stickersOpen && $selectedRoomId}
2785- {@const stickerRoom = $selectedRoomId}
2786- <div class="sticker-pop">
2787- <StickerPicker
2788- packs={roomPacks}
2789- {roomName}
2790- onSend={(pack, image) => void sendSticker(stickerRoom, pack, image)}
2791- onClone={(pack, image, x, y) => openCloneImageMenu(pack, image, x, y)}
2792- onClonePack={(pack, x, y) => openClonePackMenu(pack, x, y)}
2793- onManage={() => ((stickersOpen = false), (dialog = 'packs'))}
2794- onClose={() => (stickersOpen = false)}
2795- />
2796- </div>
2797- {/if}
2798- <div class="composer-field" class:composing={composerComposing}>
2799- {#if commandQuery !== null && commandCandidates.length > 0}
2800- <ul class="mention-finder" role="listbox" aria-label="Commands">
2801- {#each commandCandidates as name, i (name)}
2802- <li
2803- role="option"
2804- aria-selected={i === commandIndex}
2805- class:active={i === commandIndex}
2806- on:pointerdown|preventDefault={() => void pickCommand(name)}
2807- on:pointerenter={() => (commandIndex = i)}
2808- >
2809- <span class="mention-name">/{name}</span>
2810- <span class="mention-id">{COMMAND_USAGE[name]}</span>
2811- </li>
2812- {/each}
2813- </ul>
2814- {/if}
2815- {#if emoteQuery && emoteCandidates.length > 0}
2816- <ul class="mention-finder" role="listbox" aria-label="Emotes">
2817- {#each emoteCandidates as candidate, i (candidate.kind + candidate.shortcode)}
2818- <li
2819- role="option"
2820- aria-selected={i === emoteIndex}
2821- class:active={i === emoteIndex}
2822- on:pointerdown|preventDefault={() => void pickEmote(candidate)}
2823- on:pointerenter={() => (emoteIndex = i)}
2824- >
2825- {#if candidate.kind === 'custom'}
2826- <img class="finder-emote" src={mediaUrl(candidate.mxc, 64) ?? ''} alt="" />
2827- {:else}
2828- <span class="finder-emote"><EmojiText text={candidate.emoji} /></span>
2829- {/if}
2830- <span class="mention-name">:{candidate.shortcode}:</span>
2831- </li>
2832- {/each}
2833- </ul>
2834- {/if}
2835- {#if mentionQuery && mentionCandidates.length > 0}
2836- <ul class="mention-finder" role="listbox" aria-label="Mention someone">
2837- {#each mentionCandidates as person, i (person.user_id)}
2838- <li
2839- role="option"
2840- aria-selected={i === mentionIndex}
2841- class:active={i === mentionIndex}
2842- on:pointerdown|preventDefault={() => void pickMention(person)}
2843- on:pointerenter={() => (mentionIndex = i)}
2844- >
2845- <MxcAvatar mxc={person.avatar} label={person.name} size={22} />
2846- <span class="mention-name"><EmojiText text={person.name} /></span>
2847- <span class="mention-id">{person.user_id}</span>
2848- </li>
2849- {/each}
2850- </ul>
2851- {/if}
2852- <div class="composer-marks" aria-hidden="true" bind:this={marksEl}>{#each composerMarks as part}{#if part.mark}<mark><ComposerText text={part.text} /></mark>{:else}<ComposerText text={part.text} />{/if}{/each}&#8203;</div>
2853- <textarea
2854- id="composer"
2855- bind:this={composerEl}
2856- bind:value={composer}
2857- use:syncComposer
2858- rows="1"
2859- placeholder={`Message ${selectedRoom.name}`}
2860- aria-label={`Message ${selectedRoom.name}. Enter to send, Shift Enter for a new line.`}
2861- on:keydown={onComposerKey}
2862- on:input={() => void onComposerInput()}
2863- on:compositionstart={() => (composerComposing = true)}
2864- on:compositionend={() => (composerComposing = false)}
2865- on:click={updateMentionQuery}
2866- on:focus={ensureEmoji}
2867- on:keyup={(e) => (e.key.startsWith('Arrow') && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') || e.key === 'Home' || e.key === 'End' ? updateMentionQuery() : undefined}
2868- on:blur={() => ((mentionQuery = null), (commandQuery = null), (emoteQuery = null), (composerComposing = false))}
2869- on:paste={onComposerPaste}
2870- disabled={!($selectedRoomId && openReady[$selectedRoomId])}
2871- ></textarea>
2872- </div>
2873- <button
2874- type="submit"
2875- class="primary-btn"
2876- disabled={(!composer.trim() && currentStaged.length === 0) ||
2877- !($selectedRoomId && openReady[$selectedRoomId]) ||
2878- (currentStaged.length > 0 && uploadBusy)}
2879- title={!($selectedRoomId && openReady[$selectedRoomId]) ? 'Waiting for the room to open' : 'Send message'}
2880- >
2881- Send
2882- </button>
2883- </form>
2485+ <input
2486+ type="file"
2487+ accept="image/*"
2488+ bind:this={avatarInput}
2489+ class="visually-hidden"
2490+ aria-label="Choose a new avatar"
2491+ on:change={() => void onAvatarChosen()}
2492+ />
2493+ <Composer
2494+ bind:this={composerRef}
2495+ bind:value={composer}
2496+ inputId="composer"
2497+ label={`Message ${selectedRoom.name}`}
2498+ placeholder={`Message ${selectedRoom.name}`}
2499+ ready={!!($selectedRoomId && openReady[$selectedRoomId])}
2500+ picked={($selectedRoomId && pickedMentions[$selectedRoomId]) || []}
2501+ onPick={(mention) => $selectedRoomId && (pickedMentions = { ...pickedMentions, [$selectedRoomId]: [...(pickedMentions[$selectedRoomId] ?? []), mention] })}
2502+ staged={currentStaged}
2503+ onStage={stageFiles}
2504+ onUnstage={(id) => $selectedRoomId && unstage($selectedRoomId, id)}
2505+ {uploadBusy}
2506+ {uploadName}
2507+ editing={editing ? { original: editing.original } : null}
2508+ onCancelEdit={cancelEdit}
2509+ {replyingTo}
2510+ onCancelReply={() => (replyingTo = null)}
2511+ {people}
2512+ self={$sessionUser ?? null}
2513+ emotes={roomEmotes}
2514+ packs={roomPacks}
2515+ stickers={$selectedRoomId ? roomStickers($selectedRoomId) : null}
2516+ commands
2517+ onSubmit={send}
2518+ onEditLast={editLastOwn}
2519+ onMentionStart={() => $selectedRoomId && !$membersByRoom[$selectedRoomId] && void loadMembers($selectedRoomId)}
2520+ />
28842521 {:else}
28852522 <div class="join-bar">
28862523 <p>You are not a member of this room.</p>
@@ -2895,9 +2532,9 @@
28952532 </main>
28962533
28972534 {#if selectedRoom}
2898- <aside class="members" aria-hidden={!membersOpen && !threadView} aria-label={threadView ? `Thread in ${selectedRoom.name}` : `Members of ${selectedRoom.name}`}>
2535+ <aside class="thread" aria-hidden={!threadShown} aria-label={`Thread in ${selectedRoom.name}`}>
28992536 <!-- svelte-ignore a11y_no_static_element_interactions -->
2900- <div class="pane-resize left" title="Drag to resize" on:pointerdown={(e) => startResize(e, threadView ? 'thread' : 'members')} on:dblclick={() => resetWidth(threadView ? 'thread' : 'members')}></div>
2537+ <div class="pane-resize left" title="Drag to resize" on:pointerdown={(e) => startResize(e, 'thread')} on:dblclick={() => resetWidth('thread')}></div>
29012538 {#if threadView}
29022539 <ThreadPane
29032540 roomId={threadView.room_id}
@@ -2914,54 +2551,58 @@
29142551 onQuickChange={(list) => void changeQuickReactions(list)}
29152552 onUser={(userId, x, y, asMenu) => void openUser(userId, x, y, asMenu)}
29162553 onMore={(ev, x, y) => void openMessageMenu(ev, x, y)}
2554+ onMentionStart={() => !$membersByRoom[threadView.room_id] && void loadMembers(threadView.room_id)}
29172555 onClose={() => void showThread(null)}
29182556 />
2919- {:else}
2920- <div class="members-head">
2921- <h2>Members</h2>
2922- <button type="button" class="icon-btn" aria-label="Close members" on:click={() => (membersOpen = false)}>
2923- <span aria-hidden="true">✕</span>
2557+ {/if}
2558+ </aside>
2559+ <aside class="members" aria-hidden={!membersShown} aria-label={`Members of ${selectedRoom.name}`}>
2560+ <!-- svelte-ignore a11y_no_static_element_interactions -->
2561+ <div class="pane-resize left" title="Drag to resize" on:pointerdown={(e) => startResize(e, 'members')} on:dblclick={() => resetWidth('members')}></div>
2562+ <div class="members-head">
2563+ <h2>Members</h2>
2564+ <button type="button" class="icon-btn" aria-label="Close members" on:click={() => (membersOpen = false)}>
2565+ <span aria-hidden="true">✕</span>
2566+ </button>
2567+ </div>
2568+ {#if $membersLoading && members.length === 0}
2569+ <p class="side-note">Loading members…</p>
2570+ {:else if members.length === 0}
2571+ <p class="side-note">No members loaded.</p>
2572+ {#if $selectedRoomId}
2573+ <button type="button" class="ghost-btn" on:click={() => $selectedRoomId && void loadMembers($selectedRoomId)}>
2574+ Retry
29242575 </button>
2925- </div>
2926- {#if $membersLoading && members.length === 0}
2927- <p class="side-note">Loading members…</p>
2928- {:else if members.length === 0}
2929- <p class="side-note">No members loaded.</p>
2930- {#if $selectedRoomId}
2931- <button type="button" class="ghost-btn" on:click={() => $selectedRoomId && void loadMembers($selectedRoomId)}>
2932- Retry
2933- </button>
2934- {/if}
2935- {:else}
2936- <ul class="member-list">
2937- {#each memberGroups as group (group.label)}
2938- <li class="member-group">
2939- <h3 class="member-group-label">{group.label}<span>{group.members.length}</span></h3>
2940- <ul class="member-group-list">
2941- {#each group.members as member (member.user_id)}
2942- <li>
2943- <button
2944- type="button"
2945- class="member"
2946- on:click={(ev) => {
2947- const rect = ev.currentTarget.getBoundingClientRect();
2948- void openUser(member.user_id, rect.left - 308, rect.top, false);
2949- }}
2950- on:contextmenu|preventDefault={(ev) => void openUser(member.user_id, ev.clientX, ev.clientY, true)}
2951- >
2952- <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} seed={member.user_id} size={28} />
2953- <span class="member-names">
2954- <span class="member-name"><EmojiText text={member.display_name || member.name} /></span>
2955- <span class="member-id">{member.user_id}</span>
2956- </span>
2957- </button>
2958- </li>
2959- {/each}
2960- </ul>
2961- </li>
2962- {/each}
2963- </ul>
29642576 {/if}
2577+ {:else}
2578+ <ul class="member-list">
2579+ {#each memberGroups as group (group.label)}
2580+ <li class="member-group">
2581+ <h3 class="member-group-label">{group.label}<span>{group.members.length}</span></h3>
2582+ <ul class="member-group-list">
2583+ {#each group.members as member (member.user_id)}
2584+ <li>
2585+ <button
2586+ type="button"
2587+ class="member"
2588+ on:click={(ev) => {
2589+ const rect = ev.currentTarget.getBoundingClientRect();
2590+ void openUser(member.user_id, rect.left - 308, rect.top, false);
2591+ }}
2592+ on:contextmenu|preventDefault={(ev) => void openUser(member.user_id, ev.clientX, ev.clientY, true)}
2593+ >
2594+ <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} seed={member.user_id} size={28} />
2595+ <span class="member-names">
2596+ <span class="member-name"><EmojiText text={member.display_name || member.name} /></span>
2597+ <span class="member-id">{member.user_id}</span>
2598+ </span>
2599+ </button>
2600+ </li>
2601+ {/each}
2602+ </ul>
2603+ </li>
2604+ {/each}
2605+ </ul>
29652606 {/if}
29662607 </aside>
29672608 {/if}
Modified file: web/src/app.css+26 additions−12 deletions
@@ -197,9 +197,10 @@ input, textarea { font: inherit; }
197197 --rail-w: 60px;
198198 --side-w: var(--side-open, 260px);
199199 --members-w: 0px;
200+ --thread-w: 0px;
200201 --pane-ease: 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
201202 display: grid;
202- grid-template-columns: var(--rail-w) var(--side-w) minmax(0, 1fr) var(--members-w);
203+ grid-template-columns: var(--rail-w) var(--side-w) minmax(0, 1fr) var(--thread-w) var(--members-w);
203204 grid-template-rows: auto minmax(0, 1fr) auto;
204205 height: 100dvh;
205206 overflow: hidden;
@@ -208,6 +209,7 @@ input, textarea { font: inherit; }
208209 .app.rail-folded { --rail-w: 0px; }
209210 .app.nav-folded { --side-w: 0px; }
210211 .app.members-open { --members-w: var(--members-open, 240px); }
212+.app.thread-open { --thread-w: var(--thread-open, 400px); }
211213 .app.resizing { transition: none; cursor: col-resize; user-select: none; }
212214 .pane-resize {
213215 position: absolute;
@@ -227,17 +229,19 @@ input, textarea { font: inherit; }
227229 .app > .sidebar { grid-column: 2; grid-row: 2; }
228230 .app > .account { grid-column: 1; grid-row: 3; }
229231 .app > .chat { grid-column: 3; grid-row: 2 / 4; }
230-.app > .members { grid-column: 4; grid-row: 2 / 4; }
231-.app > .rail, .app > .sidebar, .app > .members {
232+.app > .thread { grid-column: 4; grid-row: 2 / 4; }
233+.app > .members { grid-column: 5; grid-row: 2 / 4; }
234+.app > .rail, .app > .sidebar, .app > .members, .app > .thread {
232235 min-width: 0;
233236 transition: visibility 0s linear 0s;
234237 }
235238 .app > .rail { overflow-x: hidden; }
236-.app > .sidebar, .app > .members { overflow: hidden; }
239+.app > .sidebar, .app > .members, .app > .thread { overflow: hidden; }
237240 .app > .sidebar > * { min-width: var(--side-open, 260px); }
238241 .app > .members > * { min-width: var(--members-open, 240px); }
239-.app > .sidebar, .app > .members { position: relative; }
240-.app.rail-folded > .rail, .app.nav-folded > .sidebar, .app:not(.members-open) > .members {
242+.app > .thread > * { min-width: var(--thread-open, 400px); }
243+.app > .sidebar, .app > .members, .app > .thread { position: relative; }
244+.app.rail-folded > .rail, .app.nav-folded > .sidebar, .app:not(.members-open) > .members, .app:not(.thread-open) > .thread {
241245 visibility: hidden;
242246 transition: visibility 0s linear 0.22s;
243247 }
@@ -865,6 +869,14 @@ input, textarea { font: inherit; }
865869 .join-bar p { margin: 0; color: var(--muted); font-size: 13px; flex: 1; }
866870
867871 /* ——— Members ——— */
872+.thread {
873+ background: var(--panel);
874+ border-left: 1px solid var(--border);
875+ display: flex;
876+ flex-direction: column;
877+ min-height: 0;
878+ overflow: hidden;
879+}
868880 .members {
869881 background: var(--panel);
870882 border-left: 1px solid var(--border);
@@ -953,9 +965,9 @@ input, textarea { font: inherit; }
953965 .account { padding-top: 10px; padding-bottom: 12px; }
954966 .pane-resize { display: none; }
955967 .app > .sidebar { transition: transform var(--pane-ease); }
956- .app > .sidebar > *, .app > .members > * { min-width: 0; }
957- .app > .members { transition: transform var(--pane-ease), visibility 0s linear 0s; }
958- .app:not(.members-open) > .members {
968+ .app > .sidebar > *, .app > .members > *, .app > .thread > * { min-width: 0; }
969+ .app > .members, .app > .thread { transition: transform var(--pane-ease), visibility 0s linear 0s; }
970+ .app:not(.members-open) > .members, .app:not(.thread-open) > .thread {
959971 transform: translateX(100%);
960972 transition: transform var(--pane-ease), visibility 0s linear 0.22s;
961973 }
@@ -978,13 +990,14 @@ input, textarea { font: inherit; }
978990 z-index: 30;
979991 cursor: pointer;
980992 }
981- .app > .members {
993+ .app > .members, .app > .thread {
982994 position: fixed;
983995 top: 0; bottom: 0; right: 0;
984996 width: min(300px, 85vw);
985997 z-index: 40;
986998 box-shadow: -8px 0 32px rgba(0, 0, 0, 0.5);
987999 }
1000+ .app > .thread { width: min(440px, 92vw); }
9881001 .only-mobile { display: inline-block; }
9891002 .only-desktop { display: none; }
9901003 .head-actions .ghost-btn { display: none; }
@@ -1025,7 +1038,7 @@ input, textarea { font: inherit; }
10251038 .app.nav-folded > .sidebar { visibility: hidden !important; transition: visibility 0s linear 0.22s; }
10261039 .app.nav-folded > .rail { grid-row: 2 / 4; }
10271040 .scrim { display: none; }
1028- .app > .members {
1041+ .app > .members, .app > .thread {
10291042 position: relative;
10301043 top: auto;
10311044 bottom: auto;
@@ -1037,7 +1050,8 @@ input, textarea { font: inherit; }
10371050 transition: visibility 0s linear 0.22s;
10381051 }
10391052 .app > .members > * { min-width: var(--members-open, 240px); }
1040- .app:not(.members-open) > .members {
1053+ .app > .thread > * { min-width: var(--thread-open, 400px); }
1054+ .app:not(.members-open) > .members, .app:not(.thread-open) > .thread {
10411055 visibility: hidden;
10421056 transition: visibility 0s linear 0.22s;
10431057 }
Added file: web/src/lib/Composer.svelte+439 additions
@@ -0,0 +1,439 @@
1+<script lang="ts">
2+ import { tick } from 'svelte';
3+ import ComposerText from './ComposerText.svelte';
4+ import EmojiText from './EmojiText.svelte';
5+ import MxcAvatar from './MxcAvatar.svelte';
6+ import StickerPicker from './StickerPicker.svelte';
7+ import { mediaUrl } from './api';
8+ import { COMMAND_USAGE, commandQueryAt, matchCommands, type CommandName } from './commands';
9+ import { markMentions } from './composer-marks';
10+ import { loadEmoji, type EmojiEntry } from './emoji';
11+ import { completeShortcode, emoteQueryAt, rankEmotes, resolveEmotes, type Emote, type EmoteCandidate, type EmoteQuery } from './emotes';
12+ import { insideOpenFence } from './markdown';
13+ import { mentionQueryAt, rankPeople, type Mention, type MentionQuery } from './mentions';
14+ import type { StagedAttachment, StickerHandlers } from './staging';
15+ import { contentPreview, senderLabel } from './types';
16+ import type { ImagePack, PackImage, Person, ReplyReference } from './types';
17+
18+ export let value: string;
19+ export let inputId: string;
20+ export let label: string;
21+ export let placeholder: string;
22+ export let ready = true;
23+ export let readyTitle = 'Send message';
24+ export let picked: Mention[] = [];
25+ export let onPick: (mention: Mention) => void;
26+ export let staged: StagedAttachment[] = [];
27+ export let onStage: (files: File[]) => void;
28+ export let onUnstage: (id: number) => void;
29+ export let uploadBusy = false;
30+ export let uploadName: string | null = null;
31+ export let editing: { original: string } | null = null;
32+ export let onCancelEdit: () => void = () => {};
33+ export let replyingTo: ReplyReference | null = null;
34+ export let onCancelReply: () => void = () => {};
35+ export let people: Record<string, Person> = {};
36+ export let self: string | null = null;
37+ export let emotes: Emote[] = [];
38+ export let packs: ImagePack[] = [];
39+ export let stickers: StickerHandlers | null = null;
40+ export let commands = false;
41+ export let onSubmit: () => void;
42+ export let onEditLast: () => boolean = () => false;
43+ export let onMentionStart: () => void = () => {};
44+
45+ let field: HTMLTextAreaElement | null = null;
46+ let marksEl: HTMLDivElement | null = null;
47+ let fileInput: HTMLInputElement | null = null;
48+ let composing = false;
49+ let stickersOpen = false;
50+
51+ let mentionQuery: MentionQuery | null = null;
52+ let mentionIndex = 0;
53+ $: mentionCandidates = mentionQuery ? rankPeople(Object.values(people), mentionQuery.query, self) : [];
54+ $: if (mentionIndex >= mentionCandidates.length) mentionIndex = 0;
55+
56+ let commandQuery: string | null = null;
57+ let commandIndex = 0;
58+ $: commandCandidates = commands && commandQuery !== null ? matchCommands(commandQuery) : [];
59+ $: if (commandIndex >= commandCandidates.length) commandIndex = 0;
60+
61+ let emoteQuery: EmoteQuery | null = null;
62+ let emoteIndex = 0;
63+ let emojiList: EmojiEntry[] = [];
64+ $: emoteCandidates = emoteQuery ? rankEmotes(emoteQuery.query, emotes, emojiList) : [];
65+ $: if (emoteIndex >= emoteCandidates.length) emoteIndex = 0;
66+
67+ $: marks = markMentions(value, picked, resolveEmotes(value, emotes).map((e) => `:${e.shortcode}:`));
68+
69+ export function focus(): void {
70+ field?.focus();
71+ }
72+
73+ export async function focusEnd(): Promise<void> {
74+ await tick();
75+ field?.focus();
76+ field?.setSelectionRange(value.length, value.length);
77+ }
78+
79+ export function closeFinders(): void {
80+ mentionQuery = null;
81+ commandQuery = null;
82+ emoteQuery = null;
83+ stickersOpen = false;
84+ }
85+
86+ function syncMarks(node: HTMLTextAreaElement) {
87+ const sync = () => {
88+ if (!marksEl) return;
89+ marksEl.style.width = `${node.clientWidth}px`;
90+ marksEl.scrollTop = node.scrollTop;
91+ marksEl.scrollLeft = node.scrollLeft;
92+ };
93+ const observer = new ResizeObserver(sync);
94+ observer.observe(node);
95+ node.addEventListener('scroll', sync);
96+ return {
97+ destroy: () => {
98+ observer.disconnect();
99+ node.removeEventListener('scroll', sync);
100+ }
101+ };
102+ }
103+
104+ function ensureEmoji(): void {
105+ if (emojiList.length) return;
106+ loadEmoji()
107+ .then((groups) => (emojiList = groups.flatMap((g) => g.emojis)))
108+ .catch(() => undefined);
109+ }
110+
111+ function caret(): number {
112+ return field?.selectionStart ?? value.length;
113+ }
114+
115+ async function placeCaret(at: number): Promise<void> {
116+ await tick();
117+ field?.focus();
118+ field?.setSelectionRange(at, at);
119+ }
120+
121+ function updateQueries(): void {
122+ if (!field) return;
123+ const at = caret();
124+ const nextEmote = emoteQueryAt(field.value, at);
125+ if (nextEmote?.start !== emoteQuery?.start) emoteIndex = 0;
126+ emoteQuery = nextEmote;
127+ const nextCommand = commands ? commandQueryAt(field.value, at) : null;
128+ if (nextCommand !== commandQuery) commandIndex = 0;
129+ commandQuery = nextCommand;
130+ const nextMention = mentionQueryAt(field.value, at);
131+ if (nextMention && !mentionQuery) onMentionStart();
132+ if (nextMention?.start !== mentionQuery?.start) mentionIndex = 0;
133+ mentionQuery = nextMention;
134+ }
135+
136+ async function onInput(): Promise<void> {
137+ if (field && value.includes(':')) {
138+ ensureEmoji();
139+ const done = completeShortcode(field.value, caret(), emojiList);
140+ if (done) {
141+ value = done.text;
142+ await placeCaret(done.caret);
143+ }
144+ }
145+ updateQueries();
146+ }
147+
148+ async function pickCommand(name: CommandName): Promise<void> {
149+ const rest = value.slice(caret()).replace(/^\S*/, '');
150+ value = `/${name}${rest.startsWith(' ') ? rest : ` ${rest}`}`;
151+ commandQuery = null;
152+ await placeCaret(name.length + 2);
153+ }
154+
155+ async function pickEmote(candidate: EmoteCandidate): Promise<void> {
156+ if (!emoteQuery) return;
157+ const inserted = candidate.kind === 'emoji' ? candidate.emoji : `:${candidate.shortcode}: `;
158+ const start = emoteQuery.start;
159+ value = value.slice(0, start) + inserted + value.slice(caret());
160+ emoteQuery = null;
161+ await placeCaret(start + inserted.length);
162+ }
163+
164+ async function pickMention(person: Person): Promise<void> {
165+ if (!mentionQuery) return;
166+ const inserted = `${person.name} `;
167+ const start = mentionQuery.start;
168+ value = value.slice(0, start) + inserted + value.slice(caret());
169+ onPick({ user_id: person.user_id, text: person.name });
170+ mentionQuery = null;
171+ await placeCaret(start + inserted.length);
172+ }
173+
174+ function step<T>(list: T[], index: number, key: string): number {
175+ return (index + (key === 'ArrowDown' ? 1 : -1) + list.length) % list.length;
176+ }
177+
178+ function onKey(ev: KeyboardEvent): void {
179+ if (ev.isComposing || composing) return;
180+ if (emoteQuery && emoteCandidates.length > 0) {
181+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
182+ ev.preventDefault();
183+ emoteIndex = step(emoteCandidates, emoteIndex, ev.key);
184+ return;
185+ }
186+ if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Tab') {
187+ ev.preventDefault();
188+ void pickEmote(emoteCandidates[emoteIndex]);
189+ return;
190+ }
191+ if (ev.key === 'Escape') {
192+ ev.stopPropagation();
193+ emoteQuery = null;
194+ return;
195+ }
196+ }
197+ if (commandQuery !== null && commandCandidates.length > 0) {
198+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
199+ ev.preventDefault();
200+ commandIndex = step(commandCandidates, commandIndex, ev.key);
201+ return;
202+ }
203+ const exact = commandCandidates.length === 1 && commandCandidates[0] === commandQuery;
204+ if (ev.key === 'Tab' || (ev.key === 'Enter' && !ev.shiftKey && !exact)) {
205+ ev.preventDefault();
206+ void pickCommand(commandCandidates[commandIndex]);
207+ return;
208+ }
209+ if (ev.key === 'Escape') {
210+ ev.stopPropagation();
211+ commandQuery = null;
212+ return;
213+ }
214+ }
215+ if (mentionQuery && mentionCandidates.length > 0) {
216+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
217+ ev.preventDefault();
218+ mentionIndex = step(mentionCandidates, mentionIndex, ev.key);
219+ return;
220+ }
221+ if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Tab') {
222+ ev.preventDefault();
223+ void pickMention(mentionCandidates[mentionIndex]);
224+ return;
225+ }
226+ if (ev.key === 'Escape') {
227+ ev.stopPropagation();
228+ mentionQuery = null;
229+ return;
230+ }
231+ }
232+ if (ev.key === 'Enter' && !ev.shiftKey && !insideOpenFence(value, caret())) {
233+ ev.preventDefault();
234+ onSubmit();
235+ } else if (ev.key === 'Escape' && editing) {
236+ ev.stopPropagation();
237+ onCancelEdit();
238+ } else if (ev.key === 'Escape' && replyingTo) {
239+ ev.stopPropagation();
240+ onCancelReply();
241+ } else if (ev.key === 'ArrowUp' && !value && !editing && onEditLast()) {
242+ ev.preventDefault();
243+ }
244+ }
245+
246+ function onPaste(ev: ClipboardEvent): void {
247+ const data = ev.clipboardData;
248+ // Office apps put a rendered image next to the copied text, so only take the image when there is no plain text.
249+ if (!data || data.types.includes('text/plain')) return;
250+ const images = Array.from(data.files).filter((f) => f.type.startsWith('image/'));
251+ if (images.length === 0) return;
252+ ev.preventDefault();
253+ onStage(images);
254+ }
255+
256+ function onFileChosen(): void {
257+ onStage(Array.from(fileInput?.files ?? []));
258+ if (fileInput) fileInput.value = '';
259+ }
260+
261+ function onCaretKey(e: KeyboardEvent): void {
262+ if ((e.key.startsWith('Arrow') && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') || e.key === 'Home' || e.key === 'End') updateQueries();
263+ }
264+</script>
265+
266+{#if editing}
267+ <div class="reply-bar" role="status">
268+ <span class="reply-bar-label">
269+ Editing message
270+ <span class="reply-bar-text"><EmojiText text={editing.original} /></span>
271+ </span>
272+ <button type="button" class="icon-btn" aria-label="Cancel edit" title="Cancel edit (Esc)" on:click={onCancelEdit}>
273+ <span aria-hidden="true">✕</span>
274+ </button>
275+ </div>
276+{:else if replyingTo && replyingTo.details.kind === 'Ready'}
277+ <div class="reply-bar" role="status">
278+ <span class="reply-bar-label">
279+ Replying to <strong><EmojiText text={people[replyingTo.details.sender.user_id]?.name ?? senderLabel(replyingTo.details.sender)} /></strong>
280+ <span class="reply-bar-text"><EmojiText text={contentPreview(replyingTo.details.content)} /></span>
281+ </span>
282+ <button type="button" class="icon-btn" aria-label="Cancel reply" title="Cancel reply (Esc)" on:click={onCancelReply}>
283+ <span aria-hidden="true">✕</span>
284+ </button>
285+ </div>
286+{/if}
287+{#if staged.length > 0}
288+ <ul class="staged" aria-label="Attachments to send">
289+ {#each staged as attachment (attachment.id)}
290+ <li class="staged-item">
291+ {#if attachment.previewUrl}
292+ <img src={attachment.previewUrl} alt={attachment.file.name || 'Pasted image'} />
293+ {:else}
294+ <span class="staged-name"><EmojiText text={attachment.file.name || 'attachment'} /></span>
295+ {/if}
296+ <button
297+ type="button"
298+ class="icon-btn staged-remove"
299+ aria-label={`Remove ${attachment.file.name || 'attachment'}`}
300+ title="Remove"
301+ on:click={() => onUnstage(attachment.id)}
302+ >
303+ <span aria-hidden="true">✕</span>
304+ </button>
305+ </li>
306+ {/each}
307+ </ul>
308+{/if}
309+<form class="composer" class:replying={replyingTo || editing} class:staging={staged.length > 0} on:submit|preventDefault={onSubmit}>
310+ <label class="visually-hidden" for={inputId}>{label}</label>
311+ <input type="file" multiple bind:this={fileInput} class="visually-hidden" aria-label="Attach a file" on:change={onFileChosen} />
312+ <button
313+ type="button"
314+ class="icon-btn"
315+ class:busy={uploadBusy}
316+ title={uploadBusy ? `Uploading ${uploadName ?? 'file'}…` : 'Attach an image or file'}
317+ aria-label="Attach an image or file"
318+ aria-busy={uploadBusy}
319+ disabled={!ready}
320+ on:click={() => fileInput?.click()}
321+ >
322+ <svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
323+ <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" />
324+ </svg>
325+ </button>
326+ {#if stickers}
327+ {@const handlers = stickers}
328+ <button
329+ type="button"
330+ class="icon-btn sticker-btn"
331+ title="Stickers"
332+ aria-label="Stickers"
333+ aria-expanded={stickersOpen}
334+ disabled={!ready}
335+ on:click={() => (stickersOpen = !stickersOpen)}
336+ >
337+ <svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
338+ <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" />
339+ <path d="M13 20v-5a2 2 0 0 1 2-2h5" fill="none" stroke="currentColor" stroke-width="1.8" />
340+ </svg>
341+ </button>
342+ {#if stickersOpen}
343+ <div class="sticker-pop">
344+ <StickerPicker
345+ {packs}
346+ roomName={handlers.roomName}
347+ onSend={(pack, image) => ((stickersOpen = false), handlers.onSend(pack, image))}
348+ onClone={(pack, image, x, y) => ((stickersOpen = false), handlers.onClone(pack, image, x, y))}
349+ onClonePack={(pack, x, y) => ((stickersOpen = false), handlers.onClonePack(pack, x, y))}
350+ onManage={() => ((stickersOpen = false), handlers.onManage())}
351+ onClose={() => (stickersOpen = false)}
352+ />
353+ </div>
354+ {/if}
355+ {/if}
356+ <div class="composer-field" class:composing>
357+ {#if commandQuery !== null && commandCandidates.length > 0}
358+ <ul class="mention-finder" role="listbox" aria-label="Commands">
359+ {#each commandCandidates as name, i (name)}
360+ <li
361+ role="option"
362+ aria-selected={i === commandIndex}
363+ class:active={i === commandIndex}
364+ on:pointerdown|preventDefault={() => void pickCommand(name)}
365+ on:pointerenter={() => (commandIndex = i)}
366+ >
367+ <span class="mention-name">/{name}</span>
368+ <span class="mention-id">{COMMAND_USAGE[name]}</span>
369+ </li>
370+ {/each}
371+ </ul>
372+ {/if}
373+ {#if emoteQuery && emoteCandidates.length > 0}
374+ <ul class="mention-finder" role="listbox" aria-label="Emotes">
375+ {#each emoteCandidates as candidate, i (candidate.kind + candidate.shortcode)}
376+ <li
377+ role="option"
378+ aria-selected={i === emoteIndex}
379+ class:active={i === emoteIndex}
380+ on:pointerdown|preventDefault={() => void pickEmote(candidate)}
381+ on:pointerenter={() => (emoteIndex = i)}
382+ >
383+ {#if candidate.kind === 'custom'}
384+ <img class="finder-emote" src={mediaUrl(candidate.mxc, 64) ?? ''} alt="" />
385+ {:else}
386+ <span class="finder-emote"><EmojiText text={candidate.emoji} /></span>
387+ {/if}
388+ <span class="mention-name">:{candidate.shortcode}:</span>
389+ </li>
390+ {/each}
391+ </ul>
392+ {/if}
393+ {#if mentionQuery && mentionCandidates.length > 0}
394+ <ul class="mention-finder" role="listbox" aria-label="Mention someone">
395+ {#each mentionCandidates as person, i (person.user_id)}
396+ <li
397+ role="option"
398+ aria-selected={i === mentionIndex}
399+ class:active={i === mentionIndex}
400+ on:pointerdown|preventDefault={() => void pickMention(person)}
401+ on:pointerenter={() => (mentionIndex = i)}
402+ >
403+ <MxcAvatar mxc={person.avatar} label={person.name} size={22} />
404+ <span class="mention-name"><EmojiText text={person.name} /></span>
405+ <span class="mention-id">{person.user_id}</span>
406+ </li>
407+ {/each}
408+ </ul>
409+ {/if}
410+ <div class="composer-marks" aria-hidden="true" bind:this={marksEl}>{#each marks as part}{#if part.mark}<mark><ComposerText text={part.text} /></mark>{:else}<ComposerText text={part.text} />{/if}{/each}&#8203;</div>
411+ <textarea
412+ id={inputId}
413+ bind:this={field}
414+ bind:value
415+ use:syncMarks
416+ rows="1"
417+ {placeholder}
418+ aria-label={`${label}. Enter to send, Shift Enter for a new line.`}
419+ on:keydown={onKey}
420+ on:input={() => void onInput()}
421+ on:compositionstart={() => (composing = true)}
422+ on:compositionend={() => (composing = false)}
423+ on:click={updateQueries}
424+ on:focus={ensureEmoji}
425+ on:keyup={onCaretKey}
426+ on:blur={() => ((mentionQuery = null), (commandQuery = null), (emoteQuery = null), (composing = false))}
427+ on:paste={onPaste}
428+ disabled={!ready}
429+ ></textarea>
430+ </div>
431+ <button
432+ type="submit"
433+ class="primary-btn"
434+ disabled={(!value.trim() && staged.length === 0) || !ready || (staged.length > 0 && uploadBusy)}
435+ title={ready ? readyTitle : 'Waiting for the room to open'}
436+ >
437+ {editing ? 'Save' : 'Send'}
438+ </button>
439+</form>
Modified file: web/src/lib/ThreadPane.svelte+162 additions−71 deletions
@@ -1,8 +1,35 @@
1+<script lang="ts" context="module">
2+ import { sessionUser } from './store';
3+ import { releaseStaged as release } from './staging';
4+ import type { Mention } from './mentions';
5+ import type { StagedAttachment } from './staging';
6+
7+ interface ThreadDraft {
8+ text: string;
9+ picked: Mention[];
10+ staged: StagedAttachment[];
11+ }
12+
13+ const drafts = new Map<string, ThreadDraft>();
14+ let draftAccount: string | null | undefined;
15+ sessionUser.subscribe((user) => {
16+ if (user === draftAccount) return;
17+ draftAccount = user;
18+ for (const saved of drafts.values()) release(saved.staged);
19+ drafts.clear();
20+ });
21+</script>
22+
123 <script lang="ts">
224 import { tick } from 'svelte';
325 import MessageRow from './MessageRow.svelte';
426 import EmojiText from './EmojiText.svelte';
5- import { editMessage, markRead, paginateRoom, reactToMessage, sendMessage } from './api';
27+ import Composer from './Composer.svelte';
28+ import { editMessage, markRead, paginateRoom, reactToMessage, sendMessage, uploadAttachment } from './api';
29+ import { parseCommand, unescapeCommand } from './commands';
30+ import { customEmotes, resolveEmotes } from './emotes';
31+ import { mentionsFromHtml, resolveMentions } from './mentions';
32+ import { releaseStaged, stageFiles } from './staging';
633 import { pushBanner, threadKey, threadTimelines, timelines } from './store';
734 import { pinnedThreads, togglePinnedThread } from './threads';
835 import type { ImagePack, LinkPreview, Person, QuickReaction, ReplyReference, TimelineEvent, TimelineItem } from './types';
@@ -21,16 +48,21 @@
2148 export let onQuickChange: (list: QuickReaction[]) => void = () => {};
2249 export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
2350 export let onMore: ((event: TimelineEvent, x: number, y: number) => void) | null = null;
51+ export let onMentionStart: () => void = () => {};
2452 export let onClose: () => void;
2553
2654 const GROUP_GAP_MS = 5 * 60 * 1000;
2755
2856 let list: HTMLDivElement | null = null;
29- let composerEl: HTMLTextAreaElement | null = null;
57+ let composerRef: Composer | null = null;
3058 let draft = '';
59+ let picked: Mention[] = [];
60+ let staged: StagedAttachment[] = [];
3161 let replyingTo: ReplyReference | null = null;
32- let editing: { eventId: string } | null = null;
62+ let editing: { eventId: string; original: string; stash: string } | null = null;
3363 let sending = false;
64+ let uploadName: string | null = null;
65+ let loadedKey = '';
3466 let paginating = false;
3567 let reacting = false;
3668 let stickToBottom = true;
@@ -40,9 +72,10 @@
4072 $: items = $threadTimelines[key] ?? [];
4173 $: roomRoot = ($timelines[roomId] ?? []).find((item) => item.kind === 'event' && item.event.event_id === rootEventId) ?? null;
4274 $: events = threadEvents(items, roomRoot);
43- $: hasStart = items.some((item) => item.kind === 'start');
75+ $: hasStart = items.some((item) => item.kind === 'start' || (item.kind === 'event' && item.event.event_id === rootEventId));
4476 $: pinned = ($pinnedThreads[roomId] ?? []).includes(rootEventId);
45- $: if (key) reset();
77+ $: emotes = customEmotes(packs);
78+ $: switchTo(key);
4679 $: void follow(events);
4780
4881 function threadEvents(list: TimelineItem[], root: TimelineItem | null): TimelineEvent[] {
@@ -58,8 +91,14 @@
5891 return prev.sender.user_id !== cur.sender.user_id || Math.abs(cur.timestamp_ms - prev.timestamp_ms) > GROUP_GAP_MS;
5992 }
6093
61- function reset(): void {
62- draft = '';
94+ function switchTo(next: string): void {
95+ if (next === loadedKey) return;
96+ if (loadedKey) drafts.set(loadedKey, { text: editing ? editing.stash : draft, picked, staged });
97+ loadedKey = next;
98+ const saved = drafts.get(next);
99+ draft = saved?.text ?? '';
100+ picked = saved?.picked ?? [];
101+ staged = saved?.staged ?? [];
63102 replyingTo = null;
64103 editing = null;
65104 stickToBottom = true;
@@ -113,64 +152,115 @@
113152 }
114153
115154 function startReply(target: ReplyReference): void {
116- editing = null;
155+ if (editing) cancelEdit();
117156 replyingTo = target;
118- composerEl?.focus();
157+ composerRef?.focus();
119158 }
120159
121160 function startEdit(eventId: string, body: string): void {
161+ const stash = editing ? editing.stash : draft;
122162 replyingTo = null;
123- editing = { eventId };
163+ editing = { eventId, original: body, stash };
164+ const target = events.find((event) => event.event_id === eventId);
165+ if (target?.content.kind === 'Message') picked = [...picked, ...mentionsFromHtml(target.content.message.formatted_body)];
124166 draft = body;
125- void tick().then(() => composerEl?.focus());
167+ void composerRef?.focusEnd();
168+ }
169+
170+ function cancelEdit(): void {
171+ if (!editing) return;
172+ draft = editing.stash;
173+ editing = null;
174+ composerRef?.focus();
175+ }
176+
177+ function editLastOwn(): boolean {
178+ for (let i = events.length - 1; i >= 0; i -= 1) {
179+ const event = events[i];
180+ if (!event.own) continue;
181+ if (!event.editable || !event.event_id || event.content.kind !== 'Message' || event.content.message.kind !== 'Text') return false;
182+ startEdit(event.event_id, event.content.message.body);
183+ return true;
184+ }
185+ return false;
186+ }
187+
188+ function stage(files: File[]): void {
189+ const accepted = stageFiles(files, pushBanner);
190+ if (accepted.length) staged = [...staged, ...accepted];
191+ composerRef?.focus();
192+ }
193+
194+ function unstage(id: number): void {
195+ releaseStaged(staged.filter((a) => a.id === id));
196+ staged = staged.filter((a) => a.id !== id);
126197 }
127198
128199 async function submit(): Promise<void> {
200+ if (sending) return;
129201 const body = draft.trim();
130- if (!body || sending) return;
131- sending = true;
202+ if (editing) {
203+ const { eventId, original } = editing;
204+ cancelEdit();
205+ draft = '';
206+ if (!body || body === original.trim()) return;
207+ editMessage(roomId, eventId, body, resolveMentions(body, picked), resolveEmotes(body, emotes), rootEventId).catch((e) =>
208+ pushBanner(e instanceof Error ? e.message : 'Edit failed')
209+ );
210+ picked = [];
211+ return;
212+ }
213+ const files = staged;
214+ if (!body && files.length === 0) return;
215+ const command = parseCommand(body);
216+ if (command && command.name !== 'me' && command.name !== 'notice') {
217+ pushBanner(`/${command.name} only works in the room composer`);
218+ return;
219+ }
220+ const text = command ? command.args : unescapeCommand(body);
221+ if (command && (!text || files.length)) {
222+ pushBanner(`/${command.name} needs something to say and no attachments`);
223+ return;
224+ }
132225 const reply = replyingTo;
133- const edit = editing;
226+ const mentions = resolveMentions(text, picked);
227+ const used = resolveEmotes(text, emotes);
228+ draft = '';
229+ picked = [];
230+ staged = [];
231+ replyingTo = null;
232+ stickToBottom = true;
233+ sending = true;
134234 try {
135- if (edit) await editMessage(roomId, edit.eventId, body, [], [], rootEventId);
136- else await sendMessage(roomId, body, reply?.event_id ?? null, [], 'text', [], rootEventId);
137- draft = '';
138- replyingTo = null;
139- editing = null;
140- stickToBottom = true;
235+ if (files.length === 0) {
236+ await sendMessage(roomId, text, reply?.event_id ?? null, mentions, command ? (command.name === 'me' ? 'emote' : 'notice') : 'text', used, rootEventId);
237+ } else {
238+ let first = true;
239+ for (const attachment of files) {
240+ uploadName = attachment.file.name || 'attachment';
241+ try {
242+ if (first) await uploadAttachment(roomId, attachment.file, text || null, reply?.event_id ?? null, mentions, used, rootEventId);
243+ else await uploadAttachment(roomId, attachment.file, null, null, [], [], rootEventId);
244+ first = false;
245+ } catch (e) {
246+ pushBanner(e instanceof Error ? e.message : 'Upload failed');
247+ }
248+ }
249+ }
141250 } catch (e) {
142251 pushBanner(e instanceof Error ? e.message : 'Sending to the thread failed');
143252 } finally {
253+ releaseStaged(files);
254+ uploadName = null;
144255 sending = false;
145256 }
146257 }
147-
148- function onKey(e: KeyboardEvent): void {
149- if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
150- e.preventDefault();
151- void submit();
152- } else if (e.key === 'Escape' && (editing || replyingTo)) {
153- e.preventDefault();
154- if (editing) draft = '';
155- editing = null;
156- replyingTo = null;
157- }
158- }
159-
160- function grow(node: HTMLTextAreaElement, _value: string) {
161- const fit = () => {
162- node.style.height = 'auto';
163- node.style.height = `${Math.min(node.scrollHeight, 200)}px`;
164- };
165- fit();
166- return { update: fit };
167- }
168258 </script>
169259
170260 <svelte:window on:focus={sendReceipt} />
171261 <svelte:document on:visibilitychange={sendReceipt} />
172262
173-<aside class="thread-pane" aria-label={`Thread in ${roomName}`}>
263+<div class="thread-pane">
174264 <header class="thread-head">
175265 <h2>Thread</h2>
176266 <span class="thread-room"><EmojiText text={roomName} /></span>
@@ -226,33 +316,38 @@
226316 {/each}
227317 </div>
228318 {#if joined}
229- <form class="thread-composer" on:submit|preventDefault={() => void submit()}>
230- {#if replyingTo || editing}
231- <div class="thread-context">
232- <span>{editing ? 'Editing' : 'Replying'}</span>
233- <button type="button" class="icon-btn" aria-label="Cancel" on:click={() => { if (editing) draft = ''; editing = null; replyingTo = null; }}>
234- <span aria-hidden="true">✕</span>
235- </button>
236- </div>
237- {/if}
238- <div class="thread-input">
239- <textarea
240- bind:this={composerEl}
241- bind:value={draft}
242- use:grow={draft}
243- rows="1"
244- placeholder="Reply in thread"
245- aria-label="Reply in thread"
246- on:keydown={onKey}
247- ></textarea>
248- <button type="submit" class="primary-btn" disabled={sending || !draft.trim()}>{editing ? 'Save' : 'Send'}</button>
249- </div>
250- </form>
319+ <div class="thread-composer">
320+ <Composer
321+ bind:this={composerRef}
322+ bind:value={draft}
323+ inputId={`thread-composer-${rootEventId}`}
324+ label="Reply in thread"
325+ placeholder="Reply in thread"
326+ {picked}
327+ onPick={(mention) => (picked = [...picked, mention])}
328+ {staged}
329+ onStage={stage}
330+ onUnstage={unstage}
331+ uploadBusy={uploadName !== null}
332+ {uploadName}
333+ editing={editing ? { original: editing.original } : null}
334+ onCancelEdit={cancelEdit}
335+ {replyingTo}
336+ onCancelReply={() => (replyingTo = null)}
337+ {people}
338+ {self}
339+ {emotes}
340+ {packs}
341+ onSubmit={() => void submit()}
342+ onEditLast={editLastOwn}
343+ {onMentionStart}
344+ />
345+ </div>
251346 {/if}
252-</aside>
347+</div>
253348
254349 <style>
255- .thread-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; height: 100%; background: var(--bg-elev, var(--bg)); border-left: 1px solid var(--border); animation: pop-in var(--pop); }
350+ .thread-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; height: 100%; animation: pop-in var(--pop); }
256351 .thread-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
257352 .thread-head h2 { margin: 0; font-size: 14px; font-weight: 650; }
258353 .thread-room { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -261,9 +356,5 @@
261356 .thread-list { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 10px; }
262357 .older { display: block; margin: 0 auto 8px; }
263358 .root { padding-bottom: 6px; margin-bottom: 6px; border-bottom: 1px solid var(--border); }
264- .thread-composer { border-top: 1px solid var(--border); padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; }
265- .thread-context { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: var(--muted); }
266- .thread-input { display: flex; align-items: flex-end; gap: 8px; }
267- .thread-input textarea { flex: 1; min-width: 0; resize: none; background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text); padding: 7px 9px; line-height: 1.4; }
268- .thread-input textarea:focus { outline: none; border-color: var(--accent); }
359+ .thread-composer { flex: none; }
269360 </style>
Modified file: web/src/lib/api.ts+4 additions−2 deletions
@@ -133,13 +133,15 @@ export function uploadAttachment(
133133 caption: string | null = null,
134134 reply_to: string | null = null,
135135 mentions: Mention[] = [],
136- emotes: Emote[] = []
136+ emotes: Emote[] = [],
137+ thread_root: string | null = null
137138 ): Promise<void> {
138139 return transport.uploadAttachment(room_id, file, {
139140 caption,
140141 formatted_caption: caption ? formatBody(caption, mentions, true, emotes) : null,
141142 reply_to,
142- mentions: mentionedUserIds(mentions)
143+ mentions: mentionedUserIds(mentions),
144+ thread_root
143145 });
144146 }
145147
Added file: web/src/lib/staging.ts+36 additions
@@ -0,0 +1,36 @@
1+import type { ImagePack, PackImage } from './types';
2+
3+export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
4+
5+export interface StagedAttachment {
6+ id: number;
7+ file: File;
8+ previewUrl: string | null;
9+}
10+
11+export interface StickerHandlers {
12+ onSend: (pack: ImagePack, image: PackImage) => void;
13+ onClone: (pack: ImagePack, image: PackImage, x: number, y: number) => void;
14+ onClonePack: (pack: ImagePack, x: number, y: number) => void;
15+ onManage: () => void;
16+ roomName: (roomId: string) => string;
17+}
18+
19+let nextId = 1;
20+
21+/** Files outside the homeserver-friendly size range are reported through `reject` and skipped. */
22+export function stageFiles(files: File[], reject: (message: string) => void): StagedAttachment[] {
23+ const accepted: StagedAttachment[] = [];
24+ for (const file of files) {
25+ if (file.size === 0 || file.size > MAX_ATTACHMENT_BYTES) {
26+ reject(`${file.name || 'File'} must be between 1 byte and 20 MiB.`);
27+ continue;
28+ }
29+ accepted.push({ id: nextId++, file, previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null });
30+ }
31+ return accepted;
32+}
33+
34+export function releaseStaged(list: StagedAttachment[]): void {
35+ for (const attachment of list) if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
36+}
Modified file: web/src/lib/transport-http.ts+1 additions
@@ -325,6 +325,7 @@ export class HttpTransport implements Transport {
325325 if (extras.caption) form.append('caption', extras.caption);
326326 if (extras.formatted_caption) form.append('formatted_caption', extras.formatted_caption);
327327 if (extras.reply_to) form.append('reply_to', extras.reply_to);
328+ if (extras.thread_root) form.append('thread_root', extras.thread_root);
328329 for (const id of extras.mentions) form.append('mentions', id);
329330 form.append('file', file, file.name || 'attachment');
330331 const res = await fetch('/api/rooms/upload', {
Modified file: web/src/lib/transport.ts+1 additions
@@ -90,6 +90,7 @@ export interface UploadExtras {
9090 formatted_caption: string | null;
9191 reply_to: string | null;
9292 mentions: string[];
93+ thread_root?: string | null;
9394 }
9495
9596 export type VerifyAction = 'Accept' | 'Confirm' | 'Reject' | 'Cancel';