From 0cc634b5eac99b8cfef3b9a93504e1f880d1102a Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 8 Jun 2026 18:47:26 -0700 Subject: [PATCH 01/19] =?UTF-8?q?feat(web):=20collaborative=20tracks=20?= =?UTF-8?q?=E2=80=94=20comma-separated=20artist=20line=20(behind=20flag)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display slice of the apps phase: renders a track's accepted collaborators as a comma-separated artist line on web track tiles and the track page, behind a new `collaborative_tracks` flag (default off). Ships inert. - packages/common: `TrackMetadata.collaborators` (UserMetadata[]), mapped in userTrackMetadataFromSDK and decoded like the owner. The field isn't in the generated SDK Track type yet, so it's read via a structural extension pending the SDK regen. New COLLABORATIVE_TRACKS feature flag. - packages/web: renders the owner plus comma-separated collaborators on one ellipsizing line. With the flag off (or no collaborators) it returns a single owner — a safe drop-in. Wired into desktop TrackTile, mobile-web TrackTile, and GiantTrackTile. Next apps slices: mobile-native tiles, upload tagging UI, notifications + accept UX, SDK write methods. Co-Authored-By: Claude Opus 4.8 --- packages/common/src/adapters/track.ts | 7 +++ packages/common/src/models/Track.ts | 4 ++ .../services/remote-config/feature-flags.ts | 6 ++- .../web/src/components/link/TrackArtists.tsx | 53 +++++++++++++++++++ packages/web/src/components/link/index.ts | 1 + .../src/components/track/GiantTrackTile.tsx | 11 +++- .../components/track/desktop/TrackTile.tsx | 5 +- .../src/components/track/mobile/TrackTile.tsx | 7 +-- 8 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 packages/web/src/components/link/TrackArtists.tsx diff --git a/packages/common/src/adapters/track.ts b/packages/common/src/adapters/track.ts index 9f1a9535d46..70cdafa26f2 100644 --- a/packages/common/src/adapters/track.ts +++ b/packages/common/src/adapters/track.ts @@ -138,6 +138,13 @@ export const userTrackMetadataFromSDK = ( : null, track_segments: input.trackSegments.map(trackSegmentFromSDK), user, + // Accepted collaborator artists, same SDK shape as `user`. The API field is + // not yet in the generated SDK Track type, so it's accessed via a local + // extension; each is decoded/cleaned like the owner. + collaborators: transformAndCleanList( + (input as { collaborators?: (typeof input)['user'][] }).collaborators, + userMetadataFromSDK + ), // Retypes license: (input.license as License) ?? null, diff --git a/packages/common/src/models/Track.ts b/packages/common/src/models/Track.ts index 77908d6e4b3..cf90f16b6be 100644 --- a/packages/common/src/models/Track.ts +++ b/packages/common/src/models/Track.ts @@ -263,6 +263,10 @@ 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[] } & Timestamped export type WriteableTrackMetadata = TrackMetadata & { diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index 0583599ccfe..66bd8adfe4d 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -16,7 +16,8 @@ export enum FeatureFlags { COLLAPSED_EXPLORE_HEADER = 'collapsed_explore_header', LAUNCHPAD_VERIFICATION = 'launchpad_verification', FAN_CLUB_TEXT_POST_POSTING = 'fan_club_text_post_posting', - QUEUE_NEW_FEATURE_BADGE = 'queue_new_feature_badge' + QUEUE_NEW_FEATURE_BADGE = 'queue_new_feature_badge', + COLLABORATIVE_TRACKS = 'collaborative_tracks' } type FlagDefaults = Record @@ -49,5 +50,6 @@ export const flagDefaults: FlagDefaults = { [FeatureFlags.COLLAPSED_EXPLORE_HEADER]: false, [FeatureFlags.LAUNCHPAD_VERIFICATION]: true, [FeatureFlags.FAN_CLUB_TEXT_POST_POSTING]: false, - [FeatureFlags.QUEUE_NEW_FEATURE_BADGE]: false + [FeatureFlags.QUEUE_NEW_FEATURE_BADGE]: false, + [FeatureFlags.COLLABORATIVE_TRACKS]: false } diff --git a/packages/web/src/components/link/TrackArtists.tsx b/packages/web/src/components/link/TrackArtists.tsx new file mode 100644 index 00000000000..853d3abbef4 --- /dev/null +++ b/packages/web/src/components/link/TrackArtists.tsx @@ -0,0 +1,53 @@ +import { ComponentProps, Fragment } from 'react' + +import { useFeatureFlag } from '@audius/common/hooks' +import { ID } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' +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. + * + * Collaborators render only when the `COLLABORATIVE_TRACKS` flag is enabled, so + * with the flag off this is equivalent to a single owner `` — making + * it a safe drop-in replacement everywhere the owner is currently shown. + */ +export const TrackArtists = ({ + userId, + collaborators, + ...userLinkProps +}: TrackArtistsProps) => { + const { isEnabled } = useFeatureFlag(FeatureFlags.COLLABORATIVE_TRACKS) + const extraArtists = isEnabled ? (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/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} - +
Date: Mon, 8 Jun 2026 19:10:05 -0700 Subject: [PATCH 02/19] =?UTF-8?q?feat(mobile):=20collaborative=20tracks=20?= =?UTF-8?q?=E2=80=94=20comma-separated=20artist=20line=20(behind=20flag)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile-native parity for the collaborator artist line, behind the collaborative_tracks flag. - (owner UserLink + collaborators) and (collaborators-only append), both flag-gated so they're no-ops when off. - TrackCard: owner UserLink -> TrackArtists (collaborators added to the track select). Wrapper centers to preserve the card's centered artist line. - TrackDetailsTile: keeps the owner Text + UserBadges and appends CollaboratorLinks, so the flag-off rendering is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../track-details-tile/TrackDetailsTile.tsx | 5 ++ .../mobile/src/components/track/TrackCard.tsx | 7 +- .../src/components/user-link/TrackArtists.tsx | 72 +++++++++++++++++++ .../mobile/src/components/user-link/index.ts | 1 + 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 packages/mobile/src/components/user-link/TrackArtists.tsx 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..ce4f6073b87 --- /dev/null +++ b/packages/mobile/src/components/user-link/TrackArtists.tsx @@ -0,0 +1,72 @@ +import { ComponentProps, Fragment } from 'react' + +import { useFeatureFlag } from '@audius/common/hooks' +import type { ID } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' + +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 the collaborative-tracks flag is off or there are none, so + * it's a no-op append to an existing owner element. + */ +export const CollaboratorLinks = ({ + collaborators, + badgeSize +}: CollaboratorLinksProps) => { + const { isEnabled } = useFeatureFlag(FeatureFlags.COLLABORATIVE_TRACKS) + if (!isEnabled || !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' From 705fb7e0b5cf71a0caa19f7a40ba53dcc93f7c61 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 8 Jun 2026 19:27:06 -0700 Subject: [PATCH 03/19] feat(apps): SDK + hooks for collaborator invite/accept/leave - SDK: TrackCollaborator EntityType; TracksApi.acceptTrackCollaboration / rejectTrackCollaboration (EntityManager Approve/Reject; reject = decline or leave). collaborators added to UploadTrackMetadataSchema (numeric user ids). - Upload adapter maps form collaborator user objects -> numeric ids on-chain. - tan-query: useAcceptTrackCollaboration, useRejectTrackCollaboration. Co-Authored-By: Claude Opus 4.8 --- packages/common/src/adapters/track.ts | 5 ++ packages/common/src/api/index.ts | 2 + .../tracks/useAcceptTrackCollaboration.ts | 38 ++++++++++++++ .../tracks/useRejectTrackCollaboration.ts | 39 ++++++++++++++ packages/sdk/src/sdk/api/tracks/TracksApi.ts | 52 +++++++++++++++++++ packages/sdk/src/sdk/api/tracks/types.ts | 16 ++++++ .../src/sdk/services/EntityManager/types.ts | 3 +- 7 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 packages/common/src/api/tan-query/tracks/useAcceptTrackCollaboration.ts create mode 100644 packages/common/src/api/tan-query/tracks/useRejectTrackCollaboration.ts diff --git a/packages/common/src/adapters/track.ts b/packages/common/src/adapters/track.ts index 70cdafa26f2..7b5b770c54f 100644 --- a/packages/common/src/adapters/track.ts +++ b/packages/common/src/adapters/track.ts @@ -338,6 +338,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/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 = { From 7631359f228afebf09a9eef84643f86c3162852c Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 8 Jun 2026 19:30:23 -0700 Subject: [PATCH 04/19] feat(web): collaborator tagging field on track upload/edit (behind flag) - CollaboratorsField: search + add collaborators (reuses SearchUsersModal / ArtistChip, like the invite-manager UI) with removable chips. Wired into TrackMetadataFields behind the collaborative_tracks flag. - collaborators added to the upload form schema (full user objects). Mobile upload tagging deferred (needs a native user picker); data plumbing already supports it. Co-Authored-By: Claude Opus 4.8 --- .../src/schemas/upload/uploadFormSchema.ts | 5 + .../edit/fields/CollaboratorsField.tsx | 145 ++++++++++++++++++ .../edit/fields/TrackMetadataFields.tsx | 9 ++ 3 files changed, 159 insertions(+) create mode 100644 packages/web/src/components/edit/fields/CollaboratorsField.tsx 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/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..c0af3c03894 100644 --- a/packages/web/src/components/edit/fields/TrackMetadataFields.tsx +++ b/packages/web/src/components/edit/fields/TrackMetadataFields.tsx @@ -1,3 +1,5 @@ +import { useFeatureFlag } from '@audius/common/hooks' +import { FeatureFlags } from '@audius/common/services' import { Flex } from '@audius/harmony' import { MAX_DESCRIPTION_LENGTH } from '@audius/sdk' import { useField } from 'formik' @@ -5,6 +7,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' @@ -14,6 +17,9 @@ const messages = { export const TrackMetadataFields = () => { const [{ value: index }] = useField('trackMetadatasIndex') + const { isEnabled: isCollaborativeTracksEnabled } = useFeatureFlag( + FeatureFlags.COLLABORATIVE_TRACKS + ) return ( @@ -35,6 +41,9 @@ export const TrackMetadataFields = () => { showMaxLength grows /> + {isCollaborativeTracksEnabled ? ( + + ) : null} ) } From 636680ad96c809516db98f0175857abf5db39161 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 8 Jun 2026 19:38:29 -0700 Subject: [PATCH 05/19] feat(apps): collaborator invite notifications + accept/decline + leave (behind flag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Notification types track_collaborator_invite / track_collaborator_accept (NotificationType + PushNotificationType + interfaces + union + adapter cases). - Web: invite notification with inline Accept/Decline (useAccept/RejectTrack- Collaboration), accept notification (display); routing in Notification.tsx. TrackMenu gains a 'Remove Me as Collaborator' item shown to accepted collaborators (not the owner) — the post-accept leave path. - Mobile: invite/accept notification components + routing + navigation handlers (navigate to the track). Deferred follow-ups: mobile upload picker, mobile overflow leave action, a pending-invites list page, and SDK read-type regen. Co-Authored-By: Claude Opus 4.8 --- packages/common/src/adapters/notification.ts | 20 +++++ .../common/src/store/notifications/types.ts | 18 ++++ .../src/hooks/useNotificationNavigation.ts | 18 ++++ .../NotificationListItem.tsx | 10 +++ .../TrackCollaboratorAcceptNotification.tsx | 56 ++++++++++++ .../TrackCollaboratorInviteNotification.tsx | 56 ++++++++++++ .../Notifications/index.ts | 2 + .../web/src/components/menu/TrackMenu.tsx | 42 ++++++++- .../Notification/Notification.tsx | 12 +++ .../TrackCollaboratorAcceptNotification.tsx | 57 ++++++++++++ .../TrackCollaboratorInviteNotification.tsx | 90 +++++++++++++++++++ 11 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorAcceptNotification.tsx create mode 100644 packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx create mode 100644 packages/web/src/components/notification/Notification/TrackCollaboratorAcceptNotification.tsx create mode 100644 packages/web/src/components/notification/Notification/TrackCollaboratorInviteNotification.tsx 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/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/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/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..58da06263f5 --- /dev/null +++ b/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorAcceptNotification.tsx @@ -0,0 +1,56 @@ +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' +} + +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 || !track) return null + + return ( + + + {messages.title} + + + + + {messages.accepted} {track.title}. + + + + ) +} 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..878a2e3ec81 --- /dev/null +++ b/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx @@ -0,0 +1,56 @@ +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' +} + +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 || !track) return null + + return ( + + + {messages.title} + + + + + {messages.invitedYou} {track.title}. + + + + ) +} 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/web/src/components/menu/TrackMenu.tsx b/packages/web/src/components/menu/TrackMenu.tsx index 68beb03f0f0..47f9ca45e3a 100644 --- a/packages/web/src/components/menu/TrackMenu.tsx +++ b/packages/web/src/components/menu/TrackMenu.tsx @@ -2,10 +2,12 @@ import { useContext } from 'react' import { useCurrentUserId, + useRejectTrackCollaboration, useRemixContest, useToggleFavoriteTrack, useTrack } from '@audius/common/api' +import { useFeatureFlag } from '@audius/common/hooks' import { ShareSource, RepostSource, @@ -28,6 +30,7 @@ import { useHostRemixContestModal, QueueSource } from '@audius/common/store' +import { FeatureFlags } from '@audius/common/services' import { Genre, Nullable, route } from '@audius/common/utils' import { PopupMenuItem } from '@audius/harmony' import { pick } from 'lodash' @@ -79,7 +82,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 +149,33 @@ const TrackMenu = ({ const { toast } = useContext(ToastContext) const dispatch = useDispatch() const { data: currentUserId } = useCurrentUserId() + const { isEnabled: isCollaborativeTracksEnabled } = useFeatureFlag( + FeatureFlags.COLLABORATIVE_TRACKS + ) + 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 = + isCollaborativeTracksEnabled && + !!currentUserId && + (partialTrack?.collaborators ?? []).some( + (collaborator) => collaborator.user_id === currentUserId + ) + const toggleSaveTrack = useToggleFavoriteTrack({ trackId: props.trackId, source: FavoriteSource.OVERFLOW @@ -370,6 +395,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 +431,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..837cb39d1e5 100644 --- a/packages/web/src/components/notification/Notification/Notification.tsx +++ b/packages/web/src/components/notification/Notification/Notification.tsx @@ -34,6 +34,8 @@ import { RemixCreateNotification } from './RemixCreateNotification' import { RepostNotification } from './RepostNotification' import { RepostOfRepostNotification } from './RepostOfRepostNotification' import { RequestManagerNotification } from './RequestManagerNotification' +import { TrackCollaboratorAcceptNotification } from './TrackCollaboratorAcceptNotification' +import { TrackCollaboratorInviteNotification } from './TrackCollaboratorInviteNotification' import { TastemakerNotification } from './TastemakerNotification' import { TierChangeNotification } from './TierChangeNotification' import { TrackAddedToPurchasedAlbumNotification } from './TrackAddedToPurchasedAlbumNotification' @@ -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..1bf5cf2f268 --- /dev/null +++ b/packages/web/src/components/notification/Notification/TrackCollaboratorAcceptNotification.tsx @@ -0,0 +1,57 @@ +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' +} + +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 || !track) return null + + return ( + + } + > + {messages.title} + + + {' '} + {messages.accepted} {track.title}. + + + + ) +} 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..f62d3ca336e --- /dev/null +++ b/packages/web/src/components/notification/Notification/TrackCollaboratorInviteNotification.tsx @@ -0,0 +1,90 @@ +import { MouseEvent, useCallback } 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 { 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', + accept: 'Accept', + decline: 'Decline' +} + +type TrackCollaboratorInviteNotificationProps = { + notification: TrackCollaboratorInviteNotificationType +} + +export const TrackCollaboratorInviteNotification = ( + props: TrackCollaboratorInviteNotificationProps +) => { + const { notification } = props + const { timeLabel, isViewed, trackId, inviterUserId } = notification + const dispatch = useDispatch() + const { data: inviter } = useUser(inviterUserId) + const { data: track } = useTrack(trackId) + const { mutate: acceptCollaboration } = useAcceptTrackCollaboration() + const { mutate: rejectCollaboration } = useRejectTrackCollaboration() + + const handleClick = useCallback(() => { + if (track?.permalink) { + dispatch(push(track.permalink)) + } + }, [dispatch, track?.permalink]) + + const handleAccept = useCallback( + (e: MouseEvent) => { + e.stopPropagation() + acceptCollaboration({ trackId }) + }, + [acceptCollaboration, trackId] + ) + + const handleDecline = useCallback( + (e: MouseEvent) => { + e.stopPropagation() + rejectCollaboration({ trackId }) + }, + [rejectCollaboration, trackId] + ) + + if (!inviter || !track) return null + + return ( + + } + > + {messages.title} + + + {' '} + {messages.invitedYou} {track.title}. + + + + + + + + ) +} From 91b46aa78c4182b04cfc67ade5583d212b9648cb Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 8 Jun 2026 21:08:41 -0700 Subject: [PATCH 06/19] feat(mobile): collaborator upload tagging + leave action (behind flag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the deferred mobile pieces. - CollaboratorField: self-contained inline search (useSearchUserResults) + removable chips, added to the mobile edit-track form behind the flag. Selected users flow through the upload metadata adapter to numeric ids. - "Remove Me as Collaborator" overflow action: new OverflowAction.LEAVE_TRACK_COLLABORATION, row label + drawer callback (rejectTrackCollaboration + toast), shown on the track page to accepted collaborators (not the owner). Known limitation: editing a track initializes collaborators from the API's accepted list only, so re-saving could drop still-pending invites — needs an owner-facing "all collaborators" source to fix. Co-Authored-By: Claude Opus 4.8 --- .../store/ui/mobile-overflow-menu/types.ts | 3 +- .../OverflowMenuDrawer.tsx | 4 + .../TrackOverflowMenuDrawer.tsx | 10 +- .../edit-track-screen/EditTrackForm.tsx | 9 +- .../fields/CollaboratorField.tsx | 119 ++++++++++++++++++ .../screens/edit-track-screen/fields/index.ts | 1 + .../track-screen/TrackScreenDetailsTile.tsx | 23 +++- 7 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 packages/mobile/src/screens/edit-track-screen/fields/CollaboratorField.tsx 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/screens/edit-track-screen/EditTrackForm.tsx b/packages/mobile/src/screens/edit-track-screen/EditTrackForm.tsx index 0051ca6ff16..f4a5b77a609 100644 --- a/packages/mobile/src/screens/edit-track-screen/EditTrackForm.tsx +++ b/packages/mobile/src/screens/edit-track-screen/EditTrackForm.tsx @@ -1,7 +1,9 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { useUpdateTrack } from '@audius/common/api' +import { useFeatureFlag } from '@audius/common/hooks' import { DownloadQuality, Name } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' import type { TrackForUpload } from '@audius/common/store' import { useWaitForDownloadModal, @@ -47,7 +49,8 @@ import { TagField, SubmenuList, RemixSettingsField, - AdvancedField + AdvancedField, + CollaboratorField } from './fields' import type { EditTrackFormProps } from './types' import { getUploadMetadataFromFormValues } from './util' @@ -95,6 +98,9 @@ export const EditTrackForm = (props: EditTrackFormProps) => { const styles = useStyles() const navigation = useNavigation() const dispatch = useDispatch() + const { isEnabled: isCollaborativeTracksEnabled } = useFeatureFlag( + FeatureFlags.COLLABORATIVE_TRACKS + ) // Use track file selector directly like web version const { track: selectedTrack, selectFile } = useTrackFileSelector() @@ -358,6 +364,7 @@ export const EditTrackForm = (props: EditTrackFormProps) => { + {isCollaborativeTracksEnabled ? : null} 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/track-screen/TrackScreenDetailsTile.tsx b/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx index 8408aae3a1d..e815a52b22b 100644 --- a/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx +++ b/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx @@ -11,7 +11,12 @@ import { useTrackPageLineup, getTrackPageLineupQueryKey } from '@audius/common/api' -import { useCurrentTrack, useGatedContentAccess } from '@audius/common/hooks' +import { + useCurrentTrack, + useFeatureFlag, + useGatedContentAccess +} from '@audius/common/hooks' +import { FeatureFlags } from '@audius/common/services' import { Name, ShareSource, @@ -209,10 +214,21 @@ export const TrackScreenDetailsTile = ({ is_scheduled_release: isScheduledRelease, _is_publishing, preview_cid, - album_backlink + album_backlink, + collaborators } = track as Track const isOwner = ownerId === currentUserId + const { isEnabled: isCollaborativeTracksEnabled } = useFeatureFlag( + FeatureFlags.COLLABORATIVE_TRACKS + ) + // Accepted collaborators (not the owner) can remove themselves from a track. + const isCollaborator = + isCollaborativeTracksEnabled && + !!currentUserId && + (collaborators ?? []).some( + (collaborator) => collaborator.user_id === currentUserId + ) const hideFavorite = isUnlisted || !hasStreamAccess const hideRepost = isUnlisted || !isReachable || !hasStreamAccess const hideOverflow = !isReachable || (isUnlisted && !isOwner) @@ -497,7 +513,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( From 4634b7750235b231a501b7255c2740ce11704156 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Tue, 9 Jun 2026 12:46:35 -0700 Subject: [PATCH 07/19] =?UTF-8?q?fix(apps):=20CI=20=E2=80=94=20add=20colla?= =?UTF-8?q?borator=20notification=20types=20to=20SDK=20+=20import=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SDK: the generated Notification union/parser threw on unknown types and the adapter switch couldn't reference them. Add TrackCollaboratorNotification (+ Action/ActionData) generated models and wire them into Notification.ts (union + FromJSONTyped cases) so track_collaborator_invite/accept parse and typecheck. Hand-authored pending an OpenAPI regen. - Fix import/order lint in web Notification.tsx and TrackMenu.tsx, and mobile TrackScreenDetailsTile.tsx. Co-Authored-By: Claude Opus 4.8 --- .../track-screen/TrackScreenDetailsTile.tsx | 2 +- .../generated/default/models/Notification.ts | 10 +- .../models/TrackCollaboratorNotification.ts | 107 ++++++++++++++++++ .../TrackCollaboratorNotificationAction.ts | 99 ++++++++++++++++ ...TrackCollaboratorNotificationActionData.ts | 79 +++++++++++++ .../sdk/api/generated/default/models/index.ts | 3 + .../web/src/components/menu/TrackMenu.tsx | 2 +- .../Notification/Notification.tsx | 4 +- 8 files changed, 301 insertions(+), 5 deletions(-) create mode 100644 packages/sdk/src/sdk/api/generated/default/models/TrackCollaboratorNotification.ts create mode 100644 packages/sdk/src/sdk/api/generated/default/models/TrackCollaboratorNotificationAction.ts create mode 100644 packages/sdk/src/sdk/api/generated/default/models/TrackCollaboratorNotificationActionData.ts diff --git a/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx b/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx index e815a52b22b..7c670e31c06 100644 --- a/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx +++ b/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx @@ -16,7 +16,6 @@ import { useFeatureFlag, useGatedContentAccess } from '@audius/common/hooks' -import { FeatureFlags } from '@audius/common/services' import { Name, ShareSource, @@ -36,6 +35,7 @@ import type { User, TokenGatedConditions } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' import type { CommonState, PlaybackTrack } from '@audius/common/store' import { playbackSelectors, 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/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/web/src/components/menu/TrackMenu.tsx b/packages/web/src/components/menu/TrackMenu.tsx index 47f9ca45e3a..6187cc6e157 100644 --- a/packages/web/src/components/menu/TrackMenu.tsx +++ b/packages/web/src/components/menu/TrackMenu.tsx @@ -16,6 +16,7 @@ import { ID, Name } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' import { cacheCollectionsActions, tracksSocialActions, @@ -30,7 +31,6 @@ import { useHostRemixContestModal, QueueSource } from '@audius/common/store' -import { FeatureFlags } from '@audius/common/services' import { Genre, Nullable, route } from '@audius/common/utils' import { PopupMenuItem } from '@audius/harmony' import { pick } from 'lodash' diff --git a/packages/web/src/components/notification/Notification/Notification.tsx b/packages/web/src/components/notification/Notification/Notification.tsx index 837cb39d1e5..a1e5f35faf1 100644 --- a/packages/web/src/components/notification/Notification/Notification.tsx +++ b/packages/web/src/components/notification/Notification/Notification.tsx @@ -34,11 +34,11 @@ import { RemixCreateNotification } from './RemixCreateNotification' import { RepostNotification } from './RepostNotification' import { RepostOfRepostNotification } from './RepostOfRepostNotification' import { RequestManagerNotification } from './RequestManagerNotification' -import { TrackCollaboratorAcceptNotification } from './TrackCollaboratorAcceptNotification' -import { TrackCollaboratorInviteNotification } from './TrackCollaboratorInviteNotification' 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' From 03bf756d258002455b6427ca8583cb9164e94e21 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Tue, 9 Jun 2026 15:21:52 -0700 Subject: [PATCH 08/19] feat(apps): enable collaborative tracks by default + fix accept on private tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the collaborative_tracks feature flag and its gating everywhere; the feature is always on now (display, upload tagging, notifications, leave). - Fix the invite notification doing nothing on a private track: a pending collaborator can't load an unlisted track (the API filters it), so the `if (!track) return null` guard blanked the whole notification — including the Accept/Decline buttons. Render off the inviter alone, fall back to "a track" for the title, and act on the trackId carried by the notification. - Add success/error toasts (and disable-while-submitting) to Accept/Decline so it's no longer silent. Same null-guard relaxed on the accept notifications and the mobile equivalents. Co-Authored-By: Claude Opus 4.8 --- .../services/remote-config/feature-flags.ts | 6 +- .../src/components/user-link/TrackArtists.tsx | 8 +-- .../edit-track-screen/EditTrackForm.tsx | 7 +-- .../TrackCollaboratorAcceptNotification.tsx | 8 ++- .../TrackCollaboratorInviteNotification.tsx | 8 ++- .../track-screen/TrackScreenDetailsTile.tsx | 11 +--- .../edit/fields/TrackMetadataFields.tsx | 9 +-- .../web/src/components/link/TrackArtists.tsx | 10 +-- .../web/src/components/menu/TrackMenu.tsx | 6 -- .../TrackCollaboratorAcceptNotification.tsx | 7 ++- .../TrackCollaboratorInviteNotification.tsx | 61 +++++++++++++++---- 11 files changed, 73 insertions(+), 68 deletions(-) diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index 66bd8adfe4d..0583599ccfe 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -16,8 +16,7 @@ export enum FeatureFlags { COLLAPSED_EXPLORE_HEADER = 'collapsed_explore_header', LAUNCHPAD_VERIFICATION = 'launchpad_verification', FAN_CLUB_TEXT_POST_POSTING = 'fan_club_text_post_posting', - QUEUE_NEW_FEATURE_BADGE = 'queue_new_feature_badge', - COLLABORATIVE_TRACKS = 'collaborative_tracks' + QUEUE_NEW_FEATURE_BADGE = 'queue_new_feature_badge' } type FlagDefaults = Record @@ -50,6 +49,5 @@ export const flagDefaults: FlagDefaults = { [FeatureFlags.COLLAPSED_EXPLORE_HEADER]: false, [FeatureFlags.LAUNCHPAD_VERIFICATION]: true, [FeatureFlags.FAN_CLUB_TEXT_POST_POSTING]: false, - [FeatureFlags.QUEUE_NEW_FEATURE_BADGE]: false, - [FeatureFlags.COLLABORATIVE_TRACKS]: false + [FeatureFlags.QUEUE_NEW_FEATURE_BADGE]: false } diff --git a/packages/mobile/src/components/user-link/TrackArtists.tsx b/packages/mobile/src/components/user-link/TrackArtists.tsx index ce4f6073b87..bb08dc5553d 100644 --- a/packages/mobile/src/components/user-link/TrackArtists.tsx +++ b/packages/mobile/src/components/user-link/TrackArtists.tsx @@ -1,8 +1,6 @@ import { ComponentProps, Fragment } from 'react' -import { useFeatureFlag } from '@audius/common/hooks' import type { ID } from '@audius/common/models' -import { FeatureFlags } from '@audius/common/services' import type { IconSize } from '@audius/harmony-native' import { Flex, Text } from '@audius/harmony-native' @@ -18,15 +16,13 @@ type CollaboratorLinksProps = { /** * Renders accepted collaborators as comma-separated `", "` entries. - * Returns null when the collaborative-tracks flag is off or there are none, so - * it's a no-op append to an existing owner element. + * Returns null when there are none, so it's a no-op append to an owner element. */ export const CollaboratorLinks = ({ collaborators, badgeSize }: CollaboratorLinksProps) => { - const { isEnabled } = useFeatureFlag(FeatureFlags.COLLABORATIVE_TRACKS) - if (!isEnabled || !collaborators?.length) { + if (!collaborators?.length) { return null } return ( diff --git a/packages/mobile/src/screens/edit-track-screen/EditTrackForm.tsx b/packages/mobile/src/screens/edit-track-screen/EditTrackForm.tsx index f4a5b77a609..c777720867f 100644 --- a/packages/mobile/src/screens/edit-track-screen/EditTrackForm.tsx +++ b/packages/mobile/src/screens/edit-track-screen/EditTrackForm.tsx @@ -1,9 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { useUpdateTrack } from '@audius/common/api' -import { useFeatureFlag } from '@audius/common/hooks' import { DownloadQuality, Name } from '@audius/common/models' -import { FeatureFlags } from '@audius/common/services' import type { TrackForUpload } from '@audius/common/store' import { useWaitForDownloadModal, @@ -98,9 +96,6 @@ export const EditTrackForm = (props: EditTrackFormProps) => { const styles = useStyles() const navigation = useNavigation() const dispatch = useDispatch() - const { isEnabled: isCollaborativeTracksEnabled } = useFeatureFlag( - FeatureFlags.COLLABORATIVE_TRACKS - ) // Use track file selector directly like web version const { track: selectedTrack, selectFile } = useTrackFileSelector() @@ -364,7 +359,7 @@ export const EditTrackForm = (props: EditTrackFormProps) => { - {isCollaborativeTracksEnabled ? : null} + diff --git a/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorAcceptNotification.tsx b/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorAcceptNotification.tsx index 58da06263f5..6b3a49f830a 100644 --- a/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorAcceptNotification.tsx +++ b/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorAcceptNotification.tsx @@ -18,7 +18,8 @@ import { const messages = { title: 'Collaboration Accepted', - accepted: 'accepted your invitation to collaborate on' + accepted: 'accepted your invitation to collaborate on', + aTrack: 'a track' } type TrackCollaboratorAcceptNotificationProps = { @@ -38,7 +39,7 @@ export const TrackCollaboratorAcceptNotification = ( navigation.navigate(notification) }, [navigation, notification]) - if (!collaborator || !track) return null + if (!collaborator) return null return ( @@ -48,7 +49,8 @@ export const TrackCollaboratorAcceptNotification = ( - {messages.accepted} {track.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 index 878a2e3ec81..666d1f594ec 100644 --- a/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx +++ b/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx @@ -18,7 +18,8 @@ import { const messages = { title: 'Track Collaboration Invite', - invitedYou: 'invited you to collaborate on' + invitedYou: 'invited you to collaborate on', + aTrack: 'a track' } type TrackCollaboratorInviteNotificationProps = { @@ -38,7 +39,7 @@ export const TrackCollaboratorInviteNotification = ( navigation.navigate(notification) }, [navigation, notification]) - if (!inviter || !track) return null + if (!inviter) return null return ( @@ -48,7 +49,8 @@ export const TrackCollaboratorInviteNotification = ( - {messages.invitedYou} {track.title}. + {messages.invitedYou}{' '} + {track?.title ?? messages.aTrack}. diff --git a/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx b/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx index 7c670e31c06..a609336f8b5 100644 --- a/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx +++ b/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx @@ -11,11 +11,7 @@ import { useTrackPageLineup, getTrackPageLineupQueryKey } from '@audius/common/api' -import { - useCurrentTrack, - useFeatureFlag, - useGatedContentAccess -} from '@audius/common/hooks' +import { useCurrentTrack, useGatedContentAccess } from '@audius/common/hooks' import { Name, ShareSource, @@ -35,7 +31,6 @@ import type { User, TokenGatedConditions } from '@audius/common/models' -import { FeatureFlags } from '@audius/common/services' import type { CommonState, PlaybackTrack } from '@audius/common/store' import { playbackSelectors, @@ -219,12 +214,8 @@ export const TrackScreenDetailsTile = ({ } = track as Track const isOwner = ownerId === currentUserId - const { isEnabled: isCollaborativeTracksEnabled } = useFeatureFlag( - FeatureFlags.COLLABORATIVE_TRACKS - ) // Accepted collaborators (not the owner) can remove themselves from a track. const isCollaborator = - isCollaborativeTracksEnabled && !!currentUserId && (collaborators ?? []).some( (collaborator) => collaborator.user_id === currentUserId diff --git a/packages/web/src/components/edit/fields/TrackMetadataFields.tsx b/packages/web/src/components/edit/fields/TrackMetadataFields.tsx index c0af3c03894..f6e3df29e16 100644 --- a/packages/web/src/components/edit/fields/TrackMetadataFields.tsx +++ b/packages/web/src/components/edit/fields/TrackMetadataFields.tsx @@ -1,5 +1,3 @@ -import { useFeatureFlag } from '@audius/common/hooks' -import { FeatureFlags } from '@audius/common/services' import { Flex } from '@audius/harmony' import { MAX_DESCRIPTION_LENGTH } from '@audius/sdk' import { useField } from 'formik' @@ -17,9 +15,6 @@ const messages = { export const TrackMetadataFields = () => { const [{ value: index }] = useField('trackMetadatasIndex') - const { isEnabled: isCollaborativeTracksEnabled } = useFeatureFlag( - FeatureFlags.COLLABORATIVE_TRACKS - ) return ( @@ -41,9 +36,7 @@ export const TrackMetadataFields = () => { showMaxLength grows /> - {isCollaborativeTracksEnabled ? ( - - ) : null} + ) } diff --git a/packages/web/src/components/link/TrackArtists.tsx b/packages/web/src/components/link/TrackArtists.tsx index 853d3abbef4..0a1d32affaf 100644 --- a/packages/web/src/components/link/TrackArtists.tsx +++ b/packages/web/src/components/link/TrackArtists.tsx @@ -1,8 +1,6 @@ import { ComponentProps, Fragment } from 'react' -import { useFeatureFlag } from '@audius/common/hooks' import { ID } from '@audius/common/models' -import { FeatureFlags } from '@audius/common/services' import { Flex, Text } from '@audius/harmony' import { UserLink } from './UserLink' @@ -18,17 +16,15 @@ type TrackArtistsProps = { * A track's artist line: the owner plus accepted collaborators as a * comma-separated list on a single line that ellipsizes on overflow. * - * Collaborators render only when the `COLLABORATIVE_TRACKS` flag is enabled, so - * with the flag off this is equivalent to a single owner `` — making - * it a safe drop-in replacement everywhere the owner is currently shown. + * 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 { isEnabled } = useFeatureFlag(FeatureFlags.COLLABORATIVE_TRACKS) - const extraArtists = isEnabled ? (collaborators ?? []) : [] + const extraArtists = collaborators ?? [] if (extraArtists.length === 0) { return diff --git a/packages/web/src/components/menu/TrackMenu.tsx b/packages/web/src/components/menu/TrackMenu.tsx index 6187cc6e157..1e37e2d45f3 100644 --- a/packages/web/src/components/menu/TrackMenu.tsx +++ b/packages/web/src/components/menu/TrackMenu.tsx @@ -7,7 +7,6 @@ import { useToggleFavoriteTrack, useTrack } from '@audius/common/api' -import { useFeatureFlag } from '@audius/common/hooks' import { ShareSource, RepostSource, @@ -16,7 +15,6 @@ import { ID, Name } from '@audius/common/models' -import { FeatureFlags } from '@audius/common/services' import { cacheCollectionsActions, tracksSocialActions, @@ -149,9 +147,6 @@ const TrackMenu = ({ const { toast } = useContext(ToastContext) const dispatch = useDispatch() const { data: currentUserId } = useCurrentUserId() - const { isEnabled: isCollaborativeTracksEnabled } = useFeatureFlag( - FeatureFlags.COLLABORATIVE_TRACKS - ) const { mutate: rejectTrackCollaboration } = useRejectTrackCollaboration() const { onOpen: openDeleteTrackConfirmation } = useDeleteTrackConfirmationModal() @@ -170,7 +165,6 @@ const TrackMenu = ({ // Whether the current user is an accepted collaborator (not the owner), and // can therefore remove themselves from the track. const isCollaborator = - isCollaborativeTracksEnabled && !!currentUserId && (partialTrack?.collaborators ?? []).some( (collaborator) => collaborator.user_id === currentUserId diff --git a/packages/web/src/components/notification/Notification/TrackCollaboratorAcceptNotification.tsx b/packages/web/src/components/notification/Notification/TrackCollaboratorAcceptNotification.tsx index 1bf5cf2f268..5e3c6365d03 100644 --- a/packages/web/src/components/notification/Notification/TrackCollaboratorAcceptNotification.tsx +++ b/packages/web/src/components/notification/Notification/TrackCollaboratorAcceptNotification.tsx @@ -16,7 +16,8 @@ import { UserNameLink } from './components/UserNameLink' const messages = { title: 'Collaboration Accepted', - accepted: 'accepted your invitation to collaborate on' + accepted: 'accepted your invitation to collaborate on', + aTrack: 'a track' } type TrackCollaboratorAcceptNotificationProps = { @@ -38,7 +39,7 @@ export const TrackCollaboratorAcceptNotification = ( } }, [dispatch, track?.permalink]) - if (!collaborator || !track) return null + if (!collaborator) return null return ( @@ -49,7 +50,7 @@ export const TrackCollaboratorAcceptNotification = ( {' '} - {messages.accepted} {track.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 index f62d3ca336e..2e575688683 100644 --- a/packages/web/src/components/notification/Notification/TrackCollaboratorInviteNotification.tsx +++ b/packages/web/src/components/notification/Notification/TrackCollaboratorInviteNotification.tsx @@ -1,4 +1,4 @@ -import { MouseEvent, useCallback } from 'react' +import { MouseEvent, useCallback, useContext } from 'react' import { useTrack, @@ -10,6 +10,7 @@ import { TrackCollaboratorInviteNotification as TrackCollaboratorInviteNotificat 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' @@ -22,8 +23,14 @@ 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' + decline: 'Decline', + accepted: 'Collaboration accepted!', + declined: 'Invitation declined', + error: 'Something went wrong. Please try again.' } type TrackCollaboratorInviteNotificationProps = { @@ -36,10 +43,15 @@ export const TrackCollaboratorInviteNotification = ( 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 } = useAcceptTrackCollaboration() - const { mutate: rejectCollaboration } = useRejectTrackCollaboration() + const { mutate: acceptCollaboration, isPending: isAccepting } = + useAcceptTrackCollaboration() + const { mutate: rejectCollaboration, isPending: isDeclining } = + useRejectTrackCollaboration() + const isSubmitting = isAccepting || isDeclining const handleClick = useCallback(() => { if (track?.permalink) { @@ -50,20 +62,35 @@ export const TrackCollaboratorInviteNotification = ( const handleAccept = useCallback( (e: MouseEvent) => { e.stopPropagation() - acceptCollaboration({ trackId }) + acceptCollaboration( + { trackId }, + { + onSuccess: () => toast(messages.accepted), + onError: () => toast(messages.error) + } + ) }, - [acceptCollaboration, trackId] + [acceptCollaboration, trackId, toast] ) const handleDecline = useCallback( (e: MouseEvent) => { e.stopPropagation() - rejectCollaboration({ trackId }) + rejectCollaboration( + { trackId }, + { + onSuccess: () => toast(messages.declined), + onError: () => toast(messages.error) + } + ) }, - [rejectCollaboration, trackId] + [rejectCollaboration, trackId, toast] ) - if (!inviter || !track) return null + // 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 ( @@ -74,13 +101,23 @@ export const TrackCollaboratorInviteNotification = ( {' '} - {messages.invitedYou} {track.title}. + {messages.invitedYou} {track?.title ?? messages.aTrack}. - - From a5024976595553e12951d7984fdca6bfdbe8d7dc Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Tue, 9 Jun 2026 16:12:09 -0700 Subject: [PATCH 09/19] fix(apps): preserve track collaborators through SDK deserialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API returns `collaborators` on track responses, but the generated SDK Track/SearchTrack models' FromJSONTyped only copy known fields — so the field was silently dropped during deserialization, and the adapter's structural cast always read `undefined`. Result: accepted collaborators never rendered on track pages/tiles even though the backend returned them. Add `collaborators?: Array` to the generated Track and SearchTrack models (interface + FromJSONTyped + ToJSON, mirroring `user`) so the field survives, and read it directly in the adapter. Co-Authored-By: Claude Opus 4.8 --- packages/common/src/adapters/track.ts | 9 ++------- .../sdk/api/generated/default/models/SearchTrack.ts | 10 +++++++++- .../sdk/src/sdk/api/generated/default/models/Track.ts | 10 +++++++++- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/common/src/adapters/track.ts b/packages/common/src/adapters/track.ts index 7b5b770c54f..d5df49d420f 100644 --- a/packages/common/src/adapters/track.ts +++ b/packages/common/src/adapters/track.ts @@ -138,13 +138,8 @@ export const userTrackMetadataFromSDK = ( : null, track_segments: input.trackSegments.map(trackSegmentFromSDK), user, - // Accepted collaborator artists, same SDK shape as `user`. The API field is - // not yet in the generated SDK Track type, so it's accessed via a local - // extension; each is decoded/cleaned like the owner. - collaborators: transformAndCleanList( - (input as { collaborators?: (typeof input)['user'][] }).collaborators, - userMetadataFromSDK - ), + // Accepted collaborator artists, decoded/cleaned like the owner. + collaborators: transformAndCleanList(input.collaborators, userMetadataFromSDK), // Retypes license: (input.license as License) ?? null, 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..c902c6d7c41 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/SearchTrack.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/SearchTrack.ts @@ -219,11 +219,17 @@ export interface SearchTrack { */ title: string; /** - * + * * @type {User} * @memberof SearchTrack */ user: User; + /** + * Accepted collaborator artists on the track. + * @type {Array} + * @memberof SearchTrack + */ + collaborators?: Array; /** * * @type {number} @@ -670,6 +676,7 @@ 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)), 'duration': json['duration'], 'isDownloadable': json['is_downloadable'], 'playCount': json['play_count'], @@ -762,6 +769,7 @@ 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)), '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..a1ab30e090c 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/Track.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/Track.ts @@ -219,11 +219,17 @@ export interface Track { */ title: string; /** - * + * * @type {User} * @memberof Track */ user: User; + /** + * Accepted collaborator artists on the track. + * @type {Array} + * @memberof Track + */ + collaborators?: Array; /** * * @type {number} @@ -678,6 +684,7 @@ 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)), 'duration': json['duration'], 'isDownloadable': json['is_downloadable'], 'playCount': json['play_count'], @@ -771,6 +778,7 @@ 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)), 'duration': value.duration, 'is_downloadable': value.isDownloadable, 'play_count': value.playCount, From a2fb3871e096ae27baa43fa9e46a95bd0959139e Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Tue, 9 Jun 2026 16:43:19 -0700 Subject: [PATCH 10/19] chore(sdk): regenerate Track/SearchTrack from deployed spec Now that the API swagger declares `collaborators` (deployed), ran the real generator (typescript-fetch v7.5.0) against api.audius.co's spec. The output for Track.ts/SearchTrack.ts is byte-identical to the prior hand-edit (only a generator-emitted whitespace differs), confirming the field is now genuinely generated rather than hand-patched. Other models left untouched (unrelated spec drift not pulled in); the hand-authored notification models remain as-is. Co-Authored-By: Claude Opus 4.8 --- .../sdk/src/sdk/api/generated/default/models/SearchTrack.ts | 2 +- packages/sdk/src/sdk/api/generated/default/models/Track.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 c902c6d7c41..a80275aab59 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/SearchTrack.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/SearchTrack.ts @@ -219,7 +219,7 @@ export interface SearchTrack { */ title: string; /** - * + * * @type {User} * @memberof SearchTrack */ 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 a1ab30e090c..0303e43eca6 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/Track.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/Track.ts @@ -219,7 +219,7 @@ export interface Track { */ title: string; /** - * + * * @type {User} * @memberof Track */ From fe8cbd398f070bfa70d12b8c0a68bb556331db54 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Tue, 9 Jun 2026 17:05:10 -0700 Subject: [PATCH 11/19] fix(apps): preserve pending collaborator invites when editing a track The edit form seeded its collaborators field from the track's accepted-only `collaborators`, so saving dropped any still-pending invites (the ETL reconciles the metadata set). Initialize the field from accepted + pending instead. - SDK Track/SearchTrack: add `pendingCollaborators` (hand-edit stopgap; the API swagger now declares `pending_collaborators` in AudiusProject/api#947, so the next regen reproduces this). - Track model + adapter: expose `pending_collaborators` (owner-only). - Web + mobile edit screens: seed the collaborators field with `[...collaborators, ...pending_collaborators]` so pending invites survive a save. Display still uses accepted-only `collaborators` (no regression). Co-Authored-By: Claude Opus 4.8 --- packages/common/src/adapters/track.ts | 5 +++++ packages/common/src/models/Track.ts | 4 ++++ .../screens/edit-track-screen/EditTrackModalScreen.tsx | 7 +++++++ .../src/sdk/api/generated/default/models/SearchTrack.ts | 8 ++++++++ .../sdk/src/sdk/api/generated/default/models/Track.ts | 8 ++++++++ packages/web/src/pages/edit-page/EditTrackPage.tsx | 7 +++++++ 6 files changed, 39 insertions(+) diff --git a/packages/common/src/adapters/track.ts b/packages/common/src/adapters/track.ts index d5df49d420f..ad3c93cfb8e 100644 --- a/packages/common/src/adapters/track.ts +++ b/packages/common/src/adapters/track.ts @@ -140,6 +140,11 @@ export const userTrackMetadataFromSDK = ( 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, diff --git a/packages/common/src/models/Track.ts b/packages/common/src/models/Track.ts index cf90f16b6be..f2ca228d72a 100644 --- a/packages/common/src/models/Track.ts +++ b/packages/common/src/models/Track.ts @@ -267,6 +267,10 @@ export type TrackMetadata = { // 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/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/sdk/src/sdk/api/generated/default/models/SearchTrack.ts b/packages/sdk/src/sdk/api/generated/default/models/SearchTrack.ts index a80275aab59..7aef5bd7d7b 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/SearchTrack.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/SearchTrack.ts @@ -230,6 +230,12 @@ export interface SearchTrack { * @memberof SearchTrack */ collaborators?: Array; + /** + * Pending collaborator invites; only present on the owner's own tracks. + * @type {Array} + * @memberof SearchTrack + */ + pendingCollaborators?: Array; /** * * @type {number} @@ -677,6 +683,7 @@ export function SearchTrackFromJSONTyped(json: any, ignoreDiscriminator: boolean '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'], @@ -770,6 +777,7 @@ export function SearchTrackToJSON(value?: SearchTrack | null): any { '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 0303e43eca6..c5704ea52f4 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/Track.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/Track.ts @@ -230,6 +230,12 @@ export interface Track { * @memberof Track */ collaborators?: Array; + /** + * Pending collaborator invites; only present on the owner's own tracks. + * @type {Array} + * @memberof Track + */ + pendingCollaborators?: Array; /** * * @type {number} @@ -685,6 +691,7 @@ export function TrackFromJSONTyped(json: any, ignoreDiscriminator: boolean): Tra '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'], @@ -779,6 +786,7 @@ export function TrackToJSON(value?: Track | null): any { '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/web/src/pages/edit-page/EditTrackPage.tsx b/packages/web/src/pages/edit-page/EditTrackPage.tsx index d9bfe345448..5f0629c13c2 100644 --- a/packages/web/src/pages/edit-page/EditTrackPage.tsx +++ b/packages/web/src/pages/edit-page/EditTrackPage.tsx @@ -116,6 +116,13 @@ export const EditTrackPage = (props: EditPageProps) => { 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: { From 9cfd5b97ee071fe80be2684f7ab68d74b12d9fca Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Thu, 11 Jun 2026 11:13:06 -0700 Subject: [PATCH 12/19] feat(web): collaborators as its own settings box with the redesigned modal Match the "Add Collaborator" Figma flow and give collaborators its own box like the other upload settings: - CollaboratorsField is now a ContextualMenu box (like Visibility / Remix Settings) rendered in EditTrackForm's settings list, showing tagged collaborators as a preview and opening a modal. - New CollaboratorsMenuFields modal: "Add Collaborator to this Track" + invite copy, collaborator rows (artist + overflow menu to remove + "Invite Pending"), and a full-width "Add Collaborator" button that reveals an inline artist search with a results list. - Removed the old inline field from TrackMetadataFields. The form field shape (collaborators: UserMetadata[]) is unchanged, so the upload adapter and mobile field stay compatible. Co-Authored-By: Claude Opus 4.8 --- .../components/edit-track/EditTrackForm.tsx | 2 + .../edit/fields/CollaboratorsField.tsx | 175 +++++---------- .../edit/fields/CollaboratorsMenuFields.tsx | 199 ++++++++++++++++++ .../edit/fields/TrackMetadataFields.tsx | 2 - 4 files changed, 251 insertions(+), 127 deletions(-) create mode 100644 packages/web/src/components/edit/fields/CollaboratorsMenuFields.tsx diff --git a/packages/web/src/components/edit-track/EditTrackForm.tsx b/packages/web/src/components/edit-track/EditTrackForm.tsx index 9110a49be2e..fb594eb0f5d 100644 --- a/packages/web/src/components/edit-track/EditTrackForm.tsx +++ b/packages/web/src/components/edit-track/EditTrackForm.tsx @@ -33,6 +33,7 @@ import { MenuFormCallbackStatus } from 'components/data-entry/ContextualMenu' import { AnchoredSubmitRow } from 'components/edit/AnchoredSubmitRow' import { AnchoredSubmitRowEdit } from 'components/edit/AnchoredSubmitRowEdit' import { AdvancedField } from 'components/edit/fields/AdvancedField' +import { CollaboratorsField } from 'components/edit/fields/CollaboratorsField' import { MultiTrackSidebar } from 'components/edit/fields/MultiTrackSidebar' import { RemixSettingsField } from 'components/edit/fields/RemixSettingsField' import { StemsAndDownloadsField } from 'components/edit/fields/StemsAndDownloadsField' @@ -396,6 +397,7 @@ const TrackEditForm = ( }} /> + {isMultiTrack ? : null} diff --git a/packages/web/src/components/edit/fields/CollaboratorsField.tsx b/packages/web/src/components/edit/fields/CollaboratorsField.tsx index 1ac7bf3424e..bdaecc794d4 100644 --- a/packages/web/src/components/edit/fields/CollaboratorsField.tsx +++ b/packages/web/src/components/edit/fields/CollaboratorsField.tsx @@ -1,145 +1,70 @@ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo } 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 { UserMetadata } from '@audius/common/models' +import { Flex, IconUserGroup, Text } from '@audius/harmony' -import ArtistChip from 'components/artist/ArtistChip' -import { SearchUsersModal } from 'components/search-users-modal/SearchUsersModal' +import { ContextualMenu } from 'components/data-entry/ContextualMenu' +import { useTrackField } from 'components/edit-track/hooks' + +import { CollaboratorsMenuFields } from './CollaboratorsMenuFields' 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}` + description: 'Tag other artists as collaborators. Each is invited to accept.' } -type CollaboratorsFieldProps = { - name: string -} +type CollaboratorsFormValues = { collaborators: UserMetadata[] } /** - * 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. + * Settings box (like Visibility / Remix Settings) for tagging collaborator + * artists. Opens the "Add Collaborator" modal; the upload adapter maps the + * selected users to numeric ids for the on-chain metadata. */ -export const CollaboratorsField = ({ name }: CollaboratorsFieldProps) => { - const { color } = useTheme() - const [{ value }, , { setValue }] = useField(name) +export const CollaboratorsField = () => { + const [{ value }, , { setValue }] = + useTrackField('collaborators') 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 initialValues = useMemo(() => ({ collaborators }), [collaborators]) - const handleRemove = useCallback( - (userId: number) => { - setValue( - collaborators.filter((collaborator) => collaborator.user_id !== userId) - ) + const onSubmit = useCallback( + (values: CollaboratorsFormValues) => { + setValue(values.collaborators) }, - [collaborators, setValue] + [setValue] ) - const renderUser = useCallback( - (user: User) => ( - - handleAdd(user)} - /> - - ), - [handleAdd, color] - ) + const renderValue = useCallback(() => { + if (collaborators.length === 0) return null + return ( + + {collaborators.map((collaborator) => ( + + + {collaborator.name} + + + ))} + + ) + }, [collaborators]) return ( - - - - {messages.label} - - - {messages.description} - - - {collaborators.length > 0 ? ( - - {collaborators.map((collaborator) => ( - - - {collaborator.name} - - handleRemove(collaborator.user_id)} - /> - - ))} - - ) : null} - - setIsOpen(false)} - excludedUserIds={excludedUserIds} - renderUser={renderUser} - /> - + } + initialValues={initialValues} + onSubmit={onSubmit} + renderValue={renderValue} + menuFields={} + /> ) } diff --git a/packages/web/src/components/edit/fields/CollaboratorsMenuFields.tsx b/packages/web/src/components/edit/fields/CollaboratorsMenuFields.tsx new file mode 100644 index 00000000000..9aa74a1ddc3 --- /dev/null +++ b/packages/web/src/components/edit/fields/CollaboratorsMenuFields.tsx @@ -0,0 +1,199 @@ +import { useMemo, useState } from 'react' + +import { + useCurrentUserId, + useSearchUsersModal, + useUsers +} from '@audius/common/api' +import { User, UserMetadata } from '@audius/common/models' +import { + Box, + Button, + Flex, + IconButton, + IconKebabHorizontal, + IconPlus, + IconTrash, + PopupMenu, + Text, + TextInput +} from '@audius/harmony' +import { useField } from 'formik' +import { useDebounce } from 'react-use' + +import ArtistChip from 'components/artist/ArtistChip' + +const messages = { + title: 'Add Collaborator to this Track', + description: + "If they accept your invite, your track will be shared with their followers, and they'll be credited as an artist.", + search: 'Search for an artist', + add: 'Add Collaborator', + invitePending: 'Invite Pending', + remove: 'Remove', + options: (name: string) => `Options for ${name}` +} + +const SEARCH_DEBOUNCE_MS = 250 +const MAX_RESULTS = 6 + +type CollaboratorRowProps = { + user: UserMetadata + onRemove: () => void +} + +// A tagged collaborator: the artist, an overflow menu (remove), and the +// invite-pending status — mirrors the Figma "Add Collaborator" rows. +const CollaboratorRow = ({ user, onRemove }: CollaboratorRowProps) => ( + + } + /> + + , onClick: onRemove }]} + renderTrigger={(anchorRef, triggerPopup) => ( + triggerPopup()} + /> + )} + /> + + {messages.invitePending} + + + +) + +type CollaboratorSearchProps = { + excludedUserIds: number[] + onSelect: (user: User) => void +} + +// Inline artist search with a results list, shown when adding a collaborator. +const CollaboratorSearch = ({ + excludedUserIds, + onSelect +}: CollaboratorSearchProps) => { + const [query, setQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') + useDebounce(() => setDebouncedQuery(query), SEARCH_DEBOUNCE_MS, [query]) + + const { userIds } = useSearchUsersModal({ query: debouncedQuery }) + const excluded = useMemo(() => new Set(excludedUserIds), [excludedUserIds]) + const ids = useMemo( + () => userIds.filter((id) => !excluded.has(id)).slice(0, MAX_RESULTS), + [userIds, excluded] + ) + const { data: users } = useUsers(ids) + + return ( + + setQuery(e.target.value)} + /> + {users && users.length > 0 ? ( + + {users.map((user) => ( + onSelect(user)} + > + } + /> + + ))} + + ) : null} + + ) +} + +// Body of the Collaborators ContextualMenu modal. +export const CollaboratorsMenuFields = () => { + const [{ value }, , { setValue }] = useField('collaborators') + const collaborators = useMemo(() => value ?? [], [value]) + const { data: currentUserId } = useCurrentUserId() + const [isSearching, setIsSearching] = useState(collaborators.length === 0) + + const excludedUserIds = useMemo(() => { + const ids = collaborators.map((collaborator) => collaborator.user_id) + if (currentUserId) ids.push(currentUserId) + return ids + }, [collaborators, currentUserId]) + + const handleAdd = (user: User) => { + setValue([...collaborators, user]) + setIsSearching(false) + } + + const handleRemove = (userId: number) => { + setValue( + collaborators.filter((collaborator) => collaborator.user_id !== userId) + ) + } + + return ( + + + + {messages.title} + + + {messages.description} + + + + {collaborators.length > 0 ? ( + + {collaborators.map((collaborator) => ( + handleRemove(collaborator.user_id)} + /> + ))} + + ) : null} + + {isSearching ? ( + + ) : ( + + )} + + ) +} diff --git a/packages/web/src/components/edit/fields/TrackMetadataFields.tsx b/packages/web/src/components/edit/fields/TrackMetadataFields.tsx index f6e3df29e16..42ccfd2d5ef 100644 --- a/packages/web/src/components/edit/fields/TrackMetadataFields.tsx +++ b/packages/web/src/components/edit/fields/TrackMetadataFields.tsx @@ -5,7 +5,6 @@ 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' @@ -36,7 +35,6 @@ export const TrackMetadataFields = () => { showMaxLength grows /> - ) } From a42c3a602076b9f5f984ed2f8fee19cce886957f Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Thu, 11 Jun 2026 11:20:46 -0700 Subject: [PATCH 13/19] style(web): wrap PopupMenu items array to satisfy prettier --- .../src/components/edit/fields/CollaboratorsMenuFields.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/edit/fields/CollaboratorsMenuFields.tsx b/packages/web/src/components/edit/fields/CollaboratorsMenuFields.tsx index 9aa74a1ddc3..e599ca55dce 100644 --- a/packages/web/src/components/edit/fields/CollaboratorsMenuFields.tsx +++ b/packages/web/src/components/edit/fields/CollaboratorsMenuFields.tsx @@ -54,7 +54,9 @@ const CollaboratorRow = ({ user, onRemove }: CollaboratorRowProps) => ( /> , onClick: onRemove }]} + items={[ + { text: messages.remove, icon: , onClick: onRemove } + ]} renderTrigger={(anchorRef, triggerPopup) => ( Date: Thu, 11 Jun 2026 11:48:55 -0700 Subject: [PATCH 14/19] copy(web): shorten collaborators box description --- packages/web/src/components/edit/fields/CollaboratorsField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/components/edit/fields/CollaboratorsField.tsx b/packages/web/src/components/edit/fields/CollaboratorsField.tsx index bdaecc794d4..3f7e911edd7 100644 --- a/packages/web/src/components/edit/fields/CollaboratorsField.tsx +++ b/packages/web/src/components/edit/fields/CollaboratorsField.tsx @@ -10,7 +10,7 @@ import { CollaboratorsMenuFields } from './CollaboratorsMenuFields' const messages = { label: 'Collaborators', - description: 'Tag other artists as collaborators. Each is invited to accept.' + description: 'Tag other artists as collaborators' } type CollaboratorsFormValues = { collaborators: UserMetadata[] } From 75f3eda0d453a3cc08d9e79f1dfa434389263e1e Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Tue, 16 Jun 2026 13:25:31 -0700 Subject: [PATCH 15/19] fix(apps): tighten track collaborator flows --- packages/common/src/adapters/track.test.ts | 86 ++++++++++++++++++- packages/common/src/adapters/track.ts | 23 ++++- packages/common/src/hooks/index.ts | 1 + .../useAcceptedTrackCollaborationInvite.ts | 60 +++++++++++++ packages/common/src/utils/index.ts | 1 + .../src/utils/trackCollaboration.test.ts | 48 +++++++++++ .../common/src/utils/trackCollaboration.ts | 30 +++++++ .../EditTrackModalScreen.tsx | 9 +- .../TrackCollaboratorInviteNotification.tsx | 85 +++++++++++++++++- .../track-screen/TrackScreenDetailsTile.tsx | 22 ++--- .../artist/ArtistCardCover.module.css | 1 + .../src/components/artist/ArtistPopover.tsx | 15 +++- .../web/src/components/link/TrackArtists.tsx | 21 ++++- packages/web/src/components/link/UserLink.tsx | 3 +- .../TrackCollaboratorInviteNotification.tsx | 40 ++++++--- .../web/src/pages/edit-page/EditTrackPage.tsx | 13 ++- 16 files changed, 404 insertions(+), 54 deletions(-) create mode 100644 packages/common/src/hooks/useAcceptedTrackCollaborationInvite.ts create mode 100644 packages/common/src/utils/trackCollaboration.test.ts create mode 100644 packages/common/src/utils/trackCollaboration.ts diff --git a/packages/common/src/adapters/track.test.ts b/packages/common/src/adapters/track.test.ts index 0acd748422d..ccd54005560 100644 --- a/packages/common/src/adapters/track.test.ts +++ b/packages/common/src/adapters/track.test.ts @@ -1,9 +1,14 @@ -import { Genre } from '@audius/sdk' +import { Genre, Id, type Track as SdkTrack, type User } from '@audius/sdk' import { describe, expect, it } from 'vitest' +import type { UserMetadata } from '~/models' import type { TrackMetadataForUpload } from '~/store/upload/types' -import { trackMetadataForUploadToSdk } from './track' +import { + getTrackCollaboratorsForEdit, + trackMetadataForUploadToSdk, + userTrackMetadataFromSDK +} from './track' const makeMetadata = ( overrides: Partial = {} @@ -14,6 +19,73 @@ const makeMetadata = ( ...overrides }) as TrackMetadataForUpload +const makeUserMetadata = (id: number): UserMetadata => + ({ + user_id: id, + handle: `user-${id}`, + name: `User ${id}` + }) as UserMetadata + +const makeSdkUser = (id: number): User => + ({ + id: Id.parse(id), + handle: `user-${id}`, + name: `User ${id}` + }) as User + +const makeSdkTrack = (overrides: Partial = {}): SdkTrack => + ({ + id: Id.parse(1), + userId: Id.parse(1), + user: makeSdkUser(1), + title: 'Test Track', + genre: Genre.Electronic, + remixOf: { tracks: [] }, + fieldVisibility: { + mood: true, + tags: true, + genre: true, + share: true, + playCount: true, + remixes: true + }, + trackSegments: [], + followeeFavorites: [], + followeeReposts: [], + favoriteCount: 0, + ...overrides + }) as SdkTrack + +describe('getTrackCollaboratorsForEdit', () => { + it('includes accepted collaborators and pending invites', () => { + const accepted = makeUserMetadata(2) + const pending = makeUserMetadata(3) + + expect( + getTrackCollaboratorsForEdit({ + collaborators: [accepted], + pending_collaborators: [pending] + }) + ).toEqual([accepted, pending]) + }) +}) + +describe('userTrackMetadataFromSDK', () => { + it('adapts accepted collaborators and pending collaborators separately', () => { + const result = userTrackMetadataFromSDK( + makeSdkTrack({ + collaborators: [makeSdkUser(2)], + pendingCollaborators: [makeSdkUser(3)] + }) + ) + + expect(result?.collaborators?.map((user) => user.user_id)).toEqual([2]) + expect(result?.pending_collaborators?.map((user) => user.user_id)).toEqual([ + 3 + ]) + }) +}) + describe('trackMetadataForUploadToSdk', () => { it('forwards allowed_api_keys as allowedApiKeys', () => { const result = trackMetadataForUploadToSdk( @@ -52,4 +124,14 @@ describe('trackMetadataForUploadToSdk', () => { expect(result.genre).toBe(Genre.Electronic) }) + + it('maps selected collaborator users to user ids', () => { + const result = trackMetadataForUploadToSdk( + makeMetadata({ + collaborators: [makeUserMetadata(2), makeUserMetadata(3)] + }) + ) + + expect(result).toMatchObject({ collaborators: [2, 3] }) + }) }) diff --git a/packages/common/src/adapters/track.ts b/packages/common/src/adapters/track.ts index ad3c93cfb8e..368e019d5eb 100644 --- a/packages/common/src/adapters/track.ts +++ b/packages/common/src/adapters/track.ts @@ -23,7 +23,11 @@ import { StemCategory, TrackSegment } from '~/models' -import { StemTrackMetadata, UserTrackMetadata } from '~/models/Track' +import type { + StemTrackMetadata, + TrackMetadata, + UserTrackMetadata +} from '~/models/Track' import type { TrackMetadataForUpload } from '~/store/upload/types' import { formatMusicalKey, License, Maybe, squashNewLines } from '~/utils' import dayjs from '~/utils/dayjs' @@ -71,6 +75,18 @@ export const trackSegmentFromSDK = ({ multihash }) +type TrackCollaboratorMetadata = Pick< + TrackMetadata, + 'collaborators' | 'pending_collaborators' +> + +export const getTrackCollaboratorsForEdit = ( + track?: Partial | null +): NonNullable => [ + ...(track?.collaborators ?? []), + ...(track?.pending_collaborators ?? []) +] + export const userTrackMetadataFromSDK = ( input: Track | SearchTrack ): UserTrackMetadata | undefined => { @@ -139,7 +155,10 @@ export const userTrackMetadataFromSDK = ( track_segments: input.trackSegments.map(trackSegmentFromSDK), user, // Accepted collaborator artists, decoded/cleaned like the owner. - collaborators: transformAndCleanList(input.collaborators, userMetadataFromSDK), + collaborators: transformAndCleanList( + input.collaborators, + userMetadataFromSDK + ), // Pending invites (owner-only); lets the edit form preserve them on save. pending_collaborators: transformAndCleanList( input.pendingCollaborators, diff --git a/packages/common/src/hooks/index.ts b/packages/common/src/hooks/index.ts index a0cdc8a8448..25ff3b75afd 100644 --- a/packages/common/src/hooks/index.ts +++ b/packages/common/src/hooks/index.ts @@ -40,6 +40,7 @@ export * from './useGetDiscordOAuthLink' export * from './useAccountSwitcher' export * from './useAccountHasClaimableRewards' export * from './useAccessAndRemixSettings' +export * from './useAcceptedTrackCollaborationInvite' export * from './purchaseContent' export * from './content' export * from './chats' diff --git a/packages/common/src/hooks/useAcceptedTrackCollaborationInvite.ts b/packages/common/src/hooks/useAcceptedTrackCollaborationInvite.ts new file mode 100644 index 00000000000..223654476eb --- /dev/null +++ b/packages/common/src/hooks/useAcceptedTrackCollaborationInvite.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { useAppContext } from '~/context' +import { ID } from '~/models/Identifiers' +import { + getAcceptedTrackCollaborationStorageKey, + isAcceptedTrackCollaborationStorageValue +} from '~/utils/trackCollaboration' + +export const useAcceptedTrackCollaborationInvite = ( + userId: ID | null | undefined, + trackId: ID | null | undefined +) => { + const { localStorage } = useAppContext() + const storageKey = useMemo( + () => + userId && trackId + ? getAcceptedTrackCollaborationStorageKey(userId, trackId) + : null, + [trackId, userId] + ) + const [isMarkedAccepted, setIsMarkedAccepted] = useState(() => + storageKey + ? isAcceptedTrackCollaborationStorageValue( + localStorage.getItemSync(storageKey) + ) + : false + ) + + useEffect(() => { + let isActive = true + + if (!storageKey) { + setIsMarkedAccepted(false) + return + } + + const readAccepted = async () => { + const value = await localStorage.getItem(storageKey) + if (isActive) { + setIsMarkedAccepted(isAcceptedTrackCollaborationStorageValue(value)) + } + } + + readAccepted() + + return () => { + isActive = false + } + }, [localStorage, storageKey]) + + const markAccepted = useCallback(() => { + if (!storageKey) return + + setIsMarkedAccepted(true) + localStorage.setItem(storageKey, 'true') + }, [localStorage, storageKey]) + + return { isMarkedAccepted, markAccepted } +} diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index dcf86461580..6e6a3f7af0d 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -10,6 +10,7 @@ export * from './performance' export * from './reducer' export * from './selectorHelpers' export * from './timeUtil' +export * from './trackCollaboration' export * from './timingUtils' export * from './typeUtils' export * from './urlUtils' diff --git a/packages/common/src/utils/trackCollaboration.test.ts b/packages/common/src/utils/trackCollaboration.test.ts new file mode 100644 index 00000000000..47545290515 --- /dev/null +++ b/packages/common/src/utils/trackCollaboration.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { + getAcceptedTrackCollaborationStorageKey, + isAcceptedTrackCollaborationStorageValue, + isTrackCollaborationAccepted +} from './trackCollaboration' + +describe('isTrackCollaborationAccepted', () => { + it('returns true when the current user is an accepted collaborator', () => { + expect( + isTrackCollaborationAccepted( + { collaborators: [{ user_id: 2 }, { user_id: 3 }] }, + 3 + ) + ).toBe(true) + }) + + it('returns false when the current user is not an accepted collaborator', () => { + expect( + isTrackCollaborationAccepted({ collaborators: [{ user_id: 2 }] }, 3) + ).toBe(false) + }) + + it('returns false without a track, user, or collaborators', () => { + expect(isTrackCollaborationAccepted(undefined, 3)).toBe(false) + expect(isTrackCollaborationAccepted({ collaborators: undefined }, 3)).toBe( + false + ) + expect( + isTrackCollaborationAccepted({ collaborators: [{ user_id: 3 }] }, null) + ).toBe(false) + }) +}) + +describe('accepted track collaboration storage helpers', () => { + it('keys accepted invites by user and track', () => { + expect(getAcceptedTrackCollaborationStorageKey(1, 2)).toBe( + 'accepted-track-collaboration:1:2' + ) + }) + + it('only treats true as accepted', () => { + expect(isAcceptedTrackCollaborationStorageValue('true')).toBe(true) + expect(isAcceptedTrackCollaborationStorageValue('false')).toBe(false) + expect(isAcceptedTrackCollaborationStorageValue(null)).toBe(false) + }) +}) diff --git a/packages/common/src/utils/trackCollaboration.ts b/packages/common/src/utils/trackCollaboration.ts new file mode 100644 index 00000000000..0eaed9f89a6 --- /dev/null +++ b/packages/common/src/utils/trackCollaboration.ts @@ -0,0 +1,30 @@ +import type { ID } from '~/models/Identifiers' + +type TrackCollaborator = { + user_id: ID +} + +type TrackWithCollaborators = { + collaborators?: TrackCollaborator[] | null +} + +export const getAcceptedTrackCollaborationStorageKey = ( + userId: ID, + trackId: ID +) => `accepted-track-collaboration:${userId}:${trackId}` + +export const isAcceptedTrackCollaborationStorageValue = ( + value: string | null | undefined +) => value === 'true' + +export const isTrackCollaborationAccepted = ( + track: TrackWithCollaborators | null | undefined, + userId: ID | null | undefined +) => { + return ( + !!userId && + !!track?.collaborators?.some( + (collaborator) => collaborator.user_id === userId + ) + ) +} diff --git a/packages/mobile/src/screens/edit-track-screen/EditTrackModalScreen.tsx b/packages/mobile/src/screens/edit-track-screen/EditTrackModalScreen.tsx index 9a816940ac5..0b20e8181be 100644 --- a/packages/mobile/src/screens/edit-track-screen/EditTrackModalScreen.tsx +++ b/packages/mobile/src/screens/edit-track-screen/EditTrackModalScreen.tsx @@ -1,5 +1,6 @@ import { useCallback } from 'react' +import { getTrackCollaboratorsForEdit } from '@audius/common/adapters' import { useTrack, useUpdateTrack } from '@audius/common/api' import { SquareSizes } from '@audius/common/models' import type { TrackMetadataForUpload } from '@audius/common/store' @@ -45,13 +46,7 @@ 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 ?? []) - ], + collaborators: getTrackCollaboratorsForEdit(track), artwork: null, trackArtwork: trackImage && trackImage.source && isImageUriSource(trackImage.source) diff --git a/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx b/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx index 666d1f594ec..6a014bba2fd 100644 --- a/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx +++ b/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx @@ -1,11 +1,21 @@ import { useCallback } from 'react' -import { useTrack, useUser } from '@audius/common/api' +import { + useAcceptTrackCollaboration, + useCurrentUserId, + useRejectTrackCollaboration, + useTrack, + useUser +} from '@audius/common/api' +import { useAcceptedTrackCollaborationInvite } from '@audius/common/hooks' import type { TrackCollaboratorInviteNotification as TrackCollaboratorInviteNotificationType } from '@audius/common/store' +import { isTrackCollaborationAccepted } from '@audius/common/utils' +import type { GestureResponderEvent } from 'react-native' import { View } from 'react-native' -import { IconUserArrowRotate } from '@audius/harmony-native' +import { Button, Flex, IconUserArrowRotate } from '@audius/harmony-native' import { useNotificationNavigation } from 'app/hooks/useNotificationNavigation' +import { useToast } from 'app/hooks/useToast' import { NotificationHeader, @@ -19,7 +29,13 @@ import { const messages = { title: 'Track Collaboration Invite', invitedYou: 'invited you to collaborate on', - aTrack: 'a track' + aTrack: 'a track', + accept: 'Accept', + decline: 'Decline', + acceptedButton: 'Accepted', + accepted: 'Collaboration accepted!', + declined: 'Invitation declined', + error: 'Something went wrong. Please try again.' } type TrackCollaboratorInviteNotificationProps = { @@ -31,14 +47,56 @@ export const TrackCollaboratorInviteNotification = ( ) => { const { notification } = props const navigation = useNotificationNavigation() + const { toast } = useToast() const { data: inviter } = useUser(notification.inviterUserId) + const { data: currentUserId } = useCurrentUserId() + const { isMarkedAccepted, markAccepted } = + useAcceptedTrackCollaborationInvite(currentUserId, notification.trackId) const { data: track } = useTrack(notification.trackId) + const { mutate: acceptCollaboration, isPending: isAccepting } = + useAcceptTrackCollaboration() + const { mutate: rejectCollaboration, isPending: isDeclining } = + useRejectTrackCollaboration() + const isSubmitting = isAccepting || isDeclining + const isAccepted = + isMarkedAccepted || isTrackCollaborationAccepted(track, currentUserId) const handlePress = useCallback(() => { navigation.navigate(notification) }, [navigation, notification]) + const handleAccept = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation() + acceptCollaboration( + { trackId: notification.trackId }, + { + onSuccess: () => { + markAccepted() + toast({ content: messages.accepted }) + }, + onError: () => toast({ content: messages.error, type: 'error' }) + } + ) + }, + [acceptCollaboration, markAccepted, notification.trackId, toast] + ) + + const handleDecline = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation() + rejectCollaboration( + { trackId: notification.trackId }, + { + onSuccess: () => toast({ content: messages.declined }), + onError: () => toast({ content: messages.error, type: 'error' }) + } + ) + }, + [notification.trackId, rejectCollaboration, toast] + ) + if (!inviter) return null return ( @@ -53,6 +111,27 @@ export const TrackCollaboratorInviteNotification = ( {track?.title ?? messages.aTrack}. + + + {isAccepted ? null : ( + + )} + ) } diff --git a/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx b/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx index a609336f8b5..4066518431d 100644 --- a/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx +++ b/packages/mobile/src/screens/track-screen/TrackScreenDetailsTile.tsx @@ -58,7 +58,6 @@ import { } from '@audius/common/utils' import { encodeHashId } from '@audius/sdk' import type { FlatList } from 'react-native' -import { TouchableOpacity } from 'react-native' import { useDispatch, useSelector } from 'react-redux' import { @@ -89,7 +88,7 @@ import { TrackImage } from 'app/components/image/TrackImage' import { OfflineStatusRow } from 'app/components/offline-downloads' import { TrackDogEar } from 'app/components/track/TrackDogEar' import { TrackFlair, Size } from 'app/components/track-flair' -import { UserBadges } from 'app/components/user-badges' +import { TrackArtists } from 'app/components/user-link' import { useNavigation } from 'app/hooks/useNavigation' import { make, track as trackEvent } from 'app/services/analytics' import { makeStyles } from 'app/styles' @@ -381,7 +380,9 @@ export const TrackScreenDetailsTile = ({ startIndex: 0, querySource: hasLineup ? { - queryKey: [...getTrackPageLineupQueryKey(trackId)] as unknown[] + queryKey: [ + ...getTrackPageLineupQueryKey(trackId) + ] as unknown[] } : null }) @@ -625,14 +626,13 @@ export const TrackScreenDetailsTile = ({ {title} {user ? ( - - - - {user.name} - - - - + ) : null} {isLongFormContent && track ? ( diff --git a/packages/web/src/components/artist/ArtistCardCover.module.css b/packages/web/src/components/artist/ArtistCardCover.module.css index c98019bdfd0..9f47d026487 100644 --- a/packages/web/src/components/artist/ArtistCardCover.module.css +++ b/packages/web/src/components/artist/ArtistCardCover.module.css @@ -12,6 +12,7 @@ .coverPhotoContentContainer { position: relative; + z-index: 2; display: flex; align-items: flex-end; width: 100%; diff --git a/packages/web/src/components/artist/ArtistPopover.tsx b/packages/web/src/components/artist/ArtistPopover.tsx index 548cd0835e5..b1d8e64de80 100644 --- a/packages/web/src/components/artist/ArtistPopover.tsx +++ b/packages/web/src/components/artist/ArtistPopover.tsx @@ -1,6 +1,7 @@ import { ReactNode, useRef, useCallback, MutableRefObject } from 'react' -import { useCurrentUserId, useUserByHandle } from '@audius/common/api' +import { useCurrentUserId, useUser, useUserByHandle } from '@audius/common/api' +import { ID } from '@audius/common/models' import { Popup, type PopupProps, type Origin } from '@audius/harmony' import { useHoverDelay } from '@audius/harmony/src/hooks/useHoverDelay' import { CSSObject } from '@emotion/react' @@ -9,6 +10,7 @@ import { ArtistCard } from './ArtistCard' type ArtistPopoverProps = { handle: string | undefined + userId?: ID children: ReactNode onNavigateAway?: () => void mouseEnterDelay?: number @@ -39,6 +41,7 @@ const DEFAULT_TRANSFORM_ORIGIN: Origin = { export const ArtistPopover = ({ handle, + userId: artistUserId, children, onNavigateAway, mouseEnterDelay = 0.5, @@ -54,8 +57,12 @@ export const ArtistPopover = ({ const { isVisible, handleMouseEnter, handleMouseLeave, clearTimer } = useHoverDelay(mouseEnterDelay, 'hover') - const { data: creator } = useUserByHandle(handle) - const { data: userId } = useCurrentUserId() + const { data: creatorById } = useUser(artistUserId) + const { data: creatorByHandle } = useUserByHandle(handle, { + enabled: !artistUserId + }) + const creator = creatorById ?? creatorByHandle + const { data: currentUserId } = useCurrentUserId() const handleClose = useCallback(() => { clearTimer() @@ -63,7 +70,7 @@ export const ArtistPopover = ({ }, [clearTimer, onNavigateAway]) const content = - creator && userId !== creator.user_id ? ( + creator && currentUserId !== creator.user_id ? ( ) : null diff --git a/packages/web/src/components/link/TrackArtists.tsx b/packages/web/src/components/link/TrackArtists.tsx index 0a1d32affaf..63bdc259b12 100644 --- a/packages/web/src/components/link/TrackArtists.tsx +++ b/packages/web/src/components/link/TrackArtists.tsx @@ -33,15 +33,30 @@ export const TrackArtists = ({ return ( - + {extraArtists.map((collaborator) => ( , - + ))} diff --git a/packages/web/src/components/link/UserLink.tsx b/packages/web/src/components/link/UserLink.tsx index 87e94463ede..319fd66329e 100644 --- a/packages/web/src/components/link/UserLink.tsx +++ b/packages/web/src/components/link/UserLink.tsx @@ -83,7 +83,7 @@ export const UserLink = (props: UserLinkProps) => { lineHeight: 'normal', minWidth: 0, maxWidth: '100%', - overflow: 'hidden', + overflow: noOverflow ? 'visible' : 'hidden', width: fullWidth ? '100%' : undefined } const nameRowStyles: CSSObject = { @@ -143,6 +143,7 @@ export const UserLink = (props: UserLinkProps) => { overflow: noOverflow ? 'visible' : 'hidden' }} handle={handle} + userId={userId} > { if (track?.permalink) { @@ -65,12 +74,15 @@ export const TrackCollaboratorInviteNotification = ( acceptCollaboration( { trackId }, { - onSuccess: () => toast(messages.accepted), + onSuccess: () => { + markAccepted() + toast(messages.accepted) + }, onError: () => toast(messages.error) } ) }, - [acceptCollaboration, trackId, toast] + [acceptCollaboration, markAccepted, trackId, toast] ) const handleDecline = useCallback( @@ -107,19 +119,21 @@ export const TrackCollaboratorInviteNotification = ( - + {isAccepted ? null : ( + + )} diff --git a/packages/web/src/pages/edit-page/EditTrackPage.tsx b/packages/web/src/pages/edit-page/EditTrackPage.tsx index 5f0629c13c2..06a502fb2bd 100644 --- a/packages/web/src/pages/edit-page/EditTrackPage.tsx +++ b/packages/web/src/pages/edit-page/EditTrackPage.tsx @@ -1,6 +1,9 @@ import { createContext } from 'react' -import { fileToSdk } from '@audius/common/adapters' +import { + fileToSdk, + getTrackCollaboratorsForEdit +} from '@audius/common/adapters' import { useStems, useTrackByParams, useUpdateTrack } from '@audius/common/api' import { SquareSizes, StemUpload, TrackMetadata } from '@audius/common/models' import { @@ -116,13 +119,7 @@ export const EditTrackPage = (props: EditPageProps) => { 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 ?? []) - ], + collaborators: getTrackCollaboratorsForEdit(track), genre: (track?.genre as Genre) ?? '', mood: (track?.mood as Mood) ?? null, artwork: { From aaf5be5e32817483cbe0bfd10adaca92da7103c9 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Tue, 16 Jun 2026 13:35:47 -0700 Subject: [PATCH 16/19] fix(apps): refresh collaborator invite status --- packages/common/src/api/index.ts | 1 + .../common/src/api/tan-query/queryKeys.ts | 1 + .../tracks/useTrackCollaborationStatus.ts | 39 +++++++++++++++++++ .../TrackCollaboratorInviteNotification.tsx | 23 +++++++++-- .../TrackCollaboratorInviteNotification.tsx | 25 +++++++++--- 5 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 packages/common/src/api/tan-query/tracks/useTrackCollaborationStatus.ts diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index 77f624579ed..8ddf1e8d279 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -92,6 +92,7 @@ export * from './tan-query/tracks/useDownloadTrackStems' export * from './tan-query/tracks/useTrackDownloadCounts' export * from './tan-query/tracks/useFavoriteTrack' export * from './tan-query/tracks/useAcceptTrackCollaboration' +export * from './tan-query/tracks/useTrackCollaborationStatus' export * from './tan-query/tracks/useRejectTrackCollaboration' export * from './tan-query/tracks/useToggleFavoriteTrack' export * from './tan-query/tracks/useTrack' diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index 088f8a94e10..d2387368408 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -18,6 +18,7 @@ export const QUERY_KEYS = { trackCommentCount: 'trackCommentCount', trackDownloadCounts: 'trackDownloadCounts', track: 'track', + trackCollaborationStatus: 'trackCollaborationStatus', tracksByUser: 'tracksByUser', tracksByHandle: 'tracksByHandle', trackByPermalink: 'trackByPermalink', diff --git a/packages/common/src/api/tan-query/tracks/useTrackCollaborationStatus.ts b/packages/common/src/api/tan-query/tracks/useTrackCollaborationStatus.ts new file mode 100644 index 00000000000..90bca5b8aee --- /dev/null +++ b/packages/common/src/api/tan-query/tracks/useTrackCollaborationStatus.ts @@ -0,0 +1,39 @@ +import { Id } from '@audius/sdk' +import { useQuery } from '@tanstack/react-query' + +import { userMetadataListFromSDK } from '~/adapters/user' +import { useQueryContext } from '~/api/tan-query/utils' +import { ID } from '~/models/Identifiers' + +import { QUERY_KEYS } from '../queryKeys' + +export const getTrackCollaborationStatusQueryKey = ( + trackId: ID | null | undefined, + userId: ID | null | undefined +) => [QUERY_KEYS.trackCollaborationStatus, trackId, userId] + +export const useTrackCollaborationStatus = ( + trackId: ID | null | undefined, + userId: ID | null | undefined +) => { + const { audiusSdk } = useQueryContext() + + return useQuery({ + queryKey: getTrackCollaborationStatusQueryKey(trackId, userId), + queryFn: async () => { + if (!trackId || !userId) return false + + const sdk = await audiusSdk() + const { data } = await sdk.tracks.getBulkTracks({ + id: [Id.parse(trackId)], + userId: Id.parse(userId) + }) + const track = data?.[0] + const collaborators = userMetadataListFromSDK(track?.collaborators) + return collaborators.some( + (collaborator) => collaborator.user_id === userId + ) + }, + enabled: !!trackId && !!userId + }) +} diff --git a/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx b/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx index 6a014bba2fd..f810714a598 100644 --- a/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx +++ b/packages/mobile/src/screens/notifications-screen/Notifications/TrackCollaboratorInviteNotification.tsx @@ -1,10 +1,11 @@ -import { useCallback } from 'react' +import { useCallback, useEffect } from 'react' import { useAcceptTrackCollaboration, useCurrentUserId, useRejectTrackCollaboration, useTrack, + useTrackCollaborationStatus, useUser } from '@audius/common/api' import { useAcceptedTrackCollaborationInvite } from '@audius/common/hooks' @@ -53,6 +54,10 @@ export const TrackCollaboratorInviteNotification = ( const { data: currentUserId } = useCurrentUserId() const { isMarkedAccepted, markAccepted } = useAcceptedTrackCollaborationInvite(currentUserId, notification.trackId) + const { + data: isCollaborationAccepted, + isPending: isCollaborationStatusPending + } = useTrackCollaborationStatus(notification.trackId, currentUserId) const { data: track } = useTrack(notification.trackId) const { mutate: acceptCollaboration, isPending: isAccepting } = useAcceptTrackCollaboration() @@ -60,7 +65,17 @@ export const TrackCollaboratorInviteNotification = ( useRejectTrackCollaboration() const isSubmitting = isAccepting || isDeclining const isAccepted = - isMarkedAccepted || isTrackCollaborationAccepted(track, currentUserId) + isMarkedAccepted || + isCollaborationAccepted || + isTrackCollaborationAccepted(track, currentUserId) + const isCheckingAccepted = + !!currentUserId && isCollaborationStatusPending && !isAccepted + + useEffect(() => { + if (isCollaborationAccepted) { + markAccepted() + } + }, [isCollaborationAccepted, markAccepted]) const handlePress = useCallback(() => { navigation.navigate(notification) @@ -115,12 +130,12 @@ export const TrackCollaboratorInviteNotification = ( - {isAccepted ? null : ( + {isAccepted || isCheckingAccepted ? null : ( - {isAccepted ? null : ( + {isAccepted || isCheckingAccepted ? null : (