this repo has no description
5
fork

Configure Feed

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

refactor: replace BoardsProvider with StoresProvider and update moderation handling

Turtlepaw 59607929 1282c265

+267 -222
+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>
+21 -102
src/components/ContentWarning.tsx
··· 8 8 import { type ModerationOpts } from "@atproto/api/dist/moderation/types"; 9 9 import { useModerationStore } from "@/lib/stores/moderation"; 10 10 import { useAuth } from "@/lib/hooks/useAuth"; 11 + import { ModerationDecision } from "@atproto/api"; 11 12 12 13 interface ContentWarningProps { 13 - post: PostView; 14 + mod: ModerationDecision; 14 15 children: React.ReactNode; 15 16 className?: string; 16 17 } 17 18 18 19 export function ContentWarning({ 19 - post, 20 + mod, 20 21 children, 21 22 className, 22 23 }: 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 - } 24 + const modUi = mod.ui("contentMedia"); 64 25 65 - loadModerationPrefs(); 66 - }, [agent, session?.did]); 26 + if (modUi.filter) return; 67 27 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 - )} 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> 113 40 </div> 114 41 </div> 115 42 </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 43 </div> 127 - </Card> 128 - ); 44 + ); 45 + } 46 + 47 + return <div className={className}>{children}</div>; 129 48 }
+41 -4
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"; ··· 10 17 import { SaveButton } from "./SaveButton"; 11 18 import { UnsaveButton } from "./UnsaveButton"; 12 19 import { LikeButton } from "./LikeButton"; 13 - import { ContentWarning } from "./ContentWarning"; 14 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"; 15 28 16 29 export type FeedItem = { 17 30 id: string; ··· 85 98 }) { 86 99 const image = getImageFromItem(item, index); 87 100 const [isDropdownOpen, setDropdownOpen] = useState(false); 101 + const modOpts = useModerationOpts(); 102 + const { session, agent } = useAuth(); 88 103 89 104 if (!image) return; 90 105 91 106 const ActionButton = showUnsaveButton ? UnsaveButton : SaveButton; 92 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 93 125 94 126 return ( 95 - <ContentWarning post={item}> 127 + <ContentWarning mod={mod}> 96 128 <div className={`relative group ${isDropdownOpen ? "hover-active" : ""}`}> 97 129 {/* Save/Unsave button – top-left */} 98 130 <div className="absolute top-3 left-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity"> ··· 154 186 <div className="flex flex-col gap-2"> 155 187 <div className="flex items-center gap-2"> 156 188 <Avatar> 157 - <AvatarImage src={item.author.avatar} /> 189 + <AvatarImage 190 + className={clsx( 191 + mod.ui("avatar").blur ? "blur-3xl" : "" 192 + )} 193 + src={item.author.avatar} 194 + /> 158 195 <AvatarFallback> 159 196 {item.author.displayName || item.author.handle} 160 197 </AvatarFallback>
-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 - }
-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 };
+6 -7
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); 74 - const prefs = await agent?.getPreferences(); 73 + const prefs = await ag.getPreferences(); 75 74 if (!prefs) return; 76 75 } else { 77 76 const did = localStorage.getItem("did"); ··· 93 92 c.addEventListener("deleted", (event: CustomEvent) => { 94 93 console.warn("Session invalidated", event.detail); 95 94 setSession(null); 96 - setAgent(null); 95 + setAgent(defaultAgent); 97 96 }); 98 97 }; 99 98 ··· 123 122 if (client && session) { 124 123 client.revoke(session.sub); 125 124 setSession(null); 126 - setAgent(null); 125 + setAgent(defaultAgent); 127 126 // refresh page 128 127 window.location.reload(); 129 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 + }
+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 + }