an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app

host-configurable moderation policy

+151 -61
+17
policy.ts
··· 1 + export const FORCED_LABELER_DIDS = [ 2 + "did:plc:ar7c4by46qjdydhdevvrndac" // bluesky moderation 3 + ]; 4 + 5 + export const UNAUTHED_FORCE_WARN_LABELS = new Set([ 6 + // i dont know if some of these are even valid labels 7 + "porn", 8 + "sexual", 9 + "graphic-media", 10 + "nudity", 11 + "nsfl", 12 + "corpse", 13 + "gore", 14 + "!no-unauthenticated" 15 + ]); 16 + 17 + export const UNAUTHED_PREVENT_OPENING_WARNS = true;
+113 -56
src/components/ModerationInitializer.tsx
··· 1 1 import { useQueries } from "@tanstack/react-query"; 2 - import { useSetAtom } from "jotai"; 3 - import { useEffect } from "react"; 2 + import { useAtom, useSetAtom } from "jotai"; 3 + import { useEffect, useRef } from "react"; 4 4 5 + import { FORCED_LABELER_DIDS, UNAUTHED_FORCE_WARN_LABELS } from "~/../policy"; 5 6 import { useAuth } from "~/providers/UnifiedAuthProvider"; 6 7 import { labelerConfigAtom } from "~/state/moderationAtoms"; 7 8 import type { LabelerDefinition, LabelPreference, LabelValueDefinition } from "~/types/moderation"; 9 + import { slingshotURLAtom } from "~/utils/atoms"; 8 10 import { useQueryIdentity } from "~/utils/useQuery"; 9 11 import { useQueryPreferences } from "~/utils/useQuery"; 10 12 11 - export const BSKY_LABELER_DID = "did:plc:ar7c4by46qjdydhdevvrndac"; 12 - 13 13 // Manual DID document resolution 14 14 const fetchDidDocument = async (did: string): Promise<any> => { 15 15 if (did.startsWith("did:plc:")) { 16 - // For PLC DIDs, fetch from plc.directory 17 16 const response = await fetch( 18 17 `https://plc.directory/${encodeURIComponent(did)}`, 19 18 ); ··· 21 20 throw new Error(`Failed to fetch PLC DID document for ${did}`); 22 21 return response.json(); 23 22 } else if (did.startsWith("did:web:")) { 24 - // For web DIDs, fetch from well-known 25 23 const handle = did.replace("did:web:", ""); 26 24 const url = `https://${handle}/.well-known/did.json`; 27 25 const response = await fetch(url); ··· 36 34 }; 37 35 38 36 export const ModerationInitializer = () => { 39 - const { agent } = useAuth(); 37 + const { agent, status } = useAuth(); 40 38 const setLabelerConfig = useSetAtom(labelerConfigAtom); 39 + const [slingshoturl] = useAtom(slingshotURLAtom); 41 40 42 - // 1. Get User Identity to get PDS URL 41 + // Define clear boolean for mode 42 + const isUnauthed = status === "signedOut" || !agent; 43 + 44 + // Track previous status to detect transitions 45 + const prevStatusRef = useRef(status); 46 + 47 + // --- 1. THE HARD FLUSH --- 48 + // When Auth Status changes (Logged In <-> Logged Out), immediately wipe the config. 49 + // This prevents "Authed" prefs from bleeding into "Unauthed" state and vice versa 50 + // while the async queries are spinning up. 51 + useEffect(() => { 52 + if (prevStatusRef.current !== status) { 53 + console.log(`[Moderation] Auth status changed (${prevStatusRef.current} -> ${status}). Flushing config.`); 54 + setLabelerConfig([]); // <--- WIPE CLEAN 55 + prevStatusRef.current = status; 56 + } 57 + }, [status, setLabelerConfig]); 58 + 59 + // 2. Get User Identity (Only if authed) 43 60 const { data: identity } = useQueryIdentity(agent?.did); 44 61 45 - // 2. Get User Preferences (Global: "porn" -> "hide") 62 + // 3. Get User Preferences (Only if authed) 46 63 const { data: prefs } = useQueryPreferences({ 47 64 agent: agent ?? undefined, 48 65 pdsUrl: identity?.pds, 49 66 }); 50 67 51 - // 3. Identify Labeler DIDs from prefs 52 - const userPrefDids = 53 - prefs?.preferences 54 - ?.find((pref: any) => pref.$type === "app.bsky.actor.defs#labelersPref") 55 - ?.labelers?.map((l: any) => l.did) ?? []; 68 + // 4. Identify Labeler DIDs 69 + // Important: If unauthed, userPrefDids MUST be empty, even if cache exists. 70 + const userPrefDids = !isUnauthed 71 + ? prefs?.preferences 72 + ?.find((pref: any) => pref.$type === "app.bsky.actor.defs#labelersPref") 73 + ?.labelers?.map((l: any) => l.did) ?? [] 74 + : []; 56 75 57 - // 2. MERGE: Force Bsky DID + User DIDs (Set removes duplicates) 76 + // 5. Force Bsky DID + User DIDs 58 77 const activeLabelerDids = Array.from( 59 - new Set([BSKY_LABELER_DID, ...userPrefDids]) 78 + new Set([...FORCED_LABELER_DIDS, ...userPrefDids]) 60 79 ); 61 80 62 - // 4. Parallel fetch all Labeler DID Documents and Service Records 81 + // 6. Parallel fetch DID Docs 63 82 const labelerDidDocQueries = useQueries({ 64 83 queries: activeLabelerDids.map((did: string) => ({ 65 84 queryKey: ["labelerDidDoc", did], 66 85 queryFn: () => fetchDidDocument(did), 67 - staleTime: 5 * 60 * 1000, // 5 minutes 68 - retry: 1, // Only retry once for DID docs 86 + staleTime: 1000 * 60 * 60 * 24, 69 87 })), 70 88 }); 71 89 90 + // 7. Parallel fetch Service Records 72 91 const labelerServiceQueries = useQueries({ 73 92 queries: activeLabelerDids.map((did: string) => ({ 74 93 queryKey: ["labelerService", did], 75 94 queryFn: async () => { 76 - if (!identity?.pds) throw new Error("No PDS URL"); 95 + const host = slingshoturl || "public.api.bsky.app"; 77 96 const response = await fetch( 78 - `${identity.pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent("app.bsky.labeler.service")}&rkey=self`, 97 + `https://${host}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent("app.bsky.labeler.service")}&rkey=self`, 79 98 ); 80 99 if (!response.ok) throw new Error("Failed to fetch labeler service"); 81 100 return response.json(); 82 101 }, 83 - enabled: !!identity?.pds && !!agent, 84 - staleTime: 5 * 60 * 1000, // 5 minutes 102 + staleTime: 1000 * 60 * 60, 85 103 })), 86 104 }); 87 105 88 106 useEffect(() => { 107 + // Guard: Wait for queries 89 108 if ( 90 - !prefs || 91 109 labelerDidDocQueries.some((q) => q.isLoading) || 92 - labelerDidDocQueries.some((q) => q.isFetching) || 93 - labelerServiceQueries.some((q) => q.isLoading) || 94 - labelerServiceQueries.some((q) => q.isFetching) 95 - ) 110 + labelerServiceQueries.some((q) => q.isLoading) 111 + ) { 96 112 return; 113 + } 97 114 98 - // Extract content label preferences 99 - const contentLabelPrefs = 100 - prefs.preferences?.filter( 101 - (pref: any) => pref.$type === "app.bsky.actor.defs#contentLabelPref", 102 - ) ?? []; 115 + // Guard: If we are supposed to be Authed, but prefs haven't loaded yet, 116 + // DO NOT run the logic. Wait. This prevents falling back to defaults temporarily. 117 + if (!isUnauthed && !prefs) { 118 + return; 119 + } 103 120 121 + // A. Extract User Global Overrides 122 + // STRICT SEPARATION: If unauthed, force this to be empty to ensure no leakage. 104 123 const globalPrefs: Record<string, LabelPreference> = {}; 105 - contentLabelPrefs.forEach((pref: any) => { 106 - globalPrefs[pref.label] = pref.visibility as LabelPreference; 107 - }); 124 + 125 + if (!isUnauthed && prefs?.preferences) { 126 + const contentLabelPrefs = prefs.preferences.filter( 127 + (pref: any) => pref.$type === "app.bsky.actor.defs#contentLabelPref", 128 + ); 129 + contentLabelPrefs.forEach((pref: any) => { 130 + globalPrefs[pref.label] = pref.visibility as LabelPreference; 131 + }); 132 + } 108 133 109 134 const definitions: LabelerDefinition[] = activeLabelerDids 110 135 .map((did: string, index: number) => { ··· 113 138 114 139 if (!didDocQuery.data || !serviceQuery.data) return null; 115 140 116 - // Extract service endpoint from DID document 117 141 const didDoc = didDocQuery.data as any; 118 142 const atprotoLabelerService = didDoc?.service?.find( 119 143 (s: any) => s.id === "#atproto_labeler", 120 144 ); 121 145 122 - const record = (serviceQuery.data as any).value; // The raw ATProto record 146 + const record = (serviceQuery.data as any).value; 123 147 124 - // 1. Create the Metadata Map 148 + // B. Gather ALL identifiers 149 + const allIdentifiers = new Set<string>(); 150 + record.policies?.labelValues?.forEach((val: string) => allIdentifiers.add(val)); 151 + record.policies?.labelValueDefinitions?.forEach((def: any) => allIdentifiers.add(def.identifier)); 152 + 153 + // C. Create Metadata Map 125 154 const labelDefs: Record<string, LabelValueDefinition> = {}; 126 - 127 155 if (record.policies.labelValueDefinitions) { 128 156 record.policies.labelValueDefinitions.forEach((def: any) => { 129 157 labelDefs[def.identifier] = { ··· 132 160 blurs: def.blurs, 133 161 adultOnly: def.adultOnly, 134 162 defaultSetting: def.defaultSetting, 135 - locales: def.locales || [] // <--- Capture the locales array 163 + locales: def.locales || [] 136 164 }; 137 165 }); 138 166 } 139 167 140 - // RESOLUTION LOGIC: 141 - // Map record.policies.labelValueDefinitions to a lookup map. 142 - // Priority: User Global Pref > Labeler Default > 'ignore' 168 + // D. Resolve Preferences 143 169 const supportedLabels: Record<string, LabelPreference> = {}; 144 170 145 - record.policies?.labelValues?.forEach((val: string) => { 146 - // Does user have a global override for this string? 171 + allIdentifiers.forEach((val) => { 172 + // todo this works but with how useModeration hooks works right now old verdicts wont get stale-d 173 + // it only works right now because these are warns and warns are negligable i guess 174 + // --- BRANCH 1: UNAUTHED MODE --- 175 + if (isUnauthed) { 176 + // 1. Strict Force Overrides 177 + if (UNAUTHED_FORCE_WARN_LABELS.has(val)) { 178 + supportedLabels[val] = "warn"; // or 'hide' if that's what your policy constant implies 179 + return; 180 + } 181 + 182 + // 2. Default Labeler Settings 183 + const def = labelDefs[val]; 184 + const rawDefault = def?.defaultSetting || "ignore"; 185 + 186 + // 3. Apply Unauthed-Specific Aliasing (Optional) 187 + // e.g., if you want to hide 'inform' labels for unauthed users 188 + supportedLabels[val] = rawDefault as LabelPreference; 189 + return; 190 + } 191 + 192 + // --- BRANCH 2: AUTHED MODE --- 193 + // 1. User Global Override (Highest Priority) 147 194 const globalPref = globalPrefs[val]; 148 - // Or use labeler default 149 - const defaultPref = 150 - record.policies?.labelValueDefinitions?.find( 151 - (d: any) => d.identifier === val, 152 - )?.defaultSetting || "ignore"; 195 + if (globalPref) { 196 + supportedLabels[val] = globalPref; 197 + return; 198 + } 153 199 154 - supportedLabels[val] = (globalPref || defaultPref) as LabelPreference; 200 + // 2. Labeler Default 201 + const def = labelDefs[val]; 202 + const rawDefault = def?.defaultSetting || "ignore"; 203 + 204 + supportedLabels[val] = rawDefault as LabelPreference; 155 205 }); 156 206 157 207 return { 158 208 did: did, 159 209 url: atprotoLabelerService?.serviceEndpoint || record.serviceEndpoint, 160 - isDefault: false, // logic to determine if this is a default Bluesky labeler 210 + isDefault: FORCED_LABELER_DIDS.includes(did), 161 211 supportedLabels, 162 212 labelDefs, 163 213 }; ··· 165 215 .filter(Boolean) as LabelerDefinition[]; 166 216 167 217 setLabelerConfig(definitions); 168 - }, [prefs, labelerDidDocQueries, labelerServiceQueries, setLabelerConfig, identity?.pds, activeLabelerDids]); 218 + }, [ 219 + prefs, 220 + labelerDidDocQueries, 221 + labelerServiceQueries, 222 + setLabelerConfig, 223 + activeLabelerDids, 224 + isUnauthed // <--- Critical dependency triggers re-eval on login/out 225 + ]); 169 226 170 - return null; // Headless component 171 - }; 227 + return null; 228 + };
+21 -5
src/components/UniversalPostRenderer.tsx
··· 15 15 import * as React from "react"; 16 16 import { useEffect, useState } from "react"; 17 17 18 + import { UNAUTHED_PREVENT_OPENING_WARNS } from "~/../policy"; 18 19 import defaultpfp from "~/../public/defaultpfp.png"; 19 20 import { useLabelInfo } from "~/hooks/useLabelInfo"; 20 21 import { useModeration } from "~/hooks/useModeration"; ··· 599 600 post.viewer?.repost ? true : false, 600 601 ); 601 602 const [, setComposerPost] = useAtom(composerAtom); 602 - const { agent } = useAuth(); 603 + const { agent, status } = useAuth(); 603 604 const [retweetUri, setRetweetUri] = useState<string | undefined>( 604 605 post.viewer?.repost, 605 606 ); ··· 676 677 const isMainItem = false; 677 678 const setMainItem = (any: any) => { }; 678 679 680 + const hideWarnsWhenUnauthed = UNAUTHED_PREVENT_OPENING_WARNS && status === "signedOut"; 681 + 679 682 const showContentWarning = warnContentLabels.length > 0; 680 683 681 684 const [isOpen, setIsOpen] = useState(!showContentWarning); 685 + const [hasUserTouchedToggleYet, setHasUserTouchedToggleYet] = useState(false); 686 + 687 + useEffect(()=>{ 688 + if(!hasUserTouchedToggleYet && showContentWarning) { 689 + setIsOpen(false); 690 + } 691 + },[hasUserTouchedToggleYet, showContentWarning]) 682 692 683 693 684 694 if (hideAuthorLabels.length > 0 || hideContentLabels.length > 0) { ··· 977 987 {/* <ModerationInner subject={post.uri} /> */} 978 988 {showContentWarning && ( 979 989 <ContentWarning 990 + unauthedgate={hideWarnsWhenUnauthed} 980 991 labels={warnContentLabels} 981 992 isOpen={isOpen} 982 993 onPress={(e) => { 983 994 e.stopPropagation(); 984 - setIsOpen(!isOpen) 995 + setHasUserTouchedToggleYet(true); 996 + if (!hideWarnsWhenUnauthed) { 997 + setIsOpen(!isOpen) 998 + } 985 999 }} 986 1000 /> 987 1001 )} ··· 1214 1228 } 1215 1229 1216 1230 export function ContentWarning({ 1231 + unauthedgate, 1217 1232 labels, 1218 1233 isOpen, 1219 1234 onPress, 1220 1235 }: { 1236 + unauthedgate?: boolean; 1221 1237 labels: ContentLabel[]; 1222 1238 isOpen: boolean; 1223 1239 onPress: React.MouseEventHandler<HTMLDivElement>; ··· 1257 1273 1258 1274 {/* Chevron */} 1259 1275 <div className="flex items-center justify-center text-gray-500 dark:text-gray-400 pl-2 gap-2 text-sm"> 1260 - {isOpen ? "hide" : "show"} 1261 - <IconMdiChevronDown 1276 + {unauthedgate ? "please login to view" : isOpen ? "hide" : "show"} 1277 + {!unauthedgate && (<IconMdiChevronDown 1262 1278 className={`text-xl transition-transform duration-300 ease-[cubic-bezier(0.2,0,0,1)] ${isOpen ? "rotate-180" : "" 1263 1279 }`} 1264 - /> 1280 + />)} 1265 1281 </div> 1266 1282 </div> 1267 1283 </div>