an appview-less Bluesky client using Constellation and PDS Queries
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
1import * as ATPAPI from "@atproto/api";
2import {
3 isAdultContentPref,
4 isBskyAppStatePref,
5 isContentLabelPref,
6 isFeedViewPref,
7 isLabelersPref,
8 isMutedWordsPref,
9 isSavedFeedsPref,
10} from "@atproto/api/dist/client/types/app/bsky/actor/defs";
11import { createFileRoute } from "@tanstack/react-router";
12import { useAtom } from "jotai";
13import { Switch } from "radix-ui";
14
15import { Header } from "~/components/Header";
16import { useAuth } from "~/providers/UnifiedAuthProvider";
17import { quickAuthAtom } from "~/utils/atoms";
18import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery";
19
20import { renderSnack } from "./__root";
21import { NotificationItem } from "./notifications";
22import { SettingHeading } from "./settings";
23
24export const Route = createFileRoute("/moderation")({
25 component: RouteComponent,
26});
27
28function RouteComponent() {
29 const { agent } = useAuth();
30
31 const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom);
32 const isAuthRestoring = quickAuth ? status === "loading" : false;
33
34 const identityresultmaybe = useQueryIdentity(
35 !isAuthRestoring ? agent?.did : undefined
36 );
37 const identity = identityresultmaybe?.data;
38
39 const prefsresultmaybe = useQueryPreferences({
40 agent: !isAuthRestoring ? (agent ?? undefined) : undefined,
41 pdsUrl: !isAuthRestoring ? identity?.pds : undefined,
42 });
43 const rawprefs = prefsresultmaybe?.data?.preferences as
44 | ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"]
45 | undefined;
46
47 //console.log(JSON.stringify(prefs, null, 2))
48
49 const parsedPref = parsePreferences(rawprefs);
50
51 return (
52 <div>
53 <Header
54 title={`Moderation`}
55 backButtonCallback={() => {
56 if (window.history.length > 1) {
57 window.history.back();
58 } else {
59 window.location.assign("/");
60 }
61 }}
62 bottomBorderDisabled={true}
63 />
64 {/* <SettingHeading title="Moderation Tools" />
65 <p>
66 todo: add all these:
67 <br />
68 - Interaction settings
69 <br />
70 - Muted words & tags
71 <br />
72 - Moderation lists
73 <br />
74 - Muted accounts
75 <br />
76 - Blocked accounts
77 <br />
78 - Verification settings
79 <br />
80 </p> */}
81 <SettingHeading title="Content Filters" />
82 <div>
83 <div className="flex items-center gap-4 px-4 py-2 border-b">
84 <label
85 htmlFor={`switch-${"hardcoded"}`}
86 className="flex flex-row flex-1"
87 >
88 <div className="flex flex-col">
89 <span className="text-md">{"Adult Content"}</span>
90 <span className="text-sm text-gray-500 dark:text-gray-400">
91 {"Enable adult content"}
92 </span>
93 </div>
94 </label>
95
96 <Switch.Root
97 id={`switch-${"hardcoded"}`}
98 checked={parsedPref?.adultContentEnabled}
99 onCheckedChange={(v) => {
100 renderSnack({
101 title: "Sorry... Modifying preferences is not implemented yet",
102 description: "You can use another app to change preferences",
103 //button: { label: 'Try Again', onClick: () => console.log('whatever') },
104 });
105 }}
106 className="m3switch root"
107 >
108 <Switch.Thumb className="m3switch thumb " />
109 </Switch.Root>
110 </div>
111 <div className="">
112 {Object.entries(parsedPref?.contentLabelPrefs ?? {}).map(
113 ([label, visibility]) => (
114 <div
115 key={label}
116 className="flex justify-between border-b py-2 px-4"
117 >
118 <label
119 htmlFor={`switch-${"hardcoded"}`}
120 className="flex flex-row flex-1"
121 >
122 <div className="flex flex-col">
123 <span className="text-md">{label}</span>
124 <span className="text-sm text-gray-500 dark:text-gray-400">
125 {"uknown labeler"}
126 </span>
127 </div>
128 </label>
129 {/* <span className="text-md text-gray-500 dark:text-gray-400">
130 {visibility}
131 </span> */}
132 <TripleToggle
133 value={visibility as "ignore" | "warn" | "hide"}
134 />
135 </div>
136 )
137 )}
138 </div>
139 </div>
140 <SettingHeading title="Advanced" />
141 {parsedPref?.labelers.map((labeler) => {
142 return (
143 <NotificationItem
144 key={labeler}
145 notification={labeler}
146 labeler={true}
147 />
148 );
149 })}
150 </div>
151 );
152}
153
154export function TripleToggle({
155 value,
156 onChange,
157}: {
158 value: "ignore" | "warn" | "hide";
159 onChange?: (newValue: "ignore" | "warn" | "hide") => void;
160}) {
161 const options: Array<"ignore" | "warn" | "hide"> = ["ignore", "warn", "hide"];
162 return (
163 <div className="flex rounded-full bg-gray-200 dark:bg-gray-800 p-1 text-sm">
164 {options.map((opt) => {
165 const isActive = opt === value;
166 return (
167 <button
168 key={opt}
169 onClick={() => {
170 renderSnack({
171 title: "Sorry... Modifying preferences is not implemented yet",
172 description: "You can use another app to change preferences",
173 //button: { label: 'Try Again', onClick: () => console.log('whatever') },
174 });
175 onChange?.(opt);
176 }}
177 className={`flex-1 px-3 py-1.5 rounded-full transition-colors ${
178 isActive
179 ? "bg-gray-400 dark:bg-gray-600 text-white"
180 : "text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-700"
181 }`}
182 >
183 {" "}
184 {opt.charAt(0).toUpperCase() + opt.slice(1)}
185 </button>
186 );
187 })}
188 </div>
189 );
190}
191
192type PrefItem =
193 ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"][number];
194
195export interface NormalizedPreferences {
196 contentLabelPrefs: Record<string, string>;
197 mutedWords: string[];
198 feedViewPrefs: Record<string, any>;
199 labelers: string[];
200 adultContentEnabled: boolean;
201 savedFeeds: {
202 pinned: string[];
203 saved: string[];
204 };
205 nuxs: string[];
206}
207
208export function parsePreferences(
209 prefs?: PrefItem[]
210): NormalizedPreferences | undefined {
211 if (!prefs) return undefined;
212 const normalized: NormalizedPreferences = {
213 contentLabelPrefs: {},
214 mutedWords: [],
215 feedViewPrefs: {},
216 labelers: [],
217 adultContentEnabled: false,
218 savedFeeds: { pinned: [], saved: [] },
219 nuxs: [],
220 };
221
222 for (const pref of prefs) {
223 switch (pref.$type) {
224 case "app.bsky.actor.defs#contentLabelPref":
225 if (!isContentLabelPref(pref)) break;
226 normalized.contentLabelPrefs[pref.label] = pref.visibility;
227 break;
228
229 case "app.bsky.actor.defs#mutedWordsPref":
230 if (!isMutedWordsPref(pref)) break;
231 for (const item of pref.items ?? []) {
232 normalized.mutedWords.push(item.value);
233 }
234 break;
235
236 case "app.bsky.actor.defs#feedViewPref":
237 if (!isFeedViewPref(pref)) break;
238 normalized.feedViewPrefs[pref.feed] = pref;
239 break;
240
241 case "app.bsky.actor.defs#labelersPref":
242 if (!isLabelersPref(pref)) break;
243 normalized.labelers.push(...(pref.labelers?.map((l) => l.did) ?? []));
244 break;
245
246 case "app.bsky.actor.defs#adultContentPref":
247 if (!isAdultContentPref(pref)) break;
248 normalized.adultContentEnabled = !!pref.enabled;
249 break;
250
251 case "app.bsky.actor.defs#savedFeedsPref":
252 if (!isSavedFeedsPref(pref)) break;
253 normalized.savedFeeds.pinned.push(...(pref.pinned ?? []));
254 normalized.savedFeeds.saved.push(...(pref.saved ?? []));
255 break;
256
257 case "app.bsky.actor.defs#bskyAppStatePref":
258 if (!isBskyAppStatePref(pref)) break;
259 normalized.nuxs.push(...(pref.nuxs?.map((n) => n.id) ?? []));
260 break;
261
262 default:
263 // unknown pref type — just ignore for now
264 break;
265 }
266 }
267
268 return normalized;
269}