atagen

malice

public
Star0
Watch0
dffd63b58af439639de9f8e155d0365d83605a8b
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/MessageRow.svelteSvelte306 lines11.7 KB
1
<script lang="ts">
2
  import MediaAttachmentView from './MediaAttachmentView.svelte';
3
  import MxcAvatar from './MxcAvatar.svelte';
4
  import type { SendState, TimelineContent, TimelineEvent } from './types';
5
  import { senderLabel } from './types';
6
  import { formatClock, formatDay, initialsOf, senderHue } from './format';
7
  import { findUrls } from './urls';
8
9
  export let itemId: string;
10
  export let event: TimelineEvent;
11
  export let showSender: boolean;
12
  export let reacting: boolean;
13
  export let onReact: (eventId: string, key: string) => void;
14
  export let senderAvatar: string | null = null;
15
16
  const QUICK_KEYS = ['👍', '❤️', '😂', '🎉', '😮', '😢'];
17
18
  const MEMBERSHIP_LABELS: Record<string, string> = {
19
    Joined: 'joined the room',
20
    Left: 'left the room',
21
    Banned: 'was banned',
22
    Unbanned: 'was unbanned',
23
    Kicked: 'was kicked',
24
    KickedAndBanned: 'was kicked and banned',
25
    Invited: 'was invited',
26
    InvitationAccepted: 'accepted the invite',
27
    InvitationRejected: 'rejected the invite',
28
    InvitationRevoked: 'had the invite revoked',
29
    Knocked: 'requested to join',
30
    KnockAccepted: 'was let in',
31
    KnockRetracted: 'withdrew the request to join',
32
    KnockDenied: 'had the request to join denied'
33
  };
34
35
  $: name = senderLabel(event.sender);
36
  $: displayName = event.sender.name_ambiguous ? `${name} (${event.sender.user_id})` : name;
37
  $: interactive = event.content.kind === 'Message' || event.content.kind === 'Sticker' || event.content.kind === 'Poll';
38
  $: previews = event.content.kind === 'Message' ? findUrls(event.content.message.body) : [];
39
40
  function sendLabel(state: SendState): string {
41
    if (state.kind === 'Failed') return state.will_retry ? 'failed — will retry' : 'failed — not delivered';
42
    if (state.kind === 'Sending') return 'sending…';
43
    if (event.own && state.kind === 'Sent' && event.event_id) return '✓ sent';
44
    return '';
45
  }
46
47
  function actorLabel(userId: string, name: string | null): string {
48
    return name ?? userId;
49
  }
50
51
  function replyPreview(content: TimelineContent): string {
52
    if (content.kind === 'Message') return content.message.body;
53
    if (content.kind === 'Sticker') return content.body || 'Sticker';
54
    if (content.kind === 'Poll') return `Poll: ${content.question}`;
55
    if (content.kind === 'Redacted') return 'Message deleted';
56
    if (content.kind === 'UnableToDecrypt') return 'Unable to decrypt';
57
    return systemLine(content);
58
  }
59
60
  function systemLine(content: TimelineContent): string {
61
    switch (content.kind) {
62
      case 'Redacted':
63
        return 'Message deleted';
64
      case 'UnableToDecrypt':
65
        return `Message from ${displayName} could not be decrypted`;
66
      case 'Membership': {
67
        const who = actorLabel(content.user_id, content.display_name);
68
        return `${who} ${MEMBERSHIP_LABELS[content.change] ?? 'updated their membership'}`;
69
      }
70
      case 'Profile': {
71
        const who = actorLabel(content.user_id, null);
72
        const parts: string[] = [];
73
        if (content.display_name) {
74
          parts.push(
75
            content.display_name.new
76
              ? `changed their display name to "${content.display_name.new}"`
77
              : 'removed their display name'
78
          );
79
        }
80
        if (content.avatar) parts.push('changed their avatar');
81
        return `${who} ${parts.length > 0 ? parts.join(' and ') : 'updated their profile'}`;
82
      }
83
      case 'RoomName':
84
        return content.name
85
          ? `${displayName} changed the room name to "${content.name}"`
86
          : `${displayName} removed the room name`;
87
      case 'RoomTopic':
88
        return content.topic
89
          ? `${displayName} changed the topic to "${content.topic}"`
90
          : `${displayName} removed the topic`;
91
      case 'RoomAvatar':
92
        return content.avatar ? `${displayName} changed the room avatar` : `${displayName} removed the room avatar`;
93
      case 'EncryptionEnabled':
94
        return `${displayName} enabled encryption in this room`;
95
      case 'RoomCreated':
96
        return `${displayName} created the room`;
97
      case 'RoomUpgraded':
98
        return `${displayName} upgraded the room to a new version`;
99
      case 'OtherState':
100
        return `${displayName} sent a state event (${content.event_type})`;
101
      case 'Other':
102
        return `${displayName} sent an event (${content.event_type})`;
103
      default:
104
        return displayName;
105
    }
106
  }
