atagen

malice

public
Star0
Watch0
cf23dbf4e77c083afa1f66d1aeccdb091796ec12
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/App.svelteSvelte1723 lines63.3 KB
1
<script lang="ts">
2
  import { onMount, tick } from 'svelte';
3
  import LoginPanel from './lib/LoginPanel.svelte';
4
  import MessageRow from './lib/MessageRow.svelte';
5
  import MxcAvatar from './lib/MxcAvatar.svelte';
6
  import ImageLightbox from './lib/ImageLightbox.svelte';
7
  import { lightbox } from './lib/lightbox';
8
  import SecurityPanel from './lib/SecurityPanel.svelte';
9
  import {
10
    closeRoom,
11
    createRoom,
12
    getEncryption,
13
    getMembers,
14
    getProfile,
15
    getRooms,
16
    getSession,
17
    getSpaceChildren,
18
    joinRoom,
19
    leaveRoom,
20
    logout,
21
    markRead,
22
    openRoom,
23
    paginateRoom,
24
    reactToMessage,
25
    uploadAttachment,
26
    editMessage
27
  } from './lib/api';
28
  import {
29
    banners,
30
    blocked,
31
    connectEvents,
32
    connection,
33
    disconnectEvents,
34
    dismissBanner,
35
    encryption,
36
    membersByRoom,
37
    membersLoading,
38
    pushBanner,
39
    imagePacks,
40
    resetClientState,
41
    rooms,
42
    selectedRoomId,
43
    sessionUser,
44
    softLogout,
45
    timelines,
46
    verification
47
  } from './lib/store';
48
  import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';
49
  import type { PendingMessage } from './lib/pending';
50
  import { mentionQueryAt, mentionsFromHtml, rankPeople, resolveMentions } from './lib/mentions';
51
  import type { Mention, MentionQuery } from './lib/mentions';
52
  import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';
53
  import {
54
    linkPreviews,
55
    loadUrlPreviewSettings,
56
    requestLinkPreview,
57
    saveUrlPreviewSettings,
58
    urlPreviewSettings
59
  } from './lib/link-previews';
60
  import { notifyTyping, stopTyping } from './lib/typing';
61
  import { formatDay } from './lib/format';
62
  import { lineHeight, messageSpacing, navFolded } from './lib/appearance';
63
  import SettingsPanel from './lib/SettingsPanel.svelte';
64
  import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState, contentPreview, senderLabel } from './lib/types';
