atagen

malice

public
Star0
Watch0
ebd21444da0f089f9ef6137a717fbf9ce17d58a6
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/MediaAttachmentView.svelteSvelte182 lines6.8 KB
1
<script lang="ts">
2
  import { onDestroy } from 'svelte';
3
  import type { MediaAttachment } from './types';
4
  import { messageMediaUrl } from './api';
5
  import { lightbox } from './lightbox';
6
  import Emoji from './Emoji.svelte';
7
  import EmojiText from './EmojiText.svelte';
8
9
  export let attachment: MediaAttachment;
10
11
  const PREVIEWABLE = new Set(['Image', 'Sticker']);
12
  const RETRY_DELAYS = [1000, 3000, 10000];
13
14
  // Encrypted media often has no fetchable thumbnail, so request the bounded
15
  // full bytes directly instead of flashing a broken thumbnail first.
16
  $: fullUrl = messageMediaUrl(attachment.id, 'Full');
17
  $: thumbUrl = messageMediaUrl(attachment.id, 'Thumbnail');
18
  $: previewable = PREVIEWABLE.has(attachment.kind);
19
20
  let fullFailed = false;
21
  let loaded = false;
22
  let mediaId: string | null = null;
23
  let retryCount = 0;
24
  let retryToken = '';
25
  let retryTimer: ReturnType<typeof setTimeout> | null = null;
26
27
  $: previewUrl = retryToken ? `${fullUrl}&retry=${retryToken}` : fullUrl;
28
  $: if (attachment.id !== mediaId) resetPreview(attachment.id);
29
30
  function clearRetry(): void {
31
    if (retryTimer !== null) clearTimeout(retryTimer);
32
    retryTimer = null;
33
  }
34
35
  function resetPreview(id: string): void {
36
    clearRetry();
37
    mediaId = id;
38
    retryCount = 0;
39
    retryToken = '';
40
    fullFailed = false;
41
    loaded = false;
42
  }
43
44
  function retryPreview(resetBudget = false): void {
45
    clearRetry();
46
    if (resetBudget) retryCount = 0;
47
    retryCount += 1;
48
    retryToken = `${Date.now()}-${retryCount}`;
49
    fullFailed = false;
50
    loaded = false;
51
  }
52
53
  function previewFailed(): void {
54
    fullFailed = true;
55
    loaded = false;
56
    if (retryTimer !== null || retryCount >= RETRY_DELAYS.length) return;
57
    retryTimer = setTimeout(() => retryPreview(), RETRY_DELAYS[retryCount]);
58
  }
59
60
  onDestroy(clearRetry);
