From ce8c23a0ff5f41b1fcaae5a8f7bb9e9d2fabd3bb Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 23 Jul 2026 11:03:09 -0700 Subject: [PATCH] feat(analytics): add Chat Blast: Message Viewed event on blast DM open Fires an Amplitude "Chat Blast: Message Viewed" event the first time a user opens a blast DM thread (detected via chat.is_blast), once per blast per user. Adds the event on both web (ChatPage) and mobile (ChatScreen), deduping via localStorage / AsyncStorage keyed on the blast chat id. Properties: isNativeMobile, chatId, audience, audienceContentType, audienceContentId. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/common/src/models/Analytics.ts | 11 ++++ packages/mobile/src/constants/storage-keys.ts | 3 ++ .../src/screens/chat-screen/ChatScreen.tsx | 36 ++++++++++++- packages/web/src/pages/chat-page/ChatPage.tsx | 54 ++++++++++++++++++- 4 files changed, 102 insertions(+), 2 deletions(-) diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index 65dddbbddd8..bcb30c408c7 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -378,6 +378,7 @@ export enum Name { CREATE_CHAT_BLAST_SUCCESS = 'Chat Blast: Create - Success', CREATE_CHAT_BLAST_FAILURE = 'Chat Blast: Create - Failure', CHAT_BLAST_MESSAGE_SENT = 'Chat Blast: Message Sent', + CHAT_BLAST_MESSAGE_VIEWED = 'Chat Blast: Message Viewed', SEND_MESSAGE_SUCCESS = 'Send Message: Success', SEND_MESSAGE_FAILURE = 'Send Message: Failure', DELETE_CHAT_SUCCESS = 'Delete Chat: Success', @@ -1959,6 +1960,15 @@ type ChatBlastMessageSent = { audienceContentId?: ID } +type ChatBlastMessageViewed = { + eventName: Name.CHAT_BLAST_MESSAGE_VIEWED + isNativeMobile?: boolean + chatId: string + audience: string + audienceContentType?: string + audienceContentId?: ID +} + type SendMessageSuccess = { eventName: Name.SEND_MESSAGE_SUCCESS } @@ -2807,6 +2817,7 @@ export type AllTrackingEvents = | ConnectWalletError | ChatBlastCTAClicked | ChatBlastMessageSent + | ChatBlastMessageViewed | CreateChatSuccess | CreateChatFailure | CreateChatBlastSuccess diff --git a/packages/mobile/src/constants/storage-keys.ts b/packages/mobile/src/constants/storage-keys.ts index 7b8e8ae6cb2..3c4bea9f089 100644 --- a/packages/mobile/src/constants/storage-keys.ts +++ b/packages/mobile/src/constants/storage-keys.ts @@ -8,3 +8,6 @@ export const THEME_STORAGE_KEY = 'theme' export const THEME_PALETTE_KEY = 'themePalette' export const THEME_MODE_KEY = 'themeMode' export const SEARCH_HISTORY_KEY = '@search-history' +// Tracks which blast chat threads the user has viewed so the +// `Chat Blast: Message Viewed` analytics event fires only once per blast. +export const VIEWED_BLAST_CHATS_KEY = '@viewed-blast-chats' diff --git a/packages/mobile/src/screens/chat-screen/ChatScreen.tsx b/packages/mobile/src/screens/chat-screen/ChatScreen.tsx index bb1a7706d0f..8055adb86a7 100644 --- a/packages/mobile/src/screens/chat-screen/ChatScreen.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatScreen.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCurrentUserId } from '@audius/common/api' import { useCanSendMessage } from '@audius/common/hooks' -import { Status } from '@audius/common/models' +import { Name, Status } from '@audius/common/models' import type { ChatMessageWithExtras } from '@audius/common/models' import { chatActions, @@ -16,6 +16,7 @@ import { } from '@audius/common/utils' import { OptionalHashId, OptionalId, type ChatBlast } from '@audius/sdk' import { Portal } from '@gorhom/portal' +import AsyncStorage from '@react-native-async-storage/async-storage' import { useFocusEffect } from '@react-navigation/native' import type { FlatListProps, LayoutChangeEvent } from 'react-native' import { @@ -40,9 +41,11 @@ import { } from 'app/components/core' import LoadingSpinner from 'app/components/loading-spinner' import { PLAY_BAR_HEIGHT } from 'app/components/now-playing-drawer' +import { VIEWED_BLAST_CHATS_KEY } from 'app/constants/storage-keys' import { light } from 'app/haptics' import { useRoute } from 'app/hooks/useRoute' import { useToast } from 'app/hooks/useToast' +import { make, track } from 'app/services/analytics' import { setVisibility } from 'app/store/drawers/slice' import { makeStyles } from 'app/styles' import { spacing } from 'app/styles/spacing' @@ -289,6 +292,37 @@ export const ChatScreen = () => { } }, [chatId, dispatch]) + // Track when a user opens a blast DM thread. Fires once per blast per user. + useEffect(() => { + if (!chatId || !chat?.is_blast) return + const blastChat = chat as ChatBlast + const trackBlastViewed = async () => { + try { + const raw = await AsyncStorage.getItem(VIEWED_BLAST_CHATS_KEY) + const viewed: string[] = raw ? JSON.parse(raw) : [] + if (!Array.isArray(viewed) || viewed.includes(chatId)) return + await AsyncStorage.setItem( + VIEWED_BLAST_CHATS_KEY, + JSON.stringify([...viewed, chatId]) + ) + track( + make({ + eventName: Name.CHAT_BLAST_MESSAGE_VIEWED, + isNativeMobile: true, + chatId, + audience: blastChat.audience, + audienceContentType: blastChat.audience_content_type, + audienceContentId: + OptionalHashId.parse(blastChat.audience_content_id) ?? undefined + }) + ) + } catch { + // Ignore storage failures; worst case the event fires again later. + } + } + trackBlastViewed() + }, [chatId, chat]) + // Fetch all permissions, blockers/blockees, and recheck_permissions flag useEffect(() => { dispatch(fetchBlockees()) diff --git a/packages/web/src/pages/chat-page/ChatPage.tsx b/packages/web/src/pages/chat-page/ChatPage.tsx index b5a1cb0c90f..54f3e3ce058 100644 --- a/packages/web/src/pages/chat-page/ChatPage.tsx +++ b/packages/web/src/pages/chat-page/ChatPage.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useRef } from 'react' import { useCanSendMessage } from '@audius/common/hooks' -import { Status } from '@audius/common/models' +import { Name, Status } from '@audius/common/models' import { chatActions, chatSelectors } from '@audius/common/store' +import { ChatBlast, OptionalHashId } from '@audius/sdk' import cn from 'classnames' import { useDispatch } from 'react-redux' import { useParams, useLocation, useNavigate } from 'react-router' @@ -11,6 +12,7 @@ import Page from 'components/page/Page' import { useIsContainerNarrow } from 'hooks/useIsContainerNarrow' import { useIsMobile } from 'hooks/useIsMobile' import { useManagedAccountNotAllowedRedirect } from 'hooks/useManagedAccountNotAllowedRedirect' +import { make, track } from 'services/analytics' import { push } from 'utils/navigation' import { useSelector } from 'utils/reducer' import { chatPage } from 'utils/route' @@ -33,6 +35,36 @@ const messages = { const NARROW_LAYOUT_THRESHOLD_PX = 1080 +// Local-storage key tracking which blast chat threads the user has already +// viewed, so the `Chat Blast: Message Viewed` event fires only once per blast. +const VIEWED_BLAST_CHATS_LOCAL_STORAGE_KEY = 'viewedBlastChats' + +const getViewedBlastChats = (): string[] => { + try { + const raw = window.localStorage.getItem( + VIEWED_BLAST_CHATS_LOCAL_STORAGE_KEY + ) + const parsed = raw ? JSON.parse(raw) : [] + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + +const markBlastChatViewed = (chatId: string) => { + try { + const viewed = getViewedBlastChats() + if (!viewed.includes(chatId)) { + window.localStorage.setItem( + VIEWED_BLAST_CHATS_LOCAL_STORAGE_KEY, + JSON.stringify([...viewed, chatId]) + ) + } + } catch { + // Ignore local-storage write failures; worst case the event fires again. + } +} + export const ChatPage = () => { useManagedAccountNotAllowedRedirect() const params = useParams<{ id?: string }>() @@ -110,6 +142,26 @@ export const ChatPage = () => { } }, [dispatch, firstOtherUser, isMobile]) + // Track when a user opens a blast DM thread. Fires once per blast per user. + useEffect(() => { + if (!currentChatId || !chat?.is_blast) return + const viewed = getViewedBlastChats() + if (viewed.includes(currentChatId)) return + const blastChat = chat as ChatBlast + markBlastChatViewed(currentChatId) + track( + make({ + eventName: Name.CHAT_BLAST_MESSAGE_VIEWED, + isNativeMobile: false, + chatId: currentChatId, + audience: blastChat.audience, + audienceContentType: blastChat.audience_content_type, + audienceContentId: + OptionalHashId.parse(blastChat.audience_content_id) ?? undefined + }) + ) + }, [currentChatId, chat]) + if (isMobile) { return }