65
  import type { MemberSummary, OwnProfile, Person, QuickReaction, ReplyReference, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
66
67
  let roomsLoading = false;
68
  let booted = false;
69
  let wasOnline = false;
70
  let search = '';
71
  let sidebarOpen = false;
72
  let membersOpen = false;
73
  let replyingTo: ReplyReference | null = null;
74
  let dialog: 'join' | 'create' | 'security' | 'settings' | null = null;
75
  let joinTarget = '';
76
  let joinBusy = false;
77
  let createName = '';
78
  let createTopic = '';
79
  let createBusy = false;
80
  let leaveBusy = false;
81
  let paginateBusy = false;
82
  let reacting = false;
83
  let composer = '';
84
  let drafts: Record<string, string> = {};
85
  let stickToBottom = true;
86
  let timelineEl: HTMLElement | null = null;
87
  let composerEl: HTMLTextAreaElement | null = null;
88
  let fileInput: HTMLInputElement | null = null;
89
  let uploadBusy = false;
90
  let uploadName: string | null = null;
91
  interface StagedAttachment {
92
    id: number;
93
    file: File;
94
    previewUrl: string | null;
95
  }
96
  let staged: Record<string, StagedAttachment[]> = {};
97
  let editing: { roomId: string; eventId: string; original: string; stash: string } | null = null;
98
  /** Everyone picked from the finder per room; only those whose name is still in the text get linked on send. */
99
  let pickedMentions: Record<string, Mention[]> = {};
100
  let mentionQuery: MentionQuery | null = null;
101
  let mentionIndex = 0;
102
  let nextStagedId = 1;
103
  const WARM_ROOM_LIMIT = 8;
104
  /** Rooms left open after switching away so coming back is instant, least recently viewed first. */
105
  let warmRooms: string[] = [];
106
  let openReady: Record<string, boolean> = {};
107
  let openFailed: Record<string, string> = {};
108
  let membersFor: string | null = null;
109
  let lastReadKey = '';
110
  let readTimer: number | null = null;
111
  let profile: OwnProfile | null = null;
112
  let profileMxc: string | null = null;
113
  let selectedSpaceId: string | null = null;
114
  let spaceChildIds: string[] | null = null;
115
  let spaceChildrenLoading = false;
116
  let spaceChildrenError: string | null = null;
117
  let avatarByUser: Record<string, string | null> = {};
118
119
  // account data is read from the local cache, which is empty on a fresh device until the first sync lands
120
  $: {
121
    const online = $connection === 'online';
122
    if (online && !wasOnline) {
123
      void loadQuickReactions();
124
      void loadUrlPreviewSettings();
125
    }
126
    wasOnline = online;
127
  }
128
  $: selectedRoom = $rooms.find((r) => r.room_id === $selectedRoomId) ?? null;
129
  $: roomPacks = packsForRoom($imagePacks, $selectedRoomId);
130
  $: previewsAllowed = selectedRoom !== null && (!selectedRoom.encrypted || $urlPreviewSettings.encrypted_rooms);
131
  $: loadPreview = previewsAllowed && $selectedRoomId ? previewLoader($selectedRoomId) : null;
132
  $: spaceRooms = $rooms.filter((r) => r.space);
133
  $: spaceParents = parentSpaceMap(spaceRooms);
134
  $: railSpaces = spaceRooms.filter((s) => !spaceParents.has(s.room_id));
135
  $: railSelected = selectedSpaceId ? topSpaceOf(selectedSpaceId, spaceParents) : null;
136
  $: invites = $rooms.filter((r) => r.membership === 'Invited');
137
  $: query = search.trim().toLowerCase();
138
  $: selectedSpace = selectedSpaceId ? ($rooms.find((r) => r.room_id === selectedSpaceId) ?? null) : null;
139
  // While a space's children load (or fail), show no channels rather than the
140
  // full list — listing everything would falsely imply it belongs to the space.
141
  $: baseRooms = selectedSpaceId ? (spaceChildIds ? $rooms.filter((r) => spaceChildIds.includes(r.room_id)) : []) : $rooms;
142
  $: visibleRooms = query
143
    ? baseRooms.filter(
144
        (r) =>
145
          r.name.toLowerCase().includes(query) || r.room_id.toLowerCase().includes(query)
146
      )
147
    : baseRooms;
148
  $: directRooms = visibleRooms.filter((r) => r.direct && !r.space && r.membership !== 'Invited');
149
  $: groupRooms = visibleRooms.filter(
150
    (r) => !r.direct && !r.space && r.membership !== 'Invited'
151
  );
152
  $: spaceList = visibleRooms.filter((r) => r.space && r.membership !== 'Invited');
153
  $: channelRooms = baseRooms.filter((r) => !r.space && r.membership !== 'Invited');
154
  $: recoveryBanner = $encryption && ($encryption.recovery === 'Disabled' || $encryption.recovery === 'Incomplete') ? $encryption : null;
155
  $: verificationBanner = $verification && $verification.kind !== 'Done' ? $verification : null;
156
  $: items = $selectedRoomId ? ($timelines[$selectedRoomId] ?? []) : [];
157
  $: currentStaged = $selectedRoomId ? (staged[$selectedRoomId] ?? []) : [];
158
  $: members = $selectedRoomId ? ($membersByRoom[$selectedRoomId] ?? []) : [];
159
  $: people = peopleIn(items, members);
160
  $: mentionCandidates = mentionQuery ? rankPeople(Object.values(people), mentionQuery.query, $sessionUser ?? null) : [];
161
  $: if (mentionIndex >= mentionCandidates.length) mentionIndex = 0;
162
  $: hasStart = items.some((i) => i.kind === 'start');
163
  $: grouped = buildGroups(items);
164
  $: lastSentId = lastOwnSent(items);
165
  $: pendingItems = $selectedRoomId ? ($pendingByRoom[$selectedRoomId] ?? []) : [];
166
  $: lastRealOwn = lastSenderIsOwn(items);
167
168
  function lastSenderIsOwn(list: TimelineItem[]): boolean {
169
    for (let i = list.length - 1; i >= 0; i -= 1) {
170
      const it = list[i];
171
      if (it.kind === 'event') return it.event.sender.user_id === $sessionUser;
172
    }
173
    return false;
174
  }
175
176
  function pendingShowSender(index: number): boolean {
177
    return index === 0 ? !lastRealOwn : false;
178
  }
179
180
  function pendingToEvent(p: PendingMessage): TimelineEvent {
181
    const state: SendState = p.status === 'failed' ? { kind: 'Failed', will_retry: false } : { kind: 'Sending' };
182
    return {
183
      item_id: `pending-${p.id}`,
184
      event_id: null,
185
      transaction_id: p.id,
186
      sender: {
187
        user_id: $sessionUser ?? '',
188
        display_name: profile?.display_name ?? null,
189
        avatar: null,
190
        name_ambiguous: false
191
      },
192
      timestamp_ms: p.createdAt,
193
      own: true,
194
      send_state: state,
195
      content: {
196
        kind: 'Message',
197
        message: { kind: 'Text', body: p.body, geo_uri: null, formatted_body: null, edited: false, media: null }
198
      },
199
      reply_to: p.replyTo,
200
      thread_root: null,
201
      thread_summary: null,
202
      reactions: [],
203
      read_by: [],
204
      editable: false
205
    };
206
  }
207
208
  interface Group {
209
    key: string;
210
    item: TimelineItem;
211
    showSender: boolean;
212
  }
213
214
  function lastOwnSent(list: TimelineItem[]): string | null {
215
    for (let i = list.length - 1; i >= 0; i--) {
216
      const item = list[i];
217
      if (item.kind === 'event' && item.event.own && item.event.send_state.kind === 'Sent') return item.event.item_id;
218
    }
219
    return null;
220
  }
221
222
  function buildGroups(list: TimelineItem[]): Group[] {
223
    const out: Group[] = [];
224
    let prevSender: string | null = null;
225
    let prevTs = 0;
226
    list.forEach((item, index) => {
227
      let showSender = true;
228
      if (item.kind === 'event') {
229
        const e = item.event;
230
        showSender =
231
          prevSender !== e.sender.user_id || Math.abs(e.timestamp_ms - prevTs) > 5 * 60 * 1000;
232
        prevSender = e.sender.user_id;
233
        prevTs = e.timestamp_ms;
234
        out.push({ key: e.item_id || e.event_id || `m-${index}`, item, showSender });
235
      } else {
236
        prevSender = null;
237
        out.push({ key: `${item.kind}-${index}`, item, showSender });
238
      }
239
    });
240
    return out;
241
  }
242
243
  function roomUnread(r: RoomSummary): number {
244
    return r.unread_messages + r.unread_mentions;
245
  }
246
247
  onMount(() => {
248
    void boot();
249
    return () => {
250
      disconnectEvents();
251
      stopTyping();
252
      if (readTimer !== null) window.clearTimeout(readTimer);
253
    };
254
  });
255
256
  async function boot(): Promise<void> {
257
    try {
258
      const session = await getSession();
259
      blocked.set(session.blocked ?? false);
260
      sessionUser.set(session.user_id);
261
      if (session.user_id) {
262
        softLogout.set(null);
263
        await refreshRooms();
264
        connectEvents();
265
        void loadProfile();
266
        void loadEncryption();
267
        void loadQuickReactions();
268
        void loadUrlPreviewSettings();
269
      } else {
270
        softLogout.set(session.soft_logout ?? null);
271
      }
272
    } catch (e) {
273
      pushBanner(e instanceof Error ? `Session check failed: ${e.message}` : 'Session check failed');
274
      sessionUser.set(null);
275
    } finally {
276
      booted = true;
277
    }
278
  }
279
280
  async function refreshRooms(): Promise<void> {
281
    roomsLoading = true;
282
    try {
283
      const res = await getRooms();
284
      rooms.set(res.rooms);
285
      pruneSpaceFilter();
286
    } catch (e) {
287
      pushBanner(e instanceof Error ? e.message : 'Could not load rooms');
288
    } finally {
289
      roomsLoading = false;
290
    }
291
  }
292
293
  async function loadProfile(): Promise<void> {
294
    try {
295
      const raw = await getProfile();
296
      profile = parseOwnProfile(raw);
297
      profileMxc = profile.avatar_url;
298
    } catch {
299
      profile = null;
300
    }
301
  }
302
303
  async function loadEncryption(): Promise<void> {
304
    try {
305
      const res = await getEncryption();
306
      const state = parseEncryptionState(res.state);
307
      if (state) encryption.set(state);
308
      const verificationState = parseVerificationState(res.verification);
309
      if (verificationState) verification.set(verificationState);
310
    } catch {
311
      // Encryption status also arrives over SSE; a failed first fetch is not fatal.
312
    }
313
  }
314
315
  function afterLogin(userId: string): void {
316
    sessionUser.set(userId);
317
    softLogout.set(null);
318
    blocked.set(false);
319
    void refreshRooms();
320
    connectEvents();
321
    void loadProfile();
322
    void loadEncryption();
323
    void loadQuickReactions();
324
    void loadUrlPreviewSettings();
325
  }
326
327
  async function doLogout(): Promise<void> {
328
    stopTyping();
329
    try {
330
      await logout();
331
    } catch (e) {
332
      pushBanner(e instanceof Error ? e.message : 'Logout failed');
333
      return;
334
    }
335
    disconnectEvents();
336
    resetClientState();
337
    softLogout.set(null);
338
    blocked.set(false);
339
    sessionUser.set(null);
340
    lightbox.set(null);
341
    composer = '';
342
    drafts = {};
343
    dialog = null;
344
    membersOpen = false;
345
    sidebarOpen = false;
346
    profile = null;
347
    profileMxc = null;
348
    selectedSpaceId = null;
349
    spaceChildIds = null;
350
    spaceChildrenError = null;
351
    openReady = {};
352
    openFailed = {};
353
    warmRooms = [];
354
    pickedMentions = {};
355
    mentionQuery = null;
356
    clearAllStaged();
357
    avatarByUser = {};
358
    resetPending();
359
  }
360
361
  /** Joined spaces that sit inside another joined space, keyed to one such parent. Either side of the link counts. */
362
  function parentSpaceMap(spaces: RoomSummary[]): Map<string, string> {
363
    const ids = new Set(spaces.map((s) => s.room_id));
364
    const parents = new Map<string, string>();
365
    for (const space of spaces) {
366
      for (const child of space.children) {
367
        if (child !== space.room_id && ids.has(child) && !parents.has(child)) parents.set(child, space.room_id);
368
      }
369
    }
370
    for (const space of spaces) {
371
      const parent = space.parent_spaces.find((id) => id !== space.room_id && ids.has(id));
372
      if (parent && !parents.has(space.room_id)) parents.set(space.room_id, parent);
373
    }
374
    for (const id of [...parents.keys()]) {
375
      const seen = new Set<string>([id]);
376
      let at = parents.get(id);
377
      while (at && !seen.has(at)) {
378
        seen.add(at);
379
        at = parents.get(at);
380
      }
381
      if (at) parents.delete(id);
382
    }
383
    return parents;
384
  }
385
386
  function topSpaceOf(id: string, parents: Map<string, string>): string {
387
    let at = id;
388
    while (parents.has(at)) at = parents.get(at)!;
389
    return at;
390
  }
391
392
  function pruneSpaceFilter(): void {
393
    if (!selectedSpaceId) return;
394
    if (!$rooms.some((r) => r.room_id === selectedSpaceId)) {
395
      selectedSpaceId = null;
396
      spaceChildIds = null;
397
      spaceChildrenError = null;
398
    }
399
  }
400
401
  async function selectHome(): Promise<void> {
402
    selectedSpaceId = null;
403
    spaceChildIds = null;
404
    spaceChildrenError = null;
405
    sidebarOpen = true;
406
  }
407
408
  async function selectSpace(spaceId: string): Promise<void> {
409
    if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;
410
    stopTyping();
411
    selectedSpaceId = spaceId;
412
    spaceChildIds = null;
413
    spaceChildrenError = null;
414
    spaceChildrenLoading = true;
415
    sidebarOpen = true;
416
    // A space is navigation, not a message timeline.
417
    selectedRoomId.set(null);
418
    try {
419
      const res = await getSpaceChildren(spaceId);
420
      if (selectedSpaceId === spaceId) spaceChildIds = res.room_ids ?? [];
421
    } catch (e) {
422
      if (selectedSpaceId === spaceId) {
423
        spaceChildrenError = e instanceof Error ? e.message : 'Could not load rooms in this space';
424
        spaceChildIds = [];
425
      }
426
    } finally {
427
      if (selectedSpaceId === spaceId) spaceChildrenLoading = false;
428
    }
429
  }
430
431
  async function selectRoom(roomId: string): Promise<void> {
432
    if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;
433
    if (roomId === $selectedRoomId && openReady[roomId]) {
434
      sidebarOpen = false;
435
      return;
436
    }
437
    stopTyping();
438
    selectedRoomId.set(roomId);
439
    composer = drafts[roomId] ?? '';
440
    replyingTo = null;
441
    editing = null;
442
    mentionQuery = null;
443
    stickToBottom = true;
444
    lastReadKey = '';
445
    sidebarOpen = false;
446
    touchWarmRoom(roomId);
447
    if (openReady[roomId]) {
448
      if (membersOpen) void loadMembers(roomId);
449
      await tick();
450
      scrollToBottom();
451
      queueReadReceipt();
452
      composerEl?.focus();
453
      return;
454
    }
455
    if (openFailed[roomId]) {
456
      const nextFailed = { ...openFailed };
457
      delete nextFailed[roomId];
458
      openFailed = nextFailed;
459
    }
460
    try {
461
      await openRoom(roomId);
462
      if (warmRooms.includes(roomId)) openReady = { ...openReady, [roomId]: true };
463
    } catch (e) {
464
      const message = e instanceof Error ? e.message : `Could not open room ${roomId}`;
465
      openFailed = { ...openFailed, [roomId]: message };
466
      pushBanner(message);
467
    }
468
    if (membersOpen) void loadMembers(roomId);
469
    await tick();
470
    scrollToBottom();
471
    queueReadReceipt();
472
    composerEl?.focus();
473
  }
474
475
  function touchWarmRoom(roomId: string): void {
476
    warmRooms = [...warmRooms.filter((id) => id !== roomId), roomId];
477
    while (warmRooms.length > WARM_ROOM_LIMIT) {
478
      const evicted = warmRooms[0];
479
      warmRooms = warmRooms.slice(1);
480
      forgetOpenRoom(evicted);
481
      closeRoom(evicted).catch(() => undefined);
482
    }
483
  }
484
485
  function forgetOpenRoom(roomId: string): void {
486
    warmRooms = warmRooms.filter((id) => id !== roomId);
487
    const nextReady = { ...openReady };
488
    delete nextReady[roomId];
489
    openReady = nextReady;
490
  }
491
492
  function scrollToBottom(): void {
493
    if (timelineEl) timelineEl.scrollTop = timelineEl.scrollHeight;
494
  }
495
496
  function onTimelineScroll(): void {
497
    if (!timelineEl) return;
498
    const gap = timelineEl.scrollHeight - timelineEl.scrollTop - timelineEl.clientHeight;
499
    stickToBottom = gap < 80;
500
  }
501
502
  $: void trackTimeline($selectedRoomId, items);
503
  async function trackTimeline(roomId: string | null, list: TimelineItem[]): Promise<void> {
504
    if (!roomId || list.length === 0) return;
505
    if (roomId !== $selectedRoomId) return;
506
    await tick();
507
    if (stickToBottom) scrollToBottom();
508
    queueReadReceipt();
509
  }
510
511
  function queueReadReceipt(): void {
512
    if (readTimer !== null) window.clearTimeout(readTimer);
513
    readTimer = window.setTimeout(() => {
514
      readTimer = null;
515
      void sendReadReceipt();
516
    }, 800);
517
  }
518
519
  async function sendReadReceipt(): Promise<void> {
520
    const roomId = $selectedRoomId;
521
    if (!roomId) return;
522
    const list = $timelines[roomId] ?? [];
523
    for (let i = list.length - 1; i >= 0; i -= 1) {
524
      const entry = list[i];
525
      if (entry.kind === 'event' && entry.event.event_id) {
526
        const key = `${roomId}:${entry.event.event_id}`;
527
        if (key === lastReadKey) return;
528
        lastReadKey = key;
529
        try {
530
          await markRead(roomId, entry.event.event_id);
531
        } catch {
532
          return;
533
        }
534
        return;
535
      }
536
    }
537
  }
538
539
  function send(): void {
540
    if (editing) {
541
      submitEdit();
542
      return;
543
    }
544
    const roomId = $selectedRoomId;
545
    const body = composer.trim();
546
    const attachments = roomId ? (staged[roomId] ?? []) : [];
547
    if (!roomId || (!body && attachments.length === 0)) return;
548
    if (!openReady[roomId]) {
549
      pushBanner('Still opening this room — sending is disabled until the room is ready.');
550
      return;
551
    }
552
    if (attachments.length > 0 && uploadBusy) return;
553
    composer = '';
554
    if (roomId in drafts) delete drafts[roomId];
555
    stickToBottom = true;
556
    const reply = replyingTo;
557
    replyingTo = null;
558
    const mentions = resolveMentions(body, pickedMentions[roomId] ?? []);
559
    pickedMentions = { ...pickedMentions, [roomId]: [] };
560
    mentionQuery = null;
561
    if (attachments.length === 0) {
562
      queueSend(roomId, body, reply, mentions);
563
    } else {
564
      const { [roomId]: _sent, ...rest } = staged;
565
      staged = rest;
566
      void sendAttachments(roomId, body, reply, mentions, attachments);
567
    }
568
    composerEl?.focus();
569
  }
570
571
  async function sendAttachments(
572
    roomId: string,
573
    body: string,
574
    reply: ReplyReference | null,
575
    mentions: Mention[],
576
    attachments: StagedAttachment[]
577
  ): Promise<void> {
578
    let first = true;
579
    uploadBusy = true;
580
    try {
581
      for (const attachment of attachments) {
582
        uploadName = attachment.file.name || 'attachment';
583
        try {
584
          if (first) await uploadAttachment(roomId, attachment.file, body || null, reply?.event_id ?? null, mentions);
585
          else await uploadAttachment(roomId, attachment.file);
586
          first = false;
587
        } catch (e) {
588
          pushBanner(e instanceof Error ? e.message : 'Upload failed');
589
        }
590
        if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
591
      }
592
    } finally {
593
      uploadBusy = false;
594
      uploadName = null;
595
    }
596
  }
597
598
  function stageFiles(files: File[]): void {
599
    const roomId = $selectedRoomId;
600
    if (!roomId || !openReady[roomId]) return;
601
    const accepted: StagedAttachment[] = [];
602
    for (const file of files) {
603
      if (file.size === 0 || file.size > 20 * 1024 * 1024) {
604
        pushBanner(`${file.name || 'File'} must be between 1 byte and 20 MiB.`);
605
        continue;
606
      }
607
      const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
608
      accepted.push({ id: nextStagedId++, file, previewUrl });
609
    }
610
    if (accepted.length === 0) return;
611
    staged = { ...staged, [roomId]: [...(staged[roomId] ?? []), ...accepted] };
612
    composerEl?.focus();
613
  }
614
615
  function unstage(roomId: string, id: number): void {
616
    const list = staged[roomId] ?? [];
617
    const removed = list.find((a) => a.id === id);
618
    if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
619
    staged = { ...staged, [roomId]: list.filter((a) => a.id !== id) };
620
  }
621
622
  function clearAllStaged(): void {
623
    for (const list of Object.values(staged)) {
624
      for (const attachment of list) if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
625
    }
626
    staged = {};
627
  }
628
629
  function startEdit(eventId: string, body: string): void {
630
    const roomId = $selectedRoomId;
631
    if (!roomId) return;
632
    const stash = editing ? editing.stash : composer;
633
    editing = { roomId, eventId, original: body, stash };
634
    const entry = items.find((i) => i.kind === 'event' && i.event.event_id === eventId);
635
    if (entry?.kind === 'event' && entry.event.content.kind === 'Message') {
636
      const existing = mentionsFromHtml(entry.event.content.message.formatted_body);
637
      pickedMentions = { ...pickedMentions, [roomId]: [...(pickedMentions[roomId] ?? []), ...existing] };
638
    }
639
    composer = body;
640
    replyingTo = null;
641
    composerEl?.focus();
642
  }
643
644
  function cancelEdit(): void {
645
    if (!editing) return;
646
    composer = editing.stash;
647
    editing = null;
648
    composerEl?.focus();
649
  }
650
651
  function submitEdit(): void {
652
    if (!editing) return;
653
    const { roomId, eventId, original } = editing;
654
    const body = composer.trim();
655
    cancelEdit();
656
    if (!body || body === original.trim()) return;
657
    editMessage(roomId, eventId, body, resolveMentions(body, pickedMentions[roomId] ?? [])).catch((e) => pushBanner(e instanceof Error ? e.message : 'Edit failed'));
658
  }
659
660
  function editLastOwn(): boolean {
661
    for (let i = items.length - 1; i >= 0; i -= 1) {
662
      const entry = items[i];
663
      if (entry.kind !== 'event' || !entry.event.own) continue;
664
      const ev = entry.event;
665
      if (!ev.editable || !ev.event_id || ev.content.kind !== 'Message' || ev.content.message.kind !== 'Text') return false;
666
      startEdit(ev.event_id, ev.content.message.body);
667
      return true;
668
    }
669
    return false;
670
  }
671
672
  function startReply(target: ReplyReference): void {
673
    if (editing) cancelEdit();
674
    replyingTo = target;
675
    composerEl?.focus();
676
  }
677
678
  function jumpTo(eventId: string): void {
679
    const el = timelineEl?.querySelector<HTMLElement>(`[data-event="${CSS.escape(eventId)}"]`);
680
    if (!el) {
681
      pushBanner('The original message is not loaded. Scroll up to load older messages.');
682
      return;
683
    }
684
    stickToBottom = false;
685
    el.scrollIntoView({ block: 'center', behavior: 'smooth' });
686
    el.classList.remove('flash');
687
    void el.offsetWidth;
688
    el.classList.add('flash');
689
  }
690
691
  let typedText = '';
692
  let typedRoom: string | null = null;
693
  $: trackTypingInput(composer, $selectedRoomId, openReady);
694
  function trackTypingInput(text: string, roomId: string | null, ready: Record<string, boolean>): void {
695
    const edited = roomId === typedRoom && text !== typedText;
696
    typedText = text;
697
    typedRoom = roomId;
698
    if (!roomId || !ready[roomId] || !text.trim()) {
699
      stopTyping();
700
    } else if (edited) {
701
      notifyTyping(roomId);
702
    }
703
  }
704
705
  $: void trackPending($selectedRoomId, pendingItems);
706
  async function trackPending(roomId: string | null, list: PendingMessage[]): Promise<void> {
707
    if (!roomId || list.length === 0) return;
708
    if (roomId !== $selectedRoomId) return;
709
    await tick();
710
    if (stickToBottom) scrollToBottom();
711
  }
712
713
  function updateMentionQuery(): void {
714
    const roomId = $selectedRoomId;
715
    const next = composerEl && roomId ? mentionQueryAt(composerEl.value, composerEl.selectionStart ?? composerEl.value.length) : null;
716
    if (next && !mentionQuery && roomId && !$membersByRoom[roomId]) void loadMembers(roomId);
717
    if (next?.start !== mentionQuery?.start) mentionIndex = 0;
718
    mentionQuery = next;
719
  }
720
721
  async function pickMention(person: Person): Promise<void> {
722
    const roomId = $selectedRoomId;
723
    if (!mentionQuery || !roomId || !composerEl) return;
724
    const caret = composerEl.selectionStart ?? composer.length;
725
    const inserted = `${person.name} `;
726
    composer = composer.slice(0, mentionQuery.start) + inserted + composer.slice(caret);
727
    const at = mentionQuery.start + inserted.length;
728
    pickedMentions = {
729
      ...pickedMentions,
730
      [roomId]: [...(pickedMentions[roomId] ?? []), { user_id: person.user_id, text: person.name }]
731
    };
732
    mentionQuery = null;
733
    await tick();
734
    composerEl.focus();
735
    composerEl.setSelectionRange(at, at);
736
  }
737
738
  function onComposerKey(ev: KeyboardEvent): void {
739
    if (mentionQuery && mentionCandidates.length > 0) {
740
      if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
741
        ev.preventDefault();
742
        const step = ev.key === 'ArrowDown' ? 1 : -1;
743
        mentionIndex = (mentionIndex + step + mentionCandidates.length) % mentionCandidates.length;
744
        return;
745
      }
746
      if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Tab') {
747
        ev.preventDefault();
748
        void pickMention(mentionCandidates[mentionIndex]);
749
        return;
750
      }
751
      if (ev.key === 'Escape') {
752
        ev.stopPropagation();
753
        mentionQuery = null;
754
        return;
755
      }
756
    }
