atagen

malice

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