feat: per channel chat moderator

This commit is contained in:
2026-02-21 16:44:00 +01:00
parent 5fca354c58
commit f4f653614d
10 changed files with 992 additions and 197 deletions

View File

@@ -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 {

View File

@@ -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{' '}
<span className="font-medium text-foreground">{log.actor}</span>
</span>
{log.actorMeta?.isChannelModerator && (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Shield className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
</TooltipTrigger>
<TooltipContent side="top">Channel Mod</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{log.actorMeta?.isPlatformAdmin && (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<ShieldAlert className="h-3.5 w-3.5 text-destructive shrink-0" />
</TooltipTrigger>
<TooltipContent side="top">Platform Admin</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{log.target && (
<>
<ArrowRight className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="text-xs font-medium">{log.target}</span>
{log.targetMeta?.isChannelModerator && (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Shield className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
</TooltipTrigger>
<TooltipContent side="top">Channel Mod</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{log.targetMeta?.isPlatformAdmin && (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<ShieldAlert className="h-3.5 w-3.5 text-destructive shrink-0" />
</TooltipTrigger>
<TooltipContent side="top">
Platform Admin
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</>
)}
@@ -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;

View File

@@ -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]

View File

@@ -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({
</div>
<div className="space-y-3">
{/* Owner */}
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
@@ -685,13 +695,9 @@ export default function ChannelSettingsClient({
<p className="text-sm text-mantle-foreground">Channel Owner</p>
</div>
</div>
<Badge variant="default">
<Shield className="h-3 w-3 mr-1" />
Owner
</Badge>
<ChannelRoleBadges roles={['owner', 'chatModerator']} />
</div>
{/* Managers */}
{channel.managers.map((manager) => {
const personalChannel = channel.managerPersonalChannels.find(
(c) => c?.ownerId === manager.id
@@ -711,26 +717,34 @@ export default function ChannelSettingsClient({
<p className="text-sm text-mantle-foreground">Manager</p>
</div>
</div>
{isOwner && (
<Button
variant="outline"
size="sm"
onClick={async () => {
if (
await confirm({
title: 'Remove Manager',
description: `Are you sure you want to remove ${personalChannel?.name} as a manager? They will no longer be able to stream or moderate this channel.`,
confirmText: 'Remove',
cancelText: 'Cancel',
})
) {
removeChannelManager(channel.id, manager.id);
}
}}
>
<UserMinus className="h-4 w-4" />
</Button>
)}
<div className="flex items-center gap-2">
<ChannelRoleBadges
roles={withPlatformAdmin(
['manager', 'chatModerator'] as const,
manager.isAdmin
)}
/>
{isOwner && (
<Button
variant="outline"
size="sm"
onClick={async () => {
if (
await confirm({
title: 'Remove Manager',
description: `Are you sure you want to remove ${personalChannel?.name} as a manager? They will no longer be able to stream or moderate this channel.`,
confirmText: 'Remove',
cancelText: 'Cancel',
})
) {
removeChannelManager(channel.id, manager.id);
}
}}
>
<UserMinus className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
})}
@@ -828,7 +842,162 @@ export default function ChannelSettingsClient({
chat.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Moderators</h3>
<div className="flex gap-2">
<AddChatModeratorDialog
channelId={channel.id}
existingModerators={[
channel.owner.id,
...channel.managers.map((manager) => manager.id),
...channel.chatModerators.map((moderator) => moderator.id),
]}
/>
<AddChatBotModeratorDialog
channelId={channel.id}
teamBots={channel.teamBotAccounts}
existingBotModerators={channel.chatModeratorBots.map((bot) => bot.id)}
/>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={channel.owner.pfpUrl} />
<AvatarFallback>{channel.owner.slack_id[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{channel.ownerPersonalChannel?.name}</p>
<p className="text-sm text-mantle-foreground">Owner</p>
</div>
</div>
<ChannelRoleBadges
roles={withPlatformAdmin(
['owner', 'chatModerator'] as const,
channel.owner.isAdmin
)}
/>
</div>
{channel.managers.map((manager) => {
const personalChannel = channel.managerPersonalChannels.find(
(candidate) => candidate?.ownerId === manager.id
);
return (
<div
key={manager.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={manager.pfpUrl} />
<AvatarFallback>{personalChannel?.name}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{personalChannel?.name}</p>
<p className="text-sm text-mantle-foreground">Manager</p>
</div>
</div>
<ChannelRoleBadges
roles={withPlatformAdmin(
['manager', 'chatModerator'] as const,
manager.isAdmin
)}
/>
</div>
);
})}
{channel.chatModerators.map((moderator) => {
const personalChannel = channel.chatModeratorPersonalChannels.find(
(candidate) => candidate?.ownerId === moderator.id
);
return (
<div
key={moderator.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={moderator.pfpUrl} />
<AvatarFallback>{personalChannel?.name}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{personalChannel?.name}</p>
<p className="text-sm text-mantle-foreground">Chat moderator</p>
</div>
</div>
<div className="flex items-center gap-2">
<ChannelRoleBadges
roles={withPlatformAdmin(['chatModerator'] as const, moderator.isAdmin)}
/>
<Button
variant="outline"
size="sm"
onClick={() => {
toast.promise(removeChatModerator(channel.id, moderator.id), {
loading: 'Removing moderator...',
success: 'Moderator removed',
error: 'Failed to remove moderator',
});
}}
>
<UserMinus className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
{channel.chatModeratorBots.map((botAccount) => (
<div
key={botAccount.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage src={botAccount.pfpUrl} />
<AvatarFallback>{botAccount.slug[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{botAccount.displayName}</p>
<p className="text-sm text-mantle-foreground">@{botAccount.slug}</p>
</div>
</div>
<div className="flex items-center gap-2">
<ChannelRoleBadges roles={['botModerator']} />
<Button
variant="outline"
size="sm"
onClick={() => {
toast.promise(removeChatBotModerator(channel.id, botAccount.id), {
loading: 'Removing bot moderator...',
success: 'Bot moderator removed',
error: 'Failed to remove bot moderator',
});
}}
>
<UserMinus className="h-4 w-4" />
</Button>
</div>
</div>
))}
{channel.chatModerators.length === 0 &&
channel.chatModeratorBots.length === 0 && (
<p className="text-mantle-foreground text-center py-4">
No extra chat moderators yet.
</p>
)}
</div>
</div>
<Separator />
<UniversalForm
fields={[
{ name: 'channelId', type: 'hidden', value: channel.id, label: 'Channel ID' },
@@ -914,6 +1083,74 @@ export default function ChannelSettingsClient({
);
}
function RoleBadge({
icon: Icon,
label,
className,
}: {
icon: LucideIcon;
label: string;
className: string;
}) {
return (
<Badge variant="outline" className={className}>
<Icon className="h-3 w-3 mr-1" />
{label}
</Badge>
);
}
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 (
<div className="flex items-center gap-1.5 flex-wrap justify-end">
{[...new Set(roles)].map((role) => {
const meta = ROLE_BADGE_META[role];
return (
<RoleBadge key={role} icon={meta.icon} label={meta.label} className={meta.className} />
);
})}
</div>
);
}
function AddManagerDialog({
channelId,
existingManagers,
@@ -966,3 +1203,121 @@ function AddManagerDialog({
</Dialog>
);
}
function AddChatModeratorDialog({
channelId,
existingModerators,
}: {
channelId: string;
existingModerators: string[];
}) {
const [open, setOpen] = useState(false);
const [selectedChannel, setSelectedChannel] = useState('');
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<UserPlus className="h-4 w-4 mr-2" />
Add User Moderator
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add chat moderator</DialogTitle>
<DialogDescription>
Choose a user who should be able to moderate this channel&apos;s chat.
</DialogDescription>
</DialogHeader>
<UserCombobox
onValueChange={(value) => {
setSelectedChannel(value);
}}
filter={existingModerators}
value={selectedChannel}
modal
/>
<DialogFooter>
<Button
disabled={!selectedChannel}
onClick={() => {
toast.promise(addChatModerator(channelId, selectedChannel), {
loading: 'Adding moderator...',
success: 'Moderator added',
error: 'Failed to add moderator',
});
setOpen(false);
setSelectedChannel('');
}}
>
Add Moderator
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Bot className="h-4 w-4 mr-2" />
Add Bot Moderator
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add bot moderator</DialogTitle>
<DialogDescription>
Bots can delete messages, timeout users, and ban users in chat.
</DialogDescription>
</DialogHeader>
<Select value={selectedBotId} onValueChange={setSelectedBotId}>
<SelectTrigger>
<SelectValue placeholder="Select bot" />
</SelectTrigger>
<SelectContent>
{availableBots.map((botAccount) => (
<SelectItem key={botAccount.id} value={botAccount.id}>
{botAccount.displayName} (@{botAccount.slug})
</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button
disabled={!selectedBotId}
onClick={() => {
toast.promise(addChatBotModerator(channelId, selectedBotId), {
loading: 'Adding bot moderator...',
success: 'Bot moderator added',
error: 'Failed to add bot moderator',
});
setOpen(false);
setSelectedBotId('');
}}
>
Add Bot
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<ChannelSettingsClient
channel={{
...channel,
chatModerators: extraChatModerators,
ownerPersonalChannel,
managerPersonalChannels,
chatModeratorPersonalChannels,
followerPersonalChannels,
teamBotAccounts,
}}
isOwner={isOwner}
currentUser={user}

View File

@@ -418,6 +418,8 @@ export interface User {
pfpUrl: string;
isBot: boolean;
displayName?: string;
isPlatformAdmin?: boolean;
channelRole?: 'owner' | 'manager' | 'chatModerator' | 'botModerator' | null;
}
interface Props {

View File

@@ -1,10 +1,24 @@
'use client';
import { ChatModerationCommand, User } from './ChatPanel';
import React, { useState } from 'react';
import { useState } from 'react';
import Image from 'next/image';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Ban, Bot, Clock3, EllipsisVertical, Eraser, Flag, UserRoundCheck } from 'lucide-react';
import {
Ban,
Bot,
Clock3,
Crown,
EllipsisVertical,
Eraser,
Flag,
Shield,
ShieldAlert,
ShieldCheck,
UserRoundCheck,
Wrench,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -22,6 +36,112 @@ import {
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type ChannelRole = NonNullable<User['channelRole']>;
const ROLE_META: Record<ChannelRole, { label: string; icon: LucideIcon; className: string }> = {
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 (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Icon className={cn('size-3.5 shrink-0', className)} />
</TooltipTrigger>
<TooltipContent side="top">{label}</TooltipContent>
</Tooltip>
);
}
function UsernameRow({ user, displayName }: { user?: User; displayName?: string }) {
const role = user?.channelRole ? ROLE_META[user.channelRole] : null;
return (
<TooltipProvider>
<span className="font-semibold text-primary shrink-0 flex items-center gap-1">
{user?.isBot && <TooltipIcon icon={Bot} label="Bot" className="text-muted-foreground" />}
{role && <TooltipIcon icon={role.icon} label={role.label} className={role.className} />}
{user?.isPlatformAdmin && (
<TooltipIcon icon={ShieldAlert} label="Platform Admin" className="text-destructive" />
)}
<span>{displayName}</span>
<span className="font-normal text-muted-foreground select-none">:</span>
</span>
</TooltipProvider>
);
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Report message</DialogTitle>
<DialogDescription>
Message against Hack Club's Code of Conduct? Let us know!
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="text-sm text-muted-foreground rounded-md border p-3 bg-muted/30">
<p className="font-medium text-foreground mb-1">Reported user</p>
<p>{displayName}</p>
<p className="mt-2">{message}</p>
</div>
<div>
<label className="text-sm font-medium">Reason</label>
<Textarea
value={reportReason}
onChange={(e) => onReasonChange(e.target.value)}
placeholder="Describe why this should be reviewed (harassment, hate speech, spam, threats, etc)."
rows={5}
className="mt-2"
/>
<p className="text-xs text-muted-foreground mt-1">Minimum 10 characters.</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={isSubmitting || reportReason.trim().length < 10}>
Submit report
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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 (
<>
<div className="group hover:bg-primary/5 rounded px-2 py-1 -mx-2 transition-colors">
<div className="flex items-start gap-2">
<span className="font-semibold text-primary shrink-0 flex items-center gap-1">
{user?.isBot && <Bot className="size-4 text-muted-foreground" />}
<span>{user?.displayName || user?.username}</span>
</span>
<div className="flex items-start gap-1.5">
<UsernameRow user={user} displayName={displayName} />
<span
lang="en"
className="text-foreground break-words overflow-wrap-anywhere min-w-0 flex-1"
className="text-foreground min-w-0 flex-1"
style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
>
<EmojiRenderer text={message} emojiMap={emojiMap} />
</span>
{hasTargetUser && user ? (
{type === 'message' && user?.id && (
<MessageActionsMenu
user={user}
msgId={msgId}
@@ -116,63 +232,19 @@ export function Message({
onModerationCommand={onModerationCommand}
onOpenReport={() => setReportOpen(true)}
/>
) : null}
)}
</div>
</div>
<Dialog
<ReportDialog
open={reportOpen}
onOpenChange={(open) => {
setReportOpen(open);
if (!open) {
setReportReason('');
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Report message</DialogTitle>
<DialogDescription>
Tell us what happened. Your report helps moderators review abusive behavior.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="text-sm text-muted-foreground rounded-md border p-3 bg-muted/30">
<p className="font-medium text-foreground mb-1">Reported user</p>
<p>{user?.displayName || user?.username}</p>
<p className="mt-2">{message}</p>
</div>
<div>
<label className="text-sm font-medium">Reason</label>
<Textarea
value={reportReason}
onChange={(event) => setReportReason(event.target.value)}
placeholder="Describe why this should be reviewed (harassment, hate speech, spam, threats, etc)."
rows={5}
className="mt-2"
/>
<p className="text-xs text-muted-foreground mt-1">Minimum 10 characters.</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setReportOpen(false)}
disabled={isSubmittingReport}
>
Cancel
</Button>
<Button
onClick={submitReport}
disabled={isSubmittingReport || reportReason.trim().length < 10}
>
Submit report
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
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 (
<DropdownMenu>
@@ -210,120 +292,82 @@ function MessageActionsMenu({
<Flag className="mr-2 h-4 w-4" />
Report user
</DropdownMenuItem>
{canModerateTarget ? (
{canMod && (
<>
<DropdownMenuItem
onClick={() => {
if (!msgId) return;
onModerationCommand?.({ type: 'mod:deleteMessage', msgId });
}}
onClick={() => msgId && runModeration({ type: 'mod:deleteMessage', msgId })}
>
<Eraser className="mr-2 h-4 w-4" />
Delete message
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onModerationCommand?.({
type: 'mod:timeoutUser',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
durationSeconds: 300,
reason: 'Timed out by moderator',
});
}}
>
<DropdownMenuItem onClick={() => timeout(300)}>
<Clock3 className="mr-2 h-4 w-4" />
Timeout 5 min
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onModerationCommand?.({
type: 'mod:timeoutUser',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
durationSeconds: 3600,
reason: 'Timed out by moderator',
});
}}
>
<DropdownMenuItem onClick={() => timeout(3600)}>
<Clock3 className="mr-2 h-4 w-4" />
Timeout 1 hour
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => {
onModerationCommand?.({
onClick={() =>
runModeration({
type: 'mod:banUser',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
targetUsername: displayName,
reason: 'Banned by moderator',
});
}}
})
}
>
<Ban className="mr-2 h-4 w-4" />
Ban user
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onModerationCommand?.({
onClick={() =>
runModeration({
type: 'mod:liftTimeout',
targetUserId: user.id,
targetUsername: user.displayName || user.username,
});
}}
targetUsername: displayName,
})
}
>
<UserRoundCheck className="mr-2 h-4 w-4" />
Lift timeout/ban
</DropdownMenuItem>
</>
) : null}
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function EmojiRenderer({ text, emojiMap }: EmojiRendererProps) {
export function EmojiRenderer({ text, emojiMap }: { text: string; emojiMap: Map<string, string> }) {
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 (
<TooltipProvider key={index}>
<Tooltip delayDuration={250}>
<TooltipProvider>
<>
{text.split(/(:[\w\-+]+:)/g).map((part, i) => {
if (part.match(/^:[\w\-+]+:$/)) {
const name = part.replaceAll(':', '');
const url = emojiMap.get(name);
if (url) {
return (
<Tooltip key={i} delayDuration={250}>
<TooltipTrigger asChild>
<span className="inline-flex items-center align-middle mx-0.5">
<Image
src={emojiUrl}
alt={part}
width={20}
height={20}
className="inline-block"
/>
<Image src={url} alt={part} width={20} height={20} className="inline-block" />
</span>
</TooltipTrigger>
<TooltipContent>{part}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
);
}
}
}
// Preserve text as-is, handling whitespace properly
if (part) {
return <span key={index}>{part}</span>;
}
return null;
})}
</>
return part ? <span key={i}>{part}</span> : null;
})}
</>
</TooltipProvider>
);
}
@@ -338,8 +382,3 @@ interface MessageProps {
channelName: string;
onModerationCommand?: (command: ChatModerationCommand) => void;
}
interface EmojiRendererProps {
text: string;
emojiMap: Map<string, string>;
}

View File

@@ -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 },
},
},
});

View File

@@ -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;

View File

@@ -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())