-
- {#each ['webp'] as format}
-
- {/each}
-
-
+

(imgError = true)}
+ class=""
+ width={600}
+ height={400}
+ alt=""
+ />
{
expect(bestImageURL(undefined)).toBe('')
})
- it('returns optimized URL for image embed', () => {
+ it('returns image URL for image embed', () => {
const result = bestImageURL(imageEmbed)
- expect(result).toContain('cdn.example.com/pic.jpg')
- expect(result).toContain('format=webp')
+ expect(result).toBe('https://cdn.example.com/pic.jpg')
+ })
+
+ it('returns thumb by default when thumb field is present', () => {
+ const embedWithThumb = {
+ $type: 'social.coves.embed.images#view' as const,
+ images: [
+ {
+ image: 'https://cdn.example.com/pic.jpg',
+ thumb: 'https://cdn.example.com/pic-thumb.jpg',
+ alt: 'A photo',
+ },
+ ],
+ } satisfies import('$lib/api/coves/types').ImageEmbed
+ // default variant (no 3rd arg) should use 'thumb'
+ expect(bestImageURL(embedWithThumb)).toBe(
+ 'https://cdn.example.com/pic-thumb.jpg',
+ )
+ })
+
+ it('returns thumb field when available for image embed', () => {
+ const embedWithThumb: ImageEmbed = {
+ $type: 'social.coves.embed.images#view',
+ images: [
+ {
+ image: 'https://cdn.example.com/pic.jpg',
+ thumb: 'https://cdn.example.com/pic-thumb.jpg',
+ alt: 'A photo',
+ },
+ ],
+ }
+ expect(bestImageURL(embedWithThumb, true, 'thumb')).toBe(
+ 'https://cdn.example.com/pic-thumb.jpg',
+ )
+ })
+
+ it('returns fullsize field when variant is fullsize', () => {
+ const embedWithFullsize: ImageEmbed = {
+ $type: 'social.coves.embed.images#view',
+ images: [
+ {
+ image: 'https://cdn.example.com/pic.jpg',
+ fullsize: 'https://cdn.example.com/pic-full.jpg',
+ alt: 'A photo',
+ },
+ ],
+ }
+ expect(bestImageURL(embedWithFullsize, true, 'fullsize')).toBe(
+ 'https://cdn.example.com/pic-full.jpg',
+ )
})
it('returns thumbnail for external embed when thumbnail=true', () => {
const result = bestImageURL(externalEmbed, true)
- expect(result).toContain('cdn.example.com/thumb.jpg')
+ expect(result).toBe('https://cdn.example.com/thumb.jpg')
})
it('returns external URI when no thumb and thumbnail=false', () => {
@@ -159,7 +207,7 @@ describe('bestImageURL', () => {
it('returns thumbnail for video embed', () => {
const result = bestImageURL(videoEmbed)
- expect(result).toContain('cdn.example.com/video-thumb.jpg')
+ expect(result).toBe('https://cdn.example.com/video-thumb.jpg')
})
it('returns empty string for video embed with no thumbnail', () => {
@@ -173,6 +221,22 @@ describe('bestImageURL', () => {
it('returns empty string for record embed', () => {
expect(bestImageURL(recordEmbed)).toBe('')
})
+
+ it('returns empty string for image embed with empty images (type-cast edge case)', () => {
+ const emptyImages = {
+ $type: 'social.coves.embed.images#view' as const,
+ images: [] as unknown as [
+ import('$lib/api/coves/types').EmbedImage,
+ ...import('$lib/api/coves/types').EmbedImage[],
+ ],
+ }
+ expect(bestImageURL(emptyImages)).toBe('')
+ })
+
+ it('returns external URI (not thumb) when thumbnail=false', () => {
+ const result = bestImageURL(externalEmbed, false)
+ expect(result).toBe('https://example.com/article')
+ })
})
// ---------------------------------------------------------------------------
@@ -180,36 +244,33 @@ describe('bestImageURL', () => {
// ---------------------------------------------------------------------------
describe('optimizeImageURL', () => {
- it('adds format and thumbnail params', () => {
+ it('swaps preset in proxy URLs', () => {
const result = optimizeImageURL(
- 'https://cdn.example.com/pic.jpg',
- 512,
- 'webp',
+ 'https://cdn.example.com/img/content_preview/plain/did:plc:abc/bafyreig1',
+ 'content_full',
+ )
+ expect(result).toBe(
+ 'https://cdn.example.com/img/content_full/plain/did:plc:abc/bafyreig1',
)
- expect(result).toContain('format=webp')
- expect(result).toContain('thumbnail=512')
- })
-
- it('returns original string for invalid URLs', () => {
- expect(optimizeImageURL('not-a-url')).toBe('not-a-url')
})
- it('respects null format param', () => {
- const result = optimizeImageURL(
+ it('returns non-proxy URLs unchanged', () => {
+ expect(optimizeImageURL('https://cdn.example.com/pic.jpg')).toBe(
'https://cdn.example.com/pic.jpg',
- 256,
- null,
)
- expect(result).not.toContain('format=')
})
- it('does not overwrite existing thumbnail param', () => {
- const result = optimizeImageURL(
- 'https://cdn.example.com/pic.jpg?thumbnail=128',
- 1024,
+ it('returns non-URL strings unchanged', () => {
+ expect(optimizeImageURL('not-a-url')).toBe('not-a-url')
+ })
+
+ it('uses content_preview as default preset', () => {
+ const proxyUrl =
+ 'https://cdn.example.com/img/avatar/plain/did:plc:abc/bafyreig1'
+ const result = optimizeImageURL(proxyUrl)
+ expect(result).toBe(
+ 'https://cdn.example.com/img/content_preview/plain/did:plc:abc/bafyreig1',
)
- expect(result).toContain('thumbnail=128')
- expect(result).not.toContain('thumbnail=1024')
})
})
diff --git a/src/lib/feature/post/helpers.ts b/src/lib/feature/post/helpers.ts
index 55255bed..c01b5b70 100644
--- a/src/lib/feature/post/helpers.ts
+++ b/src/lib/feature/post/helpers.ts
@@ -8,71 +8,67 @@ import { parseAtUri } from '$lib/api/coves/types'
import {
canParseUrl,
communitySlug,
- findClosestNumber,
isImage,
isVideo,
} from '$lib/app/util.svelte'
+import {
+ type ImagePreset,
+ type ImageVariant,
+ imageUrl,
+ withPreset,
+} from './image-proxy'
-// Algorithm to determine the best image URL to use from a Coves PostView's embed
+/**
+ * Returns the best image URL for a post embed.
+ *
+ * @param embed - The post embed to extract an image URL from.
+ * @param thumbnail - For external embeds only: when true, prefer the external
+ * embed's dedicated thumbnail over its URI. Has no effect on other embed types.
+ * @param variant - For image embeds only: selects the 'thumb' (smaller) or
+ * 'fullsize' proxy variant. Has no effect on other embed types.
+ */
export const bestImageURL = (
embed: PostEmbed | undefined,
thumbnail: boolean = true,
- width: number = 1024,
- format: 'avif' | 'webp' | null = 'webp',
+ variant: ImageVariant = 'thumb',
): string => {
if (!embed) return ''
switch (embed.$type) {
case 'social.coves.embed.images#view': {
const img = embed.images[0]
- if (img?.image) return optimizeImageURL(img.image, width, format)
+ if (img) return imageUrl(img, variant)
return ''
}
+ case 'social.coves.embed.external':
case 'social.coves.embed.external#view': {
- if (embed.external.thumb && thumbnail)
- return optimizeImageURL(embed.external.thumb, width, format)
+ if (embed.external.thumb && thumbnail) return embed.external.thumb
return embed.external.uri ?? ''
}
+ case 'social.coves.embed.video':
case 'social.coves.embed.video#view': {
- if (embed.thumbnail)
- return optimizeImageURL(embed.thumbnail, width, format)
+ if (embed.thumbnail) return embed.thumbnail
return ''
}
- default:
+ case 'social.coves.embed.post':
+ case 'social.coves.embed.record':
+ case 'social.coves.embed.record#view':
return ''
+ default: {
+ const _exhaustive: never = embed
+ return ''
+ }
}
}
+/**
+ * @deprecated Use `withPreset` from `./image-proxy` directly.
+ */
export const optimizeImageURL = (
- urlStr: string,
- width: number = 1024,
- format: 'avif' | 'webp' | null = 'webp',
+ url: string,
+ preset: ImagePreset = 'content_preview',
): string => {
- try {
- let url: URL
- try {
- url = new URL(urlStr)
- } catch {
- return urlStr
- }
-
- if (format) url.searchParams.set('format', format)
-
- if (width > 0 && !url.searchParams.has('thumbnail')) {
- url.searchParams.set(
- 'thumbnail',
- findClosestNumber(
- [128, 196, 256, 512, 728, 1024, 1536],
- width,
- ).toString(),
- )
- }
-
- return url.toString()
- } catch (e) {
- console.error(e)
- return urlStr
- }
+ return withPreset(url, preset)
}
const YOUTUBE_REGEX =
@@ -104,8 +100,10 @@ export function mediaType(embed?: PostEmbed): MediaType {
switch (embed.$type) {
case 'social.coves.embed.images#view':
return 'image'
+ case 'social.coves.embed.video':
case 'social.coves.embed.video#view':
return 'iframe'
+ case 'social.coves.embed.external':
case 'social.coves.embed.external#view': {
const uri = embed.external.uri
if (!uri) return 'none'
@@ -122,10 +120,14 @@ export function mediaType(embed?: PostEmbed): MediaType {
if (canParseUrl(uri)) return 'embed'
return 'none'
}
+ case 'social.coves.embed.post':
+ case 'social.coves.embed.record':
case 'social.coves.embed.record#view':
return 'embed'
- default:
+ default: {
+ const _exhaustive: never = embed
return 'none'
+ }
}
}
@@ -144,14 +146,20 @@ export function extractEmbedUrl(embed?: PostEmbed): string | undefined {
switch (embed.$type) {
case 'social.coves.embed.images#view':
return embed.images[0]?.image
+ case 'social.coves.embed.external':
case 'social.coves.embed.external#view':
return embed.external.uri
+ case 'social.coves.embed.video':
case 'social.coves.embed.video#view':
return embed.video
+ case 'social.coves.embed.post':
+ case 'social.coves.embed.record':
case 'social.coves.embed.record#view':
return undefined
- default:
+ default: {
+ const _exhaustive: never = embed
return undefined
+ }
}
}
@@ -162,14 +170,24 @@ export function extractEmbedThumbnail(embed?: PostEmbed): string | undefined {
if (!embed) return undefined
switch (embed.$type) {
- case 'social.coves.embed.images#view':
- return embed.images[0]?.image
+ case 'social.coves.embed.images#view': {
+ const img = embed.images[0]
+ return img ? imageUrl(img, 'thumb') : undefined
+ }
+ case 'social.coves.embed.external':
case 'social.coves.embed.external#view':
return embed.external.thumb
+ case 'social.coves.embed.video':
case 'social.coves.embed.video#view':
return embed.thumbnail
- default:
+ case 'social.coves.embed.post':
+ case 'social.coves.embed.record':
+ case 'social.coves.embed.record#view':
+ return undefined
+ default: {
+ const _exhaustive: never = embed
return undefined
+ }
}
}
@@ -180,10 +198,20 @@ export function extractEmbedTitle(embed?: PostEmbed): string | undefined {
if (!embed) return undefined
switch (embed.$type) {
+ case 'social.coves.embed.external':
case 'social.coves.embed.external#view':
return embed.external.title
- default:
+ case 'social.coves.embed.images#view':
+ case 'social.coves.embed.video':
+ case 'social.coves.embed.video#view':
+ case 'social.coves.embed.post':
+ case 'social.coves.embed.record':
+ case 'social.coves.embed.record#view':
return undefined
+ default: {
+ const _exhaustive: never = embed
+ return undefined
+ }
}
}
@@ -196,10 +224,19 @@ export function extractEmbedAlt(embed?: PostEmbed): string | undefined {
switch (embed.$type) {
case 'social.coves.embed.images#view':
return embed.images[0]?.alt
+ case 'social.coves.embed.video':
case 'social.coves.embed.video#view':
return embed.alt
- default:
+ case 'social.coves.embed.external':
+ case 'social.coves.embed.external#view':
+ case 'social.coves.embed.post':
+ case 'social.coves.embed.record':
+ case 'social.coves.embed.record#view':
return undefined
+ default: {
+ const _exhaustive: never = embed
+ return undefined
+ }
}
}
diff --git a/src/lib/feature/post/image-proxy.test.ts b/src/lib/feature/post/image-proxy.test.ts
new file mode 100644
index 00000000..cc486c1c
--- /dev/null
+++ b/src/lib/feature/post/image-proxy.test.ts
@@ -0,0 +1,142 @@
+import { describe, it, expect } from 'vitest'
+import type { EmbedImage } from '$lib/api/coves/types'
+import { parseProxyUrl, withPreset, imageUrl } from './image-proxy'
+
+describe('parseProxyUrl', () => {
+ it('parses a relative proxy URL', () => {
+ const result = parseProxyUrl(
+ '/img/avatar_small/plain/did:plc:abc123/bafkrei456',
+ )
+ expect(result).toEqual({
+ preset: 'avatar_small',
+ did: 'did:plc:abc123',
+ cid: 'bafkrei456',
+ })
+ })
+
+ it('parses a full proxy URL with base domain', () => {
+ const result = parseProxyUrl(
+ 'https://coves.social/img/content_full/plain/did:plc:xyz/bafyrei789',
+ )
+ expect(result).toEqual({
+ preset: 'content_full',
+ did: 'did:plc:xyz',
+ cid: 'bafyrei789',
+ })
+ })
+
+ it('returns null for non-proxy URLs', () => {
+ expect(parseProxyUrl('https://example.com/image.jpg')).toBeNull()
+ expect(parseProxyUrl('')).toBeNull()
+ expect(parseProxyUrl('not-a-url')).toBeNull()
+ })
+
+ it('returns null for PDS blob URLs', () => {
+ expect(
+ parseProxyUrl(
+ 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=abc&cid=xyz',
+ ),
+ ).toBeNull()
+ })
+
+ it('parses a proxy URL with did:web: DID method', () => {
+ const result = parseProxyUrl(
+ 'https://coves.social/img/avatar/plain/did:web:example.com/bafkrei456',
+ )
+ expect(result).toEqual({
+ preset: 'avatar',
+ did: 'did:web:example.com',
+ cid: 'bafkrei456',
+ })
+ })
+})
+
+describe('withPreset', () => {
+ it('swaps preset on a relative proxy URL', () => {
+ expect(
+ withPreset('/img/avatar_small/plain/did:plc:abc/bafkrei123', 'avatar'),
+ ).toBe('/img/avatar/plain/did:plc:abc/bafkrei123')
+ })
+
+ it('swaps preset on a full proxy URL', () => {
+ expect(
+ withPreset(
+ 'https://coves.social/img/content_preview/plain/did:plc:xyz/bafyrei456',
+ 'content_full',
+ ),
+ ).toBe('https://coves.social/img/content_full/plain/did:plc:xyz/bafyrei456')
+ })
+
+ it('returns non-proxy URLs unchanged', () => {
+ const pdsUrl =
+ 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=abc&cid=xyz'
+ expect(withPreset(pdsUrl, 'content_full')).toBe(pdsUrl)
+ })
+
+ it('returns empty string unchanged', () => {
+ expect(withPreset('', 'avatar')).toBe('')
+ })
+
+ it('strips query strings when swapping preset', () => {
+ const url =
+ 'https://coves.social/img/avatar/plain/did:plc:abc/bafkrei123?format=webp'
+ const result = withPreset(url, 'content_full')
+ expect(result).toBe(
+ 'https://coves.social/img/content_full/plain/did:plc:abc/bafkrei123',
+ )
+ })
+})
+
+describe('imageUrl', () => {
+ it('returns thumb when available for thumb variant', () => {
+ const img: EmbedImage = {
+ image: 'https://original.com/pic.jpg',
+ thumb: 'https://proxy.com/img/content_preview/plain/did:plc:a/baf1',
+ fullsize: 'https://proxy.com/img/content_full/plain/did:plc:a/baf1',
+ }
+ expect(imageUrl(img, 'thumb')).toBe(img.thumb)
+ })
+
+ it('returns fullsize when available for fullsize variant', () => {
+ const img: EmbedImage = {
+ image: 'https://original.com/pic.jpg',
+ thumb: 'https://proxy.com/img/content_preview/plain/did:plc:a/baf1',
+ fullsize: 'https://proxy.com/img/content_full/plain/did:plc:a/baf1',
+ }
+ expect(imageUrl(img, 'fullsize')).toBe(img.fullsize)
+ })
+
+ it('falls back to image when thumb is undefined', () => {
+ const img: EmbedImage = { image: 'https://original.com/pic.jpg' }
+ expect(imageUrl(img, 'thumb')).toBe(img.image)
+ })
+
+ it('falls back to image when fullsize is undefined', () => {
+ const img: EmbedImage = { image: 'https://original.com/pic.jpg' }
+ expect(imageUrl(img, 'fullsize')).toBe(img.image)
+ })
+
+ it('defaults to thumb variant', () => {
+ const img: EmbedImage = {
+ image: 'https://original.com/pic.jpg',
+ thumb: 'https://proxy.com/thumb',
+ }
+ expect(imageUrl(img)).toBe(img.thumb)
+ })
+
+ it('falls back to image when thumb is empty string', () => {
+ const img: EmbedImage = {
+ image: 'https://original.com/pic.jpg',
+ thumb: '',
+ }
+ expect(imageUrl(img, 'thumb')).toBe(img.image)
+ })
+
+ it('falls back to image when fullsize is empty string', () => {
+ const img: EmbedImage = {
+ image: 'https://original.com/pic.jpg',
+ fullsize: '',
+ }
+ expect(imageUrl(img, 'fullsize')).toBe(img.image)
+ })
+})
diff --git a/src/lib/feature/post/image-proxy.ts b/src/lib/feature/post/image-proxy.ts
new file mode 100644
index 00000000..ab551e48
--- /dev/null
+++ b/src/lib/feature/post/image-proxy.ts
@@ -0,0 +1,64 @@
+import type { EmbedImage } from '$lib/api/coves/types'
+
+/**
+ * Available image proxy presets matching the Coves backend.
+ * The proxy serves at /img/{preset}/plain/{did}/{cid}.
+ */
+export type ImagePreset =
+ | 'avatar'
+ | 'avatar_small'
+ | 'banner'
+ | 'content_preview'
+ | 'content_full'
+ | 'embed_thumbnail'
+
+const PROXY_URL_REGEX = /\/img\/([a-z_]+)\/plain\/(did:[^/]+)\/([^/?#]+)/
+
+export interface ParsedProxyUrl {
+ preset: ImagePreset | (string & {})
+ did: string
+ cid: string
+}
+
+/**
+ * Parses a Coves image proxy URL into its components.
+ * Returns null if the URL is not a proxy URL.
+ */
+export function parseProxyUrl(url: string): ParsedProxyUrl | null {
+ const match = url.match(PROXY_URL_REGEX)
+ if (!match) return null
+ return { preset: match[1], did: match[2], cid: match[3] }
+}
+
+/**
+ * Constructs a proxy URL with a different preset.
+ * If the input is already a proxy URL, swaps the preset.
+ * If the input is not a proxy URL, returns it unmodified.
+ */
+export function withPreset(url: string, preset: ImagePreset): string {
+ const parsed = parseProxyUrl(url)
+ if (!parsed) {
+ if (import.meta.env.DEV && url) {
+ console.warn(`[image-proxy] withPreset called with non-proxy URL: ${url}`)
+ }
+ return url
+ }
+
+ const baseEnd = url.indexOf('/img/')
+ const base = baseEnd >= 0 ? url.substring(0, baseEnd) : ''
+ return `${base}/img/${preset}/plain/${parsed.did}/${parsed.cid}`
+}
+
+export type ImageVariant = 'thumb' | 'fullsize'
+
+/**
+ * Returns the best URL for an EmbedImage given a display variant.
+ * Prefers thumb/fullsize when present and non-empty, falls back to image.
+ */
+export function imageUrl(
+ image: EmbedImage,
+ variant: ImageVariant = 'thumb',
+): string {
+ if (variant === 'fullsize') return image.fullsize || image.image
+ return image.thumb || image.image
+}
diff --git a/src/lib/feature/post/index.ts b/src/lib/feature/post/index.ts
index cb4adb01..7a395334 100644
--- a/src/lib/feature/post/index.ts
+++ b/src/lib/feature/post/index.ts
@@ -19,3 +19,4 @@ export {
} from './PostMeta.svelte'
export * from './helpers'
+export * from './image-proxy'
diff --git a/src/lib/feature/post/media/PostIframe.svelte b/src/lib/feature/post/media/PostIframe.svelte
index a4bc228f..6fb2a842 100644
--- a/src/lib/feature/post/media/PostIframe.svelte
+++ b/src/lib/feature/post/media/PostIframe.svelte
@@ -8,7 +8,8 @@
PuzzlePiece,
VideoCamera,
} from 'svelte-hero-icons/dist'
- import { type IframeType, optimizeImageURL } from '../helpers'
+ import { type IframeType } from '../helpers'
+ import { withPreset } from '../image-proxy'
const youtubeDomain = (place: 'youtube' | 'invidious' | 'piped') => {
switch (place) {
@@ -116,6 +117,7 @@
class: clazz,
}: Props = $props()
+ let thumbError = $state(false)
let data = $derived(typeData(type))
let embedUrl = $derived(urlToEmbed(url))
@@ -146,9 +148,10 @@
- {#if thumbnail}
+ {#if thumbnail && !thumbError}
diff --git a/src/lib/ui/generic/Avatar.svelte b/src/lib/ui/generic/Avatar.svelte
index 1e811e2b..7ec1c336 100644
--- a/src/lib/ui/generic/Avatar.svelte
+++ b/src/lib/ui/generic/Avatar.svelte
@@ -1,40 +1,15 @@
-{#if url && optimizedURLs[0] != undefined}
+{#if url && !imgError}

(imgError = true)}
alt=""
{width}
{title}