atagen

malice

public
Star0
Watch0
705b1e1062c28d089cbd8a8c402d6fbf7b312a29
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/App.svelteSvelte1756 lines63.9 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 { lastSpaceRooms, 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, link_previews: [] }
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) {
421
        spaceChildIds = res.room_ids ?? [];
422
        const last = $lastSpaceRooms[spaceId];
423
        if (last && spaceChildIds.includes(last) && $rooms.some((r) => r.room_id === last && !r.space)) {
424
          void selectRoom(last);
425
        }
426
      }
427
    } catch (e) {
428
      if (selectedSpaceId === spaceId) {
429
        spaceChildrenError = e instanceof Error ? e.message : 'Could not load rooms in this space';
430
        spaceChildIds = [];
431
      }
432
    } finally {
433
      if (selectedSpaceId === spaceId) spaceChildrenLoading = false;
434
    }
435
  }
436
437
  async function selectRoom(roomId: string): Promise<void> {
438
    if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;
439
    if (roomId === $selectedRoomId && openReady[roomId]) {
440
      sidebarOpen = false;
441
      return;
442
    }
443
    stopTyping();
444
    const spaceId = selectedSpaceId;
445
    if (spaceId) lastSpaceRooms.update((all) => ({ ...all, [spaceId]: roomId }));
446
    selectedRoomId.set(roomId);
447
    composer = drafts[roomId] ?? '';
448
    replyingTo = null;
449
    editing = null;
450
    mentionQuery = null;
451
    stickToBottom = true;
452
    lastReadKey = '';
453
    sidebarOpen = false;
454
    touchWarmRoom(roomId);
455
    if (openReady[roomId]) {
456
      if (membersOpen) void loadMembers(roomId);
457
      await tick();
458
      scrollToBottom();
459
      queueReadReceipt();
460
      composerEl?.focus();
461
      return;
462
    }
463
    if (openFailed[roomId]) {
464
      const nextFailed = { ...openFailed };
465
      delete nextFailed[roomId];
466
      openFailed = nextFailed;
467
    }
468
    try {
469
      await openRoom(roomId);
470
      if (warmRooms.includes(roomId)) openReady = { ...openReady, [roomId]: true };
471
    } catch (e) {
472
      const message = e instanceof Error ? e.message : `Could not open room ${roomId}`;
473
      openFailed = { ...openFailed, [roomId]: message };
474
      pushBanner(message);
475
    }
476
    if (membersOpen) void loadMembers(roomId);
477
    await tick();
478
    scrollToBottom();
479
    queueReadReceipt();
480
    composerEl?.focus();
481
  }
482
483
  function touchWarmRoom(roomId: string): void {
484
    warmRooms = [...warmRooms.filter((id) => id !== roomId), roomId];
485
    while (warmRooms.length > WARM_ROOM_LIMIT) {
486
      const evicted = warmRooms[0];
487
      warmRooms = warmRooms.slice(1);
488
      forgetOpenRoom(evicted);
489
      closeRoom(evicted).catch(() => undefined);
490
    }
491
  }
492
493
  function forgetOpenRoom(roomId: string): void {
494
    warmRooms = warmRooms.filter((id) => id !== roomId);
495
    const nextReady = { ...openReady };
496
    delete nextReady[roomId];
497
    openReady = nextReady;
498
  }
499
500
  function scrollToBottom(): void {
501
    if (timelineEl) timelineEl.scrollTop = timelineEl.scrollHeight;
502
  }
503
504
  let userScrollUntil = 0;
505
  let draggingScrollbar = false;
506
507
  function noteUserScroll(): void {
508
    userScrollUntil = performance.now() + 300;
509
  }
510
511
  function onTimelineKey(e: KeyboardEvent): void {
512
    if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(e.key)) noteUserScroll();
513
  }
514
515
  function onTimelinePointer(e: PointerEvent): void {
516
    if (e.target === timelineEl) draggingScrollbar = true;
517
  }
518
519
  function onTimelineScroll(): void {
520
    if (!timelineEl) return;
521
    const gap = timelineEl.scrollHeight - timelineEl.scrollTop - timelineEl.clientHeight;
522
    if (gap < 80) stickToBottom = true;
523
    else if (draggingScrollbar || performance.now() < userScrollUntil) stickToBottom = false;
524
  }
525
526
  function pinTimeline(node: HTMLElement) {
527
    const observer = new ResizeObserver(() => {
528
      if (stickToBottom) scrollToBottom();
529
    });
530
    observer.observe(node);
531
    if (node.parentElement) observer.observe(node.parentElement);
532
    return { destroy: () => observer.disconnect() };
533
  }
534
535
  $: void trackTimeline($selectedRoomId, items);
536
  async function trackTimeline(roomId: string | null, list: TimelineItem[]): Promise<void> {
537
    if (!roomId || list.length === 0) return;
538
    if (roomId !== $selectedRoomId) return;
539
    await tick();
540
    if (stickToBottom) scrollToBottom();
541
    queueReadReceipt();
542
  }
543
544
  function queueReadReceipt(): void {
545
    if (readTimer !== null) window.clearTimeout(readTimer);
546
    readTimer = window.setTimeout(() => {
547
      readTimer = null;
548
      void sendReadReceipt();
549
    }, 800);
550
  }
551
552
  async function sendReadReceipt(): Promise<void> {
553
    const roomId = $selectedRoomId;
554
    if (!roomId) return;
555
    const list = $timelines[roomId] ?? [];
556
    for (let i = list.length - 1; i >= 0; i -= 1) {
557
      const entry = list[i];
558
      if (entry.kind === 'event' && entry.event.event_id) {
559
        const key = `${roomId}:${entry.event.event_id}`;
560
        if (key === lastReadKey) return;
561
        lastReadKey = key;
562
        try {
563
          await markRead(roomId, entry.event.event_id);
564
        } catch {
565
          return;
566
        }
567
        return;
568
      }
569
    }
570
  }
571
572
  function send(): void {
573
    if (editing) {
574
      submitEdit();
575
      return;
576
    }
577
    const roomId = $selectedRoomId;
578
    const body = composer.trim();
579
    const attachments = roomId ? (staged[roomId] ?? []) : [];
580
    if (!roomId || (!body && attachments.length === 0)) return;
581
    if (!openReady[roomId]) {
582
      pushBanner('Still opening this room — sending is disabled until the room is ready.');
583
      return;
584
    }
585
    if (attachments.length > 0 && uploadBusy) return;
586
    composer = '';
587
    if (roomId in drafts) delete drafts[roomId];
588
    stickToBottom = true;
589
    const reply = replyingTo;
590
    replyingTo = null;
591
    const mentions = resolveMentions(body, pickedMentions[roomId] ?? []);
592
    pickedMentions = { ...pickedMentions, [roomId]: [] };
593
    mentionQuery = null;
594
    if (attachments.length === 0) {
595
      queueSend(roomId, body, reply, mentions);
596
    } else {
597
      const { [roomId]: _sent, ...rest } = staged;
598
      staged = rest;
599
      void sendAttachments(roomId, body, reply, mentions, attachments);
600
    }
601
    composerEl?.focus();
602
  }
