Refactor moderation handling and introduce labels feature (#1)

* feat: labels wip

* refactor: replace BoardsProvider with StoresProvider and update moderation handling

* fix: remove unused code

authored by turtlepaw.pds.witchcraft.syste… and committed by GitHub 9148cc85 efff9d67

+5
bun.lock
··· 18 18 "@radix-ui/react-progress": "^1.1.7", 19 19 "@radix-ui/react-scroll-area": "^1.2.9", 20 20 "@radix-ui/react-slot": "^1.2.3", 21 + "@radix-ui/react-switch": "^1.2.5", 21 22 "@radix-ui/react-tabs": "^1.1.12", 22 23 "@radix-ui/react-tooltip": "^1.2.7", 23 24 "class-variance-authority": "^0.7.1", ··· 394 395 395 396 "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], 396 397 398 + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ=="], 399 + 397 400 "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="], 398 401 399 402 "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw=="], ··· 409 412 "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], 410 413 411 414 "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], 415 + 416 + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], 412 417 413 418 "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], 414 419
+1
package.json
··· 26 26 "@radix-ui/react-progress": "^1.1.7", 27 27 "@radix-ui/react-scroll-area": "^1.2.9", 28 28 "@radix-ui/react-slot": "^1.2.3", 29 + "@radix-ui/react-switch": "^1.2.5", 29 30 "@radix-ui/react-tabs": "^1.1.12", 30 31 "@radix-ui/react-tooltip": "^1.2.7", 31 32 "class-variance-authority": "^0.7.1",
+3 -3
src/app/layout.tsx
··· 6 6 import { AuthProvider } from "@/lib/hooks/useAuth"; 7 7 import { ProfileProvider } from "@/lib/useProfile"; 8 8 import { Toaster } from "sonner"; 9 - import { BoardsProvider } from "@/lib/hooks/useBoards"; 9 + import { StoresProvider } from "@/lib/stores/storesProvider"; 10 10 11 11 const geistSans = Geist({ 12 12 variable: "--font-geist-sans", ··· 37 37 <ThemeProvider attribute="class" defaultTheme="system" enableSystem> 38 38 <AuthProvider> 39 39 <ProfileProvider> 40 - <BoardsProvider> 40 + <StoresProvider> 41 41 <div className="min-h-screen flex flex-col"> 42 42 <Navbar /> 43 43 <main className="flex-1 py-6">{children}</main> 44 44 </div> 45 - </BoardsProvider> 45 + </StoresProvider> 46 46 </ProfileProvider> 47 47 </AuthProvider> 48 48 </ThemeProvider>
+48
src/components/ContentWarning.tsx
··· 1 + "use client"; 2 + 3 + import { useState, useEffect } from "react"; 4 + import { AlertTriangle, Eye } from "lucide-react"; 5 + import { Button } from "@/components/ui/button"; 6 + import { Card } from "@/components/ui/card"; 7 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 8 + import { type ModerationOpts } from "@atproto/api/dist/moderation/types"; 9 + import { useModerationStore } from "@/lib/stores/moderation"; 10 + import { useAuth } from "@/lib/hooks/useAuth"; 11 + import { ModerationDecision } from "@atproto/api"; 12 + 13 + interface ContentWarningProps { 14 + mod: ModerationDecision; 15 + children: React.ReactNode; 16 + className?: string; 17 + } 18 + 19 + export function ContentWarning({ 20 + mod, 21 + children, 22 + className, 23 + }: ContentWarningProps) { 24 + const modUi = mod.ui("contentMedia"); 25 + 26 + if (modUi.filter) return; 27 + 28 + if (modUi.blur) { 29 + return ( 30 + <div className={className}> 31 + <div className="relative overflow-hidden rounded-2xl"> 32 + <div className="blur-3xl">{children}</div> 33 + <div className="absolute inset-0 flex items-center justify-center"></div> 34 + <div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm"> 35 + <div className="space-y-3 text-center"> 36 + <div className="flex items-center justify-center gap-3"> 37 + <AlertTriangle className="h-5 w-5 text-orange-500 flex-shrink-0" /> 38 + <h4 className="font-medium text-orange-100">Content Warning</h4> 39 + </div> 40 + </div> 41 + </div> 42 + </div> 43 + </div> 44 + ); 45 + } 46 + 47 + return <div className={className}>{children}</div>; 48 + }
+115 -73
src/components/Feed.tsx
··· 1 1 "use client"; 2 2 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 3 - import { AppBskyEmbedImages, AppBskyFeedPost, AtUri } from "@atproto/api"; 3 + import { 4 + Agent, 5 + AppBskyEmbedImages, 6 + AppBskyFeedPost, 7 + AtUri, 8 + moderatePost, 9 + ModerationPrefs, 10 + } from "@atproto/api"; 4 11 import { LoaderCircle } from "lucide-react"; 5 12 import { motion } from "motion/react"; 6 13 import Image from "next/image"; ··· 11 18 import { UnsaveButton } from "./UnsaveButton"; 12 19 import { LikeButton } from "./LikeButton"; 13 20 import { useState, useEffect } from "react"; 21 + import { 22 + DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, 23 + useModerationOpts, 24 + } from "@/lib/hooks/useModerationOpts"; 25 + import { useAuth } from "@/lib/hooks/useAuth"; 26 + import { ContentWarning } from "./ContentWarning"; 27 + import clsx from "clsx"; 14 28 15 29 export type FeedItem = { 16 30 id: string; ··· 84 98 }) { 85 99 const image = getImageFromItem(item, index); 86 100 const [isDropdownOpen, setDropdownOpen] = useState(false); 101 + const modOpts = useModerationOpts(); 102 + const { session, agent } = useAuth(); 87 103 88 104 if (!image) return; 89 105 90 106 const ActionButton = showUnsaveButton ? UnsaveButton : SaveButton; 91 107 const txt = getText(item); 108 + const opts: ModerationPrefs = modOpts.moderationPrefs ?? { 109 + adultContentEnabled: false, 110 + labelers: agent.appLabelers.map((did) => ({ 111 + did, 112 + labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, 113 + })), 114 + hiddenPosts: [], 115 + mutedWords: [], 116 + labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, 117 + }; 118 + const mod = moderatePost(item, { 119 + prefs: opts, 120 + labelDefs: modOpts.labelDefs, 121 + userDid: session?.did, 122 + }); 123 + 124 + // Debug code removed for production 92 125 93 126 return ( 94 - <div className={`relative group ${isDropdownOpen ? "hover-active" : ""}`}> 95 - {/* Save/Unsave button – top-left */} 96 - <div className="absolute top-3 left-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity"> 97 - {ActionButton && ( 98 - <ActionButton 99 - image={index} 100 - post={item} 101 - onDropdownOpenChange={setDropdownOpen} 102 - /> 103 - )} 104 - </div> 127 + <ContentWarning mod={mod}> 128 + <div className={`relative group ${isDropdownOpen ? "hover-active" : ""}`}> 129 + {/* Save/Unsave button – top-left */} 130 + <div className="absolute top-3 left-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity"> 131 + {ActionButton && ( 132 + <ActionButton 133 + image={index} 134 + post={item} 135 + onDropdownOpenChange={setDropdownOpen} 136 + /> 137 + )} 138 + </div> 105 139 106 - {/* Like button – top-right */} 107 - <div className="absolute top-3 right-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity"> 108 - <LikeButton post={item} /> 109 - </div> 140 + {/* Like button – top-right */} 141 + <div className="absolute top-3 right-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity"> 142 + <LikeButton post={item} /> 143 + </div> 110 144 111 - {/* Link wraps image only */} 112 - <Link 113 - href={`/${item.author.did}/${AtUri.make(item.uri).rkey}`} 114 - className="block" 115 - > 116 - <motion.div 117 - initial={{ opacity: 0, y: 5 }} 118 - animate={{ opacity: 1, y: 0 }} 119 - transition={{ duration: 0.5, ease: "easeOut" }} 120 - whileTap={{ scale: 0.95 }} 121 - className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900" 145 + {/* Link wraps image only */} 146 + <Link 147 + href={`/${item.author.did}/${AtUri.make(item.uri).rkey}`} 148 + className="block" 122 149 > 123 - {/* Blurred background */} 124 - <Image 125 - src={image.fullsize} 126 - alt="" 127 - fill 128 - placeholder={image.thumb ? "blur" : "empty"} 129 - blurDataURL={image.thumb} 130 - className="object-cover filter blur-xl scale-110 opacity-30" 131 - /> 132 - 133 - {/* Foreground image */} 134 - <div className="relative z-10 flex items-center justify-center w-full min-h-[120px]"> 150 + <motion.div 151 + initial={{ opacity: 0, y: 5 }} 152 + animate={{ opacity: 1, y: 0 }} 153 + transition={{ duration: 0.5, ease: "easeOut" }} 154 + whileTap={{ scale: 0.95 }} 155 + className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900" 156 + > 157 + {/* Blurred background */} 135 158 <Image 136 159 src={image.fullsize} 137 - alt={image.alt || ""} 160 + alt="" 161 + fill 138 162 placeholder={image.thumb ? "blur" : "empty"} 139 163 blurDataURL={image.thumb} 140 - width={image.aspectRatio?.width ?? 400} 141 - height={image.aspectRatio?.height ?? 400} 142 - className="object-contain max-w-full max-h-full rounded-lg" 143 - priority 164 + className="object-cover filter blur-xl scale-110 opacity-30" 144 165 /> 145 - </div> 146 166 147 - {/* Author info */} 148 - {item.author && ( 149 - <div className="absolute inset-0 z-20 bg-black/40 text-white opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity duration-300 flex flex-col justify-between p-3"> 150 - <div className="w-fit self-start" /> 167 + {/* Foreground image */} 168 + <div className="relative z-10 flex items-center justify-center w-full min-h-[120px]"> 169 + <Image 170 + src={image.fullsize} 171 + alt={image.alt || ""} 172 + placeholder={image.thumb ? "blur" : "empty"} 173 + blurDataURL={image.thumb} 174 + width={image.aspectRatio?.width ?? 400} 175 + height={image.aspectRatio?.height ?? 400} 176 + className="object-contain max-w-full max-h-full rounded-lg" 177 + priority 178 + /> 179 + </div> 151 180 152 - <div className="flex flex-col gap-2"> 153 - <div className="flex items-center gap-2"> 154 - <Avatar> 155 - <AvatarImage src={item.author.avatar} /> 156 - <AvatarFallback> 157 - {item.author.displayName || item.author.handle} 158 - </AvatarFallback> 159 - </Avatar> 160 - <div className="flex flex-col leading-tight"> 161 - <span>{item.author.displayName || item.author.handle}</span> 162 - <span className="text-white/70 text-[0.75rem]"> 163 - @{item.author.handle} 164 - </span> 165 - </div> 166 - </div> 181 + {/* Author info */} 182 + {item.author && ( 183 + <div className="absolute inset-0 z-20 bg-black/40 text-white opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity duration-300 flex flex-col justify-between p-3"> 184 + <div className="w-fit self-start" /> 167 185 168 - {txt && ( 169 - <div className="text-sm"> 170 - {txt.length > 100 ? txt.slice(0, 100) + "…" : txt} 186 + <div className="flex flex-col gap-2"> 187 + <div className="flex items-center gap-2"> 188 + <Avatar> 189 + <AvatarImage 190 + className={clsx( 191 + mod.ui("avatar").blur ? "blur-3xl" : "" 192 + )} 193 + src={item.author.avatar} 194 + /> 195 + <AvatarFallback> 196 + {item.author.displayName || item.author.handle} 197 + </AvatarFallback> 198 + </Avatar> 199 + <div className="flex flex-col leading-tight"> 200 + <span> 201 + {item.author.displayName || item.author.handle} 202 + </span> 203 + <span className="text-white/70 text-[0.75rem]"> 204 + @{item.author.handle} 205 + </span> 206 + </div> 171 207 </div> 172 - )} 208 + 209 + {txt && ( 210 + <div className="text-sm"> 211 + {txt.length > 100 ? txt.slice(0, 100) + "…" : txt} 212 + </div> 213 + )} 214 + </div> 173 215 </div> 174 - </div> 175 - )} 176 - </motion.div> 177 - </Link> 178 - </div> 216 + )} 217 + </motion.div> 218 + </Link> 219 + </div> 220 + </ContentWarning> 179 221 ); 180 222 } 181 223
src/components/ui/badge.tsx

This is a binary file and will not be displayed.

+31
src/components/ui/switch.tsx
··· 1 + "use client" 2 + 3 + import * as React from "react" 4 + import * as SwitchPrimitive from "@radix-ui/react-switch" 5 + 6 + import { cn } from "@/lib/utils" 7 + 8 + function Switch({ 9 + className, 10 + ...props 11 + }: React.ComponentProps<typeof SwitchPrimitive.Root>) { 12 + return ( 13 + <SwitchPrimitive.Root 14 + data-slot="switch" 15 + className={cn( 16 + "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", 17 + className 18 + )} 19 + {...props} 20 + > 21 + <SwitchPrimitive.Thumb 22 + data-slot="switch-thumb" 23 + className={cn( 24 + "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0" 25 + )} 26 + /> 27 + </SwitchPrimitive.Root> 28 + ) 29 + } 30 + 31 + export { Switch }
+7 -6
src/lib/hooks/useAuth.tsx
··· 18 18 19 19 type AuthContextType = { 20 20 session: OAuthSession | null; 21 - agent: Agent | null; 21 + agent: Agent; 22 22 loading: boolean; 23 23 login: (handle: string) => Promise<void>; 24 24 logout: () => void; ··· 27 27 const AuthContext = createContext<AuthContextType | null>(null); 28 28 29 29 export function AuthProvider({ children }: { children: ReactNode }) { 30 + const defaultAgent = new Agent({ service: "https://bsky.social" }); 30 31 const [session, setSession] = useState<OAuthSession | null>(null); 31 - const [agent, setAgent] = useState<Agent | null>( 32 - new Agent({ service: "https://bsky.social" }) 33 - ); 32 + const [agent, setAgent] = useState<Agent>(defaultAgent); 34 33 const [loading, setLoading] = useState(true); 35 34 const [client, setClient] = useState<BrowserOAuthClient | null>(null); 36 35 ··· 71 70 const ag = new Agent(result.session); 72 71 setSession(result.session); 73 72 setAgent(ag); 73 + const prefs = await ag.getPreferences(); 74 + if (!prefs) return; 74 75 } else { 75 76 const did = localStorage.getItem("did"); 76 77 ··· 91 92 c.addEventListener("deleted", (event: CustomEvent) => { 92 93 console.warn("Session invalidated", event.detail); 93 94 setSession(null); 94 - setAgent(null); 95 + setAgent(defaultAgent); 95 96 }); 96 97 }; 97 98 ··· 121 122 if (client && session) { 122 123 client.revoke(session.sub); 123 124 setSession(null); 124 - setAgent(null); 125 + setAgent(defaultAgent); 125 126 // refresh page 126 127 window.location.reload(); 127 128 }
-6
src/lib/hooks/useBoards.tsx
··· 54 54 55 55 return { isLoading }; 56 56 } 57 - 58 - export function BoardsProvider({ children }: PropsWithChildren) { 59 - useBoards(); 60 - useBoardItems(); 61 - return children; 62 - }
+71
src/lib/hooks/useModerationOpts.tsx
··· 1 + "use client"; 2 + import { useEffect } from "react"; 3 + import { useAuth } from "./useAuth"; 4 + import { useModerationOptsStore } from "../stores/moderationOpts"; 5 + import { DEFAULT_LABEL_SETTINGS } from "@atproto/api"; 6 + 7 + /** 8 + * From {@link https://github.com/bluesky-social/social-app/blob/2a6172cbaf2db0eda2a7cd2afaeef4b60aadf3ba/src/state/queries/preferences/moderation.ts#L15} 9 + */ 10 + export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS = 11 + Object.fromEntries( 12 + Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, "hide"]) 13 + ); 14 + 15 + export function useModerationOpts() { 16 + const { agent } = useAuth(); 17 + const { 18 + moderationPrefs, 19 + labelDefs, 20 + isLoading, 21 + error, 22 + setModerationOpts, 23 + setLoading, 24 + setError, 25 + isStale, 26 + shouldRefetch, 27 + } = useModerationOptsStore(); 28 + 29 + useEffect(() => { 30 + if (!agent || agent?.did == null) return; 31 + 32 + const fetchModerationOpts = async () => { 33 + try { 34 + setLoading(true); 35 + const prefs = await agent.getPreferences(); 36 + const labelDefs = await agent.getLabelDefinitions(prefs); 37 + setModerationOpts(prefs.moderationPrefs, labelDefs); 38 + } catch (err) { 39 + console.error("Error fetching moderation opts:", err); 40 + setError(err instanceof Error ? err.message : String(err)); 41 + } finally { 42 + setLoading(false); 43 + } 44 + }; 45 + 46 + // If we have stale data, return it immediately but fetch fresh data in background 47 + if (moderationPrefs && labelDefs && isStale()) { 48 + fetchModerationOpts(); // Background refresh 49 + } 50 + // If we have no data or data is expired, fetch immediately 51 + else if (!moderationPrefs || !labelDefs || shouldRefetch()) { 52 + fetchModerationOpts(); 53 + } 54 + }, [ 55 + agent, 56 + moderationPrefs, 57 + labelDefs, 58 + isStale, 59 + shouldRefetch, 60 + setModerationOpts, 61 + setLoading, 62 + setError, 63 + ]); 64 + 65 + return { 66 + moderationPrefs, 67 + labelDefs, 68 + isLoading, 69 + error, 70 + }; 71 + }
+51
src/lib/stores/moderation.tsx
··· 1 + import { create } from "zustand"; 2 + import { persist } from "zustand/middleware"; 3 + import { moderatePost, ModerationDecision } from "@atproto/api/dist/moderation"; 4 + import { type ModerationOpts } from "@atproto/api/dist/moderation/types"; 5 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 6 + 7 + export interface ModerationState { 8 + // Simple content warning preferences 9 + showContentWarnings: boolean; 10 + 11 + // Actions 12 + setShowContentWarnings: (show: boolean) => void; 13 + 14 + // Helper to get moderation decision using AT Protocol's built-in moderation 15 + getModerationDecision: ( 16 + post: PostView, 17 + opts: ModerationOpts 18 + ) => ModerationDecision; 19 + shouldShowWarning: (post: PostView, opts: ModerationOpts) => boolean; 20 + } 21 + 22 + export const useModerationStore = create<ModerationState>()( 23 + persist( 24 + (set, get) => ({ 25 + showContentWarnings: true, 26 + 27 + setShowContentWarnings: (show) => set({ showContentWarnings: show }), 28 + 29 + getModerationDecision: (post: PostView, opts: ModerationOpts) => { 30 + return moderatePost(post, opts); 31 + }, 32 + 33 + shouldShowWarning: (post: PostView, opts: ModerationOpts) => { 34 + const state = get(); 35 + if (!state.showContentWarnings) return false; 36 + 37 + const decision = state.getModerationDecision(post, opts); 38 + const ui = decision.ui("contentView"); 39 + 40 + // Show warning if content has alerts or informs 41 + return ui.alert || ui.inform; 42 + }, 43 + }), 44 + { 45 + name: "moderation-store", 46 + partialize: (state) => ({ 47 + showContentWarnings: state.showContentWarnings, 48 + }), 49 + } 50 + ) 51 + );
+113
src/lib/stores/moderationOpts.ts
··· 1 + import { create } from "zustand"; 2 + import { persist } from "zustand/middleware"; 3 + import { InterpretedLabelValueDefinition, ModerationPrefs } from "@atproto/api"; 4 + 5 + interface ModerationOptsData { 6 + moderationPrefs: ModerationPrefs | undefined; 7 + labelDefs: Record<string, InterpretedLabelValueDefinition[]> | undefined; 8 + lastFetched: number | null; 9 + isLoading: boolean; 10 + error: string | null; 11 + } 12 + 13 + interface ModerationOptsState extends ModerationOptsData { 14 + setModerationOpts: ( 15 + moderationPrefs: ModerationPrefs, 16 + labelDefs: Record<string, InterpretedLabelValueDefinition[]> 17 + ) => void; 18 + setLoading: (isLoading: boolean) => void; 19 + setError: (error: string | null) => void; 20 + isStale: () => boolean; 21 + shouldRefetch: () => boolean; 22 + clear: () => void; 23 + } 24 + 25 + const STALE_TIME = 15 * 60 * 1000; // 15 minutes in milliseconds 26 + const CACHE_TIME = 30 * 60 * 1000; // 30 minutes in milliseconds 27 + 28 + export const useModerationOptsStore = create<ModerationOptsState>()( 29 + persist( 30 + (set, get) => ({ 31 + moderationPrefs: undefined, 32 + labelDefs: undefined, 33 + lastFetched: null, 34 + isLoading: false, 35 + error: null, 36 + 37 + setModerationOpts: (moderationPrefs, labelDefs) => { 38 + set({ 39 + moderationPrefs, 40 + labelDefs, 41 + lastFetched: Date.now(), 42 + error: null, 43 + }); 44 + }, 45 + 46 + setLoading: (isLoading) => set({ isLoading }), 47 + 48 + setError: (error) => set({ error, isLoading: false }), 49 + 50 + isStale: () => { 51 + const { lastFetched } = get(); 52 + if (!lastFetched) return true; 53 + return Date.now() - lastFetched > STALE_TIME; 54 + }, 55 + 56 + shouldRefetch: () => { 57 + const { lastFetched, isLoading } = get(); 58 + if (isLoading) return false; 59 + if (!lastFetched) return true; 60 + return Date.now() - lastFetched > CACHE_TIME; 61 + }, 62 + 63 + clear: () => 64 + set({ 65 + moderationPrefs: undefined, 66 + labelDefs: undefined, 67 + lastFetched: null, 68 + error: null, 69 + }), 70 + }), 71 + { 72 + name: "moderation-opts-storage", 73 + partialize: (state) => ({ 74 + moderationPrefs: state.moderationPrefs, 75 + labelDefs: state.labelDefs, 76 + lastFetched: state.lastFetched, 77 + }), 78 + // Add storage configuration to handle complex objects 79 + storage: { 80 + getItem: (name) => { 81 + const str = localStorage.getItem(name); 82 + if (!str) return null; 83 + try { 84 + const parsed = JSON.parse(str); 85 + return parsed; 86 + } catch (error) { 87 + console.error( 88 + "Failed to parse moderation opts from localStorage:", 89 + error 90 + ); 91 + return null; 92 + } 93 + }, 94 + setItem: (name, value) => { 95 + try { 96 + localStorage.setItem(name, JSON.stringify(value)); 97 + } catch (error) { 98 + console.error( 99 + "Failed to serialize moderation opts to localStorage:", 100 + error 101 + ); 102 + } 103 + }, 104 + removeItem: (name) => localStorage.removeItem(name), 105 + }, 106 + } 107 + ) 108 + ); 109 + 110 + // Utility function to clear moderation options cache (useful for logout) 111 + export const clearModerationOptsCache = () => { 112 + useModerationOptsStore.getState().clear(); 113 + };
+12
src/lib/stores/storesProvider.tsx
··· 1 + "use client"; 2 + import { PropsWithChildren } from "react"; 3 + import { useBoards } from "../hooks/useBoards"; 4 + import { useBoardItems } from "../hooks/useBoardItems"; 5 + import { useModerationOpts } from "../hooks/useModerationOpts"; 6 + 7 + export function StoresProvider({ children }: PropsWithChildren) { 8 + useBoards(); 9 + useBoardItems(); 10 + useModerationOpts(); 11 + return children; 12 + }
+6
src/nav/navbar.tsx
··· 109 109 My Boards 110 110 </DropdownMenuItem> 111 111 </Link> 112 + <Link href={"/moderation"}> 113 + <DropdownMenuItem className="cursor-pointer"> 114 + Content Settings 115 + </DropdownMenuItem> 116 + </Link> 117 + <DropdownMenuSeparator /> 112 118 <DropdownMenuItem className="cursor-pointer" onClick={logout}> 113 119 Logout 114 120 </DropdownMenuItem>