about summary refs log tree commit diff
path: root/src/lib/analytics
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/analytics')
-rw-r--r--src/lib/analytics/analytics.tsx128
-rw-r--r--src/lib/analytics/analytics.web.tsx65
-rw-r--r--src/lib/analytics/types.ts98
3 files changed, 291 insertions, 0 deletions
diff --git a/src/lib/analytics/analytics.tsx b/src/lib/analytics/analytics.tsx
new file mode 100644
index 000000000..d9d53e6a9
--- /dev/null
+++ b/src/lib/analytics/analytics.tsx
@@ -0,0 +1,128 @@
+import React from 'react'
+import {AppState, AppStateStatus} from 'react-native'
+import {
+  createClient,
+  AnalyticsProvider,
+  useAnalytics as useAnalyticsOrig,
+  ClientMethods,
+} from '@segment/analytics-react-native'
+import {RootStoreModel, AppInfo} from 'state/models/root-store'
+import {useStores} from 'state/models/root-store'
+import {sha256} from 'js-sha256'
+import {ScreenEvent, TrackEvent} from './types'
+
+const segmentClient = createClient({
+  writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
+  trackAppLifecycleEvents: false,
+})
+
+export function useAnalytics() {
+  const store = useStores()
+  const methods: ClientMethods = useAnalyticsOrig()
+  return React.useMemo(() => {
+    if (store.session.hasSession) {
+      return {
+        screen: methods.screen as ScreenEvent, // ScreenEvents defines all the possible screen names
+        track: methods.track as TrackEvent, // TrackEvents defines all the possible track events and their properties
+        identify: methods.identify,
+        flush: methods.flush,
+        group: methods.group,
+        alias: methods.alias,
+        reset: methods.reset,
+      }
+    }
+    // dont send analytics pings for anonymous users
+    return {
+      screen: () => Promise<void>,
+      track: () => Promise<void>,
+      identify: () => Promise<void>,
+      flush: () => Promise<void>,
+      group: () => Promise<void>,
+      alias: () => Promise<void>,
+      reset: () => Promise<void>,
+    }
+  }, [store, methods])
+}
+
+export function init(store: RootStoreModel) {
+  store.onSessionLoaded(() => {
+    const sess = store.session.currentSession
+    if (sess) {
+      if (sess.email) {
+        store.log.debug('Ping w/hash')
+        const email_hashed = sha256(sess.email)
+        segmentClient.identify(email_hashed, {email_hashed})
+      } else {
+        store.log.debug('Ping w/o hash')
+        segmentClient.identify()
+      }
+    }
+  })
+
+  // NOTE
+  // this is a copy of segment's own lifecycle event tracking
+  // we handle it manually to ensure that it never fires while the app is backgrounded
+  // -prf
+  segmentClient.isReady.onChange(() => {
+    if (AppState.currentState !== 'active') {
+      store.log.debug('Prevented a metrics ping while the app was backgrounded')
+      return
+    }
+    const context = segmentClient.context.get()
+    if (typeof context?.app === 'undefined') {
+      store.log.debug('Aborted metrics ping due to unavailable context')
+      return
+    }
+
+    const oldAppInfo = store.appInfo
+    const newAppInfo = context.app as AppInfo
+    store.setAppInfo(newAppInfo)
+    store.log.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
+
+    if (typeof oldAppInfo === 'undefined') {
+      if (store.session.hasSession) {
+        segmentClient.track('Application Installed', {
+          version: newAppInfo.version,
+          build: newAppInfo.build,
+        })
+      }
+    } else if (newAppInfo.version !== oldAppInfo.version) {
+      if (store.session.hasSession) {
+        segmentClient.track('Application Updated', {
+          version: newAppInfo.version,
+          build: newAppInfo.build,
+          previous_version: oldAppInfo.version,
+          previous_build: oldAppInfo.build,
+        })
+      }
+    }
+    if (store.session.hasSession) {
+      segmentClient.track('Application Opened', {
+        from_background: false,
+        version: newAppInfo.version,
+        build: newAppInfo.build,
+      })
+    }
+  })
+
+  let lastState: AppStateStatus = AppState.currentState
+  AppState.addEventListener('change', (state: AppStateStatus) => {
+    if (state === 'active' && lastState !== 'active') {
+      const context = segmentClient.context.get()
+      segmentClient.track('Application Opened', {
+        from_background: true,
+        version: context?.app?.version,
+        build: context?.app?.build,
+      })
+    } else if (state !== 'active' && lastState === 'active') {
+      segmentClient.track('Application Backgrounded')
+    }
+    lastState = state
+  })
+}
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  return (
+    <AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider>
+  )
+}
diff --git a/src/lib/analytics/analytics.web.tsx b/src/lib/analytics/analytics.web.tsx
new file mode 100644
index 000000000..467ae278b
--- /dev/null
+++ b/src/lib/analytics/analytics.web.tsx
@@ -0,0 +1,65 @@
+import React from 'react'
+import {
+  createClient,
+  AnalyticsProvider,
+  useAnalytics as useAnalyticsOrig,
+} from '@segment/analytics-react'
+import {RootStoreModel} from 'state/models/root-store'
+import {useStores} from 'state/models/root-store'
+import {sha256} from 'js-sha256'
+
+const segmentClient = createClient(
+  {
+    writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI',
+  },
+  {
+    integrations: {
+      'Segment.io': {
+        apiHost: 'api.evt.bsky.app/v1',
+      },
+    },
+  },
+)
+export const track = segmentClient?.track?.bind?.(segmentClient)
+
+export function useAnalytics() {
+  const store = useStores()
+  const methods = useAnalyticsOrig()
+  return React.useMemo(() => {
+    if (store.session.hasSession) {
+      return methods
+    }
+    // dont send analytics pings for anonymous users
+    return {
+      screen: () => {},
+      track: () => {},
+      identify: () => {},
+      flush: () => {},
+      group: () => {},
+      alias: () => {},
+      reset: () => {},
+    }
+  }, [store, methods])
+}
+
+export function init(store: RootStoreModel) {
+  store.onSessionLoaded(() => {
+    const sess = store.session.currentSession
+    if (sess) {
+      if (sess.email) {
+        store.log.debug('Ping w/hash')
+        const email_hashed = sha256(sess.email)
+        segmentClient.identify(email_hashed, {email_hashed})
+      } else {
+        store.log.debug('Ping w/o hash')
+        segmentClient.identify()
+      }
+    }
+  })
+}
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  return (
+    <AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider>
+  )
+}
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts
new file mode 100644
index 000000000..0638c6b77
--- /dev/null
+++ b/src/lib/analytics/types.ts
@@ -0,0 +1,98 @@
+export type TrackEvent = (
+  event: keyof TrackPropertiesMap,
+  properties?: TrackPropertiesMap[keyof TrackPropertiesMap],
+) => Promise<void>
+
+export type ScreenEvent = (
+  name: keyof ScreenPropertiesMap,
+  properties?: ScreenPropertiesMap[keyof ScreenPropertiesMap],
+) => Promise<void>
+interface TrackPropertiesMap {
+  // LOGIN / SIGN UP events
+  'Sign In': {resumedSession: boolean} // CAN BE SERVER
+  'Create Account': {} // CAN BE SERVER
+  'Signin:PressedForgotPassword': {}
+  'Signin:PressedSelectService': {}
+  // COMPOSER / CREATE POST events
+  'Create Post': {imageCount: string} // CAN BE SERVER
+  'Composer:PastedPhotos': {}
+  'Composer:CameraOpened': {}
+  'Composer:GalleryOpened': {}
+  'HomeScreen:PressCompose': {}
+  'ProfileScreen:PressCompose': {}
+  // EDIT PROFILE events
+  'EditHandle:ViewCustomForm': {}
+  'EditHandle:ViewProvidedForm': {}
+  'EditHandle:SetNewHandle': {}
+  'EditProfile:AvatarSelected': {}
+  'EditProfile:BannerSelected': {}
+  'EditProfile:Save': {} // CAN BE SERVER
+  // FEED events
+  'Feed:onRefresh': {}
+  'Feed:onEndReached': {}
+  // FEED ITEM events
+  'FeedItem:PostReply': {} // CAN BE SERVER
+  'FeedItem:PostRepost': {} // CAN BE SERVER
+  'FeedItem:PostLike': {} // CAN BE SERVER
+  'FeedItem:PostDelete': {} // CAN BE SERVER
+  'FeedItem:ThreadMute': {} // CAN BE SERVER
+  // PROFILE HEADER events
+  'ProfileHeader:EditProfileButtonClicked': {}
+  'ProfileHeader:FollowersButtonClicked': {}
+  'ProfileHeader:FollowsButtonClicked': {}
+  'ProfileHeader:ShareButtonClicked': {}
+  'ProfileHeader:MuteAccountButtonClicked': {}
+  'ProfileHeader:UnmuteAccountButtonClicked': {}
+  'ProfileHeader:ReportAccountButtonClicked': {}
+  'ProfileHeader:AddToListsButtonClicked': {}
+  'ProfileHeader:BlockAccountButtonClicked': {}
+  'ProfileHeader:UnblockAccountButtonClicked': {}
+  'ProfileHeader:FollowButtonClicked': {}
+  'ProfileHeader:UnfollowButtonClicked': {}
+  'ViewHeader:MenuButtonClicked': {}
+  // SETTINGS events
+  'Settings:SwitchAccountButtonClicked': {}
+  'Settings:AddAccountButtonClicked': {}
+  'Settings:ChangeHandleButtonClicked': {}
+  'Settings:InvitecodesButtonClicked': {}
+  'Settings:ContentfilteringButtonClicked': {}
+  'Settings:SignOutButtonClicked': {}
+  'Settings:ContentlanguagesButtonClicked': {}
+  // MENU events
+  'Menu:ItemClicked': {url: string}
+  'Menu:FeedbackClicked': {}
+  // MOBILE SHELL events
+  'MobileShell:MyProfileButtonPressed': {}
+  'MobileShell:HomeButtonPressed': {}
+  'MobileShell:SearchButtonPressed': {}
+  'MobileShell:NotificationsButtonPressed': {}
+  'MobileShell:FeedsButtonPressed': {}
+  // LISTS events
+  'Lists:onRefresh': {}
+  'Lists:onEndReached': {}
+  'CreateMuteList:AvatarSelected': {}
+  'CreateMuteList:Save': {} // CAN BE SERVER
+  // CUSTOM FEED events
+  'MultiFeed:onEndReached': {}
+  'MultiFeed:onRefresh': {}
+  // MODERATION events
+  'Moderation:ContentfilteringButtonClicked': {}
+}
+
+interface ScreenPropertiesMap {
+  Login: {}
+  CreateAccount: {}
+  'Choose Account': {}
+  'Signin:ForgotPassword': {}
+  'Signin:SetNewPasswordForm': {}
+  'Signin:PasswordUpdatedForm': {}
+  Feed: {}
+  Notifications: {}
+  Profile: {}
+  Settings: {}
+  AppPasswords: {}
+  Moderation: {}
+  BlockedAccounts: {}
+  MutedAccounts: {}
+  SavedFeeds: {}
+}