Skip to content

Commit fa6ba5a

Browse files
authored
Merge pull request #96 from blacksky-algorithms/feature/remove-fallback-real-status
2 parents 9220a82 + bb408ff commit fa6ba5a

10 files changed

Lines changed: 341 additions & 912 deletions

File tree

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import {View} from 'react-native'
2+
import {
3+
FontAwesomeIcon,
4+
type FontAwesomeIconStyle,
5+
} from '@fortawesome/react-native-fontawesome'
6+
import {msg} from '@lingui/core/macro'
7+
import {useLingui} from '@lingui/react'
8+
import {Trans} from '@lingui/react/macro'
9+
10+
import {usePalette} from '#/lib/hooks/usePalette'
11+
import {
12+
type ProfileErrorKind,
13+
type ProfileStatusSource,
14+
useProfileStatusSource,
15+
} from '#/state/queries/profile-status'
16+
import {atoms as a, useTheme} from '#/alf'
17+
import * as Layout from '#/components/Layout'
18+
import {InlineLinkText} from '#/components/Link'
19+
import {Loader} from '#/components/Loader'
20+
import {Text} from '#/components/Typography'
21+
22+
export function ProfileStatusError({
23+
kind,
24+
didOrHandle,
25+
did,
26+
}: {
27+
kind: Exclude<ProfileErrorKind, 'unknown'>
28+
/** The identifier the user typed (handle, did, or 'me'-resolved did) — shown in messages */
29+
didOrHandle: string
30+
/** The DID, when known — used to look up which labeler suspended the account */
31+
did?: string
32+
}) {
33+
const {_} = useLingui()
34+
const enableSourceLookup = kind === 'suspendedOrTakedown' && !!did
35+
const {data: source, isLoading: isSourceLoading} = useProfileStatusSource(
36+
did,
37+
{enabled: enableSourceLookup},
38+
)
39+
40+
let title: string
41+
let body: React.ReactNode
42+
switch (kind) {
43+
case 'notFound':
44+
title = _(msg`Not found`)
45+
body = (
46+
<Text style={[a.text_center, a.text_md]}>
47+
<Trans>We couldn't find an account at {didOrHandle}.</Trans>
48+
</Text>
49+
)
50+
break
51+
case 'deactivated':
52+
title = _(msg`Account deactivated`)
53+
body = (
54+
<Text style={[a.text_center, a.text_md]}>
55+
<Trans>This account has been deactivated by its owner.</Trans>
56+
</Text>
57+
)
58+
break
59+
case 'suspendedOrTakedown':
60+
title = _(msg`Account suspended`)
61+
if (isSourceLoading) {
62+
body = (
63+
<View
64+
style={[a.flex_row, a.align_center, a.justify_center, a.gap_sm]}>
65+
<Loader size="sm" />
66+
<Text style={[a.text_md]}>
67+
<Trans>Account is suspended.</Trans>
68+
</Text>
69+
</View>
70+
)
71+
} else if (source && (source.appealUrl || source.email)) {
72+
body = <SuspendedMessage source={source} />
73+
} else if (source) {
74+
body = (
75+
<Text style={[a.text_center, a.text_md]}>
76+
<Trans>Account is suspended by @{source.handle}.</Trans>
77+
</Text>
78+
)
79+
} else {
80+
body = (
81+
<Text style={[a.text_center, a.text_md]}>
82+
<Trans>Account is suspended.</Trans>
83+
</Text>
84+
)
85+
}
86+
break
87+
}
88+
89+
return <ProfileStatusErrorShell title={title}>{body}</ProfileStatusErrorShell>
90+
}
91+
92+
function SuspendedMessage({source}: {source: ProfileStatusSource}) {
93+
if (source.appealUrl) {
94+
return (
95+
<Text style={[a.text_center, a.text_md]}>
96+
<Trans>
97+
Account is suspended by{' '}
98+
<InlineLinkText
99+
label={`@${source.handle}`}
100+
to={{screen: 'Profile', params: {name: source.handle}}}>
101+
@{source.handle}
102+
</InlineLinkText>
103+
. Click{' '}
104+
<InlineLinkText
105+
label="Submit an appeal"
106+
to={source.appealUrl}
107+
overridePresentation>
108+
here
109+
</InlineLinkText>{' '}
110+
to submit an appeal.
111+
</Trans>
112+
</Text>
113+
)
114+
}
115+
return (
116+
<Text style={[a.text_center, a.text_md]}>
117+
<Trans>
118+
Account is suspended by{' '}
119+
<InlineLinkText
120+
label={`@${source.handle}`}
121+
to={{screen: 'Profile', params: {name: source.handle}}}>
122+
@{source.handle}
123+
</InlineLinkText>
124+
, email{' '}
125+
<InlineLinkText label={source.email} to={`mailto:${source.email}`}>
126+
{source.email}
127+
</InlineLinkText>{' '}
128+
to appeal.
129+
</Trans>
130+
</Text>
131+
)
132+
}
133+
134+
function ProfileStatusErrorShell({
135+
title,
136+
children,
137+
}: {
138+
title: string
139+
children: React.ReactNode
140+
}) {
141+
const t = useTheme()
142+
const pal = usePalette('default')
143+
return (
144+
<Layout.Center testID="profileStatusErrorScreen">
145+
<Layout.Header.Outer>
146+
<Layout.Header.BackButton />
147+
<Layout.Header.Content>
148+
<Layout.Header.TitleText>
149+
<Trans>Profile</Trans>
150+
</Layout.Header.TitleText>
151+
</Layout.Header.Content>
152+
<Layout.Header.Slot />
153+
</Layout.Header.Outer>
154+
<View style={[a.px_xl, a.py_2xl]}>
155+
<View style={[a.mb_md, a.align_center]}>
156+
<View
157+
style={[
158+
a.rounded_full,
159+
{width: 50, height: 50},
160+
a.align_center,
161+
a.justify_center,
162+
{backgroundColor: t.palette.contrast_950},
163+
]}>
164+
<FontAwesomeIcon
165+
icon="exclamation"
166+
style={pal.textInverted as FontAwesomeIconStyle}
167+
size={24}
168+
/>
169+
</View>
170+
</View>
171+
<Text style={[a.text_center, a.font_bold, a.text_2xl, a.mb_md]}>
172+
{title}
173+
</Text>
174+
<View style={[a.align_center]}>{children}</View>
175+
</View>
176+
</Layout.Center>
177+
)
178+
}

