Skip to content

feat(web): row-click selection on playlist tracks in edit mode#14324

Open
dylanjeffers wants to merge 60 commits into
claude/track-multi-selectfrom
claude/per-row-selection
Open

feat(web): row-click selection on playlist tracks in edit mode#14324
dylanjeffers wants to merge 60 commits into
claude/track-multi-selectfrom
claude/per-row-selection

Conversation

@dylanjeffers
Copy link
Copy Markdown
Contributor

@dylanjeffers dylanjeffers commented May 15, 2026

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

  • New EditAwareTracksTable wraps the standard TracksTable:
    • When edit mode is active for the current collection, intercepts onClickRow(track, index) to call selection.toggle(track.track_id, index, { shift }) instead of the original handler.
    • TracksTable's onClickRow does not receive a MouseEvent, so the wrapper tracks the shift key globally via window keydown/keyup listeners and reads it at click time. Also resets on window blur — without this, Cmd/Alt-Tabbing away while holding Shift would leave the modifier stuck true (the keyup fires in the other window).
    • Outside of edit mode the wrapper is a transparent pass-through; play-on-click works unchanged.
  • TrackSelectionContext.toggle keeps 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.
  • CollectionPage swaps its TracksTable instantiation for the new wrapper and passes the collection id.
  • Per-row highlight: TracksTable now accepts an optional rowClassNameAddition(track, rowIndex) that's composed with the existing internal per-row className (used for locked/disabled state). EditAwareTracksTable uses it to apply a selected class — 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:

  • Single-click → toggle selection
  • Shift-click → range select (anchor-preserving)
  • Cmd/Ctrl+A → Select all
  • Escape → Clear selection
  • Delete → Remove selected
  • Cmd/Ctrl+Z / Cmd/Ctrl+Y → Undo / Redo
  • Toolbar buttons for Copy URLs and Remove

Test plan

  • Enter edit mode on your playlist.
  • Click a row → bar shows "1 selected"; row gains the surface-2 highlight; play state does not change.
  • Click another row → "2 selected"; both rows highlighted.
  • Shift-click a third row further down → the entire range from the anchor to that row is added and highlighted.
  • Shift-click an even further row → the range still extends from the original anchor, not from the previous shift-click target.
  • Click a selected row (no shift) → it is removed from the selection and the highlight is dropped.
  • Hold Shift, Cmd-Tab to another app, release Shift outside the window, return, and click a row → it should toggle (not range-extend), proving the blur reset works.
  • Exit edit mode → click a row → playback starts as before, no highlight.

🤖 Generated with Claude Code

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 15, 2026

🦋 Changeset detected

Latest commit: ae486de

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@audius/sdk Major
@audius/common Patch
@audius/sdk-legacy Patch
@audius/web Patch
@audius/mobile Patch
@audius/protocol-dashboard Patch
@audius/sp-actions Patch

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

@pull-request-size pull-request-size Bot added size/L and removed size/M labels May 15, 2026
@dylanjeffers dylanjeffers force-pushed the claude/track-multi-select branch from 46c2b73 to c539a7b Compare May 15, 2026 18:12
@dylanjeffers dylanjeffers force-pushed the claude/per-row-selection branch from 13ec3ba to 8cc78cc Compare May 15, 2026 18:12
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

🌐 Web preview ready

Preview URL: https://audius-web-preview-pr-14324.audius.workers.dev

Unique preview for this PR (deployed from this branch).
Workflow run

dylanjeffers and others added 23 commits May 15, 2026 11:26
## 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>
dylanjeffers and others added 28 commits May 20, 2026 17:18
## 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>&nbsp;<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>&nbsp;</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>
## 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>
@dylanjeffers dylanjeffers force-pushed the claude/per-row-selection branch from 0402d02 to eaac6cb Compare May 26, 2026 16:50
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants