diff --git a/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx b/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx index eea8bade89..b63eb97e06 100644 --- a/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx +++ b/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx @@ -28,12 +28,22 @@ jest.mock('@/components/brain/left-sidebar/waves/UnifiedWavesListWaves', () => { }; }); -type DeviceInfo = { isApp: boolean; isMobileDevice: boolean; hasTouchScreen: boolean }; +type DeviceInfo = { + isApp: boolean; + isMobileDevice: boolean; + hasTouchScreen: boolean; + isAppleMobile: boolean; +}; const useDeviceInfoMock = useDeviceInfo as jest.MockedFunction; beforeEach(() => { sentinel = null; - useDeviceInfoMock.mockReturnValue({ isApp: false, isMobileDevice: false, hasTouchScreen: false } as DeviceInfo); + useDeviceInfoMock.mockReturnValue({ + isApp: false, + isMobileDevice: false, + hasTouchScreen: false, + isAppleMobile: false, + } as DeviceInfo); }); afterEach(() => { @@ -59,7 +69,12 @@ describe('UnifiedWavesList', () => { }); it('triggers fetchNextPage when sentinel intersects', () => { - useDeviceInfoMock.mockReturnValue({ isApp: true, isMobileDevice: false, hasTouchScreen: false } as DeviceInfo); + useDeviceInfoMock.mockReturnValue({ + isApp: true, + isMobileDevice: false, + hasTouchScreen: false, + isAppleMobile: false, + } as DeviceInfo); const fetchNextPage = jest.fn(); const observerInstances: any[] = []; (global as any).IntersectionObserver = class { diff --git a/__tests__/components/header/HeaderSearchModal.test.tsx b/__tests__/components/header/HeaderSearchModal.test.tsx index 215d1d6c93..cde6998c56 100644 --- a/__tests__/components/header/HeaderSearchModal.test.tsx +++ b/__tests__/components/header/HeaderSearchModal.test.tsx @@ -142,6 +142,7 @@ function setup(options: SetupOptions = {}) { isApp: false, isMobileDevice: false, hasTouchScreen: false, + isAppleMobile: false, }); useAppWalletsMock.mockReturnValue({ appWalletsSupported: true }); useCookieConsentMock.mockReturnValue({ country: "US" }); diff --git a/__tests__/components/waves/drops/WaveDropsAll.test.tsx b/__tests__/components/waves/drops/WaveDropsAll.test.tsx index e2cf9b33dd..51bec53e05 100644 --- a/__tests__/components/waves/drops/WaveDropsAll.test.tsx +++ b/__tests__/components/waves/drops/WaveDropsAll.test.tsx @@ -35,6 +35,15 @@ jest.mock('@/components/notifications/NotificationsContext'); jest.mock('@/components/auth/Auth'); jest.mock('@/services/api/common-api'); jest.mock('next/navigation'); +jest.mock('@/hooks/useDeviceInfo', () => ({ + __esModule: true, + default: jest.fn(() => ({ + isAppleMobile: false, + isMobileDevice: false, + hasTouchScreen: false, + isApp: false + })) +})); // Mock components with proper prop capturing let containerProps: any; @@ -65,7 +74,14 @@ jest.mock('@/components/waves/drops/WaveDropsScrollBottomButton', () => ({ __esModule: true, WaveDropsScrollBottomButton: (props: any) => { scrollButtonProps = props; - return ); }; diff --git a/components/waves/drops/wave-drops-all/index.tsx b/components/waves/drops/wave-drops-all/index.tsx index 851e9db1c1..8594ad3756 100644 --- a/components/waves/drops/wave-drops-all/index.tsx +++ b/components/waves/drops/wave-drops-all/index.tsx @@ -1,12 +1,13 @@ "use client"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/components/auth/Auth"; import { useNotificationsContext } from "@/components/notifications/NotificationsContext"; import { getWaveRoute } from "@/helpers/navigation.helpers"; import { Drop, DropSize, ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { isWaveDirectMessage } from "@/helpers/waves/wave.helpers"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; import { useScrollBehavior } from "@/hooks/useScrollBehavior"; import { useVirtualizedWaveDrops } from "@/hooks/useVirtualizedWaveDrops"; import { useWaveIsTyping } from "@/hooks/useWaveIsTyping"; @@ -54,6 +55,7 @@ const WaveDropsAll: React.FC = ({ const router = useRouter(); const { removeWaveDeliveredNotifications } = useNotificationsContext(); const { connectedProfile } = useAuth(); + const { isAppleMobile } = useDeviceInfo(); const containerRef = useRef(null); const { waveMessages, fetchNextPage, waitAndRevealDrop } = @@ -65,6 +67,13 @@ const WaveDropsAll: React.FC = ({ ); const scrollBehavior = useScrollBehavior(); + const { + scrollContainerRef, + bottomAnchorRef, + isAtBottom, + shouldPinToBottom, + scrollToVisualBottom, + } = scrollBehavior; useWaveDropsNotificationRead({ waveId, @@ -81,6 +90,81 @@ const WaveDropsAll: React.FC = ({ drops: dropsForClipboard, }); + const [visibleLatestSerial, setVisibleLatestSerial] = useState( + null + ); + + useEffect(() => { + setVisibleLatestSerial(null); + }, [waveId]); + + const latestSerialNo = waveMessages?.drops?.[0]?.serial_no ?? null; + + useEffect(() => { + if (latestSerialNo === null) { + return; + } + + setVisibleLatestSerial((current) => { + if (current === null) { + return latestSerialNo; + } + + if (!isAppleMobile) { + return latestSerialNo; + } + + if (shouldPinToBottom) { + return latestSerialNo; + } + + return current; + }); + }, [latestSerialNo, isAppleMobile, shouldPinToBottom]); + + const renderedWaveMessages = useMemo(() => { + if (!waveMessages) { + return waveMessages; + } + + if (!isAppleMobile || visibleLatestSerial === null) { + return waveMessages; + } + + const filteredDrops = waveMessages.drops.filter((drop) => { + if (typeof drop.serial_no !== "number") { + return true; + } + return drop.serial_no <= visibleLatestSerial; + }); + + if (filteredDrops.length === waveMessages.drops.length) { + return waveMessages; + } + + return { + ...waveMessages, + drops: filteredDrops, + }; + }, [waveMessages, isAppleMobile, visibleLatestSerial]); + + const pendingDropsCount = useMemo(() => { + if ( + !isAppleMobile || + !waveMessages?.drops?.length || + visibleLatestSerial === null + ) { + return 0; + } + + return waveMessages.drops.reduce((count, drop) => { + if (typeof drop.serial_no !== "number") { + return count; + } + return drop.serial_no > visibleLatestSerial ? count + 1 : count; + }, 0); + }, [isAppleMobile, waveMessages?.drops, visibleLatestSerial]); + const { serialTarget, queueSerialTarget, @@ -93,11 +177,21 @@ const WaveDropsAll: React.FC = ({ waveMessages, fetchNextPage, waitAndRevealDrop, - scrollContainerRef: scrollBehavior.scrollContainerRef, - shouldPinToBottom: scrollBehavior.shouldPinToBottom, - scrollToVisualBottom: scrollBehavior.scrollToVisualBottom, + scrollContainerRef, + shouldPinToBottom, + scrollToVisualBottom, }); + const revealPendingDrops = useCallback(() => { + if (!waveMessages?.drops?.length) { + return; + } + + const newestSerial = waveMessages.drops[0].serial_no; + setVisibleLatestSerial(newestSerial); + scrollToVisualBottom(); + }, [waveMessages?.drops, scrollToVisualBottom]); + const handleTopIntersection = useCallback(async () => { if ( waveMessages?.hasNextPage && @@ -151,10 +245,10 @@ const WaveDropsAll: React.FC = ({ ref={containerRef} className="tw-flex tw-flex-col tw-h-full tw-justify-end tw-relative tw-overflow-y-auto tw-bg-iron-950"> = ({ serialTarget={serialTarget} targetDropRef={targetDropRef} onQuoteClick={handleQuoteClick} - isAtBottom={scrollBehavior.isAtBottom} - scrollToBottom={scrollBehavior.scrollToVisualBottom} + isAtBottom={isAtBottom} + scrollToBottom={scrollToVisualBottom} typingMessage={typingMessage} onDropContentClick={onDropContentClick} + pendingCount={pendingDropsCount} + onRevealPending={revealPendingDrops} /> diff --git a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx index cacf55a2f0..3011b4d976 100644 --- a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx +++ b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx @@ -41,6 +41,8 @@ interface WaveDropsContentProps { readonly scrollToBottom: () => void; readonly typingMessage: string | null; readonly onDropContentClick?: (drop: ExtendedDrop) => void; + readonly pendingCount: number; + readonly onRevealPending: () => void; } export const WaveDropsContent: React.FC = ({ @@ -60,6 +62,8 @@ export const WaveDropsContent: React.FC = ({ scrollToBottom, typingMessage, onDropContentClick, + pendingCount, + onRevealPending, }) => { const dropsCount = waveMessages?.drops?.length ?? 0; const isInitialLoading = @@ -99,6 +103,8 @@ export const WaveDropsContent: React.FC = ({ isAtBottom={isAtBottom} scrollToBottom={scrollToBottom} onDropContentClick={onDropContentClick} + pendingCount={pendingCount} + onRevealPending={onRevealPending} /> diff --git a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsMessageListSection.tsx b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsMessageListSection.tsx index 3e28875fe3..2a0f5f94f1 100644 --- a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsMessageListSection.tsx +++ b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsMessageListSection.tsx @@ -37,6 +37,8 @@ interface WaveDropsMessageListSectionProps { readonly isAtBottom: boolean; readonly scrollToBottom: () => void; readonly onDropContentClick?: (drop: ExtendedDrop) => void; + readonly pendingCount: number; + readonly onRevealPending: () => void; } const MIN_DROPS_FOR_PAGINATION = 25; @@ -59,6 +61,8 @@ export const WaveDropsMessageListSection: React.FC< isAtBottom, scrollToBottom, onDropContentClick, + pendingCount, + onRevealPending, }) => { const hasNextPage = !!waveMessages?.hasNextPage && @@ -94,6 +98,8 @@ export const WaveDropsMessageListSection: React.FC< ); diff --git a/hooks/useDeviceInfo.ts b/hooks/useDeviceInfo.ts index 14f7784c0f..f63cafd8e6 100644 --- a/hooks/useDeviceInfo.ts +++ b/hooks/useDeviceInfo.ts @@ -7,6 +7,7 @@ interface DeviceInfo { readonly isMobileDevice: boolean; readonly hasTouchScreen: boolean; readonly isApp: boolean; + readonly isAppleMobile: boolean; } /** @@ -23,8 +24,15 @@ export default function useDeviceInfo(): DeviceInfo { const { isCapacitor } = useCapacitor(); const getInfo = useCallback((): DeviceInfo => { - if (typeof window === "undefined" || typeof navigator === "undefined") - return { isMobileDevice: false, hasTouchScreen: false, isApp: false }; + if (typeof window === "undefined" || typeof navigator === "undefined") { + const info: DeviceInfo = { + isMobileDevice: false, + hasTouchScreen: false, + isApp: false, + isAppleMobile: false, + }; + return info; + } const win = window as any; const nav = navigator as Navigator & { @@ -43,13 +51,20 @@ export default function useDeviceInfo(): DeviceInfo { const classicMobile = /Android|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua); const iPadDesktopUA = ua.includes("Macintosh") && hasTouchScreen; + const appleMobile = /(iPhone|iPad|iPod)/i.test(ua) || iPadDesktopUA; const widthMobile = win.matchMedia("(max-width: 768px)").matches; const isMobileDevice = uaDataMobile ?? (classicMobile || (isCapacitor && (iPadDesktopUA || widthMobile))); - return { isMobileDevice, hasTouchScreen, isApp: isCapacitor }; + const info: DeviceInfo = { + isMobileDevice, + hasTouchScreen, + isApp: isCapacitor, + isAppleMobile: appleMobile, + }; + return info; }, [isCapacitor]); const [info, setInfo] = useState(() => getInfo()); diff --git a/hooks/useScrollBehavior.ts b/hooks/useScrollBehavior.ts index 2dafe22d32..2f8af200ad 100644 --- a/hooks/useScrollBehavior.ts +++ b/hooks/useScrollBehavior.ts @@ -78,35 +78,76 @@ export const useScrollBehavior = () => { // Intersection Observer for robust bottom detection useEffect(() => { - if (!bottomAnchorRef.current || !scrollContainerRef.current) return; - - const observer = new IntersectionObserver( - ([entry]) => { - const isIntersecting = entry.isIntersecting; - setIsAtBottom(isIntersecting); - - // If anchor comes into view, we're definitely at bottom - if (isIntersecting) { - setScrollIntent('pinned'); - } - }, - { - root: scrollContainerRef.current, - threshold: 0, - rootMargin: '50px' // Handle layout shifts + let rafId: number | null = null; + let observer: IntersectionObserver | null = null; + + const setupObserver = () => { + const container = scrollContainerRef.current; + const anchor = bottomAnchorRef.current; + + if (!container || !anchor) { + rafId = requestAnimationFrame(setupObserver); + return; } - ); - observer.observe(bottomAnchorRef.current); - return () => observer.disconnect(); + observer = new IntersectionObserver( + ([entry]) => { + const isIntersecting = entry.isIntersecting; + setIsAtBottom(isIntersecting); + + // If anchor comes into view, we're definitely at bottom + if (isIntersecting) { + setScrollIntent('pinned'); + } + }, + { + root: container, + threshold: 0, + rootMargin: '50px', // Handle layout shifts + } + ); + + observer.observe(anchor); + }; + + setupObserver(); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + observer?.disconnect(); + }; }, []); useEffect(() => { - const container = scrollContainerRef.current; - if (container) { + let rafId: number | null = null; + let attachedContainer: HTMLDivElement | null = null; + + const attachListener = () => { + const container = scrollContainerRef.current; + if (!container) { + rafId = requestAnimationFrame(attachListener); + return; + } + + attachedContainer = container; container.addEventListener("scroll", handleScroll); - return () => container.removeEventListener("scroll", handleScroll); - } + // Run once to ensure initial state reflects current scroll position + handleScroll(); + }; + + attachListener(); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + + if (attachedContainer) { + attachedContainer.removeEventListener("scroll", handleScroll); + } + }; }, [handleScroll]); // Should pin when user intends to stay at bottom AND is actually at bottom