From dbdabb975f6a9ee6df934884accbceb23855cc54 Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Tue, 22 Sep 2026 14:17:56 -0500 Subject: [PATCH] Add moderation section to stream settings Signed-off-by: Natalie Bridgers --- js/i18n/locales/en-US/common.ftl | 1 - js/i18n/public/locales/en-US/common.json | 1 - js/web/public/locales/en-US/common.json | 1 - .../src/components/dashboard/moderators.tsx | 112 ++++++++++-------- js/web/src/lib/moderation.test.ts | 31 +++++ js/web/src/lib/moderation.ts | 24 +++- js/web/src/routes/dashboard/stream/index.tsx | 8 +- 7 files changed, 117 insertions(+), 61 deletions(-) diff --git a/js/i18n/locales/en-US/common.ftl b/js/i18n/locales/en-US/common.ftl index 79d50d12f..715f81b17 100644 --- a/js/i18n/locales/en-US/common.ftl +++ b/js/i18n/locales/en-US/common.ftl @@ -391,7 +391,6 @@ metadata-learn-more = Learn more about content metadata metadata-save-failed = Failed to save metadata metadata-saved = Metadata saved moderation = Moderation -moderation-coming-soon = Moderator management hooks aren't on the web yet — this section is a placeholder. moderation-help = Add/remove stream moderators. Moderators can hide chat messages and time out users in your chat. nav-dashboard = Dashboard nav-settings = Settings diff --git a/js/i18n/public/locales/en-US/common.json b/js/i18n/public/locales/en-US/common.json index a779afe23..90cf0844f 100644 --- a/js/i18n/public/locales/en-US/common.json +++ b/js/i18n/public/locales/en-US/common.json @@ -317,7 +317,6 @@ "metadata-save-failed": "Failed to save metadata", "metadata-saved": "Metadata saved", "moderation": "Moderation", - "moderation-coming-soon": "Moderator management hooks aren't on the web yet — this section is a placeholder.", "moderation-help": "Add/remove stream moderators. Moderators can hide chat messages and time out users in your chat.", "nav-dashboard": "Dashboard", "nav-settings": "Settings", diff --git a/js/web/public/locales/en-US/common.json b/js/web/public/locales/en-US/common.json index a779afe23..90cf0844f 100644 --- a/js/web/public/locales/en-US/common.json +++ b/js/web/public/locales/en-US/common.json @@ -317,7 +317,6 @@ "metadata-save-failed": "Failed to save metadata", "metadata-saved": "Metadata saved", "moderation": "Moderation", - "moderation-coming-soon": "Moderator management hooks aren't on the web yet — this section is a placeholder.", "moderation-help": "Add/remove stream moderators. Moderators can hide chat messages and time out users in your chat.", "nav-dashboard": "Dashboard", "nav-settings": "Settings", diff --git a/js/web/src/components/dashboard/moderators.tsx b/js/web/src/components/dashboard/moderators.tsx index 09ef91d07..9a16db948 100644 --- a/js/web/src/components/dashboard/moderators.tsx +++ b/js/web/src/components/dashboard/moderators.tsx @@ -1,10 +1,15 @@ import useAvatars from "@/hooks/use-avatars"; import { useToast } from "@/hooks/use-toast"; +import { + MODERATION_PERMISSION_COLLECTION, + moderatorRecordsFromListRecords, + type ModeratorRecord, +} from "@/lib/moderation"; import { useSession } from "@/lib/session"; +import { cn } from "@/lib/utils"; import { Plus, Shield, ShieldOff, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { place } from "streamplace"; import { Button } from "../ui/button"; import { Dialog, @@ -17,14 +22,6 @@ import { import { Input } from "../ui/input"; import { Switch } from "../ui/switch"; -const PERMISSION_RECORD = "place.stream.moderation.permission"; - -interface ModeratorRecord { - uri: string; - rkey: string; - value: place.stream.moderation.permission.Main; -} - const PERMISSION_OPTIONS = [ { perm: "ban", key: "ban" }, { perm: "hide", key: "hide" }, @@ -34,14 +31,22 @@ const PERMISSION_OPTIONS = [ /** * Manage delegation of stream moderation: lists the - * place.stream.moderation.permission records in the logged-in user's repo - * and adds/removes them. Operates on the session's own repo, so the - * dashboard's stream store is not needed. + * place.stream.moderation.permission records in the logged-in user's repo and + * adds/removes them. Operates on the session's own repo, so the dashboard's + * stream store is not needed. + * + * `variant` picks the chrome: "widget" fills a dashboard grid cell with its own + * title bar, while "section" sits under a page heading with just an add button. */ -export function ModeratorsWidget() { +export function ModeratorsManager({ + variant, +}: { + variant: "widget" | "section"; +}) { const { t } = useTranslation("common"); - const { pdsAgent, did } = useSession(); + const { pdsAgent } = useSession(); const toast = useToast(); + const isWidget = variant === "widget"; const [moderators, setModerators] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -50,7 +55,7 @@ export function ModeratorsWidget() { const [removing, setRemoving] = useState(null); const moderatorDids = useMemo( - () => moderators.map((m) => m.value.moderator), + () => moderators.map((m) => m.moderator), [moderators], ); const profiles = useAvatars(moderatorDids); @@ -65,17 +70,10 @@ export function ModeratorsWidget() { try { const result = await pdsAgent.com.atproto.repo.listRecords({ repo: pdsAgent.did, - collection: PERMISSION_RECORD, + collection: MODERATION_PERMISSION_COLLECTION, limit: 100, }); - const records = (result.data.records ?? []) - .filter((r) => r.value?.$type === PERMISSION_RECORD) - .map((r) => ({ - uri: r.uri, - rkey: r.uri.split("/").pop() ?? "", - value: r.value as place.stream.moderation.permission.Main, - })); - setModerators(records); + setModerators(moderatorRecordsFromListRecords(result.data.records ?? [])); setError(null); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -94,12 +92,12 @@ export function ModeratorsWidget() { try { await pdsAgent.com.atproto.repo.deleteRecord({ repo: pdsAgent.did, - collection: PERMISSION_RECORD, + collection: MODERATION_PERMISSION_COLLECTION, rkey: removing.rkey, }); toast.show( t("moderators-removed-toast", { - handle: handleFor(removing.value.moderator, profiles), + handle: handleFor(removing.moderator, profiles), }), "", { duration: 3000 }, @@ -115,19 +113,32 @@ export function ModeratorsWidget() { } }, [pdsAgent, refresh, removing, t, toast, profiles]); + const addButton = ( + + ); + return ( -
-
-
- - {t("moderators-title")} +
+ {isWidget ? ( +
+
+ + {t("moderators-title")} +
+ {addButton}
- -
+ ) : ( +
{addButton}
+ )} -
+
{error && (

{error} @@ -177,9 +188,7 @@ export function ModeratorsWidget() { {t("moderators-remove-title")} {t("moderators-remove-description", { - handle: removing - ? handleFor(removing.value.moderator, profiles) - : "", + handle: removing ? handleFor(removing.moderator, profiles) : "", })} @@ -197,6 +206,11 @@ export function ModeratorsWidget() { ); } +/** Dashboard-grid entry point: the manager with widget chrome. */ +export function ModeratorsWidget() { + return ; +} + function handleFor( did: string, profiles: ReturnType, @@ -214,11 +228,11 @@ function ModeratorRow({ onRemove: () => void; }) { const { t } = useTranslation("common"); - const handle = handleFor(moderator.value.moderator, profiles); - const avatar = profiles[moderator.value.moderator]?.avatar; + const handle = handleFor(moderator.moderator, profiles); + const avatar = profiles[moderator.moderator]?.avatar; const isExpired = - !!moderator.value.expirationTime && - new Date(moderator.value.expirationTime) <= new Date(); + !!moderator.expirationTime && + new Date(moderator.expirationTime) <= new Date(); return (

@@ -232,7 +246,7 @@ function ModeratorRow({

@{handle}

- {moderator.value.permissions?.map((perm) => ( + {moderator.permissions?.map((perm) => ( )} - {moderator.value.expirationTime && !isExpired && ( + {moderator.expirationTime && !isExpired && ( {t("moderators-expires", { - date: new Date( - moderator.value.expirationTime, - ).toLocaleDateString(), + date: new Date(moderator.expirationTime).toLocaleDateString(), })} )} @@ -330,9 +342,9 @@ function AddModeratorDialog({ } await pdsAgent.com.atproto.repo.createRecord({ repo: pdsAgent.did, - collection: PERMISSION_RECORD, + collection: MODERATION_PERMISSION_COLLECTION, record: { - $type: PERMISSION_RECORD, + $type: MODERATION_PERMISSION_COLLECTION, moderator: moderatorDid as any, permissions: [...permissions], createdAt: new Date().toISOString(), diff --git a/js/web/src/lib/moderation.test.ts b/js/web/src/lib/moderation.test.ts index eaea1a35e..359205357 100644 --- a/js/web/src/lib/moderation.test.ts +++ b/js/web/src/lib/moderation.test.ts @@ -5,6 +5,7 @@ import { import { describe, expect, it } from "vitest"; import { moderationPermissionsFor, + moderatorRecordsFromListRecords, permissionRecordsFromListRecords, type ModerationPermissionRecord, } from "./moderation"; @@ -42,6 +43,36 @@ describe("permissionRecordsFromListRecords", () => { }); }); +describe("moderatorRecordsFromListRecords", () => { + it("derives each record's rkey from its URI", () => { + const records = moderatorRecordsFromListRecords([ + { + value: permissionRecord(), + uri: "at://did:plc:streamer/place.stream.moderation.permission/3kq", + }, + ]); + expect(records).toHaveLength(1); + expect(records[0].rkey).toBe("3kq"); + expect(records[0].moderator).toBe(MODERATOR); + }); + + it("drops non-permission records and keeps the rest addressable", () => { + const records = moderatorRecordsFromListRecords([ + { value: permissionRecord(), uri: "at://x/permission/aaa" }, + { value: { $type: "place.stream.chat.message", text: "hi" } }, + { value: null }, + ]); + expect(records.map((r) => r.rkey)).toEqual(["aaa"]); + }); + + it("falls back to an empty rkey when no URI is present", () => { + const records = moderatorRecordsFromListRecords([ + { value: permissionRecord() }, + ]); + expect(records[0].rkey).toBe(""); + }); +}); + describe("moderationPermissionsFor", () => { it("grants everything to the streamer", () => { const perms = moderationPermissionsFor([], STREAMER, STREAMER); diff --git a/js/web/src/lib/moderation.ts b/js/web/src/lib/moderation.ts index 56f6f3d24..cf2635106 100644 --- a/js/web/src/lib/moderation.ts +++ b/js/web/src/lib/moderation.ts @@ -2,7 +2,8 @@ import type { LivestreamModerationPermission } from "@streamplace/core"; export type ModerationPermissionRecord = LivestreamModerationPermission; -const PERMISSION_RECORD_TYPE = "place.stream.moderation.permission"; +export const MODERATION_PERMISSION_COLLECTION = + "place.stream.moderation.permission"; export interface ModerationPermissions { canBan: boolean; @@ -24,7 +25,7 @@ export function permissionRecordsFromListRecords( if ( !value || typeof value !== "object" || - (value as { $type?: unknown }).$type !== PERMISSION_RECORD_TYPE + (value as { $type?: unknown }).$type !== MODERATION_PERMISSION_COLLECTION ) { return []; } @@ -38,6 +39,25 @@ export function permissionRecordsFromListRecords( }); } +/** A permission record paired with its rkey, addressing the record in the repo. */ +export interface ModeratorRecord extends ModerationPermissionRecord { + rkey: string; +} + +/** + * Map raw `com.atproto.repo.listRecords` results to moderator records. Records + * without a URI (which `listRecords` always provides) get an empty rkey and are + * therefore not addressable for removal. + */ +export function moderatorRecordsFromListRecords( + records: Array<{ value: unknown; uri?: string } | null | undefined>, +): ModeratorRecord[] { + return permissionRecordsFromListRecords(records).map((record) => ({ + ...record, + rkey: record.uri?.split("/").pop() ?? "", + })); +} + /** * Resolve a viewer's moderation capabilities for a streamer from the * streamer's delegation records. The streamer always has full permissions; diff --git a/js/web/src/routes/dashboard/stream/index.tsx b/js/web/src/routes/dashboard/stream/index.tsx index bb43262f6..b0449c430 100644 --- a/js/web/src/routes/dashboard/stream/index.tsx +++ b/js/web/src/routes/dashboard/stream/index.tsx @@ -1,4 +1,5 @@ import { useDashboardStore } from "@/components/dashboard/dashboard-store-context"; +import { ModeratorsManager } from "@/components/dashboard/moderators"; import { Admonition } from "@/components/ui/admonition"; import { Button } from "@/components/ui/button"; import { @@ -1017,12 +1018,7 @@ function ModerationSection() { })}

-
- {t("moderation-coming-soon", { - defaultValue: - "Moderator management hooks aren't on the web yet. This section is a placeholder.", - })} -
+
); } -- knotmirror 0.0.1