feat(web): contextual filters — click a metadata value in the viewer to filter the surface you're on (#767) - #778
Merged
Conversation
Deeds67
force-pushed
the
feat/asset-viewer-contextual-filters
branch
from
July 14, 2026 23:27
c22eeb0 to
7eaac3e
Compare
Deeds67
force-pushed
the
feat/asset-viewer-contextual-filters
branch
2 times, most recently
from
July 21, 2026 20:44
916bf35 to
c5f2ead
Compare
Deeds67
force-pushed
the
feat/asset-viewer-contextual-filters
branch
from
July 25, 2026 19:00
3cf655e to
3b7757e
Compare
🧪 Release candidate buildLatest RC Images published
How to run this RCIn the directory containing your services:
immich-server:
image: ghcr.io/open-noodle/gallery-server:pr-778-rc.9Then pull and restart: docker compose pull immich-server
docker compose up -dEach push publishes a new numbered tag, so update the image line to move to a newer RC. To roll back, point it at an earlier Previous builds (9)
Last updated Mon, 10 Aug 2026 17:55:51 GMT — every push while the |
Deeds67
added a commit
that referenced
this pull request
Jul 27, 2026
…pplied (review) Four issues found reviewing #778 manually, all in the seam between the id or level a contextual patch produces and the id or level the panel renders. Person chips showed a raw UUID. `buildPersonFilterPatch` emitted a BARE person uuid off a Space, but every filter-option producer there (`getPhotosPersonFilterId`) emits the scoped `person:<uuid>` token. The server accepts both, so the result set was right — but the `personNames` map and the panel's people options are keyed by the scoped token, so the chip fell back to `?? id` and the panel rendered a second, orphaned UUID row beside the named one it should have ticked. Emit the scoped token, and bank the person's name against the destination URL on click: the suggestions response is a round-trip late, and never carries a `space-person:` token at all when the viewer also owns a person for that identity. The camera panel never drilled into the selected model. The model level is lazily fetched and was only ever expanded by a click, so a make+model filter rendered one bold, unfilled "Canon" row with no model row to tick. Auto-expand the selected make, the way location-filter already reveals the city under its selected country. The cascade auto-clear on the model goes with it: now that the fetch runs on mount it would silently widen a shared `?make=…&model=…` link, so an unlisted model is pinned dimmed instead, like every other level of this panel. A `state` filter was invisible in the panel. The viewer's location row renders city / state / country as three buttons and plenty of places carry the same word at two levels (Prague's EXIF is city "Prague", state "Prague"), so the middle button emits `{state, country}` — which the chip and the server handle correctly but the panel had no level for. It showed a lone ticked country, could not remove the filter, and silently AND-ed the stale state onto the next country or city clicked. Render the state as a pinned ticked row under its country, drop it on any country/city click, and count it in `hasActiveFilter`. Selected entries fell outside the truncated head. The tag list shows ten rows and the people list five, with no slot reserved for what is actually selected, so in a large library a filter applied from anywhere but the list itself was invisible. Hoist the selection instead of scrolling to it: no measurement, survives a re-fetch, and sits with the orphans already pinned above rather than splitting the selection across two places. Same hoist for the selected country, which otherwise takes its cities and its state row off screen with it.
Deeds67
force-pushed
the
feat/asset-viewer-contextual-filters
branch
from
July 30, 2026 07:36
c493ba8 to
a691c14
Compare
Deeds67
added a commit
that referenced
this pull request
Jul 30, 2026
…pplied (review) Four issues found reviewing #778 manually, all in the seam between the id or level a contextual patch produces and the id or level the panel renders. Person chips showed a raw UUID. `buildPersonFilterPatch` emitted a BARE person uuid off a Space, but every filter-option producer there (`getPhotosPersonFilterId`) emits the scoped `person:<uuid>` token. The server accepts both, so the result set was right — but the `personNames` map and the panel's people options are keyed by the scoped token, so the chip fell back to `?? id` and the panel rendered a second, orphaned UUID row beside the named one it should have ticked. Emit the scoped token, and bank the person's name against the destination URL on click: the suggestions response is a round-trip late, and never carries a `space-person:` token at all when the viewer also owns a person for that identity. The camera panel never drilled into the selected model. The model level is lazily fetched and was only ever expanded by a click, so a make+model filter rendered one bold, unfilled "Canon" row with no model row to tick. Auto-expand the selected make, the way location-filter already reveals the city under its selected country. The cascade auto-clear on the model goes with it: now that the fetch runs on mount it would silently widen a shared `?make=…&model=…` link, so an unlisted model is pinned dimmed instead, like every other level of this panel. A `state` filter was invisible in the panel. The viewer's location row renders city / state / country as three buttons and plenty of places carry the same word at two levels (Prague's EXIF is city "Prague", state "Prague"), so the middle button emits `{state, country}` — which the chip and the server handle correctly but the panel had no level for. It showed a lone ticked country, could not remove the filter, and silently AND-ed the stale state onto the next country or city clicked. Render the state as a pinned ticked row under its country, drop it on any country/city click, and count it in `hasActiveFilter`. Selected entries fell outside the truncated head. The tag list shows ten rows and the people list five, with no slot reserved for what is actually selected, so in a large library a filter applied from anywhere but the list itself was invisible. Hoist the selection instead of scrolling to it: no measurement, survives a re-fetch, and sits with the orphans already pinned above rather than splitting the selection across two places. Same hoist for the selected country, which otherwise takes its cities and its state row off screen with it.
Deeds67
force-pushed
the
feat/asset-viewer-contextual-filters
branch
from
July 30, 2026 08:42
a691c14 to
392f77f
Compare
Deeds67
added a commit
that referenced
this pull request
Jul 30, 2026
…pplied (review) Four issues found reviewing #778 manually, all in the seam between the id or level a contextual patch produces and the id or level the panel renders. Person chips showed a raw UUID. `buildPersonFilterPatch` emitted a BARE person uuid off a Space, but every filter-option producer there (`getPhotosPersonFilterId`) emits the scoped `person:<uuid>` token. The server accepts both, so the result set was right — but the `personNames` map and the panel's people options are keyed by the scoped token, so the chip fell back to `?? id` and the panel rendered a second, orphaned UUID row beside the named one it should have ticked. Emit the scoped token, and bank the person's name against the destination URL on click: the suggestions response is a round-trip late, and never carries a `space-person:` token at all when the viewer also owns a person for that identity. The camera panel never drilled into the selected model. The model level is lazily fetched and was only ever expanded by a click, so a make+model filter rendered one bold, unfilled "Canon" row with no model row to tick. Auto-expand the selected make, the way location-filter already reveals the city under its selected country. The cascade auto-clear on the model goes with it: now that the fetch runs on mount it would silently widen a shared `?make=…&model=…` link, so an unlisted model is pinned dimmed instead, like every other level of this panel. A `state` filter was invisible in the panel. The viewer's location row renders city / state / country as three buttons and plenty of places carry the same word at two levels (Prague's EXIF is city "Prague", state "Prague"), so the middle button emits `{state, country}` — which the chip and the server handle correctly but the panel had no level for. It showed a lone ticked country, could not remove the filter, and silently AND-ed the stale state onto the next country or city clicked. Render the state as a pinned ticked row under its country, drop it on any country/city click, and count it in `hasActiveFilter`. Selected entries fell outside the truncated head. The tag list shows ten rows and the people list five, with no slot reserved for what is actually selected, so in a large library a filter applied from anywhere but the list itself was invisible. Hoist the selection instead of scrolling to it: no measurement, survives a re-fetch, and sits with the orphans already pinned above rather than splitting the selection across two places. Same hoist for the selected country, which otherwise takes its cities and its state row off screen with it.
This was referenced Jul 30, 2026
Deeds67
force-pushed
the
feat/asset-viewer-contextual-filters
branch
from
August 4, 2026 19:12
a4908b5 to
50875fe
Compare
Deeds67
added a commit
that referenced
this pull request
Aug 4, 2026
…pplied (review) Four issues found reviewing #778 manually, all in the seam between the id or level a contextual patch produces and the id or level the panel renders. Person chips showed a raw UUID. `buildPersonFilterPatch` emitted a BARE person uuid off a Space, but every filter-option producer there (`getPhotosPersonFilterId`) emits the scoped `person:<uuid>` token. The server accepts both, so the result set was right — but the `personNames` map and the panel's people options are keyed by the scoped token, so the chip fell back to `?? id` and the panel rendered a second, orphaned UUID row beside the named one it should have ticked. Emit the scoped token, and bank the person's name against the destination URL on click: the suggestions response is a round-trip late, and never carries a `space-person:` token at all when the viewer also owns a person for that identity. The camera panel never drilled into the selected model. The model level is lazily fetched and was only ever expanded by a click, so a make+model filter rendered one bold, unfilled "Canon" row with no model row to tick. Auto-expand the selected make, the way location-filter already reveals the city under its selected country. The cascade auto-clear on the model goes with it: now that the fetch runs on mount it would silently widen a shared `?make=…&model=…` link, so an unlisted model is pinned dimmed instead, like every other level of this panel. A `state` filter was invisible in the panel. The viewer's location row renders city / state / country as three buttons and plenty of places carry the same word at two levels (Prague's EXIF is city "Prague", state "Prague"), so the middle button emits `{state, country}` — which the chip and the server handle correctly but the panel had no level for. It showed a lone ticked country, could not remove the filter, and silently AND-ed the stale state onto the next country or city clicked. Render the state as a pinned ticked row under its country, drop it on any country/city click, and count it in `hasActiveFilter`. Selected entries fell outside the truncated head. The tag list shows ten rows and the people list five, with no slot reserved for what is actually selected, so in a large library a filter applied from anywhere but the list itself was invisible. Hoist the selection instead of scrolling to it: no measurement, survives a re-fetch, and sits with the orphans already pinned above rather than splitting the selection across two places. Same hoist for the selected country, which otherwise takes its cities and its state row off screen with it.
Deeds67
force-pushed
the
feat/asset-viewer-contextual-filters
branch
from
August 5, 2026 18:49
50875fe to
be28bb4
Compare
Deeds67
added a commit
that referenced
this pull request
Aug 5, 2026
…pplied (review) Four issues found reviewing #778 manually, all in the seam between the id or level a contextual patch produces and the id or level the panel renders. Person chips showed a raw UUID. `buildPersonFilterPatch` emitted a BARE person uuid off a Space, but every filter-option producer there (`getPhotosPersonFilterId`) emits the scoped `person:<uuid>` token. The server accepts both, so the result set was right — but the `personNames` map and the panel's people options are keyed by the scoped token, so the chip fell back to `?? id` and the panel rendered a second, orphaned UUID row beside the named one it should have ticked. Emit the scoped token, and bank the person's name against the destination URL on click: the suggestions response is a round-trip late, and never carries a `space-person:` token at all when the viewer also owns a person for that identity. The camera panel never drilled into the selected model. The model level is lazily fetched and was only ever expanded by a click, so a make+model filter rendered one bold, unfilled "Canon" row with no model row to tick. Auto-expand the selected make, the way location-filter already reveals the city under its selected country. The cascade auto-clear on the model goes with it: now that the fetch runs on mount it would silently widen a shared `?make=…&model=…` link, so an unlisted model is pinned dimmed instead, like every other level of this panel. A `state` filter was invisible in the panel. The viewer's location row renders city / state / country as three buttons and plenty of places carry the same word at two levels (Prague's EXIF is city "Prague", state "Prague"), so the middle button emits `{state, country}` — which the chip and the server handle correctly but the panel had no level for. It showed a lone ticked country, could not remove the filter, and silently AND-ed the stale state onto the next country or city clicked. Render the state as a pinned ticked row under its country, drop it on any country/city click, and count it in `hasActiveFilter`. Selected entries fell outside the truncated head. The tag list shows ten rows and the people list five, with no slot reserved for what is actually selected, so in a large library a filter applied from anywhere but the list itself was invisible. Hoist the selection instead of scrolling to it: no measurement, survives a re-fetch, and sits with the orphans already pinned above rather than splitting the selection across two places. Same hoist for the selected country, which otherwise takes its cities and its state row off screen with it.
Design spec for turning the asset-viewer info panel into a contextual filtering surface: clicking a metadata value filters the timeline you are currently in (space / album / photos / map), while every action available today stays reachable via an explicit secondary affordance. Unifies filter state onto the URL across all four timeline surfaces, which also fixes the structured-filter half of #767 (space -> map carryover).
…review Review of the first draft found one real design hole and several gaps against the TDD/coverage/BDD requirements. Design hole: array-field merge semantics were undefined, and the two array filters have OPPOSITE server semantics (personIds ANDs via hasPeople's HAVING count=len; tagIds ORs via withAnyTagId). 'Append' would have made two adjacent panel rows move the result set in opposite directions. Now mandated as replace-never-append, guarded by a new property test P1: filtering by a value on asset A always yields a result set containing A. Also fixed: - BDD scenarios moved inside each slice (2, 3 and 6 previously had none) - explicit RED/GREEN steps added to every slice - coverage traceability matrix (E1-E25, P1 -> slice -> test type) - Slice 6 missed handleSpaceRemoveFilter, so new chips would have been unremovable inside a Space - E19 contradicted the non-goal: it tested a lens/state dropdown this spec does not build; scoped to camera only - Route.map has no query-param support; extending it is now owned by Slice 4 - E9's rationale was wrong: album-intersection is impossible (server albumId is a scalar), not merely redundant - documented web param 'owner' vs server field 'ownerId' - split the overloaded Slice 7c; added E23 (viewer opened from /map), E24 (idempotent re-click), E25 (array replace)
Plan reviewed twice on Opus against the real codebase. Second pass caught two blocking bugs introduced by the first round of fixes: - withSharedSpaces throws BadRequestException unless visibility is explicit (timeline.service.ts:158-168), so E21b/E21c could never have gone green. - The SUT's injected deps are frozen from the medium factory's real: list; ctx.get() only lazily builds FIXTURE repos. Without SharedSpaceRepository registered, buildTimeBucketOptions would hit undefined under withSharedSpaces. Also fixed: make open-api / pnpm sync:open-api / make build-sdk are all removed (use mise open-api); the generated SDK lives at packages/sdk/src, not open-api/typescript-sdk/src, so it was being dropped from the commit.
Exact-match predicates on the existing asset_exif join, mirroring make/model. No migration and no new index: these follow the exact-match path, not the ILIKE/trigram path used by description/ocr/originalFileName. Tested on a two-owner Space fixture so the RBAC property is designed in from the start: a Space viewer/editor filtering by lens or state sees assets owned by OTHER members (the issue #655 bug class).
Each location affordance (city/state/country) emitted only its own subset of the location group, so clicking a state while a city filter was active merged into city=A AND state=B — two locations no asset satisfies — silently emptying the timeline behind an incoherent folded chip. Location is one dimension everywhere else (counted once, one chip, removed as a unit); a click must replace the whole dimension. Each patch now emits all three keys, clearing the siblings it does not set, mirroring how the camera patch always emits make+model. Regression tests exercise the compose-over-existing-filter case the original tests missed (they used fresh URLs).
…t (review) Leftover alias from the carry-over deletion; zero consumers across web/src.
…ation (review) The web codec clamps description/filename/OCR to 200 code points, but the server schemas that reach the ILIKE query carried no bound — a direct API caller could push a multi-kilobyte pattern into gallery-map, time-bucket and search/metadata. Add a shared boundedTextFilter (counts code points, not UTF-16 units, so a validly-clamped client value is never rejected) and apply it to those three fields on all three DTOs. Implemented as a .refine so it stays opaque to OpenAPI generation — the spec (and SDK) are unchanged. Also pin what was untested at the DTO layer: ownerId non-uuid rejection on all three DTOs (a malformed owner filter must 400, not reach SQL as a bad ::uuid cast), presence/type for the new lens/state/album fields, and the code-point bound including the astral-character boundary.
…ain by poll (review) Adds browser click-to-filter proofs for the affordances that had only unit + server-API coverage: location (incl. the sibling-staleness guard), date, filename, lens, tag, and the shared-by owner filter on a two-owner album (the only surface that row renders on). Asserts rating/description are edit-in-place, not filter values. Adds a reload round-trip (filter survives from the URL alone) and a two-chip AND-composition proof, each with genuine absent negative controls. Fixes the latent thumbnailGeneration queue-race at three sites: that queue reads empty before the thumbnail job is enqueued, so poll thumbhash!==null instead — the fix already applied to shared-space.e2e-spec.ts.
Wrap the long safeParse assertion added in 7af287f — CI server format gate.
…nail (review) The three failing tests each opened the viewer, applied a filter (which closes it), then re-opened with a fresh page.goto deep-link — which lands the detail panel over a just-filtered timeline where its affordance buttons churn (detach from the DOM, retrying) and the click never lands. Every passing test opens the viewer exactly once. - date/filename/lens: split into one single-open test each (matches the passing city/tag tests). - composition (AND): re-open the asset from the filtered grid by clicking its thumbnail — the close→reopen path the reopen-stale tripwire proves stable — instead of a deep-link. - location sibling-staleness: dropped as an e2e (kept the note). It has no honest e2e form: any location value that conflicts with the stale sibling filters the asset OUT of the timeline, so the viewer opens over a filtered-out asset and never stabilises. The three detail-panel-location unit tests prove it.
It has no stable browser form: composing a second filter requires re-opening the viewer over an already-filtered timeline, where the affordance buttons churn (found but never 'stable') until the 60s click timeout — the same reason the sibling-staleness browser test was dropped, and it survives neither a deep-link nor a grid-thumbnail re-open. AND semantics are proven deterministically at the unit level (filter-target.spec.ts merge/D2/E25) and over the wire (gallery-map.e2e-spec.ts two-tags / two-people / spaceId+ownerId → intersection); the single-affordance tests already prove chips render and filters apply on the real surface. Also removes the now-unused reopenViaThumbnail helper.
…view) An asset with EXIF model but no make emitted ?model= on click, which every surface applied, yet getActiveFilterCount counted only make and the chip required make — so the filter applied with no chip and "0 filters", leaving it invisible and unremovable. Count and chip now key on make || model.
A space-scoped map cannot express an album filter (space ∩ album is unsatisfiable and the server 400s it — hydrateMapFilters drops albumId), yet DetailPanel still offered the album affordance there and the map pin carried albumId along: clicking filtered nothing, with no chip. DetailPanel now withholds the affordance (albumFilterUnsupported) and buildContextualMapUrl drops albumId under a spaceId. The P1 space-map surface is marked forwardsAlbumId: false, matching the album surface.
main replaced the space header's SpaceMap button with a Map tab in space-tabs.svelte, whose href is a hard-coded `/map?spaceId=<id>` — the exact drop this PR fixes: every active filter and the search term are lost on the way to the map (#767a). The Photos tab already URL-backs both, and space-tabs.svelte already reads `page`, so rebuild the link from the live URL with the same codec the map page decodes (Route.map + getSearchablePageFilterState/State). Other tabs keep the bare `/map?spaceId=<id>`, since only the Photos tab's query params are filters. space-map.svelte is left as main left it — orphaned, referenced only by its own spec.
…rebase) main's Recently Added view (#813) is built on buildPhotosTimelineOptions, which this branch made take a required `userId` (D3): without it, an `albumId` chip stops being a NARROWING of my own timeline and redefines its scope to "everything in that album" — and `?album=A&owner=<co-member>` then lists a co-member's assets. Recently Added is the same own+partner surface (it strips withSharedSpaces) and its panel has the same albums/owner sections, so it needs the same gate. Thread `userId` through buildRecentlyAddedTimelineOptions and buildRecentlyAddedPickerBucketOptions and pass authManager.user.id at both call sites; the picker keeps its takenAt bucketing so grid and predicate still read the same column.
This branch put selectedYear/selectedMonth in the filter URL codec and deleted the `pendingFilterUrlSync` + `preserveTransientTemporalFilters` carry-over from every URL-backed page (photos, spaces, albums, map) along with the helper and its type. main's Recently Added view (#813) was modelled on the photos page and still used both, so the branch's removal left it importing exports that no longer exist — the web build (and svelte-check) fail on MISSING_EXPORT. Apply the same removal here: hydrate FilterState from the URL alone, which is now lossless because year/month round-trip through the codec.
The #767 map-handoff test clicked `a[aria-label="Map"][href*="spaceId="]`, the markup of the space header's SpaceMap button. main replaced that button with a Map tab in space-tabs.svelte, which renders a plain anchor with a test id and no aria-label, so the locator timed out. Target `space-tab-map` instead, and assert the href carries the spaceId before clicking — the tab is unique, so the href scoping that used to disambiguate it from the sidebar's /map link becomes a direct assertion on the thing this test is about.
main gained the same description/originalFileName/ocr map-marker filters this branch added (#802/#810), and the auto-merge kept both copies — a duplicate key in FilteredMapMarkerDto and in the shared-space marker query. Keep the branch's boundedTextFilter versions, drop the plain duplicates, and regenerate the spec, the TypeScript SDK and the Dart client from the merged DTOs.
…link at all #796 landed on main after this branch forked: a non-owner's person is rendered as plain text rather than a link into the owner-gated `/people/{id}`, which would 404. The R8 case asserted the person-page link was still reachable — that fallback no longer exists for a non-owner, so assert what the row actually does.
…pplied (review) Four issues found reviewing #778 manually, all in the seam between the id or level a contextual patch produces and the id or level the panel renders. Person chips showed a raw UUID. `buildPersonFilterPatch` emitted a BARE person uuid off a Space, but every filter-option producer there (`getPhotosPersonFilterId`) emits the scoped `person:<uuid>` token. The server accepts both, so the result set was right — but the `personNames` map and the panel's people options are keyed by the scoped token, so the chip fell back to `?? id` and the panel rendered a second, orphaned UUID row beside the named one it should have ticked. Emit the scoped token, and bank the person's name against the destination URL on click: the suggestions response is a round-trip late, and never carries a `space-person:` token at all when the viewer also owns a person for that identity. The camera panel never drilled into the selected model. The model level is lazily fetched and was only ever expanded by a click, so a make+model filter rendered one bold, unfilled "Canon" row with no model row to tick. Auto-expand the selected make, the way location-filter already reveals the city under its selected country. The cascade auto-clear on the model goes with it: now that the fetch runs on mount it would silently widen a shared `?make=…&model=…` link, so an unlisted model is pinned dimmed instead, like every other level of this panel. A `state` filter was invisible in the panel. The viewer's location row renders city / state / country as three buttons and plenty of places carry the same word at two levels (Prague's EXIF is city "Prague", state "Prague"), so the middle button emits `{state, country}` — which the chip and the server handle correctly but the panel had no level for. It showed a lone ticked country, could not remove the filter, and silently AND-ed the stale state onto the next country or city clicked. Render the state as a pinned ticked row under its country, drop it on any country/city click, and count it in `hasActiveFilter`. Selected entries fell outside the truncated head. The tag list shows ten rows and the people list five, with no slot reserved for what is actually selected, so in a large library a filter applied from anywhere but the list itself was invisible. Hoist the selection instead of scrolling to it: no measurement, survives a re-fetch, and sits with the orphans already pinned above rather than splitting the selection across two places. Same hoist for the selected country, which otherwise takes its cities and its state row off screen with it.
…ase) This branch adds `smartSearchHasCutoff` to ServerFeaturesDto (the #767c gate that tells the map whether a smart-search cutoff can narrow at all). It is a required field on the response, so the regenerated Dart client declares it as a required named parameter — a plain `bool`, not `Optional<bool?>`, because three-state only applies to non-required fields. `makeServerFeatures` in sync_stream_service_test.dart constructs the DTO directly and so no longer compiles, failing `dart analyze --fatal-infos` (and with it `Run Dart Code Analysis` and `Unit Test Mobile`). Pass `false` — this fixture models an older fork server, and the sync-capability tests it feeds do not read the flag.
…nd 4 (rebase) Three mechanical adaptations to the v3.1.0 base. No behaviour of this branch's own feature changes. 1. The "shared by" row now needs a MULTI-member album. Upstream 409734e ("fix: shared by user detail panel", immich-app#30187) tightened DetailPanel's gate from `albumUsers.length > 0` to `> 1` — a single-member album has nobody to attribute an asset to. The upstream gate is kept; the two specs that exercise the owner/shared-by filter affordance built a one-member album fixture, so the section stopped rendering and 7 tests failed looking for `filter_by_owner: Bob`. Both fixtures now carry the owner plus the viewer. 2. eslint-unicorn 64 -> 72. The new/retightened rules were swept across upstream files but not this branch's, so `pnpm lint` failed with 28 errors in web and 16 in e2e: prefer-string-repeat, prefer-split-limit, consistent-conditional-object-spread, prefer-direct-iteration, consistent-optional-chaining, prefer-number-is-safe-integer, no-global-object-property-assignment, numeric-separators-style and require-array-sort-compare. All fixes are semantics-preserving — `...(cond && obj)` spreads `false` as a no-op, `split('#', 1)[0]` yields the same first element, `[...params]` is the same entry iterator, and the isSafeInteger swap is inert on values already bounded to 1..12 / 1000..9999. The globalThis stub assignment follows the alias pattern main already uses in archive-page.spec.ts. The e2e `.toSorted()` calls take main's `(a, b) => a.localeCompare(b)` comparator; they sort UUID strings, so the order is unchanged. 3. better-tailwindcss on the new location-filter state row: `h-4 w-4` -> `size-4`, `flex-shrink-0` -> `shrink-0`, `overflow-hidden text-ellipsis whitespace-nowrap` -> `truncate`, matching main's Tailwind 4 sweep. `shadow` -> `shadow-sm` on the map's smart-search notice is a real Tailwind 4 rename, not cosmetic.
…ists `state` and `lensModel` were only ever applied on the outer select of the two queries that list their own siblings (`getCities`, `getCameraMakes` / `getCameraModels`), and there was no contributor filter at all, so an active state / lens / owner left every other suggestion list describing the whole library. Both are now members of `FilterSuggestionFilterOptions` and are applied inside `buildFilteredAssetIds`, alongside a plain `AND asset.ownerId = X` — the same treatment #858 gave `make` / `model`. The redundant outer clauses go away with the move, and each list excludes only what it must not self-narrow by: - `getStates` / `getCameraLensModels` exclude their own field. - `getCountries` (and the unified `getFilteredCountries`) additionally exclude `state`: a state implies its country, and the location group is replaced wholesale by one click in the panel, so its top level must not be narrowed by its children — the same reason `city` was already excluded there. - `getCities` keeps `state` and the camera lists keep `lensModel`, as before. `ownerId` is a narrowing predicate inside the scope `applySuggestionScope` already resolved, never merged into that scope's own owner clause, so it can only shrink the set. Both suggestion request DTOs declare the new params — an undeclared query param is stripped by the validation pipe, which is indistinguishable from "the list did not narrow". The @GenerateSql dummy params set none of the new fields, so every new predicate stays behind its $if guard and the committed server/src/queries SQL is unchanged.
`FilterContext` — the active filter set the panel spreads into the dependent city / camera-model providers and forwards to the unified suggestion request — carried only the dimensions #858 wired. The dimensions this branch added were therefore invisible to the suggestion path: with a lens, state or contributor filter active, the People / Tags / Location / Camera lists still described the whole library. Both features worked; they did not compose. `state`, `lensModel` and `ownerId` now travel on the context and on all five `getFilterSuggestions` request builders. The unified re-fetch effect tracks the lens and the contributor too — it reconstructs the state the provider reads, so an untracked field was also an unsent one. Three exclusion decisions: - `locationFilterContext` excludes `state` alongside `country` / `city`: `handleLocationChange` replaces all three on any click, so a city list narrowed by a state that the click then clears would be a lie. - `cameraFilterContext` keeps `lensModel`: `handleCameraChange` leaves the lens chip alone, so it stays an independent narrowing of the model list. - the count/disable gate keeps today's behaviour (#858 §3.3 decision 3) — the three new keys are excluded from it, since they only ever arrive from a contextual filter, typed search or a link. `albumId` stays off the context deliberately: on the suggestion endpoints that name is a *scope* that widens ownership to album participants and is mutually exclusive with `spaceId` / `withSharedSpaces`, so forwarding the panel's album filter would 400 the request instead of narrowing it. The three free-text filters stay off too — they are typed per keystroke and compile to unindexable ILIKE / trigram scans, so they belong on the query, not on a facet list.
) buildSmartSearchParams is a hand-written allowlist, and state, lensModel, ownerId, ocr and albumId were never added to it. In query mode the chips rendered as active while the server never received them, so results ignored the filter with no way for the user to tell. Browse mode (filterStateToSearchTerms) has always applied all five. All five are supported by SmartSearchDto — albumId via the plural albumIds, matching browse mode. Unlike the suggestion endpoints, where albumId is a *scope* carrying IsNotSiblingOf guards against spaceId/withSharedSpaces, SmartSearchSchema declares albumIds as a plain filter with no sibling guard, so it composes with either scope; a test pins that. description and originalFileName stay unapplied in query mode: SmartSearchDto exposes neither, and they are unindexed leading-wildcard ilike scans, so putting them on smart search is a perf decision rather than plumbing. To stop this recurring, QUERY_MODE_FILTER_HANDLING classifies every FilterState key as sent/derived/unsupported behind `satisfies Record<keyof FilterState, ...>`, so adding a dimension without deciding its query-mode behaviour now fails tsc (TS2741) instead of failing silently at runtime.
The whitespace-only ocr fixture used a bare ' ' literal, which the bumped eslint-unicorn flags as prefer-string-repeat. Lint Web is the one gate I did not run before pushing the previous commit.
…uard `route.spec.ts` gained a guard on main (#922) that scans web/src for the literal string `Route.search` and fails on any file outside its allowlist. `filter-target.ts` has no such call — it only named the API in a comment explaining what the contextual URL builder replaced. Say it in prose so the guard keeps flagging real call sites only.
…ew affordances #927 landed on main while this branch was open. It fixed #732 by adding withSharedSpaces:true to the info-panel camera/lens `/search` anchors — the anchors this branch deletes in favour of contextual filtering. Its two tests therefore assert on markup that no longer exists. Keep the coverage rather than the mechanism: move the guarantee into the branch's own filter spec, where it holds for a different reason. Clicking the camera of a co-member's photo now filters the Space you are standing on, so the photo you clicked cannot fall out of scope. The 🔍 escape to /photos keeps it too, since buildPhotosTimelineOptions sends withSharedSpaces there.
…d locales The branch added 18 user-facing keys to en.json only, so every other locale fell back to English for the new viewer affordances. Fill them in per CLAUDE.md's i18n rule. Terminology is taken from each file's existing keys rather than invented: `lens_model`, `rating`, `tag` and `filter_*` already fix the word each locale uses. Register follows each file too — informal in de/it/es, formal in fr/ru — which only surfaces in map_smart_search_not_applied, the one sentence that addresses the user. filter_chip_rating keeps its ICU plural, with the categories each language actually needs: Polish gains `few` and Russian `few`/`many`, so 2 and 5 stars inflect correctly instead of collapsing onto `other`.
#912 moved the per-section toggles out of the always-rendered icon row and into the cog's popover. It updated the tests it could see, but this spec is branch-only, so its state-only-filter case still queried a toggle that is no longer in the document until the cog is clicked. Same `openSectionMenu` step filter-panel.spec.ts grew for its own toggle tests. The assertion still discriminates: blanking the state filter it is about makes the test fail again.
Deeds67
force-pushed
the
feat/asset-viewer-contextual-filters
branch
from
August 10, 2026 17:44
be28bb4 to
d36e287
Compare
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.
Click a metadata value in the asset viewer and it filters the surface you're already on: the viewer closes, the timeline narrows, and a removable chip appears. Works on /photos, in an album, in a Shared Space, and on the map.
Fixes #767.
What became clickable
Camera, lens, city/state/country, date, filename, tags, people and "shared by" become filter affordances. Rating, description and "appears in album" get a ⚗️ icon instead of becoming clickable themselves, because their values are already interactive. A 🔍 escapes to
/photoswhen you want to leave the current surface, and the location pin carries your active filters to the map.Filters compose: the new value is merged into whatever is already applied, and every chip's ✕ removes only what it names.
#767: the map ignored the active filter
Filtering inside a Space and then opening the map showed the full library. The map is now filter-aware and URL-backed, along with the album grid, so a filtered surface stays filtered when you move between its timeline and its map. The e2e reproduces the original report — filter a Space, open the map, the map is filtered — with a negative control asserting the non-matching marker is genuinely absent rather than merely off-URL.
Server
lensModel,stateandownerIdare new filter fields on the timeline and search/map builders. RBAC is unchanged: the new fields narrow within the caller's existing visibility and never widen it.ownerIdis the one field that could leak across owners if it were merged intouserIds, and it turned out to be entirely untested — disabling it left all 4,799 server tests green. It is now pinned by medium and e2e tests, and the leak mutation is caught by two of them.Notable behaviour fixes found along the way
Testing
Server unit + medium, web unit (including a property test asserting every contextual filter round-trips through the URL codec for every surface), and e2e covering album,
/photos, map, and the RBAC scenario.Known follow-up (not in this PR)
POST /search/metadatastill carries the scope invariant/photosused to have, so "Add all to collection" with an album chip collects across all owners. Fixing it needs a DTO change, so it is deliberately left out of scope here.