mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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:
26 files changed
+851
-108
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 }) %}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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. #}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user