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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1227 additions and 151 deletions

View File

@ -173,9 +173,13 @@ jobs:
windows:
needs: lint
runs-on: windows-latest
runs-on: windows-2019
timeout-minutes: 30
env:
BUILD_LOCATION: "C:\\Program Files (x86)\\Microsoft Visual Studio\\2019\\Enterprise\\VC\\Tools\\MSVC\\14.29.30133\\lib\\x86\\store\\references\\"
SDK_LOCATION: "C:\\Program Files (x86)\\Windows Kits\\10\\UnionMetadata\\10.0.17134.0"
steps:
- run: systeminfo
- run: git config --global core.autocrlf false
@ -185,6 +189,12 @@ jobs:
with:
node-version: '18.15.0'
- run: npm install -g yarn@1.22.10
# Set things up so @nodert-win10-rs4 dependencies build properly
- run: dir "$env:BUILD_LOCATION"
- run: dir "$env:SDK_LOCATION"
- run: "copy \"$env:BUILD_LOCATION\\platform.winmd\" \"$env:SDK_LOCATION\""
- run: dir "$env:SDK_LOCATION"
- name: Cache Desktop node_modules
id: cache-desktop-modules
@ -196,6 +206,7 @@ jobs:
if: steps.cache-desktop-modules.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
env:
CHILD_CONCURRENCY: 1
NPM_CONFIG_LOGLEVEL: verbose
- run: yarn generate

View File

@ -59,6 +59,400 @@ Signal Desktop makes use of the following open source projects.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## @nodert-win10-rs4/windows.data.xml.dom
Copyright 2019, The NodeRT Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
<http://www.apache.org/licenses/LICENSE-2.0>
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
-------------------------------------------------------------------------
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
```
## @nodert-win10-rs4/windows.ui.notifications
Copyright 2019, The NodeRT Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
<http://www.apache.org/licenses/LICENSE-2.0>
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
-------------------------------------------------------------------------
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
```
## @popperjs/core
License: MIT
@ -2634,6 +3028,28 @@ Signal Desktop makes use of the following open source projects.
END OF TERMS AND CONDITIONS
## windows-dummy-keystroke
Copyright (c) 2022 David Rickard
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
## zod
MIT License

View File

