Compare changes

Choose any two refs to compare.

+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=="], ··· 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=="], 412 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=="], 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 415 420 "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@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-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
+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",
src/components/ui/badge.tsx
+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 }
+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>
+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>
-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 + }