atagen

malice

public
Star0
Watch0
b8ab00377c2069c60530e13a2a47a6d4b1fed0e6
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: web/src/lib/MessageRow.svelte

973f8fbdaf80atagen2026-09-28 09:10 UTC1
<script lang="ts">
973f8fbdaf80atagen2026-09-28 09:10 UTC2
  import MediaAttachmentView from './MediaAttachmentView.svelte';
973f8fbdaf80atagen2026-09-28 09:10 UTC3
  import MxcAvatar from './MxcAvatar.svelte';
544a170ead2catagen2026-10-20 11:47 UTC4
  import Emoji from './Emoji.svelte';
544a170ead2catagen2026-10-20 11:47 UTC5
  import EmojiText from './EmojiText.svelte';
544a170ead2catagen2026-10-20 11:47 UTC6
  import ReactionPicker from './ReactionPicker.svelte';
b8ab00377c20atagen2026-10-22 05:13 UTC7
  import LinkCard from './LinkCard.svelte';
544a170ead2catagen2026-10-20 11:47 UTC8
  import { mediaUrl } from './api';
b8ab00377c20atagen2026-10-22 05:13 UTC9
  import type { ImagePack, LinkPreview, QuickReaction, SendState, TimelineContent, TimelineEvent } from './types';
973f8fbdaf80atagen2026-09-28 09:10 UTC10
  import { senderLabel } from './types';
973f8fbdaf80atagen2026-09-28 09:10 UTC11
  import { formatClock, formatDay, initialsOf, senderHue } from './format';
973f8fbdaf80atagen2026-09-28 09:10 UTC12
  import { findUrls } from './urls';
973f8fbdaf80atagen2026-09-28 09:10 UTC13
973f8fbdaf80atagen2026-09-28 09:10 UTC14
  export let itemId: string;
973f8fbdaf80atagen2026-09-28 09:10 UTC15
  export let event: TimelineEvent;
973f8fbdaf80atagen2026-09-28 09:10 UTC16
  export let showSender: boolean;
973f8fbdaf80atagen2026-09-28 09:10 UTC17
  export let reacting: boolean;
973f8fbdaf80atagen2026-09-28 09:10 UTC18
  export let onReact: (eventId: string, key: string) => void;
973f8fbdaf80atagen2026-09-28 09:10 UTC19
  export let senderAvatar: string | null = null;
544a170ead2catagen2026-10-20 11:47 UTC20
  export let quickReactions: QuickReaction[] = [];
544a170ead2catagen2026-10-20 11:47 UTC21
  export let packs: ImagePack[] = [];
544a170ead2catagen2026-10-20 11:47 UTC22
  export let onQuickChange: (list: QuickReaction[]) => void = () => {};
b8ab00377c20atagen2026-10-22 05:13 UTC23
  export let linkPreviews: Record<string, LinkPreview> = {};
973f8fbdaf80atagen2026-09-28 09:10 UTC24
544a170ead2catagen2026-10-20 11:47 UTC25
  const PICKER_WIDTH = 320;
544a170ead2catagen2026-10-20 11:47 UTC26
  const PICKER_HEIGHT = 380;
544a170ead2catagen2026-10-20 11:47 UTC27
  const PICKER_MARGIN = 8;
544a170ead2catagen2026-10-20 11:47 UTC28
544a170ead2catagen2026-10-20 11:47 UTC29
  let row: HTMLDivElement;
544a170ead2catagen2026-10-20 11:47 UTC30
  let pickerPop: HTMLDivElement | undefined;
544a170ead2catagen2026-10-20 11:47 UTC31
  let selected = false;
544a170ead2catagen2026-10-20 11:47 UTC32
  let pickerOpen = false;
544a170ead2catagen2026-10-20 11:47 UTC33
  let pickerStyle = '';
973f8fbdaf80atagen2026-09-28 09:10 UTC34
973f8fbdaf80atagen2026-09-28 09:10 UTC35
  const MEMBERSHIP_LABELS: Record<string, string> = {
973f8fbdaf80atagen2026-09-28 09:10 UTC36
    Joined: 'joined the room',
973f8fbdaf80atagen2026-09-28 09:10 UTC37
    Left: 'left the room',
973f8fbdaf80atagen2026-09-28 09:10 UTC38
    Banned: 'was banned',
973f8fbdaf80atagen2026-09-28 09:10 UTC39
    Unbanned: 'was unbanned',
973f8fbdaf80atagen2026-09-28 09:10 UTC40
    Kicked: 'was kicked',
973f8fbdaf80atagen2026-09-28 09:10 UTC41
    KickedAndBanned: 'was kicked and banned',
973f8fbdaf80atagen2026-09-28 09:10 UTC42
    Invited: 'was invited',
973f8fbdaf80atagen2026-09-28 09:10 UTC43
    InvitationAccepted: 'accepted the invite',
973f8fbdaf80atagen2026-09-28 09:10 UTC44
    InvitationRejected: 'rejected the invite',
973f8fbdaf80atagen2026-09-28 09:10 UTC45
    InvitationRevoked: 'had the invite revoked',
973f8fbdaf80atagen2026-09-28 09:10 UTC46
    Knocked: 'requested to join',
973f8fbdaf80atagen2026-09-28 09:10 UTC47
    KnockAccepted: 'was let in',
973f8fbdaf80atagen2026-09-28 09:10 UTC48
    KnockRetracted: 'withdrew the request to join',
973f8fbdaf80atagen2026-09-28 09:10 UTC49
    KnockDenied: 'had the request to join denied'
973f8fbdaf80atagen2026-09-28 09:10 UTC50
  };
