From 81d5fdb722ec6383880738246163b164bc2990c9 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Fri, 8 May 2026 14:42:31 -0700 Subject: [PATCH 1/3] Add Amplitude analytics to contest UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instruments three key contest interactions following the existing 'Remix Contest:' event prefix and {remixContestId, trackId} property shape used by the host/pick-winners events: - REMIX_CONTEST_VIEW: contest page/screen first resolves trackId+eventId - REMIX_CONTEST_ENTER: user taps Enter Contest / Upload Remix - REMIX_CONTEST_VIEW_SUBMISSIONS: user opens the submissions tab Mobile submissions-tab firing uses useFocusedTab from react-native-collapsible-tab-view because the contest tabs mount eagerly (lazy: false) — a plain mount effect would fire even for users who only view the Details tab. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/common/src/models/Analytics.ts | 24 +++++++++ .../screens/contest-screen/ContestScreen.tsx | 37 +++++++++++++- .../tabs/ContestSubmissionsTab.tsx | 30 ++++++++++- .../components/desktop/ContestPage.tsx | 50 +++++++++++++++++-- 4 files changed, 133 insertions(+), 8 deletions(-) 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/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/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} From 1f5a75a8bac0cb6a99b4ad4e5e006d8cada850d7 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Wed, 13 May 2026 12:08:08 -0700 Subject: [PATCH 2/3] fix: explore page filters not applying consistently across all content sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 (web): Align static explore section visibility with SearchResults render condition — hide sections when inputValue is set (during debounce window) in addition to when showSearchResults is true, so Premium and other filters applied via the filter pills always suppress unfiltered curated sections. Bug 2 (web): Guard empty-state tile behind !isPending in AlbumResultsPage and PlaylistResultsPage so the NoResultsTile is not shown while the query is still in its initial loading phase, preventing a spurious empty-state flash when combining "Downloads Available" + "Electronic" genre filters. Bug 3 (mobile): Include category !== 'all' in the showSearch guard on the explore screen so tapping a content-type pill (Tracks, Albums, etc.) immediately shows the filtered SearchResults without requiring a second filter to be added. Co-Authored-By: Claude Sonnet 4.6 --- .../mobile/src/screens/explore-screen/SearchExploreScreen.tsx | 4 ++-- .../components/desktop/SearchExplorePage.tsx | 2 +- .../components/mobile/SearchExplorePage.tsx | 2 +- .../web/src/pages/search-page/search-results/AlbumResults.tsx | 2 +- .../src/pages/search-page/search-results/PlaylistResults.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) 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/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 ( Date: Fri, 15 May 2026 15:37:14 -0700 Subject: [PATCH 3/3] fix(mobile): comment kebab menu doesn't open reliably MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler toggled isOpen/isVisible from captured state, which let the two booleans drift out of sync when the drawer was swipe-closed mid-animation — from then on, taps re-rendered the drawer with isOpen=false and it never slid back in. Always set both true on press and let onClose/onClosed drive the close path. Also remove a redundant CommentSectionProvider wrap inside the Portal: the rows close over the outer section context, and the provider short-circuits to null until its useTrack query rehydrates, which can swallow the drawer entirely. CommentDrawerHeader already portals its action drawer without this wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/fix-comment-kebab-menu.md | 5 ++++ .../comments/CommentOverflowMenu.tsx | 25 ++++++++++--------- 2 files changed, 18 insertions(+), 12 deletions(-) create mode 100644 .changeset/fix-comment-kebab-menu.md diff --git a/.changeset/fix-comment-kebab-menu.md b/.changeset/fix-comment-kebab-menu.md new file mode 100644 index 00000000000..5797a742478 --- /dev/null +++ b/.changeset/fix-comment-kebab-menu.md @@ -0,0 +1,5 @@ +--- +'@audius/mobile': patch +--- + +Fix the comment kebab (three-dot) menu in mobile: tapping the kebab now reliably opens the action drawer with Edit/Delete (and other) options. The previous handler toggled `isOpen`/`isVisible` from captured state, which let the two booleans drift out of sync after a swipe-close mid-animation — from then on, taps re-rendered the drawer with `isOpen=false` and it never slid back in. Also dropped a redundant inner `CommentSectionProvider` wrap inside the Portal that could short-circuit the drawer to `null` while its track query rehydrated. diff --git a/packages/mobile/src/components/comments/CommentOverflowMenu.tsx b/packages/mobile/src/components/comments/CommentOverflowMenu.tsx index 305298754eb..12e262a2147 100644 --- a/packages/mobile/src/components/comments/CommentOverflowMenu.tsx +++ b/packages/mobile/src/components/comments/CommentOverflowMenu.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useState } from 'react' import { useUser } from '@audius/common/api' import { - CommentSectionProvider, useCurrentCommentSection, useDeleteComment, useUpdateCommentNotificationSetting, @@ -238,8 +237,12 @@ export const CommentOverflowMenu = (props: CommentOverflowMenuProps) => { }, [deleteComment, id, parentCommentId, toast]) const handlePress = useCallback(() => { - setIsOpen(!isOpen) - setIsVisible(!isVisible) + // Always open on press; closing is driven by the drawer's onClose/onClosed + // callbacks. Toggling these from captured values let isOpen and isVisible + // drift out of sync when the drawer was swipe-closed mid-animation, after + // which subsequent taps rendered the drawer with isOpen=false (no slide-in). + setIsOpen(true) + setIsVisible(true) trackEvent( make({ @@ -247,7 +250,7 @@ export const CommentOverflowMenu = (props: CommentOverflowMenuProps) => { commentId: id }) ) - }, [isOpen, isVisible, id]) + }, [id]) return ( <> @@ -262,14 +265,12 @@ export const CommentOverflowMenu = (props: CommentOverflowMenuProps) => { {isVisible ? ( - - setIsOpen(false)} - onClosed={() => setIsVisible(false)} - /> - + setIsOpen(false)} + onClosed={() => setIsVisible(false)} + /> ) : null} {isFlagAndHideConfirmationVisible ? (