+5
bun.lock
+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
+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
src/components/ui/badge.tsx
+31
src/components/ui/switch.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
+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
+
);
+3
-3
src/app/layout.tsx
+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
-6
src/lib/hooks/useBoards.tsx
+71
src/lib/hooks/useModerationOpts.tsx
+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
+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
+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
+
}