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

Configure Feed

Select the types of activity you want to include in your feed.

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.

authored by

Aviva Ruben and committed by
serenity
e01e77c9 9fa8476c

+916 -20
+22
src/Navigation.tsx
··· 136 136 import {Referrer} from '../modules/expo-bluesky-swiss-army' 137 137 import {useAccountSwitcher} from './lib/hooks/useAccountSwitcher' 138 138 import {useNonReactiveCallback} from './lib/hooks/useNonReactiveCallback' 139 + import {ProfileSearchScreen} from './screens/Profile/ProfileSearch' 140 + import {AboutSettingsScreen} from './screens/Settings/AboutSettings' 141 + import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings' 142 + import {AccountSettingsScreen} from './screens/Settings/AccountSettings' 143 + import {AppPasswordsScreen} from './screens/Settings/AppPasswords' 144 + import {ContentAndMediaSettingsScreen} from './screens/Settings/ContentAndMediaSettings' 145 + import {DeerSettingsScreen} from './screens/Settings/DeerSettings' 146 + import {ExternalMediaPreferencesScreen} from './screens/Settings/ExternalMediaPreferences' 147 + import {FollowingFeedPreferencesScreen} from './screens/Settings/FollowingFeedPreferences' 148 + import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings' 149 + import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings' 150 + import {SettingsScreen} from './screens/Settings/Settings' 151 + import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences' 152 + import TopicScreen from './screens/Topic' 139 153 import {useLoggedOutViewControls} from './state/shell/logged-out' 140 154 import {useCloseAllActiveElements} from './state/util' 141 155 ··· 381 395 getComponent={() => AccessibilitySettingsScreen} 382 396 options={{ 383 397 title: title(msg`Accessibility Settings`), 398 + requireAuth: true, 399 + }} 400 + /> 401 + <Stack.Screen 402 + name="DeerSettings" 403 + getComponent={() => DeerSettingsScreen} 404 + options={{ 405 + title: title(msg`Deer Settings`), 384 406 requireAuth: true, 385 407 }} 386 408 />
+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
+18
src/lib/statsig/statsig.tsx
··· 129 129 dangerouslyDisableExposureLogging?: boolean 130 130 } 131 131 132 + export function useGatesCache(): Map<string, boolean> { 133 + const cache = React.useContext(GateCache) 134 + if (!cache) { 135 + throw Error('useGate() cannot be called outside StatsigProvider.') 136 + } 137 + return cache 138 + } 139 + 140 + function writeDeerGateCache(cache: Map<string, boolean>) { 141 + device.set(['deerGateCache'], JSON.stringify(Object.fromEntries(cache))) 142 + } 143 + 144 + export function resetDeerGateCache() { 145 + writeDeerGateCache(new Map()) 146 + } 147 + 132 148 export function useGate(): (gateName: Gate, options?: GateOptions) => boolean { 133 149 const cache = React.useContext(GateCache) 134 150 if (!cache) { ··· 149 165 } 150 166 } 151 167 cache.set(gateName, value) 168 + writeDeerGateCache(cache) 152 169 return value 153 170 }, 154 171 [cache], ··· 172 189 const dangerousSetGate = React.useCallback( 173 190 (gateName: Gate, value: boolean) => { 174 191 cache.set(gateName, value) 192 + writeDeerGateCache(cache) 175 193 }, 176 194 [cache], 177 195 )
+1
src/routes.ts
··· 47 47 PreferencesThreads: '/settings/threads', 48 48 PreferencesExternalEmbeds: '/settings/external-embeds', 49 49 AccessibilitySettings: '/settings/accessibility', 50 + DeerSettings: '/settings/deer', 50 51 AppearanceSettings: '/settings/appearance', 51 52 SavedFeeds: '/settings/saved-feeds', 52 53 AccountSettings: '/settings/account',
+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
··· 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 DeerIcon} 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 to="/settings/deer" label={_(msg`Deer`)}> 220 + <SettingsList.ItemIcon icon={DeerIcon} /> 221 + <SettingsList.ItemText> 222 + <Trans>Deer</Trans> 216 223 </SettingsList.ItemText> 217 224 </SettingsList.LinkItem> 218 225 <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 + }
+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 + // deer 48 + deerGateCache: 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 + })