757
    if (ev.key === 'Enter' && !ev.shiftKey) {
758
      ev.preventDefault();
759
      send();
760
    } else if (ev.key === 'Escape' && editing) {
761
      ev.stopPropagation();
762
      cancelEdit();
763
    } else if (ev.key === 'Escape' && replyingTo) {
764
      ev.stopPropagation();
765
      replyingTo = null;
766
    } else if (ev.key === 'ArrowUp' && !composer && !editing && editLastOwn()) {
767
      ev.preventDefault();
768
    }
769
  }
770
771
  function pickFiles(): void {
772
    const roomId = $selectedRoomId;
773
    if (!roomId || !openReady[roomId]) return;
774
    fileInput?.click();
775
  }
776
777
  function onFileChosen(): void {
778
    stageFiles(Array.from(fileInput?.files ?? []));
779
    if (fileInput) fileInput.value = '';
780
  }
781
782
  function onComposerPaste(ev: ClipboardEvent): void {
783
    const data = ev.clipboardData;
784
    // Office apps put a rendered image next to the copied text, so only take the image when there is no plain text.
785
    if (!data || data.types.includes('text/plain')) return;
786
    const images = Array.from(data.files).filter((f) => f.type.startsWith('image/'));
787
    if (images.length === 0) return;
788
    ev.preventDefault();
789
    stageFiles(images);
790
  }
