atagen

malice

public
Star0
Watch0
00ca0561eced72aa8fda32228099a5df8c82527d
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/MessageRow.svelteSvelte743 lines31.1 KB
1
<svelte:options immutable />
2
3
<script lang="ts">
4
  import MediaAttachmentView from './MediaAttachmentView.svelte';
5
  import MxcAvatar from './MxcAvatar.svelte';
6
  import Emoji from './Emoji.svelte';
7
  import EmojiText from './EmojiText.svelte';
8
  import MessageText from './MessageText.svelte';
9
  import ReactionPicker from './ReactionPicker.svelte';
10
  import LinkCard from './LinkCard.svelte';
11
  import { afterUpdate, onDestroy } from 'svelte';
12
  import { dissolve, expand, rowIn, rowOut, settleHeight } from './motion';
13
  import { selectedRow } from './selected-row';
14
  import { mediaUrl, messageMediaUrl } from './api';
15
  import { keywordMatcher } from './keywords';
16
  import type { ImagePack, LinkPreview, Person, QuickReaction, ReplyReference, SendState, TimelineContent, TimelineEvent } from './types';
17
  import { QUICK_REACTIONS_PER_ROW, senderLabel } from './types';
18
  import { formatClock, formatDay, initialsOf, senderHue } from './format';
19
  import { findUrls } from './urls';
20
  import { mentionsFromHtml, resolveMentions, stripMarkdownPills } from './mentions';
21
22
  export let itemId: string;
23
  export let event: TimelineEvent;
24
  export let showSender: boolean;
25
  export let reacting: boolean;
26
  export let onReact: (eventId: string, key: string, shortcode?: string) => void;
27
  export let senderAvatar: string | null = null;
28
  export let quickReactions: QuickReaction[] = [];
29
  export let packs: ImagePack[] = [];
30
  export let onQuickChange: (list: QuickReaction[]) => void = () => {};
31
  export let linkPreviews: Record<string, LinkPreview | null> = {};
32
  export let loadPreview: ((url: string) => void) | null = null;
33
  export let people: Record<string, Person> = {};
34
  export let onReply: ((target: ReplyReference) => void) | null = null;
35
  export let onJump: ((eventId: string) => void) | null = null;
36
  export let onEdit: ((eventId: string, body: string) => void) | null = null;
37
  export let editing = false;
38
  export let showSent = true;
39
  export let readOnly = false;
40
  export let self: string | null = null;
41
  export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
42
  export let onCloneSticker: ((eventId: string, body: string, x: number, y: number) => void) | null = null;
43
  export let onMore: ((event: TimelineEvent, x: number, y: number) => void) | null = null;
44
  export let onOpenThread: ((rootEventId: string) => void) | null = null;
45
46
  $: lag = lagLabel(event);
47
  $: threadRoot = onOpenThread && interactive && event.event_id && !event.thread_root ? event.event_id : null;
48
  $: threadReplies = event.thread_summary?.reply_count ?? 0;
49
50
  function lagLabel(event: TimelineEvent): string | null {
51
    const lag = event.delivery_lag_ms;
52
    if (!lag) return null;
53
    if (lag < 60 * 60 * 1000) return `${Math.max(1, Math.floor(lag / 60000))}m`;
54
    if (lag < 24 * 60 * 60 * 1000) return `${Math.floor(lag / 3600000)}h`;
55
    return `${Math.floor(lag / 86400000)}d`;
56
  }
57
58
  function openUser(ev: MouseEvent, menu: boolean): void {
59
    if (!onUser) return;
60
    const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
61
    onUser(event.sender.user_id, menu ? ev.clientX : rect.left, menu ? ev.clientY : rect.bottom + 4, menu);
62
  }
63
64
  const PICKER_WIDTH = 320;
65
  const PICKER_HEIGHT = 380;
66
  const PICKER_MARGIN = 8;
67
  const MAX_READERS = 3;
68
  const STATE_LABELS: Record<string, string> = {
69
    'm.room.pinned_events': 'changed the pinned messages',
70
    'm.room.power_levels': 'changed the room permissions',
71
    'm.room.join_rules': 'changed who can join',
72
    'm.room.history_visibility': 'changed who can read history',
73
    'm.room.guest_access': 'changed guest access',
74
    'm.room.canonical_alias': 'changed the room address',
75
    'm.room.server_acl': 'changed the server access list',
76
    'm.space.child': 'changed the rooms in this space',
77
    'm.space.parent': 'changed the parent space',
78
    'im.ponies.room_emotes': 'updated a sticker pack'
79
  };
