Skip to content
Merged
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
7 changes: 7 additions & 0 deletions apps/api/src/lib/helpers/openapi/message-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ export const messageAuthorSchema = z.object({
image: z.string().nullable(),
})

export const messageReactionSchema = z.object({
emoji: z.string(),
count: z.number().int().nonnegative(),
reactedByCurrentUser: z.boolean(),
})

export const messageWithAuthorSchema = selectMessageSchema.extend({
author: messageAuthorSchema,
mentions: z.array(messageAuthorSchema),
reactions: z.array(messageReactionSchema),
Comment thread
BuckyMcYolo marked this conversation as resolved.
})

export const listMessagesQuerySchema = paginationQuerySchema
Expand Down
47 changes: 45 additions & 2 deletions apps/api/src/lib/queries/messages.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { db } from "@repo/db"
import { message, messageMention, user } from "@repo/db/schema"
import { message, messageMention, messageReaction, user } from "@repo/db/schema"
import { and, count, desc, eq, inArray } from "drizzle-orm"

export async function fetchMessagePage(
channelId: string,
page: number,
perPage: number
perPage: number,
currentUserId: string
) {
const offset = (page - 1) * perPage

Expand Down Expand Up @@ -68,6 +69,18 @@ export async function fetchMessagePage(
)
: []

const reactionRows =
messageIds.length > 0
? await db
.select({
messageId: messageReaction.messageId,
emoji: messageReaction.emoji,
userId: messageReaction.userId,
})
.from(messageReaction)
.where(inArray(messageReaction.messageId, messageIds))
: []

const mentionsByMessageId = new Map<
string,
Array<{
Expand All @@ -78,6 +91,17 @@ export async function fetchMessagePage(
image: string | null
}>
>()
const reactionsByMessageId = new Map<
string,
Map<
string,
{
emoji: string
count: number
reactedByCurrentUser: boolean
}
>
>()

for (const mentionRow of mentionRows) {
const existingMentions = mentionsByMessageId.get(mentionRow.messageId) ?? []
Expand All @@ -91,9 +115,28 @@ export async function fetchMessagePage(
mentionsByMessageId.set(mentionRow.messageId, existingMentions)
}

for (const reactionRow of reactionRows) {
const reactionsByEmoji =
reactionsByMessageId.get(reactionRow.messageId) ?? new Map()
const existingReaction = reactionsByEmoji.get(reactionRow.emoji) ?? {
emoji: reactionRow.emoji,
count: 0,
reactedByCurrentUser: false,
}

existingReaction.count += 1
if (reactionRow.userId === currentUserId) {
existingReaction.reactedByCurrentUser = true
}

reactionsByEmoji.set(reactionRow.emoji, existingReaction)
reactionsByMessageId.set(reactionRow.messageId, reactionsByEmoji)
}

const messagesWithMentions = messages.map((msg) => ({
...msg,
mentions: mentionsByMessageId.get(msg.id) ?? [],
reactions: Array.from(reactionsByMessageId.get(msg.id)?.values() ?? []),
}))

return {
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/routes/v1/channels/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export const listChannelMessages: AppRouteHandler<
ListChannelMessagesRoute
> = async (c) => {
const guild = c.var.guild
const currentUser = c.var.user
const { channelId } = c.req.valid("param")
const { page, perPage } = c.req.valid("query")

Expand All @@ -157,7 +158,7 @@ export const listChannelMessages: AppRouteHandler<
}

return c.json(
await fetchMessagePage(channelId, page, perPage),
await fetchMessagePage(channelId, page, perPage, currentUser.id),
HttpStatusCodes.OK
)
}
2 changes: 1 addition & 1 deletion apps/api/src/routes/v1/dms/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ export const listDMMessages: AppRouteHandler<ListDMMessagesRoute> = async (
}

return c.json(
await fetchMessagePage(ch.id, page, perPage),
await fetchMessagePage(ch.id, page, perPage, currentUser.id),
HttpStatusCodes.OK
)
}
24 changes: 23 additions & 1 deletion apps/realtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import {
markChannelReadPayloadSchema,
presenceSubscribePayloadSchema,
sendMessagePayloadSchema,
toggleMessageReactionPayloadSchema,
userRoom,
} from "@repo/realtime-types"
import { createAdapter } from "@socket.io/redis-adapter"
import { createClient } from "redis"
import { Server, type Socket } from "socket.io"
import { toErrorMessage } from "@/lib/errors"
import { assertUserCanAccessChannel } from "@/services/channel-access"
import { createMessage } from "@/services/messages"
import { createMessage, toggleMessageReaction } from "@/services/messages"
import { buildMessageFanout } from "@/services/notifications"
import {
listOnlineUserIds,
Expand Down Expand Up @@ -331,6 +332,27 @@ io.on("connection", (socket) => {
}
})

socket.on("message:reaction:toggle", async (payload, ack) => {
try {
const parsed = toggleMessageReactionPayloadSchema.parse(payload)
const reactionUpdate = await toggleMessageReaction({
userId: socket.data.user.id,
payload: parsed,
})

socket
.to(channelRoom(parsed.channelId))
.emit("message:reaction:updated", reactionUpdate.update)

ack?.({
ok: true,
update: reactionUpdate.update,
})
} catch (error) {
ack?.({ ok: false, error: toErrorMessage(error) })
}
})

socket.on("channel:mark-read", async (payload, ack) => {
try {
const parsed = markChannelReadPayloadSchema.parse(payload)
Expand Down
98 changes: 96 additions & 2 deletions apps/realtime/src/services/messages.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { db, eq, schema } from "@repo/db"
import type { RealtimeMessage, SendMessagePayload } from "@repo/realtime-types"
import { and, count, db, eq, schema } from "@repo/db"
import type {
RealtimeMessage,
RealtimeMessageReactionUpdated,
SendMessagePayload,
ToggleMessageReactionPayload,
} from "@repo/realtime-types"
import {
type AccessibleChannel,
assertUserCanAccessChannel,
Expand All @@ -10,11 +15,21 @@ type CreateMessageInput = {
payload: SendMessagePayload
}

type ToggleMessageReactionInput = {
userId: string
payload: ToggleMessageReactionPayload
}

export type CreateMessageResult = {
message: RealtimeMessage
channel: AccessibleChannel
}

export type ToggleMessageReactionResult = {
update: RealtimeMessageReactionUpdated
channel: AccessibleChannel
}

export async function createMessage(input: CreateMessageInput) {
const channelRecord = await assertUserCanAccessChannel(
input.userId,
Expand Down Expand Up @@ -84,6 +99,7 @@ export async function createMessage(input: CreateMessageInput) {
image: messageWithAuthor.authorImage,
},
mentions: [],
reactions: [],
}

if (input.payload.nonce) {
Expand All @@ -95,3 +111,81 @@ export async function createMessage(input: CreateMessageInput) {
channel: channelRecord,
} satisfies CreateMessageResult
}

export async function toggleMessageReaction(input: ToggleMessageReactionInput) {
const channelRecord = await assertUserCanAccessChannel(
input.userId,
input.payload.channelId
)

const messageRecord = await db
.select({
id: schema.message.id,
})
.from(schema.message)
.where(
and(
eq(schema.message.id, input.payload.messageId),
eq(schema.message.channelId, input.payload.channelId)
)
)
.limit(1)
.then((rows) => rows[0])

if (!messageRecord) {
throw new Error("Message not found")
}

const nextReactionState = await db.transaction(async (tx) => {
const existingReaction = await tx
.select({ id: schema.messageReaction.id })
.from(schema.messageReaction)
.where(
and(
eq(schema.messageReaction.messageId, input.payload.messageId),
eq(schema.messageReaction.userId, input.userId),
eq(schema.messageReaction.emoji, input.payload.emoji)
)
)
.limit(1)
.then((rows) => rows[0])

if (existingReaction) {
await tx
.delete(schema.messageReaction)
.where(eq(schema.messageReaction.id, existingReaction.id))
return false
}

await tx.insert(schema.messageReaction).values({
messageId: input.payload.messageId,
userId: input.userId,
emoji: input.payload.emoji,
})
return true
})

const reactionCount = await db
.select({ total: count() })
.from(schema.messageReaction)
.where(
and(
eq(schema.messageReaction.messageId, input.payload.messageId),
eq(schema.messageReaction.emoji, input.payload.emoji)
)
)
.limit(1)
.then((rows) => rows[0]?.total ?? 0)
Comment thread
BuckyMcYolo marked this conversation as resolved.

return {
update: {
channelId: input.payload.channelId,
messageId: input.payload.messageId,
emoji: input.payload.emoji,
count: reactionCount,
actorUserId: input.userId,
reactedByActor: nextReactionState,
},
channel: channelRecord,
} satisfies ToggleMessageReactionResult
}
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@tiptap/suggestion": "^3.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"emoji-picker-react": "^4.18.0",
"highlight.js": "^11.11.1",
"lucide-react": "^0.563.0",
"motion": "^12.34.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/date-divider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ interface DateDividerProps {

export function DateDivider({ date }: DateDividerProps) {
return (
<div className="py-2">
<div className="py-3">
<div className="relative flex items-center">
<div className="h-px w-full bg-border/70" />
<span className="absolute left-1/2 -translate-x-1/2 rounded-full border border-border/70 bg-background px-2.5 py-0.5 text-xs font-medium text-muted-foreground">
Expand Down
Loading