diff --git a/apps/api/src/lib/helpers/openapi/message-schemas.ts b/apps/api/src/lib/helpers/openapi/message-schemas.ts index de67751..85af24c 100644 --- a/apps/api/src/lib/helpers/openapi/message-schemas.ts +++ b/apps/api/src/lib/helpers/openapi/message-schemas.ts @@ -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), }) export const listMessagesQuerySchema = paginationQuerySchema diff --git a/apps/api/src/lib/queries/messages.ts b/apps/api/src/lib/queries/messages.ts index 3993ce7..3cae024 100644 --- a/apps/api/src/lib/queries/messages.ts +++ b/apps/api/src/lib/queries/messages.ts @@ -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 @@ -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<{ @@ -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) ?? [] @@ -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 { diff --git a/apps/api/src/routes/v1/channels/handlers.ts b/apps/api/src/routes/v1/channels/handlers.ts index 253f56d..b1dff78 100644 --- a/apps/api/src/routes/v1/channels/handlers.ts +++ b/apps/api/src/routes/v1/channels/handlers.ts @@ -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") @@ -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 ) } diff --git a/apps/api/src/routes/v1/dms/handlers.ts b/apps/api/src/routes/v1/dms/handlers.ts index 2dd5c91..be01d52 100644 --- a/apps/api/src/routes/v1/dms/handlers.ts +++ b/apps/api/src/routes/v1/dms/handlers.ts @@ -292,7 +292,7 @@ export const listDMMessages: AppRouteHandler = async ( } return c.json( - await fetchMessagePage(ch.id, page, perPage), + await fetchMessagePage(ch.id, page, perPage, currentUser.id), HttpStatusCodes.OK ) } diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index aaab77c..d9055eb 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -14,6 +14,7 @@ import { markChannelReadPayloadSchema, presenceSubscribePayloadSchema, sendMessagePayloadSchema, + toggleMessageReactionPayloadSchema, userRoom, } from "@repo/realtime-types" import { createAdapter } from "@socket.io/redis-adapter" @@ -21,7 +22,7 @@ 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, @@ -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) diff --git a/apps/realtime/src/services/messages.ts b/apps/realtime/src/services/messages.ts index d5708df..092893f 100644 --- a/apps/realtime/src/services/messages.ts +++ b/apps/realtime/src/services/messages.ts @@ -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, @@ -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, @@ -84,6 +99,7 @@ export async function createMessage(input: CreateMessageInput) { image: messageWithAuthor.authorImage, }, mentions: [], + reactions: [], } if (input.payload.nonce) { @@ -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) + + 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 +} diff --git a/apps/web/package.json b/apps/web/package.json index f42a0ca..e56bff9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/components/chat/date-divider.tsx b/apps/web/src/components/chat/date-divider.tsx index ca35af0..1486793 100644 --- a/apps/web/src/components/chat/date-divider.tsx +++ b/apps/web/src/components/chat/date-divider.tsx @@ -6,7 +6,7 @@ interface DateDividerProps { export function DateDivider({ date }: DateDividerProps) { return ( -
+
diff --git a/apps/web/src/components/chat/emoji-reaction-picker.tsx b/apps/web/src/components/chat/emoji-reaction-picker.tsx index 7191f2d..68a402d 100644 --- a/apps/web/src/components/chat/emoji-reaction-picker.tsx +++ b/apps/web/src/components/chat/emoji-reaction-picker.tsx @@ -9,66 +9,34 @@ import { TooltipContent, TooltipTrigger, } from "@repo/ui/components/tooltip" +import EmojiPicker, { type EmojiClickData, Theme } from "emoji-picker-react" import { Plus, SmilePlus } from "lucide-react" +import { useTheme } from "next-themes" import { useState } from "react" const QUICK_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🙏", "🎉", "👀"] -const EXTENDED_EMOJIS = [ - "😀", - "😄", - "😁", - "😆", - "😊", - "😉", - "😍", - "🥰", - "😘", - "😎", - "🤓", - "🤩", - "😮", - "😢", - "😭", - "😡", - "🤯", - "😱", - "🥳", - "👏", - "🙌", - "🙏", - "💪", - "👀", - "🔥", - "✨", - "⭐", - "💯", - "❤️", - "💔", - "👍", - "👎", - "👌", - "✌️", - "👋", - "🎉", - "✅", - "❌", - "🚀", - "💡", -] - interface EmojiReactionPickerProps { onSelect?: (emoji: string) => void + onOpenChange?: (open: boolean) => void } -export function EmojiReactionPicker({ onSelect }: EmojiReactionPickerProps) { +export function EmojiReactionPicker({ + onSelect, + onOpenChange, +}: EmojiReactionPickerProps) { const [open, setOpen] = useState(false) - const [showExtended, setShowExtended] = useState(false) + const [showFullPicker, setShowFullPicker] = useState(false) + const { resolvedTheme } = useTheme() const handleSelect = (emoji: string) => { onSelect?.(emoji) setOpen(false) - setShowExtended(false) + setShowFullPicker(false) + } + + const handleEmojiClick = (emojiData: EmojiClickData) => { + handleSelect(emojiData.emoji) } return ( @@ -76,8 +44,9 @@ export function EmojiReactionPicker({ onSelect }: EmojiReactionPickerProps) { open={open} onOpenChange={(nextOpen) => { setOpen(nextOpen) + onOpenChange?.(nextOpen) if (!nextOpen) { - setShowExtended(false) + setShowFullPicker(false) } }} > @@ -101,23 +70,20 @@ export function EmojiReactionPicker({ onSelect }: EmojiReactionPickerProps) { event.preventDefault()} > - {showExtended ? ( -
- {EXTENDED_EMOJIS.map((emoji) => ( - - ))} -
+ {showFullPicker ? ( + ) : (
{QUICK_EMOJIS.map((emoji) => ( @@ -134,7 +100,7 @@ export function EmojiReactionPicker({ onSelect }: EmojiReactionPickerProps) { + ))} +
+ )}
) diff --git a/apps/web/src/components/chat/message-list.tsx b/apps/web/src/components/chat/message-list.tsx index ebb6502..b1d8f92 100644 --- a/apps/web/src/components/chat/message-list.tsx +++ b/apps/web/src/components/chat/message-list.tsx @@ -10,6 +10,8 @@ import { MessageItem } from "./message-item" interface MessageListProps { context: ChatContext messages: Message[] + currentUserId?: string + onReact?: (messageId: string, emoji: string) => void isLoading?: boolean hasMore?: boolean onLoadMore?: () => void @@ -57,6 +59,8 @@ const MESSAGE_GROUP_WINDOW_MINUTES = 5 export function MessageList({ context, messages, + currentUserId, + onReact, isLoading, hasMore, onLoadMore, @@ -137,7 +141,12 @@ export function MessageList({ return (
{isDateBoundary && } - +
) })} diff --git a/apps/web/src/hooks/use-message-reactions.ts b/apps/web/src/hooks/use-message-reactions.ts new file mode 100644 index 0000000..3e7d113 --- /dev/null +++ b/apps/web/src/hooks/use-message-reactions.ts @@ -0,0 +1,109 @@ +import type { RealtimeMessageReactionUpdated } from "@repo/realtime-types" +import type { QueryClient } from "@tanstack/react-query" +import { useCallback, useEffect } from "react" +import { + applyReactionUpdateToMessage, + toggleReactionOptimistically, +} from "@/lib/realtime-adapter" +import type { AppSocket } from "@/lib/socket" + +type MessageWithReactions = Parameters[0] + +interface MessagesQueryData { + data: MessageWithReactions[] +} + +interface UseMessageReactionsOptions { + socket: AppSocket | null + queryClient: QueryClient + channelId: string + currentUserId?: string +} + +export function useMessageReactions({ + socket, + queryClient, + channelId, + currentUserId, +}: UseMessageReactionsOptions) { + const updateMessageInCache = useCallback( + ( + messageId: string, + updater: (message: MessageWithReactions) => MessageWithReactions + ) => { + queryClient.setQueryData(["messages", channelId], (old) => { + if (!old) return old + return { + ...old, + data: old.data.map((message) => + message.id === messageId ? updater(message) : message + ), + } + }) + }, + [queryClient, channelId] + ) + + const toggleReactionLocal = useCallback( + (messageId: string, emoji: string) => { + updateMessageInCache(messageId, (message) => + toggleReactionOptimistically(message, emoji) + ) + }, + [updateMessageInCache] + ) + + const applyReactionServerUpdate = useCallback( + (update: RealtimeMessageReactionUpdated) => { + updateMessageInCache(update.messageId, (message) => + applyReactionUpdateToMessage(message, update, currentUserId) + ) + }, + [updateMessageInCache, currentUserId] + ) + + useEffect(() => { + if (!socket) return + + const handleReactionUpdated = (update: RealtimeMessageReactionUpdated) => { + if (update.channelId !== channelId) return + applyReactionServerUpdate(update) + } + + socket.on("message:reaction:updated", handleReactionUpdated) + return () => { + socket.off("message:reaction:updated", handleReactionUpdated) + } + }, [socket, channelId, applyReactionServerUpdate]) + + const handleReact = useCallback( + (messageId: string, emoji: string) => { + if (!socket?.connected || !currentUserId) return + + toggleReactionLocal(messageId, emoji) + + socket.emit( + "message:reaction:toggle", + { channelId, messageId, emoji }, + (result) => { + if (!result.ok) { + console.error("[chat] toggle reaction failed:", result.error) + toggleReactionLocal(messageId, emoji) + return + } + + applyReactionServerUpdate(result.update) + } + ) + }, + [ + socket, + currentUserId, + channelId, + toggleReactionLocal, + applyReactionServerUpdate, + ] + ) + + return { handleReact } +} diff --git a/apps/web/src/hooks/use-message-sending.ts b/apps/web/src/hooks/use-message-sending.ts new file mode 100644 index 0000000..773e76a --- /dev/null +++ b/apps/web/src/hooks/use-message-sending.ts @@ -0,0 +1,136 @@ +import type { QueryClient } from "@tanstack/react-query" +import { useCallback, useEffect, useRef } from "react" +import type { Message } from "@/lib/api-types" +import { + createOptimisticMessage, + realtimeMessageToMessage, +} from "@/lib/realtime-adapter" +import type { AppSocket } from "@/lib/socket" + +type MessageWithRealtimeShape = ReturnType + +interface MessagesQueryData { + data: MessageWithRealtimeShape[] +} + +interface MessageSenderUser { + id: string + name: string + username?: string | null + displayUsername?: string | null + image?: string | null +} + +interface UseMessageSendingOptions { + socket: AppSocket | null + queryClient: QueryClient + channelId: string + currentUser?: MessageSenderUser +} + +export function useMessageSending({ + socket, + queryClient, + channelId, + currentUser, +}: UseMessageSendingOptions) { + const pendingNonces = useRef(new Set()) + + const updateMessagesInCache = useCallback( + ( + updater: ( + messages: MessageWithRealtimeShape[] + ) => MessageWithRealtimeShape[] + ) => { + queryClient.setQueryData(["messages", channelId], (old) => { + if (!old) return old + return { + ...old, + data: updater(old.data), + } + }) + }, + [queryClient, channelId] + ) + + useEffect(() => { + if (!socket) return + + const handleMessageCreated = ( + message: Parameters[0] + ) => { + if (message.channelId !== channelId) return + + updateMessagesInCache((messages) => { + if (message.nonce && pendingNonces.current.has(message.nonce)) { + pendingNonces.current.delete(message.nonce) + return messages.map((m) => + m.id === message.nonce ? realtimeMessageToMessage(message) : m + ) + } + + if (messages.some((m) => m.id === message.id)) { + return messages + } + + return [realtimeMessageToMessage(message), ...messages] + }) + } + + socket.on("message:created", handleMessageCreated) + return () => { + socket.off("message:created", handleMessageCreated) + } + }, [socket, channelId, updateMessagesInCache]) + + const handleSend = useCallback( + (content: string, options?: { mentions: Message["mentions"] }) => { + if (!socket?.connected || !currentUser) return + + const nonce = crypto.randomUUID() + pendingNonces.current.add(nonce) + + const author = { + id: currentUser.id, + name: currentUser.name, + username: currentUser.username ?? null, + displayUsername: currentUser.displayUsername ?? null, + image: currentUser.image ?? null, + } + + updateMessagesInCache((messages) => [ + createOptimisticMessage( + nonce, + channelId, + content, + author, + options?.mentions ?? [] + ), + ...messages, + ]) + + socket.emit("message:send", { channelId, content, nonce }, (result) => { + if (!result.ok) { + console.error("[chat] send failed:", result.error) + pendingNonces.current.delete(nonce) + updateMessagesInCache((messages) => + messages.filter((message) => message.id !== nonce) + ) + return + } + + pendingNonces.current.delete(nonce) + updateMessagesInCache((messages) => + messages.map((message) => + message.id === nonce + ? realtimeMessageToMessage(result.message) + : message + ) + ) + }) + }, + [socket, currentUser, channelId, updateMessagesInCache] + ) + + return { handleSend } +} diff --git a/apps/web/src/lib/realtime-adapter.ts b/apps/web/src/lib/realtime-adapter.ts index 8a937a6..a7a9702 100644 --- a/apps/web/src/lib/realtime-adapter.ts +++ b/apps/web/src/lib/realtime-adapter.ts @@ -1,4 +1,7 @@ -import type { RealtimeMessage } from "@repo/realtime-types" +import type { + RealtimeMessage, + RealtimeMessageReactionUpdated, +} from "@repo/realtime-types" import type { Message, MessageAuthor } from "./api-types" export function realtimeMessageToMessage(rm: RealtimeMessage): Message { @@ -16,6 +19,112 @@ export function realtimeMessageToMessage(rm: RealtimeMessage): Message { pinned: false, editedAt: null, mentions: rm.mentions, + reactions: rm.reactions, + } +} + +export function applyReactionUpdateToMessage( + message: Message, + update: RealtimeMessageReactionUpdated, + currentUserId?: string +): Message { + if (message.id !== update.messageId) { + return message + } + + const existingReactions = message.reactions ?? [] + const reactionIndex = existingReactions.findIndex( + (reaction) => reaction.emoji === update.emoji + ) + const nextReactions = [...existingReactions] + + if (update.count <= 0) { + if (reactionIndex === -1) { + return message + } + + nextReactions.splice(reactionIndex, 1) + return { + ...message, + reactions: nextReactions, + } + } + + const reactedByCurrentUser = + currentUserId && update.actorUserId === currentUserId + ? update.reactedByActor + : ((reactionIndex >= 0 ? nextReactions[reactionIndex] : undefined) + ?.reactedByCurrentUser ?? false) + + const nextReaction = { + emoji: update.emoji, + count: update.count, + reactedByCurrentUser, + } + + if (reactionIndex === -1) { + nextReactions.push(nextReaction) + } else { + nextReactions[reactionIndex] = nextReaction + } + + return { + ...message, + reactions: nextReactions, + } +} + +/** + * Optimistically toggles a reaction for the current user on a single message. + * This mirrors server toggle behavior for immediate UI feedback. + */ +export function toggleReactionOptimistically( + message: Message, + emoji: string +): Message { + const existingReactions = message.reactions ?? [] + const reactionIndex = existingReactions.findIndex( + (reaction) => reaction.emoji === emoji + ) + + if (reactionIndex === -1) { + return { + ...message, + reactions: [ + ...existingReactions, + { emoji, count: 1, reactedByCurrentUser: true }, + ], + } + } + + const nextReactions = [...existingReactions] + const currentReaction = nextReactions[reactionIndex] + if (!currentReaction) { + return message + } + + if (currentReaction.reactedByCurrentUser) { + const nextCount = currentReaction.count - 1 + if (nextCount <= 0) { + nextReactions.splice(reactionIndex, 1) + } else { + nextReactions[reactionIndex] = { + ...currentReaction, + count: nextCount, + reactedByCurrentUser: false, + } + } + } else { + nextReactions[reactionIndex] = { + ...currentReaction, + count: currentReaction.count + 1, + reactedByCurrentUser: true, + } + } + + return { + ...message, + reactions: nextReactions, } } @@ -44,5 +153,6 @@ export function createOptimisticMessage( pinned: false, editedAt: null, mentions, + reactions: [], } } diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx index 45fa2ef..d2d09ba 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -1,19 +1,17 @@ import { authClient } from "@repo/auth/client" import { useQuery, useQueryClient } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" -import { useCallback, useEffect, useMemo, useRef } from "react" +import { useEffect, useMemo } from "react" import { ChatSkeleton } from "@/components/chat/chat-skeleton" import { MessageInput } from "@/components/chat/composer/message-input" import { ChatHeader } from "@/components/chat/header" import { MessageList } from "@/components/chat/message-list" import { useRightSidebar } from "@/components/sidebar/right-panel/right-sidebar-context" import { useSocket } from "@/context/socket-context" +import { useMessageReactions } from "@/hooks/use-message-reactions" +import { useMessageSending } from "@/hooks/use-message-sending" import { apiClient } from "@/lib/api-client" import type { ListMessagesResponse } from "@/lib/api-types" -import { - createOptimisticMessage, - realtimeMessageToMessage, -} from "@/lib/realtime-adapter" export const Route = createFileRoute("/_authenticated/$guildSlug/$channelId")({ component: ChannelView, @@ -25,8 +23,7 @@ function ChannelView() { const queryClient = useQueryClient() const { setView, clearView } = useRightSidebar() const { data: session } = authClient.useSession() - // Track nonces for optimistic messages so we can replace them on confirm - const pendingNonces = useRef(new Set()) + const currentUserId = session?.user.id useEffect(() => { setView({ @@ -89,117 +86,19 @@ function ChannelView() { } }, [socket, channelId]) - // Listen for incoming messages - useEffect(() => { - if (!socket) return - - const handleMessageCreated = ( - msg: Parameters[0] - ) => { - if (msg.channelId !== channelId) return - - queryClient.setQueryData( - ["messages", channelId], - (old) => { - if (!old) return old - // If this message was sent by us, replace the optimistic entry - if (msg.nonce && pendingNonces.current.has(msg.nonce)) { - pendingNonces.current.delete(msg.nonce) - return { - ...old, - data: old.data.map((m) => - m.id === msg.nonce ? realtimeMessageToMessage(msg) : m - ), - } - } - // Otherwise it's from someone else — prepend if not already present - if (old.data.some((m) => m.id === msg.id)) return old - return { - ...old, - data: [realtimeMessageToMessage(msg), ...old.data], - } - } - ) - } - - socket.on("message:created", handleMessageCreated) - return () => { - socket.off("message:created", handleMessageCreated) - } - }, [socket, channelId, queryClient]) - - const handleSend = useCallback( - ( - content: string, - options?: { mentions: ListMessagesResponse["data"][number]["mentions"] } - ) => { - if (!socket?.connected || !session?.user) return - - const nonce = crypto.randomUUID() - pendingNonces.current.add(nonce) - - const author = { - id: session.user.id, - name: session.user.name, - username: session.user.username ?? null, - displayUsername: session.user.displayUsername ?? null, - image: session.user.image ?? null, - } - - // Insert optimistic message immediately - queryClient.setQueryData( - ["messages", channelId], - (old) => { - if (!old) return old - return { - ...old, - data: [ - createOptimisticMessage( - nonce, - channelId, - content, - author, - options?.mentions ?? [] - ), - ...old.data, - ], - } - } - ) - - socket.emit("message:send", { channelId, content, nonce }, (result) => { - if (!result.ok) { - console.error("[chat] send failed:", result.error) - // Remove the optimistic message on failure - pendingNonces.current.delete(nonce) - queryClient.setQueryData( - ["messages", channelId], - (old) => { - if (!old) return old - return { ...old, data: old.data.filter((m) => m.id !== nonce) } - } - ) - return - } + const { handleReact } = useMessageReactions({ + socket, + queryClient, + channelId, + currentUserId, + }) - // Replace optimistic message with the confirmed one - pendingNonces.current.delete(nonce) - queryClient.setQueryData( - ["messages", channelId], - (old) => { - if (!old) return old - return { - ...old, - data: old.data.map((m) => - m.id === nonce ? realtimeMessageToMessage(result.message) : m - ), - } - } - ) - }) - }, - [socket, channelId, queryClient, session] - ) + const { handleSend } = useMessageSending({ + socket, + queryClient, + channelId, + currentUser: session?.user, + }) const mentionCandidates = useMemo( () => [ @@ -255,12 +154,14 @@ function ChannelView() {
diff --git a/apps/web/src/routes/_authenticated/dms/$dmId.tsx b/apps/web/src/routes/_authenticated/dms/$dmId.tsx index 14e52a2..366f9b7 100644 --- a/apps/web/src/routes/_authenticated/dms/$dmId.tsx +++ b/apps/web/src/routes/_authenticated/dms/$dmId.tsx @@ -1,18 +1,16 @@ import { authClient } from "@repo/auth/client" import { useQuery, useQueryClient } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" -import { useCallback, useEffect, useRef } from "react" +import { useEffect } from "react" import { ChatSkeleton } from "@/components/chat/chat-skeleton" import { MessageInput } from "@/components/chat/composer/message-input" import { ChatHeader } from "@/components/chat/header" import { MessageList } from "@/components/chat/message-list" import { useSocket } from "@/context/socket-context" +import { useMessageReactions } from "@/hooks/use-message-reactions" +import { useMessageSending } from "@/hooks/use-message-sending" import { apiClient } from "@/lib/api-client" import type { ListDMMessagesResponse } from "@/lib/api-types" -import { - createOptimisticMessage, - realtimeMessageToMessage, -} from "@/lib/realtime-adapter" export const Route = createFileRoute("/_authenticated/dms/$dmId")({ component: DMConversation, @@ -23,7 +21,7 @@ function DMConversation() { const socket = useSocket() const queryClient = useQueryClient() const { data: session } = authClient.useSession() - const pendingNonces = useRef(new Set()) + const currentUserId = session?.user.id const { data: dm, isPending } = useQuery({ queryKey: ["dms", dmId], @@ -58,116 +56,19 @@ function DMConversation() { } }, [socket, dmId]) - // Listen for incoming messages - useEffect(() => { - if (!socket) return - - const handleMessageCreated = ( - msg: Parameters[0] - ) => { - if (msg.channelId !== dmId) return - - queryClient.setQueryData( - ["messages", dmId], - (old) => { - if (!old) return old - if (msg.nonce && pendingNonces.current.has(msg.nonce)) { - pendingNonces.current.delete(msg.nonce) - return { - ...old, - data: old.data.map((m) => - m.id === msg.nonce ? realtimeMessageToMessage(msg) : m - ), - } - } - if (old.data.some((m) => m.id === msg.id)) return old - return { - ...old, - data: [realtimeMessageToMessage(msg), ...old.data], - } - } - ) - } - - socket.on("message:created", handleMessageCreated) - return () => { - socket.off("message:created", handleMessageCreated) - } - }, [socket, dmId, queryClient]) - - const handleSend = useCallback( - ( - content: string, - options?: { mentions: ListDMMessagesResponse["data"][number]["mentions"] } - ) => { - if (!socket?.connected || !session?.user) return - - const nonce = crypto.randomUUID() - pendingNonces.current.add(nonce) - - const author = { - id: session.user.id, - name: session.user.name, - username: session.user.username ?? null, - displayUsername: session.user.displayUsername ?? null, - image: session.user.image ?? null, - } - - queryClient.setQueryData( - ["messages", dmId], - (old) => { - if (!old) return old - return { - ...old, - data: [ - createOptimisticMessage( - nonce, - dmId, - content, - author, - options?.mentions ?? [] - ), - ...old.data, - ], - } - } - ) - - socket.emit( - "message:send", - { channelId: dmId, content, nonce }, - (result) => { - if (!result.ok) { - console.error("[chat] send failed:", result.error) - pendingNonces.current.delete(nonce) - queryClient.setQueryData( - ["messages", dmId], - (old) => { - if (!old) return old - return { ...old, data: old.data.filter((m) => m.id !== nonce) } - } - ) - return - } + const { handleReact } = useMessageReactions({ + socket, + queryClient, + channelId: dmId, + currentUserId, + }) - pendingNonces.current.delete(nonce) - queryClient.setQueryData( - ["messages", dmId], - (old) => { - if (!old) return old - return { - ...old, - data: old.data.map((m) => - m.id === nonce ? realtimeMessageToMessage(result.message) : m - ), - } - } - ) - } - ) - }, - [socket, dmId, queryClient, session] - ) + const { handleSend } = useMessageSending({ + socket, + queryClient, + channelId: dmId, + currentUser: session?.user, + }) if (isPending) { return @@ -220,12 +121,14 @@ function DMConversation() {
diff --git a/packages/db/src/schemas/index.ts b/packages/db/src/schemas/index.ts index 9641cf7..5362da1 100644 --- a/packages/db/src/schemas/index.ts +++ b/packages/db/src/schemas/index.ts @@ -6,6 +6,7 @@ export * from "./guild-roles" export * from "./guilds" export * from "./invitations" export * from "./message-mentions" +export * from "./message-reactions" export * from "./messages" export * from "./notification-events" export * from "./sessions" diff --git a/packages/db/src/schemas/message-reactions.ts b/packages/db/src/schemas/message-reactions.ts new file mode 100644 index 0000000..62acb69 --- /dev/null +++ b/packages/db/src/schemas/message-reactions.ts @@ -0,0 +1,48 @@ +import { relations } from "drizzle-orm" +import { + index, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core" +import { message } from "./messages" +import { user } from "./users" + +export const messageReaction = pgTable( + "message_reaction", + { + id: uuid("id").defaultRandom().primaryKey(), + createdAt: timestamp("created_at").defaultNow().notNull(), + messageId: uuid("message_id") + .notNull() + .references(() => message.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + emoji: text("emoji").notNull(), + }, + (table) => [ + uniqueIndex("messageReaction_message_user_emoji_uidx").on( + table.messageId, + table.userId, + table.emoji + ), + index("messageReaction_message_idx").on(table.messageId), + ] +) + +export const messageReactionRelations = relations( + messageReaction, + ({ one }) => ({ + message: one(message, { + fields: [messageReaction.messageId], + references: [message.id], + }), + user: one(user, { + fields: [messageReaction.userId], + references: [user.id], + }), + }) +) diff --git a/packages/realtime-types/src/events.ts b/packages/realtime-types/src/events.ts index 0fa6021..c8b60a0 100644 --- a/packages/realtime-types/src/events.ts +++ b/packages/realtime-types/src/events.ts @@ -17,6 +17,12 @@ export const sendMessagePayloadSchema = z.object({ nonce: z.string().max(100).optional(), }) +export const toggleMessageReactionPayloadSchema = z.object({ + channelId: z.string().uuid(), + messageId: z.string().uuid(), + emoji: z.string().trim().min(1).max(64), +}) + export const markChannelReadPayloadSchema = z.object({ channelId: z.string().uuid(), lastReadMessageId: z.string().uuid().optional(), @@ -24,6 +30,9 @@ export const markChannelReadPayloadSchema = z.object({ export type ChannelRoomPayload = z.infer export type SendMessagePayload = z.infer +export type ToggleMessageReactionPayload = z.infer< + typeof toggleMessageReactionPayloadSchema +> export type MarkChannelReadPayload = z.infer< typeof markChannelReadPayloadSchema > @@ -52,6 +61,12 @@ export type RealtimeMessageMention = { image: string | null } +export type RealtimeMessageReaction = { + emoji: string + count: number + reactedByCurrentUser: boolean +} + export type RealtimeMessage = { id: string channelId: string @@ -68,13 +83,27 @@ export type RealtimeMessage = { image: string | null } mentions: RealtimeMessageMention[] + reactions: RealtimeMessageReaction[] nonce?: string } +export type RealtimeMessageReactionUpdated = { + channelId: string + messageId: string + emoji: string + count: number + actorUserId: string + reactedByActor: boolean +} + export type SendMessageAck = ( result: { ok: true; message: RealtimeMessage } | ErrorResult ) => void +export type ToggleMessageReactionAck = ( + result: { ok: true; update: RealtimeMessageReactionUpdated } | ErrorResult +) => void + export type ChannelReadState = { channelId: string lastReadMessageId: string | null @@ -126,6 +155,10 @@ export interface ClientToServerEvents { "channel:join": (payload: ChannelRoomPayload, ack?: JoinLeaveAck) => void "channel:leave": (payload: ChannelRoomPayload, ack?: JoinLeaveAck) => void "message:send": (payload: SendMessagePayload, ack?: SendMessageAck) => void + "message:reaction:toggle": ( + payload: ToggleMessageReactionPayload, + ack?: ToggleMessageReactionAck + ) => void "channel:mark-read": ( payload: MarkChannelReadPayload, ack?: MarkChannelReadAck @@ -142,6 +175,7 @@ export interface ServerToClientEvents { }) => void "presence:user:update": (payload: PresenceUserUpdate) => void "message:created": (payload: RealtimeMessage) => void + "message:reaction:updated": (payload: RealtimeMessageReactionUpdated) => void "notification:unread": (payload: UnreadNotification) => void "notification:mention": (payload: MentionNotification) => void "channel:read-state": (payload: ChannelReadState) => void diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a2c9a3..6a65138 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + emoji-picker-react: + specifier: ^4.18.0 + version: 4.18.0(react@19.2.4) highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -3442,6 +3445,12 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + emoji-picker-react@4.18.0: + resolution: {integrity: sha512-vLTrLfApXAIciguGE57pXPWs9lPLBspbEpPMiUq03TIli2dHZBiB+aZ0R9/Wat0xmTfcd4AuEzQgSYxEZ8C88Q==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3620,6 +3629,9 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + flairup@1.0.0: + resolution: {integrity: sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -8018,6 +8030,11 @@ snapshots: electron-to-chromium@1.5.286: {} + emoji-picker-react@4.18.0(react@19.2.4): + dependencies: + flairup: 1.0.0 + react: 19.2.4 + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -8312,6 +8329,8 @@ snapshots: mlly: 1.8.0 rollup: 4.57.1 + flairup@1.0.0: {} + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0