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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 1 addition & 33 deletions apps/web/src/components/chat/message-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}: {
Expand Down
90 changes: 72 additions & 18 deletions apps/web/src/components/chat/message-list.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -24,6 +24,7 @@ interface MessageListProps {
isLoading?: boolean
hasMore?: boolean
onLoadMore?: () => void
isFetchingMore?: boolean
}

function EmptyState({ context }: { context: ChatContext }) {
Expand All @@ -48,7 +49,7 @@ function EmptyState({ context }: { context: ChatContext }) {
<p className="mt-1 text-sm text-muted-foreground">
{context.type === "channel"
? "This is the start of the channel."
: "Send a message to get the conversation going."}
: "Send a message to get started."}
</p>
</div>
)
Expand Down Expand Up @@ -80,10 +81,12 @@ export function MessageList({
isLoading,
hasMore,
onLoadMore,
isFetchingMore,
}: MessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const sentinelRef = useRef<HTMLDivElement>(null)
const isNearBottom = useRef(true)
const prevMessageCount = useRef(messages.length)
const prevNewestId = useRef<string | null>(null)
const [stickyDate, setStickyDate] = useState<string | null>(null)

const handleScroll = useCallback(() => {
Expand All @@ -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 (
Expand Down Expand Up @@ -202,17 +226,47 @@ export function MessageList({
)
})}
{hasMore && (
<div className="flex justify-center py-2">
<button
type="button"
onClick={onLoadMore}
className="text-xs text-muted-foreground hover:text-foreground"
>
Load more
</button>
<div ref={sentinelRef} className="flex justify-center py-3">
{isFetchingMore && (
<Loader2 className="size-5 animate-spin text-muted-foreground" />
)}
</div>
)}
</div>
</div>
)
}

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
}
24 changes: 13 additions & 11 deletions apps/web/src/hooks/use-message-deletion.ts
Original file line number Diff line number Diff line change
@@ -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 }[]
}

Expand All @@ -12,20 +13,22 @@ interface UseMessageDeletionOptions {
channelId: string
}

export function useMessageDeletion<TData extends MessagesQueryData>({
export function useMessageDeletion({
socket,
queryClient,
channelId,
}: UseMessageDeletionOptions) {
const removeMessageFromCache = useCallback(
(messageId: string) => {
queryClient.setQueryData<TData>(["messages", channelId], (old) => {
if (!old) return old
return {
...old,
data: old.data.filter((m) => m.id !== messageId),
} as TData
})
queryClient.setQueryData<InfiniteData<MessagePage>>(
["messages", channelId],
(old) => {
if (!old) return old
return updateMessagesAcrossPages(old, (msgs) =>
msgs.filter((m) => m.id !== messageId)
)
}
)
},
[queryClient, channelId]
)
Expand Down Expand Up @@ -58,7 +61,6 @@ export function useMessageDeletion<TData extends MessagesQueryData>({
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],
})
Expand Down
29 changes: 15 additions & 14 deletions apps/web/src/hooks/use-message-editing.ts
Original file line number Diff line number Diff line change
@@ -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 }[]
}

Expand All @@ -12,27 +13,28 @@ interface UseMessageEditingOptions {
channelId: string
}

export function useMessageEditing<TData extends MessagesQueryData>({
export function useMessageEditing({
socket,
queryClient,
channelId,
}: UseMessageEditingOptions) {
const updateMessageInCache = useCallback(
(messageId: string, content: string, editedAt: string) => {
queryClient.setQueryData<TData>(["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<InfiniteData<MessagePage>>(
["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

Expand All @@ -56,7 +58,6 @@ export function useMessageEditing<TData extends MessagesQueryData>({
(messageId: string, content: string) => {
if (!socket?.connected) return

// Optimistically update
const editedAt = new Date().toISOString()
updateMessageInCache(messageId, content, editedAt)

Expand Down
30 changes: 13 additions & 17 deletions apps/web/src/hooks/use-message-pinning.ts
Original file line number Diff line number Diff line change
@@ -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 }[]
}

Expand All @@ -15,32 +16,30 @@ interface UseMessagePinningOptions {
guildSlug: string
}

export function useMessagePinning<TData extends MessagesQueryData>({
export function useMessagePinning({
socket,
queryClient,
channelId,
guildSlug,
}: UseMessagePinningOptions) {
const updatePinInCache = useCallback(
(messageId: string, pinned: boolean) => {
queryClient.setQueryData<TData>(["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<InfiniteData<MessagePage>>(
["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],
})
},
[queryClient, channelId]
)

// Listen for pin toggled events from other clients
useEffect(() => {
if (!socket) return

Expand All @@ -57,7 +56,6 @@ export function useMessagePinning<TData extends MessagesQueryData>({

const handleTogglePin = useCallback(
async (messageId: string, currentlyPinned: boolean) => {
// Optimistically update
updatePinInCache(messageId, !currentlyPinned)

try {
Expand All @@ -68,11 +66,9 @@ export function useMessagePinning<TData extends MessagesQueryData>({
})

if (!res.ok) {
// Revert on failure
updatePinInCache(messageId, currentlyPinned)
}
} catch {
// Revert on failure
updatePinInCache(messageId, currentlyPinned)
}
},
Expand Down
Loading
Loading