80
81
  const owner = Symbol();
82
  let row: HTMLDivElement;
83
  let pickerPop: HTMLDivElement | undefined;
84
  let hovered = false;
85
  let focused = false;
86
  let reactorKey: string | null = null;
87
  let pickerOpen = false;
88
  let pickerStyle = '';
89
90
  const MEMBERSHIP_LABELS: Record<string, string> = {
91
    Joined: 'joined the room',
92
    Left: 'left the room',
93
    Banned: 'was banned',
94
    Unbanned: 'was unbanned',
95
    Kicked: 'was kicked',
96
    KickedAndBanned: 'was kicked and banned',
97
    Invited: 'was invited',
98
    InvitationAccepted: 'accepted the invite',
99
    InvitationRejected: 'rejected the invite',
100
    InvitationRevoked: 'had the invite revoked',
101
    Knocked: 'requested to join',
102
    KnockAccepted: 'was let in',
103
    KnockRetracted: 'withdrew the request to join',
104
    KnockDenied: 'had the request to join denied'
105
  };
106
107
  $: name = event.sender.display_name ?? people[event.sender.user_id]?.name ?? event.sender.user_id;
108
  $: displayName = event.sender.name_ambiguous ? `${name} (${event.sender.user_id})` : name;
109
  $: readers = event.read_by.map((id) => people[id] ?? { user_id: id, name: id, avatar: null });
110
  $: interactive = event.content.kind === 'Message' || event.content.kind === 'Sticker' || event.content.kind === 'Poll';
111
  $: message = event.content.kind === 'Message' ? event.content.message : null;
112
  $: rawText = message ? (message.media ? message.media.caption : message.body) ?? '' : '';
113
  $: formattedBody = message?.formatted_body ?? null;
114
  $: hasMessage = message !== null;
115
  $: stripped = stripMarkdownPills(rawText);
116
  $: linked = hasMessage ? [...mentionsFromHtml(formattedBody), ...stripped.mentions] : [];
117
  $: nameOf = (id: string) => people[id]?.name ?? null;
118
  $: mentions = resolveMentions(stripped.text, linked);
119
  $: mentionsMe = self !== null && [...linked, ...mentions].some((m) => m.user_id === self);
120
  $: own = event.own;
121
  $: highlighted = event.highlight === true;
122
  $: keywordHit = !own && (highlighted || (hasMessage && ($keywordMatcher?.test(stripped.text) ?? false)));
123
  $: previews = findUrls(stripped.text);
124
  $: bundled = message ? message.link_previews : [];
125
  $: cards = previews
126
    .map((link) => ({ link, preview: bundled.find((b) => b.url === link.href) ?? linkPreviews[link.href] ?? null }))
127
    .filter((c) => c.preview && (c.preview.title || c.preview.description || c.preview.image));
128
  $: if (loadPreview) {
129
    for (const link of previews) {
130
      if (!bundled.some((b) => b.url === link.href) && !(link.href in linkPreviews)) loadPreview(link.href);
131
    }
132
  }
133
134
  $: sent = interactive && showSent && event.own && event.send_state.kind === 'Sent' && event.event_id !== null;
135
136
  let lastShape = '';
137
  let heightBefore: number | null = null;
138
  $: noteShape(`${event.content.kind}|${rawText}|${formattedBody}|${message?.edited}|${event.reply_to?.details.kind}|${showSender}`);
139
140
  function noteShape(shape: string): void {
141
    if (row && lastShape && shape !== lastShape) heightBefore = row.offsetHeight;
142
    lastShape = shape;
143
  }
144
145
  afterUpdate(() => {
146
    if (heightBefore === null) return;
147
    const from = heightBefore;
148
    heightBefore = null;
149
    requestAnimationFrame(() => row && settleHeight(row, from));
150
  });
151
152
  function sendLabel(state: SendState): string {
153
    if (state.kind === 'Failed') return state.will_retry ? 'failed — will retry' : 'failed — not delivered';
154
    if (state.kind === 'Sending') return 'sending…';
155
    return '';
156
  }
157
158
  $: selected = $selectedRow === owner;
159
  $: showActions = !readOnly && interactive && !!event.event_id && (selected || pickerOpen || ($selectedRow === null && (hovered || focused)));
160
161
  $: syncSelectionListeners(selected);
162
  $: syncPickerListeners(pickerOpen);
163
164
  function onEscape(e: KeyboardEvent) {
165
    if (e.key === 'Escape') select(false);
166
  }
167
168
  let selectionAttached = false;
169
  let pickerAttached = false;
