From da4c307b8655f754f2b66a29dd4091d7b1be4252 Mon Sep 17 00:00:00 2001 From: Aviva Ruben Date: Tue, 8 Apr 2025 13:35:58 -0500 Subject: [PATCH] initial settings pane w basic features this commit adds settings pane, and basic features: - seeing through blocks and detaches - disabling go links it disables analytics, so it must also handle gates. this commit adds a ui for toggling gates, and makes a mess of the gate cache to persist it to storage. --- src/Navigation.tsx | 9 + src/components/Link.tsx | 7 +- src/lib/statsig/statsig.tsx | 131 +++++-------- src/routes.ts | 1 + src/screens/Settings/AboutSettings.tsx | 4 +- src/screens/Settings/DeerSettings.tsx | 181 ++++++++++++++++++ src/screens/Settings/Settings.tsx | 7 + src/state/persisted/schema.ts | 13 ++ .../preferences/constellation-enabled.tsx | 52 +++++ .../preferences/direct-fetch-records.tsx | 47 +++++ src/state/preferences/go-links-enabled.tsx | 47 +++++ src/state/preferences/index.tsx | 50 +++-- src/state/queries/direct-fetch-record.ts | 75 ++++++++ src/storage/schema.ts | 3 + src/view/com/util/post-embeds/QuoteEmbed.tsx | 90 ++++++++- 15 files changed, 606 insertions(+), 111 deletions(-) create mode 100644 src/screens/Settings/DeerSettings.tsx create mode 100644 src/state/preferences/constellation-enabled.tsx create mode 100644 src/state/preferences/direct-fetch-records.tsx create mode 100644 src/state/preferences/go-links-enabled.tsx create mode 100644 src/state/queries/direct-fetch-record.ts diff --git a/src/Navigation.tsx b/src/Navigation.tsx index d4fdc4797..61c5eb4fb 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -100,6 +100,7 @@ import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySetti import {AccountSettingsScreen} from './screens/Settings/AccountSettings' 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 {LanguageSettingsScreen} from './screens/Settings/LanguageSettings' @@ -353,6 +354,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { requireAuth: true, }} /> + DeerSettingsScreen} + options={{ + title: title(msg`Deer Settings`), + requireAuth: true, + }} + /> AppearanceSettingsScreen} diff --git a/src/components/Link.tsx b/src/components/Link.tsx index cca93c0c8..011745949 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -16,6 +16,7 @@ import { } from '#/lib/strings/url-helpers' import {isNative, isWeb} from '#/platform/detection' import {useModalControls} from '#/state/modals' +import {useGoLinksEnabled} from '#/state/preferences' import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf' import {Button, type ButtonProps} from '#/components/Button' import {useInteractionState} from '#/components/hooks/useInteractionState' @@ -100,6 +101,8 @@ export function useLink({ const {openModal, closeModal} = useModalControls() const openLink = useOpenLink() + const goLinksEnabled = useGoLinksEnabled() + const onPress = React.useCallback( (e: GestureResponderEvent) => { const exitEarlyIfFalse = outerOnPress?.(e) @@ -125,7 +128,8 @@ export function useLink({ }) } else { if (isExternal) { - openLink(href, overridePresentation, shouldProxy) + // openLink(href, overridePresentation, shouldProxy) + openLink(href, overridePresentation, goLinksEnabled && shouldProxy) } else { const shouldOpenInNewTab = shouldClickOpenNewTab(e) @@ -169,6 +173,7 @@ export function useLink({ navigation, overridePresentation, shouldProxy, + goLinksEnabled, ], ) diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index 7f04da126..cffd09fc5 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -1,19 +1,19 @@ import React from 'react' import {Platform} from 'react-native' -import {AppState, AppStateStatus} from 'react-native' -import {Statsig, StatsigProvider} from 'statsig-react-native-expo' +import {AppState, type AppStateStatus} from 'react-native' +import {Statsig} from 'statsig-react-native-expo' -import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info' +import {BUNDLE_DATE, BUNDLE_IDENTIFIER} from '#/lib/app-info' import {logger} from '#/logger' -import {MetricEvents} from '#/logger/metrics' +import {type MetricEvents} from '#/logger/metrics' import {isWeb} from '#/platform/detection' import * as persisted from '#/state/persisted' -import {useSession} from '../../state/session' +import {device} from '#/storage' import {timeout} from '../async/timeout' -import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback' -import {Gate} from './gates' +// import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback' +import {type Gate} from './gates' -const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV' +// const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV' export const initPromise = initialize() @@ -44,25 +44,25 @@ if (isWeb && typeof window !== 'undefined') { export type {MetricEvents as LogEvents} -function createStatsigOptions(prefetchUsers: StatsigUser[]) { - return { - environment: { - tier: - process.env.NODE_ENV === 'development' - ? 'development' - : IS_TESTFLIGHT - ? 'staging' - : 'production', - }, - // Don't block on waiting for network. The fetched config will kick in on next load. - // This ensures the UI is always consistent and doesn't update mid-session. - // Note this makes cold load (no local storage) and private mode return `false` for all gates. - initTimeoutMs: 1, - // Get fresh flags for other accounts as well, if any. - prefetchUsers, - api: 'https://events.bsky.app/v2', - } -} +// function createStatsigOptions(prefetchUsers: StatsigUser[]) { +// return { +// environment: { +// tier: +// process.env.NODE_ENV === 'development' +// ? 'development' +// : IS_TESTFLIGHT +// ? 'staging' +// : 'production', +// }, +// // Don't block on waiting for network. The fetched config will kick in on next load. +// // This ensures the UI is always consistent and doesn't update mid-session. +// // Note this makes cold load (no local storage) and private mode return `false` for all gates. +// initTimeoutMs: 1, +// // Get fresh flags for other accounts as well, if any. +// prefetchUsers, +// api: 'https://events.bsky.app/v2', +// } +// } type FlatJSONRecord = Record< string, @@ -152,6 +152,22 @@ type GateOptions = { dangerouslyDisableExposureLogging?: boolean } +export function useGatesCache(): Map { + const cache = React.useContext(GateCache) + if (!cache) { + throw Error('useGate() cannot be called outside StatsigProvider.') + } + return cache +} + +function writeDeerGateCache(cache: Map) { + device.set(['deerGateCache'], JSON.stringify(Object.fromEntries(cache))) +} + +export function resetDeerGateCache() { + writeDeerGateCache(new Map()) +} + export function useGate(): (gateName: Gate, options?: GateOptions) => boolean { const cache = React.useContext(GateCache) if (!cache) { @@ -172,6 +188,7 @@ export function useGate(): (gateName: Gate, options?: GateOptions) => boolean { } } cache.set(gateName, value) + writeDeerGateCache(cache) return value }, [cache], @@ -195,6 +212,7 @@ export function useDangerousSetGate(): ( const dangerousSetGate = React.useCallback( (gateName: Gate, value: boolean) => { cache.set(gateName, value) + writeDeerGateCache(cache) }, [cache], ) @@ -263,63 +281,14 @@ export async function tryFetchGates( } export function initialize() { - return Statsig.initialize(SDK_KEY, null, createStatsigOptions([])) + // return Statsig.initialize(SDK_KEY, null, createStatsigOptions([])) + return new Promise(() => {}) } export function Provider({children}: {children: React.ReactNode}) { - const {currentAccount, accounts} = useSession() - const did = currentAccount?.did - const currentStatsigUser = React.useMemo(() => toStatsigUser(did), [did]) - - const otherDidsConcatenated = accounts - .map(account => account.did) - .filter(accountDid => accountDid !== did) - .join(' ') // We're only interested in DID changes. - const otherStatsigUsers = React.useMemo( - () => otherDidsConcatenated.split(' ').map(toStatsigUser), - [otherDidsConcatenated], - ) - const statsigOptions = React.useMemo( - () => createStatsigOptions(otherStatsigUsers), - [otherStatsigUsers], + const gateCache = new Map( + Object.entries(JSON.parse(device.get(['deerGateCache']) ?? '{}')), ) - // Have our own cache in front of Statsig. - // This ensures the results remain stable until the active DID changes. - const [gateCache, setGateCache] = React.useState(() => new Map()) - const [prevDid, setPrevDid] = React.useState(did) - if (did !== prevDid) { - setPrevDid(did) - setGateCache(new Map()) - } - - // Periodically poll Statsig to get the current rule evaluations for all stored accounts. - // These changes are prefetched and stored, but don't get applied until the active DID changes. - // This ensures that when you switch an account, it already has fresh results by then. - const handleIntervalTick = useNonReactiveCallback(() => { - if (Statsig.initializeCalled()) { - // Note: Only first five will be taken into account by Statsig. - Statsig.prefetchUsers([currentStatsigUser, ...otherStatsigUsers]) - } - }) - React.useEffect(() => { - const id = setInterval(handleIntervalTick, 60e3 /* 1 min */) - return () => clearInterval(id) - }, [handleIntervalTick]) - - return ( - - - {children} - - - ) + return {children} } diff --git a/src/routes.ts b/src/routes.ts index 60bb65dd5..5126bd9ec 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -40,6 +40,7 @@ export const router = new Router({ PreferencesThreads: '/settings/threads', PreferencesExternalEmbeds: '/settings/external-embeds', AccessibilitySettings: '/settings/accessibility', + DeerSettings: '/settings/deer', AppearanceSettings: '/settings/appearance', SavedFeeds: '/settings/saved-feeds', // new settings diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx index e26242550..53456c28c 100644 --- a/src/screens/Settings/AboutSettings.tsx +++ b/src/screens/Settings/AboutSettings.tsx @@ -1,4 +1,3 @@ -import {useMemo} from 'react' import {Platform} from 'react-native' import {setStringAsync} from 'expo-clipboard' import * as FileSystem from 'expo-file-system' @@ -7,7 +6,6 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {useMutation} from '@tanstack/react-query' -import {Statsig} from 'statsig-react-native-expo' import {appVersion, BUNDLE_DATE, bundleInfo} from '#/lib/app-info' import {STATUS_PAGE_URL} from '#/lib/constants' @@ -29,7 +27,7 @@ type Props = NativeStackScreenProps export function AboutSettingsScreen({}: Props) { const {_, i18n} = useLingui() const [devModeEnabled, setDevModeEnabled] = useDevModeEnabled() - const stableID = useMemo(() => Statsig.getStableID(), []) + const stableID = `DEER_SOCIAL_OOPS` const {mutate: onClearImageCache, isPending: isClearingImageCache} = useMutation({ diff --git a/src/screens/Settings/DeerSettings.tsx b/src/screens/Settings/DeerSettings.tsx new file mode 100644 index 000000000..27acd3b75 --- /dev/null +++ b/src/screens/Settings/DeerSettings.tsx @@ -0,0 +1,181 @@ +import {useState} from 'react' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' + +import {type CommonNavigatorParams} from '#/lib/routes/types' +import {type Gate} from '#/lib/statsig/gates' +import { + resetDeerGateCache, + useDangerousSetGate, + useGatesCache, +} from '#/lib/statsig/statsig' +import {useGoLinksEnabled, useSetGoLinksEnabled} from '#/state/preferences' +import { + useConstellationEnabled, + useSetConstellationEnabled, +} from '#/state/preferences/constellation-enabled' +import { + useDirectFetchRecords, + useSetDirectFetchRecords, +} from '#/state/preferences/direct-fetch-records' +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import * as Toggle from '#/components/forms/Toggle' +import {Atom_Stroke2_Corner0_Rounded as DeerIcon} from '#/components/icons/Atom' +import {Eye_Stroke2_Corner0_Rounded as VisibilityIcon} from '#/components/icons/Eye' +import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller' +import * as Layout from '#/components/Layout' + +type Props = NativeStackScreenProps + +export function DeerSettingsScreen({}: Props) { + const {_} = useLingui() + + const goLinksEnabled = useGoLinksEnabled() + const setGoLinksEnabled = useSetGoLinksEnabled() + + const constellationEnabled = useConstellationEnabled() + const setConstellationEnabled = useSetConstellationEnabled() + + const directFetchRecords = useDirectFetchRecords() + const setDirectFetchRecords = useSetDirectFetchRecords() + + const [gates, setGatesView] = useState(Object.fromEntries(useGatesCache())) + const dangerousSetGate = useDangerousSetGate() + const setGate = (gate: Gate, value: boolean) => { + dangerousSetGate(gate, value) + setGatesView({ + ...gates, + [gate]: value, + }) + } + + return ( + + + + + + Deer + + + + + + + + + + Redirects + + setGoLinksEnabled(value)} + style={[a.w_full]}> + + Redirect through go.bsky.app + + + + + + + + + Visibility + + setDirectFetchRecords(value)} + style={[a.w_full]}> + + + Fetch records directly from PDS to see through quote blocks + + + + + setConstellationEnabled(value)} + style={[a.w_full]}> + + + TODO: Fall back to constellation api to find blocked replies + + + + + + + + + + Tweaks + + {}} + disabled={true} + style={[a.w_full]}> + + 🚧 under construction... + + + + + + + + + Gates + + {Object.entries(gates).map(([gate, status]) => ( + setGate(gate as Gate, value)} + style={[a.w_full]}> + {gate} + + + ))} + { + resetDeerGateCache() + setGatesView({}) + }} + /> + + + + + + These settings might summon nasel demons! Restart the app after + changing if anything breaks. + + + + + + + ) +} diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index a723aaa37..3f9b30707 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -34,6 +34,7 @@ import {AvatarStackWithFetch} from '#/components/AvatarStack' import {useDialogControl} from '#/components/Dialog' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' +import {Atom_Stroke2_Corner0_Rounded as DeerIcon} from '#/components/icons/Atom' import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo' import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron' import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion' @@ -194,6 +195,12 @@ export function SettingsScreen({}: Props) { Appearance + + + + Deer + + diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index f840081f3..519488e5a 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -123,6 +123,13 @@ const schema = z.object({ kawaii: z.boolean().optional(), hasCheckedForStarterPack: z.boolean().optional(), subtitlesEnabled: z.boolean().optional(), + + // deer + goLinksEnabled: z.boolean().optional(), + constellationEnabled: z.boolean().optional(), + directFetchRecords: z.boolean().optional(), + unfollowConfirm: z.boolean().optional(), + /** @deprecated */ mutedThreads: z.array(z.string()), trendingDisabled: z.boolean().optional(), @@ -174,6 +181,12 @@ export const defaults: Schema = { subtitlesEnabled: true, trendingDisabled: false, trendingVideoDisabled: false, + + // deer + goLinksEnabled: true, + constellationEnabled: false, + directFetchRecords: false, + unfollowConfirm: false, } export function tryParse(rawData: string): Schema | undefined { diff --git a/src/state/preferences/constellation-enabled.tsx b/src/state/preferences/constellation-enabled.tsx new file mode 100644 index 000000000..6e2fb992c --- /dev/null +++ b/src/state/preferences/constellation-enabled.tsx @@ -0,0 +1,52 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['constellationEnabled'] +type SetContext = (v: persisted.Schema['constellationEnabled']) => void + +const stateContext = React.createContext( + persisted.defaults.constellationEnabled, +) +const setContext = React.createContext( + (_: persisted.Schema['constellationEnabled']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState( + persisted.get('constellationEnabled'), + ) + + const setStateWrapped = React.useCallback( + (constellationEnabled: persisted.Schema['constellationEnabled']) => { + setState(constellationEnabled) + persisted.write('constellationEnabled', constellationEnabled) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate( + 'constellationEnabled', + nextConstellationEnabled => { + setState(nextConstellationEnabled) + }, + ) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useConstellationEnabled() { + return React.useContext(stateContext) +} + +export function useSetConstellationEnabled() { + return React.useContext(setContext) +} diff --git a/src/state/preferences/direct-fetch-records.tsx b/src/state/preferences/direct-fetch-records.tsx new file mode 100644 index 000000000..72e515ea2 --- /dev/null +++ b/src/state/preferences/direct-fetch-records.tsx @@ -0,0 +1,47 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['directFetchRecords'] +type SetContext = (v: persisted.Schema['directFetchRecords']) => void + +const stateContext = React.createContext( + persisted.defaults.directFetchRecords, +) +const setContext = React.createContext( + (_: persisted.Schema['directFetchRecords']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('directFetchRecords')) + + const setStateWrapped = React.useCallback( + (directFetchRecords: persisted.Schema['directFetchRecords']) => { + setState(directFetchRecords) + persisted.write('directFetchRecords', directFetchRecords) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate('directFetchRecords', nextDirectFetchRecords => { + setState(nextDirectFetchRecords) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useDirectFetchRecords() { + return React.useContext(stateContext) +} + +export function useSetDirectFetchRecords() { + return React.useContext(setContext) +} diff --git a/src/state/preferences/go-links-enabled.tsx b/src/state/preferences/go-links-enabled.tsx new file mode 100644 index 000000000..05d26035c --- /dev/null +++ b/src/state/preferences/go-links-enabled.tsx @@ -0,0 +1,47 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['goLinksEnabled'] +type SetContext = (v: persisted.Schema['goLinksEnabled']) => void + +const stateContext = React.createContext( + persisted.defaults.goLinksEnabled, +) +const setContext = React.createContext( + (_: persisted.Schema['goLinksEnabled']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('goLinksEnabled')) + + const setStateWrapped = React.useCallback( + (goLinksEnabled: persisted.Schema['goLinksEnabled']) => { + setState(goLinksEnabled) + persisted.write('goLinksEnabled', goLinksEnabled) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate('goLinksEnabled', nextGoLinksEnabled => { + setState(nextGoLinksEnabled) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useGoLinksEnabled() { + return React.useContext(stateContext) +} + +export function useSetGoLinksEnabled() { + return React.useContext(setContext) +} diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index 8530a8d0c..132561dd8 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -1,9 +1,12 @@ -import React from 'react' +import type React from 'react' import {Provider as AltTextRequiredProvider} from './alt-text-required' import {Provider as AutoplayProvider} from './autoplay' +import {Provider as ConstellationProvider} from './constellation-enabled' +import {Provider as DirectFetchRecordsProvider} from './direct-fetch-records' import {Provider as DisableHapticsProvider} from './disable-haptics' 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 InAppBrowserProvider} from './in-app-browser' import {Provider as KawaiiProvider} from './kawaii' @@ -23,6 +26,7 @@ export { useExternalEmbedsPrefs, useSetExternalEmbedPref, } from './external-embeds-prefs' +export {useGoLinksEnabled, useSetGoLinksEnabled} from './go-links-enabled' export * from './hidden-posts' export {useLabelDefinitions} from './label-defs' export {useLanguagePrefs, useLanguagePrefsApi} from './languages' @@ -32,25 +36,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return ( - - - - - - - - - - {children} - - - - - - - - - + + + + + + + + + + + + + {children} + + + + + + + + + + + + ) diff --git a/src/state/queries/direct-fetch-record.ts b/src/state/queries/direct-fetch-record.ts new file mode 100644 index 000000000..3a801e076 --- /dev/null +++ b/src/state/queries/direct-fetch-record.ts @@ -0,0 +1,75 @@ +import {type AppBskyEmbedRecord, AppBskyFeedPost, AtUri} from '@atproto/api' +import {type ProfileViewBasic} from '@atproto/api/dist/client/types/app/bsky/actor/defs' +import {useQuery} from '@tanstack/react-query' + +import {retry} from '#/lib/async/retry' +import {STALE} from '#/state/queries' +import {useAgent} from '#/state/session' +import * as bsky from '#/types/bsky' + +const RQKEY_ROOT = 'direct-fetch-record' +export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] + +export function useDirectFetchRecord({ + uri, + enabled, +}: { + uri: string + enabled?: boolean +}) { + const agent = useAgent() + return useQuery({ + staleTime: STALE.HOURS.ONE, + queryKey: RQKEY(uri || ''), + async queryFn() { + const urip = new AtUri(uri) + + if (!urip.host.startsWith('did:')) { + const res = await agent.resolveHandle({ + handle: urip.host, + }) + urip.host = res.data.did + } + + try { + // TODO: parallel, series fetch sucks there isn't a dependency + const profile = (await agent.getProfile({actor: urip.host})).data + const {data} = await retry( + 2, + e => { + if (e.message.includes(`Could not locate record:`)) { + return false + } + return true + }, + () => + agent.api.com.atproto.repo.getRecord({ + repo: urip.host, + collection: 'app.bsky.feed.post', + rkey: urip.rkey, + }), + ) + if ( + data.value && + bsky.validate(data.value, AppBskyFeedPost.validateRecord) + ) { + const record = data.value + return { + $type: 'app.bsky.embed.record#viewRecord', + uri, + author: profile as ProfileViewBasic, + cid: '', + value: record, + indexedAt: record.createdAt, + } satisfies AppBskyEmbedRecord.ViewRecord + } else { + return undefined + } + } catch (e) { + console.error(e) + return undefined + } + }, + enabled: enabled && !!uri, + }) +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 0e9b1985c..e055f9a26 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -10,6 +10,9 @@ export type Device = { } trendingBetaEnabled: boolean devMode: boolean + + // deer + deerGateCache: string } export type Account = { diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index f788af1f8..00686d97c 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -1,10 +1,10 @@ import React from 'react' import { - StyleProp, + type StyleProp, StyleSheet, TouchableOpacity, View, - ViewStyle, + type ViewStyle, } from 'react-native' import { AppBskyEmbedExternal, @@ -12,10 +12,10 @@ import { AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, AppBskyEmbedVideo, - AppBskyFeedDefs, + type AppBskyFeedDefs, AppBskyFeedPost, moderatePost, - ModerationDecision, + type ModerationDecision, RichText as RichTextAPI, } from '@atproto/api' import {AtUri} from '@atproto/api' @@ -29,11 +29,14 @@ import {usePalette} from '#/lib/hooks/usePalette' import {InfoCircleIcon} from '#/lib/icons' import {makeProfileLink} from '#/lib/routes/links' import {s} from '#/lib/styles' +import {useDirectFetchRecords} from '#/state/preferences/direct-fetch-records' import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useDirectFetchRecord} from '#/state/queries/direct-fetch-record' import {precacheProfile} from '#/state/queries/profile' import {useResolveLinkQuery} from '#/state/queries/resolve-link' import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash' import {RichText} from '#/components/RichText' import {SubtleWebHover} from '#/components/SubtleWebHover' import * as bsky from '#/types/bsky' @@ -43,7 +46,7 @@ import {Link} from '../Link' import {PostMeta} from '../PostMeta' import {Text} from '../text/Text' import {PostEmbeds} from '.' -import {QuoteEmbedViewContext} from './types' +import {type QuoteEmbedViewContext} from './types' export function MaybeQuoteEmbed({ embed, @@ -58,9 +61,25 @@ export function MaybeQuoteEmbed({ allowNestedQuotes?: boolean viewContext?: QuoteEmbedViewContext }) { + const {_} = useLingui() const t = useTheme() const pal = usePalette('default') const {currentAccount} = useSession() + + const directFetchEnabled = useDirectFetchRecords() + const shouldDirectFetch = + (AppBskyEmbedRecord.isViewBlocked(embed.record) || + AppBskyEmbedRecord.isViewDetached(embed.record)) && + directFetchEnabled + + const directRecord = useDirectFetchRecord({ + uri: + AppBskyEmbedRecord.isViewBlocked(embed.record) || + AppBskyEmbedRecord.isViewDetached(embed.record) + ? embed.record.uri + : '', + enabled: shouldDirectFetch, + }) if ( AppBskyEmbedRecord.isViewRecord(embed.record) && AppBskyFeedPost.isRecord(embed.record.value) && @@ -76,12 +95,32 @@ export function MaybeQuoteEmbed({ /> ) } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { + const record = directRecord.data + if (record !== undefined) { + return ( + + + + ) + } + return ( - Blocked + {directFetchEnabled ? ( + Blocked... + ) : ( + Blocked + )} ) @@ -99,6 +138,25 @@ export function MaybeQuoteEmbed({ const isViewerOwner = currentAccount?.did ? embed.record.uri.includes(currentAccount.did) : false + + const record = directRecord.data + if (record !== undefined) { + return ( + + + + ) + } + return ( @@ -109,6 +167,7 @@ export function MaybeQuoteEmbed({ ) : ( Removed by author )} + {directFetchEnabled ? ... : undefined} ) @@ -122,12 +181,14 @@ function QuoteEmbedModerated({ style, allowNestedQuotes, viewContext, + visibilityLabel, }: { viewRecord: AppBskyEmbedRecord.ViewRecord onOpen?: () => void style?: StyleProp allowNestedQuotes?: boolean viewContext?: QuoteEmbedViewContext + visibilityLabel?: string }) { const moderationOpts = useModerationOpts() const postView = React.useMemo( @@ -146,6 +207,7 @@ function QuoteEmbedModerated({ style={style} allowNestedQuotes={allowNestedQuotes} viewContext={viewContext} + visibilityLabel={visibilityLabel} /> ) } @@ -156,6 +218,7 @@ export function QuoteEmbed({ onOpen, style, allowNestedQuotes, + visibilityLabel, }: { quote: AppBskyFeedDefs.PostView moderation?: ModerationDecision @@ -163,6 +226,7 @@ export function QuoteEmbed({ style?: StyleProp allowNestedQuotes?: boolean viewContext?: QuoteEmbedViewContext + visibilityLabel?: string }) { const t = useTheme() const queryClient = useQueryClient() @@ -240,6 +304,14 @@ export function QuoteEmbed({ title={itemTitle} onBeforePress={onBeforePress}> + {visibilityLabel !== undefined ? ( + + + + {visibilityLabel} + + + ) : undefined}