diff --git a/js/core/src/livestream-store/websocket-consumer.test.ts b/js/core/src/livestream-store/websocket-consumer.test.ts new file mode 100644 index 000000000..3d8cdfb2a --- /dev/null +++ b/js/core/src/livestream-store/websocket-consumer.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import type { LivestreamState } from "./state"; +import { handleWebSocketMessages } from "./websocket-consumer"; + +function makeState(overrides: Partial = {}): LivestreamState { + return { + profile: null, + chatIndex: {}, + chat: [], + authors: {}, + livestream: null, + viewers: null, + pendingHides: [], + segment: null, + recentSegments: [], + problems: [], + renditions: [], + replyToMessage: null, + chatDraft: "", + badgeSlots: null, + streamKey: null, + setStreamKey: () => {}, + activeTeleport: null, + activeTeleportUri: null, + setActiveTeleportUri: () => {}, + websocketConnected: false, + hasReceivedSegment: false, + pinnedComment: null, + moderationPermissions: [], + setModerationPermissions: () => {}, + localLivestreamURI: null, + setLocalLivestreamURI: () => {}, + ...overrides, + } as LivestreamState; +} + +const MODERATOR_DID = "did:plc:mod"; +const OTHER_MODERATOR_DID = "did:plc:othermod"; + +function permissionRecord(overrides: Record = {}) { + return { + $type: "place.stream.moderation.permission", + moderator: MODERATOR_DID, + permissions: ["ban", "hide"], + createdAt: "2024-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function permissionView(record = permissionRecord()) { + return { + $type: "place.stream.moderation.defs#permissionView", + uri: "at://did:plc:streamer/place.stream.moderation.permission/3kq", + cid: "bafytest", + author: { did: "did:plc:streamer", handle: "streamer.bsky.social" }, + record, + }; +} + +describe("handleWebSocketMessages: moderation permission views", () => { + it("merges a permission view into an empty store", () => { + const state = makeState(); + const result = handleWebSocketMessages(state, [permissionView()]); + expect(result.moderationPermissions).toHaveLength(1); + expect(result.moderationPermissions[0].moderator).toBe(MODERATOR_DID); + expect(result.moderationPermissions[0].permissions).toEqual([ + "ban", + "hide", + ]); + }); + + it("replaces the record fetched earlier for the same moderator and createdAt", () => { + const state = makeState({ + moderationPermissions: [permissionRecord() as any], + }); + const result = handleWebSocketMessages(state, [ + permissionView(permissionRecord({ permissions: ["ban"] })), + ]); + expect(result.moderationPermissions).toHaveLength(1); + expect(result.moderationPermissions[0].permissions).toEqual(["ban"]); + }); + + it("keeps records belonging to other moderators", () => { + const state = makeState({ + moderationPermissions: [ + permissionRecord({ moderator: OTHER_MODERATOR_DID }) as any, + ], + }); + const result = handleWebSocketMessages(state, [permissionView()]); + expect(result.moderationPermissions).toHaveLength(2); + expect(result.moderationPermissions.map((r) => r.moderator)).toEqual([ + OTHER_MODERATOR_DID, + MODERATOR_DID, + ]); + }); + + it("treats records with a different createdAt as separate delegations", () => { + const state = makeState({ + moderationPermissions: [ + permissionRecord({ createdAt: "2023-06-01T00:00:00.000Z" }) as any, + ], + }); + const result = handleWebSocketMessages(state, [permissionView()]); + expect(result.moderationPermissions).toHaveLength(2); + }); + + it("leaves moderationPermissions alone for unrelated messages", () => { + const state = makeState(); + const result = handleWebSocketMessages(state, [ + { $type: "place.stream.livestream#viewerCount", count: 5 }, + ]); + expect(result.moderationPermissions).toEqual([]); + }); +}); diff --git a/js/core/src/livestream-store/websocket-consumer.ts b/js/core/src/livestream-store/websocket-consumer.ts index 4f63ffa2d..1ebf65006 100644 --- a/js/core/src/livestream-store/websocket-consumer.ts +++ b/js/core/src/livestream-store/websocket-consumer.ts @@ -136,6 +136,30 @@ export const handleWebSocketMessages = ( ...state, pinnedComment: null, }; + } else if ( + place.stream.moderation.defs.permissionView.isTypeOf(message) + ) { + const view = message as place.stream.moderation.defs.PermissionView; + const record = + view.record as unknown as place.stream.moderation.permission.Main; + if (record?.moderator) { + // Permission records are immutable, so moderator+createdAt + // identifies a record across the initial listRecords fetch and + // websocket pushes; a re-delivered view replaces its earlier copy + // instead of stacking a duplicate. + const withoutRecord = state.moderationPermissions.filter( + (perm) => + !( + perm.moderator === record.moderator && + perm.createdAt === record.createdAt + ), + ); + state = { + ...state, + moderationPermissions: [...withoutRecord, record], + }; + } + } else if (place.stream.live.teleport.$isTypeOf(message)) { } else if (place.stream.live.teleport.$isTypeOf(message)) { const teleportRecord = message as place.stream.live.teleport.Main; state = { -- knotmirror 0.0.1 From 30142131acaa8938d9d03d5f1f7803a93ba14f9c Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Sun, 6 Sep 2026 15:29:30 -0500 Subject: [PATCH 02/22] web: moderation permission logic and useCanModerate hook Pure resolution logic (owner short-circuit, union of unexpired delegations) lives in lib/moderation with tests; the hook binds it to the session agent and the LivestreamStore's moderationPermissions state, which the websocket now keeps current. --- js/web/src/components/auth/login-modal.tsx | 51 +------ .../auth/pds-host-selector-modal.tsx | 7 - js/web/src/hooks/use-can-moderate.ts | 81 ++++++++++++ js/web/src/lib/moderation.test.ts | 124 ++++++++++++++++++ js/web/src/lib/moderation.ts | 75 +++++++++++ 5 files changed, 282 insertions(+), 56 deletions(-) create mode 100644 js/web/src/hooks/use-can-moderate.ts create mode 100644 js/web/src/lib/moderation.test.ts create mode 100644 js/web/src/lib/moderation.ts diff --git a/js/web/src/components/auth/login-modal.tsx b/js/web/src/components/auth/login-modal.tsx index 1b5bff451..ccbb5e88e 100644 --- a/js/web/src/components/auth/login-modal.tsx +++ b/js/web/src/components/auth/login-modal.tsx @@ -1,18 +1,3 @@ -// Modal that hosts the handle-entry form for Bluesky/ATProto login. -// Opened via openLoginModal() from any "log in" entry point that -// doesn't want to navigate the main window off the current page (the -// chat input being the motivating case: the stream player would stop). -// -// Flow: -// 1. User types a handle. Once 3+ chars, useActorTypeahead hits the -// public Bluesky API and offers a ghost-completion suggestion + -// avatar preview inline in the input. Tab / ArrowRight / Space -// accepts the suggestion. -// 2. User clicks "Continue" → signIn(handle) opens a popup for the -// PDS OAuth round-trip. The modal watches the session and closes -// itself once authenticated. -// 3. User clicks "Sign Up" → modal closes and the PDS host selector -// opens. The PDS handles account creation during the OAuth flow. import { Button } from "@/components/ui/button"; import { Dialog, @@ -42,9 +27,6 @@ export function LoginModal() { const [submitting, setSubmitting] = useState(false); const { actors } = useActorTypeahead(handle); - // Filter typeahead matches to handles that start with what the user - // typed. The API can return partial matches with extra characters - // mid-string; we want prefix matches only. const suggestion = useMemo(() => { if (actors.length === 0) return null; if (handle.length < MIN_TYPEAHEAD_LENGTH) return null; @@ -56,8 +38,6 @@ export function LoginModal() { return match ?? null; }, [actors, handle]); - // The portion of the suggested handle the user hasn't typed yet, - // shown as ghost text inside the input. const completionText = useMemo(() => { if (!suggestion) return null; return suggestion.handle.slice(handle.length); @@ -85,24 +65,12 @@ export function LoginModal() { closeLoginModal(); }, [submitting, state.status, closeLoginModal]); - // If an error lands while we're waiting on the popup, drop back to the - // form so the user sees the message and can retry. The library's - // signInPopup rejects on any failure (user denial, state mismatch, - // network, etc.) and login() routes that to loginState.error. useEffect(() => { if (!loginError) return; if (!submitting) return; setSubmitting(false); }, [loginError, submitting]); - // Bounce to the full-page /login route. Used both by the - // popup-blocked branch of onSubmit and by the manual fallback - // button on the "Completing sign-in…" view (some browsers / - // extensions silently block popups, so the auto-detection never - // fires and the user needs a way out). Clear any pending - // notification first so an abandoned signInPopup's eventual - // library-internal timeout doesn't surface a toast on the new - // page. const goToLoginPage = () => { saveAuthReturnPath( `${window.location.pathname}${window.location.search}${window.location.hash}`, @@ -124,14 +92,6 @@ export function LoginModal() { setSubmitting(true); - // Detect popup-blocked by tracking whether window.open returned a - // handle. The OAuth library's signInPopup() calls - // window.open('about:blank', …) synchronously early in the function - // (see @atproto/oauth-client-browser's browser-oauth-client.js), so - // a null return means the browser blocked the popup. The previous - // version raced signIn() against a 4-second timeout, which - // incorrectly treated any user who took >4s to authorize on the PDS - // as popup-blocked and bounced them to the full-page /login route. const originalOpen = window.open; let popup: Window | null = null; window.open = (...args) => { @@ -144,14 +104,11 @@ export function LoginModal() { await signIn(trimmed); } catch { if (!popup) { - // Popup never opened; browser blocked it. Fall back to the - // full-page /login route, which always does a full-page - // redirect and works regardless of popup settings. + // Popup never opened goToLoginPage(); return; } - // Popup opened but the OAuth flow failed (user denied, error, - // etc.). The login() action routes the error to loginState.error. + // Popup opened but the OAuth flow failed setSubmitting(false); } finally { window.open = originalOpen; @@ -226,10 +183,6 @@ export function LoginModal() { )} - {/* Ghost completion text. Rendered in the same row as - the input text (same font, same padding) so it - appears to extend the user's typing. The input's - own text overlays it via caret + selection. */} {completionText && suggestion?.handle !== handle && (
{handle} diff --git a/js/web/src/components/auth/pds-host-selector-modal.tsx b/js/web/src/components/auth/pds-host-selector-modal.tsx index 936833b91..eeddc5318 100644 --- a/js/web/src/components/auth/pds-host-selector-modal.tsx +++ b/js/web/src/components/auth/pds-host-selector-modal.tsx @@ -1,10 +1,3 @@ -// PDS host selector shown during signup. -// -// The flow: user clicks "Sign Up" in the login modal → login modal -// closes and this one opens → user picks a PDS (or enters a custom -// URL) and confirms → onSubmit(pdsHost) is called and the modal -// closes. The caller is responsible for setting the PDS and starting -// the login round-trip. import { Button } from "@/components/ui/button"; import { Dialog, diff --git a/js/web/src/hooks/use-can-moderate.ts b/js/web/src/hooks/use-can-moderate.ts new file mode 100644 index 000000000..afdc43085 --- /dev/null +++ b/js/web/src/hooks/use-can-moderate.ts @@ -0,0 +1,81 @@ +import { + moderationPermissionsFor, + permissionRecordsFromListRecords, +} from "@/lib/moderation"; +import { useSession } from "@/lib/session"; +import type { LivestreamStore } from "@streamplace/core"; +import { useEffect, useState } from "react"; +import { useStore } from "zustand"; + +export interface UseCanModerateResult extends ReturnType< + typeof moderationPermissionsFor +> { + isLoading: boolean; + error: string | null; +} + +/** + * Moderation capabilities of the logged-in viewer for the stream this store + * is connected to. The streamer's delegation records are fetched once per + * (agent, streamer) pair into the store; websocket permissionView pushes + * keep the store current while the page is open. + */ +export function useCanModerate(store: LivestreamStore): UseCanModerateResult { + const { pdsAgent, did } = useSession(); + const streamerDid = useStore(store, (s) => s.livestream?.author.did) ?? null; + const records = useStore(store, (s) => s.moderationPermissions); + const setModerationPermissions = useStore( + store, + (s) => s.setModerationPermissions, + ); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + // Owners hold every permission by definition; don't spend a listRecords + // call proving it. + if (!pdsAgent?.did || !streamerDid || pdsAgent.did === streamerDid) { + setModerationPermissions([]); + setError(null); + setIsLoading(false); + return; + } + + let cancelled = false; + setIsLoading(true); + (async () => { + try { + const result = await pdsAgent.com.atproto.repo.listRecords({ + repo: streamerDid, + collection: "place.stream.moderation.permission", + limit: 100, + }); + if (cancelled) return; + setModerationPermissions( + permissionRecordsFromListRecords(result.data.records ?? []), + ); + setError(null); + } catch (e) { + if (cancelled) return; + setError( + e instanceof Error + ? e.message + : "Failed to load moderation permissions", + ); + setModerationPermissions([]); + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [pdsAgent, streamerDid, setModerationPermissions]); + + return { + ...moderationPermissionsFor(records, did, streamerDid), + isLoading, + error, + }; +} diff --git a/js/web/src/lib/moderation.test.ts b/js/web/src/lib/moderation.test.ts new file mode 100644 index 000000000..fc90e4ad4 --- /dev/null +++ b/js/web/src/lib/moderation.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + moderationPermissionsFor, + permissionRecordsFromListRecords, + type ModerationPermissionRecord, +} from "./moderation"; + +const STREAMER = "did:plc:streamer"; +const MODERATOR = "did:plc:moderator"; + +function permissionRecord( + overrides: Record = {}, +): Record { + return { + $type: "place.stream.moderation.permission", + moderator: MODERATOR, + permissions: [], + createdAt: "2024-01-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("permissionRecordsFromListRecords", () => { + it("keeps permission records and drops everything else", () => { + const records = permissionRecordsFromListRecords([ + { value: permissionRecord() }, + { value: { $type: "place.stream.chat.message", text: "hi" } }, + { value: null }, + ]); + expect(records).toHaveLength(1); + expect(records[0].moderator).toBe(MODERATOR); + }); +}); + +describe("moderationPermissionsFor", () => { + it("grants everything to the streamer", () => { + const perms = moderationPermissionsFor([], STREAMER, STREAMER); + expect(perms).toEqual({ + canBan: true, + canHide: true, + canPin: true, + canManageLivestream: true, + isOwner: true, + }); + }); + + it("grants nothing to anonymous viewers", () => { + const perms = moderationPermissionsFor([], null, STREAMER); + expect(perms).toEqual({ + canBan: false, + canHide: false, + canPin: false, + canManageLivestream: false, + isOwner: false, + }); + }); + + it("grants nothing without a streamer", () => { + const perms = moderationPermissionsFor([], MODERATOR, null); + expect(perms.isOwner).toBe(false); + expect(perms.canBan).toBe(false); + }); + + it("maps delegated permission strings to flags", () => { + const records = [ + permissionRecord({ + permissions: ["ban", "message.pin"], + }) as ModerationPermissionRecord, + ]; + const perms = moderationPermissionsFor(records, MODERATOR, STREAMER); + expect(perms).toEqual({ + canBan: true, + canHide: false, + canPin: true, + canManageLivestream: false, + isOwner: false, + }); + }); + + it("merges permissions across multiple delegation records", () => { + const records = [ + permissionRecord({ permissions: ["ban"] }), + permissionRecord({ + permissions: ["hide", "livestream.manage"], + createdAt: "2024-01-02T00:00:00.000Z", + }), + ] as ModerationPermissionRecord[]; + const perms = moderationPermissionsFor(records, MODERATOR, STREAMER); + expect(perms.canBan).toBe(true); + expect(perms.canHide).toBe(true); + expect(perms.canManageLivestream).toBe(true); + }); + + it("ignores delegations for other moderators", () => { + const records = [ + permissionRecord({ + moderator: "did:plc:someoneelse", + permissions: ["ban"], + }), + ] as ModerationPermissionRecord[]; + const perms = moderationPermissionsFor(records, MODERATOR, STREAMER); + expect(perms.canBan).toBe(false); + }); + + it("ignores expired delegations", () => { + const records = [ + permissionRecord({ + permissions: ["ban"], + expirationTime: "2020-01-01T00:00:00.000Z", + }), + ] as ModerationPermissionRecord[]; + const perms = moderationPermissionsFor(records, MODERATOR, STREAMER); + expect(perms.canBan).toBe(false); + }); + + it("honors unexpired delegations", () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + const records = [ + permissionRecord({ permissions: ["ban"], expirationTime: future }), + ] as ModerationPermissionRecord[]; + const perms = moderationPermissionsFor(records, MODERATOR, STREAMER); + expect(perms.canBan).toBe(true); + }); +}); diff --git a/js/web/src/lib/moderation.ts b/js/web/src/lib/moderation.ts new file mode 100644 index 000000000..36eb75a8f --- /dev/null +++ b/js/web/src/lib/moderation.ts @@ -0,0 +1,75 @@ +import type { place } from "streamplace"; + +export type ModerationPermissionRecord = + place.stream.moderation.permission.Main; + +const PERMISSION_RECORD_TYPE = "place.stream.moderation.permission"; + +export interface ModerationPermissions { + canBan: boolean; + canHide: boolean; + canPin: boolean; + canManageLivestream: boolean; + isOwner: boolean; +} + +/** + * Filter raw `com.atproto.repo.listRecords` results down to valid moderation + * permission record values. + */ +export function permissionRecordsFromListRecords( + records: Array<{ value: unknown } | null | undefined>, +): ModerationPermissionRecord[] { + return records + .map((r) => r?.value) + .filter( + (value): value is ModerationPermissionRecord => + !!value && + typeof value === "object" && + (value as { $type?: unknown }).$type === PERMISSION_RECORD_TYPE, + ); +} + +/** + * Resolve a viewer's moderation capabilities for a streamer from the + * streamer's delegation records. The streamer always has full permissions; + * everyone else gets the union of their unexpired delegation records' perms. + */ +export function moderationPermissionsFor( + records: ModerationPermissionRecord[], + userDid: string | null | undefined, + streamerDid: string | null | undefined, +): ModerationPermissions { + const isOwner = !!userDid && !!streamerDid && userDid === streamerDid; + if (isOwner || !userDid || !streamerDid) { + return { + canBan: isOwner, + canHide: isOwner, + canPin: isOwner, + canManageLivestream: isOwner, + isOwner, + }; + } + + const granted = new Set(); + for (const record of records) { + if (record.moderator !== userDid) continue; + if ( + record.expirationTime && + new Date(record.expirationTime) <= new Date() + ) { + continue; + } + for (const perm of record.permissions ?? []) { + granted.add(perm); + } + } + + return { + canBan: granted.has("ban"), + canHide: granted.has("hide"), + canPin: granted.has("message.pin"), + canManageLivestream: granted.has("livestream.manage"), + isOwner: false, + }; +} -- knotmirror 0.0.1 From 3b1df3f89ff2d1cfd86b3a85fcab5d8703c93eeb Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Sun, 6 Sep 2026 15:33:59 -0500 Subject: [PATCH 03/22] web: moderation action hooks with delegated and owner paths blockUser/hideMessage/pinMessage/unpinMessage/updateStreamTitle write to the owner's repo directly for streamers and call the place.stream.moderation.* procedures for delegated moderators; the server authorizes those against delegation records. --- js/web/src/hooks/use-moderation-actions.ts | 138 +++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 js/web/src/hooks/use-moderation-actions.ts diff --git a/js/web/src/hooks/use-moderation-actions.ts b/js/web/src/hooks/use-moderation-actions.ts new file mode 100644 index 000000000..64c05f2a7 --- /dev/null +++ b/js/web/src/hooks/use-moderation-actions.ts @@ -0,0 +1,138 @@ +// The lexicon-typed client brands DID/AT-URI/datetime strings; values passed +// around the UI are plain strings sourced from server data, so delegated +// procedure calls cast those fields (same approach as js/components). +import { useSession } from "@/lib/session"; +import { place } from "streamplace"; + +/** + * Moderation writes for a stream the viewer owns or moderates. Owner calls + * write to their own repo directly; delegated moderators go through the + * place.stream.moderation.* procedures, which the server authorizes against + * the streamer's delegation records. + * + * All functions reject on failure; callers own error surfacing. + */ +export function useModerationActions() { + const { pdsAgent } = useSession(); + + const requireAgent = () => { + if (!pdsAgent?.did) throw new Error("Not logged in"); + return pdsAgent; + }; + + const blockUser = async (subjectDid: string, streamerDid: string) => { + const agent = requireAgent(); + if (agent.did === streamerDid) { + return agent.com.atproto.repo.createRecord({ + repo: agent.did, + collection: "app.bsky.graph.block", + record: { + $type: "app.bsky.graph.block", + subject: subjectDid, + createdAt: new Date().toISOString(), + }, + }); + } + return agent.client.call(place.stream.moderation.createBlock, { + streamer: streamerDid as any, + subject: subjectDid as any, + }); + }; + + const hideMessage = async (messageUri: string, streamerDid: string) => { + const agent = requireAgent(); + if (agent.did === streamerDid) { + return agent.client.create( + place.stream.chat.gate, + { hiddenMessage: messageUri as any }, + { repo: agent.did as any }, + ); + } + return agent.client.call(place.stream.moderation.createGate, { + streamer: streamerDid as any, + messageUri: messageUri as any, + }); + }; + + const pinMessage = async ( + messageUri: string, + streamerDid: string, + expiresAt?: string, + ) => { + const agent = requireAgent(); + if (agent.did === streamerDid) { + return agent.com.atproto.repo.createRecord({ + repo: streamerDid, + collection: "place.stream.chat.pinnedRecord", + record: { + $type: "place.stream.chat.pinnedRecord", + pinnedMessage: messageUri, + createdAt: new Date().toISOString(), + ...(expiresAt ? { expiresAt } : {}), + }, + }); + } + return agent.client.call(place.stream.moderation.createPin, { + streamer: streamerDid as any, + messageUri: messageUri as any, + ...(expiresAt ? { expiresAt: expiresAt as any } : {}), + }); + }; + + const unpinMessage = async (pinUri: string, streamerDid: string) => { + const agent = requireAgent(); + if (agent.did === streamerDid) { + const rkey = pinUri.split("/").pop(); + if (!rkey) throw new Error("Invalid pin URI"); + return agent.com.atproto.repo.deleteRecord({ + repo: streamerDid, + collection: "place.stream.chat.pinnedRecord", + rkey, + }); + } + return agent.client.call(place.stream.moderation.deletePin, { + streamer: streamerDid as any, + pinUri: pinUri as any, + }); + }; + + const updateStreamTitle = async ( + livestreamUri: string, + title: string, + streamerDid: string, + ) => { + const agent = requireAgent(); + if (agent.did === streamerDid) { + const rkey = livestreamUri.split("/").pop(); + if (!rkey) throw new Error("Invalid livestream URI"); + // Livestream records are append-only: publishing a new record with a + // fresh createdAt acts as a chapter marker, so the old record is kept. + const { value } = await agent.client.get(place.stream.livestream, { + repo: streamerDid as any, + rkey, + }); + return agent.client.create( + place.stream.livestream, + { + ...(value as object), + title, + createdAt: new Date().toISOString() as any, + }, + { repo: streamerDid as any }, + ); + } + return agent.client.call(place.stream.moderation.updateLivestream, { + streamer: streamerDid as any, + livestreamUri: livestreamUri as any, + title, + }); + }; + + return { + blockUser, + hideMessage, + pinMessage, + unpinMessage, + updateStreamTitle, + }; +} -- knotmirror 0.0.1 From aa017c4a6e7b6dbe80f9d95b41a277949ce2855e Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Sun, 6 Sep 2026 15:40:22 -0500 Subject: [PATCH 04/22] web: per-message moderation menu and delegated unpin Messages get a hover menu with pin durations, hide, block, and delete-own, gated by the viewer's delegation flags; the owner-only pin button is folded into it. Pinned-notification unpin goes through the shared action so moderators with message.pin can unpin too. --- .../components/stream/chat-message-menu.tsx | 213 ++++++++++++++++++ js/web/src/components/stream/chat-panel.tsx | 44 ++-- .../stream/stream-notifications.tsx | 27 ++- 3 files changed, 243 insertions(+), 41 deletions(-) create mode 100644 js/web/src/components/stream/chat-message-menu.tsx diff --git a/js/web/src/components/stream/chat-message-menu.tsx b/js/web/src/components/stream/chat-message-menu.tsx new file mode 100644 index 000000000..43ce304b3 --- /dev/null +++ b/js/web/src/components/stream/chat-message-menu.tsx @@ -0,0 +1,213 @@ +import type { UseCanModerateResult } from "@/hooks/use-can-moderate"; +import { useModerationActions } from "@/hooks/use-moderation-actions"; +import { useToast } from "@/hooks/use-toast"; +import { useSession } from "@/lib/session"; +import type { LivestreamStore } from "@streamplace/core"; +import { reduceChat } from "@streamplace/core"; +import { EyeOff, MoreHorizontal, Pin, Shield, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { ChatMessageViewHydrated } from "streamplace"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; + +const PIN_DURATIONS_MINUTES = [5, 10, 15, 30, 60]; + +interface ChatMessageMenuProps { + store: LivestreamStore; + message: ChatMessageViewHydrated; + streamerDid: string | undefined; + isOwn: boolean; + moderation: UseCanModerateResult; +} + +/** + * Hover menu on a chat message: pin/hide/block for the streamer and + * delegated moderators, plus delete for the viewer's own messages. + */ +export function ChatMessageMenu({ + store, + message, + streamerDid, + isOwn, + moderation, +}: ChatMessageMenuProps) { + const { t } = useTranslation("common"); + const { pdsAgent, did } = useSession(); + const toast = useToast(); + const { blockUser, hideMessage, pinMessage } = useModerationActions(); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const deleteTimer = useRef | null>(null); + + useEffect( + () => () => { + if (deleteTimer.current) clearTimeout(deleteTimer.current); + }, + [], + ); + + const authorDid = message.author.did; + const canPin = moderation.canPin && authorDid !== streamerDid; + const canHide = moderation.canHide && authorDid !== streamerDid; + const canBlock = + moderation.canBan && + !!did && + authorDid !== did && + authorDid !== streamerDid; + + const fail = useCallback( + (fallback: string, e: unknown) => { + console.error(e); + toast.show( + t("moderation-error-title"), + e instanceof Error ? e.message : fallback, + { duration: 5000 }, + ); + }, + [t, toast], + ); + + const handleHide = useCallback(async () => { + if (!streamerDid) return; + try { + await hideMessage(message.uri, streamerDid); + // Show the message as gone right away; the chat gate arriving over + // websocket removes it for everyone else. + store.setState((s) => reduceChat(s, [], [], [message.uri])); + } catch (e) { + fail(t("moderation-hide-failed"), e); + } + }, [fail, hideMessage, message.uri, store, streamerDid, t]); + + const handleBlock = useCallback(async () => { + if (!streamerDid) return; + try { + await blockUser(authorDid, streamerDid); + toast.show( + t("moderation-user-blocked", { handle: message.author.handle }), + "", + { duration: 3000 }, + ); + } catch (e) { + fail(t("moderation-block-failed"), e); + } + }, [ + authorDid, + blockUser, + fail, + message.author.handle, + streamerDid, + t, + toast, + ]); + + const handlePin = useCallback( + async (expiresAt?: string) => { + if (!streamerDid) return; + try { + await pinMessage(message.uri, streamerDid, expiresAt); + toast.show(t("moderation-message-pinned"), "", { duration: 3000 }); + } catch (e) { + fail(t("moderation-pin-failed"), e); + } + }, + [fail, message.uri, pinMessage, streamerDid, t, toast], + ); + + const handleDeleteOwn = useCallback(async () => { + if (!pdsAgent?.did) return; + const rkey = message.uri.split("/").pop(); + if (!rkey) return; + try { + await pdsAgent.com.atproto.repo.deleteRecord({ + repo: pdsAgent.did as any, + collection: "place.stream.chat.message", + rkey, + }); + } catch (e) { + fail(t("moderation-delete-failed"), e); + } + }, [fail, message.uri, pdsAgent, t]); + + const handleDeleteClick = useCallback(() => { + // Two-step confirm; reset if the user reopens the menu later. + if (!confirmingDelete) { + setConfirmingDelete(true); + deleteTimer.current = setTimeout(() => setConfirmingDelete(false), 4000); + return; + } + if (deleteTimer.current) clearTimeout(deleteTimer.current); + setConfirmingDelete(false); + handleDeleteOwn(); + }, [confirmingDelete, handleDeleteOwn]); + + if (!did) return null; + if (!isOwn && !canPin && !canHide && !canBlock) return null; + + return ( + + + + + + {canPin && ( + + + {t("chat-pin-message")} + + + + handlePin()}> + {t("chat-pin-until-stream-end")} + + {PIN_DURATIONS_MINUTES.map((minutes) => ( + + handlePin( + new Date( + Date.now() + minutes * 60 * 1000, + ).toISOString(), + ) + } + > + {t("chat-pin-duration-minutes", { count: minutes })} + + ))} + + + + )} + {canHide && ( + + {t("chat-hide-message")} + + )} + {canBlock && ( + + {t("chat-block-user")} + + )} + {isOwn && ( + + + {confirmingDelete + ? t("chat-delete-confirm") + : t("chat-delete-message")} + + )} + + + ); +} diff --git a/js/web/src/components/stream/chat-panel.tsx b/js/web/src/components/stream/chat-panel.tsx index b7b0ce7a7..fbc9eba4b 100644 --- a/js/web/src/components/stream/chat-panel.tsx +++ b/js/web/src/components/stream/chat-panel.tsx @@ -17,7 +17,6 @@ import { ArrowUp, ChevronLeft, ChevronRight, - Pin, Reply, } from "lucide-react"; import { @@ -35,6 +34,7 @@ import { useStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; import useAvatars from "../../hooks/use-avatars"; import { prefersReducedMotion } from "../../lib/browser"; +import { useCanModerate } from "../../hooks/use-can-moderate"; import { useSession } from "../../lib/session"; import { HoverCard, @@ -42,6 +42,7 @@ import { HoverCardTrigger, } from "../ui/hover-card"; import { getAdjacentBadgeIndex } from "./badge-navigation"; +import { ChatMessageMenu } from "./chat-message-menu"; import { initializeChatScroll } from "./chat-scroll"; export function ChatPanel({ @@ -61,6 +62,7 @@ export function ChatPanel({ websocketConnected: s.websocketConnected, })), ); + const moderation = useCanModerate(store); const scrollRef = useRef(null); const anchorRef = useRef(null); @@ -216,6 +218,7 @@ export function ChatPanel({ store={store} isGrouped={isGrouped} issuerProfiles={issuerProfiles} + moderation={moderation} /> ); }) @@ -254,6 +257,7 @@ function ChatMessage({ store, isGrouped = false, issuerProfiles, + moderation, }: { message: ChatMessageViewHydrated; profile: ChatMessageViewHydrated["chatProfile"]; @@ -261,36 +265,19 @@ function ChatMessage({ store: LivestreamStore; isGrouped?: boolean; issuerProfiles: ReturnType; + moderation: ReturnType; }) { const { t } = useTranslation("common"); - const { state, pdsAgent, did } = useSession(); + const { state, did } = useSession(); const isSystem = message.author.did === "did:sys:system"; const isOwn = did === message.author.did; const streamerDid = useStore(store, (s) => s.livestream?.author.did); - const canPin = did && streamerDid && did === streamerDid; const handleReply = useCallback(() => { store.setState((s) => ({ ...s, replyToMessage: message })); }, [store, message]); - const handlePin = useCallback(async () => { - if (!pdsAgent || !streamerDid) return; - try { - await pdsAgent.com.atproto.repo.createRecord({ - repo: streamerDid, - collection: "place.stream.chat.pinnedRecord", - record: { - $type: "place.stream.chat.pinnedRecord", - pinnedMessage: message.uri, - createdAt: new Date().toISOString(), - }, - }); - } catch (e) { - console.error("Failed to pin message:", e); - } - }, [pdsAgent, streamerDid, message.uri]); - if (isSystem) { return (
@@ -315,15 +302,14 @@ function ChatMessage({ )} - {canPin && ( - + {state.status === "authenticated" && ( + )}
diff --git a/js/web/src/components/stream/stream-notifications.tsx b/js/web/src/components/stream/stream-notifications.tsx index b689f89d8..9206c671e 100644 --- a/js/web/src/components/stream/stream-notifications.tsx +++ b/js/web/src/components/stream/stream-notifications.tsx @@ -11,6 +11,8 @@ import type { } from "streamplace"; import { useStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; +import { useCanModerate } from "../../hooks/use-can-moderate"; +import { useModerationActions } from "../../hooks/use-moderation-actions"; import { useSession } from "../../lib/session"; function rgbColor( @@ -49,9 +51,12 @@ function PinnedNotification({ comment: PinnedRecordViewHydrated; }) { const { t } = useTranslation("common"); - const { state: sessionState, pdsAgent, did } = useSession(); + const { did } = useSession(); const streamerDid = useStore(store, (s) => s.livestream?.author.did); - const canUnpin = did && streamerDid && did === streamerDid; + const moderation = useCanModerate(store); + const { unpinMessage } = useModerationActions(); + const [unpinning, setUnpinning] = useState(false); + const canUnpin = !!did && moderation.canPin; const message = comment.message as ChatMessageViewHydrated | undefined; const record = comment.record; @@ -94,20 +99,17 @@ function PinnedNotification({ }, []); const handleUnpin = useCallback(async () => { - if (!pdsAgent || !streamerDid) return; + if (!streamerDid || unpinning) return; + setUnpinning(true); try { - const rkey = comment.uri.split("/").pop(); - if (!rkey) return; - await pdsAgent.com.atproto.repo.deleteRecord({ - repo: streamerDid, - collection: "place.stream.chat.pinnedRecord", - rkey, - }); + await unpinMessage(comment.uri, streamerDid); + store.setState({ pinnedComment: null }); } catch (e) { console.error("Failed to unpin message:", e); + } finally { + setUnpinning(false); } - store.setState({ pinnedComment: null }); - }, [pdsAgent, streamerDid, comment.uri, store]); + }, [streamerDid, unpinMessage, unpinning, comment.uri, store]); if (dismissed) return null; @@ -143,6 +145,7 @@ function PinnedNotification({ +
+ +
+ {error && ( +

+ {error} +

+ )} + {isLoading && moderators.length === 0 && ( +

+ {t("moderators-loading")} +

+ )} + {!isLoading && moderators.length === 0 && !error && ( +
+ +

{t("moderators-empty")}

+

+ {t("moderators-empty-hint")} +

+
+ )} + {moderators.map((mod) => ( + setRemoving(mod)} + /> + ))} +
+ + setShowAdd(false)} + onAdded={() => { + setShowAdd(false); + refresh(); + }} + /> + + { + if (!open) setRemoving(null); + }} + > + + + {t("moderators-remove-title")} + + {t("moderators-remove-description", { + handle: removing + ? handleFor(removing.value.moderator, profiles) + : "", + })} + + + + + + + + + + ); +} + +function handleFor( + did: string, + profiles: ReturnType, +): string { + return profiles[did]?.handle ?? did; +} + +function ModeratorRow({ + moderator, + profiles, + onRemove, +}: { + moderator: ModeratorRecord; + profiles: ReturnType; + onRemove: () => void; +}) { + const { t } = useTranslation("common"); + const handle = handleFor(moderator.value.moderator, profiles); + const avatar = profiles[moderator.value.moderator]?.avatar; + const isExpired = + !!moderator.value.expirationTime && + new Date(moderator.value.expirationTime) <= new Date(); + + return ( +
+ {avatar && ( + + )} +
+

@{handle}

+
+ {moderator.value.permissions?.map((perm) => ( + + {perm} + + ))} + {isExpired && ( + + {t("moderators-expired")} + + )} + {moderator.value.expirationTime && !isExpired && ( + + {t("moderators-expires", { + date: new Date( + moderator.value.expirationTime, + ).toLocaleDateString(), + })} + + )} +
+
+ +
+ ); +} + +function AddModeratorDialog({ + open, + onClose, + onAdded, +}: { + open: boolean; + onClose: () => void; + onAdded: () => void; +}) { + const { t } = useTranslation("common"); + const { pdsAgent } = useSession(); + const toast = useToast(); + const [handleOrDid, setHandleOrDid] = useState(""); + const [permissions, setPermissions] = useState>(new Set()); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + // Reset the form whenever the dialog closes so a reopened dialog + // never shows the previous submission. + useEffect(() => { + if (!open) { + setHandleOrDid(""); + setPermissions(new Set()); + setError(null); + } + }, [open]); + + const togglePermission = useCallback((perm: string) => { + setPermissions((prev) => { + const next = new Set(prev); + if (next.has(perm)) { + next.delete(perm); + } else { + next.add(perm); + } + return next; + }); + }, []); + + const handleAdd = useCallback(async () => { + if (!pdsAgent?.did) return; + const input = handleOrDid.trim().replace(/^@/, ""); + if (!input) { + setError(t("moderators-error-required")); + return; + } + if (permissions.size === 0) { + setError(t("moderators-error-permissions")); + return; + } + setIsSaving(true); + setError(null); + try { + let moderatorDid = input; + if (!moderatorDid.startsWith("did:")) { + const resolved = await pdsAgent.com.atproto.identity.resolveHandle({ + handle: moderatorDid, + }); + moderatorDid = resolved.data.did; + } + await pdsAgent.com.atproto.repo.createRecord({ + repo: pdsAgent.did, + collection: PERMISSION_RECORD, + record: { + $type: PERMISSION_RECORD, + moderator: moderatorDid as any, + permissions: [...permissions], + createdAt: new Date().toISOString(), + }, + }); + toast.show(t("moderators-added-toast"), "", { duration: 3000 }); + onAdded(); + } catch (e) { + setError(e instanceof Error ? e.message : t("moderators-add-failed")); + } finally { + setIsSaving(false); + } + }, [handleOrDid, onAdded, pdsAgent, permissions, t, toast]); + + return ( + { + if (!o) onClose(); + }} + > + + + {t("moderators-add-title")} + + {t("moderators-add-description")} + + + +
+
+ + { + setHandleOrDid(e.target.value); + if (error) setError(null); + }} + placeholder="handle.bsky.social or did:plc:…" + onKeyDown={(e) => { + if (e.key === "Enter") handleAdd(); + }} + /> +
+ +
+

+ {t("moderators-permissions-label")} +

+ {PERMISSION_OPTIONS.map((perm) => ( +
+
+

+ {t(`moderators-permission-${perm}`)} +

+

+ {t(`moderators-permission-${perm}-desc`)} +

+
+ togglePermission(perm)} + aria-label={t(`moderators-permission-${perm}`)} + /> +
+ ))} +
+ + {error && ( +

+ {error} +

+ )} +
+ + + + + +
+
+ ); +} diff --git a/js/web/src/components/dashboard/registry.tsx b/js/web/src/components/dashboard/registry.tsx index 6ce7becb0..17dea428f 100644 --- a/js/web/src/components/dashboard/registry.tsx +++ b/js/web/src/components/dashboard/registry.tsx @@ -5,6 +5,7 @@ import { Globe, MessageSquare, Radio, + Shield, SquareDashed, Video, type LucideIcon, @@ -12,6 +13,7 @@ import { import type { ComponentType } from "react"; import { useTranslation } from "react-i18next"; import { ChatPanelWidget } from "./chat-panel"; +import { ModeratorsWidget } from "./moderators"; import { MultistreamStatusWidget } from "./multistream-status"; import { ProblemsWidget } from "./problems"; import { StreamHealthWidget } from "./stream-health"; @@ -94,6 +96,14 @@ export const WIDGET_REGISTRY: Record = { minWidth: 240, minHeight: 100, }, + moderators: { + component: ModeratorsWidget, + title: "Moderators", + icon: Shield, + color: "#a855f7", + minWidth: 320, + minHeight: 260, + }, }; export const WIDGET_KEYS = Object.keys(WIDGET_REGISTRY); -- knotmirror 0.0.1 From 04442936c7d0f77167fe75cbdb16d2b4c8ee0ff7 Mon Sep 17 00:00:00 2001 From: Natalie Bridgers Date: Sun, 6 Sep 2026 15:48:40 -0500 Subject: [PATCH 06/22] web: delegated stream-title editing on the stream page A pencil beside the stream title, shown to the owner and moderators with livestream.manage, opens a title-only dialog; owners publish a new chapter-marker record, moderators go through place.stream.moderation.updateLivestream. Also dedupes concurrent useCanModerate fetches now that three surfaces mount it. --- .../src/components/dashboard/moderators.tsx | 16 +-- js/web/src/components/stream/stream-info.tsx | 115 +++++++++++++++++- js/web/src/hooks/use-can-moderate.ts | 56 ++++++--- 3 files changed, 158 insertions(+), 29 deletions(-) diff --git a/js/web/src/components/dashboard/moderators.tsx b/js/web/src/components/dashboard/moderators.tsx index 5658f1074..7bb61dd50 100644 --- a/js/web/src/components/dashboard/moderators.tsx +++ b/js/web/src/components/dashboard/moderators.tsx @@ -26,10 +26,10 @@ interface ModeratorRecord { } const PERMISSION_OPTIONS = [ - "ban", - "hide", - "livestream.manage", - "message.pin", + { perm: "ban", key: "ban" }, + { perm: "hide", key: "hide" }, + { perm: "livestream.manage", key: "manage" }, + { perm: "message.pin", key: "pin" }, ] as const; /** @@ -388,23 +388,23 @@ function AddModeratorDialog({

{t("moderators-permissions-label")}

- {PERMISSION_OPTIONS.map((perm) => ( + {PERMISSION_OPTIONS.map(({ perm, key }) => (

- {t(`moderators-permission-${perm}`)} + {t(`moderators-permission-${key}`)}

- {t(`moderators-permission-${perm}-desc`)} + {t(`moderators-permission-${key}-desc`)}

togglePermission(perm)} - aria-label={t(`moderators-permission-${perm}`)} + aria-label={t(`moderators-permission-${key}`)} />
))} diff --git a/js/web/src/components/stream/stream-info.tsx b/js/web/src/components/stream/stream-info.tsx index db8827d26..61ff3fe14 100644 --- a/js/web/src/components/stream/stream-info.tsx +++ b/js/web/src/components/stream/stream-info.tsx @@ -1,16 +1,33 @@ +import { useToast } from "@/hooks/use-toast"; import { useStore } from "@/lib/store"; import { useStreamplaceUrl } from "@/lib/store/hooks"; import { cn } from "@/lib/utils"; import type { LivestreamStore } from "@streamplace/core"; -import { Check, ChevronRight, ClipboardCopy, Plus, Share2 } from "lucide-react"; -import { useState } from "react"; +import { + Check, + ChevronRight, + ClipboardCopy, + Pencil, + Plus, + Share2, +} from "lucide-react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { place } from "streamplace"; import { useStore as useLivestreamStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; +import { useCanModerate } from "../../hooks/use-can-moderate"; import type { Liveness } from "../../hooks/use-liveness-state"; +import { useModerationActions } from "../../hooks/use-moderation-actions"; import { useSession } from "../../lib/session"; import { Button, buttonVariants } from "../ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -19,6 +36,7 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from "../ui/dropdown-menu"; +import { Textarea } from "../ui/textarea"; import { StreamAvatar } from "./stream-avatar"; const ACTIVITY_I18N_KEYS: Record = { @@ -82,6 +100,8 @@ export function StreamInfo({ const activity = activityLabel(record?.activity, t); const tags = record?.tags; const isLive = liveness === "live"; + const moderation = useCanModerate(store); + const [showEditTitle, setShowEditTitle] = useState(false); const node = useStreamplaceUrl(); @@ -124,9 +144,22 @@ export function StreamInfo({ )} -

- {title} -

+
+

+ {title} +

+ {moderation.canManageLivestream && record && ( + + )} +
{activity && ( @@ -175,6 +208,12 @@ export function StreamInfo({
+ {showEditTitle && ( + setShowEditTitle(false)} + /> + )} ); } @@ -286,3 +325,69 @@ export function CopyButton({ ); } + +function EditTitleDialog({ + store, + onClose, +}: { + store: LivestreamStore; + onClose: () => void; +}) { + const { t } = useTranslation("common"); + const toast = useToast(); + const { updateStreamTitle } = useModerationActions(); + const livestream = useLivestreamStore(store, (s) => s.livestream); + const [title, setTitle] = useState(livestream?.record.title ?? ""); + const [isSaving, setIsSaving] = useState(false); + + const handleSave = useCallback(async () => { + if (!livestream || isSaving) return; + const next = title.trim(); + if (!next) return; + setIsSaving(true); + try { + await updateStreamTitle(livestream.uri, next, livestream.author.did); + toast.show(t("edit-title-saved"), "", { duration: 3000 }); + onClose(); + } catch (e) { + console.error(e); + toast.show( + t("edit-title-failed"), + e instanceof Error ? e.message : String(e), + { duration: 5000 }, + ); + } finally { + setIsSaving(false); + } + }, [isSaving, livestream, onClose, t, title, toast, updateStreamTitle]); + + return ( + { + if (!open) onClose(); + }} + > + + + {t("edit-title-dialog-title")} + +