Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0cc634b
feat(web): collaborative tracks — comma-separated artist line (behind…
raymondjacobson Jun 9, 2026
7f973b4
feat(mobile): collaborative tracks — comma-separated artist line (beh…
raymondjacobson Jun 9, 2026
705fb7e
feat(apps): SDK + hooks for collaborator invite/accept/leave
raymondjacobson Jun 9, 2026
7631359
feat(web): collaborator tagging field on track upload/edit (behind flag)
raymondjacobson Jun 9, 2026
636680a
feat(apps): collaborator invite notifications + accept/decline + leav…
raymondjacobson Jun 9, 2026
91b46aa
feat(mobile): collaborator upload tagging + leave action (behind flag)
raymondjacobson Jun 9, 2026
4634b77
fix(apps): CI — add collaborator notification types to SDK + import o…
raymondjacobson Jun 9, 2026
03bf756
feat(apps): enable collaborative tracks by default + fix accept on pr…
raymondjacobson Jun 9, 2026
a502497
fix(apps): preserve track collaborators through SDK deserialization
raymondjacobson Jun 9, 2026
a2fb387
chore(sdk): regenerate Track/SearchTrack from deployed spec
raymondjacobson Jun 9, 2026
fe8cbd3
fix(apps): preserve pending collaborator invites when editing a track
raymondjacobson Jun 10, 2026
9cfd5b9
feat(web): collaborators as its own settings box with the redesigned …
raymondjacobson Jun 11, 2026
a42c3a6
style(web): wrap PopupMenu items array to satisfy prettier
raymondjacobson Jun 11, 2026
87fd3a7
copy(web): shorten collaborators box description
raymondjacobson Jun 11, 2026
75f3eda
fix(apps): tighten track collaborator flows
raymondjacobson Jun 16, 2026
aaf5be5
fix(apps): refresh collaborator invite status
raymondjacobson Jun 16, 2026
49dbb3a
fix(apps): polish mobile collaborator bylines
raymondjacobson Jun 16, 2026
7b7e639
fix(web): show artist card profile cover fallback
raymondjacobson Jun 16, 2026
0a970d9
fix(web): keep artist hover names visible
raymondjacobson Jun 16, 2026
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
20 changes: 20 additions & 0 deletions packages/common/src/adapters/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,26 @@ export const notificationFromSDK = (
...formatBaseNotification(notification)
}
}
case 'track_collaborator_invite': {
const data = notification.actions[0].data

return {
type: NotificationType.TrackCollaboratorInvite,
trackId: HashId.parse(data.trackId)!,
inviterUserId: HashId.parse(data.inviterUserId)!,
...formatBaseNotification(notification)
}
}
case 'track_collaborator_accept': {
const data = notification.actions[0].data

return {
type: NotificationType.TrackCollaboratorAccept,
trackId: HashId.parse(data.trackId)!,
collaboratorUserId: HashId.parse(data.collaboratorUserId)!,
...formatBaseNotification(notification)
}
}
case 'comment': {
let entityId = 0
let entityType = Entity.Track
Expand Down
86 changes: 84 additions & 2 deletions packages/common/src/adapters/track.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { Genre } from '@audius/sdk'
import { Genre, Id, type Track as SdkTrack, type User } from '@audius/sdk'
import { describe, expect, it } from 'vitest'

import type { UserMetadata } from '~/models'
import type { TrackMetadataForUpload } from '~/store/upload/types'

import { trackMetadataForUploadToSdk } from './track'
import {
getTrackCollaboratorsForEdit,
trackMetadataForUploadToSdk,
userTrackMetadataFromSDK
} from './track'

const makeMetadata = (
overrides: Partial<TrackMetadataForUpload> = {}
Expand All @@ -14,6 +19,73 @@ const makeMetadata = (
...overrides
}) as TrackMetadataForUpload

const makeUserMetadata = (id: number): UserMetadata =>
({
user_id: id,
handle: `user-${id}`,
name: `User ${id}`
}) as UserMetadata

const makeSdkUser = (id: number): User =>
({
id: Id.parse(id),
handle: `user-${id}`,
name: `User ${id}`
}) as User

const makeSdkTrack = (overrides: Partial<SdkTrack> = {}): SdkTrack =>
({
id: Id.parse(1),
userId: Id.parse(1),
user: makeSdkUser(1),
title: 'Test Track',
genre: Genre.Electronic,
remixOf: { tracks: [] },
fieldVisibility: {
mood: true,
tags: true,
genre: true,
share: true,
playCount: true,
remixes: true
},
trackSegments: [],
followeeFavorites: [],
followeeReposts: [],
favoriteCount: 0,
...overrides
}) as SdkTrack

describe('getTrackCollaboratorsForEdit', () => {
it('includes accepted collaborators and pending invites', () => {
const accepted = makeUserMetadata(2)
const pending = makeUserMetadata(3)

expect(
getTrackCollaboratorsForEdit({
collaborators: [accepted],
pending_collaborators: [pending]
})
).toEqual([accepted, pending])
})
})

describe('userTrackMetadataFromSDK', () => {
it('adapts accepted collaborators and pending collaborators separately', () => {
const result = userTrackMetadataFromSDK(
makeSdkTrack({
collaborators: [makeSdkUser(2)],
pendingCollaborators: [makeSdkUser(3)]
})
)

expect(result?.collaborators?.map((user) => user.user_id)).toEqual([2])
expect(result?.pending_collaborators?.map((user) => user.user_id)).toEqual([
3
])
})
})

describe('trackMetadataForUploadToSdk', () => {
it('forwards allowed_api_keys as allowedApiKeys', () => {
const result = trackMetadataForUploadToSdk(
Expand Down Expand Up @@ -52,4 +124,14 @@ describe('trackMetadataForUploadToSdk', () => {

expect(result.genre).toBe(Genre.Electronic)
})

it('maps selected collaborator users to user ids', () => {
const result = trackMetadataForUploadToSdk(
makeMetadata({
collaborators: [makeUserMetadata(2), makeUserMetadata(3)]
})
)

expect(result).toMatchObject({ collaborators: [2, 3] })
})
})
33 changes: 32 additions & 1 deletion packages/common/src/adapters/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ import {
StemCategory,
TrackSegment
} from '~/models'
import { StemTrackMetadata, UserTrackMetadata } from '~/models/Track'
import type {
StemTrackMetadata,
TrackMetadata,
UserTrackMetadata
} from '~/models/Track'
import type { TrackMetadataForUpload } from '~/store/upload/types'
import { formatMusicalKey, License, Maybe, squashNewLines } from '~/utils'
import dayjs from '~/utils/dayjs'
Expand Down Expand Up @@ -71,6 +75,18 @@ export const trackSegmentFromSDK = ({
multihash
})

type TrackCollaboratorMetadata = Pick<
TrackMetadata,
'collaborators' | 'pending_collaborators'
>

export const getTrackCollaboratorsForEdit = (
track?: Partial<TrackCollaboratorMetadata> | null
): NonNullable<TrackMetadata['collaborators']> => [
...(track?.collaborators ?? []),
...(track?.pending_collaborators ?? [])
]

export const userTrackMetadataFromSDK = (
input: Track | SearchTrack
): UserTrackMetadata | undefined => {
Expand Down Expand Up @@ -138,6 +154,16 @@ export const userTrackMetadataFromSDK = (
: null,
track_segments: input.trackSegments.map(trackSegmentFromSDK),
user,
// Accepted collaborator artists, decoded/cleaned like the owner.
collaborators: transformAndCleanList(
input.collaborators,
userMetadataFromSDK
),
// Pending invites (owner-only); lets the edit form preserve them on save.
pending_collaborators: transformAndCleanList(
input.pendingCollaborators,
userMetadataFromSDK
),

// Retypes
license: (input.license as License) ?? null,
Expand Down Expand Up @@ -331,6 +357,11 @@ export const trackMetadataForUploadToSdk = (
}))
}
: undefined,
// Collaborators are tagged as full user objects in the form; the on-chain
// metadata carries numeric user ids, which the ETL reconciles into invites.
collaborators: input.collaborators
? input.collaborators.map((collaborator) => collaborator.user_id)
: undefined,
stemOf: input.stem_of
? {
category: input.stem_of.category,
Expand Down
3 changes: 3 additions & 0 deletions packages/common/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ export * from './tan-query/tracks/useDeleteTrack'
export * from './tan-query/tracks/useDownloadTrackStems'
export * from './tan-query/tracks/useTrackDownloadCounts'
export * from './tan-query/tracks/useFavoriteTrack'
export * from './tan-query/tracks/useAcceptTrackCollaboration'
export * from './tan-query/tracks/useTrackCollaborationStatus'
export * from './tan-query/tracks/useRejectTrackCollaboration'
export * from './tan-query/tracks/useToggleFavoriteTrack'
export * from './tan-query/tracks/useTrack'
export * from './tan-query/tracks/useTrackByParams'
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/api/tan-query/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const QUERY_KEYS = {
trackCommentCount: 'trackCommentCount',
trackDownloadCounts: 'trackDownloadCounts',
track: 'track',
trackCollaborationStatus: 'trackCollaborationStatus',
tracksByUser: 'tracksByUser',
tracksByHandle: 'tracksByHandle',
trackByPermalink: 'trackByPermalink',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Id } from '@audius/sdk'
import { useMutation, useQueryClient } from '@tanstack/react-query'

import { useQueryContext } from '~/api/tan-query/utils'
import { ID } from '~/models/Identifiers'

import { useCurrentUserId } from '../users/account/useCurrentUserId'

import { getTrackQueryKey } from './useTrack'

type AcceptTrackCollaborationArgs = {
trackId: ID
}

/**
* Accept a pending collaborator invite on a track. The current user is the
* invited collaborator; once accepted the track surfaces on their profile.
*/
export const useAcceptTrackCollaboration = () => {
const { audiusSdk } = useQueryContext()
const queryClient = useQueryClient()
const { data: currentUserId } = useCurrentUserId()

return useMutation({
mutationFn: async ({ trackId }: AcceptTrackCollaborationArgs) => {
if (!currentUserId) throw new Error('User ID is required')
const sdk = await audiusSdk()
await sdk.tracks.acceptTrackCollaboration({
userId: Id.parse(currentUserId),
trackId: Id.parse(trackId)
})
return { trackId }
},
onSuccess: ({ trackId }) => {
queryClient.invalidateQueries({ queryKey: getTrackQueryKey(trackId) })
}
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Id } from '@audius/sdk'
import { useMutation, useQueryClient } from '@tanstack/react-query'

import { useQueryContext } from '~/api/tan-query/utils'
import { ID } from '~/models/Identifiers'

import { useCurrentUserId } from '../users/account/useCurrentUserId'

import { getTrackQueryKey } from './useTrack'

type RejectTrackCollaborationArgs = {
trackId: ID
}

/**
* Decline a pending collaborator invite, or leave a track you've already
* accepted (remove yourself as a collaborator). Both map to the same on-chain
* Reject action, signed by the current user.
*/
export const useRejectTrackCollaboration = () => {
const { audiusSdk } = useQueryContext()
const queryClient = useQueryClient()
const { data: currentUserId } = useCurrentUserId()

return useMutation({
mutationFn: async ({ trackId }: RejectTrackCollaborationArgs) => {
if (!currentUserId) throw new Error('User ID is required')
const sdk = await audiusSdk()
await sdk.tracks.rejectTrackCollaboration({
userId: Id.parse(currentUserId),
trackId: Id.parse(trackId)
})
return { trackId }
},
onSuccess: ({ trackId }) => {
queryClient.invalidateQueries({ queryKey: getTrackQueryKey(trackId) })
}
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Id } from '@audius/sdk'
import { useQuery } from '@tanstack/react-query'

import { userMetadataListFromSDK } from '~/adapters/user'
import { useQueryContext } from '~/api/tan-query/utils'
import { ID } from '~/models/Identifiers'

import { QUERY_KEYS } from '../queryKeys'

export const getTrackCollaborationStatusQueryKey = (
trackId: ID | null | undefined,
userId: ID | null | undefined
) => [QUERY_KEYS.trackCollaborationStatus, trackId, userId]

export const useTrackCollaborationStatus = (
trackId: ID | null | undefined,
userId: ID | null | undefined
) => {
const { audiusSdk } = useQueryContext()

return useQuery({
queryKey: getTrackCollaborationStatusQueryKey(trackId, userId),
queryFn: async () => {
if (!trackId || !userId) return false

const sdk = await audiusSdk()
const { data } = await sdk.tracks.getBulkTracks({
id: [Id.parse(trackId)],
userId: Id.parse(userId)
})
const track = data?.[0]
const collaborators = userMetadataListFromSDK(track?.collaborators)
return collaborators.some(
(collaborator) => collaborator.user_id === userId
)
},
enabled: !!trackId && !!userId
})
}
1 change: 1 addition & 0 deletions packages/common/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export * from './useGetDiscordOAuthLink'
export * from './useAccountSwitcher'
export * from './useAccountHasClaimableRewards'
export * from './useAccessAndRemixSettings'
export * from './useAcceptedTrackCollaborationInvite'
export * from './purchaseContent'
export * from './content'
export * from './chats'
Expand Down
60 changes: 60 additions & 0 deletions packages/common/src/hooks/useAcceptedTrackCollaborationInvite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useCallback, useEffect, useMemo, useState } from 'react'

import { useAppContext } from '~/context'
import { ID } from '~/models/Identifiers'
import {
getAcceptedTrackCollaborationStorageKey,
isAcceptedTrackCollaborationStorageValue
} from '~/utils/trackCollaboration'

export const useAcceptedTrackCollaborationInvite = (
userId: ID | null | undefined,
trackId: ID | null | undefined
) => {
const { localStorage } = useAppContext()
const storageKey = useMemo(
() =>
userId && trackId
? getAcceptedTrackCollaborationStorageKey(userId, trackId)
: null,
[trackId, userId]
)
const [isMarkedAccepted, setIsMarkedAccepted] = useState(() =>
storageKey
? isAcceptedTrackCollaborationStorageValue(
localStorage.getItemSync(storageKey)
)
: false
)

useEffect(() => {
let isActive = true

if (!storageKey) {
setIsMarkedAccepted(false)
return
}

const readAccepted = async () => {
const value = await localStorage.getItem(storageKey)
if (isActive) {
setIsMarkedAccepted(isAcceptedTrackCollaborationStorageValue(value))
}
}

readAccepted()

return () => {
isActive = false
}
}, [localStorage, storageKey])

const markAccepted = useCallback(() => {
if (!storageKey) return

setIsMarkedAccepted(true)
localStorage.setItem(storageKey, 'true')
}, [localStorage, storageKey])

return { isMarkedAccepted, markAccepted }
}
Loading
Loading