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: {