forked from bluesky-social/social-app
-
-
Notifications
You must be signed in to change notification settings - Fork 4
UX Issues with profile privacy, unfollows, mutes #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
afbase
wants to merge
1
commit into
blacksky-algorithms:main
Choose a base branch
from
afbase:ux-issues
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
…authenticated self-label to their profile
2. The AppView returns the profile data WITH the label
3. The patched @atproto/api library was always skipping this label in moderation checks (line 70 in decision.js)
4. So the moderation system never created a "blur" for it
5. The ScreenHider component never triggered
6. Profile was visible!
fallback mechanism only works with logged users. and checks the label
for privacy no-authenticated label.
This resolves the issue folkloringjamaica.blacksky.app was experiencing
@ind3fatigable.online was experiencing mute and follow issues which
surrounded cache invalidation issues that was resolved.
I tested with the follow:
Great! Now for Issue 2 (Unfollows/Mutes Not Persisting), here's what you should test:
Test Plan for Issue 2
Test 1: Unfollow Persistence
1. Follow a user (if not already following)
2. Unfollow them by clicking the "Following" button
3. ✅ Expected: Button immediately changes to "Follow"
4. Wait 1-2 seconds (for the AppView indexing lag compensation to kick in)
5. Refresh the page (hard refresh or navigate away and back)
6. ✅ Expected: Still shows "Follow" button (unfollow persisted)
7. Bonus: Check that their posts no longer appear in your Following feed
Test 2: Follow/Unfollow Multiple Times Rapidly
1. Follow a user
2. Immediately unfollow them
3. Repeat this 2-3 times quickly (stress test the mutation queue)
4. Refresh the page
5. ✅ Expected: Final state matches what you see (if you ended on "Follow", it stays "Follow" after refresh)
Test 3: Mute Persistence
1. Navigate to a user's profile
2. Mute them (via the three-dot menu → Mute)
3. ✅ Expected: Profile should indicate they're muted
4. Refresh the page
5. ✅ Expected: Still shows as muted
6. Go to your home feed
7. ✅ Expected: Their posts should no longer appear in your feed
Test 4: Unmute Persistence
1. Unmute a previously muted user
2. Refresh the page
3. ✅ Expected: No longer shows as muted
4. Check your feed
5. ✅ Expected: Their posts can now appear again
Test 5: Cross-Tab Consistency (Advanced)
1. Open the same profile in two browser tabs
2. In Tab 1: Unfollow the user
3. In Tab 2: Refresh the page after 1-2 seconds
4. ✅ Expected: Tab 2 should now show "Follow" button (cache was invalidated)
What Was Fixed:
- Before: Cache wasn't being invalidated, so refreshing would show stale data from cache
- After:
- Immediate cache invalidation on mutation success
- 500ms delayed refetch to handle AppView indexing lag
- Comprehensive invalidation of profile, feed, and related caches
If Tests Fail:
If you still see the old behavior (unfollows/mutes reverting after refresh), let me know and we can investigate further. The fixes should handle:
- ✅ Cache invalidation
- ✅ AppView indexing lag
- ✅ Optimistic updates staying in sync with server state
The issue identified was:
**Date:** November 2, 2025
**Scope:** Analysis of fallback mechanism impact on social interactions, user safety, and moderation
**Repository:** blacksky.community
---
**User-Reported Issue: "Unfollows don't stick, mutes don't persist"**
After comprehensive investigation, the root cause is **NOT the fallback mechanism** as initially suspected. The actual causes are:
1. **Missing cache invalidation** in the unfollow mutation (`useProfileUnfollowMutation`)
2. **Incomplete cache invalidation** in the mute mutation (`useProfileMuteMutation`)
3. **AppView indexing lag** creating race conditions with optimistic UI updates
4. These bugs affect **BOTH** fallback mode AND normal AppView queries
**Critical Safety Finding:**
The fallback mechanism has a **CRITICAL SAFETY GAP** regarding moderation labeler subscriptions:
- 🔴 **HIGH SEVERITY**: Moderation labels are completely bypassed in fallback mode
- Fallback always returns `labels: []` regardless of user's labeler subscriptions
- Users who subscribe to safety labelers (CSAM, self-harm, harassment filters) will see harmful content WITHOUT warnings when fallback is active
- Vulnerable populations are at increased risk
**Fallback Safety Assessment by Category:**
| Category | Safety Rating | Notes |
|----------|---------------|-------|
| Blocks | ✅ Excellent | Cache-first approach prevents privacy bypasses |
| Mutes | ✅ Good | Cache-first approach maintains privacy |
| Follows/Unfollows | ⚠️ Medium | Incomplete but not dangerous |
| Moderation Labels | 🔴 Critical | Completely bypassed, major safety issue |
| Lists | ⚠️ Medium | Mixed functionality |
| Likes/Reposts | ✅ Good | Returns empty, no safety impact |
The fallback mechanism is a **well-designed feature with excellent safety properties for its intended use case** (viewing suspended user content and handling AppView outages). It successfully prevents privacy bypasses from blocks and mutes.
However:
- It's **not causing the reported unfollow/mute bugs** (those are cache invalidation issues)
- It **does have a critical moderation label gap** that needs immediate attention
- The transparent design (hiding fallback from users) creates UX confusion when data is incomplete
---
**Storage Location:** AppView database only (NOT stored in PDS)
**API:** `app.bsky.graph.muteActor` and `app.bsky.graph.unmuteActor` (procedures, not records)
**Lexicon:** `/atproto/lexicons/app/bsky/graph/muteActor.json`
```
User mutes someone:
1. Client calls agent.mute(did)
2. Request goes to AppView: app.bsky.graph.muteActor
3. Stored in AppView's PostgreSQL database
4. NEVER written to user's PDS repository
5. Retrieved via app.bsky.graph.getMutes
```
**Privacy Rationale:** Mutes are described as "private in Bluesky" - they never leave the AppView's database, ensuring the muted user cannot discover they've been muted by inspecting your PDS.
**Code:** `src/state/queries/microcosm-fallback.ts:149-177`
```typescript
function buildViewerState(queryClient: QueryClient, did: string) {
const muteStatus = isDidMuted(queryClient, did)
const viewer: any = {}
if (muteStatus.muted) {
viewer.muted = true
}
// Mute status comes from React Query cache, not PDS
return viewer
}
```
**Function:** `src/state/queries/my-muted-accounts.ts:64-88`
```typescript
export function isDidMuted(queryClient: QueryClient, did: string): {muted: boolean} {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyGraphGetMutes.OutputSchema>
>({
queryKey: [RQKEY_ROOT],
})
// Searches through cached mute list from app.bsky.graph.getMutes
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData.pages) {
if (page.mutes.find(m => m.did === did)) {
return {muted: true}
}
}
}
return {muted: false}
}
```
The fallback checks the local React Query cache populated by previous `getMutes` calls. This means:
✅ **Safety preserved:** If you've muted someone and that data is in cache, they stay muted even in fallback mode
✅ **Privacy maintained:** Muted users don't see different behavior that would reveal the mute
⚠️ **UX issue:** If cache is stale/empty and fallback triggers, recently muted users may appear unmuted
**Code:** `src/state/queries/profile.ts:417-434`
```typescript
export function useProfileMuteMutation() {
const queryClient = useQueryClient()
return useMutation<void, Error, {did: string}>({
mutationFn: async ({did}) => {
const agent = getAgent()
await agent.mute(did)
},
onSuccess(_, {did}) {
// ✅ Invalidates mute list cache
queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
// ❌ MISSING: Doesn't invalidate profile cache for the muted DID
// ❌ MISSING: Doesn't invalidate feed caches that might contain their posts
// ❌ MISSING: Doesn't call resetProfilePostsQueries()
},
})
}
```
**Compare with block mutation** which works correctly:
```typescript
export function useProfileBlockMutation() {
const queryClient = useQueryClient()
return useMutation<void, Error, {did: string}>({
mutationFn: async ({did}) => {
const agent = getAgent()
const result = await agent.app.bsky.graph.block.create(
{repo: agent.session!.did},
{subject: did, createdAt: new Date().toISOString()},
)
return result
},
onSuccess(data, {did}) {
// ✅ Invalidates block list
queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()})
// ✅ Invalidates profile and all related caches
resetProfilePostsQueries(queryClient, did, 1000)
},
})
}
```
```
1. User mutes @badactor.com
2. Mutation succeeds, cache updated: {queryKey: ['my-muted-accounts'], data: [..., {did: 'did:plc:badactor'}]}
3. User continues browsing
4. Feed still shows @badactor.com's posts (feed cache not invalidated)
5. User clicks on profile → Shows as unmuted (profile cache not invalidated)
6. User confused: "The mute didn't stick!"
```
The fallback mechanism is **innocent** here. Whether fallback is active or not:
- The mute **did succeed** at the AppView
- The cache invalidation bug causes stale data to display
- The user sees the same bug in both modes
**Fallback doesn't make it worse** because `isDidMuted()` checks the cache first, which was already updated by the mutation.
**P0 - Fix the actual bug:**
```typescript
onSuccess(_, {did}) {
queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
// Add comprehensive cache invalidation:
resetProfilePostsQueries(queryClient, did, 1000)
queryClient.invalidateQueries({queryKey: ['profile', did]})
queryClient.invalidateQueries({queryKey: ['post-feed']})
queryClient.invalidateQueries({queryKey: ['notifications']})
}
```
---
**Storage Location:** User's PDS repository (records)
**Collection:** `app.bsky.graph.follow`
**Lexicon:** `/atproto/lexicons/app/bsky/graph/follow.json`
```json
{
"lexicon": 1,
"id": "app.bsky.graph.follow",
"defs": {
"main": {
"type": "record",
"description": "Record declaring a social 'follow' relationship...",
"key": "tid",
"record": {
"type": "object",
"required": ["subject", "createdAt"],
"properties": {
"subject": {
"type": "string",
"format": "did",
"description": "DID of the account to be followed"
},
"createdAt": {
"type": "string",
"format": "datetime"
}
}
}
}
}
}
```
```
User follows someone:
1. Client calls agent.follow(did)
2. Creates record: at://[your-did]/app.bsky.graph.follow/[tid]
3. Record value: {subject: target-did, createdAt: timestamp}
4. Written to YOUR PDS repository
5. PDS notifies AppView of new record
6. AppView indexes the follow relationship
User unfollows someone:
1. Client calls agent.deleteFollow(followUri)
2. DELETES the record from YOUR PDS
3. PDS notifies AppView of record deletion
4. AppView removes the follow relationship from index
```
**Code:** `src/state/queries/microcosm-fallback.ts:173-174`
```typescript
// We can't determine blockedBy, mutedByList, following, or followedBy from PDS alone
// These require AppView indexing, so we leave them undefined
```
The fallback **explicitly does not populate** `viewer.following` or `viewer.followedBy` fields.
**Why?** To determine if you're following someone, the fallback would need to:
```typescript
// Hypothetical implementation (NOT implemented):
async function getFollowingStatus(did: string, targetDid: string): Promise<string | undefined> {
// 1. Query YOUR PDS for all YOUR follow records
const pdsUrl = await getPdsUrl(did)
const response = await fetch(
`${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=app.bsky.graph.follow&limit=100`
)
const {records} = await response.json()
// 2. Filter through ALL your follows to find the target
const followRecord = records.find(r => r.value.subject === targetDid)
// 3. If you have 1000+ follows, need to paginate...
return followRecord?.uri
}
```
This is **expensive** (requires listing and filtering potentially thousands of records) and **not implemented** in the current fallback.
**Code:** `src/state/queries/profile.ts:354-364`
```typescript
export function useProfileUnfollowMutation(logContext: LogEvents['profile:unfollow']['logContext']) {
const queryClient = useQueryClient()
return useMutation<void, Error, {did: string; followUri: string}>({
mutationFn: async ({followUri}) => {
track('Profile:Unfollow', logContext)
const agent = getAgent()
return await agent.deleteFollow(followUri)
},
// ❌ NO onSuccess CALLBACK AT ALL
// ❌ NO queryClient.invalidateQueries()
// ❌ NO cache updates
})
}
```
**Compare with follow mutation:**
```typescript
export function useProfileFollowMutation(logContext: LogEvents['profile:follow']['logContext']) {
const queryClient = useQueryClient()
return useMutation<{uri: string}, Error, {did: string}>({
mutationFn: async ({did}) => {
track('Profile:Follow', logContext)
const agent = getAgent()
return await agent.follow(did)
},
onSuccess(data, {did}) {
// ✅ Has cache invalidation
resetProfilePostsQueries(queryClient, did, 1000)
},
})
}
```
The follow mutation has cache invalidation but unfollow doesn't!
```
1. User views @someone.com profile
- AppView returns: {following: "at://alice/app.bsky.graph.follow/abc123"}
- Cache stores this data: {queryKey: ['profile', 'did:plc:someone'], data: {..., viewer: {following: "..."}}}
2. User clicks "Unfollow"
- Optimistic update: Sets viewer.following = undefined
- UI shows: ✅ "Follow" button (unfollowed state)
- Mutation sends: agent.deleteFollow("at://alice/app.bsky.graph.follow/abc123")
- PDS: Deletes record successfully
- Mutation completes, NO cache invalidation
3. User refreshes page or navigates away and back
- Query checks cache: {queryKey: ['profile', 'did:plc:someone']}
- Cache still has OLD data: {following: "at://alice/..."}
- OR AppView query runs...
4. AppView indexing lag (50-500ms typical):
- AppView hasn't processed the delete yet
- Returns stale data: {following: "at://alice/..."}
- Cache updated with stale data
- UI shows: ❌ "Following" (looks like unfollow failed)
5. 2 seconds later, AppView finishes indexing
- But user already navigated away
- Cache not updated until next manual refresh
- User reports: "Unfollows don't stick"
```
The fallback mechanism is **NOT the cause** but can **amplify the confusion**:
**Scenario A: Normal mode (AppView indexing lag)**
```
Unfollow → Cache not invalidated → Query hits AppView → AppView stale → Shows "Following"
```
**Scenario B: Fallback mode triggered**
```
Unfollow → Cache not invalidated → Query tries AppView (fails/suspended) → Fallback triggered → Returns viewer: {} (no following field) → UI shows "Follow"
```
Ironically, **fallback mode might work better** because returning `{}` doesn't assert you're still following.
The timing of the fallback mechanism's introduction (commit `2dba3544eca8500691c8485da8e7538614645fb1`) coincided with when users started noticing the bug. However:
- The cache invalidation bug **existed before** the fallback
- The fallback made the bug **more visible** because it added another code path
- But the bug affects **both paths equally**
**P0 - Fix the actual bug:**
```typescript
export function useProfileUnfollowMutation(logContext: LogEvents['profile:unfollow']['logContext']) {
const queryClient = useQueryClient()
return useMutation<void, Error, {did: string; followUri: string}>({
mutationFn: async ({followUri}) => {
track('Profile:Unfollow', logContext)
const agent = getAgent()
return await agent.deleteFollow(followUri)
},
// Add cache invalidation:
onSuccess(_, {did}) {
resetProfilePostsQueries(queryClient, did, 1000)
},
})
}
```
**P1 - Handle AppView lag:**
```typescript
onSuccess(_, {did}) {
resetProfilePostsQueries(queryClient, did, 1000)
// Poll AppView to wait for indexing
setTimeout(() => {
queryClient.invalidateQueries({queryKey: ['profile', did]})
}, 500)
}
```
**P2 - Improve fallback completeness (optional):**
```typescript
// Implement expensive follow record querying in fallback
async function getFollowingStatus(queryClient, userDid, targetDid) {
const pdsUrl = await getPdsUrl(userDid)
const follows = await listRecords(pdsUrl, userDid, 'app.bsky.graph.follow')
return follows.find(r => r.value.subject === targetDid)?.uri
}
```
---
**Storage Location:** User's PDS repository (records)
**Collection:** `app.bsky.graph.block`
**Lexicon:** `/atproto/lexicons/app/bsky/graph/block.json`
Similar to follows, blocks are stored as records in your PDS:
```json
{
"lexicon": 1,
"id": "app.bsky.graph.block",
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"required": ["subject", "createdAt"],
"properties": {
"subject": {
"type": "string",
"format": "did"
},
"createdAt": {
"type": "string",
"format": "datetime"
}
}
}
}
}
}
```
**Lifecycle:**
```
Block someone:
at://[your-did]/app.bsky.graph.block/[tid] created with {subject: target-did}
Unblock someone:
at://[your-did]/app.bsky.graph.block/[tid] deleted
```
**Code:** `src/state/queries/microcosm-fallback.ts:149-177`
```typescript
function buildViewerState(queryClient: QueryClient, did: string) {
const blockStatus = isDidBlocked(queryClient, did)
const viewer: any = {}
if (blockStatus.blocked) {
viewer.blocking = blockStatus.blockUri
}
return viewer
}
```
**Code:** `src/state/queries/my-blocked-accounts.ts:64-93`
```typescript
export function isDidBlocked(
queryClient: QueryClient,
did: string,
): {blocked: boolean; blockUri?: string} {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyGraphGetBlocks.OutputSchema>
>({
queryKey: [RQKEY_ROOT],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) continue
for (const page of queryData.pages) {
const block = page.blocks.find(b => b.did === did)
if (block) {
return {
blocked: true,
blockUri: block.viewer?.blocking || '',
}
}
}
}
return {blocked: false}
}
```
The fallback checks the block list cache **before** constructing the profile. If a user is blocked:
1. `isDidBlocked()` returns `{blocked: true, blockUri: "..."}`
2. `viewer.blocking` is set in the synthetic response
3. **Critically:** The fallback then returns a `BlockedProfile` stub instead of real data
**Code:** `src/state/queries/microcosm-fallback.ts:179-196`
```typescript
if (viewer.blocking) {
// Return a minimal blocked profile without exposing user data
return {
did: profileDid,
handle: profile.handle,
avatar: profile.avatar,
viewer,
labels: [],
// NO posts, NO bio, NO banner
}
}
```
**Safety guarantees:**
✅ **Privacy preserved:** Blocked users don't see your content even in fallback mode
✅ **Consistency:** Blocking behavior is identical in fallback and normal modes
✅ **No bypass:** Cannot use fallback to "see around" a block
✅ **Bidirectional:** If they blocked you, fallback respects that too
**Code:** `src/state/queries/profile.ts:384-405`
```typescript
export function useProfileBlockMutation() {
const queryClient = useQueryClient()
return useMutation<void, Error, {did: string}>({
mutationFn: async ({did}) => {
const agent = getAgent()
const result = await agent.app.bsky.graph.block.create(
{repo: agent.session!.did},
{subject: did, createdAt: new Date().toISOString()},
)
return result
},
onSuccess(data, {did}) {
// ✅ Comprehensive cache invalidation
queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()})
resetProfilePostsQueries(queryClient, did, 1000)
},
})
}
```
Blocks work correctly because:
1. ✅ Cache invalidation is complete
2. ✅ Fallback checks cache first
3. ✅ Returns safe stub for blocked users
4. ✅ No privacy leaks
The block implementation demonstrates how the fallback mechanism **should work**:
- Cache-first checks
- Comprehensive invalidation
- Safe fallback behavior
- No user-reported bugs
**Recommendation:** Use blocks as the reference implementation when fixing follows and mutes.
---
**Storage Location:** User's PDS repository (records)
**Collection:** `app.bsky.feed.like`
**Lexicon:** `/atproto/lexicons/app/bsky/feed/like.json`
```
Like a post:
1. Create record: at://[your-did]/app.bsky.feed.like/[tid]
2. Record value: {subject: {uri: post-uri, cid: post-cid}, createdAt: timestamp}
3. PDS notifies AppView
4. AppView increments like count on post
Unlike a post:
1. Delete record at://[your-did]/app.bsky.feed.like/[tid]
2. AppView decrements like count
```
**Code:** `src/state/queries/microcosm-fallback.ts:413-426`
```typescript
function buildSyntheticFeedPost(
queryClient: QueryClient,
repo: string,
rkey: string,
postRecord: any,
): any {
// ... builds post object
return {
post: {
// ...
viewer: {
// ❌ No like status
// ❌ No repost status
// ❌ No threadMuted status
},
likeCount: engagementCounts?.like || 0, // ✅ From Constellation indexer
repostCount: engagementCounts?.repost || 0,
replyCount: engagementCounts?.reply || 0,
}
}
}
```
The fallback:
- ✅ Shows like **counts** (from Constellation indexer)
- ❌ Doesn't show if **you** liked it (`viewer.like` is undefined)
**User experience:**
```
Normal mode:
[❤️ 42] ← Red heart, you liked this, 42 total likes
Fallback mode:
[🤍 42] ← Grey heart, doesn't show you liked it, 42 total likes
```
**Safety impact:** ⚪ None - This is a cosmetic issue only
**Recommendation:** Low priority. Could implement by querying user's like records from PDS, but expensive and not critical.
---
**Storage Location:** User's PDS repository (records)
**Collection:** `app.bsky.feed.repost`
**Lexicon:** `/atproto/lexicons/app/bsky/feed/repost.json`
```
Repost:
1. Create record: at://[your-did]/app.bsky.feed.repost/[tid]
2. Record value: {subject: {uri: post-uri, cid: post-cid}, createdAt: timestamp}
Unrepost:
1. Delete the repost record
```
Same as likes:
- ✅ Shows repost **counts**
- ❌ Doesn't show if **you** reposted it
**Safety impact:** ⚪ None - Cosmetic only
---
**Storage Location:** User's PDS repository (records)
**Collection:** `app.bsky.feed.post` (with embed)
Quote posts are regular posts with an embedded post reference:
```json
{
"text": "Great point!",
"embed": {
"$type": "app.bsky.embed.record",
"record": {
"uri": "at://did:plc:xyz/app.bsky.feed.post/abc123",
"cid": "bafyrei..."
}
},
"createdAt": "2025-11-02T..."
}
```
**Quote posts work correctly in fallback mode** because:
1. The quote post itself is a regular post (fetched from PDS)
2. The embedded post is fetched recursively
3. The fallback can construct both posts
**Code:** `src/state/queries/microcosm-fallback.ts:490-520`
```typescript
if (embed.$type === 'app.bsky.embed.record') {
// Recursively fetch the quoted post
const quotedPost = await fetchPostFromPds(embed.record.uri)
// Build synthetic view for quoted post
// ...
}
```
✅ **Works correctly** - Quote posts display properly in fallback mode
**Safety impact:** ⚪ None - Functions as designed
---
**Storage Location:** Mixed
- **List definition:** PDS repository (`app.bsky.graph.list`)
- **List membership:** PDS repository (`app.bsky.graph.listitem`)
- **List subscriptions:** User preferences (AppView)
**Creating a list:**
```
1. Create record: at://[your-did]/app.bsky.graph.list/[tid]
2. Record value: {purpose: "app.bsky.graph.defs#modlist", name: "Spammers", ...}
```
**Adding someone to a list:**
```
1. Create record: at://[your-did]/app.bsky.graph.listitem/[tid]
2. Record value: {subject: target-did, list: list-uri}
```
**Subscribing to a list:**
```
1. Update preferences: app.bsky.actor.putPreferences
2. Add to labelers or moderation list subscriptions
```
**List definitions:** ✅ Can be read from PDS
**List membership:** ✅ Can be read from PDS
**List subscriptions:** ❌ Cannot be read (preferences are AppView-only)
**Impact:**
- Viewing someone else's list: ✅ Works
- Your own lists: ✅ Work
- Knowing if you're subscribed to a moderation list: ❌ Lost in fallback
**Safety impact:** ⚠️ Medium
If a user subscribes to a moderation list (e.g., "Known Spammers") and fallback triggers:
- The subscription information is lost
- Posts from list members appear without moderation
- Similar to the labeler subscription issue (see Section 2)
---
**Storage Location:** User preferences (AppView only)
**API:** `app.bsky.actor.getPreferences` / `app.bsky.actor.putPreferences`
```
1. User subscribes to a labeler (e.g., CSAM filter)
- Stored in preferences: {labelersPref: [{did: "did:plc:labeler"}]}
2. When viewing content:
- AppView checks: Which labelers is this user subscribed to?
- Queries those labeler services for labels on the content
- Returns labels: [{val: "csam", src: "did:plc:labeler"}]
- Client applies moderation: blur/hide/warn
3. User's subscriptions stored in AppView preferences, NOT PDS
```
🔴 **CRITICAL SAFETY GAP**
**Code:** `src/state/queries/microcosm-fallback.ts:149-177`
```typescript
function buildSyntheticProfileView(/* ... */) {
return {
did: profileDid,
handle: profile.handle,
// ...
labels: [], // ❌ ALWAYS EMPTY
viewer: buildViewerState(queryClient, did),
}
}
```
**Code:** `src/state/queries/microcosm-fallback.ts:413-450`
```typescript
function buildSyntheticFeedPost(/* ... */) {
return {
post: {
uri,
cid: postRecord.cid,
// ...
labels: [], // ❌ ALWAYS EMPTY
// ...
}
}
}
```
**The fallback NEVER:**
1. Queries user preferences to see which labelers they subscribe to
2. Queries labeler services for labels
3. Populates the `labels` array
**Result:** All content appears with `labels: []` regardless of actual labels or user subscriptions.
---
**Question:** If a user blocks someone, can the fallback show them as unblocked?
**Answer:** No. The cache-first check prevents this.
```typescript
const blockStatus = isDidBlocked(queryClient, did)
if (blockStatus.blocked) {
viewer.blocking = blockStatus.blockUri
// Return BlockedProfile stub
}
```
**Safety guarantees:**
- Blocked users stay blocked
- Content is not exposed
- Privacy is maintained
- No bypass possible
**Question:** If a user mutes someone, can that person's content still appear?
**Answer:** Not if the cache is populated. The cache-first check works the same as blocks.
```typescript
const muteStatus = isDidMuted(queryClient, did)
if (muteStatus.muted) {
viewer.muted = true
}
```
**Caveat:** If the mute cache is empty/stale, newly muted users might appear in fallback mode. However:
- This is a UX bug, not a safety breach
- The user's privacy isn't compromised (muted users don't know they're muted)
- It's annoying but not dangerous
**Question:** If a user unfollows someone, can they still see them as followed?
**Answer:** Yes, due to the cache invalidation bug, but this is not a safety issue.
**Impact:**
- User confusion
- UI/UX problem
- Not a privacy or safety breach
AT Protocol's moderation system works via **labelers** (also called moderation services):
1. **Labelers** are services that apply labels to content
2. Labels indicate: `spam`, `nsfw`, `sexual`, `graphic-media`, `csam`, `self-harm`, etc.
3. **Users subscribe** to labelers they trust
4. **Clients respect labels** by hiding, blurring, or warning
**Example flow:**
```
User subscribes to "Safety Labeler" (did:plc:safety)
↓
User views post at://bob/app.bsky.feed.post/xyz
↓
AppView queries Safety Labeler: "Any labels for this post?"
↓
Labeler responds: [{val: "csam", src: "did:plc:safety"}]
↓
AppView returns post with labels: [{val: "csam", ...}]
↓
Client sees label, HIDES the post
```
```
User subscribed to "Safety Labeler"
↓
User views suspended user's post
↓
AppView query fails → Fallback triggers
↓
Fallback fetches post from PDS directly
↓
Fallback returns: {labels: []} ❌ EMPTY
↓
Client sees no labels, SHOWS the post
```
**Post with CSAM label → Shows without warning**
**Post with self-harm label → Shows without warning**
**Post with graphic violence label → Shows without warning**
```
Alice is a survivor of abuse. She subscribes to:
- CSAM detection labeler
- Self-harm content labeler
- Harassment detection labeler
Bob is a suspended user who posted triggering content.
Alice views Bob's profile (suspended, so fallback triggers):
Normal mode:
- AppView checks Alice's subscriptions
- Queries labelers
- Returns: {labels: [{val: "self-harm"}]}
- Client HIDES the post with warning
Fallback mode:
- Fallback fetches from Bob's PDS
- Never checks Alice's preferences
- Never queries labelers
- Returns: {labels: []}
- Client SHOWS the post ❌❌❌
Alice is exposed to triggering content she explicitly opted out of.
```
🔴 **HIGH SEVERITY**
**Affected populations:**
- Minors (CSAM protection bypassed)
- Abuse survivors (triggering content shown)
- Users with PTSD (graphic content shown)
- Users avoiding harassment (labeled harassers shown)
**Exploit potential:**
- Malicious users could intentionally get suspended
- Suspended users' content bypasses all moderation
- Creates a perverse incentive (suspension = moderation bypass)
**Scope:**
- Any user subscribed to ANY labeler
- Any content with ANY labels
- Entire moderation system bypassed
The fallback was designed for a **specific use case:**
- Viewing suspended user profiles/posts
- Emergency fallback when AppView is down
- **Assumed context:** Temporary, rare events
**Trade-offs made:**
- Query speed prioritized over completeness
- Avoid adding latency from labeler queries
- Assumption: Most suspended users don't need viewing anyway
**What was missed:**
- Suspended users might have posted harmful content (that's WHY they're suspended)
- Users rely on labelers for safety, not just preference
- Fallback isn't as rare as expected (triggers on various errors)
**Question:** Can the fallback mechanism bypass moderation labels a user has subscribed to?
**Answer:** YES, completely. Not "override" but "ignore entirely."
The fallback:
1. Never queries `app.bsky.actor.getPreferences` to see user subscriptions
2. Never queries labeler services
3. Always returns `labels: []`
**This is equivalent to turning off all moderation.**
**Intentional bypass (user choice):**
```
User: "Show me NSFW content"
Settings: labelerPreferences[].visibility = "show"
Result: Content shown with disclaimer
```
**Fallback bypass (architectural):**
```
Fallback: *returns labels: []*
Client: "No labels? Must be safe!"
Result: Content shown without ANY indication
```
The user **has no idea** their moderation is bypassed.
---
**Repository:** `/Users/clint/src/blacksky-algorithms/blacksky.community/red-dwarf/`
From `red-dwarf/README.md`:
> Red Dwarf is a minimal AT Protocol client that reads directly from Personal Data Servers (PDS). It's designed as a fallback viewer for when AppView services are unavailable or users are suspended.
**Key characteristics:**
- **PDS-only:** Never queries AppView
- **Read-only:** No mutations (follow, like, post)
- **Explicit limitations:** README documents what doesn't work
- **Educational:** Shows how AT Protocol works at the PDS level
**File:** `red-dwarf/src/utils/useQuery.ts`
```typescript
export function useQuery<T>(
queryKey: string[],
queryFn: () => Promise<T>,
options?: UseQueryOptions
) {
// Standard React Query wrapper
// NO fallback mechanism
// NO AppView integration
// Direct PDS queries only
return useReactQuery({
queryKey,
queryFn,
...options,
})
}
```
**Notable:** Red-Dwarf's `useQuery` is a **simple wrapper** with no fallback logic. It always queries PDS, never AppView.
**File:** `red-dwarf/src/utils/useHydrated.ts`
```typescript
export function useHydratedProfile(did: string) {
// 1. Fetch identity from DID document
const identity = useIdentity(did)
// 2. Fetch profile record from PDS
const profileRecord = useQuery(
['profile-record', did],
() => fetchRecord(identity.pdsUrl, did, 'app.bsky.actor.profile', 'self')
)
// 3. Return raw data with NO synthetic AppView fields
return {
did,
handle: identity.handle,
displayName: profileRecord?.displayName,
description: profileRecord?.description,
avatar: profileRecord?.avatar,
// NO viewer state
// NO follower counts
// NO following status
}
}
```
**"Hydration" means:**
- Fetch raw PDS records
- Present them as-is
- Don't synthesize AppView-style responses
- Be honest about what's missing
**Red-Dwarf:** ✅ Doesn't have this problem
**Why:** Red-Dwarf has **no write operations**. There are no follow/unfollow/mute mutations to create cache invalidation bugs.
From README:
> Red Dwarf is read-only. You cannot post, follow, like, or perform any write operations.
**Red-Dwarf:** ⚠️ Has the same issue, but it's **acceptable**
**Why:** Red-Dwarf is **explicit about limitations**:
From README:
> **What doesn't work:**
> - Moderation labels (not available from PDS)
> - Following Feed (requires AppView indexing)
> - Notifications (requires AppView)
> - Search (requires AppView)
**Key difference:** User expectations.
- **Red-Dwarf:** "This is a limited PDS viewer. Some features don't work."
- **Blacksky.community:** "This is a full client. Everything works."
Users of Red-Dwarf know it's limited. Users of Blacksky.community expect full functionality.
**Red-Dwarf:** ✅ Doesn't have this problem
**Why:** Red-Dwarf **never shows follow state**. Profiles return:
```typescript
{
did: "...",
handle: "...",
displayName: "...",
// NO viewer.following
// NO viewer.followedBy
// NO follower counts
}
```
Users don't see follow buttons or follow status, so there's no confusion.
| Aspect | Red-Dwarf | Blacksky.community |
|--------|-----------|-------------------|
| **Data source** | PDS only | AppView + PDS fallback |
| **Write operations** | None | Full (follow, post, like) |
| **Viewer state** | Never shown | Attempted in fallback |
| **Moderation labels** | Documented as missing | Silently missing |
| **User expectations** | "Limited viewer" | "Full client" |
| **Cache complexity** | Simple | Complex (two sources) |
| **Consistency model** | Always stale, that's OK | Tries to be fresh, fails sometimes |
Red-Dwarf's README is upfront:
> **What doesn't work: [bulleted list]**
Blacksky.community could:
- Show a banner when fallback is active
- Indicate which features are limited
- Set user expectations
Red-Dwarf returns:
```typescript
{viewer: {}} // Empty, honest
```
Blacksky.community tries:
```typescript
{viewer: {muted: isDidMuted(...)}} // Might be wrong
```
**Better approach:** If you can't guarantee accuracy, don't present it as accurate.
Red-Dwarf is a **separate application** with a different UI. When using it, users know they're in "limited mode."
Blacksky.community could:
- Switch to a "limited mode" UI when fallback is active
- Remove buttons/features that won't work correctly
- Visual distinction makes limitations obvious
Red-Dwarf's README explains:
- Why PDS-only
- What trade-offs were made
- How AT Protocol works
Blacksky.community could benefit from similar documentation for the fallback mechanism.
**Pros:**
- Simpler, fewer edge cases
- Clear user expectations
- No cache coherence issues
**Cons:**
- No write operations (can't post/follow/like)
- Very limited functionality
- Not suitable as a daily driver
**Verdict:** Red-Dwarf's approach works for a **supplementary viewer** but not for a **primary client**. Blacksky.community needs write operations and full functionality.
**Better synthesis:**
- Keep fallback mechanism for suspended users
- Add safety guardrails (labeler queries, warnings)
- Fix cache invalidation bugs
- Be transparent when fallback is active
---
**User reports:**
> I have noticed unfollows don't stick, and even doing it multiple times doesn't work. I have also noticed the same phenomenon with unlisted mutes: you can mute someone but it seems like the record isn't being written onto my profile.
**Analysis:**
**Root cause:** Missing cache invalidation in `useProfileUnfollowMutation`
**Evidence:**
- Follow mutation has `onSuccess` with cache invalidation ✅
- Unfollow mutation has NO `onSuccess` callback ❌
- Affects both fallback and normal modes
- Timing makes it worse (AppView indexing lag)
**Fix:**
```typescript
onSuccess(_, {did}) {
resetProfilePostsQueries(queryClient, did, 1000)
}
```
**Relation to fallback:** Coincidental timing, not causal
**Root cause:** Incomplete cache invalidation in `useProfileMuteMutation`
**Evidence:**
- Mute IS written successfully to AppView ✅
- Cache invalidation only updates mute list ⚠️
- Profile cache and feed cache not invalidated ❌
- Muted user's content continues appearing in UI
**Fix:**
```typescript
onSuccess(_, {did}) {
queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
resetProfilePostsQueries(queryClient, did, 1000) // Add this
queryClient.invalidateQueries({queryKey: ['profile', did]}) // Add this
}
```
**Relation to fallback:** Independent bug, exists in both modes
**Timeline:**
```
Commit 2dba354
"Add fallback mechanism for suspended users"
│
├─ Fallback code added
├─ New code paths introduced
├─ More visible edge cases
│
└─ Cache invalidation bugs become more noticeable
│
├─ Before: Bug exists but subtle
├─ After: Bug triggered more often
└─ Users associate bug with recent change
```
**Correlation is not causation:**
The fallback mechanism:
- Was introduced around the time users noticed bugs ✅
- Added complexity to the codebase ✅
- Made existing bugs more visible ✅
- **Did NOT cause the bugs** ❌
**Actual causes existed before:**
- Unfollow mutation never had cache invalidation
- Mute mutation always had incomplete invalidation
- AppView indexing lag always existed
**Why fallback gets blamed:**
1. **Recency bias:** It's the recent change
2. **Complexity:** More code paths = more opportunities for bugs
3. **Correlation:** Timing of introduction matched user reports
But the bugs exist **with or without** the fallback.
---
**File:** `src/state/queries/profile.ts:354-364`
**Change:**
```typescript
export function useProfileUnfollowMutation(logContext: LogEvents['profile:unfollow']['logContext']) {
const queryClient = useQueryClient()
return useMutation<void, Error, {did: string; followUri: string}>({
mutationFn: async ({followUri}) => {
track('Profile:Unfollow', logContext)
const agent = getAgent()
return await agent.deleteFollow(followUri)
},
// ADD:
onSuccess(_, {did}) {
resetProfilePostsQueries(queryClient, did, 1000)
},
})
}
```
**Impact:** ✅ Fixes "unfollows don't stick"
**Risk:** Low (copying existing pattern from follow mutation)
**Effort:** 5 minutes
**File:** `src/state/queries/profile.ts:417-434`
**Change:**
```typescript
export function useProfileMuteMutation() {
const queryClient = useQueryClient()
return useMutation<void, Error, {did: string}>({
mutationFn: async ({did}) => {
const agent = getAgent()
await agent.mute(did)
},
onSuccess(_, {did}) {
queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
// ADD:
resetProfilePostsQueries(queryClient, did, 1000)
},
})
}
```
**Impact:** ✅ Fixes "mutes don't persist in UI"
**Risk:** Low
**Effort:** 5 minutes
**File:** `src/state/queries/profile.ts` (in both mutations)
**Enhancement:**
```typescript
onSuccess(_, {did}) {
// Immediate invalidation
resetProfilePostsQueries(queryClient, did, 0)
// Delayed refetch to catch AppView indexing
setTimeout(() => {
queryClient.invalidateQueries({queryKey: ['profile', did]})
queryClient.refetchQueries({queryKey: ['profile', did]})
}, 500) // Wait 500ms for AppView to index
}
```
**Impact:** ✅ Reduces race conditions with AppView indexing
**Risk:** Low
**Effort:** 10 minutes
**Create:** `src/components/FallbackModeBanner.tsx`
```typescript
export function FallbackModeBanner() {
return (
<div className="fallback-warning">
⚠️ Limited Mode: You're viewing content directly from the source.
Some safety features and moderation labels may not be available.
</div>
)
}
```
**Usage:** Show banner when fallback is active
**Impact:** ✅ Sets user expectations about limited functionality
**Risk:** Low
**Effort:** 1 hour
**File:** `src/state/queries/microcosm-fallback.ts`
**Add:**
```typescript
async function getLabelsForContent(
did: string,
uri: string
): Promise<ComAtprotoLabelDefs.Label[]> {
try {
// 1. Get user's labeler subscriptions
const agent = getAgent()
const prefs = await agent.app.bsky.actor.getPreferences()
const labelersPref = prefs.data.preferences.find(
p => p.$type === 'app.bsky.actor.defs#labelersPref'
)
if (!labelersPref?.labelers?.length) {
return []
}
// 2. Query each labeler
const labelPromises = labelersPref.labelers.map(async (labeler) => {
try {
const response = await fetch(
`https://labeler.${labeler.did}/xrpc/com.atproto.label.queryLabels?uris=${uri}`
)
const {labels} = await response.json()
return labels || []
} catch {
return []
}
})
const labelArrays = await Promise.all(labelPromises)
return labelArrays.flat()
} catch {
// If labeler queries fail, return empty (safe default)
return []
}
}
```
**Use in:**
```typescript
async function fetchPostWithFallback(uri: string) {
// ... existing code ...
// Add label fetching:
const labels = await getLabelsForContent(repo, uri)
return {
// ... existing fields ...
labels, // ✅ Now populated
}
}
```
**Impact:** ✅ Restores moderation in fallback mode
**Risk:** Medium (adds latency, labeler queries might fail)
**Effort:** 4 hours
**Trade-off:** Adds ~200-500ms latency for labeler queries. Consider:
- Only query labelers if fallback is triggered (not on normal path)
- Cache labeler responses
- Set aggressive timeout (1s)
**Create:** `docs/fallback-mechanism.md`
**Content:**
- How fallback works
- When it triggers
- What's limited
- Safety considerations
- How to disable it
**Impact:** ✅ Helps users and developers understand the system
**Effort:** 2 hours
---
**No.** The evidence strongly suggests:
1. **Primary cause:** Missing cache invalidation in unfollow/unmute mutations
2. **Secondary cause:** AppView indexing lag creating race conditions
3. **Tertiary cause:** Optimistic updates being overwritten by stale data
The fallback mechanism is **correctly implemented** for its intended purpose (showing profiles of suspended users). It has **excellent safety properties** for blocks and mutes.
**No.** The fallback mechanism provides real value but should be improved with safety guardrails.
**Immediate (fixes the reported bug):**
1. ✅ Add cache invalidation to unfollow mutation
2. ✅ Add cache invalidation to unmute mutation
3. ✅ Add AppView readiness polling for follow/unfollow operations
**High Priority (safety):**
4. ⚠️ Add fallback mode warning banner when moderation labels unavailable
5. ⚠️ Query moderation preferences in fallback
6. ⚠️ Document fallback limitations
---
**Document Version:** 2.0
**Date:** 2025-11-02
**Analysis:** Comprehensive review of fallback mechanism impact on social interactions and user safety
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
UX Fixes
This Is a PR to fix issues reported by several users from support.
Test Evaluation
Test 1: Unfollow Persistence