atagen

malice

public
Star0
Watch0
544a170ead2c7f632e7957c1ed729ba50c80ec51
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/App.svelteSvelte1286 lines45.6 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 SecurityPanel from './lib/SecurityPanel.svelte';
7
  import {
8
    closeRoom,
9
    createRoom,
10
    getEncryption,
11
    getMembers,
12
    getProfile,
13
    getRooms,
14
    getSession,
15
    getSpaceChildren,
16
    joinRoom,
17
    leaveRoom,
18
    logout,
19
    markRead,
20
    openRoom,
21
    paginateRoom,
22
    reactToMessage,
23
    uploadAttachment
24
  } from './lib/api';
25
  import {
26
    banners,
27
    blocked,
28
    connectEvents,
29
    connection,
30
    disconnectEvents,
31
    dismissBanner,
32
    encryption,
33
    membersByRoom,
34
    membersLoading,
35
    pushBanner,
36
    resetClientState,
37
    rooms,
38
    selectedRoomId,
39
    sessionUser,
40
    softLogout,
41
    timelines,
42
    verification
43
  } from './lib/store';
44
  import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';
45
  import type { PendingMessage } from './lib/pending';
46
  import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';
47
  import { notifyTyping, stopTyping } from './lib/typing';
48
  import { formatDay } from './lib/format';
49
  import { parseEncryptionState, parseOwnProfile, parseVerificationState } from './lib/types';