src/state/queries/microcosm-fallback.ts

Lines changed: 0 additions & 190 deletions
Original file line numberDiff line numberDiff line change
@@ -117,35 +117,6 @@ export async function fetchConstellationCounts(
117117
}
118118
}
119119

120-
/**
121-
* Detect if error is AppView-related (suspended user, not found, etc.)
122-
*
123-
* IMPORTANT: This determines whether fallback should be triggered.
124-
* We should NOT trigger fallback for intentional blocking/privacy errors.
125-
*/
126-
export function isAppViewError(error: any): boolean {
127-
if (!error) return false
128-
129-
const msg = error.message?.toLowerCase() || ''
130-
131-
// Do NOT trigger fallback for intentional blocking
132-
// "Requester has blocked actor" means the user intentionally blocked someone
133-
// This is NOT an AppView outage - it's privacy enforcement
134-
if (msg.includes('blocked actor')) return false
135-
if (msg.includes('requester has blocked')) return false
136-
if (msg.includes('blocking')) return false
137-
138-
// Check HTTP status codes
139-
if (error.status === 400 || error.status === 404) return true
140-
141-
// Check error messages for actual AppView issues
142-
if (msg.includes('not found')) return true
143-
if (msg.includes('suspended')) return true
144-
if (msg.includes('could not locate')) return true
145-
146-
return false
147-
}
148-
149120
/**
150121
* Build viewer state for fallback profiles
151122
* Checks local block/mute cache to populate viewer relationship fields
@@ -180,22 +151,6 @@ function buildViewerState(
180151
return viewer
181152
}
182153

183-
/**
184-
* Build a BlockedPost stub to match AppView behavior
185-
* Returns the same structure as app.bsky.feed.defs#blockedPost
186-
*/
187-
function buildBlockedPost(uri: string): any {
188-
return {
189-
$type: 'app.bsky.feed.defs#blockedPost',
190-
uri,
191-
blocked: true,
192-
author: {
193-
did: new AtUri(uri).host,
194-
handle: '',
195-
},
196-
}
197-
}
198-
199154
/**
200155
* Build a BlockedProfile stub to match AppView behavior
201156
* Returns a minimal profile view indicating the profile is blocked
@@ -272,60 +227,6 @@ export async function buildSyntheticProfileView(
272227
}
273228
}
274229

275-
/**
276-
* Build synthetic PostView from PDS + Constellation data
277-
*
278-
* SECURITY: Inherits block/mute checking from buildSyntheticProfileView.
279-
* If the author is blocked, returns BlockedPost stub to match AppView behavior.
280-
*/
281-
export async function buildSyntheticPostView(
282-
queryClient: QueryClient,
283-
atUri: string,
284-
authorDid: string,
285-
authorHandle: string,
286-
): Promise<any> {
287-
// Check if author is blocked first, before fetching any data
288-
const viewer = buildViewerState(queryClient, authorDid)
289-
if (viewer.blocking) {
290-
console.log('[Fallback] Returning blocked post stub for', atUri)
291-
return buildBlockedPost(atUri)
292-
}
293-
294-
const record = await fetchRecordViaSlingshot(atUri)
295-
if (!record) return null
296-
297-
const counts = await fetchConstellationCounts(atUri)
298-
// Build profile view (will return basic info since not blocked)
299-
const profileView = await buildSyntheticProfileView(
300-
queryClient,
301-
authorDid,
302-
authorHandle,
303-
)
304-
305-
// Get viewer state for the post itself (like, repost status)
306-
// For now we just use empty viewer as we can't determine these from PDS
307-
const postViewer = {}
308-
309-
const embeds = resolveRecordEmbeds(authorDid, record.value)
310-
311-
return {
312-
$type: 'app.bsky.feed.defs#postView',
313-
uri: atUri,
314-
cid: record.cid,
315-
author: profileView,
316-
record: record.value,
317-
embed: embeds.length > 0 ? embeds[0] : undefined,
318-
indexedAt: record.value?.createdAt || new Date().toISOString(),
319-
likeCount: counts.likeCount,
320-
repostCount: counts.repostCount,
321-
replyCount: counts.replyCount,
322-
quoteCount: counts.quoteCount,
323-
viewer: postViewer, // Post-level viewer state (likes, reposts, etc)
324-
labels: [],
325-
__fallbackMode: true, // Mark as fallback data
326-
}
327-
}
328-
329230
// Blacksky moderation account DID - labels from this account are authoritative
330231
const BLACKSKY_MOD_DID = 'did:plc:d2mkddsbmnrgr3domzg5qexf'
331232

