Calls Tab & Group Call Disposition
|
@ -457,6 +457,210 @@ Signal Desktop makes use of the following open source projects.
|
||||||
|
|
||||||
License: MIT
|
License: MIT
|
||||||
|
|
||||||
|
## @react-aria/utils
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2019 Adobe
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
## @react-spring/web
|
## @react-spring/web
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
@ -2494,6 +2698,414 @@ Signal Desktop makes use of the following open source projects.
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
|
## react-aria
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2019 Adobe
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
## react-aria-components
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2019 Adobe
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
## react-blurhash
|
## react-blurhash
|
||||||
|
|
||||||
License: MIT
|
License: MIT
|
||||||
|
|
|
@ -299,6 +299,34 @@
|
||||||
"messageformat": "Chats",
|
"messageformat": "Chats",
|
||||||
"description": "Shown as a header for non-pinned conversations in the left pane"
|
"description": "Shown as a header for non-pinned conversations in the left pane"
|
||||||
},
|
},
|
||||||
|
"icu:NavTabsToggle__showTabs": {
|
||||||
|
"messageformat": "Show Tabs",
|
||||||
|
"description": "Show in the left pane when the nav tabs are hidden, shows the nav tabs"
|
||||||
|
},
|
||||||
|
"icu:NavTabsToggle__hideTabs": {
|
||||||
|
"messageformat": "Hide Tabs",
|
||||||
|
"description": "Show in the nav tabs when the nav tabs are visible, hides the nav tabs"
|
||||||
|
},
|
||||||
|
"icu:NavTabs__ItemIconLabel--UnreadCount": {
|
||||||
|
"messageformat": "{count, number} unread",
|
||||||
|
"description": "Nav Tabs > Unread badge > Accessibility Text"
|
||||||
|
},
|
||||||
|
"icu:NavTabs__ItemIconLabel--MarkedUnread": {
|
||||||
|
"messageformat": "Marked unread",
|
||||||
|
"description": "Nav Tabs > Unread badge > when conversation is marked unread > Accessibility Text"
|
||||||
|
},
|
||||||
|
"icu:NavTabs__ItemLabel--Settings": {
|
||||||
|
"messageformat": "Settings",
|
||||||
|
"description": "Nav Tabs > Settings Button > Accessibility Text"
|
||||||
|
},
|
||||||
|
"icu:NavTabs__ItemLabel--Profile": {
|
||||||
|
"messageformat": "Profile",
|
||||||
|
"description": "Nav Tabs > Profile Button > Accessibility Text"
|
||||||
|
},
|
||||||
|
"icu:NavSidebar__BackButtonLabel": {
|
||||||
|
"messageformat": "Back",
|
||||||
|
"description": "Shown in the sidebar header when on a nested panel within the current sidebar, takes the user back to the previous step or default sidebar state"
|
||||||
|
},
|
||||||
"icu:archiveHelperText": {
|
"icu:archiveHelperText": {
|
||||||
"messageformat": "These chats are archived and will only appear in the Inbox if new messages are received.",
|
"messageformat": "These chats are archived and will only appear in the Inbox if new messages are received.",
|
||||||
"description": "Shown at the top of the archived conversations list in the left pane"
|
"description": "Shown at the top of the archived conversations list in the left pane"
|
||||||
|
@ -2150,6 +2178,10 @@
|
||||||
"messageformat": "View Safety Number",
|
"messageformat": "View Safety Number",
|
||||||
"description": "In conversation details, label for button to view safety number, opens safety number modal"
|
"description": "In conversation details, label for button to view safety number, opens safety number modal"
|
||||||
},
|
},
|
||||||
|
"icu:ConversationDetails__HeaderButton--Message": {
|
||||||
|
"messageformat": "Message",
|
||||||
|
"description": "In conversation details, label for button to switch to the conversation view in order to draft a message in that converation"
|
||||||
|
},
|
||||||
"icu:SafetyNumberNotification__viewSafetyNumber": {
|
"icu:SafetyNumberNotification__viewSafetyNumber": {
|
||||||
"messageformat": "View Safety Number",
|
"messageformat": "View Safety Number",
|
||||||
"description": "In conversation, safety number change notification, label for button to view safety number, opens safety number modal"
|
"description": "In conversation, safety number change notification, label for button to view safety number, opens safety number modal"
|
||||||
|
@ -3383,6 +3415,14 @@
|
||||||
"messageformat": "Incoming video call...",
|
"messageformat": "Incoming video call...",
|
||||||
"description": "Shown in both the incoming call bar and notification for an incoming video call"
|
"description": "Shown in both the incoming call bar and notification for an incoming video call"
|
||||||
},
|
},
|
||||||
|
"icu:outgoingAudioCall": {
|
||||||
|
"messageformat": "Outgoing voice call",
|
||||||
|
"description": "Shown in the timeline for an outgoing voice call"
|
||||||
|
},
|
||||||
|
"icu:outgoingVideoCall": {
|
||||||
|
"messageformat": "Outgoing video call",
|
||||||
|
"description": "Shown in the timeline for an outgoing video call"
|
||||||
|
},
|
||||||
"icu:incomingGroupCall__ringing-you": {
|
"icu:incomingGroupCall__ringing-you": {
|
||||||
"messageformat": "{ringer} is calling you",
|
"messageformat": "{ringer} is calling you",
|
||||||
"description": "Shown in the incoming call bar when someone is ringing you for a group call"
|
"description": "Shown in the incoming call bar when someone is ringing you for a group call"
|
||||||
|
@ -6595,6 +6635,110 @@
|
||||||
"messageformat": "Send again",
|
"messageformat": "Send again",
|
||||||
"description": "Button text for the confirmation dialog shown to user when attempting to resend message edit"
|
"description": "Button text for the confirmation dialog shown to user when attempting to resend message edit"
|
||||||
},
|
},
|
||||||
|
"icu:StoriesTab__MoreActionsLabel": {
|
||||||
|
"messageformat": "More actions",
|
||||||
|
"description": "Stories Tab > More Actions Button (opens context menu) > Accessibility Label"
|
||||||
|
},
|
||||||
|
"icu:CallsTab__HeaderTitle--CallsList": {
|
||||||
|
"messageformat": "Calls",
|
||||||
|
"description": "Calls Tab > Header > Title > On Calls List screen"
|
||||||
|
},
|
||||||
|
"icu:CallsTab__HeaderTitle--NewCall": {
|
||||||
|
"messageformat": "New Call",
|
||||||
|
"description": "Calls Tab > Header > Title > On New Call screen"
|
||||||
|
},
|
||||||
|
"icu:CallsTab__NewCallActionLabel": {
|
||||||
|
"messageformat": "New Call",
|
||||||
|
"description": "Calls Tab > New Call Action Button > Accessibility Label"
|
||||||
|
},
|
||||||
|
"icu:CallsTab__MoreActionsLabel": {
|
||||||
|
"messageformat": "More actions",
|
||||||
|
"description": "Calls Tab > More Actions Button (opens context menu) > Accessibility Label"
|
||||||
|
},
|
||||||
|
"icu:CallsTab__ClearCallHistoryLabel": {
|
||||||
|
"messageformat": "Clear call history",
|
||||||
|
"description": "Calls Tab > More Actions Context Menu > Clear Call History Button Label"
|
||||||
|
},
|
||||||
|
"icu:CallsTab__ConfirmClearCallHistory__Title": {
|
||||||
|
"messageformat": "Clear call history?",
|
||||||
|
"description": "Calls Tab > Confirm Clear Call History Dialog > Title"
|
||||||
|
},
|
||||||
|
"icu:CallsTab__ConfirmClearCallHistory__Body": {
|
||||||
|
"messageformat": "This will permanently delete all call history",
|
||||||
|
"description": "Calls Tab > Confirm Clear Call History Dialog > Body Text"
|
||||||
|
},
|
||||||
|
"icu:CallsTab__ConfirmClearCallHistory__ConfirmButton": {
|
||||||
|
"messageformat": "Clear",
|
||||||
|
"description": "Calls Tab > Confirm Clear Call History Dialog > Confirm Button"
|
||||||
|
},
|
||||||
|
"icu:CallsTab__ToastCallHistoryCleared": {
|
||||||
|
"messageformat": "Call history cleared",
|
||||||
|
"description": "Calls Tab > Clear Call History > Toast"
|
||||||
|
},
|
||||||
|
"icu:CallsTab__EmptyStateText": {
|
||||||
|
"messageformat": "Click to view or start a call",
|
||||||
|
"description": "Calls Tab > When no call is selected > Empty state > Call to action text"
|
||||||
|
},
|
||||||
|
"icu:CallsList__SearchInputPlaceholder": {
|
||||||
|
"messageformat": "Search",
|
||||||
|
"description": "Calls Tab > Calls List > Search Input > Placeholder"
|
||||||
|
},
|
||||||
|
"icu:CallsList__ToggleFilterByMissedLabel": {
|
||||||
|
"messageformat": "Toggle filter by missed",
|
||||||
|
"description": "Calls Tab > Calls List > Toggle search filter by missed > Accessibility label"
|
||||||
|
},
|
||||||
|
"icu:CallsList__ToggleFilterByMissed__RoleDescription": {
|
||||||
|
"messageformat": "Toggle",
|
||||||
|
"description": "Calls Tab > Calls List > Toggle search filter by missed > Accessibility role description ('A toggle button')"
|
||||||
|
},
|
||||||
|
"icu:CallsList__EmptyState--noQuery": {
|
||||||
|
"messageformat": "No recent calls. Get started by calling a friend.",
|
||||||
|
"description": "Calls Tab > Calls List > When no results found > With no search query"
|
||||||
|
},
|
||||||
|
"icu:CallsList__EmptyState--hasQuery": {
|
||||||
|
"messageformat": "No results for “{query}”",
|
||||||
|
"description": "Calls Tab > Calls List > When no results found > With a search query"
|
||||||
|
},
|
||||||
|
"icu:CallsList__ItemCallInfo--Incoming": {
|
||||||
|
"messageformat": "Incoming",
|
||||||
|
"description": "Calls Tab > Calls List > Call Item > Call Status > When call was accepted and was incoming"
|
||||||
|
},
|
||||||
|
"icu:CallsList__ItemCallInfo--Outgoing": {
|
||||||
|
"messageformat": "Outgoing",
|
||||||
|
"description": "Calls Tab > Calls List > Call Item > Call Status > When call was accepted and was outgoing"
|
||||||
|
},
|
||||||
|
"icu:CallsList__ItemCallInfo--Missed": {
|
||||||
|
"messageformat": "Missed",
|
||||||
|
"description": "Calls Tab > Calls List > Call Item > Call Status > When call was missed"
|
||||||
|
},
|
||||||
|
"icu:CallsList__ItemCallInfo--GroupCall": {
|
||||||
|
"messageformat": "Group call",
|
||||||
|
"description": "Calls Tab > Calls List > Call Item > Call Status > When group call is in its default state"
|
||||||
|
},
|
||||||
|
"icu:CallsNewCall__EmptyState--noQuery": {
|
||||||
|
"messageformat": "No recent conversations.",
|
||||||
|
"description": "Calls Tab > New Call > Conversations List > When no results found > With no search query"
|
||||||
|
},
|
||||||
|
"icu:CallsNewCall__EmptyState--hasQuery": {
|
||||||
|
"messageformat": "No results for “{query}”",
|
||||||
|
"description": "Calls Tab > New Call > Conversations List > When no results found > With a search query"
|
||||||
|
},
|
||||||
|
"icu:CallHistory__Description--Default": {
|
||||||
|
"messageformat": "{direction, select, Outgoing {Outgoing} other {Incoming}} {type, select, Audio {voice} Video {video} Group {group} other {}} call",
|
||||||
|
"description": "Call History > Short description of call > When call was not missed or declined (generally accepted)"
|
||||||
|
},
|
||||||
|
"icu:CallHistory__Description--Missed": {
|
||||||
|
"messageformat": "Missed {type, select, Audio {voice} Video {video} Group {group} other {}} call",
|
||||||
|
"description": "Call History > Short description of call > When incoming call was missed"
|
||||||
|
},
|
||||||
|
"icu:CallHistory__Description--Unanswered": {
|
||||||
|
"messageformat": "Unanswered {type, select, Audio {voice} Video {video} Group {group} other {}} call",
|
||||||
|
"description": "Call History > Short description of call > When outgoing call was unanswered"
|
||||||
|
},
|
||||||
|
"icu:CallHistory__Description--Declined": {
|
||||||
|
"messageformat": "Declined {type, select, Audio {voice} Video {video} Group {group} other {}} call",
|
||||||
|
"description": "Call History > Short description of call > When call was declined"
|
||||||
|
},
|
||||||
"icu:WhatsNew__modal-title": {
|
"icu:WhatsNew__modal-title": {
|
||||||
"messageformat": "What's New",
|
"messageformat": "What's New",
|
||||||
"description": "Title for the whats new modal"
|
"description": "Title for the whats new modal"
|
||||||
|
|
|
@ -194,7 +194,11 @@ const defaultWebPrefs = {
|
||||||
getEnvironment() !== Environment.Production ||
|
getEnvironment() !== Environment.Production ||
|
||||||
!isProduction(app.getVersion()),
|
!isProduction(app.getVersion()),
|
||||||
spellcheck: false,
|
spellcheck: false,
|
||||||
enableBlinkFeatures: 'CSSPseudoDir,CSSLogical',
|
// https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/platform/runtime_enabled_features.json5
|
||||||
|
enableBlinkFeatures: [
|
||||||
|
'CSSPseudoDir', // status=experimental, needed for RTL (ex: :dir(rtl))
|
||||||
|
'CSSLogical', // status=experimental, needed for RTL (ex: margin-inline-start)
|
||||||
|
].join(','),
|
||||||
};
|
};
|
||||||
|
|
||||||
const DISABLE_GPU =
|
const DISABLE_GPU =
|
||||||
|
|
1
images/icons/v3/chat/chat-fill.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 1.875a8.125 8.125 0 0 0-7.164 11.961.023.023 0 0 1 .003.01L1.574 17.36a.833.833 0 0 0 1.067 1.066l3.512-1.264h.002a8.09 8.09 0 0 0 3.845.964 8.125 8.125 0 1 0 0-16.25Z" fill="#000"/></svg>
|
After Width: | Height: | Size: 276 B |
1
images/icons/v3/filter/filter.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" d="M2.125 7c0-.483.392-.875.875-.875h18a.875.875 0 0 1 0 1.75H3A.875.875 0 0 1 2.125 7ZM5.375 12c0-.483.392-.875.875-.875h11.5a.875.875 0 0 1 0 1.75H6.25A.875.875 0 0 1 5.375 12ZM9.5 16.125a.875.875 0 0 0 0 1.75h5a.875.875 0 0 0 0-1.75h-5Z"/></svg>
|
After Width: | Height: | Size: 341 B |
1
images/icons/v3/menu/menu.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M2.604 5a.73.73 0 0 1 .73-.73h13.333a.73.73 0 1 1 0 1.46H3.333A.73.73 0 0 1 2.604 5Zm0 5a.73.73 0 0 1 .73-.73h13.333a.73.73 0 1 1 0 1.46H3.333a.73.73 0 0 1-.729-.73Zm.729 4.27a.73.73 0 1 0 0 1.46h13.334a.73.73 0 1 0 0-1.46H3.333Z" fill="#000"/></svg>
|
After Width: | Height: | Size: 334 B |
1
images/icons/v3/phone/phone-plus-light.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M15.625 2.083a.625.625 0 1 0-1.25 0v2.292h-2.292a.625.625 0 1 0 0 1.25h2.292v2.292a.625.625 0 1 0 1.25 0V5.625h2.292a.625.625 0 0 0 0-1.25h-2.292V2.083ZM6.776 3.188a2.27 2.27 0 0 0-3.413-.231l-.353.353C1.755 4.565 1.073 6.43 1.686 8.22a16.191 16.191 0 0 0 3.882 6.212 16.19 16.19 0 0 0 6.212 3.882c1.79.613 3.655-.069 4.91-1.324l.353-.353a2.27 2.27 0 0 0-.231-3.413l-2.314-1.758a2.27 2.27 0 0 0-2.978.202l-.889.888a1.949 1.949 0 0 1-.38-.17c-.41-.228-.94-.632-1.472-1.165-.533-.533-.937-1.061-1.165-1.471a1.957 1.957 0 0 1-.17-.381l.888-.889a2.27 2.27 0 0 0 .202-2.978L6.776 3.188Zm-2.529.652a1.02 1.02 0 0 1 1.534.105l1.758 2.313c.308.406.27.978-.091 1.339L6.44 8.604c-.305.304-.284.709-.238.947.05.264.171.54.318.806.299.537.783 1.157 1.374 1.748.591.591 1.21 1.075 1.748 1.374.265.147.542.267.806.318.238.046.643.067.947-.238l1.007-1.007c.361-.36.933-.4 1.339-.09l2.313 1.757a1.02 1.02 0 0 1 .105 1.534l-.354.353c-1.004 1.004-2.388 1.448-3.62 1.026a14.941 14.941 0 0 1-5.734-3.584 14.94 14.94 0 0 1-3.584-5.733c-.422-1.233.022-2.617 1.026-3.621l.353-.354Z" fill="#000"/></svg>
|
After Width: | Height: | Size: 1.1 KiB |
1
images/icons/v3/photo/phone-fill.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.935 2.707a2.108 2.108 0 0 0-3.168-.215l-.363.363C2.159 4.1 1.5 5.931 2.097 7.67a16.415 16.415 0 0 0 3.936 6.298 16.416 16.416 0 0 0 6.298 3.936c1.739.596 3.569-.062 4.814-1.307l.363-.363a2.108 2.108 0 0 0-.215-3.168l-2.378-1.807a2.108 2.108 0 0 0-2.765.188l-1.297 1.296c-.201-.012-.404-.127-.576-.222-.444-.247-1.004-.676-1.562-1.235-.559-.558-.988-1.118-1.235-1.562-.095-.172-.21-.375-.222-.576L8.554 7.85a2.108 2.108 0 0 0 .188-2.765L6.935 2.707Z" fill="#000"/></svg>
|
After Width: | Height: | Size: 556 B |
1
images/icons/v3/photo/phone.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.546 2.271a2.42 2.42 0 0 1 3.638.247L8.99 4.896a2.42 2.42 0 0 1-.216 3.175l-.875.876c.027.078.075.187.152.325.229.413.639.95 1.182 1.493.544.544 1.081.954 1.494 1.183.138.077.247.125.325.152l.875-.875a2.42 2.42 0 0 1 3.176-.216l2.378 1.807a2.42 2.42 0 0 1 .247 3.638l-.363.363c-1.308 1.308-3.259 2.025-5.136 1.382a16.727 16.727 0 0 1-6.418-4.011 16.726 16.726 0 0 1-4.01-6.418c-.644-1.877.073-3.828 1.38-5.136l.364-.363Zm2.476 1.13a.962.962 0 0 0-1.445-.098l-.363.363C3.199 4.68 2.76 6.069 3.18 7.298a15.269 15.269 0 0 0 3.662 5.859 15.269 15.269 0 0 0 5.86 3.662c1.228.421 2.617-.018 3.631-1.033l.363-.363a.962.962 0 0 0-.098-1.446l-2.377-1.806a.962.962 0 0 0-1.262.085l-1.035 1.035c-.345.344-.798.318-1.052.269a2.99 2.99 0 0 1-.854-.337c-.56-.312-1.204-.815-1.816-1.426-.611-.612-1.114-1.255-1.426-1.816a2.992 2.992 0 0 1-.337-.854c-.049-.254-.075-.707.269-1.052L7.744 7.04a.962.962 0 0 0 .086-1.262L6.022 3.401Z" fill="#000"/></svg>
|
After Width: | Height: | Size: 1,021 B |
1
images/icons/v3/stories/stories-fill.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10.232 1.25h2.87c.684 0 1.223 0 1.657.035.443.037.813.112 1.148.283.55.28.995.726 1.275 1.275.171.335.246.705.283 1.148.035.434.035.973.035 1.657v8.704c0 .684 0 1.223-.035 1.657-.037.443-.112.813-.283 1.148-.28.55-.726.995-1.275 1.275-.335.171-.705.246-1.148.283-.434.035-.973.035-1.657.035h-2.87c-.685 0-1.224 0-1.658-.035-.443-.037-.812-.112-1.148-.283a2.916 2.916 0 0 1-1.275-1.274c-.171-.336-.246-.706-.282-1.149-.036-.434-.036-.973-.036-1.657V5.648c0-.684 0-1.223.036-1.657.036-.443.111-.813.282-1.148.28-.55.726-.995 1.275-1.275.336-.171.705-.246 1.148-.283.434-.035.973-.035 1.658-.035ZM4.623 3.889c.022-.265.057-.538.123-.814l-.523.19c-.534.195-.959.35-1.29.502-.341.156-.618.325-.841.564a2.5 2.5 0 0 0-.653 1.4c-.04.325.009.646.108 1.007.097.353.251.777.445 1.31L4 13.566c.194.533.349.958.501 1.29.028.06.055.117.084.173-.002-.203-.002-.415-.002-.635V5.607c0-.649 0-1.233.04-1.718Z" fill="#000"/></svg>
|
After Width: | Height: | Size: 996 B |
|
@ -89,6 +89,7 @@
|
||||||
"@nodert-win10-rs4/windows.data.xml.dom": "0.4.4",
|
"@nodert-win10-rs4/windows.data.xml.dom": "0.4.4",
|
||||||
"@nodert-win10-rs4/windows.ui.notifications": "0.4.4",
|
"@nodert-win10-rs4/windows.ui.notifications": "0.4.4",
|
||||||
"@popperjs/core": "2.11.6",
|
"@popperjs/core": "2.11.6",
|
||||||
|
"@react-aria/utils": "3.16.0",
|
||||||
"@react-spring/web": "9.5.5",
|
"@react-spring/web": "9.5.5",
|
||||||
"@signalapp/better-sqlite3": "8.4.3",
|
"@signalapp/better-sqlite3": "8.4.3",
|
||||||
"@signalapp/libsignal-client": "0.29.1",
|
"@signalapp/libsignal-client": "0.29.1",
|
||||||
|
@ -151,6 +152,8 @@
|
||||||
"quill": "1.3.7",
|
"quill": "1.3.7",
|
||||||
"quill-delta": "4.0.1",
|
"quill-delta": "4.0.1",
|
||||||
"react": "17.0.2",
|
"react": "17.0.2",
|
||||||
|
"react-aria": "3.24.0",
|
||||||
|
"react-aria-components": "1.0.0-alpha.3",
|
||||||
"react-blurhash": "0.1.2",
|
"react-blurhash": "0.1.2",
|
||||||
"react-contextmenu": "2.11.0",
|
"react-contextmenu": "2.11.0",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "17.0.2",
|
||||||
|
@ -178,7 +181,7 @@
|
||||||
"uuid-browser": "3.1.0",
|
"uuid-browser": "3.1.0",
|
||||||
"websocket": "1.0.34",
|
"websocket": "1.0.34",
|
||||||
"@signalapp/windows-dummy-keystroke": "1.0.0",
|
"@signalapp/windows-dummy-keystroke": "1.0.0",
|
||||||
"zod": "3.5.1"
|
"zod": "3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.14.3",
|
"@babel/core": "7.14.3",
|
||||||
|
|
13
patches/react-aria-components+1.0.0-alpha.3.patch
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
diff --git a/node_modules/react-aria-components/dist/types.d.ts b/node_modules/react-aria-components/dist/types.d.ts
|
||||||
|
index eb908b4..6cd530f 100644
|
||||||
|
--- a/node_modules/react-aria-components/dist/types.d.ts
|
||||||
|
+++ b/node_modules/react-aria-components/dist/types.d.ts
|
||||||
|
@@ -44,7 +44,7 @@ interface SlotProps {
|
||||||
|
/** A slot name for the component. Slots allow the component to receive props from a parent component. */
|
||||||
|
slot?: string;
|
||||||
|
}
|
||||||
|
-export function useContextProps<T, U, E extends Element>(props: T & SlotProps, ref: React.ForwardedRef<E>, context: React.Context<ContextValue<U, E>>): [T, React.RefObject<E>];
|
||||||
|
+export function useContextProps<T, U extends SlotProps, E extends Element>(props: T & SlotProps, ref: React.ForwardedRef<E>, context: React.Context<ContextValue<U, E>>): [T, React.RefObject<E>];
|
||||||
|
interface CollectionProps<T> extends Omit<CollectionBase<T>, 'children'> {
|
||||||
|
/** The contents of the collection. */
|
||||||
|
children?: ReactNode | ((item: T) => ReactElement);
|
|
@ -592,9 +592,11 @@ message SyncMessage {
|
||||||
|
|
||||||
message CallEvent {
|
message CallEvent {
|
||||||
enum Type {
|
enum Type {
|
||||||
UNKNOWN = 0;
|
UNKNOWN = 0;
|
||||||
AUDIO_CALL = 1;
|
AUDIO_CALL = 1;
|
||||||
VIDEO_CALL = 2;
|
VIDEO_CALL = 2;
|
||||||
|
GROUP_CALL = 3;
|
||||||
|
AD_HOC_CALL = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Direction {
|
enum Direction {
|
||||||
|
@ -607,9 +609,10 @@ message SyncMessage {
|
||||||
UNKNOWN = 0;
|
UNKNOWN = 0;
|
||||||
ACCEPTED = 1;
|
ACCEPTED = 1;
|
||||||
NOT_ACCEPTED = 2;
|
NOT_ACCEPTED = 2;
|
||||||
|
DELETE = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
optional bytes peerUuid = 1;
|
optional bytes peerId = 1;
|
||||||
optional uint64 callId = 2;
|
optional uint64 callId = 2;
|
||||||
optional uint64 timestamp = 3;
|
optional uint64 timestamp = 3;
|
||||||
optional Type type = 4;
|
optional Type type = 4;
|
||||||
|
@ -617,6 +620,15 @@ message SyncMessage {
|
||||||
optional Event event = 6;
|
optional Event event = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message CallLogEvent {
|
||||||
|
enum Type {
|
||||||
|
CLEAR = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
optional Type type = 1;
|
||||||
|
optional uint64 timestamp = 2;
|
||||||
|
}
|
||||||
|
|
||||||
optional Sent sent = 1;
|
optional Sent sent = 1;
|
||||||
optional Contacts contacts = 2;
|
optional Contacts contacts = 2;
|
||||||
reserved /* groups */ 3;
|
reserved /* groups */ 3;
|
||||||
|
@ -636,6 +648,8 @@ message SyncMessage {
|
||||||
reserved 17; // pniIdentity
|
reserved 17; // pniIdentity
|
||||||
optional PniChangeNumber pniChangeNumber = 18;
|
optional PniChangeNumber pniChangeNumber = 18;
|
||||||
optional CallEvent callEvent = 19;
|
optional CallEvent callEvent = 19;
|
||||||
|
reserved 20; // callLinkUpdate
|
||||||
|
optional CallLogEvent callLogEvent = 21;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AttachmentPointer {
|
message AttachmentPointer {
|
||||||
|
|
|
@ -26,6 +26,7 @@ $color-black: #000000;
|
||||||
$color-white-alpha-06: rgba($color-white, 0.06);
|
$color-white-alpha-06: rgba($color-white, 0.06);
|
||||||
$color-white-alpha-08: rgba($color-white, 0.08);
|
$color-white-alpha-08: rgba($color-white, 0.08);
|
||||||
$color-white-alpha-12: rgba($color-white, 0.12);
|
$color-white-alpha-12: rgba($color-white, 0.12);
|
||||||
|
$color-white-alpha-16: rgba($color-white, 0.16);
|
||||||
$color-white-alpha-20: rgba($color-white, 0.2);
|
$color-white-alpha-20: rgba($color-white, 0.2);
|
||||||
$color-white-alpha-40: rgba($color-white, 0.4);
|
$color-white-alpha-40: rgba($color-white, 0.4);
|
||||||
$color-white-alpha-60: rgba($color-white, 0.6);
|
$color-white-alpha-60: rgba($color-white, 0.6);
|
||||||
|
|
94
stylesheets/_conversation.scss
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
// Copyright 2015 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
@import './mixins';
|
||||||
|
|
||||||
|
@keyframes panel--in--ltr {
|
||||||
|
from {
|
||||||
|
// stylelint-disable-next-line declaration-property-value-disallowed-list
|
||||||
|
transform: translateX(500px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
// stylelint-disable-next-line declaration-property-value-disallowed-list
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes panel--in--rtl {
|
||||||
|
from {
|
||||||
|
// stylelint-disable-next-line declaration-property-value-disallowed-list
|
||||||
|
transform: translateX(-500px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
// stylelint-disable-next-line declaration-property-value-disallowed-list
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation {
|
||||||
|
@include light-theme {
|
||||||
|
background-color: $color-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include dark-theme {
|
||||||
|
background-color: $color-gray-95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
&:not(.main) {
|
||||||
|
&:dir(ltr) {
|
||||||
|
animation: panel--in--ltr 350ms cubic-bezier(0.17, 0.17, 0, 1);
|
||||||
|
}
|
||||||
|
&:dir(rtl) {
|
||||||
|
animation: panel--in--rtl 350ms cubic-bezier(0.17, 0.17, 0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--static {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--remove {
|
||||||
|
&:dir(ltr) {
|
||||||
|
// stylelint-disable-next-line declaration-property-value-disallowed-list
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
&:dir(rtl) {
|
||||||
|
// stylelint-disable-next-line declaration-property-value-disallowed-list
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
transition: transform 350ms cubic-bezier(0.17, 0.17, 0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the main panel is hidden when other panels are in the dom
|
||||||
|
.panel + .main.panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-detail-wrapper {
|
||||||
|
height: calc(100% - 48px);
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-bubble-wrapper {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-detail-pane {
|
||||||
|
overflow-y: scroll;
|
||||||
|
padding-top: 40px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permissions-popup,
|
||||||
|
.debug-log-window {
|
||||||
|
.modal {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -43,6 +43,14 @@
|
||||||
letter-spacing: -0.34px;
|
letter-spacing: -0.34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin font-title-medium {
|
||||||
|
@include font-family;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 25px;
|
||||||
|
letter-spacing: -0.25px;
|
||||||
|
}
|
||||||
|
|
||||||
@mixin font-body-1 {
|
@mixin font-body-1 {
|
||||||
@include font-family;
|
@include font-family;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
@ -858,3 +866,15 @@ $rtl-icon-map: (
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin NavTabs__Scroller {
|
||||||
|
@include scrollbar;
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
@include light-theme {
|
||||||
|
border-color: $color-gray-04;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
border-color: $color-gray-80;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -2445,166 +2445,6 @@ button.ConversationDetails__action-button {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module: Main Header
|
|
||||||
|
|
||||||
.module-main-header {
|
|
||||||
-webkit-app-region: var(--draggable-app-region);
|
|
||||||
|
|
||||||
height: calc(#{$header-height} + var(--title-bar-drag-area-height));
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
padding-inline: 16px;
|
|
||||||
padding-top: var(--title-bar-drag-area-height);
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.module-left-pane--width-narrow & {
|
|
||||||
flex-direction: column;
|
|
||||||
height: auto;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
margin-inline-end: 12px;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-inline-end: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__avatar {
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
|
|
||||||
&--container {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.module-left-pane--width-narrow & {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
margin-inline-end: 0;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--badged {
|
|
||||||
background: $color-ultramarine;
|
|
||||||
border-radius: 100%;
|
|
||||||
border: 1px solid $color-white;
|
|
||||||
height: 8px;
|
|
||||||
width: 8px;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
inset-inline-end: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon-container {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
.module-left-pane--width-narrow & {
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__compose-icon {
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
align-items: center;
|
|
||||||
background: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
display: inline-flex;
|
|
||||||
height: 28px;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
padding: 0px;
|
|
||||||
|
|
||||||
@include keyboard-mode {
|
|
||||||
&:focus {
|
|
||||||
border-color: $color-ultramarine;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
$icon: '../images/icons/v3/compose/compose.svg';
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
content: '';
|
|
||||||
|
|
||||||
@include light-theme {
|
|
||||||
@include color-svg($icon, $color-gray-75);
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
@include color-svg($icon, $color-gray-15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__stories-badge {
|
|
||||||
@include rounded-corners;
|
|
||||||
align-items: center;
|
|
||||||
background-color: $color-accent-red;
|
|
||||||
color: $color-white;
|
|
||||||
display: flex;
|
|
||||||
font-size: 10px;
|
|
||||||
height: 16px;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
padding-block: 0;
|
|
||||||
padding-inline: 2px;
|
|
||||||
position: absolute;
|
|
||||||
inset-inline-end: -6px;
|
|
||||||
top: -4px;
|
|
||||||
user-select: none;
|
|
||||||
z-index: $z-index-base;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__stories-icon {
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
align-items: center;
|
|
||||||
background: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
display: inline-flex;
|
|
||||||
height: 28px;
|
|
||||||
justify-content: center;
|
|
||||||
margin-inline-end: 12px;
|
|
||||||
position: relative;
|
|
||||||
width: 28px;
|
|
||||||
padding: 0px;
|
|
||||||
|
|
||||||
.module-left-pane--width-narrow & {
|
|
||||||
margin-inline-end: 0;
|
|
||||||
margin-top: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include keyboard-mode {
|
|
||||||
&:focus {
|
|
||||||
border-color: $color-ultramarine;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
$icon: '../images/icons/v3/stories/stories.svg';
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
content: '';
|
|
||||||
|
|
||||||
@include light-theme {
|
|
||||||
@include color-svg($icon, $color-gray-75);
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
@include color-svg($icon, $color-gray-15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module: Image
|
// Module: Image
|
||||||
|
|
||||||
.module-image {
|
.module-image {
|
||||||
|
@ -4365,7 +4205,7 @@ button.module-image__border-overlay:focus {
|
||||||
.module-conversation-list {
|
.module-conversation-list {
|
||||||
$normal-row-height: 72px;
|
$normal-row-height: 72px;
|
||||||
|
|
||||||
@include scrollbar;
|
@include NavTabs__Scroller;
|
||||||
padding-inline: 10px;
|
padding-inline: 10px;
|
||||||
|
|
||||||
// list tiles in choose-group-members and compose extend to the edge
|
// list tiles in choose-group-members and compose extend to the edge
|
||||||
|
@ -5045,33 +4885,18 @@ button.module-image__border-overlay:focus {
|
||||||
// Module: Left Pane
|
// Module: Left Pane
|
||||||
|
|
||||||
.module-left-pane {
|
.module-left-pane {
|
||||||
border-inline-end-style: solid;
|
display: flex;
|
||||||
border-inline-end-width: 1px;
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
$background-color: $color-gray-02;
|
$background-color: $color-gray-02;
|
||||||
|
|
||||||
border-color: $color-gray-15;
|
|
||||||
background-color: $background-color;
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
border: 2px solid $color-gray-02;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@include dark-theme {
|
@include dark-theme {
|
||||||
$background-color: $color-gray-80;
|
$background-color: $color-gray-80;
|
||||||
|
|
||||||
border-color: $color-gray-65;
|
|
||||||
background-color: $background-color;
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
border: 2px solid $color-gray-80;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5122,13 +4947,11 @@ button.module-image__border-overlay:focus {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
&__contents {
|
&__contents {
|
||||||
height: calc(#{$header-height} + var(--title-bar-drag-area-height));
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-top: var(--title-bar-drag-area-height);
|
padding-block: 15px;
|
||||||
|
|
||||||
&__back-button {
|
&__back-button {
|
||||||
@include button-reset;
|
@include button-reset;
|
||||||
|
@ -5248,6 +5071,36 @@ button.module-image__border-overlay:focus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-left-pane__startComposingIcon {
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/compose/compose.svg',
|
||||||
|
$color-gray-75
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/compose/compose.svg',
|
||||||
|
$color-gray-15
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-left-pane__moreActionsIcon {
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg('../images/icons/v3/more/more.svg', $color-black);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg('../images/icons/v3/more/more.svg', $color-gray-15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.module-left-pane__archive-helper-text {
|
.module-left-pane__archive-helper-text {
|
||||||
@include font-body-2;
|
@include font-body-2;
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,7 @@ $color-black: #000000;
|
||||||
$color-white-alpha-06: rgba($color-white, 0.06);
|
$color-white-alpha-06: rgba($color-white, 0.06);
|
||||||
$color-white-alpha-08: rgba($color-white, 0.08);
|
$color-white-alpha-08: rgba($color-white, 0.08);
|
||||||
$color-white-alpha-12: rgba($color-white, 0.12);
|
$color-white-alpha-12: rgba($color-white, 0.12);
|
||||||
|
$color-white-alpha-16: rgba($color-white, 0.16);
|
||||||
$color-white-alpha-20: rgba($color-white, 0.2);
|
$color-white-alpha-20: rgba($color-white, 0.2);
|
||||||
$color-white-alpha-40: rgba($color-white, 0.4);
|
$color-white-alpha-40: rgba($color-white, 0.4);
|
||||||
$color-white-alpha-60: rgba($color-white, 0.6);
|
$color-white-alpha-60: rgba($color-white, 0.6);
|
||||||
|
@ -280,3 +281,9 @@ $z-index-modal-host: 102;
|
||||||
$z-index-above-popup: 103;
|
$z-index-above-popup: 103;
|
||||||
$z-index-calling-pip: 104;
|
$z-index-calling-pip: 104;
|
||||||
$z-index-above-context-menu: 126;
|
$z-index-above-context-menu: 126;
|
||||||
|
|
||||||
|
// global navTabs
|
||||||
|
$NavTabs__width: 80px;
|
||||||
|
// These values are 'block' specific to coordinate with the NavSidebar__Header
|
||||||
|
$NavTabs__Item__blockPadding: 2px;
|
||||||
|
$NavTabs__ItemButton__blockPadding: 10px;
|
||||||
|
|
|
@ -259,6 +259,10 @@
|
||||||
@include button-icon('../images/icons/v3/phone/phone-compact.svg');
|
@include button-icon('../images/icons/v3/phone/phone-compact.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--message::before {
|
||||||
|
@include button-icon('../images/icons/v3/chat/chat-compact.svg');
|
||||||
|
}
|
||||||
|
|
||||||
&--muted::before {
|
&--muted::before {
|
||||||
@include button-icon('../images/icons/v3/bell/bell-slash-compact.svg');
|
@include button-icon('../images/icons/v3/bell/bell-slash-compact.svg');
|
||||||
}
|
}
|
||||||
|
|
305
stylesheets/components/CallsTab.scss
Normal file
|
@ -0,0 +1,305 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
.CallsTab {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsTab__NewCallActionIcon {
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/phone/phone-plus-light.svg',
|
||||||
|
$color-black
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/phone/phone-plus-light.svg',
|
||||||
|
$color-gray-15
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsTab__MoreActionsIcon {
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg('../images/icons/v3/more/more.svg', $color-black);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg('../images/icons/v3/more/more.svg', $color-gray-15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsTab__EmptyState {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsTab__ConversationCallDetails {
|
||||||
|
display: block;
|
||||||
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding-block: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsTab__ClearCallHistoryIcon {
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/trash/trash-compact.svg',
|
||||||
|
$color-gray-90
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/trash/trash-compact.svg',
|
||||||
|
$color-white
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__Header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__ToggleFilterByMissed {
|
||||||
|
@include button-reset;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:not(.CallsList__ToggleFilterByMissed--pressed):hover {
|
||||||
|
@include light-theme {
|
||||||
|
background: $color-gray-20;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background: $color-gray-62;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px $color-white, 0 0 0 4px $color-ultramarine;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg('../images/icons/v3/filter/filter.svg', $color-black);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/filter/filter.svg',
|
||||||
|
$color-gray-15
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__ToggleFilterByMissed--pressed {
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: $color-accent-blue;
|
||||||
|
&::before {
|
||||||
|
@include color-svg('../images/icons/v3/filter/filter.svg', $color-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__ToggleFilterByMissedLabel {
|
||||||
|
@include sr-only;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__ListContainer {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__List {
|
||||||
|
@include NavTabs__Scroller;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__List--loading {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__EmptyState {
|
||||||
|
padding-block: 28px;
|
||||||
|
padding-inline: 16px;
|
||||||
|
text-align: center;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__ItemIcon {
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__ItemIcon--Phone {
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg('../images/icons/v3/phone/phone.svg', $color-black);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg('../images/icons/v3/phone/phone.svg', $color-gray-15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__ItemIcon--Video {
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg('../images/icons/v3/video/video.svg', $color-black);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg('../images/icons/v3/video/video.svg', $color-gray-15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__LoadingAvatar,
|
||||||
|
.CallsList__LoadingText {
|
||||||
|
animation: CallsList__LoadingPulse 1.5s ease-in-out infinite;
|
||||||
|
@include light-theme {
|
||||||
|
background-color: $color-gray-05;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background-color: $color-gray-75;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__LoadingAvatar {
|
||||||
|
display: block;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__LoadingText {
|
||||||
|
display: inline-block; // ensure uses line-height
|
||||||
|
height: 1em;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__LoadingText--title {
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__LoadingText--subtitle {
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__ItemTitle {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__ItemCallInfo--missed {
|
||||||
|
color: $color-accent-red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsList__Item--selected .CallsList__ItemTile {
|
||||||
|
@include light-theme {
|
||||||
|
background-color: $color-gray-15;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background-color: $color-gray-65;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes CallsList__LoadingPulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsNewCall__ListContainer {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsNewCall__List {
|
||||||
|
@include NavTabs__Scroller;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsNewCall__ListHeaderItem {
|
||||||
|
padding-block: 10px;
|
||||||
|
padding-inline: 24px;
|
||||||
|
@include font-body-1-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsNewCall__EmptyState {
|
||||||
|
padding-block: 28px;
|
||||||
|
padding-inline: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsNewCall__ItemActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsNewCall__ItemActionButton {
|
||||||
|
@include button-reset;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
&:not(:disabled, [aria-disabled='true']):hover {
|
||||||
|
@include light-theme {
|
||||||
|
background: $color-gray-20;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background: $color-gray-62;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px $color-ultramarine;
|
||||||
|
}
|
||||||
|
&:disabled,
|
||||||
|
&[aria-disabled='true'] {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsNewCall__ItemIcon {
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsNewCall__ItemIcon--Phone {
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg('../images/icons/v3/phone/phone.svg', $color-black);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg('../images/icons/v3/phone/phone.svg', $color-gray-15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsNewCall__ItemIcon--Video {
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg('../images/icons/v3/video/video.svg', $color-black);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg('../images/icons/v3/video/video.svg', $color-gray-15);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -144,7 +144,7 @@
|
||||||
@include rounded-corners;
|
@include rounded-corners;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: $color-ultramarine-dawn;
|
background: $color-ultramarine-dawn;
|
||||||
border: 2px solid $color-gray-80;
|
border: 2px solid;
|
||||||
bottom: -2px;
|
bottom: -2px;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
@ -154,6 +154,14 @@
|
||||||
width: 20px;
|
width: 20px;
|
||||||
z-index: $z-index-base;
|
z-index: $z-index-base;
|
||||||
|
|
||||||
|
@include light-theme {
|
||||||
|
border-color: $color-gray-04;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include dark-theme {
|
||||||
|
border-color: $color-gray-80;
|
||||||
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
@include color-svg(
|
@include color-svg(
|
||||||
|
@ -166,6 +174,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.StoryListItem__button:hover .MyStories__avatar__add-story {
|
.StoryListItem__button:hover,
|
||||||
border-color: $color-gray-65;
|
.StoryListItem__button--active {
|
||||||
|
.MyStories__avatar__add-story {
|
||||||
|
@include light-theme {
|
||||||
|
border-color: $color-gray-15;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
border-color: $color-gray-65;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
207
stylesheets/components/NavSidebar.scss
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
.NavSidebar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: var(--title-bar-drag-area-height);
|
||||||
|
user-select: none;
|
||||||
|
@include light-theme {
|
||||||
|
background-color: $color-gray-04;
|
||||||
|
border-inline-end: 1px solid $color-black-alpha-16;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background-color: $color-gray-80;
|
||||||
|
border-inline-end: 1px solid $color-white-alpha-16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__Header {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
|
||||||
|
.NavTabs__Toggle {
|
||||||
|
width: $NavTabs__width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar--narrow & {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__HeaderContent {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-block: calc(
|
||||||
|
$NavTabs__Item__blockPadding + $NavTabs__ItemButton__blockPadding
|
||||||
|
);
|
||||||
|
padding-inline: 24px;
|
||||||
|
|
||||||
|
.NavSidebar--narrow & {
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__HeaderContent--navTabsCollapsed:not(
|
||||||
|
.NavSidebar__HeaderContent--withBackButton
|
||||||
|
) {
|
||||||
|
padding-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__HeaderContent--withBackButton {
|
||||||
|
padding-inline-start: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__HeaderTitle {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
margin: 0;
|
||||||
|
@include font-title-medium;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
.NavSidebar--narrow & {
|
||||||
|
@include sr-only;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__HeaderTitle--withBackButton {
|
||||||
|
text-align: center;
|
||||||
|
@include font-body-1-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__BackButton {
|
||||||
|
@include button-reset();
|
||||||
|
margin-block: -4px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@include light-theme {
|
||||||
|
background: $color-gray-20;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background: $color-gray-62;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px $color-ultramarine;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/chevron/chevron-left.svg',
|
||||||
|
$color-black
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/chevron/chevron-left.svg',
|
||||||
|
$color-gray-15
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__BackButtonLabel {
|
||||||
|
@include sr-only;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__Content {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__DragHandle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
inset-inline-start: 100%;
|
||||||
|
width: 8px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: col-resize;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 0 0 2px $color-ultramarine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__DragHandle--dragging {
|
||||||
|
@include light-theme {
|
||||||
|
background-color: $color-black-alpha-12;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background-color: $color-white-alpha-12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__document--draggingHandle {
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__HeaderActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-block: -4px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.NavSidebar--narrow & {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__ActionButton {
|
||||||
|
@include button-reset();
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@include light-theme {
|
||||||
|
background: $color-gray-20;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background: $color-gray-62;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px $color-ultramarine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebar__ActionButtonLabel {
|
||||||
|
@include sr-only;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavSidebarSearchHeader {
|
||||||
|
display: flex;
|
||||||
|
margin-inline: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
183
stylesheets/components/NavTabs.scss
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
// This effectively wraps the entire app
|
||||||
|
.NavTabs__Container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
width: $NavTabs__width;
|
||||||
|
height: 100%;
|
||||||
|
padding-top: var(--title-bar-drag-area-height);
|
||||||
|
@include light-theme {
|
||||||
|
background-color: $color-gray-04;
|
||||||
|
border-inline-end: 1px solid $color-black-alpha-16;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background-color: $color-gray-80;
|
||||||
|
border-inline-end: 1px solid $color-white-alpha-16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs--collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wraps .NavTabs__ItemButton to make the hitbox larger
|
||||||
|
.NavTabs__Item {
|
||||||
|
width: 100%;
|
||||||
|
padding-block: $NavTabs__Item__blockPadding;
|
||||||
|
padding-inline: 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
&:focus {
|
||||||
|
// Handled by .NavTabs__ItemButton
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__ItemButton {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $NavTabs__ItemButton__blockPadding;
|
||||||
|
border-radius: 8px;
|
||||||
|
.NavTabs__Item:hover &,
|
||||||
|
.NavTabs__Item:focus-visible & {
|
||||||
|
@include light-theme {
|
||||||
|
background-color: $color-black-alpha-06;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background-color: $color-white-alpha-06;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.NavTabs__Item:focus-visible & {
|
||||||
|
box-shadow: 0 0 0 2px $color-ultramarine;
|
||||||
|
}
|
||||||
|
.NavTabs__Item:active &,
|
||||||
|
.NavTabs__Item[aria-selected='true'] & {
|
||||||
|
@include light-theme {
|
||||||
|
background: $color-gray-20;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background: $color-gray-62;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__ItemContent {
|
||||||
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__ItemLabel {
|
||||||
|
@include sr-only;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__ItemBadge {
|
||||||
|
@include rounded-corners;
|
||||||
|
align-items: center;
|
||||||
|
background-color: $color-accent-red;
|
||||||
|
color: $color-white;
|
||||||
|
display: flex;
|
||||||
|
font-size: 10px;
|
||||||
|
height: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-block: 0;
|
||||||
|
padding-inline: 2px;
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: -6px;
|
||||||
|
top: -4px;
|
||||||
|
user-select: none;
|
||||||
|
z-index: $z-index-base;
|
||||||
|
word-break: keep-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__ItemIcon {
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin NavTabs__Icon($icon) {
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg($icon, $color-black);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg($icon, $color-gray-15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__ItemIcon--Menu {
|
||||||
|
@include NavTabs__Icon('../images/icons/v3/menu/menu.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__ItemIcon--Settings {
|
||||||
|
@include NavTabs__Icon('../images/icons/v3/settings/settings.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__ItemIcon--Chats {
|
||||||
|
@include NavTabs__Icon('../images/icons/v3/chat/chat.svg');
|
||||||
|
.NavTabs__Item:active &,
|
||||||
|
.NavTabs__Item[aria-selected='true'] & {
|
||||||
|
@include NavTabs__Icon('../images/icons/v3/chat/chat-fill.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__ItemIcon--Calls {
|
||||||
|
@include NavTabs__Icon('../images/icons/v3/phone/phone.svg');
|
||||||
|
.NavTabs__Item:active &,
|
||||||
|
.NavTabs__Item[aria-selected='true'] & {
|
||||||
|
@include NavTabs__Icon('../images/icons/v3/phone/phone-fill.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__ItemIcon--Stories {
|
||||||
|
@include NavTabs__Icon('../images/icons/v3/stories/stories.svg');
|
||||||
|
.NavTabs__Item:active &,
|
||||||
|
.NavTabs__Item[aria-selected='true'] & {
|
||||||
|
@include NavTabs__Icon('../images/icons/v3/stories/stories-fill.svg');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__ItemIconLabel {
|
||||||
|
@include sr-only;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__TabList {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__Misc {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__TabPanel {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__AvatarBadge {
|
||||||
|
background: $color-ultramarine;
|
||||||
|
border-radius: 100%;
|
||||||
|
border: 1px solid $color-white;
|
||||||
|
height: 8px;
|
||||||
|
width: 8px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
inset-inline-end: 0;
|
||||||
|
}
|
|
@ -3,9 +3,8 @@
|
||||||
|
|
||||||
.module-SearchInput {
|
.module-SearchInput {
|
||||||
&__container {
|
&__container {
|
||||||
margin-inline: 16px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex: 1 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
// Copyright 2022 Signal Messenger, LLC
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
.Stories {
|
.Stories {
|
||||||
background: $color-gray-95;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
height: var(--window-height);
|
height: var(--window-height);
|
||||||
inset-inline-start: 0;
|
inset-inline-start: 0;
|
||||||
|
@ -11,42 +9,37 @@
|
||||||
user-select: none;
|
user-select: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: $z-index-stories;
|
z-index: $z-index-stories;
|
||||||
|
@include light-theme {
|
||||||
|
background: $color-white;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background: $color-gray-95;
|
||||||
|
}
|
||||||
|
|
||||||
&__pane {
|
&__pane {
|
||||||
background: $color-gray-80;
|
|
||||||
border-inline-end: 1px solid $color-gray-65;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 380px;
|
width: 380px;
|
||||||
padding-top: calc(14px + var(--title-bar-drag-area-height));
|
padding-top: calc(2px + var(--title-bar-drag-area-height));
|
||||||
|
@include light-theme {
|
||||||
&__add-story__button {
|
background: $color-gray-04;
|
||||||
@include color-svg('../images/icons/v3/plus/plus.svg', $color-white);
|
border-inline-end: 1px solid $color-black-alpha-16;
|
||||||
height: 20px;
|
}
|
||||||
position: absolute;
|
@include dark-theme {
|
||||||
inset-inline-end: 64px;
|
background: $color-gray-80;
|
||||||
top: 0px;
|
border-inline-end: 1px solid $color-white-alpha-16;
|
||||||
width: 20px;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
@include keyboard-mode {
|
|
||||||
background-color: $color-ultramarine;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__settings__button {
|
&__add-story__button {
|
||||||
@include dark-theme {
|
|
||||||
@include color-svg('../images/icons/v3/more/more.svg', $color-white);
|
|
||||||
}
|
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin-inline-start: 20px;
|
|
||||||
opacity: 1;
|
|
||||||
position: absolute;
|
|
||||||
inset-inline-end: 24px;
|
|
||||||
top: 0px;
|
|
||||||
width: 20px;
|
width: 20px;
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg('../images/icons/v3/plus/plus.svg', $color-black);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg('../images/icons/v3/plus/plus.svg', $color-gray-15);
|
||||||
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
@include keyboard-mode {
|
@include keyboard-mode {
|
||||||
|
@ -68,55 +61,40 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&--title {
|
&--title {
|
||||||
@include font-body-1-bold;
|
@include font-title-medium;
|
||||||
color: $color-gray-05;
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
justify-content: center;
|
@include light-theme {
|
||||||
|
color: $color-black;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
color: $color-gray-05;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--centered .Stories__pane__header--title {
|
&--centered .Stories__pane__header--title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--back {
|
|
||||||
@include button-reset;
|
|
||||||
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
height: 20px;
|
|
||||||
position: absolute;
|
|
||||||
width: 20px;
|
|
||||||
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/chevron/chevron-left.svg',
|
|
||||||
$color-white
|
|
||||||
);
|
|
||||||
|
|
||||||
@include keyboard-mode {
|
|
||||||
&:hover {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/chevron/chevron-left.svg',
|
|
||||||
$color-ultramarine-light
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__list {
|
&__list {
|
||||||
@include scrollbar;
|
@include NavTabs__Scroller;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: overlay;
|
overflow-y: overlay;
|
||||||
padding-block: 0;
|
padding-block: 0;
|
||||||
padding-inline: 14px;
|
padding-inline: 16px;
|
||||||
|
|
||||||
&--empty {
|
&--empty {
|
||||||
@include font-body-1;
|
@include font-body-1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: $color-gray-45;
|
@include light-theme() {
|
||||||
|
color: $color-gray-60;
|
||||||
|
}
|
||||||
|
@include dark-theme() {
|
||||||
|
color: $color-gray-45;
|
||||||
|
}
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -125,11 +103,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__search__container {
|
|
||||||
margin-block: 14px 10px;
|
|
||||||
margin-inline: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__placeholder {
|
&__placeholder {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: $color-gray-45;
|
color: $color-gray-45;
|
||||||
|
@ -154,33 +127,69 @@
|
||||||
@include button-reset;
|
@include button-reset;
|
||||||
@include font-body-1-bold;
|
@include font-body-1-bold;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: $color-gray-05;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-block: 12px;
|
padding-block: 12px;
|
||||||
padding-inline: 24px;
|
padding-inline: 24px;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@include light-theme {
|
||||||
|
color: $color-black;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
color: $color-gray-05;
|
||||||
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/chevron/chevron-right.svg',
|
|
||||||
$color-gray-05
|
|
||||||
);
|
|
||||||
content: '';
|
content: '';
|
||||||
height: 16px;
|
height: 16px;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--collapsed {
|
||||||
|
&::after {
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/chevron/chevron-right.svg',
|
||||||
|
$color-black
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/chevron/chevron-right.svg',
|
||||||
|
$color-gray-05
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&--expanded {
|
&--expanded {
|
||||||
// Override color-svg
|
&::after {
|
||||||
:dir(ltr) &::after,
|
@include light-theme {
|
||||||
:dir(rtl) &::after {
|
@include color-svg(
|
||||||
@include color-svg(
|
'../images/icons/v3/chevron/chevron-down.svg',
|
||||||
'../images/icons/v3/chevron/chevron-down.svg',
|
$color-black
|
||||||
$color-gray-05
|
);
|
||||||
);
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/chevron/chevron-down.svg',
|
||||||
|
$color-gray-05
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.StoriesTab__MoreActionsIcon {
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg('../images/icons/v3/more/more.svg', $color-black);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg('../images/icons/v3/more/more.svg', $color-gray-15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -15,7 +15,12 @@
|
||||||
|
|
||||||
@include keyboard-mode {
|
@include keyboard-mode {
|
||||||
&:focus {
|
&:focus {
|
||||||
background: $color-gray-65;
|
@include light-theme {
|
||||||
|
background: $color-gray-15;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background: $color-gray-65;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,7 +28,12 @@
|
||||||
// that has not been closed yet (active)
|
// that has not been closed yet (active)
|
||||||
&:hover,
|
&:hover,
|
||||||
&--active {
|
&--active {
|
||||||
background: $color-gray-65;
|
@include light-theme {
|
||||||
|
background: $color-gray-15;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background: $color-gray-65;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,16 +70,26 @@
|
||||||
|
|
||||||
&--title {
|
&--title {
|
||||||
@include font-body-1-bold;
|
@include font-body-1-bold;
|
||||||
color: $color-gray-05;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@include light-theme {
|
||||||
|
color: $color-black;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
color: $color-gray-05;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--timestamp,
|
&--timestamp,
|
||||||
&--sending,
|
&--sending,
|
||||||
&--send_failed {
|
&--send_failed {
|
||||||
@include font-body-2;
|
@include font-body-2;
|
||||||
color: $color-gray-25;
|
@include light-theme {
|
||||||
|
color: $color-gray-60;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
color: $color-gray-25;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--send_failed {
|
&--send_failed {
|
||||||
|
@ -148,32 +168,31 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
|
@mixin StoryListItem__Icon($path) {
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg($path, $color-black);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg($path, $color-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&--chat {
|
&--chat {
|
||||||
@include color-svg(
|
@include StoryListItem__Icon('../images/icons/v3/open/open-compact.svg');
|
||||||
'../images/icons/v3/open/open-compact.svg',
|
|
||||||
$color-white
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&--delete {
|
&--delete {
|
||||||
@include color-svg(
|
@include StoryListItem__Icon(
|
||||||
'../images/icons/v3/trash/trash-compact.svg',
|
'../images/icons/v3/trash/trash-compact.svg'
|
||||||
$color-white
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--hide {
|
&--hide {
|
||||||
@include color-svg(
|
@include StoryListItem__Icon('../images/icons/v3/x/x-circle-compact.svg');
|
||||||
'../images/icons/v3/x/x-circle-compact.svg',
|
|
||||||
$color-white
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&--info {
|
&--info {
|
||||||
@include color-svg(
|
@include StoryListItem__Icon('../images/icons/v3/info/info-compact.svg');
|
||||||
'../images/icons/v3/info/info-compact.svg',
|
|
||||||
$color-white
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,7 @@
|
||||||
@import './components/BadgeSustainerInstructionsDialog.scss';
|
@import './components/BadgeSustainerInstructionsDialog.scss';
|
||||||
@import './components/BetterAvatarBubble.scss';
|
@import './components/BetterAvatarBubble.scss';
|
||||||
@import './components/Button.scss';
|
@import './components/Button.scss';
|
||||||
|
@import './components/CallsTab.scss';
|
||||||
@import './components/CallingAudioIndicator.scss';
|
@import './components/CallingAudioIndicator.scss';
|
||||||
@import './components/CallingButton.scss';
|
@import './components/CallingButton.scss';
|
||||||
@import './components/CallingLobby.scss';
|
@import './components/CallingLobby.scss';
|
||||||
|
@ -102,6 +103,8 @@
|
||||||
@import './components/MiniPlayer.scss';
|
@import './components/MiniPlayer.scss';
|
||||||
@import './components/Modal.scss';
|
@import './components/Modal.scss';
|
||||||
@import './components/MyStories.scss';
|
@import './components/MyStories.scss';
|
||||||
|
@import './components/NavSidebar.scss';
|
||||||
|
@import './components/NavTabs.scss';
|
||||||
@import './components/OutgoingGiftBadgeModal.scss';
|
@import './components/OutgoingGiftBadgeModal.scss';
|
||||||
@import './components/PermissionsPopup.scss';
|
@import './components/PermissionsPopup.scss';
|
||||||
@import './components/PlaybackButton.scss';
|
@import './components/PlaybackButton.scss';
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
import { Bytes } from './context/Bytes';
|
import { Bytes } from './context/Bytes';
|
||||||
|
|
||||||
const bytes = window.SignalContext?.bytes || new Bytes();
|
const bytes = globalThis.window?.SignalContext?.bytes || new Bytes();
|
||||||
|
|
||||||
export function fromBase64(value: string): Uint8Array {
|
export function fromBase64(value: string): Uint8Array {
|
||||||
return bytes.fromBase64(value);
|
return bytes.fromBase64(value);
|
||||||
|
|
62
ts/Crypto.ts
|
@ -7,13 +7,9 @@ import { HKDF } from '@signalapp/libsignal-client';
|
||||||
|
|
||||||
import * as Bytes from './Bytes';
|
import * as Bytes from './Bytes';
|
||||||
import { calculateAgreement, generateKeyPair } from './Curve';
|
import { calculateAgreement, generateKeyPair } from './Curve';
|
||||||
import * as log from './logging/log';
|
|
||||||
import { HashType, CipherType } from './types/Crypto';
|
import { HashType, CipherType } from './types/Crypto';
|
||||||
import { ProfileDecryptError } from './types/errors';
|
import { ProfileDecryptError } from './types/errors';
|
||||||
import { UUID, UUID_BYTE_SIZE } from './types/UUID';
|
import { getBytesSubarray } from './util/uuidToBytes';
|
||||||
import type { UUIDStringType } from './types/UUID';
|
|
||||||
|
|
||||||
export { uuidToBytes } from './util/uuidToBytes';
|
|
||||||
|
|
||||||
export { HashType, CipherType };
|
export { HashType, CipherType };
|
||||||
|
|
||||||
|
@ -199,12 +195,16 @@ export function decryptSymmetric(
|
||||||
const iv = getZeroes(IV_LENGTH);
|
const iv = getZeroes(IV_LENGTH);
|
||||||
|
|
||||||
const nonce = getFirstBytes(data, NONCE_LENGTH);
|
const nonce = getFirstBytes(data, NONCE_LENGTH);
|
||||||
const ciphertext = getBytes(
|
const ciphertext = getBytesSubarray(
|
||||||
data,
|
data,
|
||||||
NONCE_LENGTH,
|
NONCE_LENGTH,
|
||||||
data.byteLength - NONCE_LENGTH - MAC_LENGTH
|
data.byteLength - NONCE_LENGTH - MAC_LENGTH
|
||||||
);
|
);
|
||||||
const theirMac = getBytes(data, data.byteLength - MAC_LENGTH, MAC_LENGTH);
|
const theirMac = getBytesSubarray(
|
||||||
|
data,
|
||||||
|
data.byteLength - MAC_LENGTH,
|
||||||
|
MAC_LENGTH
|
||||||
|
);
|
||||||
|
|
||||||
const cipherKey = hmacSha256(key, nonce);
|
const cipherKey = hmacSha256(key, nonce);
|
||||||
const macKey = hmacSha256(key, cipherKey);
|
const macKey = hmacSha256(key, cipherKey);
|
||||||
|
@ -353,52 +353,6 @@ export function getFirstBytes(data: Uint8Array, n: number): Uint8Array {
|
||||||
return data.subarray(0, n);
|
return data.subarray(0, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBytes(
|
|
||||||
data: Uint8Array,
|
|
||||||
start: number,
|
|
||||||
n: number
|
|
||||||
): Uint8Array {
|
|
||||||
return data.subarray(start, start + n);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bytesToUuid(bytes: Uint8Array): undefined | UUIDStringType {
|
|
||||||
if (bytes.byteLength !== UUID_BYTE_SIZE) {
|
|
||||||
log.warn(
|
|
||||||
'bytesToUuid: received an Uint8Array of invalid length. ' +
|
|
||||||
'Returning undefined'
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uuids = splitUuids(bytes);
|
|
||||||
if (uuids.length === 1) {
|
|
||||||
return uuids[0] || undefined;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function splitUuids(buffer: Uint8Array): Array<UUIDStringType | null> {
|
|
||||||
const uuids = new Array<UUIDStringType | null>();
|
|
||||||
for (let i = 0; i < buffer.byteLength; i += UUID_BYTE_SIZE) {
|
|
||||||
const bytes = getBytes(buffer, i, UUID_BYTE_SIZE);
|
|
||||||
const hex = Bytes.toHex(bytes);
|
|
||||||
const chunks = [
|
|
||||||
hex.substring(0, 8),
|
|
||||||
hex.substring(8, 12),
|
|
||||||
hex.substring(12, 16),
|
|
||||||
hex.substring(16, 20),
|
|
||||||
hex.substring(20),
|
|
||||||
];
|
|
||||||
const uuid = chunks.join('-');
|
|
||||||
if (uuid !== '00000000-0000-0000-0000-000000000000') {
|
|
||||||
uuids.push(UUID.cast(uuid));
|
|
||||||
} else {
|
|
||||||
uuids.push(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return uuids;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trimForDisplay(padded: Uint8Array): Uint8Array {
|
export function trimForDisplay(padded: Uint8Array): Uint8Array {
|
||||||
let paddingEnd = 0;
|
let paddingEnd = 0;
|
||||||
for (paddingEnd; paddingEnd < padded.length; paddingEnd += 1) {
|
for (paddingEnd; paddingEnd < padded.length; paddingEnd += 1) {
|
||||||
|
@ -588,7 +542,7 @@ export function decryptProfileName(
|
||||||
// SignalContext APIs
|
// SignalContext APIs
|
||||||
//
|
//
|
||||||
|
|
||||||
const { crypto } = window.SignalContext;
|
const { crypto } = globalThis.window?.SignalContext ?? {};
|
||||||
|
|
||||||
export function sign(key: Uint8Array, data: Uint8Array): Uint8Array {
|
export function sign(key: Uint8Array, data: Uint8Array): Uint8Array {
|
||||||
return crypto.sign(key, data);
|
return crypto.sign(key, data);
|
||||||
|
|
|
@ -8,8 +8,8 @@ import * as log from './logging/log';
|
||||||
import type { UUIDStringType } from './types/UUID';
|
import type { UUIDStringType } from './types/UUID';
|
||||||
import { parseIntOrThrow } from './util/parseIntOrThrow';
|
import { parseIntOrThrow } from './util/parseIntOrThrow';
|
||||||
import { SECOND, HOUR } from './util/durations';
|
import { SECOND, HOUR } from './util/durations';
|
||||||
import { uuidToBytes } from './util/uuidToBytes';
|
|
||||||
import * as Bytes from './Bytes';
|
import * as Bytes from './Bytes';
|
||||||
|
import { uuidToBytes } from './util/uuidToBytes';
|
||||||
import { HashType } from './types/Crypto';
|
import { HashType } from './types/Crypto';
|
||||||
import { getCountryCode } from './types/PhoneNumber';
|
import { getCountryCode } from './types/PhoneNumber';
|
||||||
|
|
||||||
|
|
|
@ -186,6 +186,11 @@ import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration'
|
||||||
import { makeLookup } from './util/makeLookup';
|
import { makeLookup } from './util/makeLookup';
|
||||||
import { addGlobalKeyboardShortcuts } from './services/addGlobalKeyboardShortcuts';
|
import { addGlobalKeyboardShortcuts } from './services/addGlobalKeyboardShortcuts';
|
||||||
import { createEventHandler } from './quill/signal-clipboard/util';
|
import { createEventHandler } from './quill/signal-clipboard/util';
|
||||||
|
import { onCallLogEventSync } from './util/onCallLogEventSync';
|
||||||
|
import {
|
||||||
|
getCallsHistoryForRedux,
|
||||||
|
loadCallsHistory,
|
||||||
|
} from './services/callHistoryLoader';
|
||||||
|
|
||||||
export function isOverHourIntoPast(timestamp: number): boolean {
|
export function isOverHourIntoPast(timestamp: number): boolean {
|
||||||
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
|
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
|
||||||
|
@ -434,6 +439,10 @@ export async function startApp(): Promise<void> {
|
||||||
'callEventSync',
|
'callEventSync',
|
||||||
queuedEventListener(onCallEventSync, false)
|
queuedEventListener(onCallEventSync, false)
|
||||||
);
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'callLogEventSync',
|
||||||
|
queuedEventListener(onCallLogEventSync, false)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
ourProfileKeyService.initialize(window.storage);
|
ourProfileKeyService.initialize(window.storage);
|
||||||
|
@ -1121,6 +1130,7 @@ export async function startApp(): Promise<void> {
|
||||||
loadInitialBadgesState(),
|
loadInitialBadgesState(),
|
||||||
loadStories(),
|
loadStories(),
|
||||||
loadDistributionLists(),
|
loadDistributionLists(),
|
||||||
|
loadCallsHistory(),
|
||||||
window.textsecure.storage.protocol.hydrateCaches(),
|
window.textsecure.storage.protocol.hydrateCaches(),
|
||||||
(async () => {
|
(async () => {
|
||||||
mainWindowStats = await window.SignalContext.getMainWindowStats();
|
mainWindowStats = await window.SignalContext.getMainWindowStats();
|
||||||
|
@ -1174,6 +1184,7 @@ export async function startApp(): Promise<void> {
|
||||||
menuOptions,
|
menuOptions,
|
||||||
stories: getStoriesForRedux(),
|
stories: getStoriesForRedux(),
|
||||||
storyDistributionLists: getDistributionListsForRedux(),
|
storyDistributionLists: getDistributionListsForRedux(),
|
||||||
|
callsHistory: getCallsHistoryForRedux(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = window.Signal.State.createStore(initialState);
|
const store = window.Signal.State.createStore(initialState);
|
||||||
|
@ -1193,6 +1204,10 @@ export async function startApp(): Promise<void> {
|
||||||
store.dispatch
|
store.dispatch
|
||||||
),
|
),
|
||||||
badges: bindActionCreators(actionCreators.badges, store.dispatch),
|
badges: bindActionCreators(actionCreators.badges, store.dispatch),
|
||||||
|
callHistory: bindActionCreators(
|
||||||
|
actionCreators.callHistory,
|
||||||
|
store.dispatch
|
||||||
|
),
|
||||||
calling: bindActionCreators(actionCreators.calling, store.dispatch),
|
calling: bindActionCreators(actionCreators.calling, store.dispatch),
|
||||||
composer: bindActionCreators(actionCreators.composer, store.dispatch),
|
composer: bindActionCreators(actionCreators.composer, store.dispatch),
|
||||||
conversations: bindActionCreators(
|
conversations: bindActionCreators(
|
||||||
|
|
|
@ -25,9 +25,7 @@ type PropsType = {
|
||||||
registerSingleDevice: (number: string, code: string) => Promise<void>;
|
registerSingleDevice: (number: string, code: string) => Promise<void>;
|
||||||
renderCallManager: () => JSX.Element;
|
renderCallManager: () => JSX.Element;
|
||||||
renderGlobalModalContainer: () => JSX.Element;
|
renderGlobalModalContainer: () => JSX.Element;
|
||||||
isShowingStoriesView: boolean;
|
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
renderStories: (closeView: () => unknown) => JSX.Element;
|
|
||||||
hasSelectedStoryData: boolean;
|
hasSelectedStoryData: boolean;
|
||||||
renderStoryViewer: (closeView: () => unknown) => JSX.Element;
|
renderStoryViewer: (closeView: () => unknown) => JSX.Element;
|
||||||
renderLightbox: () => JSX.Element | null;
|
renderLightbox: () => JSX.Element | null;
|
||||||
|
@ -53,7 +51,6 @@ type PropsType = {
|
||||||
titleBarDoubleClick: () => void;
|
titleBarDoubleClick: () => void;
|
||||||
toast?: AnyToast;
|
toast?: AnyToast;
|
||||||
scrollToMessage: (conversationId: string, messageId: string) => unknown;
|
scrollToMessage: (conversationId: string, messageId: string) => unknown;
|
||||||
toggleStoriesView: () => unknown;
|
|
||||||
viewStory: ViewStoryActionCreatorType;
|
viewStory: ViewStoryActionCreatorType;
|
||||||
renderInbox: () => JSX.Element;
|
renderInbox: () => JSX.Element;
|
||||||
};
|
};
|
||||||
|
@ -69,7 +66,6 @@ export function App({
|
||||||
i18n,
|
i18n,
|
||||||
isFullScreen,
|
isFullScreen,
|
||||||
isMaximized,
|
isMaximized,
|
||||||
isShowingStoriesView,
|
|
||||||
menuOptions,
|
menuOptions,
|
||||||
onUndoArchive,
|
onUndoArchive,
|
||||||
openFileInFolder,
|
openFileInFolder,
|
||||||
|
@ -81,13 +77,11 @@ export function App({
|
||||||
renderGlobalModalContainer,
|
renderGlobalModalContainer,
|
||||||
renderInbox,
|
renderInbox,
|
||||||
renderLightbox,
|
renderLightbox,
|
||||||
renderStories,
|
|
||||||
renderStoryViewer,
|
renderStoryViewer,
|
||||||
requestVerification,
|
requestVerification,
|
||||||
theme,
|
theme,
|
||||||
titleBarDoubleClick,
|
titleBarDoubleClick,
|
||||||
toast,
|
toast,
|
||||||
toggleStoriesView,
|
|
||||||
viewStory,
|
viewStory,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
let contents;
|
let contents;
|
||||||
|
@ -183,7 +177,6 @@ export function App({
|
||||||
{renderGlobalModalContainer()}
|
{renderGlobalModalContainer()}
|
||||||
{renderCallManager()}
|
{renderCallManager()}
|
||||||
{renderLightbox()}
|
{renderLightbox()}
|
||||||
{isShowingStoriesView && renderStories(toggleStoriesView)}
|
|
||||||
{hasSelectedStoryData &&
|
{hasSelectedStoryData &&
|
||||||
renderStoryViewer(() => viewStory({ closeViewer: true }))}
|
renderStoryViewer(() => viewStory({ closeViewer: true }))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -12,6 +12,7 @@ import React, { useEffect, useState } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
|
|
||||||
|
import { filterDOMProps } from '@react-aria/utils';
|
||||||
import type { AvatarColorType } from '../types/Colors';
|
import type { AvatarColorType } from '../types/Colors';
|
||||||
import type { BadgeType } from '../badges/types';
|
import type { BadgeType } from '../badges/types';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
|
@ -239,7 +240,7 @@ export function Avatar({
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
contents = (
|
contents = (
|
||||||
<button
|
<button
|
||||||
{...ariaProps}
|
{...filterDOMProps(ariaProps)}
|
||||||
className={contentsClassName}
|
className={contentsClassName}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
|
@ -46,8 +46,6 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
|
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
|
||||||
onEditProfile: action('onEditProfile'),
|
onEditProfile: action('onEditProfile'),
|
||||||
onStartUpdate: action('startUpdate'),
|
onStartUpdate: action('startUpdate'),
|
||||||
onViewArchive: action('onViewArchive'),
|
|
||||||
onViewPreferences: action('onViewPreferences'),
|
|
||||||
phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''),
|
phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''),
|
||||||
profileName: text('profileName', overrideProps.profileName || ''),
|
profileName: text('profileName', overrideProps.profileName || ''),
|
||||||
sharedGroupNames: [],
|
sharedGroupNames: [],
|
||||||
|
|
|
@ -19,8 +19,6 @@ export type Props = {
|
||||||
|
|
||||||
onEditProfile: () => unknown;
|
onEditProfile: () => unknown;
|
||||||
onStartUpdate: () => unknown;
|
onStartUpdate: () => unknown;
|
||||||
onViewPreferences: () => unknown;
|
|
||||||
onViewArchive: () => unknown;
|
|
||||||
|
|
||||||
// Matches Popper's RefHandler type
|
// Matches Popper's RefHandler type
|
||||||
innerRef?: React.Ref<HTMLDivElement>;
|
innerRef?: React.Ref<HTMLDivElement>;
|
||||||
|
@ -35,8 +33,6 @@ export function AvatarPopup(props: Props): JSX.Element {
|
||||||
name,
|
name,
|
||||||
onEditProfile,
|
onEditProfile,
|
||||||
onStartUpdate,
|
onStartUpdate,
|
||||||
onViewArchive,
|
|
||||||
onViewPreferences,
|
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
profileName,
|
profileName,
|
||||||
style,
|
style,
|
||||||
|
@ -70,54 +66,27 @@ export function AvatarPopup(props: Props): JSX.Element {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<hr className="module-avatar-popup__divider" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="module-avatar-popup__item"
|
|
||||||
onClick={onViewPreferences}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
'module-avatar-popup__item__icon',
|
|
||||||
'module-avatar-popup__item__icon-settings'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="module-avatar-popup__item__text">
|
|
||||||
{i18n('icu:mainMenuSettings')}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="module-avatar-popup__item"
|
|
||||||
onClick={onViewArchive}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
'module-avatar-popup__item__icon',
|
|
||||||
'module-avatar-popup__item__icon-archive'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="module-avatar-popup__item__text">
|
|
||||||
{i18n('icu:avatarMenuViewArchive')}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{hasPendingUpdate && (
|
{hasPendingUpdate && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<hr className="module-avatar-popup__divider" />
|
||||||
className="module-avatar-popup__item"
|
<button
|
||||||
onClick={onStartUpdate}
|
type="button"
|
||||||
>
|
className="module-avatar-popup__item"
|
||||||
<div
|
onClick={onStartUpdate}
|
||||||
className={classNames(
|
>
|
||||||
'module-avatar-popup__item__icon',
|
<div
|
||||||
'module-avatar-popup__item__icon--update'
|
className={classNames(
|
||||||
)}
|
'module-avatar-popup__item__icon',
|
||||||
/>
|
'module-avatar-popup__item__icon--update'
|
||||||
<div className="module-avatar-popup__item__text">
|
)}
|
||||||
{i18n('icu:avatarMenuUpdateAvailable')}
|
/>
|
||||||
</div>
|
<div className="module-avatar-popup__item__text">
|
||||||
<div className="module-avatar-popup__item--badge" />
|
{i18n('icu:avatarMenuUpdateAvailable')}
|
||||||
</button>
|
</div>
|
||||||
|
<div className="module-avatar-popup__item--badge" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -33,6 +33,7 @@ export enum ButtonVariant {
|
||||||
|
|
||||||
export enum ButtonIconType {
|
export enum ButtonIconType {
|
||||||
audio = 'audio',
|
audio = 'audio',
|
||||||
|
message = 'message',
|
||||||
muted = 'muted',
|
muted = 'muted',
|
||||||
search = 'search',
|
search = 'search',
|
||||||
unmuted = 'unmuted',
|
unmuted = 'unmuted',
|
||||||
|
|
476
ts/components/CallsList.tsx
Normal file
|
@ -0,0 +1,476 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import type { Index, IndexRange, ListRowProps } from 'react-virtualized';
|
||||||
|
import { InfiniteLoader, List } from 'react-virtualized';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import type { LocalizerType } from '../types/I18N';
|
||||||
|
import { ListTile } from './ListTile';
|
||||||
|
import { Avatar, AvatarSize } from './Avatar';
|
||||||
|
import { SearchInput } from './SearchInput';
|
||||||
|
import type {
|
||||||
|
CallHistoryFilterOptions,
|
||||||
|
CallHistoryGroup,
|
||||||
|
CallHistoryPagination,
|
||||||
|
} from '../types/CallDisposition';
|
||||||
|
import {
|
||||||
|
CallHistoryFilterStatus,
|
||||||
|
CallDirection,
|
||||||
|
CallType,
|
||||||
|
DirectCallStatus,
|
||||||
|
GroupCallStatus,
|
||||||
|
isSameCallHistoryGroup,
|
||||||
|
} from '../types/CallDisposition';
|
||||||
|
import { formatDateTimeShort } from '../util/timestamp';
|
||||||
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
|
import * as log from '../logging/log';
|
||||||
|
import { refMerger } from '../util/refMerger';
|
||||||
|
import { drop } from '../util/drop';
|
||||||
|
import { strictAssert } from '../util/assert';
|
||||||
|
import { UserText } from './UserText';
|
||||||
|
import { Intl } from './Intl';
|
||||||
|
import { NavSidebarSearchHeader } from './NavSidebar';
|
||||||
|
import { SizeObserver } from '../hooks/useSizeObserver';
|
||||||
|
import { formatCallHistoryGroup } from '../util/callDisposition';
|
||||||
|
|
||||||
|
function Timestamp({
|
||||||
|
i18n,
|
||||||
|
timestamp,
|
||||||
|
}: {
|
||||||
|
i18n: LocalizerType;
|
||||||
|
timestamp: number;
|
||||||
|
}): JSX.Element {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setNow(Date.now());
|
||||||
|
}, 1_000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dateTime = useMemo(() => {
|
||||||
|
return new Date(timestamp).toISOString();
|
||||||
|
}, [timestamp]);
|
||||||
|
|
||||||
|
const formatted = useMemo(() => {
|
||||||
|
void now; // Use this as a dep so we update
|
||||||
|
return formatDateTimeShort(i18n, timestamp);
|
||||||
|
}, [i18n, timestamp, now]);
|
||||||
|
|
||||||
|
return <time dateTime={dateTime}>{formatted}</time>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchResults = Readonly<{
|
||||||
|
count: number;
|
||||||
|
items: ReadonlyArray<CallHistoryGroup>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type SearchState = Readonly<{
|
||||||
|
state: 'init' | 'pending' | 'rejected' | 'fulfilled';
|
||||||
|
// Note these fields shouldnt be updated until the search is fulfilled or rejected.
|
||||||
|
options: null | { query: string; status: CallHistoryFilterStatus };
|
||||||
|
results: null | SearchResults;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const defaultInitState: SearchState = {
|
||||||
|
state: 'init',
|
||||||
|
options: null,
|
||||||
|
results: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPendingState: SearchState = {
|
||||||
|
state: 'pending',
|
||||||
|
options: null,
|
||||||
|
results: {
|
||||||
|
count: 100,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type CallsListProps = Readonly<{
|
||||||
|
getCallHistoryGroupsCount: (
|
||||||
|
options: CallHistoryFilterOptions
|
||||||
|
) => Promise<number>;
|
||||||
|
getCallHistoryGroups: (
|
||||||
|
options: CallHistoryFilterOptions,
|
||||||
|
pagination: CallHistoryPagination
|
||||||
|
) => Promise<Array<CallHistoryGroup>>;
|
||||||
|
getConversation: (id: string) => ConversationType | void;
|
||||||
|
i18n: LocalizerType;
|
||||||
|
selectedCallHistoryGroup: CallHistoryGroup | null;
|
||||||
|
onSelectCallHistoryGroup: (
|
||||||
|
conversationId: string,
|
||||||
|
selectedCallHistoryGroup: CallHistoryGroup
|
||||||
|
) => void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function rowHeight() {
|
||||||
|
return ListTile.heightCompact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CallsList({
|
||||||
|
getCallHistoryGroupsCount,
|
||||||
|
getCallHistoryGroups,
|
||||||
|
getConversation,
|
||||||
|
i18n,
|
||||||
|
selectedCallHistoryGroup,
|
||||||
|
onSelectCallHistoryGroup,
|
||||||
|
}: CallsListProps): JSX.Element {
|
||||||
|
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||||
|
const listRef = useRef<List>(null);
|
||||||
|
const [queryInput, setQueryInput] = useState('');
|
||||||
|
const [status, setStatus] = useState(CallHistoryFilterStatus.All);
|
||||||
|
const [searchState, setSearchState] = useState(defaultInitState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
const options = {
|
||||||
|
query: queryInput.toLowerCase().normalize().trim(),
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
|
||||||
|
let timer = setTimeout(() => {
|
||||||
|
setSearchState(prevSearchState => {
|
||||||
|
if (prevSearchState.state === 'init') {
|
||||||
|
return defaultPendingState;
|
||||||
|
}
|
||||||
|
return prevSearchState;
|
||||||
|
});
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
// Show loading indicator after a delay
|
||||||
|
setSearchState(defaultPendingState);
|
||||||
|
}, 300);
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
let results: SearchResults | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [count, items] = await Promise.all([
|
||||||
|
getCallHistoryGroupsCount(options),
|
||||||
|
getCallHistoryGroups(options, {
|
||||||
|
offset: 0,
|
||||||
|
limit: 100, // preloaded rows
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
results = { count, items };
|
||||||
|
} catch (error) {
|
||||||
|
log.error('CallsList#fetchTotal error fetching', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the loading indicator timeout
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
// Ignore old requests
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only commit the new search state once the results are ready
|
||||||
|
setSearchState({
|
||||||
|
state: results == null ? 'rejected' : 'fulfilled',
|
||||||
|
options,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
infiniteLoaderRef.current?.resetLoadMoreRowsCache(true);
|
||||||
|
listRef.current?.scrollToPosition(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(search());
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [getCallHistoryGroupsCount, getCallHistoryGroups, queryInput, status]);
|
||||||
|
|
||||||
|
const loadMoreRows = useCallback(
|
||||||
|
async (props: IndexRange) => {
|
||||||
|
const { state, options } = searchState;
|
||||||
|
if (state !== 'fulfilled') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
strictAssert(
|
||||||
|
options != null,
|
||||||
|
'options should never be null when status is fulfilled'
|
||||||
|
);
|
||||||
|
|
||||||
|
let { startIndex, stopIndex } = props;
|
||||||
|
|
||||||
|
if (startIndex > stopIndex) {
|
||||||
|
// flip
|
||||||
|
[startIndex, stopIndex] = [stopIndex, startIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = startIndex;
|
||||||
|
const limit = stopIndex - startIndex + 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groups = await getCallHistoryGroups(options, { offset, limit });
|
||||||
|
|
||||||
|
if (searchState.options !== options) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchState(prevSearchState => {
|
||||||
|
strictAssert(
|
||||||
|
prevSearchState.results != null,
|
||||||
|
'results should never be null here'
|
||||||
|
);
|
||||||
|
const newItems = prevSearchState.results.items.slice();
|
||||||
|
newItems.splice(startIndex, stopIndex, ...groups);
|
||||||
|
return {
|
||||||
|
...prevSearchState,
|
||||||
|
results: {
|
||||||
|
...prevSearchState.results,
|
||||||
|
items: newItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error('CallsList#loadMoreRows error fetching', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getCallHistoryGroups, searchState]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isRowLoaded = useCallback(
|
||||||
|
(props: Index) => {
|
||||||
|
return searchState.results?.items[props.index] != null;
|
||||||
|
},
|
||||||
|
[searchState]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowRenderer = useCallback(
|
||||||
|
({ key, index, style }: ListRowProps) => {
|
||||||
|
const item = searchState.results?.items.at(index) ?? null;
|
||||||
|
const conversation = item != null ? getConversation(item.peerId) : null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
searchState.state === 'pending' ||
|
||||||
|
item == null ||
|
||||||
|
conversation == null
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div key={key} style={style}>
|
||||||
|
<ListTile
|
||||||
|
leading={<div className="CallsList__LoadingAvatar" />}
|
||||||
|
title={
|
||||||
|
<span className="CallsList__LoadingText CallsList__LoadingText--title" />
|
||||||
|
}
|
||||||
|
subtitle={
|
||||||
|
<span className="CallsList__LoadingText CallsList__LoadingText--subtitle" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelected =
|
||||||
|
selectedCallHistoryGroup != null &&
|
||||||
|
isSameCallHistoryGroup(item, selectedCallHistoryGroup);
|
||||||
|
|
||||||
|
const wasMissed =
|
||||||
|
item.direction === CallDirection.Incoming &&
|
||||||
|
(item.status === DirectCallStatus.Missed ||
|
||||||
|
item.status === GroupCallStatus.Missed);
|
||||||
|
|
||||||
|
let statusText;
|
||||||
|
if (wasMissed) {
|
||||||
|
statusText = i18n('icu:CallsList__ItemCallInfo--Missed');
|
||||||
|
} else if (item.type === CallType.Group) {
|
||||||
|
statusText = i18n('icu:CallsList__ItemCallInfo--GroupCall');
|
||||||
|
} else if (item.direction === CallDirection.Outgoing) {
|
||||||
|
statusText = i18n('icu:CallsList__ItemCallInfo--Outgoing');
|
||||||
|
} else if (item.direction === CallDirection.Incoming) {
|
||||||
|
statusText = i18n('icu:CallsList__ItemCallInfo--Incoming');
|
||||||
|
} else {
|
||||||
|
strictAssert(false, 'Cannot format call');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={style}
|
||||||
|
className={classNames('CallsList__Item', {
|
||||||
|
'CallsList__Item--selected': isSelected,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ListTile
|
||||||
|
moduleClassName="CallsList__ItemTile"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
leading={
|
||||||
|
<Avatar
|
||||||
|
acceptedMessageRequest
|
||||||
|
avatarPath={conversation.avatarPath}
|
||||||
|
conversationType="group"
|
||||||
|
i18n={i18n}
|
||||||
|
isMe={false}
|
||||||
|
title={conversation.title}
|
||||||
|
sharedGroupNames={[]}
|
||||||
|
size={AvatarSize.THIRTY_TWO}
|
||||||
|
badge={undefined}
|
||||||
|
className="CallsList__ItemAvatar"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
trailing={
|
||||||
|
<span
|
||||||
|
className={classNames('CallsList__ItemIcon', {
|
||||||
|
'CallsList__ItemIcon--Phone': item.type === CallType.Audio,
|
||||||
|
'CallsList__ItemIcon--Video': item.type !== CallType.Audio,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<span
|
||||||
|
className="CallsList__ItemTitle"
|
||||||
|
data-call={formatCallHistoryGroup(item)}
|
||||||
|
>
|
||||||
|
<UserText text={conversation.title} />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
subtitle={
|
||||||
|
<span
|
||||||
|
className={classNames('CallsList__ItemCallInfo', {
|
||||||
|
'CallsList__ItemCallInfo--missed': wasMissed,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{item.children.length > 1 ? `(${item.children.length}) ` : ''}
|
||||||
|
{statusText} ·{' '}
|
||||||
|
<Timestamp i18n={i18n} timestamp={item.timestamp} />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectCallHistoryGroup(conversation.id, item);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
searchState,
|
||||||
|
getConversation,
|
||||||
|
selectedCallHistoryGroup,
|
||||||
|
onSelectCallHistoryGroup,
|
||||||
|
i18n,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearchInputChange = useCallback(
|
||||||
|
(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setQueryInput(event.target.value);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearchInputClear = useCallback(() => {
|
||||||
|
setQueryInput('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStatusToggle = useCallback(() => {
|
||||||
|
setStatus(prevStatus => {
|
||||||
|
return prevStatus === CallHistoryFilterStatus.All
|
||||||
|
? CallHistoryFilterStatus.Missed
|
||||||
|
: CallHistoryFilterStatus.All;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteringByMissed = status === CallHistoryFilterStatus.Missed;
|
||||||
|
|
||||||
|
const hasEmptyResults = searchState.results?.count === 0;
|
||||||
|
const currentQuery = searchState.options?.query ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NavSidebarSearchHeader>
|
||||||
|
<SearchInput
|
||||||
|
i18n={i18n}
|
||||||
|
placeholder={i18n('icu:CallsList__SearchInputPlaceholder')}
|
||||||
|
onChange={handleSearchInputChange}
|
||||||
|
onClear={handleSearchInputClear}
|
||||||
|
value={queryInput}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={classNames('CallsList__ToggleFilterByMissed', {
|
||||||
|
'CallsList__ToggleFilterByMissed--pressed': filteringByMissed,
|
||||||
|
})}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={filteringByMissed}
|
||||||
|
aria-roledescription={i18n(
|
||||||
|
'icu:CallsList__ToggleFilterByMissed__RoleDescription'
|
||||||
|
)}
|
||||||
|
onClick={handleStatusToggle}
|
||||||
|
>
|
||||||
|
<span className="CallsList__ToggleFilterByMissedLabel">
|
||||||
|
{i18n('icu:CallsList__ToggleFilterByMissedLabel')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</NavSidebarSearchHeader>
|
||||||
|
|
||||||
|
{hasEmptyResults && (
|
||||||
|
<p className="CallsList__EmptyState">
|
||||||
|
{currentQuery === '' ? (
|
||||||
|
i18n('icu:CallsList__EmptyState--noQuery')
|
||||||
|
) : (
|
||||||
|
<Intl
|
||||||
|
i18n={i18n}
|
||||||
|
id="icu:CallsList__EmptyState--hasQuery"
|
||||||
|
components={{
|
||||||
|
query: <UserText text={currentQuery} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SizeObserver>
|
||||||
|
{(ref, size) => {
|
||||||
|
return (
|
||||||
|
<div className="CallsList__ListContainer" ref={ref}>
|
||||||
|
{size != null && (
|
||||||
|
<InfiniteLoader
|
||||||
|
ref={infiniteLoaderRef}
|
||||||
|
isRowLoaded={isRowLoaded}
|
||||||
|
loadMoreRows={loadMoreRows}
|
||||||
|
rowCount={searchState.results?.count}
|
||||||
|
minimumBatchSize={100}
|
||||||
|
threshold={30}
|
||||||
|
>
|
||||||
|
{({ onRowsRendered, registerChild }) => {
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
className={classNames('CallsList__List', {
|
||||||
|
'CallsList__List--loading':
|
||||||
|
searchState.state === 'pending',
|
||||||
|
})}
|
||||||
|
ref={refMerger(listRef, registerChild)}
|
||||||
|
width={size.width}
|
||||||
|
height={size.height}
|
||||||
|
rowCount={searchState.results?.count ?? 0}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
rowRenderer={rowRenderer}
|
||||||
|
onRowsRendered={onRowsRendered}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</InfiniteLoader>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</SizeObserver>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
266
ts/components/CallsNewCall.tsx
Normal file
|
@ -0,0 +1,266 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { partition } from 'lodash';
|
||||||
|
import type { ListRowProps } from 'react-virtualized';
|
||||||
|
import { List } from 'react-virtualized';
|
||||||
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
|
import type { LocalizerType } from '../types/I18N';
|
||||||
|
import { SearchInput } from './SearchInput';
|
||||||
|
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
|
||||||
|
import { NavSidebarSearchHeader } from './NavSidebar';
|
||||||
|
import { ListTile } from './ListTile';
|
||||||
|
import { strictAssert } from '../util/assert';
|
||||||
|
import { UserText } from './UserText';
|
||||||
|
import { Avatar, AvatarSize } from './Avatar';
|
||||||
|
import { Intl } from './Intl';
|
||||||
|
import type { ActiveCallStateType } from '../state/ducks/calling';
|
||||||
|
import { SizeObserver } from '../hooks/useSizeObserver';
|
||||||
|
|
||||||
|
type CallsNewCallProps = Readonly<{
|
||||||
|
activeCall: ActiveCallStateType | undefined;
|
||||||
|
allConversations: ReadonlyArray<ConversationType>;
|
||||||
|
i18n: LocalizerType;
|
||||||
|
onSelectConversation: (conversationId: string) => void;
|
||||||
|
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||||
|
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||||
|
regionCode: string | undefined;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type Row =
|
||||||
|
| { kind: 'header'; title: string }
|
||||||
|
| { kind: 'conversation'; conversation: ConversationType };
|
||||||
|
|
||||||
|
export function CallsNewCall({
|
||||||
|
activeCall,
|
||||||
|
allConversations,
|
||||||
|
i18n,
|
||||||
|
onSelectConversation,
|
||||||
|
onOutgoingAudioCallInConversation,
|
||||||
|
onOutgoingVideoCallInConversation,
|
||||||
|
regionCode,
|
||||||
|
}: CallsNewCallProps): JSX.Element {
|
||||||
|
const [queryInput, setQueryInput] = useState('');
|
||||||
|
|
||||||
|
const query = useMemo(() => {
|
||||||
|
return queryInput.toLowerCase().normalize().trim();
|
||||||
|
}, [queryInput]);
|
||||||
|
|
||||||
|
const activeConversations = useMemo(() => {
|
||||||
|
return allConversations.filter(conversation => {
|
||||||
|
return conversation.activeAt != null && conversation.isArchived !== true;
|
||||||
|
});
|
||||||
|
}, [allConversations]);
|
||||||
|
|
||||||
|
const filteredConversations = useMemo(() => {
|
||||||
|
if (query === '') {
|
||||||
|
return activeConversations;
|
||||||
|
}
|
||||||
|
return filterAndSortConversationsByRecent(
|
||||||
|
activeConversations,
|
||||||
|
query,
|
||||||
|
regionCode
|
||||||
|
);
|
||||||
|
}, [activeConversations, query, regionCode]);
|
||||||
|
|
||||||
|
const [groupConversations, directConversations] = useMemo(() => {
|
||||||
|
return partition(filteredConversations, conversation => {
|
||||||
|
return conversation.type === 'group';
|
||||||
|
});
|
||||||
|
}, [filteredConversations]);
|
||||||
|
|
||||||
|
const handleSearchInputChange = useCallback(
|
||||||
|
(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setQueryInput(event.currentTarget.value);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearchInputClear = useCallback(() => {
|
||||||
|
setQueryInput('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rows = useMemo((): ReadonlyArray<Row> => {
|
||||||
|
let result: Array<Row> = [];
|
||||||
|
if (directConversations.length > 0) {
|
||||||
|
result.push({
|
||||||
|
kind: 'header',
|
||||||
|
title: 'Contacts',
|
||||||
|
});
|
||||||
|
result = result.concat(
|
||||||
|
directConversations.map(conversation => {
|
||||||
|
return {
|
||||||
|
kind: 'conversation',
|
||||||
|
conversation,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (groupConversations.length > 0) {
|
||||||
|
result.push({
|
||||||
|
kind: 'header',
|
||||||
|
title: 'Groups',
|
||||||
|
});
|
||||||
|
result = result.concat(
|
||||||
|
groupConversations.map((conversation): Row => {
|
||||||
|
return {
|
||||||
|
kind: 'conversation',
|
||||||
|
conversation,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [directConversations, groupConversations]);
|
||||||
|
|
||||||
|
const isRowLoaded = useCallback(
|
||||||
|
({ index }) => {
|
||||||
|
return rows.at(index) != null;
|
||||||
|
},
|
||||||
|
[rows]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowHeight = useCallback(
|
||||||
|
({ index }) => {
|
||||||
|
if (rows.at(index)?.kind === 'conversation') {
|
||||||
|
return ListTile.heightCompact;
|
||||||
|
}
|
||||||
|
// Height of .CallsNewCall__ListHeaderItem
|
||||||
|
return 40;
|
||||||
|
},
|
||||||
|
[rows]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowRenderer = useCallback(
|
||||||
|
({ key, index, style }: ListRowProps) => {
|
||||||
|
const item = rows.at(index);
|
||||||
|
strictAssert(item != null, 'Rendered non-existent row');
|
||||||
|
|
||||||
|
if (item.kind === 'header') {
|
||||||
|
return (
|
||||||
|
<div key={key} style={style} className="CallsNewCall__ListHeaderItem">
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const callButtonsDisabled = activeCall != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} style={style}>
|
||||||
|
<ListTile
|
||||||
|
leading={
|
||||||
|
<Avatar
|
||||||
|
acceptedMessageRequest
|
||||||
|
avatarPath={item.conversation.avatarPath}
|
||||||
|
conversationType="group"
|
||||||
|
i18n={i18n}
|
||||||
|
isMe={false}
|
||||||
|
title={item.conversation.title}
|
||||||
|
sharedGroupNames={[]}
|
||||||
|
size={AvatarSize.THIRTY_TWO}
|
||||||
|
badge={undefined}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={<UserText text={item.conversation.title} />}
|
||||||
|
trailing={
|
||||||
|
<div className="CallsNewCall__ItemActions">
|
||||||
|
{item.conversation.type === 'direct' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="CallsNewCall__ItemActionButton"
|
||||||
|
aria-disabled={callButtonsDisabled}
|
||||||
|
onClick={event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!callButtonsDisabled) {
|
||||||
|
onOutgoingAudioCallInConversation(item.conversation.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Phone" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="CallsNewCall__ItemActionButton"
|
||||||
|
aria-disabled={callButtonsDisabled}
|
||||||
|
onClick={event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!callButtonsDisabled) {
|
||||||
|
onOutgoingVideoCallInConversation(item.conversation.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Video" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectConversation(item.conversation.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
rows,
|
||||||
|
i18n,
|
||||||
|
activeCall,
|
||||||
|
onSelectConversation,
|
||||||
|
onOutgoingAudioCallInConversation,
|
||||||
|
onOutgoingVideoCallInConversation,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NavSidebarSearchHeader>
|
||||||
|
<SearchInput
|
||||||
|
i18n={i18n}
|
||||||
|
placeholder="Search"
|
||||||
|
onChange={handleSearchInputChange}
|
||||||
|
onClear={handleSearchInputClear}
|
||||||
|
value={queryInput}
|
||||||
|
/>
|
||||||
|
</NavSidebarSearchHeader>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<div className="CallsNewCall__EmptyState">
|
||||||
|
{query === '' ? (
|
||||||
|
i18n('icu:CallsNewCall__EmptyState--noQuery')
|
||||||
|
) : (
|
||||||
|
<Intl
|
||||||
|
i18n={i18n}
|
||||||
|
id="icu:CallsNewCall__EmptyState--hasQuery"
|
||||||
|
components={{
|
||||||
|
query: <UserText text={query} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<SizeObserver>
|
||||||
|
{(ref, size) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="CallsNewCall__ListContainer">
|
||||||
|
{size != null && (
|
||||||
|
<List
|
||||||
|
className="CallsNewCall__List"
|
||||||
|
width={size.width}
|
||||||
|
height={size.height}
|
||||||
|
isRowLoaded={isRowLoaded}
|
||||||
|
rowCount={rows.length}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
rowRenderer={rowRenderer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</SizeObserver>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
264
ts/components/CallsTab.tsx
Normal file
|
@ -0,0 +1,264 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import type { LocalizerType } from '../types/I18N';
|
||||||
|
import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
|
||||||
|
import { CallsList } from './CallsList';
|
||||||
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
|
import type {
|
||||||
|
CallHistoryFilterOptions,
|
||||||
|
CallHistoryGroup,
|
||||||
|
CallHistoryPagination,
|
||||||
|
} from '../types/CallDisposition';
|
||||||
|
import { CallsNewCall } from './CallsNewCall';
|
||||||
|
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||||
|
import type { ActiveCallStateType } from '../state/ducks/calling';
|
||||||
|
import { ContextMenu } from './ContextMenu';
|
||||||
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
|
|
||||||
|
enum CallsTabSidebarView {
|
||||||
|
CallsListView,
|
||||||
|
NewCallView,
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallsTabProps = Readonly<{
|
||||||
|
activeCall: ActiveCallStateType | undefined;
|
||||||
|
allConversations: ReadonlyArray<ConversationType>;
|
||||||
|
getCallHistoryGroupsCount: (
|
||||||
|
options: CallHistoryFilterOptions
|
||||||
|
) => Promise<number>;
|
||||||
|
getCallHistoryGroups: (
|
||||||
|
options: CallHistoryFilterOptions,
|
||||||
|
pagination: CallHistoryPagination
|
||||||
|
) => Promise<Array<CallHistoryGroup>>;
|
||||||
|
getConversation: (id: string) => ConversationType | void;
|
||||||
|
i18n: LocalizerType;
|
||||||
|
navTabsCollapsed: boolean;
|
||||||
|
onClearCallHistory: () => void;
|
||||||
|
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||||
|
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||||
|
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||||
|
preferredLeftPaneWidth: number;
|
||||||
|
renderConversationDetails: (
|
||||||
|
conversationId: string,
|
||||||
|
callHistoryGroup: CallHistoryGroup | null
|
||||||
|
) => JSX.Element;
|
||||||
|
regionCode: string | undefined;
|
||||||
|
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function CallsTab({
|
||||||
|
activeCall,
|
||||||
|
allConversations,
|
||||||
|
getCallHistoryGroupsCount,
|
||||||
|
getCallHistoryGroups,
|
||||||
|
getConversation,
|
||||||
|
i18n,
|
||||||
|
navTabsCollapsed,
|
||||||
|
onClearCallHistory,
|
||||||
|
onToggleNavTabsCollapse,
|
||||||
|
onOutgoingAudioCallInConversation,
|
||||||
|
onOutgoingVideoCallInConversation,
|
||||||
|
preferredLeftPaneWidth,
|
||||||
|
renderConversationDetails,
|
||||||
|
regionCode,
|
||||||
|
savePreferredLeftPaneWidth,
|
||||||
|
}: CallsTabProps): JSX.Element {
|
||||||
|
const [sidebarView, setSidebarView] = useState(
|
||||||
|
CallsTabSidebarView.CallsListView
|
||||||
|
);
|
||||||
|
const [selected, setSelected] = useState<{
|
||||||
|
conversationId: string;
|
||||||
|
callHistoryGroup: CallHistoryGroup | null;
|
||||||
|
} | null>(null);
|
||||||
|
const [
|
||||||
|
confirmClearCallHistoryDialogOpen,
|
||||||
|
setConfirmClearCallHistoryDialogOpen,
|
||||||
|
] = useState(false);
|
||||||
|
|
||||||
|
const updateSidebarView = useCallback(
|
||||||
|
(newSidebarView: CallsTabSidebarView) => {
|
||||||
|
setSidebarView(newSidebarView);
|
||||||
|
setSelected(null);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectCallHistoryGroup = useCallback(
|
||||||
|
(conversationId: string, callHistoryGroup: CallHistoryGroup) => {
|
||||||
|
setSelected({
|
||||||
|
conversationId,
|
||||||
|
callHistoryGroup,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectConversation = useCallback((conversationId: string) => {
|
||||||
|
setSelected({ conversationId, callHistoryGroup: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEscapeHandling(
|
||||||
|
sidebarView === CallsTabSidebarView.NewCallView
|
||||||
|
? () => {
|
||||||
|
updateSidebarView(CallsTabSidebarView.CallsListView);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenClearCallHistoryDialog = useCallback(() => {
|
||||||
|
setConfirmClearCallHistoryDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseClearCallHistoryDialog = useCallback(() => {
|
||||||
|
setConfirmClearCallHistoryDialogOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOutgoingAudioCallInConversation = useCallback(
|
||||||
|
(conversationId: string) => {
|
||||||
|
onOutgoingAudioCallInConversation(conversationId);
|
||||||
|
updateSidebarView(CallsTabSidebarView.CallsListView);
|
||||||
|
},
|
||||||
|
[updateSidebarView, onOutgoingAudioCallInConversation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOutgoingVideoCallInConversation = useCallback(
|
||||||
|
(conversationId: string) => {
|
||||||
|
onOutgoingVideoCallInConversation(conversationId);
|
||||||
|
updateSidebarView(CallsTabSidebarView.CallsListView);
|
||||||
|
},
|
||||||
|
[updateSidebarView, onOutgoingVideoCallInConversation]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="CallsTab">
|
||||||
|
<NavSidebar
|
||||||
|
i18n={i18n}
|
||||||
|
title={
|
||||||
|
sidebarView === CallsTabSidebarView.CallsListView
|
||||||
|
? i18n('icu:CallsTab__HeaderTitle--CallsList')
|
||||||
|
: i18n('icu:CallsTab__HeaderTitle--NewCall')
|
||||||
|
}
|
||||||
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
|
onBack={
|
||||||
|
sidebarView === CallsTabSidebarView.NewCallView
|
||||||
|
? () => {
|
||||||
|
updateSidebarView(CallsTabSidebarView.CallsListView);
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
||||||
|
requiresFullWidth
|
||||||
|
preferredLeftPaneWidth={preferredLeftPaneWidth}
|
||||||
|
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{sidebarView === CallsTabSidebarView.CallsListView && (
|
||||||
|
<>
|
||||||
|
<NavSidebarActionButton
|
||||||
|
icon={<span className="CallsTab__NewCallActionIcon" />}
|
||||||
|
label={i18n('icu:CallsTab__NewCallActionLabel')}
|
||||||
|
onClick={() => {
|
||||||
|
updateSidebarView(CallsTabSidebarView.NewCallView);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ContextMenu
|
||||||
|
i18n={i18n}
|
||||||
|
menuOptions={[
|
||||||
|
{
|
||||||
|
icon: 'CallsTab__ClearCallHistoryIcon',
|
||||||
|
label: i18n('icu:CallsTab__ClearCallHistoryLabel'),
|
||||||
|
onClick: handleOpenClearCallHistoryDialog,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
popperOptions={{
|
||||||
|
placement: 'bottom',
|
||||||
|
strategy: 'absolute',
|
||||||
|
}}
|
||||||
|
portalToRoot
|
||||||
|
>
|
||||||
|
{({ openMenu, onKeyDown }) => {
|
||||||
|
return (
|
||||||
|
<NavSidebarActionButton
|
||||||
|
onClick={openMenu}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
icon={<span className="CallsTab__MoreActionsIcon" />}
|
||||||
|
label={i18n('icu:CallsTab__MoreActionsLabel')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ContextMenu>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sidebarView === CallsTabSidebarView.CallsListView && (
|
||||||
|
<CallsList
|
||||||
|
key={CallsTabSidebarView.CallsListView}
|
||||||
|
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
||||||
|
getCallHistoryGroups={getCallHistoryGroups}
|
||||||
|
getConversation={getConversation}
|
||||||
|
i18n={i18n}
|
||||||
|
selectedCallHistoryGroup={selected?.callHistoryGroup ?? null}
|
||||||
|
onSelectCallHistoryGroup={handleSelectCallHistoryGroup}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sidebarView === CallsTabSidebarView.NewCallView && (
|
||||||
|
<CallsNewCall
|
||||||
|
key={CallsTabSidebarView.NewCallView}
|
||||||
|
activeCall={activeCall}
|
||||||
|
allConversations={allConversations}
|
||||||
|
i18n={i18n}
|
||||||
|
regionCode={regionCode}
|
||||||
|
onSelectConversation={handleSelectConversation}
|
||||||
|
onOutgoingAudioCallInConversation={
|
||||||
|
handleOutgoingAudioCallInConversation
|
||||||
|
}
|
||||||
|
onOutgoingVideoCallInConversation={
|
||||||
|
handleOutgoingVideoCallInConversation
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</NavSidebar>
|
||||||
|
{selected == null ? (
|
||||||
|
<div className="CallsTab__EmptyState">
|
||||||
|
{i18n('icu:CallsTab__EmptyStateText')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="CallsTab__ConversationCallDetails"
|
||||||
|
// Force scrolling to top when a new conversation is selected.
|
||||||
|
key={selected.conversationId}
|
||||||
|
>
|
||||||
|
{renderConversationDetails(
|
||||||
|
selected.conversationId,
|
||||||
|
selected.callHistoryGroup
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{confirmClearCallHistoryDialogOpen && (
|
||||||
|
<ConfirmationDialog
|
||||||
|
dialogName="CallsTab__ConfirmClearCallHistory"
|
||||||
|
i18n={i18n}
|
||||||
|
onClose={handleCloseClearCallHistoryDialog}
|
||||||
|
title={i18n('icu:CallsTab__ConfirmClearCallHistory__Title')}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
style: 'negative',
|
||||||
|
text: i18n(
|
||||||
|
'icu:CallsTab__ConfirmClearCallHistory__ConfirmButton'
|
||||||
|
),
|
||||||
|
action: onClearCallHistory,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{i18n('icu:CallsTab__ConfirmClearCallHistory__Body')}
|
||||||
|
</ConfirmationDialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
68
ts/components/ChatsTab.tsx
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Environment, getEnvironment } from '../environment';
|
||||||
|
import type { LocalizerType } from '../types/I18N';
|
||||||
|
import type { NavTabPanelProps } from './NavTabs';
|
||||||
|
import { WhatsNewLink } from './WhatsNewLink';
|
||||||
|
|
||||||
|
type ChatsTabProps = Readonly<{
|
||||||
|
i18n: LocalizerType;
|
||||||
|
navTabsCollapsed: boolean;
|
||||||
|
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||||
|
prevConversationId: string | undefined;
|
||||||
|
renderConversationView: () => JSX.Element;
|
||||||
|
renderLeftPane: (props: NavTabPanelProps) => JSX.Element;
|
||||||
|
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
|
||||||
|
selectedConversationId: string | undefined;
|
||||||
|
showWhatsNewModal: () => unknown;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function ChatsTab({
|
||||||
|
i18n,
|
||||||
|
navTabsCollapsed,
|
||||||
|
onToggleNavTabsCollapse,
|
||||||
|
prevConversationId,
|
||||||
|
renderConversationView,
|
||||||
|
renderLeftPane,
|
||||||
|
renderMiniPlayer,
|
||||||
|
selectedConversationId,
|
||||||
|
showWhatsNewModal,
|
||||||
|
}: ChatsTabProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div id="LeftPane">
|
||||||
|
{renderLeftPane({
|
||||||
|
collapsed: navTabsCollapsed,
|
||||||
|
onToggleCollapse: onToggleNavTabsCollapse,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="Inbox__conversation-stack">
|
||||||
|
<div id="toast" />
|
||||||
|
{selectedConversationId && (
|
||||||
|
<div
|
||||||
|
className="Inbox__conversation"
|
||||||
|
id={`conversation-${selectedConversationId}`}
|
||||||
|
>
|
||||||
|
{renderConversationView()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!prevConversationId && (
|
||||||
|
<div className="Inbox__no-conversation-open">
|
||||||
|
{renderMiniPlayer({ shouldFlow: false })}
|
||||||
|
<div className="module-splash-screen__logo module-img--128 module-logo-blue" />
|
||||||
|
<h3>
|
||||||
|
{getEnvironment() !== Environment.Staging
|
||||||
|
? i18n('icu:welcomeToSignal')
|
||||||
|
: 'THIS IS A STAGING DESKTOP'}
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
<WhatsNewLink i18n={i18n} showWhatsNewModal={showWhatsNewModal} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -291,13 +291,18 @@ export function ContextMenu<T>({
|
||||||
let buttonNode: JSX.Element;
|
let buttonNode: JSX.Element;
|
||||||
|
|
||||||
if (typeof children === 'function') {
|
if (typeof children === 'function') {
|
||||||
buttonNode = (children as (props: RenderButtonProps) => JSX.Element)({
|
buttonNode = (
|
||||||
openMenu: onClick || handleClick,
|
<>
|
||||||
onKeyDown: handleKeyDown,
|
{(children as (props: RenderButtonProps) => JSX.Element)({
|
||||||
isMenuShowing,
|
openMenu: onClick || handleClick,
|
||||||
ref: setReferenceElement,
|
onKeyDown: handleKeyDown,
|
||||||
menuNode,
|
isMenuShowing,
|
||||||
});
|
ref: setReferenceElement,
|
||||||
|
menuNode,
|
||||||
|
})}
|
||||||
|
{portalNode ? createPortal(menuNode, portalNode) : menuNode}
|
||||||
|
</>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
buttonNode = (
|
buttonNode = (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { assertDev } from '../util/assert';
|
||||||
import type { ParsedE164Type } from '../util/libphonenumberInstance';
|
import type { ParsedE164Type } from '../util/libphonenumberInstance';
|
||||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||||
import { ScrollBehavior } from '../types/Util';
|
import { ScrollBehavior } from '../types/Util';
|
||||||
import { getConversationListWidthBreakpoint } from './_util';
|
import { getNavSidebarWidthBreakpoint } from './_util';
|
||||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||||
import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid';
|
import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid';
|
||||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||||
|
@ -493,7 +493,7 @@ export function ConversationList({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const widthBreakpoint = getConversationListWidthBreakpoint(dimensions.width);
|
const widthBreakpoint = getNavSidebarWidthBreakpoint(dimensions.width);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListView
|
<ListView
|
||||||
|
|
|
@ -84,10 +84,7 @@ const Template: Story<PropsType & { daysAgo?: number }> = ({
|
||||||
{...args}
|
{...args}
|
||||||
firstEnvelopeTimestamp={firstEnvelopeTimestamp}
|
firstEnvelopeTimestamp={firstEnvelopeTimestamp}
|
||||||
envelopeTimestamp={envelopeTimestamp}
|
envelopeTimestamp={envelopeTimestamp}
|
||||||
renderConversationView={() => <div />}
|
|
||||||
renderCustomizingPreferredReactionsModal={() => <div />}
|
renderCustomizingPreferredReactionsModal={() => <div />}
|
||||||
renderLeftPane={() => <div />}
|
|
||||||
renderMiniPlayer={() => <div />}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,19 +3,10 @@
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import React, { useEffect, useState, useMemo } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
|
|
||||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
|
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import { SECOND, DAY } from '../util/durations';
|
import { SECOND, DAY } from '../util/durations';
|
||||||
import { ToastStickerPackInstallFailed } from './ToastStickerPackInstallFailed';
|
import type { SmartNavTabsProps } from '../state/smart/NavTabs';
|
||||||
import { WhatsNewLink } from './WhatsNewLink';
|
|
||||||
import { showToast } from '../util/showToast';
|
|
||||||
import { strictAssert } from '../util/assert';
|
|
||||||
import { TargetedMessageSource } from '../state/ducks/conversationsEnums';
|
|
||||||
import { usePrevious } from '../hooks/usePrevious';
|
|
||||||
import { Environment, getEnvironment } from '../environment';
|
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
firstEnvelopeTimestamp: number | undefined;
|
firstEnvelopeTimestamp: number | undefined;
|
||||||
|
@ -23,18 +14,13 @@ export type PropsType = {
|
||||||
hasInitialLoadCompleted: boolean;
|
hasInitialLoadCompleted: boolean;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
isCustomizingPreferredReactions: boolean;
|
isCustomizingPreferredReactions: boolean;
|
||||||
onConversationClosed: (id: string, reason: string) => unknown;
|
navTabsCollapsed: boolean;
|
||||||
onConversationOpened: (id: string, messageId?: string) => unknown;
|
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => unknown;
|
||||||
renderConversationView: () => JSX.Element;
|
renderCallsTab: () => JSX.Element;
|
||||||
|
renderChatsTab: () => JSX.Element;
|
||||||
renderCustomizingPreferredReactionsModal: () => JSX.Element;
|
renderCustomizingPreferredReactionsModal: () => JSX.Element;
|
||||||
renderLeftPane: () => JSX.Element;
|
renderNavTabs: (props: SmartNavTabsProps) => JSX.Element;
|
||||||
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
|
renderStoriesTab: () => JSX.Element;
|
||||||
scrollToMessage: (conversationId: string, messageId: string) => unknown;
|
|
||||||
selectedConversationId?: string;
|
|
||||||
targetedMessage?: string;
|
|
||||||
targetedMessageSource?: TargetedMessageSource;
|
|
||||||
showConversation: ShowConversationType;
|
|
||||||
showWhatsNewModal: () => unknown;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Inbox({
|
export function Inbox({
|
||||||
|
@ -43,27 +29,17 @@ export function Inbox({
|
||||||
hasInitialLoadCompleted,
|
hasInitialLoadCompleted,
|
||||||
i18n,
|
i18n,
|
||||||
isCustomizingPreferredReactions,
|
isCustomizingPreferredReactions,
|
||||||
onConversationClosed,
|
navTabsCollapsed,
|
||||||
onConversationOpened,
|
onToggleNavTabsCollapse,
|
||||||
renderConversationView,
|
renderCallsTab,
|
||||||
|
renderChatsTab,
|
||||||
renderCustomizingPreferredReactionsModal,
|
renderCustomizingPreferredReactionsModal,
|
||||||
renderLeftPane,
|
renderNavTabs,
|
||||||
renderMiniPlayer,
|
renderStoriesTab,
|
||||||
scrollToMessage,
|
|
||||||
selectedConversationId,
|
|
||||||
targetedMessage,
|
|
||||||
targetedMessageSource,
|
|
||||||
showConversation,
|
|
||||||
showWhatsNewModal,
|
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const [internalHasInitialLoadCompleted, setInternalHasInitialLoadCompleted] =
|
const [internalHasInitialLoadCompleted, setInternalHasInitialLoadCompleted] =
|
||||||
useState(hasInitialLoadCompleted);
|
useState(hasInitialLoadCompleted);
|
||||||
|
|
||||||
const prevConversationId = usePrevious(
|
|
||||||
selectedConversationId,
|
|
||||||
selectedConversationId
|
|
||||||
);
|
|
||||||
|
|
||||||
const now = useMemo(() => Date.now(), []);
|
const now = useMemo(() => Date.now(), []);
|
||||||
const midnight = useMemo(() => {
|
const midnight = useMemo(() => {
|
||||||
const date = new Date(now);
|
const date = new Date(now);
|
||||||
|
@ -74,80 +50,6 @@ export function Inbox({
|
||||||
return date.getTime();
|
return date.getTime();
|
||||||
}, [now]);
|
}, [now]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevConversationId !== selectedConversationId) {
|
|
||||||
if (prevConversationId) {
|
|
||||||
onConversationClosed(prevConversationId, 'opened another conversation');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedConversationId) {
|
|
||||||
onConversationOpened(selectedConversationId, targetedMessage);
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
selectedConversationId &&
|
|
||||||
targetedMessage &&
|
|
||||||
targetedMessageSource !== TargetedMessageSource.Focus
|
|
||||||
) {
|
|
||||||
scrollToMessage(selectedConversationId, targetedMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedConversationId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversation = window.ConversationController.get(
|
|
||||||
selectedConversationId
|
|
||||||
);
|
|
||||||
strictAssert(conversation, 'Conversation must be found');
|
|
||||||
|
|
||||||
conversation.setMarkedUnread(false);
|
|
||||||
}, [
|
|
||||||
onConversationClosed,
|
|
||||||
onConversationOpened,
|
|
||||||
prevConversationId,
|
|
||||||
scrollToMessage,
|
|
||||||
selectedConversationId,
|
|
||||||
targetedMessage,
|
|
||||||
targetedMessageSource,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function refreshConversation({
|
|
||||||
newId,
|
|
||||||
oldId,
|
|
||||||
}: {
|
|
||||||
newId: string;
|
|
||||||
oldId: string;
|
|
||||||
}) {
|
|
||||||
if (prevConversationId === oldId) {
|
|
||||||
showConversation({ conversationId: newId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close current opened conversation to reload the group information once
|
|
||||||
// linked.
|
|
||||||
function unload() {
|
|
||||||
if (!prevConversationId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onConversationClosed(prevConversationId, 'force unload requested');
|
|
||||||
}
|
|
||||||
|
|
||||||
function packInstallFailed() {
|
|
||||||
showToast(ToastStickerPackInstallFailed);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.Whisper.events.on('pack-install-failed', packInstallFailed);
|
|
||||||
window.Whisper.events.on('refreshConversation', refreshConversation);
|
|
||||||
window.Whisper.events.on('setupAsNewDevice', unload);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.Whisper.events.off('pack-install-failed', packInstallFailed);
|
|
||||||
window.Whisper.events.off('refreshConversation', refreshConversation);
|
|
||||||
window.Whisper.events.off('setupAsNewDevice', unload);
|
|
||||||
};
|
|
||||||
}, [onConversationClosed, prevConversationId, showConversation]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (internalHasInitialLoadCompleted) {
|
if (internalHasInitialLoadCompleted) {
|
||||||
return;
|
return;
|
||||||
|
@ -186,12 +88,6 @@ export function Inbox({
|
||||||
setInternalHasInitialLoadCompleted(hasInitialLoadCompleted);
|
setInternalHasInitialLoadCompleted(hasInitialLoadCompleted);
|
||||||
}, [hasInitialLoadCompleted]);
|
}, [hasInitialLoadCompleted]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedConversationId) {
|
|
||||||
window.SignalCI?.handleEvent('empty-inbox:rendered', null);
|
|
||||||
}
|
|
||||||
}, [selectedConversationId]);
|
|
||||||
|
|
||||||
if (!internalHasInitialLoadCompleted) {
|
if (!internalHasInitialLoadCompleted) {
|
||||||
let loadingProgress = 0;
|
let loadingProgress = 0;
|
||||||
if (
|
if (
|
||||||
|
@ -264,37 +160,13 @@ export function Inbox({
|
||||||
<>
|
<>
|
||||||
<div className="Inbox">
|
<div className="Inbox">
|
||||||
<div className="module-title-bar-drag-area" />
|
<div className="module-title-bar-drag-area" />
|
||||||
|
{renderNavTabs({
|
||||||
<div id="LeftPane">{renderLeftPane()}</div>
|
navTabsCollapsed,
|
||||||
|
onToggleNavTabsCollapse,
|
||||||
<div className="Inbox__conversation-stack">
|
renderChatsTab,
|
||||||
<div id="toast" />
|
renderCallsTab,
|
||||||
{selectedConversationId && (
|
renderStoriesTab,
|
||||||
<div
|
})}
|
||||||
className="Inbox__conversation"
|
|
||||||
id={`conversation-${selectedConversationId}`}
|
|
||||||
>
|
|
||||||
{renderConversationView()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!prevConversationId && (
|
|
||||||
<div className="Inbox__no-conversation-open">
|
|
||||||
{renderMiniPlayer({ shouldFlow: false })}
|
|
||||||
<div className="module-splash-screen__logo module-img--128 module-logo-blue" />
|
|
||||||
<h3>
|
|
||||||
{getEnvironment() !== Environment.Staging
|
|
||||||
? i18n('icu:welcomeToSignal')
|
|
||||||
: 'THIS IS A STAGING DESKTOP'}
|
|
||||||
</h3>
|
|
||||||
<p>
|
|
||||||
<WhatsNewLink
|
|
||||||
i18n={i18n}
|
|
||||||
showWhatsNewModal={showWhatsNewModal}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{activeModal}
|
{activeModal}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -165,6 +165,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||||
),
|
),
|
||||||
isUpdateDownloaded,
|
isUpdateDownloaded,
|
||||||
isContactManagementEnabled,
|
isContactManagementEnabled,
|
||||||
|
navTabsCollapsed: boolean('navTabsCollapsed', false),
|
||||||
|
|
||||||
setChallengeStatus: action('setChallengeStatus'),
|
setChallengeStatus: action('setChallengeStatus'),
|
||||||
lookupConversationWithoutUuid: makeFakeLookupConversationWithoutUuid(),
|
lookupConversationWithoutUuid: makeFakeLookupConversationWithoutUuid(),
|
||||||
|
@ -179,7 +180,6 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||||
'onOutgoingVideoCallInConversation'
|
'onOutgoingVideoCallInConversation'
|
||||||
),
|
),
|
||||||
removeConversation: action('removeConversation'),
|
removeConversation: action('removeConversation'),
|
||||||
renderMainHeader: () => <div />,
|
|
||||||
renderMessageSearchResult: (id: string) => (
|
renderMessageSearchResult: (id: string) => (
|
||||||
<MessageSearchResult
|
<MessageSearchResult
|
||||||
body="Lorem ipsum wow"
|
body="Lorem ipsum wow"
|
||||||
|
@ -273,6 +273,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||||
toggleConversationInChooseMembers: action(
|
toggleConversationInChooseMembers: action(
|
||||||
'toggleConversationInChooseMembers'
|
'toggleConversationInChooseMembers'
|
||||||
),
|
),
|
||||||
|
toggleNavTabsCollapse: action('toggleNavTabsCollapse'),
|
||||||
updateSearchTerm: action('updateSearchTerm'),
|
updateSearchTerm: action('updateSearchTerm'),
|
||||||
|
|
||||||
...overrideProps,
|
...overrideProps,
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
// Copyright 2019 Signal Messenger, LLC
|
// Copyright 2019 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useEffect, useCallback, useMemo, useState } from 'react';
|
import React, { useEffect, useCallback, useMemo } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { clamp, isNumber, noop } from 'lodash';
|
import { isNumber } from 'lodash';
|
||||||
|
|
||||||
import type { LeftPaneHelper, ToFindType } from './leftPane/LeftPaneHelper';
|
import type { LeftPaneHelper, ToFindType } from './leftPane/LeftPaneHelper';
|
||||||
import { FindDirection } from './leftPane/LeftPaneHelper';
|
import { FindDirection } from './leftPane/LeftPaneHelper';
|
||||||
|
@ -27,15 +27,8 @@ import { usePrevious } from '../hooks/usePrevious';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
import type { DurationInSeconds } from '../util/durations';
|
import type { DurationInSeconds } from '../util/durations';
|
||||||
import type { WidthBreakpoint } from './_util';
|
import type { WidthBreakpoint } from './_util';
|
||||||
import { getConversationListWidthBreakpoint } from './_util';
|
import { getNavSidebarWidthBreakpoint } from './_util';
|
||||||
import * as KeyboardLayout from '../services/keyboardLayout';
|
import * as KeyboardLayout from '../services/keyboardLayout';
|
||||||
import {
|
|
||||||
MIN_WIDTH,
|
|
||||||
SNAP_WIDTH,
|
|
||||||
MIN_FULL_WIDTH,
|
|
||||||
MAX_WIDTH,
|
|
||||||
getWidthFromPreferredWidth,
|
|
||||||
} from '../util/leftPaneWidth';
|
|
||||||
import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid';
|
import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid';
|
||||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||||
import type { PropsType as UnsupportedOSDialogPropsType } from '../state/smart/UnsupportedOSDialog';
|
import type { PropsType as UnsupportedOSDialogPropsType } from '../state/smart/UnsupportedOSDialog';
|
||||||
|
@ -50,6 +43,12 @@ import type {
|
||||||
SaveAvatarToDiskActionType,
|
SaveAvatarToDiskActionType,
|
||||||
} from '../types/Avatar';
|
} from '../types/Avatar';
|
||||||
import { SizeObserver } from '../hooks/useSizeObserver';
|
import { SizeObserver } from '../hooks/useSizeObserver';
|
||||||
|
import {
|
||||||
|
NavSidebar,
|
||||||
|
NavSidebarActionButton,
|
||||||
|
NavSidebarSearchHeader,
|
||||||
|
} from './NavSidebar';
|
||||||
|
import { ContextMenu } from './ContextMenu';
|
||||||
|
|
||||||
export enum LeftPaneMode {
|
export enum LeftPaneMode {
|
||||||
Inbox,
|
Inbox,
|
||||||
|
@ -114,6 +113,7 @@ export type PropsType = {
|
||||||
composeReplaceAvatar: ReplaceAvatarActionType;
|
composeReplaceAvatar: ReplaceAvatarActionType;
|
||||||
composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
|
composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
|
||||||
createGroup: () => void;
|
createGroup: () => void;
|
||||||
|
navTabsCollapsed: boolean;
|
||||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||||
removeConversation: (conversationId: string) => void;
|
removeConversation: (conversationId: string) => void;
|
||||||
|
@ -132,10 +132,10 @@ export type PropsType = {
|
||||||
startSettingGroupMetadata: () => void;
|
startSettingGroupMetadata: () => void;
|
||||||
toggleComposeEditingAvatar: () => unknown;
|
toggleComposeEditingAvatar: () => unknown;
|
||||||
toggleConversationInChooseMembers: (conversationId: string) => void;
|
toggleConversationInChooseMembers: (conversationId: string) => void;
|
||||||
|
toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||||
updateSearchTerm: (_: string) => void;
|
updateSearchTerm: (_: string) => void;
|
||||||
|
|
||||||
// Render Props
|
// Render Props
|
||||||
renderMainHeader: () => JSX.Element;
|
|
||||||
renderMessageSearchResult: (id: string) => JSX.Element;
|
renderMessageSearchResult: (id: string) => JSX.Element;
|
||||||
renderNetworkStatus: (
|
renderNetworkStatus: (
|
||||||
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||||
|
@ -178,14 +178,15 @@ export function LeftPane({
|
||||||
isUpdateDownloaded,
|
isUpdateDownloaded,
|
||||||
isContactManagementEnabled,
|
isContactManagementEnabled,
|
||||||
modeSpecificProps,
|
modeSpecificProps,
|
||||||
|
navTabsCollapsed,
|
||||||
onOutgoingAudioCallInConversation,
|
onOutgoingAudioCallInConversation,
|
||||||
onOutgoingVideoCallInConversation,
|
onOutgoingVideoCallInConversation,
|
||||||
|
|
||||||
preferredWidthFromStorage,
|
preferredWidthFromStorage,
|
||||||
removeConversation,
|
removeConversation,
|
||||||
renderCaptchaDialog,
|
renderCaptchaDialog,
|
||||||
renderCrashReportDialog,
|
renderCrashReportDialog,
|
||||||
renderExpiredBuildDialog,
|
renderExpiredBuildDialog,
|
||||||
renderMainHeader,
|
|
||||||
renderMessageSearchResult,
|
renderMessageSearchResult,
|
||||||
renderNetworkStatus,
|
renderNetworkStatus,
|
||||||
renderUnsupportedOSDialog,
|
renderUnsupportedOSDialog,
|
||||||
|
@ -195,6 +196,7 @@ export function LeftPane({
|
||||||
searchInConversation,
|
searchInConversation,
|
||||||
selectedConversationId,
|
selectedConversationId,
|
||||||
targetedMessageId,
|
targetedMessageId,
|
||||||
|
toggleNavTabsCollapse,
|
||||||
setChallengeStatus,
|
setChallengeStatus,
|
||||||
setComposeGroupAvatar,
|
setComposeGroupAvatar,
|
||||||
setComposeGroupExpireTimer,
|
setComposeGroupExpireTimer,
|
||||||
|
@ -215,12 +217,6 @@ export function LeftPane({
|
||||||
unsupportedOSDialogType,
|
unsupportedOSDialogType,
|
||||||
updateSearchTerm,
|
updateSearchTerm,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const [preferredWidth, setPreferredWidth] = useState(
|
|
||||||
// This clamp is present just in case we get a bogus value from storage.
|
|
||||||
clamp(preferredWidthFromStorage, MIN_WIDTH, MAX_WIDTH)
|
|
||||||
);
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
|
||||||
|
|
||||||
const previousModeSpecificProps = usePrevious(
|
const previousModeSpecificProps = usePrevious(
|
||||||
modeSpecificProps,
|
modeSpecificProps,
|
||||||
modeSpecificProps
|
modeSpecificProps
|
||||||
|
@ -421,76 +417,6 @@ export function LeftPane({
|
||||||
startSearch,
|
startSearch,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const requiresFullWidth = helper.requiresFullWidth();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isResizing) {
|
|
||||||
return noop;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onMouseMove = (event: MouseEvent) => {
|
|
||||||
let width: number;
|
|
||||||
|
|
||||||
const isRTL = i18n.getLocaleDirection() === 'rtl';
|
|
||||||
const x = isRTL ? window.innerWidth - event.clientX : event.clientX;
|
|
||||||
|
|
||||||
if (requiresFullWidth) {
|
|
||||||
width = Math.max(x, MIN_FULL_WIDTH);
|
|
||||||
} else if (x < SNAP_WIDTH) {
|
|
||||||
width = MIN_WIDTH;
|
|
||||||
} else {
|
|
||||||
width = clamp(x, MIN_FULL_WIDTH, MAX_WIDTH);
|
|
||||||
}
|
|
||||||
setPreferredWidth(Math.min(width, MAX_WIDTH));
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopResizing = () => {
|
|
||||||
setIsResizing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.body.addEventListener('mousemove', onMouseMove);
|
|
||||||
document.body.addEventListener('mouseup', stopResizing);
|
|
||||||
document.body.addEventListener('mouseleave', stopResizing);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.body.removeEventListener('mousemove', onMouseMove);
|
|
||||||
document.body.removeEventListener('mouseup', stopResizing);
|
|
||||||
document.body.removeEventListener('mouseleave', stopResizing);
|
|
||||||
};
|
|
||||||
}, [i18n, isResizing, requiresFullWidth]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isResizing) {
|
|
||||||
return noop;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.classList.add('is-resizing-left-pane');
|
|
||||||
return () => {
|
|
||||||
document.body.classList.remove('is-resizing-left-pane');
|
|
||||||
};
|
|
||||||
}, [isResizing]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isResizing || preferredWidth === preferredWidthFromStorage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
savePreferredLeftPaneWidth(preferredWidth);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
isResizing,
|
|
||||||
preferredWidth,
|
|
||||||
preferredWidthFromStorage,
|
|
||||||
savePreferredLeftPaneWidth,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const preRowsNode = helper.getPreRowsNode({
|
const preRowsNode = helper.getPreRowsNode({
|
||||||
clearConversationSearch,
|
clearConversationSearch,
|
||||||
clearGroupCreationError,
|
clearGroupCreationError,
|
||||||
|
@ -553,11 +479,7 @@ export function LeftPane({
|
||||||
// It also ensures that we scroll to the top when switching views.
|
// It also ensures that we scroll to the top when switching views.
|
||||||
const listKey = preRowsNode ? 1 : 0;
|
const listKey = preRowsNode ? 1 : 0;
|
||||||
|
|
||||||
const width = getWidthFromPreferredWidth(preferredWidth, {
|
const widthBreakpoint = getNavSidebarWidthBreakpoint(300);
|
||||||
requiresFullWidth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const widthBreakpoint = getConversationListWidthBreakpoint(width);
|
|
||||||
|
|
||||||
const commonDialogProps = {
|
const commonDialogProps = {
|
||||||
i18n,
|
i18n,
|
||||||
|
@ -614,127 +536,171 @@ export function LeftPane({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<NavSidebar
|
||||||
className={classNames(
|
title="Chats"
|
||||||
'module-left-pane',
|
hideHeader={
|
||||||
isResizing && 'module-left-pane--is-resizing',
|
modeSpecificProps.mode === LeftPaneMode.Archive ||
|
||||||
`module-left-pane--width-${widthBreakpoint}`,
|
modeSpecificProps.mode === LeftPaneMode.Compose ||
|
||||||
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers &&
|
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers ||
|
||||||
'module-left-pane--mode-choose-group-members',
|
modeSpecificProps.mode === LeftPaneMode.SetGroupMetadata
|
||||||
modeSpecificProps.mode === LeftPaneMode.Compose &&
|
}
|
||||||
'module-left-pane--mode-compose'
|
i18n={i18n}
|
||||||
)}
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
style={{ width }}
|
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
||||||
>
|
preferredLeftPaneWidth={preferredWidthFromStorage}
|
||||||
{/* eslint-enable jsx-a11y/no-static-element-interactions */}
|
requiresFullWidth={false}
|
||||||
<div className="module-left-pane__header">
|
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||||
{helper.getHeaderContents({
|
actions={
|
||||||
i18n,
|
<>
|
||||||
showInbox,
|
<NavSidebarActionButton
|
||||||
startComposing,
|
label={i18n('icu:newConversation')}
|
||||||
showChooseGroupMembers,
|
icon={<span className="module-left-pane__startComposingIcon" />}
|
||||||
}) || renderMainHeader()}
|
onClick={startComposing}
|
||||||
</div>
|
/>
|
||||||
{helper.getSearchInput({
|
<ContextMenu
|
||||||
clearConversationSearch,
|
i18n={i18n}
|
||||||
clearSearch,
|
menuOptions={[
|
||||||
i18n,
|
{
|
||||||
onChangeComposeSearchTerm: event => {
|
label: i18n('icu:avatarMenuViewArchive'),
|
||||||
setComposeSearchTerm(event.target.value);
|
onClick: showArchivedConversations,
|
||||||
},
|
},
|
||||||
updateSearchTerm,
|
]}
|
||||||
showConversation,
|
popperOptions={{
|
||||||
})}
|
placement: 'bottom',
|
||||||
<div className="module-left-pane__dialogs">
|
strategy: 'absolute',
|
||||||
{dialogs.map(({ key, dialog }) => (
|
}}
|
||||||
<React.Fragment key={key}>{dialog}</React.Fragment>
|
portalToRoot
|
||||||
))}
|
>
|
||||||
</div>
|
{({ openMenu, onKeyDown }) => {
|
||||||
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
|
return (
|
||||||
<SizeObserver>
|
<NavSidebarActionButton
|
||||||
{(ref, size) => (
|
onClick={openMenu}
|
||||||
<div className="module-left-pane__list--measure" ref={ref}>
|
onKeyDown={onKeyDown}
|
||||||
<div className="module-left-pane__list--wrapper">
|
icon={<span className="module-left-pane__moreActionsIcon" />}
|
||||||
<div
|
label="More Actions"
|
||||||
aria-live="polite"
|
|
||||||
className="module-left-pane__list"
|
|
||||||
data-supertab
|
|
||||||
key={listKey}
|
|
||||||
role="presentation"
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
<ConversationList
|
|
||||||
dimensions={{
|
|
||||||
width,
|
|
||||||
height: size?.height || 0,
|
|
||||||
}}
|
|
||||||
getPreferredBadge={getPreferredBadge}
|
|
||||||
getRow={getRow}
|
|
||||||
i18n={i18n}
|
|
||||||
onClickArchiveButton={showArchivedConversations}
|
|
||||||
onClickContactCheckbox={(
|
|
||||||
conversationId: string,
|
|
||||||
disabledReason: undefined | ContactCheckboxDisabledReason
|
|
||||||
) => {
|
|
||||||
switch (disabledReason) {
|
|
||||||
case undefined:
|
|
||||||
toggleConversationInChooseMembers(conversationId);
|
|
||||||
break;
|
|
||||||
case ContactCheckboxDisabledReason.AlreadyAdded:
|
|
||||||
case ContactCheckboxDisabledReason.MaximumContactsSelected:
|
|
||||||
// These are no-ops.
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw missingCaseError(disabledReason);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
showUserNotFoundModal={showUserNotFoundModal}
|
|
||||||
setIsFetchingUUID={setIsFetchingUUID}
|
|
||||||
lookupConversationWithoutUuid={lookupConversationWithoutUuid}
|
|
||||||
showConversation={showConversation}
|
|
||||||
blockConversation={blockConversation}
|
|
||||||
onSelectConversation={onSelectConversation}
|
|
||||||
onOutgoingAudioCallInConversation={
|
|
||||||
onOutgoingAudioCallInConversation
|
|
||||||
}
|
|
||||||
onOutgoingVideoCallInConversation={
|
|
||||||
onOutgoingVideoCallInConversation
|
|
||||||
}
|
|
||||||
removeConversation={
|
|
||||||
isContactManagementEnabled ? removeConversation : undefined
|
|
||||||
}
|
|
||||||
renderMessageSearchResult={renderMessageSearchResult}
|
|
||||||
rowCount={helper.getRowCount()}
|
|
||||||
scrollBehavior={scrollBehavior}
|
|
||||||
scrollToRowIndex={rowIndexToScrollTo}
|
|
||||||
scrollable={isScrollable}
|
|
||||||
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
|
|
||||||
showChooseGroupMembers={showChooseGroupMembers}
|
|
||||||
theme={theme}
|
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ContextMenu>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
className={classNames(
|
||||||
|
'module-left-pane',
|
||||||
|
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers &&
|
||||||
|
'module-left-pane--mode-choose-group-members',
|
||||||
|
modeSpecificProps.mode === LeftPaneMode.Compose &&
|
||||||
|
'module-left-pane--mode-compose'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* eslint-enable jsx-a11y/no-static-element-interactions */}
|
||||||
|
<div className="module-left-pane__header">
|
||||||
|
{helper.getHeaderContents({
|
||||||
|
i18n,
|
||||||
|
showInbox,
|
||||||
|
startComposing,
|
||||||
|
showChooseGroupMembers,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<NavSidebarSearchHeader>
|
||||||
|
{helper.getSearchInput({
|
||||||
|
clearConversationSearch,
|
||||||
|
clearSearch,
|
||||||
|
i18n,
|
||||||
|
onChangeComposeSearchTerm: event => {
|
||||||
|
setComposeSearchTerm(event.target.value);
|
||||||
|
},
|
||||||
|
updateSearchTerm,
|
||||||
|
showConversation,
|
||||||
|
})}
|
||||||
|
</NavSidebarSearchHeader>
|
||||||
|
<div className="module-left-pane__dialogs">
|
||||||
|
{dialogs.map(({ key, dialog }) => (
|
||||||
|
<React.Fragment key={key}>{dialog}</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
|
||||||
|
<SizeObserver>
|
||||||
|
{(ref, size) => (
|
||||||
|
<div className="module-left-pane__list--measure" ref={ref}>
|
||||||
|
<div className="module-left-pane__list--wrapper">
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="module-left-pane__list"
|
||||||
|
data-supertab
|
||||||
|
key={listKey}
|
||||||
|
role="presentation"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<ConversationList
|
||||||
|
dimensions={size ?? undefined}
|
||||||
|
getPreferredBadge={getPreferredBadge}
|
||||||
|
getRow={getRow}
|
||||||
|
i18n={i18n}
|
||||||
|
onClickArchiveButton={showArchivedConversations}
|
||||||
|
onClickContactCheckbox={(
|
||||||
|
conversationId: string,
|
||||||
|
disabledReason: undefined | ContactCheckboxDisabledReason
|
||||||
|
) => {
|
||||||
|
switch (disabledReason) {
|
||||||
|
case undefined:
|
||||||
|
toggleConversationInChooseMembers(conversationId);
|
||||||
|
break;
|
||||||
|
case ContactCheckboxDisabledReason.AlreadyAdded:
|
||||||
|
case ContactCheckboxDisabledReason.MaximumContactsSelected:
|
||||||
|
// These are no-ops.
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw missingCaseError(disabledReason);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
showUserNotFoundModal={showUserNotFoundModal}
|
||||||
|
setIsFetchingUUID={setIsFetchingUUID}
|
||||||
|
lookupConversationWithoutUuid={
|
||||||
|
lookupConversationWithoutUuid
|
||||||
|
}
|
||||||
|
showConversation={showConversation}
|
||||||
|
blockConversation={blockConversation}
|
||||||
|
onSelectConversation={onSelectConversation}
|
||||||
|
onOutgoingAudioCallInConversation={
|
||||||
|
onOutgoingAudioCallInConversation
|
||||||
|
}
|
||||||
|
onOutgoingVideoCallInConversation={
|
||||||
|
onOutgoingVideoCallInConversation
|
||||||
|
}
|
||||||
|
removeConversation={
|
||||||
|
isContactManagementEnabled
|
||||||
|
? removeConversation
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
renderMessageSearchResult={renderMessageSearchResult}
|
||||||
|
rowCount={helper.getRowCount()}
|
||||||
|
scrollBehavior={scrollBehavior}
|
||||||
|
scrollToRowIndex={rowIndexToScrollTo}
|
||||||
|
scrollable={isScrollable}
|
||||||
|
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
|
||||||
|
showChooseGroupMembers={showChooseGroupMembers}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</SizeObserver>
|
||||||
|
{footerContents && (
|
||||||
|
<div className="module-left-pane__footer">{footerContents}</div>
|
||||||
)}
|
)}
|
||||||
</SizeObserver>
|
|
||||||
{footerContents && (
|
{challengeStatus !== 'idle' &&
|
||||||
<div className="module-left-pane__footer">{footerContents}</div>
|
renderCaptchaDialog({
|
||||||
)}
|
onSkip() {
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
setChallengeStatus('idle');
|
||||||
<div
|
},
|
||||||
className="module-left-pane__resize-grab-area"
|
})}
|
||||||
onMouseDown={() => {
|
{crashReportCount > 0 && renderCrashReportDialog()}
|
||||||
setIsResizing(true);
|
</nav>
|
||||||
}}
|
</NavSidebar>
|
||||||
/>
|
|
||||||
{challengeStatus !== 'idle' &&
|
|
||||||
renderCaptchaDialog({
|
|
||||||
onSkip() {
|
|
||||||
setChallengeStatus('idle');
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
{crashReportCount > 0 && renderCrashReportDialog()}
|
|
||||||
</nav>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,94 +0,0 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import type { Meta, Story } from '@storybook/react';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import type { PropsType } from './MainHeader';
|
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
|
||||||
import { MainHeader } from './MainHeader';
|
|
||||||
import { ThemeType } from '../types/Util';
|
|
||||||
import { setupI18n } from '../util/setupI18n';
|
|
||||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: 'Components/MainHeader',
|
|
||||||
component: MainHeader,
|
|
||||||
argTypes: {
|
|
||||||
areStoriesEnabled: {
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
avatarPath: {
|
|
||||||
defaultValue: undefined,
|
|
||||||
},
|
|
||||||
hasPendingUpdate: {
|
|
||||||
defaultValue: false,
|
|
||||||
},
|
|
||||||
i18n: {
|
|
||||||
defaultValue: i18n,
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
defaultValue: undefined,
|
|
||||||
},
|
|
||||||
phoneNumber: {
|
|
||||||
defaultValue: undefined,
|
|
||||||
},
|
|
||||||
showArchivedConversations: { action: true },
|
|
||||||
startComposing: { action: true },
|
|
||||||
startUpdate: { action: true },
|
|
||||||
theme: {
|
|
||||||
defaultValue: ThemeType.light,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
toggleProfileEditor: { action: true },
|
|
||||||
toggleStoriesView: { action: true },
|
|
||||||
unreadStoriesCount: {
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as Meta;
|
|
||||||
|
|
||||||
// eslint-disable-next-line react/function-component-definition
|
|
||||||
const Template: Story<PropsType> = args => <MainHeader {...args} />;
|
|
||||||
|
|
||||||
export const Basic = Template.bind({});
|
|
||||||
Basic.args = {};
|
|
||||||
|
|
||||||
export const Name = Template.bind({});
|
|
||||||
{
|
|
||||||
const { name, title } = getDefaultConversation();
|
|
||||||
Name.args = {
|
|
||||||
name,
|
|
||||||
title,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PhoneNumber = Template.bind({});
|
|
||||||
{
|
|
||||||
const { name, e164: phoneNumber } = getDefaultConversation();
|
|
||||||
PhoneNumber.args = {
|
|
||||||
name,
|
|
||||||
phoneNumber,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UpdateAvailable = Template.bind({});
|
|
||||||
UpdateAvailable.args = {
|
|
||||||
hasPendingUpdate: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Stories = Template.bind({});
|
|
||||||
Stories.args = {
|
|
||||||
areStoriesEnabled: true,
|
|
||||||
unreadStoriesCount: 6,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const StoriesOverflow = Template.bind({});
|
|
||||||
StoriesOverflow.args = {
|
|
||||||
areStoriesEnabled: true,
|
|
||||||
unreadStoriesCount: 69,
|
|
||||||
};
|
|
|
@ -1,228 +0,0 @@
|
||||||
// Copyright 2018 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { usePopper } from 'react-popper';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
|
|
||||||
import { showSettings } from '../shims/Whisper';
|
|
||||||
import { Avatar, AvatarSize } from './Avatar';
|
|
||||||
import { AvatarPopup } from './AvatarPopup';
|
|
||||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
|
||||||
import type { AvatarColorType } from '../types/Colors';
|
|
||||||
import type { BadgeType } from '../badges/types';
|
|
||||||
import { handleOutsideClick } from '../util/handleOutsideClick';
|
|
||||||
|
|
||||||
const EMPTY_OBJECT = Object.freeze(Object.create(null));
|
|
||||||
|
|
||||||
export type PropsType = {
|
|
||||||
areStoriesEnabled: boolean;
|
|
||||||
avatarPath?: string;
|
|
||||||
badge?: BadgeType;
|
|
||||||
color?: AvatarColorType;
|
|
||||||
hasPendingUpdate: boolean;
|
|
||||||
i18n: LocalizerType;
|
|
||||||
isMe?: boolean;
|
|
||||||
isVerified?: boolean;
|
|
||||||
name?: string;
|
|
||||||
phoneNumber?: string;
|
|
||||||
profileName?: string;
|
|
||||||
theme: ThemeType;
|
|
||||||
title: string;
|
|
||||||
hasFailedStorySends?: boolean;
|
|
||||||
unreadStoriesCount: number;
|
|
||||||
|
|
||||||
showArchivedConversations: () => void;
|
|
||||||
startComposing: () => void;
|
|
||||||
startUpdate: () => unknown;
|
|
||||||
toggleProfileEditor: () => void;
|
|
||||||
toggleStoriesView: () => unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MainHeader({
|
|
||||||
areStoriesEnabled,
|
|
||||||
avatarPath,
|
|
||||||
badge,
|
|
||||||
color,
|
|
||||||
hasFailedStorySends,
|
|
||||||
hasPendingUpdate,
|
|
||||||
i18n,
|
|
||||||
name,
|
|
||||||
phoneNumber,
|
|
||||||
profileName,
|
|
||||||
showArchivedConversations,
|
|
||||||
startComposing,
|
|
||||||
startUpdate,
|
|
||||||
theme,
|
|
||||||
title,
|
|
||||||
toggleProfileEditor,
|
|
||||||
toggleStoriesView,
|
|
||||||
unreadStoriesCount,
|
|
||||||
}: PropsType): JSX.Element {
|
|
||||||
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
|
|
||||||
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
|
||||||
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const [showAvatarPopup, setShowAvatarPopup] = useState(false);
|
|
||||||
|
|
||||||
const popper = usePopper(targetElement, popperElement, {
|
|
||||||
placement: 'bottom-start',
|
|
||||||
strategy: 'fixed',
|
|
||||||
modifiers: [
|
|
||||||
{
|
|
||||||
name: 'offset',
|
|
||||||
options: {
|
|
||||||
offset: [null, 4],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
document.body.appendChild(div);
|
|
||||||
setPortalElement(div);
|
|
||||||
return () => {
|
|
||||||
div.remove();
|
|
||||||
setPortalElement(null);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return handleOutsideClick(
|
|
||||||
() => {
|
|
||||||
if (!showAvatarPopup) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setShowAvatarPopup(false);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
containerElements: [portalElement, targetElement],
|
|
||||||
name: 'MainHeader.showAvatarPopup',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [portalElement, targetElement, showAvatarPopup]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleGlobalKeyDown(event: KeyboardEvent) {
|
|
||||||
if (showAvatarPopup && event.key === 'Escape') {
|
|
||||||
setShowAvatarPopup(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handleGlobalKeyDown, true);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleGlobalKeyDown, true);
|
|
||||||
};
|
|
||||||
}, [showAvatarPopup]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="module-main-header">
|
|
||||||
<div
|
|
||||||
className="module-main-header__avatar--container"
|
|
||||||
data-supertab
|
|
||||||
ref={setTargetElement}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
aria-expanded={showAvatarPopup}
|
|
||||||
aria-owns="MainHeader__AvatarPopup"
|
|
||||||
acceptedMessageRequest
|
|
||||||
avatarPath={avatarPath}
|
|
||||||
badge={badge}
|
|
||||||
className="module-main-header__avatar"
|
|
||||||
color={color}
|
|
||||||
conversationType="direct"
|
|
||||||
i18n={i18n}
|
|
||||||
isMe
|
|
||||||
phoneNumber={phoneNumber}
|
|
||||||
profileName={profileName}
|
|
||||||
theme={theme}
|
|
||||||
title={title}
|
|
||||||
// `sharedGroupNames` makes no sense for yourself, but
|
|
||||||
// `<Avatar>` needs it to determine blurring.
|
|
||||||
sharedGroupNames={[]}
|
|
||||||
size={AvatarSize.TWENTY_EIGHT}
|
|
||||||
onClick={() => {
|
|
||||||
setShowAvatarPopup(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{hasPendingUpdate && (
|
|
||||||
<div className="module-main-header__avatar--badged" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showAvatarPopup &&
|
|
||||||
portalElement != null &&
|
|
||||||
createPortal(
|
|
||||||
<div
|
|
||||||
id="MainHeader__AvatarPopup"
|
|
||||||
ref={setPopperElement}
|
|
||||||
style={{ ...popper.styles.popper, zIndex: 10 }}
|
|
||||||
{...popper.attributes.popper}
|
|
||||||
>
|
|
||||||
<AvatarPopup
|
|
||||||
acceptedMessageRequest
|
|
||||||
badge={badge}
|
|
||||||
i18n={i18n}
|
|
||||||
isMe
|
|
||||||
color={color}
|
|
||||||
conversationType="direct"
|
|
||||||
name={name}
|
|
||||||
phoneNumber={phoneNumber}
|
|
||||||
profileName={profileName}
|
|
||||||
theme={theme}
|
|
||||||
title={title}
|
|
||||||
avatarPath={avatarPath}
|
|
||||||
hasPendingUpdate={hasPendingUpdate}
|
|
||||||
// See the comment above about `sharedGroupNames`.
|
|
||||||
sharedGroupNames={[]}
|
|
||||||
onEditProfile={() => {
|
|
||||||
toggleProfileEditor();
|
|
||||||
setShowAvatarPopup(false);
|
|
||||||
}}
|
|
||||||
onStartUpdate={() => {
|
|
||||||
startUpdate();
|
|
||||||
setShowAvatarPopup(false);
|
|
||||||
}}
|
|
||||||
onViewPreferences={() => {
|
|
||||||
showSettings();
|
|
||||||
setShowAvatarPopup(false);
|
|
||||||
}}
|
|
||||||
onViewArchive={() => {
|
|
||||||
showArchivedConversations();
|
|
||||||
setShowAvatarPopup(false);
|
|
||||||
}}
|
|
||||||
style={EMPTY_OBJECT}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
portalElement
|
|
||||||
)}
|
|
||||||
<div className="module-main-header__icon-container" data-supertab>
|
|
||||||
{areStoriesEnabled && (
|
|
||||||
<button
|
|
||||||
aria-label={i18n('icu:stories')}
|
|
||||||
className="module-main-header__stories-icon"
|
|
||||||
onClick={toggleStoriesView}
|
|
||||||
title={i18n('icu:stories')}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{hasFailedStorySends && (
|
|
||||||
<span className="module-main-header__stories-badge">!</span>
|
|
||||||
)}
|
|
||||||
{!hasFailedStorySends && unreadStoriesCount ? (
|
|
||||||
<span className="module-main-header__stories-badge">
|
|
||||||
{unreadStoriesCount}
|
|
||||||
</span>
|
|
||||||
) : undefined}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
aria-label={i18n('icu:newConversation')}
|
|
||||||
className="module-main-header__compose-icon"
|
|
||||||
onClick={startComposing}
|
|
||||||
title={i18n('icu:newConversation')}
|
|
||||||
type="button"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
219
ts/components/NavSidebar.tsx
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useMove } from 'react-aria';
|
||||||
|
import { NavTabsToggle } from './NavTabs';
|
||||||
|
import type { LocalizerType } from '../types/I18N';
|
||||||
|
import {
|
||||||
|
MAX_WIDTH,
|
||||||
|
MIN_FULL_WIDTH,
|
||||||
|
MIN_WIDTH,
|
||||||
|
getWidthFromPreferredWidth,
|
||||||
|
} from '../util/leftPaneWidth';
|
||||||
|
import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util';
|
||||||
|
|
||||||
|
export function NavSidebarActionButton({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
onKeyDown,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: ReactNode;
|
||||||
|
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="NavSidebar__ActionButton"
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="NavSidebar__ActionButtonLabel">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavSidebarProps = Readonly<{
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
i18n: LocalizerType;
|
||||||
|
hideHeader?: boolean;
|
||||||
|
navTabsCollapsed: boolean;
|
||||||
|
onBack?: (() => void) | null;
|
||||||
|
onToggleNavTabsCollapse(navTabsCollapsed: boolean): void;
|
||||||
|
preferredLeftPaneWidth: number;
|
||||||
|
requiresFullWidth: boolean;
|
||||||
|
savePreferredLeftPaneWidth: (width: number) => void;
|
||||||
|
title: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
enum DragState {
|
||||||
|
INITIAL,
|
||||||
|
DRAGGING,
|
||||||
|
DRAGEND,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavSidebar({
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
hideHeader,
|
||||||
|
i18n,
|
||||||
|
navTabsCollapsed,
|
||||||
|
onBack,
|
||||||
|
onToggleNavTabsCollapse,
|
||||||
|
preferredLeftPaneWidth,
|
||||||
|
requiresFullWidth,
|
||||||
|
savePreferredLeftPaneWidth,
|
||||||
|
title,
|
||||||
|
}: NavSidebarProps): JSX.Element {
|
||||||
|
const [dragState, setDragState] = useState(DragState.INITIAL);
|
||||||
|
|
||||||
|
const [preferredWidth, setPreferredWidth] = useState(() => {
|
||||||
|
return getWidthFromPreferredWidth(preferredLeftPaneWidth, {
|
||||||
|
requiresFullWidth,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const width = getWidthFromPreferredWidth(preferredWidth, {
|
||||||
|
requiresFullWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
const widthBreakpoint = getNavSidebarWidthBreakpoint(width);
|
||||||
|
|
||||||
|
// `useMove` gives us keyboard and mouse dragging support.
|
||||||
|
const { moveProps } = useMove({
|
||||||
|
onMoveStart() {
|
||||||
|
setDragState(DragState.DRAGGING);
|
||||||
|
},
|
||||||
|
onMoveEnd() {
|
||||||
|
setDragState(DragState.DRAGEND);
|
||||||
|
},
|
||||||
|
onMove(event) {
|
||||||
|
const { deltaX, shiftKey, pointerType } = event;
|
||||||
|
const isKeyboard = pointerType === 'keyboard';
|
||||||
|
const increment = isKeyboard && shiftKey ? 10 : 1;
|
||||||
|
setPreferredWidth(prevWidth => {
|
||||||
|
// Jump minimize for keyboard users
|
||||||
|
if (isKeyboard && prevWidth === MIN_FULL_WIDTH && deltaX < 0) {
|
||||||
|
return MIN_WIDTH;
|
||||||
|
}
|
||||||
|
// Jump maximize for keyboard users
|
||||||
|
if (isKeyboard && prevWidth === MIN_WIDTH && deltaX > 0) {
|
||||||
|
return MIN_FULL_WIDTH;
|
||||||
|
}
|
||||||
|
return prevWidth + deltaX * increment;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Save the preferred width when the drag ends. We can't do this in onMoveEnd
|
||||||
|
// because the width is not updated yet.
|
||||||
|
if (dragState === DragState.DRAGEND) {
|
||||||
|
setPreferredWidth(width);
|
||||||
|
savePreferredLeftPaneWidth(width);
|
||||||
|
setDragState(DragState.INITIAL);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
dragState,
|
||||||
|
preferredLeftPaneWidth,
|
||||||
|
preferredWidth,
|
||||||
|
savePreferredLeftPaneWidth,
|
||||||
|
width,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This effect helps keep the pointer `col-resize` even when you drag past the handle.
|
||||||
|
const className = 'NavSidebar__document--draggingHandle';
|
||||||
|
if (dragState === DragState.DRAGGING) {
|
||||||
|
document.body.classList.add(className);
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove(className);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [dragState]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="navigation"
|
||||||
|
className={classNames('NavSidebar', {
|
||||||
|
'NavSidebar--narrow': widthBreakpoint === WidthBreakpoint.Narrow,
|
||||||
|
})}
|
||||||
|
style={{ width }}
|
||||||
|
>
|
||||||
|
{!hideHeader && (
|
||||||
|
<div className="NavSidebar__Header">
|
||||||
|
{onBack == null && navTabsCollapsed && (
|
||||||
|
<NavTabsToggle
|
||||||
|
i18n={i18n}
|
||||||
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
|
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={classNames('NavSidebar__HeaderContent', {
|
||||||
|
'NavSidebar__HeaderContent--navTabsCollapsed': navTabsCollapsed,
|
||||||
|
'NavSidebar__HeaderContent--withBackButton': onBack != null,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{onBack != null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="link"
|
||||||
|
onClick={onBack}
|
||||||
|
className="NavSidebar__BackButton"
|
||||||
|
>
|
||||||
|
<span className="NavSidebar__BackButtonLabel">
|
||||||
|
{i18n('icu:NavSidebar__BackButtonLabel')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h1
|
||||||
|
className={classNames('NavSidebar__HeaderTitle', {
|
||||||
|
'NavSidebar__HeaderTitle--withBackButton': onBack != null,
|
||||||
|
})}
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{actions && (
|
||||||
|
<div className="NavSidebar__HeaderActions">{actions}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="NavSidebar__Content">{children}</div>
|
||||||
|
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/role-supports-aria-props -- See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/separator_role#focusable_separator */}
|
||||||
|
<div
|
||||||
|
className={classNames('NavSidebar__DragHandle', {
|
||||||
|
'NavSidebar__DragHandle--dragging': dragState === DragState.DRAGGING,
|
||||||
|
})}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-valuemin={MIN_WIDTH}
|
||||||
|
aria-valuemax={preferredLeftPaneWidth}
|
||||||
|
aria-valuenow={MAX_WIDTH}
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/separator_role#focusable_separator
|
||||||
|
tabIndex={0}
|
||||||
|
{...moveProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavSidebarSearchHeader({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
}): JSX.Element {
|
||||||
|
return <div className="NavSidebarSearchHeader">{children}</div>;
|
||||||
|
}
|
352
ts/components/NavTabs.tsx
Normal file
|
@ -0,0 +1,352 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { Key, ReactNode } from 'react';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'react-aria-components';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { usePopper } from 'react-popper';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Avatar, AvatarSize } from './Avatar';
|
||||||
|
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||||
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
|
import type { BadgeType } from '../badges/types';
|
||||||
|
import { AvatarPopup } from './AvatarPopup';
|
||||||
|
import { handleOutsideClick } from '../util/handleOutsideClick';
|
||||||
|
import type { UnreadStats } from '../state/selectors/conversations';
|
||||||
|
import { NavTab } from '../state/ducks/nav';
|
||||||
|
|
||||||
|
type NavTabProps = Readonly<{
|
||||||
|
badge?: ReactNode;
|
||||||
|
iconClassName: string;
|
||||||
|
id: NavTab;
|
||||||
|
label: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function NavTabsItem({ badge, iconClassName, id, label }: NavTabProps) {
|
||||||
|
return (
|
||||||
|
<Tab id={id} data-testid={`NavTabsItem--${id}`} className="NavTabs__Item">
|
||||||
|
<span className="NavTabs__ItemLabel">{label}</span>
|
||||||
|
<span className="NavTabs__ItemButton">
|
||||||
|
<span className="NavTabs__ItemContent">
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
className={`NavTabs__ItemIcon ${iconClassName}`}
|
||||||
|
/>
|
||||||
|
{badge && <span className="NavTabs__ItemBadge">{badge}</span>}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Tab>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavTabPanelProps = Readonly<{
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggleCollapse(collapsed: boolean): void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type NavTabsToggleProps = Readonly<{
|
||||||
|
i18n: LocalizerType;
|
||||||
|
navTabsCollapsed: boolean;
|
||||||
|
onToggleNavTabsCollapse(navTabsCollapsed: boolean): void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function NavTabsToggle({
|
||||||
|
i18n,
|
||||||
|
navTabsCollapsed,
|
||||||
|
onToggleNavTabsCollapse,
|
||||||
|
}: NavTabsToggleProps): JSX.Element {
|
||||||
|
function handleToggle() {
|
||||||
|
onToggleNavTabsCollapse(!navTabsCollapsed);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="NavTabs__Item NavTabs__Toggle"
|
||||||
|
onClick={handleToggle}
|
||||||
|
>
|
||||||
|
<span className="NavTabs__ItemButton">
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
className="NavTabs__ItemIcon NavTabs__ItemIcon--Menu"
|
||||||
|
/>
|
||||||
|
<span className="NavTabs__ItemLabel">
|
||||||
|
{navTabsCollapsed
|
||||||
|
? i18n('icu:NavTabsToggle__showTabs')
|
||||||
|
: i18n('icu:NavTabsToggle__hideTabs')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavTabsProps = Readonly<{
|
||||||
|
badge: BadgeType | undefined;
|
||||||
|
hasFailedStorySends: boolean;
|
||||||
|
hasPendingUpdate: boolean;
|
||||||
|
i18n: LocalizerType;
|
||||||
|
me: ConversationType;
|
||||||
|
navTabsCollapsed: boolean;
|
||||||
|
onShowSettings: () => void;
|
||||||
|
onStartUpdate: () => unknown;
|
||||||
|
onNavTabSelected(tab: NavTab): void;
|
||||||
|
onToggleNavTabsCollapse(collapsed: boolean): void;
|
||||||
|
onToggleProfileEditor: () => void;
|
||||||
|
renderCallsTab(props: NavTabPanelProps): JSX.Element;
|
||||||
|
renderChatsTab(props: NavTabPanelProps): JSX.Element;
|
||||||
|
renderStoriesTab(props: NavTabPanelProps): JSX.Element;
|
||||||
|
selectedNavTab: NavTab;
|
||||||
|
storiesEnabled: boolean;
|
||||||
|
theme: ThemeType;
|
||||||
|
unreadConversationsStats: UnreadStats;
|
||||||
|
unreadStoriesCount: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export function NavTabs({
|
||||||
|
badge,
|
||||||
|
hasFailedStorySends,
|
||||||
|
hasPendingUpdate,
|
||||||
|
i18n,
|
||||||
|
me,
|
||||||
|
navTabsCollapsed,
|
||||||
|
onShowSettings,
|
||||||
|
onStartUpdate,
|
||||||
|
onNavTabSelected,
|
||||||
|
onToggleNavTabsCollapse,
|
||||||
|
onToggleProfileEditor,
|
||||||
|
renderCallsTab,
|
||||||
|
renderChatsTab,
|
||||||
|
renderStoriesTab,
|
||||||
|
selectedNavTab,
|
||||||
|
storiesEnabled,
|
||||||
|
theme,
|
||||||
|
unreadConversationsStats,
|
||||||
|
unreadStoriesCount,
|
||||||
|
}: NavTabsProps): JSX.Element {
|
||||||
|
function handleSelectionChange(key: Key) {
|
||||||
|
onNavTabSelected(key as NavTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
|
||||||
|
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
||||||
|
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const [showAvatarPopup, setShowAvatarPopup] = useState(false);
|
||||||
|
|
||||||
|
const popper = usePopper(targetElement, popperElement, {
|
||||||
|
placement: 'bottom-start',
|
||||||
|
strategy: 'fixed',
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: 'offset',
|
||||||
|
options: {
|
||||||
|
offset: [null, 4],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
document.body.appendChild(div);
|
||||||
|
setPortalElement(div);
|
||||||
|
return () => {
|
||||||
|
div.remove();
|
||||||
|
setPortalElement(null);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return handleOutsideClick(
|
||||||
|
() => {
|
||||||
|
if (!showAvatarPopup) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setShowAvatarPopup(false);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
containerElements: [portalElement, targetElement],
|
||||||
|
name: 'MainHeader.showAvatarPopup',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [portalElement, targetElement, showAvatarPopup]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleGlobalKeyDown(event: KeyboardEvent) {
|
||||||
|
if (showAvatarPopup && event.key === 'Escape') {
|
||||||
|
setShowAvatarPopup(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleGlobalKeyDown, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleGlobalKeyDown, true);
|
||||||
|
};
|
||||||
|
}, [showAvatarPopup]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs orientation="vertical" className="NavTabs__Container">
|
||||||
|
<nav
|
||||||
|
className={classNames('NavTabs', {
|
||||||
|
'NavTabs--collapsed': navTabsCollapsed,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<NavTabsToggle
|
||||||
|
i18n={i18n}
|
||||||
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
|
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
||||||
|
/>
|
||||||
|
<TabList
|
||||||
|
className="NavTabs__TabList"
|
||||||
|
selectedKey={selectedNavTab}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
>
|
||||||
|
<NavTabsItem
|
||||||
|
id={NavTab.Chats}
|
||||||
|
label="Chats"
|
||||||
|
iconClassName="NavTabs__ItemIcon--Chats"
|
||||||
|
badge={
|
||||||
|
// eslint-disable-next-line no-nested-ternary
|
||||||
|
unreadConversationsStats.unreadCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="NavTabs__ItemIconLabel">
|
||||||
|
{i18n('icu:NavTabs__ItemIconLabel--UnreadCount', {
|
||||||
|
count: unreadConversationsStats.unreadCount,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden>
|
||||||
|
{unreadConversationsStats.unreadCount}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : unreadConversationsStats.markedUnread ? (
|
||||||
|
<span className="NavTabs__ItemIconLabel">
|
||||||
|
{i18n('icu:NavTabs__ItemIconLabel--MarkedUnread')}
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NavTabsItem
|
||||||
|
id={NavTab.Calls}
|
||||||
|
label="Calls"
|
||||||
|
iconClassName="NavTabs__ItemIcon--Calls"
|
||||||
|
/>
|
||||||
|
{storiesEnabled && (
|
||||||
|
<NavTabsItem
|
||||||
|
id={NavTab.Stories}
|
||||||
|
label="Stories"
|
||||||
|
iconClassName="NavTabs__ItemIcon--Stories"
|
||||||
|
badge={
|
||||||
|
// eslint-disable-next-line no-nested-ternary
|
||||||
|
hasFailedStorySends
|
||||||
|
? '!'
|
||||||
|
: unreadStoriesCount > 0
|
||||||
|
? unreadStoriesCount
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabList>
|
||||||
|
<div className="NavTabs__Misc">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="NavTabs__Item"
|
||||||
|
onClick={onShowSettings}
|
||||||
|
>
|
||||||
|
<span className="NavTabs__ItemButton">
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
className="NavTabs__ItemIcon NavTabs__ItemIcon--Settings"
|
||||||
|
/>
|
||||||
|
<span className="NavTabs__ItemLabel">
|
||||||
|
{i18n('icu:NavTabs__ItemLabel--Settings')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="NavTabs__Item"
|
||||||
|
data-supertab
|
||||||
|
onClick={() => {
|
||||||
|
setShowAvatarPopup(true);
|
||||||
|
}}
|
||||||
|
aria-label={i18n('icu:NavTabs__ItemLabel--Profile')}
|
||||||
|
>
|
||||||
|
<span className="NavTabs__ItemButton" ref={setTargetElement}>
|
||||||
|
<span className="NavTabs__ItemContent">
|
||||||
|
<Avatar
|
||||||
|
acceptedMessageRequest
|
||||||
|
avatarPath={me.avatarPath}
|
||||||
|
badge={badge}
|
||||||
|
className="module-main-header__avatar"
|
||||||
|
color={me.color}
|
||||||
|
conversationType="direct"
|
||||||
|
i18n={i18n}
|
||||||
|
isMe
|
||||||
|
phoneNumber={me.phoneNumber}
|
||||||
|
profileName={me.profileName}
|
||||||
|
theme={theme}
|
||||||
|
title={me.title}
|
||||||
|
// `sharedGroupNames` makes no sense for yourself, but
|
||||||
|
// `<Avatar>` needs it to determine blurring.
|
||||||
|
sharedGroupNames={[]}
|
||||||
|
size={AvatarSize.TWENTY_EIGHT}
|
||||||
|
/>
|
||||||
|
{hasPendingUpdate && <div className="NavTabs__AvatarBadge" />}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{showAvatarPopup &&
|
||||||
|
portalElement != null &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
id="MainHeader__AvatarPopup"
|
||||||
|
ref={setPopperElement}
|
||||||
|
style={{ ...popper.styles.popper, zIndex: 10 }}
|
||||||
|
{...popper.attributes.popper}
|
||||||
|
>
|
||||||
|
<AvatarPopup
|
||||||
|
acceptedMessageRequest
|
||||||
|
badge={badge}
|
||||||
|
i18n={i18n}
|
||||||
|
isMe
|
||||||
|
color={me.color}
|
||||||
|
conversationType="direct"
|
||||||
|
name={me.name}
|
||||||
|
phoneNumber={me.phoneNumber}
|
||||||
|
profileName={me.profileName}
|
||||||
|
theme={theme}
|
||||||
|
title={me.title}
|
||||||
|
avatarPath={me.avatarPath}
|
||||||
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
|
// See the comment above about `sharedGroupNames`.
|
||||||
|
sharedGroupNames={[]}
|
||||||
|
onEditProfile={() => {
|
||||||
|
onToggleProfileEditor();
|
||||||
|
setShowAvatarPopup(false);
|
||||||
|
}}
|
||||||
|
onStartUpdate={() => {
|
||||||
|
onStartUpdate();
|
||||||
|
setShowAvatarPopup(false);
|
||||||
|
}}
|
||||||
|
style={{}}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
portalElement
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<TabPanels>
|
||||||
|
<TabPanel id={NavTab.Chats} className="NavTabs__TabPanel">
|
||||||
|
{renderChatsTab}
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id={NavTab.Calls} className="NavTabs__TabPanel">
|
||||||
|
{renderCallsTab}
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id={NavTab.Stories} className="NavTabs__TabPanel">
|
||||||
|
{renderStoriesTab}
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
|
@ -7,7 +7,6 @@ import type { LocalizerType } from '../types/Util';
|
||||||
import { Button, ButtonVariant } from './Button';
|
import { Button, ButtonVariant } from './Button';
|
||||||
import { Intl } from './Intl';
|
import { Intl } from './Intl';
|
||||||
import { Modal } from './Modal';
|
import { Modal } from './Modal';
|
||||||
import { STORIES_COLOR_THEME } from './Stories';
|
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
@ -24,7 +23,6 @@ export function SignalConnectionsModal({
|
||||||
hasXButton
|
hasXButton
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
theme={STORIES_COLOR_THEME}
|
|
||||||
>
|
>
|
||||||
<div className="SignalConnectionsModal">
|
<div className="SignalConnectionsModal">
|
||||||
<i className="SignalConnectionsModal__icon" />
|
<i className="SignalConnectionsModal__icon" />
|
||||||
|
|
|
@ -7,7 +7,6 @@ import React, { useState, useCallback } from 'react';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
import type { ShowToastAction } from '../state/ducks/toast';
|
import type { ShowToastAction } from '../state/ducks/toast';
|
||||||
import { ContextMenu } from './ContextMenu';
|
import { ContextMenu } from './ContextMenu';
|
||||||
import { Theme } from '../util/theme';
|
|
||||||
import { ToastType } from '../types/Toast';
|
import { ToastType } from '../types/Toast';
|
||||||
import {
|
import {
|
||||||
isVideoGoodForStories,
|
isVideoGoodForStories,
|
||||||
|
@ -109,7 +108,6 @@ export function StoriesAddStoryButton({
|
||||||
placement: 'bottom',
|
placement: 'bottom',
|
||||||
strategy: 'absolute',
|
strategy: 'absolute',
|
||||||
}}
|
}}
|
||||||
theme={Theme.Dark}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|
|
@ -10,18 +10,15 @@ import type {
|
||||||
ShowConversationType,
|
ShowConversationType,
|
||||||
} from '../state/ducks/conversations';
|
} from '../state/ducks/conversations';
|
||||||
import type { ConversationStoryType, MyStoryType } from '../types/Stories';
|
import type { ConversationStoryType, MyStoryType } from '../types/Stories';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||||
import type { ShowToastAction } from '../state/ducks/toast';
|
import type { ShowToastAction } from '../state/ducks/toast';
|
||||||
import type { ViewUserStoriesActionCreatorType } from '../state/ducks/stories';
|
import type { ViewUserStoriesActionCreatorType } from '../state/ducks/stories';
|
||||||
import { ContextMenu } from './ContextMenu';
|
|
||||||
import { MyStoryButton } from './MyStoryButton';
|
import { MyStoryButton } from './MyStoryButton';
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
import { StoriesAddStoryButton } from './StoriesAddStoryButton';
|
|
||||||
import { StoryListItem } from './StoryListItem';
|
import { StoryListItem } from './StoryListItem';
|
||||||
import { Theme } from '../util/theme';
|
|
||||||
import { isNotNil } from '../util/isNotNil';
|
import { isNotNil } from '../util/isNotNil';
|
||||||
import { useRestoreFocus } from '../hooks/useRestoreFocus';
|
import { NavSidebarSearchHeader } from './NavSidebar';
|
||||||
|
|
||||||
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
|
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
|
||||||
getFn: (story, path) => {
|
getFn: (story, path) => {
|
||||||
|
@ -70,8 +67,8 @@ export type PropsType = {
|
||||||
showConversation: ShowConversationType;
|
showConversation: ShowConversationType;
|
||||||
showToast: ShowToastAction;
|
showToast: ShowToastAction;
|
||||||
stories: Array<ConversationStoryType>;
|
stories: Array<ConversationStoryType>;
|
||||||
|
theme: ThemeType;
|
||||||
toggleHideStories: (conversationId: string) => unknown;
|
toggleHideStories: (conversationId: string) => unknown;
|
||||||
toggleStoriesView: () => unknown;
|
|
||||||
viewUserStories: ViewUserStoriesActionCreatorType;
|
viewUserStories: ViewUserStoriesActionCreatorType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -84,14 +81,13 @@ export function StoriesPane({
|
||||||
myStories,
|
myStories,
|
||||||
onAddStory,
|
onAddStory,
|
||||||
onMyStoriesClicked,
|
onMyStoriesClicked,
|
||||||
onStoriesSettings,
|
|
||||||
onMediaPlaybackStart,
|
onMediaPlaybackStart,
|
||||||
queueStoryDownload,
|
queueStoryDownload,
|
||||||
showConversation,
|
showConversation,
|
||||||
showToast,
|
showToast,
|
||||||
stories,
|
stories,
|
||||||
|
theme,
|
||||||
toggleHideStories,
|
toggleHideStories,
|
||||||
toggleStoriesView,
|
|
||||||
viewUserStories,
|
viewUserStories,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
@ -106,55 +102,18 @@ export function StoriesPane({
|
||||||
setRenderedStories(stories);
|
setRenderedStories(stories);
|
||||||
}
|
}
|
||||||
}, [searchTerm, stories]);
|
}, [searchTerm, stories]);
|
||||||
|
|
||||||
const [focusRef] = useRestoreFocus();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="Stories__pane__header">
|
<NavSidebarSearchHeader>
|
||||||
<button
|
<SearchInput
|
||||||
ref={focusRef}
|
|
||||||
aria-label={i18n('icu:back')}
|
|
||||||
className="Stories__pane__header--back"
|
|
||||||
onClick={toggleStoriesView}
|
|
||||||
tabIndex={0}
|
|
||||||
type="button"
|
|
||||||
/>
|
|
||||||
<div className="Stories__pane__header--title">
|
|
||||||
{i18n('icu:Stories__title')}
|
|
||||||
</div>
|
|
||||||
<StoriesAddStoryButton
|
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
onChange={event => {
|
||||||
moduleClassName="Stories__pane__add-story"
|
setSearchTerm(event.target.value);
|
||||||
onAddStory={onAddStory}
|
|
||||||
showToast={showToast}
|
|
||||||
/>
|
|
||||||
<ContextMenu
|
|
||||||
i18n={i18n}
|
|
||||||
menuOptions={[
|
|
||||||
{
|
|
||||||
label: i18n('icu:StoriesSettings__context-menu'),
|
|
||||||
onClick: () => onStoriesSettings(),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
moduleClassName="Stories__pane__settings"
|
|
||||||
popperOptions={{
|
|
||||||
placement: 'bottom',
|
|
||||||
strategy: 'absolute',
|
|
||||||
}}
|
}}
|
||||||
theme={Theme.Dark}
|
placeholder={i18n('icu:search')}
|
||||||
|
value={searchTerm}
|
||||||
/>
|
/>
|
||||||
</div>
|
</NavSidebarSearchHeader>
|
||||||
<SearchInput
|
|
||||||
i18n={i18n}
|
|
||||||
moduleClassName="Stories__search"
|
|
||||||
onChange={event => {
|
|
||||||
setSearchTerm(event.target.value);
|
|
||||||
}}
|
|
||||||
placeholder={i18n('icu:search')}
|
|
||||||
value={searchTerm}
|
|
||||||
/>
|
|
||||||
<div className="Stories__pane__list">
|
<div className="Stories__pane__list">
|
||||||
<MyStoryButton
|
<MyStoryButton
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
@ -178,12 +137,12 @@ export function StoriesPane({
|
||||||
key={story.storyView.timestamp}
|
key={story.storyView.timestamp}
|
||||||
onGoToConversation={conversationId => {
|
onGoToConversation={conversationId => {
|
||||||
showConversation({ conversationId });
|
showConversation({ conversationId });
|
||||||
toggleStoriesView();
|
|
||||||
}}
|
}}
|
||||||
onHideStory={toggleHideStories}
|
onHideStory={toggleHideStories}
|
||||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||||
queueStoryDownload={queueStoryDownload}
|
queueStoryDownload={queueStoryDownload}
|
||||||
story={story.storyView}
|
story={story.storyView}
|
||||||
|
theme={theme}
|
||||||
viewUserStories={viewUserStories}
|
viewUserStories={viewUserStories}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -191,6 +150,7 @@ export function StoriesPane({
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className={classNames('Stories__hidden-stories', {
|
className={classNames('Stories__hidden-stories', {
|
||||||
|
'Stories__hidden-stories--collapsed': !isShowingHiddenStories,
|
||||||
'Stories__hidden-stories--expanded': isShowingHiddenStories,
|
'Stories__hidden-stories--expanded': isShowingHiddenStories,
|
||||||
})}
|
})}
|
||||||
onClick={() => setIsShowingHiddenStories(!isShowingHiddenStories)}
|
onClick={() => setIsShowingHiddenStories(!isShowingHiddenStories)}
|
||||||
|
@ -209,12 +169,12 @@ export function StoriesPane({
|
||||||
key={story.storyView.timestamp}
|
key={story.storyView.timestamp}
|
||||||
onGoToConversation={conversationId => {
|
onGoToConversation={conversationId => {
|
||||||
showConversation({ conversationId });
|
showConversation({ conversationId });
|
||||||
toggleStoriesView();
|
|
||||||
}}
|
}}
|
||||||
onHideStory={toggleHideStories}
|
onHideStory={toggleHideStories}
|
||||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||||
queueStoryDownload={queueStoryDownload}
|
queueStoryDownload={queueStoryDownload}
|
||||||
story={story.storyView}
|
story={story.storyView}
|
||||||
|
theme={theme}
|
||||||
viewUserStories={viewUserStories}
|
viewUserStories={viewUserStories}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -69,7 +69,6 @@ export type PropsType = {
|
||||||
setMyStoriesToAllSignalConnections: () => unknown;
|
setMyStoriesToAllSignalConnections: () => unknown;
|
||||||
storyViewReceiptsEnabled: boolean;
|
storyViewReceiptsEnabled: boolean;
|
||||||
toggleSignalConnectionsModal: () => unknown;
|
toggleSignalConnectionsModal: () => unknown;
|
||||||
toggleStoriesView: () => void;
|
|
||||||
setStoriesDisabled: (value: boolean) => void;
|
setStoriesDisabled: (value: boolean) => void;
|
||||||
getConversationByUuid: (uuid: UUIDStringType) => ConversationType | undefined;
|
getConversationByUuid: (uuid: UUIDStringType) => ConversationType | undefined;
|
||||||
};
|
};
|
||||||
|
@ -256,7 +255,6 @@ export function StoriesSettingsModal({
|
||||||
setMyStoriesToAllSignalConnections,
|
setMyStoriesToAllSignalConnections,
|
||||||
storyViewReceiptsEnabled,
|
storyViewReceiptsEnabled,
|
||||||
toggleSignalConnectionsModal,
|
toggleSignalConnectionsModal,
|
||||||
toggleStoriesView,
|
|
||||||
setStoriesDisabled,
|
setStoriesDisabled,
|
||||||
getConversationByUuid,
|
getConversationByUuid,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
|
@ -463,7 +461,6 @@ export function StoriesSettingsModal({
|
||||||
variant={ButtonVariant.SecondaryDestructive}
|
variant={ButtonVariant.SecondaryDestructive}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setStoriesDisabled(true);
|
setStoriesDisabled(true);
|
||||||
toggleStoriesView();
|
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -4,8 +4,8 @@
|
||||||
import type { Meta, Story } from '@storybook/react';
|
import type { Meta, Story } from '@storybook/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import type { PropsType } from './Stories';
|
import type { PropsType } from './StoriesTab';
|
||||||
import { Stories } from './Stories';
|
import { StoriesTab } from './StoriesTab';
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
import { setupI18n } from '../util/setupI18n';
|
import { setupI18n } from '../util/setupI18n';
|
||||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||||
|
@ -18,8 +18,8 @@ import * as durations from '../util/durations';
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Components/Stories',
|
title: 'Components/StoriesTab',
|
||||||
component: Stories,
|
component: StoriesTab,
|
||||||
argTypes: {
|
argTypes: {
|
||||||
deleteStoryForEveryone: { action: true },
|
deleteStoryForEveryone: { action: true },
|
||||||
getPreferredBadge: { action: true },
|
getPreferredBadge: { action: true },
|
||||||
|
@ -63,7 +63,7 @@ export default {
|
||||||
} as Meta;
|
} as Meta;
|
||||||
|
|
||||||
// eslint-disable-next-line react/function-component-definition
|
// eslint-disable-next-line react/function-component-definition
|
||||||
const Template: Story<PropsType> = args => <Stories {...args} />;
|
const Template: Story<PropsType> = args => <StoriesTab {...args} />;
|
||||||
|
|
||||||
export const Blank = Template.bind({});
|
export const Blank = Template.bind({});
|
||||||
Blank.args = {};
|
Blank.args = {};
|
|
@ -2,7 +2,6 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import classNames from 'classnames';
|
|
||||||
import type {
|
import type {
|
||||||
ConversationType,
|
ConversationType,
|
||||||
ShowConversationType,
|
ShowConversationType,
|
||||||
|
@ -12,7 +11,7 @@ import type {
|
||||||
MyStoryType,
|
MyStoryType,
|
||||||
StoryViewType,
|
StoryViewType,
|
||||||
} from '../types/Stories';
|
} from '../types/Stories';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||||
import type { ShowToastAction } from '../state/ducks/toast';
|
import type { ShowToastAction } from '../state/ducks/toast';
|
||||||
import type {
|
import type {
|
||||||
|
@ -22,9 +21,9 @@ import type {
|
||||||
} from '../state/ducks/stories';
|
} from '../state/ducks/stories';
|
||||||
import { MyStories } from './MyStories';
|
import { MyStories } from './MyStories';
|
||||||
import { StoriesPane } from './StoriesPane';
|
import { StoriesPane } from './StoriesPane';
|
||||||
import { Theme, themeClassName } from '../util/theme';
|
import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
|
||||||
import { getWidthFromPreferredWidth } from '../util/leftPaneWidth';
|
import { StoriesAddStoryButton } from './StoriesAddStoryButton';
|
||||||
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
import { ContextMenu } from './ContextMenu';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
addStoryData: AddStoryData;
|
addStoryData: AddStoryData;
|
||||||
|
@ -38,90 +37,132 @@ export type PropsType = {
|
||||||
maxAttachmentSizeInKb: number;
|
maxAttachmentSizeInKb: number;
|
||||||
me: ConversationType;
|
me: ConversationType;
|
||||||
myStories: Array<MyStoryType>;
|
myStories: Array<MyStoryType>;
|
||||||
|
navTabsCollapsed: boolean;
|
||||||
onForwardStory: (storyId: string) => unknown;
|
onForwardStory: (storyId: string) => unknown;
|
||||||
onSaveStory: (story: StoryViewType) => unknown;
|
onSaveStory: (story: StoryViewType) => unknown;
|
||||||
|
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||||
onMediaPlaybackStart: () => void;
|
onMediaPlaybackStart: () => void;
|
||||||
|
preferredLeftPaneWidth: number;
|
||||||
preferredWidthFromStorage: number;
|
preferredWidthFromStorage: number;
|
||||||
queueStoryDownload: (storyId: string) => unknown;
|
queueStoryDownload: (storyId: string) => unknown;
|
||||||
renderStoryCreator: () => JSX.Element;
|
renderStoryCreator: () => JSX.Element;
|
||||||
retryMessageSend: (messageId: string) => unknown;
|
retryMessageSend: (messageId: string) => unknown;
|
||||||
|
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
|
||||||
setAddStoryData: (data: AddStoryData) => unknown;
|
setAddStoryData: (data: AddStoryData) => unknown;
|
||||||
showConversation: ShowConversationType;
|
showConversation: ShowConversationType;
|
||||||
showStoriesSettings: () => unknown;
|
showStoriesSettings: () => unknown;
|
||||||
showToast: ShowToastAction;
|
showToast: ShowToastAction;
|
||||||
stories: Array<ConversationStoryType>;
|
stories: Array<ConversationStoryType>;
|
||||||
|
theme: ThemeType;
|
||||||
toggleHideStories: (conversationId: string) => unknown;
|
toggleHideStories: (conversationId: string) => unknown;
|
||||||
toggleStoriesView: () => unknown;
|
|
||||||
viewStory: ViewStoryActionCreatorType;
|
viewStory: ViewStoryActionCreatorType;
|
||||||
viewUserStories: ViewUserStoriesActionCreatorType;
|
viewUserStories: ViewUserStoriesActionCreatorType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STORIES_COLOR_THEME = Theme.Dark;
|
export function StoriesTab({
|
||||||
|
|
||||||
export function Stories({
|
|
||||||
addStoryData,
|
addStoryData,
|
||||||
deleteStoryForEveryone,
|
deleteStoryForEveryone,
|
||||||
getPreferredBadge,
|
getPreferredBadge,
|
||||||
hasViewReceiptSetting,
|
hasViewReceiptSetting,
|
||||||
hiddenStories,
|
hiddenStories,
|
||||||
i18n,
|
i18n,
|
||||||
isStoriesSettingsVisible,
|
|
||||||
isViewingStory,
|
|
||||||
maxAttachmentSizeInKb,
|
maxAttachmentSizeInKb,
|
||||||
me,
|
me,
|
||||||
myStories,
|
myStories,
|
||||||
|
navTabsCollapsed,
|
||||||
onForwardStory,
|
onForwardStory,
|
||||||
onSaveStory,
|
onSaveStory,
|
||||||
|
onToggleNavTabsCollapse,
|
||||||
onMediaPlaybackStart,
|
onMediaPlaybackStart,
|
||||||
preferredWidthFromStorage,
|
preferredLeftPaneWidth,
|
||||||
queueStoryDownload,
|
queueStoryDownload,
|
||||||
renderStoryCreator,
|
renderStoryCreator,
|
||||||
retryMessageSend,
|
retryMessageSend,
|
||||||
|
savePreferredLeftPaneWidth,
|
||||||
setAddStoryData,
|
setAddStoryData,
|
||||||
showConversation,
|
showConversation,
|
||||||
showStoriesSettings,
|
showStoriesSettings,
|
||||||
showToast,
|
showToast,
|
||||||
stories,
|
stories,
|
||||||
|
theme,
|
||||||
toggleHideStories,
|
toggleHideStories,
|
||||||
toggleStoriesView,
|
|
||||||
viewStory,
|
viewStory,
|
||||||
viewUserStories,
|
viewUserStories,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const width = getWidthFromPreferredWidth(preferredWidthFromStorage, {
|
|
||||||
requiresFullWidth: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isMyStories, setIsMyStories] = useState(false);
|
const [isMyStories, setIsMyStories] = useState(false);
|
||||||
|
|
||||||
// only handle ESC if not showing a child that handles their own ESC
|
function onAddStory(file?: File) {
|
||||||
useEscapeHandling(
|
if (file) {
|
||||||
(isMyStories && myStories.length) ||
|
setAddStoryData({ type: 'Media', file });
|
||||||
isViewingStory ||
|
} else {
|
||||||
isStoriesSettingsVisible ||
|
setAddStoryData({ type: 'Text' });
|
||||||
addStoryData
|
}
|
||||||
? undefined
|
}
|
||||||
: toggleStoriesView
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('Stories', themeClassName(STORIES_COLOR_THEME))}>
|
<div className="Stories">
|
||||||
{addStoryData && renderStoryCreator()}
|
{addStoryData && renderStoryCreator()}
|
||||||
<div className="Stories__pane" style={{ width }}>
|
{isMyStories && myStories.length ? (
|
||||||
{isMyStories && myStories.length ? (
|
<MyStories
|
||||||
<MyStories
|
hasViewReceiptSetting={hasViewReceiptSetting}
|
||||||
hasViewReceiptSetting={hasViewReceiptSetting}
|
i18n={i18n}
|
||||||
i18n={i18n}
|
myStories={myStories}
|
||||||
myStories={myStories}
|
onBack={() => setIsMyStories(false)}
|
||||||
onBack={() => setIsMyStories(false)}
|
onDelete={deleteStoryForEveryone}
|
||||||
onDelete={deleteStoryForEveryone}
|
onForward={onForwardStory}
|
||||||
onForward={onForwardStory}
|
onSave={onSaveStory}
|
||||||
onSave={onSaveStory}
|
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
queueStoryDownload={queueStoryDownload}
|
||||||
queueStoryDownload={queueStoryDownload}
|
retryMessageSend={retryMessageSend}
|
||||||
retryMessageSend={retryMessageSend}
|
viewStory={viewStory}
|
||||||
viewStory={viewStory}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<NavSidebar
|
||||||
|
title="Stories"
|
||||||
|
i18n={i18n}
|
||||||
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
|
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
||||||
|
preferredLeftPaneWidth={preferredLeftPaneWidth}
|
||||||
|
requiresFullWidth
|
||||||
|
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<StoriesAddStoryButton
|
||||||
|
i18n={i18n}
|
||||||
|
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
||||||
|
moduleClassName="Stories__pane__add-story"
|
||||||
|
onAddStory={onAddStory}
|
||||||
|
showToast={showToast}
|
||||||
|
/>
|
||||||
|
<ContextMenu
|
||||||
|
i18n={i18n}
|
||||||
|
menuOptions={[
|
||||||
|
{
|
||||||
|
label: i18n('icu:StoriesSettings__context-menu'),
|
||||||
|
onClick: showStoriesSettings,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
moduleClassName="Stories__pane__settings"
|
||||||
|
popperOptions={{
|
||||||
|
placement: 'bottom',
|
||||||
|
strategy: 'absolute',
|
||||||
|
}}
|
||||||
|
portalToRoot
|
||||||
|
>
|
||||||
|
{({ openMenu, onKeyDown }) => {
|
||||||
|
return (
|
||||||
|
<NavSidebarActionButton
|
||||||
|
onClick={openMenu}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
icon={<span className="StoriesTab__MoreActionsIcon" />}
|
||||||
|
label={i18n('icu:StoriesTab__MoreActionsLabel')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ContextMenu>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<StoriesPane
|
<StoriesPane
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
hiddenStories={hiddenStories}
|
hiddenStories={hiddenStories}
|
||||||
|
@ -129,11 +170,7 @@ export function Stories({
|
||||||
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
||||||
me={me}
|
me={me}
|
||||||
myStories={myStories}
|
myStories={myStories}
|
||||||
onAddStory={file =>
|
onAddStory={onAddStory}
|
||||||
file
|
|
||||||
? setAddStoryData({ type: 'Media', file })
|
|
||||||
: setAddStoryData({ type: 'Text' })
|
|
||||||
}
|
|
||||||
onMyStoriesClicked={() => {
|
onMyStoriesClicked={() => {
|
||||||
if (myStories.length) {
|
if (myStories.length) {
|
||||||
setIsMyStories(true);
|
setIsMyStories(true);
|
||||||
|
@ -147,12 +184,12 @@ export function Stories({
|
||||||
showConversation={showConversation}
|
showConversation={showConversation}
|
||||||
showToast={showToast}
|
showToast={showToast}
|
||||||
stories={stories}
|
stories={stories}
|
||||||
|
theme={theme}
|
||||||
toggleHideStories={toggleHideStories}
|
toggleHideStories={toggleHideStories}
|
||||||
toggleStoriesView={toggleStoriesView}
|
|
||||||
viewUserStories={viewUserStories}
|
viewUserStories={viewUserStories}
|
||||||
/>
|
/>
|
||||||
)}
|
</NavSidebar>
|
||||||
</div>
|
)}
|
||||||
<div className="Stories__placeholder">
|
<div className="Stories__placeholder">
|
||||||
<div className="Stories__placeholder__stories" />
|
<div className="Stories__placeholder__stories" />
|
||||||
{i18n('icu:Stories__placeholder--text')}
|
{i18n('icu:Stories__placeholder--text')}
|
|
@ -4,6 +4,7 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { get, has } from 'lodash';
|
import { get, has } from 'lodash';
|
||||||
|
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import type {
|
import type {
|
||||||
AttachmentType,
|
AttachmentType,
|
||||||
InMemoryAttachmentDraftType,
|
InMemoryAttachmentDraftType,
|
||||||
|
@ -26,6 +27,22 @@ import { TextStoryCreator } from './TextStoryCreator';
|
||||||
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
|
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
|
||||||
import type { DraftBodyRanges } from '../types/BodyRange';
|
import type { DraftBodyRanges } from '../types/BodyRange';
|
||||||
|
|
||||||
|
function usePortalElement(testid: string): HTMLDivElement | null {
|
||||||
|
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.dataset.testid = testid;
|
||||||
|
document.body.appendChild(div);
|
||||||
|
setElement(div);
|
||||||
|
return () => {
|
||||||
|
document.body.removeChild(div);
|
||||||
|
};
|
||||||
|
}, [testid]);
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
debouncedMaybeGrabLinkPreview: (
|
debouncedMaybeGrabLinkPreview: (
|
||||||
message: string,
|
message: string,
|
||||||
|
@ -119,7 +136,9 @@ export function StoryCreator({
|
||||||
skinTone,
|
skinTone,
|
||||||
toggleGroupsForStorySend,
|
toggleGroupsForStorySend,
|
||||||
toggleSignalConnectionsModal,
|
toggleSignalConnectionsModal,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element | null {
|
||||||
|
const portalElement = usePortalElement('StoryCreatorPortal');
|
||||||
|
|
||||||
const [draftAttachment, setDraftAttachment] = useState<
|
const [draftAttachment, setDraftAttachment] = useState<
|
||||||
AttachmentType | undefined
|
AttachmentType | undefined
|
||||||
>();
|
>();
|
||||||
|
@ -173,97 +192,100 @@ export function StoryCreator({
|
||||||
}
|
}
|
||||||
}, [draftAttachment, sendStoryModalOpenStateChanged]);
|
}, [draftAttachment, sendStoryModalOpenStateChanged]);
|
||||||
|
|
||||||
return (
|
return portalElement != null
|
||||||
<>
|
? createPortal(
|
||||||
{draftAttachment && isReadyToSend && (
|
<>
|
||||||
<SendStoryModal
|
{draftAttachment && isReadyToSend && (
|
||||||
draftAttachment={draftAttachment}
|
<SendStoryModal
|
||||||
candidateConversations={candidateConversations}
|
draftAttachment={draftAttachment}
|
||||||
distributionLists={distributionLists}
|
candidateConversations={candidateConversations}
|
||||||
getPreferredBadge={getPreferredBadge}
|
distributionLists={distributionLists}
|
||||||
groupConversations={groupConversations}
|
getPreferredBadge={getPreferredBadge}
|
||||||
groupStories={groupStories}
|
groupConversations={groupConversations}
|
||||||
hasFirstStoryPostExperience={hasFirstStoryPostExperience}
|
groupStories={groupStories}
|
||||||
ourConversationId={ourConversationId}
|
hasFirstStoryPostExperience={hasFirstStoryPostExperience}
|
||||||
i18n={i18n}
|
ourConversationId={ourConversationId}
|
||||||
me={me}
|
i18n={i18n}
|
||||||
onClose={() => setDraftAttachment(undefined)}
|
me={me}
|
||||||
onDeleteList={onDeleteList}
|
onClose={() => setDraftAttachment(undefined)}
|
||||||
onDistributionListCreated={onDistributionListCreated}
|
onDeleteList={onDeleteList}
|
||||||
onHideMyStoriesFrom={onHideMyStoriesFrom}
|
onDistributionListCreated={onDistributionListCreated}
|
||||||
onRemoveMembers={onRemoveMembers}
|
onHideMyStoriesFrom={onHideMyStoriesFrom}
|
||||||
onRepliesNReactionsChanged={onRepliesNReactionsChanged}
|
onRemoveMembers={onRemoveMembers}
|
||||||
onSelectedStoryList={onSelectedStoryList}
|
onRepliesNReactionsChanged={onRepliesNReactionsChanged}
|
||||||
onSend={(listIds, groupIds) => {
|
onSelectedStoryList={onSelectedStoryList}
|
||||||
onSend(listIds, groupIds, draftAttachment, bodyRanges);
|
onSend={(listIds, groupIds) => {
|
||||||
setDraftAttachment(undefined);
|
onSend(listIds, groupIds, draftAttachment, bodyRanges);
|
||||||
}}
|
setDraftAttachment(undefined);
|
||||||
onViewersUpdated={onViewersUpdated}
|
}}
|
||||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
onViewersUpdated={onViewersUpdated}
|
||||||
setMyStoriesToAllSignalConnections={
|
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||||
setMyStoriesToAllSignalConnections
|
setMyStoriesToAllSignalConnections={
|
||||||
}
|
setMyStoriesToAllSignalConnections
|
||||||
signalConnections={signalConnections}
|
}
|
||||||
toggleGroupsForStorySend={toggleGroupsForStorySend}
|
signalConnections={signalConnections}
|
||||||
mostRecentActiveStoryTimestampByGroupOrDistributionList={
|
toggleGroupsForStorySend={toggleGroupsForStorySend}
|
||||||
mostRecentActiveStoryTimestampByGroupOrDistributionList
|
mostRecentActiveStoryTimestampByGroupOrDistributionList={
|
||||||
}
|
mostRecentActiveStoryTimestampByGroupOrDistributionList
|
||||||
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
|
}
|
||||||
/>
|
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
|
||||||
)}
|
/>
|
||||||
{draftAttachment && !isReadyToSend && attachmentUrl && (
|
)}
|
||||||
<MediaEditor
|
{draftAttachment && !isReadyToSend && attachmentUrl && (
|
||||||
doneButtonLabel={i18n('icu:next2')}
|
<MediaEditor
|
||||||
i18n={i18n}
|
doneButtonLabel={i18n('icu:next2')}
|
||||||
imageSrc={attachmentUrl}
|
i18n={i18n}
|
||||||
installedPacks={installedPacks}
|
imageSrc={attachmentUrl}
|
||||||
isSending={isSending}
|
installedPacks={installedPacks}
|
||||||
onClose={onClose}
|
isSending={isSending}
|
||||||
supportsCaption
|
onClose={onClose}
|
||||||
renderCompositionTextArea={renderCompositionTextArea}
|
supportsCaption
|
||||||
imageToBlurHash={imageToBlurHash}
|
renderCompositionTextArea={renderCompositionTextArea}
|
||||||
onDone={({
|
imageToBlurHash={imageToBlurHash}
|
||||||
contentType,
|
onDone={({
|
||||||
data,
|
contentType,
|
||||||
blurHash,
|
data,
|
||||||
caption,
|
blurHash,
|
||||||
captionBodyRanges,
|
caption,
|
||||||
}) => {
|
captionBodyRanges,
|
||||||
setDraftAttachment({
|
}) => {
|
||||||
...draftAttachment,
|
setDraftAttachment({
|
||||||
contentType,
|
...draftAttachment,
|
||||||
data,
|
contentType,
|
||||||
size: data.byteLength,
|
data,
|
||||||
blurHash,
|
size: data.byteLength,
|
||||||
caption,
|
blurHash,
|
||||||
});
|
caption,
|
||||||
setBodyRanges(captionBodyRanges);
|
});
|
||||||
setIsReadyToSend(true);
|
setBodyRanges(captionBodyRanges);
|
||||||
}}
|
setIsReadyToSend(true);
|
||||||
recentStickers={recentStickers}
|
}}
|
||||||
/>
|
recentStickers={recentStickers}
|
||||||
)}
|
/>
|
||||||
{!file && (
|
)}
|
||||||
<TextStoryCreator
|
{!file && (
|
||||||
debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview}
|
<TextStoryCreator
|
||||||
i18n={i18n}
|
debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview}
|
||||||
isSending={isSending}
|
i18n={i18n}
|
||||||
linkPreview={linkPreview}
|
isSending={isSending}
|
||||||
onClose={onClose}
|
linkPreview={linkPreview}
|
||||||
onDone={textAttachment => {
|
onClose={onClose}
|
||||||
setDraftAttachment({
|
onDone={textAttachment => {
|
||||||
contentType: TEXT_ATTACHMENT,
|
setDraftAttachment({
|
||||||
textAttachment,
|
contentType: TEXT_ATTACHMENT,
|
||||||
size: textAttachment.text?.length || 0,
|
textAttachment,
|
||||||
});
|
size: textAttachment.text?.length || 0,
|
||||||
setIsReadyToSend(true);
|
});
|
||||||
}}
|
setIsReadyToSend(true);
|
||||||
onUseEmoji={onUseEmoji}
|
}}
|
||||||
onSetSkinTone={onSetSkinTone}
|
onUseEmoji={onUseEmoji}
|
||||||
recentEmojis={recentEmojis}
|
onSetSkinTone={onSetSkinTone}
|
||||||
skinTone={skinTone}
|
recentEmojis={recentEmojis}
|
||||||
/>
|
skinTone={skinTone}
|
||||||
)}
|
/>
|
||||||
</>
|
)}
|
||||||
);
|
</>,
|
||||||
|
portalElement
|
||||||
|
)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import React, { useState } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { ConversationStoryType, StoryViewType } from '../types/Stories';
|
import type { ConversationStoryType, StoryViewType } from '../types/Stories';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||||
import type { ViewUserStoriesActionCreatorType } from '../state/ducks/stories';
|
import type { ViewUserStoriesActionCreatorType } from '../state/ducks/stories';
|
||||||
import { Avatar, AvatarSize } from './Avatar';
|
import { Avatar, AvatarSize } from './Avatar';
|
||||||
|
@ -16,7 +16,6 @@ import { StoryViewTargetType, HasStories } from '../types/Stories';
|
||||||
|
|
||||||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||||
import { StoryImage } from './StoryImage';
|
import { StoryImage } from './StoryImage';
|
||||||
import { ThemeType } from '../types/Util';
|
|
||||||
import { getAvatarColor } from '../types/Colors';
|
import { getAvatarColor } from '../types/Colors';
|
||||||
|
|
||||||
export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
|
export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
|
||||||
|
@ -30,6 +29,7 @@ export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
|
||||||
queueStoryDownload: (storyId: string) => unknown;
|
queueStoryDownload: (storyId: string) => unknown;
|
||||||
onMediaPlaybackStart: () => void;
|
onMediaPlaybackStart: () => void;
|
||||||
story: StoryViewType;
|
story: StoryViewType;
|
||||||
|
theme: ThemeType;
|
||||||
viewUserStories: ViewUserStoriesActionCreatorType;
|
viewUserStories: ViewUserStoriesActionCreatorType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -45,6 +45,7 @@ function StoryListItemAvatar({
|
||||||
profileName,
|
profileName,
|
||||||
sharedGroupNames,
|
sharedGroupNames,
|
||||||
title,
|
title,
|
||||||
|
theme,
|
||||||
}: Pick<
|
}: Pick<
|
||||||
ConversationType,
|
ConversationType,
|
||||||
| 'acceptedMessageRequest'
|
| 'acceptedMessageRequest'
|
||||||
|
@ -59,6 +60,7 @@ function StoryListItemAvatar({
|
||||||
getPreferredBadge: PreferredBadgeSelectorType;
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
isMe?: boolean;
|
isMe?: boolean;
|
||||||
|
theme: ThemeType;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
|
@ -73,7 +75,7 @@ function StoryListItemAvatar({
|
||||||
sharedGroupNames={sharedGroupNames}
|
sharedGroupNames={sharedGroupNames}
|
||||||
size={AvatarSize.FORTY_EIGHT}
|
size={AvatarSize.FORTY_EIGHT}
|
||||||
storyRing={avatarStoryRing}
|
storyRing={avatarStoryRing}
|
||||||
theme={ThemeType.dark}
|
theme={theme}
|
||||||
title={title}
|
title={title}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -92,6 +94,7 @@ export function StoryListItem({
|
||||||
onMediaPlaybackStart,
|
onMediaPlaybackStart,
|
||||||
queueStoryDownload,
|
queueStoryDownload,
|
||||||
story,
|
story,
|
||||||
|
theme,
|
||||||
viewUserStories,
|
viewUserStories,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
|
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
|
||||||
|
@ -167,6 +170,7 @@ export function StoryListItem({
|
||||||
avatarStoryRing={avatarStoryRing}
|
avatarStoryRing={avatarStoryRing}
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
theme={theme}
|
||||||
{...(group || sender)}
|
{...(group || sender)}
|
||||||
/>
|
/>
|
||||||
<div className="StoryListItem__info">
|
<div className="StoryListItem__info">
|
||||||
|
|
|
@ -26,6 +26,8 @@ function getToast(toastType: ToastType): AnyToast {
|
||||||
return { toastType: ToastType.Blocked };
|
return { toastType: ToastType.Blocked };
|
||||||
case ToastType.BlockedGroup:
|
case ToastType.BlockedGroup:
|
||||||
return { toastType: ToastType.BlockedGroup };
|
return { toastType: ToastType.BlockedGroup };
|
||||||
|
case ToastType.CallHistoryCleared:
|
||||||
|
return { toastType: ToastType.CallHistoryCleared };
|
||||||
case ToastType.CannotEditMessage:
|
case ToastType.CannotEditMessage:
|
||||||
return { toastType: ToastType.CannotEditMessage };
|
return { toastType: ToastType.CannotEditMessage };
|
||||||
case ToastType.CannotForwardEmptyMessage:
|
case ToastType.CannotForwardEmptyMessage:
|
||||||
|
|
|
@ -68,6 +68,14 @@ export function ToastManager({
|
||||||
return <Toast onClose={hideToast}>{i18n('icu:unblockGroupToSend')}</Toast>;
|
return <Toast onClose={hideToast}>{i18n('icu:unblockGroupToSend')}</Toast>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toastType === ToastType.CallHistoryCleared) {
|
||||||
|
return (
|
||||||
|
<Toast onClose={hideToast}>
|
||||||
|
{i18n('icu:CallsTab__ToastCallHistoryCleared')}
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (toastType === ToastType.CannotEditMessage) {
|
if (toastType === ToastType.CannotEditMessage) {
|
||||||
return (
|
return (
|
||||||
<Toast onClose={hideToast}>
|
<Toast onClose={hideToast}>
|
||||||
|
|
|
@ -11,7 +11,6 @@ export enum WidthBreakpoint {
|
||||||
Narrow = 'narrow',
|
Narrow = 'narrow',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getConversationListWidthBreakpoint = (
|
export function getNavSidebarWidthBreakpoint(width: number): WidthBreakpoint {
|
||||||
width: number
|
return width >= 150 ? WidthBreakpoint.Wide : WidthBreakpoint.Narrow;
|
||||||
): WidthBreakpoint =>
|
}
|
||||||
width >= 150 ? WidthBreakpoint.Wide : WidthBreakpoint.Narrow;
|
|
||||||
|
|
|
@ -7,9 +7,21 @@ import { action } from '@storybook/addon-actions';
|
||||||
import { setupI18n } from '../../util/setupI18n';
|
import { setupI18n } from '../../util/setupI18n';
|
||||||
import enMessages from '../../../_locales/en/messages.json';
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
import { CallMode } from '../../types/Calling';
|
import { CallMode } from '../../types/Calling';
|
||||||
import { CallingNotification } from './CallingNotification';
|
import { CallingNotification, type PropsType } from './CallingNotification';
|
||||||
import type { CallingNotificationType } from '../../util/callingNotification';
|
import {
|
||||||
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
getDefaultConversation,
|
||||||
|
getDefaultGroup,
|
||||||
|
} from '../../test-both/helpers/getDefaultConversation';
|
||||||
|
import type { CallStatus } from '../../types/CallDisposition';
|
||||||
|
import {
|
||||||
|
CallType,
|
||||||
|
CallDirection,
|
||||||
|
GroupCallStatus,
|
||||||
|
DirectCallStatus,
|
||||||
|
} from '../../types/CallDisposition';
|
||||||
|
import { UUID } from '../../types/UUID';
|
||||||
|
import type { ConversationType } from '../../state/ducks/conversations';
|
||||||
|
import { CallExternalState } from '../../util/callingNotification';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
@ -17,15 +29,59 @@ export default {
|
||||||
title: 'Components/Conversation/CallingNotification',
|
title: 'Components/Conversation/CallingNotification',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCommonProps = () => ({
|
const getCommonProps = (options: {
|
||||||
conversationId: 'fake-conversation-id',
|
mode: CallMode;
|
||||||
i18n,
|
type?: CallType;
|
||||||
isNextItemCallingNotification: false,
|
direction?: CallDirection;
|
||||||
messageId: 'fake-message-id',
|
status?: CallStatus;
|
||||||
now: Date.now(),
|
callCreator?: ConversationType | null;
|
||||||
returnToActiveCall: action('returnToActiveCall'),
|
callExternalState?: CallExternalState;
|
||||||
startCallingLobby: action('startCallingLobby'),
|
}): PropsType => {
|
||||||
});
|
const {
|
||||||
|
mode,
|
||||||
|
type = mode === CallMode.Group ? CallType.Group : CallType.Audio,
|
||||||
|
direction = CallDirection.Outgoing,
|
||||||
|
status = mode === CallMode.Group
|
||||||
|
? GroupCallStatus.GenericGroupCall
|
||||||
|
: DirectCallStatus.Pending,
|
||||||
|
callCreator = getDefaultConversation({
|
||||||
|
uuid: UUID.generate().toString(),
|
||||||
|
isMe: direction === CallDirection.Outgoing,
|
||||||
|
}),
|
||||||
|
callExternalState = CallExternalState.Active,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const conversation =
|
||||||
|
mode === CallMode.Group ? getDefaultGroup() : getDefaultConversation();
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversationId: conversation.id,
|
||||||
|
i18n,
|
||||||
|
isNextItemCallingNotification: false,
|
||||||
|
returnToActiveCall: action('returnToActiveCall'),
|
||||||
|
startCallingLobby: action('startCallingLobby'),
|
||||||
|
callHistory: {
|
||||||
|
callId: '123',
|
||||||
|
peerId: conversation.id,
|
||||||
|
ringerId: callCreator?.uuid ?? null,
|
||||||
|
mode,
|
||||||
|
type,
|
||||||
|
direction,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
callCreator,
|
||||||
|
callExternalState,
|
||||||
|
maxDevices: mode === CallMode.Group ? 15 : 0,
|
||||||
|
deviceCount:
|
||||||
|
// eslint-disable-next-line no-nested-ternary
|
||||||
|
mode === CallMode.Group
|
||||||
|
? callExternalState === CallExternalState.Full
|
||||||
|
? 15
|
||||||
|
: 13
|
||||||
|
: Infinity,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
|
@ -42,13 +98,12 @@ const getCommonProps = () => ({
|
||||||
export function AcceptedIncomingAudioCall(): JSX.Element {
|
export function AcceptedIncomingAudioCall(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
acceptedTime={1618894800000}
|
mode: CallMode.Direct,
|
||||||
callMode={CallMode.Direct}
|
type: CallType.Audio,
|
||||||
endedTime={1618894800000}
|
direction: CallDirection.Incoming,
|
||||||
wasDeclined={false}
|
status: DirectCallStatus.Accepted,
|
||||||
wasIncoming
|
})}
|
||||||
wasVideoCall={false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -56,13 +111,13 @@ export function AcceptedIncomingAudioCall(): JSX.Element {
|
||||||
export function AcceptedIncomingVideoCall(): JSX.Element {
|
export function AcceptedIncomingVideoCall(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
acceptedTime={1618894800000}
|
mode: CallMode.Direct,
|
||||||
callMode={CallMode.Direct}
|
type: CallType.Video,
|
||||||
endedTime={1618894800000}
|
direction: CallDirection.Incoming,
|
||||||
wasDeclined={false}
|
status: DirectCallStatus.Accepted,
|
||||||
wasIncoming
|
callExternalState: CallExternalState.Ended,
|
||||||
wasVideoCall
|
})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -70,13 +125,12 @@ export function AcceptedIncomingVideoCall(): JSX.Element {
|
||||||
export function DeclinedIncomingAudioCall(): JSX.Element {
|
export function DeclinedIncomingAudioCall(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
acceptedTime={undefined}
|
mode: CallMode.Direct,
|
||||||
callMode={CallMode.Direct}
|
type: CallType.Audio,
|
||||||
endedTime={1618894800000}
|
direction: CallDirection.Incoming,
|
||||||
wasDeclined
|
status: DirectCallStatus.Declined,
|
||||||
wasIncoming
|
})}
|
||||||
wasVideoCall={false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -84,13 +138,12 @@ export function DeclinedIncomingAudioCall(): JSX.Element {
|
||||||
export function DeclinedIncomingVideoCall(): JSX.Element {
|
export function DeclinedIncomingVideoCall(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
acceptedTime={undefined}
|
mode: CallMode.Direct,
|
||||||
callMode={CallMode.Direct}
|
type: CallType.Video,
|
||||||
endedTime={1618894800000}
|
direction: CallDirection.Incoming,
|
||||||
wasDeclined
|
status: DirectCallStatus.Declined,
|
||||||
wasIncoming
|
})}
|
||||||
wasVideoCall
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -98,13 +151,12 @@ export function DeclinedIncomingVideoCall(): JSX.Element {
|
||||||
export function AcceptedOutgoingAudioCall(): JSX.Element {
|
export function AcceptedOutgoingAudioCall(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
acceptedTime={1618894800000}
|
mode: CallMode.Direct,
|
||||||
callMode={CallMode.Direct}
|
type: CallType.Audio,
|
||||||
endedTime={1618894800000}
|
direction: CallDirection.Outgoing,
|
||||||
wasDeclined={false}
|
status: DirectCallStatus.Accepted,
|
||||||
wasIncoming={false}
|
})}
|
||||||
wasVideoCall={false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -112,13 +164,12 @@ export function AcceptedOutgoingAudioCall(): JSX.Element {
|
||||||
export function AcceptedOutgoingVideoCall(): JSX.Element {
|
export function AcceptedOutgoingVideoCall(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
acceptedTime={1618894800000}
|
mode: CallMode.Direct,
|
||||||
callMode={CallMode.Direct}
|
type: CallType.Video,
|
||||||
endedTime={1618894800000}
|
direction: CallDirection.Outgoing,
|
||||||
wasDeclined={false}
|
status: DirectCallStatus.Accepted,
|
||||||
wasIncoming={false}
|
})}
|
||||||
wasVideoCall
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -126,13 +177,12 @@ export function AcceptedOutgoingVideoCall(): JSX.Element {
|
||||||
export function DeclinedOutgoingAudioCall(): JSX.Element {
|
export function DeclinedOutgoingAudioCall(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
acceptedTime={undefined}
|
mode: CallMode.Direct,
|
||||||
callMode={CallMode.Direct}
|
type: CallType.Audio,
|
||||||
endedTime={1618894800000}
|
direction: CallDirection.Outgoing,
|
||||||
wasDeclined
|
status: DirectCallStatus.Declined,
|
||||||
wasIncoming={false}
|
})}
|
||||||
wasVideoCall={false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -140,42 +190,37 @@ export function DeclinedOutgoingAudioCall(): JSX.Element {
|
||||||
export function DeclinedOutgoingVideoCall(): JSX.Element {
|
export function DeclinedOutgoingVideoCall(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
acceptedTime={undefined}
|
mode: CallMode.Direct,
|
||||||
callMode={CallMode.Direct}
|
type: CallType.Video,
|
||||||
endedTime={1618894800000}
|
direction: CallDirection.Outgoing,
|
||||||
wasDeclined
|
status: DirectCallStatus.Declined,
|
||||||
wasIncoming={false}
|
})}
|
||||||
wasVideoCall
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TwoIncomingDirectCallsBackToBack(): JSX.Element {
|
export function TwoIncomingDirectCallsBackToBack(): JSX.Element {
|
||||||
const call1: CallingNotificationType = {
|
|
||||||
callMode: CallMode.Direct,
|
|
||||||
wasIncoming: true,
|
|
||||||
wasVideoCall: true,
|
|
||||||
wasDeclined: false,
|
|
||||||
acceptedTime: 1618894800000,
|
|
||||||
endedTime: 1618894800000,
|
|
||||||
};
|
|
||||||
const call2: CallingNotificationType = {
|
|
||||||
callMode: CallMode.Direct,
|
|
||||||
wasIncoming: true,
|
|
||||||
wasVideoCall: false,
|
|
||||||
wasDeclined: false,
|
|
||||||
endedTime: 1618894800000,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
{...call1}
|
mode: CallMode.Direct,
|
||||||
|
type: CallType.Video,
|
||||||
|
direction: CallDirection.Incoming,
|
||||||
|
status: DirectCallStatus.Declined,
|
||||||
|
callExternalState: CallExternalState.Ended,
|
||||||
|
})}
|
||||||
isNextItemCallingNotification
|
isNextItemCallingNotification
|
||||||
/>
|
/>
|
||||||
<CallingNotification {...getCommonProps()} {...call2} />
|
<CallingNotification
|
||||||
|
{...getCommonProps({
|
||||||
|
mode: CallMode.Direct,
|
||||||
|
type: CallType.Audio,
|
||||||
|
direction: CallDirection.Incoming,
|
||||||
|
status: DirectCallStatus.Declined,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -185,30 +230,26 @@ TwoIncomingDirectCallsBackToBack.story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TwoOutgoingDirectCallsBackToBack(): JSX.Element {
|
export function TwoOutgoingDirectCallsBackToBack(): JSX.Element {
|
||||||
const call1: CallingNotificationType = {
|
|
||||||
callMode: CallMode.Direct,
|
|
||||||
wasIncoming: false,
|
|
||||||
wasVideoCall: true,
|
|
||||||
wasDeclined: false,
|
|
||||||
acceptedTime: 1618894800000,
|
|
||||||
endedTime: 1618894800000,
|
|
||||||
};
|
|
||||||
const call2: CallingNotificationType = {
|
|
||||||
callMode: CallMode.Direct,
|
|
||||||
wasIncoming: false,
|
|
||||||
wasVideoCall: false,
|
|
||||||
wasDeclined: false,
|
|
||||||
endedTime: 1618894800000,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
{...call1}
|
mode: CallMode.Direct,
|
||||||
|
type: CallType.Video,
|
||||||
|
direction: CallDirection.Outgoing,
|
||||||
|
status: DirectCallStatus.Declined,
|
||||||
|
callExternalState: CallExternalState.Ended,
|
||||||
|
})}
|
||||||
isNextItemCallingNotification
|
isNextItemCallingNotification
|
||||||
/>
|
/>
|
||||||
<CallingNotification {...getCommonProps()} {...call2} />
|
<CallingNotification
|
||||||
|
{...getCommonProps({
|
||||||
|
mode: CallMode.Direct,
|
||||||
|
type: CallType.Audio,
|
||||||
|
direction: CallDirection.Outgoing,
|
||||||
|
status: DirectCallStatus.Declined,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -220,13 +261,13 @@ TwoOutgoingDirectCallsBackToBack.story = {
|
||||||
export function GroupCallByUnknown(): JSX.Element {
|
export function GroupCallByUnknown(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
callMode={CallMode.Group}
|
mode: CallMode.Group,
|
||||||
creator={undefined}
|
type: CallType.Group,
|
||||||
deviceCount={15}
|
direction: CallDirection.Incoming,
|
||||||
ended={false}
|
status: GroupCallStatus.Accepted,
|
||||||
maxDevices={16}
|
callCreator: null,
|
||||||
startedTime={1618894800000}
|
})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -234,13 +275,12 @@ export function GroupCallByUnknown(): JSX.Element {
|
||||||
export function GroupCallByYou(): JSX.Element {
|
export function GroupCallByYou(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
callMode={CallMode.Group}
|
mode: CallMode.Group,
|
||||||
creator={getDefaultConversation({ isMe: true, title: 'Alicia' })}
|
type: CallType.Group,
|
||||||
deviceCount={15}
|
direction: CallDirection.Outgoing,
|
||||||
ended={false}
|
status: GroupCallStatus.Accepted,
|
||||||
maxDevices={16}
|
})}
|
||||||
startedTime={1618894800000}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -248,31 +288,28 @@ export function GroupCallByYou(): JSX.Element {
|
||||||
export function GroupCallBySomeone(): JSX.Element {
|
export function GroupCallBySomeone(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
callMode={CallMode.Group}
|
mode: CallMode.Group,
|
||||||
creator={getDefaultConversation({ isMe: false, title: 'Alicia' })}
|
type: CallType.Group,
|
||||||
deviceCount={15}
|
direction: CallDirection.Incoming,
|
||||||
ended={false}
|
status: GroupCallStatus.GenericGroupCall,
|
||||||
maxDevices={16}
|
})}
|
||||||
startedTime={1618894800000}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupCallStartedBySomeoneWithALongName(): JSX.Element {
|
export function GroupCallStartedBySomeoneWithALongName(): JSX.Element {
|
||||||
const longName = '😤🪐🦆'.repeat(50);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
callMode={CallMode.Group}
|
mode: CallMode.Group,
|
||||||
creator={getDefaultConversation({
|
type: CallType.Group,
|
||||||
title: longName,
|
direction: CallDirection.Incoming,
|
||||||
|
status: GroupCallStatus.GenericGroupCall,
|
||||||
|
callCreator: getDefaultConversation({
|
||||||
|
name: '😤🪐🦆'.repeat(50),
|
||||||
|
}),
|
||||||
})}
|
})}
|
||||||
deviceCount={15}
|
|
||||||
ended={false}
|
|
||||||
maxDevices={16}
|
|
||||||
startedTime={1618894800000}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -284,12 +321,13 @@ GroupCallStartedBySomeoneWithALongName.story = {
|
||||||
export function GroupCallActiveCallFull(): JSX.Element {
|
export function GroupCallActiveCallFull(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
callMode={CallMode.Group}
|
mode: CallMode.Group,
|
||||||
deviceCount={16}
|
type: CallType.Group,
|
||||||
ended={false}
|
direction: CallDirection.Incoming,
|
||||||
maxDevices={16}
|
status: GroupCallStatus.GenericGroupCall,
|
||||||
startedTime={1618894800000}
|
callExternalState: CallExternalState.Full,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -301,12 +339,13 @@ GroupCallActiveCallFull.story = {
|
||||||
export function GroupCallEnded(): JSX.Element {
|
export function GroupCallEnded(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps({
|
||||||
callMode={CallMode.Group}
|
mode: CallMode.Group,
|
||||||
deviceCount={0}
|
type: CallType.Group,
|
||||||
ended
|
direction: CallDirection.Incoming,
|
||||||
maxDevices={16}
|
status: GroupCallStatus.GenericGroupCall,
|
||||||
startedTime={1618894800000}
|
callExternalState: CallExternalState.Ended,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,13 +12,19 @@ import type { LocalizerType } from '../../types/Util';
|
||||||
import { CallMode } from '../../types/Calling';
|
import { CallMode } from '../../types/Calling';
|
||||||
import type { CallingNotificationType } from '../../util/callingNotification';
|
import type { CallingNotificationType } from '../../util/callingNotification';
|
||||||
import {
|
import {
|
||||||
|
CallExternalState,
|
||||||
getCallingIcon,
|
getCallingIcon,
|
||||||
getCallingNotificationText,
|
getCallingNotificationText,
|
||||||
} from '../../util/callingNotification';
|
} from '../../util/callingNotification';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import { Tooltip, TooltipPlacement } from '../Tooltip';
|
import { Tooltip, TooltipPlacement } from '../Tooltip';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import { assertDev } from '../../util/assert';
|
import {
|
||||||
|
CallDirection,
|
||||||
|
CallType,
|
||||||
|
DirectCallStatus,
|
||||||
|
GroupCallStatus,
|
||||||
|
} from '../../types/CallDisposition';
|
||||||
|
|
||||||
export type PropsActionsType = {
|
export type PropsActionsType = {
|
||||||
returnToActiveCall: () => void;
|
returnToActiveCall: () => void;
|
||||||
|
@ -34,35 +40,15 @@ type PropsHousekeeping = {
|
||||||
isNextItemCallingNotification: boolean;
|
isNextItemCallingNotification: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PropsType = CallingNotificationType & PropsActionsType & PropsHousekeeping;
|
export type PropsType = CallingNotificationType &
|
||||||
|
PropsActionsType &
|
||||||
|
PropsHousekeeping;
|
||||||
|
|
||||||
export const CallingNotification: React.FC<PropsType> = React.memo(
|
export const CallingNotification: React.FC<PropsType> = React.memo(
|
||||||
function CallingNotificationInner(props) {
|
function CallingNotificationInner(props) {
|
||||||
const { i18n } = props;
|
const { i18n } = props;
|
||||||
|
const { type, direction, status, timestamp } = props.callHistory;
|
||||||
let timestamp: number;
|
const icon = getCallingIcon(type, direction, status);
|
||||||
let wasMissed = false;
|
|
||||||
switch (props.callMode) {
|
|
||||||
case CallMode.Direct: {
|
|
||||||
const resolvedTime = props.acceptedTime ?? props.endedTime;
|
|
||||||
assertDev(resolvedTime, 'Direct call must have accepted or ended time');
|
|
||||||
timestamp = resolvedTime;
|
|
||||||
wasMissed =
|
|
||||||
props.wasIncoming && !props.acceptedTime && !props.wasDeclined;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case CallMode.Group:
|
|
||||||
timestamp = props.startedTime;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
log.error(
|
|
||||||
`CallingNotification missing case: ${missingCaseError(props)}`
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = getCallingIcon(props);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SystemMessage
|
<SystemMessage
|
||||||
button={renderCallingNotificationButton(props)}
|
button={renderCallingNotificationButton(props)}
|
||||||
|
@ -80,7 +66,12 @@ export const CallingNotification: React.FC<PropsType> = React.memo(
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
kind={wasMissed ? SystemMessageKind.Danger : SystemMessageKind.Normal}
|
kind={
|
||||||
|
status === DirectCallStatus.Missed ||
|
||||||
|
status === GroupCallStatus.Missed
|
||||||
|
? SystemMessageKind.Danger
|
||||||
|
: SystemMessageKind.Normal
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -90,7 +81,6 @@ function renderCallingNotificationButton(
|
||||||
props: Readonly<PropsType>
|
props: Readonly<PropsType>
|
||||||
): ReactNode {
|
): ReactNode {
|
||||||
const {
|
const {
|
||||||
activeCallConversationId,
|
|
||||||
conversationId,
|
conversationId,
|
||||||
i18n,
|
i18n,
|
||||||
isNextItemCallingNotification,
|
isNextItemCallingNotification,
|
||||||
|
@ -106,55 +96,65 @@ function renderCallingNotificationButton(
|
||||||
let disabledTooltipText: undefined | string;
|
let disabledTooltipText: undefined | string;
|
||||||
let onClick: () => void;
|
let onClick: () => void;
|
||||||
|
|
||||||
switch (props.callMode) {
|
switch (props.callHistory.mode) {
|
||||||
case CallMode.Direct: {
|
case CallMode.Direct: {
|
||||||
const { wasIncoming, wasVideoCall } = props;
|
const { direction, type } = props.callHistory;
|
||||||
buttonText = wasIncoming
|
buttonText =
|
||||||
? i18n('icu:calling__call-back')
|
direction === CallDirection.Incoming
|
||||||
: i18n('icu:calling__call-again');
|
? i18n('icu:calling__call-back')
|
||||||
if (activeCallConversationId) {
|
: i18n('icu:calling__call-again');
|
||||||
|
if (
|
||||||
|
props.callExternalState === CallExternalState.Joined ||
|
||||||
|
props.callExternalState === CallExternalState.InOtherCall
|
||||||
|
) {
|
||||||
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
|
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
|
||||||
onClick = noop;
|
onClick = noop;
|
||||||
} else {
|
} else {
|
||||||
onClick = () => {
|
onClick = () => {
|
||||||
startCallingLobby({ conversationId, isVideoCall: wasVideoCall });
|
startCallingLobby({
|
||||||
|
conversationId,
|
||||||
|
isVideoCall: type === CallType.Video,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case CallMode.Group: {
|
case CallMode.Group: {
|
||||||
if (props.ended) {
|
if (props.callExternalState === CallExternalState.Ended) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { deviceCount, maxDevices } = props;
|
if (props.callExternalState === CallExternalState.Joined) {
|
||||||
if (activeCallConversationId) {
|
buttonText = i18n('icu:calling__return');
|
||||||
if (activeCallConversationId === conversationId) {
|
onClick = returnToActiveCall;
|
||||||
buttonText = i18n('icu:calling__return');
|
} else if (props.callExternalState === CallExternalState.InOtherCall) {
|
||||||
onClick = returnToActiveCall;
|
buttonText = i18n('icu:calling__join');
|
||||||
} else {
|
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
|
||||||
buttonText = i18n('icu:calling__join');
|
onClick = noop;
|
||||||
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
|
} else if (props.callExternalState === CallExternalState.Full) {
|
||||||
onClick = noop;
|
|
||||||
}
|
|
||||||
} else if (deviceCount >= maxDevices) {
|
|
||||||
buttonText = i18n('icu:calling__call-is-full');
|
buttonText = i18n('icu:calling__call-is-full');
|
||||||
disabledTooltipText = i18n(
|
disabledTooltipText = i18n(
|
||||||
'icu:calling__call-notification__button__call-full-tooltip',
|
'icu:calling__call-notification__button__call-full-tooltip',
|
||||||
{
|
{
|
||||||
max: deviceCount,
|
max: props.maxDevices,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
onClick = noop;
|
onClick = noop;
|
||||||
} else {
|
} else if (props.callExternalState === CallExternalState.Active) {
|
||||||
buttonText = i18n('icu:calling__join');
|
buttonText = i18n('icu:calling__join');
|
||||||
onClick = () => {
|
onClick = () => {
|
||||||
startCallingLobby({ conversationId, isVideoCall: true });
|
startCallingLobby({ conversationId, isVideoCall: true });
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
throw missingCaseError(props.callExternalState);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case CallMode.None: {
|
||||||
|
log.error('renderCallingNotificationButton: Call mode cant be none');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
log.error(missingCaseError(props));
|
log.error(missingCaseError(props.callHistory.mode));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,13 @@ import { getDefaultConversation } from '../../../test-both/helpers/getDefaultCon
|
||||||
import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid';
|
import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid';
|
||||||
import { ThemeType } from '../../../types/Util';
|
import { ThemeType } from '../../../types/Util';
|
||||||
import { DurationInSeconds } from '../../../util/durations';
|
import { DurationInSeconds } from '../../../util/durations';
|
||||||
|
import { NavTab } from '../../../state/ducks/nav';
|
||||||
|
import { CallMode } from '../../../types/Calling';
|
||||||
|
import {
|
||||||
|
CallDirection,
|
||||||
|
CallType,
|
||||||
|
DirectCallStatus,
|
||||||
|
} from '../../../types/CallDisposition';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
@ -79,6 +86,7 @@ const createProps = (
|
||||||
metadata: {},
|
metadata: {},
|
||||||
member: getDefaultConversation(),
|
member: getDefaultConversation(),
|
||||||
})),
|
})),
|
||||||
|
selectedNavTab: NavTab.Chats,
|
||||||
setDisappearingMessages: action('setDisappearingMessages'),
|
setDisappearingMessages: action('setDisappearingMessages'),
|
||||||
showContactModal: action('showContactModal'),
|
showContactModal: action('showContactModal'),
|
||||||
pushPanelForConversation: action('pushPanelForConversation'),
|
pushPanelForConversation: action('pushPanelForConversation'),
|
||||||
|
@ -214,3 +222,32 @@ export const _11 = (): JSX.Element => (
|
||||||
_11.story = {
|
_11.story = {
|
||||||
name: '1:1',
|
name: '1:1',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function mins(n: number) {
|
||||||
|
return DurationInSeconds.toMillis(DurationInSeconds.fromMinutes(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WithCallHistoryGroup(): JSX.Element {
|
||||||
|
const props = createProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConversationDetails
|
||||||
|
{...props}
|
||||||
|
callHistoryGroup={{
|
||||||
|
peerId: props.conversation?.uuid ?? '',
|
||||||
|
mode: CallMode.Direct,
|
||||||
|
type: CallType.Video,
|
||||||
|
direction: CallDirection.Incoming,
|
||||||
|
status: DirectCallStatus.Accepted,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
children: [
|
||||||
|
{ callId: '123', timestamp: Date.now() },
|
||||||
|
{ callId: '122', timestamp: Date.now() - mins(30) },
|
||||||
|
{ callId: '121', timestamp: Date.now() - mins(45) },
|
||||||
|
{ callId: '121', timestamp: Date.now() - mins(60) },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
selectedNavTab={NavTab.Calls}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
import { Button, ButtonIconType, ButtonVariant } from '../../Button';
|
import { Button, ButtonIconType, ButtonVariant } from '../../Button';
|
||||||
import { Tooltip } from '../../Tooltip';
|
import { Tooltip } from '../../Tooltip';
|
||||||
import type {
|
import type {
|
||||||
|
@ -52,6 +53,37 @@ import type {
|
||||||
import { isConversationMuted } from '../../../util/isConversationMuted';
|
import { isConversationMuted } from '../../../util/isConversationMuted';
|
||||||
import { ConversationDetailsGroups } from './ConversationDetailsGroups';
|
import { ConversationDetailsGroups } from './ConversationDetailsGroups';
|
||||||
import { PanelType } from '../../../types/Panels';
|
import { PanelType } from '../../../types/Panels';
|
||||||
|
import type { CallStatus } from '../../../types/CallDisposition';
|
||||||
|
import {
|
||||||
|
CallType,
|
||||||
|
type CallHistoryGroup,
|
||||||
|
CallDirection,
|
||||||
|
DirectCallStatus,
|
||||||
|
GroupCallStatus,
|
||||||
|
} from '../../../types/CallDisposition';
|
||||||
|
import { formatDate, formatTime } from '../../../util/timestamp';
|
||||||
|
import { NavTab } from '../../../state/ducks/nav';
|
||||||
|
|
||||||
|
function describeCallHistory(
|
||||||
|
i18n: LocalizerType,
|
||||||
|
type: CallType,
|
||||||
|
direction: CallDirection,
|
||||||
|
status: CallStatus
|
||||||
|
): string {
|
||||||
|
if (status === DirectCallStatus.Missed || status === GroupCallStatus.Missed) {
|
||||||
|
if (direction === CallDirection.Incoming) {
|
||||||
|
return i18n('icu:CallHistory__Description--Missed', { type });
|
||||||
|
}
|
||||||
|
return i18n('icu:CallHistory__Description--Unanswered', { type });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
status === DirectCallStatus.Declined ||
|
||||||
|
status === GroupCallStatus.Declined
|
||||||
|
) {
|
||||||
|
return i18n('icu:CallHistory__Description--Declined', { type });
|
||||||
|
}
|
||||||
|
return i18n('icu:CallHistory__Description--Default', { type, direction });
|
||||||
|
}
|
||||||
|
|
||||||
enum ModalState {
|
enum ModalState {
|
||||||
NothingOpen,
|
NothingOpen,
|
||||||
|
@ -65,6 +97,7 @@ enum ModalState {
|
||||||
export type StateProps = {
|
export type StateProps = {
|
||||||
areWeASubscriber: boolean;
|
areWeASubscriber: boolean;
|
||||||
badges?: ReadonlyArray<BadgeType>;
|
badges?: ReadonlyArray<BadgeType>;
|
||||||
|
callHistoryGroup?: CallHistoryGroup | null;
|
||||||
canEditGroupInfo: boolean;
|
canEditGroupInfo: boolean;
|
||||||
canAddNewMembers: boolean;
|
canAddNewMembers: boolean;
|
||||||
conversation?: ConversationType;
|
conversation?: ConversationType;
|
||||||
|
@ -80,6 +113,7 @@ export type StateProps = {
|
||||||
memberships: ReadonlyArray<GroupV2Membership>;
|
memberships: ReadonlyArray<GroupV2Membership>;
|
||||||
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
|
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
|
||||||
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
|
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
|
||||||
|
selectedNavTab: NavTab;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
userAvatarData: ReadonlyArray<AvatarDataType>;
|
userAvatarData: ReadonlyArray<AvatarDataType>;
|
||||||
renderChooseGroupMembersModal: (
|
renderChooseGroupMembersModal: (
|
||||||
|
@ -101,6 +135,7 @@ type ActionProps = {
|
||||||
}
|
}
|
||||||
) => unknown;
|
) => unknown;
|
||||||
blockConversation: (id: string) => void;
|
blockConversation: (id: string) => void;
|
||||||
|
|
||||||
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
||||||
getProfilesForConversation: (id: string) => unknown;
|
getProfilesForConversation: (id: string) => unknown;
|
||||||
leaveGroup: (conversationId: string) => void;
|
leaveGroup: (conversationId: string) => void;
|
||||||
|
@ -153,6 +188,7 @@ export function ConversationDetails({
|
||||||
areWeASubscriber,
|
areWeASubscriber,
|
||||||
badges,
|
badges,
|
||||||
blockConversation,
|
blockConversation,
|
||||||
|
callHistoryGroup,
|
||||||
canEditGroupInfo,
|
canEditGroupInfo,
|
||||||
canAddNewMembers,
|
canAddNewMembers,
|
||||||
conversation,
|
conversation,
|
||||||
|
@ -180,6 +216,7 @@ export function ConversationDetails({
|
||||||
replaceAvatar,
|
replaceAvatar,
|
||||||
saveAvatarToDisk,
|
saveAvatarToDisk,
|
||||||
searchInConversation,
|
searchInConversation,
|
||||||
|
selectedNavTab,
|
||||||
setDisappearingMessages,
|
setDisappearingMessages,
|
||||||
setMuteExpiration,
|
setMuteExpiration,
|
||||||
showContactModal,
|
showContactModal,
|
||||||
|
@ -364,6 +401,20 @@ export function ConversationDetails({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="ConversationDetails__header-buttons">
|
<div className="ConversationDetails__header-buttons">
|
||||||
|
{selectedNavTab === NavTab.Calls && (
|
||||||
|
<Button
|
||||||
|
icon={ButtonIconType.message}
|
||||||
|
onClick={() => {
|
||||||
|
showConversation({
|
||||||
|
conversationId: conversation?.id,
|
||||||
|
switchToAssociatedView: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant={ButtonVariant.Details}
|
||||||
|
>
|
||||||
|
{i18n('icu:ConversationDetails__HeaderButton--Message')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{!conversation.isMe && (
|
{!conversation.isMe && (
|
||||||
<>
|
<>
|
||||||
<ConversationDetailsCallButton
|
<ConversationDetailsCallButton
|
||||||
|
@ -397,17 +448,60 @@ export function ConversationDetails({
|
||||||
>
|
>
|
||||||
{isMuted ? i18n('icu:unmute') : i18n('icu:mute')}
|
{isMuted ? i18n('icu:unmute') : i18n('icu:mute')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{selectedNavTab !== NavTab.Calls && (
|
||||||
icon={ButtonIconType.search}
|
<Button
|
||||||
onClick={() => {
|
icon={ButtonIconType.search}
|
||||||
searchInConversation(conversation.id);
|
onClick={() => {
|
||||||
}}
|
searchInConversation(conversation.id);
|
||||||
variant={ButtonVariant.Details}
|
}}
|
||||||
>
|
variant={ButtonVariant.Details}
|
||||||
{i18n('icu:search')}
|
>
|
||||||
</Button>
|
{i18n('icu:search')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{callHistoryGroup && (
|
||||||
|
<PanelSection>
|
||||||
|
<h2 className="ConversationDetails__CallHistoryGroup__header">
|
||||||
|
{formatDate(i18n, callHistoryGroup.timestamp)}
|
||||||
|
</h2>
|
||||||
|
<ol className="ConversationDetails__CallHistoryGroup__List">
|
||||||
|
{callHistoryGroup.children.map(child => {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={child.callId}
|
||||||
|
className="ConversationDetails__CallHistoryGroup__Item"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
'ConversationDetails__CallHistoryGroup__ItemIcon',
|
||||||
|
{
|
||||||
|
'ConversationDetails__CallHistoryGroup__ItemIcon--Audio':
|
||||||
|
callHistoryGroup.type === CallType.Audio,
|
||||||
|
'ConversationDetails__CallHistoryGroup__ItemIcon--Video':
|
||||||
|
callHistoryGroup.type !== CallType.Audio,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="ConversationDetails__CallHistoryGroup__ItemLabel">
|
||||||
|
{describeCallHistory(
|
||||||
|
i18n,
|
||||||
|
callHistoryGroup.type,
|
||||||
|
callHistoryGroup.direction,
|
||||||
|
callHistoryGroup.status
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="ConversationDetails__CallHistoryGroup__ItemTimestamp">
|
||||||
|
{formatTime(i18n, child.timestamp, Date.now(), false)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</PanelSection>
|
||||||
|
)}
|
||||||
|
|
||||||
<PanelSection>
|
<PanelSection>
|
||||||
{!isGroup || canEditGroupInfo ? (
|
{!isGroup || canEditGroupInfo ? (
|
||||||
<PanelRow
|
<PanelRow
|
||||||
|
@ -440,28 +534,30 @@ export function ConversationDetails({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<PanelRow
|
{selectedNavTab === NavTab.Chats && (
|
||||||
icon={
|
<PanelRow
|
||||||
<ConversationDetailsIcon
|
icon={
|
||||||
ariaLabel={i18n('icu:showChatColorEditor')}
|
<ConversationDetailsIcon
|
||||||
icon={IconType.color}
|
ariaLabel={i18n('icu:showChatColorEditor')}
|
||||||
/>
|
icon={IconType.color}
|
||||||
}
|
/>
|
||||||
label={i18n('icu:showChatColorEditor')}
|
}
|
||||||
onClick={() => {
|
label={i18n('icu:showChatColorEditor')}
|
||||||
pushPanelForConversation({
|
onClick={() => {
|
||||||
type: PanelType.ChatColorEditor,
|
pushPanelForConversation({
|
||||||
});
|
type: PanelType.ChatColorEditor,
|
||||||
}}
|
});
|
||||||
right={
|
}}
|
||||||
<div
|
right={
|
||||||
className={`ConversationDetails__chat-color ConversationDetails__chat-color--${conversation.conversationColor}`}
|
<div
|
||||||
style={{
|
className={`ConversationDetails__chat-color ConversationDetails__chat-color--${conversation.conversationColor}`}
|
||||||
...getCustomColorStyle(conversation.customColor),
|
style={{
|
||||||
}}
|
...getCustomColorStyle(conversation.customColor),
|
||||||
/>
|
}}
|
||||||
}
|
/>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isGroup && (
|
{isGroup && (
|
||||||
<PanelRow
|
<PanelRow
|
||||||
icon={
|
icon={
|
||||||
|
|
|
@ -10,7 +10,6 @@ import * as KeyboardLayout from '../services/keyboardLayout';
|
||||||
import { getHasPanelOpen } from '../state/selectors/conversations';
|
import { getHasPanelOpen } from '../state/selectors/conversations';
|
||||||
import { isInFullScreenCall } from '../state/selectors/calling';
|
import { isInFullScreenCall } from '../state/selectors/calling';
|
||||||
import { isShowingAnyModal } from '../state/selectors/globalModals';
|
import { isShowingAnyModal } from '../state/selectors/globalModals';
|
||||||
import { shouldShowStoriesView } from '../state/selectors/stories';
|
|
||||||
|
|
||||||
type KeyboardShortcutHandlerType = (ev: KeyboardEvent) => boolean;
|
type KeyboardShortcutHandlerType = (ev: KeyboardEvent) => boolean;
|
||||||
|
|
||||||
|
@ -36,10 +35,6 @@ function useHasGlobalModal(): boolean {
|
||||||
return useSelector<StateType, boolean>(isShowingAnyModal);
|
return useSelector<StateType, boolean>(isShowingAnyModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useHasStories(): boolean {
|
|
||||||
return useSelector<StateType, boolean>(shouldShowStoriesView);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useHasCalling(): boolean {
|
function useHasCalling(): boolean {
|
||||||
return useSelector<StateType, boolean>(isInFullScreenCall);
|
return useSelector<StateType, boolean>(isInFullScreenCall);
|
||||||
}
|
}
|
||||||
|
@ -47,10 +42,9 @@ function useHasCalling(): boolean {
|
||||||
function useHasAnyOverlay(): boolean {
|
function useHasAnyOverlay(): boolean {
|
||||||
const panels = useHasPanels();
|
const panels = useHasPanels();
|
||||||
const globalModal = useHasGlobalModal();
|
const globalModal = useHasGlobalModal();
|
||||||
const stories = useHasStories();
|
|
||||||
const calling = useHasCalling();
|
const calling = useHasCalling();
|
||||||
|
|
||||||
return panels || globalModal || stories || calling;
|
return panels || globalModal || calling;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useActiveCallShortcuts(
|
export function useActiveCallShortcuts(
|
||||||
|
|
3
ts/model-types.d.ts
vendored
|
@ -7,7 +7,6 @@ import * as Backbone from 'backbone';
|
||||||
|
|
||||||
import type { GroupV2ChangeType } from './groups';
|
import type { GroupV2ChangeType } from './groups';
|
||||||
import type { DraftBodyRanges, RawBodyRange } from './types/BodyRange';
|
import type { DraftBodyRanges, RawBodyRange } from './types/BodyRange';
|
||||||
import type { CallHistoryDetailsFromDiskType } from './types/Calling';
|
|
||||||
import type { CustomColorType, ConversationColorType } from './types/Colors';
|
import type { CustomColorType, ConversationColorType } from './types/Colors';
|
||||||
import type { DeviceType } from './textsecure/Types.d';
|
import type { DeviceType } from './textsecure/Types.d';
|
||||||
import type { SendMessageChallengeData } from './textsecure/Errors';
|
import type { SendMessageChallengeData } from './textsecure/Errors';
|
||||||
|
@ -132,7 +131,7 @@ export type EditHistoryType = {
|
||||||
export type MessageAttributesType = {
|
export type MessageAttributesType = {
|
||||||
bodyAttachment?: AttachmentType;
|
bodyAttachment?: AttachmentType;
|
||||||
bodyRanges?: ReadonlyArray<RawBodyRange>;
|
bodyRanges?: ReadonlyArray<RawBodyRange>;
|
||||||
callHistoryDetails?: CallHistoryDetailsFromDiskType;
|
callId?: string;
|
||||||
canReplyToStory?: boolean;
|
canReplyToStory?: boolean;
|
||||||
changedId?: string;
|
changedId?: string;
|
||||||
dataMessage?: Uint8Array | null;
|
dataMessage?: Uint8Array | null;
|
||||||
|
|
|
@ -30,8 +30,6 @@ import { getAboutText } from '../util/getAboutText';
|
||||||
import { getAvatarPath } from '../util/avatarUtils';
|
import { getAvatarPath } from '../util/avatarUtils';
|
||||||
import { getDraftPreview } from '../util/getDraftPreview';
|
import { getDraftPreview } from '../util/getDraftPreview';
|
||||||
import { hasDraft } from '../util/hasDraft';
|
import { hasDraft } from '../util/hasDraft';
|
||||||
import type { CallHistoryDetailsType } from '../types/Calling';
|
|
||||||
import { CallMode } from '../types/Calling';
|
|
||||||
import * as Conversation from '../types/Conversation';
|
import * as Conversation from '../types/Conversation';
|
||||||
import type { StickerType, StickerWithHydratedData } from '../types/Stickers';
|
import type { StickerType, StickerWithHydratedData } from '../types/Stickers';
|
||||||
import * as Stickers from '../types/Stickers';
|
import * as Stickers from '../types/Stickers';
|
||||||
|
@ -55,7 +53,7 @@ import type {
|
||||||
} from '../types/Colors';
|
} from '../types/Colors';
|
||||||
import type { MessageModel } from './messages';
|
import type { MessageModel } from './messages';
|
||||||
import { getContact } from '../messages/helpers';
|
import { getContact } from '../messages/helpers';
|
||||||
import { assertDev, strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
import { isConversationMuted } from '../util/isConversationMuted';
|
import { isConversationMuted } from '../util/isConversationMuted';
|
||||||
import { isConversationSMSOnly } from '../util/isConversationSMSOnly';
|
import { isConversationSMSOnly } from '../util/isConversationSMSOnly';
|
||||||
import {
|
import {
|
||||||
|
@ -63,10 +61,8 @@ import {
|
||||||
isConversationUnregistered,
|
isConversationUnregistered,
|
||||||
isConversationUnregisteredAndStale,
|
isConversationUnregisteredAndStale,
|
||||||
} from '../util/isConversationUnregistered';
|
} from '../util/isConversationUnregistered';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
|
||||||
import { sniffImageMimeType } from '../util/sniffImageMimeType';
|
import { sniffImageMimeType } from '../util/sniffImageMimeType';
|
||||||
import { isValidE164 } from '../util/isValidE164';
|
import { isValidE164 } from '../util/isValidE164';
|
||||||
import { canConversationBeUnarchived } from '../util/canConversationBeUnarchived';
|
|
||||||
import type { MIMEType } from '../types/MIME';
|
import type { MIMEType } from '../types/MIME';
|
||||||
import { IMAGE_JPEG, IMAGE_WEBP } from '../types/MIME';
|
import { IMAGE_JPEG, IMAGE_WEBP } from '../types/MIME';
|
||||||
import { UUID, UUIDKind } from '../types/UUID';
|
import { UUID, UUIDKind } from '../types/UUID';
|
||||||
|
@ -160,7 +156,6 @@ import { ReceiptType } from '../types/Receipt';
|
||||||
import { getQuoteAttachment } from '../util/makeQuote';
|
import { getQuoteAttachment } from '../util/makeQuote';
|
||||||
import { deriveProfileKeyVersion } from '../util/zkgroup';
|
import { deriveProfileKeyVersion } from '../util/zkgroup';
|
||||||
import { incrementMessageCounter } from '../util/incrementMessageCounter';
|
import { incrementMessageCounter } from '../util/incrementMessageCounter';
|
||||||
import { validateTransition } from '../util/callHistoryDetails';
|
|
||||||
import OS from '../util/os/osMain';
|
import OS from '../util/os/osMain';
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
|
@ -256,8 +251,6 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
throttledUpdateSharedGroups?: () => Promise<void>;
|
throttledUpdateSharedGroups?: () => Promise<void>;
|
||||||
|
|
||||||
private cachedLatestGroupCallEraId?: string;
|
|
||||||
|
|
||||||
private cachedIdenticon?: CachedIdenticon;
|
private cachedIdenticon?: CachedIdenticon;
|
||||||
|
|
||||||
public isFetchingUUID?: boolean;
|
public isFetchingUUID?: boolean;
|
||||||
|
@ -3069,181 +3062,6 @@ export class ConversationModel extends window.Backbone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async addCallHistory(
|
|
||||||
callHistoryDetails: CallHistoryDetailsType,
|
|
||||||
receivedAtCounter: number | undefined
|
|
||||||
): Promise<void> {
|
|
||||||
let timestamp: number;
|
|
||||||
let unread: boolean;
|
|
||||||
let detailsToSave: CallHistoryDetailsType;
|
|
||||||
|
|
||||||
switch (callHistoryDetails.callMode) {
|
|
||||||
case CallMode.Direct: {
|
|
||||||
const {
|
|
||||||
callId,
|
|
||||||
wasIncoming,
|
|
||||||
wasVideoCall,
|
|
||||||
wasDeclined,
|
|
||||||
acceptedTime,
|
|
||||||
endedTime,
|
|
||||||
} = callHistoryDetails;
|
|
||||||
log.info(
|
|
||||||
`addCallHistory: Conversation ID: ${this.id}, ` +
|
|
||||||
`Call ID: ${callId}, ` +
|
|
||||||
'Direct, ' +
|
|
||||||
`Incoming: ${wasIncoming}, ` +
|
|
||||||
`Video: ${wasVideoCall}, ` +
|
|
||||||
`Declined: ${wasDeclined}, ` +
|
|
||||||
`Accepted: ${acceptedTime}, ` +
|
|
||||||
`Ended: ${endedTime}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolvedTime = acceptedTime ?? endedTime;
|
|
||||||
assertDev(resolvedTime, 'Direct call must have accepted or ended time');
|
|
||||||
timestamp = resolvedTime;
|
|
||||||
unread =
|
|
||||||
callHistoryDetails.wasIncoming &&
|
|
||||||
!callHistoryDetails.wasDeclined &&
|
|
||||||
!callHistoryDetails.acceptedTime;
|
|
||||||
detailsToSave = {
|
|
||||||
...callHistoryDetails,
|
|
||||||
callMode: CallMode.Direct,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case CallMode.Group:
|
|
||||||
timestamp = callHistoryDetails.startedTime;
|
|
||||||
unread = false;
|
|
||||||
detailsToSave = callHistoryDetails;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw missingCaseError(callHistoryDetails);
|
|
||||||
}
|
|
||||||
// This is sometimes called inside of another conversation queue job so if
|
|
||||||
// awaited it would block on this forever.
|
|
||||||
drop(
|
|
||||||
this.queueJob('addCallHistory', async () => {
|
|
||||||
// Force save if we're adding a new call history message for a direct call
|
|
||||||
let forceSave = true;
|
|
||||||
let previousMessage: MessageAttributesType | null = null;
|
|
||||||
if (callHistoryDetails.callMode === CallMode.Direct) {
|
|
||||||
const messageId =
|
|
||||||
await window.Signal.Data.getCallHistoryMessageByCallId(
|
|
||||||
this.id,
|
|
||||||
callHistoryDetails.callId
|
|
||||||
);
|
|
||||||
if (messageId != null) {
|
|
||||||
log.info(
|
|
||||||
`addCallHistory: Found existing call history message (Call ID: ${callHistoryDetails.callId}, Message ID: ${messageId})`
|
|
||||||
);
|
|
||||||
// We don't want to force save if we're updating an existing message
|
|
||||||
forceSave = false;
|
|
||||||
previousMessage =
|
|
||||||
(await window.Signal.Data.getMessageById(messageId)) ?? null;
|
|
||||||
} else {
|
|
||||||
log.info(
|
|
||||||
`addCallHistory: No existing call history message found (Call ID: ${callHistoryDetails.callId})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!validateTransition(
|
|
||||||
previousMessage?.callHistoryDetails,
|
|
||||||
callHistoryDetails,
|
|
||||||
log
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
log.info("addCallHistory: Transition isn't valid, not saving");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message: MessageAttributesType = {
|
|
||||||
id: previousMessage?.id ?? generateGuid(),
|
|
||||||
conversationId: this.id,
|
|
||||||
type: 'call-history',
|
|
||||||
sent_at: timestamp,
|
|
||||||
timestamp,
|
|
||||||
received_at: receivedAtCounter || incrementMessageCounter(),
|
|
||||||
received_at_ms: timestamp,
|
|
||||||
readStatus: unread ? ReadStatus.Unread : ReadStatus.Read,
|
|
||||||
seenStatus: unread ? SeenStatus.Unseen : SeenStatus.NotApplicable,
|
|
||||||
callHistoryDetails,
|
|
||||||
};
|
|
||||||
|
|
||||||
const id = await window.Signal.Data.saveMessage(message, {
|
|
||||||
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
|
||||||
forceSave,
|
|
||||||
});
|
|
||||||
|
|
||||||
log.info(`addCallHistory: Saved call history message (ID: ${id})`);
|
|
||||||
|
|
||||||
const model = window.MessageController.register(
|
|
||||||
id,
|
|
||||||
new window.Whisper.Message({
|
|
||||||
...message,
|
|
||||||
id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
detailsToSave.callMode === CallMode.Direct &&
|
|
||||||
!detailsToSave.wasIncoming
|
|
||||||
) {
|
|
||||||
this.incrementSentMessageCount();
|
|
||||||
} else {
|
|
||||||
this.incrementMessageCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.trigger('newmessage', model);
|
|
||||||
|
|
||||||
void this.updateUnread();
|
|
||||||
this.set('active_at', timestamp);
|
|
||||||
|
|
||||||
if (canConversationBeUnarchived(this.attributes)) {
|
|
||||||
this.setArchived(false);
|
|
||||||
} else {
|
|
||||||
window.Signal.Data.updateConversation(this.attributes);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a group call history message if one is needed. It won't add history messages for
|
|
||||||
* the same group call era ID.
|
|
||||||
*
|
|
||||||
* Resolves with `true` if a new message was added, and `false` otherwise.
|
|
||||||
*/
|
|
||||||
async updateCallHistoryForGroupCall(
|
|
||||||
eraId: string,
|
|
||||||
creatorUuid: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
// We want to update the cache quickly in case this function is called multiple times.
|
|
||||||
const oldCachedEraId = this.cachedLatestGroupCallEraId;
|
|
||||||
this.cachedLatestGroupCallEraId = eraId;
|
|
||||||
|
|
||||||
const alreadyHasMessage =
|
|
||||||
(oldCachedEraId && oldCachedEraId === eraId) ||
|
|
||||||
(await window.Signal.Data.hasGroupCallHistoryMessage(this.id, eraId));
|
|
||||||
|
|
||||||
if (alreadyHasMessage) {
|
|
||||||
void this.updateLastMessage();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.addCallHistory(
|
|
||||||
{
|
|
||||||
callMode: CallMode.Group,
|
|
||||||
creatorUuid,
|
|
||||||
eraId,
|
|
||||||
startedTime: Date.now(),
|
|
||||||
},
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async addProfileChange(
|
async addProfileChange(
|
||||||
profileChange: unknown,
|
profileChange: unknown,
|
||||||
conversationId?: string
|
conversationId?: string
|
||||||
|
|
|
@ -147,7 +147,6 @@ import { getStoryDataFromMessageAttributes } from '../services/storyLoader';
|
||||||
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
|
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
|
||||||
import { getMessageById } from '../messages/getMessageById';
|
import { getMessageById } from '../messages/getMessageById';
|
||||||
import { shouldDownloadStory } from '../util/shouldDownloadStory';
|
import { shouldDownloadStory } from '../util/shouldDownloadStory';
|
||||||
import { shouldShowStoriesView } from '../state/selectors/stories';
|
|
||||||
import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact';
|
import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact';
|
||||||
import { SeenStatus } from '../MessageSeenStatus';
|
import { SeenStatus } from '../MessageSeenStatus';
|
||||||
import { isNewReactionReplacingPrevious } from '../reactions/util';
|
import { isNewReactionReplacingPrevious } from '../reactions/util';
|
||||||
|
@ -172,6 +171,8 @@ import {
|
||||||
saveNewMessageBatcher,
|
saveNewMessageBatcher,
|
||||||
} from '../util/messageBatcher';
|
} from '../util/messageBatcher';
|
||||||
import { normalizeUuid } from '../util/normalizeUuid';
|
import { normalizeUuid } from '../util/normalizeUuid';
|
||||||
|
import { getCallHistorySelector } from '../state/selectors/callHistory';
|
||||||
|
import { getConversationSelector } from '../state/selectors/conversations';
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
|
|
||||||
|
@ -715,9 +716,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
if (isCallHistory(attributes)) {
|
if (isCallHistory(attributes)) {
|
||||||
const state = window.reduxStore.getState();
|
const state = window.reduxStore.getState();
|
||||||
const callingNotification = getPropsForCallHistory(attributes, {
|
const callingNotification = getPropsForCallHistory(attributes, {
|
||||||
conversationSelector: findAndFormatContact,
|
|
||||||
callSelector: getCallSelector(state),
|
callSelector: getCallSelector(state),
|
||||||
activeCall: getActiveCall(state),
|
activeCall: getActiveCall(state),
|
||||||
|
callHistorySelector: getCallHistorySelector(state),
|
||||||
|
conversationSelector: getConversationSelector(state),
|
||||||
});
|
});
|
||||||
if (callingNotification) {
|
if (callingNotification) {
|
||||||
return {
|
return {
|
||||||
|
@ -2837,11 +2839,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
|
|
||||||
let queueStoryForDownload = false;
|
let queueStoryForDownload = false;
|
||||||
if (isStory(message.attributes)) {
|
if (isStory(message.attributes)) {
|
||||||
const isShowingStories = shouldShowStoriesView(reduxState);
|
queueStoryForDownload = await shouldDownloadStory(
|
||||||
|
conversation.attributes
|
||||||
queueStoryForDownload =
|
);
|
||||||
isShowingStories ||
|
|
||||||
(await shouldDownloadStory(conversation.attributes));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldHoldOffDownload =
|
const shouldHoldOffDownload =
|
||||||
|
|
17
ts/services/callHistoryLoader.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import dataInterface from '../sql/Client';
|
||||||
|
import type { CallHistoryDetails } from '../types/CallDisposition';
|
||||||
|
import { strictAssert } from '../util/assert';
|
||||||
|
|
||||||
|
let callsHistoryData: ReadonlyArray<CallHistoryDetails>;
|
||||||
|
|
||||||
|
export async function loadCallsHistory(): Promise<void> {
|
||||||
|
callsHistoryData = await dataInterface.getAllCallHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCallsHistoryForRedux(): ReadonlyArray<CallHistoryDetails> {
|
||||||
|
strictAssert(callsHistoryData != null, 'callHistory has not been loaded');
|
||||||
|
return callsHistoryData;
|
||||||
|
}
|
|
@ -17,7 +17,6 @@ import {
|
||||||
AnswerMessage,
|
AnswerMessage,
|
||||||
BusyMessage,
|
BusyMessage,
|
||||||
Call,
|
Call,
|
||||||
CallEndedReason,
|
|
||||||
CallingMessage,
|
CallingMessage,
|
||||||
CallLogLevel,
|
CallLogLevel,
|
||||||
CallState,
|
CallState,
|
||||||
|
@ -39,8 +38,8 @@ import {
|
||||||
RingUpdate,
|
RingUpdate,
|
||||||
} from '@signalapp/ringrtc';
|
} from '@signalapp/ringrtc';
|
||||||
import { uniqBy, noop } from 'lodash';
|
import { uniqBy, noop } from 'lodash';
|
||||||
import Long from 'long';
|
|
||||||
|
|
||||||
|
import Long from 'long';
|
||||||
import type {
|
import type {
|
||||||
ActionsType as CallingReduxActionsType,
|
ActionsType as CallingReduxActionsType,
|
||||||
GroupCallParticipantInfoType,
|
GroupCallParticipantInfoType,
|
||||||
|
@ -51,6 +50,7 @@ import { getConversationCallMode } from '../state/ducks/conversations';
|
||||||
import { isMe } from '../util/whatTypeOfConversation';
|
import { isMe } from '../util/whatTypeOfConversation';
|
||||||
import type {
|
import type {
|
||||||
AvailableIODevicesType,
|
AvailableIODevicesType,
|
||||||
|
CallEndedReason,
|
||||||
MediaDeviceSettings,
|
MediaDeviceSettings,
|
||||||
PresentableSource,
|
PresentableSource,
|
||||||
PresentedSource,
|
PresentedSource,
|
||||||
|
@ -74,11 +74,10 @@ import { UUID, UUIDKind } from '../types/UUID';
|
||||||
import * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
import type { ConversationModel } from '../models/conversations';
|
import type { ConversationModel } from '../models/conversations';
|
||||||
import * as Bytes from '../Bytes';
|
import * as Bytes from '../Bytes';
|
||||||
import { uuidToBytes, bytesToUuid } from '../Crypto';
|
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
|
||||||
import { drop } from '../util/drop';
|
import { drop } from '../util/drop';
|
||||||
import { dropNull } from '../util/dropNull';
|
import { dropNull } from '../util/dropNull';
|
||||||
import { getOwn } from '../util/getOwn';
|
import { getOwn } from '../util/getOwn';
|
||||||
import { isNormalNumber } from '../util/isNormalNumber';
|
|
||||||
import * as durations from '../util/durations';
|
import * as durations from '../util/durations';
|
||||||
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
|
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
|
||||||
import { handleMessageSend } from '../util/handleMessageSend';
|
import { handleMessageSend } from '../util/handleMessageSend';
|
||||||
|
@ -107,6 +106,24 @@ import {
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import { assertDev, strictAssert } from '../util/assert';
|
import { assertDev, strictAssert } from '../util/assert';
|
||||||
import { sendContentMessageToGroup, sendToGroup } from '../util/sendToGroup';
|
import { sendContentMessageToGroup, sendToGroup } from '../util/sendToGroup';
|
||||||
|
import {
|
||||||
|
formatLocalDeviceState,
|
||||||
|
formatPeekInfo,
|
||||||
|
getPeerIdFromConversation,
|
||||||
|
getLocalCallEventFromCallEndedReason,
|
||||||
|
getCallDetailsFromEndedDirectCall,
|
||||||
|
getCallEventDetails,
|
||||||
|
getLocalCallEventFromGroupCall,
|
||||||
|
getLocalCallEventFromDirectCall,
|
||||||
|
getCallDetailsFromDirectCall,
|
||||||
|
getCallDetailsFromGroupCallMeta,
|
||||||
|
updateCallHistoryFromLocalEvent,
|
||||||
|
getGroupCallMeta,
|
||||||
|
getCallIdFromRing,
|
||||||
|
getLocalCallEventFromRingUpdate,
|
||||||
|
} from '../util/callDisposition';
|
||||||
|
import { isNormalNumber } from '../util/isNormalNumber';
|
||||||
|
import { LocalCallEvent } from '../types/CallDisposition';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
processGroupCallRingCancellation,
|
processGroupCallRingCancellation,
|
||||||
|
@ -689,7 +706,51 @@ export class CallingClass {
|
||||||
{
|
{
|
||||||
onLocalDeviceStateChanged: groupCall => {
|
onLocalDeviceStateChanged: groupCall => {
|
||||||
const localDeviceState = groupCall.getLocalDeviceState();
|
const localDeviceState = groupCall.getLocalDeviceState();
|
||||||
const { eraId } = groupCall.getPeekInfo() || {};
|
const peekInfo = groupCall.getPeekInfo() ?? null;
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
'GroupCall#onLocalDeviceStateChanged',
|
||||||
|
formatLocalDeviceState(localDeviceState),
|
||||||
|
peekInfo != null ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupCallMeta = getGroupCallMeta(peekInfo);
|
||||||
|
|
||||||
|
if (groupCallMeta != null) {
|
||||||
|
try {
|
||||||
|
const localCallEvent = getLocalCallEventFromGroupCall(
|
||||||
|
groupCall,
|
||||||
|
groupCallMeta
|
||||||
|
);
|
||||||
|
|
||||||
|
if (localCallEvent != null && peekInfo != null) {
|
||||||
|
const conversation =
|
||||||
|
window.ConversationController.get(conversationId);
|
||||||
|
strictAssert(
|
||||||
|
conversation != null,
|
||||||
|
'GroupCall#onLocalDeviceStateChanged: Missing conversation'
|
||||||
|
);
|
||||||
|
const peerId = getPeerIdFromConversation(
|
||||||
|
conversation.attributes
|
||||||
|
);
|
||||||
|
|
||||||
|
const callDetails = getCallDetailsFromGroupCallMeta(
|
||||||
|
peerId,
|
||||||
|
groupCallMeta
|
||||||
|
);
|
||||||
|
const callEvent = getCallEventDetails(
|
||||||
|
callDetails,
|
||||||
|
localCallEvent
|
||||||
|
);
|
||||||
|
drop(updateCallHistoryFromLocalEvent(callEvent, null));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
'GroupCall#onLocalDeviceStateChanged: Error updating state',
|
||||||
|
Errors.toLogFormat(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
localDeviceState.connectionState === ConnectionState.NotConnected
|
localDeviceState.connectionState === ConnectionState.NotConnected
|
||||||
|
@ -703,10 +764,13 @@ export class CallingClass {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
updateMessageState === GroupCallUpdateMessageState.SentJoin &&
|
updateMessageState === GroupCallUpdateMessageState.SentJoin &&
|
||||||
eraId
|
peekInfo?.eraId != null
|
||||||
) {
|
) {
|
||||||
updateMessageState = GroupCallUpdateMessageState.SentLeft;
|
updateMessageState = GroupCallUpdateMessageState.SentLeft;
|
||||||
void this.sendGroupCallUpdateMessage(conversationId, eraId);
|
void this.sendGroupCallUpdateMessage(
|
||||||
|
conversationId,
|
||||||
|
peekInfo?.eraId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.callsByConversation[conversationId] = groupCall;
|
this.callsByConversation[conversationId] = groupCall;
|
||||||
|
@ -721,16 +785,28 @@ export class CallingClass {
|
||||||
if (
|
if (
|
||||||
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
||||||
localDeviceState.joinState === JoinState.Joined &&
|
localDeviceState.joinState === JoinState.Joined &&
|
||||||
eraId
|
peekInfo?.eraId != null
|
||||||
) {
|
) {
|
||||||
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
||||||
void this.sendGroupCallUpdateMessage(conversationId, eraId);
|
void this.sendGroupCallUpdateMessage(
|
||||||
|
conversationId,
|
||||||
|
peekInfo?.eraId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.syncGroupCallToRedux(conversationId, groupCall);
|
this.syncGroupCallToRedux(conversationId, groupCall);
|
||||||
},
|
},
|
||||||
onRemoteDeviceStatesChanged: groupCall => {
|
onRemoteDeviceStatesChanged: groupCall => {
|
||||||
|
const localDeviceState = groupCall.getLocalDeviceState();
|
||||||
|
const peekInfo = groupCall.getPeekInfo();
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
'GroupCall#onRemoteDeviceStatesChanged',
|
||||||
|
formatLocalDeviceState(localDeviceState),
|
||||||
|
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||||
|
);
|
||||||
|
|
||||||
this.syncGroupCallToRedux(conversationId, groupCall);
|
this.syncGroupCallToRedux(conversationId, groupCall);
|
||||||
},
|
},
|
||||||
onAudioLevels: groupCall => {
|
onAudioLevels: groupCall => {
|
||||||
|
@ -748,7 +824,16 @@ export class CallingClass {
|
||||||
},
|
},
|
||||||
onPeekChanged: groupCall => {
|
onPeekChanged: groupCall => {
|
||||||
const localDeviceState = groupCall.getLocalDeviceState();
|
const localDeviceState = groupCall.getLocalDeviceState();
|
||||||
const { eraId } = groupCall.getPeekInfo() || {};
|
const peekInfo = groupCall.getPeekInfo() ?? null;
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
'GroupCall#onPeekChanged',
|
||||||
|
formatLocalDeviceState(localDeviceState),
|
||||||
|
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { eraId } = peekInfo ?? {};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
||||||
localDeviceState.connectionState !== ConnectionState.NotConnected &&
|
localDeviceState.connectionState !== ConnectionState.NotConnected &&
|
||||||
|
@ -759,10 +844,7 @@ export class CallingClass {
|
||||||
void this.sendGroupCallUpdateMessage(conversationId, eraId);
|
void this.sendGroupCallUpdateMessage(conversationId, eraId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void this.updateCallHistoryForGroupCall(
|
void this.updateCallHistoryForGroupCall(conversationId, peekInfo);
|
||||||
conversationId,
|
|
||||||
groupCall.getPeekInfo()
|
|
||||||
);
|
|
||||||
this.syncGroupCallToRedux(conversationId, groupCall);
|
this.syncGroupCallToRedux(conversationId, groupCall);
|
||||||
},
|
},
|
||||||
async requestMembershipProof(groupCall) {
|
async requestMembershipProof(groupCall) {
|
||||||
|
@ -789,7 +871,17 @@ export class CallingClass {
|
||||||
requestGroupMembers: groupCall => {
|
requestGroupMembers: groupCall => {
|
||||||
groupCall.setGroupMembers(this.getGroupCallMembers(conversationId));
|
groupCall.setGroupMembers(this.getGroupCallMembers(conversationId));
|
||||||
},
|
},
|
||||||
onEnded: noop,
|
onEnded: (groupCall, endedReason) => {
|
||||||
|
const localDeviceState = groupCall.getLocalDeviceState();
|
||||||
|
const peekInfo = groupCall.getPeekInfo();
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
'GroupCall#onEnded',
|
||||||
|
endedReason,
|
||||||
|
formatLocalDeviceState(localDeviceState),
|
||||||
|
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||||
|
);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1567,12 +1659,23 @@ export class CallingClass {
|
||||||
|
|
||||||
await this.handleOutgoingSignaling(remoteUserId, message);
|
await this.handleOutgoingSignaling(remoteUserId, message);
|
||||||
|
|
||||||
const ProtoOfferType = Proto.CallingMessage.Offer.Type;
|
const wasVideoCall =
|
||||||
await this.addCallHistoryForFailedIncomingCall(
|
callingMessage.offer.type ===
|
||||||
conversation,
|
Proto.CallingMessage.Offer.Type.OFFER_VIDEO_CALL;
|
||||||
callingMessage.offer.type === ProtoOfferType.OFFER_VIDEO_CALL,
|
|
||||||
envelope.timestamp,
|
const peerId = getPeerIdFromConversation(conversation.attributes);
|
||||||
callId.toString()
|
const callDetails = getCallDetailsFromEndedDirectCall(
|
||||||
|
callId.toString(),
|
||||||
|
peerId,
|
||||||
|
peerId, // Incoming call
|
||||||
|
wasVideoCall,
|
||||||
|
envelope.timestamp
|
||||||
|
);
|
||||||
|
const localCallEvent = LocalCallEvent.Missed;
|
||||||
|
const callEvent = getCallEventDetails(callDetails, localCallEvent);
|
||||||
|
await updateCallHistoryFromLocalEvent(
|
||||||
|
callEvent,
|
||||||
|
envelope.receivedAtCounter
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
@ -1801,6 +1904,20 @@ export class CallingClass {
|
||||||
ringId,
|
ringId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localEvent = getLocalCallEventFromRingUpdate(update);
|
||||||
|
if (localEvent != null) {
|
||||||
|
const callId = getCallIdFromRing(ringId);
|
||||||
|
const callDetails = getCallDetailsFromGroupCallMeta(groupId, {
|
||||||
|
callId,
|
||||||
|
ringerId: ringerUuid,
|
||||||
|
});
|
||||||
|
const callEvent = getCallEventDetails(
|
||||||
|
callDetails,
|
||||||
|
shouldRing ? LocalCallEvent.Ringing : LocalCallEvent.Started
|
||||||
|
);
|
||||||
|
await updateCallHistoryFromLocalEvent(callEvent, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleOutgoingSignaling(
|
private async handleOutgoingSignaling(
|
||||||
|
@ -1865,8 +1982,6 @@ export class CallingClass {
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const callId = Long.fromValue(call.callId).toString();
|
|
||||||
try {
|
try {
|
||||||
// The peer must be 'trusted' before accepting a call from them.
|
// The peer must be 'trusted' before accepting a call from them.
|
||||||
// This is mostly the safety number check, unverified meaning that they were
|
// This is mostly the safety number check, unverified meaning that they were
|
||||||
|
@ -1879,12 +1994,13 @@ export class CallingClass {
|
||||||
log.info(
|
log.info(
|
||||||
`Peer is not trusted, ignoring incoming call for conversation: ${conversation.idForLogging()}`
|
`Peer is not trusted, ignoring incoming call for conversation: ${conversation.idForLogging()}`
|
||||||
);
|
);
|
||||||
await this.addCallHistoryForFailedIncomingCall(
|
|
||||||
conversation,
|
const localCallEvent = LocalCallEvent.Missed;
|
||||||
call.isVideoCall,
|
const peerId = getPeerIdFromConversation(conversation.attributes);
|
||||||
Date.now(),
|
const callDetails = getCallDetailsFromDirectCall(peerId, call);
|
||||||
callId
|
const callEvent = getCallEventDetails(callDetails, localCallEvent);
|
||||||
);
|
await updateCallHistoryFromLocalEvent(callEvent, null);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1898,20 +2014,14 @@ export class CallingClass {
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error(`Ignoring incoming call: ${Errors.toLogFormat(err)}`);
|
log.error(`Ignoring incoming call: ${Errors.toLogFormat(err)}`);
|
||||||
await this.addCallHistoryForFailedIncomingCall(
|
|
||||||
conversation,
|
|
||||||
call.isVideoCall,
|
|
||||||
Date.now(),
|
|
||||||
callId
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAutoEndedIncomingCallRequest(
|
private async handleAutoEndedIncomingCallRequest(
|
||||||
callId: CallId,
|
callIdValue: CallId,
|
||||||
remoteUserId: UserId,
|
remoteUserId: UserId,
|
||||||
reason: CallEndedReason,
|
callEndedReason: CallEndedReason,
|
||||||
ageInSeconds: number,
|
ageInSeconds: number,
|
||||||
wasVideoCall: boolean,
|
wasVideoCall: boolean,
|
||||||
receivedAtCounter: number | undefined
|
receivedAtCounter: number | undefined
|
||||||
|
@ -1921,22 +2031,28 @@ export class CallingClass {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const callId = Long.fromValue(callIdValue).toString();
|
||||||
|
const peerId = getPeerIdFromConversation(conversation.attributes);
|
||||||
|
|
||||||
// This is extra defensive, just in case RingRTC passes us a bad value. (It probably
|
// This is extra defensive, just in case RingRTC passes us a bad value. (It probably
|
||||||
// won't.)
|
// won't.)
|
||||||
const ageInMilliseconds =
|
const ageInMilliseconds =
|
||||||
isNormalNumber(ageInSeconds) && ageInSeconds >= 0
|
isNormalNumber(ageInSeconds) && ageInSeconds >= 0
|
||||||
? ageInSeconds * durations.SECOND
|
? ageInSeconds * durations.SECOND
|
||||||
: 0;
|
: 0;
|
||||||
const endedTime = Date.now() - ageInMilliseconds;
|
const timestamp = Date.now() - ageInMilliseconds;
|
||||||
|
|
||||||
await this.addCallHistoryForAutoEndedIncomingCall(
|
const callDetails = getCallDetailsFromEndedDirectCall(
|
||||||
conversation,
|
callId,
|
||||||
reason,
|
peerId,
|
||||||
endedTime,
|
remoteUserId,
|
||||||
wasVideoCall,
|
wasVideoCall,
|
||||||
receivedAtCounter,
|
timestamp
|
||||||
Long.fromValue(callId).toString()
|
|
||||||
);
|
);
|
||||||
|
const localCallEvent =
|
||||||
|
getLocalCallEventFromCallEndedReason(callEndedReason);
|
||||||
|
const callEvent = getCallEventDetails(callDetails, localCallEvent);
|
||||||
|
await updateCallHistoryFromLocalEvent(callEvent, receivedAtCounter ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private attachToCall(conversation: ConversationModel, call: Call): void {
|
private attachToCall(conversation: ConversationModel, call: Call): void {
|
||||||
|
@ -1947,44 +2063,26 @@ export class CallingClass {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let acceptedTime: number | undefined;
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
call.handleStateChanged = async () => {
|
call.handleStateChanged = async () => {
|
||||||
if (call.state === CallState.Accepted) {
|
if (call.state === CallState.Ended) {
|
||||||
acceptedTime = acceptedTime || Date.now();
|
|
||||||
await this.addCallHistoryForAcceptedCall(
|
|
||||||
conversation,
|
|
||||||
call,
|
|
||||||
acceptedTime
|
|
||||||
);
|
|
||||||
} else if (call.state === CallState.Ended) {
|
|
||||||
try {
|
|
||||||
await this.addCallHistoryForEndedCall(
|
|
||||||
conversation,
|
|
||||||
call,
|
|
||||||
acceptedTime
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
log.error(
|
|
||||||
'Failed to add call history for ended call',
|
|
||||||
Errors.toLogFormat(error)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.stopDeviceReselectionTimer();
|
this.stopDeviceReselectionTimer();
|
||||||
this.lastMediaDeviceSettings = undefined;
|
this.lastMediaDeviceSettings = undefined;
|
||||||
delete this.callsByConversation[conversation.id];
|
delete this.callsByConversation[conversation.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localCallEvent = getLocalCallEventFromDirectCall(call);
|
||||||
|
if (localCallEvent != null) {
|
||||||
|
const peerId = getPeerIdFromConversation(conversation.attributes);
|
||||||
|
const callDetails = getCallDetailsFromDirectCall(peerId, call);
|
||||||
|
const callEvent = getCallEventDetails(callDetails, localCallEvent);
|
||||||
|
await updateCallHistoryFromLocalEvent(callEvent, null);
|
||||||
|
}
|
||||||
|
|
||||||
reduxInterface.callStateChange({
|
reduxInterface.callStateChange({
|
||||||
remoteUserId: call.remoteUserId,
|
|
||||||
callId: Long.fromValue(call.callId).toString(),
|
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
acceptedTime,
|
|
||||||
callState: call.state,
|
callState: call.state,
|
||||||
callEndedReason: call.endedReason,
|
callEndedReason: call.endedReason,
|
||||||
isIncoming: call.isIncoming,
|
|
||||||
isVideoCall: call.isVideoCall,
|
|
||||||
title: conversation.getTitle(),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2137,154 +2235,55 @@ export class CallingClass {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async addCallHistoryForAcceptedCall(
|
|
||||||
conversation: ConversationModel,
|
|
||||||
call: Call,
|
|
||||||
acceptedTime: number
|
|
||||||
) {
|
|
||||||
const callId = Long.fromValue(call.callId).toString();
|
|
||||||
try {
|
|
||||||
log.info('addCallHistoryForAcceptedCall: Adding call history');
|
|
||||||
await conversation.addCallHistory(
|
|
||||||
{
|
|
||||||
callId,
|
|
||||||
callMode: CallMode.Direct,
|
|
||||||
wasIncoming: call.isIncoming,
|
|
||||||
wasVideoCall: call.isVideoCall,
|
|
||||||
wasDeclined: false,
|
|
||||||
acceptedTime,
|
|
||||||
endedTime: undefined,
|
|
||||||
},
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
log.error(
|
|
||||||
'addCallHistoryForAcceptedCall: Failed to add call history',
|
|
||||||
Errors.toLogFormat(error)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async addCallHistoryForEndedCall(
|
|
||||||
conversation: ConversationModel,
|
|
||||||
call: Call,
|
|
||||||
acceptedTimeParam: number | undefined
|
|
||||||
) {
|
|
||||||
let acceptedTime = acceptedTimeParam;
|
|
||||||
|
|
||||||
const { endedReason, isIncoming } = call;
|
|
||||||
const wasAccepted = Boolean(acceptedTime);
|
|
||||||
const isOutgoing = !isIncoming;
|
|
||||||
const wasDeclined =
|
|
||||||
!wasAccepted &&
|
|
||||||
(endedReason === CallEndedReason.Declined ||
|
|
||||||
endedReason === CallEndedReason.DeclinedOnAnotherDevice ||
|
|
||||||
(isIncoming && endedReason === CallEndedReason.LocalHangup) ||
|
|
||||||
(isOutgoing && endedReason === CallEndedReason.RemoteHangup) ||
|
|
||||||
(isOutgoing &&
|
|
||||||
endedReason === CallEndedReason.RemoteHangupNeedPermission));
|
|
||||||
if (call.endedReason === CallEndedReason.AcceptedOnAnotherDevice) {
|
|
||||||
acceptedTime = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
const callId = Long.fromValue(call.callId).toString();
|
|
||||||
|
|
||||||
await conversation.addCallHistory(
|
|
||||||
{
|
|
||||||
callId,
|
|
||||||
callMode: CallMode.Direct,
|
|
||||||
wasIncoming: call.isIncoming,
|
|
||||||
wasVideoCall: call.isVideoCall,
|
|
||||||
wasDeclined,
|
|
||||||
acceptedTime,
|
|
||||||
endedTime: Date.now(),
|
|
||||||
},
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async addCallHistoryForFailedIncomingCall(
|
|
||||||
conversation: ConversationModel,
|
|
||||||
wasVideoCall: boolean,
|
|
||||||
timestamp: number,
|
|
||||||
callId: string
|
|
||||||
) {
|
|
||||||
await conversation.addCallHistory(
|
|
||||||
{
|
|
||||||
callMode: CallMode.Direct,
|
|
||||||
wasIncoming: true,
|
|
||||||
wasVideoCall,
|
|
||||||
// Since the user didn't decline, make sure it shows up as a missed call instead
|
|
||||||
wasDeclined: false,
|
|
||||||
acceptedTime: undefined,
|
|
||||||
endedTime: timestamp,
|
|
||||||
callId,
|
|
||||||
},
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async addCallHistoryForAutoEndedIncomingCall(
|
|
||||||
conversation: ConversationModel,
|
|
||||||
reason: CallEndedReason,
|
|
||||||
endedTime: number,
|
|
||||||
wasVideoCall: boolean,
|
|
||||||
receivedAtCounter: number | undefined,
|
|
||||||
callId: string
|
|
||||||
) {
|
|
||||||
let wasDeclined = false;
|
|
||||||
let acceptedTime;
|
|
||||||
|
|
||||||
if (reason === CallEndedReason.AcceptedOnAnotherDevice) {
|
|
||||||
acceptedTime = endedTime;
|
|
||||||
} else if (reason === CallEndedReason.DeclinedOnAnotherDevice) {
|
|
||||||
wasDeclined = true;
|
|
||||||
}
|
|
||||||
// Otherwise it will show up as a missed call.
|
|
||||||
|
|
||||||
await conversation.addCallHistory(
|
|
||||||
{
|
|
||||||
callId,
|
|
||||||
callMode: CallMode.Direct,
|
|
||||||
wasIncoming: true,
|
|
||||||
wasVideoCall,
|
|
||||||
wasDeclined,
|
|
||||||
acceptedTime,
|
|
||||||
endedTime,
|
|
||||||
},
|
|
||||||
receivedAtCounter
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async updateCallHistoryForGroupCall(
|
public async updateCallHistoryForGroupCall(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
peekInfo: undefined | PeekInfo
|
peekInfo: PeekInfo | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const groupCallMeta = getGroupCallMeta(peekInfo);
|
||||||
// If we don't have the necessary pieces to peek, bail. (It's okay if we don't.)
|
// If we don't have the necessary pieces to peek, bail. (It's okay if we don't.)
|
||||||
if (!peekInfo || !peekInfo.eraId || !peekInfo.creator) {
|
if (groupCallMeta == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const creatorUuid = bytesToUuid(peekInfo.creator);
|
|
||||||
if (!creatorUuid) {
|
const creatorConversation = window.ConversationController.get(
|
||||||
log.error('updateCallHistoryForGroupCall(): bad creator UUID');
|
groupCallMeta.ringerId
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
const creatorConversation = window.ConversationController.get(creatorUuid);
|
|
||||||
|
|
||||||
const conversation = window.ConversationController.get(conversationId);
|
const conversation = window.ConversationController.get(conversationId);
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
log.error('updateCallHistoryForGroupCall(): could not find conversation');
|
log.error('maybeNotifyGroupCall(): could not find conversation');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNewCall = await conversation.updateCallHistoryForGroupCall(
|
const prevMessageId =
|
||||||
peekInfo.eraId,
|
await window.Signal.Data.getCallHistoryMessageByCallId({
|
||||||
creatorUuid
|
conversationId: conversation.id,
|
||||||
);
|
callId: groupCallMeta.callId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isNewCall = prevMessageId == null;
|
||||||
|
|
||||||
|
const groupCall = this.getGroupCall(conversationId);
|
||||||
|
if (groupCall != null) {
|
||||||
|
const localCallEvent = getLocalCallEventFromGroupCall(
|
||||||
|
groupCall,
|
||||||
|
groupCallMeta
|
||||||
|
);
|
||||||
|
if (localCallEvent != null) {
|
||||||
|
const peerId = getPeerIdFromConversation(conversation.attributes);
|
||||||
|
const callDetails = getCallDetailsFromGroupCallMeta(
|
||||||
|
peerId,
|
||||||
|
groupCallMeta
|
||||||
|
);
|
||||||
|
const callEvent = getCallEventDetails(callDetails, localCallEvent);
|
||||||
|
await updateCallHistoryFromLocalEvent(callEvent, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const wasStartedByMe = Boolean(
|
const wasStartedByMe = Boolean(
|
||||||
creatorConversation && isMe(creatorConversation.attributes)
|
creatorConversation && isMe(creatorConversation.attributes)
|
||||||
);
|
);
|
||||||
const isAnybodyElseInGroupCall = Boolean(peekInfo.devices.length);
|
const isAnybodyElseInGroupCall = Boolean(peekInfo?.devices.length);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isNewCall &&
|
isNewCall &&
|
||||||
|
|
|
@ -4,11 +4,8 @@
|
||||||
import { isEqual, isNumber } from 'lodash';
|
import { isEqual, isNumber } from 'lodash';
|
||||||
import Long from 'long';
|
import Long from 'long';
|
||||||
|
|
||||||
import {
|
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
|
||||||
uuidToBytes,
|
import { deriveMasterKeyFromGroupV1 } from '../Crypto';
|
||||||
bytesToUuid,
|
|
||||||
deriveMasterKeyFromGroupV1,
|
|
||||||
} from '../Crypto';
|
|
||||||
import * as Bytes from '../Bytes';
|
import * as Bytes from '../Bytes';
|
||||||
import {
|
import {
|
||||||
deriveGroupFields,
|
deriveGroupFields,
|
||||||
|
|
|
@ -11,8 +11,7 @@ import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
|
||||||
import { strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
import { sleep } from '../util/sleep';
|
import { sleep } from '../util/sleep';
|
||||||
import { getMinNickname, getMaxNickname } from '../util/Username';
|
import { getMinNickname, getMaxNickname } from '../util/Username';
|
||||||
import { bytesToUuid } from '../Crypto';
|
import { bytesToUuid, uuidToBytes } from '../util/uuidToBytes';
|
||||||
import { uuidToBytes } from '../util/uuidToBytes';
|
|
||||||
import type { UsernameReservationType } from '../types/Username';
|
import type { UsernameReservationType } from '../types/Username';
|
||||||
import { ReserveUsernameError, ConfirmUsernameResult } from '../types/Username';
|
import { ReserveUsernameError, ConfirmUsernameResult } from '../types/Username';
|
||||||
import * as Errors from '../types/errors';
|
import * as Errors from '../types/errors';
|
||||||
|
|
|
@ -21,6 +21,12 @@ import type { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
import type { RawBodyRange } from '../types/BodyRange';
|
import type { RawBodyRange } from '../types/BodyRange';
|
||||||
import type { GetMessagesBetweenOptions } from './Server';
|
import type { GetMessagesBetweenOptions } from './Server';
|
||||||
import type { MessageTimestamps } from '../state/ducks/conversations';
|
import type { MessageTimestamps } from '../state/ducks/conversations';
|
||||||
|
import type {
|
||||||
|
CallHistoryDetails,
|
||||||
|
CallHistoryFilter,
|
||||||
|
CallHistoryGroup,
|
||||||
|
CallHistoryPagination,
|
||||||
|
} from '../types/CallDisposition';
|
||||||
|
|
||||||
export type AdjacentMessagesByConversationOptionsType = Readonly<{
|
export type AdjacentMessagesByConversationOptionsType = Readonly<{
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
@ -628,10 +634,22 @@ export type DataInterface = {
|
||||||
getLastConversationMessage(options: {
|
getLastConversationMessage(options: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
}): Promise<MessageType | undefined>;
|
}): Promise<MessageType | undefined>;
|
||||||
getCallHistoryMessageByCallId(
|
getAllCallHistory: () => Promise<ReadonlyArray<CallHistoryDetails>>;
|
||||||
conversationId: string,
|
clearCallHistory: (beforeTimestamp: number) => Promise<Array<string>>;
|
||||||
callId: string
|
getCallHistoryMessageByCallId(options: {
|
||||||
): Promise<string | void>;
|
conversationId: string;
|
||||||
|
callId: string;
|
||||||
|
}): Promise<MessageType | undefined>;
|
||||||
|
getCallHistory(
|
||||||
|
callId: string,
|
||||||
|
peerId: string
|
||||||
|
): Promise<CallHistoryDetails | undefined>;
|
||||||
|
getCallHistoryGroupsCount(filter: CallHistoryFilter): Promise<number>;
|
||||||
|
getCallHistoryGroups(
|
||||||
|
filter: CallHistoryFilter,
|
||||||
|
pagination: CallHistoryPagination
|
||||||
|
): Promise<Array<CallHistoryGroup>>;
|
||||||
|
saveCallHistory(callHistory: CallHistoryDetails): Promise<void>;
|
||||||
hasGroupCallHistoryMessage: (
|
hasGroupCallHistoryMessage: (
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
eraId: string
|
eraId: string
|
||||||
|
|
426
ts/sql/Server.ts
|
@ -10,6 +10,7 @@ import { randomBytes } from 'crypto';
|
||||||
import type { Database, Statement } from '@signalapp/better-sqlite3';
|
import type { Database, Statement } from '@signalapp/better-sqlite3';
|
||||||
import SQL from '@signalapp/better-sqlite3';
|
import SQL from '@signalapp/better-sqlite3';
|
||||||
import pProps from 'p-props';
|
import pProps from 'p-props';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { Dictionary } from 'lodash';
|
import type { Dictionary } from 'lodash';
|
||||||
import {
|
import {
|
||||||
|
@ -60,6 +61,7 @@ import type {
|
||||||
QueryFragment,
|
QueryFragment,
|
||||||
} from './util';
|
} from './util';
|
||||||
import {
|
import {
|
||||||
|
sqlConstant,
|
||||||
sqlJoin,
|
sqlJoin,
|
||||||
sqlFragment,
|
sqlFragment,
|
||||||
sql,
|
sql,
|
||||||
|
@ -142,6 +144,18 @@ import {
|
||||||
SNIPPET_RIGHT_PLACEHOLDER,
|
SNIPPET_RIGHT_PLACEHOLDER,
|
||||||
SNIPPET_TRUNCATION_PLACEHOLDER,
|
SNIPPET_TRUNCATION_PLACEHOLDER,
|
||||||
} from '../util/search';
|
} from '../util/search';
|
||||||
|
import type {
|
||||||
|
CallHistoryDetails,
|
||||||
|
CallHistoryFilter,
|
||||||
|
CallHistoryGroup,
|
||||||
|
CallHistoryPagination,
|
||||||
|
} from '../types/CallDisposition';
|
||||||
|
import {
|
||||||
|
DirectCallStatus,
|
||||||
|
callHistoryGroupSchema,
|
||||||
|
CallHistoryFilterStatus,
|
||||||
|
callHistoryDetailsSchema,
|
||||||
|
} from '../types/CallDisposition';
|
||||||
|
|
||||||
type ConversationRow = Readonly<{
|
type ConversationRow = Readonly<{
|
||||||
json: string;
|
json: string;
|
||||||
|
@ -288,7 +302,13 @@ const dataInterface: ServerInterface = {
|
||||||
getConversationRangeCenteredOnMessage,
|
getConversationRangeCenteredOnMessage,
|
||||||
getConversationMessageStats,
|
getConversationMessageStats,
|
||||||
getLastConversationMessage,
|
getLastConversationMessage,
|
||||||
|
getAllCallHistory,
|
||||||
|
clearCallHistory,
|
||||||
getCallHistoryMessageByCallId,
|
getCallHistoryMessageByCallId,
|
||||||
|
getCallHistory,
|
||||||
|
getCallHistoryGroupsCount,
|
||||||
|
getCallHistoryGroups,
|
||||||
|
saveCallHistory,
|
||||||
hasGroupCallHistoryMessage,
|
hasGroupCallHistoryMessage,
|
||||||
migrateConversationMessages,
|
migrateConversationMessages,
|
||||||
getMessagesBetween,
|
getMessagesBetween,
|
||||||
|
@ -1755,32 +1775,32 @@ async function searchMessages({
|
||||||
// Note: this groups the results by rowid, so even if one message mentions multiple
|
// Note: this groups the results by rowid, so even if one message mentions multiple
|
||||||
// matching UUIDs, we only return one to be highlighted
|
// matching UUIDs, we only return one to be highlighted
|
||||||
const [sqlQuery, params] = sql`
|
const [sqlQuery, params] = sql`
|
||||||
SELECT
|
SELECT
|
||||||
messages.rowid as rowid,
|
messages.rowid as rowid,
|
||||||
COALESCE(messages.json, ftsResults.json) as json,
|
COALESCE(messages.json, ftsResults.json) as json,
|
||||||
COALESCE(messages.sent_at, ftsResults.sent_at) as sent_at,
|
COALESCE(messages.sent_at, ftsResults.sent_at) as sent_at,
|
||||||
COALESCE(messages.received_at, ftsResults.received_at) as received_at,
|
COALESCE(messages.received_at, ftsResults.received_at) as received_at,
|
||||||
ftsResults.ftsSnippet,
|
ftsResults.ftsSnippet,
|
||||||
mentionUuid,
|
mentionUuid,
|
||||||
start as mentionStart,
|
start as mentionStart,
|
||||||
length as mentionLength
|
length as mentionLength
|
||||||
FROM mentions
|
FROM mentions
|
||||||
INNER JOIN messages
|
INNER JOIN messages
|
||||||
ON
|
ON
|
||||||
messages.id = mentions.messageId
|
messages.id = mentions.messageId
|
||||||
AND mentions.mentionUuid IN (
|
AND mentions.mentionUuid IN (
|
||||||
${sqlJoin(contactUuidsMatchingQuery, ', ')}
|
${sqlJoin(contactUuidsMatchingQuery, ', ')}
|
||||||
)
|
)
|
||||||
AND ${
|
AND ${
|
||||||
conversationId
|
conversationId
|
||||||
? sqlFragment`messages.conversationId = ${conversationId}`
|
? sqlFragment`messages.conversationId = ${conversationId}`
|
||||||
: '1 IS 1'
|
: '1 IS 1'
|
||||||
}
|
}
|
||||||
AND messages.isViewOnce IS NOT 1
|
AND messages.isViewOnce IS NOT 1
|
||||||
AND messages.storyId IS NULL
|
AND messages.storyId IS NULL
|
||||||
FULL OUTER JOIN (
|
FULL OUTER JOIN (
|
||||||
${ftsFragment}
|
${ftsFragment}
|
||||||
) as ftsResults
|
) as ftsResults
|
||||||
USING (rowid)
|
USING (rowid)
|
||||||
GROUP BY rowid
|
GROUP BY rowid
|
||||||
ORDER BY received_at DESC, sent_at DESC
|
ORDER BY received_at DESC, sent_at DESC
|
||||||
|
@ -1910,6 +1930,7 @@ function saveMessageSync(
|
||||||
sourceUuid,
|
sourceUuid,
|
||||||
sourceDevice,
|
sourceDevice,
|
||||||
storyId,
|
storyId,
|
||||||
|
callId,
|
||||||
type,
|
type,
|
||||||
readStatus,
|
readStatus,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
|
@ -1967,6 +1988,7 @@ function saveMessageSync(
|
||||||
sourceUuid: sourceUuid || null,
|
sourceUuid: sourceUuid || null,
|
||||||
sourceDevice: sourceDevice || null,
|
sourceDevice: sourceDevice || null,
|
||||||
storyId: storyId || null,
|
storyId: storyId || null,
|
||||||
|
callId: callId || null,
|
||||||
type: type || null,
|
type: type || null,
|
||||||
readStatus: readStatus ?? null,
|
readStatus: readStatus ?? null,
|
||||||
seenStatus: seenStatus ?? SeenStatus.NotApplicable,
|
seenStatus: seenStatus ?? SeenStatus.NotApplicable,
|
||||||
|
@ -1999,6 +2021,7 @@ function saveMessageSync(
|
||||||
sourceUuid = $sourceUuid,
|
sourceUuid = $sourceUuid,
|
||||||
sourceDevice = $sourceDevice,
|
sourceDevice = $sourceDevice,
|
||||||
storyId = $storyId,
|
storyId = $storyId,
|
||||||
|
callId = $callId,
|
||||||
type = $type,
|
type = $type,
|
||||||
readStatus = $readStatus,
|
readStatus = $readStatus,
|
||||||
seenStatus = $seenStatus
|
seenStatus = $seenStatus
|
||||||
|
@ -2044,6 +2067,7 @@ function saveMessageSync(
|
||||||
sourceUuid,
|
sourceUuid,
|
||||||
sourceDevice,
|
sourceDevice,
|
||||||
storyId,
|
storyId,
|
||||||
|
callId,
|
||||||
type,
|
type,
|
||||||
readStatus,
|
readStatus,
|
||||||
seenStatus
|
seenStatus
|
||||||
|
@ -2070,6 +2094,7 @@ function saveMessageSync(
|
||||||
$sourceUuid,
|
$sourceUuid,
|
||||||
$sourceDevice,
|
$sourceDevice,
|
||||||
$storyId,
|
$storyId,
|
||||||
|
$callId,
|
||||||
$type,
|
$type,
|
||||||
$readStatus,
|
$readStatus,
|
||||||
$seenStatus
|
$seenStatus
|
||||||
|
@ -3224,30 +3249,366 @@ async function getConversationRangeCenteredOnMessage(
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCallHistoryMessageByCallId(
|
async function getAllCallHistory(): Promise<ReadonlyArray<CallHistoryDetails>> {
|
||||||
conversationId: string,
|
const db = getInstance();
|
||||||
callId: string
|
const [query] = sql`
|
||||||
): Promise<string | void> {
|
SELECT * FROM callsHistory;
|
||||||
|
`;
|
||||||
|
return db.prepare(query).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCallHistory(
|
||||||
|
beforeTimestamp: number
|
||||||
|
): Promise<Array<string>> {
|
||||||
|
const db = getInstance();
|
||||||
|
return db.transaction(() => {
|
||||||
|
const whereMessages = sqlFragment`
|
||||||
|
WHERE messages.type IS 'call-history'
|
||||||
|
AND messages.sent_at <= ${beforeTimestamp};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [selectMessagesQuery, selectMessagesParams] = sql`
|
||||||
|
SELECT id FROM messages ${whereMessages}
|
||||||
|
`;
|
||||||
|
const [clearMessagesQuery, clearMessagesParams] = sql`
|
||||||
|
DELETE FROM messages ${whereMessages}
|
||||||
|
`;
|
||||||
|
const [clearCallsHistoryQuery, clearCallsHistoryParams] = sql`
|
||||||
|
UPDATE callsHistory
|
||||||
|
SET
|
||||||
|
status = ${DirectCallStatus.Deleted},
|
||||||
|
timestamp = ${Date.now()}
|
||||||
|
WHERE callsHistory.timestamp <= ${beforeTimestamp};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const messageIds = db
|
||||||
|
.prepare(selectMessagesQuery)
|
||||||
|
.pluck()
|
||||||
|
.all(selectMessagesParams);
|
||||||
|
db.prepare(clearMessagesQuery).run(clearMessagesParams);
|
||||||
|
try {
|
||||||
|
db.prepare(clearCallsHistoryQuery).run(clearCallsHistoryParams);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return messageIds;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCallHistoryMessageByCallId(options: {
|
||||||
|
conversationId: string;
|
||||||
|
callId: string;
|
||||||
|
}): Promise<MessageType | undefined> {
|
||||||
|
const db = getInstance();
|
||||||
|
const [query, params] = sql`
|
||||||
|
SELECT json
|
||||||
|
FROM messages
|
||||||
|
WHERE conversationId = ${options.conversationId}
|
||||||
|
AND type = 'call-history'
|
||||||
|
AND callId = ${options.callId}
|
||||||
|
`;
|
||||||
|
const row = db.prepare(query).get(params);
|
||||||
|
if (row == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return jsonToObject(row.json);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCallHistory(
|
||||||
|
callId: string,
|
||||||
|
peerId: string
|
||||||
|
): Promise<CallHistoryDetails | undefined> {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
|
|
||||||
const id: string | void = db
|
const [query, params] = sql`
|
||||||
.prepare<Query>(
|
SELECT * FROM callsHistory
|
||||||
`
|
WHERE callId IS ${callId}
|
||||||
SELECT id
|
AND peerId IS ${peerId};
|
||||||
FROM messages
|
`;
|
||||||
WHERE conversationId = $conversationId
|
|
||||||
AND type = 'call-history'
|
|
||||||
AND callMode = 'Direct'
|
|
||||||
AND callId = $callId
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.pluck()
|
|
||||||
.get({
|
|
||||||
conversationId,
|
|
||||||
callId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return id;
|
const row = db.prepare(query).get(params);
|
||||||
|
|
||||||
|
if (row == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return callHistoryDetailsSchema.parse(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MISSED = sqlConstant(DirectCallStatus.Missed);
|
||||||
|
const DELETED = sqlConstant(DirectCallStatus.Deleted);
|
||||||
|
const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
function getCallHistoryGroupDataSync(
|
||||||
|
db: Database,
|
||||||
|
isCount: boolean,
|
||||||
|
filter: CallHistoryFilter,
|
||||||
|
pagination: CallHistoryPagination
|
||||||
|
): unknown {
|
||||||
|
return db.transaction(() => {
|
||||||
|
const { limit, offset } = pagination;
|
||||||
|
const { status, conversationIds } = filter;
|
||||||
|
|
||||||
|
if (conversationIds != null) {
|
||||||
|
strictAssert(conversationIds.length > 0, "can't filter by empty array");
|
||||||
|
|
||||||
|
const [createTempTable] = sql`
|
||||||
|
CREATE TEMP TABLE temp_callHistory_filtered_conversations (
|
||||||
|
uuid TEXT,
|
||||||
|
groupId TEXT
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.exec(createTempTable);
|
||||||
|
|
||||||
|
batchMultiVarQuery(db, conversationIds, ids => {
|
||||||
|
const idList = sqlJoin(
|
||||||
|
ids.map(id => sqlFragment`(${id})`),
|
||||||
|
','
|
||||||
|
);
|
||||||
|
|
||||||
|
const [insertQuery, insertParams] = sql`
|
||||||
|
INSERT INTO temp_callHistory_filtered_conversations
|
||||||
|
(uuid, groupId)
|
||||||
|
SELECT uuid, groupId
|
||||||
|
FROM conversations
|
||||||
|
WHERE conversations.id IN (${idList});
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.prepare(insertQuery).run(insertParams);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const innerJoin =
|
||||||
|
conversationIds != null
|
||||||
|
? sqlFragment`
|
||||||
|
INNER JOIN temp_callHistory_filtered_conversations ON (
|
||||||
|
temp_callHistory_filtered_conversations.uuid IS c.peerId
|
||||||
|
OR temp_callHistory_filtered_conversations.groupId IS c.peerId
|
||||||
|
)
|
||||||
|
`
|
||||||
|
: sqlFragment``;
|
||||||
|
|
||||||
|
const filterClause =
|
||||||
|
status === CallHistoryFilterStatus.All
|
||||||
|
? sqlFragment`status IS NOT ${DELETED}`
|
||||||
|
: sqlFragment`status IS ${MISSED} AND status IS NOT ${DELETED}`;
|
||||||
|
|
||||||
|
const offsetLimit =
|
||||||
|
limit > 0 ? sqlFragment`LIMIT ${limit} OFFSET ${offset}` : sqlFragment``;
|
||||||
|
|
||||||
|
const projection = isCount
|
||||||
|
? sqlFragment`COUNT(*) AS count`
|
||||||
|
: sqlFragment`peerId, ringerId, mode, type, direction, status, timestamp, possibleChildren, inPeriod`;
|
||||||
|
|
||||||
|
const [query, params] = sql`
|
||||||
|
SELECT
|
||||||
|
${projection}
|
||||||
|
FROM (
|
||||||
|
-- 1. 'callAndGroupInfo': This section collects metadata to determine the
|
||||||
|
-- parent and children of each call. We can identify the real parents of calls
|
||||||
|
-- within the query, but we need to build the children at runtime.
|
||||||
|
WITH callAndGroupInfo AS (
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
-- 1a. 'possibleParent': This identifies the first call that _could_ be
|
||||||
|
-- considered the current call's parent. Note: The 'possibleParent' is not
|
||||||
|
-- necessarily the true parent if there is another call between them that
|
||||||
|
-- isn't a part of the group.
|
||||||
|
(
|
||||||
|
SELECT callId
|
||||||
|
FROM callsHistory
|
||||||
|
WHERE
|
||||||
|
callsHistory.direction IS c.direction
|
||||||
|
AND callsHistory.type IS c.type
|
||||||
|
AND callsHistory.peerId IS c.peerId
|
||||||
|
AND (callsHistory.timestamp - ${FOUR_HOURS_IN_MS}) <= c.timestamp
|
||||||
|
AND callsHistory.timestamp >= c.timestamp
|
||||||
|
-- Tracking Android & Desktop separately to make the queries easier to compare
|
||||||
|
-- Android Constraints:
|
||||||
|
AND (
|
||||||
|
(callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR
|
||||||
|
(callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED})
|
||||||
|
)
|
||||||
|
-- Desktop Constraints:
|
||||||
|
AND callsHistory.status IS c.status
|
||||||
|
AND ${filterClause}
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
) as possibleParent,
|
||||||
|
-- 1b. 'possibleChildren': This identifies all possible calls that can
|
||||||
|
-- be grouped with the current call. Note: This current call is not
|
||||||
|
-- necessarily the parent, and not all possible children will end up as
|
||||||
|
-- children as they might have another parent
|
||||||
|
(
|
||||||
|
SELECT JSON_GROUP_ARRAY(
|
||||||
|
JSON_OBJECT(
|
||||||
|
'callId', callId,
|
||||||
|
'timestamp', timestamp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM callsHistory
|
||||||
|
WHERE
|
||||||
|
callsHistory.direction IS c.direction
|
||||||
|
AND callsHistory.type IS c.type
|
||||||
|
AND callsHistory.peerId IS c.peerId
|
||||||
|
AND (c.timestamp - ${FOUR_HOURS_IN_MS}) <= callsHistory.timestamp
|
||||||
|
AND c.timestamp >= callsHistory.timestamp
|
||||||
|
-- Tracking Android & Desktop separately to make the queries easier to compare
|
||||||
|
-- Android Constraints:
|
||||||
|
AND (
|
||||||
|
(callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR
|
||||||
|
(callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED})
|
||||||
|
)
|
||||||
|
-- Desktop Constraints:
|
||||||
|
AND callsHistory.status IS c.status
|
||||||
|
AND ${filterClause}
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
) as possibleChildren,
|
||||||
|
|
||||||
|
-- 1c. 'inPeriod': This identifies all calls in a time period after the
|
||||||
|
-- current call. They may or may not be a part of the group.
|
||||||
|
(
|
||||||
|
SELECT GROUP_CONCAT(callId)
|
||||||
|
FROM callsHistory
|
||||||
|
WHERE
|
||||||
|
(c.timestamp - ${FOUR_HOURS_IN_MS}) <= callsHistory.timestamp
|
||||||
|
AND c.timestamp >= callsHistory.timestamp
|
||||||
|
AND ${filterClause}
|
||||||
|
) AS inPeriod
|
||||||
|
FROM callsHistory AS c
|
||||||
|
${innerJoin}
|
||||||
|
WHERE
|
||||||
|
${filterClause}
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
)
|
||||||
|
-- 2. 'isParent': We need to identify the true parent of the group in cases
|
||||||
|
-- where the previous call is not a part of the group.
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
CASE
|
||||||
|
WHEN LAG (possibleParent, 1, 0) OVER (
|
||||||
|
-- Note: This is an optimization assuming that we've already got 'timestamp DESC' ordering
|
||||||
|
-- from the query above. If we find that ordering isn't always correct, we can uncomment this:
|
||||||
|
-- ORDER BY timestamp DESC
|
||||||
|
) != possibleParent THEN callId
|
||||||
|
ELSE possibleParent
|
||||||
|
END AS parent
|
||||||
|
FROM callAndGroupInfo
|
||||||
|
) AS parentCallAndGroupInfo
|
||||||
|
WHERE parent = parentCallAndGroupInfo.callId
|
||||||
|
ORDER BY parentCallAndGroupInfo.timestamp DESC
|
||||||
|
${offsetLimit};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = isCount
|
||||||
|
? db.prepare(query).pluck(true).get(params)
|
||||||
|
: db.prepare(query).all(params);
|
||||||
|
|
||||||
|
if (conversationIds != null) {
|
||||||
|
const [dropTempTableQuery] = sql`
|
||||||
|
DROP TABLE temp_callHistory_filtered_conversations;
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.exec(dropTempTableQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
const countSchema = z.number().int().nonnegative();
|
||||||
|
|
||||||
|
async function getCallHistoryGroupsCount(
|
||||||
|
filter: CallHistoryFilter
|
||||||
|
): Promise<number> {
|
||||||
|
const db = getInstance();
|
||||||
|
const result = getCallHistoryGroupDataSync(db, true, filter, {
|
||||||
|
limit: 0,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
return countSchema.parse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupsDataSchema = z.array(
|
||||||
|
callHistoryGroupSchema.omit({ children: true }).extend({
|
||||||
|
possibleChildren: z.string(),
|
||||||
|
inPeriod: z.string(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const possibleChildrenSchema = z.array(
|
||||||
|
callHistoryDetailsSchema.pick({
|
||||||
|
callId: true,
|
||||||
|
timestamp: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
async function getCallHistoryGroups(
|
||||||
|
filter: CallHistoryFilter,
|
||||||
|
pagination: CallHistoryPagination
|
||||||
|
): Promise<Array<CallHistoryGroup>> {
|
||||||
|
const db = getInstance();
|
||||||
|
const groupsData = groupsDataSchema.parse(
|
||||||
|
getCallHistoryGroupDataSync(db, false, filter, pagination)
|
||||||
|
);
|
||||||
|
|
||||||
|
const taken = new Set<string>();
|
||||||
|
|
||||||
|
return groupsData
|
||||||
|
.map(groupData => {
|
||||||
|
return {
|
||||||
|
...groupData,
|
||||||
|
possibleChildren: possibleChildrenSchema.parse(
|
||||||
|
JSON.parse(groupData.possibleChildren)
|
||||||
|
),
|
||||||
|
inPeriod: new Set(groupData.inPeriod.split(',')),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.reverse()
|
||||||
|
.map(group => {
|
||||||
|
const { possibleChildren, inPeriod, ...rest } = group;
|
||||||
|
const children = [];
|
||||||
|
|
||||||
|
for (const child of possibleChildren) {
|
||||||
|
if (!taken.has(child.callId) && inPeriod.has(child.callId)) {
|
||||||
|
children.push(child);
|
||||||
|
taken.add(child.callId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return callHistoryGroupSchema.parse({ ...rest, children });
|
||||||
|
})
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCallHistory(callHistory: CallHistoryDetails): Promise<void> {
|
||||||
|
const db = getInstance();
|
||||||
|
|
||||||
|
const [insertQuery, insertParams] = sql`
|
||||||
|
INSERT OR REPLACE INTO callsHistory (
|
||||||
|
callId,
|
||||||
|
peerId,
|
||||||
|
ringerId,
|
||||||
|
mode,
|
||||||
|
type,
|
||||||
|
direction,
|
||||||
|
status,
|
||||||
|
timestamp
|
||||||
|
) VALUES (
|
||||||
|
${callHistory.callId},
|
||||||
|
${callHistory.peerId},
|
||||||
|
${callHistory.ringerId},
|
||||||
|
${callHistory.mode},
|
||||||
|
${callHistory.type},
|
||||||
|
${callHistory.direction},
|
||||||
|
${callHistory.status},
|
||||||
|
${callHistory.timestamp}
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.prepare(insertQuery).run(insertParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hasGroupCallHistoryMessage(
|
async function hasGroupCallHistoryMessage(
|
||||||
|
@ -5087,6 +5448,7 @@ async function removeAll(): Promise<void> {
|
||||||
DELETE FROM attachment_downloads;
|
DELETE FROM attachment_downloads;
|
||||||
DELETE FROM badgeImageFiles;
|
DELETE FROM badgeImageFiles;
|
||||||
DELETE FROM badges;
|
DELETE FROM badges;
|
||||||
|
DELETE FROM callsHistory;
|
||||||
DELETE FROM conversations;
|
DELETE FROM conversations;
|
||||||
DELETE FROM emojis;
|
DELETE FROM emojis;
|
||||||
DELETE FROM groupCallRingCancellations;
|
DELETE FROM groupCallRingCancellations;
|
||||||
|
|
196
ts/sql/migrations/87-calls-history-table.ts
Normal file
|
@ -0,0 +1,196 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { Database } from '@signalapp/better-sqlite3';
|
||||||
|
|
||||||
|
import { callIdFromEra } from '@signalapp/ringrtc';
|
||||||
|
import Long from 'long';
|
||||||
|
|
||||||
|
import type { LoggerType } from '../../types/Logging';
|
||||||
|
import { sql } from '../util';
|
||||||
|
import { getOurUuid } from './41-uuid-keys';
|
||||||
|
import type { CallHistoryDetails } from '../../types/CallDisposition';
|
||||||
|
import {
|
||||||
|
DirectCallStatus,
|
||||||
|
CallDirection,
|
||||||
|
CallType,
|
||||||
|
GroupCallStatus,
|
||||||
|
callHistoryDetailsSchema,
|
||||||
|
} from '../../types/CallDisposition';
|
||||||
|
import { CallMode } from '../../types/Calling';
|
||||||
|
|
||||||
|
export default function updateToSchemaVersion87(
|
||||||
|
currentVersion: number,
|
||||||
|
db: Database,
|
||||||
|
logger: LoggerType
|
||||||
|
): void {
|
||||||
|
if (currentVersion >= 87) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
const ourUuid = getOurUuid(db);
|
||||||
|
|
||||||
|
const [modifySchema] = sql`
|
||||||
|
DROP TABLE IF EXISTS callsHistory;
|
||||||
|
|
||||||
|
CREATE TABLE callsHistory (
|
||||||
|
callId TEXT PRIMARY KEY,
|
||||||
|
peerId TEXT NOT NULL, -- conversation uuid | groupId | roomId
|
||||||
|
ringerId TEXT DEFAULT NULL, -- ringer uuid
|
||||||
|
mode TEXT NOT NULL, -- enum "Direct" | "Group"
|
||||||
|
type TEXT NOT NULL, -- enum "Audio" | "Video" | "Group"
|
||||||
|
direction TEXT NOT NULL, -- enum "Incoming" | "Outgoing
|
||||||
|
-- Direct: enum "Pending" | "Missed" | "Accepted" | "Deleted"
|
||||||
|
-- Group: enum "GenericGroupCall" | "OutgoingRing" | "Ringing" | "Joined" | "Missed" | "Declined" | "Accepted" | "Deleted"
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
UNIQUE (callId, peerId) ON CONFLICT FAIL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX callsHistory_order on callsHistory (timestamp DESC);
|
||||||
|
CREATE INDEX callsHistory_byConversation ON callsHistory (peerId);
|
||||||
|
-- For 'getCallHistoryGroupData':
|
||||||
|
-- This index should target the subqueries for 'possible_parent' and 'possible_children'
|
||||||
|
CREATE INDEX callsHistory_callAndGroupInfo_optimize on callsHistory (
|
||||||
|
direction,
|
||||||
|
peerId,
|
||||||
|
timestamp DESC,
|
||||||
|
status
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS messages_call;
|
||||||
|
|
||||||
|
ALTER TABLE messages
|
||||||
|
DROP COLUMN callId;
|
||||||
|
ALTER TABLE messages
|
||||||
|
ADD COLUMN callId TEXT;
|
||||||
|
ALTER TABLE messages
|
||||||
|
DROP COLUMN callMode;
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.exec(modifySchema);
|
||||||
|
|
||||||
|
const [selectQuery] = sql`
|
||||||
|
SELECT * FROM messages WHERE type = 'call-history';
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rows = db.prepare(selectQuery).all();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const json = JSON.parse(row.json);
|
||||||
|
const details = json.callHistoryDetails;
|
||||||
|
|
||||||
|
const { conversationId: peerId } = row;
|
||||||
|
const { callMode } = details;
|
||||||
|
|
||||||
|
let callId: string;
|
||||||
|
let type: CallType;
|
||||||
|
let direction: CallDirection;
|
||||||
|
let status: GroupCallStatus | DirectCallStatus;
|
||||||
|
let timestamp: number;
|
||||||
|
let ringerId: string | null = null;
|
||||||
|
|
||||||
|
if (details.callMode === CallMode.Direct) {
|
||||||
|
callId = details.callId;
|
||||||
|
type = details.wasVideoCall ? CallType.Video : CallType.Audio;
|
||||||
|
direction = details.wasIncoming
|
||||||
|
? CallDirection.Incoming
|
||||||
|
: CallDirection.Outgoing;
|
||||||
|
if (details.acceptedTime != null) {
|
||||||
|
status = DirectCallStatus.Accepted;
|
||||||
|
} else {
|
||||||
|
status = details.wasDeclined
|
||||||
|
? DirectCallStatus.Declined
|
||||||
|
: DirectCallStatus.Missed;
|
||||||
|
}
|
||||||
|
timestamp = details.endedTime ?? details.acceptedTime ?? null;
|
||||||
|
} else if (details.callMode === CallMode.Group) {
|
||||||
|
callId = Long.fromValue(callIdFromEra(details.eraId)).toString();
|
||||||
|
type = CallType.Group;
|
||||||
|
direction =
|
||||||
|
details.creatorUuid === ourUuid
|
||||||
|
? CallDirection.Outgoing
|
||||||
|
: CallDirection.Incoming;
|
||||||
|
status = GroupCallStatus.GenericGroupCall;
|
||||||
|
timestamp = details.startedTime;
|
||||||
|
ringerId = details.creatorUuid;
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
`updateToSchemaVersion87: unknown callMode: ${details.callMode}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callId == null) {
|
||||||
|
logger.error(
|
||||||
|
"updateToSchemaVersion87: callId doesn't exist, too old, skipping"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callHistory: CallHistoryDetails = {
|
||||||
|
callId,
|
||||||
|
peerId,
|
||||||
|
ringerId,
|
||||||
|
mode: callMode,
|
||||||
|
type,
|
||||||
|
direction,
|
||||||
|
status,
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = callHistoryDetailsSchema.safeParse(callHistory);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
logger.error(
|
||||||
|
`updateToSchemaVersion87: invalid callHistoryDetails (error: ${JSON.stringify(
|
||||||
|
result.error.format()
|
||||||
|
)}, input: ${JSON.stringify(json)}, output: ${JSON.stringify(
|
||||||
|
callHistory
|
||||||
|
)}))`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [insertQuery, insertParams] = sql`
|
||||||
|
INSERT INTO callsHistory (
|
||||||
|
callId,
|
||||||
|
peerId,
|
||||||
|
ringerId,
|
||||||
|
mode,
|
||||||
|
type,
|
||||||
|
direction,
|
||||||
|
status,
|
||||||
|
timestamp
|
||||||
|
) VALUES (
|
||||||
|
${callHistory.callId},
|
||||||
|
${callHistory.peerId},
|
||||||
|
${callHistory.ringerId},
|
||||||
|
${callHistory.mode},
|
||||||
|
${callHistory.type},
|
||||||
|
${callHistory.direction},
|
||||||
|
${callHistory.status},
|
||||||
|
${callHistory.timestamp}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.prepare(insertQuery).run(insertParams);
|
||||||
|
|
||||||
|
const [updateQuery, updateParams] = sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET json = JSON_PATCH(json, ${JSON.stringify({
|
||||||
|
callHistoryDetails: null, // delete
|
||||||
|
callId,
|
||||||
|
})})
|
||||||
|
WHERE id = ${row.id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.prepare(updateQuery).run(updateParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.pragma('user_version = 87');
|
||||||
|
})();
|
||||||
|
|
||||||
|
logger.info('updateToSchemaVersion87: success!');
|
||||||
|
}
|
|
@ -62,6 +62,7 @@ import updateToSchemaVersion83 from './83-mentions';
|
||||||
import updateToSchemaVersion84 from './84-all-mentions';
|
import updateToSchemaVersion84 from './84-all-mentions';
|
||||||
import updateToSchemaVersion85 from './85-add-kyber-keys';
|
import updateToSchemaVersion85 from './85-add-kyber-keys';
|
||||||
import updateToSchemaVersion86 from './86-story-replies-index';
|
import updateToSchemaVersion86 from './86-story-replies-index';
|
||||||
|
import updateToSchemaVersion87 from './87-calls-history-table';
|
||||||
|
|
||||||
function updateToSchemaVersion1(
|
function updateToSchemaVersion1(
|
||||||
currentVersion: number,
|
currentVersion: number,
|
||||||
|
@ -1994,6 +1995,7 @@ export const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion84,
|
updateToSchemaVersion84,
|
||||||
updateToSchemaVersion85,
|
updateToSchemaVersion85,
|
||||||
updateToSchemaVersion86,
|
updateToSchemaVersion86,
|
||||||
|
updateToSchemaVersion87,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function updateSchema(db: Database, logger: LoggerType): void {
|
export function updateSchema(db: Database, logger: LoggerType): void {
|
||||||
|
|
|
@ -36,7 +36,7 @@ export function jsonToObject<T>(json: string): T {
|
||||||
return JSON.parse(json);
|
return JSON.parse(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QueryTemplateParam = string | number | undefined;
|
export type QueryTemplateParam = string | number | null | undefined;
|
||||||
export type QueryFragmentValue = QueryFragment | QueryTemplateParam;
|
export type QueryFragmentValue = QueryFragment | QueryTemplateParam;
|
||||||
|
|
||||||
export type QueryFragment = [
|
export type QueryFragment = [
|
||||||
|
@ -66,7 +66,7 @@ export function sqlFragment(
|
||||||
...values: ReadonlyArray<QueryFragmentValue>
|
...values: ReadonlyArray<QueryFragmentValue>
|
||||||
): QueryFragment {
|
): QueryFragment {
|
||||||
let query = '';
|
let query = '';
|
||||||
const params: Array<string | number | undefined> = [];
|
const params: Array<QueryTemplateParam> = [];
|
||||||
|
|
||||||
strings.forEach((string, index) => {
|
strings.forEach((string, index) => {
|
||||||
const value = values[index];
|
const value = values[index];
|
||||||
|
@ -88,6 +88,20 @@ export function sqlFragment(
|
||||||
return [{ fragment: query }, params];
|
return [{ fragment: query }, params];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sqlConstant(value: QueryTemplateParam): QueryFragment {
|
||||||
|
let fragment;
|
||||||
|
if (value == null) {
|
||||||
|
fragment = 'NULL';
|
||||||
|
} else if (typeof value === 'number') {
|
||||||
|
fragment = `${value}`;
|
||||||
|
} else if (typeof value === 'boolean') {
|
||||||
|
fragment = `${value}`;
|
||||||
|
} else {
|
||||||
|
fragment = `'${value}'`;
|
||||||
|
}
|
||||||
|
return [{ fragment }, []];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Like `Array.prototype.join`, but for SQL fragments.
|
* Like `Array.prototype.join`, but for SQL fragments.
|
||||||
*/
|
*/
|
||||||
|
@ -96,7 +110,7 @@ export function sqlJoin(
|
||||||
separator: string
|
separator: string
|
||||||
): QueryFragment {
|
): QueryFragment {
|
||||||
let query = '';
|
let query = '';
|
||||||
const params: Array<string | number | undefined> = [];
|
const params: Array<QueryTemplateParam> = [];
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
const [{ fragment }, fragmentParams] = sqlFragment`${item}`;
|
const [{ fragment }, fragmentParams] = sqlFragment`${item}`;
|
||||||
|
@ -111,10 +125,7 @@ export function sqlJoin(
|
||||||
return [{ fragment: query }, params];
|
return [{ fragment: query }, params];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QueryTemplate = [
|
export type QueryTemplate = [string, ReadonlyArray<QueryTemplateParam>];
|
||||||
string,
|
|
||||||
ReadonlyArray<string | number | undefined>
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* You can use tagged template literals to build SQL queries
|
* You can use tagged template literals to build SQL queries
|
||||||
|
@ -137,7 +148,7 @@ export type QueryTemplate = [
|
||||||
*/
|
*/
|
||||||
export function sql(
|
export function sql(
|
||||||
strings: TemplateStringsArray,
|
strings: TemplateStringsArray,
|
||||||
...values: ReadonlyArray<QueryFragment | string | number | undefined>
|
...values: ReadonlyArray<QueryFragment | QueryTemplateParam>
|
||||||
): QueryTemplate {
|
): QueryTemplate {
|
||||||
const [{ fragment }, params] = sqlFragment(strings, ...values);
|
const [{ fragment }, params] = sqlFragment(strings, ...values);
|
||||||
return [fragment, params];
|
return [fragment, params];
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { actions as app } from './ducks/app';
|
||||||
import { actions as audioPlayer } from './ducks/audioPlayer';
|
import { actions as audioPlayer } from './ducks/audioPlayer';
|
||||||
import { actions as audioRecorder } from './ducks/audioRecorder';
|
import { actions as audioRecorder } from './ducks/audioRecorder';
|
||||||
import { actions as badges } from './ducks/badges';
|
import { actions as badges } from './ducks/badges';
|
||||||
|
import { actions as callHistory } from './ducks/callHistory';
|
||||||
import { actions as calling } from './ducks/calling';
|
import { actions as calling } from './ducks/calling';
|
||||||
import { actions as composer } from './ducks/composer';
|
import { actions as composer } from './ducks/composer';
|
||||||
import { actions as conversations } from './ducks/conversations';
|
import { actions as conversations } from './ducks/conversations';
|
||||||
|
@ -36,6 +37,7 @@ export const actionCreators: ReduxActions = {
|
||||||
audioPlayer,
|
audioPlayer,
|
||||||
audioRecorder,
|
audioRecorder,
|
||||||
badges,
|
badges,
|
||||||
|
callHistory,
|
||||||
calling,
|
calling,
|
||||||
composer,
|
composer,
|
||||||
conversations,
|
conversations,
|
||||||
|
|
91
ts/state/ducks/callHistory.ts
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { ReadonlyDeep } from 'type-fest';
|
||||||
|
import type { ThunkAction } from 'redux-thunk';
|
||||||
|
import type { StateType as RootStateType } from '../reducer';
|
||||||
|
import { clearCallHistoryDataAndSync } from '../../util/callDisposition';
|
||||||
|
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||||
|
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
|
import type { ToastActionType } from './toast';
|
||||||
|
import { showToast } from './toast';
|
||||||
|
import { ToastType } from '../../types/Toast';
|
||||||
|
import type { CallHistoryDetails } from '../../types/CallDisposition';
|
||||||
|
|
||||||
|
export type CallHistoryState = ReadonlyDeep<{
|
||||||
|
// This informs the app that underlying call history data has changed.
|
||||||
|
edition: number;
|
||||||
|
callHistoryByCallId: Record<string, CallHistoryDetails>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const CALL_HISTORY_CACHE = 'callHistory/CACHE';
|
||||||
|
const CALL_HISTORY_CLEAR = 'callHistory/CLEAR';
|
||||||
|
|
||||||
|
export type CallHistoryCache = ReadonlyDeep<{
|
||||||
|
type: typeof CALL_HISTORY_CACHE;
|
||||||
|
payload: CallHistoryDetails;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type CallHistoryClear = ReadonlyDeep<{
|
||||||
|
type: typeof CALL_HISTORY_CLEAR;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type CallHistoryAction = ReadonlyDeep<
|
||||||
|
CallHistoryCache | CallHistoryClear
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function getEmptyState(): CallHistoryState {
|
||||||
|
return {
|
||||||
|
edition: 0,
|
||||||
|
callHistoryByCallId: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheCallHistory(callHistory: CallHistoryDetails): CallHistoryCache {
|
||||||
|
return {
|
||||||
|
type: CALL_HISTORY_CACHE,
|
||||||
|
payload: callHistory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllCallHistory(): ThunkAction<
|
||||||
|
void,
|
||||||
|
RootStateType,
|
||||||
|
unknown,
|
||||||
|
CallHistoryClear | ToastActionType
|
||||||
|
> {
|
||||||
|
return async dispatch => {
|
||||||
|
await clearCallHistoryDataAndSync();
|
||||||
|
dispatch({ type: CALL_HISTORY_CLEAR });
|
||||||
|
dispatch(showToast({ toastType: ToastType.CallHistoryCleared }));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
cacheCallHistory,
|
||||||
|
clearAllCallHistory,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCallHistoryActions = (): BoundActionCreatorsMapObject<
|
||||||
|
typeof actions
|
||||||
|
> => useBoundActions(actions);
|
||||||
|
|
||||||
|
export function reducer(
|
||||||
|
state: CallHistoryState = getEmptyState(),
|
||||||
|
action: CallHistoryAction
|
||||||
|
): CallHistoryState {
|
||||||
|
switch (action.type) {
|
||||||
|
case CALL_HISTORY_CLEAR:
|
||||||
|
return { ...state, edition: state.edition + 1, callHistoryByCallId: {} };
|
||||||
|
case CALL_HISTORY_CACHE:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
callHistoryByCallId: {
|
||||||
|
...state.callHistoryByCallId,
|
||||||
|
[action.payload.callId]: action.payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
import { ipcRenderer } from 'electron';
|
import { ipcRenderer } from 'electron';
|
||||||
import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
|
import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
|
||||||
import { CallEndedReason } from '@signalapp/ringrtc';
|
|
||||||
import {
|
import {
|
||||||
hasScreenCapturePermission,
|
hasScreenCapturePermission,
|
||||||
openSystemPreferences,
|
openSystemPreferences,
|
||||||
|
@ -26,6 +25,7 @@ import type {
|
||||||
PresentableSource,
|
PresentableSource,
|
||||||
} from '../../types/Calling';
|
} from '../../types/Calling';
|
||||||
import {
|
import {
|
||||||
|
CallEndedReason,
|
||||||
CallingDeviceType,
|
CallingDeviceType,
|
||||||
CallMode,
|
CallMode,
|
||||||
CallViewMode,
|
CallViewMode,
|
||||||
|
@ -53,8 +53,6 @@ import { isDirectConversation } from '../../util/whatTypeOfConversation';
|
||||||
import { SHOW_TOAST } from './toast';
|
import { SHOW_TOAST } from './toast';
|
||||||
import { ToastType } from '../../types/Toast';
|
import { ToastType } from '../../types/Toast';
|
||||||
import type { ShowToastActionType } from './toast';
|
import type { ShowToastActionType } from './toast';
|
||||||
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
|
|
||||||
import MessageSender from '../../textsecure/SendMessage';
|
|
||||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
import { isAnybodyElseInGroupCall } from './callingHelpers';
|
import { isAnybodyElseInGroupCall } from './callingHelpers';
|
||||||
|
@ -150,15 +148,10 @@ export type AcceptCallType = ReadonlyDeep<{
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type CallStateChangeType = ReadonlyDeep<{
|
export type CallStateChangeType = ReadonlyDeep<{
|
||||||
remoteUserId: string; // TODO: Remove
|
|
||||||
callId: string; // TODO: Remove
|
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
acceptedTime?: number;
|
acceptedTime?: number;
|
||||||
callState: CallState;
|
callState: CallState;
|
||||||
callEndedReason?: CallEndedReason;
|
callEndedReason?: CallEndedReason;
|
||||||
isIncoming: boolean;
|
|
||||||
isVideoCall: boolean;
|
|
||||||
title: string;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type CancelCallType = ReadonlyDeep<{
|
export type CancelCallType = ReadonlyDeep<{
|
||||||
|
@ -363,7 +356,7 @@ const doGroupCallPeek = (
|
||||||
// to only be peeking once.
|
// to only be peeking once.
|
||||||
await Promise.all([sleep(1000), waitForOnline(navigator, window)]);
|
await Promise.all([sleep(1000), waitForOnline(navigator, window)]);
|
||||||
|
|
||||||
let peekInfo;
|
let peekInfo = null;
|
||||||
try {
|
try {
|
||||||
peekInfo = await calling.peekGroupCall(conversationId);
|
peekInfo = await calling.peekGroupCall(conversationId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -689,38 +682,18 @@ function callStateChange(
|
||||||
CallStateChangeFulfilledActionType
|
CallStateChangeFulfilledActionType
|
||||||
> {
|
> {
|
||||||
return async dispatch => {
|
return async dispatch => {
|
||||||
const {
|
const { callState, acceptedTime, callEndedReason } = payload;
|
||||||
callId,
|
|
||||||
callState,
|
|
||||||
isVideoCall,
|
|
||||||
isIncoming,
|
|
||||||
acceptedTime,
|
|
||||||
callEndedReason,
|
|
||||||
remoteUserId,
|
|
||||||
} = payload;
|
|
||||||
|
|
||||||
if (callState === CallState.Ended) {
|
if (callState === CallState.Ended) {
|
||||||
ipcRenderer.send('close-screen-share-controller');
|
ipcRenderer.send('close-screen-share-controller');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOutgoing = !isIncoming;
|
|
||||||
const wasAccepted = acceptedTime != null;
|
const wasAccepted = acceptedTime != null;
|
||||||
const isConnected = callState === CallState.Accepted; // "connected"
|
|
||||||
const isEnded = callState === CallState.Ended && callEndedReason != null;
|
const isEnded = callState === CallState.Ended && callEndedReason != null;
|
||||||
|
|
||||||
const isLocalHangup = callEndedReason === CallEndedReason.LocalHangup;
|
const isLocalHangup = callEndedReason === CallEndedReason.LocalHangup;
|
||||||
const isRemoteHangup = callEndedReason === CallEndedReason.RemoteHangup;
|
const isRemoteHangup = callEndedReason === CallEndedReason.RemoteHangup;
|
||||||
|
|
||||||
const answered = isConnected && wasAccepted;
|
|
||||||
const notAnswered = isEnded && !wasAccepted;
|
|
||||||
|
|
||||||
const isOutgoingRemoteAccept = isOutgoing && isConnected && answered;
|
|
||||||
const isIncomingLocalAccept = isIncoming && isConnected && answered;
|
|
||||||
const isOutgoingLocalHangup = isOutgoing && isLocalHangup && notAnswered;
|
|
||||||
const isIncomingLocalHangup = isIncoming && isLocalHangup && notAnswered;
|
|
||||||
const isOutgoingRemoteHangup = isOutgoing && isRemoteHangup && notAnswered;
|
|
||||||
const isIncomingRemoteHangup = isIncoming && isRemoteHangup && notAnswered;
|
|
||||||
|
|
||||||
// Play the hangup noise if:
|
// Play the hangup noise if:
|
||||||
if (
|
if (
|
||||||
// 1. I hungup (or declined)
|
// 1. I hungup (or declined)
|
||||||
|
@ -733,37 +706,6 @@ function callStateChange(
|
||||||
await callingTones.playEndCall();
|
await callingTones.playEndCall();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isIncomingRemoteHangup) {
|
|
||||||
// This is considered just another "missed" event
|
|
||||||
log.info(
|
|
||||||
`callStateChange: not syncing hangup from self (Call ID: ${callId}))`
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
isOutgoingRemoteAccept ||
|
|
||||||
isIncomingLocalAccept ||
|
|
||||||
isOutgoingLocalHangup ||
|
|
||||||
isIncomingLocalHangup ||
|
|
||||||
isOutgoingRemoteHangup
|
|
||||||
) {
|
|
||||||
log.info(`callStateChange: syncing call event (Call ID: ${callId})`);
|
|
||||||
try {
|
|
||||||
await singleProtoJobQueue.add(
|
|
||||||
MessageSender.getCallEventSync(
|
|
||||||
remoteUserId,
|
|
||||||
callId,
|
|
||||||
isVideoCall,
|
|
||||||
isIncoming,
|
|
||||||
acceptedTime != null
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
log.error(
|
|
||||||
'callStateChange: Failed to queue sync message',
|
|
||||||
Errors.toLogFormat(error)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CALL_STATE_CHANGE_FULFILLED,
|
type: CALL_STATE_CHANGE_FULFILLED,
|
||||||
payload,
|
payload,
|
||||||
|
@ -1326,10 +1268,12 @@ function onOutgoingVideoCallInConversation(
|
||||||
log.info(
|
log.info(
|
||||||
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
|
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
|
||||||
);
|
);
|
||||||
startCallingLobby({
|
dispatch(
|
||||||
conversationId,
|
startCallingLobby({
|
||||||
isVideoCall: true,
|
conversationId,
|
||||||
})(dispatch, getState, undefined);
|
isVideoCall: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
log.info('onOutgoingVideoCallInConversation: started the call');
|
log.info('onOutgoingVideoCallInConversation: started the call');
|
||||||
} else {
|
} else {
|
||||||
log.info(
|
log.info(
|
||||||
|
|
|
@ -159,6 +159,8 @@ import { ReceiptType } from '../../types/Receipt';
|
||||||
import { sortByMessageOrder } from '../../util/maybeForwardMessages';
|
import { sortByMessageOrder } from '../../util/maybeForwardMessages';
|
||||||
import { Sound, SoundType } from '../../util/Sound';
|
import { Sound, SoundType } from '../../util/Sound';
|
||||||
import { canEditMessage } from '../../util/canEditMessage';
|
import { canEditMessage } from '../../util/canEditMessage';
|
||||||
|
import type { ChangeNavTabActionType } from './nav';
|
||||||
|
import { CHANGE_NAV_TAB, NavTab, actions as navActions } from './nav';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
|
@ -3911,14 +3913,18 @@ function showConversation({
|
||||||
void,
|
void,
|
||||||
RootStateType,
|
RootStateType,
|
||||||
unknown,
|
unknown,
|
||||||
TargetedConversationChangedActionType
|
TargetedConversationChangedActionType | ChangeNavTabActionType
|
||||||
> {
|
> {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const { conversations } = getState();
|
const { conversations, nav } = getState();
|
||||||
|
|
||||||
|
if (nav.selectedNavTab !== NavTab.Chats) {
|
||||||
|
dispatch(navActions.changeNavTab(NavTab.Chats));
|
||||||
|
}
|
||||||
|
|
||||||
if (conversationId === conversations.selectedConversationId) {
|
if (conversationId === conversations.selectedConversationId) {
|
||||||
if (conversationId && messageId) {
|
if (conversationId && messageId) {
|
||||||
scrollToMessage(conversationId, messageId)(dispatch, getState, null);
|
dispatch(scrollToMessage(conversationId, messageId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
@ -4383,7 +4389,11 @@ function maybeUpdateSelectedMessageForDetails(
|
||||||
|
|
||||||
export function reducer(
|
export function reducer(
|
||||||
state: Readonly<ConversationsStateType> = getEmptyState(),
|
state: Readonly<ConversationsStateType> = getEmptyState(),
|
||||||
action: Readonly<ConversationActionType | StoryDistributionListsActionType>
|
action: Readonly<
|
||||||
|
| ConversationActionType
|
||||||
|
| StoryDistributionListsActionType
|
||||||
|
| ChangeNavTabActionType
|
||||||
|
>
|
||||||
): ConversationsStateType {
|
): ConversationsStateType {
|
||||||
if (action.type === CLEAR_CONVERSATIONS_PENDING_VERIFICATION) {
|
if (action.type === CLEAR_CONVERSATIONS_PENDING_VERIFICATION) {
|
||||||
return {
|
return {
|
||||||
|
@ -6168,5 +6178,31 @@ export function reducer(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
action.type === CHANGE_NAV_TAB &&
|
||||||
|
action.payload.selectedNavTab === NavTab.Chats
|
||||||
|
) {
|
||||||
|
const { messagesByConversation, selectedConversationId } = state;
|
||||||
|
if (selectedConversationId == null) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingConversation = messagesByConversation[selectedConversationId];
|
||||||
|
if (existingConversation == null) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
messagesByConversation: {
|
||||||
|
...messagesByConversation,
|
||||||
|
[selectedConversationId]: {
|
||||||
|
...existingConversation,
|
||||||
|
isNearBottom: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,6 @@ import type { ConfigMapType as RemoteConfigType } from '../../RemoteConfig';
|
||||||
export type ItemsStateType = ReadonlyDeep<
|
export type ItemsStateType = ReadonlyDeep<
|
||||||
{
|
{
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|
||||||
remoteConfig?: RemoteConfigType;
|
remoteConfig?: RemoteConfigType;
|
||||||
serverTimeSkew?: number;
|
serverTimeSkew?: number;
|
||||||
} & Partial<
|
} & Partial<
|
||||||
|
@ -35,6 +34,7 @@ export type ItemsStateType = ReadonlyDeep<
|
||||||
| 'defaultConversationColor'
|
| 'defaultConversationColor'
|
||||||
| 'customColors'
|
| 'customColors'
|
||||||
| 'preferredLeftPaneWidth'
|
| 'preferredLeftPaneWidth'
|
||||||
|
| 'navTabsCollapsed'
|
||||||
| 'preferredReactionEmoji'
|
| 'preferredReactionEmoji'
|
||||||
| 'areWeASubscriber'
|
| 'areWeASubscriber'
|
||||||
| 'usernameLinkColor'
|
| 'usernameLinkColor'
|
||||||
|
@ -90,6 +90,7 @@ export const actions = {
|
||||||
resetDefaultChatColor,
|
resetDefaultChatColor,
|
||||||
savePreferredLeftPaneWidth,
|
savePreferredLeftPaneWidth,
|
||||||
setGlobalDefaultConversationColor,
|
setGlobalDefaultConversationColor,
|
||||||
|
toggleNavTabsCollapse,
|
||||||
onSetSkinTone,
|
onSetSkinTone,
|
||||||
putItem,
|
putItem,
|
||||||
putItemExternal,
|
putItemExternal,
|
||||||
|
@ -98,8 +99,9 @@ export const actions = {
|
||||||
resetItems,
|
resetItems,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useActions = (): BoundActionCreatorsMapObject<typeof actions> =>
|
export const useItemsActions = (): BoundActionCreatorsMapObject<
|
||||||
useBoundActions(actions);
|
typeof actions
|
||||||
|
> => useBoundActions(actions);
|
||||||
|
|
||||||
function putItem<K extends keyof StorageAccessType>(
|
function putItem<K extends keyof StorageAccessType>(
|
||||||
key: K,
|
key: K,
|
||||||
|
@ -292,6 +294,14 @@ function markHasCompletedSafetyNumberOnboarding(): ThunkAction<
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleNavTabsCollapse(
|
||||||
|
navTabsCollapsed: boolean
|
||||||
|
): ThunkAction<void, RootStateType, unknown, ItemPutAction> {
|
||||||
|
return dispatch => {
|
||||||
|
dispatch(putItem('navTabsCollapsed', navTabsCollapsed));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Reducer
|
// Reducer
|
||||||
|
|
||||||
export function getEmptyState(): ItemsStateType {
|
export function getEmptyState(): ItemsStateType {
|
||||||
|
|
69
ts/state/ducks/nav.ts
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { ReadonlyDeep } from 'type-fest';
|
||||||
|
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||||
|
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
|
||||||
|
export enum NavTab {
|
||||||
|
Chats = 'Chats',
|
||||||
|
Calls = 'Calls',
|
||||||
|
Stories = 'Stories',
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
|
||||||
|
export type NavStateType = ReadonlyDeep<{
|
||||||
|
selectedNavTab: NavTab;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
|
||||||
|
export const CHANGE_NAV_TAB = 'nav/CHANGE_NAV_TAB';
|
||||||
|
|
||||||
|
export type ChangeNavTabActionType = ReadonlyDeep<{
|
||||||
|
type: typeof CHANGE_NAV_TAB;
|
||||||
|
payload: { selectedNavTab: NavTab };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type NavActionType = ReadonlyDeep<ChangeNavTabActionType>;
|
||||||
|
|
||||||
|
// Action Creators
|
||||||
|
|
||||||
|
function changeNavTab(selectedNavTab: NavTab): NavActionType {
|
||||||
|
return {
|
||||||
|
type: CHANGE_NAV_TAB,
|
||||||
|
payload: { selectedNavTab },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
changeNavTab,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNavActions = (): BoundActionCreatorsMapObject<typeof actions> =>
|
||||||
|
useBoundActions(actions);
|
||||||
|
|
||||||
|
// Reducer
|
||||||
|
|
||||||
|
export function getEmptyState(): NavStateType {
|
||||||
|
return {
|
||||||
|
selectedNavTab: NavTab.Chats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reducer(
|
||||||
|
state: Readonly<NavStateType> = getEmptyState(),
|
||||||
|
action: Readonly<NavActionType>
|
||||||
|
): NavStateType {
|
||||||
|
if (action.type === CHANGE_NAV_TAB) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectedNavTab: action.payload.selectedNavTab,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
|
@ -143,7 +143,6 @@ export type StoriesStateType = Readonly<{
|
||||||
addStoryData: AddStoryData;
|
addStoryData: AddStoryData;
|
||||||
hasAllStoriesUnmuted: boolean;
|
hasAllStoriesUnmuted: boolean;
|
||||||
lastOpenedAtTimestamp: number | undefined;
|
lastOpenedAtTimestamp: number | undefined;
|
||||||
openedAtTimestamp: number | undefined;
|
|
||||||
replyState?: Readonly<{
|
replyState?: Readonly<{
|
||||||
messageId: string;
|
messageId: string;
|
||||||
replies: Array<MessageAttributesType>;
|
replies: Array<MessageAttributesType>;
|
||||||
|
@ -163,7 +162,8 @@ const QUEUE_STORY_DOWNLOAD = 'stories/QUEUE_STORY_DOWNLOAD';
|
||||||
const SEND_STORY_MODAL_OPEN_STATE_CHANGED =
|
const SEND_STORY_MODAL_OPEN_STATE_CHANGED =
|
||||||
'stories/SEND_STORY_MODAL_OPEN_STATE_CHANGED';
|
'stories/SEND_STORY_MODAL_OPEN_STATE_CHANGED';
|
||||||
const STORY_CHANGED = 'stories/STORY_CHANGED';
|
const STORY_CHANGED = 'stories/STORY_CHANGED';
|
||||||
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
|
const CLEAR_STORIES_TAB_STATE = 'stories/CLEAR_STORIES_TAB_STATE';
|
||||||
|
const MARK_STORIES_TAB_VIEWED = 'stories/MARK_STORIES_TAB_VIEWED';
|
||||||
const VIEW_STORY = 'stories/VIEW_STORY';
|
const VIEW_STORY = 'stories/VIEW_STORY';
|
||||||
const STORY_REPLY_DELETED = 'stories/STORY_REPLY_DELETED';
|
const STORY_REPLY_DELETED = 'stories/STORY_REPLY_DELETED';
|
||||||
const REMOVE_ALL_STORIES = 'stories/REMOVE_ALL_STORIES';
|
const REMOVE_ALL_STORIES = 'stories/REMOVE_ALL_STORIES';
|
||||||
|
@ -217,8 +217,12 @@ type StoryChangedActionType = ReadonlyDeep<{
|
||||||
payload: StoryDataType;
|
payload: StoryDataType;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type ToggleViewActionType = ReadonlyDeep<{
|
type ClearStoriesTabStateActionType = ReadonlyDeep<{
|
||||||
type: typeof TOGGLE_VIEW;
|
type: typeof CLEAR_STORIES_TAB_STATE;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type MarkStoriesTabViewedActionType = ReadonlyDeep<{
|
||||||
|
type: typeof MARK_STORIES_TAB_VIEWED;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type ViewStoryActionType = ReadonlyDeep<{
|
type ViewStoryActionType = ReadonlyDeep<{
|
||||||
|
@ -262,7 +266,8 @@ export type StoriesActionType =
|
||||||
| QueueStoryDownloadActionType
|
| QueueStoryDownloadActionType
|
||||||
| SendStoryModalOpenStateChanged
|
| SendStoryModalOpenStateChanged
|
||||||
| StoryChangedActionType
|
| StoryChangedActionType
|
||||||
| ToggleViewActionType
|
| ClearStoriesTabStateActionType
|
||||||
|
| MarkStoriesTabViewedActionType
|
||||||
| ViewStoryActionType
|
| ViewStoryActionType
|
||||||
| StoryReplyDeletedActionType
|
| StoryReplyDeletedActionType
|
||||||
| RemoveAllStoriesActionType
|
| RemoveAllStoriesActionType
|
||||||
|
@ -627,7 +632,7 @@ function sendStoryMessage(
|
||||||
> {
|
> {
|
||||||
return async (dispatch, getState) => {
|
return async (dispatch, getState) => {
|
||||||
const { stories } = getState();
|
const { stories } = getState();
|
||||||
const { openedAtTimestamp, sendStoryModalData } = stories;
|
const { lastOpenedAtTimestamp, sendStoryModalData } = stories;
|
||||||
|
|
||||||
// Add spinners in the story creator
|
// Add spinners in the story creator
|
||||||
dispatch({
|
dispatch({
|
||||||
|
@ -636,8 +641,8 @@ function sendStoryMessage(
|
||||||
});
|
});
|
||||||
|
|
||||||
assertDev(
|
assertDev(
|
||||||
openedAtTimestamp,
|
lastOpenedAtTimestamp,
|
||||||
'sendStoryMessage: openedAtTimestamp is undefined, cannot send'
|
'sendStoryMessage: lastOpenedAtTimestamp is undefined, cannot send'
|
||||||
);
|
);
|
||||||
assertDev(
|
assertDev(
|
||||||
sendStoryModalData,
|
sendStoryModalData,
|
||||||
|
@ -649,7 +654,7 @@ function sendStoryMessage(
|
||||||
const result = await blockSendUntilConversationsAreVerified(
|
const result = await blockSendUntilConversationsAreVerified(
|
||||||
sendStoryModalData,
|
sendStoryModalData,
|
||||||
SafetyNumberChangeSource.Story,
|
SafetyNumberChangeSource.Story,
|
||||||
Date.now() - openedAtTimestamp
|
Date.now() - lastOpenedAtTimestamp
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
|
@ -720,9 +725,15 @@ function sendStoryModalOpenStateChanged(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleStoriesView(): ToggleViewActionType {
|
function clearStoriesTabState(): ClearStoriesTabStateActionType {
|
||||||
return {
|
return {
|
||||||
type: TOGGLE_VIEW,
|
type: CLEAR_STORIES_TAB_STATE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function markStoriesTabViewed(): MarkStoriesTabViewedActionType {
|
||||||
|
return {
|
||||||
|
type: MARK_STORIES_TAB_VIEWED,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1415,7 +1426,8 @@ export const actions = {
|
||||||
sendStoryMessage,
|
sendStoryMessage,
|
||||||
sendStoryModalOpenStateChanged,
|
sendStoryModalOpenStateChanged,
|
||||||
storyChanged,
|
storyChanged,
|
||||||
toggleStoriesView,
|
clearStoriesTabState,
|
||||||
|
markStoriesTabViewed,
|
||||||
verifyStoryListMembers,
|
verifyStoryListMembers,
|
||||||
viewUserStories,
|
viewUserStories,
|
||||||
viewStory,
|
viewStory,
|
||||||
|
@ -1439,7 +1451,6 @@ export function getEmptyState(
|
||||||
): StoriesStateType {
|
): StoriesStateType {
|
||||||
return {
|
return {
|
||||||
lastOpenedAtTimestamp: undefined,
|
lastOpenedAtTimestamp: undefined,
|
||||||
openedAtTimestamp: undefined,
|
|
||||||
addStoryData: undefined,
|
addStoryData: undefined,
|
||||||
stories: [],
|
stories: [],
|
||||||
hasAllStoriesUnmuted: false,
|
hasAllStoriesUnmuted: false,
|
||||||
|
@ -1451,20 +1462,22 @@ export function reducer(
|
||||||
state: Readonly<StoriesStateType> = getEmptyState(),
|
state: Readonly<StoriesStateType> = getEmptyState(),
|
||||||
action: Readonly<StoriesActionType>
|
action: Readonly<StoriesActionType>
|
||||||
): StoriesStateType {
|
): StoriesStateType {
|
||||||
if (action.type === TOGGLE_VIEW) {
|
if (action.type === MARK_STORIES_TAB_VIEWED) {
|
||||||
const isShowingStoriesView = Boolean(state.openedAtTimestamp);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
lastOpenedAtTimestamp: !isShowingStoriesView
|
lastOpenedAtTimestamp: Date.now(),
|
||||||
? state.openedAtTimestamp || Date.now()
|
|
||||||
: state.lastOpenedAtTimestamp,
|
|
||||||
openedAtTimestamp: isShowingStoriesView ? undefined : Date.now(),
|
|
||||||
replyState: undefined,
|
replyState: undefined,
|
||||||
sendStoryModalData: undefined,
|
sendStoryModalData: undefined,
|
||||||
selectedStoryData: isShowingStoriesView
|
};
|
||||||
? undefined
|
}
|
||||||
: state.selectedStoryData,
|
|
||||||
|
if (action.type === CLEAR_STORIES_TAB_STATE) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
replyState: undefined,
|
||||||
|
sendStoryModalData: undefined,
|
||||||
|
selectedStoryData: undefined,
|
||||||
|
addStoryData: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1851,8 +1864,6 @@ export function reducer(
|
||||||
if (action.type === TARGETED_CONVERSATION_CHANGED) {
|
if (action.type === TARGETED_CONVERSATION_CHANGED) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
lastOpenedAtTimestamp: state.openedAtTimestamp || Date.now(),
|
|
||||||
openedAtTimestamp: undefined,
|
|
||||||
replyState: undefined,
|
replyState: undefined,
|
||||||
sendStoryModalData: undefined,
|
sendStoryModalData: undefined,
|
||||||
selectedStoryData: undefined,
|
selectedStoryData: undefined,
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { getEmptyState as accounts } from './ducks/accounts';
|
||||||
import { getEmptyState as app } from './ducks/app';
|
import { getEmptyState as app } from './ducks/app';
|
||||||
import { getEmptyState as audioPlayer } from './ducks/audioPlayer';
|
import { getEmptyState as audioPlayer } from './ducks/audioPlayer';
|
||||||
import { getEmptyState as audioRecorder } from './ducks/audioRecorder';
|
import { getEmptyState as audioRecorder } from './ducks/audioRecorder';
|
||||||
|
import { getEmptyState as callHistory } from './ducks/callHistory';
|
||||||
import { getEmptyState as calling } from './ducks/calling';
|
import { getEmptyState as calling } from './ducks/calling';
|
||||||
import { getEmptyState as composer } from './ducks/composer';
|
import { getEmptyState as composer } from './ducks/composer';
|
||||||
import { getEmptyState as conversations } from './ducks/conversations';
|
import { getEmptyState as conversations } from './ducks/conversations';
|
||||||
|
@ -15,6 +16,7 @@ import { getEmptyState as inbox } from './ducks/inbox';
|
||||||
import { getEmptyState as lightbox } from './ducks/lightbox';
|
import { getEmptyState as lightbox } from './ducks/lightbox';
|
||||||
import { getEmptyState as linkPreviews } from './ducks/linkPreviews';
|
import { getEmptyState as linkPreviews } from './ducks/linkPreviews';
|
||||||
import { getEmptyState as mediaGallery } from './ducks/mediaGallery';
|
import { getEmptyState as mediaGallery } from './ducks/mediaGallery';
|
||||||
|
import { getEmptyState as nav } from './ducks/nav';
|
||||||
import { getEmptyState as network } from './ducks/network';
|
import { getEmptyState as network } from './ducks/network';
|
||||||
import { getEmptyState as preferredReactions } from './ducks/preferredReactions';
|
import { getEmptyState as preferredReactions } from './ducks/preferredReactions';
|
||||||
import { getEmptyState as safetyNumber } from './ducks/safetyNumber';
|
import { getEmptyState as safetyNumber } from './ducks/safetyNumber';
|
||||||
|
@ -39,15 +41,18 @@ import { getInitialState as stickers } from '../types/Stickers';
|
||||||
import { getThemeType } from '../util/getThemeType';
|
import { getThemeType } from '../util/getThemeType';
|
||||||
import { getInteractionMode } from '../services/InteractionMode';
|
import { getInteractionMode } from '../services/InteractionMode';
|
||||||
import { makeLookup } from '../util/makeLookup';
|
import { makeLookup } from '../util/makeLookup';
|
||||||
|
import type { CallHistoryDetails } from '../types/CallDisposition';
|
||||||
|
|
||||||
export function getInitialState({
|
export function getInitialState({
|
||||||
badges,
|
badges,
|
||||||
|
callsHistory,
|
||||||
stories,
|
stories,
|
||||||
storyDistributionLists,
|
storyDistributionLists,
|
||||||
mainWindowStats,
|
mainWindowStats,
|
||||||
menuOptions,
|
menuOptions,
|
||||||
}: {
|
}: {
|
||||||
badges: BadgesStateType;
|
badges: BadgesStateType;
|
||||||
|
callsHistory: ReadonlyArray<CallHistoryDetails>;
|
||||||
stories: Array<StoryDataType>;
|
stories: Array<StoryDataType>;
|
||||||
storyDistributionLists: Array<StoryDistributionListDataType>;
|
storyDistributionLists: Array<StoryDistributionListDataType>;
|
||||||
mainWindowStats: MainWindowStatsType;
|
mainWindowStats: MainWindowStatsType;
|
||||||
|
@ -88,6 +93,10 @@ export function getInitialState({
|
||||||
audioPlayer: audioPlayer(),
|
audioPlayer: audioPlayer(),
|
||||||
audioRecorder: audioRecorder(),
|
audioRecorder: audioRecorder(),
|
||||||
badges,
|
badges,
|
||||||
|
callHistory: {
|
||||||
|
...callHistory(),
|
||||||
|
callHistoryByCallId: makeLookup(callsHistory, 'callId'),
|
||||||
|
},
|
||||||
calling: calling(),
|
calling: calling(),
|
||||||
composer: composer(),
|
composer: composer(),
|
||||||
conversations: {
|
conversations: {
|
||||||
|
@ -110,6 +119,7 @@ export function getInitialState({
|
||||||
lightbox: lightbox(),
|
lightbox: lightbox(),
|
||||||
linkPreviews: linkPreviews(),
|
linkPreviews: linkPreviews(),
|
||||||
mediaGallery: mediaGallery(),
|
mediaGallery: mediaGallery(),
|
||||||
|
nav: nav(),
|
||||||
network: network(),
|
network: network(),
|
||||||
preferredReactions: preferredReactions(),
|
preferredReactions: preferredReactions(),
|
||||||
safetyNumber: safetyNumber(),
|
safetyNumber: safetyNumber(),
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { reducer as audioPlayer } from './ducks/audioPlayer';
|
||||||
import { reducer as audioRecorder } from './ducks/audioRecorder';
|
import { reducer as audioRecorder } from './ducks/audioRecorder';
|
||||||
import { reducer as badges } from './ducks/badges';
|
import { reducer as badges } from './ducks/badges';
|
||||||
import { reducer as calling } from './ducks/calling';
|
import { reducer as calling } from './ducks/calling';
|
||||||
|
import { reducer as callHistory } from './ducks/callHistory';
|
||||||
import { reducer as composer } from './ducks/composer';
|
import { reducer as composer } from './ducks/composer';
|
||||||
import { reducer as conversations } from './ducks/conversations';
|
import { reducer as conversations } from './ducks/conversations';
|
||||||
import { reducer as crashReports } from './ducks/crashReports';
|
import { reducer as crashReports } from './ducks/crashReports';
|
||||||
|
@ -20,6 +21,7 @@ import { reducer as items } from './ducks/items';
|
||||||
import { reducer as lightbox } from './ducks/lightbox';
|
import { reducer as lightbox } from './ducks/lightbox';
|
||||||
import { reducer as linkPreviews } from './ducks/linkPreviews';
|
import { reducer as linkPreviews } from './ducks/linkPreviews';
|
||||||
import { reducer as mediaGallery } from './ducks/mediaGallery';
|
import { reducer as mediaGallery } from './ducks/mediaGallery';
|
||||||
|
import { reducer as nav } from './ducks/nav';
|
||||||
import { reducer as network } from './ducks/network';
|
import { reducer as network } from './ducks/network';
|
||||||
import { reducer as preferredReactions } from './ducks/preferredReactions';
|
import { reducer as preferredReactions } from './ducks/preferredReactions';
|
||||||
import { reducer as safetyNumber } from './ducks/safetyNumber';
|
import { reducer as safetyNumber } from './ducks/safetyNumber';
|
||||||
|
@ -39,6 +41,7 @@ export const reducer = combineReducers({
|
||||||
audioRecorder,
|
audioRecorder,
|
||||||
badges,
|
badges,
|
||||||
calling,
|
calling,
|
||||||
|
callHistory,
|
||||||
composer,
|
composer,
|
||||||
conversations,
|
conversations,
|
||||||
crashReports,
|
crashReports,
|
||||||
|
@ -50,6 +53,7 @@ export const reducer = combineReducers({
|
||||||
lightbox,
|
lightbox,
|
||||||
linkPreviews,
|
linkPreviews,
|
||||||
mediaGallery,
|
mediaGallery,
|
||||||
|
nav,
|
||||||
network,
|
network,
|
||||||
preferredReactions,
|
preferredReactions,
|
||||||
safetyNumber,
|
safetyNumber,
|
||||||
|
|
31
ts/state/selectors/callHistory.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import type { CallHistoryState } from '../ducks/callHistory';
|
||||||
|
import type { StateType } from '../reducer';
|
||||||
|
import type { CallHistoryDetails } from '../../types/CallDisposition';
|
||||||
|
import { getOwn } from '../../util/getOwn';
|
||||||
|
|
||||||
|
const getCallHistory = (state: StateType): CallHistoryState =>
|
||||||
|
state.callHistory;
|
||||||
|
|
||||||
|
export const getCallHistoryEdition = createSelector(
|
||||||
|
getCallHistory,
|
||||||
|
callHistory => {
|
||||||
|
return callHistory.edition;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type CallHistorySelectorType = (
|
||||||
|
callId: string
|
||||||
|
) => CallHistoryDetails | void;
|
||||||
|
|
||||||
|
export const getCallHistorySelector = createSelector(
|
||||||
|
getCallHistory,
|
||||||
|
(callHistory): CallHistorySelectorType => {
|
||||||
|
return callId => {
|
||||||
|
return getOwn(callHistory.callHistoryByCallId, callId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
|
@ -308,16 +308,18 @@ export const getConversationComparator = createSelector(
|
||||||
_getConversationComparator
|
_getConversationComparator
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type LeftPaneLists = Readonly<{
|
||||||
|
conversations: ReadonlyArray<ConversationType>;
|
||||||
|
archivedConversations: ReadonlyArray<ConversationType>;
|
||||||
|
pinnedConversations: ReadonlyArray<ConversationType>;
|
||||||
|
}>;
|
||||||
|
|
||||||
export const _getLeftPaneLists = (
|
export const _getLeftPaneLists = (
|
||||||
lookup: ConversationLookupType,
|
lookup: ConversationLookupType,
|
||||||
comparator: (left: ConversationType, right: ConversationType) => number,
|
comparator: (left: ConversationType, right: ConversationType) => number,
|
||||||
selectedConversation?: string,
|
selectedConversation?: string,
|
||||||
pinnedConversationIds?: ReadonlyArray<string>
|
pinnedConversationIds?: ReadonlyArray<string>
|
||||||
): {
|
): LeftPaneLists => {
|
||||||
conversations: Array<ConversationType>;
|
|
||||||
archivedConversations: Array<ConversationType>;
|
|
||||||
pinnedConversations: Array<ConversationType>;
|
|
||||||
} => {
|
|
||||||
const conversations: Array<ConversationType> = [];
|
const conversations: Array<ConversationType> = [];
|
||||||
const archivedConversations: Array<ConversationType> = [];
|
const archivedConversations: Array<ConversationType> = [];
|
||||||
const pinnedConversations: Array<ConversationType> = [];
|
const pinnedConversations: Array<ConversationType> = [];
|
||||||
|
@ -529,6 +531,40 @@ export const getAllGroupsWithInviteAccess = createSelector(
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export type UnreadStats = Readonly<{
|
||||||
|
unreadCount: number;
|
||||||
|
unreadMentionsCount: number;
|
||||||
|
markedUnread: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const getAllConversationsUnreadStats = createSelector(
|
||||||
|
getLeftPaneLists,
|
||||||
|
(leftPaneLists: LeftPaneLists): UnreadStats => {
|
||||||
|
let unreadCount = 0;
|
||||||
|
let unreadMentionsCount = 0;
|
||||||
|
let markedUnread = false;
|
||||||
|
|
||||||
|
function count(conversations: ReadonlyArray<ConversationType>) {
|
||||||
|
conversations.forEach(conversation => {
|
||||||
|
if (conversation.unreadCount != null) {
|
||||||
|
unreadCount += conversation.unreadCount;
|
||||||
|
}
|
||||||
|
if (conversation.unreadMentionsCount != null) {
|
||||||
|
unreadMentionsCount += conversation.unreadMentionsCount;
|
||||||
|
}
|
||||||
|
if (conversation.markedUnread) {
|
||||||
|
markedUnread = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
count(leftPaneLists.pinnedConversations);
|
||||||
|
count(leftPaneLists.conversations);
|
||||||
|
|
||||||
|
return { unreadCount, unreadMentionsCount, markedUnread };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
|
* getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
|
||||||
* composer and group members, a different list from your primary system contacts.
|
* composer and group members, a different list from your primary system contacts.
|
||||||
|
|
|
@ -312,3 +312,8 @@ export const getTextFormattingEnabled = createSelector(
|
||||||
getItems,
|
getItems,
|
||||||
(state: ItemsStateType): boolean => Boolean(state.textFormatting ?? true)
|
(state: ItemsStateType): boolean => Boolean(state.textFormatting ?? true)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getNavTabsCollapsed = createSelector(
|
||||||
|
getItems,
|
||||||
|
(state: ItemsStateType): boolean => Boolean(state.navTabsCollapsed ?? false)
|
||||||
|
);
|
||||||
|
|
|
@ -54,14 +54,13 @@ import { BodyRange, hydrateRanges } from '../../types/BodyRange';
|
||||||
import type { AssertProps } from '../../types/Util';
|
import type { AssertProps } from '../../types/Util';
|
||||||
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
||||||
import { getMentionsRegex } from '../../types/Message';
|
import { getMentionsRegex } from '../../types/Message';
|
||||||
import { CallMode } from '../../types/Calling';
|
|
||||||
import { SignalService as Proto } from '../../protobuf';
|
import { SignalService as Proto } from '../../protobuf';
|
||||||
import type { AttachmentType } from '../../types/Attachment';
|
import type { AttachmentType } from '../../types/Attachment';
|
||||||
import { isVoiceMessage, canBeDownloaded } from '../../types/Attachment';
|
import { isVoiceMessage, canBeDownloaded } from '../../types/Attachment';
|
||||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
|
|
||||||
import type { CallingNotificationType } from '../../util/callingNotification';
|
import type { CallingNotificationType } from '../../util/callingNotification';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { CallExternalState } from '../../util/callingNotification';
|
||||||
import { getRecipients } from '../../util/getRecipients';
|
import { getRecipients } from '../../util/getRecipients';
|
||||||
import { getOwn } from '../../util/getOwn';
|
import { getOwn } from '../../util/getOwn';
|
||||||
import { isNotNil } from '../../util/isNotNil';
|
import { isNotNil } from '../../util/isNotNil';
|
||||||
|
@ -128,6 +127,9 @@ import type { AnyPaymentEvent } from '../../types/Payment';
|
||||||
import { isPaymentNotificationEvent } from '../../types/Payment';
|
import { isPaymentNotificationEvent } from '../../types/Payment';
|
||||||
import { getTitleNoDefault, getNumber } from '../../util/getTitle';
|
import { getTitleNoDefault, getNumber } from '../../util/getTitle';
|
||||||
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp';
|
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp';
|
||||||
|
import type { CallHistorySelectorType } from './callHistory';
|
||||||
|
import { CallMode } from '../../types/Calling';
|
||||||
|
import { CallDirection } from '../../types/CallDisposition';
|
||||||
|
|
||||||
export { isIncoming, isOutgoing, isStory };
|
export { isIncoming, isOutgoing, isStory };
|
||||||
|
|
||||||
|
@ -166,6 +168,7 @@ export type GetPropsForBubbleOptions = Readonly<{
|
||||||
selectedMessageIds: ReadonlyArray<string> | undefined;
|
selectedMessageIds: ReadonlyArray<string> | undefined;
|
||||||
regionCode?: string;
|
regionCode?: string;
|
||||||
callSelector: CallSelectorType;
|
callSelector: CallSelectorType;
|
||||||
|
callHistorySelector: CallHistorySelectorType;
|
||||||
activeCall?: CallStateType;
|
activeCall?: CallStateType;
|
||||||
accountSelector: AccountSelectorType;
|
accountSelector: AccountSelectorType;
|
||||||
contactNameColorSelector: ContactNameColorSelectorType;
|
contactNameColorSelector: ContactNameColorSelectorType;
|
||||||
|
@ -1307,69 +1310,79 @@ export function isCallHistory(message: MessageWithUIFieldsType): boolean {
|
||||||
|
|
||||||
export type GetPropsForCallHistoryOptions = Pick<
|
export type GetPropsForCallHistoryOptions = Pick<
|
||||||
GetPropsForBubbleOptions,
|
GetPropsForBubbleOptions,
|
||||||
'conversationSelector' | 'callSelector' | 'activeCall'
|
| 'callSelector'
|
||||||
|
| 'activeCall'
|
||||||
|
| 'callHistorySelector'
|
||||||
|
| 'conversationSelector'
|
||||||
|
| 'ourConversationId'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export function getPropsForCallHistory(
|
export function getPropsForCallHistory(
|
||||||
message: MessageWithUIFieldsType,
|
message: MessageWithUIFieldsType,
|
||||||
{
|
{
|
||||||
conversationSelector,
|
|
||||||
callSelector,
|
callSelector,
|
||||||
|
callHistorySelector,
|
||||||
activeCall,
|
activeCall,
|
||||||
|
conversationSelector,
|
||||||
|
ourConversationId,
|
||||||
}: GetPropsForCallHistoryOptions
|
}: GetPropsForCallHistoryOptions
|
||||||
): CallingNotificationType {
|
): CallingNotificationType {
|
||||||
const { callHistoryDetails } = message;
|
const { callId } = message;
|
||||||
if (!callHistoryDetails) {
|
strictAssert(callId != null, 'getPropsForCallHistory: Missing callId');
|
||||||
throw new Error('getPropsForCallHistory: Missing callHistoryDetails');
|
const callHistory = callHistorySelector(callId);
|
||||||
|
strictAssert(
|
||||||
|
callHistory != null,
|
||||||
|
'getPropsForCallHistory: Missing callHistory'
|
||||||
|
);
|
||||||
|
|
||||||
|
const conversation = conversationSelector(callHistory.peerId);
|
||||||
|
strictAssert(
|
||||||
|
conversation != null,
|
||||||
|
'getPropsForCallHistory: Missing conversation'
|
||||||
|
);
|
||||||
|
|
||||||
|
let callCreator: ConversationType | null = null;
|
||||||
|
if (callHistory.ringerId) {
|
||||||
|
callCreator = conversationSelector(callHistory.ringerId);
|
||||||
|
} else if (callHistory.direction === CallDirection.Outgoing) {
|
||||||
|
callCreator = conversationSelector(ourConversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeCallConversationId = activeCall?.conversationId;
|
const call = callSelector(callHistory.callId);
|
||||||
|
|
||||||
switch (callHistoryDetails.callMode) {
|
let deviceCount = 0;
|
||||||
// Old messages weren't saved with a call mode.
|
let maxDevices = Infinity;
|
||||||
case undefined:
|
if (
|
||||||
case CallMode.Direct:
|
call?.callMode === CallMode.Group &&
|
||||||
return {
|
call.peekInfo?.deviceCount != null &&
|
||||||
...callHistoryDetails,
|
call.peekInfo?.maxDevices != null
|
||||||
activeCallConversationId,
|
) {
|
||||||
callMode: CallMode.Direct,
|
deviceCount = call.peekInfo.deviceCount;
|
||||||
};
|
maxDevices = call.peekInfo.maxDevices;
|
||||||
case CallMode.Group: {
|
}
|
||||||
const { conversationId } = message;
|
|
||||||
if (!conversationId) {
|
|
||||||
throw new Error('getPropsForCallHistory: missing conversation ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
let call = callSelector(conversationId);
|
let callExternalState: CallExternalState;
|
||||||
if (call && call.callMode !== CallMode.Group) {
|
if (call == null || deviceCount === 0) {
|
||||||
log.error(
|
callExternalState = CallExternalState.Ended;
|
||||||
'getPropsForCallHistory: there is an unexpected non-group call; pretending it does not exist'
|
} else if (activeCall != null) {
|
||||||
);
|
if (activeCall.conversationId === call.conversationId) {
|
||||||
call = undefined;
|
callExternalState = CallExternalState.Joined;
|
||||||
}
|
} else {
|
||||||
|
callExternalState = CallExternalState.InOtherCall;
|
||||||
const creator = conversationSelector(callHistoryDetails.creatorUuid);
|
|
||||||
const deviceCount = call?.peekInfo?.deviceCount ?? 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeCallConversationId,
|
|
||||||
callMode: CallMode.Group,
|
|
||||||
conversationId,
|
|
||||||
creator,
|
|
||||||
deviceCount,
|
|
||||||
ended:
|
|
||||||
callHistoryDetails.eraId !== call?.peekInfo?.eraId || !deviceCount,
|
|
||||||
maxDevices: call?.peekInfo?.maxDevices ?? Infinity,
|
|
||||||
startedTime: callHistoryDetails.startedTime,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
default:
|
} else if (deviceCount >= maxDevices) {
|
||||||
throw new Error(
|
callExternalState = CallExternalState.Full;
|
||||||
`getPropsForCallHistory: missing case ${missingCaseError(
|
} else {
|
||||||
callHistoryDetails
|
callExternalState = CallExternalState.Active;
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
callHistory,
|
||||||
|
callCreator,
|
||||||
|
callExternalState,
|
||||||
|
deviceCount,
|
||||||
|
maxDevices,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile Change
|
// Profile Change
|
||||||
|
|
14
ts/state/selectors/nav.ts
Normal file
|
@ -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;
|
||||||
|
});
|
|
@ -47,11 +47,6 @@ import { BodyRange, hydrateRanges } from '../../types/BodyRange';
|
||||||
export const getStoriesState = (state: StateType): StoriesStateType =>
|
export const getStoriesState = (state: StateType): StoriesStateType =>
|
||||||
state.stories;
|
state.stories;
|
||||||
|
|
||||||
export const shouldShowStoriesView = createSelector(
|
|
||||||
getStoriesState,
|
|
||||||
({ openedAtTimestamp }): boolean => Boolean(openedAtTimestamp)
|
|
||||||
);
|
|
||||||
|
|
||||||
export const hasSelectedStoryData = createSelector(
|
export const hasSelectedStoryData = createSelector(
|
||||||
getStoriesState,
|
getStoriesState,
|
||||||
({ selectedStoryData }): boolean => Boolean(selectedStoryData)
|
({ selectedStoryData }): boolean => Boolean(selectedStoryData)
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
} from './user';
|
} from './user';
|
||||||
import { getActiveCall, getCallSelector } from './calling';
|
import { getActiveCall, getCallSelector } from './calling';
|
||||||
import { getPropsForBubble } from './message';
|
import { getPropsForBubble } from './message';
|
||||||
|
import { getCallHistorySelector } from './callHistory';
|
||||||
|
|
||||||
export const getTimelineItem = (
|
export const getTimelineItem = (
|
||||||
state: StateType,
|
state: StateType,
|
||||||
|
@ -45,6 +46,7 @@ export const getTimelineItem = (
|
||||||
const ourPNI = getUserPNI(state);
|
const ourPNI = getUserPNI(state);
|
||||||
const ourConversationId = getUserConversationId(state);
|
const ourConversationId = getUserConversationId(state);
|
||||||
const callSelector = getCallSelector(state);
|
const callSelector = getCallSelector(state);
|
||||||
|
const callHistorySelector = getCallHistorySelector(state);
|
||||||
const activeCall = getActiveCall(state);
|
const activeCall = getActiveCall(state);
|
||||||
const accountSelector = getAccountSelector(state);
|
const accountSelector = getAccountSelector(state);
|
||||||
const contactNameColorSelector = getContactNameColorSelector(state);
|
const contactNameColorSelector = getContactNameColorSelector(state);
|
||||||
|
@ -61,6 +63,7 @@ export const getTimelineItem = (
|
||||||
targetedMessageCounter: targetedMessage?.counter,
|
targetedMessageCounter: targetedMessage?.counter,
|
||||||
contactNameColorSelector,
|
contactNameColorSelector,
|
||||||
callSelector,
|
callSelector,
|
||||||
|
callHistorySelector,
|
||||||
activeCall,
|
activeCall,
|
||||||
accountSelector,
|
accountSelector,
|
||||||
selectedMessageIds,
|
selectedMessageIds,
|
||||||
|
|
|
@ -11,7 +11,6 @@ import OS from '../../util/os/osMain';
|
||||||
import { SmartCallManager } from './CallManager';
|
import { SmartCallManager } from './CallManager';
|
||||||
import { SmartGlobalModalContainer } from './GlobalModalContainer';
|
import { SmartGlobalModalContainer } from './GlobalModalContainer';
|
||||||
import { SmartLightbox } from './Lightbox';
|
import { SmartLightbox } from './Lightbox';
|
||||||
import { SmartStories } from './Stories';
|
|
||||||
import { SmartStoryViewer } from './StoryViewer';
|
import { SmartStoryViewer } from './StoryViewer';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import {
|
import {
|
||||||
|
@ -22,10 +21,7 @@ import {
|
||||||
getIsMainWindowFullScreen,
|
getIsMainWindowFullScreen,
|
||||||
getMenuOptions,
|
getMenuOptions,
|
||||||
} from '../selectors/user';
|
} from '../selectors/user';
|
||||||
import {
|
import { hasSelectedStoryData } from '../selectors/stories';
|
||||||
hasSelectedStoryData,
|
|
||||||
shouldShowStoriesView,
|
|
||||||
} from '../selectors/stories';
|
|
||||||
import { getHideMenuBar } from '../selectors/items';
|
import { getHideMenuBar } from '../selectors/items';
|
||||||
import { mapDispatchToProps } from '../actions';
|
import { mapDispatchToProps } from '../actions';
|
||||||
import { ErrorBoundary } from '../../components/ErrorBoundary';
|
import { ErrorBoundary } from '../../components/ErrorBoundary';
|
||||||
|
@ -57,12 +53,6 @@ const mapStateToProps = (state: StateType) => {
|
||||||
),
|
),
|
||||||
renderGlobalModalContainer: () => <SmartGlobalModalContainer />,
|
renderGlobalModalContainer: () => <SmartGlobalModalContainer />,
|
||||||
renderLightbox: () => <SmartLightbox />,
|
renderLightbox: () => <SmartLightbox />,
|
||||||
isShowingStoriesView: shouldShowStoriesView(state),
|
|
||||||
renderStories: (closeView: () => unknown) => (
|
|
||||||
<ErrorBoundary name="App/renderStories" closeView={closeView}>
|
|
||||||
<SmartStories />
|
|
||||||
</ErrorBoundary>
|
|
||||||
),
|
|
||||||
hasSelectedStoryData: hasSelectedStoryData(state),
|
hasSelectedStoryData: hasSelectedStoryData(state),
|
||||||
renderStoryViewer: (closeView: () => unknown) => (
|
renderStoryViewer: (closeView: () => unknown) => (
|
||||||
<ErrorBoundary name="App/renderStoryViewer" closeView={closeView}>
|
<ErrorBoundary name="App/renderStoryViewer" closeView={closeView}>
|
||||||
|
|
170
ts/state/smart/CallsTab.tsx
Normal file
|
@ -0,0 +1,170 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { useItemsActions } from '../ducks/items';
|
||||||
|
import {
|
||||||
|
getNavTabsCollapsed,
|
||||||
|
getPreferredLeftPaneWidth,
|
||||||
|
} from '../selectors/items';
|
||||||
|
import { getIntl, getRegionCode } from '../selectors/user';
|
||||||
|
import { CallsTab } from '../../components/CallsTab';
|
||||||
|
import {
|
||||||
|
getAllConversations,
|
||||||
|
getConversationSelector,
|
||||||
|
} from '../selectors/conversations';
|
||||||
|
import { filterAndSortConversationsByRecent } from '../../util/filterAndSortConversations';
|
||||||
|
import type {
|
||||||
|
CallHistoryFilter,
|
||||||
|
CallHistoryFilterOptions,
|
||||||
|
CallHistoryGroup,
|
||||||
|
CallHistoryPagination,
|
||||||
|
} from '../../types/CallDisposition';
|
||||||
|
import type { ConversationType } from '../ducks/conversations';
|
||||||
|
import { SmartConversationDetails } from './ConversationDetails';
|
||||||
|
import { useCallingActions } from '../ducks/calling';
|
||||||
|
import { getActiveCallState } from '../selectors/calling';
|
||||||
|
import { useCallHistoryActions } from '../ducks/callHistory';
|
||||||
|
import { getCallHistoryEdition } from '../selectors/callHistory';
|
||||||
|
import * as log from '../../logging/log';
|
||||||
|
|
||||||
|
function getCallHistoryFilter(
|
||||||
|
allConversations: Array<ConversationType>,
|
||||||
|
regionCode: string | undefined,
|
||||||
|
options: CallHistoryFilterOptions
|
||||||
|
): CallHistoryFilter | null {
|
||||||
|
const query = options.query.normalize().trim();
|
||||||
|
|
||||||
|
if (query !== '') {
|
||||||
|
const currentConversations = allConversations.filter(conversation => {
|
||||||
|
return conversation.removalStage == null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredConversations = filterAndSortConversationsByRecent(
|
||||||
|
currentConversations,
|
||||||
|
query,
|
||||||
|
regionCode
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there are no matching conversations, then no calls will match.
|
||||||
|
if (filteredConversations.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: options.status,
|
||||||
|
conversationIds: filteredConversations.map(conversation => {
|
||||||
|
return conversation.id;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: options.status,
|
||||||
|
conversationIds: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConversationDetails(
|
||||||
|
conversationId: string,
|
||||||
|
callHistoryGroup: CallHistoryGroup | null
|
||||||
|
): JSX.Element {
|
||||||
|
return (
|
||||||
|
<SmartConversationDetails
|
||||||
|
conversationId={conversationId}
|
||||||
|
callHistoryGroup={callHistoryGroup}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SmartCallsTab(): JSX.Element {
|
||||||
|
const i18n = useSelector(getIntl);
|
||||||
|
const navTabsCollapsed = useSelector(getNavTabsCollapsed);
|
||||||
|
const preferredLeftPaneWidth = useSelector(getPreferredLeftPaneWidth);
|
||||||
|
const { savePreferredLeftPaneWidth, toggleNavTabsCollapse } =
|
||||||
|
useItemsActions();
|
||||||
|
|
||||||
|
const allConversations = useSelector(getAllConversations);
|
||||||
|
const regionCode = useSelector(getRegionCode);
|
||||||
|
const getConversation = useSelector(getConversationSelector);
|
||||||
|
|
||||||
|
const activeCall = useSelector(getActiveCallState);
|
||||||
|
const callHistoryEdition = useSelector(getCallHistoryEdition);
|
||||||
|
|
||||||
|
const {
|
||||||
|
onOutgoingAudioCallInConversation,
|
||||||
|
onOutgoingVideoCallInConversation,
|
||||||
|
} = useCallingActions();
|
||||||
|
const { clearAllCallHistory: clearCallHistory } = useCallHistoryActions();
|
||||||
|
|
||||||
|
const getCallHistoryGroupsCount = useCallback(
|
||||||
|
async (options: CallHistoryFilterOptions) => {
|
||||||
|
// Informs us if the call history has changed
|
||||||
|
log.info('getCallHistoryGroupsCount: edition', callHistoryEdition);
|
||||||
|
const callHistoryFilter = getCallHistoryFilter(
|
||||||
|
allConversations,
|
||||||
|
regionCode,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
if (callHistoryFilter == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const count = await window.Signal.Data.getCallHistoryGroupsCount(
|
||||||
|
callHistoryFilter
|
||||||
|
);
|
||||||
|
log.info('getCallHistoryGroupsCount: count', count, callHistoryFilter);
|
||||||
|
return count;
|
||||||
|
},
|
||||||
|
[allConversations, regionCode, callHistoryEdition]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCallHistoryGroups = useCallback(
|
||||||
|
async (
|
||||||
|
options: CallHistoryFilterOptions,
|
||||||
|
pagination: CallHistoryPagination
|
||||||
|
) => {
|
||||||
|
// Informs us if the call history has changed
|
||||||
|
log.info('getCallHistoryGroups: edition', callHistoryEdition);
|
||||||
|
const callHistoryFilter = getCallHistoryFilter(
|
||||||
|
allConversations,
|
||||||
|
regionCode,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
if (callHistoryFilter == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const results = await window.Signal.Data.getCallHistoryGroups(
|
||||||
|
callHistoryFilter,
|
||||||
|
pagination
|
||||||
|
);
|
||||||
|
log.info(
|
||||||
|
'getCallHistoryGroupsCount: results',
|
||||||
|
results,
|
||||||
|
callHistoryFilter
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
[allConversations, regionCode, callHistoryEdition]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CallsTab
|
||||||
|
activeCall={activeCall}
|
||||||
|
allConversations={allConversations}
|
||||||
|
getConversation={getConversation}
|
||||||
|
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
||||||
|
getCallHistoryGroups={getCallHistoryGroups}
|
||||||
|
i18n={i18n}
|
||||||
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
|
onClearCallHistory={clearCallHistory}
|
||||||
|
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
||||||
|
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
|
||||||
|
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
|
||||||
|
preferredLeftPaneWidth={preferredLeftPaneWidth}
|
||||||
|
renderConversationDetails={renderConversationDetails}
|
||||||
|
regionCode={regionCode}
|
||||||
|
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
151
ts/state/smart/ChatsTab.tsx
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { ChatsTab } from '../../components/ChatsTab';
|
||||||
|
import { SmartConversationView } from './ConversationView';
|
||||||
|
import { SmartMiniPlayer } from './MiniPlayer';
|
||||||
|
import { SmartLeftPane } from './LeftPane';
|
||||||
|
import type { NavTabPanelProps } from '../../components/NavTabs';
|
||||||
|
import { useGlobalModalActions } from '../ducks/globalModals';
|
||||||
|
import { getIntl } from '../selectors/user';
|
||||||
|
import { usePrevious } from '../../hooks/usePrevious';
|
||||||
|
import { TargetedMessageSource } from '../ducks/conversationsEnums';
|
||||||
|
import type { ConversationsStateType } from '../ducks/conversations';
|
||||||
|
import { useConversationsActions } from '../ducks/conversations';
|
||||||
|
import type { StateType } from '../reducer';
|
||||||
|
import { strictAssert } from '../../util/assert';
|
||||||
|
import { showToast } from '../../util/showToast';
|
||||||
|
import { ToastStickerPackInstallFailed } from '../../components/ToastStickerPackInstallFailed';
|
||||||
|
import { getNavTabsCollapsed } from '../selectors/items';
|
||||||
|
import { useItemsActions } from '../ducks/items';
|
||||||
|
|
||||||
|
function renderConversationView() {
|
||||||
|
return <SmartConversationView />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLeftPane(props: NavTabPanelProps) {
|
||||||
|
return <SmartLeftPane {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMiniPlayer(options: { shouldFlow: boolean }) {
|
||||||
|
return <SmartMiniPlayer {...options} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SmartChatsTab(): JSX.Element {
|
||||||
|
const i18n = useSelector(getIntl);
|
||||||
|
const navTabsCollapsed = useSelector(getNavTabsCollapsed);
|
||||||
|
const { selectedConversationId, targetedMessage, targetedMessageSource } =
|
||||||
|
useSelector<StateType, ConversationsStateType>(
|
||||||
|
state => state.conversations
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
onConversationClosed,
|
||||||
|
onConversationOpened,
|
||||||
|
scrollToMessage,
|
||||||
|
showConversation,
|
||||||
|
} = useConversationsActions();
|
||||||
|
const { showWhatsNewModal } = useGlobalModalActions();
|
||||||
|
const { toggleNavTabsCollapse } = useItemsActions();
|
||||||
|
|
||||||
|
const prevConversationId = usePrevious(
|
||||||
|
selectedConversationId,
|
||||||
|
selectedConversationId
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevConversationId !== selectedConversationId) {
|
||||||
|
if (prevConversationId) {
|
||||||
|
onConversationClosed(prevConversationId, 'opened another conversation');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedConversationId) {
|
||||||
|
onConversationOpened(selectedConversationId, targetedMessage);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
selectedConversationId &&
|
||||||
|
targetedMessage &&
|
||||||
|
targetedMessageSource !== TargetedMessageSource.Focus
|
||||||
|
) {
|
||||||
|
scrollToMessage(selectedConversationId, targetedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedConversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = window.ConversationController.get(
|
||||||
|
selectedConversationId
|
||||||
|
);
|
||||||
|
strictAssert(conversation, 'Conversation must be found');
|
||||||
|
|
||||||
|
conversation.setMarkedUnread(false);
|
||||||
|
}, [
|
||||||
|
onConversationClosed,
|
||||||
|
onConversationOpened,
|
||||||
|
prevConversationId,
|
||||||
|
scrollToMessage,
|
||||||
|
selectedConversationId,
|
||||||
|
targetedMessage,
|
||||||
|
targetedMessageSource,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function refreshConversation({
|
||||||
|
newId,
|
||||||
|
oldId,
|
||||||
|
}: {
|
||||||
|
newId: string;
|
||||||
|
oldId: string;
|
||||||
|
}) {
|
||||||
|
if (prevConversationId === oldId) {
|
||||||
|
showConversation({ conversationId: newId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close current opened conversation to reload the group information once
|
||||||
|
// linked.
|
||||||
|
function unload() {
|
||||||
|
if (!prevConversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onConversationClosed(prevConversationId, 'force unload requested');
|
||||||
|
}
|
||||||
|
|
||||||
|
function packInstallFailed() {
|
||||||
|
showToast(ToastStickerPackInstallFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Whisper.events.on('pack-install-failed', packInstallFailed);
|
||||||
|
window.Whisper.events.on('refreshConversation', refreshConversation);
|
||||||
|
window.Whisper.events.on('setupAsNewDevice', unload);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.Whisper.events.off('pack-install-failed', packInstallFailed);
|
||||||
|
window.Whisper.events.off('refreshConversation', refreshConversation);
|
||||||
|
window.Whisper.events.off('setupAsNewDevice', unload);
|
||||||
|
};
|
||||||
|
}, [onConversationClosed, prevConversationId, showConversation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedConversationId) {
|
||||||
|
window.SignalCI?.handleEvent('empty-inbox:rendered', null);
|
||||||
|
}
|
||||||
|
}, [selectedConversationId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatsTab
|
||||||
|
i18n={i18n}
|
||||||
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
|
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
||||||
|
prevConversationId={prevConversationId}
|
||||||
|
renderConversationView={renderConversationView}
|
||||||
|
renderLeftPane={renderLeftPane}
|
||||||
|
renderMiniPlayer={renderMiniPlayer}
|
||||||
|
selectedConversationId={selectedConversationId}
|
||||||
|
showWhatsNewModal={showWhatsNewModal}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -7,7 +7,7 @@ import type { CompositionTextAreaProps } from '../../components/CompositionTextA
|
||||||
import { CompositionTextArea } from '../../components/CompositionTextArea';
|
import { CompositionTextArea } from '../../components/CompositionTextArea';
|
||||||
import { getIntl, getPlatform } from '../selectors/user';
|
import { getIntl, getPlatform } from '../selectors/user';
|
||||||
import { useActions as useEmojiActions } from '../ducks/emojis';
|
import { useActions as useEmojiActions } from '../ducks/emojis';
|
||||||
import { useActions as useItemsActions } from '../ducks/items';
|
import { useItemsActions } from '../ducks/items';
|
||||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||||
import { useComposerActions } from '../ducks/composer';
|
import { useComposerActions } from '../ducks/composer';
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -33,9 +33,12 @@ import {
|
||||||
getGroupSizeRecommendedLimit,
|
getGroupSizeRecommendedLimit,
|
||||||
getGroupSizeHardLimit,
|
getGroupSizeHardLimit,
|
||||||
} from '../../groups/limits';
|
} from '../../groups/limits';
|
||||||
|
import type { CallHistoryGroup } from '../../types/CallDisposition';
|
||||||
|
import { getSelectedNavTab } from '../selectors/nav';
|
||||||
|
|
||||||
export type SmartConversationDetailsProps = {
|
export type SmartConversationDetailsProps = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
callHistoryGroup?: CallHistoryGroup | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
|
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
|
||||||
|
@ -96,6 +99,7 @@ const mapStateToProps = (
|
||||||
const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151);
|
const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151);
|
||||||
return {
|
return {
|
||||||
...props,
|
...props,
|
||||||
|
|
||||||
areWeASubscriber: getAreWeASubscriber(state),
|
areWeASubscriber: getAreWeASubscriber(state),
|
||||||
badges,
|
badges,
|
||||||
canEditGroupInfo,
|
canEditGroupInfo,
|
||||||
|
@ -115,6 +119,7 @@ const mapStateToProps = (
|
||||||
hasGroupLink,
|
hasGroupLink,
|
||||||
groupsInCommon: groupsInCommonSorted,
|
groupsInCommon: groupsInCommonSorted,
|
||||||
isGroup: conversation.type === 'group',
|
isGroup: conversation.type === 'group',
|
||||||
|
selectedNavTab: getSelectedNavTab(state),
|
||||||
theme: getTheme(state),
|
theme: getTheme(state),
|
||||||
renderChooseGroupMembersModal,
|
renderChooseGroupMembersModal,
|
||||||
renderConfirmAdditionsModal,
|
renderConfirmAdditionsModal,
|
||||||
|
|