Skip to content

Commit a6e8c2e

Browse files
cursor[bot]cursoragentraymondjacobsonclaude
authored
Fix login prompt for contest comments (#14368)
## Summary - Render the contest comments composer for signed-out viewers instead of inert sign-in text - Gate composer click/submit with `useRequiresAccountCallback`, matching the Enter Contest auth prompt - Add focused coverage for the signed-out contest comment composer ## Testing - `npm run test -w @audius/web -- src/pages/contest-page/components/ContestCommentsTile.test.tsx --run` <div><a href="https://cursor.com/agents/bc-f38a20c5-458d-50df-9124-f47a41e8565b"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/automations/c63aa103-66df-4558-b31d-675358e5c6a1"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/view-automation-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/view-automation-light.png"><img alt="View Automation" width="141" height="28" src="https://cursor.com/assets/images/view-automation-dark.png"></picture></a>&nbsp;</div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Ray Jacobson <raymondjacobson@users.noreply.github.com> Co-authored-by: Raymond Jacobson <ray@audius.co> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6c05553 commit a6e8c2e

2 files changed

Lines changed: 146 additions & 15 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, expect, vi, beforeEach } from 'vitest'
2+
3+
import { fireEvent, render, screen, it } from 'test/test-utils'
4+
5+
import { ContestCommentsTile } from './ContestCommentsTile'
6+
7+
const mocks = vi.hoisted(() => ({
8+
useCurrentUserId: vi.fn(),
9+
useEventComments: vi.fn(),
10+
usePostEventComment: vi.fn(),
11+
useComment: vi.fn(),
12+
useDeleteComment: vi.fn(),
13+
useReactToComment: vi.fn(),
14+
useUser: vi.fn(),
15+
useRequiresAccountCallback: vi.fn(),
16+
requiresAccount: vi.fn()
17+
}))
18+
19+
vi.mock('@audius/common/api', async (importOriginal) => {
20+
const actual = (await importOriginal()) as object
21+
return {
22+
...actual,
23+
useCurrentUserId: mocks.useCurrentUserId,
24+
useEventComments: mocks.useEventComments,
25+
usePostEventComment: mocks.usePostEventComment,
26+
useComment: mocks.useComment,
27+
useDeleteComment: mocks.useDeleteComment,
28+
useReactToComment: mocks.useReactToComment,
29+
useUser: mocks.useUser
30+
}
31+
})
32+
33+
vi.mock('hooks/useRequiresAccount', () => ({
34+
useRequiresAccountCallback: (callback: (...args: any[]) => any) =>
35+
mocks.useRequiresAccountCallback(callback)
36+
}))
37+
38+
vi.mock('hooks/useProfilePicture', () => ({
39+
useProfilePicture: () => undefined
40+
}))
41+
42+
vi.mock('components/link/UserLink', () => ({
43+
UserLink: ({ userId }: { userId: number }) => (
44+
<span data-testid='user-link'>user-{userId}</span>
45+
)
46+
}))
47+
48+
vi.mock('components/composer-input/ComposerInput', () => ({
49+
ComposerInput: ({
50+
placeholder,
51+
onClick,
52+
readOnly
53+
}: {
54+
placeholder?: string
55+
onClick?: () => void
56+
readOnly?: boolean
57+
}) => (
58+
<textarea
59+
aria-label={placeholder}
60+
placeholder={placeholder}
61+
readOnly={readOnly}
62+
onClick={onClick}
63+
/>
64+
)
65+
}))
66+
67+
const EVENT_ID = 100
68+
const EVENT_OWNER_ID = 1
69+
70+
describe('ContestCommentsTile', () => {
71+
beforeEach(() => {
72+
vi.clearAllMocks()
73+
mocks.useCurrentUserId.mockReturnValue({ data: null })
74+
mocks.useEventComments.mockReturnValue({
75+
data: [],
76+
isPending: false,
77+
hasNextPage: false,
78+
fetchNextPage: vi.fn(),
79+
isFetchingNextPage: false
80+
})
81+
mocks.usePostEventComment.mockReturnValue({
82+
mutate: vi.fn(),
83+
isPending: false
84+
})
85+
mocks.useComment.mockReturnValue({ data: undefined })
86+
mocks.useDeleteComment.mockReturnValue({ mutate: vi.fn() })
87+
mocks.useReactToComment.mockReturnValue({ mutate: vi.fn() })
88+
mocks.useUser.mockReturnValue({ data: undefined })
89+
mocks.useRequiresAccountCallback.mockImplementation(
90+
(callback: (...args: any[]) => any) =>
91+
(...args: any[]) => {
92+
mocks.requiresAccount()
93+
// eslint-disable-next-line n/no-callback-literal
94+
return callback(...args)
95+
}
96+
)
97+
})
98+
99+
it('login-gates the comments composer for signed-out viewers', () => {
100+
render(
101+
<ContestCommentsTile
102+
eventId={EVENT_ID}
103+
eventOwnerUserId={EVENT_OWNER_ID}
104+
mode='comments'
105+
/>
106+
)
107+
108+
const input = screen.getByRole('textbox', { name: /add a comment/i })
109+
expect(input).toHaveAttribute('readonly')
110+
expect(screen.queryByText(/sign in to comment/i)).not.toBeInTheDocument()
111+
expect(mocks.requiresAccount).not.toHaveBeenCalled()
112+
113+
fireEvent.click(input)
114+
115+
expect(mocks.requiresAccount).toHaveBeenCalledTimes(1)
116+
})
117+
})

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

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

