atagen

malice

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

Blame: web/src/lib/MediaAttachmentView.svelte

973f8fbdaf80atagen2026-09-28 09:10 UTC1
<script lang="ts">
e2b04d2f1db0atagen2026-10-24 23:55 UTC2
  import { onDestroy } from 'svelte';
973f8fbdaf80atagen2026-09-28 09:10 UTC3
  import type { MediaAttachment } from './types';
973f8fbdaf80atagen2026-09-28 09:10 UTC4
  import { messageMediaUrl } from './api';
cf23dbf4e77catagen2026-10-23 06:36 UTC5
  import { lightbox } from './lightbox';
445bf199a9cfatagen2026-10-25 02:56 UTC6
  import Emoji from './Emoji.svelte';
445bf199a9cfatagen2026-10-25 02:56 UTC7
  import EmojiText from './EmojiText.svelte';
973f8fbdaf80atagen2026-09-28 09:10 UTC8
973f8fbdaf80atagen2026-09-28 09:10 UTC9
  export let attachment: MediaAttachment;
973f8fbdaf80atagen2026-09-28 09:10 UTC10
973f8fbdaf80atagen2026-09-28 09:10 UTC11
  const PREVIEWABLE = new Set(['Image', 'Sticker']);
e2b04d2f1db0atagen2026-10-24 23:55 UTC12
  const RETRY_DELAYS = [1000, 3000, 10000];
d07bca5da4aeatagen2026-10-26 03:48 UTC13
  const MAX_W = 420;
d07bca5da4aeatagen2026-10-26 03:48 UTC14
  const MAX_H = 320;
973f8fbdaf80atagen2026-09-28 09:10 UTC15
973f8fbdaf80atagen2026-09-28 09:10 UTC16
  // Encrypted media often has no fetchable thumbnail, so request the bounded
973f8fbdaf80atagen2026-09-28 09:10 UTC17
  // full bytes directly instead of flashing a broken thumbnail first.
973f8fbdaf80atagen2026-09-28 09:10 UTC18
  $: fullUrl = messageMediaUrl(attachment.id, 'Full');
973f8fbdaf80atagen2026-09-28 09:10 UTC19
  $: thumbUrl = messageMediaUrl(attachment.id, 'Thumbnail');
973f8fbdaf80atagen2026-09-28 09:10 UTC20
  $: previewable = PREVIEWABLE.has(attachment.kind);
d07bca5da4aeatagen2026-10-26 03:48 UTC21
  $: sized = !!attachment.width && !!attachment.height;
d07bca5da4aeatagen2026-10-26 03:48 UTC22
  $: frame = frameSize(attachment.width, attachment.height);
d07bca5da4aeatagen2026-10-26 03:48 UTC23
  $: gaveUp = fullFailed && retryTimer === null && retryCount >= RETRY_DELAYS.length;
973f8fbdaf80atagen2026-09-28 09:10 UTC24
973f8fbdaf80atagen2026-09-28 09:10 UTC25
  let fullFailed = false;
87336a53e733atagen2026-10-24 10:37 UTC26
  let loaded = false;
e2b04d2f1db0atagen2026-10-24 23:55 UTC27
  let mediaId: string | null = null;
e2b04d2f1db0atagen2026-10-24 23:55 UTC28
  let retryCount = 0;
e2b04d2f1db0atagen2026-10-24 23:55 UTC29
  let retryToken = '';
e2b04d2f1db0atagen2026-10-24 23:55 UTC30
  let retryTimer: ReturnType<typeof setTimeout> | null = null;
973f8fbdaf80atagen2026-09-28 09:10 UTC31
e2b04d2f1db0atagen2026-10-24 23:55 UTC32
  $: previewUrl = retryToken ? `${fullUrl}&retry=${retryToken}` : fullUrl;
e2b04d2f1db0atagen2026-10-24 23:55 UTC33
  $: if (attachment.id !== mediaId) resetPreview(attachment.id);
e2b04d2f1db0atagen2026-10-24 23:55 UTC34
e2b04d2f1db0atagen2026-10-24 23:55 UTC35
  function clearRetry(): void {
e2b04d2f1db0atagen2026-10-24 23:55 UTC36
    if (retryTimer !== null) clearTimeout(retryTimer);
e2b04d2f1db0atagen2026-10-24 23:55 UTC37
    retryTimer = null;
e2b04d2f1db0atagen2026-10-24 23:55 UTC38
  }
