tangled
alpha
login
or
join now
whey.party
/
red-dwarf
82
fork
atom
an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
82
fork
atom
overview
issues
25
pulls
pipelines
useModeration and author badges
whey.party
1 month ago
36d82759
3e16834d
+928
-99
12 changed files
expand all
collapse all
unified
split
src
api
moderation.ts
components
ModerationBatcher.tsx
ModerationInitializer.tsx
UniversalPostRenderer.tsx
hooks
useLabelInfo.ts
useModeration.ts
routes
__root.tsx
moderation.tsx
profile.$did
index.tsx
state
moderationAtoms.ts
types
moderation.ts
utils
useQuery.ts
+35
src/api/moderation.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import type { QueryLabelsResponse } from "~/types/moderation";
2
+
3
+
export const fetchLabelsBatch = async (
4
+
serviceUrl: string,
5
+
uris: string[],
6
+
): Promise<QueryLabelsResponse> => {
7
+
const url = new URL(`${serviceUrl}/xrpc/com.atproto.label.queryLabels`);
8
+
uris.forEach((uri) => url.searchParams.append("uriPatterns", uri));
9
+
10
+
// 1. Setup Timeout (5 seconds)
11
+
const controller = new AbortController();
12
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
13
+
14
+
try {
15
+
const response = await fetch(url.toString(), {
16
+
signal: controller.signal,
17
+
});
18
+
19
+
if (!response.ok) {
20
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
21
+
}
22
+
23
+
const data = await response.json();
24
+
return data as QueryLabelsResponse;
25
+
} catch (error: any) {
26
+
if (error.name === 'AbortError') {
27
+
console.error(`[fetchLabelsBatch] Timeout querying ${serviceUrl}`);
28
+
} else {
29
+
console.error(`[fetchLabelsBatch] Error querying ${serviceUrl}:`, error);
30
+
}
31
+
throw error;
32
+
} finally {
33
+
clearTimeout(timeoutId);
34
+
}
35
+
};
+166
src/components/ModerationBatcher.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { useAtom, useAtomValue } from "jotai";
2
+
import { useEffect, useRef } from "react";
3
+
4
+
import { fetchLabelsBatch } from "~/api/moderation";
5
+
import {
6
+
CACHE_TIMEOUT_MS,
7
+
labelerConfigAtom,
8
+
moderationCacheAtom,
9
+
pendingUriQueueAtom,
10
+
processingUriSetAtom,
11
+
} from "~/state/moderationAtoms";
12
+
13
+
const BATCH_CHUNK_SIZE = 25;
14
+
15
+
export const ModerationBatcher = () => {
16
+
const [queue, setQueue] = useAtom(pendingUriQueueAtom);
17
+
const [processingSet, setProcessingSet] = useAtom(processingUriSetAtom);
18
+
const [cache, setCache] = useAtom(moderationCacheAtom);
19
+
const labelers = useAtomValue(labelerConfigAtom);
20
+
21
+
const stateRef = useRef({ queue, processingSet, cache, labelers });
22
+
useEffect(() => {
23
+
stateRef.current = { queue, processingSet, cache, labelers };
24
+
}, [queue, processingSet, cache, labelers]);
25
+
26
+
useEffect(() => {
27
+
const interval = setInterval(async () => {
28
+
const {
29
+
queue: currentQueue,
30
+
processingSet: currentProcessing,
31
+
cache: currentCache,
32
+
labelers: currentLabelers,
33
+
} = stateRef.current;
34
+
35
+
if (currentQueue.size === 0 || currentLabelers.length === 0) return;
36
+
37
+
const now = Date.now();
38
+
39
+
// 1. Identify stale items
40
+
const batchUris = Array.from(currentQueue).filter((uri) => {
41
+
const entry = currentCache.get(uri);
42
+
const isStale = entry ? now - entry.timestamp > CACHE_TIMEOUT_MS : true;
43
+
return !currentProcessing.has(uri) && isStale;
44
+
});
45
+
46
+
if (batchUris.length === 0) return;
47
+
48
+
console.log(`[Batcher] Processing ${batchUris.length} URIs...`);
49
+
50
+
// 2. Lock items
51
+
setProcessingSet((prev) => {
52
+
const next = new Set(prev);
53
+
batchUris.forEach((u) => next.add(u));
54
+
return next;
55
+
});
56
+
setQueue((prev) => {
57
+
const next = new Set(prev);
58
+
batchUris.forEach((u) => next.delete(u));
59
+
return next;
60
+
});
61
+
62
+
// 3. Process chunks
63
+
const chunks = [];
64
+
for (let i = 0; i < batchUris.length; i += BATCH_CHUNK_SIZE) {
65
+
chunks.push(batchUris.slice(i, i + BATCH_CHUNK_SIZE));
66
+
}
67
+
68
+
for (const chunk of chunks) {
69
+
try {
70
+
const results = await Promise.allSettled(
71
+
currentLabelers.map((l) => fetchLabelsBatch(l.url, chunk)),
72
+
);
73
+
74
+
setCache((prevCache) => {
75
+
const nextCache = new Map(prevCache);
76
+
const updateTime = Date.now();
77
+
78
+
// A. Initialize requested URIs (to remove loading state)
79
+
chunk.forEach((uri) => {
80
+
if (!nextCache.has(uri) || nextCache.get(uri)!.timestamp < updateTime) {
81
+
nextCache.set(uri, { labels: [], timestamp: updateTime });
82
+
}
83
+
});
84
+
85
+
// B. Process Results
86
+
results.forEach((res, index) => {
87
+
if (res.status === "fulfilled") {
88
+
const labeler = currentLabelers[index];
89
+
const rawLabels = res.value.labels || [];
90
+
91
+
// --- REDUCTION LOGIC START ---
92
+
93
+
// 1. Group by URI
94
+
const labelsByUri = new Map<string, typeof rawLabels>();
95
+
rawLabels.forEach((l) => {
96
+
if (!labelsByUri.has(l.uri)) labelsByUri.set(l.uri, []);
97
+
labelsByUri.get(l.uri)!.push(l);
98
+
});
99
+
100
+
// 2. Process each URI's history
101
+
labelsByUri.forEach((labels, uri) => {
102
+
// Only process if this URI is actually in our cache/interest
103
+
if (!nextCache.has(uri)) return;
104
+
const cacheEntry = nextCache.get(uri)!;
105
+
106
+
// 3. Find latest state per (Source + Value)
107
+
// Key: "did:plc:xyz::porn" -> Latest Label Object
108
+
const latestState = new Map<string, typeof rawLabels[0]>();
109
+
110
+
labels.forEach((l) => {
111
+
const key = `${l.src}::${l.val}`;
112
+
const existing = latestState.get(key);
113
+
114
+
const currentCts = new Date(l.cts).getTime();
115
+
const existingCts = existing ? new Date(existing.cts).getTime() : 0;
116
+
117
+
if (!existing || currentCts > existingCts) {
118
+
latestState.set(key, l);
119
+
}
120
+
});
121
+
122
+
// 4. Push only active (non-negated) labels
123
+
for (const activeLabel of latestState.values()) {
124
+
if (activeLabel.neg) continue; // Skip deleted labels
125
+
126
+
// Resolve preference from the Labeler Config (our subscription)
127
+
// Note: We attribute the label to the 'labeler.did' (the service we subscribed to)
128
+
// even if the signer (src) is different, because prefs are attached to the service.
129
+
const resolvedPref =
130
+
labeler.supportedLabels?.[activeLabel.val] || "ignore";
131
+
132
+
cacheEntry.labels.push({
133
+
sourceDid: labeler.did,
134
+
val: activeLabel.val,
135
+
cts: activeLabel.cts,
136
+
preference: resolvedPref,
137
+
});
138
+
}
139
+
});
140
+
// --- REDUCTION LOGIC END ---
141
+
142
+
} else {
143
+
console.error(`[Batcher] Labeler ${currentLabelers[index].url} failed:`, res.reason);
144
+
}
145
+
});
146
+
147
+
return nextCache;
148
+
});
149
+
} catch (e) {
150
+
console.error("[Batcher] Chunk failed", e);
151
+
}
152
+
}
153
+
154
+
// 5. Release Lock
155
+
setProcessingSet((prev) => {
156
+
const next = new Set(prev);
157
+
batchUris.forEach((u) => next.delete(u));
158
+
return next;
159
+
});
160
+
}, 2000);
161
+
162
+
return () => clearInterval(interval);
163
+
}, []);
164
+
165
+
return null;
166
+
};
+164
src/components/ModerationInitializer.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { useQueries } from "@tanstack/react-query";
2
+
import { useSetAtom } from "jotai";
3
+
import { useEffect } from "react";
4
+
5
+
import { useAuth } from "~/providers/UnifiedAuthProvider";
6
+
import { labelerConfigAtom } from "~/state/moderationAtoms";
7
+
import type { LabelerDefinition, LabelPreference, LabelValueDefinition } from "~/types/moderation";
8
+
import { useQueryIdentity } from "~/utils/useQuery";
9
+
import { useQueryPreferences } from "~/utils/useQuery";
10
+
11
+
// Manual DID document resolution
12
+
const fetchDidDocument = async (did: string): Promise<any> => {
13
+
if (did.startsWith("did:plc:")) {
14
+
// For PLC DIDs, fetch from plc.directory
15
+
const response = await fetch(
16
+
`https://plc.directory/${encodeURIComponent(did)}`,
17
+
);
18
+
if (!response.ok)
19
+
throw new Error(`Failed to fetch PLC DID document for ${did}`);
20
+
return response.json();
21
+
} else if (did.startsWith("did:web:")) {
22
+
// For web DIDs, fetch from well-known
23
+
const handle = did.replace("did:web:", "");
24
+
const url = `https://${handle}/.well-known/did.json`;
25
+
const response = await fetch(url);
26
+
if (!response.ok)
27
+
throw new Error(
28
+
`Failed to fetch web DID document for ${did} (CORS or not found)`,
29
+
);
30
+
return response.json();
31
+
} else {
32
+
throw new Error(`Unsupported DID type: ${did}`);
33
+
}
34
+
};
35
+
36
+
export const ModerationInitializer = () => {
37
+
const { agent } = useAuth();
38
+
const setLabelerConfig = useSetAtom(labelerConfigAtom);
39
+
40
+
// 1. Get User Identity to get PDS URL
41
+
const { data: identity } = useQueryIdentity(agent?.did);
42
+
43
+
// 2. Get User Preferences (Global: "porn" -> "hide")
44
+
const { data: prefs } = useQueryPreferences({
45
+
agent: agent ?? undefined,
46
+
pdsUrl: identity?.pds,
47
+
});
48
+
49
+
// 3. Identify Labeler DIDs from prefs
50
+
const labelerDids =
51
+
prefs?.preferences
52
+
?.find((pref: any) => pref.$type === "app.bsky.actor.defs#labelersPref")
53
+
?.labelers?.map((l: any) => l.did) ?? [];
54
+
55
+
// 4. Parallel fetch all Labeler DID Documents and Service Records
56
+
const labelerDidDocQueries = useQueries({
57
+
queries: labelerDids.map((did: string) => ({
58
+
queryKey: ["labelerDidDoc", did],
59
+
queryFn: () => fetchDidDocument(did),
60
+
staleTime: 5 * 60 * 1000, // 5 minutes
61
+
retry: 1, // Only retry once for DID docs
62
+
})),
63
+
});
64
+
65
+
const labelerServiceQueries = useQueries({
66
+
queries: labelerDids.map((did: string) => ({
67
+
queryKey: ["labelerService", did],
68
+
queryFn: async () => {
69
+
if (!identity?.pds) throw new Error("No PDS URL");
70
+
const response = await fetch(
71
+
`${identity.pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent("app.bsky.labeler.service")}&rkey=self`,
72
+
);
73
+
if (!response.ok) throw new Error("Failed to fetch labeler service");
74
+
return response.json();
75
+
},
76
+
enabled: !!identity?.pds && !!agent,
77
+
staleTime: 5 * 60 * 1000, // 5 minutes
78
+
})),
79
+
});
80
+
81
+
useEffect(() => {
82
+
if (
83
+
!prefs ||
84
+
labelerDidDocQueries.some((q) => q.isLoading) ||
85
+
labelerDidDocQueries.some((q) => q.isFetching) ||
86
+
labelerServiceQueries.some((q) => q.isLoading) ||
87
+
labelerServiceQueries.some((q) => q.isFetching)
88
+
)
89
+
return;
90
+
91
+
// Extract content label preferences
92
+
const contentLabelPrefs =
93
+
prefs.preferences?.filter(
94
+
(pref: any) => pref.$type === "app.bsky.actor.defs#contentLabelPref",
95
+
) ?? [];
96
+
97
+
const globalPrefs: Record<string, LabelPreference> = {};
98
+
contentLabelPrefs.forEach((pref: any) => {
99
+
globalPrefs[pref.label] = pref.visibility as LabelPreference;
100
+
});
101
+
102
+
const definitions: LabelerDefinition[] = labelerDids
103
+
.map((did: string, index: number) => {
104
+
const didDocQuery = labelerDidDocQueries[index];
105
+
const serviceQuery = labelerServiceQueries[index];
106
+
107
+
if (!didDocQuery.data || !serviceQuery.data) return null;
108
+
109
+
// Extract service endpoint from DID document
110
+
const didDoc = didDocQuery.data as any;
111
+
const atprotoLabelerService = didDoc?.service?.find(
112
+
(s: any) => s.id === "#atproto_labeler",
113
+
);
114
+
115
+
const record = (serviceQuery.data as any).value; // The raw ATProto record
116
+
117
+
// 1. Create the Metadata Map
118
+
const labelDefs: Record<string, LabelValueDefinition> = {};
119
+
120
+
if (record.policies.labelValueDefinitions) {
121
+
record.policies.labelValueDefinitions.forEach((def: any) => {
122
+
labelDefs[def.identifier] = {
123
+
identifier: def.identifier,
124
+
severity: def.severity,
125
+
blurs: def.blurs,
126
+
adultOnly: def.adultOnly,
127
+
defaultSetting: def.defaultSetting,
128
+
locales: def.locales || [] // <--- Capture the locales array
129
+
};
130
+
});
131
+
}
132
+
133
+
// RESOLUTION LOGIC:
134
+
// Map record.policies.labelValueDefinitions to a lookup map.
135
+
// Priority: User Global Pref > Labeler Default > 'ignore'
136
+
const supportedLabels: Record<string, LabelPreference> = {};
137
+
138
+
record.policies?.labelValues?.forEach((val: string) => {
139
+
// Does user have a global override for this string?
140
+
const globalPref = globalPrefs[val];
141
+
// Or use labeler default
142
+
const defaultPref =
143
+
record.policies?.labelValueDefinitions?.find(
144
+
(d: any) => d.identifier === val,
145
+
)?.defaultSetting || "ignore";
146
+
147
+
supportedLabels[val] = (globalPref || defaultPref) as LabelPreference;
148
+
});
149
+
150
+
return {
151
+
did: did,
152
+
url: atprotoLabelerService?.serviceEndpoint || record.serviceEndpoint,
153
+
isDefault: false, // logic to determine if this is a default Bluesky labeler
154
+
supportedLabels,
155
+
labelDefs,
156
+
};
157
+
})
158
+
.filter(Boolean) as LabelerDefinition[];
159
+
160
+
setLabelerConfig(definitions);
161
+
}, [prefs, labelerDidDocQueries, labelerServiceQueries, setLabelerConfig, identity?.pds, labelerDids]);
162
+
163
+
return null; // Headless component
164
+
};
+113
-28
src/components/UniversalPostRenderer.tsx
···
16
import { useEffect, useState } from "react";
17
18
import defaultpfp from "~/../public/defaultpfp.png";
0
0
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
20
import { renderSnack } from "~/routes/__root";
0
21
import {
22
FollowButton,
23
Mutual,
24
} from "~/routes/profile.$did";
25
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
0
26
import {
27
composerAtom,
28
constellationURLAtom,
···
133
setReplies(
134
links
135
? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]
136
-
?.records || 0
137
: null,
138
);
139
}, [links]);
···
168
169
const replyAturis = repliesData
170
? repliesData.pages.flatMap((page) =>
171
-
page
172
-
? page.linking_records.map((record) => {
173
-
const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
174
-
return aturi;
175
-
})
176
-
: [],
177
-
)
178
: [];
179
180
const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => {
···
390
const isQuotewithImages =
391
isquotewithmedia &&
392
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
393
-
"app.bsky.embed.images";
394
const isQuotewithVideo =
395
isquotewithmedia &&
396
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
397
-
"app.bsky.embed.video";
398
399
const hasMedia =
400
hasEmbed &&
···
573
maxReplies?: number;
574
constellationLinks?: any;
575
}) {
0
0
0
0
0
0
0
0
0
0
0
576
const parsed = new AtUri(post.uri);
577
const navigate = useNavigate();
578
const [hasRetweeted, setHasRetweeted] = useState<boolean>(
···
631
632
const tags = unfediwafrnTags
633
? unfediwafrnTags
634
-
.split("\n")
635
-
.map((t) => t.trim())
636
-
.filter(Boolean)
637
: undefined;
638
639
const links = tags
640
? tags
641
-
.map((tag) => {
642
-
const encoded = encodeURIComponent(tag);
643
-
return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`;
644
-
})
645
-
.join("<br>")
646
: "";
647
648
const unfediwafrn = unfediwafrnPartial
···
654
(showWafrnText ? unfediwafrn : undefined);
655
656
const isMainItem = false;
657
-
const setMainItem = (any: any) => {};
0
0
0
0
658
659
return (
660
<div ref={ref} style={style} data-index={dataIndexPropPass}>
···
666
: setMainItem
667
? onPostClick
668
? (e) => {
669
-
setMainItem({ post: post });
670
-
onPostClick(e);
671
-
}
672
: () => {
673
-
setMainItem({ post: post });
674
-
}
675
: undefined
676
}
677
style={{
···
897
</span>
898
</div>
899
</div>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
900
{!!feedviewpostreplyhandle && (
901
<div
902
style={{
···
919
<IconMdiReply /> Reply to @{feedviewpostreplyhandle}
920
</div>
921
)}
0
922
<div
923
style={{
924
fontSize: 16,
···
1084
try {
1085
await navigator.clipboard.writeText(
1086
"https://bsky.app" +
1087
-
"/profile/" +
1088
-
post.author.handle +
1089
-
"/post/" +
1090
-
post.uri.split("/").pop(),
1091
);
1092
renderSnack({
1093
title: "Copied to clipboard!",
···
1136
Feed = "Feed",
1137
FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia",
1138
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
16
import { useEffect, useState } from "react";
17
18
import defaultpfp from "~/../public/defaultpfp.png";
19
+
import { useLabelInfo } from "~/hooks/useLabelInfo";
20
+
import { useModeration } from "~/hooks/useModeration";
21
import { useAuth } from "~/providers/UnifiedAuthProvider";
22
import { renderSnack } from "~/routes/__root";
23
+
//import { ModerationInner } from "~/routes/moderation";
24
import {
25
FollowButton,
26
Mutual,
27
} from "~/routes/profile.$did";
28
import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i";
29
+
import type { ContentLabel } from "~/types/moderation";
30
import {
31
composerAtom,
32
constellationURLAtom,
···
137
setReplies(
138
links
139
? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"]
140
+
?.records || 0
141
: null,
142
);
143
}, [links]);
···
172
173
const replyAturis = repliesData
174
? repliesData.pages.flatMap((page) =>
175
+
page
176
+
? page.linking_records.map((record) => {
177
+
const aturi = `at://${record.did}/${record.collection}/${record.rkey}`;
178
+
return aturi;
179
+
})
180
+
: [],
181
+
)
182
: [];
183
184
const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => {
···
394
const isQuotewithImages =
395
isquotewithmedia &&
396
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
397
+
"app.bsky.embed.images";
398
const isQuotewithVideo =
399
isquotewithmedia &&
400
(hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type ===
401
+
"app.bsky.embed.video";
402
403
const hasMedia =
404
hasEmbed &&
···
577
maxReplies?: number;
578
constellationLinks?: any;
579
}) {
580
+
const { isLoading: authorModLoading, labels: authorLabels } = useModeration(
581
+
post.author.did,
582
+
);
583
+
const hideAuthorLabels = authorLabels.filter(
584
+
label => label.preference === 'hide'
585
+
);
586
+
const warnAuthorLabels = authorLabels.filter(
587
+
label => label.preference === 'warn'
588
+
);
589
+
590
+
591
const parsed = new AtUri(post.uri);
592
const navigate = useNavigate();
593
const [hasRetweeted, setHasRetweeted] = useState<boolean>(
···
646
647
const tags = unfediwafrnTags
648
? unfediwafrnTags
649
+
.split("\n")
650
+
.map((t) => t.trim())
651
+
.filter(Boolean)
652
: undefined;
653
654
const links = tags
655
? tags
656
+
.map((tag) => {
657
+
const encoded = encodeURIComponent(tag);
658
+
return `<a href="https://${undfediwafrnHost}/search/${encoded}" target="_blank">#${tag.replaceAll(" ", "-")}</a>`;
659
+
})
660
+
.join("<br>")
661
: "";
662
663
const unfediwafrn = unfediwafrnPartial
···
669
(showWafrnText ? unfediwafrn : undefined);
670
671
const isMainItem = false;
672
+
const setMainItem = (any: any) => { };
673
+
674
+
if (hideAuthorLabels.length > 0 ) {
675
+
return null
676
+
}
677
678
return (
679
<div ref={ref} style={style} data-index={dataIndexPropPass}>
···
685
: setMainItem
686
? onPostClick
687
? (e) => {
688
+
setMainItem({ post: post });
689
+
onPostClick(e);
690
+
}
691
: () => {
692
+
setMainItem({ post: post });
693
+
}
694
: undefined
695
}
696
style={{
···
916
</span>
917
</div>
918
</div>
919
+
{/* <ModerationInner subject={post.author.did} /> */}
920
+
{authorModLoading ?
921
+
(
922
+
<div className="flex flex-wrap flex-row gap-1 my-1">
923
+
<div
924
+
className="text-xs bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded-full flex flex-row items-center gap-1"
925
+
>
926
+
{/* <img
927
+
src={resolvedpfp || defaultpfp}
928
+
alt="avatar"
929
+
className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
930
+
style={{
931
+
width: 12,
932
+
height: 12,
933
+
}}
934
+
/> */}
935
+
<span className="font-medium">loading badges...</span>
936
+
</div>
937
+
</div>
938
+
)
939
+
:
940
+
(
941
+
<div className="flex flex-wrap flex-row gap-1 my-1">
942
+
{warnAuthorLabels.map((label, index) => (
943
+
<SmallAuthorLabelBadge label={label} key={label.cts + label.sourceDid + label.val} />
944
+
))}
945
+
</div>
946
+
)
947
+
}
948
{!!feedviewpostreplyhandle && (
949
<div
950
style={{
···
967
<IconMdiReply /> Reply to @{feedviewpostreplyhandle}
968
</div>
969
)}
970
+
{/* <ModerationInner subject={post.uri} /> */}
971
<div
972
style={{
973
fontSize: 16,
···
1133
try {
1134
await navigator.clipboard.writeText(
1135
"https://bsky.app" +
1136
+
"/profile/" +
1137
+
post.author.handle +
1138
+
"/post/" +
1139
+
post.uri.split("/").pop(),
1140
);
1141
renderSnack({
1142
title: "Copied to clipboard!",
···
1185
Feed = "Feed",
1186
FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia",
1187
}
1188
+
1189
+
1190
+
export function SmallAuthorLabelBadge({ label, large }: { label: ContentLabel, large?: boolean }) {
1191
+
/*
1192
+
-{" "}
1193
+
{label.preference} (from {label.sourceDid})
1194
+
*/
1195
+
const { getLabelInfo } = useLabelInfo();
1196
+
const info = getLabelInfo(label.sourceDid, label.val);
1197
+
1198
+
const [imgcdn] = useAtom(imgCDNAtom);
1199
+
1200
+
1201
+
const { data: opProfile } = useQueryProfile(
1202
+
`at://${label.sourceDid}/app.bsky.actor.profile/self`,
1203
+
);
1204
+
1205
+
const resolvedpfp = getAvatarUrl(opProfile, label.sourceDid, imgcdn)
1206
+
1207
+
return (
1208
+
<div
1209
+
className={`text-xs bg-gray-100 dark:bg-gray-800 ${large ? "px-2 py-1" : "px-1 py-0.5"} rounded-full flex flex-row items-center gap-1`}
1210
+
>
1211
+
<img
1212
+
src={resolvedpfp || defaultpfp}
1213
+
alt="avatar"
1214
+
className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
1215
+
style={{
1216
+
width: 12,
1217
+
height: 12,
1218
+
}}
1219
+
/>
1220
+
<span className="font-medium">{info.name || label.val}</span>
1221
+
</div>
1222
+
)
1223
+
}
+43
src/hooks/useLabelInfo.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { useAtomValue } from "jotai";
2
+
import { useCallback } from "react";
3
+
4
+
import { labelerConfigAtom } from "~/state/moderationAtoms";
5
+
6
+
export const useLabelInfo = () => {
7
+
const labelers = useAtomValue(labelerConfigAtom);
8
+
9
+
const getLabelInfo = useCallback((sourceDid: string, val: string) => {
10
+
// 1. Find the labeler config
11
+
const labeler = labelers.find((l) => l.did === sourceDid);
12
+
13
+
// Fallback if labeler or definition is missing
14
+
const fallback = {
15
+
name: val,
16
+
description: "",
17
+
isAdult: false
18
+
};
19
+
20
+
if (!labeler) return fallback;
21
+
22
+
// 2. Look up the definition
23
+
const def = labeler.labelDefs[val];
24
+
if (!def) return fallback;
25
+
26
+
// 3. Resolve Locale (Match browser lang -> 'en' -> first available)
27
+
// You can replace 'en' with a proper i18n atom if you have one
28
+
const userLang = "en";
29
+
const locale = def.locales.find((l) => l.lang === userLang)
30
+
|| def.locales.find((l) => l.lang === "en")
31
+
|| def.locales[0];
32
+
33
+
return {
34
+
name: locale?.name || val,
35
+
description: locale?.description || "",
36
+
isAdult: def.adultOnly,
37
+
severity: def.severity,
38
+
blurs: def.blurs
39
+
};
40
+
}, [labelers]);
41
+
42
+
return { getLabelInfo };
43
+
};
+51
src/hooks/useModeration.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { useAtom, useSetAtom } from "jotai";
2
+
import { selectAtom } from "jotai/utils";
3
+
import { useEffect, useMemo } from "react";
4
+
5
+
import {
6
+
CACHE_TIMEOUT_MS,
7
+
moderationCacheAtom,
8
+
pendingUriQueueAtom,
9
+
processingUriSetAtom,
10
+
} from "~/state/moderationAtoms";
11
+
12
+
export const useModeration = (uri: string) => {
13
+
const setQueue = useSetAtom(pendingUriQueueAtom);
14
+
15
+
// 1. Select ONLY this URI's cache entry
16
+
const entryAtom = useMemo(
17
+
() => selectAtom(moderationCacheAtom, (cache) => cache.get(uri)),
18
+
[uri],
19
+
);
20
+
const [cachedEntry] = useAtom(entryAtom);
21
+
22
+
// 2. Select ONLY this URI's processing state
23
+
const isProcessingAtom = useMemo(
24
+
() => selectAtom(processingUriSetAtom, (set) => set.has(uri)),
25
+
[uri],
26
+
);
27
+
const [isProcessing] = useAtom(isProcessingAtom);
28
+
29
+
const now = Date.now();
30
+
const exists = cachedEntry !== undefined;
31
+
const isStale = exists && now - cachedEntry.timestamp > CACHE_TIMEOUT_MS;
32
+
33
+
useEffect(() => {
34
+
// Stop if we have valid data or are currently working on it
35
+
if ((exists && !isStale) || isProcessing) return;
36
+
37
+
// Queue it
38
+
setQueue((prev) => {
39
+
if (prev.has(uri)) return prev;
40
+
const next = new Set(prev);
41
+
next.add(uri);
42
+
return next;
43
+
});
44
+
}, [uri, exists, isStale, isProcessing, setQueue]);
45
+
46
+
return {
47
+
// Show loading ONLY if we have absolutely no data (first load)
48
+
isLoading: !exists,
49
+
labels: cachedEntry?.labels || [],
50
+
};
51
+
};
+5
-2
src/routes/__root.tsx
···
23
import { Import } from "~/components/Import";
24
import Login from "~/components/Login";
25
import Logo from "~/components/LogoSvg";
0
0
26
import { NotFound } from "~/components/NotFound";
27
import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider";
28
import { PollMutationQueueProvider } from "~/providers/PollMutationQueueProvider";
···
85
<UnifiedAuthProvider>
86
<LikeMutationQueueProvider>
87
<PollMutationQueueProvider>
0
0
88
<RootDocument>
89
<KeepAliveProvider>
90
<AppToaster />
···
207
const location = useLocation();
208
const navigate = useNavigate();
209
const { agent } = useAuth();
0
210
const authed = !!agent?.did;
211
-
const isHome = location.pathname === "/";
212
-
const isNotifications = location.pathname.startsWith("/notifications");
213
const isProfile =
214
agent &&
215
(location.pathname === `/profile/${agent?.did}` ||
···
23
import { Import } from "~/components/Import";
24
import Login from "~/components/Login";
25
import Logo from "~/components/LogoSvg";
26
+
import { ModerationBatcher } from "~/components/ModerationBatcher";
27
+
import { ModerationInitializer } from "~/components/ModerationInitializer";
28
import { NotFound } from "~/components/NotFound";
29
import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider";
30
import { PollMutationQueueProvider } from "~/providers/PollMutationQueueProvider";
···
87
<UnifiedAuthProvider>
88
<LikeMutationQueueProvider>
89
<PollMutationQueueProvider>
90
+
<ModerationInitializer />
91
+
<ModerationBatcher />
92
<RootDocument>
93
<KeepAliveProvider>
94
<AppToaster />
···
211
const location = useLocation();
212
const navigate = useNavigate();
213
const { agent } = useAuth();
214
+
const isNotifications = location.pathname.startsWith("/notifications");
215
const authed = !!agent?.did;
0
0
216
const isProfile =
217
agent &&
218
(location.pathname === `/profile/${agent?.did}` ||
+63
-12
src/routes/moderation.tsx
···
13
import { Switch } from "radix-ui";
14
15
import { Header } from "~/components/Header";
0
16
import { useAuth } from "~/providers/UnifiedAuthProvider";
17
import { quickAuthAtom } from "~/utils/atoms";
18
import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery";
···
28
function RouteComponent() {
29
const { agent } = useAuth();
30
31
-
const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
32
const isAuthRestoring = quickAuth ? status === "loading" : false;
33
34
const identityresultmaybe = useQueryIdentity(
35
-
!isAuthRestoring ? agent?.did : undefined
36
);
37
const identity = identityresultmaybe?.data;
38
···
43
const rawprefs = prefsresultmaybe?.data?.preferences as
44
| ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"]
45
| undefined;
46
-
47
-
//console.log(JSON.stringify(prefs, null, 2))
48
49
const parsedPref = parsePreferences(rawprefs);
50
···
96
<Switch.Root
97
id={`switch-${"hardcoded"}`}
98
checked={parsedPref?.adultContentEnabled}
99
-
onCheckedChange={(v) => {
100
renderSnack({
101
title: "Sorry... Modifying preferences is not implemented yet",
102
description: "You can use another app to change preferences",
···
108
<Switch.Thumb className="m3switch thumb " />
109
</Switch.Root>
110
</div>
0
0
0
0
0
0
0
111
<div className="">
112
{Object.entries(parsedPref?.contentLabelPrefs ?? {}).map(
113
([label, visibility]) => (
···
133
value={visibility as "ignore" | "warn" | "hide"}
134
/>
135
</div>
136
-
)
137
)}
138
</div>
139
</div>
···
174
});
175
onChange?.(opt);
176
}}
177
-
className={`flex-1 px-3 py-1.5 rounded-full transition-colors ${
178
-
isActive
179
-
? "bg-gray-400 dark:bg-gray-600 text-white"
180
-
: "text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700"
181
-
}`}
182
>
183
{" "}
184
{opt.charAt(0).toUpperCase() + opt.slice(1)}
···
206
}
207
208
export function parsePreferences(
209
-
prefs?: PrefItem[]
210
): NormalizedPreferences | undefined {
211
if (!prefs) return undefined;
212
const normalized: NormalizedPreferences = {
···
267
268
return normalized;
269
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
13
import { Switch } from "radix-ui";
14
15
import { Header } from "~/components/Header";
16
+
import { useModeration } from "~/hooks/useModeration";
17
import { useAuth } from "~/providers/UnifiedAuthProvider";
18
import { quickAuthAtom } from "~/utils/atoms";
19
import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery";
···
29
function RouteComponent() {
30
const { agent } = useAuth();
31
32
+
const [quickAuth] = useAtom(quickAuthAtom);
33
const isAuthRestoring = quickAuth ? status === "loading" : false;
34
35
const identityresultmaybe = useQueryIdentity(
36
+
!isAuthRestoring ? agent?.did : undefined,
37
);
38
const identity = identityresultmaybe?.data;
39
···
44
const rawprefs = prefsresultmaybe?.data?.preferences as
45
| ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"]
46
| undefined;
0
0
47
48
const parsedPref = parsePreferences(rawprefs);
49
···
95
<Switch.Root
96
id={`switch-${"hardcoded"}`}
97
checked={parsedPref?.adultContentEnabled}
98
+
onCheckedChange={() => {
99
renderSnack({
100
title: "Sorry... Modifying preferences is not implemented yet",
101
description: "You can use another app to change preferences",
···
107
<Switch.Thumb className="m3switch thumb " />
108
</Switch.Root>
109
</div>
110
+
111
+
<TestModeration subject="did:plc:q7suwaz53ztc4mbiqyygbn43" />
112
+
<TestModeration subject="did:plc:fpruhuo22xkm5o7ttr2ktxdo" />
113
+
<TestModeration subject="did:plc:6ayddqghxhciedbaofoxkcbs" />
114
+
<TestModeration subject="did:plc:za2ezszbzyqer7eylvtgapd5" />
115
+
<TestModeration subject="did:plc:ia76kvnndjutgedggx2ibrem" />
116
+
<TestModeration subject="did:plc:w2wbinubagmo4hlxx2ik5rrp" />
117
<div className="">
118
{Object.entries(parsedPref?.contentLabelPrefs ?? {}).map(
119
([label, visibility]) => (
···
139
value={visibility as "ignore" | "warn" | "hide"}
140
/>
141
</div>
142
+
),
143
)}
144
</div>
145
</div>
···
180
});
181
onChange?.(opt);
182
}}
183
+
className={`flex-1 px-3 py-1.5 rounded-full transition-colors ${isActive
184
+
? "bg-gray-400 dark:bg-gray-600 text-white"
185
+
: "text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700"
186
+
}`}
0
187
>
188
{" "}
189
{opt.charAt(0).toUpperCase() + opt.slice(1)}
···
211
}
212
213
export function parsePreferences(
214
+
prefs?: PrefItem[],
215
): NormalizedPreferences | undefined {
216
if (!prefs) return undefined;
217
const normalized: NormalizedPreferences = {
···
272
273
return normalized;
274
}
275
+
276
+
277
+
export function TestModeration({ subject }: { subject: string }) {
278
+
return (
279
+
<>
280
+
{/* Test the moderation system */}
281
+
<div className="px-4 py-2 border-b">
282
+
<div className="flex flex-col">
283
+
<span className="text-md font-medium">Moderation System Test</span>
284
+
<span className="text-sm text-gray-500 dark:text-gray-400">
285
+
Testing useModeration hook with example content
286
+
</span>
287
+
<ModerationInner subject={subject} />
288
+
</div>
289
+
</div>
290
+
</>
291
+
)
292
+
293
+
}
294
+
295
+
export function ModerationInner({ subject }: { subject: string }) {
296
+
const { isLoading: moderationLoading, labels: testLabels } = useModeration(
297
+
subject,
298
+
);
299
+
300
+
return (<>{moderationLoading ? (
301
+
<span className="text-sm text-blue-500">
302
+
Loading moderation data...
303
+
</span>
304
+
) : (
305
+
<div className="mt-2">
306
+
<span className="text-sm">
307
+
Found {testLabels.length} labels for {subject}
308
+
</span>
309
+
{testLabels.map((label, index) => (
310
+
<div
311
+
key={index}
312
+
className="text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded mt-1"
313
+
>
314
+
<span className="font-medium">{label.val}</span> -{" "}
315
+
{label.preference} (from {label.sourceDid})
316
+
</div>
317
+
))}
318
+
</div>
319
+
)}</>)
320
+
}
+59
-17
src/routes/profile.$did/index.tsx
···
13
useReusableTabScrollRestore,
14
} from "~/components/ReusableTabRoute";
15
import {
0
16
UniversalPostRendererATURILoader,
17
} from "~/components/UniversalPostRenderer";
18
import { renderTextWithFacets } from "~/components/UtilityFunctions";
0
19
import { useAuth } from "~/providers/UnifiedAuthProvider";
20
import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms";
21
import {
···
53
error: identityError,
54
} = useQueryIdentity(did);
55
0
0
0
0
0
0
0
0
0
0
0
56
// i was gonna check the did doc but useQueryIdentity doesnt return that info (slingshot minidoc)
57
// so instead we should query the labeler profile
58
···
100
const resultwhateversure = useQueryConstellationLinksCountDistinctDids(
101
resolvedDid
102
? {
103
-
method: "/links/count/distinct-dids",
104
-
collection: "app.bsky.graph.follow",
105
-
target: resolvedDid,
106
-
path: ".subject",
107
-
}
108
: undefined
109
);
110
···
221
<RichTextRenderer key={did} description={description} />
222
</div>
223
)}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
224
</div>
225
</div>
226
···
231
tabs={{
232
...(isLabeler
233
? {
234
-
Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />,
235
-
}
236
: {}),
237
...{
238
Posts: <PostsTab did={did} />,
···
696
// @ts-expect-error overloads sucks
697
!listmode
698
? {
699
-
target: feed.uri,
700
-
method: "/links/count",
701
-
collection: "app.bsky.feed.like",
702
-
path: ".subject.uri",
703
-
}
704
: undefined
705
);
706
···
1044
const theyFollowYouRes = useGetOneToOneState(
1045
agent?.did
1046
? {
1047
-
target: agent?.did,
1048
-
user: identity?.did ?? targetdidorhandle,
1049
-
collection: "app.bsky.graph.follow",
1050
-
path: ".subject",
1051
-
}
1052
: undefined
1053
);
1054
···
13
useReusableTabScrollRestore,
14
} from "~/components/ReusableTabRoute";
15
import {
16
+
SmallAuthorLabelBadge,
17
UniversalPostRendererATURILoader,
18
} from "~/components/UniversalPostRenderer";
19
import { renderTextWithFacets } from "~/components/UtilityFunctions";
20
+
import { useModeration } from "~/hooks/useModeration";
21
import { useAuth } from "~/providers/UnifiedAuthProvider";
22
import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms";
23
import {
···
55
error: identityError,
56
} = useQueryIdentity(did);
57
58
+
59
+
const { isLoading: authorModLoading, labels: authorLabels } = useModeration(
60
+
did,
61
+
);
62
+
const hideAuthorLabels = authorLabels.filter(
63
+
label => label.preference === 'hide'
64
+
);
65
+
const warnAuthorLabels = authorLabels.filter(
66
+
label => label.preference === 'warn'
67
+
);
68
+
69
// i was gonna check the did doc but useQueryIdentity doesnt return that info (slingshot minidoc)
70
// so instead we should query the labeler profile
71
···
113
const resultwhateversure = useQueryConstellationLinksCountDistinctDids(
114
resolvedDid
115
? {
116
+
method: "/links/count/distinct-dids",
117
+
collection: "app.bsky.graph.follow",
118
+
target: resolvedDid,
119
+
path: ".subject",
120
+
}
121
: undefined
122
);
123
···
234
<RichTextRenderer key={did} description={description} />
235
</div>
236
)}
237
+
{/* <ModerationInner subject={post.author.did} /> */}
238
+
{authorModLoading ?
239
+
(
240
+
<div className="flex flex-wrap flex-row gap-1">
241
+
<div
242
+
className="text-xs bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded-full flex flex-row items-center gap-1"
243
+
>
244
+
{/* <img
245
+
src={resolvedpfp || defaultpfp}
246
+
alt="avatar"
247
+
className={`rounded-full object-cover border border-gray-300 dark:border-gray-800 bg-gray-300 dark:bg-gray-600`}
248
+
style={{
249
+
width: 12,
250
+
height: 12,
251
+
}}
252
+
/> */}
253
+
<span className="font-medium">loading badges...</span>
254
+
</div>
255
+
</div>
256
+
)
257
+
:
258
+
(
259
+
<div className="flex flex-wrap flex-row gap-1">
260
+
{warnAuthorLabels.map((label, index) => (
261
+
<SmallAuthorLabelBadge label={label} key={label.cts + label.sourceDid + label.val} large />
262
+
))}
263
+
</div>
264
+
)
265
+
}
266
</div>
267
</div>
268
···
273
tabs={{
274
...(isLabeler
275
? {
276
+
Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />,
277
+
}
278
: {}),
279
...{
280
Posts: <PostsTab did={did} />,
···
738
// @ts-expect-error overloads sucks
739
!listmode
740
? {
741
+
target: feed.uri,
742
+
method: "/links/count",
743
+
collection: "app.bsky.feed.like",
744
+
path: ".subject.uri",
745
+
}
746
: undefined
747
);
748
···
1086
const theyFollowYouRes = useGetOneToOneState(
1087
agent?.did
1088
? {
1089
+
target: agent?.did,
1090
+
user: identity?.did ?? targetdidorhandle,
1091
+
collection: "app.bsky.graph.follow",
1092
+
path: ".subject",
1093
+
}
1094
: undefined
1095
);
1096
+92
src/state/moderationAtoms.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { atom } from "jotai";
2
+
import { atomWithStorage } from "jotai/utils";
3
+
4
+
import type { ContentLabel, LabelerDefinition } from "~/types/moderation";
5
+
6
+
// --- Configuration ---
7
+
export const CACHE_TIMEOUT_MS = 3600000; // 1 Hour
8
+
const MAX_CACHE_ENTRIES = 2000; // Limit to prevent localStorage quota issues
9
+
const STORAGE_KEY = "moderation-cache-v1";
10
+
11
+
// --- Types ---
12
+
type CacheEntry = { labels: ContentLabel[]; timestamp: number };
13
+
type CacheMap = Map<string, CacheEntry>;
14
+
15
+
// --- Custom Storage Implementation ---
16
+
// We cannot use createJSONStorage because it fails to serialize Maps.
17
+
// We must write the storage logic manually.
18
+
const mapStorage = {
19
+
getItem: (key: string, initialValue: CacheMap): CacheMap => {
20
+
if (typeof window === "undefined" || !window.localStorage) {
21
+
return initialValue;
22
+
}
23
+
24
+
try {
25
+
const item = localStorage.getItem(key);
26
+
if (!item) return initialValue;
27
+
28
+
const parsed = JSON.parse(item);
29
+
30
+
// Ensure it is an array (Map serialization format)
31
+
if (!Array.isArray(parsed)) return initialValue;
32
+
33
+
const now = Date.now();
34
+
const map = new Map<string, CacheEntry>();
35
+
36
+
parsed.forEach(([uri, data]) => {
37
+
// 1. STALENESS CHECK (On Load)
38
+
// Only load if younger than timeout
39
+
if (data && now - data.timestamp < CACHE_TIMEOUT_MS) {
40
+
map.set(uri, data);
41
+
}
42
+
});
43
+
44
+
console.log(`[Cache] Hydrated ${map.size} valid entries.`);
45
+
return map;
46
+
} catch (error) {
47
+
console.error("[Cache] Failed to load:", error);
48
+
return initialValue;
49
+
}
50
+
},
51
+
52
+
setItem: (key: string, value: CacheMap) => {
53
+
if (typeof window === "undefined" || !window.localStorage) return;
54
+
55
+
try {
56
+
let entries = Array.from(value.entries());
57
+
58
+
// 2. SAFETY CAP (On Save)
59
+
// If we have too many entries, keep only the newest ones
60
+
if (entries.length > MAX_CACHE_ENTRIES) {
61
+
// Sort by timestamp descending (newest first)
62
+
entries.sort((a, b) => b[1].timestamp - a[1].timestamp);
63
+
// Keep top N
64
+
entries = entries.slice(0, MAX_CACHE_ENTRIES);
65
+
}
66
+
67
+
// Convert Map -> Array -> JSON String
68
+
localStorage.setItem(key, JSON.stringify(entries));
69
+
} catch (error) {
70
+
console.error("[Cache] Failed to save:", error);
71
+
}
72
+
},
73
+
74
+
removeItem: (key: string) => {
75
+
if (typeof window !== "undefined" && window.localStorage) {
76
+
localStorage.removeItem(key);
77
+
}
78
+
},
79
+
};
80
+
81
+
// --- Atoms ---
82
+
83
+
export const labelerConfigAtom = atom<LabelerDefinition[]>([]);
84
+
85
+
export const moderationCacheAtom = atomWithStorage<CacheMap>(
86
+
STORAGE_KEY,
87
+
new Map(),
88
+
mapStorage // <--- Pass our custom object here
89
+
);
90
+
91
+
export const pendingUriQueueAtom = atom<Set<string>>(new Set<string>());
92
+
export const processingUriSetAtom = atom<Set<string>>(new Set<string>());
+61
src/types/moderation.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
// AT Protocol moderation types
2
+
3
+
export type LabelPreference = "ignore" | "warn" | "hide";
4
+
5
+
export interface LabelerDefinition {
6
+
did: string;
7
+
url: string;
8
+
isDefault: boolean;
9
+
supportedLabels: Record<string, LabelPreference>;
10
+
// The lookup map for UI strings
11
+
labelDefs: Record<string, LabelValueDefinition>;
12
+
}
13
+
14
+
export interface LabelValueDefinition {
15
+
identifier: string;
16
+
severity: 'inform' | 'alert' | 'none';
17
+
blurs: 'content' | 'media' | 'none';
18
+
adultOnly: boolean;
19
+
defaultSetting?: LabelPreference;
20
+
locales: Array<{
21
+
lang: string;
22
+
name: string;
23
+
description: string;
24
+
}>;
25
+
}
26
+
27
+
export interface ContentLabel {
28
+
sourceDid: string; // Who said it?
29
+
val: string; // What is the label?
30
+
cts: string; // Timestamp
31
+
preference: LabelPreference; // Resolved preference for this specific label
32
+
}
33
+
34
+
// Type for the labeler service record response
35
+
export interface LabelerServiceRecord {
36
+
did: string;
37
+
serviceEndpoint: string;
38
+
policies: {
39
+
labelValues: string[];
40
+
labelValueDefinitions?: Array<{
41
+
identifier: string;
42
+
defaultSetting: LabelPreference;
43
+
}>;
44
+
};
45
+
}
46
+
47
+
// Type for queryLabels response (matches ATProto API)
48
+
export interface QueryLabelsResponse {
49
+
cursor?: string;
50
+
labels: Array<{
51
+
ver?: number;
52
+
src: string; // DID
53
+
uri: string; // AT URI
54
+
cid?: string; // CID
55
+
val: string; // Label value
56
+
neg?: boolean; // Negation label
57
+
cts: string; // Created timestamp
58
+
exp?: string; // Expiry timestamp
59
+
sig?: Uint8Array; // Signature
60
+
}>;
61
+
}
+76
-40
src/utils/useQuery.ts
···
15
16
export function constructIdentityQuery(
17
didorhandle?: string,
18
-
slingshoturl?: string
19
) {
20
return queryOptions({
21
queryKey: ["identity", didorhandle],
22
queryFn: async () => {
23
if (!didorhandle) return undefined as undefined;
24
const res = await fetch(
25
-
`https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`
26
);
27
if (!res.ok) throw new Error("Failed to fetch post");
28
try {
···
71
queryFn: async () => {
72
if (!uri) return undefined as undefined;
73
const res = await fetch(
74
-
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
75
);
76
let data: any;
77
try {
···
135
queryFn: async () => {
136
if (!uri) return undefined as undefined;
137
const res = await fetch(
138
-
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
139
);
140
let data: any;
141
try {
···
269
const cursor = query.cursor;
270
const dids = query?.dids;
271
const res = await fetch(
272
-
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`
273
);
274
if (!res.ok) throw new Error("Failed to fetch post");
275
try {
···
308
const [constellationurl] = useAtom(constellationURLAtom);
309
const queryres = useQuery(
310
constructConstellationQuery(
311
-
query && { constellation: constellationurl, ...query }
312
-
)
313
) as unknown as UseQueryResult<linksCountResponse, Error>;
314
if (!query) {
315
return undefined as undefined;
···
389
const [constellationurl] = useAtom(constellationURLAtom);
390
return useQuery(
391
constructConstellationQuery(
392
-
query && { constellation: constellationurl, ...query }
393
-
)
394
);
395
}
396
···
446
// Authenticated flow
447
if (!agent || !pdsUrl || !feedServiceDid) {
448
throw new Error(
449
-
"Missing required info for authenticated feed fetch."
450
);
451
}
452
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
···
481
feedServiceDid?: string;
482
}) {
483
return useQuery(constructFeedSkeletonQuery(options));
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
484
}
485
486
export function constructPreferencesQuery(
487
agent?: ATPAPI.Agent | undefined,
488
-
pdsUrl?: string | undefined
489
) {
490
return queryOptions({
491
queryKey: ["preferences", agent?.did],
···
511
queryFn: async () => {
512
if (!uri) return undefined as undefined;
513
const res = await fetch(
514
-
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`
515
);
516
let data: any;
517
try {
···
590
export function constructAuthorFeedQuery(
591
did: string,
592
pdsUrl: string,
593
-
collection: string = "app.bsky.feed.post"
594
) {
595
return queryOptions({
596
queryKey: ["authorFeed", did, collection],
···
613
export function useInfiniteQueryAuthorFeed(
614
did: string | undefined,
615
pdsUrl: string | undefined,
616
-
collection?: string
617
) {
618
const { queryKey, queryFn } = constructAuthorFeedQuery(
619
did!,
620
pdsUrl!,
621
-
collection
622
);
623
624
return useInfiniteQuery({
···
655
if (isAuthed && !unauthedfeedurl) {
656
if (!agent || !pdsUrl || !feedServiceDid) {
657
throw new Error(
658
-
"Missing required info for authenticated feed fetch."
659
);
660
}
661
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
···
748
collection ? `&collection=${encodeURIComponent(collection)}` : ""
749
}${path ? `&path=${encodeURIComponent(path)}` : ""}${
750
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""
751
-
}`
752
);
753
754
if (!res.ok) throw new Error("Failed to fetch");
···
774
agent: agent || undefined,
775
isAuthed: status === "signedIn",
776
pdsUrl: identity?.pds,
777
-
feedServiceDid: "did:web:"+lycanurl,
778
-
})
779
);
780
}
781
···
802
});
803
if (!res.ok)
804
throw new Error(
805
-
`Authenticated lycan status fetch failed: ${res.statusText}`
806
);
807
return (await res.json()) as statuschek;
808
}
···
816
error?: "MethodNotImplemented";
817
message?: "Method Not Implemented";
818
status?: "finished" | "in_progress";
819
-
position?: string,
820
-
progress?: number,
821
-
822
};
823
824
//{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268}
825
type importtype = {
826
-
message?: "Import has already started" | "Import has been scheduled"
827
-
}
828
829
export function constructLycanRequestIndexQuery(options: {
830
agent?: ATPAPI.Agent;
···
849
});
850
if (!res.ok)
851
throw new Error(
852
-
`Authenticated lycan status fetch failed: ${res.statusText}`
853
);
854
-
return await res.json() as importtype;
855
}
856
return undefined;
857
},
···
864
cursor?: string;
865
};
866
867
-
868
-
export function useInfiniteQueryLycanSearch(options: { query: string, type: "likes" | "pins" | "reposts" | "quotes"}) {
869
-
870
-
871
const [lycanurl] = useAtom(lycanURLAtom);
872
const { agent, status } = useAuth();
873
const { data: identity } = useQueryIdentity(agent?.did);
874
875
const { queryKey, queryFn } = constructLycanSearchQuery({
876
-
agent: agent || undefined,
877
-
isAuthed: status === "signedIn",
878
-
pdsUrl: identity?.pds,
879
-
feedServiceDid: "did:web:"+lycanurl,
880
-
query: options.query,
881
-
type: options.type,
882
-
})
883
884
return {
885
...useInfiniteQuery({
···
900
queryKey: queryKey,
901
};
902
}
903
-
904
905
export function constructLycanSearchQuery(options: {
906
agent?: ATPAPI.Agent;
···
929
});
930
if (!res.ok)
931
throw new Error(
932
-
`Authenticated lycan status fetch failed: ${res.statusText}`
933
);
934
return (await res.json()) as LycanSearchPage;
935
}
···
15
16
export function constructIdentityQuery(
17
didorhandle?: string,
18
+
slingshoturl?: string,
19
) {
20
return queryOptions({
21
queryKey: ["identity", didorhandle],
22
queryFn: async () => {
23
if (!didorhandle) return undefined as undefined;
24
const res = await fetch(
25
+
`https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`,
26
);
27
if (!res.ok) throw new Error("Failed to fetch post");
28
try {
···
71
queryFn: async () => {
72
if (!uri) return undefined as undefined;
73
const res = await fetch(
74
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`,
75
);
76
let data: any;
77
try {
···
135
queryFn: async () => {
136
if (!uri) return undefined as undefined;
137
const res = await fetch(
138
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`,
139
);
140
let data: any;
141
try {
···
269
const cursor = query.cursor;
270
const dids = query?.dids;
271
const res = await fetch(
272
+
`https://${query.constellation}${method}?target=${encodeURIComponent(target)}${collection ? `&collection=${encodeURIComponent(collection)}` : ""}${path ? `&path=${encodeURIComponent(path)}` : ""}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}${dids ? dids.map((did) => `&did=${encodeURIComponent(did)}`).join("") : ""}`,
273
);
274
if (!res.ok) throw new Error("Failed to fetch post");
275
try {
···
308
const [constellationurl] = useAtom(constellationURLAtom);
309
const queryres = useQuery(
310
constructConstellationQuery(
311
+
query && { constellation: constellationurl, ...query },
312
+
),
313
) as unknown as UseQueryResult<linksCountResponse, Error>;
314
if (!query) {
315
return undefined as undefined;
···
389
const [constellationurl] = useAtom(constellationURLAtom);
390
return useQuery(
391
constructConstellationQuery(
392
+
query && { constellation: constellationurl, ...query },
393
+
),
394
);
395
}
396
···
446
// Authenticated flow
447
if (!agent || !pdsUrl || !feedServiceDid) {
448
throw new Error(
449
+
"Missing required info for authenticated feed fetch.",
450
);
451
}
452
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`;
···
481
feedServiceDid?: string;
482
}) {
483
return useQuery(constructFeedSkeletonQuery(options));
484
+
}
485
+
486
+
export function constructRecordQuery(
487
+
did?: string,
488
+
collection?: string,
489
+
rkey?: string,
490
+
pdsUrl?: string,
491
+
) {
492
+
return queryOptions({
493
+
queryKey: ["record", did, collection, rkey],
494
+
queryFn: async () => {
495
+
if (!did || !collection || !rkey || !pdsUrl)
496
+
return undefined as undefined;
497
+
const url = `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`;
498
+
const res = await fetch(url);
499
+
if (!res.ok) throw new Error("Failed to fetch record");
500
+
try {
501
+
return (await res.json()) as {
502
+
uri: string;
503
+
cid: string;
504
+
value: any;
505
+
};
506
+
} catch (_e) {
507
+
return undefined;
508
+
}
509
+
},
510
+
staleTime: 5 * 60 * 1000, // 5 minutes
511
+
gcTime: 5 * 60 * 1000,
512
+
});
513
+
}
514
+
515
+
export function useQueryRecord(
516
+
did?: string,
517
+
collection?: string,
518
+
rkey?: string,
519
+
pdsUrl?: string,
520
+
) {
521
+
return useQuery(constructRecordQuery(did, collection, rkey, pdsUrl));
522
}
523
524
export function constructPreferencesQuery(
525
agent?: ATPAPI.Agent | undefined,
526
+
pdsUrl?: string | undefined,
527
) {
528
return queryOptions({
529
queryKey: ["preferences", agent?.did],
···
549
queryFn: async () => {
550
if (!uri) return undefined as undefined;
551
const res = await fetch(
552
+
`https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`,
553
);
554
let data: any;
555
try {
···
628
export function constructAuthorFeedQuery(
629
did: string,
630
pdsUrl: string,
631
+
collection: string = "app.bsky.feed.post",
632
) {
633
return queryOptions({
634
queryKey: ["authorFeed", did, collection],
···
651
export function useInfiniteQueryAuthorFeed(
652
did: string | undefined,
653
pdsUrl: string | undefined,
654
+
collection?: string,
655
) {
656
const { queryKey, queryFn } = constructAuthorFeedQuery(
657
did!,
658
pdsUrl!,
659
+
collection,
660
);
661
662
return useInfiniteQuery({
···
693
if (isAuthed && !unauthedfeedurl) {
694
if (!agent || !pdsUrl || !feedServiceDid) {
695
throw new Error(
696
+
"Missing required info for authenticated feed fetch.",
697
);
698
}
699
const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`;
···
786
collection ? `&collection=${encodeURIComponent(collection)}` : ""
787
}${path ? `&path=${encodeURIComponent(path)}` : ""}${
788
cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""
789
+
}`,
790
);
791
792
if (!res.ok) throw new Error("Failed to fetch");
···
812
agent: agent || undefined,
813
isAuthed: status === "signedIn",
814
pdsUrl: identity?.pds,
815
+
feedServiceDid: "did:web:" + lycanurl,
816
+
}),
817
);
818
}
819
···
840
});
841
if (!res.ok)
842
throw new Error(
843
+
`Authenticated lycan status fetch failed: ${res.statusText}`,
844
);
845
return (await res.json()) as statuschek;
846
}
···
854
error?: "MethodNotImplemented";
855
message?: "Method Not Implemented";
856
status?: "finished" | "in_progress";
857
+
position?: string;
858
+
progress?: number;
0
859
};
860
861
//{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268}
862
type importtype = {
863
+
message?: "Import has already started" | "Import has been scheduled";
864
+
};
865
866
export function constructLycanRequestIndexQuery(options: {
867
agent?: ATPAPI.Agent;
···
886
});
887
if (!res.ok)
888
throw new Error(
889
+
`Authenticated lycan status fetch failed: ${res.statusText}`,
890
);
891
+
return (await res.json()) as importtype;
892
}
893
return undefined;
894
},
···
901
cursor?: string;
902
};
903
904
+
export function useInfiniteQueryLycanSearch(options: {
905
+
query: string;
906
+
type: "likes" | "pins" | "reposts" | "quotes";
907
+
}) {
908
const [lycanurl] = useAtom(lycanURLAtom);
909
const { agent, status } = useAuth();
910
const { data: identity } = useQueryIdentity(agent?.did);
911
912
const { queryKey, queryFn } = constructLycanSearchQuery({
913
+
agent: agent || undefined,
914
+
isAuthed: status === "signedIn",
915
+
pdsUrl: identity?.pds,
916
+
feedServiceDid: "did:web:" + lycanurl,
917
+
query: options.query,
918
+
type: options.type,
919
+
});
920
921
return {
922
...useInfiniteQuery({
···
937
queryKey: queryKey,
938
};
939
}
0
940
941
export function constructLycanSearchQuery(options: {
942
agent?: ATPAPI.Agent;
···
965
});
966
if (!res.ok)
967
throw new Error(
968
+
`Authenticated lycan status fetch failed: ${res.statusText}`,
969
);
970
return (await res.json()) as LycanSearchPage;
971
}