Brand Refresh

This commit is contained in:
Jamie Kyle 2024-11-05 18:05:24 -08:00 committed by GitHub
parent b88100d32a
commit b97e67121f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
181 changed files with 828 additions and 131 deletions

View file

@ -0,0 +1,36 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import type { ComponentMeta } from '../storybook/types';
import type { AboutProps } from './About';
import { About } from './About';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/About',
component: About,
parameters: {
layout: 'fullscreen',
},
args: {
i18n,
closeAbout: action('showWhatsNewModal'),
appEnv: 'production',
platform: 'darwin',
arch: 'arm64',
version: '1.2.3',
},
} satisfies ComponentMeta<AboutProps>;
export function Basic(args: AboutProps): JSX.Element {
return (
<div style={{ height: '100vh' }}>
<About {...args} />
</div>
);
}

View file

@ -6,28 +6,46 @@ import React from 'react';
import type { LocalizerType } from '../types/Util';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
export type PropsType = {
export type AboutProps = Readonly<{
closeAbout: () => unknown;
environment: string;
appEnv: string;
arch: string;
platform: string;
i18n: LocalizerType;
version: string;
};
}>;
export function About({
closeAbout,
environment,
appEnv,
arch,
platform,
i18n,
version,
}: PropsType): JSX.Element {
}: AboutProps): JSX.Element {
useEscapeHandling(closeAbout);
let env: string;
if (platform === 'darwin') {
if (arch === 'arm64') {
env = i18n('icu:About__AppEnvironment--AppleSilicon', { appEnv });
} else {
env = i18n('icu:About__AppEnvironment--AppleIntel', { appEnv });
}
} else {
env = i18n('icu:About__AppEnvironment', { appEnv });
}
return (
<div className="About">
<div className="module-splash-screen">
<div className="module-splash-screen__logo module-img--150" />
<div className="module-splash-screen__logo module-splash-screen__logo--128" />
<h1 className="About__Title">{i18n('icu:signalDesktop')}</h1>
<div className="version">{version}</div>
<div className="environment">{environment}</div>
<div className="environment">{env}</div>
<br />
<div>
<a href="https://signal.org">signal.org</a>
</div>

View file

@ -0,0 +1,46 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import type { ComponentMeta } from '../storybook/types';
import type { ChatsTabProps } from './ChatsTab';
import { ChatsTab } from './ChatsTab';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/ChatsTab',
component: ChatsTab,
parameters: {
layout: 'fullscreen',
},
args: {
i18n,
otherTabsUnreadStats: {
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: false,
},
isStaging: false,
hasPendingUpdate: false,
hasFailedStorySends: false,
navTabsCollapsed: false,
onToggleNavTabsCollapse: action('onToggleNavTabsCollapse'),
renderConversationView: () => <>{null}</>,
renderLeftPane: () => <>{null}</>,
renderMiniPlayer: () => <>{null}</>,
selectedConversationId: undefined,
showWhatsNewModal: action('showWhatsNewModal'),
},
} satisfies ComponentMeta<ChatsTabProps>;
export function Basic(args: ChatsTabProps): JSX.Element {
return (
<div style={{ height: '100vh' }}>
<ChatsTab {...args} />
</div>
);
}

View file

@ -7,7 +7,7 @@ import type { NavTabPanelProps } from './NavTabs';
import { WhatsNewLink } from './WhatsNewLink';
import type { UnreadStats } from '../util/countUnreadStats';
type ChatsTabProps = Readonly<{
export type ChatsTabProps = Readonly<{
otherTabsUnreadStats: UnreadStats;
i18n: LocalizerType;
isStaging: boolean;
@ -59,7 +59,7 @@ export function ChatsTab({
) : (
<div className="Inbox__no-conversation-open">
{renderMiniPlayer({ shouldFlow: false })}
<div className="module-splash-screen__logo module-img--80 module-logo-blue" />
<div className="module-splash-screen__logo module-splash-screen__logo--96" />
<h3 className="Inbox__welcome">
{isStaging
? 'THIS IS A STAGING DESKTOP'

View file

@ -150,7 +150,9 @@ export function Inbox({
}
logo = <div className="Inbox__logo">{parts}</div>;
} else {
logo = <div className="module-splash-screen__logo module-img--150" />;
logo = (
<div className="module-splash-screen__logo module-splash-screen__logo--128" />
);
}
return (

View file

@ -3,6 +3,7 @@
/* eslint-disable no-restricted-syntax */
import type { NativeThemeState } from '../types/NativeThemeNotifier.d';
import { SystemThemeType } from '../types/Util';
export type Callback = (change: NativeThemeState) => void;
@ -19,9 +20,6 @@ export interface MinimalIPC {
listener: (event: unknown, ...args: ReadonlyArray<any>) => void
): this;
}
type SystemThemeType = 'dark' | 'light';
export type SystemThemeHolder = { systemTheme: SystemThemeType };
export type NativeThemeType = {
@ -41,7 +39,9 @@ export function createNativeThemeListener(
let systemTheme: SystemThemeType;
function update(): SystemThemeType {
const nextSystemTheme = theme.shouldUseDarkColors ? 'dark' : 'light';
const nextSystemTheme = theme.shouldUseDarkColors
? SystemThemeType.dark
: SystemThemeType.light;
// eslint-disable-next-line no-param-reassign
holder.systemTheme = nextSystemTheme;
return nextSystemTheme;

View file

@ -0,0 +1,297 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas';
import { join } from 'node:path';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { strictAssert } from '../util/assert';
import { SystemThemeType } from '../types/Util';
const cwd = __dirname;
const fontsDir = join(cwd, '..', '..', 'fonts');
const imagesDir = join(cwd, '..', '..', 'images');
const trayIconsDir = join(imagesDir, 'tray-icons');
const trayIconsBaseDir = join(trayIconsDir, 'base');
const trayIconsAlertsDir = join(trayIconsDir, 'alert');
enum TrayIconSize {
Size16 = '16',
Size32 = '32',
Size48 = '48',
Size256 = '256',
}
type TrayIconValue = number | string | null;
type TrayIconImageRequest = Readonly<{
size: TrayIconSize;
theme: SystemThemeType;
value: TrayIconValue;
}>;
type TrayIconVariant = {
size: number;
maxCount: number;
badgePadding: number;
fontSize: number;
fontWeight: string;
fontOffsetY: number;
badgeShadowBlur: number;
badgeShadowOffsetY: number;
images: Record<SystemThemeType, string>;
};
GlobalFonts.loadFontsFromDir(fontsDir);
const Inter = GlobalFonts.families.find(family => {
return family.family === 'Inter';
});
strictAssert(Inter != null, `Failed to load fonts from ${fontsDir}`);
const Constants = {
fontFamily: 'Inter',
badgeColor: 'rgb(244, 67, 54)',
badgeShadowColor: 'rgba(0, 0, 0, 0.25)',
};
const Variants: Record<TrayIconSize, TrayIconVariant> = {
[TrayIconSize.Size16]: {
size: 16,
maxCount: 9,
badgePadding: 2,
fontSize: 8,
fontWeight: '500',
fontOffsetY: 0,
badgeShadowBlur: 0,
badgeShadowOffsetY: 0,
images: {
light: join(trayIconsBaseDir, 'signal-tray-icon-16x16-light-base.png'),
dark: join(trayIconsBaseDir, 'signal-tray-icon-16x16-dark-base.png'),
},
},
[TrayIconSize.Size32]: {
size: 32,
maxCount: 9,
badgePadding: 4,
fontSize: 12,
fontWeight: '500',
fontOffsetY: 0,
badgeShadowBlur: 1,
badgeShadowOffsetY: 1,
images: {
light: join(trayIconsBaseDir, 'signal-tray-icon-32x32-light-base.png'),
dark: join(trayIconsBaseDir, 'signal-tray-icon-32x32-dark-base.png'),
},
},
[TrayIconSize.Size48]: {
size: 48,
maxCount: 9,
badgePadding: 6,
fontSize: 16,
fontWeight: '500',
fontOffsetY: -1,
badgeShadowBlur: 1,
badgeShadowOffsetY: 1,
images: {
light: join(trayIconsBaseDir, 'signal-tray-icon-48x48-light-base.png'),
dark: join(trayIconsBaseDir, 'signal-tray-icon-48x48-dark-base.png'),
},
},
[TrayIconSize.Size256]: {
size: 256,
maxCount: 9,
fontSize: 72,
fontWeight: '600',
fontOffsetY: 0,
badgePadding: 32,
badgeShadowBlur: 8,
badgeShadowOffsetY: 8,
images: {
light: join(trayIconsBaseDir, 'signal-tray-icon-256x256-light-base.png'),
dark: join(trayIconsBaseDir, 'signal-tray-icon-256x256-dark-base.png'),
},
},
};
function trayIconValueToText(
value: TrayIconValue,
variant: TrayIconVariant
): string {
if (value == null) {
return '';
}
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number') {
if (!Number.isSafeInteger(value) || value < 0) {
throw new RangeError(`Unread count must be positive integer ${value}`);
}
if (value === 0) {
return '';
}
if (value > variant.maxCount) {
return `${variant.maxCount}+`;
}
return `${value}`;
}
throw new TypeError(`Invalid value ${value}`);
}
async function generateTrayIconImage(
request: TrayIconImageRequest
): Promise<Buffer> {
const variant = Variants[request.size];
if (variant == null) {
throw new TypeError(`Invalid variant size (${request.size})`);
}
const imagePath = variant.images[request.theme];
if (imagePath == null) {
throw new TypeError(`Invalid theme (theme: ${request.theme})`);
}
const text = trayIconValueToText(request.value, variant);
const image = await loadImage(imagePath);
const canvas = createCanvas(variant.size, variant.size);
const context = canvas.getContext('2d');
if (context == null) {
throw new Error('Failed to create 2d canvas context');
}
context.imageSmoothingEnabled = false;
context.imageSmoothingQuality = 'high';
context.drawImage(image, 0, 0, variant.size, variant.size);
if (text !== '') {
// Decrements by 1 until the badge fits within the canvas.
let currentFontSize = variant.fontSize;
while (currentFontSize > 4) {
const font = `${variant.fontWeight} ${currentFontSize}px ${Constants.fontFamily}`;
context.font = font;
context.textAlign = 'center';
context.textBaseline = 'middle';
// @ts-expect-error Missing types
context.textRendering = 'optimizeLegibility';
context.fontKerning = 'normal';
// All font settings should be set before now and should not change.
const capMetrics = context.measureText('X');
const textMetrics = context.measureText(text);
const textWidth = Math.ceil(
textMetrics.actualBoundingBoxRight + textMetrics.actualBoundingBoxLeft
);
const textHeight = Math.ceil(
capMetrics.actualBoundingBoxAscent + capMetrics.actualBoundingBoxDescent
);
const boxHeight = textHeight + variant.badgePadding * 2;
const boxWidth = Math.max(
boxHeight, // Ensures the badge is a circle
textWidth + variant.badgePadding * 2
);
// Needed to avoid cutting off the shadow blur
const boxMargin = variant.badgeShadowBlur;
const boxWidthWithMargins = boxWidth + boxMargin * 2;
if (boxWidthWithMargins > variant.size) {
currentFontSize -= 1;
continue;
}
const boxX = variant.size - boxWidth - boxMargin; // right aligned
const boxY = boxMargin;
const boxMidX = boxX + boxWidth / 2;
const boxMidY = boxY + boxHeight / 2;
const boxRadius = Math.ceil(boxHeight / 2);
context.save();
context.beginPath();
context.roundRect(boxX, boxY, boxWidth, boxHeight, boxRadius);
context.fillStyle = Constants.badgeColor;
if (variant.badgeShadowBlur !== 0 || variant.badgeShadowOffsetY !== 0) {
context.shadowBlur = variant.badgeShadowBlur;
context.shadowOffsetX = 0;
context.shadowOffsetY = variant.badgeShadowOffsetY;
context.shadowColor = Constants.badgeShadowColor;
}
context.fill();
context.restore();
context.fillStyle = 'white';
context.fillText(text, boxMidX, boxMidY + variant.fontOffsetY);
break;
}
if (currentFontSize <= 4) {
throw new Error(
`Badge text is too large for canvas size ${variant.size} (${text})`
);
}
}
return canvas.toBuffer('image/png');
}
function range(start: number, end: number): Array<number> {
const length = end - start + 1;
return Array.from({ length }, (_, index) => start + index);
}
async function main() {
try {
await rm(trayIconsAlertsDir, { recursive: true });
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
const requests: Array<TrayIconImageRequest> = [];
for (const size of Object.values(TrayIconSize)) {
const variant = Variants[size];
const { maxCount } = variant;
const values = range(1, maxCount + 1);
for (const theme of Object.values(SystemThemeType)) {
for (const value of values) {
requests.push({ size, theme, value });
}
}
}
await Promise.all(
requests.map(async ({ size, theme, value }) => {
const variant = Variants[size];
const text = trayIconValueToText(value, variant);
const fileDir = join(trayIconsAlertsDir);
const fileName = `signal-tray-icon-${size}x${size}-${theme}-alert-${text}.png`;
const filePath = join(fileDir, fileName);
const fileContents = await generateTrayIconImage({ size, theme, value });
console.log(`Writing "${fileName}"`);
await mkdir(fileDir, { recursive: true });
await writeFile(filePath, fileContents);
})
);
console.log('Done');
}
main().catch(error => {
console.error(error);
process.exit(1);
});

View file

@ -10,6 +10,7 @@ import type {
} from '../../context/createNativeThemeListener';
import { createNativeThemeListener } from '../../context/createNativeThemeListener';
import type { NativeThemeState } from '../../types/NativeThemeNotifier.d';
import { SystemThemeType } from '../../types/Util';
class FakeIPC extends EventEmitter implements MinimalIPC {
constructor(private readonly state: NativeThemeState) {
@ -29,7 +30,7 @@ class FakeIPC extends EventEmitter implements MinimalIPC {
}
describe('NativeThemeListener', () => {
const holder: SystemThemeHolder = { systemTheme: 'dark' };
const holder: SystemThemeHolder = { systemTheme: SystemThemeType.dark };
it('syncs the initial native theme', () => {
const dark = createNativeThemeListener(

View file

@ -5,7 +5,6 @@ import { assert } from 'chai';
import * as sinon from 'sinon';
import type { MenuItem } from 'electron';
import { BrowserWindow, Tray, nativeImage } from 'electron';
import * as path from 'path';
import { MINUTE } from '../../util/durations';
import type { SystemTrayServiceOptionsType } from '../../../app/SystemTrayService';
@ -218,26 +217,17 @@ describe('SystemTrayService', function (this: Mocha.Suite) {
// can't spy on `Tray.prototype.setImage` because it's not defined that way. So we
// spy on the specific instance, just to get the image.
const setImageSpy = sandbox.spy(tray, 'setImage');
const getImagePath = (): string => {
const result = setImageSpy.lastCall?.firstArg;
if (!result) {
throw new Error('Expected tray.setImage to be called at least once');
}
return result;
};
for (let i = 9; i >= 1; i -= 1) {
service.setUnreadCount(i);
assert.strictEqual(path.parse(getImagePath()).base, `${i}.png`);
}
for (let i = 10; i < 13; i += 1) {
service.setUnreadCount(i);
assert.strictEqual(path.parse(getImagePath()).base, '10.png');
}
service.setUnreadCount(1);
assert.strictEqual(setImageSpy.callCount, 1);
service.setUnreadCount(1);
assert.strictEqual(setImageSpy.callCount, 1);
service.setUnreadCount(2);
assert.strictEqual(setImageSpy.callCount, 2);
service.setUnreadCount(2);
assert.strictEqual(setImageSpy.callCount, 2);
service.setUnreadCount(0);
assert.match(path.parse(getImagePath()).base, /^icon_\d+\.png$/);
assert.strictEqual(setImageSpy.callCount, 3);
});
it('uses a fallback image if the icon file cannot be found', () => {
@ -251,7 +241,7 @@ describe('SystemTrayService', function (this: Mocha.Suite) {
}
const setImageStub = sandbox.stub(tray, 'setImage');
setImageStub.withArgs(sinon.match.string).throws('Failed to load');
setImageStub.onFirstCall().throws('Failed to load');
service.setUnreadCount(4);
@ -259,7 +249,7 @@ describe('SystemTrayService', function (this: Mocha.Suite) {
const NativeImage = nativeImage.createEmpty().constructor;
sinon.assert.calledTwice(setImageStub);
sinon.assert.calledWith(setImageStub, sinon.match.string);
sinon.assert.calledWith(setImageStub, sinon.match.instanceOf(NativeImage));
sinon.assert.calledWith(setImageStub, sinon.match.instanceOf(NativeImage));
});

View file

@ -60,6 +60,11 @@ export enum ThemeType {
'dark' = 'dark',
}
export enum SystemThemeType {
light = 'light',
dark = 'dark',
}
// These are strings so they can be interpolated into class names.
export enum ScrollBehavior {
Default = 'default',

View file

@ -1,16 +1,19 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ThemeType } from '../types/Util';
import { SystemThemeType, ThemeType } from '../types/Util';
export async function getThemeType(): Promise<ThemeType> {
const themeSetting = await window.Events.getThemeSetting();
if (themeSetting === 'light') {
if (
themeSetting === 'light' ||
window.systemTheme === SystemThemeType.light
) {
return ThemeType.light;
}
if (themeSetting === 'dark') {
if (themeSetting === 'dark' || window.systemTheme === SystemThemeType.dark) {
return ThemeType.dark;
}

6
ts/window.d.ts vendored
View file

@ -27,7 +27,7 @@ import type * as Crypto from './Crypto';
import type * as Curve from './Curve';
import type * as RemoteConfig from './RemoteConfig';
import type { OSType } from './util/os/shared';
import type { LocalizerType, ThemeType } from './types/Util';
import type { LocalizerType, SystemThemeType, ThemeType } from './types/Util';
import type { Receipt } from './types/Receipt';
import type { ConversationController } from './ConversationController';
import type { ReduxActions } from './state/types';
@ -104,8 +104,8 @@ export type FeatureFlagType = {
};
type AboutWindowPropsType = {
appEnv: string;
arch: string;
environmentText: string;
platform: string;
};
@ -224,7 +224,7 @@ declare global {
sendChallengeRequest: (request: IPCChallengeRequest) => void;
showKeyboardShortcuts: () => void;
storage: Storage;
systemTheme: ThemeType;
systemTheme: SystemThemeType;
Signal: SignalCoreType;

View file

@ -12,21 +12,12 @@ const { AboutWindowProps } = window.Signal;
strictAssert(AboutWindowProps, 'window values not provided');
let platform = '';
if (AboutWindowProps.platform === 'darwin') {
if (AboutWindowProps.arch === 'arm64') {
platform = ` (${i18n('icu:appleSilicon')})`;
} else {
platform = ' (Intel)';
}
}
const environmentText = `${AboutWindowProps.environmentText}${platform}`;
ReactDOM.render(
<About
closeAbout={() => window.SignalContext.executeMenuRole('close')}
environment={environmentText}
appEnv={AboutWindowProps.appEnv}
platform={AboutWindowProps.platform}
arch={AboutWindowProps.arch}
i18n={i18n}
version={window.SignalContext.getVersion()}
/>,

View file

@ -14,8 +14,8 @@ if (config.appInstance) {
const Signal = {
AboutWindowProps: {
appEnv: environments.join(' - '),
arch: process.arch,
environmentText: environments.join(' - '),
platform: process.platform,
},
};