603
604
  async function sendAttachments(
605
    roomId: string,
606
    body: string,
607
    reply: ReplyReference | null,
608
    mentions: Mention[],
609
    attachments: StagedAttachment[]
610
  ): Promise<void> {
611
    let first = true;
612
    uploadBusy = true;
613
    try {
614
      for (const attachment of attachments) {
615
        uploadName = attachment.file.name || 'attachment';
616
        try {
617
          if (first) await uploadAttachment(roomId, attachment.file, body || null, reply?.event_id ?? null, mentions);
618
          else await uploadAttachment(roomId, attachment.file);
619
          first = false;
620
        } catch (e) {
621
          pushBanner(e instanceof Error ? e.message : 'Upload failed');
622
        }
623
        if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
624
      }
625
    } finally {
626
      uploadBusy = false;
627
      uploadName = null;
628
    }
629
  }
630
631
  function stageFiles(files: File[]): void {
632
    const roomId = $selectedRoomId;
633
    if (!roomId || !openReady[roomId]) return;
634
    const accepted: StagedAttachment[] = [];
635
    for (const file of files) {
636
      if (file.size === 0 || file.size > 20 * 1024 * 1024) {
637
        pushBanner(`${file.name || 'File'} must be between 1 byte and 20 MiB.`);
638
        continue;
639
      }
640
      const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
641
      accepted.push({ id: nextStagedId++, file, previewUrl });
642
    }
643
    if (accepted.length === 0) return;
644
    staged = { ...staged, [roomId]: [...(staged[roomId] ?? []), ...accepted] };
645
    composerEl?.focus();
646
  }
647
648
  function unstage(roomId: string, id: number): void {
649
    const list = staged[roomId] ?? [];
650
    const removed = list.find((a) => a.id === id);
651
    if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
652
    staged = { ...staged, [roomId]: list.filter((a) => a.id !== id) };
653
  }
654
655
  function clearAllStaged(): void {
656
    for (const list of Object.values(staged)) {
657
      for (const attachment of list) if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
658
    }
659
    staged = {};
660
  }
661
662
  function startEdit(eventId: string, body: string): void {
663
    const roomId = $selectedRoomId;
664
    if (!roomId) return;
665
    const stash = editing ? editing.stash : composer;
666
    editing = { roomId, eventId, original: body, stash };
667
    const entry = items.find((i) => i.kind === 'event' && i.event.event_id === eventId);
668
    if (entry?.kind === 'event' && entry.event.content.kind === 'Message') {
669
      const existing = mentionsFromHtml(entry.event.content.message.formatted_body);
670
      pickedMentions = { ...pickedMentions, [roomId]: [...(pickedMentions[roomId] ?? []), ...existing] };
671
    }
672
    composer = body;
673
    replyingTo = null;
674
    composerEl?.focus();
675
  }
676
677
  function cancelEdit(): void {
678
    if (!editing) return;
679
    composer = editing.stash;
680
    editing = null;
681
    composerEl?.focus();
682
  }
683
684
  function submitEdit(): void {
685
    if (!editing) return;
686
    const { roomId, eventId, original } = editing;
687
    const body = composer.trim();
688
    cancelEdit();
689
    if (!body || body === original.trim()) return;
690
    editMessage(roomId, eventId, body, resolveMentions(body, pickedMentions[roomId] ?? [])).catch((e) => pushBanner(e instanceof Error ? e.message : 'Edit failed'));
691
  }
692
693
  function editLastOwn(): boolean {
694
    for (let i = items.length - 1; i >= 0; i -= 1) {
695
      const entry = items[i];
696
      if (entry.kind !== 'event' || !entry.event.own) continue;
697
      const ev = entry.event;
698
      if (!ev.editable || !ev.event_id || ev.content.kind !== 'Message' || ev.content.message.kind !== 'Text') return false;
699
      startEdit(ev.event_id, ev.content.message.body);
700
      return true;
701
    }
702
    return false;
703
  }
704
705
  function startReply(target: ReplyReference): void {
706
    if (editing) cancelEdit();
707
    replyingTo = target;
708
    composerEl?.focus();
709
  }
710
711
  function jumpTo(eventId: string): void {
712
    const el = timelineEl?.querySelector<HTMLElement>(`[data-event="${CSS.escape(eventId)}"]`);
713
    if (!el) {
714
      pushBanner('The original message is not loaded. Scroll up to load older messages.');
715
      return;
716
    }
717
    stickToBottom = false;
718
    el.scrollIntoView({ block: 'center', behavior: 'smooth' });
719
    el.classList.remove('flash');
720
    void el.offsetWidth;
721
    el.classList.add('flash');
722
  }
723
724
  let typedText = '';
725
  let typedRoom: string | null = null;
726
  $: trackTypingInput(composer, $selectedRoomId, openReady);
727
  function trackTypingInput(text: string, roomId: string | null, ready: Record<string, boolean>): void {
728
    const edited = roomId === typedRoom && text !== typedText;
729
    typedText = text;
730
    typedRoom = roomId;
731
    if (!roomId || !ready[roomId] || !text.trim()) {
732
      stopTyping();
733
    } else if (edited) {
734
      notifyTyping(roomId);
735
    }
736
  }
737
738
  $: void trackPending($selectedRoomId, pendingItems);
739
  async function trackPending(roomId: string | null, list: PendingMessage[]): Promise<void> {
740
    if (!roomId || list.length === 0) return;
741
    if (roomId !== $selectedRoomId) return;
742
    await tick();
743
    if (stickToBottom) scrollToBottom();
744
  }
745
746
  function updateMentionQuery(): void {
747
    const roomId = $selectedRoomId;
748
    const next = composerEl && roomId ? mentionQueryAt(composerEl.value, composerEl.selectionStart ?? composerEl.value.length) : null;
749
    if (next && !mentionQuery && roomId && !$membersByRoom[roomId]) void loadMembers(roomId);
750
    if (next?.start !== mentionQuery?.start) mentionIndex = 0;
751
    mentionQuery = next;
752
  }
