Choose a profile picture through the compose file picker (#65)

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>
This commit is contained in:
authored and GitHub committed 2026-10-15 11:38:44 +02:00
1 parent 3d7a25c1e6
commit 19266223e8
26 files changed
+851 -108

No files matched your search

+11
View File
@@ -33,6 +33,17 @@ a row beside Drafts for every account together, and a row at the top of each acc
that account alone. Archiving or snoozing from it leaves the conversation in the list. A
conversation with several labels is listed once. No migration.
**Choosing a profile picture from Immich, Paperless-ngx or another connected service opens the
same picker as attaching to a mail.** Settings → Profile showed a small grid of its own: the first
couple of dozen images at the top of the service, with no albums, no people, no search, no way to
scroll on. Each connection's button now opens the full picker, with "Use as profile picture" where
a mail has "Attach", and one picture can be chosen at a time. A PDF, or a file the service says is
over 4 MB, is shown greyed out rather than hidden; Immich and Google Photos do not report sizes, so
a large photo from them is still refused after it is chosen, with the reason under the picture
field. A scanned photograph from Paperless-ngx is now taken as the original — it used to arrive as
the archived PDF and be refused. The setup wizard's profile step still has the small grid, because
the picker would replace the wizard it is inside. No migration.
**Remote images can be shown in every message without asking (#124).**
Settings → Mail → Images has one switch, **Always show remote images**, off until you turn it on.
With it on, no message asks before loading its pictures; they still come through plMail's image
@@ -21,6 +21,13 @@ export default class extends Controller {
url: String,
draft: Number,
token: String,
/**
* 'attach' (compose) or 'avatar' (Settings → Profile). In avatar mode one
* file is chosen and handed back rather than attached to a draft.
*/
mode: { type: String, default: "attach" },
/** The connection being browsed, for the avatar hand-back. */
integration: String,
/** The status line's words, translated in _picker.html.twig. */
i18n: { type: Object, default: {} },
};
@@ -168,6 +175,26 @@ export default class extends Controller {
}
}
/**
* One file only, in avatar mode.
*
* Rows are a radio group each, so the browser keeps just one *per file*
* and nothing stops a second tick; tiles are checkboxes. A profile has one
* picture, so choosing another lets go of the last — and it has to hold
* across pages, which the paging script appends into the same container.
*/
changed(event) {
if ("avatar" !== this.modeValue || true !== event.target.checked) {
return;
}
this.modeTargets.forEach((input) => {
if (input !== event.target) {
input.checked = false;
}
});
}
async attach() {
const chosen = this.modeTargets.filter((input) => input.checked);
@@ -177,6 +204,12 @@ export default class extends Controller {
return;
}
if ("avatar" === this.modeValue) {
this._chooseAvatar(chosen[0]);
return;
}
if (0 === this.draftValue) {
// The draft is force-saved before the picker opens, so this means
// that save failed rather than that the user was too quick.
@@ -239,6 +272,27 @@ export default class extends Controller {
this.dispatch("close", { prefix: "ui--modal" });
}
/**
* Hand the chosen picture to the profile form, which posts it.
*
* Nothing is saved here: the picker and the form sit in different Turbo
* frames, so the event is the seam, as it is for attachments. The file id
* is read back out of the input's name (`mode[<id>]`) rather than through a
* Stimulus param, for the reason destination_picker_controller gives — a
* service's id is an opaque string and a typecast would change it.
*/
_chooseAvatar(input) {
const fileId = input.name.replace(/^mode\[(.*)\]$/, "$1");
document.dispatchEvent(
new CustomEvent("plmail:avatar-picked", {
detail: { integrationId: this.integrationValue, fileId },
}),
);
this.dispatch("close", { prefix: "ui--modal" });
}
/** A translated word, or the key itself — visibly wrong rather than English. */
_t(key) {
return this.i18nValue[key] ?? key;
@@ -0,0 +1,59 @@
import { Controller } from "@hotwired/stimulus";
/**
* Posts the profile form with a picture chosen in the file picker.
*
* The picker renders in the body-level modal frame and the form is in the page,
* so they cannot address each other through the DOM; the picker announces its
* choice on `document` (integration_picker_controller.js) and this is what
* listens. It adds the two fields ProfileType expects for a picture from a
* service — they are not in the form until there is a choice, so an ordinary
* save posts nothing about it — and submits.
*
* Submitting, rather than filling the fields and waiting for Save: choosing and
* saving have always been one action here, and a picture picked in a dialog
* that then needs a second click somewhere behind it reads as a pick that did
* nothing.
*
* The server does not trust either field. ProfileUpdater matches the
* integration id against the user's own connections and AvatarFromIntegration
* decides what the bytes are.
*/
export default class extends Controller {
static values = {
/** The form's name, which prefixes its fields: `profile[avatarFileId]`. */
name: String,
};
picked(event) {
const { integrationId, fileId } = event.detail ?? {};
if (undefined === integrationId || undefined === fileId) {
return;
}
this._set("avatarIntegrationId", integrationId);
this._set("avatarFileId", fileId);
this.element.requestSubmit();
}
/** A hidden field, replaced rather than duplicated on a second pick. */
_set(field, value) {
const name = `${this.nameValue}[${field}]`;
this.element
.querySelectorAll("input[type=hidden]")
.forEach((input) => {
if (input.name === name) {
input.remove();
}
});
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = value;
this.element.append(input);
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller\Integration;
use App\Controller\ChecksCsrf;
use App\Domain\Helper\AvatarStorage;
use App\Domain\Helper\InlineDisposition;
use App\Controller\Mail\ComposeAttachmentController;
use App\Domain\Enum\Integration\Capability;
@@ -14,6 +15,7 @@ use App\Entity\Mail\Message;
use App\Entity\Mail\MessagePart;
use App\Security\Voter\OwnershipVoter;
use App\Service\Integration\IntegrationFilePicker;
use App\Service\User\AvatarFromIntegration;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -67,6 +69,7 @@ final class FilePickerController extends AbstractController
public function __construct(
private readonly IntegrationFilePicker $picker,
private readonly AvatarFromIntegration $avatars,
private readonly EntityManagerInterface $em,
) {
}
@@ -90,6 +93,18 @@ final class FilePickerController extends AbstractController
$this->assertUsable($integration, Capability::Browse);
// Choosing a profile picture is the third way in. It is this same
// picker — albums, people, search, the date bar and paging — because a
// second, smaller one only ever showed the first page of the library
// root. What differs is the end of it: nothing is attached to a draft,
// the choice is handed back to the profile form (see
// integration_picker_controller.js), and that form posts it.
$forAvatar = 'avatar' === $request->query->get('mode');
if (true === $forAvatar) {
$this->assertUsable($integration, Capability::Download);
}
$folderId = $request->query->get('folder');
$draftId = $request->query->getInt('draft');
$cursor = $this->blankToNull($request->query->get('cursor'));
@@ -104,8 +119,14 @@ final class FilePickerController extends AbstractController
'folderId' => $folderId,
'draftId' => $draftId,
'query' => $query,
'maxBytes' => ComposeAttachmentController::MAX_ATTACHMENT_BYTES,
'canLink' => $integration->supports(Capability::ShareLink),
'mode' => $forAvatar ? 'avatar' : 'attach',
// The avatar store's own cap, so a photo it would refuse shows as
// too large rather than being chosen and then bounced.
'maxBytes' => $forAvatar ? AvatarStorage::MAX_BYTES : ComposeAttachmentController::MAX_ATTACHMENT_BYTES,
// Files on this page that cannot be a picture: shown, not choosable.
'refusedIds' => $forAvatar ? $this->avatars->refusedIds($view->listing) : [],
// A share link is no use as a profile picture.
'canLink' => false === $forAvatar && $integration->supports(Capability::ShareLink),
'canThumb' => $integration->supports(Capability::Thumbnail),
'canSearch' => $view->canSearch,
'buckets' => $view->buckets,
@@ -170,7 +170,6 @@ final class SettingsController extends AbstractController
// comes back whole with the errors on it — see ProfileController.
...$this->profileSection->build(
$this->getUser(),
$request,
$request->attributes->get('profileForm') instanceof FormInterface
? $request->attributes->get('profileForm')
: null,
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Domain\Interface;
use App\Domain\DTO\Integration\RemoteFile;
use App\Domain\Exception\IntegrationException;
use App\Entity\Integration\Integration;
/**
* A driver whose download() is not the file the listing described.
*
* Paperless-ngx is the one: its listing reports the original's type, while
* download() answers with the archived PDF it made from it, which is the copy
* worth attaching to a mail. For something that has to be the original — a
* profile picture taken from a scanned photograph — that PDF is the wrong file,
* under a type the listing never promised.
*
* Separate from IntegrationDriverInterface for the same reason
* SearchableDriverInterface is: every other driver has one file per id, and
* folding this in would make six of them carry a method that repeats download().
*/
interface OriginalFileDriverInterface
{
/**
* The file as it was put into the service, not as the service rendered it.
*
* @throws IntegrationException
*/
public function downloadOriginal(Integration $integration, string $fileId): RemoteFile;
}
+6 -4
View File
@@ -55,10 +55,12 @@ final class ProfileType extends AbstractType
'attr' => ['accept' => implode(',', AvatarStorage::ALLOWED_MIME)],
]);
// Present only while pictures from a connected service are on screen.
// The file id is set by the thumbnail the user clicks — each is a submit
// button carrying this field's name — so the choice and the submission
// are one action rather than two.
// Present only when a picture from a connected service is being posted.
// In the setup wizard the file id is set by the thumbnail the user
// clicks — each is a submit button carrying this field's name. In
// Settings the file picker's dialog announces its choice and
// settings/avatar_from_service_controller.js adds both fields and
// submits. Either way the choice and the submission are one action.
if (null !== $options['avatar_source']) {
$builder
->add('avatarIntegrationId', HiddenType::class, [
@@ -11,6 +11,7 @@ use App\Domain\Enum\Integration\Provider;
use App\Domain\Exception\IntegrationException;
use App\Domain\Interface\DestinationDriverInterface;
use App\Domain\Interface\IntegrationDriverInterface;
use App\Domain\Interface\OriginalFileDriverInterface;
use App\Domain\Interface\SearchableDriverInterface;
use App\Entity\Integration\Integration;
use App\Repository\Integration\IntegrationProviderConfigRepository;
@@ -62,7 +63,7 @@ use Symfony\Contracts\HttpClient\ResponseInterface;
* so neither needs a prefix to tell it from the other. The one id that *is*
* prefixed is the one upload() hands back; see there for why.
*/
final readonly class PaperlessDriver implements IntegrationDriverInterface, SearchableDriverInterface, DestinationDriverInterface
final readonly class PaperlessDriver implements IntegrationDriverInterface, SearchableDriverInterface, DestinationDriverInterface, OriginalFileDriverInterface
{
private const string API = '/api';
@@ -204,14 +205,31 @@ final readonly class PaperlessDriver implements IntegrationDriverInterface, Sear
public function download(Integration $integration, string $fileId): RemoteFile
{
$id = $this->documentFrom($fileId);
// No ?original=true: /download/ answers with the archived PDF where
// Paperless made one and falls back to the original by itself where it
// could not. That is the copy the user saw when they filed the document
// — searchable, with the OCR layer baked in — and asking for the
// original would attach the unreadable scan instead.
$response = $this->request($integration, 'GET', $this->url($integration, '/documents/'.$id.'/download/'));
return $this->fetch($integration, $fileId, false);
}
/**
* For a profile picture, which has to be the picture: a scanned photograph's
* archived copy is a PDF, and the listing it was chosen from said JPEG.
*/
public function downloadOriginal(Integration $integration, string $fileId): RemoteFile
{
return $this->fetch($integration, $fileId, true);
}
private function fetch(Integration $integration, string $fileId, bool $original): RemoteFile
{
$id = $this->documentFrom($fileId);
$response = $this->request($integration, 'GET', $this->url(
$integration,
'/documents/'.$id.'/download/'.(true === $original ? '?original=true' : ''),
));
try {
$contents = $response->getContent();
+40 -5
View File
@@ -5,10 +5,12 @@ declare(strict_types=1);
namespace App\Service\User;
use App\Domain\DTO\Integration\Entry;
use App\Domain\DTO\Integration\Listing;
use App\Domain\Enum\Integration\Capability;
use App\Domain\Exception\AvatarRefusedException;
use App\Domain\Exception\IntegrationException;
use App\Domain\Helper\AvatarStorage;
use App\Domain\Interface\OriginalFileDriverInterface;
use App\Entity\Integration\Integration;
use App\Entity\User\User;
use App\Repository\Integration\IntegrationRepository;
@@ -23,10 +25,13 @@ use finfo;
* asking someone to download one and upload it again is asking them to do the
* computer's job.
*
* Deliberately not the compose file picker: that one is built around attaching
* to a draft, and its browse endpoint takes a draft id. What is needed here is
* narrower — one image, no folders to speak of, no selection modes — so it
* borrows the drivers rather than the picker.
* Settings → Profile chooses through the compose file picker, in its avatar
* mode (FilePickerController::browse), and this class only decides what may be
* chosen and keeps the result. That replaced a smaller grid of its own, which
* listed the first page of a service's root and had no albums, no search and no
* paging — it showed whatever happened to be newest and nothing else. browse()
* below is that grid's data and is now used only by the setup wizard, whose
* profile step is itself inside the modal frame the picker opens in.
*/
final readonly class AvatarFromIntegration
{
@@ -84,6 +89,29 @@ final readonly class AvatarFromIntegration
return array_slice($images, 0, self::LIMIT);
}
/**
* Files on a picker page that cannot become a picture, so the picker can
* show them without letting them be chosen.
*
* The compose picker lists whatever the service holds — a Paperless library
* is mostly PDFs — and hiding those would leave an empty-looking page with a
* working "show more" under it. Greyed out, it is plain why nothing there
* can be picked.
*
* @return list<string> entry ids
*/
public function refusedIds(?Listing $listing): array
{
if (null === $listing) {
return [];
}
return array_values(array_map(
static fn (Entry $entry): string => $entry->id,
array_filter($listing->files(), static fn (Entry $entry): bool => false === self::looksLikeAnImage($entry)),
));
}
/**
* Only what the avatar store will take — PNG, JPEG, GIF, WebP — so nothing
* is offered that is then refused. A HEIC photograph is an image, but not
@@ -125,7 +153,14 @@ final readonly class AvatarFromIntegration
public function apply(User $user, Integration $integration, string $fileId): void
{
try {
$file = $this->drivers->forIntegration($integration)->download($integration, $fileId);
$driver = $this->drivers->forIntegration($integration);
// The original where the service keeps one apart from what it
// serves: the picker listed the file by the original's type, and
// the rendition — Paperless's archived PDF — is never a picture.
$file = $driver instanceof OriginalFileDriverInterface
? $driver->downloadOriginal($integration, $fileId)
: $driver->download($integration, $fileId);
} catch (IntegrationException $e) {
throw new AvatarRefusedException(AvatarRefusedException::UNAVAILABLE, $e);
}
+10 -46
View File
@@ -4,12 +4,10 @@ declare(strict_types=1);
namespace App\Service\User;
use App\Entity\Integration\Integration;
use App\Entity\User\User;
use App\Form\User\ProfileType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
@@ -20,6 +18,10 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
* avatar picker's variables in both places — the save endpoint rendering it
* with only the form is how picking a picture used to crash with
* "Variable avatarSources does not exist".
*
* The picture is chosen in the file picker's dialog, so what is passed here is
* only which connections to offer. It used to carry the chosen connection's
* first page of images as well; that grid is gone from Settings.
*/
final readonly class ProfileSectionViewData
{
@@ -36,55 +38,17 @@ final readonly class ProfileSectionViewData
*
* @return array<string, mixed>
*/
public function build(User $user, Request $request, ?FormInterface $form = null): array
public function build(User $user, ?FormInterface $form = null): array
{
$sources = $this->avatarSources->availableFor($user);
$picking = $this->picking($sources, $request);
$pickUrls = [];
foreach ($sources as $source) {
$pickUrls[(string) $source->id] = $this->sectionUrl((string) $source->id);
}
$form ??= $this->formFactory->create(ProfileType::class, $user, [
'action' => $this->urlGenerator->generate('app_settings_profile_save'),
'avatar_source' => null === $picking ? null : (string) $picking->id,
'action' => $this->urlGenerator->generate('app_settings_profile_save'),
]);
return [
'profileForm' => $form->createView(),
'avatarSources' => $sources,
'avatarPicking' => $picking,
'avatarEntries' => null === $picking ? [] : $this->avatarSources->browse($picking),
'avatarPickUrls' => $pickUrls,
'avatarCloseUrl' => $this->sectionUrl(),
'profileForm' => $form->createView(),
// Which connections to offer; the pictures themselves are browsed in
// the file picker's dialog, not rendered here.
'avatarSources' => $this->avatarSources->availableFor($user),
];
}
/**
* The connection whose pictures are on screen, if any.
*
* @param list<Integration> $sources
*/
private function picking(array $sources, Request $request): ?Integration
{
foreach ($sources as $source) {
// Matched against the user's own connections rather than looked up
// by id, so a borrowed id cannot browse somebody else's photos.
if ((string) $source->id === (string) $request->query->get('pick', '')) {
return $source;
}
}
return null;
}
private function sectionUrl(?string $pick = null): string
{
return $this->urlGenerator->generate('app_settings_index', array_filter([
'section' => 'profile',
'pick' => $pick,
]));
}
}
+45 -18
View File
@@ -12,11 +12,21 @@ the same thing. Only the URLs differ, so the caller passes them:
closeUrl string where "stop browsing" goes
fileField string full_name of the form field the choice binds to
current string|null URL of the picture in use, if there is one
modalPicker bool open the file picker in a dialog (see below)
A thumbnail *is* the submit: choosing and saving are one action, and the button
carries the field name the form binds it to. Browsing is a navigation rather
than a client-side toggle because these fields belong to the surrounding form,
and HTML has no nested forms.
Two ways to choose, because the two callers differ in where they live.
Settings passes `modalPicker`: each connection opens the same dialog the compose
window attaches files with, in its avatar mode, and the choice comes back through
settings/avatar_from_service_controller.js on the form. `picking`, `entries`,
`pickUrls`, `closeUrl` and `fileField` are not used.
The setup wizard does not, and still gets the grid below. Its profile step is
itself rendered in the modal frame, which the file picker would replace — the
wizard would vanish behind it. A thumbnail *is* the submit there: choosing and
saving are one action, and the button carries the field name the form binds it
to. Browsing is a navigation rather than a client-side toggle because these
fields belong to the surrounding form, and HTML has no nested forms.
#}
{% if current is defined and current is not null %}
{# Shown wherever the profile is edited. A file input reports a filename at
@@ -36,23 +46,40 @@ and HTML has no nested forms.
<div class="mt-2 flex flex-wrap items-center gap-2">
{% for source in sources %}
{% set isOpen = picking is not null and picking.id == source.id %}
<a href="{{ isOpen ? closeUrl : pickUrls[source.id] }}"
data-turbo-frame="{{ frame|default('_self') }}"
id="avatar-source-{{ source.id }}"
class="inline-flex items-center gap-2 h-8 px-3 rounded-lg text-sm font-medium border transition-colors
{% if isOpen %}
border-accent bg-accent/10 text-accent
{% else %}
border-field bg-field text-ink-soft hover:bg-hover
{% endif %}">
<i class="{{ source.provider.icon }} text-xs" aria-hidden="true"></i>
{{ source.name }}
</a>
{% if modalPicker|default(false) %}
{# type=button: this sits inside the profile form, where the
default would submit it. #}
<button type="button"
id="avatar-source-{{ source.id }}"
data-controller="ui--modal"
data-ui--modal-title-value="{{ source.name }}"
data-ui--modal-size-value="{{ source.provider.isMediaLibrary ? 'full' : 'wide' }}"
data-ui--modal-src-value="{{ path('app_integration_browse', { id: source.id, mode: 'avatar' }) }}"
data-action="click->ui--modal#open"
class="inline-flex items-center gap-2 h-8 px-3 rounded-lg text-sm font-medium border transition-colors cursor-pointer
border-field bg-field text-ink-soft hover:bg-hover">
<i class="{{ source.provider.icon }} text-xs" aria-hidden="true"></i>
{{ source.name }}
</button>
{% else %}
{% set isOpen = picking is not null and picking.id == source.id %}
<a href="{{ isOpen ? closeUrl : pickUrls[source.id] }}"
data-turbo-frame="{{ frame|default('_self') }}"
id="avatar-source-{{ source.id }}"
class="inline-flex items-center gap-2 h-8 px-3 rounded-lg text-sm font-medium border transition-colors
{% if isOpen %}
border-accent bg-accent/10 text-accent
{% else %}
border-field bg-field text-ink-soft hover:bg-hover
{% endif %}">
<i class="{{ source.provider.icon }} text-xs" aria-hidden="true"></i>
{{ source.name }}
</a>
{% endif %}
{% endfor %}
</div>
{% if picking is not null %}
{% if not modalPicker|default(false) and picking is not null %}
{% if entries|length %}
<ul class="mt-4 grid grid-cols-4 sm:grid-cols-6 gap-2">
{% for entry in entries %}
+3 -1
View File
@@ -20,7 +20,9 @@ rendered it: an album is a pile of pictures, a folder is a folder.
404 the navigation; presence of the field is what tells the two apart. See
FilePickerController::browseDestination(). #}
{% set browseArgs = { id: integration.id, folder: entry.id, draft: draftId } %}
{% if mode|default('attach') == 'destination' %}
{% if mode|default('attach') == 'avatar' %}
{% set browseArgs = { id: integration.id, folder: entry.id, mode: 'avatar' } %}
{% elseif mode|default('attach') == 'destination' %}
{% set browseArgs = { id: integration.id, folder: entry.id, mode: 'destination' } %}
{% if part|default(null) is not null %}
{% set browseArgs = browseArgs|merge({ part: part }) %}
+5 -1
View File
@@ -8,7 +8,11 @@ are still listed, because "who is this" is a reasonable thing to go looking for.
Navigates rather than selects: a person is a place, not something attachable.
#}
<a href="{{ path('app_integration_browse', { id: integration.id, folder: entry.id, draft: draftId }) }}"
{# The mode travels with the navigation — see FilePickerController::browse(). #}
{% set navArgs = mode|default('attach') == 'avatar'
? { id: integration.id, folder: entry.id, mode: 'avatar' }
: { id: integration.id, folder: entry.id, draft: draftId } %}
<a href="{{ path('app_integration_browse', navArgs) }}"
class="group flex flex-col items-center gap-2 p-2 rounded-xl hover:bg-hover transition-colors"
title="{{ entry.name }}">
<span class="relative block w-24 h-24 rounded-full overflow-hidden bg-sunken ring-2 ring-transparent group-hover:ring-accent/40 transition-[box-shadow]">
+26 -8
View File
@@ -27,6 +27,13 @@ gap under the shortcuts row.
{% set isGrid = canThumb and integration.provider.isMediaLibrary %}
{% set isPeople = listing is not null and listing.people is not empty %}
{# Which way out of the picker. 'avatar' chooses one picture for the profile form
instead of attaching files to a draft, and every link below is a navigation
inside the frame: one that forgot the mode would quietly turn the next click
back into an attach picker. They all start from navArgs for that reason. #}
{% set isAvatar = mode|default('attach') == 'avatar' %}
{% set navArgs = isAvatar ? { id: integration.id, mode: 'avatar' } : { id: integration.id, draft: draftId } %}
{% block body %}
<turbo-frame id="modal">
<template data-ui--modal-frame-title>{{ integration.name }}</template>
@@ -37,6 +44,9 @@ gap under the shortcuts row.
data-ui--modal-keep-open
data-integration--integration-picker-url-value="{{ path('app_integration_attach', { id: integration.id }) }}"
data-integration--integration-picker-draft-value="{{ draftId }}"
data-integration--integration-picker-mode-value="{{ isAvatar ? 'avatar' : 'attach' }}"
data-integration--integration-picker-integration-value="{{ integration.id }}"
data-action="change->integration--integration-picker#changed"
data-integration--integration-picker-token-value="{{ csrf_token('integration-picker') }}"
data-integration--integration-picker-i18n-value="{{ {
loading: 'integration.picker.status.loading'|trans,
@@ -60,7 +70,11 @@ gap under the shortcuts row.
{% if canSearch %}
<form method="get" action="{{ path('app_integration_browse', { id: integration.id }) }}"
class="flex items-center gap-2 px-6 py-3 shrink-0">
<input type="hidden" name="draft" value="{{ draftId }}">
{% if isAvatar %}
<input type="hidden" name="mode" value="avatar">
{% else %}
<input type="hidden" name="draft" value="{{ draftId }}">
{% endif %}
<div class="relative flex-1 min-w-0">
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-[11px] text-ink-faint" aria-hidden="true"></i>
<input type="search" name="q" value="{{ query }}"
@@ -84,7 +98,7 @@ gap under the shortcuts row.
<nav class="flex items-center gap-1 flex-wrap text-xs text-ink-muted">
{% for crumb in listing.breadcrumb %}
{% if not loop.last %}
<a href="{{ path('app_integration_browse', { id: integration.id, folder: crumb.id, draft: draftId }) }}"
<a href="{{ path('app_integration_browse', navArgs|merge({ folder: crumb.id })) }}"
class="hover:text-ink transition-colors">{{ crumb.name }}</a>
<i class="fa-solid fa-chevron-right text-[8px] text-ink-faint" aria-hidden="true"></i>
{% else %}
@@ -94,7 +108,7 @@ gap under the shortcuts row.
</nav>
{% for shortcut in listing.shortcuts %}
<a href="{{ path('app_integration_browse', { id: integration.id, folder: shortcut.id, draft: draftId }) }}"
<a href="{{ path('app_integration_browse', navArgs|merge({ folder: shortcut.id })) }}"
class="{{ loop.first ? 'ml-auto' : '' }} inline-flex items-center h-7 px-2.5 rounded-lg
text-xs font-medium text-ink-soft bg-field border border-field
hover:bg-hover transition-colors">
@@ -141,6 +155,7 @@ gap under the shortcuts row.
integration: integration,
entry: entry,
draftId: draftId,
mode: mode|default('attach'),
} only %}
{% endfor %}
</div>
@@ -154,6 +169,7 @@ gap under the shortcuts row.
integration: integration,
entry: entry,
draftId: draftId,
mode: mode|default('attach'),
isMediaLibrary: true,
} only %}
{% endfor %}
@@ -177,6 +193,7 @@ gap under the shortcuts row.
integration: integration,
entry: entry,
maxBytes: maxBytes,
refusedIds: refusedIds|default([]),
} only %}
{% endfor %}
</div>
@@ -188,7 +205,9 @@ gap under the shortcuts row.
integration: integration,
entry: entry,
draftId: draftId,
mode: mode|default('attach'),
maxBytes: maxBytes,
refusedIds: refusedIds|default([]),
canLink: canLink,
canThumb: canThumb,
isMediaLibrary: integration.provider.isMediaLibrary,
@@ -204,13 +223,11 @@ gap under the shortcuts row.
if that script never runs. #}
{% if listing.nextCursor %}
<div class="pt-3 text-center" data-integration--integration-picker-target="sentinel">
<a href="{{ path('app_integration_browse', {
id: integration.id,
<a href="{{ path('app_integration_browse', navArgs|merge({
folder: folderId,
draft: draftId,
q: query,
cursor: listing.nextCursor,
}) }}"
})) }}"
data-action="integration--integration-picker#loadMore"
class="inline-flex items-center gap-2 h-8 px-3.5 rounded-lg text-sm font-medium
text-ink-soft bg-field border border-field hover:bg-hover transition-colors">
@@ -226,6 +243,7 @@ gap under the shortcuts row.
integration: integration,
buckets: buckets,
draftId: draftId,
mode: mode|default('attach'),
} only %}
</div>
@@ -247,7 +265,7 @@ gap under the shortcuts row.
data-integration--integration-picker-target="submit"
class="inline-flex items-center justify-center h-8 px-3.5 rounded-lg text-sm font-medium text-accent-ink
bg-accent hover:bg-accent-strong transition-colors disabled:opacity-50">
{{ 'integration.picker.attach'|trans }}
{{ (isAvatar ? 'integration.picker.use_as_avatar' : 'integration.picker.attach')|trans }}
</button>
</div>
+6 -2
View File
@@ -20,12 +20,16 @@ like it needed a decision.
integration: integration,
entry: entry,
draftId: draftId,
mode: mode|default('attach'),
isMediaLibrary: isMediaLibrary|default(false),
} only %}
{% else %}
{# Only a *known* oversize blocks a copy — see the picker's docblock. #}
{% set knownTooBig = entry.size is not null and entry.size > maxBytes %}
{% set canCopy = not knownTooBig %}
{# Profile-picture mode only: a file that cannot become a picture. Shown, so
the page does not look empty, but not choosable. #}
{% set notAnImage = entry.id in refusedIds|default([]) %}
{% set canCopy = not knownTooBig and not notAnImage %}
{% set unattachable = not canCopy and not canLink %}
<li class="group relative flex items-center gap-3 px-3 py-2.5 -mx-2 rounded-xl
@@ -105,7 +109,7 @@ like it needed a decision.
{% if unattachable %}
<span class="relative shrink-0 text-[11px] text-ink-faint">
{{ 'integration.picker.too_big'|trans }}
{{ (notAnImage ? 'integration.picker.not_an_image' : 'integration.picker.too_big')|trans }}
</span>
{% elseif canLink %}
{# Above the row label, so the chip wins the click it is under. #}
+5 -6
View File
@@ -45,12 +45,11 @@ year is skipped if it would land too close to the last one drawn.
{% set offset = running / total * 100 %}
{% set running = running + bucket.count %}
<a href="{{ path('app_integration_browse', {
id: integration.id,
folder: 'timeline',
draft: draftId,
cursor: bucket.cursor,
}) }}"
{# The mode travels with the navigation — see FilePickerController::browse(). #}
{% set segmentArgs = mode|default('attach') == 'avatar'
? { id: integration.id, folder: 'timeline', mode: 'avatar', cursor: bucket.cursor }
: { id: integration.id, folder: 'timeline', draft: draftId, cursor: bucket.cursor } %}
<a href="{{ path('app_integration_browse', segmentArgs) }}"
data-integration--picker-scrubber-target="segment"
data-title="{{ bucket.title }}"
data-action="mouseenter->integration--picker-scrubber#show mouseleave->integration--picker-scrubber#hide"
+5 -2
View File
@@ -13,9 +13,12 @@ selection, which pulled its corners away from the ring.
{% set knownTooBig = entry.size is not null and entry.size > maxBytes %}
{% set thumbUrl = path('app_integration_thumbnail', { id: integration.id, file: entry.id }) %}
{% if knownTooBig %}
{# Profile-picture mode only: not something that can become a picture. #}
{% set notAnImage = entry.id in refusedIds|default([]) %}
{% if knownTooBig or notAnImage %}
<div class="relative aspect-square rounded-lg overflow-hidden bg-sunken opacity-40"
title="{{ 'integration.picker.too_big'|trans }}">
title="{{ (notAnImage ? 'integration.picker.not_an_image' : 'integration.picker.too_big')|trans }}">
<img src="{{ thumbUrl }}" alt="" loading="lazy" class="w-full h-full object-cover">
</div>
{% else %}
+10 -8
View File
@@ -22,17 +22,19 @@ find.
} %}
{% block body %}
<div class="max-w-md">
{{ form_start(profileForm) }}
{# The picker dialog hands its choice to this form — see
settings/avatar_from_service_controller.js. #}
{{ form_start(profileForm, { attr: {
'data-controller': 'settings--avatar-from-service',
'data-settings--avatar-from-service-name-value': profileForm.vars.full_name,
'data-action': 'plmail:avatar-picked@document->settings--avatar-from-service#picked',
} }) }}
{{ form_widget(profileForm) }}
{% include '_partials/_avatar_picker.html.twig' with {
sources: avatarSources,
picking: avatarPicking,
entries: avatarEntries,
pickUrls: avatarPickUrls,
closeUrl: avatarCloseUrl,
fileField: avatarPicking is null ? '' : profileForm.avatarFileId.vars.full_name,
current: app.user.avatar ? path('app_settings_profile_avatar', { filename: app.user.avatar }) : null,
sources: avatarSources,
modalPicker: true,
current: app.user.avatar ? path('app_settings_profile_avatar', { filename: app.user.avatar }) : null,
} only %}
<button
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Integration;
use App\Domain\DTO\Integration\Entry;
use App\Domain\DTO\Integration\Listing;
use App\Domain\DTO\Integration\RemoteFile;
use App\Domain\DTO\Integration\TimelineBucket;
use App\Domain\Enum\Integration\Provider;
use App\Domain\Interface\IntegrationDriverInterface;
use App\Domain\Interface\SearchableDriverInterface;
use App\Domain\Interface\TimelineDriverInterface;
use App\Entity\Integration\Integration;
use App\Entity\User\User;
use App\Service\Integration\IntegrationDriverRegistry;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\DomCrawler\Crawler;
/**
* Choosing a profile picture goes through the compose file picker.
*
* Settings → Profile used to have a grid of its own — the first page of a
* service's root, capped, with no albums, search or paging — so the picture
* could only ever be one of the newest. It opens the real picker now, in an
* avatar mode of the same browse route, and the mode has to survive every click
* inside the dialog: breadcrumbs, shortcuts, folders, people, the date bar,
* search and the next page are all navigations in the same frame, and a link
* that forgot it turns the following click back into "attach to a draft".
* That is what is pinned here, from the markup the picker renders.
*/
final class AvatarPickerModeTest extends WebTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private User $user;
protected function tearDown(): void
{
if (isset($this->connection) && true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
/**
* Every way of leaving this page of a photo library — crumb, shortcut,
* album, date bar, next page — stays in the avatar picker.
*
* The presence checks come first on purpose: a page with no links would
* satisfy "none of them lacks the mode" just as well.
*/
public function testEveryNavigationInAPhotoLibraryKeepsTheAvatarMode(): void
{
$client = $this->signIn();
$integration = $this->seedIntegration(Provider::Immich);
$crawler = $client->request('GET', '/integrations/' . $integration->id . '/browse?mode=avatar');
self::assertResponseIsSuccessful();
// crumb "Library", shortcut "Albums", album "Holiday", scrubber month,
// next page.
$links = $this->browseLinks($crawler);
self::assertGreaterThanOrEqual(5, count($links), 'the page should offer five ways onward');
foreach ($links as $href) {
self::assertStringContainsString('mode=avatar', $href);
self::assertStringNotContainsString('draft=', $href);
}
// The search box is a GET form, so its mode is a hidden field.
self::assertSame('avatar', $crawler->filter('form input[name="mode"]')->attr('value'));
self::assertCount(0, $crawler->filter('form input[name="draft"]'));
}
/**
* The people grid has its own link partial, which is the easy one to miss.
*/
public function testAPersonInThePeopleViewKeepsTheAvatarMode(): void
{
$client = $this->signIn();
$integration = $this->seedIntegration(Provider::Immich);
$crawler = $client->request('GET', '/integrations/' . $integration->id . '/browse?mode=avatar&folder=people');
self::assertResponseIsSuccessful();
$person = $crawler->filter('a[href*="folder=person"]');
self::assertCount(1, $person, 'the person should be a link');
self::assertStringContainsString('mode=avatar', (string) $person->attr('href'));
self::assertStringNotContainsString('draft=', (string) $person->attr('href'));
}
/**
* Presence companion to the two above: the same pages in attach mode carry
* the draft and not the avatar mode, so those assertions are about the mode
* rather than about a picker that happens to render no draft.
*/
public function testAttachModeStillCarriesTheDraft(): void
{
$client = $this->signIn();
$integration = $this->seedIntegration(Provider::Immich);
$crawler = $client->request('GET', '/integrations/' . $integration->id . '/browse?draft=7');
self::assertResponseIsSuccessful();
$links = $this->browseLinks($crawler);
self::assertGreaterThanOrEqual(5, count($links));
foreach ($links as $href) {
self::assertStringContainsString('draft=7', $href);
self::assertStringNotContainsString('mode=avatar', $href);
}
self::assertCount(1, $crawler->filter('form input[name="draft"]'));
}
/**
* A file store lists rows rather than tiles, and a library of scans is
* mostly PDFs. They are shown — hiding them would leave a page that looks
* empty with "show more" under it — but cannot be chosen, and nothing
* offers a share link, which is no use as a profile picture.
*/
public function testAFileStoreShowsNonImagesButDoesNotLetThemBeChosen(): void
{
$client = $this->signIn();
$integration = $this->seedIntegration(Provider::Nextcloud);
$crawler = $client->request('GET', '/integrations/' . $integration->id . '/browse?mode=avatar');
self::assertResponseIsSuccessful();
self::assertCount(1, $crawler->filter('input[name="mode[p1]"]'), 'the photo can be chosen');
self::assertCount(0, $crawler->filter('input[name="mode[d1]"]'), 'the PDF cannot');
self::assertStringContainsString('scan.pdf', (string) $client->getResponse()->getContent());
self::assertCount(0, $crawler->filter('input[value="link"]'));
// The avatar store's cap, not the attachment one.
self::assertCount(0, $crawler->filter('input[name="mode[b1]"]'), 'a photo over 4 MB cannot');
foreach ($this->browseLinks($crawler) as $href) {
self::assertStringContainsString('mode=avatar', $href);
}
// And the other side: asked to attach, the same page lets the PDF be
// chosen, so the refusal above is the mode's doing.
$crawler = $client->request('GET', '/integrations/' . $integration->id . '/browse?draft=7');
self::assertCount(1, $crawler->filter('input[name="mode[b1]"][value="copy"]'));
self::assertCount(1, $crawler->filter('input[name="mode[d1]"][value="copy"]'));
self::assertCount(1, $crawler->filter('input[name="mode[d1]"][value="link"]'));
}
/**
* The picker never needs a draft in this mode, but it is still somebody's
* connection: another user's id is refused like it is when attaching.
*/
public function testAnotherUsersConnectionIsRefused(): void
{
$client = $this->signIn();
$integration = $this->seedIntegration(Provider::Immich);
$client->loginUser($this->seedUser('stranger'));
$client->request('GET', '/integrations/' . $integration->id . '/browse?mode=avatar');
self::assertResponseStatusCodeSame(403);
}
// ── Fixtures ─────────────────────────────────────────────────────────────
/**
* @return list<string> the href of every navigation inside the picker
*/
private function browseLinks(Crawler $crawler): array
{
return $crawler->filter('a[href*="/browse"]')->each(
static fn (Crawler $a): string => (string) $a->attr('href'),
);
}
private function seedIntegration(Provider $provider): Integration
{
$integration = new Integration($this->user, $provider, 'Photos');
$integration->baseUrl = 'https://photos.example.test';
$this->em->persist($integration);
$this->em->flush();
return $integration;
}
private function signIn(): KernelBrowser
{
$client = static::createClient();
$client->disableReboot();
$container = static::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
// The service this exercises is the one place the application talks to
// somebody else's server, so it is replaced; everything between the
// request and it is real.
$container->set(IntegrationDriverRegistry::class, new IntegrationDriverRegistry([new AvatarPickerFakeDriver()]));
$this->connection->beginTransaction();
$this->user = $this->seedUser('owner');
$client->loginUser($this->user);
return $client;
}
private function seedUser(string $tag): User
{
$user = new User();
$user->email = 'avatar-picker-' . $tag . '-' . uniqid('', true) . '@example.test';
$user->nameFirst = 'Avatar';
$user->nameLast = 'Picker';
$user->roles = ['ROLE_USER'];
$user->password = '$2y$04$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP';
$this->em->persist($user);
$this->em->flush();
return $user;
}
}
/**
* A service with one page of photos, an album, a person and a date bar.
*
* Not a mock: what matters is the markup the picker draws from what it is told,
* which is easier to state as data.
*/
final class AvatarPickerFakeDriver implements IntegrationDriverInterface, SearchableDriverInterface, TimelineDriverInterface
{
public function supports(Provider $provider): bool
{
return true;
}
public function verify(Integration $integration): void
{
}
public function list(Integration $integration, ?string $folderId = null, ?string $cursor = null): Listing
{
if ('people' === $folderId) {
return new Listing([Entry::person('person:1', 'Ann')], [Entry::folder('', 'Library'), Entry::folder('people', 'People')]);
}
return new Listing(
[
Entry::folder('album-1', 'Holiday'),
new Entry('p1', 'beach.jpg', false, 1_000, 'image/jpeg'),
new Entry('d1', 'scan.pdf', false, 1_000, 'application/pdf'),
// Fine to attach to a mail, over what a profile picture may be.
new Entry('b1', 'panorama.jpg', false, 5 * 1024 * 1024, 'image/jpeg'),
],
[Entry::folder('', 'Library'), Entry::folder('timeline', 'All photos')],
'page-2',
[Entry::folder('albums', 'Albums')],
);
}
public function search(Integration $integration, string $query, ?string $folderId = null, ?string $cursor = null): Listing
{
return $this->list($integration, $folderId, $cursor);
}
public function timelineBuckets(Integration $integration): array
{
return [new TimelineBucket('cursor-1', '2026', 12, 'March 2026')];
}
public function download(Integration $integration, string $fileId): RemoteFile
{
throw new \LogicException('Browsing downloads nothing.');
}
public function upload(Integration $integration, string $absolutePath, string $filename, string $mime, ?string $folderId = null): string
{
throw new \LogicException('Browsing uploads nothing.');
}
public function shareLink(Integration $integration, string $fileId): string
{
return 'https://share.example.test/' . $fileId;
}
public function thumbnail(Integration $integration, string $fileId): ?RemoteFile
{
return null;
}
}
@@ -279,6 +279,27 @@ final class PaperlessDriverTest extends TestCase
self::assertStringEndsWith('/api/documents/91/download/', $this->requests[0]['url']);
}
/**
* The listing says image/jpeg for a scanned photograph and the default
* download is the archived PDF, so something that has to be the picture asks
* for the original explicitly.
*/
public function testDownloadOriginalAsksForTheFileAsItWasPutIn(): void
{
$driver = $this->driver([
new MockResponse('jpeg-bytes', ['response_headers' => [
'content-type' => 'image/jpeg',
'content-disposition' => 'attachment; filename="holiday.jpg"',
]]),
]);
$file = $driver->downloadOriginal($this->integration(), '91');
self::assertSame('holiday.jpg', $file->filename);
self::assertSame('image/jpeg', $file->mime);
self::assertStringEndsWith('/api/documents/91/download/?original=true', $this->requests[0]['url']);
}
public function testDownloadAsksForTheNameWhenTheHeaderWasStripped(): void
{
$driver = $this->driver([
@@ -11,6 +11,7 @@ use App\Domain\Exception\AvatarRefusedException;
use App\Domain\Exception\IntegrationException;
use App\Domain\Helper\AvatarStorage;
use App\Domain\Interface\IntegrationDriverInterface;
use App\Domain\Interface\OriginalFileDriverInterface;
use App\Entity\Integration\Integration;
use App\Entity\User\User;
use App\Repository\Integration\IntegrationRepository;
@@ -79,6 +80,28 @@ final class AvatarFromIntegrationTest extends TestCase
self::assertNull($user->avatar);
}
/**
* Paperless lists a scanned photograph as image/jpeg and serves the archived
* PDF it made from it. A picture taken from it has to be the photograph, so
* a driver that keeps the original apart is asked for that one — the
* rendition would be refused as "not an image" every time.
*/
public function testADriverThatKeepsAnOriginalIsAskedForIt(): void
{
$user = new User();
$driver = new OriginalKeepingDriver(
rendition: new RemoteFile('scan.pdf', 'application/pdf', "%PDF-1.7
"),
original: new RemoteFile('scan.png', 'image/png', self::PNG),
);
$this->pickerFor($driver)->apply($user, $this->integration(), 'f1');
self::assertNotNull($user->avatar);
self::assertStringEndsWith('.png', $user->avatar);
self::assertSame(['downloadOriginal'], $driver->calls);
}
public function testADownloadThatFailsIsRefusedRatherThanThrown(): void
{
$this->expectExceptionObject(new AvatarRefusedException(AvatarRefusedException::UNAVAILABLE));
@@ -128,6 +151,11 @@ final class AvatarFromIntegrationTest extends TestCase
}
};
return $this->pickerFor($driver);
}
private function pickerFor(IntegrationDriverInterface $driver): AvatarFromIntegration
{
return new AvatarFromIntegration(
$this->createStub(IntegrationRepository::class),
new IntegrationDriverRegistry([$driver]),
@@ -141,3 +169,62 @@ final class AvatarFromIntegrationTest extends TestCase
return new Integration(new User(), Provider::Nextcloud, 'Nextcloud');
}
}
/**
* A service whose download() is a rendition and whose original is a different
* file, recording which one it was asked for.
*/
final class OriginalKeepingDriver implements IntegrationDriverInterface, OriginalFileDriverInterface
{
/** @var list<string> */
public array $calls = [];
public function __construct(
private readonly RemoteFile $rendition,
private readonly RemoteFile $original,
) {
}
public function supports(Provider $provider): bool
{
return true;
}
public function verify(Integration $integration): void
{
}
public function list(Integration $integration, ?string $folderId = null, ?string $cursor = null): Listing
{
return new Listing([]);
}
public function download(Integration $integration, string $fileId): RemoteFile
{
$this->calls[] = 'download';
return $this->rendition;
}
public function downloadOriginal(Integration $integration, string $fileId): RemoteFile
{
$this->calls[] = 'downloadOriginal';
return $this->original;
}
public function upload(Integration $integration, string $absolutePath, string $filename, string $mime, ?string $folderId = null): string
{
return 'unused';
}
public function shareLink(Integration $integration, string $fileId): ?string
{
return null;
}
public function thumbnail(Integration $integration, string $fileId): ?RemoteFile
{
return null;
}
}
+70
View File
@@ -522,6 +522,76 @@ test.describe("compose integration picker", () => {
);
});
/**
* Settings → Profile chose a picture from a grid of its own — the first page
* of the service's root, with no albums, search or paging — so only the
* newest photos were ever on offer. It opens the picker compose uses.
*
* The service is unreachable here, so what is asserted is that the dialog
* is the picker (its own error, not an empty grid), that it was asked in
* avatar mode, and that choosing hands the file back to the form, which
* posts it. With no service there is no photo to click, so the choice is a
* ticked radio put where the picker's own markup puts one — the rest, from
* the picker's button to the form's request, is the real thing.
*/
test("Settings → Profile picks a picture through the same picker", async ({
page,
}) => {
await enableNextcloudAsAdmin(page);
await connectAsMailUser(page, "Picture cloud");
await page.goto("/settings?section=profile");
const browse = page.waitForRequest((request) =>
/\/integrations\/\d+\/browse\?mode=avatar$/.test(request.url()),
);
await page.getByRole("button", { name: "Picture cloud" }).click();
await browse;
const modal = page.locator("#modal");
await expect(modal).toContainText(/Could not reach the Nextcloud server/i);
// Nothing chosen yet: the button must not post an empty choice.
await modal.getByRole("button", { name: "Use as profile picture" }).click();
await expect(modal).toContainText("Nothing selected");
await modal
.locator('[data-controller="integration--integration-picker"]')
.evaluate((root) => {
// Two files, the second chosen after the first — a profile has
// one picture, so only the second may be left standing.
for (const [file, checked] of [["Photos/old.jpg", true], ["Photos/me.jpg", false]] as const) {
const input = document.createElement("input");
input.type = "radio";
input.name = `mode[${file}]`;
input.value = "copy";
input.checked = checked;
input.setAttribute("data-integration--integration-picker-target", "mode");
root.append(input);
}
const second = root.querySelector<HTMLInputElement>('input[name="mode[Photos/me.jpg]"]')!;
second.checked = true;
second.dispatchEvent(new Event("change", { bubbles: true }));
});
await expect(modal.locator('input[name="mode[Photos/old.jpg]"]')).not.toBeChecked();
const save = page.waitForRequest(
(request) =>
"POST" === request.method() && /\/settings\/profile$/.test(request.url()),
);
await modal.getByRole("button", { name: "Use as profile picture" }).click();
// Both fields, under the form's own name, carrying what was chosen.
const body = (await save).postData() ?? "";
expect(body).toMatch(/name="profile\[avatarIntegrationId\]"\s+\d+/);
expect(body).toMatch(/name="profile\[avatarFileId\]"\s+Photos\/me\.jpg/);
});
/**
* The attachment chip gained a "Save to…" half and was extracted into a
* shared partial, so both of its call sites had to keep working. This
+2
View File
@@ -2200,6 +2200,7 @@ integration:
link: Link
more: 'Mehr anzeigen'
no_results: 'Nichts gefunden für „%query%“.'
not_an_image: 'Keine Bilddatei'
search: Suchen
search_placeholder:
dropbox: 'Dropbox durchsuchen'
@@ -2219,6 +2220,7 @@ integration:
skipped: 'Übersprungen: %files%'
timeline: 'Zu einem Datum springen'
too_big: 'Zu groß zum Anhängen'
use_as_avatar: 'Als Profilbild verwenden'
jobs:
button:
+2
View File
@@ -2199,6 +2199,7 @@ integration:
link: Link
more: 'Show more'
no_results: 'Nothing found for “%query%”.'
not_an_image: 'Not a picture'
search: Search
search_placeholder:
dropbox: 'Search Dropbox'
@@ -2218,6 +2219,7 @@ integration:
skipped: 'Skipped: %files%'
timeline: 'Jump to a date'
too_big: 'Too large to attach'
use_as_avatar: 'Use as profile picture'
jobs:
button:
+2
View File
@@ -2079,6 +2079,7 @@ integration:
link: Link
more: 'Show more'
no_results: 'Naught found for “%query%”.'
not_an_image: 'Not a picture'
search: Search
search_placeholder:
dropbox: 'Search Dropbox'
@@ -2098,6 +2099,7 @@ integration:
skipped: 'Left on the dock: %files%'
timeline: 'Jump to a date'
too_big: 'Too heavy to haul'
use_as_avatar: 'Hoist as me flag'
jobs:
button:
+2
View File
@@ -2210,6 +2210,7 @@ integration:
link: Lien
more: 'Afficher plus'
no_results: 'Rien trouvé pour « %query% ».'
not_an_image: 'Pas une image'
search: Rechercher
search_placeholder:
dropbox: 'Rechercher dans Dropbox'
@@ -2229,6 +2230,7 @@ integration:
skipped: 'Ignorés : %files%'
timeline: 'Aller à une date'
too_big: 'Trop volumineux pour être joint'
use_as_avatar: 'Utiliser comme photo de profil'
jobs:
button: