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 9169 additions and 2721 deletions

View File

@ -457,6 +457,210 @@ Signal Desktop makes use of the following open source projects.
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
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
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
License: MIT

View File

@ -299,6 +299,34 @@
"messageformat": "Chats",
"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": {
"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"
@ -2150,6 +2178,10 @@
"messageformat": "View Safety Number",
"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": {
"messageformat": "View Safety Number",
"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...",
"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": {
"messageformat": "{ringer} is calling you",
"description": "Shown in the incoming call bar when someone is ringing you for a group call"
@ -6595,6 +6635,110 @@
"messageformat": "Send again",
"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": {
"messageformat": "What's New",
"description": "Title for the whats new modal"

View File

@ -194,7 +194,11 @@ const defaultWebPrefs = {
getEnvironment() !== Environment.Production ||
!isProduction(app.getVersion()),
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 =

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

Binary file not shown.

View File

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

View File

@ -26,6 +26,7 @@ $color-black: #000000;
$color-white-alpha-06: rgba($color-white, 0.06);
$color-white-alpha-08: rgba($color-white, 0.08);
$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-40: rgba($color-white, 0.4);
$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;
}
@mixin font-title-medium {
@include font-family;
font-weight: 600;
font-size: 18px;
line-height: 25px;
letter-spacing: -0.25px;
}
@mixin font-body-1 {
@include font-family;
font-size: 14px;
@ -858,3 +866,15 @@ $rtl-icon-map: (
top: 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 {
@ -4365,7 +4205,7 @@ button.module-image__border-overlay:focus {
.module-conversation-list {
$normal-row-height: 72px;
@include scrollbar;
@include NavTabs__Scroller;
padding-inline: 10px;
// 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 {
border-inline-end-style: solid;
border-inline-end-width: 1px;
display: inline-flex;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
position: relative;
@include light-theme {
$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 {
$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;
&__contents {
height: calc(#{$header-height} + var(--title-bar-drag-area-height));
width: 100%;
display: inline-flex;
flex-direction: row;
align-items: center;
padding-top: var(--title-bar-drag-area-height);
padding-block: 15px;
&__back-button {
@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 {
@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-08: rgba($color-white, 0.08);
$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-40: rgba($color-white, 0.4);
$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-calling-pip: 104;
$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');
}
&--message::before {
@include button-icon('../images/icons/v3/chat/chat-compact.svg');
}
&--muted::before {
@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;
align-items: center;
background: $color-ultramarine-dawn;
border: 2px solid $color-gray-80;
border: 2px solid;
bottom: -2px;
display: flex;
height: 20px;
@ -154,6 +154,14 @@
width: 20px;
z-index: $z-index-base;
@include light-theme {
border-color: $color-gray-04;
}
@include dark-theme {
border-color: $color-gray-80;
}
&::after {
content: '';
@include color-svg(
@ -166,6 +174,14 @@
}
}
.StoryListItem__button:hover .MyStories__avatar__add-story {
border-color: $color-gray-65;
.StoryListItem__button:hover,
.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 {
&__container {
margin-inline: 16px;
margin-bottom: 8px;
position: relative;
flex: 1 0 0;
}
&__icon {

View File

@ -1,8 +1,6 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.Stories {
background: $color-gray-95;
display: flex;
height: var(--window-height);
inset-inline-start: 0;
@ -11,42 +9,37 @@
user-select: none;
width: 100%;
z-index: $z-index-stories;
@include light-theme {
background: $color-white;
}
@include dark-theme {
background: $color-gray-95;
}
&__pane {
background: $color-gray-80;
border-inline-end: 1px solid $color-gray-65;
display: flex;
flex-direction: column;
height: 100%;
width: 380px;
padding-top: calc(14px + var(--title-bar-drag-area-height));
&__add-story__button {
@include color-svg('../images/icons/v3/plus/plus.svg', $color-white);
height: 20px;
position: absolute;
inset-inline-end: 64px;
top: 0px;
width: 20px;
&:focus {
@include keyboard-mode {
background-color: $color-ultramarine;
}
}
padding-top: calc(2px + var(--title-bar-drag-area-height));
@include light-theme {
background: $color-gray-04;
border-inline-end: 1px solid $color-black-alpha-16;
}
@include dark-theme {
background: $color-gray-80;
border-inline-end: 1px solid $color-white-alpha-16;
}
&__settings__button {
@include dark-theme {
@include color-svg('../images/icons/v3/more/more.svg', $color-white);
}
&__add-story__button {
height: 20px;
margin-inline-start: 20px;
opacity: 1;
position: absolute;
inset-inline-end: 24px;
top: 0px;
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 {
@include keyboard-mode {
@ -68,55 +61,40 @@
}
&--title {
@include font-body-1-bold;
color: $color-gray-05;
display: flex;
@include font-title-medium;
flex: 1;
justify-content: center;
@include light-theme {
color: $color-black;
}
@include dark-theme {
color: $color-gray-05;
}
}
&--centered .Stories__pane__header--title {
text-align: center;
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 {
@include scrollbar;
@include NavTabs__Scroller;
display: flex;
flex-direction: column;
flex: 1;
overflow-y: overlay;
padding-block: 0;
padding-inline: 14px;
padding-inline: 16px;
&--empty {
@include font-body-1;
align-items: center;
color: $color-gray-45;
@include light-theme() {
color: $color-gray-60;
}
@include dark-theme() {
color: $color-gray-45;
}
display: flex;
flex: 1;
flex-direction: column;
@ -125,11 +103,6 @@
}
}
&__search__container {
margin-block: 14px 10px;
margin-inline: 16px;
}
&__placeholder {
align-items: center;
color: $color-gray-45;
@ -154,33 +127,69 @@
@include button-reset;
@include font-body-1-bold;
align-items: center;
color: $color-gray-05;
display: flex;
justify-content: space-between;
padding-block: 12px;
padding-inline: 24px;
position: relative;
width: 100%;
@include light-theme {
color: $color-black;
}
@include dark-theme {
color: $color-gray-05;
}
&::after {
@include color-svg(
'../images/icons/v3/chevron/chevron-right.svg',
$color-gray-05
);
content: '';
height: 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 {
// Override color-svg
:dir(ltr) &::after,
:dir(rtl) &::after {
@include color-svg(
'../images/icons/v3/chevron/chevron-down.svg',
$color-gray-05
);
&::after {
@include light-theme {
@include color-svg(
'../images/icons/v3/chevron/chevron-down.svg',
$color-black
);
}
@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 {
&: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)
&:hover,
&--active {
background: $color-gray-65;
@include light-theme {
background: $color-gray-15;
}
@include dark-theme {
background: $color-gray-65;
}
}
}
@ -60,16 +70,26 @@
&--title {
@include font-body-1-bold;
color: $color-gray-05;
display: flex;
align-items: center;
@include light-theme {
color: $color-black;
}
@include dark-theme {
color: $color-gray-05;
}
}
&--timestamp,
&--sending,
&--send_failed {
@include font-body-2;
color: $color-gray-25;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
}
&--send_failed {
@ -148,32 +168,31 @@
}
&__icon {
@mixin StoryListItem__Icon($path) {
@include light-theme {
@include color-svg($path, $color-black);
}
@include dark-theme {
@include color-svg($path, $color-white);
}
}
&--chat {
@include color-svg(
'../images/icons/v3/open/open-compact.svg',
$color-white
);
@include StoryListItem__Icon('../images/icons/v3/open/open-compact.svg');
}
&--delete {
@include color-svg(
'../images/icons/v3/trash/trash-compact.svg',
$color-white
@include StoryListItem__Icon(
'../images/icons/v3/trash/trash-compact.svg'
);
}
&--hide {
@include color-svg(
'../images/icons/v3/x/x-circle-compact.svg',
$color-white
);
@include StoryListItem__Icon('../images/icons/v3/x/x-circle-compact.svg');
}
&--info {
@include color-svg(
'../images/icons/v3/info/info-compact.svg',
$color-white
);
@include StoryListItem__Icon('../images/icons/v3/info/info-compact.svg');
}
}

View File

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

View File

@ -3,7 +3,7 @@
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 {
return bytes.fromBase64(value);

View File

@ -7,13 +7,9 @@ import { HKDF } from '@signalapp/libsignal-client';
import * as Bytes from './Bytes';
import { calculateAgreement, generateKeyPair } from './Curve';
import * as log from './logging/log';
import { HashType, CipherType } from './types/Crypto';
import { ProfileDecryptError } from './types/errors';
import { UUID, UUID_BYTE_SIZE } from './types/UUID';
import type { UUIDStringType } from './types/UUID';
export { uuidToBytes } from './util/uuidToBytes';
import { getBytesSubarray } from './util/uuidToBytes';
export { HashType, CipherType };
@ -199,12 +195,16 @@ export function decryptSymmetric(
const iv = getZeroes(IV_LENGTH);
const nonce = getFirstBytes(data, NONCE_LENGTH);
const ciphertext = getBytes(
const ciphertext = getBytesSubarray(
data,
NONCE_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 macKey = hmacSha256(key, cipherKey);
@ -353,52 +353,6 @@ export function getFirstBytes(data: Uint8Array, n: number): Uint8Array {
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 {
let paddingEnd = 0;
for (paddingEnd; paddingEnd < padded.length; paddingEnd += 1) {
@ -588,7 +542,7 @@ export function decryptProfileName(
// SignalContext APIs
//
const { crypto } = window.SignalContext;
const { crypto } = globalThis.window?.SignalContext ?? {};
export function sign(key: Uint8Array, data: Uint8Array): Uint8Array {
return crypto.sign(key, data);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,8 +19,6 @@ export type Props = {
onEditProfile: () => unknown;
onStartUpdate: () => unknown;
onViewPreferences: () => unknown;
onViewArchive: () => unknown;
// Matches Popper's RefHandler type
innerRef?: React.Ref<HTMLDivElement>;
@ -35,8 +33,6 @@ export function AvatarPopup(props: Props): JSX.Element {
name,
onEditProfile,
onStartUpdate,
onViewArchive,
onViewPreferences,
phoneNumber,
profileName,
style,
@ -70,54 +66,27 @@ export function AvatarPopup(props: Props): JSX.Element {
) : null}
</div>
</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 && (
<button
type="button"
className="module-avatar-popup__item"
onClick={onStartUpdate}
>
<div
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--badge" />
</button>
<>
<hr className="module-avatar-popup__divider" />
<button
type="button"
className="module-avatar-popup__item"
onClick={onStartUpdate}
>
<div
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--badge" />
</button>
</>
)}
</div>
);

View File

@ -33,6 +33,7 @@ export enum ButtonVariant {
export enum ButtonIconType {
audio = 'audio',
message = 'message',
muted = 'muted',
search = 'search',
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;
if (typeof children === 'function') {
buttonNode = (children as (props: RenderButtonProps) => JSX.Element)({
openMenu: onClick || handleClick,
onKeyDown: handleKeyDown,
isMenuShowing,
ref: setReferenceElement,
menuNode,
});
buttonNode = (
<>
{(children as (props: RenderButtonProps) => JSX.Element)({
openMenu: onClick || handleClick,
onKeyDown: handleKeyDown,
isMenuShowing,
ref: setReferenceElement,
menuNode,
})}
{portalNode ? createPortal(menuNode, portalNode) : menuNode}
</>
);
} else {
buttonNode = (
<div

View File

@ -12,7 +12,7 @@ import { assertDev } from '../util/assert';
import type { ParsedE164Type } from '../util/libphonenumberInstance';
import type { LocalizerType, ThemeType } 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 { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid';
import type { ShowConversationType } from '../state/ducks/conversations';
@ -493,7 +493,7 @@ export function ConversationList({
return null;
}
const widthBreakpoint = getConversationListWidthBreakpoint(dimensions.width);
const widthBreakpoint = getNavSidebarWidthBreakpoint(dimensions.width);
return (
<ListView

View File

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

View File

@ -3,19 +3,10 @@
import type { ReactNode } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import type { ShowConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util';
import * as log from '../logging/log';
import { SECOND, DAY } from '../util/durations';
import { ToastStickerPackInstallFailed } from './ToastStickerPackInstallFailed';
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';
import type { SmartNavTabsProps } from '../state/smart/NavTabs';
export type PropsType = {
firstEnvelopeTimestamp: number | undefined;
@ -23,18 +14,13 @@ export type PropsType = {
hasInitialLoadCompleted: boolean;
i18n: LocalizerType;
isCustomizingPreferredReactions: boolean;
onConversationClosed: (id: string, reason: string) => unknown;
onConversationOpened: (id: string, messageId?: string) => unknown;
renderConversationView: () => JSX.Element;
navTabsCollapsed: boolean;
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => unknown;
renderCallsTab: () => JSX.Element;
renderChatsTab: () => JSX.Element;
renderCustomizingPreferredReactionsModal: () => JSX.Element;
renderLeftPane: () => JSX.Element;
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
scrollToMessage: (conversationId: string, messageId: string) => unknown;
selectedConversationId?: string;
targetedMessage?: string;
targetedMessageSource?: TargetedMessageSource;
showConversation: ShowConversationType;
showWhatsNewModal: () => unknown;
renderNavTabs: (props: SmartNavTabsProps) => JSX.Element;
renderStoriesTab: () => JSX.Element;
};
export function Inbox({
@ -43,27 +29,17 @@ export function Inbox({
hasInitialLoadCompleted,
i18n,
isCustomizingPreferredReactions,
onConversationClosed,
onConversationOpened,
renderConversationView,
navTabsCollapsed,
onToggleNavTabsCollapse,
renderCallsTab,
renderChatsTab,
renderCustomizingPreferredReactionsModal,
renderLeftPane,
renderMiniPlayer,
scrollToMessage,
selectedConversationId,
targetedMessage,
targetedMessageSource,
showConversation,
showWhatsNewModal,
renderNavTabs,
renderStoriesTab,
}: PropsType): JSX.Element {
const [internalHasInitialLoadCompleted, setInternalHasInitialLoadCompleted] =
useState(hasInitialLoadCompleted);
const prevConversationId = usePrevious(
selectedConversationId,
selectedConversationId
);
const now = useMemo(() => Date.now(), []);
const midnight = useMemo(() => {
const date = new Date(now);
@ -74,80 +50,6 @@ export function Inbox({
return date.getTime();
}, [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(() => {
if (internalHasInitialLoadCompleted) {
return;
@ -186,12 +88,6 @@ export function Inbox({
setInternalHasInitialLoadCompleted(hasInitialLoadCompleted);
}, [hasInitialLoadCompleted]);
useEffect(() => {
if (!selectedConversationId) {
window.SignalCI?.handleEvent('empty-inbox:rendered', null);
}
}, [selectedConversationId]);
if (!internalHasInitialLoadCompleted) {
let loadingProgress = 0;
if (
@ -264,37 +160,13 @@ export function Inbox({
<>
<div className="Inbox">
<div className="module-title-bar-drag-area" />
<div id="LeftPane">{renderLeftPane()}</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>
{renderNavTabs({
navTabsCollapsed,
onToggleNavTabsCollapse,
renderChatsTab,
renderCallsTab,
renderStoriesTab,
})}
</div>
{activeModal}
</>

View File

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

View File

@ -1,9 +1,9 @@
// Copyright 2019 Signal Messenger, LLC
// 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 { clamp, isNumber, noop } from 'lodash';
import { isNumber } from 'lodash';
import type { LeftPaneHelper, ToFindType } from './leftPane/LeftPaneHelper';
import { FindDirection } from './leftPane/LeftPaneHelper';
@ -27,15 +27,8 @@ import { usePrevious } from '../hooks/usePrevious';
import { missingCaseError } from '../util/missingCaseError';
import type { DurationInSeconds } from '../util/durations';
import type { WidthBreakpoint } from './_util';
import { getConversationListWidthBreakpoint } from './_util';
import { getNavSidebarWidthBreakpoint } from './_util';
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 { ShowConversationType } from '../state/ducks/conversations';
import type { PropsType as UnsupportedOSDialogPropsType } from '../state/smart/UnsupportedOSDialog';
@ -50,6 +43,12 @@ import type {
SaveAvatarToDiskActionType,
} from '../types/Avatar';
import { SizeObserver } from '../hooks/useSizeObserver';
import {
NavSidebar,
NavSidebarActionButton,
NavSidebarSearchHeader,
} from './NavSidebar';
import { ContextMenu } from './ContextMenu';
export enum LeftPaneMode {
Inbox,
@ -114,6 +113,7 @@ export type PropsType = {
composeReplaceAvatar: ReplaceAvatarActionType;
composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
createGroup: () => void;
navTabsCollapsed: boolean;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void;
removeConversation: (conversationId: string) => void;
@ -132,10 +132,10 @@ export type PropsType = {
startSettingGroupMetadata: () => void;
toggleComposeEditingAvatar: () => unknown;
toggleConversationInChooseMembers: (conversationId: string) => void;
toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
updateSearchTerm: (_: string) => void;
// Render Props
renderMainHeader: () => JSX.Element;
renderMessageSearchResult: (id: string) => JSX.Element;
renderNetworkStatus: (
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
@ -178,14 +178,15 @@ export function LeftPane({
isUpdateDownloaded,
isContactManagementEnabled,
modeSpecificProps,
navTabsCollapsed,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
preferredWidthFromStorage,
removeConversation,
renderCaptchaDialog,
renderCrashReportDialog,
renderExpiredBuildDialog,
renderMainHeader,
renderMessageSearchResult,
renderNetworkStatus,
renderUnsupportedOSDialog,
@ -195,6 +196,7 @@ export function LeftPane({
searchInConversation,
selectedConversationId,
targetedMessageId,
toggleNavTabsCollapse,
setChallengeStatus,
setComposeGroupAvatar,
setComposeGroupExpireTimer,
@ -215,12 +217,6 @@ export function LeftPane({
unsupportedOSDialogType,
updateSearchTerm,
}: 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(
modeSpecificProps,
modeSpecificProps
@ -421,76 +417,6 @@ export function LeftPane({
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({
clearConversationSearch,
clearGroupCreationError,
@ -553,11 +479,7 @@ export function LeftPane({
// It also ensures that we scroll to the top when switching views.
const listKey = preRowsNode ? 1 : 0;
const width = getWidthFromPreferredWidth(preferredWidth, {
requiresFullWidth,
});
const widthBreakpoint = getConversationListWidthBreakpoint(width);
const widthBreakpoint = getNavSidebarWidthBreakpoint(300);
const commonDialogProps = {
i18n,
@ -614,127 +536,171 @@ export function LeftPane({
}
return (
<nav
className={classNames(
'module-left-pane',
isResizing && 'module-left-pane--is-resizing',
`module-left-pane--width-${widthBreakpoint}`,
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers &&
'module-left-pane--mode-choose-group-members',
modeSpecificProps.mode === LeftPaneMode.Compose &&
'module-left-pane--mode-compose'
)}
style={{ width }}
>
{/* eslint-enable jsx-a11y/no-static-element-interactions */}
<div className="module-left-pane__header">
{helper.getHeaderContents({
i18n,
showInbox,
startComposing,
showChooseGroupMembers,
}) || renderMainHeader()}
</div>
{helper.getSearchInput({
clearConversationSearch,
clearSearch,
i18n,
onChangeComposeSearchTerm: event => {
setComposeSearchTerm(event.target.value);
},
updateSearchTerm,
showConversation,
})}
<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={{
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}
<NavSidebar
title="Chats"
hideHeader={
modeSpecificProps.mode === LeftPaneMode.Archive ||
modeSpecificProps.mode === LeftPaneMode.Compose ||
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers ||
modeSpecificProps.mode === LeftPaneMode.SetGroupMetadata
}
i18n={i18n}
navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={toggleNavTabsCollapse}
preferredLeftPaneWidth={preferredWidthFromStorage}
requiresFullWidth={false}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
actions={
<>
<NavSidebarActionButton
label={i18n('icu:newConversation')}
icon={<span className="module-left-pane__startComposingIcon" />}
onClick={startComposing}
/>
<ContextMenu
i18n={i18n}
menuOptions={[
{
label: i18n('icu:avatarMenuViewArchive'),
onClick: showArchivedConversations,
},
]}
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
portalToRoot
>
{({ openMenu, onKeyDown }) => {
return (
<NavSidebarActionButton
onClick={openMenu}
onKeyDown={onKeyDown}
icon={<span className="module-left-pane__moreActionsIcon" />}
label="More Actions"
/>
);
}}
</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>
)}
</SizeObserver>
{footerContents && (
<div className="module-left-pane__footer">{footerContents}</div>
)}
</SizeObserver>
{footerContents && (
<div className="module-left-pane__footer">{footerContents}</div>
)}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="module-left-pane__resize-grab-area"
onMouseDown={() => {
setIsResizing(true);
}}
/>
{challengeStatus !== 'idle' &&
renderCaptchaDialog({
onSkip() {
setChallengeStatus('idle');
},
})}
{crashReportCount > 0 && renderCrashReportDialog()}
</nav>
{challengeStatus !== 'idle' &&
renderCaptchaDialog({
onSkip() {
setChallengeStatus('idle');
},
})}
{crashReportCount > 0 && renderCrashReportDialog()}
</nav>
</NavSidebar>
);
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -68,6 +68,14 @@ export function ToastManager({
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) {
return (
<Toast onClose={hideToast}>

View File

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

View File

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

View File

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

View File

@ -17,6 +17,13 @@ import { getDefaultConversation } from '../../../test-both/helpers/getDefaultCon
import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid';
import { ThemeType } from '../../../types/Util';
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);
@ -79,6 +86,7 @@ const createProps = (
metadata: {},
member: getDefaultConversation(),
})),
selectedNavTab: NavTab.Chats,
setDisappearingMessages: action('setDisappearingMessages'),
showContactModal: action('showContactModal'),
pushPanelForConversation: action('pushPanelForConversation'),
@ -214,3 +222,32 @@ export const _11 = (): JSX.Element => (
_11.story = {
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 React, { useEffect, useState, useCallback } from 'react';
import classNames from 'classnames';
import { Button, ButtonIconType, ButtonVariant } from '../../Button';
import { Tooltip } from '../../Tooltip';
import type {
@ -52,6 +53,37 @@ import type {
import { isConversationMuted } from '../../../util/isConversationMuted';
import { ConversationDetailsGroups } from './ConversationDetailsGroups';
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 {
NothingOpen,
@ -65,6 +97,7 @@ enum ModalState {
export type StateProps = {
areWeASubscriber: boolean;
badges?: ReadonlyArray<BadgeType>;
callHistoryGroup?: CallHistoryGroup | null;
canEditGroupInfo: boolean;
canAddNewMembers: boolean;
conversation?: ConversationType;
@ -80,6 +113,7 @@ export type StateProps = {
memberships: ReadonlyArray<GroupV2Membership>;
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
selectedNavTab: NavTab;
theme: ThemeType;
userAvatarData: ReadonlyArray<AvatarDataType>;
renderChooseGroupMembersModal: (
@ -101,6 +135,7 @@ type ActionProps = {
}
) => unknown;
blockConversation: (id: string) => void;
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
getProfilesForConversation: (id: string) => unknown;
leaveGroup: (conversationId: string) => void;
@ -153,6 +188,7 @@ export function ConversationDetails({
areWeASubscriber,
badges,
blockConversation,
callHistoryGroup,
canEditGroupInfo,
canAddNewMembers,
conversation,
@ -180,6 +216,7 @@ export function ConversationDetails({
replaceAvatar,
saveAvatarToDisk,
searchInConversation,
selectedNavTab,
setDisappearingMessages,
setMuteExpiration,
showContactModal,
@ -364,6 +401,20 @@ export function ConversationDetails({
/>
<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 && (
<>
<ConversationDetailsCallButton
@ -397,17 +448,60 @@ export function ConversationDetails({
>
{isMuted ? i18n('icu:unmute') : i18n('icu:mute')}
</Button>
<Button
icon={ButtonIconType.search}
onClick={() => {
searchInConversation(conversation.id);
}}
variant={ButtonVariant.Details}
>
{i18n('icu:search')}
</Button>
{selectedNavTab !== NavTab.Calls && (
<Button
icon={ButtonIconType.search}
onClick={() => {
searchInConversation(conversation.id);
}}
variant={ButtonVariant.Details}
>
{i18n('icu:search')}
</Button>
)}
</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>
{!isGroup || canEditGroupInfo ? (
<PanelRow
@ -440,28 +534,30 @@ export function ConversationDetails({
}
/>
) : null}
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:showChatColorEditor')}
icon={IconType.color}
/>
}
label={i18n('icu:showChatColorEditor')}
onClick={() => {
pushPanelForConversation({
type: PanelType.ChatColorEditor,
});
}}
right={
<div
className={`ConversationDetails__chat-color ConversationDetails__chat-color--${conversation.conversationColor}`}
style={{
...getCustomColorStyle(conversation.customColor),
}}
/>
}
/>
{selectedNavTab === NavTab.Chats && (
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:showChatColorEditor')}
icon={IconType.color}
/>
}
label={i18n('icu:showChatColorEditor')}
onClick={() => {
pushPanelForConversation({
type: PanelType.ChatColorEditor,
});
}}
right={
<div
className={`ConversationDetails__chat-color ConversationDetails__chat-color--${conversation.conversationColor}`}
style={{
...getCustomColorStyle(conversation.customColor),
}}
/>
}
/>
)}
{isGroup && (
<PanelRow
icon={

View File

@ -10,7 +10,6 @@ import * as KeyboardLayout from '../services/keyboardLayout';
import { getHasPanelOpen } from '../state/selectors/conversations';
import { isInFullScreenCall } from '../state/selectors/calling';
import { isShowingAnyModal } from '../state/selectors/globalModals';
import { shouldShowStoriesView } from '../state/selectors/stories';
type KeyboardShortcutHandlerType = (ev: KeyboardEvent) => boolean;
@ -36,10 +35,6 @@ function useHasGlobalModal(): boolean {
return useSelector<StateType, boolean>(isShowingAnyModal);
}
function useHasStories(): boolean {
return useSelector<StateType, boolean>(shouldShowStoriesView);
}
function useHasCalling(): boolean {
return useSelector<StateType, boolean>(isInFullScreenCall);
}
@ -47,10 +42,9 @@ function useHasCalling(): boolean {
function useHasAnyOverlay(): boolean {
const panels = useHasPanels();
const globalModal = useHasGlobalModal();
const stories = useHasStories();
const calling = useHasCalling();
return panels || globalModal || stories || calling;
return panels || globalModal || calling;
}
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 { DraftBodyRanges, RawBodyRange } from './types/BodyRange';
import type { CallHistoryDetailsFromDiskType } from './types/Calling';
import type { CustomColorType, ConversationColorType } from './types/Colors';
import type { DeviceType } from './textsecure/Types.d';
import type { SendMessageChallengeData } from './textsecure/Errors';
@ -132,7 +131,7 @@ export type EditHistoryType = {
export type MessageAttributesType = {
bodyAttachment?: AttachmentType;
bodyRanges?: ReadonlyArray<RawBodyRange>;
callHistoryDetails?: CallHistoryDetailsFromDiskType;
callId?: string;
canReplyToStory?: boolean;
changedId?: string;
dataMessage?: Uint8Array | null;

View File

@ -30,8 +30,6 @@ import { getAboutText } from '../util/getAboutText';
import { getAvatarPath } from '../util/avatarUtils';
import { getDraftPreview } from '../util/getDraftPreview';
import { hasDraft } from '../util/hasDraft';
import type { CallHistoryDetailsType } from '../types/Calling';
import { CallMode } from '../types/Calling';
import * as Conversation from '../types/Conversation';
import type { StickerType, StickerWithHydratedData } from '../types/Stickers';
import * as Stickers from '../types/Stickers';
@ -55,7 +53,7 @@ import type {
} from '../types/Colors';
import type { MessageModel } from './messages';
import { getContact } from '../messages/helpers';
import { assertDev, strictAssert } from '../util/assert';
import { strictAssert } from '../util/assert';
import { isConversationMuted } from '../util/isConversationMuted';
import { isConversationSMSOnly } from '../util/isConversationSMSOnly';
import {
@ -63,10 +61,8 @@ import {
isConversationUnregistered,
isConversationUnregisteredAndStale,
} from '../util/isConversationUnregistered';
import { missingCaseError } from '../util/missingCaseError';
import { sniffImageMimeType } from '../util/sniffImageMimeType';
import { isValidE164 } from '../util/isValidE164';
import { canConversationBeUnarchived } from '../util/canConversationBeUnarchived';
import type { MIMEType } from '../types/MIME';
import { IMAGE_JPEG, IMAGE_WEBP } from '../types/MIME';
import { UUID, UUIDKind } from '../types/UUID';
@ -160,7 +156,6 @@ import { ReceiptType } from '../types/Receipt';
import { getQuoteAttachment } from '../util/makeQuote';
import { deriveProfileKeyVersion } from '../util/zkgroup';
import { incrementMessageCounter } from '../util/incrementMessageCounter';
import { validateTransition } from '../util/callHistoryDetails';
import OS from '../util/os/osMain';
/* eslint-disable more/no-then */
@ -256,8 +251,6 @@ export class ConversationModel extends window.Backbone
throttledUpdateSharedGroups?: () => Promise<void>;
private cachedLatestGroupCallEraId?: string;
private cachedIdenticon?: CachedIdenticon;
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(
profileChange: unknown,
conversationId?: string

View File

@ -147,7 +147,6 @@ import { getStoryDataFromMessageAttributes } from '../services/storyLoader';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
import { getMessageById } from '../messages/getMessageById';
import { shouldDownloadStory } from '../util/shouldDownloadStory';
import { shouldShowStoriesView } from '../state/selectors/stories';
import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact';
import { SeenStatus } from '../MessageSeenStatus';
import { isNewReactionReplacingPrevious } from '../reactions/util';
@ -172,6 +171,8 @@ import {
saveNewMessageBatcher,
} from '../util/messageBatcher';
import { normalizeUuid } from '../util/normalizeUuid';
import { getCallHistorySelector } from '../state/selectors/callHistory';
import { getConversationSelector } from '../state/selectors/conversations';
/* eslint-disable more/no-then */
@ -715,9 +716,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if (isCallHistory(attributes)) {
const state = window.reduxStore.getState();
const callingNotification = getPropsForCallHistory(attributes, {
conversationSelector: findAndFormatContact,
callSelector: getCallSelector(state),
activeCall: getActiveCall(state),
callHistorySelector: getCallHistorySelector(state),
conversationSelector: getConversationSelector(state),
});
if (callingNotification) {
return {
@ -2837,11 +2839,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
let queueStoryForDownload = false;
if (isStory(message.attributes)) {
const isShowingStories = shouldShowStoriesView(reduxState);
queueStoryForDownload =
isShowingStories ||
(await shouldDownloadStory(conversation.attributes));
queueStoryForDownload = await shouldDownloadStory(
conversation.attributes
);
}
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,
BusyMessage,
Call,
CallEndedReason,
CallingMessage,
CallLogLevel,
CallState,
@ -39,8 +38,8 @@ import {
RingUpdate,
} from '@signalapp/ringrtc';
import { uniqBy, noop } from 'lodash';
import Long from 'long';
import Long from 'long';
import type {
ActionsType as CallingReduxActionsType,
GroupCallParticipantInfoType,
@ -51,6 +50,7 @@ import { getConversationCallMode } from '../state/ducks/conversations';
import { isMe } from '../util/whatTypeOfConversation';
import type {
AvailableIODevicesType,
CallEndedReason,
MediaDeviceSettings,
PresentableSource,
PresentedSource,
@ -74,11 +74,10 @@ import { UUID, UUIDKind } from '../types/UUID';
import * as Errors from '../types/errors';
import type { ConversationModel } from '../models/conversations';
import * as Bytes from '../Bytes';
import { uuidToBytes, bytesToUuid } from '../Crypto';
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
import { drop } from '../util/drop';
import { dropNull } from '../util/dropNull';
import { getOwn } from '../util/getOwn';
import { isNormalNumber } from '../util/isNormalNumber';
import * as durations from '../util/durations';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
import { handleMessageSend } from '../util/handleMessageSend';
@ -107,6 +106,24 @@ import {
import * as log from '../logging/log';
import { assertDev, strictAssert } from '../util/assert';
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 {
processGroupCallRingCancellation,
@ -689,7 +706,51 @@ export class CallingClass {
{
onLocalDeviceStateChanged: groupCall => {
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 (
localDeviceState.connectionState === ConnectionState.NotConnected
@ -703,10 +764,13 @@ export class CallingClass {
if (
updateMessageState === GroupCallUpdateMessageState.SentJoin &&
eraId
peekInfo?.eraId != null
) {
updateMessageState = GroupCallUpdateMessageState.SentLeft;
void this.sendGroupCallUpdateMessage(conversationId, eraId);
void this.sendGroupCallUpdateMessage(
conversationId,
peekInfo?.eraId
);
}
} else {
this.callsByConversation[conversationId] = groupCall;
@ -721,16 +785,28 @@ export class CallingClass {
if (
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
localDeviceState.joinState === JoinState.Joined &&
eraId
peekInfo?.eraId != null
) {
updateMessageState = GroupCallUpdateMessageState.SentJoin;
void this.sendGroupCallUpdateMessage(conversationId, eraId);
void this.sendGroupCallUpdateMessage(
conversationId,
peekInfo?.eraId
);
}
}
this.syncGroupCallToRedux(conversationId, 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);
},
onAudioLevels: groupCall => {
@ -748,7 +824,16 @@ export class CallingClass {
},
onPeekChanged: groupCall => {
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 (
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
localDeviceState.connectionState !== ConnectionState.NotConnected &&
@ -759,10 +844,7 @@ export class CallingClass {
void this.sendGroupCallUpdateMessage(conversationId, eraId);
}
void this.updateCallHistoryForGroupCall(
conversationId,
groupCall.getPeekInfo()
);
void this.updateCallHistoryForGroupCall(conversationId, peekInfo);
this.syncGroupCallToRedux(conversationId, groupCall);
},
async requestMembershipProof(groupCall) {
@ -789,7 +871,17 @@ export class CallingClass {
requestGroupMembers: groupCall => {
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);
const ProtoOfferType = Proto.CallingMessage.Offer.Type;
await this.addCallHistoryForFailedIncomingCall(
conversation,
callingMessage.offer.type === ProtoOfferType.OFFER_VIDEO_CALL,
envelope.timestamp,
callId.toString()
const wasVideoCall =
callingMessage.offer.type ===
Proto.CallingMessage.Offer.Type.OFFER_VIDEO_CALL;
const peerId = getPeerIdFromConversation(conversation.attributes);
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;
@ -1801,6 +1904,20 @@ export class CallingClass {
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(
@ -1865,8 +1982,6 @@ export class CallingClass {
);
return false;
}
const callId = Long.fromValue(call.callId).toString();
try {
// The peer must be 'trusted' before accepting a call from them.
// This is mostly the safety number check, unverified meaning that they were
@ -1879,12 +1994,13 @@ export class CallingClass {
log.info(
`Peer is not trusted, ignoring incoming call for conversation: ${conversation.idForLogging()}`
);
await this.addCallHistoryForFailedIncomingCall(
conversation,
call.isVideoCall,
Date.now(),
callId
);
const localCallEvent = LocalCallEvent.Missed;
const peerId = getPeerIdFromConversation(conversation.attributes);
const callDetails = getCallDetailsFromDirectCall(peerId, call);
const callEvent = getCallEventDetails(callDetails, localCallEvent);
await updateCallHistoryFromLocalEvent(callEvent, null);
return false;
}
@ -1898,20 +2014,14 @@ export class CallingClass {
return true;
} catch (err) {
log.error(`Ignoring incoming call: ${Errors.toLogFormat(err)}`);
await this.addCallHistoryForFailedIncomingCall(
conversation,
call.isVideoCall,
Date.now(),
callId
);
return false;
}
}
private async handleAutoEndedIncomingCallRequest(
callId: CallId,
callIdValue: CallId,
remoteUserId: UserId,
reason: CallEndedReason,
callEndedReason: CallEndedReason,
ageInSeconds: number,
wasVideoCall: boolean,
receivedAtCounter: number | undefined
@ -1921,22 +2031,28 @@ export class CallingClass {
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
// won't.)
const ageInMilliseconds =
isNormalNumber(ageInSeconds) && ageInSeconds >= 0
? ageInSeconds * durations.SECOND
: 0;
const endedTime = Date.now() - ageInMilliseconds;
const timestamp = Date.now() - ageInMilliseconds;
await this.addCallHistoryForAutoEndedIncomingCall(
conversation,
reason,
endedTime,
const callDetails = getCallDetailsFromEndedDirectCall(
callId,
peerId,
remoteUserId,
wasVideoCall,
receivedAtCounter,
Long.fromValue(callId).toString()
timestamp
);
const localCallEvent =
getLocalCallEventFromCallEndedReason(callEndedReason);
const callEvent = getCallEventDetails(callDetails, localCallEvent);
await updateCallHistoryFromLocalEvent(callEvent, receivedAtCounter ?? null);
}
private attachToCall(conversation: ConversationModel, call: Call): void {
@ -1947,44 +2063,26 @@ export class CallingClass {
return;
}
let acceptedTime: number | undefined;
// eslint-disable-next-line no-param-reassign
call.handleStateChanged = async () => {
if (call.state === CallState.Accepted) {
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)
);
}
if (call.state === CallState.Ended) {
this.stopDeviceReselectionTimer();
this.lastMediaDeviceSettings = undefined;
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({
remoteUserId: call.remoteUserId,
callId: Long.fromValue(call.callId).toString(),
conversationId: conversation.id,
acceptedTime,
callState: call.state,
callEndedReason: call.endedReason,
isIncoming: call.isIncoming,
isVideoCall: call.isVideoCall,
title: conversation.getTitle(),
});
};
@ -2137,154 +2235,55 @@ export class CallingClass {
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(
conversationId: string,
peekInfo: undefined | PeekInfo
peekInfo: PeekInfo | null
): 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 (!peekInfo || !peekInfo.eraId || !peekInfo.creator) {
if (groupCallMeta == null) {
return;
}
const creatorUuid = bytesToUuid(peekInfo.creator);
if (!creatorUuid) {
log.error('updateCallHistoryForGroupCall(): bad creator UUID');
return;
}
const creatorConversation = window.ConversationController.get(creatorUuid);
const creatorConversation = window.ConversationController.get(
groupCallMeta.ringerId
);
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
log.error('updateCallHistoryForGroupCall(): could not find conversation');
log.error('maybeNotifyGroupCall(): could not find conversation');
return;
}
const isNewCall = await conversation.updateCallHistoryForGroupCall(
peekInfo.eraId,
creatorUuid
);
const prevMessageId =
await window.Signal.Data.getCallHistoryMessageByCallId({
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(
creatorConversation && isMe(creatorConversation.attributes)
);
const isAnybodyElseInGroupCall = Boolean(peekInfo.devices.length);
const isAnybodyElseInGroupCall = Boolean(peekInfo?.devices.length);
if (
isNewCall &&

View File

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

View File

@ -11,8 +11,7 @@ import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { strictAssert } from '../util/assert';
import { sleep } from '../util/sleep';
import { getMinNickname, getMaxNickname } from '../util/Username';
import { bytesToUuid } from '../Crypto';
import { uuidToBytes } from '../util/uuidToBytes';
import { bytesToUuid, uuidToBytes } from '../util/uuidToBytes';
import type { UsernameReservationType } from '../types/Username';
import { ReserveUsernameError, ConfirmUsernameResult } from '../types/Username';
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 { GetMessagesBetweenOptions } from './Server';
import type { MessageTimestamps } from '../state/ducks/conversations';
import type {
CallHistoryDetails,
CallHistoryFilter,
CallHistoryGroup,
CallHistoryPagination,
} from '../types/CallDisposition';
export type AdjacentMessagesByConversationOptionsType = Readonly<{
conversationId: string;
@ -628,10 +634,22 @@ export type DataInterface = {
getLastConversationMessage(options: {
conversationId: string;
}): Promise<MessageType | undefined>;
getCallHistoryMessageByCallId(
conversationId: string,
callId: string
): Promise<string | void>;
getAllCallHistory: () => Promise<ReadonlyArray<CallHistoryDetails>>;
clearCallHistory: (beforeTimestamp: number) => Promise<Array<string>>;
getCallHistoryMessageByCallId(options: {
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: (
conversationId: string,
eraId: string

View File

@ -10,6 +10,7 @@ import { randomBytes } from 'crypto';
import type { Database, Statement } from '@signalapp/better-sqlite3';
import SQL from '@signalapp/better-sqlite3';
import pProps from 'p-props';
import { z } from 'zod';
import type { Dictionary } from 'lodash';
import {
@ -60,6 +61,7 @@ import type {
QueryFragment,
} from './util';
import {
sqlConstant,
sqlJoin,
sqlFragment,
sql,
@ -142,6 +144,18 @@ import {
SNIPPET_RIGHT_PLACEHOLDER,
SNIPPET_TRUNCATION_PLACEHOLDER,
} from '../util/search';
import type {
CallHistoryDetails,
CallHistoryFilter,
CallHistoryGroup,
CallHistoryPagination,
} from '../types/CallDisposition';
import {
DirectCallStatus,
callHistoryGroupSchema,
CallHistoryFilterStatus,
callHistoryDetailsSchema,
} from '../types/CallDisposition';
type ConversationRow = Readonly<{
json: string;
@ -288,7 +302,13 @@ const dataInterface: ServerInterface = {
getConversationRangeCenteredOnMessage,
getConversationMessageStats,
getLastConversationMessage,
getAllCallHistory,
clearCallHistory,
getCallHistoryMessageByCallId,
getCallHistory,
getCallHistoryGroupsCount,
getCallHistoryGroups,
saveCallHistory,
hasGroupCallHistoryMessage,
migrateConversationMessages,
getMessagesBetween,
@ -1755,32 +1775,32 @@ async function searchMessages({
// Note: this groups the results by rowid, so even if one message mentions multiple
// matching UUIDs, we only return one to be highlighted
const [sqlQuery, params] = sql`
SELECT
SELECT
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.received_at, ftsResults.received_at) as received_at,
ftsResults.ftsSnippet,
mentionUuid,
start as mentionStart,
ftsResults.ftsSnippet,
mentionUuid,
start as mentionStart,
length as mentionLength
FROM mentions
INNER JOIN messages
ON
messages.id = mentions.messageId
INNER JOIN messages
ON
messages.id = mentions.messageId
AND mentions.mentionUuid IN (
${sqlJoin(contactUuidsMatchingQuery, ', ')}
)
)
AND ${
conversationId
? sqlFragment`messages.conversationId = ${conversationId}`
: '1 IS 1'
}
AND messages.isViewOnce IS NOT 1
AND messages.isViewOnce IS NOT 1
AND messages.storyId IS NULL
FULL OUTER JOIN (
${ftsFragment}
) as ftsResults
) as ftsResults
USING (rowid)
GROUP BY rowid
ORDER BY received_at DESC, sent_at DESC
@ -1910,6 +1930,7 @@ function saveMessageSync(
sourceUuid,
sourceDevice,
storyId,
callId,
type,
readStatus,
expireTimer,
@ -1967,6 +1988,7 @@ function saveMessageSync(
sourceUuid: sourceUuid || null,
sourceDevice: sourceDevice || null,
storyId: storyId || null,
callId: callId || null,
type: type || null,
readStatus: readStatus ?? null,
seenStatus: seenStatus ?? SeenStatus.NotApplicable,
@ -1999,6 +2021,7 @@ function saveMessageSync(
sourceUuid = $sourceUuid,
sourceDevice = $sourceDevice,
storyId = $storyId,
callId = $callId,
type = $type,
readStatus = $readStatus,
seenStatus = $seenStatus
@ -2044,6 +2067,7 @@ function saveMessageSync(
sourceUuid,
sourceDevice,
storyId,
callId,
type,
readStatus,
seenStatus
@ -2070,6 +2094,7 @@ function saveMessageSync(
$sourceUuid,
$sourceDevice,
$storyId,
$callId,
$type,
$readStatus,
$seenStatus
@ -3224,30 +3249,366 @@ async function getConversationRangeCenteredOnMessage(
})();
}
async function getCallHistoryMessageByCallId(
conversationId: string,
callId: string
): Promise<string | void> {
async function getAllCallHistory(): Promise<ReadonlyArray<CallHistoryDetails>> {
const db = getInstance();
const [query] = sql`
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 id: string | void = db
.prepare<Query>(
`
SELECT id
FROM messages
WHERE conversationId = $conversationId
AND type = 'call-history'
AND callMode = 'Direct'
AND callId = $callId
`
)
.pluck()
.get({
conversationId,
callId,
});
const [query, params] = sql`
SELECT * FROM callsHistory
WHERE callId IS ${callId}
AND peerId IS ${peerId};
`;
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(
@ -5087,6 +5448,7 @@ async function removeAll(): Promise<void> {
DELETE FROM attachment_downloads;
DELETE FROM badgeImageFiles;
DELETE FROM badges;
DELETE FROM callsHistory;
DELETE FROM conversations;
DELETE FROM emojis;
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 updateToSchemaVersion85 from './85-add-kyber-keys';
import updateToSchemaVersion86 from './86-story-replies-index';
import updateToSchemaVersion87 from './87-calls-history-table';
function updateToSchemaVersion1(
currentVersion: number,
@ -1994,6 +1995,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion84,
updateToSchemaVersion85,
updateToSchemaVersion86,
updateToSchemaVersion87,
];
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);
}
export type QueryTemplateParam = string | number | undefined;
export type QueryTemplateParam = string | number | null | undefined;
export type QueryFragmentValue = QueryFragment | QueryTemplateParam;
export type QueryFragment = [
@ -66,7 +66,7 @@ export function sqlFragment(
...values: ReadonlyArray<QueryFragmentValue>
): QueryFragment {
let query = '';
const params: Array<string | number | undefined> = [];
const params: Array<QueryTemplateParam> = [];
strings.forEach((string, index) => {
const value = values[index];
@ -88,6 +88,20 @@ export function sqlFragment(
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.
*/
@ -96,7 +110,7 @@ export function sqlJoin(
separator: string
): QueryFragment {
let query = '';
const params: Array<string | number | undefined> = [];
const params: Array<QueryTemplateParam> = [];
items.forEach((item, index) => {
const [{ fragment }, fragmentParams] = sqlFragment`${item}`;
@ -111,10 +125,7 @@ export function sqlJoin(
return [{ fragment: query }, params];
}
export type QueryTemplate = [
string,
ReadonlyArray<string | number | undefined>
];
export type QueryTemplate = [string, ReadonlyArray<QueryTemplateParam>];
/**
* You can use tagged template literals to build SQL queries
@ -137,7 +148,7 @@ export type QueryTemplate = [
*/
export function sql(
strings: TemplateStringsArray,
...values: ReadonlyArray<QueryFragment | string | number | undefined>
...values: ReadonlyArray<QueryFragment | QueryTemplateParam>
): QueryTemplate {
const [{ fragment }, params] = sqlFragment(strings, ...values);
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 audioRecorder } from './ducks/audioRecorder';
import { actions as badges } from './ducks/badges';
import { actions as callHistory } from './ducks/callHistory';
import { actions as calling } from './ducks/calling';
import { actions as composer } from './ducks/composer';
import { actions as conversations } from './ducks/conversations';
@ -36,6 +37,7 @@ export const actionCreators: ReduxActions = {
audioPlayer,
audioRecorder,
badges,
callHistory,
calling,
composer,
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 type { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { CallEndedReason } from '@signalapp/ringrtc';
import {
hasScreenCapturePermission,
openSystemPreferences,
@ -26,6 +25,7 @@ import type {
PresentableSource,
} from '../../types/Calling';
import {
CallEndedReason,
CallingDeviceType,
CallMode,
CallViewMode,
@ -53,8 +53,6 @@ import { isDirectConversation } from '../../util/whatTypeOfConversation';
import { SHOW_TOAST } from './toast';
import { ToastType } from '../../types/Toast';
import type { ShowToastActionType } from './toast';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
import MessageSender from '../../textsecure/SendMessage';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
import { isAnybodyElseInGroupCall } from './callingHelpers';
@ -150,15 +148,10 @@ export type AcceptCallType = ReadonlyDeep<{
}>;
export type CallStateChangeType = ReadonlyDeep<{
remoteUserId: string; // TODO: Remove
callId: string; // TODO: Remove
conversationId: string;
acceptedTime?: number;
callState: CallState;
callEndedReason?: CallEndedReason;
isIncoming: boolean;
isVideoCall: boolean;
title: string;
}>;
export type CancelCallType = ReadonlyDeep<{
@ -363,7 +356,7 @@ const doGroupCallPeek = (
// to only be peeking once.
await Promise.all([sleep(1000), waitForOnline(navigator, window)]);
let peekInfo;
let peekInfo = null;
try {
peekInfo = await calling.peekGroupCall(conversationId);
} catch (err) {
@ -689,38 +682,18 @@ function callStateChange(
CallStateChangeFulfilledActionType
> {
return async dispatch => {
const {
callId,
callState,
isVideoCall,
isIncoming,
acceptedTime,
callEndedReason,
remoteUserId,
} = payload;
const { callState, acceptedTime, callEndedReason } = payload;
if (callState === CallState.Ended) {
ipcRenderer.send('close-screen-share-controller');
}
const isOutgoing = !isIncoming;
const wasAccepted = acceptedTime != null;
const isConnected = callState === CallState.Accepted; // "connected"
const isEnded = callState === CallState.Ended && callEndedReason != null;
const isLocalHangup = callEndedReason === CallEndedReason.LocalHangup;
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:
if (
// 1. I hungup (or declined)
@ -733,37 +706,6 @@ function callStateChange(
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({
type: CALL_STATE_CHANGE_FULFILLED,
payload,
@ -1326,10 +1268,12 @@ function onOutgoingVideoCallInConversation(
log.info(
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
);
startCallingLobby({
conversationId,
isVideoCall: true,
})(dispatch, getState, undefined);
dispatch(
startCallingLobby({
conversationId,
isVideoCall: true,
})
);
log.info('onOutgoingVideoCallInConversation: started the call');
} else {
log.info(

View File

@ -159,6 +159,8 @@ import { ReceiptType } from '../../types/Receipt';
import { sortByMessageOrder } from '../../util/maybeForwardMessages';
import { Sound, SoundType } from '../../util/Sound';
import { canEditMessage } from '../../util/canEditMessage';
import type { ChangeNavTabActionType } from './nav';
import { CHANGE_NAV_TAB, NavTab, actions as navActions } from './nav';
// State
@ -3911,14 +3913,18 @@ function showConversation({
void,
RootStateType,
unknown,
TargetedConversationChangedActionType
TargetedConversationChangedActionType | ChangeNavTabActionType
> {
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 && messageId) {
scrollToMessage(conversationId, messageId)(dispatch, getState, null);
dispatch(scrollToMessage(conversationId, messageId));
}
return;
@ -4383,7 +4389,11 @@ function maybeUpdateSelectedMessageForDetails(
export function reducer(
state: Readonly<ConversationsStateType> = getEmptyState(),
action: Readonly<ConversationActionType | StoryDistributionListsActionType>
action: Readonly<
| ConversationActionType
| StoryDistributionListsActionType
| ChangeNavTabActionType
>
): ConversationsStateType {
if (action.type === CLEAR_CONVERSATIONS_PENDING_VERIFICATION) {
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;
}

View File

@ -25,7 +25,6 @@ import type { ConfigMapType as RemoteConfigType } from '../../RemoteConfig';
export type ItemsStateType = ReadonlyDeep<
{
[key: string]: unknown;
remoteConfig?: RemoteConfigType;
serverTimeSkew?: number;
} & Partial<
@ -35,6 +34,7 @@ export type ItemsStateType = ReadonlyDeep<
| 'defaultConversationColor'
| 'customColors'
| 'preferredLeftPaneWidth'
| 'navTabsCollapsed'
| 'preferredReactionEmoji'
| 'areWeASubscriber'
| 'usernameLinkColor'
@ -90,6 +90,7 @@ export const actions = {
resetDefaultChatColor,
savePreferredLeftPaneWidth,
setGlobalDefaultConversationColor,
toggleNavTabsCollapse,
onSetSkinTone,
putItem,
putItemExternal,
@ -98,8 +99,9 @@ export const actions = {
resetItems,
};
export const useActions = (): BoundActionCreatorsMapObject<typeof actions> =>
useBoundActions(actions);
export const useItemsActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
function putItem<K extends keyof StorageAccessType>(
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
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;
hasAllStoriesUnmuted: boolean;
lastOpenedAtTimestamp: number | undefined;
openedAtTimestamp: number | undefined;
replyState?: Readonly<{
messageId: string;
replies: Array<MessageAttributesType>;
@ -163,7 +162,8 @@ const QUEUE_STORY_DOWNLOAD = 'stories/QUEUE_STORY_DOWNLOAD';
const SEND_STORY_MODAL_OPEN_STATE_CHANGED =
'stories/SEND_STORY_MODAL_OPEN_STATE_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 STORY_REPLY_DELETED = 'stories/STORY_REPLY_DELETED';
const REMOVE_ALL_STORIES = 'stories/REMOVE_ALL_STORIES';
@ -217,8 +217,12 @@ type StoryChangedActionType = ReadonlyDeep<{
payload: StoryDataType;
}>;
type ToggleViewActionType = ReadonlyDeep<{
type: typeof TOGGLE_VIEW;
type ClearStoriesTabStateActionType = ReadonlyDeep<{
type: typeof CLEAR_STORIES_TAB_STATE;
}>;
type MarkStoriesTabViewedActionType = ReadonlyDeep<{
type: typeof MARK_STORIES_TAB_VIEWED;
}>;
type ViewStoryActionType = ReadonlyDeep<{
@ -262,7 +266,8 @@ export type StoriesActionType =
| QueueStoryDownloadActionType
| SendStoryModalOpenStateChanged
| StoryChangedActionType
| ToggleViewActionType
| ClearStoriesTabStateActionType
| MarkStoriesTabViewedActionType
| ViewStoryActionType
| StoryReplyDeletedActionType
| RemoveAllStoriesActionType
@ -627,7 +632,7 @@ function sendStoryMessage(
> {
return async (dispatch, getState) => {
const { stories } = getState();
const { openedAtTimestamp, sendStoryModalData } = stories;
const { lastOpenedAtTimestamp, sendStoryModalData } = stories;
// Add spinners in the story creator
dispatch({
@ -636,8 +641,8 @@ function sendStoryMessage(
});
assertDev(
openedAtTimestamp,
'sendStoryMessage: openedAtTimestamp is undefined, cannot send'
lastOpenedAtTimestamp,
'sendStoryMessage: lastOpenedAtTimestamp is undefined, cannot send'
);
assertDev(
sendStoryModalData,
@ -649,7 +654,7 @@ function sendStoryMessage(
const result = await blockSendUntilConversationsAreVerified(
sendStoryModalData,
SafetyNumberChangeSource.Story,
Date.now() - openedAtTimestamp
Date.now() - lastOpenedAtTimestamp
);
if (!result) {
@ -720,9 +725,15 @@ function sendStoryModalOpenStateChanged(
};
}
function toggleStoriesView(): ToggleViewActionType {
function clearStoriesTabState(): ClearStoriesTabStateActionType {
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,
sendStoryModalOpenStateChanged,
storyChanged,
toggleStoriesView,
clearStoriesTabState,
markStoriesTabViewed,
verifyStoryListMembers,
viewUserStories,
viewStory,
@ -1439,7 +1451,6 @@ export function getEmptyState(
): StoriesStateType {
return {
lastOpenedAtTimestamp: undefined,
openedAtTimestamp: undefined,
addStoryData: undefined,
stories: [],
hasAllStoriesUnmuted: false,
@ -1451,20 +1462,22 @@ export function reducer(
state: Readonly<StoriesStateType> = getEmptyState(),
action: Readonly<StoriesActionType>
): StoriesStateType {
if (action.type === TOGGLE_VIEW) {
const isShowingStoriesView = Boolean(state.openedAtTimestamp);
if (action.type === MARK_STORIES_TAB_VIEWED) {
return {
...state,
lastOpenedAtTimestamp: !isShowingStoriesView
? state.openedAtTimestamp || Date.now()
: state.lastOpenedAtTimestamp,
openedAtTimestamp: isShowingStoriesView ? undefined : Date.now(),
lastOpenedAtTimestamp: Date.now(),
replyState: 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) {
return {
...state,
lastOpenedAtTimestamp: state.openedAtTimestamp || Date.now(),
openedAtTimestamp: undefined,
replyState: undefined,
sendStoryModalData: 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 audioPlayer } from './ducks/audioPlayer';
import { getEmptyState as audioRecorder } from './ducks/audioRecorder';
import { getEmptyState as callHistory } from './ducks/callHistory';
import { getEmptyState as calling } from './ducks/calling';
import { getEmptyState as composer } from './ducks/composer';
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 linkPreviews } from './ducks/linkPreviews';
import { getEmptyState as mediaGallery } from './ducks/mediaGallery';
import { getEmptyState as nav } from './ducks/nav';
import { getEmptyState as network } from './ducks/network';
import { getEmptyState as preferredReactions } from './ducks/preferredReactions';
import { getEmptyState as safetyNumber } from './ducks/safetyNumber';
@ -39,15 +41,18 @@ import { getInitialState as stickers } from '../types/Stickers';
import { getThemeType } from '../util/getThemeType';
import { getInteractionMode } from '../services/InteractionMode';
import { makeLookup } from '../util/makeLookup';
import type { CallHistoryDetails } from '../types/CallDisposition';
export function getInitialState({
badges,
callsHistory,
stories,
storyDistributionLists,
mainWindowStats,
menuOptions,
}: {
badges: BadgesStateType;
callsHistory: ReadonlyArray<CallHistoryDetails>;
stories: Array<StoryDataType>;
storyDistributionLists: Array<StoryDistributionListDataType>;
mainWindowStats: MainWindowStatsType;
@ -88,6 +93,10 @@ export function getInitialState({
audioPlayer: audioPlayer(),
audioRecorder: audioRecorder(),
badges,
callHistory: {
...callHistory(),
callHistoryByCallId: makeLookup(callsHistory, 'callId'),
},
calling: calling(),
composer: composer(),
conversations: {
@ -110,6 +119,7 @@ export function getInitialState({
lightbox: lightbox(),
linkPreviews: linkPreviews(),
mediaGallery: mediaGallery(),
nav: nav(),
network: network(),
preferredReactions: preferredReactions(),
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 badges } from './ducks/badges';
import { reducer as calling } from './ducks/calling';
import { reducer as callHistory } from './ducks/callHistory';
import { reducer as composer } from './ducks/composer';
import { reducer as conversations } from './ducks/conversations';
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 linkPreviews } from './ducks/linkPreviews';
import { reducer as mediaGallery } from './ducks/mediaGallery';
import { reducer as nav } from './ducks/nav';
import { reducer as network } from './ducks/network';
import { reducer as preferredReactions } from './ducks/preferredReactions';
import { reducer as safetyNumber } from './ducks/safetyNumber';
@ -39,6 +41,7 @@ export const reducer = combineReducers({
audioRecorder,
badges,
calling,
callHistory,
composer,
conversations,
crashReports,
@ -50,6 +53,7 @@ export const reducer = combineReducers({
lightbox,
linkPreviews,
mediaGallery,
nav,
network,
preferredReactions,
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
);
type LeftPaneLists = Readonly<{
conversations: ReadonlyArray<ConversationType>;
archivedConversations: ReadonlyArray<ConversationType>;
pinnedConversations: ReadonlyArray<ConversationType>;
}>;
export const _getLeftPaneLists = (
lookup: ConversationLookupType,
comparator: (left: ConversationType, right: ConversationType) => number,
selectedConversation?: string,
pinnedConversationIds?: ReadonlyArray<string>
): {
conversations: Array<ConversationType>;
archivedConversations: Array<ConversationType>;
pinnedConversations: Array<ConversationType>;
} => {
): LeftPaneLists => {
const conversations: Array<ConversationType> = [];
const archivedConversations: 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
* composer and group members, a different list from your primary system contacts.

View File

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

View File

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

View File

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

View File

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

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