Browse Source

feat(insights-hub, react-hooks): added a usePrevious and useDetectBrowserInfo hook, and made dialog stick around if new tab is opening

benduran 1 week ago
parent
commit
df15442193

+ 28 - 2
apps/insights/src/components/Root/search-button.tsx

@@ -20,9 +20,10 @@ import {
 } from "@pythnetwork/component-library/unstyled/ListBox";
 import { useDrawer } from "@pythnetwork/component-library/useDrawer";
 import { useLogger } from "@pythnetwork/component-library/useLogger";
+import { useDetectBrowserInfo } from '@pythnetwork/react-hooks/use-detect-browser-info';
 import { matchSorter } from "match-sorter";
 import type { ReactNode } from "react";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 
 import { Cluster, ClusterToName } from "../../services/pyth";
 import { AssetClassBadge } from "../AssetClassBadge";
@@ -132,10 +133,19 @@ const SearchDialogContents = ({
   feeds,
   publishers,
 }: SearchDialogContentsProps) => {
+  /** hooks */
   const drawer = useDrawer();
   const logger = useLogger();
+  const browserInfo = useDetectBrowserInfo();
+
+  /** refs */
+  const openTabModifierActiveRef = useRef(false);
+  const middleMousePressedRef = useRef(false);
+
+  /** state */
   const [search, setSearch] = useState("");
   const [type, setType] = useState<ResultType | "">("");
+
   const closeDrawer = useCallback(() => {
     drawer.close().catch((error: unknown) => {
       logger.error(error);
@@ -231,13 +241,29 @@ const SearchDialogContents = ({
                     : (result.name ?? result.publisherKey)
                 }
                 className={styles.item ?? ""}
-                onAction={closeDrawer}
                 href={
                   result.type === ResultType.PriceFeed
                     ? `/price-feeds/${encodeURIComponent(result.symbol)}`
                     : `/publishers/${ClusterToName[result.cluster]}/${encodeURIComponent(result.publisherKey)}`
                 }
                 data-is-first={result.id === results[0]?.id ? "" : undefined}
+                onPointerDown={e => {
+                  middleMousePressedRef.current = e.button === 1;
+                  // on press is too abstracted and doesn't give us the native event
+                  // for determining if the user clicked their middle mouse button,
+                  // so we need to use the native onClick directly
+                  middleMousePressedRef.current = e.button === 1;
+                  openTabModifierActiveRef.current = (browserInfo?.isMacOS && e.metaKey) ?? e.ctrlKey;
+                }}
+                onPointerUp={() => {
+                  const userWantsNewTab = middleMousePressedRef.current || openTabModifierActiveRef.current;
+                  
+                  // they want a new tab, the search popover stays open
+                  if (!userWantsNewTab) closeDrawer();
+                  
+                  middleMousePressedRef.current = false;
+                  openTabModifierActiveRef.current = false;
+                }}
               >
                 <div className={styles.smallScreen}>
                   {result.type === ResultType.PriceFeed ? (

+ 3 - 0
packages/react-hooks/jest.config.js

@@ -0,0 +1,3 @@
+import { defineJestConfigForNextJs } from "@pythnetwork/jest-config/define-next-config";
+
+export default defineJestConfigForNextJs();

+ 10 - 0
packages/react-hooks/package.json

@@ -14,12 +14,14 @@
   },
   "devDependencies": {
     "@cprussin/eslint-config": "catalog:",
+    "@pythnetwork/jest-config": "workspace:",
     "@cprussin/tsconfig": "catalog:",
     "@types/react": "catalog:",
     "@types/react-dom": "catalog:",
     "eslint": "catalog:"
   },
   "dependencies": {
+    "ua-parser-js": "catalog:",
     "nuqs": "catalog:",
     "react": "catalog:",
     "react-dom": "catalog:"
@@ -42,6 +44,14 @@
       "types": "./dist/nuqs.d.ts",
       "default": "./dist/nuqs.mjs"
     },
+    "./use-detect-browser-info": {
+      "types": "./dist/use-detect-browser-info.d.ts",
+      "default": "./dist/use-detect-browser-info.mjs"
+    },
+    "./use-previous": {
+      "types": "./dist/use-previous.d.ts",
+      "default": "./dist/use-previous.mjs"
+    },
     "./package.json": "./package.json"
   }
 }

+ 81 - 0
packages/react-hooks/src/use-detect-browser-info.ts

@@ -0,0 +1,81 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { UAParser } from "ua-parser-js";
+
+import { usePrevious } from "./use-previous.js";
+
+function safeGetUserAgent() {
+  // this guards against this blowing up in SSR
+  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+  return globalThis.window?.navigator?.userAgent ?? "";
+}
+
+type UseDetectBrowserInfoOpts = Partial<{
+  /**
+   * how often to check and see if the user agent has updated
+   */
+  checkInterval: number;
+}>;
+
+const DEFAULT_CHECK_INTERVAL = 1000; // one secondrt
+
+/**
+ * returns relevant information about the user's browser, OS and Arch,
+ * using the super popular ua-parser-js library:
+ * npm i ua-parser-js
+ */
+export function useDetectBrowserInfo(opts?: UseDetectBrowserInfoOpts) {
+  /** props */
+  const { checkInterval = DEFAULT_CHECK_INTERVAL } = opts ?? {};
+
+  /** state */
+  const [userAgent, setUserAgent] = useState(() => safeGetUserAgent());
+
+  /** hooks */
+  const prevUserAgent = usePrevious(userAgent);
+
+  /** refs */
+  const prevUserAgentRef = useRef(prevUserAgent);
+
+  /** memos */
+  const details = useMemo(
+    () => (userAgent ? UAParser(userAgent) : undefined),
+    [userAgent],
+  );
+
+  /** effects */
+  useEffect(() => {
+    prevUserAgentRef.current = prevUserAgent;
+  });
+
+  useEffect(() => {
+    // in case somebody is spoofing their user agent using
+    // some type of browser extension, we check the user agent periodically
+    // to see if it's changed, and if it has, we update what we have
+    const userAgentCheckInterval = setInterval(() => {
+      const ua = safeGetUserAgent();
+
+      if (ua !== prevUserAgentRef.current) {
+        setUserAgent(ua);
+      }
+    }, checkInterval);
+
+    return () => {
+      clearInterval(userAgentCheckInterval);
+    };
+  }, [checkInterval]);
+
+  return useMemo(() => {
+    if (!details) return;
+
+    const lowerOsName = details.os.name?.toLowerCase() ?? "";
+    const isMacOS = lowerOsName === "macos";
+    const isWindows = lowerOsName === "windows" || lowerOsName === "win";
+
+    return {
+      ...details,
+      isLinux: !isMacOS && !isWindows,
+      isMacOS,
+      isWindows,
+    };
+  }, []);
+}

+ 18 - 0
packages/react-hooks/src/use-previous.ts

@@ -0,0 +1,18 @@
+import { useEffect, useRef } from "react";
+
+/**
+ * returns the n-1 value provided to it.
+ * useful for comparing a current component
+ * state value relative to a previous state value
+ */
+export function usePrevious<T>(val: T): T | undefined {
+  /** refs */
+  const prevRef = useRef<T>(undefined);
+
+  /** effects */
+  useEffect(() => {
+    prevRef.current = val;
+  });
+
+  return prevRef.current;
+}

+ 3 - 0
packages/react-hooks/tsconfig.json

@@ -1,4 +1,7 @@
 {
   "extends": "@cprussin/tsconfig/base.json",
+  "compilerOptions": {
+    "lib": ["DOM", "ESNext"]
+  },
   "exclude": ["node_modules", "dist"]
 }

+ 34 - 0
pnpm-lock.yaml

@@ -318,6 +318,9 @@ catalogs:
     typescript:
       specifier: ^5.9.3
       version: 5.9.3
+    ua-parser-js:
+      specifier: ^2.0.6
+      version: 2.0.6
     vercel:
       specifier: ^41.4.1
       version: 41.4.1
@@ -2219,6 +2222,9 @@ importers:
       react-dom:
         specifier: 'catalog:'
         version: 19.1.0(react@19.1.0)
+      ua-parser-js:
+        specifier: 'catalog:'
+        version: 2.0.6
     devDependencies:
       '@cprussin/eslint-config':
         specifier: 'catalog:'
@@ -2226,6 +2232,9 @@ importers:
       '@cprussin/tsconfig':
         specifier: 'catalog:'
         version: 3.1.2(typescript@5.9.3)
+      '@pythnetwork/jest-config':
+        specifier: 'workspace:'
+        version: link:../jest-config
       '@types/react':
         specifier: 'catalog:'
         version: 19.1.0
@@ -13473,6 +13482,9 @@ packages:
   detect-browser@5.3.0:
     resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==}
 
+  detect-europe-js@0.1.2:
+    resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==}
+
   detect-indent@7.0.2:
     resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==}
     engines: {node: '>=12.20'}
@@ -15931,6 +15943,9 @@ packages:
     resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
     engines: {node: '>= 0.4'}
 
+  is-standalone-pwa@0.1.1:
+    resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==}
+
   is-stream@2.0.1:
     resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
     engines: {node: '>=8'}
@@ -20700,10 +20715,17 @@ packages:
   u3@0.1.1:
     resolution: {integrity: sha512-+J5D5ir763y+Am/QY6hXNRlwljIeRMZMGs0cT6qqZVVzzT3X3nFPXVyPOFRMOR4kupB0T8JnCdpWdp6Q/iXn3w==}
 
+  ua-is-frozen@0.1.2:
+    resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==}
+
   ua-parser-js@1.0.40:
     resolution: {integrity: sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==}
     hasBin: true
 