791
792
  async function react(eventId: string, key: string, shortcode?: string): Promise<void> {
793
    const roomId = $selectedRoomId;
794
    if (!roomId || !eventId || reacting) return;
795
    reacting = true;
796
    try {
797
      await reactToMessage(roomId, eventId, key, shortcode);
798
    } catch (e) {
799
      pushBanner(e instanceof Error ? e.message : 'Reaction failed');
800
    } finally {
801
      reacting = false;
802
    }
803
  }
804
805
  function previewLoader(roomId: string): (url: string) => void {
806
    return (url) => requestLinkPreview(roomId, url);
807
  }
808
809
  async function changeEncryptedPreviews(enabled: boolean): Promise<void> {
810
    try {
811
      await saveUrlPreviewSettings({ encrypted_rooms: enabled });
812
    } catch (e) {
813
      pushBanner(e instanceof Error ? e.message : 'Could not save link preview setting');
814
    }
815
  }
816
817
  async function changeQuickReactions(list: QuickReaction[]): Promise<void> {
818
    try {
819
      await saveQuickReactions(list);
820
    } catch (e) {
821
      pushBanner(e instanceof Error ? e.message : 'Could not save quick reactions');
822
    }
823
  }
824
825
  async function paginate(): Promise<void> {
826
    const roomId = $selectedRoomId;
827
    if (!roomId || paginateBusy) return;
828
    paginateBusy = true;
829
    try {
830
      await paginateRoom(roomId);
831
    } catch (e) {
832
      pushBanner(e instanceof Error ? e.message : 'Loading older messages failed');
833
    } finally {
834
      paginateBusy = false;
835
    }
836
  }
837
838
  async function loadMembers(roomId: string): Promise<void> {
839
    if (membersFor === roomId && $membersLoading) return;
840
    membersFor = roomId;
841
    membersLoading.set(true);
842
    try {
843
      const res = await getMembers(roomId);
844
      membersByRoom.update((all) => ({ ...all, [roomId]: res.members }));
845
      avatarByUser = { ...avatarByUser, ...avatarMap(res.members) };
846
    } catch (e) {
847
      pushBanner(e instanceof Error ? e.message : 'Could not load members');
848
    } finally {
849
      if (membersFor === roomId) membersLoading.set(false);
850
    }
851
  }
852
853
  function avatarMap(list: MemberSummary[]): Record<string, string | null> {
854
    const next: Record<string, string | null> = {};
855
    for (const member of list) next[member.user_id] = member.avatar;
856
    return next;
857
  }
858
859
  function peopleIn(list: TimelineItem[], roster: MemberSummary[]): Record<string, Person> {
860
    const out: Record<string, Person> = {};
861
    for (const item of list) {
862
      if (item.kind !== 'event') continue;
863
      const s = item.event.sender;
864
      out[s.user_id] = { user_id: s.user_id, name: senderLabel(s), avatar: s.avatar };
865
    }
866
    for (const m of roster) out[m.user_id] = { user_id: m.user_id, name: m.name, avatar: m.avatar };
867
    return out;
868
  }
869
870
  function senderAvatarFor(userId: string): string | null {
871
    return avatarByUser[userId] ?? null;
872
  }
873
874
  function toggleMembers(): void {
875
    membersOpen = !membersOpen;
876
    if (membersOpen && $selectedRoomId) void loadMembers($selectedRoomId);
877
  }
878
879
  async function join(target: string): Promise<void> {
880
    const room = target.trim();
881
    if (!room || joinBusy) return;
882
    joinBusy = true;
883
    try {
884
      const res = await joinRoom(room);
885
      joinTarget = '';
886
      dialog = null;
887
      await refreshRooms();
888
      await selectRoom(res.room_id);
889
    } catch (e) {
890
      pushBanner(e instanceof Error ? e.message : 'Join failed');
891
    } finally {
892
      joinBusy = false;
893
    }
894
  }
