atagen

malice

public
Star0
Watch0
338e0df549c888fe3789e41be74f009b3a2efa95
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/MessageRow.svelteSvelte585 lines23.0 KB
1
<script lang="ts">
2
  import MediaAttachmentView from './MediaAttachmentView.svelte';
3
  import MxcAvatar from './MxcAvatar.svelte';
4
  import Emoji from './Emoji.svelte';
5
  import EmojiText from './EmojiText.svelte';
6
  import MessageText from './MessageText.svelte';
7
  import ReactionPicker from './ReactionPicker.svelte';
8
  import LinkCard from './LinkCard.svelte';
9
  import { mediaUrl, messageMediaUrl } from './api';
10
  import type { ImagePack, LinkPreview, Person, QuickReaction, ReplyReference, SendState, TimelineContent, TimelineEvent } from './types';
11
  import { senderLabel } from './types';
12
  import { formatClock, formatDay, initialsOf, senderHue } from './format';
13
  import { findUrls } from './urls';
14
  import { mentionsFromHtml, resolveMentions, stripMarkdownPills } from './mentions';
15
16
  export let itemId: string;
17
  export let event: TimelineEvent;
18
  export let showSender: boolean;
19
  export let reacting: boolean;
20
  export let onReact: (eventId: string, key: string, shortcode?: string) => void;
21
  export let senderAvatar: string | null = null;
22
  export let quickReactions: QuickReaction[] = [];
23
  export let packs: ImagePack[] = [];
24
  export let onQuickChange: (list: QuickReaction[]) => void = () => {};
25
  export let linkPreviews: Record<string, LinkPreview | null> = {};
26
  export let loadPreview: ((url: string) => void) | null = null;
27
  export let people: Record<string, Person> = {};
28
  export let onReply: ((target: ReplyReference) => void) | null = null;
29
  export let onJump: ((eventId: string) => void) | null = null;
30
  export let onEdit: ((eventId: string, body: string) => void) | null = null;
31
  export let editing = false;
32
  export let showSent = true;
33
  export let self: string | null = null;
34
  export let onUser: ((userId: string, x: number, y: number, menu: boolean) => void) | null = null;
35
  export let onCloneSticker: ((eventId: string, body: string, x: number, y: number) => void) | null = null;
36
  export let onMore: ((event: TimelineEvent, x: number, y: number) => void) | null = null;
37
38
  function openUser(ev: MouseEvent, menu: boolean): void {
39
    if (!onUser) return;
40
    const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
41
    onUser(event.sender.user_id, menu ? ev.clientX : rect.left, menu ? ev.clientY : rect.bottom + 4, menu);
42
  }
43
44
  const PICKER_WIDTH = 320;
45
  const PICKER_HEIGHT = 380;
46
  const PICKER_MARGIN = 8;
47
  const MAX_READERS = 3;
48
49
  let row: HTMLDivElement;
50
  let pickerPop: HTMLDivElement | undefined;
51
  let selected = false;
52
  let pickerOpen = false;
53
  let pickerStyle = '';
54
55
  const MEMBERSHIP_LABELS: Record<string, string> = {
56
    Joined: 'joined the room',
57
    Left: 'left the room',
58
    Banned: 'was banned',
59
    Unbanned: 'was unbanned',
60
    Kicked: 'was kicked',
61
    KickedAndBanned: 'was kicked and banned',
62
    Invited: 'was invited',
63
    InvitationAccepted: 'accepted the invite',
64
    InvitationRejected: 'rejected the invite',
65
    InvitationRevoked: 'had the invite revoked',
66
    Knocked: 'requested to join',
67
    KnockAccepted: 'was let in',
68
    KnockRetracted: 'withdrew the request to join',
69
    KnockDenied: 'had the request to join denied'
70
  };
71
72
  $: name = senderLabel(event.sender);
73
  $: displayName = event.sender.name_ambiguous ? `${name} (${event.sender.user_id})` : name;
74
  $: readers = event.read_by.map((id) => people[id] ?? { user_id: id, name: id, avatar: null });
75
  $: interactive = event.content.kind === 'Message' || event.content.kind === 'Sticker' || event.content.kind === 'Poll';
76
  $: message = event.content.kind === 'Message' ? event.content.message : null;
77
  $: stripped = stripMarkdownPills(message ? (message.media ? message.media.caption : message.body) ?? '' : '');
78
  $: linked = message ? [...mentionsFromHtml(message.formatted_body), ...stripped.mentions] : [];
79
  $: mentions = resolveMentions(stripped.text, linked);
