feat(web): row-click selection on playlist tracks in edit mode#14324
Open
dylanjeffers wants to merge 60 commits into
Open
feat(web): row-click selection on playlist tracks in edit mode#14324dylanjeffers wants to merge 60 commits into
dylanjeffers wants to merge 60 commits into
Conversation
🦋 Changeset detectedLatest commit: ae486de The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
46c2b73 to
c539a7b
Compare
13ec3ba to
8cc78cc
Compare
Contributor
🌐 Web preview readyPreview URL: https://audius-web-preview-pr-14324.audius.workers.dev Unique preview for this PR (deployed from this branch). |
## Summary The kebab handler on mobile comments toggled `isOpen` and `isVisible` together, but those two pieces of state intentionally serve different roles: - `isOpen` drives the open/close **animation** - `isVisible` controls whether the drawer is **mounted** (kept true during the close animation, set to false in `onClosed`) Because `onClose` and `onClosed` flip them on separate ticks, there is a brief window during the close animation where `isOpen=false` but `isVisible=true`. A tap on the kebab during that window inverts both via the toggle, leaving the drawer **unmounted while state thinks it's open**. The next tap then has to dig itself out of the broken state, so the kebab appears not to respond. The fix is to make `handlePress` explicitly **open** the menu. Closing is already fully owned by the drawer's `onClose` / `onClosed` callbacks (row tap, backdrop tap, or swipe-down). ## Test plan - [ ] Tap kebab on your own comment → drawer appears with Edit / Delete options - [ ] Tap kebab on another user's comment → drawer appears with Flag, Mute User, etc. - [ ] Tap a row → drawer closes; tap kebab again → drawer reopens on the first tap - [ ] Tap backdrop to dismiss; tap kebab → drawer reopens on the first tap - [ ] Tap kebab rapidly while drawer is animating closed → drawer always opens cleanly, never stuck 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary
- Drops every Sentry-related dependency (`@sentry/browser`,
`@sentry/node`, `@sentry/cli`, `redux-sentry-middleware`,
`@types/redux-sentry-middleware`, `toucan-js`) and the
`redux-sentry-middleware` wiring in the web store.
- Deletes the dedicated Sentry files
(`packages/web/src/services/sentry.ts`,
`packages/web/src/store/errors/reportToSentry.ts`,
`packages/mobile/src/app/sentry.ts`,
`packages/mobile/src/utils/reportToSentry.ts`), the iOS dSYM upload
script and `sentry.properties` for both platforms.
- Replaces ~100 `reportToSentry({...})` call sites with
`console.error(...)` (or removes them entirely when they were the only
line in a catch). Removes the `sentry` / `reportToSentry` properties
from `CommonStoreContext` and `QueryContext`, the `Feature` /
`ErrorLevel` / `ReportToSentryArgs` types, and the `SENTRY_DSN` field
from `Env` + the web/mobile env files.
- Strips Sentry steps from `.github/workflows/{web,mobile}.yml` and the
matching `.circleci/src/*.yml` sources (the generated
`.circleci/config.yml` was already clean). Removes the `sentryDSN`
convict knob and `configureSentry()` call from `identity-service`.
## Test plan
- [x] `tsc --noEmit` passes clean for `packages/common`, `packages/web`,
and `packages/mobile` (2 pre-existing identity-service errors are
unrelated to this change).
- [x] `eslint --ext=ts,tsx src` passes clean for `packages/common`,
`packages/web`, and `packages/mobile`.
- [ ] Smoke test the web app: trigger an error path (e.g. failed comment
post) and confirm it surfaces as `console.error` rather than a Sentry
capture, with no runtime errors.
- [ ] Smoke test the mobile error boundary: force a render error and
confirm the toast + `console.error` still fire.
- [ ] CI: confirm the `web-deploy-sentry-sourcemaps` job no longer runs
and that the mobile build no longer attempts to install `sentry-cli` or
upload dSYMs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary
Fixes reports of the left-drawer profile picture not loading on mobile.
The drawer's `<ProfilePicture />` was dropping the `onError` callback
returned by `useProfilePicture`, so render-time image failures never
reached `useImageSize` — the mirror retry could only fire on prefetch
failures. A slow Open Audio Validator Node that accepted the prefetch
but stalled the actual render would leave the avatar hung indefinitely
(the OS TCP timeout is 60–90s with no `onError` fired).
### Changes
- **`ProfilePicture`** — forward `onError` (chained with the caller's),
pass `priorityLowResSource` through, and set `timeoutMs={3000}` so
stalled URLs advance to the next mirror.
- **`UserImage`** — add matching `timeoutMs={3000}` for parity.
- **harmony-native `Image`** — add optional `timeoutMs` prop (default
`0` = disabled, preserving existing behavior everywhere else). When `>
0` and the source is a remote URI, synthesize an `onError` if neither
`onLoad` nor `onError` fires within the window. Mirrors the web
`MirrorImage` pattern.
- **`Artwork`** — plumb `timeoutMs` through so `Avatar → Artwork →
Image` is wired end-to-end.
### Why this works
`useImageSize` already has all the mirror-retry logic: it tracks
`failedUrls`, swaps in the next mirror's hostname via
`getNextMirrorUrl`, and re-renders. It just needed render-time failures
(including timeouts) to be reported back. With the `timeoutMs={3000}`
opt-in, hung connections become synthetic `onError`s after 3s, matching
the global pattern documented for the web client.
The fix is scoped: harmony `Image`'s default is `timeoutMs={0}` (off),
so unrelated image usages across the app keep their existing semantics.
## Test plan
- [ ] Open the app on mobile (cold start), open the left drawer, confirm
the avatar loads
- [ ] Throttle a content node hostname locally (e.g. via Charles / hosts
file → blackhole) and confirm the avatar still resolves via a mirror
within ~3s instead of hanging
- [ ] Confirm track tiles, playlist artwork, and other `Artwork`/`Image`
usages render normally (they default to `timeoutMs=0`, so behavior is
unchanged)
- [ ] Confirm progressive loading still works (low-res placeholder →
high-res crossfade) for profile pictures
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…OfVersions (#14355) ## Summary - `getCurrentVersion` on the ServiceTypeManager contract was returning a stale version (1.0.0) for the `Validator` service type — the home page and other locations using `useCurrentVersion(ServiceType.Validator)` displayed the wrong number. - Switch `fetchCurrentVersion` to read the latest registered version by calling `getNumberOfVersions(serviceType)` and then `getVersion(serviceType, numberOfVersions - 1)`. The latest onchain validator version (1.1.0) now shows correctly. ## Test plan - [ ] Open the dashboard logged out — verify the `Register a Node` card shows the latest onchain version (currently `1.1.0`) instead of `1.0.0`. - [ ] Open a user/services page that renders `RegisterNodeCard` — verify the same. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…letons (#14351) ## Summary The web Feed (and any other `TrackLineup`-driven page: Trending, Profile Tracks/Reposts, Search, Listening History, Remixes, Contest Submissions) felt like pagination was lagging: as you scroll down you'd see skeletons appear at the bottom, but the next-page request didn't actually fire until you scrolled *further past* them. Root cause: regressed in [apps#14286](#14286). The bottom-of-list skeletons were changed from being gated on `isFetching` to being gated on `hasNextPage`, which means they render persistently any time there's more to load — not just during an in-flight fetch. With `~15` skeletons at `~184px` each, they pad the `<InfiniteScroll>`'s content height by `~2` viewports. `react-infinite-scroller` measures distance to the bottom of *all* rendered content, so the threshold (also `2 * viewport`) only fires after you've scrolled through both the loaded tiles **and** the skeleton padding. The skeletons read as "loading" but no fetch had been kicked off yet. This PR: - Restores the original "skeletons only while a page is in flight" condition (`isFetching || isLoadMoreTriggered`), matching the empty-state branch a few lines above. The bottom of the InfiniteScroll content is now the bottom of the loaded tiles, so the existing 2-viewport threshold actually fires ~2 viewports before the last tile. - Corrects `APPROX_TILE_HEIGHT_LARGE` from `124` → `184` (the desktop tile is 144 body + 24 `mb='l'` + 16 parent `gap='m'`) so the in-flight skeleton count actually fills the threshold area as the comment claims. ## Test plan - [x] Open the Feed on web, scroll down — the next-page request fires while the bottom of the loaded tiles is still ~2 viewports below your viewport (check Network panel for `feed` calls), with skeletons appearing only after the request is in flight. - [x] Same on Trending (Week / Month / All-Time). - [x] Same on Profile Tracks / Reposts, Search Tracks, Listening History, Track Remixes, Contest Submissions. - [ ] Mobile-web (`useWindow` path) at a phone-sized viewport — fetch still triggers smoothly, no persistent skeleton padding. - [x] Empty state still renders when a tab has no tracks. - [x] Resizing the browser between pages still updates the threshold via `ResizeObserver` (no regression to existing behavior). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
… fetch actually runs (#14356) ## Summary Follow-up to #14355. The dashboard was still showing `1.0.0` for the Validator current version after that PR shipped. Root cause: the `getCurrentVersion` selector returned `'1.0.0'` as a fallback when `services.validator.currentVersion` was undefined, and `useCurrentVersion` only dispatches `fetchCurrentVersion` when the selected value is `undefined`. The hardcoded fallback short-circuited the dispatch, so the new `getNumberOfVersions` + `getVersion` logic introduced in #14355 never ran. - Remove the `?? '1.0.0'` fallback from the selector so the fetch dispatches. - Harden the `setCurrentVersion` reducer to initialize `state.services.validator` if it doesn't exist yet, since `fetchCurrentVersion` can resolve before `fetchServiceStakeValues` populates it. ## Test plan - [ ] Open the dashboard and confirm `Register a Node` → `Current Version` shows the latest onchain version (currently `1.1.0`) instead of `1.0.0`. - [ ] Hard refresh and confirm no race-condition crashes from `setCurrentVersion` running before service info loads. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## Summary
Replaces the AirPlay/Chromecast settings toggle and the grayed-out cast
icon with a Spotify-style "Connect" picker behind a single cast button
on **both mobile and web**.
### Mobile (now-playing drawer)
- New `IconCast` button opens a `ConnectDrawer` listing:
1. **This Device** — ends any active chromecast session
2. **AirPlay & Bluetooth** on iOS (`openAirplayDialog`) / **Bluetooth**
on Android (`Linking.sendIntent('android.settings.BLUETOOTH_SETTINGS')`
with `openSettings` fallback)
3. Each chromecast device discovered via `useDevices()` — tap starts a
session via `SessionManager.startSession(deviceId)`
- Cast button is grayed/disabled only when offline AND no chromecast
devices discovered
- The active device gets a primary tint + checkmark; the same goes for
"This Device" when nothing is casting
### Web (desktop play bar)
- New cast button placed next to the queue button
- "Connect" popup with **This web browser** + **Google Cast devices**
rows
- "Google Cast devices" calls `audio.remote.prompt()` to surface
**Chrome's built-in cast picker** — no Cast Web Sender receiver needed
- Hidden entirely when `RemotePlayback` isn't supported (non-Chromium
browsers)
### Settings
- Removed `CastSettingsRow` (the AirPlay/Chromecast segmented control).
The user no longer chooses a method up-front — they pick a target each
time from the drawer.
### Harmony
- New `IconCast` (Spotify-style laptop + speaker) and `IconCastSpeaker`
(standalone speaker for device rows). Existing `IconCastAirplay` /
`IconCastChromecast` left untouched.
### Redux (`@audius/common/store/cast`)
- Dropped `method`, `updateMethod`, `CastMethod` type, `CAST_METHOD`
storage key, and both persistence sagas
- Kept `isCasting` and added an optional `deviceName` so the drawer can
mark the active device. `GoogleCast.tsx` resolves
`castSession.getCastDevice().friendlyName` and `Airplay.tsx` passes the
audio route's `portName`
## Test plan
- [ ] **Mobile iOS**: tap cast icon on the now-playing drawer → drawer
shows This Device + AirPlay & Bluetooth + chromecast devices. AirPlay
row opens the system picker. Selecting a chromecast device starts a
session and the icon turns active.
- [ ] **Mobile Android**: same flow but second row is labeled
"Bluetooth" and opens `android.settings.BLUETOOTH_SETTINGS`.
- [ ] **Mobile offline**: cast icon is grayed/disabled.
- [ ] **Mobile**: "This Device" row ends an active chromecast session
and the audio resumes locally.
- [x] **Web (Chrome)**: cast icon appears between volume and queue.
Popup shows This web browser + Google Cast devices. Clicking the
cast-devices row opens Chrome's native picker.
- [ ] **Web (Safari/Firefox)**: cast button is hidden (no
`RemotePlayback`).
- [ ] **Settings**: the AirPlay/Chromecast segmented control is gone
from the iOS settings screen.
## Notes
- No native iOS / Android code changes — existing Bonjour entries
(`Info.plist`) and the cast receiver `222B31C8` (`AndroidManifest.xml`)
are untouched.
- Mobile typecheck/lint baseline is broken in the worktree environment
(missing `@react-native/typescript-config/tsconfig.json` in
node_modules; ~13k pre-existing errors), so a clean baseline-vs-after
comparison wasn't possible locally. The one real type issue surfaced —
narrowing `isCasting && ...` in the drawer — is fixed with
`Boolean(...)`.
- Web `cast-button/` + `PlayBar.tsx` pass ESLint cleanly.
- Web dev server couldn't be started in the worktree
(`packages/web/env/` missing), so no browser screenshot of the new
button.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## Summary Fully removes FingerprintJS from the codebase — web, mobile, common, libs, identity service, and the anti-abuse-oracle. - **Web/mobile clients**: deleted `services/fingerprint.ts`, dropped `fingerprintClient` from the store context, and stopped collecting a `visitorId` during sign-in / OAuth login (`FINGERPRINT_*` env vars removed). - **Common**: deleted `services/fingerprint/FingerprintClient`, removed it from `storeContext` and `services/index.ts`, dropped `visitorId` from `authService.signIn`. - **Identity service**: removed `fpClient.js`, `fpHelpers.js`, the `/fp` route, the `Fingerprints` model (table left in place; can be dropped via a follow-up migration), and the `fpServerApiKey` config. `requiresOtp` no longer takes a `visitorId` — new devices always require OTP; the `OTP_BYPASS_EMAILS` list still bypasses. `authentication.js` and `idSignals.js` cleaned up accordingly. Updated `test/lib/app.js` and removed the obsolete "skips otp for recognized devices" test. - **Anti-abuse-oracle**: removed `userFingerprints` and `useFingerprintDeviceCount` from `identity.ts`, dropped the fingerprint device-count term from `getUserNormalizedScore`, and removed the Fingerprints table / column from the user-attestation UI in `server.tsx`. - **Libs (sdk-legacy)**: dropped the now-unused `visitorId` parameter from `Account.login` and `IdentityService.getFn`. - **Dependencies**: removed `@fingerprintjs/fingerprintjs-pro`, `@fingerprintjs/fingerprintjs-pro-react-native`, and `@fingerprintjs/fingerprintjs-pro-server-api` from web/common/mobile/identity-service `package.json`s; `package-lock.json` regenerated. The `Fingerprints` Sequelize migration is left in place — the table can be dropped in a separate follow-up. ## Test plan - [ ] Sign in with a new device → OTP is required (no fingerprint bypass). - [ ] Sign in with an `OTP_BYPASS_EMAILS` address → no OTP (bypass still works). - [ ] OAuth login flow still completes for an existing user. - [ ] Mobile sign-in and confirm-email screens still submit successfully. - [ ] Anti-abuse-oracle attestation UI loads for a user (no Fingerprint column / section). - [ ] CI: typecheck + lint pass across `@audius/web`, `@audius/mobile`, `@audius/common`, `@audius/sdk-legacy`, identity-service, and anti-abuse-oracle. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary - Adds a now-playing indicator to track rows in playlist / track list views on mobile - Active track row now uses a subtle purple background tint (`rgba(130,86,220,0.07)`) and a purple title color (`#8256DC`) - While the track is playing, the artwork shows an animated equalizer overlay (4 bars, `#CC5DE8`, height ~4→18px, staggered durations / delays) using React Native `Animated` with `useNativeDriver: false` - New reusable `AnimatedEqBars` component lives next to `TrackArtwork` in `packages/mobile/src/components/track-list/` The paused-but-active state (active row, not currently playing) still shows the existing play-icon overlay — only the playing state swaps in the equalizer animation. ## Files changed - `packages/mobile/src/components/track-list/AnimatedEqBars.tsx` (new) - `packages/mobile/src/components/track-list/TrackArtwork.tsx` - `packages/mobile/src/components/track-list/TrackListItem.tsx` ## Test plan - [ ] Open a playlist on iOS, tap a track to play it - [ ] That row gets a purple tint and the title turns purple - [ ] Bars animate over the artwork (4 bars, anchored bottom-center, staggered) - [ ] Pause playback - [ ] Row stays purple-tinted; artwork shows the play icon (no animation) - [ ] Skip to another track in the same list - [ ] Indicator follows to the new row; old row reverts to default styling - [ ] Same checks on Android - [ ] Reorderable playlist (drag mode): tinted row still renders correctly - [ ] Locked / unlisted / deleted tracks remain unaffected when not active Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… link to /console (#14376) Mirrors [OpenAudio/staking#2](OpenAudio/staking#2) on the protocol dashboard — same code, applied here since the NodeOverview / useNodeHealth components are nearly identical between the two repos. ## Summary - Replaces the old uptime/disk/db health rows on the validator node page with stats parsed from the validator `/health-check` endpoint: **Node Type**, **Peers** (recursive count over nested groupings like `inbound`/`outbound`), **Current Height**, **Storage Type**, **Last Restart** (computed from `timestamp - uptime`), and **Git SHA** (short, full on hover). - Adds a **View Console** button on the validator overview that opens `${endpoint}/console` in a new tab. - Drops the `IndividualNodeUptimeChart` section for validators only. Content Node and Discovery Node pages are unchanged. - Guards the `ServiceTable` country flag against missing or non-ISO-3166 alpha-2 codes (validators may not report a country) — renders a neutral fallback instead. ### Implementation notes vs. the staking PR - The View Console button reuses the existing `styles.modifyBtn` / `styles.modifyBtnText` classes (matching the Manage Node button) instead of the staking app's global `gradient-button manageNodeButton` classes, which don't exist in this repo. ## Test plan - [ ] Navigate to a validator node page — confirm the six new stats render and the View Console button opens the validator's console in a new tab. - [ ] Confirm Peers count matches reality on a node whose `core.peers` is grouped (e.g. inbound/outbound) — recursion sums across nested groups. - [ ] Confirm Content Node and Discovery Node pages are visually unchanged (still show disk/db/peer-reachability/uptime chart). - [ ] Confirm a validator with no `core.peers` shows `0` rather than blank. - [ ] Confirm fetch failure renders the "Failed to fetch health data" row instead of an empty section. - [ ] Confirm the ServiceTable falls back to a neutral 🏁 icon for rows whose `country` is missing or not a 2-letter code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## Summary - Render the contest comments composer for signed-out viewers instead of inert sign-in text - Gate composer click/submit with `useRequiresAccountCallback`, matching the Enter Contest auth prompt - Add focused coverage for the signed-out contest comment composer ## Testing - `npm run test -w @audius/web -- src/pages/contest-page/components/ContestCommentsTile.test.tsx --run` <div><a href="https://cursor.com/agents/bc-f38a20c5-458d-50df-9124-f47a41e8565b"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/automations/c63aa103-66df-4558-b31d-675358e5c6a1"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/view-automation-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/view-automation-light.png"><img alt="View Automation" width="141" height="28" src="https://cursor.com/assets/images/view-automation-dark.png"></picture></a> </div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Ray Jacobson <raymondjacobson@users.noreply.github.com> Co-authored-by: Raymond Jacobson <ray@audius.co> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
) ## Summary The previous RN upgrade ([#14303](#14303)) bumped `packages/mobile/package.json` to `react-native@0.79.5` but the lockfile and `ios/Podfile.lock` stayed pinned at `0.78.3` — a plain `npm install` under `legacy-peer-deps=true` would not bump them. This PR resolves the iOS/Android package versions to actually match the declared `0.79.5`: - **`package-lock.json`** — regenerated so `react-native` and `@react-native/*` (gradle-plugin, codegen, community-cli-plugin, virtualized-lists, etc.) resolve to `0.79.5`. The deps now hoist to the monorepo root `node_modules/` instead of living under `packages/mobile/node_modules/`. - **`packages/mobile/ios/Podfile.lock`** — regenerated via `pod install`; `React-Core`, `hermes-engine`, `RCTDeprecation`, `FBLazyVector`, `React-Fabric`, etc. are now `0.79.5`. `React-hermes`, `React-jsi`, and `React-renderercss` are now declared as explicit dependencies of `React` (new in 0.79). - **`packages/mobile/ios/AudiusReactNative.xcodeproj/project.pbxproj`** — `pod install` retargeted `REACT_NATIVE_PATH` to the hoisted `node_modules/react-native` location (`${PODS_ROOT}/../../../../node_modules/react-native`). - **`packages/mobile/android/app/build.gradle`** — set `root`, `reactNativeDir`, and `cliFile` in the `react { }` block so the React Native Gradle plugin can find `ReactAndroid/gradle.properties` at the hoisted top-level `node_modules/react-native`. Without this, `:app:downloadAar` (and any other Gradle task) failed because the plugin's default convention looks under `packages/mobile/node_modules/react-native`. ## How I verified - `npm install` completes without warnings; `node_modules/react-native/package.json` shows `"version": "0.79.5"`. - `cd packages/mobile/android && ./gradlew :app:downloadAar` → `BUILD SUCCESSFUL`. - `bundle exec pod install` (run automatically by `postinstall`) regenerates `Podfile.lock` cleanly; all `React-Core (= 0.79.5)`. ## Test plan - [x] CI green - [ ] `npm run ios:dev` boots and launches the app - [ ] `npm run android:dev` boots and launches the app - [ ] Hermes JS still loads (release + debug) - [ ] CodePush release pipeline still bundles the iOS / Android JS 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## Summary
Replaces the "fetch everything in one shot" pattern on the AUDIO
transactions page with proper infinite pagination, matching what the
USDC purchases / sales / withdrawals tables already do.
## Background
\`AudioWalletTransactions.tsx\` was setting \`pageSize =
audioTransactionsCount\` so the table could render every transaction in
a single fetch (the table has no built-in pagination UI). For users with
more than 100 transactions, this exceeded the server-side validator on
\`/v1/users/{id}/transactions/audio\` (\`limit\` max=100), and the
request failed outright — power users saw an empty page.
The first commit on this branch just clamped the requested size at 100
(stopping the bleeding). This second commit replaces the workaround with
real pagination.
## Changes
**\`useAudioTransactions\`** (\`packages/common\`)
- Convert from \`useQuery\` to \`useInfiniteQuery\` with
\`getNextPageParam\` keyed on accumulated page count × pageSize.
- Expose \`loadNextPage\` with a stable identity (ref-based, matches the
pattern in \`usePurchases\`).
- Default \`pageSize\` stays at 50.
**\`AudioWalletTransactions.tsx\`** (\`packages/web\`)
- Drop the count-based \`pageSize\` hack and the \`Math.min(..., 100)\`
clamp from the previous commit.
- Pass \`fetchMore={loadNextPage}\`,
\`totalRowCount={audioTransactionsCount}\`,
\`fetchBatchSize={DEFAULT_AUDIO_TRANSACTIONS_BATCH_SIZE}\`,
\`isVirtualized={true}\` to \`AudioTransactionsTable\`. Same wiring as
\`PurchasesTab → PurchasesTable\`.
## Server-side win
This also bounds the worst-case input to the slow \`ORDER BY
transaction_type\` plan I flagged in
[api#844](AudiusProject/api#844): the server can
now never be asked for more than 50 rows per request, capping the cost
of the materialize-and-sort plan.
## Test plan
- [ ] As a low-activity user (< 50 audio txns): page renders identically
to before.
- [ ] As a heavy user (> 50): initial fetch returns 50, scrolling
triggers \`loadNextPage\` which fetches the next 50, repeats until
\`totalRowCount\` is reached.
- [ ] Sort toggles (date / transaction_type, asc / desc) reset the
infinite query correctly via queryKey change.
- [ ] Power user with > 100 txns no longer sees an empty page (the bug
this branch was opened for).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## Summary
- Fixes mobile CI failure (typecheck + lint) caused by a duplicate
`import { make, track as trackEvent } from 'app/services/analytics'` in
[ContestScreen.tsx](packages/mobile/src/screens/contest-screen/ContestScreen.tsx).
- The duplicate (out-of-order) copy is removed; the properly ordered
import is retained.
## Test plan
- [ ] CI mobile `verify` (typecheck, lint, lint:env) passes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## Summary - Fixes Mobile OTA Release (CodePush) jobs failing with `node_modules/.bin/react-native: not found` (exit 127). Example: [run 26308703765](https://github.com/AudiusProject/apps/actions/runs/26308703765/job/77452051457). - After [#14365](#14365) regenerated the lockfile, `react-native` (and the bin from `@react-native-community/cli`) hoists to the monorepo root `node_modules/`, so `packages/mobile/node_modules/.bin/react-native` no longer exists. - [`@bravemobile/react-native-code-push@12.3.2`](https://app.unpkg.com/@bravemobile/react-native-code-push@12.3.2/files/cli/dist/functions/runReactNativeBundleCommand.js) hardcodes the relative path `node_modules/.bin/react-native` (cwd is `packages/mobile`) when invoking `react-native bundle`, so the lookup fails. - Add a symlink step before `npx code-push release` in both `mobile-ota-release` and `mobile-ota-release-production` that points `packages/mobile/node_modules/.bin/react-native` at the hoisted root binary. ## Test plan - [ ] Mobile OTA Release (CodePush, ios) on this PR's merge to `main` succeeds (or workflow_dispatch w/ `rc` channel). - [ ] Mobile OTA Release (CodePush, android) on this PR's merge to `main` succeeds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…#14389) ## Summary Two changes needed to actually publish a release on main: 1. **Version bump for the binary build jobs to fire again.** The `mobile-version-check` job compares `packages/mobile/package.json` `version` between `HEAD` and `HEAD^`. Once **1.5.180** landed (PR #14365 / commit [51cf0b2](51cf0b2eb5)), every subsequent commit reported `version_changed=false`, so the iOS RC, Android RC, iOS Production, and Android Production jobs all got skipped. Bumps: - `packages/mobile/package.json`: `1.5.180` → `1.5.181` - `packages/mobile/ios/AudiusReactNative/Info.plist` `CFBundleShortVersionString`: `1.1.193` → `1.1.194` - `packages/mobile/android/app/build.gradle` `versionName`: `1.1.529` → `1.1.530` 2. **Android RC + Android Production CI no longer falls back to Node 22.** In [run 26246576840](https://github.com/AudiusProject/apps/actions/runs/26246576840), both Android jobs failed `npm ci` with: ``` npm error code EBADENGINE npm error notsup Required: {"node":">=24.10.0","npm":">=11.10.0"} npm error notsup Actual: {"npm":"10.9.8","node":"v22.22.3"} ``` The job order is `Setup Node.js → Setup Android SDK → Free disk space → Setup Java (after disk cleanup) → … → Install dependencies`. The "Free disk space" step does `sudo rm -rf "$AGENT_TOOLSDIRECTORY"`, which is where `actions/setup-node@v4` placed Node 24 — so by the time `npm ci` runs, only the runner's system Node 22 is on PATH. Java is intentionally re-set-up after cleanup; Node was not. This PR adds a matching `Setup Node.js (after disk cleanup)` step (plus the npm 11 upgrade) in both Android jobs. The iOS failures in that run were caused by an unrelated duplicate `make` import in `ContestScreen.tsx`, which has already been fixed on main by [802487b](802487bb3a). ## Test plan - [ ] Merge → Mobile CI/CD on main fires all four binary build/upload jobs (version_changed=true) - [ ] iOS RC build & upload succeeds end-to-end - [ ] Android RC build & upload reaches \`fastlane releaseCandidate\` (Node 24 visible in install step logs) - [ ] iOS Production + Android Production gated on environment approval as usual 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…omment kebab opens (#14391)
## Summary - Follow-up to [apps#14289](#14289). That PR stopped the keyboard from re-appearing on background→foreground, but a brief keyboard flash still happened on app **cold launch**. - Root cause: the explore screen's `useEffect` calls `.focus()` whenever `params?.autoFocus === true`, regardless of whether the explore tab is the active tab. If the explore screen mounts off-screen with a stale `autoFocus: true` on its route, the focus call fires anyway and briefly summons the keyboard. - Fix: gate the focus call on `useIsFocused()` so the param is only consumed while the explore tab is actually the active tab. The intentional search-icon-tap flow still works — navigating to explore puts the tab in focus before the effect runs. ## Test plan - [ ] Cold-start the app → no keyboard flash on initial load (regardless of which tab was last active before the kill) - [ ] Tap search icon from any tab header → explore opens with keyboard up and input focused - [ ] On explore, dismiss keyboard, switch to another tab, return to explore → keyboard does NOT reappear - [ ] On explore with keyboard up, background the app, return from background → keyboard does NOT reappear (still works after this change) - [ ] Tap search icon → keyboard reappears, dismiss it, tap search icon again → keyboard reappears (param re-trigger still works) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…stlane Xcode 26 (#14394) ## Summary Fixes both halves of the broken release run [26316494213](https://github.com/AudiusProject/apps/actions/runs/26316494213) so a single re-release can ship both platforms. ### 1. Android RC + Prod — Hermes entryFile pointed at monorepo root Both Android builds failed at the JS bundle task with: \`\`\` * What went wrong: A problem was found with the configuration of task ':app:createBundleReleaseCandidateReleaseJsAndAssets' (type 'BundleHermesCTask'). - In plugin 'com.facebook.react' type 'com.facebook.react.tasks.BundleHermesCTask' property 'entryFile' specifies file '/home/runner/work/apps/apps/index.js' which doesn't exist. \`\`\` When I added the Android gradle path overrides for the hoisted node_modules in #14365, I also set \`react.root = file("../../../../")\` (monorepo root). The React-Native Gradle plugin anchors \`entryFile\` to \`root\` (\`PathUtils.kt#detectEntryFile\` → \`File(reactRoot, "index.js")\`), so it searched for \`/home/runner/work/apps/apps/index.js\` instead of \`packages/mobile/index.js\`. Drop the \`root\` override and let it default to \`../..\` (= \`packages/mobile\`, where \`index.js\` lives). The explicit \`reactNativeDir\`, \`codegenDir\`, and \`cliFile\` overrides are absolute paths and continue to point at the hoisted node_modules. ### 2. iOS RC + Prod — fastlane crash on Xcode 26 altool errors Both iOS jobs successfully archived the IPA (gym ~20 min) but died at the \`pilot\` TestFlight upload step with: \`\`\` fastlane_core/itunes_transporter.rb:266:in 'execute': undefined method 'each' for nil (NoMethodError) \`\`\` Root cause is two upstream bugs interacting: - Xcode 26 changed altool's error prefix from \`*** Error:\` to \`ERROR:\`. fastlane 2.225.0's \`ERROR_REGEX = /\*\*\* Error:\s+(.+)/\` no longer matches, so \`error_line_index = nil\`. - The fallback displayer does \`@all_lines[-20..-1].each\`. Under Ruby 3.x, that slice returns \`nil\` when the array has fewer than 20 elements (Ruby 3.0 stopped clamping out-of-range negative starts), so \`.each\` crashes on nil. **The real altool error is hidden by this crash.** Both upstream bugs are fixed: - fastlane#29545 ([455bb5e1](fastlane/fastlane@455bb5e1), 2.228.0): displayer iterates \`@all_lines\` directly, no more broken slice - fastlane#29740 ([744b01ce](fastlane/fastlane@744b01ce), 2.230.0): ERROR_REGEX matches both \`*** Error:\` and Xcode 26's \`ERROR:\` Bump the iOS Gemfile pin from \`~> 2.225.0\` to \`2.234.0\` (latest at time of writing, includes both fixes). \`Gemfile.lock\` updated to match. Android's Gemfile is already \`>= 2.220.0\` and locks at 2.231.1 — unaffected. If altool itself has a real upload error remaining, it will at least surface in the logs after this lands; can be addressed in a follow-up. ### 3. Re-trigger the release flow - \`packages/mobile/package.json\`: \`1.5.181\` → \`1.5.182\` - iOS \`Info.plist\` \`CFBundleShortVersionString\`: \`1.1.194\` → \`1.1.195\` - Android \`versionName\`: \`1.1.530\` → \`1.1.531\` ## Test plan - [ ] Merge to main → version-check fires all 4 binary build/upload jobs - [ ] Android RC + Prod reach \`fastlane releaseCandidate\` / \`prod\` — no \`BundleHermesCTask entryFile\` failure - [ ] iOS RC + Prod either succeed end-to-end OR fail at \`pilot\` with a readable altool error (no more \`undefined method 'each' for nil\`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…14396) ## Summary Two real failures surfaced in [run 26319345276](https://github.com/AudiusProject/apps/actions/runs/26319345276) after the displayer fix in #14394 made fastlane log altool errors again. ### 1. iOS RC + Prod — Apple now requires iOS 26 SDK altool now returns: \`\`\` ERROR: Validation failed (409) SDK version issue. This app was built with the iOS 18.5 SDK. All iOS and iPadOS apps must be built with the iOS 26 SDK or later, included in Xcode 26 or later, in order to be uploaded to App Store Connect or submitted for distribution. (ID: 287c542e-3971-4158-b3c3-71eb1fcd6eb3) \`\`\` The \`macos-15\` runners ship Xcode 16.4 (iOS 18.5 SDK) by default — that's why gym archived fine but altool rejected the upload. Switch both iOS jobs to \`macos-26\` (Tahoe, GA), which has Xcode 26 as the only supported major. ### 2. Android RC + Prod — hermesc binary lookup also anchored at the wrong root \`\`\` Couldn't determine Hermesc location. Please set \`react.hermesCommand\` to the path of the hermesc binary file. node_modules/react-native/sdks/hermesc/%OS-BIN%/hermesc \`\`\` #14394 restored \`react.root\` to its default (\`../.. = packages/mobile\`) to unblock the Hermes JS bundle's \`entryFile\` lookup. But the same gradle plugin uses \`root\` for hermesc detection too (\`PathUtils.kt#detectOSAwareHermesCommand\` → only checks \`root/node_modules/react-native/sdks/hermesc/...\`), and RN is hoisted to the monorepo \`node_modules\` so that path doesn't exist. Set \`react.hermesCommand\` explicitly to the hoisted prebuilt: \`\`\`groovy hermesCommand = "\$rootDir/../../../node_modules/react-native/sdks/hermesc/%OS-BIN%/hermesc" \`\`\` The \`%OS-BIN%\` placeholder is substituted by the plugin at runtime (linux64-bin / osx-bin / win64-bin). Verified all three subdirs exist in \`node_modules/react-native/sdks/hermesc/\` after \`npm ci\`. ### Housekeeping - \`packages/mobile/ios/Gemfile.lock\`: dependency line aligned to \`= 2.234.0\` to match the Gemfile pin from #14394. (The squash-merge of #14394 amended the Gemfile to a pin but kept the lock at the pre-pin \`>= 2.228.0\` constraint; CI was re-resolving every build.) - Versions bumped to re-fire the build matrix: - \`packages/mobile/package.json\`: \`1.5.182\` → \`1.5.183\` - iOS Info.plist \`CFBundleShortVersionString\`: \`1.1.195\` → \`1.1.196\` - Android \`versionName\`: \`1.1.531\` → \`1.1.532\` ## Test plan - [ ] Merge → version-check fires all 4 binary jobs - [ ] Android RC + Prod reach \`fastlane releaseCandidate\` / \`prod\` (no \`Couldn't determine Hermesc location\`) - [ ] iOS RC + Prod reach App Store Connect (no \`SDK version issue\` 409) - [ ] Slack notification fires from the success branch of each iOS / Android job 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Adds a new "Add Tracks by URL" affordance to the owner action row on
playlist detail pages. Clicking it opens a modal where the user pastes
Audius track links — line, comma, or tab separated — and submits to
batch-add them to the current playlist. Albums and DDEX-imported
collections are intentionally excluded.
Implementation details:
- New `AddTracksByUrlModal` Redux modal slice (createModal helper) wired
through types/parentSlice/reducers/index.
- New `AddTracksByUrlModal` component:
- Parses pasted text into a deduped list of permalinks via
`getPathFromTrackUrl`.
- Resolves them in one round-trip via `sdk.tracks.getBulkTracks`.
- Filters out tracks already in the playlist, enforces a 100-track
cap, and reports invalid/unresolved/duplicate/over-limit counts in a
single summary toast.
- Dispatches `addTrackToPlaylist` per track with a 30 ms gap so each
saga's optimistic update reads the previous one's state.
- `addTrackToPlaylist` now accepts `{ silent: true }` so the per-track
"Added track to playlist" toast can be suppressed during batch adds;
default behavior (single-track adds elsewhere) is unchanged.
- New `IconLink` button in `OwnerActionButtons` opens the modal,
prefilled with the current collection id. Hidden for albums and
DDEX-imported collections.
Scope notes:
- Resolution uses the existing `addTrackToPlaylist` saga path
(sequential dispatches with small delay). A future PR could replace
this with a dedicated `addTracksToPlaylistBatch` saga that issues a
single SDK update for cleaner semantics on large pastes.
- Larger track-curation features from the spec (multi-select, range
select, undo/redo, copy selected URLs, multi-row drag) are deferred
to follow-up PRs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes the duplicate-playlist flow from metadata-only to a true
duplicate that also copies every track from the source.
- New DUPLICATE_PLAYLIST action carries the source playlist id, the
composed form fields, the full source track id list, and an isAlbum
flag.
- New duplicatePlaylistSaga drives the full sequence: it dispatches the
existing createPlaylist / createAlbum saga with the first source
track as initTrackId, takes() the resulting CREATE_PLAYLIST_REQUESTED
to learn the new playlist id, then sequentially dispatches
addTrackToPlaylist({ silent: true }) for every remaining track with
a small inter-dispatch delay so each saga sees the previous
optimistic update. Closes with a single summary toast.
- DuplicatePlaylistModal now dispatches DUPLICATE_PLAYLIST and exposes
the actual track count to the user ("All N tracks will be copied")
instead of the previous "tracks not copied" note.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings playlist detail-page editing inline per the new UX spec. The
existing /edit route still works for advanced fields (audience,
price, genre, etc.), but the common metadata flow is now handled
directly on the detail page.
- New `PlaylistEditModeProvider` context holds the edit-mode flag,
the staged metadata draft, conflict status, and saving status. The
matching `usePlaylistEditMode` hook safely returns a no-op shape
when not inside the provider, so shared components stay
backwards-compatible.
- Wrapped the desktop CollectionPage in the provider and rendered a
sticky `PlaylistEditModeBar` at the page footer. The bar shows a
Discard / Apply pair when there are pending changes, a slim "no
changes yet" footer while in edit mode without changes, and a
conflict banner with a Reload action when the playlist was changed
remotely since edit mode started.
- The `EditButton` pencil in the owner action row now toggles inline
edit mode instead of routing to /edit (the legacy link is kept as
a fallback when no provider is mounted).
- CollectionHeader (desktop) renders an inline `TextInput` for the
title, a `TextArea` for the description, and a `Switch` for
visibility while in edit mode. Otherwise behavior is unchanged.
- Desktop Artwork supports inline upload (file picker) when edit
mode is on; the staged image previews immediately and is sent
through on Apply along with the metadata draft.
- Apply checks the collection's `updated_at` against the timestamp
captured when edit mode started; if it has advanced, the bar
flips to the conflict state and aborts the save.
- Success messages are specific ("Saved details", "Saved artwork",
or "Saved details and artwork") based on which fields the user
changed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… undo/redo)
Adds the bulk track-curation toolbar to the playlist detail page when
in edit mode, plus keyboard shortcuts for the common operations.
- New `TrackSelectionProvider` + `useTrackSelection` hook: a Set of
selected track IDs with toggle (including shift-range across an
ordered id list), select-all, clear, and a "last clicked index" ref
so range select works in chronological click order.
- New `TrackHistoryProvider` + `useTrackHistoryContext`: undo/redo
stacks for `remove` and `add` operations. Undo of a `remove`
dispatches `addTrackToPlaylist({ silent: true })` (note: the
existing saga appends rather than re-inserting at the original
index — current ordering is a known limitation surfaced as a
follow-up).
- New `TrackBulkActionsBar` sticks to the top of the track table
while in edit mode and shows a count of selected tracks plus the
five bulk actions: Copy URLs (clipboard-writes
`${origin}${permalink}` for each selected track), Remove
(dispatches removeTrackFromPlaylist per id and pushes history
entries so each remove is undoable), Undo, Redo, and Select all /
Clear pair.
- Keyboard shortcuts wired through the same component while in edit
mode: Cmd/Ctrl+A select all, Cmd/Ctrl+Z undo, Cmd/Ctrl+Shift+Z /
Cmd/Ctrl+Y redo, Escape clears selection, Delete/Backspace removes
selected (and skips when focus is in a text input).
- CollectionPage is wrapped in the selection + history providers
alongside the existing edit-mode provider.
Scope notes — per-row checkbox UI and shift-range row-click select
require deeper changes to the existing TracksTable component and are
deferred to a follow-up PR; users can still select-all via the bar
or Cmd/Ctrl+A and operate on the full set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
While the playlist detail page is in edit mode, clicking a track row
toggles its selection in the bulk-actions context instead of playing
the track. Holding shift while clicking extends the selection over
the range between the previous click and the new one.
- New `EditAwareTracksTable` wrapper around the standard
`TracksTable`. It captures the global shift-key state with a
window listener (TracksTable's onClickRow does not pass a
MouseEvent) and rewrites `onClickRow` to call
`selection.toggle(id, index, { shift })` when edit mode is active.
- Outside of edit mode the wrapper is a transparent pass-through and
the existing play-on-click behavior is preserved.
- Desktop `CollectionPage` swaps its `TracksTable` usage for the new
edit-aware wrapper.
Combined with the bulk-actions bar from the previous PR, the user
can now: shift-click a range, Cmd/Ctrl+A to select all, Escape to
clear, Delete to remove, Cmd/Ctrl+Z/Y for undo/redo, and the bar's
Copy URLs / Remove buttons for bulk operations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds on the row-click selection from this branch to give the user visible feedback for which tracks are currently selected. - `TracksTable` gains an optional `rowClassNameAddition(track, index)` prop that's composed with the table's existing per-row className (used internally for the locked/disabled states). The hook is ref-stable so external state changes don't force a full re-render of the table machinery. - `EditAwareTracksTable` passes a `rowClassNameAddition` that returns the new `selected` CSS class when the row's track id is in the selection set and the page is in edit mode. The class draws a surface-2 background fill and a 3px accent bar on the left edge so selected rows are immediately scannable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small correctness fixes on top of the existing row-click selection in playlist edit mode. - `EditAwareTracksTable` now resets its shift-key ref on `window` blur. Previously, holding Shift while Cmd/Alt-Tabbing away would leave the ref stuck true (the keyup fires in the other window), so the next click on return was interpreted as a shift-extend. - `TrackSelectionContext.toggle` now keeps the anchor row stable across a sequence of shift-clicks. The anchor only moves on a plain click. This matches Finder / Gmail / Drive: click A, shift-click C selects A..C; a follow-up shift-click E then selects A..E (not C..E). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
0402d02 to
eaac6cb
Compare
CollectionPage now renders the EditAwareTracksTable wrapper instead of TracksTable directly, so the discovery regex no longer matched it and a new file (EditAwareTracksTable.tsx) showed up unlisted. Add the wrapper to both the regex and the non-policy allowlist so the existing responsiveColumns assertion on CollectionPage keeps running. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Wires up row-level selection on the playlist detail page when edit mode is active. Clicking a row toggles its selection in the bulk-actions context (added in #14323) instead of playing the track; holding shift extends the selection over the range between the anchor (last plain click) and the new row.
Stacked on #14323 → #14322 → #14321 → #14320 → #14319 → #14318.
Implementation
EditAwareTracksTablewraps the standardTracksTable:onClickRow(track, index)to callselection.toggle(track.track_id, index, { shift })instead of the original handler.TracksTable'sonClickRowdoes not receive aMouseEvent, so the wrapper tracks the shift key globally via window keydown/keyup listeners and reads it at click time. Also resets onwindowblur — without this, Cmd/Alt-Tabbing away while holding Shift would leave the modifier stuck true (the keyup fires in the other window).TrackSelectionContext.togglekeeps the anchor row stable across a shift-click sequence (matches Finder / Gmail / Drive): click A, shift-click C selects A..C; a follow-up shift-click E then selects A..E (not C..E). The anchor only moves on a plain click.CollectionPageswaps itsTracksTableinstantiation for the new wrapper and passes the collection id.TracksTablenow accepts an optionalrowClassNameAddition(track, rowIndex)that's composed with the existing internal per-row className (used for locked/disabled state).EditAwareTracksTableuses it to apply aselectedclass — surface-2 background fill plus a 3px accent bar on the left edge — to rows whose track id is in the selection set.With this PR, the full track-curation flow from the spec is wired:
Cmd/Ctrl+A→ Select allEscape→ Clear selectionDelete→ Remove selectedCmd/Ctrl+Z/Cmd/Ctrl+Y→ Undo / RedoTest plan
🤖 Generated with Claude Code