Bluesky app fork with some witchin' additions 💫

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.

Aviva Ruben da4c307b 261f4122

+606 -111
+9
src/Navigation.tsx
··· 100 100 import {AccountSettingsScreen} from './screens/Settings/AccountSettings' 101 101 import {AppPasswordsScreen} from './screens/Settings/AppPasswords' 102 102 import {ContentAndMediaSettingsScreen} from './screens/Settings/ContentAndMediaSettings' 103 + import {DeerSettingsScreen} from './screens/Settings/DeerSettings' 103 104 import {ExternalMediaPreferencesScreen} from './screens/Settings/ExternalMediaPreferences' 104 105 import {FollowingFeedPreferencesScreen} from './screens/Settings/FollowingFeedPreferences' 105 106 import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings' ··· 350 351 getComponent={() => AccessibilitySettingsScreen} 351 352 options={{ 352 353 title: title(msg`Accessibility Settings`), 354 + requireAuth: true, 355 + }} 356 + /> 357 + <Stack.Screen 358 + name="DeerSettings" 359 + getComponent={() => DeerSettingsScreen} 360 + options={{ 361 + title: title(msg`Deer Settings`), 353 362 requireAuth: true, 354 363 }} 355 364 />
+6 -1
src/components/Link.tsx
··· 16 16 } from '#/lib/strings/url-helpers' 17 17 import {isNative, isWeb} from '#/platform/detection' 18 18 import {useModalControls} from '#/state/modals' 19 + import {useGoLinksEnabled} from '#/state/preferences' 19 20 import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf' 20 21 import {Button, type ButtonProps} from '#/components/Button' 21 22 import {useInteractionState} from '#/components/hooks/useInteractionState' ··· 100 101 const {openModal, closeModal} = useModalControls() 101 102 const openLink = useOpenLink() 102 103 104 + const goLinksEnabled = useGoLinksEnabled() 105 + 103 106 const onPress = React.useCallback( 104 107 (e: GestureResponderEvent) => { 105 108 const exitEarlyIfFalse = outerOnPress?.(e) ··· 125 128 }) 126 129 } else { 127 130 if (isExternal) { 128 - openLink(href, overridePresentation, shouldProxy) 131 + // openLink(href, overridePresentation, shouldProxy) 132 + openLink(href, overridePresentation, goLinksEnabled && shouldProxy) 129 133 } else { 130 134 const shouldOpenInNewTab = shouldClickOpenNewTab(e) 131 135 ··· 169 173 navigation, 170 174 overridePresentation, 171 175 shouldProxy, 176 + goLinksEnabled, 172 177 ], 173 178 ) 174 179
+50 -81
src/lib/statsig/statsig.tsx
··· 1 1 import React from 'react' 2 2 import {Platform} from 'react-native' 3 - import {AppState, AppStateStatus} from 'react-native' 4 - import {Statsig, StatsigProvider} from 'statsig-react-native-expo' 3 + import {AppState, type AppStateStatus} from 'react-native' 4 + import {Statsig} from 'statsig-react-native-expo' 5 5 6 - import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info' 6 + import {BUNDLE_DATE, BUNDLE_IDENTIFIER} from '#/lib/app-info' 7 7 import {logger} from '#/logger' 8 - import {MetricEvents} from '#/logger/metrics' 8 + import {type MetricEvents} from '#/logger/metrics' 9 9 import {isWeb} from '#/platform/detection' 10 10 import * as persisted from '#/state/persisted' 11 - import {useSession} from '../../state/session' 11 + import {device} from '#/storage' 12 12 import {timeout} from '../async/timeout' 13 - import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback' 14 - import {Gate} from './gates' 13 + // import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback' 14 + import {type Gate} from './gates' 15 15 16 - const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV' 16 + // const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV' 17 17 18 18 export const initPromise = initialize() 19 19 ··· 44 44 45 45 export type {MetricEvents as LogEvents} 46 46 47 - function createStatsigOptions(prefetchUsers: StatsigUser[]) { 48 - return { 49 - environment: { 50 - tier: 51 - process.env.NODE_ENV === 'development' 52 - ? 'development' 53 - : IS_TESTFLIGHT 54 - ? 'staging' 55 - : 'production', 56 - }, 57 - // Don't block on waiting for network. The fetched config will kick in on next load. 58 - // This ensures the UI is always consistent and doesn't update mid-session. 59 - // Note this makes cold load (no local storage) and private mode return `false` for all gates. 60 - initTimeoutMs: 1, 61 - // Get fresh flags for other accounts as well, if any. 62 - prefetchUsers, 63 - api: 'https://events.bsky.app/v2', 64 - } 65 - } 47 + // function createStatsigOptions(prefetchUsers: StatsigUser[]) { 48 + // return { 49 + // environment: { 50 + // tier: 51 + // process.env.NODE_ENV === 'development' 52 + // ? 'development' 53 + // : IS_TESTFLIGHT 54 + // ? 'staging' 55 + // : 'production', 56 + // }, 57 + // // Don't block on waiting for network. The fetched config will kick in on next load. 58 + // // This ensures the UI is always consistent and doesn't update mid-session. 59 + // // Note this makes cold load (no local storage) and private mode return `false` for all gates. 60 + // initTimeoutMs: 1, 61 + // // Get fresh flags for other accounts as well, if any. 62 + // prefetchUsers, 63 + // api: 'https://events.bsky.app/v2', 64 + // } 65 + // } 66 66 67 67 type FlatJSONRecord = Record< 68 68 string, ··· 152 152 dangerouslyDisableExposureLogging?: boolean 153 153 } 154 154 155 + export function useGatesCache(): Map<string, boolean> { 156 + const cache = React.useContext(GateCache) 157 + if (!cache) { 158 + throw Error('useGate() cannot be called outside StatsigProvider.') 159 + } 160 + return cache 161 + } 162 + 163 + function writeDeerGateCache(cache: Map<string, boolean>) { 164 + device.set(['deerGateCache'], JSON.stringify(Object.fromEntries(cache))) 165 + } 166 + 167 + export function resetDeerGateCache() { 168 + writeDeerGateCache(new Map()) 169 + } 170 + 155 171 export function useGate(): (gateName: Gate, options?: GateOptions) => boolean { 156 172 const cache = React.useContext(GateCache) 157 173 if (!cache) { ··· 172 188 } 173 189 } 174 190 cache.set(gateName, value) 191 + writeDeerGateCache(cache) 175 192 return value 176 193 }, 177 194 [cache], ··· 195 212 const dangerousSetGate = React.useCallback( 196 213 (gateName: Gate, value: boolean) => { 197 214 cache.set(gateName, value) 215 + writeDeerGateCache(cache) 198 216 }, 199 217 [cache], 200 218 ) ··· 263 281 } 264 282 265 283 export function initialize() { 266 - return Statsig.initialize(SDK_KEY, null, createStatsigOptions([])) 284 + // return Statsig.initialize(SDK_KEY, null, createStatsigOptions([])) 285 + return new Promise(() => {}) 267 286 } 268 287 269 288 export function Provider({children}: {children: React.ReactNode}) { 270 - const {currentAccount, accounts} = useSession() 271 - const did = currentAccount?.did 272 - const currentStatsigUser = React.useMemo(() => toStatsigUser(did), [did]) 273 - 274 - const otherDidsConcatenated = accounts 275 - .map(account => account.did) 276 - .filter(accountDid => accountDid !== did) 277 - .join(' ') // We're only interested in DID changes. 278 - const otherStatsigUsers = React.useMemo( 279 - () => otherDidsConcatenated.split(' ').map(toStatsigUser), 280 - [otherDidsConcatenated], 281 - ) 282 - const statsigOptions = React.useMemo( 283 - () => createStatsigOptions(otherStatsigUsers), 284 - [otherStatsigUsers], 289 + const gateCache = new Map<string, boolean>( 290 + Object.entries(JSON.parse(device.get(['deerGateCache']) ?? '{}')), 285 291 ) 286 292 287 - // Have our own cache in front of Statsig. 288 - // This ensures the results remain stable until the active DID changes. 289 - const [gateCache, setGateCache] = React.useState(() => new Map()) 290 - const [prevDid, setPrevDid] = React.useState(did) 291 - if (did !== prevDid) { 292 - setPrevDid(did) 293 - setGateCache(new Map()) 294 - } 295 - 296 - // Periodically poll Statsig to get the current rule evaluations for all stored accounts. 297 - // These changes are prefetched and stored, but don't get applied until the active DID changes. 298 - // This ensures that when you switch an account, it already has fresh results by then. 299 - const handleIntervalTick = useNonReactiveCallback(() => { 300 - if (Statsig.initializeCalled()) { 301 - // Note: Only first five will be taken into account by Statsig. 302 - Statsig.prefetchUsers([currentStatsigUser, ...otherStatsigUsers]) 303 - } 304 - }) 305 - React.useEffect(() => { 306 - const id = setInterval(handleIntervalTick, 60e3 /* 1 min */) 307 - return () => clearInterval(id) 308 - }, [handleIntervalTick]) 309 - 310 - return ( 311 - <GateCache.Provider value={gateCache}> 312 - <StatsigProvider 313 - key={did} 314 - sdkKey={SDK_KEY} 315 - mountKey={currentStatsigUser.userID} 316 - user={currentStatsigUser} 317 - // This isn't really blocking due to short initTimeoutMs above. 318 - // However, it ensures `isLoading` is always `false`. 319 - waitForInitialization={true} 320 - options={statsigOptions}> 321 - {children} 322 - </StatsigProvider> 323 - </GateCache.Provider> 324 - ) 293 + return <GateCache.Provider value={gateCache}>{children}</GateCache.Provider> 325 294 }
+1
src/routes.ts
··· 40 40 PreferencesThreads: '/settings/threads', 41 41 PreferencesExternalEmbeds: '/settings/external-embeds', 42 42 AccessibilitySettings: '/settings/accessibility', 43 + DeerSettings: '/settings/deer', 43 44 AppearanceSettings: '/settings/appearance', 44 45 SavedFeeds: '/settings/saved-feeds', 45 46 // new settings
+1 -3
src/screens/Settings/AboutSettings.tsx
··· 1 - import {useMemo} from 'react' 2 1 import {Platform} from 'react-native' 3 2 import {setStringAsync} from 'expo-clipboard' 4 3 import * as FileSystem from 'expo-file-system' ··· 7 6 import {useLingui} from '@lingui/react' 8 7 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 9 8 import {useMutation} from '@tanstack/react-query' 10 - import {Statsig} from 'statsig-react-native-expo' 11 9 12 10 import {appVersion, BUNDLE_DATE, bundleInfo} from '#/lib/app-info' 13 11 import {STATUS_PAGE_URL} from '#/lib/constants' ··· 29 27 export function AboutSettingsScreen({}: Props) { 30 28 const {_, i18n} = useLingui() 31 29 const [devModeEnabled, setDevModeEnabled] = useDevModeEnabled() 32 - const stableID = useMemo(() => Statsig.getStableID(), []) 30 + const stableID = `DEER_SOCIAL_OOPS` 33 31 34 32 const {mutate: onClearImageCache, isPending: isClearingImageCache} = 35 33 useMutation({
+181
src/screens/Settings/DeerSettings.tsx
··· 1 + import {useState} from 'react' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + import {type NativeStackScreenProps} from '@react-navigation/native-stack' 5 + 6 + import {type CommonNavigatorParams} from '#/lib/routes/types' 7 + import {type Gate} from '#/lib/statsig/gates' 8 + import { 9 + resetDeerGateCache, 10 + useDangerousSetGate, 11 + useGatesCache, 12 + } from '#/lib/statsig/statsig' 13 + import {useGoLinksEnabled, useSetGoLinksEnabled} from '#/state/preferences' 14 + import { 15 + useConstellationEnabled, 16 + useSetConstellationEnabled, 17 + } from '#/state/preferences/constellation-enabled' 18 + import { 19 + useDirectFetchRecords, 20 + useSetDirectFetchRecords, 21 + } from '#/state/preferences/direct-fetch-records' 22 + import * as SettingsList from '#/screens/Settings/components/SettingsList' 23 + import {atoms as a} from '#/alf' 24 + import {Admonition} from '#/components/Admonition' 25 + import * as Toggle from '#/components/forms/Toggle' 26 + import {Atom_Stroke2_Corner0_Rounded as DeerIcon} from '#/components/icons/Atom' 27 + import {Eye_Stroke2_Corner0_Rounded as VisibilityIcon} from '#/components/icons/Eye' 28 + import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller' 29 + import * as Layout from '#/components/Layout' 30 + 31 + type Props = NativeStackScreenProps<CommonNavigatorParams> 32 + 33 + export function DeerSettingsScreen({}: Props) { 34 + const {_} = useLingui() 35 + 36 + const goLinksEnabled = useGoLinksEnabled() 37 + const setGoLinksEnabled = useSetGoLinksEnabled() 38 + 39 + const constellationEnabled = useConstellationEnabled() 40 + const setConstellationEnabled = useSetConstellationEnabled() 41 + 42 + const directFetchRecords = useDirectFetchRecords() 43 + const setDirectFetchRecords = useSetDirectFetchRecords() 44 + 45 + const [gates, setGatesView] = useState(Object.fromEntries(useGatesCache())) 46 + const dangerousSetGate = useDangerousSetGate() 47 + const setGate = (gate: Gate, value: boolean) => { 48 + dangerousSetGate(gate, value) 49 + setGatesView({ 50 + ...gates, 51 + [gate]: value, 52 + }) 53 + } 54 + 55 + return ( 56 + <Layout.Screen> 57 + <Layout.Header.Outer> 58 + <Layout.Header.BackButton /> 59 + <Layout.Header.Content> 60 + <Layout.Header.TitleText> 61 + <Trans>Deer</Trans> 62 + </Layout.Header.TitleText> 63 + </Layout.Header.Content> 64 + <Layout.Header.Slot /> 65 + </Layout.Header.Outer> 66 + <Layout.Content> 67 + <SettingsList.Container> 68 + <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 69 + <SettingsList.ItemIcon icon={DeerIcon} /> 70 + <SettingsList.ItemText> 71 + <Trans>Redirects</Trans> 72 + </SettingsList.ItemText> 73 + <Toggle.Item 74 + name="use_go_links" 75 + label={_(msg`Redirect through go.bsky.app`)} 76 + value={goLinksEnabled ?? false} 77 + onChange={value => setGoLinksEnabled(value)} 78 + style={[a.w_full]}> 79 + <Toggle.LabelText style={[a.flex_1]}> 80 + <Trans>Redirect through go.bsky.app</Trans> 81 + </Toggle.LabelText> 82 + <Toggle.Platform /> 83 + </Toggle.Item> 84 + </SettingsList.Group> 85 + 86 + <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 87 + <SettingsList.ItemIcon icon={VisibilityIcon} /> 88 + <SettingsList.ItemText> 89 + <Trans>Visibility</Trans> 90 + </SettingsList.ItemText> 91 + <Toggle.Item 92 + name="direct_fetch_records" 93 + label={_( 94 + msg`Fetch records directly from PDS to see through quote blocks`, 95 + )} 96 + value={directFetchRecords} 97 + onChange={value => setDirectFetchRecords(value)} 98 + style={[a.w_full]}> 99 + <Toggle.LabelText style={[a.flex_1]}> 100 + <Trans> 101 + Fetch records directly from PDS to see through quote blocks 102 + </Trans> 103 + </Toggle.LabelText> 104 + <Toggle.Platform /> 105 + </Toggle.Item> 106 + <Toggle.Item 107 + name="constellation_fallback" 108 + label={_( 109 + msg`Fall back to constellation api to find blocked replies`, 110 + )} 111 + disabled={true} 112 + value={constellationEnabled} 113 + onChange={value => setConstellationEnabled(value)} 114 + style={[a.w_full]}> 115 + <Toggle.LabelText style={[a.flex_1]}> 116 + <Trans> 117 + TODO: Fall back to constellation api to find blocked replies 118 + </Trans> 119 + </Toggle.LabelText> 120 + <Toggle.Platform /> 121 + </Toggle.Item> 122 + </SettingsList.Group> 123 + 124 + <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 125 + <SettingsList.ItemIcon icon={PaintRollerIcon} /> 126 + <SettingsList.ItemText> 127 + <Trans>Tweaks</Trans> 128 + </SettingsList.ItemText> 129 + <Toggle.Item 130 + name="under construction" 131 + label={_(msg`🚧 under construction...`)} 132 + value={false} 133 + onChange={() => {}} 134 + disabled={true} 135 + style={[a.w_full]}> 136 + <Toggle.LabelText style={[a.flex_1]}> 137 + <Trans>🚧 under construction...</Trans> 138 + </Toggle.LabelText> 139 + <Toggle.Platform /> 140 + </Toggle.Item> 141 + </SettingsList.Group> 142 + 143 + <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 144 + <SettingsList.ItemIcon icon={PaintRollerIcon} /> 145 + <SettingsList.ItemText> 146 + <Trans>Gates</Trans> 147 + </SettingsList.ItemText> 148 + {Object.entries(gates).map(([gate, status]) => ( 149 + <Toggle.Item 150 + key={gate} 151 + name={gate} 152 + label={gate} 153 + value={status} 154 + onChange={value => setGate(gate as Gate, value)} 155 + style={[a.w_full]}> 156 + <Toggle.LabelText style={[a.flex_1]}>{gate}</Toggle.LabelText> 157 + <Toggle.Platform /> 158 + </Toggle.Item> 159 + ))} 160 + <SettingsList.BadgeButton 161 + label={_(msg`Reset gates`)} 162 + onPress={() => { 163 + resetDeerGateCache() 164 + setGatesView({}) 165 + }} 166 + /> 167 + </SettingsList.Group> 168 + 169 + <SettingsList.Item> 170 + <Admonition type="warning" style={[a.flex_1]}> 171 + <Trans> 172 + These settings might summon nasel demons! Restart the app after 173 + changing if anything breaks. 174 + </Trans> 175 + </Admonition> 176 + </SettingsList.Item> 177 + </SettingsList.Container> 178 + </Layout.Content> 179 + </Layout.Screen> 180 + ) 181 + }
+7
src/screens/Settings/Settings.tsx
··· 34 34 import {useDialogControl} from '#/components/Dialog' 35 35 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' 36 36 import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' 37 + import {Atom_Stroke2_Corner0_Rounded as DeerIcon} from '#/components/icons/Atom' 37 38 import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo' 38 39 import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron' 39 40 import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion' ··· 192 193 <SettingsList.ItemIcon icon={PaintRollerIcon} /> 193 194 <SettingsList.ItemText> 194 195 <Trans>Appearance</Trans> 196 + </SettingsList.ItemText> 197 + </SettingsList.LinkItem> 198 + <SettingsList.LinkItem to="/settings/deer" label={_(msg`Deer`)}> 199 + <SettingsList.ItemIcon icon={DeerIcon} /> 200 + <SettingsList.ItemText> 201 + <Trans>Deer</Trans> 195 202 </SettingsList.ItemText> 196 203 </SettingsList.LinkItem> 197 204 <SettingsList.LinkItem
+13
src/state/persisted/schema.ts
··· 123 123 kawaii: z.boolean().optional(), 124 124 hasCheckedForStarterPack: z.boolean().optional(), 125 125 subtitlesEnabled: z.boolean().optional(), 126 + 127 + // deer 128 + goLinksEnabled: z.boolean().optional(), 129 + constellationEnabled: z.boolean().optional(), 130 + directFetchRecords: z.boolean().optional(), 131 + unfollowConfirm: z.boolean().optional(), 132 + 126 133 /** @deprecated */ 127 134 mutedThreads: z.array(z.string()), 128 135 trendingDisabled: z.boolean().optional(), ··· 174 181 subtitlesEnabled: true, 175 182 trendingDisabled: false, 176 183 trendingVideoDisabled: false, 184 + 185 + // deer 186 + goLinksEnabled: true, 187 + constellationEnabled: false, 188 + directFetchRecords: false, 189 + unfollowConfirm: false, 177 190 } 178 191 179 192 export function tryParse(rawData: string): Schema | undefined {
+52
src/state/preferences/constellation-enabled.tsx
··· 1 + import React from 'react' 2 + 3 + import * as persisted from '#/state/persisted' 4 + 5 + type StateContext = persisted.Schema['constellationEnabled'] 6 + type SetContext = (v: persisted.Schema['constellationEnabled']) => void 7 + 8 + const stateContext = React.createContext<StateContext>( 9 + persisted.defaults.constellationEnabled, 10 + ) 11 + const setContext = React.createContext<SetContext>( 12 + (_: persisted.Schema['constellationEnabled']) => {}, 13 + ) 14 + 15 + export function Provider({children}: React.PropsWithChildren<{}>) { 16 + const [state, setState] = React.useState( 17 + persisted.get('constellationEnabled'), 18 + ) 19 + 20 + const setStateWrapped = React.useCallback( 21 + (constellationEnabled: persisted.Schema['constellationEnabled']) => { 22 + setState(constellationEnabled) 23 + persisted.write('constellationEnabled', constellationEnabled) 24 + }, 25 + [setState], 26 + ) 27 + 28 + React.useEffect(() => { 29 + return persisted.onUpdate( 30 + 'constellationEnabled', 31 + nextConstellationEnabled => { 32 + setState(nextConstellationEnabled) 33 + }, 34 + ) 35 + }, [setStateWrapped]) 36 + 37 + return ( 38 + <stateContext.Provider value={state}> 39 + <setContext.Provider value={setStateWrapped}> 40 + {children} 41 + </setContext.Provider> 42 + </stateContext.Provider> 43 + ) 44 + } 45 + 46 + export function useConstellationEnabled() { 47 + return React.useContext(stateContext) 48 + } 49 + 50 + export function useSetConstellationEnabled() { 51 + return React.useContext(setContext) 52 + }
+47
src/state/preferences/direct-fetch-records.tsx
··· 1 + import React from 'react' 2 + 3 + import * as persisted from '#/state/persisted' 4 + 5 + type StateContext = persisted.Schema['directFetchRecords'] 6 + type SetContext = (v: persisted.Schema['directFetchRecords']) => void 7 + 8 + const stateContext = React.createContext<StateContext>( 9 + persisted.defaults.directFetchRecords, 10 + ) 11 + const setContext = React.createContext<SetContext>( 12 + (_: persisted.Schema['directFetchRecords']) => {}, 13 + ) 14 + 15 + export function Provider({children}: React.PropsWithChildren<{}>) { 16 + const [state, setState] = React.useState(persisted.get('directFetchRecords')) 17 + 18 + const setStateWrapped = React.useCallback( 19 + (directFetchRecords: persisted.Schema['directFetchRecords']) => { 20 + setState(directFetchRecords) 21 + persisted.write('directFetchRecords', directFetchRecords) 22 + }, 23 + [setState], 24 + ) 25 + 26 + React.useEffect(() => { 27 + return persisted.onUpdate('directFetchRecords', nextDirectFetchRecords => { 28 + setState(nextDirectFetchRecords) 29 + }) 30 + }, [setStateWrapped]) 31 + 32 + return ( 33 + <stateContext.Provider value={state}> 34 + <setContext.Provider value={setStateWrapped}> 35 + {children} 36 + </setContext.Provider> 37 + </stateContext.Provider> 38 + ) 39 + } 40 + 41 + export function useDirectFetchRecords() { 42 + return React.useContext(stateContext) 43 + } 44 + 45 + export function useSetDirectFetchRecords() { 46 + return React.useContext(setContext) 47 + }
+30 -20
src/state/preferences/index.tsx
··· 1 - import React from 'react' 1 + import type React from 'react' 2 2 3 3 import {Provider as AltTextRequiredProvider} from './alt-text-required' 4 4 import {Provider as AutoplayProvider} from './autoplay' 5 + import {Provider as ConstellationProvider} from './constellation-enabled' 6 + import {Provider as DirectFetchRecordsProvider} from './direct-fetch-records' 5 7 import {Provider as DisableHapticsProvider} from './disable-haptics' 6 8 import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs' 9 + import {Provider as GoLinksProvider} from './go-links-enabled' 7 10 import {Provider as HiddenPostsProvider} from './hidden-posts' 8 11 import {Provider as InAppBrowserProvider} from './in-app-browser' 9 12 import {Provider as KawaiiProvider} from './kawaii' ··· 23 26 useExternalEmbedsPrefs, 24 27 useSetExternalEmbedPref, 25 28 } from './external-embeds-prefs' 29 + export {useGoLinksEnabled, useSetGoLinksEnabled} from './go-links-enabled' 26 30 export * from './hidden-posts' 27 31 export {useLabelDefinitions} from './label-defs' 28 32 export {useLanguagePrefs, useLanguagePrefsApi} from './languages' ··· 32 36 return ( 33 37 <LanguagesProvider> 34 38 <AltTextRequiredProvider> 35 - <LargeAltBadgeProvider> 36 - <ExternalEmbedsProvider> 37 - <HiddenPostsProvider> 38 - <InAppBrowserProvider> 39 - <DisableHapticsProvider> 40 - <AutoplayProvider> 41 - <UsedStarterPacksProvider> 42 - <SubtitlesProvider> 43 - <TrendingSettingsProvider> 44 - <KawaiiProvider>{children}</KawaiiProvider> 45 - </TrendingSettingsProvider> 46 - </SubtitlesProvider> 47 - </UsedStarterPacksProvider> 48 - </AutoplayProvider> 49 - </DisableHapticsProvider> 50 - </InAppBrowserProvider> 51 - </HiddenPostsProvider> 52 - </ExternalEmbedsProvider> 53 - </LargeAltBadgeProvider> 39 + <GoLinksProvider> 40 + <DirectFetchRecordsProvider> 41 + <ConstellationProvider> 42 + <LargeAltBadgeProvider> 43 + <ExternalEmbedsProvider> 44 + <HiddenPostsProvider> 45 + <InAppBrowserProvider> 46 + <DisableHapticsProvider> 47 + <AutoplayProvider> 48 + <UsedStarterPacksProvider> 49 + <SubtitlesProvider> 50 + <TrendingSettingsProvider> 51 + <KawaiiProvider>{children}</KawaiiProvider> 52 + </TrendingSettingsProvider> 53 + </SubtitlesProvider> 54 + </UsedStarterPacksProvider> 55 + </AutoplayProvider> 56 + </DisableHapticsProvider> 57 + </InAppBrowserProvider> 58 + </HiddenPostsProvider> 59 + </ExternalEmbedsProvider> 60 + </LargeAltBadgeProvider> 61 + </ConstellationProvider> 62 + </DirectFetchRecordsProvider> 63 + </GoLinksProvider> 54 64 </AltTextRequiredProvider> 55 65 </LanguagesProvider> 56 66 )
+75
src/state/queries/direct-fetch-record.ts
··· 1 + import {type AppBskyEmbedRecord, AppBskyFeedPost, AtUri} from '@atproto/api' 2 + import {type ProfileViewBasic} from '@atproto/api/dist/client/types/app/bsky/actor/defs' 3 + import {useQuery} from '@tanstack/react-query' 4 + 5 + import {retry} from '#/lib/async/retry' 6 + import {STALE} from '#/state/queries' 7 + import {useAgent} from '#/state/session' 8 + import * as bsky from '#/types/bsky' 9 + 10 + const RQKEY_ROOT = 'direct-fetch-record' 11 + export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] 12 + 13 + export function useDirectFetchRecord({ 14 + uri, 15 + enabled, 16 + }: { 17 + uri: string 18 + enabled?: boolean 19 + }) { 20 + const agent = useAgent() 21 + return useQuery<AppBskyEmbedRecord.ViewRecord | undefined>({ 22 + staleTime: STALE.HOURS.ONE, 23 + queryKey: RQKEY(uri || ''), 24 + async queryFn() { 25 + const urip = new AtUri(uri) 26 + 27 + if (!urip.host.startsWith('did:')) { 28 + const res = await agent.resolveHandle({ 29 + handle: urip.host, 30 + }) 31 + urip.host = res.data.did 32 + } 33 + 34 + try { 35 + // TODO: parallel, series fetch sucks there isn't a dependency 36 + const profile = (await agent.getProfile({actor: urip.host})).data 37 + const {data} = await retry( 38 + 2, 39 + e => { 40 + if (e.message.includes(`Could not locate record:`)) { 41 + return false 42 + } 43 + return true 44 + }, 45 + () => 46 + agent.api.com.atproto.repo.getRecord({ 47 + repo: urip.host, 48 + collection: 'app.bsky.feed.post', 49 + rkey: urip.rkey, 50 + }), 51 + ) 52 + if ( 53 + data.value && 54 + bsky.validate(data.value, AppBskyFeedPost.validateRecord) 55 + ) { 56 + const record = data.value 57 + return { 58 + $type: 'app.bsky.embed.record#viewRecord', 59 + uri, 60 + author: profile as ProfileViewBasic, 61 + cid: '', 62 + value: record, 63 + indexedAt: record.createdAt, 64 + } satisfies AppBskyEmbedRecord.ViewRecord 65 + } else { 66 + return undefined 67 + } 68 + } catch (e) { 69 + console.error(e) 70 + return undefined 71 + } 72 + }, 73 + enabled: enabled && !!uri, 74 + }) 75 + }
+3
src/storage/schema.ts
··· 10 10 } 11 11 trendingBetaEnabled: boolean 12 12 devMode: boolean 13 + 14 + // deer 15 + deerGateCache: string 13 16 } 14 17 15 18 export type Account = {
+84 -6
src/view/com/util/post-embeds/QuoteEmbed.tsx
··· 1 1 import React from 'react' 2 2 import { 3 - StyleProp, 3 + type StyleProp, 4 4 StyleSheet, 5 5 TouchableOpacity, 6 6 View, 7 - ViewStyle, 7 + type ViewStyle, 8 8 } from 'react-native' 9 9 import { 10 10 AppBskyEmbedExternal, ··· 12 12 AppBskyEmbedRecord, 13 13 AppBskyEmbedRecordWithMedia, 14 14 AppBskyEmbedVideo, 15 - AppBskyFeedDefs, 15 + type AppBskyFeedDefs, 16 16 AppBskyFeedPost, 17 17 moderatePost, 18 - ModerationDecision, 18 + type ModerationDecision, 19 19 RichText as RichTextAPI, 20 20 } from '@atproto/api' 21 21 import {AtUri} from '@atproto/api' ··· 29 29 import {InfoCircleIcon} from '#/lib/icons' 30 30 import {makeProfileLink} from '#/lib/routes/links' 31 31 import {s} from '#/lib/styles' 32 + import {useDirectFetchRecords} from '#/state/preferences/direct-fetch-records' 32 33 import {useModerationOpts} from '#/state/preferences/moderation-opts' 34 + import {useDirectFetchRecord} from '#/state/queries/direct-fetch-record' 33 35 import {precacheProfile} from '#/state/queries/profile' 34 36 import {useResolveLinkQuery} from '#/state/queries/resolve-link' 35 37 import {useSession} from '#/state/session' 36 38 import {atoms as a, useTheme} from '#/alf' 39 + import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash' 37 40 import {RichText} from '#/components/RichText' 38 41 import {SubtleWebHover} from '#/components/SubtleWebHover' 39 42 import * as bsky from '#/types/bsky' ··· 43 46 import {PostMeta} from '../PostMeta' 44 47 import {Text} from '../text/Text' 45 48 import {PostEmbeds} from '.' 46 - import {QuoteEmbedViewContext} from './types' 49 + import {type QuoteEmbedViewContext} from './types' 47 50 48 51 export function MaybeQuoteEmbed({ 49 52 embed, ··· 58 61 allowNestedQuotes?: boolean 59 62 viewContext?: QuoteEmbedViewContext 60 63 }) { 64 + const {_} = useLingui() 61 65 const t = useTheme() 62 66 const pal = usePalette('default') 63 67 const {currentAccount} = useSession() 68 + 69 + const directFetchEnabled = useDirectFetchRecords() 70 + const shouldDirectFetch = 71 + (AppBskyEmbedRecord.isViewBlocked(embed.record) || 72 + AppBskyEmbedRecord.isViewDetached(embed.record)) && 73 + directFetchEnabled 74 + 75 + const directRecord = useDirectFetchRecord({ 76 + uri: 77 + AppBskyEmbedRecord.isViewBlocked(embed.record) || 78 + AppBskyEmbedRecord.isViewDetached(embed.record) 79 + ? embed.record.uri 80 + : '', 81 + enabled: shouldDirectFetch, 82 + }) 64 83 if ( 65 84 AppBskyEmbedRecord.isViewRecord(embed.record) && 66 85 AppBskyFeedPost.isRecord(embed.record.value) && ··· 76 95 /> 77 96 ) 78 97 } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { 98 + const record = directRecord.data 99 + if (record !== undefined) { 100 + return ( 101 + <View> 102 + <QuoteEmbedModerated 103 + viewRecord={record} 104 + onOpen={onOpen} 105 + style={style} 106 + allowNestedQuotes={allowNestedQuotes} 107 + viewContext={viewContext} 108 + visibilityLabel={_(msg`Blocked`)} 109 + /> 110 + </View> 111 + ) 112 + } 113 + 79 114 return ( 80 115 <View 81 116 style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> 82 117 <InfoCircleIcon size={18} style={pal.text} /> 83 118 <Text type="lg" style={pal.text}> 84 - <Trans>Blocked</Trans> 119 + {directFetchEnabled ? ( 120 + <Trans>Blocked...</Trans> 121 + ) : ( 122 + <Trans>Blocked</Trans> 123 + )} 85 124 </Text> 86 125 </View> 87 126 ) ··· 99 138 const isViewerOwner = currentAccount?.did 100 139 ? embed.record.uri.includes(currentAccount.did) 101 140 : false 141 + 142 + const record = directRecord.data 143 + if (record !== undefined) { 144 + return ( 145 + <View> 146 + <QuoteEmbedModerated 147 + viewRecord={record} 148 + onOpen={onOpen} 149 + style={style} 150 + allowNestedQuotes={allowNestedQuotes} 151 + viewContext={viewContext} 152 + visibilityLabel={ 153 + isViewerOwner ? _(`Removed by you`) : _(msg`Removed by author`) 154 + } 155 + /> 156 + </View> 157 + ) 158 + } 159 + 102 160 return ( 103 161 <View 104 162 style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> ··· 109 167 ) : ( 110 168 <Trans>Removed by author</Trans> 111 169 )} 170 + {directFetchEnabled ? <Trans>...</Trans> : undefined} 112 171 </Text> 113 172 </View> 114 173 ) ··· 122 181 style, 123 182 allowNestedQuotes, 124 183 viewContext, 184 + visibilityLabel, 125 185 }: { 126 186 viewRecord: AppBskyEmbedRecord.ViewRecord 127 187 onOpen?: () => void 128 188 style?: StyleProp<ViewStyle> 129 189 allowNestedQuotes?: boolean 130 190 viewContext?: QuoteEmbedViewContext 191 + visibilityLabel?: string 131 192 }) { 132 193 const moderationOpts = useModerationOpts() 133 194 const postView = React.useMemo( ··· 146 207 style={style} 147 208 allowNestedQuotes={allowNestedQuotes} 148 209 viewContext={viewContext} 210 + visibilityLabel={visibilityLabel} 149 211 /> 150 212 ) 151 213 } ··· 156 218 onOpen, 157 219 style, 158 220 allowNestedQuotes, 221 + visibilityLabel, 159 222 }: { 160 223 quote: AppBskyFeedDefs.PostView 161 224 moderation?: ModerationDecision ··· 163 226 style?: StyleProp<ViewStyle> 164 227 allowNestedQuotes?: boolean 165 228 viewContext?: QuoteEmbedViewContext 229 + visibilityLabel?: string 166 230 }) { 167 231 const t = useTheme() 168 232 const queryClient = useQueryClient() ··· 240 304 title={itemTitle} 241 305 onBeforePress={onBeforePress}> 242 306 <View pointerEvents="none"> 307 + {visibilityLabel !== undefined ? ( 308 + <View style={[styles.blockHeader, t.atoms.border_contrast_low]}> 309 + <EyeSlashIcon size="sm" style={pal.text} /> 310 + <Text type="lg" style={pal.text}> 311 + {visibilityLabel} 312 + </Text> 313 + </View> 314 + ) : undefined} 243 315 <PostMeta 244 316 author={quote.author} 245 317 moderation={moderation} ··· 333 405 }, 334 406 alert: { 335 407 marginBottom: 6, 408 + }, 409 + blockHeader: { 410 + flexDirection: 'row', 411 + alignItems: 'center', 412 + gap: 4, 413 + marginBottom: 8, 336 414 }, 337 415 })