Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/common/src/api/tan-query/comments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ export * from './useUpdateCommentNotificationSetting'
export * from './useFanClubFeed'
export * from './usePostTextUpdate'
export * from './useDeleteTextPost'
export * from './useEventComments'
export * from './usePostEventComment'
118 changes: 118 additions & 0 deletions packages/common/src/api/tan-query/comments/useEventComments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useEffect } from 'react'

import { encodeHashId, OptionalId } from '@audius/sdk'
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
import { useDispatch } from 'react-redux'

import { commentFromSDK } from '~/adapters'
import { useQueryContext } from '~/api/tan-query/utils'
import { Feature, ID } from '~/models'
import { toast } from '~/store/ui/toast/slice'

import { QUERY_KEYS } from '../queryKeys'
import { QueryKey } from '../types'
import { useCurrentUserId } from '../users/account/useCurrentUserId'
import { primeCommentData } from '../utils/primeCommentData'
import { primeRelatedData } from '../utils/primeRelatedData'

const EVENT_COMMENTS_PAGE_SIZE = 20

export type EventCommentsFeedItem = { commentId: ID }

export const getEventCommentsQueryKey = ({
eventId,
sortMethod
}: {
eventId: ID | null | undefined
sortMethod?: string
}) => {
return [
QUERY_KEYS.eventComments,
eventId,
{ sortMethod }
] as unknown as QueryKey<EventCommentsFeedItem[]>
}

type UseEventCommentsArgs = {
eventId: ID | null | undefined
sortMethod?: 'top' | 'newest' | 'timestamp'
pageSize?: number
enabled?: boolean
}

/**
* Fetch the top-level comment stream for a remix-contest event. Replies come
* back nested inside each comment (matching the track-comment shape), so the
* same CommentBlock components that render track comments can render these.
*
* The distinction between "post update" and "normal comment" is resolved at
* render time by comparing `comment.userId === eventOwnerUserId`.
*/
export const useEventComments = ({
eventId,
sortMethod = 'newest',
pageSize = EVENT_COMMENTS_PAGE_SIZE,
enabled = true
}: UseEventCommentsArgs) => {
const { audiusSdk, reportToSentry } = useQueryContext()
const queryClient = useQueryClient()
const dispatch = useDispatch()
const { data: currentUserId } = useCurrentUserId()

const queryRes = useInfiniteQuery({
initialPageParam: 0,
getNextPageParam: (lastPage: EventCommentsFeedItem[], pages) => {
if (!lastPage || lastPage.length < pageSize) return undefined
return (pages.length ?? 0) * pageSize
},
queryKey: getEventCommentsQueryKey({ eventId, sortMethod }),
queryFn: async ({ pageParam }): Promise<EventCommentsFeedItem[]> => {
if (!eventId) return []
const sdk = await audiusSdk()
// Event comments live on the events API (see swagger
// /events/{eventId}/comments). Response reuses the track_comments_response
// schema so the parsing path is identical to track / fan-club comments.
const response = await sdk.events.getEventComments({
eventId: encodeHashId(eventId)!,
userId: OptionalId.parse(currentUserId ?? undefined),
offset: pageParam,
limit: pageSize,
sortMethod
})

primeRelatedData({ related: response.related, queryClient })

const items: EventCommentsFeedItem[] = []
for (const raw of response.data ?? []) {
const comment = commentFromSDK(raw)
if (comment) {
primeCommentData({ comments: [comment], queryClient })
items.push({ commentId: comment.id })
}
}
return items
},
select: (data) => data.pages.flat(),
enabled: enabled && !!eventId
})

const { error } = queryRes

useEffect(() => {
if (error) {
reportToSentry({
error,
name: 'Comments',
feature: Feature.Comments
})
dispatch(
toast({
content:
'There was an error loading the contest feed. Please try again.'
})
)
}
}, [error, dispatch, reportToSentry])

return queryRes
}
115 changes: 115 additions & 0 deletions packages/common/src/api/tan-query/comments/usePostEventComment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Id } from '@audius/sdk'
import { useMutation, useQueryClient } from '@tanstack/react-query'

