mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
fixes
This commit is contained in:
1 parent
37653280b1
commit
35aec117bf
12 files changed
+199
-96
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import './stimulus_bootstrap.js';
|
||||
import './password_manager_ignore.js';
|
||||
import './styles/app.css';
|
||||
import '@fortawesome/fontawesome-free/css/all.css';
|
||||
@@ -0,0 +1,25 @@
|
||||
// Tom Select (via Symfony UX Autocomplete) replaces the form field with its
|
||||
// own visible <input>, so the PasswordManagerIgnore attributes a form type
|
||||
// puts on the original element never reach the one extensions actually look
|
||||
// at — and Bitwarden badges the compose To/Cc/Bcc boxes as logins. The bundle
|
||||
// announces every instance it builds; decorate the control input then.
|
||||
//
|
||||
// Mirrors App\Form\PasswordManagerIgnore::ATTR — change both together.
|
||||
const PASSWORD_MANAGER_IGNORE = {
|
||||
'data-bwignore': 'true',
|
||||
'data-1p-ignore': 'true',
|
||||
'data-lpignore': 'true',
|
||||
'data-form-type': 'other',
|
||||
};
|
||||
|
||||
document.addEventListener('autocomplete:connect', (event) => {
|
||||
const input = event.detail?.tomSelect?.control_input;
|
||||
|
||||
if (undefined === input || null === input) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(PASSWORD_MANAGER_IGNORE)) {
|
||||
input.setAttribute(name, value);
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ namespace App\Controller\Settings;
|
||||
use App\Domain\Helper\AvatarStorage;
|
||||
use App\Entity\User\User;
|
||||
use App\Form\User\ProfileType;
|
||||
use App\Service\User\ProfileSectionViewData;
|
||||
use App\Service\User\ProfileUpdater;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
@@ -28,10 +29,19 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
final class ProfileController extends AbstractController
|
||||
{
|
||||
#[Route('', name: 'save', methods: ['POST'])]
|
||||
public function save(Request $request, #[CurrentUser] User $user, ProfileUpdater $updater): Response
|
||||
public function save(Request $request, #[CurrentUser] User $user, ProfileUpdater $updater, ProfileSectionViewData $section): Response
|
||||
{
|
||||
// The avatar fields exist only on a form built with the same
|
||||
// avatar_source the picker rendered with — rebuilt without it, a
|
||||
// clicked thumbnail's avatarFileId is an "extra field" and the whole
|
||||
// submission fails. The value is only a hint for form shape: which
|
||||
// integration may actually be used is checked by ProfileUpdater
|
||||
// against the user's own connections.
|
||||
$source = trim((string) ($request->request->all('profile')['avatarIntegrationId'] ?? ''));
|
||||
|
||||
$form = $this->createForm(ProfileType::class, $user, [
|
||||
'action' => $this->generateUrl('app_settings_profile_save'),
|
||||
'action' => $this->generateUrl('app_settings_profile_save'),
|
||||
'avatar_source' => '' === $source ? null : $source,
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
@@ -41,7 +51,10 @@ final class ProfileController extends AbstractController
|
||||
return $this->redirectToRoute('app_settings_index', ['section' => 'profile']);
|
||||
}
|
||||
|
||||
return $this->render('settings/_profile.html.twig', ['profileForm' => $form], new Response(null, Response::HTTP_UNPROCESSABLE_ENTITY));
|
||||
// The full section, not just the form: the partial renders the avatar
|
||||
// picker too, and rendering it with only profileForm crashed on the
|
||||
// missing picker variables.
|
||||
return $this->render('settings/_profile.html.twig', $section->build($user, $request, $form), new Response(null, Response::HTTP_UNPROCESSABLE_ENTITY));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,9 +6,7 @@ namespace App\Controller\Settings;
|
||||
|
||||
use App\Domain\Enum\AppLocale;
|
||||
use App\Form\ApiTokenType;
|
||||
use App\Entity\User\User;
|
||||
use App\Form\User\ProfileType;
|
||||
use App\Service\User\AvatarFromIntegration;
|
||||
use App\Service\User\ProfileSectionViewData;
|
||||
use App\Form\Factory\AliasAddFormFactory;
|
||||
use App\Repository\Mail\AccountRepository;
|
||||
use App\Repository\User\ApiTokenRepository;
|
||||
@@ -39,7 +37,7 @@ final class SettingsController extends AbstractController
|
||||
private readonly string $vapidPublicKey,
|
||||
#[Autowire('%kernel.default_locale%')]
|
||||
private readonly string $defaultLocale,
|
||||
private readonly AvatarFromIntegration $avatarSources,
|
||||
private readonly ProfileSectionViewData $profileSection,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -61,7 +59,7 @@ final class SettingsController extends AbstractController
|
||||
'rules' => $this->mailRuleRepository->findForUserOrdered($this->getUser()),
|
||||
'apiTokens' => $this->apiTokenRepository->findForUser($this->getUser()),
|
||||
'apiTokenForm' => $this->createForm(ApiTokenType::class)->createView(),
|
||||
...$this->profileSection($request),
|
||||
...$this->profileSection->build($this->getUser(), $request),
|
||||
'aliasForms' => $this->aliasAddForms->forAccounts($manageableAccounts),
|
||||
'vapidPublicKey' => $this->vapidPublicKey,
|
||||
'locales' => AppLocale::cases(),
|
||||
@@ -71,52 +69,4 @@ final class SettingsController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile section's own parameters.
|
||||
*
|
||||
* The picture can be taken from a service the user has connected, exactly
|
||||
* as it can during setup — the wizard is not the only place a profile is
|
||||
* edited, and a feature reachable only from a one-time flow is a feature
|
||||
* most people never find.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function profileSection(Request $request): array
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$sources = $this->avatarSources->availableFor($user);
|
||||
$picking = null;
|
||||
|
||||
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', '')) {
|
||||
$picking = $source;
|
||||
}
|
||||
}
|
||||
|
||||
$pickUrls = [];
|
||||
|
||||
foreach ($sources as $source) {
|
||||
$pickUrls[(string) $source->id] = $this->generateUrl('app_settings_index', [
|
||||
'section' => 'profile',
|
||||
'pick' => $source->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'profileForm' => $this->createForm(ProfileType::class, $user, [
|
||||
'action' => $this->generateUrl('app_settings_profile_save'),
|
||||
'avatar_source' => null === $picking ? null : (string) $picking->id,
|
||||
])->createView(),
|
||||
'avatarSources' => $sources,
|
||||
'avatarPicking' => $picking,
|
||||
'avatarEntries' => null === $picking ? [] : $this->avatarSources->browse($picking),
|
||||
'avatarPickUrls' => $pickUrls,
|
||||
'avatarCloseUrl' => $this->generateUrl('app_settings_index', ['section' => 'profile']),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -43,7 +43,7 @@ class AccountType extends AbstractType
|
||||
'attr' => [
|
||||
'placeholder' => 'you@example.com',
|
||||
'class' => 'form-input',
|
||||
],
|
||||
] + PasswordManagerIgnore::ATTR,
|
||||
'label' => 'Account label',
|
||||
])
|
||||
->add('username', TextType::class, [
|
||||
@@ -53,7 +53,7 @@ class AccountType extends AbstractType
|
||||
'autocomplete' => 'email',
|
||||
'data-settings--imap-preset-target' => 'username',
|
||||
'data-action' => 'change->settings--imap-preset#detect blur->settings--imap-preset#detect',
|
||||
],
|
||||
] + PasswordManagerIgnore::ATTR,
|
||||
'label' => 'Email address',
|
||||
'constraints' => [new NotBlank()],
|
||||
])
|
||||
@@ -62,7 +62,7 @@ class AccountType extends AbstractType
|
||||
'placeholder' => '••••••••',
|
||||
'class' => 'form-input',
|
||||
'autocomplete' => 'new-password',
|
||||
],
|
||||
] + PasswordManagerIgnore::ATTR,
|
||||
'label' => 'Password',
|
||||
'always_empty' => true,
|
||||
'required' => $options['require_password'],
|
||||
@@ -71,7 +71,7 @@ class AccountType extends AbstractType
|
||||
'attr' => [
|
||||
'placeholder' => 'imap.example.com',
|
||||
'class' => 'form-input',
|
||||
],
|
||||
] + PasswordManagerIgnore::ATTR,
|
||||
'label' => 'IMAP host',
|
||||
'constraints' => [new NotBlank()],
|
||||
])
|
||||
@@ -97,7 +97,7 @@ class AccountType extends AbstractType
|
||||
'attr' => [
|
||||
'placeholder' => 'smtp.example.com',
|
||||
'class' => 'form-input',
|
||||
],
|
||||
] + PasswordManagerIgnore::ATTR,
|
||||
'label' => 'SMTP host',
|
||||
])
|
||||
->add('smtpPort', IntegerType::class, [
|
||||
|
||||
@@ -26,7 +26,9 @@ final class EmailAliasType extends AbstractType
|
||||
$builder->add('address', EmailType::class, [
|
||||
// Inline row next to the Add button — see ApiTokenType.
|
||||
'label' => false,
|
||||
'attr' => ['placeholder' => 'settings.aliases.add_placeholder'],
|
||||
// A bare email field reads as a login to password managers;
|
||||
// an alias is not one — see PasswordManagerIgnore.
|
||||
'attr' => ['placeholder' => 'settings.aliases.add_placeholder'] + PasswordManagerIgnore::ATTR,
|
||||
'constraints' => [
|
||||
new NotBlank(message: 'alias.invalid'),
|
||||
new Email(message: 'alias.invalid'),
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Form\Integration;
|
||||
|
||||
use App\Domain\Enum\Integration\Provider;
|
||||
use App\Form\PasswordManagerIgnore;
|
||||
use App\Entity\Integration\Integration;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
@@ -42,20 +43,20 @@ final class IntegrationConnectType extends AbstractType
|
||||
$builder->add('name', TextType::class, [
|
||||
'label' => 'settings.integrations.field.name',
|
||||
'help' => 'settings.integrations.field.name_help',
|
||||
'attr' => ['maxlength' => 100],
|
||||
'attr' => ['maxlength' => 100] + PasswordManagerIgnore::ATTR,
|
||||
]);
|
||||
|
||||
if (true === $options['url_editable']) {
|
||||
$builder->add('baseUrl', UrlType::class, [
|
||||
'label' => 'settings.integrations.field.base_url',
|
||||
'attr' => ['placeholder' => 'https://cloud.example.com'],
|
||||
'attr' => ['placeholder' => 'https://cloud.example.com'] + PasswordManagerIgnore::ATTR,
|
||||
]);
|
||||
}
|
||||
|
||||
if (true === isset(self::NEEDS_USERNAME[$provider->value])) {
|
||||
$builder->add('username', TextType::class, [
|
||||
'label' => 'settings.integrations.field.username',
|
||||
'attr' => ['autocomplete' => 'off'],
|
||||
'attr' => ['autocomplete' => 'off'] + PasswordManagerIgnore::ATTR,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -68,13 +69,9 @@ final class IntegrationConnectType extends AbstractType
|
||||
'required' => $isNew,
|
||||
'mapped' => false,
|
||||
'attr' => [
|
||||
'autocomplete' => 'off',
|
||||
'data-1p-ignore' => 'true',
|
||||
'data-lpignore' => 'true',
|
||||
'data-bwignore' => 'true',
|
||||
'data-form-type' => 'other',
|
||||
'autocomplete' => 'off',
|
||||
'placeholder' => $isNew ? '' : 'settings.integrations.field.secret_unchanged',
|
||||
],
|
||||
] + PasswordManagerIgnore::ATTR,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Form\Integration;
|
||||
use App\Domain\Enum\Integration\AuthKind;
|
||||
use App\Domain\Enum\Integration\Provider;
|
||||
use App\Entity\Integration\IntegrationProviderConfig;
|
||||
use App\Form\PasswordManagerIgnore;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
@@ -50,7 +51,7 @@ final class IntegrationProviderConfigType extends AbstractType
|
||||
'help' => $provider->needsBaseUrl()
|
||||
? 'admin.integrations.field.base_url_help_selfhosted'
|
||||
: 'admin.integrations.field.base_url_help_saas',
|
||||
'attr' => ['placeholder' => 'https://cloud.example.com'],
|
||||
'attr' => ['placeholder' => 'https://cloud.example.com'] + PasswordManagerIgnore::ATTR,
|
||||
]);
|
||||
|
||||
if (AuthKind::OAuth2 !== $provider->authKind()) {
|
||||
@@ -61,22 +62,18 @@ final class IntegrationProviderConfigType extends AbstractType
|
||||
->add('clientId', TextType::class, [
|
||||
'label' => 'admin.integrations.field.client_id',
|
||||
'required' => false,
|
||||
'attr' => ['autocomplete' => 'off'],
|
||||
'attr' => ['autocomplete' => 'off'] + PasswordManagerIgnore::ATTR,
|
||||
])
|
||||
->add('clientSecret', PasswordType::class, [
|
||||
'label' => 'admin.integrations.field.client_secret',
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
'attr' => [
|
||||
'autocomplete' => 'off',
|
||||
'data-1p-ignore' => 'true',
|
||||
'data-lpignore' => 'true',
|
||||
'data-bwignore' => 'true',
|
||||
'data-form-type' => 'other',
|
||||
'autocomplete' => 'off',
|
||||
'placeholder' => $config instanceof IntegrationProviderConfig && $config->hasClientSecret()
|
||||
? 'admin.integrations.secret_unchanged'
|
||||
: 'admin.integrations.secret_none',
|
||||
],
|
||||
] + PasswordManagerIgnore::ATTR,
|
||||
]);
|
||||
|
||||
if ($config instanceof IntegrationProviderConfig && true === $config->hasClientSecret()) {
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Form\Integration;
|
||||
|
||||
use App\Domain\Enum\Account\MailProvider;
|
||||
use App\Entity\Integration\MailProviderConfig;
|
||||
use App\Form\PasswordManagerIgnore;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
@@ -41,7 +42,7 @@ final class MailProviderConfigType extends AbstractType
|
||||
->add('clientId', TextType::class, [
|
||||
'label' => 'admin.integrations.field.client_id',
|
||||
'required' => false,
|
||||
'attr' => ['autocomplete' => 'off'],
|
||||
'attr' => ['autocomplete' => 'off'] + PasswordManagerIgnore::ATTR,
|
||||
])
|
||||
// Not a password anyone signs in with — it is a value pasted out
|
||||
// of a provider's console. `new-password` invited password managers
|
||||
@@ -53,15 +54,11 @@ final class MailProviderConfigType extends AbstractType
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
'attr' => [
|
||||
'autocomplete' => 'off',
|
||||
'data-1p-ignore' => 'true',
|
||||
'data-lpignore' => 'true',
|
||||
'data-bwignore' => 'true',
|
||||
'data-form-type' => 'other',
|
||||
'autocomplete' => 'off',
|
||||
'placeholder' => $config instanceof MailProviderConfig && $config->hasClientSecret()
|
||||
? 'admin.integrations.secret_unchanged'
|
||||
: 'admin.integrations.secret_none',
|
||||
],
|
||||
] + PasswordManagerIgnore::ATTR,
|
||||
]);
|
||||
|
||||
// Offered only when there is something to clear, so the option cannot
|
||||
@@ -82,7 +79,7 @@ final class MailProviderConfigType extends AbstractType
|
||||
'mapped' => false,
|
||||
'help' => 'admin.integrations.field.pubsub_topic_help',
|
||||
'data' => $config instanceof MailProviderConfig ? $config->getPubsubTopic() : null,
|
||||
'attr' => ['placeholder' => 'projects/your-project-id/topics/gmail-push'],
|
||||
'attr' => ['placeholder' => 'projects/your-project-id/topics/gmail-push'] + PasswordManagerIgnore::ATTR,
|
||||
])
|
||||
->add('pushVerificationToken', PasswordType::class, [
|
||||
'label' => 'admin.integrations.field.push_token',
|
||||
@@ -90,15 +87,11 @@ final class MailProviderConfigType extends AbstractType
|
||||
'mapped' => false,
|
||||
'help' => 'admin.integrations.field.push_token_help',
|
||||
'attr' => [
|
||||
'autocomplete' => 'off',
|
||||
'data-1p-ignore' => 'true',
|
||||
'data-lpignore' => 'true',
|
||||
'data-bwignore' => 'true',
|
||||
'data-form-type' => 'other',
|
||||
'autocomplete' => 'off',
|
||||
'placeholder' => $config instanceof MailProviderConfig && null !== $config->pushVerificationToken
|
||||
? 'admin.integrations.secret_unchanged'
|
||||
: 'admin.integrations.secret_none',
|
||||
],
|
||||
] + PasswordManagerIgnore::ATTR,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -123,7 +116,7 @@ final class MailProviderConfigType extends AbstractType
|
||||
'mapped' => false,
|
||||
'help' => 'admin.integrations.field.tenant_help',
|
||||
'data' => $config instanceof MailProviderConfig ? $config->getTenant() : null,
|
||||
'attr' => ['placeholder' => 'common'],
|
||||
'attr' => ['placeholder' => 'common'] + PasswordManagerIgnore::ATTR,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
/**
|
||||
* Attributes that tell password-manager extensions to leave a field alone.
|
||||
*
|
||||
* Server credentials, API keys and address fields are not website logins:
|
||||
* extensions that classify them as one draw their overlay icons into them —
|
||||
* and Bitwarden's injected chrome outlives a Turbo frame swap, leaving
|
||||
* orphaned icons and underlines floating over whatever renders next.
|
||||
* `autocomplete="off"` is ignored by all of them; these vendor attributes are
|
||||
* the documented opt-out (Bitwarden, 1Password, LastPass, Dashlane).
|
||||
*
|
||||
* Deliberately NOT applied to real logins — the sign-in form and the first
|
||||
* admin's password — where offering to save is exactly what the user wants.
|
||||
*/
|
||||
final class PasswordManagerIgnore
|
||||
{
|
||||
public const array ATTR = [
|
||||
'data-bwignore' => 'true',
|
||||
'data-1p-ignore' => 'true',
|
||||
'data-lpignore' => 'true',
|
||||
'data-form-type' => 'other',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* The Settings → Profile section's template parameters.
|
||||
*
|
||||
* Shared between the settings page that shows the section and the save
|
||||
* endpoint that re-renders it on a validation failure. The partial needs the
|
||||
* 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".
|
||||
*/
|
||||
final readonly class ProfileSectionViewData
|
||||
{
|
||||
public function __construct(
|
||||
private AvatarFromIntegration $avatarSources,
|
||||
private FormFactoryInterface $formFactory,
|
||||
private UrlGeneratorInterface $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FormInterface|null $form a submitted form to render with its
|
||||
* errors, instead of building a fresh one
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function build(User $user, Request $request, ?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,
|
||||
]);
|
||||
|
||||
return [
|
||||
'profileForm' => $form->createView(),
|
||||
'avatarSources' => $sources,
|
||||
'avatarPicking' => $picking,
|
||||
'avatarEntries' => null === $picking ? [] : $this->avatarSources->browse($picking),
|
||||
'avatarPickUrls' => $pickUrls,
|
||||
'avatarCloseUrl' => $this->sectionUrl(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,16 @@
|
||||
notch; the safe-area padding in app.css keeps content clear of it.
|
||||
Zoom is pinned so the app behaves like an installed PWA rather than a
|
||||
page — see the 16px form-control rule in app.css, which is what actually
|
||||
stops iOS zooming on focus (it ignores user-scalable). #}
|
||||
stops iOS zooming on focus (it ignores user-scalable).
|
||||
`interactive-widget=resizes-content`: Android's default (resizes-visual)
|
||||
shrinks only the visual viewport for the keyboard and leaves the layout
|
||||
viewport — where fixed elements live — at full height, so the page
|
||||
behind the fullscreen compose window stayed pannable and showed through
|
||||
below it. Resizing the content instead means fixed elements end above
|
||||
the keyboard and there is nothing to pan. iOS ignores this keyword; the
|
||||
compose controller's visualViewport tracking stays as its fallback. #}
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<title>{% block title %}Welcome!{% endblock %}</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><text y=%221em%22 font-size=%2224%22>✉️</text></svg>">
|
||||
{# PWA: iOS only offers Web Push to apps added to the Home Screen, and it
|
||||
|
||||
Reference in new issue
Block a user