+41
-167
src/components/BskyPost.svelte
+41
-167
src/components/BskyPost.svelte
···
1
1
<script lang="ts">
2
2
import { resolveDidDoc, type AtpClient } from '$lib/at/client';
3
-
import {
4
-
AppBskyActorProfile,
5
-
AppBskyEmbedExternal,
6
-
AppBskyEmbedImages,
7
-
AppBskyEmbedVideo,
8
-
AppBskyFeedPost
9
-
} from '@atcute/bluesky';
3
+
import { AppBskyActorProfile, AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky';
10
4
import {
11
5
parseCanonicalResourceUri,
12
6
type Did,
···
17
11
import { expect, ok } from '$lib/result';
18
12
import { accounts, generateColorForDid } from '$lib/accounts';
19
13
import ProfilePicture from './ProfilePicture.svelte';
20
-
import { isBlob } from '@atcute/lexicons/interfaces';
21
-
import { blob, img } from '$lib/cdn';
22
14
import BskyPost from './BskyPost.svelte';
23
15
import Icon from '@iconify/svelte';
24
16
import {
···
37
29
import { derived } from 'svelte/store';
38
30
import Device from 'svelte-device-info';
39
31
import Dropdown from './Dropdown.svelte';
40
-
import { type AppBskyEmbeds } from '$lib/at/types';
41
32
import { settings } from '$lib/settings';
42
33
import RichText from './RichText.svelte';
43
34
import { getRelativeTime } from '$lib/date';
44
35
import { likeSource, repostSource, toCanonicalUri } from '$lib';
45
36
import ProfileInfo from './ProfileInfo.svelte';
46
-
import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte';
37
+
import EmbedBadge from './EmbedBadge.svelte';
38
+
import EmbedMedia from './EmbedMedia.svelte';
47
39
48
40
interface Props {
49
41
client: AtpClient;
···
80
72
const color = $derived(generateColorForDid(did));
81
73
82
74
let handle: Handle = $state(handles.get(did) ?? 'handle.invalid');
83
-
const didDoc = resolveDidDoc(did).then((res) => {
84
-
if (res.ok) {
85
-
handle = res.value.handle;
86
-
handles.set(did, handle);
87
-
}
88
-
return res;
75
+
onMount(() => {
76
+
resolveDidDoc(did).then((res) => {
77
+
if (res.ok) {
78
+
handle = res.value.handle;
79
+
handles.set(did, handle);
80
+
}
81
+
return res;
82
+
});
89
83
});
90
84
const post = data
91
85
? Promise.resolve(ok(data))
···
120
114
}, 400);
121
115
};
122
116
123
-
const getEmbedText = (embedType: string) => {
124
-
switch (embedType) {
125
-
case 'app.bsky.embed.external':
126
-
return '🔗 has external link';
127
-
case 'app.bsky.embed.record':
128
-
return '💬 has quote';
129
-
case 'app.bsky.embed.images':
130
-
return '🖼️ has images';
131
-
case 'app.bsky.embed.video':
132
-
return '🎥 has video';
133
-
case 'app.bsky.embed.recordWithMedia':
134
-
return '📎 has quote with media';
135
-
default:
136
-
return '❓ has unknown embed';
137
-
}
138
-
};
139
-
140
117
let actionsOpen = $state(false);
141
118
let actionsPos = $state({ x: 0, y: 0 });
142
119
···
178
155
let profileOpen = $state(false);
179
156
</script>
180
157
181
-
{#snippet embedBadge(embed: AppBskyEmbeds)}
182
-
<span
183
-
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
184
-
style="
185
-
background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent);
186
-
color: {mini ? 'var(--nucleus-fg)' : color};
187
-
"
188
-
>
189
-
{getEmbedText(embed.$type!)}
190
-
</span>
191
-
{/snippet}
192
-
193
158
{#snippet profileInline()}
194
159
<button
195
160
class="
···
239
204
>
240
205
<span style="color: {color};">@{handle}</span>:
241
206
{#if record.embed}
242
-
{@render embedBadge(record.embed)}
207
+
<EmbedBadge embed={record.embed} />
243
208
{/if}
244
209
<span title={record.text}>{record.text}</span>
245
210
</div>
···
298
263
<p class="leading-normal text-wrap wrap-break-word">
299
264
<RichText text={record.text} facets={record.facets ?? []} />
300
265
{#if isOnPostComposer && record.embed}
301
-
{@render embedBadge(record.embed)}
266
+
<EmbedBadge embed={record.embed} {color} />
302
267
{/if}
303
268
</p>
304
269
{#if !isOnPostComposer && record.embed}
305
270
{@const embed = record.embed}
306
271
<div class="mt-2">
307
-
{@render postEmbed(embed)}
272
+
{#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'}
273
+
<EmbedMedia {did} {embed} />
274
+
{:else if embed.$type === 'app.bsky.embed.record'}
275
+
{@render embedPost(embed.record.uri)}
276
+
{:else if embed.$type === 'app.bsky.embed.recordWithMedia'}
277
+
<div class="space-y-1.5">
278
+
<EmbedMedia {did} embed={embed.media} />
279
+
{@render embedPost(embed.record.record.uri)}
280
+
</div>
281
+
{/if}
308
282
</div>
309
283
{/if}
310
284
{#if !isOnPostComposer}
···
319
293
{/await}
320
294
{/if}
321
295
322
-
{#snippet postEmbed(embed: AppBskyEmbeds)}
323
-
{#snippet embedMedia(
324
-
embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main
325
-
)}
326
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
327
-
<div oncontextmenu={(e) => e.stopPropagation()}>
328
-
{#if embed.$type === 'app.bsky.embed.images'}
329
-
{@const _images = embed.images.flatMap((img) =>
330
-
isBlob(img.image) ? [{ ...img, image: img.image }] : []
331
-
)}
332
-
{@const images = _images.map((i): GalleryItem => {
333
-
const sizeFactor = 200;
334
-
const size = {
335
-
width: (i.aspectRatio?.width ?? 4) * sizeFactor,
336
-
height: (i.aspectRatio?.height ?? 3) * sizeFactor
337
-
};
338
-
return {
339
-
...size,
340
-
src: img('feed_fullsize', did, i.image.ref.$link),
341
-
thumbnail: {
342
-
src: img('feed_thumbnail', did, i.image.ref.$link),
343
-
...size
344
-
}
345
-
};
346
-
})}
347
-
<PhotoSwipeGallery {images} />
348
-
{:else if embed.$type === 'app.bsky.embed.video'}
349
-
{#if isBlob(embed.video)}
350
-
{#await didDoc then didDoc}
351
-
{#if didDoc.ok}
352
-
<!-- svelte-ignore a11y_media_has_caption -->
353
-
<video
354
-
class="rounded-sm"
355
-
src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
356
-
controls
357
-
></video>
358
-
{/if}
359
-
{/await}
360
-
{/if}
361
-
{/if}
362
-
</div>
363
-
{/snippet}
364
-
{#snippet embedPost(uri: ResourceUri)}
365
-
{#if quoteDepth < 2}
366
-
{@const parsedUri = expect(parseCanonicalResourceUri(uri))}
367
-
<!-- reject recursive quotes -->
368
-
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
369
-
<BskyPost
370
-
{client}
371
-
quoteDepth={quoteDepth + 1}
372
-
did={parsedUri.repo}
373
-
rkey={parsedUri.rkey}
374
-
{isOnPostComposer}
375
-
{onQuote}
376
-
{onReply}
377
-
/>
378
-
{:else}
379
-
<span>you think you're funny with that recursive quote but i'm onto you</span>
380
-
{/if}
296
+
{#snippet embedPost(uri: ResourceUri)}
297
+
{#if quoteDepth < 2}
298
+
{@const parsedUri = expect(parseCanonicalResourceUri(uri))}
299
+
<!-- reject recursive quotes -->
300
+
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
301
+
<BskyPost
302
+
{client}
303
+
quoteDepth={quoteDepth + 1}
304
+
did={parsedUri.repo}
305
+
rkey={parsedUri.rkey}
306
+
{isOnPostComposer}
307
+
{onQuote}
308
+
{onReply}
309
+
/>
381
310
{:else}
382
-
{@render embedBadge(embed)}
311
+
<span>you think you're funny with that recursive quote but i'm onto you</span>
383
312
{/if}
384
-
{/snippet}
385
-
{#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'}
386
-
{@render embedMedia(embed)}
387
-
{:else if embed.$type === 'app.bsky.embed.record'}
388
-
{@render embedPost(embed.record.uri)}
389
-
{:else if embed.$type === 'app.bsky.embed.recordWithMedia'}
390
-
<div class="space-y-1.5">
391
-
{@render embedPost(embed.record.record.uri)}
392
-
{@render embedMedia(embed.media)}
393
-
</div>
313
+
{:else}
314
+
<EmbedBadge embed={{ $type: 'app.bsky.embed.record' } as AppBskyEmbedRecord.Main} />
394
315
{/if}
395
316
{/snippet}
396
317
···
531
452
if (autoClose) actionsOpen = false;
532
453
}}
533
454
>
534
-
<span class="font-bold">{label}</span>
455
+
<span class="font-semibold opacity-85">{label}</span>
535
456
<Icon class="h-6 w-6" {icon} />
536
457
</button>
537
458
{/snippet}
···
541
462
542
463
:global(.post-dropdown) {
543
464
@apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60;
544
-
}
545
-
546
-
.image-grid {
547
-
display: grid;
548
-
gap: 2px;
549
-
border-radius: 0.375rem;
550
-
overflow: hidden;
551
-
max-height: 500px;
552
-
}
553
-
554
-
/* 1 image: full width */
555
-
.image-grid.count-1 {
556
-
grid-template-columns: 1fr;
557
-
}
558
-
559
-
/* 2 images: side by side */
560
-
.image-grid.count-2 {
561
-
grid-template-columns: repeat(2, 1fr);
562
-
}
563
-
564
-
/* 3 images: first spans left, two stack on right */
565
-
.image-grid.count-3 {
566
-
grid-template-columns: repeat(2, 1fr);
567
-
grid-template-rows: repeat(2, 1fr);
568
-
}
569
-
.image-grid.count-3 a:first-child {
570
-
grid-row: 1 / 3;
571
-
}
572
-
573
-
/* 4+ images: 2x2 grid */
574
-
.image-grid.count-4,
575
-
.image-grid.count-5 {
576
-
grid-template-columns: repeat(2, 1fr);
577
-
grid-template-rows: repeat(2, 1fr);
578
-
}
579
-
580
-
.image-item {
581
-
width: 100%;
582
-
height: 100%;
583
-
object-fit: cover;
584
-
display: block;
585
-
cursor: pointer;
586
-
transition: opacity 0.2s;
587
-
}
588
-
589
-
.image-item:hover {
590
-
opacity: 0.9;
591
465
}
592
466
</style>
+37
src/components/EmbedBadge.svelte
+37
src/components/EmbedBadge.svelte
···
1
+
<script lang="ts">
2
+
import type { AppBskyEmbeds } from '$lib/at/types';
3
+
4
+
interface Props {
5
+
embed: AppBskyEmbeds;
6
+
color?: string;
7
+
}
8
+
9
+
let { embed, color = 'var(--nucleus-fg)' }: Props = $props();
10
+
11
+
const embedText = $derived.by(() => {
12
+
switch (embed.$type) {
13
+
case 'app.bsky.embed.external':
14
+
return '🔗 has external link';
15
+
case 'app.bsky.embed.record':
16
+
return '💬 has quote';
17
+
case 'app.bsky.embed.images':
18
+
return '🖼️ has images';
19
+
case 'app.bsky.embed.video':
20
+
return '🎥 has video';
21
+
case 'app.bsky.embed.recordWithMedia':
22
+
return '📎 has quote with media';
23
+
default:
24
+
return '❓ has unknown embed';
25
+
}
26
+
});
27
+
</script>
28
+
29
+
<span
30
+
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
31
+
style="
32
+
background: color-mix(in srgb, {color} 10%, transparent);
33
+
color: {color};
34
+
"
35
+
>
36
+
{embedText}
37
+
</span>
+53
src/components/EmbedMedia.svelte
+53
src/components/EmbedMedia.svelte
···
1
+
<script lang="ts">
2
+
import { AppBskyEmbedExternal, AppBskyEmbedImages, AppBskyEmbedVideo } from '@atcute/bluesky';
3
+
import { isBlob } from '@atcute/lexicons/interfaces';
4
+
import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte';
5
+
import { blob, img } from '$lib/cdn';
6
+
import { type Did } from '@atcute/lexicons';
7
+
import { resolveDidDoc } from '$lib/at/client';
8
+
9
+
interface Props {
10
+
did: Did;
11
+
embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main;
12
+
}
13
+
14
+
let { did, embed }: Props = $props();
15
+
</script>
16
+
17
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
18
+
<div oncontextmenu={(e) => e.stopPropagation()}>
19
+
{#if embed.$type === 'app.bsky.embed.images'}
20
+
{@const _images = embed.images.flatMap((img) =>
21
+
isBlob(img.image) ? [{ ...img, image: img.image }] : []
22
+
)}
23
+
{@const images = _images.map((i): GalleryItem => {
24
+
const sizeFactor = 200;
25
+
const size = {
26
+
width: (i.aspectRatio?.width ?? 4) * sizeFactor,
27
+
height: (i.aspectRatio?.height ?? 3) * sizeFactor
28
+
};
29
+
return {
30
+
...size,
31
+
src: img('feed_fullsize', did, i.image.ref.$link),
32
+
thumbnail: {
33
+
src: img('feed_thumbnail', did, i.image.ref.$link),
34
+
...size
35
+
}
36
+
};
37
+
})}
38
+
<PhotoSwipeGallery {images} />
39
+
{:else if embed.$type === 'app.bsky.embed.video'}
40
+
{#if isBlob(embed.video)}
41
+
{#await resolveDidDoc(did) then didDoc}
42
+
{#if didDoc.ok}
43
+
<!-- svelte-ignore a11y_media_has_caption -->
44
+
<video
45
+
class="rounded-sm"
46
+
src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
47
+
controls
48
+
></video>
49
+
{/if}
50
+
{/await}
51
+
{/if}
52
+
{/if}
53
+
</div>
+2
-1
src/components/PhotoSwipeGallery.svelte
+2
-1
src/components/PhotoSwipeGallery.svelte
···
100
100
gap: 2px;
101
101
border-radius: 4px;
102
102
overflow: hidden;
103
-
width: 100%;
103
+
width: fit-content;
104
104
}
105
105
106
106
.gallery.styling-twitter > a {
···
125
125
.gallery.styling-twitter[data-total='1'] {
126
126
display: block; /* Remove grid constraints */
127
127
height: auto;
128
+
width: fit-content;
128
129
aspect-ratio: auto; /* Remove 16:9 ratio */
129
130
border-radius: 0;
130
131
}