an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
1import * as ATPAPI from "@atproto/api";
2import { isContentLabelPref, isLabelersPref } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
3import { useQueries, useQueryClient } from "@tanstack/react-query";
4import { useAtom } from "jotai";
5import { Dialog } from "radix-ui";
6import React, { createContext, useContext, useMemo } from "react";
7
8import { FORCED_LABELER_DIDS } from "~/../policy";
9import { useAuth } from "~/providers/UnifiedAuthProvider";
10import { NotificationItem } from "~/routes/notifications";
11import { disabledLabelersAtom, slingshotURLAtom } from "~/utils/atoms";
12import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery";
13
14// higher prio overwrites lower prio
15export const LABEL_PRIO_DEFAULT = 0
16export const LABEL_PRIO_USER = 5
17// export const LABEL_PRIO_FORCED = 20 // Not used in pref merging
18
19export interface HydratedLabelValueDefinition extends ATPAPI.ComAtprotoLabelDefs.LabelValueDefinition {
20 pref: ATPAPI.ComAtprotoLabelDefs.LabelValueDefinition["defaultSetting"]
21}
22
23export type labelpref = {
24 did: string,
25 label: string,
26 visibility: ATPAPI.AppBskyActorDefs.ContentLabelPref["visibility"],
27 priority: number
28}
29
30type labeldefpref = {
31 did: string,
32 labeldefs?: ATPAPI.ComAtprotoLabelDefs.LabelValueDefinition[],
33 err?: string
34}
35
36type hydratedlabeldefpref = {
37 did: string,
38 labeldefs?: HydratedLabelValueDefinition[],
39 err?: string
40}
41
42type AutoLabelConfig = {
43 activeLabelerDids: string[];
44 mergedPrefContentLabels: labelpref[];
45 hydratedLabelDefs: Map<string, HydratedLabelValueDefinition>;
46 isLoading: boolean;
47 isError: boolean;
48 sendError: (error: LabelerError) => void;
49};
50
51export type LabelerError = { did: string; message: string };
52
53const AutoLabelContext = createContext<AutoLabelConfig | undefined>(undefined);
54
55export function useAutoLabelConfig(): AutoLabelConfig {
56 const context = useContext(AutoLabelContext);
57 if (!context) {
58 throw new Error('useAutoLabelConfig must be used within an AutoLabelProvider');
59 }
60 return context;
61}
62
63export function AutoLabelProvider({ children }: { children: React.ReactNode }) {
64 const { agent, status } = useAuth();
65 const [slingshoturl] = useAtom(slingshotURLAtom);
66 const queryClient = useQueryClient();
67
68 const [disabledLabelers, setDisabledLabelers] = useAtom(disabledLabelersAtom);
69
70 // Modal state
71 const [errorModalOpen, setErrorModalOpen] = React.useState(false);
72 const [labelerErrors, setLabelerErrors] = React.useState<LabelerError[]>([]);
73 const erroredLabelerDids = new Set(
74 labelerErrors.map(e => e.did)
75 );
76
77 const sendError = React.useCallback((error: LabelerError) => {
78 setLabelerErrors((prev) => {
79 // Avoid duplicates
80 if (prev.find(e => e.did === error.did)) return prev;
81 return [...prev, error];
82 });
83 setErrorModalOpen(true);
84 }, []);
85
86
87 const isUnauthed = status === "signedOut" || !agent;
88
89 // 1. Get User Identity & Preferences
90 const { data: identity } = useQueryIdentity(agent?.did);
91 const {
92 data: prefs,
93 isLoading: prefsLoading,
94 isError: prefsError
95 } = useQueryPreferences({
96 agent: agent ?? undefined,
97 pdsUrl: identity?.pds,
98 });
99
100 // 2. Identify Labeler DIDs & User Preferences (Use useMemo for stability)
101 const userPrefLabelerDids = useMemo(() =>
102 prefs?.preferences?.find(isLabelersPref)?.labelers.map(l => l.did) ?? []
103 , [prefs]);
104
105 const userPrefContentLabelsRaw = useMemo(() =>
106 prefs?.preferences?.filter(isContentLabelPref) ?? []
107 , [prefs]);
108
109 const userPrefContentLabels: labelpref[] = useMemo(() => userPrefContentLabelsRaw.map((pref) => {
110 const res: labelpref = {
111 did: pref.labelerDid || "global",
112 label: pref.label,
113 visibility: pref.visibility,
114 priority: LABEL_PRIO_USER
115 }
116 return res
117 }), [userPrefContentLabelsRaw]);
118
119 // 3. Force Bsky DID + User DIDs
120 const userPrefLabelerDidsFiltered = React.useMemo(
121 () => userPrefLabelerDids.filter(did => !disabledLabelers.includes(did)),
122 [userPrefLabelerDids, disabledLabelers]
123 );
124
125 const activeLabelerDids = useMemo(() => Array.from(
126 new Set([...FORCED_LABELER_DIDS, ...userPrefLabelerDidsFiltered])
127 ), [userPrefLabelerDidsFiltered]);
128
129 // 4. Parallel fetch Service Records (The expensive part)
130 const labelerServiceQueries = useQueries({
131 queries: activeLabelerDids.map((did: string) => ({
132 queryKey: ["labelerServiceDefaultstestwhatever", did],
133 queryFn: async () => {
134 const host = slingshoturl;
135 const response = await fetch(
136 `https://${host}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.labeler.service&rkey=self`,
137 );
138 if (!response.ok) throw new Error("Failed to fetch labeler service");
139 try {
140 const res = await response.json() as {
141 uri: string;
142 cid: string;
143 value: ATPAPI.AppBskyLabelerService.Record;
144 };
145 const labeldefs = res.value.policies.labelValueDefinitions
146 return {
147 did: did,
148 labeldefs: labeldefs
149 } as labeldefpref
150 } catch {
151 sendError({ did, message: ".json()" });
152 return {
153 did: did,
154 err: ".json()"
155 } as labeldefpref
156 }
157 },
158 staleTime: 1000 * 60 * 60, // Cache for 1 hour
159 })),
160 });
161
162 const defaultPrefContentLabels: labelpref[] = useMemo(() =>
163 labelerServiceQueries.flatMap(labeler => {
164 if (labeler.error || labeler.data?.err || !labeler.data) {
165 return []
166 }
167
168 const labelerDid = labeler.data.did
169
170 return labeler.data.labeldefs?.map(label => ({
171 did: labelerDid,
172 label: label.identifier,
173 visibility: label.defaultSetting,
174 priority: LABEL_PRIO_DEFAULT,
175 })) ?? []
176 })
177 , [labelerServiceQueries]);
178
179 // 5. Merge Default and User Preferences
180 const mergedPrefContentLabels: labelpref[] = useMemo(() => {
181 const allrawPrefContentLabels = [...defaultPrefContentLabels, ...userPrefContentLabels]
182
183 return Object.values(
184 allrawPrefContentLabels.reduce(
185 (acc, pref) => {
186 const key = `${pref.did}::${pref.label}`
187 const existing = acc[key]
188 if (!existing || pref.priority > existing.priority) {
189 acc[key] = pref
190 }
191 return acc
192 },
193 {} as Record<string, labelpref>
194 )
195 )
196 }, [defaultPrefContentLabels, userPrefContentLabels]);
197
198 const hydratedLabelDefs = convertLabelDefsToMap(
199 labelerServiceQueries
200 .map(item => item.data)
201 .filter((data): data is labeldefpref => data !== undefined),
202 mergedPrefContentLabels
203 )
204
205 const labelerServiceLoading = labelerServiceQueries.some(q => q.isLoading);
206 const labelerServiceError = labelerServiceQueries.some(q => q.isError);
207
208 const isLoading = prefsLoading || labelerServiceLoading;
209 const isError = (!isUnauthed && prefsError) || labelerServiceError;
210
211 const value = useMemo(() => ({
212 activeLabelerDids,
213 mergedPrefContentLabels,
214 hydratedLabelDefs,
215 isLoading,
216 isError,
217 sendError
218 }), [activeLabelerDids, mergedPrefContentLabels, hydratedLabelDefs, isLoading, isError, sendError]);
219
220 // The provider must wrap the application root where useAutoLabels is called
221 return (
222 <>
223 <AutoLabelContext.Provider value={value}>{children}</AutoLabelContext.Provider>
224 {errorModalOpen && (
225 <Dialog.Root open={errorModalOpen} onOpenChange={setErrorModalOpen}>
226 <Dialog.Overlay className="z-70 fixed inset-0 bg-black/40" />
227 <Dialog.Content className="z-80 fixed inset-0 flex justify-center items-center">
228 <div className="max-w-md max-h-[calc(100dvh-80px)] flex flex-col py-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow-xl">
229 <div className="flex flex-col gap-2 mx-4">
230 <span className="text-[19px] font-semibold">Labeler Errors</span>
231 <span className="text-gray-700 dark:text-gray-400">These labelers have returned an error. <br />You can disable them to continue using the app.</span>
232 </div>
233 <div className="mb-4 space-y-2 overflow-y-auto">
234 {labelerErrors.map(e => (
235 // <li key={e.did} className="flex justify-between items-center border-b pb-1">
236 // <span>{e.did}: {e.message}</span>
237 // <button
238 // className="text-red-500 text-sm"
239 // onClick={() => {
240 // setDisabledLabelers(prev => [...prev, e.did]);
241 // setLabelerErrors(prev => prev.filter(err => err.did !== e.did));
242 // }}
243 // >
244 // Disable
245 // </button>
246 // </li>
247 <NotificationItem
248 key={e.did}
249 notification={e.did}
250 labeler={true}
251 labelererror={e.message || "unknown error"}
252 />
253 ))}
254 </div>
255 <div className="flex justify-end gap-2 mx-4">
256 {/* <button
257 className="px-3 py-1 rounded bg-gray-200 dark:bg-gray-700"
258 onClick={() => setErrorModalOpen(false)}
259 >
260 Close
261 </button> */}
262 <button
263 className="rounded-full bg-gray-200 hover:bg-gray-300 dark:bg-gray-800 dark:hover:bg-gray-700 p-4 flex-1 flex items-center justify-center"
264 onClick={() => {
265 queryClient.refetchQueries({
266 predicate: query => {
267 const key = query.queryKey;
268
269 // Defensive: ensure shape matches
270 if (
271 !Array.isArray(key) ||
272 key.length < 4
273 ) return false;
274
275 const [, kind, , labelerDid] = key;
276
277 return (
278 kind === "slq" &&
279 typeof labelerDid === "string" &&
280 erroredLabelerDids.has(labelerDid)
281 );
282 },
283 });
284 setLabelerErrors([]);
285 setErrorModalOpen(false);
286 // optional: retry all
287 }}
288 >
289 Retry
290 </button>
291 </div>
292 </div>
293 </Dialog.Content>
294 </Dialog.Root>
295 )}
296 </>
297 );
298}
299
300function convertLabelDefsToMap(
301 labelDefPrefs: labeldefpref[],
302 mergedPrefContentLabels: labelpref[]
303): Map<string, HydratedLabelValueDefinition> {
304
305 const labelMap = new Map<string, HydratedLabelValueDefinition>();
306
307 for (const pref of labelDefPrefs) {
308 const did = pref.did;
309
310 // Only process if labeldefs array exists
311 if (pref.labeldefs && pref.labeldefs.length > 0) {
312 for (const def of pref.labeldefs) {
313 // Construct the key: did + "::" + identifier
314
315 const key = `${did}::${def.identifier}`;
316
317 const prefdlabelvis = mergedPrefContentLabels.find(i => i.did === did && i.label === def.identifier)?.visibility
318 const hydrateddef: HydratedLabelValueDefinition = {
319 ...def,
320 pref: collapsePrefs(prefdlabelvis ? prefdlabelvis : def.defaultSetting)
321 };
322
323 // Set the key and the LabelValueDefinition as the value
324 labelMap.set(key, hydrateddef);
325 }
326 }
327 }
328
329 return labelMap;
330}
331
332function collapsePrefs(def: "ignore" | "warn" | "hide" | "show" | (string & {})): "ignore" | "warn" | "hide" {
333 if (def === "ignore") return "ignore"
334 if (def === "warn") return "warn"
335 if (def === "hide") return "hide"
336 if (def === "show") return "ignore"
337 if (def === "alert") return "warn"
338 if (def === "inform") return "warn"
339 if (def === "none") return "ignore"
340 return "ignore"
341}