diff --git a/components/email/__tests__/composer-offline-send.test.tsx b/components/email/__tests__/composer-offline-send.test.tsx new file mode 100644 index 000000000..5c7b5f425 --- /dev/null +++ b/components/email/__tests__/composer-offline-send.test.tsx @@ -0,0 +1,289 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// Writing offline. The footer says "Offline" next to Send. With "Keep mail on +// this device" on, Send puts the message in the outbox as before. Without it +// nothing can keep the message safely for later: Send says so, keeps the +// text, and points at the setting. It used to fail with "Failed to send". + +const offline = vi.hoisted(() => ({ active: false, allowed: true })); +vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({ + ...(await importOriginal()), + offlineActive: () => offline.active, + offlineAllowed: () => offline.allowed, +})); + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: ({ collapseToolbarOnMobile }: { collapseToolbarOnMobile?: boolean }) => + React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-collapsed': String(!!collapseToolbarOnMobile) }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {}, warning: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + identityHooks: { + onSignatureRender: { size: 0, transform: async (value: unknown) => value }, + onIdentitySelect: { size: 0, emit: async () => {} }, + }, + templateHooks: { + onTemplateRender: { size: 0, transform: async (value: unknown) => value }, + onTemplateApply: { size: 0, emit: async () => {} }, + }, + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, + onComposeSend: { takeOver: async () => 'default' }, + onTransformOutgoingEmail: { transform: async (email: unknown) => email }, + }, + contactHooks: { + search: { call: async () => [] }, + onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +const DATA = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Lunch', + body: '

Hello there

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +let online = true; +beforeEach(() => { + online = true; + offline.active = false; + offline.allowed = true; + Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online }); +}); +afterEach(() => { + online = true; +}); + +function goOffline() { + online = false; + act(() => { window.dispatchEvent(new Event('offline')); }); +} +function goOnline() { + online = true; + act(() => { window.dispatchEvent(new Event('online')); }); +} + +const footer = () => document.querySelector('[data-composer-footer]')!; + +describe('the composer while offline', () => { + it('says "Offline" next to Send, and stops once the network is back', () => { + render(); + expect(screen.queryByTestId('composer-offline')).toBeNull(); + goOffline(); + const label = screen.getByTestId('composer-offline'); + expect(footer()).toContainElement(label); + expect(label.textContent).toContain('offline'); + expect(label.textContent).toContain('offline_hint'); + goOnline(); + expect(screen.queryByTestId('composer-offline')).toBeNull(); + }); + + it('without offline mode, Send keeps the message and says why, with the way to the setting', async () => { + const onSend = vi.fn(); + render(); + goOffline(); + fireEvent.click(screen.getByTestId('composer-send')); + const notice = await screen.findByTestId('composer-offline-notice'); + expect(notice.textContent).toContain('offline_send_unsaved'); + const link = screen.getByRole('link', { name: 'settings.keep_label' }); + expect(link.getAttribute('href')).toMatch(/\/settings\/backup\/offline$/); + expect(link).toHaveAttribute('target', '_blank'); + expect(onSend).not.toHaveBeenCalled(); + expect(screen.getByTestId('email-composer')).toBeInTheDocument(); + + goOnline(); + expect(screen.queryByTestId('composer-offline-notice')).toBeNull(); + }); + + it('says the message is in Drafts only when the server has it', async () => { + render(); + goOffline(); + fireEvent.click(screen.getByTestId('composer-send')); + expect((await screen.findByTestId('composer-offline-notice')).textContent).toContain('offline_send_saved'); + }); + + it('Ctrl+Enter takes the same way', async () => { + const onSend = vi.fn(); + render(); + goOffline(); + fireEvent.keyDown(screen.getByTestId('composer-send'), { key: 'Enter', ctrlKey: true }); + await screen.findByTestId('composer-offline-notice'); + expect(onSend).not.toHaveBeenCalled(); + }); + + it('leaves out the link where this device may not keep mail', async () => { + offline.allowed = false; + render(); + goOffline(); + fireEvent.click(screen.getByTestId('composer-send')); + await screen.findByTestId('composer-offline-notice'); + expect(screen.queryByRole('link', { name: 'settings.keep_label' })).toBeNull(); + }); + + it('with offline mode on, Send goes ahead: the outbox keeps it until the network is back', async () => { + offline.active = true; + const onSend = vi.fn(); + render(); + goOffline(); + expect(screen.getByTestId('composer-offline').textContent).toContain('offline_hint_outbox'); + fireEvent.click(screen.getByTestId('composer-send')); + await waitFor(() => expect(onSend).toHaveBeenCalled()); + expect(screen.queryByTestId('composer-offline-notice')).toBeNull(); + }); +}); diff --git a/components/email/__tests__/composer-own-window.test.tsx b/components/email/__tests__/composer-own-window.test.tsx index 199eee835..ab200fb56 100644 --- a/components/email/__tests__/composer-own-window.test.tsx +++ b/components/email/__tests__/composer-own-window.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { NextIntlClientProvider } from 'next-intl'; import { EmailComposer, type ComposerDraftData, type ComposerPopOut } from '../email-composer'; @@ -364,3 +364,22 @@ describe('a composer that is all its window shows', () => { expect(creations).toHaveLength(saved); }); }); + +describe('back online', () => { + it('saves what could not be saved while offline, so "saved in Drafts once you are back online" holds', async () => { + let onLine = true; + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine); + rejectSave = true; + mount({}); + onLine = false; + act(() => { window.dispatchEvent(new Event('offline')); }); + fireEvent.change(subjectField(), { target: { value: 'Written on a train' } }); + await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 }); + + rejectSave = false; + onLine = true; + act(() => { window.dispatchEvent(new Event('online')); }); + await waitFor(() => expect(creations).toHaveLength(2)); + expect(creations[1].subject).toBe('Written on a train'); + }); +}); diff --git a/components/email/__tests__/composer-windows-layout.test.tsx b/components/email/__tests__/composer-windows-layout.test.tsx new file mode 100644 index 000000000..a5a5dd5a4 --- /dev/null +++ b/components/email/__tests__/composer-windows-layout.test.tsx @@ -0,0 +1,275 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; + +// One layout in every composer: Send at the bottom end, Discard as a bin left +// of it, the formatting toolbar showing. A pop-out or quick compose window is +// narrower than a tablet, and used to get the phone's layout: Send in the +// header, Discard where Send was, the toolbar behind a button. + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: ({ collapseToolbarOnMobile }: { collapseToolbarOnMobile?: boolean }) => + React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-collapsed': String(!!collapseToolbarOnMobile) }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: () => {}, success: () => {}, warning: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + identityHooks: { + onSignatureRender: { size: 0, transform: async (value: unknown) => value }, + onIdentitySelect: { size: 0, emit: async () => {} }, + }, + templateHooks: { + onTemplateRender: { size: 0, transform: async (value: unknown) => value }, + onTemplateApply: { size: 0, emit: async () => {} }, + }, + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, + onComposeSend: { takeOver: async () => 'default' }, + onTransformOutgoingEmail: { transform: async (email: unknown) => email }, + }, + contactHooks: { + search: { call: async () => [] }, + onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +const DATA = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Lunch', + body: '

