an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm
at main 8.2 kB view raw
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}