diff --git a/js/components/src/components/dashboard/index.tsx b/js/components/src/components/dashboard/index.tsx index 81497d63..1280b5af 100644 --- a/js/components/src/components/dashboard/index.tsx +++ b/js/components/src/components/dashboard/index.tsx @@ -2,4 +2,5 @@ export { default as ChatPanel } from "./chat-panel"; export { default as Header } from "./header"; export { default as InformationWidget } from "./information-widget"; export { default as ModActions } from "./mod-actions"; +export { default as ModeratorPanel } from "./moderator-panel"; export { default as Problems, ProblemsWrapperRef } from "./problems"; diff --git a/js/components/src/components/dashboard/moderator-panel.tsx b/js/components/src/components/dashboard/moderator-panel.tsx new file mode 100644 index 00000000..435b79c1 --- /dev/null +++ b/js/components/src/components/dashboard/moderator-panel.tsx @@ -0,0 +1,625 @@ +import { Shield, Trash2, UserPlus } from "lucide-react-native"; +import { useEffect, useMemo, useState } from "react"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import { + Button, + Dialog, + DialogFooter, + Input, + ResponsiveDialog, + useToast, +} from "../../components/ui"; +import { useAvatars } from "../../hooks/useAvatars"; +import { atoms } from "../../lib/theme"; +import { + useAddModerator, + useListModerators, + useRemoveModerator, +} from "../../streamplace-store/moderator-management"; +import { formatHandleWithAt } from "../../utils/format-handle"; + +const { + flex, + bg, + r, + borders, + p, + text: textStyle, + layout, + gap, + mb, + px, + py, +} = atoms; + +interface ModeratorPanelProps { + isLive?: boolean; + embedded?: boolean; // If true, removes outer container styling (for use inside other panels) +} + +export default function ModeratorPanel({ + isLive, + embedded = false, +}: ModeratorPanelProps) { + const { moderators, isLoading, error, refresh } = useListModerators(); + const [showAddDialog, setShowAddDialog] = useState(false); + + // Collect all moderator DIDs for batch fetching profiles + const moderatorDIDs = useMemo( + () => moderators.map((mod) => mod.value.moderator), + [moderators], + ); + + const containerStyle = embedded + ? [flex.values[1], layout.flex.column] + : [ + flex.values[1], + bg.neutral[900], + r.lg, + borders.width.thin, + borders.color.neutral[700], + layout.flex.column, + ]; + + return ( + + {/* Header */} + + + + + Moderators + + + setShowAddDialog(true)} + style={[ + layout.flex.row, + layout.flex.alignCenter, + gap.all[2], + bg.blue[600], + px[3], + py[2], + r.md, + ]} + > + + + Add + + + + + {/* Content */} + + {isLoading && moderators.length === 0 && ( + + Loading moderators... + + )} + + {error && ( + + {error} + + )} + + {!isLoading && moderators.length === 0 && !error && ( + + + + No moderators yet + + + Add moderators to help manage your chat + + + )} + + {moderators.map((mod, index) => ( + + ))} + + + {/* Add Moderator Dialog */} + setShowAddDialog(false)} + onSuccess={() => { + setShowAddDialog(false); + refresh(); + }} + /> + + ); +} + +interface ModeratorCardProps { + moderator: { + uri: string; + cid: string; + value: { + moderator: string; + permissions: string[]; + createdAt: string; + expirationTime?: string; + }; + rkey: string; + }; + onRemove: () => void; + isLast: boolean; + moderatorDIDs: string[]; // All moderator DIDs for batch fetching +} + +function ModeratorCard({ + moderator, + onRemove, + isLast, + moderatorDIDs, +}: ModeratorCardProps) { + const { removeModerator, isLoading } = useRemoveModerator(); + const [showConfirm, setShowConfirm] = useState(false); + const toast = useToast(); + + // Use useAvatars hook to batch-fetch profiles for all moderators + const profiles = useAvatars(moderatorDIDs); + const profile = profiles[moderator.value.moderator]; + + // Format display name using existing utility + const displayName = useMemo(() => { + if (profile) { + return formatHandleWithAt(profile); + } + return moderator.value.moderator; // Fall back to DID + }, [profile, moderator.value.moderator]); + + const handleRemove = async () => { + try { + await removeModerator(moderator.rkey); + setShowConfirm(false); + toast.show( + "Moderator removed", + "The moderator has been successfully removed.", + { duration: 3 }, + ); + onRemove(); + } catch (err) { + console.error("Failed to remove moderator:", err); + toast.show( + "Error removing moderator", + err instanceof Error ? err.message : "Failed to remove moderator", + { duration: 5 }, + ); + } + }; + + const isExpired = + moderator.value.expirationTime && + new Date(moderator.value.expirationTime) < new Date(); + + return ( + + + + {displayName} + + + {moderator.value.permissions.map((perm) => ( + + + {perm} + + + ))} + {isExpired && ( + + + Expired + + + )} + + {moderator.value.expirationTime && !isExpired && ( + + Expires:{" "} + {new Date(moderator.value.expirationTime).toLocaleDateString()} + + )} + + setShowConfirm(true)} + disabled={isLoading} + style={[ + p[2], + r.md, + bg.red[900], + borders.width.thin, + borders.color.red[700], + isLoading && { opacity: 0.5 }, + ]} + > + + + + {/* Confirm Delete Dialog */} + + + + + + + + ); +} + +interface AddModeratorDialogProps { + visible: boolean; + onClose: () => void; + onSuccess: () => void; +} + +function AddModeratorDialog({ + visible, + onClose, + onSuccess, +}: AddModeratorDialogProps) { + const { addModerator, isLoading } = useAddModerator(); + const [moderatorDID, setModeratorDID] = useState(""); + const [permissions, setPermissions] = useState({ + ban: false, + hide: false, + "livestream.manage": false, + }); + const [error, setError] = useState(null); + const toast = useToast(); + + // Reset form when dialog closes + useEffect(() => { + if (!visible) { + setModeratorDID(""); + setPermissions({ ban: false, hide: false, "livestream.manage": false }); + setError(null); + } + }, [visible]); + + // Clear error when user starts typing + const handleDIDChange = (text: string) => { + setModeratorDID(text); + if (error) setError(null); + }; + + const handleAdd = async () => { + setError(null); + + if (!moderatorDID.trim()) { + setError("Please enter a DID or handle"); + return; + } + + const selectedPermissions = Object.entries(permissions) + .filter(([_, enabled]) => enabled) + .map(([perm]) => perm) as ("ban" | "hide" | "livestream.manage")[]; + + if (selectedPermissions.length === 0) { + setError("Please select at least one permission"); + return; + } + + try { + await addModerator({ + moderatorDID: moderatorDID.trim(), + permissions: selectedPermissions, + }); + toast.show( + "Moderator added", + "The new moderator has been added successfully.", + { duration: 3 }, + ); + onSuccess(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to add moderator"); + } + }; + + return ( + { + if (!open) onClose(); + }} + title="Add Moderator" + description="Enter the DID or handle of the user you want to add as a moderator and select their permissions." + size="md" + dismissible={false} + > + {/* DID Input */} + + + Moderator DID or Handle + + + + + {/* Permissions */} + + + Permissions + + + setPermissions((p) => ({ ...p, ban: !p.ban }))} + style={[ + layout.flex.row, + layout.flex.alignCenter, + p[3], + r.md, + bg.neutral[800], + borders.width.thin, + borders.color.neutral[700], + ]} + > + + {permissions.ban && ( + ✓ + )} + + + + Ban Users + + + Block users from chat + + + + + setPermissions((p) => ({ ...p, hide: !p.hide }))} + style={[ + layout.flex.row, + layout.flex.alignCenter, + p[3], + r.md, + bg.neutral[800], + borders.width.thin, + borders.color.neutral[700], + ]} + > + + {permissions.hide && ( + ✓ + )} + + + + Hide Messages + + + Hide individual chat messages + + + + + + setPermissions((p) => ({ + ...p, + "livestream.manage": !p["livestream.manage"], + })) + } + style={[ + layout.flex.row, + layout.flex.alignCenter, + p[3], + r.md, + bg.neutral[800], + borders.width.thin, + borders.color.neutral[700], + ]} + > + + {permissions["livestream.manage"] && ( + ✓ + )} + + + + Manage Livestream + + + Update stream title + + + + + + + {/* Error */} + {error && ( + + {error} + + )} + + {/* Actions */} + + + + + + ); +}