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 (
+
+ );
+}
+
+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 }) {
) : (
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) */}
-
)
+ {!getAvatarUrl(profile) && isLabeler ? (
+
+
+
+ ) : (
+
)
+ )}
@@ -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() {