atagen

malice

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