170
171
  function syncSelectionListeners(on: boolean) {
172
    if (on === selectionAttached) return;
173
    selectionAttached = on;
174
    if (on) {
175
      window.addEventListener('pointerdown', deselectOutside);
176
      window.addEventListener('keydown', onEscape);
177
    } else {
178
      window.removeEventListener('pointerdown', deselectOutside);
179
      window.removeEventListener('keydown', onEscape);
180
    }
181
  }
182
183
  function syncPickerListeners(on: boolean) {
184
    if (on === pickerAttached) return;
185
    pickerAttached = on;
186
    const op = on ? 'addEventListener' : 'removeEventListener';
187
    window[op]('scroll', closeOnScroll, true);
188
    window[op]('resize', closePicker);
189
  }
190
191
  onDestroy(() => {
192
    syncSelectionListeners(false);
193
    syncPickerListeners(false);
194
    selectedRow.update((o) => (o === owner ? null : o));
195
  });
196
197
  function select(on: boolean) {
198
    selectedRow.update((o) => (on ? owner : o === owner ? null : o));
199
  }
200
201
  function toggleSelected(e: MouseEvent) {
202
    if (readOnly || !interactive || (e.target as Element).closest('a, button, input, video, audio, .picker-pop')) return;
203
    select(!selected);
204
  }
205
206
  function onRowKeydown(e: KeyboardEvent) {
207
    if (e.target !== row || readOnly || !interactive) return;
208
    if (e.key === 'Enter' || e.key === ' ') {
209
      e.preventDefault();
210
      select(!selected);
211
    }
212
  }
213
214
  function onFocusOut(e: FocusEvent) {
215
    if (!row.contains(e.relatedTarget as Node | null)) focused = false;
216
  }
217
218
  function deselectOutside(e: PointerEvent) {
219
    if (selected && !pickerOpen && !row.contains(e.target as Node)) select(false);
220
  }
221
222
  function openPicker(e: MouseEvent) {
223
    if (pickerOpen) {
224
      pickerOpen = false;
225
      return;
226
    }
227
    const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
228
    const height = Math.min(PICKER_HEIGHT, window.innerHeight - 2 * PICKER_MARGIN);
229
    const below = window.innerHeight - rect.bottom - PICKER_MARGIN >= height;
230
    const top = below ? rect.bottom + 4 : Math.max(PICKER_MARGIN, rect.top - height - 4);
231
    const maxLeft = window.innerWidth - PICKER_WIDTH - PICKER_MARGIN;
232
    const left = Math.max(PICKER_MARGIN, Math.min(rect.right - PICKER_WIDTH, maxLeft));
233
    pickerStyle = `top:${top}px;left:${left}px;height:${height}px`;
234
    select(true);
235
    pickerOpen = true;
236
  }
237
238
  function closePicker() {
239
    pickerOpen = false;
240
  }
241
242
  function isImageKey(key: string): boolean {
243
    return key.startsWith('mxc://') || packs.some((p) => p.images.some((i) => i.reaction_key === key));
244
  }
245
246
  function react(key: string, shortcode: string | null) {
247
    if (shortcode && isImageKey(key)) onReact(event.event_id ?? '', key, shortcode);
248
    else onReact(event.event_id ?? '', key);
249
  }
250
251
  function reply() {
252
    if (!event.event_id || !onReply) return;
253
    onReply({
254
      event_id: event.event_id,
255
      details: { kind: 'Ready', sender: event.sender, content: event.content, timestamp_ms: event.timestamp_ms }
256
    });
257
  }
258
259
  $: editableBody =
260
    event.own && event.editable && event.event_id && event.content.kind === 'Message' && event.content.message.kind === 'Text'
261
      ? event.content.message.body
262
      : null;
263
264
  function edit() {
265
    if (!event.event_id || editableBody === null || !onEdit) return;
266
    onEdit(event.event_id, editableBody);
267
  }
268
269
  function reactorNames(senders: string[]): string {
270
    const names = senders.map((id) => people[id]?.name ?? id);
271
    if (names.length <= 12) return names.join(', ');
272
    return `${names.slice(0, 12).join(', ')} and ${names.length - 12} more`;
273
  }
274
275
  function pick(key: string, shortcode: string | null) {
276
    pickerOpen = false;
277
    react(key, shortcode);
278
  }
279
280
  function closeOnScroll(e: Event) {
281
    if (pickerOpen && !pickerPop?.contains(e.target as Node)) pickerOpen = false;
282
  }
283
284
  function actorLabel(userId: string, name: string | null): string {
285
    return name ?? people[userId]?.name ?? userId;
286
  }
