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 && (
-
-
- Load more
-
+
+ {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,
},