Skip to content

Commit df15442

Browse files
committed
feat(insights-hub, react-hooks): added a usePrevious and useDetectBrowserInfo hook, and made dialog stick around if new tab is opening
1 parent 6f83f1f commit df15442

File tree

8 files changed

+178
-2
lines changed

8 files changed

+178
-2
lines changed

apps/insights/src/components/Root/search-button.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ import {
2020
} from "@pythnetwork/component-library/unstyled/ListBox";
2121
import { useDrawer } from "@pythnetwork/component-library/useDrawer";
2222
import { useLogger } from "@pythnetwork/component-library/useLogger";
23+
import { useDetectBrowserInfo } from '@pythnetwork/react-hooks/use-detect-browser-info';
2324
import { matchSorter } from "match-sorter";
2425
import type { ReactNode } from "react";
25-
import { useCallback, useEffect, useMemo, useState } from "react";
26+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2627

2728
import { Cluster, ClusterToName } from "../../services/pyth";
2829
import { AssetClassBadge } from "../AssetClassBadge";
@@ -132,10 +133,19 @@ const SearchDialogContents = ({
132133
feeds,
133134
publishers,
134135
}: SearchDialogContentsProps) => {
136+
/** hooks */
135137
const drawer = useDrawer();
136138
const logger = useLogger();
139+
const browserInfo = useDetectBrowserInfo();
140+
141+
/** refs */
142+
const openTabModifierActiveRef = useRef(false);
143+
const middleMousePressedRef = useRef(false);
144+
145+
/** state */
137146
const [search, setSearch] = useState("");
138147
const [type, setType] = useState<ResultType | "">("");
148+
139149
const closeDrawer = useCallback(() => {
140150
drawer.close().catch((error: unknown) => {
141151
logger.error(error);
@@ -231,13 +241,29 @@ const SearchDialogContents = ({
231241
: (result.name ?? result.publisherKey)
232242
}
233243
className={styles.item ?? ""}
234-
onAction={closeDrawer}
235244
href={
236245
result.type === ResultType.PriceFeed
237246
? `/price-feeds/${encodeURIComponent(result.symbol)}`
238247
: `/publishers/${ClusterToName[result.cluster]}/${encodeURIComponent(result.publisherKey)}`
239248
}
240249
data-is-first={result.id === results[0]?.id ? "" : undefined}
250+
onPointerDown={e => {
251+
middleMousePressedRef.current = e.button === 1;
252+
// on press is too abstracted and doesn't give us the native event
253+
// for determining if the user clicked their middle mouse button,
254+
// so we need to use the native onClick directly
255+
middleMousePressedRef.current = e.button === 1;
256+
openTabModifierActiveRef.current = (browserInfo?.isMacOS && e.metaKey) ?? e.ctrlKey;
257+
}}
258+
onPointerUp={() => {
259+
const userWantsNewTab = middleMousePressedRef.current || openTabModifierActiveRef.current;
260+
261+
// they want a new tab, the search popover stays open
262+
if (!userWantsNewTab) closeDrawer();
263+
264+
middleMousePressedRef.current = false;
265+
openTabModifierActiveRef.current = false;
266+
}}
241267
>
242268
<div className={styles.smallScreen}>
243269
{result.type === ResultType.PriceFeed ? (
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { defineJestConfigForNextJs } from "@pythnetwork/jest-config/define-next-config";
2+
3+
export default defineJestConfigForNextJs();

packages/react-hooks/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@
1414
},
1515
"devDependencies": {
1616
"@cprussin/eslint-config": "catalog:",
17+
"@pythnetwork/jest-config": "workspace:",
1718
"@cprussin/tsconfig": "catalog:",
1819
"@types/react": "catalog:",
1920
"@types/react-dom": "catalog:",
2021
"eslint": "catalog:"
2122
},
2223
"dependencies": {
24+
"ua-parser-js": "catalog:",
2325
"nuqs": "catalog:",
2426
"react": "catalog:",
2527
"react-dom": "catalog:"
@@ -42,6 +44,14 @@
4244
"types": "./dist/nuqs.d.ts",
4345
"default": "./dist/nuqs.mjs"
4446
},
47+
"./use-detect-browser-info": {
48+
"types": "./dist/use-detect-browser-info.d.ts",
49+
"default": "./dist/use-detect-browser-info.mjs"
50+
},
51+
"./use-previous": {
52+
"types": "./dist/use-previous.d.ts",
53+
"default": "./dist/use-previous.mjs"
54+
},
4555
"./package.json": "./package.json"
4656
}
4757
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useEffect, useMemo, useRef, useState } from "react";
2+
import { UAParser } from "ua-parser-js";
3+
4+
import { usePrevious } from "./use-previous.js";
5+
6+
function safeGetUserAgent() {
7+
// this guards against this blowing up in SSR
8+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
9+
return globalThis.window?.navigator?.userAgent ?? "";
10+
}
11+
12+
type UseDetectBrowserInfoOpts = Partial<{
13+
/**
14+
* how often to check and see if the user agent has updated
15+
*/
16+
checkInterval: number;
17+
}>;
18+
19+
const DEFAULT_CHECK_INTERVAL = 1000; // one secondrt
20+
21+
/**
22+
* returns relevant information about the user's browser, OS and Arch,
23+
* using the super popular ua-parser-js library:
24+
* npm i ua-parser-js
25+
*/
26+
export function useDetectBrowserInfo(opts?: UseDetectBrowserInfoOpts) {
27+
/** props */
28+
const { checkInterval = DEFAULT_CHECK_INTERVAL } = opts ?? {};
29+
30+
/** state */
31+
const [userAgent, setUserAgent] = useState(() => safeGetUserAgent());
32+
33+
/** hooks */
34+
const prevUserAgent = usePrevious(userAgent);
35+
36+
/** refs */
37+
const prevUserAgentRef = useRef(prevUserAgent);
38+
39+
/** memos */
40+
const details = useMemo(
41+
() => (userAgent ? UAParser(userAgent) : undefined),
42+
[userAgent],
43+
);
44+
45+
/** effects */
46+
useEffect(() => {
47+
prevUserAgentRef.current = prevUserAgent;
48+
});
49+
50+
useEffect(() => {
51+
// in case somebody is spoofing their user agent using
52+
// some type of browser extension, we check the user agent periodically
53+
// to see if it's changed, and if it has, we update what we have
54+
const userAgentCheckInterval = setInterval(() => {
55+
const ua = safeGetUserAgent();
56+
57+
if (ua !== prevUserAgentRef.current) {
58+
setUserAgent(ua);
59+
}
60+
}, checkInterval);
61+
62+
return () => {
63+
clearInterval(userAgentCheckInterval);
64+
};
65+
}, [checkInterval]);
66+
67+
return useMemo(() => {
68+
if (!details) return;
69+
70+
const lowerOsName = details.os.name?.toLowerCase() ?? "";
71+
const isMacOS = lowerOsName === "macos";
72+
const isWindows = lowerOsName === "windows" || lowerOsName === "win";
73+
74+
return {
75+
...details,
76+
isLinux: !isMacOS && !isWindows,
77+
isMacOS,
78+
isWindows,
79+
};
80+
}, []);
81+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useEffect, useRef } from "react";
2+
3+
/**
4+
* returns the n-1 value provided to it.
5+
* useful for comparing a current component
6+
* state value relative to a previous state value
7+
*/
8+
export function usePrevious<T>(val: T): T | undefined {
9+
/** refs */
10+
const prevRef = useRef<T>(undefined);
11+
12+
/** effects */
13+
useEffect(() => {
14+
prevRef.current = val;
15+
});
16+
17+
return prevRef.current;
18+
}

packages/react-hooks/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
{
22
"extends": "@cprussin/tsconfig/base.json",
3+
"compilerOptions": {
4+
"lib": ["DOM", "ESNext"]
5+
},
36
"exclude": ["node_modules", "dist"]
47
}

pnpm-lock.yaml

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ catalog:
161161
typedoc: ^0.26.8
162162
typescript: ^5.9.3
163163
turbo: ^2.5.8
164+
ua-parser-js: ^2.0.6
164165
vercel: ^41.4.1
165166
viem: ^2.37.13
166167
wagmi: ^2.14.16

0 commit comments

Comments
 (0)