From bc050009e78f8fd44bc9e5f57b84bcd44abcceea Mon Sep 17 00:00:00 2001 From: "xan.lol" Date: Sun, 8 Mar 2026 21:06:00 -0700 Subject: [PATCH] feat: option for a different image cdn --- src/components/MediaPreview.tsx | 38 ++++-- .../Embed/ExternalEmbed/ExternalPlayer.tsx | 14 +- .../Post/Embed/ExternalEmbed/index.tsx | 14 +- src/components/Post/Embed/ImageEmbed.tsx | 18 ++- src/components/Post/Embed/index.tsx | 4 +- src/components/images/AutoSizedImage.tsx | 13 +- src/components/images/Gallery.tsx | 13 +- src/screens/Post/PostLikedBy.tsx | 2 +- src/screens/Post/PostQuotes.tsx | 2 +- src/screens/Post/PostRepostedBy.tsx | 2 +- src/screens/Profile/Header/Shell.tsx | 26 ++-- src/screens/Profile/ProfileFollowers.tsx | 2 +- src/screens/Profile/ProfileFollows.tsx | 2 +- src/screens/Settings/RunesSettings.tsx | 113 +++++++++++++++- src/state/persisted/schema.ts | 2 + src/state/preferences/image-cdn-host.tsx | 117 ++++++++++++++++ src/state/preferences/index.tsx | 126 +++++++++--------- src/view/com/util/UserAvatar.tsx | 22 +-- src/view/com/util/UserBanner.tsx | 18 ++- src/view/shell/Drawer.tsx | 2 +- 20 files changed, 425 insertions(+), 125 deletions(-) create mode 100644 src/state/preferences/image-cdn-host.tsx diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index b74841ff8..91513d838 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -4,10 +4,11 @@ import {type AppBskyFeedDefs} from '@atproto/api' import {Trans} from '@lingui/react/macro' import {isTenorGifUri} from '#/lib/strings/embed-player' +import {useHighQualityImages} from '#/state/preferences/high-quality-images' import { - maybeModifyHighQualityImage, - useHighQualityImages, -} from '#/state/preferences/high-quality-images' + applyImageTransforms, + useImageCdnHost, +} from '#/state/preferences/image-cdn-host' import {atoms as a, useTheme} from '#/alf' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {Text} from '#/components/Typography' @@ -24,7 +25,6 @@ export function Embed({ embed: AppBskyFeedDefs.PostView['embed'] style?: StyleProp }) { - const highQualityImages = useHighQualityImages() const e = bsky.post.parseEmbed(embed) if (!e) return null @@ -35,10 +35,7 @@ export function Embed({ {e.view.images.map(image => ( ))} @@ -59,9 +56,15 @@ export function Embed({ return ( {e.view.presentation === 'gif' ? ( - + ) : ( - + )} ) @@ -98,8 +101,17 @@ export function ImageItem({ children?: React.ReactNode }) { const t = useTheme() + const highQualityImages = useHighQualityImages() + const imageCdnHost = useImageCdnHost() + + const transformedThumbnail = thumbnail + ? applyImageTransforms(thumbnail, { + imageCdnHost, + highQualityImages, + }) + : undefined - if (!thumbnail) { + if (!transformedThumbnail) { return ( {alt} diff --git a/src/components/Post/Embed/ExternalEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx index 17c10c486..6dd7c0b44 100644 --- a/src/components/Post/Embed/ExternalEmbed/index.tsx +++ b/src/components/Post/Embed/ExternalEmbed/index.tsx @@ -11,6 +11,11 @@ import {shareUrl} from '#/lib/sharing' import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player' import {toNiceDomain} from '#/lib/strings/url-helpers' import {useExternalEmbedsPrefs} from '#/state/preferences' +import {useHighQualityImages} from '#/state/preferences/high-quality-images' +import { + applyImageTransforms, + useImageCdnHost, +} from '#/state/preferences/image-cdn-host' import {atoms as a, useTheme} from '#/alf' import {Divider} from '#/components/Divider' import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' @@ -36,8 +41,15 @@ export const ExternalEmbed = ({ const t = useTheme() const playHaptic = useHaptics() const externalEmbedPrefs = useExternalEmbedsPrefs() + const highQualityImages = useHighQualityImages() + const imageCdnHost = useImageCdnHost() const niceUrl = toNiceDomain(link.uri) const imageUri = link.thumb + ? applyImageTransforms(link.thumb, { + imageCdnHost, + highQualityImages, + }) + : undefined const embedPlayerParams = React.useMemo(() => { const params = parseEmbedPlayerFromUrl(link.uri) @@ -132,7 +144,7 @@ export const ExternalEmbed = ({ {link.description ? ( {link.description} diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index f46f033b3..b7d1bd74a 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -9,10 +9,11 @@ import { import {Image} from 'expo-image' import {useLightboxControls} from '#/state/lightbox' +import {useHighQualityImages} from '#/state/preferences/high-quality-images' import { - maybeModifyHighQualityImage, - useHighQualityImages, -} from '#/state/preferences/high-quality-images' + applyImageTransforms, + useImageCdnHost, +} from '#/state/preferences/image-cdn-host' import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' import {atoms as a} from '#/alf' import {AutoSizedImage} from '#/components/images/AutoSizedImage' @@ -29,12 +30,19 @@ export function ImageEmbed({ }) { const {openLightbox} = useLightboxControls() const highQualityImages = useHighQualityImages() + const imageCdnHost = useImageCdnHost() const {images} = embed.view if (images.length > 0) { const items = images.map(img => ({ - uri: maybeModifyHighQualityImage(img.fullsize, highQualityImages), - thumbUri: maybeModifyHighQualityImage(img.thumb, highQualityImages), + uri: applyImageTransforms(img.fullsize, { + imageCdnHost, + highQualityImages, + }), + thumbUri: applyImageTransforms(img.thumb, { + imageCdnHost, + highQualityImages, + }), alt: img.alt, dimensions: img.aspectRatio ?? null, })) diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index 28ecac0c2..9fe01188e 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -9,8 +9,8 @@ import { RichText as RichTextAPI, } from '@atproto/api' import {msg} from '@lingui/core/macro' -import {Trans} from '@lingui/react/macro' import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {makeProfileLink} from '#/lib/routes/links' @@ -377,7 +377,7 @@ export function QuoteEmbed({ author={quote.author} moderation={moderation} showAvatar - showPronouns={showPronouns} + showPronouns={showPronouns} postHref={itemHref} timestamp={quote.indexedAt} linkDisabled diff --git a/src/components/images/AutoSizedImage.tsx b/src/components/images/AutoSizedImage.tsx index bb4b9de8d..22fb1285d 100644 --- a/src/components/images/AutoSizedImage.tsx +++ b/src/components/images/AutoSizedImage.tsx @@ -12,10 +12,11 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {type Dimensions} from '#/lib/media/types' +import {useHighQualityImages} from '#/state/preferences/high-quality-images' import { - maybeModifyHighQualityImage, - useHighQualityImages, -} from '#/state/preferences/high-quality-images' + applyImageTransforms, + useImageCdnHost, +} from '#/state/preferences/image-cdn-host' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {atoms as a, useTheme} from '#/alf' import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' @@ -90,6 +91,7 @@ export function AutoSizedImage({ const containerRef = useAnimatedRef() const fetchedDimsRef = useRef<{width: number; height: number} | null>(null) const highQualityImages = useHighQualityImages() + const imageCdnHost = useImageCdnHost() let aspectRatio: number | undefined const dims = image.aspectRatio @@ -120,7 +122,10 @@ export function AutoSizedImage({ { + try { + setImageCdnHost(new URL(url).origin) + } catch { + setImageCdnHost(url) + } + control.close() + } + + const shouldDisable = () => { + try { + return !new URL(url).hostname.includes('.') + } catch (e) { + return true + } + } + + return ( + setUrl(imageCdnHost ?? '')}> + + + + + Image CDN URL + + + + + { + setUrl(value) + }} + placeholder={persisted.defaults.imageCdnHost} + placeholderTextColor={pal.colors.textLight} + onSubmitEditing={submit} + accessibilityHint={_(msg`Input the URL of the image CDN to use`)} + defaultValue={imageCdnHost} + /> + + + + + + + + + + ) +} + function PostReplacementDialog({ control, }: { @@ -657,6 +736,7 @@ export function RunesSettingsScreen({}: Props) { const highQualityImages = useHighQualityImages() const setHighQualityImages = useSetHighQualityImages() + const imageCdnHost = useImageCdnHost() const hideFeedsPromoTab = useHideFeedsPromoTab() const setHideFeedsPromoTab = useSetHideFeedsPromoTab() @@ -733,6 +813,8 @@ export function RunesSettingsScreen({}: Props) { const setLibreTranslateInstanceControl = Dialog.useDialogControl() + const setImageCdnHostControl = Dialog.useDialogControl() + const setPostReplacementDialogControl = Dialog.useDialogControl() const setOpenRouterApiKeyControl = Dialog.useDialogControl() @@ -1007,7 +1089,6 @@ export function RunesSettingsScreen({}: Props) { longer to load and use more bandwidth. - This only gets rid of the reminder on app launch, useful if your - PDS does not have email verification setup.\nThis does NOT give - access to features locked behind email verification. + PDS does not have email verification setup.\u00A0 This does NOT + give access to features locked behind email verification. @@ -1358,6 +1439,29 @@ export function RunesSettingsScreen({}: Props) { + + + + {`Image CDN`} + + setImageCdnHostControl.open()} + /> + + + + + Override the CDN host for all images. Current:  + + {imageCdnHost} + + + + + + + @@ -1406,6 +1510,7 @@ export function RunesSettingsScreen({}: Props) { + diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 0f2665134..7aee18bfb 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -173,6 +173,7 @@ const schema = z.object({ }) .optional(), highQualityImages: z.boolean().optional(), + imageCdnHost: z.string().optional(), hideUnreplyablePosts: z.boolean().optional(), pdsLabel: z .object({ @@ -310,6 +311,7 @@ export const defaults: Schema = { ], }, highQualityImages: false, + imageCdnHost: 'https://cdn.bsky.app', hideUnreplyablePosts: false, pdsLabel: { enabled: true, diff --git a/src/state/preferences/image-cdn-host.tsx b/src/state/preferences/image-cdn-host.tsx new file mode 100644 index 000000000..bca3ce41e --- /dev/null +++ b/src/state/preferences/image-cdn-host.tsx @@ -0,0 +1,117 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['imageCdnHost'] +type SetContext = (v: persisted.Schema['imageCdnHost']) => void + +const stateContext = React.createContext( + persisted.defaults.imageCdnHost, +) +const setContext = React.createContext( + (_: persisted.Schema['imageCdnHost']) => {}, +) + +const DEFAULT_IMAGE_CDN_ORIGIN = normalizeOrigin( + persisted.defaults.imageCdnHost ?? '', +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('imageCdnHost')) + + const setStateWrapped = React.useCallback( + (imageCdnHost: persisted.Schema['imageCdnHost']) => { + setState(imageCdnHost) + persisted.write('imageCdnHost', imageCdnHost) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate('imageCdnHost', nextImageCdnHost => { + setState(nextImageCdnHost) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useImageCdnHost() { + return React.useContext(stateContext) ?? persisted.defaults.imageCdnHost! +} + +export function useSetImageCdnHost() { + return React.useContext(setContext) +} + +function normalizeOrigin(input: string) { + try { + return new URL(input).origin + } catch { + return null + } +} + +function modifyImageCdnHost(src: string, imageCdnHost: string) { + try { + const srcUrl = new URL(src) + if (srcUrl.protocol !== 'https:' && srcUrl.protocol !== 'http:') { + return null + } + if (!srcUrl.pathname.startsWith('/img/')) { + return null + } + + const cdnUrl = new URL(imageCdnHost) + srcUrl.protocol = cdnUrl.protocol + srcUrl.hostname = cdnUrl.hostname + srcUrl.port = cdnUrl.port + + return srcUrl.toString() + } catch { + return null + } +} + +export function maybeModifyImageCdnHost(src: string, imageCdnHost?: string) { + if (!imageCdnHost) { + return src + } + + const nextOrigin = normalizeOrigin(imageCdnHost) + if (!nextOrigin || nextOrigin === DEFAULT_IMAGE_CDN_ORIGIN) { + return src + } + + return modifyImageCdnHost(src, nextOrigin) ?? src +} + +/** + * Combined image transformation pipeline: applies high-quality image format + * transformation first (on original CDN), then applies CDN host rewrite. + */ +export function applyImageTransforms( + src: string, + options: { + imageCdnHost?: string + highQualityImages?: boolean + }, +) { + // Import is deferred to avoid circular dependency + const {maybeModifyHighQualityImage} = + require('./high-quality-images') as typeof import('./high-quality-images') + + // First apply quality transformation on original CDN + const withQuality = maybeModifyHighQualityImage( + src, + options.highQualityImages, + ) + // Then apply CDN host replacement + return maybeModifyImageCdnHost(withQuality, options.imageCdnHost) +} diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index 43c325cab..54e64fb77 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -30,6 +30,7 @@ import {Provider as HideFeedsPromoTabProvider} from './hide-feeds-promo-tab' import {Provider as HideSimilarAccountsRecommProvider} from './hide-similar-accounts-recommendations' import {Provider as HideUnreplyablePostsProvider} from './hide-unreplyable-posts' import {Provider as HighQualityImagesProvider} from './high-quality-images' +import {Provider as ImageCdnHostProvider} from './image-cdn-host' import {Provider as InAppBrowserProvider} from './in-app-browser' import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' @@ -71,6 +72,7 @@ export { useHideFeedsPromoTab, useSetHideFeedsPromoTab, } from './hide-feeds-promo-tab' +export {useImageCdnHost, useSetImageCdnHost} from './image-cdn-host' export {useLabelDefinitions} from './label-defs' export {useLanguagePrefs, useLanguagePrefsApi} from './languages' export { @@ -105,67 +107,69 @@ export function Provider({children}: React.PropsWithChildren<{}>) { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - { - children - } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + children + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 61c206905..d5e66a73b 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -35,10 +35,11 @@ import { createComposerImage, } from '#/state/gallery' import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' +import {useHighQualityImages} from '#/state/preferences/high-quality-images' import { - maybeModifyHighQualityImage, - useHighQualityImages, -} from '#/state/preferences/high-quality-images' + applyImageTransforms, + useImageCdnHost, +} from '#/state/preferences/image-cdn-host' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' import {atoms as a, tokens, useTheme} from '#/alf' @@ -250,6 +251,7 @@ let UserAvatar = ({ const finalShape = overrideShape ?? (type === 'user' ? avishapeforce : 'square') const highQualityImages = useHighQualityImages() + const imageCdnHost = useImageCdnHost() const aviStyle = useMemo(() => { let borderRadius @@ -321,9 +323,9 @@ let UserAvatar = ({ style={aviStyle} resizeMode="cover" source={{ - uri: maybeModifyHighQualityImage( + uri: applyImageTransforms( hackModifyThumbnailPath(avatar, size < 90), - highQualityImages, + {imageCdnHost, highQualityImages}, ), }} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} @@ -335,9 +337,9 @@ let UserAvatar = ({ style={aviStyle} contentFit="cover" source={{ - uri: maybeModifyHighQualityImage( + uri: applyImageTransforms( hackModifyThumbnailPath(avatar, size < 90), - highQualityImages, + {imageCdnHost, highQualityImages}, ), }} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} @@ -379,6 +381,7 @@ let EditableUserAvatar = ({ const sheetWrapper = useSheetWrapper() const highQualityImages = useHighQualityImages() + const imageCdnHost = useImageCdnHost() const enableSquareAvatars = useEnableSquareAvatars() @@ -480,7 +483,10 @@ let EditableUserAvatar = ({ testID="userAvatarImage" style={aviStyle} source={{ - uri: maybeModifyHighQualityImage(avatar, highQualityImages), + uri: applyImageTransforms(avatar, { + imageCdnHost, + highQualityImages, + }), }} accessibilityRole="image" /> diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 1400e689e..0e504a787 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -20,10 +20,11 @@ import { compressImage, createComposerImage, } from '#/state/gallery' +import {useHighQualityImages} from '#/state/preferences/high-quality-images' import { - maybeModifyHighQualityImage, - useHighQualityImages, -} from '#/state/preferences/high-quality-images' + applyImageTransforms, + useImageCdnHost, +} from '#/state/preferences/image-cdn-host' import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' import {EventStopper} from '#/view/com/util/EventStopper' import {atoms as a, tokens, useTheme} from '#/alf' @@ -57,6 +58,7 @@ export function UserBanner({ const [rawImage, setRawImage] = useState() const editImageDialogControl = useDialogControl() const highQualityImages = useHighQualityImages() + const imageCdnHost = useImageCdnHost() const onOpenCamera = useCallback(async () => { if (!(await requestCameraAccessIfNeeded())) { @@ -132,10 +134,10 @@ export function UserBanner({ testID="userBannerImage" style={styles.bannerImage} source={{ - uri: maybeModifyHighQualityImage( - banner, + uri: applyImageTransforms(banner, { + imageCdnHost, highQualityImages, - ), + }), }} accessible={true} accessibilityIgnoresInvertColors @@ -222,7 +224,9 @@ export function UserBanner({