287
288
  function replyPreview(content: TimelineContent): string {
289
    if (content.kind === 'Message') return stripMarkdownPills(content.message.body).text;
290
    if (content.kind === 'Sticker') return content.body || 'Sticker';
291
    if (content.kind === 'Poll') return `Poll: ${content.question}`;
292
    if (content.kind === 'Redacted') return 'Message deleted';
293
    if (content.kind === 'UnableToDecrypt') return 'Unable to decrypt';
294
    return systemLine(content);
295
  }
296
297
  function systemLine(content: TimelineContent, actor = displayName): string {
298
    switch (content.kind) {
299
      case 'Redacted':
300
        return 'Message deleted';
301
      case 'UnableToDecrypt':
302
        return `Message from ${actor} could not be decrypted`;
303
      case 'Membership': {
304
        const who = actorLabel(content.user_id, content.display_name);
305
        return `${who} ${MEMBERSHIP_LABELS[content.change] ?? 'updated their membership'}`;
306
      }
307
      case 'Profile': {
308
        const who = actorLabel(content.user_id, null);
309
        const parts: string[] = [];
310
        if (content.display_name) {
311
          parts.push(
312
            content.display_name.new
313
              ? `changed their display name to "${content.display_name.new}"`
314
              : 'removed their display name'
315
          );
316
        }
317
        if (content.avatar) parts.push('changed their avatar');
318
        return `${who} ${parts.length > 0 ? parts.join(' and ') : 'updated their profile'}`;
319
      }
320
      case 'RoomName':
321
        return content.name
322
          ? `${actor} changed the room name to "${content.name}"`
323
          : `${actor} removed the room name`;
324
      case 'RoomTopic':
325
        return content.topic
326
          ? `${actor} changed the topic to "${content.topic}"`
327
          : `${actor} removed the topic`;
328
      case 'RoomAvatar':
329
        return content.avatar ? `${actor} changed the room avatar` : `${actor} removed the room avatar`;
330
      case 'EncryptionEnabled':
331
        return `${actor} enabled encryption in this room`;
332
      case 'RoomCreated':
333
        return `${actor} created the room`;
334
      case 'RoomUpgraded':
335
        return `${actor} upgraded the room to a new version`;
336
      case 'OtherState':
337
        return `${actor} ${STATE_LABELS[content.event_type] ?? `sent a state event (${content.event_type})`}`;
338
      case 'Other':
339
        return `${actor} sent an event (${content.event_type})`;
340
      default:
341
        return actor;
342
    }
343
  }
344
</script>
345
346
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
347
<div
348
  class="msg"
349
  role="group"
350
  tabindex={interactive && !readOnly ? 0 : undefined}
351
  class:own={event.own}
352
  class:editing
353
  class:mentioned={mentionsMe || keywordHit}
354
  class:system={!interactive}
355
  class:has-readers={interactive && readers.length > 0}
356
  class:selected
357
  data-item={itemId}
358
  data-event={event.event_id}
359
  in:rowIn|global
360
  out:rowOut|global
361
  bind:this={row}
362
  on:click={toggleSelected}
363
  on:keydown={onRowKeydown}
364
  on:pointerenter={() => (hovered = true)}
365
  on:pointerleave={() => (hovered = false)}
366
  on:focusin={() => (focused = true)}
367
  on:focusout={onFocusOut}
