diff --git a/README.md b/README.md index c1ae64565..24068ab48 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Without an overriding motivation, opinionated features should exist behind a tog - Stay away from PRs like... - Changing "Post" to "Skeet." - Refactoring the codebase, e.g., to replace React Query with Redux Toolkit or something. - - Adding entirely new features without prior discussion. +- Include a new toggle and preference for your feature. If we don't merge your PR for whatever reason, you are welcome to fork and/or self host: diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 2cca59972..d29e72e18 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -88,6 +88,7 @@ import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings' import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords' import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings' +import {DeerSettingsScreen} from '#/screens/Settings/DeerSettings' import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences' import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences' import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings' @@ -109,7 +110,6 @@ import { } from '#/components/dialogs/EmailDialog' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' -import {DeerSettingsScreen} from './screens/Settings/DeerSettings' import {LegacyNotificationSettingsScreen} from './screens/Settings/LegacyNotificationSettings' import {NotificationSettingsScreen} from './screens/Settings/NotificationSettings' import {LikeNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikeNotificationSettings' diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index 030d237a0..06a2744c5 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -9,6 +9,10 @@ import { import {Image} from 'expo-image' import {useLightboxControls} from '#/state/lightbox' +import { + maybeModifyHighQualityImage, + useHighQualityImages, +} from '#/state/preferences/high-quality-images' import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' import {AutoSizedImage} from '#/view/com/util/images/AutoSizedImage' import {ImageLayoutGrid} from '#/view/com/util/images/ImageLayoutGrid' @@ -24,12 +28,13 @@ export function ImageEmbed({ embed: EmbedType<'images'> }) { const {openLightbox} = useLightboxControls() + const highQualityImages = useHighQualityImages() const {images} = embed.view if (images.length > 0) { const items = 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, })) diff --git a/src/components/Post/Embed/PostPlaceholder.tsx b/src/components/Post/Embed/PostPlaceholder.tsx index 840234026..9d3226644 100644 --- a/src/components/Post/Embed/PostPlaceholder.tsx +++ b/src/components/Post/Embed/PostPlaceholder.tsx @@ -4,14 +4,25 @@ import {usePalette} from '#/lib/hooks/usePalette' import {InfoCircleIcon} from '#/lib/icons' import {Text} from '#/view/com/util/text/Text' import {atoms as a, useTheme} from '#/alf' +import {Loader} from '#/components/Loader' -export function PostPlaceholder({children}: {children: React.ReactNode}) { +export function PostPlaceholder({ + children, + directFetchEnabled, +}: { + children: React.ReactNode + directFetchEnabled?: boolean +}) { const t = useTheme() const pal = usePalette('default') return ( - + {directFetchEnabled ? ( + + ) : ( + + )} {children} diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index ace85dc98..6d5382c6a 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {View} from 'react-native' +import {StyleSheet, View} from 'react-native' import { type $Typed, type AppBskyFeedDefs, @@ -8,17 +8,22 @@ import { moderatePost, RichText as RichTextAPI, } from '@atproto/api' -import {Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {usePalette} from '#/lib/hooks/usePalette' import {makeProfileLink} from '#/lib/routes/links' +import {useDirectFetchRecords} from '#/state/preferences/direct-fetch-records' import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useDirectFetchEmbedRecord} from '#/state/queries/direct-fetch-record' import {unstableCacheProfileView} from '#/state/queries/profile' import {useSession} from '#/state/session' import {Link} from '#/view/com/util/Link' import {PostMeta} from '#/view/com/util/PostMeta' +import {Text} from '#/view/com/util/text/Text' import {atoms as a, useTheme} from '#/alf' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash' import {ContentHider} from '#/components/moderation/ContentHider' import {PostAlerts} from '#/components/moderation/PostAlerts' import {RichText} from '#/components/RichText' @@ -122,6 +127,20 @@ function RecordEmbed({ }: CommonProps & { embed: TEmbed }) { + const {_} = useLingui() + const directFetchEnabled = useDirectFetchRecords() + const shouldDirectFetch = + (embed.type === 'post_blocked' || embed.type === 'post_detached') && + directFetchEnabled + + const directRecord = useDirectFetchEmbedRecord({ + uri: + embed.type === 'post_blocked' || embed.type === 'post_detached' + ? embed.view.uri + : '', + enabled: shouldDirectFetch, + }) + switch (embed.type) { case 'feed': { return ( @@ -175,14 +194,42 @@ function RecordEmbed({ ) } case 'post_blocked': { + const record = directRecord.data + if (record !== undefined) { + return ( + + ) + } + return ( - + Blocked ) } case 'post_detached': { - return + const record = directRecord.data + if (record !== undefined) { + return ( + + ) + } + + return ( + + ) } default: { return null @@ -190,10 +237,50 @@ function RecordEmbed({ } } +export function DirectFetchEmbed({ + embed, + visibilityLabel, + visibilityLabelOwner, + ...rest +}: Omit & { + embed: EmbedType<'post'> + viewContext?: PostEmbedViewContext + visibilityLabel: string + visibilityLabelOwner?: string +}) { + const {currentAccount} = useSession() + const isViewerOwner = currentAccount?.did + ? embed.view.uri.includes(currentAccount.did) + : false + + return ( + + + + ) +} + export function PostDetachedEmbed({ embed, + directFetchEnabled, }: { embed: EmbedType<'post_detached'> + directFetchEnabled?: boolean }) { const {currentAccount} = useSession() const isViewerOwner = currentAccount?.did @@ -201,7 +288,7 @@ export function PostDetachedEmbed({ : false return ( - + {isViewerOwner ? ( Removed by you ) : ( @@ -221,9 +308,11 @@ export function QuoteEmbed({ style, isWithinQuote: parentIsWithinQuote, allowNestedQuotes: parentAllowNestedQuotes, + visibilityLabel, }: Omit & { embed: EmbedType<'post'> viewContext?: QuoteEmbedViewContext + visibilityLabel?: string }) { const moderationOpts = useModerationOpts() const quote = React.useMemo<$Typed>( @@ -292,6 +381,14 @@ export function QuoteEmbed({ title={itemTitle} onBeforePress={onBeforePress}> + {visibilityLabel !== undefined ? ( + + + + {visibilityLabel} + + + ) : undefined} ) } + +const styles = StyleSheet.create({ + blockHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + marginBottom: 8, + }, +}) diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index 892e3666f..aebe14285 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -35,7 +35,6 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers' import {toShareUrl} from '#/lib/strings/url-helpers' import {getTranslatorLink} from '#/locale/helpers' import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' import {type Shadow} from '#/state/cache/post-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useFeedFeedbackContext} from '#/state/feed-feedback' diff --git a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx index 8d52a2fdf..13cba4189 100644 --- a/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx +++ b/src/components/PostControls/ShareMenu/ShareMenuItems.web.tsx @@ -94,7 +94,7 @@ let ShareMenuItems = ({ return ( <> - {!hideInPWI && copyLinkItem} + {copyLinkItem} {hasSession && ( )} - {hideInPWI && ( + {false && hideInPWI && ( <> {hasSession && } {copyLinkItem} diff --git a/src/components/verification/VerifierDialog.tsx b/src/components/verification/VerifierDialog.tsx index c510e6398..122a0fb8e 100644 --- a/src/components/verification/VerifierDialog.tsx +++ b/src/components/verification/VerifierDialog.tsx @@ -102,8 +102,7 @@ function Inner({ Accounts with a scalloped blue check mark{' '} - {NON_BREAKING_SPACE} - + {' '} can verify others. These trusted verifiers are selected by{' '} {deerVerificationEnabled ? 'you' : 'Bluesky'}. diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx index 6b9686de5..2f4ece588 100644 --- a/src/screens/Profile/Header/Handle.tsx +++ b/src/screens/Profile/Header/Handle.tsx @@ -3,7 +3,7 @@ import {type AppBskyActorDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {isInvalidHandle} from '#/lib/strings/handles' +import {isInvalidHandle, sanitizeHandle} from '#/lib/strings/handles' import {isIOS} from '#/platform/detection' import {type Shadow} from '#/state/cache/types' import {useShowLinkInHandle} from '#/state/preferences/show-link-in-handle.tsx' @@ -64,7 +64,7 @@ export function ProfileHeaderHandle({ ) : ( - `@${profile.handle}` + sanitizeHandle(profile.handle, '@') )} diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx index c3c9aaeea..d8ce7f0e0 100644 --- a/src/screens/Settings/AboutSettings.tsx +++ b/src/screens/Settings/AboutSettings.tsx @@ -30,7 +30,7 @@ export function AboutSettingsScreen({}: Props) { const {_, i18n} = useLingui() const [devModeEnabled, setDevModeEnabled] = useDevMode() const [demoModeEnabled, setDemoModeEnabled] = useDemoMode() - const stableID = useMemo(() => Statsig.getStableID(), []) + const stableID = `DEER_SOCIAL_OOPS` const {mutate: onClearImageCache, isPending: isClearingImageCache} = useMutation({ diff --git a/src/state/geolocation.tsx b/src/state/geolocation.tsx index 022f233e7..c0153194f 100644 --- a/src/state/geolocation.tsx +++ b/src/state/geolocation.tsx @@ -1,7 +1,6 @@ import React from 'react' import EventEmitter from 'eventemitter3' -import {networkRetry} from '#/lib/async/retry' import {logger} from '#/logger' import {type Device, device} from '#/storage' @@ -27,24 +26,6 @@ export const DEFAULT_GEOLOCATION: Device['geolocation'] = { countryCode: 'US', } -async function getGeolocation(): Promise { - const res = await fetch(`https://bsky.app/ipcc`) - - if (!res.ok) { - throw new Error(`geolocation: lookup failed ${res.status}`) - } - - const json = await res.json() - - if (json.countryCode) { - return { - countryCode: json.countryCode, - } - } else { - return undefined - } -} - /** * Local promise used within this file only. */ @@ -64,57 +45,16 @@ export function beginResolveGeolocation() { * In dev, IP server is unavailable, so we just set the default geolocation * and fail closed. */ - if (__DEV__) { - geolocationResolution = new Promise(y => y({success: true})) + geolocationResolution = new Promise(y => y({success: true})) + if (device.get(['geolocation']) == undefined) { device.set(['geolocation'], DEFAULT_GEOLOCATION) } } -geolocationResolution = new Promise(async resolve => { - let success = true - - try { - // Try once, fail fast - const geolocation = await getGeolocation() - if (geolocation) { - device.set(['geolocation'], geolocation) - emitGeolocationUpdate(geolocation) - logger.debug(`geolocation: success`, {geolocation}) - } else { - // endpoint should throw on all failures, this is insurance - throw new Error(`geolocation: nothing returned from initial request`) - } - } catch (e: any) { - success = false - - logger.debug(`geolocation: failed initial request`, { - safeMessage: e.message, - }) - - // set to default - device.set(['geolocation'], DEFAULT_GEOLOCATION) - - // retry 3 times, but don't await, proceed with default - networkRetry(3, getGeolocation) - .then(geolocation => { - if (geolocation) { - device.set(['geolocation'], geolocation) - emitGeolocationUpdate(geolocation) - logger.debug(`geolocation: success`, {geolocation}) - success = true - } else { - // endpoint should throw on all failures, this is insurance - throw new Error(`geolocation: nothing returned from retries`) - } - }) - .catch((e: any) => { - // complete fail closed - logger.debug(`geolocation: failed retries`, {safeMessage: e.message}) - }) - } finally { - resolve({success}) - } -}) +export function setGeolocation(geolocation: Device['geolocation']) { + device.set(['geolocation'], geolocation) + emitGeolocationUpdate(geolocation) +} /** * Ensure that geolocation has been resolved, or at the very least attempted diff --git a/src/state/queries/direct-fetch-record.ts b/src/state/queries/direct-fetch-record.ts index 074d82169..bae65ec01 100644 --- a/src/state/queries/direct-fetch-record.ts +++ b/src/state/queries/direct-fetch-record.ts @@ -12,6 +12,7 @@ import {retry} from '#/lib/async/retry' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' import * as bsky from '#/types/bsky' +import {type EmbedType} from '#/types/bsky/post' const RQKEY_ROOT = 'direct-fetch-record' export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] @@ -62,20 +63,23 @@ export async function directFetchRecordAndProfile( export async function directFetchEmbedRecord( agent: BskyAgent, uri: string, -): Promise { +): Promise | undefined> { const res = await directFetchRecordAndProfile(agent, uri) if (res === undefined) return undefined const {profile, record} = res if (record && bsky.validate(record, AppBskyFeedPost.validateRecord)) { return { - $type: 'app.bsky.embed.record#viewRecord', - uri, - author: profile as ProfileViewBasic, - cid: 'directfetch', - value: record, - indexedAt: new Date().toISOString(), - } satisfies AppBskyEmbedRecord.ViewRecord + type: 'post', + view: { + $type: 'app.bsky.embed.record#viewRecord', + uri, + author: profile as ProfileViewBasic, + cid: 'directfetch', + value: record, + indexedAt: new Date().toISOString(), + } satisfies AppBskyEmbedRecord.ViewRecord, + } } else { return undefined } @@ -89,7 +93,7 @@ export function useDirectFetchEmbedRecord({ enabled?: boolean }) { const agent = useAgent() - return useQuery({ + return useQuery | undefined>({ staleTime: STALE.HOURS.ONE, queryKey: RQKEY(uri || ''), async queryFn() { diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 8825d1864..1d2cd6aa9 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -10,10 +10,10 @@ export type Device = { } trendingBetaEnabled: boolean devMode: boolean + demoMode: boolean // deer deerGateCache: string - demoMode: boolean } export type Account = { diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 088ab8871..fe218e639 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -164,6 +164,7 @@ export function getItemsForFeedback(feedRow: FeedRow): return feedRow.items.map((item, i) => ({ item: item.items[0], feedContext: feedRow.items[i].feedContext, + reqId: feedRow.items[i].reqId, })) } else if (feedRow.type === 'videoGridRow') { return feedRow.items.map((item, i) => ({ diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index e4c42c073..9e5aa4ed6 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -330,7 +330,7 @@ let ProfileMenu = ({ Remove trust - + ) : ( Trust verifier - + ))} {isSelf && canGoLive && ( diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index c37f5c3a1..188c230a6 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -431,6 +431,7 @@ let EditableUserAvatar = ({ }, [onSelectNewAvatar], ) + return ( <> diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index dd124f4de..c218be70c 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -1,4 +1,3 @@ -import React from 'react' import {useCallback, useState} from 'react' import { Pressable, @@ -7,11 +6,12 @@ import { View, } from 'react-native' import { + measure, type MeasuredDimensions, runOnJS, runOnUI, + useAnimatedRef, } from 'react-native-reanimated' -import {measure, useAnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' import {type ModerationUI} from '@atproto/api' import {msg, Trans} from '@lingui/macro' @@ -24,7 +24,6 @@ import { import {compressIfNeeded} from '#/lib/media/manip' import {openCamera, openCropper, openPicker} from '#/lib/media/picker' import {type PickerImage} from '#/lib/media/picker.shared' -import {useTheme} from '#/lib/ThemeContext' import {logger} from '#/logger' import {isAndroid, isNative} from '#/platform/detection' import { @@ -39,7 +38,7 @@ import { } from '#/state/preferences/high-quality-images' import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' import {EventStopper} from '#/view/com/util/EventStopper' -import {atoms as a, tokens, useTheme as useAlfTheme} from '#/alf' +import {atoms as a, tokens, useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import { @@ -61,18 +60,17 @@ export function UserBanner({ moderation?: ModerationUI onSelectNewBanner?: (img: PickerImage | null) => void }) { - const theme = useTheme() - const t = useAlfTheme() + const t = useTheme() const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const sheetWrapper = useSheetWrapper() + const [rawImage, setRawImage] = useState() + const editImageDialogControl = useDialogControl() const {openLightbox} = useLightboxControls() const highQualityImages = useHighQualityImages() const bannerRef = useAnimatedRef() - const [rawImage, setRawImage] = useState() - const editImageDialogControl = useDialogControl() const onOpenCamera = useCallback(async () => { if (!(await requestCameraAccessIfNeeded())) { @@ -126,7 +124,7 @@ export function UserBanner({ onSelectNewBanner?.(null) }, [onSelectNewBanner]) - const _openLightbox = React.useCallback( + const _openLightbox = useCallback( (uri: string, thumbRect: MeasuredDimensions | null) => { openLightbox({ images: [ @@ -145,7 +143,7 @@ export function UserBanner({ [openLightbox, highQualityImages], ) - const onPressBanner = React.useCallback(() => { + const onPressBanner = useCallback(() => { if (banner && !(moderation?.blur && moderation?.noOverride)) { runOnUI(() => { 'worklet' @@ -175,7 +173,12 @@ export function UserBanner({ @@ -266,12 +269,9 @@ export function UserBanner({ accessibilityHint=""> Date: Wed, 18 Jun 2025 10:46:42 -0500 Subject: [PATCH 2/2] Fix video downloading --- src/components/PostControls/PostMenu/PostMenuItems.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index aebe14285..892e3666f 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -35,6 +35,7 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers' import {toShareUrl} from '#/lib/strings/url-helpers' import {getTranslatorLink} from '#/locale/helpers' import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' import {type Shadow} from '#/state/cache/post-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useFeedFeedbackContext} from '#/state/feed-feedback'