diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 6e91dd01b611..6c33a0b6fcf3 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -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 diff --git a/_locales/en/messages.json b/_locales/en/messages.json index cc564a62970f..f7c2a71b0764 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/app/main.ts b/app/main.ts index b6925ca23769..c0210c5525f7 100644 --- a/app/main.ts +++ b/app/main.ts @@ -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 = diff --git a/images/icons/v3/chat/chat-fill.svg b/images/icons/v3/chat/chat-fill.svg new file mode 100644 index 000000000000..ea4410b45f1c --- /dev/null +++ b/images/icons/v3/chat/chat-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/filter/filter.svg b/images/icons/v3/filter/filter.svg new file mode 100644 index 000000000000..2fbe7d54e988 --- /dev/null +++ b/images/icons/v3/filter/filter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/menu/menu.svg b/images/icons/v3/menu/menu.svg new file mode 100644 index 000000000000..ea40377d8844 --- /dev/null +++ b/images/icons/v3/menu/menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/phone/phone-plus-light.svg b/images/icons/v3/phone/phone-plus-light.svg new file mode 100644 index 000000000000..296632db70e5 --- /dev/null +++ b/images/icons/v3/phone/phone-plus-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/photo/phone-fill.svg b/images/icons/v3/photo/phone-fill.svg new file mode 100644 index 000000000000..d64465d12e11 --- /dev/null +++ b/images/icons/v3/photo/phone-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/photo/phone.svg b/images/icons/v3/photo/phone.svg new file mode 100644 index 000000000000..3bc73836a42b --- /dev/null +++ b/images/icons/v3/photo/phone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/stories/stories-fill.svg b/images/icons/v3/stories/stories-fill.svg new file mode 100644 index 000000000000..6c7002405d3e --- /dev/null +++ b/images/icons/v3/stories/stories-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index 658421557677..c910be6476cf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/patches/react-aria-components+1.0.0-alpha.3.patch b/patches/react-aria-components+1.0.0-alpha.3.patch new file mode 100644 index 000000000000..6c18387e1a82 --- /dev/null +++ b/patches/react-aria-components+1.0.0-alpha.3.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-aria-components/dist/types.d.ts b/node_modules/react-aria-components/dist/types.d.ts +index eb908b4..6cd530f 100644 +--- a/node_modules/react-aria-components/dist/types.d.ts ++++ b/node_modules/react-aria-components/dist/types.d.ts +@@ -44,7 +44,7 @@ interface SlotProps { + /** A slot name for the component. Slots allow the component to receive props from a parent component. */ + slot?: string; + } +-export function useContextProps(props: T & SlotProps, ref: React.ForwardedRef, context: React.Context>): [T, React.RefObject]; ++export function useContextProps(props: T & SlotProps, ref: React.ForwardedRef, context: React.Context>): [T, React.RefObject]; + interface CollectionProps extends Omit, 'children'> { + /** The contents of the collection. */ + children?: ReactNode | ((item: T) => ReactElement); diff --git a/protos/SignalService.proto b/protos/SignalService.proto index d8382be35795..61ee94c1c1ad 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -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 { diff --git a/sticker-creator/src/colors.scss b/sticker-creator/src/colors.scss index fc3ca03a03cb..a9da725b369d 100644 --- a/sticker-creator/src/colors.scss +++ b/sticker-creator/src/colors.scss @@ -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); diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss new file mode 100644 index 000000000000..914e1f6ba62c --- /dev/null +++ b/stylesheets/_conversation.scss @@ -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; + } +} diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 2cfb3d595f3f..8d3525c94bd6 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -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; + } + } +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 4fb344e4dacc..b40a4b5a9f15 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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; diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index 5c7d4d5f776f..79ad8f81c6c9 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -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; diff --git a/stylesheets/components/Button.scss b/stylesheets/components/Button.scss index 9cced7b58bee..07e6f41751f7 100644 --- a/stylesheets/components/Button.scss +++ b/stylesheets/components/Button.scss @@ -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'); } diff --git a/stylesheets/components/CallsTab.scss b/stylesheets/components/CallsTab.scss new file mode 100644 index 000000000000..a1d69c57629e --- /dev/null +++ b/stylesheets/components/CallsTab.scss @@ -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); + } +} diff --git a/stylesheets/components/ConversationDetails.scss b/stylesheets/components/ConversationDetails.scss index 2bc0fe438144..aa6365793e33 100644 --- a/stylesheets/components/ConversationDetails.scss +++ b/stylesheets/components/ConversationDetails.scss @@ -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; +} diff --git a/stylesheets/components/MyStories.scss b/stylesheets/components/MyStories.scss index 4712f4bc4d2f..a389fe24e0ed 100644 --- a/stylesheets/components/MyStories.scss +++ b/stylesheets/components/MyStories.scss @@ -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; + } + } } diff --git a/stylesheets/components/NavSidebar.scss b/stylesheets/components/NavSidebar.scss new file mode 100644 index 000000000000..4462258ce926 --- /dev/null +++ b/stylesheets/components/NavSidebar.scss @@ -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; +} diff --git a/stylesheets/components/NavTabs.scss b/stylesheets/components/NavTabs.scss new file mode 100644 index 000000000000..1bc749235409 --- /dev/null +++ b/stylesheets/components/NavTabs.scss @@ -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; +} diff --git a/stylesheets/components/SearchInput.scss b/stylesheets/components/SearchInput.scss index 158efddf95e5..13a482bc7867 100644 --- a/stylesheets/components/SearchInput.scss +++ b/stylesheets/components/SearchInput.scss @@ -3,9 +3,8 @@ .module-SearchInput { &__container { - margin-inline: 16px; - margin-bottom: 8px; position: relative; + flex: 1 0 0; } &__icon { diff --git a/stylesheets/components/Stories.scss b/stylesheets/components/Stories.scss index 10c485e3f277..b649f4861d89 100644 --- a/stylesheets/components/Stories.scss +++ b/stylesheets/components/Stories.scss @@ -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); + } +} diff --git a/stylesheets/components/StoryListItem.scss b/stylesheets/components/StoryListItem.scss index 5f608551ad81..f06e4dfdb034 100644 --- a/stylesheets/components/StoryListItem.scss +++ b/stylesheets/components/StoryListItem.scss @@ -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'); } } diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 0f306c859bce..b86f31e3a6e3 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -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'; diff --git a/ts/Bytes.ts b/ts/Bytes.ts index d7894cffe8c7..1934cc9eac92 100644 --- a/ts/Bytes.ts +++ b/ts/Bytes.ts @@ -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); diff --git a/ts/Crypto.ts b/ts/Crypto.ts index 587a08d7af7f..ceb3273b7046 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -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 { - const uuids = new Array(); - 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); diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index b8f7855972da..47472541929e 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -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'; diff --git a/ts/background.ts b/ts/background.ts index 1d766e5d03b3..7640926c4a69 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -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 { 'callEventSync', queuedEventListener(onCallEventSync, false) ); + messageReceiver.addEventListener( + 'callLogEventSync', + queuedEventListener(onCallLogEventSync, false) + ); }); ourProfileKeyService.initialize(window.storage); @@ -1121,6 +1130,7 @@ export async function startApp(): Promise { loadInitialBadgesState(), loadStories(), loadDistributionLists(), + loadCallsHistory(), window.textsecure.storage.protocol.hydrateCaches(), (async () => { mainWindowStats = await window.SignalContext.getMainWindowStats(); @@ -1174,6 +1184,7 @@ export async function startApp(): Promise { menuOptions, stories: getStoriesForRedux(), storyDistributionLists: getDistributionListsForRedux(), + callsHistory: getCallsHistoryForRedux(), }); const store = window.Signal.State.createStore(initialState); @@ -1193,6 +1204,10 @@ export async function startApp(): Promise { 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( diff --git a/ts/components/App.tsx b/ts/components/App.tsx index a350b76c8477..11e1b983b152 100644 --- a/ts/components/App.tsx +++ b/ts/components/App.tsx @@ -25,9 +25,7 @@ type PropsType = { registerSingleDevice: (number: string, code: string) => Promise; 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 }))} diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index e3db4cacb3cd..8493e906f8bf 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -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 = ( -
- - + {hasPendingUpdate && ( - + <> +
+ + )} ); diff --git a/ts/components/Button.tsx b/ts/components/Button.tsx index ee0485ed881b..ce39c7bb45f3 100644 --- a/ts/components/Button.tsx +++ b/ts/components/Button.tsx @@ -33,6 +33,7 @@ export enum ButtonVariant { export enum ButtonIconType { audio = 'audio', + message = 'message', muted = 'muted', search = 'search', unmuted = 'unmuted', diff --git a/ts/components/CallsList.tsx b/ts/components/CallsList.tsx new file mode 100644 index 000000000000..563bfadea695 --- /dev/null +++ b/ts/components/CallsList.tsx @@ -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 ; +} + +type SearchResults = Readonly<{ + count: number; + items: ReadonlyArray; +}>; + +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; + getCallHistoryGroups: ( + options: CallHistoryFilterOptions, + pagination: CallHistoryPagination + ) => Promise>; + 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(null); + const listRef = useRef(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 ( +
+ } + title={ + + } + subtitle={ + + } + /> +
+ ); + } + + 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 ( +
+ + } + trailing={ + + } + title={ + + + + } + subtitle={ + + {item.children.length > 1 ? `(${item.children.length}) ` : ''} + {statusText} ·{' '} + + + } + onClick={() => { + onSelectCallHistoryGroup(conversation.id, item); + }} + /> +
+ ); + }, + [ + searchState, + getConversation, + selectedCallHistoryGroup, + onSelectCallHistoryGroup, + i18n, + ] + ); + + const handleSearchInputChange = useCallback( + (event: ChangeEvent) => { + 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 ( + <> + + + + + + {hasEmptyResults && ( +

+ {currentQuery === '' ? ( + i18n('icu:CallsList__EmptyState--noQuery') + ) : ( + , + }} + /> + )} +

+ )} + + + {(ref, size) => { + return ( +
+ {size != null && ( + + {({ onRowsRendered, registerChild }) => { + return ( + + ); + }} + + )} +
+ ); + }} +
+ + ); +} diff --git a/ts/components/CallsNewCall.tsx b/ts/components/CallsNewCall.tsx new file mode 100644 index 000000000000..ec3dfc6e29b9 --- /dev/null +++ b/ts/components/CallsNewCall.tsx @@ -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; + 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) => { + setQueryInput(event.currentTarget.value); + }, + [] + ); + + const handleSearchInputClear = useCallback(() => { + setQueryInput(''); + }, []); + + const rows = useMemo((): ReadonlyArray => { + let result: Array = []; + 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 ( +
+ {item.title} +
+ ); + } + + const callButtonsDisabled = activeCall != null; + + return ( +
+ + } + title={} + trailing={ +
+ {item.conversation.type === 'direct' && ( + + )} + +
+ } + onClick={() => { + onSelectConversation(item.conversation.id); + }} + /> +
+ ); + }, + [ + rows, + i18n, + activeCall, + onSelectConversation, + onOutgoingAudioCallInConversation, + onOutgoingVideoCallInConversation, + ] + ); + + return ( + <> + + + + {rows.length === 0 && ( +
+ {query === '' ? ( + i18n('icu:CallsNewCall__EmptyState--noQuery') + ) : ( + , + }} + /> + )} +
+ )} + {rows.length > 0 && ( + + {(ref, size) => { + return ( +
+ {size != null && ( + + )} +
+ ); + }} +
+ )} + + ); +} diff --git a/ts/components/CallsTab.tsx b/ts/components/CallsTab.tsx new file mode 100644 index 000000000000..4aeb011e8f8f --- /dev/null +++ b/ts/components/CallsTab.tsx @@ -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; + getCallHistoryGroupsCount: ( + options: CallHistoryFilterOptions + ) => Promise; + getCallHistoryGroups: ( + options: CallHistoryFilterOptions, + pagination: CallHistoryPagination + ) => Promise>; + 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 ( + <> +
+ { + updateSidebarView(CallsTabSidebarView.CallsListView); + } + : null + } + onToggleNavTabsCollapse={onToggleNavTabsCollapse} + requiresFullWidth + preferredLeftPaneWidth={preferredLeftPaneWidth} + savePreferredLeftPaneWidth={savePreferredLeftPaneWidth} + actions={ + <> + {sidebarView === CallsTabSidebarView.CallsListView && ( + <> + } + label={i18n('icu:CallsTab__NewCallActionLabel')} + onClick={() => { + updateSidebarView(CallsTabSidebarView.NewCallView); + }} + /> + + {({ openMenu, onKeyDown }) => { + return ( + } + label={i18n('icu:CallsTab__MoreActionsLabel')} + /> + ); + }} + + + )} + + } + > + {sidebarView === CallsTabSidebarView.CallsListView && ( + + )} + {sidebarView === CallsTabSidebarView.NewCallView && ( + + )} + + {selected == null ? ( +
+ {i18n('icu:CallsTab__EmptyStateText')} +
+ ) : ( +
+ {renderConversationDetails( + selected.conversationId, + selected.callHistoryGroup + )} +
+ )} +
+ {confirmClearCallHistoryDialogOpen && ( + + {i18n('icu:CallsTab__ConfirmClearCallHistory__Body')} + + )} + + ); +} diff --git a/ts/components/ChatsTab.tsx b/ts/components/ChatsTab.tsx new file mode 100644 index 000000000000..e8dfd3f0948a --- /dev/null +++ b/ts/components/ChatsTab.tsx @@ -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 ( + <> +
+ {renderLeftPane({ + collapsed: navTabsCollapsed, + onToggleCollapse: onToggleNavTabsCollapse, + })} +
+
+
+ {selectedConversationId && ( +
+ {renderConversationView()} +
+ )} + {!prevConversationId && ( +
+ {renderMiniPlayer({ shouldFlow: false })} +
+

+ {getEnvironment() !== Environment.Staging + ? i18n('icu:welcomeToSignal') + : 'THIS IS A STAGING DESKTOP'} +

+

+ +

+
+ )} +
+ + ); +} diff --git a/ts/components/ContextMenu.tsx b/ts/components/ContextMenu.tsx index b9af3906a881..262935a528c5 100644 --- a/ts/components/ContextMenu.tsx +++ b/ts/components/ContextMenu.tsx @@ -291,13 +291,18 @@ export function ContextMenu({ 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 = (
= ({ {...args} firstEnvelopeTimestamp={firstEnvelopeTimestamp} envelopeTimestamp={envelopeTimestamp} - renderConversationView={() =>
} renderCustomizingPreferredReactionsModal={() =>
} - renderLeftPane={() =>
} - renderMiniPlayer={() =>
} /> ); }; diff --git a/ts/components/Inbox.tsx b/ts/components/Inbox.tsx index 558be808a256..74a623352110 100644 --- a/ts/components/Inbox.tsx +++ b/ts/components/Inbox.tsx @@ -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({ <>
- -
{renderLeftPane()}
- -
-
- {selectedConversationId && ( -
- {renderConversationView()} -
- )} - {!prevConversationId && ( -
- {renderMiniPlayer({ shouldFlow: false })} -
-

- {getEnvironment() !== Environment.Staging - ? i18n('icu:welcomeToSignal') - : 'THIS IS A STAGING DESKTOP'} -

-

- -

-
- )} -
+ {renderNavTabs({ + navTabsCollapsed, + onToggleNavTabsCollapse, + renderChatsTab, + renderCallsTab, + renderStoriesTab, + })}
{activeModal} diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index 9d5ca9eaa325..38d1807beb67 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -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: () =>
, renderMessageSearchResult: (id: string) => ( { toggleConversationInChooseMembers: action( 'toggleConversationInChooseMembers' ), + toggleNavTabsCollapse: action('toggleNavTabsCollapse'), updateSearchTerm: action('updateSearchTerm'), ...overrideProps, diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index c554f9772f59..e1e6f03cfef5 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -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 ( - + + {challengeStatus !== 'idle' && + renderCaptchaDialog({ + onSkip() { + setChallengeStatus('idle'); + }, + })} + {crashReportCount > 0 && renderCrashReportDialog()} + + ); } diff --git a/ts/components/MainHeader.stories.tsx b/ts/components/MainHeader.stories.tsx deleted file mode 100644 index 04fdd9773326..000000000000 --- a/ts/components/MainHeader.stories.tsx +++ /dev/null @@ -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 = 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, -}; diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx deleted file mode 100644 index 266d521a3604..000000000000 --- a/ts/components/MainHeader.tsx +++ /dev/null @@ -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(null); - const [popperElement, setPopperElement] = useState(null); - const [portalElement, setPortalElement] = useState(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 ( -
-
- ` needs it to determine blurring. - sharedGroupNames={[]} - size={AvatarSize.TWENTY_EIGHT} - onClick={() => { - setShowAvatarPopup(true); - }} - /> - {hasPendingUpdate && ( -
- )} -
- {showAvatarPopup && - portalElement != null && - createPortal( -
- { - toggleProfileEditor(); - setShowAvatarPopup(false); - }} - onStartUpdate={() => { - startUpdate(); - setShowAvatarPopup(false); - }} - onViewPreferences={() => { - showSettings(); - setShowAvatarPopup(false); - }} - onViewArchive={() => { - showArchivedConversations(); - setShowAvatarPopup(false); - }} - style={EMPTY_OBJECT} - /> -
, - portalElement - )} -
- {areStoriesEnabled && ( - - )} -
-
- ); -} diff --git a/ts/components/NavSidebar.tsx b/ts/components/NavSidebar.tsx new file mode 100644 index 000000000000..b5d7b9b9f3d2 --- /dev/null +++ b/ts/components/NavSidebar.tsx @@ -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; + onKeyDown?: KeyboardEventHandler; +}): JSX.Element { + return ( + + ); +} + +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 ( +
+ {!hideHeader && ( +
+ {onBack == null && navTabsCollapsed && ( + + )} +
+ {onBack != null && ( + + )} +

+ {title} +

+ {actions && ( +
{actions}
+ )} +
+
+ )} + +
{children}
+ + {/* 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 */} +
+
+ ); +} + +export function NavSidebarSearchHeader({ + children, +}: { + children: ReactNode; +}): JSX.Element { + return
{children}
; +} diff --git a/ts/components/NavTabs.tsx b/ts/components/NavTabs.tsx new file mode 100644 index 000000000000..022b01a8257a --- /dev/null +++ b/ts/components/NavTabs.tsx @@ -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 ( + + {label} + + + + {badge && {badge}} + + + + ); +} + +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 ( + + ); +} + +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(null); + const [popperElement, setPopperElement] = useState(null); + const [portalElement, setPortalElement] = useState(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 ( + + + + + {renderChatsTab} + + + {renderCallsTab} + + + {renderStoriesTab} + + + + ); +} diff --git a/ts/components/SignalConnectionsModal.tsx b/ts/components/SignalConnectionsModal.tsx index 6db2824831b8..552c6e53baa5 100644 --- a/ts/components/SignalConnectionsModal.tsx +++ b/ts/components/SignalConnectionsModal.tsx @@ -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} >
diff --git a/ts/components/StoriesAddStoryButton.tsx b/ts/components/StoriesAddStoryButton.tsx index 477f0bfd12bb..fe300dc411ea 100644 --- a/ts/components/StoriesAddStoryButton.tsx +++ b/ts/components/StoriesAddStoryButton.tsx @@ -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} diff --git a/ts/components/StoriesPane.tsx b/ts/components/StoriesPane.tsx index 518398d2063b..bbb614a6974e 100644 --- a/ts/components/StoriesPane.tsx +++ b/ts/components/StoriesPane.tsx @@ -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 = { getFn: (story, path) => { @@ -70,8 +67,8 @@ export type PropsType = { showConversation: ShowConversationType; showToast: ShowToastAction; stories: Array; + 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 ( <> -
-
- { - setSearchTerm(event.target.value); - }} - placeholder={i18n('icu:search')} - value={searchTerm} - /> +
{ showConversation({ conversationId }); - toggleStoriesView(); }} onHideStory={toggleHideStories} onMediaPlaybackStart={onMediaPlaybackStart} queueStoryDownload={queueStoryDownload} story={story.storyView} + theme={theme} viewUserStories={viewUserStories} /> ))} @@ -191,6 +150,7 @@ export function StoriesPane({ <> + )} {!conversation.isMe && ( <> {isMuted ? i18n('icu:unmute') : i18n('icu:mute')} - + {selectedNavTab !== NavTab.Calls && ( + + )}
+ {callHistoryGroup && ( + +

+ {formatDate(i18n, callHistoryGroup.timestamp)} +

+
    + {callHistoryGroup.children.map(child => { + return ( +
  1. + + + {describeCallHistory( + i18n, + callHistoryGroup.type, + callHistoryGroup.direction, + callHistoryGroup.status + )} + + + {formatTime(i18n, child.timestamp, Date.now(), false)} + +
  2. + ); + })} +
+
+ )} + {!isGroup || canEditGroupInfo ? ( ) : null} - - } - label={i18n('icu:showChatColorEditor')} - onClick={() => { - pushPanelForConversation({ - type: PanelType.ChatColorEditor, - }); - }} - right={ -
- } - /> + {selectedNavTab === NavTab.Chats && ( + + } + label={i18n('icu:showChatColorEditor')} + onClick={() => { + pushPanelForConversation({ + type: PanelType.ChatColorEditor, + }); + }} + right={ +
+ } + /> + )} {isGroup && ( boolean; @@ -36,10 +35,6 @@ function useHasGlobalModal(): boolean { return useSelector(isShowingAnyModal); } -function useHasStories(): boolean { - return useSelector(shouldShowStoriesView); -} - function useHasCalling(): boolean { return useSelector(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( diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index b128795b4c0c..62d39f2cebe8 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -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; - callHistoryDetails?: CallHistoryDetailsFromDiskType; + callId?: string; canReplyToStory?: boolean; changedId?: string; dataMessage?: Uint8Array | null; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index ecef557627dc..815190f676a1 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -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; - 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 { - 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 { - // 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 diff --git a/ts/models/messages.ts b/ts/models/messages.ts index e57fdb9dd6cd..75c481770280 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -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 { 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 { let queueStoryForDownload = false; if (isStory(message.attributes)) { - const isShowingStories = shouldShowStoriesView(reduxState); - - queueStoryForDownload = - isShowingStories || - (await shouldDownloadStory(conversation.attributes)); + queueStoryForDownload = await shouldDownloadStory( + conversation.attributes + ); } const shouldHoldOffDownload = diff --git a/ts/services/callHistoryLoader.ts b/ts/services/callHistoryLoader.ts new file mode 100644 index 000000000000..d1a1533ba80f --- /dev/null +++ b/ts/services/callHistoryLoader.ts @@ -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; + +export async function loadCallsHistory(): Promise { + callsHistoryData = await dataInterface.getAllCallHistory(); +} + +export function getCallsHistoryForRedux(): ReadonlyArray { + strictAssert(callsHistoryData != null, 'callHistory has not been loaded'); + return callsHistoryData; +} diff --git a/ts/services/calling.ts b/ts/services/calling.ts index add2d3c995c3..a9af59f2e8d1 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -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 { + 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 && diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index e306ebade860..05b837b73161 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -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, diff --git a/ts/services/username.ts b/ts/services/username.ts index 57792f0ab809..4fa34cec9d93 100644 --- a/ts/services/username.ts +++ b/ts/services/username.ts @@ -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'; diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index a69147e93b33..b68cce108299 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -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; - getCallHistoryMessageByCallId( - conversationId: string, - callId: string - ): Promise; + getAllCallHistory: () => Promise>; + clearCallHistory: (beforeTimestamp: number) => Promise>; + getCallHistoryMessageByCallId(options: { + conversationId: string; + callId: string; + }): Promise; + getCallHistory( + callId: string, + peerId: string + ): Promise; + getCallHistoryGroupsCount(filter: CallHistoryFilter): Promise; + getCallHistoryGroups( + filter: CallHistoryFilter, + pagination: CallHistoryPagination + ): Promise>; + saveCallHistory(callHistory: CallHistoryDetails): Promise; hasGroupCallHistoryMessage: ( conversationId: string, eraId: string diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index ce0fcc3175f5..3ad4e70e190a 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -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 { +async function getAllCallHistory(): Promise> { + const db = getInstance(); + const [query] = sql` + SELECT * FROM callsHistory; + `; + return db.prepare(query).all(); +} + +async function clearCallHistory( + beforeTimestamp: number +): Promise> { + 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 { + 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 { const db = getInstance(); - const id: string | void = db - .prepare( - ` - 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 { + 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> { + const db = getInstance(); + const groupsData = groupsDataSchema.parse( + getCallHistoryGroupDataSync(db, false, filter, pagination) + ); + + const taken = new Set(); + + 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 { + 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 { DELETE FROM attachment_downloads; DELETE FROM badgeImageFiles; DELETE FROM badges; + DELETE FROM callsHistory; DELETE FROM conversations; DELETE FROM emojis; DELETE FROM groupCallRingCancellations; diff --git a/ts/sql/migrations/87-calls-history-table.ts b/ts/sql/migrations/87-calls-history-table.ts new file mode 100644 index 000000000000..1097054c5715 --- /dev/null +++ b/ts/sql/migrations/87-calls-history-table.ts @@ -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!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 86ce1e322f89..006386550620 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -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 { diff --git a/ts/sql/util.ts b/ts/sql/util.ts index b55f17c1e27b..76636aa2fd63 100644 --- a/ts/sql/util.ts +++ b/ts/sql/util.ts @@ -36,7 +36,7 @@ export function jsonToObject(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 ): QueryFragment { let query = ''; - const params: Array = []; + const params: Array = []; 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 = []; + const params: Array = []; 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 -]; +export type QueryTemplate = [string, ReadonlyArray]; /** * You can use tagged template literals to build SQL queries @@ -137,7 +148,7 @@ export type QueryTemplate = [ */ export function sql( strings: TemplateStringsArray, - ...values: ReadonlyArray + ...values: ReadonlyArray ): QueryTemplate { const [{ fragment }, params] = sqlFragment(strings, ...values); return [fragment, params]; diff --git a/ts/state/actions.ts b/ts/state/actions.ts index 0e3664204ba8..efda4bc80fa4 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -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, diff --git a/ts/state/ducks/callHistory.ts b/ts/state/ducks/callHistory.ts new file mode 100644 index 000000000000..223747a4757c --- /dev/null +++ b/ts/state/ducks/callHistory.ts @@ -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; +}>; + +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; + } +} diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index ffba08908683..3ab51db59567 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -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( diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 292c7b9d4efc..46e4f8a7ca52 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -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 = getEmptyState(), - action: Readonly + 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; } diff --git a/ts/state/ducks/items.ts b/ts/state/ducks/items.ts index b2d0cdbdfec0..787f7fee745e 100644 --- a/ts/state/ducks/items.ts +++ b/ts/state/ducks/items.ts @@ -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 => - useBoundActions(actions); +export const useItemsActions = (): BoundActionCreatorsMapObject< + typeof actions +> => useBoundActions(actions); function putItem( key: K, @@ -292,6 +294,14 @@ function markHasCompletedSafetyNumberOnboarding(): ThunkAction< }; } +function toggleNavTabsCollapse( + navTabsCollapsed: boolean +): ThunkAction { + return dispatch => { + dispatch(putItem('navTabsCollapsed', navTabsCollapsed)); + }; +} + // Reducer export function getEmptyState(): ItemsStateType { diff --git a/ts/state/ducks/nav.ts b/ts/state/ducks/nav.ts new file mode 100644 index 000000000000..9b35caca9e4e --- /dev/null +++ b/ts/state/ducks/nav.ts @@ -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; + +// Action Creators + +function changeNavTab(selectedNavTab: NavTab): NavActionType { + return { + type: CHANGE_NAV_TAB, + payload: { selectedNavTab }, + }; +} + +export const actions = { + changeNavTab, +}; + +export const useNavActions = (): BoundActionCreatorsMapObject => + useBoundActions(actions); + +// Reducer + +export function getEmptyState(): NavStateType { + return { + selectedNavTab: NavTab.Chats, + }; +} + +export function reducer( + state: Readonly = getEmptyState(), + action: Readonly +): NavStateType { + if (action.type === CHANGE_NAV_TAB) { + return { + ...state, + selectedNavTab: action.payload.selectedNavTab, + }; + } + + return state; +} diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index 6f28f421d16b..ff75766dd13e 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -143,7 +143,6 @@ export type StoriesStateType = Readonly<{ addStoryData: AddStoryData; hasAllStoriesUnmuted: boolean; lastOpenedAtTimestamp: number | undefined; - openedAtTimestamp: number | undefined; replyState?: Readonly<{ messageId: string; replies: Array; @@ -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 = getEmptyState(), action: Readonly ): 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, diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index 626e255b34ab..18572b2fa49c 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -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; stories: Array; storyDistributionLists: Array; 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(), diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 171cd0b48ea3..477c82f1b7b8 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -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, diff --git a/ts/state/selectors/callHistory.ts b/ts/state/selectors/callHistory.ts new file mode 100644 index 000000000000..02b95ca50390 --- /dev/null +++ b/ts/state/selectors/callHistory.ts @@ -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); + }; + } +); diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 85cb87861d2a..3c9ea5be0737 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -308,16 +308,18 @@ export const getConversationComparator = createSelector( _getConversationComparator ); +type LeftPaneLists = Readonly<{ + conversations: ReadonlyArray; + archivedConversations: ReadonlyArray; + pinnedConversations: ReadonlyArray; +}>; + export const _getLeftPaneLists = ( lookup: ConversationLookupType, comparator: (left: ConversationType, right: ConversationType) => number, selectedConversation?: string, pinnedConversationIds?: ReadonlyArray -): { - conversations: Array; - archivedConversations: Array; - pinnedConversations: Array; -} => { +): LeftPaneLists => { const conversations: Array = []; const archivedConversations: Array = []; const pinnedConversations: Array = []; @@ -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) { + 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. diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index 75ebfb353fb8..e0551972383f 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -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) +); diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 26de4c6a3331..2b660c834ebb 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -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 | 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 diff --git a/ts/state/selectors/nav.ts b/ts/state/selectors/nav.ts new file mode 100644 index 000000000000..6109f67edb8d --- /dev/null +++ b/ts/state/selectors/nav.ts @@ -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; +}); diff --git a/ts/state/selectors/stories.ts b/ts/state/selectors/stories.ts index 261aab66b33d..ce210048a3f4 100644 --- a/ts/state/selectors/stories.ts +++ b/ts/state/selectors/stories.ts @@ -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) diff --git a/ts/state/selectors/timeline.ts b/ts/state/selectors/timeline.ts index 54a84a850ca5..9cb982ff395d 100644 --- a/ts/state/selectors/timeline.ts +++ b/ts/state/selectors/timeline.ts @@ -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, diff --git a/ts/state/smart/App.tsx b/ts/state/smart/App.tsx index f0f3a1df5101..39a46624a5ec 100644 --- a/ts/state/smart/App.tsx +++ b/ts/state/smart/App.tsx @@ -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: () => , renderLightbox: () => , - isShowingStoriesView: shouldShowStoriesView(state), - renderStories: (closeView: () => unknown) => ( - - - - ), hasSelectedStoryData: hasSelectedStoryData(state), renderStoryViewer: (closeView: () => unknown) => ( diff --git a/ts/state/smart/CallsTab.tsx b/ts/state/smart/CallsTab.tsx new file mode 100644 index 000000000000..dbf37bd7c0d4 --- /dev/null +++ b/ts/state/smart/CallsTab.tsx @@ -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, + 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 ( + + ); +} + +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 ( + + ); +} diff --git a/ts/state/smart/ChatsTab.tsx b/ts/state/smart/ChatsTab.tsx new file mode 100644 index 000000000000..5f1a6feb0275 --- /dev/null +++ b/ts/state/smart/ChatsTab.tsx @@ -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 ; +} + +function renderLeftPane(props: NavTabPanelProps) { + return ; +} + +function renderMiniPlayer(options: { shouldFlow: boolean }) { + return ; +} + +export function SmartChatsTab(): JSX.Element { + const i18n = useSelector(getIntl); + const navTabsCollapsed = useSelector(getNavTabsCollapsed); + const { selectedConversationId, targetedMessage, targetedMessageSource } = + useSelector( + 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 ( + + ); +} diff --git a/ts/state/smart/CompositionTextArea.tsx b/ts/state/smart/CompositionTextArea.tsx index ed92e9f257a8..72769bbf5fad 100644 --- a/ts/state/smart/CompositionTextArea.tsx +++ b/ts/state/smart/CompositionTextArea.tsx @@ -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 { diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 12a4209828cc..b4f43fe9fe16 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -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, diff --git a/ts/state/smart/CustomizingPreferredReactionsModal.tsx b/ts/state/smart/CustomizingPreferredReactionsModal.tsx index f5ecbdfc284d..8d9ddaad827a 100644 --- a/ts/state/smart/CustomizingPreferredReactionsModal.tsx +++ b/ts/state/smart/CustomizingPreferredReactionsModal.tsx @@ -7,7 +7,7 @@ import { useSelector } from 'react-redux'; import type { StateType } from '../reducer'; import type { LocalizerType } from '../../types/Util'; import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions'; -import { useActions as useItemsActions } from '../ducks/items'; +import { useItemsActions } from '../ducks/items'; import { getIntl } from '../selectors/user'; import { getEmojiSkinTone } from '../selectors/items'; import { useRecentEmojis } from '../selectors/emojis'; diff --git a/ts/state/smart/Inbox.tsx b/ts/state/smart/Inbox.tsx index 0cfc411178f0..8cdaaa058f78 100644 --- a/ts/state/smart/Inbox.tsx +++ b/ts/state/smart/Inbox.tsx @@ -4,32 +4,37 @@ import React from 'react'; import { useSelector } from 'react-redux'; import type { AppStateType } from '../ducks/app'; -import type { ConversationsStateType } from '../ducks/conversations'; import type { StateType } from '../reducer'; import { Inbox } from '../../components/Inbox'; import { getIntl } from '../selectors/user'; -import { SmartConversationView } from './ConversationView'; import { SmartCustomizingPreferredReactionsModal } from './CustomizingPreferredReactionsModal'; -import { SmartLeftPane } from './LeftPane'; -import { useConversationsActions } from '../ducks/conversations'; -import { useGlobalModalActions } from '../ducks/globalModals'; import { getIsCustomizingPreferredReactions } from '../selectors/preferredReactions'; -import { SmartMiniPlayer } from './MiniPlayer'; +import type { SmartNavTabsProps } from './NavTabs'; +import { SmartNavTabs } from './NavTabs'; +import { SmartStoriesTab } from './StoriesTab'; +import { SmartCallsTab } from './CallsTab'; +import { useItemsActions } from '../ducks/items'; +import { getNavTabsCollapsed } from '../selectors/items'; +import { SmartChatsTab } from './ChatsTab'; -function renderConversationView() { - return ; +function renderChatsTab() { + return ; +} + +function renderCallsTab() { + return ; } function renderCustomizingPreferredReactionsModal() { return ; } -function renderMiniPlayer(options: { shouldFlow: boolean }) { - return ; +function renderNavTabs(props: SmartNavTabsProps) { + return ; } -function renderLeftPane() { - return ; +function renderStoriesTab() { + return ; } export function SmartInbox(): JSX.Element { @@ -46,17 +51,9 @@ export function SmartInbox(): JSX.Element { const { hasInitialLoadCompleted } = useSelector( state => state.app ); - const { selectedConversationId, targetedMessage, targetedMessageSource } = - useSelector( - state => state.conversations - ); - const { - onConversationClosed, - onConversationOpened, - scrollToMessage, - showConversation, - } = useConversationsActions(); - const { showWhatsNewModal } = useGlobalModalActions(); + + const navTabsCollapsed = useSelector(getNavTabsCollapsed); + const { toggleNavTabsCollapse } = useItemsActions(); return ( ); } diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 6ba918ab3561..64ff07d184e7 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -41,6 +41,7 @@ import { getPreferredLeftPaneWidth, getUsernamesEnabled, getContactManagementEnabled, + getNavTabsCollapsed, } from '../selectors/items'; import { getComposeAvatarData, @@ -70,7 +71,6 @@ import { getGroupSizeHardLimit, } from '../../groups/limits'; -import { SmartMainHeader } from './MainHeader'; import { SmartMessageSearchResult } from './MessageSearchResult'; import { SmartNetworkStatus } from './NetworkStatus'; import { SmartRelinkDialog } from './RelinkDialog'; @@ -80,9 +80,6 @@ import { SmartUpdateDialog } from './UpdateDialog'; import { SmartCaptchaDialog } from './CaptchaDialog'; import { SmartCrashReportDialog } from './CrashReportDialog'; -function renderMainHeader(): JSX.Element { - return ; -} function renderMessageSearchResult(id: string): JSX.Element { return ; } @@ -229,6 +226,7 @@ const mapStateToProps = (state: StateType) => { unsupportedOSDialogType, modeSpecificProps: getModeSpecificProps(state), + navTabsCollapsed: getNavTabsCollapsed(state), preferredWidthFromStorage: getPreferredLeftPaneWidth(state), selectedConversationId: getSelectedConversationId(state), targetedMessageId: getTargetedMessage(state)?.id, @@ -240,7 +238,6 @@ const mapStateToProps = (state: StateType) => { regionCode: getRegionCode(state), challengeStatus: state.network.challengeStatus, crashReportCount: state.crashReports.count, - renderMainHeader, renderMessageSearchResult, renderNetworkStatus, renderRelinkDialog, diff --git a/ts/state/smart/MainHeader.tsx b/ts/state/smart/MainHeader.tsx deleted file mode 100644 index 9cbf28061e33..000000000000 --- a/ts/state/smart/MainHeader.tsx +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2019 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { connect } from 'react-redux'; -import { mapDispatchToProps } from '../actions'; - -import { MainHeader } from '../../components/MainHeader'; -import type { StateType } from '../reducer'; - -import { getPreferredBadgeSelector } from '../selectors/badges'; -import { - getIntl, - getRegionCode, - getTheme, - getUserConversationId, - getUserNumber, -} from '../selectors/user'; -import { getMe } from '../selectors/conversations'; -import { getStoriesEnabled } from '../selectors/items'; -import { - getStoriesNotificationCount, - getHasAnyFailedStorySends, -} from '../selectors/stories'; - -const mapStateToProps = (state: StateType) => { - const me = getMe(state); - - return { - areStoriesEnabled: getStoriesEnabled(state), - hasPendingUpdate: Boolean(state.updates.didSnooze), - regionCode: getRegionCode(state), - ourConversationId: getUserConversationId(state), - ourNumber: getUserNumber(state), - ...me, - badge: getPreferredBadgeSelector(state)(me.badges), - theme: getTheme(state), - i18n: getIntl(state), - hasFailedStorySends: getHasAnyFailedStorySends(state), - unreadStoriesCount: getStoriesNotificationCount(state), - }; -}; - -const smart = connect(mapStateToProps, mapDispatchToProps); - -export const SmartMainHeader = smart(MainHeader); diff --git a/ts/state/smart/NavTabs.tsx b/ts/state/smart/NavTabs.tsx new file mode 100644 index 000000000000..8dac5321aace --- /dev/null +++ b/ts/state/smart/NavTabs.tsx @@ -0,0 +1,94 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import type { NavTabPanelProps } from '../../components/NavTabs'; +import { NavTabs } from '../../components/NavTabs'; +import { getIntl, getTheme } from '../selectors/user'; +import { + getAllConversationsUnreadStats, + getMe, +} from '../selectors/conversations'; +import { getPreferredBadgeSelector } from '../selectors/badges'; +import type { StateType } from '../reducer'; +import { + getHasAnyFailedStorySends, + getStoriesNotificationCount, +} from '../selectors/stories'; +import { showSettings } from '../../shims/Whisper'; +import { useGlobalModalActions } from '../ducks/globalModals'; +import { useUpdatesActions } from '../ducks/updates'; +import { getStoriesEnabled } from '../selectors/items'; +import { getSelectedNavTab } from '../selectors/nav'; +import type { NavTab } from '../ducks/nav'; +import { useNavActions } from '../ducks/nav'; + +export type SmartNavTabsProps = Readonly<{ + navTabsCollapsed: boolean; + onToggleNavTabsCollapse(navTabsCollapsed: boolean): void; + renderCallsTab(props: NavTabPanelProps): JSX.Element; + renderChatsTab(props: NavTabPanelProps): JSX.Element; + renderStoriesTab(props: NavTabPanelProps): JSX.Element; +}>; + +export function SmartNavTabs({ + navTabsCollapsed, + onToggleNavTabsCollapse, + renderCallsTab, + renderChatsTab, + renderStoriesTab, +}: SmartNavTabsProps): JSX.Element { + const i18n = useSelector(getIntl); + const selectedNavTab = useSelector(getSelectedNavTab); + const { changeNavTab } = useNavActions(); + const me = useSelector(getMe); + const badge = useSelector(getPreferredBadgeSelector)(me.badges); + const theme = useSelector(getTheme); + const storiesEnabled = useSelector(getStoriesEnabled); + const unreadConversationsStats = useSelector(getAllConversationsUnreadStats); + const unreadStoriesCount = useSelector(getStoriesNotificationCount); + const hasFailedStorySends = useSelector(getHasAnyFailedStorySends); + + const hasPendingUpdate = useSelector((state: StateType) => { + return state.updates.didSnooze; + }); + + const { toggleProfileEditor } = useGlobalModalActions(); + const { startUpdate } = useUpdatesActions(); + + const onNavTabSelected = useCallback( + (tab: NavTab) => { + // For some reason react-aria will call this more often than the tab + // actually changing. + if (tab !== selectedNavTab) { + changeNavTab(tab); + } + }, + [changeNavTab, selectedNavTab] + ); + + return ( + + ); +} diff --git a/ts/state/smart/ReactionPicker.tsx b/ts/state/smart/ReactionPicker.tsx index 66656bbc3ae7..c2dce45a3c1a 100644 --- a/ts/state/smart/ReactionPicker.tsx +++ b/ts/state/smart/ReactionPicker.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { useSelector } from 'react-redux'; import type { StateType } from '../reducer'; import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions'; -import { useActions as useItemsActions } from '../ducks/items'; +import { useItemsActions } from '../ducks/items'; import { getIntl } from '../selectors/user'; import { getPreferredReactionEmoji } from '../selectors/items'; diff --git a/ts/state/smart/StoriesSettingsModal.tsx b/ts/state/smart/StoriesSettingsModal.tsx index 1f538a1c5500..c5b2c19841bc 100644 --- a/ts/state/smart/StoriesSettingsModal.tsx +++ b/ts/state/smart/StoriesSettingsModal.tsx @@ -24,7 +24,7 @@ import { useStoriesActions } from '../ducks/stories'; import { useConversationsActions } from '../ducks/conversations'; export function SmartStoriesSettingsModal(): JSX.Element | null { - const { toggleStoriesView, setStoriesDisabled } = useStoriesActions(); + const { setStoriesDisabled } = useStoriesActions(); const { hideStoriesSettings, toggleSignalConnectionsModal } = useGlobalModalActions(); const { @@ -71,7 +71,6 @@ export function SmartStoriesSettingsModal(): JSX.Element | null { setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections} storyViewReceiptsEnabled={storyViewReceiptsEnabled} toggleSignalConnectionsModal={toggleSignalConnectionsModal} - toggleStoriesView={toggleStoriesView} setStoriesDisabled={setStoriesDisabled} /> ); diff --git a/ts/state/smart/Stories.tsx b/ts/state/smart/StoriesTab.tsx similarity index 78% rename from ts/state/smart/Stories.tsx rename to ts/state/smart/StoriesTab.tsx index 3c5ced0e6de1..b2ce54a08b6f 100644 --- a/ts/state/smart/Stories.tsx +++ b/ts/state/smart/StoriesTab.tsx @@ -1,20 +1,21 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { useEffect } from 'react'; import { useSelector } from 'react-redux'; import type { LocalizerType } from '../../types/Util'; import type { StateType } from '../reducer'; import { SmartStoryCreator } from './StoryCreator'; -import { Stories } from '../../components/Stories'; +import { StoriesTab } from '../../components/StoriesTab'; import { getMaximumAttachmentSizeInKb } from '../../types/AttachmentSize'; import type { ConfigKeyType } from '../../RemoteConfig'; import { getMe } from '../selectors/conversations'; -import { getIntl } from '../selectors/user'; +import { getIntl, getTheme } from '../selectors/user'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { getHasStoryViewReceiptSetting, + getNavTabsCollapsed, getPreferredLeftPaneWidth, getRemoteConfig, } from '../selectors/items'; @@ -22,19 +23,19 @@ import { getAddStoryData, getSelectedStoryData, getStories, - shouldShowStoriesView, } from '../selectors/stories'; import { useConversationsActions } from '../ducks/conversations'; import { useGlobalModalActions } from '../ducks/globalModals'; import { useStoriesActions } from '../ducks/stories'; import { useToastActions } from '../ducks/toast'; import { useAudioPlayerActions } from '../ducks/audioPlayer'; +import { useItemsActions } from '../ducks/items'; function renderStoryCreator(): JSX.Element { return ; } -export function SmartStories(): JSX.Element | null { +export function SmartStoriesTab(): JSX.Element | null { const storiesActions = useStoriesActions(); const { retryMessageSend, @@ -48,10 +49,6 @@ export function SmartStories(): JSX.Element | null { const i18n = useSelector(getIntl); - const isShowingStoriesView = useSelector( - shouldShowStoriesView - ); - const preferredWidthFromStorage = useSelector( getPreferredLeftPaneWidth ); @@ -79,12 +76,22 @@ export function SmartStories(): JSX.Element | null { ); const { pauseVoiceNotePlayer } = useAudioPlayerActions(); - if (!isShowingStoriesView) { - return null; - } + const preferredLeftPaneWidth = useSelector(getPreferredLeftPaneWidth); + const navTabsCollapsed = useSelector(getNavTabsCollapsed); + const { savePreferredLeftPaneWidth, toggleNavTabsCollapse } = + useItemsActions(); + + const theme = useSelector(getTheme); + + useEffect(() => { + storiesActions.markStoriesTabViewed(); + return () => { + storiesActions.clearStoriesTabState(); + }; + }, [storiesActions]); return ( - { toggleForwardMessagesModal([messageId]); }} @@ -100,14 +108,18 @@ export function SmartStories(): JSX.Element | null { saveAttachment(story.attachment, story.timestamp); } }} + onToggleNavTabsCollapse={toggleNavTabsCollapse} onMediaPlaybackStart={pauseVoiceNotePlayer} + preferredLeftPaneWidth={preferredLeftPaneWidth} preferredWidthFromStorage={preferredWidthFromStorage} renderStoryCreator={renderStoryCreator} retryMessageSend={retryMessageSend} + savePreferredLeftPaneWidth={savePreferredLeftPaneWidth} showConversation={showConversation} showStoriesSettings={showStoriesSettings} showToast={showToast} stories={stories} + theme={theme} toggleHideStories={toggleHideStories} isViewingStory={selectedStoryData !== undefined} isStoriesSettingsVisible={isStoriesSettingsVisible} diff --git a/ts/state/smart/StoryCreator.tsx b/ts/state/smart/StoryCreator.tsx index 9eff75b73b6a..9239edbc5e06 100644 --- a/ts/state/smart/StoryCreator.tsx +++ b/ts/state/smart/StoryCreator.tsx @@ -35,7 +35,7 @@ import { processAttachment } from '../../util/processAttachment'; import { useConversationsActions } from '../ducks/conversations'; import { useActions as useEmojisActions } from '../ducks/emojis'; import { useGlobalModalActions } from '../ducks/globalModals'; -import { useActions as useItemsActions } from '../ducks/items'; +import { useItemsActions } from '../ducks/items'; import { useLinkPreviewActions } from '../ducks/linkPreviews'; import { useRecentEmojis } from '../selectors/emojis'; import { useStoriesActions } from '../ducks/stories'; diff --git a/ts/state/smart/StoryViewer.tsx b/ts/state/smart/StoryViewer.tsx index 4d52bcf468f5..fff50762146d 100644 --- a/ts/state/smart/StoryViewer.tsx +++ b/ts/state/smart/StoryViewer.tsx @@ -35,7 +35,7 @@ import { asyncShouldNeverBeCalled } from '../../util/shouldNeverBeCalled'; import { useActions as useEmojisActions } from '../ducks/emojis'; import { useConversationsActions } from '../ducks/conversations'; import { useRecentEmojis } from '../selectors/emojis'; -import { useActions as useItemsActions } from '../ducks/items'; +import { useItemsActions } from '../ducks/items'; import { useAudioPlayerActions } from '../ducks/audioPlayer'; import { useGlobalModalActions } from '../ducks/globalModals'; import { useStoriesActions } from '../ducks/stories'; @@ -136,7 +136,6 @@ export function SmartStoryViewer(): JSX.Element | null { onHideStory={toggleHideStories} onGoToConversation={senderId => { showConversation({ conversationId: senderId }); - storiesActions.toggleStoriesView(); }} onReactToStory={async (emoji, story) => { const { messageId } = story; diff --git a/ts/state/types.ts b/ts/state/types.ts index e6bbc441bc92..b5f11a5ff1a2 100644 --- a/ts/state/types.ts +++ b/ts/state/types.ts @@ -6,6 +6,7 @@ import type { actions as app } from './ducks/app'; import type { actions as audioPlayer } from './ducks/audioPlayer'; import type { actions as audioRecorder } from './ducks/audioRecorder'; import type { actions as badges } from './ducks/badges'; +import type { actions as callHistory } from './ducks/callHistory'; import type { actions as calling } from './ducks/calling'; import type { actions as composer } from './ducks/composer'; import type { actions as conversations } from './ducks/conversations'; @@ -35,6 +36,7 @@ export type ReduxActions = { audioPlayer: typeof audioPlayer; audioRecorder: typeof audioRecorder; badges: typeof badges; + callHistory: typeof callHistory; calling: typeof calling; composer: typeof composer; conversations: typeof conversations; diff --git a/ts/test-both/util/callingNotification_test.ts b/ts/test-both/util/callingNotification_test.ts index 5205eb691615..ef4fde22d5bb 100644 --- a/ts/test-both/util/callingNotification_test.ts +++ b/ts/test-both/util/callingNotification_test.ts @@ -2,11 +2,23 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; -import { getCallingNotificationText } from '../../util/callingNotification'; +import { + CallExternalState, + getCallingNotificationText, +} from '../../util/callingNotification'; import { CallMode } from '../../types/Calling'; import { setupI18n } from '../../util/setupI18n'; import enMessages from '../../../_locales/en/messages.json'; -import { getDefaultConversation } from '../helpers/getDefaultConversation'; +import { + getDefaultConversation, + getDefaultGroup, +} from '../helpers/getDefaultConversation'; +import { + CallDirection, + CallType, + GroupCallStatus, +} from '../../types/CallDisposition'; +import { getPeerIdFromConversation } from '../../util/callDisposition'; describe('calling notification helpers', () => { const i18n = setupI18n('en', enMessages); @@ -15,15 +27,24 @@ describe('calling notification helpers', () => { // Direct call behavior is not tested here. it('says that the call has ended', () => { + const callCreator = getDefaultConversation(); assert.strictEqual( getCallingNotificationText( { - callMode: CallMode.Group, - conversationId: 'abc123', - ended: true, + callHistory: { + callId: '123', + peerId: getPeerIdFromConversation(getDefaultGroup()), + ringerId: callCreator.uuid ?? null, + mode: CallMode.Group, + type: CallType.Group, + direction: CallDirection.Incoming, + timestamp: Date.now(), + status: GroupCallStatus.Missed, + }, + callCreator, + callExternalState: CallExternalState.Ended, deviceCount: 1, maxDevices: 23, - startedTime: Date.now(), }, i18n ), @@ -32,19 +53,26 @@ describe('calling notification helpers', () => { }); it("includes the creator's first name when describing a call", () => { - const conversation = getDefaultConversation({ + const callCreator = getDefaultConversation({ systemGivenName: 'Luigi', }); assert.strictEqual( getCallingNotificationText( { - callMode: CallMode.Group, - conversationId: 'abc123', - creator: conversation, - ended: false, + callHistory: { + callId: '123', + peerId: getPeerIdFromConversation(getDefaultGroup()), + ringerId: callCreator.uuid ?? null, + mode: CallMode.Group, + type: CallType.Group, + direction: CallDirection.Incoming, + timestamp: Date.now(), + status: GroupCallStatus.Ringing, + }, + callCreator, + callExternalState: CallExternalState.Active, deviceCount: 1, maxDevices: 23, - startedTime: Date.now(), }, i18n ), @@ -53,20 +81,27 @@ describe('calling notification helpers', () => { }); it("if the creator doesn't have a first name, falls back to their title", () => { - const conversation = getDefaultConversation({ + const callCreator = getDefaultConversation({ systemGivenName: undefined, title: 'Luigi Mario', }); assert.strictEqual( getCallingNotificationText( { - callMode: CallMode.Group, - conversationId: 'abc123', - creator: conversation, - ended: false, + callHistory: { + callId: '123', + peerId: getPeerIdFromConversation(getDefaultGroup()), + ringerId: callCreator.uuid ?? null, + mode: CallMode.Group, + type: CallType.Group, + direction: CallDirection.Incoming, + timestamp: Date.now(), + status: GroupCallStatus.Ringing, + }, + callCreator, + callExternalState: CallExternalState.Active, deviceCount: 1, maxDevices: 23, - startedTime: Date.now(), }, i18n ), @@ -75,19 +110,26 @@ describe('calling notification helpers', () => { }); it('has a special message if you were the one to start the call', () => { - const conversation = getDefaultConversation({ + const callCreator = getDefaultConversation({ isMe: true, }); assert.strictEqual( getCallingNotificationText( { - callMode: CallMode.Group, - conversationId: 'abc123', - creator: conversation, - ended: false, + callHistory: { + callId: '123', + peerId: getPeerIdFromConversation(getDefaultGroup()), + ringerId: callCreator.uuid ?? null, + mode: CallMode.Group, + type: CallType.Group, + direction: CallDirection.Outgoing, + timestamp: Date.now(), + status: GroupCallStatus.Ringing, + }, + callCreator, + callExternalState: CallExternalState.Active, deviceCount: 1, maxDevices: 23, - startedTime: Date.now(), }, i18n ), @@ -99,12 +141,20 @@ describe('calling notification helpers', () => { assert.strictEqual( getCallingNotificationText( { - callMode: CallMode.Group, - conversationId: 'abc123', - ended: false, + callHistory: { + callId: '123', + peerId: getPeerIdFromConversation(getDefaultGroup()), + ringerId: null, + mode: CallMode.Group, + type: CallType.Group, + direction: CallDirection.Outgoing, + timestamp: Date.now(), + status: GroupCallStatus.Ringing, + }, + callCreator: null, + callExternalState: CallExternalState.Active, deviceCount: 1, maxDevices: 23, - startedTime: Date.now(), }, i18n ), diff --git a/ts/test-electron/Crypto_test.ts b/ts/test-electron/Crypto_test.ts index 77577218a8f8..f0b027ac2165 100644 --- a/ts/test-electron/Crypto_test.ts +++ b/ts/test-electron/Crypto_test.ts @@ -27,9 +27,8 @@ import { hmacSha256, verifyHmacSha256, randomInt, - uuidToBytes, - bytesToUuid, } from '../Crypto'; +import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes'; const BUCKET_SIZES = [ 541, 568, 596, 626, 657, 690, 725, 761, 799, 839, 881, 925, 972, 1020, 1071, diff --git a/ts/test-electron/sql/getCallHistoryGroups_test.ts b/ts/test-electron/sql/getCallHistoryGroups_test.ts new file mode 100644 index 000000000000..dcc8d882cf20 --- /dev/null +++ b/ts/test-electron/sql/getCallHistoryGroups_test.ts @@ -0,0 +1,224 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import dataInterface from '../../sql/Client'; +import { UUID } from '../../types/UUID'; +import type { UUIDStringType } from '../../types/UUID'; + +import { CallMode } from '../../types/Calling'; +import type { + CallHistoryDetails, + CallHistoryGroup, +} from '../../types/CallDisposition'; +import { + CallDirection, + CallHistoryFilterStatus, + CallType, + DirectCallStatus, +} from '../../types/CallDisposition'; +import { strictAssert } from '../../util/assert'; +import type { ConversationAttributesType } from '../../model-types'; + +const { removeAll, getCallHistoryGroups, saveCallHistory, saveConversation } = + dataInterface; + +function getUuid(): UUIDStringType { + return UUID.generate().toString(); +} + +function toGroup(calls: Array): CallHistoryGroup { + const firstCall = calls.at(0); + strictAssert(firstCall != null, 'needs at least 1 item'); + return { + peerId: firstCall.peerId, + mode: firstCall.mode, + type: firstCall.type, + direction: firstCall.direction, + timestamp: firstCall.timestamp, + status: firstCall.status, + children: calls.map(call => { + return { callId: call.callId, timestamp: call.timestamp }; + }), + }; +} + +describe('sql/getCallHistoryGroups', () => { + beforeEach(async () => { + await removeAll(); + }); + + it('should merge related items in order', async () => { + const now = Date.now(); + const conversationId = getUuid(); + + function toCall(callId: string, timestamp: number) { + return { + callId, + peerId: conversationId, + ringerId: conversationId, + mode: CallMode.Direct, + type: CallType.Video, + direction: CallDirection.Incoming, + timestamp, + status: DirectCallStatus.Accepted, + }; + } + + const call1 = toCall('1', now - 10); + const call2 = toCall('2', now); + + await saveCallHistory(call1); + await saveCallHistory(call2); + + const groups = await getCallHistoryGroups( + { status: CallHistoryFilterStatus.All, conversationIds: null }, + { offset: 0, limit: 0 } + ); + + assert.deepEqual(groups, [toGroup([call2, call1])]); + }); + + it('should separate unrelated items in order', async () => { + const now = Date.now(); + const conversationId = getUuid(); + + function toCall(callId: string, timestamp: number, type: CallType) { + return { + callId, + peerId: conversationId, + ringerId: conversationId, + mode: CallMode.Direct, + type, + direction: CallDirection.Incoming, + timestamp, + status: DirectCallStatus.Accepted, + }; + } + + const call1 = toCall('1', now - 10, CallType.Video); + const call2 = toCall('2', now, CallType.Audio); + + await saveCallHistory(call1); + await saveCallHistory(call2); + + const groups = await getCallHistoryGroups( + { status: CallHistoryFilterStatus.All, conversationIds: null }, + { offset: 0, limit: 0 } + ); + + assert.deepEqual(groups, [toGroup([call2]), toGroup([call1])]); + }); + + it('should split groups that are contiguous', async () => { + const now = Date.now(); + const conversationId = getUuid(); + + function toCall(callId: string, timestamp: number, type: CallType) { + return { + callId, + peerId: conversationId, + ringerId: conversationId, + mode: CallMode.Direct, + type, + direction: CallDirection.Incoming, + timestamp, + status: DirectCallStatus.Accepted, + }; + } + + const call1 = toCall('1', now - 30, CallType.Video); + const call2 = toCall('2', now - 20, CallType.Video); + const call3 = toCall('3', now - 10, CallType.Audio); + const call4 = toCall('4', now, CallType.Video); + + await saveCallHistory(call1); + await saveCallHistory(call2); + await saveCallHistory(call3); + await saveCallHistory(call4); + + const groups = await getCallHistoryGroups( + { status: CallHistoryFilterStatus.All, conversationIds: null }, + { offset: 0, limit: 0 } + ); + + assert.deepEqual(groups, [ + toGroup([call4]), + toGroup([call3]), + toGroup([call2, call1]), + ]); + }); + + it('should search in the correct conversations', async () => { + const now = Date.now(); + + const conversation1Uuid = getUuid(); + const conversation2GroupId = 'groupId:2'; + + const conversation1: ConversationAttributesType = { + type: 'private', + version: 0, + id: 'id:1', + uuid: conversation1Uuid, + }; + + const conversation2: ConversationAttributesType = { + type: 'group', + version: 2, + id: 'id:2', + groupId: conversation2GroupId, + }; + + await saveConversation(conversation1); + await saveConversation(conversation2); + + function toCall( + callId: string, + timestamp: number, + mode: CallMode, + conversationId: string | UUIDStringType + ) { + return { + callId, + peerId: conversationId, + ringerId: null, + mode, + type: CallType.Video, + direction: CallDirection.Incoming, + timestamp, + status: DirectCallStatus.Accepted, + }; + } + + const call1 = toCall('1', now - 10, CallMode.Direct, conversation1Uuid); + const call2 = toCall('2', now, CallMode.Group, conversation2GroupId); + + await saveCallHistory(call1); + await saveCallHistory(call2); + + { + const groups = await getCallHistoryGroups( + { + status: CallHistoryFilterStatus.All, + conversationIds: [conversation1.id], + }, + { offset: 0, limit: 0 } + ); + + assert.deepEqual(groups, [toGroup([call1])]); + } + + { + const groups = await getCallHistoryGroups( + { + status: CallHistoryFilterStatus.All, + conversationIds: [conversation2.id], + }, + { offset: 0, limit: 0 } + ); + + assert.deepEqual(groups, [toGroup([call2])]); + } + }); +}); diff --git a/ts/test-electron/sql/getCallHistoryMessageByCallId_test.ts b/ts/test-electron/sql/getCallHistoryMessageByCallId_test.ts index e002d0f424af..335305bb8303 100644 --- a/ts/test-electron/sql/getCallHistoryMessageByCallId_test.ts +++ b/ts/test-electron/sql/getCallHistoryMessageByCallId_test.ts @@ -8,7 +8,6 @@ import { UUID } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID'; import type { MessageAttributesType } from '../../model-types.d'; -import { CallMode } from '../../types/Calling'; const { removeAll, @@ -40,15 +39,7 @@ describe('sql/getCallHistoryMessageByCallId', () => { sent_at: now - 10, received_at: now - 10, timestamp: now - 10, - callHistoryDetails: { - callId: '12345', - callMode: CallMode.Direct, - wasIncoming: true, - wasVideoCall: true, - wasDeclined: true, - acceptedTime: now - 10, - endedTime: undefined, - }, + callId: '12345', }; await saveMessages([callHistoryMessage], { @@ -56,12 +47,13 @@ describe('sql/getCallHistoryMessageByCallId', () => { ourUuid, }); - assert.lengthOf(await _getAllMessages(), 1); + const allMessages = await _getAllMessages(); + assert.lengthOf(allMessages, 1); - const messageId = await getCallHistoryMessageByCallId( + const message = await getCallHistoryMessageByCallId({ conversationId, - '12345' - ); - assert.strictEqual(messageId, callHistoryMessage.id); + callId: '12345', + }); + assert.strictEqual(message?.id, callHistoryMessage.id); }); }); diff --git a/ts/test-mock/messaging/stories_test.ts b/ts/test-mock/messaging/stories_test.ts index 91559d4a0ef1..d6b1aa90ce4a 100644 --- a/ts/test-mock/messaging/stories_test.ts +++ b/ts/test-mock/messaging/stories_test.ts @@ -214,10 +214,9 @@ describe('story/messaging', function unknownContacts() { debug('waiting for storage service sync to complete'); await app.waitForStorageService(); - const leftPane = window.locator('#LeftPane'); + await window.getByTestId('NavTabsItem--Stories').click(); debug('Create and send a story to the group'); - await leftPane.getByRole('button', { name: 'Stories' }).click(); await window.getByRole('button', { name: 'Add a story' }).first().click(); await window.getByRole('button', { name: 'Text story' }).click(); await window.locator('.TextAttachment').click(); diff --git a/ts/test-mock/pnp/send_gv2_invite_test.ts b/ts/test-mock/pnp/send_gv2_invite_test.ts index d790ec1a1868..143ed81ab58a 100644 --- a/ts/test-mock/pnp/send_gv2_invite_test.ts +++ b/ts/test-mock/pnp/send_gv2_invite_test.ts @@ -102,9 +102,9 @@ describe('pnp/send gv2 invite', function needsName() { debug('clicking compose and "New group" buttons'); - await leftPane.locator('.module-main-header__compose-icon').click(); + await window.getByRole('button', { name: 'New chat' }).click(); - await leftPane.locator('[data-testid=CreateNewGroupButton]').click(); + await leftPane.getByTestId('CreateNewGroupButton').click(); debug('inviting ACI member'); diff --git a/ts/test-mock/pnp/username_test.ts b/ts/test-mock/pnp/username_test.ts index e56f4567feef..e08ded4bcf95 100644 --- a/ts/test-mock/pnp/username_test.ts +++ b/ts/test-mock/pnp/username_test.ts @@ -156,9 +156,7 @@ describe('pnp/username', function needsName() { const window = await app.getWindow(); debug('opening avatar context menu'); - await window - .locator('.module-main-header .module-Avatar__contents') - .click(); + await window.getByRole('button', { name: 'Profile' }).click(); debug('opening profile editor'); await window @@ -288,7 +286,7 @@ describe('pnp/username', function needsName() { const window = await app.getWindow(); debug('entering username into search field'); - await window.locator('button[aria-label="New chat"]').click(); + await window.getByRole('button', { name: 'New chat' }).click(); const searchInput = window.locator('.module-SearchInput__container input'); await searchInput.type(CARL_USERNAME); diff --git a/ts/test-mock/rate-limit/story_test.ts b/ts/test-mock/rate-limit/story_test.ts index b7582ae28079..4ce4050e7baf 100644 --- a/ts/test-mock/rate-limit/story_test.ts +++ b/ts/test-mock/rate-limit/story_test.ts @@ -79,8 +79,9 @@ describe('story/no-sender-key', function needsName() { debug('Posting a new story'); { const storiesPane = window.locator('.Stories'); + const storiesCreator = window.locator('.StoryCreator'); - await window.locator('button.module-main-header__stories-icon').click(); + await window.getByTestId('NavTabsItem--Stories').click(); await storiesPane .locator('button.Stories__pane__add-story__button') @@ -93,13 +94,15 @@ describe('story/no-sender-key', function needsName() { .click(); debug('Focusing textarea'); - await storiesPane.locator('.TextAttachment__story').click(); + await storiesCreator.locator('.TextAttachment__story').click(); debug('Entering text'); - await storiesPane.locator('.TextAttachment__text__textarea').type('123'); + await storiesCreator + .locator('.TextAttachment__text__textarea') + .type('123'); debug('Clicking "Next"'); - await storiesPane + await storiesCreator .locator('.StoryCreator__toolbar button >> "Next"') .click(); diff --git a/ts/test-node/sql_migrations_test.ts b/ts/test-node/sql_migrations_test.ts index a65ff7b574c5..e024de09b293 100644 --- a/ts/test-node/sql_migrations_test.ts +++ b/ts/test-node/sql_migrations_test.ts @@ -18,6 +18,9 @@ import { SeenStatus } from '../MessageSeenStatus'; import { objectToJSON, sql, sqlJoin } from '../sql/util'; import type { MessageType } from '../sql/Interface'; import { BodyRange } from '../types/BodyRange'; +import { CallMode } from '../types/Calling'; +import { callHistoryDetailsSchema } from '../types/CallDisposition'; +import type { MessageAttributesType } from '../model-types'; const OUR_UUID = generateGuid(); @@ -3451,19 +3454,19 @@ describe('SQL migrations test', () => { updateToVersion(schemaVersion); const [query, params] = sql` EXPLAIN QUERY PLAN - SELECT + SELECT messages.rowid, mentionUuid FROM mentions - INNER JOIN messages - ON - messages.id = mentions.messageId + INNER JOIN messages + ON + messages.id = mentions.messageId AND mentions.mentionUuid IN ( ${sqlJoin(['a', 'b', 'c'], ', ')} - ) - AND messages.isViewOnce IS NOT 1 + ) + AND messages.isViewOnce IS NOT 1 AND messages.storyId IS NULL - + LIMIT 100; `; const { detail } = db.prepare(query).get(params); @@ -3574,4 +3577,86 @@ describe('SQL migrations test', () => { ); }); }); + + describe('updateToSchemaVersion87', () => { + it('pulls out call history messages into the new table', () => { + updateToVersion(86); + + const message1Id = generateGuid(); + const message2Id = generateGuid(); + const conversationId = generateGuid(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- using old types + const message1: MessageAttributesType & { callHistoryDetails: any } = { + id: message1Id, + type: 'call-history', + conversationId, + sent_at: Date.now() - 10, + received_at: Date.now() - 10, + timestamp: Date.now() - 10, + callHistoryDetails: { + callId: '123', + callMode: CallMode.Direct, + wasDeclined: false, + wasDeleted: false, + wasIncoming: false, + wasVideoCall: false, + acceptedTime: Date.now(), + endedTime: undefined, + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- using old types + const message2: MessageAttributesType & { callHistoryDetails: any } = { + id: message2Id, + type: 'call-history', + conversationId, + sent_at: Date.now(), + received_at: Date.now(), + timestamp: Date.now(), + callHistoryDetails: { + callMode: CallMode.Group, + creatorUuid: generateGuid(), + eraId: (0x123).toString(16), + startedTime: Date.now(), + }, + }; + + const [insertQuery, insertParams] = sql` + INSERT INTO messages ( + id, + conversationId, + type, + json + ) + VALUES + ( + ${message1Id}, + ${conversationId}, + ${message1.type}, + ${JSON.stringify(message1)} + ), + ( + ${message2Id}, + ${conversationId}, + ${message2.type}, + ${JSON.stringify(message2)} + ); + `; + + db.prepare(insertQuery).run(insertParams); + + updateToVersion(87); + + const [selectHistoryQuery] = sql` + SELECT * FROM callsHistory; + `; + + const rows = db.prepare(selectHistoryQuery).all(); + + for (const row of rows) { + callHistoryDetailsSchema.parse(row); + } + }); + }); }); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 799e42b290da..e4addf05b415 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -49,7 +49,7 @@ import { parseIntOrThrow } from '../util/parseIntOrThrow'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; import { Zone } from '../util/Zone'; import { DurationInSeconds, SECOND } from '../util/durations'; -import { bytesToUuid } from '../Crypto'; +import { bytesToUuid } from '../util/uuidToBytes'; import type { DownloadedAttachmentType } from '../types/Attachment'; import { Address } from '../types/Address'; import { QualifiedAddress } from '../types/QualifiedAddress'; @@ -88,6 +88,7 @@ import type { UnprocessedType, } from './Types.d'; import { + CallEventSyncEvent, EmptyEvent, EnvelopeQueuedEvent, EnvelopeUnsealedEvent, @@ -113,7 +114,7 @@ import { ViewSyncEvent, ContactSyncEvent, StoryRecipientUpdateEvent, - CallEventSyncEvent, + CallLogEventSyncEvent, } from './messageReceiverEvents'; import * as log from '../logging/log'; import * as durations from '../util/durations'; @@ -128,6 +129,8 @@ import { isOlderThan } from '../util/timestamp'; import { inspectUnknownFieldTags } from '../util/inspectProtobufs'; import { incrementMessageCounter } from '../util/incrementMessageCounter'; import { filterAndClean } from '../types/BodyRange'; +import { getCallEventForProto } from '../util/callDisposition'; +import { CallLogEvent } from '../types/CallDisposition'; const GROUPV2_ID_LENGTH = 32; const RETRY_TIMEOUT = 2 * 60 * 1000; @@ -640,6 +643,11 @@ export default class MessageReceiver handler: (ev: CallEventSyncEvent) => void ): void; + public override addEventListener( + name: 'callLogEventSync', + handler: (ev: CallLogEventSyncEvent) => void + ): void; + public override addEventListener(name: string, handler: EventHandler): void { return super.addEventListener(name, handler); } @@ -3041,6 +3049,9 @@ export default class MessageReceiver if (syncMessage.callEvent) { return this.handleCallEvent(envelope, syncMessage.callEvent); } + if (syncMessage.callLogEvent) { + return this.handleCallLogEvent(envelope, syncMessage.callLogEvent); + } this.removeFromCache(envelope); const envelopeId = getEnvelopeId(envelope); @@ -3353,57 +3364,16 @@ export default class MessageReceiver ): Promise { const logId = getEnvelopeId(envelope); log.info('MessageReceiver.handleCallEvent', logId); - const { peerUuid, callId, type } = callEvent; - - if (!peerUuid) { - throw new Error('MessageReceiver.handleCallEvent: missing peerUuid'); - } - - if (!callId) { - throw new Error('MessageReceiver.handleCallEvent: missing callId'); - } logUnexpectedUrgentValue(envelope, 'callEventSync'); - if ( - type !== Proto.SyncMessage.CallEvent.Type.VIDEO_CALL && - type !== Proto.SyncMessage.CallEvent.Type.AUDIO_CALL - ) { - log.warn('MessageReceiver.handleCallEvent: unknown call type'); - return; - } + const { receivedAtCounter } = envelope; - const peerUuidStr = bytesToUuid(peerUuid); - - strictAssert( - peerUuidStr != null, - 'MessageReceiver.handleCallEvent: invalid peerUuid' - ); - - const { receivedAtCounter, timestamp } = envelope; - - const wasIncoming = - callEvent.direction === Proto.SyncMessage.CallEvent.Direction.INCOMING; - const wasVideoCall = - callEvent.type === Proto.SyncMessage.CallEvent.Type.VIDEO_CALL; - const wasAccepted = - callEvent.event === Proto.SyncMessage.CallEvent.Event.ACCEPTED; - const wasDeclined = - callEvent.event === Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED; - - const acceptedTime = wasAccepted ? timestamp : undefined; - const endedTime = wasDeclined ? timestamp : undefined; + const callEventDetails = getCallEventForProto(callEvent); const callEventSync = new CallEventSyncEvent( { - timestamp: envelope.timestamp, - peerUuid: peerUuidStr, - callId: callId.toString(), - wasIncoming, - wasVideoCall, - wasDeclined, - acceptedTime, - endedTime, + callEventDetails, receivedAtCounter, }, this.removeFromCache.bind(this, envelope) @@ -3413,6 +3383,49 @@ export default class MessageReceiver log.info('handleCallEvent: finished'); } + private async handleCallLogEvent( + envelope: ProcessedEnvelope, + callLogEvent: Proto.SyncMessage.ICallLogEvent + ): Promise { + const logId = getEnvelopeId(envelope); + log.info('MessageReceiver.handleCallLogEvent', logId); + + logUnexpectedUrgentValue(envelope, 'callLogEventSync'); + + const { receivedAtCounter } = envelope; + + let event: CallLogEvent; + if (callLogEvent.type == null) { + throw new Error('MessageReceiver.handleCallLogEvent: type was null'); + } else if ( + callLogEvent.type === Proto.SyncMessage.CallLogEvent.Type.CLEAR + ) { + event = CallLogEvent.Clear; + } else { + throw new Error( + `MessageReceiver.handleCallLogEvent: unknown type ${callLogEvent.type}` + ); + } + + if (callLogEvent.timestamp == null) { + throw new Error('MessageReceiver.handleCallLogEvent: timestamp was null'); + } + const timestamp = callLogEvent.timestamp.toNumber(); + + const callLogEventSync = new CallLogEventSyncEvent( + { + event, + timestamp, + receivedAtCounter, + }, + this.removeFromCache.bind(this, envelope) + ); + + await this.dispatchAndWait(logId, callLogEventSync); + + log.info('handleCallLogEvent: finished'); + } + private async handleContacts( envelope: ProcessedEnvelope, contacts: Proto.SyncMessage.IContacts diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 7599b4ef0d94..9ba3f192ddb0 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -1663,52 +1663,6 @@ export default class MessageSender { }; } - static getCallEventSync( - peerUuid: string, - callId: string, - isVideoCall: boolean, - isIncoming: boolean, - isAccepted: boolean - ): SingleProtoJobData { - const myUuid = window.textsecure.storage.user.getCheckedUuid(); - const syncMessage = MessageSender.createSyncMessage(); - - const type = isVideoCall - ? Proto.SyncMessage.CallEvent.Type.VIDEO_CALL - : Proto.SyncMessage.CallEvent.Type.AUDIO_CALL; - const direction = isIncoming - ? Proto.SyncMessage.CallEvent.Direction.INCOMING - : Proto.SyncMessage.CallEvent.Direction.OUTGOING; - const event = isAccepted - ? Proto.SyncMessage.CallEvent.Event.ACCEPTED - : Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED; - - syncMessage.callEvent = new Proto.SyncMessage.CallEvent({ - peerUuid: uuidToBytes(peerUuid), - callId: Long.fromString(callId), - type, - direction, - event, - timestamp: Long.fromNumber(Date.now()), - }); - - const contentMessage = new Proto.Content(); - contentMessage.syncMessage = syncMessage; - - const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; - - return { - contentHint: ContentHint.RESENDABLE, - identifier: myUuid.toString(), - isSyncMessage: true, - protoBase64: Bytes.toBase64( - Proto.Content.encode(contentMessage).finish() - ), - type: 'callEventSync', - urgent: false, - }; - } - static getVerificationSync( destinationE164: string | undefined, destinationUuid: string | undefined, diff --git a/ts/textsecure/cds/CDSSocketBase.ts b/ts/textsecure/cds/CDSSocketBase.ts index b064b7b7750e..54021ded3460 100644 --- a/ts/textsecure/cds/CDSSocketBase.ts +++ b/ts/textsecure/cds/CDSSocketBase.ts @@ -11,7 +11,7 @@ import type { LoggerType } from '../../types/Logging'; import { strictAssert } from '../../util/assert'; import { UUID_BYTE_SIZE } from '../../types/UUID'; import * as Bytes from '../../Bytes'; -import { uuidToBytes, bytesToUuid } from '../../Crypto'; +import { uuidToBytes, bytesToUuid } from '../../util/uuidToBytes'; import { SignalService as Proto } from '../../protobuf'; import type { CDSRequestOptionsType, diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index ec4553032340..ef866acd7622 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -12,6 +12,7 @@ import type { ProcessedSent, } from './Types.d'; import type { ModifiedContactDetails } from './ContactsParser'; +import type { CallEventDetails, CallLogEvent } from '../types/CallDisposition'; export class EmptyEvent extends Event { constructor() { @@ -404,14 +405,7 @@ export class ViewSyncEvent extends ConfirmableEvent { } export type CallEventSyncEventData = Readonly<{ - timestamp: number; - peerUuid: string; - callId: string; - wasVideoCall: boolean; - wasIncoming: boolean; - wasDeclined: boolean; - acceptedTime: number | undefined; - endedTime: number | undefined; + callEventDetails: CallEventDetails; receivedAtCounter: number; }>; @@ -424,6 +418,21 @@ export class CallEventSyncEvent extends ConfirmableEvent { } } +export type CallLogEventSyncEventData = Readonly<{ + event: CallLogEvent; + timestamp: number; + receivedAtCounter: number; +}>; + +export class CallLogEventSyncEvent extends ConfirmableEvent { + constructor( + public readonly callLogEvent: CallLogEventSyncEventData, + confirm: ConfirmCallback + ) { + super('callLogEventSync', confirm); + } +} + export type StoryRecipientUpdateData = Readonly<{ destinationUuid: string; storyMessageRecipients: Array; diff --git a/ts/types/CallDisposition.ts b/ts/types/CallDisposition.ts new file mode 100644 index 000000000000..95c32b998fb6 --- /dev/null +++ b/ts/types/CallDisposition.ts @@ -0,0 +1,206 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { z } from 'zod'; +import Long from 'long'; +import { CallMode } from './Calling'; +import { bytesToUuid } from '../util/uuidToBytes'; +import { SignalService as Proto } from '../protobuf'; +import * as Bytes from '../Bytes'; + +export enum CallType { + Audio = 'Audio', + Video = 'Video', + Group = 'Group', +} + +export enum CallDirection { + Incoming = 'Incoming', + Outgoing = 'Outgoing', +} + +export enum CallLogEvent { + Clear = 'Clear', +} + +export enum LocalCallEvent { + Started = 'LocalStarted', + Ringing = 'LocalRinging', + Accepted = 'LocalAccepted', + Declined = 'LocalDeclined', + Hangup = 'LocalHangup', // Incoming = Declined, Outgoing = Missed + RemoteHangup = 'LocalRemoteHangup', // Incoming = Missed, Outgoing = Declined + Missed = 'LocalMissed', + Delete = 'LocalDelete', +} + +export enum RemoteCallEvent { + Accepted = 'Accepted', + NotAccepted = 'NotAccepted', + Delete = 'Delete', +} + +export type CallEvent = LocalCallEvent | RemoteCallEvent; + +export enum DirectCallStatus { + Pending = 'Pending', + Accepted = 'Accepted', + Missed = 'Missed', + Declined = 'Declined', + Deleted = 'Deleted', +} + +export enum GroupCallStatus { + GenericGroupCall = 'GenericGroupCall', + OutgoingRing = 'OutgoingRing', + Ringing = 'Ringing', + Joined = 'Joined', + // keep these in sync with direct + Accepted = DirectCallStatus.Accepted, + Missed = DirectCallStatus.Missed, + Declined = DirectCallStatus.Declined, + Deleted = DirectCallStatus.Deleted, +} + +export type CallStatus = DirectCallStatus | GroupCallStatus; + +export type CallDetails = Readonly<{ + callId: string; + peerId: string; + ringerId: string | null; + mode: CallMode; + type: CallType; + direction: CallDirection; + timestamp: number; +}>; + +export type CallEventDetails = CallDetails & + Readonly<{ + event: CallEvent; + }>; + +export type CallHistoryDetails = CallDetails & + Readonly<{ + status: CallStatus; + }>; + +export type CallHistoryGroup = Omit & + Readonly<{ + children: ReadonlyArray<{ + callId: string; + timestamp: number; + }>; + }>; + +export type GroupCallMeta = Readonly<{ + callId: string; + ringerId: string; +}>; + +export enum CallHistoryFilterStatus { + All = 'All', + Missed = 'Missed', +} + +export type CallHistoryFilterOptions = Readonly<{ + status: CallHistoryFilterStatus; + query: string; +}>; + +export type CallHistoryFilter = Readonly<{ + status: CallHistoryFilterStatus; + conversationIds: ReadonlyArray | null; +}>; + +export type CallHistoryPagination = Readonly<{ + offset: number; + limit: number; +}>; + +const ringerIdSchema = z.union([z.string(), z.null()]); + +const callModeSchema = z.nativeEnum(CallMode); +const callTypeSchema = z.nativeEnum(CallType); +const callDirectionSchema = z.nativeEnum(CallDirection); +const callEventSchema = z.union([ + z.nativeEnum(LocalCallEvent), + z.nativeEnum(RemoteCallEvent), +]); +const callStatusSchema = z.union([ + z.nativeEnum(DirectCallStatus), + z.nativeEnum(GroupCallStatus), +]); + +export const callDetailsSchema = z.object({ + callId: z.string(), + peerId: z.string(), + ringerId: ringerIdSchema, + mode: callModeSchema, + type: callTypeSchema, + direction: callDirectionSchema, + timestamp: z.number(), +}) satisfies z.ZodType; + +export const callEventDetailsSchema = callDetailsSchema.extend({ + event: callEventSchema, +}) satisfies z.ZodType; + +export const callHistoryDetailsSchema = callDetailsSchema.extend({ + status: callStatusSchema, +}) satisfies z.ZodType; + +export const callHistoryGroupSchema = z.object({ + peerId: z.string(), + mode: callModeSchema, + type: callTypeSchema, + direction: callDirectionSchema, + status: callStatusSchema, + timestamp: z.number(), + children: z.array( + z.object({ + callId: z.string(), + timestamp: z.number(), + }) + ), +}) satisfies z.ZodType; + +const peerIdInBytesSchema = z.instanceof(Uint8Array).transform(value => { + const uuid = bytesToUuid(value); + if (uuid != null) { + return uuid; + } + // assuming groupId + return Bytes.toBase64(value); +}); + +const longToStringSchema = z + .instanceof(Long) + .transform(long => long.toString()); + +const longToNumberSchema = z + .instanceof(Long) + .transform(long => long.toNumber()); + +export const callEventNormalizeSchema = z.object({ + peerId: peerIdInBytesSchema, + callId: longToStringSchema, + timestamp: longToNumberSchema, + type: z.nativeEnum(Proto.SyncMessage.CallEvent.Type), + direction: z.nativeEnum(Proto.SyncMessage.CallEvent.Direction), + event: z.nativeEnum(Proto.SyncMessage.CallEvent.Event), +}); + +export function isSameCallHistoryGroup( + a: CallHistoryGroup, + b: CallHistoryGroup +): boolean { + return ( + a.peerId === b.peerId && + a.timestamp === b.timestamp && + // For a bit more safety. + a.mode === b.mode && + a.type === b.type && + a.direction === b.direction && + a.status === b.status + ); +} diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index a79efa3103a1..a76e14043987 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -113,7 +113,6 @@ export enum CallEndedReason { AcceptedOnAnotherDevice = 'AcceptedOnAnotherDevice', DeclinedOnAnotherDevice = 'DeclinedOnAnotherDevice', BusyOnAnotherDevice = 'BusyOnAnotherDevice', - CallerIsNotMultiring = 'CallerIsNotMultiring', } // Must be kept in sync with RingRTC's ConnectionState @@ -166,33 +165,6 @@ export type MediaDeviceSettings = AvailableIODevicesType & { selectedCamera: string | undefined; }; -type DirectCallHistoryDetailsType = { - callId: string; - callMode: CallMode.Direct; - wasIncoming: boolean; - wasVideoCall: boolean; - wasDeclined: boolean; - acceptedTime?: number; - endedTime?: number; -}; - -type GroupCallHistoryDetailsType = { - callMode: CallMode.Group; - creatorUuid: string; - eraId: string; - startedTime: number; -}; - -export type CallHistoryDetailsType = - | DirectCallHistoryDetailsType - | GroupCallHistoryDetailsType; - -// Old messages weren't saved with a `callMode`. -export type CallHistoryDetailsFromDiskType = - | (Omit & - Partial>) - | GroupCallHistoryDetailsType; - export type ChangeIODevicePayloadType = | { type: CallingDeviceType.CAMERA; selectedDevice: string } | { type: CallingDeviceType.MICROPHONE; selectedDevice: AudioDevice } diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 847b9eabbbf0..1fe226406056 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -154,6 +154,7 @@ export type StorageAccessType = { zoomFactor: ZoomFactorType; preferredLeftPaneWidth: number; nextScheduledUpdateKeyTime: number; + navTabsCollapsed: boolean; areWeASubscriber: boolean; subscriberId: Uint8Array; subscriberCurrencyCode: string; diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index 8b604f42119f..a34ef99268bc 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -7,6 +7,7 @@ export enum ToastType { AlreadyRequestedToJoin = 'AlreadyRequestedToJoin', Blocked = 'Blocked', BlockedGroup = 'BlockedGroup', + CallHistoryCleared = 'CallHistoryCleared', CannotEditMessage = 'CannotEditMessage', CannotForwardEmptyMessage = 'CannotForwardEmptyMessage', CannotMixMultiAndNonMultiAttachments = 'CannotMixMultiAndNonMultiAttachments', @@ -55,6 +56,7 @@ export type AnyToast = | { toastType: ToastType.AlreadyRequestedToJoin } | { toastType: ToastType.Blocked } | { toastType: ToastType.BlockedGroup } + | { toastType: ToastType.CallHistoryCleared } | { toastType: ToastType.CannotEditMessage } | { toastType: ToastType.CannotForwardEmptyMessage } | { toastType: ToastType.CannotMixMultiAndNonMultiAttachments } diff --git a/ts/types/UUID.ts b/ts/types/UUID.ts index bec4a694d22b..4552c4402f52 100644 --- a/ts/types/UUID.ts +++ b/ts/types/UUID.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import { v4 as generateUUID } from 'uuid'; - import { strictAssert } from '../util/assert'; export type UUIDStringType = diff --git a/ts/util/callDisposition.ts b/ts/util/callDisposition.ts new file mode 100644 index 000000000000..08c4d24f0d37 --- /dev/null +++ b/ts/util/callDisposition.ts @@ -0,0 +1,907 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import Long from 'long'; +import type { + Call, + GroupCall, + PeekInfo, + LocalDeviceState, +} from '@signalapp/ringrtc'; +import { + CallState, + ConnectionState, + JoinState, + callIdFromEra, + callIdFromRingId, + RingUpdate, +} from '@signalapp/ringrtc'; +import { v4 as generateGuid } from 'uuid'; +import { strictAssert } from './assert'; +import { SignalService as Proto } from '../protobuf'; +import { bytesToUuid, uuidToBytes } from './uuidToBytes'; +import { missingCaseError } from './missingCaseError'; +import { CallEndedReason, CallMode } from '../types/Calling'; +import { isMe } from './whatTypeOfConversation'; +import * as log from '../logging/log'; +import * as Errors from '../types/errors'; +import { incrementMessageCounter } from './incrementMessageCounter'; +import { ReadStatus, maxReadStatus } from '../messages/MessageReadStatus'; +import { SeenStatus, maxSeenStatus } from '../MessageSeenStatus'; +import { canConversationBeUnarchived } from './canConversationBeUnarchived'; +import type { + ConversationAttributesType, + MessageAttributesType, +} from '../model-types'; +import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; +import MessageSender from '../textsecure/SendMessage'; +import * as Bytes from '../Bytes'; +import type { + CallDetails, + CallEvent, + CallEventDetails, + CallHistoryDetails, + CallHistoryGroup, + CallStatus, + GroupCallMeta, +} from '../types/CallDisposition'; +import { + DirectCallStatus, + GroupCallStatus, + callEventNormalizeSchema, + CallType, + CallDirection, + callEventDetailsSchema, + LocalCallEvent, + RemoteCallEvent, + callHistoryDetailsSchema, + callDetailsSchema, +} from '../types/CallDisposition'; +import type { ConversationType } from '../state/ducks/conversations'; +import { drop } from './drop'; + +// utils +// ----- + +export function formatCallEvent(callEvent: CallEventDetails): string { + const { callId, peerId, direction, event, type, mode, timestamp } = callEvent; + return `CallEvent (${callId}, ${peerId}, ${mode}, ${event}, ${direction}, ${type}, ${mode}, ${timestamp})`; +} + +export function formatCallHistory(callHistory: CallHistoryDetails): string { + const { callId, peerId, direction, status, type, mode, timestamp } = + callHistory; + return `CallHistory (${callId}, ${peerId}, ${mode}, ${status}, ${direction}, ${type}, ${mode}, ${timestamp})`; +} + +export function formatCallHistoryGroup( + callHistoryGroup: CallHistoryGroup +): string { + const { peerId, direction, status, type, mode, timestamp } = callHistoryGroup; + return `CallHistoryGroup (${peerId}, ${mode}, ${status}, ${direction}, ${type}, ${mode}, ${timestamp})`; +} + +export function formatPeekInfo(peekInfo: PeekInfo): string { + const { eraId, deviceCount, creator } = peekInfo; + const callId = eraId != null ? getCallIdFromEra(eraId) : null; + const creatorUuid = creator != null ? getCreatorUuid(creator) : null; + return `PeekInfo (${eraId}, ${callId}, ${creatorUuid}, ${deviceCount})`; +} + +export function formatLocalDeviceState( + localDeviceState: LocalDeviceState +): string { + const connectionState = ConnectionState[localDeviceState.connectionState]; + const joinState = JoinState[localDeviceState.joinState]; + return `LocalDeviceState (${connectionState}, ${joinState})`; +} + +export function getCallIdFromRing(ringId: bigint): string { + return Long.fromValue(callIdFromRingId(ringId)).toString(); +} + +export function getCallIdFromEra(eraId: string): string { + return Long.fromValue(callIdFromEra(eraId)).toString(); +} + +export function getCreatorUuid(creator: Buffer): string { + const uuid = bytesToUuid(creator); + strictAssert(uuid != null, 'creator uuid buffer was not a valid uuid'); + return uuid; +} + +export function getGroupCallMeta( + peekInfo: PeekInfo | null +): GroupCallMeta | null { + if (peekInfo?.eraId == null || peekInfo?.creator == null) { + return null; + } + const callId = getCallIdFromEra(peekInfo.eraId); + const ringerId = bytesToUuid(peekInfo.creator); + strictAssert(ringerId != null, 'peekInfo.creator was invalid uuid'); + return { callId, ringerId }; +} + +export function getPeerIdFromConversation( + conversation: ConversationAttributesType | ConversationType +): string { + if (conversation.type === 'direct' || conversation.type === 'private') { + strictAssert(conversation.uuid != null, 'UUID must exist for direct chat'); + return conversation.uuid; + } + strictAssert( + conversation.groupId != null, + 'groupId must exist for group chat' + ); + return conversation.groupId; +} + +// Call Events <-> Protos +// ---------------------- + +export function getCallEventForProto( + callEventProto: Proto.SyncMessage.ICallEvent +): CallEventDetails { + const callEvent = callEventNormalizeSchema.parse(callEventProto); + const { callId, peerId, timestamp } = callEvent; + + let type: CallType; + if (callEvent.type === Proto.SyncMessage.CallEvent.Type.GROUP_CALL) { + type = CallType.Group; + } else if (callEvent.type === Proto.SyncMessage.CallEvent.Type.AUDIO_CALL) { + type = CallType.Audio; + } else if (callEvent.type === Proto.SyncMessage.CallEvent.Type.VIDEO_CALL) { + type = CallType.Video; + } else { + throw new TypeError(`Unknown call type ${callEvent.type}`); + } + + let mode: CallMode; + if (type === CallType.Group) { + mode = CallMode.Group; + } else { + mode = CallMode.Direct; + } + + let direction: CallDirection; + if (callEvent.direction === Proto.SyncMessage.CallEvent.Direction.INCOMING) { + direction = CallDirection.Incoming; + } else if ( + callEvent.direction === Proto.SyncMessage.CallEvent.Direction.OUTGOING + ) { + direction = CallDirection.Outgoing; + } else { + throw new TypeError(`Unknown call direction ${callEvent.direction}`); + } + + let event: RemoteCallEvent; + if (callEvent.event === Proto.SyncMessage.CallEvent.Event.ACCEPTED) { + event = RemoteCallEvent.Accepted; + } else if ( + callEvent.event === Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED + ) { + event = RemoteCallEvent.NotAccepted; + } else if (callEvent.event === Proto.SyncMessage.CallEvent.Event.DELETE) { + event = RemoteCallEvent.Delete; + } else { + throw new TypeError(`Unknown call event ${callEvent.event}`); + } + + return callEventDetailsSchema.parse({ + callId, + peerId, + ringerId: null, + mode, + type, + direction, + timestamp, + event, + }); +} + +const directionToProto = { + [CallDirection.Incoming]: Proto.SyncMessage.CallEvent.Direction.INCOMING, + [CallDirection.Outgoing]: Proto.SyncMessage.CallEvent.Direction.OUTGOING, +}; + +const typeToProto = { + [CallType.Audio]: Proto.SyncMessage.CallEvent.Type.AUDIO_CALL, + [CallType.Video]: Proto.SyncMessage.CallEvent.Type.VIDEO_CALL, + [CallType.Group]: Proto.SyncMessage.CallEvent.Type.GROUP_CALL, +}; + +const statusToProto: Record< + CallStatus, + Proto.SyncMessage.CallEvent.Event | null +> = { + [DirectCallStatus.Accepted]: Proto.SyncMessage.CallEvent.Event.ACCEPTED, // and GroupCallStatus.Accepted + [DirectCallStatus.Declined]: Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED, // and GroupCallStatus.Declined + [DirectCallStatus.Deleted]: Proto.SyncMessage.CallEvent.Event.DELETE, // and GroupCallStatus.Deleted + [DirectCallStatus.Missed]: null, // and GroupCallStatus.Missed + [DirectCallStatus.Pending]: null, + [GroupCallStatus.GenericGroupCall]: null, + [GroupCallStatus.Joined]: null, + [GroupCallStatus.OutgoingRing]: null, + [GroupCallStatus.Ringing]: null, +}; + +function shouldSyncStatus(callStatus: CallStatus) { + return statusToProto[callStatus] != null; +} + +function getProtoForCallHistory( + callHistory: CallHistoryDetails +): Proto.SyncMessage.ICallEvent | null { + const event = statusToProto[callHistory.status]; + + strictAssert( + event != null, + `getProtoForCallHistory: Cannot create proto for status ${formatCallHistory( + callHistory + )}` + ); + + let peerId = uuidToBytes(callHistory.peerId); + if (peerId.length === 0) { + peerId = Bytes.fromBase64(callHistory.peerId); + } + + return new Proto.SyncMessage.CallEvent({ + peerId, + callId: Long.fromString(callHistory.callId), + type: typeToProto[callHistory.type], + direction: directionToProto[callHistory.direction], + event, + timestamp: Long.fromNumber(callHistory.timestamp), + }); +} + +// Local Events +// ------------ + +const endedReasonToEvent: Record = { + // Accepted + [CallEndedReason.AcceptedOnAnotherDevice]: LocalCallEvent.Accepted, + // Hangup (Incoming = Declined, Outgoing = Missed) + [CallEndedReason.RemoteHangup]: LocalCallEvent.RemoteHangup, + [CallEndedReason.LocalHangup]: LocalCallEvent.Hangup, + // Missed + [CallEndedReason.Busy]: LocalCallEvent.Missed, + [CallEndedReason.BusyOnAnotherDevice]: LocalCallEvent.Missed, + [CallEndedReason.ConnectionFailure]: LocalCallEvent.Missed, + [CallEndedReason.Glare]: LocalCallEvent.Missed, + [CallEndedReason.GlareFailure]: LocalCallEvent.Missed, + [CallEndedReason.InternalFailure]: LocalCallEvent.Missed, + [CallEndedReason.ReCall]: LocalCallEvent.Missed, + [CallEndedReason.ReceivedOfferExpired]: LocalCallEvent.Missed, + [CallEndedReason.ReceivedOfferWhileActive]: LocalCallEvent.Missed, + [CallEndedReason.ReceivedOfferWithGlare]: LocalCallEvent.Missed, + [CallEndedReason.RemoteHangupNeedPermission]: LocalCallEvent.Missed, + [CallEndedReason.SignalingFailure]: LocalCallEvent.Missed, + [CallEndedReason.Timeout]: LocalCallEvent.Missed, + [CallEndedReason.Declined]: LocalCallEvent.Missed, + [CallEndedReason.DeclinedOnAnotherDevice]: LocalCallEvent.Missed, +}; + +export function getLocalCallEventFromCallEndedReason( + callEndedReason: CallEndedReason +): LocalCallEvent { + log.info('getLocalCallEventFromCallEndedReason', callEndedReason); + return endedReasonToEvent[callEndedReason]; +} + +export function getLocalCallEventFromDirectCall( + call: Call +): LocalCallEvent | null { + log.info('getLocalCallEventFromDirectCall', call.state); + if (call.state === CallState.Accepted) { + return LocalCallEvent.Accepted; + } + if (call.state === CallState.Ended) { + strictAssert(call.endedReason != null, 'Call ended without reason'); + return getLocalCallEventFromCallEndedReason(call.endedReason); + } + if (call.state === CallState.Ringing) { + return LocalCallEvent.Ringing; + } + if (call.state === CallState.Prering) { + return null; + } + if (call.state === CallState.Reconnecting) { + return null; + } + throw missingCaseError(call.state); +} + +const ringUpdateToEvent: Record = { + [RingUpdate.AcceptedOnAnotherDevice]: LocalCallEvent.Accepted, + [RingUpdate.BusyLocally]: LocalCallEvent.Missed, + [RingUpdate.BusyOnAnotherDevice]: LocalCallEvent.Missed, + [RingUpdate.CancelledByRinger]: LocalCallEvent.Missed, + [RingUpdate.DeclinedOnAnotherDevice]: LocalCallEvent.Missed, + [RingUpdate.ExpiredRequest]: LocalCallEvent.Missed, + [RingUpdate.Requested]: LocalCallEvent.Ringing, +}; + +export function getLocalCallEventFromRingUpdate( + update: RingUpdate +): LocalCallEvent | null { + log.info('getLocalCallEventFromRingUpdate', RingUpdate[update]); + return ringUpdateToEvent[update]; +} + +export function getLocalCallEventFromGroupCall( + groupCall: GroupCall, + groupCallMeta: GroupCallMeta +): LocalCallEvent | null { + const direction = getCallDirectionFromRingerId(groupCallMeta.ringerId); + const localDeviceState = groupCall.getLocalDeviceState(); + log.info( + 'getLocalCallEventFromGroupCall', + direction, + JoinState[localDeviceState.joinState] + ); + if (direction === CallDirection.Incoming) { + if (localDeviceState.joinState === JoinState.Joined) { + return LocalCallEvent.Accepted; + } + if (localDeviceState.joinState === JoinState.NotJoined) { + return null; // Group calls shouldn't send "NotAccepted" + } + if (localDeviceState.joinState === JoinState.Joining) { + return LocalCallEvent.Accepted; + } + throw missingCaseError(localDeviceState.joinState); + } else { + if (localDeviceState.joinState === JoinState.NotJoined) { + return LocalCallEvent.Hangup; + } + return LocalCallEvent.Ringing; + } +} + +// Call Direction +// -------------- + +function getCallDirectionFromRingerId(ringerId: string): CallDirection { + const ringerConversation = window.ConversationController.get(ringerId); + strictAssert( + ringerConversation != null, + `getCallDirectionFromRingerId: Missing ringer conversation (${ringerId})` + ); + const direction = isMe(ringerConversation.attributes) + ? CallDirection.Outgoing + : CallDirection.Incoming; + return direction; +} + +// Call Details +// ------------ + +export function getCallDetailsFromDirectCall( + peerId: string, + call: Call +): CallDetails { + return callDetailsSchema.parse({ + callId: Long.fromValue(call.callId).toString(), + peerId, + ringerId: call.isIncoming ? call.remoteUserId : null, + mode: CallMode.Direct, + type: call.isVideoCall ? CallType.Video : CallType.Audio, + direction: call.isIncoming + ? CallDirection.Incoming + : CallDirection.Outgoing, + timestamp: Date.now(), + }); +} + +export function getCallDetailsFromEndedDirectCall( + callId: string, + peerId: string, + ringerId: string, + wasVideoCall: boolean, + timestamp: number +): CallDetails { + return callDetailsSchema.parse({ + callId, + peerId, + ringerId, + mode: CallMode.Direct, + type: wasVideoCall ? CallType.Video : CallType.Audio, + direction: getCallDirectionFromRingerId(ringerId), + timestamp, + }); +} + +export function getCallDetailsFromGroupCallMeta( + peerId: string, + groupCallMeta: GroupCallMeta +): CallDetails { + return callDetailsSchema.parse({ + callId: groupCallMeta.callId, + peerId, + ringerId: groupCallMeta.ringerId, + mode: CallMode.Group, + type: CallType.Group, + direction: getCallDirectionFromRingerId(groupCallMeta.ringerId), + timestamp: Date.now(), + }); +} + +// Call Event Details +// ------------------ + +export function getCallEventDetails( + callDetails: CallDetails, + event: LocalCallEvent +): CallEventDetails { + return callEventDetailsSchema.parse({ ...callDetails, event }); +} + +// transitions +// ----------- + +export function transitionCallHistory( + callHistory: CallHistoryDetails | null, + callEvent: CallEventDetails +): CallHistoryDetails { + const { callId, peerId, ringerId, mode, type, direction, event } = callEvent; + + if (callHistory != null) { + strictAssert(callHistory.callId === callId, 'callId must be same'); + strictAssert(callHistory.peerId === peerId, 'peerId must be same'); + strictAssert( + ringerId == null || callHistory.ringerId === ringerId, + 'ringerId must be same if it exists' + ); + strictAssert(callHistory.direction === direction, 'direction must be same'); + strictAssert(callHistory.type === type, 'type must be same'); + strictAssert(callHistory.mode === mode, 'mode must be same'); + } + + const prevStatus = callHistory?.status ?? null; + let status: DirectCallStatus | GroupCallStatus; + + if (mode === CallMode.Direct) { + status = transitionDirectCallStatus( + prevStatus as DirectCallStatus | null, + event, + direction + ); + } else if (mode === CallMode.Group) { + status = transitionGroupCallStatus( + prevStatus as GroupCallStatus | null, + event, + direction + ); + } else if (mode === CallMode.None) { + throw new TypeError('Call mode must not be none'); + } else { + throw missingCaseError(mode); + } + + const timestamp = Math.max(callEvent.timestamp, callHistory?.timestamp ?? 0); + + return callHistoryDetailsSchema.parse({ + callId, + peerId, + ringerId, + mode, + type, + direction, + timestamp, + status, + }); +} + +function transitionDirectCallStatus( + status: DirectCallStatus | null, + callEvent: CallEvent, + direction: CallDirection +): DirectCallStatus { + log.info('transitionDirectCallStatus', status, callEvent, direction); + // In all cases if we get a delete event, we need to delete the call, and never + // transition from deleted. + if ( + callEvent === RemoteCallEvent.Delete || + callEvent === LocalCallEvent.Delete || + status === DirectCallStatus.Deleted + ) { + return DirectCallStatus.Deleted; + } + + if ( + callEvent === RemoteCallEvent.Accepted || + callEvent === LocalCallEvent.Accepted + ) { + return DirectCallStatus.Accepted; + } + + if (status === DirectCallStatus.Accepted) { + return status; + } + + if (callEvent === RemoteCallEvent.NotAccepted) { + if (status === DirectCallStatus.Declined) { + return DirectCallStatus.Declined; + } + return DirectCallStatus.Missed; + } + + if (callEvent === LocalCallEvent.Missed) { + return DirectCallStatus.Missed; + } + + if (callEvent === LocalCallEvent.Declined) { + return DirectCallStatus.Declined; + } + + if (callEvent === LocalCallEvent.Hangup) { + if (direction === CallDirection.Incoming) { + return DirectCallStatus.Declined; + } + return DirectCallStatus.Missed; + } + + if (callEvent === LocalCallEvent.RemoteHangup) { + if (direction === CallDirection.Incoming) { + return DirectCallStatus.Missed; + } + return DirectCallStatus.Declined; + } + + if ( + callEvent === LocalCallEvent.Started || + callEvent === LocalCallEvent.Ringing + ) { + return DirectCallStatus.Pending; + } + + throw missingCaseError(callEvent); +} + +function transitionGroupCallStatus( + status: GroupCallStatus | null, + event: CallEvent, + direction: CallDirection +): GroupCallStatus { + log.info('transitionGroupCallStatus', status, event, direction); + + // In all cases if we get a delete event, we need to delete the call, and never + // transition from deleted. + if ( + event === RemoteCallEvent.Delete || + event === LocalCallEvent.Delete || + status === GroupCallStatus.Deleted + ) { + return GroupCallStatus.Deleted; + } + + if (status === GroupCallStatus.Accepted) { + return status; + } + + if (event === RemoteCallEvent.NotAccepted) { + throw new Error(`callHistoryDetails: Group calls shouldn't send ${event}`); + } + + if (event === RemoteCallEvent.Accepted || event === LocalCallEvent.Accepted) { + switch (status) { + case null: { + return GroupCallStatus.Joined; + } + case GroupCallStatus.Ringing: + case GroupCallStatus.Missed: + case GroupCallStatus.Declined: { + return GroupCallStatus.Accepted; + } + case GroupCallStatus.GenericGroupCall: { + return GroupCallStatus.Joined; + } + case GroupCallStatus.Joined: + case GroupCallStatus.OutgoingRing: { + return status; + } + default: { + throw missingCaseError(status); + } + } + } + + if (event === LocalCallEvent.Started) { + return GroupCallStatus.GenericGroupCall; + } + + if (event === LocalCallEvent.Ringing) { + return GroupCallStatus.Ringing; + } + + if (event === LocalCallEvent.Missed) { + return GroupCallStatus.Missed; + } + + if (event === LocalCallEvent.Declined) { + return GroupCallStatus.Declined; + } + + if (event === LocalCallEvent.Hangup) { + if (direction === CallDirection.Incoming) { + return GroupCallStatus.Declined; + } + return GroupCallStatus.Missed; + } + + if (event === LocalCallEvent.RemoteHangup) { + if (direction === CallDirection.Incoming) { + return GroupCallStatus.Missed; + } + return GroupCallStatus.Declined; + } + + throw missingCaseError(event); +} + +// actions +// ------- + +async function updateLocalCallHistory( + callEvent: CallEventDetails, + receivedAtCounter: number | null +): Promise { + const conversation = window.ConversationController.get(callEvent.peerId); + strictAssert( + conversation != null, + `updateLocalCallHistory: Conversation not found ${formatCallEvent( + callEvent + )}` + ); + return conversation.queueJob( + 'updateLocalCallHistory', + async () => { + log.info( + 'updateLocalCallHistory: Processing call event:', + formatCallEvent(callEvent) + ); + + const prevCallHistory = + (await window.Signal.Data.getCallHistory( + callEvent.callId, + callEvent.peerId + )) ?? null; + + if (prevCallHistory != null) { + log.info( + 'updateLocalCallHistory: Found previous call history:', + formatCallHistory(prevCallHistory) + ); + } else { + log.info('updateLocalCallHistory: No previous call history'); + } + + let callHistory: CallHistoryDetails; + try { + callHistory = transitionCallHistory(prevCallHistory, callEvent); + } catch (error) { + log.error( + "updateLocalCallHistory: Couldn't transition call history:", + formatCallEvent(callEvent), + Errors.toLogFormat(error) + ); + return null; + } + + log.info( + 'updateLocalCallHistory: Saving call history:', + formatCallHistory(callHistory) + ); + await window.Signal.Data.saveCallHistory(callHistory); + window.reduxActions.callHistory.cacheCallHistory(callHistory); + + const prevMessage = + await window.Signal.Data.getCallHistoryMessageByCallId({ + conversationId: conversation.id, + callId: callHistory.callId, + }); + + if (prevMessage != null) { + log.info( + 'updateLocalCallHistory: Found previous call history message:', + prevMessage.id + ); + } else { + log.info( + 'updateLocalCallHistory: No previous call history message', + conversation.id + ); + } + + let unread = false; + if (callHistory.mode === CallMode.Direct) { + unread = + callHistory.direction === CallDirection.Incoming && + callHistory.status === DirectCallStatus.Missed; + } else if (callHistory.mode === CallMode.Group) { + unread = + callHistory.direction === CallDirection.Incoming && + (callHistory.status === GroupCallStatus.GenericGroupCall || + callHistory.status === GroupCallStatus.Missed); + } + + let readStatus = unread ? ReadStatus.Unread : ReadStatus.Read; + let seenStatus = unread ? SeenStatus.Unseen : SeenStatus.NotApplicable; + + if (prevMessage?.readStatus != null) { + readStatus = maxReadStatus(readStatus, prevMessage.readStatus); + } + if (prevMessage?.seenStatus != null) { + seenStatus = maxSeenStatus(seenStatus, prevMessage.seenStatus); + } + + const message: MessageAttributesType = { + id: prevMessage?.id ?? generateGuid(), + conversationId: conversation.id, + type: 'call-history', + sent_at: callHistory.timestamp, + timestamp: callHistory.timestamp, + received_at: receivedAtCounter ?? incrementMessageCounter(), + received_at_ms: callHistory.timestamp, + readStatus, + seenStatus, + callId: callHistory.callId, + }; + + const id = await window.Signal.Data.saveMessage(message, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + // We don't want to force save if we're updating an existing message + forceSave: prevMessage == null, + }); + log.info('updateLocalCallHistory: Saved call history message:', id); + + const model = window.MessageController.register( + id, + new window.Whisper.Message({ + ...message, + id, + }) + ); + + if (callHistory.direction === CallDirection.Outgoing) { + conversation.incrementSentMessageCount(); + } else { + conversation.incrementMessageCount(); + } + + conversation.trigger('newmessage', model); + + void conversation.updateLastMessage(); + void conversation.updateUnread(); + conversation.set('active_at', callHistory.timestamp); + + if (canConversationBeUnarchived(conversation.attributes)) { + conversation.setArchived(false); + } else { + window.Signal.Data.updateConversation(conversation.attributes); + } + + return callHistory; + } + ); +} + +async function updateRemoteCallHistory( + callHistory: CallHistoryDetails +): Promise { + if (!shouldSyncStatus(callHistory.status)) { + log.info( + 'updateRemoteCallHistory: Not syncing call history:', + formatCallHistory(callHistory) + ); + return; + } + + log.info( + 'updateRemoteCallHistory: syncing call history:', + formatCallHistory(callHistory) + ); + + try { + const myUuid = window.textsecure.storage.user.getCheckedUuid(); + const syncMessage = MessageSender.createSyncMessage(); + + syncMessage.callEvent = getProtoForCallHistory(callHistory); + + const contentMessage = new Proto.Content(); + contentMessage.syncMessage = syncMessage; + + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + + await singleProtoJobQueue.add({ + contentHint: ContentHint.RESENDABLE, + identifier: myUuid.toString(), + isSyncMessage: true, + protoBase64: Bytes.toBase64( + Proto.Content.encode(contentMessage).finish() + ), + type: 'callEventSync', + urgent: false, + }); + } catch (error) { + log.error( + 'updateRemoteCallHistory: Failed to queue sync message:', + Errors.toLogFormat(error) + ); + } +} + +export async function updateCallHistoryFromRemoteEvent( + callEvent: CallEventDetails, + receivedAtCounter: number +): Promise { + await updateLocalCallHistory(callEvent, receivedAtCounter); +} + +export async function updateCallHistoryFromLocalEvent( + callEvent: CallEventDetails, + receivedAtCounter: number | null +): Promise { + const updatedCallHistory = await updateLocalCallHistory( + callEvent, + receivedAtCounter + ); + if (updatedCallHistory == null) { + return; + } + await updateRemoteCallHistory(updatedCallHistory); +} + +export async function clearCallHistoryDataAndSync(): Promise { + try { + const timestamp = Date.now(); + + log.info(`clearCallHistory: Clearing call history before ${timestamp}`); + const messageIds = await window.Signal.Data.clearCallHistory(timestamp); + + messageIds.forEach(messageId => { + const message = window.MessageController.getById(messageId); + const conversation = message?.getConversation(); + if (message == null || conversation == null) { + return; + } + window.reduxActions.conversations.messageDeleted( + messageId, + message.get('conversationId') + ); + drop(conversation.updateLastMessage()); + window.MessageController.unregister(messageId); + }); + + const myUuid = window.textsecure.storage.user.getCheckedUuid(); + + const callLogEvent = new Proto.SyncMessage.CallLogEvent({ + type: Proto.SyncMessage.CallLogEvent.Type.CLEAR, + timestamp: Long.fromNumber(timestamp), + }); + + const syncMessage = MessageSender.createSyncMessage(); + syncMessage.callLogEvent = callLogEvent; + + const contentMessage = new Proto.Content(); + contentMessage.syncMessage = syncMessage; + + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + + log.info('clearCallHistory: Queueing sync message'); + await singleProtoJobQueue.add({ + contentHint: ContentHint.RESENDABLE, + identifier: myUuid.toString(), + isSyncMessage: true, + protoBase64: Bytes.toBase64( + Proto.Content.encode(contentMessage).finish() + ), + type: 'callLogEventSync', + urgent: false, + }); + } catch (error) { + log.error('clearCallHistory: Failed to clear call history', error); + } +} diff --git a/ts/util/callHistoryDetails.ts b/ts/util/callHistoryDetails.ts deleted file mode 100644 index 29a0ed8208e6..000000000000 --- a/ts/util/callHistoryDetails.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright 2023 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import type { CallHistoryDetailsFromDiskType } from '../types/Calling'; -import { CallMode } from '../types/Calling'; -import type { LoggerType } from '../types/Logging'; -import { strictAssert } from './assert'; -import { missingCaseError } from './missingCaseError'; - -enum CallHistoryStatus { - Pending = 'Pending', - Missed = 'Missed', - Accepted = 'Accepted', - NotAccepted = 'NotAccepted', -} - -function getCallHistoryStatus( - callHistoryDetails: CallHistoryDetailsFromDiskType -): CallHistoryStatus { - strictAssert( - callHistoryDetails.callMode === CallMode.Direct, - "Can't get call history status for group call (unimplemented)" - ); - if (callHistoryDetails.acceptedTime != null) { - return CallHistoryStatus.Accepted; - } - if (callHistoryDetails.wasDeclined) { - return CallHistoryStatus.NotAccepted; - } - if (callHistoryDetails.endedTime != null) { - return CallHistoryStatus.Missed; - } - return CallHistoryStatus.Pending; -} - -function isAllowedTransition( - from: CallHistoryStatus, - to: CallHistoryStatus, - log: LoggerType -): boolean { - if (from === CallHistoryStatus.Pending) { - log.info('callHistoryDetails: Can go from pending to anything.'); - return true; - } - if (to === CallHistoryStatus.Pending) { - log.info("callHistoryDetails: Can't go to pending once out of it."); - return false; - } - if (from === CallHistoryStatus.Missed) { - log.info( - "callHistoryDetails: A missed call on this device might've been picked up or explicitly declined on a linked device." - ); - return true; - } - if (from === CallHistoryStatus.Accepted) { - log.info( - 'callHistoryDetails: If we accept anywhere that beats everything.' - ); - return false; - } - if ( - from === CallHistoryStatus.NotAccepted && - to === CallHistoryStatus.Accepted - ) { - log.info( - 'callHistoryDetails: If we declined on this device but picked up on another device, that counts as accepted.' - ); - return true; - } - - if (from === CallHistoryStatus.NotAccepted) { - log.info( - "callHistoryDetails: Can't transition from NotAccepted to anything else" - ); - return false; - } - - throw missingCaseError(from); -} - -export function validateTransition( - prev: CallHistoryDetailsFromDiskType | void, - next: CallHistoryDetailsFromDiskType, - log: LoggerType -): boolean { - // Only validating Direct calls for now - if (next.callMode !== CallMode.Direct) { - return true; - } - if (prev == null) { - return true; - } - - strictAssert( - prev.callMode === CallMode.Direct && next.callMode === CallMode.Direct, - "Call mode must be 'Direct'" - ); - strictAssert(prev.callId === next.callId, 'Call ID must not change'); - strictAssert( - prev.wasIncoming === next.wasIncoming, - 'wasIncoming must not change' - ); - strictAssert( - prev.wasVideoCall === next.wasVideoCall, - 'wasVideoCall must not change' - ); - - const before = getCallHistoryStatus(prev); - const after = getCallHistoryStatus(next); - log.info( - `callHistoryDetails: Checking transition (Call ID: ${next.callId}, Before: ${before}, After: ${after})` - ); - return isAllowedTransition(before, after, log); -} diff --git a/ts/util/callingNotification.ts b/ts/util/callingNotification.ts index 4c503ff1cabf..eff67cced242 100644 --- a/ts/util/callingNotification.ts +++ b/ts/util/callingNotification.ts @@ -4,110 +4,131 @@ import type { LocalizerType } from '../types/Util'; import { CallMode } from '../types/Calling'; import { missingCaseError } from './missingCaseError'; -import * as log from '../logging/log'; +import type { CallStatus } from '../types/CallDisposition'; +import { + CallDirection, + DirectCallStatus, + type CallHistoryDetails, + CallType, +} from '../types/CallDisposition'; import type { ConversationType } from '../state/ducks/conversations'; -type DirectCallNotificationType = { - callMode: CallMode.Direct; - activeCallConversationId?: string; - wasIncoming: boolean; - wasVideoCall: boolean; - wasDeclined: boolean; - acceptedTime?: number; - endedTime?: number; -}; +export enum CallExternalState { + Active, + Full, + Joined, + Ended, + InOtherCall, +} -type GroupCallNotificationType = { - activeCallConversationId?: string; - callMode: CallMode.Group; - conversationId: string; - creator?: ConversationType; - ended: boolean; +export type CallingNotificationType = Readonly<{ + callHistory: CallHistoryDetails; + callCreator: ConversationType | null; + callExternalState: CallExternalState; deviceCount: number; maxDevices: number; - startedTime: number; -}; - -export type CallingNotificationType = - | DirectCallNotificationType - | GroupCallNotificationType; +}>; function getDirectCallNotificationText( - { - wasIncoming, - wasVideoCall, - wasDeclined, - acceptedTime, - }: DirectCallNotificationType, + callDirection: CallDirection, + callType: CallType, + callStatus: DirectCallStatus, i18n: LocalizerType ): string { - const wasAccepted = Boolean(acceptedTime); + if (callStatus === DirectCallStatus.Pending) { + if (callDirection === CallDirection.Incoming) { + return callType === CallType.Video + ? i18n('icu:incomingVideoCall') + : i18n('icu:incomingAudioCall'); + } + return callType === CallType.Video + ? i18n('icu:outgoingVideoCall') + : i18n('icu:outgoingAudioCall'); + } - if (wasIncoming) { - if (wasDeclined) { - if (wasVideoCall) { - return i18n('icu:declinedIncomingVideoCall'); - } - return i18n('icu:declinedIncomingAudioCall'); + if (callStatus === DirectCallStatus.Accepted) { + if (callDirection === CallDirection.Incoming) { + return callType === CallType.Video + ? i18n('icu:acceptedIncomingVideoCall') + : i18n('icu:acceptedIncomingAudioCall'); } - if (wasAccepted) { - if (wasVideoCall) { - return i18n('icu:acceptedIncomingVideoCall'); - } - return i18n('icu:acceptedIncomingAudioCall'); - } - if (wasVideoCall) { - return i18n('icu:missedIncomingVideoCall'); - } - return i18n('icu:missedIncomingAudioCall'); + return callType === CallType.Video + ? i18n('icu:acceptedOutgoingVideoCall') + : i18n('icu:acceptedOutgoingAudioCall'); } - if (wasAccepted) { - if (wasVideoCall) { - return i18n('icu:acceptedOutgoingVideoCall'); + + if (callStatus === DirectCallStatus.Declined) { + if (callDirection === CallDirection.Incoming) { + return callType === CallType.Video + ? i18n('icu:declinedIncomingVideoCall') + : i18n('icu:declinedIncomingAudioCall'); } - return i18n('icu:acceptedOutgoingAudioCall'); + return callType === CallType.Video + ? i18n('icu:missedOrDeclinedOutgoingVideoCall') + : i18n('icu:missedOrDeclinedOutgoingAudioCall'); } - if (wasVideoCall) { - return i18n('icu:missedOrDeclinedOutgoingVideoCall'); + + if (callStatus === DirectCallStatus.Missed) { + if (callDirection === CallDirection.Incoming) { + return callType === CallType.Video + ? i18n('icu:missedIncomingVideoCall') + : i18n('icu:missedIncomingAudioCall'); + } + return callType === CallType.Video + ? i18n('icu:missedOrDeclinedOutgoingVideoCall') + : i18n('icu:missedOrDeclinedOutgoingAudioCall'); } - return i18n('icu:missedOrDeclinedOutgoingAudioCall'); + + if (callStatus === DirectCallStatus.Deleted) { + throw new Error( + 'getDirectCallNotificationText: Cannot render deleted call' + ); + } + + throw missingCaseError(callStatus); } function getGroupCallNotificationText( - notification: GroupCallNotificationType, + callExternalState: CallExternalState, + creator: ConversationType | null, i18n: LocalizerType ): string { - if (notification.ended) { + if (callExternalState === CallExternalState.Ended) { return i18n('icu:calling__call-notification__ended'); } - if (!notification.creator) { + if (creator == null) { return i18n('icu:calling__call-notification__started-by-someone'); } - if (notification.creator.isMe) { + if (creator.isMe) { return i18n('icu:calling__call-notification__started-by-you'); } return i18n('icu:calling__call-notification__started', { - name: notification.creator.systemGivenName ?? notification.creator.title, + name: creator.systemGivenName ?? creator.title, }); } export function getCallingNotificationText( - notification: CallingNotificationType, + callingNotification: CallingNotificationType, i18n: LocalizerType ): string { - switch (notification.callMode) { - case CallMode.Direct: - return getDirectCallNotificationText(notification, i18n); - case CallMode.Group: - return getGroupCallNotificationText(notification, i18n); - default: - log.error( - `getCallingNotificationText: missing case ${missingCaseError( - notification - )}` - ); - return ''; + const { callHistory, callCreator, callExternalState } = callingNotification; + if (callHistory.mode === CallMode.Direct) { + return getDirectCallNotificationText( + callHistory.direction, + callHistory.type, + callHistory.status as DirectCallStatus, + i18n + ); } + if (callHistory.mode === CallMode.Group) { + return getGroupCallNotificationText(callExternalState, callCreator, i18n); + } + if (callHistory.mode === CallMode.None) { + throw new Error( + 'getCallingNotificationText: Cannot render call history details with mode = None' + ); + } + throw missingCaseError(callHistory.mode); } type CallingIconType = @@ -120,42 +141,41 @@ type CallingIconType = | 'video-missed' | 'video-outgoing'; -function getDirectCallingIcon({ - wasIncoming, - wasVideoCall, - acceptedTime, -}: DirectCallNotificationType): CallingIconType { - const wasAccepted = Boolean(acceptedTime); - - // video - if (wasVideoCall) { - if (wasAccepted) { - return wasIncoming ? 'video-incoming' : 'video-outgoing'; - } - return 'video-missed'; - } - - if (wasAccepted) { - return wasIncoming ? 'audio-incoming' : 'audio-outgoing'; - } - - return 'audio-missed'; -} - export function getCallingIcon( - notification: CallingNotificationType + callType: CallType, + callDirection: CallDirection, + callStatus: CallStatus ): CallingIconType { - switch (notification.callMode) { - case CallMode.Direct: - return getDirectCallingIcon(notification); - case CallMode.Group: - return 'video'; - default: - log.error( - `getCallingNotificationText: missing case ${missingCaseError( - notification - )}` - ); - return 'phone'; + if (callType === CallType.Audio) { + if (callStatus === DirectCallStatus.Accepted) { + return callDirection === CallDirection.Incoming + ? 'audio-incoming' + : 'audio-outgoing'; + } + if ( + callStatus === DirectCallStatus.Missed || + callStatus === DirectCallStatus.Declined + ) { + return 'audio-missed'; + } + return 'phone'; } + if (callType === CallType.Video) { + if (callStatus === DirectCallStatus.Accepted) { + return callDirection === CallDirection.Incoming + ? 'video-incoming' + : 'video-outgoing'; + } + if ( + callStatus === DirectCallStatus.Missed || + callStatus === DirectCallStatus.Declined + ) { + return 'video-missed'; + } + return 'video'; + } + if (callType === CallType.Group) { + return 'video'; + } + throw missingCaseError(callType); } diff --git a/ts/util/handleMessageSend.ts b/ts/util/handleMessageSend.ts index c213db17008e..3146c3549d17 100644 --- a/ts/util/handleMessageSend.ts +++ b/ts/util/handleMessageSend.ts @@ -64,6 +64,7 @@ export const sendTypesEnum = z.enum([ 'viewOnceSync', 'viewSync', 'callEventSync', + 'callLogEventSync', // No longer used, all non-urgent 'legacyGroupChange', diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 99f5d0194011..a9f7431b23a8 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2925,5 +2925,21 @@ "line": " message.innerHTML = window.i18n('icu:optimizingApplication');", "reasonCategory": "usageTrusted", "updated": "2021-09-17T21:02:59.414Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallsList.tsx", + "line": " const infiniteLoaderRef = useRef(null);", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2023-08-02T00:21:37.858Z", + "reasonDetail": "" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallsList.tsx", + "line": " const listRef = useRef(null);", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2023-08-02T00:21:37.858Z", + "reasonDetail": "" } ] diff --git a/ts/util/lint/linter.ts b/ts/util/lint/linter.ts index 655178b7c6b1..adc018234c3b 100644 --- a/ts/util/lint/linter.ts +++ b/ts/util/lint/linter.ts @@ -77,12 +77,15 @@ const excludedFilesRegexp = RegExp( '^.github/.+', // Modules we trust + '^node_modules/@react-aria/.+', + '^node_modules/@react-stately/.+', '^node_modules/@signalapp/libsignal-client/.+', '^node_modules/core-js-pure/.+', '^node_modules/core-js/.+', '^node_modules/fbjs/.+', '^node_modules/lodash/.+', '^node_modules/react/.+', + '^node_modules/react-aria-components/.+', '^node_modules/react-contextmenu/.+', '^node_modules/react-dom/.+', '^node_modules/react-hot-loader/.+', @@ -100,6 +103,7 @@ const excludedFilesRegexp = RegExp( '^node_modules/snyk-resolve-deps/.+', '^node_modules/snyk-try-require/.+', '^node_modules/@snyk/.+', + '^node_modules/use-sync-external-store/.+', // Submodules we trust '^node_modules/react-color/.+/(?:core-js|fbjs|lodash)/.+', diff --git a/ts/util/onCallEventSync.ts b/ts/util/onCallEventSync.ts index 4433b5965d3d..8896f1e1c207 100644 --- a/ts/util/onCallEventSync.ts +++ b/ts/util/onCallEventSync.ts @@ -3,47 +3,24 @@ import type { CallEventSyncEvent } from '../textsecure/messageReceiverEvents'; import * as log from '../logging/log'; -import { CallMode } from '../types/Calling'; +import { updateCallHistoryFromRemoteEvent } from './callDisposition'; export async function onCallEventSync( syncEvent: CallEventSyncEvent ): Promise { const { callEvent, confirm } = syncEvent; - const { - peerUuid, - callId, - wasIncoming, - wasVideoCall, - wasDeclined, - acceptedTime, - endedTime, - receivedAtCounter, - } = callEvent; + const { callEventDetails, receivedAtCounter } = callEvent; + const { peerId } = callEventDetails; - const conversation = window.ConversationController.get(peerUuid); + const conversation = window.ConversationController.get(peerId); if (!conversation) { - log.warn(`onCallEventSync: No conversation found for peerUuid ${peerUuid}`); + log.warn( + `onCallEventSync: No conversation found for conversationId ${peerId}` + ); return; } - log.info( - `onCallEventSync: Queuing job to add call history (Call ID: ${callId})` - ); - await conversation.queueJob('onCallEventSync', async () => { - await conversation.addCallHistory( - { - callId, - callMode: CallMode.Direct, - wasDeclined, - wasIncoming, - wasVideoCall, - acceptedTime: acceptedTime ?? undefined, - endedTime: endedTime ?? undefined, - }, - receivedAtCounter - ); - - confirm(); - }); + await updateCallHistoryFromRemoteEvent(callEventDetails, receivedAtCounter); + confirm(); } diff --git a/ts/util/onCallLogEventSync.ts b/ts/util/onCallLogEventSync.ts new file mode 100644 index 000000000000..47911a6f7159 --- /dev/null +++ b/ts/util/onCallLogEventSync.ts @@ -0,0 +1,26 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { CallLogEventSyncEvent } from '../textsecure/messageReceiverEvents'; +import * as log from '../logging/log'; +import { CallLogEvent } from '../types/CallDisposition'; +import { missingCaseError } from './missingCaseError'; + +export async function onCallLogEventSync( + syncEvent: CallLogEventSyncEvent +): Promise { + const { callLogEvent, confirm } = syncEvent; + const { event, timestamp } = callLogEvent; + + log.info( + `onCallLogEventSync: Processing event (Event: ${event}, Timestamp: ${timestamp})` + ); + + if (event === CallLogEvent.Clear) { + log.info(`onCallLogEventSync: Clearing call history before ${timestamp}`); + await window.Signal.Data.clearCallHistory(timestamp); + confirm(); + } else { + throw missingCaseError(event); + } +} diff --git a/ts/util/uuidToBytes.ts b/ts/util/uuidToBytes.ts index 882351aa1990..0bac49a6de93 100644 --- a/ts/util/uuidToBytes.ts +++ b/ts/util/uuidToBytes.ts @@ -2,7 +2,56 @@ // SPDX-License-Identifier: AGPL-3.0-only import { chunk } from 'lodash'; +import type { UUIDStringType } from '../types/UUID'; +import { UUID, UUID_BYTE_SIZE } from '../types/UUID'; import * as log from '../logging/log'; +import * as Bytes from '../Bytes'; + +export function getBytesSubarray( + 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 { + const uuids = new Array(); + for (let i = 0; i < buffer.byteLength; i += UUID_BYTE_SIZE) { + const bytes = getBytesSubarray(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 uuidToBytes(uuid: string): Uint8Array { if (uuid.length !== 36) { diff --git a/yarn.lock b/yarn.lock index 72fc1ad24c42..0982f56a07b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1646,6 +1646,13 @@ dependencies: tslib "^2.4.0" +"@formatjs/fast-memoize@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-2.0.1.tgz#f15aaa73caad5562899c69bdcad8db82adcd3b0b" + integrity sha512-M2GgV+qJn5WJQAYewz7q2Cdl6fobQa69S1AzSM2y0P68ZDbK5cWrJIcPCO395Of1ksftGZoOt4LYCO/j9BKBSA== + dependencies: + tslib "^2.4.0" + "@formatjs/icu-messageformat-parser@2.1.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.0.tgz#a54293dd7f098d6a6f6a084ab08b6d54a3e8c12d" @@ -1673,6 +1680,15 @@ "@formatjs/icu-skeleton-parser" "1.3.18" tslib "^2.4.0" +"@formatjs/icu-messageformat-parser@2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.3.1.tgz#953080ea5c053bc73bdf55d0a524a3c3c133ae6b" + integrity sha512-knF2AkAKN4Upv4oIiKY4Wd/dLH68TNMPgV/tJMu/T6FP9aQwbv8fpj7U3lkyniPaNVxvia56Gxax8MKOjtxLSQ== + dependencies: + "@formatjs/ecma402-abstract" "1.14.3" + "@formatjs/icu-skeleton-parser" "1.3.18" + tslib "^2.4.0" + "@formatjs/icu-skeleton-parser@1.3.13": version "1.3.13" resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.13.tgz#f7e186e72ed73c3272d22a3aacb646e77368b099" @@ -1817,6 +1833,35 @@ resolved "https://registry.yarnpkg.com/@indutny/sneequals/-/sneequals-4.0.0.tgz#94f74e577019759c5d12818e7c7ff1b9300653a4" integrity sha512-kQUBQtcm4aVqJil+KRfA7SycJqcWlFEa7MJTYyl4XAahHOPXnzgqvlzUPQOw1tRFlvnzxRpXNUpJxej2fdAPjg== +"@internationalized/date@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@internationalized/date/-/date-3.2.0.tgz#1d266e5e5543a059cf8cca9b954fa033c3e58a75" + integrity sha512-VDMHN1m33L4eqPs5BaihzgQJXyaORbMoHOtrapFxx179J8ucY5CRIHYsq5RRLKPHZWgjNfa5v6amWWDkkMFywA== + dependencies: + "@swc/helpers" "^0.4.14" + +"@internationalized/message@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@internationalized/message/-/message-3.1.0.tgz#b284014cd8bbb430a648b76c87c62bdca968b04c" + integrity sha512-Oo5m70FcBdADf7G8NkUffVSfuCdeAYVfsvNjZDi9ELpjvkc4YNJVTHt/NyTI9K7FgAVoELxiP9YmN0sJ+HNHYQ== + dependencies: + "@swc/helpers" "^0.4.14" + intl-messageformat "^10.1.0" + +"@internationalized/number@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@internationalized/number/-/number-3.2.0.tgz#dffb661cacd61a87b814c47b7d5240a286249066" + integrity sha512-GUXkhXSX1Ee2RURnzl+47uvbOxnlMnvP9Er+QePTjDjOPWuunmLKlEkYkEcLiiJp7y4l9QxGDLOlVr8m69LS5w== + dependencies: + "@swc/helpers" "^0.4.14" + +"@internationalized/string@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@internationalized/string/-/string-3.1.0.tgz#0b365906a8c3f44800b0db52c2e990cff345abce" + integrity sha512-TJQKiyUb+wyAfKF59UNeZ/kELMnkxyecnyPCnBI1ma4NaXReJW+7Cc2mObXAqraIBJUVv7rgI46RLKrLgi35ng== + dependencies: + "@swc/helpers" "^0.4.14" + "@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3": version "0.1.3" resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" @@ -2236,6 +2281,544 @@ version "1.1.0" resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" +"@react-aria/breadcrumbs@^3.5.1": + version "3.5.1" + resolved "https://registry.yarnpkg.com/@react-aria/breadcrumbs/-/breadcrumbs-3.5.1.tgz#e4bddbaebc7fe0fed34c904a03929618affc5730" + integrity sha512-/7UMNtwTBbhPiswoEZF2zGUtezinKeLrEMmywzWgxryJ6A/edfT5KXXcPr7MZdWH5faEFq27WSYsMqdX1J3MsA== + dependencies: + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/link" "^3.5.0" + "@react-aria/utils" "^3.16.0" + "@react-types/breadcrumbs" "^3.5.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/button@^3.7.1": + version "3.7.1" + resolved "https://registry.yarnpkg.com/@react-aria/button/-/button-3.7.1.tgz#780eb2e50bfd2078dd4c880cef7a53f67acf9df5" + integrity sha512-l4Xqu83mT9STB89JLNsejHjHdFZClp/xez07LYfqibdvgcXiH311I76n1QCZqga2OGuIsW+fKmpMtuVPDdS61g== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/interactions" "^3.15.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/toggle" "^3.5.1" + "@react-types/button" "^3.7.2" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/calendar@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@react-aria/calendar/-/calendar-3.2.0.tgz#f0f3fdedf65f3813ab98dc557dd3eb77d92e48bb" + integrity sha512-3wnCusOi7mU9kRMDxspAL81SXAsEVzWuPILOl6OXQHbVtDvRWqkhlqWkjDDoStKD9uwDDB9rfcCsfOQBJnj63A== + dependencies: + "@internationalized/date" "^3.2.0" + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/live-announcer" "^3.3.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/calendar" "^3.2.0" + "@react-types/button" "^3.7.2" + "@react-types/calendar" "^3.2.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/checkbox@^3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@react-aria/checkbox/-/checkbox-3.9.0.tgz#021d612f2578140a9a288b854da3430e4da226d6" + integrity sha512-r6f7fQIZMv5k8x73v9i8Q/WsWqd6Q2yJ8F9TdOrYg5vrRot+QaxzC61HFyW7o+e7A5+UXQfTgU9E/Lezy9YSbA== + dependencies: + "@react-aria/label" "^3.5.1" + "@react-aria/toggle" "^3.6.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/checkbox" "^3.4.1" + "@react-stately/toggle" "^3.5.1" + "@react-types/checkbox" "^3.4.3" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/combobox@^3.6.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@react-aria/combobox/-/combobox-3.6.0.tgz#5859445ee5ed280e2deabdd35af51ab06974b2fe" + integrity sha512-GQqKUlSZy7wciSbLstq0zTTDP2zPPLxwrsqH/SahruRQqFYG8D/5aaqDMooPg17nGWg6wQ693Ho572+dIIgx6A== + dependencies: + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/listbox" "^3.9.0" + "@react-aria/live-announcer" "^3.3.0" + "@react-aria/menu" "^3.9.0" + "@react-aria/overlays" "^3.14.0" + "@react-aria/selection" "^3.14.0" + "@react-aria/textfield" "^3.9.1" + "@react-aria/utils" "^3.16.0" + "@react-stately/collections" "^3.7.0" + "@react-stately/combobox" "^3.5.0" + "@react-stately/layout" "^3.12.0" + "@react-types/button" "^3.7.2" + "@react-types/combobox" "^3.6.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/datepicker@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@react-aria/datepicker/-/datepicker-3.4.0.tgz#152d361aba5688f58c9ac1a62fb9e1a6ee2313d2" + integrity sha512-KhyyeLgWU5eJ+LqpfgOXFm/QBS6SIsX60zOgcQmst+LstsyuYjGQD7oqZX3UIMRWWgWj6urkYHk1yrZtam6doQ== + dependencies: + "@internationalized/date" "^3.2.0" + "@internationalized/number" "^3.2.0" + "@internationalized/string" "^3.1.0" + "@react-aria/focus" "^3.12.0" + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/label" "^3.5.1" + "@react-aria/spinbutton" "^3.4.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/datepicker" "^3.4.0" + "@react-types/button" "^3.7.2" + "@react-types/calendar" "^3.2.0" + "@react-types/datepicker" "^3.3.0" + "@react-types/dialog" "^3.5.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/dialog@^3.5.1": + version "3.5.1" + resolved "https://registry.yarnpkg.com/@react-aria/dialog/-/dialog-3.5.1.tgz#b9b2ca2c7d4c56e5ed987e26d903234535589dbe" + integrity sha512-nvBIO7GbRSoLPtbS38wCuCHXEbRUIAhD87XKGsFslsmK8csZgJiJa8ZQQNknfvNcEBRIYzNRz2XMPJmnN4H1og== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/overlays" "^3.14.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/overlays" "^3.5.1" + "@react-types/dialog" "^3.5.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/dnd@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@react-aria/dnd/-/dnd-3.2.0.tgz#1a0f632952b8b72756739473f2578d0232612bc7" + integrity sha512-O0/JhHA2Qf5gMDqI9DD7xJIZBovUFbn4Y25xMiN52dighmdVLKtw8opgIp+K4lL3n+KR3aG/49R2JYiwJIxHOA== + dependencies: + "@internationalized/string" "^3.1.0" + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/live-announcer" "^3.3.0" + "@react-aria/overlays" "^3.14.0" + "@react-aria/utils" "^3.16.0" + "@react-aria/visually-hidden" "^3.8.0" + "@react-stately/dnd" "^3.2.0" + "@react-types/button" "^3.7.2" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/focus@^3.12.0": + version "3.12.0" + resolved "https://registry.yarnpkg.com/@react-aria/focus/-/focus-3.12.0.tgz#ac01f13782c608d0ed24a3f0b39c96b4a0031716" + integrity sha512-nY6/2lpXzLep6dzQEESoowiSqNcy7DFWuRD/qHj9uKcQwWpYH/rqBrHVS/RNvL6Cz/fBA7L/4AzByJ6pTBtoeA== + dependencies: + "@react-aria/interactions" "^3.15.0" + "@react-aria/utils" "^3.16.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + clsx "^1.1.1" + +"@react-aria/grid@^3.7.0": + version "3.7.0" + resolved "https://registry.yarnpkg.com/@react-aria/grid/-/grid-3.7.0.tgz#f09f3a1697fe39c246cb6aa4bfd47aa8ab00f8c8" + integrity sha512-jXo+/wQotHDSaMSVdVT7Hxzz65Nj2yK1wssIUQPEZalRhcosGWI1vhdQOD0g9GQL1l5DLyw0m55sych6naeBlw== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/live-announcer" "^3.3.0" + "@react-aria/selection" "^3.14.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/collections" "^3.7.0" + "@react-stately/grid" "^3.6.0" + "@react-stately/selection" "^3.13.0" + "@react-stately/virtualizer" "^3.5.1" + "@react-types/checkbox" "^3.4.3" + "@react-types/grid" "^3.1.7" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/gridlist@^3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@react-aria/gridlist/-/gridlist-3.3.0.tgz#fd0829b8b2b5208a8ac0448f6d9dab006bf1b797" + integrity sha512-VNXnNRcAPel1C9KvhIj+lAC3UvtAg8nrkrtdBvuJTWJPuorAvfF8Dy5Oan+bHoo5KFT/SW96KsR7olH5aZucoQ== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/grid" "^3.7.0" + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/selection" "^3.14.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/list" "^3.8.0" + "@react-types/checkbox" "^3.4.3" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/i18n@^3.7.1": + version "3.7.1" + resolved "https://registry.yarnpkg.com/@react-aria/i18n/-/i18n-3.7.1.tgz#bd52869f67a5847f2dbe89603255a2dfc5ef45b4" + integrity sha512-2fu1cv8yD3V+rlhOqstTdGAubadoMFuPE7lA1FfYdaJNxXa09iWqvpipUPlxYJrahW0eazkesOPDKFwOEMF1iA== + dependencies: + "@internationalized/date" "^3.2.0" + "@internationalized/message" "^3.1.0" + "@internationalized/number" "^3.2.0" + "@internationalized/string" "^3.1.0" + "@react-aria/ssr" "^3.6.0" + "@react-aria/utils" "^3.16.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/interactions@^3.15.0": + version "3.15.0" + resolved "https://registry.yarnpkg.com/@react-aria/interactions/-/interactions-3.15.0.tgz#db638d6ae0407be52ecaa1882c2ebc2931880da8" + integrity sha512-8br5uatPDISEWMINKGs7RhNPtqLhRsgwQsooaH7Jgxjs0LBlylODa8l7D3NA1uzVzlvfnZm/t2YN/y8ieRSDcQ== + dependencies: + "@react-aria/ssr" "^3.6.0" + "@react-aria/utils" "^3.16.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/label@^3.5.1": + version "3.5.1" + resolved "https://registry.yarnpkg.com/@react-aria/label/-/label-3.5.1.tgz#ad7f9c141a1d5af143957716e01404ed4ab558e0" + integrity sha512-3KNg6/MJNMN25o0psBbCWzhJNFjtT5NtYJPrFwGHbAfVWvMTRqNftoyrhR490Ac0q2eMKIXkULl1HVn3izrAuw== + dependencies: + "@react-aria/utils" "^3.16.0" + "@react-types/label" "^3.7.3" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/link@^3.5.0": + version "3.5.0" + resolved "https://registry.yarnpkg.com/@react-aria/link/-/link-3.5.0.tgz#6c8e14b8f11f2a67a0f910f77670c0d4f274dda4" + integrity sha512-GcQEHL1MauvTEfqWy4JGP7/bHPaJZ8QJKmDOKrCQCzcT4ts+YaaG6dGGzkkaKK7gymRAF4ePHWWFHySN5mSE7w== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/interactions" "^3.15.0" + "@react-aria/utils" "^3.16.0" + "@react-types/link" "^3.4.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/listbox@^3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@react-aria/listbox/-/listbox-3.9.0.tgz#243d9a863d2592f003aa2c7604962e4db1d57dee" + integrity sha512-CWJBw+R9eGrd2I/RRIpXeTmCTiJRPz9JgL2EYage1+8lCV0sp7HIH2StTMsVBzCA1eH+vJ06LBcPuiZBdtZFlA== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/interactions" "^3.15.0" + "@react-aria/label" "^3.5.1" + "@react-aria/selection" "^3.14.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/collections" "^3.7.0" + "@react-stately/list" "^3.8.0" + "@react-types/listbox" "^3.4.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/live-announcer@^3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@react-aria/live-announcer/-/live-announcer-3.3.0.tgz#04a2a233c2f48c53994f83cafdc4336ec1ea3700" + integrity sha512-6diTS6mIf70KdxfGqiDxHV+9Qv8a9A88EqBllzXGF6HWPdcwde/GIEmfpTwj8g1ImNGZYUwDkv4Hd9lFj0MXEg== + dependencies: + "@swc/helpers" "^0.4.14" + +"@react-aria/menu@^3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@react-aria/menu/-/menu-3.9.0.tgz#cad4caf1aca4bbed8c4a02cac94ffb87f42adf28" + integrity sha512-lIbfWzFvYE7EPOno3lVogXHlc6fzswymlpJWiMBKaB68wkfCtknIIL1cwWssiwgGU63v08H5YpQOZdxRwux2PQ== + dependencies: + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/overlays" "^3.14.0" + "@react-aria/selection" "^3.14.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/collections" "^3.7.0" + "@react-stately/menu" "^3.5.1" + "@react-stately/tree" "^3.6.0" + "@react-types/button" "^3.7.2" + "@react-types/menu" "^3.9.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/meter@^3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@react-aria/meter/-/meter-3.4.1.tgz#6d1fe833f4a5a4469ceca49570a2f3107acb7e58" + integrity sha512-z+FGa8mZgLk/A0leNrGXb43YnOafRea+pZbDQvRQZa5E9kNIVhXaIfFrs0f+Wro8rnOPM8G2V17/XSfy9M809A== + dependencies: + "@react-aria/progress" "^3.4.1" + "@react-types/meter" "^3.3.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/numberfield@^3.5.0": + version "3.5.0" + resolved "https://registry.yarnpkg.com/@react-aria/numberfield/-/numberfield-3.5.0.tgz#ddd8352d33f5455b558751f0a7142b2d5a691e48" + integrity sha512-gOe0BKrYGXrjqn0dkMuMoB+WYzn1qwxR7QvKwwfceutUmirjEvMSQOldnBhHao55pxd4/4bWssrEHhJb7YmPiQ== + dependencies: + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/live-announcer" "^3.3.0" + "@react-aria/spinbutton" "^3.4.0" + "@react-aria/textfield" "^3.9.1" + "@react-aria/utils" "^3.16.0" + "@react-stately/numberfield" "^3.4.1" + "@react-types/button" "^3.7.2" + "@react-types/numberfield" "^3.4.1" + "@react-types/shared" "^3.18.0" + "@react-types/textfield" "^3.7.1" + "@swc/helpers" "^0.4.14" + +"@react-aria/overlays@^3.14.0": + version "3.14.0" + resolved "https://registry.yarnpkg.com/@react-aria/overlays/-/overlays-3.14.0.tgz#2206ca33010dbf6f1986dac6019caeeb5632c03d" + integrity sha512-lt4vOj44ho0LpmpaHwQ4VgX7eNfKXig9VD7cvE9u7uyECG51jqt9go19s4+/O+otX7pPrhdYlEB2FxLFJocxfw== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/ssr" "^3.6.0" + "@react-aria/utils" "^3.16.0" + "@react-aria/visually-hidden" "^3.8.0" + "@react-stately/overlays" "^3.5.1" + "@react-types/button" "^3.7.2" + "@react-types/overlays" "^3.7.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/progress@^3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@react-aria/progress/-/progress-3.4.1.tgz#5eef5dae40275614125928f5c54d81e2f645f83c" + integrity sha512-hSM4TDfL9Sy0hMFEEXjYsKDR1ZnNdVV8EQ6WDe1RdHkqifKtbEoUC5fvQu5ZdPx65jG6xWx/WG9Mv/ylMiYnig== + dependencies: + "@react-aria/i18n" "^3.7.1" + "@react-aria/label" "^3.5.1" + "@react-aria/utils" "^3.16.0" + "@react-types/progress" "^3.4.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/radio@^3.6.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@react-aria/radio/-/radio-3.6.0.tgz#e9bf6fffd1f0f11d3f7c62b247a9a327f550ec39" + integrity sha512-yMyaqFSf05P8w4LE50ENIJza4iM74CEyAhVlQwxRszXhJk6uro5bnxTSJqPrdRdI5+anwOijH53+x5dISO3KWA== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/label" "^3.5.1" + "@react-aria/utils" "^3.16.0" + "@react-stately/radio" "^3.8.0" + "@react-types/radio" "^3.4.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/searchfield@^3.5.1": + version "3.5.1" + resolved "https://registry.yarnpkg.com/@react-aria/searchfield/-/searchfield-3.5.1.tgz#cfb9ba1a6414b6a590e338a1b40c02044f3699c8" + integrity sha512-gJQWgBIycxZXdmluHWUdCGN5gSArLJnDnuri3el8ECURZM7C+zxDeHd6A8xlKPNF+m5X0HcarrDAnEdXNjKKlQ== + dependencies: + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/textfield" "^3.9.1" + "@react-aria/utils" "^3.16.0" + "@react-stately/searchfield" "^3.4.1" + "@react-types/button" "^3.7.2" + "@react-types/searchfield" "^3.4.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/select@^3.10.0": + version "3.10.0" + resolved "https://registry.yarnpkg.com/@react-aria/select/-/select-3.10.0.tgz#51c1741a91bfacf5e7c8a5abe35b6d9f9da7368c" + integrity sha512-Adn/uQdGj0BUTe/gqvhtyEdkUQ0j0oZPrhxo6MIwXiX3vyu/GJJBgeSe67Z848iZvYzVk3iheFS88qvwmJ0Qbg== + dependencies: + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/label" "^3.5.1" + "@react-aria/listbox" "^3.9.0" + "@react-aria/menu" "^3.9.0" + "@react-aria/selection" "^3.14.0" + "@react-aria/utils" "^3.16.0" + "@react-aria/visually-hidden" "^3.8.0" + "@react-stately/select" "^3.5.0" + "@react-types/button" "^3.7.2" + "@react-types/select" "^3.8.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/selection@^3.14.0": + version "3.14.0" + resolved "https://registry.yarnpkg.com/@react-aria/selection/-/selection-3.14.0.tgz#33520cee7d3f67da0f1c718694c8f557f03a487f" + integrity sha512-4/cq3mP75/qbhz2OkWmrfL6MJ+7+KfFsT6wvVNvxgOWR0n4jivHToKi3DXo2TzInvNU+10Ha7FCWavZoUNgSlA== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/collections" "^3.7.0" + "@react-stately/selection" "^3.13.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/separator@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@react-aria/separator/-/separator-3.3.1.tgz#2018c643adc7e2ae42702adf653a5b0aa1e709e4" + integrity sha512-BNiJpkzHDnNBeZFGud9MSLzUkYevq7WT0+XO60SMvD/OhDBoBPp26b6fK1W81HKTbs+bk/dvmlnnbx9ViGsLzw== + dependencies: + "@react-aria/utils" "^3.16.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/slider@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@react-aria/slider/-/slider-3.4.0.tgz#f5615861cc77dc240f2d3d7776f07badac364dcf" + integrity sha512-fW3gQhafs8ACAN7HGBpzmGV+hHVMUxI4UZ/V3h/LJ1vIxZY857iSQolzfJFBYhCyV0YU4D4uDUcYZhoH18GZnQ== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/label" "^3.5.1" + "@react-aria/utils" "^3.16.0" + "@react-stately/radio" "^3.8.0" + "@react-stately/slider" "^3.3.1" + "@react-types/radio" "^3.4.1" + "@react-types/shared" "^3.18.0" + "@react-types/slider" "^3.5.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/spinbutton@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@react-aria/spinbutton/-/spinbutton-3.4.0.tgz#a500f5eb42e459f2a2789728832446f61c365313" + integrity sha512-8JEHw3pnosEYOQSZol0QpXMRhdb3z4FtaSovUdCPo7x7A7BtGCVsy3lAt31+WvQAknzZIDwxSBaNAcOj0cYhWQ== + dependencies: + "@react-aria/i18n" "^3.7.1" + "@react-aria/live-announcer" "^3.3.0" + "@react-aria/utils" "^3.16.0" + "@react-types/button" "^3.7.2" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/ssr@^3.6.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@react-aria/ssr/-/ssr-3.6.0.tgz#e5d52bd1686ff229f68f806cf94ee29dd9f54fb7" + integrity sha512-OFiYQdv+Yk7AO7IsQu/fAEPijbeTwrrEYvdNoJ3sblBBedD5j5fBTNWrUPNVlwC4XWWnWTCMaRIVsJujsFiWXg== + dependencies: + "@swc/helpers" "^0.4.14" + +"@react-aria/switch@^3.5.0": + version "3.5.0" + resolved "https://registry.yarnpkg.com/@react-aria/switch/-/switch-3.5.0.tgz#a83fb396a113d6ce918c2cb723e565039775a7bf" + integrity sha512-nMrwT0McuQ7ki6rSDFIuf9qa9UjcA1XJQ9zDRD2CC10F48xpHHi12iZpS8GAEdG2jTNdCZ3qSO1HsIt63uEQoQ== + dependencies: + "@react-aria/toggle" "^3.6.0" + "@react-stately/toggle" "^3.5.1" + "@react-types/switch" "^3.3.1" + "@swc/helpers" "^0.4.14" + +"@react-aria/table@^3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@react-aria/table/-/table-3.9.0.tgz#d626649702567b7b519bb712bd1b76022845e476" + integrity sha512-hY1tM7NRjP+gRvm2OGgWeEZ8An0tzljj0O19JCg7oi6IpypFJqeSqSUQml1OIv5wbZ04pQnoYGtMkP7h7YqkPw== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/grid" "^3.7.0" + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/live-announcer" "^3.3.0" + "@react-aria/selection" "^3.14.0" + "@react-aria/utils" "^3.16.0" + "@react-aria/visually-hidden" "^3.8.0" + "@react-stately/collections" "^3.7.0" + "@react-stately/table" "^3.9.0" + "@react-stately/virtualizer" "^3.5.1" + "@react-types/checkbox" "^3.4.3" + "@react-types/grid" "^3.1.7" + "@react-types/shared" "^3.18.0" + "@react-types/table" "^3.6.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/tabs@^3.5.0": + version "3.5.0" + resolved "https://registry.yarnpkg.com/@react-aria/tabs/-/tabs-3.5.0.tgz#fb2c110002f9a00bb82aed7ca8f006b4f7d8b710" + integrity sha512-QnCoNHDmeRoPWLHsr1Q81RN/KymwU79XS/zHguhZ3fx59je9bswUDG77NjylcPRXoOEOZ18gZ+Y7reBVRhNEog== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/selection" "^3.14.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/list" "^3.8.0" + "@react-stately/tabs" "^3.4.0" + "@react-types/shared" "^3.18.0" + "@react-types/tabs" "^3.2.1" + "@swc/helpers" "^0.4.14" + +"@react-aria/textfield@^3.9.1": + version "3.9.1" + resolved "https://registry.yarnpkg.com/@react-aria/textfield/-/textfield-3.9.1.tgz#e032b0a5f2006891de7ff3465dc76381990341fc" + integrity sha512-IxJ6QupBD8yiEwF1etj4BWfwjNpc3Y00j+pzRIuo07bbkEOPl0jtKxW5YHG9un6nC9a5CKIHcILato1Q0Tsy0g== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/label" "^3.5.1" + "@react-aria/utils" "^3.16.0" + "@react-types/shared" "^3.18.0" + "@react-types/textfield" "^3.7.1" + "@swc/helpers" "^0.4.14" + +"@react-aria/toggle@^3.6.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@react-aria/toggle/-/toggle-3.6.0.tgz#c80b5743b58ab11daf3f46a2142fd87e898b3a2a" + integrity sha512-W6xncx5zzqCaPU2XsgjWnACHL3WBpxphYLvF5XlICRg0nZVjGPIWPDDUGyDoPsSUeGMW2vxtFY6erKXtcy4Kgw== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/interactions" "^3.15.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/toggle" "^3.5.1" + "@react-types/checkbox" "^3.4.3" + "@react-types/shared" "^3.18.0" + "@react-types/switch" "^3.3.1" + "@swc/helpers" "^0.4.14" + +"@react-aria/tooltip@^3.5.0": + version "3.5.0" + resolved "https://registry.yarnpkg.com/@react-aria/tooltip/-/tooltip-3.5.0.tgz#0581c4647f1ddf71baca893ecb2dda3d7aceaffb" + integrity sha512-zjKJDUMVbkzRpSHLGYpK12NpWy2NPfqS7MlGPB8fjjdY4bQjVOGlGWPqcfnE28gdNRYWZuMBwJSC0NrT8iylUg== + dependencies: + "@react-aria/focus" "^3.12.0" + "@react-aria/interactions" "^3.15.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/tooltip" "^3.4.0" + "@react-types/shared" "^3.18.0" + "@react-types/tooltip" "^3.4.0" + "@swc/helpers" "^0.4.14" + +"@react-aria/utils@3.16.0", "@react-aria/utils@^3.16.0": + version "3.16.0" + resolved "https://registry.yarnpkg.com/@react-aria/utils/-/utils-3.16.0.tgz#0394f575e47b1c48a15844dc58e1775a9f72f8f5" + integrity sha512-BumpgENDlXuoRPQm1OfVUYRcxY9vwuXw1AmUpwF61v55gAZT3LvJWsfF8jgfQNzLJr5jtr7xvUx7pXuEyFpJMA== + dependencies: + "@react-aria/ssr" "^3.6.0" + "@react-stately/utils" "^3.6.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + clsx "^1.1.1" + +"@react-aria/visually-hidden@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@react-aria/visually-hidden/-/visually-hidden-3.8.0.tgz#9bdcf708e4e628041d8fbac66c7dcb98b9529da9" + integrity sha512-Ox7VcO8vfdA1rCHPcUuP9DWfCI9bNFVlvN/u66AfjwBLH40MnGGdob5hZswQnbxOY4e0kwkMQDmZwNPYzBQgsg== + dependencies: + "@react-aria/interactions" "^3.15.0" + "@react-aria/utils" "^3.16.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + clsx "^1.1.1" + "@react-spring/animated@~9.5.5": version "9.5.5" resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.5.5.tgz#d3bfd0f62ed13a337463a55d2c93bb23c15bbf3e" @@ -2282,6 +2865,466 @@ "@react-spring/shared" "~9.5.5" "@react-spring/types" "~9.5.5" +"@react-stately/calendar@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@react-stately/calendar/-/calendar-3.2.0.tgz#ecfd9469e4c26ec9e372658409b37e9cce52ac5e" + integrity sha512-A13QSmlzLI5rtpIu2QIkij4ST29MWkCJd1kM6WFDS/1if8lSzfPL3kI4tdFDaFzFCwmv2Hb2cIfv9soIG8KASQ== + dependencies: + "@internationalized/date" "^3.2.0" + "@react-stately/utils" "^3.6.0" + "@react-types/calendar" "^3.2.0" + "@react-types/datepicker" "^3.3.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/checkbox@^3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@react-stately/checkbox/-/checkbox-3.4.1.tgz#3b0930ce773d64c465a8ddfdb0d461d4ea8a79b5" + integrity sha512-Ju1EBuIE/JJbuhd8xMkgqf3KuSNpRrwXsgtI+Ur42F+lAedZH7vqy+5bZPo0Q3u0yHcNJzexZXOxHZNqq1ij8w== + dependencies: + "@react-stately/toggle" "^3.5.1" + "@react-stately/utils" "^3.6.0" + "@react-types/checkbox" "^3.4.3" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/collections@^3.7.0": + version "3.7.0" + resolved "https://registry.yarnpkg.com/@react-stately/collections/-/collections-3.7.0.tgz#5f032ba5e8b554e90bf1a2077220d2560618dbf7" + integrity sha512-xZHJxjGXFe3LUbuNgR1yATBVSIQnm+ItLq2DJZo3JzTtRu3gEwLoRRoapPsBQnC5VsjcaimgoqvT05P0AlvCTQ== + dependencies: + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/combobox@^3.5.0": + version "3.5.0" + resolved "https://registry.yarnpkg.com/@react-stately/combobox/-/combobox-3.5.0.tgz#f3ea28dd743d5d9bc8ebd17bfeab1fa3ea21f176" + integrity sha512-1klrkm1q1awoPUIXt0kKRrUu+rISLQkHRkStjLupXgGOnJUyYP0XWPYHCnRV+IR2K2RnWYiEY5kOi7TEp/F7Fw== + dependencies: + "@react-stately/collections" "^3.7.0" + "@react-stately/list" "^3.8.0" + "@react-stately/menu" "^3.5.1" + "@react-stately/select" "^3.5.0" + "@react-stately/utils" "^3.6.0" + "@react-types/combobox" "^3.6.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/data@^3.9.1": + version "3.9.1" + resolved "https://registry.yarnpkg.com/@react-stately/data/-/data-3.9.1.tgz#bfa817330f78b6308595c1ddab53cbdffab252f8" + integrity sha512-UClgI8jQTF3hVR/WLa2ht7Gjd2x2PRnYycDmfY+mfbd+ONBD7rX/m3KWGgrR8AvO05qSpQoSlab8D+cfLXvgWA== + dependencies: + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/datepicker@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@react-stately/datepicker/-/datepicker-3.4.0.tgz#d531cf792edafa14733de17528b7b17df8e0a696" + integrity sha512-JiRQBQYDXOQDdJl5YUGob10aVYp2N/F5rSSkRt7MrBJhC87bkDW0ARfs83gnl398WOJ6d9rJp0f+CJa1mjtzUw== + dependencies: + "@internationalized/date" "^3.2.0" + "@internationalized/string" "^3.1.0" + "@react-stately/overlays" "^3.5.1" + "@react-stately/utils" "^3.6.0" + "@react-types/datepicker" "^3.3.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/dnd@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@react-stately/dnd/-/dnd-3.2.0.tgz#a9d600ca5124c059a73dfdec6b2e6cbb2f04af86" + integrity sha512-e+f5lBiBBHmgqwcKKPxJBpCSx08iuNacNUFQ5/yIWm/enpjwTQhCMyfOFCLM1DfSllM/19GlqV/GiDRM7xjEAQ== + dependencies: + "@react-stately/selection" "^3.13.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/grid@^3.6.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@react-stately/grid/-/grid-3.6.0.tgz#210d09efc4df415cfcffb73b6c8cfa18f5bab0f4" + integrity sha512-Sq/ivfq9Kskghoe6rYh2PfhB9/jBGfoj8wUZ4bqHcalTrBjfUvkcWMSFosibYPNZFDkA7r00bbJPDJVf1VLhuw== + dependencies: + "@react-stately/collections" "^3.7.0" + "@react-stately/selection" "^3.13.0" + "@react-types/grid" "^3.1.7" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/layout@^3.12.0": + version "3.12.0" + resolved "https://registry.yarnpkg.com/@react-stately/layout/-/layout-3.12.0.tgz#ce93b4459a24024ae886692d7338e6b8a954b334" + integrity sha512-CsBGh1Xp3SL64g5xTxNYEWdnNmmqryOyrK4BW59pzpXFytVquv4MBb6t/YRl5PnhtsORxk5aTR21NZkhDQa7jA== + dependencies: + "@react-stately/collections" "^3.7.0" + "@react-stately/table" "^3.9.0" + "@react-stately/virtualizer" "^3.5.1" + "@react-types/grid" "^3.1.7" + "@react-types/shared" "^3.18.0" + "@react-types/table" "^3.6.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/list@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@react-stately/list/-/list-3.8.0.tgz#a5de6f3b125c8cb90b65188978bace4eabe9a9a5" + integrity sha512-eJ1iUFnXPZi5MGW2h/RdNTrKtq4HLoAlFAQbC4eSPlET6VDeFsX9NkKhE/A111ia24DnWCqJB5zH20EvNbOxxA== + dependencies: + "@react-stately/collections" "^3.7.0" + "@react-stately/selection" "^3.13.0" + "@react-stately/utils" "^3.6.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/menu@^3.5.1": + version "3.5.1" + resolved "https://registry.yarnpkg.com/@react-stately/menu/-/menu-3.5.1.tgz#2cffe84d8c93920822fdd92bf716205460873db7" + integrity sha512-nnuZlDBFIc3gB34kofbKDStFg9r8rijY+7ez2VWQmss72I9D7+JTn7OXJxV0oQt2lBYmNfS5W6bC9uXk3Z4dLg== + dependencies: + "@react-stately/overlays" "^3.5.1" + "@react-stately/utils" "^3.6.0" + "@react-types/menu" "^3.9.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/numberfield@^3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@react-stately/numberfield/-/numberfield-3.4.1.tgz#b4e1c7e49ad600dbd902b256e0a9b3e49a6f69b4" + integrity sha512-fpIyk3Wf9HN/fY/T2y4q9mA/9z4no8QMY4tEIn/tkumjU6QGzxCSRO0qb3RFE8sU0etsVAZOkPi+97DeQVLExw== + dependencies: + "@internationalized/number" "^3.2.0" + "@react-stately/utils" "^3.6.0" + "@react-types/numberfield" "^3.4.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/overlays@^3.5.1": + version "3.5.1" + resolved "https://registry.yarnpkg.com/@react-stately/overlays/-/overlays-3.5.1.tgz#7d876261fe288cfbc4cc56869481b7cd7a14e30f" + integrity sha512-lDKqqpdaIQdJb8DS4+tT7p0TLyCeaUaFpEtWZNjyv1/nguoqYtSeRwnyPR4p/YM4AW7SJspNiTJSLQxkTMIa8w== + dependencies: + "@react-stately/utils" "^3.6.0" + "@react-types/overlays" "^3.7.1" + "@swc/helpers" "^0.4.14" + +"@react-stately/radio@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@react-stately/radio/-/radio-3.8.0.tgz#0d67dc499b2bebf2411e5ac272bf98e4911f2d24" + integrity sha512-3xNocZ8jlS8JcQtlS+pGhGLmrTA/P6zWs7Xi3Cx/I6ialFVL7IE0W37Z0XTYrvpNhE9hmG4+j63ZqQDNj2nu6A== + dependencies: + "@react-stately/utils" "^3.6.0" + "@react-types/radio" "^3.4.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/searchfield@^3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@react-stately/searchfield/-/searchfield-3.4.1.tgz#cb3109bb0803f79fa49e98acb7eae25cea22b783" + integrity sha512-iEMcT2hH15TSoONi6FyFa9mh+H/UyNneYFzaUgl7kEClfL38Dq/y0zF18N9T8PJ0GvXN2Yj9Fc0AvycNy3DQ8g== + dependencies: + "@react-stately/utils" "^3.6.0" + "@react-types/searchfield" "^3.4.1" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/select@^3.5.0": + version "3.5.0" + resolved "https://registry.yarnpkg.com/@react-stately/select/-/select-3.5.0.tgz#7a443c9d4a3542a24ac9380db4ed328285de1ba3" + integrity sha512-65gCPkIcyhGBDlWKYQY+Xvx38r7dtZ/GMp09LFZqqZTYSe29EgY45Owv4+EQ2ZSoZxb3cEvG/sv+hLL0VSGjgQ== + dependencies: + "@react-stately/collections" "^3.7.0" + "@react-stately/list" "^3.8.0" + "@react-stately/menu" "^3.5.1" + "@react-stately/selection" "^3.13.0" + "@react-stately/utils" "^3.6.0" + "@react-types/select" "^3.8.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/selection@^3.13.0": + version "3.13.0" + resolved "https://registry.yarnpkg.com/@react-stately/selection/-/selection-3.13.0.tgz#a4efe8ebebd99cc38fa5cfe90c9038bbe155192a" + integrity sha512-F6FiB5GIS6wdmDDJtD2ofr+y6ysLHcvHVyUZHm00aEup2hcNjtNx3x4MlFIc3tO1LvxDSIIWXJhPXdB4sb32uw== + dependencies: + "@react-stately/collections" "^3.7.0" + "@react-stately/utils" "^3.6.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/slider@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@react-stately/slider/-/slider-3.3.1.tgz#4097840556259d1c89b166add3af44178d13d301" + integrity sha512-d38VY/jAvDzohYvqsdwsegcRCmzO1Ed4N3cdSGqYNTkr/nLTye/NZGpzt8kGbPUsc4UzOH7GoycqG6x6hFlyuw== + dependencies: + "@react-aria/i18n" "^3.7.1" + "@react-aria/utils" "^3.16.0" + "@react-stately/utils" "^3.6.0" + "@react-types/shared" "^3.18.0" + "@react-types/slider" "^3.5.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/table@^3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@react-stately/table/-/table-3.9.0.tgz#8e8e60adec80f943d0f842b1629bfe84b0bef16b" + integrity sha512-Cl0jmC5eCEhWBAhCjhGklsgYluziNZHF34lHnc99T/DPP+OxwrgwS9rJKTW7L6UOvHU/ADKjEwkE/fZuqVBohg== + dependencies: + "@react-stately/collections" "^3.7.0" + "@react-stately/grid" "^3.6.0" + "@react-stately/selection" "^3.13.0" + "@react-types/grid" "^3.1.7" + "@react-types/shared" "^3.18.0" + "@react-types/table" "^3.6.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/tabs@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@react-stately/tabs/-/tabs-3.4.0.tgz#7cd90cc65dae46d86a45bdbb1bb09102ad627556" + integrity sha512-GeU0cykAEsyTf2tWC7JZqqLrgxPT1WriCmu9QAswJ7Dev1PkPvwDy3CEhJ3QDklTlhiLXLZOooyHh37lZTjRdg== + dependencies: + "@react-stately/list" "^3.8.0" + "@react-stately/utils" "^3.6.0" + "@react-types/shared" "^3.18.0" + "@react-types/tabs" "^3.2.1" + "@swc/helpers" "^0.4.14" + +"@react-stately/toggle@^3.5.1": + version "3.5.1" + resolved "https://registry.yarnpkg.com/@react-stately/toggle/-/toggle-3.5.1.tgz#9e8a2c55f8e18c5904c07f724cf7c056fd55660c" + integrity sha512-PF4ZaATpXWu7DkneGSZ2/PA6LJ1MrhKNiaENTZlbojXMRr5kK33wPzaDW7I8O25IUm0+rvQicv7A6QkEOxgOPg== + dependencies: + "@react-stately/utils" "^3.6.0" + "@react-types/checkbox" "^3.4.3" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/tooltip@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@react-stately/tooltip/-/tooltip-3.4.0.tgz#9d5118e71083b8a419a5d49bc993c7fa376c2365" + integrity sha512-TQyDIcugRah4eGmbK6UsyrtJrKJKte+xKv8X7kgdiGVMWiENiMG5h+3pGa8OT07FJzg7FvQHkMH+hrIuAqXT2g== + dependencies: + "@react-stately/overlays" "^3.5.1" + "@react-stately/utils" "^3.6.0" + "@react-types/tooltip" "^3.4.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/tree@^3.6.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@react-stately/tree/-/tree-3.6.0.tgz#bdcb645ca2e566d0a7e3dc1cdde13b491f34dfc1" + integrity sha512-9ekYGaebgMmd2p6PGRzsvr8KsDsDnrJF2uLV1GMq9hBaMxOLN5/dpxgfZGdHWoF3MXgeHeLloqrleMNfO6g64Q== + dependencies: + "@react-stately/collections" "^3.7.0" + "@react-stately/selection" "^3.13.0" + "@react-stately/utils" "^3.6.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-stately/utils@^3.6.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@react-stately/utils/-/utils-3.6.0.tgz#f273e7fcb348254347d2e88c8f0c45571060c207" + integrity sha512-rptF7iUWDrquaYvBAS4QQhOBQyLBncDeHF03WnHXAxnuPJXNcr9cXJtjJPGCs036ZB8Q2hc9BGG5wNyMkF5v+Q== + dependencies: + "@swc/helpers" "^0.4.14" + +"@react-stately/virtualizer@^3.5.1": + version "3.5.1" + resolved "https://registry.yarnpkg.com/@react-stately/virtualizer/-/virtualizer-3.5.1.tgz#176516da9b97f2a78159655d95412e98f1226c82" + integrity sha512-TVszEl8+os5eAwoETAJ0ndz5cnYFQs52OIcWonKRYbNp5KvWAV+OA2HuIrB3SSC29ZRB2bDqpj4S2LY4wWJPCw== + dependencies: + "@react-aria/utils" "^3.16.0" + "@react-types/shared" "^3.18.0" + "@swc/helpers" "^0.4.14" + +"@react-types/breadcrumbs@^3.5.1": + version "3.5.1" + resolved "https://registry.yarnpkg.com/@react-types/breadcrumbs/-/breadcrumbs-3.5.1.tgz#9dfc1542d94512261f7c96946fdc82e40f4101fc" + integrity sha512-+l9134cLOrLpxfzrCzEZiVpH7rfhFm8/+xklpbbpz4RguAHmP5bvi9TMRqK0mC9LAdm2GhG7i23YED8Gcv5EVQ== + dependencies: + "@react-types/link" "^3.4.1" + "@react-types/shared" "^3.18.0" + +"@react-types/button@^3.7.2": + version "3.7.2" + resolved "https://registry.yarnpkg.com/@react-types/button/-/button-3.7.2.tgz#954566e9b576780bda0c018d7f5046a1d44f5677" + integrity sha512-P7L+r+k4yVrvsfEWx3wlzbb+G7c9XNWzxEBfy6WX9HnKb/J5bo4sP5Zi8/TFVaKTlaG60wmVhdr+8KWSjL0GuQ== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/calendar@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@react-types/calendar/-/calendar-3.2.0.tgz#7e5dae7cf7a2ec3795ece0b445a3d6c40a424e4e" + integrity sha512-MunGx/lQgf/Lf9v2MrWoqKTZhJJcyAhUno2MewytdMQNXwtY2FB1X4fUufMMrKHwhVnFVkGfEQJCh4FAm5P9JA== + dependencies: + "@internationalized/date" "^3.2.0" + "@react-types/shared" "^3.18.0" + +"@react-types/checkbox@^3.4.3": + version "3.4.3" + resolved "https://registry.yarnpkg.com/@react-types/checkbox/-/checkbox-3.4.3.tgz#fb1585030b000a9fcc87d624a218dd3f2e3038d1" + integrity sha512-kn2f8mK88yvRrCfh8jYCDL2xpPhSApFWk9+qjWGsX/bnGGob7D5n71YYQ4cS58117YK2nrLc/AyQJXcZnJiA7Q== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/combobox@^3.6.1": + version "3.6.1" + resolved "https://registry.yarnpkg.com/@react-types/combobox/-/combobox-3.6.1.tgz#8b951474e8be26bc6c44315b53dc1d1ddd3b4a53" + integrity sha512-CydRYMc80d4Wi6HeXUhmVPrVUnvQm60WJUaX2hM71tkKFo9ZOM6oW02YuOicjkNr7gpM7PLUxvM4Poc9EvDQTw== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/datepicker@^3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@react-types/datepicker/-/datepicker-3.3.0.tgz#587d5c05b25cb3b494235b6cb230b7bb5f3273cc" + integrity sha512-dKhkpG3UhdwYqdpVjg5dCQgMefpr7sa4a6Ep6fvbyD/q7gv9+h0/1J5F3FJynW+CBL6uYhcZjNev2vjYVTDbEg== + dependencies: + "@internationalized/date" "^3.2.0" + "@react-types/overlays" "^3.7.1" + "@react-types/shared" "^3.18.0" + +"@react-types/dialog@^3.5.1": + version "3.5.1" + resolved "https://registry.yarnpkg.com/@react-types/dialog/-/dialog-3.5.1.tgz#0e1faa133238e026c4ba52c62fd2c8d748539b1f" + integrity sha512-a0eeGIITFuOxY2fIL1WkJT5yWIMIQ+VM4vE5MtS59zV9JynDaiL4uNL4yg08kJZm8oyzxIWwrov4gAbEVVWbDQ== + dependencies: + "@react-types/overlays" "^3.7.1" + "@react-types/shared" "^3.18.0" + +"@react-types/grid@^3.1.7": + version "3.1.7" + resolved "https://registry.yarnpkg.com/@react-types/grid/-/grid-3.1.7.tgz#f031b7c258550610cc3ba2e197b95214c1472177" + integrity sha512-YKo/AbJrgWErPmr5y0K4o6Ts9ModFv5+2FVujecIydu3zLuHsVcx//6uVeHSy2W+uTV9vU/dpMP+GGgg+vWQhw== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/label@^3.7.3": + version "3.7.3" + resolved "https://registry.yarnpkg.com/@react-types/label/-/label-3.7.3.tgz#adab02949088a450f9dd947319bc5ca66eef5c4b" + integrity sha512-TKuQ2REPl4UVq/wl3CAujzixeNVVso0Kob+0T1nP8jIt9k9ssdLMAgSh8Z4zNNfR+oBIngYOA9IToMnbx6qACA== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/link@^3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@react-types/link/-/link-3.4.1.tgz#e6aa92b640f8e09c29c84b6954f9da3f89a965e1" + integrity sha512-ZoCfuS+0A0QrCG5kfp4ZeqXCMW39WCyTRSD9FCQvtTYOgCT4G5rvXBnCKIaN8T8w6WbgEbkg2wpRSG3Qd0GZJQ== + dependencies: + "@react-aria/interactions" "^3.15.0" + "@react-types/shared" "^3.18.0" + +"@react-types/listbox@^3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@react-types/listbox/-/listbox-3.4.1.tgz#3d9f5859ad4eb550a6c1c532042316b32e43b606" + integrity sha512-2h1zJDQI3v4BFBwpjKc+OYXM2EzN2uxG5DiZ4MZUcWJDpa1+rOlfaPtBNUPiEVHt6fm6qeuoYVPf3r65Lo3IDw== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/menu@^3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@react-types/menu/-/menu-3.9.0.tgz#2c98335e9e563f06a25996cde73b99a8920ef6a9" + integrity sha512-aalUYwOkzcHn8X59vllgtH96YLqZvAr4mTj5GEs8chv5JVlmArUzcDiOymNrYZ0p9JzshzSUqxxXyCFpnnxghw== + dependencies: + "@react-types/overlays" "^3.7.1" + "@react-types/shared" "^3.18.0" + +"@react-types/meter@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@react-types/meter/-/meter-3.3.1.tgz#236301c6c86ae6962488aefa42785247d1cc118d" + integrity sha512-KWaJ3OFW4X3tROpz/Dtun1d/RmghzXEBqAKeuv0AQDwy2QaQhQdAKgMpS7mPbkF906Xl8eNNDms+0Yi56EYJog== + dependencies: + "@react-types/progress" "^3.4.0" + "@react-types/shared" "^3.18.0" + +"@react-types/numberfield@^3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@react-types/numberfield/-/numberfield-3.4.1.tgz#f52d053b0ab6a3bd894878f621532224d3480caf" + integrity sha512-iS+s2BgOWUxYnMt+LG1OxlKZWeggKMBs55/NzVF5I2MCe1ju8ZUgM27g9A/gvUTdjt+fqx6VZu0MCipw0rVkIQ== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/overlays@^3.7.1": + version "3.7.1" + resolved "https://registry.yarnpkg.com/@react-types/overlays/-/overlays-3.7.1.tgz#f06bf0b7845e5a609c3a3eda0bb3f2aea71a143a" + integrity sha512-2AwYQkelr4p1uXR1KJIGQEbubOumzM853Hsyup2y/TaMbjvBWOVyzYWSrQURex667JZmpwUb0qjkEH+4z3Q74g== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/progress@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@react-types/progress/-/progress-3.4.0.tgz#6a8b7642e38bbc73389dfa1eceb32ab57561cae8" + integrity sha512-aSb7mn6nqVla8svO75/QZba7PhhdTh2rsvdwhvPkB7S06pbX6f0x+YCqXrpT+v9aPGxQ8q6U1b2I0fLrmQTSeA== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/radio@^3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@react-types/radio/-/radio-3.4.1.tgz#e79efb40c35109d888d3aa8de4d8dd0f4c4028e3" + integrity sha512-8r7s+Zj0JoIpYgbuHjhE/eWUHKiptaFvYXMH986yKAg969VQlQiP9Dm4oWv2d+p26WbGK7oJDQJCt8NjASWl8g== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/searchfield@^3.4.1": + version "3.4.1" + resolved "https://registry.yarnpkg.com/@react-types/searchfield/-/searchfield-3.4.1.tgz#d20f2389af230cfa4b53e1e5913c0b2f6bcbdf56" + integrity sha512-JmIwylx88IYrntfw7vAWCL1Ip5okJIRtC8Ne6mr2IjT4oGA9BRF5LpoPdEZlXfVPwLt7jlwGLUwKphbkds+yUA== + dependencies: + "@react-types/shared" "^3.18.0" + "@react-types/textfield" "^3.7.1" + +"@react-types/select@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@react-types/select/-/select-3.8.0.tgz#a5ad61f99bf4f490a24ef82bd76270ffcf4633f5" + integrity sha512-hdaB3CzK8GSip9oGahfnlwolRqdNow85CQwf5P0oEtIDdijihrG6hyphPu5HYGK687EF+lfhnWUYUMwckEwB8Q== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/shared@^3.18.0": + version "3.18.0" + resolved "https://registry.yarnpkg.com/@react-types/shared/-/shared-3.18.0.tgz#4f2bacad5912eba6667695ee3f9e8ac9f79849f7" + integrity sha512-WJj7RAPj7NLdR/VzFObgvCju9NMDktWSruSPJ3DrL5qyrrvJoyMW67L4YjNoVp2b7Y+k10E0q4fSMV0PlJoL0w== + +"@react-types/slider@^3.5.0": + version "3.5.0" + resolved "https://registry.yarnpkg.com/@react-types/slider/-/slider-3.5.0.tgz#3cf51b11ee57ac54d4314f4cabab0d47dc433c85" + integrity sha512-ri0jGWt1x/+nWLLJmlRKaS0xyAjTE1UtsobEYotKkQjzG93WrsEZrb0tLmDnXyEfWi3NXyrReQcORveyv4EQ5g== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/switch@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@react-types/switch/-/switch-3.3.1.tgz#76f644391918d3035c7dc3228724b462dfd81924" + integrity sha512-EvKWPtcOLTF7Wh8YCxJEtmqRZX3qSLRYPaIntl/CKF+14QXErPXwOn0ObLfy6VNda5jDJBOecWpgC69JEjkvfw== + dependencies: + "@react-types/checkbox" "^3.4.3" + "@react-types/shared" "^3.18.0" + +"@react-types/table@^3.6.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@react-types/table/-/table-3.6.0.tgz#f71718ac85a9352a5f52a2d8d8bdf98883cf1f54" + integrity sha512-jUp8yTWJuJlqpJY+EIEppgjFsZ3oj4y9zg1oUO+l1rqRWEqmAdoq42g3dTZHmnz9hQJkUeo34I1HGaB9kxNqvg== + dependencies: + "@react-types/grid" "^3.1.7" + "@react-types/shared" "^3.18.0" + +"@react-types/tabs@^3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@react-types/tabs/-/tabs-3.2.1.tgz#060438afba1a400993a4733e33542046cb1495b0" + integrity sha512-KgvhrYvISQUq540iuNc3bRvOCfLvaeqpB5VwDYR8amG1FVWHklCW8xx8Uz63SVkOvNtExYCrlw63M/OnjRUzOw== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/textfield@^3.7.1": + version "3.7.1" + resolved "https://registry.yarnpkg.com/@react-types/textfield/-/textfield-3.7.1.tgz#6dc0c98384a1acba3f69a21b6a1563aa7b20de48" + integrity sha512-6V5+6/VgDbmgN61pyVct1VrXb2hqq7Y43BFQ+/ZhFDlVaMpC5xKWKgW/gPbGLLc27gax8t2Brt7VHJj+d+yrUw== + dependencies: + "@react-types/shared" "^3.18.0" + +"@react-types/tooltip@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@react-types/tooltip/-/tooltip-3.4.0.tgz#5d02407903257ed888b7d45898a1903ed597e2e3" + integrity sha512-dvMwX377uJAMTuditfvwWed53YjV62XWMqW29Fave4xg3A807VVK3H1iEgwCIGA9ve2XHF8cJbqSHD635qU+tQ== + dependencies: + "@react-types/overlays" "^3.7.1" + "@react-types/shared" "^3.18.0" + "@signalapp/better-sqlite3@8.4.3": version "8.4.3" resolved "https://registry.yarnpkg.com/@signalapp/better-sqlite3/-/better-sqlite3-8.4.3.tgz#7ffa8d03d2a12543247936bfb7b9f74cdbc6fe9b" @@ -3707,6 +4750,13 @@ regenerator-runtime "^0.13.7" resolve-from "^5.0.0" +"@swc/helpers@^0.4.14": + version "0.4.14" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.14.tgz#1352ac6d95e3617ccb7c1498ff019654f1e12a74" + integrity sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw== + dependencies: + tslib "^2.4.0" + "@szmarczak/http-timer@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" @@ -6861,6 +7911,11 @@ clsx@^1.0.4: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA== +clsx@^1.1.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" + integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== + code-error-fragment@0.0.230: version "0.0.230" resolved "https://registry.yarnpkg.com/code-error-fragment/-/code-error-fragment-0.0.230.tgz#d736d75c832445342eca1d1fedbf17d9618b14d7" @@ -11139,6 +12194,16 @@ intl-messageformat@10.3.1: "@formatjs/icu-messageformat-parser" "2.3.0" tslib "^2.4.0" +intl-messageformat@^10.1.0: + version "10.3.4" + resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-10.3.4.tgz#20f064c28b46fa6d352a4c4ba5e9bfc597af3eba" + integrity sha512-/FxUIrlbPtuykSNX85CB5sp2FjLVeTmdD7TfRkVFPft2n4FgcSlAcilFytYiFAEmPHc+0PvpLCIPXeaGFzIvOg== + dependencies: + "@formatjs/ecma402-abstract" "1.14.3" + "@formatjs/fast-memoize" "2.0.1" + "@formatjs/icu-messageformat-parser" "2.3.1" + tslib "^2.4.0" + intl-messageformat@^9.3.19: version "9.13.0" resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.13.0.tgz#97360b73bd82212e4f6005c712a4a16053165468" @@ -15438,6 +16503,64 @@ rc@^1.2.7, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-aria-components@1.0.0-alpha.3: + version "1.0.0-alpha.3" + resolved "https://registry.yarnpkg.com/react-aria-components/-/react-aria-components-1.0.0-alpha.3.tgz#c891ecb04c22ae60dcd719833c41364e5b62c09b" + integrity sha512-rhakTyOPsTwk/ylCCcK38/y3yN2SXPWN2wPknNwDQ9wE+P/PQWIrc3WxOlhTFGltLC1/KXAAIvJrkPgPBFTE1g== + dependencies: + "@internationalized/date" "^3.2.0" + "@react-aria/focus" "^3.12.0" + "@react-aria/utils" "^3.16.0" + "@react-stately/table" "^3.9.0" + "@react-types/grid" "^3.1.7" + "@react-types/shared" "^3.18.0" + "@react-types/table" "^3.6.0" + "@swc/helpers" "^0.4.14" + react-aria "^3.24.0" + react-stately "^3.22.0" + use-sync-external-store "^1.2.0" + +react-aria@3.24.0, react-aria@^3.24.0: + version "3.24.0" + resolved "https://registry.yarnpkg.com/react-aria/-/react-aria-3.24.0.tgz#3d7f89c6c4809b20007137dca4242ac8f24d2097" + integrity sha512-uqqUOTlRVbOTsbCMr2+SVgRg4345LYBnpBXpLZnYwhlDwDK+w7qXf+AO0cUty6fD3jYw0FmCp0PhyF1bfk1MGg== + dependencies: + "@react-aria/breadcrumbs" "^3.5.1" + "@react-aria/button" "^3.7.1" + "@react-aria/calendar" "^3.2.0" + "@react-aria/checkbox" "^3.9.0" + "@react-aria/combobox" "^3.6.0" + "@react-aria/datepicker" "^3.4.0" + "@react-aria/dialog" "^3.5.1" + "@react-aria/dnd" "^3.2.0" + "@react-aria/focus" "^3.12.0" + "@react-aria/gridlist" "^3.3.0" + "@react-aria/i18n" "^3.7.1" + "@react-aria/interactions" "^3.15.0" + "@react-aria/label" "^3.5.1" + "@react-aria/link" "^3.5.0" + "@react-aria/listbox" "^3.9.0" + "@react-aria/menu" "^3.9.0" + "@react-aria/meter" "^3.4.1" + "@react-aria/numberfield" "^3.5.0" + "@react-aria/overlays" "^3.14.0" + "@react-aria/progress" "^3.4.1" + "@react-aria/radio" "^3.6.0" + "@react-aria/searchfield" "^3.5.1" + "@react-aria/select" "^3.10.0" + "@react-aria/selection" "^3.14.0" + "@react-aria/separator" "^3.3.1" + "@react-aria/slider" "^3.4.0" + "@react-aria/ssr" "^3.6.0" + "@react-aria/switch" "^3.5.0" + "@react-aria/table" "^3.9.0" + "@react-aria/tabs" "^3.5.0" + "@react-aria/textfield" "^3.9.1" + "@react-aria/tooltip" "^3.5.0" + "@react-aria/utils" "^3.16.0" + "@react-aria/visually-hidden" "^3.8.0" + "@react-types/shared" "^3.18.0" + react-blurhash@0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/react-blurhash/-/react-blurhash-0.1.2.tgz#16bdce59be4f48dc7816a26e8f0435f73d3a2bb2" @@ -15653,6 +16776,34 @@ react-sizeme@^3.0.1: shallowequal "^1.1.0" throttle-debounce "^3.0.1" +react-stately@^3.22.0: + version "3.22.0" + resolved "https://registry.yarnpkg.com/react-stately/-/react-stately-3.22.0.tgz#c72c82401b7db5815f9906a742a0883839a65363" + integrity sha512-w5itlPtjfUpxy+195LxRbaCNaGN1NVfPHelhYXuoPoKNgUvmy54uKXvP1Ek1ETZ9e55BaXuMs83yXv94wIMdpQ== + dependencies: + "@react-stately/calendar" "^3.2.0" + "@react-stately/checkbox" "^3.4.1" + "@react-stately/collections" "^3.7.0" + "@react-stately/combobox" "^3.5.0" + "@react-stately/data" "^3.9.1" + "@react-stately/datepicker" "^3.4.0" + "@react-stately/dnd" "^3.2.0" + "@react-stately/list" "^3.8.0" + "@react-stately/menu" "^3.5.1" + "@react-stately/numberfield" "^3.4.1" + "@react-stately/overlays" "^3.5.1" + "@react-stately/radio" "^3.8.0" + "@react-stately/searchfield" "^3.4.1" + "@react-stately/select" "^3.5.0" + "@react-stately/selection" "^3.13.0" + "@react-stately/slider" "^3.3.1" + "@react-stately/table" "^3.9.0" + "@react-stately/tabs" "^3.4.0" + "@react-stately/toggle" "^3.5.1" + "@react-stately/tooltip" "^3.4.0" + "@react-stately/tree" "^3.6.0" + "@react-types/shared" "^3.18.0" + react-syntax-highlighter@^15.4.5: version "15.5.0" resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-15.5.0.tgz#4b3eccc2325fa2ec8eff1e2d6c18fa4a9e07ab20" @@ -18550,6 +19701,11 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" +use-sync-external-store@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + use@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/use/-/use-3.1.0.tgz#14716bf03fdfefd03040aef58d8b4b85f3a7c544" @@ -19334,10 +20490,10 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@3.5.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.5.1.tgz#e93ce58e182bb76f7d29ccd24feee72611f9a129" - integrity sha512-Gg9GTai0iDHowuYM9VNhdFMmesgt44ufzqaE5CPHshpuK5fCzbibdqCnrWuYH6ZmOn/N+BlGmwZtVSijhKmhKw== +zod@3.21.4: + version "3.21.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db" + integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw== zod@^3.20.2: version "3.20.2"