atagen

malice

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