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

useModeration and author badges

+928 -99
+35
src/api/moderation.ts
··· 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
··· 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
··· 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 16 import { useEffect, useState } from "react"; 17 17 18 18 import defaultpfp from "~/../public/defaultpfp.png"; 19 + import { useLabelInfo } from "~/hooks/useLabelInfo"; 20 + import { useModeration } from "~/hooks/useModeration"; 19 21 import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 22 import { renderSnack } from "~/routes/__root"; 23 + //import { ModerationInner } from "~/routes/moderation"; 21 24 import { 22 25 FollowButton, 23 26 Mutual, 24 27 } from "~/routes/profile.$did"; 25 28 import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; 29 + import type { ContentLabel } from "~/types/moderation"; 26 30 import { 27 31 composerAtom, 28 32 constellationURLAtom, ··· 133 137 setReplies( 134 138 links 135 139 ? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"] 136 - ?.records || 0 140 + ?.records || 0 137 141 : null, 138 142 ); 139 143 }, [links]); ··· 168 172 169 173 const replyAturis = repliesData 170 174 ? 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 - ) 175 + page 176 + ? page.linking_records.map((record) => { 177 + const aturi = `at://${record.did}/${record.collection}/${record.rkey}`; 178 + return aturi; 179 + }) 180 + : [], 181 + ) 178 182 : []; 179 183 180 184 const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => { ··· 390 394 const isQuotewithImages = 391 395 isquotewithmedia && 392 396 (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 393 - "app.bsky.embed.images"; 397 + "app.bsky.embed.images"; 394 398 const isQuotewithVideo = 395 399 isquotewithmedia && 396 400 (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === 397 - "app.bsky.embed.video"; 401 + "app.bsky.embed.video"; 398 402 399 403 const hasMedia = 400 404 hasEmbed && ··· 573 577 maxReplies?: number; 574 578 constellationLinks?: any; 575 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 + 576 591 const parsed = new AtUri(post.uri); 577 592 const navigate = useNavigate(); 578 593 const [hasRetweeted, setHasRetweeted] = useState<boolean>( ··· 631 646 632 647 const tags = unfediwafrnTags 633 648 ? unfediwafrnTags 634 - .split("\n") 635 - .map((t) => t.trim()) 636 - .filter(Boolean) 649 + .split("\n") 650 + .map((t) => t.trim()) 651 + .filter(Boolean) 637 652 : undefined; 638 653 639 654 const links = tags 640 655 ? 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>") 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>") 646 661 : ""; 647 662 648 663 const unfediwafrn = unfediwafrnPartial ··· 654 669 (showWafrnText ? unfediwafrn : undefined); 655 670 656 671 const isMainItem = false; 657 - const setMainItem = (any: any) => {}; 672 + const setMainItem = (any: any) => { }; 673 + 674 + if (hideAuthorLabels.length > 0 ) { 675 + return null 676 + } 658 677 659 678 return ( 660 679 <div ref={ref} style={style} data-index={dataIndexPropPass}> ··· 666 685 : setMainItem 667 686 ? onPostClick 668 687 ? (e) => { 669 - setMainItem({ post: post }); 670 - onPostClick(e); 671 - } 688 + setMainItem({ post: post }); 689 + onPostClick(e); 690 + } 672 691 : () => { 673 - setMainItem({ post: post }); 674 - } 692 + setMainItem({ post: post }); 693 + } 675 694 : undefined 676 695 } 677 696 style={{ ··· 897 916 </span> 898 917 </div> 899 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 + } 900 948 {!!feedviewpostreplyhandle && ( 901 949 <div 902 950 style={{ ··· 919 967 <IconMdiReply /> Reply to @{feedviewpostreplyhandle} 920 968 </div> 921 969 )} 970 + {/* <ModerationInner subject={post.uri} /> */} 922 971 <div 923 972 style={{ 924 973 fontSize: 16, ··· 1084 1133 try { 1085 1134 await navigator.clipboard.writeText( 1086 1135 "https://bsky.app" + 1087 - "/profile/" + 1088 - post.author.handle + 1089 - "/post/" + 1090 - post.uri.split("/").pop(), 1136 + "/profile/" + 1137 + post.author.handle + 1138 + "/post/" + 1139 + post.uri.split("/").pop(), 1091 1140 ); 1092 1141 renderSnack({ 1093 1142 title: "Copied to clipboard!", ··· 1136 1185 Feed = "Feed", 1137 1186 FeedEmbedRecordWithMedia = "FeedEmbedRecordWithMedia", 1138 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
··· 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
··· 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 23 import { Import } from "~/components/Import"; 24 24 import Login from "~/components/Login"; 25 25 import Logo from "~/components/LogoSvg"; 26 + import { ModerationBatcher } from "~/components/ModerationBatcher"; 27 + import { ModerationInitializer } from "~/components/ModerationInitializer"; 26 28 import { NotFound } from "~/components/NotFound"; 27 29 import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; 28 30 import { PollMutationQueueProvider } from "~/providers/PollMutationQueueProvider"; ··· 85 87 <UnifiedAuthProvider> 86 88 <LikeMutationQueueProvider> 87 89 <PollMutationQueueProvider> 90 + <ModerationInitializer /> 91 + <ModerationBatcher /> 88 92 <RootDocument> 89 93 <KeepAliveProvider> 90 94 <AppToaster /> ··· 207 211 const location = useLocation(); 208 212 const navigate = useNavigate(); 209 213 const { agent } = useAuth(); 214 + const isNotifications = location.pathname.startsWith("/notifications"); 210 215 const authed = !!agent?.did; 211 - const isHome = location.pathname === "/"; 212 - const isNotifications = location.pathname.startsWith("/notifications"); 213 216 const isProfile = 214 217 agent && 215 218 (location.pathname === `/profile/${agent?.did}` ||
+63 -12
src/routes/moderation.tsx
··· 13 13 import { Switch } from "radix-ui"; 14 14 15 15 import { Header } from "~/components/Header"; 16 + import { useModeration } from "~/hooks/useModeration"; 16 17 import { useAuth } from "~/providers/UnifiedAuthProvider"; 17 18 import { quickAuthAtom } from "~/utils/atoms"; 18 19 import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery"; ··· 28 29 function RouteComponent() { 29 30 const { agent } = useAuth(); 30 31 31 - const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); 32 + const [quickAuth] = useAtom(quickAuthAtom); 32 33 const isAuthRestoring = quickAuth ? status === "loading" : false; 33 34 34 35 const identityresultmaybe = useQueryIdentity( 35 - !isAuthRestoring ? agent?.did : undefined 36 + !isAuthRestoring ? agent?.did : undefined, 36 37 ); 37 38 const identity = identityresultmaybe?.data; 38 39 ··· 43 44 const rawprefs = prefsresultmaybe?.data?.preferences as 44 45 | ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"] 45 46 | undefined; 46 - 47 - //console.log(JSON.stringify(prefs, null, 2)) 48 47 49 48 const parsedPref = parsePreferences(rawprefs); 50 49 ··· 96 95 <Switch.Root 97 96 id={`switch-${"hardcoded"}`} 98 97 checked={parsedPref?.adultContentEnabled} 99 - onCheckedChange={(v) => { 98 + onCheckedChange={() => { 100 99 renderSnack({ 101 100 title: "Sorry... Modifying preferences is not implemented yet", 102 101 description: "You can use another app to change preferences", ··· 108 107 <Switch.Thumb className="m3switch thumb " /> 109 108 </Switch.Root> 110 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" /> 111 117 <div className=""> 112 118 {Object.entries(parsedPref?.contentLabelPrefs ?? {}).map( 113 119 ([label, visibility]) => ( ··· 133 139 value={visibility as "ignore" | "warn" | "hide"} 134 140 /> 135 141 </div> 136 - ) 142 + ), 137 143 )} 138 144 </div> 139 145 </div> ··· 174 180 }); 175 181 onChange?.(opt); 176 182 }} 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 - }`} 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 + }`} 182 187 > 183 188 {" "} 184 189 {opt.charAt(0).toUpperCase() + opt.slice(1)} ··· 206 211 } 207 212 208 213 export function parsePreferences( 209 - prefs?: PrefItem[] 214 + prefs?: PrefItem[], 210 215 ): NormalizedPreferences | undefined { 211 216 if (!prefs) return undefined; 212 217 const normalized: NormalizedPreferences = { ··· 267 272 268 273 return normalized; 269 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 13 useReusableTabScrollRestore, 14 14 } from "~/components/ReusableTabRoute"; 15 15 import { 16 + SmallAuthorLabelBadge, 16 17 UniversalPostRendererATURILoader, 17 18 } from "~/components/UniversalPostRenderer"; 18 19 import { renderTextWithFacets } from "~/components/UtilityFunctions"; 20 + import { useModeration } from "~/hooks/useModeration"; 19 21 import { useAuth } from "~/providers/UnifiedAuthProvider"; 20 22 import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; 21 23 import { ··· 53 55 error: identityError, 54 56 } = useQueryIdentity(did); 55 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 + 56 69 // i was gonna check the did doc but useQueryIdentity doesnt return that info (slingshot minidoc) 57 70 // so instead we should query the labeler profile 58 71 ··· 100 113 const resultwhateversure = useQueryConstellationLinksCountDistinctDids( 101 114 resolvedDid 102 115 ? { 103 - method: "/links/count/distinct-dids", 104 - collection: "app.bsky.graph.follow", 105 - target: resolvedDid, 106 - path: ".subject", 107 - } 116 + method: "/links/count/distinct-dids", 117 + collection: "app.bsky.graph.follow", 118 + target: resolvedDid, 119 + path: ".subject", 120 + } 108 121 : undefined 109 122 ); 110 123 ··· 221 234 <RichTextRenderer key={did} description={description} /> 222 235 </div> 223 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 + } 224 266 </div> 225 267 </div> 226 268 ··· 231 273 tabs={{ 232 274 ...(isLabeler 233 275 ? { 234 - Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />, 235 - } 276 + Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />, 277 + } 236 278 : {}), 237 279 ...{ 238 280 Posts: <PostsTab did={did} />, ··· 696 738 // @ts-expect-error overloads sucks 697 739 !listmode 698 740 ? { 699 - target: feed.uri, 700 - method: "/links/count", 701 - collection: "app.bsky.feed.like", 702 - path: ".subject.uri", 703 - } 741 + target: feed.uri, 742 + method: "/links/count", 743 + collection: "app.bsky.feed.like", 744 + path: ".subject.uri", 745 + } 704 746 : undefined 705 747 ); 706 748 ··· 1044 1086 const theyFollowYouRes = useGetOneToOneState( 1045 1087 agent?.did 1046 1088 ? { 1047 - target: agent?.did, 1048 - user: identity?.did ?? targetdidorhandle, 1049 - collection: "app.bsky.graph.follow", 1050 - path: ".subject", 1051 - } 1089 + target: agent?.did, 1090 + user: identity?.did ?? targetdidorhandle, 1091 + collection: "app.bsky.graph.follow", 1092 + path: ".subject", 1093 + } 1052 1094 : undefined 1053 1095 ); 1054 1096
+92
src/state/moderationAtoms.ts
··· 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
··· 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 15 16 16 export function constructIdentityQuery( 17 17 didorhandle?: string, 18 - slingshoturl?: string 18 + slingshoturl?: string, 19 19 ) { 20 20 return queryOptions({ 21 21 queryKey: ["identity", didorhandle], 22 22 queryFn: async () => { 23 23 if (!didorhandle) return undefined as undefined; 24 24 const res = await fetch( 25 - `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` 25 + `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`, 26 26 ); 27 27 if (!res.ok) throw new Error("Failed to fetch post"); 28 28 try { ··· 71 71 queryFn: async () => { 72 72 if (!uri) return undefined as undefined; 73 73 const res = await fetch( 74 - `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 74 + `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`, 75 75 ); 76 76 let data: any; 77 77 try { ··· 135 135 queryFn: async () => { 136 136 if (!uri) return undefined as undefined; 137 137 const res = await fetch( 138 - `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 138 + `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`, 139 139 ); 140 140 let data: any; 141 141 try { ··· 269 269 const cursor = query.cursor; 270 270 const dids = query?.dids; 271 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("") : ""}` 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 273 ); 274 274 if (!res.ok) throw new Error("Failed to fetch post"); 275 275 try { ··· 308 308 const [constellationurl] = useAtom(constellationURLAtom); 309 309 const queryres = useQuery( 310 310 constructConstellationQuery( 311 - query && { constellation: constellationurl, ...query } 312 - ) 311 + query && { constellation: constellationurl, ...query }, 312 + ), 313 313 ) as unknown as UseQueryResult<linksCountResponse, Error>; 314 314 if (!query) { 315 315 return undefined as undefined; ··· 389 389 const [constellationurl] = useAtom(constellationURLAtom); 390 390 return useQuery( 391 391 constructConstellationQuery( 392 - query && { constellation: constellationurl, ...query } 393 - ) 392 + query && { constellation: constellationurl, ...query }, 393 + ), 394 394 ); 395 395 } 396 396 ··· 446 446 // Authenticated flow 447 447 if (!agent || !pdsUrl || !feedServiceDid) { 448 448 throw new Error( 449 - "Missing required info for authenticated feed fetch." 449 + "Missing required info for authenticated feed fetch.", 450 450 ); 451 451 } 452 452 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; ··· 481 481 feedServiceDid?: string; 482 482 }) { 483 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)); 484 522 } 485 523 486 524 export function constructPreferencesQuery( 487 525 agent?: ATPAPI.Agent | undefined, 488 - pdsUrl?: string | undefined 526 + pdsUrl?: string | undefined, 489 527 ) { 490 528 return queryOptions({ 491 529 queryKey: ["preferences", agent?.did], ··· 511 549 queryFn: async () => { 512 550 if (!uri) return undefined as undefined; 513 551 const res = await fetch( 514 - `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` 552 + `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`, 515 553 ); 516 554 let data: any; 517 555 try { ··· 590 628 export function constructAuthorFeedQuery( 591 629 did: string, 592 630 pdsUrl: string, 593 - collection: string = "app.bsky.feed.post" 631 + collection: string = "app.bsky.feed.post", 594 632 ) { 595 633 return queryOptions({ 596 634 queryKey: ["authorFeed", did, collection], ··· 613 651 export function useInfiniteQueryAuthorFeed( 614 652 did: string | undefined, 615 653 pdsUrl: string | undefined, 616 - collection?: string 654 + collection?: string, 617 655 ) { 618 656 const { queryKey, queryFn } = constructAuthorFeedQuery( 619 657 did!, 620 658 pdsUrl!, 621 - collection 659 + collection, 622 660 ); 623 661 624 662 return useInfiniteQuery({ ··· 655 693 if (isAuthed && !unauthedfeedurl) { 656 694 if (!agent || !pdsUrl || !feedServiceDid) { 657 695 throw new Error( 658 - "Missing required info for authenticated feed fetch." 696 + "Missing required info for authenticated feed fetch.", 659 697 ); 660 698 } 661 699 const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; ··· 748 786 collection ? `&collection=${encodeURIComponent(collection)}` : "" 749 787 }${path ? `&path=${encodeURIComponent(path)}` : ""}${ 750 788 cursor ? `&cursor=${encodeURIComponent(cursor)}` : "" 751 - }` 789 + }`, 752 790 ); 753 791 754 792 if (!res.ok) throw new Error("Failed to fetch"); ··· 774 812 agent: agent || undefined, 775 813 isAuthed: status === "signedIn", 776 814 pdsUrl: identity?.pds, 777 - feedServiceDid: "did:web:"+lycanurl, 778 - }) 815 + feedServiceDid: "did:web:" + lycanurl, 816 + }), 779 817 ); 780 818 } 781 819 ··· 802 840 }); 803 841 if (!res.ok) 804 842 throw new Error( 805 - `Authenticated lycan status fetch failed: ${res.statusText}` 843 + `Authenticated lycan status fetch failed: ${res.statusText}`, 806 844 ); 807 845 return (await res.json()) as statuschek; 808 846 } ··· 816 854 error?: "MethodNotImplemented"; 817 855 message?: "Method Not Implemented"; 818 856 status?: "finished" | "in_progress"; 819 - position?: string, 820 - progress?: number, 821 - 857 + position?: string; 858 + progress?: number; 822 859 }; 823 860 824 861 //{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268} 825 862 type importtype = { 826 - message?: "Import has already started" | "Import has been scheduled" 827 - } 863 + message?: "Import has already started" | "Import has been scheduled"; 864 + }; 828 865 829 866 export function constructLycanRequestIndexQuery(options: { 830 867 agent?: ATPAPI.Agent; ··· 849 886 }); 850 887 if (!res.ok) 851 888 throw new Error( 852 - `Authenticated lycan status fetch failed: ${res.statusText}` 889 + `Authenticated lycan status fetch failed: ${res.statusText}`, 853 890 ); 854 - return await res.json() as importtype; 891 + return (await res.json()) as importtype; 855 892 } 856 893 return undefined; 857 894 }, ··· 864 901 cursor?: string; 865 902 }; 866 903 867 - 868 - export function useInfiniteQueryLycanSearch(options: { query: string, type: "likes" | "pins" | "reposts" | "quotes"}) { 869 - 870 - 904 + export function useInfiniteQueryLycanSearch(options: { 905 + query: string; 906 + type: "likes" | "pins" | "reposts" | "quotes"; 907 + }) { 871 908 const [lycanurl] = useAtom(lycanURLAtom); 872 909 const { agent, status } = useAuth(); 873 910 const { data: identity } = useQueryIdentity(agent?.did); 874 911 875 912 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 - }) 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 + }); 883 920 884 921 return { 885 922 ...useInfiniteQuery({ ··· 900 937 queryKey: queryKey, 901 938 }; 902 939 } 903 - 904 940 905 941 export function constructLycanSearchQuery(options: { 906 942 agent?: ATPAPI.Agent; ··· 929 965 }); 930 966 if (!res.ok) 931 967 throw new Error( 932 - `Authenticated lycan status fetch failed: ${res.statusText}` 968 + `Authenticated lycan status fetch failed: ${res.statusText}`, 933 969 ); 934 970 return (await res.json()) as LycanSearchPage; 935 971 }