107
</script>
108
109
<div class="msg" class:own={event.own} class:system={!interactive} data-item={itemId}>
110
  {#if showSender}
111
    {#if senderAvatar}
112
      <MxcAvatar mxc={senderAvatar} label={name} size={34} />
113
    {:else}
114
      <div
115
        class="avatar"
116
        aria-hidden="true"
117
        style={`background:hsl(${senderHue(event.sender.user_id)} 35% 32%)`}
118
      >
119
        {initialsOf(name)}
120
      </div>
121
    {/if}
122
  {:else}
123
    <div class="avatar-spacer" aria-hidden="true">
124
      <span class="hover-time">{formatClock(event.timestamp_ms)}</span>
125
    </div>
126
  {/if}
127
  <div class="msg-body">
128
    {#if showSender}
129
      <div class="msg-head">
130
        <span class="sender" style={`color:hsl(${senderHue(event.sender.user_id)} 70% 72%)`}>
131
          {displayName}
132
        </span>
133
        <time class="time" datetime={new Date(event.timestamp_ms).toISOString()}>
134
          {formatDay(event.timestamp_ms)} {formatClock(event.timestamp_ms)}
135
        </time>
136
        {#if event.content.kind === 'Message' && event.content.message.edited}
137
          <span class="edited" title="Edited">(edited)</span>
138
        {/if}
139
      </div>
140
    {/if}
141
    {#if event.reply_to}
142
      <div class="reply-quote">
143
        {#if event.reply_to.details.kind === 'Ready'}
144
          Replying to {senderLabel(event.reply_to.details.sender)}: {replyPreview(event.reply_to.details.content)}
145
        {:else if event.reply_to.details.kind === 'Pending'}
146
          Loading reply…
147
        {:else}
148
          Reply unavailable
149
        {/if}
150
      </div>
151
    {/if}
152
153
    {#if event.content.kind === 'Message'}
154
      {@const m = event.content.message}
155
      <p class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>
156
        {#if m.kind === 'Emote'}* {displayName} {/if}{m.body}
157
      </p>
158
      {#if m.media}
159
        <MediaAttachmentView attachment={m.media} />
160
      {/if}
161
      {#if previews.length > 0}
162
        <div class="link-previews">
163
          {#each previews as preview (preview.href)}
164
            <a class="link-card" href={preview.href} target="_blank" rel="noopener noreferrer">
165
              <span class="link-host">{preview.hostname}</span>
166
              {#if preview.path}<span class="link-path">{preview.path}</span>{/if}
167
              <span class="link-url">{preview.display}</span>
168
            </a>
169
          {/each}
170
        </div>
171
      {/if}
172
    {:else if event.content.kind === 'Sticker'}
173
      {#if event.content.body}<p class="text">{event.content.body}</p>{/if}
174
      {#if event.content.media}
175
        <MediaAttachmentView attachment={event.content.media} />
176
      {/if}
177
    {:else if event.content.kind === 'Poll'}
178
      <p class="text">📊 {event.content.question}</p>
179
    {:else}
180
      <p class="system-line">{systemLine(event.content)}</p>
181
    {/if}
182
183
    {#if interactive && event.reactions.length > 0}
184
      <div class="reactions" aria-label="Reactions">
185
        {#each event.reactions as r (r.key)}
186
          <button
187
            type="button"
188
            class="reaction"
189
            class:mine={r.own}
190
            title={r.own ? 'You reacted — activate to remove' : `${r.count} reaction${r.count === 1 ? '' : 's'}`}
191
            disabled={!event.event_id || reacting}
192
            on:click={() => event.event_id && onReact(event.event_id, r.key)}
193
          >
194
            <span aria-hidden="true">{r.key}</span>
195
            <span class="count">{r.count}</span>
196
          </button>
197
        {/each}
198
      </div>
199
    {/if}
200
    {#if interactive}
201
      <div class="msg-foot">
202
        {#if sendLabel(event.send_state)}
203
          <span class="sendstate" class:failed={event.send_state.kind === 'Failed'}>{sendLabel(event.send_state)}</span>
204
        {/if}
205
        {#if event.read_by.length > 0}
206
          <span class="readby" title={event.read_by.join(', ')}>✓✓ {event.read_by.length}</span>
207
        {/if}
208
        {#if event.event_id}
209
          <span class="quick-react" role="group" aria-label={`React to message from ${displayName}`}>
210
            {#each QUICK_KEYS as key (key)}
211
              <button
212
                type="button"
213
                title={`React ${key}`}
214
                aria-label={`React ${key} to message from ${displayName}`}
215
                disabled={reacting}
216
                on:click={() => onReact(event.event_id ?? '', key)}
217
              >
218
                {key}
219
              </button>
220
            {/each}
221
          </span>
222
        {/if}
223
      </div>
224
    {/if}
225
  </div>
226
</div>
227
228
<style>
229
  .msg { display: flex; gap: 10px; padding: 3px 0; }
230
  .msg:hover { background: rgba(255, 255, 255, 0.018); }
231
  .msg:hover .hover-time { opacity: 1; }
232
  .msg-foot { display: flex; align-items: center; gap: 8px; min-height: 18px; }
233
  .sendstate { font-size: 11px; color: var(--muted); }
234
  .sendstate.failed { color: var(--danger); }
235
  .link-previews { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; max-width: 420px; }
236
  .link-card {
237
    display: flex; flex-direction: column; gap: 1px;
238
    border: 1px solid var(--border); border-radius: 8px;
239
    background: var(--bg-soft); padding: 8px 10px;
240
    text-decoration: none; color: var(--text);
241
  }
242
  .link-card:hover { border-color: var(--accent); }
243
  .link-host { font-size: 12.5px; font-weight: 650; }
244
  .link-path { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
245
  .link-url { font-size: 11.5px; color: var(--accent); overflow-wrap: anywhere; }
246
  .avatar {
247
    flex: none;
248
    width: 34px; height: 34px;
249
    border-radius: 50%;
250
    display: grid; place-items: center;
251
    font-size: 12px; font-weight: 700; color: #fff;
252
  }
253
  .avatar-spacer { flex: none; width: 34px; padding-top: 2px; text-align: center; }
254
  .hover-time { opacity: 0; font-size: 10.5px; color: var(--muted); }
255
  .msg-body { min-width: 0; flex: 1; }
256
  .msg-head { display: flex; align-items: baseline; gap: 8px; }
257
  .sender { font-weight: 650; font-size: 13.5px; }
258
  .time, .edited { color: var(--muted); font-size: 11.5px; }
259
  .reply-quote {
260
    border-left: 2px solid var(--accent);
261
    padding: 2px 8px;
262
    margin: 2px 0 4px;
263
    color: var(--muted);
264
    font-size: 12.5px;
265
    background: var(--bg-soft);
266
    border-radius: 0 6px 6px 0;
267
  }
268
  .text {
269
    margin: 1px 0 2px;
270
    font-size: 14px;
271
    line-height: 1.5;
272
    white-space: pre-wrap;
273
    overflow-wrap: anywhere;
274
  }
275
  .text.emote { font-style: italic; color: var(--muted); }
276
  .text.notice { color: var(--muted); }
277
  .system-line { color: var(--muted); font-size: 12.5px; font-style: italic; margin: 1px 0; }
278
  .msg.system { opacity: 0.85; }
279
  .reactions { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
280
  .reaction {
281
    display: inline-flex; align-items: center; gap: 4px;
282
    background: var(--bg-soft);
283
    border: 1px solid var(--border);
284
    border-radius: 999px;
285
    padding: 1px 8px;
286
    font-size: 13px;
287
    color: var(--text);
288
    cursor: pointer;
289
  }
290
  .reaction.mine { border-color: var(--accent); background: rgba(88, 101, 242, 0.16); }
291
  .reaction:disabled { cursor: default; opacity: 0.85; }
292
  .msg-foot { display: flex; align-items: center; gap: 8px; min-height: 18px; }
293
  .readby { font-size: 11px; color: var(--muted); }
294
  .msg:hover .quick-react, .msg:focus-within .quick-react { display: inline-flex; }
295
  .quick-react button {
296
    background: transparent;
297
    border: 1px solid transparent;
298
    border-radius: 6px;
299
    cursor: pointer;
300
    font-size: 13px;
301
    padding: 0 3px;
302
    opacity: 0.75;
303
  }
304
  .quick-react button:hover:not(:disabled) { opacity: 1; border-color: var(--border); background: var(--bg-soft); }
305
  .quick-react button:disabled { opacity: 0.4; cursor: default; }
306
</style>