Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

feat: option for a different image cdn

xan.lol bc050009 4d6a859f

verified
+425 -125
+25 -13
src/components/MediaPreview.tsx
··· 4 4 import {Trans} from '@lingui/react/macro' 5 5 6 6 import {isTenorGifUri} from '#/lib/strings/embed-player' 7 + import {useHighQualityImages} from '#/state/preferences/high-quality-images' 7 8 import { 8 - maybeModifyHighQualityImage, 9 - useHighQualityImages, 10 - } from '#/state/preferences/high-quality-images' 9 + applyImageTransforms, 10 + useImageCdnHost, 11 + } from '#/state/preferences/image-cdn-host' 11 12 import {atoms as a, useTheme} from '#/alf' 12 13 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 13 14 import {Text} from '#/components/Typography' ··· 24 25 embed: AppBskyFeedDefs.PostView['embed'] 25 26 style?: StyleProp<ViewStyle> 26 27 }) { 27 - const highQualityImages = useHighQualityImages() 28 28 const e = bsky.post.parseEmbed(embed) 29 29 30 30 if (!e) return null ··· 35 35 {e.view.images.map(image => ( 36 36 <ImageItem 37 37 key={image.thumb} 38 - thumbnail={maybeModifyHighQualityImage( 39 - image.thumb, 40 - highQualityImages, 41 - )} 38 + thumbnail={image.thumb} 42 39 alt={image.alt} 43 40 /> 44 41 ))} ··· 59 56 return ( 60 57 <Outer style={style}> 61 58 {e.view.presentation === 'gif' ? ( 62 - <GifItem thumbnail={e.view.thumbnail} alt={e.view.alt} /> 59 + <GifItem 60 + thumbnail={e.view.thumbnail ? e.view.thumbnail : undefined} 61 + alt={e.view.alt} 62 + /> 63 63 ) : ( 64 - <VideoItem thumbnail={e.view.thumbnail} alt={e.view.alt} /> 64 + <VideoItem 65 + thumbnail={e.view.thumbnail ? e.view.thumbnail : undefined} 66 + alt={e.view.alt} 67 + /> 65 68 )} 66 69 </Outer> 67 70 ) ··· 98 101 children?: React.ReactNode 99 102 }) { 100 103 const t = useTheme() 104 + const highQualityImages = useHighQualityImages() 105 + const imageCdnHost = useImageCdnHost() 101 106 102 - if (!thumbnail) { 107 + const transformedThumbnail = thumbnail 108 + ? applyImageTransforms(thumbnail, { 109 + imageCdnHost, 110 + highQualityImages, 111 + }) 112 + : undefined 113 + 114 + if (!transformedThumbnail) { 103 115 return ( 104 116 <View 105 117 style={[ ··· 119 131 return ( 120 132 <View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}> 121 133 <Image 122 - key={thumbnail} 123 - source={{uri: thumbnail}} 134 + key={transformedThumbnail} 135 + source={{uri: transformedThumbnail}} 124 136 alt={alt} 125 137 style={[a.flex_1, a.rounded_xs, t.atoms.bg_contrast_25]} 126 138 contentFit="cover"
+13 -1
src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx
··· 27 27 getPlayerAspect, 28 28 } from '#/lib/strings/embed-player' 29 29 import {useExternalEmbedsPrefs} from '#/state/preferences' 30 + import {useHighQualityImages} from '#/state/preferences/high-quality-images' 31 + import { 32 + applyImageTransforms, 33 + useImageCdnHost, 34 + } from '#/state/preferences/image-cdn-host' 30 35 import {EventStopper} from '#/view/com/util/EventStopper' 31 36 import {atoms as a, useTheme} from '#/alf' 32 37 import {useDialogControl} from '#/components/Dialog' ··· 128 133 const windowDims = useWindowDimensions() 129 134 const externalEmbedsPrefs = useExternalEmbedsPrefs() 130 135 const consentDialogControl = useDialogControl() 136 + const highQualityImages = useHighQualityImages() 137 + const imageCdnHost = useImageCdnHost() 131 138 132 139 const [isPlayerActive, setPlayerActive] = React.useState(false) 133 140 const [isLoading, setIsLoading] = React.useState(true) ··· 224 231 <> 225 232 <Image 226 233 style={[a.flex_1]} 227 - source={{uri: link.thumb}} 234 + source={{ 235 + uri: applyImageTransforms(link.thumb, { 236 + imageCdnHost, 237 + highQualityImages, 238 + }), 239 + }} 228 240 accessibilityIgnoresInvertColors 229 241 loading="lazy" 230 242 />
+13 -1
src/components/Post/Embed/ExternalEmbed/index.tsx
··· 11 11 import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player' 12 12 import {toNiceDomain} from '#/lib/strings/url-helpers' 13 13 import {useExternalEmbedsPrefs} from '#/state/preferences' 14 + import {useHighQualityImages} from '#/state/preferences/high-quality-images' 15 + import { 16 + applyImageTransforms, 17 + useImageCdnHost, 18 + } from '#/state/preferences/image-cdn-host' 14 19 import {atoms as a, useTheme} from '#/alf' 15 20 import {Divider} from '#/components/Divider' 16 21 import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' ··· 36 41 const t = useTheme() 37 42 const playHaptic = useHaptics() 38 43 const externalEmbedPrefs = useExternalEmbedsPrefs() 44 + const highQualityImages = useHighQualityImages() 45 + const imageCdnHost = useImageCdnHost() 39 46 const niceUrl = toNiceDomain(link.uri) 40 47 const imageUri = link.thumb 48 + ? applyImageTransforms(link.thumb, { 49 + imageCdnHost, 50 + highQualityImages, 51 + }) 52 + : undefined 41 53 const embedPlayerParams = React.useMemo(() => { 42 54 const params = parseEmbedPlayerFromUrl(link.uri) 43 55 ··· 132 144 {link.description ? ( 133 145 <Text 134 146 emoji 135 - numberOfLines={link.thumb ? 2 : 4} 147 + numberOfLines={imageUri ? 2 : 4} 136 148 style={[a.text_sm, a.leading_snug]}> 137 149 {link.description} 138 150 </Text>
+13 -5
src/components/Post/Embed/ImageEmbed.tsx
··· 9 9 import {Image} from 'expo-image' 10 10 11 11 import {useLightboxControls} from '#/state/lightbox' 12 + import {useHighQualityImages} from '#/state/preferences/high-quality-images' 12 13 import { 13 - maybeModifyHighQualityImage, 14 - useHighQualityImages, 15 - } from '#/state/preferences/high-quality-images' 14 + applyImageTransforms, 15 + useImageCdnHost, 16 + } from '#/state/preferences/image-cdn-host' 16 17 import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' 17 18 import {atoms as a} from '#/alf' 18 19 import {AutoSizedImage} from '#/components/images/AutoSizedImage' ··· 29 30 }) { 30 31 const {openLightbox} = useLightboxControls() 31 32 const highQualityImages = useHighQualityImages() 33 + const imageCdnHost = useImageCdnHost() 32 34 const {images} = embed.view 33 35 34 36 if (images.length > 0) { 35 37 const items = images.map(img => ({ 36 - uri: maybeModifyHighQualityImage(img.fullsize, highQualityImages), 37 - thumbUri: maybeModifyHighQualityImage(img.thumb, highQualityImages), 38 + uri: applyImageTransforms(img.fullsize, { 39 + imageCdnHost, 40 + highQualityImages, 41 + }), 42 + thumbUri: applyImageTransforms(img.thumb, { 43 + imageCdnHost, 44 + highQualityImages, 45 + }), 38 46 alt: img.alt, 39 47 dimensions: img.aspectRatio ?? null, 40 48 }))
+2 -2
src/components/Post/Embed/index.tsx
··· 9 9 RichText as RichTextAPI, 10 10 } from '@atproto/api' 11 11 import {msg} from '@lingui/core/macro' 12 - import {Trans} from '@lingui/react/macro' 13 12 import {useLingui} from '@lingui/react' 13 + import {Trans} from '@lingui/react/macro' 14 14 import {useQueryClient} from '@tanstack/react-query' 15 15 16 16 import {makeProfileLink} from '#/lib/routes/links' ··· 377 377 author={quote.author} 378 378 moderation={moderation} 379 379 showAvatar 380 - showPronouns={showPronouns} 380 + showPronouns={showPronouns} 381 381 postHref={itemHref} 382 382 timestamp={quote.indexedAt} 383 383 linkDisabled
+9 -4
src/components/images/AutoSizedImage.tsx
··· 12 12 import {Trans} from '@lingui/react/macro' 13 13 14 14 import {type Dimensions} from '#/lib/media/types' 15 + import {useHighQualityImages} from '#/state/preferences/high-quality-images' 15 16 import { 16 - maybeModifyHighQualityImage, 17 - useHighQualityImages, 18 - } from '#/state/preferences/high-quality-images' 17 + applyImageTransforms, 18 + useImageCdnHost, 19 + } from '#/state/preferences/image-cdn-host' 19 20 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 20 21 import {atoms as a, useTheme} from '#/alf' 21 22 import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' ··· 90 91 const containerRef = useAnimatedRef() 91 92 const fetchedDimsRef = useRef<{width: number; height: number} | null>(null) 92 93 const highQualityImages = useHighQualityImages() 94 + const imageCdnHost = useImageCdnHost() 93 95 94 96 let aspectRatio: number | undefined 95 97 const dims = image.aspectRatio ··· 120 122 <Image 121 123 contentFit={isContain ? 'contain' : 'cover'} 122 124 style={[a.w_full, a.h_full]} 123 - source={maybeModifyHighQualityImage(image.thumb, highQualityImages)} 125 + source={applyImageTransforms(image.thumb, { 126 + imageCdnHost, 127 + highQualityImages, 128 + })} 124 129 accessible={true} // Must set for `accessibilityLabel` to work 125 130 accessibilityIgnoresInvertColors 126 131 accessibilityLabel={image.alt}
+9 -4
src/components/images/Gallery.tsx
··· 8 8 import {Trans} from '@lingui/react/macro' 9 9 10 10 import {type Dimensions} from '#/lib/media/types' 11 + import {useHighQualityImages} from '#/state/preferences/high-quality-images' 11 12 import { 12 - maybeModifyHighQualityImage, 13 - useHighQualityImages, 14 - } from '#/state/preferences/high-quality-images' 13 + applyImageTransforms, 14 + useImageCdnHost, 15 + } from '#/state/preferences/image-cdn-host' 15 16 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 16 17 import {atoms as a, useTheme} from '#/alf' 17 18 import {MediaInsetBorder} from '#/components/MediaInsetBorder' ··· 53 54 const {_} = useLingui() 54 55 const largeAltBadge = useLargeAltBadgeEnabled() 55 56 const highQualityImages = useHighQualityImages() 57 + const imageCdnHost = useImageCdnHost() 56 58 const image = images[index] 57 59 const hasAlt = !!image.alt 58 60 const hideBadges = ··· 82 84 accessibilityHint=""> 83 85 <Image 84 86 source={{ 85 - uri: maybeModifyHighQualityImage(image.thumb, highQualityImages), 87 + uri: applyImageTransforms(image.thumb, { 88 + imageCdnHost, 89 + highQualityImages, 90 + }), 86 91 }} 87 92 style={[a.flex_1]} 88 93 accessible={true}
+1 -1
src/screens/Post/PostLikedBy.tsx
··· 1 1 import React from 'react' 2 2 import {msg} from '@lingui/core/macro' 3 - import {Plural, Trans} from '@lingui/react/macro' 4 3 import {useLingui} from '@lingui/react' 4 + import {Plural, Trans} from '@lingui/react/macro' 5 5 import {useFocusEffect} from '@react-navigation/native' 6 6 7 7 import {useSetTitle} from '#/lib/hooks/useSetTitle'
+1 -1
src/screens/Post/PostQuotes.tsx
··· 1 1 import React from 'react' 2 2 import {msg} from '@lingui/core/macro' 3 - import {Plural, Trans} from '@lingui/react/macro' 4 3 import {useLingui} from '@lingui/react' 4 + import {Plural, Trans} from '@lingui/react/macro' 5 5 import {useFocusEffect} from '@react-navigation/native' 6 6 7 7 import {useSetTitle} from '#/lib/hooks/useSetTitle'
+1 -1
src/screens/Post/PostRepostedBy.tsx
··· 1 1 import React from 'react' 2 2 import {msg} from '@lingui/core/macro' 3 - import {Plural, Trans} from '@lingui/react/macro' 4 3 import {useLingui} from '@lingui/react' 4 + import {Plural, Trans} from '@lingui/react/macro' 5 5 import {useFocusEffect} from '@react-navigation/native' 6 6 7 7 import {useSetTitle} from '#/lib/hooks/useSetTitle'
+17 -9
src/screens/Profile/Header/Shell.tsx
··· 20 20 import {useLightboxControls} from '#/state/lightbox' 21 21 import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' 22 22 import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 23 + import {useHighQualityImages} from '#/state/preferences/high-quality-images' 23 24 import { 24 - maybeModifyHighQualityImage, 25 - useHighQualityImages, 26 - } from '#/state/preferences/high-quality-images' 25 + applyImageTransforms, 26 + useImageCdnHost, 27 + } from '#/state/preferences/image-cdn-host' 27 28 import {useSession} from '#/state/session' 28 29 import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 29 30 import {UserAvatar} from '#/view/com/util/UserAvatar' ··· 68 69 const playHaptic = useHaptics() 69 70 const liveStatusControl = useDialogControl() 70 71 const highQualityImages = useHighQualityImages() 72 + const imageCdnHost = useImageCdnHost() 71 73 const enableSquareAvatars = useEnableSquareAvatars() 72 74 const enableSquareButtons = useEnableSquareButtons() 73 75 ··· 102 104 openLightbox({ 103 105 images: [ 104 106 { 105 - uri: maybeModifyHighQualityImage(uri, highQualityImages), 106 - thumbUri: maybeModifyHighQualityImage(uri, highQualityImages), 107 + uri: applyImageTransforms(uri, {imageCdnHost, highQualityImages}), 108 + thumbUri: applyImageTransforms(uri, { 109 + imageCdnHost, 110 + highQualityImages, 111 + }), 107 112 thumbRect, 108 113 dimensions: 109 114 type === 'circle-avi' || type === 'rect-avi' ··· 124 129 index: 0, 125 130 }) 126 131 }, 127 - [openLightbox, highQualityImages, enableSquareAvatars], 132 + [openLightbox, imageCdnHost, highQualityImages, enableSquareAvatars], 128 133 ) 129 134 130 135 // theres probs a better way instead of just making a separate one but this works:tm: so its whatever ··· 133 138 openLightbox({ 134 139 images: [ 135 140 { 136 - uri: maybeModifyHighQualityImage(uri, highQualityImages), 137 - thumbUri: maybeModifyHighQualityImage(uri, highQualityImages), 141 + uri: applyImageTransforms(uri, {imageCdnHost, highQualityImages}), 142 + thumbUri: applyImageTransforms(uri, { 143 + imageCdnHost, 144 + highQualityImages, 145 + }), 138 146 thumbRect, 139 147 dimensions: thumbRect, 140 148 thumbDimensions: null, ··· 144 152 index: 0, 145 153 }) 146 154 }, 147 - [openLightbox, highQualityImages], 155 + [openLightbox, imageCdnHost, highQualityImages], 148 156 ) 149 157 150 158 const isMe = useMemo(
+1 -1
src/screens/Profile/ProfileFollowers.tsx
··· 1 1 import React from 'react' 2 2 import {msg} from '@lingui/core/macro' 3 - import {Plural} from '@lingui/react/macro' 4 3 import {useLingui} from '@lingui/react' 4 + import {Plural} from '@lingui/react/macro' 5 5 import {useFocusEffect} from '@react-navigation/native' 6 6 7 7 import {useSetTitle} from '#/lib/hooks/useSetTitle'
+1 -1
src/screens/Profile/ProfileFollows.tsx
··· 1 1 import React from 'react' 2 2 import {msg} from '@lingui/core/macro' 3 - import {Plural} from '@lingui/react/macro' 4 3 import {useLingui} from '@lingui/react' 4 + import {Plural} from '@lingui/react/macro' 5 5 import {useFocusEffect} from '@react-navigation/native' 6 6 7 7 import {useSetTitle} from '#/lib/hooks/useSetTitle'
+109 -4
src/screens/Settings/RunesSettings.tsx
··· 1 1 import {useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import {type ProfileViewBasic} from '@atproto/api/dist/client/types/app/bsky/actor/defs' 4 - import {msg, Trans} from '@lingui/macro' 4 + import {msg} from '@lingui/core/macro' 5 5 import {useLingui} from '@lingui/react' 6 + import {Trans} from '@lingui/react/macro' 6 7 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 7 8 8 9 import {DEFAULT_ALT_TEXT_AI_MODEL} from '#/lib/constants' ··· 98 99 useHighQualityImages, 99 100 useSetHighQualityImages, 100 101 } from '#/state/preferences/high-quality-images' 102 + import { 103 + useImageCdnHost, 104 + useSetImageCdnHost, 105 + } from '#/state/preferences/image-cdn-host' 101 106 import {useModerationOpts} from '#/state/preferences/moderation-opts' 102 107 import { 103 108 useNoAppLabelers, ··· 319 324 ) 320 325 } 321 326 327 + function ImageCdnHostDialog({control}: {control: Dialog.DialogControlProps}) { 328 + const pal = usePalette('default') 329 + const {_} = useLingui() 330 + 331 + const imageCdnHost = useImageCdnHost() 332 + const [url, setUrl] = useState(imageCdnHost ?? '') 333 + const setImageCdnHost = useSetImageCdnHost() 334 + 335 + const submit = () => { 336 + try { 337 + setImageCdnHost(new URL(url).origin) 338 + } catch { 339 + setImageCdnHost(url) 340 + } 341 + control.close() 342 + } 343 + 344 + const shouldDisable = () => { 345 + try { 346 + return !new URL(url).hostname.includes('.') 347 + } catch (e) { 348 + return true 349 + } 350 + } 351 + 352 + return ( 353 + <Dialog.Outer 354 + control={control} 355 + nativeOptions={{preventExpansion: true}} 356 + onClose={() => setUrl(imageCdnHost ?? '')}> 357 + <Dialog.Handle /> 358 + <Dialog.ScrollableInner label={_(msg`Image CDN URL`)}> 359 + <View style={[a.gap_sm, a.pb_lg]}> 360 + <Text style={[a.text_2xl, a.font_bold]}> 361 + <Trans>Image CDN URL</Trans> 362 + </Text> 363 + </View> 364 + 365 + <View style={a.gap_lg}> 366 + <Dialog.Input 367 + label="Text input field" 368 + autoFocus 369 + style={[styles.textInput, pal.border, pal.text]} 370 + onChangeText={value => { 371 + setUrl(value) 372 + }} 373 + placeholder={persisted.defaults.imageCdnHost} 374 + placeholderTextColor={pal.colors.textLight} 375 + onSubmitEditing={submit} 376 + accessibilityHint={_(msg`Input the URL of the image CDN to use`)} 377 + defaultValue={imageCdnHost} 378 + /> 379 + 380 + <View style={IS_WEB && [a.flex_row, a.justify_end]}> 381 + <Button 382 + label={_(msg`Save`)} 383 + size="large" 384 + onPress={submit} 385 + variant="solid" 386 + color="primary" 387 + disabled={shouldDisable()}> 388 + <ButtonText> 389 + <Trans>Save</Trans> 390 + </ButtonText> 391 + </Button> 392 + </View> 393 + </View> 394 + 395 + <Dialog.Close /> 396 + </Dialog.ScrollableInner> 397 + </Dialog.Outer> 398 + ) 399 + } 400 + 322 401 function PostReplacementDialog({ 323 402 control, 324 403 }: { ··· 657 736 658 737 const highQualityImages = useHighQualityImages() 659 738 const setHighQualityImages = useSetHighQualityImages() 739 + const imageCdnHost = useImageCdnHost() 660 740 661 741 const hideFeedsPromoTab = useHideFeedsPromoTab() 662 742 const setHideFeedsPromoTab = useSetHideFeedsPromoTab() ··· 733 813 734 814 const setLibreTranslateInstanceControl = Dialog.useDialogControl() 735 815 816 + const setImageCdnHostControl = Dialog.useDialogControl() 817 + 736 818 const setPostReplacementDialogControl = Dialog.useDialogControl() 737 819 738 820 const setOpenRouterApiKeyControl = Dialog.useDialogControl() ··· 1007 1089 longer to load and use more bandwidth. 1008 1090 </Trans> 1009 1091 </Admonition> 1010 - 1011 1092 <Toggle.Item 1012 1093 name="hide_feeds_promo_tab" 1013 1094 label={_(msg`Hide "Feeds ✨" tab when only one feed is selected`)} ··· 1096 1177 <Admonition type="warning" style={[a.flex_1]}> 1097 1178 <Trans> 1098 1179 This only gets rid of the reminder on app launch, useful if your 1099 - PDS does not have email verification setup.\nThis does NOT give 1100 - access to features locked behind email verification. 1180 + PDS does not have email verification setup.\u00A0 This does NOT 1181 + give access to features locked behind email verification. 1101 1182 </Trans> 1102 1183 </Admonition> 1103 1184 ··· 1358 1439 1359 1440 <SettingsList.Divider /> 1360 1441 1442 + <SettingsList.Item> 1443 + <SettingsList.ItemIcon icon={EarthIcon} /> 1444 + <SettingsList.ItemText> 1445 + <Trans>{`Image CDN`}</Trans> 1446 + </SettingsList.ItemText> 1447 + <SettingsList.BadgeButton 1448 + label={_(msg`Change`)} 1449 + onPress={() => setImageCdnHostControl.open()} 1450 + /> 1451 + </SettingsList.Item> 1452 + <SettingsList.Item> 1453 + <Admonition type="info" style={[a.flex_1]}> 1454 + <Trans> 1455 + Override the CDN host for all images. Current:  1456 + <InlineLinkText to={imageCdnHost} label={imageCdnHost}> 1457 + {imageCdnHost} 1458 + </InlineLinkText> 1459 + </Trans> 1460 + </Admonition> 1461 + </SettingsList.Item> 1462 + 1463 + <SettingsList.Divider /> 1464 + 1361 1465 <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 1362 1466 <SettingsList.ItemIcon icon={RaisingHandIcon} /> 1363 1467 <SettingsList.ItemText> ··· 1406 1510 <LibreTranslateInstanceDialog 1407 1511 control={setLibreTranslateInstanceControl} 1408 1512 /> 1513 + <ImageCdnHostDialog control={setImageCdnHostControl} /> 1409 1514 <PostReplacementDialog control={setPostReplacementDialogControl} /> 1410 1515 <OpenRouterApiKeyDialog control={setOpenRouterApiKeyControl} /> 1411 1516 <OpenRouterModelDialog control={setOpenRouterModelControl} />
+2
src/state/persisted/schema.ts
··· 173 173 }) 174 174 .optional(), 175 175 highQualityImages: z.boolean().optional(), 176 + imageCdnHost: z.string().optional(), 176 177 hideUnreplyablePosts: z.boolean().optional(), 177 178 pdsLabel: z 178 179 .object({ ··· 310 311 ], 311 312 }, 312 313 highQualityImages: false, 314 + imageCdnHost: 'https://cdn.bsky.app', 313 315 hideUnreplyablePosts: false, 314 316 pdsLabel: { 315 317 enabled: true,
+117
src/state/preferences/image-cdn-host.tsx
··· 1 + import React from 'react' 2 + 3 + import * as persisted from '#/state/persisted' 4 + 5 + type StateContext = persisted.Schema['imageCdnHost'] 6 + type SetContext = (v: persisted.Schema['imageCdnHost']) => void 7 + 8 + const stateContext = React.createContext<StateContext>( 9 + persisted.defaults.imageCdnHost, 10 + ) 11 + const setContext = React.createContext<SetContext>( 12 + (_: persisted.Schema['imageCdnHost']) => {}, 13 + ) 14 + 15 + const DEFAULT_IMAGE_CDN_ORIGIN = normalizeOrigin( 16 + persisted.defaults.imageCdnHost ?? '', 17 + ) 18 + 19 + export function Provider({children}: React.PropsWithChildren<{}>) { 20 + const [state, setState] = React.useState(persisted.get('imageCdnHost')) 21 + 22 + const setStateWrapped = React.useCallback( 23 + (imageCdnHost: persisted.Schema['imageCdnHost']) => { 24 + setState(imageCdnHost) 25 + persisted.write('imageCdnHost', imageCdnHost) 26 + }, 27 + [setState], 28 + ) 29 + 30 + React.useEffect(() => { 31 + return persisted.onUpdate('imageCdnHost', nextImageCdnHost => { 32 + setState(nextImageCdnHost) 33 + }) 34 + }, [setStateWrapped]) 35 + 36 + return ( 37 + <stateContext.Provider value={state}> 38 + <setContext.Provider value={setStateWrapped}> 39 + {children} 40 + </setContext.Provider> 41 + </stateContext.Provider> 42 + ) 43 + } 44 + 45 + export function useImageCdnHost() { 46 + return React.useContext(stateContext) ?? persisted.defaults.imageCdnHost! 47 + } 48 + 49 + export function useSetImageCdnHost() { 50 + return React.useContext(setContext) 51 + } 52 + 53 + function normalizeOrigin(input: string) { 54 + try { 55 + return new URL(input).origin 56 + } catch { 57 + return null 58 + } 59 + } 60 + 61 + function modifyImageCdnHost(src: string, imageCdnHost: string) { 62 + try { 63 + const srcUrl = new URL(src) 64 + if (srcUrl.protocol !== 'https:' && srcUrl.protocol !== 'http:') { 65 + return null 66 + } 67 + if (!srcUrl.pathname.startsWith('/img/')) { 68 + return null 69 + } 70 + 71 + const cdnUrl = new URL(imageCdnHost) 72 + srcUrl.protocol = cdnUrl.protocol 73 + srcUrl.hostname = cdnUrl.hostname 74 + srcUrl.port = cdnUrl.port 75 + 76 + return srcUrl.toString() 77 + } catch { 78 + return null 79 + } 80 + } 81 + 82 + export function maybeModifyImageCdnHost(src: string, imageCdnHost?: string) { 83 + if (!imageCdnHost) { 84 + return src 85 + } 86 + 87 + const nextOrigin = normalizeOrigin(imageCdnHost) 88 + if (!nextOrigin || nextOrigin === DEFAULT_IMAGE_CDN_ORIGIN) { 89 + return src 90 + } 91 + 92 + return modifyImageCdnHost(src, nextOrigin) ?? src 93 + } 94 + 95 + /** 96 + * Combined image transformation pipeline: applies high-quality image format 97 + * transformation first (on original CDN), then applies CDN host rewrite. 98 + */ 99 + export function applyImageTransforms( 100 + src: string, 101 + options: { 102 + imageCdnHost?: string 103 + highQualityImages?: boolean 104 + }, 105 + ) { 106 + // Import is deferred to avoid circular dependency 107 + const {maybeModifyHighQualityImage} = 108 + require('./high-quality-images') as typeof import('./high-quality-images') 109 + 110 + // First apply quality transformation on original CDN 111 + const withQuality = maybeModifyHighQualityImage( 112 + src, 113 + options.highQualityImages, 114 + ) 115 + // Then apply CDN host replacement 116 + return maybeModifyImageCdnHost(withQuality, options.imageCdnHost) 117 + }
+65 -61
src/state/preferences/index.tsx
··· 30 30 import {Provider as HideSimilarAccountsRecommProvider} from './hide-similar-accounts-recommendations' 31 31 import {Provider as HideUnreplyablePostsProvider} from './hide-unreplyable-posts' 32 32 import {Provider as HighQualityImagesProvider} from './high-quality-images' 33 + import {Provider as ImageCdnHostProvider} from './image-cdn-host' 33 34 import {Provider as InAppBrowserProvider} from './in-app-browser' 34 35 import {Provider as KawaiiProvider} from './kawaii' 35 36 import {Provider as LanguagesProvider} from './languages' ··· 71 72 useHideFeedsPromoTab, 72 73 useSetHideFeedsPromoTab, 73 74 } from './hide-feeds-promo-tab' 75 + export {useImageCdnHost, useSetImageCdnHost} from './image-cdn-host' 74 76 export {useLabelDefinitions} from './label-defs' 75 77 export {useLanguagePrefs, useLanguagePrefsApi} from './languages' 76 78 export { ··· 105 107 <ExternalEmbedsProvider> 106 108 <HiddenPostsProvider> 107 109 <HighQualityImagesProvider> 108 - <InAppBrowserProvider> 109 - <DisableHapticsProvider> 110 - <AutoplayProvider> 111 - <UsedStarterPacksProvider> 112 - <SubtitlesProvider> 113 - <TrendingSettingsProvider> 114 - <RepostCarouselProvider> 115 - <KawaiiProvider> 116 - <HideFeedsPromoTabProvider> 117 - <DisableViaRepostNotificationProvider> 118 - <DisableLikesMetricsProvider> 119 - <DisableRepostsMetricsProvider> 120 - <DisableQuotesMetricsProvider> 121 - <DisableSavesMetricsProvider> 122 - <DisableReplyMetricsProvider> 123 - <DisableFollowersMetricsProvider> 124 - <DisableFollowingMetricsProvider> 125 - <DisableFollowedByMetricsProvider> 126 - <DisablePostsMetricsProvider> 127 - <HideSimilarAccountsRecommProvider> 128 - <HideUnreplyablePostsProvider> 129 - <EnableSquareAvatarsProvider> 130 - <EnableSquareButtonsProvider> 131 - <PostNameReplacementProvider> 132 - <DisableVerifyEmailReminderProvider> 133 - <TranslationServicePreferenceProvider> 134 - <OpenRouterProvider> 135 - <DisableComposerPromptProvider> 136 - <DiscoverContextEnabledProvider> 137 - { 138 - children 139 - } 140 - </DiscoverContextEnabledProvider> 141 - </DisableComposerPromptProvider> 142 - </OpenRouterProvider> 143 - </TranslationServicePreferenceProvider> 144 - </DisableVerifyEmailReminderProvider> 145 - </PostNameReplacementProvider> 146 - </EnableSquareButtonsProvider> 147 - </EnableSquareAvatarsProvider> 148 - </HideUnreplyablePostsProvider> 149 - </HideSimilarAccountsRecommProvider> 150 - </DisablePostsMetricsProvider> 151 - </DisableFollowedByMetricsProvider> 152 - </DisableFollowingMetricsProvider> 153 - </DisableFollowersMetricsProvider> 154 - </DisableReplyMetricsProvider> 155 - </DisableSavesMetricsProvider> 156 - </DisableQuotesMetricsProvider> 157 - </DisableRepostsMetricsProvider> 158 - </DisableLikesMetricsProvider> 159 - </DisableViaRepostNotificationProvider> 160 - </HideFeedsPromoTabProvider> 161 - </KawaiiProvider> 162 - </RepostCarouselProvider> 163 - </TrendingSettingsProvider> 164 - </SubtitlesProvider> 165 - </UsedStarterPacksProvider> 166 - </AutoplayProvider> 167 - </DisableHapticsProvider> 168 - </InAppBrowserProvider> 110 + <ImageCdnHostProvider> 111 + <InAppBrowserProvider> 112 + <DisableHapticsProvider> 113 + <AutoplayProvider> 114 + <UsedStarterPacksProvider> 115 + <SubtitlesProvider> 116 + <TrendingSettingsProvider> 117 + <RepostCarouselProvider> 118 + <KawaiiProvider> 119 + <HideFeedsPromoTabProvider> 120 + <DisableViaRepostNotificationProvider> 121 + <DisableLikesMetricsProvider> 122 + <DisableRepostsMetricsProvider> 123 + <DisableQuotesMetricsProvider> 124 + <DisableSavesMetricsProvider> 125 + <DisableReplyMetricsProvider> 126 + <DisableFollowersMetricsProvider> 127 + <DisableFollowingMetricsProvider> 128 + <DisableFollowedByMetricsProvider> 129 + <DisablePostsMetricsProvider> 130 + <HideSimilarAccountsRecommProvider> 131 + <HideUnreplyablePostsProvider> 132 + <EnableSquareAvatarsProvider> 133 + <EnableSquareButtonsProvider> 134 + <PostNameReplacementProvider> 135 + <DisableVerifyEmailReminderProvider> 136 + <TranslationServicePreferenceProvider> 137 + <OpenRouterProvider> 138 + <DisableComposerPromptProvider> 139 + <DiscoverContextEnabledProvider> 140 + { 141 + children 142 + } 143 + </DiscoverContextEnabledProvider> 144 + </DisableComposerPromptProvider> 145 + </OpenRouterProvider> 146 + </TranslationServicePreferenceProvider> 147 + </DisableVerifyEmailReminderProvider> 148 + </PostNameReplacementProvider> 149 + </EnableSquareButtonsProvider> 150 + </EnableSquareAvatarsProvider> 151 + </HideUnreplyablePostsProvider> 152 + </HideSimilarAccountsRecommProvider> 153 + </DisablePostsMetricsProvider> 154 + </DisableFollowedByMetricsProvider> 155 + </DisableFollowingMetricsProvider> 156 + </DisableFollowersMetricsProvider> 157 + </DisableReplyMetricsProvider> 158 + </DisableSavesMetricsProvider> 159 + </DisableQuotesMetricsProvider> 160 + </DisableRepostsMetricsProvider> 161 + </DisableLikesMetricsProvider> 162 + </DisableViaRepostNotificationProvider> 163 + </HideFeedsPromoTabProvider> 164 + </KawaiiProvider> 165 + </RepostCarouselProvider> 166 + </TrendingSettingsProvider> 167 + </SubtitlesProvider> 168 + </UsedStarterPacksProvider> 169 + </AutoplayProvider> 170 + </DisableHapticsProvider> 171 + </InAppBrowserProvider> 172 + </ImageCdnHostProvider> 169 173 </HighQualityImagesProvider> 170 174 </HiddenPostsProvider> 171 175 </ExternalEmbedsProvider>
+14 -8
src/view/com/util/UserAvatar.tsx
··· 35 35 createComposerImage, 36 36 } from '#/state/gallery' 37 37 import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' 38 + import {useHighQualityImages} from '#/state/preferences/high-quality-images' 38 39 import { 39 - maybeModifyHighQualityImage, 40 - useHighQualityImages, 41 - } from '#/state/preferences/high-quality-images' 40 + applyImageTransforms, 41 + useImageCdnHost, 42 + } from '#/state/preferences/image-cdn-host' 42 43 import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' 43 44 import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' 44 45 import {atoms as a, tokens, useTheme} from '#/alf' ··· 250 251 const finalShape = 251 252 overrideShape ?? (type === 'user' ? avishapeforce : 'square') 252 253 const highQualityImages = useHighQualityImages() 254 + const imageCdnHost = useImageCdnHost() 253 255 254 256 const aviStyle = useMemo(() => { 255 257 let borderRadius ··· 321 323 style={aviStyle} 322 324 resizeMode="cover" 323 325 source={{ 324 - uri: maybeModifyHighQualityImage( 326 + uri: applyImageTransforms( 325 327 hackModifyThumbnailPath(avatar, size < 90), 326 - highQualityImages, 328 + {imageCdnHost, highQualityImages}, 327 329 ), 328 330 }} 329 331 blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} ··· 335 337 style={aviStyle} 336 338 contentFit="cover" 337 339 source={{ 338 - uri: maybeModifyHighQualityImage( 340 + uri: applyImageTransforms( 339 341 hackModifyThumbnailPath(avatar, size < 90), 340 - highQualityImages, 342 + {imageCdnHost, highQualityImages}, 341 343 ), 342 344 }} 343 345 blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} ··· 379 381 380 382 const sheetWrapper = useSheetWrapper() 381 383 const highQualityImages = useHighQualityImages() 384 + const imageCdnHost = useImageCdnHost() 382 385 383 386 const enableSquareAvatars = useEnableSquareAvatars() 384 387 ··· 480 483 testID="userAvatarImage" 481 484 style={aviStyle} 482 485 source={{ 483 - uri: maybeModifyHighQualityImage(avatar, highQualityImages), 486 + uri: applyImageTransforms(avatar, { 487 + imageCdnHost, 488 + highQualityImages, 489 + }), 484 490 }} 485 491 accessibilityRole="image" 486 492 />
+11 -7
src/view/com/util/UserBanner.tsx
··· 20 20 compressImage, 21 21 createComposerImage, 22 22 } from '#/state/gallery' 23 + import {useHighQualityImages} from '#/state/preferences/high-quality-images' 23 24 import { 24 - maybeModifyHighQualityImage, 25 - useHighQualityImages, 26 - } from '#/state/preferences/high-quality-images' 25 + applyImageTransforms, 26 + useImageCdnHost, 27 + } from '#/state/preferences/image-cdn-host' 27 28 import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' 28 29 import {EventStopper} from '#/view/com/util/EventStopper' 29 30 import {atoms as a, tokens, useTheme} from '#/alf' ··· 57 58 const [rawImage, setRawImage] = useState<ComposerImage | undefined>() 58 59 const editImageDialogControl = useDialogControl() 59 60 const highQualityImages = useHighQualityImages() 61 + const imageCdnHost = useImageCdnHost() 60 62 61 63 const onOpenCamera = useCallback(async () => { 62 64 if (!(await requestCameraAccessIfNeeded())) { ··· 132 134 testID="userBannerImage" 133 135 style={styles.bannerImage} 134 136 source={{ 135 - uri: maybeModifyHighQualityImage( 136 - banner, 137 + uri: applyImageTransforms(banner, { 138 + imageCdnHost, 137 139 highQualityImages, 138 - ), 140 + }), 139 141 }} 140 142 accessible={true} 141 143 accessibilityIgnoresInvertColors ··· 222 224 <Image 223 225 style={[styles.bannerImage, t.atoms.bg_contrast_25]} 224 226 contentFit="cover" 225 - source={{uri: maybeModifyHighQualityImage(banner, highQualityImages)}} 227 + source={{ 228 + uri: applyImageTransforms(banner, {imageCdnHost, highQualityImages}), 229 + }} 226 230 blurRadius={moderation?.blur ? 100 : 0} 227 231 accessible={true} 228 232 accessibilityIgnoresInvertColors
+1 -1
src/view/shell/Drawer.tsx
··· 56 56 UserCircle_Stroke2_Corner0_Rounded as UserCircle, 57 57 } from '#/components/icons/UserCircle' 58 58 import {InlineLinkText} from '#/components/Link' 59 - import {Text} from '#/components/Typography' 60 59 import {PdsBadge} from '#/components/PdsBadge' 60 + import {Text} from '#/components/Typography' 61 61 import {useSimpleVerificationState} from '#/components/verification' 62 62 import {VerificationCheck} from '#/components/verification/VerificationCheck' 63 63 import {IS_WEB} from '#/env'