+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=="],
···
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
+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
+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
+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
+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
src/components/ui/badge.tsx
This is a binary file and will not be displayed.
+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 }
+7
-6
src/lib/hooks/useAuth.tsx
+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
-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
+
}
+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
+
);
+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
+
}