diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e32d7c7012d..53e0e9aca35 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -147,6 +147,14 @@ "message": "Set Up as Standalone Device", "description": "Only available on development modes, menu option to open up the standalone device setup sequence" }, + "contextMenuCopyLink": { + "message": "Copy Link", + "description": "Shown in the context menu for a link to indicate that the user can copy the link" + }, + "contextMenuNoSuggestions": { + "message": "No Suggestions", + "description": "Shown in the context menu for a misspelled word to indicate that there are no suggestions to replace the misspelled word" + }, "avatarMenuViewArchive": { "message": "View Archive", "description": "One of the menu options available in the Avatar Popup menu" @@ -1080,6 +1088,10 @@ "message": "Enable spell check of text entered in message composition box", "description": "Description of the media permission description" }, + "spellCheckDirty": { + "message": "You must restart Signal to apply your new settings", + "description": "Shown when the user changes their spellcheck setting to indicate that they must restart Signal." + }, "clearDataHeader": { "message": "Clear Data", "description": "Header in the settings dialog for the section dealing with data deletion" diff --git a/app/spell_check.js b/app/spell_check.js new file mode 100644 index 00000000000..98e9a3425d9 --- /dev/null +++ b/app/spell_check.js @@ -0,0 +1,102 @@ +/* global exports, require */ +/* eslint-disable strict */ + +const { Menu, clipboard } = require('electron'); +const osLocale = require('os-locale'); + +exports.setup = (browserWindow, messages) => { + const { session } = browserWindow.webContents; + const userLocale = osLocale.sync().replace(/_/g, '-'); + const userLocales = [userLocale, userLocale.split('-')[0]]; + const available = session.availableSpellCheckerLanguages; + const languages = userLocales.filter(l => available.includes(l)); + console.log(`spellcheck: user locale: ${userLocale}`); + console.log('spellcheck: available spellchecker languages: ', available); + console.log('spellcheck: setting languages to: ', languages); + session.setSpellCheckerLanguages(languages); + + browserWindow.webContents.on('context-menu', (_event, params) => { + const { editFlags } = params; + const isMisspelled = Boolean(params.misspelledWord); + const isLink = Boolean(params.linkURL); + const showMenu = params.isEditable || editFlags.canCopy || isLink; + + // Popup editor menu + if (showMenu) { + const template = []; + + if (isMisspelled) { + if (params.dictionarySuggestions.length > 0) { + template.push( + ...params.dictionarySuggestions.map(label => ({ + label, + click: () => { + browserWindow.webContents.replaceMisspelling(label); + }, + })) + ); + } else { + template.push({ + label: messages.contextMenuNoSuggestions.message, + enabled: false, + }); + } + template.push({ type: 'separator' }); + } + + if (params.isEditable) { + if (editFlags.canUndo) { + template.push({ label: messages.editMenuUndo.message, role: 'undo' }); + } + // This is only ever `true` if undo was triggered via the context menu + // (not ctrl/cmd+z) + if (editFlags.canRedo) { + template.push({ label: messages.editMenuRedo.message, role: 'redo' }); + } + if (editFlags.canUndo || editFlags.canRedo) { + template.push({ type: 'separator' }); + } + if (editFlags.canCut) { + template.push({ label: messages.editMenuCut.message, role: 'cut' }); + } + } + + if (editFlags.canCopy || isLink) { + template.push({ + label: isLink + ? messages.contextMenuCopyLink.message + : messages.editMenuCopy.message, + role: isLink ? undefined : 'copy', + click: isLink + ? () => { + clipboard.writeText(params.linkURL); + } + : undefined, + }); + } + + if (editFlags.canPaste) { + template.push({ label: messages.editMenuPaste.message, role: 'paste' }); + } + + if (editFlags.canPaste) { + template.push({ + label: messages.editMenuPasteAndMatchStyle.message, + 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({ + label: messages.editMenuSelectAll.message, + role: 'selectall', + }); + } + + const menu = Menu.buildFromTemplate(template); + menu.popup(browserWindow); + } + }); +}; diff --git a/js/background.js b/js/background.js index f7144a094a6..7e961e136a0 100644 --- a/js/background.js +++ b/js/background.js @@ -334,7 +334,6 @@ getSpellCheck: () => storage.get('spell-check', true), setSpellCheck: value => { storage.put('spell-check', value); - startSpellCheck(); }, // eslint-disable-next-line eqeqeq @@ -545,19 +544,6 @@ } }); - const startSpellCheck = () => { - if (!window.enableSpellCheck || !window.disableSpellCheck) { - return; - } - - if (window.Events.getSpellCheck()) { - window.enableSpellCheck(); - } else { - window.disableSpellCheck(); - } - }; - startSpellCheck(); - try { await Promise.all([ ConversationController.load(), diff --git a/js/spell_check.js b/js/spell_check.js deleted file mode 100644 index 4a4f31ed7df..00000000000 --- a/js/spell_check.js +++ /dev/null @@ -1,172 +0,0 @@ -/* global require, process, _ */ - -/* eslint-disable strict */ - -const electron = require('electron'); - -const Typo = require('typo-js'); -const osLocale = require('os-locale'); - -const { remote, webFrame } = electron; - -// `remote.require` since `Menu` is a main-process module. -const buildEditorContextMenu = remote.require('electron-editor-context-menu'); - -const EN_VARIANT = /^en/; - -// Prevent the spellchecker from showing contractions as errors. -const ENGLISH_SKIP_WORDS = [ - 'ain', - 'couldn', - 'didn', - 'doesn', - 'hadn', - 'hasn', - 'mightn', - 'mustn', - 'needn', - 'oughtn', - 'shan', - 'shouldn', - 'wasn', - 'weren', - 'wouldn', -]; - -function setupLinux(locale) { - if (EN_VARIANT.test(locale)) { - window.log.info('Detected English locale on Linux. Enabling spell check.'); - - return new Typo(locale); - } - - window.log.info( - 'Detected non-English locale on Linux. Disabling spell check.' - ); - - return null; -} - -// We load locale this way and not via app.getLocale() because this call returns -// 'es_ES' and not just 'es.' And hunspell requires the fully-qualified locale. -const locale = osLocale.sync().replace('-', '_'); - -// The LANG environment variable is how node spellchecker finds its default language: -// https://github.com/atom/node-spellchecker/blob/59d2d5eee5785c4b34e9669cd5d987181d17c098/lib/spellchecker.js#L29 -if (!process.env.LANG) { - process.env.LANG = locale; -} - -let spellchecker = null; - -if (process.platform === 'linux') { - spellchecker = setupLinux(locale); -} else { - spellchecker = new Typo(locale); - // OSX and Windows 8+ have OS-level spellcheck APIs - window.log.info( - 'Using OS-level spell check API with locale', - process.env.LANG - ); -} - -const simpleChecker = { - spellCheck(words, callback) { - const mispelled = words.filter(word => this.isMisspelled(word)); - callback(mispelled); - }, - isMisspelled(word) { - if (!spellchecker) { - return false; - } - - const misspelled = !spellchecker.check(word); - - // The idea is to make this as fast as possible. For the many, many calls which - // don't result in the red squiggly, we minimize the number of checks. - if (!misspelled) { - return false; - } - - // Only if we think we've found an error do we check the locale and skip list. - if (locale.match(EN_VARIANT) && _.contains(ENGLISH_SKIP_WORDS, word)) { - return false; - } - - return true; - }, - getSuggestions(text) { - if (!spellchecker) { - return []; - } - - return spellchecker.suggest(text); - }, - add() {}, -}; - -const dummyChecker = { - spellCheck(words, callback) { - callback([]); - }, - isMisspelled() { - return false; - }, - getSuggestions() { - return []; - }, - add() { - // nothing - }, -}; - -window.spellChecker = simpleChecker; -window.disableSpellCheck = () => { - window.removeEventListener('contextmenu', spellCheckHandler); - window.addEventListener('contextmenu', defaultContextMenuHandler); - webFrame.setSpellCheckProvider('en-US', dummyChecker); -}; - -window.enableSpellCheck = () => { - webFrame.setSpellCheckProvider('en-US', simpleChecker); - window.addEventListener('contextmenu', spellCheckHandler); - window.removeEventListener('contextmenu', defaultContextMenuHandler); -}; - -const defaultContextMenuHandler = e => { - // Only show the context menu in text editors. - if (!e.target.closest('textarea, input, [contenteditable="true"]')) { - return; - } - - const menu = buildEditorContextMenu({}); - - // @see js/spell_check.js:177 - setTimeout(() => { - menu.popup(remote.getCurrentWindow()); - }, 30); -}; - -const spellCheckHandler = e => { - // Only show the context menu in text editors. - if (!e.target.closest('textarea, input, [contenteditable="true"]')) { - return; - } - - const selectedText = window.getSelection().toString(); - const isMisspelled = selectedText && simpleChecker.isMisspelled(selectedText); - const spellingSuggestions = - isMisspelled && simpleChecker.getSuggestions(selectedText).slice(0, 5); - const menu = buildEditorContextMenu({ - isMisspelled, - spellingSuggestions, - }); - - // The 'contextmenu' event is emitted after 'selectionchange' has fired - // but possibly before the visible selection has changed. Try to wait - // to show the menu until after that, otherwise the visible selection - // will update after the menu dismisses and look weird. - setTimeout(() => { - menu.popup(remote.getCurrentWindow()); - }, 30); -}; diff --git a/js/views/settings_view.js b/js/views/settings_view.js index e1b879cc1b2..52055c06833 100644 --- a/js/views/settings_view.js +++ b/js/views/settings_view.js @@ -106,7 +106,17 @@ el: this.$('.spell-check-setting'), name: 'spell-check-setting', value: window.initialData.spellCheck, - setFn: window.setSpellCheck, + setFn: val => { + const $msg = this.$('.spell-check-setting-message'); + if (val !== window.appStartInitialSpellcheckSetting) { + $msg.show(); + $msg.attr('aria-hidden', false); + } else { + $msg.hide(); + $msg.attr('aria-hidden', true); + } + window.setSpellCheck(val); + }, }); if (Settings.isHideMenuBarSupported()) { new CheckboxView({ @@ -131,6 +141,10 @@ 'click .clear-data': 'onClearData', }, render_attributes() { + const spellCheckDirty = + window.initialData.spellCheck !== + window.appStartInitialSpellcheckSetting; + return { deviceNameLabel: i18n('deviceName'), deviceName: window.initialData.deviceName, @@ -157,6 +171,9 @@ mediaPermissionsDescription: i18n('mediaPermissionsDescription'), generalHeader: i18n('general'), spellCheckDescription: i18n('spellCheckDescription'), + spellCheckHidden: spellCheckDirty ? 'false' : 'true', + spellCheckDisplay: spellCheckDirty ? 'inherit' : 'none', + spellCheckDirtyText: i18n('spellCheckDirty'), }; }, onClose() { diff --git a/main.js b/main.js index 520974eca3d..5708cef3dc0 100644 --- a/main.js +++ b/main.js @@ -16,6 +16,7 @@ const electron = require('electron'); const packageJson = require('./package.json'); const GlobalErrors = require('./app/global_errors'); +const { setup: setupSpellChecker } = require('./app/spell_check'); GlobalErrors.addHandler(); @@ -94,6 +95,19 @@ const { } = require('./app/protocol_filter'); const { installPermissionsHandler } = require('./app/permissions'); +let appStartInitialSpellcheckSetting = true; + +async function getSpellCheckSetting() { + const json = await sql.getItemById('spell-check'); + + // Default to `true` if setting doesn't exist yet + if (!json) { + return true; + } + + return json.value; +} + function showWindow() { if (!mainWindow) { return; @@ -182,6 +196,7 @@ function prepareURL(pathSegments, moreKeys) { contentProxyUrl: config.contentProxyUrl, importMode: importMode ? true : undefined, // for stringify() serverTrustRoot: config.get('serverTrustRoot'), + appStartInitialSpellcheckSetting, ...moreKeys, }, }); @@ -240,7 +255,7 @@ function isVisible(window, bounds) { ); } -function createWindow() { +async function createWindow() { const { screen } = electron; const windowOptions = Object.assign( { @@ -260,6 +275,7 @@ function createWindow() { contextIsolation: false, preload: path.join(__dirname, 'preload.js'), nativeWindowOpen: true, + spellcheck: await getSpellCheckSetting(), }, icon: path.join(__dirname, 'images', 'icon_256.png'), }, @@ -296,6 +312,7 @@ function createWindow() { // Create the browser window. mainWindow = new BrowserWindow(windowOptions); + setupSpellChecker(mainWindow, locale.messages); if (!usingTrayIcon && windowConfig && windowConfig.maximized) { mainWindow.maximize(); } @@ -525,7 +542,7 @@ function showAbout() { } let settingsWindow; -async function showSettingsWindow() { +function showSettingsWindow() { if (settingsWindow) { settingsWindow.show(); return; @@ -621,10 +638,12 @@ async function showStickerCreator() { contextIsolation: false, preload: path.join(__dirname, 'sticker-creator/preload.js'), nativeWindowOpen: true, + spellcheck: await getSpellCheckSetting(), }, }; stickerCreatorWindow = new BrowserWindow(options); + setupSpellChecker(stickerCreatorWindow, locale.messages); handleCommonWindowEvents(stickerCreatorWindow); @@ -797,6 +816,8 @@ app.on('ready', async () => { console.log('sql.initialize was unsuccessful; returning early'); return; } + // eslint-disable-next-line more/no-then + appStartInitialSpellcheckSetting = await getSpellCheckSetting(); await sqlChannels.initialize(); try { diff --git a/package.json b/package.json index c0722139058..6d462d152a5 100644 --- a/package.json +++ b/package.json @@ -75,8 +75,6 @@ "copy-text-to-clipboard": "2.1.0", "curve25519-n": "https://github.com/scottnonnenberg-signal/node-curve25519.git#3e94f60bc54b2426476520d8d1a0aa835c25f5cc", "draft-js": "0.10.5", - "electron-context-menu": "0.11.0", - "electron-editor-context-menu": "1.1.1", "electron-mocha": "8.1.1", "electron-notarize": "0.1.1", "emoji-datasource": "5.0.1", @@ -139,7 +137,6 @@ "tmp": "0.0.33", "to-arraybuffer": "1.0.1", "typeface-inter": "3.10.0", - "typo-js": "1.1.0", "underscore": "1.9.0", "uuid": "3.3.2", "websocket": "1.0.28" diff --git a/patches/typo-js+1.1.0.patch b/patches/typo-js+1.1.0.patch deleted file mode 100644 index 9a960682794..00000000000 --- a/patches/typo-js+1.1.0.patch +++ /dev/null @@ -1,21 +0,0 @@ -diff --git a/node_modules/typo-js/typo.js b/node_modules/typo-js/typo.js -index 68c285b..95ebc6c 100644 ---- a/node_modules/typo-js/typo.js -+++ b/node_modules/typo-js/typo.js -@@ -431,7 +431,7 @@ Typo.prototype = { - dictionaryTable[word] = null; - } - -- if (rules.length > 0) { -+ if (rules && rules.length > 0) { - if (dictionaryTable[word] === null) { - dictionaryTable[word] = []; - } -@@ -546,6 +546,7 @@ Typo.prototype = { - else if (this.flags.FLAG === "num") { - return textCodes.split(","); - } -+ return []; - }, - - /** diff --git a/preload.js b/preload.js index 357d8f19094..231c1261f6b 100644 --- a/preload.js +++ b/preload.js @@ -395,23 +395,17 @@ try { window.Signal.Debug = require('./js/modules/debug'); window.Signal.Logs = require('./js/modules/logs'); - // Add right-click listener for selected text and urls - const contextMenu = require('electron-context-menu'); - - contextMenu({ - showInspectElement: false, - shouldShowMenu: (event, params) => - Boolean( - !params.isEditable && - params.mediaType === 'none' && - (params.linkURL || params.selectionText) - ), + window.addEventListener('contextmenu', e => { + const editable = e.target.closest( + 'textarea, input, [contenteditable="true"]' + ); + const link = e.target.closest('a'); + const selection = Boolean(window.getSelection().toString()); + if (!editable && !selection && !link) { + e.preventDefault(); + } }); - // We pull this in last, because the native module involved appears to be sensitive to - // /tmp mounted as noexec on Linux. - require('./js/spell_check'); - if (config.environment === 'test') { /* eslint-disable global-require, import/no-extraneous-dependencies */ window.test = { diff --git a/settings.html b/settings.html index bdef5810cff..338ac5ee7b3 100644 --- a/settings.html +++ b/settings.html @@ -100,6 +100,9 @@