Settings → Profile opens the same file picker as attaching to a mail, in an avatar mode: albums, people, search and paging, one picture at a time, files that cannot be a picture greyed out. The choice is posted straight back to the profile form, and the connection is matched against the user's own. From Paperless-ngx the original is taken rather than the archived PDF. The setup wizard keeps its small grid for now.
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Save to… chooser drew Paperless tags as folders to open, and clicking one did nothing. A DestinationKind (folder, album, tag) now decides how the chooser is drawn: a tag is the choice itself, with a No tag row. The upload is sent as a string with a Content-Length instead of streamed, which Paperless read as an empty form ("No file was submitted", #79).
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Immich stores the video half of a Live Photo as an asset of its own with
visibility "hidden", never shows it in its timeline and never makes a
preview for it. A metadata search lists every visibility but "locked"
unless asked for one, so those clips came back as grey placeholder tiles
that could only be attached as a three-second video.
They are dropped when an entry is built rather than filtered in the
request: the request can name one visibility only, and "timeline" would
take archived photos away with the clips. A server that predates the
field sends none, and such an asset is kept. Library, search, album and
person views all share the mapping, and a page of nothing but clips
still carries its cursor so the scroll goes on.
A page can now arrive with no entries and a cursor. The picker said
"empty" for any entry-less listing and rendered no results container,
which is what the next page is appended into, so an all-clips first page
would have swallowed every page after it. "Empty" is said only when
there is no cursor either, with two render tests for the two sides.
Fixes#46
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Twenty-one stream responses each spelled out the same append-to-
toast-region wrapper around _toast.html.twig. They now include
_partials/_toast.stream.html.twig with the same message and type, so the
markup is unchanged. Compose streams (owned elsewhere) and
_send_outcome (passes extra hide_after) are left as they are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The integration picker's status line, the connection test's request
failure, the layout's default "Welcome!" title and the demo pages'
operator warnings were English in every locale. The picker and the
connection test get their words as Stimulus values from Twig; the demo
warnings keep the variable names as identifiers beside a translated
sentence. Also adds demo.privacy.processing_title, which the privacy
page used and no locale had.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Let a filter choose the folder it saves attachments into.
"Save attachments to Nextcloud" had no folder beside it, so every
filter-driven upload landed in the root of the connection — while the same
save done by hand from a message opens a browsable chooser.
The rule editor now opens that same chooser rather than a second one that
looks like it. The destination picker grew a part-less mode: a save from the
reading pane has an attachment in hand and browses to a folder to put it in, a
rule is naming a folder for mail that has not arrived, so with nothing in hand
the choice is reported back to the editor instead of uploaded to on the spot.
Presence of the part field is what tells the two apart — an explicit part=0
still 404s, so the save path keeps its ownership check.
Nothing behind the editor needed building. RuleActionExecutor, the queue
message, the handler and the driver have all carried a folder the whole time;
there was no way to set one.
Released together with the bulk-queue work in the commit before this one; the
changelog entry covers both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Saving an attachment to a connected service was fire-and-forget into one
configured folder, and every upload-capable service was offered for every
attachment. Two changes:
Type gating. Provider::acceptsMime() decides, off the media-library flag the
picker already renders from: a photo library takes image/* and video/* only, a
file store takes anything including an unknown type. The "Save to…" menu filters
its targets through IntegrationsGlobal::forUploadOf(mime), so "Save to Immich"
never appears on a PDF.
Destination picker. Choosing a service now opens the file picker in a new
destination mode instead of saving on the spot — a folder chooser for a file
store ("Save here" on the current folder), an album chooser for a photo library
(each album a save target, plus "Library" for none). Both reuse the modal shell,
_folder_row and the driver listings; both offer create-folder / create-album
where the provider makes it cheap (Nextcloud MKCOL, Immich/Google Photos POST
/albums). The chosen destination is threaded into pushAttachment and validated
server-side by the driver — Nextcloud refuses traversal, the photo libraries
confirm the album belongs to the account — before a byte moves. The
fire-and-forget default (filter rules, any save with no chosen destination) is
untouched, and the last chosen destination is remembered under its own setting
key so it cannot redirect a rule.
Tests: mime-gating matrix, destination threading + validation short-circuit,
driver-level traversal/foreign-album rejection, create-folder/album; e2e for the
gate (photo library on an image, not a PDF) and the folder picker carrying its
destination, with a seeder for the two-attachment / two-connection fixture.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A-04: every account rendered a folder toggle labelled "Toggle account folders",
so three accounts gave three controls a screen reader reads identically. The
address is in the label now, and the account link is named explicitly rather
than by a `truncate` span that CSS can cut mid-address.
A-07 (second half): computing --rgb-accent-ink by luminance achieves nothing
while 63 call sites paint `text-white` on `bg-accent` directly. They now use
text-accent-ink and inherit the measured choice. The two left behind are white
over a black gradient and over a danger fill, which are not the accent.
H-05: the search box kept a stale query when a folder was clicked — the input
survives the Turbo visit and a folder page has no `q` to overwrite it with, so
the box claimed a filter the list below it had not applied. It now tracks `?q=`
in the address bar, which is also right on the way back in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mail sign-in credentials were env-only, so adding Gmail to a running
deployment meant editing a file and restarting a container. They are the same
kind of thing IntegrationProviderConfig already holds, so they now sit beside
it in a "Mail sign-in" block above the services, with the Gmail Pub/Sub topic
and push verification token alongside.
Nothing existing breaks. OAuthProviderFactory and the new GmailPushSettings
both read a stored value first and fall back to the env vars, and "first" does
not mean "a half-filled row wins" — an admin who saves a client id and no
secret must not silently disable a working .env. That precedence has its own
test, because every existing Gmail and Microsoft account depends on this path.
GmailPushSettings also collapses four separate #[Autowire(env:)] reads of the
same two values, which is what made them unchangeable without a restart.
Google Drive, Google Photos and OneDrive can now inherit the mail app's
credentials — they genuinely are the same registration. The copy is
server-side: the secret moves between rows and never enters a response, so an
admin can reuse a secret they were never shown. The help text says plainly
that this grants no scopes; the permission still has to be added at the
provider, and a silent half-setup would be worse than no button.
The three forms were hand-drawn — inputs, labels and footers written out by
hand, bypassing the form theme entirely. They are FormTypes now, rendered
through the shared modal shell, which gained before_fields/after_fields blocks
so a form can add prose or a copyable value without redrawing the chrome. Same
appearance, minus four slightly different footers.
That refactor surfaced three real bugs:
The theme's checkbox_widget delegated to form_widget_simple, which emits
neither type="checkbox" nor checked — so every checkbox in a modal form
rendered as an empty text input. Nothing had used one yet, so it had stayed
latent. Rendered directly now, and radio too.
A Symfony form with no explicit action submits to the *document* URL. These
render inside a Turbo Frame, so every save was posting to
/admin?section=integrations and quietly doing nothing.
{% embed ... only %} does not pass the outer context into overridden blocks,
which is why the forms first came back as "content missing".
Spacing: the dialog was tight and help text ran into the next field's label.
Rows are further apart than the gap inside a row now, and help is ink-faint
rather than ink-muted so it reads as subordinate to its own field.
Element ids on the mail block, the mail rows and the provider rows. Locating
those by visible text kept matching the tutorial body inside the row, which
cost several rounds of chasing e2e locators for no reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four fixes, all from using the pickers against real servers.
Nextcloud had no search at all. It does now, via WebDAV's SEARCH method rather
than the OCS unified-search endpoint: OCS answers with app URLs like
/apps/files/?dir=…&openfile=123, which are not WebDAV paths, and our entry ids
are paths. SEARCH returns ordinary multistatus hrefs, so the existing parsing
handles the response unchanged and every hit is immediately addressable. Drive,
OneDrive and Dropbox gained search too, so the only provider without it is
Google Photos, whose Library API offers no text search over media.
The scope href in that request has to be relative to the DAV root —
"/files/alice", not "/remote.php/dav/files/alice". Nextcloud cannot resolve the
absolute form and answers 404, which surfaced to the user as "could not find
that folder or file" when the folder was fine and the request was not.
The thumbnail route answered 404 whenever a service had no preview. Legitimate
HTTP, but plenty of things legitimately have no preview, so the console filled
with failed requests that looked like breakage and buried real errors. It now
serves a neutral inline SVG with 200, which also makes three copies of an inline
onerror handler dead code, so they are gone.
Previews in the file list were costing far more than they were worth: a search
returning 200 rows fired 200 proxied requests, each making the service render a
document preview — 4.5 MB and 34 seconds for a list of invoices, where every
preview resolved to the same red PDF square. Previews are now asked for only
where they tell you something, images and video, and everything else gets a
typed icon. The Nextcloud search cap dropped from 200 to 60 at the same time:
basicsearch has no cursor, so that number is the whole result set, and a picker
is for finding one file.
Dropdowns were see-through enough to read toolbar icons through their labels.
The design system had pane and pane-flat, both alpha surfaces, and nothing for
something that floats over content — so there is now a popover utility with an
opaque surface, used by the compose service menu and the save-to menu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The photo grid got the attention and the other two views were left behind.
In the file list the only way to pick something was a small "Copy" chip on the
right, so the obvious gesture — click the file you want — did nothing at all.
The whole row selects now, the way a whole tile does in the grid.
Copy and link stay mutually exclusive with no JavaScript by remaining two radios
in one group. Nesting a label inside a label is invalid, so the row's label is
absolutely positioned over the row, the content above it is pointer-events-none
so clicks fall through, and the link chip sits above both with its own label.
One tap picks copy; the chip still picks link.
Link is no longer permanently visible. It appeared on every row, which made each
one look like it needed a decision, when copy is what almost everyone wants; it
now shows on hover, on focus, or when a file is oversized and link is the only
option left. No tick on the row either — the ring and tinted fill are
unambiguous across a full width, and the only free corner is where that chip
lives.
Folder rows were drawn twice with different icons, fa-folder in the list and
fa-images in the grid's album list, so the same thing looked like two things
depending on where you met it. One partial now, with the icon chosen by whether
the service holds photos rather than by which branch rendered it.
Dropped the divide-y rules between rows: rows now carry their own rounded hover
and selected fills, and hairlines cut straight through them.
People tiles grew to match the wider dialog — 96px portraits, a hover ring, and
room for the name to be read rather than truncated at 11px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dialog read as a three-tone sandwich: bg-sunken filled both the header and
the footer while the body sat on the pane surface. That is the opposite of what
the rest of the app does — a pane is one surface and divides with hairlines,
keeping bg-sunken for genuinely inset areas. The header also rounded with
rounded-t-2xl against a panel using rounded-pane, so the corners disagreed at
every density setting.
Now: one continuous surface, no fills, and no internal rules at all. The
hairlines under the header, above the footer and beside the scrubber read as
seams across a single sheet rather than structure, so spacing carries the
separation instead. The panel clips, which is what keeps the corners honest.
Width is per-trigger. A label form wants to stay narrow and a photo grid wants
the screen, so a trigger asks for a preset and the controller applies it —
every preset removed before the chosen one is added, since the shell is shared
and a wide picker would otherwise leave the next form stretched. The map lives
in the template, not the controller: a width named only in JavaScript risks
being purged from the Tailwind build. Media libraries open at the widest preset,
file stores one step down.
The frame no longer scrolls; it is a flex column and the loaded content decides
what moves inside it. That is what lets the picker hold its search, trail and
footer still while only the results scroll, and it replaces the picker's fixed
55vh slice of the viewport with "whatever the panel has spare", floored so a
short listing still looks deliberate.
Note for anyone hitting a stretched, footer-less dialog after pulling this:
Tailwind compiles on demand, and this adds new arbitrary values. Run
bin/console tailwind:build — without the rebuild the panel has no height cap and
pushes its own footer off screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entry carried a public $kind property alongside a kind() accessor. Twig
resolves a property before a method of the same name, so every template read
the raw null and the picker died on any listing that was not empty — which is
to say, always. The property is now $declaredKind and templates ask
Listing::people() instead of reaching into the first entry's kind.
The e2e suite could not have caught this: with no real Immich to talk to it
only ever renders the picker's error branch. So there is now an invariant test
asserting no public property on these DTOs shadows a public method — the
collision is not an error anywhere in PHP, it is a runtime failure in whichever
template happens to call the accessor. Verified it fails when the shadowing is
put back.
The scrubber pushed through the footer. Buckets are monthly, so a twenty-year
library is ~240 segments, and a minimum height on each made the bar taller than
the grid beside it. Segments are now purely proportional — basis-0 plus a grow
factor totals exactly the container height and cannot overflow — the bar clips,
and the height bound moved to the row, since bounding only the scroller left the
other child free to grow it.
Year labels moved out of the segments into an absolutely positioned overlay at
each year's cumulative offset. Inside a one-pixel December a label spilled over
its neighbours, which is why the early years came out stacked; a year is now
skipped if it would land within about one line of the last one drawn.
A thin month is a thin target as a result. That is what dragging is for, and
the readout names where you are.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three additions to the photo picker.
A date bar down the right of the grid, sized by how many photos each month
holds so it shows the shape of the library rather than treating a busy month
and an empty one alike. Counts come from /api/timeline/buckets in one call,
which is what makes jumping to a month that has never been loaded instant: the
query restarts at that date instead of paging forward to reach it. A bar
derived from loaded pages would have misrepresented the library until the user
had scrolled all of it, which is worse than no bar.
The cursor had to grow an anchor for that to work — "<iso>@<page>" rather than
a bare page number. Without carrying the date along, page two of a jump to
2019 would silently have come from the top of the library. A hand-edited
cursor falls back to the start rather than 500ing, and the date is validated
before it reaches a query.
People, from Immich's face recognition, as a second shortcut beside Albums.
Neither is a parent of the other: both are ways of slicing the same library.
Faces render as portraits with the name always under them, because a face is
identified by its name and hiding it behind hover the way the photo grid does
would make the grid unreadable. Unnamed faces are still listed — "who is this"
is a reasonable thing to go looking for.
The search box now serves both meanings. It takes the folder it was launched
from, so in the people view it filters faces by name via /api/search/person
and everywhere else it searches photos. One box, two behaviours, rather than a
second box that only appears sometimes.
Two seams rather than special cases: EntryKind lets a driver say it handed over
a person, so the template picks the shape from the data instead of checking
which provider it is talking to; and TimelineDriverInterface joins
SearchableDriverInterface as an optional capability, so the five drivers with no
timeline carry nothing. Buckets are fetched only where a whole-library date bar
means something — not beside an album, a person, or a search result, each of
which is already a slice.
The scrubber degrades all the way down: segments are real links, so it works by
navigation with no JavaScript; the controller only adds the hover readout and
drag-to-scrub, which commits on release so passing over a month costs nothing.
A missing buckets endpoint costs the bar and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five bugs from using the grid for real.
Searching closed the modal. The modal treats any successful submit as "done",
which is right for a form and wrong for the search box, since that submit is
navigation. Forms can now opt out with data-modal-keep-open.
The selection ring looked muddy along the bottom edge because the date
gradient painted over it. The ring is now the last child, so it sits above
both the image and the bar, and it shares rounded-lg with the clipping label
so the two radii cannot disagree. The scale-on-select transform is gone —
shrinking the image pulled its corners away from the ring, which is what made
the radii look mismatched in the first place.
Scrolled images showed up in the gap under the shortcuts row. Padding on a
scroll container is not clipped, so content scrolls into it and stays visible;
the vertical padding moved to an inner wrapper. The breadcrumb and shortcuts
also merged into one row, so the results start below a single opaque boundary.
Paging had silently vanished from the grid: the "load more" block was added
inside the list branch only. It is back for both, and now appends rather than
navigates — a frame link reset the scroll to the top and dropped every tick
already made, which on a photo grid means starting over. An IntersectionObserver
rooted on the scroll container auto-loads ahead of the sentinel, and the link
still works by navigating if that script never runs.
Tiles and rows moved into their own partials, because the paging script appends
more of exactly them and a second copy of that markup in JavaScript would
drift from the copy in Twig.
The "no button when nothing is connected" e2e test was order-dependent: it
assumed a fresh database while this file's other tests connect a service. It
now disconnects whatever it finds first, and waits for the frame's content
rather than the frame — the placeholder is visible before the content lands,
so the earlier version found nothing to disconnect and its premise quietly
stopped holding.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four fixes and two features, from testing the thing against a real server.
The bug behind "no files listed" was mine. An unknown size was treated as
possibly-oversize, and since Immich has no share link to fall back on, that
made *every* photo unattachable — greyed out with "too large" and no way to
select it. Only a known oversize blocks a copy now; the attach endpoint
already re-checks the real size once it has the bytes and reports what it
skipped, so being optimistic costs one wasted fetch in the rare case and is
the difference between a usable picker and a dead one.
The landing view is the library itself rather than the album list. Most
photos worth attaching were never filed into an album, so opening on albums
made a full library look empty. Albums are still there as a sideways jump,
offered through a new Listing::$shortcuts — driver-declared navigation, so a
service's own shape reaches the UI without the template growing a
per-provider branch.
Search is Immich's own. Smart search first, because CLIP understanding "dog
on a beach" is the reason to use Immich's search rather than ours; its ML
service is optional, so a failure falls back to metadata search on filename
— a dumber search beats a dead search box. Capability::Search plus a
separate SearchableDriverInterface, so drivers with nothing to offer here
do not carry a stub that throws.
Photo libraries now render as a grid of previews instead of a list of
filenames, because nobody recognises a photo by reading IMG_4821.jpg. The
whole tile is the control; the date appears on hover, on keyboard focus, and
while selected — hover alone would hide it permanently on a touch screen.
Provider::isMediaLibrary() draws the line, since a wall of thumbnails is
just as wrong for picking a spreadsheet.
Tap handling is guarded. The grid fills the scrollable area, so nearly every
scroll gesture starts on a tile, and browsers do not reliably suppress the
click after a touch scroll once momentum and nested scrollers are involved.
A pointer that drifts more than 10px cancels the toggle — an unexpected
photo arriving in someone's mail is a worse failure than a missed tap.
Album contents come from search rather than the album endpoint, which
returns every asset in one response — fine for a holiday album, not for one
with ten thousand photos. Paging is a link, so it stays JavaScript-free.
Also lists the permissions an Immich API key needs, in the connect form
where the person minting the key will read it. A key short of one of these
fails only at the operation needing it — uploads working while previews 404
— which is far harder to diagnose than being told up front.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Attachments gain a "Save to…" menu listing the user's Upload-capable
connections, so a file can leave the app the same way it came in.
AttachmentResolver is what makes this work everywhere: a gmail:// or
msgraph:// part is materialised on first access, so an attachment on a
Gmail message that has never touched our disk uploads exactly like a
locally stored one. Its failure is caught separately from the driver's —
being unable to fetch the original says nothing about the integration's
health, and must not mark a working connection as broken.
The chip moved into a shared partial. It was duplicated across
_thread_message and _message_content, and adding a second half to both
copies is how one of them ends up stale. The service list comes from a new
`integrations` Twig global rather than a passed-in variable because several
of the five call sites include with `only` — threading it through would
touch every render path that shows a message and silently miss one.
Saving changes nothing on screen, so the response is a toast and nothing
else: no frame to replace, and the thread keeps its scroll position.
The e2e test needed an attachment fixture, and the first version of the
seeder used its own account — which left an extra thread in the inbox
forever and broke four bulk-action specs that count rows. It now seeds into
the account seed-mail already owns, so that seeder's wipe cleans up after
it whichever spec file runs first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The compose action bar had a dead Google Drive button with no handler. It
becomes an entry point sized to what the user actually has: nothing at all
with no connections, the provider's own icon with one, a menu only once
there is a choice to make.
Per file the user picks copy or link. Copy fetches the bytes into a real
MessagePart, so the mail is self-contained and the recipient needs nothing
from the service; it obeys the same 25 MB cap a local upload does. Link
asks the driver for a public URL and drops an anchor in the body — the
only option above the cap.
Both constraints are enforced where they are known rather than discovered
late. A file over the cap cannot be copied, a service that cannot share a
single file cannot link, and a file that is both is shown as unattachable
instead of offering a choice that would fail. An unknown size counts as
possibly-too-big: Immich omits it until EXIF extraction runs, and a copy
that dies at the cap after downloading wastes the round trip.
Bytes are fetched eagerly rather than deferred behind an integration://
storage scheme. The cap bounds the request, and it leaves AttachmentResolver
— which every send and download already goes through — untouched.
The response is JSON plus one window event rather than a Turbo Stream.
Compose lives in the dock or thread frame and the picker in the modal
frame, so a stream would have to reach across two nested frames and fight
the modal's close-on-submit. The event carries the draft id so compose
windows other than the one that opened the picker ignore it.
Thumbnails are proxied through our own route: every service puts previews
behind the same credential as the originals, so a direct URL would either
leak the credential into markup or 401 in the browser.
Adds a small generic dropdown controller — compose's from-picker has
bespoke open/close logic written before there was a second menu to justify
sharing anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>