diff --git a/packages/common/src/adapters/notification.ts b/packages/common/src/adapters/notification.ts
index 879d4cd2ecc..a3382baaf99 100644
--- a/packages/common/src/adapters/notification.ts
+++ b/packages/common/src/adapters/notification.ts
@@ -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
diff --git a/packages/common/src/adapters/track.ts b/packages/common/src/adapters/track.ts
index 16ee8edb846..ae1ba4e8429 100644
--- a/packages/common/src/adapters/track.ts
+++ b/packages/common/src/adapters/track.ts
@@ -135,6 +135,13 @@ 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,
@@ -328,6 +335,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,
diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts
index 74242741d43..77f624579ed 100644
--- a/packages/common/src/api/index.ts
+++ b/packages/common/src/api/index.ts
@@ -91,6 +91,8 @@ 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/useRejectTrackCollaboration'
export * from './tan-query/tracks/useToggleFavoriteTrack'
export * from './tan-query/tracks/useTrack'
export * from './tan-query/tracks/useTrackByParams'
diff --git a/packages/common/src/api/tan-query/tracks/useAcceptTrackCollaboration.ts b/packages/common/src/api/tan-query/tracks/useAcceptTrackCollaboration.ts
new file mode 100644
index 00000000000..f282974bf92
--- /dev/null
+++ b/packages/common/src/api/tan-query/tracks/useAcceptTrackCollaboration.ts
@@ -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) })
+ }
+ })
+}
diff --git a/packages/common/src/api/tan-query/tracks/useRejectTrackCollaboration.ts b/packages/common/src/api/tan-query/tracks/useRejectTrackCollaboration.ts
new file mode 100644
index 00000000000..eaa1d42108e
--- /dev/null
+++ b/packages/common/src/api/tan-query/tracks/useRejectTrackCollaboration.ts
@@ -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) })
+ }
+ })
+}
diff --git a/packages/common/src/models/Track.ts b/packages/common/src/models/Track.ts
index 77908d6e4b3..f2ca228d72a 100644
--- a/packages/common/src/models/Track.ts
+++ b/packages/common/src/models/Track.ts
@@ -263,6 +263,14 @@ export type TrackMetadata = {
playlist_name: string
permalink: string
}
+
+ // Accepted collaborator artists (collaborative tracks). Embedded by the API,
+ // same shape as `user` (the track owner). Empty when there are none.
+ collaborators?: UserMetadata[]
+
+ // Pending collaborator invites — only populated by the API on the owner's own
+ // tracks, so the edit form can preserve still-pending invites on save.
+ pending_collaborators?: UserMetadata[]
} & Timestamped
export type WriteableTrackMetadata = TrackMetadata & {
diff --git a/packages/common/src/schemas/upload/uploadFormSchema.ts b/packages/common/src/schemas/upload/uploadFormSchema.ts
index ef85f513bbb..a0eec26b8ed 100644
--- a/packages/common/src/schemas/upload/uploadFormSchema.ts
+++ b/packages/common/src/schemas/upload/uploadFormSchema.ts
@@ -153,6 +153,11 @@ const createSdkSchema = () =>
.object({
track_id: z.optional(z.number()).nullable(),
allowed_api_keys: z.optional(z.array(z.string())).nullable(),
+ // Tagged collaborator artists (full user objects in the form; the upload
+ // adapter maps them to numeric ids for the on-chain metadata).
+ collaborators: z.optional(
+ z.array(z.object({ user_id: z.number() }).passthrough()).nullable()
+ ),
description: z
.optional(z.string().max(MAX_DESCRIPTION_LENGTH))
.nullable(),
diff --git a/packages/common/src/store/notifications/types.ts b/packages/common/src/store/notifications/types.ts
index 96331615b45..bb254753f5f 100644
--- a/packages/common/src/store/notifications/types.ts
+++ b/packages/common/src/store/notifications/types.ts
@@ -39,6 +39,8 @@ export enum NotificationType {
USDCPurchaseBuyer = 'USDCPurchaseBuyer',
RequestManager = 'RequestManager',
ApproveManagerRequest = 'ApproveManagerRequest',
+ TrackCollaboratorInvite = 'TrackCollaboratorInvite',
+ TrackCollaboratorAccept = 'TrackCollaboratorAccept',
Comment = 'Comment',
CommentThread = 'CommentThread',
CommentMention = 'CommentMention',
@@ -85,6 +87,8 @@ export enum PushNotificationType {
MessageReaction = 'MessageReaction',
RequestManager = 'RequestManager',
ApproveManagerRequest = 'ApproveManagerRequest',
+ TrackCollaboratorInvite = 'TrackCollaboratorInvite',
+ TrackCollaboratorAccept = 'TrackCollaboratorAccept',
Comment = 'Comment',
CommentThread = 'CommentThread',
CommentMention = 'CommentMention',
@@ -548,6 +552,18 @@ export type ApproveManagerRequestNotification = BaseNotification & {
userId: ID
}
+export type TrackCollaboratorInviteNotification = BaseNotification & {
+ type: NotificationType.TrackCollaboratorInvite
+ trackId: ID
+ inviterUserId: ID
+}
+
+export type TrackCollaboratorAcceptNotification = BaseNotification & {
+ type: NotificationType.TrackCollaboratorAccept
+ trackId: ID
+ collaboratorUserId: ID
+}
+
export type CommentNotification = BaseNotification & {
type: NotificationType.Comment
entityId: ID
@@ -686,6 +702,8 @@ export type Notification =
| USDCPurchaseBuyerNotification
| RequestManagerNotification
| ApproveManagerRequestNotification
+ | TrackCollaboratorInviteNotification
+ | TrackCollaboratorAcceptNotification
| CommentNotification
| CommentThreadNotification
| CommentMentionNotification
diff --git a/packages/common/src/store/ui/mobile-overflow-menu/types.ts b/packages/common/src/store/ui/mobile-overflow-menu/types.ts
index 9e0d344d2c3..a37de421c3b 100644
--- a/packages/common/src/store/ui/mobile-overflow-menu/types.ts
+++ b/packages/common/src/store/ui/mobile-overflow-menu/types.ts
@@ -37,7 +37,8 @@ export enum OverflowAction {
PLAY_NEXT = 'PLAY_NEXT',
ADD_TO_QUEUE = 'ADD_TO_QUEUE',
PLAY_COLLECTION_NEXT = 'PLAY_COLLECTION_NEXT',
- ADD_COLLECTION_TO_QUEUE = 'ADD_COLLECTION_TO_QUEUE'
+ ADD_COLLECTION_TO_QUEUE = 'ADD_COLLECTION_TO_QUEUE',
+ LEAVE_TRACK_COLLABORATION = 'LEAVE_TRACK_COLLABORATION'
}
export enum OverflowSource {
diff --git a/packages/mobile/src/components/overflow-menu-drawer/OverflowMenuDrawer.tsx b/packages/mobile/src/components/overflow-menu-drawer/OverflowMenuDrawer.tsx
index 6c9cf689781..c38baf2e252 100644
--- a/packages/mobile/src/components/overflow-menu-drawer/OverflowMenuDrawer.tsx
+++ b/packages/mobile/src/components/overflow-menu-drawer/OverflowMenuDrawer.tsx
@@ -56,6 +56,10 @@ const overflowRowConfig = ({
[OverflowAction.EDIT_TRACK]: { text: 'Edit Track' },
[OverflowAction.RELEASE_NOW]: { text: 'Release Now' },
[OverflowAction.DELETE_TRACK]: { text: 'Delete Track', isDestructive: true },
+ [OverflowAction.LEAVE_TRACK_COLLABORATION]: {
+ text: 'Remove Me as Collaborator',
+ isDestructive: true
+ },
[OverflowAction.VIEW_EPISODE_PAGE]: { text: 'View Episode Page' },
[OverflowAction.MARK_AS_PLAYED]: { text: 'Mark as Played' },
[OverflowAction.MARK_AS_UNPLAYED]: { text: 'Mark as Unplayed' },
diff --git a/packages/mobile/src/components/overflow-menu-drawer/TrackOverflowMenuDrawer.tsx b/packages/mobile/src/components/overflow-menu-drawer/TrackOverflowMenuDrawer.tsx
index e68660aff54..6258a1ab859 100644
--- a/packages/mobile/src/components/overflow-menu-drawer/TrackOverflowMenuDrawer.tsx
+++ b/packages/mobile/src/components/overflow-menu-drawer/TrackOverflowMenuDrawer.tsx
@@ -4,6 +4,7 @@ import type React from 'react'
import {
useCollection,
useCurrentUserId,
+ useRejectTrackCollaboration,
useToggleFavoriteTrack,
useTrack,
useUser
@@ -63,7 +64,8 @@ const messages = {
markedAsPlayed: 'Marked as Played',
markedAsUnplayed: 'Marked as Unplayed',
willPlayNext: 'Will play next',
- addedToQueue: 'Added to queue'
+ addedToQueue: 'Added to queue',
+ removedCollaboration: 'Removed as collaborator'
}
const TrackOverflowMenuDrawer = ({ render }: Props) => {
@@ -96,6 +98,8 @@ const TrackOverflowMenuDrawer = ({ render }: Props) => {
source: FavoriteSource.OVERFLOW
})
+ const { mutate: rejectTrackCollaboration } = useRejectTrackCollaboration()
+
const handlePurchasePress = useCallback(() => {
if (track?.track_id) {
openPremiumContentPurchaseModal(
@@ -215,6 +219,10 @@ const TrackOverflowMenuDrawer = ({ render }: Props) => {
})
)
},
+ [OverflowAction.LEAVE_TRACK_COLLABORATION]: () => {
+ rejectTrackCollaboration({ trackId: id })
+ toast({ content: messages.removedCollaboration })
+ },
[OverflowAction.MARK_AS_PLAYED]: () => {
dispatch(
setTrackPosition({
diff --git a/packages/mobile/src/components/track-details-tile/TrackDetailsTile.tsx b/packages/mobile/src/components/track-details-tile/TrackDetailsTile.tsx
index 3b29162445b..84fa09d05fb 100644
--- a/packages/mobile/src/components/track-details-tile/TrackDetailsTile.tsx
+++ b/packages/mobile/src/components/track-details-tile/TrackDetailsTile.tsx
@@ -20,6 +20,7 @@ import {
IconUserFollowing
} from '@audius/harmony-native'
import { Text } from 'app/components/core'
+import { CollaboratorLinks } from 'app/components/user-link'
import { UserBadges } from 'app/components/user-badges'
import { useIsUSDCEnabled } from 'app/hooks/useIsUSDCEnabled'
import { makeStyles, flexRowCentered, typography } from 'app/styles'
@@ -168,6 +169,10 @@ export const TrackDetailsTile = ({
{owner.name}
+
{earnAmount ? (
diff --git a/packages/mobile/src/components/track/TrackCard.tsx b/packages/mobile/src/components/track/TrackCard.tsx
index 4f82034d818..ed726bf48c3 100644
--- a/packages/mobile/src/components/track/TrackCard.tsx
+++ b/packages/mobile/src/components/track/TrackCard.tsx
@@ -15,7 +15,7 @@ import {
Paper,
Text
} from '@audius/harmony-native'
-import { UserLink } from 'app/components/user-link'
+import { TrackArtists } from 'app/components/user-link'
import { useNavigation } from 'app/hooks/useNavigation'
import { LockedStatusBadge } from '../core'
@@ -46,6 +46,7 @@ export const TrackCard = (props: TrackCardProps) => {
track,
'title',
'owner_id',
+ 'collaborators',
'repost_count',
'save_count',
'is_unlisted',
@@ -58,6 +59,7 @@ export const TrackCard = (props: TrackCardProps) => {
const {
title,
owner_id,
+ collaborators,
repost_count,
save_count,
is_unlisted,
@@ -99,8 +101,9 @@ export const TrackCard = (props: TrackCardProps) => {
{title}
-
diff --git a/packages/mobile/src/components/user-link/TrackArtists.tsx b/packages/mobile/src/components/user-link/TrackArtists.tsx
new file mode 100644
index 00000000000..bb08dc5553d
--- /dev/null
+++ b/packages/mobile/src/components/user-link/TrackArtists.tsx
@@ -0,0 +1,68 @@
+import { ComponentProps, Fragment } from 'react'
+
+import type { ID } from '@audius/common/models'
+
+import type { IconSize } from '@audius/harmony-native'
+import { Flex, Text } from '@audius/harmony-native'
+
+import { UserLink } from './UserLink'
+
+type Collaborator = { user_id: ID }
+
+type CollaboratorLinksProps = {
+ collaborators?: Collaborator[] | null
+ badgeSize?: IconSize
+}
+
+/**
+ * Renders accepted collaborators as comma-separated `", "` entries.
+ * Returns null when there are none, so it's a no-op append to an owner element.
+ */
+export const CollaboratorLinks = ({
+ collaborators,
+ badgeSize
+}: CollaboratorLinksProps) => {
+ if (!collaborators?.length) {
+ return null
+ }
+ return (
+ <>
+ {collaborators.map((collaborator) => (
+
+ ,
+
+
+ ))}
+ >
+ )
+}
+
+type TrackArtistsProps = ComponentProps & {
+ /** Accepted collaborator artists embedded on the track. */
+ collaborators?: Collaborator[] | null
+}
+
+/**
+ * A track's artist line for mobile: the owner `` plus accepted
+ * collaborators. With the flag off (or no collaborators) it renders just the
+ * owner — a safe drop-in for an existing owner ``.
+ */
+export const TrackArtists = ({
+ collaborators,
+ ...userLinkProps
+}: TrackArtistsProps) => {
+ return (
+
+
+
+
+ )
+}
diff --git a/packages/mobile/src/components/user-link/index.ts b/packages/mobile/src/components/user-link/index.ts
index 2698e7b9d91..3b585d8765c 100644
--- a/packages/mobile/src/components/user-link/index.ts
+++ b/packages/mobile/src/components/user-link/index.ts
@@ -1 +1,2 @@
export * from './UserLink'
+export * from './TrackArtists'
diff --git a/packages/mobile/src/hooks/useNotificationNavigation.ts b/packages/mobile/src/hooks/useNotificationNavigation.ts
index fea7996c2bb..1ba3930373d 100644
--- a/packages/mobile/src/hooks/useNotificationNavigation.ts
+++ b/packages/mobile/src/hooks/useNotificationNavigation.ts
@@ -33,6 +33,8 @@ import type {
USDCPurchaseSellerNotification,
RequestManagerNotification,
ApproveManagerRequestNotification,
+ TrackCollaboratorInviteNotification,
+ TrackCollaboratorAcceptNotification,
CommentNotification,
CommentMentionNotification,
CommentThreadNotification,
@@ -355,6 +357,22 @@ export const useNotificationNavigation = () => {
},
[NotificationType.ApproveManagerRequest]: userIdHandler,
[NotificationType.RequestManager]: userIdHandler,
+ [NotificationType.TrackCollaboratorInvite]: (
+ notification: TrackCollaboratorInviteNotification
+ ) => {
+ navigation.navigate('Track', {
+ trackId: notification.trackId,
+ canBeUnlisted: false
+ })
+ },
+ [NotificationType.TrackCollaboratorAccept]: (
+ notification: TrackCollaboratorAcceptNotification
+ ) => {
+ navigation.navigate('Track', {
+ trackId: notification.trackId,
+ canBeUnlisted: false
+ })
+ },
[PushNotificationType.Message]: messagesHandler,
[PushNotificationType.MessageReaction]: messagesHandler,
[NotificationType.Comment]: entityHandler,
diff --git a/packages/mobile/src/screens/edit-track-screen/EditTrackForm.tsx b/packages/mobile/src/screens/edit-track-screen/EditTrackForm.tsx
index 0051ca6ff16..c777720867f 100644
--- a/packages/mobile/src/screens/edit-track-screen/EditTrackForm.tsx
+++ b/packages/mobile/src/screens/edit-track-screen/EditTrackForm.tsx
@@ -47,7 +47,8 @@ import {
TagField,
SubmenuList,
RemixSettingsField,
- AdvancedField
+ AdvancedField,
+ CollaboratorField
} from './fields'
import type { EditTrackFormProps } from './types'
import { getUploadMetadataFromFormValues } from './util'
@@ -358,6 +359,7 @@ export const EditTrackForm = (props: EditTrackFormProps) => {
+
diff --git a/packages/mobile/src/screens/edit-track-screen/EditTrackModalScreen.tsx b/packages/mobile/src/screens/edit-track-screen/EditTrackModalScreen.tsx
index 0d4b08811fa..9a816940ac5 100644
--- a/packages/mobile/src/screens/edit-track-screen/EditTrackModalScreen.tsx
+++ b/packages/mobile/src/screens/edit-track-screen/EditTrackModalScreen.tsx
@@ -45,6 +45,13 @@ export const EditTrackModalScreen = () => {
const initialValues = {
...track,
+ // Seed the editable collaborators with accepted + still-pending invites so
+ // saving doesn't drop pending ones (the ETL reconciles the set on update).
+ // `pending_collaborators` is only populated for the track owner.
+ collaborators: [
+ ...(track.collaborators ?? []),
+ ...(track.pending_collaborators ?? [])
+ ],
artwork: null,
trackArtwork:
trackImage && trackImage.source && isImageUriSource(trackImage.source)
diff --git a/packages/mobile/src/screens/edit-track-screen/fields/CollaboratorField.tsx b/packages/mobile/src/screens/edit-track-screen/fields/CollaboratorField.tsx
new file mode 100644
index 00000000000..cb04faab15d
--- /dev/null
+++ b/packages/mobile/src/screens/edit-track-screen/fields/CollaboratorField.tsx
@@ -0,0 +1,119 @@
+import { useCallback, useState } from 'react'
+
+import { useCurrentUserId, useSearchUserResults } from '@audius/common/api'
+import type { User, UserMetadata } from '@audius/common/models'
+import { useField } from 'formik'
+import { Pressable } from 'react-native'
+import { useDebounce } from 'react-use'
+
+import { Flex, IconClose, IconSearch, Text } from '@audius/harmony-native'
+import { TextInput } from 'app/components/core'
+
+const DEBOUNCE_MS = 300
+const name = 'collaborators'
+
+const messages = {
+ label: 'Collaborators',
+ description:
+ 'Tag other artists as collaborators. Each is invited to accept; once they do, the track also appears on their profile.',
+ search: 'Search Users'
+}
+
+/**
+ * Mobile track-upload field for tagging collaborator artists. Self-contained
+ * inline search + removable chips; the upload adapter maps the selected users
+ * to numeric ids for the on-chain metadata.
+ */
+export const CollaboratorField = () => {
+ const [{ value }, , { setValue }] = useField(name)
+ const collaborators = value ?? []
+ const [query, setQuery] = useState('')
+ const [debouncedQuery, setDebouncedQuery] = useState('')
+ useDebounce(() => setDebouncedQuery(query), DEBOUNCE_MS, [query])
+
+ const { data: currentUserId } = useCurrentUserId()
+ const { data: results } = useSearchUserResults(
+ { query: debouncedQuery.trim(), pageSize: 8 },
+ { enabled: debouncedQuery.trim().length > 0 }
+ )
+
+ const selectedIds = new Set(collaborators.map((c) => c.user_id))
+ const filteredResults = (results ?? []).filter(
+ (user) => user.user_id !== currentUserId && !selectedIds.has(user.user_id)
+ )
+
+ const handleAdd = useCallback(
+ (user: User) => {
+ setValue([...collaborators, user])
+ setQuery('')
+ setDebouncedQuery('')
+ },
+ [collaborators, setValue]
+ )
+
+ const handleRemove = useCallback(
+ (userId: number) => {
+ setValue(collaborators.filter((c) => c.user_id !== userId))
+ },
+ [collaborators, setValue]
+ )
+
+ return (
+
+
+
+ {messages.label}
+
+
+ {messages.description}
+
+
+ {collaborators.length > 0 ? (
+
+ {collaborators.map((collaborator) => (
+
+
+ {collaborator.name}
+
+ handleRemove(collaborator.user_id)}
+ >
+
+
+
+ ))}
+
+ ) : null}
+
+ {filteredResults.length > 0 ? (
+
+ {filteredResults.map((user) => (
+ handleAdd(user)}>
+
+ {user.name}
+
+ @{user.handle}
+
+
+
+ ))}
+
+ ) : null}
+
+ )
+}
diff --git a/packages/mobile/src/screens/edit-track-screen/fields/index.ts b/packages/mobile/src/screens/edit-track-screen/fields/index.ts
index 2e118bdfa6e..893559eb8bb 100644
--- a/packages/mobile/src/screens/edit-track-screen/fields/index.ts
+++ b/packages/mobile/src/screens/edit-track-screen/fields/index.ts
@@ -1,5 +1,6 @@
export * from './SelectGenreField'
export * from './DescriptionField'
+export * from './CollaboratorField'
export * from './TagField'
export * from './SelectMoodField'
export * from './SubmenuList'
diff --git a/packages/mobile/src/screens/notifications-screen/NotificationListItem.tsx b/packages/mobile/src/screens/notifications-screen/NotificationListItem.tsx
index be7017e1f0a..311076349c1 100644
--- a/packages/mobile/src/screens/notifications-screen/NotificationListItem.tsx
+++ b/packages/mobile/src/screens/notifications-screen/NotificationListItem.tsx
@@ -25,6 +25,8 @@ import {
USDCPurchaseBuyerNotification,
ApproveManagerRequestNotification,
RequestManagerNotification,
+ TrackCollaboratorInviteNotification,
+ TrackCollaboratorAcceptNotification,
CommentNotification,
CommentThreadNotification,
CommentMentionNotification,
@@ -97,6 +99,14 @@ export const NotificationListItem = (props: NotificationListItemProps) => {
return
case NotificationType.ApproveManagerRequest:
return
+ case NotificationType.TrackCollaboratorInvite:
+ return (
+
+ )
+ case NotificationType.TrackCollaboratorAccept:
+ return (
+
+ )
case NotificationType.Comment:
return
case NotificationType.CommentThread:
diff --git a/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorAcceptNotification.tsx b/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorAcceptNotification.tsx
new file mode 100644
index 00000000000..6b3a49f830a
--- /dev/null
+++ b/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorAcceptNotification.tsx
@@ -0,0 +1,58 @@
+import { useCallback } from 'react'
+
+import { useTrack, useUser } from '@audius/common/api'
+import type { TrackCollaboratorAcceptNotification as TrackCollaboratorAcceptNotificationType } from '@audius/common/store'
+import { View } from 'react-native'
+
+import { IconUserArrowRotate } from '@audius/harmony-native'
+import { useNotificationNavigation } from 'app/hooks/useNotificationNavigation'
+
+import {
+ NotificationHeader,
+ NotificationProfilePicture,
+ NotificationText,
+ NotificationTile,
+ NotificationTitle,
+ UserNameLink
+} from '../Notification'
+
+const messages = {
+ title: 'Collaboration Accepted',
+ accepted: 'accepted your invitation to collaborate on',
+ aTrack: 'a track'
+}
+
+type TrackCollaboratorAcceptNotificationProps = {
+ notification: TrackCollaboratorAcceptNotificationType
+}
+
+export const TrackCollaboratorAcceptNotification = (
+ props: TrackCollaboratorAcceptNotificationProps
+) => {
+ const { notification } = props
+ const navigation = useNotificationNavigation()
+
+ const { data: collaborator } = useUser(notification.collaboratorUserId)
+ const { data: track } = useTrack(notification.trackId)
+
+ const handlePress = useCallback(() => {
+ navigation.navigate(notification)
+ }, [navigation, notification])
+
+ if (!collaborator) return null
+
+ return (
+
+
+ {messages.title}
+
+
+
+
+ {messages.accepted}{' '}
+ {track?.title ?? messages.aTrack}.
+
+
+
+ )
+}
diff --git a/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx b/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx
new file mode 100644
index 00000000000..666d1f594ec
--- /dev/null
+++ b/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx
@@ -0,0 +1,58 @@
+import { useCallback } from 'react'
+
+import { useTrack, useUser } from '@audius/common/api'
+import type { TrackCollaboratorInviteNotification as TrackCollaboratorInviteNotificationType } from '@audius/common/store'
+import { View } from 'react-native'
+
+import { IconUserArrowRotate } from '@audius/harmony-native'
+import { useNotificationNavigation } from 'app/hooks/useNotificationNavigation'
+
+import {
+ NotificationHeader,
+ NotificationProfilePicture,
+ NotificationText,
+ NotificationTile,
+ NotificationTitle,
+ UserNameLink
+} from '../Notification'
+
+const messages = {
+ title: 'Track Collaboration Invite',
+ invitedYou: 'invited you to collaborate on',
+ aTrack: 'a track'
+}
+
+type TrackCollaboratorInviteNotificationProps = {
+ notification: TrackCollaboratorInviteNotificationType
+}
+
+export const TrackCollaboratorInviteNotification = (
+ props: TrackCollaboratorInviteNotificationProps
+) => {
+ const { notification } = props
+ const navigation = useNotificationNavigation()
+
+ const { data: inviter } = useUser(notification.inviterUserId)
+ const { data: track } = useTrack(notification.trackId)
+
+ const handlePress = useCallback(() => {
+ navigation.navigate(notification)
+ }, [navigation, notification])
+
+ if (!inviter) return null
+
+ return (
+
+
+ {messages.title}
+
+
+
+
+ {messages.invitedYou}{' '}
+ {track?.title ?? messages.aTrack}.
+
+
+
+ )
+}
diff --git a/packages/mobile/src/screens/notifications-screen/Notifications/index.ts b/packages/mobile/src/screens/notifications-screen/Notifications/index.ts
index 476b6cf2c51..f4dbc520137 100644
--- a/packages/mobile/src/screens/notifications-screen/Notifications/index.ts
+++ b/packages/mobile/src/screens/notifications-screen/Notifications/index.ts
@@ -20,6 +20,8 @@ export * from './USDCPurchaseSellerNotification'
export * from './USDCPurchaseBuyerNotification'
export * from './RequestManagerNotification'
export * from './ApproveManagerRequestNotification'
+export * from './TrackCollaboratorInviteNotification'
+export * from './TrackCollaboratorAcceptNotification'
export * from './CommentNotification'
export * from './CommentThreadNotification'
export * from './CommentMentionNotification'
diff --git a/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx b/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx
index 8408aae3a1d..a609336f8b5 100644
--- a/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx
+++ b/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx
@@ -209,10 +209,17 @@ export const TrackScreenDetailsTile = ({
is_scheduled_release: isScheduledRelease,
_is_publishing,
preview_cid,
- album_backlink
+ album_backlink,
+ collaborators
} = track as Track
const isOwner = ownerId === currentUserId
+ // Accepted collaborators (not the owner) can remove themselves from a track.
+ const isCollaborator =
+ !!currentUserId &&
+ (collaborators ?? []).some(
+ (collaborator) => collaborator.user_id === currentUserId
+ )
const hideFavorite = isUnlisted || !hasStreamAccess
const hideRepost = isUnlisted || !isReachable || !hasStreamAccess
const hideOverflow = !isReachable || (isUnlisted && !isOwner)
@@ -497,7 +504,8 @@ export const TrackScreenDetailsTile = ({
isOwner && isScheduledRelease && isUnlisted
? OverflowAction.RELEASE_NOW
: null,
- isOwner && !ddexApp ? OverflowAction.DELETE_TRACK : null
+ isOwner && !ddexApp ? OverflowAction.DELETE_TRACK : null,
+ isCollaborator ? OverflowAction.LEAVE_TRACK_COLLABORATION : null
].filter(removeNullable)
dispatch(
diff --git a/packages/sdk/src/sdk/api/generated/default/models/Notification.ts b/packages/sdk/src/sdk/api/generated/default/models/Notification.ts
index d7cdada2d69..fc9f0b4ff80 100644
--- a/packages/sdk/src/sdk/api/generated/default/models/Notification.ts
+++ b/packages/sdk/src/sdk/api/generated/default/models/Notification.ts
@@ -215,6 +215,10 @@ import {
RequestManagerNotificationFromJSONTyped,
RequestManagerNotificationToJSON,
} from './RequestManagerNotification';
+import type { TrackCollaboratorNotification } from './TrackCollaboratorNotification';
+import {
+ TrackCollaboratorNotificationFromJSONTyped,
+} from './TrackCollaboratorNotification';
import {
SaveNotification,
instanceOfSaveNotification,
@@ -319,7 +323,7 @@ import {
*
* @export
*/
-export type Notification = { type: 'announcement' } & AnnouncementNotification | { type: 'approve_manager_request' } & ApproveManagerRequestNotification | { type: 'artist_remix_contest_ended' } & ArtistRemixContestEndedNotification | { type: 'artist_remix_contest_ending_soon' } & ArtistRemixContestEndingSoonNotification | { type: 'artist_remix_contest_submissions' } & ArtistRemixContestSubmissionsNotification | { type: 'challenge_reward' } & ChallengeRewardNotification | { type: 'claimable_reward' } & ClaimableRewardNotification | { type: 'comment' } & CommentNotification | { type: 'comment_mention' } & CommentMentionNotification | { type: 'comment_reaction' } & CommentReactionNotification | { type: 'comment_thread' } & CommentThreadNotification | { type: 'cosign' } & CosignNotification | { type: 'create' } & CreateNotification | { type: 'fan_club_text_post' } & FanClubTextPostNotification | { type: 'fan_remix_contest_ended' } & FanRemixContestEndedNotification | { type: 'fan_remix_contest_ending_soon' } & FanRemixContestEndingSoonNotification | { type: 'fan_remix_contest_started' } & FanRemixContestStartedNotification | { type: 'fan_remix_contest_submission' } & FanRemixContestSubmissionNotification | { type: 'fan_remix_contest_winners_selected' } & FanRemixContestWinnersSelectedNotification | { type: 'follow' } & FollowNotification | { type: 'listen_streak_reminder' } & ListenStreakReminderNotification | { type: 'milestone' } & MilestoneNotification | { type: 'reaction' } & ReactionNotification | { type: 'remix' } & RemixNotification | { type: 'remix_contest_update' } & RemixContestUpdateNotification | { type: 'repost' } & RepostNotification | { type: 'repost_of_repost' } & RepostOfRepostNotification | { type: 'request_manager' } & RequestManagerNotification | { type: 'save' } & SaveNotification | { type: 'save_of_repost' } & SaveOfRepostNotification | { type: 'supporter_dethroned' } & SupporterDethronedNotification | { type: 'supporter_rank_up' } & SupporterRankUpNotification | { type: 'supporting_rank_up' } & SupporterRankUpNotification | { type: 'tastemaker' } & TastemakerNotification | { type: 'tier_change' } & TierChangeNotification | { type: 'tip_receive' } & ReceiveTipNotification | { type: 'tip_send' } & SendTipNotification | { type: 'track_added_to_playlist' } & TrackAddedToPlaylistNotification | { type: 'track_added_to_purchased_album' } & TrackAddedToPurchasedAlbumNotification | { type: 'trending' } & TrendingNotification | { type: 'trending_playlist' } & TrendingPlaylistNotification | { type: 'trending_underground' } & TrendingUndergroundNotification | { type: 'usdc_purchase_buyer' } & UsdcPurchaseBuyerNotification | { type: 'usdc_purchase_seller' } & UsdcPurchaseSellerNotification;
+export type Notification = { type: 'announcement' } & AnnouncementNotification | { type: 'approve_manager_request' } & ApproveManagerRequestNotification | { type: 'artist_remix_contest_ended' } & ArtistRemixContestEndedNotification | { type: 'artist_remix_contest_ending_soon' } & ArtistRemixContestEndingSoonNotification | { type: 'artist_remix_contest_submissions' } & ArtistRemixContestSubmissionsNotification | { type: 'challenge_reward' } & ChallengeRewardNotification | { type: 'claimable_reward' } & ClaimableRewardNotification | { type: 'comment' } & CommentNotification | { type: 'comment_mention' } & CommentMentionNotification | { type: 'comment_reaction' } & CommentReactionNotification | { type: 'comment_thread' } & CommentThreadNotification | { type: 'cosign' } & CosignNotification | { type: 'create' } & CreateNotification | { type: 'fan_club_text_post' } & FanClubTextPostNotification | { type: 'fan_remix_contest_ended' } & FanRemixContestEndedNotification | { type: 'fan_remix_contest_ending_soon' } & FanRemixContestEndingSoonNotification | { type: 'fan_remix_contest_started' } & FanRemixContestStartedNotification | { type: 'fan_remix_contest_submission' } & FanRemixContestSubmissionNotification | { type: 'fan_remix_contest_winners_selected' } & FanRemixContestWinnersSelectedNotification | { type: 'follow' } & FollowNotification | { type: 'listen_streak_reminder' } & ListenStreakReminderNotification | { type: 'milestone' } & MilestoneNotification | { type: 'reaction' } & ReactionNotification | { type: 'remix' } & RemixNotification | { type: 'remix_contest_update' } & RemixContestUpdateNotification | { type: 'repost' } & RepostNotification | { type: 'repost_of_repost' } & RepostOfRepostNotification | { type: 'request_manager' } & RequestManagerNotification | { type: 'save' } & SaveNotification | { type: 'save_of_repost' } & SaveOfRepostNotification | { type: 'supporter_dethroned' } & SupporterDethronedNotification | { type: 'supporter_rank_up' } & SupporterRankUpNotification | { type: 'supporting_rank_up' } & SupporterRankUpNotification | { type: 'tastemaker' } & TastemakerNotification | { type: 'tier_change' } & TierChangeNotification | { type: 'tip_receive' } & ReceiveTipNotification | { type: 'tip_send' } & SendTipNotification | { type: 'track_added_to_playlist' } & TrackAddedToPlaylistNotification | { type: 'track_added_to_purchased_album' } & TrackAddedToPurchasedAlbumNotification | { type: 'trending' } & TrendingNotification | { type: 'trending_playlist' } & TrendingPlaylistNotification | { type: 'trending_underground' } & TrendingUndergroundNotification | { type: 'usdc_purchase_buyer' } & UsdcPurchaseBuyerNotification | { type: 'usdc_purchase_seller' } & UsdcPurchaseSellerNotification | { type: 'track_collaborator_invite' } & TrackCollaboratorNotification | { type: 'track_collaborator_accept' } & TrackCollaboratorNotification;
export function NotificationFromJSON(json: any): Notification {
return NotificationFromJSONTyped(json, false);
@@ -418,6 +422,10 @@ export function NotificationFromJSONTyped(json: any, ignoreDiscriminator: boolea
return {...UsdcPurchaseBuyerNotificationFromJSONTyped(json, true), type: 'usdc_purchase_buyer'};
case 'usdc_purchase_seller':
return {...UsdcPurchaseSellerNotificationFromJSONTyped(json, true), type: 'usdc_purchase_seller'};
+ case 'track_collaborator_invite':
+ return {...TrackCollaboratorNotificationFromJSONTyped(json, true), type: 'track_collaborator_invite'};
+ case 'track_collaborator_accept':
+ return {...TrackCollaboratorNotificationFromJSONTyped(json, true), type: 'track_collaborator_accept'};
default:
throw new Error(`No variant of Notification exists with 'type=${json['type']}'`);
}
diff --git a/packages/sdk/src/sdk/api/generated/default/models/SearchTrack.ts b/packages/sdk/src/sdk/api/generated/default/models/SearchTrack.ts
index ac361f2c2e9..7aef5bd7d7b 100644
--- a/packages/sdk/src/sdk/api/generated/default/models/SearchTrack.ts
+++ b/packages/sdk/src/sdk/api/generated/default/models/SearchTrack.ts
@@ -224,6 +224,18 @@ export interface SearchTrack {
* @memberof SearchTrack
*/
user: User;
+ /**
+ * Accepted collaborator artists on the track.
+ * @type {Array}
+ * @memberof SearchTrack
+ */
+ collaborators?: Array;
+ /**
+ * Pending collaborator invites; only present on the owner's own tracks.
+ * @type {Array}
+ * @memberof SearchTrack
+ */
+ pendingCollaborators?: Array;
/**
*
* @type {number}
@@ -670,6 +682,8 @@ export function SearchTrackFromJSONTyped(json: any, ignoreDiscriminator: boolean
'tags': !exists(json, 'tags') ? undefined : json['tags'],
'title': json['title'],
'user': UserFromJSON(json['user']),
+ 'collaborators': !exists(json, 'collaborators') ? undefined : ((json['collaborators'] as Array).map(UserFromJSON)),
+ 'pendingCollaborators': !exists(json, 'pending_collaborators') ? undefined : ((json['pending_collaborators'] as Array).map(UserFromJSON)),
'duration': json['duration'],
'isDownloadable': json['is_downloadable'],
'playCount': json['play_count'],
@@ -762,6 +776,8 @@ export function SearchTrackToJSON(value?: SearchTrack | null): any {
'tags': value.tags,
'title': value.title,
'user': UserToJSON(value.user),
+ 'collaborators': value.collaborators === undefined ? undefined : ((value.collaborators as Array).map(UserToJSON)),
+ 'pending_collaborators': value.pendingCollaborators === undefined ? undefined : ((value.pendingCollaborators as Array).map(UserToJSON)),
'duration': value.duration,
'is_downloadable': value.isDownloadable,
'play_count': value.playCount,
diff --git a/packages/sdk/src/sdk/api/generated/default/models/Track.ts b/packages/sdk/src/sdk/api/generated/default/models/Track.ts
index 31821a1502b..c5704ea52f4 100644
--- a/packages/sdk/src/sdk/api/generated/default/models/Track.ts
+++ b/packages/sdk/src/sdk/api/generated/default/models/Track.ts
@@ -224,6 +224,18 @@ export interface Track {
* @memberof Track
*/
user: User;
+ /**
+ * Accepted collaborator artists on the track.
+ * @type {Array}
+ * @memberof Track
+ */
+ collaborators?: Array;
+ /**
+ * Pending collaborator invites; only present on the owner's own tracks.
+ * @type {Array}
+ * @memberof Track
+ */
+ pendingCollaborators?: Array;
/**
*
* @type {number}
@@ -678,6 +690,8 @@ export function TrackFromJSONTyped(json: any, ignoreDiscriminator: boolean): Tra
'tags': !exists(json, 'tags') ? undefined : json['tags'],
'title': json['title'],
'user': UserFromJSON(json['user']),
+ 'collaborators': !exists(json, 'collaborators') ? undefined : ((json['collaborators'] as Array).map(UserFromJSON)),
+ 'pendingCollaborators': !exists(json, 'pending_collaborators') ? undefined : ((json['pending_collaborators'] as Array).map(UserFromJSON)),
'duration': json['duration'],
'isDownloadable': json['is_downloadable'],
'playCount': json['play_count'],
@@ -771,6 +785,8 @@ export function TrackToJSON(value?: Track | null): any {
'tags': value.tags,
'title': value.title,
'user': UserToJSON(value.user),
+ 'collaborators': value.collaborators === undefined ? undefined : ((value.collaborators as Array).map(UserToJSON)),
+ 'pending_collaborators': value.pendingCollaborators === undefined ? undefined : ((value.pendingCollaborators as Array).map(UserToJSON)),
'duration': value.duration,
'is_downloadable': value.isDownloadable,
'play_count': value.playCount,
diff --git a/packages/sdk/src/sdk/api/generated/default/models/TrackCollaboratorNotification.ts b/packages/sdk/src/sdk/api/generated/default/models/TrackCollaboratorNotification.ts
new file mode 100644
index 00000000000..82ea4b413a6
--- /dev/null
+++ b/packages/sdk/src/sdk/api/generated/default/models/TrackCollaboratorNotification.ts
@@ -0,0 +1,107 @@
+/* tslint:disable */
+/* eslint-disable */
+// @ts-nocheck
+/**
+ * Audius API
+ *
+ * The version of the OpenAPI document: 1.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+import { exists, mapValues } from '../runtime';
+import type { TrackCollaboratorNotificationAction } from './TrackCollaboratorNotificationAction';
+import {
+ TrackCollaboratorNotificationActionFromJSON,
+ TrackCollaboratorNotificationActionFromJSONTyped,
+ TrackCollaboratorNotificationActionToJSON,
+} from './TrackCollaboratorNotificationAction';
+
+/**
+ *
+ * @export
+ * @interface TrackCollaboratorNotification
+ */
+export interface TrackCollaboratorNotification {
+ /**
+ *
+ * @type {string}
+ * @memberof TrackCollaboratorNotification
+ */
+ type: string;
+ /**
+ *
+ * @type {string}
+ * @memberof TrackCollaboratorNotification
+ */
+ groupId: string;
+ /**
+ *
+ * @type {boolean}
+ * @memberof TrackCollaboratorNotification
+ */
+ isSeen: boolean;
+ /**
+ *
+ * @type {number}
+ * @memberof TrackCollaboratorNotification
+ */
+ seenAt?: number;
+ /**
+ *
+ * @type {Array}
+ * @memberof TrackCollaboratorNotification
+ */
+ actions: Array;
+}
+
+/**
+ * Check if a given object implements the TrackCollaboratorNotification interface.
+ */
+export function instanceOfTrackCollaboratorNotification(value: object): value is TrackCollaboratorNotification {
+ let isInstance = true;
+ isInstance = isInstance && "type" in value && value["type"] !== undefined;
+ isInstance = isInstance && "groupId" in value && value["groupId"] !== undefined;
+ isInstance = isInstance && "isSeen" in value && value["isSeen"] !== undefined;
+ isInstance = isInstance && "actions" in value && value["actions"] !== undefined;
+
+ return isInstance;
+}
+
+export function TrackCollaboratorNotificationFromJSON(json: any): TrackCollaboratorNotification {
+ return TrackCollaboratorNotificationFromJSONTyped(json, false);
+}
+
+export function TrackCollaboratorNotificationFromJSONTyped(json: any, ignoreDiscriminator: boolean): TrackCollaboratorNotification {
+ if ((json === undefined) || (json === null)) {
+ return json;
+ }
+ return {
+
+ 'type': json['type'],
+ 'groupId': json['group_id'],
+ 'isSeen': json['is_seen'],
+ 'seenAt': !exists(json, 'seen_at') ? undefined : json['seen_at'],
+ 'actions': ((json['actions'] as Array).map(TrackCollaboratorNotificationActionFromJSON)),
+ };
+}
+
+export function TrackCollaboratorNotificationToJSON(value?: TrackCollaboratorNotification | null): any {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (value === null) {
+ return null;
+ }
+ return {
+
+ 'type': value.type,
+ 'group_id': value.groupId,
+ 'is_seen': value.isSeen,
+ 'seen_at': value.seenAt,
+ 'actions': ((value.actions as Array).map(TrackCollaboratorNotificationActionToJSON)),
+ };
+}
diff --git a/packages/sdk/src/sdk/api/generated/default/models/TrackCollaboratorNotificationAction.ts b/packages/sdk/src/sdk/api/generated/default/models/TrackCollaboratorNotificationAction.ts
new file mode 100644
index 00000000000..830134ee64a
--- /dev/null
+++ b/packages/sdk/src/sdk/api/generated/default/models/TrackCollaboratorNotificationAction.ts
@@ -0,0 +1,99 @@
+/* tslint:disable */
+/* eslint-disable */
+// @ts-nocheck
+/**
+ * Audius API
+ *
+ * The version of the OpenAPI document: 1.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+import { exists, mapValues } from '../runtime';
+import type { TrackCollaboratorNotificationActionData } from './TrackCollaboratorNotificationActionData';
+import {
+ TrackCollaboratorNotificationActionDataFromJSON,
+ TrackCollaboratorNotificationActionDataFromJSONTyped,
+ TrackCollaboratorNotificationActionDataToJSON,
+} from './TrackCollaboratorNotificationActionData';
+
+/**
+ *
+ * @export
+ * @interface TrackCollaboratorNotificationAction
+ */
+export interface TrackCollaboratorNotificationAction {
+ /**
+ *
+ * @type {string}
+ * @memberof TrackCollaboratorNotificationAction
+ */
+ specifier: string;
+ /**
+ *
+ * @type {string}
+ * @memberof TrackCollaboratorNotificationAction
+ */
+ type: string;
+ /**
+ *
+ * @type {number}
+ * @memberof TrackCollaboratorNotificationAction
+ */
+ timestamp: number;
+ /**
+ *
+ * @type {TrackCollaboratorNotificationActionData}
+ * @memberof TrackCollaboratorNotificationAction
+ */
+ data: TrackCollaboratorNotificationActionData;
+}
+
+/**
+ * Check if a given object implements the TrackCollaboratorNotificationAction interface.
+ */
+export function instanceOfTrackCollaboratorNotificationAction(value: object): value is TrackCollaboratorNotificationAction {
+ let isInstance = true;
+ isInstance = isInstance && "specifier" in value && value["specifier"] !== undefined;
+ isInstance = isInstance && "type" in value && value["type"] !== undefined;
+ isInstance = isInstance && "timestamp" in value && value["timestamp"] !== undefined;
+ isInstance = isInstance && "data" in value && value["data"] !== undefined;
+
+ return isInstance;
+}
+
+export function TrackCollaboratorNotificationActionFromJSON(json: any): TrackCollaboratorNotificationAction {
+ return TrackCollaboratorNotificationActionFromJSONTyped(json, false);
+}
+
+export function TrackCollaboratorNotificationActionFromJSONTyped(json: any, ignoreDiscriminator: boolean): TrackCollaboratorNotificationAction {
+ if ((json === undefined) || (json === null)) {
+ return json;
+ }
+ return {
+
+ 'specifier': json['specifier'],
+ 'type': json['type'],
+ 'timestamp': json['timestamp'],
+ 'data': TrackCollaboratorNotificationActionDataFromJSON(json['data']),
+ };
+}
+
+export function TrackCollaboratorNotificationActionToJSON(value?: TrackCollaboratorNotificationAction | null): any {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (value === null) {
+ return null;
+ }
+ return {
+
+ 'specifier': value.specifier,
+ 'type': value.type,
+ 'timestamp': value.timestamp,
+ 'data': TrackCollaboratorNotificationActionDataToJSON(value.data),
+ };
+}
diff --git a/packages/sdk/src/sdk/api/generated/default/models/TrackCollaboratorNotificationActionData.ts b/packages/sdk/src/sdk/api/generated/default/models/TrackCollaboratorNotificationActionData.ts
new file mode 100644
index 00000000000..a22372da362
--- /dev/null
+++ b/packages/sdk/src/sdk/api/generated/default/models/TrackCollaboratorNotificationActionData.ts
@@ -0,0 +1,79 @@
+/* tslint:disable */
+/* eslint-disable */
+// @ts-nocheck
+/**
+ * Audius API
+ *
+ * The version of the OpenAPI document: 1.0
+ *
+ *
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+import { exists, mapValues } from '../runtime';
+
+/**
+ *
+ * @export
+ * @interface TrackCollaboratorNotificationActionData
+ */
+export interface TrackCollaboratorNotificationActionData {
+ /**
+ *
+ * @type {string}
+ * @memberof TrackCollaboratorNotificationActionData
+ */
+ trackId: string;
+ /**
+ *
+ * @type {string}
+ * @memberof TrackCollaboratorNotificationActionData
+ */
+ inviterUserId: string;
+ /**
+ *
+ * @type {string}
+ * @memberof TrackCollaboratorNotificationActionData
+ */
+ collaboratorUserId: string;
+}
+
+/**
+ * Check if a given object implements the TrackCollaboratorNotificationActionData interface.
+ */
+export function instanceOfTrackCollaboratorNotificationActionData(value: object): value is TrackCollaboratorNotificationActionData {
+ return true;
+}
+
+export function TrackCollaboratorNotificationActionDataFromJSON(json: any): TrackCollaboratorNotificationActionData {
+ return TrackCollaboratorNotificationActionDataFromJSONTyped(json, false);
+}
+
+export function TrackCollaboratorNotificationActionDataFromJSONTyped(json: any, ignoreDiscriminator: boolean): TrackCollaboratorNotificationActionData {
+ if ((json === undefined) || (json === null)) {
+ return json;
+ }
+ return {
+
+ 'trackId': !exists(json, 'track_id') ? undefined : json['track_id'],
+ 'inviterUserId': !exists(json, 'inviter_user_id') ? undefined : json['inviter_user_id'],
+ 'collaboratorUserId': !exists(json, 'collaborator_user_id') ? undefined : json['collaborator_user_id'],
+ };
+}
+
+export function TrackCollaboratorNotificationActionDataToJSON(value?: TrackCollaboratorNotificationActionData | null): any {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (value === null) {
+ return null;
+ }
+ return {
+
+ 'track_id': value.trackId,
+ 'inviter_user_id': value.inviterUserId,
+ 'collaborator_user_id': value.collaboratorUserId,
+ };
+}
diff --git a/packages/sdk/src/sdk/api/generated/default/models/index.ts b/packages/sdk/src/sdk/api/generated/default/models/index.ts
index 2795a98bfd5..cf2391adaa6 100644
--- a/packages/sdk/src/sdk/api/generated/default/models/index.ts
+++ b/packages/sdk/src/sdk/api/generated/default/models/index.ts
@@ -269,6 +269,9 @@ export * from './RepostOfRepostNotificationActionData';
export * from './RepostRequestBody';
export * from './Reposts';
export * from './RequestManagerNotification';
+export * from './TrackCollaboratorNotification';
+export * from './TrackCollaboratorNotificationAction';
+export * from './TrackCollaboratorNotificationActionData';
export * from './RequestManagerNotificationAction';
export * from './RequestManagerNotificationActionData';
export * from './RewardCodeErrorResponse';
diff --git a/packages/sdk/src/sdk/api/tracks/TracksApi.ts b/packages/sdk/src/sdk/api/tracks/TracksApi.ts
index 6a1ecea2ed0..2196d33e825 100644
--- a/packages/sdk/src/sdk/api/tracks/TracksApi.ts
+++ b/packages/sdk/src/sdk/api/tracks/TracksApi.ts
@@ -72,6 +72,8 @@ import {
type CreateTrackRequestWithFiles,
PublishStemSchema,
UploadTrackSchema,
+ TrackCollaboratorSchema,
+ type EntityManagerTrackCollaboratorRequest,
type TracksApiServicesConfig
} from './types'
@@ -569,6 +571,56 @@ export class TracksApi extends GeneratedTracksApi {
return super.deleteTrack(params, requestInit)
}
+ /** @hidden
+ * Accept a collaborator invite on a track. Signed by the invited
+ * collaborator; the track owner tags collaborators in the track metadata.
+ */
+ async acceptTrackCollaboration(
+ params: EntityManagerTrackCollaboratorRequest,
+ advancedOptions?: AdvancedOptions
+ ) {
+ const { userId, trackId } = await parseParams(
+ 'acceptTrackCollaboration',
+ TrackCollaboratorSchema
+ )(params)
+
+ if (!this.entityManager) {
+ throw new UninitializedEntityManagerError()
+ }
+ return await this.entityManager.manageEntity({
+ userId,
+ entityType: EntityType.TRACK_COLLABORATOR,
+ entityId: trackId,
+ action: Action.APPROVE,
+ ...advancedOptions
+ })
+ }
+
+ /** @hidden
+ * Decline a pending collaborator invite, or leave a track you've already
+ * accepted. Signed by the collaborator. Both map to the same Reject action.
+ */
+ async rejectTrackCollaboration(
+ params: EntityManagerTrackCollaboratorRequest,
+ advancedOptions?: AdvancedOptions
+ ) {
+ const { userId, trackId } = await parseParams(
+ 'rejectTrackCollaboration',
+ TrackCollaboratorSchema
+ )(params)
+
+ if (!this.entityManager) {
+ throw new UninitializedEntityManagerError()
+ }
+ return await this.entityManager.manageEntity({
+ userId,
+ entityType: EntityType.TRACK_COLLABORATOR,
+ entityId: trackId,
+ action: Action.REJECT,
+ ...advancedOptions
+ })
+ }
+
/** @hidden
* Favorite a track
*/
diff --git a/packages/sdk/src/sdk/api/tracks/types.ts b/packages/sdk/src/sdk/api/tracks/types.ts
index 383e159d507..bee2f5cce8d 100644
--- a/packages/sdk/src/sdk/api/tracks/types.ts
+++ b/packages/sdk/src/sdk/api/tracks/types.ts
@@ -153,6 +153,9 @@ export const UploadTrackMetadataSchema = z.object({
.nullable(),
accessAuthorities: z.optional(z.array(z.string()).nullable()),
allowedApiKeys: z.optional(z.array(z.string()).nullable()),
+ // Collaborator artist user ids (numeric). Indexed by the ETL as pending
+ // invites; each tagged artist must accept before the credit is active.
+ collaborators: z.optional(z.array(z.number()).nullable()),
isDownloadGated: z.optional(z.boolean()),
downloadConditions: z
.optional(
@@ -317,6 +320,19 @@ export const DeleteTrackSchema = z
export type EntityManagerDeleteTrackRequest = z.input
+// A collaborator accepts/declines (or leaves) a track collaboration. userId is
+// the collaborator; trackId is the track they were tagged on.
+export const TrackCollaboratorSchema = z
+ .object({
+ userId: HashId,
+ trackId: HashId
+ })
+ .strict()
+
+export type EntityManagerTrackCollaboratorRequest = z.input<
+ typeof TrackCollaboratorSchema
+>
+
export const FavoriteTrackSchema = z
.object({
userId: HashId,
diff --git a/packages/sdk/src/sdk/services/EntityManager/types.ts b/packages/sdk/src/sdk/services/EntityManager/types.ts
index 4a12cffecb8..d7ef14b1adc 100644
--- a/packages/sdk/src/sdk/services/EntityManager/types.ts
+++ b/packages/sdk/src/sdk/services/EntityManager/types.ts
@@ -106,7 +106,8 @@ export enum EntityType {
EMAIL_ACCESS = 'EmailAccess',
ASSOCIATED_WALLET = 'AssociatedWallet',
COLLECTIBLES = 'Collectibles',
- EVENT = 'Event'
+ EVENT = 'Event',
+ TRACK_COLLABORATOR = 'TrackCollaborator'
}
export type AdvancedOptions = {
diff --git a/packages/web/src/components/edit/fields/CollaboratorsField.tsx b/packages/web/src/components/edit/fields/CollaboratorsField.tsx
new file mode 100644
index 00000000000..1ac7bf3424e
--- /dev/null
+++ b/packages/web/src/components/edit/fields/CollaboratorsField.tsx
@@ -0,0 +1,145 @@
+import { useCallback, useMemo, useState } from 'react'
+
+import { useCurrentUserId } from '@audius/common/api'
+import { User, UserMetadata } from '@audius/common/models'
+import {
+ Box,
+ Button,
+ Flex,
+ IconButton,
+ IconClose,
+ Text,
+ useTheme
+} from '@audius/harmony'
+import { useField } from 'formik'
+
+import ArtistChip from 'components/artist/ArtistChip'
+import { SearchUsersModal } from 'components/search-users-modal/SearchUsersModal'
+
+const messages = {
+ label: 'Collaborators',
+ description:
+ 'Tag other artists as collaborators. Each is invited to accept; once they do, the track also appears on their profile.',
+ add: 'Add Collaborator',
+ modalTitle: 'Add Collaborators',
+ remove: (name: string) => `Remove ${name}`
+}
+
+type CollaboratorsFieldProps = {
+ name: string
+}
+
+/**
+ * Track-upload field for tagging collaborator artists, modeled on the
+ * invite-manager search UI. Stores the selected users on the form; the upload
+ * adapter maps them to numeric ids for the on-chain metadata.
+ */
+export const CollaboratorsField = ({ name }: CollaboratorsFieldProps) => {
+ const { color } = useTheme()
+ const [{ value }, , { setValue }] = useField(name)
+ const collaborators = useMemo(() => value ?? [], [value])
+ const [isOpen, setIsOpen] = useState(false)
+ const { data: currentUserId } = useCurrentUserId()
+
+ const excludedUserIds = useMemo(() => {
+ const ids = collaborators.map((collaborator) => collaborator.user_id)
+ if (currentUserId) ids.push(currentUserId)
+ return ids
+ }, [collaborators, currentUserId])
+
+ const handleAdd = useCallback(
+ (user: User) => {
+ setValue([...collaborators, user])
+ setIsOpen(false)
+ },
+ [collaborators, setValue]
+ )
+
+ const handleRemove = useCallback(
+ (userId: number) => {
+ setValue(
+ collaborators.filter((collaborator) => collaborator.user_id !== userId)
+ )
+ },
+ [collaborators, setValue]
+ )
+
+ const renderUser = useCallback(
+ (user: User) => (
+
+ handleAdd(user)}
+ />
+
+ ),
+ [handleAdd, color]
+ )
+
+ return (
+
+
+
+ {messages.label}
+
+
+ {messages.description}
+
+
+ {collaborators.length > 0 ? (
+
+ {collaborators.map((collaborator) => (
+
+
+ {collaborator.name}
+
+ handleRemove(collaborator.user_id)}
+ />
+
+ ))}
+
+ ) : null}
+
+ setIsOpen(false)}
+ excludedUserIds={excludedUserIds}
+ renderUser={renderUser}
+ />
+
+ )
+}
diff --git a/packages/web/src/components/edit/fields/TrackMetadataFields.tsx b/packages/web/src/components/edit/fields/TrackMetadataFields.tsx
index 42ccfd2d5ef..f6e3df29e16 100644
--- a/packages/web/src/components/edit/fields/TrackMetadataFields.tsx
+++ b/packages/web/src/components/edit/fields/TrackMetadataFields.tsx
@@ -5,6 +5,7 @@ import { useField } from 'formik'
import { getTrackFieldName } from 'components/edit-track/hooks'
import { ArtworkField, TagField, TextAreaField } from 'components/form-fields'
+import { CollaboratorsField } from './CollaboratorsField'
import { SelectGenreField } from './SelectGenreField'
import { SelectMoodField } from './SelectMoodField'
import { TrackNameField } from './TrackNameField'
@@ -35,6 +36,7 @@ export const TrackMetadataFields = () => {
showMaxLength
grows
/>
+
)
}
diff --git a/packages/web/src/components/link/TrackArtists.tsx b/packages/web/src/components/link/TrackArtists.tsx
new file mode 100644
index 00000000000..0a1d32affaf
--- /dev/null
+++ b/packages/web/src/components/link/TrackArtists.tsx
@@ -0,0 +1,49 @@
+import { ComponentProps, Fragment } from 'react'
+
+import { ID } from '@audius/common/models'
+import { Flex, Text } from '@audius/harmony'
+
+import { UserLink } from './UserLink'
+
+type TrackArtistsProps = {
+ /** The track owner. */
+ userId: ID
+ /** Accepted collaborator artists, as embedded on the track. */
+ collaborators?: { user_id: ID }[] | null
+} & Omit, 'userId'>
+
+/**
+ * A track's artist line: the owner plus accepted collaborators as a
+ * comma-separated list on a single line that ellipsizes on overflow.
+ *
+ * With no collaborators this is equivalent to a single owner `` —
+ * a safe drop-in replacement everywhere the owner is currently shown.
+ */
+export const TrackArtists = ({
+ userId,
+ collaborators,
+ ...userLinkProps
+}: TrackArtistsProps) => {
+ const extraArtists = collaborators ?? []
+
+ if (extraArtists.length === 0) {
+ return
+ }
+
+ return (
+
+
+ {extraArtists.map((collaborator) => (
+
+
+ ,
+
+
+
+ ))}
+
+ )
+}
diff --git a/packages/web/src/components/link/index.ts b/packages/web/src/components/link/index.ts
index a11dec85e11..e85b3bc9ad3 100644
--- a/packages/web/src/components/link/index.ts
+++ b/packages/web/src/components/link/index.ts
@@ -2,5 +2,6 @@ export * from './SeoLink'
export * from './ExternalLink'
export * from './ExternalTextLink'
export * from './UserLink'
+export * from './TrackArtists'
export * from './TrackLink'
export * from './TextLink'
diff --git a/packages/web/src/components/menu/TrackMenu.tsx b/packages/web/src/components/menu/TrackMenu.tsx
index 68beb03f0f0..1e37e2d45f3 100644
--- a/packages/web/src/components/menu/TrackMenu.tsx
+++ b/packages/web/src/components/menu/TrackMenu.tsx
@@ -2,6 +2,7 @@ import { useContext } from 'react'
import {
useCurrentUserId,
+ useRejectTrackCollaboration,
useRemixContest,
useToggleFavoriteTrack,
useTrack
@@ -79,7 +80,9 @@ const messages = {
playNext: 'Play Next',
addToQueue: 'Add to Queue',
willPlayNext: 'Will play next',
- addedToQueue: 'Added to queue'
+ addedToQueue: 'Added to queue',
+ removeCollaboration: 'Remove Me as Collaborator',
+ removedCollaboration: 'Removed as collaborator'
}
export type OwnProps = {
@@ -144,13 +147,29 @@ const TrackMenu = ({
const { toast } = useContext(ToastContext)
const dispatch = useDispatch()
const { data: currentUserId } = useCurrentUserId()
+ const { mutate: rejectTrackCollaboration } = useRejectTrackCollaboration()
const { onOpen: openDeleteTrackConfirmation } =
useDeleteTrackConfirmationModal()
const { onOpen: openHostRemixContest } = useHostRemixContestModal()
const { data: partialTrack } = useTrack(props.trackId, {
- select: (track) => pick(track, ['album_backlink', 'permalink', 'remix_of'])
+ select: (track) =>
+ pick(track, [
+ 'album_backlink',
+ 'permalink',
+ 'remix_of',
+ 'collaborators',
+ 'owner_id'
+ ])
})
+ // Whether the current user is an accepted collaborator (not the owner), and
+ // can therefore remove themselves from the track.
+ const isCollaborator =
+ !!currentUserId &&
+ (partialTrack?.collaborators ?? []).some(
+ (collaborator) => collaborator.user_id === currentUserId
+ )
+
const toggleSaveTrack = useToggleFavoriteTrack({
trackId: props.trackId,
source: FavoriteSource.OVERFLOW
@@ -370,6 +389,16 @@ const TrackMenu = ({
}
}
+ const leaveCollaborationMenuItem: PopupMenuItem = {
+ text: messages.removeCollaboration,
+ onClick: () => {
+ if (trackId) {
+ rejectTrackCollaboration({ trackId })
+ toast(messages.removedCollaboration)
+ }
+ }
+ }
+
const menu: { items: PopupMenuItem[] } = { items: [] }
if (
@@ -396,6 +425,9 @@ const TrackMenu = ({
if (includeFavorite && !isOwner && (!isDeleted || isFavorited)) {
menu.items.push(favoriteMenuItem)
}
+ if (isCollaborator && !isOwner && !isDeleted) {
+ menu.items.push(leaveCollaborationMenuItem)
+ }
if (includeAddToAlbum && !isDeleted && isOwner) {
menu.items.push(addToAlbumMenuItem)
}
diff --git a/packages/web/src/components/notification/Notification/Notification.tsx b/packages/web/src/components/notification/Notification/Notification.tsx
index 2d7063492ff..a1e5f35faf1 100644
--- a/packages/web/src/components/notification/Notification/Notification.tsx
+++ b/packages/web/src/components/notification/Notification/Notification.tsx
@@ -37,6 +37,8 @@ import { RequestManagerNotification } from './RequestManagerNotification'
import { TastemakerNotification } from './TastemakerNotification'
import { TierChangeNotification } from './TierChangeNotification'
import { TrackAddedToPurchasedAlbumNotification } from './TrackAddedToPurchasedAlbumNotification'
+import { TrackCollaboratorAcceptNotification } from './TrackCollaboratorAcceptNotification'
+import { TrackCollaboratorInviteNotification } from './TrackCollaboratorInviteNotification'
import { TrendingTrackNotification } from './TrendingTrackNotification'
import { TrendingUndergroundNotification } from './TrendingUndergroundNotification'
import { USDCPurchaseBuyerNotification } from './USDCPurchaseBuyerNotification'
@@ -112,6 +114,16 @@ export const Notification = (props: NotificationProps) => {
case NotificationType.ApproveManagerRequest: {
return
}
+ case NotificationType.TrackCollaboratorInvite: {
+ return (
+
+ )
+ }
+ case NotificationType.TrackCollaboratorAccept: {
+ return (
+
+ )
+ }
case NotificationType.AddTrackToPlaylist: {
return
}
diff --git a/packages/web/src/components/notification/Notification/TrackCollaboratorAcceptNotification.tsx b/packages/web/src/components/notification/Notification/TrackCollaboratorAcceptNotification.tsx
new file mode 100644
index 00000000000..5e3c6365d03
--- /dev/null
+++ b/packages/web/src/components/notification/Notification/TrackCollaboratorAcceptNotification.tsx
@@ -0,0 +1,58 @@
+import { useCallback } from 'react'
+
+import { useTrack, useUser } from '@audius/common/api'
+import { TrackCollaboratorAcceptNotification as TrackCollaboratorAcceptNotificationType } from '@audius/common/store'
+import { IconUserArrowRotate } from '@audius/harmony'
+import { useDispatch } from 'react-redux'
+
+import { push } from 'utils/navigation'
+
+import { NotificationBody } from './components/NotificationBody'
+import { NotificationFooter } from './components/NotificationFooter'
+import { NotificationHeader } from './components/NotificationHeader'
+import { NotificationTile } from './components/NotificationTile'
+import { NotificationTitle } from './components/NotificationTitle'
+import { UserNameLink } from './components/UserNameLink'
+
+const messages = {
+ title: 'Collaboration Accepted',
+ accepted: 'accepted your invitation to collaborate on',
+ aTrack: 'a track'
+}
+
+type TrackCollaboratorAcceptNotificationProps = {
+ notification: TrackCollaboratorAcceptNotificationType
+}
+
+export const TrackCollaboratorAcceptNotification = (
+ props: TrackCollaboratorAcceptNotificationProps
+) => {
+ const { notification } = props
+ const { timeLabel, isViewed, trackId, collaboratorUserId } = notification
+ const dispatch = useDispatch()
+ const { data: collaborator } = useUser(collaboratorUserId)
+ const { data: track } = useTrack(trackId)
+
+ const handleClick = useCallback(() => {
+ if (track?.permalink) {
+ dispatch(push(track.permalink))
+ }
+ }, [dispatch, track?.permalink])
+
+ if (!collaborator) return null
+
+ return (
+
+ }
+ >
+ {messages.title}
+
+
+ {' '}
+ {messages.accepted} {track?.title ?? messages.aTrack}.
+
+
+
+ )
+}
diff --git a/packages/web/src/components/notification/Notification/TrackCollaboratorInviteNotification.tsx b/packages/web/src/components/notification/Notification/TrackCollaboratorInviteNotification.tsx
new file mode 100644
index 00000000000..2e575688683
--- /dev/null
+++ b/packages/web/src/components/notification/Notification/TrackCollaboratorInviteNotification.tsx
@@ -0,0 +1,127 @@
+import { MouseEvent, useCallback, useContext } from 'react'
+
+import {
+ useTrack,
+ useUser,
+ useAcceptTrackCollaboration,
+ useRejectTrackCollaboration
+} from '@audius/common/api'
+import { TrackCollaboratorInviteNotification as TrackCollaboratorInviteNotificationType } from '@audius/common/store'
+import { Button, Flex, IconUserArrowRotate } from '@audius/harmony'
+import { useDispatch } from 'react-redux'
+
+import { ToastContext } from 'components/toast/ToastContext'
+import { push } from 'utils/navigation'
+
+import { NotificationBody } from './components/NotificationBody'
+import { NotificationFooter } from './components/NotificationFooter'
+import { NotificationHeader } from './components/NotificationHeader'
+import { NotificationTile } from './components/NotificationTile'
+import { NotificationTitle } from './components/NotificationTitle'
+import { UserNameLink } from './components/UserNameLink'
+
+const messages = {
+ title: 'Track Collaboration Invite',
+ invitedYou: 'invited you to collaborate on',
+ // The track may be private — a pending collaborator can't load it yet, so fall
+ // back to a generic noun rather than blocking the whole notification.
+ aTrack: 'a track',
+ accept: 'Accept',
+ decline: 'Decline',
+ accepted: 'Collaboration accepted!',
+ declined: 'Invitation declined',
+ error: 'Something went wrong. Please try again.'
+}
+
+type TrackCollaboratorInviteNotificationProps = {
+ notification: TrackCollaboratorInviteNotificationType
+}
+
+export const TrackCollaboratorInviteNotification = (
+ props: TrackCollaboratorInviteNotificationProps
+) => {
+ const { notification } = props
+ const { timeLabel, isViewed, trackId, inviterUserId } = notification
+ const dispatch = useDispatch()
+ const { toast } = useContext(ToastContext)
+ const { data: inviter } = useUser(inviterUserId)
+ // Best-effort: private tracks won't load for a pending collaborator.
+ const { data: track } = useTrack(trackId)
+ const { mutate: acceptCollaboration, isPending: isAccepting } =
+ useAcceptTrackCollaboration()
+ const { mutate: rejectCollaboration, isPending: isDeclining } =
+ useRejectTrackCollaboration()
+ const isSubmitting = isAccepting || isDeclining
+
+ const handleClick = useCallback(() => {
+ if (track?.permalink) {
+ dispatch(push(track.permalink))
+ }
+ }, [dispatch, track?.permalink])
+
+ const handleAccept = useCallback(
+ (e: MouseEvent) => {
+ e.stopPropagation()
+ acceptCollaboration(
+ { trackId },
+ {
+ onSuccess: () => toast(messages.accepted),
+ onError: () => toast(messages.error)
+ }
+ )
+ },
+ [acceptCollaboration, trackId, toast]
+ )
+
+ const handleDecline = useCallback(
+ (e: MouseEvent) => {
+ e.stopPropagation()
+ rejectCollaboration(
+ { trackId },
+ {
+ onSuccess: () => toast(messages.declined),
+ onError: () => toast(messages.error)
+ }
+ )
+ },
+ [rejectCollaboration, trackId, toast]
+ )
+
+ // Only the inviter (a public user) is required to render; the track itself
+ // may be unavailable (private) without breaking accept/decline, which act on
+ // the trackId carried by the notification.
+ if (!inviter) return null
+
+ return (
+
+ }
+ >
+ {messages.title}
+
+
+ {' '}
+ {messages.invitedYou} {track?.title ?? messages.aTrack}.
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/web/src/components/track/GiantTrackTile.tsx b/packages/web/src/components/track/GiantTrackTile.tsx
index a3b0de54846..6768a4194c5 100644
--- a/packages/web/src/components/track/GiantTrackTile.tsx
+++ b/packages/web/src/components/track/GiantTrackTile.tsx
@@ -47,7 +47,7 @@ import { pick } from 'lodash'
import { useToggle } from 'react-use'
import useMeasure from 'react-use-measure'
-import { UserLink } from 'components/link'
+import { TrackArtists } from 'components/link'
import Menu from 'components/menu/Menu'
import { SearchTag } from 'components/search-bar/SearchTag'
import Skeleton from 'components/skeleton/Skeleton'
@@ -191,6 +191,9 @@ export const GiantTrackTile = ({
const { data: track } = useTrack(trackId, {
select: (track) => pick(track, ['is_downloadable', 'preview_cid'])
})
+ const { data: collaborators } = useTrack(trackId, {
+ select: (track) => track.collaborators
+ })
const shouldShowDownloadSection = !!track?.is_downloadable
// Preview button is shown for USDC-gated tracks if user does not have access
// or is the owner
@@ -539,7 +542,11 @@ export const GiantTrackTile = ({
}}
>
By
-
+
) : (
-
) : null}
-
>
) : null}
-
+
{
const trackAsMetadataForUpload: TrackMetadataForUpload = {
...(track as TrackMetadata),
+ // Seed the editable collaborators with accepted + still-pending invites, so
+ // saving doesn't drop pending ones (the ETL reconciles the metadata set on
+ // update). `pending_collaborators` is only populated for the track owner.
+ collaborators: [
+ ...(track?.collaborators ?? []),
+ ...(track?.pending_collaborators ?? [])
+ ],
genre: (track?.genre as Genre) ?? '',
mood: (track?.mood as Mood) ?? null,
artwork: {