import { useQueryContext } from '~/api/tan-query/utils'
import { Comment, Feature, ID } from '~/models'
import { toast } from '~/store/ui/toast/slice'

import { getEventCommentsQueryKey } from './useEventComments'
import { getCommentQueryKey } from './utils'

export type PostEventCommentArgs = {
userId: ID
eventId: ID
body: string
parentCommentId?: ID
mentions?: ID[]
}

/**
* Post a comment on a remix-contest event. The same mutation serves both
* "post updates" (when the author is the event owner) and regular user
* comments — the indexer and UI both disambiguate by comparing user_id to
* the event's owner, so there's no client-side branching here.
*/
export const usePostEventComment = () => {
const { audiusSdk, reportToSentry } = useQueryContext()
const queryClient = useQueryClient()

return useMutation({
mutationFn: async (args: PostEventCommentArgs & { newId?: ID }) => {
const sdk = await audiusSdk()
return await sdk.comments.createComment({
userId: Id.parse(args.userId)!,
metadata: {
commentId: args.newId,
entityId: args.eventId,
entityType: 'Event',
body: args.body,
parentCommentId: args.parentCommentId,
mentions: args.mentions ?? []
} as any
})
},
onMutate: async (args: PostEventCommentArgs & { newId?: ID }) => {
const { userId, eventId, body } = args
const sdk = await audiusSdk()
const newId = await sdk.comments.generateCommentId()
args.newId = newId

const newComment: Comment = {
id: newId,
entityId: eventId,
entityType: 'Event',
userId,
message: body,
mentions: [],
isEdited: false,
trackTimestampS: undefined,
reactCount: 0,
replyCount: 0,
replies: undefined,
createdAt: new Date().toISOString(),
updatedAt: undefined,
isMembersOnly: false
} as unknown as Comment

// Prime the individual comment cache
queryClient.setQueryData(getCommentQueryKey(newId), newComment)

// Only optimistically push top-level comments to the feed; replies are
// nested inside their parent comment and come back on invalidation.
if (!args.parentCommentId) {
const feedQueryKey = getEventCommentsQueryKey({
eventId,
sortMethod: 'newest'
})
queryClient.setQueryData(feedQueryKey, (prevData: any) => {
if (!prevData) return prevData
const next = structuredClone(prevData)
if (next.pages?.[0]) {
next.pages[0].unshift({ commentId: newId })
}
return next
})
}

return { newId }
},
onSuccess: (_data, args) => {
queryClient.invalidateQueries({
queryKey: getEventCommentsQueryKey({
eventId: args.eventId,
sortMethod: 'newest'
})
})
},
onError: (error: Error, args) => {
reportToSentry({
error,
additionalInfo: args,
name: 'Comments',
feature: Feature.Comments
})
toast({
content: 'There was an error posting your comment. Please try again.'
})
queryClient.invalidateQueries({
queryKey: getEventCommentsQueryKey({
eventId: args.eventId,
sortMethod: 'newest'
})
})
}
})
}
9 changes: 9 additions & 0 deletions packages/common/src/api/tan-query/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@ export * from './useAllRemixContests'
export * from './useEvent'
export * from './useEvents'
export * from './useEventsByEntityId'
export * from './useFollowEvent'
export * from './useRemixContest'
export * from './useRemixContestWinners'

// Mutations
export * from './useCreateEvent'
export * from './useUpdateEvent'
export * from './useDeleteEvent'

// Query key helpers (needed by tests and for manual cache priming).
export {
getEventQueryKey,
getEventIdsByEntityIdQueryKey,
getEventListQueryKey
} from './utils'
export type { EventIdsByEntityIdOptions } from './utils'
137 changes: 137 additions & 0 deletions packages/common/src/api/tan-query/events/useFollowEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Id, encodeHashId } from '@audius/sdk'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'

import { useQueryContext } from '~/api/tan-query/utils'
import { Feature, ID } from '~/models'
import { toast } from '~/store/ui/toast/slice'

