diff --git a/src/Navigation.tsx b/src/Navigation.tsx index fa33a9d56..46e725c6f 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -136,6 +136,20 @@ import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' import {useAccountSwitcher} from './lib/hooks/useAccountSwitcher' import {useNonReactiveCallback} from './lib/hooks/useNonReactiveCallback' +import {ProfileSearchScreen} from './screens/Profile/ProfileSearch' +import {AboutSettingsScreen} from './screens/Settings/AboutSettings' +import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings' +import {AccountSettingsScreen} from './screens/Settings/AccountSettings' +import {AppPasswordsScreen} from './screens/Settings/AppPasswords' +import {ContentAndMediaSettingsScreen} from './screens/Settings/ContentAndMediaSettings' +import {DeerSettingsScreen} from './screens/Settings/DeerSettings' +import {ExternalMediaPreferencesScreen} from './screens/Settings/ExternalMediaPreferences' +import {FollowingFeedPreferencesScreen} from './screens/Settings/FollowingFeedPreferences' +import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings' +import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings' +import {SettingsScreen} from './screens/Settings/Settings' +import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences' +import TopicScreen from './screens/Topic' import {useLoggedOutViewControls} from './state/shell/logged-out' import {useCloseAllActiveElements} from './state/util' @@ -384,6 +398,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { requireAuth: true, }} /> + DeerSettingsScreen} + options={{ + title: title(msg`Deer Settings`), + requireAuth: true, + }} + /> AppearanceSettingsScreen} diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 2b533b599..739af80f2 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -20,6 +20,7 @@ import { } from '#/lib/strings/url-helpers' import {isNative, isWeb} from '#/platform/detection' import {useModalControls} from '#/state/modals' +import {useGoLinksEnabled} from '#/state/preferences' import {atoms as a, flatten, type TextStyleProp, useTheme, web} from '#/alf' import {Button, type ButtonProps} from '#/components/Button' import {useInteractionState} from '#/components/hooks/useInteractionState' @@ -117,6 +118,8 @@ export function useLink({ const {linkWarningDialogControl} = useGlobalDialogsControlContext() const openLink = useOpenLink() + const goLinksEnabled = useGoLinksEnabled() + const onPress = React.useCallback( (e: GestureResponderEvent) => { const exitEarlyIfFalse = outerOnPress?.(e) @@ -141,7 +144,8 @@ export function useLink({ }) } else { if (isExternal) { - openLink(href, overridePresentation, shouldProxy) + // openLink(href, overridePresentation, shouldProxy) + openLink(href, overridePresentation, goLinksEnabled && shouldProxy) } else { const shouldOpenInNewTab = shouldClickOpenNewTab(e) @@ -214,6 +218,7 @@ export function useLink({ overridePresentation, shouldProxy, linkWarningDialogControl, + goLinksEnabled, ], ) diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index 2f18c071f..52ef902e2 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -129,6 +129,22 @@ type GateOptions = { dangerouslyDisableExposureLogging?: boolean } +export function useGatesCache(): Map { + const cache = React.useContext(GateCache) + if (!cache) { + throw Error('useGate() cannot be called outside StatsigProvider.') + } + return cache +} + +function writeDeerGateCache(cache: Map) { + device.set(['deerGateCache'], JSON.stringify(Object.fromEntries(cache))) +} + +export function resetDeerGateCache() { + writeDeerGateCache(new Map()) +} + export function useGate(): (gateName: Gate, options?: GateOptions) => boolean { const cache = React.useContext(GateCache) if (!cache) { @@ -149,6 +165,7 @@ export function useGate(): (gateName: Gate, options?: GateOptions) => boolean { } } cache.set(gateName, value) + writeDeerGateCache(cache) return value }, [cache], @@ -172,6 +189,7 @@ export function useDangerousSetGate(): ( const dangerousSetGate = React.useCallback( (gateName: Gate, value: boolean) => { cache.set(gateName, value) + writeDeerGateCache(cache) }, [cache], ) diff --git a/src/routes.ts b/src/routes.ts index 1ed913bb2..6c93b2d28 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -47,6 +47,7 @@ export const router = new Router({ PreferencesThreads: '/settings/threads', PreferencesExternalEmbeds: '/settings/external-embeds', AccessibilitySettings: '/settings/accessibility', + DeerSettings: '/settings/deer', AppearanceSettings: '/settings/appearance', SavedFeeds: '/settings/saved-feeds', AccountSettings: '/settings/account', diff --git a/src/screens/Settings/DeerSettings.tsx b/src/screens/Settings/DeerSettings.tsx new file mode 100644 index 000000000..27acd3b75 --- /dev/null +++ b/src/screens/Settings/DeerSettings.tsx @@ -0,0 +1,181 @@ +import {useState} from 'react' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' + +import {type CommonNavigatorParams} from '#/lib/routes/types' +import {type Gate} from '#/lib/statsig/gates' +import { + resetDeerGateCache, + useDangerousSetGate, + useGatesCache, +} from '#/lib/statsig/statsig' +import {useGoLinksEnabled, useSetGoLinksEnabled} from '#/state/preferences' +import { + useConstellationEnabled, + useSetConstellationEnabled, +} from '#/state/preferences/constellation-enabled' +import { + useDirectFetchRecords, + useSetDirectFetchRecords, +} from '#/state/preferences/direct-fetch-records' +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import * as Toggle from '#/components/forms/Toggle' +import {Atom_Stroke2_Corner0_Rounded as DeerIcon} from '#/components/icons/Atom' +import {Eye_Stroke2_Corner0_Rounded as VisibilityIcon} from '#/components/icons/Eye' +import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller' +import * as Layout from '#/components/Layout' + +type Props = NativeStackScreenProps + +export function DeerSettingsScreen({}: Props) { + const {_} = useLingui() + + const goLinksEnabled = useGoLinksEnabled() + const setGoLinksEnabled = useSetGoLinksEnabled() + + const constellationEnabled = useConstellationEnabled() + const setConstellationEnabled = useSetConstellationEnabled() + + const directFetchRecords = useDirectFetchRecords() + const setDirectFetchRecords = useSetDirectFetchRecords() + + const [gates, setGatesView] = useState(Object.fromEntries(useGatesCache())) + const dangerousSetGate = useDangerousSetGate() + const setGate = (gate: Gate, value: boolean) => { + dangerousSetGate(gate, value) + setGatesView({ + ...gates, + [gate]: value, + }) + } + + return ( + + + + + + Deer + + + + + + + + + + Redirects + + setGoLinksEnabled(value)} + style={[a.w_full]}> + + Redirect through go.bsky.app + + + + + + + + + Visibility + + setDirectFetchRecords(value)} + style={[a.w_full]}> + + + Fetch records directly from PDS to see through quote blocks + + + + + setConstellationEnabled(value)} + style={[a.w_full]}> + + + TODO: Fall back to constellation api to find blocked replies + + + + + + + + + + Tweaks + + {}} + disabled={true} + style={[a.w_full]}> + + 🚧 under construction... + + + + + + + + + Gates + + {Object.entries(gates).map(([gate, status]) => ( + setGate(gate as Gate, value)} + style={[a.w_full]}> + {gate} + + + ))} + { + resetDeerGateCache() + setGatesView({}) + }} + /> + + + + + + These settings might summon nasel demons! Restart the app after + changing if anything breaks. + + + + + + + ) +} diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 106502f7d..3cf698dd5 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -40,6 +40,7 @@ import {Button, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' +import {Atom_Stroke2_Corner0_Rounded as DeerIcon} from '#/components/icons/Atom' import {Bell_Stroke2_Corner0_Rounded as NotificationIcon} from '#/components/icons/Bell' import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo' import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron' @@ -215,6 +216,12 @@ export function SettingsScreen({}: Props) { Appearance + + + + Deer + + diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 1381f66b5..77d2deeb6 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -123,6 +123,13 @@ const schema = z.object({ kawaii: z.boolean().optional(), hasCheckedForStarterPack: z.boolean().optional(), subtitlesEnabled: z.boolean().optional(), + + // deer + goLinksEnabled: z.boolean().optional(), + constellationEnabled: z.boolean().optional(), + directFetchRecords: z.boolean().optional(), + unfollowConfirm: z.boolean().optional(), + /** @deprecated */ mutedThreads: z.array(z.string()), trendingDisabled: z.boolean().optional(), @@ -174,6 +181,12 @@ export const defaults: Schema = { subtitlesEnabled: true, trendingDisabled: false, trendingVideoDisabled: false, + + // deer + goLinksEnabled: true, + constellationEnabled: false, + directFetchRecords: false, + unfollowConfirm: false, } export function tryParse(rawData: string): Schema | undefined { diff --git a/src/state/preferences/constellation-enabled.tsx b/src/state/preferences/constellation-enabled.tsx new file mode 100644 index 000000000..6e2fb992c --- /dev/null +++ b/src/state/preferences/constellation-enabled.tsx @@ -0,0 +1,52 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['constellationEnabled'] +type SetContext = (v: persisted.Schema['constellationEnabled']) => void + +const stateContext = React.createContext( + persisted.defaults.constellationEnabled, +) +const setContext = React.createContext( + (_: persisted.Schema['constellationEnabled']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState( + persisted.get('constellationEnabled'), + ) + + const setStateWrapped = React.useCallback( + (constellationEnabled: persisted.Schema['constellationEnabled']) => { + setState(constellationEnabled) + persisted.write('constellationEnabled', constellationEnabled) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate( + 'constellationEnabled', + nextConstellationEnabled => { + setState(nextConstellationEnabled) + }, + ) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useConstellationEnabled() { + return React.useContext(stateContext) +} + +export function useSetConstellationEnabled() { + return React.useContext(setContext) +} diff --git a/src/state/preferences/direct-fetch-records.tsx b/src/state/preferences/direct-fetch-records.tsx new file mode 100644 index 000000000..72e515ea2 --- /dev/null +++ b/src/state/preferences/direct-fetch-records.tsx @@ -0,0 +1,47 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['directFetchRecords'] +type SetContext = (v: persisted.Schema['directFetchRecords']) => void + +const stateContext = React.createContext( + persisted.defaults.directFetchRecords, +) +const setContext = React.createContext( + (_: persisted.Schema['directFetchRecords']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('directFetchRecords')) + + const setStateWrapped = React.useCallback( + (directFetchRecords: persisted.Schema['directFetchRecords']) => { + setState(directFetchRecords) + persisted.write('directFetchRecords', directFetchRecords) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate('directFetchRecords', nextDirectFetchRecords => { + setState(nextDirectFetchRecords) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useDirectFetchRecords() { + return React.useContext(stateContext) +} + +export function useSetDirectFetchRecords() { + return React.useContext(setContext) +} diff --git a/src/state/preferences/go-links-enabled.tsx b/src/state/preferences/go-links-enabled.tsx new file mode 100644 index 000000000..05d26035c --- /dev/null +++ b/src/state/preferences/go-links-enabled.tsx @@ -0,0 +1,47 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['goLinksEnabled'] +type SetContext = (v: persisted.Schema['goLinksEnabled']) => void + +const stateContext = React.createContext( + persisted.defaults.goLinksEnabled, +) +const setContext = React.createContext( + (_: persisted.Schema['goLinksEnabled']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('goLinksEnabled')) + + const setStateWrapped = React.useCallback( + (goLinksEnabled: persisted.Schema['goLinksEnabled']) => { + setState(goLinksEnabled) + persisted.write('goLinksEnabled', goLinksEnabled) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate('goLinksEnabled', nextGoLinksEnabled => { + setState(nextGoLinksEnabled) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useGoLinksEnabled() { + return React.useContext(stateContext) +} + +export function useSetGoLinksEnabled() { + return React.useContext(setContext) +} diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index 740ea4bf3..132561dd8 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -2,8 +2,11 @@ import type React from 'react' import {Provider as AltTextRequiredProvider} from './alt-text-required' import {Provider as AutoplayProvider} from './autoplay' +import {Provider as ConstellationProvider} from './constellation-enabled' +import {Provider as DirectFetchRecordsProvider} from './direct-fetch-records' import {Provider as DisableHapticsProvider} from './disable-haptics' import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs' +import {Provider as GoLinksProvider} from './go-links-enabled' import {Provider as HiddenPostsProvider} from './hidden-posts' import {Provider as InAppBrowserProvider} from './in-app-browser' import {Provider as KawaiiProvider} from './kawaii' @@ -23,6 +26,7 @@ export { useExternalEmbedsPrefs, useSetExternalEmbedPref, } from './external-embeds-prefs' +export {useGoLinksEnabled, useSetGoLinksEnabled} from './go-links-enabled' export * from './hidden-posts' export {useLabelDefinitions} from './label-defs' export {useLanguagePrefs, useLanguagePrefsApi} from './languages' @@ -32,25 +36,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return ( - - - - - - - - - - {children} - - - - - - - - - + + + + + + + + + + + + + {children} + + + + + + + + + + + + ) diff --git a/src/state/queries/direct-fetch-record.ts b/src/state/queries/direct-fetch-record.ts new file mode 100644 index 000000000..3a801e076 --- /dev/null +++ b/src/state/queries/direct-fetch-record.ts @@ -0,0 +1,75 @@ +import {type AppBskyEmbedRecord, AppBskyFeedPost, AtUri} from '@atproto/api' +import {type ProfileViewBasic} from '@atproto/api/dist/client/types/app/bsky/actor/defs' +import {useQuery} from '@tanstack/react-query' + +import {retry} from '#/lib/async/retry' +import {STALE} from '#/state/queries' +import {useAgent} from '#/state/session' +import * as bsky from '#/types/bsky' + +const RQKEY_ROOT = 'direct-fetch-record' +export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] + +export function useDirectFetchRecord({ + uri, + enabled, +}: { + uri: string + enabled?: boolean +}) { + const agent = useAgent() + return useQuery({ + staleTime: STALE.HOURS.ONE, + queryKey: RQKEY(uri || ''), + async queryFn() { + const urip = new AtUri(uri) + + if (!urip.host.startsWith('did:')) { + const res = await agent.resolveHandle({ + handle: urip.host, + }) + urip.host = res.data.did + } + + try { + // TODO: parallel, series fetch sucks there isn't a dependency + const profile = (await agent.getProfile({actor: urip.host})).data + const {data} = await retry( + 2, + e => { + if (e.message.includes(`Could not locate record:`)) { + return false + } + return true + }, + () => + agent.api.com.atproto.repo.getRecord({ + repo: urip.host, + collection: 'app.bsky.feed.post', + rkey: urip.rkey, + }), + ) + if ( + data.value && + bsky.validate(data.value, AppBskyFeedPost.validateRecord) + ) { + const record = data.value + return { + $type: 'app.bsky.embed.record#viewRecord', + uri, + author: profile as ProfileViewBasic, + cid: '', + value: record, + indexedAt: record.createdAt, + } satisfies AppBskyEmbedRecord.ViewRecord + } else { + return undefined + } + } catch (e) { + console.error(e) + return undefined + } + }, + enabled: enabled && !!uri, + }) +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index d562d9fae..90c8d57f4 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -43,6 +43,9 @@ export type Device = { */ policyUpdateDebugOverride?: boolean [PolicyUpdate202508]?: boolean + + // deer + deerGateCache: string } export type Account = { diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx new file mode 100644 index 000000000..00686d97c --- /dev/null +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -0,0 +1,415 @@ +import React from 'react' +import { + type StyleProp, + StyleSheet, + TouchableOpacity, + View, + type ViewStyle, +} from 'react-native' +import { + AppBskyEmbedExternal, + AppBskyEmbedImages, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyEmbedVideo, + type AppBskyFeedDefs, + AppBskyFeedPost, + moderatePost, + type ModerationDecision, + RichText as RichTextAPI, +} from '@atproto/api' +import {AtUri} from '@atproto/api' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' + +import {HITSLOP_20} from '#/lib/constants' +import {usePalette} from '#/lib/hooks/usePalette' +import {InfoCircleIcon} from '#/lib/icons' +import {makeProfileLink} from '#/lib/routes/links' +import {s} from '#/lib/styles' +import {useDirectFetchRecords} from '#/state/preferences/direct-fetch-records' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useDirectFetchRecord} from '#/state/queries/direct-fetch-record' +import {precacheProfile} from '#/state/queries/profile' +import {useResolveLinkQuery} from '#/state/queries/resolve-link' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash' +import {RichText} from '#/components/RichText' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import * as bsky from '#/types/bsky' +import {ContentHider} from '../../../../components/moderation/ContentHider' +import {PostAlerts} from '../../../../components/moderation/PostAlerts' +import {Link} from '../Link' +import {PostMeta} from '../PostMeta' +import {Text} from '../text/Text' +import {PostEmbeds} from '.' +import {type QuoteEmbedViewContext} from './types' + +export function MaybeQuoteEmbed({ + embed, + onOpen, + style, + allowNestedQuotes, + viewContext, +}: { + embed: AppBskyEmbedRecord.View + onOpen?: () => void + style?: StyleProp + allowNestedQuotes?: boolean + viewContext?: QuoteEmbedViewContext +}) { + const {_} = useLingui() + const t = useTheme() + const pal = usePalette('default') + const {currentAccount} = useSession() + + const directFetchEnabled = useDirectFetchRecords() + const shouldDirectFetch = + (AppBskyEmbedRecord.isViewBlocked(embed.record) || + AppBskyEmbedRecord.isViewDetached(embed.record)) && + directFetchEnabled + + const directRecord = useDirectFetchRecord({ + uri: + AppBskyEmbedRecord.isViewBlocked(embed.record) || + AppBskyEmbedRecord.isViewDetached(embed.record) + ? embed.record.uri + : '', + enabled: shouldDirectFetch, + }) + if ( + AppBskyEmbedRecord.isViewRecord(embed.record) && + AppBskyFeedPost.isRecord(embed.record.value) && + AppBskyFeedPost.validateRecord(embed.record.value).success + ) { + return ( + + ) + } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) { + const record = directRecord.data + if (record !== undefined) { + return ( + + + + ) + } + + return ( + + + + {directFetchEnabled ? ( + Blocked... + ) : ( + Blocked + )} + + + ) + } else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) { + return ( + + + + Deleted + + + ) + } else if (AppBskyEmbedRecord.isViewDetached(embed.record)) { + const isViewerOwner = currentAccount?.did + ? embed.record.uri.includes(currentAccount.did) + : false + + const record = directRecord.data + if (record !== undefined) { + return ( + + + + ) + } + + return ( + + + + {isViewerOwner ? ( + Removed by you + ) : ( + Removed by author + )} + {directFetchEnabled ? ... : undefined} + + + ) + } + return null +} + +function QuoteEmbedModerated({ + viewRecord, + onOpen, + style, + allowNestedQuotes, + viewContext, + visibilityLabel, +}: { + viewRecord: AppBskyEmbedRecord.ViewRecord + onOpen?: () => void + style?: StyleProp + allowNestedQuotes?: boolean + viewContext?: QuoteEmbedViewContext + visibilityLabel?: string +}) { + const moderationOpts = useModerationOpts() + const postView = React.useMemo( + () => viewRecordToPostView(viewRecord), + [viewRecord], + ) + const moderation = React.useMemo(() => { + return moderationOpts ? moderatePost(postView, moderationOpts) : undefined + }, [postView, moderationOpts]) + + return ( + + ) +} + +export function QuoteEmbed({ + quote, + moderation, + onOpen, + style, + allowNestedQuotes, + visibilityLabel, +}: { + quote: AppBskyFeedDefs.PostView + moderation?: ModerationDecision + onOpen?: () => void + style?: StyleProp + allowNestedQuotes?: boolean + viewContext?: QuoteEmbedViewContext + visibilityLabel?: string +}) { + const t = useTheme() + const queryClient = useQueryClient() + const pal = usePalette('default') + const itemUrip = new AtUri(quote.uri) + const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) + const itemTitle = `Post by ${quote.author.handle}` + + const richText = React.useMemo(() => { + if ( + !bsky.dangerousIsType( + quote.record, + AppBskyFeedPost.isRecord, + ) + ) + return undefined + const {text, facets} = quote.record + return text.trim() + ? new RichTextAPI({text: text, facets: facets}) + : undefined + }, [quote.record]) + + const embed = React.useMemo(() => { + const e = quote.embed + + if (allowNestedQuotes) { + return e + } else { + if ( + AppBskyEmbedImages.isView(e) || + AppBskyEmbedExternal.isView(e) || + AppBskyEmbedVideo.isView(e) + ) { + return e + } else if ( + AppBskyEmbedRecordWithMedia.isView(e) && + (AppBskyEmbedImages.isView(e.media) || + AppBskyEmbedExternal.isView(e.media) || + AppBskyEmbedVideo.isView(e.media)) + ) { + return e.media + } + } + }, [quote.embed, allowNestedQuotes]) + + const onBeforePress = React.useCallback(() => { + precacheProfile(queryClient, quote.author) + onOpen?.() + }, [queryClient, quote.author, onOpen]) + + const [hover, setHover] = React.useState(false) + return ( + { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + + + + + {visibilityLabel !== undefined ? ( + + + + {visibilityLabel} + + + ) : undefined} + + + {moderation ? ( + + ) : null} + {richText ? ( + + ) : null} + {embed && } + + + + ) +} + +export function QuoteX({onRemove}: {onRemove: () => void}) { + const {_} = useLingui() + return ( + + + + ) +} + +export function LazyQuoteEmbed({uri}: {uri: string}) { + const {data} = useResolveLinkQuery(uri) + const moderationOpts = useModerationOpts() + if (!data || data.type !== 'record' || data.kind !== 'post') { + return null + } + const moderation = moderationOpts + ? moderatePost(data.view, moderationOpts) + : undefined + return +} + +function viewRecordToPostView( + viewRecord: AppBskyEmbedRecord.ViewRecord, +): AppBskyFeedDefs.PostView { + const {value, embeds, ...rest} = viewRecord + return { + ...rest, + $type: 'app.bsky.feed.defs#postView', + record: value, + embed: embeds?.[0], + } +} + +const styles = StyleSheet.create({ + errorContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + borderRadius: 8, + marginTop: 8, + paddingVertical: 14, + paddingHorizontal: 14, + borderWidth: StyleSheet.hairlineWidth, + }, + alert: { + marginBottom: 6, + }, + blockHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + marginBottom: 8, + }, +})