From 6d549ffe77b46b05fc26e7594e46254e32369713 Mon Sep 17 00:00:00 2001 From: vincent Date: Thu, 18 Dec 2025 11:13:41 -0500 Subject: [PATCH] feat(frontend): Add livestream title management to delegated moderation Enable delegated moderators with livestream.manage permission to update stream titles via mod view. - Add useUpdateLivestreamRecord hook with owner/delegated paths - Implement update stream title dialog in mod view - Add livestream management permission checks - Export block module from streamplace-store Note: block.tsx now contains multiple moderation actions. Related to #718 --- .../src/components/chat/mod-view.tsx | 550 ++++++++++++++---- js/components/src/streamplace-store/block.tsx | 81 +++ js/components/src/streamplace-store/index.tsx | 1 + 3 files changed, 504 insertions(+), 128 deletions(-) diff --git a/js/components/src/components/chat/mod-view.tsx b/js/components/src/components/chat/mod-view.tsx index 7547bfe8f..95bca92e0 100644 --- a/js/components/src/components/chat/mod-view.tsx +++ b/js/components/src/components/chat/mod-view.tsx @@ -5,27 +5,36 @@ import { usePlayerStore } from "../../player-store"; import { useCreateBlockRecord, useCreateHideChatRecord, + useUpdateLivestreamRecord, } from "../../streamplace-store/block"; -import { useCanModerate } from "../../streamplace-store/moderation"; +import { + ModerationPermissions, + useCanModerate, +} from "../../streamplace-store/moderation"; import { usePDSAgent } from "../../streamplace-store/xrpc"; import { Linking } from "react-native"; import { ChatMessageViewHydrated } from "streamplace"; import { useDeleteChatMessage, + useLivestream, useLivestreamStore, } from "../../livestream-store"; import { useStreamplaceStore } from "../../streamplace-store"; import { formatHandle, formatHandleWithAt } from "../../utils/format-handle"; import { atoms, + Button, + DialogFooter, DropdownMenu, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger, layout, + ResponsiveDialog, ResponsiveDropdownMenuContent, Text, + Textarea, useToast, View, } from "../ui"; @@ -46,11 +55,16 @@ export type ModViewRef = { export const ModView = forwardRef(() => { const triggerRef = useRef(null); const message = usePlayerStore((state) => state.modMessage); + const toast = useToast(); let agent = usePDSAgent(); let [messageRemoved, setMessageRemoved] = useState(false); let { createBlock, isLoading: isBlockLoading } = useCreateBlockRecord(); let { createHideChat, isLoading: isHideLoading } = useCreateHideChatRecord(); + let { updateLivestream, isLoading: isUpdateTitleLoading } = + useUpdateLivestreamRecord(); + const livestream = useLivestream(); + const [showUpdateTitleDialog, setShowUpdateTitleDialog] = useState(false); const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); const setReportSubject = usePlayerStore((x) => x.setReportSubject); @@ -67,16 +81,11 @@ export const ModView = forwardRef(() => { // get the logged in user's identity const handle = useStreamplaceStore((state) => state.handle); - if (!agent?.did) { - - Log in to submit mod actions - ; - } - const cleanup = () => { setModMessage(null); }; + // Effect must be called unconditionally (before any early returns) useEffect(() => { if (message) { setMessageRemoved(false); @@ -86,135 +95,262 @@ export const ModView = forwardRef(() => { } }, [message]); - // Can show moderation actions if user can hide or ban - const canModerate = modPermissions.canHide || modPermissions.canBan; + // Early return AFTER all hooks have been called + if (!agent?.did) { + return ( + + Log in to submit mod actions + + ); + } + + // Can show moderation actions if user can hide, ban, or manage livestream + const canModerate = + modPermissions.canHide || + modPermissions.canBan || + modPermissions.canManageLivestream; + + // Check if any moderation actions are actually available for this message + // This must match the individual action checks inside the DropdownMenuGroup + const hasAvailableActions = !!( + message && + agent?.did && + ((modPermissions.canHide && message.author.did !== streamerDID) || + (modPermissions.canBan && + message.author.did !== agent.did && + message.author.did !== streamerDID)) + ); return ( - { - if (!isOpen) { - cleanup(); - } - }} - > - - {/* Hidden trigger */} - - - - {message && ( - <> - - - - - {new Date(message.record.createdAt).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - hour12: false, - })}{" "} - {formatHandleWithAt(message.author)}: {message.record.text} - - - - - - {canModerate && ( - - {modPermissions.canHide && ( - { - if (isHideLoading || messageRemoved) return; - createHideChat(message.uri, streamerDID ?? undefined) - .then((r) => setMessageRemoved(true)) - .catch((e) => console.error(e)); - }} - > - - {isHideLoading - ? "Removing..." - : messageRemoved - ? "Message removed" - : "Remove this message"} - - - )} - {modPermissions.canBan && ( - { - createBlock(message.author.did, streamerDID ?? undefined) - .then((r) => console.log(r)) - .catch((e) => console.error(e)); - }} - > - {message.author.did === agent?.did ? ( - - Block yourself (you can't block yourself) - - ) : ( - - {isBlockLoading - ? "Blocking..." - : `Block user ${formatHandleWithAt(message.author)} from this channel`} - - )} - - )} - - )} + <> + { + if (!isOpen) { + cleanup(); + } + }} + > + + {/* Hidden trigger */} + + + + {message && ( + + )} + + - + {/* Update Stream Title Dialog - rendered outside dropdown */} + {showUpdateTitleDialog && ( + setShowUpdateTitleDialog(false)} + /> + )} + + ); +}); + +interface ModViewContentProps { + message: ChatMessageViewHydrated; + modPermissions: ModerationPermissions; + agent: ReturnType; + streamerDID?: string; + hasAvailableActions: boolean; + isHideLoading: boolean; + isBlockLoading: boolean; + messageRemoved: boolean; + setMessageRemoved: (removed: boolean) => void; + createHideChat: (uri: string, streamerDID?: string) => Promise; + createBlock: (did: string, streamerDID?: string) => Promise; + toast: ReturnType; + setShowUpdateTitleDialog: (show: boolean) => void; + isUpdateTitleLoading: boolean; + livestream: any; + setReportModalOpen: (open: boolean) => void; + setReportSubject: (subject: any) => void; + deleteChatMessage: (uri: string) => Promise; +} + +function ModViewContent({ + message, + modPermissions, + agent, + streamerDID, + hasAvailableActions, + isHideLoading, + isBlockLoading, + messageRemoved, + setMessageRemoved, + createHideChat, + createBlock, + toast, + setShowUpdateTitleDialog, + isUpdateTitleLoading, + livestream, + setReportModalOpen, + setReportSubject, + deleteChatMessage, +}: ModViewContentProps) { + const { onOpenChange } = useRootContext(); + + return ( + <> + + + + + {new Date(message.record.createdAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + hour12: false, + })}{" "} + {formatHandleWithAt(message.author)}: {message.record.text} + + + + + + {hasAvailableActions && ( + + {modPermissions.canHide && message.author.did !== streamerDID && ( + { + if (isHideLoading || messageRemoved) return; + createHideChat(message.uri, streamerDID ?? undefined) + .then((r) => setMessageRemoved(true)) + .catch((e) => console.error(e)); + }} + > + + {isHideLoading + ? "Hiding..." + : messageRemoved + ? "Message hidden" + : "Hide this message"} + + + )} + {modPermissions.canBan && + agent?.did && + message.author.did !== agent.did && + message.author.did !== streamerDID && ( { - Linking.openURL( - `https://${BSKY_FRONTEND_DOMAIN}/profile/${formatHandle(message.author)}`, - ); + if (isBlockLoading) return; + createBlock(message.author.did, streamerDID ?? undefined) + .then((r) => { + toast.show( + "User blocked", + `${formatHandleWithAt(message.author)} has been blocked from this channel.`, + { duration: 3 }, + ); + onOpenChange?.(false); + }) + .catch((e) => { + console.error(e); + toast.show( + "Error blocking user", + e instanceof Error ? e.message : "Failed to block user", + { duration: 5 }, + ); + }); }} > - View user on {BSKY_FRONTEND_DOMAIN} + + {isBlockLoading + ? "Blocking..." + : `Block user ${formatHandleWithAt(message.author)} from this channel`} + - {message.author.did === agent?.did && ( - - )} - {message.author.did !== agent?.did && ( - - )} - - + )} + + )} + + {modPermissions.canManageLivestream && ( + + { + setShowUpdateTitleDialog(true); + }} + disabled={isUpdateTitleLoading || !livestream} + > + + {isUpdateTitleLoading ? "Updating..." : "Update stream title"} + + + + )} + + + { + Linking.openURL( + `https://${BSKY_FRONTEND_DOMAIN}/profile/${formatHandle(message.author)}`, + ); + }} + > + View user on {BSKY_FRONTEND_DOMAIN} + + {message.author.did === agent?.did && ( + )} - - + {message.author.did !== agent?.did && ( + + )} + + ); -}); +} enum DeleteState { None, @@ -225,12 +361,13 @@ enum DeleteState { export function DeleteButton({ message, deleteChatMessage, + onOpenChange, }: { message: ChatMessageViewHydrated; deleteChatMessage: (uri: string) => Promise; + onOpenChange?: (open: boolean) => void; }) { const [confirming, setConfirming] = useState(DeleteState.None); - const { onOpenChange } = useRootContext(); const toast = useToast(); return ( void; setReportSubject: (subject: any) => void; + onOpenChange?: (open: boolean) => void; }) { - const { onOpenChange } = useRootContext(); return ( { @@ -295,3 +433,159 @@ export function ReportButton({ ); } + +interface UpdateStreamTitleDialogProps { + livestream: any; + streamerDID?: string; + updateLivestream: ( + livestreamUri: string, + title: string, + streamerDID?: string, + ) => Promise; + isLoading: boolean; + onClose: () => void; +} + +function UpdateStreamTitleDialog({ + livestream, + streamerDID, + updateLivestream, + isLoading, + onClose, +}: UpdateStreamTitleDialogProps) { + const [title, setTitle] = useState(livestream?.record?.title || ""); + const [error, setError] = useState(null); + const toast = useToast(); + + useEffect(() => { + if (livestream?.record?.title) { + setTitle(livestream.record.title); + } + }, [livestream?.record?.title]); + + const handleUpdate = async () => { + setError(null); + + if (!title.trim()) { + setError("Please enter a stream title"); + return; + } + + if (!livestream?.uri) { + setError("No livestream found"); + return; + } + + try { + await updateLivestream(livestream.uri, title.trim(), streamerDID); + toast.show( + "Stream title updated", + "The stream title has been successfully updated.", + { duration: 3 }, + ); + onClose(); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to update stream title", + ); + } + }; + + return ( + { + if (!open) { + onClose(); + setError(null); + setTitle(livestream?.record?.title || ""); + } + }} + title="Update Stream Title" + description="Update the title of the livestream." + size="md" + dismissible={false} + > + + + + Stream Title + +