diff --git a/apps/chat/src/index.ts b/apps/chat/src/index.ts
index 9700ade..70727f2 100644
--- a/apps/chat/src/index.ts
+++ b/apps/chat/src/index.ts
@@ -290,6 +290,9 @@ app.get(
pfpUrl: botAccount.botAccount.pfpUrl,
displayName: botAccount.botAccount.displayName,
isBot: true,
+ moderatorUserId: botAccount.botAccount.ownerId,
+ isPlatformAdmin: false,
+ channelRole: null,
};
personalChannel = {
@@ -309,6 +312,9 @@ app.get(
username: userChannel.name,
pfpUrl: session.user.pfpUrl,
isBot: false,
+ moderatorUserId: session.user.id,
+ isPlatformAdmin: Boolean(session.user.isAdmin),
+ channelRole: null,
};
personalChannel = userChannel;
}
@@ -343,6 +349,16 @@ app.get(
id: true,
},
},
+ chatModerators: {
+ select: {
+ id: true,
+ },
+ },
+ chatModeratorBots: {
+ select: {
+ id: true,
+ },
+ },
},
});
@@ -351,11 +367,41 @@ app.get(
return;
}
+ let channelRole: ChatUser['channelRole'] = null;
+ const activeChatUser = chatUser;
+ if (activeChatUser) {
+ if (activeChatUser.isBot) {
+ if (channel.chatModeratorBots.some((bot) => bot.id === activeChatUser.id)) {
+ channelRole = 'botModerator';
+ }
+ } else if (channel.ownerId === activeChatUser.id) {
+ channelRole = 'owner';
+ } else if (channel.managers.some((manager) => manager.id === activeChatUser.id)) {
+ channelRole = 'manager';
+ } else if (channel.chatModerators.some((moderator) => moderator.id === activeChatUser.id)) {
+ channelRole = 'chatModerator';
+ }
+ }
+
+ if (chatUser) {
+ const moderatorUser = await prisma.user.findUnique({
+ where: { id: chatUser.moderatorUserId },
+ select: { isAdmin: true },
+ });
+
+ chatUser = {
+ ...chatUser,
+ isPlatformAdmin: Boolean(moderatorUser?.isAdmin),
+ channelRole,
+ };
+ }
+
const isModerator = Boolean(
chatUser &&
- !chatUser.isBot &&
- (channel.ownerId === chatUser.id ||
- channel.managers.some((manager) => manager.id === chatUser.id))
+ (chatUser.channelRole === 'owner' ||
+ chatUser.channelRole === 'manager' ||
+ chatUser.channelRole === 'chatModerator' ||
+ chatUser.channelRole === 'botModerator')
);
const moderationSettings = await getCachedModerationSettings(channel.id);
@@ -485,7 +531,7 @@ app.get(
await logModerationEvent({
action: ChatModerationAction.MESSAGE_DELETED,
channelId: socketState.channelId,
- moderatorId: socketState.chatUser.id,
+ moderatorId: socketState.chatUser.moderatorUserId,
reason: 'Message deleted by moderator',
details: { msgId },
});
@@ -510,9 +556,11 @@ app.get(
return;
}
+ const actingModeratorUserId = socketState.chatUser.moderatorUserId;
+
const targetUserId = typeof msg.targetUserId === 'string' ? msg.targetUserId : '';
- if (!targetUserId || targetUserId === socketState.chatUser.id) {
+ if (!targetUserId || targetUserId === actingModeratorUserId) {
socket.send(
JSON.stringify({
type: 'moderationError',
@@ -542,10 +590,14 @@ app.get(
}
const actingUserRecord = await prisma.user.findUnique({
- where: { id: socketState.chatUser.id },
+ where: { id: actingModeratorUserId },
select: { isAdmin: true },
});
- if (targetUserRecord.isAdmin && !actingUserRecord?.isAdmin) {
+ if (
+ process.env.NODE_ENV === 'production' &&
+ targetUserRecord.isAdmin &&
+ !actingUserRecord?.isAdmin
+ ) {
socket.send(
JSON.stringify({
type: 'moderationError',
@@ -569,7 +621,7 @@ app.get(
await logModerationEvent({
action: ChatModerationAction.USER_UNBANNED,
channelId: socketState.channelId,
- moderatorId: socketState.chatUser.id,
+ moderatorId: actingModeratorUserId,
targetUserId,
reason: 'User unbanned in chat',
});
@@ -610,12 +662,12 @@ app.get(
create: {
channelId: socketState.channelId,
userId: targetUserId,
- bannedById: socketState.chatUser.id,
+ bannedById: actingModeratorUserId,
reason,
expiresAt,
},
update: {
- bannedById: socketState.chatUser.id,
+ bannedById: actingModeratorUserId,
reason,
expiresAt,
},
@@ -627,7 +679,7 @@ app.get(
? ChatModerationAction.USER_TIMEOUT
: ChatModerationAction.USER_BANNED,
channelId: socketState.channelId,
- moderatorId: socketState.chatUser.id,
+ moderatorId: actingModeratorUserId,
targetUserId,
reason,
details: durationSeconds ? { durationSeconds } : undefined,
@@ -770,6 +822,8 @@ app.get(
pfpUrl: chatUser.pfpUrl,
displayName: chatUser.displayName,
isBot: chatUser.isBot || false,
+ isPlatformAdmin: chatUser.isPlatformAdmin,
+ channelRole: chatUser.channelRole,
},
message,
msgId,
@@ -878,6 +932,9 @@ interface ChatUser {
pfpUrl: string;
displayName?: string;
isBot: boolean;
+ moderatorUserId: string;
+ isPlatformAdmin: boolean;
+ channelRole: 'owner' | 'manager' | 'chatModerator' | 'botModerator' | null;
}
interface ChatModerationSettingsShape {
diff --git a/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx b/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx
index 88811e4..a429d32 100644
--- a/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx
+++ b/apps/web/src/app/(ui)/(protected)/admin/page.client.tsx
@@ -2,7 +2,6 @@
import { useState, useEffect, useCallback } from 'react';
import { format, formatDistanceToNow } from 'date-fns';
-import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -33,7 +32,9 @@ import {
Shield,
Activity,
Hash,
+ ShieldAlert,
} from 'lucide-react';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
Dialog,
DialogContent,
@@ -899,11 +900,53 @@ export default function AdminPanelClient({ currentUser }: AdminPanelClientProps)
by{' '}
{log.actor}
+ {log.actorMeta?.isChannelModerator && (
+
+
+
+
+
+ Channel Mod
+
+
+ )}
+ {log.actorMeta?.isPlatformAdmin && (
+
+
+
+
+
+ Platform Admin
+
+
+ )}
{log.target && (
<>
{log.target}
+ {log.targetMeta?.isChannelModerator && (
+
+
+
+
+
+ Channel Mod
+
+
+ )}
+ {log.targetMeta?.isPlatformAdmin && (
+
+
+
+
+
+
+ Platform Admin
+
+
+
+ )}
>
)}
@@ -1266,7 +1309,15 @@ interface AuditLog {
action: string;
createdAt: string;
actor: string;
+ actorMeta?: {
+ isPlatformAdmin: boolean;
+ isChannelModerator: boolean;
+ };
target: string | null;
+ targetMeta?: {
+ isPlatformAdmin: boolean;
+ isChannelModerator: boolean;
+ } | null;
reason: string | null;
details?: unknown;
channelName?: string;
diff --git a/apps/web/src/app/(ui)/(protected)/api/admin/audit/route.ts b/apps/web/src/app/(ui)/(protected)/api/admin/audit/route.ts
index d213904..cdcee08 100644
--- a/apps/web/src/app/(ui)/(protected)/api/admin/audit/route.ts
+++ b/apps/web/src/app/(ui)/(protected)/api/admin/audit/route.ts
@@ -17,7 +17,10 @@ export async function GET(request: NextRequest) {
take,
include: {
actor: {
- include: {
+ select: {
+ id: true,
+ isAdmin: true,
+ slack_id: true,
personalChannel: {
select: {
name: true,
@@ -33,11 +36,15 @@ export async function GET(request: NextRequest) {
include: {
channel: {
select: {
+ id: true,
name: true,
},
},
moderator: {
- include: {
+ select: {
+ id: true,
+ isAdmin: true,
+ slack_id: true,
personalChannel: {
select: {
name: true,
@@ -46,7 +53,10 @@ export async function GET(request: NextRequest) {
},
},
targetUser: {
- include: {
+ select: {
+ id: true,
+ isAdmin: true,
+ slack_id: true,
personalChannel: {
select: {
name: true,
@@ -84,6 +94,36 @@ export async function GET(request: NextRequest) {
targetUser.personalChannel?.name ?? targetUser.slack_id,
])
);
+ const targetUserAdminMap = new Map(
+ targetUsers.map((targetUser) => [targetUser.id, targetUser.isAdmin])
+ );
+
+ const actorIds = [
+ ...new Set([
+ ...adminLogs.map((log) => log.actorId),
+ ...chatLogs.map((log) => log.moderatorId),
+ ...chatLogs.map((log) => log.targetUserId).filter(Boolean),
+ ...targetUserIds,
+ ]),
+ ] as string[];
+
+ const modRoleUsers =
+ actorIds.length > 0
+ ? await prisma.user.findMany({
+ where: {
+ id: { in: actorIds },
+ OR: [
+ { ownedChannels: { some: {} } },
+ { managedChannels: { some: {} } },
+ { chatModeratedChannels: { some: {} } },
+ ],
+ },
+ select: {
+ id: true,
+ },
+ })
+ : [];
+ const channelModSet = new Set(modRoleUsers.map((user) => user.id));
const normalizedAdminLogs = adminLogs.map((log) => ({
id: log.id,
@@ -96,6 +136,16 @@ export async function GET(request: NextRequest) {
(log.targetUserId ? (targetUserMap.get(log.targetUserId) ?? log.targetUserId) : null),
reason: log.reason,
details: log.details,
+ actorMeta: {
+ isPlatformAdmin: log.actor.isAdmin,
+ isChannelModerator: channelModSet.has(log.actorId),
+ },
+ targetMeta: log.targetUserId
+ ? {
+ isPlatformAdmin: Boolean(targetUserAdminMap.get(log.targetUserId)),
+ isChannelModerator: channelModSet.has(log.targetUserId),
+ }
+ : null,
}));
const normalizedChatLogs = chatLogs.map((log) => ({
@@ -108,6 +158,16 @@ export async function GET(request: NextRequest) {
reason: log.reason,
details: log.details,
channelName: log.channel.name,
+ actorMeta: {
+ isPlatformAdmin: log.moderator.isAdmin,
+ isChannelModerator: true,
+ },
+ targetMeta: log.targetUser
+ ? {
+ isPlatformAdmin: log.targetUser.isAdmin,
+ isChannelModerator: channelModSet.has(log.targetUser.id),
+ }
+ : null,
}));
const logs = [...normalizedAdminLogs, ...normalizedChatLogs]
diff --git a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx
index 04697ce..4c845ac 100644
--- a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx
+++ b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.client.tsx
@@ -22,12 +22,18 @@ import {
Eye,
EyeOff,
MessageSquareWarning,
+ Bot,
} from 'lucide-react';
+import type { LucideIcon } from 'lucide-react';
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
import {
updateChannelSettings,
addChannelManager,
removeChannelManager,
+ addChatModerator,
+ removeChatModerator,
+ addChatBotModerator,
+ removeChatBotModerator,
deleteChannel,
toggleGlobalChannelNotifs,
editStreamInfo,
@@ -43,6 +49,7 @@ import type {
StreamKey,
Follow,
ChatModerationSettings,
+ BotAccount,
} from '@hctv/db';
import {
Dialog,
@@ -79,6 +86,10 @@ interface ChannelSettingsClientProps {
ownerPersonalChannel: Channel | null;
managers: User[];
managerPersonalChannels: (Channel | null)[];
+ chatModerators: User[];
+ chatModeratorPersonalChannels: (Channel | null)[];
+ chatModeratorBots: BotAccount[];
+ teamBotAccounts: BotAccount[];
streamInfo: StreamInfo[];
streamKey: StreamKey | null;
chatSettings: ChatModerationSettings | null;
@@ -671,7 +682,6 @@ export default function ChannelSettingsClient({
- {/* Owner */}
@@ -685,13 +695,9 @@ export default function ChannelSettingsClient({
Channel Owner
-
-
- Owner
-
+
- {/* Managers */}
{channel.managers.map((manager) => {
const personalChannel = channel.managerPersonalChannels.find(
(c) => c?.ownerId === manager.id
@@ -711,26 +717,34 @@ export default function ChannelSettingsClient({
Manager
- {isOwner && (
-
- )}
+
+
+ {isOwner && (
+
+ )}
+
);
})}
@@ -828,7 +842,162 @@ export default function ChannelSettingsClient({
chat.
-
+
+
+
+
Moderators
+
+
manager.id),
+ ...channel.chatModerators.map((moderator) => moderator.id),
+ ]}
+ />
+ bot.id)}
+ />
+
+
+
+
+
+
+
+
+ {channel.owner.slack_id[0]?.toUpperCase()}
+
+
+
{channel.ownerPersonalChannel?.name}
+
Owner
+
+
+
+
+
+ {channel.managers.map((manager) => {
+ const personalChannel = channel.managerPersonalChannels.find(
+ (candidate) => candidate?.ownerId === manager.id
+ );
+ return (
+
+
+
+
+ {personalChannel?.name}
+
+
+
{personalChannel?.name}
+
Manager
+
+
+
+
+ );
+ })}
+
+ {channel.chatModerators.map((moderator) => {
+ const personalChannel = channel.chatModeratorPersonalChannels.find(
+ (candidate) => candidate?.ownerId === moderator.id
+ );
+ return (
+
+
+
+
+ {personalChannel?.name}
+
+
+
{personalChannel?.name}
+
Chat moderator
+
+
+
+
+
+
+
+ );
+ })}
+
+ {channel.chatModeratorBots.map((botAccount) => (
+
+
+
+
+ {botAccount.slug[0]?.toUpperCase()}
+
+
+
{botAccount.displayName}
+
@{botAccount.slug}
+
+
+
+
+
+
+
+ ))}
+
+ {channel.chatModerators.length === 0 &&
+ channel.chatModeratorBots.length === 0 && (
+
+ No extra chat moderators yet.
+
+ )}
+
+
+
+
+
+
+ {label}
+
+ );
+}
+
+type ChannelRoleBadgeKey = 'owner' | 'manager' | 'chatModerator' | 'botModerator' | 'platformAdmin';
+
+const ROLE_BADGE_META: Record<
+ ChannelRoleBadgeKey,
+ { icon: LucideIcon; label: string; className: string }
+> = {
+ owner: {
+ icon: Shield,
+ label: 'Owner',
+ className: 'border-primary/30 bg-primary/10 text-primary',
+ },
+ manager: {
+ icon: Wrench,
+ label: 'Manager',
+ className: 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300',
+ },
+ chatModerator: {
+ icon: MessageSquareWarning,
+ label: 'Chat Mod',
+ className: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
+ },
+ botModerator: {
+ icon: Bot,
+ label: 'Bot Mod',
+ className: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300',
+ },
+ platformAdmin: {
+ icon: Shield,
+ label: 'Platform Admin',
+ className: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',
+ },
+};
+
+const withPlatformAdmin = (
+ roles: readonly ChannelRoleBadgeKey[],
+ isPlatformAdmin?: boolean
+): ChannelRoleBadgeKey[] => (isPlatformAdmin ? [...roles, 'platformAdmin'] : [...roles]);
+
+function ChannelRoleBadges({ roles }: { roles: ChannelRoleBadgeKey[] }) {
+ return (
+
+ {[...new Set(roles)].map((role) => {
+ const meta = ROLE_BADGE_META[role];
+ return (
+
+ );
+ })}
+
+ );
+}
+
function AddManagerDialog({
channelId,
existingManagers,
@@ -966,3 +1203,121 @@ function AddManagerDialog({
);
}
+
+function AddChatModeratorDialog({
+ channelId,
+ existingModerators,
+}: {
+ channelId: string;
+ existingModerators: string[];
+}) {
+ const [open, setOpen] = useState(false);
+ const [selectedChannel, setSelectedChannel] = useState('');
+
+ return (
+
+ );
+}
+
+function AddChatBotModeratorDialog({
+ channelId,
+ teamBots,
+ existingBotModerators,
+}: {
+ channelId: string;
+ teamBots: BotAccount[];
+ existingBotModerators: string[];
+}) {
+ const [open, setOpen] = useState(false);
+ const [selectedBotId, setSelectedBotId] = useState('');
+
+ const availableBots = teamBots.filter(
+ (botAccount) => !existingBotModerators.includes(botAccount.id)
+ );
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.tsx b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.tsx
index 0c0c4ff..678168c 100644
--- a/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.tsx
+++ b/apps/web/src/app/(ui)/(protected)/settings/channel/[channelName]/page.tsx
@@ -22,6 +22,8 @@ export default async function ChannelSettingsPage({
include: {
owner: true,
managers: true,
+ chatModerators: true,
+ chatModeratorBots: true,
streamInfo: true,
streamKey: true,
chatSettings: true,
@@ -53,17 +55,38 @@ export default async function ChannelSettingsPage({
const managerPersonalChannels = await Promise.all(
channel.managers.map((manager) => resolvePersonalChannel(manager.id))
);
+ const managerIds = new Set(channel.managers.map((manager) => manager.id));
+ const extraChatModerators = channel.chatModerators.filter(
+ (moderator) => moderator.id !== channel.ownerId && !managerIds.has(moderator.id)
+ );
+ const chatModeratorPersonalChannels = await Promise.all(
+ extraChatModerators.map((moderator) => resolvePersonalChannel(moderator.id))
+ );
const followerPersonalChannels = await Promise.all(
channel.followers.map((follower) => resolvePersonalChannel(follower.user.id))
);
+ const teamMemberIds = [channel.ownerId, ...channel.managers.map((manager) => manager.id)];
+ const teamBotAccounts = await prisma.botAccount.findMany({
+ where: {
+ ownerId: {
+ in: teamMemberIds,
+ },
+ },
+ orderBy: {
+ slug: 'asc',
+ },
+ });
return (
;
+
+const ROLE_META: Record = {
+ owner: { label: 'Owner', icon: Crown, className: 'text-amber-500' },
+ manager: { label: 'Manager', icon: Wrench, className: 'text-violet-500' },
+ chatModerator: { label: 'Chat Mod', icon: Shield, className: 'text-emerald-500' },
+ botModerator: { label: 'Bot Mod', icon: ShieldCheck, className: 'text-cyan-500' },
+};
+
+function TooltipIcon({
+ icon: Icon,
+ label,
+ className,
+}: {
+ icon: LucideIcon;
+ label: string;
+ className?: string;
+}) {
+ return (
+
+
+
+
+ {label}
+
+ );
+}
+
+function UsernameRow({ user, displayName }: { user?: User; displayName?: string }) {
+ const role = user?.channelRole ? ROLE_META[user.channelRole] : null;
+
+ return (
+
+
+ {user?.isBot && }
+ {role && }
+ {user?.isPlatformAdmin && (
+
+ )}
+ {displayName}
+ :
+
+
+ );
+}
+
+function ReportDialog({
+ open,
+ onOpenChange,
+ displayName,
+ message,
+ reportReason,
+ onReasonChange,
+ onSubmit,
+ isSubmitting,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ displayName?: string;
+ message: string;
+ reportReason: string;
+ onReasonChange: (value: string) => void;
+ onSubmit: () => void;
+ isSubmitting: boolean;
+}) {
+ return (
+
+ );
+}
export function Message({
user,
@@ -37,6 +157,7 @@ export function Message({
const [reportOpen, setReportOpen] = useState(false);
const [reportReason, setReportReason] = useState('');
const [isSubmittingReport, setIsSubmittingReport] = useState(false);
+ const displayName = user?.displayName || user?.username;
if (type === 'systemMsg') {
return (
@@ -46,12 +167,8 @@ export function Message({
);
}
- const hasTargetUser = type === 'message' && Boolean(user?.id);
-
const submitReport = async () => {
- if (!user?.id || !viewerId || viewerId === user.id) {
- return;
- }
+ if (!user?.id || !viewerId || viewerId === user.id) return;
const reason = reportReason.trim();
if (reason.length < 10) {
@@ -61,24 +178,21 @@ export function Message({
setIsSubmittingReport(true);
try {
- const response = await fetch('/api/stream/chat/report', {
+ const res = await fetch('/api/stream/chat/report', {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channelName,
targetUserId: user.id,
- targetUsername: user.displayName || user.username,
+ targetUsername: displayName,
msgId,
message,
reason,
}),
});
- if (!response.ok) {
- const errorText = await response.text();
- toast.error(errorText || 'Failed to submit report.');
+ if (!res.ok) {
+ toast.error((await res.text()) || 'Failed to submit report.');
return;
}
@@ -92,22 +206,24 @@ export function Message({
}
};
+ const handleReportOpenChange = (open: boolean) => {
+ setReportOpen(open);
+ if (!open) setReportReason('');
+ };
+
return (
<>
-
-
- {user?.isBot && }
- {user?.displayName || user?.username}
-
+
+
- {hasTargetUser && user ? (
+ {type === 'message' && user?.id && (
setReportOpen(true)}
/>
- ) : null}
+ )}
-
-
+ onOpenChange={handleReportOpenChange}
+ displayName={displayName}
+ message={message}
+ reportReason={reportReason}
+ onReasonChange={setReportReason}
+ onSubmit={submitReport}
+ isSubmitting={isSubmittingReport}
+ />
>
);
}
@@ -192,11 +264,21 @@ function MessageActionsMenu({
onModerationCommand?: (command: ChatModerationCommand) => void;
onOpenReport: () => void;
}) {
- if (!viewerId || !user.id || user.id === viewerId) {
- return null;
- }
+ if (!viewerId || !user.id || user.id === viewerId) return null;
- const canModerateTarget = Boolean(canModerate && onModerationCommand);
+ const displayName = user.displayName || user.username;
+ const canMod = Boolean(canModerate && onModerationCommand);
+
+ const runModeration = (command: ChatModerationCommand) => onModerationCommand?.(command);
+
+ const timeout = (durationSeconds: number) =>
+ runModeration({
+ type: 'mod:timeoutUser',
+ targetUserId: user.id,
+ targetUsername: displayName,
+ durationSeconds,
+ reason: 'Timed out by moderator',
+ });
return (
@@ -210,120 +292,82 @@ function MessageActionsMenu({
Report user
-
- {canModerateTarget ? (
+ {canMod && (
<>
{
- if (!msgId) return;
- onModerationCommand?.({ type: 'mod:deleteMessage', msgId });
- }}
+ onClick={() => msgId && runModeration({ type: 'mod:deleteMessage', msgId })}
>
Delete message
- {
- onModerationCommand?.({
- type: 'mod:timeoutUser',
- targetUserId: user.id,
- targetUsername: user.displayName || user.username,
- durationSeconds: 300,
- reason: 'Timed out by moderator',
- });
- }}
- >
+ timeout(300)}>
Timeout 5 min
- {
- onModerationCommand?.({
- type: 'mod:timeoutUser',
- targetUserId: user.id,
- targetUsername: user.displayName || user.username,
- durationSeconds: 3600,
- reason: 'Timed out by moderator',
- });
- }}
- >
+ timeout(3600)}>
Timeout 1 hour
{
- onModerationCommand?.({
+ onClick={() =>
+ runModeration({
type: 'mod:banUser',
targetUserId: user.id,
- targetUsername: user.displayName || user.username,
+ targetUsername: displayName,
reason: 'Banned by moderator',
- });
- }}
+ })
+ }
>
Ban user
{
- onModerationCommand?.({
+ onClick={() =>
+ runModeration({
type: 'mod:liftTimeout',
targetUserId: user.id,
- targetUsername: user.displayName || user.username,
- });
- }}
+ targetUsername: displayName,
+ })
+ }
>
Lift timeout/ban
>
- ) : null}
+ )}
);
}
-export function EmojiRenderer({ text, emojiMap }: EmojiRendererProps) {
+export function EmojiRenderer({ text, emojiMap }: { text: string; emojiMap: Map
}) {
if (!text) return null;
- const parts = text.split(/(:[\w\-+]+:)/g);
-
return (
- <>
- {parts.map((part, index) => {
- if (part.match(/^:[\w\-+]+:$/)) {
- const emojiName = part.replaceAll(':', '');
- const emojiUrl = emojiMap.get(emojiName);
-
- if (emojiUrl) {
- return (
-
-
+
+ <>
+ {text.split(/(:[\w\-+]+:)/g).map((part, i) => {
+ if (part.match(/^:[\w\-+]+:$/)) {
+ const name = part.replaceAll(':', '');
+ const url = emojiMap.get(name);
+ if (url) {
+ return (
+
-
+
{part}
-
- );
+ );
+ }
}
- }
-
- // Preserve text as-is, handling whitespace properly
- if (part) {
- return {part};
- }
- return null;
- })}
- >
+ return part ? {part} : null;
+ })}
+ >
+
);
}
@@ -338,8 +382,3 @@ interface MessageProps {
channelName: string;
onModerationCommand?: (command: ChatModerationCommand) => void;
}
-
-interface EmojiRendererProps {
- text: string;
- emojiMap: Map;
-}
diff --git a/apps/web/src/lib/form/actions.ts b/apps/web/src/lib/form/actions.ts
index 3997a6f..93d981e 100644
--- a/apps/web/src/lib/form/actions.ts
+++ b/apps/web/src/lib/form/actions.ts
@@ -262,6 +262,174 @@ export async function addChannelManager(channelId: string, userChannel: string)
managers: {
connect: { id: userDb.id },
},
+ chatModerators: {
+ connect: { id: userDb.id },
+ },
+ },
+ });
+
+ revalidatePath(`/settings/channel/${channel.name}`);
+ return { success: true };
+}
+
+export async function addChatModerator(channelId: string, userChannel: string) {
+ const { user } = await validateRequest();
+ if (!user) {
+ return { success: false, error: 'Unauthorized' };
+ }
+
+ const channel = await prisma.channel.findUnique({
+ where: { id: channelId },
+ include: { owner: true, managers: true, chatModerators: true },
+ });
+
+ if (!channel) {
+ return { success: false, error: 'Channel not found' };
+ }
+
+ if (!can(user, 'update', 'channel', { channel })) {
+ return { success: false, error: 'Unauthorized' };
+ }
+
+ const userDb = await resolveUserFromPersonalChannelName(userChannel);
+ if (!userDb) {
+ return { success: false, error: 'User not found' };
+ }
+
+ if (
+ channel.ownerId === userDb.id ||
+ channel.managers.some((manager) => manager.id === userDb.id)
+ ) {
+ return { success: false, error: 'This user is already a built-in moderator' };
+ }
+
+ if (channel.chatModerators.some((moderator) => moderator.id === userDb.id)) {
+ return { success: false, error: 'User is already a chat moderator' };
+ }
+
+ await prisma.channel.update({
+ where: { id: channelId },
+ data: {
+ chatModerators: {
+ connect: { id: userDb.id },
+ },
+ },
+ });
+
+ revalidatePath(`/settings/channel/${channel.name}`);
+ return { success: true };
+}
+
+export async function removeChatModerator(channelId: string, userId: string) {
+ const { user } = await validateRequest();
+ if (!user) {
+ return { success: false, error: 'Unauthorized' };
+ }
+
+ const channel = await prisma.channel.findUnique({
+ where: { id: channelId },
+ include: { owner: true, managers: true },
+ });
+
+ if (!channel) {
+ return { success: false, error: 'Channel not found' };
+ }
+
+ if (!can(user, 'update', 'channel', { channel })) {
+ return { success: false, error: 'Unauthorized' };
+ }
+
+ await prisma.channel.update({
+ where: { id: channelId },
+ data: {
+ chatModerators: {
+ disconnect: { id: userId },
+ },
+ },
+ });
+
+ revalidatePath(`/settings/channel/${channel.name}`);
+ return { success: true };
+}
+
+export async function addChatBotModerator(channelId: string, botId: string) {
+ const { user } = await validateRequest();
+ if (!user) {
+ return { success: false, error: 'Unauthorized' };
+ }
+
+ const channel = await prisma.channel.findUnique({
+ where: { id: channelId },
+ include: { owner: true, managers: true, chatModeratorBots: true },
+ });
+
+ if (!channel) {
+ return { success: false, error: 'Channel not found' };
+ }
+
+ if (!can(user, 'update', 'channel', { channel })) {
+ return { success: false, error: 'Unauthorized' };
+ }
+
+ const bot = await prisma.botAccount.findUnique({
+ where: { id: botId },
+ select: { id: true, ownerId: true },
+ });
+
+ if (!bot) {
+ return { success: false, error: 'Bot not found' };
+ }
+
+ if (channel.chatModeratorBots.some((existingBot) => existingBot.id === bot.id)) {
+ return { success: false, error: 'Bot is already a chat moderator' };
+ }
+
+ const canUseBot =
+ bot.ownerId === channel.ownerId ||
+ channel.managers.some((manager) => manager.id === bot.ownerId);
+
+ if (!canUseBot) {
+ return { success: false, error: 'Bot owner must be a channel manager or owner' };
+ }
+
+ await prisma.channel.update({
+ where: { id: channelId },
+ data: {
+ chatModeratorBots: {
+ connect: { id: bot.id },
+ },
+ },
+ });
+
+ revalidatePath(`/settings/channel/${channel.name}`);
+ return { success: true };
+}
+
+export async function removeChatBotModerator(channelId: string, botId: string) {
+ const { user } = await validateRequest();
+ if (!user) {
+ return { success: false, error: 'Unauthorized' };
+ }
+
+ const channel = await prisma.channel.findUnique({
+ where: { id: channelId },
+ include: { owner: true, managers: true },
+ });
+
+ if (!channel) {
+ return { success: false, error: 'Channel not found' };
+ }
+
+ if (!can(user, 'update', 'channel', { channel })) {
+ return { success: false, error: 'Unauthorized' };
+ }
+
+ await prisma.channel.update({
+ where: { id: channelId },
+ data: {
+ chatModeratorBots: {
+ disconnect: { id: botId },
+ },
},
});
@@ -355,6 +523,9 @@ export async function removeChannelManager(channelId: string, userId: string) {
managers: {
disconnect: { id: userId },
},
+ chatModerators: {
+ disconnect: { id: userId },
+ },
},
});
diff --git a/packages/db/prisma/migrations/20260221120000_channel_chat_moderators/migration.sql b/packages/db/prisma/migrations/20260221120000_channel_chat_moderators/migration.sql
new file mode 100644
index 0000000..6d6bdc3
--- /dev/null
+++ b/packages/db/prisma/migrations/20260221120000_channel_chat_moderators/migration.sql
@@ -0,0 +1,33 @@
+-- CreateTable
+CREATE TABLE "_ChannelChatModerators" (
+ "A" TEXT NOT NULL,
+ "B" TEXT NOT NULL,
+
+ CONSTRAINT "_ChannelChatModerators_AB_pkey" PRIMARY KEY ("A","B")
+);
+
+-- CreateTable
+CREATE TABLE "_ChannelChatBotModerators" (
+ "A" TEXT NOT NULL,
+ "B" TEXT NOT NULL,
+
+ CONSTRAINT "_ChannelChatBotModerators_AB_pkey" PRIMARY KEY ("A","B")
+);
+
+-- CreateIndex
+CREATE INDEX "_ChannelChatModerators_B_index" ON "_ChannelChatModerators"("B");
+
+-- CreateIndex
+CREATE INDEX "_ChannelChatBotModerators_B_index" ON "_ChannelChatBotModerators"("B");
+
+-- AddForeignKey
+ALTER TABLE "_ChannelChatModerators" ADD CONSTRAINT "_ChannelChatModerators_A_fkey" FOREIGN KEY ("A") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "_ChannelChatModerators" ADD CONSTRAINT "_ChannelChatModerators_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "_ChannelChatBotModerators" ADD CONSTRAINT "_ChannelChatBotModerators_A_fkey" FOREIGN KEY ("A") REFERENCES "BotAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "_ChannelChatBotModerators" ADD CONSTRAINT "_ChannelChatBotModerators_B_fkey" FOREIGN KEY ("B") REFERENCES "Channel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 44848b3..eec8aff 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -30,6 +30,7 @@ model User {
ownedChannels Channel[] @relation("ChannelOwner")
managedChannels Channel[] @relation("ChannelManagers")
+ chatModeratedChannels Channel[] @relation("ChannelChatModerators")
sessions Session[]
streams StreamInfo[]
followers Follow[] @relation("UserFollows")
@@ -62,6 +63,8 @@ model Channel {
owner User @relation("ChannelOwner", fields: [ownerId], references: [id])
ownerId String
managers User[] @relation("ChannelManagers")
+ chatModerators User[] @relation("ChannelChatModerators")
+ chatModeratorBots BotAccount[] @relation("ChannelChatBotModerators")
streamInfo StreamInfo[]
followers Follow[] @relation("ChannelFollowers")
streamKey StreamKey?
@@ -138,6 +141,7 @@ model BotAccount {
pfpUrl String
owner User @relation(fields: [ownerId], references: [id])
ownerId String
+ moderatingChannels Channel[] @relation("ChannelChatBotModerators")
apiKeys BotApiKey[]
createdAt DateTime @default(now())