Calls Tab & Group Call Disposition

This commit is contained in:
Jamie Kyle 2023-08-08 17:53:06 -07:00 committed by GitHub
parent 620e85ca01
commit 1eaabb6734
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
139 changed files with 9182 additions and 2721 deletions

View file

@ -457,6 +457,210 @@ Signal Desktop makes use of the following open source projects.
License: MIT License: MIT
## @react-aria/utils
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
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2019 Adobe
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.
## @react-spring/web ## @react-spring/web
MIT License MIT License
@ -2494,6 +2698,414 @@ 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
## react-aria
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
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2019 Adobe
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.
## react-aria-components
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
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2019 Adobe
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.
## react-blurhash ## react-blurhash
License: MIT License: MIT

View file

@ -299,6 +299,34 @@
"messageformat": "Chats", "messageformat": "Chats",
"description": "Shown as a header for non-pinned conversations in the left pane" "description": "Shown as a header for non-pinned conversations in the left pane"
}, },
"icu:NavTabsToggle__showTabs": {
"messageformat": "Show Tabs",
"description": "Show in the left pane when the nav tabs are hidden, shows the nav tabs"
},
"icu:NavTabsToggle__hideTabs": {
"messageformat": "Hide Tabs",
"description": "Show in the nav tabs when the nav tabs are visible, hides the nav tabs"
},
"icu:NavTabs__ItemIconLabel--UnreadCount": {
"messageformat": "{count, number} unread",
"description": "Nav Tabs > Unread badge > Accessibility Text"
},
"icu:NavTabs__ItemIconLabel--MarkedUnread": {
"messageformat": "Marked unread",
"description": "Nav Tabs > Unread badge > when conversation is marked unread > Accessibility Text"
},
"icu:NavTabs__ItemLabel--Settings": {
"messageformat": "Settings",
"description": "Nav Tabs > Settings Button > Accessibility Text"
},
"icu:NavTabs__ItemLabel--Profile": {
"messageformat": "Profile",
"description": "Nav Tabs > Profile Button > Accessibility Text"
},
"icu:NavSidebar__BackButtonLabel": {
"messageformat": "Back",
"description": "Shown in the sidebar header when on a nested panel within the current sidebar, takes the user back to the previous step or default sidebar state"
},
"icu:archiveHelperText": { "icu:archiveHelperText": {
"messageformat": "These chats are archived and will only appear in the Inbox if new messages are received.", "messageformat": "These chats are archived and will only appear in the Inbox if new messages are received.",
"description": "Shown at the top of the archived conversations list in the left pane" "description": "Shown at the top of the archived conversations list in the left pane"
@ -2150,6 +2178,10 @@
"messageformat": "View Safety Number", "messageformat": "View Safety Number",
"description": "In conversation details, label for button to view safety number, opens safety number modal" "description": "In conversation details, label for button to view safety number, opens safety number modal"
}, },
"icu:ConversationDetails__HeaderButton--Message": {
"messageformat": "Message",
"description": "In conversation details, label for button to switch to the conversation view in order to draft a message in that converation"
},
"icu:SafetyNumberNotification__viewSafetyNumber": { "icu:SafetyNumberNotification__viewSafetyNumber": {
"messageformat": "View Safety Number", "messageformat": "View Safety Number",
"description": "In conversation, safety number change notification, label for button to view safety number, opens safety number modal" "description": "In conversation, safety number change notification, label for button to view safety number, opens safety number modal"
@ -3383,6 +3415,14 @@
"messageformat": "Incoming video call...", "messageformat": "Incoming video call...",
"description": "Shown in both the incoming call bar and notification for an incoming video call" "description": "Shown in both the incoming call bar and notification for an incoming video call"
}, },
"icu:outgoingAudioCall": {
"messageformat": "Outgoing voice call",
"description": "Shown in the timeline for an outgoing voice call"
},
"icu:outgoingVideoCall": {
"messageformat": "Outgoing video call",
"description": "Shown in the timeline for an outgoing video call"
},
"icu:incomingGroupCall__ringing-you": { "icu:incomingGroupCall__ringing-you": {
"messageformat": "{ringer} is calling you", "messageformat": "{ringer} is calling you",
"description": "Shown in the incoming call bar when someone is ringing you for a group call" "description": "Shown in the incoming call bar when someone is ringing you for a group call"
@ -6595,6 +6635,110 @@
"messageformat": "Send again", "messageformat": "Send again",
"description": "Button text for the confirmation dialog shown to user when attempting to resend message edit" "description": "Button text for the confirmation dialog shown to user when attempting to resend message edit"
}, },
"icu:StoriesTab__MoreActionsLabel": {
"messageformat": "More actions",
"description": "Stories Tab > More Actions Button (opens context menu) > Accessibility Label"
},
"icu:CallsTab__HeaderTitle--CallsList": {
"messageformat": "Calls",
"description": "Calls Tab > Header > Title > On Calls List screen"
},
"icu:CallsTab__HeaderTitle--NewCall": {
"messageformat": "New Call",
"description": "Calls Tab > Header > Title > On New Call screen"
},
"icu:CallsTab__NewCallActionLabel": {
"messageformat": "New Call",
"description": "Calls Tab > New Call Action Button > Accessibility Label"
},
"icu:CallsTab__MoreActionsLabel": {
"messageformat": "More actions",
"description": "Calls Tab > More Actions Button (opens context menu) > Accessibility Label"
},
"icu:CallsTab__ClearCallHistoryLabel": {
"messageformat": "Clear call history",
"description": "Calls Tab > More Actions Context Menu > Clear Call History Button Label"
},
"icu:CallsTab__ConfirmClearCallHistory__Title": {
"messageformat": "Clear call history?",
"description": "Calls Tab > Confirm Clear Call History Dialog > Title"
},
"icu:CallsTab__ConfirmClearCallHistory__Body": {
"messageformat": "This will permanently delete all call history",
"description": "Calls Tab > Confirm Clear Call History Dialog > Body Text"
},
"icu:CallsTab__ConfirmClearCallHistory__ConfirmButton": {
"messageformat": "Clear",
"description": "Calls Tab > Confirm Clear Call History Dialog > Confirm Button"
},
"icu:CallsTab__ToastCallHistoryCleared": {
"messageformat": "Call history cleared",
"description": "Calls Tab > Clear Call History > Toast"
},
"icu:CallsTab__EmptyStateText": {
"messageformat": "Click to view or start a call",
"description": "Calls Tab > When no call is selected > Empty state > Call to action text"
},
"icu:CallsList__SearchInputPlaceholder": {
"messageformat": "Search",
"description": "Calls Tab > Calls List > Search Input > Placeholder"
},
"icu:CallsList__ToggleFilterByMissedLabel": {
"messageformat": "Toggle filter by missed",
"description": "Calls Tab > Calls List > Toggle search filter by missed > Accessibility label"
},
"icu:CallsList__ToggleFilterByMissed__RoleDescription": {
"messageformat": "Toggle",
"description": "Calls Tab > Calls List > Toggle search filter by missed > Accessibility role description ('A toggle button')"
},
"icu:CallsList__EmptyState--noQuery": {
"messageformat": "No recent calls. Get started by calling a friend.",
"description": "Calls Tab > Calls List > When no results found > With no search query"
},
"icu:CallsList__EmptyState--hasQuery": {
"messageformat": "No results for “{query}”",
"description": "Calls Tab > Calls List > When no results found > With a search query"
},
"icu:CallsList__ItemCallInfo--Incoming": {
"messageformat": "Incoming",
"description": "Calls Tab > Calls List > Call Item > Call Status > When call was accepted and was incoming"
},
"icu:CallsList__ItemCallInfo--Outgoing": {
"messageformat": "Outgoing",
"description": "Calls Tab > Calls List > Call Item > Call Status > When call was accepted and was outgoing"
},
"icu:CallsList__ItemCallInfo--Missed": {
"messageformat": "Missed",
"description": "Calls Tab > Calls List > Call Item > Call Status > When call was missed"
},
"icu:CallsList__ItemCallInfo--GroupCall": {
"messageformat": "Group call",
"description": "Calls Tab > Calls List > Call Item > Call Status > When group call is in its default state"
},
"icu:CallsNewCall__EmptyState--noQuery": {
"messageformat": "No recent conversations.",
"description": "Calls Tab > New Call > Conversations List > When no results found > With no search query"
},
"icu:CallsNewCall__EmptyState--hasQuery": {
"messageformat": "No results for “{query}”",
"description": "Calls Tab > New Call > Conversations List > When no results found > With a search query"
},
"icu:CallHistory__Description--Default": {
"messageformat": "{direction, select, Outgoing {Outgoing} other {Incoming}} {type, select, Audio {voice} Video {video} Group {group} other {}} call",
"description": "Call History > Short description of call > When call was not missed or declined (generally accepted)"
},
"icu:CallHistory__Description--Missed": {
"messageformat": "Missed {type, select, Audio {voice} Video {video} Group {group} other {}} call",
"description": "Call History > Short description of call > When incoming call was missed"
},
"icu:CallHistory__Description--Unanswered": {
"messageformat": "Unanswered {type, select, Audio {voice} Video {video} Group {group} other {}} call",
"description": "Call History > Short description of call > When outgoing call was unanswered"
},
"icu:CallHistory__Description--Declined": {
"messageformat": "Declined {type, select, Audio {voice} Video {video} Group {group} other {}} call",
"description": "Call History > Short description of call > When call was declined"
},
"icu:WhatsNew__modal-title": { "icu:WhatsNew__modal-title": {
"messageformat": "What's New", "messageformat": "What's New",
"description": "Title for the whats new modal" "description": "Title for the whats new modal"

View file

@ -194,7 +194,11 @@ const defaultWebPrefs = {
getEnvironment() !== Environment.Production || getEnvironment() !== Environment.Production ||
!isProduction(app.getVersion()), !isProduction(app.getVersion()),
spellcheck: false, spellcheck: false,
enableBlinkFeatures: 'CSSPseudoDir,CSSLogical', // https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/platform/runtime_enabled_features.json5
enableBlinkFeatures: [
'CSSPseudoDir', // status=experimental, needed for RTL (ex: :dir(rtl))
'CSSLogical', // status=experimental, needed for RTL (ex: margin-inline-start)
].join(','),
}; };
const DISABLE_GPU = const DISABLE_GPU =

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 1.875a8.125 8.125 0 0 0-7.164 11.961.023.023 0 0 1 .003.01L1.574 17.36a.833.833 0 0 0 1.067 1.066l3.512-1.264h.002a8.09 8.09 0 0 0 3.845.964 8.125 8.125 0 1 0 0-16.25Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" d="M2.125 7c0-.483.392-.875.875-.875h18a.875.875 0 0 1 0 1.75H3A.875.875 0 0 1 2.125 7ZM5.375 12c0-.483.392-.875.875-.875h11.5a.875.875 0 0 1 0 1.75H6.25A.875.875 0 0 1 5.375 12ZM9.5 16.125a.875.875 0 0 0 0 1.75h5a.875.875 0 0 0 0-1.75h-5Z"/></svg>

After

Width:  |  Height:  |  Size: 341 B

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2.604 5a.73.73 0 0 1 .73-.73h13.333a.73.73 0 1 1 0 1.46H3.333A.73.73 0 0 1 2.604 5Zm0 5a.73.73 0 0 1 .73-.73h13.333a.73.73 0 1 1 0 1.46H3.333a.73.73 0 0 1-.729-.73Zm.729 4.27a.73.73 0 1 0 0 1.46h13.334a.73.73 0 1 0 0-1.46H3.333Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 334 B

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15.625 2.083a.625.625 0 1 0-1.25 0v2.292h-2.292a.625.625 0 1 0 0 1.25h2.292v2.292a.625.625 0 1 0 1.25 0V5.625h2.292a.625.625 0 0 0 0-1.25h-2.292V2.083ZM6.776 3.188a2.27 2.27 0 0 0-3.413-.231l-.353.353C1.755 4.565 1.073 6.43 1.686 8.22a16.191 16.191 0 0 0 3.882 6.212 16.19 16.19 0 0 0 6.212 3.882c1.79.613 3.655-.069 4.91-1.324l.353-.353a2.27 2.27 0 0 0-.231-3.413l-2.314-1.758a2.27 2.27 0 0 0-2.978.202l-.889.888a1.949 1.949 0 0 1-.38-.17c-.41-.228-.94-.632-1.472-1.165-.533-.533-.937-1.061-1.165-1.471a1.957 1.957 0 0 1-.17-.381l.888-.889a2.27 2.27 0 0 0 .202-2.978L6.776 3.188Zm-2.529.652a1.02 1.02 0 0 1 1.534.105l1.758 2.313c.308.406.27.978-.091 1.339L6.44 8.604c-.305.304-.284.709-.238.947.05.264.171.54.318.806.299.537.783 1.157 1.374 1.748.591.591 1.21 1.075 1.748 1.374.265.147.542.267.806.318.238.046.643.067.947-.238l1.007-1.007c.361-.36.933-.4 1.339-.09l2.313 1.757a1.02 1.02 0 0 1 .105 1.534l-.354.353c-1.004 1.004-2.388 1.448-3.62 1.026a14.941 14.941 0 0 1-5.734-3.584 14.94 14.94 0 0 1-3.584-5.733c-.422-1.233.022-2.617 1.026-3.621l.353-.354Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.935 2.707a2.108 2.108 0 0 0-3.168-.215l-.363.363C2.159 4.1 1.5 5.931 2.097 7.67a16.415 16.415 0 0 0 3.936 6.298 16.416 16.416 0 0 0 6.298 3.936c1.739.596 3.569-.062 4.814-1.307l.363-.363a2.108 2.108 0 0 0-.215-3.168l-2.378-1.807a2.108 2.108 0 0 0-2.765.188l-1.297 1.296c-.201-.012-.404-.127-.576-.222-.444-.247-1.004-.676-1.562-1.235-.559-.558-.988-1.118-1.235-1.562-.095-.172-.21-.375-.222-.576L8.554 7.85a2.108 2.108 0 0 0 .188-2.765L6.935 2.707Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 556 B

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.546 2.271a2.42 2.42 0 0 1 3.638.247L8.99 4.896a2.42 2.42 0 0 1-.216 3.175l-.875.876c.027.078.075.187.152.325.229.413.639.95 1.182 1.493.544.544 1.081.954 1.494 1.183.138.077.247.125.325.152l.875-.875a2.42 2.42 0 0 1 3.176-.216l2.378 1.807a2.42 2.42 0 0 1 .247 3.638l-.363.363c-1.308 1.308-3.259 2.025-5.136 1.382a16.727 16.727 0 0 1-6.418-4.011 16.726 16.726 0 0 1-4.01-6.418c-.644-1.877.073-3.828 1.38-5.136l.364-.363Zm2.476 1.13a.962.962 0 0 0-1.445-.098l-.363.363C3.199 4.68 2.76 6.069 3.18 7.298a15.269 15.269 0 0 0 3.662 5.859 15.269 15.269 0 0 0 5.86 3.662c1.228.421 2.617-.018 3.631-1.033l.363-.363a.962.962 0 0 0-.098-1.446l-2.377-1.806a.962.962 0 0 0-1.262.085l-1.035 1.035c-.345.344-.798.318-1.052.269a2.99 2.99 0 0 1-.854-.337c-.56-.312-1.204-.815-1.816-1.426-.611-.612-1.114-1.255-1.426-1.816a2.992 2.992 0 0 1-.337-.854c-.049-.254-.075-.707.269-1.052L7.744 7.04a.962.962 0 0 0 .086-1.262L6.022 3.401Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 1,021 B

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.232 1.25h2.87c.684 0 1.223 0 1.657.035.443.037.813.112 1.148.283.55.28.995.726 1.275 1.275.171.335.246.705.283 1.148.035.434.035.973.035 1.657v8.704c0 .684 0 1.223-.035 1.657-.037.443-.112.813-.283 1.148-.28.55-.726.995-1.275 1.275-.335.171-.705.246-1.148.283-.434.035-.973.035-1.657.035h-2.87c-.685 0-1.224 0-1.658-.035-.443-.037-.812-.112-1.148-.283a2.916 2.916 0 0 1-1.275-1.274c-.171-.336-.246-.706-.282-1.149-.036-.434-.036-.973-.036-1.657V5.648c0-.684 0-1.223.036-1.657.036-.443.111-.813.282-1.148.28-.55.726-.995 1.275-1.275.336-.171.705-.246 1.148-.283.434-.035.973-.035 1.658-.035ZM4.623 3.889c.022-.265.057-.538.123-.814l-.523.19c-.534.195-.959.35-1.29.502-.341.156-.618.325-.841.564a2.5 2.5 0 0 0-.653 1.4c-.04.325.009.646.108 1.007.097.353.251.777.445 1.31L4 13.566c.194.533.349.958.501 1.29.028.06.055.117.084.173-.002-.203-.002-.415-.002-.635V5.607c0-.649 0-1.233.04-1.718Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 996 B

View file

@ -89,6 +89,7 @@
"@nodert-win10-rs4/windows.data.xml.dom": "0.4.4", "@nodert-win10-rs4/windows.data.xml.dom": "0.4.4",
"@nodert-win10-rs4/windows.ui.notifications": "0.4.4", "@nodert-win10-rs4/windows.ui.notifications": "0.4.4",
"@popperjs/core": "2.11.6", "@popperjs/core": "2.11.6",
"@react-aria/utils": "3.16.0",
"@react-spring/web": "9.5.5", "@react-spring/web": "9.5.5",
"@signalapp/better-sqlite3": "8.4.3", "@signalapp/better-sqlite3": "8.4.3",
"@signalapp/libsignal-client": "0.29.1", "@signalapp/libsignal-client": "0.29.1",
@ -151,6 +152,8 @@
"quill": "1.3.7", "quill": "1.3.7",
"quill-delta": "4.0.1", "quill-delta": "4.0.1",
"react": "17.0.2", "react": "17.0.2",
"react-aria": "3.24.0",
"react-aria-components": "1.0.0-alpha.3",
"react-blurhash": "0.1.2", "react-blurhash": "0.1.2",
"react-contextmenu": "2.11.0", "react-contextmenu": "2.11.0",
"react-dom": "17.0.2", "react-dom": "17.0.2",
@ -178,7 +181,7 @@
"uuid-browser": "3.1.0", "uuid-browser": "3.1.0",
"websocket": "1.0.34", "websocket": "1.0.34",
"@signalapp/windows-dummy-keystroke": "1.0.0", "@signalapp/windows-dummy-keystroke": "1.0.0",
"zod": "3.5.1" "zod": "3.21.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.14.3", "@babel/core": "7.14.3",

View file

@ -0,0 +1,13 @@
diff --git a/node_modules/react-aria-components/dist/types.d.ts b/node_modules/react-aria-components/dist/types.d.ts
index eb908b4..6cd530f 100644
--- a/node_modules/react-aria-components/dist/types.d.ts
+++ b/node_modules/react-aria-components/dist/types.d.ts
@@ -44,7 +44,7 @@ interface SlotProps {
/** A slot name for the component. Slots allow the component to receive props from a parent component. */
slot?: string;
}
-export function useContextProps<T, U, E extends Element>(props: T & SlotProps, ref: React.ForwardedRef<E>, context: React.Context<ContextValue<U, E>>): [T, React.RefObject<E>];
+export function useContextProps<T, U extends SlotProps, E extends Element>(props: T & SlotProps, ref: React.ForwardedRef<E>, context: React.Context<ContextValue<U, E>>): [T, React.RefObject<E>];
interface CollectionProps<T> extends Omit<CollectionBase<T>, 'children'> {
/** The contents of the collection. */
children?: ReactNode | ((item: T) => ReactElement);

View file

@ -592,9 +592,11 @@ message SyncMessage {
message CallEvent { message CallEvent {
enum Type { enum Type {
UNKNOWN = 0; UNKNOWN = 0;
AUDIO_CALL = 1; AUDIO_CALL = 1;
VIDEO_CALL = 2; VIDEO_CALL = 2;
GROUP_CALL = 3;
AD_HOC_CALL = 4;
} }
enum Direction { enum Direction {
@ -607,9 +609,10 @@ message SyncMessage {
UNKNOWN = 0; UNKNOWN = 0;
ACCEPTED = 1; ACCEPTED = 1;
NOT_ACCEPTED = 2; NOT_ACCEPTED = 2;
DELETE = 3;
} }
optional bytes peerUuid = 1; optional bytes peerId = 1;
optional uint64 callId = 2; optional uint64 callId = 2;
optional uint64 timestamp = 3; optional uint64 timestamp = 3;
optional Type type = 4; optional Type type = 4;
@ -617,6 +620,15 @@ message SyncMessage {
optional Event event = 6; optional Event event = 6;
} }
message CallLogEvent {
enum Type {
CLEAR = 0;
}
optional Type type = 1;
optional uint64 timestamp = 2;
}
optional Sent sent = 1; optional Sent sent = 1;
optional Contacts contacts = 2; optional Contacts contacts = 2;
reserved /* groups */ 3; reserved /* groups */ 3;
@ -636,6 +648,8 @@ message SyncMessage {
reserved 17; // pniIdentity reserved 17; // pniIdentity
optional PniChangeNumber pniChangeNumber = 18; optional PniChangeNumber pniChangeNumber = 18;
optional CallEvent callEvent = 19; optional CallEvent callEvent = 19;
reserved 20; // callLinkUpdate
optional CallLogEvent callLogEvent = 21;
} }
message AttachmentPointer { message AttachmentPointer {

View file

@ -26,6 +26,7 @@ $color-black: #000000;
$color-white-alpha-06: rgba($color-white, 0.06); $color-white-alpha-06: rgba($color-white, 0.06);
$color-white-alpha-08: rgba($color-white, 0.08); $color-white-alpha-08: rgba($color-white, 0.08);
$color-white-alpha-12: rgba($color-white, 0.12); $color-white-alpha-12: rgba($color-white, 0.12);
$color-white-alpha-16: rgba($color-white, 0.16);
$color-white-alpha-20: rgba($color-white, 0.2); $color-white-alpha-20: rgba($color-white, 0.2);
$color-white-alpha-40: rgba($color-white, 0.4); $color-white-alpha-40: rgba($color-white, 0.4);
$color-white-alpha-60: rgba($color-white, 0.6); $color-white-alpha-60: rgba($color-white, 0.6);

View file

@ -0,0 +1,94 @@
// Copyright 2015 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
@import './mixins';
@keyframes panel--in--ltr {
from {
// stylelint-disable-next-line declaration-property-value-disallowed-list
transform: translateX(500px);
}
to {
// stylelint-disable-next-line declaration-property-value-disallowed-list
transform: translateX(0);
}
}
@keyframes panel--in--rtl {
from {
// stylelint-disable-next-line declaration-property-value-disallowed-list
transform: translateX(-500px);
}
to {
// stylelint-disable-next-line declaration-property-value-disallowed-list
transform: translateX(0);
}
}
.conversation {
@include light-theme {
background-color: $color-white;
}
@include dark-theme {
background-color: $color-gray-95;
}
.panel {
&:not(.main) {
&:dir(ltr) {
animation: panel--in--ltr 350ms cubic-bezier(0.17, 0.17, 0, 1);
}
&:dir(rtl) {
animation: panel--in--rtl 350ms cubic-bezier(0.17, 0.17, 0, 1);
}
}
&--static {
animation: none;
}
&--remove {
&:dir(ltr) {
// stylelint-disable-next-line declaration-property-value-disallowed-list
transform: translateX(100%);
}
&:dir(rtl) {
// stylelint-disable-next-line declaration-property-value-disallowed-list
transform: translateX(-100%);
}
transition: transform 350ms cubic-bezier(0.17, 0.17, 0, 1);
}
}
}
// Make sure the main panel is hidden when other panels are in the dom
.panel + .main.panel {
display: none;
}
.message-detail-wrapper {
height: calc(100% - 48px);
width: 100%;
overflow-y: auto;
}
.typing-bubble-wrapper {
margin-bottom: 20px;
}
.contact-detail-pane {
overflow-y: scroll;
padding-top: 40px;
padding-bottom: 40px;
}
.permissions-popup,
.debug-log-window {
.modal {
background-color: transparent;
padding: 0;
}
}

View file

@ -43,6 +43,14 @@
letter-spacing: -0.34px; letter-spacing: -0.34px;
} }
@mixin font-title-medium {
@include font-family;
font-weight: 600;
font-size: 18px;
line-height: 25px;
letter-spacing: -0.25px;
}
@mixin font-body-1 { @mixin font-body-1 {
@include font-family; @include font-family;
font-size: 14px; font-size: 14px;
@ -858,3 +866,15 @@ $rtl-icon-map: (
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
} }
@mixin NavTabs__Scroller {
@include scrollbar;
&::-webkit-scrollbar-thumb {
@include light-theme {
border-color: $color-gray-04;
}
@include dark-theme {
border-color: $color-gray-80;
}
}
}

View file

@ -2445,166 +2445,6 @@ button.ConversationDetails__action-button {
} }
} }
// Module: Main Header
.module-main-header {
-webkit-app-region: var(--draggable-app-region);
height: calc(#{$header-height} + var(--title-bar-drag-area-height));
width: 100%;
padding-inline: 16px;
padding-top: var(--title-bar-drag-area-height);
display: flex;
justify-content: space-between;
flex-direction: row;
align-items: center;
.module-left-pane--width-narrow & {
flex-direction: column;
height: auto;
justify-content: center;
}
& > * {
margin-inline-end: 12px;
&:last-child {
margin-inline-end: 0;
}
}
&__avatar {
-webkit-app-region: no-drag;
&--container {
position: relative;
.module-left-pane--width-narrow & {
margin-bottom: 20px;
margin-inline-end: 0;
margin-top: 12px;
}
}
&--badged {
background: $color-ultramarine;
border-radius: 100%;
border: 1px solid $color-white;
height: 8px;
width: 8px;
position: absolute;
top: 0;
inset-inline-end: 0;
}
}
&__icon-container {
display: flex;
.module-left-pane--width-narrow & {
flex-direction: column-reverse;
}
}
&__compose-icon {
-webkit-app-region: no-drag;
align-items: center;
background: none;
border-radius: 4px;
border: 2px solid transparent;
display: inline-flex;
height: 28px;
justify-content: center;
width: 28px;
padding: 0px;
@include keyboard-mode {
&:focus {
border-color: $color-ultramarine;
outline: none;
}
}
&::before {
$icon: '../images/icons/v3/compose/compose.svg';
width: 20px;
height: 20px;
content: '';
@include light-theme {
@include color-svg($icon, $color-gray-75);
}
@include dark-theme {
@include color-svg($icon, $color-gray-15);
}
}
}
&__stories-badge {
@include rounded-corners;
align-items: center;
background-color: $color-accent-red;
color: $color-white;
display: flex;
font-size: 10px;
height: 16px;
justify-content: center;
min-width: 16px;
overflow: hidden;
padding-block: 0;
padding-inline: 2px;
position: absolute;
inset-inline-end: -6px;
top: -4px;
user-select: none;
z-index: $z-index-base;
}
&__stories-icon {
-webkit-app-region: no-drag;
align-items: center;
background: none;
border-radius: 4px;
border: 2px solid transparent;
display: inline-flex;
height: 28px;
justify-content: center;
margin-inline-end: 12px;
position: relative;
width: 28px;
padding: 0px;
.module-left-pane--width-narrow & {
margin-inline-end: 0;
margin-top: 16px;
margin-bottom: 20px;
}
@include keyboard-mode {
&:focus {
border-color: $color-ultramarine;
outline: none;
}
}
&::before {
$icon: '../images/icons/v3/stories/stories.svg';
width: 20px;
height: 20px;
content: '';
@include light-theme {
@include color-svg($icon, $color-gray-75);
}
@include dark-theme {
@include color-svg($icon, $color-gray-15);
}
}
}
}
// Module: Image // Module: Image
.module-image { .module-image {
@ -4365,7 +4205,7 @@ button.module-image__border-overlay:focus {
.module-conversation-list { .module-conversation-list {
$normal-row-height: 72px; $normal-row-height: 72px;
@include scrollbar; @include NavTabs__Scroller;
padding-inline: 10px; padding-inline: 10px;
// list tiles in choose-group-members and compose extend to the edge // list tiles in choose-group-members and compose extend to the edge
@ -5045,33 +4885,18 @@ button.module-image__border-overlay:focus {
// Module: Left Pane // Module: Left Pane
.module-left-pane { .module-left-pane {
border-inline-end-style: solid; display: flex;
border-inline-end-width: 1px;
display: inline-flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
width: 100%;
position: relative; position: relative;
@include light-theme { @include light-theme {
$background-color: $color-gray-02; $background-color: $color-gray-02;
border-color: $color-gray-15;
background-color: $background-color;
::-webkit-scrollbar-thumb {
border: 2px solid $color-gray-02;
}
} }
@include dark-theme { @include dark-theme {
$background-color: $color-gray-80; $background-color: $color-gray-80;
border-color: $color-gray-65;
background-color: $background-color;
::-webkit-scrollbar-thumb {
border: 2px solid $color-gray-80;
}
} }
} }
@ -5122,13 +4947,11 @@ button.module-image__border-overlay:focus {
user-select: none; user-select: none;
&__contents { &__contents {
height: calc(#{$header-height} + var(--title-bar-drag-area-height));
width: 100%; width: 100%;
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding-top: var(--title-bar-drag-area-height); padding-block: 15px;
&__back-button { &__back-button {
@include button-reset; @include button-reset;
@ -5248,6 +5071,36 @@ button.module-image__border-overlay:focus {
} }
} }
.module-left-pane__startComposingIcon {
display: block;
width: 20px;
height: 20px;
@include light-theme {
@include color-svg(
'../images/icons/v3/compose/compose.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/compose/compose.svg',
$color-gray-15
);
}
}
.module-left-pane__moreActionsIcon {
display: block;
width: 20px;
height: 20px;
@include light-theme {
@include color-svg('../images/icons/v3/more/more.svg', $color-black);
}
@include dark-theme {
@include color-svg('../images/icons/v3/more/more.svg', $color-gray-15);
}
}
.module-left-pane__archive-helper-text { .module-left-pane__archive-helper-text {
@include font-body-2; @include font-body-2;

View file

@ -36,6 +36,7 @@ $color-black: #000000;
$color-white-alpha-06: rgba($color-white, 0.06); $color-white-alpha-06: rgba($color-white, 0.06);
$color-white-alpha-08: rgba($color-white, 0.08); $color-white-alpha-08: rgba($color-white, 0.08);
$color-white-alpha-12: rgba($color-white, 0.12); $color-white-alpha-12: rgba($color-white, 0.12);
$color-white-alpha-16: rgba($color-white, 0.16);
$color-white-alpha-20: rgba($color-white, 0.2); $color-white-alpha-20: rgba($color-white, 0.2);
$color-white-alpha-40: rgba($color-white, 0.4); $color-white-alpha-40: rgba($color-white, 0.4);
$color-white-alpha-60: rgba($color-white, 0.6); $color-white-alpha-60: rgba($color-white, 0.6);
@ -280,3 +281,9 @@ $z-index-modal-host: 102;
$z-index-above-popup: 103; $z-index-above-popup: 103;
$z-index-calling-pip: 104; $z-index-calling-pip: 104;
$z-index-above-context-menu: 126; $z-index-above-context-menu: 126;
// global navTabs
$NavTabs__width: 80px;
// These values are 'block' specific to coordinate with the NavSidebar__Header
$NavTabs__Item__blockPadding: 2px;
$NavTabs__ItemButton__blockPadding: 10px;

View file

@ -259,6 +259,10 @@
@include button-icon('../images/icons/v3/phone/phone-compact.svg'); @include button-icon('../images/icons/v3/phone/phone-compact.svg');
} }
&--message::before {
@include button-icon('../images/icons/v3/chat/chat-compact.svg');
}
&--muted::before { &--muted::before {
@include button-icon('../images/icons/v3/bell/bell-slash-compact.svg'); @include button-icon('../images/icons/v3/bell/bell-slash-compact.svg');
} }

View file

@ -0,0 +1,305 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.CallsTab {
display: flex;
width: 100%;
height: 100%;
}
.CallsTab__NewCallActionIcon {
display: block;
width: 20px;
height: 20px;
@include light-theme {
@include color-svg(
'../images/icons/v3/phone/phone-plus-light.svg',
$color-black
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/phone/phone-plus-light.svg',
$color-gray-15
);
}
}
.CallsTab__MoreActionsIcon {
display: block;
width: 20px;
height: 20px;
@include light-theme {
@include color-svg('../images/icons/v3/more/more.svg', $color-black);
}
@include dark-theme {
@include color-svg('../images/icons/v3/more/more.svg', $color-gray-15);
}
}
.CallsTab__EmptyState {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
}
.CallsTab__ConversationCallDetails {
display: block;
overflow: auto;
width: 100%;
height: 100%;
padding-block: 80px;
}
.CallsTab__ClearCallHistoryIcon {
@include light-theme {
@include color-svg(
'../images/icons/v3/trash/trash-compact.svg',
$color-gray-90
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/trash/trash-compact.svg',
$color-white
);
}
}
.CallsList__Header {
display: flex;
gap: 0px;
}
.CallsList__ToggleFilterByMissed {
@include button-reset;
flex-shrink: 0;
padding: 4px;
border-radius: 4px;
&:not(.CallsList__ToggleFilterByMissed--pressed):hover {
@include light-theme {
background: $color-gray-20;
}
@include dark-theme {
background: $color-gray-62;
}
}
&:focus {
outline: none;
}
&:focus-visible {
box-shadow: 0 0 0 2px $color-white, 0 0 0 4px $color-ultramarine;
}
&::before {
content: '';
display: block;
width: 20px;
height: 20px;
@include light-theme {
@include color-svg('../images/icons/v3/filter/filter.svg', $color-black);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/filter/filter.svg',
$color-gray-15
);
}
}
}
.CallsList__ToggleFilterByMissed--pressed {
border-radius: 9999px;
background: $color-accent-blue;
&::before {
@include color-svg('../images/icons/v3/filter/filter.svg', $color-white);
}
}
.CallsList__ToggleFilterByMissedLabel {
@include sr-only;
}
.CallsList__ListContainer {
flex-grow: 1;
overflow: hidden;
}
.CallsList__List {
@include NavTabs__Scroller;
}
.CallsList__List--loading {
overflow: hidden !important;
}
.CallsList__EmptyState {
padding-block: 28px;
padding-inline: 16px;
text-align: center;
text-wrap: balance;
}
.CallsList__ItemIcon {
display: block;
width: 20px;
height: 20px;
}
.CallsList__ItemIcon--Phone {
@include light-theme {
@include color-svg('../images/icons/v3/phone/phone.svg', $color-black);
}
@include dark-theme {
@include color-svg('../images/icons/v3/phone/phone.svg', $color-gray-15);
}
}
.CallsList__ItemIcon--Video {
@include light-theme {
@include color-svg('../images/icons/v3/video/video.svg', $color-black);
}
@include dark-theme {
@include color-svg('../images/icons/v3/video/video.svg', $color-gray-15);
}
}
.CallsList__LoadingAvatar,
.CallsList__LoadingText {
animation: CallsList__LoadingPulse 1.5s ease-in-out infinite;
@include light-theme {
background-color: $color-gray-05;
}
@include dark-theme {
background-color: $color-gray-75;
}
}
.CallsList__LoadingAvatar {
display: block;
width: 32px;
height: 32px;
border-radius: 9999px;
}
.CallsList__LoadingText {
display: inline-block; // ensure uses line-height
height: 1em;
border-radius: 4px;
}
.CallsList__LoadingText--title {
width: 75%;
}
.CallsList__LoadingText--subtitle {
width: 60%;
}
.CallsList__ItemTitle {
font-weight: bold;
}
.CallsList__ItemCallInfo--missed {
color: $color-accent-red;
}
.CallsList__Item--selected .CallsList__ItemTile {
@include light-theme {
background-color: $color-gray-15;
}
@include dark-theme {
background-color: $color-gray-65;
}
}
@keyframes CallsList__LoadingPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
.CallsNewCall__ListContainer {
flex-grow: 1;
overflow: hidden;
}
.CallsNewCall__List {
@include NavTabs__Scroller;
}
.CallsNewCall__ListHeaderItem {
padding-block: 10px;
padding-inline: 24px;
@include font-body-1-bold;
}
.CallsNewCall__EmptyState {
padding-block: 28px;
padding-inline: 16px;
text-align: center;
}
.CallsNewCall__ItemActions {
display: flex;
gap: 12px;
align-items: center;
}
.CallsNewCall__ItemActionButton {
@include button-reset;
padding: 4px;
border-radius: 4px;
&:not(:disabled, [aria-disabled='true']):hover {
@include light-theme {
background: $color-gray-20;
}
@include dark-theme {
background: $color-gray-62;
}
}
&:focus {
outline: none;
}
&:focus-visible {
box-shadow: 0 0 0 2px $color-ultramarine;
}
&:disabled,
&[aria-disabled='true'] {
opacity: 0.5;
}
}
.CallsNewCall__ItemIcon {
display: block;
width: 20px;
height: 20px;
}
.CallsNewCall__ItemIcon--Phone {
@include light-theme {
@include color-svg('../images/icons/v3/phone/phone.svg', $color-black);
}
@include dark-theme {
@include color-svg('../images/icons/v3/phone/phone.svg', $color-gray-15);
}
}
.CallsNewCall__ItemIcon--Video {
@include light-theme {
@include color-svg('../images/icons/v3/video/video.svg', $color-black);
}
@include dark-theme {
@include color-svg('../images/icons/v3/video/video.svg', $color-gray-15);
}
}

View file

@ -512,3 +512,55 @@
} }
} }
} }
.ConversationDetails__CallHistoryGroup__header {
@include font-title-2;
margin-block: 24px 16px;
}
.ConversationDetails__CallHistoryGroup__List {
list-style: none;
margin: 0;
padding: 0;
}
.ConversationDetails__CallHistoryGroup__Item {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding-block: 10px;
padding-inline: 24px;
}
.ConversationDetails__CallHistoryGroup__ItemIcon {
display: block;
width: 20px;
height: 20px;
}
.ConversationDetails__CallHistoryGroup__ItemIcon--Audio {
@include light-theme {
@include color-svg('../images/icons/v3/phone/phone.svg', $color-black);
}
@include dark-theme {
@include color-svg('../images/icons/v3/phone/phone.svg', $color-gray-15);
}
}
.ConversationDetails__CallHistoryGroup__ItemIcon--Video {
@include light-theme {
@include color-svg('../images/icons/v3/video/video.svg', $color-black);
}
@include dark-theme {
@include color-svg('../images/icons/v3/video/video.svg', $color-gray-15);
}
}
.ConversationDetails__CallHistoryGroup__ItemLabel {
flex: 1;
}
.ConversationDetails__CallHistoryGroup__ItemTimestamp {
flex-shrink: 0;
}

View file

@ -144,7 +144,7 @@
@include rounded-corners; @include rounded-corners;
align-items: center; align-items: center;
background: $color-ultramarine-dawn; background: $color-ultramarine-dawn;
border: 2px solid $color-gray-80; border: 2px solid;
bottom: -2px; bottom: -2px;
display: flex; display: flex;
height: 20px; height: 20px;
@ -154,6 +154,14 @@
width: 20px; width: 20px;
z-index: $z-index-base; z-index: $z-index-base;
@include light-theme {
border-color: $color-gray-04;
}
@include dark-theme {
border-color: $color-gray-80;
}
&::after { &::after {
content: ''; content: '';
@include color-svg( @include color-svg(
@ -166,6 +174,14 @@
} }
} }
.StoryListItem__button:hover .MyStories__avatar__add-story { .StoryListItem__button:hover,
border-color: $color-gray-65; .StoryListItem__button--active {
.MyStories__avatar__add-story {
@include light-theme {
border-color: $color-gray-15;
}
@include dark-theme {
border-color: $color-gray-65;
}
}
} }

View file

@ -0,0 +1,207 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.NavSidebar {
position: relative;
z-index: 10;
height: 100%;
display: flex;
flex-direction: column;
flex-shrink: 0;
padding-top: var(--title-bar-drag-area-height);
user-select: none;
@include light-theme {
background-color: $color-gray-04;
border-inline-end: 1px solid $color-black-alpha-16;
}
@include dark-theme {
background-color: $color-gray-80;
border-inline-end: 1px solid $color-white-alpha-16;
}
}
.NavSidebar__Header {
display: flex;
align-items: start;
flex-shrink: 0;
padding-bottom: 6px;
.NavTabs__Toggle {
width: $NavTabs__width;
}
.NavSidebar--narrow & {
flex-direction: column;
align-items: center;
}
}
.NavSidebar__HeaderContent {
display: flex;
width: 100%;
flex: 1;
align-items: center;
justify-content: center;
padding-block: calc(
$NavTabs__Item__blockPadding + $NavTabs__ItemButton__blockPadding
);
padding-inline: 24px;
.NavSidebar--narrow & {
padding-inline: 0;
}
}
.NavSidebar__HeaderContent--navTabsCollapsed:not(
.NavSidebar__HeaderContent--withBackButton
) {
padding-inline-start: 0;
}
.NavSidebar__HeaderContent--withBackButton {
padding-inline-start: 16px;
}
.NavSidebar__HeaderTitle {
flex: 1 1 0%;
margin: 0;
@include font-title-medium;
line-height: 20px;
.NavSidebar--narrow & {
@include sr-only;
}
}
.NavSidebar__HeaderTitle--withBackButton {
text-align: center;
@include font-body-1-bold;
}
.NavSidebar__BackButton {
@include button-reset();
margin-block: -4px;
padding: 4px;
border-radius: 4px;
&:hover {
@include light-theme {
background: $color-gray-20;
}
@include dark-theme {
background: $color-gray-62;
}
}
&:focus {
outline: none;
}
&:focus-visible {
box-shadow: 0 0 0 2px $color-ultramarine;
}
&::before {
content: '';
display: block;
width: 20px;
height: 20px;
@include light-theme {
@include color-svg(
'../images/icons/v3/chevron/chevron-left.svg',
$color-black
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/chevron/chevron-left.svg',
$color-gray-15
);
}
}
}
.NavSidebar__BackButtonLabel {
@include sr-only;
}
.NavSidebar__Content {
flex: 1 1 0%;
min-height: 0;
display: flex;
flex-direction: column;
}
.NavSidebar__DragHandle {
position: absolute;
top: 0;
bottom: 0;
inset-inline-start: 100%;
width: 8px;
background: transparent;
cursor: col-resize;
&:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px $color-ultramarine;
}
}
.NavSidebar__DragHandle--dragging {
@include light-theme {
background-color: $color-black-alpha-12;
}
@include dark-theme {
background-color: $color-white-alpha-12;
}
}
.NavSidebar__document--draggingHandle {
cursor: col-resize;
}
.NavSidebar__HeaderActions {
display: flex;
gap: 20px;
margin-block: -4px;
align-items: center;
justify-content: center;
.NavSidebar--narrow & {
flex-direction: column;
}
}
.NavSidebar__ActionButton {
@include button-reset();
padding: 4px;
border-radius: 4px;
&:hover {
@include light-theme {
background: $color-gray-20;
}
@include dark-theme {
background: $color-gray-62;
}
}
&:focus {
outline: none;
}
&:focus-visible {
box-shadow: 0 0 0 2px $color-ultramarine;
}
}
.NavSidebar__ActionButtonLabel {
@include sr-only;
}
.NavSidebarSearchHeader {
display: flex;
margin-inline: 16px;
margin-bottom: 8px;
gap: 12px;
}

View file

@ -0,0 +1,183 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// This effectively wraps the entire app
.NavTabs__Container {
position: relative;
z-index: 0;
display: flex;
width: 100%;
height: 100%;
}
.NavTabs {
display: flex;
flex-shrink: 0;
flex-direction: column;
width: $NavTabs__width;
height: 100%;
padding-top: var(--title-bar-drag-area-height);
@include light-theme {
background-color: $color-gray-04;
border-inline-end: 1px solid $color-black-alpha-16;
}
@include dark-theme {
background-color: $color-gray-80;
border-inline-end: 1px solid $color-white-alpha-16;
}
}
.NavTabs--collapsed {
display: none;
}
// Wraps .NavTabs__ItemButton to make the hitbox larger
.NavTabs__Item {
width: 100%;
padding-block: $NavTabs__Item__blockPadding;
padding-inline: 10px;
border: none;
background: transparent;
cursor: pointer;
&:focus {
// Handled by .NavTabs__ItemButton
outline: none;
}
}
.NavTabs__ItemButton {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: $NavTabs__ItemButton__blockPadding;
border-radius: 8px;
.NavTabs__Item:hover &,
.NavTabs__Item:focus-visible & {
@include light-theme {
background-color: $color-black-alpha-06;
}
@include dark-theme {
background-color: $color-white-alpha-06;
}
}
.NavTabs__Item:focus-visible & {
box-shadow: 0 0 0 2px $color-ultramarine;
}
.NavTabs__Item:active &,
.NavTabs__Item[aria-selected='true'] & {
@include light-theme {
background: $color-gray-20;
}
@include dark-theme {
background: $color-gray-62;
}
}
}
.NavTabs__ItemContent {
display: inline-flex;
position: relative;
}
.NavTabs__ItemLabel {
@include sr-only;
}
.NavTabs__ItemBadge {
@include rounded-corners;
align-items: center;
background-color: $color-accent-red;
color: $color-white;
display: flex;
font-size: 10px;
height: 16px;
justify-content: center;
min-width: 16px;
overflow: hidden;
padding-block: 0;
padding-inline: 2px;
position: absolute;
inset-inline-end: -6px;
top: -4px;
user-select: none;
z-index: $z-index-base;
word-break: keep-all;
}
.NavTabs__ItemIcon {
display: block;
width: 20px;
height: 20px;
}
@mixin NavTabs__Icon($icon) {
@include light-theme {
@include color-svg($icon, $color-black);
}
@include dark-theme {
@include color-svg($icon, $color-gray-15);
}
}
.NavTabs__ItemIcon--Menu {
@include NavTabs__Icon('../images/icons/v3/menu/menu.svg');
}
.NavTabs__ItemIcon--Settings {
@include NavTabs__Icon('../images/icons/v3/settings/settings.svg');
}
.NavTabs__ItemIcon--Chats {
@include NavTabs__Icon('../images/icons/v3/chat/chat.svg');
.NavTabs__Item:active &,
.NavTabs__Item[aria-selected='true'] & {
@include NavTabs__Icon('../images/icons/v3/chat/chat-fill.svg');
}
}
.NavTabs__ItemIcon--Calls {
@include NavTabs__Icon('../images/icons/v3/phone/phone.svg');
.NavTabs__Item:active &,
.NavTabs__Item[aria-selected='true'] & {
@include NavTabs__Icon('../images/icons/v3/phone/phone-fill.svg');
}
}
.NavTabs__ItemIcon--Stories {
@include NavTabs__Icon('../images/icons/v3/stories/stories.svg');
.NavTabs__Item:active &,
.NavTabs__Item[aria-selected='true'] & {
@include NavTabs__Icon('../images/icons/v3/stories/stories-fill.svg');
}
}
.NavTabs__ItemIconLabel {
@include sr-only;
}
.NavTabs__TabList {
flex: 1;
}
.NavTabs__Misc {
padding-bottom: 8px;
}
.NavTabs__TabPanel {
position: relative;
display: flex;
flex: 1;
min-width: 0;
}
.NavTabs__AvatarBadge {
background: $color-ultramarine;
border-radius: 100%;
border: 1px solid $color-white;
height: 8px;
width: 8px;
position: absolute;
top: 0;
inset-inline-end: 0;
}

View file

@ -3,9 +3,8 @@
.module-SearchInput { .module-SearchInput {
&__container { &__container {
margin-inline: 16px;
margin-bottom: 8px;
position: relative; position: relative;
flex: 1 0 0;
} }
&__icon { &__icon {

View file

@ -1,8 +1,6 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
.Stories { .Stories {
background: $color-gray-95;
display: flex; display: flex;
height: var(--window-height); height: var(--window-height);
inset-inline-start: 0; inset-inline-start: 0;
@ -11,42 +9,37 @@
user-select: none; user-select: none;
width: 100%; width: 100%;
z-index: $z-index-stories; z-index: $z-index-stories;
@include light-theme {
background: $color-white;
}
@include dark-theme {
background: $color-gray-95;
}
&__pane { &__pane {
background: $color-gray-80;
border-inline-end: 1px solid $color-gray-65;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
width: 380px; width: 380px;
padding-top: calc(14px + var(--title-bar-drag-area-height)); padding-top: calc(2px + var(--title-bar-drag-area-height));
@include light-theme {
&__add-story__button { background: $color-gray-04;
@include color-svg('../images/icons/v3/plus/plus.svg', $color-white); border-inline-end: 1px solid $color-black-alpha-16;
height: 20px; }
position: absolute; @include dark-theme {
inset-inline-end: 64px; background: $color-gray-80;
top: 0px; border-inline-end: 1px solid $color-white-alpha-16;
width: 20px;
&:focus {
@include keyboard-mode {
background-color: $color-ultramarine;
}
}
} }
&__settings__button { &__add-story__button {
@include dark-theme {
@include color-svg('../images/icons/v3/more/more.svg', $color-white);
}
height: 20px; height: 20px;
margin-inline-start: 20px;
opacity: 1;
position: absolute;
inset-inline-end: 24px;
top: 0px;
width: 20px; width: 20px;
@include light-theme {
@include color-svg('../images/icons/v3/plus/plus.svg', $color-black);
}
@include dark-theme {
@include color-svg('../images/icons/v3/plus/plus.svg', $color-gray-15);
}
&:focus { &:focus {
@include keyboard-mode { @include keyboard-mode {
@ -68,55 +61,40 @@
} }
&--title { &--title {
@include font-body-1-bold; @include font-title-medium;
color: $color-gray-05;
display: flex;
flex: 1; flex: 1;
justify-content: center; @include light-theme {
color: $color-black;
}
@include dark-theme {
color: $color-gray-05;
}
} }
&--centered .Stories__pane__header--title { &--centered .Stories__pane__header--title {
text-align: center; text-align: center;
width: 100%; width: 100%;
} }
&--back {
@include button-reset;
-webkit-app-region: no-drag;
height: 20px;
position: absolute;
width: 20px;
@include color-svg(
'../images/icons/v3/chevron/chevron-left.svg',
$color-white
);
@include keyboard-mode {
&:hover {
@include color-svg(
'../images/icons/v3/chevron/chevron-left.svg',
$color-ultramarine-light
);
}
}
}
} }
&__list { &__list {
@include scrollbar; @include NavTabs__Scroller;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
overflow-y: overlay; overflow-y: overlay;
padding-block: 0; padding-block: 0;
padding-inline: 14px; padding-inline: 16px;
&--empty { &--empty {
@include font-body-1; @include font-body-1;
align-items: center; align-items: center;
color: $color-gray-45; @include light-theme() {
color: $color-gray-60;
}
@include dark-theme() {
color: $color-gray-45;
}
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
@ -125,11 +103,6 @@
} }
} }
&__search__container {
margin-block: 14px 10px;
margin-inline: 16px;
}
&__placeholder { &__placeholder {
align-items: center; align-items: center;
color: $color-gray-45; color: $color-gray-45;
@ -154,33 +127,69 @@
@include button-reset; @include button-reset;
@include font-body-1-bold; @include font-body-1-bold;
align-items: center; align-items: center;
color: $color-gray-05;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding-block: 12px; padding-block: 12px;
padding-inline: 24px; padding-inline: 24px;
position: relative; position: relative;
width: 100%; width: 100%;
@include light-theme {
color: $color-black;
}
@include dark-theme {
color: $color-gray-05;
}
&::after { &::after {
@include color-svg(
'../images/icons/v3/chevron/chevron-right.svg',
$color-gray-05
);
content: ''; content: '';
height: 16px; height: 16px;
width: 16px; width: 16px;
} }
&--collapsed {
&::after {
@include light-theme {
@include color-svg(
'../images/icons/v3/chevron/chevron-right.svg',
$color-black
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/chevron/chevron-right.svg',
$color-gray-05
);
}
}
}
&--expanded { &--expanded {
// Override color-svg &::after {
:dir(ltr) &::after, @include light-theme {
:dir(rtl) &::after { @include color-svg(
@include color-svg( '../images/icons/v3/chevron/chevron-down.svg',
'../images/icons/v3/chevron/chevron-down.svg', $color-black
$color-gray-05 );
); }
@include dark-theme {
@include color-svg(
'../images/icons/v3/chevron/chevron-down.svg',
$color-gray-05
);
}
} }
} }
} }
} }
.StoriesTab__MoreActionsIcon {
display: block;
width: 20px;
height: 20px;
@include light-theme {
@include color-svg('../images/icons/v3/more/more.svg', $color-black);
}
@include dark-theme {
@include color-svg('../images/icons/v3/more/more.svg', $color-gray-15);
}
}

View file

@ -15,7 +15,12 @@
@include keyboard-mode { @include keyboard-mode {
&:focus { &:focus {
background: $color-gray-65; @include light-theme {
background: $color-gray-15;
}
@include dark-theme {
background: $color-gray-65;
}
} }
} }
@ -23,7 +28,12 @@
// that has not been closed yet (active) // that has not been closed yet (active)
&:hover, &:hover,
&--active { &--active {
background: $color-gray-65; @include light-theme {
background: $color-gray-15;
}
@include dark-theme {
background: $color-gray-65;
}
} }
} }
@ -60,16 +70,26 @@
&--title { &--title {
@include font-body-1-bold; @include font-body-1-bold;
color: $color-gray-05;
display: flex; display: flex;
align-items: center; align-items: center;
@include light-theme {
color: $color-black;
}
@include dark-theme {
color: $color-gray-05;
}
} }
&--timestamp, &--timestamp,
&--sending, &--sending,
&--send_failed { &--send_failed {
@include font-body-2; @include font-body-2;
color: $color-gray-25; @include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
} }
&--send_failed { &--send_failed {
@ -148,32 +168,31 @@
} }
&__icon { &__icon {
@mixin StoryListItem__Icon($path) {
@include light-theme {
@include color-svg($path, $color-black);
}
@include dark-theme {
@include color-svg($path, $color-white);
}
}
&--chat { &--chat {
@include color-svg( @include StoryListItem__Icon('../images/icons/v3/open/open-compact.svg');
'../images/icons/v3/open/open-compact.svg',
$color-white
);
} }
&--delete { &--delete {
@include color-svg( @include StoryListItem__Icon(
'../images/icons/v3/trash/trash-compact.svg', '../images/icons/v3/trash/trash-compact.svg'
$color-white
); );
} }
&--hide { &--hide {
@include color-svg( @include StoryListItem__Icon('../images/icons/v3/x/x-circle-compact.svg');
'../images/icons/v3/x/x-circle-compact.svg',
$color-white
);
} }
&--info { &--info {
@include color-svg( @include StoryListItem__Icon('../images/icons/v3/info/info-compact.svg');
'../images/icons/v3/info/info-compact.svg',
$color-white
);
} }
} }

View file

@ -35,6 +35,7 @@
@import './components/BadgeSustainerInstructionsDialog.scss'; @import './components/BadgeSustainerInstructionsDialog.scss';
@import './components/BetterAvatarBubble.scss'; @import './components/BetterAvatarBubble.scss';
@import './components/Button.scss'; @import './components/Button.scss';
@import './components/CallsTab.scss';
@import './components/CallingAudioIndicator.scss'; @import './components/CallingAudioIndicator.scss';
@import './components/CallingButton.scss'; @import './components/CallingButton.scss';
@import './components/CallingLobby.scss'; @import './components/CallingLobby.scss';
@ -102,6 +103,8 @@
@import './components/MiniPlayer.scss'; @import './components/MiniPlayer.scss';
@import './components/Modal.scss'; @import './components/Modal.scss';
@import './components/MyStories.scss'; @import './components/MyStories.scss';
@import './components/NavSidebar.scss';
@import './components/NavTabs.scss';
@import './components/OutgoingGiftBadgeModal.scss'; @import './components/OutgoingGiftBadgeModal.scss';
@import './components/PermissionsPopup.scss'; @import './components/PermissionsPopup.scss';
@import './components/PlaybackButton.scss'; @import './components/PlaybackButton.scss';

View file

@ -3,7 +3,7 @@
import { Bytes } from './context/Bytes'; import { Bytes } from './context/Bytes';
const bytes = window.SignalContext?.bytes || new Bytes(); const bytes = globalThis.window?.SignalContext?.bytes || new Bytes();
export function fromBase64(value: string): Uint8Array { export function fromBase64(value: string): Uint8Array {
return bytes.fromBase64(value); return bytes.fromBase64(value);

View file

@ -7,13 +7,9 @@ import { HKDF } from '@signalapp/libsignal-client';
import * as Bytes from './Bytes'; import * as Bytes from './Bytes';
import { calculateAgreement, generateKeyPair } from './Curve'; import { calculateAgreement, generateKeyPair } from './Curve';
import * as log from './logging/log';
import { HashType, CipherType } from './types/Crypto'; import { HashType, CipherType } from './types/Crypto';
import { ProfileDecryptError } from './types/errors'; import { ProfileDecryptError } from './types/errors';
import { UUID, UUID_BYTE_SIZE } from './types/UUID'; import { getBytesSubarray } from './util/uuidToBytes';
import type { UUIDStringType } from './types/UUID';
export { uuidToBytes } from './util/uuidToBytes';
export { HashType, CipherType }; export { HashType, CipherType };
@ -199,12 +195,16 @@ export function decryptSymmetric(
const iv = getZeroes(IV_LENGTH); const iv = getZeroes(IV_LENGTH);
const nonce = getFirstBytes(data, NONCE_LENGTH); const nonce = getFirstBytes(data, NONCE_LENGTH);
const ciphertext = getBytes( const ciphertext = getBytesSubarray(
data, data,
NONCE_LENGTH, NONCE_LENGTH,
data.byteLength - NONCE_LENGTH - MAC_LENGTH data.byteLength - NONCE_LENGTH - MAC_LENGTH
); );
const theirMac = getBytes(data, data.byteLength - MAC_LENGTH, MAC_LENGTH); const theirMac = getBytesSubarray(
data,
data.byteLength - MAC_LENGTH,
MAC_LENGTH
);
const cipherKey = hmacSha256(key, nonce); const cipherKey = hmacSha256(key, nonce);
const macKey = hmacSha256(key, cipherKey); const macKey = hmacSha256(key, cipherKey);
@ -353,52 +353,6 @@ export function getFirstBytes(data: Uint8Array, n: number): Uint8Array {
return data.subarray(0, n); return data.subarray(0, n);
} }
export function getBytes(
data: Uint8Array,
start: number,
n: number
): Uint8Array {
return data.subarray(start, start + n);
}
export function bytesToUuid(bytes: Uint8Array): undefined | UUIDStringType {
if (bytes.byteLength !== UUID_BYTE_SIZE) {
log.warn(
'bytesToUuid: received an Uint8Array of invalid length. ' +
'Returning undefined'
);
return undefined;
}
const uuids = splitUuids(bytes);
if (uuids.length === 1) {
return uuids[0] || undefined;
}
return undefined;
}
export function splitUuids(buffer: Uint8Array): Array<UUIDStringType | null> {
const uuids = new Array<UUIDStringType | null>();
for (let i = 0; i < buffer.byteLength; i += UUID_BYTE_SIZE) {
const bytes = getBytes(buffer, i, UUID_BYTE_SIZE);
const hex = Bytes.toHex(bytes);
const chunks = [
hex.substring(0, 8),
hex.substring(8, 12),
hex.substring(12, 16),
hex.substring(16, 20),
hex.substring(20),
];
const uuid = chunks.join('-');
if (uuid !== '00000000-0000-0000-0000-000000000000') {
uuids.push(UUID.cast(uuid));
} else {
uuids.push(null);
}
}
return uuids;
}
export function trimForDisplay(padded: Uint8Array): Uint8Array { export function trimForDisplay(padded: Uint8Array): Uint8Array {
let paddingEnd = 0; let paddingEnd = 0;
for (paddingEnd; paddingEnd < padded.length; paddingEnd += 1) { for (paddingEnd; paddingEnd < padded.length; paddingEnd += 1) {
@ -588,7 +542,7 @@ export function decryptProfileName(
// SignalContext APIs // SignalContext APIs
// //
const { crypto } = window.SignalContext; const { crypto } = globalThis.window?.SignalContext ?? {};
export function sign(key: Uint8Array, data: Uint8Array): Uint8Array { export function sign(key: Uint8Array, data: Uint8Array): Uint8Array {
return crypto.sign(key, data); return crypto.sign(key, data);

View file

@ -8,8 +8,8 @@ import * as log from './logging/log';
import type { UUIDStringType } from './types/UUID'; import type { UUIDStringType } from './types/UUID';
import { parseIntOrThrow } from './util/parseIntOrThrow'; import { parseIntOrThrow } from './util/parseIntOrThrow';
import { SECOND, HOUR } from './util/durations'; import { SECOND, HOUR } from './util/durations';
import { uuidToBytes } from './util/uuidToBytes';
import * as Bytes from './Bytes'; import * as Bytes from './Bytes';
import { uuidToBytes } from './util/uuidToBytes';
import { HashType } from './types/Crypto'; import { HashType } from './types/Crypto';
import { getCountryCode } from './types/PhoneNumber'; import { getCountryCode } from './types/PhoneNumber';

View file

@ -186,6 +186,11 @@ import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration'
import { makeLookup } from './util/makeLookup'; import { makeLookup } from './util/makeLookup';
import { addGlobalKeyboardShortcuts } from './services/addGlobalKeyboardShortcuts'; import { addGlobalKeyboardShortcuts } from './services/addGlobalKeyboardShortcuts';
import { createEventHandler } from './quill/signal-clipboard/util'; import { createEventHandler } from './quill/signal-clipboard/util';
import { onCallLogEventSync } from './util/onCallLogEventSync';
import {
getCallsHistoryForRedux,
loadCallsHistory,
} from './services/callHistoryLoader';
export function isOverHourIntoPast(timestamp: number): boolean { export function isOverHourIntoPast(timestamp: number): boolean {
return isNumber(timestamp) && isOlderThan(timestamp, HOUR); return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
@ -434,6 +439,10 @@ export async function startApp(): Promise<void> {
'callEventSync', 'callEventSync',
queuedEventListener(onCallEventSync, false) queuedEventListener(onCallEventSync, false)
); );
messageReceiver.addEventListener(
'callLogEventSync',
queuedEventListener(onCallLogEventSync, false)
);
}); });
ourProfileKeyService.initialize(window.storage); ourProfileKeyService.initialize(window.storage);
@ -1121,6 +1130,7 @@ export async function startApp(): Promise<void> {
loadInitialBadgesState(), loadInitialBadgesState(),
loadStories(), loadStories(),
loadDistributionLists(), loadDistributionLists(),
loadCallsHistory(),
window.textsecure.storage.protocol.hydrateCaches(), window.textsecure.storage.protocol.hydrateCaches(),
(async () => { (async () => {
mainWindowStats = await window.SignalContext.getMainWindowStats(); mainWindowStats = await window.SignalContext.getMainWindowStats();
@ -1174,6 +1184,7 @@ export async function startApp(): Promise<void> {
menuOptions, menuOptions,
stories: getStoriesForRedux(), stories: getStoriesForRedux(),
storyDistributionLists: getDistributionListsForRedux(), storyDistributionLists: getDistributionListsForRedux(),
callsHistory: getCallsHistoryForRedux(),
}); });
const store = window.Signal.State.createStore(initialState); const store = window.Signal.State.createStore(initialState);
@ -1193,6 +1204,10 @@ export async function startApp(): Promise<void> {
store.dispatch store.dispatch
), ),
badges: bindActionCreators(actionCreators.badges, store.dispatch), badges: bindActionCreators(actionCreators.badges, store.dispatch),
callHistory: bindActionCreators(
actionCreators.callHistory,
store.dispatch
),
calling: bindActionCreators(actionCreators.calling, store.dispatch), calling: bindActionCreators(actionCreators.calling, store.dispatch),
composer: bindActionCreators(actionCreators.composer, store.dispatch), composer: bindActionCreators(actionCreators.composer, store.dispatch),
conversations: bindActionCreators( conversations: bindActionCreators(

View file

@ -25,9 +25,7 @@ type PropsType = {
registerSingleDevice: (number: string, code: string) => Promise<void>; registerSingleDevice: (number: string, code: string) => Promise<void>;
renderCallManager: () => JSX.Element; renderCallManager: () => JSX.Element;
renderGlobalModalContainer: () => JSX.Element; renderGlobalModalContainer: () => JSX.Element;
isShowingStoriesView: boolean;
i18n: LocalizerType; i18n: LocalizerType;
renderStories: (closeView: () => unknown) => JSX.Element;
hasSelectedStoryData: boolean; hasSelectedStoryData: boolean;
renderStoryViewer: (closeView: () => unknown) => JSX.Element; renderStoryViewer: (closeView: () => unknown) => JSX.Element;
renderLightbox: () => JSX.Element | null; renderLightbox: () => JSX.Element | null;
@ -53,7 +51,6 @@ type PropsType = {
titleBarDoubleClick: () => void; titleBarDoubleClick: () => void;
toast?: AnyToast; toast?: AnyToast;
scrollToMessage: (conversationId: string, messageId: string) => unknown; scrollToMessage: (conversationId: string, messageId: string) => unknown;
toggleStoriesView: () => unknown;
viewStory: ViewStoryActionCreatorType; viewStory: ViewStoryActionCreatorType;
renderInbox: () => JSX.Element; renderInbox: () => JSX.Element;
}; };
@ -69,7 +66,6 @@ export function App({
i18n, i18n,
isFullScreen, isFullScreen,
isMaximized, isMaximized,
isShowingStoriesView,
menuOptions, menuOptions,
onUndoArchive, onUndoArchive,
openFileInFolder, openFileInFolder,
@ -81,13 +77,11 @@ export function App({
renderGlobalModalContainer, renderGlobalModalContainer,
renderInbox, renderInbox,
renderLightbox, renderLightbox,
renderStories,
renderStoryViewer, renderStoryViewer,
requestVerification, requestVerification,
theme, theme,
titleBarDoubleClick, titleBarDoubleClick,
toast, toast,
toggleStoriesView,
viewStory, viewStory,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
let contents; let contents;
@ -183,7 +177,6 @@ export function App({
{renderGlobalModalContainer()} {renderGlobalModalContainer()}
{renderCallManager()} {renderCallManager()}
{renderLightbox()} {renderLightbox()}
{isShowingStoriesView && renderStories(toggleStoriesView)}
{hasSelectedStoryData && {hasSelectedStoryData &&
renderStoryViewer(() => viewStory({ closeViewer: true }))} renderStoryViewer(() => viewStory({ closeViewer: true }))}
</div> </div>

View file

@ -12,6 +12,7 @@ import React, { useEffect, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { noop } from 'lodash'; import { noop } from 'lodash';
import { filterDOMProps } from '@react-aria/utils';
import type { AvatarColorType } from '../types/Colors'; import type { AvatarColorType } from '../types/Colors';
import type { BadgeType } from '../badges/types'; import type { BadgeType } from '../badges/types';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
@ -239,7 +240,7 @@ export function Avatar({
if (onClick) { if (onClick) {
contents = ( contents = (
<button <button
{...ariaProps} {...filterDOMProps(ariaProps)}
className={contentsClassName} className={contentsClassName}
type="button" type="button"
onClick={onClick} onClick={onClick}

View file

@ -46,8 +46,6 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false), noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
onEditProfile: action('onEditProfile'), onEditProfile: action('onEditProfile'),
onStartUpdate: action('startUpdate'), onStartUpdate: action('startUpdate'),
onViewArchive: action('onViewArchive'),
onViewPreferences: action('onViewPreferences'),
phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''), phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''),
profileName: text('profileName', overrideProps.profileName || ''), profileName: text('profileName', overrideProps.profileName || ''),
sharedGroupNames: [], sharedGroupNames: [],

View file

@ -19,8 +19,6 @@ export type Props = {
onEditProfile: () => unknown; onEditProfile: () => unknown;
onStartUpdate: () => unknown; onStartUpdate: () => unknown;
onViewPreferences: () => unknown;
onViewArchive: () => unknown;
// Matches Popper's RefHandler type // Matches Popper's RefHandler type
innerRef?: React.Ref<HTMLDivElement>; innerRef?: React.Ref<HTMLDivElement>;
@ -35,8 +33,6 @@ export function AvatarPopup(props: Props): JSX.Element {
name, name,
onEditProfile, onEditProfile,
onStartUpdate, onStartUpdate,
onViewArchive,
onViewPreferences,
phoneNumber, phoneNumber,
profileName, profileName,
style, style,
@ -70,54 +66,27 @@ export function AvatarPopup(props: Props): JSX.Element {
) : null} ) : null}
</div> </div>
</button> </button>
<hr className="module-avatar-popup__divider" />
<button
type="button"
className="module-avatar-popup__item"
onClick={onViewPreferences}
>
<div
className={classNames(
'module-avatar-popup__item__icon',
'module-avatar-popup__item__icon-settings'
)}
/>
<div className="module-avatar-popup__item__text">
{i18n('icu:mainMenuSettings')}
</div>
</button>
<button
type="button"
className="module-avatar-popup__item"
onClick={onViewArchive}
>
<div
className={classNames(
'module-avatar-popup__item__icon',
'module-avatar-popup__item__icon-archive'
)}
/>
<div className="module-avatar-popup__item__text">
{i18n('icu:avatarMenuViewArchive')}
</div>
</button>
{hasPendingUpdate && ( {hasPendingUpdate && (
<button <>
type="button" <hr className="module-avatar-popup__divider" />
className="module-avatar-popup__item" <button
onClick={onStartUpdate} type="button"
> className="module-avatar-popup__item"
<div onClick={onStartUpdate}
className={classNames( >
'module-avatar-popup__item__icon', <div
'module-avatar-popup__item__icon--update' className={classNames(
)} 'module-avatar-popup__item__icon',
/> 'module-avatar-popup__item__icon--update'
<div className="module-avatar-popup__item__text"> )}
{i18n('icu:avatarMenuUpdateAvailable')} />
</div> <div className="module-avatar-popup__item__text">
<div className="module-avatar-popup__item--badge" /> {i18n('icu:avatarMenuUpdateAvailable')}
</button> </div>
<div className="module-avatar-popup__item--badge" />
</button>
</>
)} )}
</div> </div>
); );

View file

@ -33,6 +33,7 @@ export enum ButtonVariant {
export enum ButtonIconType { export enum ButtonIconType {
audio = 'audio', audio = 'audio',
message = 'message',
muted = 'muted', muted = 'muted',
search = 'search', search = 'search',
unmuted = 'unmuted', unmuted = 'unmuted',

476
ts/components/CallsList.tsx Normal file
View file

@ -0,0 +1,476 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ChangeEvent } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { Index, IndexRange, ListRowProps } from 'react-virtualized';
import { InfiniteLoader, List } from 'react-virtualized';
import classNames from 'classnames';
import type { LocalizerType } from '../types/I18N';
import { ListTile } from './ListTile';
import { Avatar, AvatarSize } from './Avatar';
import { SearchInput } from './SearchInput';
import type {
CallHistoryFilterOptions,
CallHistoryGroup,
CallHistoryPagination,
} from '../types/CallDisposition';
import {
CallHistoryFilterStatus,
CallDirection,
CallType,
DirectCallStatus,
GroupCallStatus,
isSameCallHistoryGroup,
} from '../types/CallDisposition';
import { formatDateTimeShort } from '../util/timestamp';
import type { ConversationType } from '../state/ducks/conversations';
import * as log from '../logging/log';
import { refMerger } from '../util/refMerger';
import { drop } from '../util/drop';
import { strictAssert } from '../util/assert';
import { UserText } from './UserText';
import { Intl } from './Intl';
import { NavSidebarSearchHeader } from './NavSidebar';
import { SizeObserver } from '../hooks/useSizeObserver';
import { formatCallHistoryGroup } from '../util/callDisposition';
function Timestamp({
i18n,
timestamp,
}: {
i18n: LocalizerType;
timestamp: number;
}): JSX.Element {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => {
setNow(Date.now());
}, 1_000);
return () => {
clearInterval(timer);
};
}, []);
const dateTime = useMemo(() => {
return new Date(timestamp).toISOString();
}, [timestamp]);
const formatted = useMemo(() => {
void now; // Use this as a dep so we update
return formatDateTimeShort(i18n, timestamp);
}, [i18n, timestamp, now]);
return <time dateTime={dateTime}>{formatted}</time>;
}
type SearchResults = Readonly<{
count: number;
items: ReadonlyArray<CallHistoryGroup>;
}>;
type SearchState = Readonly<{
state: 'init' | 'pending' | 'rejected' | 'fulfilled';
// Note these fields shouldnt be updated until the search is fulfilled or rejected.
options: null | { query: string; status: CallHistoryFilterStatus };
results: null | SearchResults;
}>;
const defaultInitState: SearchState = {
state: 'init',
options: null,
results: null,
};
const defaultPendingState: SearchState = {
state: 'pending',
options: null,
results: {
count: 100,
items: [],
},
};
type CallsListProps = Readonly<{
getCallHistoryGroupsCount: (
options: CallHistoryFilterOptions
) => Promise<number>;
getCallHistoryGroups: (
options: CallHistoryFilterOptions,
pagination: CallHistoryPagination
) => Promise<Array<CallHistoryGroup>>;
getConversation: (id: string) => ConversationType | void;
i18n: LocalizerType;
selectedCallHistoryGroup: CallHistoryGroup | null;
onSelectCallHistoryGroup: (
conversationId: string,
selectedCallHistoryGroup: CallHistoryGroup
) => void;
}>;
function rowHeight() {
return ListTile.heightCompact;
}
export function CallsList({
getCallHistoryGroupsCount,
getCallHistoryGroups,
getConversation,
i18n,
selectedCallHistoryGroup,
onSelectCallHistoryGroup,
}: CallsListProps): JSX.Element {
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const listRef = useRef<List>(null);
const [queryInput, setQueryInput] = useState('');
const [status, setStatus] = useState(CallHistoryFilterStatus.All);
const [searchState, setSearchState] = useState(defaultInitState);
useEffect(() => {
const controller = new AbortController();
async function search() {
const options = {
query: queryInput.toLowerCase().normalize().trim(),
status,
};
let timer = setTimeout(() => {
setSearchState(prevSearchState => {
if (prevSearchState.state === 'init') {
return defaultPendingState;
}
return prevSearchState;
});
timer = setTimeout(() => {
// Show loading indicator after a delay
setSearchState(defaultPendingState);
}, 300);
}, 50);
let results: SearchResults | null = null;
try {
const [count, items] = await Promise.all([
getCallHistoryGroupsCount(options),
getCallHistoryGroups(options, {
offset: 0,
limit: 100, // preloaded rows
}),
]);
results = { count, items };
} catch (error) {
log.error('CallsList#fetchTotal error fetching', error);
}
// Clear the loading indicator timeout
clearTimeout(timer);
// Ignore old requests
if (controller.signal.aborted) {
return;
}
// Only commit the new search state once the results are ready
setSearchState({
state: results == null ? 'rejected' : 'fulfilled',
options,
results,
});
infiniteLoaderRef.current?.resetLoadMoreRowsCache(true);
listRef.current?.scrollToPosition(0);
}
drop(search());
return () => {
controller.abort();
};
}, [getCallHistoryGroupsCount, getCallHistoryGroups, queryInput, status]);
const loadMoreRows = useCallback(
async (props: IndexRange) => {
const { state, options } = searchState;
if (state !== 'fulfilled') {
return;
}
strictAssert(
options != null,
'options should never be null when status is fulfilled'
);
let { startIndex, stopIndex } = props;
if (startIndex > stopIndex) {
// flip
[startIndex, stopIndex] = [stopIndex, startIndex];
}
const offset = startIndex;
const limit = stopIndex - startIndex + 1;
try {
const groups = await getCallHistoryGroups(options, { offset, limit });
if (searchState.options !== options) {
return;
}
setSearchState(prevSearchState => {
strictAssert(
prevSearchState.results != null,
'results should never be null here'
);
const newItems = prevSearchState.results.items.slice();
newItems.splice(startIndex, stopIndex, ...groups);
return {
...prevSearchState,
results: {
...prevSearchState.results,
items: newItems,
},
};
});
} catch (error) {
log.error('CallsList#loadMoreRows error fetching', error);
}
},
[getCallHistoryGroups, searchState]
);
const isRowLoaded = useCallback(
(props: Index) => {
return searchState.results?.items[props.index] != null;
},
[searchState]
);
const rowRenderer = useCallback(
({ key, index, style }: ListRowProps) => {
const item = searchState.results?.items.at(index) ?? null;
const conversation = item != null ? getConversation(item.peerId) : null;
if (
searchState.state === 'pending' ||
item == null ||
conversation == null
) {
return (
<div key={key} style={style}>
<ListTile
leading={<div className="CallsList__LoadingAvatar" />}
title={
<span className="CallsList__LoadingText CallsList__LoadingText--title" />
}
subtitle={
<span className="CallsList__LoadingText CallsList__LoadingText--subtitle" />
}
/>
</div>
);
}
const isSelected =
selectedCallHistoryGroup != null &&
isSameCallHistoryGroup(item, selectedCallHistoryGroup);
const wasMissed =
item.direction === CallDirection.Incoming &&
(item.status === DirectCallStatus.Missed ||
item.status === GroupCallStatus.Missed);
let statusText;
if (wasMissed) {
statusText = i18n('icu:CallsList__ItemCallInfo--Missed');
} else if (item.type === CallType.Group) {
statusText = i18n('icu:CallsList__ItemCallInfo--GroupCall');
} else if (item.direction === CallDirection.Outgoing) {
statusText = i18n('icu:CallsList__ItemCallInfo--Outgoing');
} else if (item.direction === CallDirection.Incoming) {
statusText = i18n('icu:CallsList__ItemCallInfo--Incoming');
} else {
strictAssert(false, 'Cannot format call');
}
return (
<div
key={key}
style={style}
className={classNames('CallsList__Item', {
'CallsList__Item--selected': isSelected,
})}
>
<ListTile
moduleClassName="CallsList__ItemTile"
aria-selected={isSelected}
leading={
<Avatar
acceptedMessageRequest
avatarPath={conversation.avatarPath}
conversationType="group"
i18n={i18n}
isMe={false}
title={conversation.title}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}
badge={undefined}
className="CallsList__ItemAvatar"
/>
}
trailing={
<span
className={classNames('CallsList__ItemIcon', {
'CallsList__ItemIcon--Phone': item.type === CallType.Audio,
'CallsList__ItemIcon--Video': item.type !== CallType.Audio,
})}
/>
}
title={
<span
className="CallsList__ItemTitle"
data-call={formatCallHistoryGroup(item)}
>
<UserText text={conversation.title} />
</span>
}
subtitle={
<span
className={classNames('CallsList__ItemCallInfo', {
'CallsList__ItemCallInfo--missed': wasMissed,
})}
>
{item.children.length > 1 ? `(${item.children.length}) ` : ''}
{statusText} &middot;{' '}
<Timestamp i18n={i18n} timestamp={item.timestamp} />
</span>
}
onClick={() => {
onSelectCallHistoryGroup(conversation.id, item);
}}
/>
</div>
);
},
[
searchState,
getConversation,
selectedCallHistoryGroup,
onSelectCallHistoryGroup,
i18n,
]
);
const handleSearchInputChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setQueryInput(event.target.value);
},
[]
);
const handleSearchInputClear = useCallback(() => {
setQueryInput('');
}, []);
const handleStatusToggle = useCallback(() => {
setStatus(prevStatus => {
return prevStatus === CallHistoryFilterStatus.All
? CallHistoryFilterStatus.Missed
: CallHistoryFilterStatus.All;
});
}, []);
const filteringByMissed = status === CallHistoryFilterStatus.Missed;
const hasEmptyResults = searchState.results?.count === 0;
const currentQuery = searchState.options?.query ?? '';
return (
<>
<NavSidebarSearchHeader>
<SearchInput
i18n={i18n}
placeholder={i18n('icu:CallsList__SearchInputPlaceholder')}
onChange={handleSearchInputChange}
onClear={handleSearchInputClear}
value={queryInput}
/>
<button
className={classNames('CallsList__ToggleFilterByMissed', {
'CallsList__ToggleFilterByMissed--pressed': filteringByMissed,
})}
type="button"
aria-pressed={filteringByMissed}
aria-roledescription={i18n(
'icu:CallsList__ToggleFilterByMissed__RoleDescription'
)}
onClick={handleStatusToggle}
>
<span className="CallsList__ToggleFilterByMissedLabel">
{i18n('icu:CallsList__ToggleFilterByMissedLabel')}
</span>
</button>
</NavSidebarSearchHeader>
{hasEmptyResults && (
<p className="CallsList__EmptyState">
{currentQuery === '' ? (
i18n('icu:CallsList__EmptyState--noQuery')
) : (
<Intl
i18n={i18n}
id="icu:CallsList__EmptyState--hasQuery"
components={{
query: <UserText text={currentQuery} />,
}}
/>
)}
</p>
)}
<SizeObserver>
{(ref, size) => {
return (
<div className="CallsList__ListContainer" ref={ref}>
{size != null && (
<InfiniteLoader
ref={infiniteLoaderRef}
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={searchState.results?.count}
minimumBatchSize={100}
threshold={30}
>
{({ onRowsRendered, registerChild }) => {
return (
<List
className={classNames('CallsList__List', {
'CallsList__List--loading':
searchState.state === 'pending',
})}
ref={refMerger(listRef, registerChild)}
width={size.width}
height={size.height}
rowCount={searchState.results?.count ?? 0}
rowHeight={rowHeight}
rowRenderer={rowRenderer}
onRowsRendered={onRowsRendered}
/>
);
}}
</InfiniteLoader>
)}
</div>
);
}}
</SizeObserver>
</>
);
}

View file

@ -0,0 +1,266 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ChangeEvent } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { partition } from 'lodash';
import type { ListRowProps } from 'react-virtualized';
import { List } from 'react-virtualized';
import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/I18N';
import { SearchInput } from './SearchInput';
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
import { NavSidebarSearchHeader } from './NavSidebar';
import { ListTile } from './ListTile';
import { strictAssert } from '../util/assert';
import { UserText } from './UserText';
import { Avatar, AvatarSize } from './Avatar';
import { Intl } from './Intl';
import type { ActiveCallStateType } from '../state/ducks/calling';
import { SizeObserver } from '../hooks/useSizeObserver';
type CallsNewCallProps = Readonly<{
activeCall: ActiveCallStateType | undefined;
allConversations: ReadonlyArray<ConversationType>;
i18n: LocalizerType;
onSelectConversation: (conversationId: string) => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void;
regionCode: string | undefined;
}>;
type Row =
| { kind: 'header'; title: string }
| { kind: 'conversation'; conversation: ConversationType };
export function CallsNewCall({
activeCall,
allConversations,
i18n,
onSelectConversation,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
regionCode,
}: CallsNewCallProps): JSX.Element {
const [queryInput, setQueryInput] = useState('');
const query = useMemo(() => {
return queryInput.toLowerCase().normalize().trim();
}, [queryInput]);
const activeConversations = useMemo(() => {
return allConversations.filter(conversation => {
return conversation.activeAt != null && conversation.isArchived !== true;
});
}, [allConversations]);
const filteredConversations = useMemo(() => {
if (query === '') {
return activeConversations;
}
return filterAndSortConversationsByRecent(
activeConversations,
query,
regionCode
);
}, [activeConversations, query, regionCode]);
const [groupConversations, directConversations] = useMemo(() => {
return partition(filteredConversations, conversation => {
return conversation.type === 'group';
});
}, [filteredConversations]);
const handleSearchInputChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setQueryInput(event.currentTarget.value);
},
[]
);
const handleSearchInputClear = useCallback(() => {
setQueryInput('');
}, []);
const rows = useMemo((): ReadonlyArray<Row> => {
let result: Array<Row> = [];
if (directConversations.length > 0) {
result.push({
kind: 'header',
title: 'Contacts',
});
result = result.concat(
directConversations.map(conversation => {
return {
kind: 'conversation',
conversation,
};
})
);
}
if (groupConversations.length > 0) {
result.push({
kind: 'header',
title: 'Groups',
});
result = result.concat(
groupConversations.map((conversation): Row => {
return {
kind: 'conversation',
conversation,
};
})
);
}
return result;
}, [directConversations, groupConversations]);
const isRowLoaded = useCallback(
({ index }) => {
return rows.at(index) != null;
},
[rows]
);
const rowHeight = useCallback(
({ index }) => {
if (rows.at(index)?.kind === 'conversation') {
return ListTile.heightCompact;
}
// Height of .CallsNewCall__ListHeaderItem
return 40;
},
[rows]
);
const rowRenderer = useCallback(
({ key, index, style }: ListRowProps) => {
const item = rows.at(index);
strictAssert(item != null, 'Rendered non-existent row');
if (item.kind === 'header') {
return (
<div key={key} style={style} className="CallsNewCall__ListHeaderItem">
{item.title}
</div>
);
}
const callButtonsDisabled = activeCall != null;
return (
<div key={key} style={style}>
<ListTile
leading={
<Avatar
acceptedMessageRequest
avatarPath={item.conversation.avatarPath}
conversationType="group"
i18n={i18n}
isMe={false}
title={item.conversation.title}
sharedGroupNames={[]}
size={AvatarSize.THIRTY_TWO}
badge={undefined}
/>
}
title={<UserText text={item.conversation.title} />}
trailing={
<div className="CallsNewCall__ItemActions">
{item.conversation.type === 'direct' && (
<button
type="button"
className="CallsNewCall__ItemActionButton"
aria-disabled={callButtonsDisabled}
onClick={event => {
event.stopPropagation();
if (!callButtonsDisabled) {
onOutgoingAudioCallInConversation(item.conversation.id);
}
}}
>
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Phone" />
</button>
)}
<button
type="button"
className="CallsNewCall__ItemActionButton"
aria-disabled={callButtonsDisabled}
onClick={event => {
event.stopPropagation();
if (!callButtonsDisabled) {
onOutgoingVideoCallInConversation(item.conversation.id);
}
}}
>
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Video" />
</button>
</div>
}
onClick={() => {
onSelectConversation(item.conversation.id);
}}
/>
</div>
);
},
[
rows,
i18n,
activeCall,
onSelectConversation,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
]
);
return (
<>
<NavSidebarSearchHeader>
<SearchInput
i18n={i18n}
placeholder="Search"
onChange={handleSearchInputChange}
onClear={handleSearchInputClear}
value={queryInput}
/>
</NavSidebarSearchHeader>
{rows.length === 0 && (
<div className="CallsNewCall__EmptyState">
{query === '' ? (
i18n('icu:CallsNewCall__EmptyState--noQuery')
) : (
<Intl
i18n={i18n}
id="icu:CallsNewCall__EmptyState--hasQuery"
components={{
query: <UserText text={query} />,
}}
/>
)}
</div>
)}
{rows.length > 0 && (
<SizeObserver>
{(ref, size) => {
return (
<div ref={ref} className="CallsNewCall__ListContainer">
{size != null && (
<List
className="CallsNewCall__List"
width={size.width}
height={size.height}
isRowLoaded={isRowLoaded}
rowCount={rows.length}
rowHeight={rowHeight}
rowRenderer={rowRenderer}
/>
)}
</div>
);
}}
</SizeObserver>
)}
</>
);
}

264
ts/components/CallsTab.tsx Normal file
View file

@ -0,0 +1,264 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState } from 'react';
import type { LocalizerType } from '../types/I18N';
import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
import { CallsList } from './CallsList';
import type { ConversationType } from '../state/ducks/conversations';
import type {
CallHistoryFilterOptions,
CallHistoryGroup,
CallHistoryPagination,
} from '../types/CallDisposition';
import { CallsNewCall } from './CallsNewCall';
import { useEscapeHandling } from '../hooks/useEscapeHandling';
import type { ActiveCallStateType } from '../state/ducks/calling';
import { ContextMenu } from './ContextMenu';
import { ConfirmationDialog } from './ConfirmationDialog';
enum CallsTabSidebarView {
CallsListView,
NewCallView,
}
type CallsTabProps = Readonly<{
activeCall: ActiveCallStateType | undefined;
allConversations: ReadonlyArray<ConversationType>;
getCallHistoryGroupsCount: (
options: CallHistoryFilterOptions
) => Promise<number>;
getCallHistoryGroups: (
options: CallHistoryFilterOptions,
pagination: CallHistoryPagination
) => Promise<Array<CallHistoryGroup>>;
getConversation: (id: string) => ConversationType | void;
i18n: LocalizerType;
navTabsCollapsed: boolean;
onClearCallHistory: () => void;
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void;
preferredLeftPaneWidth: number;
renderConversationDetails: (
conversationId: string,
callHistoryGroup: CallHistoryGroup | null
) => JSX.Element;
regionCode: string | undefined;
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
}>;
export function CallsTab({
activeCall,
allConversations,
getCallHistoryGroupsCount,
getCallHistoryGroups,
getConversation,
i18n,
navTabsCollapsed,
onClearCallHistory,
onToggleNavTabsCollapse,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
preferredLeftPaneWidth,
renderConversationDetails,
regionCode,
savePreferredLeftPaneWidth,
}: CallsTabProps): JSX.Element {
const [sidebarView, setSidebarView] = useState(
CallsTabSidebarView.CallsListView
);
const [selected, setSelected] = useState<{
conversationId: string;
callHistoryGroup: CallHistoryGroup | null;
} | null>(null);
const [
confirmClearCallHistoryDialogOpen,
setConfirmClearCallHistoryDialogOpen,
] = useState(false);
const updateSidebarView = useCallback(
(newSidebarView: CallsTabSidebarView) => {
setSidebarView(newSidebarView);
setSelected(null);
},
[]
);
const handleSelectCallHistoryGroup = useCallback(
(conversationId: string, callHistoryGroup: CallHistoryGroup) => {
setSelected({
conversationId,
callHistoryGroup,
});
},
[]
);
const handleSelectConversation = useCallback((conversationId: string) => {
setSelected({ conversationId, callHistoryGroup: null });
}, []);
useEscapeHandling(
sidebarView === CallsTabSidebarView.NewCallView
? () => {
updateSidebarView(CallsTabSidebarView.CallsListView);
}
: undefined
);
const handleOpenClearCallHistoryDialog = useCallback(() => {
setConfirmClearCallHistoryDialogOpen(true);
}, []);
const handleCloseClearCallHistoryDialog = useCallback(() => {
setConfirmClearCallHistoryDialogOpen(false);
}, []);
const handleOutgoingAudioCallInConversation = useCallback(
(conversationId: string) => {
onOutgoingAudioCallInConversation(conversationId);
updateSidebarView(CallsTabSidebarView.CallsListView);
},
[updateSidebarView, onOutgoingAudioCallInConversation]
);
const handleOutgoingVideoCallInConversation = useCallback(
(conversationId: string) => {
onOutgoingVideoCallInConversation(conversationId);
updateSidebarView(CallsTabSidebarView.CallsListView);
},
[updateSidebarView, onOutgoingVideoCallInConversation]
);
return (
<>
<div className="CallsTab">
<NavSidebar
i18n={i18n}
title={
sidebarView === CallsTabSidebarView.CallsListView
? i18n('icu:CallsTab__HeaderTitle--CallsList')
: i18n('icu:CallsTab__HeaderTitle--NewCall')
}
navTabsCollapsed={navTabsCollapsed}
onBack={
sidebarView === CallsTabSidebarView.NewCallView
? () => {
updateSidebarView(CallsTabSidebarView.CallsListView);
}
: null
}
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
requiresFullWidth
preferredLeftPaneWidth={preferredLeftPaneWidth}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
actions={
<>
{sidebarView === CallsTabSidebarView.CallsListView && (
<>
<NavSidebarActionButton
icon={<span className="CallsTab__NewCallActionIcon" />}
label={i18n('icu:CallsTab__NewCallActionLabel')}
onClick={() => {
updateSidebarView(CallsTabSidebarView.NewCallView);
}}
/>
<ContextMenu
i18n={i18n}
menuOptions={[
{
icon: 'CallsTab__ClearCallHistoryIcon',
label: i18n('icu:CallsTab__ClearCallHistoryLabel'),
onClick: handleOpenClearCallHistoryDialog,
},
]}
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
portalToRoot
>
{({ openMenu, onKeyDown }) => {
return (
<NavSidebarActionButton
onClick={openMenu}
onKeyDown={onKeyDown}
icon={<span className="CallsTab__MoreActionsIcon" />}
label={i18n('icu:CallsTab__MoreActionsLabel')}
/>
);
}}
</ContextMenu>
</>
)}
</>
}
>
{sidebarView === CallsTabSidebarView.CallsListView && (
<CallsList
key={CallsTabSidebarView.CallsListView}
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
getCallHistoryGroups={getCallHistoryGroups}
getConversation={getConversation}
i18n={i18n}
selectedCallHistoryGroup={selected?.callHistoryGroup ?? null}
onSelectCallHistoryGroup={handleSelectCallHistoryGroup}
/>
)}
{sidebarView === CallsTabSidebarView.NewCallView && (
<CallsNewCall
key={CallsTabSidebarView.NewCallView}
activeCall={activeCall}
allConversations={allConversations}
i18n={i18n}
regionCode={regionCode}
onSelectConversation={handleSelectConversation}
onOutgoingAudioCallInConversation={
handleOutgoingAudioCallInConversation
}
onOutgoingVideoCallInConversation={
handleOutgoingVideoCallInConversation
}
/>
)}
</NavSidebar>
{selected == null ? (
<div className="CallsTab__EmptyState">
{i18n('icu:CallsTab__EmptyStateText')}
</div>
) : (
<div
className="CallsTab__ConversationCallDetails"
// Force scrolling to top when a new conversation is selected.
key={selected.conversationId}
>
{renderConversationDetails(
selected.conversationId,
selected.callHistoryGroup
)}
</div>
)}
</div>
{confirmClearCallHistoryDialogOpen && (
<ConfirmationDialog
dialogName="CallsTab__ConfirmClearCallHistory"
i18n={i18n}
onClose={handleCloseClearCallHistoryDialog}
title={i18n('icu:CallsTab__ConfirmClearCallHistory__Title')}
actions={[
{
style: 'negative',
text: i18n(
'icu:CallsTab__ConfirmClearCallHistory__ConfirmButton'
),
action: onClearCallHistory,
},
]}
>
{i18n('icu:CallsTab__ConfirmClearCallHistory__Body')}
</ConfirmationDialog>
)}
</>
);
}

View file

@ -0,0 +1,68 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Environment, getEnvironment } from '../environment';
import type { LocalizerType } from '../types/I18N';
import type { NavTabPanelProps } from './NavTabs';
import { WhatsNewLink } from './WhatsNewLink';
type ChatsTabProps = Readonly<{
i18n: LocalizerType;
navTabsCollapsed: boolean;
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
prevConversationId: string | undefined;
renderConversationView: () => JSX.Element;
renderLeftPane: (props: NavTabPanelProps) => JSX.Element;
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
selectedConversationId: string | undefined;
showWhatsNewModal: () => unknown;
}>;
export function ChatsTab({
i18n,
navTabsCollapsed,
onToggleNavTabsCollapse,
prevConversationId,
renderConversationView,
renderLeftPane,
renderMiniPlayer,
selectedConversationId,
showWhatsNewModal,
}: ChatsTabProps): JSX.Element {
return (
<>
<div id="LeftPane">
{renderLeftPane({
collapsed: navTabsCollapsed,
onToggleCollapse: onToggleNavTabsCollapse,
})}
</div>
<div className="Inbox__conversation-stack">
<div id="toast" />
{selectedConversationId && (
<div
className="Inbox__conversation"
id={`conversation-${selectedConversationId}`}
>
{renderConversationView()}
</div>
)}
{!prevConversationId && (
<div className="Inbox__no-conversation-open">
{renderMiniPlayer({ shouldFlow: false })}
<div className="module-splash-screen__logo module-img--128 module-logo-blue" />
<h3>
{getEnvironment() !== Environment.Staging
? i18n('icu:welcomeToSignal')
: 'THIS IS A STAGING DESKTOP'}
</h3>
<p>
<WhatsNewLink i18n={i18n} showWhatsNewModal={showWhatsNewModal} />
</p>
</div>
)}
</div>
</>
);
}

View file

@ -291,13 +291,18 @@ export function ContextMenu<T>({
let buttonNode: JSX.Element; let buttonNode: JSX.Element;
if (typeof children === 'function') { if (typeof children === 'function') {
buttonNode = (children as (props: RenderButtonProps) => JSX.Element)({ buttonNode = (
openMenu: onClick || handleClick, <>
onKeyDown: handleKeyDown, {(children as (props: RenderButtonProps) => JSX.Element)({
isMenuShowing, openMenu: onClick || handleClick,
ref: setReferenceElement, onKeyDown: handleKeyDown,
menuNode, isMenuShowing,
}); ref: setReferenceElement,
menuNode,
})}
{portalNode ? createPortal(menuNode, portalNode) : menuNode}
</>
);
} else { } else {
buttonNode = ( buttonNode = (
<div <div

View file

@ -12,7 +12,7 @@ import { assertDev } from '../util/assert';
import type { ParsedE164Type } from '../util/libphonenumberInstance'; import type { ParsedE164Type } from '../util/libphonenumberInstance';
import type { LocalizerType, ThemeType } from '../types/Util'; import type { LocalizerType, ThemeType } from '../types/Util';
import { ScrollBehavior } from '../types/Util'; import { ScrollBehavior } from '../types/Util';
import { getConversationListWidthBreakpoint } from './_util'; import { getNavSidebarWidthBreakpoint } from './_util';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid'; import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid';
import type { ShowConversationType } from '../state/ducks/conversations'; import type { ShowConversationType } from '../state/ducks/conversations';
@ -493,7 +493,7 @@ export function ConversationList({
return null; return null;
} }
const widthBreakpoint = getConversationListWidthBreakpoint(dimensions.width); const widthBreakpoint = getNavSidebarWidthBreakpoint(dimensions.width);
return ( return (
<ListView <ListView

View file

@ -84,10 +84,7 @@ const Template: Story<PropsType & { daysAgo?: number }> = ({
{...args} {...args}
firstEnvelopeTimestamp={firstEnvelopeTimestamp} firstEnvelopeTimestamp={firstEnvelopeTimestamp}
envelopeTimestamp={envelopeTimestamp} envelopeTimestamp={envelopeTimestamp}
renderConversationView={() => <div />}
renderCustomizingPreferredReactionsModal={() => <div />} renderCustomizingPreferredReactionsModal={() => <div />}
renderLeftPane={() => <div />}
renderMiniPlayer={() => <div />}
/> />
); );
}; };

View file

@ -3,19 +3,10 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import React, { useEffect, useState, useMemo } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import type { ShowConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { SECOND, DAY } from '../util/durations'; import { SECOND, DAY } from '../util/durations';
import { ToastStickerPackInstallFailed } from './ToastStickerPackInstallFailed'; import type { SmartNavTabsProps } from '../state/smart/NavTabs';
import { WhatsNewLink } from './WhatsNewLink';
import { showToast } from '../util/showToast';
import { strictAssert } from '../util/assert';
import { TargetedMessageSource } from '../state/ducks/conversationsEnums';
import { usePrevious } from '../hooks/usePrevious';
import { Environment, getEnvironment } from '../environment';
export type PropsType = { export type PropsType = {
firstEnvelopeTimestamp: number | undefined; firstEnvelopeTimestamp: number | undefined;
@ -23,18 +14,13 @@ export type PropsType = {
hasInitialLoadCompleted: boolean; hasInitialLoadCompleted: boolean;
i18n: LocalizerType; i18n: LocalizerType;
isCustomizingPreferredReactions: boolean; isCustomizingPreferredReactions: boolean;
onConversationClosed: (id: string, reason: string) => unknown; navTabsCollapsed: boolean;
onConversationOpened: (id: string, messageId?: string) => unknown; onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => unknown;
renderConversationView: () => JSX.Element; renderCallsTab: () => JSX.Element;
renderChatsTab: () => JSX.Element;
renderCustomizingPreferredReactionsModal: () => JSX.Element; renderCustomizingPreferredReactionsModal: () => JSX.Element;
renderLeftPane: () => JSX.Element; renderNavTabs: (props: SmartNavTabsProps) => JSX.Element;
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element; renderStoriesTab: () => JSX.Element;
scrollToMessage: (conversationId: string, messageId: string) => unknown;
selectedConversationId?: string;
targetedMessage?: string;
targetedMessageSource?: TargetedMessageSource;
showConversation: ShowConversationType;
showWhatsNewModal: () => unknown;
}; };
export function Inbox({ export function Inbox({
@ -43,27 +29,17 @@ export function Inbox({
hasInitialLoadCompleted, hasInitialLoadCompleted,
i18n, i18n,
isCustomizingPreferredReactions, isCustomizingPreferredReactions,
onConversationClosed, navTabsCollapsed,
onConversationOpened, onToggleNavTabsCollapse,
renderConversationView, renderCallsTab,
renderChatsTab,
renderCustomizingPreferredReactionsModal, renderCustomizingPreferredReactionsModal,
renderLeftPane, renderNavTabs,
renderMiniPlayer, renderStoriesTab,
scrollToMessage,
selectedConversationId,
targetedMessage,
targetedMessageSource,
showConversation,
showWhatsNewModal,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const [internalHasInitialLoadCompleted, setInternalHasInitialLoadCompleted] = const [internalHasInitialLoadCompleted, setInternalHasInitialLoadCompleted] =
useState(hasInitialLoadCompleted); useState(hasInitialLoadCompleted);
const prevConversationId = usePrevious(
selectedConversationId,
selectedConversationId
);
const now = useMemo(() => Date.now(), []); const now = useMemo(() => Date.now(), []);
const midnight = useMemo(() => { const midnight = useMemo(() => {
const date = new Date(now); const date = new Date(now);
@ -74,80 +50,6 @@ export function Inbox({
return date.getTime(); return date.getTime();
}, [now]); }, [now]);
useEffect(() => {
if (prevConversationId !== selectedConversationId) {
if (prevConversationId) {
onConversationClosed(prevConversationId, 'opened another conversation');
}
if (selectedConversationId) {
onConversationOpened(selectedConversationId, targetedMessage);
}
} else if (
selectedConversationId &&
targetedMessage &&
targetedMessageSource !== TargetedMessageSource.Focus
) {
scrollToMessage(selectedConversationId, targetedMessage);
}
if (!selectedConversationId) {
return;
}
const conversation = window.ConversationController.get(
selectedConversationId
);
strictAssert(conversation, 'Conversation must be found');
conversation.setMarkedUnread(false);
}, [
onConversationClosed,
onConversationOpened,
prevConversationId,
scrollToMessage,
selectedConversationId,
targetedMessage,
targetedMessageSource,
]);
useEffect(() => {
function refreshConversation({
newId,
oldId,
}: {
newId: string;
oldId: string;
}) {
if (prevConversationId === oldId) {
showConversation({ conversationId: newId });
}
}
// Close current opened conversation to reload the group information once
// linked.
function unload() {
if (!prevConversationId) {
return;
}
onConversationClosed(prevConversationId, 'force unload requested');
}
function packInstallFailed() {
showToast(ToastStickerPackInstallFailed);
}
window.Whisper.events.on('pack-install-failed', packInstallFailed);
window.Whisper.events.on('refreshConversation', refreshConversation);
window.Whisper.events.on('setupAsNewDevice', unload);
return () => {
window.Whisper.events.off('pack-install-failed', packInstallFailed);
window.Whisper.events.off('refreshConversation', refreshConversation);
window.Whisper.events.off('setupAsNewDevice', unload);
};
}, [onConversationClosed, prevConversationId, showConversation]);
useEffect(() => { useEffect(() => {
if (internalHasInitialLoadCompleted) { if (internalHasInitialLoadCompleted) {
return; return;
@ -186,12 +88,6 @@ export function Inbox({
setInternalHasInitialLoadCompleted(hasInitialLoadCompleted); setInternalHasInitialLoadCompleted(hasInitialLoadCompleted);
}, [hasInitialLoadCompleted]); }, [hasInitialLoadCompleted]);
useEffect(() => {
if (!selectedConversationId) {
window.SignalCI?.handleEvent('empty-inbox:rendered', null);
}
}, [selectedConversationId]);
if (!internalHasInitialLoadCompleted) { if (!internalHasInitialLoadCompleted) {
let loadingProgress = 0; let loadingProgress = 0;
if ( if (
@ -264,37 +160,13 @@ export function Inbox({
<> <>
<div className="Inbox"> <div className="Inbox">
<div className="module-title-bar-drag-area" /> <div className="module-title-bar-drag-area" />
{renderNavTabs({
<div id="LeftPane">{renderLeftPane()}</div> navTabsCollapsed,
onToggleNavTabsCollapse,
<div className="Inbox__conversation-stack"> renderChatsTab,
<div id="toast" /> renderCallsTab,
{selectedConversationId && ( renderStoriesTab,
<div })}
className="Inbox__conversation"
id={`conversation-${selectedConversationId}`}
>
{renderConversationView()}
</div>
)}
{!prevConversationId && (
<div className="Inbox__no-conversation-open">
{renderMiniPlayer({ shouldFlow: false })}
<div className="module-splash-screen__logo module-img--128 module-logo-blue" />
<h3>
{getEnvironment() !== Environment.Staging
? i18n('icu:welcomeToSignal')
: 'THIS IS A STAGING DESKTOP'}
</h3>
<p>
<WhatsNewLink
i18n={i18n}
showWhatsNewModal={showWhatsNewModal}
/>
</p>
</div>
)}
</div>
</div> </div>
{activeModal} {activeModal}
</> </>

View file

@ -165,6 +165,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
), ),
isUpdateDownloaded, isUpdateDownloaded,
isContactManagementEnabled, isContactManagementEnabled,
navTabsCollapsed: boolean('navTabsCollapsed', false),
setChallengeStatus: action('setChallengeStatus'), setChallengeStatus: action('setChallengeStatus'),
lookupConversationWithoutUuid: makeFakeLookupConversationWithoutUuid(), lookupConversationWithoutUuid: makeFakeLookupConversationWithoutUuid(),
@ -179,7 +180,6 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
'onOutgoingVideoCallInConversation' 'onOutgoingVideoCallInConversation'
), ),
removeConversation: action('removeConversation'), removeConversation: action('removeConversation'),
renderMainHeader: () => <div />,
renderMessageSearchResult: (id: string) => ( renderMessageSearchResult: (id: string) => (
<MessageSearchResult <MessageSearchResult
body="Lorem ipsum wow" body="Lorem ipsum wow"
@ -273,6 +273,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
toggleConversationInChooseMembers: action( toggleConversationInChooseMembers: action(
'toggleConversationInChooseMembers' 'toggleConversationInChooseMembers'
), ),
toggleNavTabsCollapse: action('toggleNavTabsCollapse'),
updateSearchTerm: action('updateSearchTerm'), updateSearchTerm: action('updateSearchTerm'),
...overrideProps, ...overrideProps,

View file

@ -1,9 +1,9 @@
// Copyright 2019 Signal Messenger, LLC // Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useCallback, useMemo, useState } from 'react'; import React, { useEffect, useCallback, useMemo } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { clamp, isNumber, noop } from 'lodash'; import { isNumber } from 'lodash';
import type { LeftPaneHelper, ToFindType } from './leftPane/LeftPaneHelper'; import type { LeftPaneHelper, ToFindType } from './leftPane/LeftPaneHelper';
import { FindDirection } from './leftPane/LeftPaneHelper'; import { FindDirection } from './leftPane/LeftPaneHelper';
@ -27,15 +27,8 @@ import { usePrevious } from '../hooks/usePrevious';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import type { DurationInSeconds } from '../util/durations'; import type { DurationInSeconds } from '../util/durations';
import type { WidthBreakpoint } from './_util'; import type { WidthBreakpoint } from './_util';
import { getConversationListWidthBreakpoint } from './_util'; import { getNavSidebarWidthBreakpoint } from './_util';
import * as KeyboardLayout from '../services/keyboardLayout'; import * as KeyboardLayout from '../services/keyboardLayout';
import {
MIN_WIDTH,
SNAP_WIDTH,
MIN_FULL_WIDTH,
MAX_WIDTH,
getWidthFromPreferredWidth,
} from '../util/leftPaneWidth';
import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid'; import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid';
import type { ShowConversationType } from '../state/ducks/conversations'; import type { ShowConversationType } from '../state/ducks/conversations';
import type { PropsType as UnsupportedOSDialogPropsType } from '../state/smart/UnsupportedOSDialog'; import type { PropsType as UnsupportedOSDialogPropsType } from '../state/smart/UnsupportedOSDialog';
@ -50,6 +43,12 @@ import type {
SaveAvatarToDiskActionType, SaveAvatarToDiskActionType,
} from '../types/Avatar'; } from '../types/Avatar';
import { SizeObserver } from '../hooks/useSizeObserver'; import { SizeObserver } from '../hooks/useSizeObserver';
import {
NavSidebar,
NavSidebarActionButton,
NavSidebarSearchHeader,
} from './NavSidebar';
import { ContextMenu } from './ContextMenu';
export enum LeftPaneMode { export enum LeftPaneMode {
Inbox, Inbox,
@ -114,6 +113,7 @@ export type PropsType = {
composeReplaceAvatar: ReplaceAvatarActionType; composeReplaceAvatar: ReplaceAvatarActionType;
composeSaveAvatarToDisk: SaveAvatarToDiskActionType; composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
createGroup: () => void; createGroup: () => void;
navTabsCollapsed: boolean;
onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void;
removeConversation: (conversationId: string) => void; removeConversation: (conversationId: string) => void;
@ -132,10 +132,10 @@ export type PropsType = {
startSettingGroupMetadata: () => void; startSettingGroupMetadata: () => void;
toggleComposeEditingAvatar: () => unknown; toggleComposeEditingAvatar: () => unknown;
toggleConversationInChooseMembers: (conversationId: string) => void; toggleConversationInChooseMembers: (conversationId: string) => void;
toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
updateSearchTerm: (_: string) => void; updateSearchTerm: (_: string) => void;
// Render Props // Render Props
renderMainHeader: () => JSX.Element;
renderMessageSearchResult: (id: string) => JSX.Element; renderMessageSearchResult: (id: string) => JSX.Element;
renderNetworkStatus: ( renderNetworkStatus: (
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }> _: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
@ -178,14 +178,15 @@ export function LeftPane({
isUpdateDownloaded, isUpdateDownloaded,
isContactManagementEnabled, isContactManagementEnabled,
modeSpecificProps, modeSpecificProps,
navTabsCollapsed,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
preferredWidthFromStorage, preferredWidthFromStorage,
removeConversation, removeConversation,
renderCaptchaDialog, renderCaptchaDialog,
renderCrashReportDialog, renderCrashReportDialog,
renderExpiredBuildDialog, renderExpiredBuildDialog,
renderMainHeader,
renderMessageSearchResult, renderMessageSearchResult,
renderNetworkStatus, renderNetworkStatus,
renderUnsupportedOSDialog, renderUnsupportedOSDialog,
@ -195,6 +196,7 @@ export function LeftPane({
searchInConversation, searchInConversation,
selectedConversationId, selectedConversationId,
targetedMessageId, targetedMessageId,
toggleNavTabsCollapse,
setChallengeStatus, setChallengeStatus,
setComposeGroupAvatar, setComposeGroupAvatar,
setComposeGroupExpireTimer, setComposeGroupExpireTimer,
@ -215,12 +217,6 @@ export function LeftPane({
unsupportedOSDialogType, unsupportedOSDialogType,
updateSearchTerm, updateSearchTerm,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const [preferredWidth, setPreferredWidth] = useState(
// This clamp is present just in case we get a bogus value from storage.
clamp(preferredWidthFromStorage, MIN_WIDTH, MAX_WIDTH)
);
const [isResizing, setIsResizing] = useState(false);
const previousModeSpecificProps = usePrevious( const previousModeSpecificProps = usePrevious(
modeSpecificProps, modeSpecificProps,
modeSpecificProps modeSpecificProps
@ -421,76 +417,6 @@ export function LeftPane({
startSearch, startSearch,
]); ]);
const requiresFullWidth = helper.requiresFullWidth();
useEffect(() => {
if (!isResizing) {
return noop;
}
const onMouseMove = (event: MouseEvent) => {
let width: number;
const isRTL = i18n.getLocaleDirection() === 'rtl';
const x = isRTL ? window.innerWidth - event.clientX : event.clientX;
if (requiresFullWidth) {
width = Math.max(x, MIN_FULL_WIDTH);
} else if (x < SNAP_WIDTH) {
width = MIN_WIDTH;
} else {
width = clamp(x, MIN_FULL_WIDTH, MAX_WIDTH);
}
setPreferredWidth(Math.min(width, MAX_WIDTH));
event.preventDefault();
};
const stopResizing = () => {
setIsResizing(false);
};
document.body.addEventListener('mousemove', onMouseMove);
document.body.addEventListener('mouseup', stopResizing);
document.body.addEventListener('mouseleave', stopResizing);
return () => {
document.body.removeEventListener('mousemove', onMouseMove);
document.body.removeEventListener('mouseup', stopResizing);
document.body.removeEventListener('mouseleave', stopResizing);
};
}, [i18n, isResizing, requiresFullWidth]);
useEffect(() => {
if (!isResizing) {
return noop;
}
document.body.classList.add('is-resizing-left-pane');
return () => {
document.body.classList.remove('is-resizing-left-pane');
};
}, [isResizing]);
useEffect(() => {
if (isResizing || preferredWidth === preferredWidthFromStorage) {
return;
}
const timeout = setTimeout(() => {
savePreferredLeftPaneWidth(preferredWidth);
}, 1000);
return () => {
clearTimeout(timeout);
};
}, [
isResizing,
preferredWidth,
preferredWidthFromStorage,
savePreferredLeftPaneWidth,
]);
const preRowsNode = helper.getPreRowsNode({ const preRowsNode = helper.getPreRowsNode({
clearConversationSearch, clearConversationSearch,
clearGroupCreationError, clearGroupCreationError,
@ -553,11 +479,7 @@ export function LeftPane({
// It also ensures that we scroll to the top when switching views. // It also ensures that we scroll to the top when switching views.
const listKey = preRowsNode ? 1 : 0; const listKey = preRowsNode ? 1 : 0;
const width = getWidthFromPreferredWidth(preferredWidth, { const widthBreakpoint = getNavSidebarWidthBreakpoint(300);
requiresFullWidth,
});
const widthBreakpoint = getConversationListWidthBreakpoint(width);
const commonDialogProps = { const commonDialogProps = {
i18n, i18n,
@ -614,127 +536,171 @@ export function LeftPane({
} }
return ( return (
<nav <NavSidebar
className={classNames( title="Chats"
'module-left-pane', hideHeader={
isResizing && 'module-left-pane--is-resizing', modeSpecificProps.mode === LeftPaneMode.Archive ||
`module-left-pane--width-${widthBreakpoint}`, modeSpecificProps.mode === LeftPaneMode.Compose ||
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers && modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers ||
'module-left-pane--mode-choose-group-members', modeSpecificProps.mode === LeftPaneMode.SetGroupMetadata
modeSpecificProps.mode === LeftPaneMode.Compose && }
'module-left-pane--mode-compose' i18n={i18n}
)} navTabsCollapsed={navTabsCollapsed}
style={{ width }} onToggleNavTabsCollapse={toggleNavTabsCollapse}
> preferredLeftPaneWidth={preferredWidthFromStorage}
{/* eslint-enable jsx-a11y/no-static-element-interactions */} requiresFullWidth={false}
<div className="module-left-pane__header"> savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
{helper.getHeaderContents({ actions={
i18n, <>
showInbox, <NavSidebarActionButton
startComposing, label={i18n('icu:newConversation')}
showChooseGroupMembers, icon={<span className="module-left-pane__startComposingIcon" />}
}) || renderMainHeader()} onClick={startComposing}
</div> />
{helper.getSearchInput({ <ContextMenu
clearConversationSearch, i18n={i18n}
clearSearch, menuOptions={[
i18n, {
onChangeComposeSearchTerm: event => { label: i18n('icu:avatarMenuViewArchive'),
setComposeSearchTerm(event.target.value); onClick: showArchivedConversations,
}, },
updateSearchTerm, ]}
showConversation, popperOptions={{
})} placement: 'bottom',
<div className="module-left-pane__dialogs"> strategy: 'absolute',
{dialogs.map(({ key, dialog }) => ( }}
<React.Fragment key={key}>{dialog}</React.Fragment> portalToRoot
))} >
</div> {({ openMenu, onKeyDown }) => {
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>} return (
<SizeObserver> <NavSidebarActionButton
{(ref, size) => ( onClick={openMenu}
<div className="module-left-pane__list--measure" ref={ref}> onKeyDown={onKeyDown}
<div className="module-left-pane__list--wrapper"> icon={<span className="module-left-pane__moreActionsIcon" />}
<div label="More Actions"
aria-live="polite"
className="module-left-pane__list"
data-supertab
key={listKey}
role="presentation"
tabIndex={-1}
>
<ConversationList
dimensions={{
width,
height: size?.height || 0,
}}
getPreferredBadge={getPreferredBadge}
getRow={getRow}
i18n={i18n}
onClickArchiveButton={showArchivedConversations}
onClickContactCheckbox={(
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => {
switch (disabledReason) {
case undefined:
toggleConversationInChooseMembers(conversationId);
break;
case ContactCheckboxDisabledReason.AlreadyAdded:
case ContactCheckboxDisabledReason.MaximumContactsSelected:
// These are no-ops.
break;
default:
throw missingCaseError(disabledReason);
}
}}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
lookupConversationWithoutUuid={lookupConversationWithoutUuid}
showConversation={showConversation}
blockConversation={blockConversation}
onSelectConversation={onSelectConversation}
onOutgoingAudioCallInConversation={
onOutgoingAudioCallInConversation
}
onOutgoingVideoCallInConversation={
onOutgoingVideoCallInConversation
}
removeConversation={
isContactManagementEnabled ? removeConversation : undefined
}
renderMessageSearchResult={renderMessageSearchResult}
rowCount={helper.getRowCount()}
scrollBehavior={scrollBehavior}
scrollToRowIndex={rowIndexToScrollTo}
scrollable={isScrollable}
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
showChooseGroupMembers={showChooseGroupMembers}
theme={theme}
/> />
);
}}
</ContextMenu>
</>
}
>
<nav
className={classNames(
'module-left-pane',
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers &&
'module-left-pane--mode-choose-group-members',
modeSpecificProps.mode === LeftPaneMode.Compose &&
'module-left-pane--mode-compose'
)}
>
{/* eslint-enable jsx-a11y/no-static-element-interactions */}
<div className="module-left-pane__header">
{helper.getHeaderContents({
i18n,
showInbox,
startComposing,
showChooseGroupMembers,
})}
</div>
<NavSidebarSearchHeader>
{helper.getSearchInput({
clearConversationSearch,
clearSearch,
i18n,
onChangeComposeSearchTerm: event => {
setComposeSearchTerm(event.target.value);
},
updateSearchTerm,
showConversation,
})}
</NavSidebarSearchHeader>
<div className="module-left-pane__dialogs">
{dialogs.map(({ key, dialog }) => (
<React.Fragment key={key}>{dialog}</React.Fragment>
))}
</div>
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
<SizeObserver>
{(ref, size) => (
<div className="module-left-pane__list--measure" ref={ref}>
<div className="module-left-pane__list--wrapper">
<div
aria-live="polite"
className="module-left-pane__list"
data-supertab
key={listKey}
role="presentation"
tabIndex={-1}
>
<ConversationList
dimensions={size ?? undefined}
getPreferredBadge={getPreferredBadge}
getRow={getRow}
i18n={i18n}
onClickArchiveButton={showArchivedConversations}
onClickContactCheckbox={(
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => {
switch (disabledReason) {
case undefined:
toggleConversationInChooseMembers(conversationId);
break;
case ContactCheckboxDisabledReason.AlreadyAdded:
case ContactCheckboxDisabledReason.MaximumContactsSelected:
// These are no-ops.
break;
default:
throw missingCaseError(disabledReason);
}
}}
showUserNotFoundModal={showUserNotFoundModal}
setIsFetchingUUID={setIsFetchingUUID}
lookupConversationWithoutUuid={
lookupConversationWithoutUuid
}
showConversation={showConversation}
blockConversation={blockConversation}
onSelectConversation={onSelectConversation}
onOutgoingAudioCallInConversation={
onOutgoingAudioCallInConversation
}
onOutgoingVideoCallInConversation={
onOutgoingVideoCallInConversation
}
removeConversation={
isContactManagementEnabled
? removeConversation
: undefined
}
renderMessageSearchResult={renderMessageSearchResult}
rowCount={helper.getRowCount()}
scrollBehavior={scrollBehavior}
scrollToRowIndex={rowIndexToScrollTo}
scrollable={isScrollable}
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
showChooseGroupMembers={showChooseGroupMembers}
theme={theme}
/>
</div>
</div> </div>
</div> </div>
</div> )}
</SizeObserver>
{footerContents && (
<div className="module-left-pane__footer">{footerContents}</div>
)} )}
</SizeObserver>
{footerContents && ( {challengeStatus !== 'idle' &&
<div className="module-left-pane__footer">{footerContents}</div> renderCaptchaDialog({
)} onSkip() {
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} setChallengeStatus('idle');
<div },
className="module-left-pane__resize-grab-area" })}
onMouseDown={() => { {crashReportCount > 0 && renderCrashReportDialog()}
setIsResizing(true); </nav>
}} </NavSidebar>
/>
{challengeStatus !== 'idle' &&
renderCaptchaDialog({
onSkip() {
setChallengeStatus('idle');
},
})}
{crashReportCount > 0 && renderCrashReportDialog()}
</nav>
); );
} }

View file

@ -1,94 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, Story } from '@storybook/react';
import React from 'react';
import type { PropsType } from './MainHeader';
import enMessages from '../../_locales/en/messages.json';
import { MainHeader } from './MainHeader';
import { ThemeType } from '../types/Util';
import { setupI18n } from '../util/setupI18n';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/MainHeader',
component: MainHeader,
argTypes: {
areStoriesEnabled: {
defaultValue: false,
},
avatarPath: {
defaultValue: undefined,
},
hasPendingUpdate: {
defaultValue: false,
},
i18n: {
defaultValue: i18n,
},
name: {
defaultValue: undefined,
},
phoneNumber: {
defaultValue: undefined,
},
showArchivedConversations: { action: true },
startComposing: { action: true },
startUpdate: { action: true },
theme: {
defaultValue: ThemeType.light,
},
title: {
defaultValue: '',
},
toggleProfileEditor: { action: true },
toggleStoriesView: { action: true },
unreadStoriesCount: {
defaultValue: 0,
},
},
} as Meta;
// eslint-disable-next-line react/function-component-definition
const Template: Story<PropsType> = args => <MainHeader {...args} />;
export const Basic = Template.bind({});
Basic.args = {};
export const Name = Template.bind({});
{
const { name, title } = getDefaultConversation();
Name.args = {
name,
title,
};
}
export const PhoneNumber = Template.bind({});
{
const { name, e164: phoneNumber } = getDefaultConversation();
PhoneNumber.args = {
name,
phoneNumber,
};
}
export const UpdateAvailable = Template.bind({});
UpdateAvailable.args = {
hasPendingUpdate: true,
};
export const Stories = Template.bind({});
Stories.args = {
areStoriesEnabled: true,
unreadStoriesCount: 6,
};
export const StoriesOverflow = Template.bind({});
StoriesOverflow.args = {
areStoriesEnabled: true,
unreadStoriesCount: 69,
};

View file

@ -1,228 +0,0 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useState } from 'react';
import { usePopper } from 'react-popper';
import { createPortal } from 'react-dom';
import { showSettings } from '../shims/Whisper';
import { Avatar, AvatarSize } from './Avatar';
import { AvatarPopup } from './AvatarPopup';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { AvatarColorType } from '../types/Colors';
import type { BadgeType } from '../badges/types';
import { handleOutsideClick } from '../util/handleOutsideClick';
const EMPTY_OBJECT = Object.freeze(Object.create(null));
export type PropsType = {
areStoriesEnabled: boolean;
avatarPath?: string;
badge?: BadgeType;
color?: AvatarColorType;
hasPendingUpdate: boolean;
i18n: LocalizerType;
isMe?: boolean;
isVerified?: boolean;
name?: string;
phoneNumber?: string;
profileName?: string;
theme: ThemeType;
title: string;
hasFailedStorySends?: boolean;
unreadStoriesCount: number;
showArchivedConversations: () => void;
startComposing: () => void;
startUpdate: () => unknown;
toggleProfileEditor: () => void;
toggleStoriesView: () => unknown;
};
export function MainHeader({
areStoriesEnabled,
avatarPath,
badge,
color,
hasFailedStorySends,
hasPendingUpdate,
i18n,
name,
phoneNumber,
profileName,
showArchivedConversations,
startComposing,
startUpdate,
theme,
title,
toggleProfileEditor,
toggleStoriesView,
unreadStoriesCount,
}: PropsType): JSX.Element {
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
const [showAvatarPopup, setShowAvatarPopup] = useState(false);
const popper = usePopper(targetElement, popperElement, {
placement: 'bottom-start',
strategy: 'fixed',
modifiers: [
{
name: 'offset',
options: {
offset: [null, 4],
},
},
],
});
useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setPortalElement(div);
return () => {
div.remove();
setPortalElement(null);
};
}, []);
useEffect(() => {
return handleOutsideClick(
() => {
if (!showAvatarPopup) {
return false;
}
setShowAvatarPopup(false);
return true;
},
{
containerElements: [portalElement, targetElement],
name: 'MainHeader.showAvatarPopup',
}
);
}, [portalElement, targetElement, showAvatarPopup]);
useEffect(() => {
function handleGlobalKeyDown(event: KeyboardEvent) {
if (showAvatarPopup && event.key === 'Escape') {
setShowAvatarPopup(false);
}
}
document.addEventListener('keydown', handleGlobalKeyDown, true);
return () => {
document.removeEventListener('keydown', handleGlobalKeyDown, true);
};
}, [showAvatarPopup]);
return (
<div className="module-main-header">
<div
className="module-main-header__avatar--container"
data-supertab
ref={setTargetElement}
>
<Avatar
aria-expanded={showAvatarPopup}
aria-owns="MainHeader__AvatarPopup"
acceptedMessageRequest
avatarPath={avatarPath}
badge={badge}
className="module-main-header__avatar"
color={color}
conversationType="direct"
i18n={i18n}
isMe
phoneNumber={phoneNumber}
profileName={profileName}
theme={theme}
title={title}
// `sharedGroupNames` makes no sense for yourself, but
// `<Avatar>` needs it to determine blurring.
sharedGroupNames={[]}
size={AvatarSize.TWENTY_EIGHT}
onClick={() => {
setShowAvatarPopup(true);
}}
/>
{hasPendingUpdate && (
<div className="module-main-header__avatar--badged" />
)}
</div>
{showAvatarPopup &&
portalElement != null &&
createPortal(
<div
id="MainHeader__AvatarPopup"
ref={setPopperElement}
style={{ ...popper.styles.popper, zIndex: 10 }}
{...popper.attributes.popper}
>
<AvatarPopup
acceptedMessageRequest
badge={badge}
i18n={i18n}
isMe
color={color}
conversationType="direct"
name={name}
phoneNumber={phoneNumber}
profileName={profileName}
theme={theme}
title={title}
avatarPath={avatarPath}
hasPendingUpdate={hasPendingUpdate}
// See the comment above about `sharedGroupNames`.
sharedGroupNames={[]}
onEditProfile={() => {
toggleProfileEditor();
setShowAvatarPopup(false);
}}
onStartUpdate={() => {
startUpdate();
setShowAvatarPopup(false);
}}
onViewPreferences={() => {
showSettings();
setShowAvatarPopup(false);
}}
onViewArchive={() => {
showArchivedConversations();
setShowAvatarPopup(false);
}}
style={EMPTY_OBJECT}
/>
</div>,
portalElement
)}
<div className="module-main-header__icon-container" data-supertab>
{areStoriesEnabled && (
<button
aria-label={i18n('icu:stories')}
className="module-main-header__stories-icon"
onClick={toggleStoriesView}
title={i18n('icu:stories')}
type="button"
>
{hasFailedStorySends && (
<span className="module-main-header__stories-badge">!</span>
)}
{!hasFailedStorySends && unreadStoriesCount ? (
<span className="module-main-header__stories-badge">
{unreadStoriesCount}
</span>
) : undefined}
</button>
)}
<button
aria-label={i18n('icu:newConversation')}
className="module-main-header__compose-icon"
onClick={startComposing}
title={i18n('icu:newConversation')}
type="button"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,219 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react';
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import { useMove } from 'react-aria';
import { NavTabsToggle } from './NavTabs';
import type { LocalizerType } from '../types/I18N';
import {
MAX_WIDTH,
MIN_FULL_WIDTH,
MIN_WIDTH,
getWidthFromPreferredWidth,
} from '../util/leftPaneWidth';
import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util';
export function NavSidebarActionButton({
icon,
label,
onClick,
onKeyDown,
}: {
icon: ReactNode;
label: ReactNode;
onClick: MouseEventHandler<HTMLButtonElement>;
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
}): JSX.Element {
return (
<button
type="button"
className="NavSidebar__ActionButton"
onClick={onClick}
onKeyDown={onKeyDown}
>
{icon}
<span className="NavSidebar__ActionButtonLabel">{label}</span>
</button>
);
}
export type NavSidebarProps = Readonly<{
actions?: ReactNode;
children: ReactNode;
i18n: LocalizerType;
hideHeader?: boolean;
navTabsCollapsed: boolean;
onBack?: (() => void) | null;
onToggleNavTabsCollapse(navTabsCollapsed: boolean): void;
preferredLeftPaneWidth: number;
requiresFullWidth: boolean;
savePreferredLeftPaneWidth: (width: number) => void;
title: string;
}>;
enum DragState {
INITIAL,
DRAGGING,
DRAGEND,
}
export function NavSidebar({
actions,
children,
hideHeader,
i18n,
navTabsCollapsed,
onBack,
onToggleNavTabsCollapse,
preferredLeftPaneWidth,
requiresFullWidth,
savePreferredLeftPaneWidth,
title,
}: NavSidebarProps): JSX.Element {
const [dragState, setDragState] = useState(DragState.INITIAL);
const [preferredWidth, setPreferredWidth] = useState(() => {
return getWidthFromPreferredWidth(preferredLeftPaneWidth, {
requiresFullWidth,
});
});
const width = getWidthFromPreferredWidth(preferredWidth, {
requiresFullWidth,
});
const widthBreakpoint = getNavSidebarWidthBreakpoint(width);
// `useMove` gives us keyboard and mouse dragging support.
const { moveProps } = useMove({
onMoveStart() {
setDragState(DragState.DRAGGING);
},
onMoveEnd() {
setDragState(DragState.DRAGEND);
},
onMove(event) {
const { deltaX, shiftKey, pointerType } = event;
const isKeyboard = pointerType === 'keyboard';
const increment = isKeyboard && shiftKey ? 10 : 1;
setPreferredWidth(prevWidth => {
// Jump minimize for keyboard users
if (isKeyboard && prevWidth === MIN_FULL_WIDTH && deltaX < 0) {
return MIN_WIDTH;
}
// Jump maximize for keyboard users
if (isKeyboard && prevWidth === MIN_WIDTH && deltaX > 0) {
return MIN_FULL_WIDTH;
}
return prevWidth + deltaX * increment;
});
},
});
useEffect(() => {
// Save the preferred width when the drag ends. We can't do this in onMoveEnd
// because the width is not updated yet.
if (dragState === DragState.DRAGEND) {
setPreferredWidth(width);
savePreferredLeftPaneWidth(width);
setDragState(DragState.INITIAL);
}
}, [
dragState,
preferredLeftPaneWidth,
preferredWidth,
savePreferredLeftPaneWidth,
width,
]);
useEffect(() => {
// This effect helps keep the pointer `col-resize` even when you drag past the handle.
const className = 'NavSidebar__document--draggingHandle';
if (dragState === DragState.DRAGGING) {
document.body.classList.add(className);
return () => {
document.body.classList.remove(className);
};
}
return undefined;
}, [dragState]);
return (
<div
role="navigation"
className={classNames('NavSidebar', {
'NavSidebar--narrow': widthBreakpoint === WidthBreakpoint.Narrow,
})}
style={{ width }}
>
{!hideHeader && (
<div className="NavSidebar__Header">
{onBack == null && navTabsCollapsed && (
<NavTabsToggle
i18n={i18n}
navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
/>
)}
<div
className={classNames('NavSidebar__HeaderContent', {
'NavSidebar__HeaderContent--navTabsCollapsed': navTabsCollapsed,
'NavSidebar__HeaderContent--withBackButton': onBack != null,
})}
>
{onBack != null && (
<button
type="button"
role="link"
onClick={onBack}
className="NavSidebar__BackButton"
>
<span className="NavSidebar__BackButtonLabel">
{i18n('icu:NavSidebar__BackButtonLabel')}
</span>
</button>
)}
<h1
className={classNames('NavSidebar__HeaderTitle', {
'NavSidebar__HeaderTitle--withBackButton': onBack != null,
})}
aria-live="assertive"
>
{title}
</h1>
{actions && (
<div className="NavSidebar__HeaderActions">{actions}</div>
)}
</div>
</div>
)}
<div className="NavSidebar__Content">{children}</div>
{/* eslint-disable-next-line jsx-a11y/role-supports-aria-props -- See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/separator_role#focusable_separator */}
<div
className={classNames('NavSidebar__DragHandle', {
'NavSidebar__DragHandle--dragging': dragState === DragState.DRAGGING,
})}
role="separator"
aria-orientation="vertical"
aria-valuemin={MIN_WIDTH}
aria-valuemax={preferredLeftPaneWidth}
aria-valuenow={MAX_WIDTH}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/separator_role#focusable_separator
tabIndex={0}
{...moveProps}
/>
</div>
);
}
export function NavSidebarSearchHeader({
children,
}: {
children: ReactNode;
}): JSX.Element {
return <div className="NavSidebarSearchHeader">{children}</div>;
}

352
ts/components/NavTabs.tsx Normal file
View file

@ -0,0 +1,352 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Key, ReactNode } from 'react';
import React, { useEffect, useState } from 'react';
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'react-aria-components';
import classNames from 'classnames';
import { usePopper } from 'react-popper';
import { createPortal } from 'react-dom';
import { Avatar, AvatarSize } from './Avatar';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
import type { BadgeType } from '../badges/types';
import { AvatarPopup } from './AvatarPopup';
import { handleOutsideClick } from '../util/handleOutsideClick';
import type { UnreadStats } from '../state/selectors/conversations';
import { NavTab } from '../state/ducks/nav';
type NavTabProps = Readonly<{
badge?: ReactNode;
iconClassName: string;
id: NavTab;
label: string;
}>;
function NavTabsItem({ badge, iconClassName, id, label }: NavTabProps) {
return (
<Tab id={id} data-testid={`NavTabsItem--${id}`} className="NavTabs__Item">
<span className="NavTabs__ItemLabel">{label}</span>
<span className="NavTabs__ItemButton">
<span className="NavTabs__ItemContent">
<span
role="presentation"
className={`NavTabs__ItemIcon ${iconClassName}`}
/>
{badge && <span className="NavTabs__ItemBadge">{badge}</span>}
</span>
</span>
</Tab>
);
}
export type NavTabPanelProps = Readonly<{
collapsed: boolean;
onToggleCollapse(collapsed: boolean): void;
}>;
export type NavTabsToggleProps = Readonly<{
i18n: LocalizerType;
navTabsCollapsed: boolean;
onToggleNavTabsCollapse(navTabsCollapsed: boolean): void;
}>;
export function NavTabsToggle({
i18n,
navTabsCollapsed,
onToggleNavTabsCollapse,
}: NavTabsToggleProps): JSX.Element {
function handleToggle() {
onToggleNavTabsCollapse(!navTabsCollapsed);
}
return (
<button
type="button"
className="NavTabs__Item NavTabs__Toggle"
onClick={handleToggle}
>
<span className="NavTabs__ItemButton">
<span
role="presentation"
className="NavTabs__ItemIcon NavTabs__ItemIcon--Menu"
/>
<span className="NavTabs__ItemLabel">
{navTabsCollapsed
? i18n('icu:NavTabsToggle__showTabs')
: i18n('icu:NavTabsToggle__hideTabs')}
</span>
</span>
</button>
);
}
export type NavTabsProps = Readonly<{
badge: BadgeType | undefined;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
i18n: LocalizerType;
me: ConversationType;
navTabsCollapsed: boolean;
onShowSettings: () => void;
onStartUpdate: () => unknown;
onNavTabSelected(tab: NavTab): void;
onToggleNavTabsCollapse(collapsed: boolean): void;
onToggleProfileEditor: () => void;
renderCallsTab(props: NavTabPanelProps): JSX.Element;
renderChatsTab(props: NavTabPanelProps): JSX.Element;
renderStoriesTab(props: NavTabPanelProps): JSX.Element;
selectedNavTab: NavTab;
storiesEnabled: boolean;
theme: ThemeType;
unreadConversationsStats: UnreadStats;
unreadStoriesCount: number;
}>;
export function NavTabs({
badge,
hasFailedStorySends,
hasPendingUpdate,
i18n,
me,
navTabsCollapsed,
onShowSettings,
onStartUpdate,
onNavTabSelected,
onToggleNavTabsCollapse,
onToggleProfileEditor,
renderCallsTab,
renderChatsTab,
renderStoriesTab,
selectedNavTab,
storiesEnabled,
theme,
unreadConversationsStats,
unreadStoriesCount,
}: NavTabsProps): JSX.Element {
function handleSelectionChange(key: Key) {
onNavTabSelected(key as NavTab);
}
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
const [showAvatarPopup, setShowAvatarPopup] = useState(false);
const popper = usePopper(targetElement, popperElement, {
placement: 'bottom-start',
strategy: 'fixed',
modifiers: [
{
name: 'offset',
options: {
offset: [null, 4],
},
},
],
});
useEffect(() => {
const div = document.createElement('div');
document.body.appendChild(div);
setPortalElement(div);
return () => {
div.remove();
setPortalElement(null);
};
}, []);
useEffect(() => {
return handleOutsideClick(
() => {
if (!showAvatarPopup) {
return false;
}
setShowAvatarPopup(false);
return true;
},
{
containerElements: [portalElement, targetElement],
name: 'MainHeader.showAvatarPopup',
}
);
}, [portalElement, targetElement, showAvatarPopup]);
useEffect(() => {
function handleGlobalKeyDown(event: KeyboardEvent) {
if (showAvatarPopup && event.key === 'Escape') {
setShowAvatarPopup(false);
}
}
document.addEventListener('keydown', handleGlobalKeyDown, true);
return () => {
document.removeEventListener('keydown', handleGlobalKeyDown, true);
};
}, [showAvatarPopup]);
return (
<Tabs orientation="vertical" className="NavTabs__Container">
<nav
className={classNames('NavTabs', {
'NavTabs--collapsed': navTabsCollapsed,
})}
>
<NavTabsToggle
i18n={i18n}
navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
/>
<TabList
className="NavTabs__TabList"
selectedKey={selectedNavTab}
onSelectionChange={handleSelectionChange}
>
<NavTabsItem
id={NavTab.Chats}
label="Chats"
iconClassName="NavTabs__ItemIcon--Chats"
badge={
// eslint-disable-next-line no-nested-ternary
unreadConversationsStats.unreadCount > 0 ? (
<>
<span className="NavTabs__ItemIconLabel">
{i18n('icu:NavTabs__ItemIconLabel--UnreadCount', {
count: unreadConversationsStats.unreadCount,
})}
</span>
<span aria-hidden>
{unreadConversationsStats.unreadCount}
</span>
</>
) : unreadConversationsStats.markedUnread ? (
<span className="NavTabs__ItemIconLabel">
{i18n('icu:NavTabs__ItemIconLabel--MarkedUnread')}
</span>
) : null
}
/>
<NavTabsItem
id={NavTab.Calls}
label="Calls"
iconClassName="NavTabs__ItemIcon--Calls"
/>
{storiesEnabled && (
<NavTabsItem
id={NavTab.Stories}
label="Stories"
iconClassName="NavTabs__ItemIcon--Stories"
badge={
// eslint-disable-next-line no-nested-ternary
hasFailedStorySends
? '!'
: unreadStoriesCount > 0
? unreadStoriesCount
: null
}
/>
)}
</TabList>
<div className="NavTabs__Misc">
<button
type="button"
className="NavTabs__Item"
onClick={onShowSettings}
>
<span className="NavTabs__ItemButton">
<span
role="presentation"
className="NavTabs__ItemIcon NavTabs__ItemIcon--Settings"
/>
<span className="NavTabs__ItemLabel">
{i18n('icu:NavTabs__ItemLabel--Settings')}
</span>
</span>
</button>
<button
type="button"
className="NavTabs__Item"
data-supertab
onClick={() => {
setShowAvatarPopup(true);
}}
aria-label={i18n('icu:NavTabs__ItemLabel--Profile')}
>
<span className="NavTabs__ItemButton" ref={setTargetElement}>
<span className="NavTabs__ItemContent">
<Avatar
acceptedMessageRequest
avatarPath={me.avatarPath}
badge={badge}
className="module-main-header__avatar"
color={me.color}
conversationType="direct"
i18n={i18n}
isMe
phoneNumber={me.phoneNumber}
profileName={me.profileName}
theme={theme}
title={me.title}
// `sharedGroupNames` makes no sense for yourself, but
// `<Avatar>` needs it to determine blurring.
sharedGroupNames={[]}
size={AvatarSize.TWENTY_EIGHT}
/>
{hasPendingUpdate && <div className="NavTabs__AvatarBadge" />}
</span>
</span>
</button>
{showAvatarPopup &&
portalElement != null &&
createPortal(
<div
id="MainHeader__AvatarPopup"
ref={setPopperElement}
style={{ ...popper.styles.popper, zIndex: 10 }}
{...popper.attributes.popper}
>
<AvatarPopup
acceptedMessageRequest
badge={badge}
i18n={i18n}
isMe
color={me.color}
conversationType="direct"
name={me.name}
phoneNumber={me.phoneNumber}
profileName={me.profileName}
theme={theme}
title={me.title}
avatarPath={me.avatarPath}
hasPendingUpdate={hasPendingUpdate}
// See the comment above about `sharedGroupNames`.
sharedGroupNames={[]}
onEditProfile={() => {
onToggleProfileEditor();
setShowAvatarPopup(false);
}}
onStartUpdate={() => {
onStartUpdate();
setShowAvatarPopup(false);
}}
style={{}}
/>
</div>,
portalElement
)}
</div>
</nav>
<TabPanels>
<TabPanel id={NavTab.Chats} className="NavTabs__TabPanel">
{renderChatsTab}
</TabPanel>
<TabPanel id={NavTab.Calls} className="NavTabs__TabPanel">
{renderCallsTab}
</TabPanel>
<TabPanel id={NavTab.Stories} className="NavTabs__TabPanel">
{renderStoriesTab}
</TabPanel>
</TabPanels>
</Tabs>
);
}

View file

@ -7,7 +7,6 @@ import type { LocalizerType } from '../types/Util';
import { Button, ButtonVariant } from './Button'; import { Button, ButtonVariant } from './Button';
import { Intl } from './Intl'; import { Intl } from './Intl';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { STORIES_COLOR_THEME } from './Stories';
export type PropsType = { export type PropsType = {
i18n: LocalizerType; i18n: LocalizerType;
@ -24,7 +23,6 @@ export function SignalConnectionsModal({
hasXButton hasXButton
i18n={i18n} i18n={i18n}
onClose={onClose} onClose={onClose}
theme={STORIES_COLOR_THEME}
> >
<div className="SignalConnectionsModal"> <div className="SignalConnectionsModal">
<i className="SignalConnectionsModal__icon" /> <i className="SignalConnectionsModal__icon" />

View file

@ -7,7 +7,6 @@ import React, { useState, useCallback } from 'react';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import type { ShowToastAction } from '../state/ducks/toast'; import type { ShowToastAction } from '../state/ducks/toast';
import { ContextMenu } from './ContextMenu'; import { ContextMenu } from './ContextMenu';
import { Theme } from '../util/theme';
import { ToastType } from '../types/Toast'; import { ToastType } from '../types/Toast';
import { import {
isVideoGoodForStories, isVideoGoodForStories,
@ -109,7 +108,6 @@ export function StoriesAddStoryButton({
placement: 'bottom', placement: 'bottom',
strategy: 'absolute', strategy: 'absolute',
}} }}
theme={Theme.Dark}
> >
{children} {children}
</ContextMenu> </ContextMenu>

View file

@ -10,18 +10,15 @@ import type {
ShowConversationType, ShowConversationType,
} from '../state/ducks/conversations'; } from '../state/ducks/conversations';
import type { ConversationStoryType, MyStoryType } from '../types/Stories'; import type { ConversationStoryType, MyStoryType } from '../types/Stories';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType, ThemeType } from '../types/Util';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { ShowToastAction } from '../state/ducks/toast'; import type { ShowToastAction } from '../state/ducks/toast';
import type { ViewUserStoriesActionCreatorType } from '../state/ducks/stories'; import type { ViewUserStoriesActionCreatorType } from '../state/ducks/stories';
import { ContextMenu } from './ContextMenu';
import { MyStoryButton } from './MyStoryButton'; import { MyStoryButton } from './MyStoryButton';
import { SearchInput } from './SearchInput'; import { SearchInput } from './SearchInput';
import { StoriesAddStoryButton } from './StoriesAddStoryButton';
import { StoryListItem } from './StoryListItem'; import { StoryListItem } from './StoryListItem';
import { Theme } from '../util/theme';
import { isNotNil } from '../util/isNotNil'; import { isNotNil } from '../util/isNotNil';
import { useRestoreFocus } from '../hooks/useRestoreFocus'; import { NavSidebarSearchHeader } from './NavSidebar';
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = { const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
getFn: (story, path) => { getFn: (story, path) => {
@ -70,8 +67,8 @@ export type PropsType = {
showConversation: ShowConversationType; showConversation: ShowConversationType;
showToast: ShowToastAction; showToast: ShowToastAction;
stories: Array<ConversationStoryType>; stories: Array<ConversationStoryType>;
theme: ThemeType;
toggleHideStories: (conversationId: string) => unknown; toggleHideStories: (conversationId: string) => unknown;
toggleStoriesView: () => unknown;
viewUserStories: ViewUserStoriesActionCreatorType; viewUserStories: ViewUserStoriesActionCreatorType;
}; };
@ -84,14 +81,13 @@ export function StoriesPane({
myStories, myStories,
onAddStory, onAddStory,
onMyStoriesClicked, onMyStoriesClicked,
onStoriesSettings,
onMediaPlaybackStart, onMediaPlaybackStart,
queueStoryDownload, queueStoryDownload,
showConversation, showConversation,
showToast, showToast,
stories, stories,
theme,
toggleHideStories, toggleHideStories,
toggleStoriesView,
viewUserStories, viewUserStories,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@ -106,55 +102,18 @@ export function StoriesPane({
setRenderedStories(stories); setRenderedStories(stories);
} }
}, [searchTerm, stories]); }, [searchTerm, stories]);
const [focusRef] = useRestoreFocus();
return ( return (
<> <>
<div className="Stories__pane__header"> <NavSidebarSearchHeader>
<button <SearchInput
ref={focusRef}
aria-label={i18n('icu:back')}
className="Stories__pane__header--back"
onClick={toggleStoriesView}
tabIndex={0}
type="button"
/>
<div className="Stories__pane__header--title">
{i18n('icu:Stories__title')}
</div>
<StoriesAddStoryButton
i18n={i18n} i18n={i18n}
maxAttachmentSizeInKb={maxAttachmentSizeInKb} onChange={event => {
moduleClassName="Stories__pane__add-story" setSearchTerm(event.target.value);
onAddStory={onAddStory}
showToast={showToast}
/>
<ContextMenu
i18n={i18n}
menuOptions={[
{
label: i18n('icu:StoriesSettings__context-menu'),
onClick: () => onStoriesSettings(),
},
]}
moduleClassName="Stories__pane__settings"
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}} }}
theme={Theme.Dark} placeholder={i18n('icu:search')}
value={searchTerm}
/> />
</div> </NavSidebarSearchHeader>
<SearchInput
i18n={i18n}
moduleClassName="Stories__search"
onChange={event => {
setSearchTerm(event.target.value);
}}
placeholder={i18n('icu:search')}
value={searchTerm}
/>
<div className="Stories__pane__list"> <div className="Stories__pane__list">
<MyStoryButton <MyStoryButton
i18n={i18n} i18n={i18n}
@ -178,12 +137,12 @@ export function StoriesPane({
key={story.storyView.timestamp} key={story.storyView.timestamp}
onGoToConversation={conversationId => { onGoToConversation={conversationId => {
showConversation({ conversationId }); showConversation({ conversationId });
toggleStoriesView();
}} }}
onHideStory={toggleHideStories} onHideStory={toggleHideStories}
onMediaPlaybackStart={onMediaPlaybackStart} onMediaPlaybackStart={onMediaPlaybackStart}
queueStoryDownload={queueStoryDownload} queueStoryDownload={queueStoryDownload}
story={story.storyView} story={story.storyView}
theme={theme}
viewUserStories={viewUserStories} viewUserStories={viewUserStories}
/> />
))} ))}
@ -191,6 +150,7 @@ export function StoriesPane({
<> <>
<button <button
className={classNames('Stories__hidden-stories', { className={classNames('Stories__hidden-stories', {
'Stories__hidden-stories--collapsed': !isShowingHiddenStories,
'Stories__hidden-stories--expanded': isShowingHiddenStories, 'Stories__hidden-stories--expanded': isShowingHiddenStories,
})} })}
onClick={() => setIsShowingHiddenStories(!isShowingHiddenStories)} onClick={() => setIsShowingHiddenStories(!isShowingHiddenStories)}
@ -209,12 +169,12 @@ export function StoriesPane({
key={story.storyView.timestamp} key={story.storyView.timestamp}
onGoToConversation={conversationId => { onGoToConversation={conversationId => {
showConversation({ conversationId }); showConversation({ conversationId });
toggleStoriesView();
}} }}
onHideStory={toggleHideStories} onHideStory={toggleHideStories}
onMediaPlaybackStart={onMediaPlaybackStart} onMediaPlaybackStart={onMediaPlaybackStart}
queueStoryDownload={queueStoryDownload} queueStoryDownload={queueStoryDownload}
story={story.storyView} story={story.storyView}
theme={theme}
viewUserStories={viewUserStories} viewUserStories={viewUserStories}
/> />
))} ))}

View file

@ -69,7 +69,6 @@ export type PropsType = {
setMyStoriesToAllSignalConnections: () => unknown; setMyStoriesToAllSignalConnections: () => unknown;
storyViewReceiptsEnabled: boolean; storyViewReceiptsEnabled: boolean;
toggleSignalConnectionsModal: () => unknown; toggleSignalConnectionsModal: () => unknown;
toggleStoriesView: () => void;
setStoriesDisabled: (value: boolean) => void; setStoriesDisabled: (value: boolean) => void;
getConversationByUuid: (uuid: UUIDStringType) => ConversationType | undefined; getConversationByUuid: (uuid: UUIDStringType) => ConversationType | undefined;
}; };
@ -256,7 +255,6 @@ export function StoriesSettingsModal({
setMyStoriesToAllSignalConnections, setMyStoriesToAllSignalConnections,
storyViewReceiptsEnabled, storyViewReceiptsEnabled,
toggleSignalConnectionsModal, toggleSignalConnectionsModal,
toggleStoriesView,
setStoriesDisabled, setStoriesDisabled,
getConversationByUuid, getConversationByUuid,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
@ -463,7 +461,6 @@ export function StoriesSettingsModal({
variant={ButtonVariant.SecondaryDestructive} variant={ButtonVariant.SecondaryDestructive}
onClick={async () => { onClick={async () => {
setStoriesDisabled(true); setStoriesDisabled(true);
toggleStoriesView();
onClose(); onClose();
}} }}
> >

View file

@ -4,8 +4,8 @@
import type { Meta, Story } from '@storybook/react'; import type { Meta, Story } from '@storybook/react';
import React from 'react'; import React from 'react';
import type { PropsType } from './Stories'; import type { PropsType } from './StoriesTab';
import { Stories } from './Stories'; import { StoriesTab } from './StoriesTab';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../_locales/en/messages.json';
import { setupI18n } from '../util/setupI18n'; import { setupI18n } from '../util/setupI18n';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
@ -18,8 +18,8 @@ import * as durations from '../util/durations';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
export default { export default {
title: 'Components/Stories', title: 'Components/StoriesTab',
component: Stories, component: StoriesTab,
argTypes: { argTypes: {
deleteStoryForEveryone: { action: true }, deleteStoryForEveryone: { action: true },
getPreferredBadge: { action: true }, getPreferredBadge: { action: true },
@ -63,7 +63,7 @@ export default {
} as Meta; } as Meta;
// eslint-disable-next-line react/function-component-definition // eslint-disable-next-line react/function-component-definition
const Template: Story<PropsType> = args => <Stories {...args} />; const Template: Story<PropsType> = args => <StoriesTab {...args} />;
export const Blank = Template.bind({}); export const Blank = Template.bind({});
Blank.args = {}; Blank.args = {};

View file

@ -2,7 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react'; import React, { useState } from 'react';
import classNames from 'classnames';
import type { import type {
ConversationType, ConversationType,
ShowConversationType, ShowConversationType,
@ -12,7 +11,7 @@ import type {
MyStoryType, MyStoryType,
StoryViewType, StoryViewType,
} from '../types/Stories'; } from '../types/Stories';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType, ThemeType } from '../types/Util';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { ShowToastAction } from '../state/ducks/toast'; import type { ShowToastAction } from '../state/ducks/toast';
import type { import type {
@ -22,9 +21,9 @@ import type {
} from '../state/ducks/stories'; } from '../state/ducks/stories';
import { MyStories } from './MyStories'; import { MyStories } from './MyStories';
import { StoriesPane } from './StoriesPane'; import { StoriesPane } from './StoriesPane';
import { Theme, themeClassName } from '../util/theme'; import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
import { getWidthFromPreferredWidth } from '../util/leftPaneWidth'; import { StoriesAddStoryButton } from './StoriesAddStoryButton';
import { useEscapeHandling } from '../hooks/useEscapeHandling'; import { ContextMenu } from './ContextMenu';
export type PropsType = { export type PropsType = {
addStoryData: AddStoryData; addStoryData: AddStoryData;
@ -38,90 +37,132 @@ export type PropsType = {
maxAttachmentSizeInKb: number; maxAttachmentSizeInKb: number;
me: ConversationType; me: ConversationType;
myStories: Array<MyStoryType>; myStories: Array<MyStoryType>;
navTabsCollapsed: boolean;
onForwardStory: (storyId: string) => unknown; onForwardStory: (storyId: string) => unknown;
onSaveStory: (story: StoryViewType) => unknown; onSaveStory: (story: StoryViewType) => unknown;
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
onMediaPlaybackStart: () => void; onMediaPlaybackStart: () => void;
preferredLeftPaneWidth: number;
preferredWidthFromStorage: number; preferredWidthFromStorage: number;
queueStoryDownload: (storyId: string) => unknown; queueStoryDownload: (storyId: string) => unknown;
renderStoryCreator: () => JSX.Element; renderStoryCreator: () => JSX.Element;
retryMessageSend: (messageId: string) => unknown; retryMessageSend: (messageId: string) => unknown;
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
setAddStoryData: (data: AddStoryData) => unknown; setAddStoryData: (data: AddStoryData) => unknown;
showConversation: ShowConversationType; showConversation: ShowConversationType;
showStoriesSettings: () => unknown; showStoriesSettings: () => unknown;
showToast: ShowToastAction; showToast: ShowToastAction;
stories: Array<ConversationStoryType>; stories: Array<ConversationStoryType>;
theme: ThemeType;
toggleHideStories: (conversationId: string) => unknown; toggleHideStories: (conversationId: string) => unknown;
toggleStoriesView: () => unknown;
viewStory: ViewStoryActionCreatorType; viewStory: ViewStoryActionCreatorType;
viewUserStories: ViewUserStoriesActionCreatorType; viewUserStories: ViewUserStoriesActionCreatorType;
}; };
export const STORIES_COLOR_THEME = Theme.Dark; export function StoriesTab({
export function Stories({
addStoryData, addStoryData,
deleteStoryForEveryone, deleteStoryForEveryone,
getPreferredBadge, getPreferredBadge,
hasViewReceiptSetting, hasViewReceiptSetting,
hiddenStories, hiddenStories,
i18n, i18n,
isStoriesSettingsVisible,
isViewingStory,
maxAttachmentSizeInKb, maxAttachmentSizeInKb,
me, me,
myStories, myStories,
navTabsCollapsed,
onForwardStory, onForwardStory,
onSaveStory, onSaveStory,
onToggleNavTabsCollapse,
onMediaPlaybackStart, onMediaPlaybackStart,
preferredWidthFromStorage, preferredLeftPaneWidth,
queueStoryDownload, queueStoryDownload,
renderStoryCreator, renderStoryCreator,
retryMessageSend, retryMessageSend,
savePreferredLeftPaneWidth,
setAddStoryData, setAddStoryData,
showConversation, showConversation,
showStoriesSettings, showStoriesSettings,
showToast, showToast,
stories, stories,
theme,
toggleHideStories, toggleHideStories,
toggleStoriesView,
viewStory, viewStory,
viewUserStories, viewUserStories,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const width = getWidthFromPreferredWidth(preferredWidthFromStorage, {
requiresFullWidth: true,
});
const [isMyStories, setIsMyStories] = useState(false); const [isMyStories, setIsMyStories] = useState(false);
// only handle ESC if not showing a child that handles their own ESC function onAddStory(file?: File) {
useEscapeHandling( if (file) {
(isMyStories && myStories.length) || setAddStoryData({ type: 'Media', file });
isViewingStory || } else {
isStoriesSettingsVisible || setAddStoryData({ type: 'Text' });
addStoryData }
? undefined }
: toggleStoriesView
);
return ( return (
<div className={classNames('Stories', themeClassName(STORIES_COLOR_THEME))}> <div className="Stories">
{addStoryData && renderStoryCreator()} {addStoryData && renderStoryCreator()}
<div className="Stories__pane" style={{ width }}> {isMyStories && myStories.length ? (
{isMyStories && myStories.length ? ( <MyStories
<MyStories hasViewReceiptSetting={hasViewReceiptSetting}
hasViewReceiptSetting={hasViewReceiptSetting} i18n={i18n}
i18n={i18n} myStories={myStories}
myStories={myStories} onBack={() => setIsMyStories(false)}
onBack={() => setIsMyStories(false)} onDelete={deleteStoryForEveryone}
onDelete={deleteStoryForEveryone} onForward={onForwardStory}
onForward={onForwardStory} onSave={onSaveStory}
onSave={onSaveStory} onMediaPlaybackStart={onMediaPlaybackStart}
onMediaPlaybackStart={onMediaPlaybackStart} queueStoryDownload={queueStoryDownload}
queueStoryDownload={queueStoryDownload} retryMessageSend={retryMessageSend}
retryMessageSend={retryMessageSend} viewStory={viewStory}
viewStory={viewStory} />
/> ) : (
) : ( <NavSidebar
title="Stories"
i18n={i18n}
navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
preferredLeftPaneWidth={preferredLeftPaneWidth}
requiresFullWidth
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
actions={
<>
<StoriesAddStoryButton
i18n={i18n}
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
moduleClassName="Stories__pane__add-story"
onAddStory={onAddStory}
showToast={showToast}
/>
<ContextMenu
i18n={i18n}
menuOptions={[
{
label: i18n('icu:StoriesSettings__context-menu'),
onClick: showStoriesSettings,
},
]}
moduleClassName="Stories__pane__settings"
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
portalToRoot
>
{({ openMenu, onKeyDown }) => {
return (
<NavSidebarActionButton
onClick={openMenu}
onKeyDown={onKeyDown}
icon={<span className="StoriesTab__MoreActionsIcon" />}
label={i18n('icu:StoriesTab__MoreActionsLabel')}
/>
);
}}
</ContextMenu>
</>
}
>
<StoriesPane <StoriesPane
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
hiddenStories={hiddenStories} hiddenStories={hiddenStories}
@ -129,11 +170,7 @@ export function Stories({
maxAttachmentSizeInKb={maxAttachmentSizeInKb} maxAttachmentSizeInKb={maxAttachmentSizeInKb}
me={me} me={me}
myStories={myStories} myStories={myStories}
onAddStory={file => onAddStory={onAddStory}
file
? setAddStoryData({ type: 'Media', file })
: setAddStoryData({ type: 'Text' })
}
onMyStoriesClicked={() => { onMyStoriesClicked={() => {
if (myStories.length) { if (myStories.length) {
setIsMyStories(true); setIsMyStories(true);
@ -147,12 +184,12 @@ export function Stories({
showConversation={showConversation} showConversation={showConversation}
showToast={showToast} showToast={showToast}
stories={stories} stories={stories}
theme={theme}
toggleHideStories={toggleHideStories} toggleHideStories={toggleHideStories}
toggleStoriesView={toggleStoriesView}
viewUserStories={viewUserStories} viewUserStories={viewUserStories}
/> />
)} </NavSidebar>
</div> )}
<div className="Stories__placeholder"> <div className="Stories__placeholder">
<div className="Stories__placeholder__stories" /> <div className="Stories__placeholder__stories" />
{i18n('icu:Stories__placeholder--text')} {i18n('icu:Stories__placeholder--text')}

View file

@ -4,6 +4,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { get, has } from 'lodash'; import { get, has } from 'lodash';
import { createPortal } from 'react-dom';
import type { import type {
AttachmentType, AttachmentType,
InMemoryAttachmentDraftType, InMemoryAttachmentDraftType,
@ -26,6 +27,22 @@ import { TextStoryCreator } from './TextStoryCreator';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea'; import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
import type { DraftBodyRanges } from '../types/BodyRange'; import type { DraftBodyRanges } from '../types/BodyRange';
function usePortalElement(testid: string): HTMLDivElement | null {
const [element, setElement] = useState<HTMLDivElement | null>(null);
useEffect(() => {
const div = document.createElement('div');
div.dataset.testid = testid;
document.body.appendChild(div);
setElement(div);
return () => {
document.body.removeChild(div);
};
}, [testid]);
return element;
}
export type PropsType = { export type PropsType = {
debouncedMaybeGrabLinkPreview: ( debouncedMaybeGrabLinkPreview: (
message: string, message: string,
@ -119,7 +136,9 @@ export function StoryCreator({
skinTone, skinTone,
toggleGroupsForStorySend, toggleGroupsForStorySend,
toggleSignalConnectionsModal, toggleSignalConnectionsModal,
}: PropsType): JSX.Element { }: PropsType): JSX.Element | null {
const portalElement = usePortalElement('StoryCreatorPortal');
const [draftAttachment, setDraftAttachment] = useState< const [draftAttachment, setDraftAttachment] = useState<
AttachmentType | undefined AttachmentType | undefined
>(); >();
@ -173,97 +192,100 @@ export function StoryCreator({
} }
}, [draftAttachment, sendStoryModalOpenStateChanged]); }, [draftAttachment, sendStoryModalOpenStateChanged]);
return ( return portalElement != null
<> ? createPortal(
{draftAttachment && isReadyToSend && ( <>
<SendStoryModal {draftAttachment && isReadyToSend && (
draftAttachment={draftAttachment} <SendStoryModal
candidateConversations={candidateConversations} draftAttachment={draftAttachment}
distributionLists={distributionLists} candidateConversations={candidateConversations}
getPreferredBadge={getPreferredBadge} distributionLists={distributionLists}
groupConversations={groupConversations} getPreferredBadge={getPreferredBadge}
groupStories={groupStories} groupConversations={groupConversations}
hasFirstStoryPostExperience={hasFirstStoryPostExperience} groupStories={groupStories}
ourConversationId={ourConversationId} hasFirstStoryPostExperience={hasFirstStoryPostExperience}
i18n={i18n} ourConversationId={ourConversationId}
me={me} i18n={i18n}
onClose={() => setDraftAttachment(undefined)} me={me}
onDeleteList={onDeleteList} onClose={() => setDraftAttachment(undefined)}
onDistributionListCreated={onDistributionListCreated} onDeleteList={onDeleteList}
onHideMyStoriesFrom={onHideMyStoriesFrom} onDistributionListCreated={onDistributionListCreated}
onRemoveMembers={onRemoveMembers} onHideMyStoriesFrom={onHideMyStoriesFrom}
onRepliesNReactionsChanged={onRepliesNReactionsChanged} onRemoveMembers={onRemoveMembers}
onSelectedStoryList={onSelectedStoryList} onRepliesNReactionsChanged={onRepliesNReactionsChanged}
onSend={(listIds, groupIds) => { onSelectedStoryList={onSelectedStoryList}
onSend(listIds, groupIds, draftAttachment, bodyRanges); onSend={(listIds, groupIds) => {
setDraftAttachment(undefined); onSend(listIds, groupIds, draftAttachment, bodyRanges);
}} setDraftAttachment(undefined);
onViewersUpdated={onViewersUpdated} }}
onMediaPlaybackStart={onMediaPlaybackStart} onViewersUpdated={onViewersUpdated}
setMyStoriesToAllSignalConnections={ onMediaPlaybackStart={onMediaPlaybackStart}
setMyStoriesToAllSignalConnections setMyStoriesToAllSignalConnections={
} setMyStoriesToAllSignalConnections
signalConnections={signalConnections} }
toggleGroupsForStorySend={toggleGroupsForStorySend} signalConnections={signalConnections}
mostRecentActiveStoryTimestampByGroupOrDistributionList={ toggleGroupsForStorySend={toggleGroupsForStorySend}
mostRecentActiveStoryTimestampByGroupOrDistributionList mostRecentActiveStoryTimestampByGroupOrDistributionList={
} mostRecentActiveStoryTimestampByGroupOrDistributionList
toggleSignalConnectionsModal={toggleSignalConnectionsModal} }
/> toggleSignalConnectionsModal={toggleSignalConnectionsModal}
)} />
{draftAttachment && !isReadyToSend && attachmentUrl && ( )}
<MediaEditor {draftAttachment && !isReadyToSend && attachmentUrl && (
doneButtonLabel={i18n('icu:next2')} <MediaEditor
i18n={i18n} doneButtonLabel={i18n('icu:next2')}
imageSrc={attachmentUrl} i18n={i18n}
installedPacks={installedPacks} imageSrc={attachmentUrl}
isSending={isSending} installedPacks={installedPacks}
onClose={onClose} isSending={isSending}
supportsCaption onClose={onClose}
renderCompositionTextArea={renderCompositionTextArea} supportsCaption
imageToBlurHash={imageToBlurHash} renderCompositionTextArea={renderCompositionTextArea}
onDone={({ imageToBlurHash={imageToBlurHash}
contentType, onDone={({
data, contentType,
blurHash, data,
caption, blurHash,
captionBodyRanges, caption,
}) => { captionBodyRanges,
setDraftAttachment({ }) => {
...draftAttachment, setDraftAttachment({
contentType, ...draftAttachment,
data, contentType,
size: data.byteLength, data,
blurHash, size: data.byteLength,
caption, blurHash,
}); caption,
setBodyRanges(captionBodyRanges); });
setIsReadyToSend(true); setBodyRanges(captionBodyRanges);
}} setIsReadyToSend(true);
recentStickers={recentStickers} }}
/> recentStickers={recentStickers}
)} />
{!file && ( )}
<TextStoryCreator {!file && (
debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview} <TextStoryCreator
i18n={i18n} debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview}
isSending={isSending} i18n={i18n}
linkPreview={linkPreview} isSending={isSending}
onClose={onClose} linkPreview={linkPreview}
onDone={textAttachment => { onClose={onClose}
setDraftAttachment({ onDone={textAttachment => {
contentType: TEXT_ATTACHMENT, setDraftAttachment({
textAttachment, contentType: TEXT_ATTACHMENT,
size: textAttachment.text?.length || 0, textAttachment,
}); size: textAttachment.text?.length || 0,
setIsReadyToSend(true); });
}} setIsReadyToSend(true);
onUseEmoji={onUseEmoji} }}
onSetSkinTone={onSetSkinTone} onUseEmoji={onUseEmoji}
recentEmojis={recentEmojis} onSetSkinTone={onSetSkinTone}
skinTone={skinTone} recentEmojis={recentEmojis}
/> skinTone={skinTone}
)} />
</> )}
); </>,
portalElement
)
: null;
} }

View file

@ -5,7 +5,7 @@ import React, { useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { ConversationStoryType, StoryViewType } from '../types/Stories'; import type { ConversationStoryType, StoryViewType } from '../types/Stories';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType, ThemeType } from '../types/Util';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { ViewUserStoriesActionCreatorType } from '../state/ducks/stories'; import type { ViewUserStoriesActionCreatorType } from '../state/ducks/stories';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
@ -16,7 +16,6 @@ import { StoryViewTargetType, HasStories } from '../types/Stories';
import { MessageTimestamp } from './conversation/MessageTimestamp'; import { MessageTimestamp } from './conversation/MessageTimestamp';
import { StoryImage } from './StoryImage'; import { StoryImage } from './StoryImage';
import { ThemeType } from '../types/Util';
import { getAvatarColor } from '../types/Colors'; import { getAvatarColor } from '../types/Colors';
export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & { export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
@ -30,6 +29,7 @@ export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
queueStoryDownload: (storyId: string) => unknown; queueStoryDownload: (storyId: string) => unknown;
onMediaPlaybackStart: () => void; onMediaPlaybackStart: () => void;
story: StoryViewType; story: StoryViewType;
theme: ThemeType;
viewUserStories: ViewUserStoriesActionCreatorType; viewUserStories: ViewUserStoriesActionCreatorType;
}; };
@ -45,6 +45,7 @@ function StoryListItemAvatar({
profileName, profileName,
sharedGroupNames, sharedGroupNames,
title, title,
theme,
}: Pick< }: Pick<
ConversationType, ConversationType,
| 'acceptedMessageRequest' | 'acceptedMessageRequest'
@ -59,6 +60,7 @@ function StoryListItemAvatar({
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType; i18n: LocalizerType;
isMe?: boolean; isMe?: boolean;
theme: ThemeType;
}): JSX.Element { }): JSX.Element {
return ( return (
<Avatar <Avatar
@ -73,7 +75,7 @@ function StoryListItemAvatar({
sharedGroupNames={sharedGroupNames} sharedGroupNames={sharedGroupNames}
size={AvatarSize.FORTY_EIGHT} size={AvatarSize.FORTY_EIGHT}
storyRing={avatarStoryRing} storyRing={avatarStoryRing}
theme={ThemeType.dark} theme={theme}
title={title} title={title}
/> />
); );
@ -92,6 +94,7 @@ export function StoryListItem({
onMediaPlaybackStart, onMediaPlaybackStart,
queueStoryDownload, queueStoryDownload,
story, story,
theme,
viewUserStories, viewUserStories,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false); const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
@ -167,6 +170,7 @@ export function StoryListItem({
avatarStoryRing={avatarStoryRing} avatarStoryRing={avatarStoryRing}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
i18n={i18n} i18n={i18n}
theme={theme}
{...(group || sender)} {...(group || sender)}
/> />
<div className="StoryListItem__info"> <div className="StoryListItem__info">

View file

@ -26,6 +26,8 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.Blocked }; return { toastType: ToastType.Blocked };
case ToastType.BlockedGroup: case ToastType.BlockedGroup:
return { toastType: ToastType.BlockedGroup }; return { toastType: ToastType.BlockedGroup };
case ToastType.CallHistoryCleared:
return { toastType: ToastType.CallHistoryCleared };
case ToastType.CannotEditMessage: case ToastType.CannotEditMessage:
return { toastType: ToastType.CannotEditMessage }; return { toastType: ToastType.CannotEditMessage };
case ToastType.CannotForwardEmptyMessage: case ToastType.CannotForwardEmptyMessage:

View file

@ -68,6 +68,14 @@ export function ToastManager({
return <Toast onClose={hideToast}>{i18n('icu:unblockGroupToSend')}</Toast>; return <Toast onClose={hideToast}>{i18n('icu:unblockGroupToSend')}</Toast>;
} }
if (toastType === ToastType.CallHistoryCleared) {
return (
<Toast onClose={hideToast}>
{i18n('icu:CallsTab__ToastCallHistoryCleared')}
</Toast>
);
}
if (toastType === ToastType.CannotEditMessage) { if (toastType === ToastType.CannotEditMessage) {
return ( return (
<Toast onClose={hideToast}> <Toast onClose={hideToast}>

View file

@ -11,7 +11,6 @@ export enum WidthBreakpoint {
Narrow = 'narrow', Narrow = 'narrow',
} }
export const getConversationListWidthBreakpoint = ( export function getNavSidebarWidthBreakpoint(width: number): WidthBreakpoint {
width: number return width >= 150 ? WidthBreakpoint.Wide : WidthBreakpoint.Narrow;
): WidthBreakpoint => }
width >= 150 ? WidthBreakpoint.Wide : WidthBreakpoint.Narrow;

View file

@ -7,9 +7,21 @@ import { action } from '@storybook/addon-actions';
import { setupI18n } from '../../util/setupI18n'; import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import { CallMode } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
import { CallingNotification } from './CallingNotification'; import { CallingNotification, type PropsType } from './CallingNotification';
import type { CallingNotificationType } from '../../util/callingNotification'; import {
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; getDefaultConversation,
getDefaultGroup,
} from '../../test-both/helpers/getDefaultConversation';
import type { CallStatus } from '../../types/CallDisposition';
import {
CallType,
CallDirection,
GroupCallStatus,
DirectCallStatus,
} from '../../types/CallDisposition';
import { UUID } from '../../types/UUID';
import type { ConversationType } from '../../state/ducks/conversations';
import { CallExternalState } from '../../util/callingNotification';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -17,15 +29,59 @@ export default {
title: 'Components/Conversation/CallingNotification', title: 'Components/Conversation/CallingNotification',
}; };
const getCommonProps = () => ({ const getCommonProps = (options: {
conversationId: 'fake-conversation-id', mode: CallMode;
i18n, type?: CallType;
isNextItemCallingNotification: false, direction?: CallDirection;
messageId: 'fake-message-id', status?: CallStatus;
now: Date.now(), callCreator?: ConversationType | null;
returnToActiveCall: action('returnToActiveCall'), callExternalState?: CallExternalState;
startCallingLobby: action('startCallingLobby'), }): PropsType => {
}); const {
mode,
type = mode === CallMode.Group ? CallType.Group : CallType.Audio,
direction = CallDirection.Outgoing,
status = mode === CallMode.Group
? GroupCallStatus.GenericGroupCall
: DirectCallStatus.Pending,
callCreator = getDefaultConversation({
uuid: UUID.generate().toString(),
isMe: direction === CallDirection.Outgoing,
}),
callExternalState = CallExternalState.Active,
} = options;
const conversation =
mode === CallMode.Group ? getDefaultGroup() : getDefaultConversation();
return {
conversationId: conversation.id,
i18n,
isNextItemCallingNotification: false,
returnToActiveCall: action('returnToActiveCall'),
startCallingLobby: action('startCallingLobby'),
callHistory: {
callId: '123',
peerId: conversation.id,
ringerId: callCreator?.uuid ?? null,
mode,
type,
direction,
timestamp: Date.now(),
status,
},
callCreator,
callExternalState,
maxDevices: mode === CallMode.Group ? 15 : 0,
deviceCount:
// eslint-disable-next-line no-nested-ternary
mode === CallMode.Group
? callExternalState === CallExternalState.Full
? 15
: 13
: Infinity,
};
};
/* /*
<CallingNotification <CallingNotification
@ -42,13 +98,12 @@ const getCommonProps = () => ({
export function AcceptedIncomingAudioCall(): JSX.Element { export function AcceptedIncomingAudioCall(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
acceptedTime={1618894800000} mode: CallMode.Direct,
callMode={CallMode.Direct} type: CallType.Audio,
endedTime={1618894800000} direction: CallDirection.Incoming,
wasDeclined={false} status: DirectCallStatus.Accepted,
wasIncoming })}
wasVideoCall={false}
/> />
); );
} }
@ -56,13 +111,13 @@ export function AcceptedIncomingAudioCall(): JSX.Element {
export function AcceptedIncomingVideoCall(): JSX.Element { export function AcceptedIncomingVideoCall(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
acceptedTime={1618894800000} mode: CallMode.Direct,
callMode={CallMode.Direct} type: CallType.Video,
endedTime={1618894800000} direction: CallDirection.Incoming,
wasDeclined={false} status: DirectCallStatus.Accepted,
wasIncoming callExternalState: CallExternalState.Ended,
wasVideoCall })}
/> />
); );
} }
@ -70,13 +125,12 @@ export function AcceptedIncomingVideoCall(): JSX.Element {
export function DeclinedIncomingAudioCall(): JSX.Element { export function DeclinedIncomingAudioCall(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
acceptedTime={undefined} mode: CallMode.Direct,
callMode={CallMode.Direct} type: CallType.Audio,
endedTime={1618894800000} direction: CallDirection.Incoming,
wasDeclined status: DirectCallStatus.Declined,
wasIncoming })}
wasVideoCall={false}
/> />
); );
} }
@ -84,13 +138,12 @@ export function DeclinedIncomingAudioCall(): JSX.Element {
export function DeclinedIncomingVideoCall(): JSX.Element { export function DeclinedIncomingVideoCall(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
acceptedTime={undefined} mode: CallMode.Direct,
callMode={CallMode.Direct} type: CallType.Video,
endedTime={1618894800000} direction: CallDirection.Incoming,
wasDeclined status: DirectCallStatus.Declined,
wasIncoming })}
wasVideoCall
/> />
); );
} }
@ -98,13 +151,12 @@ export function DeclinedIncomingVideoCall(): JSX.Element {
export function AcceptedOutgoingAudioCall(): JSX.Element { export function AcceptedOutgoingAudioCall(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
acceptedTime={1618894800000} mode: CallMode.Direct,
callMode={CallMode.Direct} type: CallType.Audio,
endedTime={1618894800000} direction: CallDirection.Outgoing,
wasDeclined={false} status: DirectCallStatus.Accepted,
wasIncoming={false} })}
wasVideoCall={false}
/> />
); );
} }
@ -112,13 +164,12 @@ export function AcceptedOutgoingAudioCall(): JSX.Element {
export function AcceptedOutgoingVideoCall(): JSX.Element { export function AcceptedOutgoingVideoCall(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
acceptedTime={1618894800000} mode: CallMode.Direct,
callMode={CallMode.Direct} type: CallType.Video,
endedTime={1618894800000} direction: CallDirection.Outgoing,
wasDeclined={false} status: DirectCallStatus.Accepted,
wasIncoming={false} })}
wasVideoCall
/> />
); );
} }
@ -126,13 +177,12 @@ export function AcceptedOutgoingVideoCall(): JSX.Element {
export function DeclinedOutgoingAudioCall(): JSX.Element { export function DeclinedOutgoingAudioCall(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
acceptedTime={undefined} mode: CallMode.Direct,
callMode={CallMode.Direct} type: CallType.Audio,
endedTime={1618894800000} direction: CallDirection.Outgoing,
wasDeclined status: DirectCallStatus.Declined,
wasIncoming={false} })}
wasVideoCall={false}
/> />
); );
} }
@ -140,42 +190,37 @@ export function DeclinedOutgoingAudioCall(): JSX.Element {
export function DeclinedOutgoingVideoCall(): JSX.Element { export function DeclinedOutgoingVideoCall(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
acceptedTime={undefined} mode: CallMode.Direct,
callMode={CallMode.Direct} type: CallType.Video,
endedTime={1618894800000} direction: CallDirection.Outgoing,
wasDeclined status: DirectCallStatus.Declined,
wasIncoming={false} })}
wasVideoCall
/> />
); );
} }
export function TwoIncomingDirectCallsBackToBack(): JSX.Element { export function TwoIncomingDirectCallsBackToBack(): JSX.Element {
const call1: CallingNotificationType = {
callMode: CallMode.Direct,
wasIncoming: true,
wasVideoCall: true,
wasDeclined: false,
acceptedTime: 1618894800000,
endedTime: 1618894800000,
};
const call2: CallingNotificationType = {
callMode: CallMode.Direct,
wasIncoming: true,
wasVideoCall: false,
wasDeclined: false,
endedTime: 1618894800000,
};
return ( return (
<> <>
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
{...call1} mode: CallMode.Direct,
type: CallType.Video,
direction: CallDirection.Incoming,
status: DirectCallStatus.Declined,
callExternalState: CallExternalState.Ended,
})}
isNextItemCallingNotification isNextItemCallingNotification
/> />
<CallingNotification {...getCommonProps()} {...call2} /> <CallingNotification
{...getCommonProps({
mode: CallMode.Direct,
type: CallType.Audio,
direction: CallDirection.Incoming,
status: DirectCallStatus.Declined,
})}
/>
</> </>
); );
} }
@ -185,30 +230,26 @@ TwoIncomingDirectCallsBackToBack.story = {
}; };
export function TwoOutgoingDirectCallsBackToBack(): JSX.Element { export function TwoOutgoingDirectCallsBackToBack(): JSX.Element {
const call1: CallingNotificationType = {
callMode: CallMode.Direct,
wasIncoming: false,
wasVideoCall: true,
wasDeclined: false,
acceptedTime: 1618894800000,
endedTime: 1618894800000,
};
const call2: CallingNotificationType = {
callMode: CallMode.Direct,
wasIncoming: false,
wasVideoCall: false,
wasDeclined: false,
endedTime: 1618894800000,
};
return ( return (
<> <>
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
{...call1} mode: CallMode.Direct,
type: CallType.Video,
direction: CallDirection.Outgoing,
status: DirectCallStatus.Declined,
callExternalState: CallExternalState.Ended,
})}
isNextItemCallingNotification isNextItemCallingNotification
/> />
<CallingNotification {...getCommonProps()} {...call2} /> <CallingNotification
{...getCommonProps({
mode: CallMode.Direct,
type: CallType.Audio,
direction: CallDirection.Outgoing,
status: DirectCallStatus.Declined,
})}
/>
</> </>
); );
} }
@ -220,13 +261,13 @@ TwoOutgoingDirectCallsBackToBack.story = {
export function GroupCallByUnknown(): JSX.Element { export function GroupCallByUnknown(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
callMode={CallMode.Group} mode: CallMode.Group,
creator={undefined} type: CallType.Group,
deviceCount={15} direction: CallDirection.Incoming,
ended={false} status: GroupCallStatus.Accepted,
maxDevices={16} callCreator: null,
startedTime={1618894800000} })}
/> />
); );
} }
@ -234,13 +275,12 @@ export function GroupCallByUnknown(): JSX.Element {
export function GroupCallByYou(): JSX.Element { export function GroupCallByYou(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
callMode={CallMode.Group} mode: CallMode.Group,
creator={getDefaultConversation({ isMe: true, title: 'Alicia' })} type: CallType.Group,
deviceCount={15} direction: CallDirection.Outgoing,
ended={false} status: GroupCallStatus.Accepted,
maxDevices={16} })}
startedTime={1618894800000}
/> />
); );
} }
@ -248,31 +288,28 @@ export function GroupCallByYou(): JSX.Element {
export function GroupCallBySomeone(): JSX.Element { export function GroupCallBySomeone(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
callMode={CallMode.Group} mode: CallMode.Group,
creator={getDefaultConversation({ isMe: false, title: 'Alicia' })} type: CallType.Group,
deviceCount={15} direction: CallDirection.Incoming,
ended={false} status: GroupCallStatus.GenericGroupCall,
maxDevices={16} })}
startedTime={1618894800000}
/> />
); );
} }
export function GroupCallStartedBySomeoneWithALongName(): JSX.Element { export function GroupCallStartedBySomeoneWithALongName(): JSX.Element {
const longName = '😤🪐🦆'.repeat(50);
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
callMode={CallMode.Group} mode: CallMode.Group,
creator={getDefaultConversation({ type: CallType.Group,
title: longName, direction: CallDirection.Incoming,
status: GroupCallStatus.GenericGroupCall,
callCreator: getDefaultConversation({
name: '😤🪐🦆'.repeat(50),
}),
})} })}
deviceCount={15}
ended={false}
maxDevices={16}
startedTime={1618894800000}
/> />
); );
} }
@ -284,12 +321,13 @@ GroupCallStartedBySomeoneWithALongName.story = {
export function GroupCallActiveCallFull(): JSX.Element { export function GroupCallActiveCallFull(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
callMode={CallMode.Group} mode: CallMode.Group,
deviceCount={16} type: CallType.Group,
ended={false} direction: CallDirection.Incoming,
maxDevices={16} status: GroupCallStatus.GenericGroupCall,
startedTime={1618894800000} callExternalState: CallExternalState.Full,
})}
/> />
); );
} }
@ -301,12 +339,13 @@ GroupCallActiveCallFull.story = {
export function GroupCallEnded(): JSX.Element { export function GroupCallEnded(): JSX.Element {
return ( return (
<CallingNotification <CallingNotification
{...getCommonProps()} {...getCommonProps({
callMode={CallMode.Group} mode: CallMode.Group,
deviceCount={0} type: CallType.Group,
ended direction: CallDirection.Incoming,
maxDevices={16} status: GroupCallStatus.GenericGroupCall,
startedTime={1618894800000} callExternalState: CallExternalState.Ended,
})}
/> />
); );
} }

View file

@ -12,13 +12,19 @@ import type { LocalizerType } from '../../types/Util';
import { CallMode } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
import type { CallingNotificationType } from '../../util/callingNotification'; import type { CallingNotificationType } from '../../util/callingNotification';
import { import {
CallExternalState,
getCallingIcon, getCallingIcon,
getCallingNotificationText, getCallingNotificationText,
} from '../../util/callingNotification'; } from '../../util/callingNotification';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { Tooltip, TooltipPlacement } from '../Tooltip'; import { Tooltip, TooltipPlacement } from '../Tooltip';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { assertDev } from '../../util/assert'; import {
CallDirection,
CallType,
DirectCallStatus,
GroupCallStatus,
} from '../../types/CallDisposition';
export type PropsActionsType = { export type PropsActionsType = {
returnToActiveCall: () => void; returnToActiveCall: () => void;
@ -34,35 +40,15 @@ type PropsHousekeeping = {
isNextItemCallingNotification: boolean; isNextItemCallingNotification: boolean;
}; };
type PropsType = CallingNotificationType & PropsActionsType & PropsHousekeeping; export type PropsType = CallingNotificationType &
PropsActionsType &
PropsHousekeeping;
export const CallingNotification: React.FC<PropsType> = React.memo( export const CallingNotification: React.FC<PropsType> = React.memo(
function CallingNotificationInner(props) { function CallingNotificationInner(props) {
const { i18n } = props; const { i18n } = props;
const { type, direction, status, timestamp } = props.callHistory;
let timestamp: number; const icon = getCallingIcon(type, direction, status);
let wasMissed = false;
switch (props.callMode) {
case CallMode.Direct: {
const resolvedTime = props.acceptedTime ?? props.endedTime;
assertDev(resolvedTime, 'Direct call must have accepted or ended time');
timestamp = resolvedTime;
wasMissed =
props.wasIncoming && !props.acceptedTime && !props.wasDeclined;
break;
}
case CallMode.Group:
timestamp = props.startedTime;
break;
default:
log.error(
`CallingNotification missing case: ${missingCaseError(props)}`
);
return null;
}
const icon = getCallingIcon(props);
return ( return (
<SystemMessage <SystemMessage
button={renderCallingNotificationButton(props)} button={renderCallingNotificationButton(props)}
@ -80,7 +66,12 @@ export const CallingNotification: React.FC<PropsType> = React.memo(
</> </>
} }
icon={icon} icon={icon}
kind={wasMissed ? SystemMessageKind.Danger : SystemMessageKind.Normal} kind={
status === DirectCallStatus.Missed ||
status === GroupCallStatus.Missed
? SystemMessageKind.Danger
: SystemMessageKind.Normal
}
/> />
); );
} }
@ -90,7 +81,6 @@ function renderCallingNotificationButton(
props: Readonly<PropsType> props: Readonly<PropsType>
): ReactNode { ): ReactNode {
const { const {
activeCallConversationId,
conversationId, conversationId,
i18n, i18n,
isNextItemCallingNotification, isNextItemCallingNotification,
@ -106,55 +96,65 @@ function renderCallingNotificationButton(
let disabledTooltipText: undefined | string; let disabledTooltipText: undefined | string;
let onClick: () => void; let onClick: () => void;
switch (props.callMode) { switch (props.callHistory.mode) {
case CallMode.Direct: { case CallMode.Direct: {
const { wasIncoming, wasVideoCall } = props; const { direction, type } = props.callHistory;
buttonText = wasIncoming buttonText =
? i18n('icu:calling__call-back') direction === CallDirection.Incoming
: i18n('icu:calling__call-again'); ? i18n('icu:calling__call-back')
if (activeCallConversationId) { : i18n('icu:calling__call-again');
if (
props.callExternalState === CallExternalState.Joined ||
props.callExternalState === CallExternalState.InOtherCall
) {
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip'); disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
onClick = noop; onClick = noop;
} else { } else {
onClick = () => { onClick = () => {
startCallingLobby({ conversationId, isVideoCall: wasVideoCall }); startCallingLobby({
conversationId,
isVideoCall: type === CallType.Video,
});
}; };
} }
break; break;
} }
case CallMode.Group: { case CallMode.Group: {
if (props.ended) { if (props.callExternalState === CallExternalState.Ended) {
return null; return null;
} }
const { deviceCount, maxDevices } = props; if (props.callExternalState === CallExternalState.Joined) {
if (activeCallConversationId) { buttonText = i18n('icu:calling__return');
if (activeCallConversationId === conversationId) { onClick = returnToActiveCall;
buttonText = i18n('icu:calling__return'); } else if (props.callExternalState === CallExternalState.InOtherCall) {
onClick = returnToActiveCall; buttonText = i18n('icu:calling__join');
} else { disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
buttonText = i18n('icu:calling__join'); onClick = noop;
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip'); } else if (props.callExternalState === CallExternalState.Full) {
onClick = noop;
}
} else if (deviceCount >= maxDevices) {
buttonText = i18n('icu:calling__call-is-full'); buttonText = i18n('icu:calling__call-is-full');
disabledTooltipText = i18n( disabledTooltipText = i18n(
'icu:calling__call-notification__button__call-full-tooltip', 'icu:calling__call-notification__button__call-full-tooltip',
{ {
max: deviceCount, max: props.maxDevices,
} }
); );
onClick = noop; onClick = noop;
} else { } else if (props.callExternalState === CallExternalState.Active) {
buttonText = i18n('icu:calling__join'); buttonText = i18n('icu:calling__join');
onClick = () => { onClick = () => {
startCallingLobby({ conversationId, isVideoCall: true }); startCallingLobby({ conversationId, isVideoCall: true });
}; };
} else {
throw missingCaseError(props.callExternalState);
} }
break; break;
} }
case CallMode.None: {
log.error('renderCallingNotificationButton: Call mode cant be none');
return null;
}
default: default:
log.error(missingCaseError(props)); log.error(missingCaseError(props.callHistory.mode));
return null; return null;
} }

View file

@ -17,6 +17,13 @@ import { getDefaultConversation } from '../../../test-both/helpers/getDefaultCon
import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid'; import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid';
import { ThemeType } from '../../../types/Util'; import { ThemeType } from '../../../types/Util';
import { DurationInSeconds } from '../../../util/durations'; import { DurationInSeconds } from '../../../util/durations';
import { NavTab } from '../../../state/ducks/nav';
import { CallMode } from '../../../types/Calling';
import {
CallDirection,
CallType,
DirectCallStatus,
} from '../../../types/CallDisposition';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -79,6 +86,7 @@ const createProps = (
metadata: {}, metadata: {},
member: getDefaultConversation(), member: getDefaultConversation(),
})), })),
selectedNavTab: NavTab.Chats,
setDisappearingMessages: action('setDisappearingMessages'), setDisappearingMessages: action('setDisappearingMessages'),
showContactModal: action('showContactModal'), showContactModal: action('showContactModal'),
pushPanelForConversation: action('pushPanelForConversation'), pushPanelForConversation: action('pushPanelForConversation'),
@ -214,3 +222,32 @@ export const _11 = (): JSX.Element => (
_11.story = { _11.story = {
name: '1:1', name: '1:1',
}; };
function mins(n: number) {
return DurationInSeconds.toMillis(DurationInSeconds.fromMinutes(n));
}
export function WithCallHistoryGroup(): JSX.Element {
const props = createProps();
return (
<ConversationDetails
{...props}
callHistoryGroup={{
peerId: props.conversation?.uuid ?? '',
mode: CallMode.Direct,
type: CallType.Video,
direction: CallDirection.Incoming,
status: DirectCallStatus.Accepted,
timestamp: Date.now(),
children: [
{ callId: '123', timestamp: Date.now() },
{ callId: '122', timestamp: Date.now() - mins(30) },
{ callId: '121', timestamp: Date.now() - mins(45) },
{ callId: '121', timestamp: Date.now() - mins(60) },
],
}}
selectedNavTab={NavTab.Calls}
/>
);
}

View file

@ -4,6 +4,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import classNames from 'classnames';
import { Button, ButtonIconType, ButtonVariant } from '../../Button'; import { Button, ButtonIconType, ButtonVariant } from '../../Button';
import { Tooltip } from '../../Tooltip'; import { Tooltip } from '../../Tooltip';
import type { import type {
@ -52,6 +53,37 @@ import type {
import { isConversationMuted } from '../../../util/isConversationMuted'; import { isConversationMuted } from '../../../util/isConversationMuted';
import { ConversationDetailsGroups } from './ConversationDetailsGroups'; import { ConversationDetailsGroups } from './ConversationDetailsGroups';
import { PanelType } from '../../../types/Panels'; import { PanelType } from '../../../types/Panels';
import type { CallStatus } from '../../../types/CallDisposition';
import {
CallType,
type CallHistoryGroup,
CallDirection,
DirectCallStatus,
GroupCallStatus,
} from '../../../types/CallDisposition';
import { formatDate, formatTime } from '../../../util/timestamp';
import { NavTab } from '../../../state/ducks/nav';
function describeCallHistory(
i18n: LocalizerType,
type: CallType,
direction: CallDirection,
status: CallStatus
): string {
if (status === DirectCallStatus.Missed || status === GroupCallStatus.Missed) {
if (direction === CallDirection.Incoming) {
return i18n('icu:CallHistory__Description--Missed', { type });
}
return i18n('icu:CallHistory__Description--Unanswered', { type });
}
if (
status === DirectCallStatus.Declined ||
status === GroupCallStatus.Declined
) {
return i18n('icu:CallHistory__Description--Declined', { type });
}
return i18n('icu:CallHistory__Description--Default', { type, direction });
}
enum ModalState { enum ModalState {
NothingOpen, NothingOpen,
@ -65,6 +97,7 @@ enum ModalState {
export type StateProps = { export type StateProps = {
areWeASubscriber: boolean; areWeASubscriber: boolean;
badges?: ReadonlyArray<BadgeType>; badges?: ReadonlyArray<BadgeType>;
callHistoryGroup?: CallHistoryGroup | null;
canEditGroupInfo: boolean; canEditGroupInfo: boolean;
canAddNewMembers: boolean; canAddNewMembers: boolean;
conversation?: ConversationType; conversation?: ConversationType;
@ -80,6 +113,7 @@ export type StateProps = {
memberships: ReadonlyArray<GroupV2Membership>; memberships: ReadonlyArray<GroupV2Membership>;
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>; pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>; pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
selectedNavTab: NavTab;
theme: ThemeType; theme: ThemeType;
userAvatarData: ReadonlyArray<AvatarDataType>; userAvatarData: ReadonlyArray<AvatarDataType>;
renderChooseGroupMembersModal: ( renderChooseGroupMembersModal: (
@ -101,6 +135,7 @@ type ActionProps = {
} }
) => unknown; ) => unknown;
blockConversation: (id: string) => void; blockConversation: (id: string) => void;
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
getProfilesForConversation: (id: string) => unknown; getProfilesForConversation: (id: string) => unknown;
leaveGroup: (conversationId: string) => void; leaveGroup: (conversationId: string) => void;
@ -153,6 +188,7 @@ export function ConversationDetails({
areWeASubscriber, areWeASubscriber,
badges, badges,
blockConversation, blockConversation,
callHistoryGroup,
canEditGroupInfo, canEditGroupInfo,
canAddNewMembers, canAddNewMembers,
conversation, conversation,
@ -180,6 +216,7 @@ export function ConversationDetails({
replaceAvatar, replaceAvatar,
saveAvatarToDisk, saveAvatarToDisk,
searchInConversation, searchInConversation,
selectedNavTab,
setDisappearingMessages, setDisappearingMessages,
setMuteExpiration, setMuteExpiration,
showContactModal, showContactModal,
@ -364,6 +401,20 @@ export function ConversationDetails({
/> />
<div className="ConversationDetails__header-buttons"> <div className="ConversationDetails__header-buttons">
{selectedNavTab === NavTab.Calls && (
<Button
icon={ButtonIconType.message}
onClick={() => {
showConversation({
conversationId: conversation?.id,
switchToAssociatedView: true,
});
}}
variant={ButtonVariant.Details}
>
{i18n('icu:ConversationDetails__HeaderButton--Message')}
</Button>
)}
{!conversation.isMe && ( {!conversation.isMe && (
<> <>
<ConversationDetailsCallButton <ConversationDetailsCallButton
@ -397,17 +448,60 @@ export function ConversationDetails({
> >
{isMuted ? i18n('icu:unmute') : i18n('icu:mute')} {isMuted ? i18n('icu:unmute') : i18n('icu:mute')}
</Button> </Button>
<Button {selectedNavTab !== NavTab.Calls && (
icon={ButtonIconType.search} <Button
onClick={() => { icon={ButtonIconType.search}
searchInConversation(conversation.id); onClick={() => {
}} searchInConversation(conversation.id);
variant={ButtonVariant.Details} }}
> variant={ButtonVariant.Details}
{i18n('icu:search')} >
</Button> {i18n('icu:search')}
</Button>
)}
</div> </div>
{callHistoryGroup && (
<PanelSection>
<h2 className="ConversationDetails__CallHistoryGroup__header">
{formatDate(i18n, callHistoryGroup.timestamp)}
</h2>
<ol className="ConversationDetails__CallHistoryGroup__List">
{callHistoryGroup.children.map(child => {
return (
<li
key={child.callId}
className="ConversationDetails__CallHistoryGroup__Item"
>
<span
className={classNames(
'ConversationDetails__CallHistoryGroup__ItemIcon',
{
'ConversationDetails__CallHistoryGroup__ItemIcon--Audio':
callHistoryGroup.type === CallType.Audio,
'ConversationDetails__CallHistoryGroup__ItemIcon--Video':
callHistoryGroup.type !== CallType.Audio,
}
)}
/>
<span className="ConversationDetails__CallHistoryGroup__ItemLabel">
{describeCallHistory(
i18n,
callHistoryGroup.type,
callHistoryGroup.direction,
callHistoryGroup.status
)}
</span>
<span className="ConversationDetails__CallHistoryGroup__ItemTimestamp">
{formatTime(i18n, child.timestamp, Date.now(), false)}
</span>
</li>
);
})}
</ol>
</PanelSection>
)}
<PanelSection> <PanelSection>
{!isGroup || canEditGroupInfo ? ( {!isGroup || canEditGroupInfo ? (
<PanelRow <PanelRow
@ -440,28 +534,30 @@ export function ConversationDetails({
} }
/> />
) : null} ) : null}
<PanelRow {selectedNavTab === NavTab.Chats && (
icon={ <PanelRow
<ConversationDetailsIcon icon={
ariaLabel={i18n('icu:showChatColorEditor')} <ConversationDetailsIcon
icon={IconType.color} ariaLabel={i18n('icu:showChatColorEditor')}
/> icon={IconType.color}
} />
label={i18n('icu:showChatColorEditor')} }
onClick={() => { label={i18n('icu:showChatColorEditor')}
pushPanelForConversation({ onClick={() => {
type: PanelType.ChatColorEditor, pushPanelForConversation({
}); type: PanelType.ChatColorEditor,
}} });
right={ }}
<div right={
className={`ConversationDetails__chat-color ConversationDetails__chat-color--${conversation.conversationColor}`} <div
style={{ className={`ConversationDetails__chat-color ConversationDetails__chat-color--${conversation.conversationColor}`}
...getCustomColorStyle(conversation.customColor), style={{
}} ...getCustomColorStyle(conversation.customColor),
/> }}
} />
/> }
/>
)}
{isGroup && ( {isGroup && (
<PanelRow <PanelRow
icon={ icon={

View file

@ -10,7 +10,6 @@ import * as KeyboardLayout from '../services/keyboardLayout';
import { getHasPanelOpen } from '../state/selectors/conversations'; import { getHasPanelOpen } from '../state/selectors/conversations';
import { isInFullScreenCall } from '../state/selectors/calling'; import { isInFullScreenCall } from '../state/selectors/calling';
import { isShowingAnyModal } from '../state/selectors/globalModals'; import { isShowingAnyModal } from '../state/selectors/globalModals';
import { shouldShowStoriesView } from '../state/selectors/stories';
type KeyboardShortcutHandlerType = (ev: KeyboardEvent) => boolean; type KeyboardShortcutHandlerType = (ev: KeyboardEvent) => boolean;
@ -36,10 +35,6 @@ function useHasGlobalModal(): boolean {
return useSelector<StateType, boolean>(isShowingAnyModal); return useSelector<StateType, boolean>(isShowingAnyModal);
} }
function useHasStories(): boolean {
return useSelector<StateType, boolean>(shouldShowStoriesView);
}
function useHasCalling(): boolean { function useHasCalling(): boolean {
return useSelector<StateType, boolean>(isInFullScreenCall); return useSelector<StateType, boolean>(isInFullScreenCall);
} }
@ -47,10 +42,9 @@ function useHasCalling(): boolean {
function useHasAnyOverlay(): boolean { function useHasAnyOverlay(): boolean {
const panels = useHasPanels(); const panels = useHasPanels();
const globalModal = useHasGlobalModal(); const globalModal = useHasGlobalModal();
const stories = useHasStories();
const calling = useHasCalling(); const calling = useHasCalling();
return panels || globalModal || stories || calling; return panels || globalModal || calling;
} }
export function useActiveCallShortcuts( export function useActiveCallShortcuts(

3
ts/model-types.d.ts vendored
View file

@ -7,7 +7,6 @@ import * as Backbone from 'backbone';
import type { GroupV2ChangeType } from './groups'; import type { GroupV2ChangeType } from './groups';
import type { DraftBodyRanges, RawBodyRange } from './types/BodyRange'; import type { DraftBodyRanges, RawBodyRange } from './types/BodyRange';
import type { CallHistoryDetailsFromDiskType } from './types/Calling';
import type { CustomColorType, ConversationColorType } from './types/Colors'; import type { CustomColorType, ConversationColorType } from './types/Colors';
import type { DeviceType } from './textsecure/Types.d'; import type { DeviceType } from './textsecure/Types.d';
import type { SendMessageChallengeData } from './textsecure/Errors'; import type { SendMessageChallengeData } from './textsecure/Errors';
@ -132,7 +131,7 @@ export type EditHistoryType = {
export type MessageAttributesType = { export type MessageAttributesType = {
bodyAttachment?: AttachmentType; bodyAttachment?: AttachmentType;
bodyRanges?: ReadonlyArray<RawBodyRange>; bodyRanges?: ReadonlyArray<RawBodyRange>;
callHistoryDetails?: CallHistoryDetailsFromDiskType; callId?: string;
canReplyToStory?: boolean; canReplyToStory?: boolean;
changedId?: string; changedId?: string;
dataMessage?: Uint8Array | null; dataMessage?: Uint8Array | null;

View file

@ -30,8 +30,6 @@ import { getAboutText } from '../util/getAboutText';
import { getAvatarPath } from '../util/avatarUtils'; import { getAvatarPath } from '../util/avatarUtils';
import { getDraftPreview } from '../util/getDraftPreview'; import { getDraftPreview } from '../util/getDraftPreview';
import { hasDraft } from '../util/hasDraft'; import { hasDraft } from '../util/hasDraft';
import type { CallHistoryDetailsType } from '../types/Calling';
import { CallMode } from '../types/Calling';
import * as Conversation from '../types/Conversation'; import * as Conversation from '../types/Conversation';
import type { StickerType, StickerWithHydratedData } from '../types/Stickers'; import type { StickerType, StickerWithHydratedData } from '../types/Stickers';
import * as Stickers from '../types/Stickers'; import * as Stickers from '../types/Stickers';
@ -55,7 +53,7 @@ import type {
} from '../types/Colors'; } from '../types/Colors';
import type { MessageModel } from './messages'; import type { MessageModel } from './messages';
import { getContact } from '../messages/helpers'; import { getContact } from '../messages/helpers';
import { assertDev, strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { isConversationMuted } from '../util/isConversationMuted'; import { isConversationMuted } from '../util/isConversationMuted';
import { isConversationSMSOnly } from '../util/isConversationSMSOnly'; import { isConversationSMSOnly } from '../util/isConversationSMSOnly';
import { import {
@ -63,10 +61,8 @@ import {
isConversationUnregistered, isConversationUnregistered,
isConversationUnregisteredAndStale, isConversationUnregisteredAndStale,
} from '../util/isConversationUnregistered'; } from '../util/isConversationUnregistered';
import { missingCaseError } from '../util/missingCaseError';
import { sniffImageMimeType } from '../util/sniffImageMimeType'; import { sniffImageMimeType } from '../util/sniffImageMimeType';
import { isValidE164 } from '../util/isValidE164'; import { isValidE164 } from '../util/isValidE164';
import { canConversationBeUnarchived } from '../util/canConversationBeUnarchived';
import type { MIMEType } from '../types/MIME'; import type { MIMEType } from '../types/MIME';
import { IMAGE_JPEG, IMAGE_WEBP } from '../types/MIME'; import { IMAGE_JPEG, IMAGE_WEBP } from '../types/MIME';
import { UUID, UUIDKind } from '../types/UUID'; import { UUID, UUIDKind } from '../types/UUID';
@ -160,7 +156,6 @@ import { ReceiptType } from '../types/Receipt';
import { getQuoteAttachment } from '../util/makeQuote'; import { getQuoteAttachment } from '../util/makeQuote';
import { deriveProfileKeyVersion } from '../util/zkgroup'; import { deriveProfileKeyVersion } from '../util/zkgroup';
import { incrementMessageCounter } from '../util/incrementMessageCounter'; import { incrementMessageCounter } from '../util/incrementMessageCounter';
import { validateTransition } from '../util/callHistoryDetails';
import OS from '../util/os/osMain'; import OS from '../util/os/osMain';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
@ -256,8 +251,6 @@ export class ConversationModel extends window.Backbone
throttledUpdateSharedGroups?: () => Promise<void>; throttledUpdateSharedGroups?: () => Promise<void>;
private cachedLatestGroupCallEraId?: string;
private cachedIdenticon?: CachedIdenticon; private cachedIdenticon?: CachedIdenticon;
public isFetchingUUID?: boolean; public isFetchingUUID?: boolean;
@ -3069,181 +3062,6 @@ export class ConversationModel extends window.Backbone
} }
} }
async addCallHistory(
callHistoryDetails: CallHistoryDetailsType,
receivedAtCounter: number | undefined
): Promise<void> {
let timestamp: number;
let unread: boolean;
let detailsToSave: CallHistoryDetailsType;
switch (callHistoryDetails.callMode) {
case CallMode.Direct: {
const {
callId,
wasIncoming,
wasVideoCall,
wasDeclined,
acceptedTime,
endedTime,
} = callHistoryDetails;
log.info(
`addCallHistory: Conversation ID: ${this.id}, ` +
`Call ID: ${callId}, ` +
'Direct, ' +
`Incoming: ${wasIncoming}, ` +
`Video: ${wasVideoCall}, ` +
`Declined: ${wasDeclined}, ` +
`Accepted: ${acceptedTime}, ` +
`Ended: ${endedTime}`
);
const resolvedTime = acceptedTime ?? endedTime;
assertDev(resolvedTime, 'Direct call must have accepted or ended time');
timestamp = resolvedTime;
unread =
callHistoryDetails.wasIncoming &&
!callHistoryDetails.wasDeclined &&
!callHistoryDetails.acceptedTime;
detailsToSave = {
...callHistoryDetails,
callMode: CallMode.Direct,
};
break;
}
case CallMode.Group:
timestamp = callHistoryDetails.startedTime;
unread = false;
detailsToSave = callHistoryDetails;
break;
default:
throw missingCaseError(callHistoryDetails);
}
// This is sometimes called inside of another conversation queue job so if
// awaited it would block on this forever.
drop(
this.queueJob('addCallHistory', async () => {
// Force save if we're adding a new call history message for a direct call
let forceSave = true;
let previousMessage: MessageAttributesType | null = null;
if (callHistoryDetails.callMode === CallMode.Direct) {
const messageId =
await window.Signal.Data.getCallHistoryMessageByCallId(
this.id,
callHistoryDetails.callId
);
if (messageId != null) {
log.info(
`addCallHistory: Found existing call history message (Call ID: ${callHistoryDetails.callId}, Message ID: ${messageId})`
);
// We don't want to force save if we're updating an existing message
forceSave = false;
previousMessage =
(await window.Signal.Data.getMessageById(messageId)) ?? null;
} else {
log.info(
`addCallHistory: No existing call history message found (Call ID: ${callHistoryDetails.callId})`
);
}
}
if (
!validateTransition(
previousMessage?.callHistoryDetails,
callHistoryDetails,
log
)
) {
log.info("addCallHistory: Transition isn't valid, not saving");
return;
}
const message: MessageAttributesType = {
id: previousMessage?.id ?? generateGuid(),
conversationId: this.id,
type: 'call-history',
sent_at: timestamp,
timestamp,
received_at: receivedAtCounter || incrementMessageCounter(),
received_at_ms: timestamp,
readStatus: unread ? ReadStatus.Unread : ReadStatus.Read,
seenStatus: unread ? SeenStatus.Unseen : SeenStatus.NotApplicable,
callHistoryDetails,
};
const id = await window.Signal.Data.saveMessage(message, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
forceSave,
});
log.info(`addCallHistory: Saved call history message (ID: ${id})`);
const model = window.MessageController.register(
id,
new window.Whisper.Message({
...message,
id,
})
);
if (
detailsToSave.callMode === CallMode.Direct &&
!detailsToSave.wasIncoming
) {
this.incrementSentMessageCount();
} else {
this.incrementMessageCount();
}
this.trigger('newmessage', model);
void this.updateUnread();
this.set('active_at', timestamp);
if (canConversationBeUnarchived(this.attributes)) {
this.setArchived(false);
} else {
window.Signal.Data.updateConversation(this.attributes);
}
})
);
}
/**
* Adds a group call history message if one is needed. It won't add history messages for
* the same group call era ID.
*
* Resolves with `true` if a new message was added, and `false` otherwise.
*/
async updateCallHistoryForGroupCall(
eraId: string,
creatorUuid: string
): Promise<boolean> {
// We want to update the cache quickly in case this function is called multiple times.
const oldCachedEraId = this.cachedLatestGroupCallEraId;
this.cachedLatestGroupCallEraId = eraId;
const alreadyHasMessage =
(oldCachedEraId && oldCachedEraId === eraId) ||
(await window.Signal.Data.hasGroupCallHistoryMessage(this.id, eraId));
if (alreadyHasMessage) {
void this.updateLastMessage();
return false;
}
await this.addCallHistory(
{
callMode: CallMode.Group,
creatorUuid,
eraId,
startedTime: Date.now(),
},
undefined
);
return true;
}
async addProfileChange( async addProfileChange(
profileChange: unknown, profileChange: unknown,
conversationId?: string conversationId?: string

View file

@ -147,7 +147,6 @@ import { getStoryDataFromMessageAttributes } from '../services/storyLoader';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue'; import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
import { getMessageById } from '../messages/getMessageById'; import { getMessageById } from '../messages/getMessageById';
import { shouldDownloadStory } from '../util/shouldDownloadStory'; import { shouldDownloadStory } from '../util/shouldDownloadStory';
import { shouldShowStoriesView } from '../state/selectors/stories';
import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact'; import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact';
import { SeenStatus } from '../MessageSeenStatus'; import { SeenStatus } from '../MessageSeenStatus';
import { isNewReactionReplacingPrevious } from '../reactions/util'; import { isNewReactionReplacingPrevious } from '../reactions/util';
@ -172,6 +171,8 @@ import {
saveNewMessageBatcher, saveNewMessageBatcher,
} from '../util/messageBatcher'; } from '../util/messageBatcher';
import { normalizeUuid } from '../util/normalizeUuid'; import { normalizeUuid } from '../util/normalizeUuid';
import { getCallHistorySelector } from '../state/selectors/callHistory';
import { getConversationSelector } from '../state/selectors/conversations';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
@ -715,9 +716,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if (isCallHistory(attributes)) { if (isCallHistory(attributes)) {
const state = window.reduxStore.getState(); const state = window.reduxStore.getState();
const callingNotification = getPropsForCallHistory(attributes, { const callingNotification = getPropsForCallHistory(attributes, {
conversationSelector: findAndFormatContact,
callSelector: getCallSelector(state), callSelector: getCallSelector(state),
activeCall: getActiveCall(state), activeCall: getActiveCall(state),
callHistorySelector: getCallHistorySelector(state),
conversationSelector: getConversationSelector(state),
}); });
if (callingNotification) { if (callingNotification) {
return { return {
@ -2837,11 +2839,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
let queueStoryForDownload = false; let queueStoryForDownload = false;
if (isStory(message.attributes)) { if (isStory(message.attributes)) {
const isShowingStories = shouldShowStoriesView(reduxState); queueStoryForDownload = await shouldDownloadStory(
conversation.attributes
queueStoryForDownload = );
isShowingStories ||
(await shouldDownloadStory(conversation.attributes));
} }
const shouldHoldOffDownload = const shouldHoldOffDownload =

View file

@ -0,0 +1,17 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import dataInterface from '../sql/Client';
import type { CallHistoryDetails } from '../types/CallDisposition';
import { strictAssert } from '../util/assert';
let callsHistoryData: ReadonlyArray<CallHistoryDetails>;
export async function loadCallsHistory(): Promise<void> {
callsHistoryData = await dataInterface.getAllCallHistory();
}
export function getCallsHistoryForRedux(): ReadonlyArray<CallHistoryDetails> {
strictAssert(callsHistoryData != null, 'callHistory has not been loaded');
return callsHistoryData;
}

View file

@ -17,7 +17,6 @@ import {
AnswerMessage, AnswerMessage,
BusyMessage, BusyMessage,
Call, Call,
CallEndedReason,
CallingMessage, CallingMessage,
CallLogLevel, CallLogLevel,
CallState, CallState,
@ -39,8 +38,8 @@ import {
RingUpdate, RingUpdate,
} from '@signalapp/ringrtc'; } from '@signalapp/ringrtc';
import { uniqBy, noop } from 'lodash'; import { uniqBy, noop } from 'lodash';
import Long from 'long';
import Long from 'long';
import type { import type {
ActionsType as CallingReduxActionsType, ActionsType as CallingReduxActionsType,
GroupCallParticipantInfoType, GroupCallParticipantInfoType,
@ -51,6 +50,7 @@ import { getConversationCallMode } from '../state/ducks/conversations';
import { isMe } from '../util/whatTypeOfConversation'; import { isMe } from '../util/whatTypeOfConversation';
import type { import type {
AvailableIODevicesType, AvailableIODevicesType,
CallEndedReason,
MediaDeviceSettings, MediaDeviceSettings,
PresentableSource, PresentableSource,
PresentedSource, PresentedSource,
@ -74,11 +74,10 @@ import { UUID, UUIDKind } from '../types/UUID';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import type { ConversationModel } from '../models/conversations'; import type { ConversationModel } from '../models/conversations';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import { uuidToBytes, bytesToUuid } from '../Crypto'; import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import { dropNull } from '../util/dropNull'; import { dropNull } from '../util/dropNull';
import { getOwn } from '../util/getOwn'; import { getOwn } from '../util/getOwn';
import { isNormalNumber } from '../util/isNormalNumber';
import * as durations from '../util/durations'; import * as durations from '../util/durations';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
import { handleMessageSend } from '../util/handleMessageSend'; import { handleMessageSend } from '../util/handleMessageSend';
@ -107,6 +106,24 @@ import {
import * as log from '../logging/log'; import * as log from '../logging/log';
import { assertDev, strictAssert } from '../util/assert'; import { assertDev, strictAssert } from '../util/assert';
import { sendContentMessageToGroup, sendToGroup } from '../util/sendToGroup'; import { sendContentMessageToGroup, sendToGroup } from '../util/sendToGroup';
import {
formatLocalDeviceState,
formatPeekInfo,
getPeerIdFromConversation,
getLocalCallEventFromCallEndedReason,
getCallDetailsFromEndedDirectCall,
getCallEventDetails,
getLocalCallEventFromGroupCall,
getLocalCallEventFromDirectCall,
getCallDetailsFromDirectCall,
getCallDetailsFromGroupCallMeta,
updateCallHistoryFromLocalEvent,
getGroupCallMeta,
getCallIdFromRing,
getLocalCallEventFromRingUpdate,
} from '../util/callDisposition';
import { isNormalNumber } from '../util/isNormalNumber';
import { LocalCallEvent } from '../types/CallDisposition';
const { const {
processGroupCallRingCancellation, processGroupCallRingCancellation,
@ -689,7 +706,51 @@ export class CallingClass {
{ {
onLocalDeviceStateChanged: groupCall => { onLocalDeviceStateChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState(); const localDeviceState = groupCall.getLocalDeviceState();
const { eraId } = groupCall.getPeekInfo() || {}; const peekInfo = groupCall.getPeekInfo() ?? null;
log.info(
'GroupCall#onLocalDeviceStateChanged',
formatLocalDeviceState(localDeviceState),
peekInfo != null ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
const groupCallMeta = getGroupCallMeta(peekInfo);
if (groupCallMeta != null) {
try {
const localCallEvent = getLocalCallEventFromGroupCall(
groupCall,
groupCallMeta
);
if (localCallEvent != null && peekInfo != null) {
const conversation =
window.ConversationController.get(conversationId);
strictAssert(
conversation != null,
'GroupCall#onLocalDeviceStateChanged: Missing conversation'
);
const peerId = getPeerIdFromConversation(
conversation.attributes
);
const callDetails = getCallDetailsFromGroupCallMeta(
peerId,
groupCallMeta
);
const callEvent = getCallEventDetails(
callDetails,
localCallEvent
);
drop(updateCallHistoryFromLocalEvent(callEvent, null));
}
} catch (error) {
log.error(
'GroupCall#onLocalDeviceStateChanged: Error updating state',
Errors.toLogFormat(error)
);
}
}
if ( if (
localDeviceState.connectionState === ConnectionState.NotConnected localDeviceState.connectionState === ConnectionState.NotConnected
@ -703,10 +764,13 @@ export class CallingClass {
if ( if (
updateMessageState === GroupCallUpdateMessageState.SentJoin && updateMessageState === GroupCallUpdateMessageState.SentJoin &&
eraId peekInfo?.eraId != null
) { ) {
updateMessageState = GroupCallUpdateMessageState.SentLeft; updateMessageState = GroupCallUpdateMessageState.SentLeft;
void this.sendGroupCallUpdateMessage(conversationId, eraId); void this.sendGroupCallUpdateMessage(
conversationId,
peekInfo?.eraId
);
} }
} else { } else {
this.callsByConversation[conversationId] = groupCall; this.callsByConversation[conversationId] = groupCall;
@ -721,16 +785,28 @@ export class CallingClass {
if ( if (
updateMessageState === GroupCallUpdateMessageState.SentNothing && updateMessageState === GroupCallUpdateMessageState.SentNothing &&
localDeviceState.joinState === JoinState.Joined && localDeviceState.joinState === JoinState.Joined &&
eraId peekInfo?.eraId != null
) { ) {
updateMessageState = GroupCallUpdateMessageState.SentJoin; updateMessageState = GroupCallUpdateMessageState.SentJoin;
void this.sendGroupCallUpdateMessage(conversationId, eraId); void this.sendGroupCallUpdateMessage(
conversationId,
peekInfo?.eraId
);
} }
} }
this.syncGroupCallToRedux(conversationId, groupCall); this.syncGroupCallToRedux(conversationId, groupCall);
}, },
onRemoteDeviceStatesChanged: groupCall => { onRemoteDeviceStatesChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState();
const peekInfo = groupCall.getPeekInfo();
log.info(
'GroupCall#onRemoteDeviceStatesChanged',
formatLocalDeviceState(localDeviceState),
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
this.syncGroupCallToRedux(conversationId, groupCall); this.syncGroupCallToRedux(conversationId, groupCall);
}, },
onAudioLevels: groupCall => { onAudioLevels: groupCall => {
@ -748,7 +824,16 @@ export class CallingClass {
}, },
onPeekChanged: groupCall => { onPeekChanged: groupCall => {
const localDeviceState = groupCall.getLocalDeviceState(); const localDeviceState = groupCall.getLocalDeviceState();
const { eraId } = groupCall.getPeekInfo() || {}; const peekInfo = groupCall.getPeekInfo() ?? null;
log.info(
'GroupCall#onPeekChanged',
formatLocalDeviceState(localDeviceState),
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
const { eraId } = peekInfo ?? {};
if ( if (
updateMessageState === GroupCallUpdateMessageState.SentNothing && updateMessageState === GroupCallUpdateMessageState.SentNothing &&
localDeviceState.connectionState !== ConnectionState.NotConnected && localDeviceState.connectionState !== ConnectionState.NotConnected &&
@ -759,10 +844,7 @@ export class CallingClass {
void this.sendGroupCallUpdateMessage(conversationId, eraId); void this.sendGroupCallUpdateMessage(conversationId, eraId);
} }
void this.updateCallHistoryForGroupCall( void this.updateCallHistoryForGroupCall(conversationId, peekInfo);
conversationId,
groupCall.getPeekInfo()
);
this.syncGroupCallToRedux(conversationId, groupCall); this.syncGroupCallToRedux(conversationId, groupCall);
}, },
async requestMembershipProof(groupCall) { async requestMembershipProof(groupCall) {
@ -789,7 +871,17 @@ export class CallingClass {
requestGroupMembers: groupCall => { requestGroupMembers: groupCall => {
groupCall.setGroupMembers(this.getGroupCallMembers(conversationId)); groupCall.setGroupMembers(this.getGroupCallMembers(conversationId));
}, },
onEnded: noop, onEnded: (groupCall, endedReason) => {
const localDeviceState = groupCall.getLocalDeviceState();
const peekInfo = groupCall.getPeekInfo();
log.info(
'GroupCall#onEnded',
endedReason,
formatLocalDeviceState(localDeviceState),
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
);
},
} }
); );
@ -1567,12 +1659,23 @@ export class CallingClass {
await this.handleOutgoingSignaling(remoteUserId, message); await this.handleOutgoingSignaling(remoteUserId, message);
const ProtoOfferType = Proto.CallingMessage.Offer.Type; const wasVideoCall =
await this.addCallHistoryForFailedIncomingCall( callingMessage.offer.type ===
conversation, Proto.CallingMessage.Offer.Type.OFFER_VIDEO_CALL;
callingMessage.offer.type === ProtoOfferType.OFFER_VIDEO_CALL,
envelope.timestamp, const peerId = getPeerIdFromConversation(conversation.attributes);
callId.toString() const callDetails = getCallDetailsFromEndedDirectCall(
callId.toString(),
peerId,
peerId, // Incoming call
wasVideoCall,
envelope.timestamp
);
const localCallEvent = LocalCallEvent.Missed;
const callEvent = getCallEventDetails(callDetails, localCallEvent);
await updateCallHistoryFromLocalEvent(
callEvent,
envelope.receivedAtCounter
); );
return; return;
@ -1801,6 +1904,20 @@ export class CallingClass {
ringId, ringId,
}); });
} }
const localEvent = getLocalCallEventFromRingUpdate(update);
if (localEvent != null) {
const callId = getCallIdFromRing(ringId);
const callDetails = getCallDetailsFromGroupCallMeta(groupId, {
callId,
ringerId: ringerUuid,
});
const callEvent = getCallEventDetails(
callDetails,
shouldRing ? LocalCallEvent.Ringing : LocalCallEvent.Started
);
await updateCallHistoryFromLocalEvent(callEvent, null);
}
} }
private async handleOutgoingSignaling( private async handleOutgoingSignaling(
@ -1865,8 +1982,6 @@ export class CallingClass {
); );
return false; return false;
} }
const callId = Long.fromValue(call.callId).toString();
try { try {
// The peer must be 'trusted' before accepting a call from them. // The peer must be 'trusted' before accepting a call from them.
// This is mostly the safety number check, unverified meaning that they were // This is mostly the safety number check, unverified meaning that they were
@ -1879,12 +1994,13 @@ export class CallingClass {
log.info( log.info(
`Peer is not trusted, ignoring incoming call for conversation: ${conversation.idForLogging()}` `Peer is not trusted, ignoring incoming call for conversation: ${conversation.idForLogging()}`
); );
await this.addCallHistoryForFailedIncomingCall(
conversation, const localCallEvent = LocalCallEvent.Missed;
call.isVideoCall, const peerId = getPeerIdFromConversation(conversation.attributes);
Date.now(), const callDetails = getCallDetailsFromDirectCall(peerId, call);
callId const callEvent = getCallEventDetails(callDetails, localCallEvent);
); await updateCallHistoryFromLocalEvent(callEvent, null);
return false; return false;
} }
@ -1898,20 +2014,14 @@ export class CallingClass {
return true; return true;
} catch (err) { } catch (err) {
log.error(`Ignoring incoming call: ${Errors.toLogFormat(err)}`); log.error(`Ignoring incoming call: ${Errors.toLogFormat(err)}`);
await this.addCallHistoryForFailedIncomingCall(
conversation,
call.isVideoCall,
Date.now(),
callId
);
return false; return false;
} }
} }
private async handleAutoEndedIncomingCallRequest( private async handleAutoEndedIncomingCallRequest(
callId: CallId, callIdValue: CallId,
remoteUserId: UserId, remoteUserId: UserId,
reason: CallEndedReason, callEndedReason: CallEndedReason,
ageInSeconds: number, ageInSeconds: number,
wasVideoCall: boolean, wasVideoCall: boolean,
receivedAtCounter: number | undefined receivedAtCounter: number | undefined
@ -1921,22 +2031,28 @@ export class CallingClass {
return; return;
} }
const callId = Long.fromValue(callIdValue).toString();
const peerId = getPeerIdFromConversation(conversation.attributes);
// This is extra defensive, just in case RingRTC passes us a bad value. (It probably // This is extra defensive, just in case RingRTC passes us a bad value. (It probably
// won't.) // won't.)
const ageInMilliseconds = const ageInMilliseconds =
isNormalNumber(ageInSeconds) && ageInSeconds >= 0 isNormalNumber(ageInSeconds) && ageInSeconds >= 0
? ageInSeconds * durations.SECOND ? ageInSeconds * durations.SECOND
: 0; : 0;
const endedTime = Date.now() - ageInMilliseconds; const timestamp = Date.now() - ageInMilliseconds;
await this.addCallHistoryForAutoEndedIncomingCall( const callDetails = getCallDetailsFromEndedDirectCall(
conversation, callId,
reason, peerId,
endedTime, remoteUserId,
wasVideoCall, wasVideoCall,
receivedAtCounter, timestamp
Long.fromValue(callId).toString()
); );
const localCallEvent =
getLocalCallEventFromCallEndedReason(callEndedReason);
const callEvent = getCallEventDetails(callDetails, localCallEvent);
await updateCallHistoryFromLocalEvent(callEvent, receivedAtCounter ?? null);
} }
private attachToCall(conversation: ConversationModel, call: Call): void { private attachToCall(conversation: ConversationModel, call: Call): void {
@ -1947,44 +2063,26 @@ export class CallingClass {
return; return;
} }
let acceptedTime: number | undefined;
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
call.handleStateChanged = async () => { call.handleStateChanged = async () => {
if (call.state === CallState.Accepted) { if (call.state === CallState.Ended) {
acceptedTime = acceptedTime || Date.now();
await this.addCallHistoryForAcceptedCall(
conversation,
call,
acceptedTime
);
} else if (call.state === CallState.Ended) {
try {
await this.addCallHistoryForEndedCall(
conversation,
call,
acceptedTime
);
} catch (error) {
log.error(
'Failed to add call history for ended call',
Errors.toLogFormat(error)
);
}
this.stopDeviceReselectionTimer(); this.stopDeviceReselectionTimer();
this.lastMediaDeviceSettings = undefined; this.lastMediaDeviceSettings = undefined;
delete this.callsByConversation[conversation.id]; delete this.callsByConversation[conversation.id];
} }
const localCallEvent = getLocalCallEventFromDirectCall(call);
if (localCallEvent != null) {
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromDirectCall(peerId, call);
const callEvent = getCallEventDetails(callDetails, localCallEvent);
await updateCallHistoryFromLocalEvent(callEvent, null);
}
reduxInterface.callStateChange({ reduxInterface.callStateChange({
remoteUserId: call.remoteUserId,
callId: Long.fromValue(call.callId).toString(),
conversationId: conversation.id, conversationId: conversation.id,
acceptedTime,
callState: call.state, callState: call.state,
callEndedReason: call.endedReason, callEndedReason: call.endedReason,
isIncoming: call.isIncoming,
isVideoCall: call.isVideoCall,
title: conversation.getTitle(),
}); });
}; };
@ -2137,154 +2235,55 @@ export class CallingClass {
return true; return true;
} }
private async addCallHistoryForAcceptedCall(
conversation: ConversationModel,
call: Call,
acceptedTime: number
) {
const callId = Long.fromValue(call.callId).toString();
try {
log.info('addCallHistoryForAcceptedCall: Adding call history');
await conversation.addCallHistory(
{
callId,
callMode: CallMode.Direct,
wasIncoming: call.isIncoming,
wasVideoCall: call.isVideoCall,
wasDeclined: false,
acceptedTime,
endedTime: undefined,
},
undefined
);
} catch (error) {
log.error(
'addCallHistoryForAcceptedCall: Failed to add call history',
Errors.toLogFormat(error)
);
}
}
private async addCallHistoryForEndedCall(
conversation: ConversationModel,
call: Call,
acceptedTimeParam: number | undefined
) {
let acceptedTime = acceptedTimeParam;
const { endedReason, isIncoming } = call;
const wasAccepted = Boolean(acceptedTime);
const isOutgoing = !isIncoming;
const wasDeclined =
!wasAccepted &&
(endedReason === CallEndedReason.Declined ||
endedReason === CallEndedReason.DeclinedOnAnotherDevice ||
(isIncoming && endedReason === CallEndedReason.LocalHangup) ||
(isOutgoing && endedReason === CallEndedReason.RemoteHangup) ||
(isOutgoing &&
endedReason === CallEndedReason.RemoteHangupNeedPermission));
if (call.endedReason === CallEndedReason.AcceptedOnAnotherDevice) {
acceptedTime = Date.now();
}
const callId = Long.fromValue(call.callId).toString();
await conversation.addCallHistory(
{
callId,
callMode: CallMode.Direct,
wasIncoming: call.isIncoming,
wasVideoCall: call.isVideoCall,
wasDeclined,
acceptedTime,
endedTime: Date.now(),
},
undefined
);
}
private async addCallHistoryForFailedIncomingCall(
conversation: ConversationModel,
wasVideoCall: boolean,
timestamp: number,
callId: string
) {
await conversation.addCallHistory(
{
callMode: CallMode.Direct,
wasIncoming: true,
wasVideoCall,
// Since the user didn't decline, make sure it shows up as a missed call instead
wasDeclined: false,
acceptedTime: undefined,
endedTime: timestamp,
callId,
},
undefined
);
}
private async addCallHistoryForAutoEndedIncomingCall(
conversation: ConversationModel,
reason: CallEndedReason,
endedTime: number,
wasVideoCall: boolean,
receivedAtCounter: number | undefined,
callId: string
) {
let wasDeclined = false;
let acceptedTime;
if (reason === CallEndedReason.AcceptedOnAnotherDevice) {
acceptedTime = endedTime;
} else if (reason === CallEndedReason.DeclinedOnAnotherDevice) {
wasDeclined = true;
}
// Otherwise it will show up as a missed call.
await conversation.addCallHistory(
{
callId,
callMode: CallMode.Direct,
wasIncoming: true,
wasVideoCall,
wasDeclined,
acceptedTime,
endedTime,
},
receivedAtCounter
);
}
public async updateCallHistoryForGroupCall( public async updateCallHistoryForGroupCall(
conversationId: string, conversationId: string,
peekInfo: undefined | PeekInfo peekInfo: PeekInfo | null
): Promise<void> { ): Promise<void> {
const groupCallMeta = getGroupCallMeta(peekInfo);
// If we don't have the necessary pieces to peek, bail. (It's okay if we don't.) // If we don't have the necessary pieces to peek, bail. (It's okay if we don't.)
if (!peekInfo || !peekInfo.eraId || !peekInfo.creator) { if (groupCallMeta == null) {
return; return;
} }
const creatorUuid = bytesToUuid(peekInfo.creator);
if (!creatorUuid) { const creatorConversation = window.ConversationController.get(
log.error('updateCallHistoryForGroupCall(): bad creator UUID'); groupCallMeta.ringerId
return; );
}
const creatorConversation = window.ConversationController.get(creatorUuid);
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (!conversation) { if (!conversation) {
log.error('updateCallHistoryForGroupCall(): could not find conversation'); log.error('maybeNotifyGroupCall(): could not find conversation');
return; return;
} }
const isNewCall = await conversation.updateCallHistoryForGroupCall( const prevMessageId =
peekInfo.eraId, await window.Signal.Data.getCallHistoryMessageByCallId({
creatorUuid conversationId: conversation.id,
); callId: groupCallMeta.callId,
});
const isNewCall = prevMessageId == null;
const groupCall = this.getGroupCall(conversationId);
if (groupCall != null) {
const localCallEvent = getLocalCallEventFromGroupCall(
groupCall,
groupCallMeta
);
if (localCallEvent != null) {
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromGroupCallMeta(
peerId,
groupCallMeta
);
const callEvent = getCallEventDetails(callDetails, localCallEvent);
await updateCallHistoryFromLocalEvent(callEvent, null);
}
}
const wasStartedByMe = Boolean( const wasStartedByMe = Boolean(
creatorConversation && isMe(creatorConversation.attributes) creatorConversation && isMe(creatorConversation.attributes)
); );
const isAnybodyElseInGroupCall = Boolean(peekInfo.devices.length); const isAnybodyElseInGroupCall = Boolean(peekInfo?.devices.length);
if ( if (
isNewCall && isNewCall &&

View file

@ -4,11 +4,8 @@
import { isEqual, isNumber } from 'lodash'; import { isEqual, isNumber } from 'lodash';
import Long from 'long'; import Long from 'long';
import { import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
uuidToBytes, import { deriveMasterKeyFromGroupV1 } from '../Crypto';
bytesToUuid,
deriveMasterKeyFromGroupV1,
} from '../Crypto';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import { import {
deriveGroupFields, deriveGroupFields,

View file

@ -11,8 +11,7 @@ import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { sleep } from '../util/sleep'; import { sleep } from '../util/sleep';
import { getMinNickname, getMaxNickname } from '../util/Username'; import { getMinNickname, getMaxNickname } from '../util/Username';
import { bytesToUuid } from '../Crypto'; import { bytesToUuid, uuidToBytes } from '../util/uuidToBytes';
import { uuidToBytes } from '../util/uuidToBytes';
import type { UsernameReservationType } from '../types/Username'; import type { UsernameReservationType } from '../types/Username';
import { ReserveUsernameError, ConfirmUsernameResult } from '../types/Username'; import { ReserveUsernameError, ConfirmUsernameResult } from '../types/Username';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';

View file

@ -21,6 +21,12 @@ import type { ReadStatus } from '../messages/MessageReadStatus';
import type { RawBodyRange } from '../types/BodyRange'; import type { RawBodyRange } from '../types/BodyRange';
import type { GetMessagesBetweenOptions } from './Server'; import type { GetMessagesBetweenOptions } from './Server';
import type { MessageTimestamps } from '../state/ducks/conversations'; import type { MessageTimestamps } from '../state/ducks/conversations';
import type {
CallHistoryDetails,
CallHistoryFilter,
CallHistoryGroup,
CallHistoryPagination,
} from '../types/CallDisposition';
export type AdjacentMessagesByConversationOptionsType = Readonly<{ export type AdjacentMessagesByConversationOptionsType = Readonly<{
conversationId: string; conversationId: string;
@ -628,10 +634,22 @@ export type DataInterface = {
getLastConversationMessage(options: { getLastConversationMessage(options: {
conversationId: string; conversationId: string;
}): Promise<MessageType | undefined>; }): Promise<MessageType | undefined>;
getCallHistoryMessageByCallId( getAllCallHistory: () => Promise<ReadonlyArray<CallHistoryDetails>>;
conversationId: string, clearCallHistory: (beforeTimestamp: number) => Promise<Array<string>>;
callId: string getCallHistoryMessageByCallId(options: {
): Promise<string | void>; conversationId: string;
callId: string;
}): Promise<MessageType | undefined>;
getCallHistory(
callId: string,
peerId: string
): Promise<CallHistoryDetails | undefined>;
getCallHistoryGroupsCount(filter: CallHistoryFilter): Promise<number>;
getCallHistoryGroups(
filter: CallHistoryFilter,
pagination: CallHistoryPagination
): Promise<Array<CallHistoryGroup>>;
saveCallHistory(callHistory: CallHistoryDetails): Promise<void>;
hasGroupCallHistoryMessage: ( hasGroupCallHistoryMessage: (
conversationId: string, conversationId: string,
eraId: string eraId: string

View file

@ -10,6 +10,7 @@ import { randomBytes } from 'crypto';
import type { Database, Statement } from '@signalapp/better-sqlite3'; import type { Database, Statement } from '@signalapp/better-sqlite3';
import SQL from '@signalapp/better-sqlite3'; import SQL from '@signalapp/better-sqlite3';
import pProps from 'p-props'; import pProps from 'p-props';
import { z } from 'zod';
import type { Dictionary } from 'lodash'; import type { Dictionary } from 'lodash';
import { import {
@ -60,6 +61,7 @@ import type {
QueryFragment, QueryFragment,
} from './util'; } from './util';
import { import {
sqlConstant,
sqlJoin, sqlJoin,
sqlFragment, sqlFragment,
sql, sql,
@ -142,6 +144,18 @@ import {
SNIPPET_RIGHT_PLACEHOLDER, SNIPPET_RIGHT_PLACEHOLDER,
SNIPPET_TRUNCATION_PLACEHOLDER, SNIPPET_TRUNCATION_PLACEHOLDER,
} from '../util/search'; } from '../util/search';
import type {
CallHistoryDetails,
CallHistoryFilter,
CallHistoryGroup,
CallHistoryPagination,
} from '../types/CallDisposition';
import {
DirectCallStatus,
callHistoryGroupSchema,
CallHistoryFilterStatus,
callHistoryDetailsSchema,
} from '../types/CallDisposition';
type ConversationRow = Readonly<{ type ConversationRow = Readonly<{
json: string; json: string;
@ -288,7 +302,13 @@ const dataInterface: ServerInterface = {
getConversationRangeCenteredOnMessage, getConversationRangeCenteredOnMessage,
getConversationMessageStats, getConversationMessageStats,
getLastConversationMessage, getLastConversationMessage,
getAllCallHistory,
clearCallHistory,
getCallHistoryMessageByCallId, getCallHistoryMessageByCallId,
getCallHistory,
getCallHistoryGroupsCount,
getCallHistoryGroups,
saveCallHistory,
hasGroupCallHistoryMessage, hasGroupCallHistoryMessage,
migrateConversationMessages, migrateConversationMessages,
getMessagesBetween, getMessagesBetween,
@ -1755,32 +1775,32 @@ async function searchMessages({
// Note: this groups the results by rowid, so even if one message mentions multiple // Note: this groups the results by rowid, so even if one message mentions multiple
// matching UUIDs, we only return one to be highlighted // matching UUIDs, we only return one to be highlighted
const [sqlQuery, params] = sql` const [sqlQuery, params] = sql`
SELECT SELECT
messages.rowid as rowid, messages.rowid as rowid,
COALESCE(messages.json, ftsResults.json) as json, COALESCE(messages.json, ftsResults.json) as json,
COALESCE(messages.sent_at, ftsResults.sent_at) as sent_at, COALESCE(messages.sent_at, ftsResults.sent_at) as sent_at,
COALESCE(messages.received_at, ftsResults.received_at) as received_at, COALESCE(messages.received_at, ftsResults.received_at) as received_at,
ftsResults.ftsSnippet, ftsResults.ftsSnippet,
mentionUuid, mentionUuid,
start as mentionStart, start as mentionStart,
length as mentionLength length as mentionLength
FROM mentions FROM mentions
INNER JOIN messages INNER JOIN messages
ON ON
messages.id = mentions.messageId messages.id = mentions.messageId
AND mentions.mentionUuid IN ( AND mentions.mentionUuid IN (
${sqlJoin(contactUuidsMatchingQuery, ', ')} ${sqlJoin(contactUuidsMatchingQuery, ', ')}
) )
AND ${ AND ${
conversationId conversationId
? sqlFragment`messages.conversationId = ${conversationId}` ? sqlFragment`messages.conversationId = ${conversationId}`
: '1 IS 1' : '1 IS 1'
} }
AND messages.isViewOnce IS NOT 1 AND messages.isViewOnce IS NOT 1
AND messages.storyId IS NULL AND messages.storyId IS NULL
FULL OUTER JOIN ( FULL OUTER JOIN (
${ftsFragment} ${ftsFragment}
) as ftsResults ) as ftsResults
USING (rowid) USING (rowid)
GROUP BY rowid GROUP BY rowid
ORDER BY received_at DESC, sent_at DESC ORDER BY received_at DESC, sent_at DESC
@ -1910,6 +1930,7 @@ function saveMessageSync(
sourceUuid, sourceUuid,
sourceDevice, sourceDevice,
storyId, storyId,
callId,
type, type,
readStatus, readStatus,
expireTimer, expireTimer,
@ -1967,6 +1988,7 @@ function saveMessageSync(
sourceUuid: sourceUuid || null, sourceUuid: sourceUuid || null,
sourceDevice: sourceDevice || null, sourceDevice: sourceDevice || null,
storyId: storyId || null, storyId: storyId || null,
callId: callId || null,
type: type || null, type: type || null,
readStatus: readStatus ?? null, readStatus: readStatus ?? null,
seenStatus: seenStatus ?? SeenStatus.NotApplicable, seenStatus: seenStatus ?? SeenStatus.NotApplicable,
@ -1999,6 +2021,7 @@ function saveMessageSync(
sourceUuid = $sourceUuid, sourceUuid = $sourceUuid,
sourceDevice = $sourceDevice, sourceDevice = $sourceDevice,
storyId = $storyId, storyId = $storyId,
callId = $callId,
type = $type, type = $type,
readStatus = $readStatus, readStatus = $readStatus,
seenStatus = $seenStatus seenStatus = $seenStatus
@ -2044,6 +2067,7 @@ function saveMessageSync(
sourceUuid, sourceUuid,
sourceDevice, sourceDevice,
storyId, storyId,
callId,
type, type,
readStatus, readStatus,
seenStatus seenStatus
@ -2070,6 +2094,7 @@ function saveMessageSync(
$sourceUuid, $sourceUuid,
$sourceDevice, $sourceDevice,
$storyId, $storyId,
$callId,
$type, $type,
$readStatus, $readStatus,
$seenStatus $seenStatus
@ -3224,30 +3249,366 @@ async function getConversationRangeCenteredOnMessage(
})(); })();
} }
async function getCallHistoryMessageByCallId( async function getAllCallHistory(): Promise<ReadonlyArray<CallHistoryDetails>> {
conversationId: string, const db = getInstance();
callId: string const [query] = sql`
): Promise<string | void> { SELECT * FROM callsHistory;
`;
return db.prepare(query).all();
}
async function clearCallHistory(
beforeTimestamp: number
): Promise<Array<string>> {
const db = getInstance();
return db.transaction(() => {
const whereMessages = sqlFragment`
WHERE messages.type IS 'call-history'
AND messages.sent_at <= ${beforeTimestamp};
`;
const [selectMessagesQuery, selectMessagesParams] = sql`
SELECT id FROM messages ${whereMessages}
`;
const [clearMessagesQuery, clearMessagesParams] = sql`
DELETE FROM messages ${whereMessages}
`;
const [clearCallsHistoryQuery, clearCallsHistoryParams] = sql`
UPDATE callsHistory
SET
status = ${DirectCallStatus.Deleted},
timestamp = ${Date.now()}
WHERE callsHistory.timestamp <= ${beforeTimestamp};
`;
const messageIds = db
.prepare(selectMessagesQuery)
.pluck()
.all(selectMessagesParams);
db.prepare(clearMessagesQuery).run(clearMessagesParams);
try {
db.prepare(clearCallsHistoryQuery).run(clearCallsHistoryParams);
} catch (error) {
logger.error(error, error.message);
throw error;
}
return messageIds;
})();
}
async function getCallHistoryMessageByCallId(options: {
conversationId: string;
callId: string;
}): Promise<MessageType | undefined> {
const db = getInstance();
const [query, params] = sql`
SELECT json
FROM messages
WHERE conversationId = ${options.conversationId}
AND type = 'call-history'
AND callId = ${options.callId}
`;
const row = db.prepare(query).get(params);
if (row == null) {
return;
}
return jsonToObject(row.json);
}
async function getCallHistory(
callId: string,
peerId: string
): Promise<CallHistoryDetails | undefined> {
const db = getInstance(); const db = getInstance();
const id: string | void = db const [query, params] = sql`
.prepare<Query>( SELECT * FROM callsHistory
` WHERE callId IS ${callId}
SELECT id AND peerId IS ${peerId};
FROM messages `;
WHERE conversationId = $conversationId
AND type = 'call-history'
AND callMode = 'Direct'
AND callId = $callId
`
)
.pluck()
.get({
conversationId,
callId,
});
return id; const row = db.prepare(query).get(params);
if (row == null) {
return;
}
return callHistoryDetailsSchema.parse(row);
}
const MISSED = sqlConstant(DirectCallStatus.Missed);
const DELETED = sqlConstant(DirectCallStatus.Deleted);
const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000);
function getCallHistoryGroupDataSync(
db: Database,
isCount: boolean,
filter: CallHistoryFilter,
pagination: CallHistoryPagination
): unknown {
return db.transaction(() => {
const { limit, offset } = pagination;
const { status, conversationIds } = filter;
if (conversationIds != null) {
strictAssert(conversationIds.length > 0, "can't filter by empty array");
const [createTempTable] = sql`
CREATE TEMP TABLE temp_callHistory_filtered_conversations (
uuid TEXT,
groupId TEXT
);
`;
db.exec(createTempTable);
batchMultiVarQuery(db, conversationIds, ids => {
const idList = sqlJoin(
ids.map(id => sqlFragment`(${id})`),
','
);
const [insertQuery, insertParams] = sql`
INSERT INTO temp_callHistory_filtered_conversations
(uuid, groupId)
SELECT uuid, groupId
FROM conversations
WHERE conversations.id IN (${idList});
`;
db.prepare(insertQuery).run(insertParams);
});
}
const innerJoin =
conversationIds != null
? sqlFragment`
INNER JOIN temp_callHistory_filtered_conversations ON (
temp_callHistory_filtered_conversations.uuid IS c.peerId
OR temp_callHistory_filtered_conversations.groupId IS c.peerId
)
`
: sqlFragment``;
const filterClause =
status === CallHistoryFilterStatus.All
? sqlFragment`status IS NOT ${DELETED}`
: sqlFragment`status IS ${MISSED} AND status IS NOT ${DELETED}`;
const offsetLimit =
limit > 0 ? sqlFragment`LIMIT ${limit} OFFSET ${offset}` : sqlFragment``;
const projection = isCount
? sqlFragment`COUNT(*) AS count`
: sqlFragment`peerId, ringerId, mode, type, direction, status, timestamp, possibleChildren, inPeriod`;
const [query, params] = sql`
SELECT
${projection}
FROM (
-- 1. 'callAndGroupInfo': This section collects metadata to determine the
-- parent and children of each call. We can identify the real parents of calls
-- within the query, but we need to build the children at runtime.
WITH callAndGroupInfo AS (
SELECT
*,
-- 1a. 'possibleParent': This identifies the first call that _could_ be
-- considered the current call's parent. Note: The 'possibleParent' is not
-- necessarily the true parent if there is another call between them that
-- isn't a part of the group.
(
SELECT callId
FROM callsHistory
WHERE
callsHistory.direction IS c.direction
AND callsHistory.type IS c.type
AND callsHistory.peerId IS c.peerId
AND (callsHistory.timestamp - ${FOUR_HOURS_IN_MS}) <= c.timestamp
AND callsHistory.timestamp >= c.timestamp
-- Tracking Android & Desktop separately to make the queries easier to compare
-- Android Constraints:
AND (
(callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR
(callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED})
)
-- Desktop Constraints:
AND callsHistory.status IS c.status
AND ${filterClause}
ORDER BY timestamp DESC
) as possibleParent,
-- 1b. 'possibleChildren': This identifies all possible calls that can
-- be grouped with the current call. Note: This current call is not
-- necessarily the parent, and not all possible children will end up as
-- children as they might have another parent
(
SELECT JSON_GROUP_ARRAY(
JSON_OBJECT(
'callId', callId,
'timestamp', timestamp
)
)
FROM callsHistory
WHERE
callsHistory.direction IS c.direction
AND callsHistory.type IS c.type
AND callsHistory.peerId IS c.peerId
AND (c.timestamp - ${FOUR_HOURS_IN_MS}) <= callsHistory.timestamp
AND c.timestamp >= callsHistory.timestamp
-- Tracking Android & Desktop separately to make the queries easier to compare
-- Android Constraints:
AND (
(callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR
(callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED})
)
-- Desktop Constraints:
AND callsHistory.status IS c.status
AND ${filterClause}
ORDER BY timestamp DESC
) as possibleChildren,
-- 1c. 'inPeriod': This identifies all calls in a time period after the
-- current call. They may or may not be a part of the group.
(
SELECT GROUP_CONCAT(callId)
FROM callsHistory
WHERE
(c.timestamp - ${FOUR_HOURS_IN_MS}) <= callsHistory.timestamp
AND c.timestamp >= callsHistory.timestamp
AND ${filterClause}
) AS inPeriod
FROM callsHistory AS c
${innerJoin}
WHERE
${filterClause}
ORDER BY timestamp DESC
)
-- 2. 'isParent': We need to identify the true parent of the group in cases
-- where the previous call is not a part of the group.
SELECT
*,
CASE
WHEN LAG (possibleParent, 1, 0) OVER (
-- Note: This is an optimization assuming that we've already got 'timestamp DESC' ordering
-- from the query above. If we find that ordering isn't always correct, we can uncomment this:
-- ORDER BY timestamp DESC
) != possibleParent THEN callId
ELSE possibleParent
END AS parent
FROM callAndGroupInfo
) AS parentCallAndGroupInfo
WHERE parent = parentCallAndGroupInfo.callId
ORDER BY parentCallAndGroupInfo.timestamp DESC
${offsetLimit};
`;
const result = isCount
? db.prepare(query).pluck(true).get(params)
: db.prepare(query).all(params);
if (conversationIds != null) {
const [dropTempTableQuery] = sql`
DROP TABLE temp_callHistory_filtered_conversations;
`;
db.exec(dropTempTableQuery);
}
return result;
})();
}
const countSchema = z.number().int().nonnegative();
async function getCallHistoryGroupsCount(
filter: CallHistoryFilter
): Promise<number> {
const db = getInstance();
const result = getCallHistoryGroupDataSync(db, true, filter, {
limit: 0,
offset: 0,
});
return countSchema.parse(result);
}
const groupsDataSchema = z.array(
callHistoryGroupSchema.omit({ children: true }).extend({
possibleChildren: z.string(),
inPeriod: z.string(),
})
);
const possibleChildrenSchema = z.array(
callHistoryDetailsSchema.pick({
callId: true,
timestamp: true,
})
);
async function getCallHistoryGroups(
filter: CallHistoryFilter,
pagination: CallHistoryPagination
): Promise<Array<CallHistoryGroup>> {
const db = getInstance();
const groupsData = groupsDataSchema.parse(
getCallHistoryGroupDataSync(db, false, filter, pagination)
);
const taken = new Set<string>();
return groupsData
.map(groupData => {
return {
...groupData,
possibleChildren: possibleChildrenSchema.parse(
JSON.parse(groupData.possibleChildren)
),
inPeriod: new Set(groupData.inPeriod.split(',')),
};
})
.reverse()
.map(group => {
const { possibleChildren, inPeriod, ...rest } = group;
const children = [];
for (const child of possibleChildren) {
if (!taken.has(child.callId) && inPeriod.has(child.callId)) {
children.push(child);
taken.add(child.callId);
}
}
return callHistoryGroupSchema.parse({ ...rest, children });
})
.reverse();
}
async function saveCallHistory(callHistory: CallHistoryDetails): Promise<void> {
const db = getInstance();
const [insertQuery, insertParams] = sql`
INSERT OR REPLACE INTO callsHistory (
callId,
peerId,
ringerId,
mode,
type,
direction,
status,
timestamp
) VALUES (
${callHistory.callId},
${callHistory.peerId},
${callHistory.ringerId},
${callHistory.mode},
${callHistory.type},
${callHistory.direction},
${callHistory.status},
${callHistory.timestamp}
);
`;
db.prepare(insertQuery).run(insertParams);
} }
async function hasGroupCallHistoryMessage( async function hasGroupCallHistoryMessage(
@ -5087,6 +5448,7 @@ async function removeAll(): Promise<void> {
DELETE FROM attachment_downloads; DELETE FROM attachment_downloads;
DELETE FROM badgeImageFiles; DELETE FROM badgeImageFiles;
DELETE FROM badges; DELETE FROM badges;
DELETE FROM callsHistory;
DELETE FROM conversations; DELETE FROM conversations;
DELETE FROM emojis; DELETE FROM emojis;
DELETE FROM groupCallRingCancellations; DELETE FROM groupCallRingCancellations;

View file

@ -0,0 +1,196 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from '@signalapp/better-sqlite3';
import { callIdFromEra } from '@signalapp/ringrtc';
import Long from 'long';
import type { LoggerType } from '../../types/Logging';
import { sql } from '../util';
import { getOurUuid } from './41-uuid-keys';
import type { CallHistoryDetails } from '../../types/CallDisposition';
import {
DirectCallStatus,
CallDirection,
CallType,
GroupCallStatus,
callHistoryDetailsSchema,
} from '../../types/CallDisposition';
import { CallMode } from '../../types/Calling';
export default function updateToSchemaVersion87(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 87) {
return;
}
db.transaction(() => {
const ourUuid = getOurUuid(db);
const [modifySchema] = sql`
DROP TABLE IF EXISTS callsHistory;
CREATE TABLE callsHistory (
callId TEXT PRIMARY KEY,
peerId TEXT NOT NULL, -- conversation uuid | groupId | roomId
ringerId TEXT DEFAULT NULL, -- ringer uuid
mode TEXT NOT NULL, -- enum "Direct" | "Group"
type TEXT NOT NULL, -- enum "Audio" | "Video" | "Group"
direction TEXT NOT NULL, -- enum "Incoming" | "Outgoing
-- Direct: enum "Pending" | "Missed" | "Accepted" | "Deleted"
-- Group: enum "GenericGroupCall" | "OutgoingRing" | "Ringing" | "Joined" | "Missed" | "Declined" | "Accepted" | "Deleted"
status TEXT NOT NULL,
timestamp INTEGER NOT NULL,
UNIQUE (callId, peerId) ON CONFLICT FAIL
);
CREATE INDEX callsHistory_order on callsHistory (timestamp DESC);
CREATE INDEX callsHistory_byConversation ON callsHistory (peerId);
-- For 'getCallHistoryGroupData':
-- This index should target the subqueries for 'possible_parent' and 'possible_children'
CREATE INDEX callsHistory_callAndGroupInfo_optimize on callsHistory (
direction,
peerId,
timestamp DESC,
status
);
DROP INDEX IF EXISTS messages_call;
ALTER TABLE messages
DROP COLUMN callId;
ALTER TABLE messages
ADD COLUMN callId TEXT;
ALTER TABLE messages
DROP COLUMN callMode;
`;
db.exec(modifySchema);
const [selectQuery] = sql`
SELECT * FROM messages WHERE type = 'call-history';
`;
const rows = db.prepare(selectQuery).all();
for (const row of rows) {
const json = JSON.parse(row.json);
const details = json.callHistoryDetails;
const { conversationId: peerId } = row;
const { callMode } = details;
let callId: string;
let type: CallType;
let direction: CallDirection;
let status: GroupCallStatus | DirectCallStatus;
let timestamp: number;
let ringerId: string | null = null;
if (details.callMode === CallMode.Direct) {
callId = details.callId;
type = details.wasVideoCall ? CallType.Video : CallType.Audio;
direction = details.wasIncoming
? CallDirection.Incoming
: CallDirection.Outgoing;
if (details.acceptedTime != null) {
status = DirectCallStatus.Accepted;
} else {
status = details.wasDeclined
? DirectCallStatus.Declined
: DirectCallStatus.Missed;
}
timestamp = details.endedTime ?? details.acceptedTime ?? null;
} else if (details.callMode === CallMode.Group) {
callId = Long.fromValue(callIdFromEra(details.eraId)).toString();
type = CallType.Group;
direction =
details.creatorUuid === ourUuid
? CallDirection.Outgoing
: CallDirection.Incoming;
status = GroupCallStatus.GenericGroupCall;
timestamp = details.startedTime;
ringerId = details.creatorUuid;
} else {
logger.error(
`updateToSchemaVersion87: unknown callMode: ${details.callMode}`
);
continue;
}
if (callId == null) {
logger.error(
"updateToSchemaVersion87: callId doesn't exist, too old, skipping"
);
continue;
}
const callHistory: CallHistoryDetails = {
callId,
peerId,
ringerId,
mode: callMode,
type,
direction,
status,
timestamp,
};
const result = callHistoryDetailsSchema.safeParse(callHistory);
if (!result.success) {
logger.error(
`updateToSchemaVersion87: invalid callHistoryDetails (error: ${JSON.stringify(
result.error.format()
)}, input: ${JSON.stringify(json)}, output: ${JSON.stringify(
callHistory
)}))`
);
continue;
}
const [insertQuery, insertParams] = sql`
INSERT INTO callsHistory (
callId,
peerId,
ringerId,
mode,
type,
direction,
status,
timestamp
) VALUES (
${callHistory.callId},
${callHistory.peerId},
${callHistory.ringerId},
${callHistory.mode},
${callHistory.type},
${callHistory.direction},
${callHistory.status},
${callHistory.timestamp}
)
`;
db.prepare(insertQuery).run(insertParams);
const [updateQuery, updateParams] = sql`
UPDATE messages
SET json = JSON_PATCH(json, ${JSON.stringify({
callHistoryDetails: null, // delete
callId,
})})
WHERE id = ${row.id}
`;
db.prepare(updateQuery).run(updateParams);
}
db.pragma('user_version = 87');
})();
logger.info('updateToSchemaVersion87: success!');
}

View file

@ -62,6 +62,7 @@ import updateToSchemaVersion83 from './83-mentions';
import updateToSchemaVersion84 from './84-all-mentions'; import updateToSchemaVersion84 from './84-all-mentions';
import updateToSchemaVersion85 from './85-add-kyber-keys'; import updateToSchemaVersion85 from './85-add-kyber-keys';
import updateToSchemaVersion86 from './86-story-replies-index'; import updateToSchemaVersion86 from './86-story-replies-index';
import updateToSchemaVersion87 from './87-calls-history-table';
function updateToSchemaVersion1( function updateToSchemaVersion1(
currentVersion: number, currentVersion: number,
@ -1994,6 +1995,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion84, updateToSchemaVersion84,
updateToSchemaVersion85, updateToSchemaVersion85,
updateToSchemaVersion86, updateToSchemaVersion86,
updateToSchemaVersion87,
]; ];
export function updateSchema(db: Database, logger: LoggerType): void { export function updateSchema(db: Database, logger: LoggerType): void {

View file

@ -36,7 +36,7 @@ export function jsonToObject<T>(json: string): T {
return JSON.parse(json); return JSON.parse(json);
} }
export type QueryTemplateParam = string | number | undefined; export type QueryTemplateParam = string | number | null | undefined;
export type QueryFragmentValue = QueryFragment | QueryTemplateParam; export type QueryFragmentValue = QueryFragment | QueryTemplateParam;
export type QueryFragment = [ export type QueryFragment = [
@ -66,7 +66,7 @@ export function sqlFragment(
...values: ReadonlyArray<QueryFragmentValue> ...values: ReadonlyArray<QueryFragmentValue>
): QueryFragment { ): QueryFragment {
let query = ''; let query = '';
const params: Array<string | number | undefined> = []; const params: Array<QueryTemplateParam> = [];
strings.forEach((string, index) => { strings.forEach((string, index) => {
const value = values[index]; const value = values[index];
@ -88,6 +88,20 @@ export function sqlFragment(
return [{ fragment: query }, params]; return [{ fragment: query }, params];
} }
export function sqlConstant(value: QueryTemplateParam): QueryFragment {
let fragment;
if (value == null) {
fragment = 'NULL';
} else if (typeof value === 'number') {
fragment = `${value}`;
} else if (typeof value === 'boolean') {
fragment = `${value}`;
} else {
fragment = `'${value}'`;
}
return [{ fragment }, []];
}
/** /**
* Like `Array.prototype.join`, but for SQL fragments. * Like `Array.prototype.join`, but for SQL fragments.
*/ */
@ -96,7 +110,7 @@ export function sqlJoin(
separator: string separator: string
): QueryFragment { ): QueryFragment {
let query = ''; let query = '';
const params: Array<string | number | undefined> = []; const params: Array<QueryTemplateParam> = [];
items.forEach((item, index) => { items.forEach((item, index) => {
const [{ fragment }, fragmentParams] = sqlFragment`${item}`; const [{ fragment }, fragmentParams] = sqlFragment`${item}`;
@ -111,10 +125,7 @@ export function sqlJoin(
return [{ fragment: query }, params]; return [{ fragment: query }, params];
} }
export type QueryTemplate = [ export type QueryTemplate = [string, ReadonlyArray<QueryTemplateParam>];
string,
ReadonlyArray<string | number | undefined>
];
/** /**
* You can use tagged template literals to build SQL queries * You can use tagged template literals to build SQL queries
@ -137,7 +148,7 @@ export type QueryTemplate = [
*/ */
export function sql( export function sql(
strings: TemplateStringsArray, strings: TemplateStringsArray,
...values: ReadonlyArray<QueryFragment | string | number | undefined> ...values: ReadonlyArray<QueryFragment | QueryTemplateParam>
): QueryTemplate { ): QueryTemplate {
const [{ fragment }, params] = sqlFragment(strings, ...values); const [{ fragment }, params] = sqlFragment(strings, ...values);
return [fragment, params]; return [fragment, params];

View file

@ -6,6 +6,7 @@ import { actions as app } from './ducks/app';
import { actions as audioPlayer } from './ducks/audioPlayer'; import { actions as audioPlayer } from './ducks/audioPlayer';
import { actions as audioRecorder } from './ducks/audioRecorder'; import { actions as audioRecorder } from './ducks/audioRecorder';
import { actions as badges } from './ducks/badges'; import { actions as badges } from './ducks/badges';
import { actions as callHistory } from './ducks/callHistory';
import { actions as calling } from './ducks/calling'; import { actions as calling } from './ducks/calling';
import { actions as composer } from './ducks/composer'; import { actions as composer } from './ducks/composer';
import { actions as conversations } from './ducks/conversations'; import { actions as conversations } from './ducks/conversations';
@ -36,6 +37,7 @@ export const actionCreators: ReduxActions = {
audioPlayer, audioPlayer,
audioRecorder, audioRecorder,
badges, badges,
callHistory,
calling, calling,
composer, composer,
conversations, conversations,

View file

@ -0,0 +1,91 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReadonlyDeep } from 'type-fest';
import type { ThunkAction } from 'redux-thunk';
import type { StateType as RootStateType } from '../reducer';
import { clearCallHistoryDataAndSync } from '../../util/callDisposition';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
import type { ToastActionType } from './toast';
import { showToast } from './toast';
import { ToastType } from '../../types/Toast';
import type { CallHistoryDetails } from '../../types/CallDisposition';
export type CallHistoryState = ReadonlyDeep<{
// This informs the app that underlying call history data has changed.
edition: number;
callHistoryByCallId: Record<string, CallHistoryDetails>;
}>;
const CALL_HISTORY_CACHE = 'callHistory/CACHE';
const CALL_HISTORY_CLEAR = 'callHistory/CLEAR';
export type CallHistoryCache = ReadonlyDeep<{
type: typeof CALL_HISTORY_CACHE;
payload: CallHistoryDetails;
}>;
export type CallHistoryClear = ReadonlyDeep<{
type: typeof CALL_HISTORY_CLEAR;
}>;
export type CallHistoryAction = ReadonlyDeep<
CallHistoryCache | CallHistoryClear
>;
export function getEmptyState(): CallHistoryState {
return {
edition: 0,
callHistoryByCallId: {},
};
}
function cacheCallHistory(callHistory: CallHistoryDetails): CallHistoryCache {
return {
type: CALL_HISTORY_CACHE,
payload: callHistory,
};
}
function clearAllCallHistory(): ThunkAction<
void,
RootStateType,
unknown,
CallHistoryClear | ToastActionType
> {
return async dispatch => {
await clearCallHistoryDataAndSync();
dispatch({ type: CALL_HISTORY_CLEAR });
dispatch(showToast({ toastType: ToastType.CallHistoryCleared }));
};
}
export const actions = {
cacheCallHistory,
clearAllCallHistory,
};
export const useCallHistoryActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
export function reducer(
state: CallHistoryState = getEmptyState(),
action: CallHistoryAction
): CallHistoryState {
switch (action.type) {
case CALL_HISTORY_CLEAR:
return { ...state, edition: state.edition + 1, callHistoryByCallId: {} };
case CALL_HISTORY_CACHE:
return {
...state,
callHistoryByCallId: {
...state.callHistoryByCallId,
[action.payload.callId]: action.payload,
},
};
default:
return state;
}
}

View file

@ -3,7 +3,6 @@
import { ipcRenderer } from 'electron'; import { ipcRenderer } from 'electron';
import type { ThunkAction, ThunkDispatch } from 'redux-thunk'; import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { CallEndedReason } from '@signalapp/ringrtc';
import { import {
hasScreenCapturePermission, hasScreenCapturePermission,
openSystemPreferences, openSystemPreferences,
@ -26,6 +25,7 @@ import type {
PresentableSource, PresentableSource,
} from '../../types/Calling'; } from '../../types/Calling';
import { import {
CallEndedReason,
CallingDeviceType, CallingDeviceType,
CallMode, CallMode,
CallViewMode, CallViewMode,
@ -53,8 +53,6 @@ import { isDirectConversation } from '../../util/whatTypeOfConversation';
import { SHOW_TOAST } from './toast'; import { SHOW_TOAST } from './toast';
import { ToastType } from '../../types/Toast'; import { ToastType } from '../../types/Toast';
import type { ShowToastActionType } from './toast'; import type { ShowToastActionType } from './toast';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
import MessageSender from '../../textsecure/SendMessage';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions';
import { isAnybodyElseInGroupCall } from './callingHelpers'; import { isAnybodyElseInGroupCall } from './callingHelpers';
@ -150,15 +148,10 @@ export type AcceptCallType = ReadonlyDeep<{
}>; }>;
export type CallStateChangeType = ReadonlyDeep<{ export type CallStateChangeType = ReadonlyDeep<{
remoteUserId: string; // TODO: Remove
callId: string; // TODO: Remove
conversationId: string; conversationId: string;
acceptedTime?: number; acceptedTime?: number;
callState: CallState; callState: CallState;
callEndedReason?: CallEndedReason; callEndedReason?: CallEndedReason;
isIncoming: boolean;
isVideoCall: boolean;
title: string;
}>; }>;
export type CancelCallType = ReadonlyDeep<{ export type CancelCallType = ReadonlyDeep<{
@ -363,7 +356,7 @@ const doGroupCallPeek = (
// to only be peeking once. // to only be peeking once.
await Promise.all([sleep(1000), waitForOnline(navigator, window)]); await Promise.all([sleep(1000), waitForOnline(navigator, window)]);
let peekInfo; let peekInfo = null;
try { try {
peekInfo = await calling.peekGroupCall(conversationId); peekInfo = await calling.peekGroupCall(conversationId);
} catch (err) { } catch (err) {
@ -689,38 +682,18 @@ function callStateChange(
CallStateChangeFulfilledActionType CallStateChangeFulfilledActionType
> { > {
return async dispatch => { return async dispatch => {
const { const { callState, acceptedTime, callEndedReason } = payload;
callId,
callState,
isVideoCall,
isIncoming,
acceptedTime,
callEndedReason,
remoteUserId,
} = payload;
if (callState === CallState.Ended) { if (callState === CallState.Ended) {
ipcRenderer.send('close-screen-share-controller'); ipcRenderer.send('close-screen-share-controller');
} }
const isOutgoing = !isIncoming;
const wasAccepted = acceptedTime != null; const wasAccepted = acceptedTime != null;
const isConnected = callState === CallState.Accepted; // "connected"
const isEnded = callState === CallState.Ended && callEndedReason != null; const isEnded = callState === CallState.Ended && callEndedReason != null;
const isLocalHangup = callEndedReason === CallEndedReason.LocalHangup; const isLocalHangup = callEndedReason === CallEndedReason.LocalHangup;
const isRemoteHangup = callEndedReason === CallEndedReason.RemoteHangup; const isRemoteHangup = callEndedReason === CallEndedReason.RemoteHangup;
const answered = isConnected && wasAccepted;
const notAnswered = isEnded && !wasAccepted;
const isOutgoingRemoteAccept = isOutgoing && isConnected && answered;
const isIncomingLocalAccept = isIncoming && isConnected && answered;
const isOutgoingLocalHangup = isOutgoing && isLocalHangup && notAnswered;
const isIncomingLocalHangup = isIncoming && isLocalHangup && notAnswered;
const isOutgoingRemoteHangup = isOutgoing && isRemoteHangup && notAnswered;
const isIncomingRemoteHangup = isIncoming && isRemoteHangup && notAnswered;
// Play the hangup noise if: // Play the hangup noise if:
if ( if (
// 1. I hungup (or declined) // 1. I hungup (or declined)
@ -733,37 +706,6 @@ function callStateChange(
await callingTones.playEndCall(); await callingTones.playEndCall();
} }
if (isIncomingRemoteHangup) {
// This is considered just another "missed" event
log.info(
`callStateChange: not syncing hangup from self (Call ID: ${callId}))`
);
} else if (
isOutgoingRemoteAccept ||
isIncomingLocalAccept ||
isOutgoingLocalHangup ||
isIncomingLocalHangup ||
isOutgoingRemoteHangup
) {
log.info(`callStateChange: syncing call event (Call ID: ${callId})`);
try {
await singleProtoJobQueue.add(
MessageSender.getCallEventSync(
remoteUserId,
callId,
isVideoCall,
isIncoming,
acceptedTime != null
)
);
} catch (error) {
log.error(
'callStateChange: Failed to queue sync message',
Errors.toLogFormat(error)
);
}
}
dispatch({ dispatch({
type: CALL_STATE_CHANGE_FULFILLED, type: CALL_STATE_CHANGE_FULFILLED,
payload, payload,
@ -1326,10 +1268,12 @@ function onOutgoingVideoCallInConversation(
log.info( log.info(
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call' 'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
); );
startCallingLobby({ dispatch(
conversationId, startCallingLobby({
isVideoCall: true, conversationId,
})(dispatch, getState, undefined); isVideoCall: true,
})
);
log.info('onOutgoingVideoCallInConversation: started the call'); log.info('onOutgoingVideoCallInConversation: started the call');
} else { } else {
log.info( log.info(

View file

@ -159,6 +159,8 @@ import { ReceiptType } from '../../types/Receipt';
import { sortByMessageOrder } from '../../util/maybeForwardMessages'; import { sortByMessageOrder } from '../../util/maybeForwardMessages';
import { Sound, SoundType } from '../../util/Sound'; import { Sound, SoundType } from '../../util/Sound';
import { canEditMessage } from '../../util/canEditMessage'; import { canEditMessage } from '../../util/canEditMessage';
import type { ChangeNavTabActionType } from './nav';
import { CHANGE_NAV_TAB, NavTab, actions as navActions } from './nav';
// State // State
@ -3911,14 +3913,18 @@ function showConversation({
void, void,
RootStateType, RootStateType,
unknown, unknown,
TargetedConversationChangedActionType TargetedConversationChangedActionType | ChangeNavTabActionType
> { > {
return (dispatch, getState) => { return (dispatch, getState) => {
const { conversations } = getState(); const { conversations, nav } = getState();
if (nav.selectedNavTab !== NavTab.Chats) {
dispatch(navActions.changeNavTab(NavTab.Chats));
}
if (conversationId === conversations.selectedConversationId) { if (conversationId === conversations.selectedConversationId) {
if (conversationId && messageId) { if (conversationId && messageId) {
scrollToMessage(conversationId, messageId)(dispatch, getState, null); dispatch(scrollToMessage(conversationId, messageId));
} }
return; return;
@ -4383,7 +4389,11 @@ function maybeUpdateSelectedMessageForDetails(
export function reducer( export function reducer(
state: Readonly<ConversationsStateType> = getEmptyState(), state: Readonly<ConversationsStateType> = getEmptyState(),
action: Readonly<ConversationActionType | StoryDistributionListsActionType> action: Readonly<
| ConversationActionType
| StoryDistributionListsActionType
| ChangeNavTabActionType
>
): ConversationsStateType { ): ConversationsStateType {
if (action.type === CLEAR_CONVERSATIONS_PENDING_VERIFICATION) { if (action.type === CLEAR_CONVERSATIONS_PENDING_VERIFICATION) {
return { return {
@ -6168,5 +6178,31 @@ export function reducer(
}; };
} }
if (
action.type === CHANGE_NAV_TAB &&
action.payload.selectedNavTab === NavTab.Chats
) {
const { messagesByConversation, selectedConversationId } = state;
if (selectedConversationId == null) {
return state;
}
const existingConversation = messagesByConversation[selectedConversationId];
if (existingConversation == null) {
return state;
}
return {
...state,
messagesByConversation: {
...messagesByConversation,
[selectedConversationId]: {
...existingConversation,
isNearBottom: true,
},
},
};
}
return state; return state;
} }

View file

@ -25,7 +25,6 @@ import type { ConfigMapType as RemoteConfigType } from '../../RemoteConfig';
export type ItemsStateType = ReadonlyDeep< export type ItemsStateType = ReadonlyDeep<
{ {
[key: string]: unknown; [key: string]: unknown;
remoteConfig?: RemoteConfigType; remoteConfig?: RemoteConfigType;
serverTimeSkew?: number; serverTimeSkew?: number;
} & Partial< } & Partial<
@ -35,6 +34,7 @@ export type ItemsStateType = ReadonlyDeep<
| 'defaultConversationColor' | 'defaultConversationColor'
| 'customColors' | 'customColors'
| 'preferredLeftPaneWidth' | 'preferredLeftPaneWidth'
| 'navTabsCollapsed'
| 'preferredReactionEmoji' | 'preferredReactionEmoji'
| 'areWeASubscriber' | 'areWeASubscriber'
| 'usernameLinkColor' | 'usernameLinkColor'
@ -90,6 +90,7 @@ export const actions = {
resetDefaultChatColor, resetDefaultChatColor,
savePreferredLeftPaneWidth, savePreferredLeftPaneWidth,
setGlobalDefaultConversationColor, setGlobalDefaultConversationColor,
toggleNavTabsCollapse,
onSetSkinTone, onSetSkinTone,
putItem, putItem,
putItemExternal, putItemExternal,
@ -98,8 +99,9 @@ export const actions = {
resetItems, resetItems,
}; };
export const useActions = (): BoundActionCreatorsMapObject<typeof actions> => export const useItemsActions = (): BoundActionCreatorsMapObject<
useBoundActions(actions); typeof actions
> => useBoundActions(actions);
function putItem<K extends keyof StorageAccessType>( function putItem<K extends keyof StorageAccessType>(
key: K, key: K,
@ -292,6 +294,14 @@ function markHasCompletedSafetyNumberOnboarding(): ThunkAction<
}; };
} }
function toggleNavTabsCollapse(
navTabsCollapsed: boolean
): ThunkAction<void, RootStateType, unknown, ItemPutAction> {
return dispatch => {
dispatch(putItem('navTabsCollapsed', navTabsCollapsed));
};
}
// Reducer // Reducer
export function getEmptyState(): ItemsStateType { export function getEmptyState(): ItemsStateType {

69
ts/state/ducks/nav.ts Normal file
View file

@ -0,0 +1,69 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReadonlyDeep } from 'type-fest';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
// Types
export enum NavTab {
Chats = 'Chats',
Calls = 'Calls',
Stories = 'Stories',
}
// State
export type NavStateType = ReadonlyDeep<{
selectedNavTab: NavTab;
}>;
// Actions
export const CHANGE_NAV_TAB = 'nav/CHANGE_NAV_TAB';
export type ChangeNavTabActionType = ReadonlyDeep<{
type: typeof CHANGE_NAV_TAB;
payload: { selectedNavTab: NavTab };
}>;
export type NavActionType = ReadonlyDeep<ChangeNavTabActionType>;
// Action Creators
function changeNavTab(selectedNavTab: NavTab): NavActionType {
return {
type: CHANGE_NAV_TAB,
payload: { selectedNavTab },
};
}
export const actions = {
changeNavTab,
};
export const useNavActions = (): BoundActionCreatorsMapObject<typeof actions> =>
useBoundActions(actions);
// Reducer
export function getEmptyState(): NavStateType {
return {
selectedNavTab: NavTab.Chats,
};
}
export function reducer(
state: Readonly<NavStateType> = getEmptyState(),
action: Readonly<NavActionType>
): NavStateType {
if (action.type === CHANGE_NAV_TAB) {
return {
...state,
selectedNavTab: action.payload.selectedNavTab,
};
}
return state;
}

View file

@ -143,7 +143,6 @@ export type StoriesStateType = Readonly<{
addStoryData: AddStoryData; addStoryData: AddStoryData;
hasAllStoriesUnmuted: boolean; hasAllStoriesUnmuted: boolean;
lastOpenedAtTimestamp: number | undefined; lastOpenedAtTimestamp: number | undefined;
openedAtTimestamp: number | undefined;
replyState?: Readonly<{ replyState?: Readonly<{
messageId: string; messageId: string;
replies: Array<MessageAttributesType>; replies: Array<MessageAttributesType>;
@ -163,7 +162,8 @@ const QUEUE_STORY_DOWNLOAD = 'stories/QUEUE_STORY_DOWNLOAD';
const SEND_STORY_MODAL_OPEN_STATE_CHANGED = const SEND_STORY_MODAL_OPEN_STATE_CHANGED =
'stories/SEND_STORY_MODAL_OPEN_STATE_CHANGED'; 'stories/SEND_STORY_MODAL_OPEN_STATE_CHANGED';
const STORY_CHANGED = 'stories/STORY_CHANGED'; const STORY_CHANGED = 'stories/STORY_CHANGED';
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW'; const CLEAR_STORIES_TAB_STATE = 'stories/CLEAR_STORIES_TAB_STATE';
const MARK_STORIES_TAB_VIEWED = 'stories/MARK_STORIES_TAB_VIEWED';
const VIEW_STORY = 'stories/VIEW_STORY'; const VIEW_STORY = 'stories/VIEW_STORY';
const STORY_REPLY_DELETED = 'stories/STORY_REPLY_DELETED'; const STORY_REPLY_DELETED = 'stories/STORY_REPLY_DELETED';
const REMOVE_ALL_STORIES = 'stories/REMOVE_ALL_STORIES'; const REMOVE_ALL_STORIES = 'stories/REMOVE_ALL_STORIES';
@ -217,8 +217,12 @@ type StoryChangedActionType = ReadonlyDeep<{
payload: StoryDataType; payload: StoryDataType;
}>; }>;
type ToggleViewActionType = ReadonlyDeep<{ type ClearStoriesTabStateActionType = ReadonlyDeep<{
type: typeof TOGGLE_VIEW; type: typeof CLEAR_STORIES_TAB_STATE;
}>;
type MarkStoriesTabViewedActionType = ReadonlyDeep<{
type: typeof MARK_STORIES_TAB_VIEWED;
}>; }>;
type ViewStoryActionType = ReadonlyDeep<{ type ViewStoryActionType = ReadonlyDeep<{
@ -262,7 +266,8 @@ export type StoriesActionType =
| QueueStoryDownloadActionType | QueueStoryDownloadActionType
| SendStoryModalOpenStateChanged | SendStoryModalOpenStateChanged
| StoryChangedActionType | StoryChangedActionType
| ToggleViewActionType | ClearStoriesTabStateActionType
| MarkStoriesTabViewedActionType
| ViewStoryActionType | ViewStoryActionType
| StoryReplyDeletedActionType | StoryReplyDeletedActionType
| RemoveAllStoriesActionType | RemoveAllStoriesActionType
@ -627,7 +632,7 @@ function sendStoryMessage(
> { > {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const { stories } = getState(); const { stories } = getState();
const { openedAtTimestamp, sendStoryModalData } = stories; const { lastOpenedAtTimestamp, sendStoryModalData } = stories;
// Add spinners in the story creator // Add spinners in the story creator
dispatch({ dispatch({
@ -636,8 +641,8 @@ function sendStoryMessage(
}); });
assertDev( assertDev(
openedAtTimestamp, lastOpenedAtTimestamp,
'sendStoryMessage: openedAtTimestamp is undefined, cannot send' 'sendStoryMessage: lastOpenedAtTimestamp is undefined, cannot send'
); );
assertDev( assertDev(
sendStoryModalData, sendStoryModalData,
@ -649,7 +654,7 @@ function sendStoryMessage(
const result = await blockSendUntilConversationsAreVerified( const result = await blockSendUntilConversationsAreVerified(
sendStoryModalData, sendStoryModalData,
SafetyNumberChangeSource.Story, SafetyNumberChangeSource.Story,
Date.now() - openedAtTimestamp Date.now() - lastOpenedAtTimestamp
); );
if (!result) { if (!result) {
@ -720,9 +725,15 @@ function sendStoryModalOpenStateChanged(
}; };
} }
function toggleStoriesView(): ToggleViewActionType { function clearStoriesTabState(): ClearStoriesTabStateActionType {
return { return {
type: TOGGLE_VIEW, type: CLEAR_STORIES_TAB_STATE,
};
}
function markStoriesTabViewed(): MarkStoriesTabViewedActionType {
return {
type: MARK_STORIES_TAB_VIEWED,
}; };
} }
@ -1415,7 +1426,8 @@ export const actions = {
sendStoryMessage, sendStoryMessage,
sendStoryModalOpenStateChanged, sendStoryModalOpenStateChanged,
storyChanged, storyChanged,
toggleStoriesView, clearStoriesTabState,
markStoriesTabViewed,
verifyStoryListMembers, verifyStoryListMembers,
viewUserStories, viewUserStories,
viewStory, viewStory,
@ -1439,7 +1451,6 @@ export function getEmptyState(
): StoriesStateType { ): StoriesStateType {
return { return {
lastOpenedAtTimestamp: undefined, lastOpenedAtTimestamp: undefined,
openedAtTimestamp: undefined,
addStoryData: undefined, addStoryData: undefined,
stories: [], stories: [],
hasAllStoriesUnmuted: false, hasAllStoriesUnmuted: false,
@ -1451,20 +1462,22 @@ export function reducer(
state: Readonly<StoriesStateType> = getEmptyState(), state: Readonly<StoriesStateType> = getEmptyState(),
action: Readonly<StoriesActionType> action: Readonly<StoriesActionType>
): StoriesStateType { ): StoriesStateType {
if (action.type === TOGGLE_VIEW) { if (action.type === MARK_STORIES_TAB_VIEWED) {
const isShowingStoriesView = Boolean(state.openedAtTimestamp);
return { return {
...state, ...state,
lastOpenedAtTimestamp: !isShowingStoriesView lastOpenedAtTimestamp: Date.now(),
? state.openedAtTimestamp || Date.now()
: state.lastOpenedAtTimestamp,
openedAtTimestamp: isShowingStoriesView ? undefined : Date.now(),
replyState: undefined, replyState: undefined,
sendStoryModalData: undefined, sendStoryModalData: undefined,
selectedStoryData: isShowingStoriesView };
? undefined }
: state.selectedStoryData,
if (action.type === CLEAR_STORIES_TAB_STATE) {
return {
...state,
replyState: undefined,
sendStoryModalData: undefined,
selectedStoryData: undefined,
addStoryData: undefined,
}; };
} }
@ -1851,8 +1864,6 @@ export function reducer(
if (action.type === TARGETED_CONVERSATION_CHANGED) { if (action.type === TARGETED_CONVERSATION_CHANGED) {
return { return {
...state, ...state,
lastOpenedAtTimestamp: state.openedAtTimestamp || Date.now(),
openedAtTimestamp: undefined,
replyState: undefined, replyState: undefined,
sendStoryModalData: undefined, sendStoryModalData: undefined,
selectedStoryData: undefined, selectedStoryData: undefined,

View file

@ -5,6 +5,7 @@ import { getEmptyState as accounts } from './ducks/accounts';
import { getEmptyState as app } from './ducks/app'; import { getEmptyState as app } from './ducks/app';
import { getEmptyState as audioPlayer } from './ducks/audioPlayer'; import { getEmptyState as audioPlayer } from './ducks/audioPlayer';
import { getEmptyState as audioRecorder } from './ducks/audioRecorder'; import { getEmptyState as audioRecorder } from './ducks/audioRecorder';
import { getEmptyState as callHistory } from './ducks/callHistory';
import { getEmptyState as calling } from './ducks/calling'; import { getEmptyState as calling } from './ducks/calling';
import { getEmptyState as composer } from './ducks/composer'; import { getEmptyState as composer } from './ducks/composer';
import { getEmptyState as conversations } from './ducks/conversations'; import { getEmptyState as conversations } from './ducks/conversations';
@ -15,6 +16,7 @@ import { getEmptyState as inbox } from './ducks/inbox';
import { getEmptyState as lightbox } from './ducks/lightbox'; import { getEmptyState as lightbox } from './ducks/lightbox';
import { getEmptyState as linkPreviews } from './ducks/linkPreviews'; import { getEmptyState as linkPreviews } from './ducks/linkPreviews';
import { getEmptyState as mediaGallery } from './ducks/mediaGallery'; import { getEmptyState as mediaGallery } from './ducks/mediaGallery';
import { getEmptyState as nav } from './ducks/nav';
import { getEmptyState as network } from './ducks/network'; import { getEmptyState as network } from './ducks/network';
import { getEmptyState as preferredReactions } from './ducks/preferredReactions'; import { getEmptyState as preferredReactions } from './ducks/preferredReactions';
import { getEmptyState as safetyNumber } from './ducks/safetyNumber'; import { getEmptyState as safetyNumber } from './ducks/safetyNumber';
@ -39,15 +41,18 @@ import { getInitialState as stickers } from '../types/Stickers';
import { getThemeType } from '../util/getThemeType'; import { getThemeType } from '../util/getThemeType';
import { getInteractionMode } from '../services/InteractionMode'; import { getInteractionMode } from '../services/InteractionMode';
import { makeLookup } from '../util/makeLookup'; import { makeLookup } from '../util/makeLookup';
import type { CallHistoryDetails } from '../types/CallDisposition';
export function getInitialState({ export function getInitialState({
badges, badges,
callsHistory,
stories, stories,
storyDistributionLists, storyDistributionLists,
mainWindowStats, mainWindowStats,
menuOptions, menuOptions,
}: { }: {
badges: BadgesStateType; badges: BadgesStateType;
callsHistory: ReadonlyArray<CallHistoryDetails>;
stories: Array<StoryDataType>; stories: Array<StoryDataType>;
storyDistributionLists: Array<StoryDistributionListDataType>; storyDistributionLists: Array<StoryDistributionListDataType>;
mainWindowStats: MainWindowStatsType; mainWindowStats: MainWindowStatsType;
@ -88,6 +93,10 @@ export function getInitialState({
audioPlayer: audioPlayer(), audioPlayer: audioPlayer(),
audioRecorder: audioRecorder(), audioRecorder: audioRecorder(),
badges, badges,
callHistory: {
...callHistory(),
callHistoryByCallId: makeLookup(callsHistory, 'callId'),
},
calling: calling(), calling: calling(),
composer: composer(), composer: composer(),
conversations: { conversations: {
@ -110,6 +119,7 @@ export function getInitialState({
lightbox: lightbox(), lightbox: lightbox(),
linkPreviews: linkPreviews(), linkPreviews: linkPreviews(),
mediaGallery: mediaGallery(), mediaGallery: mediaGallery(),
nav: nav(),
network: network(), network: network(),
preferredReactions: preferredReactions(), preferredReactions: preferredReactions(),
safetyNumber: safetyNumber(), safetyNumber: safetyNumber(),

View file

@ -9,6 +9,7 @@ import { reducer as audioPlayer } from './ducks/audioPlayer';
import { reducer as audioRecorder } from './ducks/audioRecorder'; import { reducer as audioRecorder } from './ducks/audioRecorder';
import { reducer as badges } from './ducks/badges'; import { reducer as badges } from './ducks/badges';
import { reducer as calling } from './ducks/calling'; import { reducer as calling } from './ducks/calling';
import { reducer as callHistory } from './ducks/callHistory';
import { reducer as composer } from './ducks/composer'; import { reducer as composer } from './ducks/composer';
import { reducer as conversations } from './ducks/conversations'; import { reducer as conversations } from './ducks/conversations';
import { reducer as crashReports } from './ducks/crashReports'; import { reducer as crashReports } from './ducks/crashReports';
@ -20,6 +21,7 @@ import { reducer as items } from './ducks/items';
import { reducer as lightbox } from './ducks/lightbox'; import { reducer as lightbox } from './ducks/lightbox';
import { reducer as linkPreviews } from './ducks/linkPreviews'; import { reducer as linkPreviews } from './ducks/linkPreviews';
import { reducer as mediaGallery } from './ducks/mediaGallery'; import { reducer as mediaGallery } from './ducks/mediaGallery';
import { reducer as nav } from './ducks/nav';
import { reducer as network } from './ducks/network'; import { reducer as network } from './ducks/network';
import { reducer as preferredReactions } from './ducks/preferredReactions'; import { reducer as preferredReactions } from './ducks/preferredReactions';
import { reducer as safetyNumber } from './ducks/safetyNumber'; import { reducer as safetyNumber } from './ducks/safetyNumber';
@ -39,6 +41,7 @@ export const reducer = combineReducers({
audioRecorder, audioRecorder,
badges, badges,
calling, calling,
callHistory,
composer, composer,
conversations, conversations,
crashReports, crashReports,
@ -50,6 +53,7 @@ export const reducer = combineReducers({
lightbox, lightbox,
linkPreviews, linkPreviews,
mediaGallery, mediaGallery,
nav,
network, network,
preferredReactions, preferredReactions,
safetyNumber, safetyNumber,

View file

@ -0,0 +1,31 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import type { CallHistoryState } from '../ducks/callHistory';
import type { StateType } from '../reducer';
import type { CallHistoryDetails } from '../../types/CallDisposition';
import { getOwn } from '../../util/getOwn';
const getCallHistory = (state: StateType): CallHistoryState =>
state.callHistory;
export const getCallHistoryEdition = createSelector(
getCallHistory,
callHistory => {
return callHistory.edition;
}
);
export type CallHistorySelectorType = (
callId: string
) => CallHistoryDetails | void;
export const getCallHistorySelector = createSelector(
getCallHistory,
(callHistory): CallHistorySelectorType => {
return callId => {
return getOwn(callHistory.callHistoryByCallId, callId);
};
}
);

View file

@ -308,16 +308,18 @@ export const getConversationComparator = createSelector(
_getConversationComparator _getConversationComparator
); );
type LeftPaneLists = Readonly<{
conversations: ReadonlyArray<ConversationType>;
archivedConversations: ReadonlyArray<ConversationType>;
pinnedConversations: ReadonlyArray<ConversationType>;
}>;
export const _getLeftPaneLists = ( export const _getLeftPaneLists = (
lookup: ConversationLookupType, lookup: ConversationLookupType,
comparator: (left: ConversationType, right: ConversationType) => number, comparator: (left: ConversationType, right: ConversationType) => number,
selectedConversation?: string, selectedConversation?: string,
pinnedConversationIds?: ReadonlyArray<string> pinnedConversationIds?: ReadonlyArray<string>
): { ): LeftPaneLists => {
conversations: Array<ConversationType>;
archivedConversations: Array<ConversationType>;
pinnedConversations: Array<ConversationType>;
} => {
const conversations: Array<ConversationType> = []; const conversations: Array<ConversationType> = [];
const archivedConversations: Array<ConversationType> = []; const archivedConversations: Array<ConversationType> = [];
const pinnedConversations: Array<ConversationType> = []; const pinnedConversations: Array<ConversationType> = [];
@ -529,6 +531,40 @@ export const getAllGroupsWithInviteAccess = createSelector(
}) })
); );
export type UnreadStats = Readonly<{
unreadCount: number;
unreadMentionsCount: number;
markedUnread: boolean;
}>;
export const getAllConversationsUnreadStats = createSelector(
getLeftPaneLists,
(leftPaneLists: LeftPaneLists): UnreadStats => {
let unreadCount = 0;
let unreadMentionsCount = 0;
let markedUnread = false;
function count(conversations: ReadonlyArray<ConversationType>) {
conversations.forEach(conversation => {
if (conversation.unreadCount != null) {
unreadCount += conversation.unreadCount;
}
if (conversation.unreadMentionsCount != null) {
unreadMentionsCount += conversation.unreadMentionsCount;
}
if (conversation.markedUnread) {
markedUnread = true;
}
});
}
count(leftPaneLists.pinnedConversations);
count(leftPaneLists.conversations);
return { unreadCount, unreadMentionsCount, markedUnread };
}
);
/** /**
* getComposableContacts/getCandidateContactsForNewGroup both return contacts for the * getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
* composer and group members, a different list from your primary system contacts. * composer and group members, a different list from your primary system contacts.

View file

@ -312,3 +312,8 @@ export const getTextFormattingEnabled = createSelector(
getItems, getItems,
(state: ItemsStateType): boolean => Boolean(state.textFormatting ?? true) (state: ItemsStateType): boolean => Boolean(state.textFormatting ?? true)
); );
export const getNavTabsCollapsed = createSelector(
getItems,
(state: ItemsStateType): boolean => Boolean(state.navTabsCollapsed ?? false)
);

View file

@ -54,14 +54,13 @@ import { BodyRange, hydrateRanges } from '../../types/BodyRange';
import type { AssertProps } from '../../types/Util'; import type { AssertProps } from '../../types/Util';
import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import { getMentionsRegex } from '../../types/Message'; import { getMentionsRegex } from '../../types/Message';
import { CallMode } from '../../types/Calling';
import { SignalService as Proto } from '../../protobuf'; import { SignalService as Proto } from '../../protobuf';
import type { AttachmentType } from '../../types/Attachment'; import type { AttachmentType } from '../../types/Attachment';
import { isVoiceMessage, canBeDownloaded } from '../../types/Attachment'; import { isVoiceMessage, canBeDownloaded } from '../../types/Attachment';
import { ReadStatus } from '../../messages/MessageReadStatus'; import { ReadStatus } from '../../messages/MessageReadStatus';
import type { CallingNotificationType } from '../../util/callingNotification'; import type { CallingNotificationType } from '../../util/callingNotification';
import { missingCaseError } from '../../util/missingCaseError'; import { CallExternalState } from '../../util/callingNotification';
import { getRecipients } from '../../util/getRecipients'; import { getRecipients } from '../../util/getRecipients';
import { getOwn } from '../../util/getOwn'; import { getOwn } from '../../util/getOwn';
import { isNotNil } from '../../util/isNotNil'; import { isNotNil } from '../../util/isNotNil';
@ -128,6 +127,9 @@ import type { AnyPaymentEvent } from '../../types/Payment';
import { isPaymentNotificationEvent } from '../../types/Payment'; import { isPaymentNotificationEvent } from '../../types/Payment';
import { getTitleNoDefault, getNumber } from '../../util/getTitle'; import { getTitleNoDefault, getNumber } from '../../util/getTitle';
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp'; import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp';
import type { CallHistorySelectorType } from './callHistory';
import { CallMode } from '../../types/Calling';
import { CallDirection } from '../../types/CallDisposition';
export { isIncoming, isOutgoing, isStory }; export { isIncoming, isOutgoing, isStory };
@ -166,6 +168,7 @@ export type GetPropsForBubbleOptions = Readonly<{
selectedMessageIds: ReadonlyArray<string> | undefined; selectedMessageIds: ReadonlyArray<string> | undefined;
regionCode?: string; regionCode?: string;
callSelector: CallSelectorType; callSelector: CallSelectorType;
callHistorySelector: CallHistorySelectorType;
activeCall?: CallStateType; activeCall?: CallStateType;
accountSelector: AccountSelectorType; accountSelector: AccountSelectorType;
contactNameColorSelector: ContactNameColorSelectorType; contactNameColorSelector: ContactNameColorSelectorType;
@ -1307,69 +1310,79 @@ export function isCallHistory(message: MessageWithUIFieldsType): boolean {
export type GetPropsForCallHistoryOptions = Pick< export type GetPropsForCallHistoryOptions = Pick<
GetPropsForBubbleOptions, GetPropsForBubbleOptions,
'conversationSelector' | 'callSelector' | 'activeCall' | 'callSelector'
| 'activeCall'
| 'callHistorySelector'
| 'conversationSelector'
| 'ourConversationId'
>; >;
export function getPropsForCallHistory( export function getPropsForCallHistory(
message: MessageWithUIFieldsType, message: MessageWithUIFieldsType,
{ {
conversationSelector,
callSelector, callSelector,
callHistorySelector,
activeCall, activeCall,
conversationSelector,
ourConversationId,
}: GetPropsForCallHistoryOptions }: GetPropsForCallHistoryOptions
): CallingNotificationType { ): CallingNotificationType {
const { callHistoryDetails } = message; const { callId } = message;
if (!callHistoryDetails) { strictAssert(callId != null, 'getPropsForCallHistory: Missing callId');
throw new Error('getPropsForCallHistory: Missing callHistoryDetails'); const callHistory = callHistorySelector(callId);
strictAssert(
callHistory != null,
'getPropsForCallHistory: Missing callHistory'
);
const conversation = conversationSelector(callHistory.peerId);
strictAssert(
conversation != null,
'getPropsForCallHistory: Missing conversation'
);
let callCreator: ConversationType | null = null;
if (callHistory.ringerId) {
callCreator = conversationSelector(callHistory.ringerId);
} else if (callHistory.direction === CallDirection.Outgoing) {
callCreator = conversationSelector(ourConversationId);
} }
const activeCallConversationId = activeCall?.conversationId; const call = callSelector(callHistory.callId);
switch (callHistoryDetails.callMode) { let deviceCount = 0;
// Old messages weren't saved with a call mode. let maxDevices = Infinity;
case undefined: if (
case CallMode.Direct: call?.callMode === CallMode.Group &&
return { call.peekInfo?.deviceCount != null &&
...callHistoryDetails, call.peekInfo?.maxDevices != null
activeCallConversationId, ) {
callMode: CallMode.Direct, deviceCount = call.peekInfo.deviceCount;
}; maxDevices = call.peekInfo.maxDevices;
case CallMode.Group: { }
const { conversationId } = message;
if (!conversationId) {
throw new Error('getPropsForCallHistory: missing conversation ID');
}
let call = callSelector(conversationId); let callExternalState: CallExternalState;
if (call && call.callMode !== CallMode.Group) { if (call == null || deviceCount === 0) {
log.error( callExternalState = CallExternalState.Ended;
'getPropsForCallHistory: there is an unexpected non-group call; pretending it does not exist' } else if (activeCall != null) {
); if (activeCall.conversationId === call.conversationId) {
call = undefined; callExternalState = CallExternalState.Joined;
} } else {
callExternalState = CallExternalState.InOtherCall;
const creator = conversationSelector(callHistoryDetails.creatorUuid);
const deviceCount = call?.peekInfo?.deviceCount ?? 0;
return {
activeCallConversationId,
callMode: CallMode.Group,
conversationId,
creator,
deviceCount,
ended:
callHistoryDetails.eraId !== call?.peekInfo?.eraId || !deviceCount,
maxDevices: call?.peekInfo?.maxDevices ?? Infinity,
startedTime: callHistoryDetails.startedTime,
};
} }
default: } else if (deviceCount >= maxDevices) {
throw new Error( callExternalState = CallExternalState.Full;
`getPropsForCallHistory: missing case ${missingCaseError( } else {
callHistoryDetails callExternalState = CallExternalState.Active;
)}`
);
} }
return {
callHistory,
callCreator,
callExternalState,
deviceCount,
maxDevices,
};
} }
// Profile Change // Profile Change

14
ts/state/selectors/nav.ts Normal file
View file

@ -0,0 +1,14 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import type { StateType } from '../reducer';
import type { NavStateType } from '../ducks/nav';
function getNav(state: StateType): NavStateType {
return state.nav;
}
export const getSelectedNavTab = createSelector(getNav, nav => {
return nav.selectedNavTab;
});

View file

@ -47,11 +47,6 @@ import { BodyRange, hydrateRanges } from '../../types/BodyRange';
export const getStoriesState = (state: StateType): StoriesStateType => export const getStoriesState = (state: StateType): StoriesStateType =>
state.stories; state.stories;
export const shouldShowStoriesView = createSelector(
getStoriesState,
({ openedAtTimestamp }): boolean => Boolean(openedAtTimestamp)
);
export const hasSelectedStoryData = createSelector( export const hasSelectedStoryData = createSelector(
getStoriesState, getStoriesState,
({ selectedStoryData }): boolean => Boolean(selectedStoryData) ({ selectedStoryData }): boolean => Boolean(selectedStoryData)

View file

@ -21,6 +21,7 @@ import {
} from './user'; } from './user';
import { getActiveCall, getCallSelector } from './calling'; import { getActiveCall, getCallSelector } from './calling';
import { getPropsForBubble } from './message'; import { getPropsForBubble } from './message';
import { getCallHistorySelector } from './callHistory';
export const getTimelineItem = ( export const getTimelineItem = (
state: StateType, state: StateType,
@ -45,6 +46,7 @@ export const getTimelineItem = (
const ourPNI = getUserPNI(state); const ourPNI = getUserPNI(state);
const ourConversationId = getUserConversationId(state); const ourConversationId = getUserConversationId(state);
const callSelector = getCallSelector(state); const callSelector = getCallSelector(state);
const callHistorySelector = getCallHistorySelector(state);
const activeCall = getActiveCall(state); const activeCall = getActiveCall(state);
const accountSelector = getAccountSelector(state); const accountSelector = getAccountSelector(state);
const contactNameColorSelector = getContactNameColorSelector(state); const contactNameColorSelector = getContactNameColorSelector(state);
@ -61,6 +63,7 @@ export const getTimelineItem = (
targetedMessageCounter: targetedMessage?.counter, targetedMessageCounter: targetedMessage?.counter,
contactNameColorSelector, contactNameColorSelector,
callSelector, callSelector,
callHistorySelector,
activeCall, activeCall,
accountSelector, accountSelector,
selectedMessageIds, selectedMessageIds,

View file

@ -11,7 +11,6 @@ import OS from '../../util/os/osMain';
import { SmartCallManager } from './CallManager'; import { SmartCallManager } from './CallManager';
import { SmartGlobalModalContainer } from './GlobalModalContainer'; import { SmartGlobalModalContainer } from './GlobalModalContainer';
import { SmartLightbox } from './Lightbox'; import { SmartLightbox } from './Lightbox';
import { SmartStories } from './Stories';
import { SmartStoryViewer } from './StoryViewer'; import { SmartStoryViewer } from './StoryViewer';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import { import {
@ -22,10 +21,7 @@ import {
getIsMainWindowFullScreen, getIsMainWindowFullScreen,
getMenuOptions, getMenuOptions,
} from '../selectors/user'; } from '../selectors/user';
import { import { hasSelectedStoryData } from '../selectors/stories';
hasSelectedStoryData,
shouldShowStoriesView,
} from '../selectors/stories';
import { getHideMenuBar } from '../selectors/items'; import { getHideMenuBar } from '../selectors/items';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
import { ErrorBoundary } from '../../components/ErrorBoundary'; import { ErrorBoundary } from '../../components/ErrorBoundary';
@ -57,12 +53,6 @@ const mapStateToProps = (state: StateType) => {
), ),
renderGlobalModalContainer: () => <SmartGlobalModalContainer />, renderGlobalModalContainer: () => <SmartGlobalModalContainer />,
renderLightbox: () => <SmartLightbox />, renderLightbox: () => <SmartLightbox />,
isShowingStoriesView: shouldShowStoriesView(state),
renderStories: (closeView: () => unknown) => (
<ErrorBoundary name="App/renderStories" closeView={closeView}>
<SmartStories />
</ErrorBoundary>
),
hasSelectedStoryData: hasSelectedStoryData(state), hasSelectedStoryData: hasSelectedStoryData(state),
renderStoryViewer: (closeView: () => unknown) => ( renderStoryViewer: (closeView: () => unknown) => (
<ErrorBoundary name="App/renderStoryViewer" closeView={closeView}> <ErrorBoundary name="App/renderStoryViewer" closeView={closeView}>

170
ts/state/smart/CallsTab.tsx Normal file
View file

@ -0,0 +1,170 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { useItemsActions } from '../ducks/items';
import {
getNavTabsCollapsed,
getPreferredLeftPaneWidth,
} from '../selectors/items';
import { getIntl, getRegionCode } from '../selectors/user';
import { CallsTab } from '../../components/CallsTab';
import {
getAllConversations,
getConversationSelector,
} from '../selectors/conversations';
import { filterAndSortConversationsByRecent } from '../../util/filterAndSortConversations';
import type {
CallHistoryFilter,
CallHistoryFilterOptions,
CallHistoryGroup,
CallHistoryPagination,
} from '../../types/CallDisposition';
import type { ConversationType } from '../ducks/conversations';
import { SmartConversationDetails } from './ConversationDetails';
import { useCallingActions } from '../ducks/calling';
import { getActiveCallState } from '../selectors/calling';
import { useCallHistoryActions } from '../ducks/callHistory';
import { getCallHistoryEdition } from '../selectors/callHistory';
import * as log from '../../logging/log';
function getCallHistoryFilter(
allConversations: Array<ConversationType>,
regionCode: string | undefined,
options: CallHistoryFilterOptions
): CallHistoryFilter | null {
const query = options.query.normalize().trim();
if (query !== '') {
const currentConversations = allConversations.filter(conversation => {
return conversation.removalStage == null;
});
const filteredConversations = filterAndSortConversationsByRecent(
currentConversations,
query,
regionCode
);
// If there are no matching conversations, then no calls will match.
if (filteredConversations.length === 0) {
return null;
}
return {
status: options.status,
conversationIds: filteredConversations.map(conversation => {
return conversation.id;
}),
};
}
return {
status: options.status,
conversationIds: null,
};
}
function renderConversationDetails(
conversationId: string,
callHistoryGroup: CallHistoryGroup | null
): JSX.Element {
return (
<SmartConversationDetails
conversationId={conversationId}
callHistoryGroup={callHistoryGroup}
/>
);
}
export function SmartCallsTab(): JSX.Element {
const i18n = useSelector(getIntl);
const navTabsCollapsed = useSelector(getNavTabsCollapsed);
const preferredLeftPaneWidth = useSelector(getPreferredLeftPaneWidth);
const { savePreferredLeftPaneWidth, toggleNavTabsCollapse } =
useItemsActions();
const allConversations = useSelector(getAllConversations);
const regionCode = useSelector(getRegionCode);
const getConversation = useSelector(getConversationSelector);
const activeCall = useSelector(getActiveCallState);
const callHistoryEdition = useSelector(getCallHistoryEdition);
const {
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
} = useCallingActions();
const { clearAllCallHistory: clearCallHistory } = useCallHistoryActions();
const getCallHistoryGroupsCount = useCallback(
async (options: CallHistoryFilterOptions) => {
// Informs us if the call history has changed
log.info('getCallHistoryGroupsCount: edition', callHistoryEdition);
const callHistoryFilter = getCallHistoryFilter(
allConversations,
regionCode,
options
);
if (callHistoryFilter == null) {
return 0;
}
const count = await window.Signal.Data.getCallHistoryGroupsCount(
callHistoryFilter
);
log.info('getCallHistoryGroupsCount: count', count, callHistoryFilter);
return count;
},
[allConversations, regionCode, callHistoryEdition]
);
const getCallHistoryGroups = useCallback(
async (
options: CallHistoryFilterOptions,
pagination: CallHistoryPagination
) => {
// Informs us if the call history has changed
log.info('getCallHistoryGroups: edition', callHistoryEdition);
const callHistoryFilter = getCallHistoryFilter(
allConversations,
regionCode,
options
);
if (callHistoryFilter == null) {
return [];
}
const results = await window.Signal.Data.getCallHistoryGroups(
callHistoryFilter,
pagination
);
log.info(
'getCallHistoryGroupsCount: results',
results,
callHistoryFilter
);
return results;
},
[allConversations, regionCode, callHistoryEdition]
);
return (
<CallsTab
activeCall={activeCall}
allConversations={allConversations}
getConversation={getConversation}
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
getCallHistoryGroups={getCallHistoryGroups}
i18n={i18n}
navTabsCollapsed={navTabsCollapsed}
onClearCallHistory={clearCallHistory}
onToggleNavTabsCollapse={toggleNavTabsCollapse}
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
preferredLeftPaneWidth={preferredLeftPaneWidth}
renderConversationDetails={renderConversationDetails}
regionCode={regionCode}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
/>
);
}

151
ts/state/smart/ChatsTab.tsx Normal file
View file

@ -0,0 +1,151 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { ChatsTab } from '../../components/ChatsTab';
import { SmartConversationView } from './ConversationView';
import { SmartMiniPlayer } from './MiniPlayer';
import { SmartLeftPane } from './LeftPane';
import type { NavTabPanelProps } from '../../components/NavTabs';
import { useGlobalModalActions } from '../ducks/globalModals';
import { getIntl } from '../selectors/user';
import { usePrevious } from '../../hooks/usePrevious';
import { TargetedMessageSource } from '../ducks/conversationsEnums';
import type { ConversationsStateType } from '../ducks/conversations';
import { useConversationsActions } from '../ducks/conversations';
import type { StateType } from '../reducer';
import { strictAssert } from '../../util/assert';
import { showToast } from '../../util/showToast';
import { ToastStickerPackInstallFailed } from '../../components/ToastStickerPackInstallFailed';
import { getNavTabsCollapsed } from '../selectors/items';
import { useItemsActions } from '../ducks/items';
function renderConversationView() {
return <SmartConversationView />;
}
function renderLeftPane(props: NavTabPanelProps) {
return <SmartLeftPane {...props} />;
}
function renderMiniPlayer(options: { shouldFlow: boolean }) {
return <SmartMiniPlayer {...options} />;
}
export function SmartChatsTab(): JSX.Element {
const i18n = useSelector(getIntl);
const navTabsCollapsed = useSelector(getNavTabsCollapsed);
const { selectedConversationId, targetedMessage, targetedMessageSource } =
useSelector<StateType, ConversationsStateType>(
state => state.conversations
);
const {
onConversationClosed,
onConversationOpened,
scrollToMessage,
showConversation,
} = useConversationsActions();
const { showWhatsNewModal } = useGlobalModalActions();
const { toggleNavTabsCollapse } = useItemsActions();
const prevConversationId = usePrevious(
selectedConversationId,
selectedConversationId
);
useEffect(() => {
if (prevConversationId !== selectedConversationId) {
if (prevConversationId) {
onConversationClosed(prevConversationId, 'opened another conversation');
}
if (selectedConversationId) {
onConversationOpened(selectedConversationId, targetedMessage);
}
} else if (
selectedConversationId &&
targetedMessage &&
targetedMessageSource !== TargetedMessageSource.Focus
) {
scrollToMessage(selectedConversationId, targetedMessage);
}
if (!selectedConversationId) {
return;
}
const conversation = window.ConversationController.get(
selectedConversationId
);
strictAssert(conversation, 'Conversation must be found');
conversation.setMarkedUnread(false);
}, [
onConversationClosed,
onConversationOpened,
prevConversationId,
scrollToMessage,
selectedConversationId,
targetedMessage,
targetedMessageSource,
]);
useEffect(() => {
function refreshConversation({
newId,
oldId,
}: {
newId: string;
oldId: string;
}) {
if (prevConversationId === oldId) {
showConversation({ conversationId: newId });
}
}
// Close current opened conversation to reload the group information once
// linked.
function unload() {
if (!prevConversationId) {
return;
}
onConversationClosed(prevConversationId, 'force unload requested');
}
function packInstallFailed() {
showToast(ToastStickerPackInstallFailed);
}
window.Whisper.events.on('pack-install-failed', packInstallFailed);
window.Whisper.events.on('refreshConversation', refreshConversation);
window.Whisper.events.on('setupAsNewDevice', unload);
return () => {
window.Whisper.events.off('pack-install-failed', packInstallFailed);
window.Whisper.events.off('refreshConversation', refreshConversation);
window.Whisper.events.off('setupAsNewDevice', unload);
};
}, [onConversationClosed, prevConversationId, showConversation]);
useEffect(() => {
if (!selectedConversationId) {
window.SignalCI?.handleEvent('empty-inbox:rendered', null);
}
}, [selectedConversationId]);
return (
<ChatsTab
i18n={i18n}
navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={toggleNavTabsCollapse}
prevConversationId={prevConversationId}
renderConversationView={renderConversationView}
renderLeftPane={renderLeftPane}
renderMiniPlayer={renderMiniPlayer}
selectedConversationId={selectedConversationId}
showWhatsNewModal={showWhatsNewModal}
/>
);
}

View file

@ -7,7 +7,7 @@ import type { CompositionTextAreaProps } from '../../components/CompositionTextA
import { CompositionTextArea } from '../../components/CompositionTextArea'; import { CompositionTextArea } from '../../components/CompositionTextArea';
import { getIntl, getPlatform } from '../selectors/user'; import { getIntl, getPlatform } from '../selectors/user';
import { useActions as useEmojiActions } from '../ducks/emojis'; import { useActions as useEmojiActions } from '../ducks/emojis';
import { useActions as useItemsActions } from '../ducks/items'; import { useItemsActions } from '../ducks/items';
import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredBadgeSelector } from '../selectors/badges';
import { useComposerActions } from '../ducks/composer'; import { useComposerActions } from '../ducks/composer';
import { import {

View file

@ -33,9 +33,12 @@ import {
getGroupSizeRecommendedLimit, getGroupSizeRecommendedLimit,
getGroupSizeHardLimit, getGroupSizeHardLimit,
} from '../../groups/limits'; } from '../../groups/limits';
import type { CallHistoryGroup } from '../../types/CallDisposition';
import { getSelectedNavTab } from '../selectors/nav';
export type SmartConversationDetailsProps = { export type SmartConversationDetailsProps = {
conversationId: string; conversationId: string;
callHistoryGroup?: CallHistoryGroup | null;
}; };
const ACCESS_ENUM = Proto.AccessControl.AccessRequired; const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
@ -96,6 +99,7 @@ const mapStateToProps = (
const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151); const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151);
return { return {
...props, ...props,
areWeASubscriber: getAreWeASubscriber(state), areWeASubscriber: getAreWeASubscriber(state),
badges, badges,
canEditGroupInfo, canEditGroupInfo,
@ -115,6 +119,7 @@ const mapStateToProps = (
hasGroupLink, hasGroupLink,
groupsInCommon: groupsInCommonSorted, groupsInCommon: groupsInCommonSorted,
isGroup: conversation.type === 'group', isGroup: conversation.type === 'group',
selectedNavTab: getSelectedNavTab(state),
theme: getTheme(state), theme: getTheme(state),
renderChooseGroupMembersModal, renderChooseGroupMembersModal,
renderConfirmAdditionsModal, renderConfirmAdditionsModal,

Some files were not shown because too many files have changed in this diff Show more