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 */}
+
+
+
+
+
+ );
+}