diff --git a/README.md b/README.md
index bac7944df..4df7ce340 100644
--- a/README.md
+++ b/README.md
@@ -22,17 +22,18 @@ alt="Get it on Obtainium" height="54" /> -->
- You can change "post" to any phrase you want in the UI
- Choose between sharing witchsky.app or bsky.app links
- Embed player works with [stream.place](https://stream.place/) links!
-- Open posts in PDSls and original fedi-bridged pages
+- Open posts in PDSls and original bridged fedi instances
- You can delete and redraft (basically edit!) posts
- Better defaults (alt text required 😉 autoplay off 🫨)
- Read your chat threads with deleted/deactivated accounts
-- More unique repost icons
+- Unique repost icons in notifications
+- Repost carousels in following feed
- Can download videos
- Stay on a page when switching accounts
- 'Mutuals' in place of 'Following' when relevant
- Pet label (option to toggle, & visibility for everyone else)
- Account AI preferences screen
-- No unread notifications badge limit (30+)
+- No unread notifications badge limit (✗30+)
- No age assurance or blocks based on location
- No push notifications (hopefully will be added later)
- Kept as up-to-date as possible (sporadically unstable as a result)
@@ -88,10 +89,15 @@ These are all available as options in the Runes sub-page of the app's settings.
- Account switcher
- Use compact account switcher
- Automatically use compact account switcher with more than 6 accounts
- - Combine reposts into a horizontal carousel
- - Display images in higher quality
+ - Anything else
+ - Compact posts
- Show client used to post
- Change custom post phrase
+ - Images
+ - Thumbnail format
+ - Full-size format
+ - Download format
+ - Load especially small images as PNGs
- Infrastructure
- Post translation provider
- Google Translate
@@ -120,13 +126,12 @@ These are all available as options in the Runes sub-page of the app's settings.
### TODO: Xan
- [ ] Setup App Linking for Android (.well-known w/ app package fingerprint)
-- [ ] Make fallback email addresses use witchsky.social in Legacy sign-in automatic PDS detection
- [ ] Change followed accounts [on onboarding](https://github.com/blacksky-algorithms/blacksky.community/commit/e36ee43efb4999f070860d7f70122e45b28c1e2b)
- [ ] Visual replies indicator like the [Firmament userstyle](https://witchsky.app/profile/did:plc:jwhxcrf5uvl3vyw7nurecgt5/post/3m4rr3vzmak2a)
- [ ] Permit editing Menu options in Runes > Menus (remember [Outlinks page](https://witchsky.app/profile/did:plc:q7suwaz53ztc4mbiqyygbn43/post/3m5zjhhshic2g)?)
- [ ] ShareMenuItems.tsx, ShareMenuItems.web.tsx
- [ ] Add Open bridged OG fedi account page option to ProfileMenu.tsx
-- [ ] Witchsky PDS and .social site (list good songs containing 'bitch' in their titles for related site)
+- [ ] Witchsky PDS and .social site (list good songs containing 'bitch' in their titles on related site)
### Upcoming or wishful features
diff --git a/src/components/Lightbox/Lightbox.web.tsx b/src/components/Lightbox/Lightbox.web.tsx
index 5b6762342..fa9cd05cf 100644
--- a/src/components/Lightbox/Lightbox.web.tsx
+++ b/src/components/Lightbox/Lightbox.web.tsx
@@ -7,6 +7,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {saveImageToMediaLibrary} from '#/lib/media/manip'
import {useA11y} from '#/state/a11y'
+import {useDownloadFormat} from '#/state/preferences/download-format'
import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
import {
atoms as a,
@@ -107,6 +108,7 @@ function LightboxGallery({
const {gtPhone} = useBreakpoints()
const enableSquareButtons = useEnableSquareButtons()
+ const downloadFormat = useDownloadFormat()
const canGoLeft = index >= 1
const canGoRight = index < imgs.length - 1
@@ -309,7 +311,10 @@ function LightboxGallery({
{
- saveImageToMediaLibrary({uri: img.uri}).then(
+ saveImageToMediaLibrary({
+ uri: img.uri,
+ format: downloadFormat ?? 'jpeg',
+ }).then(
() => {
Toast.show(l`Image saved`)
},
diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx
index b61f2842b..a71419109 100644
--- a/src/components/MediaPreview.tsx
+++ b/src/components/MediaPreview.tsx
@@ -4,11 +4,11 @@ import {type AppBskyFeedDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {isGifEmbed} from '#/lib/strings/embed-player'
-import {useHighQualityImages} from '#/state/preferences/high-quality-images'
import {
applyImageTransforms,
useImageCdnHost,
} from '#/state/preferences/image-cdn-host'
+import {useThumbnailFormat} from '#/state/preferences/thumbnail-format'
import {atoms as a, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text} from '#/components/Typography'
@@ -101,13 +101,13 @@ export function ImageItem({
children?: React.ReactNode
}) {
const t = useTheme()
- const highQualityImages = useHighQualityImages()
+ const thumbnailFormat = useThumbnailFormat()
const imageCdnHost = useImageCdnHost()
const transformedThumbnail = thumbnail
? applyImageTransforms(thumbnail, {
imageCdnHost,
- highQualityImages,
+ format: thumbnailFormat,
})
: undefined
diff --git a/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx
index 4231319f3..2597ee61f 100644
--- a/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx
+++ b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx
@@ -26,11 +26,11 @@ import {
getPlayerAspect,
} from '#/lib/strings/embed-player'
import {useExternalEmbedsPrefs} from '#/state/preferences'
-import {useHighQualityImages} from '#/state/preferences/high-quality-images'
import {
applyImageTransforms,
useImageCdnHost,
} from '#/state/preferences/image-cdn-host'
+import {useThumbnailFormat} from '#/state/preferences/thumbnail-format'
import {EventStopper} from '#/view/com/util/EventStopper'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
@@ -136,7 +136,7 @@ export function ExternalPlayer({
const windowDims = useWindowDimensions()
const externalEmbedsPrefs = useExternalEmbedsPrefs()
const consentDialogControl = useDialogControl()
- const highQualityImages = useHighQualityImages()
+ const thumbnailFormat = useThumbnailFormat()
const imageCdnHost = useImageCdnHost()
const [isPlayerActive, setIsPlayerActive] = useState(false)
@@ -237,7 +237,7 @@ export function ExternalPlayer({
source={{
uri: applyImageTransforms(link.thumb, {
imageCdnHost,
- highQualityImages,
+ format: thumbnailFormat,
}),
}}
accessibilityIgnoresInvertColors
diff --git a/src/components/Post/Embed/ExternalEmbed/index.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx
index cabc84847..cc135e94c 100644
--- a/src/components/Post/Embed/ExternalEmbed/index.tsx
+++ b/src/components/Post/Embed/ExternalEmbed/index.tsx
@@ -14,11 +14,11 @@ import {
} 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 {useThumbnailFormat} from '#/state/preferences/thumbnail-format'
import {atoms as a, useTheme} from '#/alf'
import {Divider} from '#/components/Divider'
import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
@@ -44,13 +44,13 @@ export const ExternalEmbed = ({
const t = useTheme()
const playHaptic = useHaptics()
const externalEmbedPrefs = useExternalEmbedsPrefs()
- const highQualityImages = useHighQualityImages()
+ const thumbnailFormat = useThumbnailFormat()
const imageCdnHost = useImageCdnHost()
const niceUrl = toNiceDomain(link.uri)
const imageUri = link.thumb
? applyImageTransforms(link.thumb, {
imageCdnHost,
- highQualityImages,
+ format: thumbnailFormat,
})
: undefined
const embedPlayerParams = useMemo(() => {
diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx
index 57489f3b0..b3af1abf0 100644
--- a/src/components/Post/Embed/ImageEmbed.tsx
+++ b/src/components/Post/Embed/ImageEmbed.tsx
@@ -2,11 +2,14 @@ import {InteractionManager, View} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image'
-import {useHighQualityImages} from '#/state/preferences/high-quality-images'
+import {resolveEmbedImageUris} from '#/lib/media/embed-image-formats'
+import {useFullsizeFormat} from '#/state/preferences/fullsize-format'
import {
applyImageTransforms,
useImageCdnHost,
} from '#/state/preferences/image-cdn-host'
+import {useLoadAsPngs} from '#/state/preferences/load-small-pngs'
+import {useThumbnailFormat} from '#/state/preferences/thumbnail-format'
import {atoms as a, tokens} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {Gallery} from '#/components/images/Gallery'
@@ -26,24 +29,27 @@ export function ImageEmbed({
}) {
const ax = useAnalytics()
const {openLightbox} = useLightboxControls()
- const highQualityImages = useHighQualityImages()
+ const fullsizeFormat = useFullsizeFormat()
+ const thumbnailFormat = useThumbnailFormat()
+ const loadAsPngs = useLoadAsPngs()
const imageCdnHost = useImageCdnHost()
const {images} = embed.view
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
if (images.length > 0) {
- const items = images.map(img => ({
- uri: applyImageTransforms(img.fullsize, {
- imageCdnHost,
- highQualityImages,
- }),
- thumbUri: applyImageTransforms(img.thumb, {
- imageCdnHost,
- highQualityImages,
- }),
- alt: img.alt,
- dimensions: img.aspectRatio ?? null,
- }))
+ const items = images.map(img => {
+ const {fullsize, thumb} = resolveEmbedImageUris(img, {
+ thumbnailFormat: thumbnailFormat ?? 'webp',
+ fullsizeFormat: fullsizeFormat ?? 'webp',
+ loadAsPngs: loadAsPngs ?? true,
+ })
+ return {
+ uri: applyImageTransforms(fullsize, {imageCdnHost}),
+ thumbUri: applyImageTransforms(thumb, {imageCdnHost}),
+ alt: img.alt,
+ dimensions: img.aspectRatio ?? null,
+ }
+ })
const onPress = (
index: number,
refs: AnimatedRef[],
diff --git a/src/components/images/AutoSizedImage.tsx b/src/components/images/AutoSizedImage.tsx
index eefb5a368..04d3ad03a 100644
--- a/src/components/images/AutoSizedImage.tsx
+++ b/src/components/images/AutoSizedImage.tsx
@@ -12,12 +12,12 @@ 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 {
applyImageTransforms,
useImageCdnHost,
} from '#/state/preferences/image-cdn-host'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
+import {useThumbnailFormat} from '#/state/preferences/thumbnail-format'
import {atoms as a, useTheme, web} from '#/alf'
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
@@ -90,7 +90,7 @@ export function AutoSizedImage({
const largeAlt = useLargeAltBadgeEnabled()
const containerRef = useAnimatedRef()
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
- const highQualityImages = useHighQualityImages()
+ const thumbnailFormat = useThumbnailFormat()
const imageCdnHost = useImageCdnHost()
let aspectRatio: number | undefined
@@ -124,7 +124,7 @@ export function AutoSizedImage({
style={[a.w_full, a.h_full]}
source={applyImageTransforms(image.thumb, {
imageCdnHost,
- highQualityImages,
+ format: thumbnailFormat,
})}
accessible={true} // Must set for `accessibilityLabel` to work
accessibilityIgnoresInvertColors
diff --git a/src/components/images/ImageLayoutGridItem.tsx b/src/components/images/ImageLayoutGridItem.tsx
index 8bf6c7e1d..8b053bfbc 100644
--- a/src/components/images/ImageLayoutGridItem.tsx
+++ b/src/components/images/ImageLayoutGridItem.tsx
@@ -8,12 +8,12 @@ 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 {
applyImageTransforms,
useImageCdnHost,
} from '#/state/preferences/image-cdn-host'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
+import {useThumbnailFormat} from '#/state/preferences/thumbnail-format'
import {atoms as a, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
@@ -53,7 +53,7 @@ export function GalleryItem({
const t = useTheme()
const {_} = useLingui()
const largeAltBadge = useLargeAltBadgeEnabled()
- const highQualityImages = useHighQualityImages()
+ const thumbnailFormat = useThumbnailFormat()
const imageCdnHost = useImageCdnHost()
const image = images[index]
const hasAlt = !!image.alt
@@ -86,7 +86,7 @@ export function GalleryItem({
source={{
uri: applyImageTransforms(image.thumb, {
imageCdnHost,
- highQualityImages,
+ format: thumbnailFormat,
}),
}}
style={[a.flex_1]}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 9d0fbc3ef..eb4621533 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -7,7 +7,8 @@ import {
CHAT_PROXY_DID,
ENV_APPVIEW_DID_PROXY,
ENV_PUBLIC_BSKY_SERVICE,
- IS_DEV} from '#/env'
+ IS_DEV,
+} from '#/env'
export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
export const STAGING_SERVICE = 'https://staging.bsky.dev'
@@ -255,3 +256,6 @@ export const webLinks = {
}
export const DOH_ENDPOINT = 'https://cloudflare-dns.com/dns-query'
+
+export const PNG_IMG_MAX_BYTE = 100_000
+export const PNG_IMG_MAX_SIZE = 300
diff --git a/src/lib/media/embed-image-formats.ts b/src/lib/media/embed-image-formats.ts
new file mode 100644
index 000000000..1507e0704
--- /dev/null
+++ b/src/lib/media/embed-image-formats.ts
@@ -0,0 +1,39 @@
+import {PNG_IMG_MAX_SIZE} from '#/lib/constants'
+import {modifyImageFormat} from '#/lib/media/util'
+
+export function resolveEmbedImageUris(
+ img: {
+ thumb: string
+ fullsize: string
+ aspectRatio?: {width: number; height: number} | null
+ },
+ {
+ thumbnailFormat,
+ fullsizeFormat,
+ loadAsPngs,
+ }: {
+ thumbnailFormat: string
+ fullsizeFormat: string
+ loadAsPngs: boolean
+ },
+) {
+ const lowRes =
+ img.aspectRatio &&
+ img.aspectRatio.width <= 1000 &&
+ img.aspectRatio.height <= 1000
+
+ const pngSized =
+ loadAsPngs &&
+ img.aspectRatio &&
+ img.aspectRatio.width <= PNG_IMG_MAX_SIZE &&
+ img.aspectRatio.height <= PNG_IMG_MAX_SIZE
+
+ const resolvedFullsizeFormat = pngSized ? 'png' : fullsizeFormat
+ const fullsize = modifyImageFormat(img.fullsize, resolvedFullsizeFormat)
+ const thumb =
+ pngSized && lowRes
+ ? fullsize
+ : modifyImageFormat(img.thumb, thumbnailFormat)
+
+ return {fullsize, thumb}
+}
diff --git a/src/lib/media/image-formats.ts b/src/lib/media/image-formats.ts
new file mode 100644
index 000000000..366cc0e66
--- /dev/null
+++ b/src/lib/media/image-formats.ts
@@ -0,0 +1,18 @@
+import {IS_NATIVE} from '#/env'
+
+export const IMAGE_FORMATS = [
+ {label: 'AVIF', value: 'avif'},
+ {label: 'WebP', value: 'webp'},
+ {label: 'JPEG', value: 'jpeg'},
+ {label: 'PNG', value: 'png'},
+ {label: 'GIF', value: 'gif'},
+ {label: 'BMP', value: 'bmp'},
+ {label: 'ICO', value: 'ico'},
+ {label: 'JPEG XL', value: 'jxl'},
+ ...(IS_NATIVE ? [{label: 'HEIC', value: 'heic'}] : []),
+]
+
+export function formatToFileExt(format: string) {
+ if (format === 'jpeg') return 'jpg'
+ return format
+}
diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts
index 5037cfc7d..1c219154b 100644
--- a/src/lib/media/manip.ts
+++ b/src/lib/media/manip.ts
@@ -17,11 +17,12 @@ import * as MediaLibrary from 'expo-media-library'
import * as Sharing from 'expo-sharing'
import {POST_IMG_MAX} from '#/lib/constants'
+import {formatToFileExt} from '#/lib/media/image-formats'
import {logger} from '#/logger'
import {IS_ANDROID, IS_IOS} from '#/env'
import {type PickerImage} from './picker.shared'
import {type Dimensions} from './types'
-import {convertCdnPreset} from './util'
+import {getDownloadImageUri} from './util'
import {mimeToExt} from './video/util'
export async function compressIfNeeded(
@@ -103,19 +104,29 @@ export async function shareImageModal({uri}: {uri: string}) {
const ALBUM_NAME = 'Bluesky'
/**
- * Saves an image to the user's device. Uses the CDN's `download` preset
- * which uses the JPEG version with the Content-Disposition header set to
- * `attachment; filename=`. On native this saves to the media library;
- * on web it triggers a browser download.
+ * Saves an image to the user's device. Uses the CDN's `download` preset with the
+ * chosen format suffix. On native this saves to the media library; on web it
+ * triggers a browser download.
*/
-export async function saveImageToMediaLibrary({uri}: {uri: string}) {
- const downloadUri = convertCdnPreset(uri, 'download')
+export async function saveImageToMediaLibrary({
+ uri,
+ format = 'jpeg',
+}: {
+ uri: string
+ format?: string
+}) {
+ const downloadUri = getDownloadImageUri(uri, format)
const downloadedPath = await downloadImage(
downloadUri,
String(uuid.v4()),
20e3,
)
- const imagePath = await moveToPermanentPath(downloadedPath, '.jpg')
+ const dotIndex = downloadedPath.lastIndexOf('.')
+ const ext =
+ dotIndex >= 0
+ ? downloadedPath.slice(dotIndex)
+ : `.${formatToFileExt(format)}`
+ const imagePath = await moveToPermanentPath(downloadedPath, ext)
// save
try {
diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts
index fbd4d9551..cee8531a7 100644
--- a/src/lib/media/manip.web.ts
+++ b/src/lib/media/manip.web.ts
@@ -1,7 +1,8 @@
import {POST_IMG_MAX} from '#/lib/constants'
+import {formatToFileExt} from '#/lib/media/image-formats'
import {type PickerImage} from './picker.shared'
import {type Dimensions} from './types'
-import {blobToDataUri, convertCdnPreset, getDataUriSize} from './util'
+import {blobToDataUri, getDataUriSize, getDownloadImageUri} from './util'
import {mimeToExt} from './video/util'
export async function compressIfNeeded(
@@ -52,14 +53,20 @@ export async function shareImageModal(_opts: {uri: string}) {
}
/**
- * Saves an image to the user's device. Uses the CDN's `download` preset
- * which serves a JPEG with `Content-Disposition: attachment`. On web this
- * triggers a browser download via a temporary anchor — no fetch needed.
+ * Saves an image to the user's device. Uses the CDN's `download` preset with the
+ * chosen format suffix. On web this triggers a browser download via a temporary
+ * anchor — no fetch needed.
*/
-export async function saveImageToMediaLibrary({uri}: {uri: string}) {
- const downloadUri = convertCdnPreset(uri, 'download')
+export async function saveImageToMediaLibrary({
+ uri,
+ format = 'jpeg',
+}: {
+ uri: string
+ format?: string
+}) {
+ const downloadUri = getDownloadImageUri(uri, format)
const segments = downloadUri.split('/')
- const filename = `bluesky-${segments.at(-1) ?? 'image'}.jpg`
+ const filename = `bluesky-${segments.at(-1) ?? 'image'}.${formatToFileExt(format)}`
downloadUrl(downloadUri, filename)
}
diff --git a/src/lib/media/save-image.ios.ts b/src/lib/media/save-image.ios.ts
index 7bd270de8..3d62a0c2d 100644
--- a/src/lib/media/save-image.ios.ts
+++ b/src/lib/media/save-image.ios.ts
@@ -2,6 +2,7 @@ import {useCallback} from 'react'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
+import {useDownloadFormat} from '#/state/preferences/download-format'
import * as Toast from '#/components/Toast'
import {IS_NATIVE} from '#/env'
import {saveImageToMediaLibrary} from './manip'
@@ -14,6 +15,7 @@ import {saveImageToMediaLibrary} from './manip'
*/
export function useSaveImageToMediaLibrary() {
const {_} = useLingui()
+ const downloadFormat = useDownloadFormat()
return useCallback(
async (uri: string) => {
if (!IS_NATIVE) {
@@ -21,12 +23,15 @@ export function useSaveImageToMediaLibrary() {
}
try {
- await saveImageToMediaLibrary({uri})
+ await saveImageToMediaLibrary({
+ uri,
+ format: downloadFormat ?? 'jpeg',
+ })
Toast.show(_(msg`Image saved`))
} catch (e: any) {
Toast.show(_(msg`Failed to save image: ${String(e)}`), {type: 'error'})
}
},
- [_],
+ [downloadFormat, _],
)
}
diff --git a/src/lib/media/save-image.ts b/src/lib/media/save-image.ts
index bf48d8e68..d2753c835 100644
--- a/src/lib/media/save-image.ts
+++ b/src/lib/media/save-image.ts
@@ -3,6 +3,7 @@ import * as MediaLibrary from 'expo-media-library'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
+import {useDownloadFormat} from '#/state/preferences/download-format'
import * as Toast from '#/components/Toast'
import {IS_NATIVE} from '#/env'
import {saveImageToMediaLibrary} from './manip'
@@ -12,6 +13,7 @@ import {saveImageToMediaLibrary} from './manip'
*/
export function useSaveImageToMediaLibrary() {
const {_} = useLingui()
+ const downloadFormat = useDownloadFormat()
const [permissionResponse, requestPermission, getPermission] =
MediaLibrary.usePermissions({
granularPermissions: ['photo'],
@@ -24,7 +26,10 @@ export function useSaveImageToMediaLibrary() {
async function save() {
try {
- await saveImageToMediaLibrary({uri})
+ await saveImageToMediaLibrary({
+ uri,
+ format: downloadFormat ?? 'jpeg',
+ })
Toast.show(_(msg`Image saved`))
} catch (e: any) {
@@ -63,6 +68,6 @@ export function useSaveImageToMediaLibrary() {
}
}
},
- [permissionResponse, requestPermission, getPermission, _],
+ [permissionResponse, requestPermission, getPermission, downloadFormat, _],
)
}
diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts
index da0b306d4..ab08aece7 100644
--- a/src/lib/media/util.ts
+++ b/src/lib/media/util.ts
@@ -47,3 +47,14 @@ const IMGPROXY_PRESET_RE =
export function convertCdnPreset(uri: string, preset: ImgproxyPreset): string {
return uri.replace(IMGPROXY_PRESET_RE, `$1${preset}$3`)
}
+
+export function modifyImageFormat(uri: string, format: string) {
+ const atPosition = uri.lastIndexOf('@')
+ return atPosition === -1
+ ? `${uri}@${format}`
+ : uri.slice(0, atPosition + 1) + format
+}
+
+export function getDownloadImageUri(uri: string, format: string) {
+ return modifyImageFormat(convertCdnPreset(uri, 'download'), format)
+}
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
index 7332da95a..0b76eab21 100644
--- a/src/screens/Profile/Header/Shell.tsx
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -16,11 +16,12 @@ import {type NavigationProp} from '#/lib/routes/types'
import {type Shadow} from '#/state/cache/types'
import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars'
import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
-import {useHighQualityImages} from '#/state/preferences/high-quality-images'
+import {useFullsizeFormat} from '#/state/preferences/fullsize-format'
import {
applyImageTransforms,
useImageCdnHost,
} from '#/state/preferences/image-cdn-host'
+import {useThumbnailFormat} from '#/state/preferences/thumbnail-format'
import {useSession} from '#/state/session'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {UserAvatar} from '#/view/com/util/UserAvatar'
@@ -65,7 +66,8 @@ let ProfileHeaderShell = ({
const {top: topInset} = useSafeAreaInsets()
const playHaptic = useHaptics()
const liveStatusControl = useDialogControl()
- const highQualityImages = useHighQualityImages()
+ const fullsizeFormat = useFullsizeFormat()
+ const thumbnailFormat = useThumbnailFormat()
const imageCdnHost = useImageCdnHost()
const enableSquareAvatars = useEnableSquareAvatars()
const enableSquareButtons = useEnableSquareButtons()
@@ -90,10 +92,13 @@ let ProfileHeaderShell = ({
openLightbox({
images: [
{
- uri: applyImageTransforms(uri, {imageCdnHost, highQualityImages}),
+ uri: applyImageTransforms(uri, {
+ imageCdnHost,
+ format: fullsizeFormat,
+ }),
thumbUri: applyImageTransforms(uri, {
imageCdnHost,
- highQualityImages,
+ format: thumbnailFormat,
}),
thumbRect: null,
thumbRef,
@@ -121,7 +126,13 @@ let ProfileHeaderShell = ({
index: 0,
})
},
- [openLightbox, imageCdnHost, highQualityImages, enableSquareAvatars],
+ [
+ openLightbox,
+ imageCdnHost,
+ fullsizeFormat,
+ thumbnailFormat,
+ enableSquareAvatars,
+ ],
)
const isMe = useMemo(
diff --git a/src/screens/Settings/RunesSettings/DisplaySettings.tsx b/src/screens/Settings/RunesSettings/DisplaySettings.tsx
index e90244aa2..45c35992c 100644
--- a/src/screens/Settings/RunesSettings/DisplaySettings.tsx
+++ b/src/screens/Settings/RunesSettings/DisplaySettings.tsx
@@ -3,6 +3,7 @@ import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {usePalette} from '#/lib/hooks/usePalette'
+import {IMAGE_FORMATS} from '#/lib/media/image-formats'
import {dynamicActivate} from '#/locale/i18n'
import {dynamicActivate as dynamicActivateWeb} from '#/locale/i18n.web'
import {type AppLanguage} from '#/locale/languages'
@@ -14,9 +15,17 @@ import {
import {useAutoCompactAccountSwitcher} from '#/state/preferences/auto-compact-account-switcher'
import {useCompactAccountSwitcher} from '#/state/preferences/compact-account-switcher'
import {
- useHighQualityImages,
- useSetHighQualityImages,
-} from '#/state/preferences/high-quality-images'
+ useDownloadFormat,
+ useSetDownloadFormat,
+} from '#/state/preferences/download-format'
+import {
+ useFullsizeFormat,
+ useSetFullsizeFormat,
+} from '#/state/preferences/fullsize-format'
+import {
+ useLoadAsPngs,
+ useSetLoadAsPngs,
+} from '#/state/preferences/load-small-pngs'
import {
usePostReplacement,
useSetPostReplacement,
@@ -25,9 +34,12 @@ import {
useSetShowViaClient,
useShowViaClient,
} from '#/state/preferences/show-via-client'
+import {
+ useSetThumbnailFormat,
+ useThumbnailFormat,
+} from '#/state/preferences/thumbnail-format'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a} from '#/alf'
-import {Admonition} from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
@@ -36,6 +48,7 @@ import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Ima
import {Pencil_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
+import * as Select from '#/components/Select'
import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
import {ItemTextWithSubtitle} from '../NotificationSettings/components/ItemTextWithSubtitle'
@@ -47,12 +60,18 @@ export function RunesDisplaySettingsScreen() {
const alsoLikedFeedEnabled = useAlsoLikedFeedEnabled()
const alsoLikedCollapseByDefault = useAlsoLikedCollapseByDefault()
- const highQualityImages = useHighQualityImages()
- const setHighQualityImages = useSetHighQualityImages()
-
const showViaClient = useShowViaClient()
const setShowViaClient = useSetShowViaClient()
+ const thumbnailFormat = useThumbnailFormat() ?? 'webp'
+ const setThumbnailFormat = useSetThumbnailFormat()
+ const fullsizeFormat = useFullsizeFormat() ?? 'webp'
+ const setFullsizeFormat = useSetFullsizeFormat()
+ const downloadFormat = useDownloadFormat() ?? 'jpeg'
+ const setDownloadFormat = useSetDownloadFormat()
+ const loadAsPngs = useLoadAsPngs()
+ const setLoadAsPngs = useSetLoadAsPngs()
+
const setPostReplacementDialogControl = Dialog.useDialogControl()
return (
@@ -82,27 +101,6 @@ export function RunesDisplaySettingsScreen() {
subtitleText={}
/>
- setHighQualityImages(value)}>
-
-
-
- Display images in higher quality
-
-
-
-
-
-
-
- Images will be served as PNG instead of WEBP. Images will take
- longer to load and use more bandwidth.
-
-
-
+
+
+
+
+
+ Images
+
+
+
+ Thumbnail format
+
+ setThumbnailFormat(value)}>
+
+
+
+
+ (
+
+
+ {label}
+
+ )}
+ items={IMAGE_FORMATS}
+ />
+
+
+
+
+ Full-size format
+
+ setFullsizeFormat(value)}>
+
+
+
+
+ (
+
+
+ {label}
+
+ )}
+ items={IMAGE_FORMATS}
+ />
+
+
+
+
+ Download format
+
+ setDownloadFormat(value)}>
+
+
+
+
+ (
+
+
+ {label}
+
+ )}
+ items={IMAGE_FORMATS}
+ />
+
+
+ setLoadAsPngs(value)}
+ style={[a.w_full, a.mt_xs]}>
+
+
+ Load especially small images as PNGs
+
+
+
)
}
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index 9d0f513c5..f5d069533 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -213,7 +213,12 @@ const schema = z.object({
trusted: z.array(z.string()),
})
.optional(),
+ /** @deprecated Use thumbnailFormat and fullsizeFormat instead */
highQualityImages: z.boolean().optional(),
+ thumbnailFormat: z.string().optional(),
+ fullsizeFormat: z.string().optional(),
+ downloadFormat: z.string().optional(),
+ loadAsPngs: z.boolean().optional(),
imageCdnHost: z.string().optional(),
plcDirectory: z.string().optional(),
hideUnreplyablePosts: z.boolean().optional(),
@@ -357,6 +362,10 @@ export const defaults: Schema = {
trusted: [],
},
highQualityImages: false,
+ thumbnailFormat: 'webp',
+ fullsizeFormat: 'webp',
+ downloadFormat: 'jpeg',
+ loadAsPngs: true,
imageCdnHost: 'https://cdn.bsky.app',
plcDirectory: 'https://plc.directory',
hideUnreplyablePosts: false,
@@ -403,16 +412,24 @@ export function tryParse(rawData: string): Schema | undefined {
}
const parsed = schema.safeParse(objData)
if (parsed.success) {
- const data = parsed.data
+ let data = parsed.data
if (
(objData as {noAppLabelers?: boolean}).noAppLabelers &&
!data.ignoredAppLabelers?.length
) {
- return {
+ data = {
...data,
ignoredAppLabelers: [BSKY_LABELER_DID],
}
}
+ if (data.highQualityImages) {
+ data = {
+ ...data,
+ thumbnailFormat: data.thumbnailFormat ?? 'png',
+ fullsizeFormat: data.fullsizeFormat ?? 'png',
+ loadAsPngs: data.loadAsPngs ?? true,
+ }
+ }
return data
} else {
const errors =
diff --git a/src/state/preferences/download-format.tsx b/src/state/preferences/download-format.tsx
new file mode 100644
index 000000000..a847bd6ae
--- /dev/null
+++ b/src/state/preferences/download-format.tsx
@@ -0,0 +1,54 @@
+import {
+ createContext,
+ type PropsWithChildren,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from 'react'
+
+import * as persisted from '#/state/persisted'
+
+type StateContext = persisted.Schema['downloadFormat']
+type SetContext = (v: persisted.Schema['downloadFormat']) => void
+
+const stateContext = createContext(
+ persisted.defaults.downloadFormat,
+)
+const setContext = createContext(
+ (_: persisted.Schema['downloadFormat']) => {},
+)
+
+export function Provider({children}: PropsWithChildren<{}>) {
+ const [state, setState] = useState(persisted.get('downloadFormat'))
+
+ const setStateWrapped = useCallback(
+ (downloadFormat: persisted.Schema['downloadFormat']) => {
+ setState(downloadFormat)
+ persisted.write('downloadFormat', downloadFormat)
+ },
+ [setState],
+ )
+
+ useEffect(() => {
+ return persisted.onUpdate('downloadFormat', nextDownloadFormat => {
+ setState(nextDownloadFormat)
+ })
+ }, [setStateWrapped])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export function useDownloadFormat() {
+ return useContext(stateContext)
+}
+
+export function useSetDownloadFormat() {
+ return useContext(setContext)
+}
diff --git a/src/state/preferences/fullsize-format.tsx b/src/state/preferences/fullsize-format.tsx
new file mode 100644
index 000000000..0ff52860c
--- /dev/null
+++ b/src/state/preferences/fullsize-format.tsx
@@ -0,0 +1,54 @@
+import {
+ createContext,
+ type PropsWithChildren,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from 'react'
+
+import * as persisted from '#/state/persisted'
+
+type StateContext = persisted.Schema['fullsizeFormat']
+type SetContext = (v: persisted.Schema['fullsizeFormat']) => void
+
+const stateContext = createContext(
+ persisted.defaults.fullsizeFormat,
+)
+const setContext = createContext(
+ (_: persisted.Schema['fullsizeFormat']) => {},
+)
+
+export function Provider({children}: PropsWithChildren<{}>) {
+ const [state, setState] = useState(persisted.get('fullsizeFormat'))
+
+ const setStateWrapped = useCallback(
+ (fullsizeFormat: persisted.Schema['fullsizeFormat']) => {
+ setState(fullsizeFormat)
+ persisted.write('fullsizeFormat', fullsizeFormat)
+ },
+ [setState],
+ )
+
+ useEffect(() => {
+ return persisted.onUpdate('fullsizeFormat', nextFullsizeFormat => {
+ setState(nextFullsizeFormat)
+ })
+ }, [setStateWrapped])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export function useFullsizeFormat() {
+ return useContext(stateContext)
+}
+
+export function useSetFullsizeFormat() {
+ return useContext(setContext)
+}
diff --git a/src/state/preferences/high-quality-images.tsx b/src/state/preferences/high-quality-images.tsx
deleted file mode 100644
index 0eff6dbfe..000000000
--- a/src/state/preferences/high-quality-images.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import {
- createContext,
- type PropsWithChildren,
- useCallback,
- useContext,
- useEffect,
- useState,
-} from 'react'
-
-import * as persisted from '#/state/persisted'
-
-type StateContext = persisted.Schema['highQualityImages']
-type SetContext = (v: persisted.Schema['highQualityImages']) => void
-
-const stateContext = createContext(
- persisted.defaults.highQualityImages,
-)
-const setContext = createContext(
- (_: persisted.Schema['highQualityImages']) => {},
-)
-
-export function Provider({children}: PropsWithChildren<{}>) {
- const [state, setState] = useState(persisted.get('highQualityImages'))
-
- const setStateWrapped = useCallback(
- (highQualityImages: persisted.Schema['highQualityImages']) => {
- setState(highQualityImages)
- persisted.write('highQualityImages', highQualityImages)
- },
- [setState],
- )
-
- useEffect(() => {
- return persisted.onUpdate('highQualityImages', nextHighQualityImages => {
- setState(nextHighQualityImages)
- })
- }, [setStateWrapped])
-
- return (
-
-
- {children}
-
-
- )
-}
-
-export function useHighQualityImages() {
- return useContext(stateContext)
-}
-
-export function useSetHighQualityImages() {
- return useContext(setContext)
-}
-
-// This is a little weird to have here imo but it works I guess
-function modifyHighQualityImage(src: string) {
- try {
- const url = new URL(src)
- if (url.hostname === 'cdn.bsky.app') {
- // bluesky does not have this in urls anymore, but some forks like blacksky still do this so we need to still check for this
- if (url.pathname.endsWith('@jpeg'))
- url.pathname = url.pathname.replace(/@jpeg$/, '@png')
- else url.pathname = url.pathname += '@png'
- return url.toString()
- }
- } catch {
- // ignored, in case the URL is somehow malformed
- }
-
- return null
-}
-
-// Like `hackModifyThumbnailPath`, it's easier to just pipe the src into a function like this
-export function maybeModifyHighQualityImage(src: string, isEnabled?: boolean) {
- if (isEnabled) {
- return modifyHighQualityImage(src) ?? src
- } else {
- return src
- }
-}
diff --git a/src/state/preferences/image-cdn-host.tsx b/src/state/preferences/image-cdn-host.tsx
index 22d22440d..909a34092 100644
--- a/src/state/preferences/image-cdn-host.tsx
+++ b/src/state/preferences/image-cdn-host.tsx
@@ -99,26 +99,19 @@ export function maybeModifyImageCdnHost(src: string, imageCdnHost?: string) {
return modifyImageCdnHost(src, nextOrigin) ?? src
}
+import {modifyImageFormat} from '#/lib/media/util'
+
/**
- * Combined image transformation pipeline: applies high-quality image format
- * transformation first (on original CDN), then applies CDN host rewrite.
+ * Combined image transformation pipeline: applies image format, then CDN host
+ * rewrite.
*/
export function applyImageTransforms(
src: string,
options: {
imageCdnHost?: string
- highQualityImages?: boolean
+ format?: string
},
) {
- // 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)
+ const withFormat = modifyImageFormat(src, options.format ?? 'webp')
+ return maybeModifyImageCdnHost(withFormat, options.imageCdnHost)
}
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index 344428a31..77aebef60 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -28,24 +28,26 @@ import {Provider as DisableTopOfFeedButtonProvider} from './disable-top-of-feed-
import {Provider as DisableVerifyEmailReminderProvider} from './disable-verify-email-reminder'
import {Provider as DisableViaRepostNotificationProvider} from './disable-via-repost-notification'
import {Provider as DiscoverContextEnabledProvider} from './discover-context-enabled'
+import {Provider as DownloadFormatProvider} from './download-format'
import {Provider as EnableSquareAvatarsProvider} from './enable-square-avatars'
import {Provider as EnableSquareButtonsProvider} from './enable-square-buttons'
import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
import {Provider as ExternalShareButtonsProvider} from './external-share-buttons'
import {Provider as FaviconServiceProvider} from './favicon-service'
+import {Provider as FullsizeFormatProvider} from './fullsize-format'
import {Provider as GoLinksProvider} from './go-links-enabled'
import {Provider as HiddenPostsProvider} from './hidden-posts'
import {Provider as HideFeedsPromoTabProvider} from './hide-feeds-promo-tab'
import {Provider as HideScaryFollowButtonsProvider} from './hide-scary-follow-buttons.tsx'
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 IgnoredAppLabelersProvider} from './ignored-app-labelers'
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'
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
+import {Provider as LoadSmallPNGsProvider} from './load-small-pngs'
import {Provider as NoDiscoverProvider} from './no-discover-fallback'
import {Provider as OmitViaFieldProvider} from './omit-via-field'
import {Provider as OpenRouterProvider} from './openrouter'
@@ -61,6 +63,7 @@ import {Provider as ShowLinkInHandleProvider} from './show-link-in-handle'
import {Provider as ShowLinkInHandleOnlyOnWorkingLinksProvider} from './show-link-in-handle-only-on-working-links'
import {Provider as ShowViaClientProvider} from './show-via-client'
import {Provider as SubtitlesProvider} from './subtitles'
+import {Provider as ThumbnailFormatProvider} from './thumbnail-format'
import {Provider as TranslationServicePreferenceProvider} from './translation-service-preference'
import {Provider as TrendingSettingsProvider} from './trending'
import {Provider as UseHandleInLinksProvider} from './use-handle-in-links'
@@ -168,99 +171,105 @@ export function Provider({children}: PropsWithChildren<{}>) {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- children
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ children
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/state/preferences/load-small-pngs.tsx b/src/state/preferences/load-small-pngs.tsx
new file mode 100644
index 000000000..8b44cb7b9
--- /dev/null
+++ b/src/state/preferences/load-small-pngs.tsx
@@ -0,0 +1,52 @@
+import {
+ createContext,
+ type PropsWithChildren,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from 'react'
+
+import * as persisted from '#/state/persisted'
+
+type StateContext = persisted.Schema['loadAsPngs']
+type SetContext = (v: persisted.Schema['loadAsPngs']) => void
+
+const stateContext = createContext(persisted.defaults.loadAsPngs)
+const setContext = createContext(
+ (_: persisted.Schema['loadAsPngs']) => {},
+)
+
+export function Provider({children}: PropsWithChildren<{}>) {
+ const [state, setState] = useState(persisted.get('loadAsPngs'))
+
+ const setStateWrapped = useCallback(
+ (loadAsPngs: persisted.Schema['loadAsPngs']) => {
+ setState(loadAsPngs)
+ persisted.write('loadAsPngs', loadAsPngs)
+ },
+ [setState],
+ )
+
+ useEffect(() => {
+ return persisted.onUpdate('loadAsPngs', nextLoadAsPngs => {
+ setState(nextLoadAsPngs)
+ })
+ }, [setStateWrapped])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export function useLoadAsPngs() {
+ return useContext(stateContext)
+}
+
+export function useSetLoadAsPngs() {
+ return useContext(setContext)
+}
diff --git a/src/state/preferences/settings-sync.tsx b/src/state/preferences/settings-sync.tsx
index 7d8ec8303..75c78b6e6 100644
--- a/src/state/preferences/settings-sync.tsx
+++ b/src/state/preferences/settings-sync.tsx
@@ -74,7 +74,10 @@ export const SYNCED_PREFS_KEYS = [
'disableVerifyEmailReminder',
'showViaClient',
'deerVerification',
- 'highQualityImages',
+ 'thumbnailFormat',
+ 'fullsizeFormat',
+ 'downloadFormat',
+ 'loadAsPngs',
'imageCdnHost',
'plcDirectory',
'hideUnreplyablePosts',
diff --git a/src/state/preferences/thumbnail-format.tsx b/src/state/preferences/thumbnail-format.tsx
new file mode 100644
index 000000000..ca2bef851
--- /dev/null
+++ b/src/state/preferences/thumbnail-format.tsx
@@ -0,0 +1,54 @@
+import {
+ createContext,
+ type PropsWithChildren,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from 'react'
+
+import * as persisted from '#/state/persisted'
+
+type StateContext = persisted.Schema['thumbnailFormat']
+type SetContext = (v: persisted.Schema['thumbnailFormat']) => void
+
+const stateContext = createContext(
+ persisted.defaults.thumbnailFormat,
+)
+const setContext = createContext(
+ (_: persisted.Schema['thumbnailFormat']) => {},
+)
+
+export function Provider({children}: PropsWithChildren<{}>) {
+ const [state, setState] = useState(persisted.get('thumbnailFormat'))
+
+ const setStateWrapped = useCallback(
+ (thumbnailFormat: persisted.Schema['thumbnailFormat']) => {
+ setState(thumbnailFormat)
+ persisted.write('thumbnailFormat', thumbnailFormat)
+ },
+ [setState],
+ )
+
+ useEffect(() => {
+ return persisted.onUpdate('thumbnailFormat', nextThumbnailFormat => {
+ setState(nextThumbnailFormat)
+ })
+ }, [setStateWrapped])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export function useThumbnailFormat() {
+ return useContext(stateContext)
+}
+
+export function useSetThumbnailFormat() {
+ return useContext(setContext)
+}
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index f14a33689..55be5d865 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -38,11 +38,11 @@ import {
createComposerImage,
} from '#/state/gallery'
import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars'
-import {useHighQualityImages} from '#/state/preferences/high-quality-images'
import {
applyImageTransforms,
useImageCdnHost,
} from '#/state/preferences/image-cdn-host'
+import {useThumbnailFormat} from '#/state/preferences/thumbnail-format'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
import {atoms as a, tokens, useTheme} from '#/alf'
@@ -253,7 +253,7 @@ let UserAvatar = ({
const finalShape =
overrideShape ?? (type === 'user' ? avishapeforce : 'square')
- const highQualityImages = useHighQualityImages()
+ const thumbnailFormat = useThumbnailFormat()
const imageCdnHost = useImageCdnHost()
const aviStyle = useMemo(() => {
@@ -343,7 +343,7 @@ let UserAvatar = ({
source={{
uri: applyImageTransforms(
hackModifyThumbnailPath(avatar, size < 90),
- {imageCdnHost, highQualityImages},
+ {imageCdnHost, format: thumbnailFormat},
),
}}
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
@@ -357,7 +357,7 @@ let UserAvatar = ({
source={{
uri: applyImageTransforms(
hackModifyThumbnailPath(avatar, size < 90),
- {imageCdnHost, highQualityImages},
+ {imageCdnHost, format: thumbnailFormat},
),
}}
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
@@ -398,7 +398,7 @@ let EditableUserAvatar = ({
const editImageDialogControl = useDialogControl()
const sheetWrapper = useSheetWrapper()
- const highQualityImages = useHighQualityImages()
+ const thumbnailFormat = useThumbnailFormat()
const imageCdnHost = useImageCdnHost()
const enableSquareAvatars = useEnableSquareAvatars()
@@ -519,7 +519,7 @@ let EditableUserAvatar = ({
source={{
uri: applyImageTransforms(avatar, {
imageCdnHost,
- highQualityImages,
+ format: thumbnailFormat,
}),
}}
accessibilityRole="image"
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index cb8cf6ef5..8edb51aec 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -20,11 +20,12 @@ import {
compressImage,
createComposerImage,
} from '#/state/gallery'
-import {useHighQualityImages} from '#/state/preferences/high-quality-images'
+import {useFullsizeFormat} from '#/state/preferences/fullsize-format'
import {
applyImageTransforms,
useImageCdnHost,
} from '#/state/preferences/image-cdn-host'
+import {useThumbnailFormat} from '#/state/preferences/thumbnail-format'
import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
import {EventStopper} from '#/view/com/util/EventStopper'
import {atoms as a, tokens, useTheme} from '#/alf'
@@ -57,7 +58,8 @@ export function UserBanner({
const sheetWrapper = useSheetWrapper()
const [rawImage, setRawImage] = useState()
const editImageDialogControl = useDialogControl()
- const highQualityImages = useHighQualityImages()
+ const fullsizeFormat = useFullsizeFormat()
+ const thumbnailFormat = useThumbnailFormat()
const imageCdnHost = useImageCdnHost()
const onOpenCamera = useCallback(async () => {
@@ -139,7 +141,7 @@ export function UserBanner({
source={{
uri: applyImageTransforms(banner, {
imageCdnHost,
- highQualityImages,
+ format: thumbnailFormat,
}),
}}
accessible={true}
@@ -228,7 +230,10 @@ export function UserBanner({
style={[styles.bannerImage, t.atoms.bg_contrast_25]}
contentFit="cover"
source={{
- uri: applyImageTransforms(banner, {imageCdnHost, highQualityImages}),
+ uri: applyImageTransforms(banner, {
+ imageCdnHost,
+ format: fullsizeFormat,
+ }),
}}
blurRadius={moderation?.blur ? 100 : 0}
accessible={true}