diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index 21a6d42f4a3..727748efcae 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -578,6 +578,9 @@ export enum Name { REMIX_CONTEST_DELETE = 'Remix Contest: Delete', REMIX_CONTEST_PICK_WINNERS_OPEN = 'Remix Contest: Pick Winners Open', REMIX_CONTEST_PICK_WINNERS_FINALIZE = 'Remix Contest: Finalize Winners', + REMIX_CONTEST_VIEW = 'Remix Contest: View', + REMIX_CONTEST_ENTER = 'Remix Contest: Enter', + REMIX_CONTEST_VIEW_SUBMISSIONS = 'Remix Contest: View Submissions', // Android App Lifecycle ANDROID_APP_RESTART_HEARTBEAT = 'Android App: Restart Due to Heartbeat', @@ -2868,6 +2871,24 @@ export type RemixContestPickWinnersFinalize = { trackId: ID } +export type RemixContestView = { + eventName: Name.REMIX_CONTEST_VIEW + remixContestId: ID + trackId: ID +} + +export type RemixContestEnter = { + eventName: Name.REMIX_CONTEST_ENTER + remixContestId: ID + trackId: ID +} + +export type RemixContestViewSubmissions = { + eventName: Name.REMIX_CONTEST_VIEW_SUBMISSIONS + remixContestId: ID + trackId: ID +} + export type AndroidAppRestartHeartbeat = { eventName: Name.ANDROID_APP_RESTART_HEARTBEAT timeSinceLastHeartbeat: number @@ -3504,6 +3525,9 @@ export type AllTrackingEvents = | RemixContestDelete | RemixContestPickWinnersOpen | RemixContestPickWinnersFinalize + | RemixContestView + | RemixContestEnter + | RemixContestViewSubmissions | AndroidAppRestartHeartbeat | AndroidAppRestartStale | AndroidAppRestartForceQuit diff --git a/packages/mobile/src/components/comments/CommentOverflowMenu.tsx b/packages/mobile/src/components/comments/CommentOverflowMenu.tsx index 305298754eb..e9abb19f7a7 100644 --- a/packages/mobile/src/components/comments/CommentOverflowMenu.tsx +++ b/packages/mobile/src/components/comments/CommentOverflowMenu.tsx @@ -238,8 +238,8 @@ export const CommentOverflowMenu = (props: CommentOverflowMenuProps) => { }, [deleteComment, id, parentCommentId, toast]) const handlePress = useCallback(() => { - setIsOpen(!isOpen) - setIsVisible(!isVisible) + setIsOpen(true) + setIsVisible(true) trackEvent( make({ @@ -247,7 +247,7 @@ export const CommentOverflowMenu = (props: CommentOverflowMenuProps) => { commentId: id }) ) - }, [isOpen, isVisible, id]) + }, [id]) return ( <> diff --git a/packages/mobile/src/screens/contest-screen/ContestScreen.tsx b/packages/mobile/src/screens/contest-screen/ContestScreen.tsx index 03cf4032cf5..fa404380cec 100644 --- a/packages/mobile/src/screens/contest-screen/ContestScreen.tsx +++ b/packages/mobile/src/screens/contest-screen/ContestScreen.tsx @@ -3,6 +3,7 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, useState } from 'react' @@ -21,7 +22,7 @@ import { useUser } from '@audius/common/api' import { useFeatureFlag } from '@audius/common/hooks' -import { ShareSource } from '@audius/common/models' +import { Name, ShareSource } from '@audius/common/models' import { FeatureFlags } from '@audius/common/services' import { shareModalUIActions } from '@audius/common/store' import { dayjs, getLocalTimezone } from '@audius/common/utils' @@ -36,6 +37,7 @@ import { useDispatch } from 'react-redux' import { Button, Divider, Flex, Text } from '@audius/harmony-native' import { Screen, ScreenContent } from 'app/components/core' import { ProfilePicture } from 'app/components/core/ProfilePicture' +import { make, track as trackEvent } from 'app/services/analytics' import { CollapsibleTabNavigator, collapsibleTabScreen @@ -295,7 +297,38 @@ export const ContestScreen = () => { dispatch(setVisibility({ drawer: 'PickWinners', visible: true })) }, [trackId, dispatch]) - const handleEnterContest = useEnterContest(trackId) + const enterContest = useEnterContest(trackId) + const handleEnterContest = useCallback(async () => { + if (trackId != null && eventId != null) { + trackEvent( + make({ + eventName: Name.REMIX_CONTEST_ENTER, + remixContestId: eventId, + trackId + }) + ) + } + await enterContest() + }, [enterContest, trackId, eventId]) + + // Fire a Remix Contest: View event the first time the screen resolves + // both a trackId and an eventId. The screen is mounted once per + // navigation push, so a ref guard makes the event idempotent across + // unrelated re-renders (followers count update, scroll-y reaction, + // etc.) while still firing on each fresh push. + const hasFiredViewRef = useRef(false) + useEffect(() => { + if (hasFiredViewRef.current) return + if (trackId == null || eventId == null) return + hasFiredViewRef.current = true + trackEvent( + make({ + eventName: Name.REMIX_CONTEST_VIEW, + remixContestId: eventId, + trackId + }) + ) + }, [trackId, eventId]) // Hide the stack navigator header — the in-hero back button is the // only back affordance in the Figma (2888-131647). Leaving the diff --git a/packages/mobile/src/screens/contest-screen/tabs/ContestSubmissionsTab.tsx b/packages/mobile/src/screens/contest-screen/tabs/ContestSubmissionsTab.tsx index 72b56e1c105..78a7ae09c30 100644 --- a/packages/mobile/src/screens/contest-screen/tabs/ContestSubmissionsTab.tsx +++ b/packages/mobile/src/screens/contest-screen/tabs/ContestSubmissionsTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getRemixesQueryKey, @@ -6,10 +6,13 @@ import { useRemixesCount, useRemixesLineup } from '@audius/common/api' +import { Name } from '@audius/common/models' import type { ID } from '@audius/common/models' +import { useFocusedTab } from 'react-native-collapsible-tab-view' import { Divider, FilterButton, Flex, Text } from '@audius/harmony-native' import { TrackLineup } from 'app/components/lineup/TrackLineup' +import { make, track as trackEvent } from 'app/services/analytics' import { useContestPage } from '../ContestPageContext' @@ -56,10 +59,33 @@ const SORT_OPTIONS = [ * gap by hydrating Redux from the tan-query cache immediately. */ export const ContestSubmissionsTab = () => { - const { trackId } = useContestPage() + const { trackId, eventId } = useContestPage() const { data: contest } = useRemixContest(trackId) const winnerCount = contest?.eventData?.winners?.length ?? 0 + // Fire a Remix Contest: View Submissions event the first time the + // user actually focuses this tab. Tabs are mounted eagerly + // (`lazy: false` in `CollapsibleTabNavigator`), so a plain mount + // effect would fire even for users who only ever look at the Details + // tab. `useFocusedTab` from react-native-collapsible-tab-view is the + // primitive the tab navigator already uses — it returns the + // currently-focused tab name and re-runs effects when that changes. + const focusedTab = useFocusedTab() + const hasFiredSubmissionsViewRef = useRef(false) + useEffect(() => { + if (hasFiredSubmissionsViewRef.current) return + if (focusedTab !== 'Submissions') return + if (trackId == null || eventId == null) return + hasFiredSubmissionsViewRef.current = true + trackEvent( + make({ + eventName: Name.REMIX_CONTEST_VIEW_SUBMISSIONS, + remixContestId: eventId, + trackId + }) + ) + }, [focusedTab, trackId, eventId]) + const [sortMethod, setSortMethod] = useState<'recent' | 'plays' | 'likes'>( 'recent' ) diff --git a/packages/mobile/src/screens/explore-screen/SearchExploreScreen.tsx b/packages/mobile/src/screens/explore-screen/SearchExploreScreen.tsx index 3b1811d3cdd..1dc675d4f1e 100644 --- a/packages/mobile/src/screens/explore-screen/SearchExploreScreen.tsx +++ b/packages/mobile/src/screens/explore-screen/SearchExploreScreen.tsx @@ -21,7 +21,7 @@ import { useExploreRoute } from './hooks' const SearchExploreContent = () => { const { params } = useExploreRoute<'SearchExplore'>() const scrollRef = useRef(null) - const [, setCategory] = useSearchCategory() + const [category, setCategory] = useSearchCategory() const [filters, setFilters] = useSearchFilters() const [query, setQuery] = useSearchQuery() const [, setAutoFocus] = useSearchAutoFocus() @@ -50,7 +50,7 @@ const SearchExploreContent = () => { } }) - const showSearch = Boolean(query || hasAnyFilter) + const showSearch = Boolean(query || hasAnyFilter || category !== 'all') return ( diff --git a/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx b/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx index b26f69d18e9..3d1280c48f1 100644 --- a/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx +++ b/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getRemixesQueryKey, @@ -16,7 +16,7 @@ import { } from '@audius/common/api' import { useFeatureFlag } from '@audius/common/hooks' import type { ID } from '@audius/common/models' -import { SquareSizes, ShareSource } from '@audius/common/models' +import { Name, SquareSizes, ShareSource } from '@audius/common/models' import { FeatureFlags } from '@audius/common/services' import { remixesPageActions, @@ -53,6 +53,7 @@ import { UserGeneratedText } from 'components/user-generated-text' import { VideoEmbed } from 'components/video-embed/VideoEmbed' import { useRequiresAccountCallback } from 'hooks/useRequiresAccount' import { useTrackCoverArt } from 'hooks/useTrackCoverArt' +import { make, track as trackEvent } from 'services/analytics' import { useRemixPageParams } from 'pages/remixes-page/hooks' import { useUpdateSearchParams } from 'pages/search-page/hooks' import { @@ -318,6 +319,24 @@ const ContestPage = ({ containerRef: _containerRef }: ContestPageProps) => { } }, [dispatch]) + // Fire a Remix Contest: View event the first time the page resolves a + // trackId + eventId for the contest. Guard with a ref so navigating + // between contest tabs (which doesn't unmount the page) doesn't + // re-fire the event. + const hasFiredViewRef = useRef(false) + useEffect(() => { + if (hasFiredViewRef.current) return + if (trackId == null || eventId == null) return + hasFiredViewRef.current = true + trackEvent( + make({ + eventName: Name.REMIX_CONTEST_VIEW, + remixContestId: eventId, + trackId + }) + ) + }, [trackId, eventId]) + const isEnded = useMemo(() => { if (!contest?.endDate) return true return dayjs(contest.endDate).isBefore(dayjs()) @@ -368,7 +387,19 @@ const ContestPage = ({ containerRef: _containerRef }: ContestPageProps) => { // shape; reused across the desktop + mobile contest pages and the // mobile track-page contest details tab so submitters get the same // pre-filled form regardless of entry point. - const handleEnterContest = useEnterContest(trackId) + const enterContest = useEnterContest(trackId) + const handleEnterContest = useCallback(async () => { + if (trackId != null && eventId != null) { + trackEvent( + make({ + eventName: Name.REMIX_CONTEST_ENTER, + remixContestId: eventId, + trackId + }) + ) + } + await enterContest() + }, [enterContest, trackId, eventId]) const handleShareContest = useCallback(() => { if (!trackId) return @@ -711,7 +742,18 @@ const ContestPage = ({ containerRef: _containerRef }: ContestPageProps) => { size='large' isSelected={activeTab === 'submissions'} label={messages.submissionsTab(submissionsCount)} - onClick={() => setActiveTab('submissions')} + onClick={() => { + if (activeTab !== 'submissions' && trackId && eventId) { + trackEvent( + make({ + eventName: Name.REMIX_CONTEST_VIEW_SUBMISSIONS, + remixContestId: eventId, + trackId + }) + ) + } + setActiveTab('submissions') + }} /> ) : null} diff --git a/packages/web/src/pages/search-explore-page/components/desktop/SearchExplorePage.tsx b/packages/web/src/pages/search-explore-page/components/desktop/SearchExplorePage.tsx index 14bf13436e8..65306dc7c11 100644 --- a/packages/web/src/pages/search-explore-page/components/desktop/SearchExplorePage.tsx +++ b/packages/web/src/pages/search-explore-page/components/desktop/SearchExplorePage.tsx @@ -344,7 +344,7 @@ const SearchExplorePage = ({ minWidth: MIN_DESKTOP_CONTENT_WIDTH_PX, overflowX: 'clip', overflowY: 'visible', - display: showSearchResults ? 'none' : undefined + display: inputValue || showSearchResults ? 'none' : undefined }} > {sectionConfigs.map(({ key, shouldRender, element }) => diff --git a/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx b/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx index 410af39ebc3..12e818589d0 100644 --- a/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx +++ b/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx @@ -224,7 +224,7 @@ const SearchExplorePage = ({ direction='column' mt='l' gap='2xl' - css={{ display: showSearchResults ? 'none' : undefined }} + css={{ display: inputValue || showSearchResults ? 'none' : undefined }} > {showTrackContent && showUserContextualContent ? ( diff --git a/packages/web/src/pages/search-page/search-results/AlbumResults.tsx b/packages/web/src/pages/search-page/search-results/AlbumResults.tsx index b9cdc725d8f..bb6b73a12a7 100644 --- a/packages/web/src/pages/search-page/search-results/AlbumResults.tsx +++ b/packages/web/src/pages/search-page/search-results/AlbumResults.tsx @@ -152,7 +152,7 @@ export const AlbumResultsPage = () => { const { data, isFetching, hasNextPage, loadNextPage, isPending } = queryData const isResultsEmpty = data?.length === 0 - const showNoResultsTile = !isFetching && isResultsEmpty + const showNoResultsTile = !isFetching && !isPending && isResultsEmpty return ( { } = queryData const isResultsEmpty = playlists?.length === 0 - const showNoResultsTile = !isFetching && isResultsEmpty + const showNoResultsTile = !isFetching && !isPending && isResultsEmpty return (