368
>
369
  {#if showActions && event.event_id}
370
    <div class="actions" role="toolbar" aria-label={`Actions for message from ${displayName}`}>
371
      <div class="quick" style={`--quick-cols:${Math.min(quickReactions.length, QUICK_REACTIONS_PER_ROW)}`}>
372
      {#each quickReactions as r (r.key)}
373
        {@const label = r.shortcode ? `:${r.shortcode}:` : r.key}
374
        <button
375
          type="button"
376
          title={`React ${label}`}
377
          aria-label={`React ${label} to message from ${displayName}`}
378
          disabled={reacting}
379
          on:click={() => react(r.key, r.shortcode)}
380
        >
381
          {#if r.key.startsWith('mxc://')}
382
            <img class="emote" src={mediaUrl(r.key, 64) ?? ''} alt="" />
383
          {:else}
384
            <Emoji emoji={r.key} size={18} />
385
          {/if}
386
        </button>
387
      {/each}
388
      </div>
389
      <button
390
        type="button"
391
        title="Add reaction"
392
        aria-label={`Add reaction to message from ${displayName}`}
393
        aria-haspopup="dialog"
394
        aria-expanded={pickerOpen}
395
        on:pointerdown|stopPropagation
396
        on:click={openPicker}
397
      >
398
        ☺+
399
      </button>
400
      {#if onCloneSticker && event.content.kind === 'Sticker'}
401
        {@const stickerBody = event.content.body}
402
        {@const stickerId = event.event_id}
403
        <button
404
          type="button"
405
          title="Clone sticker"
406
          aria-label="Clone sticker to one of your packs"
407
          on:click|stopPropagation={(ev) => onCloneSticker?.(stickerId, stickerBody, ev.clientX, ev.clientY)}
408
        >
409
          ⧉
410
        </button>
411
      {/if}
412
      {#if onReply}
413
        <button
414
          type="button"
415
          title="Reply"
416
          aria-label={`Reply to message from ${displayName}`}
417
          on:click|stopPropagation={reply}
418
        >
419
          ↩
420
        </button>
421
      {/if}
422
      {#if threadRoot}
423
        {@const root = threadRoot}
424
        <button
425
          type="button"
426
          title="Reply in thread"
427
          aria-label={`Reply in thread to message from ${displayName}`}
428
          on:click|stopPropagation={() => onOpenThread?.(root)}
429
        >
430
          <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 3h11v7h-6l-3 3v-3h-2z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M5 6h6M5 8h4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
431
        </button>
432
      {/if}
433
      {#if onEdit && editableBody !== null}
434
        <button type="button" title="Edit" aria-label="Edit your message" on:click|stopPropagation={edit}>
435
          ✎
436
        </button>
437
      {/if}
438
      {#if onMore}
439
        <button
440
          type="button"
441
          title="More"
442
          aria-label={`More actions for message from ${displayName}`}
443
          aria-haspopup="menu"
444
          on:click|stopPropagation={(ev) => onMore?.(event, ev.clientX, ev.clientY)}
445
        >
446
          ⋯
447
        </button>
448
      {/if}
449
    </div>
450
    {#if pickerOpen}
451
      <div class="picker-pop" style={pickerStyle} bind:this={pickerPop}>
452
        <ReactionPicker {packs} quick={quickReactions} onPick={pick} {onQuickChange} onClose={closePicker} />
453
      </div>
454
    {/if}
455
  {/if}
456
  {#if event.reply_to}
457
    {@const target = event.reply_to.event_id}
458
    <div class="reply-line" class:linked={showSender}>
459
      <button
460
        type="button"
461
        class="reply-quote"
462
        disabled={!onJump}
463
        title="Jump to the original message"
464
        on:click|stopPropagation={() => onJump?.(target)}
465
      >
466
        {#if event.reply_to.details.kind === 'Ready'}
467
          <span class="reply-sender"><EmojiText text={event.reply_to.details.sender.display_name ?? people[event.reply_to.details.sender.user_id]?.name ?? senderLabel(event.reply_to.details.sender)} /></span>
468
          <span class="reply-text"><EmojiText text={replyPreview(event.reply_to.details.content)} /></span>
469
        {:else if event.reply_to.details.kind === 'Pending'}
470
          Loading reply…
471
        {:else}
472
          Reply unavailable
473
        {/if}
474
      </button>
475
    </div>
476
  {/if}
477
  {#if showSender}
478
    <button
479
      type="button"
480
      class="user-hit"
481
      disabled={!onUser}
482
      tabindex="-1"
483
      aria-hidden="true"
484
      on:click|stopPropagation={(ev) => openUser(ev, false)}
485
      on:contextmenu|preventDefault|stopPropagation={(ev) => openUser(ev, true)}
486
    >
487
      {#if senderAvatar}
488
        <MxcAvatar mxc={senderAvatar} label={name} size={34} eager />
489
      {:else}
490
        <span class="avatar" style={`background:hsl(${senderHue(event.sender.user_id)} 35% 32%)`}>
491
          <span><EmojiText text={initialsOf(name)} /></span>
492
        </span>
493
      {/if}
494
    </button>
495
  {:else}
496
    <div class="avatar-spacer" aria-hidden="true">
497
      <span class="hover-time">{formatClock(event.timestamp_ms)}</span>
498
    </div>
499
  {/if}
500
  <div class="msg-body">
501
    {#if showSender}
502
      <div class="msg-head">
503
        <button
504
          type="button"
505
          class="sender"
506
          disabled={!onUser}
507
          style={`color:hsl(${senderHue(event.sender.user_id)} 70% var(--name-lightness))`}
508
          on:click|stopPropagation={(ev) => openUser(ev, false)}
509
          on:contextmenu|preventDefault|stopPropagation={(ev) => openUser(ev, true)}
510
        >
511
          <EmojiText text={displayName} />
512
        </button>
513
        {#if lag}
514
          <span class="federation-lag" title="Possibly delayed by federation" aria-label={`Arrived ${lag} late, possibly delayed by federation`}>
515
            <span aria-hidden="true">◷</span><span>{lag}</span>
516
          </span>
517
        {/if}
518
        <time class="time" datetime={new Date(event.timestamp_ms).toISOString()}>
519
          {formatDay(event.timestamp_ms)} {formatClock(event.timestamp_ms)}
520
        </time>
521
        {#if event.content.kind === 'Message' && event.content.message.edited}
522
          <span class="edited" title="Edited" transition:dissolve>(edited)</span>
523
        {/if}
524
      </div>
525
    {/if}
526
527
    {#if event.content.kind === 'Message'}
528
      {@const m = event.content.message}
529
      {#if stripped.text}
530
        <div class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>{#if m.kind === 'Emote'}* <EmojiText text={displayName} /> {/if}<MessageText text={stripped.text} html={m.formatted_body} jumbo={m.kind === 'Text'} {mentions} {self} {nameOf} />{#if m.edited && !showSender}<span class="edited" title="Edited" transition:dissolve>(edited)</span>{/if}</div>
531
      {/if}
532
      {#if m.media}
533
        <MediaAttachmentView attachment={m.media} />
534
      {/if}
535
      {#if cards.length > 0}
536
        <div class="link-previews" transition:expand>
537
          {#each cards as card (card.link.href)}
538
            <div transition:expand><LinkCard link={card.link} preview={card.preview} /></div>
539
          {/each}
540
        </div>
541
      {/if}
542
    {:else if event.content.kind === 'Sticker'}
543
      {#if event.content.media}
544
        <MediaAttachmentView attachment={event.content.media} />
545
      {:else if event.content.body}<p class="text"><EmojiText text={event.content.body} /></p>{/if}
546
    {:else if event.content.kind === 'Poll'}
547
      <p class="text"><EmojiText text={`📊 ${event.content.question}`} /></p>
548
    {:else}
549
      <p class="system-line"><EmojiText text={systemLine(event.content, displayName)} /></p>
550
    {/if}
551
552
    {#if interactive && event.reactions.length > 0}
553
      <div class="reactions" aria-label="Reactions" transition:expand>
554
        {#each event.reactions as r (r.key)}
555
          <button
556
            type="button"
557
            class="reaction"
558
            transition:expand={{ axis: 'x' }}
559
            class:mine={r.own}
560
            aria-label={`${r.shortcode ? `:${r.shortcode}:` : r.key.startsWith('mxc://') ? 'custom emote' : r.key}, ${r.count} reaction${r.count === 1 ? '' : 's'}`}
561
            disabled={readOnly || !event.event_id || reacting}
562
            aria-describedby={reactorKey === r.key ? `${itemId}-reactors-${r.key}` : undefined}
563
            on:pointerenter={() => (reactorKey = r.key)}
564
            on:pointerleave={() => (reactorKey = null)}
565
            on:focus={() => (reactorKey = r.key)}
566
            on:blur={() => (reactorKey = null)}
567
            on:click={() => event.event_id && onReact(event.event_id, r.key)}
568
          >
569
            {#if r.image}
570
              <img class="emote" src={messageMediaUrl(r.image, 'Thumbnail')} alt={r.shortcode ?? ''} title={r.shortcode ?? ''} />
571
            {:else if r.key.startsWith('mxc://')}
572
              <img class="emote" src={mediaUrl(r.key, 64) ?? ''} alt="" />
573
            {:else}
574
              <Emoji emoji={r.key} size={16} />
575
            {/if}
576
            <span class="count">{r.count}</span>
577
            {#if reactorKey === r.key}
578
            <span class="reactors" role="tooltip" id={`${itemId}-reactors-${r.key}`}>
579
              <span class="reactors-key"><EmojiText text={r.shortcode ? `:${r.shortcode}:` : r.key.startsWith('mxc://') ? 'custom emote' : r.key} /></span>
580
              <EmojiText text={reactorNames(r.senders)} />
581
            </span>
582
            {/if}
583
          </button>
584
        {/each}
585
      </div>
586
    {/if}
587
    {#if threadRoot && threadReplies > 0}
588
      {@const root = threadRoot}
589
      {@const latest = event.thread_summary?.latest_event_timestamp_ms ?? null}
590
      <button type="button" class="thread-chip" transition:expand on:click|stopPropagation={() => onOpenThread?.(root)}>
591
        <span class="thread-count">{threadReplies} {threadReplies === 1 ? 'reply' : 'replies'}</span>
592
        {#if latest}<time datetime={new Date(latest).toISOString()}>{formatDay(latest)} {formatClock(latest)}</time>{/if}
593
      </button>
594
    {/if}
595
    {#if sent}<span class="sent-tick" title="Sent" aria-label="Sent" transition:dissolve>✓</span>{/if}
596
    {#if interactive && sendLabel(event.send_state)}
597
      <div class="msg-foot" transition:expand>
598
        <span class="sendstate" class:failed={event.send_state.kind === 'Failed'}>{sendLabel(event.send_state)}</span>
599
      </div>
600
    {/if}
601
    {#if interactive && readers.length > 0}
602
      {@const seenBy = `Seen by ${readers.map((r) => r.name).join(', ')}`}
603
      <div class="readby" transition:dissolve title={seenBy} aria-label={seenBy} role="img">
604
        {#each readers.slice(0, MAX_READERS) as r (r.user_id)}
605
          <MxcAvatar mxc={r.avatar} label={r.name} size={16} />
606
        {/each}
607
        {#if readers.length > MAX_READERS}<span class="more">+{readers.length - MAX_READERS}</span>{/if}
608
      </div>
609
    {/if}
610
  </div>
611
</div>
612
613
<style>
614
  .msg { position: relative; display: flex; flex-wrap: wrap; gap: 0 10px; padding: var(--msg-gap, 3px) 0; }
615
  .reply-line { position: relative; flex: 0 0 100%; min-width: 0; padding-left: 44px; }
616
  .reply-line.linked::before { content: ''; position: absolute; left: 16px; top: 50%; bottom: -3px; width: 24px; border-left: 2px solid var(--border); border-top: 2px solid var(--border); border-top-left-radius: 8px; pointer-events: none; }
617
  .msg:hover, .msg.selected { background: var(--hover-faint); }
618
  .msg:hover .hover-time, .msg.selected .hover-time { opacity: 1; }
619
  .msg-foot { display: flex; align-items: center; gap: 8px; }
620
  .thread-chip { display: inline-flex; align-items: baseline; gap: 8px; margin-top: 4px; padding: 3px 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg-soft); color: var(--text); font: inherit; font-size: 12px; cursor: pointer; transition: border-color var(--pop), background var(--pop); }
621
  .thread-chip:hover { border-color: var(--accent); background: var(--hover-faint); }
622
  .thread-count { color: var(--accent); font-weight: 650; }
623
  .thread-chip time { color: var(--muted); font-size: 11px; }
624
  .sendstate { font-size: 11px; color: var(--muted); }
625
  .sent-tick { position: absolute; right: 12px; bottom: 4px; font-size: 11px; color: var(--muted); }
626
  .sendstate.failed { color: var(--danger); }
627
  .link-previews { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; max-width: 420px; }
628
  .avatar {
629
    flex: none;
630
    width: 34px; height: 34px;
631
    border-radius: 50%;
632
    display: grid; place-items: center;
633
    font-size: 12px; font-weight: 700; color: #fff;
634
  }
635
  .avatar-spacer { flex: none; width: 34px; padding-top: 2px; text-align: center; }
636
  .hover-time { opacity: 0; font-size: 10.5px; color: var(--muted); }
637
  .msg-body { min-width: 0; flex: 1; transition: padding-right var(--pop); }
638
  .msg-head { display: flex; align-items: baseline; gap: 8px; }
639
  .federation-lag { display: inline-flex; align-items: center; gap: 3px; color: var(--danger); font-size: 11px; font-weight: 650; }
640
  .federation-lag > span:last-child { display: inline-grid; place-items: center; min-width: 24px; height: 18px; padding: 0 4px; border: 1px solid currentColor; border-radius: 999px; }
641
  .sender { font-weight: 650; font-size: 13.5px; padding: 0; border: none; background: none; font-family: inherit; cursor: pointer; }
642
  .sender:hover { text-decoration: underline; }
643
  .user-hit { flex: none; align-self: flex-start; padding: 0; border: none; background: none; border-radius: 50%; cursor: pointer; }
644
  .time, .edited { color: var(--muted); font-size: 11.5px; }
645
  .text .edited { margin-left: 6px; }
646
  .msg.editing { background: color-mix(in srgb, var(--accent) 10%, transparent); }
647
  .msg.mentioned { background: color-mix(in srgb, var(--warn) 8%, transparent); box-shadow: inset 2px 0 0 var(--warn); }
648
  .reply-quote {
649
    display: flex; gap: 6px; align-items: baseline;
650
    max-width: min(100%, 520px);
651
    border: none;
652
    border-left: 2px solid var(--accent);
653
    padding: 2px 8px;
654
    margin: 2px 0 4px;
655
    color: var(--muted);
656
    font-size: 12.5px;
657
    text-align: left;
658
    background: var(--bg-soft);
659
    border-radius: 0 var(--r-sm) var(--r-sm) 0;
660
    cursor: pointer;
661
  }
662
  .reply-quote:disabled { cursor: default; }
663
  .reply-quote:hover:not(:disabled) { color: var(--text); }
664
  .reply-sender { flex: none; font-weight: 650; }
665
  .reply-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
666
  :global(.msg.flash) { animation: reply-flash 1.2s ease-out; }
667
  @keyframes -global-reply-flash { from { background: color-mix(in srgb, var(--accent) 22%, transparent); } }
668
  .text {
669
    margin: 1px 0 2px;
670
    font-size: 14px;
671
    line-height: var(--msg-line-height, 1.5);
672
    white-space: pre-wrap;
673
    overflow-wrap: anywhere;
674
  }
675
  .text.emote { font-style: italic; color: var(--muted); }
676
  .text.notice { color: var(--muted); }
677
  .system-line { color: var(--muted); font-size: 12.5px; font-style: italic; margin: 1px 0; }
678
  .msg.system { opacity: 0.85; --msg-gap: 1.5px; }
679
  .msg.system .avatar-spacer { height: 0; padding-top: 0; }
680
  .reactions { display: flex; flex-wrap: wrap; gap: 4px; margin-top: max(4px, var(--msg-gap, 3px)); }
681
  .reaction {
682
    display: inline-flex; align-items: center; gap: 4px;
683
    background: var(--bg-soft);
684
    border: 1px solid var(--border);
685
    border-radius: var(--r-sm);
686
    padding: 1px 8px;
687
    font-size: 13px;
688
    color: var(--text);
689
    cursor: pointer;
690
  }
691
  .reaction { position: relative; }
692
  .reactors {
693
    position: absolute;
694
    left: 0;
695
    bottom: calc(100% + 6px);
696
    z-index: 15;
697
    display: block;
698
    width: max-content;
699
    max-width: 260px;
700
    padding: 6px 9px;
701
    border: 1px solid var(--border);
702
    border-radius: var(--r-md);
703
    background: var(--panel-2);
704
    box-shadow: 0 6px 18px rgb(0 0 0 / 0.3);
705
    color: var(--text);
706
    font-size: 12px;
707
    line-height: 1.35;
708
    text-align: left;
709
    white-space: normal;
710
    pointer-events: none;
711
  }
712
  .reactors-key { display: block; color: var(--muted); font-size: 11px; }
713
  .reaction.mine { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
714
  .reaction:disabled { cursor: default; opacity: 0.85; }
715
  .msg.has-readers .msg-body { padding-right: 72px; }
716
  .readby { position: absolute; right: 12px; bottom: 4px; display: flex; align-items: center; }
717
  .readby > :global(* + *) { margin-left: -4px; }
718
  .readby :global(.mxc-avatar), .readby :global(.mxc-fallback) { box-shadow: 0 0 0 1.5px var(--bg); }
719
  .readby .more { margin-left: 3px; font-size: 10.5px; color: var(--muted); }
720
  .actions {
721
    position: absolute; top: -12px; right: 10px; z-index: 2;
722
    display: flex; align-items: flex-start; gap: 2px;
723
    padding: 2px;
724
    background: var(--bg-soft);
725
    border: 1px solid var(--border);
726
    border-radius: var(--r-md);
727
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
728
  }
729
  .quick { display: grid; grid-template-columns: repeat(var(--quick-cols, 8), auto); gap: 2px; }
730
  .quick:empty { display: none; }
731
  .actions button {
732
    background: transparent;
733
    border: 1px solid transparent;
734
    border-radius: var(--r-sm);
735
    cursor: pointer;
736
    font-size: 14px;
737
    padding: 1px 5px;
738
  }
739
  .actions button:hover:not(:disabled) { border-color: var(--border); background: var(--hover); }
740
  .actions button:disabled { opacity: 0.4; cursor: default; }
741
  .emote { width: 1.3em; height: 1.3em; object-fit: contain; vertical-align: middle; }
742
  .picker-pop { position: fixed; z-index: 60; width: 320px; max-width: calc(100vw - 16px); animation: pop-in var(--pop); }
743
</style>