diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index 208973cc9..a45dacf2e 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -1,10 +1,14 @@ -import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' import {Image} from 'expo-image' -import {AppBskyFeedDefs} from '@atproto/api' +import {type AppBskyFeedDefs} from '@atproto/api' import {Trans} from '@lingui/macro' +import type React from 'react' import {isTenorGifUri} from '#/lib/strings/embed-player' +import { + maybeModifyHighQualityImage, + useHighQualityImages, +} from '#/state/preferences/high-quality-images' import {atoms as a, useTheme} from '#/alf' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {Text} from '#/components/Typography' @@ -21,6 +25,7 @@ export function Embed({ embed: AppBskyFeedDefs.PostView['embed'] style?: StyleProp }) { + const highQualityImages = useHighQualityImages() const e = bsky.post.parseEmbed(embed) if (!e) return null @@ -31,7 +36,10 @@ export function Embed({ {e.view.images.map(image => ( ))} diff --git a/src/screens/Settings/DeerSettings.tsx b/src/screens/Settings/DeerSettings.tsx index f04cb5955..d1da73763 100644 --- a/src/screens/Settings/DeerSettings.tsx +++ b/src/screens/Settings/DeerSettings.tsx @@ -38,6 +38,10 @@ import { useHideFollowNotifications, useSetHideFollowNotifications, } from '#/state/preferences/hide-follow-notifications' +import { + useHighQualityImages, + useSetHighQualityImages, +} from '#/state/preferences/high-quality-images' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { useNoAppLabelers, @@ -260,6 +264,9 @@ export function DeerSettingsScreen({}: Props) { const noDiscoverFallback = useNoDiscoverFallback() const setNoDiscoverFallback = useSetNoDiscoverFallback() + const highQualityImages = useHighQualityImages() + const setHighQualityImages = useSetHighQualityImages() + const hideFollowNotifications = useHideFollowNotifications() const setHideFollowNotifications = useSetHideFollowNotifications() @@ -521,6 +528,24 @@ export function DeerSettingsScreen({}: Props) { + + setHighQualityImages(value)} + style={[a.w_full]}> + + Display images in higher quality + + + + + + Images will be served as PNG instead of JPEG. Images will take + longer to load and use more bandwidth. + + diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index df03cfd73..687a57f8d 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -140,6 +140,7 @@ const schema = z.object({ trusted: z.array(z.string()), }) .optional(), + highQualityImages: z.boolean().optional(), /** @deprecated */ mutedThreads: z.array(z.string()), @@ -213,6 +214,7 @@ export const defaults: Schema = { 'did:plc:b2kutgxqlltwc6lhs724cfwr', ], }, + highQualityImages: false, } export function tryParse(rawData: string): Schema | undefined { diff --git a/src/state/preferences/high-quality-images.tsx b/src/state/preferences/high-quality-images.tsx new file mode 100644 index 000000000..039ef4ef2 --- /dev/null +++ b/src/state/preferences/high-quality-images.tsx @@ -0,0 +1,71 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['highQualityImages'] +type SetContext = (v: persisted.Schema['highQualityImages']) => void + +const stateContext = React.createContext( + persisted.defaults.highQualityImages, +) +const setContext = React.createContext( + (_: persisted.Schema['highQualityImages']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('highQualityImages')) + + const setStateWrapped = React.useCallback( + (highQualityImages: persisted.Schema['highQualityImages']) => { + setState(highQualityImages) + persisted.write('highQualityImages', highQualityImages) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate('highQualityImages', nextHighQualityImages => { + setState(nextHighQualityImages) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useHighQualityImages() { + return React.useContext(stateContext) +} + +export function useSetHighQualityImages() { + return React.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' && url.pathname.endsWith('@jpeg')) { + url.pathname = url.pathname.replace(/@jpeg$/, '@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/index.tsx b/src/state/preferences/index.tsx index f7a00d62e..330d578e9 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -11,6 +11,7 @@ import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs' import {Provider as GoLinksProvider} from './go-links-enabled' import {Provider as HiddenPostsProvider} from './hidden-posts' import {Provider as FollowNotificationsProvider} from './hide-follow-notifications' +import {Provider as HighQualityImagesProvider} from './high-quality-images' import {Provider as InAppBrowserProvider} from './in-app-browser' import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' @@ -55,23 +56,25 @@ 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 20fc1c65d..62b4be32c 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -2,14 +2,14 @@ import React, {memo, useMemo} from 'react' import { Image, Pressable, - StyleProp, + type StyleProp, StyleSheet, View, - ViewStyle, + type ViewStyle, } from 'react-native' -import {Image as RNImage} from 'react-native-image-crop-picker' +import {type Image as RNImage} from 'react-native-image-crop-picker' import Svg, {Circle, Path, Rect} from 'react-native-svg' -import {ModerationUI} from '@atproto/api' +import {type ModerationUI} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -24,6 +24,10 @@ import {makeProfileLink} from '#/lib/routes/links' import {colors} from '#/lib/styles' import {logger} from '#/logger' import {isAndroid, isNative, isWeb} from '#/platform/detection' +import { + maybeModifyHighQualityImage, + useHighQualityImages, +} from '#/state/preferences/high-quality-images' import {precacheProfile} from '#/state/queries/profile' import {HighPriorityImage} from '#/view/com/util/images/Image' import {tokens, useTheme} from '#/alf' @@ -38,7 +42,7 @@ import {Link} from '#/components/Link' import {MediaInsetBorder} from '#/components/MediaInsetBorder' import * as Menu from '#/components/Menu' import {ProfileHoverCard} from '#/components/ProfileHoverCard' -import * as bsky from '#/types/bsky' +import type * as bsky from '#/types/bsky' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' @@ -194,6 +198,7 @@ let UserAvatar = ({ const pal = usePalette('default') const backgroundColor = pal.colors.backgroundLight const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') + const highQualityImages = useHighQualityImages() const aviStyle = useMemo(() => { if (finalShape === 'square') { @@ -247,7 +252,10 @@ let UserAvatar = ({ style={aviStyle} resizeMode="cover" source={{ - uri: hackModifyThumbnailPath(avatar, size < 90), + uri: maybeModifyHighQualityImage( + hackModifyThumbnailPath(avatar, size < 90), + highQualityImages, + ), }} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} onLoad={onLoad} @@ -258,7 +266,10 @@ let UserAvatar = ({ style={aviStyle} contentFit="cover" source={{ - uri: hackModifyThumbnailPath(avatar, size < 90), + uri: maybeModifyHighQualityImage( + hackModifyThumbnailPath(avatar, size < 90), + highQualityImages, + ), }} blurRadius={moderation?.blur ? BLUR_AMOUNT : 0} onLoad={onLoad} @@ -289,6 +300,7 @@ let EditableUserAvatar = ({ const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const sheetWrapper = useSheetWrapper() + const highQualityImages = useHighQualityImages() const aviStyle = useMemo(() => { if (type === 'algo' || type === 'list') { @@ -367,7 +379,9 @@ let EditableUserAvatar = ({ ) : ( diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index b29b30a53..69cf22b6d 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -27,6 +27,10 @@ import {useTheme} from '#/lib/ThemeContext' import {logger} from '#/logger' import {isAndroid, isNative} from '#/platform/detection' import {useLightboxControls} from '#/state/lightbox' +import { + maybeModifyHighQualityImage, + useHighQualityImages, +} from '#/state/preferences/high-quality-images' import {EventStopper} from '#/view/com/util/EventStopper' import {tokens, useTheme as useAlfTheme} from '#/alf' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' @@ -58,6 +62,7 @@ export function UserBanner({ const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const sheetWrapper = useSheetWrapper() const {openLightbox} = useLightboxControls() + const highQualityImages = useHighQualityImages() const bannerRef = useHandleRef() @@ -108,8 +113,8 @@ export function UserBanner({ openLightbox({ images: [ { - uri, - thumbUri: uri, + uri: maybeModifyHighQualityImage(uri, highQualityImages), + thumbUri: maybeModifyHighQualityImage(uri, highQualityImages), thumbRect, dimensions: thumbRect, thumbDimensions: null, @@ -119,7 +124,7 @@ export function UserBanner({ index: 0, }) }, - [openLightbox], + [openLightbox, highQualityImages], ) const onPressBanner = React.useCallback(() => { @@ -144,7 +149,9 @@ export function UserBanner({ @@ -222,7 +229,7 @@ export function UserBanner({ {backgroundColor: theme.palette.default.backgroundLight}, ]} contentFit="cover" - source={{uri: banner}} + source={{uri: maybeModifyHighQualityImage(banner, highQualityImages)}} blurRadius={moderation?.blur ? 100 : 0} accessible={true} accessibilityIgnoresInvertColors diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 883d3814f..cf9dc86b6 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -1,13 +1,17 @@ import React, {useRef} from 'react' -import {DimensionValue, Pressable, View} from 'react-native' +import {type DimensionValue, Pressable, View} from 'react-native' import {Image} from 'expo-image' -import {AppBskyEmbedImages} from '@atproto/api' +import {type AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef' -import type {Dimensions} from '#/lib/media/types' +import {type HandleRef, useHandleRef} from '#/lib/hooks/useHandleRef' +import {type Dimensions} from '#/lib/media/types' import {isNative} from '#/platform/detection' +import { + maybeModifyHighQualityImage, + useHighQualityImages, +} from '#/state/preferences/high-quality-images' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' @@ -77,6 +81,7 @@ export function AutoSizedImage({ const largeAlt = useLargeAltBadgeEnabled() const containerRef = useHandleRef() const fetchedDimsRef = useRef<{width: number; height: number} | null>(null) + const highQualityImages = useHighQualityImages() let aspectRatio: number | undefined const dims = image.aspectRatio @@ -107,7 +112,7 @@ export function AutoSizedImage({ 0) { const items = embed.images.map(img => ({ - uri: img.fullsize, - thumbUri: img.thumb, + uri: maybeModifyHighQualityImage(img.fullsize, highQualityImages), + thumbUri: maybeModifyHighQualityImage(img.thumb, highQualityImages), alt: img.alt, dimensions: img.aspectRatio ?? null, }))