From 36d82759310657a12263d579edd64214c31f32dc Mon Sep 17 00:00:00 2001 From: "whey.party" Date: Tue, 20 Jan 2026 14:09:58 +0700 Subject: [PATCH] useModeration and author badges --- src/api/moderation.ts | 35 +++++ src/components/ModerationBatcher.tsx | 166 +++++++++++++++++++++++ src/components/ModerationInitializer.tsx | 164 ++++++++++++++++++++++ src/components/UniversalPostRenderer.tsx | 141 +++++++++++++++---- src/hooks/useLabelInfo.ts | 43 ++++++ src/hooks/useModeration.ts | 51 +++++++ src/routes/__root.tsx | 7 +- src/routes/moderation.tsx | 75 ++++++++-- src/routes/profile.$did/index.tsx | 76 ++++++++--- src/state/moderationAtoms.ts | 92 +++++++++++++ src/types/moderation.ts | 61 +++++++++ src/utils/useQuery.ts | 116 ++++++++++------ 12 files changed, 928 insertions(+), 99 deletions(-) create mode 100644 src/api/moderation.ts create mode 100644 src/components/ModerationBatcher.tsx create mode 100644 src/components/ModerationInitializer.tsx create mode 100644 src/hooks/useLabelInfo.ts create mode 100644 src/hooks/useModeration.ts create mode 100644 src/state/moderationAtoms.ts create mode 100644 src/types/moderation.ts diff --git a/src/api/moderation.ts b/src/api/moderation.ts new file mode 100644 index 0000000..8eaca71 --- /dev/null +++ b/src/api/moderation.ts @@ -0,0 +1,35 @@ +import type { QueryLabelsResponse } from "~/types/moderation"; + +export const fetchLabelsBatch = async ( + serviceUrl: string, + uris: string[], +): Promise => { + const url = new URL(`${serviceUrl}/xrpc/com.atproto.label.queryLabels`); + uris.forEach((uri) => url.searchParams.append("uriPatterns", uri)); + + // 1. Setup Timeout (5 seconds) + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + const response = await fetch(url.toString(), { + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + return data as QueryLabelsResponse; + } catch (error: any) { + if (error.name === 'AbortError') { + console.error(`[fetchLabelsBatch] Timeout querying ${serviceUrl}`); + } else { + console.error(`[fetchLabelsBatch] Error querying ${serviceUrl}:`, error); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +}; \ No newline at end of file diff --git a/src/components/ModerationBatcher.tsx b/src/components/ModerationBatcher.tsx new file mode 100644 index 0000000..ead82ab --- /dev/null +++ b/src/components/ModerationBatcher.tsx @@ -0,0 +1,166 @@ +import { useAtom, useAtomValue } from "jotai"; +import { useEffect, useRef } from "react"; + +import { fetchLabelsBatch } from "~/api/moderation"; +import { + CACHE_TIMEOUT_MS, + labelerConfigAtom, + moderationCacheAtom, + pendingUriQueueAtom, + processingUriSetAtom, +} from "~/state/moderationAtoms"; + +const BATCH_CHUNK_SIZE = 25; + +export const ModerationBatcher = () => { + const [queue, setQueue] = useAtom(pendingUriQueueAtom); + const [processingSet, setProcessingSet] = useAtom(processingUriSetAtom); + const [cache, setCache] = useAtom(moderationCacheAtom); + const labelers = useAtomValue(labelerConfigAtom); + + const stateRef = useRef({ queue, processingSet, cache, labelers }); + useEffect(() => { + stateRef.current = { queue, processingSet, cache, labelers }; + }, [queue, processingSet, cache, labelers]); + + useEffect(() => { + const interval = setInterval(async () => { + const { + queue: currentQueue, + processingSet: currentProcessing, + cache: currentCache, + labelers: currentLabelers, + } = stateRef.current; + + if (currentQueue.size === 0 || currentLabelers.length === 0) return; + + const now = Date.now(); + + // 1. Identify stale items + const batchUris = Array.from(currentQueue).filter((uri) => { + const entry = currentCache.get(uri); + const isStale = entry ? now - entry.timestamp > CACHE_TIMEOUT_MS : true; + return !currentProcessing.has(uri) && isStale; + }); + + if (batchUris.length === 0) return; + + console.log(`[Batcher] Processing ${batchUris.length} URIs...`); + + // 2. Lock items + setProcessingSet((prev) => { + const next = new Set(prev); + batchUris.forEach((u) => next.add(u)); + return next; + }); + setQueue((prev) => { + const next = new Set(prev); + batchUris.forEach((u) => next.delete(u)); + return next; + }); + + // 3. Process chunks + const chunks = []; + for (let i = 0; i < batchUris.length; i += BATCH_CHUNK_SIZE) { + chunks.push(batchUris.slice(i, i + BATCH_CHUNK_SIZE)); + } + + for (const chunk of chunks) { + try { + const results = await Promise.allSettled( + currentLabelers.map((l) => fetchLabelsBatch(l.url, chunk)), + ); + + setCache((prevCache) => { + const nextCache = new Map(prevCache); + const updateTime = Date.now(); + + // A. Initialize requested URIs (to remove loading state) + chunk.forEach((uri) => { + if (!nextCache.has(uri) || nextCache.get(uri)!.timestamp < updateTime) { + nextCache.set(uri, { labels: [], timestamp: updateTime }); + } + }); + + // B. Process Results + results.forEach((res, index) => { + if (res.status === "fulfilled") { + const labeler = currentLabelers[index]; + const rawLabels = res.value.labels || []; + + // --- REDUCTION LOGIC START --- + + // 1. Group by URI + const labelsByUri = new Map(); + rawLabels.forEach((l) => { + if (!labelsByUri.has(l.uri)) labelsByUri.set(l.uri, []); + labelsByUri.get(l.uri)!.push(l); + }); + + // 2. Process each URI's history + labelsByUri.forEach((labels, uri) => { + // Only process if this URI is actually in our cache/interest + if (!nextCache.has(uri)) return; + const cacheEntry = nextCache.get(uri)!; + + // 3. Find latest state per (Source + Value) + // Key: "did:plc:xyz::porn" -> Latest Label Object + const latestState = new Map(); + + labels.forEach((l) => { + const key = `${l.src}::${l.val}`; + const existing = latestState.get(key); + + const currentCts = new Date(l.cts).getTime(); + const existingCts = existing ? new Date(existing.cts).getTime() : 0; + + if (!existing || currentCts > existingCts) { + latestState.set(key, l); + } + }); + + // 4. Push only active (non-negated) labels + for (const activeLabel of latestState.values()) { + if (activeLabel.neg) continue; // Skip deleted labels + + // Resolve preference from the Labeler Config (our subscription) + // Note: We attribute the label to the 'labeler.did' (the service we subscribed to) + // even if the signer (src) is different, because prefs are attached to the service. + const resolvedPref = + labeler.supportedLabels?.[activeLabel.val] || "ignore"; + + cacheEntry.labels.push({ + sourceDid: labeler.did, + val: activeLabel.val, + cts: activeLabel.cts, + preference: resolvedPref, + }); + } + }); + // --- REDUCTION LOGIC END --- + + } else { + console.error(`[Batcher] Labeler ${currentLabelers[index].url} failed:`, res.reason); + } + }); + + return nextCache; + }); + } catch (e) { + console.error("[Batcher] Chunk failed", e); + } + } + + // 5. Release Lock + setProcessingSet((prev) => { + const next = new Set(prev); + batchUris.forEach((u) => next.delete(u)); + return next; + }); + }, 2000); + + return () => clearInterval(interval); + }, []); + + return null; +}; \ No newline at end of file diff --git a/src/components/ModerationInitializer.tsx b/src/components/ModerationInitializer.tsx new file mode 100644 index 0000000..03282b7 --- /dev/null +++ b/src/components/ModerationInitializer.tsx @@ -0,0 +1,164 @@ +import { useQueries } from "@tanstack/react-query"; +import { useSetAtom } from "jotai"; +import { useEffect } from "react"; + +import { useAuth } from "~/providers/UnifiedAuthProvider"; +import { labelerConfigAtom } from "~/state/moderationAtoms"; +import type { LabelerDefinition, LabelPreference, LabelValueDefinition } from "~/types/moderation"; +import { useQueryIdentity } from "~/utils/useQuery"; +import { useQueryPreferences } from "~/utils/useQuery"; + +// Manual DID document resolution +const fetchDidDocument = async (did: string): Promise => { + if (did.startsWith("did:plc:")) { + // For PLC DIDs, fetch from plc.directory + const response = await fetch( + `https://plc.directory/${encodeURIComponent(did)}`, + ); + if (!response.ok) + throw new Error(`Failed to fetch PLC DID document for ${did}`); + return response.json(); + } else if (did.startsWith("did:web:")) { + // For web DIDs, fetch from well-known + const handle = did.replace("did:web:", ""); + const url = `https://${handle}/.well-known/did.json`; + const response = await fetch(url); + if (!response.ok) + throw new Error( + `Failed to fetch web DID document for ${did} (CORS or not found)`, + ); + return response.json(); + } else { + throw new Error(`Unsupported DID type: ${did}`); + } +}; + +export const ModerationInitializer = () => { + const { agent } = useAuth(); + const setLabelerConfig = useSetAtom(labelerConfigAtom); + + // 1. Get User Identity to get PDS URL + const { data: identity } = useQueryIdentity(agent?.did); + + // 2. Get User Preferences (Global: "porn" -> "hide") + const { data: prefs } = useQueryPreferences({ + agent: agent ?? undefined, + pdsUrl: identity?.pds, + }); + + // 3. Identify Labeler DIDs from prefs + const labelerDids = + prefs?.preferences + ?.find((pref: any) => pref.$type === "app.bsky.actor.defs#labelersPref") + ?.labelers?.map((l: any) => l.did) ?? []; + + // 4. Parallel fetch all Labeler DID Documents and Service Records + const labelerDidDocQueries = useQueries({ + queries: labelerDids.map((did: string) => ({ + queryKey: ["labelerDidDoc", did], + queryFn: () => fetchDidDocument(did), + staleTime: 5 * 60 * 1000, // 5 minutes + retry: 1, // Only retry once for DID docs + })), + }); + + const labelerServiceQueries = useQueries({ + queries: labelerDids.map((did: string) => ({ + queryKey: ["labelerService", did], + queryFn: async () => { + if (!identity?.pds) throw new Error("No PDS URL"); + const response = await fetch( + `${identity.pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent("app.bsky.labeler.service")}&rkey=self`, + ); + if (!response.ok) throw new Error("Failed to fetch labeler service"); + return response.json(); + }, + enabled: !!identity?.pds && !!agent, + staleTime: 5 * 60 * 1000, // 5 minutes + })), + }); + + useEffect(() => { + if ( + !prefs || + labelerDidDocQueries.some((q) => q.isLoading) || + labelerDidDocQueries.some((q) => q.isFetching) || + labelerServiceQueries.some((q) => q.isLoading) || + labelerServiceQueries.some((q) => q.isFetching) + ) + return; + + // Extract content label preferences + const contentLabelPrefs = + prefs.preferences?.filter( + (pref: any) => pref.$type === "app.bsky.actor.defs#contentLabelPref", + ) ?? []; + + const globalPrefs: Record = {}; + contentLabelPrefs.forEach((pref: any) => { + globalPrefs[pref.label] = pref.visibility as LabelPreference; + }); + + const definitions: LabelerDefinition[] = labelerDids + .map((did: string, index: number) => { + const didDocQuery = labelerDidDocQueries[index]; + const serviceQuery = labelerServiceQueries[index]; + + if (!didDocQuery.data || !serviceQuery.data) return null; + + // Extract service endpoint from DID document + const didDoc = didDocQuery.data as any; + const atprotoLabelerService = didDoc?.service?.find( + (s: any) => s.id === "#atproto_labeler", + ); + + const record = (serviceQuery.data as any).value; // The raw ATProto record + + // 1. Create the Metadata Map + const labelDefs: Record = {}; + + if (record.policies.labelValueDefinitions) { + record.policies.labelValueDefinitions.forEach((def: any) => { + labelDefs[def.identifier] = { + identifier: def.identifier, + severity: def.severity, + blurs: def.blurs, + adultOnly: def.adultOnly, + defaultSetting: def.defaultSetting, + locales: def.locales || [] // <--- Capture the locales array + }; + }); + } + + // RESOLUTION LOGIC: + // Map record.policies.labelValueDefinitions to a lookup map. + // Priority: User Global Pref > Labeler Default > 'ignore' + const supportedLabels: Record = {}; + + record.policies?.labelValues?.forEach((val: string) => { + // Does user have a global override for this string? + const globalPref = globalPrefs[val]; + // Or use labeler default + const defaultPref = + record.policies?.labelValueDefinitions?.find( + (d: any) => d.identifier === val, + )?.defaultSetting || "ignore"; + + supportedLabels[val] = (globalPref || defaultPref) as LabelPreference; + }); + + return { + did: did, + url: atprotoLabelerService?.serviceEndpoint || record.serviceEndpoint, + isDefault: false, // logic to determine if this is a default Bluesky labeler + supportedLabels, + labelDefs, + }; + }) + .filter(Boolean) as LabelerDefinition[]; + + setLabelerConfig(definitions); + }, [prefs, labelerDidDocQueries, labelerServiceQueries, setLabelerConfig, identity?.pds, labelerDids]); + + return null; // Headless component +}; diff --git a/src/components/UniversalPostRenderer.tsx b/src/components/UniversalPostRenderer.tsx index cdc799d..b15f0e9 100644 --- a/src/components/UniversalPostRenderer.tsx +++ b/src/components/UniversalPostRenderer.tsx @@ -16,13 +16,17 @@ import * as React from "react"; import { useEffect, useState } from "react"; import defaultpfp from "~/../public/defaultpfp.png"; +import { useLabelInfo } from "~/hooks/useLabelInfo"; +import { useModeration } from "~/hooks/useModeration"; import { useAuth } from "~/providers/UnifiedAuthProvider"; import { renderSnack } from "~/routes/__root"; +//import { ModerationInner } from "~/routes/moderation"; import { FollowButton, Mutual, } from "~/routes/profile.$did"; import type { LightboxProps } from "~/routes/profile.$did/post.$rkey.image.$i"; +import type { ContentLabel } from "~/types/moderation"; import { composerAtom, constellationURLAtom, @@ -133,7 +137,7 @@ export function UniversalPostRendererATURILoader({ setReplies( links ? links?.links?.["app.bsky.feed.post"]?.[".reply.parent.uri"] - ?.records || 0 + ?.records || 0 : null, ); }, [links]); @@ -168,13 +172,13 @@ export function UniversalPostRendererATURILoader({ const replyAturis = repliesData ? repliesData.pages.flatMap((page) => - page - ? page.linking_records.map((record) => { - const aturi = `at://${record.did}/${record.collection}/${record.rkey}`; - return aturi; - }) - : [], - ) + page + ? page.linking_records.map((record) => { + const aturi = `at://${record.did}/${record.collection}/${record.rkey}`; + return aturi; + }) + : [], + ) : []; const { oldestOpsReply, oldestOpsReplyElseNewestNonOpsReply } = (() => { @@ -390,11 +394,11 @@ export function UniversalPostRendererRawRecordShim({ const isQuotewithImages = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === - "app.bsky.embed.images"; + "app.bsky.embed.images"; const isQuotewithVideo = isquotewithmedia && (hasEmbed as ATPAPI.AppBskyEmbedRecordWithMedia.Main)?.media?.$type === - "app.bsky.embed.video"; + "app.bsky.embed.video"; const hasMedia = hasEmbed && @@ -573,6 +577,17 @@ export function UniversalPostRenderer({ maxReplies?: number; constellationLinks?: any; }) { + const { isLoading: authorModLoading, labels: authorLabels } = useModeration( + post.author.did, + ); + const hideAuthorLabels = authorLabels.filter( + label => label.preference === 'hide' + ); + const warnAuthorLabels = authorLabels.filter( + label => label.preference === 'warn' + ); + + const parsed = new AtUri(post.uri); const navigate = useNavigate(); const [hasRetweeted, setHasRetweeted] = useState( @@ -631,18 +646,18 @@ export function UniversalPostRenderer({ const tags = unfediwafrnTags ? unfediwafrnTags - .split("\n") - .map((t) => t.trim()) - .filter(Boolean) + .split("\n") + .map((t) => t.trim()) + .filter(Boolean) : undefined; const links = tags ? tags - .map((tag) => { - const encoded = encodeURIComponent(tag); - return `#${tag.replaceAll(" ", "-")}`; - }) - .join("
") + .map((tag) => { + const encoded = encodeURIComponent(tag); + return `#${tag.replaceAll(" ", "-")}`; + }) + .join("
") : ""; const unfediwafrn = unfediwafrnPartial @@ -654,7 +669,11 @@ export function UniversalPostRenderer({ (showWafrnText ? unfediwafrn : undefined); const isMainItem = false; - const setMainItem = (any: any) => {}; + const setMainItem = (any: any) => { }; + + if (hideAuthorLabels.length > 0 ) { + return null + } return (
@@ -666,12 +685,12 @@ export function UniversalPostRenderer({ : setMainItem ? onPostClick ? (e) => { - setMainItem({ post: post }); - onPostClick(e); - } + setMainItem({ post: post }); + onPostClick(e); + } : () => { - setMainItem({ post: post }); - } + setMainItem({ post: post }); + } : undefined } style={{ @@ -897,6 +916,35 @@ export function UniversalPostRenderer({
+ {/* */} + {authorModLoading ? + ( +
+
+ {/* avatar */} + loading badges... +
+
+ ) + : + ( +
+ {warnAuthorLabels.map((label, index) => ( + + ))} +
+ ) + } {!!feedviewpostreplyhandle && (
Reply to @{feedviewpostreplyhandle}
)} + {/* */}
+ avatar + {info.name || label.val} +
+ ) +} \ No newline at end of file diff --git a/src/hooks/useLabelInfo.ts b/src/hooks/useLabelInfo.ts new file mode 100644 index 0000000..adccd2b --- /dev/null +++ b/src/hooks/useLabelInfo.ts @@ -0,0 +1,43 @@ +import { useAtomValue } from "jotai"; +import { useCallback } from "react"; + +import { labelerConfigAtom } from "~/state/moderationAtoms"; + +export const useLabelInfo = () => { + const labelers = useAtomValue(labelerConfigAtom); + + const getLabelInfo = useCallback((sourceDid: string, val: string) => { + // 1. Find the labeler config + const labeler = labelers.find((l) => l.did === sourceDid); + + // Fallback if labeler or definition is missing + const fallback = { + name: val, + description: "", + isAdult: false + }; + + if (!labeler) return fallback; + + // 2. Look up the definition + const def = labeler.labelDefs[val]; + if (!def) return fallback; + + // 3. Resolve Locale (Match browser lang -> 'en' -> first available) + // You can replace 'en' with a proper i18n atom if you have one + const userLang = "en"; + const locale = def.locales.find((l) => l.lang === userLang) + || def.locales.find((l) => l.lang === "en") + || def.locales[0]; + + return { + name: locale?.name || val, + description: locale?.description || "", + isAdult: def.adultOnly, + severity: def.severity, + blurs: def.blurs + }; + }, [labelers]); + + return { getLabelInfo }; +}; \ No newline at end of file diff --git a/src/hooks/useModeration.ts b/src/hooks/useModeration.ts new file mode 100644 index 0000000..ab7b47b --- /dev/null +++ b/src/hooks/useModeration.ts @@ -0,0 +1,51 @@ +import { useAtom, useSetAtom } from "jotai"; +import { selectAtom } from "jotai/utils"; +import { useEffect, useMemo } from "react"; + +import { + CACHE_TIMEOUT_MS, + moderationCacheAtom, + pendingUriQueueAtom, + processingUriSetAtom, +} from "~/state/moderationAtoms"; + +export const useModeration = (uri: string) => { + const setQueue = useSetAtom(pendingUriQueueAtom); + + // 1. Select ONLY this URI's cache entry + const entryAtom = useMemo( + () => selectAtom(moderationCacheAtom, (cache) => cache.get(uri)), + [uri], + ); + const [cachedEntry] = useAtom(entryAtom); + + // 2. Select ONLY this URI's processing state + const isProcessingAtom = useMemo( + () => selectAtom(processingUriSetAtom, (set) => set.has(uri)), + [uri], + ); + const [isProcessing] = useAtom(isProcessingAtom); + + const now = Date.now(); + const exists = cachedEntry !== undefined; + const isStale = exists && now - cachedEntry.timestamp > CACHE_TIMEOUT_MS; + + useEffect(() => { + // Stop if we have valid data or are currently working on it + if ((exists && !isStale) || isProcessing) return; + + // Queue it + setQueue((prev) => { + if (prev.has(uri)) return prev; + const next = new Set(prev); + next.add(uri); + return next; + }); + }, [uri, exists, isStale, isProcessing, setQueue]); + + return { + // Show loading ONLY if we have absolutely no data (first load) + isLoading: !exists, + labels: cachedEntry?.labels || [], + }; +}; \ No newline at end of file diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 1847f6f..7bbe2b8 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -23,6 +23,8 @@ import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; import { Import } from "~/components/Import"; import Login from "~/components/Login"; import Logo from "~/components/LogoSvg"; +import { ModerationBatcher } from "~/components/ModerationBatcher"; +import { ModerationInitializer } from "~/components/ModerationInitializer"; import { NotFound } from "~/components/NotFound"; import { LikeMutationQueueProvider } from "~/providers/LikeMutationQueueProvider"; import { PollMutationQueueProvider } from "~/providers/PollMutationQueueProvider"; @@ -85,6 +87,8 @@ function RootComponent() { + + @@ -207,9 +211,8 @@ function RootDocument({ children }: { children: React.ReactNode }) { const location = useLocation(); const navigate = useNavigate(); const { agent } = useAuth(); - const authed = !!agent?.did; - const isHome = location.pathname === "/"; const isNotifications = location.pathname.startsWith("/notifications"); + const authed = !!agent?.did; const isProfile = agent && (location.pathname === `/profile/${agent?.did}` || diff --git a/src/routes/moderation.tsx b/src/routes/moderation.tsx index cc851e5..66c83bf 100644 --- a/src/routes/moderation.tsx +++ b/src/routes/moderation.tsx @@ -13,6 +13,7 @@ import { useAtom } from "jotai"; import { Switch } from "radix-ui"; import { Header } from "~/components/Header"; +import { useModeration } from "~/hooks/useModeration"; import { useAuth } from "~/providers/UnifiedAuthProvider"; import { quickAuthAtom } from "~/utils/atoms"; import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery"; @@ -28,11 +29,11 @@ export const Route = createFileRoute("/moderation")({ function RouteComponent() { const { agent } = useAuth(); - const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); + const [quickAuth] = useAtom(quickAuthAtom); const isAuthRestoring = quickAuth ? status === "loading" : false; const identityresultmaybe = useQueryIdentity( - !isAuthRestoring ? agent?.did : undefined + !isAuthRestoring ? agent?.did : undefined, ); const identity = identityresultmaybe?.data; @@ -44,8 +45,6 @@ function RouteComponent() { | ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"] | undefined; - //console.log(JSON.stringify(prefs, null, 2)) - const parsedPref = parsePreferences(rawprefs); return ( @@ -96,7 +95,7 @@ function RouteComponent() { { + onCheckedChange={() => { renderSnack({ title: "Sorry... Modifying preferences is not implemented yet", description: "You can use another app to change preferences", @@ -108,6 +107,13 @@ function RouteComponent() { + + + + + + +
{Object.entries(parsedPref?.contentLabelPrefs ?? {}).map( ([label, visibility]) => ( @@ -133,7 +139,7 @@ function RouteComponent() { value={visibility as "ignore" | "warn" | "hide"} />
- ) + ), )} @@ -174,11 +180,10 @@ export function TripleToggle({ }); onChange?.(opt); }} - className={`flex-1 px-3 py-1.5 rounded-full transition-colors ${ - isActive - ? "bg-gray-400 dark:bg-gray-600 text-white" - : "text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700" - }`} + className={`flex-1 px-3 py-1.5 rounded-full transition-colors ${isActive + ? "bg-gray-400 dark:bg-gray-600 text-white" + : "text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700" + }`} > {" "} {opt.charAt(0).toUpperCase() + opt.slice(1)} @@ -206,7 +211,7 @@ export interface NormalizedPreferences { } export function parsePreferences( - prefs?: PrefItem[] + prefs?: PrefItem[], ): NormalizedPreferences | undefined { if (!prefs) return undefined; const normalized: NormalizedPreferences = { @@ -267,3 +272,49 @@ export function parsePreferences( return normalized; } + + +export function TestModeration({ subject }: { subject: string }) { + return ( + <> + {/* Test the moderation system */} +
+
+ Moderation System Test + + Testing useModeration hook with example content + + +
+
+ + ) + +} + +export function ModerationInner({ subject }: { subject: string }) { + const { isLoading: moderationLoading, labels: testLabels } = useModeration( + subject, + ); + + return (<>{moderationLoading ? ( + + Loading moderation data... + + ) : ( +
+ + Found {testLabels.length} labels for {subject} + + {testLabels.map((label, index) => ( +
+ {label.val} -{" "} + {label.preference} (from {label.sourceDid}) +
+ ))} +
+ )}) +} \ No newline at end of file diff --git a/src/routes/profile.$did/index.tsx b/src/routes/profile.$did/index.tsx index 5b65e18..515eaca 100644 --- a/src/routes/profile.$did/index.tsx +++ b/src/routes/profile.$did/index.tsx @@ -13,9 +13,11 @@ import { useReusableTabScrollRestore, } from "~/components/ReusableTabRoute"; import { + SmallAuthorLabelBadge, UniversalPostRendererATURILoader, } from "~/components/UniversalPostRenderer"; import { renderTextWithFacets } from "~/components/UtilityFunctions"; +import { useModeration } from "~/hooks/useModeration"; import { useAuth } from "~/providers/UnifiedAuthProvider"; import { enableBitesAtom, imgCDNAtom, profileChipsAtom } from "~/utils/atoms"; import { @@ -53,6 +55,17 @@ function ProfileComponent() { error: identityError, } = useQueryIdentity(did); + + const { isLoading: authorModLoading, labels: authorLabels } = useModeration( + did, + ); + const hideAuthorLabels = authorLabels.filter( + label => label.preference === 'hide' + ); + const warnAuthorLabels = authorLabels.filter( + label => label.preference === 'warn' + ); + // i was gonna check the did doc but useQueryIdentity doesnt return that info (slingshot minidoc) // so instead we should query the labeler profile @@ -100,11 +113,11 @@ function ProfileComponent() { const resultwhateversure = useQueryConstellationLinksCountDistinctDids( resolvedDid ? { - method: "/links/count/distinct-dids", - collection: "app.bsky.graph.follow", - target: resolvedDid, - path: ".subject", - } + method: "/links/count/distinct-dids", + collection: "app.bsky.graph.follow", + target: resolvedDid, + path: ".subject", + } : undefined ); @@ -221,6 +234,35 @@ function ProfileComponent() { )} + {/* */} + {authorModLoading ? + ( +
+
+ {/* avatar */} + loading badges... +
+
+ ) + : + ( +
+ {warnAuthorLabels.map((label, index) => ( + + ))} +
+ ) + } @@ -231,8 +273,8 @@ function ProfileComponent() { tabs={{ ...(isLabeler ? { - Labels: , - } + Labels: , + } : {}), ...{ Posts: , @@ -696,11 +738,11 @@ export function FeedItemRender({ // @ts-expect-error overloads sucks !listmode ? { - target: feed.uri, - method: "/links/count", - collection: "app.bsky.feed.like", - path: ".subject.uri", - } + target: feed.uri, + method: "/links/count", + collection: "app.bsky.feed.like", + path: ".subject.uri", + } : undefined ); @@ -1044,11 +1086,11 @@ export function Mutual({ targetdidorhandle }: { targetdidorhandle: string }) { const theyFollowYouRes = useGetOneToOneState( agent?.did ? { - target: agent?.did, - user: identity?.did ?? targetdidorhandle, - collection: "app.bsky.graph.follow", - path: ".subject", - } + target: agent?.did, + user: identity?.did ?? targetdidorhandle, + collection: "app.bsky.graph.follow", + path: ".subject", + } : undefined ); diff --git a/src/state/moderationAtoms.ts b/src/state/moderationAtoms.ts new file mode 100644 index 0000000..af87817 --- /dev/null +++ b/src/state/moderationAtoms.ts @@ -0,0 +1,92 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +import type { ContentLabel, LabelerDefinition } from "~/types/moderation"; + +// --- Configuration --- +export const CACHE_TIMEOUT_MS = 3600000; // 1 Hour +const MAX_CACHE_ENTRIES = 2000; // Limit to prevent localStorage quota issues +const STORAGE_KEY = "moderation-cache-v1"; + +// --- Types --- +type CacheEntry = { labels: ContentLabel[]; timestamp: number }; +type CacheMap = Map; + +// --- Custom Storage Implementation --- +// We cannot use createJSONStorage because it fails to serialize Maps. +// We must write the storage logic manually. +const mapStorage = { + getItem: (key: string, initialValue: CacheMap): CacheMap => { + if (typeof window === "undefined" || !window.localStorage) { + return initialValue; + } + + try { + const item = localStorage.getItem(key); + if (!item) return initialValue; + + const parsed = JSON.parse(item); + + // Ensure it is an array (Map serialization format) + if (!Array.isArray(parsed)) return initialValue; + + const now = Date.now(); + const map = new Map(); + + parsed.forEach(([uri, data]) => { + // 1. STALENESS CHECK (On Load) + // Only load if younger than timeout + if (data && now - data.timestamp < CACHE_TIMEOUT_MS) { + map.set(uri, data); + } + }); + + console.log(`[Cache] Hydrated ${map.size} valid entries.`); + return map; + } catch (error) { + console.error("[Cache] Failed to load:", error); + return initialValue; + } + }, + + setItem: (key: string, value: CacheMap) => { + if (typeof window === "undefined" || !window.localStorage) return; + + try { + let entries = Array.from(value.entries()); + + // 2. SAFETY CAP (On Save) + // If we have too many entries, keep only the newest ones + if (entries.length > MAX_CACHE_ENTRIES) { + // Sort by timestamp descending (newest first) + entries.sort((a, b) => b[1].timestamp - a[1].timestamp); + // Keep top N + entries = entries.slice(0, MAX_CACHE_ENTRIES); + } + + // Convert Map -> Array -> JSON String + localStorage.setItem(key, JSON.stringify(entries)); + } catch (error) { + console.error("[Cache] Failed to save:", error); + } + }, + + removeItem: (key: string) => { + if (typeof window !== "undefined" && window.localStorage) { + localStorage.removeItem(key); + } + }, +}; + +// --- Atoms --- + +export const labelerConfigAtom = atom([]); + +export const moderationCacheAtom = atomWithStorage( + STORAGE_KEY, + new Map(), + mapStorage // <--- Pass our custom object here +); + +export const pendingUriQueueAtom = atom>(new Set()); +export const processingUriSetAtom = atom>(new Set()); \ No newline at end of file diff --git a/src/types/moderation.ts b/src/types/moderation.ts new file mode 100644 index 0000000..9175c7a --- /dev/null +++ b/src/types/moderation.ts @@ -0,0 +1,61 @@ +// AT Protocol moderation types + +export type LabelPreference = "ignore" | "warn" | "hide"; + +export interface LabelerDefinition { + did: string; + url: string; + isDefault: boolean; + supportedLabels: Record; + // The lookup map for UI strings + labelDefs: Record; +} + +export interface LabelValueDefinition { + identifier: string; + severity: 'inform' | 'alert' | 'none'; + blurs: 'content' | 'media' | 'none'; + adultOnly: boolean; + defaultSetting?: LabelPreference; + locales: Array<{ + lang: string; + name: string; + description: string; + }>; +} + +export interface ContentLabel { + sourceDid: string; // Who said it? + val: string; // What is the label? + cts: string; // Timestamp + preference: LabelPreference; // Resolved preference for this specific label +} + +// Type for the labeler service record response +export interface LabelerServiceRecord { + did: string; + serviceEndpoint: string; + policies: { + labelValues: string[]; + labelValueDefinitions?: Array<{ + identifier: string; + defaultSetting: LabelPreference; + }>; + }; +} + +// Type for queryLabels response (matches ATProto API) +export interface QueryLabelsResponse { + cursor?: string; + labels: Array<{ + ver?: number; + src: string; // DID + uri: string; // AT URI + cid?: string; // CID + val: string; // Label value + neg?: boolean; // Negation label + cts: string; // Created timestamp + exp?: string; // Expiry timestamp + sig?: Uint8Array; // Signature + }>; +} diff --git a/src/utils/useQuery.ts b/src/utils/useQuery.ts index cc41392..b846539 100644 --- a/src/utils/useQuery.ts +++ b/src/utils/useQuery.ts @@ -15,14 +15,14 @@ import { constellationURLAtom, lycanURLAtom, slingshotURLAtom } from "./atoms"; export function constructIdentityQuery( didorhandle?: string, - slingshoturl?: string + slingshoturl?: string, ) { return queryOptions({ queryKey: ["identity", didorhandle], queryFn: async () => { if (!didorhandle) return undefined as undefined; const res = await fetch( - `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}` + `https://${slingshoturl}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(didorhandle)}`, ); if (!res.ok) throw new Error("Failed to fetch post"); try { @@ -71,7 +71,7 @@ export function constructPostQuery(uri?: string, slingshoturl?: string) { queryFn: async () => { if (!uri) return undefined as undefined; const res = await fetch( - `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` + `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`, ); let data: any; try { @@ -135,7 +135,7 @@ export function constructProfileQuery(uri?: string, slingshoturl?: string) { queryFn: async () => { if (!uri) return undefined as undefined; const res = await fetch( - `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` + `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`, ); let data: any; try { @@ -269,7 +269,7 @@ export function constructConstellationQuery(query?: { const cursor = query.cursor; const dids = query?.dids; const res = await fetch( - `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("") : ""}` + `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("") : ""}`, ); if (!res.ok) throw new Error("Failed to fetch post"); try { @@ -308,8 +308,8 @@ export function useQueryConstellationLinksCountDistinctDids(query?: { const [constellationurl] = useAtom(constellationURLAtom); const queryres = useQuery( constructConstellationQuery( - query && { constellation: constellationurl, ...query } - ) + query && { constellation: constellationurl, ...query }, + ), ) as unknown as UseQueryResult; if (!query) { return undefined as undefined; @@ -389,8 +389,8 @@ export function useQueryConstellation(query?: { const [constellationurl] = useAtom(constellationURLAtom); return useQuery( constructConstellationQuery( - query && { constellation: constellationurl, ...query } - ) + query && { constellation: constellationurl, ...query }, + ), ); } @@ -446,7 +446,7 @@ export function constructFeedSkeletonQuery(options?: { // Authenticated flow if (!agent || !pdsUrl || !feedServiceDid) { throw new Error( - "Missing required info for authenticated feed fetch." + "Missing required info for authenticated feed fetch.", ); } const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}`; @@ -483,9 +483,47 @@ export function useQueryFeedSkeleton(options?: { return useQuery(constructFeedSkeletonQuery(options)); } +export function constructRecordQuery( + did?: string, + collection?: string, + rkey?: string, + pdsUrl?: string, +) { + return queryOptions({ + queryKey: ["record", did, collection, rkey], + queryFn: async () => { + if (!did || !collection || !rkey || !pdsUrl) + return undefined as undefined; + const url = `${pdsUrl}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`; + const res = await fetch(url); + if (!res.ok) throw new Error("Failed to fetch record"); + try { + return (await res.json()) as { + uri: string; + cid: string; + value: any; + }; + } catch (_e) { + return undefined; + } + }, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 5 * 60 * 1000, + }); +} + +export function useQueryRecord( + did?: string, + collection?: string, + rkey?: string, + pdsUrl?: string, +) { + return useQuery(constructRecordQuery(did, collection, rkey, pdsUrl)); +} + export function constructPreferencesQuery( agent?: ATPAPI.Agent | undefined, - pdsUrl?: string | undefined + pdsUrl?: string | undefined, ) { return queryOptions({ queryKey: ["preferences", agent?.did], @@ -511,7 +549,7 @@ export function constructArbitraryQuery(uri?: string, slingshoturl?: string) { queryFn: async () => { if (!uri) return undefined as undefined; const res = await fetch( - `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}` + `https://${slingshoturl}/xrpc/com.bad-example.repo.getUriRecord?at_uri=${encodeURIComponent(uri)}`, ); let data: any; try { @@ -590,7 +628,7 @@ type ListRecordsResponse = { export function constructAuthorFeedQuery( did: string, pdsUrl: string, - collection: string = "app.bsky.feed.post" + collection: string = "app.bsky.feed.post", ) { return queryOptions({ queryKey: ["authorFeed", did, collection], @@ -613,12 +651,12 @@ export function constructAuthorFeedQuery( export function useInfiniteQueryAuthorFeed( did: string | undefined, pdsUrl: string | undefined, - collection?: string + collection?: string, ) { const { queryKey, queryFn } = constructAuthorFeedQuery( did!, pdsUrl!, - collection + collection, ); return useInfiniteQuery({ @@ -655,7 +693,7 @@ export function constructInfiniteFeedSkeletonQuery(options: { if (isAuthed && !unauthedfeedurl) { if (!agent || !pdsUrl || !feedServiceDid) { throw new Error( - "Missing required info for authenticated feed fetch." + "Missing required info for authenticated feed fetch.", ); } const url = `${pdsUrl}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; @@ -748,7 +786,7 @@ export function yknowIReallyHateThisButWhateverGuardedConstructConstellationInfi collection ? `&collection=${encodeURIComponent(collection)}` : "" }${path ? `&path=${encodeURIComponent(path)}` : ""}${ cursor ? `&cursor=${encodeURIComponent(cursor)}` : "" - }` + }`, ); if (!res.ok) throw new Error("Failed to fetch"); @@ -774,8 +812,8 @@ export function useQueryLycanStatus() { agent: agent || undefined, isAuthed: status === "signedIn", pdsUrl: identity?.pds, - feedServiceDid: "did:web:"+lycanurl, - }) + feedServiceDid: "did:web:" + lycanurl, + }), ); } @@ -802,7 +840,7 @@ export function constructLycanStatusCheckQuery(options: { }); if (!res.ok) throw new Error( - `Authenticated lycan status fetch failed: ${res.statusText}` + `Authenticated lycan status fetch failed: ${res.statusText}`, ); return (await res.json()) as statuschek; } @@ -816,15 +854,14 @@ type statuschek = { error?: "MethodNotImplemented"; message?: "Method Not Implemented"; status?: "finished" | "in_progress"; - position?: string, - progress?: number, - + position?: string; + progress?: number; }; //{"status":"in_progress","position":"2025-08-30T06:53:18Z","progress":0.0878319661441268} type importtype = { - message?: "Import has already started" | "Import has been scheduled" -} + message?: "Import has already started" | "Import has been scheduled"; +}; export function constructLycanRequestIndexQuery(options: { agent?: ATPAPI.Agent; @@ -849,9 +886,9 @@ export function constructLycanRequestIndexQuery(options: { }); if (!res.ok) throw new Error( - `Authenticated lycan status fetch failed: ${res.statusText}` + `Authenticated lycan status fetch failed: ${res.statusText}`, ); - return await res.json() as importtype; + return (await res.json()) as importtype; } return undefined; }, @@ -864,22 +901,22 @@ type LycanSearchPage = { cursor?: string; }; - -export function useInfiniteQueryLycanSearch(options: { query: string, type: "likes" | "pins" | "reposts" | "quotes"}) { - - +export function useInfiniteQueryLycanSearch(options: { + query: string; + type: "likes" | "pins" | "reposts" | "quotes"; +}) { const [lycanurl] = useAtom(lycanURLAtom); const { agent, status } = useAuth(); const { data: identity } = useQueryIdentity(agent?.did); const { queryKey, queryFn } = constructLycanSearchQuery({ - agent: agent || undefined, - isAuthed: status === "signedIn", - pdsUrl: identity?.pds, - feedServiceDid: "did:web:"+lycanurl, - query: options.query, - type: options.type, - }) + agent: agent || undefined, + isAuthed: status === "signedIn", + pdsUrl: identity?.pds, + feedServiceDid: "did:web:" + lycanurl, + query: options.query, + type: options.type, + }); return { ...useInfiniteQuery({ @@ -901,7 +938,6 @@ export function useInfiniteQueryLycanSearch(options: { query: string, type: "lik }; } - export function constructLycanSearchQuery(options: { agent?: ATPAPI.Agent; isAuthed: boolean; @@ -929,7 +965,7 @@ export function constructLycanSearchQuery(options: { }); if (!res.ok) throw new Error( - `Authenticated lycan status fetch failed: ${res.statusText}` + `Authenticated lycan status fetch failed: ${res.statusText}`, ); return (await res.json()) as LycanSearchPage; } -- 2.51.2