973f8fbdaf80atagen2026-09-28 09:10 UTC51
973f8fbdaf80atagen2026-09-28 09:10 UTC52
  $: name = senderLabel(event.sender);
973f8fbdaf80atagen2026-09-28 09:10 UTC53
  $: displayName = event.sender.name_ambiguous ? `${name} (${event.sender.user_id})` : name;
973f8fbdaf80atagen2026-09-28 09:10 UTC54
  $: interactive = event.content.kind === 'Message' || event.content.kind === 'Sticker' || event.content.kind === 'Poll';
973f8fbdaf80atagen2026-09-28 09:10 UTC55
  $: previews = event.content.kind === 'Message' ? findUrls(event.content.message.body) : [];
973f8fbdaf80atagen2026-09-28 09:10 UTC56
973f8fbdaf80atagen2026-09-28 09:10 UTC57
  function sendLabel(state: SendState): string {
973f8fbdaf80atagen2026-09-28 09:10 UTC58
    if (state.kind === 'Failed') return state.will_retry ? 'failed — will retry' : 'failed — not delivered';
973f8fbdaf80atagen2026-09-28 09:10 UTC59
    if (state.kind === 'Sending') return 'sending…';
973f8fbdaf80atagen2026-09-28 09:10 UTC60
    if (event.own && state.kind === 'Sent' && event.event_id) return '✓ sent';
973f8fbdaf80atagen2026-09-28 09:10 UTC61
    return '';
973f8fbdaf80atagen2026-09-28 09:10 UTC62
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC63
544a170ead2catagen2026-10-20 11:47 UTC64
  function toggleSelected(e: MouseEvent) {
544a170ead2catagen2026-10-20 11:47 UTC65
    if (!interactive || (e.target as Element).closest('a, button, input, video, audio, .picker-pop')) return;
544a170ead2catagen2026-10-20 11:47 UTC66
    selected = !selected;
544a170ead2catagen2026-10-20 11:47 UTC67
  }
544a170ead2catagen2026-10-20 11:47 UTC68
544a170ead2catagen2026-10-20 11:47 UTC69
  function deselectOutside(e: PointerEvent) {
544a170ead2catagen2026-10-20 11:47 UTC70
    if (selected && !pickerOpen && !row.contains(e.target as Node)) selected = false;
544a170ead2catagen2026-10-20 11:47 UTC71
  }
544a170ead2catagen2026-10-20 11:47 UTC72
544a170ead2catagen2026-10-20 11:47 UTC73
  function openPicker(e: MouseEvent) {
544a170ead2catagen2026-10-20 11:47 UTC74
    if (pickerOpen) {
544a170ead2catagen2026-10-20 11:47 UTC75
      pickerOpen = false;
544a170ead2catagen2026-10-20 11:47 UTC76
      return;
544a170ead2catagen2026-10-20 11:47 UTC77
    }
544a170ead2catagen2026-10-20 11:47 UTC78
    const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
544a170ead2catagen2026-10-20 11:47 UTC79
    const height = Math.min(PICKER_HEIGHT, window.innerHeight - 2 * PICKER_MARGIN);
544a170ead2catagen2026-10-20 11:47 UTC80
    const below = window.innerHeight - rect.bottom - PICKER_MARGIN >= height;
544a170ead2catagen2026-10-20 11:47 UTC81
    const top = below ? rect.bottom + 4 : Math.max(PICKER_MARGIN, rect.top - height - 4);
544a170ead2catagen2026-10-20 11:47 UTC82
    const maxLeft = window.innerWidth - PICKER_WIDTH - PICKER_MARGIN;
544a170ead2catagen2026-10-20 11:47 UTC83
    const left = Math.max(PICKER_MARGIN, Math.min(rect.right - PICKER_WIDTH, maxLeft));
544a170ead2catagen2026-10-20 11:47 UTC84
    pickerStyle = `top:${top}px;left:${left}px;height:${height}px`;
544a170ead2catagen2026-10-20 11:47 UTC85
    selected = true;
544a170ead2catagen2026-10-20 11:47 UTC86
    pickerOpen = true;
544a170ead2catagen2026-10-20 11:47 UTC87
  }
544a170ead2catagen2026-10-20 11:47 UTC88
544a170ead2catagen2026-10-20 11:47 UTC89
  function closePicker() {
544a170ead2catagen2026-10-20 11:47 UTC90
    pickerOpen = false;
544a170ead2catagen2026-10-20 11:47 UTC91
  }
544a170ead2catagen2026-10-20 11:47 UTC92
544a170ead2catagen2026-10-20 11:47 UTC93
  function pick(key: string) {
544a170ead2catagen2026-10-20 11:47 UTC94
    pickerOpen = false;
544a170ead2catagen2026-10-20 11:47 UTC95
    onReact(event.event_id ?? '', key);
544a170ead2catagen2026-10-20 11:47 UTC96
  }
544a170ead2catagen2026-10-20 11:47 UTC97
544a170ead2catagen2026-10-20 11:47 UTC98
  function closeOnScroll(e: Event) {
544a170ead2catagen2026-10-20 11:47 UTC99
    if (pickerOpen && !pickerPop?.contains(e.target as Node)) pickerOpen = false;
544a170ead2catagen2026-10-20 11:47 UTC100
  }
544a170ead2catagen2026-10-20 11:47 UTC101
973f8fbdaf80atagen2026-09-28 09:10 UTC102
  function actorLabel(userId: string, name: string | null): string {
973f8fbdaf80atagen2026-09-28 09:10 UTC103
    return name ?? userId;
973f8fbdaf80atagen2026-09-28 09:10 UTC104
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC105
973f8fbdaf80atagen2026-09-28 09:10 UTC106
  function replyPreview(content: TimelineContent): string {
973f8fbdaf80atagen2026-09-28 09:10 UTC107
    if (content.kind === 'Message') return content.message.body;
973f8fbdaf80atagen2026-09-28 09:10 UTC108
    if (content.kind === 'Sticker') return content.body || 'Sticker';
973f8fbdaf80atagen2026-09-28 09:10 UTC109
    if (content.kind === 'Poll') return `Poll: ${content.question}`;
973f8fbdaf80atagen2026-09-28 09:10 UTC110
    if (content.kind === 'Redacted') return 'Message deleted';
973f8fbdaf80atagen2026-09-28 09:10 UTC111
    if (content.kind === 'UnableToDecrypt') return 'Unable to decrypt';
973f8fbdaf80atagen2026-09-28 09:10 UTC112
    return systemLine(content);
973f8fbdaf80atagen2026-09-28 09:10 UTC113
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC114
973f8fbdaf80atagen2026-09-28 09:10 UTC115
  function systemLine(content: TimelineContent): string {
973f8fbdaf80atagen2026-09-28 09:10 UTC116
    switch (content.kind) {
973f8fbdaf80atagen2026-09-28 09:10 UTC117
      case 'Redacted':
973f8fbdaf80atagen2026-09-28 09:10 UTC118
        return 'Message deleted';
973f8fbdaf80atagen2026-09-28 09:10 UTC119
      case 'UnableToDecrypt':
973f8fbdaf80atagen2026-09-28 09:10 UTC120
        return `Message from ${displayName} could not be decrypted`;
973f8fbdaf80atagen2026-09-28 09:10 UTC121
      case 'Membership': {
973f8fbdaf80atagen2026-09-28 09:10 UTC122
        const who = actorLabel(content.user_id, content.display_name);
973f8fbdaf80atagen2026-09-28 09:10 UTC123
        return `${who} ${MEMBERSHIP_LABELS[content.change] ?? 'updated their membership'}`;
973f8fbdaf80atagen2026-09-28 09:10 UTC124
      }
973f8fbdaf80atagen2026-09-28 09:10 UTC125
      case 'Profile': {
973f8fbdaf80atagen2026-09-28 09:10 UTC126
        const who = actorLabel(content.user_id, null);
973f8fbdaf80atagen2026-09-28 09:10 UTC127
        const parts: string[] = [];
973f8fbdaf80atagen2026-09-28 09:10 UTC128
        if (content.display_name) {
973f8fbdaf80atagen2026-09-28 09:10 UTC129
          parts.push(
973f8fbdaf80atagen2026-09-28 09:10 UTC130
            content.display_name.new
973f8fbdaf80atagen2026-09-28 09:10 UTC131
              ? `changed their display name to "${content.display_name.new}"`
973f8fbdaf80atagen2026-09-28 09:10 UTC132
              : 'removed their display name'
973f8fbdaf80atagen2026-09-28 09:10 UTC133
          );
973f8fbdaf80atagen2026-09-28 09:10 UTC134
        }
973f8fbdaf80atagen2026-09-28 09:10 UTC135
        if (content.avatar) parts.push('changed their avatar');
973f8fbdaf80atagen2026-09-28 09:10 UTC136
        return `${who} ${parts.length > 0 ? parts.join(' and ') : 'updated their profile'}`;
973f8fbdaf80atagen2026-09-28 09:10 UTC137
      }
973f8fbdaf80atagen2026-09-28 09:10 UTC138
      case 'RoomName':
973f8fbdaf80atagen2026-09-28 09:10 UTC139
        return content.name
973f8fbdaf80atagen2026-09-28 09:10 UTC140
          ? `${displayName} changed the room name to "${content.name}"`
973f8fbdaf80atagen2026-09-28 09:10 UTC141
          : `${displayName} removed the room name`;
973f8fbdaf80atagen2026-09-28 09:10 UTC142
      case 'RoomTopic':
973f8fbdaf80atagen2026-09-28 09:10 UTC143
        return content.topic
973f8fbdaf80atagen2026-09-28 09:10 UTC144
          ? `${displayName} changed the topic to "${content.topic}"`
973f8fbdaf80atagen2026-09-28 09:10 UTC145
          : `${displayName} removed the topic`;
973f8fbdaf80atagen2026-09-28 09:10 UTC146
      case 'RoomAvatar':
973f8fbdaf80atagen2026-09-28 09:10 UTC147
        return content.avatar ? `${displayName} changed the room avatar` : `${displayName} removed the room avatar`;
973f8fbdaf80atagen2026-09-28 09:10 UTC148
      case 'EncryptionEnabled':
973f8fbdaf80atagen2026-09-28 09:10 UTC149
        return `${displayName} enabled encryption in this room`;
973f8fbdaf80atagen2026-09-28 09:10 UTC150
      case 'RoomCreated':
973f8fbdaf80atagen2026-09-28 09:10 UTC151
        return `${displayName} created the room`;
973f8fbdaf80atagen2026-09-28 09:10 UTC152
      case 'RoomUpgraded':
973f8fbdaf80atagen2026-09-28 09:10 UTC153
        return `${displayName} upgraded the room to a new version`;
973f8fbdaf80atagen2026-09-28 09:10 UTC154
      case 'OtherState':
973f8fbdaf80atagen2026-09-28 09:10 UTC155
        return `${displayName} sent a state event (${content.event_type})`;
973f8fbdaf80atagen2026-09-28 09:10 UTC156
      case 'Other':
973f8fbdaf80atagen2026-09-28 09:10 UTC157
        return `${displayName} sent an event (${content.event_type})`;
973f8fbdaf80atagen2026-09-28 09:10 UTC158
      default:
973f8fbdaf80atagen2026-09-28 09:10 UTC159
        return displayName;
973f8fbdaf80atagen2026-09-28 09:10 UTC160
    }
973f8fbdaf80atagen2026-09-28 09:10 UTC161
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC162
</script>
973f8fbdaf80atagen2026-09-28 09:10 UTC163
544a170ead2catagen2026-10-20 11:47 UTC164
<svelte:window
544a170ead2catagen2026-10-20 11:47 UTC165
  on:pointerdown={deselectOutside}
544a170ead2catagen2026-10-20 11:47 UTC166
  on:keydown={(e) => e.key === 'Escape' && (selected = false)}
544a170ead2catagen2026-10-20 11:47 UTC167
  on:scroll|capture={closeOnScroll}
544a170ead2catagen2026-10-20 11:47 UTC168
  on:resize={closePicker}
544a170ead2catagen2026-10-20 11:47 UTC169
/>
544a170ead2catagen2026-10-20 11:47 UTC170
544a170ead2catagen2026-10-20 11:47 UTC171
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
544a170ead2catagen2026-10-20 11:47 UTC172
<div
544a170ead2catagen2026-10-20 11:47 UTC173
  class="msg"
544a170ead2catagen2026-10-20 11:47 UTC174
  class:own={event.own}
544a170ead2catagen2026-10-20 11:47 UTC175
  class:system={!interactive}
544a170ead2catagen2026-10-20 11:47 UTC176
  class:selected
544a170ead2catagen2026-10-20 11:47 UTC177
  data-item={itemId}
544a170ead2catagen2026-10-20 11:47 UTC178
  bind:this={row}
544a170ead2catagen2026-10-20 11:47 UTC179
  on:click={toggleSelected}
544a170ead2catagen2026-10-20 11:47 UTC180
>
544a170ead2catagen2026-10-20 11:47 UTC181
  {#if interactive && event.event_id}
544a170ead2catagen2026-10-20 11:47 UTC182
    <div class="actions" role="toolbar" aria-label={`Actions for message from ${displayName}`}>
544a170ead2catagen2026-10-20 11:47 UTC183
      {#each quickReactions as r (r.key)}
544a170ead2catagen2026-10-20 11:47 UTC184
        {@const label = r.shortcode ? `:${r.shortcode}:` : r.key}
544a170ead2catagen2026-10-20 11:47 UTC185
        <button
544a170ead2catagen2026-10-20 11:47 UTC186
          type="button"
544a170ead2catagen2026-10-20 11:47 UTC187
          title={`React ${label}`}
544a170ead2catagen2026-10-20 11:47 UTC188
          aria-label={`React ${label} to message from ${displayName}`}
544a170ead2catagen2026-10-20 11:47 UTC189
          disabled={reacting}
544a170ead2catagen2026-10-20 11:47 UTC190
          on:click={() => onReact(event.event_id ?? '', r.key)}
544a170ead2catagen2026-10-20 11:47 UTC191
        >
544a170ead2catagen2026-10-20 11:47 UTC192
          {#if r.key.startsWith('mxc://')}
544a170ead2catagen2026-10-20 11:47 UTC193
            <img class="emote" src={mediaUrl(r.key) ?? ''} alt="" />
544a170ead2catagen2026-10-20 11:47 UTC194
          {:else}
544a170ead2catagen2026-10-20 11:47 UTC195
            <Emoji emoji={r.key} size={18} />
544a170ead2catagen2026-10-20 11:47 UTC196
          {/if}
544a170ead2catagen2026-10-20 11:47 UTC197
        </button>
544a170ead2catagen2026-10-20 11:47 UTC198
      {/each}
544a170ead2catagen2026-10-20 11:47 UTC199
      <button
544a170ead2catagen2026-10-20 11:47 UTC200
        type="button"
544a170ead2catagen2026-10-20 11:47 UTC201
        title="Add reaction"
544a170ead2catagen2026-10-20 11:47 UTC202
        aria-label={`Add reaction to message from ${displayName}`}
544a170ead2catagen2026-10-20 11:47 UTC203
        aria-haspopup="dialog"
544a170ead2catagen2026-10-20 11:47 UTC204
        aria-expanded={pickerOpen}
544a170ead2catagen2026-10-20 11:47 UTC205
        on:pointerdown|stopPropagation
544a170ead2catagen2026-10-20 11:47 UTC206
        on:click={openPicker}
544a170ead2catagen2026-10-20 11:47 UTC207
      >
544a170ead2catagen2026-10-20 11:47 UTC208
        ☺+
544a170ead2catagen2026-10-20 11:47 UTC209
      </button>
544a170ead2catagen2026-10-20 11:47 UTC210
    </div>
544a170ead2catagen2026-10-20 11:47 UTC211
    {#if pickerOpen}
544a170ead2catagen2026-10-20 11:47 UTC212
      <div class="picker-pop" style={pickerStyle} bind:this={pickerPop}>
544a170ead2catagen2026-10-20 11:47 UTC213
        <ReactionPicker {packs} quick={quickReactions} onPick={(key) => pick(key)} {onQuickChange} onClose={closePicker} />
544a170ead2catagen2026-10-20 11:47 UTC214
      </div>
544a170ead2catagen2026-10-20 11:47 UTC215
    {/if}
544a170ead2catagen2026-10-20 11:47 UTC216
  {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC217
  {#if showSender}
973f8fbdaf80atagen2026-09-28 09:10 UTC218
    {#if senderAvatar}
973f8fbdaf80atagen2026-09-28 09:10 UTC219
      <MxcAvatar mxc={senderAvatar} label={name} size={34} />
973f8fbdaf80atagen2026-09-28 09:10 UTC220
    {:else}
973f8fbdaf80atagen2026-09-28 09:10 UTC221
      <div
973f8fbdaf80atagen2026-09-28 09:10 UTC222
        class="avatar"
973f8fbdaf80atagen2026-09-28 09:10 UTC223
        aria-hidden="true"
973f8fbdaf80atagen2026-09-28 09:10 UTC224
        style={`background:hsl(${senderHue(event.sender.user_id)} 35% 32%)`}
973f8fbdaf80atagen2026-09-28 09:10 UTC225
      >
973f8fbdaf80atagen2026-09-28 09:10 UTC226
        {initialsOf(name)}
973f8fbdaf80atagen2026-09-28 09:10 UTC227
      </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC228
    {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC229
  {:else}
973f8fbdaf80atagen2026-09-28 09:10 UTC230
    <div class="avatar-spacer" aria-hidden="true">
973f8fbdaf80atagen2026-09-28 09:10 UTC231
      <span class="hover-time">{formatClock(event.timestamp_ms)}</span>
973f8fbdaf80atagen2026-09-28 09:10 UTC232
    </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC233
  {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC234
  <div class="msg-body">
973f8fbdaf80atagen2026-09-28 09:10 UTC235
    {#if showSender}
973f8fbdaf80atagen2026-09-28 09:10 UTC236
      <div class="msg-head">
973f8fbdaf80atagen2026-09-28 09:10 UTC237
        <span class="sender" style={`color:hsl(${senderHue(event.sender.user_id)} 70% 72%)`}>
973f8fbdaf80atagen2026-09-28 09:10 UTC238
          {displayName}
973f8fbdaf80atagen2026-09-28 09:10 UTC239
        </span>
973f8fbdaf80atagen2026-09-28 09:10 UTC240
        <time class="time" datetime={new Date(event.timestamp_ms).toISOString()}>
973f8fbdaf80atagen2026-09-28 09:10 UTC241
          {formatDay(event.timestamp_ms)} {formatClock(event.timestamp_ms)}
973f8fbdaf80atagen2026-09-28 09:10 UTC242
        </time>
973f8fbdaf80atagen2026-09-28 09:10 UTC243
        {#if event.content.kind === 'Message' && event.content.message.edited}
973f8fbdaf80atagen2026-09-28 09:10 UTC244
          <span class="edited" title="Edited">(edited)</span>
973f8fbdaf80atagen2026-09-28 09:10 UTC245
        {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC246
      </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC247
    {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC248
    {#if event.reply_to}
973f8fbdaf80atagen2026-09-28 09:10 UTC249
      <div class="reply-quote">
973f8fbdaf80atagen2026-09-28 09:10 UTC250
        {#if event.reply_to.details.kind === 'Ready'}
544a170ead2catagen2026-10-20 11:47 UTC251
          Replying to {senderLabel(event.reply_to.details.sender)}: <EmojiText text={replyPreview(event.reply_to.details.content)} />
973f8fbdaf80atagen2026-09-28 09:10 UTC252
        {:else if event.reply_to.details.kind === 'Pending'}
973f8fbdaf80atagen2026-09-28 09:10 UTC253
          Loading reply…
973f8fbdaf80atagen2026-09-28 09:10 UTC254
        {:else}
973f8fbdaf80atagen2026-09-28 09:10 UTC255
          Reply unavailable
973f8fbdaf80atagen2026-09-28 09:10 UTC256
        {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC257
      </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC258
    {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC259
973f8fbdaf80atagen2026-09-28 09:10 UTC260
    {#if event.content.kind === 'Message'}
973f8fbdaf80atagen2026-09-28 09:10 UTC261
      {@const m = event.content.message}
973f8fbdaf80atagen2026-09-28 09:10 UTC262
      <p class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>
544a170ead2catagen2026-10-20 11:47 UTC263
        {#if m.kind === 'Emote'}* {displayName} {/if}<EmojiText text={m.body} jumbo={m.kind === 'Text'} />
973f8fbdaf80atagen2026-09-28 09:10 UTC264
      </p>
973f8fbdaf80atagen2026-09-28 09:10 UTC265
      {#if m.media}
973f8fbdaf80atagen2026-09-28 09:10 UTC266
        <MediaAttachmentView attachment={m.media} />
973f8fbdaf80atagen2026-09-28 09:10 UTC267
      {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC268
      {#if previews.length > 0}
973f8fbdaf80atagen2026-09-28 09:10 UTC269
        <div class="link-previews">
973f8fbdaf80atagen2026-09-28 09:10 UTC270
          {#each previews as preview (preview.href)}
b8ab00377c20atagen2026-10-22 05:13 UTC271
            <LinkCard link={preview} preview={linkPreviews[preview.href] ?? null} />
973f8fbdaf80atagen2026-09-28 09:10 UTC272
          {/each}
973f8fbdaf80atagen2026-09-28 09:10 UTC273
        </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC274
      {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC275
    {:else if event.content.kind === 'Sticker'}
544a170ead2catagen2026-10-20 11:47 UTC276
      {#if event.content.body}<p class="text"><EmojiText text={event.content.body} /></p>{/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC277
      {#if event.content.media}
973f8fbdaf80atagen2026-09-28 09:10 UTC278
        <MediaAttachmentView attachment={event.content.media} />
973f8fbdaf80atagen2026-09-28 09:10 UTC279
      {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC280
    {:else if event.content.kind === 'Poll'}
973f8fbdaf80atagen2026-09-28 09:10 UTC281
      <p class="text">📊 {event.content.question}</p>
973f8fbdaf80atagen2026-09-28 09:10 UTC282
    {:else}
973f8fbdaf80atagen2026-09-28 09:10 UTC283
      <p class="system-line">{systemLine(event.content)}</p>
973f8fbdaf80atagen2026-09-28 09:10 UTC284
    {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC285
973f8fbdaf80atagen2026-09-28 09:10 UTC286
    {#if interactive && event.reactions.length > 0}
973f8fbdaf80atagen2026-09-28 09:10 UTC287
      <div class="reactions" aria-label="Reactions">
973f8fbdaf80atagen2026-09-28 09:10 UTC288
        {#each event.reactions as r (r.key)}
973f8fbdaf80atagen2026-09-28 09:10 UTC289
          <button
973f8fbdaf80atagen2026-09-28 09:10 UTC290
            type="button"
973f8fbdaf80atagen2026-09-28 09:10 UTC291
            class="reaction"
973f8fbdaf80atagen2026-09-28 09:10 UTC292
            class:mine={r.own}
973f8fbdaf80atagen2026-09-28 09:10 UTC293
            title={r.own ? 'You reacted — activate to remove' : `${r.count} reaction${r.count === 1 ? '' : 's'}`}
973f8fbdaf80atagen2026-09-28 09:10 UTC294
            disabled={!event.event_id || reacting}
973f8fbdaf80atagen2026-09-28 09:10 UTC295
            on:click={() => event.event_id && onReact(event.event_id, r.key)}
973f8fbdaf80atagen2026-09-28 09:10 UTC296
          >
544a170ead2catagen2026-10-20 11:47 UTC297
            {#if r.key.startsWith('mxc://')}
544a170ead2catagen2026-10-20 11:47 UTC298
              <img class="emote" src={mediaUrl(r.key) ?? ''} alt="" />
544a170ead2catagen2026-10-20 11:47 UTC299
            {:else}
544a170ead2catagen2026-10-20 11:47 UTC300
              <Emoji emoji={r.key} size={16} />
544a170ead2catagen2026-10-20 11:47 UTC301
            {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC302
            <span class="count">{r.count}</span>
973f8fbdaf80atagen2026-09-28 09:10 UTC303
          </button>
973f8fbdaf80atagen2026-09-28 09:10 UTC304
        {/each}
973f8fbdaf80atagen2026-09-28 09:10 UTC305
      </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC306
    {/if}
544a170ead2catagen2026-10-20 11:47 UTC307
    {#if interactive && (sendLabel(event.send_state) || event.read_by.length > 0)}
973f8fbdaf80atagen2026-09-28 09:10 UTC308
      <div class="msg-foot">
973f8fbdaf80atagen2026-09-28 09:10 UTC309
        {#if sendLabel(event.send_state)}
973f8fbdaf80atagen2026-09-28 09:10 UTC310
          <span class="sendstate" class:failed={event.send_state.kind === 'Failed'}>{sendLabel(event.send_state)}</span>
973f8fbdaf80atagen2026-09-28 09:10 UTC311
        {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC312
        {#if event.read_by.length > 0}
973f8fbdaf80atagen2026-09-28 09:10 UTC313
          <span class="readby" title={event.read_by.join(', ')}>✓✓ {event.read_by.length}</span>
973f8fbdaf80atagen2026-09-28 09:10 UTC314
        {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC315
      </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC316
    {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC317
  </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC318
</div>
973f8fbdaf80atagen2026-09-28 09:10 UTC319
973f8fbdaf80atagen2026-09-28 09:10 UTC320
<style>
544a170ead2catagen2026-10-20 11:47 UTC321
  .msg { position: relative; display: flex; gap: 10px; padding: 3px 0; }
544a170ead2catagen2026-10-20 11:47 UTC322
  .msg:hover, .msg.selected { background: rgba(255, 255, 255, 0.018); }
544a170ead2catagen2026-10-20 11:47 UTC323
  .msg:hover .hover-time, .msg.selected .hover-time { opacity: 1; }
544a170ead2catagen2026-10-20 11:47 UTC324
  .msg-foot { display: flex; align-items: center; gap: 8px; }
973f8fbdaf80atagen2026-09-28 09:10 UTC325
  .sendstate { font-size: 11px; color: var(--muted); }
973f8fbdaf80atagen2026-09-28 09:10 UTC326
  .sendstate.failed { color: var(--danger); }
973f8fbdaf80atagen2026-09-28 09:10 UTC327
  .link-previews { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; max-width: 420px; }
973f8fbdaf80atagen2026-09-28 09:10 UTC328
  .avatar {
973f8fbdaf80atagen2026-09-28 09:10 UTC329
    flex: none;
973f8fbdaf80atagen2026-09-28 09:10 UTC330
    width: 34px; height: 34px;
973f8fbdaf80atagen2026-09-28 09:10 UTC331
    border-radius: 50%;
973f8fbdaf80atagen2026-09-28 09:10 UTC332
    display: grid; place-items: center;
973f8fbdaf80atagen2026-09-28 09:10 UTC333
    font-size: 12px; font-weight: 700; color: #fff;
973f8fbdaf80atagen2026-09-28 09:10 UTC334
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC335
  .avatar-spacer { flex: none; width: 34px; padding-top: 2px; text-align: center; }
973f8fbdaf80atagen2026-09-28 09:10 UTC336
  .hover-time { opacity: 0; font-size: 10.5px; color: var(--muted); }
973f8fbdaf80atagen2026-09-28 09:10 UTC337
  .msg-body { min-width: 0; flex: 1; }
973f8fbdaf80atagen2026-09-28 09:10 UTC338
  .msg-head { display: flex; align-items: baseline; gap: 8px; }
973f8fbdaf80atagen2026-09-28 09:10 UTC339
  .sender { font-weight: 650; font-size: 13.5px; }
973f8fbdaf80atagen2026-09-28 09:10 UTC340
  .time, .edited { color: var(--muted); font-size: 11.5px; }
973f8fbdaf80atagen2026-09-28 09:10 UTC341
  .reply-quote {
973f8fbdaf80atagen2026-09-28 09:10 UTC342
    border-left: 2px solid var(--accent);
973f8fbdaf80atagen2026-09-28 09:10 UTC343
    padding: 2px 8px;
973f8fbdaf80atagen2026-09-28 09:10 UTC344
    margin: 2px 0 4px;
973f8fbdaf80atagen2026-09-28 09:10 UTC345
    color: var(--muted);
973f8fbdaf80atagen2026-09-28 09:10 UTC346
    font-size: 12.5px;
973f8fbdaf80atagen2026-09-28 09:10 UTC347
    background: var(--bg-soft);
973f8fbdaf80atagen2026-09-28 09:10 UTC348
    border-radius: 0 6px 6px 0;
973f8fbdaf80atagen2026-09-28 09:10 UTC349
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC350
  .text {
973f8fbdaf80atagen2026-09-28 09:10 UTC351
    margin: 1px 0 2px;
973f8fbdaf80atagen2026-09-28 09:10 UTC352
    font-size: 14px;
973f8fbdaf80atagen2026-09-28 09:10 UTC353
    line-height: 1.5;
973f8fbdaf80atagen2026-09-28 09:10 UTC354
    white-space: pre-wrap;
973f8fbdaf80atagen2026-09-28 09:10 UTC355
    overflow-wrap: anywhere;
973f8fbdaf80atagen2026-09-28 09:10 UTC356
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC357
  .text.emote { font-style: italic; color: var(--muted); }
973f8fbdaf80atagen2026-09-28 09:10 UTC358
  .text.notice { color: var(--muted); }
973f8fbdaf80atagen2026-09-28 09:10 UTC359
  .system-line { color: var(--muted); font-size: 12.5px; font-style: italic; margin: 1px 0; }
973f8fbdaf80atagen2026-09-28 09:10 UTC360
  .msg.system { opacity: 0.85; }
973f8fbdaf80atagen2026-09-28 09:10 UTC361
  .reactions { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
973f8fbdaf80atagen2026-09-28 09:10 UTC362
  .reaction {
973f8fbdaf80atagen2026-09-28 09:10 UTC363
    display: inline-flex; align-items: center; gap: 4px;
973f8fbdaf80atagen2026-09-28 09:10 UTC364
    background: var(--bg-soft);
973f8fbdaf80atagen2026-09-28 09:10 UTC365
    border: 1px solid var(--border);
973f8fbdaf80atagen2026-09-28 09:10 UTC366
    border-radius: 999px;
973f8fbdaf80atagen2026-09-28 09:10 UTC367
    padding: 1px 8px;
973f8fbdaf80atagen2026-09-28 09:10 UTC368
    font-size: 13px;
973f8fbdaf80atagen2026-09-28 09:10 UTC369
    color: var(--text);
973f8fbdaf80atagen2026-09-28 09:10 UTC370
    cursor: pointer;
973f8fbdaf80atagen2026-09-28 09:10 UTC371
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC372
  .reaction.mine { border-color: var(--accent); background: rgba(88, 101, 242, 0.16); }
973f8fbdaf80atagen2026-09-28 09:10 UTC373
  .reaction:disabled { cursor: default; opacity: 0.85; }
973f8fbdaf80atagen2026-09-28 09:10 UTC374
  .readby { font-size: 11px; color: var(--muted); }
544a170ead2catagen2026-10-20 11:47 UTC375
  .actions {
544a170ead2catagen2026-10-20 11:47 UTC376
    position: absolute; top: -14px; right: 12px; z-index: 2;
544a170ead2catagen2026-10-20 11:47 UTC377
    display: flex; gap: 2px;
544a170ead2catagen2026-10-20 11:47 UTC378
    padding: 2px;
544a170ead2catagen2026-10-20 11:47 UTC379
    background: var(--bg-soft);
544a170ead2catagen2026-10-20 11:47 UTC380
    border: 1px solid var(--border);
544a170ead2catagen2026-10-20 11:47 UTC381
    border-radius: 8px;
544a170ead2catagen2026-10-20 11:47 UTC382
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
544a170ead2catagen2026-10-20 11:47 UTC383
    opacity: 0;
544a170ead2catagen2026-10-20 11:47 UTC384
    pointer-events: none;
544a170ead2catagen2026-10-20 11:47 UTC385
  }
544a170ead2catagen2026-10-20 11:47 UTC386
  .msg:hover .actions, .msg:focus-within .actions, .msg.selected .actions { opacity: 1; pointer-events: auto; }
544a170ead2catagen2026-10-20 11:47 UTC387
  .actions button {
973f8fbdaf80atagen2026-09-28 09:10 UTC388
    background: transparent;
973f8fbdaf80atagen2026-09-28 09:10 UTC389
    border: 1px solid transparent;
973f8fbdaf80atagen2026-09-28 09:10 UTC390
    border-radius: 6px;
973f8fbdaf80atagen2026-09-28 09:10 UTC391
    cursor: pointer;
544a170ead2catagen2026-10-20 11:47 UTC392
    font-size: 14px;
544a170ead2catagen2026-10-20 11:47 UTC393
    padding: 1px 5px;
973f8fbdaf80atagen2026-09-28 09:10 UTC394
  }
544a170ead2catagen2026-10-20 11:47 UTC395
  .actions button:hover:not(:disabled) { border-color: var(--border); background: rgba(255, 255, 255, 0.06); }
544a170ead2catagen2026-10-20 11:47 UTC396
  .actions button:disabled { opacity: 0.4; cursor: default; }
544a170ead2catagen2026-10-20 11:47 UTC397
  .emote { width: 1.3em; height: 1.3em; object-fit: contain; vertical-align: middle; }
544a170ead2catagen2026-10-20 11:47 UTC398
  .picker-pop { position: fixed; z-index: 60; width: 320px; max-width: calc(100vw - 16px); }
973f8fbdaf80atagen2026-09-28 09:10 UTC399
</style>