753
754
  async function pickMention(person: Person): Promise<void> {
755
    const roomId = $selectedRoomId;
756
    if (!mentionQuery || !roomId || !composerEl) return;
757
    const caret = composerEl.selectionStart ?? composer.length;
758
    const inserted = `${person.name} `;
759
    composer = composer.slice(0, mentionQuery.start) + inserted + composer.slice(caret);
760
    const at = mentionQuery.start + inserted.length;
761
    pickedMentions = {
762
      ...pickedMentions,
763
      [roomId]: [...(pickedMentions[roomId] ?? []), { user_id: person.user_id, text: person.name }]
764
    };
765
    mentionQuery = null;
766
    await tick();
767
    composerEl.focus();
768
    composerEl.setSelectionRange(at, at);
769
  }
770
771
  function onComposerKey(ev: KeyboardEvent): void {
772
    if (mentionQuery && mentionCandidates.length > 0) {
773
      if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
774
        ev.preventDefault();
775
        const step = ev.key === 'ArrowDown' ? 1 : -1;
776
        mentionIndex = (mentionIndex + step + mentionCandidates.length) % mentionCandidates.length;
777
        return;
778
      }
779
      if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Tab') {
780
        ev.preventDefault();
781
        void pickMention(mentionCandidates[mentionIndex]);
782
        return;
783
      }
784
      if (ev.key === 'Escape') {
785
        ev.stopPropagation();
786
        mentionQuery = null;
787
        return;
788
      }
789
    }
790
    if (ev.key === 'Enter' && !ev.shiftKey) {
791
      ev.preventDefault();
792
      send();
793
    } else if (ev.key === 'Escape' && editing) {
794
      ev.stopPropagation();
795
      cancelEdit();
796
    } else if (ev.key === 'Escape' && replyingTo) {
797
      ev.stopPropagation();
798
      replyingTo = null;
799
    } else if (ev.key === 'ArrowUp' && !composer && !editing && editLastOwn()) {
800
      ev.preventDefault();
801
    }
802
  }
803
804
  function pickFiles(): void {
805
    const roomId = $selectedRoomId;
806
    if (!roomId || !openReady[roomId]) return;
807
    fileInput?.click();
808
  }
809
810
  function onFileChosen(): void {
811
    stageFiles(Array.from(fileInput?.files ?? []));
812
    if (fileInput) fileInput.value = '';
813
  }
814
815
  function onComposerPaste(ev: ClipboardEvent): void {
816
    const data = ev.clipboardData;
817
    // Office apps put a rendered image next to the copied text, so only take the image when there is no plain text.
818
    if (!data || data.types.includes('text/plain')) return;
819
    const images = Array.from(data.files).filter((f) => f.type.startsWith('image/'));
820
    if (images.length === 0) return;
821
    ev.preventDefault();
822
    stageFiles(images);
823
  }
824
825
  async function react(eventId: string, key: string, shortcode?: string): Promise<void> {
826
    const roomId = $selectedRoomId;
827
    if (!roomId || !eventId || reacting) return;
828
    reacting = true;
829
    try {
830
      await reactToMessage(roomId, eventId, key, shortcode);
831
    } catch (e) {
832
      pushBanner(e instanceof Error ? e.message : 'Reaction failed');
833
    } finally {
834
      reacting = false;
835
    }
836
  }
837
838
  function previewLoader(roomId: string): (url: string) => void {
839
    return (url) => requestLinkPreview(roomId, url);
840
  }
841
842
  async function changeEncryptedPreviews(enabled: boolean): Promise<void> {
843
    try {
844
      await saveUrlPreviewSettings({ encrypted_rooms: enabled });
845
    } catch (e) {
846
      pushBanner(e instanceof Error ? e.message : 'Could not save link preview setting');
847
    }
848
  }
849
850
  async function changeQuickReactions(list: QuickReaction[]): Promise<void> {
851
    try {
852
      await saveQuickReactions(list);
853
    } catch (e) {
854
      pushBanner(e instanceof Error ? e.message : 'Could not save quick reactions');
855
    }
856
  }
857
858
  async function paginate(): Promise<void> {
859
    const roomId = $selectedRoomId;
860
    if (!roomId || paginateBusy) return;
861
    paginateBusy = true;
862
    try {
863
      await paginateRoom(roomId);
864
    } catch (e) {
865
      pushBanner(e instanceof Error ? e.message : 'Loading older messages failed');
866
    } finally {
867
      paginateBusy = false;
868
    }
869
  }
870
871
  async function loadMembers(roomId: string): Promise<void> {
872
    if (membersFor === roomId && $membersLoading) return;
873
    membersFor = roomId;
874
    membersLoading.set(true);
875
    try {
876
      const res = await getMembers(roomId);
877
      membersByRoom.update((all) => ({ ...all, [roomId]: res.members }));
878
      avatarByUser = { ...avatarByUser, ...avatarMap(res.members) };
879
    } catch (e) {
880
      pushBanner(e instanceof Error ? e.message : 'Could not load members');
881
    } finally {
882
      if (membersFor === roomId) membersLoading.set(false);
883
    }
884
  }
885
886
  function avatarMap(list: MemberSummary[]): Record<string, string | null> {
887
    const next: Record<string, string | null> = {};
888
    for (const member of list) next[member.user_id] = member.avatar;
889
    return next;
890
  }
891
892
  function peopleIn(list: TimelineItem[], roster: MemberSummary[]): Record<string, Person> {
893
    const out: Record<string, Person> = {};
894
    for (const item of list) {
895
      if (item.kind !== 'event') continue;
896
      const s = item.event.sender;
897
      out[s.user_id] = { user_id: s.user_id, name: senderLabel(s), avatar: s.avatar };
898
    }
899
    for (const m of roster) out[m.user_id] = { user_id: m.user_id, name: m.name, avatar: m.avatar };
900
    return out;
901
  }
902
903
  function senderAvatarFor(userId: string): string | null {
904
    return avatarByUser[userId] ?? null;
905
  }
906
907
  function toggleMembers(): void {
908
    membersOpen = !membersOpen;
909
    if (membersOpen && $selectedRoomId) void loadMembers($selectedRoomId);
910
  }
911
912
  async function join(target: string): Promise<void> {
913
    const room = target.trim();
914
    if (!room || joinBusy) return;
915
    joinBusy = true;
916
    try {
917
      const res = await joinRoom(room);
918
      joinTarget = '';
919
      dialog = null;
920
      await refreshRooms();
921
      await selectRoom(res.room_id);
922
    } catch (e) {
923
      pushBanner(e instanceof Error ? e.message : 'Join failed');
924
    } finally {
925
      joinBusy = false;
926
    }
927
  }
928
929
  async function create(): Promise<void> {
930
    const name = createName.trim();
931
    if (!name || createBusy) return;
932
    createBusy = true;
933
    try {
934
      const res = await createRoom(name, createTopic.trim() || undefined);
935
      createName = '';
936
      createTopic = '';
937
      dialog = null;
938
      await refreshRooms();
939
      await selectRoom(res.room_id);
940
    } catch (e) {
941
      pushBanner(e instanceof Error ? e.message : 'Create room failed');
942
    } finally {
943
      createBusy = false;
944
    }
945
  }
