diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index c554833..d827ecc 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -23,4 +23,6 @@ declare global { const IconMdiClose: typeof import('~icons/mdi/close.jsx').default const IconMdiMessageReplyTextOutline: typeof import('~icons/mdi/message-reply-text-outline.jsx').default const IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline.jsx').default + const IconMdiShield: typeof import('~icons/mdi/shield.jsx').default + const IconMdiShieldOutline: typeof import('~icons/mdi/shield-outline.jsx').default } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index eb45a89..7ac299e 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SettingsRouteImport } from './routes/settings' import { Route as SearchRouteImport } from './routes/search' import { Route as NotificationsRouteImport } from './routes/notifications' +import { Route as ModerationRouteImport } from './routes/moderation' import { Route as FeedsRouteImport } from './routes/feeds' import { Route as PathlessLayoutRouteImport } from './routes/_pathlessLayout' import { Route as IndexRouteImport } from './routes/index' @@ -44,6 +45,11 @@ const NotificationsRoute = NotificationsRouteImport.update({ path: '/notifications', getParentRoute: () => rootRouteImport, } as any) +const ModerationRoute = ModerationRouteImport.update({ + id: '/moderation', + path: '/moderation', + getParentRoute: () => rootRouteImport, +} as any) const FeedsRoute = FeedsRouteImport.update({ id: '/feeds', path: '/feeds', @@ -133,6 +139,7 @@ const ProfileDidPostRkeyImageIRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/feeds': typeof FeedsRoute + '/moderation': typeof ModerationRoute '/notifications': typeof NotificationsRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute @@ -152,6 +159,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/feeds': typeof FeedsRoute + '/moderation': typeof ModerationRoute '/notifications': typeof NotificationsRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute @@ -173,6 +181,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/_pathlessLayout': typeof PathlessLayoutRouteWithChildren '/feeds': typeof FeedsRoute + '/moderation': typeof ModerationRoute '/notifications': typeof NotificationsRoute '/search': typeof SearchRoute '/settings': typeof SettingsRoute @@ -195,6 +204,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/feeds' + | '/moderation' | '/notifications' | '/search' | '/settings' @@ -214,6 +224,7 @@ export interface FileRouteTypes { to: | '/' | '/feeds' + | '/moderation' | '/notifications' | '/search' | '/settings' @@ -234,6 +245,7 @@ export interface FileRouteTypes { | '/' | '/_pathlessLayout' | '/feeds' + | '/moderation' | '/notifications' | '/search' | '/settings' @@ -256,6 +268,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute PathlessLayoutRoute: typeof PathlessLayoutRouteWithChildren FeedsRoute: typeof FeedsRoute + ModerationRoute: typeof ModerationRoute NotificationsRoute: typeof NotificationsRoute SearchRoute: typeof SearchRoute SettingsRoute: typeof SettingsRoute @@ -290,6 +303,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof NotificationsRouteImport parentRoute: typeof rootRouteImport } + '/moderation': { + id: '/moderation' + path: '/moderation' + fullPath: '/moderation' + preLoaderRoute: typeof ModerationRouteImport + parentRoute: typeof rootRouteImport + } '/feeds': { id: '/feeds' path: '/feeds' @@ -456,6 +476,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, PathlessLayoutRoute: PathlessLayoutRouteWithChildren, FeedsRoute: FeedsRoute, + ModerationRoute: ModerationRoute, NotificationsRoute: NotificationsRoute, SearchRoute: SearchRoute, SettingsRoute: SettingsRoute, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 562bdeb..d79b928 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -213,6 +213,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { const isSettings = location.pathname.startsWith("/settings"); const isSearch = location.pathname.startsWith("/search"); const isFeeds = location.pathname.startsWith("/feeds"); + const isModeration = location.pathname.startsWith("/moderation"); const locationEnum: | "feeds" @@ -220,6 +221,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { | "settings" | "notifications" | "profile" + | "moderation" | "home" = isFeeds ? "feeds" : isSearch @@ -230,7 +232,9 @@ function RootDocument({ children }: { children: React.ReactNode }) { ? "notifications" : isProfile ? "profile" - : "home"; + : isModeration + ? "moderation" + : "home"; const [, setComposerPost] = useAtom(composerAtom); @@ -310,6 +314,18 @@ function RootDocument({ children }: { children: React.ReactNode }) { } text="Feeds" /> + } + ActiveIcon={} + active={locationEnum === "moderation"} + onClickCallbback={() => + navigate({ + to: "/moderation", + //params: { did: agent.assertDid }, + }) + } + text="Moderation" + /> @@ -553,6 +569,19 @@ function RootDocument({ children }: { children: React.ReactNode }) { } text="Feeds" /> + } + ActiveIcon={} + active={locationEnum === "moderation"} + onClickCallbback={() => + navigate({ + to: "/moderation", + //params: { did: agent.assertDid }, + }) + } + text="Moderation" + /> } ActiveIcon={} - active={locationEnum === "settings"} + active={locationEnum === "settings" || locationEnum === "feeds" || locationEnum === "moderation"} onClickCallbback={() => navigate({ to: "/settings", @@ -833,7 +862,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { ); } -function MaterialNavItem({ +export function MaterialNavItem({ InactiveIcon, ActiveIcon, text, diff --git a/src/routes/feeds.tsx b/src/routes/feeds.tsx index 38cc06a..dd42c24 100644 --- a/src/routes/feeds.tsx +++ b/src/routes/feeds.tsx @@ -1,9 +1,26 @@ import { createFileRoute } from "@tanstack/react-router"; +import { Header } from "~/components/Header"; + export const Route = createFileRoute("/feeds")({ component: Feeds, }); export function Feeds() { - return
Feeds page (coming soon)
; + return ( +
+
{ + if (window.history.length > 1) { + window.history.back(); + } else { + window.location.assign("/"); + } + }} + bottomBorderDisabled={true} + /> + Feeds page (coming soon) +
+ ); } diff --git a/src/routes/moderation.tsx b/src/routes/moderation.tsx new file mode 100644 index 0000000..cc851e5 --- /dev/null +++ b/src/routes/moderation.tsx @@ -0,0 +1,269 @@ +import * as ATPAPI from "@atproto/api"; +import { + isAdultContentPref, + isBskyAppStatePref, + isContentLabelPref, + isFeedViewPref, + isLabelersPref, + isMutedWordsPref, + isSavedFeedsPref, +} from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import { createFileRoute } from "@tanstack/react-router"; +import { useAtom } from "jotai"; +import { Switch } from "radix-ui"; + +import { Header } from "~/components/Header"; +import { useAuth } from "~/providers/UnifiedAuthProvider"; +import { quickAuthAtom } from "~/utils/atoms"; +import { useQueryIdentity, useQueryPreferences } from "~/utils/useQuery"; + +import { renderSnack } from "./__root"; +import { NotificationItem } from "./notifications"; +import { SettingHeading } from "./settings"; + +export const Route = createFileRoute("/moderation")({ + component: RouteComponent, +}); + +function RouteComponent() { + const { agent } = useAuth(); + + const [quickAuth, setQuickAuth] = useAtom(quickAuthAtom); + const isAuthRestoring = quickAuth ? status === "loading" : false; + + const identityresultmaybe = useQueryIdentity( + !isAuthRestoring ? agent?.did : undefined + ); + const identity = identityresultmaybe?.data; + + const prefsresultmaybe = useQueryPreferences({ + agent: !isAuthRestoring ? (agent ?? undefined) : undefined, + pdsUrl: !isAuthRestoring ? identity?.pds : undefined, + }); + const rawprefs = prefsresultmaybe?.data?.preferences as + | ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"] + | undefined; + + //console.log(JSON.stringify(prefs, null, 2)) + + const parsedPref = parsePreferences(rawprefs); + + return ( +
+
{ + if (window.history.length > 1) { + window.history.back(); + } else { + window.location.assign("/"); + } + }} + bottomBorderDisabled={true} + /> + {/* +

+ todo: add all these: +
+ - Interaction settings +
+ - Muted words & tags +
+ - Moderation lists +
+ - Muted accounts +
+ - Blocked accounts +
+ - Verification settings +
+

*/} + +
+
+ + + { + renderSnack({ + title: "Sorry... Modifying preferences is not implemented yet", + description: "You can use another app to change preferences", + //button: { label: 'Try Again', onClick: () => console.log('whatever') }, + }); + }} + className="m3switch root" + > + + +
+
+ {Object.entries(parsedPref?.contentLabelPrefs ?? {}).map( + ([label, visibility]) => ( +
+ + {/* + {visibility} + */} + +
+ ) + )} +
+
+ + {parsedPref?.labelers.map((labeler) => { + return ( + + ); + })} +
+ ); +} + +export function TripleToggle({ + value, + onChange, +}: { + value: "ignore" | "warn" | "hide"; + onChange?: (newValue: "ignore" | "warn" | "hide") => void; +}) { + const options: Array<"ignore" | "warn" | "hide"> = ["ignore", "warn", "hide"]; + return ( +
+ {options.map((opt) => { + const isActive = opt === value; + return ( + + ); + })} +
+ ); +} + +type PrefItem = + ATPAPI.AppBskyActorGetPreferences.OutputSchema["preferences"][number]; + +export interface NormalizedPreferences { + contentLabelPrefs: Record; + mutedWords: string[]; + feedViewPrefs: Record; + labelers: string[]; + adultContentEnabled: boolean; + savedFeeds: { + pinned: string[]; + saved: string[]; + }; + nuxs: string[]; +} + +export function parsePreferences( + prefs?: PrefItem[] +): NormalizedPreferences | undefined { + if (!prefs) return undefined; + const normalized: NormalizedPreferences = { + contentLabelPrefs: {}, + mutedWords: [], + feedViewPrefs: {}, + labelers: [], + adultContentEnabled: false, + savedFeeds: { pinned: [], saved: [] }, + nuxs: [], + }; + + for (const pref of prefs) { + switch (pref.$type) { + case "app.bsky.actor.defs#contentLabelPref": + if (!isContentLabelPref(pref)) break; + normalized.contentLabelPrefs[pref.label] = pref.visibility; + break; + + case "app.bsky.actor.defs#mutedWordsPref": + if (!isMutedWordsPref(pref)) break; + for (const item of pref.items ?? []) { + normalized.mutedWords.push(item.value); + } + break; + + case "app.bsky.actor.defs#feedViewPref": + if (!isFeedViewPref(pref)) break; + normalized.feedViewPrefs[pref.feed] = pref; + break; + + case "app.bsky.actor.defs#labelersPref": + if (!isLabelersPref(pref)) break; + normalized.labelers.push(...(pref.labelers?.map((l) => l.did) ?? [])); + break; + + case "app.bsky.actor.defs#adultContentPref": + if (!isAdultContentPref(pref)) break; + normalized.adultContentEnabled = !!pref.enabled; + break; + + case "app.bsky.actor.defs#savedFeedsPref": + if (!isSavedFeedsPref(pref)) break; + normalized.savedFeeds.pinned.push(...(pref.pinned ?? [])); + normalized.savedFeeds.saved.push(...(pref.saved ?? [])); + break; + + case "app.bsky.actor.defs#bskyAppStatePref": + if (!isBskyAppStatePref(pref)) break; + normalized.nuxs.push(...(pref.nuxs?.map((n) => n.id) ?? [])); + break; + + default: + // unknown pref type — just ignore for now + break; + } + } + + return normalized; +} diff --git a/src/routes/notifications.tsx b/src/routes/notifications.tsx index eefd301..c425e8f 100644 --- a/src/routes/notifications.tsx +++ b/src/routes/notifications.tsx @@ -572,7 +572,7 @@ function InteractionsButton({ ); } -export function NotificationItem({ notification }: { notification: string }) { +export function NotificationItem({ notification, labeler }: { notification: string, labeler?: boolean }) { const aturi = new AtUri(notification); const bite = aturi.collection === "net.wafrn.feed.bite"; const navigate = useNavigate(); @@ -618,7 +618,7 @@ export function NotificationItem({ notification }: { notification: string }) { {identity?.handle} ) : (
diff --git a/src/routes/profile.$did/index.tsx b/src/routes/profile.$did/index.tsx index ea4ba66..6744970 100644 --- a/src/routes/profile.$did/index.tsx +++ b/src/routes/profile.$did/index.tsx @@ -32,6 +32,7 @@ import { useQueryIdentity, useQueryProfile, } from "~/utils/useQuery"; +import IconMdiShieldOutline from "~icons/mdi/shield-outline.jsx"; import { renderSnack } from "../__root"; import { Chip } from "../notifications"; @@ -52,6 +53,20 @@ function ProfileComponent() { error: identityError, } = useQueryIdentity(did); + // i was gonna check the did doc but useQueryIdentity doesnt return that info (slingshot minidoc) + // so instead we should query the labeler profile + + const { data: labelerProfile } = useQueryArbitrary( + identity?.did + ? `at://${identity?.did}/app.bsky.labeler.service/self` + : undefined + ); + + const isLabeler = !!labelerProfile?.cid; + const labelerRecord = isLabeler + ? (labelerProfile?.value as ATPAPI.AppBskyLabelerService.Record) + : undefined; + const resolvedDid = did.startsWith("did:") ? did : identity?.did; const resolvedHandle = did.startsWith("did:") ? identity?.handle : did; const pdsUrl = identity?.pds; @@ -141,11 +156,19 @@ function ProfileComponent() { {/* Avatar (PFP) */}
- avatar + {!getAvatarUrl(profile) && isLabeler ? ( +
+ +
+ ) : ( + avatar + )}
@@ -206,10 +229,17 @@ function ProfileComponent() { , - Reposts: , - Feeds: , - Lists: , + ...(isLabeler + ? { + Labels: , + } + : {}), + ...{ + Posts: , + Reposts: , + Feeds: , + Lists: , + }, ...(identity?.did === agent?.did ? { Likes: } : {}), @@ -533,6 +563,86 @@ function FeedsTab({ did }: { did: string }) { ); } +function LabelsTab({ + did, + labelerRecord, +}: { + did: string; + labelerRecord?: ATPAPI.AppBskyLabelerService.Record; +}) { + useReusableTabScrollRestore(`Profile` + did); + const { agent } = useAuth(); + // const { + // data: identity, + // isLoading: isIdentityLoading, + // error: identityError, + // } = useQueryIdentity(did); + + // const resolvedDid = did.startsWith("did:") ? did : identity?.did; + + const labelMap = new Map( + labelerRecord?.policies?.labelValueDefinitions?.map((def) => { + const locale = def.locales.find((l) => l.lang === "en") ?? def.locales[0]; + return [ + def.identifier, + { + name: locale?.name, + description: locale?.description, + blur: def.blurs, + severity: def.severity, + adultOnly: def.adultOnly, + defaultSetting: def.defaultSetting, + }, + ]; + }) + ); + + return ( + <> +
+ Labels +
+
+ {[...labelMap.entries()].map(([key, item]) => ( +
+
{item.name}
+
+ {item.description} +
+
+ {item.blur && Blur: {item.blur} } + {item.severity && • Severity: {item.severity} } + {item.adultOnly && • 18+ only} +
+
+ ))} +
+ + {/* Loading and "Load More" states */} + {!labelerRecord && ( +
Loading labels...
+ )} + {/* {!labelerRecord && ( +
Loading more...
+ )} */} + {/* {hasNextPage && !isFetchingNextPage && ( + + )} + {feeds.length === 0 && !arePostsLoading && ( +
No feeds found.
+ )} */} + + ); +} + export function FeedItemRenderAturiLoader({ aturi, listmode, diff --git a/src/routes/settings.tsx b/src/routes/settings.tsx index 2b7cc1e..3a5e223 100644 --- a/src/routes/settings.tsx +++ b/src/routes/settings.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { Slider, Switch } from "radix-ui"; import { useEffect, useState } from "react"; @@ -21,11 +21,14 @@ import { videoCDNAtom, } from "~/utils/atoms"; +import { MaterialNavItem } from "./__root"; + export const Route = createFileRoute("/settings")({ component: Settings, }); export function Settings() { + const navigate = useNavigate(); return ( <>
+
+ + } + ActiveIcon={} + active={false} + onClickCallbback={() => + navigate({ + to: "/feeds", + //params: { did: agent.assertDid }, + }) + } + text="Feeds" + /> + } + ActiveIcon={} + active={false} + onClickCallbback={() => + navigate({ + to: "/moderation", + //params: { did: agent.assertDid }, + }) + } + text="Moderation" + /> +
@@ -102,9 +132,7 @@ export function Settings() {