An ATproto social media client -- with an independent Appview.

feat: settings screen (#64)

* 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.

* fix: deconflict cherry-pick

* refactor: we aren't deer

thank you aviva for the work you've done :)

* refactor: label fixme

* refactor: comment out file

for typecheck purposes

---------

Co-authored-by: Aviva Ruben <aviva@rubenfamily.com>

authored by serenity Aviva Ruben and committed by GitHub be216453 5e03a44b

Changed files
+925 -37
src
+22 -13
src/Navigation.tsx
··· 89 89 import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers' 90 90 import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows' 91 91 import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' 92 - import {ProfileSearchScreen} from '#/screens/Profile/ProfileSearch' 93 92 import {ProfileListScreen} from '#/screens/ProfileList' 94 93 import {SavedFeeds} from '#/screens/SavedFeeds' 95 94 import {SearchScreen} from '#/screens/Search' 96 - import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings' 97 - import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings' 98 - import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings' 99 95 import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacySettings' 100 96 import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' 101 97 import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings' 102 - import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords' 103 - import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings' 104 - import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences' 105 - import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences' 98 + import {ExperimentalSettingsScreen} from '#/screens/Settings/ExperimentalSettings' 106 99 import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings' 107 - import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings' 108 100 import {LegacyNotificationSettingsScreen} from '#/screens/Settings/LegacyNotificationSettings' 109 101 import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings' 110 102 import {ActivityNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ActivityNotificationSettings' ··· 117 109 import {ReplyNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/ReplyNotificationSettings' 118 110 import {RepostNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostNotificationSettings' 119 111 import {RepostsOnRepostsNotificationSettingsScreen} from '#/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings' 120 - import {PrivacyAndSecuritySettingsScreen} from '#/screens/Settings/PrivacyAndSecuritySettings' 121 - import {SettingsScreen} from '#/screens/Settings/Settings' 122 - import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences' 123 112 import { 124 113 StarterPackScreen, 125 114 StarterPackScreenShort, 126 115 } from '#/screens/StarterPack/StarterPackScreen' 127 116 import {Wizard} from '#/screens/StarterPack/Wizard' 128 - import TopicScreen from '#/screens/Topic' 129 117 import {VideoFeed} from '#/screens/VideoFeed' 130 118 import {type Theme, useTheme} from '#/alf' 131 119 import { ··· 136 124 import {Referrer} from '../modules/expo-bluesky-swiss-army' 137 125 import {useAccountSwitcher} from './lib/hooks/useAccountSwitcher' 138 126 import {useNonReactiveCallback} from './lib/hooks/useNonReactiveCallback' 127 + import {ProfileSearchScreen} from './screens/Profile/ProfileSearch' 128 + import {AboutSettingsScreen} from './screens/Settings/AboutSettings' 129 + import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings' 130 + import {AccountSettingsScreen} from './screens/Settings/AccountSettings' 131 + import {AppPasswordsScreen} from './screens/Settings/AppPasswords' 132 + import {ContentAndMediaSettingsScreen} from './screens/Settings/ContentAndMediaSettings' 133 + import {ExternalMediaPreferencesScreen} from './screens/Settings/ExternalMediaPreferences' 134 + import {FollowingFeedPreferencesScreen} from './screens/Settings/FollowingFeedPreferences' 135 + import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings' 136 + import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings' 137 + import {SettingsScreen} from './screens/Settings/Settings' 138 + import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences' 139 + import TopicScreen from './screens/Topic' 139 140 import {useLoggedOutViewControls} from './state/shell/logged-out' 140 141 import {useCloseAllActiveElements} from './state/util' 141 142 ··· 381 382 getComponent={() => AccessibilitySettingsScreen} 382 383 options={{ 383 384 title: title(msg`Accessibility Settings`), 385 + requireAuth: true, 386 + }} 387 + /> 388 + <Stack.Screen 389 + name="ExperimentalSettings" 390 + getComponent={() => ExperimentalSettingsScreen} 391 + options={{ 392 + title: title(msg`Experimental Settings`), 384 393 requireAuth: true, 385 394 }} 386 395 />
+6 -1
src/components/Link.tsx
··· 20 20 } from '#/lib/strings/url-helpers' 21 21 import {isNative, isWeb} from '#/platform/detection' 22 22 import {useModalControls} from '#/state/modals' 23 + import {useGoLinksEnabled} from '#/state/preferences' 23 24 import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf' 24 25 import {Button, type ButtonProps} from '#/components/Button' 25 26 import {useInteractionState} from '#/components/hooks/useInteractionState' ··· 117 118 const {linkWarningDialogControl} = useGlobalDialogsControlContext() 118 119 const openLink = useOpenLink() 119 120 121 + const goLinksEnabled = useGoLinksEnabled() 122 + 120 123 const onPress = React.useCallback( 121 124 (e: GestureResponderEvent) => { 122 125 const exitEarlyIfFalse = outerOnPress?.(e) ··· 141 144 }) 142 145 } else { 143 146 if (isExternal) { 144 - openLink(href, overridePresentation, shouldProxy) 147 + // openLink(href, overridePresentation, shouldProxy) 148 + openLink(href, overridePresentation, goLinksEnabled && shouldProxy) 145 149 } else { 146 150 const shouldOpenInNewTab = shouldClickOpenNewTab(e) 147 151 ··· 214 218 overridePresentation, 215 219 shouldProxy, 216 220 linkWarningDialogControl, 221 + goLinksEnabled, 217 222 ], 218 223 ) 219 224
+4 -4
src/components/Post/Embed/index.tsx
··· 175 175 case 'post_not_found': { 176 176 return ( 177 177 <PostPlaceholderText> 178 - <Trans>Deleted </Trans> 178 + <Trans>Deleted</Trans> 179 179 </PostPlaceholderText> 180 180 ) 181 181 } 182 182 case 'post_blocked': { 183 183 return ( 184 184 <PostPlaceholderText> 185 - <Trans>Blocked </Trans> 185 + <Trans>Blocked</Trans> 186 186 </PostPlaceholderText> 187 187 ) 188 188 } ··· 208 208 return ( 209 209 <PostPlaceholderText> 210 210 {isViewerOwner ? ( 211 - <Trans> Removed by you </Trans> 211 + <Trans>Removed by you</Trans> 212 212 ) : ( 213 - <Trans>Removed by author </Trans> 213 + <Trans>Removed by author</Trans> 214 214 )} 215 215 </PostPlaceholderText> 216 216 )
+1
src/lib/routes/types.ts
··· 123 123 Feeds: undefined 124 124 Notifications: undefined 125 125 Messages: {pushToConversation?: string; animation?: 'push' | 'pop'} 126 + ExperimentalSettings: undefined 126 127 } 127 128 128 129 export type AllNavigatorParams = CommonNavigatorParams & {
+19
src/lib/statsig/statsig.tsx
··· 7 7 import {isWeb} from '#/platform/detection' 8 8 import * as persisted from '#/state/persisted' 9 9 import * as env from '#/env' 10 + import {device} from '#/storage' 10 11 import {timeout} from '../async/timeout' 11 12 import {type Gate} from './gates' 12 13 ··· 129 130 dangerouslyDisableExposureLogging?: boolean 130 131 } 131 132 133 + export function useGatesCache(): Map<string, boolean> { 134 + const cache = React.useContext(GateCache) 135 + if (!cache) { 136 + throw Error('useGate() cannot be called outside StatsigProvider.') 137 + } 138 + return cache 139 + } 140 + 141 + function writeCatskyGateCache(cache: Map<string, boolean>) { 142 + device.set(['catskyGateCache'], JSON.stringify(Object.fromEntries(cache))) 143 + } 144 + 145 + export function resetCatskyGateCache() { 146 + writeCatskyGateCache(new Map()) 147 + } 148 + 132 149 export function useGate(): (gateName: Gate, options?: GateOptions) => boolean { 133 150 const cache = React.useContext(GateCache) 134 151 if (!cache) { ··· 149 166 } 150 167 } 151 168 cache.set(gateName, value) 169 + writeCatskyGateCache(cache) 152 170 return value 153 171 }, 154 172 [cache], ··· 172 190 const dangerousSetGate = React.useCallback( 173 191 (gateName: Gate, value: boolean) => { 174 192 cache.set(gateName, value) 193 + writeCatskyGateCache(cache) 175 194 }, 176 195 [cache], 177 196 )
+1
src/routes.ts
··· 47 47 PreferencesThreads: '/settings/threads', 48 48 PreferencesExternalEmbeds: '/settings/external-embeds', 49 49 AccessibilitySettings: '/settings/accessibility', 50 + ExperimentalSettings: '/settings/experimental', 50 51 AppearanceSettings: '/settings/appearance', 51 52 SavedFeeds: '/settings/saved-feeds', 52 53 AccountSettings: '/settings/account',
+182
src/screens/Settings/ExperimentalSettings.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 + resetCatskyGateCache, 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 ExperimentalIcon} 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 ExperimentalSettingsScreen({}: 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>Experimental</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={ExperimentalIcon} /> 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`FIXME: 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 + FIXME: Fetch records directly from PDS to see through quote 102 + blocks 103 + </Trans> 104 + </Toggle.LabelText> 105 + <Toggle.Platform /> 106 + </Toggle.Item> 107 + <Toggle.Item 108 + name="constellation_fallback" 109 + label={_( 110 + msg`Fall back to constellation api to find blocked replies`, 111 + )} 112 + disabled={true} 113 + value={constellationEnabled} 114 + onChange={value => setConstellationEnabled(value)} 115 + style={[a.w_full]}> 116 + <Toggle.LabelText style={[a.flex_1]}> 117 + <Trans> 118 + TODO: Fall back to constellation api to find blocked replies 119 + </Trans> 120 + </Toggle.LabelText> 121 + <Toggle.Platform /> 122 + </Toggle.Item> 123 + </SettingsList.Group> 124 + 125 + <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 126 + <SettingsList.ItemIcon icon={PaintRollerIcon} /> 127 + <SettingsList.ItemText> 128 + <Trans>Tweaks</Trans> 129 + </SettingsList.ItemText> 130 + <Toggle.Item 131 + name="under construction" 132 + label={_(msg`🚧 under construction...`)} 133 + value={false} 134 + onChange={() => {}} 135 + disabled={true} 136 + style={[a.w_full]}> 137 + <Toggle.LabelText style={[a.flex_1]}> 138 + <Trans>🚧 under construction...</Trans> 139 + </Toggle.LabelText> 140 + <Toggle.Platform /> 141 + </Toggle.Item> 142 + </SettingsList.Group> 143 + 144 + <SettingsList.Group contentContainerStyle={[a.gap_sm]}> 145 + <SettingsList.ItemIcon icon={PaintRollerIcon} /> 146 + <SettingsList.ItemText> 147 + <Trans>Gates</Trans> 148 + </SettingsList.ItemText> 149 + {Object.entries(gates).map(([gate, status]) => ( 150 + <Toggle.Item 151 + key={gate} 152 + name={gate} 153 + label={gate} 154 + value={status} 155 + onChange={value => setGate(gate as Gate, value)} 156 + style={[a.w_full]}> 157 + <Toggle.LabelText style={[a.flex_1]}>{gate}</Toggle.LabelText> 158 + <Toggle.Platform /> 159 + </Toggle.Item> 160 + ))} 161 + <SettingsList.BadgeButton 162 + label={_(msg`Reset gates`)} 163 + onPress={() => { 164 + resetCatskyGateCache() 165 + setGatesView({}) 166 + }} 167 + /> 168 + </SettingsList.Group> 169 + 170 + <SettingsList.Item> 171 + <Admonition type="warning" style={[a.flex_1]}> 172 + <Trans> 173 + These settings might summon nasel demons! Restart the app after 174 + changing if anything breaks. 175 + </Trans> 176 + </Admonition> 177 + </SettingsList.Item> 178 + </SettingsList.Container> 179 + </Layout.Content> 180 + </Layout.Screen> 181 + ) 182 + }
+9
src/screens/Settings/Settings.tsx
··· 40 40 import {useDialogControl} from '#/components/Dialog' 41 41 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' 42 42 import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' 43 + import {Atom_Stroke2_Corner0_Rounded as ExperimentalIcon} from '#/components/icons/Atom' 43 44 import {Bell_Stroke2_Corner0_Rounded as NotificationIcon} from '#/components/icons/Bell' 44 45 import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo' 45 46 import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron' ··· 213 214 <SettingsList.ItemIcon icon={PaintRollerIcon} /> 214 215 <SettingsList.ItemText> 215 216 <Trans>Appearance</Trans> 217 + </SettingsList.ItemText> 218 + </SettingsList.LinkItem> 219 + <SettingsList.LinkItem 220 + to="/settings/experimental" 221 + label={_(msg`Experimental`)}> 222 + <SettingsList.ItemIcon icon={ExperimentalIcon} /> 223 + <SettingsList.ItemText> 224 + <Trans>Experimental</Trans> 216 225 </SettingsList.ItemText> 217 226 </SettingsList.LinkItem> 218 227 <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 + // experimental (deer/zeppelin/fork niche toggle stuff) 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 + }
+29 -19
src/state/preferences/index.tsx
··· 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
··· 43 43 */ 44 44 policyUpdateDebugOverride?: boolean 45 45 [PolicyUpdate202508]?: boolean 46 + 47 + // catsky 48 + catskyGateCache: string 46 49 } 47 50 48 51 export type Account = {
+415
src/view/com/util/post-embeds/QuoteEmbed.tsx
··· 1 + // import React from 'react' 2 + // import { 3 + // type StyleProp, 4 + // StyleSheet, 5 + // TouchableOpacity, 6 + // View, 7 + // type ViewStyle, 8 + // } from 'react-native' 9 + // import { 10 + // AppBskyEmbedExternal, 11 + // AppBskyEmbedImages, 12 + // AppBskyEmbedRecord, 13 + // AppBskyEmbedRecordWithMedia, 14 + // AppBskyEmbedVideo, 15 + // type AppBskyFeedDefs, 16 + // AppBskyFeedPost, 17 + // moderatePost, 18 + // type ModerationDecision, 19 + // RichText as RichTextAPI, 20 + // } from '@atproto/api' 21 + // import {AtUri} from '@atproto/api' 22 + // import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 23 + // import {msg, Trans} from '@lingui/macro' 24 + // import {useLingui} from '@lingui/react' 25 + // import {useQueryClient} from '@tanstack/react-query' 26 + // 27 + // import {HITSLOP_20} from '#/lib/constants' 28 + // import {usePalette} from '#/lib/hooks/usePalette' 29 + // import {InfoCircleIcon} from '#/lib/icons' 30 + // import {makeProfileLink} from '#/lib/routes/links' 31 + // import {s} from '#/lib/styles' 32 + // import {useDirectFetchRecords} from '#/state/preferences/direct-fetch-records' 33 + // import {useModerationOpts} from '#/state/preferences/moderation-opts' 34 + // import {useDirectFetchRecord} from '#/state/queries/direct-fetch-record' 35 + // import {precacheProfile} from '#/state/queries/profile' 36 + // import {useResolveLinkQuery} from '#/state/queries/resolve-link' 37 + // import {useSession} from '#/state/session' 38 + // import {atoms as a, useTheme} from '#/alf' 39 + // import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash' 40 + // import {RichText} from '#/components/RichText' 41 + // import {SubtleWebHover} from '#/components/SubtleWebHover' 42 + // import * as bsky from '#/types/bsky' 43 + // import {ContentHider} from '../../../../components/moderation/ContentHider' 44 + // import {PostAlerts} from '../../../../components/moderation/PostAlerts' 45 + // import {Link} from '../Link' 46 + // import {PostMeta} from '../PostMeta' 47 + // import {Text} from '../text/Text' 48 + // import {PostEmbeds} from '.' 49 + // import {type QuoteEmbedViewContext} from './types' 50 + // 51 + // export function MaybeQuoteEmbed({ 52 + // embed, 53 + // onOpen, 54 + // style, 55 + // allowNestedQuotes, 56 + // viewContext, 57 + // }: { 58 + // embed: AppBskyEmbedRecord.View 59 + // onOpen?: () => void 60 + // style?: StyleProp<ViewStyle> 61 + // allowNestedQuotes?: boolean 62 + // viewContext?: QuoteEmbedViewContext 63 + // }) { 64 + // const {_} = useLingui() 65 + // const t = useTheme() 66 + // const pal = usePalette('default') 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 + // }) 83 + // if ( 84 + // AppBskyEmbedRecord.isViewRecord(embed.record) && 85 + // AppBskyFeedPost.isRecord(embed.record.value) && 86 + // AppBskyFeedPost.validateRecord(embed.record.value).success 87 + // ) { 88 + // return ( 89 + // <QuoteEmbedModerated 90 + // viewRecord={embed.record} 91 + // onOpen={onOpen} 92 + // style={style} 93 + // allowNestedQuotes={allowNestedQuotes} 94 + // viewContext={viewContext} 95 + // /> 96 + // ) 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 + // 114 + // return ( 115 + // <View 116 + // style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> 117 + // <InfoCircleIcon size={18} style={pal.text} /> 118 + // <Text type="lg" style={pal.text}> 119 + // {directFetchEnabled ? ( 120 + // <Trans>Blocked...</Trans> 121 + // ) : ( 122 + // <Trans>Blocked</Trans> 123 + // )} 124 + // </Text> 125 + // </View> 126 + // ) 127 + // } else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) { 128 + // return ( 129 + // <View 130 + // style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> 131 + // <InfoCircleIcon size={18} style={pal.text} /> 132 + // <Text type="lg" style={pal.text}> 133 + // <Trans>Deleted</Trans> 134 + // </Text> 135 + // </View> 136 + // ) 137 + // } else if (AppBskyEmbedRecord.isViewDetached(embed.record)) { 138 + // const isViewerOwner = currentAccount?.did 139 + // ? embed.record.uri.includes(currentAccount.did) 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 + // 160 + // return ( 161 + // <View 162 + // style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}> 163 + // <InfoCircleIcon size={18} style={pal.text} /> 164 + // <Text type="lg" style={pal.text}> 165 + // {isViewerOwner ? ( 166 + // <Trans>Removed by you</Trans> 167 + // ) : ( 168 + // <Trans>Removed by author</Trans> 169 + // )} 170 + // {directFetchEnabled ? <Trans>...</Trans> : undefined} 171 + // </Text> 172 + // </View> 173 + // ) 174 + // } 175 + // return null 176 + // } 177 + // 178 + // function QuoteEmbedModerated({ 179 + // viewRecord, 180 + // onOpen, 181 + // style, 182 + // allowNestedQuotes, 183 + // viewContext, 184 + // visibilityLabel, 185 + // }: { 186 + // viewRecord: AppBskyEmbedRecord.ViewRecord 187 + // onOpen?: () => void 188 + // style?: StyleProp<ViewStyle> 189 + // allowNestedQuotes?: boolean 190 + // viewContext?: QuoteEmbedViewContext 191 + // visibilityLabel?: string 192 + // }) { 193 + // const moderationOpts = useModerationOpts() 194 + // const postView = React.useMemo( 195 + // () => viewRecordToPostView(viewRecord), 196 + // [viewRecord], 197 + // ) 198 + // const moderation = React.useMemo(() => { 199 + // return moderationOpts ? moderatePost(postView, moderationOpts) : undefined 200 + // }, [postView, moderationOpts]) 201 + // 202 + // return ( 203 + // <QuoteEmbed 204 + // quote={postView} 205 + // moderation={moderation} 206 + // onOpen={onOpen} 207 + // style={style} 208 + // allowNestedQuotes={allowNestedQuotes} 209 + // viewContext={viewContext} 210 + // visibilityLabel={visibilityLabel} 211 + // /> 212 + // ) 213 + // } 214 + // 215 + // export function QuoteEmbed({ 216 + // quote, 217 + // moderation, 218 + // onOpen, 219 + // style, 220 + // allowNestedQuotes, 221 + // visibilityLabel, 222 + // }: { 223 + // quote: AppBskyFeedDefs.PostView 224 + // moderation?: ModerationDecision 225 + // onOpen?: () => void 226 + // style?: StyleProp<ViewStyle> 227 + // allowNestedQuotes?: boolean 228 + // viewContext?: QuoteEmbedViewContext 229 + // visibilityLabel?: string 230 + // }) { 231 + // const t = useTheme() 232 + // const queryClient = useQueryClient() 233 + // const pal = usePalette('default') 234 + // const itemUrip = new AtUri(quote.uri) 235 + // const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) 236 + // const itemTitle = `Post by ${quote.author.handle}` 237 + // 238 + // const richText = React.useMemo(() => { 239 + // if ( 240 + // !bsky.dangerousIsType<AppBskyFeedPost.Record>( 241 + // quote.record, 242 + // AppBskyFeedPost.isRecord, 243 + // ) 244 + // ) 245 + // return undefined 246 + // const {text, facets} = quote.record 247 + // return text.trim() 248 + // ? new RichTextAPI({text: text, facets: facets}) 249 + // : undefined 250 + // }, [quote.record]) 251 + // 252 + // const embed = React.useMemo(() => { 253 + // const e = quote.embed 254 + // 255 + // if (allowNestedQuotes) { 256 + // return e 257 + // } else { 258 + // if ( 259 + // AppBskyEmbedImages.isView(e) || 260 + // AppBskyEmbedExternal.isView(e) || 261 + // AppBskyEmbedVideo.isView(e) 262 + // ) { 263 + // return e 264 + // } else if ( 265 + // AppBskyEmbedRecordWithMedia.isView(e) && 266 + // (AppBskyEmbedImages.isView(e.media) || 267 + // AppBskyEmbedExternal.isView(e.media) || 268 + // AppBskyEmbedVideo.isView(e.media)) 269 + // ) { 270 + // return e.media 271 + // } 272 + // } 273 + // }, [quote.embed, allowNestedQuotes]) 274 + // 275 + // const onBeforePress = React.useCallback(() => { 276 + // precacheProfile(queryClient, quote.author) 277 + // onOpen?.() 278 + // }, [queryClient, quote.author, onOpen]) 279 + // 280 + // const [hover, setHover] = React.useState(false) 281 + // return ( 282 + // <View 283 + // onPointerEnter={() => { 284 + // setHover(true) 285 + // }} 286 + // onPointerLeave={() => { 287 + // setHover(false) 288 + // }}> 289 + // <ContentHider 290 + // modui={moderation?.ui('contentList')} 291 + // style={[ 292 + // a.rounded_md, 293 + // a.p_md, 294 + // a.mt_sm, 295 + // a.border, 296 + // t.atoms.border_contrast_low, 297 + // style, 298 + // ]} 299 + // childContainerStyle={[a.pt_sm]}> 300 + // <SubtleWebHover hover={hover} /> 301 + // <Link 302 + // hoverStyle={{borderColor: pal.colors.borderLinkHover}} 303 + // href={itemHref} 304 + // title={itemTitle} 305 + // onBeforePress={onBeforePress}> 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} 315 + // <PostMeta 316 + // author={quote.author} 317 + // moderation={moderation} 318 + // showAvatar 319 + // postHref={itemHref} 320 + // timestamp={quote.indexedAt} 321 + // /> 322 + // </View> 323 + // {moderation ? ( 324 + // <PostAlerts 325 + // modui={moderation.ui('contentView')} 326 + // style={[a.py_xs]} 327 + // /> 328 + // ) : null} 329 + // {richText ? ( 330 + // <RichText 331 + // value={richText} 332 + // style={a.text_md} 333 + // numberOfLines={20} 334 + // disableLinks 335 + // /> 336 + // ) : null} 337 + // {embed && <PostEmbeds embed={embed} moderation={moderation} />} 338 + // </Link> 339 + // </ContentHider> 340 + // </View> 341 + // ) 342 + // } 343 + // 344 + // export function QuoteX({onRemove}: {onRemove: () => void}) { 345 + // const {_} = useLingui() 346 + // return ( 347 + // <TouchableOpacity 348 + // style={[ 349 + // a.absolute, 350 + // a.p_xs, 351 + // a.rounded_full, 352 + // a.align_center, 353 + // a.justify_center, 354 + // { 355 + // top: 16, 356 + // right: 10, 357 + // backgroundColor: 'rgba(0, 0, 0, 0.75)', 358 + // }, 359 + // ]} 360 + // onPress={onRemove} 361 + // accessibilityRole="button" 362 + // accessibilityLabel={_(msg`Remove quote`)} 363 + // accessibilityHint={_(msg`Removes quoted post`)} 364 + // onAccessibilityEscape={onRemove} 365 + // hitSlop={HITSLOP_20}> 366 + // <FontAwesomeIcon size={12} icon="xmark" style={s.white} /> 367 + // </TouchableOpacity> 368 + // ) 369 + // } 370 + // 371 + // export function LazyQuoteEmbed({uri}: {uri: string}) { 372 + // const {data} = useResolveLinkQuery(uri) 373 + // const moderationOpts = useModerationOpts() 374 + // if (!data || data.type !== 'record' || data.kind !== 'post') { 375 + // return null 376 + // } 377 + // const moderation = moderationOpts 378 + // ? moderatePost(data.view, moderationOpts) 379 + // : undefined 380 + // return <QuoteEmbed quote={data.view} moderation={moderation} /> 381 + // } 382 + // 383 + // function viewRecordToPostView( 384 + // viewRecord: AppBskyEmbedRecord.ViewRecord, 385 + // ): AppBskyFeedDefs.PostView { 386 + // const {value, embeds, ...rest} = viewRecord 387 + // return { 388 + // ...rest, 389 + // $type: 'app.bsky.feed.defs#postView', 390 + // record: value, 391 + // embed: embeds?.[0], 392 + // } 393 + // } 394 + // 395 + // const styles = StyleSheet.create({ 396 + // errorContainer: { 397 + // flexDirection: 'row', 398 + // alignItems: 'center', 399 + // gap: 4, 400 + // borderRadius: 8, 401 + // marginTop: 8, 402 + // paddingVertical: 14, 403 + // paddingHorizontal: 14, 404 + // borderWidth: StyleSheet.hairlineWidth, 405 + // }, 406 + // alert: { 407 + // marginBottom: 6, 408 + // }, 409 + // blockHeader: { 410 + // flexDirection: 'row', 411 + // alignItems: 'center', 412 + // gap: 4, 413 + // marginBottom: 8, 414 + // }, 415 + // })