61
62
  function openLightbox(ev: MouseEvent): void {
63
    if (ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
64
    ev.preventDefault();
65
    lightbox.set({ src: fullUrl, alt: attachment.caption || attachment.filename, filename: attachment.filename, mxc: attachment.mxc });
66
  }
67
68
  function sizeLabel(): string {
69
    if (typeof attachment.size_bytes !== 'number') return '';
70
    const bytes = attachment.size_bytes;
71
    if (bytes < 1024) return `${bytes} B`;
72
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
73
    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
74
  }
75
76
  function durationLabel(): string {
77
    if (typeof attachment.duration_ms !== 'number') return '';
78
    const totalSeconds = Math.round(attachment.duration_ms / 1000);
79
    const minutes = Math.floor(totalSeconds / 60);
80
    const seconds = totalSeconds % 60;
81
    return `${minutes}:${seconds.toString().padStart(2, '0')}`;
82
  }
83
</script>
84
85
<svelte:window on:online={() => { if (fullFailed) retryPreview(true); }} />
86
87
{#if previewable && !fullFailed}
88
  <figure class="media-figure">
89
    <a href={fullUrl} target="_blank" rel="noopener noreferrer" title={attachment.filename} on:click={openLightbox}>
90
      <img
91
        class="media-image"
92
        class:loaded
93
        src={previewUrl}
94
        alt={attachment.caption || attachment.filename}
95
        loading="lazy"
96
        on:load={() => (loaded = true)}
97
        on:error={previewFailed}
98
      />
99
    </a>
100
    {#if attachment.kind !== 'Sticker'}
101
      <figcaption class="media-caption">
102
        {#if attachment.caption && attachment.caption !== attachment.filename}
103
          <span><EmojiText text={attachment.caption} /></span>
104
        {:else}
105
          <span><EmojiText text={attachment.filename} /></span>
106
        {/if}
107
        {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}
108
      </figcaption>
109
    {/if}
110
  </figure>
111
{:else if previewable && fullFailed}
112
  <div class="media-file">
113
    <span class="file-icon" aria-hidden="true"><Emoji emoji="🖼️" size={18} /></span>
114
    <a href={fullUrl} target="_blank" rel="noopener noreferrer"><EmojiText text={attachment.caption || attachment.filename} /></a>
115
    <span class="media-size">{retryTimer !== null ? 'Retrying preview…' : 'Preview unavailable'}</span>
116
    <button type="button" class="ghost-btn" on:click={() => retryPreview(true)}>Retry</button>
117
  </div>
118
{:else if attachment.kind === 'Video'}
119
  <div class="media-block">
120
    <!-- svelte-ignore a11y-media-has-caption -->
121
    <video class="media-video" controls preload="metadata" poster={attachment.has_thumbnail ? thumbUrl : undefined}>
122
      <source src={fullUrl} type={attachment.mimetype ?? undefined} />
123
      Your browser cannot play this video.
124
    </video>
125
    <div class="media-caption">
126
      <a href={fullUrl} target="_blank" rel="noopener noreferrer"><EmojiText text={attachment.caption || attachment.filename} /></a>
127
      {#if durationLabel()}<span class="media-size"> · {durationLabel()}</span>{/if}
128
      {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}
129
    </div>
130
  </div>
131
{:else if attachment.kind === 'Audio'}
132
  <div class="media-block">
133
    <!-- svelte-ignore a11y-media-has-caption -->
134
    <audio class="media-audio" controls preload="metadata" src={fullUrl}></audio>
135
    <div class="media-caption">
136
      <span><EmojiText text={attachment.caption || attachment.filename} /></span>
137
      {#if durationLabel()}<span class="media-size"> · {durationLabel()}</span>{/if}
138
      {#if sizeLabel()}<span class="media-size"> · {sizeLabel()}</span>{/if}
139
    </div>
140
  </div>
141
{:else}
142
  <div class="media-file">
143
    <span class="file-icon" aria-hidden="true"><Emoji emoji="📎" size={18} /></span>
144
    <a href={fullUrl} target="_blank" rel="noopener noreferrer" download={attachment.filename}>
145
      <EmojiText text={attachment.caption || attachment.filename} />
146
    </a>
147
    {#if sizeLabel()}<span class="media-size">{sizeLabel()}</span>{/if}
148
  </div>
149
{/if}
150
151
<style>
152
  .media-figure { margin: 6px 0 2px; max-width: 420px; }
153
  .media-image {
154
    display: block;
155
    max-width: 100%;
156
    max-height: 320px;
157
    border-radius: var(--r-md);
158
    border: 1px solid var(--border);
159
    background: var(--bg-soft);
160
  }
161
  .media-image.loaded { border-color: transparent; background: none; }
162
  .media-video { display: block; max-width: 100%; max-height: 320px; border-radius: var(--r-md); background: #000; }
163
  .media-audio { display: block; width: 100%; max-width: 420px; margin-top: 6px; }
164
  .media-block { margin: 6px 0 2px; max-width: 420px; }
165
  .media-caption { font-size: 12px; color: var(--muted); margin-top: 4px; display: flex; gap: 4px; flex-wrap: wrap; }
166
  .media-caption a { color: var(--text); }
167
  .media-size { opacity: 0.85; }
168
  .media-file {
169
    display: inline-flex;
170
    align-items: center;
171
    gap: 8px;
172
    flex-wrap: wrap;
173
    margin: 6px 0 2px;
174
    padding: 6px 12px;
175
    border: 1px solid var(--border);
176
    border-radius: var(--r-md);
177
    background: var(--bg-soft);
178
    max-width: 100%;
179
  }
180
  .media-file a { color: var(--text); overflow-wrap: anywhere; }
181
  .file-icon { font-size: 15px; }
182
</style>