e2b04d2f1db0atagen2026-10-24 23:55 UTC39
e2b04d2f1db0atagen2026-10-24 23:55 UTC40
  function resetPreview(id: string): void {
e2b04d2f1db0atagen2026-10-24 23:55 UTC41
    clearRetry();
e2b04d2f1db0atagen2026-10-24 23:55 UTC42
    mediaId = id;
e2b04d2f1db0atagen2026-10-24 23:55 UTC43
    retryCount = 0;
e2b04d2f1db0atagen2026-10-24 23:55 UTC44
    retryToken = '';
e2b04d2f1db0atagen2026-10-24 23:55 UTC45
    fullFailed = false;
e2b04d2f1db0atagen2026-10-24 23:55 UTC46
    loaded = false;
e2b04d2f1db0atagen2026-10-24 23:55 UTC47
  }
e2b04d2f1db0atagen2026-10-24 23:55 UTC48
e2b04d2f1db0atagen2026-10-24 23:55 UTC49
  function retryPreview(resetBudget = false): void {
e2b04d2f1db0atagen2026-10-24 23:55 UTC50
    clearRetry();
e2b04d2f1db0atagen2026-10-24 23:55 UTC51
    if (resetBudget) retryCount = 0;
e2b04d2f1db0atagen2026-10-24 23:55 UTC52
    retryCount += 1;
e2b04d2f1db0atagen2026-10-24 23:55 UTC53
    retryToken = `${Date.now()}-${retryCount}`;
973f8fbdaf80atagen2026-09-28 09:10 UTC54
    fullFailed = false;
87336a53e733atagen2026-10-24 10:37 UTC55
    loaded = false;
973f8fbdaf80atagen2026-09-28 09:10 UTC56
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC57
e2b04d2f1db0atagen2026-10-24 23:55 UTC58
  function previewFailed(): void {
e2b04d2f1db0atagen2026-10-24 23:55 UTC59
    fullFailed = true;
e2b04d2f1db0atagen2026-10-24 23:55 UTC60
    loaded = false;
e2b04d2f1db0atagen2026-10-24 23:55 UTC61
    if (retryTimer !== null || retryCount >= RETRY_DELAYS.length) return;
e2b04d2f1db0atagen2026-10-24 23:55 UTC62
    retryTimer = setTimeout(() => retryPreview(), RETRY_DELAYS[retryCount]);
e2b04d2f1db0atagen2026-10-24 23:55 UTC63
  }
e2b04d2f1db0atagen2026-10-24 23:55 UTC64
e2b04d2f1db0atagen2026-10-24 23:55 UTC65
  onDestroy(clearRetry);
e2b04d2f1db0atagen2026-10-24 23:55 UTC66
cf23dbf4e77catagen2026-10-23 06:36 UTC67
  function openLightbox(ev: MouseEvent): void {
cf23dbf4e77catagen2026-10-23 06:36 UTC68
    if (ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
cf23dbf4e77catagen2026-10-23 06:36 UTC69
    ev.preventDefault();
7cca1d5628d1atagen2026-10-24 07:36 UTC70
    lightbox.set({ src: fullUrl, alt: attachment.caption || attachment.filename, filename: attachment.filename, mxc: attachment.mxc });
cf23dbf4e77catagen2026-10-23 06:36 UTC71
  }
cf23dbf4e77catagen2026-10-23 06:36 UTC72
d07bca5da4aeatagen2026-10-26 03:48 UTC73
  function frameSize(width: number | null, height: number | null): string {
d07bca5da4aeatagen2026-10-26 03:48 UTC74
    if (!width || !height) return 'width:240px;height:160px';
d07bca5da4aeatagen2026-10-26 03:48 UTC75
    const scale = Math.min(1, MAX_W / width, MAX_H / height);
d07bca5da4aeatagen2026-10-26 03:48 UTC76
    return `width:${Math.max(1, Math.round(width * scale))}px;aspect-ratio:${width}/${height}`;
d07bca5da4aeatagen2026-10-26 03:48 UTC77
  }
d07bca5da4aeatagen2026-10-26 03:48 UTC78
973f8fbdaf80atagen2026-09-28 09:10 UTC79
  function sizeLabel(): string {
973f8fbdaf80atagen2026-09-28 09:10 UTC80
    if (typeof attachment.size_bytes !== 'number') return '';
973f8fbdaf80atagen2026-09-28 09:10 UTC81
    const bytes = attachment.size_bytes;
973f8fbdaf80atagen2026-09-28 09:10 UTC82
    if (bytes < 1024) return `${bytes} B`;
973f8fbdaf80atagen2026-09-28 09:10 UTC83
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
973f8fbdaf80atagen2026-09-28 09:10 UTC84
    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
973f8fbdaf80atagen2026-09-28 09:10 UTC85
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC86
973f8fbdaf80atagen2026-09-28 09:10 UTC87
  function durationLabel(): string {
973f8fbdaf80atagen2026-09-28 09:10 UTC88
    if (typeof attachment.duration_ms !== 'number') return '';
973f8fbdaf80atagen2026-09-28 09:10 UTC89
    const totalSeconds = Math.round(attachment.duration_ms / 1000);
973f8fbdaf80atagen2026-09-28 09:10 UTC90
    const minutes = Math.floor(totalSeconds / 60);
973f8fbdaf80atagen2026-09-28 09:10 UTC91
    const seconds = totalSeconds % 60;
973f8fbdaf80atagen2026-09-28 09:10 UTC92
    return `${minutes}:${seconds.toString().padStart(2, '0')}`;
973f8fbdaf80atagen2026-09-28 09:10 UTC93
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC94
</script>
973f8fbdaf80atagen2026-09-28 09:10 UTC95
e2b04d2f1db0atagen2026-10-24 23:55 UTC96
<svelte:window on:online={() => { if (fullFailed) retryPreview(true); }} />
e2b04d2f1db0atagen2026-10-24 23:55 UTC97
d07bca5da4aeatagen2026-10-26 03:48 UTC98
{#if previewable && !gaveUp}
973f8fbdaf80atagen2026-09-28 09:10 UTC99
  <figure class="media-figure">
d07bca5da4aeatagen2026-10-26 03:48 UTC100
    <a class="media-frame" class:loaded class:natural={!sized && loaded} style={sized || !loaded ? frame : undefined} href={fullUrl} target="_blank" rel="noopener noreferrer" title={attachment.filename} on:click={openLightbox}>
973f8fbdaf80atagen2026-09-28 09:10 UTC101
      <img
973f8fbdaf80atagen2026-09-28 09:10 UTC102
        class="media-image"
87336a53e733atagen2026-10-24 10:37 UTC103
        class:loaded
e2b04d2f1db0atagen2026-10-24 23:55 UTC104
        src={previewUrl}
973f8fbdaf80atagen2026-09-28 09:10 UTC105
        alt={attachment.caption || attachment.filename}
973f8fbdaf80atagen2026-09-28 09:10 UTC106
        loading="lazy"
d07bca5da4aeatagen2026-10-26 03:48 UTC107
        on:load={() => ((loaded = true), (fullFailed = false))}
e2b04d2f1db0atagen2026-10-24 23:55 UTC108
        on:error={previewFailed}
973f8fbdaf80atagen2026-09-28 09:10 UTC109
      />
973f8fbdaf80atagen2026-09-28 09:10 UTC110
    </a>
dfd1feaae855atagen2026-10-24 10:54 UTC111
    {#if attachment.kind !== 'Sticker'}
dfd1feaae855atagen2026-10-24 10:54 UTC112
      <figcaption class="media-caption">
dfd1feaae855atagen2026-10-24 10:54 UTC113
        {#if attachment.caption && attachment.caption !== attachment.filename}
445bf199a9cfatagen2026-10-25 02:56 UTC114
          <span><EmojiText text={attachment.caption} /></span>
dfd1feaae855atagen2026-10-24 10:54 UTC115
        {:else}
445bf199a9cfatagen2026-10-25 02:56 UTC116
          <span><EmojiText text={attachment.filename} /></span>
dfd1feaae855atagen2026-10-24 10:54 UTC117
        {/if}
dfd1feaae855atagen2026-10-24 10:54 UTC118
        {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}
dfd1feaae855atagen2026-10-24 10:54 UTC119
      </figcaption>
dfd1feaae855atagen2026-10-24 10:54 UTC120
    {/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC121
  </figure>
d07bca5da4aeatagen2026-10-26 03:48 UTC122
{:else if previewable}
973f8fbdaf80atagen2026-09-28 09:10 UTC123
  <div class="media-file">
445bf199a9cfatagen2026-10-25 02:56 UTC124
    <span class="file-icon" aria-hidden="true"><Emoji emoji="🖼️" size={18} /></span>
445bf199a9cfatagen2026-10-25 02:56 UTC125
    <a href={fullUrl} target="_blank" rel="noopener noreferrer"><EmojiText text={attachment.caption || attachment.filename} /></a>
d07bca5da4aeatagen2026-10-26 03:48 UTC126
    <span class="media-size">Preview unavailable</span>
e2b04d2f1db0atagen2026-10-24 23:55 UTC127
    <button type="button" class="ghost-btn" on:click={() => retryPreview(true)}>Retry</button>
973f8fbdaf80atagen2026-09-28 09:10 UTC128
  </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC129
{:else if attachment.kind === 'Video'}
973f8fbdaf80atagen2026-09-28 09:10 UTC130
  <div class="media-block">
973f8fbdaf80atagen2026-09-28 09:10 UTC131
    <!-- svelte-ignore a11y-media-has-caption -->
973f8fbdaf80atagen2026-09-28 09:10 UTC132
    <video class="media-video" controls preload="metadata" poster={attachment.has_thumbnail ? thumbUrl : undefined}>
973f8fbdaf80atagen2026-09-28 09:10 UTC133
      <source src={fullUrl} type={attachment.mimetype ?? undefined} />
973f8fbdaf80atagen2026-09-28 09:10 UTC134
      Your browser cannot play this video.
973f8fbdaf80atagen2026-09-28 09:10 UTC135
    </video>
973f8fbdaf80atagen2026-09-28 09:10 UTC136
    <div class="media-caption">
445bf199a9cfatagen2026-10-25 02:56 UTC137
      <a href={fullUrl} target="_blank" rel="noopener noreferrer"><EmojiText text={attachment.caption || attachment.filename} /></a>
973f8fbdaf80atagen2026-09-28 09:10 UTC138
      {#if durationLabel()}<span class="media-size"> · {durationLabel()}</span>{/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC139
      {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC140
    </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC141
  </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC142
{:else if attachment.kind === 'Audio'}
973f8fbdaf80atagen2026-09-28 09:10 UTC143
  <div class="media-block">
973f8fbdaf80atagen2026-09-28 09:10 UTC144
    <!-- svelte-ignore a11y-media-has-caption -->
973f8fbdaf80atagen2026-09-28 09:10 UTC145
    <audio class="media-audio" controls preload="metadata" src={fullUrl}></audio>
973f8fbdaf80atagen2026-09-28 09:10 UTC146
    <div class="media-caption">
445bf199a9cfatagen2026-10-25 02:56 UTC147
      <span><EmojiText text={attachment.caption || attachment.filename} /></span>
973f8fbdaf80atagen2026-09-28 09:10 UTC148
      {#if durationLabel()}<span class="media-size"> · {durationLabel()}</span>{/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC149
      {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC150
    </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC151
  </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC152
{:else}
973f8fbdaf80atagen2026-09-28 09:10 UTC153
  <div class="media-file">
445bf199a9cfatagen2026-10-25 02:56 UTC154
    <span class="file-icon" aria-hidden="true"><Emoji emoji="📎" size={18} /></span>
973f8fbdaf80atagen2026-09-28 09:10 UTC155
    <a href={fullUrl} target="_blank" rel="noopener noreferrer" download={attachment.filename}>
445bf199a9cfatagen2026-10-25 02:56 UTC156
      <EmojiText text={attachment.caption || attachment.filename} />
973f8fbdaf80atagen2026-09-28 09:10 UTC157
    </a>
973f8fbdaf80atagen2026-09-28 09:10 UTC158
    {#if sizeLabel()}<span class="media-size">{sizeLabel()}</span>{/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC159
  </div>
973f8fbdaf80atagen2026-09-28 09:10 UTC160
{/if}
973f8fbdaf80atagen2026-09-28 09:10 UTC161
973f8fbdaf80atagen2026-09-28 09:10 UTC162
<style>
973f8fbdaf80atagen2026-09-28 09:10 UTC163
  .media-figure { margin: 6px 0 2px; max-width: 420px; }
d07bca5da4aeatagen2026-10-26 03:48 UTC164
  .media-frame {
973f8fbdaf80atagen2026-09-28 09:10 UTC165
    display: block;
973f8fbdaf80atagen2026-09-28 09:10 UTC166
    max-width: 100%;
05a48e9e7942atagen2026-10-24 09:32 UTC167
    border-radius: var(--r-md);
973f8fbdaf80atagen2026-09-28 09:10 UTC168
    border: 1px solid var(--border);
973f8fbdaf80atagen2026-09-28 09:10 UTC169
    background: var(--bg-soft);
d07bca5da4aeatagen2026-10-26 03:48 UTC170
    overflow: hidden;
973f8fbdaf80atagen2026-09-28 09:10 UTC171
  }
d07bca5da4aeatagen2026-10-26 03:48 UTC172
  .media-frame.loaded { border-color: transparent; background: none; }
d07bca5da4aeatagen2026-10-26 03:48 UTC173
  .media-frame.natural { display: inline-block; }
d07bca5da4aeatagen2026-10-26 03:48 UTC174
  .natural .media-image { width: auto; height: auto; max-width: 100%; max-height: 320px; }
d07bca5da4aeatagen2026-10-26 03:48 UTC175
  .media-image { display: block; width: 100%; height: 100%; object-fit: contain; opacity: 0; }
d07bca5da4aeatagen2026-10-26 03:48 UTC176
  .media-image.loaded { opacity: 1; }
05a48e9e7942atagen2026-10-24 09:32 UTC177
  .media-video { display: block; max-width: 100%; max-height: 320px; border-radius: var(--r-md); background: #000; }
973f8fbdaf80atagen2026-09-28 09:10 UTC178
  .media-audio { display: block; width: 100%; max-width: 420px; margin-top: 6px; }
973f8fbdaf80atagen2026-09-28 09:10 UTC179
  .media-block { margin: 6px 0 2px; max-width: 420px; }
973f8fbdaf80atagen2026-09-28 09:10 UTC180
  .media-caption { font-size: 12px; color: var(--muted); margin-top: 4px; display: flex; gap: 4px; flex-wrap: wrap; }
973f8fbdaf80atagen2026-09-28 09:10 UTC181
  .media-caption a { color: var(--text); }
973f8fbdaf80atagen2026-09-28 09:10 UTC182
  .media-size { opacity: 0.85; }
973f8fbdaf80atagen2026-09-28 09:10 UTC183
  .media-file {
973f8fbdaf80atagen2026-09-28 09:10 UTC184
    display: inline-flex;
973f8fbdaf80atagen2026-09-28 09:10 UTC185
    align-items: center;
973f8fbdaf80atagen2026-09-28 09:10 UTC186
    gap: 8px;
e2b04d2f1db0atagen2026-10-24 23:55 UTC187
    flex-wrap: wrap;
973f8fbdaf80atagen2026-09-28 09:10 UTC188
    margin: 6px 0 2px;
973f8fbdaf80atagen2026-09-28 09:10 UTC189
    padding: 6px 12px;
973f8fbdaf80atagen2026-09-28 09:10 UTC190
    border: 1px solid var(--border);
05a48e9e7942atagen2026-10-24 09:32 UTC191
    border-radius: var(--r-md);
973f8fbdaf80atagen2026-09-28 09:10 UTC192
    background: var(--bg-soft);
973f8fbdaf80atagen2026-09-28 09:10 UTC193
    max-width: 100%;
973f8fbdaf80atagen2026-09-28 09:10 UTC194
  }
973f8fbdaf80atagen2026-09-28 09:10 UTC195
  .media-file a { color: var(--text); overflow-wrap: anywhere; }
973f8fbdaf80atagen2026-09-28 09:10 UTC196
  .file-icon { font-size: 15px; }
973f8fbdaf80atagen2026-09-28 09:10 UTC197
</style>