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

Configure Feed

Select the types of activity you want to include in your feed.

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 }