@ -34,12 +34,10 @@ Install the [Xcode Command-Line Tools](http://osxdaily.com/2014/02/12/install-co
### Windows
1. **Windows 7 only:**
- Install Microsoft .NET Framework 4.5.1:
https://www.microsoft.com/en-us/download/details.aspx?id=40773
- Install Windows SDK version 8.1: https://developer.microsoft.com/en-us/windows/downloads/sdk-archive
2. Download _Build Tools for Visual Studio_ from the [Visual Studio download page](https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2019) and install it including the "Desktop development with C++" option.
1. Download _Build Tools for Visual Studio 2017_ from the [Visual Studio 'older downloads' page](https://visualstudio.microsoft.com/vs/older-downloads/) and install it, including the "Desktop development with C++" option.
2. Install Windows 10 SDK, version 1803 (10.0.17134.x) from the [SDK Archive page](https://developer.microsoft.com/en-us/windows/downloads/sdk-archive)
3. Download and install the latest Python 3 release from https://www.python.org/downloads/windows/ (3.6 or later required).
4. Copy `platform.winmd` from your build tools location (like `C:\Program Files (x86)\Microsoft Visual Studio\2017\BuildTools\VC\Tools\MSVC\14.16.27023\lib\x86\store\references`) to the Windows SDK path: `C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.17134.0`. This is for our [`@nodert-win10-rs4`](https://github.com/NodeRT/NodeRT) dependencies.
### Linux

View File

@ -174,7 +174,7 @@ export class SystemTrayService {
);
if (this.browserWindow) {
this.browserWindow.show();
forceOnTop(this.browserWindow);
focusAndForceToTop(this.browserWindow);
}
},
}),
@ -223,7 +223,7 @@ export class SystemTrayService {
browserWindow.hide();
} else {
browserWindow.show();
forceOnTop(browserWindow);
focusAndForceToTop(browserWindow);
}
});
@ -269,7 +269,7 @@ function getDefaultIcon(): NativeImage {
return defaultIcon;
}
function forceOnTop(browserWindow: BrowserWindow) {
export function focusAndForceToTop(browserWindow: BrowserWindow): void {
// On some versions of GNOME the window may not be on top when restored.
// This trick should fix it.
// Thanks to: https://github.com/Enrico204/Whatsapp-Desktop/commit/6b0dc86b64e481b455f8fce9b4d797e86d000dc1

View File

@ -0,0 +1,70 @@
// Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ipcMain as ipc } from 'electron';
import type { IpcMainInvokeEvent } from 'electron';
// These dependencies don't export typescript properly
// https://github.com/NodeRT/NodeRT/issues/167
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { XmlDocument } from '@nodert-win10-rs4/windows.data.xml.dom';
import {
ToastNotification,
ToastNotificationManager,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
} from '@nodert-win10-rs4/windows.ui.notifications';
import * as log from '../ts/logging/log';
import { AUMID } from './startup_config';
import type { WindowsNotificationData } from '../ts/services/notifications';
import { renderWindowsToast } from './renderWindowsToast';
export { sendDummyKeystroke } from 'windows-dummy-keystroke';
const NOTIFICATION_GROUP = 'group';
const NOTIFICATION_TAG = 'tag';
ipc.handle(
'windows-notifications:show',
(_event: IpcMainInvokeEvent, data: WindowsNotificationData) => {
try {
// First, clear all previous notifications - we want just one notification at a time
clearAllNotifications();
const xmlDocument = new XmlDocument();
xmlDocument.loadXml(renderWindowsToast(data));
const toast = new ToastNotification(xmlDocument);
toast.tag = NOTIFICATION_TAG;
toast.group = NOTIFICATION_GROUP;
const notifier = ToastNotificationManager.createToastNotifier(AUMID);
notifier.show(toast);
} catch (error) {
log.error(
`Windows Notifications: Failed to show notification: ${error.stack}`
);
}
}
);
ipc.handle('windows-notifications:clear-all', () => {
try {
clearAllNotifications();
} catch (error) {
log.error(
`Windows Notifications: Failed to clear notifications: ${error.stack}`
);
}
});
function clearAllNotifications() {
ToastNotificationManager.history.remove(
NOTIFICATION_TAG,
NOTIFICATION_GROUP,
AUMID
);
}

View File

@ -81,7 +81,7 @@ import * as bounce from '../ts/services/bounce';
import * as updater from '../ts/updater/index';
import { updateDefaultSession } from './updateDefaultSession';
import { PreventDisplaySleepService } from './PreventDisplaySleepService';
import { SystemTrayService } from './SystemTrayService';
import { SystemTrayService, focusAndForceToTop } from './SystemTrayService';
import { SystemTraySettingCache } from './SystemTraySettingCache';
import {
SystemTraySetting,
@ -208,6 +208,20 @@ const CLI_LANG = cliOptions.lang as string | undefined;
setupCrashReports(getLogger, FORCE_ENABLE_CRASH_REPORTS);
let sendDummyKeystroke: undefined | (() => void);
if (OS.isWindows()) {
try {
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
const windowsNotifications = require('./WindowsNotifications');
sendDummyKeystroke = windowsNotifications.sendDummyKeystroke;
} catch (error) {
getLogger().error(
'Failed to initalize Windows Notifications:',
error.stack
);
}
}
function showWindow() {
if (!mainWindow) {
return;
@ -218,7 +232,7 @@ function showWindow() {
// the window to reposition:
// https://github.com/signalapp/Signal-Desktop/issues/1429
if (mainWindow.isVisible()) {
mainWindow.focus();
focusAndForceToTop(mainWindow);
} else {
mainWindow.show();
}
@ -232,6 +246,10 @@ if (!process.mas) {
app.exit();
} else {
app.on('second-instance', (_e: Electron.Event, argv: Array<string>) => {
// Workaround to let AllowSetForegroundWindow succeed.
// See https://www.npmjs.com/package/windows-dummy-keystroke for a full explanation of why this is needed.
sendDummyKeystroke?.();
// Someone tried to run a second instance, we should focus our window
if (mainWindow) {
if (mainWindow.isMinimized()) {
@ -2316,9 +2334,12 @@ ipc.on('get-config', async event => {
serverTrustRoot: config.get<string>('serverTrustRoot'),
theme,
appStartInitialSpellcheckSetting,
userDataPath: app.getPath('userData'),
homePath: app.getPath('home'),
// paths
crashDumpsPath: app.getPath('crashDumps'),
homePath: app.getPath('home'),
installPath: app.getAppPath(),
userDataPath: app.getPath('userData'),
directoryConfig: directoryConfig.data,
@ -2442,6 +2463,30 @@ function handleSgnlHref(incomingHref: string) {
} else if (command === 'signal.me' && hash) {
getLogger().info('Showing conversation from sgnl protocol link');
mainWindow.webContents.send('show-conversation-via-signal.me', { hash });
} else if (
command === 'show-conversation' &&
args &&
args.get('conversationId')
) {
getLogger().info('Showing conversation from notification');
mainWindow.webContents.send('show-conversation-via-notification', {
conversationId: args.get('conversationId'),
messageId: args.get('messageId'),
storyId: args.get('storyId'),
});
} else if (
command === 'start-call-lobby' &&
args &&
args.get('conversationId')
) {
getLogger().info('Starting call lobby from notification');
mainWindow.webContents.send('start-call-lobby', {
conversationId: args.get('conversationId'),
});
} else if (command === 'show-window') {
mainWindow.webContents.send('show-window');
} else if (command === 'set-is-presenting') {
mainWindow.webContents.send('set-is-presenting');
} else {
getLogger().info('Showing warning that we cannot process link');
mainWindow.webContents.send('unknown-sgnl-link');

View File

@ -0,0 +1,84 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { WindowsNotificationData } from '../ts/services/notifications';
import { NotificationType } from '../ts/services/notifications';
import { missingCaseError } from '../ts/util/missingCaseError';
function pathToUri(path: string) {
return `file:///${encodeURI(path.replace(/\\/g, '/'))}`;
}
const Toast = (props: {
launch: string;
// Note: though React doesn't like it, Windows seems to require that this be camelcase
activationType: string;
children: React.ReactNode;
}) => React.createElement('toast', props);
const Visual = (props: { children: React.ReactNode }) =>
React.createElement('visual', props);
const Binding = (props: { template: string; children: React.ReactNode }) =>
React.createElement('binding', props);
const Text = (props: { id: string; children: React.ReactNode }) =>
React.createElement('text', props);
const Image = (props: { id: string; src: string; 'hint-crop': string }) =>
React.createElement('image', props);
export function renderWindowsToast({
avatarPath,
body,
conversationId,
heading,
messageId,
storyId,
type,
}: WindowsNotificationData): string {
// Note: with these templates, the first <text> is one line, bolded
// https://learn.microsoft.com/en-us/previous-versions/windows/apps/hh761494(v=win.10)?redirectedfrom=MSDN#toastimageandtext02
// https://learn.microsoft.com/en-us/previous-versions/windows/apps/hh761494(v=win.10)?redirectedfrom=MSDN#toasttext02
const image = avatarPath ? (
<Image id="1" src={pathToUri(avatarPath)} hint-crop="circle" />
) : null;
const template = avatarPath ? 'ToastImageAndText02' : 'ToastText02';
let launch: URL;
// Note:
// 1) this maps to the notify() function in services/notifications.ts
// 2) this also maps to the url-handling in main.ts
if (type === NotificationType.Message || type === NotificationType.Reaction) {
launch = new URL('sgnl://show-conversation');
launch.searchParams.set('conversationId', conversationId);
if (messageId) {
launch.searchParams.set('messageId', messageId);
}
if (storyId) {
launch.searchParams.set('storyId', storyId);
}
} else if (type === NotificationType.IncomingGroupCall) {
launch = new URL(`sgnl://start-call-lobby`);
launch.searchParams.set('conversationId', conversationId);
} else if (type === NotificationType.IncomingCall) {
launch = new URL('sgnl://show-window');
} else if (type === NotificationType.IsPresenting) {
launch = new URL('sgnl://set-is-presenting');
} else {
throw missingCaseError(type);
}
return renderToStaticMarkup(
<Toast launch={launch.href} activationType="protocol">
<Visual>
<Binding template={template}>
{image}
<Text id="1">{heading}</Text>
<Text id="2">{body}</Text>
</Binding>
</Visual>
</Toast>
);
}

View File

@ -12,8 +12,8 @@ GlobalErrors.addHandler();
// set such that only we have read access to our files
process.umask(0o077);
const appUserModelId = `org.whispersystems.${packageJson.name}`;
export const AUMID = `org.whispersystems.${packageJson.name}`;
console.log('Set Windows Application User Model ID (AUMID)', {
appUserModelId,
AUMID,
});
app.setAppUserModelId(appUserModelId);
app.setAppUserModelId(AUMID);

View File

@ -86,6 +86,8 @@
"@formatjs/intl-localematcher": "0.2.32",
"@indutny/frameless-titlebar": "2.3.5",
"@indutny/sneequals": "4.0.0",
"@nodert-win10-rs4/windows.data.xml.dom": "0.4.4",
"@nodert-win10-rs4/windows.ui.notifications": "0.4.4",
"@popperjs/core": "2.11.6",
"@react-spring/web": "9.5.5",
"@signalapp/better-sqlite3": "8.4.3",
@ -175,6 +177,7 @@
"uuid": "3.3.2",
"uuid-browser": "3.1.0",
"websocket": "1.0.34",
"windows-dummy-keystroke": "git+https://git@github.com/scottnonnenberg-signal/windows-dummy-keystroke.git#2227c50613020d0bb5d8d1921c96d2b9b4476291",
"zod": "3.5.1"
},
"devDependencies": {
@ -502,6 +505,9 @@
"node_modules/@signalapp/ringrtc/build/${platform}/*${arch}*.node",
"node_modules/mac-screen-capture-permissions/build/Release/*.node",
"node_modules/fs-xattr/build/Release/*.node",
"node_modules/@nodert-win10-rs4/windows.data.xml.dom/build/Release/*.node",
"node_modules/@nodert-win10-rs4/windows.ui.notifications/build/Release/*.node",
"node_modules/windows-dummy-keystroke/build/Release/*.node",
"!**/node_modules/react-dom/*/*.development.js",
"!node_modules/mp4box/**",
"node_modules/mp4box/package.json",

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),

View File

@ -2026,6 +2026,20 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@nodert-win10-rs4/windows.data.xml.dom@0.4.4":
version "0.4.4"
resolved "https://registry.yarnpkg.com/@nodert-win10-rs4/windows.data.xml.dom/-/windows.data.xml.dom-0.4.4.tgz#84b749671e26033031c3d8324766bf9746a8b12e"
integrity sha512-DxnuNqQC1Fot/bLpOVqeykVGfLKXe0+staMRlh08aUYOFqSSGa4LLxhKvgCHlt65zhZJ89nip0Dw4HJSUPS2uA==
dependencies:
nan latest
"@nodert-win10-rs4/windows.ui.notifications@0.4.4":
version "0.4.4"
resolved "https://registry.yarnpkg.com/@nodert-win10-rs4/windows.ui.notifications/-/windows.ui.notifications-0.4.4.tgz#7c34e179f5e57d473c4b0ca904759e476899f5df"
integrity sha512-C03i5bj7LdE2Ta9ei7GiTPGb54bPd+ON8xqFAFfwPEeg+KTQEtG9R8JdTfiGJACYM583KiclMT1tKq8uyfZLcA==
dependencies:
nan latest
"@npmcli/fs@^1.0.0":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-1.1.1.tgz#72f719fe935e687c56a4faecf3c03d06ba593257"
@ -13456,6 +13470,11 @@ nan@^2.12.1:
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==
nan@^2.17.0, nan@latest:
version "2.17.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb"
integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==
nanoid@3.1.25:
version "3.1.25"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.25.tgz#09ca32747c0e543f0e1814b7d3793477f9c8e152"
@ -19027,6 +19046,13 @@ wildcard@^2.0.0:
resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec"
integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==
"windows-dummy-keystroke@git+https://git@github.com/scottnonnenberg-signal/windows-dummy-keystroke.git#2227c50613020d0bb5d8d1921c96d2b9b4476291":
version "1.0.0"
resolved "git+https://git@github.com/scottnonnenberg-signal/windows-dummy-keystroke.git#2227c50613020d0bb5d8d1921c96d2b9b4476291"
dependencies:
bindings "^1.5.0"
nan "^2.17.0"
word-wrap@^1.2.3:
version "1.2.4"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f"