@@ -581,94 +482,3 @@ export async function buildSyntheticEmbedViewRecord(
581482
__fallbackMode: true,
582483
}
583484
}
584-
585-
/**
586-
* Build synthetic feed page from PDS data
587-
* This is used for infinite queries that need paginated results
588-
*
589-
* IMPORTANT: This function bypasses Slingshot and fetches directly from the user's PDS
590-
* because Slingshot does not support the `com.atproto.repo.listRecords` endpoint needed
591-
* for bulk record fetching.
592-
*
593-
* Trade-off: No caching benefit from Slingshot, but we can still provide author feed
594-
* functionality for AppView-suspended users.
595-
*
596-
* Each post in the feed will trigger:
597-
* - 1 record fetch via Slingshot (for the full post data, cached)
598-
* - 1 Constellation request (for engagement counts)
599-
* - Profile fetch (cached after first request)
600-
*
601-
* SECURITY: Respects block/mute relationships. If author is blocked, the feed will be empty.
602-
*/
603-
export async function buildSyntheticFeedPage(
604-
queryClient: QueryClient,
605-
did: string,
606-
pdsUrl: string,
607-
cursor?: string,
608-
): Promise<any> {
609-
// Check if this author is blocked before fetching any posts
610-
const viewer = buildViewerState(queryClient, did)
611-
if (viewer.blocking) {
612-
console.log('[Fallback] Author is blocked, returning empty feed for', did)
613-
// Return empty feed to prevent viewing blocked user's posts via fallback
614-
return {
615-
feed: [],
616-
cursor: undefined,
617-
__fallbackMode: true,
618-
__blocked: true,
619-
}
620-
}
621-
622-
try {
623-
const limit = 25
624-
const cursorParam = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''
625-
626-
// Fetch posts directly from PDS using com.atproto.repo.listRecords
627-
// NOTE: This bypasses Slingshot because listRecords is not available there
628-
const url = `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.feed.post&limit=${limit}${cursorParam}`
629-
const res = await fetch(url)
630-
631-
if (!res.ok) {
632-
console.error(
633-
'[Fallback] Failed to fetch author feed from PDS:',
634-
res.statusText,
635-
)
636-
return null
637-
}
638-
639-
const data = await res.json()
640-
641-
// Build FeedViewPost array from records
642-
const feed = await Promise.all(
643-
data.records.map(async (record: any) => {
644-
const postView = await buildSyntheticPostView(
645-
queryClient,
646-
record.uri,
647-
did,
648-
'', // Handle will be resolved in buildSyntheticPostView
649-
)
650-
651-
if (!postView) return null
652-
653-
// Wrap in FeedViewPost format
654-
return {
655-
$type: 'app.bsky.feed.defs#feedViewPost',
656-
post: postView,
657-
feedContext: undefined,
658-
}
659-
}),
660-
)
661-
662-
// Filter out null results
663-
const validFeed = feed.filter(item => item !== null)
664-
665-
return {
666-
feed: validFeed,
667-
cursor: data.cursor,
668-
__fallbackMode: true, // Mark as fallback data
669-
}
670-
} catch (e) {
671-
console.error('[Fallback] Failed to build synthetic feed page:', e)
672-
return null
673-
}
674-
}

0 commit comments

Comments
 (0)