an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
at main 341 lines 12 kB view raw
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}