diff --git a/__tests__/components/header/share/HeaderShare.test.tsx b/__tests__/components/header/share/HeaderShare.test.tsx index e9b19dd038..63666608d2 100644 --- a/__tests__/components/header/share/HeaderShare.test.tsx +++ b/__tests__/components/header/share/HeaderShare.test.tsx @@ -233,7 +233,7 @@ describe("HeaderShare", () => { }); describe("Authentication State Handling", () => { - it("shows Share Connection tab when authenticated", async () => { + it("shows Connection tab when authenticated", async () => { mockUseCapacitor.mockReturnValue({ isCapacitor: false } as any); mockIsMobile.mockReturnValue(false); @@ -260,8 +260,8 @@ describe("HeaderShare", () => { await screen.findByTestId("header-share-modal"); - // Should show Share Connection button when authenticated - expect(screen.getByText("Share Connection")).toBeInTheDocument(); + // Should show Connection button when authenticated + expect(screen.getByText("Connection")).toBeInTheDocument(); expect(screen.getByText("Current URL")).toBeInTheDocument(); expect(screen.getByText("6529 Apps")).toBeInTheDocument(); }); @@ -291,8 +291,8 @@ describe("HeaderShare", () => { await screen.findByTestId("header-share-modal"); - // Should NOT show Share Connection button when not authenticated - expect(screen.queryByText("Share Connection")).not.toBeInTheDocument(); + // Should NOT show Connection button when not authenticated + expect(screen.queryByText("Connection")).not.toBeInTheDocument(); expect(screen.getByText("Current URL")).toBeInTheDocument(); expect(screen.getByText("6529 Apps")).toBeInTheDocument(); }); diff --git a/components/header/share/HeaderShare.module.scss b/components/header/share/HeaderShare.module.scss deleted file mode 100644 index a1299fd247..0000000000 --- a/components/header/share/HeaderShare.module.scss +++ /dev/null @@ -1,32 +0,0 @@ -@use "../../../styles/variables.scss"; - -.disabledMenuBtn { - pointer-events: none; -} - -.modalBody { - background-color: variables.$very-dark-grey; -} - -.url { - font-size: smaller; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: variables.$lightest-grey; - max-width: calc(100% - 20px); -} - -.urlCopy { - height: 16px; - cursor: pointer; - color: variables.$lightest-grey; - - &:hover { - color: variables.$off-white; - } - - &.copied { - color: green; - } -} diff --git a/components/header/share/HeaderShare.tsx b/components/header/share/HeaderShare.tsx index dccffc6ec0..f1a900aa54 100644 --- a/components/header/share/HeaderShare.tsx +++ b/components/header/share/HeaderShare.tsx @@ -7,8 +7,7 @@ import { ShareIcon } from "@heroicons/react/24/outline"; import yaml from "js-yaml"; import Image from "next/image"; import { usePathname, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; -import { Button, Modal } from "react-bootstrap"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Tooltip } from "react-tooltip"; import useIsMobileDevice from "@/hooks/isMobileDevice"; import useCapacitor from "@/hooks/useCapacitor"; @@ -20,11 +19,88 @@ import { getWalletRole, } from "@/services/auth/auth.utils"; import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; -import styles from "./HeaderShare.module.scss"; import { ShareMobileApp } from "./HeaderShareMobileApps"; const QRCode = require("qrcode"); +interface OSInfo { + name: "windows" | "mac" | "linux"; + url: string; + displayName: string; + downloadPath: string; + image: string; + enabled: boolean; + version?: string | undefined; +} + +interface FileData { + url: string; + sha512: string; + size: number; +} + +interface LatestYml { + version: string; + files: FileData[]; +} + +const CORE_OS_CONFIGS: OSInfo[] = [ + { + name: "windows", + url: "https://6529bucket.s3.eu-west-1.amazonaws.com/6529-core-app/win/latest.yml", + displayName: "Windows", + downloadPath: "6529-core-app/win/links", + image: "/windows.png", + enabled: true, + }, + { + name: "mac", + url: "https://6529bucket.s3.eu-west-1.amazonaws.com/6529-core-app/mac/latest-mac.yml", + displayName: "macOS", + downloadPath: "6529-core-app/mac/links", + image: "/macos.png", + enabled: true, + }, + { + name: "linux", + url: "https://6529bucket.s3.eu-west-1.amazonaws.com/6529-core-app/linux/latest-linux.yml", + displayName: "Linux", + downloadPath: "6529-core-app/linux/links", + image: "/linux.png", + enabled: true, + }, +]; + +const bodyScrollLock = (() => { + let lockCount = 0; + let previousOverflow = ""; + + return { + lock: () => { + if (typeof document === "undefined") { + return; + } + + if (lockCount === 0) { + previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + } + + lockCount += 1; + }, + unlock: () => { + if (typeof document === "undefined" || lockCount === 0) { + return; + } + + lockCount -= 1; + if (lockCount === 0) { + document.body.style.overflow = previousOverflow; + } + }, + }; +})(); + enum Mode { NAVIGATE, SHARE, @@ -39,13 +115,45 @@ enum SubMode { const squareStyle = { width: "100%", - maxWidth: "1000px", - aspectRatio: "1 / 1", display: "flex", alignItems: "center", justifyContent: "center", }; +function getSubTabCount(activeTab: Mode, isElectron: boolean): number { + if (activeTab === Mode.NAVIGATE) { + return isElectron ? 2 : 3; + } + return isElectron ? 1 : 2; +} + +function getSubTabLabel(activeTab: Mode): string { + if (activeTab === Mode.APPS) { + return "Select Platform"; + } + if (activeTab === Mode.SHARE) { + return "Open Link In"; + } + return "Open URL In"; +} + +function getFocusableElements(container: HTMLElement): HTMLElement[] { + const selectors = [ + 'a[href]:not([tabindex="-1"])', + 'button:not([disabled]):not([tabindex="-1"])', + 'textarea:not([disabled]):not([tabindex="-1"])', + 'input:not([disabled]):not([tabindex="-1"])', + 'select:not([disabled]):not([tabindex="-1"])', + '[tabindex]:not([tabindex="-1"])', + ].join(","); + + return Array.from(container.querySelectorAll(selectors)).filter( + (element) => + !element.hasAttribute("disabled") && + element.getAttribute("aria-hidden") !== "true" + ); +} + export default function HeaderShare({ isCollapsed = false, }: { @@ -106,9 +214,11 @@ export function HeaderQRModal({ }) { const pathname = usePathname(); const searchParams = useSearchParams(); - const isMobile = useIsMobileDevice(); + const [shouldRender, setShouldRender] = useState(show); + const [isVisible, setIsVisible] = useState(show); + const { isAuthenticated } = useSeizeConnectContext(); const [activeTab, setActiveTab] = useState( @@ -129,6 +239,86 @@ export function HeaderQRModal({ const [shareConnectionSrc, setShareConnectionSrc] = useState(""); const [urlCopied, setUrlCopied] = useState(false); + const onCloseRef = useRef(onClose); + const copyTimeoutRef = useRef | null>(null); + const dialogRef = useRef(null); + const previouslyFocusedElementRef = useRef(null); + + const trapFocusInDialog = useCallback((event: KeyboardEvent) => { + if (event.key !== "Tab") { + return; + } + + const dialog = dialogRef.current; + if (!dialog) { + return; + } + + const focusableElements = getFocusableElements(dialog); + if (focusableElements.length === 0) { + event.preventDefault(); + dialog.focus(); + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements.at(-1); + if (!firstElement || !lastElement) { + event.preventDefault(); + dialog.focus(); + return; + } + const activeElement = document.activeElement as HTMLElement | null; + const activeInsideDialog = activeElement + ? dialog.contains(activeElement) + : false; + + if (event.shiftKey) { + if ( + !activeInsideDialog || + activeElement === firstElement || + activeElement === dialog + ) { + event.preventDefault(); + lastElement.focus(); + } + return; + } + + if (!activeInsideDialog || activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }, []); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + useEffect(() => { + return () => { + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = null; + } + }; + }, []); + + const handleEscapeKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Tab") { + trapFocusInDialog(event); + return; + } + + if (event.key === "Escape") { + event.stopPropagation(); + event.preventDefault(); + onCloseRef.current(); + } + }, + [trapFocusInDialog] + ); function generateSources( refreshToken: string | null, @@ -173,24 +363,33 @@ export function HeaderQRModal({ setShareConnectionSrc(""); } - QRCode.toDataURL(browserUrl, { width: 500, margin: 0 }).then( - (dataUrl: string) => { + QRCode.toDataURL(browserUrl, { width: 500, margin: 0 }) + .then((dataUrl: string) => { setNavigateBrowserSrc(dataUrl); - } - ); - - QRCode.toDataURL(appUrl, { width: 500, margin: 0 }).then( - (dataUrl: string) => { + }) + .catch((error: unknown) => { + console.error("Failed to generate browser QR code", error); + setNavigateBrowserSrc(""); + }); + + QRCode.toDataURL(appUrl, { width: 500, margin: 0 }) + .then((dataUrl: string) => { setNavigateAppSrc(dataUrl); - } - ); + }) + .catch((error: unknown) => { + console.error("Failed to generate mobile app QR code", error); + setNavigateAppSrc(""); + }); if (shareConnectionAppUrl) { - QRCode.toDataURL(shareConnectionAppUrl, { width: 500, margin: 0 }).then( - (dataUrl: string) => { + QRCode.toDataURL(shareConnectionAppUrl, { width: 500, margin: 0 }) + .then((dataUrl: string) => { setShareConnectionSrc(dataUrl); - } - ); + }) + .catch((error: unknown) => { + console.error("Failed to generate share connection QR code", error); + setShareConnectionSrc(""); + }); } } @@ -210,176 +409,303 @@ export function HeaderQRModal({ setShareConnectionSrc(""); }, 150); return () => clearTimeout(timer); + }, [show, isAuthenticated]); + + useEffect(() => { + if (show) { + setShouldRender(true); + const raf = requestAnimationFrame(() => setIsVisible(true)); + return () => cancelAnimationFrame(raf); + } + + setIsVisible(false); + const timeout = setTimeout(() => setShouldRender(false), 200); + return () => clearTimeout(timeout); }, [show]); - function printImage() { - const renderQRCodeImage = (src: string, alt: string) => { - const defaultStyle = { - maxWidth: "100%", - height: "auto", - border: "5px solid #fff", + useEffect(() => { + if (!shouldRender) { + return; + } + + bodyScrollLock.lock(); + globalThis.addEventListener("keydown", handleEscapeKeyDown); + + return () => { + bodyScrollLock.unlock(); + globalThis.removeEventListener("keydown", handleEscapeKeyDown); + }; + }, [shouldRender, handleEscapeKeyDown]); + + useEffect(() => { + if (!shouldRender) { + return; + } + + const activeElement = document.activeElement; + previouslyFocusedElementRef.current = + activeElement instanceof HTMLElement ? activeElement : null; + + const raf = requestAnimationFrame(() => { + const dialog = dialogRef.current; + if (!dialog) { + return; + } + + const focusableElements = getFocusableElements(dialog); + const firstFocusableElement = focusableElements[0]; + if (firstFocusableElement) { + firstFocusableElement.focus(); + return; + } + + dialog.focus(); + }); + + return () => { + cancelAnimationFrame(raf); + previouslyFocusedElementRef.current?.focus(); + }; + }, [shouldRender]); + + const renderQRCodeImage = (src: string, alt: string) => { + const normalizedSrc = src?.trim(); + + return ( +
+ {normalizedSrc ? ( + {alt} + ) : ( +
+ )} +
+ ); + }; + + const renderCoreLink = (url: string) => { + return ( + + ); + }; + + const getNavigateContent = () => { + if (activeSubTab === SubMode.BROWSER) { + return { + content: renderQRCodeImage( + navigateBrowserSrc, + "Browser Link - QR Code" + ), + url: navigateBrowserUrl, }; - return ( - {alt} - ); + } + + if (activeSubTab === SubMode.CORE) { + return { + content: renderCoreLink(navigateCoreUrl), + url: navigateCoreUrl, + }; + } + + return { + content: renderQRCodeImage(navigateAppSrc, "Mobile App Link - QR Code"), + url: navigateAppUrl, }; + }; - const renderCoreLink = (url: string) => { - return ( -
- - 6529 Desktop - - + const getShareContent = () => { + if (activeSubTab === SubMode.CORE) { + return { + content: renderCoreLink(shareConnectionCoreUrl), + url: shareConnectionCoreUrl, + }; + } + + if (activeSubTab === SubMode.APP) { + return { + content: renderQRCodeImage( + shareConnectionSrc, + "Share Connection - QR Code" + ), + url: shareConnectionAppUrl, + }; + } + + return { content: Invalid submode for SHARE, url: "" }; + }; + + const getAppsContent = () => { + if (activeSubTab === SubMode.CORE) { + return { content: , url: "" }; + } + + return { + content: ( +
+ +
- ); + ), + url: "", }; + }; - let content = null; - let url = ""; - + const getDisplayContent = () => { if (activeTab === Mode.NAVIGATE) { - switch (activeSubTab) { - case SubMode.BROWSER: - url = navigateBrowserUrl; - content = renderQRCodeImage( - navigateBrowserSrc, - "Browser Link - QR Code" - ); - break; - case SubMode.APP: - url = navigateAppUrl; - content = renderQRCodeImage( - navigateAppSrc, - "Mobile App Link - QR Code" - ); - break; - case SubMode.CORE: - url = navigateCoreUrl; - content = renderCoreLink(navigateCoreUrl); - break; - default: - break; - } - } else if (activeTab === Mode.SHARE) { - switch (activeSubTab) { - case SubMode.APP: - url = shareConnectionAppUrl; - content = renderQRCodeImage( - shareConnectionSrc, - "Share Connection - QR Code" - ); - break; - case SubMode.CORE: - content = renderCoreLink(shareConnectionCoreUrl); - url = shareConnectionCoreUrl; - break; - default: - content = Invalid submode for SHARE; - break; - } - } else if (activeTab === Mode.APPS) { - switch (activeSubTab) { - case SubMode.APP: - content = ( -
- - -
- ); - break; - case SubMode.CORE: - content = ; - break; - } + return getNavigateContent(); } + if (activeTab === Mode.SHARE) { + return getShareContent(); + } + + return getAppsContent(); + }; + + function printImage() { + const { content, url } = getDisplayContent(); + return ( - <> - {content} - {url && ( -
-
{url}
- +
+
+ {content} +
+
+ {url ? ( +
+
+ {url} +
+
+ ) : ( +
)} - +
); } + if (!shouldRender) { + return null; + } + return ( - - - { - setActiveTab(tab); - setActiveSubTab(subTab); - }} - /> - {printImage()} - - +
); } @@ -394,119 +720,107 @@ function ModalMenu({ readonly activeSubTab: SubMode; readonly onTabChange: (tab: Mode, subTab: SubMode) => void; }) { - const isElectron = useElectron(); + const isElectron = useElectron() ?? false; + const topTabCount = isShareConnection ? 3 : 2; + const subTabCount = getSubTabCount(activeTab, isElectron); + const subTabLabel = getSubTabLabel(activeTab); + const getMenuButtonClass = (active: boolean) => { + const baseClassName = + "tw-inline-flex tw-h-10 tw-w-full tw-min-w-0 tw-items-center tw-justify-center tw-overflow-hidden tw-text-ellipsis tw-whitespace-nowrap tw-rounded-xl tw-border-0 tw-px-2 tw-text-[15px] tw-font-medium tw-transition tw-duration-200"; + + if (active) { + return `${baseClassName} tw-bg-iron-700 tw-text-iron-50`; + } + + return `${baseClassName} tw-bg-iron-900 tw-text-iron-400 hover:tw-bg-iron-800 hover:tw-text-iron-100`; + }; return ( -
-
- {isShareConnection && ( - - )} - - + {isShareConnection && ( + + )} + + +
-
- - {activeTab === Mode.NAVIGATE && ( - - )} - {!isElectron && ( - - )} + 6529 Mobile + + {activeTab === Mode.NAVIGATE && ( + + )} + {!isElectron && ( + + )} +
); } function CoreAppsDownload() { - interface OSInfo { - name: "windows" | "mac" | "linux"; - url: string; - displayName: string; - downloadPath: string; - image: string; - enabled: boolean; - version?: string | undefined; - } - - interface FileData { - url: string; - sha512: string; - size: number; - } - - interface LatestYml { - version: string; - files: FileData[]; - } - - const osConfigs: OSInfo[] = [ - { - name: "windows", - url: "https://6529bucket.s3.eu-west-1.amazonaws.com/6529-core-app/win/latest.yml", - displayName: "Windows", - downloadPath: "6529-core-app/win/links", - image: "/windows.png", - enabled: true, - }, - { - name: "mac", - url: "https://6529bucket.s3.eu-west-1.amazonaws.com/6529-core-app/mac/latest-mac.yml", - displayName: "macOS", - downloadPath: "6529-core-app/mac/links", - image: "/macos.png", - enabled: true, - }, - { - name: "linux", - url: "https://6529bucket.s3.eu-west-1.amazonaws.com/6529-core-app/linux/latest-linux.yml", - displayName: "Linux", - downloadPath: "6529-core-app/linux/links", - image: "/linux.png", - enabled: true, - }, - ]; - const [versions, setVersions] = useState([]); useEffect(() => { @@ -523,17 +837,28 @@ function CoreAppsDownload() { const loadVersions = async () => { const versions: OSInfo[] = []; - for (const osConfig of osConfigs.filter((config) => config.enabled)) { - try { - const ymlData = await fetchYml(osConfig.url); - versions.push({ ...osConfig, version: ymlData.version }); - } catch (error) { - console.error( - `Failed to fetch or process ${osConfig.displayName}:`, - error - ); + const enabledConfigs = CORE_OS_CONFIGS.filter((config) => config.enabled); + const results = await Promise.allSettled( + enabledConfigs.map((config) => fetchYml(config.url)) + ); + + results.forEach((result, index) => { + const osConfig = enabledConfigs[index]; + if (!osConfig) { + return; } - } + + if (result.status === "fulfilled") { + versions.push({ ...osConfig, version: result.value.version }); + return; + } + + console.error( + `Failed to fetch or process ${osConfig.displayName}:`, + result.reason + ); + }); + setVersions(versions); }; @@ -597,7 +922,7 @@ function CoreAppDownload({ />
-
+
{platform} v{version}