Improved windows notifications
This commit is contained in:
parent
584e39d569
commit
40c21b1666
31 changed files with 1227 additions and 151 deletions
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
70
app/WindowsNotifications.ts
Normal file
70
app/WindowsNotifications.ts
Normal 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
|
||||
);
|
||||
}
|
53
app/main.ts
53
app/main.ts
|
@ -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');
|
||||
|
|
84
app/renderWindowsToast.tsx
Normal file
84
app/renderWindowsToast.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -1251,7 +1251,7 @@ function setPresenting(
|
|||
return;
|
||||
}
|
||||
|
||||
calling.setPresenting(
|
||||
await calling.setPresenting(
|
||||
activeCall.conversationId,
|
||||
activeCallState.hasLocalVideo,
|
||||
sourceToPresent
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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];
|
||||
|
||||
|
|
96
ts/test-node/app/renderWindowsToast_test.tsx
Normal file
96
ts/test-node/app/renderWindowsToast_test.tsx
Normal 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&messageId=message6&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);
|
||||
});
|
||||
});
|
|
@ -43,6 +43,7 @@ export const rendererConfigSchema = z.object({
|
|||
environment: environmentSchema,
|
||||
homePath: configRequiredStringSchema,
|
||||
hostname: configRequiredStringSchema,
|
||||
installPath: configRequiredStringSchema,
|
||||
osRelease: configRequiredStringSchema,
|
||||
osVersion: configRequiredStringSchema,
|
||||
resolvedTranslationsLocale: configRequiredStringSchema,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
3
ts/window.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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),
|
||||
|
|
26
yarn.lock
26
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue