diff --git a/__tests__/FooterWrapper.test.tsx b/__tests__/FooterWrapper.test.tsx index c87f6ed0c0..5182107155 100644 --- a/__tests__/FooterWrapper.test.tsx +++ b/__tests__/FooterWrapper.test.tsx @@ -8,7 +8,9 @@ jest.mock("@/hooks/useDeviceInfo", () => ({ default: jest.fn(() => ({ isMobileDevice: false, hasTouchScreen: false, + shouldUseTouchUI: false, isApp: false, + isAppleMobile: false, })), })); jest.mock("next/navigation", () => ({ usePathname: jest.fn() })); diff --git a/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx b/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx index d825a2d3cd..ff161041a6 100644 --- a/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx +++ b/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx @@ -1,30 +1,46 @@ -import { act, render, screen } from '@testing-library/react'; -import React from 'react'; -import UnifiedWavesList from '@/components/brain/left-sidebar/waves/UnifiedWavesList'; -import useDeviceInfo from '@/hooks/useDeviceInfo'; -import { createMockMinimalWave } from '@/__tests__/utils/mockFactories'; +import { createMockMinimalWave } from "@/__tests__/utils/mockFactories"; +import UnifiedWavesList from "@/components/brain/left-sidebar/waves/UnifiedWavesList"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; +import { act, render, screen } from "@testing-library/react"; +import React from "react"; -jest.mock('@/hooks/useDeviceInfo'); -jest.mock('@/components/brain/left-sidebar/waves/UnifiedWavesListLoader', () => ({ - UnifiedWavesListLoader: ({ isFetchingNextPage }: any) =>
{String(isFetchingNextPage)}
-})); -jest.mock('@/components/brain/left-sidebar/waves/UnifiedWavesListEmpty', () => ({ - __esModule: true, - default: ({ sortedWaves }: any) =>
{sortedWaves.length}
-})); +jest.mock("@/hooks/useDeviceInfo"); +jest.mock( + "@/components/brain/left-sidebar/waves/UnifiedWavesListLoader", + () => ({ + UnifiedWavesListLoader: ({ isFetchingNextPage }: any) => ( +
{String(isFetchingNextPage)}
+ ), + }) +); +jest.mock( + "@/components/brain/left-sidebar/waves/UnifiedWavesListEmpty", + () => ({ + __esModule: true, + default: ({ sortedWaves }: any) => ( +
{sortedWaves.length}
+ ), + }) +); let sentinel: HTMLElement | null = null; -jest.mock('@/components/brain/left-sidebar/waves/UnifiedWavesListWaves', () => { +jest.mock("@/components/brain/left-sidebar/waves/UnifiedWavesListWaves", () => { return { __esModule: true, default: React.forwardRef((props: any, ref: any) => { const sentinelRef = React.useRef(null); const containerRef = React.useRef(null); React.useImperativeHandle(ref, () => ({ sentinelRef, containerRef })); - React.useEffect(() => { sentinel = sentinelRef.current; }, []); - return
; - }) + React.useEffect(() => { + sentinel = sentinelRef.current; + }, []); + return ( +
+
+
+ ); + }), }; }); @@ -32,9 +48,12 @@ type DeviceInfo = { isApp: boolean; isMobileDevice: boolean; hasTouchScreen: boolean; + shouldUseTouchUI: boolean; isAppleMobile: boolean; }; -const useDeviceInfoMock = useDeviceInfo as jest.MockedFunction; +const useDeviceInfoMock = useDeviceInfo as jest.MockedFunction< + typeof useDeviceInfo +>; beforeEach(() => { sentinel = null; @@ -42,6 +61,7 @@ beforeEach(() => { isApp: false, isMobileDevice: false, hasTouchScreen: false, + shouldUseTouchUI: false, isAppleMobile: false, } as DeviceInfo); }); @@ -50,8 +70,8 @@ afterEach(() => { jest.resetAllMocks(); }); -describe('UnifiedWavesList', () => { - it('shows create link when not in app', () => { +describe("UnifiedWavesList", () => { + it("shows create link when not in app", () => { render( { scrollContainerRef={React.createRef()} /> ); - expect(screen.getByText('Create Wave')).toBeInTheDocument(); - expect(screen.getByTestId('loader')).toHaveTextContent('false'); + expect(screen.getByText("Create Wave")).toBeInTheDocument(); + expect(screen.getByTestId("loader")).toHaveTextContent("false"); }); - it('triggers fetchNextPage when sentinel intersects', () => { + it("triggers fetchNextPage when sentinel intersects", () => { useDeviceInfoMock.mockReturnValue({ isApp: true, isMobileDevice: false, hasTouchScreen: false, + shouldUseTouchUI: false, isAppleMobile: false, } as DeviceInfo); const fetchNextPage = jest.fn(); const observerInstances: any[] = []; (global as any).IntersectionObserver = class { callback: any; - constructor(cb: any) { this.callback = cb; observerInstances.push(this); } + constructor(cb: any) { + this.callback = cb; + observerInstances.push(this); + } observe() {} disconnect() {} }; render( ({ isApp: false, isMobileDevice: false, hasTouchScreen: false, + shouldUseTouchUI: false, + isAppleMobile: false, })), })); diff --git a/__tests__/components/memes/drops/MemesLeaderboardDrop.test.tsx b/__tests__/components/memes/drops/MemesLeaderboardDrop.test.tsx index ce10425dda..f96b8367e9 100644 --- a/__tests__/components/memes/drops/MemesLeaderboardDrop.test.tsx +++ b/__tests__/components/memes/drops/MemesLeaderboardDrop.test.tsx @@ -70,7 +70,7 @@ beforeEach(() => { }); test('calls onDropClick when not touch screen', async () => { - useDeviceInfo.mockReturnValue({ hasTouchScreen: false }); + useDeviceInfo.mockReturnValue({ hasTouchScreen: false, shouldUseTouchUI: false, isMobileDevice: false, isApp: false, isAppleMobile: false }); useIsMobileScreen.mockReturnValue(false); const onClick = jest.fn(); const { container } = render( @@ -81,7 +81,7 @@ test('calls onDropClick when not touch screen', async () => { }); test('does not call onDropClick on touch devices', async () => { - useDeviceInfo.mockReturnValue({ hasTouchScreen: true }); + useDeviceInfo.mockReturnValue({ hasTouchScreen: true, shouldUseTouchUI: true, isMobileDevice: false, isApp: false, isAppleMobile: false }); useIsMobileScreen.mockReturnValue(false); const onClick = jest.fn(); const { container } = render( @@ -92,7 +92,7 @@ test('does not call onDropClick on touch devices', async () => { }); test('opens voting modal on desktop', async () => { - useDeviceInfo.mockReturnValue({ hasTouchScreen: false }); + useDeviceInfo.mockReturnValue({ hasTouchScreen: false, shouldUseTouchUI: false, isMobileDevice: false, isApp: false, isAppleMobile: false }); useIsMobileScreen.mockReturnValue(false); render(); expect(screen.getByTestId('modal')).toHaveTextContent('closed'); @@ -101,7 +101,7 @@ test('opens voting modal on desktop', async () => { }); test('uses mobile modal on small screens', async () => { - useDeviceInfo.mockReturnValue({ hasTouchScreen: false }); + useDeviceInfo.mockReturnValue({ hasTouchScreen: false, shouldUseTouchUI: false, isMobileDevice: false, isApp: false, isAppleMobile: false }); useIsMobileScreen.mockReturnValue(true); render(); await userEvent.click(screen.getByTestId('vote-btn')); diff --git a/__tests__/components/navigation/BackButton.test.tsx b/__tests__/components/navigation/BackButton.test.tsx index 3e7643473c..7539a45666 100644 --- a/__tests__/components/navigation/BackButton.test.tsx +++ b/__tests__/components/navigation/BackButton.test.tsx @@ -27,6 +27,8 @@ jest.mock("@/hooks/useDeviceInfo", () => ({ isApp: false, isMobileDevice: false, hasTouchScreen: false, + shouldUseTouchUI: false, + isAppleMobile: false, }), })); diff --git a/__tests__/components/navigation/ViewContext.test.tsx b/__tests__/components/navigation/ViewContext.test.tsx index 6a83b763b8..2094b8febf 100644 --- a/__tests__/components/navigation/ViewContext.test.tsx +++ b/__tests__/components/navigation/ViewContext.test.tsx @@ -12,6 +12,8 @@ jest.mock("@/hooks/useDeviceInfo", () => ({ isApp: false, isMobileDevice: false, hasTouchScreen: false, + shouldUseTouchUI: false, + isAppleMobile: false, }), })); diff --git a/__tests__/components/network/NetworkPageLayout.test.tsx b/__tests__/components/network/NetworkPageLayout.test.tsx index 9e7dc10175..558787dbbc 100644 --- a/__tests__/components/network/NetworkPageLayout.test.tsx +++ b/__tests__/components/network/NetworkPageLayout.test.tsx @@ -46,6 +46,8 @@ describe("NetworkPageLayout", () => { isApp: false, isMobileDevice: false, hasTouchScreen: false, + shouldUseTouchUI: false, + isAppleMobile: false, }); }); @@ -73,6 +75,8 @@ describe("NetworkPageLayout", () => { isApp: true, isMobileDevice: false, hasTouchScreen: false, + shouldUseTouchUI: false, + isAppleMobile: false, }); renderComponent(); diff --git a/__tests__/components/utils/tooltip/UserProfileTooltipWrapper.test.tsx b/__tests__/components/utils/tooltip/UserProfileTooltipWrapper.test.tsx index e445b249d6..b136a1bf24 100644 --- a/__tests__/components/utils/tooltip/UserProfileTooltipWrapper.test.tsx +++ b/__tests__/components/utils/tooltip/UserProfileTooltipWrapper.test.tsx @@ -39,6 +39,10 @@ jest.mock('@/hooks/useDeviceInfo', () => ({ __esModule: true, default: () => ({ hasTouchScreen: false, + shouldUseTouchUI: false, + isMobileDevice: false, + isApp: false, + isAppleMobile: false, }), })); @@ -72,6 +76,10 @@ describe('UserProfileTooltipWrapper', () => { // Mock touch device const mockUseDeviceInfo = jest.fn(() => ({ hasTouchScreen: true, + shouldUseTouchUI: true, + isMobileDevice: false, + isApp: false, + isAppleMobile: false, })); jest.doMock('../../../../hooks/useDeviceInfo', () => ({ diff --git a/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx b/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx index 73b86f4120..3a7b6af379 100644 --- a/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx +++ b/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx @@ -4,6 +4,7 @@ import { act, fireEvent, render } from "@testing-library/react"; jest.mock("@/hooks/useDeviceInfo", () => () => ({ isMobileDevice: true, hasTouchScreen: true, + shouldUseTouchUI: true, isApp: true, isAppleMobile: false, })); diff --git a/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx b/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx index f830590b1b..1c9cecef63 100644 --- a/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx +++ b/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx @@ -4,9 +4,9 @@ import DropMobileMenuHandler from "@/components/waves/drops/DropMobileMenuHandle import { DropSize } from "@/helpers/waves/drop.helpers"; jest.mock("@/hooks/isMobileDevice", () => () => true); -jest.mock("@/hooks/useIsTouchDevice", () => ({ +jest.mock("@/hooks/useDeviceInfo", () => ({ __esModule: true, - default: () => true, + default: () => ({ shouldUseTouchUI: true }), })); jest.mock("@/components/waves/drops/WaveDropMobileMenu", () => ({ diff --git a/__tests__/components/waves/drops/WaveDrop.test.tsx b/__tests__/components/waves/drops/WaveDrop.test.tsx index 92577a62be..d4e149fe84 100644 --- a/__tests__/components/waves/drops/WaveDrop.test.tsx +++ b/__tests__/components/waves/drops/WaveDrop.test.tsx @@ -32,9 +32,9 @@ jest.mock("@/components/waves/drops/WaveDropMobileMenu", () => () => ( )); jest.mock("@/hooks/isMobileDevice"); -jest.mock("@/hooks/useIsTouchDevice", () => ({ +jest.mock("@/hooks/useDeviceInfo", () => ({ __esModule: true, - default: jest.fn(() => false), + default: jest.fn(() => ({ shouldUseTouchUI: false })), })); jest.mock("next/navigation", () => ({ diff --git a/__tests__/components/waves/drops/WaveDropReactions.test.tsx b/__tests__/components/waves/drops/WaveDropReactions.test.tsx index aa50ba5261..4b57a45c05 100644 --- a/__tests__/components/waves/drops/WaveDropReactions.test.tsx +++ b/__tests__/components/waves/drops/WaveDropReactions.test.tsx @@ -23,9 +23,9 @@ jest.mock("@/services/api/common-api", () => ({ commonApiDelete: jest.fn(), })); -jest.mock("@/hooks/useIsTouchDevice", () => ({ +jest.mock("@/hooks/useDeviceInfo", () => ({ __esModule: true, - default: jest.fn(() => false), + default: jest.fn(() => ({ shouldUseTouchUI: false })), })); jest.mock("@/hooks/useLongPressInteraction", () => ({ @@ -345,8 +345,8 @@ describe("WaveDropReactions", () => { }); it("adds touch handlers for long press on touch devices", () => { - const mockUseIsTouchDevice = require("@/hooks/useIsTouchDevice").default; - mockUseIsTouchDevice.mockReturnValue(true); + const mockUseDeviceInfo = require("@/hooks/useDeviceInfo").default; + mockUseDeviceInfo.mockReturnValue({ shouldUseTouchUI: true }); mockUseEmoji.mockReturnValue( createEmojiContextValue( @@ -378,7 +378,7 @@ describe("WaveDropReactions", () => { const reactionButton = screen.getAllByRole("button")[0]; expect(reactionButton).toBeInTheDocument(); - mockUseIsTouchDevice.mockReturnValue(false); + mockUseDeviceInfo.mockReturnValue({ shouldUseTouchUI: false }); }); it("renders profile handles as clickable links in tooltip", async () => { diff --git a/__tests__/components/waves/drops/WaveDropsAll.test.tsx b/__tests__/components/waves/drops/WaveDropsAll.test.tsx index bd6f5aaf78..feac812b37 100644 --- a/__tests__/components/waves/drops/WaveDropsAll.test.tsx +++ b/__tests__/components/waves/drops/WaveDropsAll.test.tsx @@ -193,6 +193,7 @@ interface MockSetupOptions { isAppleMobile?: boolean | undefined; isMobileDevice?: boolean | undefined; hasTouchScreen?: boolean | undefined; + shouldUseTouchUI?: boolean | undefined; isApp?: boolean | undefined; } | undefined; @@ -279,6 +280,7 @@ function setupMocks(options: MockSetupOptions = {}) { isAppleMobile: options.deviceInfo?.isAppleMobile ?? false, isMobileDevice: options.deviceInfo?.isMobileDevice ?? false, hasTouchScreen: options.deviceInfo?.hasTouchScreen ?? false, + shouldUseTouchUI: options.deviceInfo?.shouldUseTouchUI ?? false, isApp: options.deviceInfo?.isApp ?? false, }); diff --git a/__tests__/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.interaction.test.tsx b/__tests__/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.interaction.test.tsx index 1b9592c91f..e47db83d14 100644 --- a/__tests__/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.interaction.test.tsx +++ b/__tests__/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.interaction.test.tsx @@ -54,7 +54,7 @@ beforeEach(() => { test('opens voting modal when button clicked', async () => { const user = userEvent.setup(); useRules.mockReturnValue({ canShowVote: true, canDelete: true }); - useDeviceInfo.mockReturnValue({ hasTouchScreen: false }); + useDeviceInfo.mockReturnValue({ hasTouchScreen: false, shouldUseTouchUI: false, isMobileDevice: false, isApp: false, isAppleMobile: false }); useIsMobileScreen.mockReturnValue(false); render(); expect(screen.getByTestId('modal')).toHaveTextContent('false'); @@ -65,7 +65,7 @@ test('opens voting modal when button clicked', async () => { test('uses mobile modal and hides options when cannot delete', () => { useRules.mockReturnValue({ canShowVote: true, canDelete: false }); - useDeviceInfo.mockReturnValue({ hasTouchScreen: false }); + useDeviceInfo.mockReturnValue({ hasTouchScreen: false, shouldUseTouchUI: false, isMobileDevice: false, isApp: false, isAppleMobile: false }); useIsMobileScreen.mockReturnValue(true); useLongPressInteraction.mockReturnValue({ isActive: false, setIsActive: jest.fn(), touchHandlers: {} }); render(); diff --git a/__tests__/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.test.tsx b/__tests__/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.test.tsx index b327f81761..1e3ce9751e 100644 --- a/__tests__/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.test.tsx +++ b/__tests__/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.test.tsx @@ -4,7 +4,7 @@ import { DefaultWaveLeaderboardDrop } from '@/components/waves/leaderboard/drops import type { ApiWave } from '@/generated/models/ObjectSerializer'; jest.mock('@/hooks/drops/useDropInteractionRules', () => ({ useDropInteractionRules: () => ({ canShowVote: true, canDelete: true }) })); -jest.mock('@/hooks/useDeviceInfo', () => ({ __esModule: true, default: () => ({ hasTouchScreen: false }) })); +jest.mock('@/hooks/useDeviceInfo', () => ({ __esModule: true, default: () => ({ hasTouchScreen: false, shouldUseTouchUI: false, isMobileDevice: false, isApp: false, isAppleMobile: false }) })); jest.mock('@/hooks/isMobileScreen', () => ({ __esModule: true, default: () => false })); jest.mock('@/hooks/useLongPressInteraction', () => ({ __esModule: true, default: () => ({ isActive:false, setIsActive: jest.fn(), touchHandlers:{} }) })); diff --git a/__tests__/components/waves/winners/drops/MemesWaveWinnerDrop.test.tsx b/__tests__/components/waves/winners/drops/MemesWaveWinnerDrop.test.tsx index 0461433afa..bc90e7dc44 100644 --- a/__tests__/components/waves/winners/drops/MemesWaveWinnerDrop.test.tsx +++ b/__tests__/components/waves/winners/drops/MemesWaveWinnerDrop.test.tsx @@ -15,7 +15,7 @@ jest.mock('@fortawesome/react-fontawesome', () => ({ FontAwesomeIcon: () => ({ __esModule: true, default: ({href,children,onClick,className}:any) => {children} })); jest.mock('@/components/memes/drops/MemeDropTraits', () => () =>
); jest.mock('@/components/drops/view/item/content/media/DropListItemContentMedia', () => () =>
); -jest.mock('@/hooks/useDeviceInfo', () => ({ __esModule: true, default: () => ({ hasTouchScreen: false }) })); +jest.mock('@/hooks/useDeviceInfo', () => ({ __esModule: true, default: () => ({ hasTouchScreen: false, shouldUseTouchUI: false, isMobileDevice: false, isApp: false, isAppleMobile: false }) })); jest.mock('@/hooks/useLongPressInteraction', () => ({ __esModule: true, default: () => ({ isActive:false, setIsActive: jest.fn(), touchHandlers:{} }) })); jest.mock('@/components/waves/drops/WaveDropActionsOpen', () => () =>
); jest.mock('@/components/utils/select/dropdown/CommonDropdownItemsMobileWrapper', () => (p:any) =>
{p.children}
); diff --git a/__tests__/contexts/wave/hooks/useActiveWaveManager.test.tsx b/__tests__/contexts/wave/hooks/useActiveWaveManager.test.tsx index f64666857c..66aba491d4 100644 --- a/__tests__/contexts/wave/hooks/useActiveWaveManager.test.tsx +++ b/__tests__/contexts/wave/hooks/useActiveWaveManager.test.tsx @@ -8,6 +8,7 @@ jest.mock("@/hooks/useDeviceInfo", () => ({ isApp: false, isMobileDevice: false, hasTouchScreen: false, + shouldUseTouchUI: false, isAppleMobile: false, }), })); diff --git a/__tests__/hooks/useDeviceInfo.test.ts b/__tests__/hooks/useDeviceInfo.test.ts index 33ed3ea306..b6105de255 100644 --- a/__tests__/hooks/useDeviceInfo.test.ts +++ b/__tests__/hooks/useDeviceInfo.test.ts @@ -9,7 +9,13 @@ const capacitorMock = require("@/hooks/useCapacitor").default as jest.Mock; let touchStartHandler: EventListener | null = null; -function defineMatchMedia(pointerFine = true, width = false) { +function defineMatchMedia( + pointerFine = true, + anyPointerFine = true, + hover = true, + anyHover = true, + width = false +) { Object.defineProperty(globalThis, "matchMedia", { writable: true, value: jest.fn((query: string) => { @@ -20,6 +26,27 @@ function defineMatchMedia(pointerFine = true, width = false) { removeEventListener: jest.fn(), }; } + if (query === "(any-pointer: fine)") { + return { + matches: anyPointerFine, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + } + if (query === "(hover: hover)") { + return { + matches: hover, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + } + if (query === "(any-hover: hover)") { + return { + matches: anyHover, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + } if (query === "(max-width: 768px)") { return { matches: width, @@ -63,12 +90,13 @@ describe("useDeviceInfo", () => { value: 5, configurable: true, }); - defineMatchMedia(false, false); + defineMatchMedia(false, false, false, false, false); const { result } = renderHook(() => useDeviceInfo()); expect(result.current.isMobileDevice).toBe(true); expect(result.current.isAppleMobile).toBe(true); expect(result.current.isApp).toBe(false); expect(result.current.hasTouchScreen).toBe(true); + expect(result.current.shouldUseTouchUI).toBe(true); }); it("detects capacitor mobile with desktop UA", () => { @@ -81,12 +109,13 @@ describe("useDeviceInfo", () => { value: 5, configurable: true, }); - defineMatchMedia(false, true); + defineMatchMedia(false, false, false, false, true); const { result } = renderHook(() => useDeviceInfo()); expect(result.current.isMobileDevice).toBe(true); expect(result.current.isApp).toBe(true); expect(result.current.isAppleMobile).toBe(true); expect(result.current.hasTouchScreen).toBe(true); + expect(result.current.shouldUseTouchUI).toBe(false); }); it("returns false for desktop without touch", () => { @@ -99,10 +128,11 @@ describe("useDeviceInfo", () => { value: 0, configurable: true, }); - defineMatchMedia(true, false); + defineMatchMedia(true, true, true, true, false); const { result } = renderHook(() => useDeviceInfo()); expect(result.current.isMobileDevice).toBe(false); expect(result.current.hasTouchScreen).toBe(false); + expect(result.current.shouldUseTouchUI).toBe(false); expect(result.current.isAppleMobile).toBe(false); }); @@ -116,10 +146,11 @@ describe("useDeviceInfo", () => { value: 0, configurable: true, }); - defineMatchMedia(true, false); + defineMatchMedia(true, true, true, true, false); const { result } = renderHook(() => useDeviceInfo()); expect(result.current.hasTouchScreen).toBe(false); + expect(result.current.shouldUseTouchUI).toBe(false); act(() => { if (touchStartHandler) { @@ -128,6 +159,7 @@ describe("useDeviceInfo", () => { }); expect(result.current.hasTouchScreen).toBe(true); + expect(result.current.shouldUseTouchUI).toBe(false); }); it("hasTouchScreen is true when maxTouchPoints > 0 even without touch event", () => { @@ -140,8 +172,39 @@ describe("useDeviceInfo", () => { value: 5, configurable: true, }); - defineMatchMedia(true, false); + defineMatchMedia(true, true, true, true, false); + const { result } = renderHook(() => useDeviceInfo()); + expect(result.current.hasTouchScreen).toBe(true); + expect(result.current.shouldUseTouchUI).toBe(false); + }); + + it('shouldUseTouchUI is false for desktop with fine pointer and hover', () => { + capacitorMock.mockReturnValue({ isCapacitor: false }); + Object.defineProperty(globalThis.navigator, 'userAgent', { value: 'Mozilla/5.0', configurable: true }); + Object.defineProperty(globalThis.navigator, 'maxTouchPoints', { value: 10, configurable: true }); + defineMatchMedia(true, true, true, true, false); + const { result } = renderHook(() => useDeviceInfo()); + expect(result.current.hasTouchScreen).toBe(true); + expect(result.current.shouldUseTouchUI).toBe(false); + }); + + it('shouldUseTouchUI is true for mobile device without fine pointer or hover', () => { + capacitorMock.mockReturnValue({ isCapacitor: false }); + Object.defineProperty(globalThis.navigator, 'userAgent', { value: 'Android', configurable: true }); + Object.defineProperty(globalThis.navigator, 'maxTouchPoints', { value: 5, configurable: true }); + defineMatchMedia(false, false, false, false, false); + const { result } = renderHook(() => useDeviceInfo()); + expect(result.current.hasTouchScreen).toBe(true); + expect(result.current.shouldUseTouchUI).toBe(true); + }); + + it('shouldUseTouchUI is false for touchscreen laptop (has fine pointer)', () => { + capacitorMock.mockReturnValue({ isCapacitor: false }); + Object.defineProperty(globalThis.navigator, 'userAgent', { value: 'Mozilla/5.0 Windows', configurable: true }); + Object.defineProperty(globalThis.navigator, 'maxTouchPoints', { value: 10, configurable: true }); + defineMatchMedia(true, true, true, true, false); const { result } = renderHook(() => useDeviceInfo()); expect(result.current.hasTouchScreen).toBe(true); + expect(result.current.shouldUseTouchUI).toBe(false); }); }); diff --git a/__tests__/hooks/useIsTouchDevice.test.ts b/__tests__/hooks/useIsTouchDevice.test.ts deleted file mode 100644 index 3ba9bc3119..0000000000 --- a/__tests__/hooks/useIsTouchDevice.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { renderHook, act } from "@testing-library/react"; -import useIsTouchDevice from "@/hooks/useIsTouchDevice"; - -describe("useIsTouchDevice", () => { - let addEventListenerSpy: jest.SpyInstance; - let removeEventListenerSpy: jest.SpyInstance; - let touchStartHandler: EventListener | null = null; - let originalMaxTouchPoints: number | undefined; - - beforeEach(() => { - originalMaxTouchPoints = (globalThis.navigator as Navigator | undefined)?.maxTouchPoints; - addEventListenerSpy = jest.spyOn(globalThis, "addEventListener").mockImplementation((event, handler) => { - if (event === "touchstart") { - touchStartHandler = handler as EventListener; - } - }); - removeEventListenerSpy = jest.spyOn(globalThis, "removeEventListener"); - }); - - afterEach(() => { - addEventListenerSpy.mockRestore(); - removeEventListenerSpy.mockRestore(); - touchStartHandler = null; - if (typeof originalMaxTouchPoints === "number") { - Object.defineProperty(globalThis.navigator, "maxTouchPoints", { - value: originalMaxTouchPoints, - configurable: true, - }); - } else { - // Ensure tests don't leak touch points across cases. - try { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete (globalThis.navigator as any).maxTouchPoints; - } catch { - // ignore - } - } - jest.restoreAllMocks(); - }); - - it("returns false initially when fine pointer is detected", () => { - Object.defineProperty(globalThis, "matchMedia", { - writable: true, - value: jest.fn((query: string) => ({ - matches: query === "(pointer: fine)", - })), - }); - - const { result } = renderHook(() => useIsTouchDevice()); - expect(result.current).toBe(false); - }); - - it("returns false initially and does not listen for touch when fine pointer exists", () => { - Object.defineProperty(globalThis, "matchMedia", { - writable: true, - value: jest.fn((query: string) => ({ - matches: query === "(pointer: fine)", - })), - }); - - renderHook(() => useIsTouchDevice()); - expect(addEventListenerSpy).not.toHaveBeenCalledWith("touchstart", expect.any(Function), expect.any(Object)); - }); - - it("returns true initially when maxTouchPoints > 0 and no fine pointer", () => { - Object.defineProperty(globalThis.navigator, "maxTouchPoints", { value: 10, configurable: true }); - Object.defineProperty(globalThis, "matchMedia", { - writable: true, - value: jest.fn(() => ({ matches: false })), - }); - - const { result } = renderHook(() => useIsTouchDevice()); - expect(result.current).toBe(true); - expect(addEventListenerSpy).not.toHaveBeenCalledWith("touchstart", expect.any(Function), expect.any(Object)); - }); - - it("returns false when a fine pointer exists even if maxTouchPoints > 0", () => { - Object.defineProperty(globalThis.navigator, "maxTouchPoints", { value: 10, configurable: true }); - Object.defineProperty(globalThis, "matchMedia", { - writable: true, - value: jest.fn((query: string) => ({ - matches: query === "(any-pointer: fine)", - })), - }); - - const { result } = renderHook(() => useIsTouchDevice()); - expect(result.current).toBe(false); - expect(addEventListenerSpy).not.toHaveBeenCalledWith("touchstart", expect.any(Function), expect.any(Object)); - }); - - it("returns false initially but switches to true after touchstart when no fine pointer", () => { - Object.defineProperty(globalThis, "matchMedia", { - writable: true, - value: jest.fn(() => ({ matches: false })), - }); - - const { result } = renderHook(() => useIsTouchDevice()); - expect(result.current).toBe(false); - - act(() => { - if (touchStartHandler) { - touchStartHandler(new Event("touchstart")); - } - }); - - expect(result.current).toBe(true); - }); - - it("removes touchstart listener after first touch", () => { - Object.defineProperty(globalThis, "matchMedia", { - writable: true, - value: jest.fn(() => ({ matches: false })), - }); - - renderHook(() => useIsTouchDevice()); - - act(() => { - if (touchStartHandler) { - touchStartHandler(new Event("touchstart")); - } - }); - - expect(removeEventListenerSpy).toHaveBeenCalledWith("touchstart", expect.any(Function)); - }); - - it("cleans up event listener on unmount", () => { - Object.defineProperty(globalThis, "matchMedia", { - writable: true, - value: jest.fn(() => ({ matches: false })), - }); - - const { unmount } = renderHook(() => useIsTouchDevice()); - unmount(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith("touchstart", expect.any(Function)); - }); -}); \ No newline at end of file diff --git a/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx b/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx index 2283249d04..4fba4f1756 100644 --- a/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx +++ b/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx @@ -34,7 +34,7 @@ const BrainLeftSidebarWave: React.FC = ({ const { activeWave } = useMyStream(); const { id: activeWaveId, set: setActiveWave } = activeWave; const prefetchWaveData = usePrefetchWaveData(); - const { isApp, hasTouchScreen } = useDeviceInfo(); + const { isApp, shouldUseTouchUI } = useDeviceInfo(); const isDropWave = wave.type !== ApiWaveType.Chat; const formattedWaveName = useMemo(() => { @@ -140,7 +140,7 @@ const BrainLeftSidebarWave: React.FC = ({ > 0; const tooltipId = `wave-collapsed-${wave.id}`; const expandedTooltipId = `wave-expanded-${wave.id}`; - const showCollapsedTooltip = collapsed && !hasTouchScreen; - const showExpandedTooltip = !collapsed && !hasTouchScreen && isNameTruncated; + const showCollapsedTooltip = collapsed && !shouldUseTouchUI; + const showExpandedTooltip = !collapsed && !shouldUseTouchUI && isNameTruncated; if (collapsed) { return ( diff --git a/components/brain/left-sidebar/web/WebDirectMessagesList.tsx b/components/brain/left-sidebar/web/WebDirectMessagesList.tsx index bfad294ad3..4e31a589a3 100644 --- a/components/brain/left-sidebar/web/WebDirectMessagesList.tsx +++ b/components/brain/left-sidebar/web/WebDirectMessagesList.tsx @@ -1,7 +1,7 @@ "use client"; import useCreateModalState from "@/hooks/useCreateModalState"; -import useIsTouchDevice from "@/hooks/useIsTouchDevice"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; import { faPaperPlane } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Image from "next/image"; @@ -33,7 +33,8 @@ const WebDirectMessagesList: React.FC = ({ const { connectedProfile } = useContext(AuthContext); const { isDirectMessageModalOpen, openDirectMessage, close, isApp } = useCreateModalState(); - const isTouchDevice = useIsTouchDevice(); + const { shouldUseTouchUI } = useDeviceInfo(); + const isTouchDevice = shouldUseTouchUI; const shouldRenderCreateDirectMessage = !isApp; diff --git a/components/brain/left-sidebar/web/WebUnifiedWavesListWaves.tsx b/components/brain/left-sidebar/web/WebUnifiedWavesListWaves.tsx index ea98e9286d..8eb955f31e 100644 --- a/components/brain/left-sidebar/web/WebUnifiedWavesListWaves.tsx +++ b/components/brain/left-sidebar/web/WebUnifiedWavesListWaves.tsx @@ -2,7 +2,7 @@ import PrimaryButton from "@/components/utils/button/PrimaryButton"; import useCreateModalState from "@/hooks/useCreateModalState"; -import useIsTouchDevice from "@/hooks/useIsTouchDevice"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import React, { forwardRef, useImperativeHandle, useMemo, useRef } from "react"; @@ -70,7 +70,8 @@ const WebUnifiedWavesListWaves = forwardRef< const sentinelRef = useRef(null); const { connectedProfile } = useAuth(); const { openWave, isApp } = useCreateModalState(); - const isTouchDevice = useIsTouchDevice(); + const { shouldUseTouchUI } = useDeviceInfo(); + const isTouchDevice = shouldUseTouchUI; useImperativeHandle(ref, () => ({ sentinelRef, diff --git a/components/drops/view/item/content/media/MediaDisplayGLB.tsx b/components/drops/view/item/content/media/MediaDisplayGLB.tsx index 6c0a4f5b49..1ad3e1eb5d 100644 --- a/components/drops/view/item/content/media/MediaDisplayGLB.tsx +++ b/components/drops/view/item/content/media/MediaDisplayGLB.tsx @@ -21,7 +21,7 @@ export default function MediaDisplayGLB({ const modelRef = useRef(null); const containerRef = useRef(null); const [isActive, setIsActive] = useState(false); - const { hasTouchScreen } = useDeviceInfo(); + const { shouldUseTouchUI } = useDeviceInfo(); useEffect(() => { const modelViewer = modelRef.current; @@ -48,7 +48,7 @@ export default function MediaDisplayGLB({ return () => { document.removeEventListener("click", handleClickOutside); }; - }, [isActive, hasTouchScreen]); + }, [isActive]); const handleCubeToggle = (e: React.MouseEvent) => { e.stopPropagation(); @@ -155,7 +155,7 @@ export default function MediaDisplayGLB({ {/* Tooltip for desktop only */} - {!hasTouchScreen && ( + {!shouldUseTouchUI && ( = ({ const { canDelete } = useDropInteractionRules(drop); const [isVotingModalOpen, setIsVotingModalOpen] = useState(false); - // Get device info from useDeviceInfo hook - const { hasTouchScreen } = useDeviceInfo(); + const { shouldUseTouchUI } = useDeviceInfo(); let mediaImageScale = ImageScale.AUTOx800; if (isMobileScreen) { mediaImageScale = ImageScale.AUTOx450; @@ -54,9 +53,8 @@ export const MemesLeaderboardDrop: React.FC = ({ mediaImageScale = ImageScale.AUTOx600; } - // Use long press interaction hook with touch screen info from device hook const { isActive, setIsActive, touchHandlers } = useLongPressInteraction({ - hasTouchScreen, + hasTouchScreen: shouldUseTouchUI, }); // Extract metadata @@ -77,7 +75,7 @@ export const MemesLeaderboardDrop: React.FC = ({
{ - if (hasTouchScreen) return; + if (shouldUseTouchUI) return; startDropOpen({ dropId: drop.id, waveId: drop.wave.id, @@ -96,7 +94,7 @@ export const MemesLeaderboardDrop: React.FC = ({
- {!hasTouchScreen && ( + {!shouldUseTouchUI && ( <> {canDelete && } @@ -213,7 +211,7 @@ export const MemesLeaderboardDrop: React.FC = ({
{/* Touch slide-up menu for leaderboard */} - {hasTouchScreen && + {shouldUseTouchUI && createPortal( = ({ drop, children, }) => { + const { shouldUseTouchUI } = useDeviceInfo(); const borderClasses = getBorderClasses(drop); return ( -
+
{children}
diff --git a/components/providers/LayoutWrapper.tsx b/components/providers/LayoutWrapper.tsx index cca39b192f..40e07a1cca 100644 --- a/components/providers/LayoutWrapper.tsx +++ b/components/providers/LayoutWrapper.tsx @@ -18,7 +18,7 @@ export default function LayoutWrapper({ }: { readonly children: ReactNode; }) { - const { isApp, hasTouchScreen } = useDeviceInfo(); + const { isApp, shouldUseTouchUI } = useDeviceInfo(); const { refreshKey } = useGlobalRefresh(); const isSmallScreen = useIsMobileScreen(); const [isTouchTabletViewport, setIsTouchTabletViewport] = useState(() => { @@ -30,7 +30,7 @@ export default function LayoutWrapper({ const pathname = usePathname(); useEffect(() => { - if (!hasTouchScreen) { + if (!shouldUseTouchUI) { setIsTouchTabletViewport(false); return; } @@ -64,7 +64,7 @@ export default function LayoutWrapper({ mediaQuery.onchange = previousOnChange ?? null; } }; - }, [hasTouchScreen]); + }, [shouldUseTouchUI]); const isAccessOrRestricted = pathname?.startsWith("/access") || pathname?.startsWith("/restricted"); @@ -73,7 +73,7 @@ export default function LayoutWrapper({ WebLayout; const isSmallLayout = - hasTouchScreen && (isSmallScreen || isTouchTabletViewport); + shouldUseTouchUI && (isSmallScreen || isTouchTabletViewport); if (isApp) { LayoutComponent = MobileLayout; diff --git a/components/utils/tooltip/UserProfileTooltipWrapper.tsx b/components/utils/tooltip/UserProfileTooltipWrapper.tsx index 8abf0f7785..c11fe9200e 100644 --- a/components/utils/tooltip/UserProfileTooltipWrapper.tsx +++ b/components/utils/tooltip/UserProfileTooltipWrapper.tsx @@ -14,10 +14,9 @@ export default function UserProfileTooltipWrapper({ children, placement = "auto" }: UserProfileTooltipWrapperProps) { - const { hasTouchScreen } = useDeviceInfo(); + const { shouldUseTouchUI } = useDeviceInfo(); - // If it's a touch device, just render the children without the tooltip - if (hasTouchScreen) { + if (shouldUseTouchUI) { return <>{children}; } diff --git a/components/waves/drops/DropMobileMenuHandler.tsx b/components/waves/drops/DropMobileMenuHandler.tsx index ddaa522b83..86c45cd5ac 100644 --- a/components/waves/drops/DropMobileMenuHandler.tsx +++ b/components/waves/drops/DropMobileMenuHandler.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useRef, useState } from "react"; import WaveDropMobileMenu from "./WaveDropMobileMenu"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import useIsMobileDevice from "@/hooks/isMobileDevice"; -import useIsTouchDevice from "@/hooks/useIsTouchDevice"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; interface DropMobileMenuHandlerProps { readonly drop: ExtendedDrop; @@ -28,7 +28,8 @@ export default function DropMobileMenuHandler({ const [longPressTriggered, setLongPressTriggered] = useState(false); const [isSlideUp, setIsSlideUp] = useState(false); const isMobile = useIsMobileDevice(); - const hasTouch = useIsTouchDevice() || isMobile; + const { shouldUseTouchUI } = useDeviceInfo(); + const hasTouch = shouldUseTouchUI || isMobile; const longPressTimeout = useRef(null); const touchStartX = useRef(0); diff --git a/components/waves/drops/WaveDrop.tsx b/components/waves/drops/WaveDrop.tsx index 65d2c52217..50927e8b4a 100644 --- a/components/waves/drops/WaveDrop.tsx +++ b/components/waves/drops/WaveDrop.tsx @@ -8,7 +8,7 @@ import type { ApiUpdateDropRequest } from "@/generated/models/ApiUpdateDropReque import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { useDropUpdateMutation } from "@/hooks/drops/useDropUpdateMutation"; import useIsMobileDevice from "@/hooks/isMobileDevice"; -import useIsTouchDevice from "@/hooks/useIsTouchDevice"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; import { selectEditingDropId, setEditingDropId } from "@/store/editSlice"; import type { ActiveDropState } from "@/types/dropInteractionTypes"; import { memo, useCallback, useEffect, useRef, useState } from "react"; @@ -99,10 +99,12 @@ const getDropClasses = ( groupingClass: string, location: DropLocation, rank: number | null, - isDrop: boolean + isDrop: boolean, + hasTouch: boolean ): string => { + const touchSelectClass = hasTouch ? "touch-select-none" : ""; const baseClasses = - "touch-select-none tw-cursor-default tw-relative tw-group tw-w-full tw-flex tw-flex-col tw-px-4 tw-transition-colors tw-duration-300"; + `${touchSelectClass} tw-cursor-default tw-relative tw-group tw-w-full tw-flex tw-flex-col tw-px-4 tw-transition-colors tw-duration-300`.trim(); const streamClasses = "tw-rounded-xl"; @@ -172,7 +174,8 @@ const WaveDrop = ({ !isDrop && shouldGroupWithDrop(drop, nextDrop); const isMobile = useIsMobileDevice(); - const hasTouch = useIsTouchDevice() || isMobile; + const { shouldUseTouchUI } = useDeviceInfo(); + const hasTouch = shouldUseTouchUI || isMobile; const compact = useCompactMode(); const isProfileView = location === DropLocation.PROFILE; @@ -343,7 +346,8 @@ const WaveDrop = ({ groupingClass, location, drop.rank, - isDrop + isDrop, + hasTouch ); return ( diff --git a/components/waves/drops/WaveDropContent.tsx b/components/waves/drops/WaveDropContent.tsx index ccc99dbea7..802ff64ca1 100644 --- a/components/waves/drops/WaveDropContent.tsx +++ b/components/waves/drops/WaveDropContent.tsx @@ -3,7 +3,7 @@ import type { ApiDrop } from "@/generated/models/ApiDrop"; import WaveDropPart from "./WaveDropPart"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { ImageScale } from "@/helpers/image.helpers"; -import useIsTouchDevice from "@/hooks/useIsTouchDevice"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; interface WaveDropContentProps { readonly drop: ExtendedDrop; @@ -38,8 +38,8 @@ const WaveDropContent: React.FC = ({ mediaImageScale = ImageScale.AUTOx450, hasTouch, }) => { - const isTouchDevice = useIsTouchDevice(); - const effectiveHasTouch = hasTouch ?? isTouchDevice; + const { shouldUseTouchUI } = useDeviceInfo(); + const effectiveHasTouch = hasTouch ?? shouldUseTouchUI; return ( = ({ ); const [copied, setCopied] = useState(false); + const [copiedText, setCopiedText] = useState(false); - const copyToClipboard = () => { + const extractTextFromDrop = (): string => { + if (!drop.parts || drop.parts.length === 0) { + return ""; + } + + const textParts = drop.parts + .map((part) => { + if (!part.content) return ""; + let text = part.content; + text = text.replaceAll(/\[([^\]]+)\]\([^\)]+\)/g, "$1"); + text = text.replaceAll(/\*\*([^*]+)\*\*/g, "$1"); + text = text.replaceAll(/\*([^*]+)\*/g, "$1"); + text = text.replaceAll(/`([^`]+)`/g, "$1"); + text = text.replaceAll(/#{1,6}\s+/g, ""); + text = text.replaceAll(/\n{3,}/g, "\n\n"); + return text.trim(); + }) + .filter((text) => text.length > 0); + + return textParts.join("\n\n"); + }; + + const copyTextToClipboard = async () => { + if (longPressTriggered) return; + if (isTemporaryDrop) return; + + const text = extractTextFromDrop(); + if (!text) return; + + try { + await navigator.clipboard.writeText(text); + setCopiedText(true); + setTimeout(() => setCopiedText(false), 2000); + } catch (err) { + console.error("Failed to copy text:", err); + } + }; + + const copyToClipboard = async () => { if (longPressTriggered) return; if (isTemporaryDrop) return; @@ -94,22 +133,12 @@ const WaveDropMobileMenu: FC = ({ isApp: false, })}`; - if (navigator?.clipboard?.writeText) { - navigator.clipboard.writeText(dropLink).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); - } else { - const textArea = document.createElement("textarea"); - textArea.value = dropLink; - textArea.style.position = "fixed"; - textArea.style.left = "-9999px"; - document.body.appendChild(textArea); - textArea.select(); - document.execCommand("copy"); - document.body.removeChild(textArea); + try { + await navigator.clipboard.writeText(dropLink); setCopied(true); setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy link:", err); } }; @@ -246,6 +275,40 @@ const WaveDropMobileMenu: FC = ({ /> )} + {extractTextFromDrop() && ( + + )} + {showCopyOption && (