946
947
  async function leave(): Promise<void> {
948
    const roomId = $selectedRoomId;
949
    if (!roomId || leaveBusy) return;
950
    leaveBusy = true;
951
    stopTyping();
952
    try {
953
      await leaveRoom(roomId);
954
      if ($selectedRoomId === roomId) selectedRoomId.set(null);
955
      if (warmRooms.includes(roomId)) {
956
        forgetOpenRoom(roomId);
957
        closeRoom(roomId).catch(() => undefined);
958
      }
959
      await refreshRooms();
960
    } catch (e) {
961
      pushBanner(e instanceof Error ? e.message : 'Leave failed');
962
    } finally {
963
      leaveBusy = false;
964
    }
965
  }
966
967
  function connectionClass(state: string): string {
968
    if (state === 'online') return 'dot-online';
969
    if (state === 'connecting' || state === 'reconnecting') return 'dot-busy';
970
    return 'dot-off';
971
  }
972
973
  function onKeydown(ev: KeyboardEvent): void {
974
    if (ev.key === 'Escape') {
975
      if (dialog) dialog = null;
976
      else if (membersOpen && window.innerWidth < 1100) membersOpen = false;
977
      else if (sidebarOpen) sidebarOpen = false;
978
    }
979
  }
980
</script>
981
982
<svelte:window on:keydown={onKeydown} on:pointerup={() => (draggingScrollbar = false)} />
983
984
{#if !booted}
985
  <div class="splash" role="status" aria-label="Loading Malice">
986
    <div class="splash-mark" aria-hidden="true">M</div>
987
    <p>Loading Malice…</p>
988
  </div>
989
{:else if $blocked}
990
  <div class="splash" role="status" aria-label="Malice is open in another tab">
991
    <div class="splash-mark" aria-hidden="true">M</div>
992
    <p>Malice is open in another tab. Close this tab or use that one instead.</p>
993
  </div>
994
{:else if $sessionUser == null}
995
  <LoginPanel softLogout={$softLogout} on:done={(ev) => afterLogin(ev.detail)} />
996
{:else}
997
  <div class="app" class:nav-folded={$navFolded && selectedRoom} style:--msg-gap={`${$messageSpacing}px`} style:--msg-line-height={$lineHeight}>
998
    <nav class="rail" aria-label="Servers and spaces">
999
      <button
1000
        type="button"
1001
        class="rail-btn"
1002
        class:active={selectedSpaceId === null}
1003
        title="Home — all channels"
1004
        aria-label="Home — all channels"
1005
        aria-pressed={selectedSpaceId === null}
1006
        on:click={() => void selectHome()}
1007
      >
1008
        <span aria-hidden="true">⌂</span>
1009
      </button>
1010
      <div class="rail-sep" aria-hidden="true"></div>
1011
      {#each railSpaces as space (space.room_id)}
1012
        <button
1013
          type="button"
1014
          class="rail-btn rail-space"
1015
          class:active={railSelected === space.room_id}
1016
          title={space.name}
1017
          aria-label={`Show channels in space ${space.name}`}
1018
          aria-pressed={selectedSpaceId === space.room_id}
1019
          on:click={() => void selectSpace(space.room_id)}
1020
        >
1021
          <MxcAvatar mxc={space.avatar} label={space.name} size={44} shape="rounded" />
1022
        </button>
1023
      {/each}
1024
      <button
1025
        type="button"
1026
        class="rail-btn rail-add"
1027
        aria-label="Join or create a room"
1028
        on:click={() => {
1029
          dialog = 'join';
1030
        }}
1031
      >
1032
        <span aria-hidden="true">+</span>
1033
      </button>
1034
    </nav>
1035
1036
    <aside class="sidebar" class:open={sidebarOpen} aria-label="Rooms">
1037
      <div class="side-search">
1038
        <label class="visually-hidden" for="room-search">Search rooms</label>
1039
        <input
1040
          id="room-search"
1041
          type="search"
1042
          placeholder="Search rooms"
1043
          bind:value={search}
1044
          autocomplete="off"
1045
        />
1046
        <button
1047
          type="button"
1048
          class="icon-btn"
1049
          title="Refresh rooms"
1050
          aria-label="Refresh rooms"
1051
          disabled={roomsLoading}
1052
          on:click={() => void refreshRooms()}
1053
        >
1054
          <span aria-hidden="true">⟳</span>
1055
        </button>
1056
      </div>
1057
      <div class="side-scroll">
1058
        {#if selectedSpace}
1059
          <div class="space-context">
1060
            <MxcAvatar mxc={selectedSpace.avatar} label={selectedSpace.name} size={28} shape="rounded" />
1061
            <div class="space-context-text">
1062
              <strong>{selectedSpace.name}</strong>
1063
            </div>
1064
            {#if spaceParents.has(selectedSpace.room_id)}
1065
              {@const parentId = spaceParents.get(selectedSpace.room_id) ?? ''}
1066
              <button
1067
                type="button"
1068
                class="icon-btn"
1069
                title="Up to the parent space"
1070
                aria-label="Up to the parent space"
1071
                on:click={() => void selectSpace(parentId)}
1072
              >
1073
                <span aria-hidden="true">↑</span>
1074
              </button>
1075
            {/if}
1076
          </div>
1077
        {:else}
1078
          <div class="space-context">
1079
            <div class="space-context-text">
1080
              <strong>Home</strong>
1081
            </div>
1082
          </div>
1083
        {/if}
1084
        {#if roomsLoading && $rooms.length === 0}
1085
          <p class="side-note">Loading rooms…</p>
1086
        {:else if $rooms.length === 0}
1087
          <p class="side-note">No rooms yet</p>
1088
          <div class="side-cta">
1089
            <button type="button" on:click={() => (dialog = 'join')}>Join a room</button>
1090
            <button type="button" on:click={() => (dialog = 'create')}>Create one</button>
1091
          </div>
1092
        {:else}
1093
          {#if selectedSpaceId && spaceChildrenLoading}
1094
            <p class="side-note">Loading channels…</p>
1095
          {:else if selectedSpaceId && spaceChildrenError}
1096
            <p class="side-note" role="alert">{spaceChildrenError}</p>
1097
            <div class="side-cta">
1098
              <button type="button" on:click={() => selectedSpaceId && void selectSpace(selectedSpaceId)}>Retry</button>
1099
              <button type="button" on:click={() => void selectHome()}>Back to Home</button>
1100
            </div>
1101
          {:else if selectedSpaceId && spaceChildIds && channelRooms.length === 0}
1102
            <p class="side-note">No channels</p>
1103
            <div class="side-cta">
1104
              <button type="button" on:click={() => void selectHome()}>Back to Home</button>
1105
            </div>
1106
          {:else}
1107
            {#if invites.length > 0 && !selectedSpaceId}
1108
              <h2 class="side-h">Invites</h2>
1109
              <ul class="room-list">
1110
                {#each invites as room (room.room_id)}
1111
                  <li>
1112
                    <button
1113
                      type="button"
1114
                      class="room-row"
1115
                      class:active={$selectedRoomId === room.room_id}
1116
                      on:click={() => void selectRoom(room.room_id)}
1117
                    >
1118
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
1119
                      <span class="room-name">{room.name}</span>
1120
                      <span
1121
                        class="invite-join"
1122
                        role="button"
1123
                        tabindex="0"
1124
                        aria-label={`Join ${room.name}`}
1125
                        on:click|stopPropagation={() => void join(room.room_id)}
1126
                        on:keydown|stopPropagation={(ev) => {
1127
                          if (ev.key === 'Enter' || ev.key === ' ') {
1128
                            ev.preventDefault();
1129
                            void join(room.room_id);
1130
                          }
1131
                        }}
1132
                      >
1133
                        Join
1134
                      </span>
1135
                    </button>
1136
                  </li>
1137
                {/each}
1138
              </ul>
1139
            {/if}
1140
1141
            {#if directRooms.length > 0}
1142
              <h2 class="side-h">Direct messages</h2>
1143
              <ul class="room-list">
1144
                {#each directRooms as room (room.room_id)}
1145
                  <li>
1146
                    <button
1147
                      type="button"
1148
                      class="room-row"
1149
                      class:active={$selectedRoomId === room.room_id}
1150
                      aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
1151
                      on:click={() => void selectRoom(room.room_id)}
1152
                    >
1153
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} />
1154
                      <span class="room-name">{room.name}</span>
1155
                      {#if roomUnread(room) > 0}
1156
                        <span
1157
                          class="badge"
1158
                          class:mention={room.unread_mentions > 0}
1159
                          aria-label={`${roomUnread(room)} unread`}
1160
                        >
1161
                          {roomUnread(room) > 99 ? '99+' : roomUnread(room)}
1162
                        </span>
1163
                      {/if}
1164
                    </button>
1165
                  </li>
1166
                {/each}
1167
              </ul>
1168
            {/if}
1169
1170
            {#if groupRooms.length > 0}
1171
              <h2 class="side-h">{selectedSpace ? 'Channels' : 'Rooms'}</h2>
1172
              <ul class="room-list">
1173
                {#each groupRooms as room (room.room_id)}
1174
                  <li>
1175
                    <button
1176
                      type="button"
1177
                      class="room-row"
1178
                      class:active={$selectedRoomId === room.room_id}
1179
                      aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
1180
                      on:click={() => void selectRoom(room.room_id)}
1181
                    >
1182
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
1183
                      <span class="room-hash" aria-hidden="true">#</span>
1184
                      <span class="room-name">{room.name}</span>
1185
                      {#if roomUnread(room) > 0}
1186
                        <span
1187
                          class="badge"
1188
                          class:mention={room.unread_mentions > 0}
1189
                          aria-label={`${roomUnread(room)} unread`}
1190
                        >
1191
                          {roomUnread(room) > 99 ? '99+' : roomUnread(room)}
1192
                        </span>
1193
                      {/if}
1194
                    </button>
1195
                  </li>
1196
                {/each}
1197
              </ul>
1198
            {/if}
1199
1200
            {#if spaceList.length > 0}
1201
              <h2 class="side-h">{selectedSpace ? 'Subspaces' : 'Spaces'}</h2>
1202
              <ul class="room-list">
1203
                {#each spaceList as room (room.room_id)}
1204
                  <li>
1205
                    <button
1206
                      type="button"
1207
                      class="room-row"
1208
                      class:active={selectedSpaceId === room.room_id}
1209
                      aria-label={`Show channels in space ${room.name}`}
1210
                      on:click={() => void selectSpace(room.room_id)}
1211
                    >
1212
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
1213
                      <span class="room-hash" aria-hidden="true">◇</span>
1214
                      <span class="room-name">{room.name}</span>
1215
                    </button>
1216
                  </li>
1217
                {/each}
1218
              </ul>
1219
            {/if}
1220
1221
            {#if query && visibleRooms.length === 0}
1222
              <p class="side-note">No rooms match “{search.trim()}”.</p>
1223
            {/if}
1224
          {/if}
1225
        {/if}
1226
      </div>
1227
1228
      <div class="side-footer">
1229
        <div class="me" title={$sessionUser}>
1230
          {#if profileMxc}
1231
            <MxcAvatar mxc={profileMxc} label={profile?.display_name ?? $sessionUser} size={30} />
1232
          {:else}
1233
            <span class="me-avatar" aria-hidden="true">{$sessionUser.slice(1, 2).toUpperCase()}</span>
1234
          {/if}
1235
          <span class="me-id">{profile?.display_name && profile.display_name !== $sessionUser ? `${profile.display_name} (${$sessionUser})` : $sessionUser}</span>
1236
        </div>
1237
        <button
1238
          type="button"
1239
          class="icon-btn"
1240
          title="Encryption and verification"
1241
          aria-label="Encryption and verification"
1242
          on:click={() => (dialog = 'security')}
1243
        >
1244
          <span aria-hidden="true">🛡</span>
1245
        </button>
1246
        <button
1247
          type="button"
1248
          class="icon-btn"
1249
          title="Settings"
1250
          aria-label="Settings"
1251
          on:click={() => (dialog = 'settings')}
1252
        >
1253
          <span aria-hidden="true">⚙</span>
1254
        </button>
1255
        <button type="button" class="icon-btn" title="Log out" aria-label="Log out" on:click={() => void doLogout()}>
1256
          <span aria-hidden="true">⏻</span>
1257
        </button>
1258
      </div>
1259
    </aside>
1260
    {#if sidebarOpen}
1261
      <button type="button" class="scrim" aria-label="Close room list" on:click={() => (sidebarOpen = false)}></button>
1262
    {/if}
1263
1264
    <main class="chat">
1265
        {#if $banners.length > 0}
1266
          <div class="banners" aria-live="polite">
1267
            {#each $banners as banner (banner.id)}
1268
              <div class="banner" role="alert">
1269
                <span>{banner.message}</span>
1270
                <button
1271
                  type="button"
1272
                  aria-label="Dismiss error"
1273
                  on:click={() => dismissBanner(banner.id)}
1274
                >
1275
                  ✕
1276
                </button>
1277
              </div>
1278
            {/each}
1279
          </div>
1280
        {/if}
1281
      {#if !selectedRoom}
1282
        <div class="empty">
1283
          <div class="empty-mark" aria-hidden="true">◈</div>
1284
          {#if selectedSpace}
1285
            <h1>{selectedSpace.name}</h1>
1286
          {:else}
1287
            <h1>Welcome to Malice</h1>
1288
          {/if}
1289
          <div class="empty-cta">
1290
            <button type="button" class="primary-btn" on:click={() => (dialog = 'join')}>Join a room</button>
1291
            <button type="button" class="ghost-btn" on:click={() => (dialog = 'create')}>Create a room</button>
1292
          </div>
1293
          <p class="conn-line">
1294
            <span class="dot {connectionClass($connection)}" aria-hidden="true"></span>
1295
            {$connection}
1296
          </p>
1297
        </div>
1298
      {:else}
1299
        <header class="chat-head">
1300
          <button
1301
            type="button"
1302
            class="icon-btn only-mobile"
1303
            aria-label="Open room list"
1304
            on:click={() => (sidebarOpen = true)}
1305
          >
1306
            <span aria-hidden="true">☰</span>
1307
          </button>
1308
          <button
1309
            type="button"
1310
            class="icon-btn only-desktop"
1311
            aria-pressed={!$navFolded}
1312
            aria-label={$navFolded ? 'Show spaces and rooms' : 'Hide spaces and rooms'}
1313
            title={$navFolded ? 'Show spaces and rooms' : 'Hide spaces and rooms'}
1314
            on:click={() => navFolded.update((v) => !v)}
1315
          >
1316
            <span aria-hidden="true">{$navFolded ? '»' : '«'}</span>
1317
          </button>
1318
          <div class="head-title">
1319
            <div class="head-room">
1320
              <MxcAvatar mxc={selectedRoom.avatar} label={selectedRoom.name} size={36} shape={selectedRoom.space ? 'rounded' : 'circle'} />
1321
              <div class="head-room-text">
1322
                <h1>{selectedRoom.name}</h1>
1323
                <div class="head-meta">
1324
                  {#if selectedRoom.space}
1325
                    <span class="pill">space</span>
1326
                  {/if}
1327
                  {#if selectedRoom.encrypted}
1328
                    <span class="pill" title="End-to-end encrypted">🔒 encrypted</span>
1329
                  {/if}
1330
                  {#if selectedRoom.direct}
1331
                    <span class="pill">direct</span>
1332
                  {/if}
1333
                  <span class="conn" title={`Sync state: ${$connection}`}>
1334
                    <span class="dot {connectionClass($connection)}" aria-hidden="true"></span>
1335
                    {$connection}
1336
                  </span>
1337
                </div>
1338
              </div>
1339
            </div>
1340
          </div>
1341
          <div class="head-actions">
1342
            <button
1343
              type="button"
1344
              class="ghost-btn"
1345
              aria-label="Encryption and verification"
1346
              title="Encryption and verification"
1347
              on:click={() => (dialog = 'security')}
1348
            >
1349
              🛡 Security
1350
            </button>
1351
            <button
1352
              type="button"
1353
              class="ghost-btn"
1354
              aria-pressed={membersOpen}
1355
              aria-label={membersOpen ? 'Hide members' : 'Show members'}
1356
              on:click={toggleMembers}
1357
            >
1358
              Members
1359
            </button>
1360
            {#if selectedRoom.membership === 'Joined'}
1361
              <button
1362
                type="button"
1363
                class="ghost-btn danger"
1364
                disabled={leaveBusy}
1365
                on:click={() => void leave()}
1366
              >
1367
                {leaveBusy ? 'Leaving…' : 'Leave'}
1368
              </button>
1369
            {:else}
1370
              <button
1371
                type="button"
1372
                class="primary-btn"
1373
                disabled={joinBusy}
1374
                on:click={() => void join(selectedRoom.room_id)}
1375
              >
1376
                {joinBusy ? 'Joining…' : 'Join'}
1377
              </button>
1378
            {/if}
1379
            <button
1380
              type="button"
1381
              class="icon-btn only-mobile"
1382
              aria-label="Toggle members"
1383
              on:click={toggleMembers}
1384
            >
1385
              <span aria-hidden="true">ⓘ</span>
1386
            </button>
1387
          </div>
1388
        </header>
1389
1390
1391
        {#if recoveryBanner}
1392
          <div class="sec-banner warn" role="status">
1393
            <span>Message history is locked</span>
1394
            <button type="button" on:click={() => (dialog = 'security')}>Recover</button>
1395
          </div>
1396
        {/if}
1397
        {#if verificationBanner}
1398
          <div class="sec-banner" role="status">
1399
            {#if verificationBanner.kind === 'Incoming'}
1400
              <span>A device wants to verify this session</span>
1401
              <button type="button" on:click={() => (dialog = 'security')}>Review request</button>
1402
            {:else if verificationBanner.kind === 'Comparison'}
1403
              <span>Verification codes ready</span>
1404
              <button type="button" on:click={() => (dialog = 'security')}>Compare codes</button>
1405
            {:else if verificationBanner.kind === 'Waiting'}
1406
              <span>Verification started</span>
1407
              <button type="button" on:click={() => (dialog = 'security')}>Open verification</button>
1408
            {:else if verificationBanner.kind === 'Failed'}
1409
              <span>Verification failed: {verificationBanner.message}</span>
1410
              <button type="button" on:click={() => (dialog = 'security')}>Try again</button>
1411
            {:else if verificationBanner.kind === 'Cancelled'}
1412
              <span>Verification cancelled: {verificationBanner.reason}</span>
1413
              <button type="button" on:click={() => (dialog = 'security')}>Open security</button>
1414
            {:else}
1415
              <span>Verification in progress</span>
1416
              <button type="button" on:click={() => (dialog = 'security')}>Open security</button>
1417
            {/if}
1418
          </div>
1419
        {/if}
1420
        {#if $selectedRoomId && openFailed[$selectedRoomId]}
1421
          <p class="conn-banner error" role="alert">{openFailed[$selectedRoomId]}</p>
1422
        {/if}
1423
1424
        <!-- svelte-ignore a11y_no_static_element_interactions -->
1425
        <div
1426
          class="timeline"
1427
          bind:this={timelineEl}
1428
          on:scroll={onTimelineScroll}
1429
          on:wheel={noteUserScroll}
1430
          on:touchmove={noteUserScroll}
1431
          on:keydown={onTimelineKey}
1432
          on:pointerdown={onTimelinePointer}
1433
          aria-label={`Messages in ${selectedRoom.name}`}
1434
        >
1435
          <div class="timeline-inner" use:pinTimeline>
1436
            <div class="timeline-top">
1437
              {#if hasStart}
1438
                <p class="start-note">Beginning of history</p>
1439
              {:else}
1440
                <button type="button" class="ghost-btn" disabled={paginateBusy} on:click={() => void paginate()}>
1441
                  {paginateBusy ? 'Loading…' : 'Load older messages'}
1442
                </button>
1443
              {/if}
1444
            </div>
1445
            {#if grouped.length === 0}
1446
              <p class="side-note timeline-empty">No messages yet</p>
1447
            {:else}
1448
              {#each grouped as g (g.key)}
1449
                {#if g.item.kind === 'day'}
1450
                  <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div>
1451
                {:else if g.item.kind === 'readMarker'}
1452
                  <div class="new-row"><span>New</span></div>
1453
                {:else if g.item.kind === 'start'}
1454
                  <p class="start-note">Beginning of history</p>
1455
                {:else if g.item.kind === 'unknown'}
1456
                  <p class="unknown-note">Unsupported event ({g.item.label})</p>
1457
                {:else}
1458
                  <MessageRow
1459
                    itemId={g.key}
1460
                    event={g.item.event}
1461
                    showSender={g.showSender}
1462
                    senderAvatar={senderAvatarFor(g.item.event.sender.user_id)}
1463
                    {reacting}
1464
                    onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
1465
                    quickReactions={$quickReactions}
1466
                    packs={roomPacks}
1467
                    linkPreviews={$linkPreviews}
1468
                    {loadPreview}
1469
                    onQuickChange={(list) => void changeQuickReactions(list)}
1470
                    {people}
1471
                    self={$sessionUser ?? null}
1472
                    onReply={selectedRoom.membership === 'Joined' ? startReply : null}
1473
                    onJump={jumpTo}
1474
                    onEdit={selectedRoom.membership === 'Joined' ? startEdit : null}
1475
                    editing={editing !== null && editing.eventId === g.item.event.event_id}
1476
                    showSent={g.item.event.item_id === lastSentId && pendingItems.length === 0}
1477
                  />
1478
                {/if}
1479
              {/each}
1480
            {/if}
1481
            {#each pendingItems as p, i (p.id)}
1482
              <MessageRow
1483
                itemId={`pending-${p.id}`}
1484
                event={pendingToEvent(p)}
1485
                onJump={jumpTo}
1486
                showSender={pendingShowSender(i)}
1487
                senderAvatar={senderAvatarFor($sessionUser ?? '')}
1488
                {reacting}
1489
                onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
1490
                quickReactions={$quickReactions}
1491
                packs={roomPacks}
1492
                linkPreviews={$linkPreviews}
1493
                {loadPreview}
1494
                onQuickChange={(list) => void changeQuickReactions(list)}
1495
              />
1496
              {#if p.status === 'failed'}
1497
                <div class="pending-actions">
1498
                  <span class="pending-error" role="alert">{p.error}</span>
1499
                  <button type="button" class="ghost-btn" on:click={() => retryPending(p.id)}>Retry</button>
1500
                  <button type="button" class="ghost-btn danger" on:click={() => discardPending(p.id)}>Discard</button>
1501
                </div>
1502
              {/if}
1503
            {/each}
1504
          </div>
1505
        </div>
1506
1507
        {#if selectedRoom.membership === 'Joined'}
1508
          {#if editing}
1509
            <div class="reply-bar" role="status">
1510
              <span class="reply-bar-label">
1511
                Editing message
1512
                <span class="reply-bar-text">{editing.original}</span>
1513
              </span>
1514
              <button type="button" class="icon-btn" aria-label="Cancel edit" title="Cancel edit (Esc)" on:click={cancelEdit}>
1515
                <span aria-hidden="true">✕</span>
1516
              </button>
1517
            </div>
1518
          {:else if replyingTo && replyingTo.details.kind === 'Ready'}
1519
            <div class="reply-bar" role="status">
1520
              <span class="reply-bar-label">
1521
                Replying to <strong>{senderLabel(replyingTo.details.sender)}</strong>
1522
                <span class="reply-bar-text">{contentPreview(replyingTo.details.content)}</span>
1523
              </span>
1524
              <button type="button" class="icon-btn" aria-label="Cancel reply" title="Cancel reply (Esc)" on:click={() => (replyingTo = null)}>
1525
                <span aria-hidden="true">✕</span>
1526
              </button>
1527
            </div>
1528
          {/if}
1529
          {#if $selectedRoomId && currentStaged.length > 0}
1530
            {@const stagedRoom = $selectedRoomId}
1531
            <ul class="staged" aria-label="Attachments to send">
1532
              {#each currentStaged as attachment (attachment.id)}
1533
                <li class="staged-item">
1534
                  {#if attachment.previewUrl}
1535
                    <img src={attachment.previewUrl} alt={attachment.file.name || 'Pasted image'} />
1536
                  {:else}
1537
                    <span class="staged-name">{attachment.file.name || 'attachment'}</span>
1538
                  {/if}
1539
                  <button
1540
                    type="button"
1541
                    class="icon-btn staged-remove"
1542
                    aria-label={`Remove ${attachment.file.name || 'attachment'}`}
1543
                    title="Remove"
1544
                    on:click={() => unstage(stagedRoom, attachment.id)}
1545
                  >
1546
                    <span aria-hidden="true">✕</span>
1547
                  </button>
1548
                </li>
1549
              {/each}
1550
            </ul>
1551
          {/if}
1552
          <form class="composer" class:replying={replyingTo || editing} class:staging={currentStaged.length > 0} on:submit|preventDefault={() => send()}>
1553
            <label class="visually-hidden" for="composer">Message {selectedRoom.name}</label>
1554
            {#if mentionQuery && mentionCandidates.length > 0}
1555
              <ul class="mention-finder" role="listbox" aria-label="Mention someone">
1556
                {#each mentionCandidates as person, i (person.user_id)}
1557
                  <li
1558
                    role="option"
1559
                    aria-selected={i === mentionIndex}
1560
                    class:active={i === mentionIndex}
1561
                    on:pointerdown|preventDefault={() => void pickMention(person)}
1562
                    on:pointerenter={() => (mentionIndex = i)}
1563
                  >
1564
                    <MxcAvatar mxc={person.avatar} label={person.name} size={22} />
1565
                    <span class="mention-name">{person.name}</span>
1566
                    <span class="mention-id">{person.user_id}</span>
1567
                  </li>
1568
                {/each}
1569
              </ul>
1570
            {/if}
1571
            <input
1572
              type="file"
1573
              multiple
1574
              bind:this={fileInput}
1575
              class="visually-hidden"
1576
              aria-label="Attach a file"
1577
              on:change={() => void onFileChosen()}
1578
            />
1579
            <button
1580
              type="button"
1581
              class="ghost-btn"
1582
              title="Attach an image or file"
1583
              aria-label="Attach an image or file"
1584
              disabled={!($selectedRoomId && openReady[$selectedRoomId])}
1585
              on:click={pickFiles}
1586
            >
1587
              {uploadBusy ? `Uploading ${uploadName ?? 'file'}…` : '📎'}
1588
            </button>
1589
            <textarea
1590
              id="composer"
1591
              bind:this={composerEl}
1592
              bind:value={composer}
1593
              rows="1"
1594
              placeholder={`Message ${selectedRoom.name}`}
1595
              aria-label={`Message ${selectedRoom.name}. Enter to send, Shift Enter for a new line.`}
1596
              on:keydown={onComposerKey}
1597
              on:input={updateMentionQuery}
1598
              on:click={updateMentionQuery}
1599
              on:keyup={(e) => (e.key.startsWith('Arrow') && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') || e.key === 'Home' || e.key === 'End' ? updateMentionQuery() : undefined}
1600
              on:blur={() => (mentionQuery = null)}
1601
              on:paste={onComposerPaste}
1602
              disabled={!($selectedRoomId && openReady[$selectedRoomId])}
1603
            ></textarea>
1604
            <button
1605
              type="submit"
1606
              class="primary-btn"
1607
              disabled={(!composer.trim() && currentStaged.length === 0) ||
1608
                !($selectedRoomId && openReady[$selectedRoomId]) ||
1609
                (currentStaged.length > 0 && uploadBusy)}
1610
              title={!($selectedRoomId && openReady[$selectedRoomId]) ? 'Waiting for the room to open' : 'Send message'}
1611
            >
1612
              Send
1613
            </button>
1614
          </form>
1615
        {:else}
1616
          <div class="join-bar">
1617
            <p>You are not a member of this room.</p>
1618
            <button type="button" class="primary-btn" disabled={joinBusy} on:click={() => void join(selectedRoom.room_id)}>
1619
              {joinBusy ? 'Joining…' : `Join ${selectedRoom.name}`}
1620
            </button>
1621
          </div>
1622
        {/if}
1623
      {/if}
1624
    </main>
1625
1626
    {#if selectedRoom && membersOpen}
1627
      <aside class="members" aria-label={`Members of ${selectedRoom.name}`}>
1628
        <div class="members-head">
1629
          <h2>Members</h2>
1630
          <button type="button" class="icon-btn" aria-label="Close members" on:click={() => (membersOpen = false)}>
1631
            <span aria-hidden="true">✕</span>
1632
          </button>
1633
        </div>
1634
        {#if $membersLoading && members.length === 0}
1635
          <p class="side-note">Loading members…</p>
1636
        {:else if members.length === 0}
1637
          <p class="side-note">No members loaded.</p>
1638
          {#if $selectedRoomId}
1639
            <button type="button" class="ghost-btn" on:click={() => $selectedRoomId && void loadMembers($selectedRoomId)}>
1640
              Retry
1641
            </button>
1642
          {/if}
1643
        {:else}
1644
          <ul class="member-list">
1645
            {#each members as member (member.user_id)}
1646
              <li class="member">
1647
                <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} size={30} />
1648
                <span class="member-names">
1649
                  <span class="member-name">{member.display_name || member.name}</span>
1650
                  <span class="member-id">{member.user_id}</span>
1651
                </span>
1652
              </li>
1653
            {/each}
1654
          </ul>
1655
        {/if}
1656
      </aside>
1657
    {/if}
1658
1659
    {#if dialog}
1660
      <div class="modal-wrap" role="presentation" on:click|self={() => (dialog = null)}>
1661
        {#if dialog === 'security'}
1662
          <SecurityPanel
1663
            encryptionState={$encryption}
1664
            verificationState={$verification}
1665
            onAction={() => void loadEncryption()}
1666
            encryptedPreviews={$urlPreviewSettings.encrypted_rooms}
1667
            onEncryptedPreviews={(enabled) => void changeEncryptedPreviews(enabled)}
1668
            on:close={() => (dialog = null)}
1669
          />
1670
        {:else if dialog === 'settings'}
1671
          <SettingsPanel
1672
            quick={$quickReactions}
1673
            packs={roomPacks}
1674
            onQuickChange={(list) => void changeQuickReactions(list)}
1675
            on:close={() => (dialog = null)}
1676
          />
1677
        {:else}
1678
          <div class="modal" role="dialog" aria-modal="true" aria-label={dialog === 'join' ? 'Join a room' : 'Create a room'}>
1679
            <div class="modal-tabs" role="tablist" aria-label="Room actions">
1680
              <button
1681
                type="button"
1682
                role="tab"
1683
                aria-selected={dialog === 'join'}
1684
                class:active={dialog === 'join'}
1685
                on:click={() => (dialog = 'join')}
1686
              >
1687
                Join
1688
              </button>
1689
              <button
1690
                type="button"
1691
                role="tab"
1692
                aria-selected={dialog === 'create'}
1693
                class:active={dialog === 'create'}
1694
                on:click={() => (dialog = 'create')}
1695
              >
1696
                Create
1697
              </button>
1698
              <button
1699
                type="button"
1700
                class="icon-btn modal-x"
1701
                aria-label="Close dialog"
1702
                on:click={() => (dialog = null)}
1703
              >
1704
                <span aria-hidden="true">✕</span>
1705
              </button>
1706
            </div>
1707
            {#if dialog === 'join'}
1708
              <form on:submit|preventDefault={() => void join(joinTarget)}>
1709
                <label class="field">
1710
                  <span>Room ID or alias</span>
1711
                  <input
1712
                    type="text"
1713
                    placeholder="!room:example.org or #alias:example.org"
1714
                    bind:value={joinTarget}
1715
                    required
1716
                    disabled={joinBusy}
1717
                  />
1718
                </label>
1719
                <button type="submit" class="primary-btn wide" disabled={joinBusy || !joinTarget.trim()}>
1720
                  {joinBusy ? 'Joining…' : 'Join room'}
1721
                </button>
1722
              </form>
1723
            {:else}
1724
            <form on:submit|preventDefault={() => void create()}>
1725
              <label class="field">
1726
                <span>Room name</span>
1727
                <input
1728
                  type="text"
1729
                  placeholder="Weekend plans"
1730
                  bind:value={createName}
1731
                  required
1732
                  disabled={createBusy}
1733
                />
1734
              </label>
1735
              <label class="field">
1736
                <span>Topic (optional)</span>
1737
                <input
1738
                  type="text"
1739
                  placeholder="What should this room be about?"
1740
                  bind:value={createTopic}
1741
                  disabled={createBusy}
1742
                />
1743
              </label>
1744
              <button type="submit" class="primary-btn wide" disabled={createBusy || !createName.trim()}>
1745
                {createBusy ? 'Creating…' : 'Create room'}
1746
              </button>
1747
            </form>
1748
            {/if}
1749
          </div>
1750
        {/if}
1751
      </div>
1752
    {/if}
1753
  </div>
1754
{/if}
1755
1756
<ImageLightbox />