mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
Merge branch 'feat/do-ux-compose-windows' into feat/do-ux-compose-fixes
# Conflicts: # components/email/email-composer.tsx
This commit is contained in:
@@ -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<object>()),
|
||||
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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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: '<p>Hello there</p>',
|
||||
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<HTMLElement>('[data-composer-footer]')!;
|
||||
|
||||
describe('the composer while offline', () => {
|
||||
it('says "Offline" next to Send, and stops once the network is back', () => {
|
||||
render(<EmailComposer initialData={DATA} ownWindow={{}} />);
|
||||
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(<EmailComposer initialData={DATA} ownWindow={{}} onSend={onSend} />);
|
||||
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(<EmailComposer initialData={{ ...DATA, draftId: 'd1' }} ownWindow={{}} onSend={vi.fn()} />);
|
||||
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(<EmailComposer initialData={DATA} ownWindow={{}} onSend={onSend} />);
|
||||
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(<EmailComposer initialData={DATA} ownWindow={{}} onSend={vi.fn()} />);
|
||||
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(<EmailComposer initialData={DATA} ownWindow={{}} onSend={onSend} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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: '<p>Hello there</p>',
|
||||
showCc: false,
|
||||
showBcc: false,
|
||||
selectedIdentityId: 'id-me',
|
||||
subAddressTag: '',
|
||||
mode: 'compose' as const,
|
||||
draftId: null,
|
||||
};
|
||||
|
||||
const footer = () => document.querySelector<HTMLElement>('[data-composer-footer]')!;
|
||||
const header = () => document.querySelector<HTMLElement>('[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(<EmailComposer initialData={DATA} ownWindow={{}} />);
|
||||
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(<EmailComposer initialData={DATA} ownWindow={{}} />);
|
||||
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(<EmailComposer initialData={DATA} ownWindow={{}} />);
|
||||
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(<EmailComposer initialData={DATA} />);
|
||||
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(<EmailComposer initialData={DATA} onClose={onClose} />);
|
||||
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(<EmailComposer initialData={DATA} ownWindow={{}} />);
|
||||
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(<EmailComposer initialData={DATA} />);
|
||||
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(<EmailComposer initialData={DATA} />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<ResizeObserverCallback>();
|
||||
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(<RichTextEditor content="<p>Hello</p>" onChange={() => {}} 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<HTMLElement>('[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));
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLDivElement>) => {
|
||||
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' && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Save className="w-3 h-3 animate-pulse" />
|
||||
<span className="hidden md:inline">{t('saving')}</span>
|
||||
<span className={phoneLayout ? "hidden md:inline" : undefined}>{t('saving')}</span>
|
||||
</div>
|
||||
)}
|
||||
{saveStatus === 'saved' && (
|
||||
<div className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<Check className="w-3 h-3" />
|
||||
<span className="hidden md:inline">{savedOnDevice ? tOfflineDrafts('saved_on_device') : t('draft_saved')}</span>
|
||||
<span className={phoneLayout ? "hidden md:inline" : undefined}>{savedOnDevice ? tOfflineDrafts('saved_on_device') : t('draft_saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<div className="flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
|
||||
<X className="w-3 h-3" />
|
||||
<span className="hidden md:inline">{t('save_failed')}</span>
|
||||
<span className={phoneLayout ? "hidden md:inline" : undefined}>{t('save_failed')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile: keep scheduled send reachable without consuming footer space. */}
|
||||
{composerClient?.hasDelayedSend() || hasSendMenuActions ? (
|
||||
{phoneLayout && (composerClient?.hasDelayedSend() || hasSendMenuActions ? (
|
||||
<div className="relative inline-flex md:hidden">
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
onClick={() => sendOrSayOffline()}
|
||||
disabled={!canSend || isSending || isSavingAndClosing}
|
||||
title={getSendTooltip()}
|
||||
size="sm"
|
||||
@@ -4906,7 +4957,7 @@ export function EmailComposer({
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
onClick={() => sendOrSayOffline()}
|
||||
disabled={!canSend || isSending || isSavingAndClosing}
|
||||
title={getSendTooltip()}
|
||||
size="sm"
|
||||
@@ -4916,14 +4967,26 @@ export function EmailComposer({
|
||||
<Send className="w-4 h-4 me-1.5" />
|
||||
{t('send')}
|
||||
</Button>
|
||||
)}
|
||||
))}
|
||||
{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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMaximized((v) => !v)}
|
||||
className="hidden md:grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label={maximized ? t('restore') : t('maximize')}
|
||||
title={maximized ? t('restore') : t('maximize')}
|
||||
data-testid="composer-maximize"
|
||||
>
|
||||
{maximized ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
{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. */}
|
||||
<IconButton
|
||||
onClick={() => 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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{offlineSendNotice && !browserOnline && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t bg-muted px-4 py-2 text-sm text-foreground" data-testid="composer-offline-notice">
|
||||
<CloudOff className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0">{tComposeWindows(offlineSendNotice === 'saved' ? 'offline_send_saved' : 'offline_send_unsaved')}</span>
|
||||
{offlineAllowed(composingAccountId ?? null) && (
|
||||
<a
|
||||
href={appPath(buildSettingsPath('backup', 'offline'))}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="font-medium text-primary underline underline-offset-2"
|
||||
>
|
||||
{tOffline('settings.keep_label')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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({
|
||||
<span className="hidden @[40rem]/composer-bar:inline text-sm">{t('attach')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{!plainTextMode && (
|
||||
{!plainTextMode && (mvWindow || phoneLayout) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -5732,6 +5812,13 @@ export function EmailComposer({
|
||||
|
||||
{/* Right side - Discard + Send (desktop) */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!browserOnline && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground" title={offlineHint} data-testid="composer-offline">
|
||||
<CloudOff className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{tComposeWindows('offline')}
|
||||
<span className="sr-only">{offlineHint}</span>
|
||||
</span>
|
||||
)}
|
||||
{mvWindow ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -5752,17 +5839,17 @@ export function EmailComposer({
|
||||
data-testid="composer-discard"
|
||||
aria-label={t('discard')}
|
||||
title={t('discard')}
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10 md:@max-[26rem]/composer-bar:px-2.5 max-md:@max-[23rem]/composer-bar:px-2.5"
|
||||
className="px-2.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 max-md:h-11 max-md:w-11"
|
||||
>
|
||||
{/* Next to Send in a narrow composer the word runs over the tools on the left. */}
|
||||
<Trash2 className="hidden w-4 h-4 max-md:@max-[23rem]/composer-bar:block md:block md:@[26rem]/composer-bar:hidden" aria-hidden="true" />
|
||||
<span className="max-md:@max-[23rem]/composer-bar:hidden md:hidden md:@[26rem]/composer-bar:inline">{t('discard')}</span>
|
||||
{/* A bin left of Send in every composer: the word ran over the tools in a narrow one. */}
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||
<span className="sr-only">{t('discard')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{composerClient?.hasDelayedSend() || hasSendMenuActions ? (
|
||||
<div data-mv-send={mvWindow ? "" : undefined} className="relative hidden md:inline-flex">
|
||||
<div data-mv-send={mvWindow ? "" : undefined} className={cn("relative", phoneLayout ? "hidden md:inline-flex" : "inline-flex")}>
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
onClick={() => sendOrSayOffline()}
|
||||
disabled={!canSend || isSending || isSavingAndClosing}
|
||||
title={getSendTooltip()}
|
||||
data-testid="composer-send"
|
||||
@@ -5787,12 +5874,12 @@ export function EmailComposer({
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
onClick={() => sendOrSayOffline()}
|
||||
disabled={!canSend || isSending || isSavingAndClosing}
|
||||
title={getSendTooltip()}
|
||||
data-testid="composer-send"
|
||||
data-mv-send={mvWindow ? "" : undefined}
|
||||
className="hidden md:inline-flex"
|
||||
className={phoneLayout ? "hidden md:inline-flex" : "inline-flex"}
|
||||
>
|
||||
<Send className="w-4 h-4 me-2" />
|
||||
{t('send')}
|
||||
|
||||
@@ -70,8 +70,56 @@ import {
|
||||
Rows3,
|
||||
Columns3,
|
||||
ImageIcon,
|
||||
Ellipsis,
|
||||
} from "@/components/icons";
|
||||
|
||||
/**
|
||||
* Groups of the formatting toolbar that move behind "More formatting" when one
|
||||
* row is too narrow for all of them, the least used first. Each with its
|
||||
* buttons and the separators that go with it.
|
||||
*/
|
||||
const FOLDABLE_GROUPS = [
|
||||
{ id: "history", buttons: 3, separators: 2 },
|
||||
{ id: "align", buttons: 3, separators: 1 },
|
||||
{ id: "headings", buttons: 2, separators: 1 },
|
||||
{ id: "table", buttons: 1, separators: 0 },
|
||||
{ id: "blocks", buttons: 2, separators: 0 },
|
||||
] as const;
|
||||
type FoldableGroup = (typeof FOLDABLE_GROUPS)[number]["id"];
|
||||
/**
|
||||
* Always in the row: font size, bold, italic, underline, strikethrough, the
|
||||
* two colours, the lists, link, an image's description and More (12), with
|
||||
* their 3 separators.
|
||||
*/
|
||||
const FIXED_TOOLBAR = { buttons: 12, separators: 3 };
|
||||
|
||||
/**
|
||||
* How many of FOLDABLE_GROUPS go behind "More formatting" for one row of
|
||||
* `width` px. 0 for a toolbar that isn't laid out (hidden, or not measured).
|
||||
*/
|
||||
export function formattingFoldCount(width: number, sizes: { button: number; gap: number; separator: number; directionButton?: boolean }): number {
|
||||
if (!(width > 0)) return 0;
|
||||
const span = (buttons: number, separators: number) => buttons * (sizes.button + sizes.gap) + separators * (sizes.separator + sizes.gap);
|
||||
const groups = FOLDABLE_GROUPS.map((group) => span(group.buttons + (group.id === "align" && sizes.directionButton ? 1 : 0), group.separators));
|
||||
let needed = span(FIXED_TOOLBAR.buttons, FIXED_TOOLBAR.separators) + groups.reduce((sum, group) => sum + group, 0);
|
||||
// All of it fits without the More button.
|
||||
if (needed - (sizes.button + sizes.gap) <= width + 1) return 0;
|
||||
for (let count = 1; count <= groups.length; count++) {
|
||||
needed -= groups[count - 1];
|
||||
if (needed <= width + 1) return count;
|
||||
}
|
||||
return groups.length;
|
||||
}
|
||||
|
||||
/** The toolbar's inner width and the sizes of what is in it, in px. */
|
||||
function measureFormattingToolbar(toolbar: HTMLElement, directionButton: boolean): number {
|
||||
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
const style = getComputedStyle(toolbar);
|
||||
const width = toolbar.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0);
|
||||
const button = toolbar.querySelector<HTMLElement>(":scope > [data-toolbar-item], :scope > div > [data-toolbar-item]")?.offsetWidth || 1.75 * rem;
|
||||
return formattingFoldCount(width, { button, gap: 0.125 * rem, separator: 1 + 0.25 * rem, directionButton });
|
||||
}
|
||||
|
||||
export interface InlineImageUpload {
|
||||
src: string;
|
||||
cid?: string;
|
||||
@@ -699,6 +747,7 @@ export function RichTextEditor({
|
||||
}, [editor, linkDialog]);
|
||||
|
||||
const tToolbar = useTranslations("email_composer.toolbar");
|
||||
const tWindows = useTranslations("compose_windows");
|
||||
const tCommon = useTranslations("common");
|
||||
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -717,11 +766,13 @@ export function RichTextEditor({
|
||||
// focus last keeps it, the arrows, Home and End move inside.
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const rovingItemRef = useRef<string | null>(null);
|
||||
// Buttons in the "More formatting" panel are the panel's, with its own keys.
|
||||
const inRow = (el: HTMLElement) => !el.closest("[data-toolbar-more-panel]");
|
||||
const toolbarItems = () =>
|
||||
Array.from(toolbarRef.current?.querySelectorAll<HTMLButtonElement>("[data-toolbar-item]") ?? [])
|
||||
.filter((el) => !el.disabled);
|
||||
.filter((el) => !el.disabled && inRow(el));
|
||||
const syncToolbarTabStops = (focus?: HTMLButtonElement) => {
|
||||
const all = Array.from(toolbarRef.current?.querySelectorAll<HTMLButtonElement>("[data-toolbar-item]") ?? []);
|
||||
const all = Array.from(toolbarRef.current?.querySelectorAll<HTMLButtonElement>("[data-toolbar-item]") ?? []).filter(inRow);
|
||||
const enabled = all.filter((el) => !el.disabled);
|
||||
const current = focus ?? enabled.find((el) => el.dataset.toolbarItem === rovingItemRef.current) ?? enabled[0];
|
||||
if (current) rovingItemRef.current = current.dataset.toolbarItem ?? null;
|
||||
@@ -740,6 +791,36 @@ export function RichTextEditor({
|
||||
return !!stop && document.activeElement === stop;
|
||||
};
|
||||
|
||||
// One row: what doesn't fit goes behind "More formatting" (a wrapped
|
||||
// second row took the body's room in a narrow composer).
|
||||
const [foldCount, setFoldCount] = useState(0);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const moreWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const hasEditor = !!editor;
|
||||
useLayoutEffect(() => {
|
||||
const toolbar = toolbarRef.current;
|
||||
if (!toolbar) return;
|
||||
const measure = () => setFoldCount(measureFormattingToolbar(toolbar, !!rtlEditingSupport));
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
measure();
|
||||
setMoreOpen(false);
|
||||
});
|
||||
observer.observe(toolbar);
|
||||
return () => observer.disconnect();
|
||||
}, [hasEditor, rtlEditingSupport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!moreOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (moreWrapperRef.current && !moreWrapperRef.current.contains(e.target as Node)) setMoreOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [moreOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fontSizeMenuOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -799,6 +880,15 @@ export function RichTextEditor({
|
||||
const altTarget = findAltTarget(editor, altRequest);
|
||||
const imageSelected = editor.isActive("image");
|
||||
|
||||
// A group that doesn't fit renders in the "More formatting" panel instead, in row order.
|
||||
const folded = new Set<FoldableGroup>(FOLDABLE_GROUPS.slice(0, foldCount).map((group) => group.id));
|
||||
const overflow: React.ReactNode[] = [];
|
||||
const place = (group: FoldableGroup, node: React.ReactNode) => {
|
||||
if (!folded.has(group)) return node;
|
||||
overflow.push(<React.Fragment key={group}>{node}</React.Fragment>);
|
||||
return null;
|
||||
};
|
||||
|
||||
const onToolbarKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// Keys inside a popover are the popover's.
|
||||
@@ -819,7 +909,7 @@ export function RichTextEditor({
|
||||
};
|
||||
const onToolbarFocus = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target instanceof HTMLButtonElement && target.hasAttribute("data-toolbar-item")) syncToolbarTabStops(target);
|
||||
if (target instanceof HTMLButtonElement && target.hasAttribute("data-toolbar-item") && inRow(target)) syncToolbarTabStops(target);
|
||||
};
|
||||
|
||||
const renderPalette = (current: string | null, apply: (color: string) => void, removeLabel: string, remove: () => void) => (
|
||||
@@ -1084,6 +1174,7 @@ export function RichTextEditor({
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
{place("headings", <>
|
||||
<ToolbarButton
|
||||
item="heading-1"
|
||||
pressed={editor.isActive("heading", { level: 1 })}
|
||||
@@ -1102,6 +1193,7 @@ export function RichTextEditor({
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarSeparator />
|
||||
</>)}
|
||||
|
||||
<ToolbarButton
|
||||
item="bullet-list"
|
||||
@@ -1119,6 +1211,7 @@ export function RichTextEditor({
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
{place("blocks", <>
|
||||
<ToolbarButton
|
||||
item="quote"
|
||||
pressed={editor.isActive("blockquote")}
|
||||
@@ -1135,9 +1228,11 @@ export function RichTextEditor({
|
||||
>
|
||||
<Code className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
</>)}
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
{place("align", <>
|
||||
<ToolbarButton
|
||||
item="align-left"
|
||||
pressed={editor.isActive({ textAlign: "left" })}
|
||||
@@ -1181,6 +1276,7 @@ export function RichTextEditor({
|
||||
)}
|
||||
|
||||
<ToolbarSeparator />
|
||||
</>)}
|
||||
|
||||
<ToolbarButton
|
||||
item="link"
|
||||
@@ -1205,7 +1301,7 @@ export function RichTextEditor({
|
||||
</ToolbarButton>
|
||||
)}
|
||||
|
||||
<div ref={tableWrapperRef} className="relative">
|
||||
{place("table", <div ref={tableWrapperRef} className="relative">
|
||||
<ToolbarButton
|
||||
ref={tableButtonRef}
|
||||
item="table"
|
||||
@@ -1257,8 +1353,9 @@ export function RichTextEditor({
|
||||
</ToolbarPopover>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>)}
|
||||
|
||||
{place("history", <>
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButton
|
||||
@@ -1287,6 +1384,35 @@ export function RichTextEditor({
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
</>)}
|
||||
|
||||
{overflow.length > 0 && (
|
||||
<div ref={moreWrapperRef} className="relative">
|
||||
<ToolbarButton
|
||||
ref={moreButtonRef}
|
||||
item="more-formatting"
|
||||
popup="dialog"
|
||||
expanded={moreOpen}
|
||||
onClick={() => setMoreOpen((v) => !v)}
|
||||
title={tWindows("more_formatting")}
|
||||
>
|
||||
<Ellipsis className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
{moreOpen && (
|
||||
<ToolbarPopover
|
||||
label={tWindows("more_formatting")}
|
||||
role="dialog"
|
||||
triggerRef={moreButtonRef}
|
||||
onClose={() => setMoreOpen(false)}
|
||||
className="p-1 flex w-max max-w-[16rem] flex-wrap items-center gap-0.5"
|
||||
>
|
||||
<div data-toolbar-more-panel="" className="contents">
|
||||
{overflow}
|
||||
</div>
|
||||
</ToolbarPopover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
|
||||
@@ -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<typeof useEmailStore.getState>;
|
||||
let sendAt: string;
|
||||
let cancelUndoSend: ReturnType<typeof vi.fn>;
|
||||
|
||||
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<unknown>; 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(<PopoutComposeApp />);
|
||||
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<PopoutMessage, { type: 'sent' }> | 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<PopoutMessage, { type: 'sent' }> | undefined;
|
||||
expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<PopoutHost />);
|
||||
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(<PopoutHost />);
|
||||
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(<PopoutHost />);
|
||||
|
||||
@@ -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<PopoutSentNotice["pendingUndoSend"]>;
|
||||
|
||||
/** 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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center" data-testid="popout-sending">
|
||||
<Send className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
||||
<p role="status" className="text-sm text-foreground">{tViewer("sending_in", { seconds })}</p>
|
||||
{!over && (
|
||||
<Button onClick={() => void undo()} disabled={busy} data-testid="popout-undo-send">
|
||||
{tViewer("undo_send")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HeldSend | null>(null);
|
||||
const [held, setHeld] = useState<HeldSend | null>(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 <SendingWithUndo held={held} onUndone={heldUndone} onGone={heldGone} />;
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={ComposerErrorFallback}>
|
||||
<EmailComposer
|
||||
@@ -405,6 +534,26 @@ export function PopoutComposeApp() {
|
||||
// draft is the newest there is. An ordinary reload remembers as it unloads.
|
||||
const [handoffId] = useState(() => (named && draftId && !rememberedComposePayload(named) ? null : named));
|
||||
const [payload, setPayload] = useState<ComposePopoutPayload | null>(null);
|
||||
// After Undo: the message back in Drafts, opened again in a new composer here.
|
||||
const [reopen, setReopen] = useState<ReopenTarget | null>(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 (
|
||||
<PopoutShell kind="compose" accountId={accountId}>
|
||||
{payload ? <PopoutComposer payload={payload} handoffId={handoffId} />
|
||||
{payload ? <PopoutComposer key={generation} payload={payload} handoffId={generation === 0 ? handoffId : null} onReopen={handleReopen} />
|
||||
: reopen ? <RestoredDraft draftId={reopen.draftId} accountId={reopen.accountId} ownerAccountId={reopen.ownerAccountId} onReady={reopened} />
|
||||
: handoffId ? <PopoutSkeleton label={t("opening")} />
|
||||
: draftId ? <RestoredDraft draftId={draftId} accountId={accountId} ownerAccountId={ownerAccountId} onReady={setPayload} />
|
||||
: <NewMessageGate onReady={setPayload} />}
|
||||
|
||||
@@ -60,8 +60,9 @@ function claimOnce(name: string, work: () => void | Promise<void>): 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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
+26
-5
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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} د",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} دقیقه",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} דק'",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} 分後に再通知",
|
||||
|
||||
@@ -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}분 후 다시 알림",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} минут хойшлуулах",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} мин",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} นาที",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} хв",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} 分鐘後再提醒",
|
||||
|
||||
@@ -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} 分钟后再提醒",
|
||||
|
||||
Reference in New Issue
Block a user