From 53e8ef53d26a810f17f9424d6c9c15dd11b8f484 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Sat, 24 Jan 2026 08:57:33 +0200 Subject: [PATCH 1/4] Touch devices Signed-off-by: prxt6529 --- __tests__/FooterWrapper.test.tsx | 2 + .../waves/UnifiedWavesList.test.tsx | 4 + .../header/HeaderSearchModal.test.tsx | 1 + .../header/HeaderSearchModalItem.test.tsx | 2 + .../memes/drops/MemesLeaderboardDrop.test.tsx | 8 +- .../components/navigation/BackButton.test.tsx | 2 + .../navigation/ViewContext.test.tsx | 2 + .../network/NetworkPageLayout.test.tsx | 4 + .../UserProfileTooltipWrapper.test.tsx | 8 + .../waves/drop/SingleWaveDropChat.test.tsx | 1 + .../drops/DropMobileMenuHandler.test.tsx | 2 +- .../components/waves/drops/WaveDrop.test.tsx | 2 +- .../waves/drops/WaveDropReactions.test.tsx | 10 +- .../waves/drops/WaveDropsAll.test.tsx | 2 + ...ltWaveLeaderboardDrop.interaction.test.tsx | 4 +- .../drops/DefaultWaveLeaderboardDrop.test.tsx | 2 +- .../drops/MemesWaveWinnerDrop.test.tsx | 2 +- __tests__/hooks/useIsTouchDevice.test.ts | 137 ------------------ .../waves/BrainLeftSidebarWave.tsx | 4 +- .../web/WebBrainLeftSidebarWave/index.tsx | 8 +- .../web/WebDirectMessagesList.tsx | 5 +- .../web/WebUnifiedWavesListWaves.tsx | 5 +- .../item/content/media/MediaDisplayGLB.tsx | 6 +- .../layout/sidebar/nav/WebSidebarNavItem.tsx | 4 +- .../memes/drops/MemesLeaderboardDrop.tsx | 12 +- .../memes/drops/MemesLeaderboardDropCard.tsx | 4 +- components/providers/LayoutWrapper.tsx | 8 +- .../tooltip/UserProfileTooltipWrapper.tsx | 5 +- .../waves/drops/ArtistSubmissionBadge.tsx | 6 +- .../waves/drops/DropMobileMenuHandler.tsx | 5 +- components/waves/drops/WaveDrop.tsx | 14 +- components/waves/drops/WaveDropContent.tsx | 6 +- components/waves/drops/WaveDropMobileMenu.tsx | 83 +++++++++++ components/waves/drops/WaveDropPart.tsx | 4 +- components/waves/drops/WaveDropReactions.tsx | 5 +- .../participation/EndedParticipationDrop.tsx | 5 +- .../OngoingParticipationDrop.tsx | 5 +- .../ParticipationDropContent.tsx | 5 +- .../waves/drops/winner/DefaultWinnerDrop.tsx | 5 +- components/waves/gallery/WaveGalleryItem.tsx | 6 +- .../drops/DefaultWaveLeaderboardDrop.tsx | 6 +- .../gallery/WaveLeaderboardGalleryItem.tsx | 12 +- .../winners/drops/DefaultWaveWinnerDrop.tsx | 10 +- .../winners/drops/MemesWaveWinnerDrop.tsx | 12 +- hooks/useDeviceInfo.ts | 21 +++ hooks/useIsTouchDevice.ts | 55 ------- 46 files changed, 234 insertions(+), 287 deletions(-) delete mode 100644 __tests__/hooks/useIsTouchDevice.test.ts delete mode 100644 hooks/useIsTouchDevice.ts 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..7ba4a9f433 100644 --- a/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx +++ b/__tests__/components/brain/left-sidebar/waves/UnifiedWavesList.test.tsx @@ -32,6 +32,7 @@ type DeviceInfo = { isApp: boolean; isMobileDevice: boolean; hasTouchScreen: boolean; + shouldUseTouchUI: boolean; isAppleMobile: boolean; }; const useDeviceInfoMock = useDeviceInfo as jest.MockedFunction; @@ -42,6 +43,7 @@ beforeEach(() => { isApp: false, isMobileDevice: false, hasTouchScreen: false, + shouldUseTouchUI: false, isAppleMobile: false, } as DeviceInfo); }); @@ -72,6 +74,8 @@ describe('UnifiedWavesList', () => { isApp: true, isMobileDevice: false, hasTouchScreen: false, + shouldUseTouchUI: false, + shouldUseTouchUI: false, isAppleMobile: false, } as DeviceInfo); const fetchNextPage = jest.fn(); diff --git a/__tests__/components/header/HeaderSearchModal.test.tsx b/__tests__/components/header/HeaderSearchModal.test.tsx index 7bc53509f6..6c0c6b5f10 100644 --- a/__tests__/components/header/HeaderSearchModal.test.tsx +++ b/__tests__/components/header/HeaderSearchModal.test.tsx @@ -162,6 +162,7 @@ function setup(options: SetupOptions = {}) { isApp: false, isMobileDevice: false, hasTouchScreen: false, + shouldUseTouchUI: false, isAppleMobile: false, }); useAppWalletsMock.mockReturnValue({ appWalletsSupported: true }); diff --git a/__tests__/components/header/HeaderSearchModalItem.test.tsx b/__tests__/components/header/HeaderSearchModalItem.test.tsx index ad334c93ae..4971a59ea9 100644 --- a/__tests__/components/header/HeaderSearchModalItem.test.tsx +++ b/__tests__/components/header/HeaderSearchModalItem.test.tsx @@ -16,6 +16,8 @@ jest.mock("@/hooks/useDeviceInfo", () => ({ 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 9df8813a1d..a3315e2544 100644 --- a/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx +++ b/__tests__/components/waves/drops/DropMobileMenuHandler.test.tsx @@ -4,7 +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('@/hooks/useDeviceInfo', () => ({ __esModule: true, default: () => ({ shouldUseTouchUI: 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 4eb6c77335..3e4bf231f5 100644 --- a/__tests__/components/waves/drops/WaveDrop.test.tsx +++ b/__tests__/components/waves/drops/WaveDrop.test.tsx @@ -16,7 +16,7 @@ jest.mock('@/components/waves/drops/WaveDropRatings', () => () =>
() =>
); jest.mock('@/hooks/isMobileDevice'); -jest.mock('@/hooks/useIsTouchDevice', () => ({ __esModule: true, default: jest.fn(() => false) })); +jest.mock('@/hooks/useDeviceInfo', () => ({ __esModule: true, default: jest.fn(() => ({ shouldUseTouchUI: false })) })); jest.mock('next/navigation', () => ({ useRouter: jest.fn(() => ({ push: jest.fn() })), 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 6b6f41361e..e793307c55 100644 --- a/__tests__/components/waves/drops/WaveDropsAll.test.tsx +++ b/__tests__/components/waves/drops/WaveDropsAll.test.tsx @@ -179,6 +179,7 @@ interface MockSetupOptions { isAppleMobile?: boolean | undefined; isMobileDevice?: boolean | undefined; hasTouchScreen?: boolean | undefined; + shouldUseTouchUI?: boolean | undefined; isApp?: boolean | undefined; } | undefined; } @@ -258,6 +259,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__/hooks/useIsTouchDevice.test.ts b/__tests__/hooks/useIsTouchDevice.test.ts deleted file mode 100644 index 5bc0e2f54e..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)); - }); -}); diff --git a/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx b/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx index ead841fa78..1c0bc43af4 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(() => { @@ -139,7 +139,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; @@ -53,9 +52,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 @@ -75,7 +73,7 @@ export const MemesLeaderboardDrop: React.FC = ({ return (
!hasTouchScreen && onDropClick(drop)} + onClick={() => !shouldUseTouchUI && onDropClick(drop)} >
@@ -86,7 +84,7 @@ export const MemesLeaderboardDrop: React.FC = ({
- {!hasTouchScreen && ( + {!shouldUseTouchUI && ( <> {canDelete && } @@ -203,7 +201,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/ArtistSubmissionBadge.tsx b/components/waves/drops/ArtistSubmissionBadge.tsx index 65aec6424c..d42db3ed62 100644 --- a/components/waves/drops/ArtistSubmissionBadge.tsx +++ b/components/waves/drops/ArtistSubmissionBadge.tsx @@ -29,7 +29,7 @@ export const ArtistSubmissionBadge: React.FC = ({ tooltipId = "submission-badge", }) => { const isMobile = useIsMobileDevice(); - const { hasTouchScreen } = useDeviceInfo(); + const { shouldUseTouchUI } = useDeviceInfo(); const id = useId(); const uniqueTooltipId = `${tooltipId}-${id}`; const [isTooltipOpen, setIsTooltipOpen] = React.useState(false); @@ -37,7 +37,7 @@ export const ArtistSubmissionBadge: React.FC = ({ if (submissionCount === 0) return null; const dataTooltipId = - !isMobile && !hasTouchScreen ? uniqueTooltipId : undefined; + !isMobile && !shouldUseTouchUI ? uniqueTooltipId : undefined; return ( <> @@ -74,7 +74,7 @@ export const ArtistSubmissionBadge: React.FC = ({ {/* Tooltip - only on non-touch devices */} - {!isMobile && !hasTouchScreen && ( + {!isMobile && !shouldUseTouchUI && ( (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 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.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1"); + text = text.replace(/\*\*([^\*]+)\*\*/g, "$1"); + text = text.replace(/\*([^\*]+)\*/g, "$1"); + text = text.replace(/`([^`]+)`/g, "$1"); + text = text.replace(/#{1,6}\s+/g, ""); + text = text.replace(/\n{3,}/g, "\n\n"); + return text.trim(); + }) + .filter((text) => text.length > 0); + + return textParts.join("\n\n"); + }; + + const copyTextToClipboard = () => { + if (longPressTriggered) return; + if (isTemporaryDrop) return; + + const text = extractTextFromDrop(); + if (!text) return; + + if (navigator?.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(() => { + setCopiedText(true); + setTimeout(() => setCopiedText(false), 2000); + }); + } else { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-9999px"; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand("copy"); + document.body.removeChild(textArea); + setCopiedText(true); + setTimeout(() => setCopiedText(false), 2000); + } + }; const copyToClipboard = () => { if (longPressTriggered) return; @@ -246,6 +295,40 @@ const WaveDropMobileMenu: FC = ({ /> )} + {extractTextFromDrop() && ( + + )} + {showCopyOption && (