import { QUERY_KEYS } from '../queryKeys'
import { QueryKey } from '../types'

export type EventFollowState = {
isFollowed: boolean
followerCount: number
}

export const getEventFollowStateQueryKey = (eventId: ID | null | undefined) => {
return [
QUERY_KEYS.eventFollowState,
eventId
] as unknown as QueryKey<EventFollowState>
}

/**
* Read-hook: is the current user subscribed to this remix-contest event?
* Also returns the total follower count so the page can render both the
* button state and a "X following" number without a second call.
*
* Backed by GET /v1/events/:eventId/follow_state — a small ad-hoc endpoint
* we added specifically for this flow.
*/
export const useEventFollowState = (eventId: ID | null | undefined) => {
const { audiusSdk } = useQueryContext()

return useQuery({
queryKey: getEventFollowStateQueryKey(eventId),
enabled: !!eventId,
queryFn: async (): Promise<EventFollowState> => {
if (!eventId) {
return { isFollowed: false, followerCount: 0 }
}
const sdk = await audiusSdk()
// The generated response is already camelCased by the openapi
// generator (see EventFollowState.ts), so no snake_case adaptation
// needed here.
const response = await sdk.events.getEventFollowState({
eventId: encodeHashId(eventId)!
})
return {
isFollowed: !!response.data?.isFollowed,
followerCount: Number(response.data?.followerCount ?? 0)
}
}
})
}

export const useFollowEvent = () => {
const { audiusSdk, reportToSentry } = useQueryContext()
const queryClient = useQueryClient()

return useMutation({
mutationFn: async ({ userId, eventId }: { userId: ID; eventId: ID }) => {
const sdk = await audiusSdk()
return await sdk.events.followEvent({
userId: Id.parse(userId)!,
eventId: Id.parse(eventId)!
})
},
onMutate: async ({ eventId }) => {
// Optimistic: flip the button immediately.
const key = getEventFollowStateQueryKey(eventId)
const prev = queryClient.getQueryData<EventFollowState>(key)
queryClient.setQueryData(key, {
isFollowed: true,
followerCount: (prev?.followerCount ?? 0) + 1
})
return { prev }
},
onError: (error: Error, { eventId }, ctx) => {
queryClient.setQueryData(
getEventFollowStateQueryKey(eventId),
ctx?.prev ?? { isFollowed: false, followerCount: 0 }
)
reportToSentry({
error,
name: 'Events',
feature: Feature.Events
})
toast({ content: 'Could not follow contest. Please try again.' })
},
onSettled: (_data, _err, { eventId }) => {
queryClient.invalidateQueries({
queryKey: getEventFollowStateQueryKey(eventId)
})
}
})
}

export const useUnfollowEvent = () => {
const { audiusSdk, reportToSentry } = useQueryContext()
const queryClient = useQueryClient()

return useMutation({
mutationFn: async ({ userId, eventId }: { userId: ID; eventId: ID }) => {
const sdk = await audiusSdk()
return await sdk.events.unfollowEvent({
userId: Id.parse(userId)!,
eventId: Id.parse(eventId)!
})
},
onMutate: async ({ eventId }) => {
const key = getEventFollowStateQueryKey(eventId)
const prev = queryClient.getQueryData<EventFollowState>(key)
queryClient.setQueryData(key, {
isFollowed: false,
followerCount: Math.max(0, (prev?.followerCount ?? 1) - 1)
})
return { prev }
},
onError: (error: Error, { eventId }, ctx) => {
queryClient.setQueryData(
getEventFollowStateQueryKey(eventId),
ctx?.prev ?? { isFollowed: true, followerCount: 0 }
)
reportToSentry({
error,
name: 'Events',
feature: Feature.Events
})
toast({ content: 'Could not unfollow contest. Please try again.' })
},
onSettled: (_data, _err, { eventId }) => {
queryClient.invalidateQueries({
queryKey: getEventFollowStateQueryKey(eventId)
})
}
})
}
Loading
Loading