diff --git a/packages/common/src/api/tan-query/coins/useUpdateFanClub.ts b/packages/common/src/api/tan-query/coins/useUpdateFanClub.ts index 1ef94179f80..05cc6193194 100644 --- a/packages/common/src/api/tan-query/coins/useUpdateFanClub.ts +++ b/packages/common/src/api/tan-query/coins/useUpdateFanClub.ts @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { Coin } from '~/adapters/coin' import { useCurrentUserId } from '~/api' -import { useQueryContext } from '~/api/tan-query/utils' +import { uploadBackdropImage, useQueryContext } from '~/api/tan-query/utils' import { QUERY_KEYS } from '../queryKeys' @@ -41,29 +41,10 @@ export const useUpdateFanClub = () => { let bannerImageUrl: string | undefined if (updateCoinRequest.bannerImageFile) { - const uploadResponse = await sdk.services.storage - .uploadFile({ - file: updateCoinRequest.bannerImageFile, - metadata: { - template: 'img_backdrop' - } - }) - .start() - const cid = getBannerImageUrl(uploadResponse.results) - if (!cid) { - throw new Error('Failed to process banner image upload') - } - - // Convert CID to content node URL (same pattern as logo_uri from relay service) - const contentNodeEndpoint = await ( - sdk.services.storage as any - ).storageNodeSelector?.getSelectedNode() - - if (!contentNodeEndpoint) { - throw new Error('No content node available') - } - - bannerImageUrl = `${contentNodeEndpoint}/content/${cid}` + bannerImageUrl = await uploadBackdropImage( + sdk, + updateCoinRequest.bannerImageFile + ) } else if (updateCoinRequest.removeBanner) { bannerImageUrl = '' } @@ -125,14 +106,3 @@ export const useUpdateFanClub = () => { } }) } - -const getBannerImageUrl = (results: Record = {}) => { - const prioritizedSizes = ['2000x', '1500x', '1280x', '1000x', '640x'] - for (const size of prioritizedSizes) { - if (results[size]) { - return results[size] - } - } - const firstResult = Object.values(results)[0] - return firstResult -} diff --git a/packages/common/src/api/tan-query/comments/useMuteUser.ts b/packages/common/src/api/tan-query/comments/useMuteUser.ts index 44ab55f762a..1fb34768802 100644 --- a/packages/common/src/api/tan-query/comments/useMuteUser.ts +++ b/packages/common/src/api/tan-query/comments/useMuteUser.ts @@ -99,14 +99,20 @@ export const useMuteUser = () => { // Generic toast error dispatch(toast({ content: messages.muteUserError })) + if (trackId === undefined) return // No way to know what comment count should be here, so we just reset the query data queryClient.resetQueries({ - queryKey: ['trackCommentCount', trackId] + queryKey: getTrackCommentCountQueryKey(trackId) }) // Reload data - queryClient.resetQueries({ - queryKey: ['trackCommentList', trackId, currentSort] - }) + if (currentSort !== undefined) { + queryClient.resetQueries({ + queryKey: getTrackCommentListQueryKey({ + trackId, + sortMethod: currentSort + }) + }) + } } }) } diff --git a/packages/common/src/api/tan-query/notifications/useMarkNotificationsAsViewed.ts b/packages/common/src/api/tan-query/notifications/useMarkNotificationsAsViewed.ts index 745e85458f2..27f118e70e0 100644 --- a/packages/common/src/api/tan-query/notifications/useMarkNotificationsAsViewed.ts +++ b/packages/common/src/api/tan-query/notifications/useMarkNotificationsAsViewed.ts @@ -29,11 +29,20 @@ export const useMarkNotificationsAsViewed = () => { }) }, onMutate: () => { + const queryKey = getNotificationUnreadCountQueryKey(currentUserId) + const previousUnreadCount = queryClient.getQueryData(queryKey) // Optimistically set unread count to 0 - queryClient.setQueryData( - getNotificationUnreadCountQueryKey(currentUserId), - 0 - ) + queryClient.setQueryData(queryKey, 0) + return { previousUnreadCount } + }, + onError: (_error, _variables, context) => { + // Roll back the optimistic update so the unread badge isn't lost + if (context?.previousUnreadCount !== undefined) { + queryClient.setQueryData( + getNotificationUnreadCountQueryKey(currentUserId), + context.previousUnreadCount + ) + } } }) } diff --git a/packages/common/src/api/tan-query/utils/__tests__/uploadBackdropImage.test.ts b/packages/common/src/api/tan-query/utils/__tests__/uploadBackdropImage.test.ts new file mode 100644 index 00000000000..a148b41d922 --- /dev/null +++ b/packages/common/src/api/tan-query/utils/__tests__/uploadBackdropImage.test.ts @@ -0,0 +1,60 @@ +import type { AudiusSdkWithServices } from '@audius/sdk' +import { describe, it, expect, vi } from 'vitest' + +import { uploadBackdropImage } from '../uploadBackdropImage' + +const makeSdk = ({ + results, + node = 'https://validator.example.com' +}: { + results: Record + node?: string | null +}) => { + const uploadFile = vi.fn(() => ({ + start: vi.fn(async () => ({ results })) + })) + const sdk = { + services: { + storage: { uploadFile }, + storageNodeSelector: { getSelectedNode: vi.fn(async () => node) } + } + } as unknown as AudiusSdkWithServices + return { sdk, uploadFile } +} + +const file = new File(['x'], 'banner.png', { type: 'image/png' }) + +describe('uploadBackdropImage', () => { + it('uploads with the img_backdrop template and returns the largest variant URL', async () => { + const { sdk, uploadFile } = makeSdk({ + results: { '640x': 'cid640', '2000x': 'cid2000', '1000x': 'cid1000' } + }) + + const url = await uploadBackdropImage(sdk, file) + + expect(uploadFile).toHaveBeenCalledWith({ + file, + metadata: { template: 'img_backdrop' } + }) + expect(url).toBe('https://validator.example.com/content/cid2000') + }) + + it('falls back to any variant when no prioritized size is present', async () => { + const { sdk } = makeSdk({ results: { original: 'cidOriginal' } }) + await expect(uploadBackdropImage(sdk, file)).resolves.toBe( + 'https://validator.example.com/content/cidOriginal' + ) + }) + + it('throws when the upload returns no CID', async () => { + const { sdk } = makeSdk({ results: {} }) + await expect(uploadBackdropImage(sdk, file)).rejects.toThrow() + }) + + it('throws when no validator node is available', async () => { + const { sdk } = makeSdk({ results: { '2000x': 'cid' }, node: null }) + await expect(uploadBackdropImage(sdk, file)).rejects.toThrow( + 'No validator node available' + ) + }) +}) diff --git a/packages/common/src/api/tan-query/utils/index.ts b/packages/common/src/api/tan-query/utils/index.ts index b398e207c6b..fa5c4af0ad3 100644 --- a/packages/common/src/api/tan-query/utils/index.ts +++ b/packages/common/src/api/tan-query/utils/index.ts @@ -12,5 +12,6 @@ export * from './primeTrackData' export * from './primeUserData' export * from './QueryContext' export * from './updateTrackData' +export * from './uploadBackdropImage' export * from './updateCollectionData' export * from './useQueries' diff --git a/packages/common/src/api/tan-query/utils/uploadBackdropImage.ts b/packages/common/src/api/tan-query/utils/uploadBackdropImage.ts new file mode 100644 index 00000000000..97c4eebbac5 --- /dev/null +++ b/packages/common/src/api/tan-query/utils/uploadBackdropImage.ts @@ -0,0 +1,48 @@ +import type { AudiusSdkWithServices } from '@audius/sdk' + +const PRIORITIZED_BACKDROP_SIZES = ['2000x', '1500x', '1280x', '1000x', '640x'] + +/** + * Picks the CID of the largest available resized variant from an + * `img_backdrop` upload result, falling back to any variant. + */ +const getBackdropImageCid = (results: Record = {}) => { + for (const size of PRIORITIZED_BACKDROP_SIZES) { + if (results[size]) { + return results[size] + } + } + return Object.values(results)[0] +} + +/** + * Uploads a wide-aspect image (e.g. a fan club banner or contest cover) using + * the `img_backdrop` template and resolves to a URL on the selected validator + * node that serves the largest resized variant. + * + * Throws if the upload produces no CID or no validator node is available. + */ +export const uploadBackdropImage = async ( + sdk: AudiusSdkWithServices, + file: File +): Promise => { + const uploadResponse = await sdk.services.storage + .uploadFile({ + file, + metadata: { template: 'img_backdrop' } + }) + .start() + + const cid = getBackdropImageCid(uploadResponse.results) + if (!cid) { + throw new Error('Failed to process backdrop image upload') + } + + const validatorNodeEndpoint = + await sdk.services.storageNodeSelector.getSelectedNode() + if (!validatorNodeEndpoint) { + throw new Error('No validator node available') + } + + return `${validatorNodeEndpoint}/content/${cid}` +} diff --git a/packages/mobile/src/hooks/useEnterContest.ts b/packages/mobile/src/hooks/useEnterContest.ts index fb1df1cba70..f57fed6c4d5 100644 --- a/packages/mobile/src/hooks/useEnterContest.ts +++ b/packages/mobile/src/hooks/useEnterContest.ts @@ -22,16 +22,21 @@ export const useEnterContest = (trackId: ID | undefined) => { let artwork: { url: string; file: any } | undefined const imageUrl = originalTrack?.artwork?.[SquareSizes.SIZE_480_BY_480] ?? '' if (imageUrl) { - const response = await fetch(imageUrl) - const blob = await response.blob() - const file = new File([blob], 'image.jpg', { type: blob.type }) - artwork = { - url: imageUrl, - file: { - // @ts-ignore: KJ - _data is on the file for some reason - ...file._data, - uri: imageUrl + try { + const response = await fetch(imageUrl) + const blob = await response.blob() + const file = new File([blob], 'image.jpg', { type: blob.type }) + artwork = { + url: imageUrl, + file: { + // @ts-ignore: KJ - _data is on the file for some reason + ...file._data, + uri: imageUrl + } } + } catch (e) { + // Artwork is only a pre-fill, so continue to upload without it + console.warn('Failed to pre-fill contest artwork', e) } } diff --git a/packages/web/src/hooks/useLaunchCoin.ts b/packages/web/src/hooks/useLaunchCoin.ts index 63cf9bcc5f4..86332aecbc4 100644 --- a/packages/web/src/hooks/useLaunchCoin.ts +++ b/packages/web/src/hooks/useLaunchCoin.ts @@ -4,6 +4,7 @@ import { getConnectedWalletsQueryOptions, getCurrentAccountQueryKey, getUserQueryKey, + uploadBackdropImage, useQueryContext } from '@audius/common/api' import { @@ -184,29 +185,10 @@ export const useLaunchCoin = () => { let resolvedBannerImageUrl = bannerImageUrl if (bannerImageFile) { - const uploadResponse = await sdk.services.storage - .uploadFile({ - file: bannerImageFile, - metadata: { - template: 'img_backdrop' - } - }) - .start() - const cid = getBannerImageUrl(uploadResponse.results) - if (!cid) { - throw new Error('Failed to process banner image upload') - } - - // Convert CID to content node URL - const contentNodeEndpoint = await ( - sdk.services.storage as any - ).storageNodeSelector?.getSelectedNode() - - if (!contentNodeEndpoint) { - throw new Error('No content node available') - } - - resolvedBannerImageUrl = `${contentNodeEndpoint}/content/${cid}` + resolvedBannerImageUrl = await uploadBackdropImage( + sdk, + bannerImageFile + ) } // Create coin in Audius database @@ -282,14 +264,3 @@ export const useLaunchCoin = () => { } }) } - -const getBannerImageUrl = (results: Record = {}) => { - const prioritizedSizes = ['2000x', '1500x', '1280x', '1000x', '640x'] - for (const size of prioritizedSizes) { - if (results[size]) { - return results[size] - } - } - const firstResult = Object.values(results)[0] - return firstResult -} diff --git a/packages/web/src/pages/host-remix-contest-page/useUploadContestCover.ts b/packages/web/src/pages/host-remix-contest-page/useUploadContestCover.ts index 29cec4a9b20..fceb314d339 100644 --- a/packages/web/src/pages/host-remix-contest-page/useUploadContestCover.ts +++ b/packages/web/src/pages/host-remix-contest-page/useUploadContestCover.ts @@ -1,12 +1,12 @@ import { useCallback, useState } from 'react' -import { useQueryContext } from '@audius/common/api' +import { uploadBackdropImage, useQueryContext } from '@audius/common/api' /** - * Handles mediorum upload of a contest cover photo. Mirrors the pattern - * used by useLaunchCoin / useUpdateFanClub for banner images — uploads the - * file with `img_backdrop` template (wide aspect-ratio) and resolves to a - * content-node URL the form can persist in `eventData.coverPhotoUrl`. + * Handles upload of a contest cover photo. Uses the shared + * `uploadBackdropImage` helper (also used for fan club banners), which uploads + * the file with the `img_backdrop` template (wide aspect-ratio) and resolves + * to a validator node URL the form can persist in `eventData.coverPhotoUrl`. */ export const useUploadContestCover = () => { const { audiusSdk } = useQueryContext() @@ -17,33 +17,7 @@ export const useUploadContestCover = () => { setIsUploading(true) try { const sdk = await audiusSdk() - const uploadResponse = await sdk.services.storage - .uploadFile({ - file, - metadata: { template: 'img_backdrop' } - }) - .start() - - const results = (uploadResponse.results ?? {}) as Record - const prioritizedSizes = ['2000x', '1500x', '1280x', '1000x', '640x'] - let cid: string | undefined - for (const size of prioritizedSizes) { - if (results[size]) { - cid = results[size] - break - } - } - if (!cid) cid = Object.values(results)[0] - if (!cid) throw new Error('Upload returned no CID') - - const contentNodeEndpoint = await ( - sdk.services.storage as any - ).storageNodeSelector?.getSelectedNode() - if (!contentNodeEndpoint) { - throw new Error('No content node available') - } - - return `${contentNodeEndpoint}/content/${cid}` + return await uploadBackdropImage(sdk, file) } catch (error) { console.error('HostRemixContest', error as Error) return null diff --git a/packages/web/src/pages/pay-and-earn-page/components/WithdrawalsTab.tsx b/packages/web/src/pages/pay-and-earn-page/components/WithdrawalsTab.tsx index 75d62648427..ce205d1239f 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/WithdrawalsTab.tsx +++ b/packages/web/src/pages/pay-and-earn-page/components/WithdrawalsTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useCurrentUserId, @@ -128,46 +128,44 @@ const useWithdrawalTransactionPoller = ( method: GetUSDCTransactionsMethodEnum.Send }) + // Read via refs so the poller only restarts when the awaited transaction + // changes. `reset` is a new function every render. + const transactionsRef = useRef(transactions) + transactionsRef.current = transactions + const resetRef = useRef(reset) + resetRef.current = reset + useEffect(() => { if (!lastCompletedTransaction || !userId) return - setIsPolling(true) - let timedOut = false - const timerId = setTimeout(() => { - timedOut = true - setIsPolling(false) - }, 60000) - - const pollInterval = setInterval(async () => { - // Check if transaction exists in current data - const found = transactions?.some( + const isFound = () => + !!transactionsRef.current?.some( (t) => t.signature === lastCompletedTransaction ) + if (isFound()) return - if (found) { - clearInterval(pollInterval) - clearTimeout(timerId) - setIsPolling(false) - return - } + setIsPolling(true) + + const stopPolling = () => { + clearInterval(pollInterval) + clearTimeout(timerId) + setIsPolling(false) + } - // If we've timed out, stop polling - if (timedOut) { - clearInterval(pollInterval) + // Stop polling after 60s even if the transaction never shows up + const timerId = setTimeout(stopPolling, 60000) + + const pollInterval = setInterval(() => { + if (isFound()) { + stopPolling() return } - // Refetch transactions - await reset() + resetRef.current() }, 1000) - // Cleanup - return () => { - clearInterval(pollInterval) - clearTimeout(timerId) - setIsPolling(false) - } - }, [lastCompletedTransaction, userId, transactions, reset]) + return stopPolling + }, [lastCompletedTransaction, userId]) return { isPolling,