Use patched frameless-titlebar on Windows

This commit is contained in:
Fedor Indutny 2022-06-08 15:00:32 -07:00 committed by GitHub
parent 79c52847cd
commit 5634601554
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 1343 additions and 323 deletions

View file

@ -1,15 +1,21 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
import { useTheme } from '../hooks/useTheme';
import { TitleBarContainer } from './TitleBarContainer';
import type { ExecuteMenuRoleType } from './TitleBarContainer';
export type PropsType = {
closeAbout: () => unknown;
environment: string;
i18n: LocalizerType;
version: string;
platform: string;
executeMenuRole: ExecuteMenuRoleType;
};
export const About = ({
@ -17,34 +23,45 @@ export const About = ({
i18n,
environment,
version,
platform,
executeMenuRole,
}: PropsType): JSX.Element => {
useEscapeHandling(closeAbout);
return (
<div className="About">
<div className="module-splash-screen">
<div className="module-splash-screen__logo module-img--150" />
const theme = useTheme();
<div className="version">{version}</div>
<div className="environment">{environment}</div>
<div>
<a href="https://signal.org">signal.org</a>
</div>
<br />
<div>
<a
className="acknowledgments"
href="https://github.com/signalapp/Signal-Desktop/blob/main/ACKNOWLEDGMENTS.md"
>
{i18n('softwareAcknowledgments')}
</a>
</div>
<div>
<a className="privacy" href="https://signal.org/legal">
{i18n('privacyPolicy')}
</a>
return (
<TitleBarContainer
platform={platform}
theme={theme}
executeMenuRole={executeMenuRole}
title={i18n('aboutSignalDesktop')}
>
<div className="About">
<div className="module-splash-screen">
<div className="module-splash-screen__logo module-img--150" />
<div className="version">{version}</div>
<div className="environment">{environment}</div>
<div>
<a href="https://signal.org">signal.org</a>
</div>
<br />
<div>
<a
className="acknowledgments"
href="https://github.com/signalapp/Signal-Desktop/blob/main/ACKNOWLEDGMENTS.md"
>
{i18n('softwareAcknowledgments')}
</a>
</div>
<div>
<a className="privacy" href="https://signal.org/legal">
{i18n('privacyPolicy')}
</a>
</div>
</div>
</div>
</div>
</TitleBarContainer>
);
};

View file

@ -1,4 +1,4 @@
// Copyright 2021 Signal Messenger, LLC
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ComponentProps } from 'react';
@ -11,11 +11,16 @@ import { Inbox } from './Inbox';
import { SmartInstallScreen } from '../state/smart/InstallScreen';
import { StandaloneRegistration } from './StandaloneRegistration';
import { ThemeType } from '../types/Util';
import type { LocaleMessagesType } from '../types/I18N';
import { usePageVisibility } from '../hooks/usePageVisibility';
import { useReducedMotion } from '../hooks/useReducedMotion';
import type { MenuOptionsType, MenuActionType } from '../types/menu';
import { TitleBarContainer } from './TitleBarContainer';
import type { ExecuteMenuRoleType } from './TitleBarContainer';
type PropsType = {
appView: AppViewType;
localeMessages: LocaleMessagesType;
openInbox: () => void;
registerSingleDevice: (number: string, code: string) => Promise<void>;
renderCallManager: () => JSX.Element;
@ -28,6 +33,14 @@ type PropsType = {
token: string
) => Promise<void>;
theme: ThemeType;
isMaximized: boolean;
isFullScreen: boolean;
menuOptions: MenuOptionsType;
platform: string;
executeMenuRole: ExecuteMenuRoleType;
executeMenuAction: (action: MenuActionType) => void;
titleBarDoubleClick: () => void;
} & ComponentProps<typeof Inbox>;
export const App = ({
@ -39,6 +52,11 @@ export const App = ({
i18n,
isCustomizingPreferredReactions,
isShowingStoriesView,
isMaximized,
isFullScreen,
menuOptions,
platform,
localeMessages,
renderCallManager,
renderCustomizingPreferredReactionsModal,
renderGlobalModalContainer,
@ -49,6 +67,9 @@ export const App = ({
registerSingleDevice,
theme,
verifyConversationsStoppingSend,
executeMenuAction,
executeMenuRole,
titleBarDoubleClick,
}: PropsType): JSX.Element => {
let contents;
@ -113,17 +134,31 @@ export const App = ({
}, [prefersReducedMotion]);
return (
<div
className={classNames({
App: true,
'light-theme': theme === ThemeType.light,
'dark-theme': theme === ThemeType.dark,
})}
<TitleBarContainer
title="Signal"
theme={theme}
isMaximized={isMaximized}
isFullScreen={isFullScreen}
platform={platform}
hasMenu
localeMessages={localeMessages}
menuOptions={menuOptions}
executeMenuRole={executeMenuRole}
executeMenuAction={executeMenuAction}
titleBarDoubleClick={titleBarDoubleClick}
>
{renderGlobalModalContainer()}
{renderCallManager()}
{isShowingStoriesView && renderStories()}
{contents}
</div>
<div
className={classNames({
App: true,
'light-theme': theme === ThemeType.light,
'dark-theme': theme === ThemeType.dark,
})}
>
{renderGlobalModalContainer()}
{renderCallManager()}
{isShowingStoriesView && renderStories()}
{contents}
</div>
</TitleBarContainer>
);
};

View file

@ -25,6 +25,8 @@ const createProps = (): PropsType => ({
await sleep(5000);
return 'https://picsum.photos/1800/900';
},
executeMenuRole: action('executeMenuRole'),
platform: 'win32',
});
export default {

View file

@ -10,10 +10,13 @@ import type { LocalizerType } from '../types/Util';
import { Spinner } from './Spinner';
import { ToastDebugLogError } from './ToastDebugLogError';
import { ToastLinkCopied } from './ToastLinkCopied';
import { TitleBarContainer } from './TitleBarContainer';
import type { ExecuteMenuRoleType } from './TitleBarContainer';
import { ToastLoadingFullLogs } from './ToastLoadingFullLogs';
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
import { createSupportUrl } from '../util/createSupportUrl';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
import { useTheme } from '../hooks/useTheme';
enum LoadState {
NotStarted,
@ -28,6 +31,8 @@ export type PropsType = {
i18n: LocalizerType;
fetchLogs: () => Promise<string>;
uploadLogs: (logs: string) => Promise<string>;
platform: string;
executeMenuRole: ExecuteMenuRoleType;
};
enum ToastType {
@ -42,6 +47,8 @@ export const DebugLogWindow = ({
i18n,
fetchLogs,
uploadLogs,
platform,
executeMenuRole,
}: PropsType): JSX.Element => {
const [loadState, setLoadState] = useState<LoadState>(LoadState.NotStarted);
const [logText, setLogText] = useState<string | undefined>();
@ -49,6 +56,8 @@ export const DebugLogWindow = ({
const [textAreaValue, setTextAreaValue] = useState<string>(i18n('loading'));
const [toastType, setToastType] = useState<ToastType | undefined>();
const theme = useTheme();
useEscapeHandling(closeWindow);
useEffect(() => {
@ -135,32 +144,41 @@ export const DebugLogWindow = ({
});
return (
<div className="DebugLogWindow">
<div>
<div className="DebugLogWindow__title">{i18n('debugLogSuccess')}</div>
<p className="DebugLogWindow__subtitle">
{i18n('debugLogSuccessNextSteps')}
</p>
<TitleBarContainer
platform={platform}
theme={theme}
executeMenuRole={executeMenuRole}
title={i18n('debugLog')}
>
<div className="DebugLogWindow">
<div>
<div className="DebugLogWindow__title">
{i18n('debugLogSuccess')}
</div>
<p className="DebugLogWindow__subtitle">
{i18n('debugLogSuccessNextSteps')}
</p>
</div>
<div className="DebugLogWindow__container">
<input
className="DebugLogWindow__link"
readOnly
type="text"
value={publicLogURL}
/>
</div>
<div className="DebugLogWindow__footer">
<Button
onClick={() => openLinkInWebBrowser(supportURL)}
variant={ButtonVariant.Secondary}
>
{i18n('reportIssue')}
</Button>
<Button onClick={copyLog}>{i18n('debugLogCopy')}</Button>
</div>
{toastElement}
</div>
<div className="DebugLogWindow__container">
<input
className="DebugLogWindow__link"
readOnly
type="text"
value={publicLogURL}
/>
</div>
<div className="DebugLogWindow__footer">
<Button
onClick={() => openLinkInWebBrowser(supportURL)}
variant={ButtonVariant.Secondary}
>
{i18n('reportIssue')}
</Button>
<Button onClick={copyLog}>{i18n('debugLogCopy')}</Button>
</div>
{toastElement}
</div>
</TitleBarContainer>
);
}
@ -170,43 +188,50 @@ export const DebugLogWindow = ({
loadState === LoadState.Started || loadState === LoadState.Submitting;
return (
<div className="DebugLogWindow">
<div>
<div className="DebugLogWindow__title">{i18n('submitDebugLog')}</div>
<p className="DebugLogWindow__subtitle">
{i18n('debugLogExplanation')}
</p>
<TitleBarContainer
platform={platform}
theme={theme}
executeMenuRole={executeMenuRole}
title={i18n('debugLog')}
>
<div className="DebugLogWindow">
<div>
<div className="DebugLogWindow__title">{i18n('submitDebugLog')}</div>
<p className="DebugLogWindow__subtitle">
{i18n('debugLogExplanation')}
</p>
</div>
<div className="DebugLogWindow__container">
{isLoading ? (
<Spinner svgSize="normal" />
) : (
<textarea
className="DebugLogWindow__textarea"
readOnly
rows={5}
spellCheck={false}
value={textAreaValue}
/>
)}
</div>
<div className="DebugLogWindow__footer">
<Button
disabled={!canSave}
onClick={() => {
if (logText) {
downloadLog(logText);
}
}}
variant={ButtonVariant.Secondary}
>
{i18n('debugLogSave')}
</Button>
<Button disabled={!canSubmit} onClick={handleSubmit}>
{i18n('submit')}
</Button>
</div>
{toastElement}
</div>
<div className="DebugLogWindow__container">
{isLoading ? (
<Spinner svgSize="normal" />
) : (
<textarea
className="DebugLogWindow__textarea"
readOnly
rows={5}
spellCheck={false}
value={textAreaValue}
/>
)}
</div>
<div className="DebugLogWindow__footer">
<Button
disabled={!canSave}
onClick={() => {
if (logText) {
downloadLog(logText);
}
}}
variant={ButtonVariant.Secondary}
>
{i18n('debugLogSave')}
</Button>
<Button disabled={!canSubmit} onClick={handleSubmit}>
{i18n('submit')}
</Button>
</div>
{toastElement}
</div>
</TitleBarContainer>
);
};

View file

@ -103,8 +103,19 @@ export const ModalHost = React.memo(
useFocusTrap ? (
<FocusTrap
focusTrapOptions={{
// This is alright because the overlay covers the entire screen
allowOutsideClick: false,
allowOutsideClick: ({ target }) => {
if (!target || !(target instanceof HTMLElement)) {
return false;
}
const titleBar = document.querySelector(
'.TitleBarContainer__title'
);
if (titleBar?.contains(target)) {
return true;
}
return false;
},
}}
>
{modalContent}

View file

@ -156,6 +156,9 @@ const createProps = (): PropsType => ({
onZoomFactorChange: action('onZoomFactorChange'),
i18n,
executeMenuRole: action('executeMenuRole'),
platform: 'win32',
});
export default {

View file

@ -29,6 +29,8 @@ import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability';
import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode';
import { Select } from './Select';
import { Spinner } from './Spinner';
import { TitleBarContainer } from './TitleBarContainer';
import type { ExecuteMenuRoleType } from './TitleBarContainer';
import { getCustomColorStyle } from '../util/getCustomColorStyle';
import {
DEFAULT_DURATIONS_IN_SECONDS,
@ -37,6 +39,7 @@ import {
} from '../util/expirationTimer';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
import { useUniqueId } from '../hooks/useUniqueId';
import { useTheme } from '../hooks/useTheme';
type CheckboxChangeHandlerType = (value: boolean) => unknown;
type SelectChangeHandlerType<T = string | number> = (value: T) => unknown;
@ -99,6 +102,8 @@ export type PropsType = {
value: CustomColorType;
}
) => unknown;
platform: string;
executeMenuRole: ExecuteMenuRoleType;
// Limited support features
isAudioNotificationsSupported: boolean;
@ -193,6 +198,7 @@ export const Preferences = ({
doDeleteAllData,
doneRendering,
editCustomColor,
executeMenuRole,
getConversationsWithCustomColor,
hasAudioNotifications,
hasAutoDownloadUpdate,
@ -250,6 +256,7 @@ export const Preferences = ({
onThemeChange,
onUniversalExpireTimerChange,
onZoomFactorChange,
platform,
removeCustomColor,
removeCustomColorOnConversations,
resetAllChatColors,
@ -273,6 +280,7 @@ export const Preferences = ({
const [nowSyncing, setNowSyncing] = useState(false);
const [showDisappearingTimerDialog, setShowDisappearingTimerDialog] =
useState(false);
const theme = useTheme();
useEffect(() => {
doneRendering();
@ -1017,78 +1025,85 @@ export const Preferences = ({
}
return (
<div className="Preferences">
<div className="Preferences__page-selector">
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--general': true,
'Preferences__button--selected': page === Page.General,
})}
onClick={() => setPage(Page.General)}
>
{i18n('Preferences__button--general')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--appearance': true,
'Preferences__button--selected':
page === Page.Appearance || page === Page.ChatColor,
})}
onClick={() => setPage(Page.Appearance)}
>
{i18n('Preferences__button--appearance')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--chats': true,
'Preferences__button--selected': page === Page.Chats,
})}
onClick={() => setPage(Page.Chats)}
>
{i18n('Preferences__button--chats')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--calls': true,
'Preferences__button--selected': page === Page.Calls,
})}
onClick={() => setPage(Page.Calls)}
>
{i18n('Preferences__button--calls')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--notifications': true,
'Preferences__button--selected': page === Page.Notifications,
})}
onClick={() => setPage(Page.Notifications)}
>
{i18n('Preferences__button--notifications')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--privacy': true,
'Preferences__button--selected': page === Page.Privacy,
})}
onClick={() => setPage(Page.Privacy)}
>
{i18n('Preferences__button--privacy')}
</button>
<TitleBarContainer
platform={platform}
theme={theme}
executeMenuRole={executeMenuRole}
title={i18n('signalDesktopPreferences')}
>
<div className="Preferences">
<div className="Preferences__page-selector">
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--general': true,
'Preferences__button--selected': page === Page.General,
})}
onClick={() => setPage(Page.General)}
>
{i18n('Preferences__button--general')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--appearance': true,
'Preferences__button--selected':
page === Page.Appearance || page === Page.ChatColor,
})}
onClick={() => setPage(Page.Appearance)}
>
{i18n('Preferences__button--appearance')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--chats': true,
'Preferences__button--selected': page === Page.Chats,
})}
onClick={() => setPage(Page.Chats)}
>
{i18n('Preferences__button--chats')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--calls': true,
'Preferences__button--selected': page === Page.Calls,
})}
onClick={() => setPage(Page.Calls)}
>
{i18n('Preferences__button--calls')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--notifications': true,
'Preferences__button--selected': page === Page.Notifications,
})}
onClick={() => setPage(Page.Notifications)}
>
{i18n('Preferences__button--notifications')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--privacy': true,
'Preferences__button--selected': page === Page.Privacy,
})}
onClick={() => setPage(Page.Privacy)}
>
{i18n('Preferences__button--privacy')}
</button>
</div>
<div className="Preferences__settings-pane">{settings}</div>
</div>
<div className="Preferences__settings-pane">{settings}</div>
</div>
</TitleBarContainer>
);
};

