From 549d76285a19f06e20bc5814becca7fc4331c262 Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 25 Mar 2025 21:16:54 +0800 Subject: [PATCH] refactor(tracker): add tracker package Signed-off-by: Innei --- apps/desktop/src/main/src/tracker/index.ts | 16 -- .../src/main/src/updater/hot-updater.ts | 4 +- apps/desktop/src/renderer/package.json | 1 + apps/desktop/src/renderer/src/App.tsx | 5 +- .../components/common/ImpressionTracker.tsx | 31 +-- .../src/hooks/biz/useNavigateEntry.ts | 13 +- .../src/renderer/src/initialize/analytics.ts | 36 ++-- .../src/renderer/src/initialize/index.ts | 5 +- .../src/renderer/src/modules/boost/query.tsx | 4 +- .../modules/command/commands/integration.tsx | 9 +- .../layouts/EntryListHeader.shared.tsx | 88 ++++---- .../actions/image-gallery-actions.tsx | 5 +- .../src/renderer/src/modules/panel/cmdk.tsx | 4 +- .../src/renderer/src/modules/panel/cmdn.tsx | 6 +- .../src/modules/player/corner-player.tsx | 6 +- .../modules/update-notice/UpdateNotice.tsx | 3 +- .../src/renderer/src/modules/wallet/hooks.ts | 3 +- apps/desktop/src/renderer/src/queries/feed.ts | 3 +- .../src/renderer/src/queries/wallet.tsx | 5 +- apps/mobile/package.json | 2 + apps/mobile/src/components/ui/image/Image.tsx | 4 +- apps/mobile/src/initialize/op.ts | 9 - apps/mobile/src/lib/api-fetch.ts | 4 +- apps/mobile/src/lib/auth.ts | 2 +- apps/mobile/src/lib/op.ts | 10 + apps/mobile/src/lib/proxy-env.ts | 9 +- .../src/modules/settings/routes/About.tsx | 6 +- apps/mobile/src/store/user/hooks.ts | 23 +- packages/shared/src/env.rn.ts | 36 ++-- packages/tracker/package.json | 6 + packages/tracker/src/index.ts | 8 + packages/tracker/src/op/api.ts | 84 ++++++++ packages/tracker/src/op/index.ts | 200 ++++++++++++++++++ packages/tracker/src/points.ts | 164 ++++++++++++++ packages/tracker/tsconfig.json | 7 + pnpm-lock.yaml | 20 ++ 36 files changed, 673 insertions(+), 168 deletions(-) delete mode 100644 apps/desktop/src/main/src/tracker/index.ts delete mode 100644 apps/mobile/src/initialize/op.ts create mode 100644 apps/mobile/src/lib/op.ts create mode 100644 packages/tracker/package.json create mode 100644 packages/tracker/src/index.ts create mode 100644 packages/tracker/src/op/api.ts create mode 100644 packages/tracker/src/op/index.ts create mode 100644 packages/tracker/src/points.ts create mode 100644 packages/tracker/tsconfig.json diff --git a/apps/desktop/src/main/src/tracker/index.ts b/apps/desktop/src/main/src/tracker/index.ts deleted file mode 100644 index 0f2522b1b..000000000 --- a/apps/desktop/src/main/src/tracker/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { op } from "~/lib/op" - -const PREFIX = "app:" -export const hotUpdateDownloadTrack = (version: string) => { - op.track(`${PREFIX}hot-update-download`, { version }) -} -export const hotUpdateAppNotSupportTriggerTrack = (data: { - appVersion: string - manifestVersion: string -}) => { - op.track(`${PREFIX}hot-update-app-not-support-trigger`, data) -} - -export const hotUpdateRenderSuccessTrack = (version: string) => { - op.track(`${PREFIX}hot-update-render-success`, { version }) -} diff --git a/apps/desktop/src/main/src/updater/hot-updater.ts b/apps/desktop/src/main/src/updater/hot-updater.ts index 520552ee7..5bccf8294 100644 --- a/apps/desktop/src/main/src/updater/hot-updater.ts +++ b/apps/desktop/src/main/src/updater/hot-updater.ts @@ -14,7 +14,6 @@ import { load } from "js-yaml" import { x } from "tar" import { GITHUB_OWNER, GITHUB_REPO, HOTUPDATE_RENDER_ENTRY_DIR } from "~/constants/app" -import { hotUpdateDownloadTrack, hotUpdateRenderSuccessTrack } from "~/tracker" import { getMainWindow } from "~/window" import { appUpdaterConfig } from "./configs" @@ -134,7 +133,6 @@ export const canUpdateRender = async (): Promise<[CanUpdateRenderState, Manifest return [CanUpdateRenderState.NEEDED, manifest] } const downloadRenderAsset = async (manifest: Manifest) => { - hotUpdateDownloadTrack(manifest.version) const { filename } = manifest const url = await getFileDownloadUrl(filename) @@ -181,7 +179,7 @@ export const hotUpdateRender = async (manifest: Manifest) => { JSON.stringify(manifest), ) logger.info(`Hot update render success, update to ${manifest.version}`) - hotUpdateRenderSuccessTrack(manifest.version) + const mainWindow = getMainWindow() if (!mainWindow) return false const caller = callWindowExpose(mainWindow) diff --git a/apps/desktop/src/renderer/package.json b/apps/desktop/src/renderer/package.json index 99c6b396e..bbc3a49f4 100644 --- a/apps/desktop/src/renderer/package.json +++ b/apps/desktop/src/renderer/package.json @@ -18,6 +18,7 @@ "@electron-toolkit/preload": "3.0.1", "@follow/electron-main": "workspace:*", "@follow/shared": "workspace:*", + "@follow/tracker": "workspace:*", "@fontsource/sn-pro": "5.2.5", "@headlessui/react": "2.2.0", "@hookform/resolvers": "4.1.3", diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 0a5bd1019..4ba1443a0 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -1,5 +1,6 @@ import { isMobile } from "@follow/components/hooks/useMobile.js" import { IN_ELECTRON } from "@follow/shared/constants" +import { tracker } from "@follow/tracker" import { cn, getOS } from "@follow/utils/utils" import { useEffect } from "react" import { Outlet } from "react-router" @@ -58,9 +59,7 @@ const AppLayer = () => { removeAppSkeleton() const doneTime = Math.trunc(performance.now()) - window.analytics?.capture("ui_render_init", { - time: doneTime, - }) + tracker.uiRenderInit(doneTime) appLog("App is ready", `${doneTime}ms`) applyAfterReadyCallbacks() diff --git a/apps/desktop/src/renderer/src/components/common/ImpressionTracker.tsx b/apps/desktop/src/renderer/src/components/common/ImpressionTracker.tsx index e372f14a8..b8c593abd 100644 --- a/apps/desktop/src/renderer/src/components/common/ImpressionTracker.tsx +++ b/apps/desktop/src/renderer/src/components/common/ImpressionTracker.tsx @@ -1,20 +1,27 @@ +import type { TrackerPoints } from "@follow/tracker" +import { tracker } from "@follow/tracker" +import type { AllTrackers } from "@follow/tracker/src/points" import { memo, useState } from "react" import { useInView } from "react-intersection-observer" -type ImpressionProps = { - event: string +type ImpressionProps = { + event: T onTrack?: () => any - properties?: Record + properties?: Parameters + children: React.ReactNode } -export const ImpressionView: Component<{ shouldTrack?: boolean } & ImpressionProps> = (props) => { + +export function ImpressionView( + props: ImpressionProps & { shouldTrack?: boolean }, +) { const { shouldTrack = true, ...rest } = props if (!shouldTrack) { return <>{props.children} } - return + return } -const ImpressionViewImpl: Component = memo((props) => { +function ImpressionViewImpl(props: ImpressionProps) { const [impression, setImpression] = useState(false) const { ref } = useInView({ @@ -26,10 +33,8 @@ const ImpressionViewImpl: Component = memo((props) => { } setImpression(true) - window.analytics?.capture(props.event, { - impression: 1, - ...props.properties, - }) + // @ts-expect-error + tracker[props.event]?.apply(null, props.properties) props.onTrack?.() }, }) @@ -40,6 +45,6 @@ const ImpressionViewImpl: Component = memo((props) => { {!impression && } ) -}) - -ImpressionViewImpl.displayName = "ImpressionView" +} +const MemoImpressionViewImpl = memo(ImpressionViewImpl) +MemoImpressionViewImpl.displayName = "ImpressionView" diff --git a/apps/desktop/src/renderer/src/hooks/biz/useNavigateEntry.ts b/apps/desktop/src/renderer/src/hooks/biz/useNavigateEntry.ts index ce4ada9e1..1aff85bc2 100644 --- a/apps/desktop/src/renderer/src/hooks/biz/useNavigateEntry.ts +++ b/apps/desktop/src/renderer/src/hooks/biz/useNavigateEntry.ts @@ -2,6 +2,7 @@ import { getReadonlyRoute, getStableRouterNavigate } from "@follow/components/at import { useMobile } from "@follow/components/hooks/useMobile.js" import { useSheetContext } from "@follow/components/ui/sheet/context.js" import type { FeedViewType } from "@follow/constants" +import { tracker } from "@follow/tracker" import { useCallback } from "react" import { disableShowAISummary } from "~/atoms/ai-summary" @@ -81,13 +82,11 @@ export const navigateEntry = (options: NavigateEntryOptions) => { disableShowAISummary() disableShowAITranslation() - if (window.analytics) { - window.analytics.capture("Navigate Entry", { - feedId: finalFeedId, - entryId, - timelineId: finalTimelineId, - }) - } + tracker.navigateEntry({ + feedId: finalFeedId, + entryId: finalEntryId, + timelineId: finalTimelineId, + }) const path = `/timeline/${finalTimelineId}/${finalFeedId}/${finalEntryId}` diff --git a/apps/desktop/src/renderer/src/initialize/analytics.ts b/apps/desktop/src/renderer/src/initialize/analytics.ts index ba2c5b944..f04715f2a 100644 --- a/apps/desktop/src/renderer/src/initialize/analytics.ts +++ b/apps/desktop/src/renderer/src/initialize/analytics.ts @@ -1,4 +1,5 @@ import { env } from "@follow/shared/env.desktop" +import { setOpenPanelTracker } from "@follow/tracker" import type { TrackProperties } from "@openpanel/web" import { getGeneralSettings } from "~/atoms/settings/general" @@ -6,14 +7,6 @@ import { whoami } from "~/atoms/user" import { op } from "./op" -declare global { - interface Window { - analytics?: { - capture: (event_name: string, properties?: TrackProperties | null) => void - reset: () => void - } - } -} export const initAnalytics = () => { if (env.VITE_OPENPANEL_CLIENT_ID === undefined) return @@ -23,21 +16,16 @@ export const initAnalytics = () => { hash: GIT_COMMIT_SHA, }) - window.analytics = { - reset: () => { - // op.clear() - }, - capture(event_name: string, properties?: TrackProperties | null) { - if (import.meta.env.DEV) return - if (!getGeneralSettings().sendAnonymousData) { - return - } - const me = whoami() + setOpenPanelTracker(async (name, properties) => { + if (import.meta.env.DEV) return + if (!getGeneralSettings().sendAnonymousData) { + return + } + const me = whoami() - op.track(event_name, { - ...properties, - user_id: me?.id, - } as TrackProperties) - }, - } + return op.track(name, { + ...properties, + user_id: me?.id, + } as TrackProperties) + }) } diff --git a/apps/desktop/src/renderer/src/initialize/index.ts b/apps/desktop/src/renderer/src/initialize/index.ts index dd28220f8..23439114e 100644 --- a/apps/desktop/src/renderer/src/initialize/index.ts +++ b/apps/desktop/src/renderer/src/initialize/index.ts @@ -1,6 +1,7 @@ import { initializeDayjs } from "@follow/components/dayjs" import { registerGlobalContext } from "@follow/shared/bridge" import { IN_ELECTRON } from "@follow/shared/constants" +import { tracker } from "@follow/tracker" import { repository } from "@pkg" import { enableMapSet } from "immer" @@ -115,14 +116,14 @@ export const initializeApp = async () => { const loadingTime = Date.now() - now appLog(`Initialize ${APP_NAME} done,`, `${loadingTime}ms`) - window.analytics?.capture("app_init", { + tracker.appInit({ electron: IN_ELECTRON, loading_time: loadingTime, using_indexed_db: enabledDataPersist, data_hydrated_time: dataHydratedTime, version: APP_VERSION, + rn: false, }) - // Options for react-google-recaptcha window.recaptchaOptions = { useRecaptchaNet: true, diff --git a/apps/desktop/src/renderer/src/modules/boost/query.tsx b/apps/desktop/src/renderer/src/modules/boost/query.tsx index 54c73ff3c..ed3610f33 100644 --- a/apps/desktop/src/renderer/src/modules/boost/query.tsx +++ b/apps/desktop/src/renderer/src/modules/boost/query.tsx @@ -1,3 +1,4 @@ +import { tracker } from "@follow/tracker" import { useMutation } from "@tanstack/react-query" import { toast } from "sonner" @@ -48,7 +49,8 @@ export const useBoostFeedMutation = () => { query.getStatus({ feedId: variables.feedId }).invalidate() query.getBoosters({ feedId: variables.feedId }).invalidate() updateFeedBoostStatus(variables.feedId, true) - window.analytics?.capture("boost_sent", { + + tracker.boostSent({ amount: variables.amount, feedId: variables.feedId, }) diff --git a/apps/desktop/src/renderer/src/modules/command/commands/integration.tsx b/apps/desktop/src/renderer/src/modules/command/commands/integration.tsx index d4ad5a51e..eb6b2c1e3 100644 --- a/apps/desktop/src/renderer/src/modules/command/commands/integration.tsx +++ b/apps/desktop/src/renderer/src/modules/command/commands/integration.tsx @@ -7,6 +7,7 @@ import { SimpleIconsReadwise, } from "@follow/components/ui/platform-icon/icons.js" import { IN_ELECTRON } from "@follow/shared/constants" +import { tracker } from "@follow/tracker" import { useMutation, useQuery } from "@tanstack/react-query" import type { FetchError } from "ofetch" import { ofetch } from "ofetch" @@ -123,7 +124,7 @@ const useRegisterReadwiseCommands = () => { return } try { - window.analytics?.capture("integration", { + tracker.integration({ type: "readwise", event: "save", }) @@ -194,7 +195,7 @@ const useRegisterInstapaperCommands = () => { } try { - window.analytics?.capture("integration", { + tracker.integration({ type: "instapaper", event: "save", }) @@ -300,7 +301,7 @@ const useRegisterObsidianCommands = () => { return } const markdownContent = await getEntryContentAsMarkdown(entry) - window.analytics?.capture("integration", { + tracker.integration({ type: "obsidian", event: "save", }) @@ -407,7 +408,7 @@ const useRegisterReadeckCommands = () => { return } try { - window.analytics?.capture("integration", { + tracker.integration({ type: "readeck", event: "save", }) diff --git a/apps/desktop/src/renderer/src/modules/entry-column/layouts/EntryListHeader.shared.tsx b/apps/desktop/src/renderer/src/modules/entry-column/layouts/EntryListHeader.shared.tsx index 760bfffcf..c3b3e6cde 100644 --- a/apps/desktop/src/renderer/src/modules/entry-column/layouts/EntryListHeader.shared.tsx +++ b/apps/desktop/src/renderer/src/modules/entry-column/layouts/EntryListHeader.shared.tsx @@ -5,6 +5,7 @@ import { ActionButton } from "@follow/components/ui/button/index.js" import { DividerVertical } from "@follow/components/ui/divider/Divider.js" import { SegmentGroup, SegmentItem } from "@follow/components/ui/segment/index.js" import { Slider } from "@follow/components/ui/slider/index.js" +import { tracker } from "@follow/tracker" import { clsx, cn } from "@follow/utils/utils" import { HoverCard, @@ -23,7 +24,6 @@ import { useSetZenMode, useUISettingKey, } from "~/atoms/settings/ui" -import { ImpressionView } from "~/components/common/ImpressionTracker" import { shortcuts } from "~/constants/shortcuts" import { useAIDailyReportModal } from "~/modules/ai/ai-daily/useAIDailyReportModal" @@ -34,19 +34,15 @@ export const DailyReportButton: FC = () => { const { t } = useTranslation() return ( - - { - present() - window.analytics?.capture("Daily Report Modal", { - click: 1, - }) - }} - tooltip={t("entry_list_header.daily_report")} - > - - - + { + present() + tracker.dailyReportModal() + }} + tooltip={t("entry_list_header.daily_report")} + > + + ) } @@ -150,44 +146,34 @@ export const WideModeButton = () => { const setIsZenMode = useSetZenMode() return ( - - { - if (isZenMode) { - setIsZenMode(false) - } else { - setUISetting("wideMode", !isWideMode) - // TODO: Remove this after useMeasure can get bounds in time - window.dispatchEvent(new Event("resize")) - } - window.analytics?.capture("Switch to Wide Mode", { - wideMode: !isWideMode ? 1 : 0, - click: 1, - }) - }} - tooltip={ - isZenMode - ? t("zen.exit") - : !isWideMode - ? t("entry_list_header.switch_to_widemode") - : t("entry_list_header.switch_to_normalmode") + { + if (isZenMode) { + setIsZenMode(false) + } else { + setUISetting("wideMode", !isWideMode) + // TODO: Remove this after useMeasure can get bounds in time + window.dispatchEvent(new Event("resize")) } - > - {isZenMode ? ( - - ) : ( - - )} - - + tracker.wideMode({ mode: isWideMode ? "wide" : "normal" }) + }} + tooltip={ + isZenMode + ? t("zen.exit") + : !isWideMode + ? t("entry_list_header.switch_to_widemode") + : t("entry_list_header.switch_to_normalmode") + } + > + {isZenMode ? ( + + ) : ( + + )} + ) } diff --git a/apps/desktop/src/renderer/src/modules/entry-content/actions/image-gallery-actions.tsx b/apps/desktop/src/renderer/src/modules/entry-content/actions/image-gallery-actions.tsx index 85cefe3d9..d9639bda0 100644 --- a/apps/desktop/src/renderer/src/modules/entry-content/actions/image-gallery-actions.tsx +++ b/apps/desktop/src/renderer/src/modules/entry-content/actions/image-gallery-actions.tsx @@ -1,5 +1,6 @@ import { ActionButton } from "@follow/components/ui/button/index.js" import type { MediaModel } from "@follow/models" +import { tracker } from "@follow/tracker" import { useTranslation } from "react-i18next" import { useModalStack } from "~/components/ui/modal/stacked/hooks" @@ -15,7 +16,9 @@ export const ImageGalleryAction = ({ id }: { id: string }) => { return ( { - window.analytics?.capture("entry_content_header_image_gallery_click") + tracker.entryContentHeaderImageGalleryClick({ + feedId: id, + }) present({ title: t("entry_actions.image_gallery"), content: () => , diff --git a/apps/desktop/src/renderer/src/modules/panel/cmdk.tsx b/apps/desktop/src/renderer/src/modules/panel/cmdk.tsx index 07540a224..b26a28d8c 100644 --- a/apps/desktop/src/renderer/src/modules/panel/cmdk.tsx +++ b/apps/desktop/src/renderer/src/modules/panel/cmdk.tsx @@ -11,6 +11,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.jsx" import type { FeedViewType } from "@follow/constants" import { useInputComposition } from "@follow/hooks" +import { tracker } from "@follow/tracker" import { clsx, cn } from "@follow/utils/utils" import { Command } from "cmdk" import type { FC } from "react" @@ -46,7 +47,8 @@ export const SearchCmdK: React.FC = () => { React.useEffect(() => { if (!open) return - window.analytics?.capture("search_open") + tracker.searchOpen() + // Refresh data setPage(0) setSearchInstance(() => searchActions.createLocalDbSearch()) diff --git a/apps/desktop/src/renderer/src/modules/panel/cmdn.tsx b/apps/desktop/src/renderer/src/modules/panel/cmdn.tsx index 2a0da96f4..c8eedce2e 100644 --- a/apps/desktop/src/renderer/src/modules/panel/cmdn.tsx +++ b/apps/desktop/src/renderer/src/modules/panel/cmdn.tsx @@ -1,5 +1,6 @@ import { Form, FormControl, FormField, FormItem } from "@follow/components/ui/form/index.jsx" import { useRegisterGlobalContext } from "@follow/shared/bridge" +import { tracker } from "@follow/tracker" import { cn } from "@follow/utils/utils" import { zodResolver } from "@hookform/resolvers/zod" import { useLayoutEffect } from "react" @@ -45,7 +46,10 @@ const CmdNPanel = () => { const defaultView = getRouteParams().view - window.analytics?.capture("quick_add_feed", { url, defaultView }) + tracker.quickAddFeed({ + type: "url", + defaultView: Number(defaultView), + }) present({ title: t("feed_form.add_feed"), diff --git a/apps/desktop/src/renderer/src/modules/player/corner-player.tsx b/apps/desktop/src/renderer/src/modules/player/corner-player.tsx index 6f8296535..9d176706b 100644 --- a/apps/desktop/src/renderer/src/modules/player/corner-player.tsx +++ b/apps/desktop/src/renderer/src/modules/player/corner-player.tsx @@ -2,6 +2,7 @@ import { useFocusable } from "@follow/components/common/Focusable.js" import { useMobile } from "@follow/components/hooks/useMobile.js" import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.jsx" import { FeedViewType } from "@follow/constants" +import { tracker } from "@follow/tracker" import { cn } from "@follow/utils/utils" import * as Slider from "@radix-ui/react-slider" import dayjs from "dayjs" @@ -76,7 +77,8 @@ const usePlayerTracker = () => { useEffect(() => { const handler = () => { const playerState = getAudioPlayerAtomValue() - window.analytics?.capture("player_open_duration", { + + tracker.playerOpenDuration({ duration: Date.now() - playerOpenAt, status: playerState.status, trigger: "beforeunload", @@ -90,7 +92,7 @@ const usePlayerTracker = () => { useEffect(() => { if (!show) { const playerState = getAudioPlayerAtomValue() - window.analytics?.capture("player_open_duration", { + tracker.playerOpenDuration({ duration: Date.now() - playerOpenAt, status: playerState.status, trigger: "manual", diff --git a/apps/desktop/src/renderer/src/modules/update-notice/UpdateNotice.tsx b/apps/desktop/src/renderer/src/modules/update-notice/UpdateNotice.tsx index 0e5a6424f..f23af93eb 100644 --- a/apps/desktop/src/renderer/src/modules/update-notice/UpdateNotice.tsx +++ b/apps/desktop/src/renderer/src/modules/update-notice/UpdateNotice.tsx @@ -1,3 +1,4 @@ +import { tracker } from "@follow/tracker" import { cn } from "@follow/utils/utils" import { m, useMotionTemplate, useMotionValue } from "framer-motion" import { useCallback, useEffect } from "react" @@ -26,7 +27,7 @@ export const UpdateNotice = () => { const handleClick = useCallback(() => { const status = getUpdaterStatus() if (!status) return - window.analytics?.capture("update_restart", { + tracker.updateRestart({ type: status.type, }) switch (status.type) { diff --git a/apps/desktop/src/renderer/src/modules/wallet/hooks.ts b/apps/desktop/src/renderer/src/modules/wallet/hooks.ts index e8d5f1af9..b775ab05f 100644 --- a/apps/desktop/src/renderer/src/modules/wallet/hooks.ts +++ b/apps/desktop/src/renderer/src/modules/wallet/hooks.ts @@ -1,3 +1,4 @@ +import { tracker } from "@follow/tracker" import { createElement, useCallback } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -24,7 +25,7 @@ export const useTipModal = () => { toast.error("Invalid feed id or entry id") return } - window.analytics?.capture("tip_modal_opened", { entryId }) + tracker.tipModalOpened({ entryId }) present({ title: t("tip_modal.tip_title"), content: () => createElement(TipModalContent, { userId, feedId, entryId }), diff --git a/apps/desktop/src/renderer/src/queries/feed.ts b/apps/desktop/src/renderer/src/queries/feed.ts index 7a63b86aa..7e8957330 100644 --- a/apps/desktop/src/renderer/src/queries/feed.ts +++ b/apps/desktop/src/renderer/src/queries/feed.ts @@ -1,3 +1,4 @@ +import { tracker } from "@follow/tracker" import { formatXml } from "@follow/utils/utils" import { useMutation } from "@tanstack/react-query" import { useRef } from "react" @@ -64,7 +65,7 @@ export const useClaimFeedMutation = (feedId: string) => toastFetchError(err) }, onSuccess() { - window.analytics?.capture("feed_claimed", { + tracker.feedClaimed({ feedId, }) }, diff --git a/apps/desktop/src/renderer/src/queries/wallet.tsx b/apps/desktop/src/renderer/src/queries/wallet.tsx index 1e80f8756..818292419 100644 --- a/apps/desktop/src/renderer/src/queries/wallet.tsx +++ b/apps/desktop/src/renderer/src/queries/wallet.tsx @@ -1,3 +1,4 @@ +import { tracker } from "@follow/tracker" import { useMutation } from "@tanstack/react-query" import { useNavigate } from "react-router" import { toast } from "sonner" @@ -109,7 +110,7 @@ export const useClaimWalletDailyRewardMutation = () => { onSuccess() { wallet.get().invalidate() wallet.claimCheck().invalidate() - window.analytics?.capture("daily_reward_claimed") + tracker.dailyRewardClaimed() toast(
navigate("/power")}> @@ -138,7 +139,7 @@ export const useWalletTipMutation = () => onSuccess(_, variables) { wallet.get().invalidate() wallet.transactions.get().invalidate() - window.analytics?.capture("tip_sent", { + tracker.tipSent({ amount: variables.amount, entryId: variables.entryId, }) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5e6e552ca..edf823f71 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -29,6 +29,7 @@ "@follow/utils": "workspace:*", "@gorhom/portal": "1.0.14", "@hookform/resolvers": "4.1.3", + "@openpanel/sdk": "1.0.0", "@react-native-community/image-editor": "4.3.0", "@react-native-firebase/analytics": "21.12.0", "@react-native-firebase/app": "21.12.0", @@ -47,6 +48,7 @@ "es-toolkit": "1.33.0", "expo": "52.0.39", "expo-apple-authentication": "7.1.3", + "expo-application": "6.0.2", "expo-av": "15.0.2", "expo-blur": "14.0.3", "expo-build-properties": "0.13.2", diff --git a/apps/mobile/src/components/ui/image/Image.tsx b/apps/mobile/src/components/ui/image/Image.tsx index 6cf2f03af..b58f25163 100644 --- a/apps/mobile/src/components/ui/image/Image.tsx +++ b/apps/mobile/src/components/ui/image/Image.tsx @@ -6,9 +6,9 @@ import { forwardRef, useCallback, useMemo, useState } from "react" import { proxyEnv } from "@/src/lib/proxy-env" -const buildSafeHeaders = createBuildSafeHeaders(proxyEnv.VITE_WEB_URL, [ +const buildSafeHeaders = createBuildSafeHeaders(proxyEnv.WEB_URL, [ IMAGE_PROXY_URL, - proxyEnv.VITE_API_URL, + proxyEnv.API_URL, ]) export type ImageProps = Omit & { diff --git a/apps/mobile/src/initialize/op.ts b/apps/mobile/src/initialize/op.ts deleted file mode 100644 index 0d912e273..000000000 --- a/apps/mobile/src/initialize/op.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { OpenPanel } from "@openpanel/web" - -export const op = new OpenPanel({ - clientId: process.env.EXPO_PUBLIC_OPENPANEL_CLIENT_ID ?? "", - trackScreenViews: true, - trackOutgoingLinks: true, - trackAttributes: true, - apiUrl: process.env.EXPO_PUBLIC_OPENPANEL_API_URL, -}) diff --git a/apps/mobile/src/lib/api-fetch.ts b/apps/mobile/src/lib/api-fetch.ts index 51078ea27..89bb9777c 100644 --- a/apps/mobile/src/lib/api-fetch.ts +++ b/apps/mobile/src/lib/api-fetch.ts @@ -11,7 +11,7 @@ const { hc } = require("hono/dist/cjs/client/client") as typeof import("hono/cli export const apiFetch = ofetch.create({ retry: false, - baseURL: proxyEnv.VITE_API_URL, + baseURL: proxyEnv.API_URL, onRequest: async (ctx) => { const { options, request } = ctx if (__DEV__) { @@ -47,7 +47,7 @@ export const apiFetch = ofetch.create({ }, }) -export const apiClient = hc(proxyEnv.VITE_API_URL, { +export const apiClient = hc(proxyEnv.API_URL, { fetch: async (input: any, options = {}) => apiFetch(input.toString(), options).catch((err) => { throw err diff --git a/apps/mobile/src/lib/auth.ts b/apps/mobile/src/lib/auth.ts index 057b73e75..3ca4edeb4 100644 --- a/apps/mobile/src/lib/auth.ts +++ b/apps/mobile/src/lib/auth.ts @@ -41,7 +41,7 @@ const serverPlugins = [ ] satisfies BetterAuthClientPlugin[] const authClient = createAuthClient({ - baseURL: `${proxyEnv.VITE_API_URL}/better-auth`, + baseURL: `${proxyEnv.API_URL}/better-auth`, plugins: [ twoFactorClient(), { diff --git a/apps/mobile/src/lib/op.ts b/apps/mobile/src/lib/op.ts new file mode 100644 index 000000000..0793b284d --- /dev/null +++ b/apps/mobile/src/lib/op.ts @@ -0,0 +1,10 @@ +import { OpenPanel } from "@openpanel/sdk" + +import { proxyEnv } from "./proxy-env" + +export const op = new OpenPanel({ + clientId: proxyEnv.OPENPANEL_CLIENT_ID ?? "", + + apiUrl: proxyEnv.OPENPANEL_API_URL, + // waitForProfile: true, +}) diff --git a/apps/mobile/src/lib/proxy-env.ts b/apps/mobile/src/lib/proxy-env.ts index d12bb929f..f05ac07d9 100644 --- a/apps/mobile/src/lib/proxy-env.ts +++ b/apps/mobile/src/lib/proxy-env.ts @@ -1,5 +1,5 @@ -import type { env } from "@follow/shared/src/env.rn" -import { envProfileMap } from "@follow/shared/src/env.rn" +import type { env, envProfileMap } from "@follow/shared/src/env.rn" +import { getEnvProfiles__dangerously } from "@follow/shared/src/env.rn" import { createAtomHooks } from "@follow/utils" import { reloadAppAsync } from "expo" import { atomWithStorage } from "jotai/utils" @@ -21,7 +21,10 @@ export const proxyEnv = new Proxy( { get(target, prop) { const profile = getEnvProfile() as keyof typeof envProfileMap - return envProfileMap[profile][prop as keyof (typeof envProfileMap)[typeof profile]] + const envProfiles = getEnvProfiles__dangerously() + const envMap = envProfiles[profile] + + return envMap[prop as keyof typeof envMap] }, }, ) as any as typeof env diff --git a/apps/mobile/src/modules/settings/routes/About.tsx b/apps/mobile/src/modules/settings/routes/About.tsx index 518d172b7..f8663da1d 100644 --- a/apps/mobile/src/modules/settings/routes/About.tsx +++ b/apps/mobile/src/modules/settings/routes/About.tsx @@ -1,4 +1,4 @@ -import Constants from "expo-constants" +import { nativeApplicationVersion, nativeBuildVersion } from "expo-application" import { Linking, Text, View } from "react-native" import { Link } from "@/src/components/common/Link" @@ -43,8 +43,8 @@ const links = [ ] export const AboutScreen = () => { - const buildId = Constants.expoConfig?.extra?.eas?.buildId || "Development" - const appVersion = Constants.expoConfig?.version || "0.0.0" + const buildId = nativeBuildVersion + const appVersion = nativeApplicationVersion return ( diff --git a/apps/mobile/src/store/user/hooks.ts b/apps/mobile/src/store/user/hooks.ts index ea7bfd9d1..d8e605bf8 100644 --- a/apps/mobile/src/store/user/hooks.ts +++ b/apps/mobile/src/store/user/hooks.ts @@ -4,6 +4,7 @@ import { useEffect } from "react" import { apiClient } from "@/src/lib/api-fetch" import { kv } from "@/src/lib/kv" import { useNavigation } from "@/src/lib/navigation/hooks" +import { op } from "@/src/lib/op" import { OnboardingScreen } from "@/src/screens/onboarding" import { isNewUserQueryKey, isOnboardingFinishedStorageKey } from "./constants" @@ -12,10 +13,30 @@ import { userSyncService, useUserStore } from "./store" export const whoamiQueryKey = ["user", "whoami"] export const usePrefetchSessionUser = () => { - useQuery({ + const query = useQuery({ queryKey: whoamiQueryKey, queryFn: () => userSyncService.whoami(), }) + + useEffect(() => { + if (query.data) { + const user = query.data + op.identify({ + profileId: user.id, + email: user.email!, + avatar: user.image ?? undefined, + lastName: user.name!, + properties: { + handle: user.handle, + name: user.name, + }, + }) + op.track("user_login", { + userId: query.data.id, + }) + } + }, [query.data]) + return query } export const useWhoami = () => { diff --git a/packages/shared/src/env.rn.ts b/packages/shared/src/env.rn.ts index 6d2de110d..d25de99e0 100644 --- a/packages/shared/src/env.rn.ts +++ b/packages/shared/src/env.rn.ts @@ -3,26 +3,36 @@ */ const profile = "prod" -export const envProfileMap = { +const envProfileMap = { prod: { - VITE_API_URL: "https://api.follow.is", - VITE_WEB_URL: "https://app.follow.is", - VITE_INBOXES_EMAIL: "@follow.re", + API_URL: "https://api.follow.is", + WEB_URL: "https://app.follow.is", + INBOXES_EMAIL: "@follow.re", + OPENPANEL_CLIENT_ID: "0e477ab4-d92d-4d6e-b889-b09d86ab908e", + OPENPANEL_API_URL: "https://openpanel.follow.is/api", }, dev: { - VITE_API_URL: "https://api.dev.follow.is", - VITE_WEB_URL: "https://dev.follow.is", - VITE_INBOXES_EMAIL: "__devdev@follow.re", + API_URL: "https://api.dev.follow.is", + WEB_URL: "https://dev.follow.is", + INBOXES_EMAIL: "__dev@follow.re", }, staging: { - VITE_API_URL: "https://api.follow.is", - VITE_WEB_URL: "https://staging.follow.is", - VITE_INBOXES_EMAIL: "@follow.re", + API_URL: "https://api.follow.is", + WEB_URL: "https://staging.follow.is", + INBOXES_EMAIL: "@follow.re", + OPENPANEL_CLIENT_ID: "0e477ab4-d92d-4d6e-b889-b09d86ab908e", + OPENPANEL_API_URL: "https://openpanel.follow.is/api", }, } - +export const getEnvProfiles__dangerously = () => envProfileMap +export type { envProfileMap } +/** + * @description this env always use prod env, please use `proxyEnv` to access dynamic env + */ export const env = { - VITE_WEB_URL: envProfileMap[profile].VITE_WEB_URL, - VITE_API_URL: envProfileMap[profile].VITE_API_URL, + WEB_URL: envProfileMap[profile].WEB_URL, + API_URL: envProfileMap[profile].API_URL, APP_CHECK_DEBUG_TOKEN: process.env.EXPO_PUBLIC_APP_CHECK_DEBUG_TOKEN, + OPENPANEL_CLIENT_ID: envProfileMap[profile].OPENPANEL_CLIENT_ID, + OPENPANEL_API_URL: envProfileMap[profile].OPENPANEL_API_URL, } diff --git a/packages/tracker/package.json b/packages/tracker/package.json new file mode 100644 index 000000000..1125cc0a2 --- /dev/null +++ b/packages/tracker/package.json @@ -0,0 +1,6 @@ +{ + "name": "@follow/tracker", + "version": "0.0.1", + "private": true, + "main": "./src/index.ts" +} diff --git a/packages/tracker/src/index.ts b/packages/tracker/src/index.ts new file mode 100644 index 000000000..ebad532cf --- /dev/null +++ b/packages/tracker/src/index.ts @@ -0,0 +1,8 @@ +import { TrackerPoints, trackManager } from "./points" + +export const setOpenPanelTracker = trackManager.setTrackFn.bind(trackManager) + +export const tracker = new TrackerPoints() + +export { type TrackerPoints } from "./points" +export { TrackerMapper } from "./points" diff --git a/packages/tracker/src/op/api.ts b/packages/tracker/src/op/api.ts new file mode 100644 index 000000000..a3f687f45 --- /dev/null +++ b/packages/tracker/src/op/api.ts @@ -0,0 +1,84 @@ +interface ApiConfig { + baseUrl: string + defaultHeaders?: Record> + maxRetries?: number + initialRetryDelay?: number + + headers?: Record> +} + +interface FetchOptions extends RequestInit { + retries?: number +} + +export class Api { + private baseUrl: string + private headers: Record> + private maxRetries: number + private initialRetryDelay: number + + constructor(config: ApiConfig) { + this.baseUrl = config.baseUrl + this.headers = { + "Content-Type": "application/json", + ...config.defaultHeaders, + ...config.headers, + } + this.maxRetries = config.maxRetries ?? 3 + this.initialRetryDelay = config.initialRetryDelay ?? 500 + } + + private async resolveHeaders(): Promise> { + const resolvedHeaders: Record = {} + for (const [key, value] of Object.entries(this.headers)) { + const resolvedValue = await value + if (resolvedValue !== null) { + resolvedHeaders[key] = resolvedValue + } + } + return resolvedHeaders + } + + private async post( + url: string, + data: ReqBody, + options: FetchOptions, + attempt: number, + ): Promise { + try { + const response = await fetch(url, { + method: "POST", + headers: await this.resolveHeaders(), + body: JSON.stringify(data ?? {}), + keepalive: true, + ...options, + }) + + if (response.status === 401) return null + + if (response.status !== 200 && response.status !== 202) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const responseText = await response.text() + return responseText ? JSON.parse(responseText) : null + } catch (error) { + if (attempt < this.maxRetries) { + const delay = this.initialRetryDelay * 2 ** attempt + await new Promise((resolve) => setTimeout(resolve, delay)) + return this.post(url, data, options, attempt + 1) + } + console.error("Max retries reached:", error) + return null + } + } + + async fetch( + path: string, + data: ReqBody, + options: FetchOptions = {}, + ): Promise { + const url = `${this.baseUrl}${path}` + return this.post(url, data, options, 0) + } +} diff --git a/packages/tracker/src/op/index.ts b/packages/tracker/src/op/index.ts new file mode 100644 index 000000000..358e29539 --- /dev/null +++ b/packages/tracker/src/op/index.ts @@ -0,0 +1,200 @@ +import { Api } from "./api" + +export type TrackHandlerPayload = + | { + type: "track" + payload: TrackPayload + } + | { + type: "increment" + payload: IncrementPayload + } + | { + type: "decrement" + payload: DecrementPayload + } + | { + type: "alias" + payload: AliasPayload + } + | { + type: "identify" + payload: IdentifyPayload + } + +export type TrackPayload = { + name: string + properties?: Record + profileId?: string +} + +export type TrackProperties = { + [key: string]: unknown + profileId?: string +} + +export type IdentifyPayload = { + profileId: string + firstName?: string + lastName?: string + email?: string + avatar?: string + properties?: Record +} + +export type AliasPayload = { + profileId: string + alias: string +} + +export type IncrementPayload = { + profileId: string + property: string + value?: number +} + +export type DecrementPayload = { + profileId: string + property: string + value?: number +} + +export type OpenPanelOptions = { + clientId: string + clientSecret?: string + apiUrl?: string + sdk?: string + sdkVersion?: string + waitForProfile?: boolean + filter?: (payload: TrackHandlerPayload) => boolean + disabled?: boolean +} + +export class OpenPanel { + api: Api + profileId?: string + global?: Record + queue: TrackHandlerPayload[] = [] + + constructor(public options: OpenPanelOptions) { + const defaultHeaders: Record = { + "openpanel-client-id": options.clientId, + } + + if (options.clientSecret) { + defaultHeaders["openpanel-client-secret"] = options.clientSecret + } + + defaultHeaders["openpanel-sdk-name"] = options.sdk || "node" + defaultHeaders["openpanel-sdk-version"] = options.sdkVersion || process.env.SDK_VERSION! + + this.api = new Api({ + baseUrl: options.apiUrl || "https://api.openpanel.dev", + defaultHeaders, + }) + } + + ready() { + this.options.waitForProfile = false + this.flush() + } + + async send(payload: TrackHandlerPayload) { + if (this.options.disabled) { + return + } + + // eslint-disable-next-line unicorn/no-array-callback-reference + if (this.options.filter && !this.options.filter(payload)) { + return + } + + if (this.options.waitForProfile && !this.profileId) { + this.queue.push(payload) + return + } + return this.api.fetch("/track", payload) + } + + setGlobalProperties(properties: Record) { + this.global = { + ...this.global, + ...properties, + } + } + + async track(name: string, properties?: TrackProperties) { + return this.send({ + type: "track", + payload: { + name, + profileId: properties?.profileId ?? this.profileId, + properties: { + ...this.global, + ...properties, + }, + }, + }) + } + + async identify(payload: IdentifyPayload) { + if (payload.profileId) { + this.profileId = payload.profileId + this.flush() + } + + if (Object.keys(payload).length > 1) { + return this.send({ + type: "identify", + payload: { + ...payload, + properties: { + ...this.global, + ...payload.properties, + }, + }, + }) + } + } + + async alias(payload: AliasPayload) { + return this.send({ + type: "alias", + payload, + }) + } + + async increment(payload: IncrementPayload) { + return this.send({ + type: "increment", + payload, + }) + } + + async decrement(payload: DecrementPayload) { + return this.send({ + type: "decrement", + payload, + }) + } + + clear() { + this.profileId = undefined + // should we force a session end here? + } + + flush() { + this.queue.forEach((item) => { + this.send({ + ...item, + // Not sure why ts-expect-error is needed here + // @ts-expect-error + payload: { + ...item.payload, + profileId: item.payload.profileId ?? this.profileId, + }, + }) + }) + this.queue = [] + } +} diff --git a/packages/tracker/src/points.ts b/packages/tracker/src/points.ts new file mode 100644 index 000000000..0fe4e4432 --- /dev/null +++ b/packages/tracker/src/points.ts @@ -0,0 +1,164 @@ +import type { OpenPanel } from "./op" + +type Tracker = (name: string, properties?: Record) => Promise +class TrackManager { + private trackFns: Tracker[] = [] + + setTrackFn(fn: Tracker) { + this.trackFns.push(fn) + + return () => { + this.trackFns = this.trackFns.filter((t) => t !== fn) + } + } + + getTrackFn(): Tracker { + if (this.trackFns.length === 0) { + console.error("[Tracker warn]: Track function not set") + } + return (name, properties) => { + return Promise.all(this.trackFns.map((fn) => fn(name, properties))) + } + } + + setOpenPanelTracker(tracker: OpenPanel["track"]) { + this.trackFns.push((name, properties) => tracker(name, properties)) + } +} + +export const trackManager = new TrackManager() +export enum TrackerMapper { + Identify = 1000, + UserLogin = 1001, + UiRenderInit = 1002, + AppInit = 1003, + // Biz + NavigateEntry = 2000, + BoostSent = 2001, + Integration = 2002, + DailyReportModal = 2003, + SwitchToMasonry = 2004, + WideMode = 2005, + EntryContentHeaderImageGalleryClick = 2006, + SearchOpen = 2007, + QuickAddFeed = 2008, + PlayerOpenDuration = 2009, + UpdateRestart = 2010, + TipModalOpened = 2011, + FeedClaimed = 2012, + DailyRewardClaimed = 2013, + TipSent = 2014, +} + +const CodeToTrackerName = Object.fromEntries( + Object.entries(TrackerMapper).map(([key, value]) => [value, key]), +) as Record + +export class TrackerPoints { + // App + identify(userId: string) { + this.track(TrackerMapper.Identify, { userId }) + } + + appInit(props: { + electron?: boolean + rn?: boolean + loading_time?: number + using_indexed_db?: boolean + data_hydrated_time?: number + version?: string + }) { + this.track(TrackerMapper.AppInit, props) + } + + userLogin(userId: string) { + this.track(TrackerMapper.UserLogin, { userId }) + } + + /** + * For desktop UI only + */ + uiRenderInit(spentTime: number) { + this.track(TrackerMapper.UiRenderInit, { time: spentTime }) + } + + navigateEntry(props: { feedId?: string; entryId?: string; timelineId?: string }) { + this.track(TrackerMapper.NavigateEntry, props) + } + boostSent(props: { amount: string; feedId: string }) { + this.track(TrackerMapper.BoostSent, props) + } + + integration(props: { type: string; event: string }) { + this.track(TrackerMapper.Integration, props) + } + + dailyReportModal() { + this.track(TrackerMapper.DailyReportModal) + } + + switchToMasonry() { + this.track(TrackerMapper.SwitchToMasonry) + } + + wideMode(props: { mode: "wide" | "normal" }) { + this.track(TrackerMapper.WideMode, props) + } + + entryContentHeaderImageGalleryClick(props: { feedId?: string }) { + this.track(TrackerMapper.EntryContentHeaderImageGalleryClick, props) + } + searchOpen() { + this.track(TrackerMapper.SearchOpen) + } + + quickAddFeed(props: { type: "url" | "search"; defaultView: number }) { + this.track(TrackerMapper.QuickAddFeed, props) + } + playerOpenDuration(props: { + duration: number + status?: "playing" | "loading" | "paused" + trigger?: "manual" | "beforeunload" + }) { + this.track(TrackerMapper.PlayerOpenDuration, props) + } + + updateRestart(props: { type: "app" | "renderer" | "pwa" }) { + this.track(TrackerMapper.UpdateRestart, props) + } + + tipModalOpened(props: { entryId?: string }) { + this.track(TrackerMapper.TipModalOpened, props) + } + + feedClaimed(props: { feedId: string }) { + this.track(TrackerMapper.FeedClaimed, props) + } + + dailyRewardClaimed() { + this.track(TrackerMapper.DailyRewardClaimed) + } + + tipSent(props: { amount: string; entryId: string }) { + this.track(TrackerMapper.TipSent, props) + } + + private track(code: TrackerMapper, properties?: Record) { + if (code) { + let name = CodeToTrackerName[code] + if (name) { + name = snakeCase(name) + Reflect.apply(trackManager.getTrackFn(), null, [name, { ...properties, __code: code }]) + } + } + } +} +const snakeCase = (string: string) => { + return string + .replaceAll(/\W+/g, " ") + .split(/ |\B(?=[A-Z])/) + .map((word) => word.toLowerCase()) + .join("_") +} + +export type AllTrackers = keyof TrackerPoints diff --git a/packages/tracker/tsconfig.json b/packages/tracker/tsconfig.json new file mode 100644 index 000000000..678514240 --- /dev/null +++ b/packages/tracker/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.extend.json", + "compilerOptions": { + "baseUrl": ".", + "declaration": false + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 405f622ad..8ead57547 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -481,6 +481,9 @@ importers: '@follow/shared': specifier: workspace:* version: link:../../../../packages/shared + '@follow/tracker': + specifier: workspace:* + version: link:../../../../packages/tracker '@fontsource/sn-pro': specifier: 5.2.5 version: 5.2.5 @@ -788,6 +791,9 @@ importers: '@hookform/resolvers': specifier: 4.1.3 version: 4.1.3(react-hook-form@7.54.2(react@18.3.1)) + '@openpanel/sdk': + specifier: 1.0.0 + version: 1.0.0 '@react-native-community/image-editor': specifier: 4.3.0 version: 4.3.0(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)) @@ -842,6 +848,9 @@ importers: expo-apple-authentication: specifier: 7.1.3 version: 7.1.3(expo@52.0.39(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.13.4(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)(utf-8-validate@6.0.5))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)) + expo-application: + specifier: 6.0.2 + version: 6.0.2(expo@52.0.39(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.13.4(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)(utf-8-validate@6.0.5)) expo-av: specifier: 15.0.2 version: 15.0.2(1c0838ca83e190af6509d6d21281a8a6) @@ -1536,6 +1545,8 @@ importers: specifier: 3.24.2 version: 3.24.2 + packages/tracker: {} + packages/types: {} packages/utils: @@ -9153,6 +9164,11 @@ packages: expo: '*' react-native: '*' + expo-application@6.0.2: + resolution: {integrity: sha512-qcj6kGq3mc7x5yIb5KxESurFTJCoEKwNEL34RdPEvTB/xhl7SeVZlu05sZBqxB1V4Ryzq/LsCb7NHNfBbb3L7A==} + peerDependencies: + expo: '*' + expo-asset@11.0.5: resolution: {integrity: sha512-TL60LmMBGVzs3NQcO8ylWqBumMh4sx0lmeJsn7+9C88fylGDhyyVnKZ1PyTXo9CVDBkndutZx2JUEQWM9BaiXw==} peerDependencies: @@ -25941,6 +25957,10 @@ snapshots: expo: 52.0.39(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.13.4(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)(utf-8-validate@6.0.5) react-native: 0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5) + expo-application@6.0.2(expo@52.0.39(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.13.4(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)(utf-8-validate@6.0.5)): + dependencies: + expo: 52.0.39(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.13.4(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)(utf-8-validate@6.0.5) + expo-asset@11.0.5(expo@52.0.35(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@expo/metro-runtime@4.0.1(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5)))(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.8.1)(react-native-webview@13.13.4(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1)(utf-8-validate@6.0.5))(react-native@0.77.1(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@types/react@18.3.12)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@6.0.5))(react@18.3.1): dependencies: '@expo/image-utils': 0.6.5