diff --git a/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx index d0e14985ab..ab2a265e0a 100644 --- a/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx +++ b/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx @@ -102,11 +102,18 @@ jest.mock("@/services/api/common-api", () => ({ const wave = { id: "10", participation: {}, metrics: { muted: false } } as any; const mockOnDropClick = jest.fn(); +const setDocumentVisibility = (visibilityState: DocumentVisibilityState) => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: visibilityState, + }); +}; describe("MyStreamWaveChat", () => { let store: any; beforeEach(() => { + setDocumentVisibility("visible"); capturedPropsHolder.current = {}; replaceMock.mockClear(); searchParamsMock.get.mockReset(); @@ -244,6 +251,27 @@ describe("MyStreamWaveChat", () => { }); }); + it("does not call the read endpoint on unmount when the tab is hidden", async () => { + setDocumentVisibility("hidden"); + + const { unmount } = renderWithProvider( + + ); + + await act(async () => { + unmount(); + }); + + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + expect(invalidateNotificationsMock).not.toHaveBeenCalled(); + expect(mockRemoveWaveDeliveredNotifications).not.toHaveBeenCalled(); + }); + it("skips notification cleanup on unmount for anonymous viewers", async () => { mockUseAuth.mockReturnValue({ connectedProfile: null, diff --git a/__tests__/components/drops/view/item/content/attachments/DropAttachmentDisplay.test.tsx b/__tests__/components/drops/view/item/content/attachments/DropAttachmentDisplay.test.tsx index a1a4988dd4..2c3d1cfdce 100644 --- a/__tests__/components/drops/view/item/content/attachments/DropAttachmentDisplay.test.tsx +++ b/__tests__/components/drops/view/item/content/attachments/DropAttachmentDisplay.test.tsx @@ -116,12 +116,13 @@ describe("DropAttachmentDisplay", () => { ); expect( - screen.getByRole("link", { name: "Open attachment" }) - ).toHaveAttribute("href", "https://example.com/files/paper.pdf"); + screen.queryByRole("link", { name: "Open attachment" }) + ).not.toBeInTheDocument(); await user.click( - screen.getByRole("button", { name: "Download attachment" }) + screen.getByRole("button", { name: "Attachment options" }) ); + await user.click(screen.getByRole("button", { name: "Download" })); expect(createObjectURLSpy).toHaveBeenCalled(); expect(clickSpy).toHaveBeenCalled(); @@ -183,7 +184,7 @@ describe("DropAttachmentDisplay", () => { ).not.toBeInTheDocument(); }); - it("copies CSV attachment links without showing the PDF open action", async () => { + it("copies CSV attachment links without showing the open action", async () => { const user = userEvent.setup(); const writeText = jest.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { @@ -198,16 +199,46 @@ describe("DropAttachmentDisplay", () => { /> ); - const copyButton = screen.getByRole("button", { - name: "Copy attachment link", - }); + await user.click( + screen.getByRole("button", { name: "Attachment options" }) + ); + const copyButton = screen.getByRole("button", { name: "Copy link" }); await user.click(copyButton); expect(writeText).toHaveBeenCalledWith( "https://example.com/files/data.csv" ); - expect(copyButton).toHaveAttribute("title", "Copied"); - expect(copyButton).toHaveClass("tw-border-primary-400"); + expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Open attachment" }) + ).not.toBeInTheDocument(); + }); + + it("copies PDF attachment links without showing the open action", async () => { + const user = userEvent.setup(); + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + render( + + ); + + await user.click( + screen.getByRole("button", { name: "Attachment options" }) + ); + const copyButton = screen.getByRole("button", { name: "Copy link" }); + await user.click(copyButton); + + expect(writeText).toHaveBeenCalledWith( + "https://example.com/files/paper.pdf" + ); + expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); expect( screen.queryByRole("link", { name: "Open attachment" }) ).not.toBeInTheDocument(); @@ -230,11 +261,134 @@ describe("DropAttachmentDisplay", () => { ); await user.click( - screen.getByRole("button", { name: "Copy attachment link" }) + screen.getByRole("button", { name: "Attachment options" }) + ); + await user.click(screen.getByRole("button", { name: "Copy link" })); + + expect(writeText).toHaveBeenCalledWith( + "https://ipfs.example.com/ipfs/bafybeigateway/sample.csv" + ); + }); + + it("opens attachment preview without loading metadata", async () => { + const user = userEvent.setup(); + const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + text: async () => JSON.stringify({ name: "Sample", edition: 1 }), + } as Response); + + render( + + ); + + await user.click( + screen.getByRole("button", { name: "Render attachment preview" }) ); + expect(screen.getByTitle("sample.pdf")).toBeInTheDocument(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(screen.queryByText(/"name": "Sample"/)).not.toBeInTheDocument(); + }); + + it("renders IPFS attachment metadata from the root CID", async () => { + const user = userEvent.setup(); + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + jest.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + text: async () => JSON.stringify({ name: "Sample", edition: 1 }), + } as Response); + + render( + + ); + + await user.click( + screen.getByRole("button", { name: "Attachment options" }) + ); + await user.click(screen.getByRole("button", { name: "View metadata" })); + + await waitFor(() => { + expect(screen.getByText(/"name": "Sample"/)).toBeInTheDocument(); + }); + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://ipfs.example.com/ipfs/bafybeigateway/metadata.json", + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + + const copyMetadataButton = screen.getByRole("button", { + name: "Copy metadata link", + }); + await user.click(copyMetadataButton); + + expect(writeText).toHaveBeenCalledWith( + "https://ipfs.example.com/ipfs/bafybeigateway/metadata.json" + ); + expect(copyMetadataButton).toHaveAttribute("title", "Copied"); + await user.click( + screen.getByRole("button", { name: "Close attachment details" }) + ); + expect(screen.queryByText(/"name": "Sample"/)).not.toBeInTheDocument(); + }); + + it("shows a metadata not found message when metadata cannot load", async () => { + const user = userEvent.setup(); + jest.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + text: async () => "", + } as Response); + + render( + + ); + + await user.click( + screen.getByRole("button", { name: "Attachment options" }) + ); + await user.click(screen.getByRole("button", { name: "View metadata" })); + + await waitFor(() => { + expect(screen.getByText("Metadata not found.")).toBeInTheDocument(); + }); + }); + + it("copies attachment links from the options menu", async () => { + const user = userEvent.setup(); + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + render( + + ); + + await user.click( + screen.getByRole("button", { name: "Attachment options" }) + ); + await user.click(screen.getByRole("button", { name: "Copy link" })); expect(writeText).toHaveBeenCalledWith( "https://ipfs.example.com/ipfs/bafybeigateway/sample.csv" ); + expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); }); }); diff --git a/__tests__/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.test.tsx b/__tests__/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.test.tsx index 82d6de3319..94c6b6a625 100644 --- a/__tests__/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.test.tsx +++ b/__tests__/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead.test.tsx @@ -1,7 +1,7 @@ import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { useWaveDropsNotificationRead } from "@/components/waves/drops/wave-drops-all/hooks/useWaveDropsNotificationRead"; import { commonApiPostWithoutBodyAndResponse } from "@/services/api/common-api"; -import { render, waitFor } from "@testing-library/react"; +import { act, render, waitFor } from "@testing-library/react"; import React from "react"; jest.mock("@/services/api/common-api", () => ({ @@ -33,8 +33,15 @@ describe("useWaveDropsNotificationRead", () => { const removeWaveDeliveredNotifications = jest .fn() .mockResolvedValue(undefined); + const setDocumentVisibility = (visibilityState: DocumentVisibilityState) => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: visibilityState, + }); + }; beforeEach(() => { + setDocumentVisibility("visible"); invalidateNotifications.mockClear(); removeWaveDeliveredNotifications.mockClear(); ( @@ -82,4 +89,54 @@ describe("useWaveDropsNotificationRead", () => { expect(invalidateNotifications).toHaveBeenCalled(); }); }); + + it("does not call the read endpoint when the tab is hidden", () => { + setDocumentVisibility("hidden"); + + render( + + + + ); + + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + expect(invalidateNotifications).not.toHaveBeenCalled(); + expect(removeWaveDeliveredNotifications).not.toHaveBeenCalled(); + }); + + it("syncs read state when a hidden tab becomes visible", async () => { + setDocumentVisibility("hidden"); + + render( + + + + ); + + expect(removeWaveDeliveredNotifications).not.toHaveBeenCalled(); + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + + act(() => { + setDocumentVisibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + }); + + await waitFor(() => { + expect(removeWaveDeliveredNotifications).toHaveBeenCalledWith("wave-1"); + expect(commonApiPostWithoutBodyAndResponse).toHaveBeenCalledWith({ + endpoint: "notifications/wave/wave-1/read", + }); + expect(invalidateNotifications).toHaveBeenCalled(); + }); + }); }); diff --git a/__tests__/useWaveRealtimeUpdater.test.ts b/__tests__/useWaveRealtimeUpdater.test.ts index 022c5f7dd8..b294d15678 100644 --- a/__tests__/useWaveRealtimeUpdater.test.ts +++ b/__tests__/useWaveRealtimeUpdater.test.ts @@ -17,6 +17,12 @@ jest.mock("@/services/api/drop-api", () => ({ fetchDropByIdBatched: jest.fn(), })); +jest.mock("@tanstack/react-query", () => ({ + useQueryClient: jest.fn(() => ({ + setQueriesData: jest.fn(), + })), +})); + const { commonApiPostWithoutBodyAndResponse, } = require("@/services/api/common-api"); @@ -25,6 +31,17 @@ const { fetchDropByIdBatched } = require("@/services/api/drop-api"); const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); describe("useWaveRealtimeUpdater", () => { + const setDocumentVisibility = (visibilityState: DocumentVisibilityState) => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: visibilityState, + }); + }; + + beforeEach(() => { + setDocumentVisibility("visible"); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -219,6 +236,28 @@ describe("useWaveRealtimeUpdater", () => { }); }); + it("does not call the read endpoint for an active hidden wave", async () => { + setDocumentVisibility("hidden"); + const store = { + wave1: { drops: [], latestFetchedSerialNo: 10 }, + }; + const props = baseProps(store); + props.activeWaveId = "wave1"; + const { result } = renderHook(() => useWaveRealtimeUpdater(props)); + const drop: any = { id: "d9-hidden", wave: { id: "wave1" }, author: {} }; + + await act(async () => + result.current.processIncomingDrop( + drop, + ProcessIncomingDropType.DROP_INSERT + ) + ); + await flushPromises(); + + expect(commonApiPostWithoutBodyAndResponse).not.toHaveBeenCalled(); + expect(props.removeWaveDeliveredNotifications).not.toHaveBeenCalled(); + }); + it("does not mark non-active wave as read", async () => { const store = { wave1: { drops: [], latestFetchedSerialNo: 10 }, diff --git a/components/brain/my-stream/MyStreamWaveChat.tsx b/components/brain/my-stream/MyStreamWaveChat.tsx index d436127be6..c8a4534c33 100644 --- a/components/brain/my-stream/MyStreamWaveChat.tsx +++ b/components/brain/my-stream/MyStreamWaveChat.tsx @@ -81,6 +81,10 @@ const WaveChatLeaveHandler: React.FC = ({ return () => { setUnreadDividerSerialNo(null); void (async () => { + if (document.visibilityState !== "visible") { + return; + } + try { await Promise.resolve(removeWaveDeliveredNotifications(waveId)); } catch (error: unknown) { diff --git a/components/drops/view/item/content/attachments/DropAttachmentDisplay.tsx b/components/drops/view/item/content/attachments/DropAttachmentDisplay.tsx index 295fed9d0a..3e9cde0b89 100644 --- a/components/drops/view/item/content/attachments/DropAttachmentDisplay.tsx +++ b/components/drops/view/item/content/attachments/DropAttachmentDisplay.tsx @@ -4,14 +4,18 @@ import { getFileInfoFromUrl } from "@/helpers/file.helpers"; import { shareFetchedBlobInNativeApp } from "@/helpers/capacitorBlobDownload.helpers"; import { TOOLTIP_STYLES } from "@/helpers/tooltip.helpers"; import { resolveIpfsUrlSync } from "@/components/ipfs/IPFSContext"; +import CommonDropdownItemsDefaultWrapper from "@/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper"; +import JsonPreview from "@/components/drops/view/item/content/attachments/JsonPreview"; import { faShieldHalved } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ArrowDownTrayIcon, - ArrowTopRightOnSquareIcon, + CodeBracketSquareIcon, DocumentIcon, + EllipsisHorizontalIcon, EyeIcon, EyeSlashIcon, + LinkIcon, TableCellsIcon, } from "@heroicons/react/24/outline"; import useCapacitor from "@/hooks/useCapacitor"; @@ -19,6 +23,7 @@ import useDeviceInfo from "@/hooks/useDeviceInfo"; import useIsMobileDevice from "@/hooks/isMobileDevice"; import clsx from "clsx"; import { Tooltip } from "react-tooltip"; +import type { ReactNode, RefObject } from "react"; import { useEffect, useId, useMemo, useRef, useState } from "react"; const SAFE_URL_PROTOCOLS = new Set(["https:", "http:", "blob:"]); @@ -41,6 +46,40 @@ function getSafeAttachmentUrl(rawUrl: string): string | null { type AttachmentRenderType = "csv" | "pdf" | "unknown"; +function getAttachmentMetadataUrl(rawUrl: string): string | null { + const trimmedUrl = rawUrl.trim(); + if (!trimmedUrl) { + return null; + } + + if (trimmedUrl.toLowerCase().startsWith("ipfs://")) { + const withoutProtocol = trimmedUrl.slice("ipfs://".length); + const rootCid = withoutProtocol.split(/[/?#]/)[0]; + return rootCid ? `ipfs://${rootCid}/metadata.json` : null; + } + + try { + const base = globalThis.window?.location?.origin || "https://6529.io"; + const parsed = new URL(trimmedUrl, base); + const pathSegments = parsed.pathname.split("/"); + const ipfsIndex = pathSegments.indexOf("ipfs"); + + if (ipfsIndex >= 0 && pathSegments[ipfsIndex + 1]) { + parsed.pathname = [ + ...pathSegments.slice(0, ipfsIndex + 2), + "metadata.json", + ].join("/"); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } + + return null; + } catch { + return null; + } +} + const CSV_PREVIEW_MAX_ROWS = 51; const CSV_PREVIEW_MAX_COLUMNS = 12; const CSV_PREVIEW_MAX_CHARS = 500_000; @@ -57,7 +96,6 @@ const TRUSTED_BADGE_ICON_SIZE_CLASS_BY_SIZE = { default: "tw-h-2.5 tw-w-2.5", compact: "tw-h-[9px] tw-w-[9px]", } as const; - const CSV_PREVIEW_TIMEOUT_MESSAGE = "CSV preview timed out. Please download the file."; const CSV_PREVIEW_SIZE_MESSAGE = @@ -183,7 +221,9 @@ async function releaseCsvPreviewReader( await reader.cancel().catch(() => undefined); try { reader.releaseLock(); - } catch {} + } catch { + // Reader may already be released after cancellation or stream completion. + } } async function readCsvPreviewFromStreamBody( @@ -537,7 +577,7 @@ function CsvAttachmentPreview({ url }: { readonly url: string }) { let isFirstRow = true; return ( -
+
{visibleRows.map((row) => { @@ -577,6 +617,157 @@ function CsvAttachmentPreview({ url }: { readonly url: string }) { ); } +function AnimatedAttachmentPanel({ + isOpen, + children, +}: { + readonly isOpen: boolean; + readonly children: ReactNode; +}) { + const [shouldRender, setShouldRender] = useState(isOpen); + + useEffect(() => { + if (isOpen) { + setShouldRender(true); + return; + } + + const timeoutId = globalThis.window.setTimeout( + () => setShouldRender(false), + 220 + ); + return () => globalThis.window.clearTimeout(timeoutId); + }, [isOpen]); + + if (!shouldRender && !isOpen) { + return null; + } + + return ( +
+
+
+ {children} +
+
+
+ ); +} + +function AttachmentMoreMenu({ + isOpen, + hasMetadata, + isDetailsOpen, + copiedLink, + isDownloading, + buttonRef, + onToggle, + onToggleDetails, + onCopyLink, + onDownload, +}: { + readonly isOpen: boolean; + readonly hasMetadata: boolean; + readonly isDetailsOpen: boolean; + readonly copiedLink: boolean; + readonly isDownloading: boolean; + readonly buttonRef: RefObject; + readonly onToggle: () => void; + readonly onToggleDetails: () => void; + readonly onCopyLink: () => void; + readonly onDownload: () => void; +}) { + const getMenuItemClassName = (active = false) => + clsx( + "tw-flex tw-w-full tw-cursor-pointer tw-items-center tw-gap-x-3 tw-rounded-lg tw-border-0 tw-bg-transparent tw-px-3 tw-py-2 tw-text-left tw-transition-colors tw-duration-200 desktop-hover:hover:tw-bg-iron-800", + active + ? "tw-text-primary-300 desktop-hover:hover:tw-text-primary-300" + : "tw-text-iron-300 desktop-hover:hover:tw-text-iron-300" + ); + + return ( + <> + + { + if (!open && isOpen) { + onToggle(); + } + }} + buttonRef={buttonRef} + > +
  • +
    + {hasMetadata && ( + + )} + + +
    +
  • +
    + + ); +} + export default function DropAttachmentDisplay({ mimeType, attachmentUrl, @@ -588,25 +779,24 @@ export default function DropAttachmentDisplay({ readonly fileName?: string | undefined; readonly disableMediaInteraction?: boolean | undefined; }) { - const [isRendered, setIsRendered] = useState(false); + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + const [isMoreMenuOpen, setIsMoreMenuOpen] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const [copiedLink, setCopiedLink] = useState(false); const downloadAbortRef = useRef(null); + const moreButtonRef = useRef(null); const { isCapacitor } = useCapacitor(); const safeAttachmentUrl = useMemo( () => getSafeAttachmentUrl(attachmentUrl), [attachmentUrl] ); - const { - renderType, - fileName, - label, - canRender, - canOpenInNewTab, - canCopyLink, - canDownload, - Icon, - } = useMemo(() => { + const safeMetadataUrl = useMemo(() => { + const metadataUrl = getAttachmentMetadataUrl(attachmentUrl); + return metadataUrl ? getSafeAttachmentUrl(metadataUrl) : null; + }, [attachmentUrl]); + const isRendered = isPreviewOpen; + const { renderType, fileName, label, canRender, Icon } = useMemo(() => { const nextRenderType = getAttachmentRenderType(mimeType, attachmentUrl); const fileInfo = getFileInfoFromUrl(attachmentUrl); const fallbackExtension = getFallbackExtension(nextRenderType); @@ -617,20 +807,12 @@ export default function DropAttachmentDisplay({ const nextCanRender = safeAttachmentUrl !== null && (nextRenderType === "pdf" || nextRenderType === "csv"); - const nextCanOpenInNewTab = - safeAttachmentUrl !== null && nextRenderType === "pdf"; - const nextCanCopyLink = - safeAttachmentUrl !== null && nextRenderType === "csv"; - const nextCanDownload = safeAttachmentUrl !== null; const NextIcon = nextRenderType === "csv" ? TableCellsIcon : DocumentIcon; return { renderType: nextRenderType, fileName: nextFileName, label: nextLabel, canRender: nextCanRender, - canOpenInNewTab: nextCanOpenInNewTab, - canCopyLink: nextCanCopyLink, - canDownload: nextCanDownload, Icon: NextIcon, }; }, [attachmentUrl, mimeType, providedFileName, safeAttachmentUrl]); @@ -725,6 +907,30 @@ export default function DropAttachmentDisplay({ } }; + const handleToggleDetails = () => { + setIsDetailsOpen((current) => !current); + setIsMoreMenuOpen(false); + }; + + const handleToggleMoreMenu = () => { + if (!isMoreMenuOpen) { + setCopiedLink(false); + } + setIsMoreMenuOpen((current) => !current); + }; + + const handleMenuCopyLink = async () => { + await handleCopyLink(); + globalThis.window.setTimeout(() => { + setIsMoreMenuOpen(false); + }, 300); + }; + + const handleMenuDownload = () => { + void handleDownload(); + setIsMoreMenuOpen(false); + }; + return (
    setIsRendered((current) => !current)} + onClick={() => setIsPreviewOpen((current) => !current)} aria-label={ - isRendered + isPreviewOpen ? "Hide attachment preview" : "Render attachment preview" } - title={isRendered ? "Hide preview" : "Render preview"} + title={isPreviewOpen ? "Hide preview" : "Render preview"} className="tw-inline-flex tw-size-8 tw-items-center tw-justify-center tw-rounded-md tw-border tw-border-solid tw-border-iron-700 tw-bg-iron-800 tw-text-iron-100 tw-transition desktop-hover:hover:tw-bg-iron-700" > - {isRendered ? ( + {isPreviewOpen ? (
    )}
    - {isRendered && renderType === "pdf" && safeAttachmentUrl && ( -