227 lines
		
	
	
	
		
			7.1 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			227 lines
		
	
	
	
		
			7.1 KiB
			
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
// Copyright 2020 Signal Messenger, LLC
 | 
						|
// 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';
 | 
						|
import { strictAssert } from '../ts/util/assert';
 | 
						|
import type { LoggerType } from '../ts/types/Logging';
 | 
						|
import { createLogger } from '../ts/logging/log';
 | 
						|
import { handleAttachmentRequest } from './attachment_channel';
 | 
						|
 | 
						|
const log = createLogger('spell_check');
 | 
						|
 | 
						|
export const FAKE_DEFAULT_LOCALE = 'und'; // 'und' is the BCP 47 subtag for "undetermined"
 | 
						|
 | 
						|
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
 | 
						|
      // 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' }
 | 
						|
    );
 | 
						|
    if (matchedLocale !== FAKE_DEFAULT_LOCALE) {
 | 
						|
      matchedLocales.push(matchedLocale);
 | 
						|
    }
 | 
						|
  });
 | 
						|
 | 
						|
  if (matchedLocales.length === 0) {
 | 
						|
    matchedLocales.push(defaultLocale);
 | 
						|
  }
 | 
						|
 | 
						|
  return matchedLocales;
 | 
						|
}
 | 
						|
 | 
						|
export const setup = (
 | 
						|
  browserWindow: BrowserWindow,
 | 
						|
  preferredSystemLocales: ReadonlyArray<string>,
 | 
						|
  localeOverride: string | null,
 | 
						|
  i18n: LocalizerType,
 | 
						|
  logger: LoggerType
 | 
						|
): void => {
 | 
						|
  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');
 | 
						|
  log.info('spellcheck: user locales:', combinedLocales);
 | 
						|
  log.info('spellcheck: available spellchecker languages:', availableLocales);
 | 
						|
  log.info('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 isImage =
 | 
						|
      params.mediaType === 'image' &&
 | 
						|
      params.hasImageContents &&
 | 
						|
      params.srcURL &&
 | 
						|
      !params.selectionText.trim();
 | 
						|
 | 
						|
    const showMenu =
 | 
						|
      params.isEditable || editFlags.canCopy || isLink || isImage;
 | 
						|
 | 
						|
    // Popup editor menu
 | 
						|
    if (showMenu) {
 | 
						|
      const template: MenuListType = [];
 | 
						|
 | 
						|
      if (isMisspelled) {
 | 
						|
        if (params.dictionarySuggestions.length > 0) {
 | 
						|
          template.push(
 | 
						|
            ...params.dictionarySuggestions.map(label => ({
 | 
						|
              label,
 | 
						|
              click: () => {
 | 
						|
                browserWindow.webContents.replaceMisspelling(label);
 | 
						|
              },
 | 
						|
            }))
 | 
						|
          );
 | 
						|
        } else {
 | 
						|
          template.push({
 | 
						|
            label: i18n('icu:contextMenuNoSuggestions'),
 | 
						|
            enabled: false,
 | 
						|
          });
 | 
						|
        }
 | 
						|
        template.push({ type: 'separator' });
 | 
						|
      }
 | 
						|
 | 
						|
      if (params.isEditable) {
 | 
						|
        if (editFlags.canUndo) {
 | 
						|
          template.push({ label: i18n('icu:editMenuUndo'), 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: i18n('icu:editMenuRedo'), role: 'redo' });
 | 
						|
        }
 | 
						|
        if (editFlags.canUndo || editFlags.canRedo) {
 | 
						|
          template.push({ type: 'separator' });
 | 
						|
        }
 | 
						|
        if (editFlags.canCut) {
 | 
						|
          template.push({ label: i18n('icu:editMenuCut'), role: 'cut' });
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      if (editFlags.canCopy || isLink || isImage) {
 | 
						|
        let click;
 | 
						|
        let label;
 | 
						|
 | 
						|
        if (isLink) {
 | 
						|
          click = () => {
 | 
						|
            clipboard.writeText(params.linkURL);
 | 
						|
          };
 | 
						|
          label = i18n('icu:contextMenuCopyLink');
 | 
						|
        } else if (isImage) {
 | 
						|
          click = async () => {
 | 
						|
            const parsedSrcUrl = maybeParseUrl(params.srcURL);
 | 
						|
            if (!parsedSrcUrl || parsedSrcUrl.protocol !== 'attachment:') {
 | 
						|
              return;
 | 
						|
            }
 | 
						|
 | 
						|
            const urlIsViewOnce =
 | 
						|
              parsedSrcUrl.searchParams.get('disposition') === 'temporary';
 | 
						|
            if (urlIsViewOnce) {
 | 
						|
              return;
 | 
						|
            }
 | 
						|
 | 
						|
            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);
 | 
						|
            }
 | 
						|
          };
 | 
						|
          label = i18n('icu:contextMenuCopyImage');
 | 
						|
        } else {
 | 
						|
          label = i18n('icu:editMenuCopy');
 | 
						|
        }
 | 
						|
 | 
						|
        template.push({
 | 
						|
          label,
 | 
						|
          role: isLink || isImage ? undefined : 'copy',
 | 
						|
          click,
 | 
						|
        });
 | 
						|
      }
 | 
						|
 | 
						|
      if (editFlags.canPaste && !isImage) {
 | 
						|
        template.push({ label: i18n('icu:editMenuPaste'), role: 'paste' });
 | 
						|
      }
 | 
						|
 | 
						|
      if (editFlags.canPaste && !isImage) {
 | 
						|
        template.push({
 | 
						|
          label: i18n('icu:editMenuPasteAndMatchStyle'),
 | 
						|
          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: i18n('icu:editMenuSelectAll'),
 | 
						|
          role: 'selectAll',
 | 
						|
        });
 | 
						|
      }
 | 
						|
 | 
						|
      const menu = Menu.buildFromTemplate(template);
 | 
						|
      menu.popup({
 | 
						|
        window: browserWindow,
 | 
						|
        frame: params.frame ?? undefined,
 | 
						|
      });
 | 
						|
    }
 | 
						|
  });
 | 
						|
};
 |