diff --git a/packages/web/src/pages/contest-page/components/ContestCommentsTile.test.tsx b/packages/web/src/pages/contest-page/components/ContestCommentsTile.test.tsx
new file mode 100644
index 00000000000..08f19e9b8c9
--- /dev/null
+++ b/packages/web/src/pages/contest-page/components/ContestCommentsTile.test.tsx
@@ -0,0 +1,117 @@
+import { describe, expect, vi, beforeEach } from 'vitest'
+
+import { fireEvent, render, screen, it } from 'test/test-utils'
+
+import { ContestCommentsTile } from './ContestCommentsTile'
+
+const mocks = vi.hoisted(() => ({
+ useCurrentUserId: vi.fn(),
+ useEventComments: vi.fn(),
+ usePostEventComment: vi.fn(),
+ useComment: vi.fn(),
+ useDeleteComment: vi.fn(),
+ useReactToComment: vi.fn(),
+ useUser: vi.fn(),
+ useRequiresAccountCallback: vi.fn(),
+ requiresAccount: vi.fn()
+}))
+
+vi.mock('@audius/common/api', async (importOriginal) => {
+ const actual = (await importOriginal()) as object
+ return {
+ ...actual,
+ useCurrentUserId: mocks.useCurrentUserId,
+ useEventComments: mocks.useEventComments,
+ usePostEventComment: mocks.usePostEventComment,
+ useComment: mocks.useComment,
+ useDeleteComment: mocks.useDeleteComment,
+ useReactToComment: mocks.useReactToComment,
+ useUser: mocks.useUser
+ }
+})
+
+vi.mock('hooks/useRequiresAccount', () => ({
+ useRequiresAccountCallback: (callback: (...args: any[]) => any) =>
+ mocks.useRequiresAccountCallback(callback)
+}))
+
+vi.mock('hooks/useProfilePicture', () => ({
+ useProfilePicture: () => undefined
+}))
+
+vi.mock('components/link/UserLink', () => ({
+ UserLink: ({ userId }: { userId: number }) => (
+ user-{userId}
+ )
+}))
+
+vi.mock('components/composer-input/ComposerInput', () => ({
+ ComposerInput: ({
+ placeholder,
+ onClick,
+ readOnly
+ }: {
+ placeholder?: string
+ onClick?: () => void
+ readOnly?: boolean
+ }) => (
+
+ )
+}))
+
+const EVENT_ID = 100
+const EVENT_OWNER_ID = 1
+
+describe('ContestCommentsTile', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.useCurrentUserId.mockReturnValue({ data: null })
+ mocks.useEventComments.mockReturnValue({
+ data: [],
+ isPending: false,
+ hasNextPage: false,
+ fetchNextPage: vi.fn(),
+ isFetchingNextPage: false
+ })
+ mocks.usePostEventComment.mockReturnValue({
+ mutate: vi.fn(),
+ isPending: false
+ })
+ mocks.useComment.mockReturnValue({ data: undefined })
+ mocks.useDeleteComment.mockReturnValue({ mutate: vi.fn() })
+ mocks.useReactToComment.mockReturnValue({ mutate: vi.fn() })
+ mocks.useUser.mockReturnValue({ data: undefined })
+ mocks.useRequiresAccountCallback.mockImplementation(
+ (callback: (...args: any[]) => any) =>
+ (...args: any[]) => {
+ mocks.requiresAccount()
+ // eslint-disable-next-line n/no-callback-literal
+ return callback(...args)
+ }
+ )
+ })
+
+ it('login-gates the comments composer for signed-out viewers', () => {
+ render(
+
+ )
+
+ const input = screen.getByRole('textbox', { name: /add a comment/i })
+ expect(input).toHaveAttribute('readonly')
+ expect(screen.queryByText(/sign in to comment/i)).not.toBeInTheDocument()
+ expect(mocks.requiresAccount).not.toHaveBeenCalled()
+
+ fireEvent.click(input)
+
+ expect(mocks.requiresAccount).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/packages/web/src/pages/contest-page/components/ContestCommentsTile.tsx b/packages/web/src/pages/contest-page/components/ContestCommentsTile.tsx
index ffdd991132b..f41da6aabe7 100644
--- a/packages/web/src/pages/contest-page/components/ContestCommentsTile.tsx
+++ b/packages/web/src/pages/contest-page/components/ContestCommentsTile.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useState } from 'react'
+import { useMemo, useState } from 'react'
import {
getCommentQueryKey,
@@ -36,6 +36,7 @@ import { ComposerInput } from 'components/composer-input/ComposerInput'
import { UserLink } from 'components/link/UserLink'
import { VideoEmbed } from 'components/video-embed/VideoEmbed'
import { useProfilePicture } from 'hooks/useProfilePicture'
+import { useRequiresAccountCallback } from 'hooks/useRequiresAccount'
import { Timestamp } from '../../../components/comments/Timestamp'
import { AttachVideoModal } from '../../fan-club-detail-page/components/AttachVideoModal'
@@ -66,8 +67,8 @@ const messages = {
* - `updates` renders only host-authored top-level posts. Composer shown
* only to the host; composer exposes an Attach Video affordance.
* - `comments` renders everything that *isn't* a host post-update
- * (community comments + replies). Composer shown to every signed-in
- * user. No video attach — that's host-only.
+ * (community comments + replies). Composer shown to public viewers and
+ * login-gated for signed-out users. No video attach — that's host-only.
*/
export type ContestCommentsMode = 'updates' | 'comments'
@@ -127,8 +128,10 @@ export const ContestCommentsTile = ({
const { data: currentUserId } = useCurrentUserId()
const isEventOwner =
currentUserId !== null &&
+ currentUserId !== undefined &&
eventOwnerUserId !== undefined &&
currentUserId === eventOwnerUserId
+ const isLoggedIn = currentUserId !== null && currentUserId !== undefined
// Sort toggle lives on the Comments panel only. Updates is host-curated
// and always pinned to newest-first.
@@ -151,12 +154,11 @@ export const ContestCommentsTile = ({
mode === 'updates' ? messages.updatesHeading : messages.commentsHeading
// In `comments` mode the host should NOT see the top-level composer —
// they participate via replies (and via the dedicated POST UPDATE
- // composer for announcements). Viewers see the top-level composer.
+ // composer for announcements). Public viewers see the top-level composer;
+ // signed-out viewers get the same account gate as Enter Contest.
// In `updates` mode only the host can compose top-level posts.
const showComposer =
- !hideComposer &&
- currentUserId !== null &&
- (mode === 'comments' ? !isEventOwner : isEventOwner)
+ !hideComposer && (mode === 'comments' ? !isEventOwner : isEventOwner)
// When `hideComposer` is set, the caller is rendering a feed-only
// tile alongside a separate composer (e.g. desktop details), so the
// "sign in to comment" stub would be a redundant CTA. Track separately
@@ -176,7 +178,15 @@ export const ContestCommentsTile = ({
// track page uses the same pattern.
const [messageId, setMessageId] = useState(0)
- const handleComposerSubmit = useCallback(
+ const handleComposerClick = useRequiresAccountCallback(
+ () => {},
+ [],
+ undefined,
+ undefined,
+ 'account'
+ )
+
+ const handleComposerSubmit = useRequiresAccountCallback(
(value: string) => {
const body = value.trim()
if (!body || !currentUserId) return
@@ -268,13 +278,15 @@ export const ContestCommentsTile = ({
{showComposer ? (
-
+ {isLoggedIn ? (
+
+ ) : null}
{/* ComposerInput renders its own send affordance + Enter
submit, so no external send button is needed. Matches
@@ -286,8 +298,10 @@ export const ContestCommentsTile = ({
placeholder={composerPlaceholder}
maxLength={400}
maxMentions={10}
+ onClick={handleComposerClick}
onSubmit={(value) => handleComposerSubmit(value)}
disabled={isPosting}
+ readOnly={!isLoggedIn}
blurOnSubmit
/>