80
  $: mentionsMe = self !== null && [...linked, ...mentions].some((m) => m.user_id === self);
81
  $: previews = findUrls(stripped.text);
82
  $: bundled = message ? message.link_previews : [];
83
  $: cards = previews
84
    .map((link) => ({ link, preview: bundled.find((b) => b.url === link.href) ?? linkPreviews[link.href] ?? null }))
85
    .filter((c) => c.preview && (c.preview.title || c.preview.description || c.preview.image));
86
  $: if (loadPreview) {
87
    for (const link of previews) {
88
      if (!bundled.some((b) => b.url === link.href) && !(link.href in linkPreviews)) loadPreview(link.href);
89
    }
90
  }
91
92
  function sendLabel(state: SendState): string {
93
    if (state.kind === 'Failed') return state.will_retry ? 'failed — will retry' : 'failed — not delivered';
94
    if (state.kind === 'Sending') return 'sending…';
95
    if (showSent && event.own && state.kind === 'Sent' && event.event_id) return '✓ sent';
96
    return '';
97
  }
98
99
  function toggleSelected(e: MouseEvent) {
100
    if (!interactive || (e.target as Element).closest('a, button, input, video, audio, .picker-pop')) return;
101
    selected = !selected;
102
  }
103
104
  function deselectOutside(e: PointerEvent) {
105
    if (selected && !pickerOpen && !row.contains(e.target as Node)) selected = false;
106
  }
107
108
  function openPicker(e: MouseEvent) {
109
    if (pickerOpen) {
110
      pickerOpen = false;
111
      return;
112
    }
113
    const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
114
    const height = Math.min(PICKER_HEIGHT, window.innerHeight - 2 * PICKER_MARGIN);
115
    const below = window.innerHeight - rect.bottom - PICKER_MARGIN >= height;
116
    const top = below ? rect.bottom + 4 : Math.max(PICKER_MARGIN, rect.top - height - 4);
117
    const maxLeft = window.innerWidth - PICKER_WIDTH - PICKER_MARGIN;
118
    const left = Math.max(PICKER_MARGIN, Math.min(rect.right - PICKER_WIDTH, maxLeft));
119
    pickerStyle = `top:${top}px;left:${left}px;height:${height}px`;
120
    selected = true;
121
    pickerOpen = true;
122
  }
123
124
  function closePicker() {
125
    pickerOpen = false;
126
  }
127
128
  function isImageKey(key: string): boolean {
129
    return key.startsWith('mxc://') || packs.some((p) => p.images.some((i) => i.reaction_key === key));
130
  }
131
132
  function react(key: string, shortcode: string | null) {
133
    if (shortcode && isImageKey(key)) onReact(event.event_id ?? '', key, shortcode);
134
    else onReact(event.event_id ?? '', key);
135
  }
136
137
  function reply() {
138
    if (!event.event_id || !onReply) return;
139
    onReply({
140
      event_id: event.event_id,
141
      details: { kind: 'Ready', sender: event.sender, content: event.content, timestamp_ms: event.timestamp_ms }
142
    });
143
  }
144
145
  $: editableBody =
146
    event.own && event.editable && event.event_id && event.content.kind === 'Message' && event.content.message.kind === 'Text'
147
      ? event.content.message.body
148
      : null;
149
150
  function edit() {
151
    if (!event.event_id || editableBody === null || !onEdit) return;
152
    onEdit(event.event_id, editableBody);
153
  }
154
155
  function reactorNames(senders: string[]): string {
156
    const names = senders.map((id) => people[id]?.name ?? id);
157
    if (names.length <= 12) return names.join(', ');
158
    return `${names.slice(0, 12).join(', ')} and ${names.length - 12} more`;
159
  }
160
161
  function pick(key: string, shortcode: string | null) {
162
    pickerOpen = false;
163
    react(key, shortcode);
164
  }
165
166
  function closeOnScroll(e: Event) {
167
    if (pickerOpen && !pickerPop?.contains(e.target as Node)) pickerOpen = false;
168
  }
169
170
  function actorLabel(userId: string, name: string | null): string {
171
    return name ?? userId;
172
  }
173
174
  function replyPreview(content: TimelineContent): string {
175
    if (content.kind === 'Message') return stripMarkdownPills(content.message.body).text;
176
    if (content.kind === 'Sticker') return content.body || 'Sticker';
177
    if (content.kind === 'Poll') return `Poll: ${content.question}`;
178
    if (content.kind === 'Redacted') return 'Message deleted';
179
    if (content.kind === 'UnableToDecrypt') return 'Unable to decrypt';
180
    return systemLine(content);
181
  }
