Commit dda384a360e0
tarball10 files changed+822-584
@@ -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),@@ -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
)@@ -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
- mediaUrl52
+ editMessage5353
} 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
+ roomName1516
+ };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:window1734
+ 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:resizing19701760
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
<button20711862
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 message2698
- <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
- <button2726
- 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
- <input2741
- type="file"2742
- multiple2743
- bind:this={fileInput}2744
- class="visually-hidden"2745
- aria-label="Attach a file"2746
- on:change={() => void onFileChosen()}2747
- />2748
- <input2749
- 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
- <button2757
- 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
- <button2771
- 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
- <StickerPicker2788
- 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
- <li2803
- 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
- <li2819
- 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
- <li2839
- 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}​</div>2853
- <textarea2854
- id="composer"2855
- bind:this={composerEl}2856
- bind:value={composer}2857
- use:syncComposer2858
- 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
- <button2874
- 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
- Send2882
- </button>2883
- </form>2485
+ <input2486
+ 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
+ <Composer2494
+ 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
+ commands2517
+ 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
<ThreadPane29032540
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
+ Retry29242575
</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
- Retry2933
- </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
- <button2944
- 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
+ <button2586
+ 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}@@ -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
}@@ -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 message270
+ <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
+ <button297
+ 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
+ <button313
+ 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
+ <button329
+ 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
+ <StickerPicker345
+ {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
+ <li361
+ 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
+ <li377
+ 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
+ <li397
+ 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}​</div>411
+ <textarea412
+ id={inputId}413
+ bind:this={field}414
+ bind:value415
+ use:syncMarks416
+ 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
+ <button432
+ 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>@@ -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
- <textarea240
- 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
+ <Composer321
+ 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>@@ -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 = null137138
): 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_root143145
});144146
}145147
@@ -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
+}@@ -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', {@@ -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';