Skip to content

feat(web): contextual filters — click a metadata value in the viewer to filter the surface you're on (#767) - #778

Merged
Deeds67 merged 88 commits into
mainfrom
feat/asset-viewer-contextual-filters
Aug 12, 2026
Merged

feat(web): contextual filters — click a metadata value in the viewer to filter the surface you're on (#767)#778
Deeds67 merged 88 commits into
mainfrom
feat/asset-viewer-contextual-filters

Conversation

@Deeds67

@Deeds67 Deeds67 commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

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 /photos when 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, state and ownerId are 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.

ownerId is the one field that could leak across owners if it were merged into userIds, 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

  • The album grid chipped filters it never actually applied.
  • The map ORed people/tags where every timeline ANDs them.
  • Shared links silently dropped the temporal filter.
  • Putting year/month in the URL codec let the filter carry-over machinery be deleted outright, retiring two bugs with one simplification.

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/metadata still carries the scope invariant /photos used 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.

@Deeds67 Deeds67 added the changelog:feat Feature change for changelog label Jul 14, 2026
@Deeds67
Deeds67 force-pushed the feat/asset-viewer-contextual-filters branch from c22eeb0 to 7eaac3e Compare July 14, 2026 23:27
@Deeds67
Deeds67 force-pushed the feat/asset-viewer-contextual-filters branch 2 times, most recently from 916bf35 to c5f2ead Compare July 21, 2026 20:44
@Deeds67
Deeds67 force-pushed the feat/asset-viewer-contextual-filters branch from 3cf655e to 3b7757e Compare July 25, 2026 19:00
@Deeds67 Deeds67 added the rc Auto-build a release-candidate server image and post it on the PR label Jul 25, 2026
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

🧪 Release candidate build

Latest RC pr-778-rc.9 — built from d36e287 · build run

Images published

  • ghcr.io/open-noodle/gallery-server:pr-778-rc.9 (linux/amd64 + linux/arm64)
How to run this RC

In the directory containing your docker-compose.yml, create (or append to) a docker-compose.override.yml:

services:
  immich-server:
    image: ghcr.io/open-noodle/gallery-server:pr-778-rc.9

Then pull and restart:

docker compose pull immich-server
docker compose up -d

Each 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 rc.<n> or delete the override and run docker compose up -d again.

Previous builds (9)
Tag Commit Built
pr-778-rc.8 be28bb4 2026-08-05 19:07 UTC
pr-778-rc.7 50875fe 2026-08-04 19:30 UTC
pr-778-rc.6 a4908b5 2026-07-30 13:20 UTC
pr-778-rc.5 dae54a8 2026-07-30 12:43 UTC
pr-778-rc.4 867eb48 2026-07-30 12:18 UTC
pr-778-rc.3 392f77f 2026-07-30 08:56 UTC
pr-778-rc.2 a691c14 2026-07-30 08:00 UTC
pr-778-rc.1 c493ba8 2026-07-27 08:58 UTC
pr-778-rc.0 3b7757e 2026-07-25 19:46 UTC

Last updated Mon, 10 Aug 2026 17:55:51 GMT — every push while the rc/rc-ml label is set publishes a new numbered RC. All pr-778 RC images are deleted when this PR closes.

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
Deeds67 force-pushed the feat/asset-viewer-contextual-filters branch from c493ba8 to a691c14 Compare July 30, 2026 07:36
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
Deeds67 force-pushed the feat/asset-viewer-contextual-filters branch from a691c14 to 392f77f Compare July 30, 2026 08:42
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
Deeds67 force-pushed the feat/asset-viewer-contextual-filters branch from a4908b5 to 50875fe Compare August 4, 2026 19:12
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
Deeds67 force-pushed the feat/asset-viewer-contextual-filters branch from 50875fe to be28bb4 Compare August 5, 2026 18:49
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).
Deeds67 added 28 commits August 10, 2026 19:41
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
Deeds67 force-pushed the feat/asset-viewer-contextual-filters branch from be28bb4 to d36e287 Compare August 10, 2026 17:44
@Deeds67
Deeds67 merged commit 578dbea into main Aug 12, 2026
54 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog:feat Feature change for changelog 📱mobile rc Auto-build a release-candidate server image and post it on the PR 🗄️server 🖥️web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Map view ignores active search filter inside a Space, shows full library instead of filtered results

1 participant