mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
feat: per channel chat moderator
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -418,6 +418,8 @@ export interface User {
|
||||
pfpUrl: string;
|
||||
isBot: boolean;
|
||||
displayName?: string;
|
||||
isPlatformAdmin?: boolean;
|
||||
channelRole?: 'owner' | 'manager' | 'chatModerator' | 'botModerator' | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user