33
import {
44
getCommentQueryKey,
@@ -36,6 +36,7 @@ import { ComposerInput } from 'components/composer-input/ComposerInput'
3636
import { UserLink } from 'components/link/UserLink'
3737
import { VideoEmbed } from 'components/video-embed/VideoEmbed'
3838
import { useProfilePicture } from 'hooks/useProfilePicture'
39+
import { useRequiresAccountCallback } from 'hooks/useRequiresAccount'
3940

4041
import { Timestamp } from '../../../components/comments/Timestamp'
4142
import { AttachVideoModal } from '../../fan-club-detail-page/components/AttachVideoModal'
@@ -66,8 +67,8 @@ const messages = {
6667
* - `updates` renders only host-authored top-level posts. Composer shown
6768
* only to the host; composer exposes an Attach Video affordance.
6869
* - `comments` renders everything that *isn't* a host post-update
69-
* (community comments + replies). Composer shown to every signed-in
70-
* user. No video attach — that's host-only.
70+
* (community comments + replies). Composer shown to public viewers and
71+
* login-gated for signed-out users. No video attach — that's host-only.
7172
*/
7273
export type ContestCommentsMode = 'updates' | 'comments'
7374

@@ -127,8 +128,10 @@ export const ContestCommentsTile = ({
127128
const { data: currentUserId } = useCurrentUserId()
128129
const isEventOwner =
129130
currentUserId !== null &&
131+
currentUserId !== undefined &&
130132
eventOwnerUserId !== undefined &&
131133
currentUserId === eventOwnerUserId
134+
const isLoggedIn = currentUserId !== null && currentUserId !== undefined
132135

133136
// Sort toggle lives on the Comments panel only. Updates is host-curated
134137
// and always pinned to newest-first.
@@ -151,12 +154,11 @@ export const ContestCommentsTile = ({
151154
mode === 'updates' ? messages.updatesHeading : messages.commentsHeading
152155
// In `comments` mode the host should NOT see the top-level composer —
153156
// they participate via replies (and via the dedicated POST UPDATE
154-
// composer for announcements). Viewers see the top-level composer.
157+
// composer for announcements). Public viewers see the top-level composer;
158+
// signed-out viewers get the same account gate as Enter Contest.
155159
// In `updates` mode only the host can compose top-level posts.
156160
const showComposer =
157-
!hideComposer &&
158-
currentUserId !== null &&
159-
(mode === 'comments' ? !isEventOwner : isEventOwner)
161+
!hideComposer && (mode === 'comments' ? !isEventOwner : isEventOwner)
160162
// When `hideComposer` is set, the caller is rendering a feed-only
161163
// tile alongside a separate composer (e.g. desktop details), so the
162164
// "sign in to comment" stub would be a redundant CTA. Track separately
@@ -176,7 +178,15 @@ export const ContestCommentsTile = ({
176178
// track page uses the same pattern.
177179
const [messageId, setMessageId] = useState(0)
178180

179-
const handleComposerSubmit = useCallback(
181+
const handleComposerClick = useRequiresAccountCallback(
182+
() => {},
183+
[],
184+
undefined,
185+
undefined,
186+
'account'
187+
)
188+
189+
const handleComposerSubmit = useRequiresAccountCallback(
180190
(value: string) => {
181191
const body = value.trim()
182192
if (!body || !currentUserId) return
@@ -268,13 +278,15 @@ export const ContestCommentsTile = ({
268278
{showComposer ? (
269279
<Flex direction='column' gap='m' w='100%'>
270280
<Flex w='100%' gap='s' alignItems='center'>
271-
<HarmonyAvatar
272-
size='auto'
273-
borderWidth='thin'
274-
isLoading={false}
275-
src={profileImage}
276-
css={{ width: 32, height: 32, flexShrink: 0 }}
277-
/>
281+
{isLoggedIn ? (
282+
<HarmonyAvatar
283+
size='auto'
284+
borderWidth='thin'
285+
isLoading={false}
286+
src={profileImage}
287+
css={{ width: 32, height: 32, flexShrink: 0 }}
288+
/>
289+
) : null}
278290
<Box css={{ flex: 1, minWidth: 0 }}>
279291
{/* ComposerInput renders its own send affordance + Enter
280292
submit, so no external send button is needed. Matches
@@ -286,8 +298,10 @@ export const ContestCommentsTile = ({
286298
placeholder={composerPlaceholder}
287299
maxLength={400}
288300
maxMentions={10}
301+
onClick={handleComposerClick}
289302
onSubmit={(value) => handleComposerSubmit(value)}
290303
disabled={isPosting}
304+
readOnly={!isLoggedIn}
291305
blurOnSubmit
292306
/>
293307
</Box>

0 commit comments

Comments
 (0)