895
896
  async function create(): Promise<void> {
897
    const name = createName.trim();
898
    if (!name || createBusy) return;
899
    createBusy = true;
900
    try {
901
      const res = await createRoom(name, createTopic.trim() || undefined);
902
      createName = '';
903
      createTopic = '';
904
      dialog = null;
905
      await refreshRooms();
906
      await selectRoom(res.room_id);
907
    } catch (e) {
908
      pushBanner(e instanceof Error ? e.message : 'Create room failed');
909
    } finally {
910
      createBusy = false;
911
    }
912
  }
913
914
  async function leave(): Promise<void> {
915
    const roomId = $selectedRoomId;
916
    if (!roomId || leaveBusy) return;
917
    leaveBusy = true;
918
    stopTyping();
919
    try {
920
      await leaveRoom(roomId);
921
      if ($selectedRoomId === roomId) selectedRoomId.set(null);
922
      if (warmRooms.includes(roomId)) {
923
        forgetOpenRoom(roomId);
924
        closeRoom(roomId).catch(() => undefined);
925
      }
926
      await refreshRooms();
927
    } catch (e) {
928
      pushBanner(e instanceof Error ? e.message : 'Leave failed');
929
    } finally {
930
      leaveBusy = false;
931
    }
932
  }
933
934
  function connectionClass(state: string): string {
935
    if (state === 'online') return 'dot-online';
936
    if (state === 'connecting' || state === 'reconnecting') return 'dot-busy';
937
    return 'dot-off';
938
  }
939
940
  function onKeydown(ev: KeyboardEvent): void {
941
    if (ev.key === 'Escape') {
942
      if (dialog) dialog = null;
943
      else if (membersOpen && window.innerWidth < 1100) membersOpen = false;
944
      else if (sidebarOpen) sidebarOpen = false;
945
    }
946
  }
