Improved windows notifications

This commit is contained in:
Scott Nonnenberg
2023-08-01 09:06:29 -07:00
committed by GitHub
parent 584e39d569
commit 40c21b1666
31 changed files with 1227 additions and 151 deletions

View File

@@ -167,7 +167,6 @@ import { SeenStatus } from './MessageSeenStatus';
import MessageSender from './textsecure/SendMessage';
import type AccountManager from './textsecure/AccountManager';
import { onStoryRecipientUpdate } from './util/onStoryRecipientUpdate';
import { StoryViewModeType, StoryViewTargetType } from './types/Stories';
import { downloadOnboardingStory } from './util/downloadOnboardingStory';
import { flushAttachmentDownloadQueue } from './util/attachmentDownloadQueue';
import { StartupQueue } from './util/StartupQueue';
@@ -1540,27 +1539,6 @@ export async function startApp(): Promise<void> {
activeWindowService.registerForActive(() => notificationService.clear());
window.addEventListener('unload', () => notificationService.fastClear());
notificationService.on('click', (id, messageId, storyId) => {
window.IPC.showWindow();
if (id) {
if (storyId) {
window.reduxActions.stories.viewStory({
storyId,
storyViewMode: StoryViewModeType.Single,
viewTarget: StoryViewTargetType.Replies,
});
} else {
window.reduxActions.conversations.showConversation({
conversationId: id,
messageId,
});
}
} else {
window.reduxActions.app.openInbox();
}
});
// Maybe refresh remote configuration when we become active
activeWindowService.registerForActive(async () => {
strictAssert(server !== undefined, 'WebAPI not ready');

View File

@@ -79,7 +79,11 @@ export type PropsType = {
i18n: LocalizerType;
isGroupCallOutboundRingEnabled: boolean;
me: ConversationType;
notifyForCall: (title: string, isVideoCall: boolean) => unknown;
notifyForCall: (
conversationId: string,
title: string,
isVideoCall: boolean
) => unknown;
openSystemPreferencesAction: () => unknown;
playRingtone: () => unknown;
setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void;

View File

@@ -3,21 +3,36 @@
import React from 'react';
import { IdenticonSVG } from './IdenticonSVG';
import { IdenticonSVGForContact, IdenticonSVGForGroup } from './IdenticonSVG';
import { AvatarColorMap } from '../types/Colors';
export default {
title: 'Components/IdenticonSVG',
};
export function AllColors(): JSX.Element {
export function AllColorsForContact(): JSX.Element {
const stories: Array<JSX.Element> = [];
AvatarColorMap.forEach(value =>
stories.push(
<IdenticonSVG
<IdenticonSVGForContact
backgroundColor={value.bg}
text="HI"
foregroundColor={value.fg}
/>
)
);
return <>{stories}</>;
}
export function AllColorsForGroup(): JSX.Element {
const stories: Array<JSX.Element> = [];
AvatarColorMap.forEach(value =>
stories.push(
<IdenticonSVGForGroup
backgroundColor={value.bg}
content="HI"
foregroundColor={value.fg}
/>
)

View File

@@ -3,17 +3,17 @@
import React from 'react';
export type PropsType = {
export type PropsTypeForContact = {
backgroundColor: string;
content: string;
text: string;
foregroundColor: string;
};
export function IdenticonSVG({
export function IdenticonSVGForContact({
backgroundColor,
content,
text,
foregroundColor,
}: PropsType): JSX.Element {
}: PropsTypeForContact): JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<circle cx="50" cy="50" r="40" fill={backgroundColor} />
@@ -26,8 +26,44 @@ export function IdenticonSVG({
x="50"
y="50"
>
{content}
{text}
</text>
</svg>
);
}
export type PropsTypeForGroup = {
backgroundColor: string;
foregroundColor: string;
};
export function IdenticonSVGForGroup({
backgroundColor,
foregroundColor,
}: PropsTypeForGroup): JSX.Element {
// Note: the inner SVG below is taken from images/icons/v3/group/group.svg, viewBox
// added to match the original SVG, new dimensions to create match Avatar.tsx.
return (
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<circle cx="50" cy="50" r="40" fill={backgroundColor} />
<svg viewBox="0 0 20 20" height="45" width="60" y="27.5" x="20">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.833 5.957c0-1.778 1.195-3.353 2.917-3.353 1.722 0 2.917 1.575 2.917 3.353 0 .902-.294 1.759-.794 2.404-.499.645-1.242 1.118-2.123 1.118-.88 0-1.624-.473-2.123-1.118-.5-.645-.794-1.502-.794-2.404Zm2.917-1.895c-.694 0-1.458.681-1.458 1.895 0 .594.196 1.134.488 1.511.292.378.643.553.97.553.327 0 .678-.175.97-.553.292-.377.488-.917.488-1.511 0-1.214-.764-1.895-1.458-1.895Z"
fill={foregroundColor}
/>
<path
d="M6.25 10.52c.93 0 1.821.202 2.613.564a6.44 6.44 0 0 0-1.03 1.152 4.905 4.905 0 0 0-1.583-.257c-2.23 0-3.934 1.421-4.226 3.125h4.769a6.113 6.113 0 0 0 .05 1.459H1.464a.94.94 0 0 1-.943-.938c0-2.907 2.66-5.104 5.729-5.104Z"
fill={foregroundColor}
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13.75 10.52c-3.07 0-5.73 2.198-5.73 5.105 0 .545.45.938.944.938h9.572a.94.94 0 0 0 .943-.938c0-2.907-2.66-5.104-5.729-5.104Zm0 1.46c2.23 0 3.934 1.42 4.226 3.124H9.524c.292-1.704 1.997-3.125 4.226-3.125Zm-7.5-9.376c-1.722 0-2.917 1.575-2.917 3.353 0 .902.294 1.759.794 2.404.499.645 1.242 1.118 2.123 1.118.881 0 1.624-.473 2.123-1.118.5-.645.794-1.502.794-2.404 0-1.778-1.195-3.353-2.917-3.353ZM4.792 5.957c0-1.214.764-1.895 1.458-1.895.695 0 1.458.681 1.458 1.895 0 .594-.195 1.134-.488 1.511-.292.378-.643.553-.97.553-.327 0-.678-.175-.97-.553-.292-.377-.488-.917-.488-1.511Z"
fill={foregroundColor}
/>
</svg>
</svg>
);
}

View File

@@ -41,7 +41,11 @@ export type PropsType = {
>;
bounceAppIconStart(): unknown;
bounceAppIconStop(): unknown;
notifyForCall(conversationTitle: string, isVideoCall: boolean): unknown;
notifyForCall(
conversationId: string,
conversationTitle: string,
isVideoCall: boolean
): unknown;
} & (
| {
callMode: CallMode.Direct;
@@ -217,8 +221,8 @@ export function IncomingCallBar(props: PropsType): JSX.Element | null {
const initialTitleRef = useRef<string>(title);
useEffect(() => {
const initialTitle = initialTitleRef.current;
notifyForCall(initialTitle, isVideoCall);
}, [isVideoCall, notifyForCall]);
notifyForCall(conversationId, initialTitle, isVideoCall);
}, [conversationId, isVideoCall, notifyForCall]);
useEffect(() => {
bounceAppIconStart();

View File

@@ -82,7 +82,10 @@ import type { DraftBodyRanges } from '../types/BodyRange';
import { BodyRange } from '../types/BodyRange';
import { migrateColor } from '../util/migrateColor';
import { isNotNil } from '../util/isNotNil';
import { notificationService } from '../services/notifications';
import {
NotificationType,
notificationService,
} from '../services/notifications';
import { storageServiceUploadJob } from '../services/storage';
import { scheduleOptimizeFTS } from '../services/ftsOptimizer';
import { getSendOptions } from '../util/getSendOptions';
@@ -158,6 +161,7 @@ import { getQuoteAttachment } from '../util/makeQuote';
import { deriveProfileKeyVersion } from '../util/zkgroup';
import { incrementMessageCounter } from '../util/incrementMessageCounter';
import { validateTransition } from '../util/callHistoryDetails';
import OS from '../util/os/osMain';
/* eslint-disable more/no-then */
window.Whisper = window.Whisper || {};
@@ -167,6 +171,7 @@ const {
deleteAttachmentData,
doesAttachmentExist,
getAbsoluteAttachmentPath,
getAbsoluteTempPath,
readStickerData,
upgradeMessageSchema,
writeNewAttachmentData,
@@ -198,9 +203,10 @@ const ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE = new Set([
]);
type CachedIdenticon = {
readonly url: string;
readonly content: string;
readonly color: AvatarColorType;
readonly text?: string;
readonly path?: string;
readonly url: string;
};
export class ConversationModel extends window.Backbone
@@ -5123,17 +5129,7 @@ export class ConversationModel extends window.Backbone
group: this.getTitle(),
});
let notificationIconUrl;
const avatarPath = getAvatarPath(this.attributes);
if (avatarPath) {
notificationIconUrl = getAbsoluteAttachmentPath(avatarPath);
} else if (isMessageInDirectConversation) {
notificationIconUrl = await this.getIdenticon();
} else {
// Not technically needed, but helps us be explicit: we don't show an icon for a
// group that doesn't have an icon.
notificationIconUrl = undefined;
}
const { url, absolutePath } = await this.getAvatarOrIdenticon();
const messageJSON = message.toJSON();
const messageId = message.id;
@@ -5145,31 +5141,86 @@ export class ConversationModel extends window.Backbone
storyId: isMessageInDirectConversation
? undefined
: message.get('storyId'),
notificationIconUrl,
notificationIconUrl: url,
notificationIconAbsolutePath: absolutePath,
isExpiringMessage,
message: message.getNotificationText(),
messageId,
reaction: reaction ? reaction.toJSON() : null,
sentAt: message.get('timestamp'),
type: reaction ? NotificationType.Reaction : NotificationType.Message,
});
}
private async getIdenticon(): Promise<string> {
async getAvatarOrIdenticon(): Promise<{
url: string;
absolutePath?: string;
}> {
const avatarPath = getAvatarPath(this.attributes);
if (avatarPath) {
return {
url: getAbsoluteAttachmentPath(avatarPath),
absolutePath: getAbsoluteAttachmentPath(avatarPath),
};
}
const { url, path } = await this.getIdenticon({
saveToDisk: OS.isWindows(),
});
return {
url,
absolutePath: path ? getAbsoluteTempPath(path) : undefined,
};
}
private async getIdenticon({
saveToDisk,
}: { saveToDisk?: boolean } = {}): Promise<{
url: string;
path?: string;
}> {
const isContact = isDirectConversation(this.attributes);
const color = this.getColor();
const title = this.getTitle();
const content = (title && getInitials(title)) || '#';
if (isContact) {
const text = (title && getInitials(title)) || '#';
const cached = this.cachedIdenticon;
if (cached && cached.content === content && cached.color === color) {
return cached.url;
const cached = this.cachedIdenticon;
if (cached && cached.text === text && cached.color === color) {
return { ...cached };
}
const { url, path } = await createIdenticon(
color,
{
type: 'contact',
text,
},
{
saveToDisk,
}
);
this.cachedIdenticon = { text, color, url, path };
return { url, path };
}
const url = await createIdenticon(color, content);
const cached = this.cachedIdenticon;
if (cached && cached.color === color) {
return { ...cached };
}
this.cachedIdenticon = { content, color, url };
const { url, path } = await createIdenticon(
color,
{ type: 'group' },
{
saveToDisk,
}
);
return url;
this.cachedIdenticon = { color, url, path };
return { url, path };
}
notifyTyping(options: {

View File

@@ -118,7 +118,10 @@ import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import { notificationService } from '../services/notifications';
import {
NotificationType,
notificationService,
} from '../services/notifications';
import type {
LinkPreviewType,
LinkPreviewWithHydratedData,
@@ -1445,6 +1448,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
: window.i18n('icu:Stories__failed-send--full'),
isExpiringMessage: false,
sentAt: this.get('timestamp'),
type: NotificationType.Message,
});
}

View File

@@ -102,9 +102,10 @@ import {
notificationService,
NotificationSetting,
FALLBACK_NOTIFICATION_TITLE,
NotificationType,
} from './notifications';
import * as log from '../logging/log';
import { assertDev } from '../util/assert';
import { assertDev, strictAssert } from '../util/assert';
import { sendContentMessageToGroup, sendToGroup } from '../util/sendToGroup';
const {
@@ -1240,11 +1241,11 @@ export class CallingClass {
return presentableSources;
}
setPresenting(
async setPresenting(
conversationId: string,
hasLocalVideo: boolean,
source?: PresentedSource
): void {
): Promise<void> {
const call = getOwn(this.callsByConversation, conversationId);
if (!call) {
log.warn('Trying to set presenting for a non-existent call');
@@ -1274,15 +1275,18 @@ export class CallingClass {
this.setOutgoingVideoIsScreenShare(call, isPresenting);
if (source) {
const conversation = window.ConversationController.get(conversationId);
strictAssert(conversation, 'setPresenting: conversation not found');
const { url, absolutePath } = await conversation.getAvatarOrIdenticon();
ipcRenderer.send('show-screen-share', source.name);
notificationService.notify({
icon: 'images/icons/v3/video/video-fill.svg',
conversationId,
iconPath: absolutePath,
iconUrl: url,
message: window.i18n('icu:calling__presenting--notification-body'),
onNotificationClick: () => {
if (this.reduxInterface) {
this.reduxInterface.setPresenting();
}
},
type: NotificationType.IsPresenting,
sentAt: 0,
silent: true,
title: window.i18n('icu:calling__presenting--notification-title'),
@@ -2288,14 +2292,14 @@ export class CallingClass {
isAnybodyElseInGroupCall &&
!conversation.isMuted()
) {
this.notifyForGroupCall(conversation, creatorConversation);
await this.notifyForGroupCall(conversation, creatorConversation);
}
}
private notifyForGroupCall(
private async notifyForGroupCall(
conversation: Readonly<ConversationModel>,
creatorConversation: undefined | Readonly<ConversationModel>
): void {
): Promise<void> {
let notificationTitle: string;
let notificationMessage: string;
@@ -2320,15 +2324,14 @@ export class CallingClass {
break;
}
const { url, absolutePath } = await conversation.getAvatarOrIdenticon();
notificationService.notify({
icon: 'images/icons/v3/video/video-fill.svg',
conversationId: conversation.id,
iconPath: absolutePath,
iconUrl: url,
message: notificationMessage,
onNotificationClick: () => {
this.reduxInterface?.startCallingLobby({
conversationId: conversation.id,
isVideoCall: true,
});
},
type: NotificationType.IncomingGroupCall,
sentAt: 0,
silent: false,
title: notificationTitle,

View File

@@ -20,6 +20,7 @@ type NotificationDataType = Readonly<{
messageId: string;
message: string;
notificationIconUrl?: undefined | string;
notificationIconAbsolutePath?: undefined | string;
reaction?: {
emoji: string;
targetAuthorUuid: string;
@@ -28,10 +29,33 @@ type NotificationDataType = Readonly<{
senderTitle: string;
sentAt: number;
storyId?: string;
type: NotificationType;
useTriToneSound?: boolean;
wasShown?: boolean;
}>;
export type NotificationClickData = Readonly<{
conversationId: string;
messageId?: string;
storyId?: string;
}>;
export type WindowsNotificationData = {
avatarPath?: string;
body: string;
conversationId: string;
heading: string;
messageId?: string;
storyId?: string;
type: NotificationType;
};
export enum NotificationType {
IncomingCall = 'IncomingCall',
IncomingGroupCall = 'IncomingGroupCall',
IsPresenting = 'IsPresenting',
Message = 'Message',
Reaction = 'Reaction',
}
// The keys and values don't match here. This is because the values correspond to old
// setting names. In the future, we may wish to migrate these to match.
export enum NotificationSetting {
@@ -126,35 +150,81 @@ class NotificationService extends EventEmitter {
* which includes debouncing and user permission logic.
*/
public notify({
icon,
conversationId,
iconUrl,
iconPath,
message,
messageId,
onNotificationClick,
sentAt,
silent,
storyId,
title,
type,
useTriToneSound,
}: Readonly<{
icon?: string;
conversationId: string;
iconUrl?: string;
iconPath?: string;
message: string;
messageId?: string;
onNotificationClick: () => void;
sentAt: number;
silent: boolean;
storyId?: string;
title: string;
type: NotificationType;
useTriToneSound?: boolean;
}>): void {
log.info('NotificationService: showing a notification', sentAt);
this.lastNotification?.close();
if (OS.isWindows()) {
// Note: showing a windows notification clears all previous notifications first
drop(
window.IPC.showWindowsNotification({
avatarPath: iconPath,
body: message,
conversationId,
heading: title,
messageId,
storyId,
type,
})
);
} else {
this.lastNotification?.close();
const notification = new window.Notification(title, {
body: OS.isLinux() ? filterNotificationText(message) : message,
icon,
silent: true,
tag: messageId,
});
notification.onclick = onNotificationClick;
const notification = new window.Notification(title, {
body: OS.isLinux() ? filterNotificationText(message) : message,
icon: iconUrl,
silent: true,
tag: messageId,
});
// Note: this maps to the xmlTemplate() function in app/WindowsNotifications.ts
if (
type === NotificationType.Message ||
type === NotificationType.Reaction
) {
window.IPC.showWindow();
window.Events.showConversationViaNotification({
conversationId,
messageId,
storyId,
});
} else if (type === NotificationType.IncomingGroupCall) {
window.IPC.showWindow();
window.reduxActions?.calling?.startCallingLobby({
conversationId,
isVideoCall: true,
});
} else if (type === NotificationType.IsPresenting) {
window.reduxActions?.calling?.setPresenting();
} else if (type === NotificationType.IncomingCall) {
window.IPC.showWindow();
} else {
throw missingCaseError(type);
}
this.lastNotification = notification;
}
if (!silent) {
const soundType =
@@ -162,8 +232,6 @@ class NotificationService extends EventEmitter {
// We kick off the sound to be played. No need to await it.
drop(new Sound({ soundType }).play());
}
this.lastNotification = notification;
}
// Remove the last notification if both conditions hold:
@@ -225,16 +293,23 @@ class NotificationService extends EventEmitter {
private fastUpdate(): void {
const storage = this.getStorage();
const i18n = this.getI18n();
if (this.lastNotification) {
this.lastNotification.close();
this.lastNotification = null;
}
const { notificationData } = this;
const isAppFocused = window.SignalContext.activeWindowService.isActive();
const userSetting = this.getNotificationSetting();
if (OS.isWindows()) {
// Note: notificationData will be set if we're replacing the previous notification
// with a new one, so we won't clear here. That's because we always clear before
// adding anythhing new; just one notification at a time. Electron forces it, so
// we replicate it with our Windows notifications.
if (!notificationData) {
drop(window.IPC.clearAllWindowsNotifications());
}
} else if (this.lastNotification) {
this.lastNotification.close();
this.lastNotification = null;
}
// This isn't a boolean because TypeScript isn't smart enough to know that, if
// `Boolean(notificationData)` is true, `notificationData` is truthy.
const shouldShowNotification =
@@ -269,6 +344,7 @@ class NotificationService extends EventEmitter {
let notificationTitle: string;
let notificationMessage: string;
let notificationIconUrl: undefined | string;
let notificationIconAbsolutePath: undefined | string;
const {
conversationId,
@@ -281,6 +357,7 @@ class NotificationService extends EventEmitter {
sentAt,
useTriToneSound,
wasShown,
type,
} = notificationData;
if (wasShown) {
@@ -299,7 +376,8 @@ class NotificationService extends EventEmitter {
case NotificationSetting.NameOnly:
case NotificationSetting.NameAndMessage: {
notificationTitle = senderTitle;
({ notificationIconUrl } = notificationData);
({ notificationIconUrl, notificationIconAbsolutePath } =
notificationData);
if (
isExpiringMessage &&
@@ -347,15 +425,16 @@ class NotificationService extends EventEmitter {
};
this.notify({
icon: notificationIconUrl,
conversationId,
iconUrl: notificationIconUrl,
iconPath: notificationIconAbsolutePath,
messageId,
message: notificationMessage,
onNotificationClick: () => {
this.emit('click', conversationId, messageId, storyId);
},
sentAt,
silent: !shouldPlayNotificationSound,
storyId,
title: notificationTitle,
type,
useTriToneSound,
});
}

View File

@@ -123,6 +123,7 @@ type MigrationsModuleType = {
writeNewDraftData: (data: Uint8Array) => Promise<string>;
writeNewAvatarData: (data: Uint8Array) => Promise<string>;
writeNewBadgeImageFileData: (data: Uint8Array) => Promise<string>;
writeNewTempData: (data: Uint8Array) => Promise<string>;
};
export function initializeMigrations({
@@ -294,6 +295,7 @@ export function initializeMigrations({
writeNewAvatarData,
writeNewDraftData,
writeNewBadgeImageFileData,
writeNewTempData,
};
}

View File

@@ -1251,7 +1251,7 @@ function setPresenting(
return;
}
calling.setPresenting(
await calling.setPresenting(
activeCall.conversationId,
activeCallState.hasLocalVideo,
sourceToPresent

View File

@@ -32,11 +32,13 @@ import {
import {
FALLBACK_NOTIFICATION_TITLE,
NotificationSetting,
NotificationType,
notificationService,
} from '../../services/notifications';
import * as log from '../../logging/log';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { isConversationTooBigToRing } from '../../conversations/isConversationTooBigToRing';
import { strictAssert } from '../../util/assert';
function renderDeviceSelection(): JSX.Element {
return <SmartCallingDeviceSelection />;
@@ -50,6 +52,7 @@ const getGroupCallVideoFrameSource =
callingService.getGroupCallVideoFrameSource.bind(callingService);
async function notifyForCall(
conversationId: string,
title: string,
isVideoCall: boolean
): Promise<void> {
@@ -78,20 +81,23 @@ async function notifyForCall(
break;
}
const conversation = window.ConversationController.get(conversationId);
strictAssert(conversation, 'notifyForCall: conversation not found');
const { url, absolutePath } = await conversation.getAvatarOrIdenticon();
notificationService.notify({
conversationId,
title: notificationTitle,
icon: isVideoCall
? 'images/icons/v3/video/video-fill.svg'
: 'images/icons/v3/phone/phone-fill.svg',
iconPath: absolutePath,
iconUrl: url,
message: isVideoCall
? window.i18n('icu:incomingVideoCall')
: window.i18n('icu:incomingAudioCall'),
onNotificationClick: () => {
window.IPC.showWindow();
},
sentAt: 0,
// The ringtone plays so we don't need sound for the notification
silent: true,
type: NotificationType.IncomingCall,
});
}

View File

@@ -170,12 +170,22 @@ describe('calling duck', () => {
};
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let oldEvents: any;
beforeEach(function beforeEach() {
this.sandbox = sinon.createSandbox();
oldEvents = window.Events;
window.Events = {
getCallRingtoneNotification: sinon.spy(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
});
afterEach(function afterEach() {
this.sandbox.restore();
window.Events = oldEvents;
});
describe('actions', () => {
@@ -257,7 +267,7 @@ describe('calling duck', () => {
);
});
it('calls setPresenting on the calling service', function test() {
it('calls setPresenting on the calling service', async function test() {
const { setPresenting } = actions;
const dispatch = sinon.spy();
const presentedSource = {
@@ -271,7 +281,7 @@ describe('calling duck', () => {
},
});
setPresenting(presentedSource)(dispatch, getState, null);
await setPresenting(presentedSource)(dispatch, getState, null);
sinon.assert.calledOnce(this.callingServiceSetPresenting);
sinon.assert.calledWith(
@@ -282,7 +292,7 @@ describe('calling duck', () => {
);
});
it('dispatches SET_PRESENTING', () => {
it('dispatches SET_PRESENTING', async () => {
const { setPresenting } = actions;
const dispatch = sinon.spy();
const presentedSource = {
@@ -296,7 +306,7 @@ describe('calling duck', () => {
},
});
setPresenting(presentedSource)(dispatch, getState, null);
await setPresenting(presentedSource)(dispatch, getState, null);
sinon.assert.calledOnce(dispatch);
sinon.assert.calledWith(dispatch, {
@@ -305,7 +315,7 @@ describe('calling duck', () => {
});
});
it('turns off presenting when no value is passed in', () => {
it('turns off presenting when no value is passed in', async () => {
const dispatch = sinon.spy();
const { setPresenting } = actions;
const presentedSource = {
@@ -320,7 +330,7 @@ describe('calling duck', () => {
},
});
setPresenting(presentedSource)(dispatch, getState, null);
await setPresenting(presentedSource)(dispatch, getState, null);
const action = dispatch.getCall(0).args[0];
@@ -336,7 +346,7 @@ describe('calling duck', () => {
);
});
it('sets the presenting value when one is passed in', () => {
it('sets the presenting value when one is passed in', async () => {
const dispatch = sinon.spy();
const { setPresenting } = actions;
@@ -347,7 +357,7 @@ describe('calling duck', () => {
},
});
setPresenting()(dispatch, getState, null);
await setPresenting()(dispatch, getState, null);
const action = dispatch.getCall(0).args[0];

View File

@@ -0,0 +1,96 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { renderWindowsToast } from '../../../app/renderWindowsToast';
import { NotificationType } from '../../services/notifications';
describe('renderWindowsToast', () => {
it('handles toast with image', () => {
const xml = renderWindowsToast({
avatarPath: 'C:/temp/ab/abcd',
body: 'Hi there!',
heading: 'Alice',
conversationId: 'conversation5',
type: NotificationType.Message,
});
const expected =
'<toast launch="sgnl://show-conversation?conversationId=conversation5" activationType="protocol"><visual><binding template="ToastImageAndText02"><image id="1" src="file:///C:/temp/ab/abcd" hint-crop="circle"></image><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
assert.strictEqual(xml, expected);
});
it('handles toast with no image', () => {
const xml = renderWindowsToast({
body: 'Hi there!',
heading: 'Alice',
conversationId: 'conversation5',
type: NotificationType.Message,
});
const expected =
'<toast launch="sgnl://show-conversation?conversationId=conversation5" activationType="protocol"><visual><binding template="ToastText02"><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
assert.strictEqual(xml, expected);
});
it('handles toast with messageId and storyId', () => {
const xml = renderWindowsToast({
body: 'Hi there!',
heading: 'Alice',
conversationId: 'conversation5',
messageId: 'message6',
storyId: 'story7',
type: NotificationType.Message,
});
const expected =
'<toast launch="sgnl://show-conversation?conversationId=conversation5&amp;messageId=message6&amp;storyId=story7" activationType="protocol"><visual><binding template="ToastText02"><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
assert.strictEqual(xml, expected);
});
it('handles toast with for incoming call', () => {
const xml = renderWindowsToast({
body: 'Hi there!',
heading: 'Alice',
conversationId: 'conversation5',
type: NotificationType.IncomingCall,
});
const expected =
'<toast launch="sgnl://show-window" activationType="protocol"><visual><binding template="ToastText02"><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
assert.strictEqual(xml, expected);
});
it('handles toast with for incoming group call', () => {
const xml = renderWindowsToast({
body: 'Hi there!',
heading: 'Alice',
conversationId: 'conversation5',
type: NotificationType.IncomingGroupCall,
});
const expected =
'<toast launch="sgnl://start-call-lobby?conversationId=conversation5" activationType="protocol"><visual><binding template="ToastText02"><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
assert.strictEqual(xml, expected);
});
it('handles toast with for presenting screen', () => {
const xml = renderWindowsToast({
body: 'Hi there!',
heading: 'Alice',
conversationId: 'conversation5',
type: NotificationType.IsPresenting,
});
const expected =
'<toast launch="sgnl://set-is-presenting" activationType="protocol"><visual><binding template="ToastText02"><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
assert.strictEqual(xml, expected);
});
});

View File

@@ -43,6 +43,7 @@ export const rendererConfigSchema = z.object({
environment: environmentSchema,
homePath: configRequiredStringSchema,
hostname: configRequiredStringSchema,
installPath: configRequiredStringSchema,
osRelease: configRequiredStringSchema,
osVersion: configRequiredStringSchema,
resolvedTranslationsLocale: configRequiredStringSchema,

View File

@@ -43,6 +43,8 @@ import { lookupConversationWithoutUuid } from './lookupConversationWithoutUuid';
import * as log from '../logging/log';
import { deleteAllMyStories } from './deleteAllMyStories';
import { isEnabled } from '../RemoteConfig';
import type { NotificationClickData } from '../services/notifications';
import { StoryViewModeType, StoryViewTargetType } from '../types/Stories';
type SentMediaQualityType = 'standard' | 'high';
type ThemeType = 'light' | 'dark' | 'system';
@@ -115,6 +117,7 @@ export type IPCEventsCallbacksType = {
removeDarkOverlay: () => void;
resetAllChatColors: () => void;
resetDefaultChatColor: () => void;
showConversationViaNotification: (data: NotificationClickData) => void;
showConversationViaSignalDotMe: (hash: string) => Promise<void>;
showKeyboardShortcuts: () => void;
showGroupViaLink: (x: string) => Promise<void>;
@@ -510,6 +513,29 @@ export function createIPCEvents(
});
}
},
showConversationViaNotification({
conversationId,
messageId,
storyId,
}: NotificationClickData) {
if (conversationId) {
if (storyId) {
window.reduxActions.stories.viewStory({
storyId,
storyViewMode: StoryViewModeType.Single,
viewTarget: StoryViewTargetType.Replies,
});
} else {
window.reduxActions.conversations.showConversation({
conversationId,
messageId,
});
}
} else {
window.reduxActions.app.openInbox();
}
},
async showConversationViaSignalDotMe(hash: string) {
if (!Registration.everDone()) {
log.info(

View File

@@ -6,25 +6,55 @@ import loadImage from 'blueimp-load-image';
import { renderToString } from 'react-dom/server';
import type { AvatarColorType } from '../types/Colors';
import { AvatarColorMap } from '../types/Colors';
import { IdenticonSVG } from '../components/IdenticonSVG';
import {
IdenticonSVGForContact,
IdenticonSVGForGroup,
} from '../components/IdenticonSVG';
import { missingCaseError } from './missingCaseError';
const TARGET_MIME = 'image/png';
type IdenticonDetailsType =
| {
type: 'contact';
text: string;
}
| {
type: 'group';
};
export function createIdenticon(
color: AvatarColorType,
content: string
): Promise<string> {
details: IdenticonDetailsType,
{ saveToDisk }: { saveToDisk?: boolean } = {}
): Promise<{ url: string; path?: string }> {
const [defaultColorValue] = Array.from(AvatarColorMap.values());
const avatarColor = AvatarColorMap.get(color);
const html = renderToString(
<IdenticonSVG
backgroundColor={avatarColor?.bg || defaultColorValue.bg}
content={content}
foregroundColor={avatarColor?.fg || defaultColorValue.fg}
/>
);
let html: string;
if (details.type === 'contact') {
html = renderToString(
<IdenticonSVGForContact
backgroundColor={avatarColor?.bg || defaultColorValue.bg}
text={details.text}
foregroundColor={avatarColor?.fg || defaultColorValue.fg}
/>
);
} else if (details.type === 'group') {
html = renderToString(
<IdenticonSVGForGroup
backgroundColor={avatarColor?.bg || defaultColorValue.bg}
foregroundColor={avatarColor?.fg || defaultColorValue.fg}
/>
);
} else {
throw missingCaseError(details);
}
const svg = new Blob([html], { type: 'image/svg+xml;charset=utf-8' });
const svgUrl = URL.createObjectURL(svg);
return new Promise(resolve => {
return new Promise((resolve, reject) => {
const img = document.createElement('img');
img.onload = () => {
const canvas = loadImage.scale(img, {
@@ -33,7 +63,11 @@ export function createIdenticon(
maxHeight: 100,
});
if (!(canvas instanceof HTMLCanvasElement)) {
resolve('');
reject(
new Error(
'createIdenticon: canvas was not an instance of HTMLCanvasElement'
)
);
return;
}
@@ -42,11 +76,46 @@ export function createIdenticon(
ctx.drawImage(img, 0, 0);
}
URL.revokeObjectURL(svgUrl);
resolve(canvas.toDataURL('image/png'));
const url = canvas.toDataURL(TARGET_MIME);
if (!saveToDisk) {
resolve({ url });
}
canvas.toBlob(blob => {
if (!blob) {
reject(
new Error(
'createIdenticon: no blob data provided in toBlob callback'
)
);
return;
}
const reader = new FileReader();
reader.addEventListener('loadend', async () => {
const arrayBuffer = reader.result;
if (!arrayBuffer || typeof arrayBuffer === 'string') {
reject(
new Error(
'createIdenticon: no data in reader.result in FileReader loadend event'
)
);
return;
}
const data = new Uint8Array(arrayBuffer);
const path = await window.Signal.Migrations.writeNewTempData(data);
resolve({ url, path });
});
reader.readAsArrayBuffer(blob);
}, TARGET_MIME);
};
img.onerror = () => {
URL.revokeObjectURL(svgUrl);
resolve('');
reject(new Error('createIdenticon: Unable to create img element'));
};
img.src = svgUrl;

3
ts/window.d.ts vendored
View File

@@ -57,12 +57,14 @@ import type { initializeMigrations } from './signal';
import type { RetryPlaceholders } from './util/retryPlaceholders';
import type { PropsPreloadType as PreferencesPropsType } from './components/Preferences';
import type { LocaleDirection } from '../app/locale';
import type { WindowsNotificationData } from './services/notifications';
import type { HourCyclePreference } from './types/I18N';
export { Long } from 'long';
export type IPCType = {
addSetupMenuItems: () => void;
clearAllWindowsNotifications: () => Promise<void>;
closeAbout: () => void;
crashReports: {
getCount: () => Promise<number>;
@@ -88,6 +90,7 @@ export type IPCType = {
) => Promise<void>;
showSettings: () => void;
showWindow: () => void;
showWindowsNotification: (data: WindowsNotificationData) => Promise<void>;
shutdown: () => void;
titleBarDoubleClick: () => void;
updateSystemTraySetting: (value: SystemTraySetting) => void;

View File

@@ -44,7 +44,7 @@ export type MinimalSignalContextType = {
getMainWindowStats: () => Promise<MainWindowStatsType>;
getMenuOptions: () => Promise<MenuOptionsType>;
getNodeVersion: () => string;
getPath: (name: 'userData' | 'home') => string;
getPath: (name: 'userData' | 'home' | 'install') => string;
getVersion: () => string;
nativeThemeListener: NativeThemeType;
Settings: {

View File

@@ -18,6 +18,10 @@ import * as Errors from '../../types/errors';
import { strictAssert } from '../../util/assert';
import { drop } from '../../util/drop';
import type {
NotificationClickData,
WindowsNotificationData,
} from '../../services/notifications';
// It is important to call this as early as possible
window.i18n = SignalContext.i18n;
@@ -73,6 +77,10 @@ if (config.theme === 'light') {
const IPC: IPCType = {
addSetupMenuItems: () => ipc.send('add-setup-menu-items'),
clearAllWindowsNotifications: async () => {
log.info('show window');
return ipc.invoke('windows-notifications:clear-all');
},
closeAbout: () => ipc.send('close-about'),
crashReports: {
getCount: () => ipc.invoke('crash-reports:get-count'),
@@ -115,6 +123,9 @@ const IPC: IPCType = {
log.info('show window');
ipc.send('show-window');
},
showWindowsNotification: async (data: WindowsNotificationData) => {
return ipc.invoke('windows-notifications:show', data);
},
shutdown: () => {
log.info('shutdown');
ipc.send('shutdown');
@@ -315,6 +326,28 @@ ipc.on('authorize-art-creator', (_event, info) => {
window.Events.authorizeArtCreator?.({ token, pubKeyBase64 });
});
ipc.on('start-call-lobby', (_event, { conversationId }) => {
window.reduxActions?.calling?.startCallingLobby({
conversationId,
isVideoCall: true,
});
});
ipc.on('show-window', () => {
window.IPC.showWindow();
});
ipc.on('set-is-presenting', () => {
window.reduxActions?.calling?.setPresenting();
});
ipc.on(
'show-conversation-via-notification',
(_event, data: NotificationClickData) => {
const { showConversationViaNotification } = window.Events;
if (showConversationViaNotification) {
void showConversationViaNotification(data);
}
}
);
ipc.on('show-conversation-via-signal.me', (_event, info) => {
const { hash } = info;
strictAssert(typeof hash === 'string', 'Got an invalid hash over IPC');

View File

@@ -30,7 +30,7 @@ export const MinimalSignalContext: MinimalSignalContextType = {
config.appInstance ? String(config.appInstance) : undefined,
getEnvironment: () => environment,
getNodeVersion: (): string => String(config.nodeVersion),
getPath: (name: 'userData' | 'home'): string => {
getPath: (name: 'userData' | 'home' | 'install'): string => {
return String(config[`${name}Path`]);
},
getVersion: (): string => String(config.version),