50
  import type { MemberSummary, OwnProfile, QuickReaction, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
51
52
  let roomsLoading = false;
53
  let booted = false;
54
  let wasOnline = false;
55
  let search = '';
56
  let sidebarOpen = false;
57
  let membersOpen = false;
58
  let dialog: 'join' | 'create' | 'security' | null = null;
59
  let joinTarget = '';
60
  let joinBusy = false;
61
  let createName = '';
62
  let createTopic = '';
63
  let createBusy = false;
64
  let leaveBusy = false;
65
  let paginateBusy = false;
66
  let reacting = false;
67
  let composer = '';
68
  let drafts: Record<string, string> = {};
69
  let stickToBottom = true;
70
  let timelineEl: HTMLElement | null = null;
71
  let composerEl: HTMLTextAreaElement | null = null;
72
  let fileInput: HTMLInputElement | null = null;
73
  let uploadBusy = false;
74
  let uploadName: string | null = null;
75
  let openedRoom: string | null = null;
76
  let openReady: Record<string, boolean> = {};
77
  let openFailed: Record<string, string> = {};
78
  let membersFor: string | null = null;
79
  let lastReadKey = '';
80
  let readTimer: number | null = null;
81
  let profile: OwnProfile | null = null;
82
  let profileMxc: string | null = null;
83
  let selectedSpaceId: string | null = null;
84
  let spaceChildIds: string[] | null = null;
85
  let spaceChildrenLoading = false;
86
  let spaceChildrenError: string | null = null;
87
  let avatarByUser: Record<string, string | null> = {};
88
89
  // account data is read from the local cache, which is empty on a fresh device until the first sync lands
90
  $: {
91
    const online = $connection === 'online';
92
    if (online && !wasOnline) void loadQuickReactions();
93
    wasOnline = online;
94
  }
95
  $: selectedRoom = $rooms.find((r) => r.room_id === $selectedRoomId) ?? null;
96
  $: spaceRooms = $rooms.filter((r) => r.space);
97
  $: invites = $rooms.filter((r) => r.membership === 'Invited');
98
  $: query = search.trim().toLowerCase();
99
  $: selectedSpace = selectedSpaceId ? ($rooms.find((r) => r.room_id === selectedSpaceId) ?? null) : null;
100
  // While a space's children load (or fail), show no channels rather than the
101
  // full list — listing everything would falsely imply it belongs to the space.
102
  $: baseRooms = selectedSpaceId ? (spaceChildIds ? $rooms.filter((r) => spaceChildIds.includes(r.room_id)) : []) : $rooms;
103
  $: visibleRooms = query
104
    ? baseRooms.filter(
105
        (r) =>
106
          r.name.toLowerCase().includes(query) || r.room_id.toLowerCase().includes(query)
107
      )
108
    : baseRooms;
109
  $: directRooms = visibleRooms.filter((r) => r.direct && !r.space && r.membership !== 'Invited');
110
  $: groupRooms = visibleRooms.filter(
111
    (r) => !r.direct && !r.space && r.membership !== 'Invited'
112
  );
113
  $: spaceList = visibleRooms.filter((r) => r.space && r.membership !== 'Invited');
114
  $: channelRooms = baseRooms.filter((r) => !r.space && r.membership !== 'Invited');
115
  $: recoveryBanner = $encryption && ($encryption.recovery === 'Disabled' || $encryption.recovery === 'Incomplete') ? $encryption : null;
116
  $: verificationBanner = $verification && $verification.kind !== 'Done' ? $verification : null;
117
  $: items = $selectedRoomId ? ($timelines[$selectedRoomId] ?? []) : [];
118
  $: members = $selectedRoomId ? ($membersByRoom[$selectedRoomId] ?? []) : [];
119
  $: hasStart = items.some((i) => i.kind === 'start');
120
  $: grouped = buildGroups(items);
121
  $: pendingItems = $selectedRoomId ? ($pendingByRoom[$selectedRoomId] ?? []) : [];
122
  $: lastRealOwn = lastSenderIsOwn(items);
123
124
  function lastSenderIsOwn(list: TimelineItem[]): boolean {
125
    for (let i = list.length - 1; i >= 0; i -= 1) {
126
      const it = list[i];
127
      if (it.kind === 'event') return it.event.sender.user_id === $sessionUser;
128
    }
129
    return false;
130
  }
131
132
  function pendingShowSender(index: number): boolean {
133
    return index === 0 ? !lastRealOwn : false;
134
  }
135
136
  function pendingToEvent(p: PendingMessage): TimelineEvent {
137
    const state: SendState = p.status === 'failed' ? { kind: 'Failed', will_retry: false } : { kind: 'Sending' };
138
    return {
139
      item_id: `pending-${p.id}`,
140
      event_id: null,
141
      transaction_id: p.id,
142
      sender: {
143
        user_id: $sessionUser ?? '',
144
        display_name: profile?.display_name ?? null,
145
        avatar: null,
146
        name_ambiguous: false
147
      },
148
      timestamp_ms: p.createdAt,
149
      own: true,
150
      send_state: state,
151
      content: {
152
        kind: 'Message',
153
        message: { kind: 'Text', body: p.body, geo_uri: null, formatted_body: null, edited: false, media: null }
154
      },
155
      reply_to: null,
156
      thread_root: null,
157
      thread_summary: null,
158
      reactions: [],
159
      read_by: [],
160
      editable: false
161
    };
162
  }
163
164
  interface Group {
165
    key: string;
166
    item: TimelineItem;
167
    showSender: boolean;
168
  }
169
170
  function buildGroups(list: TimelineItem[]): Group[] {
171
    const out: Group[] = [];
172
    let prevSender: string | null = null;
173
    let prevTs = 0;
174
    list.forEach((item, index) => {
175
      let showSender = true;
176
      if (item.kind === 'event') {
177
        const e = item.event;
178
        showSender =
179
          prevSender !== e.sender.user_id || Math.abs(e.timestamp_ms - prevTs) > 5 * 60 * 1000;
180
        prevSender = e.sender.user_id;
181
        prevTs = e.timestamp_ms;
182
        out.push({ key: e.item_id || e.event_id || `m-${index}`, item, showSender });
183
      } else {
184
        prevSender = null;
185
        out.push({ key: `${item.kind}-${index}`, item, showSender });
186
      }
187
    });
188
    return out;
189
  }
190
191
  function roomUnread(r: RoomSummary): number {
192
    return r.unread_messages + r.unread_mentions;
193
  }
194
195
  onMount(() => {
196
    void boot();
197
    return () => {
198
      disconnectEvents();
199
      stopTyping();
200
      if (readTimer !== null) window.clearTimeout(readTimer);
201
    };
202
  });
203
204
  async function boot(): Promise<void> {
205
    try {
206
      const session = await getSession();
207
      blocked.set(session.blocked ?? false);
208
      sessionUser.set(session.user_id);
209
      if (session.user_id) {
210
        softLogout.set(null);
211
        await refreshRooms();
212
        connectEvents();
213
        void loadProfile();
214
        void loadEncryption();
215
        void loadQuickReactions();
216
      } else {
217
        softLogout.set(session.soft_logout ?? null);
218
      }
219
    } catch (e) {
220
      pushBanner(e instanceof Error ? `Session check failed: ${e.message}` : 'Session check failed');
221
      sessionUser.set(null);
222
    } finally {
223
      booted = true;
224
    }
225
  }
226
227
  async function refreshRooms(): Promise<void> {
228
    roomsLoading = true;
229
    try {
230
      const res = await getRooms();
231
      rooms.set(res.rooms);
232
      pruneSpaceFilter();
233
    } catch (e) {
234
      pushBanner(e instanceof Error ? e.message : 'Could not load rooms');
235
    } finally {
236
      roomsLoading = false;
237
    }
238
  }
239
240
  async function loadProfile(): Promise<void> {
241
    try {
242
      const raw = await getProfile();
243
      profile = parseOwnProfile(raw);
244
      profileMxc = profile.avatar_url;
245
    } catch {
246
      profile = null;
247
    }
248
  }
249
250
  async function loadEncryption(): Promise<void> {
251
    try {
252
      const res = await getEncryption();
253
      const state = parseEncryptionState(res.state);
254
      if (state) encryption.set(state);
255
      const verificationState = parseVerificationState(res.verification);
256
      if (verificationState) verification.set(verificationState);
257
    } catch {
258
      // Encryption status also arrives over SSE; a failed first fetch is not fatal.
259
    }
260
  }
261
262
  function afterLogin(userId: string): void {
263
    sessionUser.set(userId);
264
    softLogout.set(null);
265
    blocked.set(false);
266
    void refreshRooms();
267
    connectEvents();
268
    void loadProfile();
269
    void loadEncryption();
270
    void loadQuickReactions();
271
  }
272
273
  async function doLogout(): Promise<void> {
274
    stopTyping();
275
    try {
276
      await logout();
277
    } catch (e) {
278
      pushBanner(e instanceof Error ? e.message : 'Logout failed');
279
      return;
280
    }
281
    disconnectEvents();
282
    resetClientState();
283
    softLogout.set(null);
284
    blocked.set(false);
285
    sessionUser.set(null);
286
    composer = '';
287
    drafts = {};
288
    dialog = null;
289
    membersOpen = false;
290
    sidebarOpen = false;
291
    profile = null;
292
    profileMxc = null;
293
    selectedSpaceId = null;
294
    spaceChildIds = null;
295
    spaceChildrenError = null;
296
    openReady = {};
297
    openFailed = {};
298
    avatarByUser = {};
299
    resetPending();
300
  }
301
302
  function pruneSpaceFilter(): void {
303
    if (!selectedSpaceId) return;
304
    if (!$rooms.some((r) => r.room_id === selectedSpaceId)) {
305
      selectedSpaceId = null;
306
      spaceChildIds = null;
307
      spaceChildrenError = null;
308
    }
309
  }
310
311
  async function selectHome(): Promise<void> {
312
    selectedSpaceId = null;
313
    spaceChildIds = null;
314
    spaceChildrenError = null;
315
    sidebarOpen = true;
316
  }
317
318
  async function selectSpace(spaceId: string): Promise<void> {
319
    if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;
320
    stopTyping();
321
    const prev = openedRoom;
322
    if (prev) {
323
      openedRoom = null;
324
      closeRoom(prev).catch(() => undefined);
325
    }
326
    selectedSpaceId = spaceId;
327
    spaceChildIds = null;
328
    spaceChildrenError = null;
329
    spaceChildrenLoading = true;
330
    sidebarOpen = true;
331
    // A space is navigation, not a message timeline.
332
    selectedRoomId.set(null);
333
    try {
334
      const res = await getSpaceChildren(spaceId);
335
      if (selectedSpaceId === spaceId) spaceChildIds = res.room_ids ?? [];
336
    } catch (e) {
337
      if (selectedSpaceId === spaceId) {
338
        spaceChildrenError = e instanceof Error ? e.message : 'Could not load rooms in this space';
339
        spaceChildIds = [];
340
      }
341
    } finally {
342
      if (selectedSpaceId === spaceId) spaceChildrenLoading = false;
343
    }
344
  }
345
346
  async function selectRoom(roomId: string): Promise<void> {
347
    if ($selectedRoomId && composer.trim()) drafts[$selectedRoomId] = composer;
348
    if (roomId === $selectedRoomId && openReady[roomId]) {
349
      sidebarOpen = false;
350
      return;
351
    }
352
    stopTyping();
353
    const prev = openedRoom;
354
    openedRoom = roomId;
355
    selectedRoomId.set(roomId);
356
    composer = drafts[roomId] ?? '';
357
    stickToBottom = true;
358
    lastReadKey = '';
359
    sidebarOpen = false;
360
    openReady = { ...openReady, [roomId]: false };
361
    if (openFailed[roomId]) {
362
      const nextFailed = { ...openFailed };
363
      delete nextFailed[roomId];
364
      openFailed = nextFailed;
365
    }
366
    try {
367
      await openRoom(roomId);
368
      openReady = { ...openReady, [roomId]: true };
369
    } catch (e) {
370
      const message = e instanceof Error ? e.message : `Could not open room ${roomId}`;
371
      openFailed = { ...openFailed, [roomId]: message };
372
      pushBanner(message);
373
    }
374
    if (membersOpen) void loadMembers(roomId);
375
    await tick();
376
    scrollToBottom();
377
    queueReadReceipt();
378
    composerEl?.focus();
379
  }
380
381
  function scrollToBottom(): void {
382
    if (timelineEl) timelineEl.scrollTop = timelineEl.scrollHeight;
383
  }
384
385
  function onTimelineScroll(): void {
386
    if (!timelineEl) return;
387
    const gap = timelineEl.scrollHeight - timelineEl.scrollTop - timelineEl.clientHeight;
388
    stickToBottom = gap < 80;
389
  }
390
391
  $: void trackTimeline($selectedRoomId, items);
392
  async function trackTimeline(roomId: string | null, list: TimelineItem[]): Promise<void> {
393
    if (!roomId || list.length === 0) return;
394
    if (roomId !== $selectedRoomId) return;
395
    await tick();
396
    if (stickToBottom) scrollToBottom();
397
    queueReadReceipt();
398
  }
399
400
  function queueReadReceipt(): void {
401
    if (readTimer !== null) window.clearTimeout(readTimer);
402
    readTimer = window.setTimeout(() => {
403
      readTimer = null;
404
      void sendReadReceipt();
405
    }, 800);
406
  }
407
408
  async function sendReadReceipt(): Promise<void> {
409
    const roomId = $selectedRoomId;
410
    if (!roomId) return;
411
    const list = $timelines[roomId] ?? [];
412
    for (let i = list.length - 1; i >= 0; i -= 1) {
413
      const entry = list[i];
414
      if (entry.kind === 'event' && entry.event.event_id) {
415
        const key = `${roomId}:${entry.event.event_id}`;
416
        if (key === lastReadKey) return;
417
        lastReadKey = key;
418
        try {
419
          await markRead(roomId, entry.event.event_id);
420
        } catch {
421
          return;
422
        }
423
        return;
424
      }
425
    }
426
  }
427
428
  function send(): void {
429
    const roomId = $selectedRoomId;
430
    const body = composer.trim();
431
    if (!roomId || !body) return;
432
    if (!openReady[roomId]) {
433
      pushBanner('Still opening this room — sending is disabled until the room is ready.');
434
      return;
435
    }
436
    composer = '';
437
    if (roomId in drafts) delete drafts[roomId];
438
    stickToBottom = true;
439
    queueSend(roomId, body);
440
    composerEl?.focus();
441
  }
442
443
  let typedText = '';
444
  let typedRoom: string | null = null;
445
  $: trackTypingInput(composer, $selectedRoomId, openReady);
446
  function trackTypingInput(text: string, roomId: string | null, ready: Record<string, boolean>): void {
447
    const edited = roomId === typedRoom && text !== typedText;
448
    typedText = text;
449
    typedRoom = roomId;
450
    if (!roomId || !ready[roomId] || !text.trim()) {
451
      stopTyping();
452
    } else if (edited) {
453
      notifyTyping(roomId);
454
    }
455
  }
456
457
  $: void trackPending($selectedRoomId, pendingItems);
458
  async function trackPending(roomId: string | null, list: PendingMessage[]): Promise<void> {
459
    if (!roomId || list.length === 0) return;
460
    if (roomId !== $selectedRoomId) return;
461
    await tick();
462
    if (stickToBottom) scrollToBottom();
463
  }
464
465
  function onComposerKey(ev: KeyboardEvent): void {
466
    if (ev.key === 'Enter' && !ev.shiftKey) {
467
      ev.preventDefault();
468
      send();
469
    }
470
  }
471
472
  async function upload(): Promise<void> {
473
    const roomId = $selectedRoomId;
474
    if (!roomId || uploadBusy || !openReady[roomId]) return;
475
    fileInput?.click();
476
  }
477
478
  async function onFileChosen(): Promise<void> {
479
    const roomId = $selectedRoomId;
480
    const files = fileInput?.files;
481
    if (!roomId || !files || files.length === 0) return;
482
    const file = files[0];
483
    if (file.size === 0 || file.size > 20 * 1024 * 1024) {
484
      pushBanner('File must be between 1 byte and 20 MiB.');
485
      if (fileInput) fileInput.value = '';
486
      return;
487
    }
488
    uploadBusy = true;
489
    uploadName = file.name || 'attachment';
490
    try {
491
      await uploadAttachment(roomId, file);
492
      stickToBottom = true;
493
    } catch (e) {
494
      pushBanner(e instanceof Error ? e.message : 'Upload failed');
495
    } finally {
496
      uploadBusy = false;
497
      uploadName = null;
498
      if (fileInput) fileInput.value = '';
499
    }
500
  }
501
  async function react(eventId: string, key: string): Promise<void> {
502
    const roomId = $selectedRoomId;
503
    if (!roomId || !eventId || reacting) return;
504
    reacting = true;
505
    try {
506
      await reactToMessage(roomId, eventId, key);
507
    } catch (e) {
508
      pushBanner(e instanceof Error ? e.message : 'Reaction failed');
509
    } finally {
510
      reacting = false;
511
    }
512
  }
513
514
  async function changeQuickReactions(list: QuickReaction[]): Promise<void> {
515
    try {
516
      await saveQuickReactions(list);
517
    } catch (e) {
518
      pushBanner(e instanceof Error ? e.message : 'Could not save quick reactions');
519
    }
520
  }
521
522
  async function paginate(): Promise<void> {
523
    const roomId = $selectedRoomId;
524
    if (!roomId || paginateBusy) return;
525
    paginateBusy = true;
526
    try {
527
      await paginateRoom(roomId);
528
    } catch (e) {
529
      pushBanner(e instanceof Error ? e.message : 'Loading older messages failed');
530
    } finally {
531
      paginateBusy = false;
532
    }
533
  }
534
535
  async function loadMembers(roomId: string): Promise<void> {
536
    if (membersFor === roomId && $membersLoading) return;
537
    membersFor = roomId;
538
    membersLoading.set(true);
539
    try {
540
      const res = await getMembers(roomId);
541
      membersByRoom.update((all) => ({ ...all, [roomId]: res.members }));
542
      avatarByUser = { ...avatarByUser, ...avatarMap(res.members) };
543
    } catch (e) {
544
      pushBanner(e instanceof Error ? e.message : 'Could not load members');
545
    } finally {
546
      if (membersFor === roomId) membersLoading.set(false);
547
    }
548
  }
549
550
  function avatarMap(list: MemberSummary[]): Record<string, string | null> {
551
    const next: Record<string, string | null> = {};
552
    for (const member of list) next[member.user_id] = member.avatar;
553
    return next;
554
  }
555
556
  function senderAvatarFor(userId: string): string | null {
557
    return avatarByUser[userId] ?? null;
558
  }
559
560
  function toggleMembers(): void {
561
    membersOpen = !membersOpen;
562
    if (membersOpen && $selectedRoomId) void loadMembers($selectedRoomId);
563
  }
564
565
  async function join(target: string): Promise<void> {
566
    const room = target.trim();
567
    if (!room || joinBusy) return;
568
    joinBusy = true;
569
    try {
570
      const res = await joinRoom(room);
571
      joinTarget = '';
572
      dialog = null;
573
      await refreshRooms();
574
      await selectRoom(res.room_id);
575
    } catch (e) {
576
      pushBanner(e instanceof Error ? e.message : 'Join failed');
577
    } finally {
578
      joinBusy = false;
579
    }
580
  }
581
582
  async function create(): Promise<void> {
583
    const name = createName.trim();
584
    if (!name || createBusy) return;
585
    createBusy = true;
586
    try {
587
      const res = await createRoom(name, createTopic.trim() || undefined);
588
      createName = '';
589
      createTopic = '';
590
      dialog = null;
591
      await refreshRooms();
592
      await selectRoom(res.room_id);
593
    } catch (e) {
594
      pushBanner(e instanceof Error ? e.message : 'Create room failed');
595
    } finally {
596
      createBusy = false;
597
    }
598
  }
599
600
  async function leave(): Promise<void> {
601
    const roomId = $selectedRoomId;
602
    if (!roomId || leaveBusy) return;
603
    leaveBusy = true;
604
    stopTyping();
605
    try {
606
      await leaveRoom(roomId);
607
      const prev = openedRoom;
608
      if (prev === roomId) openedRoom = null;
609
      if ($selectedRoomId === roomId) selectedRoomId.set(null);
610
      if (prev) closeRoom(prev).catch(() => undefined);
611
      await refreshRooms();
612
    } catch (e) {
613
      pushBanner(e instanceof Error ? e.message : 'Leave failed');
614
    } finally {
615
      leaveBusy = false;
616
    }
617
  }
618
619
  function connectionClass(state: string): string {
620
    if (state === 'online') return 'dot-online';
621
    if (state === 'connecting' || state === 'reconnecting') return 'dot-busy';
622
    return 'dot-off';
623
  }
624
625
  function onKeydown(ev: KeyboardEvent): void {
626
    if (ev.key === 'Escape') {
627
      if (dialog) dialog = null;
628
      else if (membersOpen && window.innerWidth < 1100) membersOpen = false;
629
      else if (sidebarOpen) sidebarOpen = false;
630
    }
631
  }
632
</script>
633
634
<svelte:window on:keydown={onKeydown} />
635
636
{#if !booted}
637
  <div class="splash" role="status" aria-label="Loading Malice">
638
    <div class="splash-mark" aria-hidden="true">M</div>
639
    <p>Loading Malice…</p>
640
  </div>
641
{:else if $blocked}
642
  <div class="splash" role="status" aria-label="Malice is open in another tab">
643
    <div class="splash-mark" aria-hidden="true">M</div>
644
    <p>Malice is open in another tab. Close this tab or use that one instead.</p>
645
  </div>
646
{:else if $sessionUser == null}
647
  <LoginPanel softLogout={$softLogout} on:done={(ev) => afterLogin(ev.detail)} />
648
{:else}
649
  <div class="app">
650
    <nav class="rail" aria-label="Servers and spaces">
651
      <button
652
        type="button"
653
        class="rail-btn"
654
        class:active={selectedSpaceId === null}
655
        title="Home — all channels"
656
        aria-label="Home — all channels"
657
        aria-pressed={selectedSpaceId === null}
658
        on:click={() => void selectHome()}
659
      >
660
        <span aria-hidden="true">⌂</span>
661
      </button>
662
      <div class="rail-sep" aria-hidden="true"></div>
663
      {#each spaceRooms as space (space.room_id)}
664
        <button
665
          type="button"
666
          class="rail-btn rail-space"
667
          class:active={selectedSpaceId === space.room_id}
668
          title={space.name}
669
          aria-label={`Show channels in space ${space.name}`}
670
          aria-pressed={selectedSpaceId === space.room_id}
671
          on:click={() => void selectSpace(space.room_id)}
672
        >
673
          <MxcAvatar mxc={space.avatar} label={space.name} size={44} shape="rounded" />
674
        </button>
675
      {/each}
676
      <button
677
        type="button"
678
        class="rail-btn rail-add"
679
        aria-label="Join or create a room"
680
        on:click={() => {
681
          dialog = 'join';
682
        }}
683
      >
684
        <span aria-hidden="true">+</span>
685
      </button>
686
    </nav>
687
688
    <aside class="sidebar" class:open={sidebarOpen} aria-label="Rooms">
689
      <div class="side-search">
690
        <label class="visually-hidden" for="room-search">Search rooms</label>
691
        <input
692
          id="room-search"
693
          type="search"
694
          placeholder="Search rooms"
695
          bind:value={search}
696
          autocomplete="off"
697
        />
698
        <button
699
          type="button"
700
          class="icon-btn"
701
          title="Refresh rooms"
702
          aria-label="Refresh rooms"
703
          disabled={roomsLoading}
704
          on:click={() => void refreshRooms()}
705
        >
706
          <span aria-hidden="true">⟳</span>
707
        </button>
708
      </div>
709
      <div class="side-scroll">
710
        {#if selectedSpace}
711
          <div class="space-context">
712
            <MxcAvatar mxc={selectedSpace.avatar} label={selectedSpace.name} size={28} shape="rounded" />
713
            <div class="space-context-text">
714
              <strong>{selectedSpace.name}</strong>
715
              <span>Space — channels below</span>
716
            </div>
717
          </div>
718
        {:else}
719
          <div class="space-context">
720
            <div class="space-context-text">
721
              <strong>Home</strong>
722
              <span>All channels</span>
723
            </div>
724
          </div>
725
        {/if}
726
        {#if roomsLoading && $rooms.length === 0}
727
          <p class="side-note">Loading rooms…</p>
728
        {:else if $rooms.length === 0}
729
          <p class="side-note">No rooms yet. Join one or create your own.</p>
730
          <div class="side-cta">
731
            <button type="button" on:click={() => (dialog = 'join')}>Join a room</button>
732
            <button type="button" on:click={() => (dialog = 'create')}>Create one</button>
733
          </div>
734
        {:else}
735
          {#if selectedSpaceId && spaceChildrenLoading}
736
            <p class="side-note">Loading channels in this space…</p>
737
          {:else if selectedSpaceId && spaceChildrenError}
738
            <p class="side-note" role="alert">{spaceChildrenError}</p>
739
            <div class="side-cta">
740
              <button type="button" on:click={() => selectedSpaceId && void selectSpace(selectedSpaceId)}>Retry</button>
741
              <button type="button" on:click={() => void selectHome()}>Back to Home</button>
742
            </div>
743
          {:else if selectedSpaceId && spaceChildIds && channelRooms.length === 0}
744
            <p class="side-note">No channels found in this space yet.</p>
745
            <div class="side-cta">
746
              <button type="button" on:click={() => void selectHome()}>Back to Home</button>
747
            </div>
748
          {:else}
749
            {#if invites.length > 0 && !selectedSpaceId}
750
              <h2 class="side-h">Invites</h2>
751
              <ul class="room-list">
752
                {#each invites as room (room.room_id)}
753
                  <li>
754
                    <button
755
                      type="button"
756
                      class="room-row"
757
                      class:active={$selectedRoomId === room.room_id}
758
                      on:click={() => void selectRoom(room.room_id)}
759
                    >
760
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
761
                      <span class="room-name">{room.name}</span>
762
                      <span
763
                        class="invite-join"
764
                        role="button"
765
                        tabindex="0"
766
                        aria-label={`Join ${room.name}`}
767
                        on:click|stopPropagation={() => void join(room.room_id)}
768
                        on:keydown|stopPropagation={(ev) => {
769
                          if (ev.key === 'Enter' || ev.key === ' ') {
770
                            ev.preventDefault();
771
                            void join(room.room_id);
772
                          }
773
                        }}
774
                      >
775
                        Join
776
                      </span>
777
                    </button>
778
                  </li>
779
                {/each}
780
              </ul>
781
            {/if}
782
783
            {#if directRooms.length > 0}
784
              <h2 class="side-h">Direct messages</h2>
785
              <ul class="room-list">
786
                {#each directRooms as room (room.room_id)}
787
                  <li>
788
                    <button
789
                      type="button"
790
                      class="room-row"
791
                      class:active={$selectedRoomId === room.room_id}
792
                      aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
793
                      on:click={() => void selectRoom(room.room_id)}
794
                    >
795
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} />
796
                      <span class="room-name">{room.name}</span>
797
                      {#if roomUnread(room) > 0}
798
                        <span
799
                          class="badge"
800
                          class:mention={room.unread_mentions > 0}
801
                          aria-label={`${roomUnread(room)} unread`}
802
                        >
803
                          {roomUnread(room) > 99 ? '99+' : roomUnread(room)}
804
                        </span>
805
                      {/if}
806
                    </button>
807
                  </li>
808
                {/each}
809
              </ul>
810
            {/if}
811
812
            {#if groupRooms.length > 0}
813
              <h2 class="side-h">{selectedSpace ? 'Channels in this space' : 'Rooms'}</h2>
814
              <ul class="room-list">
815
                {#each groupRooms as room (room.room_id)}
816
                  <li>
817
                    <button
818
                      type="button"
819
                      class="room-row"
820
                      class:active={$selectedRoomId === room.room_id}
821
                      aria-current={$selectedRoomId === room.room_id ? 'true' : undefined}
822
                      on:click={() => void selectRoom(room.room_id)}
823
                    >
824
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
825
                      <span class="room-hash" aria-hidden="true">#</span>
826
                      <span class="room-name">{room.name}</span>
827
                      {#if roomUnread(room) > 0}
828
                        <span
829
                          class="badge"
830
                          class:mention={room.unread_mentions > 0}
831
                          aria-label={`${roomUnread(room)} unread`}
832
                        >
833
                          {roomUnread(room) > 99 ? '99+' : roomUnread(room)}
834
                        </span>
835
                      {/if}
836
                    </button>
837
                  </li>
838
                {/each}
839
              </ul>
840
            {/if}
841
842
            {#if !selectedSpaceId && spaceList.length > 0}
843
              <h2 class="side-h">Spaces</h2>
844
              <ul class="room-list">
845
                {#each spaceList as room (room.room_id)}
846
                  <li>
847
                    <button
848
                      type="button"
849
                      class="room-row"
850
                      class:active={selectedSpaceId === room.room_id}
851
                      aria-label={`Show channels in space ${room.name}`}
852
                      on:click={() => void selectSpace(room.room_id)}
853
                    >
854
                      <MxcAvatar mxc={room.avatar} label={room.name} size={26} shape="rounded" />
855
                      <span class="room-hash" aria-hidden="true">◇</span>
856
                      <span class="room-name">{room.name}</span>
857
                    </button>
858
                  </li>
859
                {/each}
860
              </ul>
861
            {/if}
862
863
            {#if query && visibleRooms.length === 0}
864
              <p class="side-note">No rooms match “{search.trim()}”.</p>
865
            {/if}
866
          {/if}
867
        {/if}
868
      </div>
869
870
      <div class="side-footer">
871
        <div class="me" title={$sessionUser}>
872
          {#if profileMxc}
873
            <MxcAvatar mxc={profileMxc} label={profile?.display_name ?? $sessionUser} size={30} />
874
          {:else}
875
            <span class="me-avatar" aria-hidden="true">{$sessionUser.slice(1, 2).toUpperCase()}</span>
876
          {/if}
877
          <span class="me-id">{profile?.display_name && profile.display_name !== $sessionUser ? `${profile.display_name} (${$sessionUser})` : $sessionUser}</span>
878
        </div>
879
        <button
880
          type="button"
881
          class="icon-btn"
882
          title="Encryption and verification"
883
          aria-label="Encryption and verification"
884
          on:click={() => (dialog = 'security')}
885
        >
886
          <span aria-hidden="true">🛡</span>
887
        </button>
888
        <button type="button" class="icon-btn" title="Log out" aria-label="Log out" on:click={() => void doLogout()}>
889
          <span aria-hidden="true">⏻</span>
890
        </button>
891
      </div>
892
    </aside>
893
    {#if sidebarOpen}
894
      <button type="button" class="scrim" aria-label="Close room list" on:click={() => (sidebarOpen = false)}></button>
895
    {/if}
896
897
    <main class="chat">
898
        {#if $banners.length > 0}
899
          <div class="banners" aria-live="polite">
900
            {#each $banners as banner (banner.id)}
901
              <div class="banner" role="alert">
902
                <span>{banner.message}</span>
903
                <button
904
                  type="button"
905
                  aria-label="Dismiss error"
906
                  on:click={() => dismissBanner(banner.id)}
907
                >
908
                  ✕
909
                </button>
910
              </div>
911
            {/each}
912
          </div>
913
        {/if}
914
      {#if !selectedRoom}
915
        <div class="empty">
916
          <div class="empty-mark" aria-hidden="true">◈</div>
917
          {#if selectedSpace}
918
            <h1>{selectedSpace.name}</h1>
919
            <p>Select a channel below to start chatting. Spaces group channels — they are not chat rooms.</p>
920
          {:else}
921
            <h1>Welcome to Malice</h1>
922
            <p>Pick a room from the sidebar, or join one to get started.</p>
923
          {/if}
924
          <div class="empty-cta">
925
            <button type="button" class="primary-btn" on:click={() => (dialog = 'join')}>Join a room</button>
926
            <button type="button" class="ghost-btn" on:click={() => (dialog = 'create')}>Create a room</button>
927
          </div>
928
          <p class="conn-line">
929
            <span class="dot {connectionClass($connection)}" aria-hidden="true"></span>
930
            {$connection}
931
          </p>
932
        </div>
933
      {:else}
934
        <header class="chat-head">
935
          <button
936
            type="button"
937
            class="icon-btn only-mobile"
938
            aria-label="Open room list"
939
            on:click={() => (sidebarOpen = true)}
940
          >
941
            <span aria-hidden="true">☰</span>
942
          </button>
943
          <div class="head-title">
944
            <div class="head-room">
945
              <MxcAvatar mxc={selectedRoom.avatar} label={selectedRoom.name} size={36} shape={selectedRoom.space ? 'rounded' : 'circle'} />
946
              <div class="head-room-text">
947
                <h1>{selectedRoom.name}</h1>
948
                <div class="head-meta">
949
                  {#if selectedRoom.space}
950
                    <span class="pill">space</span>
951
                  {/if}
952
                  {#if selectedRoom.encrypted}
953
                    <span class="pill" title="End-to-end encrypted">🔒 encrypted</span>
954
                  {/if}
955
                  {#if selectedRoom.direct}
956
                    <span class="pill">direct</span>
957
                  {/if}
958
                  <span class="conn" title={`Sync state: ${$connection}`}>
959
                    <span class="dot {connectionClass($connection)}" aria-hidden="true"></span>
960
                    {$connection}
961
                  </span>
962
                </div>
963
              </div>
964
            </div>
965
          </div>
966
          <div class="head-actions">
967
            <button
968
              type="button"
969
              class="ghost-btn"
970
              aria-label="Encryption and verification"
971
              title="Encryption and verification"
972
              on:click={() => (dialog = 'security')}
973
            >
974
              🛡 Security
975
            </button>
976
            <button
977
              type="button"
978
              class="ghost-btn"
979
              aria-pressed={membersOpen}
980
              aria-label={membersOpen ? 'Hide members' : 'Show members'}
981
              on:click={toggleMembers}
982
            >
983
              Members
984
            </button>
985
            {#if selectedRoom.membership === 'Joined'}
986
              <button
987
                type="button"
988
                class="ghost-btn danger"
989
                disabled={leaveBusy}
990
                on:click={() => void leave()}
991
              >
992
                {leaveBusy ? 'Leaving…' : 'Leave'}
993
              </button>
994
            {:else}
995
              <button
996
                type="button"
997
                class="primary-btn"
998
                disabled={joinBusy}
999
                on:click={() => void join(selectedRoom.room_id)}
1000
              >
1001
                {joinBusy ? 'Joining…' : 'Join'}
1002
              </button>
1003
            {/if}
1004
            <button
1005
              type="button"
1006
              class="icon-btn only-mobile"
1007
              aria-label="Toggle members"
1008
              on:click={toggleMembers}
1009
            >
1010
              <span aria-hidden="true">ⓘ</span>
1011
            </button>
1012
          </div>
1013
        </header>
1014
1015
1016
        {#if recoveryBanner}
1017
          <div class="sec-banner warn" role="status">
1018
            <span>Message history is locked — recovery is {recoveryBanner.recovery}. Enter your recovery key to decrypt.</span>
1019
            <button type="button" on:click={() => (dialog = 'security')}>Recover</button>
1020
          </div>
1021
        {/if}
1022
        {#if verificationBanner}
1023
          <div class="sec-banner" role="status">
1024
            {#if verificationBanner.kind === 'Incoming'}
1025
              <span>A device wants to verify this session. Review the request to accept it.</span>
1026
              <button type="button" on:click={() => (dialog = 'security')}>Review request</button>
1027
            {:else if verificationBanner.kind === 'Comparison'}
1028
              <span>Verification codes are ready — compare emojis and numbers.</span>
1029
              <button type="button" on:click={() => (dialog = 'security')}>Compare codes</button>
1030
            {:else if verificationBanner.kind === 'Waiting'}
1031
              <span>Verification started — approve on your other device, then compare codes here.</span>
1032
              <button type="button" on:click={() => (dialog = 'security')}>Open verification</button>
1033
            {:else if verificationBanner.kind === 'Failed'}
1034
              <span>Verification failed: {verificationBanner.message}</span>
1035
              <button type="button" on:click={() => (dialog = 'security')}>Try again</button>
1036
            {:else if verificationBanner.kind === 'Cancelled'}
1037
              <span>Verification cancelled: {verificationBanner.reason}</span>
1038
              <button type="button" on:click={() => (dialog = 'security')}>Open security</button>
1039
            {:else}
1040
              <span>Verification in progress.</span>
1041
              <button type="button" on:click={() => (dialog = 'security')}>Open security</button>
1042
            {/if}
1043
          </div>
1044
        {/if}
1045
        {#if $connection !== 'online'}
1046
          <p class="conn-banner" role="status">
1047
            Sync is {$connection}. New messages will appear when the connection recovers.
1048
          </p>
1049
        {/if}
1050
        {#if $selectedRoomId && !openReady[$selectedRoomId] && !openFailed[$selectedRoomId]}
1051
          <p class="conn-banner" role="status">Opening {selectedRoom.name}… sending is disabled until the room is ready.</p>
1052
        {/if}
1053
        {#if $selectedRoomId && openFailed[$selectedRoomId]}
1054
          <p class="conn-banner error" role="alert">{openFailed[$selectedRoomId]}</p>
1055
        {/if}
1056
1057
        <div class="timeline" bind:this={timelineEl} on:scroll={onTimelineScroll} aria-label={`Messages in ${selectedRoom.name}`}>
1058
          <div class="timeline-top">
1059
            {#if hasStart}
1060
              <p class="start-note">This is the beginning of the visible history.</p>
1061
            {:else}
1062
              <button type="button" class="ghost-btn" disabled={paginateBusy} on:click={() => void paginate()}>
1063
                {paginateBusy ? 'Loading…' : 'Load older messages'}
1064
              </button>
1065
            {/if}
1066
          </div>
1067
          {#if grouped.length === 0}
1068
            <p class="side-note timeline-empty">No messages yet. Say hello!</p>
1069
          {:else}
1070
            {#each grouped as g (g.key)}
1071
              {#if g.item.kind === 'day'}
1072
                <div class="day-row"><span>{formatDay(g.item.timestamp_ms)}</span></div>
1073
              {:else if g.item.kind === 'readMarker'}
1074
                <div class="new-row"><span>New</span></div>
1075
              {:else if g.item.kind === 'start'}
1076
                <p class="start-note">This is the beginning of the visible history.</p>
1077
              {:else if g.item.kind === 'unknown'}
1078
                <p class="unknown-note">Unsupported event ({g.item.label})</p>
1079
              {:else}
1080
                <MessageRow
1081
                  itemId={g.key}
1082
                  event={g.item.event}
1083
                  showSender={g.showSender}
1084
                  senderAvatar={senderAvatarFor(g.item.event.sender.user_id)}
1085
                  {reacting}
1086
                  onReact={(eventId, key) => void react(eventId, key)}
1087
                  quickReactions={$quickReactions}
1088
                  packs={[]}
1089
                  onQuickChange={(list) => void changeQuickReactions(list)}
1090
                />
1091
              {/if}
1092
            {/each}
1093
          {/if}
1094
          {#each pendingItems as p, i (p.id)}
1095
            <MessageRow
1096
              itemId={`pending-${p.id}`}
1097
              event={pendingToEvent(p)}
1098
              showSender={pendingShowSender(i)}
1099
              senderAvatar={senderAvatarFor($sessionUser ?? '')}
1100
              {reacting}
1101
              onReact={(eventId, key) => void react(eventId, key)}
1102
              quickReactions={$quickReactions}
1103
              packs={[]}
1104
              onQuickChange={(list) => void changeQuickReactions(list)}
1105
            />
1106
            {#if p.status === 'failed'}
1107
              <div class="pending-actions">
1108
                <span class="pending-error" role="alert">{p.error}</span>
1109
                <button type="button" class="ghost-btn" on:click={() => retryPending(p.id)}>Retry</button>
1110
                <button type="button" class="ghost-btn danger" on:click={() => discardPending(p.id)}>Discard</button>
1111
              </div>
1112
            {/if}
1113
          {/each}
1114
        </div>
1115
1116
        {#if selectedRoom.membership === 'Joined'}
1117
          <form class="composer" on:submit|preventDefault={() => send()}>
1118
            <label class="visually-hidden" for="composer">Message {selectedRoom.name}</label>
1119
            <input
1120
              type="file"
1121
              bind:this={fileInput}
1122
              class="visually-hidden"
1123
              aria-label="Attach a file"
1124
              on:change={() => void onFileChosen()}
1125
            />
1126
            <button
1127
              type="button"
1128
              class="ghost-btn"
1129
              title="Attach an image or file"
1130
              aria-label="Attach an image or file"
1131
              disabled={!($selectedRoomId && openReady[$selectedRoomId]) || uploadBusy}
1132
              on:click={() => void upload()}
1133
            >
1134
              {uploadBusy ? `Uploading ${uploadName ?? 'file'}…` : '📎'}
1135
            </button>
1136
            <textarea
1137
              id="composer"
1138
              bind:this={composerEl}
1139
              bind:value={composer}
1140
              rows="1"
1141
              placeholder={`Message ${selectedRoom.name}`}
1142
              aria-label={`Message ${selectedRoom.name}. Enter to send, Shift Enter for a new line.`}
1143
              on:keydown={onComposerKey}
1144
              disabled={!($selectedRoomId && openReady[$selectedRoomId])}
1145
            ></textarea>
1146
            <button
1147
              type="submit"
1148
              class="primary-btn"
1149
              disabled={!composer.trim() || !($selectedRoomId && openReady[$selectedRoomId])}
1150
              title={!($selectedRoomId && openReady[$selectedRoomId]) ? 'Waiting for the room to open' : 'Send message'}
1151
            >
1152
              Send
1153
            </button>
1154
          </form>
1155
          <p class="composer-note">Sending and failed states show under your message.</p>
1156
        {:else}
1157
          <div class="join-bar">
1158
            <p>You are not a member of this room.</p>
1159
            <button type="button" class="primary-btn" disabled={joinBusy} on:click={() => void join(selectedRoom.room_id)}>
1160
              {joinBusy ? 'Joining…' : `Join ${selectedRoom.name}`}
1161
            </button>
1162
          </div>
1163
        {/if}
1164
      {/if}
1165
    </main>
1166
1167
    {#if selectedRoom && membersOpen}
1168
      <aside class="members" aria-label={`Members of ${selectedRoom.name}`}>
1169
        <div class="members-head">
1170
          <h2>Members</h2>
1171
          <button type="button" class="icon-btn" aria-label="Close members" on:click={() => (membersOpen = false)}>
1172
            <span aria-hidden="true">✕</span>
1173
          </button>
1174
        </div>
1175
        {#if $membersLoading && members.length === 0}
1176
          <p class="side-note">Loading members…</p>
1177
        {:else if members.length === 0}
1178
          <p class="side-note">No members loaded.</p>
1179
          {#if $selectedRoomId}
1180
            <button type="button" class="ghost-btn" on:click={() => $selectedRoomId && void loadMembers($selectedRoomId)}>
1181
              Retry
1182
            </button>
1183
          {/if}
1184
        {:else}
1185
          <ul class="member-list">
1186
            {#each members as member (member.user_id)}
1187
              <li class="member">
1188
                <MxcAvatar mxc={member.avatar} label={member.display_name || member.name || member.user_id} size={30} />
1189
                <span class="member-names">
1190
                  <span class="member-name">{member.display_name || member.name}</span>
1191
                  <span class="member-id">{member.user_id}</span>
1192
                </span>
1193
              </li>
1194
            {/each}
1195
          </ul>
1196
        {/if}
1197
      </aside>
1198
    {/if}
1199
1200
    {#if dialog}
1201
      <div class="modal-wrap" role="presentation" on:click|self={() => (dialog = null)}>
1202
        {#if dialog === 'security'}
1203
          <SecurityPanel
1204
            encryptionState={$encryption}
1205
            verificationState={$verification}
1206
            onAction={() => void loadEncryption()}
1207
            on:close={() => (dialog = null)}
1208
          />
1209
        {:else}
1210
          <div class="modal" role="dialog" aria-modal="true" aria-label={dialog === 'join' ? 'Join a room' : 'Create a room'}>
1211
            <div class="modal-tabs" role="tablist" aria-label="Room actions">
1212
              <button
1213
                type="button"
1214
                role="tab"
1215
                aria-selected={dialog === 'join'}
1216
                class:active={dialog === 'join'}
1217
                on:click={() => (dialog = 'join')}
1218
              >
1219
                Join
1220
              </button>
1221
              <button
1222
                type="button"
1223
                role="tab"
1224
                aria-selected={dialog === 'create'}
1225
                class:active={dialog === 'create'}
1226
                on:click={() => (dialog = 'create')}
1227
              >
1228
                Create
1229
              </button>
1230
              <button
1231
                type="button"
1232
                class="icon-btn modal-x"
1233
                aria-label="Close dialog"
1234
                on:click={() => (dialog = null)}
1235
              >
1236
                <span aria-hidden="true">✕</span>
1237
              </button>
1238
            </div>
1239
            {#if dialog === 'join'}
1240
              <form on:submit|preventDefault={() => void join(joinTarget)}>
1241
                <label class="field">
1242
                  <span>Room ID or alias</span>
1243
                  <input
1244
                    type="text"
1245
                    placeholder="!room:example.org or #alias:example.org"
1246
                    bind:value={joinTarget}
1247
                    required
1248
                    disabled={joinBusy}
1249
                  />
1250
                </label>
1251
                <button type="submit" class="primary-btn wide" disabled={joinBusy || !joinTarget.trim()}>
1252
                  {joinBusy ? 'Joining…' : 'Join room'}
1253
                </button>
1254
              </form>
1255
            {:else}
1256
            <form on:submit|preventDefault={() => void create()}>
1257
              <label class="field">
1258
                <span>Room name</span>
1259
                <input
1260
                  type="text"
1261
                  placeholder="Weekend plans"
1262
                  bind:value={createName}
1263
                  required
1264
                  disabled={createBusy}
1265
                />
1266
              </label>
1267
              <label class="field">
1268
                <span>Topic (optional)</span>
1269
                <input
1270
                  type="text"
1271
                  placeholder="What should this room be about?"
1272
                  bind:value={createTopic}
1273
                  disabled={createBusy}
1274
                />
1275
              </label>
1276
              <button type="submit" class="primary-btn wide" disabled={createBusy || !createName.trim()}>
1277
                {createBusy ? 'Creating…' : 'Create room'}
1278
              </button>
1279
            </form>
1280
            {/if}
1281
          </div>
1282
        {/if}
1283
      </div>
1284
    {/if}
1285
  </div>
1286
{/if}