182
183
  function systemLine(content: TimelineContent): string {
184
    switch (content.kind) {
185
      case 'Redacted':
186
        return 'Message deleted';
187
      case 'UnableToDecrypt':
188
        return `Message from ${displayName} could not be decrypted`;
189
      case 'Membership': {
190
        const who = actorLabel(content.user_id, content.display_name);
191
        return `${who} ${MEMBERSHIP_LABELS[content.change] ?? 'updated their membership'}`;
192
      }
193
      case 'Profile': {
194
        const who = actorLabel(content.user_id, null);
195
        const parts: string[] = [];
196
        if (content.display_name) {
197
          parts.push(
198
            content.display_name.new
199
              ? `changed their display name to "${content.display_name.new}"`
200
              : 'removed their display name'
201
          );
202
        }
203
        if (content.avatar) parts.push('changed their avatar');
204
        return `${who} ${parts.length > 0 ? parts.join(' and ') : 'updated their profile'}`;
205
      }
206
      case 'RoomName':
207
        return content.name
208
          ? `${displayName} changed the room name to "${content.name}"`
209
          : `${displayName} removed the room name`;
210
      case 'RoomTopic':
211
        return content.topic
212
          ? `${displayName} changed the topic to "${content.topic}"`
213
          : `${displayName} removed the topic`;
214
      case 'RoomAvatar':
215
        return content.avatar ? `${displayName} changed the room avatar` : `${displayName} removed the room avatar`;
216
      case 'EncryptionEnabled':
217
        return `${displayName} enabled encryption in this room`;
218
      case 'RoomCreated':
219
        return `${displayName} created the room`;
220
      case 'RoomUpgraded':
221
        return `${displayName} upgraded the room to a new version`;
222
      case 'OtherState':
223
        return `${displayName} sent a state event (${content.event_type})`;
224
      case 'Other':
225
        return `${displayName} sent an event (${content.event_type})`;
226
      default:
227
        return displayName;
228
    }
229
  }
230
</script>
231
232
<svelte:window
233
  on:pointerdown={deselectOutside}
234
  on:keydown={(e) => e.key === 'Escape' && (selected = false)}
235
  on:scroll|capture={closeOnScroll}
236
  on:resize={closePicker}
237
/>
238
239
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
240
<div
241
  class="msg"
242
  class:own={event.own}
243
  class:editing
244
  class:mentioned={mentionsMe}
245
  class:system={!interactive}
246
  class:has-readers={interactive && readers.length > 0}
247
  class:selected
248
  data-item={itemId}
249
  data-event={event.event_id}
250
  bind:this={row}
251
  on:click={toggleSelected}
