Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 }) => (
<span data-testid='user-link'>user-{userId}</span>
)
}))

vi.mock('components/composer-input/ComposerInput', () => ({
ComposerInput: ({
placeholder,
onClick,
readOnly
}: {
placeholder?: string
onClick?: () => void
readOnly?: boolean
}) => (
<textarea
aria-label={placeholder}
placeholder={placeholder}
readOnly={readOnly}
onClick={onClick}
/>
)
}))

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(
<ContestCommentsTile
eventId={EVENT_ID}
eventOwnerUserId={EVENT_OWNER_ID}
mode='comments'
/>
)

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)
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'

import {
getCommentQueryKey,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -268,13 +278,15 @@ export const ContestCommentsTile = ({
{showComposer ? (
<Flex direction='column' gap='m' w='100%'>
<Flex w='100%' gap='s' alignItems='center'>
<HarmonyAvatar
size='auto'
borderWidth='thin'
isLoading={false}
src={profileImage}
css={{ width: 32, height: 32, flexShrink: 0 }}
/>
{isLoggedIn ? (
<HarmonyAvatar
size='auto'
borderWidth='thin'
isLoading={false}
src={profileImage}
css={{ width: 32, height: 32, flexShrink: 0 }}
/>
) : null}
<Box css={{ flex: 1, minWidth: 0 }}>
{/* ComposerInput renders its own send affordance + Enter
submit, so no external send button is needed. Matches
Expand All @@ -286,8 +298,10 @@ export const ContestCommentsTile = ({
placeholder={composerPlaceholder}
maxLength={400}
maxMentions={10}
onClick={handleComposerClick}
onSubmit={(value) => handleComposerSubmit(value)}
disabled={isPosting}
readOnly={!isLoggedIn}
blurOnSubmit
/>
</Box>
Expand Down
Loading