atagen

malice

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