analytics-tracker.tsx 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
  1. import { useEffect, useLayoutEffect, useRef } from 'react';
  2. import { AppState } from 'react-native';
  3. import { useGlobalSearchParams, usePathname } from 'expo-router';
  4. import { useCurrentUserStore } from 'app/context/user';
  5. import { parseTabKey } from 'app/lib/navigation/tab-path';
  6. import { resolveTabPageUrl } from 'app/components/nav/tab-page-url';
  7. import { installGlobalErrorCapture, sendStoredErrors } from './global-errors';
  8. import { setHealthView, startScreenLoad } from './health';
  9. import { setTrackerVisible, startTracker, trackPageview } from './tracker';
  10. /** A screen shown for less than this isn't a screen view (web waits as long for the title). */
  11. const SETTLE_MS = 400;
  12. /** `/view-post?id=5` from a UNA page URL or path (`https://site/view-post?id=5`, `view-post`). */
  13. function toPagePath(url: string): string {
  14. const absolute = /^https?:\/\/[^/]+(\/[^#]*)?/i.exec(url);
  15. if (absolute) return absolute[1] || '/';
  16. const path = url.split('#')[0] ?? '';
  17. return path.startsWith('/') ? path : `/${path}`;
  18. }
  19. /**
  20. * The UNA page a screen shows, resolved as the tab Screen resolves it (`url` param, the More
  21. * tab's last page, the tab's menu item). Other routes (`/pg`) are their own path.
  22. * `user` is the signed-in profile, `false` for a guest, `null` while unknown.
  23. */
  24. function getScreenPath(pathname: string, url: string | undefined, user: { url?: string } | false | null): string {
  25. const tabKey = parseTabKey(pathname);
  26. if (!tabKey) return pathname;
  27. if (user === null) return '';
  28. const page = resolveTabPageUrl(tabKey, url, user, pathname !== tabKey);
  29. return page ? toPagePath(page) : '';
  30. }
  31. /**
  32. * Sends screen views, engaged time, errors and vitals to UNA's analytics. Mounted once, in the
  33. * tabs layout. A screen's `screen_load` starts when its path changes and ends when its page is
  34. * laid out (`ScreenReadyMarker` in the page layout).
  35. */
  36. export function AnalyticsTracker() {
  37. const pathname = usePathname();
  38. const params = useGlobalSearchParams<{ url?: string | string[] }>();
  39. const url = Array.isArray(params.url) ? params.url[0] : params.url;
  40. const user = useCurrentUserStore((store) => store.currentUser);
  41. const screenPath = getScreenPath(pathname, url, user);
  42. const firstScreen = useRef(true);
  43. // A layout effect: it runs before the new screen's marker can report its layout.
  44. useLayoutEffect(() => {
  45. if (!screenPath) return;
  46. setHealthView(screenPath);
  47. if (firstScreen.current) firstScreen.current = false;
  48. else startScreenLoad();
  49. }, [screenPath]);
  50. useEffect(() => {
  51. // already installed in apps/expo/index.js; here in case that didn't run
  52. installGlobalErrorCapture();
  53. void sendStoredErrors();
  54. startTracker(AppState.currentState === 'active');
  55. // `inactive` (iOS app switcher, Control Center) passes quickly: only background counts as hidden
  56. const subscription = AppState.addEventListener('change', (appState) => {
  57. if (appState === 'active') setTrackerVisible(true);
  58. else if (appState === 'background') setTrackerVisible(false);
  59. });
  60. return () => subscription.remove();
  61. }, []);
  62. useEffect(() => {
  63. if (!screenPath) return undefined;
  64. // A screen left before this fires isn't counted, as on web: the tab root a deep
  65. // link lands on just before its page is pushed.
  66. const timer = setTimeout(() => trackPageview(screenPath), SETTLE_MS);
  67. return () => clearTimeout(timer);
  68. }, [screenPath]);
  69. return null;
  70. }