atagen

malice

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