tangled
alpha
login
or
join now
bunware.org
/
pin.to.it
Scrapboard.org client
6
fork
atom
overview
issues
pulls
pipelines
Compare changes
Choose any two refs to compare.
base:
main
labels
no tags found
compare:
main
labels
no tags found
go
+526
-100
14 changed files
expand all
collapse all
unified
split
bun.lock
package.json
src
app
layout.tsx
components
ContentWarning.tsx
Feed.tsx
ui
badge.tsx
switch.tsx
lib
hooks
useAuth.tsx
useBoards.tsx
useModerationOpts.tsx
stores
moderation.tsx
moderationOpts.ts
storesProvider.tsx
nav
navbar.tsx
+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
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
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
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
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
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
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",
+48
src/components/ContentWarning.tsx
···
1
1
+
"use client";
2
2
+
3
3
+
import { useState, useEffect } from "react";
4
4
+
import { AlertTriangle, Eye } from "lucide-react";
5
5
+
import { Button } from "@/components/ui/button";
6
6
+
import { Card } from "@/components/ui/card";
7
7
+
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
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
11
+
import { ModerationDecision } from "@atproto/api";
12
12
+
13
13
+
interface ContentWarningProps {
14
14
+
mod: ModerationDecision;
15
15
+
children: React.ReactNode;
16
16
+
className?: string;
17
17
+
}
18
18
+
19
19
+
export function ContentWarning({
20
20
+
mod,
21
21
+
children,
22
22
+
className,
23
23
+
}: ContentWarningProps) {
24
24
+
const modUi = mod.ui("contentMedia");
25
25
+
26
26
+
if (modUi.filter) return;
27
27
+
28
28
+
if (modUi.blur) {
29
29
+
return (
30
30
+
<div className={className}>
31
31
+
<div className="relative overflow-hidden rounded-2xl">
32
32
+
<div className="blur-3xl">{children}</div>
33
33
+
<div className="absolute inset-0 flex items-center justify-center"></div>
34
34
+
<div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm">
35
35
+
<div className="space-y-3 text-center">
36
36
+
<div className="flex items-center justify-center gap-3">
37
37
+
<AlertTriangle className="h-5 w-5 text-orange-500 flex-shrink-0" />
38
38
+
<h4 className="font-medium text-orange-100">Content Warning</h4>
39
39
+
</div>
40
40
+
</div>
41
41
+
</div>
42
42
+
</div>
43
43
+
</div>
44
44
+
);
45
45
+
}
46
46
+
47
47
+
return <div className={className}>{children}</div>;
48
48
+
}
+128
-86
src/components/Feed.tsx
···
1
1
-
2
2
-
3
3
-
4
4
-
5
5
-
6
6
-
1
1
+
"use client";
2
2
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
3
3
+
import {
4
4
+
Agent,
5
5
+
AppBskyEmbedImages,
6
6
+
AppBskyFeedPost,
7
7
+
AtUri,
8
8
+
moderatePost,
9
9
+
ModerationPrefs,
10
10
+
} from "@atproto/api";
11
11
+
import { LoaderCircle } from "lucide-react";
12
12
+
import { motion } from "motion/react";
13
13
+
import Image from "next/image";
7
14
8
15
9
16
···
11
18
import { UnsaveButton } from "./UnsaveButton";
12
19
import { LikeButton } from "./LikeButton";
13
20
import { useState, useEffect } from "react";
21
21
+
import {
22
22
+
DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
23
23
+
useModerationOpts,
24
24
+
} from "@/lib/hooks/useModerationOpts";
25
25
+
import { useAuth } from "@/lib/hooks/useAuth";
26
26
+
import { ContentWarning } from "./ContentWarning";
27
27
+
import clsx from "clsx";
14
28
15
29
export type FeedItem = {
30
30
+
id: string;
16
31
17
32
18
33
···
80
95
81
96
82
97
98
98
+
}) {
99
99
+
const image = getImageFromItem(item, index);
100
100
+
const [isDropdownOpen, setDropdownOpen] = useState(false);
101
101
+
const modOpts = useModerationOpts();
102
102
+
const { session, agent } = useAuth();
83
103
104
104
+
if (!image) return;
84
105
85
85
-
86
86
-
87
87
-
88
88
-
89
89
-
90
90
-
106
106
+
const ActionButton = showUnsaveButton ? UnsaveButton : SaveButton;
91
107
const txt = getText(item);
108
108
+
const opts: ModerationPrefs = modOpts.moderationPrefs ?? {
109
109
+
adultContentEnabled: false,
110
110
+
labelers: agent.appLabelers.map((did) => ({
111
111
+
did,
112
112
+
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
113
113
+
})),
114
114
+
hiddenPosts: [],
115
115
+
mutedWords: [],
116
116
+
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
117
117
+
};
118
118
+
const mod = moderatePost(item, {
119
119
+
prefs: opts,
120
120
+
labelDefs: modOpts.labelDefs,
121
121
+
userDid: session?.did,
122
122
+
});
92
123
93
93
-
return (
94
94
-
<div className={`relative group ${isDropdownOpen ? "hover-active" : ""}`}>
95
95
-
{/* Save/Unsave button โ top-left */}
96
96
-
<div className="absolute top-3 left-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity">
97
97
-
{ActionButton && (
98
98
-
<ActionButton
99
99
-
image={index}
100
100
-
post={item}
101
101
-
onDropdownOpenChange={setDropdownOpen}
102
102
-
/>
103
103
-
)}
104
104
-
</div>
124
124
+
// Debug code removed for production
105
125
106
106
-
{/* Like button โ top-right */}
107
107
-
<div className="absolute top-3 right-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity">
108
108
-
<LikeButton post={item} />
109
109
-
</div>
126
126
+
return (
127
127
+
<ContentWarning mod={mod}>
128
128
+
<div className={`relative group ${isDropdownOpen ? "hover-active" : ""}`}>
129
129
+
{/* Save/Unsave button โ top-left */}
130
130
+
<div className="absolute top-3 left-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity">
131
131
+
{ActionButton && (
132
132
+
<ActionButton
133
133
+
image={index}
134
134
+
post={item}
135
135
+
onDropdownOpenChange={setDropdownOpen}
136
136
+
/>
137
137
+
)}
138
138
+
</div>
110
139
111
111
-
{/* Link wraps image only */}
112
112
-
<Link
113
113
-
href={`/${item.author.did}/${AtUri.make(item.uri).rkey}`}
114
114
-
className="block"
115
115
-
>
116
116
-
<motion.div
117
117
-
initial={{ opacity: 0, y: 5 }}
118
118
-
animate={{ opacity: 1, y: 0 }}
119
119
-
transition={{ duration: 0.5, ease: "easeOut" }}
120
120
-
whileTap={{ scale: 0.95 }}
121
121
-
className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900"
140
140
+
{/* Like button โ top-right */}
141
141
+
<div className="absolute top-3 right-3 z-30 opacity-0 group-hover:opacity-100 group-[.hover-active]:opacity-100 transition-opacity">
142
142
+
<LikeButton post={item} />
143
143
+
</div>
144
144
+
145
145
+
{/* Link wraps image only */}
146
146
+
<Link
147
147
+
href={`/${item.author.did}/${AtUri.make(item.uri).rkey}`}
148
148
+
className="block"
122
149
>
123
123
-
{/* Blurred background */}
124
124
-
<Image
125
125
-
src={image.fullsize}
126
126
-
alt=""
127
127
-
fill
128
128
-
placeholder={image.thumb ? "blur" : "empty"}
129
129
-
blurDataURL={image.thumb}
130
130
-
className="object-cover filter blur-xl scale-110 opacity-30"
131
131
-
/>
132
132
-
133
133
-
{/* Foreground image */}
134
134
-
<div className="relative z-10 flex items-center justify-center w-full min-h-[120px]">
150
150
+
<motion.div
151
151
+
initial={{ opacity: 0, y: 5 }}
152
152
+
animate={{ opacity: 1, y: 0 }}
153
153
+
transition={{ duration: 0.5, ease: "easeOut" }}
154
154
+
whileTap={{ scale: 0.95 }}
155
155
+
className="group relative w-full min-h-[120px] min-w-[120px] overflow-hidden rounded-xl bg-gray-900"
156
156
+
>
157
157
+
{/* Blurred background */}
135
158
<Image
136
159
src={image.fullsize}
137
137
-
alt={image.alt || ""}
160
160
+
alt=""
161
161
+
fill
138
162
placeholder={image.thumb ? "blur" : "empty"}
139
163
blurDataURL={image.thumb}
140
140
-
width={image.aspectRatio?.width ?? 400}
141
141
-
height={image.aspectRatio?.height ?? 400}
142
142
-
className="object-contain max-w-full max-h-full rounded-lg"
143
143
-
priority
164
164
+
className="object-cover filter blur-xl scale-110 opacity-30"
144
165
/>
145
145
-
</div>
146
166
147
147
-
{/* Author info */}
148
148
-
{item.author && (
149
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
150
-
<div className="w-fit self-start" />
151
151
-
152
152
-
<div className="flex flex-col gap-2">
153
153
-
<div className="flex items-center gap-2">
154
154
-
<Avatar>
155
155
-
<AvatarImage src={item.author.avatar} />
156
156
-
<AvatarFallback>
157
157
-
{item.author.displayName || item.author.handle}
158
158
-
</AvatarFallback>
159
159
-
</Avatar>
160
160
-
<div className="flex flex-col leading-tight">
161
161
-
<span>{item.author.displayName || item.author.handle}</span>
162
162
-
<span className="text-white/70 text-[0.75rem]">
163
163
-
@{item.author.handle}
164
164
-
</span>
165
165
-
</div>
166
166
-
</div>
167
167
+
{/* Foreground image */}
168
168
+
<div className="relative z-10 flex items-center justify-center w-full min-h-[120px]">
169
169
+
<Image
170
170
+
src={image.fullsize}
171
171
+
alt={image.alt || ""}
172
172
+
placeholder={image.thumb ? "blur" : "empty"}
173
173
+
blurDataURL={image.thumb}
174
174
+
width={image.aspectRatio?.width ?? 400}
175
175
+
height={image.aspectRatio?.height ?? 400}
176
176
+
className="object-contain max-w-full max-h-full rounded-lg"
177
177
+
priority
178
178
+
/>
179
179
+
</div>
167
180
168
168
-
{txt && (
169
169
-
<div className="text-sm">
170
170
-
{txt.length > 100 ? txt.slice(0, 100) + "โฆ" : txt}
181
181
+
{/* Author info */}
182
182
+
{item.author && (
183
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
184
+
<div className="w-fit self-start" />
185
185
+
186
186
+
<div className="flex flex-col gap-2">
187
187
+
<div className="flex items-center gap-2">
188
188
+
<Avatar>
189
189
+
<AvatarImage
190
190
+
className={clsx(
191
191
+
mod.ui("avatar").blur ? "blur-3xl" : ""
192
192
+
)}
193
193
+
src={item.author.avatar}
194
194
+
/>
195
195
+
<AvatarFallback>
196
196
+
{item.author.displayName || item.author.handle}
197
197
+
</AvatarFallback>
198
198
+
</Avatar>
199
199
+
<div className="flex flex-col leading-tight">
200
200
+
<span>
201
201
+
{item.author.displayName || item.author.handle}
202
202
+
</span>
203
203
+
<span className="text-white/70 text-[0.75rem]">
204
204
+
@{item.author.handle}
205
205
+
</span>
206
206
+
</div>
171
207
</div>
172
172
-
)}
208
208
+
209
209
+
{txt && (
210
210
+
<div className="text-sm">
211
211
+
{txt.length > 100 ? txt.slice(0, 100) + "โฆ" : txt}
212
212
+
</div>
213
213
+
)}
214
214
+
</div>
173
215
</div>
174
174
-
</div>
175
175
-
)}
176
176
-
</motion.div>
177
177
-
</Link>
178
178
-
</div>
216
216
+
)}
217
217
+
</motion.div>
218
218
+
</Link>
219
219
+
</div>
220
220
+
</ContentWarning>
179
221
);
180
222
}
181
223
src/components/ui/badge.tsx
+31
src/components/ui/switch.tsx
···
1
1
+
"use client"
2
2
+
3
3
+
import * as React from "react"
4
4
+
import * as SwitchPrimitive from "@radix-ui/react-switch"
5
5
+
6
6
+
import { cn } from "@/lib/utils"
7
7
+
8
8
+
function Switch({
9
9
+
className,
10
10
+
...props
11
11
+
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
12
12
+
return (
13
13
+
<SwitchPrimitive.Root
14
14
+
data-slot="switch"
15
15
+
className={cn(
16
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
17
+
className
18
18
+
)}
19
19
+
{...props}
20
20
+
>
21
21
+
<SwitchPrimitive.Thumb
22
22
+
data-slot="switch-thumb"
23
23
+
className={cn(
24
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
25
+
)}
26
26
+
/>
27
27
+
</SwitchPrimitive.Root>
28
28
+
)
29
29
+
}
30
30
+
31
31
+
export { Switch }
+57
-5
src/lib/hooks/useAuth.tsx
···
16
16
17
17
18
18
19
19
+
type AuthContextType = {
20
20
+
session: OAuthSession | null;
21
21
+
agent: Agent;
22
22
+
loading: boolean;
23
23
+
login: (handle: string) => Promise<void>;
24
24
+
logout: () => void;
25
25
+
26
26
+
27
27
+
const AuthContext = createContext<AuthContextType | null>(null);
28
28
+
29
29
+
export function AuthProvider({ children }: { children: ReactNode }) {
30
30
+
const defaultAgent = new Agent({ service: "https://bsky.social" });
31
31
+
const [session, setSession] = useState<OAuthSession | null>(null);
32
32
+
const [agent, setAgent] = useState<Agent>(defaultAgent);
33
33
+
const [loading, setLoading] = useState(true);
34
34
+
const [client, setClient] = useState<BrowserOAuthClient | null>(null);
19
35
20
36
21
37
···
51
67
52
68
53
69
70
70
+
const ag = new Agent(result.session);
71
71
+
setSession(result.session);
72
72
+
setAgent(ag);
73
73
+
const prefs = await ag.getPreferences();
74
74
+
if (!prefs) return;
75
75
+
} else {
76
76
+
const did = localStorage.getItem("did");
77
77
+
78
78
+
79
79
+
80
80
+
81
81
+
82
82
+
83
83
+
84
84
+
85
85
+
86
86
+
87
87
+
88
88
+
89
89
+
90
90
+
91
91
+
92
92
+
c.addEventListener("deleted", (event: CustomEvent) => {
93
93
+
console.warn("Session invalidated", event.detail);
94
94
+
setSession(null);
95
95
+
setAgent(defaultAgent);
96
96
+
});
97
97
+
};
98
98
+
99
99
+
100
100
+
101
101
+
102
102
+
103
103
+
54
104
55
105
56
106
···
68
118
69
119
70
120
71
71
-
const ag = new Agent(result.session);
72
72
-
setSession(result.session);
73
73
-
setAgent(ag);
74
74
-
} else {
75
75
-
const did = localStorage.getItem("did");
76
121
122
122
+
if (client && session) {
123
123
+
client.revoke(session.sub);
124
124
+
setSession(null);
125
125
+
setAgent(defaultAgent);
126
126
+
// refresh page
127
127
+
window.location.reload();
128
128
+
}
+51
src/lib/stores/moderation.tsx
···
1
1
+
import { create } from "zustand";
2
2
+
import { persist } from "zustand/middleware";
3
3
+
import { moderatePost, ModerationDecision } from "@atproto/api/dist/moderation";
4
4
+
import { type ModerationOpts } from "@atproto/api/dist/moderation/types";
5
5
+
import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs";
6
6
+
7
7
+
export interface ModerationState {
8
8
+
// Simple content warning preferences
9
9
+
showContentWarnings: boolean;
10
10
+
11
11
+
// Actions
12
12
+
setShowContentWarnings: (show: boolean) => void;
13
13
+
14
14
+
// Helper to get moderation decision using AT Protocol's built-in moderation
15
15
+
getModerationDecision: (
16
16
+
post: PostView,
17
17
+
opts: ModerationOpts
18
18
+
) => ModerationDecision;
19
19
+
shouldShowWarning: (post: PostView, opts: ModerationOpts) => boolean;
20
20
+
}
21
21
+
22
22
+
export const useModerationStore = create<ModerationState>()(
23
23
+
persist(
24
24
+
(set, get) => ({
25
25
+
showContentWarnings: true,
26
26
+
27
27
+
setShowContentWarnings: (show) => set({ showContentWarnings: show }),
28
28
+
29
29
+
getModerationDecision: (post: PostView, opts: ModerationOpts) => {
30
30
+
return moderatePost(post, opts);
31
31
+
},
32
32
+
33
33
+
shouldShowWarning: (post: PostView, opts: ModerationOpts) => {
34
34
+
const state = get();
35
35
+
if (!state.showContentWarnings) return false;
36
36
+
37
37
+
const decision = state.getModerationDecision(post, opts);
38
38
+
const ui = decision.ui("contentView");
39
39
+
40
40
+
// Show warning if content has alerts or informs
41
41
+
return ui.alert || ui.inform;
42
42
+
},
43
43
+
}),
44
44
+
{
45
45
+
name: "moderation-store",
46
46
+
partialize: (state) => ({
47
47
+
showContentWarnings: state.showContentWarnings,
48
48
+
}),
49
49
+
}
50
50
+
)
51
51
+
);
+6
src/nav/navbar.tsx
···
109
109
My Boards
110
110
</DropdownMenuItem>
111
111
</Link>
112
112
+
<Link href={"/moderation"}>
113
113
+
<DropdownMenuItem className="cursor-pointer">
114
114
+
Content Settings
115
115
+
</DropdownMenuItem>
116
116
+
</Link>
117
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
9
-
import { BoardsProvider } from "@/lib/hooks/useBoards";
9
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
40
-
<BoardsProvider>
40
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
45
-
</BoardsProvider>
45
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
57
-
58
58
-
export function BoardsProvider({ children }: PropsWithChildren) {
59
59
-
useBoards();
60
60
-
useBoardItems();
61
61
-
return children;
62
62
-
}
+71
src/lib/hooks/useModerationOpts.tsx
···
1
1
+
"use client";
2
2
+
import { useEffect } from "react";
3
3
+
import { useAuth } from "./useAuth";
4
4
+
import { useModerationOptsStore } from "../stores/moderationOpts";
5
5
+
import { DEFAULT_LABEL_SETTINGS } from "@atproto/api";
6
6
+
7
7
+
/**
8
8
+
* From {@link https://github.com/bluesky-social/social-app/blob/2a6172cbaf2db0eda2a7cd2afaeef4b60aadf3ba/src/state/queries/preferences/moderation.ts#L15}
9
9
+
*/
10
10
+
export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS =
11
11
+
Object.fromEntries(
12
12
+
Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, "hide"])
13
13
+
);
14
14
+
15
15
+
export function useModerationOpts() {
16
16
+
const { agent } = useAuth();
17
17
+
const {
18
18
+
moderationPrefs,
19
19
+
labelDefs,
20
20
+
isLoading,
21
21
+
error,
22
22
+
setModerationOpts,
23
23
+
setLoading,
24
24
+
setError,
25
25
+
isStale,
26
26
+
shouldRefetch,
27
27
+
} = useModerationOptsStore();
28
28
+
29
29
+
useEffect(() => {
30
30
+
if (!agent || agent?.did == null) return;
31
31
+
32
32
+
const fetchModerationOpts = async () => {
33
33
+
try {
34
34
+
setLoading(true);
35
35
+
const prefs = await agent.getPreferences();
36
36
+
const labelDefs = await agent.getLabelDefinitions(prefs);
37
37
+
setModerationOpts(prefs.moderationPrefs, labelDefs);
38
38
+
} catch (err) {
39
39
+
console.error("Error fetching moderation opts:", err);
40
40
+
setError(err instanceof Error ? err.message : String(err));
41
41
+
} finally {
42
42
+
setLoading(false);
43
43
+
}
44
44
+
};
45
45
+
46
46
+
// If we have stale data, return it immediately but fetch fresh data in background
47
47
+
if (moderationPrefs && labelDefs && isStale()) {
48
48
+
fetchModerationOpts(); // Background refresh
49
49
+
}
50
50
+
// If we have no data or data is expired, fetch immediately
51
51
+
else if (!moderationPrefs || !labelDefs || shouldRefetch()) {
52
52
+
fetchModerationOpts();
53
53
+
}
54
54
+
}, [
55
55
+
agent,
56
56
+
moderationPrefs,
57
57
+
labelDefs,
58
58
+
isStale,
59
59
+
shouldRefetch,
60
60
+
setModerationOpts,
61
61
+
setLoading,
62
62
+
setError,
63
63
+
]);
64
64
+
65
65
+
return {
66
66
+
moderationPrefs,
67
67
+
labelDefs,
68
68
+
isLoading,
69
69
+
error,
70
70
+
};
71
71
+
}
+113
src/lib/stores/moderationOpts.ts
···
1
1
+
import { create } from "zustand";
2
2
+
import { persist } from "zustand/middleware";
3
3
+
import { InterpretedLabelValueDefinition, ModerationPrefs } from "@atproto/api";
4
4
+
5
5
+
interface ModerationOptsData {
6
6
+
moderationPrefs: ModerationPrefs | undefined;
7
7
+
labelDefs: Record<string, InterpretedLabelValueDefinition[]> | undefined;
8
8
+
lastFetched: number | null;
9
9
+
isLoading: boolean;
10
10
+
error: string | null;
11
11
+
}
12
12
+
13
13
+
interface ModerationOptsState extends ModerationOptsData {
14
14
+
setModerationOpts: (
15
15
+
moderationPrefs: ModerationPrefs,
16
16
+
labelDefs: Record<string, InterpretedLabelValueDefinition[]>
17
17
+
) => void;
18
18
+
setLoading: (isLoading: boolean) => void;
19
19
+
setError: (error: string | null) => void;
20
20
+
isStale: () => boolean;
21
21
+
shouldRefetch: () => boolean;
22
22
+
clear: () => void;
23
23
+
}
24
24
+
25
25
+
const STALE_TIME = 15 * 60 * 1000; // 15 minutes in milliseconds
26
26
+
const CACHE_TIME = 30 * 60 * 1000; // 30 minutes in milliseconds
27
27
+
28
28
+
export const useModerationOptsStore = create<ModerationOptsState>()(
29
29
+
persist(
30
30
+
(set, get) => ({
31
31
+
moderationPrefs: undefined,
32
32
+
labelDefs: undefined,
33
33
+
lastFetched: null,
34
34
+
isLoading: false,
35
35
+
error: null,
36
36
+
37
37
+
setModerationOpts: (moderationPrefs, labelDefs) => {
38
38
+
set({
39
39
+
moderationPrefs,
40
40
+
labelDefs,
41
41
+
lastFetched: Date.now(),
42
42
+
error: null,
43
43
+
});
44
44
+
},
45
45
+
46
46
+
setLoading: (isLoading) => set({ isLoading }),
47
47
+
48
48
+
setError: (error) => set({ error, isLoading: false }),
49
49
+
50
50
+
isStale: () => {
51
51
+
const { lastFetched } = get();
52
52
+
if (!lastFetched) return true;
53
53
+
return Date.now() - lastFetched > STALE_TIME;
54
54
+
},
55
55
+
56
56
+
shouldRefetch: () => {
57
57
+
const { lastFetched, isLoading } = get();
58
58
+
if (isLoading) return false;
59
59
+
if (!lastFetched) return true;
60
60
+
return Date.now() - lastFetched > CACHE_TIME;
61
61
+
},
62
62
+
63
63
+
clear: () =>
64
64
+
set({
65
65
+
moderationPrefs: undefined,
66
66
+
labelDefs: undefined,
67
67
+
lastFetched: null,
68
68
+
error: null,
69
69
+
}),
70
70
+
}),
71
71
+
{
72
72
+
name: "moderation-opts-storage",
73
73
+
partialize: (state) => ({
74
74
+
moderationPrefs: state.moderationPrefs,
75
75
+
labelDefs: state.labelDefs,
76
76
+
lastFetched: state.lastFetched,
77
77
+
}),
78
78
+
// Add storage configuration to handle complex objects
79
79
+
storage: {
80
80
+
getItem: (name) => {
81
81
+
const str = localStorage.getItem(name);
82
82
+
if (!str) return null;
83
83
+
try {
84
84
+
const parsed = JSON.parse(str);
85
85
+
return parsed;
86
86
+
} catch (error) {
87
87
+
console.error(
88
88
+
"Failed to parse moderation opts from localStorage:",
89
89
+
error
90
90
+
);
91
91
+
return null;
92
92
+
}
93
93
+
},
94
94
+
setItem: (name, value) => {
95
95
+
try {
96
96
+
localStorage.setItem(name, JSON.stringify(value));
97
97
+
} catch (error) {
98
98
+
console.error(
99
99
+
"Failed to serialize moderation opts to localStorage:",
100
100
+
error
101
101
+
);
102
102
+
}
103
103
+
},
104
104
+
removeItem: (name) => localStorage.removeItem(name),
105
105
+
},
106
106
+
}
107
107
+
)
108
108
+
);
109
109
+
110
110
+
// Utility function to clear moderation options cache (useful for logout)
111
111
+
export const clearModerationOptsCache = () => {
112
112
+
useModerationOptsStore.getState().clear();
113
113
+
};
+12
src/lib/stores/storesProvider.tsx
···
1
1
+
"use client";
2
2
+
import { PropsWithChildren } from "react";
3
3
+
import { useBoards } from "../hooks/useBoards";
4
4
+
import { useBoardItems } from "../hooks/useBoardItems";
5
5
+
import { useModerationOpts } from "../hooks/useModerationOpts";
6
6
+
7
7
+
export function StoresProvider({ children }: PropsWithChildren) {
8
8
+
useBoards();
9
9
+
useBoardItems();
10
10
+
useModerationOpts();
11
11
+
return children;
12
12
+
}