1+ import { AtUri } from '@atproto/api'
2+ import { type QueryClient } from '@tanstack/react-query'
3+
4+ import { isDidBlocked } from './my-blocked-accounts'
5+ import { isDidMuted } from './my-muted-accounts'
6+
17const SLINGSHOT_URL = 'https://slingshot.microcosm.blue'
28const CONSTELLATION_URL = 'https://constellation.microcosm.blue'
39
@@ -109,23 +115,106 @@ export async function fetchConstellationCounts(
109115
110116/**
111117 * Detect if error is AppView-related (suspended user, not found, etc.)
118+ *
119+ * IMPORTANT: This determines whether fallback should be triggered.
120+ * We should NOT trigger fallback for intentional blocking/privacy errors.
112121 */
113122export function isAppViewError ( error : any ) : boolean {
114123 if ( ! error ) return false
115124
125+ const msg = error . message ?. toLowerCase ( ) || ''
126+
127+ // Do NOT trigger fallback for intentional blocking
128+ // "Requester has blocked actor" means the user intentionally blocked someone
129+ // This is NOT an AppView outage - it's privacy enforcement
130+ if ( msg . includes ( 'blocked actor' ) ) return false
131+ if ( msg . includes ( 'requester has blocked' ) ) return false
132+ if ( msg . includes ( 'blocking' ) ) return false
133+
116134 // Check HTTP status codes
117135 if ( error . status === 400 || error . status === 404 ) return true
118136
119- // Check error messages
120- // TODO: see if there is an easy way to source error messages from the appview
121- const msg = error . message ?. toLowerCase ( ) || ''
137+ // Check error messages for actual AppView issues
122138 if ( msg . includes ( 'not found' ) ) return true
123139 if ( msg . includes ( 'suspended' ) ) return true
124140 if ( msg . includes ( 'could not locate' ) ) return true
125141
126142 return false
127143}
128144
145+ /**
146+ * Build viewer state for fallback profiles
147+ * Checks local block/mute cache to populate viewer relationship fields
148+ */
149+ function buildViewerState (
150+ queryClient : QueryClient ,
151+ did : string ,
152+ ) : {
153+ blocking ?: string
154+ blockedBy ?: boolean
155+ muted ?: boolean
156+ mutedByList ?: boolean
157+ following ?: string
158+ followedBy ?: string
159+ } {
160+ const blockStatus = isDidBlocked ( queryClient , did )
161+ const muteStatus = isDidMuted ( queryClient , did )
162+
163+ const viewer : any = { }
164+
165+ if ( blockStatus . blocked ) {
166+ viewer . blocking = blockStatus . blockUri
167+ }
168+
169+ if ( muteStatus . muted ) {
170+ viewer . muted = true
171+ }
172+
173+ // We can't determine blockedBy, mutedByList, following, or followedBy from PDS alone
174+ // These require AppView indexing, so we leave them undefined
175+
176+ return viewer
177+ }
178+
179+ /**
180+ * Build a BlockedPost stub to match AppView behavior
181+ * Returns the same structure as app.bsky.feed.defs#blockedPost
182+ */
183+ function buildBlockedPost ( uri : string ) : any {
184+ return {
185+ $type : 'app.bsky.feed.defs#blockedPost' ,
186+ uri,
187+ blocked : true ,
188+ author : {
189+ did : new AtUri ( uri ) . host ,
190+ handle : '' ,
191+ } ,
192+ }
193+ }
194+
195+ /**
196+ * Build a BlockedProfile stub to match AppView behavior
197+ * Returns a minimal profile view indicating the profile is blocked
198+ */
199+ function buildBlockedProfileView ( did : string ) : any {
200+ return {
201+ $type : 'app.bsky.actor.defs#profileViewBasic' ,
202+ did,
203+ handle : '' ,
204+ displayName : '' ,
205+ avatar : undefined ,
206+ viewer : {
207+ blocking : 'blocked' ,
208+ blockedBy : false ,
209+ muted : false ,
210+ mutedByList : false ,
211+ } ,
212+ labels : [ ] ,
213+ __fallbackMode : true ,
214+ __blocked : true , // Internal marker
215+ }
216+ }
217+
129218/**
130219 * Build synthetic ProfileViewDetailed from PDS data
131220 *
@@ -135,11 +224,25 @@ export function isAppViewError(error: any): boolean {
135224 * LIMITATION: Profile-level aggregate counts (followers, following, posts) are not
136225 * available from Slingshot or Constellation and are set to undefined. These would
137226 * require AppView-style indexing infrastructure.
227+ *
228+ * SECURITY: Checks local block/mute state to maintain privacy preferences.
229+ * Throws error if user has blocked the profile to prevent privacy bypass.
138230 */
139231export async function buildSyntheticProfileView (
232+ queryClient : QueryClient ,
140233 did : string ,
141234 handle : string ,
142235) : Promise < any > {
236+ // Check viewer state before fetching profile data
237+ const viewer = buildViewerState ( queryClient , did )
238+
239+ // If user has blocked this profile, return BlockedProfile stub
240+ // This matches AppView behavior and allows existing UI to handle it correctly
241+ if ( viewer . blocking ) {
242+ console . log ( '[Fallback] Returning blocked profile stub for' , did )
243+ return buildBlockedProfileView ( did )
244+ }
245+
143246 const profileUri = `at://${ did } /app.bsky.actor.profile/self`
144247 const record = await fetchRecordViaSlingshot ( profileUri )
145248
@@ -159,25 +262,45 @@ export async function buildSyntheticProfileView(
159262 followsCount : undefined , // Not available from PDS or Constellation
160263 postsCount : undefined , // Not available from PDS or Constellation
161264 indexedAt : new Date ( ) . toISOString ( ) ,
162- viewer : { } ,
265+ viewer, // Use viewer state with block/mute info
163266 labels : [ ] ,
164267 __fallbackMode : true , // Mark as fallback data
165268 }
166269}
167270
168271/**
169272 * Build synthetic PostView from PDS + Constellation data
273+ *
274+ * SECURITY: Inherits block/mute checking from buildSyntheticProfileView.
275+ * If the author is blocked, returns BlockedPost stub to match AppView behavior.
170276 */
171277export async function buildSyntheticPostView (
278+ queryClient : QueryClient ,
172279 atUri : string ,
173280 authorDid : string ,
174281 authorHandle : string ,
175282) : Promise < any > {
283+ // Check if author is blocked first, before fetching any data
284+ const viewer = buildViewerState ( queryClient , authorDid )
285+ if ( viewer . blocking ) {
286+ console . log ( '[Fallback] Returning blocked post stub for' , atUri )
287+ return buildBlockedPost ( atUri )
288+ }
289+
176290 const record = await fetchRecordViaSlingshot ( atUri )
177291 if ( ! record ) return null
178292
179293 const counts = await fetchConstellationCounts ( atUri )
180- const profileView = await buildSyntheticProfileView ( authorDid , authorHandle )
294+ // Build profile view (will return basic info since not blocked)
295+ const profileView = await buildSyntheticProfileView (
296+ queryClient ,
297+ authorDid ,
298+ authorHandle ,
299+ )
300+
301+ // Get viewer state for the post itself (like, repost status)
302+ // For now we just use empty viewer as we can't determine these from PDS
303+ const postViewer = { }
181304
182305 return {
183306 $type : 'app.bsky.feed.defs#postView' ,
@@ -189,7 +312,7 @@ export async function buildSyntheticPostView(
189312 likeCount : counts . likeCount ,
190313 repostCount : counts . repostCount ,
191314 replyCount : counts . replyCount ,
192- viewer : { } ,
315+ viewer : postViewer , // Post-level viewer state (likes, reposts, etc)
193316 labels : [ ] ,
194317 __fallbackMode : true , // Mark as fallback data
195318 }
@@ -210,12 +333,28 @@ export async function buildSyntheticPostView(
210333 * - 1 record fetch via Slingshot (for the full post data, cached)
211334 * - 1 Constellation request (for engagement counts)
212335 * - Profile fetch (cached after first request)
336+ *
337+ * SECURITY: Respects block/mute relationships. If author is blocked, the feed will be empty.
213338 */
214339export async function buildSyntheticFeedPage (
340+ queryClient : QueryClient ,
215341 did : string ,
216342 pdsUrl : string ,
217343 cursor ?: string ,
218344) : Promise < any > {
345+ // Check if this author is blocked before fetching any posts
346+ const viewer = buildViewerState ( queryClient , did )
347+ if ( viewer . blocking ) {
348+ console . log ( '[Fallback] Author is blocked, returning empty feed for' , did )
349+ // Return empty feed to prevent viewing blocked user's posts via fallback
350+ return {
351+ feed : [ ] ,
352+ cursor : undefined ,
353+ __fallbackMode : true ,
354+ __blocked : true ,
355+ }
356+ }
357+
219358 try {
220359 const limit = 25
221360 const cursorParam = cursor ? `&cursor=${ encodeURIComponent ( cursor ) } ` : ''
@@ -239,6 +378,7 @@ export async function buildSyntheticFeedPage(
239378 const feed = await Promise . all (
240379 data . records . map ( async ( record : any ) => {
241380 const postView = await buildSyntheticPostView (
381+ queryClient ,
242382 record . uri ,
243383 did ,
244384 '' , // Handle will be resolved in buildSyntheticPostView
0 commit comments