mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-11-06 23:35:50 +00:00
fix(compose): a link popover, a themed emoji picker, and clocks that follow the setting
Five reported defects in the compose editor, and one the mobile specs were
never going to catch.
The clock. formatWallClock() asked Intl for an hour and never said which
kind, so the send-later presets followed the LOCALE's default rather than the
user's twelve-or-twenty-four-hour setting — "8:00 AM" beside server-rendered
timestamps reading 08:00. The setting is now stamped on <html> once per render
and read back through assets/clock_format.js, which is a module rather than a
Stimulus value because the snooze menu had the identical bug and shares no
template with compose. Both pass hour12 explicitly now.
The link button. It raised prompt('Enter URL:') — one unthemed field, no way
to say what the link should read as, and a bare "example.com" going in as a
relative link to a path inside the app. It is now a popover with a URL field
and a text field, the text prefilled from the selection. The selection
survives because the toolbar's saved range is FROZEN while the popover is
open: focusing the URL field blurs the editor, and a blur allowed to re-read
the selection would record the collapsed one. URLs are normalised and screened
against MailBodySanitizer's own allowLinkSchemes list, so nothing the editor
accepts is silently dropped on save, and "example.com" becomes https://.
Links in the editor. A click in a contenteditable places a caret and nothing
else, so a link just written was unreachable. Clicking one now shows the same
popover's other face — the address, Open, Change, Remove — and it dismisses on
Escape, on an outside click, and when the caret moves off the link. It does
not take focus and does not restore any range, because that face appears
beside a caret the user placed themselves. Anchors in the editor are also
styled as links again: Tailwind's preflight resets `a` to inherit, which left
a freshly inserted link indistinguishable from the words around it.
The emoji picker's "weird black borders". --rgb-line is `0 0 0` in every light
theme and `255 255 255` in every dark one — a hue whose weight lives in
--line-alpha — and the picker was handed it with no alpha, so its hairlines
resolved to pure black. Its search field and scrollbar were black for a second
reason: emoji-picker-element declares color-scheme on its own :host from
prefers-color-scheme, the OS setting this app uses for nothing. Both fixed,
the second with `color-scheme: inherit` so a runtime theme switch carries the
picker with it.
The plain-text warning. window.confirm() becomes a popover over the editor,
with the same rule (only when there is formatting to lose) and the same
reversibility. Refusable by Cancel, Escape or an outside click.
And the one nobody reported: the More-options menu was UNREACHABLE on a phone.
Its button sits in .compose-scroll-x, whose overflow-x:auto computes
overflow-y:auto too, so a menu opening upwards out of a 36px strip was clipped
to nothing — plain text, priority and read receipt could be tapped open and
never seen. Pinned to the viewport below md, as the emoji panel already was.
compose-mobile.spec.ts now measures every one of these against the viewport at
393px AND 320px, in both axes and with a minimum size, because the last round
shipped an off-screen emoji panel past a spec that only asserted the panel
existed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c7ec3b36f9
commit
832050b304
17 files changed
+1745
-36
No files matched your search
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* The user's twelve-or-twenty-four-hour choice, on the browser side.
|
||||
*
|
||||
* Every time Twig prints goes through ClockGlobal, which resolves the setting
|
||||
* (`clock.time`, `clock.timeCompact`, `clock.hour`). Nothing in the browser
|
||||
* can reach it: `Intl.DateTimeFormat` asked for an hour and not told which
|
||||
* kind falls back to the LOCALE's default, so an en-GB user who had chosen a
|
||||
* 12-hour clock got 24-hour labels and an en-US user who had chosen 24 got
|
||||
* "8:00 AM" — in the send-later menu and the snooze menu, next to timestamps
|
||||
* the server had drawn the other way round.
|
||||
*
|
||||
* Its own module rather than a Stimulus value because the two menus that want
|
||||
* it share no template and no controller, and the list of surfaces that print
|
||||
* a time from JavaScript only grows. templates/_layout/app.html.twig stamps
|
||||
* the setting on <html> once per render; this reads it back.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Document} [doc]
|
||||
* @returns {boolean|undefined} `undefined` — meaning "let the locale decide" —
|
||||
* when there is no preference on the page, which is a document rendered
|
||||
* outside the app layout or a unit test.
|
||||
*/
|
||||
export function prefersHour12(doc = document) {
|
||||
const flag = doc?.documentElement?.dataset?.clockHour12;
|
||||
|
||||
if ("true" === flag) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ("false" === flag) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['toField', 'ccField', 'bccField', 'subject', 'body', 'saveStatus', 'toCollection', 'collapsible', 'minimizeIcon', 'expandIcon', 'ccBtn', 'bccBtn', 'title', 'accountSelect', 'fromBtn', 'fromLabel', 'fromChevron', 'fromDropdown', 'fromRow', 'fields', 'fieldsChevron', 'fileInput', 'imageInput', 'attachments', 'scroller', 'formatBar', 'formatToggle', 'sendBtn', 'errors', 'plainBody', 'plainToggle', 'plainCheck'];
|
||||
static targets = ['toField', 'ccField', 'bccField', 'subject', 'body', 'saveStatus', 'toCollection', 'collapsible', 'minimizeIcon', 'expandIcon', 'ccBtn', 'bccBtn', 'title', 'accountSelect', 'fromBtn', 'fromLabel', 'fromChevron', 'fromDropdown', 'fromRow', 'fields', 'fieldsChevron', 'fileInput', 'imageInput', 'attachments', 'scroller', 'formatBar', 'formatToggle', 'sendBtn', 'errors', 'plainBody', 'plainToggle', 'plainCheck', 'plainWarning', 'plainWarningConfirm'];
|
||||
|
||||
/** Below this the dock window is the whole screen — matches Tailwind's md. */
|
||||
static MOBILE_QUERY = '(max-width: 767px)';
|
||||
@@ -178,6 +178,7 @@ export default class extends Controller {
|
||||
form.removeEventListener('input', this._boundAutosave);
|
||||
form.removeEventListener('submit', this._boundHandleSubmit);
|
||||
document.removeEventListener('click', this._boundCloseDropdown, { capture: true });
|
||||
this._closePlainWarning();
|
||||
this._mq?.removeEventListener('change', this._boundBreakpoint);
|
||||
this._unwatchViewport();
|
||||
document.body.style.overflow = '';
|
||||
@@ -1001,7 +1002,92 @@ export default class extends Controller {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setPlainText(false === this._isPlainText());
|
||||
const plain = false === this._isPlainText();
|
||||
|
||||
// Going plain, with formatting that flattening would destroy: ask
|
||||
// first. The question is a popover over the editor rather than
|
||||
// `window.confirm()` — same rule, same reversibility, but a dialog the
|
||||
// app drew rather than one the browser did, which on a phone is a
|
||||
// system sheet dropped over a half-written message.
|
||||
if (true === plain && true === this._plainTextLosesFormatting()) {
|
||||
this._openPlainWarning();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this._setPlainText(plain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there formatting here that going plain would throw away?
|
||||
*
|
||||
* Unchanged from when this guarded the `confirm()`: an empty body, or one
|
||||
* that is already nothing but text, converts silently. Lifted out only so
|
||||
* that the question and the answer are no longer inside the function that
|
||||
* performs the switch.
|
||||
*/
|
||||
_plainTextLosesFormatting() {
|
||||
const text = this._bodyAsText(this.bodyTarget);
|
||||
|
||||
return this.bodyTarget.innerHTML.trim() !== text.replace(/\n/g, '')
|
||||
&& text.trim().length > 0;
|
||||
}
|
||||
|
||||
_openPlainWarning() {
|
||||
if (false === this.hasPlainWarningTarget) {
|
||||
// No popover in this window (the settings signature editor renders
|
||||
// the toolbar without one): fall back to switching, which is what
|
||||
// declining could only ever postpone.
|
||||
this._setPlainText(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.plainWarningTarget.hidden = false;
|
||||
|
||||
this._boundPlainWarningOutside ??= (event) => {
|
||||
if (false === this.plainWarningTarget.contains(event.target)) {
|
||||
this.cancelPlainText();
|
||||
}
|
||||
};
|
||||
this._boundPlainWarningEscape ??= (event) => {
|
||||
if ('Escape' === event.key) {
|
||||
event.stopPropagation();
|
||||
this.cancelPlainText();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', this._boundPlainWarningOutside, { capture: true });
|
||||
document.addEventListener('keydown', this._boundPlainWarningEscape);
|
||||
|
||||
// Focus lands on Continue so the dialog is operable from the keyboard
|
||||
// at all; Escape and Cancel are both a decline, which is the safe
|
||||
// default this guard exists to protect.
|
||||
if (this.hasPlainWarningConfirmTarget) {
|
||||
this.plainWarningConfirmTarget.focus();
|
||||
}
|
||||
}
|
||||
|
||||
_closePlainWarning() {
|
||||
if (false === this.hasPlainWarningTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.plainWarningTarget.hidden = true;
|
||||
|
||||
document.removeEventListener('click', this._boundPlainWarningOutside, { capture: true });
|
||||
document.removeEventListener('keydown', this._boundPlainWarningEscape);
|
||||
}
|
||||
|
||||
/** "Continue" — the switch the warning was standing in front of. */
|
||||
confirmPlainText() {
|
||||
this._closePlainWarning();
|
||||
this._setPlainText(true);
|
||||
}
|
||||
|
||||
/** "Cancel", Escape, or a click outside. The body is left exactly as it was. */
|
||||
cancelPlainText() {
|
||||
this._closePlainWarning();
|
||||
}
|
||||
|
||||
_setPlainText(plain) {
|
||||
@@ -1009,20 +1095,7 @@ export default class extends Controller {
|
||||
const editor = this.bodyTarget;
|
||||
|
||||
if (true === plain) {
|
||||
const text = this._bodyAsText(editor);
|
||||
|
||||
// Only warn when there is something to lose. An empty body, or one
|
||||
// that is already nothing but text, converts silently.
|
||||
if (editor.innerHTML.trim() !== text.replace(/\n/g, '') && text.trim().length > 0) {
|
||||
if (false === window.confirm(this._t(
|
||||
'confirmPlainText',
|
||||
'Switching to plain text removes all formatting from this message. Continue?',
|
||||
))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
textarea.value = text;
|
||||
textarea.value = this._bodyAsText(editor);
|
||||
} else {
|
||||
// Coming back: the editor still holds the HTML it always did, so
|
||||
// there is nothing to restore unless the user typed into the
|
||||
|
||||
@@ -11,8 +11,26 @@ export default class extends Controller {
|
||||
'italicBtn',
|
||||
'underlineBtn',
|
||||
'colorSwatch',
|
||||
// The link popover and its two faces. See #openLinkPopover.
|
||||
'linkBtn',
|
||||
'linkPopover',
|
||||
'linkEdit',
|
||||
'linkView',
|
||||
'linkHeading',
|
||||
'linkUrl',
|
||||
'linkText',
|
||||
'linkError',
|
||||
'linkUnlink',
|
||||
'linkHref',
|
||||
];
|
||||
|
||||
static values = {
|
||||
// Translated server-side and handed over as one object, exactly as
|
||||
// compose--compose and ui--select take theirs. Nothing in this file
|
||||
// spells an English string it expects a user to read.
|
||||
i18n: { type: Object, default: {} },
|
||||
};
|
||||
|
||||
// Keyed by the <option>'s value, not its label. The labels used to be the
|
||||
// keys, which meant they could never be translated: "Small" was both what
|
||||
// the German user read and what this map had to be looked up by.
|
||||
@@ -29,20 +47,47 @@ export default class extends Controller {
|
||||
monospace: '"Courier New", monospace',
|
||||
};
|
||||
|
||||
/**
|
||||
* The schemes a link may carry, and deliberately the same four as
|
||||
* MailBodySanitizer::allowLinkSchemes(). Kept identical so the editor never
|
||||
* accepts an href the save would silently drop — a `javascript:` URL that
|
||||
* looked fine while writing and vanished on reload is a worse bug than a
|
||||
* refusal, because nothing tells the writer it happened.
|
||||
*/
|
||||
static LINK_SCHEMES = ['http', 'https', 'mailto', 'tel'];
|
||||
|
||||
// Saved selection range — populated on editor blur so colour picker
|
||||
// and other focus-stealing controls can restore it before acting.
|
||||
#savedRange = null;
|
||||
|
||||
// While the link popover is open the saved range is FROZEN. Focusing a
|
||||
// text field inside the popover blurs the editor, which fires the blur
|
||||
// handler, which would otherwise overwrite the very range the popover was
|
||||
// opened to act on — with whatever the selection had collapsed to. This is
|
||||
// the whole selection-preservation story in one flag.
|
||||
#rangeLocked = false;
|
||||
|
||||
// The <a> the popover is currently about, if any. Non-null means "edit this
|
||||
// one in place"; null means "wrap the selection in a new one". Nesting an
|
||||
// anchor inside an anchor is not something a mail client should ever
|
||||
// produce, and execCommand('createLink') happily would.
|
||||
#linkAnchor = null;
|
||||
|
||||
connect() {
|
||||
this._boundUpdateState = this._updateButtonState.bind(this);
|
||||
this._boundSyncInput = this._syncHiddenInput.bind(this);
|
||||
this._boundSaveRange = this._saveRange.bind(this);
|
||||
this._boundEditorClick = this._handleEditorClick.bind(this);
|
||||
this._boundOutside = this._handleOutside.bind(this);
|
||||
this._boundEscape = this._handleEscape.bind(this);
|
||||
this._boundSelection = this._handleSelectionChange.bind(this);
|
||||
|
||||
if (this.hasEditorTarget) {
|
||||
this.editorTarget.addEventListener('keyup', this._boundUpdateState);
|
||||
this.editorTarget.addEventListener('mouseup', this._boundUpdateState);
|
||||
this.editorTarget.addEventListener('input', this._boundSyncInput);
|
||||
this.editorTarget.addEventListener('blur', this._boundSaveRange);
|
||||
this.editorTarget.addEventListener('click', this._boundEditorClick);
|
||||
}
|
||||
|
||||
this.element.querySelectorAll('[data-compose--compose-toolbar-action]').forEach(btn => {
|
||||
@@ -56,12 +101,19 @@ export default class extends Controller {
|
||||
this.editorTarget.removeEventListener('mouseup', this._boundUpdateState);
|
||||
this.editorTarget.removeEventListener('input', this._boundSyncInput);
|
||||
this.editorTarget.removeEventListener('blur', this._boundSaveRange);
|
||||
this.editorTarget.removeEventListener('click', this._boundEditorClick);
|
||||
}
|
||||
|
||||
this._detachLinkListeners();
|
||||
}
|
||||
|
||||
// ── Selection save / restore ──────────────────────────────────────
|
||||
|
||||
_saveRange() {
|
||||
if (true === this.#rangeLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
this.#savedRange = sel.getRangeAt(0).cloneRange();
|
||||
@@ -70,6 +122,26 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the selection RIGHT NOW, past the lock.
|
||||
*
|
||||
* Used at the moment a popover opens: the toolbar buttons cancel their own
|
||||
* mousedown, so the editor still holds the user's selection when the click
|
||||
* handler runs, and this is the last instant at which that is true.
|
||||
*
|
||||
* It does not itself LEAVE the lock on — _openLinkEditor sets it, before
|
||||
* it focuses a field and therefore before anything can blur the editor.
|
||||
* Capturing without opening (a surface with no popover in it) must not
|
||||
* leave the range frozen for the rest of the session.
|
||||
*/
|
||||
_captureRange() {
|
||||
const locked = this.#rangeLocked;
|
||||
|
||||
this.#rangeLocked = false;
|
||||
this._saveRange();
|
||||
this.#rangeLocked = locked;
|
||||
}
|
||||
|
||||
_restoreRange() {
|
||||
this._focusEditor();
|
||||
if (!this.#savedRange) { return; }
|
||||
@@ -334,13 +406,489 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Links ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Three jobs, one popover.
|
||||
//
|
||||
// • the toolbar button — write a link, or edit the one the caret is in
|
||||
// • a click on a link in the body — say where it goes and offer the three
|
||||
// things you can do to it, because a click in a contenteditable only
|
||||
// places a caret and a link you just wrote is otherwise unreachable
|
||||
// • Change, which is the first of those reached from the second
|
||||
//
|
||||
// The URL is normalised and screened HERE rather than left to the server.
|
||||
// MailBodySanitizer is the backstop and refuses anything outside
|
||||
// http/https/mailto/tel on the way out — but a `javascript:` href that the
|
||||
// editor accepted and the save silently dropped is an editing experience
|
||||
// that lies, and a bare "example.com" that becomes a relative link to
|
||||
// /mail/example.com is worse: it survives the sanitiser intact and is
|
||||
// simply wrong. Both are decided before anything enters the document.
|
||||
|
||||
/** The toolbar button. */
|
||||
insertLink() {
|
||||
this._saveRange();
|
||||
const url = prompt('Enter URL:');
|
||||
if (url) {
|
||||
this._restoreRange();
|
||||
this._exec('createLink', url);
|
||||
this._captureRange();
|
||||
|
||||
const anchor = this._anchorInSelection();
|
||||
const selection = this.#savedRange ? this.#savedRange.toString() : '';
|
||||
|
||||
// The caret is inside a link: edit that one, prefilled from it. The
|
||||
// alternative — wrapping it in a second anchor — produces markup no
|
||||
// mail client renders the way the writer meant.
|
||||
if (null !== anchor) {
|
||||
this._openLinkEditor(anchor, anchor.getAttribute('href') ?? '', anchor.textContent);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this._openLinkEditor(null, '', selection);
|
||||
}
|
||||
|
||||
/** Enter submits, Escape closes and gives the selection back. */
|
||||
linkKeydown(event) {
|
||||
// Only the editing face has anything to submit. Enter on the viewing
|
||||
// face's Open button must stay the browser's own "activate this link".
|
||||
const editing = this.hasLinkEditTarget && false === this.linkEditTarget.hidden;
|
||||
|
||||
if ('Enter' === event.key && true === editing) {
|
||||
// The popover lives inside the compose <form>: an un-cancelled
|
||||
// Enter in a text field submits it, which is to say it SENDS the
|
||||
// message the user was still writing a link into.
|
||||
event.preventDefault();
|
||||
this.applyLink();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ('Escape' === event.key) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.closeLink();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel.
|
||||
*
|
||||
* The selection is handed back only if this was the EDITING face. That
|
||||
* face froze the range and took focus away from the body, so giving it
|
||||
* back is the courtesy that makes Escape a true no-op. The viewing face
|
||||
* did neither — it appears beside a caret the user placed themselves — and
|
||||
* restoring anything there would drag the caret back to wherever the last
|
||||
* saved range happened to be, which is the popover fighting the caret.
|
||||
*/
|
||||
closeLink() {
|
||||
if (false === this.hasLinkPopoverTarget || true === this.linkPopoverTarget.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasEditing = this.hasLinkEditTarget && false === this.linkEditTarget.hidden;
|
||||
|
||||
this.linkPopoverTarget.hidden = true;
|
||||
this.#linkAnchor = null;
|
||||
this._detachLinkListeners();
|
||||
this._clearLinkError();
|
||||
|
||||
if (this.hasLinkBtnTarget) {
|
||||
this.linkBtnTarget.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
// Unfreeze first, then restore — the restore itself moves the selection
|
||||
// and the next genuine blur must be free to record where it ended up.
|
||||
this.#rangeLocked = false;
|
||||
|
||||
if (true === wasEditing) {
|
||||
this._restoreRange();
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply, from either the button or Enter. */
|
||||
applyLink() {
|
||||
if (false === this.hasLinkUrlTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = this._normaliseUrl(this.linkUrlTarget.value);
|
||||
|
||||
if (null === url) {
|
||||
this._showLinkError(this._t(
|
||||
'' === this.linkUrlTarget.value.trim() ? 'linkInvalid' : 'linkUnsafe',
|
||||
'' === this.linkUrlTarget.value.trim()
|
||||
? 'Enter a web address.'
|
||||
: 'Only http, https, mailto and tel addresses can be linked.',
|
||||
));
|
||||
this.linkUrlTarget.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const text = this.hasLinkTextTarget ? this.linkTextTarget.value.trim() : '';
|
||||
const anchor = this.#linkAnchor;
|
||||
|
||||
if (null !== anchor) {
|
||||
this._writeAnchor(anchor, url, '' === text ? anchor.textContent : text);
|
||||
this.#linkAnchor = null;
|
||||
this._closeAndSync();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const created = document.createElement('a');
|
||||
this._writeAnchor(created, url, '' === text ? url : text);
|
||||
|
||||
// _insertAtCaret restores the saved range and replaces whatever it
|
||||
// covers, which is right: the text field was prefilled FROM that
|
||||
// selection, so the selection is what the new link stands in for.
|
||||
this.#rangeLocked = false;
|
||||
this.linkPopoverTarget.hidden = true;
|
||||
this._detachLinkListeners();
|
||||
this._insertAtCaret(created);
|
||||
this._closeAndSync();
|
||||
}
|
||||
|
||||
/** Open, from the viewing face. */
|
||||
openLink() {
|
||||
const href = this.hasLinkHrefTarget ? this.linkHrefTarget.getAttribute('href') : null;
|
||||
|
||||
if (null === href || '#' === href) {
|
||||
return;
|
||||
}
|
||||
|
||||
// `noopener` as well as the attribute on the anchor: this is a scripted
|
||||
// open and the feature string is what governs it.
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
/** Change, from the viewing face — the same popover, other side up. */
|
||||
changeLink() {
|
||||
const anchor = this.#linkAnchor;
|
||||
|
||||
if (null === anchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._openLinkEditor(anchor, anchor.getAttribute('href') ?? '', anchor.textContent);
|
||||
}
|
||||
|
||||
/** Remove — the text stays, the link goes. */
|
||||
removeLink() {
|
||||
const anchor = this.#linkAnchor;
|
||||
|
||||
if (null === anchor) {
|
||||
this.closeLink();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Where to put the caret afterwards, worked out before the node it
|
||||
// refers to stops existing.
|
||||
const parent = anchor.parentNode;
|
||||
const marker = document.createTextNode('');
|
||||
|
||||
parent?.insertBefore(marker, anchor);
|
||||
this._unwrapNode(anchor);
|
||||
|
||||
this.#linkAnchor = null;
|
||||
this.#rangeLocked = false;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStartAfter(marker);
|
||||
range.collapse(true);
|
||||
this.#savedRange = range.cloneRange();
|
||||
marker.remove();
|
||||
|
||||
this.linkPopoverTarget.hidden = true;
|
||||
this._detachLinkListeners();
|
||||
this._closeAndSync();
|
||||
}
|
||||
|
||||
// ── Link popover internals ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Show the editing face.
|
||||
*
|
||||
* `anchor` non-null makes this an edit in place and turns Remove on.
|
||||
*
|
||||
* The URL field takes focus, and doing so blurs the editor — which is
|
||||
* exactly why _captureRange() has already frozen the range. Nothing
|
||||
* between here and applyLink() can move the user's place.
|
||||
*/
|
||||
_openLinkEditor(anchor, url, text) {
|
||||
if (false === this.hasLinkPopoverTarget
|
||||
|| false === this.hasLinkEditTarget
|
||||
|| false === this.hasLinkUrlTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#linkAnchor = anchor;
|
||||
this.#rangeLocked = true;
|
||||
|
||||
this._clearLinkError();
|
||||
|
||||
this.linkEditTarget.hidden = false;
|
||||
if (this.hasLinkViewTarget) { this.linkViewTarget.hidden = true; }
|
||||
|
||||
if (this.hasLinkHeadingTarget) {
|
||||
this.linkHeadingTarget.textContent = null === anchor
|
||||
? this._t('linkAdd', 'Insert link')
|
||||
: this._t('linkEdit', 'Edit link');
|
||||
}
|
||||
|
||||
this.linkUrlTarget.value = url;
|
||||
|
||||
if (this.hasLinkTextTarget) {
|
||||
this.linkTextTarget.value = text ?? '';
|
||||
}
|
||||
|
||||
if (this.hasLinkUnlinkTarget) {
|
||||
this.linkUnlinkTarget.hidden = null === anchor;
|
||||
}
|
||||
|
||||
this._showLinkPopover();
|
||||
|
||||
// A new link starts in the URL field; an existing one usually wants its
|
||||
// address changed too, so it does the same.
|
||||
this.linkUrlTarget.focus();
|
||||
this.linkUrlTarget.select();
|
||||
}
|
||||
|
||||
/** Show the viewing face for a link the caret has landed in. */
|
||||
_openLinkViewer(anchor) {
|
||||
if (false === this.hasLinkPopoverTarget
|
||||
|| false === this.hasLinkViewTarget
|
||||
|| false === this.hasLinkEditTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = anchor.getAttribute('href') ?? '';
|
||||
|
||||
this.#linkAnchor = anchor;
|
||||
|
||||
this.linkEditTarget.hidden = true;
|
||||
this.linkViewTarget.hidden = false;
|
||||
|
||||
if (this.hasLinkHrefTarget) {
|
||||
this.linkHrefTarget.setAttribute('href', href);
|
||||
this.linkHrefTarget.textContent = href;
|
||||
}
|
||||
|
||||
this._showLinkPopover();
|
||||
|
||||
// Deliberately NOT focused and NOT range-locked: this face appears
|
||||
// while the user is still typing, and stealing the caret out of the
|
||||
// body to a popover they did not ask for is the behaviour the report
|
||||
// called "fighting the caret".
|
||||
document.addEventListener('selectionchange', this._boundSelection);
|
||||
}
|
||||
|
||||
_showLinkPopover() {
|
||||
this.linkPopoverTarget.hidden = false;
|
||||
|
||||
if (this.hasLinkBtnTarget) {
|
||||
this.linkBtnTarget.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
// Capture phase, like ui--dropdown's: the menu has to close even when
|
||||
// whatever was clicked stops propagation on its way up.
|
||||
document.addEventListener('click', this._boundOutside, { capture: true });
|
||||
document.addEventListener('keydown', this._boundEscape);
|
||||
}
|
||||
|
||||
_detachLinkListeners() {
|
||||
document.removeEventListener('click', this._boundOutside, { capture: true });
|
||||
document.removeEventListener('keydown', this._boundEscape);
|
||||
document.removeEventListener('selectionchange', this._boundSelection);
|
||||
}
|
||||
|
||||
_closeAndSync() {
|
||||
this.#rangeLocked = false;
|
||||
this._detachLinkListeners();
|
||||
|
||||
if (this.hasLinkPopoverTarget) {
|
||||
this.linkPopoverTarget.hidden = true;
|
||||
}
|
||||
|
||||
if (this.hasLinkBtnTarget) {
|
||||
this.linkBtnTarget.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
this._syncHiddenInput();
|
||||
|
||||
// Nothing script changes in the DOM fires `input`, and the autosave is
|
||||
// a listener on it — without this the link is on screen and gone again
|
||||
// on reload. Same reason as _insertAtCaret's.
|
||||
if (this.hasEditorTarget) {
|
||||
this.editorTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
this._focusEditor();
|
||||
this._restoreRange();
|
||||
}
|
||||
|
||||
/** href, and the two attributes that keep a new tab from reaching back. */
|
||||
_writeAnchor(anchor, url, text) {
|
||||
anchor.setAttribute('href', url);
|
||||
anchor.setAttribute('target', '_blank');
|
||||
anchor.setAttribute('rel', 'noopener noreferrer');
|
||||
|
||||
if ('string' === typeof text && '' !== text && anchor.textContent !== text) {
|
||||
anchor.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed address, as an href — or null if it must not become one.
|
||||
*
|
||||
* Refusals and rewrites, in order:
|
||||
*
|
||||
* • control characters are stripped first, so `java\nscript:alert(1)` —
|
||||
* which browsers happily execute and a naive scheme test reads as
|
||||
* scheme-less — is measured as the `javascript:` URL it is.
|
||||
* • a URL that names a scheme keeps it only if it is one the sanitiser
|
||||
* will also keep. That list is MailBodySanitizer's allowLinkSchemes()
|
||||
* verbatim, so nothing this accepts is dropped on save.
|
||||
* • "example.com" becomes "https://example.com" rather than a relative
|
||||
* link. This is the case the old prompt got wrong every time.
|
||||
* • "someone@example.com" becomes a mailto:, which is what anyone typing
|
||||
* an address into a link field meant.
|
||||
*/
|
||||
_normaliseUrl(raw) {
|
||||
if ('string' !== typeof raw) { return null; }
|
||||
|
||||
// Control characters out first. Browsers strip the tab and newline
|
||||
// from a URL themselves, which is what makes "java\nscript:alert(1)"
|
||||
// execute while reading as scheme-less to a naive test.
|
||||
const value = raw.replace(/[\u0000-\u001F\u007F]/g, '').trim();
|
||||
|
||||
if ('' === value) { return null; }
|
||||
|
||||
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(value);
|
||||
|
||||
if (null !== scheme) {
|
||||
return this.constructor.LINK_SCHEMES.includes(scheme[1].toLowerCase())
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
// Protocol-relative, which is a scheme choice rather than a hostname.
|
||||
if (value.startsWith('//')) { return `https:${value}`; }
|
||||
|
||||
// An address, not a host. A slash anywhere means it is a path.
|
||||
if (value.includes('@') && false === value.includes('/')) {
|
||||
return `mailto:${value}`;
|
||||
}
|
||||
|
||||
return `https://${value}`;
|
||||
}
|
||||
|
||||
/** The <a> the saved range sits in, or null. */
|
||||
_anchorInSelection() {
|
||||
if (null === this.#savedRange || false === this.hasEditorTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const node = this.#savedRange.startContainer;
|
||||
const el = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node;
|
||||
const link = el?.closest?.('a');
|
||||
|
||||
return (link && this.editorTarget.contains(link)) ? link : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A click on a link in the body.
|
||||
*
|
||||
* The caret is left exactly where the browser put it — the popover is an
|
||||
* addition to the click, not a replacement for it — except on a modified
|
||||
* click, which everywhere else in every application means "follow this",
|
||||
* and is honoured as such.
|
||||
*/
|
||||
_handleEditorClick(event) {
|
||||
const anchor = event.target?.closest?.('a');
|
||||
|
||||
if (!anchor || false === this.editorTarget.contains(anchor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (true === event.metaKey || true === event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
|
||||
const href = this._normaliseUrl(anchor.getAttribute('href') ?? '');
|
||||
|
||||
if (null !== href) {
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this._openLinkViewer(anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* The caret has moved. If it is no longer in the link the viewing face is
|
||||
* about, that face has nothing left to describe.
|
||||
*
|
||||
* Only ever attached while the viewer is up — the editing face freezes the
|
||||
* selection on purpose and must not be dismissed by its own focus move.
|
||||
*/
|
||||
_handleSelectionChange() {
|
||||
if (null === this.#linkAnchor || false === this.hasLinkViewTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (true === this.linkViewTarget.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sel = window.getSelection();
|
||||
|
||||
if (!sel || 0 === sel.rangeCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = sel.getRangeAt(0).startContainer;
|
||||
const el = (node.nodeType === Node.TEXT_NODE) ? node.parentElement : node;
|
||||
|
||||
// Still inside the popover (the user is reaching for one of its
|
||||
// buttons) or still inside the link: leave it up.
|
||||
if (this.linkPopoverTarget.contains(el) || this.#linkAnchor.contains(el)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.linkPopoverTarget.hidden = true;
|
||||
this.#linkAnchor = null;
|
||||
this._detachLinkListeners();
|
||||
}
|
||||
|
||||
_handleOutside(event) {
|
||||
if (false === this.linkPopoverTarget.contains(event.target)) {
|
||||
this.closeLink();
|
||||
}
|
||||
}
|
||||
|
||||
_handleEscape(event) {
|
||||
if ('Escape' === event.key) {
|
||||
event.stopPropagation();
|
||||
this.closeLink();
|
||||
}
|
||||
}
|
||||
|
||||
_showLinkError(message) {
|
||||
if (false === this.hasLinkErrorTarget) { return; }
|
||||
|
||||
this.linkErrorTarget.textContent = message;
|
||||
this.linkErrorTarget.hidden = false;
|
||||
}
|
||||
|
||||
_clearLinkError() {
|
||||
if (false === this.hasLinkErrorTarget) { return; }
|
||||
|
||||
this.linkErrorTarget.textContent = '';
|
||||
this.linkErrorTarget.hidden = true;
|
||||
}
|
||||
|
||||
/** A translated string, falling back to English if the value is absent. */
|
||||
_t(key, fallback) {
|
||||
return this.i18nValue?.[key] ?? fallback;
|
||||
}
|
||||
|
||||
changeFontFamily(event) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import { snoozeOptions } from "../../snooze_options.js";
|
||||
import { prefersHour12 } from "../../clock_format.js";
|
||||
|
||||
/**
|
||||
* Fills a snooze menu with concrete wake times.
|
||||
@@ -62,10 +63,16 @@ export default class extends Controller {
|
||||
/**
|
||||
* "Sat 08:00" — weekday and time, no date. The label above it already says
|
||||
* which day in words; this is the confirmation, not the primary reading.
|
||||
*
|
||||
* `hour12` explicitly, for the same reason formatWallClock() passes it: an
|
||||
* hour asked for without it follows the LOCALE's default rather than the
|
||||
* user's clock setting, so this menu printed "8:00 AM" at someone who had
|
||||
* chosen a 24-hour clock everywhere else in the app.
|
||||
*/
|
||||
#format(date) {
|
||||
return date.toLocaleString(document.documentElement.lang || undefined, {
|
||||
weekday: "short",
|
||||
hour12: prefersHour12(),
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
* PHP's.
|
||||
*/
|
||||
|
||||
import { prefersHour12 } from "./clock_format.js";
|
||||
|
||||
/** "Morning", for every preset that lands on a future day. */
|
||||
const MORNING_HOUR = 8;
|
||||
|
||||
@@ -192,10 +194,16 @@ export function scheduleOptions(timeZone, now = new Date()) {
|
||||
* time. Building the Date in UTC and formatting in UTC is how you get Intl to
|
||||
* do the weekday and the 12/24-hour choice without also doing an offset.
|
||||
*
|
||||
* `hour12` is passed EXPLICITLY. Leaving it out is what made this the one
|
||||
* clock in the app that ignored the user's setting; the default now comes from
|
||||
* their choice, and only falls back to the locale's when there is no choice on
|
||||
* the page to read.
|
||||
*
|
||||
* @param {string} at wall clock, "YYYY-MM-DDTHH:MM"
|
||||
* @param {string} locale
|
||||
* @param {boolean} [hour12] overrides the document-level preference
|
||||
*/
|
||||
export function formatWallClock(at, locale) {
|
||||
export function formatWallClock(at, locale, hour12 = prefersHour12()) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(at);
|
||||
|
||||
if (null === match) {
|
||||
@@ -207,6 +215,10 @@ export function formatWallClock(at, locale) {
|
||||
return new Intl.DateTimeFormat(locale || undefined, {
|
||||
timeZone: "UTC",
|
||||
weekday: "short",
|
||||
// `hourCycle` is not set alongside it on purpose: the two conflict, and
|
||||
// hour12 alone lets the locale keep its own idea of whether 24-hour
|
||||
// means h23 or h24.
|
||||
hour12: hour12,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(Date.UTC(year, month - 1, day, hour, minute)));
|
||||
|
||||
+61
-3
@@ -1479,15 +1479,46 @@
|
||||
of being a white rectangle in a dark window. */
|
||||
emoji-picker {
|
||||
--emoji-font-family: 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif;
|
||||
|
||||
/* THE CHANNELS SPENT THROUGH THEIR ALPHAS, which is the only correct way
|
||||
to read them, and the "weird black borders" in the report were what
|
||||
happens otherwise: --rgb-line is `0 0 0` in every light theme and
|
||||
`255 255 255` in every dark one, because it carries a hue and leaves the
|
||||
weight to --line-alpha (0.06–0.1). Written as rgb(var(--rgb-line)) with
|
||||
no alpha it resolved to PURE BLACK — around the panel, around the search
|
||||
field, and between the category tabs — in exactly the themes where a
|
||||
hairline was meant. The pairs below are the same ones --color-line,
|
||||
--color-hover and --color-sunken are built from, so the picker's edges
|
||||
are now drawn with the values every other edge in the app uses. */
|
||||
--background: rgb(var(--rgb-surface));
|
||||
--border-color: rgb(var(--rgb-line));
|
||||
--input-border-color: rgb(var(--rgb-line));
|
||||
--border-color: rgb(var(--rgb-line) / var(--line-alpha));
|
||||
--input-border-color: rgb(var(--rgb-line) / var(--line-alpha));
|
||||
--input-font-color: rgb(var(--rgb-ink));
|
||||
--input-placeholder-color: rgb(var(--rgb-ink-faint));
|
||||
--category-font-color: rgb(var(--rgb-ink-muted));
|
||||
--indicator-color: rgb(var(--rgb-accent));
|
||||
--button-active-background: rgb(var(--rgb-raised) / var(--raised-alpha));
|
||||
--button-hover-background: rgb(var(--rgb-raised) / var(--raised-alpha));
|
||||
--button-hover-background: rgb(var(--rgb-raised) / var(--hover-alpha));
|
||||
--custom-emoji-background: rgb(var(--rgb-sunken) / var(--sunken-alpha));
|
||||
--outline-color: rgb(var(--rgb-accent));
|
||||
--border-radius: var(--app-radius);
|
||||
|
||||
/* The second half of the same bug, and the half CSS variables could not
|
||||
fix. emoji-picker-element declares `color-scheme` on its own :host from
|
||||
`prefers-color-scheme` — the OPERATING SYSTEM's setting, which this app
|
||||
does not use for anything: the theme comes from the user's account and
|
||||
:root declares color-scheme per theme block to match. On a dark desktop
|
||||
running the app in a light theme the picker therefore asked the browser
|
||||
for dark UA widgets, and got a black search field and a black scrollbar
|
||||
inside a white panel.
|
||||
|
||||
`inherit` hands it back to the theme. Declarations from the document
|
||||
tree beat a shadow tree's :host for the same element, so this wins
|
||||
without !important — and because it inherits rather than naming a
|
||||
scheme, the runtime theme switch (which rewrites data-theme on <html>
|
||||
and re-resolves :root) carries the picker with it. */
|
||||
color-scheme: inherit;
|
||||
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: min(24rem, 50vh);
|
||||
@@ -1505,6 +1536,15 @@ emoji-picker {
|
||||
.emoji-panel {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Room for the panel AND the toolbar under it on a short phone. `bottom-20`
|
||||
puts its foot at 5rem; 50vh of an 851px viewport is 425px, which together
|
||||
overflow the top of a 667px iPhone SE. Measured against the viewport
|
||||
rather than assumed — compose-mobile.spec.ts asserts the box is inside
|
||||
it at 393px and at 320px. */
|
||||
emoji-picker {
|
||||
max-height: min(20rem, calc(100dvh - 12rem));
|
||||
}
|
||||
}
|
||||
|
||||
/* The body being written. Only emoji code points resolve here; every other
|
||||
@@ -1513,3 +1553,21 @@ emoji-picker {
|
||||
[data-compose--compose-toolbar-target="editor"] {
|
||||
font-family: 'Noto Color Emoji', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* A link in the body has to LOOK like one.
|
||||
Tailwind's preflight resets `a` to `color: inherit; text-decoration: inherit`,
|
||||
which is right for an app whose every anchor is already a styled button or a
|
||||
list row — and wrong for the one surface where anchors are user content. A
|
||||
link inserted into the editor was indistinguishable from the words around
|
||||
it: nothing said it was there, so nothing invited the click that now opens
|
||||
the link popover.
|
||||
|
||||
Accent rather than a browser blue, so it matches the theme, and the same
|
||||
colour the popover writes the address in. The caret stays a text caret: this
|
||||
is a document being written, and a pointer cursor over part of a sentence
|
||||
would say the click does something other than put the cursor there. */
|
||||
[data-compose--compose-toolbar-target="editor"] a[href] {
|
||||
color: rgb(var(--rgb-accent));
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
@@ -19,6 +19,19 @@
|
||||
value of the kernel actually serving the request — false in prod, true
|
||||
in dev — so the bootstrap can correct the baked-in one. #}
|
||||
data-app-debug="{{ app.debug ? 'true' : 'false' }}"
|
||||
{# The user's twelve-or-twenty-four-hour choice, for the JavaScript that
|
||||
prints a time. Twig has `clock.time` and reads the setting directly;
|
||||
`Intl.DateTimeFormat` in the browser has no idea it exists and follows
|
||||
the *locale's* default instead — which is how the send-later presets
|
||||
came to say "8:00 AM" to someone who had asked for a 24-hour clock.
|
||||
|
||||
On <html> rather than threaded through each controller as a Stimulus
|
||||
value because it is wanted in three unrelated places already (the
|
||||
send-later menu, the snooze menu, and anything that formats a time
|
||||
next) and none of them share a template. Same argument as ClockGlobal
|
||||
being a Twig global rather than a controller variable. Read through
|
||||
`prefersHour12()` in assets/clock_format.js. #}
|
||||
data-clock-hour12="{{ clock.is12Hour ? 'true' : 'false' }}"
|
||||
style="{{ appearance_vars() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
{#
|
||||
compose/_link_popover.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
The insert-link popover, for any surface driven by `compose--compose-toolbar`.
|
||||
|
||||
What it replaces is `prompt('Enter URL:')` — one unthemed field, no way to say
|
||||
what the link should READ as, and a bare "example.com" going in as a relative
|
||||
link to a path inside the app.
|
||||
|
||||
ONE element with two faces rather than two popovers. They are the same object
|
||||
at two moments — "what is this link" and "what should this link be" — and
|
||||
Change is a switch between them rather than a different window. The controller
|
||||
drives both; it is also what holds the saved selection the URL is applied to,
|
||||
which is why focusing a field in here does not lose the user's place. See the
|
||||
note on #savedRange in compose_toolbar_controller.js.
|
||||
|
||||
A partial rather than markup in the compose window, because the settings
|
||||
signature editor runs the SAME controller and had the same link button — and
|
||||
therefore had the same prompt. One popover, two hosts, no second copy to
|
||||
forget when one of them changes.
|
||||
|
||||
The POSITIONING is the caller's, passed in as `class`: the compose window
|
||||
anchors it above a toolbar block that spans the window and never scrolls (the
|
||||
button itself lives in a sideways scroller and would drag the popover off
|
||||
screen with it); the signature editor anchors it under its own small bar.
|
||||
|
||||
Variables:
|
||||
id string unique suffix for the two field ids
|
||||
class string positioning and width classes for the popover box
|
||||
#}
|
||||
<div data-compose--compose-toolbar-target="linkPopover" hidden
|
||||
role="dialog"
|
||||
aria-label="{{ 'compose.link.dialog'|trans }}"
|
||||
data-action="keydown->compose--compose-toolbar#linkKeydown"
|
||||
class="{{ class|default('absolute bottom-full mb-2 left-2 right-2 md:left-auto md:right-3 md:w-80') }}
|
||||
z-40 popover p-3 text-sm">
|
||||
|
||||
{# ── Editing face ──────────────────────────────────────────── #}
|
||||
<div data-compose--compose-toolbar-target="linkEdit" class="space-y-2">
|
||||
<p data-compose--compose-toolbar-target="linkHeading"
|
||||
class="text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
|
||||
{{ 'compose.link.add'|trans }}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label for="link-url-{{ id }}" class="block text-[11px] text-ink-faint mb-0.5">
|
||||
{{ 'compose.link.url_label'|trans }}
|
||||
</label>
|
||||
{# `text`, not `url`: a bare "example.com" is what people type and
|
||||
is exactly what the controller normalises to https://example.com.
|
||||
type=url would have the browser refuse it before we saw it. #}
|
||||
<input type="text" inputmode="url"
|
||||
id="link-url-{{ id }}"
|
||||
data-compose--compose-toolbar-target="linkUrl"
|
||||
autocomplete="off" spellcheck="false"
|
||||
placeholder="{{ 'compose.link.url_placeholder'|trans }}"
|
||||
class="w-full rounded-lg border border-line bg-surface px-2 py-1.5
|
||||
text-sm text-ink placeholder:text-ink-faint outline-none
|
||||
focus:ring-2 focus:ring-accent">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="link-text-{{ id }}" class="block text-[11px] text-ink-faint mb-0.5">
|
||||
{{ 'compose.link.text_label'|trans }}
|
||||
</label>
|
||||
<input type="text"
|
||||
id="link-text-{{ id }}"
|
||||
data-compose--compose-toolbar-target="linkText"
|
||||
autocomplete="off"
|
||||
placeholder="{{ 'compose.link.text_placeholder'|trans }}"
|
||||
class="w-full rounded-lg border border-line bg-surface px-2 py-1.5
|
||||
text-sm text-ink placeholder:text-ink-faint outline-none
|
||||
focus:ring-2 focus:ring-accent">
|
||||
</div>
|
||||
|
||||
<p data-compose--compose-toolbar-target="linkError" hidden
|
||||
role="alert" class="text-xs text-danger"></p>
|
||||
|
||||
<div class="flex items-center gap-2 pt-0.5">
|
||||
<button type="button"
|
||||
data-action="click->compose--compose-toolbar#applyLink"
|
||||
class="rounded-full bg-accent text-white text-sm font-semibold
|
||||
px-3 py-1.5 hover:bg-white/10 transition-colors">
|
||||
{{ 'compose.link.apply'|trans }}
|
||||
</button>
|
||||
<button type="button"
|
||||
data-action="click->compose--compose-toolbar#closeLink"
|
||||
class="rounded-full px-3 py-1.5 text-sm text-ink-muted
|
||||
hover:bg-hover hover:text-ink transition-colors">
|
||||
{{ 'compose.link.cancel'|trans }}
|
||||
</button>
|
||||
{# Only while an existing link is being edited — there is nothing
|
||||
to unlink otherwise. #}
|
||||
<button type="button" hidden
|
||||
data-compose--compose-toolbar-target="linkUnlink"
|
||||
data-action="click->compose--compose-toolbar#removeLink"
|
||||
class="ml-auto text-xs text-danger hover:underline">
|
||||
{{ 'compose.link.remove'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Viewing face ──────────────────────────────────────────────
|
||||
A click in a contenteditable only places a caret, so a link the user
|
||||
just wrote is otherwise unreachable without sending the message first.
|
||||
This is the Gmail affordance: the address, and the three things you can
|
||||
do to it. #}
|
||||
<div data-compose--compose-toolbar-target="linkView" hidden class="space-y-2">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
|
||||
{{ 'compose.link.heading'|trans }}
|
||||
</p>
|
||||
{# A real anchor, so the browser's own "copy link address" and
|
||||
middle-click work on it. #}
|
||||
<a data-compose--compose-toolbar-target="linkHref"
|
||||
href="#" target="_blank" rel="noopener noreferrer"
|
||||
class="block truncate text-sm text-accent hover:underline"></a>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button"
|
||||
data-action="click->compose--compose-toolbar#openLink"
|
||||
class="px-2 py-1 rounded text-sm text-ink-muted hover:bg-hover
|
||||
hover:text-ink transition-colors">
|
||||
{{ 'compose.link.open'|trans }}
|
||||
</button>
|
||||
<button type="button"
|
||||
data-action="click->compose--compose-toolbar#changeLink"
|
||||
class="px-2 py-1 rounded text-sm text-ink-muted hover:bg-hover
|
||||
hover:text-ink transition-colors">
|
||||
{{ 'compose.link.change'|trans }}
|
||||
</button>
|
||||
<button type="button"
|
||||
data-action="click->compose--compose-toolbar#removeLink"
|
||||
class="px-2 py-1 rounded text-sm text-danger hover:bg-hover
|
||||
transition-colors">
|
||||
{{ 'compose.link.remove'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +55,16 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
|
||||
tooLarge: 'compose.attach_too_large'|trans({ '%name%': '%s', '%limit%': '%d' }),
|
||||
imageFailed: 'compose.image_insert_failed'|trans,
|
||||
noSignature: 'compose.signature_none'|trans,
|
||||
confirmPlainText: 'compose.confirm.plain_text'|trans,
|
||||
}|json_encode|e('html_attr') }}"
|
||||
{# The toolbar controller's own vocabulary. Same arrangement and same
|
||||
reason as compose--compose's above: the link popover writes words
|
||||
onto the screen from JavaScript, and a string baked into the
|
||||
controller is a string that stays English in a German window. #}
|
||||
data-compose--compose-toolbar-i18n-value="{{ {
|
||||
linkAdd: 'compose.link.add'|trans,
|
||||
linkEdit: 'compose.link.edit'|trans,
|
||||
linkInvalid: 'compose.link.invalid'|trans,
|
||||
linkUnsafe: 'compose.link.unsafe'|trans,
|
||||
}|json_encode|e('html_attr') }}"
|
||||
{# Every From option's signature, keyed by the same "accountId|address"
|
||||
token the hidden account <select> carries. Handed over in one go so
|
||||
@@ -417,7 +426,67 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
|
||||
</div>
|
||||
|
||||
{# ── Toolbar ──────────────────────────────────────────── #}
|
||||
<div class="px-2 md:px-3 pb-2 md:pb-3 pt-1 shrink-0">
|
||||
{# `relative` so the two popovers below can anchor to THIS block
|
||||
rather than to a button. Both the format bar and the icon row
|
||||
scroll sideways on a phone (.compose-scroll-x), and anything
|
||||
anchored to a control inside one of them opens wherever that
|
||||
control happens to have been scrolled to — which is how the emoji
|
||||
panel came to open past the right edge of the screen. This
|
||||
wrapper spans the window and never scrolls, so a popover pinned
|
||||
to it is on screen by construction at every width. #}
|
||||
<div class="relative px-2 md:px-3 pb-2 md:pb-3 pt-1 shrink-0">
|
||||
|
||||
{# ── Link popover ─────────────────────────────────────
|
||||
Shared with the settings signature editor, which runs the
|
||||
same toolbar controller and had the same `prompt()`. The
|
||||
positioning is passed in because it is the one thing the two
|
||||
hosts disagree about: here it is anchored to the toolbar
|
||||
BLOCK, which spans the window and never scrolls — the link
|
||||
button itself lives in a sideways scroller and would drag
|
||||
the popover off the screen with it. #}
|
||||
{{ include('compose/_link_popover.html.twig', {
|
||||
id: form.vars.id,
|
||||
class: 'absolute bottom-full mb-2 left-2 right-2 md:left-auto md:right-3 md:w-80',
|
||||
}, with_context = false) }}
|
||||
|
||||
{# ── Plain-text warning ───────────────────────────────
|
||||
Was `window.confirm()`. The rule it enforces has not changed
|
||||
— it only appears when there is formatting that flattening
|
||||
would actually destroy, and the switch stays reversible for
|
||||
as long as the draft has not been saved in plain mode (see
|
||||
_setPlainText) — but a native dialog is the one thing in this
|
||||
window the theme never reached, and on a phone it is a
|
||||
system sheet over an app that was mid-sentence. #}
|
||||
<div data-compose--compose-target="plainWarning" hidden
|
||||
role="alertdialog"
|
||||
aria-labelledby="{{ form.vars.id }}_plain_warning_title"
|
||||
aria-describedby="{{ form.vars.id }}_plain_warning_body"
|
||||
class="absolute bottom-full mb-2 left-2 right-2 md:left-auto md:right-3 md:w-80
|
||||
z-40 popover p-3 text-sm">
|
||||
<p id="{{ form.vars.id }}_plain_warning_title"
|
||||
class="text-sm font-semibold text-ink">
|
||||
{{ 'compose.plain_warning.title'|trans }}
|
||||
</p>
|
||||
<p id="{{ form.vars.id }}_plain_warning_body"
|
||||
class="mt-1 text-xs text-ink-muted">
|
||||
{{ 'compose.confirm.plain_text'|trans }}
|
||||
</p>
|
||||
<div class="mt-2.5 flex items-center gap-2">
|
||||
<button type="button"
|
||||
data-compose--compose-target="plainWarningConfirm"
|
||||
data-action="click->compose--compose#confirmPlainText"
|
||||
class="rounded-full bg-accent text-white text-sm font-semibold
|
||||
px-3 py-1.5 hover:bg-white/10 transition-colors">
|
||||
{{ 'compose.plain_warning.continue'|trans }}
|
||||
</button>
|
||||
<button type="button"
|
||||
data-action="click->compose--compose#cancelPlainText"
|
||||
class="rounded-full px-3 py-1.5 text-sm text-ink-muted
|
||||
hover:bg-hover hover:text-ink transition-colors">
|
||||
{{ 'compose.plain_warning.cancel'|trans }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# One line that scrolls sideways below md — wrapping it would
|
||||
eat three rows of a phone screen. The controller hides it
|
||||
@@ -730,8 +799,18 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
|
||||
<input type="file" multiple class="hidden"
|
||||
data-compose--compose-target="fileInput"
|
||||
data-action="change->compose--compose#uploadFiles">
|
||||
{# `data-compose--compose-toolbar-action` is load-bearing
|
||||
here, not decoration: it is what the controller hangs a
|
||||
mousedown preventDefault on, and that is the only reason
|
||||
the editor keeps the user's selection while this button
|
||||
is pressed. Without it the selection collapses before
|
||||
the popover has had a chance to read it, and the text
|
||||
field prefills empty. #}
|
||||
<button type="button" data-compose--compose-toolbar-action
|
||||
data-compose--compose-toolbar-target="linkBtn"
|
||||
data-action="click->compose--compose-toolbar#insertLink"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
title="{{ 'compose.insert_link'|trans }}"
|
||||
aria-label="{{ 'compose.insert_link'|trans }}"
|
||||
class="{{ ic }}"><i class="fa-solid fa-link" aria-hidden="true"></i></button>
|
||||
@@ -873,9 +952,29 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
|
||||
aria-label="{{ 'compose.more_options'|trans }}"
|
||||
class="{{ ic }}"><i class="fa-solid fa-ellipsis-vertical" aria-hidden="true"></i></button>
|
||||
|
||||
{# Below md this menu is pinned to the VIEWPORT rather
|
||||
than to its button, exactly as the emoji panel is and
|
||||
for exactly the same reason — except that here it was
|
||||
not merely mispositioned, it was unreachable. The
|
||||
button sits in `.compose-scroll-x`, whose
|
||||
`overflow-x: auto` computes `overflow-y: auto` as
|
||||
well, so a menu opening UPWARDS out of a 36px strip
|
||||
was clipped to nothing: on a phone the three controls
|
||||
in here (plain text, priority, read receipt) could be
|
||||
tapped open and never seen. A fixed element is not
|
||||
clipped by an overflow ancestor — its containing
|
||||
block is .compose-window, which below md is the
|
||||
screen — which is the whole trick.
|
||||
|
||||
`bottom-20` clears the action bar; `w-auto` releases
|
||||
the `w-64` so left-2/right-2 can set the width.
|
||||
compose-mobile.spec.ts measures the box against the
|
||||
viewport at 393px and at 320px. #}
|
||||
<div data-ui--dropdown-target="menu" hidden
|
||||
class="absolute bottom-full mb-1 left-0 md:right-auto z-20
|
||||
w-64 max-w-[calc(100vw-2rem)] popover py-1">
|
||||
class="absolute bottom-full mb-1 left-0 md:right-auto z-30
|
||||
w-64 max-w-[calc(100vw-2rem)] popover py-1
|
||||
max-md:fixed max-md:inset-auto max-md:left-2 max-md:right-2
|
||||
max-md:bottom-20 max-md:w-auto max-md:max-w-none">
|
||||
|
||||
{# Plain text. A toggle, not a submit: what it
|
||||
changes is which surface the body lives in, and
|
||||
@@ -950,8 +1049,14 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
|
||||
data-action="click->ui--dropdown#toggle"
|
||||
title="{{ 'integration.picker.attach_from_service'|trans }}"
|
||||
class="{{ ic }}"><i class="fa-solid fa-cloud-arrow-down"></i></button>
|
||||
{# Pinned to the viewport below md, same as the more-
|
||||
options menu above and for the same reason: this
|
||||
button is in the sideways scroller too, and a
|
||||
menu opening upwards out of it is clipped away. #}
|
||||
<div data-ui--dropdown-target="menu" hidden
|
||||
class="absolute bottom-full mb-1 right-0 md:left-0 md:right-auto z-20 min-w-48 popover py-1">
|
||||
class="absolute bottom-full mb-1 right-0 md:left-0 md:right-auto z-30 min-w-48 popover py-1
|
||||
max-md:fixed max-md:inset-auto max-md:left-2 max-md:right-2
|
||||
max-md:bottom-20 max-md:min-w-0">
|
||||
{% for integration in pickerIntegrations %}
|
||||
<button type="button"
|
||||
data-controller="ui--modal"
|
||||
|
||||
@@ -28,7 +28,20 @@ Variables:
|
||||
|
||||
<form method="post" action="{{ action }}" data-turbo-stream
|
||||
data-controller="compose--compose-toolbar"
|
||||
class="mt-1">
|
||||
{# The four strings the toolbar controller writes from JavaScript. Not
|
||||
optional decoration: without it the link popover's heading and its two
|
||||
refusals fall back to their English defaults, which is the one thing
|
||||
this app does not do. #}
|
||||
data-compose--compose-toolbar-i18n-value="{{ {
|
||||
linkAdd: 'compose.link.add'|trans,
|
||||
linkEdit: 'compose.link.edit'|trans,
|
||||
linkInvalid: 'compose.link.invalid'|trans,
|
||||
linkUnsafe: 'compose.link.unsafe'|trans,
|
||||
}|json_encode|e('html_attr') }}"
|
||||
{# `relative` for the link popover at the foot of this form. It anchors
|
||||
here rather than inside the editor box, which is `overflow-hidden` and
|
||||
would clip it to nothing. #}
|
||||
class="relative mt-1">
|
||||
<input type="hidden" name="_token" value="{{ token }}">
|
||||
|
||||
{# What is actually submitted. The contenteditable below is mirrored into
|
||||
@@ -90,6 +103,16 @@ Variables:
|
||||
empty:before:pointer-events-none">{{ signature|raw }}</div>
|
||||
</div>
|
||||
|
||||
{# Same popover as the compose window's, same controller behind it — this
|
||||
editor's link button used to raise the same `prompt()` and would have
|
||||
been the one place left doing so. Opens downwards from the toolbar: this
|
||||
field sits in the middle of a settings page with room below it, unlike
|
||||
the compose window which is already at the foot of the screen. #}
|
||||
{{ include('compose/_link_popover.html.twig', {
|
||||
id: 'signature-' ~ id,
|
||||
class: 'absolute left-0 top-10 w-80 max-w-[calc(100%-1rem)]',
|
||||
}, with_context = false) }}
|
||||
|
||||
<div class="flex justify-end mt-1.5">
|
||||
<button type="submit"
|
||||
class="rounded-full bg-accent text-white text-xs font-semibold px-3 py-1.5
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test, expect, type Page } from "./support/test";
|
||||
import { seed } from "./support/config";
|
||||
|
||||
/**
|
||||
* The twelve-or-twenty-four-hour setting, honoured by the JAVASCRIPT clocks.
|
||||
*
|
||||
* Every time Twig prints goes through ClockGlobal and has always followed the
|
||||
* setting. The two menus that print a time from the browser did not, and could
|
||||
* not: `Intl.DateTimeFormat` asked for an hour and not told which kind falls
|
||||
* back to the LOCALE's default. So the send-later presets and the snooze times
|
||||
* said "8:00 AM" to a user who had asked for a 24-hour clock, sitting inches
|
||||
* from server-rendered timestamps that said 08:00.
|
||||
*
|
||||
* The setting is now stamped on <html> once per render and read back by
|
||||
* assets/clock_format.js. This spec is about the two ends of that wire.
|
||||
*/
|
||||
|
||||
const SEND_OPTIONS = '[data-controller~="compose--schedule"]';
|
||||
|
||||
test.beforeAll(() => {
|
||||
seed("seed-mail", "clear-drafts");
|
||||
});
|
||||
|
||||
/** Choose a clock in Settings. `""` is "follow the interface language". */
|
||||
async function chooseClock(page: Page, value: "12" | "24" | ""): Promise<void> {
|
||||
await page.goto("/settings?section=general");
|
||||
await page.locator("#settings-clock").selectOption(value);
|
||||
await expect(page.locator("html")).toHaveAttribute(
|
||||
"data-clock-hour12",
|
||||
value === "12" ? "true" : value === "24" ? "false" : /true|false/,
|
||||
);
|
||||
}
|
||||
|
||||
async function openCompose(page: Page): Promise<void> {
|
||||
await page.goto("/mail/inbox");
|
||||
await page.getByRole("link", { name: "Compose" }).first().click();
|
||||
await expect(
|
||||
page.locator('#compose_dock [data-compose--compose-toolbar-target="editor"]'),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/** The times written onto the send-later presets, as the user reads them. */
|
||||
async function presetTimes(page: Page): Promise<string[]> {
|
||||
await page.locator(`${SEND_OPTIONS}`).getByRole("button", { name: "Send options" }).click();
|
||||
|
||||
const labels = page.locator(`${SEND_OPTIONS} [data-schedule-when]`);
|
||||
await expect(labels.first()).not.toBeEmpty();
|
||||
|
||||
return (await labels.allTextContents()).filter((text) => text.trim() !== "");
|
||||
}
|
||||
|
||||
test.describe("the clock setting reaches the browser", () => {
|
||||
// The wire itself. Without this attribute there is nothing for the
|
||||
// formatters to read, and they go back to guessing from the locale.
|
||||
test("is published on <html> for JavaScript to read", async ({ page }) => {
|
||||
await chooseClock(page, "24");
|
||||
await page.goto("/mail/inbox");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-clock-hour12", "false");
|
||||
|
||||
await chooseClock(page, "12");
|
||||
await page.goto("/mail/inbox");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-clock-hour12", "true");
|
||||
});
|
||||
|
||||
test("the send-later presets are drawn in a 24-hour clock when that is the setting", async ({
|
||||
page,
|
||||
}) => {
|
||||
await chooseClock(page, "24");
|
||||
await openCompose(page);
|
||||
|
||||
for (const time of await presetTimes(page)) {
|
||||
expect(time).toMatch(/\b(08|13):00\b/);
|
||||
expect(time).not.toMatch(/[AaPp]\.?[Mm]/);
|
||||
}
|
||||
});
|
||||
|
||||
test("and in a 12-hour clock when that is", async ({ page }) => {
|
||||
await chooseClock(page, "12");
|
||||
await openCompose(page);
|
||||
|
||||
for (const time of await presetTimes(page)) {
|
||||
expect(time).toMatch(/[AaPp]\.?\s?[Mm]/);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The snooze menu is the other JavaScript clock in the app and had the
|
||||
* same bug. Here because the fix is one shared module: a regression in
|
||||
* prefersHour12() breaks both, and one spec that only covered compose
|
||||
* would report it as fixed.
|
||||
*/
|
||||
test("the snooze menu follows it too", async ({ page }) => {
|
||||
await chooseClock(page, "24");
|
||||
await page.goto("/mail/inbox");
|
||||
|
||||
// Through the list toolbar, which only appears once something is
|
||||
// selected — and which is the surface neither compose nor the thread
|
||||
// row owns, so this spec measures the shared module rather than one
|
||||
// caller's markup.
|
||||
await page.locator("[data-thread-select]").first().check({ force: true });
|
||||
await page.getByRole("button", { name: "Snooze" }).first().click();
|
||||
|
||||
const times = page.locator("[data-snooze-when]:not(:empty)");
|
||||
await expect(times.first()).toBeVisible();
|
||||
|
||||
for (const text of await times.allTextContents()) {
|
||||
expect(text).not.toMatch(/[AaPp]\.?\s?[Mm]/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import { test, expect, type Page } from "./support/test";
|
||||
import { seed } from "./support/config";
|
||||
|
||||
/**
|
||||
* The insert-link popover, and links being reachable inside the editor.
|
||||
*
|
||||
* Both replace things the browser used to do for us and did badly. Inserting a
|
||||
* link was `prompt('Enter URL:')` — one unthemed field, no way to say what the
|
||||
* link should READ as, and a bare "example.com" went in as a relative link to
|
||||
* a path inside the app. And a link once written was unreachable: a click in a
|
||||
* contenteditable places a caret and nothing else, so there was no way to
|
||||
* check where a link went, change it, or take it off again short of sending
|
||||
* the message to yourself.
|
||||
*
|
||||
* What is asserted here is behaviour a server-side test cannot see: where the
|
||||
* selection is when the popover opens, what markup comes out, and what the
|
||||
* editor does with a `javascript:` URL. Whether the result survives the save
|
||||
* is MailBodySanitizer's own tests, next door.
|
||||
*/
|
||||
|
||||
const DOCK = "#compose_dock";
|
||||
const EDITOR = '[data-compose--compose-toolbar-target="editor"]';
|
||||
const HIDDEN = '[data-compose--compose-toolbar-target="hiddenInput"]';
|
||||
const POPOVER = '[data-compose--compose-toolbar-target="linkPopover"]';
|
||||
const URL_FIELD = '[data-compose--compose-toolbar-target="linkUrl"]';
|
||||
const TEXT_FIELD = '[data-compose--compose-toolbar-target="linkText"]';
|
||||
const VIEW = '[data-compose--compose-toolbar-target="linkView"]';
|
||||
const EDIT = '[data-compose--compose-toolbar-target="linkEdit"]';
|
||||
const ERROR = '[data-compose--compose-toolbar-target="linkError"]';
|
||||
|
||||
test.beforeAll(() => {
|
||||
seed("seed-mail", "clear-drafts");
|
||||
});
|
||||
|
||||
async function openCompose(page: Page): Promise<void> {
|
||||
await page.goto("/mail/inbox");
|
||||
await page.getByRole("link", { name: "Compose" }).first().click();
|
||||
await expect(page.locator(`${DOCK} ${EDITOR}`)).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Type `text` into the body and leave its last `length` characters selected.
|
||||
*
|
||||
* Backwards from the caret rather than forwards from Home, because the body
|
||||
* this opens with is not necessarily empty: the account may carry a signature,
|
||||
* in which case Home is the start of whichever line the signature put the
|
||||
* caret on and the selection would be six characters of "Kind regards". The
|
||||
* caret after typing is the one position this can be sure of.
|
||||
*/
|
||||
async function typeAndSelect(page: Page, text: string, length: number): Promise<void> {
|
||||
const editor = page.locator(`${DOCK} ${EDITOR}`);
|
||||
|
||||
await editor.click();
|
||||
await page.keyboard.type(text);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
await page.keyboard.press("Shift+ArrowLeft");
|
||||
}
|
||||
}
|
||||
|
||||
const linkButton = (page: Page) =>
|
||||
page.locator(DOCK).getByRole("button", { name: "Insert link" });
|
||||
|
||||
test.describe("insert link", () => {
|
||||
// The headline of the report: not a browser dialog.
|
||||
test("opens an in-app popover rather than a native prompt", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
|
||||
// A native prompt would hang the click until something answered it,
|
||||
// and this listener is what would have to answer. It must never fire.
|
||||
let dialogs = 0;
|
||||
page.on("dialog", (dialog) => {
|
||||
dialogs += 1;
|
||||
void dialog.dismiss();
|
||||
});
|
||||
|
||||
await page.locator(`${DOCK} ${EDITOR}`).click();
|
||||
await linkButton(page).click();
|
||||
|
||||
await expect(page.locator(`${DOCK} ${POPOVER}`)).toBeVisible();
|
||||
await expect(page.locator(`${DOCK} ${URL_FIELD}`)).toBeVisible();
|
||||
await expect(page.locator(`${DOCK} ${TEXT_FIELD}`)).toBeVisible();
|
||||
expect(dialogs).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* The selection survives the popover opening. It is the reason the toolbar
|
||||
* keeps a saved range at all, and the reason the buttons cancel their own
|
||||
* mousedown: focusing the URL field blurs the editor, and a blur that was
|
||||
* allowed to re-read the selection would record the collapsed one.
|
||||
*/
|
||||
test("prefills the text field from the selection", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
await typeAndSelect(page, "a link to plMail", 6);
|
||||
|
||||
await linkButton(page).click();
|
||||
|
||||
await expect(page.locator(`${DOCK} ${TEXT_FIELD}`)).toHaveValue("plMail");
|
||||
});
|
||||
|
||||
test("wraps the selection, and normalises a bare host to https", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
await typeAndSelect(page, "a link to plMail", 6);
|
||||
|
||||
await linkButton(page).click();
|
||||
await page.locator(`${DOCK} ${URL_FIELD}`).fill("example.com");
|
||||
await page.locator(`${DOCK}`).getByRole("button", { name: "Apply" }).click();
|
||||
|
||||
const anchor = page.locator(`${DOCK} ${EDITOR} a`);
|
||||
|
||||
await expect(anchor).toHaveCount(1);
|
||||
// https://, not a relative link to /mail/example.com — which is what
|
||||
// the old prompt produced and what the sanitiser would have kept.
|
||||
await expect(anchor).toHaveAttribute("href", "https://example.com");
|
||||
await expect(anchor).toHaveText("plMail");
|
||||
// A new tab that cannot reach back into the app that opened it.
|
||||
await expect(anchor).toHaveAttribute("rel", "noopener noreferrer");
|
||||
|
||||
// And it is in the hidden input, which is what actually gets posted.
|
||||
expect(await page.locator(`${DOCK} ${HIDDEN}`).inputValue()).toContain(
|
||||
'href="https://example.com"',
|
||||
);
|
||||
});
|
||||
|
||||
test("Enter applies without submitting the message", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
await page.locator(`${DOCK} ${EDITOR}`).click();
|
||||
|
||||
await linkButton(page).click();
|
||||
await page.locator(`${DOCK} ${URL_FIELD}`).fill("https://example.org/a");
|
||||
await page.locator(`${DOCK} ${TEXT_FIELD}`).fill("a link");
|
||||
await page.locator(`${DOCK} ${URL_FIELD}`).press("Enter");
|
||||
|
||||
// The popover lives inside the compose <form>: an un-cancelled Enter
|
||||
// in a field would send the half-written message.
|
||||
await expect(page.locator(`${DOCK} ${EDITOR}`)).toBeVisible();
|
||||
await expect(page.locator(`${DOCK} ${EDITOR} a`)).toHaveText("a link");
|
||||
});
|
||||
|
||||
test("Escape closes it and gives the selection back", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
await typeAndSelect(page, "a link to plMail", 6);
|
||||
|
||||
await linkButton(page).click();
|
||||
await page.locator(`${DOCK} ${URL_FIELD}`).press("Escape");
|
||||
|
||||
await expect(page.locator(`${DOCK} ${POPOVER}`)).toBeHidden();
|
||||
await expect(page.locator(`${DOCK} ${EDITOR} a`)).toHaveCount(0);
|
||||
|
||||
// The six characters are still selected, so the very next formatting
|
||||
// action applies to them — which is what "gives it back" has to mean.
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString())).toBe("plMail");
|
||||
});
|
||||
|
||||
/**
|
||||
* MailBodySanitizer refuses anything outside http/https/mailto/tel on the
|
||||
* way out, and would drop this silently. A refusal the writer can see is
|
||||
* the difference between a rule and a disappearance.
|
||||
*/
|
||||
test("refuses a javascript: URL, in the editor, with a reason", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
await page.locator(`${DOCK} ${EDITOR}`).click();
|
||||
|
||||
await linkButton(page).click();
|
||||
await page.locator(`${DOCK} ${URL_FIELD}`).fill("javascript:alert(1)");
|
||||
await page.locator(DOCK).getByRole("button", { name: "Apply" }).click();
|
||||
|
||||
await expect(page.locator(`${DOCK} ${ERROR}`)).toBeVisible();
|
||||
await expect(page.locator(`${DOCK} ${POPOVER}`)).toBeVisible();
|
||||
await expect(page.locator(`${DOCK} ${EDITOR} a`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* The allow-list is a list, not a `javascript:` special case — it is
|
||||
* MailBodySanitizer::allowLinkSchemes() spelled again, so `data:` is
|
||||
* refused for the same reason and by the same rule.
|
||||
*
|
||||
* (A `javascript:` URL broken up by a newline, the classic way past a
|
||||
* naive scheme test, cannot be typed into this field at all: a single-line
|
||||
* <input> strips CR and LF from its own value before anything reads it.
|
||||
* The controller strips control characters anyway, for the hrefs it reads
|
||||
* back off anchors that came from elsewhere.)
|
||||
*/
|
||||
test("refuses a data: URL by the same rule", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
await page.locator(`${DOCK} ${EDITOR}`).click();
|
||||
|
||||
await linkButton(page).click();
|
||||
await page.locator(`${DOCK} ${URL_FIELD}`).fill("data:text/html,<h1>hi</h1>");
|
||||
await page.locator(DOCK).getByRole("button", { name: "Apply" }).click();
|
||||
|
||||
await expect(page.locator(`${DOCK} ${ERROR}`)).toBeVisible();
|
||||
await expect(page.locator(`${DOCK} ${EDITOR} a`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("turns a bare address into a mailto:", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
await page.locator(`${DOCK} ${EDITOR}`).click();
|
||||
|
||||
await linkButton(page).click();
|
||||
await page.locator(`${DOCK} ${URL_FIELD}`).fill("someone@example.com");
|
||||
await page.locator(DOCK).getByRole("button", { name: "Apply" }).click();
|
||||
|
||||
await expect(page.locator(`${DOCK} ${EDITOR} a`)).toHaveAttribute(
|
||||
"href",
|
||||
"mailto:someone@example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The signature editor runs the SAME toolbar controller, and had the same link
|
||||
* button raising the same `prompt()`. It is the one other host of the shared
|
||||
* partial, so it is the one place a change to the popover can break unseen.
|
||||
*/
|
||||
test.describe("the signature editor's link popover", () => {
|
||||
test("is the same popover, and still not a browser prompt", async ({ page }) => {
|
||||
let dialogs = 0;
|
||||
page.on("dialog", (dialog) => {
|
||||
dialogs += 1;
|
||||
void dialog.dismiss();
|
||||
});
|
||||
|
||||
await page.goto("/settings?section=aliases");
|
||||
|
||||
const form = page.locator('form[action$="/compose-defaults/signature"]').first();
|
||||
const editor = form.locator('[data-compose--compose-toolbar-target="editor"]');
|
||||
|
||||
await editor.click();
|
||||
await page.keyboard.type("regards, me");
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await page.keyboard.press("Shift+ArrowLeft");
|
||||
}
|
||||
|
||||
await form.getByRole("button", { name: "Insert link" }).click();
|
||||
|
||||
await expect(form.locator(POPOVER)).toBeVisible();
|
||||
await expect(form.locator(TEXT_FIELD)).toHaveValue("me");
|
||||
expect(dialogs).toBe(0);
|
||||
|
||||
await form.locator(URL_FIELD).fill("example.net");
|
||||
await form.getByRole("button", { name: "Apply" }).click();
|
||||
|
||||
await expect(editor.locator("a")).toHaveAttribute("href", "https://example.net");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("a link in the editor", () => {
|
||||
/** Write one link and leave the caret inside it. */
|
||||
async function withLink(page: Page): Promise<void> {
|
||||
await openCompose(page);
|
||||
await typeAndSelect(page, "a link to plMail", 6);
|
||||
|
||||
await linkButton(page).click();
|
||||
await page.locator(`${DOCK} ${URL_FIELD}`).fill("https://example.com/one");
|
||||
await page.locator(DOCK).getByRole("button", { name: "Apply" }).click();
|
||||
await expect(page.locator(`${DOCK} ${EDITOR} a`)).toHaveCount(1);
|
||||
}
|
||||
|
||||
test("clicking it shows where it goes", async ({ page }) => {
|
||||
await withLink(page);
|
||||
|
||||
await page.locator(`${DOCK} ${EDITOR} a`).click();
|
||||
|
||||
await expect(page.locator(`${DOCK} ${VIEW}`)).toBeVisible();
|
||||
await expect(
|
||||
page.locator(`${DOCK} [data-compose--compose-toolbar-target="linkHref"]`),
|
||||
).toHaveText("https://example.com/one");
|
||||
});
|
||||
|
||||
test("Change reopens the editor prefilled from it", async ({ page }) => {
|
||||
await withLink(page);
|
||||
|
||||
await page.locator(`${DOCK} ${EDITOR} a`).click();
|
||||
await page.locator(DOCK).getByRole("button", { name: "Change" }).click();
|
||||
|
||||
await expect(page.locator(`${DOCK} ${EDIT}`)).toBeVisible();
|
||||
await expect(page.locator(`${DOCK} ${URL_FIELD}`)).toHaveValue(
|
||||
"https://example.com/one",
|
||||
);
|
||||
await expect(page.locator(`${DOCK} ${TEXT_FIELD}`)).toHaveValue("plMail");
|
||||
});
|
||||
|
||||
// In place, not nested. execCommand('createLink') over a selection already
|
||||
// inside an <a> produces an <a> inside an <a>, which no client renders the
|
||||
// way the writer meant.
|
||||
test("editing changes the link rather than nesting a second one", async ({ page }) => {
|
||||
await withLink(page);
|
||||
|
||||
await page.locator(`${DOCK} ${EDITOR} a`).click();
|
||||
await page.locator(DOCK).getByRole("button", { name: "Change" }).click();
|
||||
await page.locator(`${DOCK} ${URL_FIELD}`).fill("https://example.com/two");
|
||||
await page.locator(DOCK).getByRole("button", { name: "Apply" }).click();
|
||||
|
||||
await expect(page.locator(`${DOCK} ${EDITOR} a`)).toHaveCount(1);
|
||||
await expect(page.locator(`${DOCK} ${EDITOR} a`)).toHaveAttribute(
|
||||
"href",
|
||||
"https://example.com/two",
|
||||
);
|
||||
await expect(page.locator(`${DOCK} ${EDITOR} a a`)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Remove unwraps it and keeps the words", async ({ page }) => {
|
||||
await withLink(page);
|
||||
|
||||
await page.locator(`${DOCK} ${EDITOR} a`).click();
|
||||
await page.locator(DOCK).getByRole("button", { name: "Remove link" }).click();
|
||||
|
||||
await expect(page.locator(`${DOCK} ${EDITOR} a`)).toHaveCount(0);
|
||||
await expect(page.locator(`${DOCK} ${EDITOR}`)).toContainText("a link to plMail");
|
||||
expect(await page.locator(`${DOCK} ${HIDDEN}`).inputValue()).not.toContain("<a");
|
||||
});
|
||||
|
||||
// The popover must not fight the caret: it is an addition to the click,
|
||||
// not a replacement for it, and it goes away when the caret does.
|
||||
test("dismisses itself once the caret leaves the link", async ({ page }) => {
|
||||
await withLink(page);
|
||||
|
||||
await page.locator(`${DOCK} ${EDITOR} a`).click();
|
||||
await expect(page.locator(`${DOCK} ${VIEW}`)).toBeVisible();
|
||||
|
||||
// Select the whole body: the range now starts outside the anchor,
|
||||
// which is the "the caret is not in this link any more" the controller
|
||||
// watches selectionchange for.
|
||||
await page.keyboard.press("ControlOrMeta+a");
|
||||
|
||||
await expect(page.locator(`${DOCK} ${POPOVER}`)).toBeHidden();
|
||||
});
|
||||
|
||||
test("Escape dismisses it", async ({ page }) => {
|
||||
await withLink(page);
|
||||
|
||||
await page.locator(`${DOCK} ${EDITOR} a`).click();
|
||||
await expect(page.locator(`${DOCK} ${VIEW}`)).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.locator(`${DOCK} ${POPOVER}`)).toBeHidden();
|
||||
});
|
||||
|
||||
// Typing still works, which is the thing a popover over an editor is most
|
||||
// likely to break.
|
||||
test("leaves ordinary typing alone", async ({ page }) => {
|
||||
await withLink(page);
|
||||
|
||||
await page.locator(`${DOCK} ${EDITOR} a`).click();
|
||||
await page.keyboard.press("End");
|
||||
await page.keyboard.type(" and more");
|
||||
|
||||
await expect(page.locator(`${DOCK} ${EDITOR}`)).toContainText("and more");
|
||||
await expect(page.locator(`${DOCK} ${POPOVER}`)).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -288,6 +288,107 @@ test.describe("mobile compose window", () => {
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height + 0.5);
|
||||
});
|
||||
|
||||
/**
|
||||
* EVERY popover the toolbar can open, measured against the viewport at two
|
||||
* phone widths.
|
||||
*
|
||||
* The last round shipped an emoji panel that was off screen on a phone
|
||||
* while this file passed, because the spec asserted the panel EXISTED.
|
||||
* Existence is not usability: a panel whose left edge is at -140 is
|
||||
* visible to `toBeVisible()`, present to `toHaveCount(1)`, and useless to
|
||||
* the person holding the phone. So these assert geometry — the box is
|
||||
* inside the viewport on both axes — and they do it at 320px as well as
|
||||
* 393px, because the narrow end is where a fixed `w-64` stops fitting.
|
||||
*
|
||||
* The reason any of it works: none of these are anchored to their button.
|
||||
* Every control that opens one lives in `.compose-scroll-x`, whose
|
||||
* `overflow-x: auto` computes `overflow-y: auto` as well — so a menu
|
||||
* anchored to a button in there is both mispositioned (the strip scrolls)
|
||||
* and clipped (it opens upwards out of a 36px box). The more-options menu
|
||||
* was clipped away entirely until this spec went looking.
|
||||
*/
|
||||
test.describe("popovers stay on screen", () => {
|
||||
const cases: { name: string; open: (page: Page) => Promise<void>; panel: string }[] = [
|
||||
{
|
||||
name: "the emoji picker",
|
||||
panel: `${composeWindow} .emoji-panel`,
|
||||
open: async (page) => {
|
||||
await page.locator(composeWindow)
|
||||
.getByRole("button", { name: "Insert emoji" }).click();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "the link popover",
|
||||
panel: '[data-compose--compose-toolbar-target="linkPopover"]',
|
||||
open: async (page) => {
|
||||
await page.locator('[data-compose--compose-toolbar-target="editor"]').click();
|
||||
await page.locator(composeWindow)
|
||||
.getByRole("button", { name: "Insert link" }).click();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "the more-options menu",
|
||||
// By what is inside it, not by position: the window holds
|
||||
// several dropdown menus and the send-options one is hidden at
|
||||
// this width, so `.first()` would silently measure that.
|
||||
panel: '[data-ui--dropdown-target="menu"]:has([data-compose--compose-target="plainToggle"])',
|
||||
open: async (page) => {
|
||||
await page.locator(composeWindow)
|
||||
.getByRole("button", { name: "More options" }).click();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "the plain-text warning",
|
||||
panel: '[data-compose--compose-target="plainWarning"]',
|
||||
open: async (page) => {
|
||||
const editor = page.locator('[data-compose--compose-toolbar-target="editor"]');
|
||||
|
||||
await editor.click();
|
||||
await page.keyboard.type("emphasis");
|
||||
await page.keyboard.press("ControlOrMeta+a");
|
||||
await page.locator(composeWindow).getByRole("button", { name: "Aa" }).click();
|
||||
await page.locator(composeWindow).getByRole("button", { name: "Bold" }).click();
|
||||
|
||||
await page.locator(composeWindow)
|
||||
.getByRole("button", { name: "More options" }).click();
|
||||
await page.getByRole("button", { name: /Plain text mode/i }).click();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const width of [393, 320]) {
|
||||
for (const { name, open, panel } of cases) {
|
||||
test(`${name} at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 851 });
|
||||
await openCompose(page);
|
||||
|
||||
// Push the icon row to its far end first. A panel anchored
|
||||
// to a button in there would now open past the right edge,
|
||||
// which is exactly the bug being guarded against.
|
||||
const strip = page.locator(`${composeWindow} .compose-scroll-x`).last();
|
||||
await strip.hover();
|
||||
await page.mouse.wheel(400, 0);
|
||||
|
||||
await open(page);
|
||||
|
||||
const target = page.locator(panel).first();
|
||||
await expect(target).toBeVisible();
|
||||
|
||||
const box = (await target.boundingBox())!;
|
||||
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(width + 0.5);
|
||||
expect(box.y).toBeGreaterThanOrEqual(0);
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(851 + 0.5);
|
||||
// And wide enough to be worth opening — a 0px-tall clipped
|
||||
// box also satisfies "inside the viewport".
|
||||
expect(box.width).toBeGreaterThan(120);
|
||||
expect(box.height).toBeGreaterThan(40);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("returns to the mailbox from the back arrow", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const DOCK = "#compose_dock";
|
||||
const EDITOR = '[data-compose--compose-toolbar-target="editor"]';
|
||||
const HIDDEN = '[data-compose--compose-toolbar-target="hiddenInput"]';
|
||||
const PLAIN = '[data-compose--compose-target="plainBody"]';
|
||||
const WARNING = '[data-compose--compose-target="plainWarning"]';
|
||||
const SIGNATURE = "[data-pl-signature]";
|
||||
|
||||
test.beforeAll(() => {
|
||||
@@ -120,13 +121,15 @@ test.describe("compose more options", () => {
|
||||
await editor.click();
|
||||
await page.keyboard.type("Words with no markup.");
|
||||
|
||||
// The confirm is the warning about losing formatting — accepted here,
|
||||
// because losing it is exactly what the test is asking for.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
|
||||
await page.locator(`${DOCK} .fa-ellipsis-vertical`).locator("..").click();
|
||||
await page.getByRole("button", { name: /Plain text mode/i }).click();
|
||||
|
||||
// The warning about losing formatting — accepted here, because losing
|
||||
// it is exactly what this test is asking for. A popover now rather
|
||||
// than a `window.confirm()`, which is why there is no dialog listener.
|
||||
await expect(page.locator(`${DOCK} ${WARNING}`)).toBeVisible();
|
||||
await page.locator(DOCK).getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await expect(plain).toBeVisible();
|
||||
await expect(editor).toBeHidden();
|
||||
await expect(plain).toHaveValue(/Words with no markup\./);
|
||||
@@ -157,10 +160,14 @@ test.describe("compose more options", () => {
|
||||
|
||||
const menu = page.locator(`${DOCK} .fa-ellipsis-vertical`).locator("..");
|
||||
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await menu.click();
|
||||
await page.getByRole("button", { name: /Plain text mode/i }).click();
|
||||
|
||||
// There IS formatting to lose here, so the warning stands in the way —
|
||||
// and accepting it is what the rest of this test needs.
|
||||
await expect(page.locator(`${DOCK} ${WARNING}`)).toBeVisible();
|
||||
await page.locator(DOCK).getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
await menu.click();
|
||||
await page.getByRole("button", { name: /Plain text mode/i }).click();
|
||||
|
||||
@@ -170,6 +177,65 @@ test.describe("compose more options", () => {
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The warning is an in-app popover now, not `window.confirm()`. Which
|
||||
* means it has to be refusable — the browser's dialog was, and a warning
|
||||
* that only has a Continue is not a warning.
|
||||
*/
|
||||
test("declining the plain-text warning leaves the formatting alone", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openCompose(page);
|
||||
|
||||
const editor = page.locator(`${DOCK} ${EDITOR}`);
|
||||
|
||||
await editor.click();
|
||||
await page.keyboard.type("emphasis");
|
||||
await page.keyboard.press("ControlOrMeta+a");
|
||||
await page
|
||||
.locator(`${DOCK} [data-compose--compose-toolbar-target="boldBtn"]`)
|
||||
.click();
|
||||
|
||||
await page.locator(`${DOCK} .fa-ellipsis-vertical`).locator("..").click();
|
||||
await page.getByRole("button", { name: /Plain text mode/i }).click();
|
||||
|
||||
const warning = page.locator(`${DOCK} ${WARNING}`);
|
||||
await expect(warning).toBeVisible();
|
||||
|
||||
// No browser dialog was ever raised: nothing here would have answered
|
||||
// one, so a lingering confirm() would hang this test rather than pass it.
|
||||
await page.locator(DOCK).getByRole("button", { name: "Keep formatting" }).click();
|
||||
|
||||
await expect(warning).toBeHidden();
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(page.locator(`${DOCK} ${PLAIN}`)).toBeHidden();
|
||||
expect(await page.locator(`${DOCK} ${HIDDEN}`).inputValue()).toMatch(
|
||||
/<(b|strong)>/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("Escape declines the plain-text warning too", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
|
||||
const editor = page.locator(`${DOCK} ${EDITOR}`);
|
||||
|
||||
await editor.click();
|
||||
await page.keyboard.type("emphasis");
|
||||
await page.keyboard.press("ControlOrMeta+a");
|
||||
await page
|
||||
.locator(`${DOCK} [data-compose--compose-toolbar-target="boldBtn"]`)
|
||||
.click();
|
||||
|
||||
await page.locator(`${DOCK} .fa-ellipsis-vertical`).locator("..").click();
|
||||
await page.getByRole("button", { name: /Plain text mode/i }).click();
|
||||
await expect(page.locator(`${DOCK} ${WARNING}`)).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.locator(`${DOCK} ${WARNING}`)).toBeHidden();
|
||||
await expect(page.locator(`${DOCK} ${PLAIN}`)).toBeHidden();
|
||||
});
|
||||
|
||||
test("priority and read receipt are real form controls", async ({ page }) => {
|
||||
await openCompose(page);
|
||||
|
||||
|
||||
@@ -1181,12 +1181,32 @@ compose:
|
||||
insert_link: Link einfügen
|
||||
invalid_address: "„%address%“ ist keine gültige E-Mail-Adresse"
|
||||
italic: Kursiv
|
||||
link:
|
||||
add: Link einfügen
|
||||
apply: Übernehmen
|
||||
cancel: Abbrechen
|
||||
change: Ändern
|
||||
dialog: Link
|
||||
edit: Link bearbeiten
|
||||
heading: Link
|
||||
invalid: Bitte eine Webadresse eingeben.
|
||||
open: Öffnen
|
||||
remove: Link entfernen
|
||||
text_label: Anzuzeigender Text
|
||||
text_placeholder: Die Wörter, die den Link tragen
|
||||
unsafe: Nur Web-, Mail- und Telefonadressen lassen sich verlinken.
|
||||
url_label: Webadresse
|
||||
url_placeholder: "beispiel.de"
|
||||
minimize: Minimieren
|
||||
more_options: Weitere Optionen
|
||||
new_message: Neue Nachricht
|
||||
no_recipients: Keine Empfänger
|
||||
outdent: Einzug verkleinern
|
||||
plain_text: Nur-Text-Modus
|
||||
plain_warning:
|
||||
cancel: Formatierung behalten
|
||||
continue: Fortfahren
|
||||
title: Formatierung entfernen?
|
||||
pop_out: In eigenem Fenster öffnen
|
||||
read_receipt: Lesebestätigung anfordern
|
||||
recipient_required: Füge mindestens einen Empfänger hinzu, bevor du sendest.
|
||||
|
||||
@@ -1163,12 +1163,37 @@ compose:
|
||||
insert_link: Insert link
|
||||
invalid_address: "“%address%” is not a valid email address"
|
||||
italic: Italic
|
||||
# The insert-link popover, both of its faces. Nothing here is spelled in
|
||||
# JavaScript: the controller reads the four strings it writes at runtime
|
||||
# out of a data-…-i18n-value object Twig fills from these keys.
|
||||
link:
|
||||
add: Insert link
|
||||
apply: Apply
|
||||
cancel: Cancel
|
||||
change: Change
|
||||
dialog: Link
|
||||
edit: Edit link
|
||||
heading: Link
|
||||
invalid: Enter a web address.
|
||||
open: Open
|
||||
remove: Remove link
|
||||
text_label: Text to display
|
||||
text_placeholder: The words that carry the link
|
||||
unsafe: Only web, mail and telephone addresses can be linked.
|
||||
url_label: Web address
|
||||
url_placeholder: "example.com"
|
||||
minimize: Minimize
|
||||
more_options: More options
|
||||
new_message: New Message
|
||||
no_recipients: No recipients
|
||||
outdent: Outdent
|
||||
plain_text: Plain text mode
|
||||
# The warning that used to be a browser confirm(). The question itself is
|
||||
# still compose.confirm.plain_text — only the frame around it is new.
|
||||
plain_warning:
|
||||
cancel: Keep formatting
|
||||
continue: Continue
|
||||
title: Remove formatting?
|
||||
pop_out: Open in compose window
|
||||
read_receipt: Request read receipt
|
||||
recipient_required: Add at least one recipient before sending.
|
||||
|
||||
@@ -899,12 +899,32 @@ compose:
|
||||
insert_link: Splice in a link
|
||||
invalid_address: "“%address%” be no proper address"
|
||||
italic: Aslant
|
||||
link:
|
||||
add: Splice in a link
|
||||
apply: Make it so
|
||||
cancel: Belay that
|
||||
change: Recut it
|
||||
dialog: Link
|
||||
edit: Recut this link
|
||||
heading: Link
|
||||
invalid: Name a port on the web.
|
||||
open: Set sail for it
|
||||
remove: Cut the line
|
||||
text_label: Words to show
|
||||
text_placeholder: The words that carry the line
|
||||
unsafe: "Only web, post an' telephone berths may be linked."
|
||||
url_label: Web berth
|
||||
url_placeholder: "example.com"
|
||||
minimize: Batten down
|
||||
more_options: More riggin'
|
||||
new_message: A Fresh Scroll
|
||||
no_recipients: Nobody to send it to
|
||||
outdent: Shove it outboard
|
||||
plain_text: "Plain hand, no flourishes"
|
||||
plain_warning:
|
||||
cancel: "Keep the flourishes"
|
||||
continue: "Aye, carry on"
|
||||
title: "Strip the flourishes?"
|
||||
pop_out: Open in its own cabin
|
||||
read_receipt: "Beg word it were read"
|
||||
recipient_required: Name at least one soul afore ye cast off.
|
||||
|
||||
Reference in new issue
Block a user