diff --git a/packages/common/src/api/tan-query/notifications/useNotifications.ts b/packages/common/src/api/tan-query/notifications/useNotifications.ts index 3a260269f1f..42bb24a8388 100644 --- a/packages/common/src/api/tan-query/notifications/useNotifications.ts +++ b/packages/common/src/api/tan-query/notifications/useNotifications.ts @@ -1,7 +1,11 @@ import { useEffect, useMemo } from 'react' -import { Id, type NotificationsResponse } from '@audius/sdk' -import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query' +import { Id } from '@audius/sdk' +import { + InfiniteData, + useInfiniteQuery, + useQueryClient +} from '@tanstack/react-query' import { usePrevious } from 'react-use' import { notificationFromSDK, transformAndCleanList } from '~/adapters' @@ -11,156 +15,24 @@ import { ChallengeRewardID } from '~/models' import { ID } from '~/models/Identifiers' import { StringKeys } from '~/services' import { - Entity, NotificationType, Notification } from '~/store/notifications/types' -import { useCollections } from '../collection/useCollections' import { QUERY_KEYS } from '../queryKeys' -import { useTracks } from '../tracks/useTracks' import { QueryKey, QueryOptions } from '../types' import { useCurrentUserId } from '../users/account/useCurrentUserId' -import { useUsers } from '../users/useUsers' +import { primeRelatedData } from '../utils/primeRelatedData' import { useNotificationUnreadCount } from './useNotificationUnreadCount' const DEFAULT_LIMIT = 20 -const USER_INITIAL_LOAD_COUNT = 9 type PageParam = { timestamp: number groupId: string | undefined } | null -type EntityIds = { - userIds: ID[] - trackIds: ID[] - collectionIds: ID[] -} - -const collectEntityIds = (notifications: Notification[]): EntityIds => { - const trackIds = new Set() - const collectionIds = new Set() - const userIds = new Set() - - notifications.forEach((notification) => { - const { type } = notification - if (type === NotificationType.UserSubscription) { - if (notification.entityType === Entity.Track) { - if (notification.entityIds.length === 1) { - trackIds.add(notification.entityIds[0]) - } - } else if ( - notification.entityType === Entity.Playlist || - notification.entityType === Entity.Album - ) { - if (notification.entityIds.length === 1) { - collectionIds.add(notification.entityIds[0]) - } - } - userIds.add(notification.userId) - } - if ( - type === NotificationType.Repost || - type === NotificationType.RepostOfRepost || - type === NotificationType.Favorite || - type === NotificationType.FavoriteOfRepost || - (type === NotificationType.Milestone && 'entityType' in notification) - ) { - if (notification.entityType === Entity.Track) { - trackIds.add(notification.entityId) - } else if ( - notification.entityType === Entity.Playlist || - notification.entityType === Entity.Album - ) { - collectionIds.add(notification.entityId) - } else if (notification.entityType === Entity.User) { - userIds.add(notification.entityId) - } - } - if ( - type === NotificationType.Follow || - type === NotificationType.Repost || - type === NotificationType.RepostOfRepost || - type === NotificationType.Favorite || - type === NotificationType.FavoriteOfRepost - ) { - notification.userIds - .slice(0, USER_INITIAL_LOAD_COUNT) - .forEach((id) => userIds.add(id)) - } - if (type === NotificationType.RemixCreate) { - trackIds.add(notification.parentTrackId).add(notification.childTrackId) - } - if (type === NotificationType.RemixCosign) { - notification.entityIds.forEach((id) => trackIds.add(id)) - userIds.add(notification.parentTrackUserId) - } - if ( - type === NotificationType.TrendingTrack || - type === NotificationType.TrendingUnderground - ) { - trackIds.add(notification.entityId) - } - if ( - type === NotificationType.AddTrackToPlaylist || - type === NotificationType.TrackAddedToPurchasedAlbum - ) { - trackIds.add(notification.trackId) - userIds.add(notification.playlistOwnerId) - collectionIds.add(notification.playlistId) - } - if (type === NotificationType.Tastemaker) { - userIds.add(notification.userId) - trackIds.add(notification.entityId) - } - if ( - type === NotificationType.USDCPurchaseBuyer || - type === NotificationType.USDCPurchaseSeller - ) { - notification.userIds.forEach((id) => userIds.add(id)) - if (notification.entityType === Entity.Track) { - trackIds.add(notification.entityId) - } else if (notification.entityType === Entity.Album) { - collectionIds.add(notification.entityId) - } - } - if ( - type === NotificationType.RequestManager || - type === NotificationType.ApproveManagerRequest - ) { - userIds.add(notification.userId) - } - if ( - type === NotificationType.Comment || - type === NotificationType.CommentThread || - type === NotificationType.CommentMention || - type === NotificationType.CommentReaction - ) { - if (notification.entityType === Entity.Track) { - trackIds.add(notification.entityId) - } - notification.userIds - .slice(0, USER_INITIAL_LOAD_COUNT) - .forEach((id) => userIds.add(id)) - } - if (type === NotificationType.RemixCreate) { - trackIds.add(notification.parentTrackId) - trackIds.add(notification.childTrackId) - } - if (type === NotificationType.FanClubTextPost) { - userIds.add(notification.entityUserId) - } - }) - - return { - userIds: Array.from(userIds), - trackIds: Array.from(trackIds), - collectionIds: Array.from(collectionIds) - } -} - export const getNotificationsQueryKey = ({ currentUserId, pageSize @@ -181,6 +53,7 @@ export const getNotificationsQueryKey = ({ */ export const useNotifications = (options?: QueryOptions) => { const { audiusSdk } = useQueryContext() + const queryClient = useQueryClient() const { data: currentUserId } = useCurrentUserId() const pageSize = DEFAULT_LIMIT const { data: unreadCount } = useNotificationUnreadCount() @@ -205,25 +78,17 @@ export const useNotifications = (options?: QueryOptions) => { initialPageParam: null as PageParam, queryFn: async ({ pageParam = null }) => { const sdk = await audiusSdk() - const response = await ( - sdk.notifications as { - getNotifications: (params: { - userId: string - limit?: number - timestamp?: number - groupId?: string - }) => Promise - } - ).getNotifications({ + const response = await sdk.notifications.getNotifications({ userId: Id.parse(currentUserId), limit: DEFAULT_LIMIT, timestamp: pageParam?.timestamp, groupId: pageParam?.groupId }) - const data = response + + primeRelatedData({ related: response.related, queryClient }) const notifications = transformAndCleanList( - data?.data?.notifications, + response?.data?.notifications, notificationFromSDK ) as Notification[] @@ -263,38 +128,12 @@ export const useNotifications = (options?: QueryOptions) => { } }, [unreadCount, prevUnreadCount, query]) - const lastPage = query.data?.pages[query.data.pages.length - 1] - const { userIds, trackIds, collectionIds } = lastPage - ? collectEntityIds(lastPage) - : { userIds: undefined, trackIds: undefined, collectionIds: undefined } - - // Pre-fetch related entities - const { isPending: isUsersPending } = useUsers(userIds) - const { isPending: isTracksPending } = useTracks(trackIds) - const { isPending: isCollectionsPending } = useCollections(collectionIds) - - // Return all pages except the last one if it's still loading entity data - const notifications = query.data?.pages.slice(0, -1).flat() ?? [] - if ( - !query.isPending && - !isUsersPending && - !isTracksPending && - !isCollectionsPending && - lastPage - ) { - notifications.push(...lastPage) - } + const notifications = query.data?.pages.flat() ?? [] const queryResults = query as typeof query & { notifications: Notification[] - isAllPending: boolean } queryResults.notifications = notifications - queryResults.isAllPending = - queryResults.isPending || - isUsersPending || - isTracksPending || - isCollectionsPending return queryResults } diff --git a/packages/common/src/api/tan-query/utils/primeRelatedData.ts b/packages/common/src/api/tan-query/utils/primeRelatedData.ts index da01329b6c9..666cc762ecd 100644 --- a/packages/common/src/api/tan-query/utils/primeRelatedData.ts +++ b/packages/common/src/api/tan-query/utils/primeRelatedData.ts @@ -3,16 +3,19 @@ import { QueryClient } from '@tanstack/react-query' import { transformAndCleanList, + userCollectionMetadataFromSDK, userTrackMetadataFromSDK, userMetadataFromSDK } from '~/adapters' +import { primeCollectionData } from './primeCollectionData' import { primeTrackData } from './primeTrackData' import { primeUserData } from './primeUserData' /** * Utility function to prime related data from API responses - * This handles both users and tracks that may be included in the related field + * This handles users, tracks, and playlists that may be included in the + * related field. */ export const primeRelatedData = ({ related, @@ -27,9 +30,8 @@ export const primeRelatedData = ({ }) => { if (!related) return - const { users, tracks } = related + const { users, tracks, playlists } = related - // Prime user data if available if (users && users.length > 0) { primeUserData({ users: transformAndCleanList(users, userMetadataFromSDK), @@ -39,7 +41,6 @@ export const primeRelatedData = ({ }) } - // Prime track data if available if (tracks && tracks.length > 0) { primeTrackData({ tracks: transformAndCleanList(tracks, userTrackMetadataFromSDK), @@ -48,4 +49,16 @@ export const primeRelatedData = ({ skipQueryData }) } + + if (playlists && playlists.length > 0) { + primeCollectionData({ + collections: transformAndCleanList( + playlists, + userCollectionMetadataFromSDK + ), + queryClient, + forceReplace, + skipQueryData + }) + } } 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/mobile/src/screens/notifications-screen/NotificationList.tsx b/packages/mobile/src/screens/notifications-screen/NotificationList.tsx index 8d13a30fc0d..fb575adf487 100644 --- a/packages/mobile/src/screens/notifications-screen/NotificationList.tsx +++ b/packages/mobile/src/screens/notifications-screen/NotificationList.tsx @@ -87,7 +87,7 @@ export const NotificationList = () => { const { notifications, - isAllPending: isPending, + isPending, isError, fetchNextPage, refetch, diff --git a/packages/sdk/rollup.config.ts b/packages/sdk/rollup.config.ts index a8ffd2d8e86..268fe72c34b 100644 --- a/packages/sdk/rollup.config.ts +++ b/packages/sdk/rollup.config.ts @@ -1,5 +1,8 @@ +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' + import alias from '@rollup/plugin-alias' -import babel from '@rollup/plugin-babel' +import { babel } from '@rollup/plugin-babel' import commonjs from '@rollup/plugin-commonjs' import json from '@rollup/plugin-json' import resolve from '@rollup/plugin-node-resolve' @@ -9,7 +12,9 @@ import nodePolyfills from 'rollup-plugin-polyfill-node' import { terser } from 'rollup-plugin-terser' import { visualizer } from 'rollup-plugin-visualizer' -import pkg from './package.json' +const pkg = JSON.parse( + readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf-8') +) const extensions = ['.js', '.ts'] diff --git a/packages/sdk/src/sdk/api/generated/default/models/NotificationsResponse.ts b/packages/sdk/src/sdk/api/generated/default/models/NotificationsResponse.ts index 6db02292a43..0fa13f21784 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/NotificationsResponse.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/NotificationsResponse.ts @@ -19,6 +19,12 @@ import { NotificationsFromJSONTyped, NotificationsToJSON, } from './Notifications'; +import type { Related } from './Related'; +import { + RelatedFromJSON, + RelatedFromJSONTyped, + RelatedToJSON, +} from './Related'; import type { VersionMetadata } from './VersionMetadata'; import { VersionMetadataFromJSON, @@ -80,6 +86,12 @@ export interface NotificationsResponse { * @memberof NotificationsResponse */ data?: Notifications; + /** + * + * @type {Related} + * @memberof NotificationsResponse + */ + related?: Related; } /** @@ -116,6 +128,7 @@ export function NotificationsResponseFromJSONTyped(json: any, ignoreDiscriminato 'timestamp': json['timestamp'], 'version': VersionMetadataFromJSON(json['version']), 'data': !exists(json, 'data') ? undefined : NotificationsFromJSON(json['data']), + 'related': !exists(json, 'related') ? undefined : RelatedFromJSON(json['related']), }; } @@ -136,6 +149,7 @@ export function NotificationsResponseToJSON(value?: NotificationsResponse | null 'timestamp': value.timestamp, 'version': VersionMetadataToJSON(value.version), 'data': NotificationsToJSON(value.data), + 'related': RelatedToJSON(value.related), }; } diff --git a/packages/web/src/components/notification/NotificationPage.tsx b/packages/web/src/components/notification/NotificationPage.tsx index e2fc5de4ca8..2219d3c664d 100644 --- a/packages/web/src/components/notification/NotificationPage.tsx +++ b/packages/web/src/components/notification/NotificationPage.tsx @@ -30,7 +30,7 @@ const SCROLL_THRESHOLD = 300 export const NotificationPage = () => { const { notifications, - isAllPending: isPending, + isPending, hasNextPage, fetchNextPage, isFetchingNextPage diff --git a/packages/web/src/components/notification/NotificationPanel.tsx b/packages/web/src/components/notification/NotificationPanel.tsx index c0cb4e300ee..526db473e2c 100644 --- a/packages/web/src/components/notification/NotificationPanel.tsx +++ b/packages/web/src/components/notification/NotificationPanel.tsx @@ -57,7 +57,7 @@ export const NotificationPanel = ({ notifications, fetchNextPage, hasNextPage, - isAllPending: isPending, + isPending, isError, isFetchingNextPage } = useNotifications() 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..3a3546fb6da 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, @@ -55,6 +55,7 @@ import { useRequiresAccountCallback } from 'hooks/useRequiresAccount' import { useTrackCoverArt } from 'hooks/useTrackCoverArt' import { useRemixPageParams } from 'pages/remixes-page/hooks' import { useUpdateSearchParams } from 'pages/search-page/hooks' +import { make, track as trackEvent } from 'services/analytics' import { fullContestPage, hostRemixContestPage, @@ -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}