atagen

malice

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