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 (
+
+ );
+}
diff --git a/js/web/src/hooks/use-can-moderate.ts b/js/web/src/hooks/use-can-moderate.ts
index afdc43085..903c4dc08 100644
--- a/js/web/src/hooks/use-can-moderate.ts
+++ b/js/web/src/hooks/use-can-moderate.ts
@@ -1,10 +1,12 @@
import {
moderationPermissionsFor,
permissionRecordsFromListRecords,
+ type ModerationPermissionRecord,
} from "@/lib/moderation";
import { useSession } from "@/lib/session";
import type { LivestreamStore } from "@streamplace/core";
import { useEffect, useState } from "react";
+import type { StreamplaceAgent } from "streamplace";
import { useStore } from "zustand";
export interface UseCanModerateResult extends ReturnType<
@@ -14,6 +16,23 @@ export interface UseCanModerateResult extends ReturnType<
error: string | null;
}
+// Chat panel, stream info, and the pinned banner each mount this hook for
+// the same stream; let them share one in-flight listRecords fetch per
+// (agent, streamer) instead of stacking identical requests.
+const inflight = new Map>();
+
+async function fetchDelegations(
+ agent: StreamplaceAgent,
+ streamerDid: string,
+): Promise {
+ const result = await agent.com.atproto.repo.listRecords({
+ repo: streamerDid,
+ collection: "place.stream.moderation.permission",
+ limit: 100,
+ });
+ return permissionRecordsFromListRecords(result.data.records ?? []);
+}
+
/**
* Moderation capabilities of the logged-in viewer for the stream this store
* is connected to. The streamer's delegation records are fetched once per
@@ -42,20 +61,25 @@ export function useCanModerate(store: LivestreamStore): UseCanModerateResult {
}
let cancelled = false;
- setIsLoading(true);
- (async () => {
- try {
- const result = await pdsAgent.com.atproto.repo.listRecords({
- repo: streamerDid,
- collection: "place.stream.moderation.permission",
- limit: 100,
+ const key = `${pdsAgent.did}:${streamerDid}`;
+ let request = inflight.get(key);
+ if (!request) {
+ request = fetchDelegations(pdsAgent, streamerDid)
+ .then((records) => {
+ setModerationPermissions(records);
+ })
+ .finally(() => {
+ inflight.delete(key);
});
- if (cancelled) return;
- setModerationPermissions(
- permissionRecordsFromListRecords(result.data.records ?? []),
- );
- setError(null);
- } catch (e) {
+ inflight.set(key, request);
+ }
+
+ setIsLoading(true);
+ request
+ .then(() => {
+ if (!cancelled) setError(null);
+ })
+ .catch((e) => {
if (cancelled) return;
setError(
e instanceof Error
@@ -63,10 +87,10 @@ export function useCanModerate(store: LivestreamStore): UseCanModerateResult {
: "Failed to load moderation permissions",
);
setModerationPermissions([]);
- } finally {
+ })
+ .finally(() => {
if (!cancelled) setIsLoading(false);
- }
- })();
+ });
return () => {
cancelled = true;