an appview-less Bluesky client using Constellation and PDS Queries reddwarf.app
frontend spa bluesky reddwarf microcosm

[moderation] initial display of prefs

rimar1337 665413c9 7edbb928

+2
src/auto-imports.d.ts
··· 23 23 const IconMdiClose: typeof import('~icons/mdi/close.jsx').default 24 24 const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default 25 25 const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default 26 + const IconMdiShield: typeof import('~icons/mdi/shield.jsx').default 27 + const IconMdiShieldOutline: typeof import('~icons/mdi/shield-outline.jsx').default 26 28 }
+21
src/routeTree.gen.ts
··· 12 12 import { Route as SettingsRouteImport } from './routes/settings' 13 13 import { Route as SearchRouteImport } from './routes/search' 14 14 import { Route as NotificationsRouteImport } from './routes/notifications' 15 + import { Route as ModerationRouteImport } from './routes/moderation' 15 16 import { Route as FeedsRouteImport } from './routes/feeds' 16 17 import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout' 17 18 import { Route as IndexRouteImport } from './routes/index' ··· 42 43 const NotificationsRoute = NotificationsRouteImport.update({ 43 44 id: '/notifications', 44 45 path: '/notifications', 46 + getParentRoute: () => rootRouteImport, 47 + } as any) 48 + const ModerationRoute = ModerationRouteImport.update({ 49 + id: '/moderation', 50 + path: '/moderation', 45 51 getParentRoute: () => rootRouteImport, 46 52 } as any) 47 53 const FeedsRoute = FeedsRouteImport.update({ ··· 133 139 export interface FileRoutesByFullPath { 134 140 '/': typeof IndexRoute 135 141 '/feeds': typeof FeedsRoute 142 + '/moderation': typeof ModerationRoute 136 143 '/notifications': typeof NotificationsRoute 137 144 '/search': typeof SearchRoute 138 145 '/settings': typeof SettingsRoute ··· 152 159 export interface FileRoutesByTo { 153 160 '/': typeof IndexRoute 154 161 '/feeds': typeof FeedsRoute 162 + '/moderation': typeof ModerationRoute 155 163 '/notifications': typeof NotificationsRoute 156 164 '/search': typeof SearchRoute 157 165 '/settings': typeof SettingsRoute ··· 173 181 '/': typeof IndexRoute 174 182 '/_pathlessLayout': typeof PathlessLayoutRouteWithChildren 175 183 '/feeds': typeof FeedsRoute 184 + '/moderation': typeof ModerationRoute 176 185 '/notifications': typeof NotificationsRoute 177 186 '/search': typeof SearchRoute 178 187 '/settings': typeof SettingsRoute ··· 195 204 fullPaths: 196 205 | '/' 197 206 | '/feeds' 207 + | '/moderation' 198 208 | '/notifications' 199 209 | '/search' 200 210 | '/settings' ··· 214 224 to: 215 225 | '/' 216 226 | '/feeds' 227 + | '/moderation' 217 228 | '/notifications' 218 229 | '/search' 219 230 | '/settings' ··· 234 245 | '/' 235 246 | '/_pathlessLayout' 236 247 | '/feeds' 248 + | '/moderation' 237 249 | '/notifications' 238 250 | '/search' 239 251 | '/settings' ··· 256 268 IndexRoute: typeof IndexRoute 257 269 PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren 258 270 FeedsRoute: typeof FeedsRoute 271 + ModerationRoute: typeof ModerationRoute 259 272 NotificationsRoute: typeof NotificationsRoute 260 273 SearchRoute: typeof SearchRoute 261 274 SettingsRoute: typeof SettingsRoute ··· 288 301 path: '/notifications' 289 302 fullPath: '/notifications' 290 303 preLoaderRoute: typeof NotificationsRouteImport 304 + parentRoute: typeof rootRouteImport 305 + } 306 + '/moderation': { 307 + id: '/moderation' 308 + path: '/moderation' 309 + fullPath: '/moderation' 310 + preLoaderRoute: typeof ModerationRouteImport 291 311 parentRoute: typeof rootRouteImport 292 312 } 293 313 '/feeds': { ··· 456 476 IndexRoute: IndexRoute, 457 477 PathlessLayoutRoute: PathlessLayoutRouteWithChildren, 458 478 FeedsRoute: FeedsRoute, 479 + ModerationRoute: ModerationRoute, 459 480 NotificationsRoute: NotificationsRoute, 460 481 SearchRoute: SearchRoute, 461 482 SettingsRoute: SettingsRoute,
+32 -3
src/routes/__root.tsx
··· 213 213 const isSettings = location.pathname.startsWith("/settings"); 214 214 const isSearch = location.pathname.startsWith("/search"); 215 215 const isFeeds = location.pathname.startsWith("/feeds"); 216 + const isModeration = location.pathname.startsWith("/moderation"); 216 217 217 218 const locationEnum: 218 219 | "feeds" ··· 220 221 | "settings" 221 222 | "notifications" 222 223 | "profile" 224 + | "moderation" 223 225 | "home" = isFeeds 224 226 ? "feeds" 225 227 : isSearch ··· 230 232 ? "notifications" 231 233 : isProfile 232 234 ? "profile" 233 - : "home"; 235 + : isModeration 236 + ? "moderation" 237 + : "home"; 234 238 235 239 const [, setComposerPost] = useAtom(composerAtom); 236 240 ··· 309 313 }) 310 314 } 311 315 text="Feeds" 316 + /> 317 + <MaterialNavItem 318 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 319 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 320 + active={locationEnum === "moderation"} 321 + onClickCallbback={() => 322 + navigate({ 323 + to: "/moderation", 324 + //params: { did: agent.assertDid }, 325 + }) 326 + } 327 + text="Moderation" 312 328 /> 313 329 <MaterialNavItem 314 330 InactiveIcon={ ··· 555 571 /> 556 572 <MaterialNavItem 557 573 small 574 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 575 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 576 + active={locationEnum === "moderation"} 577 + onClickCallbback={() => 578 + navigate({ 579 + to: "/moderation", 580 + //params: { did: agent.assertDid }, 581 + }) 582 + } 583 + text="Moderation" 584 + /> 585 + <MaterialNavItem 586 + small 558 587 InactiveIcon={ 559 588 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 560 589 } ··· 778 807 <IconMaterialSymbolsSettingsOutline className="w-6 h-6" /> 779 808 } 780 809 ActiveIcon={<IconMaterialSymbolsSettings className="w-6 h-6" />} 781 - active={locationEnum === "settings"} 810 + active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"} 782 811 onClickCallbback={() => 783 812 navigate({ 784 813 to: "/settings", ··· 833 862 ); 834 863 } 835 864 836 - function MaterialNavItem({ 865 + export function MaterialNavItem({ 837 866 InactiveIcon, 838 867 ActiveIcon, 839 868 text,
+18 -1
src/routes/feeds.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 3 + import { Header } from "~/components/Header"; 4 + 3 5 export const Route = createFileRoute("/feeds")({ 4 6 component: Feeds, 5 7 }); 6 8 7 9 export function Feeds() { 8 - return <div className="p-6">Feeds page (coming soon)</div>; 10 + return ( 11 + <div className=""> 12 + <Header 13 + title={`Feeds`} 14 + backButtonCallback={() => { 15 + if (window.history.length > 1) { 16 + window.history.back(); 17 + } else { 18 + window.location.assign("/"); 19 + } 20 + }} 21 + bottomBorderDisabled={true} 22 + /> 23 + Feeds page (coming soon) 24 + </div> 25 + ); 9 26 }
+269
src/routes/moderation.tsx
··· 1 + import * as ATPAPI from "@atproto/api"; 2 + import { 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"; 11 + import { createFileRoute } from "@tanstack/react-router"; 12 + import { useAtom } from "jotai"; 13 + import { Switch } from "radix-ui"; 14 + 15 + import { Header } from "~/components/Header"; 16 + import { useAuth } from "~/providers/UnifiedAuthProvider"; 17 + import { quickAuthAtom } from "~/utils/atoms"; 18 + import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery"; 19 + 20 + import { renderSnack } from "./__root"; 21 + import { NotificationItem } from "./notifications"; 22 + import { SettingHeading } from "./settings"; 23 + 24 + export const Route = createFileRoute("/moderation")({ 25 + component: RouteComponent, 26 + }); 27 + 28 + function 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 + 154 + export 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 + 192 + type PrefItem = 193 + ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"][number]; 194 + 195 + export 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 + 208 + export 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 + }
+2 -2
src/routes/notifications.tsx
··· 572 572 ); 573 573 } 574 574 575 - export function NotificationItem({ notification }: { notification: string }) { 575 + export function NotificationItem({ notification, labeler }: { notification: string, labeler?: boolean }) { 576 576 const aturi = new AtUri(notification); 577 577 const bite = aturi.collection === "net.wafrn.feed.bite"; 578 578 const navigate = useNavigate(); ··· 618 618 <img 619 619 src={avatar || defaultpfp} 620 620 alt={identity?.handle} 621 - className="w-10 h-10 rounded-full" 621 + className={`w-10 h-10 ${labeler ? "rounded-md" : "rounded-full"}`} 622 622 /> 623 623 ) : ( 624 624 <div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-700" />
+119 -9
src/routes/profile.$did/index.tsx
··· 32 32 useQueryIdentity, 33 33 useQueryProfile, 34 34 } from "~/utils/useQuery"; 35 + import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx"; 35 36 36 37 import { renderSnack } from "../__root"; 37 38 import { Chip } from "../notifications"; ··· 51 52 isLoading: isIdentityLoading, 52 53 error: identityError, 53 54 } = useQueryIdentity(did); 55 + 56 + // i was gonna check the did doc but useQueryIdentity doesnt return that info (slingshot minidoc) 57 + // so instead we should query the labeler profile 58 + 59 + const { data: labelerProfile } = useQueryArbitrary( 60 + identity?.did 61 + ? `at://${identity?.did}/app.bsky.labeler.service/self` 62 + : undefined 63 + ); 64 + 65 + const isLabeler = !!labelerProfile?.cid; 66 + const labelerRecord = isLabeler 67 + ? (labelerProfile?.value as ATPAPI.AppBskyLabelerService.Record) 68 + : undefined; 54 69 55 70 const resolvedDid = did.startsWith("did:") ? did : identity?.did; 56 71 const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; ··· 141 156 142 157 {/* Avatar (PFP) */} 143 158 <div className="absolute left-[16px] top-[100px] "> 144 - <img 145 - src={getAvatarUrl(profile) || "/favicon.png"} 146 - alt="avatar" 147 - className="w-28 h-28 rounded-full object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700" 148 - /> 159 + {!getAvatarUrl(profile) && isLabeler ? ( 160 + <div 161 + className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} items-center justify-center flex object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`} 162 + > 163 + <IconMdiShieldOutline className="w-20 h-20" /> 164 + </div> 165 + ) : ( 166 + <img 167 + src={getAvatarUrl(profile) || "/favicon.png"} 168 + alt="avatar" 169 + className={`w-28 h-28 ${isLabeler ? "rounded-md" : "rounded-full"} object-cover border-4 border-white dark:border-gray-950 bg-gray-300 dark:bg-gray-700`} 170 + /> 171 + )} 149 172 </div> 150 173 151 174 <div className="absolute right-[16px] top-[170px] flex flex-row gap-2.5"> ··· 206 229 <ReusableTabRoute 207 230 route={`Profile` + did} 208 231 tabs={{ 209 - Posts: <PostsTab did={did} />, 210 - Reposts: <RepostsTab did={did} />, 211 - Feeds: <FeedsTab did={did} />, 212 - Lists: <ListsTab did={did} />, 232 + ...(isLabeler 233 + ? { 234 + Labels: <LabelsTab did={did} labelerRecord={labelerRecord} />, 235 + } 236 + : {}), 237 + ...{ 238 + Posts: <PostsTab did={did} />, 239 + Reposts: <RepostsTab did={did} />, 240 + Feeds: <FeedsTab did={did} />, 241 + Lists: <ListsTab did={did} />, 242 + }, 213 243 ...(identity?.did === agent?.did 214 244 ? { Likes: <SelfLikesTab did={did} /> } 215 245 : {}), ··· 529 559 {feeds.length === 0 && !arePostsLoading && ( 530 560 <div className="p-4 text-center text-gray-500">No feeds found.</div> 531 561 )} 562 + </> 563 + ); 564 + } 565 + 566 + function LabelsTab({ 567 + did, 568 + labelerRecord, 569 + }: { 570 + did: string; 571 + labelerRecord?: ATPAPI.AppBskyLabelerService.Record; 572 + }) { 573 + useReusableTabScrollRestore(`Profile` + did); 574 + const { agent } = useAuth(); 575 + // const { 576 + // data: identity, 577 + // isLoading: isIdentityLoading, 578 + // error: identityError, 579 + // } = useQueryIdentity(did); 580 + 581 + // const resolvedDid = did.startsWith("did:") ? did : identity?.did; 582 + 583 + const labelMap = new Map( 584 + labelerRecord?.policies?.labelValueDefinitions?.map((def) => { 585 + const locale = def.locales.find((l) => l.lang === "en") ?? def.locales[0]; 586 + return [ 587 + def.identifier, 588 + { 589 + name: locale?.name, 590 + description: locale?.description, 591 + blur: def.blurs, 592 + severity: def.severity, 593 + adultOnly: def.adultOnly, 594 + defaultSetting: def.defaultSetting, 595 + }, 596 + ]; 597 + }) 598 + ); 599 + 600 + return ( 601 + <> 602 + <div className="text-gray-500 dark:text-gray-400 text-lg font-semibold my-3 mx-4"> 603 + Labels 604 + </div> 605 + <div> 606 + {[...labelMap.entries()].map(([key, item]) => ( 607 + <div 608 + key={key} 609 + className="border-gray-300 dark:border-gray-700 border-b px-4 py-4" 610 + > 611 + <div className="font-semibold text-lg">{item.name}</div> 612 + <div className="text-sm text-gray-500 dark:text-gray-400"> 613 + {item.description} 614 + </div> 615 + <div className="mt-1 text-xs text-gray-400"> 616 + {item.blur && <span>Blur: {item.blur} </span>} 617 + {item.severity && <span>• Severity: {item.severity} </span>} 618 + {item.adultOnly && <span>• 18+ only</span>} 619 + </div> 620 + </div> 621 + ))} 622 + </div> 623 + 624 + {/* Loading and "Load More" states */} 625 + {!labelerRecord && ( 626 + <div className="p-4 text-center text-gray-500">Loading labels...</div> 627 + )} 628 + {/* {!labelerRecord && ( 629 + <div className="p-4 text-center text-gray-500">Loading more...</div> 630 + )} */} 631 + {/* {hasNextPage && !isFetchingNextPage && ( 632 + <button 633 + onClick={() => fetchNextPage()} 634 + className="w-[calc(100%-2rem)] mx-4 my-4 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 font-semibold" 635 + > 636 + Load More Feeds 637 + </button> 638 + )} 639 + {feeds.length === 0 && !arePostsLoading && ( 640 + <div className="p-4 text-center text-gray-500">No feeds found.</div> 641 + )} */} 532 642 </> 533 643 ); 534 644 }
+32 -4
src/routes/settings.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 1 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 2 import { useAtom, useAtomValue, useSetAtom } from "jotai"; 3 3 import { Slider, Switch } from "radix-ui"; 4 4 import { useEffect, useState } from "react"; ··· 21 21 videoCDNAtom, 22 22 } from "~/utils/atoms"; 23 23 24 + import { MaterialNavItem } from "./__root"; 25 + 24 26 export const Route = createFileRoute("/settings")({ 25 27 component: Settings, 26 28 }); 27 29 28 30 export function Settings() { 31 + const navigate = useNavigate(); 29 32 return ( 30 33 <> 31 34 <Header ··· 41 44 <div className="lg:hidden"> 42 45 <Login /> 43 46 </div> 47 + <div className="sm:hidden flex flex-col justify-around mt-4"> 48 + <SettingHeading title="Other Pages" top /> 49 + <MaterialNavItem 50 + InactiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />} 51 + ActiveIcon={<IconMaterialSymbolsTag className="w-6 h-6" />} 52 + active={false} 53 + onClickCallbback={() => 54 + navigate({ 55 + to: "/feeds", 56 + //params: { did: agent.assertDid }, 57 + }) 58 + } 59 + text="Feeds" 60 + /> 61 + <MaterialNavItem 62 + InactiveIcon={<IconMdiShieldOutline className="w-6 h-6" />} 63 + ActiveIcon={<IconMdiShield className="w-6 h-6" />} 64 + active={false} 65 + onClickCallbback={() => 66 + navigate({ 67 + to: "/moderation", 68 + //params: { did: agent.assertDid }, 69 + }) 70 + } 71 + text="Moderation" 72 + /> 73 + </div> 44 74 <div className="h-4" /> 45 75 46 76 <SettingHeading title="Personalization" top /> ··· 102 132 <SwitchSetting 103 133 atom={enableWafrnTextAtom} 104 134 title={"Wafrn Text"} 105 - description={ 106 - "Show the original text of posts from Wafrn instances" 107 - } 135 + description={"Show the original text of posts from Wafrn instances"} 108 136 //init={false} 109 137 /> 110 138 <p className="text-gray-500 dark:text-gray-400 py-4 px-4 text-sm border rounded-xl mx-4 mt-8 mb-4">