From a1b0aae31ce3a3146da1137776e37b6a26185a29 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Thu, 8 Jan 2026 16:23:29 +0200 Subject: [PATCH 1/5] Reactions dialog Signed-off-by: prxt6529 --- .../waves/drops/WaveDropReactions.test.tsx | 192 +++++++++++++++- .../WaveDropReactionsDetailDialog.test.tsx | 183 ++++++++++++++++ __tests__/hooks/useIsTouchDevice.test.ts | 115 ++++++++++ .../web/WebDirectMessagesList.tsx | 60 ++--- .../web/WebUnifiedWavesListWaves.tsx | 36 ++- components/waves/drops/WaveDropReactions.tsx | 193 ++++++++++------ .../drops/WaveDropReactionsDetailDialog.tsx | 206 ++++++++++++++++++ hooks/useIsTouchDevice.ts | 26 +++ 8 files changed, 883 insertions(+), 128 deletions(-) create mode 100644 __tests__/components/waves/drops/WaveDropReactionsDetailDialog.test.tsx create mode 100644 __tests__/hooks/useIsTouchDevice.test.ts create mode 100644 components/waves/drops/WaveDropReactionsDetailDialog.tsx create mode 100644 hooks/useIsTouchDevice.ts diff --git a/__tests__/components/waves/drops/WaveDropReactions.test.tsx b/__tests__/components/waves/drops/WaveDropReactions.test.tsx index 24d3f67d6d..67737fe058 100644 --- a/__tests__/components/waves/drops/WaveDropReactions.test.tsx +++ b/__tests__/components/waves/drops/WaveDropReactions.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import WaveDropReactions from "@/components/waves/drops/WaveDropReactions"; import { useEmoji } from "@/contexts/EmojiContext"; -import * as commonApi from "@/services/api/common-api"; // Import directly to mock methods +import * as commonApi from "@/services/api/common-api"; jest.mock("@/contexts/wave/MyStreamContext", () => ({ useMyStream: jest.fn(() => ({ @@ -10,12 +10,10 @@ jest.mock("@/contexts/wave/MyStreamContext", () => ({ })), })); -// Mock useEmoji with sample emojiMap and findNativeEmoji jest.mock("@/contexts/EmojiContext", () => ({ useEmoji: jest.fn(), })); -// Mock formatLargeNumber for predictable output (optional) jest.mock("@/helpers/Helpers", () => ({ formatLargeNumber: (num: number) => `${num}`, })); @@ -25,6 +23,24 @@ jest.mock("@/services/api/common-api", () => ({ commonApiDelete: jest.fn(), })); +jest.mock("@/hooks/useIsTouchDevice", () => ({ + __esModule: true, + default: jest.fn(() => false), +})); + +jest.mock("@/hooks/useLongPressInteraction", () => ({ + __esModule: true, + default: jest.fn(() => ({ + longPressTriggered: false, + touchHandlers: { + onTouchStart: jest.fn(), + onTouchMove: jest.fn(), + onTouchEnd: jest.fn(), + onTouchCancel: jest.fn(), + }, + })), +})); + const mockUseEmoji = useEmoji as jest.Mock; type NativeEmojiMock = { skins: Array<{ native: string }> }; @@ -223,4 +239,174 @@ describe("WaveDropReactions", () => { expect(button).toHaveTextContent("2"); }); }); + + it("shows 'and X more' in tooltip when more than 3 profiles", async () => { + mockUseEmoji.mockReturnValue( + createEmojiContextValue( + [ + { + category: "people", + emojis: [{ id: "gm", skins: [{ src: "gm.png" }] }], + }, + ], + () => null + ) + ); + + render( + + ); + + const reactionButton = screen.getAllByRole("button")[0]; + fireEvent.mouseEnter(reactionButton); + + await waitFor(() => { + const moreButton = screen.queryByText(/and 2 others/); + expect(moreButton).toBeInTheDocument(); + }); + }); + + it("opens detail dialog when 'and X more' is clicked", async () => { + mockUseEmoji.mockReturnValue( + createEmojiContextValue( + [ + { + category: "people", + emojis: [{ id: "gm", skins: [{ src: "gm.png" }] }], + }, + ], + () => null + ) + ); + + render( + + ); + + const reactionButton = screen.getAllByRole("button")[0]; + fireEvent.mouseEnter(reactionButton); + + await waitFor(() => { + const moreButton = screen.getByText(/and 1 other/); + fireEvent.click(moreButton); + }); + + await waitFor(() => { + expect(screen.getByText("Reactions")).toBeInTheDocument(); + }); + }); + + it("adds touch handlers for long press on touch devices", () => { + const mockUseIsTouchDevice = require("@/hooks/useIsTouchDevice").default; + mockUseIsTouchDevice.mockReturnValue(true); + + mockUseEmoji.mockReturnValue( + createEmojiContextValue( + [ + { + category: "people", + emojis: [{ id: "gm", skins: [{ src: "gm.png" }] }], + }, + ], + () => null + ) + ); + + render( + + ); + + const reactionButton = screen.getAllByRole("button")[0]; + expect(reactionButton).toBeInTheDocument(); + + mockUseIsTouchDevice.mockReturnValue(false); + }); + + it("renders profile handles as clickable links in tooltip", async () => { + mockUseEmoji.mockReturnValue( + createEmojiContextValue( + [ + { + category: "people", + emojis: [{ id: "gm", skins: [{ src: "gm.png" }] }], + }, + ], + () => null + ) + ); + + render( + + ); + + const reactionButton = screen.getAllByRole("button")[0]; + fireEvent.mouseEnter(reactionButton); + + await waitFor(() => { + const user1Link = screen.getByRole("link", { name: "user1" }); + expect(user1Link).toHaveAttribute("href", "/user1"); + }); + }); }); diff --git a/__tests__/components/waves/drops/WaveDropReactionsDetailDialog.test.tsx b/__tests__/components/waves/drops/WaveDropReactionsDetailDialog.test.tsx new file mode 100644 index 0000000000..f1df7dd9c7 --- /dev/null +++ b/__tests__/components/waves/drops/WaveDropReactionsDetailDialog.test.tsx @@ -0,0 +1,183 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import WaveDropReactionsDetailDialog from "@/components/waves/drops/WaveDropReactionsDetailDialog"; +import { useEmoji } from "@/contexts/EmojiContext"; + +jest.mock("@/contexts/EmojiContext", () => ({ + useEmoji: jest.fn(), +})); + +const mockUseEmoji = useEmoji as jest.Mock; + +const createEmojiContextValue = ( + emojiMap: Array<{ + category: string; + emojis: Array<{ id: string; skins: Array<{ src: string }> }>; + }> = [], + findNativeEmoji: ( + id: string + ) => { skins: Array<{ native: string }> } | null = () => null +) => ({ + emojiMap, + loading: false, + categories: [], + categoryIcons: {}, + findNativeEmoji, + findCustomEmoji: () => null, +}); + +const mockReactions = [ + { + reaction: ":thumbsup:", + profiles: [ + { id: "1", handle: "user1", pfp: "https://example.com/pfp1.jpg" }, + { id: "2", handle: "user2", pfp: null }, + { id: "3", handle: null, pfp: null }, + ], + }, + { + reaction: ":heart:", + profiles: [{ id: "4", handle: "user4", pfp: "https://example.com/pfp4.jpg" }], + }, +]; + +describe("WaveDropReactionsDetailDialog", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseEmoji.mockReturnValue( + createEmojiContextValue([], (id: string) => { + if (id === "thumbsup") return { skins: [{ native: "👍" }] }; + if (id === "heart") return { skins: [{ native: "❤️" }] }; + return null; + }) + ); + }); + + it("renders nothing when closed", () => { + render( + + ); + + expect(screen.queryByText("Reactions")).not.toBeInTheDocument(); + }); + + it("renders dialog with title when open", () => { + render( + + ); + + expect(screen.getByText("Reactions")).toBeInTheDocument(); + }); + + it("renders reaction buttons in sidebar", () => { + render( + + ); + + expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + }); + + it("renders profiles for selected reaction", () => { + render( + + ); + + expect(screen.getByText("user1")).toBeInTheDocument(); + expect(screen.getByText("user2")).toBeInTheDocument(); + }); + + it("switches reaction when clicking another reaction button", () => { + render( + + ); + + expect(screen.getByText("user1")).toBeInTheDocument(); + + const heartButton = screen.getByText("1").closest("button"); + if (heartButton) { + fireEvent.click(heartButton); + } + + expect(screen.getByText("user4")).toBeInTheDocument(); + expect(screen.queryByText("user1")).not.toBeInTheDocument(); + }); + + it("renders profile links for users with handles", () => { + render( + + ); + + const user1Link = screen.getByText("user1").closest("a"); + expect(user1Link).toHaveAttribute("href", "/user1"); + }); + + it("renders profile avatars", () => { + render( + + ); + + const avatars = screen.getAllByRole("img"); + expect(avatars.length).toBeGreaterThan(0); + }); + + it("calls onClose when dialog is closed", () => { + const onClose = jest.fn(); + render( + + ); + + const closeButton = screen.getByTitle("Close panel"); + fireEvent.click(closeButton); + + expect(onClose).toHaveBeenCalled(); + }); + + it("defaults to first reaction when no initialReaction provided", () => { + render( + + ); + + expect(screen.getByText("user1")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/hooks/useIsTouchDevice.test.ts b/__tests__/hooks/useIsTouchDevice.test.ts new file mode 100644 index 0000000000..46c7cc2154 --- /dev/null +++ b/__tests__/hooks/useIsTouchDevice.test.ts @@ -0,0 +1,115 @@ +import { renderHook, act } from "@testing-library/react"; +import useIsTouchDevice from "@/hooks/useIsTouchDevice"; + +describe("useIsTouchDevice", () => { + const originalWindow = global.window; + const originalNavigator = global.navigator; + + afterEach(() => { + Object.defineProperty(global, "window", { + value: originalWindow, + writable: true, + }); + Object.defineProperty(global, "navigator", { + value: originalNavigator, + writable: true, + }); + }); + + it("returns false when window is undefined", () => { + Object.defineProperty(global, "window", { + value: undefined, + writable: true, + }); + + const { result } = renderHook(() => useIsTouchDevice()); + expect(result.current).toBe(false); + }); + + it("returns true when ontouchstart is in window", () => { + const mockWindow = { + ...originalWindow, + ontouchstart: null, + matchMedia: jest.fn(() => ({ matches: false })), + }; + Object.defineProperty(global, "window", { + value: mockWindow, + writable: true, + }); + + const { result } = renderHook(() => useIsTouchDevice()); + + act(() => { + jest.runAllTimers?.(); + }); + + expect(result.current).toBe(true); + }); + + it("returns true when navigator.maxTouchPoints > 0", () => { + const mockWindow = { + ...originalWindow, + matchMedia: jest.fn(() => ({ matches: false })), + }; + Object.defineProperty(global, "window", { + value: mockWindow, + writable: true, + }); + Object.defineProperty(global, "navigator", { + value: { maxTouchPoints: 5 }, + writable: true, + }); + + const { result } = renderHook(() => useIsTouchDevice()); + + act(() => { + jest.runAllTimers?.(); + }); + + expect(result.current).toBe(true); + }); + + it("returns true when matchMedia pointer:coarse matches", () => { + const mockWindow = { + ...originalWindow, + matchMedia: jest.fn((query: string) => ({ + matches: query === "(pointer: coarse)", + })), + }; + Object.defineProperty(global, "window", { + value: mockWindow, + writable: true, + }); + Object.defineProperty(global, "navigator", { + value: { maxTouchPoints: 0 }, + writable: true, + }); + + const { result } = renderHook(() => useIsTouchDevice()); + + act(() => { + jest.runAllTimers?.(); + }); + + expect(result.current).toBe(true); + }); + + it("returns false when no touch indicators present", () => { + const mockWindow = { + ...originalWindow, + matchMedia: jest.fn(() => ({ matches: false })), + }; + Object.defineProperty(global, "window", { + value: mockWindow, + writable: true, + }); + Object.defineProperty(global, "navigator", { + value: { maxTouchPoints: 0 }, + writable: true, + }); + + const { result } = renderHook(() => useIsTouchDevice()); + + expect(result.current).toBe(false); + }); +}); diff --git a/components/brain/left-sidebar/web/WebDirectMessagesList.tsx b/components/brain/left-sidebar/web/WebDirectMessagesList.tsx index a18976b60b..bfad294ad3 100644 --- a/components/brain/left-sidebar/web/WebDirectMessagesList.tsx +++ b/components/brain/left-sidebar/web/WebDirectMessagesList.tsx @@ -1,25 +1,24 @@ "use client"; -import React, { useRef, useContext, type ReactNode } from "react"; -import type { - WebUnifiedWavesListWavesHandle, -} from "./WebUnifiedWavesListWaves"; -import WebUnifiedWavesListWaves from "./WebUnifiedWavesListWaves"; -import { UnifiedWavesListLoader } from "../waves/UnifiedWavesListLoader"; -import UnifiedWavesListEmpty from "../waves/UnifiedWavesListEmpty"; -import PrimaryButton from "../../../utils/button/PrimaryButton"; +import useCreateModalState from "@/hooks/useCreateModalState"; +import useIsTouchDevice from "@/hooks/useIsTouchDevice"; import { faPaperPlane } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Image from "next/image"; +import React, { useContext, useRef, type ReactNode } from "react"; import { Tooltip as ReactTooltip } from "react-tooltip"; import { useMyStream } from "../../../../contexts/wave/MyStreamContext"; +import { useInfiniteScroll } from "../../../../hooks/useInfiniteScroll"; import { AuthContext } from "../../../auth/Auth"; -import HeaderUserConnect from "../../../header/user/HeaderUserConnect"; import { useSeizeConnectContext } from "../../../auth/SeizeConnectContext"; -import Image from "next/image"; +import HeaderUserConnect from "../../../header/user/HeaderUserConnect"; import UserSetUpProfileCta from "../../../user/utils/set-up-profile/UserSetUpProfileCta"; -import { useInfiniteScroll } from "../../../../hooks/useInfiniteScroll"; +import PrimaryButton from "../../../utils/button/PrimaryButton"; import CreateDirectMessageModal from "../../../waves/create-dm/CreateDirectMessageModal"; -import useCreateModalState from "@/hooks/useCreateModalState"; +import UnifiedWavesListEmpty from "../waves/UnifiedWavesListEmpty"; +import { UnifiedWavesListLoader } from "../waves/UnifiedWavesListLoader"; +import type { WebUnifiedWavesListWavesHandle } from "./WebUnifiedWavesListWaves"; +import WebUnifiedWavesListWaves from "./WebUnifiedWavesListWaves"; interface WebDirectMessagesListProps { readonly scrollContainerRef: React.RefObject; @@ -34,19 +33,7 @@ const WebDirectMessagesList: React.FC = ({ const { connectedProfile } = useContext(AuthContext); const { isDirectMessageModalOpen, openDirectMessage, close, isApp } = useCreateModalState(); - - const globalScope = globalThis as typeof globalThis & { - window?: Window | undefined; - navigator?: Navigator | undefined; - }; - const browserWindow = globalScope.window; - const browserNavigator = globalScope.navigator; - - const isTouchDevice = - !!browserWindow && - ("ontouchstart" in browserWindow || - (browserNavigator?.maxTouchPoints ?? 0) > 0 || - browserWindow.matchMedia?.("(pointer: coarse)")?.matches); + const isTouchDevice = useIsTouchDevice(); const shouldRenderCreateDirectMessage = !isApp; @@ -69,10 +56,7 @@ const WebDirectMessagesList: React.FC = ({ let listContent: ReactNode; if (isInitialLoad) { listContent = ( - + ); } else if (isEmpty) { listContent = ( @@ -101,8 +85,8 @@ const WebDirectMessagesList: React.FC = ({ if (!isAuthenticated) { return ( -
-
+
+
6529 Seize = ({ if (!connectedProfile) { return ( -
-
+
+
6529 Seize = ({ } return ( -
-
+
+
{(shouldRenderCreateDirectMessage || !isCollapsed) && (
= ({
)} -
+
{listContent} (null); const { connectedProfile } = useAuth(); const { openWave, isApp } = useCreateModalState(); - - const globalScope = globalThis as typeof globalThis & { - window?: Window | undefined; - navigator?: Navigator | undefined; - }; - const browserWindow = globalScope.window; - const browserNavigator = globalScope.navigator; - - const isTouchDevice = - !!browserWindow && - ("ontouchstart" in browserWindow || - (browserNavigator?.maxTouchPoints ?? 0) > 0 || - browserWindow.matchMedia?.("(pointer: coarse)")?.matches); + const isTouchDevice = useIsTouchDevice(); useImperativeHandle(ref, () => ({ sentinelRef, diff --git a/components/waves/drops/WaveDropReactions.tsx b/components/waves/drops/WaveDropReactions.tsx index 8e4e1d345b..fd3246f262 100644 --- a/components/waves/drops/WaveDropReactions.tsx +++ b/components/waves/drops/WaveDropReactions.tsx @@ -10,9 +10,12 @@ import type { ApiDropReaction } from "@/generated/models/ApiDropReaction"; import { formatLargeNumber } from "@/helpers/Helpers"; import { buildTooltipId } from "@/helpers/tooltip.helpers"; import { DropSize } from "@/helpers/waves/drop.helpers"; +import useIsTouchDevice from "@/hooks/useIsTouchDevice"; +import useLongPressInteraction from "@/hooks/useLongPressInteraction"; import { commonApiDelete, commonApiPost } from "@/services/api/common-api"; import clsx from "clsx"; import Image from "next/image"; +import Link from "next/link"; import React, { useCallback, useEffect, @@ -28,12 +31,24 @@ import { toProfileMin, } from "./reaction-utils"; import styles from "./WaveDropReactions.module.scss"; +import WaveDropReactionsDetailDialog from "./WaveDropReactionsDetailDialog"; interface WaveDropReactionsProps { readonly drop: ApiDrop; } const WaveDropReactions: React.FC = ({ drop }) => { + const [dialogReaction, setDialogReaction] = useState(null); + const isTouchDevice = useIsTouchDevice(); + + const handleOpenDialog = useCallback((reactionKey: string) => { + setDialogReaction(reactionKey); + }, []); + + const handleCloseDialog = useCallback(() => { + setDialogReaction(null); + }, []); + return ( <> {drop.reactions.map((reaction) => ( @@ -41,8 +56,16 @@ const WaveDropReactions: React.FC = ({ drop }) => { key={`${reaction.reaction}-${reaction.profiles.length}`} drop={drop} reaction={reaction} + onOpenDetailDialog={handleOpenDialog} + isTouchDevice={isTouchDevice} /> ))} + ); }; @@ -50,22 +73,55 @@ const WaveDropReactions: React.FC = ({ drop }) => { function WaveDropReaction({ drop, reaction, + onOpenDetailDialog, + isTouchDevice, }: { readonly drop: ApiDrop; readonly reaction: ApiDropReaction; + readonly onOpenDetailDialog: (reactionKey: string) => void; + readonly isTouchDevice: boolean; }) { const { setToast, connectedProfile } = useAuth(); const { emojiMap, findNativeEmoji } = useEmoji(); const { applyOptimisticDropUpdate } = useMyStream(); const rollbackRef = useRef<(() => void) | null>(null); + const handleLongPressStart = useCallback(() => { + onOpenDetailDialog(reaction.reaction); + }, [onOpenDetailDialog, reaction.reaction]); + + const { longPressTriggered, touchHandlers } = useLongPressInteraction({ + hasTouchScreen: isTouchDevice, + onInteractionStart: handleLongPressStart, + longPressDuration: 400, + }); + + const wrappedTouchHandlers = useMemo( + () => ({ + onTouchStart: (e: React.TouchEvent) => { + e.stopPropagation(); + touchHandlers.onTouchStart(e); + }, + onTouchMove: (e: React.TouchEvent) => { + e.stopPropagation(); + touchHandlers.onTouchMove(e); + }, + onTouchEnd: (e: React.TouchEvent) => { + e.stopPropagation(); + touchHandlers.onTouchEnd(); + }, + onTouchCancel: (e: React.TouchEvent) => { + e.stopPropagation(); + touchHandlers.onTouchCancel(); + }, + }), + [touchHandlers] + ); + const [total, setTotal] = useState(reaction.profiles.length); const [selected, setSelected] = useState( reaction.reaction === drop.context_profile_context?.reaction ); - const [handles, setHandles] = useState( - reaction.profiles.map((p) => p.handle ?? p.id) - ); const [animate, setAnimate] = useState(false); // Refs to track previous values for change detection @@ -90,7 +146,6 @@ function WaveDropReaction({ return () => clearTimeout(timeoutId); }, [drop.context_profile_context?.reaction, reaction.reaction]); - // Sync total and handles when profiles change from server useEffect(() => { if (reaction.profiles === prevProfilesRef.current) { return; @@ -98,17 +153,9 @@ function WaveDropReaction({ prevProfilesRef.current = reaction.profiles; const nextTotal = reaction.profiles.length; - const nextHandles = reaction.profiles.map((p) => p.handle ?? p.id); const timeoutId = setTimeout(() => { setTotal((current) => (current === nextTotal ? current : nextTotal)); - setHandles((current) => { - const sameLength = current.length === nextHandles.length; - const sameValues = sameLength - ? current.every((value, index) => value === nextHandles[index]) - : false; - return sameValues ? current : nextHandles; - }); }, 0); return () => clearTimeout(timeoutId); }, [reaction.profiles]); @@ -268,25 +315,12 @@ function WaveDropReaction({ ); const handleClick = useCallback(async () => { - const resolvedHandle = - connectedProfile?.handle ?? connectedProfile?.id ?? ""; + if (longPressTriggered) { + return; + } - // optimistic update setSelected((s) => !s); setTotal((n) => Math.max(0, n + (selected ? -1 : 1))); - if (selected) { - setHandles((h) => - resolvedHandle ? h.filter((value) => value !== resolvedHandle) : h - ); - } else { - setHandles((h) => { - if (!resolvedHandle) { - return h; - } - const nextHandles = [...h, resolvedHandle]; - return Array.from(new Set(nextHandles)); - }); - } applyOptimisticReactionChange(!selected); @@ -308,43 +342,34 @@ function WaveDropReaction({ if (typeof error === "string") msg = error; setToast({ message: msg, type: "error" }); - // optimistic revert setSelected((s) => !s); setTotal((n) => Math.max(0, n + (selected ? 1 : -1))); - if (selected) { - setHandles((h) => - resolvedHandle ? Array.from(new Set([...h, resolvedHandle])) : h - ); - } else { - setHandles((h) => - resolvedHandle ? h.filter((value) => value !== resolvedHandle) : h - ); - } rollbackRef.current?.(); rollbackRef.current = null; } rollbackRef.current = null; }, [ applyOptimisticReactionChange, - connectedProfile?.handle, - connectedProfile?.id, drop.id, + longPressTriggered, reaction.reaction, selected, setToast, ]); - // tooltip text - const tooltipText = useMemo(() => { - const limit = 12; - const truncate = (handle: string) => - handle.length > limit ? handle.slice(0, limit) + "…" : handle; - - const truncatedHandles = handles.map(truncate); + const tooltipProfiles = useMemo(() => { + const displayProfiles = reaction.profiles.slice(0, 3); + const moreCount = total > 3 ? total - 3 : 0; + return { displayProfiles, moreCount }; + }, [reaction.profiles, total]); - if (total <= 3) return truncatedHandles.join(", "); - return `${truncatedHandles.slice(0, 3).join(", ")} and ${total - 3} more`; - }, [handles, total]); + const handleMoreClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onOpenDetailDialog(reaction.reaction); + }, + [onOpenDetailDialog, reaction.reaction] + ); // styles const borderStyle = selected ? "tw-border-primary-500" : "tw-border-iron-700"; @@ -366,7 +391,7 @@ function WaveDropReaction({ <> - -
- {emojiNodeTooltip} - by {tooltipText} -
-
+ {!isTouchDevice && ( + +
+ {emojiNodeTooltip} + + by{" "} + {tooltipProfiles.displayProfiles.map((profile, index) => { + const displayName = profile.handle ?? profile.id; + const isLast = + index === tooltipProfiles.displayProfiles.length - 1; + const showComma = !isLast; + + return ( + + {profile.handle ? ( + e.stopPropagation()} + > + {displayName} + + ) : ( + {displayName} + )} + {showComma && ", "} + + ); + })} + {tooltipProfiles.moreCount > 0 && ( + <> + {" "} + + + )} + +
+
+ )} ); } diff --git a/components/waves/drops/WaveDropReactionsDetailDialog.tsx b/components/waves/drops/WaveDropReactionsDetailDialog.tsx new file mode 100644 index 0000000000..ea6e8c120b --- /dev/null +++ b/components/waves/drops/WaveDropReactionsDetailDialog.tsx @@ -0,0 +1,206 @@ +"use client"; + +import MobileWrapperDialog from "@/components/mobile-wrapper-dialog/MobileWrapperDialog"; +import { useEmoji } from "@/contexts/EmojiContext"; +import type { ApiDropReaction } from "@/generated/models/ApiDropReaction"; +import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; +import clsx from "clsx"; +import Image from "next/image"; +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; + +interface WaveDropReactionsDetailDialogProps { + readonly isOpen: boolean; + readonly onClose: () => void; + readonly reactions: ApiDropReaction[]; + readonly initialReaction?: string | undefined; +} + +export default function WaveDropReactionsDetailDialog({ + isOpen, + onClose, + reactions, + initialReaction, +}: WaveDropReactionsDetailDialogProps) { + const [selectedReaction, setSelectedReaction] = useState( + initialReaction ?? reactions[0]?.reaction ?? "" + ); + + useEffect(() => { + if (isOpen && initialReaction) { + setSelectedReaction(initialReaction); + } + }, [isOpen, initialReaction]); + + const selectedReactionData = useMemo( + () => reactions.find((r) => r.reaction === selectedReaction), + [reactions, selectedReaction] + ); + + return ( + +
+ + +
+
+ ); +} + +function ReactionsSidebar({ + reactions, + selectedReaction, + onSelectReaction, +}: { + readonly reactions: ApiDropReaction[]; + readonly selectedReaction: string; + readonly onSelectReaction: (reaction: string) => void; +}) { + return ( +
+
+ {reactions.map((reaction) => ( + onSelectReaction(reaction.reaction)} + /> + ))} +
+
+ ); +} + +function ReactionButton({ + reaction, + isSelected, + onClick, +}: { + readonly reaction: ApiDropReaction; + readonly isSelected: boolean; + readonly onClick: () => void; +}) { + const { emojiMap, findNativeEmoji } = useEmoji(); + + const emojiId = reaction.reaction.replaceAll(":", ""); + + const emojiNode = useMemo(() => { + const custom = emojiMap + .flatMap((cat) => cat.emojis) + .find((e) => e.id === emojiId); + + const customSrc = custom?.skins[0]?.src; + if (customSrc) { + return ( +
+ {emojiId} +
+ ); + } + + const native = findNativeEmoji(emojiId); + if (native) { + return ( + + {native.skins[0]?.native} + + ); + } + + return null; + }, [emojiId, emojiMap, findNativeEmoji]); + + return ( + + ); +} + +function ProfilesList({ + profiles, +}: { + readonly profiles: ApiDropReaction["profiles"]; +}) { + return ( +
+
+ {profiles.map((profile) => ( + + ))} +
+
+ ); +} + +function ProfileItem({ + profile, +}: { + readonly profile: ApiDropReaction["profiles"][number]; +}) { + const displayName = profile.handle ?? profile.id; + const profileLink = profile.handle ? `/${profile.handle}` : null; + + const content = ( +
+
+ {profile.pfp ? ( + {displayName} + ) : ( +
+ )} +
+
+ + {displayName} + + {profile.handle && ( + + {profile.handle} + + )} +
+
+ ); + + if (profileLink) { + return ( + + {content} + + ); + } + + return
{content}
; +} diff --git a/hooks/useIsTouchDevice.ts b/hooks/useIsTouchDevice.ts new file mode 100644 index 0000000000..3b58700ce2 --- /dev/null +++ b/hooks/useIsTouchDevice.ts @@ -0,0 +1,26 @@ +"use client"; + +import { useState, useEffect } 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; + }; + const browserWindow = globalScope.window; + const browserNavigator = globalScope.navigator; + + const isTouch = + !!browserWindow && + ("ontouchstart" in browserWindow || + (browserNavigator?.maxTouchPoints ?? 0) > 0 || + browserWindow.matchMedia?.("(pointer: coarse)")?.matches); + + setIsTouchDevice(isTouch); + }, []); + + return isTouchDevice; +} From 264bea4cdddc6fd7ad96e626ad750870a7dd4db5 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Thu, 8 Jan 2026 16:34:01 +0200 Subject: [PATCH 2/5] WIP Signed-off-by: prxt6529 --- __tests__/hooks/useIsTouchDevice.test.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/__tests__/hooks/useIsTouchDevice.test.ts b/__tests__/hooks/useIsTouchDevice.test.ts index 46c7cc2154..0540ca98aa 100644 --- a/__tests__/hooks/useIsTouchDevice.test.ts +++ b/__tests__/hooks/useIsTouchDevice.test.ts @@ -2,22 +2,22 @@ import { renderHook, act } from "@testing-library/react"; import useIsTouchDevice from "@/hooks/useIsTouchDevice"; describe("useIsTouchDevice", () => { - const originalWindow = global.window; - const originalNavigator = global.navigator; + const originalWindow = globalThis.window; + const originalNavigator = globalThis.navigator; afterEach(() => { - Object.defineProperty(global, "window", { + Object.defineProperty(globalThis, "window", { value: originalWindow, writable: true, }); - Object.defineProperty(global, "navigator", { + Object.defineProperty(globalThis, "navigator", { value: originalNavigator, writable: true, }); }); it("returns false when window is undefined", () => { - Object.defineProperty(global, "window", { + Object.defineProperty(globalThis, "window", { value: undefined, writable: true, }); @@ -32,7 +32,7 @@ describe("useIsTouchDevice", () => { ontouchstart: null, matchMedia: jest.fn(() => ({ matches: false })), }; - Object.defineProperty(global, "window", { + Object.defineProperty(globalThis, "window", { value: mockWindow, writable: true, }); @@ -51,11 +51,11 @@ describe("useIsTouchDevice", () => { ...originalWindow, matchMedia: jest.fn(() => ({ matches: false })), }; - Object.defineProperty(global, "window", { + Object.defineProperty(globalThis, "window", { value: mockWindow, writable: true, }); - Object.defineProperty(global, "navigator", { + Object.defineProperty(globalThis, "navigator", { value: { maxTouchPoints: 5 }, writable: true, }); @@ -76,11 +76,11 @@ describe("useIsTouchDevice", () => { matches: query === "(pointer: coarse)", })), }; - Object.defineProperty(global, "window", { + Object.defineProperty(globalThis, "window", { value: mockWindow, writable: true, }); - Object.defineProperty(global, "navigator", { + Object.defineProperty(globalThis, "navigator", { value: { maxTouchPoints: 0 }, writable: true, }); @@ -99,11 +99,11 @@ describe("useIsTouchDevice", () => { ...originalWindow, matchMedia: jest.fn(() => ({ matches: false })), }; - Object.defineProperty(global, "window", { + Object.defineProperty(globalThis, "window", { value: mockWindow, writable: true, }); - Object.defineProperty(global, "navigator", { + Object.defineProperty(globalThis, "navigator", { value: { maxTouchPoints: 0 }, writable: true, }); From a7053adb52d376fb8568a4e8794ef0f37d55f69e Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Thu, 8 Jan 2026 16:43:46 +0200 Subject: [PATCH 3/5] WIP Signed-off-by: prxt6529 --- .../waves/drops/WaveDropReactions.test.tsx | 111 ++++++++++-------- 1 file changed, 61 insertions(+), 50 deletions(-) diff --git a/__tests__/components/waves/drops/WaveDropReactions.test.tsx b/__tests__/components/waves/drops/WaveDropReactions.test.tsx index 67737fe058..aa50ba5261 100644 --- a/__tests__/components/waves/drops/WaveDropReactions.test.tsx +++ b/__tests__/components/waves/drops/WaveDropReactions.test.tsx @@ -1,8 +1,8 @@ -import React from "react"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import WaveDropReactions from "@/components/waves/drops/WaveDropReactions"; import { useEmoji } from "@/contexts/EmojiContext"; import * as commonApi from "@/services/api/common-api"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; jest.mock("@/contexts/wave/MyStreamContext", () => ({ useMyStream: jest.fn(() => ({ @@ -46,7 +46,10 @@ const mockUseEmoji = useEmoji as jest.Mock; type NativeEmojiMock = { skins: Array<{ native: string }> }; const createEmojiContextValue = ( - emojiMap: Array<{ category: string; emojis: Array<{ id: string; skins: Array<{ src: string }> }> }> = [], + emojiMap: Array<{ + category: string; + emojis: Array<{ id: string; skins: Array<{ src: string }> }>; + }> = [], findNativeEmoji: (id: string) => NativeEmojiMock | null = () => null ) => ({ emojiMap, @@ -66,11 +69,20 @@ const createEmojiContextValue = ( }, }); +const createMockDrop = (overrides: Record = {}) => ({ + id: "test-drop", + wave: { id: "test-wave" }, + reactions: [], + ...overrides, +}); + describe("WaveDropReactions", () => { const getMyStreamMock = () => - (require("@/contexts/wave/MyStreamContext") as { - useMyStream: jest.Mock; - }).useMyStream; + ( + require("@/contexts/wave/MyStreamContext") as { + useMyStream: jest.Mock; + } + ).useMyStream; beforeEach(() => { // Reset call history without removing default implementations @@ -86,11 +98,11 @@ describe("WaveDropReactions", () => { [ { category: "people", - emojis: [{ id: "gm", skins: [{ src: "gm.png" }] }], + emojis: [{ id: "gm", skins: [{ src: "/gm.png" }] }], }, { category: "people", - emojis: [{ id: "gm1", skins: [{ src: "gm1.png" }] }], + emojis: [{ id: "gm1", skins: [{ src: "/gm1.png" }] }], }, ], (id: string) => @@ -101,13 +113,18 @@ describe("WaveDropReactions", () => { render( ); @@ -123,7 +140,7 @@ describe("WaveDropReactions", () => { [ { category: "people", - emojis: [{ id: "gm", skins: [{ src: "gm.png" }] }], + emojis: [{ id: "gm", skins: [{ src: "/gm.png" }] }], }, ], () => null @@ -133,42 +150,41 @@ describe("WaveDropReactions", () => { render( ); const img = screen .getAllByRole("img") - .find((el) => el.getAttribute("src") === "gm.png"); + .find((el) => el.getAttribute("src")?.includes("gm.png")); expect(img).toBeInTheDocument(); }); it("renders with native emoji when not found in emojiMap", () => { mockUseEmoji.mockReturnValue( - createEmojiContextValue( - [], - (id: string) => - id === "grinning" ? { skins: [{ native: "😊" }] } : null + createEmojiContextValue([], (id: string) => + id === "grinning" ? { skins: [{ native: "😊" }] } : null ) ); render( ); @@ -179,7 +195,7 @@ describe("WaveDropReactions", () => { it("returns null if no emoji found", () => { mockUseEmoji.mockReturnValue(createEmojiContextValue()); - render(); + render(); // Since no emoji is found, these buttons will render nothing expect(screen.queryByRole("button")).toBeNull(); }); @@ -190,7 +206,7 @@ describe("WaveDropReactions", () => { [ { category: "people", - emojis: [{ id: "gm", skins: [{ src: "gm.png" }] }], + emojis: [{ id: "gm", skins: [{ src: "/gm.png" }] }], }, ], () => null @@ -207,18 +223,17 @@ describe("WaveDropReactions", () => { render( ); @@ -246,7 +261,7 @@ describe("WaveDropReactions", () => { [ { category: "people", - emojis: [{ id: "gm", skins: [{ src: "gm.png" }] }], + emojis: [{ id: "gm", skins: [{ src: "/gm.png" }] }], }, ], () => null @@ -256,8 +271,7 @@ describe("WaveDropReactions", () => { render( { ], }, ], - } as any + }) as any } /> ); @@ -290,7 +304,7 @@ describe("WaveDropReactions", () => { [ { category: "people", - emojis: [{ id: "gm", skins: [{ src: "gm.png" }] }], + emojis: [{ id: "gm", skins: [{ src: "/gm.png" }] }], }, ], () => null @@ -300,8 +314,7 @@ describe("WaveDropReactions", () => { render( { ], }, ], - } as any + }) as any } /> ); @@ -340,7 +353,7 @@ describe("WaveDropReactions", () => { [ { category: "people", - emojis: [{ id: "gm", skins: [{ src: "gm.png" }] }], + emojis: [{ id: "gm", skins: [{ src: "/gm.png" }] }], }, ], () => null @@ -350,15 +363,14 @@ describe("WaveDropReactions", () => { render( ); @@ -375,7 +387,7 @@ describe("WaveDropReactions", () => { [ { category: "people", - emojis: [{ id: "gm", skins: [{ src: "gm.png" }] }], + emojis: [{ id: "gm", skins: [{ src: "/gm.png" }] }], }, ], () => null @@ -385,8 +397,7 @@ describe("WaveDropReactions", () => { render( { ], }, ], - } as any + }) as any } /> ); From 4b578830b6fbc5ba142ff6e3e76209fbe978d710 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Thu, 8 Jan 2026 16:54:23 +0200 Subject: [PATCH 4/5] WIP Signed-off-by: prxt6529 --- .../drops/WaveDropActionsAddReaction.test.tsx | 14 ---------- .../WaveDropReactionsDetailDialog.test.tsx | 26 ++++++++++--------- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx b/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx index 2a1e4cd693..eed39484fe 100644 --- a/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx +++ b/__tests__/components/waves/drops/WaveDropActionsAddReaction.test.tsx @@ -92,14 +92,6 @@ const tempDrop = { stableKey: "temp-001", stableHash: "hash-temp-001", } as ExtendedDrop; -const lightDrop = { - ...baseDrop, - id: "light-001", - type: DropSize.LIGHT, - stableKey: "light-001", - stableHash: "hash-light-001", -} as unknown as ExtendedDrop; - describe("WaveDropActionsAddReaction", () => { beforeEach(() => { jest.clearAllMocks(); @@ -125,12 +117,6 @@ describe("WaveDropActionsAddReaction", () => { expect(button).toBeDisabled(); }); - it("disables button when drop is light", () => { - render(); - const button = screen.getByRole("button", { name: /add reaction/i }); - expect(button).toBeDisabled(); - }); - it("opens and closes picker on desktop button click", async () => { render(); const button = screen.getByRole("button", { name: /add reaction/i }); diff --git a/__tests__/components/waves/drops/WaveDropReactionsDetailDialog.test.tsx b/__tests__/components/waves/drops/WaveDropReactionsDetailDialog.test.tsx index f1df7dd9c7..9fc831e75e 100644 --- a/__tests__/components/waves/drops/WaveDropReactionsDetailDialog.test.tsx +++ b/__tests__/components/waves/drops/WaveDropReactionsDetailDialog.test.tsx @@ -30,14 +30,16 @@ const mockReactions = [ { reaction: ":thumbsup:", profiles: [ - { id: "1", handle: "user1", pfp: "https://example.com/pfp1.jpg" }, - { id: "2", handle: "user2", pfp: null }, - { id: "3", handle: null, pfp: null }, + { id: "profile-1", handle: "alice", pfp: "https://example.com/pfp1.jpg" }, + { id: "profile-2", handle: "bob", pfp: null }, + { id: "profile-3", handle: null, pfp: null }, ], }, { reaction: ":heart:", - profiles: [{ id: "4", handle: "user4", pfp: "https://example.com/pfp4.jpg" }], + profiles: [ + { id: "profile-4", handle: "charlie", pfp: "https://example.com/pfp4.jpg" }, + ], }, ]; @@ -100,8 +102,8 @@ describe("WaveDropReactionsDetailDialog", () => { /> ); - expect(screen.getByText("user1")).toBeInTheDocument(); - expect(screen.getByText("user2")).toBeInTheDocument(); + expect(screen.getAllByText("alice").length).toBeGreaterThan(0); + expect(screen.getAllByText("bob").length).toBeGreaterThan(0); }); it("switches reaction when clicking another reaction button", () => { @@ -114,15 +116,15 @@ describe("WaveDropReactionsDetailDialog", () => { /> ); - expect(screen.getByText("user1")).toBeInTheDocument(); + expect(screen.getAllByText("alice").length).toBeGreaterThan(0); const heartButton = screen.getByText("1").closest("button"); if (heartButton) { fireEvent.click(heartButton); } - expect(screen.getByText("user4")).toBeInTheDocument(); - expect(screen.queryByText("user1")).not.toBeInTheDocument(); + expect(screen.getAllByText("charlie").length).toBeGreaterThan(0); + expect(screen.queryByText("alice")).not.toBeInTheDocument(); }); it("renders profile links for users with handles", () => { @@ -135,8 +137,8 @@ describe("WaveDropReactionsDetailDialog", () => { /> ); - const user1Link = screen.getByText("user1").closest("a"); - expect(user1Link).toHaveAttribute("href", "/user1"); + const aliceLink = screen.getAllByText("alice")[0].closest("a"); + expect(aliceLink).toHaveAttribute("href", "/alice"); }); it("renders profile avatars", () => { @@ -178,6 +180,6 @@ describe("WaveDropReactionsDetailDialog", () => { /> ); - expect(screen.getByText("user1")).toBeInTheDocument(); + expect(screen.getAllByText("alice").length).toBeGreaterThan(0); }); }); From 3875b10b9af7d12bf17e0ad46679db50b881f382 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Thu, 8 Jan 2026 16:54:47 +0200 Subject: [PATCH 5/5] WIP Signed-off-by: prxt6529 --- .../WaveDropReactionsDetailDialog.test.tsx | 10 +++++--- .../drops/WaveDropActionsAddReaction.tsx | 24 +++++++++---------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/__tests__/components/waves/drops/WaveDropReactionsDetailDialog.test.tsx b/__tests__/components/waves/drops/WaveDropReactionsDetailDialog.test.tsx index 9fc831e75e..e737fa1336 100644 --- a/__tests__/components/waves/drops/WaveDropReactionsDetailDialog.test.tsx +++ b/__tests__/components/waves/drops/WaveDropReactionsDetailDialog.test.tsx @@ -1,7 +1,7 @@ -import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; import WaveDropReactionsDetailDialog from "@/components/waves/drops/WaveDropReactionsDetailDialog"; import { useEmoji } from "@/contexts/EmojiContext"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; jest.mock("@/contexts/EmojiContext", () => ({ useEmoji: jest.fn(), @@ -38,7 +38,11 @@ const mockReactions = [ { reaction: ":heart:", profiles: [ - { id: "profile-4", handle: "charlie", pfp: "https://example.com/pfp4.jpg" }, + { + id: "profile-4", + handle: "charlie", + pfp: "https://example.com/pfp4.jpg", + }, ], }, ]; diff --git a/components/waves/drops/WaveDropActionsAddReaction.tsx b/components/waves/drops/WaveDropActionsAddReaction.tsx index 0e63a96855..e5b1341c85 100644 --- a/components/waves/drops/WaveDropActionsAddReaction.tsx +++ b/components/waves/drops/WaveDropActionsAddReaction.tsx @@ -1,26 +1,26 @@ "use client"; -import React, { useState, useRef, useEffect, useCallback } from "react"; -import type { ApiDrop } from "@/generated/models/ApiDrop"; -import { Tooltip } from "react-tooltip"; -import { createPortal } from "react-dom"; -import Picker from "@emoji-mart/react"; -import data from "@emoji-mart/data"; -import { useEmoji } from "@/contexts/EmojiContext"; -import MobileWrapperDialog from "@/components/mobile-wrapper-dialog/MobileWrapperDialog"; -import { commonApiPost } from "@/services/api/common-api"; import { useAuth } from "@/components/auth/Auth"; -import type { ApiAddReactionToDropRequest } from "@/generated/models/ApiAddReactionToDropRequest"; +import MobileWrapperDialog from "@/components/mobile-wrapper-dialog/MobileWrapperDialog"; +import { useEmoji } from "@/contexts/EmojiContext"; import { useMyStream } from "@/contexts/wave/MyStreamContext"; +import type { ApiAddReactionToDropRequest } from "@/generated/models/ApiAddReactionToDropRequest"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import type { ApiDropContextProfileContext } from "@/generated/models/ApiDropContextProfileContext"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { DropSize } from "@/helpers/waves/drop.helpers"; +import { commonApiPost } from "@/services/api/common-api"; +import data from "@emoji-mart/data"; +import Picker from "@emoji-mart/react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Tooltip } from "react-tooltip"; import { - findReactionIndex, cloneReactionEntries, + findReactionIndex, removeUserFromReactions, toProfileMin, } from "./reaction-utils"; -import type { ApiDropContextProfileContext } from "@/generated/models/ApiDropContextProfileContext"; const WaveDropActionsAddReaction: React.FC<{ readonly drop: ExtendedDrop;