Skip to content

Commit 3fcc4ac

Browse files
authored
Merge pull request #26 from afbase/blocks-and-mutes
add blocking and muting functionality
2 parents 6837841 + 89540b7 commit 3fcc4ac

File tree

4 files changed

+257
-20
lines changed

4 files changed

+257
-20
lines changed

src/state/queries/microcosm-fallback.ts

Lines changed: 146 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
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+
17
const SLINGSHOT_URL = 'https://slingshot.microcosm.blue'
28
const 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
*/
113122
export 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
*/
139231
export 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
*/
171277
export 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
*/
214339
export 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

src/state/queries/my-blocked-accounts.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import {AppBskyActorDefs, AppBskyGraphGetBlocks} from '@atproto/api'
1+
import {type AppBskyActorDefs, type AppBskyGraphGetBlocks} from '@atproto/api'
22
import {
3-
InfiniteData,
4-
QueryClient,
5-
QueryKey,
3+
type InfiniteData,
4+
type QueryClient,
5+
type QueryKey,
66
useInfiniteQuery,
77
} from '@tanstack/react-query'
88

@@ -56,3 +56,36 @@ export function* findAllProfilesInQueryData(
5656
}
5757
}
5858
}
59+
60+
/**
61+
* Check if a DID is blocked by the current user
62+
* Used by fallback mechanism to maintain block privacy
63+
*/
64+
export function isDidBlocked(
65+
queryClient: QueryClient,
66+
did: string,
67+
): {blocked: boolean; blockUri?: string} {
68+
const queryDatas = queryClient.getQueriesData<
69+
InfiniteData<AppBskyGraphGetBlocks.OutputSchema>
70+
>({
71+
queryKey: [RQKEY_ROOT],
72+
})
73+
74+
for (const [_queryKey, queryData] of queryDatas) {
75+
if (!queryData?.pages) {
76+
continue
77+
}
78+
for (const page of queryData?.pages) {
79+
for (const block of page.blocks) {
80+
if (block.did === did && block.viewer?.blocking) {
81+
return {
82+
blocked: true,
83+
blockUri: block.viewer.blocking,
84+
}
85+
}
86+
}
87+
}
88+
}
89+
90+
return {blocked: false}
91+
}

src/state/queries/my-muted-accounts.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import {AppBskyActorDefs, AppBskyGraphGetMutes} from '@atproto/api'
1+
import {type AppBskyActorDefs, type AppBskyGraphGetMutes} from '@atproto/api'
22
import {
3-
InfiniteData,
4-
QueryClient,
5-
QueryKey,
3+
type InfiniteData,
4+
type QueryClient,
5+
type QueryKey,
66
useInfiniteQuery,
77
} from '@tanstack/react-query'
88

@@ -56,3 +56,33 @@ export function* findAllProfilesInQueryData(
5656
}
5757
}
5858
}
59+
60+
/**
61+
* Check if a DID is muted by the current user
62+
* Used by fallback mechanism to maintain mute preferences
63+
*/
64+
export function isDidMuted(
65+
queryClient: QueryClient,
66+
did: string,
67+
): {muted: boolean} {
68+
const queryDatas = queryClient.getQueriesData<
69+
InfiniteData<AppBskyGraphGetMutes.OutputSchema>
70+
>({
71+
queryKey: [RQKEY_ROOT],
72+
})
73+
74+
for (const [_queryKey, queryData] of queryDatas) {
75+
if (!queryData?.pages) {
76+
continue
77+
}
78+
for (const page of queryData?.pages) {
79+
for (const mute of page.mutes) {
80+
if (mute.did === did) {
81+
return {muted: true}
82+
}
83+
}
84+
}
85+
}
86+
87+
return {muted: false}
88+
}

0 commit comments

Comments
 (0)