atagen

malice

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