+  ua-parser-js@2.0.6:
+    resolution: {integrity: sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg==}
+    hasBin: true
+
   uc.micro@2.1.0:
     resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
 
@@ -39457,6 +39479,8 @@ snapshots:
 
   detect-browser@5.3.0: {}
 
+  detect-europe-js@0.1.2: {}
+
   detect-indent@7.0.2: {}
 
   detect-libc@1.0.3:
@@ -43199,6 +43223,8 @@ snapshots:
     dependencies:
       call-bound: 1.0.4
 
+  is-standalone-pwa@0.1.1: {}
+
   is-stream@2.0.1: {}
 
   is-stream@4.0.1: {}
@@ -50096,8 +50122,16 @@ snapshots:
 
   u3@0.1.1: {}
 
+  ua-is-frozen@0.1.2: {}
+
   ua-parser-js@1.0.40: {}
 
+  ua-parser-js@2.0.6:
+    dependencies:
+      detect-europe-js: 0.1.2
+      is-standalone-pwa: 0.1.1
+      ua-is-frozen: 0.1.2
+
   uc.micro@2.1.0: {}
 
   ufo@1.5.4: {}

+ 1 - 0
pnpm-workspace.yaml

@@ -161,6 +161,7 @@ catalog:
   typedoc: ^0.26.8
   typescript: ^5.9.3
   turbo: ^2.5.8
+  ua-parser-js: ^2.0.6
   vercel: ^41.4.1
   viem: ^2.37.13
   wagmi: ^2.14.16