signal-desktop/app/spell_check.ts

223 lines
7 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2020 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
import type { BrowserWindow } from 'electron';
import { Menu, clipboard, nativeImage } from 'electron';
import * as LocaleMatcher from '@formatjs/intl-localematcher';
import { maybeParseUrl } from '../ts/util/url';
import type { MenuListType } from '../ts/types/menu';
import type { LocalizerType } from '../ts/types/Util';
2023-05-17 17:19:27 +00:00
import { strictAssert } from '../ts/util/assert';
import type { LoggerType } from '../ts/types/Logging';
2024-07-12 18:44:26 +00:00
import { handleAttachmentRequest } from './attachment_channel';
2023-05-17 17:19:27 +00:00
export const FAKE_DEFAULT_LOCALE = 'en-x-ignore'; // -x- is an extension space for attaching other metadata to the locale
strictAssert(
new Intl.Locale(FAKE_DEFAULT_LOCALE).toString() === FAKE_DEFAULT_LOCALE,
"Ensure Intl doesn't change our fake locale ever"
);
export function getLanguages(
preferredSystemLocales: ReadonlyArray<string>,
availableLocales: ReadonlyArray<string>,
defaultLocale: string
): Array<string> {
const matchedLocales = [];
preferredSystemLocales.forEach(preferredSystemLocale => {
const matchedLocale = LocaleMatcher.match(
[preferredSystemLocale],
availableLocales as Array<string>, // bad types
2023-05-17 17:19:27 +00:00
// We don't want to fallback to the default locale right away in case we might
// match some other locales first.
//
// However, we do want to match the default locale in case the user's locales
// actually matches it.
//
// This fake locale allows us to reliably filter it out within the loop.
FAKE_DEFAULT_LOCALE,
{ algorithm: 'best fit' }
);
2023-05-17 17:19:27 +00:00
if (matchedLocale !== FAKE_DEFAULT_LOCALE) {
matchedLocales.push(matchedLocale);
}
});
if (matchedLocales.length === 0) {
matchedLocales.push(defaultLocale);
}
return matchedLocales;
}
2020-03-20 21:00:11 +00:00
export const setup = (
browserWindow: BrowserWindow,
preferredSystemLocales: ReadonlyArray<string>,
localeOverride: string | null,
i18n: LocalizerType,
logger: LoggerType
): void => {
2020-03-20 21:00:11 +00:00
const { session } = browserWindow.webContents;
session.on('spellcheck-dictionary-download-begin', (_event, lang) => {
logger.info('spellcheck: dictionary download begin:', lang);
});
session.on('spellcheck-dictionary-download-failure', (_event, lang) => {
logger.error('spellcheck: dictionary download failure:', lang);
});
session.on('spellcheck-dictionary-download-success', (_event, lang) => {
logger.info('spellcheck: dictionary download success:', lang);
});
session.on('spellcheck-dictionary-initialized', (_event, lang) => {
logger.info('spellcheck: dictionary initialized:', lang);
});
// Locale override should be combined with other preferences rather than
// replace them entirely.
const combinedLocales =
localeOverride != null
? [localeOverride, ...preferredSystemLocales]
: preferredSystemLocales;
const availableLocales = session.availableSpellCheckerLanguages;
const languages = getLanguages(combinedLocales, availableLocales, 'en');
console.log('spellcheck: user locales:', combinedLocales);
console.log(
'spellcheck: available spellchecker languages:',
availableLocales
);
console.log('spellcheck: setting languages to:', languages);
2020-03-20 21:00:11 +00:00
session.setSpellCheckerLanguages(languages);
browserWindow.webContents.on('context-menu', (_event, params) => {
const { editFlags } = params;
const isMisspelled = Boolean(params.misspelledWord);
const isLink = Boolean(params.linkURL);
const isImage =
params.mediaType === 'image' && params.hasImageContents && params.srcURL;
const showMenu =
params.isEditable || editFlags.canCopy || isLink || isImage;
2020-03-20 21:00:11 +00:00
// Popup editor menu
if (showMenu) {
const template: MenuListType = [];
2020-03-20 21:00:11 +00:00
if (isMisspelled) {
if (params.dictionarySuggestions.length > 0) {
template.push(
...params.dictionarySuggestions.map(label => ({
label,
click: () => {
browserWindow.webContents.replaceMisspelling(label);
},
}))
);
} else {
template.push({
2023-03-30 00:03:25 +00:00
label: i18n('icu:contextMenuNoSuggestions'),
2020-03-20 21:00:11 +00:00
enabled: false,
});
}
template.push({ type: 'separator' });
}
if (params.isEditable) {
if (editFlags.canUndo) {
2023-03-30 00:03:25 +00:00
template.push({ label: i18n('icu:editMenuUndo'), role: 'undo' });
2020-03-20 21:00:11 +00:00
}
// This is only ever `true` if undo was triggered via the context menu
// (not ctrl/cmd+z)
if (editFlags.canRedo) {
2023-03-30 00:03:25 +00:00
template.push({ label: i18n('icu:editMenuRedo'), role: 'redo' });
2020-03-20 21:00:11 +00:00
}
if (editFlags.canUndo || editFlags.canRedo) {
template.push({ type: 'separator' });
}
if (editFlags.canCut) {
2023-03-30 00:03:25 +00:00
template.push({ label: i18n('icu:editMenuCut'), role: 'cut' });
2020-03-20 21:00:11 +00:00
}
}
if (editFlags.canCopy || isLink || isImage) {
let click;
let label;
if (isLink) {
click = () => {
clipboard.writeText(params.linkURL);
};
2023-03-30 00:03:25 +00:00
label = i18n('icu:contextMenuCopyLink');
} else if (isImage) {
2024-07-12 18:44:26 +00:00
click = async () => {
const parsedSrcUrl = maybeParseUrl(params.srcURL);
2024-07-12 18:44:26 +00:00
if (!parsedSrcUrl || parsedSrcUrl.protocol !== 'attachment:') {
return;
}
const urlIsViewOnce =
parsedSrcUrl.searchParams.get('disposition') === 'temporary';
if (urlIsViewOnce) {
return;
}
2024-07-12 18:44:26 +00:00
const req = new Request(parsedSrcUrl, {
method: 'GET',
});
try {
const res = await handleAttachmentRequest(req);
if (!res.ok) {
return;
}
const image = nativeImage.createFromBuffer(
Buffer.from(await res.arrayBuffer())
);
clipboard.writeImage(image);
} catch (error) {
logger.error('Failed to load image', error);
}
};
2023-03-30 00:03:25 +00:00
label = i18n('icu:contextMenuCopyImage');
} else {
2023-03-30 00:03:25 +00:00
label = i18n('icu:editMenuCopy');
}
2020-03-20 21:00:11 +00:00
template.push({
label,
role: isLink || isImage ? undefined : 'copy',
click,
2020-03-20 21:00:11 +00:00
});
}
if (editFlags.canPaste && !isImage) {
2023-03-30 00:03:25 +00:00
template.push({ label: i18n('icu:editMenuPaste'), role: 'paste' });
2020-03-20 21:00:11 +00:00
}
if (editFlags.canPaste && !isImage) {
2020-03-20 21:00:11 +00:00
template.push({
2023-03-30 00:03:25 +00:00
label: i18n('icu:editMenuPasteAndMatchStyle'),
2020-03-20 21:00:11 +00:00
role: 'pasteAndMatchStyle',
});
}
// Only enable select all in editors because select all in non-editors
// results in all the UI being selected
if (editFlags.canSelectAll && params.isEditable) {
template.push({
2023-03-30 00:03:25 +00:00
label: i18n('icu:editMenuSelectAll'),
role: 'selectAll',
2020-03-20 21:00:11 +00:00
});
}
const menu = Menu.buildFromTemplate(template);
menu.popup({
window: browserWindow,
});
2020-03-20 21:00:11 +00:00
}
});
};