Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/chat/delete-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ObjectId } from 'mongodb'
import { collections } from '../database/collections'
import { events } from '../events'

export async function deleteMessage(id: string): Promise<void> {
await collections.chatMessages.updateOne(
{ _id: new ObjectId(id) },
{ $set: { deletedAt: new Date() } },
)
events.emit('chat:messageDeleted', { messageId: id })
}
11 changes: 6 additions & 5 deletions src/chat/get-snapshot.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -7,17 +7,17 @@ import { logger } from '../logger'
export async function getSnapshot(params?: {
limit?: number
before?: Date
}): Promise<ChatMessageModel[]> {
}): Promise<WithId<ChatMessageModel>[]> {
logger.debug({ params }, `chat.getSnapshot()`)
const limit = params?.limit ?? 20

let filter: Filter<ChatMessageModel> = {}
const baseFilter: Filter<ChatMessageModel> = { 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) {
Expand All @@ -30,6 +30,7 @@ export async function getSnapshot(params?: {
const extraSameDayMessages = await collections.chatMessages
.find(
{
deletedAt: { $exists: false },
at: {
$lt: oldest.at,
$gte: oldestDayStart,
Expand Down
2 changes: 2 additions & 0 deletions src/chat/index.ts
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion src/chat/send.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -16,7 +17,7 @@ interface SendParams {

const mutex = new Mutex()

export async function send(params: SendParams): Promise<ChatMessageModel> {
export async function send(params: SendParams): Promise<WithId<ChatMessageModel>> {
return await mutex.runExclusive(async () => {
const escaped = escape(params.body)
const { body: originalBody, mentions } = await resolveMentions(escaped)
Expand Down
1 change: 1 addition & 0 deletions src/database/models/chat-message.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export interface ChatMessageModel {
body: string
mentions: SteamId64[]
originalBody: string
deletedAt?: Date
}
9 changes: 7 additions & 2 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatMessageModel>
previousMessage?: WithId<ChatMessageModel> | undefined
}

'configuration:updated': {
Expand Down
7 changes: 7 additions & 0 deletions src/queue/plugins/sync-clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
)
26 changes: 26 additions & 0 deletions src/queue/views/html/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
30 changes: 24 additions & 6 deletions src/queue/views/html/chat.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div class="chat" id="chat">
<div class="chat" id="chat" data-is-admin={isAdmin ? '' : undefined}>
{props.user ? (
<>
<ChatMessages />
Expand Down Expand Up @@ -65,7 +67,7 @@ function ChatDateSeparator(props: { at: Date }) {
)
}

export function ChatMessageList(props: { messages: ChatMessageModel[] }) {
export function ChatMessageList(props: { messages: WithId<ChatMessageModel>[] }) {
let trigger = <></>
if (props.messages.length > 0) {
trigger = (
Expand Down Expand Up @@ -107,8 +109,12 @@ export function ChatMessageList(props: { messages: ChatMessageModel[] }) {
)
}

ChatMessages.remove = function (messageId: string) {
return <p id={`msg-${messageId}`} hx-swap-oob="delete"></p>
}

ChatMessages.append = function (props: {
message: ChatMessageModel
message: WithId<ChatMessageModel>
previousMessageAt?: Date | undefined
}) {
return (
Expand Down Expand Up @@ -155,13 +161,14 @@ export function ChatPrompt() {
)
}

async function ChatMessage(props: { message: ChatMessageModel }) {
async function ChatMessage(props: { message: WithId<ChatMessageModel> }) {
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 (
<p>
<p class="chat-message" id={`msg-${messageId}`}>
<span class="at">{safeAt}</span>{' '}
<a
href={`/players/${author.steamId}`}
Expand All @@ -172,6 +179,17 @@ async function ChatMessage(props: { message: ChatMessageModel }) {
{author.name}
</a>
: <span class="body">{safeBody}</span>
<button
class="delete-btn"
hx-delete={`/chat/${messageId}`}
hx-target="closest p"
hx-swap="delete"
hx-confirm="Delete this message?"
title="Delete message"
>
<IconX />
<span class="sr-only">Delete message</span>
</button>
</p>
)
}
16 changes: 16 additions & 0 deletions src/routes/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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()
},
)
})
70 changes: 70 additions & 0 deletions tests/10-queue/10-chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading