Scrapboard.org client

feat: labels wip

+409 -72
+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",
+7
src/app/moderation/page.tsx
··· 1 + "use client"; 2 + 3 + import { ModerationSettings } from "@/components/ModerationSettings"; 4 + 5 + export default function ModerationPage() { 6 + return <ModerationSettings />; 7 + }
+129
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 + 12 + interface ContentWarningProps { 13 + post: PostView; 14 + children: React.ReactNode; 15 + className?: string; 16 + } 17 + 18 + export function ContentWarning({ 19 + post, 20 + children, 21 + className, 22 + }: ContentWarningProps) { 23 + const { session, agent } = useAuth(); 24 + const { shouldShowWarning, getModerationDecision } = useModerationStore(); 25 + const [showContent, setShowContent] = useState(false); 26 + const [moderationOpts, setModerationOpts] = useState<ModerationOpts | null>( 27 + null 28 + ); 29 + 30 + // Load user's actual moderation preferences from Bluesky 31 + useEffect(() => { 32 + async function loadModerationPrefs() { 33 + if (!agent || !session?.did) return; 34 + 35 + try { 36 + const prefs = await agent.getPreferences(); 37 + const moderationPrefs = prefs.moderationPrefs; 38 + 39 + setModerationOpts({ 40 + userDid: session.did, 41 + prefs: { 42 + adultContentEnabled: moderationPrefs.adultContentEnabled, 43 + labels: moderationPrefs.labels, 44 + labelers: moderationPrefs.labelers, 45 + mutedWords: moderationPrefs.mutedWords, 46 + hiddenPosts: moderationPrefs.hiddenPosts, 47 + }, 48 + }); 49 + } catch (error) { 50 + console.warn("Failed to load moderation preferences:", error); 51 + // Fallback to basic preferences 52 + setModerationOpts({ 53 + userDid: session.did, 54 + prefs: { 55 + adultContentEnabled: false, 56 + labels: {}, 57 + labelers: [], 58 + mutedWords: [], 59 + hiddenPosts: [], 60 + }, 61 + }); 62 + } 63 + } 64 + 65 + loadModerationPrefs(); 66 + }, [agent, session?.did]); 67 + 68 + // Don't render anything until we have moderation options 69 + if (!moderationOpts) { 70 + return <div className={className}>{children}</div>; 71 + } 72 + 73 + const shouldWarn = shouldShowWarning(post, moderationOpts); 74 + 75 + // If no warning needed, show content normally 76 + if (!shouldWarn || showContent) { 77 + return <div className={className}>{children}</div>; 78 + } 79 + 80 + // Get the specific moderation decision to show relevant warning 81 + const decision = getModerationDecision(post, moderationOpts); 82 + const ui = decision.ui("contentView"); 83 + const causes = [...ui.alerts, ...ui.informs]; 84 + 85 + return ( 86 + <Card 87 + className={`p-4 border-orange-200 dark:border-orange-800 ${className}`} 88 + > 89 + <div className="space-y-3"> 90 + <div className="flex items-start gap-3"> 91 + <AlertTriangle className="h-5 w-5 text-orange-500 mt-0.5 flex-shrink-0" /> 92 + <div className="flex-1 space-y-2"> 93 + <h4 className="font-medium text-orange-900 dark:text-orange-100"> 94 + Content Warning 95 + </h4> 96 + <div className="text-sm text-orange-800 dark:text-orange-200 space-y-1"> 97 + {causes.map((cause, index) => ( 98 + <div key={index}> 99 + {cause.type === "label" && ( 100 + <span> 101 + This content has been labeled: {cause.label.val} 102 + {cause.labelDef.adultOnly && " (adult content)"} 103 + </span> 104 + )} 105 + {cause.type === "mute-word" && ( 106 + <span>Contains muted words</span> 107 + )} 108 + </div> 109 + ))} 110 + {causes.length === 0 && ( 111 + <span>This content may be sensitive</span> 112 + )} 113 + </div> 114 + </div> 115 + </div> 116 + 117 + <Button 118 + variant="outline" 119 + size="sm" 120 + onClick={() => setShowContent(true)} 121 + className="border-orange-300 text-orange-700 hover:bg-orange-50 dark:border-orange-700 dark:text-orange-300 dark:hover:bg-orange-950" 122 + > 123 + <Eye className="h-4 w-4 mr-1" /> 124 + Show Content 125 + </Button> 126 + </div> 127 + </Card> 128 + ); 129 + }
+77 -72
src/components/Feed.tsx
··· 10 10 import { SaveButton } from "./SaveButton"; 11 11 import { UnsaveButton } from "./UnsaveButton"; 12 12 import { LikeButton } from "./LikeButton"; 13 + import { ContentWarning } from "./ContentWarning"; 13 14 import { useState, useEffect } from "react"; 14 15 15 16 export type FeedItem = { ··· 91 92 const txt = getText(item); 92 93 93 94 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> 95 + <ContentWarning post={item}> 96 + <div className={`relative group ${isDropdownOpen ? "hover-active" : ""}`}> 97 + {/* Save/Unsave button – top-left */} 98 + <div className="absolute top-3 left-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity"> 99 + {ActionButton && ( 100 + <ActionButton 101 + image={index} 102 + post={item} 103 + onDropdownOpenChange={setDropdownOpen} 104 + /> 105 + )} 106 + </div> 105 107 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> 108 + {/* Like button – top-right */} 109 + <div className="absolute top-3 right-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity"> 110 + <LikeButton post={item} /> 111 + </div> 110 112 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" 113 + {/* Link wraps image only */} 114 + <Link 115 + href={`/${item.author.did}/${AtUri.make(item.uri).rkey}`} 116 + className="block" 122 117 > 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]"> 118 + <motion.div 119 + initial={{ opacity: 0, y: 5 }} 120 + animate={{ opacity: 1, y: 0 }} 121 + transition={{ duration: 0.5, ease: "easeOut" }} 122 + whileTap={{ scale: 0.95 }} 123 + className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900" 124 + > 125 + {/* Blurred background */} 135 126 <Image 136 127 src={image.fullsize} 137 - alt={image.alt || ""} 128 + alt="" 129 + fill 138 130 placeholder={image.thumb ? "blur" : "empty"} 139 131 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 132 + className="object-cover filter blur-xl scale-110 opacity-30" 144 133 /> 145 - </div> 146 134 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" /> 135 + {/* Foreground image */} 136 + <div className="relative z-10 flex items-center justify-center w-full min-h-[120px]"> 137 + <Image 138 + src={image.fullsize} 139 + alt={image.alt || ""} 140 + placeholder={image.thumb ? "blur" : "empty"} 141 + blurDataURL={image.thumb} 142 + width={image.aspectRatio?.width ?? 400} 143 + height={image.aspectRatio?.height ?? 400} 144 + className="object-contain max-w-full max-h-full rounded-lg" 145 + priority 146 + /> 147 + </div> 151 148 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> 149 + {/* Author info */} 150 + {item.author && ( 151 + <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"> 152 + <div className="w-fit self-start" /> 167 153 168 - {txt && ( 169 - <div className="text-sm"> 170 - {txt.length > 100 ? txt.slice(0, 100) + "…" : txt} 154 + <div className="flex flex-col gap-2"> 155 + <div className="flex items-center gap-2"> 156 + <Avatar> 157 + <AvatarImage src={item.author.avatar} /> 158 + <AvatarFallback> 159 + {item.author.displayName || item.author.handle} 160 + </AvatarFallback> 161 + </Avatar> 162 + <div className="flex flex-col leading-tight"> 163 + <span> 164 + {item.author.displayName || item.author.handle} 165 + </span> 166 + <span className="text-white/70 text-[0.75rem]"> 167 + @{item.author.handle} 168 + </span> 169 + </div> 171 170 </div> 172 - )} 171 + 172 + {txt && ( 173 + <div className="text-sm"> 174 + {txt.length > 100 ? txt.slice(0, 100) + "…" : txt} 175 + </div> 176 + )} 177 + </div> 173 178 </div> 174 - </div> 175 - )} 176 - </motion.div> 177 - </Link> 178 - </div> 179 + )} 180 + </motion.div> 181 + </Link> 182 + </div> 183 + </ContentWarning> 179 184 ); 180 185 } 181 186
+74
src/components/ModerationSettings.tsx
··· 1 + "use client"; 2 + 3 + import { 4 + Card, 5 + CardContent, 6 + CardDescription, 7 + CardHeader, 8 + CardTitle, 9 + } from "@/components/ui/card"; 10 + import { Label } from "@/components/ui/label"; 11 + import { Switch } from "@/components/ui/switch"; 12 + import { Shield } from "lucide-react"; 13 + import { useModerationStore } from "@/lib/stores/moderation"; 14 + 15 + export function ModerationSettings() { 16 + const { showContentWarnings, setShowContentWarnings } = useModerationStore(); 17 + 18 + return ( 19 + <div className="max-w-2xl mx-auto p-6 space-y-6"> 20 + <div className="space-y-2"> 21 + <h1 className="text-3xl font-bold flex items-center gap-2"> 22 + <Shield className="h-8 w-8" /> 23 + Content Settings 24 + </h1> 25 + <p className="text-muted-foreground"> 26 + Simple content warning settings. All other moderation is handled by 27 + your Bluesky account settings. 28 + </p> 29 + </div> 30 + 31 + <Card> 32 + <CardHeader> 33 + <CardTitle>Content Warnings</CardTitle> 34 + <CardDescription> 35 + Control whether to show warnings for potentially sensitive content 36 + based on community labels. 37 + </CardDescription> 38 + </CardHeader> 39 + <CardContent> 40 + <div className="flex items-center justify-between"> 41 + <div className="space-y-1"> 42 + <Label>Show Content Warnings</Label> 43 + <p className="text-sm text-muted-foreground"> 44 + Display warnings for content that has been labeled by the 45 + community 46 + </p> 47 + </div> 48 + <Switch 49 + checked={showContentWarnings} 50 + onCheckedChange={setShowContentWarnings} 51 + /> 52 + </div> 53 + </CardContent> 54 + </Card> 55 + 56 + <Card> 57 + <CardHeader> 58 + <CardTitle>Additional Moderation</CardTitle> 59 + <CardDescription> 60 + For blocking users, muting words, or other moderation features, 61 + please use the official Bluesky app. 62 + </CardDescription> 63 + </CardHeader> 64 + <CardContent className="text-sm text-muted-foreground"> 65 + <p> 66 + This app syncs with your Bluesky moderation preferences 67 + automatically. Changes made in the official Bluesky app will be 68 + reflected here. 69 + </p> 70 + </CardContent> 71 + </Card> 72 + </div> 73 + ); 74 + }
src/components/ui/badge.tsx

This is a binary file and will not be displayed.

+26
src/components/ui/label.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import * as LabelPrimitive from "@radix-ui/react-label"; 5 + import { cva, type VariantProps } from "class-variance-authority"; 6 + 7 + import { cn } from "@/lib/utils"; 8 + 9 + const labelVariants = cva( 10 + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 + ); 12 + 13 + const Label = React.forwardRef< 14 + React.ElementRef<typeof LabelPrimitive.Root>, 15 + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & 16 + VariantProps<typeof labelVariants> 17 + >(({ className, ...props }, ref) => ( 18 + <LabelPrimitive.Root 19 + ref={ref} 20 + className={cn(labelVariants(), className)} 21 + {...props} 22 + /> 23 + )); 24 + Label.displayName = LabelPrimitive.Root.displayName; 25 + 26 + export { Label };
+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 }
+2
src/lib/hooks/useAuth.tsx
··· 71 71 const ag = new Agent(result.session); 72 72 setSession(result.session); 73 73 setAgent(ag); 74 + const prefs = await agent?.getPreferences(); 75 + if (!prefs) return; 74 76 } else { 75 77 const did = localStorage.getItem("did"); 76 78
+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 + );
+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>