View file

@ -0,0 +1,185 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { ReactNode } from 'react';
import TitleBar from '@indutny/frameless-titlebar';
import type { MenuItem } from '@indutny/frameless-titlebar';
import type { MenuItemConstructorOptions } from 'electron';
import { createTemplate } from '../../app/menu';
import { ThemeType } from '../types/Util';
import type { LocaleMessagesType } from '../types/I18N';
import type { MenuOptionsType, MenuActionType } from '../types/menu';
export type MenuPropsType = Readonly<{
hasMenu: true;
localeMessages: LocaleMessagesType;
menuOptions: MenuOptionsType;
executeMenuAction: (action: MenuActionType) => void;
}>;
export type ExecuteMenuRoleType = (
role: MenuItemConstructorOptions['role']
) => void;
export type PropsType = Readonly<{
title: string;
theme: ThemeType;
isMaximized?: boolean;
isFullScreen?: boolean;
platform: string;
executeMenuRole: ExecuteMenuRoleType;
titleBarDoubleClick?: () => void;
children: ReactNode;
}> &
(MenuPropsType | { hasMenu?: false });
// Windows only
const ROLE_TO_ACCELERATOR = new Map<
MenuItemConstructorOptions['role'],
string
>();
ROLE_TO_ACCELERATOR.set('undo', 'CmdOrCtrl+Z');
ROLE_TO_ACCELERATOR.set('redo', 'CmdOrCtrl+Y');
ROLE_TO_ACCELERATOR.set('cut', 'CmdOrCtrl+X');
ROLE_TO_ACCELERATOR.set('copy', 'CmdOrCtrl+C');
ROLE_TO_ACCELERATOR.set('paste', 'CmdOrCtrl+V');
ROLE_TO_ACCELERATOR.set('pasteAndMatchStyle', 'CmdOrCtrl+Shift+V');
ROLE_TO_ACCELERATOR.set('selectAll', 'CmdOrCtrl+A');
ROLE_TO_ACCELERATOR.set('resetZoom', 'CmdOrCtrl+0');
ROLE_TO_ACCELERATOR.set('zoomIn', 'CmdOrCtrl+=');
ROLE_TO_ACCELERATOR.set('zoomOut', 'CmdOrCtrl+-');
ROLE_TO_ACCELERATOR.set('togglefullscreen', 'F11');
ROLE_TO_ACCELERATOR.set('toggleDevTools', 'CmdOrCtrl+Shift+I');
ROLE_TO_ACCELERATOR.set('minimize', 'CmdOrCtrl+M');
function convertMenu(
menuList: ReadonlyArray<MenuItemConstructorOptions>,
executeMenuRole: (role: MenuItemConstructorOptions['role']) => void
): Array<MenuItem> {
return menuList.map(item => {
const {
type,
label,
accelerator: originalAccelerator,
click: originalClick,
submenu: originalSubmenu,
role,
} = item;
let submenu: Array<MenuItem> | undefined;
if (Array.isArray(originalSubmenu)) {
submenu = convertMenu(originalSubmenu, executeMenuRole);
} else if (originalSubmenu) {
throw new Error('Non-array submenu is not supported');
}
let click: (() => unknown) | undefined;
if (originalClick) {
if (role) {
throw new Error(`Menu item: ${label} has both click and role`);
}
// We don't use arguments in app/menu.ts
click = originalClick as () => unknown;
} else if (role) {
click = () => executeMenuRole(role);
}
let accelerator: string | undefined;
if (originalAccelerator) {
accelerator = originalAccelerator.toString();
} else if (role) {
accelerator = ROLE_TO_ACCELERATOR.get(role);
}
return {
type,
label,
accelerator,
click,
submenu,
};
});
}
export const TitleBarContainer = (props: PropsType): JSX.Element => {
const {
title,
theme,
isMaximized,
isFullScreen,
executeMenuRole,
titleBarDoubleClick,
children,
platform,
hasMenu,
} = props;
if (platform !== 'win32' || isFullScreen) {
return <>{children}</>;
}
let maybeMenu: Array<MenuItem> | undefined;
if (hasMenu) {
const { localeMessages, menuOptions, executeMenuAction } = props;
const menuTemplate = createTemplate(
{
...menuOptions,
// actions
forceUpdate: () => executeMenuAction('forceUpdate'),
openContactUs: () => executeMenuAction('openContactUs'),
openForums: () => executeMenuAction('openForums'),
openJoinTheBeta: () => executeMenuAction('openJoinTheBeta'),
openReleaseNotes: () => executeMenuAction('openReleaseNotes'),
openSupportPage: () => executeMenuAction('openSupportPage'),
setupAsNewDevice: () => executeMenuAction('setupAsNewDevice'),
setupAsStandalone: () => executeMenuAction('setupAsStandalone'),
showAbout: () => executeMenuAction('showAbout'),
showDebugLog: () => executeMenuAction('showDebugLog'),
showKeyboardShortcuts: () => executeMenuAction('showKeyboardShortcuts'),
showSettings: () => executeMenuAction('showSettings'),
showStickerCreator: () => executeMenuAction('showStickerCreator'),
showWindow: () => executeMenuAction('showWindow'),
},
localeMessages
);
maybeMenu = convertMenu(menuTemplate, executeMenuRole);
}
const titleBarTheme = {
bar: {
palette:
theme === ThemeType.light ? ('light' as const) : ('dark' as const),
},
// Hide overlay
menu: {
overlay: {
opacity: 0,
},
},
};
return (
<div className="TitleBarContainer">
<TitleBar
className="TitleBarContainer__title"
platform={platform}
title={title}
iconSrc="images/icon_32.png"
theme={titleBarTheme}
maximized={isMaximized}
menu={maybeMenu}
onDoubleClick={titleBarDoubleClick}
hideControls
/>
<div className="TitleBarContainer__content">{children}</div>
</div>
);
};