diff --git a/src/chat/delete-message.ts b/src/chat/delete-message.ts new file mode 100644 index 00000000..59e4687b --- /dev/null +++ b/src/chat/delete-message.ts @@ -0,0 +1,11 @@ +import { ObjectId } from 'mongodb' +import { collections } from '../database/collections' +import { events } from '../events' + +export async function deleteMessage(id: string): Promise { + await collections.chatMessages.updateOne( + { _id: new ObjectId(id) }, + { $set: { deletedAt: new Date() } }, + ) + events.emit('chat:messageDeleted', { messageId: id }) +} diff --git a/src/chat/get-snapshot.ts b/src/chat/get-snapshot.ts index ce7560a9..0bd53e53 100644 --- a/src/chat/get-snapshot.ts +++ b/src/chat/get-snapshot.ts @@ -1,4 +1,4 @@ -import type { Filter } from 'mongodb' +import type { Filter, WithId } from 'mongodb' import { startOfDay } from 'date-fns' import { collections } from '../database/collections' import type { ChatMessageModel } from '../database/models/chat-message.model' @@ -7,17 +7,17 @@ import { logger } from '../logger' export async function getSnapshot(params?: { limit?: number before?: Date -}): Promise { +}): Promise[]> { logger.debug({ params }, `chat.getSnapshot()`) const limit = params?.limit ?? 20 - let filter: Filter = {} + const baseFilter: Filter = { deletedAt: { $exists: false } } if (params?.before) { - filter = { at: { $lt: params.before } } + baseFilter.at = { $lt: params.before } } const messages = await collections.chatMessages - .find(filter, { sort: { at: -1 }, limit }) + .find(baseFilter, { sort: { at: -1 }, limit }) .toArray() if (messages.length === 0) { @@ -30,6 +30,7 @@ export async function getSnapshot(params?: { const extraSameDayMessages = await collections.chatMessages .find( { + deletedAt: { $exists: false }, at: { $lt: oldest.at, $gte: oldestDayStart, diff --git a/src/chat/index.ts b/src/chat/index.ts index 7cdad69c..88fbcd90 100644 --- a/src/chat/index.ts +++ b/src/chat/index.ts @@ -1,7 +1,9 @@ +import { deleteMessage } from './delete-message' import { getSnapshot } from './get-snapshot' import { send } from './send' export const chat = { + deleteMessage, getSnapshot, send, } as const diff --git a/src/chat/send.ts b/src/chat/send.ts index 81647856..76d8ccf6 100644 --- a/src/chat/send.ts +++ b/src/chat/send.ts @@ -1,3 +1,4 @@ +import type { WithId } from 'mongodb' import { collections } from '../database/collections' import type { ChatMessageModel } from '../database/models/chat-message.model' import { errors } from '../errors' @@ -16,7 +17,7 @@ interface SendParams { const mutex = new Mutex() -export async function send(params: SendParams): Promise { +export async function send(params: SendParams): Promise> { return await mutex.runExclusive(async () => { const escaped = escape(params.body) const { body: originalBody, mentions } = await resolveMentions(escaped) diff --git a/src/database/models/chat-message.model.ts b/src/database/models/chat-message.model.ts index 6646b8cc..f7288dd2 100644 --- a/src/database/models/chat-message.model.ts +++ b/src/database/models/chat-message.model.ts @@ -6,4 +6,5 @@ export interface ChatMessageModel { body: string mentions: SteamId64[] originalBody: string + deletedAt?: Date } diff --git a/src/events.ts b/src/events.ts index 0c399fdc..55ce2e74 100644 --- a/src/events.ts +++ b/src/events.ts @@ -17,11 +17,16 @@ import type { Configuration } from './database/models/configuration-entry.model' import type { MumbleClientStatus } from './mumble/status' import type { ChatMessageModel } from './database/models/chat-message.model' import type { GameSlotId } from './shared/types/game-slot-id' +import type { WithId } from 'mongodb' export interface Events { + 'chat:messageDeleted': { + messageId: string + } + 'chat:messageSent': { - message: ChatMessageModel - previousMessage?: ChatMessageModel | undefined + message: WithId + previousMessage?: WithId | undefined } 'configuration:updated': { diff --git a/src/queue/plugins/sync-clients.ts b/src/queue/plugins/sync-clients.ts index 60eea487..46c0b4df 100644 --- a/src/queue/plugins/sync-clients.ts +++ b/src/queue/plugins/sync-clients.ts @@ -254,6 +254,13 @@ export default fp( }), ) }) + + events.on('chat:messageDeleted', ({ messageId }) => { + app.gateway + .to({ authenticated: true }) + .to({ url: '/' }) + .send(() => ChatMessages.remove(messageId)) + }) }, { name: 'update clients' }, ) diff --git a/src/queue/views/html/chat.css b/src/queue/views/html/chat.css index 16217f1a..9b6fc9ea 100644 --- a/src/queue/views/html/chat.css +++ b/src/queue/views/html/chat.css @@ -125,7 +125,33 @@ } } } + + .delete-btn { + display: none; + background: none; + border: none; + padding: 0 2px; + margin-left: 4px; + cursor: pointer; + color: var(--color-abru-light-50); + vertical-align: middle; + line-height: 1; + + &:hover { + color: var(--color-alert); + } + + svg { + width: 14px; + height: 14px; + } + } } } + + &[data-is-admin] .message-list p.chat-message:hover .delete-btn { + display: inline-flex; + align-items: center; + } } } diff --git a/src/queue/views/html/chat.tsx b/src/queue/views/html/chat.tsx index 63d9eda8..e3e2919c 100644 --- a/src/queue/views/html/chat.tsx +++ b/src/queue/views/html/chat.tsx @@ -1,14 +1,16 @@ import { format, isSameDay, isToday, isYesterday } from 'date-fns' +import type { WithId } from 'mongodb' import { chat } from '../../../chat' import type { ChatMessageModel } from '../../../database/models/chat-message.model' -import { IconLoader3, IconSend2 } from '../../../html/components/icons' +import { IconLoader3, IconSend2, IconX } from '../../../html/components/icons' import { players } from '../../../players' import type { User } from '../../../auth/types/user' import { PlayerRole } from '../../../database/models/player.model' export async function Chat(props: { user?: User | undefined }) { + const isAdmin = props.user?.player.roles.includes(PlayerRole.admin) ?? false return ( -
+
{props.user ? ( <> @@ -65,7 +67,7 @@ function ChatDateSeparator(props: { at: Date }) { ) } -export function ChatMessageList(props: { messages: ChatMessageModel[] }) { +export function ChatMessageList(props: { messages: WithId[] }) { let trigger = <> if (props.messages.length > 0) { trigger = ( @@ -107,8 +109,12 @@ export function ChatMessageList(props: { messages: ChatMessageModel[] }) { ) } +ChatMessages.remove = function (messageId: string) { + return

+} + ChatMessages.append = function (props: { - message: ChatMessageModel + message: WithId previousMessageAt?: Date | undefined }) { return ( @@ -155,13 +161,14 @@ export function ChatPrompt() { ) } -async function ChatMessage(props: { message: ChatMessageModel }) { +async function ChatMessage(props: { message: WithId }) { const author = await players.bySteamId(props.message.author, ['name', 'roles', 'steamId']) const safeAt = format(props.message.at, 'HH:mm') const safeBody = props.message.body const isAdmin = author.roles.includes(PlayerRole.admin) + const messageId = props.message._id.toString() return ( -

+

{safeAt}{' '} : {safeBody} +

) } diff --git a/src/routes/chat/index.ts b/src/routes/chat/index.ts index 7bac0799..e76c54c7 100644 --- a/src/routes/chat/index.ts +++ b/src/routes/chat/index.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { chat } from '../../chat' import { MentionList } from '../../chat/views/html/mention-list' import { collections } from '../../database/collections' +import { PlayerRole } from '../../database/models/player.model' import { ChatMessageList } from '../../queue/views/html/chat' import { routes } from '../../utils/routes' @@ -74,4 +75,19 @@ export default routes(async app => { return reply.status(200).send(MentionList({ players: candidates })) }, ) + .delete( + '/:id', + { + config: { + authorize: [PlayerRole.admin], + }, + schema: { + params: z.object({ id: z.string() }), + }, + }, + async (request, reply) => { + await chat.deleteMessage(request.params.id) + return reply.status(204).send() + }, + ) }) diff --git a/tests/10-queue/10-chat.spec.ts b/tests/10-queue/10-chat.spec.ts index 960cba0a..b3a319ed 100644 --- a/tests/10-queue/10-chat.spec.ts +++ b/tests/10-queue/10-chat.spec.ts @@ -237,6 +237,76 @@ test('mention completion round-trip: complete and send @6v6 @9v9', async ({ user } }) +test('delete button is not visible for non-admin users @6v6 @9v9', async ({ users }) => { + const [admin, nonAdmin] = [users.getAdmin(), users.getNext(u => !u.isAdmin)] + + for (const user of [admin, nonAdmin]) { + const page = await user.page() + await page.goto('/') + await page.getByRole('button', { name: 'Chat' }).click() + } + + const sentence = `delete-btn-test ${Date.now()}` + const adminPage = await admin.page() + await adminPage.getByPlaceholder('Send message...').fill(sentence) + await adminPage.getByRole('button', { name: 'Send message' }).click() + await expect(adminPage.getByPlaceholder('Send message...')).toBeEmpty() + + const nonAdminPage = await nonAdmin.page() + await expect(nonAdminPage.getByText(sentence)).toBeVisible() + const message = nonAdminPage.locator('p.chat-message', { hasText: sentence }) + await message.hover() + await expect(message.getByTitle('Delete message')).not.toBeVisible() +}) + +test('delete button is visible on hover for admin users @6v6 @9v9', async ({ users }) => { + const [admin, nonAdmin] = [users.getAdmin(), users.getNext(u => !u.isAdmin)] + + for (const user of [admin, nonAdmin]) { + const page = await user.page() + await page.goto('/') + await page.getByRole('button', { name: 'Chat' }).click() + } + + const sentence = `delete-btn-admin-test ${Date.now()}` + const nonAdminPage = await nonAdmin.page() + await nonAdminPage.getByPlaceholder('Send message...').fill(sentence) + await nonAdminPage.getByRole('button', { name: 'Send message' }).click() + await expect(nonAdminPage.getByPlaceholder('Send message...')).toBeEmpty() + + const adminPage = await admin.page() + await expect(adminPage.getByText(sentence)).toBeVisible() + const message = adminPage.locator('p.chat-message', { hasText: sentence }) + await message.hover() + await expect(message.getByTitle('Delete message')).toBeVisible() +}) + +test('admin can delete a chat message @6v6 @9v9', async ({ users }) => { + const [admin, nonAdmin] = [users.getAdmin(), users.getNext(u => !u.isAdmin)] + + for (const user of [admin, nonAdmin]) { + const page = await user.page() + await page.goto('/') + await page.getByRole('button', { name: 'Chat' }).click() + } + + const sentence = `delete-me ${Date.now()}` + const nonAdminPage = await nonAdmin.page() + await nonAdminPage.getByPlaceholder('Send message...').fill(sentence) + await nonAdminPage.getByRole('button', { name: 'Send message' }).click() + await expect(nonAdminPage.getByPlaceholder('Send message...')).toBeEmpty() + + const adminPage = await admin.page() + await expect(adminPage.getByText(sentence)).toBeVisible() + const message = adminPage.locator('p.chat-message', { hasText: sentence }) + await message.hover() + adminPage.once('dialog', dialog => dialog.accept()) + await message.getByTitle('Delete message').click() + + await expect(adminPage.getByText(sentence)).not.toBeVisible() + await expect(nonAdminPage.getByText(sentence)).not.toBeVisible() +}) + test('links inside chat messages are clickable @6v6 @9v9', async ({ users }) => { const [sender, receiver] = users.getMany(2)