Calls Tab & Group Call Disposition
|
@ -457,6 +457,210 @@ Signal Desktop makes use of the following open source projects.
|
|||
|
||||
License: MIT
|
||||
|
||||
## @react-aria/utils
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2019 Adobe
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
## @react-spring/web
|
||||
|
||||
MIT License
|
||||
|
@ -2494,6 +2698,414 @@ Signal Desktop makes use of the following open source projects.
|
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## react-aria
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2019 Adobe
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
## react-aria-components
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2019 Adobe
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
## react-blurhash
|
||||
|
||||
License: MIT
|
||||
|
|
|
@ -299,6 +299,34 @@
|
|||
"messageformat": "Chats",
|
||||
"description": "Shown as a header for non-pinned conversations in the left pane"
|
||||
},
|
||||
"icu:NavTabsToggle__showTabs": {
|
||||
"messageformat": "Show Tabs",
|
||||
"description": "Show in the left pane when the nav tabs are hidden, shows the nav tabs"
|
||||
},
|
||||
"icu:NavTabsToggle__hideTabs": {
|
||||
"messageformat": "Hide Tabs",
|
||||
"description": "Show in the nav tabs when the nav tabs are visible, hides the nav tabs"
|
||||
},
|
||||
"icu:NavTabs__ItemIconLabel--UnreadCount": {
|
||||
"messageformat": "{count, number} unread",
|
||||
"description": "Nav Tabs > Unread badge > Accessibility Text"
|
||||
},
|
||||
"icu:NavTabs__ItemIconLabel--MarkedUnread": {
|
||||
"messageformat": "Marked unread",
|
||||
"description": "Nav Tabs > Unread badge > when conversation is marked unread > Accessibility Text"
|
||||
},
|
||||
"icu:NavTabs__ItemLabel--Settings": {
|
||||
"messageformat": "Settings",
|
||||
"description": "Nav Tabs > Settings Button > Accessibility Text"
|
||||
},
|
||||
"icu:NavTabs__ItemLabel--Profile": {
|
||||
"messageformat": "Profile",
|
||||
"description": "Nav Tabs > Profile Button > Accessibility Text"
|
||||
},
|
||||
"icu:NavSidebar__BackButtonLabel": {
|
||||
"messageformat": "Back",
|
||||
"description": "Shown in the sidebar header when on a nested panel within the current sidebar, takes the user back to the previous step or default sidebar state"
|
||||
},
|
||||
"icu:archiveHelperText": {
|
||||
"messageformat": "These chats are archived and will only appear in the Inbox if new messages are received.",
|
||||
"description": "Shown at the top of the archived conversations list in the left pane"
|
||||
|
@ -2150,6 +2178,10 @@
|
|||
"messageformat": "View Safety Number",
|
||||
"description": "In conversation details, label for button to view safety number, opens safety number modal"
|
||||
},
|
||||
"icu:ConversationDetails__HeaderButton--Message": {
|
||||
"messageformat": "Message",
|
||||
"description": "In conversation details, label for button to switch to the conversation view in order to draft a message in that converation"
|
||||
},
|
||||
"icu:SafetyNumberNotification__viewSafetyNumber": {
|
||||
"messageformat": "View Safety Number",
|
||||
"description": "In conversation, safety number change notification, label for button to view safety number, opens safety number modal"
|
||||
|
@ -3383,6 +3415,14 @@
|
|||
"messageformat": "Incoming video call...",
|
||||
"description": "Shown in both the incoming call bar and notification for an incoming video call"
|
||||
},
|
||||
"icu:outgoingAudioCall": {
|
||||
"messageformat": "Outgoing voice call",
|
||||
"description": "Shown in the timeline for an outgoing voice call"
|
||||
},
|
||||
"icu:outgoingVideoCall": {
|
||||
"messageformat": "Outgoing video call",
|
||||
"description": "Shown in the timeline for an outgoing video call"
|
||||
},
|
||||
"icu:incomingGroupCall__ringing-you": {
|
||||
"messageformat": "{ringer} is calling you",
|
||||
"description": "Shown in the incoming call bar when someone is ringing you for a group call"
|
||||
|
@ -6595,6 +6635,110 @@
|
|||
"messageformat": "Send again",
|
||||
"description": "Button text for the confirmation dialog shown to user when attempting to resend message edit"
|
||||
},
|
||||
"icu:StoriesTab__MoreActionsLabel": {
|
||||
"messageformat": "More actions",
|
||||
"description": "Stories Tab > More Actions Button (opens context menu) > Accessibility Label"
|
||||
},
|
||||
"icu:CallsTab__HeaderTitle--CallsList": {
|
||||
"messageformat": "Calls",
|
||||
"description": "Calls Tab > Header > Title > On Calls List screen"
|
||||
},
|
||||
"icu:CallsTab__HeaderTitle--NewCall": {
|
||||
"messageformat": "New Call",
|
||||
"description": "Calls Tab > Header > Title > On New Call screen"
|
||||
},
|
||||
"icu:CallsTab__NewCallActionLabel": {
|
||||
"messageformat": "New Call",
|
||||
"description": "Calls Tab > New Call Action Button > Accessibility Label"
|
||||
},
|
||||
"icu:CallsTab__MoreActionsLabel": {
|
||||
"messageformat": "More actions",
|
||||
"description": "Calls Tab > More Actions Button (opens context menu) > Accessibility Label"
|
||||
},
|
||||
"icu:CallsTab__ClearCallHistoryLabel": {
|
||||
"messageformat": "Clear call history",
|
||||
"description": "Calls Tab > More Actions Context Menu > Clear Call History Button Label"
|
||||
},
|
||||
"icu:CallsTab__ConfirmClearCallHistory__Title": {
|
||||
"messageformat": "Clear call history?",
|
||||
"description": "Calls Tab > Confirm Clear Call History Dialog > Title"
|
||||
},
|
||||
"icu:CallsTab__ConfirmClearCallHistory__Body": {
|
||||
"messageformat": "This will permanently delete all call history",
|
||||
"description": "Calls Tab > Confirm Clear Call History Dialog > Body Text"
|
||||
},
|
||||
"icu:CallsTab__ConfirmClearCallHistory__ConfirmButton": {
|
||||
"messageformat": "Clear",
|
||||
"description": "Calls Tab > Confirm Clear Call History Dialog > Confirm Button"
|
||||
},
|
||||
"icu:CallsTab__ToastCallHistoryCleared": {
|
||||
"messageformat": "Call history cleared",
|
||||
"description": "Calls Tab > Clear Call History > Toast"
|
||||
},
|
||||
"icu:CallsTab__EmptyStateText": {
|
||||
"messageformat": "Click to view or start a call",
|
||||
"description": "Calls Tab > When no call is selected > Empty state > Call to action text"
|
||||
},
|
||||
"icu:CallsList__SearchInputPlaceholder": {
|
||||
"messageformat": "Search",
|
||||
"description": "Calls Tab > Calls List > Search Input > Placeholder"
|
||||
},
|
||||
"icu:CallsList__ToggleFilterByMissedLabel": {
|
||||
"messageformat": "Toggle filter by missed",
|
||||
"description": "Calls Tab > Calls List > Toggle search filter by missed > Accessibility label"
|
||||
},
|
||||
"icu:CallsList__ToggleFilterByMissed__RoleDescription": {
|
||||
"messageformat": "Toggle",
|
||||
"description": "Calls Tab > Calls List > Toggle search filter by missed > Accessibility role description ('A toggle button')"
|
||||
},
|
||||
"icu:CallsList__EmptyState--noQuery": {
|
||||
"messageformat": "No recent calls. Get started by calling a friend.",
|
||||
"description": "Calls Tab > Calls List > When no results found > With no search query"
|
||||
},
|
||||
"icu:CallsList__EmptyState--hasQuery": {
|
||||
"messageformat": "No results for “{query}”",
|
||||
"description": "Calls Tab > Calls List > When no results found > With a search query"
|
||||
},
|
||||
"icu:CallsList__ItemCallInfo--Incoming": {
|
||||
"messageformat": "Incoming",
|
||||
"description": "Calls Tab > Calls List > Call Item > Call Status > When call was accepted and was incoming"
|
||||
},
|
||||
"icu:CallsList__ItemCallInfo--Outgoing": {
|
||||
"messageformat": "Outgoing",
|
||||
"description": "Calls Tab > Calls List > Call Item > Call Status > When call was accepted and was outgoing"
|
||||
},
|
||||
"icu:CallsList__ItemCallInfo--Missed": {
|
||||
"messageformat": "Missed",
|
||||
"description": "Calls Tab > Calls List > Call Item > Call Status > When call was missed"
|
||||
},
|
||||
"icu:CallsList__ItemCallInfo--GroupCall": {
|
||||
"messageformat": "Group call",
|
||||
"description": "Calls Tab > Calls List > Call Item > Call Status > When group call is in its default state"
|
||||
},
|
||||
"icu:CallsNewCall__EmptyState--noQuery": {
|
||||
"messageformat": "No recent conversations.",
|
||||
"description": "Calls Tab > New Call > Conversations List > When no results found > With no search query"
|
||||
},
|
||||
"icu:CallsNewCall__EmptyState--hasQuery": {
|
||||
"messageformat": "No results for “{query}”",
|
||||
"description": "Calls Tab > New Call > Conversations List > When no results found > With a search query"
|
||||
},
|
||||
"icu:CallHistory__Description--Default": {
|
||||
"messageformat": "{direction, select, Outgoing {Outgoing} other {Incoming}} {type, select, Audio {voice} Video {video} Group {group} other {}} call",
|
||||
"description": "Call History > Short description of call > When call was not missed or declined (generally accepted)"
|
||||
},
|
||||
"icu:CallHistory__Description--Missed": {
|
||||
"messageformat": "Missed {type, select, Audio {voice} Video {video} Group {group} other {}} call",
|
||||
"description": "Call History > Short description of call > When incoming call was missed"
|
||||
},
|
||||
"icu:CallHistory__Description--Unanswered": {
|
||||
"messageformat": "Unanswered {type, select, Audio {voice} Video {video} Group {group} other {}} call",
|
||||
"description": "Call History > Short description of call > When outgoing call was unanswered"
|
||||
},
|
||||
"icu:CallHistory__Description--Declined": {
|
||||
"messageformat": "Declined {type, select, Audio {voice} Video {video} Group {group} other {}} call",
|
||||
"description": "Call History > Short description of call > When call was declined"
|
||||
},
|
||||
"icu:WhatsNew__modal-title": {
|
||||
"messageformat": "What's New",
|
||||
"description": "Title for the whats new modal"
|
||||
|
|
|
@ -194,7 +194,11 @@ const defaultWebPrefs = {
|
|||
getEnvironment() !== Environment.Production ||
|
||||
!isProduction(app.getVersion()),
|
||||
spellcheck: false,
|
||||
enableBlinkFeatures: 'CSSPseudoDir,CSSLogical',
|
||||
// https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/platform/runtime_enabled_features.json5
|
||||
enableBlinkFeatures: [
|
||||
'CSSPseudoDir', // status=experimental, needed for RTL (ex: :dir(rtl))
|
||||
'CSSLogical', // status=experimental, needed for RTL (ex: margin-inline-start)
|
||||
].join(','),
|
||||
};
|
||||
|
||||
const DISABLE_GPU =
|
||||
|
|
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.ui.notifications": "0.4.4",
|
||||
"@popperjs/core": "2.11.6",
|
||||
"@react-aria/utils": "3.16.0",
|
||||
"@react-spring/web": "9.5.5",
|
||||
"@signalapp/better-sqlite3": "8.4.3",
|
||||
"@signalapp/libsignal-client": "0.29.1",
|
||||
|
@ -151,6 +152,8 @@
|
|||
"quill": "1.3.7",
|
||||
"quill-delta": "4.0.1",
|
||||
"react": "17.0.2",
|
||||
"react-aria": "3.24.0",
|
||||
"react-aria-components": "1.0.0-alpha.3",
|
||||
"react-blurhash": "0.1.2",
|
||||
"react-contextmenu": "2.11.0",
|
||||
"react-dom": "17.0.2",
|
||||
|
@ -178,7 +181,7 @@
|
|||
"uuid-browser": "3.1.0",
|
||||
"websocket": "1.0.34",
|
||||
"@signalapp/windows-dummy-keystroke": "1.0.0",
|
||||
"zod": "3.5.1"
|
||||
"zod": "3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.14.3",
|
||||
|
|
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 {
|
||||
enum Type {
|
||||
UNKNOWN = 0;
|
||||
AUDIO_CALL = 1;
|
||||
VIDEO_CALL = 2;
|
||||
UNKNOWN = 0;
|
||||
AUDIO_CALL = 1;
|
||||
VIDEO_CALL = 2;
|
||||
GROUP_CALL = 3;
|
||||
AD_HOC_CALL = 4;
|
||||
}
|
||||
|
||||
enum Direction {
|
||||
|
@ -607,9 +609,10 @@ message SyncMessage {
|
|||
UNKNOWN = 0;
|
||||
ACCEPTED = 1;
|
||||
NOT_ACCEPTED = 2;
|
||||
DELETE = 3;
|
||||
}
|
||||
|
||||
optional bytes peerUuid = 1;
|
||||
optional bytes peerId = 1;
|
||||
optional uint64 callId = 2;
|
||||
optional uint64 timestamp = 3;
|
||||
optional Type type = 4;
|
||||
|
@ -617,6 +620,15 @@ message SyncMessage {
|
|||
optional Event event = 6;
|
||||
}
|
||||
|
||||
message CallLogEvent {
|
||||
enum Type {
|
||||
CLEAR = 0;
|
||||
}
|
||||
|
||||
optional Type type = 1;
|
||||
optional uint64 timestamp = 2;
|
||||
}
|
||||
|
||||
optional Sent sent = 1;
|
||||
optional Contacts contacts = 2;
|
||||
reserved /* groups */ 3;
|
||||
|
@ -636,6 +648,8 @@ message SyncMessage {
|
|||
reserved 17; // pniIdentity
|
||||
optional PniChangeNumber pniChangeNumber = 18;
|
||||
optional CallEvent callEvent = 19;
|
||||
reserved 20; // callLinkUpdate
|
||||
optional CallLogEvent callLogEvent = 21;
|
||||
}
|
||||
|
||||
message AttachmentPointer {
|
||||
|
|
|
@ -26,6 +26,7 @@ $color-black: #000000;
|
|||
$color-white-alpha-06: rgba($color-white, 0.06);
|
||||
$color-white-alpha-08: rgba($color-white, 0.08);
|
||||
$color-white-alpha-12: rgba($color-white, 0.12);
|
||||
$color-white-alpha-16: rgba($color-white, 0.16);
|
||||
$color-white-alpha-20: rgba($color-white, 0.2);
|
||||
$color-white-alpha-40: rgba($color-white, 0.4);
|
||||
$color-white-alpha-60: rgba($color-white, 0.6);
|
||||
|
|
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;
|
||||
}
|
||||
|
||||
@mixin font-title-medium {
|
||||
@include font-family;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
line-height: 25px;
|
||||
letter-spacing: -0.25px;
|
||||
}
|
||||
|
||||
@mixin font-body-1 {
|
||||
@include font-family;
|
||||
font-size: 14px;
|
||||
|
@ -858,3 +866,15 @@ $rtl-icon-map: (
|
|||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@mixin NavTabs__Scroller {
|
||||
@include scrollbar;
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@include light-theme {
|
||||
border-color: $color-gray-04;
|
||||
}
|
||||
@include dark-theme {
|
||||
border-color: $color-gray-80;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2445,166 +2445,6 @@ button.ConversationDetails__action-button {
|
|||
}
|
||||
}
|
||||
|
||||
// Module: Main Header
|
||||
|
||||
.module-main-header {
|
||||
-webkit-app-region: var(--draggable-app-region);
|
||||
|
||||
height: calc(#{$header-height} + var(--title-bar-drag-area-height));
|
||||
width: 100%;
|
||||
|
||||
padding-inline: 16px;
|
||||
padding-top: var(--title-bar-drag-area-height);
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.module-left-pane--width-narrow & {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
& > * {
|
||||
margin-inline-end: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&--container {
|
||||
position: relative;
|
||||
|
||||
.module-left-pane--width-narrow & {
|
||||
margin-bottom: 20px;
|
||||
margin-inline-end: 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&--badged {
|
||||
background: $color-ultramarine;
|
||||
border-radius: 100%;
|
||||
border: 1px solid $color-white;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon-container {
|
||||
display: flex;
|
||||
|
||||
.module-left-pane--width-narrow & {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
}
|
||||
|
||||
&__compose-icon {
|
||||
-webkit-app-region: no-drag;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
display: inline-flex;
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
padding: 0px;
|
||||
|
||||
@include keyboard-mode {
|
||||
&:focus {
|
||||
border-color: $color-ultramarine;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
$icon: '../images/icons/v3/compose/compose.svg';
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
content: '';
|
||||
|
||||
@include light-theme {
|
||||
@include color-svg($icon, $color-gray-75);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg($icon, $color-gray-15);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__stories-badge {
|
||||
@include rounded-corners;
|
||||
align-items: center;
|
||||
background-color: $color-accent-red;
|
||||
color: $color-white;
|
||||
display: flex;
|
||||
font-size: 10px;
|
||||
height: 16px;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
overflow: hidden;
|
||||
padding-block: 0;
|
||||
padding-inline: 2px;
|
||||
position: absolute;
|
||||
inset-inline-end: -6px;
|
||||
top: -4px;
|
||||
user-select: none;
|
||||
z-index: $z-index-base;
|
||||
}
|
||||
|
||||
&__stories-icon {
|
||||
-webkit-app-region: no-drag;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
display: inline-flex;
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
margin-inline-end: 12px;
|
||||
position: relative;
|
||||
width: 28px;
|
||||
padding: 0px;
|
||||
|
||||
.module-left-pane--width-narrow & {
|
||||
margin-inline-end: 0;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@include keyboard-mode {
|
||||
&:focus {
|
||||
border-color: $color-ultramarine;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
$icon: '../images/icons/v3/stories/stories.svg';
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
content: '';
|
||||
|
||||
@include light-theme {
|
||||
@include color-svg($icon, $color-gray-75);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg($icon, $color-gray-15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Module: Image
|
||||
|
||||
.module-image {
|
||||
|
@ -4365,7 +4205,7 @@ button.module-image__border-overlay:focus {
|
|||
.module-conversation-list {
|
||||
$normal-row-height: 72px;
|
||||
|
||||
@include scrollbar;
|
||||
@include NavTabs__Scroller;
|
||||
padding-inline: 10px;
|
||||
|
||||
// list tiles in choose-group-members and compose extend to the edge
|
||||
|
@ -5045,33 +4885,18 @@ button.module-image__border-overlay:focus {
|
|||
// Module: Left Pane
|
||||
|
||||
.module-left-pane {
|
||||
border-inline-end-style: solid;
|
||||
border-inline-end-width: 1px;
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
@include light-theme {
|
||||
$background-color: $color-gray-02;
|
||||
|
||||
border-color: $color-gray-15;
|
||||
background-color: $background-color;
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: 2px solid $color-gray-02;
|
||||
}
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
$background-color: $color-gray-80;
|
||||
|
||||
border-color: $color-gray-65;
|
||||
background-color: $background-color;
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: 2px solid $color-gray-80;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -5122,13 +4947,11 @@ button.module-image__border-overlay:focus {
|
|||
user-select: none;
|
||||
|
||||
&__contents {
|
||||
height: calc(#{$header-height} + var(--title-bar-drag-area-height));
|
||||
width: 100%;
|
||||
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-top: var(--title-bar-drag-area-height);
|
||||
padding-block: 15px;
|
||||
|
||||
&__back-button {
|
||||
@include button-reset;
|
||||
|
@ -5248,6 +5071,36 @@ button.module-image__border-overlay:focus {
|
|||
}
|
||||
}
|
||||
|
||||
.module-left-pane__startComposingIcon {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@include light-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/compose/compose.svg',
|
||||
$color-gray-75
|
||||
);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/compose/compose.svg',
|
||||
$color-gray-15
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.module-left-pane__moreActionsIcon {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@include light-theme {
|
||||
@include color-svg('../images/icons/v3/more/more.svg', $color-black);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg('../images/icons/v3/more/more.svg', $color-gray-15);
|
||||
}
|
||||
}
|
||||
|
||||
.module-left-pane__archive-helper-text {
|
||||
@include font-body-2;
|
||||
|
||||
|
|
|
@ -36,6 +36,7 @@ $color-black: #000000;
|
|||
$color-white-alpha-06: rgba($color-white, 0.06);
|
||||
$color-white-alpha-08: rgba($color-white, 0.08);
|
||||
$color-white-alpha-12: rgba($color-white, 0.12);
|
||||
$color-white-alpha-16: rgba($color-white, 0.16);
|
||||
$color-white-alpha-20: rgba($color-white, 0.2);
|
||||
$color-white-alpha-40: rgba($color-white, 0.4);
|
||||
$color-white-alpha-60: rgba($color-white, 0.6);
|
||||
|
@ -280,3 +281,9 @@ $z-index-modal-host: 102;
|
|||
$z-index-above-popup: 103;
|
||||
$z-index-calling-pip: 104;
|
||||
$z-index-above-context-menu: 126;
|
||||
|
||||
// global navTabs
|
||||
$NavTabs__width: 80px;
|
||||
// These values are 'block' specific to coordinate with the NavSidebar__Header
|
||||
$NavTabs__Item__blockPadding: 2px;
|
||||
$NavTabs__ItemButton__blockPadding: 10px;
|
||||
|
|
|
@ -259,6 +259,10 @@
|
|||
@include button-icon('../images/icons/v3/phone/phone-compact.svg');
|
||||
}
|
||||
|
||||
&--message::before {
|
||||
@include button-icon('../images/icons/v3/chat/chat-compact.svg');
|
||||
}
|
||||
|
||||
&--muted::before {
|
||||
@include button-icon('../images/icons/v3/bell/bell-slash-compact.svg');
|
||||
}
|
||||
|
|
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;
|
||||
align-items: center;
|
||||
background: $color-ultramarine-dawn;
|
||||
border: 2px solid $color-gray-80;
|
||||
border: 2px solid;
|
||||
bottom: -2px;
|
||||
display: flex;
|
||||
height: 20px;
|
||||
|
@ -154,6 +154,14 @@
|
|||
width: 20px;
|
||||
z-index: $z-index-base;
|
||||
|
||||
@include light-theme {
|
||||
border-color: $color-gray-04;
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
border-color: $color-gray-80;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
@include color-svg(
|
||||
|
@ -166,6 +174,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
.StoryListItem__button:hover .MyStories__avatar__add-story {
|
||||
border-color: $color-gray-65;
|
||||
.StoryListItem__button:hover,
|
||||
.StoryListItem__button--active {
|
||||
.MyStories__avatar__add-story {
|
||||
@include light-theme {
|
||||
border-color: $color-gray-15;
|
||||
}
|
||||
@include dark-theme {
|
||||
border-color: $color-gray-65;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
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 {
|
||||
&__container {
|
||||
margin-inline: 16px;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
.Stories {
|
||||
background: $color-gray-95;
|
||||
display: flex;
|
||||
height: var(--window-height);
|
||||
inset-inline-start: 0;
|
||||
|
@ -11,42 +9,37 @@
|
|||
user-select: none;
|
||||
width: 100%;
|
||||
z-index: $z-index-stories;
|
||||
@include light-theme {
|
||||
background: $color-white;
|
||||
}
|
||||
@include dark-theme {
|
||||
background: $color-gray-95;
|
||||
}
|
||||
|
||||
&__pane {
|
||||
background: $color-gray-80;
|
||||
border-inline-end: 1px solid $color-gray-65;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 380px;
|
||||
padding-top: calc(14px + var(--title-bar-drag-area-height));
|
||||
|
||||
&__add-story__button {
|
||||
@include color-svg('../images/icons/v3/plus/plus.svg', $color-white);
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
inset-inline-end: 64px;
|
||||
top: 0px;
|
||||
width: 20px;
|
||||
|
||||
&:focus {
|
||||
@include keyboard-mode {
|
||||
background-color: $color-ultramarine;
|
||||
}
|
||||
}
|
||||
padding-top: calc(2px + var(--title-bar-drag-area-height));
|
||||
@include light-theme {
|
||||
background: $color-gray-04;
|
||||
border-inline-end: 1px solid $color-black-alpha-16;
|
||||
}
|
||||
@include dark-theme {
|
||||
background: $color-gray-80;
|
||||
border-inline-end: 1px solid $color-white-alpha-16;
|
||||
}
|
||||
|
||||
&__settings__button {
|
||||
@include dark-theme {
|
||||
@include color-svg('../images/icons/v3/more/more.svg', $color-white);
|
||||
}
|
||||
&__add-story__button {
|
||||
height: 20px;
|
||||
margin-inline-start: 20px;
|
||||
opacity: 1;
|
||||
position: absolute;
|
||||
inset-inline-end: 24px;
|
||||
top: 0px;
|
||||
width: 20px;
|
||||
@include light-theme {
|
||||
@include color-svg('../images/icons/v3/plus/plus.svg', $color-black);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg('../images/icons/v3/plus/plus.svg', $color-gray-15);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
@include keyboard-mode {
|
||||
|
@ -68,55 +61,40 @@
|
|||
}
|
||||
|
||||
&--title {
|
||||
@include font-body-1-bold;
|
||||
color: $color-gray-05;
|
||||
display: flex;
|
||||
@include font-title-medium;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
@include light-theme {
|
||||
color: $color-black;
|
||||
}
|
||||
@include dark-theme {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
}
|
||||
|
||||
&--centered .Stories__pane__header--title {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--back {
|
||||
@include button-reset;
|
||||
|
||||
-webkit-app-region: no-drag;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
|
||||
@include color-svg(
|
||||
'../images/icons/v3/chevron/chevron-left.svg',
|
||||
$color-white
|
||||
);
|
||||
|
||||
@include keyboard-mode {
|
||||
&:hover {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/chevron/chevron-left.svg',
|
||||
$color-ultramarine-light
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
@include scrollbar;
|
||||
@include NavTabs__Scroller;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: overlay;
|
||||
padding-block: 0;
|
||||
padding-inline: 14px;
|
||||
padding-inline: 16px;
|
||||
|
||||
&--empty {
|
||||
@include font-body-1;
|
||||
align-items: center;
|
||||
color: $color-gray-45;
|
||||
@include light-theme() {
|
||||
color: $color-gray-60;
|
||||
}
|
||||
@include dark-theme() {
|
||||
color: $color-gray-45;
|
||||
}
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
|
@ -125,11 +103,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__search__container {
|
||||
margin-block: 14px 10px;
|
||||
margin-inline: 16px;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
align-items: center;
|
||||
color: $color-gray-45;
|
||||
|
@ -154,33 +127,69 @@
|
|||
@include button-reset;
|
||||
@include font-body-1-bold;
|
||||
align-items: center;
|
||||
color: $color-gray-05;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-block: 12px;
|
||||
padding-inline: 24px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@include light-theme {
|
||||
color: $color-black;
|
||||
}
|
||||
@include dark-theme {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
|
||||
&::after {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/chevron/chevron-right.svg',
|
||||
$color-gray-05
|
||||
);
|
||||
content: '';
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
&--collapsed {
|
||||
&::after {
|
||||
@include light-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/chevron/chevron-right.svg',
|
||||
$color-black
|
||||
);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/chevron/chevron-right.svg',
|
||||
$color-gray-05
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
// Override color-svg
|
||||
:dir(ltr) &::after,
|
||||
:dir(rtl) &::after {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/chevron/chevron-down.svg',
|
||||
$color-gray-05
|
||||
);
|
||||
&::after {
|
||||
@include light-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/chevron/chevron-down.svg',
|
||||
$color-black
|
||||
);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/chevron/chevron-down.svg',
|
||||
$color-gray-05
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.StoriesTab__MoreActionsIcon {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@include light-theme {
|
||||
@include color-svg('../images/icons/v3/more/more.svg', $color-black);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg('../images/icons/v3/more/more.svg', $color-gray-15);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,12 @@
|
|||
|
||||
@include keyboard-mode {
|
||||
&:focus {
|
||||
background: $color-gray-65;
|
||||
@include light-theme {
|
||||
background: $color-gray-15;
|
||||
}
|
||||
@include dark-theme {
|
||||
background: $color-gray-65;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,7 +28,12 @@
|
|||
// that has not been closed yet (active)
|
||||
&:hover,
|
||||
&--active {
|
||||
background: $color-gray-65;
|
||||
@include light-theme {
|
||||
background: $color-gray-15;
|
||||
}
|
||||
@include dark-theme {
|
||||
background: $color-gray-65;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -60,16 +70,26 @@
|
|||
|
||||
&--title {
|
||||
@include font-body-1-bold;
|
||||
color: $color-gray-05;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@include light-theme {
|
||||
color: $color-black;
|
||||
}
|
||||
@include dark-theme {
|
||||
color: $color-gray-05;
|
||||
}
|
||||
}
|
||||
|
||||
&--timestamp,
|
||||
&--sending,
|
||||
&--send_failed {
|
||||
@include font-body-2;
|
||||
color: $color-gray-25;
|
||||
@include light-theme {
|
||||
color: $color-gray-60;
|
||||
}
|
||||
@include dark-theme {
|
||||
color: $color-gray-25;
|
||||
}
|
||||
}
|
||||
|
||||
&--send_failed {
|
||||
|
@ -148,32 +168,31 @@
|
|||
}
|
||||
|
||||
&__icon {
|
||||
@mixin StoryListItem__Icon($path) {
|
||||
@include light-theme {
|
||||
@include color-svg($path, $color-black);
|
||||
}
|
||||
@include dark-theme {
|
||||
@include color-svg($path, $color-white);
|
||||
}
|
||||
}
|
||||
|
||||
&--chat {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/open/open-compact.svg',
|
||||
$color-white
|
||||
);
|
||||
@include StoryListItem__Icon('../images/icons/v3/open/open-compact.svg');
|
||||
}
|
||||
|
||||
&--delete {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/trash/trash-compact.svg',
|
||||
$color-white
|
||||
@include StoryListItem__Icon(
|
||||
'../images/icons/v3/trash/trash-compact.svg'
|
||||
);
|
||||
}
|
||||
|
||||
&--hide {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/x/x-circle-compact.svg',
|
||||
$color-white
|
||||
);
|
||||
@include StoryListItem__Icon('../images/icons/v3/x/x-circle-compact.svg');
|
||||
}
|
||||
|
||||
&--info {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/info/info-compact.svg',
|
||||
$color-white
|
||||
);
|
||||
@include StoryListItem__Icon('../images/icons/v3/info/info-compact.svg');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
@import './components/BadgeSustainerInstructionsDialog.scss';
|
||||
@import './components/BetterAvatarBubble.scss';
|
||||
@import './components/Button.scss';
|
||||
@import './components/CallsTab.scss';
|
||||
@import './components/CallingAudioIndicator.scss';
|
||||
@import './components/CallingButton.scss';
|
||||
@import './components/CallingLobby.scss';
|
||||
|
@ -102,6 +103,8 @@
|
|||
@import './components/MiniPlayer.scss';
|
||||
@import './components/Modal.scss';
|
||||
@import './components/MyStories.scss';
|
||||
@import './components/NavSidebar.scss';
|
||||
@import './components/NavTabs.scss';
|
||||
@import './components/OutgoingGiftBadgeModal.scss';
|
||||
@import './components/PermissionsPopup.scss';
|
||||
@import './components/PlaybackButton.scss';
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import { Bytes } from './context/Bytes';
|
||||
|
||||
const bytes = window.SignalContext?.bytes || new Bytes();
|
||||
const bytes = globalThis.window?.SignalContext?.bytes || new Bytes();
|
||||
|
||||
export function fromBase64(value: string): Uint8Array {
|
||||
return bytes.fromBase64(value);
|
||||
|
|
62
ts/Crypto.ts
|
@ -7,13 +7,9 @@ import { HKDF } from '@signalapp/libsignal-client';
|
|||
|
||||
import * as Bytes from './Bytes';
|
||||
import { calculateAgreement, generateKeyPair } from './Curve';
|
||||
import * as log from './logging/log';
|
||||
import { HashType, CipherType } from './types/Crypto';
|
||||
import { ProfileDecryptError } from './types/errors';
|
||||
import { UUID, UUID_BYTE_SIZE } from './types/UUID';
|
||||
import type { UUIDStringType } from './types/UUID';
|
||||
|
||||
export { uuidToBytes } from './util/uuidToBytes';
|
||||
import { getBytesSubarray } from './util/uuidToBytes';
|
||||
|
||||
export { HashType, CipherType };
|
||||
|
||||
|
@ -199,12 +195,16 @@ export function decryptSymmetric(
|
|||
const iv = getZeroes(IV_LENGTH);
|
||||
|
||||
const nonce = getFirstBytes(data, NONCE_LENGTH);
|
||||
const ciphertext = getBytes(
|
||||
const ciphertext = getBytesSubarray(
|
||||
data,
|
||||
NONCE_LENGTH,
|
||||
data.byteLength - NONCE_LENGTH - MAC_LENGTH
|
||||
);
|
||||
const theirMac = getBytes(data, data.byteLength - MAC_LENGTH, MAC_LENGTH);
|
||||
const theirMac = getBytesSubarray(
|
||||
data,
|
||||
data.byteLength - MAC_LENGTH,
|
||||
MAC_LENGTH
|
||||
);
|
||||
|
||||
const cipherKey = hmacSha256(key, nonce);
|
||||
const macKey = hmacSha256(key, cipherKey);
|
||||
|
@ -353,52 +353,6 @@ export function getFirstBytes(data: Uint8Array, n: number): Uint8Array {
|
|||
return data.subarray(0, n);
|
||||
}
|
||||
|
||||
export function getBytes(
|
||||
data: Uint8Array,
|
||||
start: number,
|
||||
n: number
|
||||
): Uint8Array {
|
||||
return data.subarray(start, start + n);
|
||||
}
|
||||
|
||||
export function bytesToUuid(bytes: Uint8Array): undefined | UUIDStringType {
|
||||
if (bytes.byteLength !== UUID_BYTE_SIZE) {
|
||||
log.warn(
|
||||
'bytesToUuid: received an Uint8Array of invalid length. ' +
|
||||
'Returning undefined'
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const uuids = splitUuids(bytes);
|
||||
if (uuids.length === 1) {
|
||||
return uuids[0] || undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function splitUuids(buffer: Uint8Array): Array<UUIDStringType | null> {
|
||||
const uuids = new Array<UUIDStringType | null>();
|
||||
for (let i = 0; i < buffer.byteLength; i += UUID_BYTE_SIZE) {
|
||||
const bytes = getBytes(buffer, i, UUID_BYTE_SIZE);
|
||||
const hex = Bytes.toHex(bytes);
|
||||
const chunks = [
|
||||
hex.substring(0, 8),
|
||||
hex.substring(8, 12),
|
||||
hex.substring(12, 16),
|
||||
hex.substring(16, 20),
|
||||
hex.substring(20),
|
||||
];
|
||||
const uuid = chunks.join('-');
|
||||
if (uuid !== '00000000-0000-0000-0000-000000000000') {
|
||||
uuids.push(UUID.cast(uuid));
|
||||
} else {
|
||||
uuids.push(null);
|
||||
}
|
||||
}
|
||||
return uuids;
|
||||
}
|
||||
|
||||
export function trimForDisplay(padded: Uint8Array): Uint8Array {
|
||||
let paddingEnd = 0;
|
||||
for (paddingEnd; paddingEnd < padded.length; paddingEnd += 1) {
|
||||
|
@ -588,7 +542,7 @@ export function decryptProfileName(
|
|||
// SignalContext APIs
|
||||
//
|
||||
|
||||
const { crypto } = window.SignalContext;
|
||||
const { crypto } = globalThis.window?.SignalContext ?? {};
|
||||
|
||||
export function sign(key: Uint8Array, data: Uint8Array): Uint8Array {
|
||||
return crypto.sign(key, data);
|
||||
|
|
|
@ -8,8 +8,8 @@ import * as log from './logging/log';
|
|||
import type { UUIDStringType } from './types/UUID';
|
||||
import { parseIntOrThrow } from './util/parseIntOrThrow';
|
||||
import { SECOND, HOUR } from './util/durations';
|
||||
import { uuidToBytes } from './util/uuidToBytes';
|
||||
import * as Bytes from './Bytes';
|
||||
import { uuidToBytes } from './util/uuidToBytes';
|
||||
import { HashType } from './types/Crypto';
|
||||
import { getCountryCode } from './types/PhoneNumber';
|
||||
|
||||
|
|
|
@ -186,6 +186,11 @@ import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration'
|
|||
import { makeLookup } from './util/makeLookup';
|
||||
import { addGlobalKeyboardShortcuts } from './services/addGlobalKeyboardShortcuts';
|
||||
import { createEventHandler } from './quill/signal-clipboard/util';
|
||||
import { onCallLogEventSync } from './util/onCallLogEventSync';
|
||||
import {
|
||||
getCallsHistoryForRedux,
|
||||
loadCallsHistory,
|
||||
} from './services/callHistoryLoader';
|
||||
|
||||
export function isOverHourIntoPast(timestamp: number): boolean {
|
||||
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
|
||||
|
@ -434,6 +439,10 @@ export async function startApp(): Promise<void> {
|
|||
'callEventSync',
|
||||
queuedEventListener(onCallEventSync, false)
|
||||
);
|
||||
messageReceiver.addEventListener(
|
||||
'callLogEventSync',
|
||||
queuedEventListener(onCallLogEventSync, false)
|
||||
);
|
||||
});
|
||||
|
||||
ourProfileKeyService.initialize(window.storage);
|
||||
|
@ -1121,6 +1130,7 @@ export async function startApp(): Promise<void> {
|
|||
loadInitialBadgesState(),
|
||||
loadStories(),
|
||||
loadDistributionLists(),
|
||||
loadCallsHistory(),
|
||||
window.textsecure.storage.protocol.hydrateCaches(),
|
||||
(async () => {
|
||||
mainWindowStats = await window.SignalContext.getMainWindowStats();
|
||||
|
@ -1174,6 +1184,7 @@ export async function startApp(): Promise<void> {
|
|||
menuOptions,
|
||||
stories: getStoriesForRedux(),
|
||||
storyDistributionLists: getDistributionListsForRedux(),
|
||||
callsHistory: getCallsHistoryForRedux(),
|
||||
});
|
||||
|
||||
const store = window.Signal.State.createStore(initialState);
|
||||
|
@ -1193,6 +1204,10 @@ export async function startApp(): Promise<void> {
|
|||
store.dispatch
|
||||
),
|
||||
badges: bindActionCreators(actionCreators.badges, store.dispatch),
|
||||
callHistory: bindActionCreators(
|
||||
actionCreators.callHistory,
|
||||
store.dispatch
|
||||
),
|
||||
calling: bindActionCreators(actionCreators.calling, store.dispatch),
|
||||
composer: bindActionCreators(actionCreators.composer, store.dispatch),
|
||||
conversations: bindActionCreators(
|
||||
|
|
|
@ -25,9 +25,7 @@ type PropsType = {
|
|||
registerSingleDevice: (number: string, code: string) => Promise<void>;
|
||||
renderCallManager: () => JSX.Element;
|
||||
renderGlobalModalContainer: () => JSX.Element;
|
||||
isShowingStoriesView: boolean;
|
||||
i18n: LocalizerType;
|
||||
renderStories: (closeView: () => unknown) => JSX.Element;
|
||||
hasSelectedStoryData: boolean;
|
||||
renderStoryViewer: (closeView: () => unknown) => JSX.Element;
|
||||
renderLightbox: () => JSX.Element | null;
|
||||
|
@ -53,7 +51,6 @@ type PropsType = {
|
|||
titleBarDoubleClick: () => void;
|
||||
toast?: AnyToast;
|
||||
scrollToMessage: (conversationId: string, messageId: string) => unknown;
|
||||
toggleStoriesView: () => unknown;
|
||||
viewStory: ViewStoryActionCreatorType;
|
||||
renderInbox: () => JSX.Element;
|
||||
};
|
||||
|
@ -69,7 +66,6 @@ export function App({
|
|||
i18n,
|
||||
isFullScreen,
|
||||
isMaximized,
|
||||
isShowingStoriesView,
|
||||
menuOptions,
|
||||
onUndoArchive,
|
||||
openFileInFolder,
|
||||
|
@ -81,13 +77,11 @@ export function App({
|
|||
renderGlobalModalContainer,
|
||||
renderInbox,
|
||||
renderLightbox,
|
||||
renderStories,
|
||||
renderStoryViewer,
|
||||
requestVerification,
|
||||
theme,
|
||||
titleBarDoubleClick,
|
||||
toast,
|
||||
toggleStoriesView,
|
||||
viewStory,
|
||||
}: PropsType): JSX.Element {
|
||||
let contents;
|
||||
|
@ -183,7 +177,6 @@ export function App({
|
|||
{renderGlobalModalContainer()}
|
||||
{renderCallManager()}
|
||||
{renderLightbox()}
|
||||
{isShowingStoriesView && renderStories(toggleStoriesView)}
|
||||
{hasSelectedStoryData &&
|
||||
renderStoryViewer(() => viewStory({ closeViewer: true }))}
|
||||
</div>
|
||||
|
|
|
@ -12,6 +12,7 @@ import React, { useEffect, useState } from 'react';
|
|||
import classNames from 'classnames';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import { filterDOMProps } from '@react-aria/utils';
|
||||
import type { AvatarColorType } from '../types/Colors';
|
||||
import type { BadgeType } from '../badges/types';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
|
@ -239,7 +240,7 @@ export function Avatar({
|
|||
if (onClick) {
|
||||
contents = (
|
||||
<button
|
||||
{...ariaProps}
|
||||
{...filterDOMProps(ariaProps)}
|
||||
className={contentsClassName}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
|
|
|
@ -46,8 +46,6 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false),
|
||||
onEditProfile: action('onEditProfile'),
|
||||
onStartUpdate: action('startUpdate'),
|
||||
onViewArchive: action('onViewArchive'),
|
||||
onViewPreferences: action('onViewPreferences'),
|
||||
phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''),
|
||||
profileName: text('profileName', overrideProps.profileName || ''),
|
||||
sharedGroupNames: [],
|
||||
|
|
|
@ -19,8 +19,6 @@ export type Props = {
|
|||
|
||||
onEditProfile: () => unknown;
|
||||
onStartUpdate: () => unknown;
|
||||
onViewPreferences: () => unknown;
|
||||
onViewArchive: () => unknown;
|
||||
|
||||
// Matches Popper's RefHandler type
|
||||
innerRef?: React.Ref<HTMLDivElement>;
|
||||
|
@ -35,8 +33,6 @@ export function AvatarPopup(props: Props): JSX.Element {
|
|||
name,
|
||||
onEditProfile,
|
||||
onStartUpdate,
|
||||
onViewArchive,
|
||||
onViewPreferences,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
style,
|
||||
|
@ -70,54 +66,27 @@ export function AvatarPopup(props: Props): JSX.Element {
|
|||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
<hr className="module-avatar-popup__divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="module-avatar-popup__item"
|
||||
onClick={onViewPreferences}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-avatar-popup__item__icon',
|
||||
'module-avatar-popup__item__icon-settings'
|
||||
)}
|
||||
/>
|
||||
<div className="module-avatar-popup__item__text">
|
||||
{i18n('icu:mainMenuSettings')}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="module-avatar-popup__item"
|
||||
onClick={onViewArchive}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-avatar-popup__item__icon',
|
||||
'module-avatar-popup__item__icon-archive'
|
||||
)}
|
||||
/>
|
||||
<div className="module-avatar-popup__item__text">
|
||||
{i18n('icu:avatarMenuViewArchive')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{hasPendingUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
className="module-avatar-popup__item"
|
||||
onClick={onStartUpdate}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-avatar-popup__item__icon',
|
||||
'module-avatar-popup__item__icon--update'
|
||||
)}
|
||||
/>
|
||||
<div className="module-avatar-popup__item__text">
|
||||
{i18n('icu:avatarMenuUpdateAvailable')}
|
||||
</div>
|
||||
<div className="module-avatar-popup__item--badge" />
|
||||
</button>
|
||||
<>
|
||||
<hr className="module-avatar-popup__divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="module-avatar-popup__item"
|
||||
onClick={onStartUpdate}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-avatar-popup__item__icon',
|
||||
'module-avatar-popup__item__icon--update'
|
||||
)}
|
||||
/>
|
||||
<div className="module-avatar-popup__item__text">
|
||||
{i18n('icu:avatarMenuUpdateAvailable')}
|
||||
</div>
|
||||
<div className="module-avatar-popup__item--badge" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -33,6 +33,7 @@ export enum ButtonVariant {
|
|||
|
||||
export enum ButtonIconType {
|
||||
audio = 'audio',
|
||||
message = 'message',
|
||||
muted = 'muted',
|
||||
search = 'search',
|
||||
unmuted = 'unmuted',
|
||||
|
|
476
ts/components/CallsList.tsx
Normal file
|
@ -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;
|
||||
|
||||
if (typeof children === 'function') {
|
||||
buttonNode = (children as (props: RenderButtonProps) => JSX.Element)({
|
||||
openMenu: onClick || handleClick,
|
||||
onKeyDown: handleKeyDown,
|
||||
isMenuShowing,
|
||||
ref: setReferenceElement,
|
||||
menuNode,
|
||||
});
|
||||
buttonNode = (
|
||||
<>
|
||||
{(children as (props: RenderButtonProps) => JSX.Element)({
|
||||
openMenu: onClick || handleClick,
|
||||
onKeyDown: handleKeyDown,
|
||||
isMenuShowing,
|
||||
ref: setReferenceElement,
|
||||
menuNode,
|
||||
})}
|
||||
{portalNode ? createPortal(menuNode, portalNode) : menuNode}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
buttonNode = (
|
||||
<div
|
||||
|
|
|
@ -12,7 +12,7 @@ import { assertDev } from '../util/assert';
|
|||
import type { ParsedE164Type } from '../util/libphonenumberInstance';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import { ScrollBehavior } from '../types/Util';
|
||||
import { getConversationListWidthBreakpoint } from './_util';
|
||||
import { getNavSidebarWidthBreakpoint } from './_util';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid';
|
||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||
|
@ -493,7 +493,7 @@ export function ConversationList({
|
|||
return null;
|
||||
}
|
||||
|
||||
const widthBreakpoint = getConversationListWidthBreakpoint(dimensions.width);
|
||||
const widthBreakpoint = getNavSidebarWidthBreakpoint(dimensions.width);
|
||||
|
||||
return (
|
||||
<ListView
|
||||
|
|
|
@ -84,10 +84,7 @@ const Template: Story<PropsType & { daysAgo?: number }> = ({
|
|||
{...args}
|
||||
firstEnvelopeTimestamp={firstEnvelopeTimestamp}
|
||||
envelopeTimestamp={envelopeTimestamp}
|
||||
renderConversationView={() => <div />}
|
||||
renderCustomizingPreferredReactionsModal={() => <div />}
|
||||
renderLeftPane={() => <div />}
|
||||
renderMiniPlayer={() => <div />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,19 +3,10 @@
|
|||
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
|
||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
|
||||
import * as log from '../logging/log';
|
||||
import { SECOND, DAY } from '../util/durations';
|
||||
import { ToastStickerPackInstallFailed } from './ToastStickerPackInstallFailed';
|
||||
import { WhatsNewLink } from './WhatsNewLink';
|
||||
import { showToast } from '../util/showToast';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { TargetedMessageSource } from '../state/ducks/conversationsEnums';
|
||||
import { usePrevious } from '../hooks/usePrevious';
|
||||
import { Environment, getEnvironment } from '../environment';
|
||||
import type { SmartNavTabsProps } from '../state/smart/NavTabs';
|
||||
|
||||
export type PropsType = {
|
||||
firstEnvelopeTimestamp: number | undefined;
|
||||
|
@ -23,18 +14,13 @@ export type PropsType = {
|
|||
hasInitialLoadCompleted: boolean;
|
||||
i18n: LocalizerType;
|
||||
isCustomizingPreferredReactions: boolean;
|
||||
onConversationClosed: (id: string, reason: string) => unknown;
|
||||
onConversationOpened: (id: string, messageId?: string) => unknown;
|
||||
renderConversationView: () => JSX.Element;
|
||||
navTabsCollapsed: boolean;
|
||||
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => unknown;
|
||||
renderCallsTab: () => JSX.Element;
|
||||
renderChatsTab: () => JSX.Element;
|
||||
renderCustomizingPreferredReactionsModal: () => JSX.Element;
|
||||
renderLeftPane: () => JSX.Element;
|
||||
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
|
||||
scrollToMessage: (conversationId: string, messageId: string) => unknown;
|
||||
selectedConversationId?: string;
|
||||
targetedMessage?: string;
|
||||
targetedMessageSource?: TargetedMessageSource;
|
||||
showConversation: ShowConversationType;
|
||||
showWhatsNewModal: () => unknown;
|
||||
renderNavTabs: (props: SmartNavTabsProps) => JSX.Element;
|
||||
renderStoriesTab: () => JSX.Element;
|
||||
};
|
||||
|
||||
export function Inbox({
|
||||
|
@ -43,27 +29,17 @@ export function Inbox({
|
|||
hasInitialLoadCompleted,
|
||||
i18n,
|
||||
isCustomizingPreferredReactions,
|
||||
onConversationClosed,
|
||||
onConversationOpened,
|
||||
renderConversationView,
|
||||
navTabsCollapsed,
|
||||
onToggleNavTabsCollapse,
|
||||
renderCallsTab,
|
||||
renderChatsTab,
|
||||
renderCustomizingPreferredReactionsModal,
|
||||
renderLeftPane,
|
||||
renderMiniPlayer,
|
||||
scrollToMessage,
|
||||
selectedConversationId,
|
||||
targetedMessage,
|
||||
targetedMessageSource,
|
||||
showConversation,
|
||||
showWhatsNewModal,
|
||||
renderNavTabs,
|
||||
renderStoriesTab,
|
||||
}: PropsType): JSX.Element {
|
||||
const [internalHasInitialLoadCompleted, setInternalHasInitialLoadCompleted] =
|
||||
useState(hasInitialLoadCompleted);
|
||||
|
||||
const prevConversationId = usePrevious(
|
||||
selectedConversationId,
|
||||
selectedConversationId
|
||||
);
|
||||
|
||||
const now = useMemo(() => Date.now(), []);
|
||||
const midnight = useMemo(() => {
|
||||
const date = new Date(now);
|
||||
|
@ -74,80 +50,6 @@ export function Inbox({
|
|||
return date.getTime();
|
||||
}, [now]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevConversationId !== selectedConversationId) {
|
||||
if (prevConversationId) {
|
||||
onConversationClosed(prevConversationId, 'opened another conversation');
|
||||
}
|
||||
|
||||
if (selectedConversationId) {
|
||||
onConversationOpened(selectedConversationId, targetedMessage);
|
||||
}
|
||||
} else if (
|
||||
selectedConversationId &&
|
||||
targetedMessage &&
|
||||
targetedMessageSource !== TargetedMessageSource.Focus
|
||||
) {
|
||||
scrollToMessage(selectedConversationId, targetedMessage);
|
||||
}
|
||||
|
||||
if (!selectedConversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = window.ConversationController.get(
|
||||
selectedConversationId
|
||||
);
|
||||
strictAssert(conversation, 'Conversation must be found');
|
||||
|
||||
conversation.setMarkedUnread(false);
|
||||
}, [
|
||||
onConversationClosed,
|
||||
onConversationOpened,
|
||||
prevConversationId,
|
||||
scrollToMessage,
|
||||
selectedConversationId,
|
||||
targetedMessage,
|
||||
targetedMessageSource,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
function refreshConversation({
|
||||
newId,
|
||||
oldId,
|
||||
}: {
|
||||
newId: string;
|
||||
oldId: string;
|
||||
}) {
|
||||
if (prevConversationId === oldId) {
|
||||
showConversation({ conversationId: newId });
|
||||
}
|
||||
}
|
||||
|
||||
// Close current opened conversation to reload the group information once
|
||||
// linked.
|
||||
function unload() {
|
||||
if (!prevConversationId) {
|
||||
return;
|
||||
}
|
||||
onConversationClosed(prevConversationId, 'force unload requested');
|
||||
}
|
||||
|
||||
function packInstallFailed() {
|
||||
showToast(ToastStickerPackInstallFailed);
|
||||
}
|
||||
|
||||
window.Whisper.events.on('pack-install-failed', packInstallFailed);
|
||||
window.Whisper.events.on('refreshConversation', refreshConversation);
|
||||
window.Whisper.events.on('setupAsNewDevice', unload);
|
||||
|
||||
return () => {
|
||||
window.Whisper.events.off('pack-install-failed', packInstallFailed);
|
||||
window.Whisper.events.off('refreshConversation', refreshConversation);
|
||||
window.Whisper.events.off('setupAsNewDevice', unload);
|
||||
};
|
||||
}, [onConversationClosed, prevConversationId, showConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (internalHasInitialLoadCompleted) {
|
||||
return;
|
||||
|
@ -186,12 +88,6 @@ export function Inbox({
|
|||
setInternalHasInitialLoadCompleted(hasInitialLoadCompleted);
|
||||
}, [hasInitialLoadCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedConversationId) {
|
||||
window.SignalCI?.handleEvent('empty-inbox:rendered', null);
|
||||
}
|
||||
}, [selectedConversationId]);
|
||||
|
||||
if (!internalHasInitialLoadCompleted) {
|
||||
let loadingProgress = 0;
|
||||
if (
|
||||
|
@ -264,37 +160,13 @@ export function Inbox({
|
|||
<>
|
||||
<div className="Inbox">
|
||||
<div className="module-title-bar-drag-area" />
|
||||
|
||||
<div id="LeftPane">{renderLeftPane()}</div>
|
||||
|
||||
<div className="Inbox__conversation-stack">
|
||||
<div id="toast" />
|
||||
{selectedConversationId && (
|
||||
<div
|
||||
className="Inbox__conversation"
|
||||
id={`conversation-${selectedConversationId}`}
|
||||
>
|
||||
{renderConversationView()}
|
||||
</div>
|
||||
)}
|
||||
{!prevConversationId && (
|
||||
<div className="Inbox__no-conversation-open">
|
||||
{renderMiniPlayer({ shouldFlow: false })}
|
||||
<div className="module-splash-screen__logo module-img--128 module-logo-blue" />
|
||||
<h3>
|
||||
{getEnvironment() !== Environment.Staging
|
||||
? i18n('icu:welcomeToSignal')
|
||||
: 'THIS IS A STAGING DESKTOP'}
|
||||
</h3>
|
||||
<p>
|
||||
<WhatsNewLink
|
||||
i18n={i18n}
|
||||
showWhatsNewModal={showWhatsNewModal}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderNavTabs({
|
||||
navTabsCollapsed,
|
||||
onToggleNavTabsCollapse,
|
||||
renderChatsTab,
|
||||
renderCallsTab,
|
||||
renderStoriesTab,
|
||||
})}
|
||||
</div>
|
||||
{activeModal}
|
||||
</>
|
||||
|
|
|
@ -165,6 +165,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
|||
),
|
||||
isUpdateDownloaded,
|
||||
isContactManagementEnabled,
|
||||
navTabsCollapsed: boolean('navTabsCollapsed', false),
|
||||
|
||||
setChallengeStatus: action('setChallengeStatus'),
|
||||
lookupConversationWithoutUuid: makeFakeLookupConversationWithoutUuid(),
|
||||
|
@ -179,7 +180,6 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
|||
'onOutgoingVideoCallInConversation'
|
||||
),
|
||||
removeConversation: action('removeConversation'),
|
||||
renderMainHeader: () => <div />,
|
||||
renderMessageSearchResult: (id: string) => (
|
||||
<MessageSearchResult
|
||||
body="Lorem ipsum wow"
|
||||
|
@ -273,6 +273,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
|||
toggleConversationInChooseMembers: action(
|
||||
'toggleConversationInChooseMembers'
|
||||
),
|
||||
toggleNavTabsCollapse: action('toggleNavTabsCollapse'),
|
||||
updateSearchTerm: action('updateSearchTerm'),
|
||||
|
||||
...overrideProps,
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
// Copyright 2019 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useEffect, useCallback, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useCallback, useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { clamp, isNumber, noop } from 'lodash';
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
import type { LeftPaneHelper, ToFindType } from './leftPane/LeftPaneHelper';
|
||||
import { FindDirection } from './leftPane/LeftPaneHelper';
|
||||
|
@ -27,15 +27,8 @@ import { usePrevious } from '../hooks/usePrevious';
|
|||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import type { DurationInSeconds } from '../util/durations';
|
||||
import type { WidthBreakpoint } from './_util';
|
||||
import { getConversationListWidthBreakpoint } from './_util';
|
||||
import { getNavSidebarWidthBreakpoint } from './_util';
|
||||
import * as KeyboardLayout from '../services/keyboardLayout';
|
||||
import {
|
||||
MIN_WIDTH,
|
||||
SNAP_WIDTH,
|
||||
MIN_FULL_WIDTH,
|
||||
MAX_WIDTH,
|
||||
getWidthFromPreferredWidth,
|
||||
} from '../util/leftPaneWidth';
|
||||
import type { LookupConversationWithoutUuidActionsType } from '../util/lookupConversationWithoutUuid';
|
||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||
import type { PropsType as UnsupportedOSDialogPropsType } from '../state/smart/UnsupportedOSDialog';
|
||||
|
@ -50,6 +43,12 @@ import type {
|
|||
SaveAvatarToDiskActionType,
|
||||
} from '../types/Avatar';
|
||||
import { SizeObserver } from '../hooks/useSizeObserver';
|
||||
import {
|
||||
NavSidebar,
|
||||
NavSidebarActionButton,
|
||||
NavSidebarSearchHeader,
|
||||
} from './NavSidebar';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
|
||||
export enum LeftPaneMode {
|
||||
Inbox,
|
||||
|
@ -114,6 +113,7 @@ export type PropsType = {
|
|||
composeReplaceAvatar: ReplaceAvatarActionType;
|
||||
composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
|
||||
createGroup: () => void;
|
||||
navTabsCollapsed: boolean;
|
||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||
removeConversation: (conversationId: string) => void;
|
||||
|
@ -132,10 +132,10 @@ export type PropsType = {
|
|||
startSettingGroupMetadata: () => void;
|
||||
toggleComposeEditingAvatar: () => unknown;
|
||||
toggleConversationInChooseMembers: (conversationId: string) => void;
|
||||
toggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||
updateSearchTerm: (_: string) => void;
|
||||
|
||||
// Render Props
|
||||
renderMainHeader: () => JSX.Element;
|
||||
renderMessageSearchResult: (id: string) => JSX.Element;
|
||||
renderNetworkStatus: (
|
||||
_: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
|
||||
|
@ -178,14 +178,15 @@ export function LeftPane({
|
|||
isUpdateDownloaded,
|
||||
isContactManagementEnabled,
|
||||
modeSpecificProps,
|
||||
navTabsCollapsed,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
|
||||
preferredWidthFromStorage,
|
||||
removeConversation,
|
||||
renderCaptchaDialog,
|
||||
renderCrashReportDialog,
|
||||
renderExpiredBuildDialog,
|
||||
renderMainHeader,
|
||||
renderMessageSearchResult,
|
||||
renderNetworkStatus,
|
||||
renderUnsupportedOSDialog,
|
||||
|
@ -195,6 +196,7 @@ export function LeftPane({
|
|||
searchInConversation,
|
||||
selectedConversationId,
|
||||
targetedMessageId,
|
||||
toggleNavTabsCollapse,
|
||||
setChallengeStatus,
|
||||
setComposeGroupAvatar,
|
||||
setComposeGroupExpireTimer,
|
||||
|
@ -215,12 +217,6 @@ export function LeftPane({
|
|||
unsupportedOSDialogType,
|
||||
updateSearchTerm,
|
||||
}: PropsType): JSX.Element {
|
||||
const [preferredWidth, setPreferredWidth] = useState(
|
||||
// This clamp is present just in case we get a bogus value from storage.
|
||||
clamp(preferredWidthFromStorage, MIN_WIDTH, MAX_WIDTH)
|
||||
);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
const previousModeSpecificProps = usePrevious(
|
||||
modeSpecificProps,
|
||||
modeSpecificProps
|
||||
|
@ -421,76 +417,6 @@ export function LeftPane({
|
|||
startSearch,
|
||||
]);
|
||||
|
||||
const requiresFullWidth = helper.requiresFullWidth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
let width: number;
|
||||
|
||||
const isRTL = i18n.getLocaleDirection() === 'rtl';
|
||||
const x = isRTL ? window.innerWidth - event.clientX : event.clientX;
|
||||
|
||||
if (requiresFullWidth) {
|
||||
width = Math.max(x, MIN_FULL_WIDTH);
|
||||
} else if (x < SNAP_WIDTH) {
|
||||
width = MIN_WIDTH;
|
||||
} else {
|
||||
width = clamp(x, MIN_FULL_WIDTH, MAX_WIDTH);
|
||||
}
|
||||
setPreferredWidth(Math.min(width, MAX_WIDTH));
|
||||
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const stopResizing = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
document.body.addEventListener('mousemove', onMouseMove);
|
||||
document.body.addEventListener('mouseup', stopResizing);
|
||||
document.body.addEventListener('mouseleave', stopResizing);
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener('mousemove', onMouseMove);
|
||||
document.body.removeEventListener('mouseup', stopResizing);
|
||||
document.body.removeEventListener('mouseleave', stopResizing);
|
||||
};
|
||||
}, [i18n, isResizing, requiresFullWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
document.body.classList.add('is-resizing-left-pane');
|
||||
return () => {
|
||||
document.body.classList.remove('is-resizing-left-pane');
|
||||
};
|
||||
}, [isResizing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isResizing || preferredWidth === preferredWidthFromStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
savePreferredLeftPaneWidth(preferredWidth);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [
|
||||
isResizing,
|
||||
preferredWidth,
|
||||
preferredWidthFromStorage,
|
||||
savePreferredLeftPaneWidth,
|
||||
]);
|
||||
|
||||
const preRowsNode = helper.getPreRowsNode({
|
||||
clearConversationSearch,
|
||||
clearGroupCreationError,
|
||||
|
@ -553,11 +479,7 @@ export function LeftPane({
|
|||
// It also ensures that we scroll to the top when switching views.
|
||||
const listKey = preRowsNode ? 1 : 0;
|
||||
|
||||
const width = getWidthFromPreferredWidth(preferredWidth, {
|
||||
requiresFullWidth,
|
||||
});
|
||||
|
||||
const widthBreakpoint = getConversationListWidthBreakpoint(width);
|
||||
const widthBreakpoint = getNavSidebarWidthBreakpoint(300);
|
||||
|
||||
const commonDialogProps = {
|
||||
i18n,
|
||||
|
@ -614,127 +536,171 @@ export function LeftPane({
|
|||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={classNames(
|
||||
'module-left-pane',
|
||||
isResizing && 'module-left-pane--is-resizing',
|
||||
`module-left-pane--width-${widthBreakpoint}`,
|
||||
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers &&
|
||||
'module-left-pane--mode-choose-group-members',
|
||||
modeSpecificProps.mode === LeftPaneMode.Compose &&
|
||||
'module-left-pane--mode-compose'
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
{/* eslint-enable jsx-a11y/no-static-element-interactions */}
|
||||
<div className="module-left-pane__header">
|
||||
{helper.getHeaderContents({
|
||||
i18n,
|
||||
showInbox,
|
||||
startComposing,
|
||||
showChooseGroupMembers,
|
||||
}) || renderMainHeader()}
|
||||
</div>
|
||||
{helper.getSearchInput({
|
||||
clearConversationSearch,
|
||||
clearSearch,
|
||||
i18n,
|
||||
onChangeComposeSearchTerm: event => {
|
||||
setComposeSearchTerm(event.target.value);
|
||||
},
|
||||
updateSearchTerm,
|
||||
showConversation,
|
||||
})}
|
||||
<div className="module-left-pane__dialogs">
|
||||
{dialogs.map(({ key, dialog }) => (
|
||||
<React.Fragment key={key}>{dialog}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
|
||||
<SizeObserver>
|
||||
{(ref, size) => (
|
||||
<div className="module-left-pane__list--measure" ref={ref}>
|
||||
<div className="module-left-pane__list--wrapper">
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="module-left-pane__list"
|
||||
data-supertab
|
||||
key={listKey}
|
||||
role="presentation"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ConversationList
|
||||
dimensions={{
|
||||
width,
|
||||
height: size?.height || 0,
|
||||
}}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
getRow={getRow}
|
||||
i18n={i18n}
|
||||
onClickArchiveButton={showArchivedConversations}
|
||||
onClickContactCheckbox={(
|
||||
conversationId: string,
|
||||
disabledReason: undefined | ContactCheckboxDisabledReason
|
||||
) => {
|
||||
switch (disabledReason) {
|
||||
case undefined:
|
||||
toggleConversationInChooseMembers(conversationId);
|
||||
break;
|
||||
case ContactCheckboxDisabledReason.AlreadyAdded:
|
||||
case ContactCheckboxDisabledReason.MaximumContactsSelected:
|
||||
// These are no-ops.
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(disabledReason);
|
||||
}
|
||||
}}
|
||||
showUserNotFoundModal={showUserNotFoundModal}
|
||||
setIsFetchingUUID={setIsFetchingUUID}
|
||||
lookupConversationWithoutUuid={lookupConversationWithoutUuid}
|
||||
showConversation={showConversation}
|
||||
blockConversation={blockConversation}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onOutgoingAudioCallInConversation={
|
||||
onOutgoingAudioCallInConversation
|
||||
}
|
||||
onOutgoingVideoCallInConversation={
|
||||
onOutgoingVideoCallInConversation
|
||||
}
|
||||
removeConversation={
|
||||
isContactManagementEnabled ? removeConversation : undefined
|
||||
}
|
||||
renderMessageSearchResult={renderMessageSearchResult}
|
||||
rowCount={helper.getRowCount()}
|
||||
scrollBehavior={scrollBehavior}
|
||||
scrollToRowIndex={rowIndexToScrollTo}
|
||||
scrollable={isScrollable}
|
||||
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
|
||||
showChooseGroupMembers={showChooseGroupMembers}
|
||||
theme={theme}
|
||||
<NavSidebar
|
||||
title="Chats"
|
||||
hideHeader={
|
||||
modeSpecificProps.mode === LeftPaneMode.Archive ||
|
||||
modeSpecificProps.mode === LeftPaneMode.Compose ||
|
||||
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers ||
|
||||
modeSpecificProps.mode === LeftPaneMode.SetGroupMetadata
|
||||
}
|
||||
i18n={i18n}
|
||||
navTabsCollapsed={navTabsCollapsed}
|
||||
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
||||
preferredLeftPaneWidth={preferredWidthFromStorage}
|
||||
requiresFullWidth={false}
|
||||
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||
actions={
|
||||
<>
|
||||
<NavSidebarActionButton
|
||||
label={i18n('icu:newConversation')}
|
||||
icon={<span className="module-left-pane__startComposingIcon" />}
|
||||
onClick={startComposing}
|
||||
/>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
label: i18n('icu:avatarMenuViewArchive'),
|
||||
onClick: showArchivedConversations,
|
||||
},
|
||||
]}
|
||||
popperOptions={{
|
||||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
}}
|
||||
portalToRoot
|
||||
>
|
||||
{({ openMenu, onKeyDown }) => {
|
||||
return (
|
||||
<NavSidebarActionButton
|
||||
onClick={openMenu}
|
||||
onKeyDown={onKeyDown}
|
||||
icon={<span className="module-left-pane__moreActionsIcon" />}
|
||||
label="More Actions"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ContextMenu>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<nav
|
||||
className={classNames(
|
||||
'module-left-pane',
|
||||
modeSpecificProps.mode === LeftPaneMode.ChooseGroupMembers &&
|
||||
'module-left-pane--mode-choose-group-members',
|
||||
modeSpecificProps.mode === LeftPaneMode.Compose &&
|
||||
'module-left-pane--mode-compose'
|
||||
)}
|
||||
>
|
||||
{/* eslint-enable jsx-a11y/no-static-element-interactions */}
|
||||
<div className="module-left-pane__header">
|
||||
{helper.getHeaderContents({
|
||||
i18n,
|
||||
showInbox,
|
||||
startComposing,
|
||||
showChooseGroupMembers,
|
||||
})}
|
||||
</div>
|
||||
<NavSidebarSearchHeader>
|
||||
{helper.getSearchInput({
|
||||
clearConversationSearch,
|
||||
clearSearch,
|
||||
i18n,
|
||||
onChangeComposeSearchTerm: event => {
|
||||
setComposeSearchTerm(event.target.value);
|
||||
},
|
||||
updateSearchTerm,
|
||||
showConversation,
|
||||
})}
|
||||
</NavSidebarSearchHeader>
|
||||
<div className="module-left-pane__dialogs">
|
||||
{dialogs.map(({ key, dialog }) => (
|
||||
<React.Fragment key={key}>{dialog}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
|
||||
<SizeObserver>
|
||||
{(ref, size) => (
|
||||
<div className="module-left-pane__list--measure" ref={ref}>
|
||||
<div className="module-left-pane__list--wrapper">
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="module-left-pane__list"
|
||||
data-supertab
|
||||
key={listKey}
|
||||
role="presentation"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ConversationList
|
||||
dimensions={size ?? undefined}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
getRow={getRow}
|
||||
i18n={i18n}
|
||||
onClickArchiveButton={showArchivedConversations}
|
||||
onClickContactCheckbox={(
|
||||
conversationId: string,
|
||||
disabledReason: undefined | ContactCheckboxDisabledReason
|
||||
) => {
|
||||
switch (disabledReason) {
|
||||
case undefined:
|
||||
toggleConversationInChooseMembers(conversationId);
|
||||
break;
|
||||
case ContactCheckboxDisabledReason.AlreadyAdded:
|
||||
case ContactCheckboxDisabledReason.MaximumContactsSelected:
|
||||
// These are no-ops.
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(disabledReason);
|
||||
}
|
||||
}}
|
||||
showUserNotFoundModal={showUserNotFoundModal}
|
||||
setIsFetchingUUID={setIsFetchingUUID}
|
||||
lookupConversationWithoutUuid={
|
||||
lookupConversationWithoutUuid
|
||||
}
|
||||
showConversation={showConversation}
|
||||
blockConversation={blockConversation}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onOutgoingAudioCallInConversation={
|
||||
onOutgoingAudioCallInConversation
|
||||
}
|
||||
onOutgoingVideoCallInConversation={
|
||||
onOutgoingVideoCallInConversation
|
||||
}
|
||||
removeConversation={
|
||||
isContactManagementEnabled
|
||||
? removeConversation
|
||||
: undefined
|
||||
}
|
||||
renderMessageSearchResult={renderMessageSearchResult}
|
||||
rowCount={helper.getRowCount()}
|
||||
scrollBehavior={scrollBehavior}
|
||||
scrollToRowIndex={rowIndexToScrollTo}
|
||||
scrollable={isScrollable}
|
||||
shouldRecomputeRowHeights={shouldRecomputeRowHeights}
|
||||
showChooseGroupMembers={showChooseGroupMembers}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SizeObserver>
|
||||
{footerContents && (
|
||||
<div className="module-left-pane__footer">{footerContents}</div>
|
||||
)}
|
||||
</SizeObserver>
|
||||
{footerContents && (
|
||||
<div className="module-left-pane__footer">{footerContents}</div>
|
||||
)}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="module-left-pane__resize-grab-area"
|
||||
onMouseDown={() => {
|
||||
setIsResizing(true);
|
||||
}}
|
||||
/>
|
||||
{challengeStatus !== 'idle' &&
|
||||
renderCaptchaDialog({
|
||||
onSkip() {
|
||||
setChallengeStatus('idle');
|
||||
},
|
||||
})}
|
||||
{crashReportCount > 0 && renderCrashReportDialog()}
|
||||
</nav>
|
||||
|
||||
{challengeStatus !== 'idle' &&
|
||||
renderCaptchaDialog({
|
||||
onSkip() {
|
||||
setChallengeStatus('idle');
|
||||
},
|
||||
})}
|
||||
{crashReportCount > 0 && renderCrashReportDialog()}
|
||||
</nav>
|
||||
</NavSidebar>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 { Intl } from './Intl';
|
||||
import { Modal } from './Modal';
|
||||
import { STORIES_COLOR_THEME } from './Stories';
|
||||
|
||||
export type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
|
@ -24,7 +23,6 @@ export function SignalConnectionsModal({
|
|||
hasXButton
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
theme={STORIES_COLOR_THEME}
|
||||
>
|
||||
<div className="SignalConnectionsModal">
|
||||
<i className="SignalConnectionsModal__icon" />
|
||||
|
|
|
@ -7,7 +7,6 @@ import React, { useState, useCallback } from 'react';
|
|||
import type { LocalizerType } from '../types/Util';
|
||||
import type { ShowToastAction } from '../state/ducks/toast';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import { Theme } from '../util/theme';
|
||||
import { ToastType } from '../types/Toast';
|
||||
import {
|
||||
isVideoGoodForStories,
|
||||
|
@ -109,7 +108,6 @@ export function StoriesAddStoryButton({
|
|||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
}}
|
||||
theme={Theme.Dark}
|
||||
>
|
||||
{children}
|
||||
</ContextMenu>
|
||||
|
|
|
@ -10,18 +10,15 @@ import type {
|
|||
ShowConversationType,
|
||||
} from '../state/ducks/conversations';
|
||||
import type { ConversationStoryType, MyStoryType } from '../types/Stories';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import type { ShowToastAction } from '../state/ducks/toast';
|
||||
import type { ViewUserStoriesActionCreatorType } from '../state/ducks/stories';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import { MyStoryButton } from './MyStoryButton';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { StoriesAddStoryButton } from './StoriesAddStoryButton';
|
||||
import { StoryListItem } from './StoryListItem';
|
||||
import { Theme } from '../util/theme';
|
||||
import { isNotNil } from '../util/isNotNil';
|
||||
import { useRestoreFocus } from '../hooks/useRestoreFocus';
|
||||
import { NavSidebarSearchHeader } from './NavSidebar';
|
||||
|
||||
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
|
||||
getFn: (story, path) => {
|
||||
|
@ -70,8 +67,8 @@ export type PropsType = {
|
|||
showConversation: ShowConversationType;
|
||||
showToast: ShowToastAction;
|
||||
stories: Array<ConversationStoryType>;
|
||||
theme: ThemeType;
|
||||
toggleHideStories: (conversationId: string) => unknown;
|
||||
toggleStoriesView: () => unknown;
|
||||
viewUserStories: ViewUserStoriesActionCreatorType;
|
||||
};
|
||||
|
||||
|
@ -84,14 +81,13 @@ export function StoriesPane({
|
|||
myStories,
|
||||
onAddStory,
|
||||
onMyStoriesClicked,
|
||||
onStoriesSettings,
|
||||
onMediaPlaybackStart,
|
||||
queueStoryDownload,
|
||||
showConversation,
|
||||
showToast,
|
||||
stories,
|
||||
theme,
|
||||
toggleHideStories,
|
||||
toggleStoriesView,
|
||||
viewUserStories,
|
||||
}: PropsType): JSX.Element {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
@ -106,55 +102,18 @@ export function StoriesPane({
|
|||
setRenderedStories(stories);
|
||||
}
|
||||
}, [searchTerm, stories]);
|
||||
|
||||
const [focusRef] = useRestoreFocus();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="Stories__pane__header">
|
||||
<button
|
||||
ref={focusRef}
|
||||
aria-label={i18n('icu:back')}
|
||||
className="Stories__pane__header--back"
|
||||
onClick={toggleStoriesView}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
/>
|
||||
<div className="Stories__pane__header--title">
|
||||
{i18n('icu:Stories__title')}
|
||||
</div>
|
||||
<StoriesAddStoryButton
|
||||
<NavSidebarSearchHeader>
|
||||
<SearchInput
|
||||
i18n={i18n}
|
||||
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
||||
moduleClassName="Stories__pane__add-story"
|
||||
onAddStory={onAddStory}
|
||||
showToast={showToast}
|
||||
/>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
label: i18n('icu:StoriesSettings__context-menu'),
|
||||
onClick: () => onStoriesSettings(),
|
||||
},
|
||||
]}
|
||||
moduleClassName="Stories__pane__settings"
|
||||
popperOptions={{
|
||||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
onChange={event => {
|
||||
setSearchTerm(event.target.value);
|
||||
}}
|
||||
theme={Theme.Dark}
|
||||
placeholder={i18n('icu:search')}
|
||||
value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
<SearchInput
|
||||
i18n={i18n}
|
||||
moduleClassName="Stories__search"
|
||||
onChange={event => {
|
||||
setSearchTerm(event.target.value);
|
||||
}}
|
||||
placeholder={i18n('icu:search')}
|
||||
value={searchTerm}
|
||||
/>
|
||||
</NavSidebarSearchHeader>
|
||||
<div className="Stories__pane__list">
|
||||
<MyStoryButton
|
||||
i18n={i18n}
|
||||
|
@ -178,12 +137,12 @@ export function StoriesPane({
|
|||
key={story.storyView.timestamp}
|
||||
onGoToConversation={conversationId => {
|
||||
showConversation({ conversationId });
|
||||
toggleStoriesView();
|
||||
}}
|
||||
onHideStory={toggleHideStories}
|
||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
story={story.storyView}
|
||||
theme={theme}
|
||||
viewUserStories={viewUserStories}
|
||||
/>
|
||||
))}
|
||||
|
@ -191,6 +150,7 @@ export function StoriesPane({
|
|||
<>
|
||||
<button
|
||||
className={classNames('Stories__hidden-stories', {
|
||||
'Stories__hidden-stories--collapsed': !isShowingHiddenStories,
|
||||
'Stories__hidden-stories--expanded': isShowingHiddenStories,
|
||||
})}
|
||||
onClick={() => setIsShowingHiddenStories(!isShowingHiddenStories)}
|
||||
|
@ -209,12 +169,12 @@ export function StoriesPane({
|
|||
key={story.storyView.timestamp}
|
||||
onGoToConversation={conversationId => {
|
||||
showConversation({ conversationId });
|
||||
toggleStoriesView();
|
||||
}}
|
||||
onHideStory={toggleHideStories}
|
||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
story={story.storyView}
|
||||
theme={theme}
|
||||
viewUserStories={viewUserStories}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -69,7 +69,6 @@ export type PropsType = {
|
|||
setMyStoriesToAllSignalConnections: () => unknown;
|
||||
storyViewReceiptsEnabled: boolean;
|
||||
toggleSignalConnectionsModal: () => unknown;
|
||||
toggleStoriesView: () => void;
|
||||
setStoriesDisabled: (value: boolean) => void;
|
||||
getConversationByUuid: (uuid: UUIDStringType) => ConversationType | undefined;
|
||||
};
|
||||
|
@ -256,7 +255,6 @@ export function StoriesSettingsModal({
|
|||
setMyStoriesToAllSignalConnections,
|
||||
storyViewReceiptsEnabled,
|
||||
toggleSignalConnectionsModal,
|
||||
toggleStoriesView,
|
||||
setStoriesDisabled,
|
||||
getConversationByUuid,
|
||||
}: PropsType): JSX.Element {
|
||||
|
@ -463,7 +461,6 @@ export function StoriesSettingsModal({
|
|||
variant={ButtonVariant.SecondaryDestructive}
|
||||
onClick={async () => {
|
||||
setStoriesDisabled(true);
|
||||
toggleStoriesView();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -4,8 +4,8 @@
|
|||
import type { Meta, Story } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { PropsType } from './Stories';
|
||||
import { Stories } from './Stories';
|
||||
import type { PropsType } from './StoriesTab';
|
||||
import { StoriesTab } from './StoriesTab';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
|
@ -18,8 +18,8 @@ import * as durations from '../util/durations';
|
|||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
export default {
|
||||
title: 'Components/Stories',
|
||||
component: Stories,
|
||||
title: 'Components/StoriesTab',
|
||||
component: StoriesTab,
|
||||
argTypes: {
|
||||
deleteStoryForEveryone: { action: true },
|
||||
getPreferredBadge: { action: true },
|
||||
|
@ -63,7 +63,7 @@ export default {
|
|||
} as Meta;
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
const Template: Story<PropsType> = args => <Stories {...args} />;
|
||||
const Template: Story<PropsType> = args => <StoriesTab {...args} />;
|
||||
|
||||
export const Blank = Template.bind({});
|
||||
Blank.args = {};
|
|
@ -2,7 +2,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import type {
|
||||
ConversationType,
|
||||
ShowConversationType,
|
||||
|
@ -12,7 +11,7 @@ import type {
|
|||
MyStoryType,
|
||||
StoryViewType,
|
||||
} from '../types/Stories';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import type { ShowToastAction } from '../state/ducks/toast';
|
||||
import type {
|
||||
|
@ -22,9 +21,9 @@ import type {
|
|||
} from '../state/ducks/stories';
|
||||
import { MyStories } from './MyStories';
|
||||
import { StoriesPane } from './StoriesPane';
|
||||
import { Theme, themeClassName } from '../util/theme';
|
||||
import { getWidthFromPreferredWidth } from '../util/leftPaneWidth';
|
||||
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||
import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
|
||||
import { StoriesAddStoryButton } from './StoriesAddStoryButton';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
|
||||
export type PropsType = {
|
||||
addStoryData: AddStoryData;
|
||||
|
@ -38,90 +37,132 @@ export type PropsType = {
|
|||
maxAttachmentSizeInKb: number;
|
||||
me: ConversationType;
|
||||
myStories: Array<MyStoryType>;
|
||||
navTabsCollapsed: boolean;
|
||||
onForwardStory: (storyId: string) => unknown;
|
||||
onSaveStory: (story: StoryViewType) => unknown;
|
||||
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||
onMediaPlaybackStart: () => void;
|
||||
preferredLeftPaneWidth: number;
|
||||
preferredWidthFromStorage: number;
|
||||
queueStoryDownload: (storyId: string) => unknown;
|
||||
renderStoryCreator: () => JSX.Element;
|
||||
retryMessageSend: (messageId: string) => unknown;
|
||||
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
|
||||
setAddStoryData: (data: AddStoryData) => unknown;
|
||||
showConversation: ShowConversationType;
|
||||
showStoriesSettings: () => unknown;
|
||||
showToast: ShowToastAction;
|
||||
stories: Array<ConversationStoryType>;
|
||||
theme: ThemeType;
|
||||
toggleHideStories: (conversationId: string) => unknown;
|
||||
toggleStoriesView: () => unknown;
|
||||
viewStory: ViewStoryActionCreatorType;
|
||||
viewUserStories: ViewUserStoriesActionCreatorType;
|
||||
};
|
||||
|
||||
export const STORIES_COLOR_THEME = Theme.Dark;
|
||||
|
||||
export function Stories({
|
||||
export function StoriesTab({
|
||||
addStoryData,
|
||||
deleteStoryForEveryone,
|
||||
getPreferredBadge,
|
||||
hasViewReceiptSetting,
|
||||
hiddenStories,
|
||||
i18n,
|
||||
isStoriesSettingsVisible,
|
||||
isViewingStory,
|
||||
maxAttachmentSizeInKb,
|
||||
me,
|
||||
myStories,
|
||||
navTabsCollapsed,
|
||||
onForwardStory,
|
||||
onSaveStory,
|
||||
onToggleNavTabsCollapse,
|
||||
onMediaPlaybackStart,
|
||||
preferredWidthFromStorage,
|
||||
preferredLeftPaneWidth,
|
||||
queueStoryDownload,
|
||||
renderStoryCreator,
|
||||
retryMessageSend,
|
||||
savePreferredLeftPaneWidth,
|
||||
setAddStoryData,
|
||||
showConversation,
|
||||
showStoriesSettings,
|
||||
showToast,
|
||||
stories,
|
||||
theme,
|
||||
toggleHideStories,
|
||||
toggleStoriesView,
|
||||
viewStory,
|
||||
viewUserStories,
|
||||
}: PropsType): JSX.Element {
|
||||
const width = getWidthFromPreferredWidth(preferredWidthFromStorage, {
|
||||
requiresFullWidth: true,
|
||||
});
|
||||
|
||||
const [isMyStories, setIsMyStories] = useState(false);
|
||||
|
||||
// only handle ESC if not showing a child that handles their own ESC
|
||||
useEscapeHandling(
|
||||
(isMyStories && myStories.length) ||
|
||||
isViewingStory ||
|
||||
isStoriesSettingsVisible ||
|
||||
addStoryData
|
||||
? undefined
|
||||
: toggleStoriesView
|
||||
);
|
||||
function onAddStory(file?: File) {
|
||||
if (file) {
|
||||
setAddStoryData({ type: 'Media', file });
|
||||
} else {
|
||||
setAddStoryData({ type: 'Text' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('Stories', themeClassName(STORIES_COLOR_THEME))}>
|
||||
<div className="Stories">
|
||||
{addStoryData && renderStoryCreator()}
|
||||
<div className="Stories__pane" style={{ width }}>
|
||||
{isMyStories && myStories.length ? (
|
||||
<MyStories
|
||||
hasViewReceiptSetting={hasViewReceiptSetting}
|
||||
i18n={i18n}
|
||||
myStories={myStories}
|
||||
onBack={() => setIsMyStories(false)}
|
||||
onDelete={deleteStoryForEveryone}
|
||||
onForward={onForwardStory}
|
||||
onSave={onSaveStory}
|
||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
retryMessageSend={retryMessageSend}
|
||||
viewStory={viewStory}
|
||||
/>
|
||||
) : (
|
||||
{isMyStories && myStories.length ? (
|
||||
<MyStories
|
||||
hasViewReceiptSetting={hasViewReceiptSetting}
|
||||
i18n={i18n}
|
||||
myStories={myStories}
|
||||
onBack={() => setIsMyStories(false)}
|
||||
onDelete={deleteStoryForEveryone}
|
||||
onForward={onForwardStory}
|
||||
onSave={onSaveStory}
|
||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||
queueStoryDownload={queueStoryDownload}
|
||||
retryMessageSend={retryMessageSend}
|
||||
viewStory={viewStory}
|
||||
/>
|
||||
) : (
|
||||
<NavSidebar
|
||||
title="Stories"
|
||||
i18n={i18n}
|
||||
navTabsCollapsed={navTabsCollapsed}
|
||||
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
||||
preferredLeftPaneWidth={preferredLeftPaneWidth}
|
||||
requiresFullWidth
|
||||
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||
actions={
|
||||
<>
|
||||
<StoriesAddStoryButton
|
||||
i18n={i18n}
|
||||
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
||||
moduleClassName="Stories__pane__add-story"
|
||||
onAddStory={onAddStory}
|
||||
showToast={showToast}
|
||||
/>
|
||||
<ContextMenu
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
label: i18n('icu:StoriesSettings__context-menu'),
|
||||
onClick: showStoriesSettings,
|
||||
},
|
||||
]}
|
||||
moduleClassName="Stories__pane__settings"
|
||||
popperOptions={{
|
||||
placement: 'bottom',
|
||||
strategy: 'absolute',
|
||||
}}
|
||||
portalToRoot
|
||||
>
|
||||
{({ openMenu, onKeyDown }) => {
|
||||
return (
|
||||
<NavSidebarActionButton
|
||||
onClick={openMenu}
|
||||
onKeyDown={onKeyDown}
|
||||
icon={<span className="StoriesTab__MoreActionsIcon" />}
|
||||
label={i18n('icu:StoriesTab__MoreActionsLabel')}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ContextMenu>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StoriesPane
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
hiddenStories={hiddenStories}
|
||||
|
@ -129,11 +170,7 @@ export function Stories({
|
|||
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
||||
me={me}
|
||||
myStories={myStories}
|
||||
onAddStory={file =>
|
||||
file
|
||||
? setAddStoryData({ type: 'Media', file })
|
||||
: setAddStoryData({ type: 'Text' })
|
||||
}
|
||||
onAddStory={onAddStory}
|
||||
onMyStoriesClicked={() => {
|
||||
if (myStories.length) {
|
||||
setIsMyStories(true);
|
||||
|
@ -147,12 +184,12 @@ export function Stories({
|
|||
showConversation={showConversation}
|
||||
showToast={showToast}
|
||||
stories={stories}
|
||||
theme={theme}
|
||||
toggleHideStories={toggleHideStories}
|
||||
toggleStoriesView={toggleStoriesView}
|
||||
viewUserStories={viewUserStories}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</NavSidebar>
|
||||
)}
|
||||
<div className="Stories__placeholder">
|
||||
<div className="Stories__placeholder__stories" />
|
||||
{i18n('icu:Stories__placeholder--text')}
|
|
@ -4,6 +4,7 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { get, has } from 'lodash';
|
||||
|
||||
import { createPortal } from 'react-dom';
|
||||
import type {
|
||||
AttachmentType,
|
||||
InMemoryAttachmentDraftType,
|
||||
|
@ -26,6 +27,22 @@ import { TextStoryCreator } from './TextStoryCreator';
|
|||
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
|
||||
import type { DraftBodyRanges } from '../types/BodyRange';
|
||||
|
||||
function usePortalElement(testid: string): HTMLDivElement | null {
|
||||
const [element, setElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const div = document.createElement('div');
|
||||
div.dataset.testid = testid;
|
||||
document.body.appendChild(div);
|
||||
setElement(div);
|
||||
return () => {
|
||||
document.body.removeChild(div);
|
||||
};
|
||||
}, [testid]);
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
export type PropsType = {
|
||||
debouncedMaybeGrabLinkPreview: (
|
||||
message: string,
|
||||
|
@ -119,7 +136,9 @@ export function StoryCreator({
|
|||
skinTone,
|
||||
toggleGroupsForStorySend,
|
||||
toggleSignalConnectionsModal,
|
||||
}: PropsType): JSX.Element {
|
||||
}: PropsType): JSX.Element | null {
|
||||
const portalElement = usePortalElement('StoryCreatorPortal');
|
||||
|
||||
const [draftAttachment, setDraftAttachment] = useState<
|
||||
AttachmentType | undefined
|
||||
>();
|
||||
|
@ -173,97 +192,100 @@ export function StoryCreator({
|
|||
}
|
||||
}, [draftAttachment, sendStoryModalOpenStateChanged]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{draftAttachment && isReadyToSend && (
|
||||
<SendStoryModal
|
||||
draftAttachment={draftAttachment}
|
||||
candidateConversations={candidateConversations}
|
||||
distributionLists={distributionLists}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
groupConversations={groupConversations}
|
||||
groupStories={groupStories}
|
||||
hasFirstStoryPostExperience={hasFirstStoryPostExperience}
|
||||
ourConversationId={ourConversationId}
|
||||
i18n={i18n}
|
||||
me={me}
|
||||
onClose={() => setDraftAttachment(undefined)}
|
||||
onDeleteList={onDeleteList}
|
||||
onDistributionListCreated={onDistributionListCreated}
|
||||
onHideMyStoriesFrom={onHideMyStoriesFrom}
|
||||
onRemoveMembers={onRemoveMembers}
|
||||
onRepliesNReactionsChanged={onRepliesNReactionsChanged}
|
||||
onSelectedStoryList={onSelectedStoryList}
|
||||
onSend={(listIds, groupIds) => {
|
||||
onSend(listIds, groupIds, draftAttachment, bodyRanges);
|
||||
setDraftAttachment(undefined);
|
||||
}}
|
||||
onViewersUpdated={onViewersUpdated}
|
||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||
setMyStoriesToAllSignalConnections={
|
||||
setMyStoriesToAllSignalConnections
|
||||
}
|
||||
signalConnections={signalConnections}
|
||||
toggleGroupsForStorySend={toggleGroupsForStorySend}
|
||||
mostRecentActiveStoryTimestampByGroupOrDistributionList={
|
||||
mostRecentActiveStoryTimestampByGroupOrDistributionList
|
||||
}
|
||||
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
|
||||
/>
|
||||
)}
|
||||
{draftAttachment && !isReadyToSend && attachmentUrl && (
|
||||
<MediaEditor
|
||||
doneButtonLabel={i18n('icu:next2')}
|
||||
i18n={i18n}
|
||||
imageSrc={attachmentUrl}
|
||||
installedPacks={installedPacks}
|
||||
isSending={isSending}
|
||||
onClose={onClose}
|
||||
supportsCaption
|
||||
renderCompositionTextArea={renderCompositionTextArea}
|
||||
imageToBlurHash={imageToBlurHash}
|
||||
onDone={({
|
||||
contentType,
|
||||
data,
|
||||
blurHash,
|
||||
caption,
|
||||
captionBodyRanges,
|
||||
}) => {
|
||||
setDraftAttachment({
|
||||
...draftAttachment,
|
||||
contentType,
|
||||
data,
|
||||
size: data.byteLength,
|
||||
blurHash,
|
||||
caption,
|
||||
});
|
||||
setBodyRanges(captionBodyRanges);
|
||||
setIsReadyToSend(true);
|
||||
}}
|
||||
recentStickers={recentStickers}
|
||||
/>
|
||||
)}
|
||||
{!file && (
|
||||
<TextStoryCreator
|
||||
debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview}
|
||||
i18n={i18n}
|
||||
isSending={isSending}
|
||||
linkPreview={linkPreview}
|
||||
onClose={onClose}
|
||||
onDone={textAttachment => {
|
||||
setDraftAttachment({
|
||||
contentType: TEXT_ATTACHMENT,
|
||||
textAttachment,
|
||||
size: textAttachment.text?.length || 0,
|
||||
});
|
||||
setIsReadyToSend(true);
|
||||
}}
|
||||
onUseEmoji={onUseEmoji}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
recentEmojis={recentEmojis}
|
||||
skinTone={skinTone}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return portalElement != null
|
||||
? createPortal(
|
||||
<>
|
||||
{draftAttachment && isReadyToSend && (
|
||||
<SendStoryModal
|
||||
draftAttachment={draftAttachment}
|
||||
candidateConversations={candidateConversations}
|
||||
distributionLists={distributionLists}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
groupConversations={groupConversations}
|
||||
groupStories={groupStories}
|
||||
hasFirstStoryPostExperience={hasFirstStoryPostExperience}
|
||||
ourConversationId={ourConversationId}
|
||||
i18n={i18n}
|
||||
me={me}
|
||||
onClose={() => setDraftAttachment(undefined)}
|
||||
onDeleteList={onDeleteList}
|
||||
onDistributionListCreated={onDistributionListCreated}
|
||||
onHideMyStoriesFrom={onHideMyStoriesFrom}
|
||||
onRemoveMembers={onRemoveMembers}
|
||||
onRepliesNReactionsChanged={onRepliesNReactionsChanged}
|
||||
onSelectedStoryList={onSelectedStoryList}
|
||||
onSend={(listIds, groupIds) => {
|
||||
onSend(listIds, groupIds, draftAttachment, bodyRanges);
|
||||
setDraftAttachment(undefined);
|
||||
}}
|
||||
onViewersUpdated={onViewersUpdated}
|
||||
onMediaPlaybackStart={onMediaPlaybackStart}
|
||||
setMyStoriesToAllSignalConnections={
|
||||
setMyStoriesToAllSignalConnections
|
||||
}
|
||||
signalConnections={signalConnections}
|
||||
toggleGroupsForStorySend={toggleGroupsForStorySend}
|
||||
mostRecentActiveStoryTimestampByGroupOrDistributionList={
|
||||
mostRecentActiveStoryTimestampByGroupOrDistributionList
|
||||
}
|
||||
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
|
||||
/>
|
||||
)}
|
||||
{draftAttachment && !isReadyToSend && attachmentUrl && (
|
||||
<MediaEditor
|
||||
doneButtonLabel={i18n('icu:next2')}
|
||||
i18n={i18n}
|
||||
imageSrc={attachmentUrl}
|
||||
installedPacks={installedPacks}
|
||||
isSending={isSending}
|
||||
onClose={onClose}
|
||||
supportsCaption
|
||||
renderCompositionTextArea={renderCompositionTextArea}
|
||||
imageToBlurHash={imageToBlurHash}
|
||||
onDone={({
|
||||
contentType,
|
||||
data,
|
||||
blurHash,
|
||||
caption,
|
||||
captionBodyRanges,
|
||||
}) => {
|
||||
setDraftAttachment({
|
||||
...draftAttachment,
|
||||
contentType,
|
||||
data,
|
||||
size: data.byteLength,
|
||||
blurHash,
|
||||
caption,
|
||||
});
|
||||
setBodyRanges(captionBodyRanges);
|
||||
setIsReadyToSend(true);
|
||||
}}
|
||||
recentStickers={recentStickers}
|
||||
/>
|
||||
)}
|
||||
{!file && (
|
||||
<TextStoryCreator
|
||||
debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview}
|
||||
i18n={i18n}
|
||||
isSending={isSending}
|
||||
linkPreview={linkPreview}
|
||||
onClose={onClose}
|
||||
onDone={textAttachment => {
|
||||
setDraftAttachment({
|
||||
contentType: TEXT_ATTACHMENT,
|
||||
textAttachment,
|
||||
size: textAttachment.text?.length || 0,
|
||||
});
|
||||
setIsReadyToSend(true);
|
||||
}}
|
||||
onUseEmoji={onUseEmoji}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
recentEmojis={recentEmojis}
|
||||
skinTone={skinTone}
|
||||
/>
|
||||
)}
|
||||
</>,
|
||||
portalElement
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import React, { useState } from 'react';
|
|||
import classNames from 'classnames';
|
||||
import type { ConversationStoryType, StoryViewType } from '../types/Stories';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import type { ViewUserStoriesActionCreatorType } from '../state/ducks/stories';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
|
@ -16,7 +16,6 @@ import { StoryViewTargetType, HasStories } from '../types/Stories';
|
|||
|
||||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||
import { StoryImage } from './StoryImage';
|
||||
import { ThemeType } from '../types/Util';
|
||||
import { getAvatarColor } from '../types/Colors';
|
||||
|
||||
export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
|
||||
|
@ -30,6 +29,7 @@ export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
|
|||
queueStoryDownload: (storyId: string) => unknown;
|
||||
onMediaPlaybackStart: () => void;
|
||||
story: StoryViewType;
|
||||
theme: ThemeType;
|
||||
viewUserStories: ViewUserStoriesActionCreatorType;
|
||||
};
|
||||
|
||||
|
@ -45,6 +45,7 @@ function StoryListItemAvatar({
|
|||
profileName,
|
||||
sharedGroupNames,
|
||||
title,
|
||||
theme,
|
||||
}: Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
|
@ -59,6 +60,7 @@ function StoryListItemAvatar({
|
|||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
i18n: LocalizerType;
|
||||
isMe?: boolean;
|
||||
theme: ThemeType;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Avatar
|
||||
|
@ -73,7 +75,7 @@ function StoryListItemAvatar({
|
|||
sharedGroupNames={sharedGroupNames}
|
||||
size={AvatarSize.FORTY_EIGHT}
|
||||
storyRing={avatarStoryRing}
|
||||
theme={ThemeType.dark}
|
||||
theme={theme}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
|
@ -92,6 +94,7 @@ export function StoryListItem({
|
|||
onMediaPlaybackStart,
|
||||
queueStoryDownload,
|
||||
story,
|
||||
theme,
|
||||
viewUserStories,
|
||||
}: PropsType): JSX.Element {
|
||||
const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false);
|
||||
|
@ -167,6 +170,7 @@ export function StoryListItem({
|
|||
avatarStoryRing={avatarStoryRing}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
theme={theme}
|
||||
{...(group || sender)}
|
||||
/>
|
||||
<div className="StoryListItem__info">
|
||||
|
|
|
@ -26,6 +26,8 @@ function getToast(toastType: ToastType): AnyToast {
|
|||
return { toastType: ToastType.Blocked };
|
||||
case ToastType.BlockedGroup:
|
||||
return { toastType: ToastType.BlockedGroup };
|
||||
case ToastType.CallHistoryCleared:
|
||||
return { toastType: ToastType.CallHistoryCleared };
|
||||
case ToastType.CannotEditMessage:
|
||||
return { toastType: ToastType.CannotEditMessage };
|
||||
case ToastType.CannotForwardEmptyMessage:
|
||||
|
|
|
@ -68,6 +68,14 @@ export function ToastManager({
|
|||
return <Toast onClose={hideToast}>{i18n('icu:unblockGroupToSend')}</Toast>;
|
||||
}
|
||||
|
||||
if (toastType === ToastType.CallHistoryCleared) {
|
||||
return (
|
||||
<Toast onClose={hideToast}>
|
||||
{i18n('icu:CallsTab__ToastCallHistoryCleared')}
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
if (toastType === ToastType.CannotEditMessage) {
|
||||
return (
|
||||
<Toast onClose={hideToast}>
|
||||
|
|
|
@ -11,7 +11,6 @@ export enum WidthBreakpoint {
|
|||
Narrow = 'narrow',
|
||||
}
|
||||
|
||||
export const getConversationListWidthBreakpoint = (
|
||||
width: number
|
||||
): WidthBreakpoint =>
|
||||
width >= 150 ? WidthBreakpoint.Wide : WidthBreakpoint.Narrow;
|
||||
export function getNavSidebarWidthBreakpoint(width: number): WidthBreakpoint {
|
||||
return width >= 150 ? WidthBreakpoint.Wide : WidthBreakpoint.Narrow;
|
||||
}
|
||||
|
|
|
@ -7,9 +7,21 @@ import { action } from '@storybook/addon-actions';
|
|||
import { setupI18n } from '../../util/setupI18n';
|
||||
import enMessages from '../../../_locales/en/messages.json';
|
||||
import { CallMode } from '../../types/Calling';
|
||||
import { CallingNotification } from './CallingNotification';
|
||||
import type { CallingNotificationType } from '../../util/callingNotification';
|
||||
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
||||
import { CallingNotification, type PropsType } from './CallingNotification';
|
||||
import {
|
||||
getDefaultConversation,
|
||||
getDefaultGroup,
|
||||
} from '../../test-both/helpers/getDefaultConversation';
|
||||
import type { CallStatus } from '../../types/CallDisposition';
|
||||
import {
|
||||
CallType,
|
||||
CallDirection,
|
||||
GroupCallStatus,
|
||||
DirectCallStatus,
|
||||
} from '../../types/CallDisposition';
|
||||
import { UUID } from '../../types/UUID';
|
||||
import type { ConversationType } from '../../state/ducks/conversations';
|
||||
import { CallExternalState } from '../../util/callingNotification';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -17,15 +29,59 @@ export default {
|
|||
title: 'Components/Conversation/CallingNotification',
|
||||
};
|
||||
|
||||
const getCommonProps = () => ({
|
||||
conversationId: 'fake-conversation-id',
|
||||
i18n,
|
||||
isNextItemCallingNotification: false,
|
||||
messageId: 'fake-message-id',
|
||||
now: Date.now(),
|
||||
returnToActiveCall: action('returnToActiveCall'),
|
||||
startCallingLobby: action('startCallingLobby'),
|
||||
});
|
||||
const getCommonProps = (options: {
|
||||
mode: CallMode;
|
||||
type?: CallType;
|
||||
direction?: CallDirection;
|
||||
status?: CallStatus;
|
||||
callCreator?: ConversationType | null;
|
||||
callExternalState?: CallExternalState;
|
||||
}): PropsType => {
|
||||
const {
|
||||
mode,
|
||||
type = mode === CallMode.Group ? CallType.Group : CallType.Audio,
|
||||
direction = CallDirection.Outgoing,
|
||||
status = mode === CallMode.Group
|
||||
? GroupCallStatus.GenericGroupCall
|
||||
: DirectCallStatus.Pending,
|
||||
callCreator = getDefaultConversation({
|
||||
uuid: UUID.generate().toString(),
|
||||
isMe: direction === CallDirection.Outgoing,
|
||||
}),
|
||||
callExternalState = CallExternalState.Active,
|
||||
} = options;
|
||||
|
||||
const conversation =
|
||||
mode === CallMode.Group ? getDefaultGroup() : getDefaultConversation();
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
i18n,
|
||||
isNextItemCallingNotification: false,
|
||||
returnToActiveCall: action('returnToActiveCall'),
|
||||
startCallingLobby: action('startCallingLobby'),
|
||||
callHistory: {
|
||||
callId: '123',
|
||||
peerId: conversation.id,
|
||||
ringerId: callCreator?.uuid ?? null,
|
||||
mode,
|
||||
type,
|
||||
direction,
|
||||
timestamp: Date.now(),
|
||||
status,
|
||||
},
|
||||
callCreator,
|
||||
callExternalState,
|
||||
maxDevices: mode === CallMode.Group ? 15 : 0,
|
||||
deviceCount:
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
mode === CallMode.Group
|
||||
? callExternalState === CallExternalState.Full
|
||||
? 15
|
||||
: 13
|
||||
: Infinity,
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
<CallingNotification
|
||||
|
@ -42,13 +98,12 @@ const getCommonProps = () => ({
|
|||
export function AcceptedIncomingAudioCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={1618894800000}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined={false}
|
||||
wasIncoming
|
||||
wasVideoCall={false}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Audio,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Accepted,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -56,13 +111,13 @@ export function AcceptedIncomingAudioCall(): JSX.Element {
|
|||
export function AcceptedIncomingVideoCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={1618894800000}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined={false}
|
||||
wasIncoming
|
||||
wasVideoCall
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Accepted,
|
||||
callExternalState: CallExternalState.Ended,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -70,13 +125,12 @@ export function AcceptedIncomingVideoCall(): JSX.Element {
|
|||
export function DeclinedIncomingAudioCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={undefined}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined
|
||||
wasIncoming
|
||||
wasVideoCall={false}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Audio,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Declined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -84,13 +138,12 @@ export function DeclinedIncomingAudioCall(): JSX.Element {
|
|||
export function DeclinedIncomingVideoCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={undefined}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined
|
||||
wasIncoming
|
||||
wasVideoCall
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Declined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -98,13 +151,12 @@ export function DeclinedIncomingVideoCall(): JSX.Element {
|
|||
export function AcceptedOutgoingAudioCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={1618894800000}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined={false}
|
||||
wasIncoming={false}
|
||||
wasVideoCall={false}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Audio,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: DirectCallStatus.Accepted,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -112,13 +164,12 @@ export function AcceptedOutgoingAudioCall(): JSX.Element {
|
|||
export function AcceptedOutgoingVideoCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={1618894800000}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined={false}
|
||||
wasIncoming={false}
|
||||
wasVideoCall
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: DirectCallStatus.Accepted,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -126,13 +177,12 @@ export function AcceptedOutgoingVideoCall(): JSX.Element {
|
|||
export function DeclinedOutgoingAudioCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={undefined}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined
|
||||
wasIncoming={false}
|
||||
wasVideoCall={false}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Audio,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: DirectCallStatus.Declined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -140,42 +190,37 @@ export function DeclinedOutgoingAudioCall(): JSX.Element {
|
|||
export function DeclinedOutgoingVideoCall(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
acceptedTime={undefined}
|
||||
callMode={CallMode.Direct}
|
||||
endedTime={1618894800000}
|
||||
wasDeclined
|
||||
wasIncoming={false}
|
||||
wasVideoCall
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: DirectCallStatus.Declined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwoIncomingDirectCallsBackToBack(): JSX.Element {
|
||||
const call1: CallingNotificationType = {
|
||||
callMode: CallMode.Direct,
|
||||
wasIncoming: true,
|
||||
wasVideoCall: true,
|
||||
wasDeclined: false,
|
||||
acceptedTime: 1618894800000,
|
||||
endedTime: 1618894800000,
|
||||
};
|
||||
const call2: CallingNotificationType = {
|
||||
callMode: CallMode.Direct,
|
||||
wasIncoming: true,
|
||||
wasVideoCall: false,
|
||||
wasDeclined: false,
|
||||
endedTime: 1618894800000,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
{...call1}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Declined,
|
||||
callExternalState: CallExternalState.Ended,
|
||||
})}
|
||||
isNextItemCallingNotification
|
||||
/>
|
||||
<CallingNotification {...getCommonProps()} {...call2} />
|
||||
<CallingNotification
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Audio,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Declined,
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -185,30 +230,26 @@ TwoIncomingDirectCallsBackToBack.story = {
|
|||
};
|
||||
|
||||
export function TwoOutgoingDirectCallsBackToBack(): JSX.Element {
|
||||
const call1: CallingNotificationType = {
|
||||
callMode: CallMode.Direct,
|
||||
wasIncoming: false,
|
||||
wasVideoCall: true,
|
||||
wasDeclined: false,
|
||||
acceptedTime: 1618894800000,
|
||||
endedTime: 1618894800000,
|
||||
};
|
||||
const call2: CallingNotificationType = {
|
||||
callMode: CallMode.Direct,
|
||||
wasIncoming: false,
|
||||
wasVideoCall: false,
|
||||
wasDeclined: false,
|
||||
endedTime: 1618894800000,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
{...call1}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: DirectCallStatus.Declined,
|
||||
callExternalState: CallExternalState.Ended,
|
||||
})}
|
||||
isNextItemCallingNotification
|
||||
/>
|
||||
<CallingNotification {...getCommonProps()} {...call2} />
|
||||
<CallingNotification
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Audio,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: DirectCallStatus.Declined,
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -220,13 +261,13 @@ TwoOutgoingDirectCallsBackToBack.story = {
|
|||
export function GroupCallByUnknown(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
callMode={CallMode.Group}
|
||||
creator={undefined}
|
||||
deviceCount={15}
|
||||
ended={false}
|
||||
maxDevices={16}
|
||||
startedTime={1618894800000}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Group,
|
||||
type: CallType.Group,
|
||||
direction: CallDirection.Incoming,
|
||||
status: GroupCallStatus.Accepted,
|
||||
callCreator: null,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -234,13 +275,12 @@ export function GroupCallByUnknown(): JSX.Element {
|
|||
export function GroupCallByYou(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
callMode={CallMode.Group}
|
||||
creator={getDefaultConversation({ isMe: true, title: 'Alicia' })}
|
||||
deviceCount={15}
|
||||
ended={false}
|
||||
maxDevices={16}
|
||||
startedTime={1618894800000}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Group,
|
||||
type: CallType.Group,
|
||||
direction: CallDirection.Outgoing,
|
||||
status: GroupCallStatus.Accepted,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -248,31 +288,28 @@ export function GroupCallByYou(): JSX.Element {
|
|||
export function GroupCallBySomeone(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
callMode={CallMode.Group}
|
||||
creator={getDefaultConversation({ isMe: false, title: 'Alicia' })}
|
||||
deviceCount={15}
|
||||
ended={false}
|
||||
maxDevices={16}
|
||||
startedTime={1618894800000}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Group,
|
||||
type: CallType.Group,
|
||||
direction: CallDirection.Incoming,
|
||||
status: GroupCallStatus.GenericGroupCall,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupCallStartedBySomeoneWithALongName(): JSX.Element {
|
||||
const longName = '😤🪐🦆'.repeat(50);
|
||||
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
callMode={CallMode.Group}
|
||||
creator={getDefaultConversation({
|
||||
title: longName,
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Group,
|
||||
type: CallType.Group,
|
||||
direction: CallDirection.Incoming,
|
||||
status: GroupCallStatus.GenericGroupCall,
|
||||
callCreator: getDefaultConversation({
|
||||
name: '😤🪐🦆'.repeat(50),
|
||||
}),
|
||||
})}
|
||||
deviceCount={15}
|
||||
ended={false}
|
||||
maxDevices={16}
|
||||
startedTime={1618894800000}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -284,12 +321,13 @@ GroupCallStartedBySomeoneWithALongName.story = {
|
|||
export function GroupCallActiveCallFull(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
callMode={CallMode.Group}
|
||||
deviceCount={16}
|
||||
ended={false}
|
||||
maxDevices={16}
|
||||
startedTime={1618894800000}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Group,
|
||||
type: CallType.Group,
|
||||
direction: CallDirection.Incoming,
|
||||
status: GroupCallStatus.GenericGroupCall,
|
||||
callExternalState: CallExternalState.Full,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -301,12 +339,13 @@ GroupCallActiveCallFull.story = {
|
|||
export function GroupCallEnded(): JSX.Element {
|
||||
return (
|
||||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
callMode={CallMode.Group}
|
||||
deviceCount={0}
|
||||
ended
|
||||
maxDevices={16}
|
||||
startedTime={1618894800000}
|
||||
{...getCommonProps({
|
||||
mode: CallMode.Group,
|
||||
type: CallType.Group,
|
||||
direction: CallDirection.Incoming,
|
||||
status: GroupCallStatus.GenericGroupCall,
|
||||
callExternalState: CallExternalState.Ended,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -12,13 +12,19 @@ import type { LocalizerType } from '../../types/Util';
|
|||
import { CallMode } from '../../types/Calling';
|
||||
import type { CallingNotificationType } from '../../util/callingNotification';
|
||||
import {
|
||||
CallExternalState,
|
||||
getCallingIcon,
|
||||
getCallingNotificationText,
|
||||
} from '../../util/callingNotification';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { Tooltip, TooltipPlacement } from '../Tooltip';
|
||||
import * as log from '../../logging/log';
|
||||
import { assertDev } from '../../util/assert';
|
||||
import {
|
||||
CallDirection,
|
||||
CallType,
|
||||
DirectCallStatus,
|
||||
GroupCallStatus,
|
||||
} from '../../types/CallDisposition';
|
||||
|
||||
export type PropsActionsType = {
|
||||
returnToActiveCall: () => void;
|
||||
|
@ -34,35 +40,15 @@ type PropsHousekeeping = {
|
|||
isNextItemCallingNotification: boolean;
|
||||
};
|
||||
|
||||
type PropsType = CallingNotificationType & PropsActionsType & PropsHousekeeping;
|
||||
export type PropsType = CallingNotificationType &
|
||||
PropsActionsType &
|
||||
PropsHousekeeping;
|
||||
|
||||
export const CallingNotification: React.FC<PropsType> = React.memo(
|
||||
function CallingNotificationInner(props) {
|
||||
const { i18n } = props;
|
||||
|
||||
let timestamp: number;
|
||||
let wasMissed = false;
|
||||
switch (props.callMode) {
|
||||
case CallMode.Direct: {
|
||||
const resolvedTime = props.acceptedTime ?? props.endedTime;
|
||||
assertDev(resolvedTime, 'Direct call must have accepted or ended time');
|
||||
timestamp = resolvedTime;
|
||||
wasMissed =
|
||||
props.wasIncoming && !props.acceptedTime && !props.wasDeclined;
|
||||
break;
|
||||
}
|
||||
case CallMode.Group:
|
||||
timestamp = props.startedTime;
|
||||
break;
|
||||
default:
|
||||
log.error(
|
||||
`CallingNotification missing case: ${missingCaseError(props)}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const icon = getCallingIcon(props);
|
||||
|
||||
const { type, direction, status, timestamp } = props.callHistory;
|
||||
const icon = getCallingIcon(type, direction, status);
|
||||
return (
|
||||
<SystemMessage
|
||||
button={renderCallingNotificationButton(props)}
|
||||
|
@ -80,7 +66,12 @@ export const CallingNotification: React.FC<PropsType> = React.memo(
|
|||
</>
|
||||
}
|
||||
icon={icon}
|
||||
kind={wasMissed ? SystemMessageKind.Danger : SystemMessageKind.Normal}
|
||||
kind={
|
||||
status === DirectCallStatus.Missed ||
|
||||
status === GroupCallStatus.Missed
|
||||
? SystemMessageKind.Danger
|
||||
: SystemMessageKind.Normal
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -90,7 +81,6 @@ function renderCallingNotificationButton(
|
|||
props: Readonly<PropsType>
|
||||
): ReactNode {
|
||||
const {
|
||||
activeCallConversationId,
|
||||
conversationId,
|
||||
i18n,
|
||||
isNextItemCallingNotification,
|
||||
|
@ -106,55 +96,65 @@ function renderCallingNotificationButton(
|
|||
let disabledTooltipText: undefined | string;
|
||||
let onClick: () => void;
|
||||
|
||||
switch (props.callMode) {
|
||||
switch (props.callHistory.mode) {
|
||||
case CallMode.Direct: {
|
||||
const { wasIncoming, wasVideoCall } = props;
|
||||
buttonText = wasIncoming
|
||||
? i18n('icu:calling__call-back')
|
||||
: i18n('icu:calling__call-again');
|
||||
if (activeCallConversationId) {
|
||||
const { direction, type } = props.callHistory;
|
||||
buttonText =
|
||||
direction === CallDirection.Incoming
|
||||
? i18n('icu:calling__call-back')
|
||||
: i18n('icu:calling__call-again');
|
||||
if (
|
||||
props.callExternalState === CallExternalState.Joined ||
|
||||
props.callExternalState === CallExternalState.InOtherCall
|
||||
) {
|
||||
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
|
||||
onClick = noop;
|
||||
} else {
|
||||
onClick = () => {
|
||||
startCallingLobby({ conversationId, isVideoCall: wasVideoCall });
|
||||
startCallingLobby({
|
||||
conversationId,
|
||||
isVideoCall: type === CallType.Video,
|
||||
});
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CallMode.Group: {
|
||||
if (props.ended) {
|
||||
if (props.callExternalState === CallExternalState.Ended) {
|
||||
return null;
|
||||
}
|
||||
const { deviceCount, maxDevices } = props;
|
||||
if (activeCallConversationId) {
|
||||
if (activeCallConversationId === conversationId) {
|
||||
buttonText = i18n('icu:calling__return');
|
||||
onClick = returnToActiveCall;
|
||||
} else {
|
||||
buttonText = i18n('icu:calling__join');
|
||||
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
|
||||
onClick = noop;
|
||||
}
|
||||
} else if (deviceCount >= maxDevices) {
|
||||
if (props.callExternalState === CallExternalState.Joined) {
|
||||
buttonText = i18n('icu:calling__return');
|
||||
onClick = returnToActiveCall;
|
||||
} else if (props.callExternalState === CallExternalState.InOtherCall) {
|
||||
buttonText = i18n('icu:calling__join');
|
||||
disabledTooltipText = i18n('icu:calling__in-another-call-tooltip');
|
||||
onClick = noop;
|
||||
} else if (props.callExternalState === CallExternalState.Full) {
|
||||
buttonText = i18n('icu:calling__call-is-full');
|
||||
disabledTooltipText = i18n(
|
||||
'icu:calling__call-notification__button__call-full-tooltip',
|
||||
{
|
||||
max: deviceCount,
|
||||
max: props.maxDevices,
|
||||
}
|
||||
);
|
||||
onClick = noop;
|
||||
} else {
|
||||
} else if (props.callExternalState === CallExternalState.Active) {
|
||||
buttonText = i18n('icu:calling__join');
|
||||
onClick = () => {
|
||||
startCallingLobby({ conversationId, isVideoCall: true });
|
||||
};
|
||||
} else {
|
||||
throw missingCaseError(props.callExternalState);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CallMode.None: {
|
||||
log.error('renderCallingNotificationButton: Call mode cant be none');
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
log.error(missingCaseError(props));
|
||||
log.error(missingCaseError(props.callHistory.mode));
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,13 @@ import { getDefaultConversation } from '../../../test-both/helpers/getDefaultCon
|
|||
import { makeFakeLookupConversationWithoutUuid } from '../../../test-both/helpers/fakeLookupConversationWithoutUuid';
|
||||
import { ThemeType } from '../../../types/Util';
|
||||
import { DurationInSeconds } from '../../../util/durations';
|
||||
import { NavTab } from '../../../state/ducks/nav';
|
||||
import { CallMode } from '../../../types/Calling';
|
||||
import {
|
||||
CallDirection,
|
||||
CallType,
|
||||
DirectCallStatus,
|
||||
} from '../../../types/CallDisposition';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -79,6 +86,7 @@ const createProps = (
|
|||
metadata: {},
|
||||
member: getDefaultConversation(),
|
||||
})),
|
||||
selectedNavTab: NavTab.Chats,
|
||||
setDisappearingMessages: action('setDisappearingMessages'),
|
||||
showContactModal: action('showContactModal'),
|
||||
pushPanelForConversation: action('pushPanelForConversation'),
|
||||
|
@ -214,3 +222,32 @@ export const _11 = (): JSX.Element => (
|
|||
_11.story = {
|
||||
name: '1:1',
|
||||
};
|
||||
|
||||
function mins(n: number) {
|
||||
return DurationInSeconds.toMillis(DurationInSeconds.fromMinutes(n));
|
||||
}
|
||||
|
||||
export function WithCallHistoryGroup(): JSX.Element {
|
||||
const props = createProps();
|
||||
|
||||
return (
|
||||
<ConversationDetails
|
||||
{...props}
|
||||
callHistoryGroup={{
|
||||
peerId: props.conversation?.uuid ?? '',
|
||||
mode: CallMode.Direct,
|
||||
type: CallType.Video,
|
||||
direction: CallDirection.Incoming,
|
||||
status: DirectCallStatus.Accepted,
|
||||
timestamp: Date.now(),
|
||||
children: [
|
||||
{ callId: '123', timestamp: Date.now() },
|
||||
{ callId: '122', timestamp: Date.now() - mins(30) },
|
||||
{ callId: '121', timestamp: Date.now() - mins(45) },
|
||||
{ callId: '121', timestamp: Date.now() - mins(60) },
|
||||
],
|
||||
}}
|
||||
selectedNavTab={NavTab.Calls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Button, ButtonIconType, ButtonVariant } from '../../Button';
|
||||
import { Tooltip } from '../../Tooltip';
|
||||
import type {
|
||||
|
@ -52,6 +53,37 @@ import type {
|
|||
import { isConversationMuted } from '../../../util/isConversationMuted';
|
||||
import { ConversationDetailsGroups } from './ConversationDetailsGroups';
|
||||
import { PanelType } from '../../../types/Panels';
|
||||
import type { CallStatus } from '../../../types/CallDisposition';
|
||||
import {
|
||||
CallType,
|
||||
type CallHistoryGroup,
|
||||
CallDirection,
|
||||
DirectCallStatus,
|
||||
GroupCallStatus,
|
||||
} from '../../../types/CallDisposition';
|
||||
import { formatDate, formatTime } from '../../../util/timestamp';
|
||||
import { NavTab } from '../../../state/ducks/nav';
|
||||
|
||||
function describeCallHistory(
|
||||
i18n: LocalizerType,
|
||||
type: CallType,
|
||||
direction: CallDirection,
|
||||
status: CallStatus
|
||||
): string {
|
||||
if (status === DirectCallStatus.Missed || status === GroupCallStatus.Missed) {
|
||||
if (direction === CallDirection.Incoming) {
|
||||
return i18n('icu:CallHistory__Description--Missed', { type });
|
||||
}
|
||||
return i18n('icu:CallHistory__Description--Unanswered', { type });
|
||||
}
|
||||
if (
|
||||
status === DirectCallStatus.Declined ||
|
||||
status === GroupCallStatus.Declined
|
||||
) {
|
||||
return i18n('icu:CallHistory__Description--Declined', { type });
|
||||
}
|
||||
return i18n('icu:CallHistory__Description--Default', { type, direction });
|
||||
}
|
||||
|
||||
enum ModalState {
|
||||
NothingOpen,
|
||||
|
@ -65,6 +97,7 @@ enum ModalState {
|
|||
export type StateProps = {
|
||||
areWeASubscriber: boolean;
|
||||
badges?: ReadonlyArray<BadgeType>;
|
||||
callHistoryGroup?: CallHistoryGroup | null;
|
||||
canEditGroupInfo: boolean;
|
||||
canAddNewMembers: boolean;
|
||||
conversation?: ConversationType;
|
||||
|
@ -80,6 +113,7 @@ export type StateProps = {
|
|||
memberships: ReadonlyArray<GroupV2Membership>;
|
||||
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
|
||||
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
|
||||
selectedNavTab: NavTab;
|
||||
theme: ThemeType;
|
||||
userAvatarData: ReadonlyArray<AvatarDataType>;
|
||||
renderChooseGroupMembersModal: (
|
||||
|
@ -101,6 +135,7 @@ type ActionProps = {
|
|||
}
|
||||
) => unknown;
|
||||
blockConversation: (id: string) => void;
|
||||
|
||||
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
|
||||
getProfilesForConversation: (id: string) => unknown;
|
||||
leaveGroup: (conversationId: string) => void;
|
||||
|
@ -153,6 +188,7 @@ export function ConversationDetails({
|
|||
areWeASubscriber,
|
||||
badges,
|
||||
blockConversation,
|
||||
callHistoryGroup,
|
||||
canEditGroupInfo,
|
||||
canAddNewMembers,
|
||||
conversation,
|
||||
|
@ -180,6 +216,7 @@ export function ConversationDetails({
|
|||
replaceAvatar,
|
||||
saveAvatarToDisk,
|
||||
searchInConversation,
|
||||
selectedNavTab,
|
||||
setDisappearingMessages,
|
||||
setMuteExpiration,
|
||||
showContactModal,
|
||||
|
@ -364,6 +401,20 @@ export function ConversationDetails({
|
|||
/>
|
||||
|
||||
<div className="ConversationDetails__header-buttons">
|
||||
{selectedNavTab === NavTab.Calls && (
|
||||
<Button
|
||||
icon={ButtonIconType.message}
|
||||
onClick={() => {
|
||||
showConversation({
|
||||
conversationId: conversation?.id,
|
||||
switchToAssociatedView: true,
|
||||
});
|
||||
}}
|
||||
variant={ButtonVariant.Details}
|
||||
>
|
||||
{i18n('icu:ConversationDetails__HeaderButton--Message')}
|
||||
</Button>
|
||||
)}
|
||||
{!conversation.isMe && (
|
||||
<>
|
||||
<ConversationDetailsCallButton
|
||||
|
@ -397,17 +448,60 @@ export function ConversationDetails({
|
|||
>
|
||||
{isMuted ? i18n('icu:unmute') : i18n('icu:mute')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={ButtonIconType.search}
|
||||
onClick={() => {
|
||||
searchInConversation(conversation.id);
|
||||
}}
|
||||
variant={ButtonVariant.Details}
|
||||
>
|
||||
{i18n('icu:search')}
|
||||
</Button>
|
||||
{selectedNavTab !== NavTab.Calls && (
|
||||
<Button
|
||||
icon={ButtonIconType.search}
|
||||
onClick={() => {
|
||||
searchInConversation(conversation.id);
|
||||
}}
|
||||
variant={ButtonVariant.Details}
|
||||
>
|
||||
{i18n('icu:search')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{callHistoryGroup && (
|
||||
<PanelSection>
|
||||
<h2 className="ConversationDetails__CallHistoryGroup__header">
|
||||
{formatDate(i18n, callHistoryGroup.timestamp)}
|
||||
</h2>
|
||||
<ol className="ConversationDetails__CallHistoryGroup__List">
|
||||
{callHistoryGroup.children.map(child => {
|
||||
return (
|
||||
<li
|
||||
key={child.callId}
|
||||
className="ConversationDetails__CallHistoryGroup__Item"
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
'ConversationDetails__CallHistoryGroup__ItemIcon',
|
||||
{
|
||||
'ConversationDetails__CallHistoryGroup__ItemIcon--Audio':
|
||||
callHistoryGroup.type === CallType.Audio,
|
||||
'ConversationDetails__CallHistoryGroup__ItemIcon--Video':
|
||||
callHistoryGroup.type !== CallType.Audio,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<span className="ConversationDetails__CallHistoryGroup__ItemLabel">
|
||||
{describeCallHistory(
|
||||
i18n,
|
||||
callHistoryGroup.type,
|
||||
callHistoryGroup.direction,
|
||||
callHistoryGroup.status
|
||||
)}
|
||||
</span>
|
||||
<span className="ConversationDetails__CallHistoryGroup__ItemTimestamp">
|
||||
{formatTime(i18n, child.timestamp, Date.now(), false)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection>
|
||||
{!isGroup || canEditGroupInfo ? (
|
||||
<PanelRow
|
||||
|
@ -440,28 +534,30 @@ export function ConversationDetails({
|
|||
}
|
||||
/>
|
||||
) : null}
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('icu:showChatColorEditor')}
|
||||
icon={IconType.color}
|
||||
/>
|
||||
}
|
||||
label={i18n('icu:showChatColorEditor')}
|
||||
onClick={() => {
|
||||
pushPanelForConversation({
|
||||
type: PanelType.ChatColorEditor,
|
||||
});
|
||||
}}
|
||||
right={
|
||||
<div
|
||||
className={`ConversationDetails__chat-color ConversationDetails__chat-color--${conversation.conversationColor}`}
|
||||
style={{
|
||||
...getCustomColorStyle(conversation.customColor),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{selectedNavTab === NavTab.Chats && (
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('icu:showChatColorEditor')}
|
||||
icon={IconType.color}
|
||||
/>
|
||||
}
|
||||
label={i18n('icu:showChatColorEditor')}
|
||||
onClick={() => {
|
||||
pushPanelForConversation({
|
||||
type: PanelType.ChatColorEditor,
|
||||
});
|
||||
}}
|
||||
right={
|
||||
<div
|
||||
className={`ConversationDetails__chat-color ConversationDetails__chat-color--${conversation.conversationColor}`}
|
||||
style={{
|
||||
...getCustomColorStyle(conversation.customColor),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isGroup && (
|
||||
<PanelRow
|
||||
icon={
|
||||
|
|
|
@ -10,7 +10,6 @@ import * as KeyboardLayout from '../services/keyboardLayout';
|
|||
import { getHasPanelOpen } from '../state/selectors/conversations';
|
||||
import { isInFullScreenCall } from '../state/selectors/calling';
|
||||
import { isShowingAnyModal } from '../state/selectors/globalModals';
|
||||
import { shouldShowStoriesView } from '../state/selectors/stories';
|
||||
|
||||
type KeyboardShortcutHandlerType = (ev: KeyboardEvent) => boolean;
|
||||
|
||||
|
@ -36,10 +35,6 @@ function useHasGlobalModal(): boolean {
|
|||
return useSelector<StateType, boolean>(isShowingAnyModal);
|
||||
}
|
||||
|
||||
function useHasStories(): boolean {
|
||||
return useSelector<StateType, boolean>(shouldShowStoriesView);
|
||||
}
|
||||
|
||||
function useHasCalling(): boolean {
|
||||
return useSelector<StateType, boolean>(isInFullScreenCall);
|
||||
}
|
||||
|
@ -47,10 +42,9 @@ function useHasCalling(): boolean {
|
|||
function useHasAnyOverlay(): boolean {
|
||||
const panels = useHasPanels();
|
||||
const globalModal = useHasGlobalModal();
|
||||
const stories = useHasStories();
|
||||
const calling = useHasCalling();
|
||||
|
||||
return panels || globalModal || stories || calling;
|
||||
return panels || globalModal || calling;
|
||||
}
|
||||
|
||||
export function useActiveCallShortcuts(
|
||||
|
|
3
ts/model-types.d.ts
vendored
|
@ -7,7 +7,6 @@ import * as Backbone from 'backbone';
|
|||
|
||||
import type { GroupV2ChangeType } from './groups';
|
||||
import type { DraftBodyRanges, RawBodyRange } from './types/BodyRange';
|
||||
import type { CallHistoryDetailsFromDiskType } from './types/Calling';
|
||||
import type { CustomColorType, ConversationColorType } from './types/Colors';
|
||||
import type { DeviceType } from './textsecure/Types.d';
|
||||
import type { SendMessageChallengeData } from './textsecure/Errors';
|
||||
|
@ -132,7 +131,7 @@ export type EditHistoryType = {
|
|||
export type MessageAttributesType = {
|
||||
bodyAttachment?: AttachmentType;
|
||||
bodyRanges?: ReadonlyArray<RawBodyRange>;
|
||||
callHistoryDetails?: CallHistoryDetailsFromDiskType;
|
||||
callId?: string;
|
||||
canReplyToStory?: boolean;
|
||||
changedId?: string;
|
||||
dataMessage?: Uint8Array | null;
|
||||
|
|
|
@ -30,8 +30,6 @@ import { getAboutText } from '../util/getAboutText';
|
|||
import { getAvatarPath } from '../util/avatarUtils';
|
||||
import { getDraftPreview } from '../util/getDraftPreview';
|
||||
import { hasDraft } from '../util/hasDraft';
|
||||
import type { CallHistoryDetailsType } from '../types/Calling';
|
||||
import { CallMode } from '../types/Calling';
|
||||
import * as Conversation from '../types/Conversation';
|
||||
import type { StickerType, StickerWithHydratedData } from '../types/Stickers';
|
||||
import * as Stickers from '../types/Stickers';
|
||||
|
@ -55,7 +53,7 @@ import type {
|
|||
} from '../types/Colors';
|
||||
import type { MessageModel } from './messages';
|
||||
import { getContact } from '../messages/helpers';
|
||||
import { assertDev, strictAssert } from '../util/assert';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { isConversationMuted } from '../util/isConversationMuted';
|
||||
import { isConversationSMSOnly } from '../util/isConversationSMSOnly';
|
||||
import {
|
||||
|
@ -63,10 +61,8 @@ import {
|
|||
isConversationUnregistered,
|
||||
isConversationUnregisteredAndStale,
|
||||
} from '../util/isConversationUnregistered';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { sniffImageMimeType } from '../util/sniffImageMimeType';
|
||||
import { isValidE164 } from '../util/isValidE164';
|
||||
import { canConversationBeUnarchived } from '../util/canConversationBeUnarchived';
|
||||
import type { MIMEType } from '../types/MIME';
|
||||
import { IMAGE_JPEG, IMAGE_WEBP } from '../types/MIME';
|
||||
import { UUID, UUIDKind } from '../types/UUID';
|
||||
|
@ -160,7 +156,6 @@ import { ReceiptType } from '../types/Receipt';
|
|||
import { getQuoteAttachment } from '../util/makeQuote';
|
||||
import { deriveProfileKeyVersion } from '../util/zkgroup';
|
||||
import { incrementMessageCounter } from '../util/incrementMessageCounter';
|
||||
import { validateTransition } from '../util/callHistoryDetails';
|
||||
import OS from '../util/os/osMain';
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
|
@ -256,8 +251,6 @@ export class ConversationModel extends window.Backbone
|
|||
|
||||
throttledUpdateSharedGroups?: () => Promise<void>;
|
||||
|
||||
private cachedLatestGroupCallEraId?: string;
|
||||
|
||||
private cachedIdenticon?: CachedIdenticon;
|
||||
|
||||
public isFetchingUUID?: boolean;
|
||||
|
@ -3069,181 +3062,6 @@ export class ConversationModel extends window.Backbone
|
|||
}
|
||||
}
|
||||
|
||||
async addCallHistory(
|
||||
callHistoryDetails: CallHistoryDetailsType,
|
||||
receivedAtCounter: number | undefined
|
||||
): Promise<void> {
|
||||
let timestamp: number;
|
||||
let unread: boolean;
|
||||
let detailsToSave: CallHistoryDetailsType;
|
||||
|
||||
switch (callHistoryDetails.callMode) {
|
||||
case CallMode.Direct: {
|
||||
const {
|
||||
callId,
|
||||
wasIncoming,
|
||||
wasVideoCall,
|
||||
wasDeclined,
|
||||
acceptedTime,
|
||||
endedTime,
|
||||
} = callHistoryDetails;
|
||||
log.info(
|
||||
`addCallHistory: Conversation ID: ${this.id}, ` +
|
||||
`Call ID: ${callId}, ` +
|
||||
'Direct, ' +
|
||||
`Incoming: ${wasIncoming}, ` +
|
||||
`Video: ${wasVideoCall}, ` +
|
||||
`Declined: ${wasDeclined}, ` +
|
||||
`Accepted: ${acceptedTime}, ` +
|
||||
`Ended: ${endedTime}`
|
||||
);
|
||||
|
||||
const resolvedTime = acceptedTime ?? endedTime;
|
||||
assertDev(resolvedTime, 'Direct call must have accepted or ended time');
|
||||
timestamp = resolvedTime;
|
||||
unread =
|
||||
callHistoryDetails.wasIncoming &&
|
||||
!callHistoryDetails.wasDeclined &&
|
||||
!callHistoryDetails.acceptedTime;
|
||||
detailsToSave = {
|
||||
...callHistoryDetails,
|
||||
callMode: CallMode.Direct,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case CallMode.Group:
|
||||
timestamp = callHistoryDetails.startedTime;
|
||||
unread = false;
|
||||
detailsToSave = callHistoryDetails;
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(callHistoryDetails);
|
||||
}
|
||||
// This is sometimes called inside of another conversation queue job so if
|
||||
// awaited it would block on this forever.
|
||||
drop(
|
||||
this.queueJob('addCallHistory', async () => {
|
||||
// Force save if we're adding a new call history message for a direct call
|
||||
let forceSave = true;
|
||||
let previousMessage: MessageAttributesType | null = null;
|
||||
if (callHistoryDetails.callMode === CallMode.Direct) {
|
||||
const messageId =
|
||||
await window.Signal.Data.getCallHistoryMessageByCallId(
|
||||
this.id,
|
||||
callHistoryDetails.callId
|
||||
);
|
||||
if (messageId != null) {
|
||||
log.info(
|
||||
`addCallHistory: Found existing call history message (Call ID: ${callHistoryDetails.callId}, Message ID: ${messageId})`
|
||||
);
|
||||
// We don't want to force save if we're updating an existing message
|
||||
forceSave = false;
|
||||
previousMessage =
|
||||
(await window.Signal.Data.getMessageById(messageId)) ?? null;
|
||||
} else {
|
||||
log.info(
|
||||
`addCallHistory: No existing call history message found (Call ID: ${callHistoryDetails.callId})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!validateTransition(
|
||||
previousMessage?.callHistoryDetails,
|
||||
callHistoryDetails,
|
||||
log
|
||||
)
|
||||
) {
|
||||
log.info("addCallHistory: Transition isn't valid, not saving");
|
||||
return;
|
||||
}
|
||||
|
||||
const message: MessageAttributesType = {
|
||||
id: previousMessage?.id ?? generateGuid(),
|
||||
conversationId: this.id,
|
||||
type: 'call-history',
|
||||
sent_at: timestamp,
|
||||
timestamp,
|
||||
received_at: receivedAtCounter || incrementMessageCounter(),
|
||||
received_at_ms: timestamp,
|
||||
readStatus: unread ? ReadStatus.Unread : ReadStatus.Read,
|
||||
seenStatus: unread ? SeenStatus.Unseen : SeenStatus.NotApplicable,
|
||||
callHistoryDetails,
|
||||
};
|
||||
|
||||
const id = await window.Signal.Data.saveMessage(message, {
|
||||
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||
forceSave,
|
||||
});
|
||||
|
||||
log.info(`addCallHistory: Saved call history message (ID: ${id})`);
|
||||
|
||||
const model = window.MessageController.register(
|
||||
id,
|
||||
new window.Whisper.Message({
|
||||
...message,
|
||||
id,
|
||||
})
|
||||
);
|
||||
|
||||
if (
|
||||
detailsToSave.callMode === CallMode.Direct &&
|
||||
!detailsToSave.wasIncoming
|
||||
) {
|
||||
this.incrementSentMessageCount();
|
||||
} else {
|
||||
this.incrementMessageCount();
|
||||
}
|
||||
|
||||
this.trigger('newmessage', model);
|
||||
|
||||
void this.updateUnread();
|
||||
this.set('active_at', timestamp);
|
||||
|
||||
if (canConversationBeUnarchived(this.attributes)) {
|
||||
this.setArchived(false);
|
||||
} else {
|
||||
window.Signal.Data.updateConversation(this.attributes);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a group call history message if one is needed. It won't add history messages for
|
||||
* the same group call era ID.
|
||||
*
|
||||
* Resolves with `true` if a new message was added, and `false` otherwise.
|
||||
*/
|
||||
async updateCallHistoryForGroupCall(
|
||||
eraId: string,
|
||||
creatorUuid: string
|
||||
): Promise<boolean> {
|
||||
// We want to update the cache quickly in case this function is called multiple times.
|
||||
const oldCachedEraId = this.cachedLatestGroupCallEraId;
|
||||
this.cachedLatestGroupCallEraId = eraId;
|
||||
|
||||
const alreadyHasMessage =
|
||||
(oldCachedEraId && oldCachedEraId === eraId) ||
|
||||
(await window.Signal.Data.hasGroupCallHistoryMessage(this.id, eraId));
|
||||
|
||||
if (alreadyHasMessage) {
|
||||
void this.updateLastMessage();
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.addCallHistory(
|
||||
{
|
||||
callMode: CallMode.Group,
|
||||
creatorUuid,
|
||||
eraId,
|
||||
startedTime: Date.now(),
|
||||
},
|
||||
undefined
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
async addProfileChange(
|
||||
profileChange: unknown,
|
||||
conversationId?: string
|
||||
|
|
|
@ -147,7 +147,6 @@ import { getStoryDataFromMessageAttributes } from '../services/storyLoader';
|
|||
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
|
||||
import { getMessageById } from '../messages/getMessageById';
|
||||
import { shouldDownloadStory } from '../util/shouldDownloadStory';
|
||||
import { shouldShowStoriesView } from '../state/selectors/stories';
|
||||
import type { EmbeddedContactWithHydratedAvatar } from '../types/EmbeddedContact';
|
||||
import { SeenStatus } from '../MessageSeenStatus';
|
||||
import { isNewReactionReplacingPrevious } from '../reactions/util';
|
||||
|
@ -172,6 +171,8 @@ import {
|
|||
saveNewMessageBatcher,
|
||||
} from '../util/messageBatcher';
|
||||
import { normalizeUuid } from '../util/normalizeUuid';
|
||||
import { getCallHistorySelector } from '../state/selectors/callHistory';
|
||||
import { getConversationSelector } from '../state/selectors/conversations';
|
||||
|
||||
/* eslint-disable more/no-then */
|
||||
|
||||
|
@ -715,9 +716,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
if (isCallHistory(attributes)) {
|
||||
const state = window.reduxStore.getState();
|
||||
const callingNotification = getPropsForCallHistory(attributes, {
|
||||
conversationSelector: findAndFormatContact,
|
||||
callSelector: getCallSelector(state),
|
||||
activeCall: getActiveCall(state),
|
||||
callHistorySelector: getCallHistorySelector(state),
|
||||
conversationSelector: getConversationSelector(state),
|
||||
});
|
||||
if (callingNotification) {
|
||||
return {
|
||||
|
@ -2837,11 +2839,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
|
||||
let queueStoryForDownload = false;
|
||||
if (isStory(message.attributes)) {
|
||||
const isShowingStories = shouldShowStoriesView(reduxState);
|
||||
|
||||
queueStoryForDownload =
|
||||
isShowingStories ||
|
||||
(await shouldDownloadStory(conversation.attributes));
|
||||
queueStoryForDownload = await shouldDownloadStory(
|
||||
conversation.attributes
|
||||
);
|
||||
}
|
||||
|
||||
const shouldHoldOffDownload =
|
||||
|
|
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,
|
||||
BusyMessage,
|
||||
Call,
|
||||
CallEndedReason,
|
||||
CallingMessage,
|
||||
CallLogLevel,
|
||||
CallState,
|
||||
|
@ -39,8 +38,8 @@ import {
|
|||
RingUpdate,
|
||||
} from '@signalapp/ringrtc';
|
||||
import { uniqBy, noop } from 'lodash';
|
||||
import Long from 'long';
|
||||
|
||||
import Long from 'long';
|
||||
import type {
|
||||
ActionsType as CallingReduxActionsType,
|
||||
GroupCallParticipantInfoType,
|
||||
|
@ -51,6 +50,7 @@ import { getConversationCallMode } from '../state/ducks/conversations';
|
|||
import { isMe } from '../util/whatTypeOfConversation';
|
||||
import type {
|
||||
AvailableIODevicesType,
|
||||
CallEndedReason,
|
||||
MediaDeviceSettings,
|
||||
PresentableSource,
|
||||
PresentedSource,
|
||||
|
@ -74,11 +74,10 @@ import { UUID, UUIDKind } from '../types/UUID';
|
|||
import * as Errors from '../types/errors';
|
||||
import type { ConversationModel } from '../models/conversations';
|
||||
import * as Bytes from '../Bytes';
|
||||
import { uuidToBytes, bytesToUuid } from '../Crypto';
|
||||
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
|
||||
import { drop } from '../util/drop';
|
||||
import { dropNull } from '../util/dropNull';
|
||||
import { getOwn } from '../util/getOwn';
|
||||
import { isNormalNumber } from '../util/isNormalNumber';
|
||||
import * as durations from '../util/durations';
|
||||
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
|
||||
import { handleMessageSend } from '../util/handleMessageSend';
|
||||
|
@ -107,6 +106,24 @@ import {
|
|||
import * as log from '../logging/log';
|
||||
import { assertDev, strictAssert } from '../util/assert';
|
||||
import { sendContentMessageToGroup, sendToGroup } from '../util/sendToGroup';
|
||||
import {
|
||||
formatLocalDeviceState,
|
||||
formatPeekInfo,
|
||||
getPeerIdFromConversation,
|
||||
getLocalCallEventFromCallEndedReason,
|
||||
getCallDetailsFromEndedDirectCall,
|
||||
getCallEventDetails,
|
||||
getLocalCallEventFromGroupCall,
|
||||
getLocalCallEventFromDirectCall,
|
||||
getCallDetailsFromDirectCall,
|
||||
getCallDetailsFromGroupCallMeta,
|
||||
updateCallHistoryFromLocalEvent,
|
||||
getGroupCallMeta,
|
||||
getCallIdFromRing,
|
||||
getLocalCallEventFromRingUpdate,
|
||||
} from '../util/callDisposition';
|
||||
import { isNormalNumber } from '../util/isNormalNumber';
|
||||
import { LocalCallEvent } from '../types/CallDisposition';
|
||||
|
||||
const {
|
||||
processGroupCallRingCancellation,
|
||||
|
@ -689,7 +706,51 @@ export class CallingClass {
|
|||
{
|
||||
onLocalDeviceStateChanged: groupCall => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const { eraId } = groupCall.getPeekInfo() || {};
|
||||
const peekInfo = groupCall.getPeekInfo() ?? null;
|
||||
|
||||
log.info(
|
||||
'GroupCall#onLocalDeviceStateChanged',
|
||||
formatLocalDeviceState(localDeviceState),
|
||||
peekInfo != null ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
|
||||
const groupCallMeta = getGroupCallMeta(peekInfo);
|
||||
|
||||
if (groupCallMeta != null) {
|
||||
try {
|
||||
const localCallEvent = getLocalCallEventFromGroupCall(
|
||||
groupCall,
|
||||
groupCallMeta
|
||||
);
|
||||
|
||||
if (localCallEvent != null && peekInfo != null) {
|
||||
const conversation =
|
||||
window.ConversationController.get(conversationId);
|
||||
strictAssert(
|
||||
conversation != null,
|
||||
'GroupCall#onLocalDeviceStateChanged: Missing conversation'
|
||||
);
|
||||
const peerId = getPeerIdFromConversation(
|
||||
conversation.attributes
|
||||
);
|
||||
|
||||
const callDetails = getCallDetailsFromGroupCallMeta(
|
||||
peerId,
|
||||
groupCallMeta
|
||||
);
|
||||
const callEvent = getCallEventDetails(
|
||||
callDetails,
|
||||
localCallEvent
|
||||
);
|
||||
drop(updateCallHistoryFromLocalEvent(callEvent, null));
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'GroupCall#onLocalDeviceStateChanged: Error updating state',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
localDeviceState.connectionState === ConnectionState.NotConnected
|
||||
|
@ -703,10 +764,13 @@ export class CallingClass {
|
|||
|
||||
if (
|
||||
updateMessageState === GroupCallUpdateMessageState.SentJoin &&
|
||||
eraId
|
||||
peekInfo?.eraId != null
|
||||
) {
|
||||
updateMessageState = GroupCallUpdateMessageState.SentLeft;
|
||||
void this.sendGroupCallUpdateMessage(conversationId, eraId);
|
||||
void this.sendGroupCallUpdateMessage(
|
||||
conversationId,
|
||||
peekInfo?.eraId
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.callsByConversation[conversationId] = groupCall;
|
||||
|
@ -721,16 +785,28 @@ export class CallingClass {
|
|||
if (
|
||||
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
||||
localDeviceState.joinState === JoinState.Joined &&
|
||||
eraId
|
||||
peekInfo?.eraId != null
|
||||
) {
|
||||
updateMessageState = GroupCallUpdateMessageState.SentJoin;
|
||||
void this.sendGroupCallUpdateMessage(conversationId, eraId);
|
||||
void this.sendGroupCallUpdateMessage(
|
||||
conversationId,
|
||||
peekInfo?.eraId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.syncGroupCallToRedux(conversationId, groupCall);
|
||||
},
|
||||
onRemoteDeviceStatesChanged: groupCall => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const peekInfo = groupCall.getPeekInfo();
|
||||
|
||||
log.info(
|
||||
'GroupCall#onRemoteDeviceStatesChanged',
|
||||
formatLocalDeviceState(localDeviceState),
|
||||
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
|
||||
this.syncGroupCallToRedux(conversationId, groupCall);
|
||||
},
|
||||
onAudioLevels: groupCall => {
|
||||
|
@ -748,7 +824,16 @@ export class CallingClass {
|
|||
},
|
||||
onPeekChanged: groupCall => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const { eraId } = groupCall.getPeekInfo() || {};
|
||||
const peekInfo = groupCall.getPeekInfo() ?? null;
|
||||
|
||||
log.info(
|
||||
'GroupCall#onPeekChanged',
|
||||
formatLocalDeviceState(localDeviceState),
|
||||
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
|
||||
const { eraId } = peekInfo ?? {};
|
||||
|
||||
if (
|
||||
updateMessageState === GroupCallUpdateMessageState.SentNothing &&
|
||||
localDeviceState.connectionState !== ConnectionState.NotConnected &&
|
||||
|
@ -759,10 +844,7 @@ export class CallingClass {
|
|||
void this.sendGroupCallUpdateMessage(conversationId, eraId);
|
||||
}
|
||||
|
||||
void this.updateCallHistoryForGroupCall(
|
||||
conversationId,
|
||||
groupCall.getPeekInfo()
|
||||
);
|
||||
void this.updateCallHistoryForGroupCall(conversationId, peekInfo);
|
||||
this.syncGroupCallToRedux(conversationId, groupCall);
|
||||
},
|
||||
async requestMembershipProof(groupCall) {
|
||||
|
@ -789,7 +871,17 @@ export class CallingClass {
|
|||
requestGroupMembers: groupCall => {
|
||||
groupCall.setGroupMembers(this.getGroupCallMembers(conversationId));
|
||||
},
|
||||
onEnded: noop,
|
||||
onEnded: (groupCall, endedReason) => {
|
||||
const localDeviceState = groupCall.getLocalDeviceState();
|
||||
const peekInfo = groupCall.getPeekInfo();
|
||||
|
||||
log.info(
|
||||
'GroupCall#onEnded',
|
||||
endedReason,
|
||||
formatLocalDeviceState(localDeviceState),
|
||||
peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)'
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -1567,12 +1659,23 @@ export class CallingClass {
|
|||
|
||||
await this.handleOutgoingSignaling(remoteUserId, message);
|
||||
|
||||
const ProtoOfferType = Proto.CallingMessage.Offer.Type;
|
||||
await this.addCallHistoryForFailedIncomingCall(
|
||||
conversation,
|
||||
callingMessage.offer.type === ProtoOfferType.OFFER_VIDEO_CALL,
|
||||
envelope.timestamp,
|
||||
callId.toString()
|
||||
const wasVideoCall =
|
||||
callingMessage.offer.type ===
|
||||
Proto.CallingMessage.Offer.Type.OFFER_VIDEO_CALL;
|
||||
|
||||
const peerId = getPeerIdFromConversation(conversation.attributes);
|
||||
const callDetails = getCallDetailsFromEndedDirectCall(
|
||||
callId.toString(),
|
||||
peerId,
|
||||
peerId, // Incoming call
|
||||
wasVideoCall,
|
||||
envelope.timestamp
|
||||
);
|
||||
const localCallEvent = LocalCallEvent.Missed;
|
||||
const callEvent = getCallEventDetails(callDetails, localCallEvent);
|
||||
await updateCallHistoryFromLocalEvent(
|
||||
callEvent,
|
||||
envelope.receivedAtCounter
|
||||
);
|
||||
|
||||
return;
|
||||
|
@ -1801,6 +1904,20 @@ export class CallingClass {
|
|||
ringId,
|
||||
});
|
||||
}
|
||||
|
||||
const localEvent = getLocalCallEventFromRingUpdate(update);
|
||||
if (localEvent != null) {
|
||||
const callId = getCallIdFromRing(ringId);
|
||||
const callDetails = getCallDetailsFromGroupCallMeta(groupId, {
|
||||
callId,
|
||||
ringerId: ringerUuid,
|
||||
});
|
||||
const callEvent = getCallEventDetails(
|
||||
callDetails,
|
||||
shouldRing ? LocalCallEvent.Ringing : LocalCallEvent.Started
|
||||
);
|
||||
await updateCallHistoryFromLocalEvent(callEvent, null);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOutgoingSignaling(
|
||||
|
@ -1865,8 +1982,6 @@ export class CallingClass {
|
|||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const callId = Long.fromValue(call.callId).toString();
|
||||
try {
|
||||
// The peer must be 'trusted' before accepting a call from them.
|
||||
// This is mostly the safety number check, unverified meaning that they were
|
||||
|
@ -1879,12 +1994,13 @@ export class CallingClass {
|
|||
log.info(
|
||||
`Peer is not trusted, ignoring incoming call for conversation: ${conversation.idForLogging()}`
|
||||
);
|
||||
await this.addCallHistoryForFailedIncomingCall(
|
||||
conversation,
|
||||
call.isVideoCall,
|
||||
Date.now(),
|
||||
callId
|
||||
);
|
||||
|
||||
const localCallEvent = LocalCallEvent.Missed;
|
||||
const peerId = getPeerIdFromConversation(conversation.attributes);
|
||||
const callDetails = getCallDetailsFromDirectCall(peerId, call);
|
||||
const callEvent = getCallEventDetails(callDetails, localCallEvent);
|
||||
await updateCallHistoryFromLocalEvent(callEvent, null);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -1898,20 +2014,14 @@ export class CallingClass {
|
|||
return true;
|
||||
} catch (err) {
|
||||
log.error(`Ignoring incoming call: ${Errors.toLogFormat(err)}`);
|
||||
await this.addCallHistoryForFailedIncomingCall(
|
||||
conversation,
|
||||
call.isVideoCall,
|
||||
Date.now(),
|
||||
callId
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAutoEndedIncomingCallRequest(
|
||||
callId: CallId,
|
||||
callIdValue: CallId,
|
||||
remoteUserId: UserId,
|
||||
reason: CallEndedReason,
|
||||
callEndedReason: CallEndedReason,
|
||||
ageInSeconds: number,
|
||||
wasVideoCall: boolean,
|
||||
receivedAtCounter: number | undefined
|
||||
|
@ -1921,22 +2031,28 @@ export class CallingClass {
|
|||
return;
|
||||
}
|
||||
|
||||
const callId = Long.fromValue(callIdValue).toString();
|
||||
const peerId = getPeerIdFromConversation(conversation.attributes);
|
||||
|
||||
// This is extra defensive, just in case RingRTC passes us a bad value. (It probably
|
||||
// won't.)
|
||||
const ageInMilliseconds =
|
||||
isNormalNumber(ageInSeconds) && ageInSeconds >= 0
|
||||
? ageInSeconds * durations.SECOND
|
||||
: 0;
|
||||
const endedTime = Date.now() - ageInMilliseconds;
|
||||
const timestamp = Date.now() - ageInMilliseconds;
|
||||
|
||||
await this.addCallHistoryForAutoEndedIncomingCall(
|
||||
conversation,
|
||||
reason,
|
||||
endedTime,
|
||||
const callDetails = getCallDetailsFromEndedDirectCall(
|
||||
callId,
|
||||
peerId,
|
||||
remoteUserId,
|
||||
wasVideoCall,
|
||||
receivedAtCounter,
|
||||
Long.fromValue(callId).toString()
|
||||
timestamp
|
||||
);
|
||||
const localCallEvent =
|
||||
getLocalCallEventFromCallEndedReason(callEndedReason);
|
||||
const callEvent = getCallEventDetails(callDetails, localCallEvent);
|
||||
await updateCallHistoryFromLocalEvent(callEvent, receivedAtCounter ?? null);
|
||||
}
|
||||
|
||||
private attachToCall(conversation: ConversationModel, call: Call): void {
|
||||
|
@ -1947,44 +2063,26 @@ export class CallingClass {
|
|||
return;
|
||||
}
|
||||
|
||||
let acceptedTime: number | undefined;
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
call.handleStateChanged = async () => {
|
||||
if (call.state === CallState.Accepted) {
|
||||
acceptedTime = acceptedTime || Date.now();
|
||||
await this.addCallHistoryForAcceptedCall(
|
||||
conversation,
|
||||
call,
|
||||
acceptedTime
|
||||
);
|
||||
} else if (call.state === CallState.Ended) {
|
||||
try {
|
||||
await this.addCallHistoryForEndedCall(
|
||||
conversation,
|
||||
call,
|
||||
acceptedTime
|
||||
);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'Failed to add call history for ended call',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
if (call.state === CallState.Ended) {
|
||||
this.stopDeviceReselectionTimer();
|
||||
this.lastMediaDeviceSettings = undefined;
|
||||
delete this.callsByConversation[conversation.id];
|
||||
}
|
||||
|
||||
const localCallEvent = getLocalCallEventFromDirectCall(call);
|
||||
if (localCallEvent != null) {
|
||||
const peerId = getPeerIdFromConversation(conversation.attributes);
|
||||
const callDetails = getCallDetailsFromDirectCall(peerId, call);
|
||||
const callEvent = getCallEventDetails(callDetails, localCallEvent);
|
||||
await updateCallHistoryFromLocalEvent(callEvent, null);
|
||||
}
|
||||
|
||||
reduxInterface.callStateChange({
|
||||
remoteUserId: call.remoteUserId,
|
||||
callId: Long.fromValue(call.callId).toString(),
|
||||
conversationId: conversation.id,
|
||||
acceptedTime,
|
||||
callState: call.state,
|
||||
callEndedReason: call.endedReason,
|
||||
isIncoming: call.isIncoming,
|
||||
isVideoCall: call.isVideoCall,
|
||||
title: conversation.getTitle(),
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -2137,154 +2235,55 @@ export class CallingClass {
|
|||
return true;
|
||||
}
|
||||
|
||||
private async addCallHistoryForAcceptedCall(
|
||||
conversation: ConversationModel,
|
||||
call: Call,
|
||||
acceptedTime: number
|
||||
) {
|
||||
const callId = Long.fromValue(call.callId).toString();
|
||||
try {
|
||||
log.info('addCallHistoryForAcceptedCall: Adding call history');
|
||||
await conversation.addCallHistory(
|
||||
{
|
||||
callId,
|
||||
callMode: CallMode.Direct,
|
||||
wasIncoming: call.isIncoming,
|
||||
wasVideoCall: call.isVideoCall,
|
||||
wasDeclined: false,
|
||||
acceptedTime,
|
||||
endedTime: undefined,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'addCallHistoryForAcceptedCall: Failed to add call history',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async addCallHistoryForEndedCall(
|
||||
conversation: ConversationModel,
|
||||
call: Call,
|
||||
acceptedTimeParam: number | undefined
|
||||
) {
|
||||
let acceptedTime = acceptedTimeParam;
|
||||
|
||||
const { endedReason, isIncoming } = call;
|
||||
const wasAccepted = Boolean(acceptedTime);
|
||||
const isOutgoing = !isIncoming;
|
||||
const wasDeclined =
|
||||
!wasAccepted &&
|
||||
(endedReason === CallEndedReason.Declined ||
|
||||
endedReason === CallEndedReason.DeclinedOnAnotherDevice ||
|
||||
(isIncoming && endedReason === CallEndedReason.LocalHangup) ||
|
||||
(isOutgoing && endedReason === CallEndedReason.RemoteHangup) ||
|
||||
(isOutgoing &&
|
||||
endedReason === CallEndedReason.RemoteHangupNeedPermission));
|
||||
if (call.endedReason === CallEndedReason.AcceptedOnAnotherDevice) {
|
||||
acceptedTime = Date.now();
|
||||
}
|
||||
|
||||
const callId = Long.fromValue(call.callId).toString();
|
||||
|
||||
await conversation.addCallHistory(
|
||||
{
|
||||
callId,
|
||||
callMode: CallMode.Direct,
|
||||
wasIncoming: call.isIncoming,
|
||||
wasVideoCall: call.isVideoCall,
|
||||
wasDeclined,
|
||||
acceptedTime,
|
||||
endedTime: Date.now(),
|
||||
},
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
private async addCallHistoryForFailedIncomingCall(
|
||||
conversation: ConversationModel,
|
||||
wasVideoCall: boolean,
|
||||
timestamp: number,
|
||||
callId: string
|
||||
) {
|
||||
await conversation.addCallHistory(
|
||||
{
|
||||
callMode: CallMode.Direct,
|
||||
wasIncoming: true,
|
||||
wasVideoCall,
|
||||
// Since the user didn't decline, make sure it shows up as a missed call instead
|
||||
wasDeclined: false,
|
||||
acceptedTime: undefined,
|
||||
endedTime: timestamp,
|
||||
callId,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
private async addCallHistoryForAutoEndedIncomingCall(
|
||||
conversation: ConversationModel,
|
||||
reason: CallEndedReason,
|
||||
endedTime: number,
|
||||
wasVideoCall: boolean,
|
||||
receivedAtCounter: number | undefined,
|
||||
callId: string
|
||||
) {
|
||||
let wasDeclined = false;
|
||||
let acceptedTime;
|
||||
|
||||
if (reason === CallEndedReason.AcceptedOnAnotherDevice) {
|
||||
acceptedTime = endedTime;
|
||||
} else if (reason === CallEndedReason.DeclinedOnAnotherDevice) {
|
||||
wasDeclined = true;
|
||||
}
|
||||
// Otherwise it will show up as a missed call.
|
||||
|
||||
await conversation.addCallHistory(
|
||||
{
|
||||
callId,
|
||||
callMode: CallMode.Direct,
|
||||
wasIncoming: true,
|
||||
wasVideoCall,
|
||||
wasDeclined,
|
||||
acceptedTime,
|
||||
endedTime,
|
||||
},
|
||||
receivedAtCounter
|
||||
);
|
||||
}
|
||||
|
||||
public async updateCallHistoryForGroupCall(
|
||||
conversationId: string,
|
||||
peekInfo: undefined | PeekInfo
|
||||
peekInfo: PeekInfo | null
|
||||
): Promise<void> {
|
||||
const groupCallMeta = getGroupCallMeta(peekInfo);
|
||||
// If we don't have the necessary pieces to peek, bail. (It's okay if we don't.)
|
||||
if (!peekInfo || !peekInfo.eraId || !peekInfo.creator) {
|
||||
if (groupCallMeta == null) {
|
||||
return;
|
||||
}
|
||||
const creatorUuid = bytesToUuid(peekInfo.creator);
|
||||
if (!creatorUuid) {
|
||||
log.error('updateCallHistoryForGroupCall(): bad creator UUID');
|
||||
return;
|
||||
}
|
||||
const creatorConversation = window.ConversationController.get(creatorUuid);
|
||||
|
||||
const creatorConversation = window.ConversationController.get(
|
||||
groupCallMeta.ringerId
|
||||
);
|
||||
|
||||
const conversation = window.ConversationController.get(conversationId);
|
||||
if (!conversation) {
|
||||
log.error('updateCallHistoryForGroupCall(): could not find conversation');
|
||||
log.error('maybeNotifyGroupCall(): could not find conversation');
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewCall = await conversation.updateCallHistoryForGroupCall(
|
||||
peekInfo.eraId,
|
||||
creatorUuid
|
||||
);
|
||||
const prevMessageId =
|
||||
await window.Signal.Data.getCallHistoryMessageByCallId({
|
||||
conversationId: conversation.id,
|
||||
callId: groupCallMeta.callId,
|
||||
});
|
||||
|
||||
const isNewCall = prevMessageId == null;
|
||||
|
||||
const groupCall = this.getGroupCall(conversationId);
|
||||
if (groupCall != null) {
|
||||
const localCallEvent = getLocalCallEventFromGroupCall(
|
||||
groupCall,
|
||||
groupCallMeta
|
||||
);
|
||||
if (localCallEvent != null) {
|
||||
const peerId = getPeerIdFromConversation(conversation.attributes);
|
||||
const callDetails = getCallDetailsFromGroupCallMeta(
|
||||
peerId,
|
||||
groupCallMeta
|
||||
);
|
||||
const callEvent = getCallEventDetails(callDetails, localCallEvent);
|
||||
await updateCallHistoryFromLocalEvent(callEvent, null);
|
||||
}
|
||||
}
|
||||
|
||||
const wasStartedByMe = Boolean(
|
||||
creatorConversation && isMe(creatorConversation.attributes)
|
||||
);
|
||||
const isAnybodyElseInGroupCall = Boolean(peekInfo.devices.length);
|
||||
const isAnybodyElseInGroupCall = Boolean(peekInfo?.devices.length);
|
||||
|
||||
if (
|
||||
isNewCall &&
|
||||
|
|
|
@ -4,11 +4,8 @@
|
|||
import { isEqual, isNumber } from 'lodash';
|
||||
import Long from 'long';
|
||||
|
||||
import {
|
||||
uuidToBytes,
|
||||
bytesToUuid,
|
||||
deriveMasterKeyFromGroupV1,
|
||||
} from '../Crypto';
|
||||
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes';
|
||||
import { deriveMasterKeyFromGroupV1 } from '../Crypto';
|
||||
import * as Bytes from '../Bytes';
|
||||
import {
|
||||
deriveGroupFields,
|
||||
|
|
|
@ -11,8 +11,7 @@ import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
|
|||
import { strictAssert } from '../util/assert';
|
||||
import { sleep } from '../util/sleep';
|
||||
import { getMinNickname, getMaxNickname } from '../util/Username';
|
||||
import { bytesToUuid } from '../Crypto';
|
||||
import { uuidToBytes } from '../util/uuidToBytes';
|
||||
import { bytesToUuid, uuidToBytes } from '../util/uuidToBytes';
|
||||
import type { UsernameReservationType } from '../types/Username';
|
||||
import { ReserveUsernameError, ConfirmUsernameResult } from '../types/Username';
|
||||
import * as Errors from '../types/errors';
|
||||
|
|
|
@ -21,6 +21,12 @@ import type { ReadStatus } from '../messages/MessageReadStatus';
|
|||
import type { RawBodyRange } from '../types/BodyRange';
|
||||
import type { GetMessagesBetweenOptions } from './Server';
|
||||
import type { MessageTimestamps } from '../state/ducks/conversations';
|
||||
import type {
|
||||
CallHistoryDetails,
|
||||
CallHistoryFilter,
|
||||
CallHistoryGroup,
|
||||
CallHistoryPagination,
|
||||
} from '../types/CallDisposition';
|
||||
|
||||
export type AdjacentMessagesByConversationOptionsType = Readonly<{
|
||||
conversationId: string;
|
||||
|
@ -628,10 +634,22 @@ export type DataInterface = {
|
|||
getLastConversationMessage(options: {
|
||||
conversationId: string;
|
||||
}): Promise<MessageType | undefined>;
|
||||
getCallHistoryMessageByCallId(
|
||||
conversationId: string,
|
||||
callId: string
|
||||
): Promise<string | void>;
|
||||
getAllCallHistory: () => Promise<ReadonlyArray<CallHistoryDetails>>;
|
||||
clearCallHistory: (beforeTimestamp: number) => Promise<Array<string>>;
|
||||
getCallHistoryMessageByCallId(options: {
|
||||
conversationId: string;
|
||||
callId: string;
|
||||
}): Promise<MessageType | undefined>;
|
||||
getCallHistory(
|
||||
callId: string,
|
||||
peerId: string
|
||||
): Promise<CallHistoryDetails | undefined>;
|
||||
getCallHistoryGroupsCount(filter: CallHistoryFilter): Promise<number>;
|
||||
getCallHistoryGroups(
|
||||
filter: CallHistoryFilter,
|
||||
pagination: CallHistoryPagination
|
||||
): Promise<Array<CallHistoryGroup>>;
|
||||
saveCallHistory(callHistory: CallHistoryDetails): Promise<void>;
|
||||
hasGroupCallHistoryMessage: (
|
||||
conversationId: string,
|
||||
eraId: string
|
||||
|
|
426
ts/sql/Server.ts
|
@ -10,6 +10,7 @@ import { randomBytes } from 'crypto';
|
|||
import type { Database, Statement } from '@signalapp/better-sqlite3';
|
||||
import SQL from '@signalapp/better-sqlite3';
|
||||
import pProps from 'p-props';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Dictionary } from 'lodash';
|
||||
import {
|
||||
|
@ -60,6 +61,7 @@ import type {
|
|||
QueryFragment,
|
||||
} from './util';
|
||||
import {
|
||||
sqlConstant,
|
||||
sqlJoin,
|
||||
sqlFragment,
|
||||
sql,
|
||||
|
@ -142,6 +144,18 @@ import {
|
|||
SNIPPET_RIGHT_PLACEHOLDER,
|
||||
SNIPPET_TRUNCATION_PLACEHOLDER,
|
||||
} from '../util/search';
|
||||
import type {
|
||||
CallHistoryDetails,
|
||||
CallHistoryFilter,
|
||||
CallHistoryGroup,
|
||||
CallHistoryPagination,
|
||||
} from '../types/CallDisposition';
|
||||
import {
|
||||
DirectCallStatus,
|
||||
callHistoryGroupSchema,
|
||||
CallHistoryFilterStatus,
|
||||
callHistoryDetailsSchema,
|
||||
} from '../types/CallDisposition';
|
||||
|
||||
type ConversationRow = Readonly<{
|
||||
json: string;
|
||||
|
@ -288,7 +302,13 @@ const dataInterface: ServerInterface = {
|
|||
getConversationRangeCenteredOnMessage,
|
||||
getConversationMessageStats,
|
||||
getLastConversationMessage,
|
||||
getAllCallHistory,
|
||||
clearCallHistory,
|
||||
getCallHistoryMessageByCallId,
|
||||
getCallHistory,
|
||||
getCallHistoryGroupsCount,
|
||||
getCallHistoryGroups,
|
||||
saveCallHistory,
|
||||
hasGroupCallHistoryMessage,
|
||||
migrateConversationMessages,
|
||||
getMessagesBetween,
|
||||
|
@ -1755,32 +1775,32 @@ async function searchMessages({
|
|||
// Note: this groups the results by rowid, so even if one message mentions multiple
|
||||
// matching UUIDs, we only return one to be highlighted
|
||||
const [sqlQuery, params] = sql`
|
||||
SELECT
|
||||
SELECT
|
||||
messages.rowid as rowid,
|
||||
COALESCE(messages.json, ftsResults.json) as json,
|
||||
COALESCE(messages.json, ftsResults.json) as json,
|
||||
COALESCE(messages.sent_at, ftsResults.sent_at) as sent_at,
|
||||
COALESCE(messages.received_at, ftsResults.received_at) as received_at,
|
||||
ftsResults.ftsSnippet,
|
||||
mentionUuid,
|
||||
start as mentionStart,
|
||||
ftsResults.ftsSnippet,
|
||||
mentionUuid,
|
||||
start as mentionStart,
|
||||
length as mentionLength
|
||||
FROM mentions
|
||||
INNER JOIN messages
|
||||
ON
|
||||
messages.id = mentions.messageId
|
||||
INNER JOIN messages
|
||||
ON
|
||||
messages.id = mentions.messageId
|
||||
AND mentions.mentionUuid IN (
|
||||
${sqlJoin(contactUuidsMatchingQuery, ', ')}
|
||||
)
|
||||
)
|
||||
AND ${
|
||||
conversationId
|
||||
? sqlFragment`messages.conversationId = ${conversationId}`
|
||||
: '1 IS 1'
|
||||
}
|
||||
AND messages.isViewOnce IS NOT 1
|
||||
AND messages.isViewOnce IS NOT 1
|
||||
AND messages.storyId IS NULL
|
||||
FULL OUTER JOIN (
|
||||
${ftsFragment}
|
||||
) as ftsResults
|
||||
) as ftsResults
|
||||
USING (rowid)
|
||||
GROUP BY rowid
|
||||
ORDER BY received_at DESC, sent_at DESC
|
||||
|
@ -1910,6 +1930,7 @@ function saveMessageSync(
|
|||
sourceUuid,
|
||||
sourceDevice,
|
||||
storyId,
|
||||
callId,
|
||||
type,
|
||||
readStatus,
|
||||
expireTimer,
|
||||
|
@ -1967,6 +1988,7 @@ function saveMessageSync(
|
|||
sourceUuid: sourceUuid || null,
|
||||
sourceDevice: sourceDevice || null,
|
||||
storyId: storyId || null,
|
||||
callId: callId || null,
|
||||
type: type || null,
|
||||
readStatus: readStatus ?? null,
|
||||
seenStatus: seenStatus ?? SeenStatus.NotApplicable,
|
||||
|
@ -1999,6 +2021,7 @@ function saveMessageSync(
|
|||
sourceUuid = $sourceUuid,
|
||||
sourceDevice = $sourceDevice,
|
||||
storyId = $storyId,
|
||||
callId = $callId,
|
||||
type = $type,
|
||||
readStatus = $readStatus,
|
||||
seenStatus = $seenStatus
|
||||
|
@ -2044,6 +2067,7 @@ function saveMessageSync(
|
|||
sourceUuid,
|
||||
sourceDevice,
|
||||
storyId,
|
||||
callId,
|
||||
type,
|
||||
readStatus,
|
||||
seenStatus
|
||||
|
@ -2070,6 +2094,7 @@ function saveMessageSync(
|
|||
$sourceUuid,
|
||||
$sourceDevice,
|
||||
$storyId,
|
||||
$callId,
|
||||
$type,
|
||||
$readStatus,
|
||||
$seenStatus
|
||||
|
@ -3224,30 +3249,366 @@ async function getConversationRangeCenteredOnMessage(
|
|||
})();
|
||||
}
|
||||
|
||||
async function getCallHistoryMessageByCallId(
|
||||
conversationId: string,
|
||||
callId: string
|
||||
): Promise<string | void> {
|
||||
async function getAllCallHistory(): Promise<ReadonlyArray<CallHistoryDetails>> {
|
||||
const db = getInstance();
|
||||
const [query] = sql`
|
||||
SELECT * FROM callsHistory;
|
||||
`;
|
||||
return db.prepare(query).all();
|
||||
}
|
||||
|
||||
async function clearCallHistory(
|
||||
beforeTimestamp: number
|
||||
): Promise<Array<string>> {
|
||||
const db = getInstance();
|
||||
return db.transaction(() => {
|
||||
const whereMessages = sqlFragment`
|
||||
WHERE messages.type IS 'call-history'
|
||||
AND messages.sent_at <= ${beforeTimestamp};
|
||||
`;
|
||||
|
||||
const [selectMessagesQuery, selectMessagesParams] = sql`
|
||||
SELECT id FROM messages ${whereMessages}
|
||||
`;
|
||||
const [clearMessagesQuery, clearMessagesParams] = sql`
|
||||
DELETE FROM messages ${whereMessages}
|
||||
`;
|
||||
const [clearCallsHistoryQuery, clearCallsHistoryParams] = sql`
|
||||
UPDATE callsHistory
|
||||
SET
|
||||
status = ${DirectCallStatus.Deleted},
|
||||
timestamp = ${Date.now()}
|
||||
WHERE callsHistory.timestamp <= ${beforeTimestamp};
|
||||
`;
|
||||
|
||||
const messageIds = db
|
||||
.prepare(selectMessagesQuery)
|
||||
.pluck()
|
||||
.all(selectMessagesParams);
|
||||
db.prepare(clearMessagesQuery).run(clearMessagesParams);
|
||||
try {
|
||||
db.prepare(clearCallsHistoryQuery).run(clearCallsHistoryParams);
|
||||
} catch (error) {
|
||||
logger.error(error, error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return messageIds;
|
||||
})();
|
||||
}
|
||||
|
||||
async function getCallHistoryMessageByCallId(options: {
|
||||
conversationId: string;
|
||||
callId: string;
|
||||
}): Promise<MessageType | undefined> {
|
||||
const db = getInstance();
|
||||
const [query, params] = sql`
|
||||
SELECT json
|
||||
FROM messages
|
||||
WHERE conversationId = ${options.conversationId}
|
||||
AND type = 'call-history'
|
||||
AND callId = ${options.callId}
|
||||
`;
|
||||
const row = db.prepare(query).get(params);
|
||||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
return jsonToObject(row.json);
|
||||
}
|
||||
|
||||
async function getCallHistory(
|
||||
callId: string,
|
||||
peerId: string
|
||||
): Promise<CallHistoryDetails | undefined> {
|
||||
const db = getInstance();
|
||||
|
||||
const id: string | void = db
|
||||
.prepare<Query>(
|
||||
`
|
||||
SELECT id
|
||||
FROM messages
|
||||
WHERE conversationId = $conversationId
|
||||
AND type = 'call-history'
|
||||
AND callMode = 'Direct'
|
||||
AND callId = $callId
|
||||
`
|
||||
)
|
||||
.pluck()
|
||||
.get({
|
||||
conversationId,
|
||||
callId,
|
||||
});
|
||||
const [query, params] = sql`
|
||||
SELECT * FROM callsHistory
|
||||
WHERE callId IS ${callId}
|
||||
AND peerId IS ${peerId};
|
||||
`;
|
||||
|
||||
return id;
|
||||
const row = db.prepare(query).get(params);
|
||||
|
||||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return callHistoryDetailsSchema.parse(row);
|
||||
}
|
||||
|
||||
const MISSED = sqlConstant(DirectCallStatus.Missed);
|
||||
const DELETED = sqlConstant(DirectCallStatus.Deleted);
|
||||
const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000);
|
||||
|
||||
function getCallHistoryGroupDataSync(
|
||||
db: Database,
|
||||
isCount: boolean,
|
||||
filter: CallHistoryFilter,
|
||||
pagination: CallHistoryPagination
|
||||
): unknown {
|
||||
return db.transaction(() => {
|
||||
const { limit, offset } = pagination;
|
||||
const { status, conversationIds } = filter;
|
||||
|
||||
if (conversationIds != null) {
|
||||
strictAssert(conversationIds.length > 0, "can't filter by empty array");
|
||||
|
||||
const [createTempTable] = sql`
|
||||
CREATE TEMP TABLE temp_callHistory_filtered_conversations (
|
||||
uuid TEXT,
|
||||
groupId TEXT
|
||||
);
|
||||
`;
|
||||
|
||||
db.exec(createTempTable);
|
||||
|
||||
batchMultiVarQuery(db, conversationIds, ids => {
|
||||
const idList = sqlJoin(
|
||||
ids.map(id => sqlFragment`(${id})`),
|
||||
','
|
||||
);
|
||||
|
||||
const [insertQuery, insertParams] = sql`
|
||||
INSERT INTO temp_callHistory_filtered_conversations
|
||||
(uuid, groupId)
|
||||
SELECT uuid, groupId
|
||||
FROM conversations
|
||||
WHERE conversations.id IN (${idList});
|
||||
`;
|
||||
|
||||
db.prepare(insertQuery).run(insertParams);
|
||||
});
|
||||
}
|
||||
|
||||
const innerJoin =
|
||||
conversationIds != null
|
||||
? sqlFragment`
|
||||
INNER JOIN temp_callHistory_filtered_conversations ON (
|
||||
temp_callHistory_filtered_conversations.uuid IS c.peerId
|
||||
OR temp_callHistory_filtered_conversations.groupId IS c.peerId
|
||||
)
|
||||
`
|
||||
: sqlFragment``;
|
||||
|
||||
const filterClause =
|
||||
status === CallHistoryFilterStatus.All
|
||||
? sqlFragment`status IS NOT ${DELETED}`
|
||||
: sqlFragment`status IS ${MISSED} AND status IS NOT ${DELETED}`;
|
||||
|
||||
const offsetLimit =
|
||||
limit > 0 ? sqlFragment`LIMIT ${limit} OFFSET ${offset}` : sqlFragment``;
|
||||
|
||||
const projection = isCount
|
||||
? sqlFragment`COUNT(*) AS count`
|
||||
: sqlFragment`peerId, ringerId, mode, type, direction, status, timestamp, possibleChildren, inPeriod`;
|
||||
|
||||
const [query, params] = sql`
|
||||
SELECT
|
||||
${projection}
|
||||
FROM (
|
||||
-- 1. 'callAndGroupInfo': This section collects metadata to determine the
|
||||
-- parent and children of each call. We can identify the real parents of calls
|
||||
-- within the query, but we need to build the children at runtime.
|
||||
WITH callAndGroupInfo AS (
|
||||
SELECT
|
||||
*,
|
||||
-- 1a. 'possibleParent': This identifies the first call that _could_ be
|
||||
-- considered the current call's parent. Note: The 'possibleParent' is not
|
||||
-- necessarily the true parent if there is another call between them that
|
||||
-- isn't a part of the group.
|
||||
(
|
||||
SELECT callId
|
||||
FROM callsHistory
|
||||
WHERE
|
||||
callsHistory.direction IS c.direction
|
||||
AND callsHistory.type IS c.type
|
||||
AND callsHistory.peerId IS c.peerId
|
||||
AND (callsHistory.timestamp - ${FOUR_HOURS_IN_MS}) <= c.timestamp
|
||||
AND callsHistory.timestamp >= c.timestamp
|
||||
-- Tracking Android & Desktop separately to make the queries easier to compare
|
||||
-- Android Constraints:
|
||||
AND (
|
||||
(callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR
|
||||
(callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED})
|
||||
)
|
||||
-- Desktop Constraints:
|
||||
AND callsHistory.status IS c.status
|
||||
AND ${filterClause}
|
||||
ORDER BY timestamp DESC
|
||||
) as possibleParent,
|
||||
-- 1b. 'possibleChildren': This identifies all possible calls that can
|
||||
-- be grouped with the current call. Note: This current call is not
|
||||
-- necessarily the parent, and not all possible children will end up as
|
||||
-- children as they might have another parent
|
||||
(
|
||||
SELECT JSON_GROUP_ARRAY(
|
||||
JSON_OBJECT(
|
||||
'callId', callId,
|
||||
'timestamp', timestamp
|
||||
)
|
||||
)
|
||||
FROM callsHistory
|
||||
WHERE
|
||||
callsHistory.direction IS c.direction
|
||||
AND callsHistory.type IS c.type
|
||||
AND callsHistory.peerId IS c.peerId
|
||||
AND (c.timestamp - ${FOUR_HOURS_IN_MS}) <= callsHistory.timestamp
|
||||
AND c.timestamp >= callsHistory.timestamp
|
||||
-- Tracking Android & Desktop separately to make the queries easier to compare
|
||||
-- Android Constraints:
|
||||
AND (
|
||||
(callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR
|
||||
(callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED})
|
||||
)
|
||||
-- Desktop Constraints:
|
||||
AND callsHistory.status IS c.status
|
||||
AND ${filterClause}
|
||||
ORDER BY timestamp DESC
|
||||
) as possibleChildren,
|
||||
|
||||
-- 1c. 'inPeriod': This identifies all calls in a time period after the
|
||||
-- current call. They may or may not be a part of the group.
|
||||
(
|
||||
SELECT GROUP_CONCAT(callId)
|
||||
FROM callsHistory
|
||||
WHERE
|
||||
(c.timestamp - ${FOUR_HOURS_IN_MS}) <= callsHistory.timestamp
|
||||
AND c.timestamp >= callsHistory.timestamp
|
||||
AND ${filterClause}
|
||||
) AS inPeriod
|
||||
FROM callsHistory AS c
|
||||
${innerJoin}
|
||||
WHERE
|
||||
${filterClause}
|
||||
ORDER BY timestamp DESC
|
||||
)
|
||||
-- 2. 'isParent': We need to identify the true parent of the group in cases
|
||||
-- where the previous call is not a part of the group.
|
||||
SELECT
|
||||
*,
|
||||
CASE
|
||||
WHEN LAG (possibleParent, 1, 0) OVER (
|
||||
-- Note: This is an optimization assuming that we've already got 'timestamp DESC' ordering
|
||||
-- from the query above. If we find that ordering isn't always correct, we can uncomment this:
|
||||
-- ORDER BY timestamp DESC
|
||||
) != possibleParent THEN callId
|
||||
ELSE possibleParent
|
||||
END AS parent
|
||||
FROM callAndGroupInfo
|
||||
) AS parentCallAndGroupInfo
|
||||
WHERE parent = parentCallAndGroupInfo.callId
|
||||
ORDER BY parentCallAndGroupInfo.timestamp DESC
|
||||
${offsetLimit};
|
||||
`;
|
||||
|
||||
const result = isCount
|
||||
? db.prepare(query).pluck(true).get(params)
|
||||
: db.prepare(query).all(params);
|
||||
|
||||
if (conversationIds != null) {
|
||||
const [dropTempTableQuery] = sql`
|
||||
DROP TABLE temp_callHistory_filtered_conversations;
|
||||
`;
|
||||
|
||||
db.exec(dropTempTableQuery);
|
||||
}
|
||||
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
const countSchema = z.number().int().nonnegative();
|
||||
|
||||
async function getCallHistoryGroupsCount(
|
||||
filter: CallHistoryFilter
|
||||
): Promise<number> {
|
||||
const db = getInstance();
|
||||
const result = getCallHistoryGroupDataSync(db, true, filter, {
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
});
|
||||
return countSchema.parse(result);
|
||||
}
|
||||
|
||||
const groupsDataSchema = z.array(
|
||||
callHistoryGroupSchema.omit({ children: true }).extend({
|
||||
possibleChildren: z.string(),
|
||||
inPeriod: z.string(),
|
||||
})
|
||||
);
|
||||
|
||||
const possibleChildrenSchema = z.array(
|
||||
callHistoryDetailsSchema.pick({
|
||||
callId: true,
|
||||
timestamp: true,
|
||||
})
|
||||
);
|
||||
|
||||
async function getCallHistoryGroups(
|
||||
filter: CallHistoryFilter,
|
||||
pagination: CallHistoryPagination
|
||||
): Promise<Array<CallHistoryGroup>> {
|
||||
const db = getInstance();
|
||||
const groupsData = groupsDataSchema.parse(
|
||||
getCallHistoryGroupDataSync(db, false, filter, pagination)
|
||||
);
|
||||
|
||||
const taken = new Set<string>();
|
||||
|
||||
return groupsData
|
||||
.map(groupData => {
|
||||
return {
|
||||
...groupData,
|
||||
possibleChildren: possibleChildrenSchema.parse(
|
||||
JSON.parse(groupData.possibleChildren)
|
||||
),
|
||||
inPeriod: new Set(groupData.inPeriod.split(',')),
|
||||
};
|
||||
})
|
||||
.reverse()
|
||||
.map(group => {
|
||||
const { possibleChildren, inPeriod, ...rest } = group;
|
||||
const children = [];
|
||||
|
||||
for (const child of possibleChildren) {
|
||||
if (!taken.has(child.callId) && inPeriod.has(child.callId)) {
|
||||
children.push(child);
|
||||
taken.add(child.callId);
|
||||
}
|
||||
}
|
||||
|
||||
return callHistoryGroupSchema.parse({ ...rest, children });
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
async function saveCallHistory(callHistory: CallHistoryDetails): Promise<void> {
|
||||
const db = getInstance();
|
||||
|
||||
const [insertQuery, insertParams] = sql`
|
||||
INSERT OR REPLACE INTO callsHistory (
|
||||
callId,
|
||||
peerId,
|
||||
ringerId,
|
||||
mode,
|
||||
type,
|
||||
direction,
|
||||
status,
|
||||
timestamp
|
||||
) VALUES (
|
||||
${callHistory.callId},
|
||||
${callHistory.peerId},
|
||||
${callHistory.ringerId},
|
||||
${callHistory.mode},
|
||||
${callHistory.type},
|
||||
${callHistory.direction},
|
||||
${callHistory.status},
|
||||
${callHistory.timestamp}
|
||||
);
|
||||
`;
|
||||
|
||||
db.prepare(insertQuery).run(insertParams);
|
||||
}
|
||||
|
||||
async function hasGroupCallHistoryMessage(
|
||||
|
@ -5087,6 +5448,7 @@ async function removeAll(): Promise<void> {
|
|||
DELETE FROM attachment_downloads;
|
||||
DELETE FROM badgeImageFiles;
|
||||
DELETE FROM badges;
|
||||
DELETE FROM callsHistory;
|
||||
DELETE FROM conversations;
|
||||
DELETE FROM emojis;
|
||||
DELETE FROM groupCallRingCancellations;
|
||||
|
|
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 updateToSchemaVersion85 from './85-add-kyber-keys';
|
||||
import updateToSchemaVersion86 from './86-story-replies-index';
|
||||
import updateToSchemaVersion87 from './87-calls-history-table';
|
||||
|
||||
function updateToSchemaVersion1(
|
||||
currentVersion: number,
|
||||
|
@ -1994,6 +1995,7 @@ export const SCHEMA_VERSIONS = [
|
|||
updateToSchemaVersion84,
|
||||
updateToSchemaVersion85,
|
||||
updateToSchemaVersion86,
|
||||
updateToSchemaVersion87,
|
||||
];
|
||||
|
||||
export function updateSchema(db: Database, logger: LoggerType): void {
|
||||
|
|
|
@ -36,7 +36,7 @@ export function jsonToObject<T>(json: string): T {
|
|||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
export type QueryTemplateParam = string | number | undefined;
|
||||
export type QueryTemplateParam = string | number | null | undefined;
|
||||
export type QueryFragmentValue = QueryFragment | QueryTemplateParam;
|
||||
|
||||
export type QueryFragment = [
|
||||
|
@ -66,7 +66,7 @@ export function sqlFragment(
|
|||
...values: ReadonlyArray<QueryFragmentValue>
|
||||
): QueryFragment {
|
||||
let query = '';
|
||||
const params: Array<string | number | undefined> = [];
|
||||
const params: Array<QueryTemplateParam> = [];
|
||||
|
||||
strings.forEach((string, index) => {
|
||||
const value = values[index];
|
||||
|
@ -88,6 +88,20 @@ export function sqlFragment(
|
|||
return [{ fragment: query }, params];
|
||||
}
|
||||
|
||||
export function sqlConstant(value: QueryTemplateParam): QueryFragment {
|
||||
let fragment;
|
||||
if (value == null) {
|
||||
fragment = 'NULL';
|
||||
} else if (typeof value === 'number') {
|
||||
fragment = `${value}`;
|
||||
} else if (typeof value === 'boolean') {
|
||||
fragment = `${value}`;
|
||||
} else {
|
||||
fragment = `'${value}'`;
|
||||
}
|
||||
return [{ fragment }, []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `Array.prototype.join`, but for SQL fragments.
|
||||
*/
|
||||
|
@ -96,7 +110,7 @@ export function sqlJoin(
|
|||
separator: string
|
||||
): QueryFragment {
|
||||
let query = '';
|
||||
const params: Array<string | number | undefined> = [];
|
||||
const params: Array<QueryTemplateParam> = [];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const [{ fragment }, fragmentParams] = sqlFragment`${item}`;
|
||||
|
@ -111,10 +125,7 @@ export function sqlJoin(
|
|||
return [{ fragment: query }, params];
|
||||
}
|
||||
|
||||
export type QueryTemplate = [
|
||||
string,
|
||||
ReadonlyArray<string | number | undefined>
|
||||
];
|
||||
export type QueryTemplate = [string, ReadonlyArray<QueryTemplateParam>];
|
||||
|
||||
/**
|
||||
* You can use tagged template literals to build SQL queries
|
||||
|
@ -137,7 +148,7 @@ export type QueryTemplate = [
|
|||
*/
|
||||
export function sql(
|
||||
strings: TemplateStringsArray,
|
||||
...values: ReadonlyArray<QueryFragment | string | number | undefined>
|
||||
...values: ReadonlyArray<QueryFragment | QueryTemplateParam>
|
||||
): QueryTemplate {
|
||||
const [{ fragment }, params] = sqlFragment(strings, ...values);
|
||||
return [fragment, params];
|
||||
|
|
|
@ -6,6 +6,7 @@ import { actions as app } from './ducks/app';
|
|||
import { actions as audioPlayer } from './ducks/audioPlayer';
|
||||
import { actions as audioRecorder } from './ducks/audioRecorder';
|
||||
import { actions as badges } from './ducks/badges';
|
||||
import { actions as callHistory } from './ducks/callHistory';
|
||||
import { actions as calling } from './ducks/calling';
|
||||
import { actions as composer } from './ducks/composer';
|
||||
import { actions as conversations } from './ducks/conversations';
|
||||
|
@ -36,6 +37,7 @@ export const actionCreators: ReduxActions = {
|
|||
audioPlayer,
|
||||
audioRecorder,
|
||||
badges,
|
||||
callHistory,
|
||||
calling,
|
||||
composer,
|
||||
conversations,
|
||||
|
|
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 type { ThunkAction, ThunkDispatch } from 'redux-thunk';
|
||||
import { CallEndedReason } from '@signalapp/ringrtc';
|
||||
import {
|
||||
hasScreenCapturePermission,
|
||||
openSystemPreferences,
|
||||
|
@ -26,6 +25,7 @@ import type {
|
|||
PresentableSource,
|
||||
} from '../../types/Calling';
|
||||
import {
|
||||
CallEndedReason,
|
||||
CallingDeviceType,
|
||||
CallMode,
|
||||
CallViewMode,
|
||||
|
@ -53,8 +53,6 @@ import { isDirectConversation } from '../../util/whatTypeOfConversation';
|
|||
import { SHOW_TOAST } from './toast';
|
||||
import { ToastType } from '../../types/Toast';
|
||||
import type { ShowToastActionType } from './toast';
|
||||
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
|
||||
import MessageSender from '../../textsecure/SendMessage';
|
||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||
import { isAnybodyElseInGroupCall } from './callingHelpers';
|
||||
|
@ -150,15 +148,10 @@ export type AcceptCallType = ReadonlyDeep<{
|
|||
}>;
|
||||
|
||||
export type CallStateChangeType = ReadonlyDeep<{
|
||||
remoteUserId: string; // TODO: Remove
|
||||
callId: string; // TODO: Remove
|
||||
conversationId: string;
|
||||
acceptedTime?: number;
|
||||
callState: CallState;
|
||||
callEndedReason?: CallEndedReason;
|
||||
isIncoming: boolean;
|
||||
isVideoCall: boolean;
|
||||
title: string;
|
||||
}>;
|
||||
|
||||
export type CancelCallType = ReadonlyDeep<{
|
||||
|
@ -363,7 +356,7 @@ const doGroupCallPeek = (
|
|||
// to only be peeking once.
|
||||
await Promise.all([sleep(1000), waitForOnline(navigator, window)]);
|
||||
|
||||
let peekInfo;
|
||||
let peekInfo = null;
|
||||
try {
|
||||
peekInfo = await calling.peekGroupCall(conversationId);
|
||||
} catch (err) {
|
||||
|
@ -689,38 +682,18 @@ function callStateChange(
|
|||
CallStateChangeFulfilledActionType
|
||||
> {
|
||||
return async dispatch => {
|
||||
const {
|
||||
callId,
|
||||
callState,
|
||||
isVideoCall,
|
||||
isIncoming,
|
||||
acceptedTime,
|
||||
callEndedReason,
|
||||
remoteUserId,
|
||||
} = payload;
|
||||
const { callState, acceptedTime, callEndedReason } = payload;
|
||||
|
||||
if (callState === CallState.Ended) {
|
||||
ipcRenderer.send('close-screen-share-controller');
|
||||
}
|
||||
|
||||
const isOutgoing = !isIncoming;
|
||||
const wasAccepted = acceptedTime != null;
|
||||
const isConnected = callState === CallState.Accepted; // "connected"
|
||||
const isEnded = callState === CallState.Ended && callEndedReason != null;
|
||||
|
||||
const isLocalHangup = callEndedReason === CallEndedReason.LocalHangup;
|
||||
const isRemoteHangup = callEndedReason === CallEndedReason.RemoteHangup;
|
||||
|
||||
const answered = isConnected && wasAccepted;
|
||||
const notAnswered = isEnded && !wasAccepted;
|
||||
|
||||
const isOutgoingRemoteAccept = isOutgoing && isConnected && answered;
|
||||
const isIncomingLocalAccept = isIncoming && isConnected && answered;
|
||||
const isOutgoingLocalHangup = isOutgoing && isLocalHangup && notAnswered;
|
||||
const isIncomingLocalHangup = isIncoming && isLocalHangup && notAnswered;
|
||||
const isOutgoingRemoteHangup = isOutgoing && isRemoteHangup && notAnswered;
|
||||
const isIncomingRemoteHangup = isIncoming && isRemoteHangup && notAnswered;
|
||||
|
||||
// Play the hangup noise if:
|
||||
if (
|
||||
// 1. I hungup (or declined)
|
||||
|
@ -733,37 +706,6 @@ function callStateChange(
|
|||
await callingTones.playEndCall();
|
||||
}
|
||||
|
||||
if (isIncomingRemoteHangup) {
|
||||
// This is considered just another "missed" event
|
||||
log.info(
|
||||
`callStateChange: not syncing hangup from self (Call ID: ${callId}))`
|
||||
);
|
||||
} else if (
|
||||
isOutgoingRemoteAccept ||
|
||||
isIncomingLocalAccept ||
|
||||
isOutgoingLocalHangup ||
|
||||
isIncomingLocalHangup ||
|
||||
isOutgoingRemoteHangup
|
||||
) {
|
||||
log.info(`callStateChange: syncing call event (Call ID: ${callId})`);
|
||||
try {
|
||||
await singleProtoJobQueue.add(
|
||||
MessageSender.getCallEventSync(
|
||||
remoteUserId,
|
||||
callId,
|
||||
isVideoCall,
|
||||
isIncoming,
|
||||
acceptedTime != null
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'callStateChange: Failed to queue sync message',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: CALL_STATE_CHANGE_FULFILLED,
|
||||
payload,
|
||||
|
@ -1326,10 +1268,12 @@ function onOutgoingVideoCallInConversation(
|
|||
log.info(
|
||||
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
|
||||
);
|
||||
startCallingLobby({
|
||||
conversationId,
|
||||
isVideoCall: true,
|
||||
})(dispatch, getState, undefined);
|
||||
dispatch(
|
||||
startCallingLobby({
|
||||
conversationId,
|
||||
isVideoCall: true,
|
||||
})
|
||||
);
|
||||
log.info('onOutgoingVideoCallInConversation: started the call');
|
||||
} else {
|
||||
log.info(
|
||||
|
|
|
@ -159,6 +159,8 @@ import { ReceiptType } from '../../types/Receipt';
|
|||
import { sortByMessageOrder } from '../../util/maybeForwardMessages';
|
||||
import { Sound, SoundType } from '../../util/Sound';
|
||||
import { canEditMessage } from '../../util/canEditMessage';
|
||||
import type { ChangeNavTabActionType } from './nav';
|
||||
import { CHANGE_NAV_TAB, NavTab, actions as navActions } from './nav';
|
||||
|
||||
// State
|
||||
|
||||
|
@ -3911,14 +3913,18 @@ function showConversation({
|
|||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
TargetedConversationChangedActionType
|
||||
TargetedConversationChangedActionType | ChangeNavTabActionType
|
||||
> {
|
||||
return (dispatch, getState) => {
|
||||
const { conversations } = getState();
|
||||
const { conversations, nav } = getState();
|
||||
|
||||
if (nav.selectedNavTab !== NavTab.Chats) {
|
||||
dispatch(navActions.changeNavTab(NavTab.Chats));
|
||||
}
|
||||
|
||||
if (conversationId === conversations.selectedConversationId) {
|
||||
if (conversationId && messageId) {
|
||||
scrollToMessage(conversationId, messageId)(dispatch, getState, null);
|
||||
dispatch(scrollToMessage(conversationId, messageId));
|
||||
}
|
||||
|
||||
return;
|
||||
|
@ -4383,7 +4389,11 @@ function maybeUpdateSelectedMessageForDetails(
|
|||
|
||||
export function reducer(
|
||||
state: Readonly<ConversationsStateType> = getEmptyState(),
|
||||
action: Readonly<ConversationActionType | StoryDistributionListsActionType>
|
||||
action: Readonly<
|
||||
| ConversationActionType
|
||||
| StoryDistributionListsActionType
|
||||
| ChangeNavTabActionType
|
||||
>
|
||||
): ConversationsStateType {
|
||||
if (action.type === CLEAR_CONVERSATIONS_PENDING_VERIFICATION) {
|
||||
return {
|
||||
|
@ -6168,5 +6178,31 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
|
||||
if (
|
||||
action.type === CHANGE_NAV_TAB &&
|
||||
action.payload.selectedNavTab === NavTab.Chats
|
||||
) {
|
||||
const { messagesByConversation, selectedConversationId } = state;
|
||||
if (selectedConversationId == null) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const existingConversation = messagesByConversation[selectedConversationId];
|
||||
if (existingConversation == null) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
messagesByConversation: {
|
||||
...messagesByConversation,
|
||||
[selectedConversationId]: {
|
||||
...existingConversation,
|
||||
isNearBottom: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -25,7 +25,6 @@ import type { ConfigMapType as RemoteConfigType } from '../../RemoteConfig';
|
|||
export type ItemsStateType = ReadonlyDeep<
|
||||
{
|
||||
[key: string]: unknown;
|
||||
|
||||
remoteConfig?: RemoteConfigType;
|
||||
serverTimeSkew?: number;
|
||||
} & Partial<
|
||||
|
@ -35,6 +34,7 @@ export type ItemsStateType = ReadonlyDeep<
|
|||
| 'defaultConversationColor'
|
||||
| 'customColors'
|
||||
| 'preferredLeftPaneWidth'
|
||||
| 'navTabsCollapsed'
|
||||
| 'preferredReactionEmoji'
|
||||
| 'areWeASubscriber'
|
||||
| 'usernameLinkColor'
|
||||
|
@ -90,6 +90,7 @@ export const actions = {
|
|||
resetDefaultChatColor,
|
||||
savePreferredLeftPaneWidth,
|
||||
setGlobalDefaultConversationColor,
|
||||
toggleNavTabsCollapse,
|
||||
onSetSkinTone,
|
||||
putItem,
|
||||
putItemExternal,
|
||||
|
@ -98,8 +99,9 @@ export const actions = {
|
|||
resetItems,
|
||||
};
|
||||
|
||||
export const useActions = (): BoundActionCreatorsMapObject<typeof actions> =>
|
||||
useBoundActions(actions);
|
||||
export const useItemsActions = (): BoundActionCreatorsMapObject<
|
||||
typeof actions
|
||||
> => useBoundActions(actions);
|
||||
|
||||
function putItem<K extends keyof StorageAccessType>(
|
||||
key: K,
|
||||
|
@ -292,6 +294,14 @@ function markHasCompletedSafetyNumberOnboarding(): ThunkAction<
|
|||
};
|
||||
}
|
||||
|
||||
function toggleNavTabsCollapse(
|
||||
navTabsCollapsed: boolean
|
||||
): ThunkAction<void, RootStateType, unknown, ItemPutAction> {
|
||||
return dispatch => {
|
||||
dispatch(putItem('navTabsCollapsed', navTabsCollapsed));
|
||||
};
|
||||
}
|
||||
|
||||
// Reducer
|
||||
|
||||
export function getEmptyState(): ItemsStateType {
|
||||
|
|
69
ts/state/ducks/nav.ts
Normal file
|
@ -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;
|
||||
hasAllStoriesUnmuted: boolean;
|
||||
lastOpenedAtTimestamp: number | undefined;
|
||||
openedAtTimestamp: number | undefined;
|
||||
replyState?: Readonly<{
|
||||
messageId: string;
|
||||
replies: Array<MessageAttributesType>;
|
||||
|
@ -163,7 +162,8 @@ const QUEUE_STORY_DOWNLOAD = 'stories/QUEUE_STORY_DOWNLOAD';
|
|||
const SEND_STORY_MODAL_OPEN_STATE_CHANGED =
|
||||
'stories/SEND_STORY_MODAL_OPEN_STATE_CHANGED';
|
||||
const STORY_CHANGED = 'stories/STORY_CHANGED';
|
||||
const TOGGLE_VIEW = 'stories/TOGGLE_VIEW';
|
||||
const CLEAR_STORIES_TAB_STATE = 'stories/CLEAR_STORIES_TAB_STATE';
|
||||
const MARK_STORIES_TAB_VIEWED = 'stories/MARK_STORIES_TAB_VIEWED';
|
||||
const VIEW_STORY = 'stories/VIEW_STORY';
|
||||
const STORY_REPLY_DELETED = 'stories/STORY_REPLY_DELETED';
|
||||
const REMOVE_ALL_STORIES = 'stories/REMOVE_ALL_STORIES';
|
||||
|
@ -217,8 +217,12 @@ type StoryChangedActionType = ReadonlyDeep<{
|
|||
payload: StoryDataType;
|
||||
}>;
|
||||
|
||||
type ToggleViewActionType = ReadonlyDeep<{
|
||||
type: typeof TOGGLE_VIEW;
|
||||
type ClearStoriesTabStateActionType = ReadonlyDeep<{
|
||||
type: typeof CLEAR_STORIES_TAB_STATE;
|
||||
}>;
|
||||
|
||||
type MarkStoriesTabViewedActionType = ReadonlyDeep<{
|
||||
type: typeof MARK_STORIES_TAB_VIEWED;
|
||||
}>;
|
||||
|
||||
type ViewStoryActionType = ReadonlyDeep<{
|
||||
|
@ -262,7 +266,8 @@ export type StoriesActionType =
|
|||
| QueueStoryDownloadActionType
|
||||
| SendStoryModalOpenStateChanged
|
||||
| StoryChangedActionType
|
||||
| ToggleViewActionType
|
||||
| ClearStoriesTabStateActionType
|
||||
| MarkStoriesTabViewedActionType
|
||||
| ViewStoryActionType
|
||||
| StoryReplyDeletedActionType
|
||||
| RemoveAllStoriesActionType
|
||||
|
@ -627,7 +632,7 @@ function sendStoryMessage(
|
|||
> {
|
||||
return async (dispatch, getState) => {
|
||||
const { stories } = getState();
|
||||
const { openedAtTimestamp, sendStoryModalData } = stories;
|
||||
const { lastOpenedAtTimestamp, sendStoryModalData } = stories;
|
||||
|
||||
// Add spinners in the story creator
|
||||
dispatch({
|
||||
|
@ -636,8 +641,8 @@ function sendStoryMessage(
|
|||
});
|
||||
|
||||
assertDev(
|
||||
openedAtTimestamp,
|
||||
'sendStoryMessage: openedAtTimestamp is undefined, cannot send'
|
||||
lastOpenedAtTimestamp,
|
||||
'sendStoryMessage: lastOpenedAtTimestamp is undefined, cannot send'
|
||||
);
|
||||
assertDev(
|
||||
sendStoryModalData,
|
||||
|
@ -649,7 +654,7 @@ function sendStoryMessage(
|
|||
const result = await blockSendUntilConversationsAreVerified(
|
||||
sendStoryModalData,
|
||||
SafetyNumberChangeSource.Story,
|
||||
Date.now() - openedAtTimestamp
|
||||
Date.now() - lastOpenedAtTimestamp
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
|
@ -720,9 +725,15 @@ function sendStoryModalOpenStateChanged(
|
|||
};
|
||||
}
|
||||
|
||||
function toggleStoriesView(): ToggleViewActionType {
|
||||
function clearStoriesTabState(): ClearStoriesTabStateActionType {
|
||||
return {
|
||||
type: TOGGLE_VIEW,
|
||||
type: CLEAR_STORIES_TAB_STATE,
|
||||
};
|
||||
}
|
||||
|
||||
function markStoriesTabViewed(): MarkStoriesTabViewedActionType {
|
||||
return {
|
||||
type: MARK_STORIES_TAB_VIEWED,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1415,7 +1426,8 @@ export const actions = {
|
|||
sendStoryMessage,
|
||||
sendStoryModalOpenStateChanged,
|
||||
storyChanged,
|
||||
toggleStoriesView,
|
||||
clearStoriesTabState,
|
||||
markStoriesTabViewed,
|
||||
verifyStoryListMembers,
|
||||
viewUserStories,
|
||||
viewStory,
|
||||
|
@ -1439,7 +1451,6 @@ export function getEmptyState(
|
|||
): StoriesStateType {
|
||||
return {
|
||||
lastOpenedAtTimestamp: undefined,
|
||||
openedAtTimestamp: undefined,
|
||||
addStoryData: undefined,
|
||||
stories: [],
|
||||
hasAllStoriesUnmuted: false,
|
||||
|
@ -1451,20 +1462,22 @@ export function reducer(
|
|||
state: Readonly<StoriesStateType> = getEmptyState(),
|
||||
action: Readonly<StoriesActionType>
|
||||
): StoriesStateType {
|
||||
if (action.type === TOGGLE_VIEW) {
|
||||
const isShowingStoriesView = Boolean(state.openedAtTimestamp);
|
||||
|
||||
if (action.type === MARK_STORIES_TAB_VIEWED) {
|
||||
return {
|
||||
...state,
|
||||
lastOpenedAtTimestamp: !isShowingStoriesView
|
||||
? state.openedAtTimestamp || Date.now()
|
||||
: state.lastOpenedAtTimestamp,
|
||||
openedAtTimestamp: isShowingStoriesView ? undefined : Date.now(),
|
||||
lastOpenedAtTimestamp: Date.now(),
|
||||
replyState: undefined,
|
||||
sendStoryModalData: undefined,
|
||||
selectedStoryData: isShowingStoriesView
|
||||
? undefined
|
||||
: state.selectedStoryData,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === CLEAR_STORIES_TAB_STATE) {
|
||||
return {
|
||||
...state,
|
||||
replyState: undefined,
|
||||
sendStoryModalData: undefined,
|
||||
selectedStoryData: undefined,
|
||||
addStoryData: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1851,8 +1864,6 @@ export function reducer(
|
|||
if (action.type === TARGETED_CONVERSATION_CHANGED) {
|
||||
return {
|
||||
...state,
|
||||
lastOpenedAtTimestamp: state.openedAtTimestamp || Date.now(),
|
||||
openedAtTimestamp: undefined,
|
||||
replyState: undefined,
|
||||
sendStoryModalData: undefined,
|
||||
selectedStoryData: undefined,
|
||||
|
|
|
@ -5,6 +5,7 @@ import { getEmptyState as accounts } from './ducks/accounts';
|
|||
import { getEmptyState as app } from './ducks/app';
|
||||
import { getEmptyState as audioPlayer } from './ducks/audioPlayer';
|
||||
import { getEmptyState as audioRecorder } from './ducks/audioRecorder';
|
||||
import { getEmptyState as callHistory } from './ducks/callHistory';
|
||||
import { getEmptyState as calling } from './ducks/calling';
|
||||
import { getEmptyState as composer } from './ducks/composer';
|
||||
import { getEmptyState as conversations } from './ducks/conversations';
|
||||
|
@ -15,6 +16,7 @@ import { getEmptyState as inbox } from './ducks/inbox';
|
|||
import { getEmptyState as lightbox } from './ducks/lightbox';
|
||||
import { getEmptyState as linkPreviews } from './ducks/linkPreviews';
|
||||
import { getEmptyState as mediaGallery } from './ducks/mediaGallery';
|
||||
import { getEmptyState as nav } from './ducks/nav';
|
||||
import { getEmptyState as network } from './ducks/network';
|
||||
import { getEmptyState as preferredReactions } from './ducks/preferredReactions';
|
||||
import { getEmptyState as safetyNumber } from './ducks/safetyNumber';
|
||||
|
@ -39,15 +41,18 @@ import { getInitialState as stickers } from '../types/Stickers';
|
|||
import { getThemeType } from '../util/getThemeType';
|
||||
import { getInteractionMode } from '../services/InteractionMode';
|
||||
import { makeLookup } from '../util/makeLookup';
|
||||
import type { CallHistoryDetails } from '../types/CallDisposition';
|
||||
|
||||
export function getInitialState({
|
||||
badges,
|
||||
callsHistory,
|
||||
stories,
|
||||
storyDistributionLists,
|
||||
mainWindowStats,
|
||||
menuOptions,
|
||||
}: {
|
||||
badges: BadgesStateType;
|
||||
callsHistory: ReadonlyArray<CallHistoryDetails>;
|
||||
stories: Array<StoryDataType>;
|
||||
storyDistributionLists: Array<StoryDistributionListDataType>;
|
||||
mainWindowStats: MainWindowStatsType;
|
||||
|
@ -88,6 +93,10 @@ export function getInitialState({
|
|||
audioPlayer: audioPlayer(),
|
||||
audioRecorder: audioRecorder(),
|
||||
badges,
|
||||
callHistory: {
|
||||
...callHistory(),
|
||||
callHistoryByCallId: makeLookup(callsHistory, 'callId'),
|
||||
},
|
||||
calling: calling(),
|
||||
composer: composer(),
|
||||
conversations: {
|
||||
|
@ -110,6 +119,7 @@ export function getInitialState({
|
|||
lightbox: lightbox(),
|
||||
linkPreviews: linkPreviews(),
|
||||
mediaGallery: mediaGallery(),
|
||||
nav: nav(),
|
||||
network: network(),
|
||||
preferredReactions: preferredReactions(),
|
||||
safetyNumber: safetyNumber(),
|
||||
|
|
|
@ -9,6 +9,7 @@ import { reducer as audioPlayer } from './ducks/audioPlayer';
|
|||
import { reducer as audioRecorder } from './ducks/audioRecorder';
|
||||
import { reducer as badges } from './ducks/badges';
|
||||
import { reducer as calling } from './ducks/calling';
|
||||
import { reducer as callHistory } from './ducks/callHistory';
|
||||
import { reducer as composer } from './ducks/composer';
|
||||
import { reducer as conversations } from './ducks/conversations';
|
||||
import { reducer as crashReports } from './ducks/crashReports';
|
||||
|
@ -20,6 +21,7 @@ import { reducer as items } from './ducks/items';
|
|||
import { reducer as lightbox } from './ducks/lightbox';
|
||||
import { reducer as linkPreviews } from './ducks/linkPreviews';
|
||||
import { reducer as mediaGallery } from './ducks/mediaGallery';
|
||||
import { reducer as nav } from './ducks/nav';
|
||||
import { reducer as network } from './ducks/network';
|
||||
import { reducer as preferredReactions } from './ducks/preferredReactions';
|
||||
import { reducer as safetyNumber } from './ducks/safetyNumber';
|
||||
|
@ -39,6 +41,7 @@ export const reducer = combineReducers({
|
|||
audioRecorder,
|
||||
badges,
|
||||
calling,
|
||||
callHistory,
|
||||
composer,
|
||||
conversations,
|
||||
crashReports,
|
||||
|
@ -50,6 +53,7 @@ export const reducer = combineReducers({
|
|||
lightbox,
|
||||
linkPreviews,
|
||||
mediaGallery,
|
||||
nav,
|
||||
network,
|
||||
preferredReactions,
|
||||
safetyNumber,
|
||||
|
|
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
|
||||
);
|
||||
|
||||
type LeftPaneLists = Readonly<{
|
||||
conversations: ReadonlyArray<ConversationType>;
|
||||
archivedConversations: ReadonlyArray<ConversationType>;
|
||||
pinnedConversations: ReadonlyArray<ConversationType>;
|
||||
}>;
|
||||
|
||||
export const _getLeftPaneLists = (
|
||||
lookup: ConversationLookupType,
|
||||
comparator: (left: ConversationType, right: ConversationType) => number,
|
||||
selectedConversation?: string,
|
||||
pinnedConversationIds?: ReadonlyArray<string>
|
||||
): {
|
||||
conversations: Array<ConversationType>;
|
||||
archivedConversations: Array<ConversationType>;
|
||||
pinnedConversations: Array<ConversationType>;
|
||||
} => {
|
||||
): LeftPaneLists => {
|
||||
const conversations: Array<ConversationType> = [];
|
||||
const archivedConversations: Array<ConversationType> = [];
|
||||
const pinnedConversations: Array<ConversationType> = [];
|
||||
|
@ -529,6 +531,40 @@ export const getAllGroupsWithInviteAccess = createSelector(
|
|||
})
|
||||
);
|
||||
|
||||
export type UnreadStats = Readonly<{
|
||||
unreadCount: number;
|
||||
unreadMentionsCount: number;
|
||||
markedUnread: boolean;
|
||||
}>;
|
||||
|
||||
export const getAllConversationsUnreadStats = createSelector(
|
||||
getLeftPaneLists,
|
||||
(leftPaneLists: LeftPaneLists): UnreadStats => {
|
||||
let unreadCount = 0;
|
||||
let unreadMentionsCount = 0;
|
||||
let markedUnread = false;
|
||||
|
||||
function count(conversations: ReadonlyArray<ConversationType>) {
|
||||
conversations.forEach(conversation => {
|
||||
if (conversation.unreadCount != null) {
|
||||
unreadCount += conversation.unreadCount;
|
||||
}
|
||||
if (conversation.unreadMentionsCount != null) {
|
||||
unreadMentionsCount += conversation.unreadMentionsCount;
|
||||
}
|
||||
if (conversation.markedUnread) {
|
||||
markedUnread = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
count(leftPaneLists.pinnedConversations);
|
||||
count(leftPaneLists.conversations);
|
||||
|
||||
return { unreadCount, unreadMentionsCount, markedUnread };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
|
||||
* composer and group members, a different list from your primary system contacts.
|
||||
|
|
|
@ -312,3 +312,8 @@ export const getTextFormattingEnabled = createSelector(
|
|||
getItems,
|
||||
(state: ItemsStateType): boolean => Boolean(state.textFormatting ?? true)
|
||||
);
|
||||
|
||||
export const getNavTabsCollapsed = createSelector(
|
||||
getItems,
|
||||
(state: ItemsStateType): boolean => Boolean(state.navTabsCollapsed ?? false)
|
||||
);
|
||||
|
|
|
@ -54,14 +54,13 @@ import { BodyRange, hydrateRanges } from '../../types/BodyRange';
|
|||
import type { AssertProps } from '../../types/Util';
|
||||
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
||||
import { getMentionsRegex } from '../../types/Message';
|
||||
import { CallMode } from '../../types/Calling';
|
||||
import { SignalService as Proto } from '../../protobuf';
|
||||
import type { AttachmentType } from '../../types/Attachment';
|
||||
import { isVoiceMessage, canBeDownloaded } from '../../types/Attachment';
|
||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||
|
||||
import type { CallingNotificationType } from '../../util/callingNotification';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { CallExternalState } from '../../util/callingNotification';
|
||||
import { getRecipients } from '../../util/getRecipients';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
import { isNotNil } from '../../util/isNotNil';
|
||||
|
@ -128,6 +127,9 @@ import type { AnyPaymentEvent } from '../../types/Payment';
|
|||
import { isPaymentNotificationEvent } from '../../types/Payment';
|
||||
import { getTitleNoDefault, getNumber } from '../../util/getTitle';
|
||||
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp';
|
||||
import type { CallHistorySelectorType } from './callHistory';
|
||||
import { CallMode } from '../../types/Calling';
|
||||
import { CallDirection } from '../../types/CallDisposition';
|
||||
|
||||
export { isIncoming, isOutgoing, isStory };
|
||||
|
||||
|
@ -166,6 +168,7 @@ export type GetPropsForBubbleOptions = Readonly<{
|
|||
selectedMessageIds: ReadonlyArray<string> | undefined;
|
||||
regionCode?: string;
|
||||
callSelector: CallSelectorType;
|
||||
callHistorySelector: CallHistorySelectorType;
|
||||
activeCall?: CallStateType;
|
||||
accountSelector: AccountSelectorType;
|
||||
contactNameColorSelector: ContactNameColorSelectorType;
|
||||
|
@ -1307,69 +1310,79 @@ export function isCallHistory(message: MessageWithUIFieldsType): boolean {
|
|||
|
||||
export type GetPropsForCallHistoryOptions = Pick<
|
||||
GetPropsForBubbleOptions,
|
||||
'conversationSelector' | 'callSelector' | 'activeCall'
|
||||
| 'callSelector'
|
||||
| 'activeCall'
|
||||
| 'callHistorySelector'
|
||||
| 'conversationSelector'
|
||||
| 'ourConversationId'
|
||||
>;
|
||||
|
||||
export function getPropsForCallHistory(
|
||||
message: MessageWithUIFieldsType,
|
||||
{
|
||||
conversationSelector,
|
||||
callSelector,
|
||||
callHistorySelector,
|
||||
activeCall,
|
||||
conversationSelector,
|
||||
ourConversationId,
|
||||
}: GetPropsForCallHistoryOptions
|
||||
): CallingNotificationType {
|
||||
const { callHistoryDetails } = message;
|
||||
if (!callHistoryDetails) {
|
||||
throw new Error('getPropsForCallHistory: Missing callHistoryDetails');
|
||||
const { callId } = message;
|
||||
strictAssert(callId != null, 'getPropsForCallHistory: Missing callId');
|
||||
const callHistory = callHistorySelector(callId);
|
||||
strictAssert(
|
||||
callHistory != null,
|
||||
'getPropsForCallHistory: Missing callHistory'
|
||||
);
|
||||
|
||||
const conversation = conversationSelector(callHistory.peerId);
|
||||
strictAssert(
|
||||
conversation != null,
|
||||
'getPropsForCallHistory: Missing conversation'
|
||||
);
|
||||
|
||||
let callCreator: ConversationType | null = null;
|
||||
if (callHistory.ringerId) {
|
||||
callCreator = conversationSelector(callHistory.ringerId);
|
||||
} else if (callHistory.direction === CallDirection.Outgoing) {
|
||||
callCreator = conversationSelector(ourConversationId);
|
||||
}
|
||||
|
||||
const activeCallConversationId = activeCall?.conversationId;
|
||||
const call = callSelector(callHistory.callId);
|
||||
|
||||
switch (callHistoryDetails.callMode) {
|
||||
// Old messages weren't saved with a call mode.
|
||||
case undefined:
|
||||
case CallMode.Direct:
|
||||
return {
|
||||
...callHistoryDetails,
|
||||
activeCallConversationId,
|
||||
callMode: CallMode.Direct,
|
||||
};
|
||||
case CallMode.Group: {
|
||||
const { conversationId } = message;
|
||||
if (!conversationId) {
|
||||
throw new Error('getPropsForCallHistory: missing conversation ID');
|
||||
}
|
||||
let deviceCount = 0;
|
||||
let maxDevices = Infinity;
|
||||
if (
|
||||
call?.callMode === CallMode.Group &&
|
||||
call.peekInfo?.deviceCount != null &&
|
||||
call.peekInfo?.maxDevices != null
|
||||
) {
|
||||
deviceCount = call.peekInfo.deviceCount;
|
||||
maxDevices = call.peekInfo.maxDevices;
|
||||
}
|
||||
|
||||
let call = callSelector(conversationId);
|
||||
if (call && call.callMode !== CallMode.Group) {
|
||||
log.error(
|
||||
'getPropsForCallHistory: there is an unexpected non-group call; pretending it does not exist'
|
||||
);
|
||||
call = undefined;
|
||||
}
|
||||
|
||||
const creator = conversationSelector(callHistoryDetails.creatorUuid);
|
||||
const deviceCount = call?.peekInfo?.deviceCount ?? 0;
|
||||
|
||||
return {
|
||||
activeCallConversationId,
|
||||
callMode: CallMode.Group,
|
||||
conversationId,
|
||||
creator,
|
||||
deviceCount,
|
||||
ended:
|
||||
callHistoryDetails.eraId !== call?.peekInfo?.eraId || !deviceCount,
|
||||
maxDevices: call?.peekInfo?.maxDevices ?? Infinity,
|
||||
startedTime: callHistoryDetails.startedTime,
|
||||
};
|
||||
let callExternalState: CallExternalState;
|
||||
if (call == null || deviceCount === 0) {
|
||||
callExternalState = CallExternalState.Ended;
|
||||
} else if (activeCall != null) {
|
||||
if (activeCall.conversationId === call.conversationId) {
|
||||
callExternalState = CallExternalState.Joined;
|
||||
} else {
|
||||
callExternalState = CallExternalState.InOtherCall;
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`getPropsForCallHistory: missing case ${missingCaseError(
|
||||
callHistoryDetails
|
||||
)}`
|
||||
);
|
||||
} else if (deviceCount >= maxDevices) {
|
||||
callExternalState = CallExternalState.Full;
|
||||
} else {
|
||||
callExternalState = CallExternalState.Active;
|
||||
}
|
||||
|
||||
return {
|
||||
callHistory,
|
||||
callCreator,
|
||||
callExternalState,
|
||||
deviceCount,
|
||||
maxDevices,
|
||||
};
|
||||
}
|
||||
|
||||
// Profile Change
|
||||
|
|
14
ts/state/selectors/nav.ts
Normal file
|
@ -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 =>
|
||||
state.stories;
|
||||
|
||||
export const shouldShowStoriesView = createSelector(
|
||||
getStoriesState,
|
||||
({ openedAtTimestamp }): boolean => Boolean(openedAtTimestamp)
|
||||
);
|
||||
|
||||
export const hasSelectedStoryData = createSelector(
|
||||
getStoriesState,
|
||||
({ selectedStoryData }): boolean => Boolean(selectedStoryData)
|
||||
|
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from './user';
|
||||
import { getActiveCall, getCallSelector } from './calling';
|
||||
import { getPropsForBubble } from './message';
|
||||
import { getCallHistorySelector } from './callHistory';
|
||||
|
||||
export const getTimelineItem = (
|
||||
state: StateType,
|
||||
|
@ -45,6 +46,7 @@ export const getTimelineItem = (
|
|||
const ourPNI = getUserPNI(state);
|
||||
const ourConversationId = getUserConversationId(state);
|
||||
const callSelector = getCallSelector(state);
|
||||
const callHistorySelector = getCallHistorySelector(state);
|
||||
const activeCall = getActiveCall(state);
|
||||
const accountSelector = getAccountSelector(state);
|
||||
const contactNameColorSelector = getContactNameColorSelector(state);
|
||||
|
@ -61,6 +63,7 @@ export const getTimelineItem = (
|
|||
targetedMessageCounter: targetedMessage?.counter,
|
||||
contactNameColorSelector,
|
||||
callSelector,
|
||||
callHistorySelector,
|
||||
activeCall,
|
||||
accountSelector,
|
||||
selectedMessageIds,
|
||||
|
|
|
@ -11,7 +11,6 @@ import OS from '../../util/os/osMain';
|
|||
import { SmartCallManager } from './CallManager';
|
||||
import { SmartGlobalModalContainer } from './GlobalModalContainer';
|
||||
import { SmartLightbox } from './Lightbox';
|
||||
import { SmartStories } from './Stories';
|
||||
import { SmartStoryViewer } from './StoryViewer';
|
||||
import type { StateType } from '../reducer';
|
||||
import {
|
||||
|
@ -22,10 +21,7 @@ import {
|
|||
getIsMainWindowFullScreen,
|
||||
getMenuOptions,
|
||||
} from '../selectors/user';
|
||||
import {
|
||||
hasSelectedStoryData,
|
||||
shouldShowStoriesView,
|
||||
} from '../selectors/stories';
|
||||
import { hasSelectedStoryData } from '../selectors/stories';
|
||||
import { getHideMenuBar } from '../selectors/items';
|
||||
import { mapDispatchToProps } from '../actions';
|
||||
import { ErrorBoundary } from '../../components/ErrorBoundary';
|
||||
|
@ -57,12 +53,6 @@ const mapStateToProps = (state: StateType) => {
|
|||
),
|
||||
renderGlobalModalContainer: () => <SmartGlobalModalContainer />,
|
||||
renderLightbox: () => <SmartLightbox />,
|
||||
isShowingStoriesView: shouldShowStoriesView(state),
|
||||
renderStories: (closeView: () => unknown) => (
|
||||
<ErrorBoundary name="App/renderStories" closeView={closeView}>
|
||||
<SmartStories />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
hasSelectedStoryData: hasSelectedStoryData(state),
|
||||
renderStoryViewer: (closeView: () => unknown) => (
|
||||
<ErrorBoundary name="App/renderStoryViewer" closeView={closeView}>
|
||||
|
|
170
ts/state/smart/CallsTab.tsx
Normal file
|
@ -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 { getIntl, getPlatform } from '../selectors/user';
|
||||
import { useActions as useEmojiActions } from '../ducks/emojis';
|
||||
import { useActions as useItemsActions } from '../ducks/items';
|
||||
import { useItemsActions } from '../ducks/items';
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||
import { useComposerActions } from '../ducks/composer';
|
||||
import {
|
||||
|
|
|
@ -33,9 +33,12 @@ import {
|
|||
getGroupSizeRecommendedLimit,
|
||||
getGroupSizeHardLimit,
|
||||
} from '../../groups/limits';
|
||||
import type { CallHistoryGroup } from '../../types/CallDisposition';
|
||||
import { getSelectedNavTab } from '../selectors/nav';
|
||||
|
||||
export type SmartConversationDetailsProps = {
|
||||
conversationId: string;
|
||||
callHistoryGroup?: CallHistoryGroup | null;
|
||||
};
|
||||
|
||||
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
|
||||
|
@ -96,6 +99,7 @@ const mapStateToProps = (
|
|||
const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151);
|
||||
return {
|
||||
...props,
|
||||
|
||||
areWeASubscriber: getAreWeASubscriber(state),
|
||||
badges,
|
||||
canEditGroupInfo,
|
||||
|
@ -115,6 +119,7 @@ const mapStateToProps = (
|
|||
hasGroupLink,
|
||||
groupsInCommon: groupsInCommonSorted,
|
||||
isGroup: conversation.type === 'group',
|
||||
selectedNavTab: getSelectedNavTab(state),
|
||||
theme: getTheme(state),
|
||||
renderChooseGroupMembersModal,
|
||||
renderConfirmAdditionsModal,
|
||||
|
|