947
</script>
948
949
<svelte:window on:keydown={onKeydown} />
950
951
{#if !booted}
952
  <div class="splash" role="status" aria-label="Loading Malice">
953
    <div class="splash-mark" aria-hidden="true">M</div>
954
    <p>Loading Malice…</p>
955
  </div>
956
{:else if $blocked}
957
  <div class="splash" role="status" aria-label="Malice is open in another tab">
958
    <div class="splash-mark" aria-hidden="true">M</div>
959
    <p>Malice is open in another tab. Close this tab or use that one instead.</p>
960
  </div>
961
{:else if $sessionUser == null}
962
  <LoginPanel softLogout={$softLogout} on:done={(ev) => afterLogin(ev.detail)} />
963
{:else}
964
  <div class="app" class:nav-folded={$navFolded && selectedRoom} style:--msg-gap={`${$messageSpacing}px`} style:--msg-line-height={$lineHeight}>
965
    <nav class="rail" aria-label="Servers and spaces">
966
      <button
967
        type="button"
968
        class="rail-btn"
969
        class:active={selectedSpaceId === null}
970
        title="Home — all channels"
971
        aria-label="Home — all channels"
972
        aria-pressed={selectedSpaceId === null}
973
        on:click={() => void selectHome()}
974
      >
975
        <span aria-hidden="true">⌂</span>
976
      </button>
977
      <div class="rail-sep" aria-hidden="true"></div>
978
      {#each railSpaces as space (space.room_id)}
979
        <button
980
          type="button"
981
          class="rail-btn rail-space"
982
          class:active={railSelected === space.room_id}
983
          title={space.name}
984
          aria-label={`Show channels in space ${space.name}`}
985
          aria-pressed={selectedSpaceId === space.room_id}
986
          on:click={() => void selectSpace(space.room_id)}
987
        >
988
          <MxcAvatar mxc={space.avatar} label={space.name} size={44} shape="rounded" />
989
        </button>
990
      {/each}
991
      <button
992
        type="button"
993
        class="rail-btn rail-add"
994
        aria-label="Join or create a room"
995
        on:click={() => {
996
          dialog = 'join';
997
        }}
998
      >
999
        <span aria-hidden="true">+</span>
1000
      </button>
1001
    </nav>
1002
1003
    <aside class="sidebar" class:open={sidebarOpen} aria-label="Rooms">
1004
      <div class="side-search">
1005
        <label class="visually-hidden" for="room-search">Search rooms</label>
1006
        <input
1007
          id="room-search"
1008
          type="search"
1009
          placeholder="Search rooms"
1010
          bind:value={search}
1011
          autocomplete="off"
1012
        />
1013
        <button
1014
          type="button"
1015
          class="icon-btn"
1016
          title="Refresh rooms"
1017
          aria-label="Refresh rooms"
1018
          disabled={roomsLoading}
1019
          on:click={() => void refreshRooms()}
1020
        >
1021
          <span aria-hidden="true">⟳</span>
1022
        </button>
1023
      </div>
1024
      <div class="side-scroll">
1025
        {#if selectedSpace}
1026
          <div class="space-context">
1027
            <MxcAvatar mxc={selectedSpace.avatar} label={selectedSpace.name} size={28} shape="rounded" />
1028
            <div class="space-context-text">
1029
              <strong>{selectedSpace.name}</strong>
1030
              <span>Space — channels below</span>
1031
            </div>
1032
            {#if spaceParents.has(selectedSpace.room_id)}
1033
              {@const parentId = spaceParents.get(selectedSpace.room_id) ?? ''}
1034
              <button
1035
                type="button"
1036
                class="icon-btn"
1037
                title="Up to the parent space"
1038
                aria-label="Up to the parent space"
1039
                on:click={() => void selectSpace(parentId)}
1040
              >
1041
                <span aria-hidden="true">↑</span>
1042
              </button>
1043
            {/if}
1044
          </div>
1045
        {:else}
1046
          <div class="space-context">
1047
            <div class="space-context-text">
1048
              <strong>Home</strong>
1049
              <span>All channels</span>
1050
            </div>
1051
          </div>
1052
        {/if}
1053
        {#if roomsLoading && $rooms.length === 0}
1054
          <p class="side-note">Loading rooms…</p>
1055
        {:else if $rooms.length === 0}
1056
          <p class="side-note">No rooms yet. Join one or create your own.</p>
1057
          <div class="side-cta">
1058
            <button type="button" on:click={() => (dialog = 'join')}>Join a room</button>
1059
            <button type="button" on:click={() => (dialog = 'create')}>Create one</button>
1060
          </div>
1061
        {:else}
1062
          {#if selectedSpaceId && spaceChildrenLoading}
1063
            <p class="side-note">Loading channels in this space…</p>
1064
          {:else if selectedSpaceId && spaceChildrenError}
1065
            <p class="side-note" role="alert">{spaceChildrenError}</p>
1066
            <div class="side-cta">
1067
              <button type="button" on:click={() => selectedSpaceId && void selectSpace(selectedSpaceId)}>Retry</button>
1068
              <button type="button" on:click={() => void selectHome()}>Back to Home</button>
1069
            </div>
1070
          {:else if selectedSpaceId && spaceChildIds && channelRooms.length === 0}
1071
            <p class="side-note">No channels found in this space yet.</p>
1072
            <div class="side-cta">
1073
              <button type="button" on:click={() => void selectHome()}>Back to Home</button>
1074
            </div>
1075
          {:else}
1076
            {#if invites.length > 0 && !selectedSpaceId}
1077
              <h2 class="side-h">Invites</h2>
1078
              <ul class="room-list">
1079
                {#each invites as room (room.room_id)}
1080
                  <li>
1081
                    <button
1082
                      type="button"
1083
                      class="room-row"
1084
                      class:active={$selectedRoomId === room.room_id}
1085
                      on:click={() => void selectRoom(room.room_id)}
1086
                    >
1087
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
1088
                      <span class="room-name">{room.name}</span>
1089
                      <span
1090
                        class="invite-join"
1091
                        role="button"
1092
                        tabindex="0"
1093
                        aria-label={`Join ${room.name}`}
1094
                        on:click|stopPropagation={() => void join(room.room_id)}
1095
                        on:keydown|stopPropagation={(ev) => {
1096
                          if (ev.key === 'Enter' || ev.key === ' ') {
1097
                            ev.preventDefault();
1098
                            void join(room.room_id);
1099
                          }
1100
                        }}
1101
                      >
1102
                        Join
1103
                      </span>
1104
                    </button>
1105
                  </li>
1106
                {/each}
1107
              </ul>
1108
            {/if}
1109
1110
            {#if directRooms.length > 0}
1111
              <h2 class="side-h">Direct messages</h2>
1112
              <ul class="room-list">
1113
                {#each directRooms as room (room.room_id)}
1114
                  <li>
1115
                    <button
1116
                      type="button"
1117
                      class="room-row"
1118
                      class:active={$selectedRoomId === room.room_id}
1119
                      aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
1120
                      on:click={() => void selectRoom(room.room_id)}
1121
                    >
1122
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} />
1123
                      <span class="room-name">{room.name}</span>
1124
                      {#if roomUnread(room) > 0}
1125
                        <span
1126
                          class="badge"
1127
                          class:mention={room.unread_mentions > 0}
1128
                          aria-label={`${roomUnread(room)} unread`}
1129
                        >
1130
                          {roomUnread(room) > 99 ? '99+' : roomUnread(room)}
1131
                        </span>
1132
                      {/if}
1133
                    </button>
1134
                  </li>
1135
                {/each}
1136
              </ul>
1137
            {/if}
1138
1139
            {#if groupRooms.length > 0}
1140
              <h2 class="side-h">{selectedSpace ? 'Channels in this space' : 'Rooms'}</h2>
1141
              <ul class="room-list">
1142
                {#each groupRooms as room (room.room_id)}
1143
                  <li>
1144
                    <button
1145
                      type="button"
1146
                      class="room-row"
1147
                      class:active={$selectedRoomId === room.room_id}
1148
                      aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
1149
                      on:click={() => void selectRoom(room.room_id)}
1150
                    >
1151
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
1152
                      <span class="room-hash" aria-hidden="true">#</span>
1153
                      <span class="room-name">{room.name}</span>
1154
                      {#if roomUnread(room) > 0}
1155
                        <span
1156
                          class="badge"
1157
                          class:mention={room.unread_mentions > 0}
1158
                          aria-label={`${roomUnread(room)} unread`}
1159
                        >
1160
                          {roomUnread(room) > 99 ? '99+' : roomUnread(room)}
1161
                        </span>
1162
                      {/if}
1163
                    </button>
1164
                  </li>
1165
                {/each}
1166
              </ul>
1167
            {/if}
1168
1169
            {#if spaceList.length > 0}
1170
              <h2 class="side-h">{selectedSpace ? 'Subspaces' : 'Spaces'}</h2>
1171
              <ul class="room-list">
1172
                {#each spaceList as room (room.room_id)}
1173
                  <li>
1174
                    <button
1175
                      type="button"
1176
                      class="room-row"
1177
                      class:active={selectedSpaceId === room.room_id}
1178
                      aria-label={`Show channels in space ${room.name}`}
1179
                      on:click={() => void selectSpace(room.room_id)}
1180
                    >
1181
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
1182
                      <span class="room-hash" aria-hidden="true">◇</span>
1183
                      <span class="room-name">{room.name}</span>
1184
                    </button>
1185
                  </li>
1186
                {/each}
1187
              </ul>
1188
            {/if}
1189
1190
            {#if query && visibleRooms.length === 0}
1191
              <p class="side-note">No rooms match “{search.trim()}”.</p>
1192
            {/if}
1193
          {/if}
1194
        {/if}
1195
      </div>
1196
1197
      <div class="side-footer">
1198
        <div class="me" title={$sessionUser}>
1199
          {#if profileMxc}
1200
            <MxcAvatar mxc={profileMxc} label={profile?.display_name ?? $sessionUser} size={30} />
1201
          {:else}
1202
            <span class="me-avatar" aria-hidden="true">{$sessionUser.slice(1, 2).toUpperCase()}</span>
1203
          {/if}
1204
          <span class="me-id">{profile?.display_name && profile.display_name !== $sessionUser ? `${profile.display_name} (${$sessionUser})` : $sessionUser}</span>
1205
        </div>
1206
        <button
1207
          type="button"
1208
          class="icon-btn"
1209
          title="Encryption and verification"
1210
          aria-label="Encryption and verification"
1211
          on:click={() => (dialog = 'security')}
1212
        >
1213
          <span aria-hidden="true">🛡</span>
1214
        </button>
1215
        <button
1216
          type="button"
1217
          class="icon-btn"
1218
          title="Settings"
1219
          aria-label="Settings"
1220
          on:click={() => (dialog = 'settings')}
1221
        >
1222
          <span aria-hidden="true">⚙</span>
1223
        </button>
1224
        <button type="button" class="icon-btn" title="Log out" aria-label="Log out" on:click={() => void doLogout()}>
1225
          <span aria-hidden="true">⏻</span>
1226
        </button>
1227
      </div>
1228
    </aside>
1229
    {#if sidebarOpen}
1230
      <button type="button" class="scrim" aria-label="Close room list" on:click={() => (sidebarOpen = false)}></button>
1231
    {/if}
1232
1233
    <main class="chat">
1234
        {#if $banners.length > 0}
1235
          <div class="banners" aria-live="polite">
1236
            {#each $banners as banner (banner.id)}
1237
              <div class="banner" role="alert">
1238
                <span>{banner.message}</span>
1239
                <button
1240
                  type="button"
1241
                  aria-label="Dismiss error"
1242
                  on:click={() => dismissBanner(banner.id)}
1243
                >
1244
                  ✕
1245
                </button>
1246
              </div>
1247
            {/each}
1248
          </div>
1249
        {/if}
1250
      {#if !selectedRoom}
1251
        <div class="empty">
1252
          <div class="empty-mark" aria-hidden="true">◈</div>
1253
          {#if selectedSpace}
1254
            <h1>{selectedSpace.name}</h1>
1255
            <p>Select a channel below to start chatting. Spaces group channels — they are not chat rooms.</p>
1256
          {:else}
1257
            <h1>Welcome to Malice</h1>
1258
            <p>Pick a room from the sidebar, or join one to get started.</p>
1259
          {/if}
1260
          <div class="empty-cta">
1261
            <button type="button" class="primary-btn" on:click={() => (dialog = 'join')}>Join a room</button>
1262
            <button type="button" class="ghost-btn" on:click={() => (dialog = 'create')}>Create a room</button>
1263
          </div>
1264
          <p class="conn-line">
1265
            <span class="dot {connectionClass($connection)}" aria-hidden="true"></span>
1266
            {$connection}
1267
          </p>
1268
        </div>
1269
      {:else}
1270
        <header class="chat-head">
1271
          <button
1272
            type="button"
1273
            class="icon-btn only-mobile"
1274
            aria-label="Open room list"
1275
            on:click={() => (sidebarOpen = true)}
1276
          >
1277
            <span aria-hidden="true">☰</span>
1278
          </button>
1279
          <button
1280
            type="button"
1281
            class="icon-btn only-desktop"
1282
            aria-pressed={!$navFolded}
1283
            aria-label={$navFolded ? 'Show spaces and rooms' : 'Hide spaces and rooms'}
1284
            title={$navFolded ? 'Show spaces and rooms' : 'Hide spaces and rooms'}
1285
            on:click={() => navFolded.update((v) => !v)}
1286
          >
1287
            <span aria-hidden="true">{$navFolded ? '»' : '«'}</span>
1288
          </button>
1289
          <div class="head-title">
1290
            <div class="head-room">
1291
              <MxcAvatar mxc={selectedRoom.avatar} label={selectedRoom.name} size={36} shape={selectedRoom.space ? 'rounded' : 'circle'} />
1292
              <div class="head-room-text">
1293
                <h1>{selectedRoom.name}</h1>
1294
                <div class="head-meta">
1295
                  {#if selectedRoom.space}
1296
                    <span class="pill">space</span>
1297
                  {/if}
1298
                  {#if selectedRoom.encrypted}
1299
                    <span class="pill" title="End-to-end encrypted">🔒 encrypted</span>
1300
                  {/if}
1301
                  {#if selectedRoom.direct}
1302
                    <span class="pill">direct</span>
1303
                  {/if}
1304
                  <span class="conn" title={`Sync state: ${$connection}`}>
1305
                    <span class="dot {connectionClass($connection)}" aria-hidden="true"></span>
1306
                    {$connection}
1307
                  </span>
1308
                </div>
1309
              </div>
1310
            </div>
1311
          </div>
1312
          <div class="head-actions">
1313
            <button
1314
              type="button"
1315
              class="ghost-btn"
1316
              aria-label="Encryption and verification"
1317
              title="Encryption and verification"
1318
              on:click={() => (dialog = 'security')}
1319
            >
1320
              🛡 Security
1321
            </button>
1322
            <button
1323
              type="button"
1324
              class="ghost-btn"
1325
              aria-pressed={membersOpen}
1326
              aria-label={membersOpen ? 'Hide members' : 'Show members'}
1327
              on:click={toggleMembers}
1328
            >
1329
              Members
1330
            </button>
1331
            {#if selectedRoom.membership === 'Joined'}
1332
              <button
1333
                type="button"
1334
                class="ghost-btn danger"
1335
                disabled={leaveBusy}
1336
                on:click={() => void leave()}
1337
              >
1338
                {leaveBusy ? 'Leaving…' : 'Leave'}
1339
              </button>
1340
            {:else}
1341
              <button
1342
                type="button"
1343
                class="primary-btn"
1344
                disabled={joinBusy}
1345
                on:click={() => void join(selectedRoom.room_id)}
1346
              >
1347
                {joinBusy ? 'Joining…' : 'Join'}
1348
              </button>
1349
            {/if}
1350
            <button
1351
              type="button"
1352
              class="icon-btn only-mobile"
1353
              aria-label="Toggle members"
1354
              on:click={toggleMembers}
1355
            >
1356
              <span aria-hidden="true">ⓘ</span>
1357
            </button>
1358
          </div>
1359
        </header>
1360
1361
1362
        {#if recoveryBanner}
1363
          <div class="sec-banner warn" role="status">
1364
            <span>Message history is locked — recovery is {recoveryBanner.recovery}. Enter your recovery key to decrypt.</span>
1365
            <button type="button" on:click={() => (dialog = 'security')}>Recover</button>
1366
          </div>
1367
        {/if}
1368
        {#if verificationBanner}
1369
          <div class="sec-banner" role="status">
1370
            {#if verificationBanner.kind === 'Incoming'}
1371
              <span>A device wants to verify this session. Review the request to accept it.</span>
1372
              <button type="button" on:click={() => (dialog = 'security')}>Review request</button>
1373
            {:else if verificationBanner.kind === 'Comparison'}
1374
              <span>Verification codes are ready — compare emojis and numbers.</span>
1375
              <button type="button" on:click={() => (dialog = 'security')}>Compare codes</button>
1376
            {:else if verificationBanner.kind === 'Waiting'}
1377
              <span>Verification started — approve on your other device, then compare codes here.</span>
1378
              <button type="button" on:click={() => (dialog = 'security')}>Open verification</button>
1379
            {:else if verificationBanner.kind === 'Failed'}
1380
              <span>Verification failed: {verificationBanner.message}</span>
1381
              <button type="button" on:click={() => (dialog = 'security')}>Try again</button>
1382
            {:else if verificationBanner.kind === 'Cancelled'}
1383
              <span>Verification cancelled: {verificationBanner.reason}</span>
1384
              <button type="button" on:click={() => (dialog = 'security')}>Open security</button>
1385
            {:else}
1386
              <span>Verification in progress.</span>
1387
              <button type="button" on:click={() => (dialog = 'security')}>Open security</button>
1388
            {/if}
1389
          </div>
1390
        {/if}
1391
        {#if $connection !== 'online'}
1392
          <p class="conn-banner" role="status">
1393
            Sync is {$connection}. New messages will appear when the connection recovers.
1394
          </p>
1395
        {/if}
1396
        {#if $selectedRoomId && !openReady[$selectedRoomId] && !openFailed[$selectedRoomId]}
1397
          <p class="conn-banner" role="status">Opening {selectedRoom.name}… sending is disabled until the room is ready.</p>
1398
        {/if}
1399
        {#if $selectedRoomId && openFailed[$selectedRoomId]}
1400
          <p class="conn-banner error" role="alert">{openFailed[$selectedRoomId]}</p>
1401
        {/if}
1402
1403
        <div class="timeline" bind:this={timelineEl} on:scroll={onTimelineScroll} aria-label={`Messages in ${selectedRoom.name}`}>
1404
          <div class="timeline-top">
1405
            {#if hasStart}
1406
              <p class="start-note">This is the beginning of the visible history.</p>
1407
            {:else}
1408
              <button type="button" class="ghost-btn" disabled={paginateBusy} on:click={() => void paginate()}>
1409
                {paginateBusy ? 'Loading…' : 'Load older messages'}
1410
              </button>
1411
            {/if}
1412
          </div>
1413
          {#if grouped.length === 0}
1414
            <p class="side-note timeline-empty">No messages yet. Say hello!</p>
1415
          {:else}
1416
            {#each grouped as g (g.key)}
1417
              {#if g.item.kind === 'day'}
1418
                <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div>
1419
              {:else if g.item.kind === 'readMarker'}
1420
                <div class="new-row"><span>New</span></div>
1421
              {:else if g.item.kind === 'start'}
1422
                <p class="start-note">This is the beginning of the visible history.</p>
1423
              {:else if g.item.kind === 'unknown'}
1424
                <p class="unknown-note">Unsupported event ({g.item.label})</p>
1425
              {:else}
1426
                <MessageRow
1427
                  itemId={g.key}
1428
                  event={g.item.event}
1429
                  showSender={g.showSender}
1430
                  senderAvatar={senderAvatarFor(g.item.event.sender.user_id)}
1431
                  {reacting}
1432
                  onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
1433
                  quickReactions={$quickReactions}
1434
                  packs={roomPacks}
1435
                  linkPreviews={$linkPreviews}
1436
                  {loadPreview}
1437
                  onQuickChange={(list) => void changeQuickReactions(list)}
1438
                  {people}
1439
                  onReply={selectedRoom.membership === 'Joined' ? startReply : null}
1440
                  onJump={jumpTo}
1441
                  onEdit={selectedRoom.membership === 'Joined' ? startEdit : null}
1442
                  editing={editing !== null && editing.eventId === g.item.event.event_id}
1443
                  showSent={g.item.event.item_id === lastSentId && pendingItems.length === 0}
1444
                />
1445
              {/if}
1446
            {/each}
1447
          {/if}
1448
          {#each pendingItems as p, i (p.id)}
1449
            <MessageRow
1450
              itemId={`pending-${p.id}`}
1451
              event={pendingToEvent(p)}
1452
              onJump={jumpTo}
1453
              showSender={pendingShowSender(i)}
1454
              senderAvatar={senderAvatarFor($sessionUser ?? '')}
1455
              {reacting}
1456
              onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
1457
              quickReactions={$quickReactions}
1458
              packs={roomPacks}
1459
              linkPreviews={$linkPreviews}
1460
              {loadPreview}
1461
              onQuickChange={(list) => void changeQuickReactions(list)}
1462
            />
1463
            {#if p.status === 'failed'}
1464
              <div class="pending-actions">
1465
                <span class="pending-error" role="alert">{p.error}</span>
1466
                <button type="button" class="ghost-btn" on:click={() => retryPending(p.id)}>Retry</button>
1467
                <button type="button" class="ghost-btn danger" on:click={() => discardPending(p.id)}>Discard</button>
1468
              </div>
1469
            {/if}
1470
          {/each}
1471
        </div>
1472
1473
        {#if selectedRoom.membership === 'Joined'}
1474
          {#if editing}
1475
            <div class="reply-bar" role="status">
1476
              <span class="reply-bar-label">
1477
                Editing message
1478
                <span class="reply-bar-text">{editing.original}</span>
1479
              </span>
1480
              <button type="button" class="icon-btn" aria-label="Cancel edit" title="Cancel edit (Esc)" on:click={cancelEdit}>
1481
                <span aria-hidden="true">✕</span>
1482
              </button>
1483
            </div>
1484
          {:else if replyingTo && replyingTo.details.kind === 'Ready'}
1485
            <div class="reply-bar" role="status">
1486
              <span class="reply-bar-label">
1487
                Replying to <strong>{senderLabel(replyingTo.details.sender)}</strong>
1488
                <span class="reply-bar-text">{contentPreview(replyingTo.details.content)}</span>
1489
              </span>
1490
              <button type="button" class="icon-btn" aria-label="Cancel reply" title="Cancel reply (Esc)" on:click={() => (replyingTo = null)}>
1491
                <span aria-hidden="true">✕</span>
1492
              </button>
1493
            </div>
1494
          {/if}
1495
          {#if $selectedRoomId && currentStaged.length > 0}
1496
            {@const stagedRoom = $selectedRoomId}
1497
            <ul class="staged" aria-label="Attachments to send">
1498
              {#each currentStaged as attachment (attachment.id)}
1499
                <li class="staged-item">
1500
                  {#if attachment.previewUrl}
1501
                    <img src={attachment.previewUrl} alt={attachment.file.name || 'Pasted image'} />
1502
                  {:else}
1503
                    <span class="staged-name">{attachment.file.name || 'attachment'}</span>
1504
                  {/if}
1505
                  <button
1506
                    type="button"
1507
                    class="icon-btn staged-remove"
1508
                    aria-label={`Remove ${attachment.file.name || 'attachment'}`}
1509
                    title="Remove"
1510
                    on:click={() => unstage(stagedRoom, attachment.id)}
1511
                  >
1512
                    <span aria-hidden="true">✕</span>
1513
                  </button>
1514
                </li>
1515
              {/each}
1516
            </ul>
1517
          {/if}
1518
          <form class="composer" class:replying={replyingTo || editing} class:staging={currentStaged.length > 0} on:submit|preventDefault={() => send()}>
1519
            <label class="visually-hidden" for="composer">Message {selectedRoom.name}</label>
1520
            {#if mentionQuery && mentionCandidates.length > 0}
1521
              <ul class="mention-finder" role="listbox" aria-label="Mention someone">
1522
                {#each mentionCandidates as person, i (person.user_id)}
1523
                  <li
1524
                    role="option"
1525
                    aria-selected={i === mentionIndex}
1526
                    class:active={i === mentionIndex}
1527
                    on:pointerdown|preventDefault={() => void pickMention(person)}
1528
                    on:pointerenter={() => (mentionIndex = i)}
1529
                  >
1530
                    <MxcAvatar mxc={person.avatar} label={person.name} size={22} />
1531
                    <span class="mention-name">{person.name}</span>
1532
                    <span class="mention-id">{person.user_id}</span>
1533
                  </li>
1534
                {/each}
1535
              </ul>
1536
            {/if}
1537
            <input
1538
              type="file"
1539
              multiple
1540
              bind:this={fileInput}
1541
              class="visually-hidden"
1542
              aria-label="Attach a file"
1543
              on:change={() => void onFileChosen()}
1544
            />
1545
            <button
1546
              type="button"
1547
              class="ghost-btn"
1548
              title="Attach an image or file"
1549
              aria-label="Attach an image or file"
1550
              disabled={!($selectedRoomId && openReady[$selectedRoomId])}
1551
              on:click={pickFiles}
1552
            >
1553
              {uploadBusy ? `Uploading ${uploadName ?? 'file'}…` : '📎'}
1554
            </button>
1555
            <textarea
1556
              id="composer"
1557
              bind:this={composerEl}
1558
              bind:value={composer}
1559
              rows="1"
1560
              placeholder={`Message ${selectedRoom.name}`}
1561
              aria-label={`Message ${selectedRoom.name}. Enter to send, Shift Enter for a new line.`}
1562
              on:keydown={onComposerKey}
1563
              on:input={updateMentionQuery}
1564
              on:click={updateMentionQuery}
1565
              on:keyup={(e) => (e.key.startsWith('Arrow') && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') || e.key === 'Home' || e.key === 'End' ? updateMentionQuery() : undefined}
1566
              on:blur={() => (mentionQuery = null)}
1567
              on:paste={onComposerPaste}
1568
              disabled={!($selectedRoomId && openReady[$selectedRoomId])}
1569
            ></textarea>
1570
            <button
1571
              type="submit"
1572
              class="primary-btn"
1573
              disabled={(!composer.trim() && currentStaged.length === 0) ||
1574
                !($selectedRoomId && openReady[$selectedRoomId]) ||
1575
                (currentStaged.length > 0 && uploadBusy)}
1576
              title={!($selectedRoomId && openReady[$selectedRoomId]) ? 'Waiting for the room to open' : 'Send message'}
1577
            >
1578
              Send
1579
            </button>
1580
          </form>
1581
          <p class="composer-note">Sending and failed states show under your message.</p>
1582
        {:else}
1583
          <div class="join-bar">
1584
            <p>You are not a member of this room.</p>
1585
            <button type="button" class="primary-btn" disabled={joinBusy} on:click={() => void join(selectedRoom.room_id)}>
1586
              {joinBusy ? 'Joining…' : `Join ${selectedRoom.name}`}
1587
            </button>
1588
          </div>
1589
        {/if}
1590
      {/if}
1591
    </main>
1592
1593
    {#if selectedRoom && membersOpen}
1594
      <aside class="members" aria-label={`Members of ${selectedRoom.name}`}>
1595
        <div class="members-head">
1596
          <h2>Members</h2>
1597
          <button type="button" class="icon-btn" aria-label="Close members" on:click={() => (membersOpen = false)}>
1598
            <span aria-hidden="true">✕</span>
1599
          </button>
1600
        </div>
1601
        {#if $membersLoading && members.length === 0}
1602
          <p class="side-note">Loading members…</p>
1603
        {:else if members.length === 0}
1604
          <p class="side-note">No members loaded.</p>
1605
          {#if $selectedRoomId}
1606
            <button type="button" class="ghost-btn" on:click={() => $selectedRoomId && void loadMembers($selectedRoomId)}>
1607
              Retry
1608
            </button>
1609
          {/if}
1610
        {:else}
1611
          <ul class="member-list">
1612
            {#each members as member (member.user_id)}
1613
              <li class="member">
1614
                <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} size={30} />
1615
                <span class="member-names">
1616
                  <span class="member-name">{member.display_name || member.name}</span>
1617
                  <span class="member-id">{member.user_id}</span>
1618
                </span>
1619
              </li>
1620
            {/each}
1621
          </ul>
1622
        {/if}
1623
      </aside>
1624
    {/if}
1625
1626
    {#if dialog}
1627
      <div class="modal-wrap" role="presentation" on:click|self={() => (dialog = null)}>
1628
        {#if dialog === 'security'}
1629
          <SecurityPanel
1630
            encryptionState={$encryption}
1631
            verificationState={$verification}
1632
            onAction={() => void loadEncryption()}
1633
            encryptedPreviews={$urlPreviewSettings.encrypted_rooms}
1634
            onEncryptedPreviews={(enabled) => void changeEncryptedPreviews(enabled)}
1635
            on:close={() => (dialog = null)}
1636
          />
1637
        {:else if dialog === 'settings'}
1638
          <SettingsPanel
1639
            quick={$quickReactions}
1640
            packs={roomPacks}
1641
            onQuickChange={(list) => void changeQuickReactions(list)}
1642
            on:close={() => (dialog = null)}
1643
          />
1644
        {:else}
1645
          <div class="modal" role="dialog" aria-modal="true" aria-label={dialog === 'join' ? 'Join a room' : 'Create a room'}>
1646
            <div class="modal-tabs" role="tablist" aria-label="Room actions">
1647
              <button
1648
                type="button"
1649
                role="tab"
1650
                aria-selected={dialog === 'join'}
1651
                class:active={dialog === 'join'}
1652
                on:click={() => (dialog = 'join')}
1653
              >
1654
                Join
1655
              </button>
1656
              <button
1657
                type="button"
1658
                role="tab"
1659
                aria-selected={dialog === 'create'}
1660
                class:active={dialog === 'create'}
1661
                on:click={() => (dialog = 'create')}
1662
              >
1663
                Create
1664
              </button>
1665
              <button
1666
                type="button"
1667
                class="icon-btn modal-x"
1668
                aria-label="Close dialog"
1669
                on:click={() => (dialog = null)}
1670
              >
1671
                <span aria-hidden="true">✕</span>
1672
              </button>
1673
            </div>
1674
            {#if dialog === 'join'}
1675
              <form on:submit|preventDefault={() => void join(joinTarget)}>
1676
                <label class="field">
1677
                  <span>Room ID or alias</span>
1678
                  <input
1679
                    type="text"
1680
                    placeholder="!room:example.org or #alias:example.org"
1681
                    bind:value={joinTarget}
1682
                    required
1683
                    disabled={joinBusy}
1684
                  />
1685
                </label>
1686
                <button type="submit" class="primary-btn wide" disabled={joinBusy || !joinTarget.trim()}>
1687
                  {joinBusy ? 'Joining…' : 'Join room'}
1688
                </button>
1689
              </form>
1690
            {:else}
1691
            <form on:submit|preventDefault={() => void create()}>
1692
              <label class="field">
1693
                <span>Room name</span>
1694
                <input
1695
                  type="text"
1696
                  placeholder="Weekend plans"
1697
                  bind:value={createName}
1698
                  required
1699
                  disabled={createBusy}
1700
                />
1701
              </label>
1702
              <label class="field">
1703
                <span>Topic (optional)</span>
1704
                <input
1705
                  type="text"
1706
                  placeholder="What should this room be about?"
1707
                  bind:value={createTopic}
1708
                  disabled={createBusy}
1709
                />
1710
              </label>
1711
              <button type="submit" class="primary-btn wide" disabled={createBusy || !createName.trim()}>
1712
                {createBusy ? 'Creating…' : 'Create room'}
1713
              </button>
1714
            </form>
1715
            {/if}
1716
          </div>
1717
        {/if}
1718
      </div>
1719
    {/if}
1720
  </div>
1721
{/if}
1722
1723
<ImageLightbox />