atagen

malice

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