From 569146c5bcd18774301a5b6f58db57885b6b5af2 Mon Sep 17 00:00:00 2001 From: Hampus Date: Fri, 11 Sep 2026 21:06:00 +0200 Subject: [PATCH] fix(app): pick favorite GIF preview kind from content type (#2696) --- .../pickers/gif/GifPickerGridData.test.ts | 78 +++++++++++++++++++ .../pickers/gif/GifPickerGridData.ts | 34 ++++---- .../pickers/gif/GifPickerGridItem.tsx | 35 +++------ .../pickers/gif/GifPickerMediaKind.test.ts | 38 +++++++++ .../pickers/gif/GifPickerMediaKind.ts | 28 +++++++ .../components/pickers/gif/GifPickerTypes.ts | 2 + 6 files changed, 175 insertions(+), 40 deletions(-) create mode 100644 fluxer_app/src/features/channel/components/pickers/gif/GifPickerMediaKind.test.ts create mode 100644 fluxer_app/src/features/channel/components/pickers/gif/GifPickerMediaKind.ts diff --git a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerGridData.test.ts b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerGridData.test.ts index 3eb0b3c74..42f1f550c 100644 --- a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerGridData.test.ts +++ b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerGridData.test.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {describe, expect, it} from 'vitest'; +import type {FavoriteGifEntry} from './FavoriteGifTypes'; import {buildGifPickerGridData} from './GifPickerGridData'; import type {FavoriteAwareGif} from './GifPickerTypes'; @@ -19,7 +20,84 @@ function gif(id: string, width = 200, height = 120): FavoriteAwareGif { }; } +const OPAQUE_PROXY_URL = + 'https://media.test/external/sig/v2/aHR0cHM6Ly9zdGF0aWMua2xpcHkuY29tL2lpLzhjLzZhL2E4LzVsU2U1SFBCLndlYm0'; + +function slimFavorite(overrides: Partial = {}): FavoriteGifEntry { + return { + url: 'https://klipy.com/gifs/doc-brown-bttf', + proxy_url: OPAQUE_PROXY_URL, + width: 640, + height: 360, + media: {}, + content_type: 'video/webm', + placeholder: null, + ...overrides, + }; +} + describe('GifPickerGridData', () => { + it('carries the stored content type of a slimmed favorite into its tile', () => { + const data = buildGifPickerGridData({ + surface: 'favorites', + loading: false, + columns: 3, + provider: 'klipy', + featured: {gifs: [], categories: []}, + gifs: [], + favoriteGifs: [slimFavorite()], + favoriteMemes: [], + useSavedMediaForGifFavorites: false, + featuredFavoritePreviewSeed: 0, + favoriteTitle: 'Favorites', + trendingTitle: 'Trending', + }); + expect(data[0]).toMatchObject({ + type: 'gif', + gif: {proxy_src: OPAQUE_PROXY_URL, contentType: 'video/webm'}, + }); + }); + + it('carries the stored content type of a slimmed favorite into the featured favorites tile', () => { + const data = buildGifPickerGridData({ + surface: 'featured', + loading: false, + columns: 3, + provider: 'klipy', + featured: {gifs: [gif('featured')], categories: []}, + gifs: [], + favoriteGifs: [slimFavorite()], + favoriteMemes: [], + useSavedMediaForGifFavorites: false, + featuredFavoritePreviewSeed: 0, + favoriteTitle: 'Favorites', + trendingTitle: 'Trending', + }); + expect(data[0]).toMatchObject({ + type: 'category', + previewProxySrc: OPAQUE_PROXY_URL, + previewContentType: 'video/webm', + }); + }); + + it('takes the content type of the saved media standing in for an empty favorites tile', () => { + const data = buildGifPickerGridData({ + surface: 'featured', + loading: false, + columns: 3, + provider: 'klipy', + featured: {gifs: [gif('featured')], categories: []}, + gifs: [], + favoriteGifs: [], + favoriteMemes: [{contentType: 'video/mp4', url: 'meme-0'}], + useSavedMediaForGifFavorites: true, + featuredFavoritePreviewSeed: 0, + favoriteTitle: 'Favorites', + trendingTitle: 'Trending', + }); + expect(data[0]).toMatchObject({type: 'category', previewProxySrc: 'meme-0', previewContentType: 'video/mp4'}); + }); + it('keeps featured data deterministic while choosing preview tiles', () => { const data = buildGifPickerGridData({ surface: 'featured', diff --git a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerGridData.ts b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerGridData.ts index 2c1387abf..bca5a047e 100644 --- a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerGridData.ts +++ b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerGridData.ts @@ -2,6 +2,7 @@ import { type FavoriteGifEntry, + inferFormatContentType, pickBestPreviewFormat, } from '@app/features/channel/components/pickers/gif/FavoriteGifTypes'; import type {GifPickerGridItemData} from '@app/features/channel/components/pickers/gif/GifPickerTypes'; @@ -72,6 +73,7 @@ function buildFavoriteGifItems( const previewProxySrc = best?.format.proxy_src ?? fallbackSrc; const previewWidth = best?.format.width ?? entry.width; const previewHeight = best?.format.height ?? entry.height; + const previewContentType = best ? inferFormatContentType(best.key) : entry.content_type; items.push({ type: 'gif', key: entry.url, @@ -86,6 +88,7 @@ function buildFavoriteGifItems( width: previewWidth > 0 ? previewWidth : DEFAULT_GIF_SIZE, height: previewHeight > 0 ? previewHeight : DEFAULT_GIF_SIZE, media: entry.media, + contentType: previewContentType, favoriteGifLookup: {url: entry.url}, }, }); @@ -102,21 +105,21 @@ function buildFeaturedItems(input: BuildGifPickerGridDataInput): Array= 0 ? input.favoriteGifs[favoriteGifPreviewIndex] : undefined; const favoriteGifPreview = pickBestPreviewFormat(favoriteGifPreviewEntry?.media); - const favoriteMemePreview = - gifvMemes.length > 0 - ? (gifvMemes[Math.floor(input.featuredFavoritePreviewSeed * gifvMemes.length)]?.url ?? '') - : ''; - const favoriteTilePreview = - input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0 - ? favoriteMemePreview - : favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || ''; - const favoriteTileProxyPreview = - input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0 - ? favoriteTilePreview - : favoriteGifPreview?.format.proxy_src || - favoriteGifPreviewEntry?.proxy_url || - favoriteGifPreviewEntry?.url || - ''; + const favoriteMemeCandidate = + gifvMemes.length > 0 ? (gifvMemes[Math.floor(input.featuredFavoritePreviewSeed * gifvMemes.length)] ?? null) : null; + const favoriteMemePreview = favoriteMemeCandidate?.url ?? ''; + const usesFavoriteMemePreview = input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0; + const favoriteTilePreview = usesFavoriteMemePreview + ? favoriteMemePreview + : favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || ''; + const favoriteTileProxyPreview = usesFavoriteMemePreview + ? favoriteTilePreview + : favoriteGifPreview?.format.proxy_src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || ''; + const favoriteTileContentType = (() => { + if (usesFavoriteMemePreview) return favoriteMemeCandidate?.contentType ?? ''; + if (favoriteGifPreview) return inferFormatContentType(favoriteGifPreview.key); + return favoriteGifPreviewEntry?.proxy_url ? favoriteGifPreviewEntry.content_type : ''; + })(); const favoritesTile: Array = (input.includeFavoritesTile ?? true) ? [ @@ -128,6 +131,7 @@ function buildFeaturedItems(input: BuildGifPickerGridDataInput): Array { + if (item.type === 'gif') return item.gif.contentType ?? ''; + if (item.type === 'category') return item.previewContentType ?? ''; + return ''; + })(); const thumbnailPlaceholder = (() => { if (item.type !== 'gif') return null; if (item.gif.placeholder) return item.gif.placeholder; @@ -133,7 +115,10 @@ export const GifPickerGridItem = observer(function GifPickerGridItem({ return FavoriteGif.findByUrl(lookupUrl)?.placeholder ?? null; })(); const usesVideoElement = - !isSkeleton && proxySrc !== null && proxySrc.length > 0 && resolvesToVideo(proxySrc, mediaSourceUrl); + !isSkeleton && + proxySrc !== null && + proxySrc.length > 0 && + resolvesToVideo(mediaContentType, proxySrc, mediaSourceUrl); const videoThumbHashURL = decodeThumbHashDataURL(usesVideoElement ? thumbnailPlaceholder : null); const hasThumbnailContent = (proxySrc !== null && proxySrc.length > 0) || thumbnailPlaceholder !== null; const videoRef = usePooledVideo({ diff --git a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerMediaKind.test.ts b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerMediaKind.test.ts new file mode 100644 index 000000000..f68507860 --- /dev/null +++ b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerMediaKind.test.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {describe, expect, it} from 'vitest'; +import {resolvesToVideo} from './GifPickerMediaKind'; + +const OPAQUE_PROXY_SRC = + 'https://media.test/external/sig/v2/aHR0cHM6Ly9zdGF0aWMua2xpcHkuY29tL2lpLzhjLzZhL2E4LzVsU2U1SFBCLndlYm0'; +const PLAIN_PROXY_SRC = 'https://media.test/external/sig/https/static.klipy.com/ii/8c/6a/a8/5lSe5HPB.webm'; + +describe('resolvesToVideo', () => { + it('trusts a stored video content type when no url states its kind', () => { + expect(resolvesToVideo('video/webm', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(true); + }); + + it('trusts a stored image content type when no url states its kind', () => { + expect(resolvesToVideo('image/gif', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(false); + }); + + it('falls back to an image when nothing states the kind', () => { + expect(resolvesToVideo('', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(false); + }); + + it('reads the extension off a plain proxy path', () => { + expect(resolvesToVideo('', PLAIN_PROXY_SRC, null)).toBe(true); + }); + + it('reads the extension off the media source when the proxy path is opaque', () => { + expect(resolvesToVideo('', OPAQUE_PROXY_SRC, 'https://static.klipy.com/ii/8c/6a/a8/5lSe5HPB.webm')).toBe(true); + }); + + it('keeps images as images when the proxy path states its kind', () => { + expect(resolvesToVideo('', 'https://media.test/external/sig/https/cdn.test/a.gif', null)).toBe(false); + }); + + it('ignores a query string when reading the extension', () => { + expect(resolvesToVideo('', 'https://media.test/external/sig/https/cdn.test/a.mp4?width=200', null)).toBe(true); + }); +}); diff --git a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerMediaKind.ts b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerMediaKind.ts new file mode 100644 index 000000000..f4a18694b --- /dev/null +++ b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerMediaKind.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +const VIDEO_FILE_EXTENSION_REGEX = /\.(mp4|webm|mov|m4v)(?:$|\?)/iu; +const IMAGE_FILE_EXTENSION_REGEX = /\.(gif|webp|png|jpe?g|avif)(?:$|\?)/iu; + +function testSourcePath(value: string, pattern: RegExp): boolean { + try { + const url = new URL(value); + return pattern.test(url.pathname); + } catch { + return pattern.test(value); + } +} + +function isVideoSourceUrl(value: string): boolean { + return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX); +} + +function statesItsMediaKind(value: string): boolean { + return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX) || testSourcePath(value, IMAGE_FILE_EXTENSION_REGEX); +} + +export function resolvesToVideo(contentType: string, proxySrc: string, mediaSourceUrl: string | null): boolean { + if (contentType.startsWith('video/')) return true; + if (contentType.startsWith('image/')) return false; + if (statesItsMediaKind(proxySrc)) return isVideoSourceUrl(proxySrc); + return mediaSourceUrl !== null && isVideoSourceUrl(mediaSourceUrl); +} diff --git a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerTypes.ts b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerTypes.ts index cd3dc6d1c..1e973c4ab 100644 --- a/fluxer_app/src/features/channel/components/pickers/gif/GifPickerTypes.ts +++ b/fluxer_app/src/features/channel/components/pickers/gif/GifPickerTypes.ts @@ -10,6 +10,7 @@ export interface FavoriteGifLookup { export type FavoriteAwareGif = Gif & { favoriteGifLookup?: FavoriteGifLookup; + contentType?: string; }; export type GifPickerGridItemData = | { @@ -20,6 +21,7 @@ export type GifPickerGridItemData = categoryKind: 'favorites' | 'trending' | 'category'; previewUrl: string; previewProxySrc: string; + previewContentType?: string; width: number; height: number; }