252
>
253
  {#if interactive && event.event_id}
254
    <div class="actions" role="toolbar" aria-label={`Actions for message from ${displayName}`}>
255
      {#each quickReactions as r (r.key)}
256
        {@const label = r.shortcode ? `:${r.shortcode}:` : r.key}
257
        <button
258
          type="button"
259
          title={`React ${label}`}
260
          aria-label={`React ${label} to message from ${displayName}`}
261
          disabled={reacting}
262
          on:click={() => react(r.key, r.shortcode)}
263
        >
264
          {#if r.key.startsWith('mxc://')}
265
            <img class="emote" src={mediaUrl(r.key) ?? ''} alt="" />
266
          {:else}
267
            <Emoji emoji={r.key} size={18} />
268
          {/if}
269
        </button>
270
      {/each}
271
      <button
272
        type="button"
273
        title="Add reaction"
274
        aria-label={`Add reaction to message from ${displayName}`}
275
        aria-haspopup="dialog"
276
        aria-expanded={pickerOpen}
277
        on:pointerdown|stopPropagation
278
        on:click={openPicker}
279
      >
280
        ☺+
281
      </button>
282
      {#if onCloneSticker && event.content.kind === 'Sticker'}
283
        {@const stickerBody = event.content.body}
284
        {@const stickerId = event.event_id}
285
        <button
286
          type="button"
287
          title="Clone sticker"
288
          aria-label="Clone sticker to one of your packs"
289
          on:click|stopPropagation={(ev) => onCloneSticker?.(stickerId, stickerBody, ev.clientX, ev.clientY)}
290
        >
291
          ⧉
292
        </button>
293
      {/if}
294
      {#if onReply}
295
        <button
296
          type="button"
297
          title="Reply"
298
          aria-label={`Reply to message from ${displayName}`}
299
          on:click|stopPropagation={reply}
300
        >
301
          ↩
302
        </button>
303
      {/if}
304
      {#if onEdit && editableBody !== null}
305
        <button type="button" title="Edit" aria-label="Edit your message" on:click|stopPropagation={edit}>
306
          ✎
307
        </button>
308
      {/if}
309
      {#if onMore}
310
        <button
311
          type="button"
312
          title="More"
313
          aria-label={`More actions for message from ${displayName}`}
314
          aria-haspopup="menu"
315
          on:click|stopPropagation={(ev) => onMore?.(event, ev.clientX, ev.clientY)}
316
        >
317
          ⋯
318
        </button>
319
      {/if}
320
    </div>
321
    {#if pickerOpen}
322
      <div class="picker-pop" style={pickerStyle} bind:this={pickerPop}>
323
        <ReactionPicker {packs} quick={quickReactions} onPick={pick} {onQuickChange} onClose={closePicker} />
324
      </div>
325
    {/if}
326
  {/if}
327
  {#if showSender}
328
    <button
329
      type="button"
330
      class="user-hit"
331
      tabindex="-1"
332
      aria-hidden="true"
333
      on:click|stopPropagation={(ev) => openUser(ev, false)}
334
      on:contextmenu|preventDefault|stopPropagation={(ev) => openUser(ev, true)}
335
    >
336
      {#if senderAvatar}
337
        <MxcAvatar mxc={senderAvatar} label={name} size={34} />
338
      {:else}
339
        <span class="avatar" style={`background:hsl(${senderHue(event.sender.user_id)} 35% 32%)`}>
340
          {initialsOf(name)}
341
        </span>
342
      {/if}
343
    </button>
344
  {:else}
345
    <div class="avatar-spacer" aria-hidden="true">
346
      <span class="hover-time">{formatClock(event.timestamp_ms)}</span>
347
    </div>
348
  {/if}
349
  <div class="msg-body">
350
    {#if showSender}
351
      <div class="msg-head">
352
        <button
353
          type="button"
354
          class="sender"
355
          style={`color:hsl(${senderHue(event.sender.user_id)} 70% var(--name-lightness))`}
356
          on:click|stopPropagation={(ev) => openUser(ev, false)}
357
          on:contextmenu|preventDefault|stopPropagation={(ev) => openUser(ev, true)}
358
        >
359
          {displayName}
360
        </button>
361
        <time class="time" datetime={new Date(event.timestamp_ms).toISOString()}>
362
          {formatDay(event.timestamp_ms)} {formatClock(event.timestamp_ms)}
363
        </time>
364
        {#if event.content.kind === 'Message' && event.content.message.edited}
365
          <span class="edited" title="Edited">(edited)</span>
366
        {/if}
367
      </div>
368
    {/if}
369
    {#if event.reply_to}
370
      {@const target = event.reply_to.event_id}
371
      <button
372
        type="button"
373
        class="reply-quote"
374
        disabled={!onJump}
375
        title="Jump to the original message"
376
        on:click|stopPropagation={() => onJump?.(target)}
377
      >
378
        {#if event.reply_to.details.kind === 'Ready'}
379
          <span class="reply-sender">{senderLabel(event.reply_to.details.sender)}</span>
380
          <span class="reply-text"><EmojiText text={replyPreview(event.reply_to.details.content)} /></span>
381
        {:else if event.reply_to.details.kind === 'Pending'}
382
          Loading reply…
383
        {:else}
384
          Reply unavailable
385
        {/if}
386
      </button>
387
    {/if}
388
389
    {#if event.content.kind === 'Message'}
390
      {@const m = event.content.message}
391
      {#if stripped.text}
392
        <div class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>
393
          {#if m.kind === 'Emote'}* {displayName} {/if}<MessageText text={stripped.text} html={m.formatted_body} jumbo={m.kind === 'Text'} {mentions} {self} nameOf={(id) => people[id]?.name ?? null} />
394
          {#if m.edited && !showSender}<span class="edited" title="Edited">(edited)</span>{/if}
395
        </div>
396
      {/if}
397
      {#if m.media}
398
        <MediaAttachmentView attachment={m.media} />
399
      {/if}
400
      {#if cards.length > 0}
401
        <div class="link-previews">
402
          {#each cards as card (card.link.href)}
403
            <LinkCard link={card.link} preview={card.preview} />
404
          {/each}
405
        </div>
406
      {/if}
407
    {:else if event.content.kind === 'Sticker'}
408
      {#if event.content.body}<p class="text"><EmojiText text={event.content.body} /></p>{/if}
409
      {#if event.content.media}
410
        <MediaAttachmentView attachment={event.content.media} />
411
      {/if}
412
    {:else if event.content.kind === 'Poll'}
413
      <p class="text">📊 {event.content.question}</p>
414
    {:else}
415
      <p class="system-line">{systemLine(event.content)}</p>
416
    {/if}
417
418
    {#if interactive && event.reactions.length > 0}
419
      <div class="reactions" aria-label="Reactions">
420
        {#each event.reactions as r (r.key)}
421
          <button
422
            type="button"
423
            class="reaction"
424
            class:mine={r.own}
425
            aria-describedby={`${itemId}-reactors-${r.key}`}
426
            disabled={!event.event_id || reacting}
427
            on:click={() => event.event_id && onReact(event.event_id, r.key)}
428
          >
429
            {#if r.image}
430
              <img class="emote" src={messageMediaUrl(r.image, 'Thumbnail')} alt={r.shortcode ?? ''} title={r.shortcode ?? ''} />
431
            {:else if r.key.startsWith('mxc://')}
432
              <img class="emote" src={mediaUrl(r.key) ?? ''} alt="" />
433
            {:else}
434
              <Emoji emoji={r.key} size={16} />
435
            {/if}
436
            <span class="count">{r.count}</span>
437
            <span class="reactors" role="tooltip" id={`${itemId}-reactors-${r.key}`}>
438
              <span class="reactors-key">{r.shortcode ? `:${r.shortcode}:` : r.key.startsWith('mxc://') ? 'custom emote' : r.key}</span>
439
              {reactorNames(r.senders)}
440
            </span>
441
          </button>
442
        {/each}
443
      </div>
444
    {/if}
445
    {#if interactive && sendLabel(event.send_state)}
446
      <div class="msg-foot">
447
        <span class="sendstate" class:failed={event.send_state.kind === 'Failed'}>{sendLabel(event.send_state)}</span>
448
      </div>
449
    {/if}
450
    {#if interactive && readers.length > 0}
451
      {@const seenBy = `Seen by ${readers.map((r) => r.name).join(', ')}`}
452
      <div class="readby" title={seenBy} aria-label={seenBy} role="img">
453
        {#each readers.slice(0, MAX_READERS) as r (r.user_id)}
454
          <MxcAvatar mxc={r.avatar} label={r.name} size={16} />
455
        {/each}
456
        {#if readers.length > MAX_READERS}<span class="more">+{readers.length - MAX_READERS}</span>{/if}
457
      </div>
458
    {/if}
459
  </div>
460
</div>
461
462
<style>
463
  .msg { position: relative; display: flex; gap: 10px; padding: var(--msg-gap, 3px) 0; }
464
  .msg:hover, .msg.selected { background: var(--hover-faint); }
465
  .msg:hover .hover-time, .msg.selected .hover-time { opacity: 1; }
466
  .msg-foot { display: flex; align-items: center; gap: 8px; }
467
  .sendstate { font-size: 11px; color: var(--muted); }
468
  .sendstate.failed { color: var(--danger); }
469
  .link-previews { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; max-width: 420px; }
470
  .avatar {
471
    flex: none;
472
    width: 34px; height: 34px;
473
    border-radius: 50%;
474
    display: grid; place-items: center;
475
    font-size: 12px; font-weight: 700; color: #fff;
476
  }
477
  .avatar-spacer { flex: none; width: 34px; padding-top: 2px; text-align: center; }
478
  .hover-time { opacity: 0; font-size: 10.5px; color: var(--muted); }
479
  .msg-body { min-width: 0; flex: 1; }
480
  .msg-head { display: flex; align-items: baseline; gap: 8px; }
481
  .sender { font-weight: 650; font-size: 13.5px; padding: 0; border: none; background: none; font-family: inherit; cursor: pointer; }
482
  .sender:hover { text-decoration: underline; }
483
  .user-hit { flex: none; align-self: flex-start; padding: 0; border: none; background: none; border-radius: 50%; cursor: pointer; }
484
  .time, .edited { color: var(--muted); font-size: 11.5px; }
485
  .text .edited { margin-left: 6px; }
486
  .msg.editing { background: color-mix(in srgb, var(--accent) 10%, transparent); }
487
  .msg.mentioned { background: color-mix(in srgb, var(--warn) 8%, transparent); box-shadow: inset 2px 0 0 var(--warn); }
488
  .reply-quote {
489
    display: flex; gap: 6px; align-items: baseline;
490
    max-width: min(100%, 520px);
491
    border: none;
492
    border-left: 2px solid var(--accent);
493
    padding: 2px 8px;
494
    margin: 2px 0 4px;
495
    color: var(--muted);
496
    font-size: 12.5px;
497
    text-align: left;
498
    background: var(--bg-soft);
499
    border-radius: 0 6px 6px 0;
500
    cursor: pointer;
501
  }
502
  .reply-quote:disabled { cursor: default; }
503
  .reply-quote:hover:not(:disabled) { color: var(--text); }
504
  .reply-sender { flex: none; font-weight: 650; }
505
  .reply-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
506
  :global(.msg.flash) { animation: reply-flash 1.2s ease-out; }
507
  @keyframes -global-reply-flash { from { background: color-mix(in srgb, var(--accent) 22%, transparent); } }
508
  .text {
509
    margin: 1px 0 2px;
510
    font-size: 14px;
511
    line-height: var(--msg-line-height, 1.5);
512
    white-space: pre-wrap;
513
    overflow-wrap: anywhere;
514
  }
515
  .text.emote { font-style: italic; color: var(--muted); }
516
  .text.notice { color: var(--muted); }
517
  .system-line { color: var(--muted); font-size: 12.5px; font-style: italic; margin: 1px 0; }
518
  .msg.system { opacity: 0.85; }
519
  .reactions { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
520
  .reaction {
521
    display: inline-flex; align-items: center; gap: 4px;
522
    background: var(--bg-soft);
523
    border: 1px solid var(--border);
524
    border-radius: 999px;
525
    padding: 1px 8px;
526
    font-size: 13px;
527
    color: var(--text);
528
    cursor: pointer;
529
  }
530
  .reaction { position: relative; }
531
  .reactors {
532
    position: absolute;
533
    left: 0;
534
    bottom: calc(100% + 6px);
535
    z-index: 15;
536
    display: none;
537
    width: max-content;
538
    max-width: 260px;
539
    padding: 6px 9px;
540
    border: 1px solid var(--border);
541
    border-radius: 8px;
542
    background: var(--panel-2);
543
    box-shadow: 0 6px 18px rgb(0 0 0 / 0.3);
544
    color: var(--text);
545
    font-size: 12px;
546
    line-height: 1.35;
547
    text-align: left;
548
    white-space: normal;
549
    pointer-events: none;
550
  }
551
  .reactors-key { display: block; color: var(--muted); font-size: 11px; }
552
  .reaction:hover .reactors, .reaction:focus-visible .reactors { display: block; }
553
  .reaction.mine { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); }
554
  .reaction:disabled { cursor: default; opacity: 0.85; }
555
  .msg.has-readers .msg-body { padding-right: 72px; }
556
  .readby { position: absolute; right: 12px; bottom: 4px; display: flex; align-items: center; }
557
  .readby > :global(* + *) { margin-left: -4px; }
558
  .readby :global(.mxc-avatar), .readby :global(.mxc-fallback) { box-shadow: 0 0 0 1.5px var(--bg); }
559
  .readby .more { margin-left: 3px; font-size: 10.5px; color: var(--muted); }
560
  .actions {
561
    position: absolute; top: -14px; right: 12px; z-index: 2;
562
    display: flex; gap: 2px;
563
    padding: 2px;
564
    background: var(--bg-soft);
565
    border: 1px solid var(--border);
566
    border-radius: 8px;
567
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
568
    opacity: 0;
569
    pointer-events: none;
570
  }
571
  .msg:hover .actions, .msg:focus-within .actions, .msg.selected .actions { opacity: 1; pointer-events: auto; }
572
  :global(.timeline-inner:has(.msg.selected)) .msg:not(.selected) .actions { opacity: 0; pointer-events: none; }
573
  .actions button {
574
    background: transparent;
575
    border: 1px solid transparent;
576
    border-radius: 6px;
577
    cursor: pointer;
578
    font-size: 14px;
579
    padding: 1px 5px;
580
  }
581
  .actions button:hover:not(:disabled) { border-color: var(--border); background: var(--hover); }
582
  .actions button:disabled { opacity: 0.4; cursor: default; }
583
  .emote { width: 1.3em; height: 1.3em; object-fit: contain; vertical-align: middle; }
584
  .picker-pop { position: fixed; z-index: 60; width: 320px; max-width: calc(100vw - 16px); }
585
</style>