import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react'; import { Message } from './Message'; import { useChatStore } from '../../stores/chatStore'; import { useSpaceStore, isDmChannel } from '../../stores/spaceStore'; import { useAuthStore } from '../../stores/authStore'; import { useSocialStore } from '../../stores/socialStore'; import { Avatar } from '../ui/Avatar'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { isSelf } from '../../utils/identity'; import { useDelayedLoading } from '../../hooks/useDelayedLoading'; import type { MessageWithUser } from '@backspace/shared'; const EMPTY_MESSAGES: MessageWithUser[] = []; interface MessageListProps { channelId: string; jumpToMessageId?: string | null; onJumpComplete?: () => void; } function isSameGroup(prev: MessageWithUser, curr: MessageWithUser): boolean { if (prev.userId !== curr.userId) return false; const timeDiff = curr.createdAt - prev.createdAt; return timeDiff < 5 * 60 * 1000; // 5 minutes } function formatDateDivider(timestamp: number): string { const date = new Date(timestamp); return date.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }); } function shouldShowDateDivider(prev: MessageWithUser | undefined, curr: MessageWithUser): boolean { if (!prev) return true; const prevDate = new Date(prev.createdAt).toDateString(); const currDate = new Date(curr.createdAt).toDateString(); return prevDate !== currDate; } export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: MessageListProps) { const messages = useChatStore((s) => s.messages.get(channelId)) ?? EMPTY_MESSAGES; const loadMessages = useChatStore((s) => s.loadMessages); const loadMoreMessages = useChatStore((s) => s.loadMoreMessages); const loadMessagesAround = useChatStore((s) => s.loadMessagesAround); const isLoading = useChatStore((s) => s.isLoading); const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true); const ackChannel = useChatStore((s) => s.ackChannel); const saveScrollPosition = useChatStore((s) => s.saveScrollPosition); const bottomRef = useRef(null); const containerRef = useRef(null); const contentRef = useRef(null); const [isNearBottom, setIsNearBottom] = useState(true); const isNearBottomRef = useRef(true); const [isLoadingMore, setIsLoadingMore] = useState(false); const showInitialSkeleton = useDelayedLoading(isLoading && messages.length === 0); const showPaginationSkeleton = useDelayedLoading(isLoadingMore); const prevMessagesLength = useRef(0); const prevChannelIdRef = useRef(channelId); const visibleMsgIdRef = useRef(null); const ackTimerRef = useRef>(); // Permission check: DM channels always allow history; space channels check READ_MESSAGE_HISTORY const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId)); const isDm = isDmChannel(channelId); const canReadHistory = isDm || hasPermissionBit(channelPerms, PermissionBits.READ_MESSAGE_HISTORY); useEffect(() => { if (canReadHistory) { loadMessages(channelId); } }, [channelId, loadMessages, canReadHistory]); // Track the last message ID so the ack re-fires when a temp message is replaced by its server-confirmed ID const lastMessageId = messages.length > 0 ? messages[messages.length - 1]?.id ?? '' : ''; // Ack channel when messages load or when new messages arrive while near bottom useEffect(() => { if (messages.length > 0 && isNearBottom) { clearTimeout(ackTimerRef.current); ackTimerRef.current = setTimeout(() => ackChannel(channelId), 200); } return () => clearTimeout(ackTimerRef.current); }, [channelId, messages.length, lastMessageId, isNearBottom, ackChannel]); // Save scroll anchor (tracked by handleScroll) when leaving a channel, then reset tracking useEffect(() => { const prevId = prevChannelIdRef.current; prevChannelIdRef.current = channelId; // Save or clear the old channel's scroll position if (prevId && prevId !== channelId) { if (visibleMsgIdRef.current) { // User was scrolled up — save the anchor message saveScrollPosition(prevId, visibleMsgIdRef.current); visibleMsgIdRef.current = null; } else { // User was at bottom — clear any stale saved position so we snap to bottom next time const pos = useChatStore.getState().scrollPositions; if (pos.has(prevId)) { const next = new Map(pos); next.delete(prevId); useChatStore.setState({ scrollPositions: next }); } } } prevMessagesLength.current = 0; // If we have a saved position for the incoming channel, don't mark as near-bottom // — this prevents the ResizeObserver from snapping to bottom before the restore rAF fires const willRestore = useChatStore.getState().scrollPositions.has(channelId); setIsNearBottom(!willRestore); isNearBottomRef.current = !willRestore; }, [channelId, saveScrollPosition]); // Handle scrolling: initial load restores position or snaps to bottom, // new messages smooth-scroll if near bottom useEffect(() => { const prev = prevMessagesLength.current; prevMessagesLength.current = messages.length; if (messages.length === 0) return; if (prev === 0) { // Initial load / channel switch — restore to saved message anchor or snap to bottom const savedMsgId = useChatStore.getState().scrollPositions.get(channelId); requestAnimationFrame(() => { const container = containerRef.current; if (!container) return; if (savedMsgId) { const el = document.getElementById(`msg-${savedMsgId}`); if (el) { el.scrollIntoView({ block: 'start' }); const dist = container.scrollHeight - container.scrollTop - container.clientHeight; const near = dist < 5000; setIsNearBottom(near); isNearBottomRef.current = near; return; } } // No saved anchor or message not in cache — snap to bottom container.scrollTop = container.scrollHeight; }); } else if (messages.length > prev && isNearBottom) { // New messages arrived while near bottom — smooth scroll bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); } }, [messages.length, isNearBottom, channelId]); // Auto-scroll when content height grows (embeds/images loading) while near bottom const hasMessages = messages.length > 0; useEffect(() => { const content = contentRef.current; const container = containerRef.current; if (!content || !container) return; const observer = new ResizeObserver(() => { if (isNearBottomRef.current && containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; } }); observer.observe(content); return () => observer.disconnect(); }, [hasMessages, channelId]); // Scroll to bottom when any image/media inside the message list finishes loading. // The `load` event doesn't bubble, but capture-phase listeners on ancestors still fire. // This handles the case ResizeObserver misses due to its own layout-loop suppression. useEffect(() => { const content = contentRef.current; if (!content) return; const handleMediaLoad = () => { if (isNearBottomRef.current && containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; } }; content.addEventListener('load', handleMediaLoad, true); return () => content.removeEventListener('load', handleMediaLoad, true); }, [hasMessages, channelId]); // Jump-to-message: scroll to target and highlight useEffect(() => { if (!jumpToMessageId) return; const scrollToMessage = () => { const el = document.getElementById(`msg-${jumpToMessageId}`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('search-highlight'); setTimeout(() => el.classList.remove('search-highlight'), 2000); onJumpComplete?.(); return true; } return false; }; // Check if the message is already in the cache if (scrollToMessage()) return; // Not in cache — load messages around the target loadMessagesAround(channelId, jumpToMessageId).then(() => { // Wait for React to render the new messages requestAnimationFrame(() => { requestAnimationFrame(() => { scrollToMessage(); }); }); }); }, [jumpToMessageId, channelId, loadMessagesAround, onJumpComplete]); const handleScroll = useCallback(async () => { const container = containerRef.current; if (!container) return; // Check if near bottom const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; const nearBottom = distanceFromBottom < 5000; setIsNearBottom(nearBottom); isNearBottomRef.current = nearBottom; // Track top-visible message for scroll position persistence if (!nearBottom) { const containerTop = container.getBoundingClientRect().top; const msgEls = container.querySelectorAll('[id^="msg-"]'); for (const el of msgEls) { if (el.getBoundingClientRect().bottom > containerTop) { visibleMsgIdRef.current = el.id.replace('msg-', ''); break; } } } else { visibleMsgIdRef.current = null; } // Load more when scrolled to top if (container.scrollTop < 50 && hasMore && !isLoadingMore) { setIsLoadingMore(true); const prevScrollHeight = container.scrollHeight; const loaded = await loadMoreMessages(channelId); if (loaded) { // Maintain scroll position requestAnimationFrame(() => { container.scrollTop = container.scrollHeight - prevScrollHeight; }); } setIsLoadingMore(false); } }, [channelId, hasMore, isLoadingMore, loadMoreMessages]); if (!canReadHistory) { return (
You do not have permission to view message history in this channel
); } if (showInitialSkeleton) { return (
{Array.from({ length: 7 }, (_, i) => (
{i % 2 === 0 && (
)}
))}
); } return (
{showPaginationSkeleton && (
{Array.from({ length: 3 }, (_, i) => (
))}
)} {!hasMore && }
{messages.map((msg, i) => { const prevMsg = messages[i - 1]; const showDate = shouldShowDateDivider(prevMsg, msg); const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg); return ( {showDate && (
{formatDateDivider(msg.createdAt)}
)} ); })}
{!isNearBottom && messages.length > 0 && ( )}
); } function WelcomeHeader({ channelId }: { channelId: string }) { const dmChannels = useSpaceStore((s) => s.dmChannels); const authUser = useAuthStore((s) => s.user); const removeFriend = useSocialStore((s) => s.removeFriend); const friends = useSocialStore((s) => s.friends); const isDm = isDmChannel(channelId); if (isDm) { const dm = dmChannels.find(d => d.id === channelId); const otherUser = dm?.members.find(m => !isSelf(m, authUser)); const displayName = otherUser?.displayName ?? otherUser?.username ?? 'Unknown'; const username = otherUser?.username ?? 'unknown'; const isFriend = otherUser ? friends.some(f => f.id === otherUser.id) : false; return (

{displayName}

This is the beginning of your direct message history with @{username}.

{isFriend && otherUser && (
)}
); } return (

Welcome to the channel!

This is the start of the conversation.

); }