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
"@radix-ui/react-progress": "^1.1.7",
19
"@radix-ui/react-scroll-area": "^1.2.9",
20
"@radix-ui/react-slot": "^1.2.3",
0
21
"@radix-ui/react-tabs": "^1.1.12",
22
"@radix-ui/react-tooltip": "^1.2.7",
23
"class-variance-authority": "^0.7.1",
···
394
395
"@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
0
0
397
"@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
399
"@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
411
"@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
0
0
413
"@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
415
"@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=="],
···
18
"@radix-ui/react-progress": "^1.1.7",
19
"@radix-ui/react-scroll-area": "^1.2.9",
20
"@radix-ui/react-slot": "^1.2.3",
21
+
"@radix-ui/react-switch": "^1.2.5",
22
"@radix-ui/react-tabs": "^1.1.12",
23
"@radix-ui/react-tooltip": "^1.2.7",
24
"class-variance-authority": "^0.7.1",
···
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=="],
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
+
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=="],
401
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=="],
···
413
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=="],
417
+
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=="],
419
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
"@radix-ui/react-progress": "^1.1.7",
27
"@radix-ui/react-scroll-area": "^1.2.9",
28
"@radix-ui/react-slot": "^1.2.3",
0
29
"@radix-ui/react-tabs": "^1.1.12",
30
"@radix-ui/react-tooltip": "^1.2.7",
31
"class-variance-authority": "^0.7.1",
···
26
"@radix-ui/react-progress": "^1.1.7",
27
"@radix-ui/react-scroll-area": "^1.2.9",
28
"@radix-ui/react-slot": "^1.2.3",
29
+
"@radix-ui/react-switch": "^1.2.5",
30
"@radix-ui/react-tabs": "^1.1.12",
31
"@radix-ui/react-tooltip": "^1.2.7",
32
"class-variance-authority": "^0.7.1",
+48
src/components/ContentWarning.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
+
}
+128
-86
src/components/Feed.tsx
···
1
-
2
-
3
-
4
-
5
-
6
-
0
0
0
0
0
0
0
7
8
9
···
11
import { UnsaveButton } from "./UnsaveButton";
12
import { LikeButton } from "./LikeButton";
13
import { useState, useEffect } from "react";
0
0
0
0
0
0
0
14
15
export type FeedItem = {
0
16
17
18
···
80
81
82
0
0
0
0
0
83
0
84
85
-
86
-
87
-
88
-
89
-
90
-
91
const txt = getText(item);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
92
93
-
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>
105
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>
0
0
0
0
0
0
0
0
0
110
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"
122
>
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]">
135
<Image
136
src={image.fullsize}
137
-
alt={image.alt || ""}
0
138
placeholder={image.thumb ? "blur" : "empty"}
139
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
144
/>
145
-
</div>
146
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" />
151
-
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>
167
168
-
{txt && (
169
-
<div className="text-sm">
170
-
{txt.length > 100 ? txt.slice(0, 100) + "โฆ" : txt}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
171
</div>
172
-
)}
0
0
0
0
0
0
173
</div>
174
-
</div>
175
-
)}
176
-
</motion.div>
177
-
</Link>
178
-
</div>
179
);
180
}
181
···
1
+
"use client";
2
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
3
+
import {
4
+
Agent,
5
+
AppBskyEmbedImages,
6
+
AppBskyFeedPost,
7
+
AtUri,
8
+
moderatePost,
9
+
ModerationPrefs,
10
+
} from "@atproto/api";
11
+
import { LoaderCircle } from "lucide-react";
12
+
import { motion } from "motion/react";
13
+
import Image from "next/image";
14
15
16
···
18
import { UnsaveButton } from "./UnsaveButton";
19
import { LikeButton } from "./LikeButton";
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";
28
29
export type FeedItem = {
30
+
id: string;
31
32
33
···
95
96
97
98
+
}) {
99
+
const image = getImageFromItem(item, index);
100
+
const [isDropdownOpen, setDropdownOpen] = useState(false);
101
+
const modOpts = useModerationOpts();
102
+
const { session, agent } = useAuth();
103
104
+
if (!image) return;
105
106
+
const ActionButton = showUnsaveButton ? UnsaveButton : SaveButton;
0
0
0
0
0
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
0
0
0
0
0
0
0
0
0
0
0
125
126
+
return (
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>
139
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>
144
+
145
+
{/* Link wraps image only */}
146
+
<Link
147
+
href={`/${item.author.did}/${AtUri.make(item.uri).rkey}`}
148
+
className="block"
0
0
149
>
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 */}
0
0
0
0
158
<Image
159
src={image.fullsize}
160
+
alt=""
161
+
fill
162
placeholder={image.thumb ? "blur" : "empty"}
163
blurDataURL={image.thumb}
164
+
className="object-cover filter blur-xl scale-110 opacity-30"
0
0
0
165
/>
0
166
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>
0
0
0
0
0
0
0
180
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" />
185
+
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>
207
</div>
208
+
209
+
{txt && (
210
+
<div className="text-sm">
211
+
{txt.length > 100 ? txt.slice(0, 100) + "โฆ" : txt}
212
+
</div>
213
+
)}
214
+
</div>
215
</div>
216
+
)}
217
+
</motion.div>
218
+
</Link>
219
+
</div>
220
+
</ContentWarning>
221
);
222
}
223
src/components/ui/badge.tsx
+31
src/components/ui/switch.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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 }
+57
-5
src/lib/hooks/useAuth.tsx
···
16
17
18
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
19
20
21
···
51
52
53
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
54
55
56
···
68
69
70
71
-
const ag = new Agent(result.session);
72
-
setSession(result.session);
73
-
setAgent(ag);
74
-
} else {
75
-
const did = localStorage.getItem("did");
76
0
0
0
0
0
0
0
···
16
17
18
19
+
type AuthContextType = {
20
+
session: OAuthSession | null;
21
+
agent: Agent;
22
+
loading: boolean;
23
+
login: (handle: string) => Promise<void>;
24
+
logout: () => void;
25
+
26
+
27
+
const AuthContext = createContext<AuthContextType | null>(null);
28
+
29
+
export function AuthProvider({ children }: { children: ReactNode }) {
30
+
const defaultAgent = new Agent({ service: "https://bsky.social" });
31
+
const [session, setSession] = useState<OAuthSession | null>(null);
32
+
const [agent, setAgent] = useState<Agent>(defaultAgent);
33
+
const [loading, setLoading] = useState(true);
34
+
const [client, setClient] = useState<BrowserOAuthClient | null>(null);
35
36
37
···
67
68
69
70
+
const ag = new Agent(result.session);
71
+
setSession(result.session);
72
+
setAgent(ag);
73
+
const prefs = await ag.getPreferences();
74
+
if (!prefs) return;
75
+
} else {
76
+
const did = localStorage.getItem("did");
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
c.addEventListener("deleted", (event: CustomEvent) => {
93
+
console.warn("Session invalidated", event.detail);
94
+
setSession(null);
95
+
setAgent(defaultAgent);
96
+
});
97
+
};
98
+
99
+
100
+
101
+
102
+
103
+
104
105
106
···
118
119
120
0
0
0
0
0
121
122
+
if (client && session) {
123
+
client.revoke(session.sub);
124
+
setSession(null);
125
+
setAgent(defaultAgent);
126
+
// refresh page
127
+
window.location.reload();
128
+
}
+51
src/lib/stores/moderation.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
My Boards
110
</DropdownMenuItem>
111
</Link>
0
0
0
0
0
0
112
<DropdownMenuItem className="cursor-pointer" onClick={logout}>
113
Logout
114
</DropdownMenuItem>
···
109
My Boards
110
</DropdownMenuItem>
111
</Link>
112
+
<Link href={"/moderation"}>
113
+
<DropdownMenuItem className="cursor-pointer">
114
+
Content Settings
115
+
</DropdownMenuItem>
116
+
</Link>
117
+
<DropdownMenuSeparator />
118
<DropdownMenuItem className="cursor-pointer" onClick={logout}>
119
Logout
120
</DropdownMenuItem>
+3
-3
src/app/layout.tsx
···
6
import { AuthProvider } from "@/lib/hooks/useAuth";
7
import { ProfileProvider } from "@/lib/useProfile";
8
import { Toaster } from "sonner";
9
-
import { BoardsProvider } from "@/lib/hooks/useBoards";
10
11
const geistSans = Geist({
12
variable: "--font-geist-sans",
···
37
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
38
<AuthProvider>
39
<ProfileProvider>
40
-
<BoardsProvider>
41
<div className="min-h-screen flex flex-col">
42
<Navbar />
43
<main className="flex-1 py-6">{children}</main>
44
</div>
45
-
</BoardsProvider>
46
</ProfileProvider>
47
</AuthProvider>
48
</ThemeProvider>
···
6
import { AuthProvider } from "@/lib/hooks/useAuth";
7
import { ProfileProvider } from "@/lib/useProfile";
8
import { Toaster } from "sonner";
9
+
import { StoresProvider } from "@/lib/stores/storesProvider";
10
11
const geistSans = Geist({
12
variable: "--font-geist-sans",
···
37
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
38
<AuthProvider>
39
<ProfileProvider>
40
+
<StoresProvider>
41
<div className="min-h-screen flex flex-col">
42
<Navbar />
43
<main className="flex-1 py-6">{children}</main>
44
</div>
45
+
</StoresProvider>
46
</ProfileProvider>
47
</AuthProvider>
48
</ThemeProvider>
-6
src/lib/hooks/useBoards.tsx
···
54
55
return { isLoading };
56
}
57
-
58
-
export function BoardsProvider({ children }: PropsWithChildren) {
59
-
useBoards();
60
-
useBoardItems();
61
-
return children;
62
-
}
···
54
55
return { isLoading };
56
}
0
0
0
0
0
0
+71
src/lib/hooks/useModerationOpts.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
0
0
0
0
0
0
0
0
0
0
···
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
+
}