Skip to content

Commit 23804e2

Browse files
dylanjeffersclaude
andauthored
Auto-follow contest when user downloads a stem (#14295)
## Summary Downloading a stem is a strong engagement signal that the user wants to participate in the contest, so this PR wires `ContestStemsCard` (mobile + web) to fire `useFollowEvent` for the parent contest the first time a signed-in user kicks off a download. ## Implementation Both surfaces use the same shape: - Resolve the contest event from the card's existing `trackId` prop via `useRemixContest` (same pattern `ContestScreen` / desktop `ContestPage` already use). - Pair with `useCurrentUserId` + `useEventFollowState`, then guard a `followContestIfNeeded` helper on (signed-in + not already following). - Call the helper inside both download handlers: - **Mobile** (`packages/mobile/src/screens/contest-screen/ContestStemsCard.tsx`): inside `handleDownloadOne` and `handleDownloadAll` (both open `WaitForDownloadModal`). - **Web** (`packages/web/src/pages/contest-page/components/ContestStemsCard.tsx`): inside the two `useRequiresAccountCallback`-wrapped handlers — `handleDownloadAll` (archive modal) and `handleDownloadOne` (wait-for-download modal). Sitting inside the `useRequiresAccountCallback` means the follow only fires after the sign-in gate has resolved, so we never call `followEvent({ userId: null, ... })`. The follow mutation is optimistic (see `packages/common/src/api/tan-query/events/useFollowEvent.ts`), so the contest's Follow button flips to \"Following\" instantly with automatic rollback on error. ## ⚠️ Timing note — please weigh in The brief said \"after a **successful** stem download completes,\" but I fire the follow on **download tap**, not on completion. The reason: there's no completion signal exposed to callers today. - `DOWNLOAD_FINISHED` is defined in `packages/common/src/store/social/tracks/actions.ts` but never dispatched anywhere. - `WaitForDownloadModalState` doesn't accept an `onSuccess` callback — the modal dispatches `tracksSocialActions.downloadTrack` to a saga, and the saga silently completes (then waits for `CANCEL_DOWNLOAD`). - Hooking true completion would need: (a) the saga to dispatch a success action after `downloadTracks` returns, plus (b) plumbing that signal back to the modal/caller. Doable but a bigger change than the brief implied. In practice, firing on tap should be fine because: 1. Tapping the download button is itself the engagement signal — the user has expressed intent. 2. The follow mutation is optimistic; users see instant feedback and can unfollow with one tap. 3. The guard on `followState?.isFollowed` means tapping a second time doesn't double-fire. **If you'd rather have it fire only on true download success, say the word and I'll do the saga + modal-state refactor in a follow-up.** ## Test plan Couldn't auto-verify — this needs a signed-in user on a live contest with stems, which the preview environment can't reproduce. The existing `ContestPage.test.tsx` already mocks every hook the new code uses (`useRemixContest`, `useCurrentUserId`, `useEventFollowState`, `useFollowEvent`) so CI should be green. Manual QA checklist: - [ ] Signed-in user, not already following → tap any per-stem download icon → Follow button on contest page flips to Following; download starts. - [ ] Signed-in user, not already following → tap Download All → same. - [ ] Signed-in user, **already following** → tap download → no spurious mutation fires (Network tab clean), download still starts. - [ ] Signed-out user on web → tap download → sign-in flow triggers (`useRequiresAccountCallback`); after sign-in the original handler runs and the follow fires. - [ ] Signed-out user on mobile → tap download → follow stays no-op (`currentUserId` is null), download flow behaves as today. - [ ] iOS + Android. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 74e248b commit 23804e2

2 files changed

Lines changed: 63 additions & 4 deletions

File tree

packages/mobile/src/screens/contest-screen/ContestStemsCard.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1-
import { useMemo, useState } from 'react'
1+
import { useCallback, useMemo, useState } from 'react'
22

3-
import { useFileSizes, useStems, useTrack, useUser } from '@audius/common/api'
3+
import {
4+
useCurrentUserId,
5+
useEventFollowState,
6+
useFileSizes,
7+
useFollowEvent,
8+
useRemixContest,
9+
useStems,
10+
useTrack,
11+
useUser
12+
} from '@audius/common/api'
413
import type { ID } from '@audius/common/models'
514
import {
615
DownloadQuality,
@@ -105,7 +114,26 @@ export const ContestStemsCard = ({ trackId }: ContestStemsCardProps) => {
105114

106115
const { onOpen: openWaitForDownloadModal } = useWaitForDownloadModal()
107116

117+
// Auto-follow the contest when a signed-in user kicks off a stem
118+
// download — downloading a stem is a strong engagement signal that
119+
// the user wants to participate. Fires on tap (rather than on
120+
// download completion) because the download saga doesn't expose a
121+
// success hook to the caller; the follow mutation is optimistic so
122+
// the UI updates instantly, and the user can unfollow at any time.
123+
const { data: contest } = useRemixContest(trackId)
124+
const contestEventId = contest?.eventId
125+
const { data: currentUserId } = useCurrentUserId()
126+
const { data: followState } = useEventFollowState(contestEventId)
127+
const { mutate: followEvent } = useFollowEvent()
128+
129+
const followContestIfNeeded = useCallback(() => {
130+
if (!contestEventId || !currentUserId) return
131+
if (followState?.isFollowed) return
132+
followEvent({ userId: currentUserId, eventId: contestEventId })
133+
}, [contestEventId, currentUserId, followState?.isFollowed, followEvent])
134+
108135
const handleDownloadOne = (downloadTrackId: ID) => {
136+
followContestIfNeeded()
109137
openWaitForDownloadModal({
110138
parentTrackId: trackId,
111139
trackIds: [downloadTrackId],
@@ -115,6 +143,7 @@ export const ContestStemsCard = ({ trackId }: ContestStemsCardProps) => {
115143

116144
const handleDownloadAll = () => {
117145
if (!track) return
146+
followContestIfNeeded()
118147
// All stems + (optionally) the parent track.
119148
const ids = [
120149
...(track.is_downloadable ? [trackId] : []),

packages/web/src/pages/contest-page/components/ContestStemsCard.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { MouseEvent, useCallback, useState } from 'react'
22

33
import {
4+
useCurrentUserId,
5+
useEventFollowState,
46
useFileSizes,
7+
useFollowEvent,
8+
useRemixContest,
59
useStems,
610
useTrack,
711
useTrackByPermalink,
@@ -95,6 +99,24 @@ export const ContestStemsCard = ({ trackId }: ContestStemsCardProps) => {
9599
useDownloadTrackArchiveModal()
96100
const { onOpen: openWaitForDownloadModal } = useWaitForDownloadModal()
97101

102+
// Auto-follow the contest when a signed-in user kicks off a stem
103+
// download — downloading a stem is a strong engagement signal that
104+
// the user wants to participate. Fires on tap (rather than on
105+
// download completion) because the download saga doesn't expose a
106+
// success hook to the caller; the follow mutation is optimistic so
107+
// the UI updates instantly, and the user can unfollow at any time.
108+
const { data: contest } = useRemixContest(trackId)
109+
const contestEventId = contest?.eventId
110+
const { data: currentUserId } = useCurrentUserId()
111+
const { data: followState } = useEventFollowState(contestEventId)
112+
const { mutate: followEvent } = useFollowEvent()
113+
114+
const followContestIfNeeded = useCallback(() => {
115+
if (!contestEventId || !currentUserId) return
116+
if (followState?.isFollowed) return
117+
followEvent({ userId: currentUserId, eventId: contestEventId })
118+
}, [contestEventId, currentUserId, followState?.isFollowed, followEvent])
119+
98120
const stemsCount = stems.length
99121
// Default to expanded for short lists so users can see the stems
100122
// without an extra click; collapse when there are more than
@@ -129,12 +151,19 @@ export const ContestStemsCard = ({ trackId }: ContestStemsCardProps) => {
129151
(e: MouseEvent) => {
130152
e.stopPropagation()
131153
if (!track) return
154+
followContestIfNeeded()
132155
openDownloadTrackArchiveModal({
133156
trackId,
134157
fileCount: stemsCount + (track.is_downloadable ? 1 : 0)
135158
})
136159
},
137-
[openDownloadTrackArchiveModal, trackId, stemsCount, track]
160+
[
161+
followContestIfNeeded,
162+
openDownloadTrackArchiveModal,
163+
trackId,
164+
stemsCount,
165+
track
166+
]
138167
)
139168

140169
const handleUnlockAll = useRequiresAccountCallback(
@@ -153,13 +182,14 @@ export const ContestStemsCard = ({ trackId }: ContestStemsCardProps) => {
153182
// specific trackId the user clicked.
154183
const handleDownloadOne = useRequiresAccountCallback(
155184
(downloadTrackId: ID, parentTrackId?: ID) => {
185+
followContestIfNeeded()
156186
openWaitForDownloadModal({
157187
parentTrackId,
158188
trackIds: [downloadTrackId],
159189
quality: DownloadQuality.ORIGINAL
160190
})
161191
},
162-
[openWaitForDownloadModal]
192+
[followContestIfNeeded, openWaitForDownloadModal]
163193
)
164194

165195
// Click-through to the track page. Guarded: action buttons inside

0 commit comments

Comments
 (0)