From 0b5b294940cc47cf9000557cd22ae30aff5b1ff3 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 29 May 2026 16:06:20 -0700 Subject: [PATCH] feat(genres): custom genres on mobile + relax common form/type gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile track upload/edit picker (SelectGenreScreen) was still a strict canonical-only ListSelectionScreen, blocking the custom-genres flow that shipped in #14424. Plus a write-side bug in the common upload form schema and missing GenreString re-export caught during the follow-up audit. Changes: - packages/mobile/src/screens/edit-track-screen/screens/SelectGenreScreen.tsx: rewrite to wrap ListSelectionScreen with disableSearch + a controlled header TextInput. When the typed input is non-empty and doesn't match any known label exactly, prepend a "Use \"X\" as a custom genre" item to the list. Tap commits via the existing onChange/setValue flow. maxLength caps at 100 chars to match the SDK/API limit. - packages/common/src/schemas/upload/uploadFormSchema.ts: GenreSchema changed from z.enum(Object.values(Genre)) to z.string().min(1).max(100). Previously rejected custom genres at form validation before they reached the SDK. - packages/common/src/utils/genres.ts: re-export GenreString from @audius/sdk so downstream consumers can opt into the loose type for read-side / metadata paths. - packages/mobile/examples/upload/App.tsx: drop the inline hardcoded GENRES chip list and use a plain TextInput. Reference example for SDK consumers no longer implies "genres must be from this list." Filter/state typings (Search, trending/types, trending/actions, useTrending, useSearchResults, quickSearch, Analytics) kept as Genre — these are write-side / filter-selection paths where users pick from the canonical list. typecheck clean on common, web, and mobile. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/schemas/upload/uploadFormSchema.ts | 13 ++-- packages/common/src/utils/genres.ts | 9 +++ packages/mobile/examples/upload/App.tsx | 66 +++++------------- .../screens/SelectGenreScreen.tsx | 69 +++++++++++++++++-- 4 files changed, 97 insertions(+), 60 deletions(-) diff --git a/packages/common/src/schemas/upload/uploadFormSchema.ts b/packages/common/src/schemas/upload/uploadFormSchema.ts index 3537e9e4534..ef85f513bbb 100644 --- a/packages/common/src/schemas/upload/uploadFormSchema.ts +++ b/packages/common/src/schemas/upload/uploadFormSchema.ts @@ -1,4 +1,4 @@ -import { Genre, Mood, NativeFile, MAX_DESCRIPTION_LENGTH } from '@audius/sdk' +import { Mood, NativeFile, MAX_DESCRIPTION_LENGTH } from '@audius/sdk' import { z } from 'zod' import { imageBlank } from '~/assets' @@ -54,11 +54,16 @@ const USDCPurchaseConditionsSchema = z }) .strict() -/** Same as SDK. */ +/** + * Same as SDK: arbitrary string up to 100 chars. The Genre enum is reserved + * for autocomplete suggestions; users may submit any value. + */ const GenreSchema = z - .enum(Object.values(Genre) as [Genre, ...Genre[]]) + .string() + .min(1, { message: messages.genreRequiredError }) + .max(100) .nullable() - .refine((val) => val !== null, { + .refine((val) => val !== null && val.length > 0, { message: messages.genreRequiredError }) diff --git a/packages/common/src/utils/genres.ts b/packages/common/src/utils/genres.ts index 5e779f12a0a..0a2c441dcbb 100644 --- a/packages/common/src/utils/genres.ts +++ b/packages/common/src/utils/genres.ts @@ -3,6 +3,15 @@ import { Genre as SDKGenre } from '@audius/sdk' /** Re-export SDK Genre as the canonical source for track metadata. */ export { Genre } from '@audius/sdk' +/** + * Track genre value. Any string up to 100 chars is accepted at the API/SDK + * layer; the {@link Genre} enum provides the canonical autocomplete values. + * Use this type for read-side / metadata typings so custom-genre tracks + * flow through without TS errors. Continue using `Genre` for write-side + * autocomplete sources and static lists where only known values are valid. + */ +export type { GenreString } from '@audius/sdk' + /** * UI-only value for "all genres" filter (e.g. trending page). * Not part of SDK Genre - use for filter state only. diff --git a/packages/mobile/examples/upload/App.tsx b/packages/mobile/examples/upload/App.tsx index 0814fb4835b..726f1ce8785 100644 --- a/packages/mobile/examples/upload/App.tsx +++ b/packages/mobile/examples/upload/App.tsx @@ -22,18 +22,10 @@ import { import { config } from './src/config' import { getSDK } from './src/sdk' -const GENRES = [ - 'Electronic', - 'Rock', - 'Hip-Hop/Rap', - 'Pop', - 'R&B/Soul', - 'Alternative', - 'Country', - 'Jazz', - 'Folk', - 'Classical' -] as const +// Genre is a freeform string up to 100 chars at the API/SDK layer. See +// `Genre` from @audius/sdk for the canonical autocomplete suggestions if +// you want to show a picker; this example uses a plain text input. +const MAX_GENRE_LENGTH = 100 type Screen = 'home' | 'signed-in' @@ -46,7 +38,7 @@ export default function App() { useState(null) const [coverUri, setCoverUri] = useState(null) const [title, setTitle] = useState('') - const [genre, setGenre] = useState<(typeof GENRES)[number]>('Electronic') + const [genre, setGenre] = useState('') const [description, setDescription] = useState('') const [uploading, setUploading] = useState(false) const [result, setResult] = useState(null) @@ -131,7 +123,7 @@ export default function App() { setAudioFile(null) setCoverUri(null) setTitle('') - setGenre('Electronic') + setGenre('') setDescription('') setResult(null) setScreen('home') @@ -302,31 +294,16 @@ export default function App() { /> Genre - - {GENRES.map((g) => ( - setGenre(g)} - > - - {g} - - - ))} - + `Use "${value}" as a custom genre` } -const genres = GENRES.map((genre) => ({ +const knownGenres = GENRES.map((genre) => ({ value: convertGenreLabelToValue(genre), label: genre })) +const useStyles = makeStyles(({ spacing, typography }) => ({ + searchInput: { + paddingVertical: spacing(3), + fontSize: typography.fontSize.large + } +})) + export const SelectGenreScreen = () => { - const [{ value }, , { setValue }] = useField('genre') + const [{ value }, , { setValue }] = useField('genre') + const [input, setInput] = useState('') + const styles = useStyles() + + const trimmed = input.trim() + const lower = trimmed.toLowerCase() + + const filtered = useMemo(() => { + if (trimmed === '') return knownGenres + return knownGenres.filter( + (g) => + g.label.toLowerCase().includes(lower) || + g.value.toLowerCase().includes(lower) + ) + }, [trimmed, lower]) + + const data = useMemo(() => { + if (trimmed === '') return knownGenres + const matchesKnownExactly = knownGenres.some( + (g) => + g.label.toLowerCase() === lower || g.value.toLowerCase() === lower + ) + if (matchesKnownExactly || trimmed.length > MAX_GENRE_LENGTH) { + return filtered + } + return [ + { value: trimmed, label: messages.useCustom(trimmed) }, + ...filtered + ] + }, [trimmed, lower, filtered]) return ( ( {item.label} @@ -28,7 +71,21 @@ export const SelectGenreScreen = () => { )} screenTitle={messages.screenTitle} icon={IconGenre} - searchText={messages.searchText} + disableSearch + header={ + + + + } value={value} onChange={setValue} />