diff --git a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx index 03c60b8acb..1418f29ac5 100644 --- a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx +++ b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx @@ -89,7 +89,7 @@ describe('BrainLeftSidebarWave', () => { render(); const link = screen.getByRole('link'); await userEvent.click(link); - expect(setActiveWave).toHaveBeenCalledWith('1', { isDirectMessage: false, serialNo: null, divider: null }); + expect(setActiveWave).toHaveBeenCalledWith('1', { isDirectMessage: false, divider: null }); }); it('shows drop indicators for non-chat waves', () => { @@ -101,7 +101,7 @@ describe('BrainLeftSidebarWave', () => { it('includes firstUnreadDropSerialNo in href when present', () => { const waveWithUnread = { ...baseWave, id: '3', firstUnreadDropSerialNo: 42 }; render(); - expect(screen.getByRole('link')).toHaveAttribute('href', '/waves?divider=42&wave=3&serialNo=42'); + expect(screen.getByRole('link')).toHaveAttribute('href', '/waves?divider=42&wave=3'); }); it('does not include serialNo in href when firstUnreadDropSerialNo is null', () => { diff --git a/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx index 9a9026fc74..6171bfc2d4 100644 --- a/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx @@ -1,9 +1,9 @@ -import { render, screen, act } from "@testing-library/react"; +import MyStreamWaveChat from "@/components/brain/my-stream/MyStreamWaveChat"; +import { editSlice } from "@/store/editSlice"; +import { configureStore } from "@reduxjs/toolkit"; +import { act, render, screen } from "@testing-library/react"; import React from "react"; import { Provider } from "react-redux"; -import { configureStore } from "@reduxjs/toolkit"; -import { editSlice } from "@/store/editSlice"; -import MyStreamWaveChat from "@/components/brain/my-stream/MyStreamWaveChat"; const replaceMock = jest.fn(); const searchParamsMock = { get: jest.fn() }; @@ -23,11 +23,15 @@ jest.mock("@/components/brain/my-stream/layout/LayoutContext", () => ({ useLayout: () => ({ waveViewStyle: { height: "1px" } }), })); -let capturedProps: any = {}; +const capturedPropsHolder = { current: {} as any }; jest.mock("@/components/waves/drops/wave-drops-all", () => ({ __esModule: true, default: (props: any) => { - capturedProps = props; + capturedPropsHolder.current = props; + return
; + }, + WaveDropsAllWithoutProvider: (props: any) => { + capturedPropsHolder.current = props; return
; }, })); @@ -55,21 +59,26 @@ jest.mock("@/hooks/useDeviceInfo", () => ({ default: () => ({ isApp: false }), })); +jest.mock("@/contexts/wave/UnreadDividerContext", () => ({ + UnreadDividerProvider: ({ children }: any) => <>{children}, +})); + jest.mock("@/components/waves/gallery", () => ({ WaveGallery: () =>
, })); -const wave = { id: "10" } as any; +const wave = { id: "10", metrics: { muted: false } } as any; const mockOnDropClick = jest.fn(); describe("MyStreamWaveChat", () => { let store: any; beforeEach(() => { - capturedProps = {}; + capturedPropsHolder.current = {}; replaceMock.mockClear(); searchParamsMock.get.mockReset(); mockIsMemesWave = false; + mockOnDropClick.mockClear(); store = configureStore({ reducer: { edit: editSlice.reducer }, }); @@ -93,7 +102,7 @@ describe("MyStreamWaveChat", () => { ); }); expect(replaceMock).toHaveBeenCalled(); - expect(capturedProps.initialDrop).toBe(5); + expect(capturedPropsHolder.current.initialDrop).toBe(5); expect(screen.getByTestId("memes-btn")).toBeInTheDocument(); }); @@ -110,7 +119,7 @@ describe("MyStreamWaveChat", () => { ); }); expect(replaceMock).not.toHaveBeenCalled(); - expect(capturedProps.initialDrop).toBeNull(); + expect(capturedPropsHolder.current.initialDrop).toBeNull(); expect(screen.queryByTestId("memes-btn")).toBeNull(); }); }); diff --git a/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx b/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx index 001ca0181e..9df8813a1d 100644 --- a/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx +++ b/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx @@ -4,6 +4,7 @@ import DropMobileMenuHandler from '@/components/waves/drops/DropMobileMenuHandle import { DropSize } from '@/helpers/waves/drop.helpers'; jest.mock('@/hooks/isMobileDevice', () => () => true); +jest.mock('@/hooks/useIsTouchDevice', () => ({ __esModule: true, default: () => true })); jest.mock('@/components/waves/drops/WaveDropMobileMenu', () => ({ __esModule: true, diff --git a/__tests__/components/waves/drops/WaveDrop.test.tsx b/__tests__/components/waves/drops/WaveDrop.test.tsx index b3fa061868..4eb6c77335 100644 --- a/__tests__/components/waves/drops/WaveDrop.test.tsx +++ b/__tests__/components/waves/drops/WaveDrop.test.tsx @@ -16,6 +16,7 @@ jest.mock('@/components/waves/drops/WaveDropRatings', () => () =>
() =>
); jest.mock('@/hooks/isMobileDevice'); +jest.mock('@/hooks/useIsTouchDevice', () => ({ __esModule: true, default: jest.fn(() => false) })); jest.mock('next/navigation', () => ({ useRouter: jest.fn(() => ({ push: jest.fn() })), diff --git a/__tests__/components/waves/drops/WaveDropsAll.test.tsx b/__tests__/components/waves/drops/WaveDropsAll.test.tsx index eb812650e2..6b6f41361e 100644 --- a/__tests__/components/waves/drops/WaveDropsAll.test.tsx +++ b/__tests__/components/waves/drops/WaveDropsAll.test.tsx @@ -85,6 +85,11 @@ jest.mock('@/components/waves/drops/WaveDropsScrollBottomButton', () => ({ } })); +jest.mock('@/components/waves/drops/WaveDropsScrollToUnreadButton', () => ({ + __esModule: true, + WaveDropsScrollToUnreadButton: () => + ); +}; diff --git a/components/waves/drops/wave-drops-all/index.tsx b/components/waves/drops/wave-drops-all/index.tsx index ff890b6032..13322708cd 100644 --- a/components/waves/drops/wave-drops-all/index.tsx +++ b/components/waves/drops/wave-drops-all/index.tsx @@ -113,8 +113,13 @@ const WaveDropsAllInner: React.FC = ({ ); const prevLatestSerialNoRef = useRef(null); + const initializedWaveRef = useRef(null); useEffect(() => { + if (initializedWaveRef.current === waveId) { + return; + } + initializedWaveRef.current = waveId; setVisibleLatestSerial(null); prevLatestSerialNoRef.current = null; setUnreadDividerSerialNo(dividerSerialNo ?? null); @@ -127,18 +132,8 @@ const WaveDropsAllInner: React.FC = ({ return; } - const prevSerial = prevLatestSerialNoRef.current; prevLatestSerialNoRef.current = latestSerialNo; - - if (prevSerial !== null && latestSerialNo > prevSerial && !isAtBottom) { - setUnreadDividerSerialNo((current) => { - if (current === null) { - return prevSerial + 1; - } - return current; - }); - } - }, [latestSerialNo, isAtBottom, setUnreadDividerSerialNo]); + }, [latestSerialNo]); useEffect(() => { if (latestSerialNo === null) { @@ -319,12 +314,15 @@ const WaveDropsAllInner: React.FC = ({ bottomPaddingClassName={bottomPaddingClassName} boostedDrops={boostedDrops} onBoostedDropClick={queueSerialTarget} + onScrollToUnread={queueSerialTarget} />
); }; +export const WaveDropsAllWithoutProvider: React.FC = WaveDropsAllInner; + const WaveDropsAll: React.FC = ({ waveId, dropId, diff --git a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx index 3b60eb0261..44afa5336b 100644 --- a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx +++ b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx @@ -49,6 +49,7 @@ interface WaveDropsContentProps { readonly bottomPaddingClassName?: string | undefined; readonly boostedDrops?: ApiDrop[] | undefined; readonly onBoostedDropClick?: ((serialNo: number) => void) | undefined; + readonly onScrollToUnread?: ((serialNo: number) => void) | undefined; } export const WaveDropsContent: React.FC = ({ @@ -73,6 +74,7 @@ export const WaveDropsContent: React.FC = ({ bottomPaddingClassName, boostedDrops, onBoostedDropClick, + onScrollToUnread, }) => { const { unreadDividerSerialNo } = useUnreadDivider(); const dropsCount = waveMessages?.drops.length ?? 0; @@ -119,6 +121,7 @@ export const WaveDropsContent: React.FC = ({ unreadDividerSerialNo={unreadDividerSerialNo} boostedDrops={boostedDrops} onBoostedDropClick={onBoostedDropClick} + onScrollToUnread={onScrollToUnread} /> diff --git a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsMessageListSection.tsx b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsMessageListSection.tsx index 42ff8c759b..ab726d2a44 100644 --- a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsMessageListSection.tsx +++ b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsMessageListSection.tsx @@ -1,6 +1,7 @@ import DropsList from "@/components/drops/view/DropsList"; import { WaveDropsReverseContainer } from "@/components/waves/drops/WaveDropsReverseContainer"; import { WaveDropsScrollBottomButton } from "@/components/waves/drops/WaveDropsScrollBottomButton"; +import { WaveDropsScrollToUnreadButton } from "@/components/waves/drops/WaveDropsScrollToUnreadButton"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import type { useVirtualizedWaveDrops } from "@/hooks/useVirtualizedWaveDrops"; @@ -45,6 +46,7 @@ interface WaveDropsMessageListSectionProps { readonly unreadDividerSerialNo?: number | null | undefined; readonly boostedDrops?: ApiDrop[] | undefined; readonly onBoostedDropClick?: ((serialNo: number) => void) | undefined; + readonly onScrollToUnread?: ((serialNo: number) => void) | undefined; } const MIN_DROPS_FOR_PAGINATION = 25; @@ -73,6 +75,7 @@ export const WaveDropsMessageListSection: React.FC< unreadDividerSerialNo, boostedDrops, onBoostedDropClick, + onScrollToUnread, }) => { const hasNextPage = !!waveMessages?.hasNextPage && @@ -109,6 +112,13 @@ export const WaveDropsMessageListSection: React.FC< />
+ {onScrollToUnread && ( + + )} { - if (typeof window === "undefined" || typeof navigator === "undefined") { - const info: DeviceInfo = { - isMobileDevice: false, - hasTouchScreen: false, - isApp: false, - isAppleMobile: false, + const touchDetectedRef = useRef(false); + + const getInfo = useCallback( + (touchDetected: boolean): DeviceInfo => { + if (typeof globalThis === "undefined" || typeof navigator === "undefined") { + return { + isMobileDevice: false, + hasTouchScreen: false, + isApp: false, + isAppleMobile: false, + }; + } + + const win = globalThis as typeof globalThis & { + matchMedia: (query: string) => MediaQueryList; + }; + const nav = navigator as Navigator & { + msMaxTouchPoints?: number | undefined; + userAgentData?: { mobile?: boolean | undefined } | undefined; + standalone?: boolean | undefined; }; - return info; - } - - const win = window as any; - const nav = navigator as Navigator & { - msMaxTouchPoints?: number | undefined; - userAgentData?: { mobile?: boolean | undefined } | undefined; - standalone?: boolean | undefined; - }; - const hasTouchScreen = - (nav.maxTouchPoints ?? nav.msMaxTouchPoints ?? 0) > 0 || - "ontouchstart" in win || - win.matchMedia("(pointer: coarse)").matches; - - const ua = nav.userAgent; - const uaDataMobile = nav.userAgentData?.mobile; - 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))); - - const info: DeviceInfo = { - isMobileDevice, - hasTouchScreen, - isApp: isCapacitor, - isAppleMobile: appleMobile, - }; - return info; - }, [isCapacitor]); + const maxTouchPoints = nav.maxTouchPoints ?? nav.msMaxTouchPoints ?? 0; + const hasTouchScreen = touchDetected || maxTouchPoints > 0; + + const ua = nav.userAgent; + const uaDataMobile = nav.userAgentData?.mobile; + 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 ?? false; + + const isMobileDevice = + uaDataMobile ?? + (classicMobile || (isCapacitor && (iPadDesktopUA || widthMobile))); + + return { + isMobileDevice, + hasTouchScreen, + isApp: isCapacitor, + isAppleMobile: appleMobile, + }; + }, + [isCapacitor] + ); - const [info, setInfo] = useState(() => getInfo()); + const [info, setInfo] = useState(() => getInfo(false)); useEffect(() => { - const mq = window.matchMedia("(pointer: coarse)"); + const hasEventListenerApi = + typeof globalThis.addEventListener === "function" && + typeof globalThis.removeEventListener === "function"; + const update = () => setInfo((prev) => { - const next = getInfo(); + const next = getInfo(touchDetectedRef.current); if ( prev.isMobileDevice === next.isMobileDevice && prev.hasTouchScreen === next.hasTouchScreen && @@ -85,19 +80,24 @@ export default function useDeviceInfo(): DeviceInfo { return next; }); - mq.addEventListener("change", update); - window.addEventListener("resize", update); - const onceTouch = () => { + touchDetectedRef.current = true; update(); - window.removeEventListener("touchstart", onceTouch); + if (hasEventListenerApi) { + globalThis.removeEventListener("touchstart", onceTouch); + } }; - window.addEventListener("touchstart", onceTouch, { passive: true }); + + if (hasEventListenerApi) { + globalThis.addEventListener("resize", update); + globalThis.addEventListener("touchstart", onceTouch, { passive: true }); + } return () => { - mq.removeEventListener("change", update); - window.removeEventListener("resize", update); - window.removeEventListener("touchstart", onceTouch); + if (hasEventListenerApi) { + globalThis.removeEventListener("resize", update); + globalThis.removeEventListener("touchstart", onceTouch); + } }; }, [getInfo]); diff --git a/hooks/useIsTouchDevice.ts b/hooks/useIsTouchDevice.ts index 3b58700ce2..f875bc757f 100644 --- a/hooks/useIsTouchDevice.ts +++ b/hooks/useIsTouchDevice.ts @@ -1,25 +1,35 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; export default function useIsTouchDevice(): boolean { const [isTouchDevice, setIsTouchDevice] = useState(false); useEffect(() => { - const globalScope = globalThis as typeof globalThis & { - window?: Window | undefined; - navigator?: Navigator | undefined; + if (typeof globalThis === "undefined") { + return; + } + + const win = globalThis as typeof globalThis & { + matchMedia?: (query: string) => MediaQueryList; }; - const browserWindow = globalScope.window; - const browserNavigator = globalScope.navigator; - const isTouch = - !!browserWindow && - ("ontouchstart" in browserWindow || - (browserNavigator?.maxTouchPoints ?? 0) > 0 || - browserWindow.matchMedia?.("(pointer: coarse)")?.matches); + const hasFinePointer = win.matchMedia?.("(pointer: fine)")?.matches; + if (hasFinePointer) { + setIsTouchDevice(false); + return; + } + + const onTouchStart = () => { + setIsTouchDevice(true); + globalThis.removeEventListener("touchstart", onTouchStart); + }; - setIsTouchDevice(isTouch); + globalThis.addEventListener("touchstart", onTouchStart, { passive: true }); + + return () => { + globalThis.removeEventListener("touchstart", onTouchStart); + }; }, []); return isTouchDevice;