Hello there

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +const footer = () => document.querySelector('[data-composer-footer]')!; +const header = () => document.querySelector('[data-composer-header]')!; + +describe('a composer in a window of its own', () => { + it('has one Send, at the bottom end, with Discard as a bin right before it', () => { + render(); + const sends = screen.getAllByTestId('composer-send'); + expect(sends).toHaveLength(1); + expect(footer()).toContainElement(sends[0]); + expect(sends[0].className).not.toMatch(/(^|\s)hidden(\s|$)/); + expect(within(header()).queryByTestId('composer-send')).toBeNull(); + + const discard = screen.getByTestId('composer-discard'); + expect(discard.querySelector('svg')).not.toBeNull(); + // Right before Send, in the same group. + expect(discard.nextElementSibling).toBe(sends[0]); + }); + + it('shows the formatting toolbar, with no button to fold it away', () => { + render(); + expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-collapsed', 'false'); + expect(screen.queryByRole('button', { name: 'formatting' })).toBeNull(); + }); + + it('keeps the close button where the main window has it', () => { + render(); + expect(screen.getByTestId('composer-close').className).not.toContain('order-first'); + }); +}); + +describe('full screen', () => { + it('puts the composer over the whole window, and the same button puts it back', () => { + render(); + const root = screen.getByTestId('email-composer'); + expect(root).not.toHaveAttribute('data-maximized'); + + fireEvent.click(screen.getByRole('button', { name: 'maximize' })); + expect(root).toHaveAttribute('data-maximized'); + expect(root.className).toMatch(/(^|\s)fixed(\s|$)/); + expect(root.className).toContain('inset-0'); + + fireEvent.click(screen.getByRole('button', { name: 'restore' })); + expect(root).not.toHaveAttribute('data-maximized'); + expect(root.className).not.toMatch(/(^|\s)fixed(\s|$)/); + }); + + it('goes back with Escape, without closing the composer', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'maximize' })); + const root = screen.getByTestId('email-composer'); + + fireEvent.keyDown(screen.getByRole('button', { name: 'restore' }), { key: 'Escape' }); + + expect(root).not.toHaveAttribute('data-maximized'); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + }); + + it('is not offered in a window of its own, which is all composer already', () => { + render(); + expect(screen.queryByRole('button', { name: 'maximize' })).toBeNull(); + }); +}); + +describe('a composer in the main window', () => { + it('keeps the phone layout for a phone: Send in the header there, toolbar folded', () => { + render(); + expect(within(header()).getByTestId('composer-send')).toBeInTheDocument(); + expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-collapsed', 'true'); + expect(screen.getByRole('button', { name: 'formatting' })).toBeInTheDocument(); + }); + + it('shows Discard as a bin too, with its name for screen readers', () => { + render(); + const discard = screen.getByTestId('composer-discard'); + expect(discard).toHaveAccessibleName('discard'); + expect(discard.querySelector('svg')).not.toBeNull(); + expect(within(discard).getByText('discard').className).toContain('sr-only'); + }); +}); diff --git a/components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx b/components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx new file mode 100644 index 000000000..c45bc217c --- /dev/null +++ b/components/email/__tests__/rich-text-editor-toolbar-overflow.test.tsx @@ -0,0 +1,114 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; + +import { RichTextEditor, formattingFoldCount } from '../rich-text-editor'; + +// The formatting toolbar keeps one row: in a narrow composer (585 px at +// 1280x720) it wrapped to two and took the body's room. What doesn't fit goes +// behind "More formatting", the least used first. + +let toolbarWidth = 1200; +const observers = new Set(); +class ReportingResizeObserver { + constructor(private cb: ResizeObserverCallback) {} + observe() { observers.add(this.cb); } + unobserve() {} + disconnect() { observers.delete(this.cb); } +} +vi.stubGlobal('ResizeObserver', ReportingResizeObserver); + +const realClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')!; +Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get(this: HTMLElement) { + return this.getAttribute('role') === 'toolbar' ? toolbarWidth : realClientWidth.get!.call(this); + }, +}); + +afterEach(() => { + toolbarWidth = 1200; +}); + +async function setup() { + let ready = false; + render( {}} onEditorReady={() => { ready = true; }} />); + await waitFor(() => expect(ready).toBe(true)); +} + +async function resize(width: number) { + toolbarWidth = width; + await act(async () => { + for (const cb of observers) cb([], {} as ResizeObserver); + }); +} + +const toolbar = () => screen.getByRole('toolbar', { name: 'formatting' }); +const rowItem = (name: string) => toolbar().querySelector(`[data-toolbar-item="${name}"]`); + +describe('formatting toolbar in a narrow composer', () => { + it('shows every button in one row when there is room, without a More button', async () => { + await setup(); + for (const name of ['heading-1', 'quote', 'align-left', 'table', 'undo']) expect(rowItem(name)).not.toBeNull(); + expect(rowItem('more-formatting')).toBeNull(); + }); + + it('moves what does not fit behind "More formatting", and shows it there', async () => { + await setup(); + await resize(561); + + // The least used go first: undo, redo, clear formatting, then alignment. + for (const name of ['undo', 'redo', 'clear-formatting', 'align-left', 'align-right']) expect(rowItem(name)).toBeNull(); + for (const name of ['bold', 'bullet-list', 'link', 'heading-1', 'table']) expect(rowItem(name)).not.toBeNull(); + + const more = rowItem('more-formatting') as HTMLButtonElement; + expect(more).toHaveAccessibleName('more_formatting'); + expect(more).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(more); + const panel = screen.getByRole('dialog', { name: 'more_formatting' }); + expect(more).toHaveAttribute('aria-expanded', 'true'); + for (const name of ['align-left', 'align-center', 'align-right', 'clear-formatting', 'undo', 'redo']) { + expect(within(panel).getByRole('button', { name: new RegExp(`^${name.replace('-', '_')}`) })).toBeInTheDocument(); + } + }); + + it('keeps the arrows in the row: the panel buttons are no Tab stops of the toolbar', async () => { + await setup(); + await resize(561); + fireEvent.click(rowItem('more-formatting')!); + const panel = screen.getByRole('dialog', { name: 'more_formatting' }); + const rowStops = Array.from(toolbar().querySelectorAll('[data-toolbar-item]')) + .filter((el) => !panel.contains(el) && el.tabIndex === 0); + expect(rowStops).toHaveLength(1); + }); + + it('brings everything back into the row when the composer gets wider', async () => { + await setup(); + await resize(400); + expect(rowItem('heading-1')).toBeNull(); + await resize(1000); + expect(rowItem('heading-1')).not.toBeNull(); + expect(rowItem('undo')).not.toBeNull(); + expect(rowItem('more-formatting')).toBeNull(); + }); +}); + +describe('formattingFoldCount', () => { + const sizes = { button: 28, gap: 2, separator: 5 }; + + it('folds nothing for a toolbar that is not laid out', () => { + expect(formattingFoldCount(0, sizes)).toBe(0); + }); + + it('folds more groups the narrower the row', () => { + const counts = [900, 700, 561, 480, 400, 300].map((width) => formattingFoldCount(width, sizes)); + expect(counts[0]).toBe(0); + for (let i = 1; i < counts.length; i++) expect(counts[i]).toBeGreaterThanOrEqual(counts[i - 1]); + expect(counts[counts.length - 1]).toBe(5); + }); + + it('makes room for the text direction button', () => { + const width = 700; + expect(formattingFoldCount(width, { ...sizes, directionButton: true })).toBeGreaterThanOrEqual(formattingFoldCount(width, sizes)); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index c93e6c7b9..ef1a8a99d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -42,6 +42,7 @@ import { Forward, BookUser, Lock, + CloudOff, } from "@/components/icons"; import { useRouter } from "@/i18n/navigation"; import { ComposerPluginMenuItems, ComposerPluginToolbarButtons, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu"; @@ -100,6 +101,9 @@ import type { DirectoryEntry } from "@/stores/contact-store"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { isStandInId } from "@/lib/jmap/stand-in-ids"; import { noteDraftReplacedElsewhere } from "@/lib/offline/queue"; +import { offlineActive, offlineAllowed } from "@/lib/offline/offline-mode"; +import { useBrowserOnline } from "@/hooks/use-browser-online"; +import { appPath, buildSettingsPath } from "@/lib/deep-links"; import type { FileNode } from "@/lib/jmap/types"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store"; @@ -1215,6 +1219,13 @@ export function EmailComposer({ // bar folded away behind "Aa" until asked for. const mvWindow = useMvComposeWindow(); const [mvShowFormatting, setMvShowFormatting] = useState(false); + // A pop-out or quick compose window is narrower than a tablet, but it is a + // window on a computer: it keeps the main window's layout, not the phone's. + const phoneLayout = !ownWindow; + // Full screen: the composer over the whole window, as Gmail has it. The same + // button or Escape puts it back. Not in a window of its own, which is all composer. + const [maximized, setMaximized] = useState(false); + const canMaximize = !ownWindow && !mvWindow; // "close" (X, Escape): save or discard. "discard" (the Discard button): the // user already chose, so only confirm it. const [closeDialogKind, setCloseDialogKind] = useState<'close' | 'discard'>('close'); @@ -4654,6 +4665,35 @@ export function EmailComposer({ }; }); + // Offline. With "Keep mail on this device" on, a send waits in the outbox + // and goes out once the server answers again. Without it there is nowhere + // safe to keep a message for later: Send says so and the text stays here. + const tComposeWindows = useTranslations('compose_windows'); + const browserOnline = useBrowserOnline(); + const [offlineSendNotice, setOfflineSendNotice] = useState<'saved' | 'unsaved' | null>(null); + const sendOrSayOffline = () => { + if (typeof navigator !== 'undefined' && navigator.onLine === false && !offlineActive(composingAccountId ?? null)) { + const saved = !!draftIdRef.current && changeVersionRef.current === savedVersionRef.current && !saveFailedRef.current; + setOfflineSendNotice(saved ? 'saved' : 'unsaved'); + announceStatus(tComposeWindows(saved ? 'offline_send_saved' : 'offline_send_unsaved')); + return; + } + handleSend(); + }; + // Back online: the notice goes, and what couldn't be saved meanwhile is saved now. + const wasOnlineRef = useRef(browserOnline); + useEffect(() => { + const cameBack = browserOnline && !wasOnlineRef.current; + wasOnlineRef.current = browserOnline; + if (!cameBack) return; + setOfflineSendNotice(null); + if (saveCloseAttemptRef.current || sentRef.current || isSendingRef.current || !isDirtyRef.current) return; + if (changeVersionRef.current === savedVersionRef.current && !saveFailedRef.current) return; + void saveDraftRef.current({ auto: true }); + }, [browserOnline]); + // Only asked while offline: it reads the organisation's policy and the store's support. + const offlineHint = browserOnline ? '' : tComposeWindows(offlineActive(composingAccountId ?? null) ? 'offline_hint_outbox' : 'offline_hint'); + const handleComposerKeyDown = (e: React.KeyboardEvent) => { if (e.defaultPrevented) return; // Keys from a dialog the composer opened (Insert link, ...) reach here @@ -4698,6 +4738,11 @@ export function EmailComposer({ // And for proofreading's popup and panel: Escape closes only them. if (proofreading.popup || proofreading.panel) return; e.preventDefault(); + // Full screen first goes back to where the composer was. + if (maximized) { + setMaximized(false); + return; + } handleClose(); return; } @@ -4715,7 +4760,7 @@ export function EmailComposer({ } e.preventDefault(); - handleSend(); + sendOrSayOffline(); return; } @@ -4731,7 +4776,13 @@ export function EmailComposer({ data-testid="email-composer" // Edits made while the draft moves wouldn't move with it. inert={isPoppingOut || undefined} - className={cn("flex h-full bg-background relative", className)} + className={cn( + "flex h-full bg-background relative", + className, + // Over everything but dialogs (z-60) and toasts; below the desktop app's window controls. + maximized && "fixed inset-0 z-40 h-auto pt-[var(--titlebar-row-height)]", + )} + data-maximized={maximized || undefined} onKeyDownCapture={handleComposerKeyDown} // Bubble phase, in the same batch as the field's own onChange. A state // change in the capture phase renders first and puts the old value back. @@ -4858,28 +4909,28 @@ export function EmailComposer({ {saveStatus === 'saving' && (
- {t('saving')} + {t('saving')}
)} {saveStatus === 'saved' && (
- {savedOnDevice ? tOfflineDrafts('saved_on_device') : t('draft_saved')} + {savedOnDevice ? tOfflineDrafts('saved_on_device') : t('draft_saved')}
)} {saveStatus === 'error' && (
- {t('save_failed')} + {t('save_failed')}
)} {/* Mobile: keep scheduled send reachable without consuming footer space. */} - {composerClient?.hasDelayedSend() || hasSendMenuActions ? ( + {phoneLayout && (composerClient?.hasDelayedSend() || hasSendMenuActions ? (
) : ( - )} + ))} {rightPanelToggle} - {popOutButton("hidden md:grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", "w-4 h-4")} + {canMaximize && ( + + )} + {popOutButton(cn(phoneLayout ? "hidden md:grid" : "grid", "h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"), "w-4 h-4")} {/* Close at the top end (R4). On a phone it moves to the start, as the phone's mail apps have it, with Send at the end. */} handleClose()} - className="-me-2 max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11" + className={phoneLayout ? "-me-2 max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11" : "-me-2"} label={tCommon('close')} data-testid="composer-close" data-own-window-close={ownWindow ? "" : undefined} @@ -5333,7 +5396,7 @@ export function EmailComposer({ ariaInvalid={validationErrors.body} ariaDescribedBy={`${fieldIdBase}-body-error`} defaultTextStyle={composeStyle} - collapseToolbarOnMobile={!showFormattingOnMobile} + collapseToolbarOnMobile={phoneLayout && !showFormattingOnMobile} toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : 'top'} onEditorReady={handleEditorReady} mentionCandidates={recipientMentionsEnabled ? mentionCandidates : undefined} @@ -5544,6 +5607,23 @@ export function EmailComposer({ )} + {offlineSendNotice && !browserOnline && ( +
+
+ )} + {/* Bottom toolbar. In Mountain View's window both halves fold into one row (see [data-mv-compose-footer] in globals.css): Send first, the tools after it, and Discard as a bin at the far end. */} @@ -5598,7 +5678,7 @@ export function EmailComposer({ {t('attach')} )} - {!plainTextMode && ( + {!plainTextMode && (mvWindow || phoneLayout) && ( )} {composerClient?.hasDelayedSend() || hasSendMenuActions ? ( -
+
) : (
{/* Editor */} diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx index 6ceedf1fa..d92e90439 100644 --- a/components/popout/__tests__/popout-compose-app.test.tsx +++ b/components/popout/__tests__/popout-compose-app.test.tsx @@ -4,6 +4,7 @@ import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-ch import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; import { createComposeHandoff, rememberComposePayload, rememberedComposePayload } from '@/lib/popout/compose-handoff'; import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; import { useIdentityStore } from '@/stores/identity-store'; import { usePluginStore } from '@/stores/plugin-store'; import { emailHooks } from '@/lib/plugin-hooks'; @@ -547,3 +548,90 @@ describe('after this page crashed', () => { expect(rememberedComposePayload('h2')).toBeNull(); }); }); + +describe('a send held for undo', () => { + const closeSelf = vi.fn(); + const MAILBOXES = [{ id: 'mDrafts', role: 'drafts' }, { id: 'mSent', role: 'sent' }]; + const startDraft = { + id: 'd0', subject: 'Before sending', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }], + keywords: { $draft: true }, mailboxIds: { mDrafts: true }, + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [], + }; + const restoredDraft = { + id: 'e1', subject: 'Back again', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }], + keywords: { $draft: true }, mailboxIds: { mDrafts: true }, + bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [], + }; + let originalEmail: ReturnType; + let sendAt: string; + let cancelUndoSend: ReturnType; + + beforeEach(() => { + closeSelf.mockClear(); + originalEmail = useEmailStore.getState(); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf, setRestore: vi.fn() } } as never; + useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never); + identityMocks.multi = { enabled: false, allIdentities: [], groups: [] }; + const client = { getEmail: vi.fn(async (id: string) => (id === 'e1' ? restoredDraft : startDraft)), getMailboxes: vi.fn(async () => MAILBOXES) }; + useAuthStore.setState({ client: client as never, getClientForAccount: (id: string) => (id === 'alice' ? client : undefined) } as never); + cancelUndoSend = vi.fn(async () => restoredDraft); + useEmailStore.setState({ + sendEmail: vi.fn(async () => { + useEmailStore.setState({ pendingUndoSend: { submissionId: 'sub1', emailId: 'e1', identityId: 'id-1', sendAt, isSmime: false } }); + return { scheduled: true, emailSubmissionId: 'sub1', emailId: 'e1', sendAt }; + }), + cancelUndoSend, + } as never); + }); + + afterEach(() => { + useEmailStore.setState(originalEmail); + window.bulwarkDesktop = undefined; + }); + + type Sending = { onSend: (data: object) => Promise; onClose: () => void }; + const DATA = { to: ['a@example.org'], cc: [], bcc: [], subject: 'Hi', body: 'Text', localAccountId: 'alice' }; + + /** Opens the window, sends from it and lets the composer close, as it does after a send. */ + async function sendFromWindow(seconds: number) { + sendAt = new Date(Date.now() + seconds * 1000).toISOString(); + window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice'); + render(); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending')); + const props = composer as unknown as Sending; + await act(async () => { await props.onSend(DATA); }); + act(() => props.onClose()); + } + + it('stays open with "Sending in" and Undo, and does not hand the undo to the main window', async () => { + await sendFromWindow(5); + expect(screen.getByTestId('popout-sending').textContent).toContain('sending_in'); + expect(screen.getByRole('button', { name: 'undo_send' })).toBeInTheDocument(); + expect(closeSelf).not.toHaveBeenCalled(); + const sent = heard.find((message) => message.type === 'sent') as Extract | undefined; + expect(sent?.notice.pendingUndoSend ?? null).toBeNull(); + }); + + it('closes once the undo time is over', async () => { + await sendFromWindow(1.2); + expect(closeSelf).not.toHaveBeenCalled(); + await waitFor(() => expect(closeSelf).toHaveBeenCalled(), { timeout: 4000 }); + }); + + it('Undo takes the send back and opens the message again in this window', async () => { + await sendFromWindow(30); + fireEvent.click(screen.getByRole('button', { name: 'undo_send' })); + await waitFor(() => expect(cancelUndoSend).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ submissionId: 'sub1', localAccountId: 'alice' }))); + await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Back again')); + expect(closeSelf).not.toHaveBeenCalled(); + expect(new URLSearchParams(window.location.search).get('draft')).toBe('e1'); + }); + + it('a message scheduled for later still goes to the main window, and the window closes', async () => { + await sendFromWindow(3600); + expect(screen.queryByTestId('popout-sending')).toBeNull(); + expect(closeSelf).toHaveBeenCalled(); + const sent = heard.find((message) => message.type === 'sent') as Extract | undefined; + expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1'); + }); +}); diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index e8a0dd03e..1627d07a7 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -92,9 +92,20 @@ afterEach(() => { const answers = () => heard.filter((message) => message.type === 'session-answer'); -describe('in an ordinary browser tab', () => { +/** A browser tab: not installed, with or without a mouse. */ +function stubTab(fine: boolean) { + delete window.bulwarkDesktop; + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('pointer: fine') ? fine : false, + addEventListener() {}, + removeEventListener() {}, + })); + window.matchMedia = globalThis.matchMedia; +} + +describe('in a browser tab on a phone or tablet', () => { it('runs nothing: no channel and no lock', async () => { - delete window.bulwarkDesktop; + stubTab(false); const opened = FakeBroadcastChannel.open.size; render(); await settle(); @@ -106,6 +117,18 @@ describe('in an ordinary browser tab', () => { }); }); +describe('in a browser tab on a computer', () => { + it('lends its session to a composer popped out of it', async () => { + stubTab(true); + render(); + await settle(); + await expect(requestWindowSession({}, 2000)).resolves.toMatchObject({ + activeAccountId: 'alice', + sessions: [{ accountId: 'alice', authMode: 'oauth' }], + }); + }); +}); + describe('PopoutHost', () => { it('holds no lock while idle, which would keep the page out of the back/forward cache', async () => { render(); diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 4b9f058a8..e29b8810d 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer"; import { ErrorBoundary, ComposerErrorFallback } from "@/components/error"; @@ -13,8 +13,9 @@ import { toast } from "@/stores/toast-store"; import { formatRejectedRecipients } from "@/lib/jmap/client"; import { debug } from "@/lib/debug"; import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; -import { announceSent } from "@/lib/plugin-after-send"; -import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type RescuedDraft } from "@/lib/popout/channel"; +import { announceSent, cancelAnnouncedSend, releaseAnnouncedSend } from "@/lib/plugin-after-send"; +import { cancelFailureToast } from "@/lib/send-failure-message"; +import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type PopoutSentNotice, type RescuedDraft } from "@/lib/popout/channel"; import { acknowledgeComposeHandoff, forgetComposePayload, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff"; import type { ComposePopoutPayload } from "@/lib/popout/compose-payload"; import { returnComposerToMain, type ReturnResult } from "@/lib/popout/return-to-main"; @@ -30,6 +31,7 @@ import { emailHooks } from "@/lib/plugin-hooks"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { useIdentityStore } from "@/stores/identity-store"; import type { Email } from "@/lib/jmap/types"; +import { Send } from "@/components/icons"; import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell"; /** A warning with no main window left to show it stays this long before the window goes. */ @@ -49,6 +51,91 @@ const MAIN_CHECK_MS = 1000; /** How long a reloaded window waits to hear what became of the text it handed over on the way out. */ const RESCUE_ANSWER_MS = 2000; +/** A send held this long or less is an undo window ("Sending in 5 s"), not a message scheduled for later. */ +const UNDO_WINDOW_MS = 120_000; +/** Undo goes this long before the server lets the message go, as in the main window. */ +const UNDO_MARGIN_MS = 1000; +/** What the plugins are told when the hold runs out gets this long before the window goes. */ +const RELEASE_WAIT_MS = 1000; + +type HeldSend = NonNullable; + +/** A draft to open again in this window, after Undo brought the message back to Drafts. */ +interface ReopenTarget { + draftId: string; + accountId: string; + ownerAccountId?: string; +} + +/** + * A send held for undo, shown in the window it was sent from: that's where the + * user is looking, and quick compose is used with the main window out of sight. + * The window stays until the hold runs out, then goes. + */ +function SendingWithUndo({ held, onUndone, onGone }: { + held: HeldSend; + /** The send was taken back; the message, back in Drafts, when there is one to open. */ + onUndone: (restored: Email | null) => void; + onGone: () => void; +}) { + const tViewer = useTranslations("email_viewer"); + const [now, setNow] = useState(() => Date.now()); + const [busy, setBusy] = useState(false); + const sendAt = Date.parse(held.sendAt); + const over = !Number.isFinite(sendAt) || now >= sendAt - UNDO_MARGIN_MS; + + useEffect(() => { + if (over) return; + const timer = setInterval(() => setNow(Date.now()), 250); + return () => clearInterval(timer); + }, [over]); + + // Too late to undo: the plugins hear of the send now, and the window goes. + useEffect(() => { + if (!over || busy) return; + releaseAnnouncedSend(held.submissionId); + const timer = setTimeout(onGone, RELEASE_WAIT_MS); + return () => clearTimeout(timer); + }, [over, busy, held.submissionId, onGone]); + + const undo = async () => { + const auth = useAuthStore.getState(); + // Never another login: the submission id names another submission there. + const client = held.localAccountId ? auth.getClientForAccount(held.localAccountId) : auth.client; + if (!client) { + toast.error(tViewer("undo_send_signed_out")); + return; + } + setBusy(true); + let restored: Email | null; + try { + restored = await useEmailStore.getState().cancelUndoSend(client, held); + } catch (error) { + debug.error("Failed to undo the send:", error); + const { title, message } = cancelFailureToast(error, tViewer, tViewer("undo_send_failed")); + toast.error(title, message ? { message } : undefined); + setBusy(false); + return; + } + // Never left the server: plugins don't hear about it. + cancelAnnouncedSend(held.submissionId); + onUndone(held.isSmime ? null : restored); + }; + + const seconds = Math.max(0, Math.ceil((sendAt - now) / 1000)); + return ( +
+
+ ); +} + function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: string } { if (typeof window === "undefined") return { handoffId: null }; const search = new URLSearchParams(window.location.search); @@ -214,7 +301,12 @@ function NewMessageGate({ onReady }: { onReady: (payload: ComposePopoutPayload) } /** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */ -function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; handoffId: string | null }) { +function PopoutComposer({ payload, handoffId, onReopen }: { + payload: ComposePopoutPayload; + handoffId: string | null; + /** Undo brought the message back to Drafts: open it again here. */ + onReopen: (target: ReopenTarget) => void; +}) { const t = useTranslations(); const tQuick = useTranslations("desktopQuickCompose"); const client = useAuthStore((s) => s.client); @@ -267,6 +359,16 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; const accountId = data.localAccountId ?? useAuthStore.getState().activeAccountId; const pending = useEmailStore.getState().pendingUndoSend; + const withLogin = pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null; + // An undo window stays here, where Send was pressed: the window shows it + // and closes when it runs out. A message scheduled for later goes to the + // main window as before. + const remaining = withLogin ? Date.parse(withLogin.sendAt) - Date.now() : NaN; + const keepHere = result.scheduled && withLogin && Number.isFinite(remaining) && remaining <= UNDO_WINDOW_MS ? withLogin : null; + if (keepHere) { + heldRef.current = keepHere; + useEmailStore.setState({ pendingUndoSend: null }); + } const rejectedRecipients = result.rejectedRecipients?.length ? formatRejectedRecipients(result.rejectedRecipients) : undefined; const taken = await announceSentToMainWindow({ accountId, @@ -274,7 +376,7 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; queued: !!result.queued, filingError: !!result.filingError, rejectedRecipients, - pendingUndoSend: pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null, + pendingUndoSend: keepHere ? null : withLogin, }); // No main window to say so, and this is the only notice these get (#1123). if (!taken && (result.filingError || rejectedRecipients)) { @@ -301,11 +403,36 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; } }, [client, t]); + // A send held for undo, kept in this window until it runs out. + const heldRef = useRef(null); + const [held, setHeld] = useState(null); + const handleClose = useCallback(() => { + announceMailChanged(useAuthStore.getState().activeAccountId); + if (heldRef.current) { + setHeld(heldRef.current); + return; + } + closePopoutWindow(); + }, []); + + const heldGone = useCallback(() => { announceMailChanged(useAuthStore.getState().activeAccountId); closePopoutWindow(); }, []); + const heldUndone = useCallback((restored: Email | null) => { + const sent = heldRef.current; + heldRef.current = null; + const login = sent?.localAccountId ?? useAuthStore.getState().activeAccountId; + announceMailChanged(login); + if (!restored || !login) { + closePopoutWindow(); + return; + } + onReopen({ draftId: restored.id, accountId: login, ownerAccountId: sent?.submissionAccountId }); + }, [onReopen]); + const [mainWindow] = useState(watchMainWindow); useEffect(() => () => mainWindow.stop(), [mainWindow]); @@ -373,6 +500,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; }, }), [handoffId, mainWindow, payload]); + if (held) return ; + return ( (named && draftId && !rememberedComposePayload(named) ? null : named)); const [payload, setPayload] = useState(null); + // After Undo: the message back in Drafts, opened again in a new composer here. + const [reopen, setReopen] = useState(null); + const [generation, setGeneration] = useState(0); + const handleReopen = useCallback((target: ReopenTarget) => { + // A reload starts from the draft, as after a save. + const url = new URL(window.location.href); + url.searchParams.delete("handoff"); + url.searchParams.set("draft", target.draftId); + url.searchParams.set("account", target.accountId); + if (target.ownerAccountId) url.searchParams.set("owner", target.ownerAccountId); + else url.searchParams.delete("owner"); + window.history.replaceState(null, "", `${url.pathname}${url.search}`); + setPayload(null); + setReopen(target); + }, []); + const reopened = useCallback((next: ComposePopoutPayload) => { + setReopen(null); + setGeneration((n) => n + 1); + setPayload(next); + }, []); useEffect(() => { if (!handoffId) return; @@ -438,7 +587,8 @@ export function PopoutComposeApp() { return ( - {payload ? + {payload ? + : reopen ? : handoffId ? : draftId ? : } diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx index 4239c73b6..fc7305e60 100644 --- a/components/popout/popout-host.tsx +++ b/components/popout/popout-host.tsx @@ -60,8 +60,9 @@ function claimOnce(name: string, work: () => void | Promise): void { /** * The main window's side of pop-outs: lends them its session and renews it * for them, passes on that mail changed, and takes over what a pop-out can't - * say any more once it has closed. Only where pop-outs are on offer: an - * ordinary browser tab runs none of this. + * say any more once it has closed. Only where pop-outs are on offer: a + * browser tab on a computer runs it for composers popped out of it, one on a + * phone or tablet runs none of this. */ export function PopoutHost() { const available = usePopoutsAvailable(); diff --git a/hooks/use-browser-online.ts b/hooks/use-browser-online.ts new file mode 100644 index 000000000..00d6d86e1 --- /dev/null +++ b/hooks/use-browser-online.ts @@ -0,0 +1,21 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +function subscribe(onChange: () => void): () => void { + window.addEventListener("online", onChange); + window.addEventListener("offline", onChange); + return () => { + window.removeEventListener("online", onChange); + window.removeEventListener("offline", onChange); + }; +} + +/** + * Whether the browser says it has a network, kept up to date. Also without + * offline mode, whose store only follows it while that runs. True on the + * server and while the page hydrates. + */ +export function useBrowserOnline(): boolean { + return useSyncExternalStore(subscribe, () => navigator.onLine !== false, () => true); +} diff --git a/hooks/use-popouts-available.ts b/hooks/use-popouts-available.ts index 7628846a0..9e391d73d 100644 --- a/hooks/use-popouts-available.ts +++ b/hooks/use-popouts-available.ts @@ -7,27 +7,30 @@ import { resolveEmailBlobContext } from "@/stores/email-store"; import { toast } from "@/stores/toast-store"; import type { Email } from "@/lib/jmap/types"; import { openMessageWindow } from "@/lib/popout/open"; -import { popoutsAvailable } from "@/lib/popout/support"; +import { composerPopoutsAvailable, popoutsAvailable } from "@/lib/popout/support"; import { isPopoutWindow } from "@/lib/popout/window-role"; /** - * Whether to offer "Open in new window" here. False on the server and in the - * first render, so the markup hydrates the same; false in the demo, whose - * client has no session to share, and inside a pop-out (for a message: it is - * already in its own window). + * Whether to offer "Open in new window" here: for a composer, the default, + * also in a browser tab on a computer; for a message only in an installed or + * the desktop app. False on the server and in the first render, so the + * markup hydrates the same; false in the demo, whose client has no session to + * share, and inside a pop-out (for a message: it is already in its own window). + * The main window's side of pop-outs (PopoutHost) runs wherever a composer can + * move out. */ -export function usePopoutsAvailable(): boolean { +export function usePopoutsAvailable(kind: "compose" | "message" = "compose"): boolean { const isDemoMode = useAuthStore((s) => s.isDemoMode); const [available, setAvailable] = useState(false); useEffect(() => { - const check = () => setAvailable(popoutsAvailable() && !isPopoutWindow()); + const check = () => setAvailable((kind === "message" ? popoutsAvailable() : composerPopoutsAvailable()) && !isPopoutWindow()); check(); // Installing the running tab turns it into a window without a reload. if (typeof window.matchMedia !== "function") return; const media = window.matchMedia("(display-mode: standalone)"); media.addEventListener?.("change", check); return () => media.removeEventListener?.("change", check); - }, []); + }, [kind]); return available && !isDemoMode; } @@ -37,7 +40,7 @@ export function usePopoutsAvailable(): boolean { * blocks the window; nothing else changes then. */ export function useOpenEmailInWindow(): ((email: Email) => void) | null { - const available = usePopoutsAvailable(); + const available = usePopoutsAvailable("message"); const t = useTranslations("pwaWindows"); const open = useCallback((email: Email) => { const auth = useAuthStore.getState(); diff --git a/lib/__tests__/popout-windows.test.ts b/lib/__tests__/popout-windows.test.ts index f3675c552..a041f2114 100644 --- a/lib/__tests__/popout-windows.test.ts +++ b/lib/__tests__/popout-windows.test.ts @@ -132,6 +132,53 @@ describe('popoutsAvailable', () => { }); }); +describe('composerPopoutsAvailable', () => { + async function available() { + const { composerPopoutsAvailable } = await import('@/lib/popout/support'); + return composerPopoutsAvailable(); + } + + it('is on in a browser tab on a computer: a composer opens as a popup window, as in Gmail', async () => { + stubDisplay({ standalone: false }); + expect(await available()).toBe(true); + }); + + it('is off in a browser tab on a phone or tablet, where window.open makes a tab', async () => { + stubDisplay({ standalone: false, fine: false }); + expect(await available()).toBe(false); + }); + + it('is on wherever a message can move out too', async () => { + stubDisplay(); + expect(await available()).toBe(true); + stubDisplay({ standalone: false, fine: false }); + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: {} }; + expect(await available()).toBe(true); + }); + + it('is off in an installed app on a touch device', async () => { + stubDisplay({ fine: false }); + expect(await available()).toBe(false); + }); + + it('is off in a tab without what keeps a shared session safe', async () => { + stubDisplay({ standalone: false }); + vi.stubGlobal('BroadcastChannel', undefined); + expect(await available()).toBe(false); + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + vi.stubGlobal('crypto', {}); + expect(await available()).toBe(false); + }); + + it('lets the hook offer a composer its own window from a tab, and still not a message', async () => { + stubDisplay({ standalone: false }); + const { renderHook } = await import('@testing-library/react'); + const { usePopoutsAvailable } = await import('@/hooks/use-popouts-available'); + expect(renderHook(() => usePopoutsAvailable()).result.current).toBe(true); + expect(renderHook(() => usePopoutsAvailable('message')).result.current).toBe(false); + }, 60_000); +}); + describe('opening windows', () => { it('opens a message as a popup in the app, with its login and account', async () => { const { openMessageWindow } = await import('@/lib/popout/open'); diff --git a/lib/popout/support.ts b/lib/popout/support.ts index f06f22aeb..150a6380b 100644 --- a/lib/popout/support.ts +++ b/lib/popout/support.ts @@ -1,7 +1,8 @@ /** * Whether "Open in new window" is on offer: an installed app or the desktop * app, on a device with windows. In a browser tab, a tab does the same job - * and the app behaves as it always has. + * for a message, and the app behaves as it always has. A composer is the + * exception: it moves to a window of its own from a tab too, as in Gmail. */ import { IS_LITE } from '@/lib/lite'; @@ -13,16 +14,36 @@ export function hasDesktopWindows(): boolean { return typeof window !== 'undefined' && !!window.bulwarkDesktop?.windows; } -/** Display mode is `browser` in Electron popups too, hence the bridge check. */ -export function popoutsAvailable(): boolean { +/** A second window can share this one's session, safely. */ +function windowsCanShareSessions(): boolean { // Lite has no such route: a static export can't list every message id. if (IS_LITE || typeof window === 'undefined') return false; // Sharing the session and renewing tokens safely across windows need these. - if (typeof BroadcastChannel === 'undefined' || !navigator.locks || !canSealSessions()) return false; + return typeof BroadcastChannel !== 'undefined' && !!navigator.locks && canSealSessions(); +} + +/** A device with a mouse or trackpad: a phone or tablet has no second window. */ +function hasFinePointer(): boolean { + return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: fine)').matches; +} + +/** Display mode is `browser` in Electron popups too, hence the bridge check. */ +export function popoutsAvailable(): boolean { + if (!windowsCanShareSessions()) return false; if (hasDesktopWindows()) return true; if (!isStandaloneDisplayMode()) return false; // A home-screen app on a phone or tablet has no second window: window.open // leaves for the browser there. if ((window.navigator as Navigator & { standalone?: boolean }).standalone === true) return false; - return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: fine)').matches; + return hasFinePointer(); +} + +/** + * A composer can move to a window of its own: wherever a message can, and in + * a browser tab on a computer too, where it opens as a popup window. + */ +export function composerPopoutsAvailable(): boolean { + if (popoutsAvailable()) return true; + if (!windowsCanShareSessions() || isStandaloneDisplayMode()) return false; + return hasFinePointer(); } diff --git a/locales/ar/common.json b/locales/ar/common.json index 68c8242b1..58c7fe5a6 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -7109,6 +7109,14 @@ "types_character": "تكتب هذه المفاتيح حرفًا على لوحة مفاتيحك، وسيتوقف عن العمل في كل التطبيقات. جرّب مفاتيح أخرى.", "reserved": "يستخدم النظام أو معظم التطبيقات هذه المفاتيح. جرّب مفاتيح أخرى." }, + "compose_windows": { + "more_formatting": "مزيد من التنسيق", + "offline": "دون اتصال", + "offline_hint": "يحتاج الإرسال إلى اتصال.", + "offline_hint_outbox": "ما ترسله الآن ينتظر في صندوق الصادر ويُرسل عند عودة الاتصال.", + "offline_send_saved": "أنت دون اتصال. الرسالة محفوظة في المسودات.", + "offline_send_unsaved": "أنت دون اتصال. تبقى الرسالة هنا وتُحفظ في المسودات عند عودة الاتصال." + }, "desktopReminders": { "generic_title": "تذكير", "snooze": "تأجيل {count} د", diff --git a/locales/ca/common.json b/locales/ca/common.json index b63bab5c3..e1da6608f 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -7109,6 +7109,14 @@ "types_character": "Aquestes tecles escriuen un caràcter al teu teclat, i deixaria de funcionar a totes les aplicacions. Prova altres tecles.", "reserved": "El sistema o la majoria d'aplicacions fan servir aquestes tecles. Prova altres tecles." }, + "compose_windows": { + "more_formatting": "Més format", + "offline": "Sense connexió", + "offline_hint": "Per enviar cal connexió.", + "offline_hint_outbox": "El que envieu ara espera a la safata de sortida i s'envia quan torneu a tenir connexió.", + "offline_send_saved": "No teniu connexió. El missatge està desat a Esborranys.", + "offline_send_unsaved": "No teniu connexió. El missatge es queda aquí i es desa a Esborranys quan torneu a tenir connexió." + }, "desktopReminders": { "generic_title": "Recordatori", "snooze": "Posposa {count} min", diff --git a/locales/cs/common.json b/locales/cs/common.json index 6b0cf3b1a..fa9b5d3c8 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -7109,6 +7109,14 @@ "types_character": "Tyto klávesy na vaší klávesnici píší znak, který by pak nefungoval v žádné aplikaci. Zkuste jiné klávesy.", "reserved": "Tyto klávesy používá systém nebo většina aplikací. Zkuste jiné klávesy." }, + "compose_windows": { + "more_formatting": "Další formátování", + "offline": "Offline", + "offline_hint": "Odeslání potřebuje připojení.", + "offline_hint_outbox": "Co teď odešlete, počká mezi zprávami k odeslání a odejde, až budete znovu online.", + "offline_send_saved": "Jste offline. Zpráva je uložená v Konceptech.", + "offline_send_unsaved": "Jste offline. Zpráva tu zůstane a uloží se do Konceptů, až budete znovu online." + }, "desktopReminders": { "generic_title": "Připomenutí", "snooze": "Odložit o {count} min", diff --git a/locales/da/common.json b/locales/da/common.json index aca6afafd..656cadee7 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -7109,6 +7109,14 @@ "types_character": "Disse taster skriver et tegn på dit tastatur, og det ville holde op med at virke i alle apps. Prøv andre taster.", "reserved": "Dit system eller de fleste apps bruger disse taster. Prøv andre taster." }, + "compose_windows": { + "more_formatting": "Mere formatering", + "offline": "Offline", + "offline_hint": "Det kræver forbindelse at sende.", + "offline_hint_outbox": "Det, du sender nu, venter i udbakken og sendes, når du er online igen.", + "offline_send_saved": "Du er offline. Beskeden er gemt i Kladder.", + "offline_send_unsaved": "Du er offline. Beskeden bliver her og gemmes i Kladder, når du er online igen." + }, "desktopReminders": { "generic_title": "Påmindelse", "snooze": "Udsæt {count} min.", diff --git a/locales/de/common.json b/locales/de/common.json index 884536e57..55853ac55 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -7109,6 +7109,14 @@ "types_character": "Diese Tasten erzeugen auf Ihrer Tastatur ein Zeichen, das dann in keiner App mehr funktionieren würde. Versuchen Sie andere Tasten.", "reserved": "Ihr System oder die meisten Apps verwenden diese Tasten. Versuchen Sie andere Tasten." }, + "compose_windows": { + "more_formatting": "Weitere Formatierung", + "offline": "Offline", + "offline_hint": "Zum Senden ist eine Verbindung nötig.", + "offline_hint_outbox": "Was Sie jetzt senden, wartet im Postausgang und wird gesendet, sobald Sie wieder online sind.", + "offline_send_saved": "Sie sind offline. Die Nachricht ist in den Entwürfen gespeichert.", + "offline_send_unsaved": "Sie sind offline. Die Nachricht bleibt hier und wird in den Entwürfen gespeichert, sobald Sie wieder online sind." + }, "desktopReminders": { "generic_title": "Erinnerung", "snooze": "{count} Min. schlummern", diff --git a/locales/en/common.json b/locales/en/common.json index 15de2fb71..d3304d888 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -7109,6 +7109,14 @@ "types_character": "These keys type a character on your keyboard, and it would stop working in every app. Try other keys.", "reserved": "Your system or most apps use these keys. Try other keys." }, + "compose_windows": { + "more_formatting": "More formatting", + "offline": "Offline", + "offline_hint": "Sending needs a connection.", + "offline_hint_outbox": "What you send now waits in the outbox and goes out when you're back online.", + "offline_send_saved": "You're offline. The message is saved in Drafts.", + "offline_send_unsaved": "You're offline. The message stays here and is saved in Drafts when you're back online." + }, "desktopReminders": { "generic_title": "Reminder", "snooze": "Snooze {count} min", diff --git a/locales/es/common.json b/locales/es/common.json index bb91610a8..f36dde45f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -7109,6 +7109,14 @@ "types_character": "Estas teclas escriben un carácter en tu teclado, y dejaría de funcionar en todas las aplicaciones. Prueba otras teclas.", "reserved": "Tu sistema o la mayoría de las aplicaciones usan estas teclas. Prueba otras teclas." }, + "compose_windows": { + "more_formatting": "Más formato", + "offline": "Sin conexión", + "offline_hint": "Para enviar hace falta conexión.", + "offline_hint_outbox": "Lo que envíes ahora espera en la bandeja de salida y se envía cuando vuelvas a tener conexión.", + "offline_send_saved": "No tienes conexión. El mensaje está guardado en Borradores.", + "offline_send_unsaved": "No tienes conexión. El mensaje se queda aquí y se guarda en Borradores cuando vuelvas a tener conexión." + }, "desktopReminders": { "generic_title": "Recordatorio", "snooze": "Posponer {count} min", diff --git a/locales/et/common.json b/locales/et/common.json index 158469544..696254787 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -7109,6 +7109,14 @@ "types_character": "Need klahvid kirjutavad teie klaviatuuril märgi, mis lakkaks kõigis rakendustes töötamast. Proovige teisi klahve.", "reserved": "Neid klahve kasutab süsteem või enamik rakendusi. Proovige teisi klahve." }, + "compose_windows": { + "more_formatting": "Rohkem vormindust", + "offline": "Võrguta", + "offline_hint": "Saatmiseks on vaja ühendust.", + "offline_hint_outbox": "Mida praegu saadad, ootab väljuvate kirjade kastis ja läheb teele, kui oled jälle võrgus.", + "offline_send_saved": "Oled võrguta. Kiri on salvestatud mustanditesse.", + "offline_send_unsaved": "Oled võrguta. Kiri jääb siia ja salvestatakse mustanditesse, kui oled jälle võrgus." + }, "desktopReminders": { "generic_title": "Meeldetuletus", "snooze": "Lükka {count} min edasi", diff --git a/locales/fa/common.json b/locales/fa/common.json index 6e75c02ab..ea0ca72a2 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -7109,6 +7109,14 @@ "types_character": "این کلیدها روی صفحه‌کلید شما نویسه‌ای می‌نویسند و آن نویسه دیگر در هیچ برنامه‌ای کار نمی‌کند. کلیدهای دیگری را امتحان کنید.", "reserved": "سیستم یا بیشتر برنامه‌ها از این کلیدها استفاده می‌کنند. کلیدهای دیگری را امتحان کنید." }, + "compose_windows": { + "more_formatting": "قالب‌بندی بیشتر", + "offline": "آفلاین", + "offline_hint": "ارسال به اتصال نیاز دارد.", + "offline_hint_outbox": "آنچه اکنون می‌فرستید در صندوق خروجی می‌ماند و وقتی دوباره آنلاین شدید فرستاده می‌شود.", + "offline_send_saved": "آفلاین هستید. پیام در پیش‌نویس‌ها ذخیره شده است.", + "offline_send_unsaved": "آفلاین هستید. پیام همین‌جا می‌ماند و وقتی دوباره آنلاین شدید در پیش‌نویس‌ها ذخیره می‌شود." + }, "desktopReminders": { "generic_title": "یادآوری", "snooze": "تعویق {count} دقیقه", diff --git a/locales/fr/common.json b/locales/fr/common.json index 6ade6955d..0cb389ffd 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -7109,6 +7109,14 @@ "types_character": "Ces touches produisent un caractère sur votre clavier, et il ne fonctionnerait plus dans aucune application. Essayez d'autres touches.", "reserved": "Votre système ou la plupart des applications utilisent ces touches. Essayez d'autres touches." }, + "compose_windows": { + "more_formatting": "Plus de mise en forme", + "offline": "Hors ligne", + "offline_hint": "L'envoi nécessite une connexion.", + "offline_hint_outbox": "Ce que vous envoyez maintenant attend dans la boîte d'envoi et part dès que vous êtes de nouveau en ligne.", + "offline_send_saved": "Vous êtes hors ligne. Le message est enregistré dans les Brouillons.", + "offline_send_unsaved": "Vous êtes hors ligne. Le message reste ici et sera enregistré dans les Brouillons dès que vous serez de nouveau en ligne." + }, "desktopReminders": { "generic_title": "Rappel", "snooze": "Reporter de {count} min", diff --git a/locales/he/common.json b/locales/he/common.json index 51bbe4dc8..d2b2dcbe6 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -7110,6 +7110,14 @@ "types_character": "המקשים האלה מקלידים תו במקלדת שלכם, והוא יפסיק לעבוד בכל האפליקציות. נסו מקשים אחרים.", "reserved": "המערכת או רוב האפליקציות משתמשות במקשים האלה. נסו מקשים אחרים." }, + "compose_windows": { + "more_formatting": "עוד עיצוב", + "offline": "אין חיבור", + "offline_hint": "כדי לשלוח צריך חיבור.", + "offline_hint_outbox": "מה ששולחים עכשיו ממתין בתיבת הדואר היוצא ונשלח כשחוזרים לרשת.", + "offline_send_saved": "אין חיבור. ההודעה שמורה בטיוטות.", + "offline_send_unsaved": "אין חיבור. ההודעה נשארת כאן ותישמר בטיוטות כשתחזרו לרשת." + }, "desktopReminders": { "generic_title": "תזכורת", "snooze": "דחייה ב-{count} דק'", diff --git a/locales/hu/common.json b/locales/hu/common.json index cf6ef46e8..6ddc16963 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -7109,6 +7109,14 @@ "types_character": "Ezek a billentyűk karaktert írnak az Ön billentyűzetén, amely többé egyik alkalmazásban sem működne. Próbáljon más billentyűket.", "reserved": "Ezeket a billentyűket a rendszer vagy a legtöbb alkalmazás használja. Próbáljon más billentyűket." }, + "compose_windows": { + "more_formatting": "További formázás", + "offline": "Offline", + "offline_hint": "A küldéshez kapcsolat kell.", + "offline_hint_outbox": "Amit most elküld, a postázandók között vár, és elmegy, amint újra online lesz.", + "offline_send_saved": "Offline állapotban van. Az üzenet a Piszkozatok között van mentve.", + "offline_send_unsaved": "Offline állapotban van. Az üzenet itt marad, és a Piszkozatok közé kerül, amint újra online lesz." + }, "desktopReminders": { "generic_title": "Emlékeztető", "snooze": "Halasztás {count} perccel", diff --git a/locales/it/common.json b/locales/it/common.json index 036794cb5..199d681a8 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -7109,6 +7109,14 @@ "types_character": "Questi tasti scrivono un carattere sulla tua tastiera, che smetterebbe di funzionare in tutte le app. Prova altri tasti.", "reserved": "Il sistema o la maggior parte delle app usano questi tasti. Prova altri tasti." }, + "compose_windows": { + "more_formatting": "Altre opzioni di formattazione", + "offline": "Offline", + "offline_hint": "Per inviare serve una connessione.", + "offline_hint_outbox": "Quello che invii ora aspetta nella posta in uscita e parte quando torni online.", + "offline_send_saved": "Sei offline. Il messaggio è salvato nelle Bozze.", + "offline_send_unsaved": "Sei offline. Il messaggio resta qui e viene salvato nelle Bozze quando torni online." + }, "desktopReminders": { "generic_title": "Promemoria", "snooze": "Rimanda di {count} min", diff --git a/locales/ja/common.json b/locales/ja/common.json index 8fba56594..fd47c9e9b 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -7109,6 +7109,14 @@ "types_character": "このキーはお使いのキーボードで文字を入力します。割り当てると、すべてのアプリでその文字を入力できなくなります。別のキーをお試しください。", "reserved": "このキーはシステムまたは多くのアプリが使用しています。別のキーをお試しください。" }, + "compose_windows": { + "more_formatting": "その他の書式", + "offline": "オフライン", + "offline_hint": "送信には接続が必要です。", + "offline_hint_outbox": "今送信したものは送信トレイで待機し、オンラインに戻ると送信されます。", + "offline_send_saved": "オフラインです。メッセージは下書きに保存されています。", + "offline_send_unsaved": "オフラインです。メッセージはここに残り、オンラインに戻ると下書きに保存されます。" + }, "desktopReminders": { "generic_title": "リマインダー", "snooze": "{count} 分後に再通知", diff --git a/locales/ko/common.json b/locales/ko/common.json index 70a3b58bf..a6fde0874 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -7109,6 +7109,14 @@ "types_character": "이 키는 사용 중인 키보드에서 문자를 입력하며, 지정하면 모든 앱에서 그 문자를 입력할 수 없게 됩니다. 다른 키를 사용해 보세요.", "reserved": "시스템 또는 대부분의 앱이 이 키를 사용합니다. 다른 키를 사용해 보세요." }, + "compose_windows": { + "more_formatting": "추가 서식", + "offline": "오프라인", + "offline_hint": "보내려면 연결이 필요합니다.", + "offline_hint_outbox": "지금 보내는 메일은 보낼편지함에서 기다리다가 다시 온라인이 되면 전송됩니다.", + "offline_send_saved": "오프라인 상태입니다. 메시지는 임시보관함에 저장되어 있습니다.", + "offline_send_unsaved": "오프라인 상태입니다. 메시지는 여기에 남아 있으며 다시 온라인이 되면 임시보관함에 저장됩니다." + }, "desktopReminders": { "generic_title": "알림", "snooze": "{count}분 후 다시 알림", diff --git a/locales/lv/common.json b/locales/lv/common.json index 19f579b5e..4b822a22c 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -7109,6 +7109,14 @@ "types_character": "Šie taustiņi jūsu tastatūrā raksta rakstzīmi, kas pārstātu darboties visās lietotnēs. Izmēģiniet citus taustiņus.", "reserved": "Šos taustiņus izmanto sistēma vai lielākā daļa lietotņu. Izmēģiniet citus taustiņus." }, + "compose_windows": { + "more_formatting": "Vairāk formatēšanas", + "offline": "Bezsaistē", + "offline_hint": "Sūtīšanai vajadzīgs savienojums.", + "offline_hint_outbox": "Ko sūtāt tagad, gaida izsūtnē un tiek nosūtīts, kad atkal esat tiešsaistē.", + "offline_send_saved": "Jūs esat bezsaistē. Ziņojums ir saglabāts melnrakstos.", + "offline_send_unsaved": "Jūs esat bezsaistē. Ziņojums paliek šeit un tiks saglabāts melnrakstos, kad atkal būsiet tiešsaistē." + }, "desktopReminders": { "generic_title": "Atgādinājums", "snooze": "Atlikt par {count} min", diff --git a/locales/mn/common.json b/locales/mn/common.json index 22bbfaa7e..a0a492b57 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -7109,6 +7109,14 @@ "types_character": "Эдгээр товч таны гар дээр тэмдэгт бичдэг тул тэр тэмдэгт бүх аппад ажиллахаа болино. Өөр товч туршина уу.", "reserved": "Систем эсвэл ихэнх апп эдгээр товчийг ашигладаг. Өөр товч туршина уу." }, + "compose_windows": { + "more_formatting": "Бусад хэлбэржүүлэлт", + "offline": "Офлайн", + "offline_hint": "Илгээхэд холболт хэрэгтэй.", + "offline_hint_outbox": "Одоо илгээсэн зүйл гарах хайрцагт хүлээж, онлайн болмогц илгээгдэнэ.", + "offline_send_saved": "Та офлайн байна. Мессеж нооргонд хадгалагдсан.", + "offline_send_unsaved": "Та офлайн байна. Мессеж энд үлдэж, онлайн болмогц нооргонд хадгалагдана." + }, "desktopReminders": { "generic_title": "Сануулга", "snooze": "{count} минут хойшлуулах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 7d0f99430..64bd1d76f 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -7109,6 +7109,14 @@ "types_character": "Disse tastene skriver et tegn på tastaturet ditt, og det ville slutte å virke i alle apper. Prøv andre taster.", "reserved": "Systemet ditt eller de fleste apper bruker disse tastene. Prøv andre taster." }, + "compose_windows": { + "more_formatting": "Mer formatering", + "offline": "Frakoblet", + "offline_hint": "Du må ha tilkobling for å sende.", + "offline_hint_outbox": "Det du sender nå, venter i utboksen og sendes når du er på nett igjen.", + "offline_send_saved": "Du er frakoblet. Meldingen er lagret i Utkast.", + "offline_send_unsaved": "Du er frakoblet. Meldingen blir her og lagres i Utkast når du er på nett igjen." + }, "desktopReminders": { "generic_title": "Påminnelse", "snooze": "Utsett {count} min", diff --git a/locales/nl/common.json b/locales/nl/common.json index d7634a009..8d98a7a6b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -7109,6 +7109,14 @@ "types_character": "Deze toetsen typen een teken op je toetsenbord, en dat zou in geen enkele app meer werken. Probeer andere toetsen.", "reserved": "Je systeem of de meeste apps gebruiken deze toetsen. Probeer andere toetsen." }, + "compose_windows": { + "more_formatting": "Meer opmaak", + "offline": "Offline", + "offline_hint": "Voor verzenden is een verbinding nodig.", + "offline_hint_outbox": "Wat je nu verstuurt, wacht in Postvak UIT en gaat weg zodra je weer online bent.", + "offline_send_saved": "Je bent offline. Het bericht is opgeslagen in Concepten.", + "offline_send_unsaved": "Je bent offline. Het bericht blijft hier en wordt in Concepten opgeslagen zodra je weer online bent." + }, "desktopReminders": { "generic_title": "Herinnering", "snooze": "{count} min uitstellen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 8af14a9e8..517637d99 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -7109,6 +7109,14 @@ "types_character": "Te klawisze wpisują znak na Twojej klawiaturze i przestałby on działać we wszystkich aplikacjach. Wypróbuj inne klawisze.", "reserved": "Te klawisze są używane przez system lub większość aplikacji. Wypróbuj inne klawisze." }, + "compose_windows": { + "more_formatting": "Więcej formatowania", + "offline": "Offline", + "offline_hint": "Do wysłania potrzebne jest połączenie.", + "offline_hint_outbox": "To, co teraz wyślesz, czeka w skrzynce nadawczej i wyjdzie, gdy wrócisz do sieci.", + "offline_send_saved": "Jesteś offline. Wiadomość jest zapisana w Szkicach.", + "offline_send_unsaved": "Jesteś offline. Wiadomość zostaje tutaj i zostanie zapisana w Szkicach, gdy wrócisz do sieci." + }, "desktopReminders": { "generic_title": "Przypomnienie", "snooze": "Odłóż o {count} min", diff --git a/locales/pt/common.json b/locales/pt/common.json index 5111a4013..192489a92 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -7109,6 +7109,14 @@ "types_character": "Estas teclas escrevem um carácter no seu teclado, e ele deixaria de funcionar em todas as aplicações. Experimente outras teclas.", "reserved": "O seu sistema ou a maioria das aplicações usam estas teclas. Experimente outras teclas." }, + "compose_windows": { + "more_formatting": "Mais formatação", + "offline": "Offline", + "offline_hint": "Enviar precisa de ligação.", + "offline_hint_outbox": "O que enviar agora espera na caixa de saída e é enviado quando voltar a estar online.", + "offline_send_saved": "Está offline. A mensagem está guardada em Rascunhos.", + "offline_send_unsaved": "Está offline. A mensagem fica aqui e é guardada em Rascunhos quando voltar a estar online." + }, "desktopReminders": { "generic_title": "Lembrete", "snooze": "Adiar {count} min", diff --git a/locales/ro/common.json b/locales/ro/common.json index 733283ce6..fb9a52ef6 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -7109,6 +7109,14 @@ "types_character": "Aceste taste scriu un caracter pe tastatura ta, iar el n-ar mai funcționa în nicio aplicație. Încearcă alte taste.", "reserved": "Sistemul sau majoritatea aplicațiilor folosesc aceste taste. Încearcă alte taste." }, + "compose_windows": { + "more_formatting": "Mai multe opțiuni de formatare", + "offline": "Offline", + "offline_hint": "Pentru trimitere e nevoie de conexiune.", + "offline_hint_outbox": "Ce trimiteți acum așteaptă în Mesaje de trimis și pleacă atunci când sunteți din nou online.", + "offline_send_saved": "Sunteți offline. Mesajul este salvat în Ciorne.", + "offline_send_unsaved": "Sunteți offline. Mesajul rămâne aici și este salvat în Ciorne când sunteți din nou online." + }, "desktopReminders": { "generic_title": "Memento", "snooze": "Amână {count} min", diff --git a/locales/ru/common.json b/locales/ru/common.json index c63313d37..c6f29fd16 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -7109,6 +7109,14 @@ "types_character": "На вашей клавиатуре эти клавиши вводят символ, и он перестал бы работать во всех приложениях. Попробуйте другие клавиши.", "reserved": "Эти клавиши использует система или большинство приложений. Попробуйте другие клавиши." }, + "compose_windows": { + "more_formatting": "Дополнительное форматирование", + "offline": "Не в сети", + "offline_hint": "Для отправки нужно подключение.", + "offline_hint_outbox": "То, что вы отправите сейчас, подождёт в исходящих и уйдёт, когда вы снова будете в сети.", + "offline_send_saved": "Вы не в сети. Сообщение сохранено в черновиках.", + "offline_send_unsaved": "Вы не в сети. Сообщение останется здесь и сохранится в черновиках, когда вы снова будете в сети." + }, "desktopReminders": { "generic_title": "Напоминание", "snooze": "Отложить на {count} мин", diff --git a/locales/sk/common.json b/locales/sk/common.json index 070aec513..91eaacf94 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -7109,6 +7109,14 @@ "types_character": "Tieto klávesy na vašej klávesnici píšu znak, ktorý by potom nefungoval v žiadnej aplikácii. Skúste iné klávesy.", "reserved": "Tieto klávesy používa systém alebo väčšina aplikácií. Skúste iné klávesy." }, + "compose_windows": { + "more_formatting": "Ďalšie formátovanie", + "offline": "Offline", + "offline_hint": "Na odoslanie treba pripojenie.", + "offline_hint_outbox": "Čo teraz odošlete, počká medzi správami na odoslanie a odíde, keď budete znova online.", + "offline_send_saved": "Ste offline. Správa je uložená v Konceptoch.", + "offline_send_unsaved": "Ste offline. Správa tu zostane a uloží sa do Konceptov, keď budete znova online." + }, "desktopReminders": { "generic_title": "Pripomienka", "snooze": "Odložiť o {count} min", diff --git a/locales/th/common.json b/locales/th/common.json index 7d9dd0b0e..f065a2157 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -7117,6 +7117,14 @@ "types_character": "ปุ่มเหล่านี้พิมพ์อักขระบนแป้นพิมพ์ของคุณ และอักขระนั้นจะใช้ไม่ได้ในทุกแอป ลองใช้ปุ่มอื่น", "reserved": "ระบบหรือแอปส่วนใหญ่ใช้ปุ่มเหล่านี้ ลองใช้ปุ่มอื่น" }, + "compose_windows": { + "more_formatting": "การจัดรูปแบบเพิ่มเติม", + "offline": "ออฟไลน์", + "offline_hint": "ต้องเชื่อมต่อจึงจะส่งได้", + "offline_hint_outbox": "สิ่งที่คุณส่งตอนนี้จะรออยู่ในกล่องขาออกและถูกส่งเมื่อคุณกลับมาออนไลน์", + "offline_send_saved": "คุณออฟไลน์อยู่ ข้อความถูกบันทึกไว้ในจดหมายร่างแล้ว", + "offline_send_unsaved": "คุณออฟไลน์อยู่ ข้อความจะอยู่ที่นี่และถูกบันทึกในจดหมายร่างเมื่อคุณกลับมาออนไลน์" + }, "desktopReminders": { "generic_title": "การแจ้งเตือน", "snooze": "เลื่อน {count} นาที", diff --git a/locales/tr/common.json b/locales/tr/common.json index 7cae9a10d..a57478d98 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -7109,6 +7109,14 @@ "types_character": "Bu tuşlar klavyenizde bir karakter yazar ve bu karakter hiçbir uygulamada çalışmaz olur. Başka tuşlar deneyin.", "reserved": "Bu tuşları sisteminiz veya çoğu uygulama kullanıyor. Başka tuşlar deneyin." }, + "compose_windows": { + "more_formatting": "Daha fazla biçimlendirme", + "offline": "Çevrimdışı", + "offline_hint": "Göndermek için bağlantı gerekir.", + "offline_hint_outbox": "Şimdi gönderdikleriniz giden kutusunda bekler ve yeniden çevrimiçi olduğunuzda gönderilir.", + "offline_send_saved": "Çevrimdışısınız. İleti Taslaklar'a kaydedildi.", + "offline_send_unsaved": "Çevrimdışısınız. İleti burada kalır ve yeniden çevrimiçi olduğunuzda Taslaklar'a kaydedilir." + }, "desktopReminders": { "generic_title": "Anımsatıcı", "snooze": "{count} dk ertele", diff --git a/locales/uk/common.json b/locales/uk/common.json index 2fe8cc2da..78662b5a1 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -7109,6 +7109,14 @@ "types_character": "На вашій клавіатурі ці клавіші вводять символ, і він перестав би працювати в усіх застосунках. Спробуйте інші клавіші.", "reserved": "Ці клавіші використовує система або більшість застосунків. Спробуйте інші клавіші." }, + "compose_windows": { + "more_formatting": "Додаткове форматування", + "offline": "Не в мережі", + "offline_hint": "Для надсилання потрібне підключення.", + "offline_hint_outbox": "Те, що ви надішлете зараз, чекатиме у вихідних і піде, коли ви знову будете в мережі.", + "offline_send_saved": "Ви не в мережі. Повідомлення збережено в чернетках.", + "offline_send_unsaved": "Ви не в мережі. Повідомлення залишиться тут і збережеться в чернетках, коли ви знову будете в мережі." + }, "desktopReminders": { "generic_title": "Нагадування", "snooze": "Відкласти на {count} хв", diff --git a/locales/vi/common.json b/locales/vi/common.json index b9ffe90df..bcf9b2f50 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -7121,6 +7121,14 @@ "types_character": "Các phím này gõ một ký tự trên bàn phím của bạn, và ký tự đó sẽ không dùng được trong mọi ứng dụng. Hãy thử phím khác.", "reserved": "Hệ thống hoặc hầu hết ứng dụng dùng các phím này. Hãy thử phím khác." }, + "compose_windows": { + "more_formatting": "Thêm định dạng", + "offline": "Ngoại tuyến", + "offline_hint": "Cần có kết nối để gửi.", + "offline_hint_outbox": "Thư bạn gửi bây giờ sẽ chờ trong Hộp thư đi và được gửi khi bạn trực tuyến trở lại.", + "offline_send_saved": "Bạn đang ngoại tuyến. Thư đã được lưu trong Bản nháp.", + "offline_send_unsaved": "Bạn đang ngoại tuyến. Thư vẫn ở đây và sẽ được lưu vào Bản nháp khi bạn trực tuyến trở lại." + }, "desktopReminders": { "generic_title": "Lời nhắc", "snooze": "Báo lại sau {count} phút", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index f6a9b6eb3..845487676 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -7109,6 +7109,14 @@ "types_character": "這些鍵在您的鍵盤上會輸入一個字元,占用後該字元在所有應用程式中都無法輸入。請試試其他按鍵。", "reserved": "系統或大多數應用程式正在使用這些鍵。請試試其他按鍵。" }, + "compose_windows": { + "more_formatting": "更多格式", + "offline": "離線", + "offline_hint": "寄出需要連線。", + "offline_hint_outbox": "你現在寄出的郵件會在寄件匣中等待,恢復連線後送出。", + "offline_send_saved": "你已離線。郵件已儲存在草稿中。", + "offline_send_unsaved": "你已離線。郵件會留在這裡,恢復連線後儲存到草稿。" + }, "desktopReminders": { "generic_title": "提醒", "snooze": "{count} 分鐘後再提醒", diff --git a/locales/zh/common.json b/locales/zh/common.json index e8c040237..12cfc890b 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -7109,6 +7109,14 @@ "types_character": "这些键在您的键盘上会输入一个字符,占用后该字符在所有应用中都无法输入。请试试其他按键。", "reserved": "系统或大多数应用在使用这些键。请试试其他按键。" }, + "compose_windows": { + "more_formatting": "更多格式", + "offline": "离线", + "offline_hint": "发送需要联网。", + "offline_hint_outbox": "你现在发送的邮件会在发件箱中等待,恢复联网后发出。", + "offline_send_saved": "你已离线。邮件已保存在草稿中。", + "offline_send_unsaved": "你已离线。邮件会留在这里,恢复联网后保存到草稿。" + }, "desktopReminders": { "generic_title": "提醒", "snooze": "{count} 分钟后再提醒",