diff --git a/apps/web/src/components/chat/message-item.tsx b/apps/web/src/components/chat/message-item.tsx index 58de255..3fee7d8 100644 --- a/apps/web/src/components/chat/message-item.tsx +++ b/apps/web/src/components/chat/message-item.tsx @@ -25,6 +25,7 @@ import { EmbedCard } from "./embed-card" import { MessageActionBar } from "./message-action-bar" import { AttachmentGrid } from "./message-attachment" import { MessageEditInput } from "./message-edit-input" +import { scrollToMessage } from "./message-list" import { MessageMarkdown } from "./message-markdown" interface MessageItemProps { @@ -46,39 +47,6 @@ function nameInitial(name: string) { return trimmed.length > 0 ? trimmed.charAt(0).toUpperCase() : "?" } -function scrollToMessage(messageId: string) { - const el = document.querySelector( - `[data-message-id="${messageId}"]` - ) as HTMLElement | null - if (!el) return - - const scrollContainer = el.closest( - "[data-message-scroll]" - ) as HTMLElement | null - if (!scrollContainer) return - - const containerRect = scrollContainer.getBoundingClientRect() - const elRect = el.getBoundingClientRect() - const offset = - elRect.top - - containerRect.top - - containerRect.height / 2 + - elRect.height / 2 - - scrollContainer.scrollBy({ top: offset, behavior: "smooth" }) - - el.style.transition = "background-color 0.3s ease" - el.style.backgroundColor = - "color-mix(in oklch, var(--primary) 15%, transparent)" - setTimeout(() => { - el.style.transition = "background-color 1s ease-out" - el.style.backgroundColor = "" - }, 700) - setTimeout(() => { - el.style.transition = "" - }, 2000) -} - function ReplyPreview({ referencedMessage, }: { diff --git a/apps/web/src/components/chat/message-list.tsx b/apps/web/src/components/chat/message-list.tsx index de5574a..6e3d0a8 100644 --- a/apps/web/src/components/chat/message-list.tsx +++ b/apps/web/src/components/chat/message-list.tsx @@ -1,7 +1,7 @@ import { Skeleton } from "@repo/ui/components/skeleton" import { cn } from "@repo/ui/lib/utils" import { differenceInMinutes, isSameDay } from "@repo/utils/date" -import { Hash, User, Users } from "lucide-react" +import { Hash, Loader2, User, Users } from "lucide-react" import { useCallback, useEffect, useRef, useState } from "react" import type { Message } from "@/lib/api-types" import type { MentionCandidate } from "./composer/mention-types" @@ -24,6 +24,7 @@ interface MessageListProps { isLoading?: boolean hasMore?: boolean onLoadMore?: () => void + isFetchingMore?: boolean } function EmptyState({ context }: { context: ChatContext }) { @@ -48,7 +49,7 @@ function EmptyState({ context }: { context: ChatContext }) {

{context.type === "channel" ? "This is the start of the channel." - : "Send a message to get the conversation going."} + : "Send a message to get started."}

) @@ -80,10 +81,12 @@ export function MessageList({ isLoading, hasMore, onLoadMore, + isFetchingMore, }: MessageListProps) { const scrollRef = useRef(null) + const sentinelRef = useRef(null) const isNearBottom = useRef(true) - const prevMessageCount = useRef(messages.length) + const prevNewestId = useRef(null) const [stickyDate, setStickyDate] = useState(null) const handleScroll = useCallback(() => { @@ -99,23 +102,44 @@ export function MessageList({ for (const divider of dividers) { const rect = divider.getBoundingClientRect() - // If the divider's bottom is above the container top, it's scrolled past if (rect.bottom < containerTop + 8) { topDate = (divider as HTMLElement).dataset.dateDivider ?? null - break // first in DOM = visually lowest in flex-col-reverse = just scrolled past + break } } setStickyDate(topDate) }, []) + // Auto-scroll only for new messages (not for older page loads) useEffect(() => { - // Always scroll on initial load (count went from 0 to N), otherwise only if near bottom - if (prevMessageCount.current === 0 || isNearBottom.current) { - scrollRef.current?.scrollTo({ top: 0 }) + const newestId = messages[0]?.id ?? null + if (prevNewestId.current === null || isNearBottom.current) { + if (newestId !== prevNewestId.current) { + scrollRef.current?.scrollTo({ top: 0 }) + } } - prevMessageCount.current = messages.length - }, [messages.length]) + prevNewestId.current = newestId + }, [messages]) + + // IntersectionObserver for infinite scroll + useEffect(() => { + const sentinel = sentinelRef.current + const container = scrollRef.current + if (!sentinel || !container || !hasMore || !onLoadMore) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !isFetchingMore) { + onLoadMore() + } + }, + { root: container, rootMargin: "200px" } + ) + + observer.observe(sentinel) + return () => observer.disconnect() + }, [hasMore, onLoadMore, isFetchingMore]) if (isLoading) { return ( @@ -202,17 +226,47 @@ export function MessageList({ ) })} {hasMore && ( -
- +
+ {isFetchingMore && ( + + )}
)}
) } + +export function scrollToMessage(messageId: string): boolean { + const el = document.querySelector( + `[data-message-id="${messageId}"]` + ) as HTMLElement | null + if (!el) return false + + const scrollContainer = el.closest( + "[data-message-scroll]" + ) as HTMLElement | null + if (!scrollContainer) return false + + const containerRect = scrollContainer.getBoundingClientRect() + const elRect = el.getBoundingClientRect() + const offset = + elRect.top - + containerRect.top - + containerRect.height / 2 + + elRect.height / 2 + + scrollContainer.scrollBy({ top: offset, behavior: "smooth" }) + + el.style.transition = "background-color 0.3s ease" + el.style.backgroundColor = + "color-mix(in oklch, var(--primary) 15%, transparent)" + setTimeout(() => { + el.style.transition = "background-color 1s ease-out" + el.style.backgroundColor = "" + }, 700) + setTimeout(() => { + el.style.transition = "" + }, 2000) + return true +} diff --git a/apps/web/src/hooks/use-message-deletion.ts b/apps/web/src/hooks/use-message-deletion.ts index bc92cae..0a2e7af 100644 --- a/apps/web/src/hooks/use-message-deletion.ts +++ b/apps/web/src/hooks/use-message-deletion.ts @@ -1,8 +1,9 @@ -import type { QueryClient } from "@tanstack/react-query" +import type { InfiniteData, QueryClient } from "@tanstack/react-query" import { useCallback, useEffect } from "react" +import { updateMessagesAcrossPages } from "@/lib/message-cache-utils" import type { AppSocket } from "@/lib/socket" -interface MessagesQueryData { +interface MessagePage { data: { id: string }[] } @@ -12,20 +13,22 @@ interface UseMessageDeletionOptions { channelId: string } -export function useMessageDeletion({ +export function useMessageDeletion({ socket, queryClient, channelId, }: UseMessageDeletionOptions) { const removeMessageFromCache = useCallback( (messageId: string) => { - queryClient.setQueryData(["messages", channelId], (old) => { - if (!old) return old - return { - ...old, - data: old.data.filter((m) => m.id !== messageId), - } as TData - }) + queryClient.setQueryData>( + ["messages", channelId], + (old) => { + if (!old) return old + return updateMessagesAcrossPages(old, (msgs) => + msgs.filter((m) => m.id !== messageId) + ) + } + ) }, [queryClient, channelId] ) @@ -58,7 +61,6 @@ export function useMessageDeletion({ socket.emit("message:delete", { channelId, messageId }, (result) => { if (!result.ok) { console.error("[chat] delete message failed:", result.error) - // Re-fetch to restore state void queryClient.invalidateQueries({ queryKey: ["messages", channelId], }) diff --git a/apps/web/src/hooks/use-message-editing.ts b/apps/web/src/hooks/use-message-editing.ts index cb5caac..2de3bc8 100644 --- a/apps/web/src/hooks/use-message-editing.ts +++ b/apps/web/src/hooks/use-message-editing.ts @@ -1,8 +1,9 @@ -import type { QueryClient } from "@tanstack/react-query" +import type { InfiniteData, QueryClient } from "@tanstack/react-query" import { useCallback, useEffect } from "react" +import { updateMessagesAcrossPages } from "@/lib/message-cache-utils" import type { AppSocket } from "@/lib/socket" -interface MessagesQueryData { +interface MessagePage { data: { id: string; content: string | null; editedAt: string | null }[] } @@ -12,27 +13,28 @@ interface UseMessageEditingOptions { channelId: string } -export function useMessageEditing({ +export function useMessageEditing({ socket, queryClient, channelId, }: UseMessageEditingOptions) { const updateMessageInCache = useCallback( (messageId: string, content: string, editedAt: string) => { - queryClient.setQueryData(["messages", channelId], (old) => { - if (!old) return old - return { - ...old, - data: old.data.map((m) => - m.id === messageId ? { ...m, content, editedAt } : m - ), - } as TData - }) + queryClient.setQueryData>( + ["messages", channelId], + (old) => { + if (!old) return old + return updateMessagesAcrossPages(old, (msgs) => + msgs.map((m) => + m.id === messageId ? { ...m, content, editedAt } : m + ) + ) + } + ) }, [queryClient, channelId] ) - // Listen for message:updated from other clients useEffect(() => { if (!socket) return @@ -56,7 +58,6 @@ export function useMessageEditing({ (messageId: string, content: string) => { if (!socket?.connected) return - // Optimistically update const editedAt = new Date().toISOString() updateMessageInCache(messageId, content, editedAt) diff --git a/apps/web/src/hooks/use-message-pinning.ts b/apps/web/src/hooks/use-message-pinning.ts index b25bad9..e21acde 100644 --- a/apps/web/src/hooks/use-message-pinning.ts +++ b/apps/web/src/hooks/use-message-pinning.ts @@ -1,10 +1,11 @@ import type { RealtimeMessagePinToggled } from "@repo/realtime-types" -import type { QueryClient } from "@tanstack/react-query" +import type { InfiniteData, QueryClient } from "@tanstack/react-query" import { useCallback, useEffect } from "react" import { apiClient } from "@/lib/api-client" +import { updateMessagesAcrossPages } from "@/lib/message-cache-utils" import type { AppSocket } from "@/lib/socket" -interface MessagesQueryData { +interface MessagePage { data: { id: string; pinned: boolean }[] } @@ -15,7 +16,7 @@ interface UseMessagePinningOptions { guildSlug: string } -export function useMessagePinning({ +export function useMessagePinning({ socket, queryClient, channelId, @@ -23,16 +24,15 @@ export function useMessagePinning({ }: UseMessagePinningOptions) { const updatePinInCache = useCallback( (messageId: string, pinned: boolean) => { - queryClient.setQueryData(["messages", channelId], (old) => { - if (!old) return old - return { - ...old, - data: old.data.map((m) => - m.id === messageId ? { ...m, pinned } : m - ), - } as TData - }) - // Invalidate pinned messages panel cache + queryClient.setQueryData>( + ["messages", channelId], + (old) => { + if (!old) return old + return updateMessagesAcrossPages(old, (msgs) => + msgs.map((m) => (m.id === messageId ? { ...m, pinned } : m)) + ) + } + ) void queryClient.invalidateQueries({ queryKey: ["pinned-messages", channelId], }) @@ -40,7 +40,6 @@ export function useMessagePinning({ [queryClient, channelId] ) - // Listen for pin toggled events from other clients useEffect(() => { if (!socket) return @@ -57,7 +56,6 @@ export function useMessagePinning({ const handleTogglePin = useCallback( async (messageId: string, currentlyPinned: boolean) => { - // Optimistically update updatePinInCache(messageId, !currentlyPinned) try { @@ -68,11 +66,9 @@ export function useMessagePinning({ }) if (!res.ok) { - // Revert on failure updatePinInCache(messageId, currentlyPinned) } } catch { - // Revert on failure updatePinInCache(messageId, currentlyPinned) } }, diff --git a/apps/web/src/hooks/use-message-reactions.ts b/apps/web/src/hooks/use-message-reactions.ts index afb8870..d9f9ec6 100644 --- a/apps/web/src/hooks/use-message-reactions.ts +++ b/apps/web/src/hooks/use-message-reactions.ts @@ -1,6 +1,7 @@ import type { RealtimeMessageReactionUpdated } from "@repo/realtime-types" -import type { QueryClient } from "@tanstack/react-query" +import type { InfiniteData, QueryClient } from "@tanstack/react-query" import { useCallback, useEffect, useMemo } from "react" +import { updateMessagesAcrossPages } from "@/lib/message-cache-utils" import { applyReactionUpdateToMessage, toggleReactionOptimistically, @@ -9,7 +10,7 @@ import type { AppSocket } from "@/lib/socket" type MessageWithReactions = Parameters[0] -interface MessagesQueryData { +interface MessagePage { data: MessageWithReactions[] } @@ -21,7 +22,7 @@ interface UseMessageReactionsOptions { currentUserName?: string } -export function useMessageReactions({ +export function useMessageReactions({ socket, queryClient, channelId, @@ -33,15 +34,17 @@ export function useMessageReactions({ 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.setQueryData>( + ["messages", channelId], + (old) => { + if (!old) return old + return updateMessagesAcrossPages(old, (msgs) => + msgs.map((message) => + message.id === messageId ? updater(message) : message + ) + ) } - }) + ) }, [queryClient, channelId] ) diff --git a/apps/web/src/hooks/use-message-sending.ts b/apps/web/src/hooks/use-message-sending.ts index deed312..900f628 100644 --- a/apps/web/src/hooks/use-message-sending.ts +++ b/apps/web/src/hooks/use-message-sending.ts @@ -1,7 +1,8 @@ import type { RealtimeMessageEmbedsUpdated } from "@repo/realtime-types" -import type { QueryClient } from "@tanstack/react-query" +import type { InfiniteData, QueryClient } from "@tanstack/react-query" import { useCallback, useEffect, useRef } from "react" import type { Message } from "@/lib/api-types" +import { updateMessagesAcrossPages } from "@/lib/message-cache-utils" import { createOptimisticMessage, realtimeMessageToMessage, @@ -10,7 +11,7 @@ import type { AppSocket } from "@/lib/socket" type MessageWithRealtimeShape = ReturnType -interface MessagesQueryData { +interface MessagePage { data: MessageWithRealtimeShape[] } @@ -29,7 +30,7 @@ interface UseMessageSendingOptions { currentUser?: MessageSenderUser } -export function useMessageSending({ +export function useMessageSending({ socket, queryClient, channelId, @@ -43,13 +44,31 @@ export function useMessageSending({ messages: MessageWithRealtimeShape[] ) => MessageWithRealtimeShape[] ) => { - queryClient.setQueryData(["messages", channelId], (old) => { - if (!old) return old - return { - ...old, - data: updater(old.data), + queryClient.setQueryData>( + ["messages", channelId], + (old) => { + if (!old) return old + return updateMessagesAcrossPages(old, updater) } - }) + ) + }, + [queryClient, channelId] + ) + + const prependToFirstPage = useCallback( + (message: MessageWithRealtimeShape) => { + queryClient.setQueryData>( + ["messages", channelId], + (old) => { + if (!old) return old + return { + ...old, + pages: old.pages.map((page, i) => + i === 0 ? { ...page, data: [message, ...page.data] } : page + ), + } + } + ) }, [queryClient, channelId] ) @@ -62,27 +81,43 @@ export function useMessageSending({ ) => { if (message.channelId !== channelId) return - updateMessagesInCache((messages) => { - if (message.nonce && pendingNonces.current.has(message.nonce)) { - pendingNonces.current.delete(message.nonce) - return messages.map((m) => + // Check if this is a nonce replacement (our own optimistic message) + if (message.nonce && pendingNonces.current.has(message.nonce)) { + pendingNonces.current.delete(message.nonce) + updateMessagesInCache((messages) => + messages.map((m) => m.id === message.nonce ? realtimeMessageToMessage(message) : m ) - } + ) + return + } - if (messages.some((m) => m.id === message.id)) { - return messages - } + // Check for duplicates across all pages + const allMessages = queryClient.getQueryData>([ + "messages", + channelId, + ]) + if (allMessages) { + const isDuplicate = allMessages.pages.some((page) => + page.data.some((m) => m.id === message.id) + ) + if (isDuplicate) return + } - return [realtimeMessageToMessage(message), ...messages] - }) + prependToFirstPage(realtimeMessageToMessage(message)) } socket.on("message:created", handleMessageCreated) return () => { socket.off("message:created", handleMessageCreated) } - }, [socket, channelId, updateMessagesInCache]) + }, [ + socket, + channelId, + updateMessagesInCache, + prependToFirstPage, + queryClient, + ]) useEffect(() => { if (!socket) return @@ -125,7 +160,7 @@ export function useMessageSending({ image: currentUser.image ?? null, } - updateMessagesInCache((messages) => [ + prependToFirstPage( createOptimisticMessage( nonce, channelId, @@ -134,9 +169,8 @@ export function useMessageSending({ options?.mentions ?? [], options?.referencedMessage ?? undefined, options?.attachments ?? [] - ), - ...messages, - ]) + ) + ) const referencedMessageId = options?.referencedMessage?.id const attachments = options?.attachments ?? undefined @@ -170,7 +204,7 @@ export function useMessageSending({ } ) }, - [socket, currentUser, channelId, updateMessagesInCache] + [socket, currentUser, channelId, prependToFirstPage, updateMessagesInCache] ) return { handleSend } diff --git a/apps/web/src/lib/message-cache-utils.ts b/apps/web/src/lib/message-cache-utils.ts new file mode 100644 index 0000000..1d39bc9 --- /dev/null +++ b/apps/web/src/lib/message-cache-utils.ts @@ -0,0 +1,24 @@ +import type { InfiniteData } from "@tanstack/react-query" + +interface PageWithMessages { + data: T[] +} + +/** + * Applies an updater function to messages across all pages in an infinite query. + * Used by hooks that modify the messages cache (delete, edit, react, pin). + */ +export function updateMessagesAcrossPages< + TPage extends PageWithMessages, +>( + infiniteData: InfiniteData, + updater: (messages: TPage["data"]) => TPage["data"] +): InfiniteData { + return { + ...infiniteData, + pages: infiniteData.pages.map((page) => ({ + ...page, + data: updater(page.data), + })), + } +} diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx index 2e55baf..da0f0f7 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -5,7 +5,11 @@ import { roleHasPermissions, } from "@repo/auth/permissions" import { useIsMobile } from "@repo/ui/hooks/use-mobile" -import { useQuery, useQueryClient } from "@tanstack/react-query" +import { + useInfiniteQuery, + useQuery, + useQueryClient, +} from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" import { useCallback, useEffect, useMemo } from "react" import { useDropzone } from "react-dropzone" @@ -13,7 +17,7 @@ import { ChatSkeleton } from "@/components/chat/chat-skeleton" import { MessageInput } from "@/components/chat/composer/message-input" import { DropZoneOverlay } from "@/components/chat/drop-zone-overlay" import { ChatHeader } from "@/components/chat/header" -import { MessageList } from "@/components/chat/message-list" +import { MessageList, scrollToMessage } from "@/components/chat/message-list" import { TypingIndicator } from "@/components/chat/typing-indicator" import { useRightSidebar } from "@/components/sidebar/right-panel/right-sidebar-context" import { useSocket } from "@/context/socket-context" @@ -28,7 +32,6 @@ import { useMessageSending } from "@/hooks/use-message-sending" import { useReplyState } from "@/hooks/use-reply-state" import { useTypingIndicator } from "@/hooks/use-typing-indicator" import { apiClient } from "@/lib/api-client" -import type { ListMessagesResponse } from "@/lib/api-types" type ChannelSearchParams = { msgId?: string @@ -41,15 +44,6 @@ export const Route = createFileRoute("/_authenticated/$guildSlug/$channelId")({ }), }) -function scrollToMessage(messageId: string) { - const el = document.querySelector(`[data-message-id="${messageId}"]`) - if (!el) return false - el.scrollIntoView({ behavior: "smooth", block: "center" }) - el.classList.add("bg-primary/10") - setTimeout(() => el.classList.remove("bg-primary/10"), 2000) - return true -} - function ChannelView() { const { guildSlug, channelId } = Route.useParams() const { msgId } = Route.useSearch() @@ -101,24 +95,37 @@ function ChannelView() { }, }) - const { data: messagesData, isPending: messagesLoading } = useQuery({ + const { + data: messagesInfinite, + isPending: messagesLoading, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ queryKey: ["messages", channelId], - queryFn: async () => { + queryFn: async ({ pageParam }) => { const res = await apiClient.v1.guilds[":guildSlug"].channels[ ":channelId" ].messages.$get({ param: { guildSlug, channelId }, - query: {}, + query: { page: String(pageParam), perPage: "50" }, }) if (!res.ok) throw new Error("Failed to fetch messages") return res.json() }, + initialPageParam: 1, + getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, enabled: !!data, }) + const messages = useMemo( + () => messagesInfinite?.pages.flatMap((page) => page.data) ?? [], + [messagesInfinite] + ) + // Scroll to a specific message when navigating from search useEffect(() => { - if (!msgId || messagesLoading || !messagesData?.data.length) return + if (!msgId || messagesLoading || !messages.length) return // Give DOM time to render const timer = setTimeout(() => { if (scrollToMessage(msgId)) { @@ -126,7 +133,7 @@ function ChannelView() { } }, 100) return () => clearTimeout(timer) - }, [msgId, messagesLoading, messagesData, navigate]) + }, [msgId, messagesLoading, messages, navigate]) const { data: guildMembersData } = useQuery({ queryKey: ["guild-members", guildSlug], @@ -150,7 +157,7 @@ function ChannelView() { } }, [socket, channelId]) - const { handleReact } = useMessageReactions({ + const { handleReact } = useMessageReactions({ socket, queryClient, channelId, @@ -158,19 +165,19 @@ function ChannelView() { currentUserName: session?.user.name, }) - const { handleDelete } = useMessageDeletion({ + const { handleDelete } = useMessageDeletion({ socket, queryClient, channelId, }) - const { handleEdit } = useMessageEditing({ + const { handleEdit } = useMessageEditing({ socket, queryClient, channelId, }) - const { handleSend } = useMessageSending({ + const { handleSend } = useMessageSending({ socket, queryClient, channelId, @@ -191,7 +198,7 @@ function ChannelView() { isGuildRole(activeMember.role) && roleHasPermissions(activeMember.role as GuildRole, { message: ["pin"] }) - const { handleTogglePin } = useMessagePinning({ + const { handleTogglePin } = useMessagePinning({ socket, queryClient, channelId, @@ -301,7 +308,10 @@ function ChannelView() { /> fetchNextPage()} + isFetchingMore={isFetchingNextPage} currentUserId={currentUserId} blockedUserIds={blockedUserIds} onReact={handleReact} diff --git a/apps/web/src/routes/_authenticated/dms/$dmId.tsx b/apps/web/src/routes/_authenticated/dms/$dmId.tsx index e6414cd..dbbc13d 100644 --- a/apps/web/src/routes/_authenticated/dms/$dmId.tsx +++ b/apps/web/src/routes/_authenticated/dms/$dmId.tsx @@ -1,13 +1,17 @@ import { authClient } from "@repo/auth/client" -import { useQuery, useQueryClient } from "@tanstack/react-query" +import { + useInfiniteQuery, + useQuery, + useQueryClient, +} from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" -import { useCallback, useEffect } from "react" +import { useCallback, useEffect, useMemo } from "react" import { useDropzone } from "react-dropzone" import { ChatSkeleton } from "@/components/chat/chat-skeleton" import { MessageInput } from "@/components/chat/composer/message-input" import { DropZoneOverlay } from "@/components/chat/drop-zone-overlay" import { ChatHeader } from "@/components/chat/header" -import { MessageList } from "@/components/chat/message-list" +import { MessageList, scrollToMessage } from "@/components/chat/message-list" import { TypingIndicator } from "@/components/chat/typing-indicator" import { useSocket } from "@/context/socket-context" import { useAutoMarkRead } from "@/hooks/use-auto-mark-read" @@ -20,7 +24,6 @@ import { useMessageSending } from "@/hooks/use-message-sending" import { useReplyState } from "@/hooks/use-reply-state" import { useTypingIndicator } from "@/hooks/use-typing-indicator" import { apiClient } from "@/lib/api-client" -import type { ListDMMessagesResponse } from "@/lib/api-types" type DMSearchParams = { msgId?: string @@ -33,15 +36,6 @@ export const Route = createFileRoute("/_authenticated/dms/$dmId")({ }), }) -function scrollToMessage(messageId: string) { - const el = document.querySelector(`[data-message-id="${messageId}"]`) - if (!el) return false - el.scrollIntoView({ behavior: "smooth", block: "center" }) - el.classList.add("bg-primary/10") - setTimeout(() => el.classList.remove("bg-primary/10"), 2000) - return true -} - function DMConversation() { const { dmId } = Route.useParams() const { msgId } = Route.useSearch() @@ -62,29 +56,42 @@ function DMConversation() { }, }) - const { data: messagesData, isPending: messagesLoading } = useQuery({ + const { + data: messagesInfinite, + isPending: messagesLoading, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ queryKey: ["messages", dmId], - queryFn: async () => { + queryFn: async ({ pageParam }) => { const res = await apiClient.v1.dms[":dmId"].messages.$get({ param: { dmId }, - query: {}, + query: { page: String(pageParam), perPage: "50" }, }) if (!res.ok) throw new Error("Failed to fetch messages") return res.json() }, + initialPageParam: 1, + getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, enabled: !!dm, }) + const messages = useMemo( + () => messagesInfinite?.pages.flatMap((page) => page.data) ?? [], + [messagesInfinite] + ) + // Scroll to a specific message when navigating from search useEffect(() => { - if (!msgId || messagesLoading || !messagesData?.data.length) return + if (!msgId || messagesLoading || !messages.length) return const timer = setTimeout(() => { if (scrollToMessage(msgId)) { void navigate({ search: {}, replace: true }) } }, 100) return () => clearTimeout(timer) - }, [msgId, messagesLoading, messagesData, navigate]) + }, [msgId, messagesLoading, messages, navigate]) // Join/leave the DM channel room for real-time messages useEffect(() => { @@ -97,7 +104,7 @@ function DMConversation() { } }, [socket, dmId]) - const { handleReact } = useMessageReactions({ + const { handleReact } = useMessageReactions({ socket, queryClient, channelId: dmId, @@ -105,19 +112,19 @@ function DMConversation() { currentUserName: session?.user.name, }) - const { handleDelete } = useMessageDeletion({ + const { handleDelete } = useMessageDeletion({ socket, queryClient, channelId: dmId, }) - const { handleEdit } = useMessageEditing({ + const { handleEdit } = useMessageEditing({ socket, queryClient, channelId: dmId, }) - const { handleSend } = useMessageSending({ + const { handleSend } = useMessageSending({ socket, queryClient, channelId: dmId, @@ -216,7 +223,10 @@ function DMConversation() { fetchNextPage()} + isFetchingMore={isFetchingNextPage} currentUserId={currentUserId} blockedUserIds={blockedUserIds} onReact={handleReact} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index cc2be0a..f18d6a6 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -33,6 +33,9 @@ export default defineConfig(({ mode }) => { "@": resolve(__dirname, "./src"), }, }, + server: { + allowedHosts: true, + }, preview: { allowedHosts: true, },