atagen

malice

public
Star0
Watch0
e15025643a2002d6ac85ce960505bdddc6bfe927
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/App.svelteSvelte1807 lines66.1 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, railFolded } 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
  let roomMenuOpen = false;
974
  let roomMenuEl: HTMLDivElement | null = null;
975
976
  function roomMenuAction(action: () => void): void {
977
    roomMenuOpen = false;
978
    action();
979
  }
980
981
  function closeRoomMenuOutside(ev: PointerEvent): void {
982
    if (roomMenuOpen && !roomMenuEl?.contains(ev.target as Node)) roomMenuOpen = false;
983
  }
984
985
  function onKeydown(ev: KeyboardEvent): void {
986
    if (ev.key === 'Escape') {
987
      if (roomMenuOpen) roomMenuOpen = false;
988
      else if (dialog) dialog = null;
989
      else if (membersOpen && window.innerWidth < 1100) membersOpen = false;
990
      else if (sidebarOpen) sidebarOpen = false;
991
    }
992
  }
993
</script>
994
995
<svelte:window
996
  on:keydown={onKeydown}
997
  on:pointerdown={closeRoomMenuOutside}
998
  on:pointerup={() => (draggingScrollbar = false)}
999
/>
1000
1001
{#if !booted}
1002
  <div class="splash" role="status" aria-label="Loading Malice">
1003
    <div class="splash-mark" aria-hidden="true">M</div>
1004
    <p>Loading Malice…</p>
1005
  </div>
1006
{:else if $blocked}
1007
  <div class="splash" role="status" aria-label="Malice is open in another tab">
1008
    <div class="splash-mark" aria-hidden="true">M</div>
1009
    <p>Malice is open in another tab. Close this tab or use that one instead.</p>
1010
  </div>
1011
{:else if $sessionUser == null}
1012
  <LoginPanel softLogout={$softLogout} on:done={(ev) => afterLogin(ev.detail)} />
1013
{:else}
1014
  <div
1015
    class="app"
1016
    class:nav-folded={$navFolded && selectedRoom}
1017
    class:rail-folded={$railFolded && selectedRoom}
1018
    class:members-open={selectedRoom && membersOpen}
1019
    style:--msg-gap={`${$messageSpacing}px`}
1020
    style:--msg-line-height={$lineHeight}
1021
  >
1022
    <nav class="rail" aria-label="Servers and spaces">
1023
      <button
1024
        type="button"
1025
        class="rail-btn"
1026
        class:active={selectedSpaceId === null}
1027
        title="Home — all channels"
1028
        aria-label="Home — all channels"
1029
        aria-pressed={selectedSpaceId === null}
1030
        on:click={() => void selectHome()}
1031
      >
1032
        <span aria-hidden="true">⌂</span>
1033
      </button>
1034
      <div class="rail-sep" aria-hidden="true"></div>
1035
      {#each railSpaces as space (space.room_id)}
1036
        <button
1037
          type="button"
1038
          class="rail-btn rail-space"
1039
          class:active={railSelected === space.room_id}
1040
          title={space.name}
1041
          aria-label={`Show channels in space ${space.name}`}
1042
          aria-pressed={selectedSpaceId === space.room_id}
1043
          on:click={() => void selectSpace(space.room_id)}
1044
        >
1045
          <MxcAvatar mxc={space.avatar} label={space.name} size={44} shape="rounded" />
1046
        </button>
1047
      {/each}
1048
      <button
1049
        type="button"
1050
        class="rail-btn rail-add"
1051
        aria-label="Join or create a room"
1052
        on:click={() => {
1053
          dialog = 'join';
1054
        }}
1055
      >
1056
        <span aria-hidden="true">+</span>
1057
      </button>
1058
    </nav>
1059
1060
    <aside class="sidebar" class:open={sidebarOpen} aria-label="Rooms">
1061
      <div class="side-search">
1062
        <label class="visually-hidden" for="room-search">Search rooms</label>
1063
        <input
1064
          id="room-search"
1065
          type="search"
1066
          placeholder="Search rooms"
1067
          bind:value={search}
1068
          autocomplete="off"
1069
        />
1070
        <button
1071
          type="button"
1072
          class="icon-btn"
1073
          title="Refresh rooms"
1074
          aria-label="Refresh rooms"
1075
          disabled={roomsLoading}
1076
          on:click={() => void refreshRooms()}
1077
        >
1078
          <span aria-hidden="true">⟳</span>
1079
        </button>
1080
      </div>
1081
      <div class="side-scroll">
1082
        {#if selectedSpace}
1083
          <div class="space-context">
1084
            <MxcAvatar mxc={selectedSpace.avatar} label={selectedSpace.name} size={28} shape="rounded" />
1085
            <div class="space-context-text">
1086
              <strong>{selectedSpace.name}</strong>
1087
            </div>
1088
            {#if spaceParents.has(selectedSpace.room_id)}
1089
              {@const parentId = spaceParents.get(selectedSpace.room_id) ?? ''}
1090
              <button
1091
                type="button"
1092
                class="icon-btn"
1093
                title="Up to the parent space"
1094
                aria-label="Up to the parent space"
1095
                on:click={() => void selectSpace(parentId)}
1096
              >
1097
                <span aria-hidden="true">↑</span>
1098
              </button>
1099
            {/if}
1100
          </div>
1101
        {:else}
1102
          <div class="space-context">
1103
            <div class="space-context-text">
1104
              <strong>Home</strong>
1105
            </div>
1106
          </div>
1107
        {/if}
1108
        {#if roomsLoading && $rooms.length === 0}
1109
          <p class="side-note">Loading rooms…</p>
1110
        {:else if $rooms.length === 0}
1111
          <p class="side-note">No rooms yet</p>
1112
          <div class="side-cta">
1113
            <button type="button" on:click={() => (dialog = 'join')}>Join a room</button>
1114
            <button type="button" on:click={() => (dialog = 'create')}>Create one</button>
1115
          </div>
1116
        {:else}
1117
          {#if selectedSpaceId && spaceChildrenLoading}
1118
            <p class="side-note">Loading channels…</p>
1119
          {:else if selectedSpaceId && spaceChildrenError}
1120
            <p class="side-note" role="alert">{spaceChildrenError}</p>
1121
            <div class="side-cta">
1122
              <button type="button" on:click={() => selectedSpaceId && void selectSpace(selectedSpaceId)}>Retry</button>
1123
              <button type="button" on:click={() => void selectHome()}>Back to Home</button>
1124
            </div>
1125
          {:else if selectedSpaceId && spaceChildIds && channelRooms.length === 0}
1126
            <p class="side-note">No channels</p>
1127
            <div class="side-cta">
1128
              <button type="button" on:click={() => void selectHome()}>Back to Home</button>
1129
            </div>
1130
          {:else}
1131
            {#if invites.length > 0 && !selectedSpaceId}
1132
              <h2 class="side-h">Invites</h2>
1133
              <ul class="room-list">
1134
                {#each invites as room (room.room_id)}
1135
                  <li>
1136
                    <button
1137
                      type="button"
1138
                      class="room-row"
1139
                      class:active={$selectedRoomId === room.room_id}
1140
                      on:click={() => void selectRoom(room.room_id)}
1141
                    >
1142
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
1143
                      <span class="room-name">{room.name}</span>
1144
                      <span
1145
                        class="invite-join"
1146
                        role="button"
1147
                        tabindex="0"
1148
                        aria-label={`Join ${room.name}`}
1149
                        on:click|stopPropagation={() => void join(room.room_id)}
1150
                        on:keydown|stopPropagation={(ev) => {
1151
                          if (ev.key === 'Enter' || ev.key === ' ') {
1152
                            ev.preventDefault();
1153
                            void join(room.room_id);
1154
                          }
1155
                        }}
1156
                      >
1157
                        Join
1158
                      </span>
1159
                    </button>
1160
                  </li>
1161
                {/each}
1162
              </ul>
1163
            {/if}
1164
1165
            {#if directRooms.length > 0}
1166
              <h2 class="side-h">Direct messages</h2>
1167
              <ul class="room-list">
1168
                {#each directRooms as room (room.room_id)}
1169
                  <li>
1170
                    <button
1171
                      type="button"
1172
                      class="room-row"
1173
                      class:active={$selectedRoomId === room.room_id}
1174
                      aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
1175
                      on:click={() => void selectRoom(room.room_id)}
1176
                    >
1177
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} />
1178
                      <span class="room-name">{room.name}</span>
1179
                      {#if roomUnread(room) > 0}
1180
                        <span
1181
                          class="badge"
1182
                          class:mention={room.unread_mentions > 0}
1183
                          aria-label={`${roomUnread(room)} unread`}
1184
                        >
1185
                          {roomUnread(room) > 99 ? '99+' : roomUnread(room)}
1186
                        </span>
1187
                      {/if}
1188
                    </button>
1189
                  </li>
1190
                {/each}
1191
              </ul>
1192
            {/if}
1193
1194
            {#if groupRooms.length > 0}
1195
              <h2 class="side-h">{selectedSpace ? 'Channels' : 'Rooms'}</h2>
1196
              <ul class="room-list">
1197
                {#each groupRooms as room (room.room_id)}
1198
                  <li>
1199
                    <button
1200
                      type="button"
1201
                      class="room-row"
1202
                      class:active={$selectedRoomId === room.room_id}
1203
                      aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
1204
                      on:click={() => void selectRoom(room.room_id)}
1205
                    >
1206
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
1207
                      <span class="room-hash" aria-hidden="true">#</span>
1208
                      <span class="room-name">{room.name}</span>
1209
                      {#if roomUnread(room) > 0}
1210
                        <span
1211
                          class="badge"
1212
                          class:mention={room.unread_mentions > 0}
1213
                          aria-label={`${roomUnread(room)} unread`}
1214
                        >
1215
                          {roomUnread(room) > 99 ? '99+' : roomUnread(room)}
1216
                        </span>
1217
                      {/if}
1218
                    </button>
1219
                  </li>
1220
                {/each}
1221
              </ul>
1222
            {/if}
1223
1224
            {#if spaceList.length > 0}
1225
              <h2 class="side-h">{selectedSpace ? 'Subspaces' : 'Spaces'}</h2>
1226
              <ul class="room-list">
1227
                {#each spaceList as room (room.room_id)}
1228
                  <li>
1229
                    <button
1230
                      type="button"
1231
                      class="room-row"
1232
                      class:active={selectedSpaceId === room.room_id}
1233
                      aria-label={`Show channels in space ${room.name}`}
1234
                      on:click={() => void selectSpace(room.room_id)}
1235
                    >
1236
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
1237
                      <span class="room-hash" aria-hidden="true">◇</span>
1238
                      <span class="room-name">{room.name}</span>
1239
                    </button>
1240
                  </li>
1241
                {/each}
1242
              </ul>
1243
            {/if}
1244
1245
            {#if query && visibleRooms.length === 0}
1246
              <p class="side-note">No rooms match “{search.trim()}”.</p>
1247
            {/if}
1248
          {/if}
1249
        {/if}
1250
      </div>
1251
1252
      <div class="side-footer">
1253
        <div class="me" title={$sessionUser}>
1254
          {#if profileMxc}
1255
            <MxcAvatar mxc={profileMxc} label={profile?.display_name ?? $sessionUser} size={30} />
1256
          {:else}
1257
            <span class="me-avatar" aria-hidden="true">{$sessionUser.slice(1, 2).toUpperCase()}</span>
1258
          {/if}
1259
          <span class="me-id">{profile?.display_name && profile.display_name !== $sessionUser ? `${profile.display_name} (${$sessionUser})` : $sessionUser}</span>
1260
        </div>
1261
        <button
1262
          type="button"
1263
          class="icon-btn"
1264
          title="Encryption and verification"
1265
          aria-label="Encryption and verification"
1266
          on:click={() => (dialog = 'security')}
1267
        >
1268
          <span aria-hidden="true">🛡</span>
1269
        </button>
1270
        <button
1271
          type="button"
1272
          class="icon-btn"
1273
          title="Settings"
1274
          aria-label="Settings"
1275
          on:click={() => (dialog = 'settings')}
1276
        >
1277
          <span aria-hidden="true">⚙</span>
1278
        </button>
1279
        <button type="button" class="icon-btn" title="Log out" aria-label="Log out" on:click={() => void doLogout()}>
1280
          <span aria-hidden="true">⏻</span>
1281
        </button>
1282
      </div>
1283
    </aside>
1284
    {#if sidebarOpen}
1285
      <button type="button" class="scrim" aria-label="Close room list" on:click={() => (sidebarOpen = false)}></button>
1286
    {/if}
1287
1288
    <main class="chat">
1289
        {#if $banners.length > 0}
1290
          <div class="banners" aria-live="polite">
1291
            {#each $banners as banner (banner.id)}
1292
              <div class="banner" role="alert">
1293
                <span>{banner.message}</span>
1294
                <button
1295
                  type="button"
1296
                  aria-label="Dismiss error"
1297
                  on:click={() => dismissBanner(banner.id)}
1298
                >
1299
                  ✕
1300
                </button>
1301
              </div>
1302
            {/each}
1303
          </div>
1304
        {/if}
1305
      {#if !selectedRoom}
1306
        <div class="empty">
1307
          <div class="empty-mark" aria-hidden="true">◈</div>
1308
          {#if selectedSpace}
1309
            <h1>{selectedSpace.name}</h1>
1310
          {:else}
1311
            <h1>Welcome to Malice</h1>
1312
          {/if}
1313
          <div class="empty-cta">
1314
            <button type="button" class="primary-btn" on:click={() => (dialog = 'join')}>Join a room</button>
1315
            <button type="button" class="ghost-btn" on:click={() => (dialog = 'create')}>Create a room</button>
1316
          </div>
1317
          <p class="conn-line">
1318
            <span class="dot {connectionClass($connection)}" aria-hidden="true"></span>
1319
            {$connection}
1320
          </p>
1321
        </div>
1322
      {:else}
1323
        <header class="chat-head">
1324
          <button
1325
            type="button"
1326
            class="icon-btn only-mobile"
1327
            aria-label="Open room list"
1328
            on:click={() => (sidebarOpen = true)}
1329
          >
1330
            <span aria-hidden="true">☰</span>
1331
          </button>
1332
          <button
1333
            type="button"
1334
            class="icon-btn only-desktop pane-toggle"
1335
            aria-pressed={!$railFolded}
1336
            aria-label={$railFolded ? 'Show spaces' : 'Hide spaces'}
1337
            title={$railFolded ? 'Show spaces' : 'Hide spaces'}
1338
            on:click={() => railFolded.update((v) => !v)}
1339
          >
1340
            <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
1341
              <rect x="3" y="4" width="18" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
1342
              <circle cx="7" cy="9" r="1.4" fill="currentColor" />
1343
              <circle cx="7" cy="13" r="1.4" fill="currentColor" />
1344
              <circle cx="7" cy="17" r="1.4" fill="currentColor" />
1345
            </svg>
1346
          </button>
1347
          <button
1348
            type="button"
1349
            class="icon-btn only-desktop pane-toggle"
1350
            aria-pressed={!$navFolded}
1351
            aria-label={$navFolded ? 'Show channels' : 'Hide channels'}
1352
            title={$navFolded ? 'Show channels' : 'Hide channels'}
1353
            on:click={() => navFolded.update((v) => !v)}
1354
          >
1355
            <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
1356
              <rect x="3" y="4" width="18" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
1357
              <path d="M10 4v16" stroke="currentColor" stroke-width="2" />
1358
            </svg>
1359
          </button>
1360
          <div class="head-title">
1361
            <div class="head-room">
1362
              <MxcAvatar mxc={selectedRoom.avatar} label={selectedRoom.name} size={36} shape={selectedRoom.space ? 'rounded' : 'circle'} />
1363
              <div class="head-room-text">
1364
                <h1>{selectedRoom.name}</h1>
1365
                <div class="head-meta">
1366
                  {#if selectedRoom.space}
1367
                    <span class="pill">space</span>
1368
                  {/if}
1369
                  {#if selectedRoom.encrypted}
1370
                    <span class="pill" title="End-to-end encrypted">🔒 encrypted</span>
1371
                  {/if}
1372
                  {#if selectedRoom.direct}
1373
                    <span class="pill">direct</span>
1374
                  {/if}
1375
                  <span class="conn" title={`Sync state: ${$connection}`}>
1376
                    <span class="dot {connectionClass($connection)}" aria-hidden="true"></span>
1377
                    {$connection}
1378
                  </span>
1379
                </div>
1380
              </div>
1381
            </div>
1382
          </div>
1383
          <div class="head-actions">
1384
            {#if selectedRoom.membership !== 'Joined'}
1385
              <button
1386
                type="button"
1387
                class="primary-btn"
1388
                disabled={joinBusy}
1389
                on:click={() => void join(selectedRoom.room_id)}
1390
              >
1391
                {joinBusy ? 'Joining…' : 'Join'}
1392
              </button>
1393
            {/if}
1394
            <button
1395
              type="button"
1396
              class="icon-btn"
1397
              aria-pressed={membersOpen}
1398
              aria-label={membersOpen ? 'Hide members' : 'Show members'}
1399
              title="Members"
1400
              on:click={toggleMembers}
1401
            >
1402
              <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
1403
                <circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="2" />
1404
                <path d="M4 20c0-4 3.6-6 8-6s8 2 8 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
1405
              </svg>
1406
            </button>
1407
            <div class="room-menu-wrap" bind:this={roomMenuEl}>
1408
              <button
1409
                type="button"
1410
                class="icon-btn"
1411
                aria-haspopup="menu"
1412
                aria-expanded={roomMenuOpen}
1413
                aria-label="Room options"
1414
                title="Room options"
1415
                on:click={() => (roomMenuOpen = !roomMenuOpen)}
1416
              >
1417
                <span aria-hidden="true">⋯</span>
1418
              </button>
1419
              {#if roomMenuOpen}
1420
                <div class="room-menu" role="menu">
1421
                  <button type="button" role="menuitem" on:click={() => roomMenuAction(() => (dialog = 'security'))}>
1422
                    Security
1423
                  </button>
1424
                  {#if selectedRoom.membership === 'Joined'}
1425
                    <button
1426
                      type="button"
1427
                      role="menuitem"
1428
                      class="danger"
1429
                      disabled={leaveBusy}
1430
                      on:click={() => roomMenuAction(() => void leave())}
1431
                    >
1432
                      {leaveBusy ? 'Leaving…' : 'Leave room'}
1433
                    </button>
1434
                  {/if}
1435
                </div>
1436
              {/if}
1437
            </div>
1438
          </div>
1439
        </header>
1440
1441
1442
        {#if recoveryBanner}
1443
          <div class="sec-banner warn" role="status">
1444
            <span>Message history is locked</span>
1445
            <button type="button" on:click={() => (dialog = 'security')}>Recover</button>
1446
          </div>
1447
        {/if}
1448
        {#if verificationBanner}
1449
          <div class="sec-banner" role="status">
1450
            {#if verificationBanner.kind === 'Incoming'}
1451
              <span>A device wants to verify this session</span>
1452
              <button type="button" on:click={() => (dialog = 'security')}>Review request</button>
1453
            {:else if verificationBanner.kind === 'Comparison'}
1454
              <span>Verification codes ready</span>
1455
              <button type="button" on:click={() => (dialog = 'security')}>Compare codes</button>
1456
            {:else if verificationBanner.kind === 'Waiting'}
1457
              <span>Verification started</span>
1458
              <button type="button" on:click={() => (dialog = 'security')}>Open verification</button>
1459
            {:else if verificationBanner.kind === 'Failed'}
1460
              <span>Verification failed: {verificationBanner.message}</span>
1461
              <button type="button" on:click={() => (dialog = 'security')}>Try again</button>
1462
            {:else if verificationBanner.kind === 'Cancelled'}
1463
              <span>Verification cancelled: {verificationBanner.reason}</span>
1464
              <button type="button" on:click={() => (dialog = 'security')}>Open security</button>
1465
            {:else}
1466
              <span>Verification in progress</span>
1467
              <button type="button" on:click={() => (dialog = 'security')}>Open security</button>
1468
            {/if}
1469
          </div>
1470
        {/if}
1471
        {#if $selectedRoomId && openFailed[$selectedRoomId]}
1472
          <p class="conn-banner error" role="alert">{openFailed[$selectedRoomId]}</p>
1473
        {/if}
1474
1475
        <!-- svelte-ignore a11y_no_static_element_interactions -->
1476
        <div
1477
          class="timeline"
1478
          bind:this={timelineEl}
1479
          on:scroll={onTimelineScroll}
1480
          on:wheel={noteUserScroll}
1481
          on:touchmove={noteUserScroll}
1482
          on:keydown={onTimelineKey}
1483
          on:pointerdown={onTimelinePointer}
1484
          aria-label={`Messages in ${selectedRoom.name}`}
1485
        >
1486
          <div class="timeline-inner" use:pinTimeline>
1487
            <div class="timeline-top">
1488
              {#if hasStart}
1489
                <p class="start-note">Beginning of history</p>
1490
              {:else}
1491
                <button type="button" class="ghost-btn" disabled={paginateBusy} on:click={() => void paginate()}>
1492
                  {paginateBusy ? 'Loading…' : 'Load older messages'}
1493
                </button>
1494
              {/if}
1495
            </div>
1496
            {#if grouped.length === 0}
1497
              <p class="side-note timeline-empty">No messages yet</p>
1498
            {:else}
1499
              {#each grouped as g (g.key)}
1500
                {#if g.item.kind === 'day'}
1501
                  <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div>
1502
                {:else if g.item.kind === 'readMarker'}
1503
                  <div class="new-row"><span>New</span></div>
1504
                {:else if g.item.kind === 'start'}
1505
                  <p class="start-note">Beginning of history</p>
1506
                {:else if g.item.kind === 'unknown'}
1507
                  <p class="unknown-note">Unsupported event ({g.item.label})</p>
1508
                {:else}
1509
                  <MessageRow
1510
                    itemId={g.key}
1511
                    event={g.item.event}
1512
                    showSender={g.showSender}
1513
                    senderAvatar={senderAvatarFor(g.item.event.sender.user_id) ?? g.item.event.sender.avatar}
1514
                    {reacting}
1515
                    onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
1516
                    quickReactions={$quickReactions}
1517
                    packs={roomPacks}
1518
                    linkPreviews={$linkPreviews}
1519
                    {loadPreview}
1520
                    onQuickChange={(list) => void changeQuickReactions(list)}
1521
                    {people}
1522
                    self={$sessionUser ?? null}
1523
                    onReply={selectedRoom.membership === 'Joined' ? startReply : null}
1524
                    onJump={jumpTo}
1525
                    onEdit={selectedRoom.membership === 'Joined' ? startEdit : null}
1526
                    editing={editing !== null && editing.eventId === g.item.event.event_id}
1527
                    showSent={g.item.event.item_id === lastSentId && pendingItems.length === 0}
1528
                  />
1529
                {/if}
1530
              {/each}
1531
            {/if}
1532
            {#each pendingItems as p, i (p.id)}
1533
              <MessageRow
1534
                itemId={`pending-${p.id}`}
1535
                event={pendingToEvent(p)}
1536
                onJump={jumpTo}
1537
                showSender={pendingShowSender(i)}
1538
                senderAvatar={senderAvatarFor($sessionUser ?? '')}
1539
                {reacting}
1540
                onReact={(eventId, key, shortcode) => void react(eventId, key, shortcode)}
1541
                quickReactions={$quickReactions}
1542
                packs={roomPacks}
1543
                linkPreviews={$linkPreviews}
1544
                {loadPreview}
1545
                onQuickChange={(list) => void changeQuickReactions(list)}
1546
              />
1547
              {#if p.status === 'failed'}
1548
                <div class="pending-actions">
1549
                  <span class="pending-error" role="alert">{p.error}</span>
1550
                  <button type="button" class="ghost-btn" on:click={() => retryPending(p.id)}>Retry</button>
1551
                  <button type="button" class="ghost-btn danger" on:click={() => discardPending(p.id)}>Discard</button>
1552
                </div>
1553
              {/if}
1554
            {/each}
1555
          </div>
1556
        </div>
1557
1558
        {#if selectedRoom.membership === 'Joined'}
1559
          {#if editing}
1560
            <div class="reply-bar" role="status">
1561
              <span class="reply-bar-label">
1562
                Editing message
1563
                <span class="reply-bar-text">{editing.original}</span>
1564
              </span>
1565
              <button type="button" class="icon-btn" aria-label="Cancel edit" title="Cancel edit (Esc)" on:click={cancelEdit}>
1566
                <span aria-hidden="true">✕</span>
1567
              </button>
1568
            </div>
1569
          {:else if replyingTo && replyingTo.details.kind === 'Ready'}
1570
            <div class="reply-bar" role="status">
1571
              <span class="reply-bar-label">
1572
                Replying to <strong>{senderLabel(replyingTo.details.sender)}</strong>
1573
                <span class="reply-bar-text">{contentPreview(replyingTo.details.content)}</span>
1574
              </span>
1575
              <button type="button" class="icon-btn" aria-label="Cancel reply" title="Cancel reply (Esc)" on:click={() => (replyingTo = null)}>
1576
                <span aria-hidden="true">✕</span>
1577
              </button>
1578
            </div>
1579
          {/if}
1580
          {#if $selectedRoomId && currentStaged.length > 0}
1581
            {@const stagedRoom = $selectedRoomId}
1582
            <ul class="staged" aria-label="Attachments to send">
1583
              {#each currentStaged as attachment (attachment.id)}
1584
                <li class="staged-item">
1585
                  {#if attachment.previewUrl}
1586
                    <img src={attachment.previewUrl} alt={attachment.file.name || 'Pasted image'} />
1587
                  {:else}
1588
                    <span class="staged-name">{attachment.file.name || 'attachment'}</span>
1589
                  {/if}
1590
                  <button
1591
                    type="button"
1592
                    class="icon-btn staged-remove"
1593
                    aria-label={`Remove ${attachment.file.name || 'attachment'}`}
1594
                    title="Remove"
1595
                    on:click={() => unstage(stagedRoom, attachment.id)}
1596
                  >
1597
                    <span aria-hidden="true">✕</span>
1598
                  </button>
1599
                </li>
1600
              {/each}
1601
            </ul>
1602
          {/if}
1603
          <form class="composer" class:replying={replyingTo || editing} class:staging={currentStaged.length > 0} on:submit|preventDefault={() => send()}>
1604
            <label class="visually-hidden" for="composer">Message {selectedRoom.name}</label>
1605
            {#if mentionQuery && mentionCandidates.length > 0}
1606
              <ul class="mention-finder" role="listbox" aria-label="Mention someone">
1607
                {#each mentionCandidates as person, i (person.user_id)}
1608
                  <li
1609
                    role="option"
1610
                    aria-selected={i === mentionIndex}
1611
                    class:active={i === mentionIndex}
1612
                    on:pointerdown|preventDefault={() => void pickMention(person)}
1613
                    on:pointerenter={() => (mentionIndex = i)}
1614
                  >
1615
                    <MxcAvatar mxc={person.avatar} label={person.name} size={22} />
1616
                    <span class="mention-name">{person.name}</span>
1617
                    <span class="mention-id">{person.user_id}</span>
1618
                  </li>
1619
                {/each}
1620
              </ul>
1621
            {/if}
1622
            <input
1623
              type="file"
1624
              multiple
1625
              bind:this={fileInput}
1626
              class="visually-hidden"
1627
              aria-label="Attach a file"
1628
              on:change={() => void onFileChosen()}
1629
            />
1630
            <button
1631
              type="button"
1632
              class="ghost-btn"
1633
              title="Attach an image or file"
1634
              aria-label="Attach an image or file"
1635
              disabled={!($selectedRoomId && openReady[$selectedRoomId])}
1636
              on:click={pickFiles}
1637
            >
1638
              {uploadBusy ? `Uploading ${uploadName ?? 'file'}…` : '📎'}
1639
            </button>
1640
            <textarea
1641
              id="composer"
1642
              bind:this={composerEl}
1643
              bind:value={composer}
1644
              rows="1"
1645
              placeholder={`Message ${selectedRoom.name}`}
1646
              aria-label={`Message ${selectedRoom.name}. Enter to send, Shift Enter for a new line.`}
1647
              on:keydown={onComposerKey}
1648
              on:input={updateMentionQuery}
1649
              on:click={updateMentionQuery}
1650
              on:keyup={(e) => (e.key.startsWith('Arrow') && e.key !== 'ArrowUp' && e.key !== 'ArrowDown') || e.key === 'Home' || e.key === 'End' ? updateMentionQuery() : undefined}
1651
              on:blur={() => (mentionQuery = null)}
1652
              on:paste={onComposerPaste}
1653
              disabled={!($selectedRoomId && openReady[$selectedRoomId])}
1654
            ></textarea>
1655
            <button
1656
              type="submit"
1657
              class="primary-btn"
1658
              disabled={(!composer.trim() && currentStaged.length === 0) ||
1659
                !($selectedRoomId && openReady[$selectedRoomId]) ||
1660
                (currentStaged.length > 0 && uploadBusy)}
1661
              title={!($selectedRoomId && openReady[$selectedRoomId]) ? 'Waiting for the room to open' : 'Send message'}
1662
            >
1663
              Send
1664
            </button>
1665
          </form>
1666
        {:else}
1667
          <div class="join-bar">
1668
            <p>You are not a member of this room.</p>
1669
            <button type="button" class="primary-btn" disabled={joinBusy} on:click={() => void join(selectedRoom.room_id)}>
1670
              {joinBusy ? 'Joining…' : `Join ${selectedRoom.name}`}
1671
            </button>
1672
          </div>
1673
        {/if}
1674
      {/if}
1675
    </main>
1676
1677
    {#if selectedRoom}
1678
      <aside class="members" aria-hidden={!membersOpen} aria-label={`Members of ${selectedRoom.name}`}>
1679
        <div class="members-head">
1680
          <h2>Members</h2>
1681
          <button type="button" class="icon-btn" aria-label="Close members" on:click={() => (membersOpen = false)}>
1682
            <span aria-hidden="true">✕</span>
1683
          </button>
1684
        </div>
1685
        {#if $membersLoading && members.length === 0}
1686
          <p class="side-note">Loading members…</p>
1687
        {:else if members.length === 0}
1688
          <p class="side-note">No members loaded.</p>
1689
          {#if $selectedRoomId}
1690
            <button type="button" class="ghost-btn" on:click={() => $selectedRoomId && void loadMembers($selectedRoomId)}>
1691
              Retry
1692
            </button>
1693
          {/if}
1694
        {:else}
1695
          <ul class="member-list">
1696
            {#each members as member (member.user_id)}
1697
              <li class="member">
1698
                <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} size={30} />
1699
                <span class="member-names">
1700
                  <span class="member-name">{member.display_name || member.name}</span>
1701
                  <span class="member-id">{member.user_id}</span>
1702
                </span>
1703
              </li>
1704
            {/each}
1705
          </ul>
1706
        {/if}
1707
      </aside>
1708
    {/if}
1709
1710
    {#if dialog}
1711
      <div class="modal-wrap" role="presentation" on:click|self={() => (dialog = null)}>
1712
        {#if dialog === 'security'}
1713
          <SecurityPanel
1714
            encryptionState={$encryption}
1715
            verificationState={$verification}
1716
            onAction={() => void loadEncryption()}
1717
            encryptedPreviews={$urlPreviewSettings.encrypted_rooms}
1718
            onEncryptedPreviews={(enabled) => void changeEncryptedPreviews(enabled)}
1719
            on:close={() => (dialog = null)}
1720
          />
1721
        {:else if dialog === 'settings'}
1722
          <SettingsPanel
1723
            quick={$quickReactions}
1724
            packs={roomPacks}
1725
            onQuickChange={(list) => void changeQuickReactions(list)}
1726
            on:close={() => (dialog = null)}
1727
          />
1728
        {:else}
1729
          <div class="modal" role="dialog" aria-modal="true" aria-label={dialog === 'join' ? 'Join a room' : 'Create a room'}>
1730
            <div class="modal-tabs" role="tablist" aria-label="Room actions">
1731
              <button
1732
                type="button"
1733
                role="tab"
1734
                aria-selected={dialog === 'join'}
1735
                class:active={dialog === 'join'}
1736
                on:click={() => (dialog = 'join')}
1737
              >
1738
                Join
1739
              </button>
1740
              <button
1741
                type="button"
1742
                role="tab"
1743
                aria-selected={dialog === 'create'}
1744
                class:active={dialog === 'create'}
1745
                on:click={() => (dialog = 'create')}
1746
              >
1747
                Create
1748
              </button>
1749
              <button
1750
                type="button"
1751
                class="icon-btn modal-x"
1752
                aria-label="Close dialog"
1753
                on:click={() => (dialog = null)}
1754
              >
1755
                <span aria-hidden="true">✕</span>
1756
              </button>
1757
            </div>
1758
            {#if dialog === 'join'}
1759
              <form on:submit|preventDefault={() => void join(joinTarget)}>
1760
                <label class="field">
1761
                  <span>Room ID or alias</span>
1762
                  <input
1763
                    type="text"
1764
                    placeholder="!room:example.org or #alias:example.org"
1765
                    bind:value={joinTarget}
1766
                    required
1767
                    disabled={joinBusy}
1768
                  />
1769
                </label>
1770
                <button type="submit" class="primary-btn wide" disabled={joinBusy || !joinTarget.trim()}>
1771
                  {joinBusy ? 'Joining…' : 'Join room'}
1772
                </button>
1773
              </form>
1774
            {:else}
1775
            <form on:submit|preventDefault={() => void create()}>
1776
              <label class="field">
1777
                <span>Room name</span>
1778
                <input
1779
                  type="text"
1780
                  placeholder="Weekend plans"
1781
                  bind:value={createName}
1782
                  required
1783
                  disabled={createBusy}
1784
                />
1785
              </label>
1786
              <label class="field">
1787
                <span>Topic (optional)</span>
1788
                <input
1789
                  type="text"
1790
                  placeholder="What should this room be about?"
1791
                  bind:value={createTopic}
1792
                  disabled={createBusy}
1793
                />
1794
              </label>
1795
              <button type="submit" class="primary-btn wide" disabled={createBusy || !createName.trim()}>
1796
                {createBusy ? 'Creating…' : 'Create room'}
1797
              </button>
1798
            </form>
1799
            {/if}
1800
          </div>
1801
        {/if}
1802
      </div>
1803
    {/if}
1804
  </div>
1805
{/if}
1806
1807
<ImageLightbox />