Compare commits

...

77 Commits

Author SHA1 Message Date
Ajay Bura
eb753a3f32 v1.6.1
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-19 20:33:52 +05:30
Ajay Bura
4a300a3cb2 Fix people search icon displacement
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-19 20:32:47 +05:30
Ajay Bura
c4e16418e0 Open settings on sidebar user profile click
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-19 20:05:13 +05:30
Ajay Bura
27e7a67a9a Separate jump to unread & mark as read
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-19 19:37:38 +05:30
Ajay Bura
ce9f140ddf Refector sass
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-19 10:28:41 +05:30
Ajay Bura
85c3240b54 Fix theme
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-18 10:10:23 +05:30
Ajay Bura
9c12e11375 Fix read receipt count
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-17 17:22:38 +05:30
Ajay Bura
630dbee817 Fix theme
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-17 11:32:21 +05:30
Ajay Bura
18dc02c700 Fix mxid colors for dark theme
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-16 18:39:44 +05:30
Ajay Bura
3d7e509f9a Localize fonts
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-16 17:55:16 +05:30
Ajay Bura
ed27e6b8e4 Fix dark theme color
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-15 21:43:23 +05:30
Ajay Bura
631ce7645f Fix msg timeline keep scrolling when not in focus
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-15 17:26:22 +05:30
Ajay Bura
181382b2b7 Fix show msg header after new msgs divider
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-15 17:05:45 +05:30
Ajay Bura
ca15e69ae0 Fix multiple new message indicator
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-14 20:47:01 +05:30
Ajay Bura
ba64ba0bd0 Fix dialog closing animation jank
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-14 17:26:32 +05:30
Ajay Bura
1df4d32d69 Fix reaction not active
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-14 16:23:41 +05:30
Ajay Bura
5d380453a4 Bugs fixed
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-13 21:18:23 +05:30
Ajay Bura
ba629f1764 v1.6.0
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-13 21:12:42 +05:30
Ajay Bura
f2edcaff85 Updated olm -> v3.2.8, matrix-js-sdk -> v15.2.1
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-13 21:07:15 +05:30
Ajay Bura
6d358d4087 Bugs fixed
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-13 21:05:37 +05:30
Ajay Bura
46dd50a744 Fix hide auto fill suggestions on msg send
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-13 19:38:11 +05:30
Ajay Bura
3c2058f0e1 Updated olm to v3.2.7
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-13 18:08:09 +05:30
Ajay Bura
c22c407ee5 Make spoiler click to toggle (#176)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-13 15:14:57 +05:30
Ajay Bura
1ed1dfc78a Fix bug
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-13 14:40:24 +05:30
Ajay Bura
5797a1d8e5 Add typing outside focus on msg feild (#112)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-13 14:31:43 +05:30
Ajay Bura
6d5d40b8e3 Fix multiple unread divider
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-13 11:03:48 +05:30
Ajay Bura
90c6b18cbb Add btn to hide membership events from timeline
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-12 20:53:32 +05:30
Ajay Bura
ecb7d5ef10 Fix theme colors
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-12 19:55:03 +05:30
Ajay Bura
e2b347c783 Fix messages white-space
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-12 18:45:43 +05:30
Ajay Bura
88a988d876 Remove goto cmds from msg input also fix #81
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-12 11:31:52 +05:30
Ajay Bura
fbeecc0479 Add hotkey ctrl+k for search
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-11 10:50:34 +05:30
Ajay Bura
413188c995 Add recent opened room in search
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-11 09:55:38 +05:30
Ajay Bura
77818f9342 Fix bug
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-10 17:28:29 +05:30
Ajay Bura
c9ec161ccc Add search modal (#132)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-10 17:22:53 +05:30
Ajay Bura
20443f8a4d Fix crashes
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-10 11:45:43 +05:30
Ajay Bura
299ceac557 Fix auto load room members
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-10 10:51:32 +05:30
Ajay Bura
9365e5bfb9 Fix bug
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-09 19:01:39 +05:30
Ajay Bura
3e75841a83 Fix crash in E2E rooms
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-09 15:27:59 +05:30
Ajay Bura
74b8a0f10f Fix msg not auto loading backwards
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-09 12:00:19 +05:30
Ajay Bura
c291729ed6 Optimize message comp
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-09 11:59:17 +05:30
Ajay Bura
a70245a3b1 Fix date in same day
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-08 21:52:25 +05:30
Ajay Bura
dde022d179 Add server side aggregated events
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-08 21:23:18 +05:30
Ajay Bura
0d12c64c47 Add animation on profile pic hover
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-08 14:02:44 +05:30
Ajay Bura
27d0a88b36 Fix unable to mark as read some rooms
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-08 13:49:47 +05:30
Ajay Bura
ca55141276 Show date for msgs older than a day
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-08 13:37:25 +05:30
Ajay Bura
e20b9d054d Add animation on hover in sidebar
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-08 11:11:05 +05:30
Ajay Bura
c1e3645d57 Implement sending read receipt in new pagination
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-07 21:04:07 +05:30
Ajay Bura
50db137dea Add export E2E key (#178) 2021-12-06 10:22:45 +05:30
Ajay Bura
5b109c2b79 Improved performance of local timeline pagination
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-04 19:34:22 +05:30
Ajay Bura
25b7093302 Added local timeline pagination
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-04 15:25:14 +05:30
Ajay Bura
38cbb87a62 Added unread indicator (#67), reply link back to original (#96)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-03 18:32:10 +05:30
Ajay Bura
0c0a978886 Parse reply using m.in_reply_to (#134)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-03 18:30:05 +05:30
Ajay Bura
fb5f368894 Added primary varient in IconButton
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-03 18:26:18 +05:30
Ajay Bura
9454ffd1af Update UX of Divider comp
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-12-03 18:25:29 +05:30
Ajay Bura
16f35d9a34 Fix bug in creating dm
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-24 10:08:51 +05:30
Ajay Bura
bb6a64790d More twemojify text
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-23 17:07:15 +05:30
Ajay Bura
b9378118dd Twemojify text
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-23 16:33:35 +05:30
Ajay Bura
b6485f91ae Fix crash on room create
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-23 16:24:12 +05:30
Ajay Bura
e1e8ca9633 Fix space invite open
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-23 15:51:39 +05:30
Ajay Bura
72f476a750 Fix sinitizeText
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-23 12:30:40 +05:30
Ajay Bura
f897809202 Fix emoji size in Avatar
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-23 12:27:01 +05:30
Ajay Bura
647d085c5f Twemojified all text
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-23 11:56:02 +05:30
Ajay Bura
9d0f99c509 Fix checkbox in register flow
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-22 14:37:14 +05:30
Ajay Bura
fd25a23d91 Downgraded linkifyjs
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-21 19:21:03 +05:30
Ajay Bura
7fdf165ff3 Allow html in m.text
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-21 18:31:58 +05:30
Ajay Bura
b3e27da26d Fix table scroll
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-21 15:27:03 +05:30
Ajay Bura
2479dc4096 Use formatted_body to parse markdown (#133) and partially implement #105, #19
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-21 14:30:21 +05:30
Ajay Bura
7e7a5e692e Refectored Message comp
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-20 13:29:32 +05:30
Ajay Bura
f628a6c3d6 Updated dependencies
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-19 13:20:34 +05:30
Ajay Bura
5b0f95fed9 Fix alignment in ProfileViewer
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-19 10:00:47 +05:30
Ajay Bura
6aa98d5eac Fix message not comming in encrypted rooms
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-19 10:00:07 +05:30
Ajay Bura
8e1fe9558e Specified sha for build script
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-18 18:19:04 +05:30
Ajay Bura
38c3e53ce7 Specified node version to workflows x 2
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-18 18:14:49 +05:30
Ajay Bura
9627766f7d Specified node version to workflows
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-18 18:11:12 +05:30
Ajay Bura
57697142a2 Add pagination in room timeline
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-18 13:32:12 +05:30
Ajay Bura
beb32755a3 Allow msg width to span over screen
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-15 09:41:12 +05:30
Ajay Bura
cb6e71e544 Save peopleDrawer visibility in localStorage
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-11-15 09:23:59 +05:30
135 changed files with 4487 additions and 4822 deletions

View File

@@ -12,7 +12,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: jsmrcaga/action-netlify-deploy@master - uses: jsmrcaga/action-netlify-deploy@9cc40dcd499dd1511b3cc99912444f8970411cc6
with: with:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE2_ID }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE2_ID }}

View File

@@ -11,7 +11,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: jsmrcaga/action-netlify-deploy@master - uses: jsmrcaga/action-netlify-deploy@9cc40dcd499dd1511b3cc99912444f8970411cc6
with: with:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

View File

@@ -10,7 +10,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents
> - Tweet about it (tag @cinnyapp) > - Tweet about it (tag @cinnyapp)
> - Refer this project in your project's readme > - Refer this project in your project's readme
> - Mention the project at local meetups and tell your friends/colleagues > - Mention the project at local meetups and tell your friends/colleagues
> - [Donate to us](https://liberapay.com/ajbura/donate) > - [Donate to us](https://cinny.in/#sponsor)
<!-- omit in toc --> <!-- omit in toc -->
## Table of Contents ## Table of Contents

BIN
olm.wasm

Binary file not shown.

2347
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "cinny", "name": "cinny",
"version": "1.5.1", "version": "1.6.1",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"engines": { "engines": {
@@ -15,18 +15,18 @@
"author": "Ajay Bura", "author": "Ajay Bura",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.4.tgz", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"@tippyjs/react": "^4.2.5", "@tippyjs/react": "^4.2.5",
"babel-polyfill": "^6.26.0", "babel-polyfill": "^6.26.0",
"browser-encrypt-attachment": "^0.3.0", "browser-encrypt-attachment": "^0.3.0",
"dateformat": "^4.5.1", "dateformat": "^4.5.1",
"emojibase-data": "^6.2.0", "emojibase-data": "^6.2.0",
"file-saver": "^2.0.5",
"flux": "^4.0.1", "flux": "^4.0.1",
"formik": "^2.2.9", "formik": "^2.2.9",
"html-react-parser": "^1.2.7", "html-react-parser": "^1.2.7",
"linkify-react": "^3.0.3", "linkifyjs": "^2.1.9",
"linkifyjs": "^3.0.3", "matrix-js-sdk": "^15.2.1",
"matrix-js-sdk": "^12.4.1",
"micromark": "^3.0.3", "micromark": "^3.0.3",
"micromark-extension-gfm": "^1.0.0", "micromark-extension-gfm": "^1.0.0",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
@@ -34,11 +34,8 @@
"react-autosize-textarea": "^7.1.0", "react-autosize-textarea": "^7.1.0",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-google-recaptcha": "^2.1.0", "react-google-recaptcha": "^2.1.0",
"react-markdown": "^6.0.1",
"react-modal": "^3.13.1", "react-modal": "^3.13.1",
"react-router-dom": "^5.2.0", "sanitize-html": "^2.5.3",
"react-syntax-highlighter": "^15.4.3",
"remark-gfm": "^1.0.0",
"tippy.js": "^6.3.1", "tippy.js": "^6.3.1",
"twemoji": "^13.1.0" "twemoji": "^13.1.0"
}, },

View File

@@ -4,7 +4,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0 maximum-scale=1.0 user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0 maximum-scale=1.0 user-scalable=no">
<link href="https://api.fontshare.com/css?f[]=supreme@300,301,400,401,500,501,700,701&display=swap" rel="stylesheet">
<title>Cinny</title> <title>Cinny</title>
<meta name="name" content="Cinny"> <meta name="name" content="Cinny">
<meta name="author" content="Ajay Bura"> <meta name="author" content="Ajay Bura">

Binary file not shown.

View File

@@ -0,0 +1,202 @@
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 [yyyy] [name of copyright owner]
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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,11 +2,13 @@ import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './Avatar.scss'; import './Avatar.scss';
import { twemojify } from '../../../util/twemojify';
import Text from '../text/Text'; import Text from '../text/Text';
import RawIcon from '../system-icons/RawIcon'; import RawIcon from '../system-icons/RawIcon';
function Avatar({ function Avatar({
text, bgColor, iconSrc, imageSrc, size, text, bgColor, iconSrc, iconColor, imageSrc, size,
}) { }) {
const [image, updateImage] = useState(imageSrc); const [image, updateImage] = useState(imageSrc);
let textSize = 's1'; let textSize = 's1';
@@ -20,16 +22,20 @@ function Avatar({
<div className={`avatar-container avatar-container__${size} noselect`}> <div className={`avatar-container avatar-container__${size} noselect`}>
{ {
image !== null image !== null
? <img src={image} onError={() => updateImage(null)} alt="avatar" /> ? <img draggable="false" src={image} onError={() => updateImage(null)} alt="avatar" />
: ( : (
<span <span
style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }} style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }}
className={`avatar__border${iconSrc !== null ? ' avatar__bordered' : ''} inline-flex--center`} className={`avatar__border${iconSrc !== null ? '--active' : ''}`}
> >
{ {
iconSrc !== null iconSrc !== null
? <RawIcon size={size} src={iconSrc} /> ? <RawIcon size={size} src={iconSrc} color={iconColor} />
: text !== null && <Text variant={textSize}>{[...text][0]}</Text> : text !== null && (
<Text variant={textSize} primary>
{twemojify([...text][0])}
</Text>
)
} }
</span> </span>
) )
@@ -42,6 +48,7 @@ Avatar.defaultProps = {
text: null, text: null,
bgColor: 'transparent', bgColor: 'transparent',
iconSrc: null, iconSrc: null,
iconColor: null,
imageSrc: null, imageSrc: null,
size: 'normal', size: 'normal',
}; };
@@ -50,6 +57,7 @@ Avatar.propTypes = {
text: PropTypes.string, text: PropTypes.string,
bgColor: PropTypes.string, bgColor: PropTypes.string,
iconSrc: PropTypes.string, iconSrc: PropTypes.string,
iconColor: PropTypes.string,
imageSrc: PropTypes.string, imageSrc: PropTypes.string,
size: PropTypes.oneOf(['large', 'normal', 'small', 'extra-small']), size: PropTypes.oneOf(['large', 'normal', 'small', 'extra-small']),
}; };

View File

@@ -1,3 +1,5 @@
@use '../../partials/flex';
.avatar-container { .avatar-container {
display: inline-flex; display: inline-flex;
width: 42px; width: 42px;
@@ -24,18 +26,16 @@
height: var(--av-extra-small); height: var(--av-extra-small);
} }
img { > img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
border-radius: inherit; border-radius: inherit;
} }
.avatar__bordered {
box-shadow: var(--bs-surface-border);
}
.avatar__border { .avatar__border {
@extend .cp-fx__row--c-c;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@@ -45,7 +45,11 @@
border-radius: inherit; border-radius: inherit;
.text { .text {
color: var(--tc-primary-high); color: white;
}
&--active {
@extend .avatar__border;
box-shadow: var(--bs-surface-border);
} }
} }
} }

View File

@@ -8,7 +8,7 @@ function NotificationBadge({ alert, content }) {
const notificationClass = alert ? ' notification-badge--alert' : ''; const notificationClass = alert ? ' notification-badge--alert' : '';
return ( return (
<div className={`notification-badge${notificationClass}`}> <div className={`notification-badge${notificationClass}`}>
{content !== null && <Text variant="b3">{content}</Text>} {content !== null && <Text variant="b3" weight="bold">{content}</Text>}
</div> </div>
); );
} }

View File

@@ -8,7 +8,6 @@
.text { .text {
color: var(--tc-badge); color: var(--tc-badge);
text-align: center; text-align: center;
font-weight: 700;
} }
&--alert { &--alert {

View File

@@ -1,4 +1,5 @@
@use 'state'; @use 'state';
@use '../../partials/dir';
.btn-surface, .btn-surface,
.btn-primary, .btn-primary,
@@ -18,27 +19,10 @@
@include state.disabled; @include state.disabled;
&--icon { &--icon {
padding: { @include dir.side(padding, var(--sp-tight), var(--sp-loose));
left: var(--sp-tight);
right: var(--sp-loose);
}
[dir=rtl] & {
padding: {
left: var(--sp-loose);
right: var(--sp-tight);
}
}
.ic-raw { .ic-raw {
margin-right: var(--sp-extra-tight); @include dir.side(margin, 0, var(--sp-extra-tight));
[dir=rtl] & {
margin: {
right: 0;
left: var(--sp-extra-tight);
}
}
} }
} }
} }

View File

@@ -9,7 +9,7 @@ import Text from '../text/Text';
const IconButton = React.forwardRef(({ const IconButton = React.forwardRef(({
variant, size, type, variant, size, type,
tooltip, tooltipPlacement, src, onClick, tooltip, tooltipPlacement, src, onClick, tabIndex,
}, ref) => { }, ref) => {
const btn = ( const btn = (
<button <button
@@ -19,6 +19,7 @@ const IconButton = React.forwardRef(({
onClick={onClick} onClick={onClick}
// eslint-disable-next-line react/button-has-type // eslint-disable-next-line react/button-has-type
type={type} type={type}
tabIndex={tabIndex}
> >
<RawIcon size={size} src={src} /> <RawIcon size={size} src={src} />
</button> </button>
@@ -41,16 +42,18 @@ IconButton.defaultProps = {
tooltip: null, tooltip: null,
tooltipPlacement: 'top', tooltipPlacement: 'top',
onClick: null, onClick: null,
tabIndex: 0,
}; };
IconButton.propTypes = { IconButton.propTypes = {
variant: PropTypes.oneOf(['surface', 'positive', 'caution', 'danger']), variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
size: PropTypes.oneOf(['normal', 'small', 'extra-small']), size: PropTypes.oneOf(['normal', 'small', 'extra-small']),
type: PropTypes.oneOf(['button', 'submit', 'reset']), type: PropTypes.oneOf(['button', 'submit', 'reset']),
tooltip: PropTypes.string, tooltip: PropTypes.string,
tooltipPlacement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']), tooltipPlacement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
src: PropTypes.string.isRequired, src: PropTypes.string.isRequired,
onClick: PropTypes.func, onClick: PropTypes.func,
tabIndex: PropTypes.number,
}; };
export default IconButton; export default IconButton;

View File

@@ -29,6 +29,13 @@
@include focus(var(--bg-surface-hover)); @include focus(var(--bg-surface-hover));
@include state.active(var(--bg-surface-active)); @include state.active(var(--bg-surface-active));
} }
.ic-btn-primary {
@include color(var(--ic-primary-normal));
@include state.hover(var(--bg-primary-hover));
@include focus(var(--bg-primary-hover));
@include state.active(var(--bg-primary-active));
background-color: var(--bg-primary);
}
.ic-btn-positive { .ic-btn-positive {
@include color(var(--ic-positive-normal)); @include color(var(--ic-positive-normal));
@include state.hover(var(--bg-positive-hover)); @include state.hover(var(--bg-positive-hover));

View File

@@ -1,3 +1,5 @@
@use '../../partials/dir';
.toggle { .toggle {
width: 44px; width: 44px;
height: 24px; height: 24px;
@@ -27,13 +29,13 @@
background-color: var(--bg-positive); background-color: var(--bg-positive);
&::before { &::before {
background-color: white; --ltr: translateX(calc(125%));
transform: translateX(calc(125%)); --rtl: translateX(calc(-125%));
opacity: 1; @include dir.prop(transform, var(--ltr), var(--rtl));
[dir=rtl] & { transform: translateX(calc(125%));
transform: translateX(calc(-125%)); background-color: white;
} opacity: 1;
} }
} }
} }

View File

@@ -1,3 +1,5 @@
@use '../../partials/dir';
.chip { .chip {
padding: var(--sp-ultra-tight) var(--sp-extra-tight); padding: var(--sp-ultra-tight) var(--sp-extra-tight);
@@ -24,10 +26,6 @@
& > .ic-raw { & > .ic-raw {
width: 16px; width: 16px;
height: 16px; height: 16px;
margin-right: var(--sp-ultra-tight); @include dir.side(margin, 0, var(--sp-ultra-tight));
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-ultra-tight);
}
} }
} }

View File

@@ -67,7 +67,7 @@ function MenuHeader({ children }) {
} }
MenuHeader.propTypes = { MenuHeader.propTypes = {
children: PropTypes.string.isRequired, children: PropTypes.node.isRequired,
}; };
function MenuItem({ function MenuItem({

View File

@@ -1,3 +1,6 @@
@use '../../partials/text';
@use '../../partials/dir';
.context-menu { .context-menu {
background-color: var(--bg-surface); background-color: var(--bg-surface);
box-shadow: var(--bs-popup); box-shadow: var(--bs-popup);
@@ -29,10 +32,8 @@
border-bottom: 1px solid var(--bg-surface-border); border-bottom: 1px solid var(--bg-surface-border);
.text { .text {
@extend .cp-txt__ellipsis;
color: var(--tc-surface-low); color: var(--tc-surface-low);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
&:not(:first-child) { &:not(:first-child) {
@@ -44,23 +45,17 @@
.context-menu__item { .context-menu__item {
button[class^="btn"] { button[class^="btn"] {
width: 100%; width: 100%;
justify-content: start; justify-content: flex-start;
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
white-space: nowrap; white-space: nowrap;
.text:first-child { .text:first-child {
margin: { @include dir.side(
left: calc(var(--ic-small) + var(--sp-ultra-tight)); margin,
right: var(--sp-extra-tight); calc(var(--ic-small) + var(--sp-ultra-tight)),
} var(--sp-extra-tight)
);
[dir=rtl] & {
margin: {
left: var(--sp-extra-tight);
right: calc(var(--ic-small) + var(--sp-ultra-tight));
}
}
} }
} }
.btn-surface:focus { .btn-surface:focus {

View File

@@ -4,26 +4,25 @@ import './Divider.scss';
import Text from '../text/Text'; import Text from '../text/Text';
function Divider({ text, variant }) { function Divider({ text, variant, align }) {
const dividerClass = ` divider--${variant}`; const dividerClass = ` divider--${variant} divider--${align}`;
return ( return (
<div className={`divider${dividerClass}`}> <div className={`divider${dividerClass}`}>
{text !== false && <Text className="divider__text" variant="b3">{text}</Text>} {text !== null && <Text className="divider__text" variant="b3" weight="bold">{text}</Text>}
</div> </div>
); );
} }
Divider.defaultProps = { Divider.defaultProps = {
text: false, text: null,
variant: 'surface', variant: 'surface',
align: 'center',
}; };
Divider.propTypes = { Divider.propTypes = {
text: PropTypes.oneOfType([ text: PropTypes.string,
PropTypes.string, variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
PropTypes.bool, align: PropTypes.oneOf(['left', 'center', 'right']),
]),
variant: PropTypes.oneOf(['surface', 'primary', 'caution', 'danger']),
}; };
export default Divider; export default Divider;

View File

@@ -1,68 +1,68 @@
.divider { .divider-line {
--local-divider-color: var(--bg-surface-border); content: '';
display: inline-block;
flex: 1;
border-bottom: 1px solid var(--local-divider-color);
opacity: var(--local-divider-opacity);
}
margin: var(--sp-extra-tight) var(--sp-normal); .divider {
margin-right: var(--sp-extra-tight);
display: flex; display: flex;
align-items: center; align-items: center;
position: relative;
&::before { &--center::before,
content: ""; &--right::before {
display: inline-block; @extend .divider-line;
flex: 1; }
margin-left: calc(var(--av-small) + var(--sp-tight)); &--center::after,
border-bottom: 1px solid var(--local-divider-color); &--left::after {
opacity: 0.18; @extend .divider-line;
[dir=rtl] & {
margin: {
left: 0;
right: calc(var(--av-small) + var(--sp-tight));
}
}
} }
&__text { &__text {
margin-left: var(--sp-normal); padding: 2px var(--sp-extra-tight);
} border-radius: calc(var(--bo-radius) / 2);
[dir=rtl] & {
margin: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
&__text {
margin: {
left: 0;
right: var(--sp-normal);
}
}
} }
} }
.divider--surface { .divider--surface {
--local-divider-color: var(--tc-surface-low); --local-divider-color: var(--bg-divider);
--local-divider-opacity: 1;
.divider__text { .divider__text {
color: var(--tc-surface-low); color: var(--tc-surface-low);
border: 1px solid var(--bg-divider);
} }
} }
.divider--primary { .divider--primary {
--local-divider-color: var(--bg-primary); --local-divider-color: var(--bg-primary);
--local-divider-opacity: .8;
.divider__text { .divider__text {
color: var(--bg-primary); color: var(--tc-primary-high);
background-color: var(--bg-primary);
}
}
.divider--positive {
--local-divider-color: var(--bg-positive);
--local-divider-opacity: .8;
.divider__text {
color: var(--bg-surface);
background-color: var(--bg-positive);
} }
} }
.divider--danger { .divider--danger {
--local-divider-color: var(--bg-danger); --local-divider-color: var(--bg-danger);
--local-divider-opacity: .8;
.divider__text { .divider__text {
color: var(--bg-danger); color: var(--bg-surface);
background-color: var(--bg-danger);
} }
} }
.divider--caution { .divider--caution {
--local-divider-color: var(--bg-caution); --local-divider-color: var(--bg-caution);
--local-divider-opacity: .8;
.divider__text { .divider__text {
color: var(--bg-caution); color: var(--bg-surface);
background-color: var(--bg-caution);
} }
} }

View File

@@ -1,63 +1,43 @@
@use '../../partials/text';
@use '../../partials/dir';
.header { .header {
padding: { @include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
left: var(--sp-normal);
right: var(--sp-extra-tight);
}
width: 100%; width: 100%;
height: var(--header-height); height: var(--header-height);
border-bottom: 1px solid var(--bg-surface-border); border-bottom: 1px solid var(--bg-surface-border);
display: flex; display: flex;
align-items: center; align-items: center;
[dir=rtl] & {
padding: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
&__title-wrapper { &__title-wrapper {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
margin: 0 var(--sp-tight); margin: 0 var(--sp-tight);
&:first-child { &:first-child {
margin-left: 0; @include dir.side(margin, 0, var(--sp-tight));
[dir=rtl] & {
margin-right: 0;
}
} }
& > .text:first-child { & > .text:first-child {
@extend .cp-txt__ellipsis;
min-width: 0; min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
& > .text-b3{ & > .text-b3{
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin-top: var(--sp-ultra-tight); margin-top: var(--sp-ultra-tight);
margin-left: var(--sp-tight); @include dir.side(margin, var(--sp-tight), 0);
padding-left: var(--sp-tight); @include dir.side(padding, var(--sp-tight), 0);
border-left: 1px solid var(--bg-surface-border); @include dir.side(border, 1px solid var(--bg-surface-border), none);
max-height: calc(2 * var(--lh-b3)); max-height: calc(2 * var(--lh-b3));
overflow: hidden; overflow: hidden;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
display: -webkit-box; display: -webkit-box;
[dir=rtl] & {
margin-left: 0;
padding-left: 0;
border-left: none;
margin-right: var(--sp-tight);
padding-right: var(--sp-tight);
border-right: 1px solid var(--bg-surface-border);
}
} }
} }
} }

View File

@@ -4,6 +4,8 @@ import './RawModal.scss';
import Modal from 'react-modal'; import Modal from 'react-modal';
import navigation from '../../../client/state/navigation';
Modal.setAppElement('#root'); Modal.setAppElement('#root');
function RawModal({ function RawModal({
@@ -23,6 +25,9 @@ function RawModal({
default: default:
modalClass += 'raw-modal__small '; modalClass += 'raw-modal__small ';
} }
navigation.setIsRawModalVisible(isOpen);
const modalOverlayClass = (overlayClassName !== null) ? `${overlayClassName} ` : ''; const modalOverlayClass = (overlayClassName !== null) ? `${overlayClassName} ` : '';
return ( return (
<Modal <Modal

View File

@@ -32,7 +32,8 @@
@mixin scroll { @mixin scroll {
overflow: hidden; overflow: hidden;
overscroll-behavior: none; // Below code stop scroll when x-scrollable content come in timeline
// overscroll-behavior: none;
@extend .firefox-scrollbar; @extend .firefox-scrollbar;
@extend .webkit-scrollbar; @extend .webkit-scrollbar;
@extend .webkit-scrollbar-track; @extend .webkit-scrollbar-track;

View File

@@ -1,4 +1,5 @@
@use '../button/state'; @use '../button/state';
@use '../../partials/dir';
.segmented-controls { .segmented-controls {
background-color: var(--bg-surface-low); background-color: var(--bg-surface-low);
@@ -20,12 +21,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-left: 1px solid var(--bg-surface-border); @include dir.side(border, 1px solid var(--bg-surface-border), none);
[dir=rtl] & {
border-left: none;
border-right: 1px solid var(--bg-surface-border);
}
& .text:nth-child(2) { & .text:nth-child(2) {
margin: 0 var(--sp-extra-tight); margin: 0 var(--sp-extra-tight);

View File

@@ -3,25 +3,39 @@ import PropTypes from 'prop-types';
import './Text.scss'; import './Text.scss';
function Text({ function Text({
id, className, variant, children, className, style, variant, weight,
primary, span, children,
}) { }) {
const cName = className !== '' ? `${className} ` : ''; const classes = [];
if (variant === 'h1') return <h1 id={id === '' ? undefined : id} className={`${cName}text text-h1`}>{ children }</h1>; if (className) classes.push(className);
if (variant === 'h2') return <h2 id={id === '' ? undefined : id} className={`${cName}text text-h2`}>{ children }</h2>;
if (variant === 's1') return <h4 id={id === '' ? undefined : id} className={`${cName}text text-s1`}>{ children }</h4>; classes.push(`text text-${variant} text-${weight}`);
return <p id={id === '' ? undefined : id} className={`${cName}text text-${variant}`}>{ children }</p>; if (primary) classes.push('font-primary');
const textClass = classes.join(' ');
if (span) return <span className={textClass} style={style}>{ children }</span>;
if (variant === 'h1') return <h1 className={textClass} style={style}>{ children }</h1>;
if (variant === 'h2') return <h2 className={textClass} style={style}>{ children }</h2>;
if (variant === 's1') return <h4 className={textClass} style={style}>{ children }</h4>;
return <p className={textClass} style={style}>{ children }</p>;
} }
Text.defaultProps = { Text.defaultProps = {
id: '', className: null,
className: '', style: null,
variant: 'b1', variant: 'b1',
weight: 'normal',
primary: false,
span: false,
}; };
Text.propTypes = { Text.propTypes = {
id: PropTypes.string,
className: PropTypes.string, className: PropTypes.string,
style: PropTypes.shape({}),
variant: PropTypes.oneOf(['h1', 'h2', 's1', 'b1', 'b2', 'b3']), variant: PropTypes.oneOf(['h1', 'h2', 's1', 'b1', 'b2', 'b3']),
weight: PropTypes.oneOf(['light', 'normal', 'medium', 'bold']),
primary: PropTypes.bool,
span: PropTypes.bool,
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
}; };

View File

@@ -1,41 +1,60 @@
@mixin font($type, $weight) { @mixin font($type) {
font-size: var(--fs-#{$type}); font-size: var(--fs-#{$type});
font-weight: $weight;
letter-spacing: var(--ls-#{$type}); letter-spacing: var(--ls-#{$type});
line-height: var(--lh-#{$type}); line-height: var(--lh-#{$type});
& img.emoji,
& img[data-mx-emoticon] {
height: var(--fs-#{$type});
}
} }
%text { .text {
margin: 0; margin: 0;
padding: 0; padding: 0;
color: var(--tc-surface-high); color: var(--tc-surface-high);
& img.emoji,
& img[data-mx-emoticon] {
margin: 0 !important;
margin-right: 2px !important;
padding: 0 !important;
position: relative;
top: 2px;
}
}
.text-light {
font-weight: var(--fw-light);
}
.text-normal {
font-weight: var(--fw-normal);
}
.text-medium {
font-weight: var(--fw-medium);
}
.text-bold {
font-weight: var(--fw-bold);
} }
.text-h1 { .text-h1 {
@extend %text; @include font(h1);
@include font(h1, 500);
} }
.text-h2 { .text-h2 {
@extend %text; @include font(h2);
@include font(h2, 500);
} }
.text-s1 { .text-s1 {
@extend %text; @include font(s1);
@include font(s1, 400);
} }
.text-b1 { .text-b1 {
@extend %text; @include font(b1);
@include font(b1, 400);
color: var(--tc-surface-normal); color: var(--tc-surface-normal);
} }
.text-b2 { .text-b2 {
@extend %text; @include font(b2);
@include font(b2, 400);
color: var(--tc-surface-normal); color: var(--tc-surface-normal);
} }
.text-b3 { .text-b3 {
@extend %text; @include font(b3);
@include font(b3, 400);
color: var(--tc-surface-low); color: var(--tc-surface-low);
} }

View File

@@ -4,7 +4,7 @@ import './Tooltip.scss';
import Tippy from '@tippyjs/react'; import Tippy from '@tippyjs/react';
function Tooltip({ function Tooltip({
className, placement, content, children, className, placement, content, delay, children,
}) { }) {
return ( return (
<Tippy <Tippy
@@ -14,7 +14,7 @@ function Tooltip({
arrow={false} arrow={false}
maxWidth={250} maxWidth={250}
placement={placement} placement={placement}
delay={[0, 0]} delay={delay}
duration={[100, 0]} duration={[100, 0]}
> >
{children} {children}
@@ -25,12 +25,14 @@ function Tooltip({
Tooltip.defaultProps = { Tooltip.defaultProps = {
placement: 'top', placement: 'top',
className: '', className: '',
delay: [200, 0],
}; };
Tooltip.propTypes = { Tooltip.propTypes = {
className: PropTypes.string, className: PropTypes.string,
placement: PropTypes.string, placement: PropTypes.string,
content: PropTypes.node.isRequired, content: PropTypes.node.isRequired,
delay: PropTypes.arrayOf(PropTypes.number),
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
}; };

View File

@@ -0,0 +1,10 @@
/* eslint-disable import/prefer-default-export */
import { useState } from 'react';
export function useForceUpdate() {
const [data, setData] = useState(null);
return [data, function forceUpdateHook() {
setData({});
}];
}

22
src/app/hooks/useStore.js Normal file
View File

@@ -0,0 +1,22 @@
/* eslint-disable import/prefer-default-export */
import { useEffect, useRef } from 'react';
export function useStore(...args) {
const itemRef = useRef(null);
const getItem = () => itemRef.current;
const setItem = (event) => {
itemRef.current = event;
return itemRef.current;
};
useEffect(() => {
itemRef.current = null;
return () => {
itemRef.current = null;
};
}, args);
return { getItem, setItem };
}

View File

@@ -2,27 +2,32 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './Dialog.scss'; import './Dialog.scss';
import { twemojify } from '../../../util/twemojify';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Header, { TitleWrapper } from '../../atoms/header/Header'; import Header, { TitleWrapper } from '../../atoms/header/Header';
import ScrollView from '../../atoms/scroll/ScrollView'; import ScrollView from '../../atoms/scroll/ScrollView';
import RawModal from '../../atoms/modal/RawModal'; import RawModal from '../../atoms/modal/RawModal';
function Dialog({ function Dialog({
className, isOpen, title, className, isOpen, title, onAfterOpen, onAfterClose,
contentOptions, onRequestClose, children, contentOptions, onRequestClose, closeFromOutside, children,
}) { }) {
return ( return (
<RawModal <RawModal
className={`${className === null ? '' : `${className} `}dialog-model`} className={`${className === null ? '' : `${className} `}dialog-model`}
isOpen={isOpen} isOpen={isOpen}
onAfterOpen={onAfterOpen}
onAfterClose={onAfterClose}
onRequestClose={onRequestClose} onRequestClose={onRequestClose}
closeFromOutside={closeFromOutside}
size="small" size="small"
> >
<div className="dialog"> <div className="dialog">
<div className="dialog__content"> <div className="dialog__content">
<Header> <Header>
<TitleWrapper> <TitleWrapper>
<Text variant="h2">{title}</Text> <Text variant="h2" weight="medium" primary>{twemojify(title)}</Text>
</TitleWrapper> </TitleWrapper>
{contentOptions} {contentOptions}
</Header> </Header>
@@ -42,7 +47,10 @@ function Dialog({
Dialog.defaultProps = { Dialog.defaultProps = {
className: null, className: null,
contentOptions: null, contentOptions: null,
onAfterOpen: null,
onAfterClose: null,
onRequestClose: null, onRequestClose: null,
closeFromOutside: true,
}; };
Dialog.propTypes = { Dialog.propTypes = {
@@ -50,7 +58,10 @@ Dialog.propTypes = {
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
contentOptions: PropTypes.node, contentOptions: PropTypes.node,
onAfterOpen: PropTypes.func,
onAfterClose: PropTypes.func,
onRequestClose: PropTypes.func, onRequestClose: PropTypes.func,
closeFromOutside: PropTypes.bool,
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
}; };

View File

@@ -0,0 +1,59 @@
/* eslint-disable react/prop-types */
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './FollowingMembers.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import { openReadReceipts } from '../../../client/action/navigation';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import { getUsersActionJsx } from '../../organisms/room/common';
function FollowingMembers({ roomTimeline }) {
const [followingMembers, setFollowingMembers] = useState([]);
const { roomId } = roomTimeline;
const mx = initMatrix.matrixClient;
const { roomsInput } = initMatrix;
const myUserId = mx.getUserId();
const handleOnMessageSent = () => setFollowingMembers([]);
useEffect(() => {
const updateFollowingMembers = () => {
setFollowingMembers(roomTimeline.getLiveReaders());
};
updateFollowingMembers();
roomTimeline.on(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
roomsInput.on(cons.events.roomsInput.MESSAGE_SENT, handleOnMessageSent);
return () => {
roomTimeline.removeListener(cons.events.roomTimeline.LIVE_RECEIPT, updateFollowingMembers);
roomsInput.removeListener(cons.events.roomsInput.MESSAGE_SENT, handleOnMessageSent);
};
}, [roomTimeline]);
const filteredM = followingMembers.filter((userId) => userId !== myUserId);
return filteredM.length !== 0 && (
<button
className="following-members"
onClick={() => openReadReceipts(roomId, followingMembers)}
type="button"
>
<RawIcon
size="extra-small"
src={TickMarkIC}
/>
<Text variant="b2">{getUsersActionJsx(roomId, filteredM, 'following the conversation.')}</Text>
</button>
);
}
FollowingMembers.propTypes = {
roomTimeline: PropTypes.shape({}).isRequired,
};
export default FollowingMembers;

View File

@@ -0,0 +1,31 @@
@use '../../partials/text';
.following-members {
width: 100%;
padding: 0 var(--sp-normal);
display: flex;
justify-content: flex-end;
align-items: center;
cursor: pointer;
& .ic-raw {
min-width: var(--ic-extra-small);
opacity: 0.4;
margin: 0 var(--sp-extra-tight);
}
& .text {
@extend .cp-txt__ellipsis;
color: var(--tc-surface-low);
b {
color: var(--tc-surface-normal);
}
}
&:hover,
&:focus {
background-color: var(--bg-surface-hover);
}
&:active {
background-color: var(--bg-surface-active);
}
}

View File

@@ -53,7 +53,7 @@ function ImageUpload({
size="large" size="large"
/> />
<div className={`img-upload__process ${uploadPromise === null ? ' img-upload__process--stopped' : ''}`}> <div className={`img-upload__process ${uploadPromise === null ? ' img-upload__process--stopped' : ''}`}>
{uploadPromise === null && <Text variant="b3">Upload</Text>} {uploadPromise === null && <Text variant="b3" weight="bold">Upload</Text>}
{uploadPromise !== null && <Spinner size="small" />} {uploadPromise !== null && <Spinner size="small" />}
</div> </div>
</button> </button>

View File

@@ -24,7 +24,6 @@
z-index: 1; z-index: 1;
& .text { & .text {
text-transform: uppercase; text-transform: uppercase;
font-weight: 600;
color: white; color: white;
} }
&--stopped { &--stopped {

View File

@@ -1,108 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import './ImportE2ERoomKeys.scss';
import EventEmitter from 'events';
import initMatrix from '../../../client/initMatrix';
import decryptMegolmKeyFile from '../../../util/decryptE2ERoomKeys';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import Button from '../../atoms/button/Button';
import Input from '../../atoms/input/Input';
import Spinner from '../../atoms/spinner/Spinner';
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
const viewEvent = new EventEmitter();
async function tryDecrypt(file, password) {
try {
const arrayBuffer = await file.arrayBuffer();
viewEvent.emit('importing', true);
viewEvent.emit('status', 'Decrypting file...');
const keys = await decryptMegolmKeyFile(arrayBuffer, password);
viewEvent.emit('status', 'Decrypting messages...');
await initMatrix.matrixClient.importRoomKeys(JSON.parse(keys));
viewEvent.emit('status', null);
viewEvent.emit('importing', false);
} catch (e) {
viewEvent.emit('status', e.friendlyText || 'Something went wrong!');
viewEvent.emit('importing', false);
}
}
function ImportE2ERoomKeys() {
const [keyFile, setKeyFile] = useState(null);
const [status, setStatus] = useState(null);
const [isImporting, setIsImporting] = useState(false);
const inputRef = useRef(null);
const passwordRef = useRef(null);
useEffect(() => {
const handleIsImporting = (isImp) => setIsImporting(isImp);
const handleStatus = (msg) => setStatus(msg);
viewEvent.on('importing', handleIsImporting);
viewEvent.on('status', handleStatus);
return () => {
viewEvent.removeListener('importing', handleIsImporting);
viewEvent.removeListener('status', handleStatus);
};
}, []);
function importE2ERoomKeys() {
const password = passwordRef.current.value;
if (password === '' || keyFile === null) return;
if (isImporting) return;
tryDecrypt(keyFile, password);
}
function handleFileChange(e) {
const file = e.target.files.item(0);
passwordRef.current.value = '';
setKeyFile(file);
setStatus(null);
}
function removeImportKeysFile() {
inputRef.current.value = null;
passwordRef.current.value = null;
setKeyFile(null);
setStatus(null);
}
useEffect(() => {
if (!isImporting && status === null) {
removeImportKeysFile();
}
}, [isImporting, status]);
return (
<div className="import-e2e-room-keys">
<input ref={inputRef} onChange={handleFileChange} style={{ display: 'none' }} type="file" />
<form className="import-e2e-room-keys__form" onSubmit={(e) => { e.preventDefault(); importE2ERoomKeys(); }}>
{ keyFile !== null && (
<div className="import-e2e-room-keys__file">
<IconButton onClick={removeImportKeysFile} src={CirclePlusIC} tooltip="Remove file" />
<Text>{keyFile.name}</Text>
</div>
)}
{keyFile === null && <Button onClick={() => inputRef.current.click()}>Import keys</Button>}
<Input forwardRef={passwordRef} type="password" placeholder="Password" required />
<Button disabled={isImporting} variant="primary" type="submit">Decrypt</Button>
</form>
{ isImporting && status !== null && (
<div className="import-e2e-room-keys__process">
<Spinner size="small" />
<Text variant="b2">{status}</Text>
</div>
)}
{!isImporting && status !== null && <Text className="import-e2e-room-keys__error" variant="b2">{status}</Text>}
</div>
);
}
export default ImportE2ERoomKeys;

View File

@@ -0,0 +1,100 @@
import React, { useState, useEffect, useRef } from 'react';
import './ExportE2ERoomKeys.scss';
import FileSaver from 'file-saver';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import { encryptMegolmKeyFile } from '../../../util/cryptE2ERoomKeys';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import Input from '../../atoms/input/Input';
import Spinner from '../../atoms/spinner/Spinner';
import { useStore } from '../../hooks/useStore';
function ExportE2ERoomKeys() {
const isMountStore = useStore();
const [status, setStatus] = useState({
isOngoing: false,
msg: null,
type: cons.status.PRE_FLIGHT,
});
const passwordRef = useRef(null);
const confirmPasswordRef = useRef(null);
const exportE2ERoomKeys = async () => {
const password = passwordRef.current.value;
if (password !== confirmPasswordRef.current.value) {
setStatus({
isOngoing: false,
msg: 'Password does not match.',
type: cons.status.ERROR,
});
return;
}
setStatus({
isOngoing: true,
msg: 'Getting keys...',
type: cons.status.IN_FLIGHT,
});
try {
const keys = await initMatrix.matrixClient.exportRoomKeys();
if (isMountStore.getItem()) {
setStatus({
isOngoing: true,
msg: 'Encrypting keys...',
type: cons.status.IN_FLIGHT,
});
}
const encKeys = await encryptMegolmKeyFile(JSON.stringify(keys), password);
const blob = new Blob([encKeys], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, 'cinny-keys.txt');
if (isMountStore.getItem()) {
setStatus({
isOngoing: false,
msg: 'Successfully exported all keys.',
type: cons.status.SUCCESS,
});
}
} catch (e) {
if (isMountStore.getItem()) {
setStatus({
isOngoing: false,
msg: e.friendlyText || 'Failed to export keys. Please try again.',
type: cons.status.ERROR,
});
}
}
};
useEffect(() => {
isMountStore.setItem(true);
return () => {
isMountStore.setItem(false);
};
}, []);
return (
<div className="export-e2e-room-keys">
<form className="export-e2e-room-keys__form" onSubmit={(e) => { e.preventDefault(); exportE2ERoomKeys(); }}>
<Input forwardRef={passwordRef} type="password" placeholder="Password" required />
<Input forwardRef={confirmPasswordRef} type="password" placeholder="Confirm password" required />
<Button disabled={status.isOngoing} variant="primary" type="submit">Export</Button>
</form>
{ status.type === cons.status.IN_FLIGHT && (
<div className="import-e2e-room-keys__process">
<Spinner size="small" />
<Text variant="b2">{status.msg}</Text>
</div>
)}
{status.type === cons.status.SUCCESS && <Text className="import-e2e-room-keys__success" variant="b2">{status.msg}</Text>}
{status.type === cons.status.ERROR && <Text className="import-e2e-room-keys__error" variant="b2">{status.msg}</Text>}
</div>
);
}
export default ExportE2ERoomKeys;

View File

@@ -0,0 +1,28 @@
.export-e2e-room-keys {
margin-top: var(--sp-extra-tight);
&__form {
display: flex;
& > .input-container {
flex: 1;
min-width: 0;
}
& > *:nth-child(2) {
margin: 0 var(--sp-tight);
}
}
&__process {
margin-top: var(--sp-tight);
display: flex;
justify-content: center;
align-items: center;
& .text {
margin: 0 var(--sp-tight);
}
}
&__error {
margin-top: var(--sp-tight);
color: var(--tc-danger-high);
}
}

View File

@@ -0,0 +1,133 @@
import React, { useState, useEffect, useRef } from 'react';
import './ImportE2ERoomKeys.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import { decryptMegolmKeyFile } from '../../../util/cryptE2ERoomKeys';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import Button from '../../atoms/button/Button';
import Input from '../../atoms/input/Input';
import Spinner from '../../atoms/spinner/Spinner';
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
import { useStore } from '../../hooks/useStore';
function ImportE2ERoomKeys() {
const isMountStore = useStore();
const [keyFile, setKeyFile] = useState(null);
const [status, setStatus] = useState({
isOngoing: false,
msg: null,
type: cons.status.PRE_FLIGHT,
});
const inputRef = useRef(null);
const passwordRef = useRef(null);
async function tryDecrypt(file, password) {
try {
const arrayBuffer = await file.arrayBuffer();
if (isMountStore.getItem()) {
setStatus({
isOngoing: true,
msg: 'Decrypting file...',
type: cons.status.IN_FLIGHT,
});
}
const keys = await decryptMegolmKeyFile(arrayBuffer, password);
if (isMountStore.getItem()) {
setStatus({
isOngoing: true,
msg: 'Decrypting messages...',
type: cons.status.IN_FLIGHT,
});
}
await initMatrix.matrixClient.importRoomKeys(JSON.parse(keys));
if (isMountStore.getItem()) {
setStatus({
isOngoing: false,
msg: 'Successfully imported all keys.',
type: cons.status.SUCCESS,
});
inputRef.current.value = null;
passwordRef.current.value = null;
}
} catch (e) {
if (isMountStore.getItem()) {
setStatus({
isOngoing: false,
msg: e.friendlyText || 'Failed to decrypt keys. Please try again.',
type: cons.status.ERROR,
});
}
}
}
const importE2ERoomKeys = () => {
const password = passwordRef.current.value;
if (password === '' || keyFile === null) return;
if (status.isOngoing) return;
tryDecrypt(keyFile, password);
};
const handleFileChange = (e) => {
const file = e.target.files.item(0);
passwordRef.current.value = '';
setKeyFile(file);
setStatus({
isOngoing: false,
msg: null,
type: cons.status.PRE_FLIGHT,
});
};
const removeImportKeysFile = () => {
if (status.isOngoing) return;
inputRef.current.value = null;
passwordRef.current.value = null;
setKeyFile(null);
setStatus({
isOngoing: false,
msg: null,
type: cons.status.PRE_FLIGHT,
});
};
useEffect(() => {
isMountStore.setItem(true);
return () => {
isMountStore.setItem(false);
};
}, []);
return (
<div className="import-e2e-room-keys">
<input ref={inputRef} onChange={handleFileChange} style={{ display: 'none' }} type="file" />
<form className="import-e2e-room-keys__form" onSubmit={(e) => { e.preventDefault(); importE2ERoomKeys(); }}>
{ keyFile !== null && (
<div className="import-e2e-room-keys__file">
<IconButton onClick={removeImportKeysFile} src={CirclePlusIC} tooltip="Remove file" />
<Text>{keyFile.name}</Text>
</div>
)}
{keyFile === null && <Button onClick={() => inputRef.current.click()}>Import keys</Button>}
<Input forwardRef={passwordRef} type="password" placeholder="Password" required />
<Button disabled={status.isOngoing} variant="primary" type="submit">Decrypt</Button>
</form>
{ status.type === cons.status.IN_FLIGHT && (
<div className="import-e2e-room-keys__process">
<Spinner size="small" />
<Text variant="b2">{status.msg}</Text>
</div>
)}
{status.type === cons.status.SUCCESS && <Text className="import-e2e-room-keys__success" variant="b2">{status.msg}</Text>}
{status.type === cons.status.ERROR && <Text className="import-e2e-room-keys__error" variant="b2">{status.msg}</Text>}
</div>
);
}
export default ImportE2ERoomKeys;

View File

@@ -1,3 +1,5 @@
@use '../../partials/text';
@use '../../partials/dir';
.import-e2e-room-keys { .import-e2e-room-keys {
&__file { &__file {
@@ -22,17 +24,9 @@
} }
& .text { & .text {
margin-left: var(--sp-tight); @extend .cp-txt__ellipsis;
margin-right: var(--sp-loose); @include dir.side(margin, var(--sp-tight), var(--sp-loose));
max-width: 86px; max-width: 86px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
[dir=rtl] {
margin-right: var(--sp-tight);
margin-left: var(--sp-loose);
}
} }
} }
@@ -58,6 +52,10 @@
} }
&__error { &__error {
margin-top: var(--sp-tight); margin-top: var(--sp-tight);
color: var(--bg-danger); color: var(--tc-danger-high);
}
&__success {
margin-top: var(--sp-tight);
color: var(--tc-positive-high);
} }
} }

View File

@@ -1,3 +1,5 @@
@use '../../partials/text';
.file-header { .file-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -5,11 +7,9 @@
min-height: 42px; min-height: 42px;
& .file-name { & .file-name {
@extend .cp-txt__ellipsis;
flex: 1; flex: 1;
color: var(--tc-surface-low); color: var(--tc-surface-low);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
& a { & a {

View File

@@ -1,51 +1,36 @@
import React, { useRef } from 'react'; /* eslint-disable react/prop-types */
import React, { useState, useEffect, useCallback, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './Message.scss'; import './Message.scss';
import Linkify from 'linkify-react'; import { twemojify } from '../../../util/twemojify';
import ReactMarkdown from 'react-markdown';
import gfm from 'remark-gfm'; import initMatrix from '../../../client/initMatrix';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { getUsername, getUsernameOfRoomMember, parseReply } from '../../../util/matrixUtil';
import { coy } from 'react-syntax-highlighter/dist/esm/styles/prism'; import colorMXID from '../../../util/colorMXID';
import parse from 'html-react-parser'; import { getEventCords } from '../../../util/common';
import twemoji from 'twemoji'; import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
import { getUsername } from '../../../util/matrixUtil'; import {
openEmojiBoard, openProfileViewer, openReadReceipts, replyTo,
} from '../../../client/action/navigation';
import { sanitizeCustomHtml } from '../../../util/sanitize';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon'; import RawIcon from '../../atoms/system-icons/RawIcon';
import Button from '../../atoms/button/Button'; import Button from '../../atoms/button/Button';
import Tooltip from '../../atoms/tooltip/Tooltip'; import Tooltip from '../../atoms/tooltip/Tooltip';
import Input from '../../atoms/input/Input'; import Input from '../../atoms/input/Input';
import Avatar from '../../atoms/avatar/Avatar';
import IconButton from '../../atoms/button/IconButton';
import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu';
import * as Media from '../media/Media';
import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg'; import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg';
import EmojiAddIC from '../../../../public/res/ic/outlined/emoji-add.svg';
const components = { import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
code({ import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
// eslint-disable-next-line react/prop-types import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
inline, className, children, import BinIC from '../../../../public/res/ic/outlined/bin.svg';
}) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={coy}
language={match[1]}
PreTag="div"
showLineNumbers
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className}>{String(children)}</code>
);
},
};
function linkifyContent(content) {
return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
}
function genMarkdown(content) {
return <ReactMarkdown remarkPlugins={[gfm]} components={components} linkTarget="_blank">{content}</ReactMarkdown>;
}
function PlaceholderMessage() { function PlaceholderMessage() {
return ( return (
@@ -55,7 +40,7 @@ function PlaceholderMessage() {
</div> </div>
<div className="ph-msg__main-container"> <div className="ph-msg__main-container">
<div className="ph-msg__header" /> <div className="ph-msg__header" />
<div className="ph-msg__content"> <div className="ph-msg__body">
<div /> <div />
<div /> <div />
<div /> <div />
@@ -66,35 +51,52 @@ function PlaceholderMessage() {
); );
} }
function MessageHeader({ const MessageAvatar = React.memo(({
userId, name, color, time, roomId, mEvent, userId, username,
}) { }) => {
const avatarSrc = mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop');
return ( return (
<div className="message__header"> <div className="message__avatar-container">
<div style={{ color }} className="message__profile"> <button type="button" onClick={() => openProfileViewer(userId, roomId)}>
<Text variant="b1">{name}</Text> <Avatar imageSrc={avatarSrc} text={username} bgColor={colorMXID(userId)} size="small" />
<Text variant="b1">{userId}</Text> </button>
</div>
<div className="message__time">
<Text variant="b3">{time}</Text>
</div>
</div> </div>
); );
} });
const MessageHeader = React.memo(({
userId, username, time,
}) => (
<div className="message__header">
<Text
style={{ color: colorMXID(userId) }}
className="message__profile"
variant="b1"
weight="medium"
span
>
<span>{twemojify(username)}</span>
<span>{twemojify(userId)}</span>
</Text>
<div className="message__time">
<Text variant="b3">{time}</Text>
</div>
</div>
));
MessageHeader.propTypes = { MessageHeader.propTypes = {
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, username: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
time: PropTypes.string.isRequired, time: PropTypes.string.isRequired,
}; };
function MessageReply({ name, color, content }) { function MessageReply({ name, color, body }) {
return ( return (
<div className="message__reply"> <div className="message__reply">
<Text variant="b2"> <Text variant="b2">
<RawIcon color={color} size="extra-small" src={ReplyArrowIC} /> <RawIcon color={color} size="extra-small" src={ReplyArrowIC} />
<span style={{ color }}>{name}</span> <span style={{ color }}>{twemojify(name)}</span>
<>{` ${content}`}</> {' '}
{twemojify(body)}
</Text> </Text>
</div> </div>
); );
@@ -103,54 +105,122 @@ function MessageReply({ name, color, content }) {
MessageReply.propTypes = { MessageReply.propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
color: PropTypes.string.isRequired, color: PropTypes.string.isRequired,
content: PropTypes.string.isRequired, body: PropTypes.string.isRequired,
}; };
function MessageContent({ const MessageReplyWrapper = React.memo(({ roomTimeline, eventId }) => {
senderName, const [reply, setReply] = useState(null);
content, const isMountedRef = useRef(true);
isMarkdown,
isEdited, useEffect(() => {
msgType, const mx = initMatrix.matrixClient;
}) { const timelineSet = roomTimeline.getUnfilteredTimelineSet();
const loadReply = async () => {
const eTimeline = await mx.getEventTimeline(timelineSet, eventId);
await roomTimeline.decryptAllEventsOfTimeline(eTimeline);
const mEvent = eTimeline.getTimelineSet().findEventById(eventId);
const rawBody = mEvent.getContent().body;
const username = getUsernameOfRoomMember(mEvent.sender);
if (isMountedRef.current === false) return;
const fallbackBody = mEvent.isRedacted() ? '*** This message has been deleted ***' : '*** Unable to load reply content ***';
setReply({
to: username,
color: colorMXID(mEvent.getSender()),
body: parseReply(rawBody)?.body ?? rawBody ?? fallbackBody,
event: mEvent,
});
};
loadReply();
return () => {
isMountedRef.current = false;
};
}, []);
const focusReply = () => {
if (reply?.event.isRedacted()) return;
roomTimeline.loadEventTimeline(eventId);
};
return ( return (
<div className="message__content"> <div
<div className="text text-b1"> className="message__reply-wrapper"
{ msgType === 'm.emote' && `* ${senderName} ` } onClick={focusReply}
{ isMarkdown ? genMarkdown(content) : linkifyContent(content) } onKeyDown={focusReply}
</div> role="button"
{ isEdited && <Text className="message__content-edited" variant="b3">(edited)</Text>} tabIndex="0"
>
{reply !== null && <MessageReply name={reply.to} color={reply.color} body={reply.body} />}
</div> </div>
); );
} });
MessageContent.defaultProps = { MessageReplyWrapper.propTypes = {
isMarkdown: false, roomTimeline: PropTypes.shape({}).isRequired,
isEdited: false, eventId: PropTypes.string.isRequired,
};
MessageContent.propTypes = {
senderName: PropTypes.string.isRequired,
content: PropTypes.node.isRequired,
isMarkdown: PropTypes.bool,
isEdited: PropTypes.bool,
msgType: PropTypes.string.isRequired,
}; };
function MessageEdit({ content, onSave, onCancel }) { const MessageBody = React.memo(({
senderName,
body,
isCustomHTML,
isEdited,
msgType,
}) => {
// if body is not string it is a React element.
if (typeof body !== 'string') return <div className="message__body">{body}</div>;
const content = isCustomHTML
? twemojify(sanitizeCustomHtml(body), undefined, true, false)
: <p>{twemojify(body, undefined, true)}</p>;
return (
<div className="message__body">
<div className="text text-b1">
{ msgType === 'm.emote' && (
<>
{'* '}
{twemojify(senderName)}
{' '}
</>
)}
{ content }
</div>
{ isEdited && <Text className="message__body-edited" variant="b3">(edited)</Text>}
</div>
);
});
MessageBody.defaultProps = {
isCustomHTML: false,
isEdited: false,
msgType: null,
};
MessageBody.propTypes = {
senderName: PropTypes.string.isRequired,
body: PropTypes.node.isRequired,
isCustomHTML: PropTypes.bool,
isEdited: PropTypes.bool,
msgType: PropTypes.string,
};
function MessageEdit({ body, onSave, onCancel }) {
const editInputRef = useRef(null); const editInputRef = useRef(null);
function handleKeyDown(e) { const handleKeyDown = (e) => {
if (e.keyCode === 13 && e.shiftKey === false) { if (e.keyCode === 13 && e.shiftKey === false) {
e.preventDefault(); e.preventDefault();
onSave(editInputRef.current.value); onSave(editInputRef.current.value);
} }
} };
return ( return (
<form className="message__edit" onSubmit={(e) => { e.preventDefault(); onSave(editInputRef.current.value); }}> <form className="message__edit" onSubmit={(e) => { e.preventDefault(); onSave(editInputRef.current.value); }}>
<Input <Input
forwardRef={editInputRef} forwardRef={editInputRef}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
value={content} value={body}
placeholder="Edit message" placeholder="Edit message"
required required
resizable resizable
@@ -163,21 +233,43 @@ function MessageEdit({ content, onSave, onCancel }) {
); );
} }
MessageEdit.propTypes = { MessageEdit.propTypes = {
content: PropTypes.string.isRequired, body: PropTypes.string.isRequired,
onSave: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired,
}; };
function MessageReactionGroup({ children }) { function getMyEmojiEvent(emojiKey, eventId, roomTimeline) {
return ( const mx = initMatrix.matrixClient;
<div className="message__reactions text text-b3 noselect"> const rEvents = roomTimeline.reactionTimeline.get(eventId);
{ children } let rEvent = null;
</div> rEvents?.find((rE) => {
); if (rE.getRelation() === null) return false;
if (rE.getRelation().key === emojiKey && rE.getSender() === mx.getUserId()) {
rEvent = rE;
return true;
}
return false;
});
return rEvent;
}
function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
const myAlreadyReactEvent = getMyEmojiEvent(emojiKey, eventId, roomTimeline);
if (myAlreadyReactEvent) {
const rId = myAlreadyReactEvent.getId();
if (rId.startsWith('~')) return;
redactEvent(roomId, rId);
return;
}
sendReaction(roomId, eventId, emojiKey);
}
function pickEmoji(e, roomId, eventId, roomTimeline) {
openEmojiBoard(getEventCords(e), (emoji) => {
toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline);
e.target.click();
});
} }
MessageReactionGroup.propTypes = {
children: PropTypes.node.isRequired,
};
function genReactionMsg(userIds, reaction) { function genReactionMsg(userIds, reaction) {
const genLessContText = (text) => <span style={{ opacity: '.6' }}>{text}</span>; const genLessContText = (text) => <span style={{ opacity: '.6' }}>{text}</span>;
@@ -193,114 +285,390 @@ function genReactionMsg(userIds, reaction) {
<> <>
{msg} {msg}
{genLessContText(' reacted with')} {genLessContText(' reacted with')}
{parse(twemoji.parse(reaction))} {twemojify(reaction, { className: 'react-emoji' })}
</> </>
); );
} }
function MessageReaction({ function MessageReaction({
reaction, users, isActive, onClick, reaction, count, users, isActive, onClick,
}) { }) {
return ( return (
<Tooltip <Tooltip
className="msg__reaction-tooltip" className="msg__reaction-tooltip"
content={<Text variant="b2">{genReactionMsg(users, reaction)}</Text>} content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction) : 'Unable to load who has reacted'}</Text>}
> >
<button <button
onClick={onClick} onClick={onClick}
type="button" type="button"
className={`msg__reaction${isActive ? ' msg__reaction--active' : ''}`} className={`msg__reaction${isActive ? ' msg__reaction--active' : ''}`}
> >
{ parse(twemoji.parse(reaction)) } { twemojify(reaction, { className: 'react-emoji' }) }
<Text variant="b3" className="msg__reaction-count">{users.length}</Text> <Text variant="b3" className="msg__reaction-count">{count}</Text>
</button> </button>
</Tooltip> </Tooltip>
); );
} }
MessageReaction.propTypes = { MessageReaction.propTypes = {
reaction: PropTypes.node.isRequired, reaction: PropTypes.node.isRequired,
count: PropTypes.number.isRequired,
users: PropTypes.arrayOf(PropTypes.string).isRequired, users: PropTypes.arrayOf(PropTypes.string).isRequired,
isActive: PropTypes.bool.isRequired, isActive: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
}; };
function MessageOptions({ children }) { function MessageReactionGroup({ roomTimeline, mEvent }) {
const { roomId, reactionTimeline } = roomTimeline;
const eventId = mEvent.getId();
const mx = initMatrix.matrixClient;
const reactions = {};
const eventReactions = reactionTimeline.get(eventId);
const addReaction = (key, count, senderId, isActive) => {
let reaction = reactions[key];
if (reaction === undefined) {
reaction = {
count: 0,
users: [],
isActive: false,
};
}
if (count) {
reaction.count = count;
} else {
reaction.users.push(senderId);
reaction.count = reaction.users.length;
if (isActive) reaction.isActive = isActive;
}
reactions[key] = reaction;
};
if (eventReactions) {
eventReactions.forEach((rEvent) => {
if (rEvent.getRelation() === null) return;
const reaction = rEvent.getRelation();
const senderId = rEvent.getSender();
const isActive = senderId === mx.getUserId();
addReaction(reaction.key, undefined, senderId, isActive);
});
} else {
// Use aggregated reactions
const aggregatedReaction = mEvent.getServerAggregatedRelation('m.annotation')?.chunk;
if (!aggregatedReaction) return null;
aggregatedReaction.forEach((reaction) => {
if (reaction.type !== 'm.reaction') return;
addReaction(reaction.key, reaction.count, undefined, false);
});
}
return ( return (
<div className="message__options"> <div className="message__reactions text text-b3 noselect">
{children} {
Object.keys(reactions).map((key) => (
<MessageReaction
key={key}
reaction={key}
count={reactions[key].count}
users={reactions[key].users}
isActive={reactions[key].isActive}
onClick={() => {
toggleEmoji(roomId, eventId, key, roomTimeline);
}}
/>
))
}
<IconButton
onClick={(e) => {
pickEmoji(e, roomId, eventId, roomTimeline);
}}
src={EmojiAddIC}
size="extra-small"
tooltip="Add reaction"
/>
</div> </div>
); );
} }
MessageOptions.propTypes = { MessageReactionGroup.propTypes = {
children: PropTypes.node.isRequired, roomTimeline: PropTypes.shape({}).isRequired,
mEvent: PropTypes.shape({}).isRequired,
}; };
function Message({ function isMedia(mE) {
avatar, header, reply, content, editContent, reactions, options, return (
msgType, mE.getContent()?.msgtype === 'm.file'
}) { || mE.getContent()?.msgtype === 'm.image'
const className = [ || mE.getContent()?.msgtype === 'm.audio'
'message', || mE.getContent()?.msgtype === 'm.video'
header === null ? ' message--content-only' : ' message--full', || mE.getType() === 'm.sticker'
]; );
}
const MessageOptions = React.memo(({
roomTimeline, mEvent, edit, reply,
}) => {
const { roomId, room } = roomTimeline;
const mx = initMatrix.matrixClient;
const eventId = mEvent.getId();
const senderId = mEvent.getSender();
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel;
const canIRedact = room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel);
return (
<div className="message__options">
<IconButton
onClick={(e) => pickEmoji(e, roomId, eventId, roomTimeline)}
src={EmojiAddIC}
size="extra-small"
tooltip="Add reaction"
/>
<IconButton
onClick={() => reply()}
src={ReplyArrowIC}
size="extra-small"
tooltip="Reply"
/>
{(senderId === mx.getUserId() && !isMedia(mEvent)) && (
<IconButton
onClick={() => edit(true)}
src={PencilIC}
size="extra-small"
tooltip="Edit"
/>
)}
<ContextMenu
content={() => (
<>
<MenuHeader>Options</MenuHeader>
<MenuItem
iconSrc={TickMarkIC}
onClick={() => openReadReceipts(roomId, roomTimeline.getEventReaders(mEvent))}
>
Read receipts
</MenuItem>
{(canIRedact || senderId === mx.getUserId()) && (
<>
<MenuBorder />
<MenuItem
variant="danger"
iconSrc={BinIC}
onClick={() => {
if (window.confirm('Are you sure you want to delete this event')) {
redactEvent(roomId, eventId);
}
}}
>
Delete
</MenuItem>
</>
)}
</>
)}
render={(toggleMenu) => (
<IconButton
onClick={toggleMenu}
src={VerticalMenuIC}
size="extra-small"
tooltip="Options"
/>
)}
/>
</div>
);
});
MessageOptions.propTypes = {
roomTimeline: PropTypes.shape({}).isRequired,
mEvent: PropTypes.shape({}).isRequired,
edit: PropTypes.func.isRequired,
reply: PropTypes.func.isRequired,
};
function genMediaContent(mE) {
const mx = initMatrix.matrixClient;
const mContent = mE.getContent();
if (!mContent || !mContent.body) return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
let mediaMXC = mContent?.url;
const isEncryptedFile = typeof mediaMXC === 'undefined';
if (isEncryptedFile) mediaMXC = mContent?.file?.url;
let thumbnailMXC = mContent?.info?.thumbnail_url;
if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
let msgType = mE.getContent()?.msgtype;
if (mE.getType() === 'm.sticker') msgType = 'm.image';
switch (msgType) { switch (msgType) {
case 'm.text': case 'm.file':
className.push('message--type-text'); return (
break; <Media.File
case 'm.emote': name={mContent.body}
className.push('message--type-emote'); link={mx.mxcUrlToHttp(mediaMXC)}
break; type={mContent.info?.mimetype}
case 'm.notice': file={mContent.file || null}
className.push('message--type-notice'); />
break; );
case 'm.image':
return (
<Media.Image
name={mContent.body}
width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
link={mx.mxcUrlToHttp(mediaMXC)}
file={isEncryptedFile ? mContent.file : null}
type={mContent.info?.mimetype}
/>
);
case 'm.audio':
return (
<Media.Audio
name={mContent.body}
link={mx.mxcUrlToHttp(mediaMXC)}
type={mContent.info?.mimetype}
file={mContent.file || null}
/>
);
case 'm.video':
if (typeof thumbnailMXC === 'undefined') {
thumbnailMXC = mContent.info?.thumbnail_file?.url || null;
}
return (
<Media.Video
name={mContent.body}
link={mx.mxcUrlToHttp(mediaMXC)}
thumbnail={thumbnailMXC === null ? null : mx.mxcUrlToHttp(thumbnailMXC)}
thumbnailFile={isEncryptedFile ? mContent.info?.thumbnail_file : null}
thumbnailType={mContent.info?.thumbnail_info?.mimetype || null}
width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
file={isEncryptedFile ? mContent.file : null}
type={mContent.info?.mimetype}
/>
);
default: default:
return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
}
}
function getEditedBody(editedMEvent) {
const newContent = editedMEvent.getContent()['m.new_content'];
if (typeof newContent === 'undefined') return [null, false, null];
const isCustomHTML = newContent.format === 'org.matrix.custom.html';
const parsedContent = parseReply(newContent.body);
if (parsedContent === null) {
return [newContent.body, isCustomHTML, newContent.formatted_body ?? null];
}
return [parsedContent.body, isCustomHTML, newContent.formatted_body ?? null];
}
function Message({
mEvent, isBodyOnly, roomTimeline, focus, time,
}) {
const [isEditing, setIsEditing] = useState(false);
const { roomId, editedTimeline, reactionTimeline } = roomTimeline;
const className = ['message', (isBodyOnly ? 'message--body-only' : 'message--full')];
if (focus) className.push('message--focus');
const content = mEvent.getContent();
const eventId = mEvent.getId();
const msgType = content?.msgtype;
const senderId = mEvent.getSender();
let { body } = content;
const username = getUsernameOfRoomMember(mEvent.sender);
const edit = useCallback(() => {
setIsEditing(true);
}, []);
const reply = useCallback(() => {
replyTo(senderId, eventId, body);
}, [body]);
if (body === undefined) return null;
if (msgType === 'm.emote') className.push('message--type-emote');
let isCustomHTML = content.format === 'org.matrix.custom.html';
const isEdited = editedTimeline.has(eventId);
const haveReactions = reactionTimeline.has(eventId) || !!mEvent.getServerAggregatedRelation('m.annotation');
const isReply = !!mEvent.replyEventId;
let customHTML = isCustomHTML ? content.formatted_body : null;
if (isEdited) {
const editedList = editedTimeline.get(eventId);
const editedMEvent = editedList[editedList.length - 1];
[body, isCustomHTML, customHTML] = getEditedBody(editedMEvent);
if (typeof body !== 'string') return null;
}
if (isReply) {
body = parseReply(body)?.body ?? body;
} }
return ( return (
<div className={className.join(' ')}> <div className={className.join(' ')}>
<div className="message__avatar-container"> {
{avatar !== null && avatar} isBodyOnly
</div> ? <div className="message__avatar-container" />
: <MessageAvatar roomId={roomId} mEvent={mEvent} userId={senderId} username={username} />
}
<div className="message__main-container"> <div className="message__main-container">
{header !== null && header} {!isBodyOnly && (
{reply !== null && reply} <MessageHeader userId={senderId} username={username} time={time} />
{content !== null && content} )}
{editContent !== null && editContent} {isReply && (
{reactions !== null && reactions} <MessageReplyWrapper
{options !== null && options} roomTimeline={roomTimeline}
eventId={mEvent.replyEventId}
/>
)}
{!isEditing && (
<MessageBody
senderName={username}
isCustomHTML={isCustomHTML}
body={isMedia(mEvent) ? genMediaContent(mEvent) : customHTML ?? body}
msgType={msgType}
isEdited={isEdited}
/>
)}
{isEditing && (
<MessageEdit
body={body}
onSave={(newBody) => {
if (newBody !== body) {
initMatrix.roomsInput.sendEditedMessage(roomId, mEvent, newBody);
}
setIsEditing(false);
}}
onCancel={() => setIsEditing(false)}
/>
)}
{haveReactions && (
<MessageReactionGroup roomTimeline={roomTimeline} mEvent={mEvent} />
)}
{!isEditing && (
<MessageOptions
roomTimeline={roomTimeline}
mEvent={mEvent}
edit={edit}
reply={reply}
/>
)}
</div> </div>
</div> </div>
); );
} }
Message.defaultProps = { Message.defaultProps = {
avatar: null, isBodyOnly: false,
header: null, focus: false,
reply: null,
content: null,
editContent: null,
reactions: null,
options: null,
msgType: 'm.text',
}; };
Message.propTypes = { Message.propTypes = {
avatar: PropTypes.node, mEvent: PropTypes.shape({}).isRequired,
header: PropTypes.node, isBodyOnly: PropTypes.bool,
reply: PropTypes.node, roomTimeline: PropTypes.shape({}).isRequired,
content: PropTypes.node, focus: PropTypes.bool,
editContent: PropTypes.node, time: PropTypes.string.isRequired,
reactions: PropTypes.node,
options: PropTypes.node,
msgType: PropTypes.string,
}; };
export { export { Message, MessageReply, PlaceholderMessage };
Message,
MessageHeader,
MessageReply,
MessageContent,
MessageEdit,
MessageReactionGroup,
MessageReaction,
MessageOptions,
PlaceholderMessage,
};

View File

@@ -1,9 +1,11 @@
@use '../../atoms/scroll/scrollbar'; @use '../../atoms/scroll/scrollbar';
@use '../../partials/text';
@use '../../partials/dir';
.message, .message,
.ph-msg { .ph-msg {
padding: var(--sp-ultra-tight) var(--sp-normal); padding: var(--sp-ultra-tight);
padding-right: var(--sp-extra-tight); @include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
display: flex; display: flex;
&:hover { &:hover {
@@ -12,27 +14,21 @@
display: flex; display: flex;
} }
} }
[dir=rtl] & {
padding: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
&__avatar-container { &__avatar-container {
padding-top: 6px; padding-top: 6px;
margin-right: var(--sp-tight); @include dir.side(margin, 0, var(--sp-tight));
& .avatar-container {
transition: transform 200ms var(--fluid-push);
&:hover {
transform: translateY(-4px);
}
}
& button { & button {
cursor: pointer; cursor: pointer;
} display: flex;
[dir=rtl] & {
margin: {
left: var(--sp-tight);
right: 0;
}
} }
} }
@@ -46,7 +42,7 @@
.message { .message {
&--full + &--full, &--full + &--full,
&--content-only + &--full, &--body-only + &--full,
& + .timeline-change, & + .timeline-change,
.timeline-change + & { .timeline-change + & {
margin-top: var(--sp-normal); margin-top: var(--sp-normal);
@@ -54,6 +50,12 @@
&__avatar-container { &__avatar-container {
width: var(--av-small); width: var(--av-small);
} }
&--focus {
--ltr: inset 2px 0 0 var(--bg-caution);
--rtl: inset -2px 0 0 var(--bg-caution);
@include dir.prop(box-shadow, var(--ltr), var(--rtl));
background-color: var(--bg-caution-hover);
}
} }
.ph-msg { .ph-msg {
@@ -65,39 +67,33 @@
} }
&__header, &__header,
&__content > div { &__body > div {
margin: var(--sp-ultra-tight) 0; margin: var(--sp-ultra-tight);
margin-right: var(--sp-extra-tight); @include dir.side(margin, 0, var(--sp-extra-tight));
height: var(--fs-b1); height: var(--fs-b1);
width: 100%; width: 100%;
max-width: 100px; max-width: 100px;
background-color: var(--bg-surface-hover); background-color: var(--bg-surface-hover);
border-radius: calc(var(--bo-radius) / 2); border-radius: calc(var(--bo-radius) / 2);
[dir=rtl] & {
margin: {
right: 0;
left: var(--sp-extra-tight);
}
}
} }
&__content { &__body {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
} }
&__content > div:nth-child(1n) { &__body > div:nth-child(1n) {
max-width: 10%; max-width: 10%;
} }
&__content > div:nth-child(2n) { &__body > div:nth-child(2n) {
max-width: 50%; max-width: 50%;
} }
} }
.message__reply, .message__reply,
.message__content, .message__body,
.message__body__wrapper,
.message__edit, .message__edit,
.message__reactions { .message__reactions {
max-width: 640px; max-width: calc(100% - 88px);
min-width: 0; min-width: 0;
} }
@@ -109,24 +105,16 @@
& .message__profile { & .message__profile {
min-width: 0; min-width: 0;
color: var(--tc-surface-high); color: var(--tc-surface-high);
margin-right: var(--sp-tight); @include dir.side(margin, 0, var(--sp-tight));
[dir=rtl] & { & > span {
margin-left: var(--sp-tight); @extend .cp-txt__ellipsis;
margin-right: 0;
}
& > .text {
color: inherit; color: inherit;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
} }
& > .text:last-child { display: none; } & > span:last-child { display: none; }
&:hover { &:hover {
& > .text:first-child { display: none; } & > span:first-child { display: none; }
& > .text:last-child { display: block; } & > span:last-child { display: block; }
} }
} }
@@ -141,20 +129,31 @@
} }
} }
.message__reply { .message__reply {
&-wrapper {
min-height: 20px;
cursor: pointer;
&:empty {
border-radius: calc(var(--bo-radius) / 2);
background-color: var(--bg-surface-hover);
max-width: 200px;
cursor: auto;
}
&:hover .text {
color: var(--tc-surface-high);
}
}
.text { .text {
@extend .cp-txt__ellipsis;
color: var(--tc-surface-low); color: var(--tc-surface-low);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.ic-raw { .ic-raw {
width: 16px; width: 16px;
height: 14px; height: 14px;
} }
} }
.message__content { .message__body {
word-break: break-word; word-break: break-word;
& > .text > * { & > .text > * {
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -162,6 +161,48 @@
& a { & a {
word-break: break-word; word-break: break-word;
} }
& span[data-mx-pill] {
background-color: hsla(0, 0%, 64%, 0.15);
padding: 0 2px;
border-radius: 4px;
cursor: pointer;
font-weight: var(--fw-medium);
&:hover {
background-color: hsla(0, 0%, 64%, 0.3);
color: var(--tc-surface-high);
}
&[data-mx-ping] {
background-color: var(--bg-ping);
&:hover {
background-color: var(--bg-ping-hover);
}
}
}
& span[data-mx-spoiler] {
border-radius: 4px;
background-color: rgba(124, 124, 124, 0.5);
color:transparent;
cursor: pointer;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
& > * {
opacity: 0;
}
}
.data-mx-spoiler--visible {
background-color: var(--bg-surface-active) !important;
color: inherit !important;
user-select: initial !important;
& > * {
opacity: inherit !important;
}
}
&-edited { &-edited {
color: var(--tc-surface-low); color: var(--tc-surface-low);
} }
@@ -169,10 +210,8 @@
.message__edit { .message__edit {
padding: var(--sp-extra-tight) 0; padding: var(--sp-extra-tight) 0;
&-btns button { &-btns button {
margin: var(--sp-tight) var(--sp-tight) 0 0; margin: var(--sp-tight) 0 0 0;
[dir=rtl] & { @include dir.side(margin, 0, var(--sp-tight));
margin: var(--sp-tight) 0 0 var(--sp-tight);
}
} }
} }
.message__reactions { .message__reactions {
@@ -189,7 +228,8 @@
} }
} }
.msg__reaction { .msg__reaction {
margin: var(--sp-extra-tight) var(--sp-extra-tight) 0 0; margin: var(--sp-extra-tight) 0 0 0;
@include dir.side(margin, 0, var(--sp-extra-tight));
padding: 0 var(--sp-ultra-tight); padding: 0 var(--sp-ultra-tight);
min-height: 26px; min-height: 26px;
display: inline-flex; display: inline-flex;
@@ -200,7 +240,7 @@
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
& .emoji { & .react-emoji {
width: 14px; width: 14px;
height: 14px; height: 14px;
margin: 2px; margin: 2px;
@@ -209,20 +249,13 @@
margin: 0 var(--sp-ultra-tight); margin: 0 var(--sp-ultra-tight);
color: var(--tc-surface-normal) color: var(--tc-surface-normal)
} }
&-tooltip .emoji { &-tooltip .react-emoji {
width: 14px; width: 14px;
height: 14px; height: 14px;
margin: 0 var(--sp-ultra-tight); margin: 0 var(--sp-ultra-tight);
margin-bottom: -2px; margin-bottom: -2px;
} }
[dir=rtl] & {
margin: {
right: 0;
left: var(--sp-extra-tight);
}
}
@media (hover: hover) { @media (hover: hover) {
&:hover { &:hover {
background-color: var(--bg-surface-hover); background-color: var(--bg-surface-hover);
@@ -248,53 +281,40 @@
.message__options { .message__options {
position: absolute; position: absolute;
top: 0; top: 0;
right: 60px; @include dir.prop(right, 60px, unset);
z-index: 999; @include dir.prop(left, unset, 60px);
z-index: 99;
transform: translateY(-50%); transform: translateY(-50%);
border-radius: var(--bo-radius); border-radius: var(--bo-radius);
box-shadow: var(--bs-surface-border); box-shadow: var(--bs-surface-border);
background-color: var(--bg-surface-low); background-color: var(--bg-surface-low);
display: none; display: none;
[dir=rtl] & {
left: 60px;
right: unset;
}
}
@media (min-width: 1620px) {
.message__options {
right: unset;
left: 770px;
[dir=rtl] {
left: unset;
right: 770px
}
}
} }
// markdown formating // markdown formating
.message__content { .message__body {
& h1, & h1,
& h2 { & h2 {
color: var(--tc-surface-high); color: var(--tc-surface-high);
margin: var(--sp-extra-loose) 0 var(--sp-normal); margin: var(--sp-loose) 0 var(--sp-normal);
line-height: var(--lh-h1); line-height: var(--lh-h1);
} }
& h3, & h3,
& h4 { & h4 {
color: var(--tc-surface-high); color: var(--tc-surface-high);
margin: var(--sp-loose) 0 var(--sp-tight); margin: var(--sp-normal) 0 var(--sp-tight);
line-height: var(--lh-h2); line-height: var(--lh-h2);
} }
& h5, & h5,
& h6 { & h6 {
color: var(--tc-surface-high); color: var(--tc-surface-high);
margin: var(--sp-normal) 0 var(--sp-extra-tight); margin: var(--sp-tight) 0 var(--sp-extra-tight);
line-height: var(--lh-s1); line-height: var(--lh-s1);
} }
& hr { & hr {
border-color: var(--bg-surface-border); border-color: var(--bg-divider);
} }
.text img { .text img {
@@ -336,60 +356,59 @@
@include scrollbar.scroll__h; @include scrollbar.scroll__h;
@include scrollbar.scroll--auto-hide; @include scrollbar.scroll--auto-hide;
} }
& pre code { & pre {
color: var(--tc-surface-normal) !important; display: inline-block;
max-width: 100%;
@include scrollbar.scroll;
@include scrollbar.scroll__h;
@include scrollbar.scroll--auto-hide;
& code {
color: var(--tc-surface-normal) !important;
white-space: pre;
}
} }
& blockquote { & blockquote {
padding-left: var(--sp-extra-tight); display: inline-block;
border-left: 4px solid var(--bg-surface-active); max-width: 100%;
@include dir.side(padding, var(--sp-extra-tight), 0);
@include dir.side(border, 4px solid var(--bg-surface-active), 0);
white-space: initial !important; white-space: initial !important;
& > * { & > * {
white-space: pre-wrap; white-space: pre-wrap;
} }
[dir=rtl] & {
padding: {
left: 0;
right: var(--sp-extra-tight);
}
border: {
left: none;
right: 4px solid var(--bg-surface-active);
}
}
} }
& ul, & ul,
& ol { & ol {
margin: var(--sp-ultra-tight) 0; margin: var(--sp-ultra-tight) 0;
padding-left: 24px; @include dir.side(padding, 24px, 0);
white-space: initial !important; white-space: initial !important;
& > * { & > * {
white-space: pre-wrap; white-space: pre-wrap;
} }
[dir=rtl] & {
padding: {
left: 0;
right: 24px;
}
}
} }
& ul.contains-task-list { & ul.contains-task-list {
padding: 0; padding: 0;
list-style: none; list-style: none;
} }
& table { & table {
display: inline-block;
max-width: 100%;
white-space: normal !important;
background-color: var(--bg-surface-hover); background-color: var(--bg-surface-hover);
border-radius: calc(var(--bo-radius) / 2); border-radius: calc(var(--bo-radius) / 2);
border-spacing: 0; border-spacing: 0;
border: 1px solid var(--bg-surface-border); border: 1px solid var(--bg-surface-border);
@include scrollbar.scroll;
@include scrollbar.scroll__h;
@include scrollbar.scroll--auto-hide;
& td, & th { & td, & th {
padding: var(--sp-extra-tight); padding: var(--sp-extra-tight);
border: 1px solid var(--bg-surface-border); border: 1px solid var(--bg-surface-border);
border-width: 0 1px 1px 0; border-width: 0 1px 1px 0;
white-space: pre;
&:last-child { &:last-child {
border-width: 0; border-width: 0;
border-bottom-width: 1px; border-bottom-width: 1px;
@@ -412,7 +431,7 @@
} }
.message.message--type-emote { .message.message--type-emote {
.message__content { .message__body {
font-style: italic; font-style: italic;
// Remove blockness of first `<p>` so that markdown emotes stay on one line. // Remove blockness of first `<p>` so that markdown emotes stay on one line.

View File

@@ -10,9 +10,10 @@ import LeaveArraowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
import InviteArraowIC from '../../../../public/res/ic/outlined/invite-arrow.svg'; import InviteArraowIC from '../../../../public/res/ic/outlined/invite-arrow.svg';
import InviteCancelArraowIC from '../../../../public/res/ic/outlined/invite-cancel-arrow.svg'; import InviteCancelArraowIC from '../../../../public/res/ic/outlined/invite-cancel-arrow.svg';
import UserIC from '../../../../public/res/ic/outlined/user.svg'; import UserIC from '../../../../public/res/ic/outlined/user.svg';
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
function TimelineChange({ variant, content, time, onClick }) { function TimelineChange({
variant, content, time, onClick,
}) {
let iconSrc; let iconSrc;
switch (variant) { switch (variant) {
@@ -31,9 +32,6 @@ function TimelineChange({ variant, content, time, onClick }) {
case 'avatar': case 'avatar':
iconSrc = UserIC; iconSrc = UserIC;
break; break;
case 'follow':
iconSrc = TickMarkIC;
break;
default: default:
iconSrc = JoinArraowIC; iconSrc = JoinArraowIC;
break; break;
@@ -47,7 +45,6 @@ function TimelineChange({ variant, content, time, onClick }) {
<div className="timeline-change__content"> <div className="timeline-change__content">
<Text variant="b2"> <Text variant="b2">
{content} {content}
{/* <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify> */}
</Text> </Text>
</div> </div>
<div className="timeline-change__time"> <div className="timeline-change__time">
@@ -66,7 +63,6 @@ TimelineChange.propTypes = {
variant: PropTypes.oneOf([ variant: PropTypes.oneOf([
'join', 'leave', 'invite', 'join', 'leave', 'invite',
'invite-cancel', 'avatar', 'other', 'invite-cancel', 'avatar', 'other',
'follow',
]), ]),
content: PropTypes.oneOfType([ content: PropTypes.oneOfType([
PropTypes.string, PropTypes.string,

View File

@@ -1,6 +1,9 @@
@use '../../partials/dir';
.timeline-change { .timeline-change {
padding: var(--sp-ultra-tight) var(--sp-normal); padding: var(--sp-ultra-tight);
padding-right: var(--sp-extra-tight); @include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; width: 100%;
@@ -9,13 +12,6 @@
background-color: var(--bg-surface-hover); background-color: var(--bg-surface-hover);
} }
[dir=rtl] & {
padding: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
&__avatar-container { &__avatar-container {
width: var(--av-small); width: var(--av-small);
display: inline-flex; display: inline-flex;

View File

@@ -2,6 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './PeopleSelector.scss'; import './PeopleSelector.scss';
import { twemojify } from '../../../util/twemojify';
import { blurOnBubbling } from '../../atoms/button/script'; import { blurOnBubbling } from '../../atoms/button/script';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
@@ -19,7 +21,7 @@ function PeopleSelector({
type="button" type="button"
> >
<Avatar imageSrc={avatarSrc} text={name} bgColor={color} size="extra-small" /> <Avatar imageSrc={avatarSrc} text={name} bgColor={color} size="extra-small" />
<Text className="people-selector__name" variant="b1">{name}</Text> <Text className="people-selector__name" variant="b1">{twemojify(name)}</Text>
{peopleRole !== null && <Text className="people-selector__role" variant="b3">{peopleRole}</Text>} {peopleRole !== null && <Text className="people-selector__role" variant="b3">{peopleRole}</Text>}
</button> </button>
</div> </div>

View File

@@ -1,17 +1,14 @@
@use '../../partials/text';
@use '../../partials/dir';
.people-selector { .people-selector {
width: 100%; width: 100%;
padding: var(--sp-extra-tight); padding: var(--sp-extra-tight);
padding-left: var(--sp-normal); @include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
display: flex; display: flex;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
[dir=rtl] & {
padding: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
@media (hover: hover) { @media (hover: hover) {
&:hover { &:hover {
background-color: var(--bg-surface-hover); background-color: var(--bg-surface-hover);
@@ -26,13 +23,11 @@
} }
&__name { &__name {
@extend .cp-txt__ellipsis;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin: 0 var(--sp-tight); margin: 0 var(--sp-tight);
color: var(--tc-surface-normal); color: var(--tc-surface-normal);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
} }
&__role { &__role {
color: var(--tc-surface-low); color: var(--tc-surface-low);

View File

@@ -2,6 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './PopupWindow.scss'; import './PopupWindow.scss';
import { twemojify } from '../../../util/twemojify';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton'; import IconButton from '../../atoms/button/IconButton';
import { MenuItem } from '../../atoms/context-menu/ContextMenu'; import { MenuItem } from '../../atoms/context-menu/ContextMenu';
@@ -66,7 +68,7 @@ function PopupWindow({
<Header> <Header>
<IconButton size="small" src={ChevronLeftIC} onClick={onRequestClose} tooltip="Back" /> <IconButton size="small" src={ChevronLeftIC} onClick={onRequestClose} tooltip="Back" />
<TitleWrapper> <TitleWrapper>
<Text variant="s1">{title}</Text> <Text variant="s1" weight="medium" primary>{twemojify(title)}</Text>
</TitleWrapper> </TitleWrapper>
{drawerOptions} {drawerOptions}
</Header> </Header>
@@ -82,7 +84,7 @@ function PopupWindow({
<div className="pw__content"> <div className="pw__content">
<Header> <Header>
<TitleWrapper> <TitleWrapper>
<Text variant="h2">{contentTitle !== null ? contentTitle : title}</Text> <Text variant="h2" weight="medium" primary>{twemojify(contentTitle !== null ? contentTitle : title)}</Text>
</TitleWrapper> </TitleWrapper>
{contentOptions} {contentOptions}
</Header> </Header>

View File

@@ -1,3 +1,5 @@
@use '../../partials/dir';
.pw-model { .pw-model {
--modal-height: 656px; --modal-height: 656px;
max-height: var(--modal-height) !important; max-height: var(--modal-height) !important;
@@ -16,14 +18,7 @@
&__drawer { &__drawer {
width: var(--popup-window-drawer-width); width: var(--popup-window-drawer-width);
background-color: var(--bg-surface-low); background-color: var(--bg-surface-low);
border-right: 1px solid var(--bg-surface-border); @include dir.side(border, none, 1px solid var(--bg-surface-border));
[dir=rtl] & {
border: {
right: none;
left: 1px solid var(--bg-surface-border);
}
}
} }
&__content { &__content {
flex: 1; flex: 1;
@@ -52,11 +47,9 @@
.pw__drawer { .pw__drawer {
& .header { & .header {
padding-left: var(--sp-tight); padding-left: var(--sp-tight);
@include dir.side(padding, var(--sp-tight), var(--sp-tight));
& .header__title-wrapper { & .header__title-wrapper {
margin: 0 var(--sp-extra-tight); @include dir.side(margin, var(--sp-ultra-tight), var(--sp-extra-tight));
}
[dir=rtl] & {
padding-right: var(--sp-tight);
} }
} }
} }
@@ -77,15 +70,8 @@
& .context-menu__item > button { & .context-menu__item > button {
border-radius: var(--bo-radius); border-radius: var(--bo-radius);
& .text {
color: var(--tc-surface-normal);
}
& .ic-raw { & .ic-raw {
margin-right: var(--sp-tight); @include dir.side(margin, 0, var(--sp-tight));
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-tight);
}
} }
} }
} }

View File

@@ -2,16 +2,12 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './RoomIntro.scss'; import './RoomIntro.scss';
import Linkify from 'linkify-react'; import { twemojify } from '../../../util/twemojify';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar'; import Avatar from '../../atoms/avatar/Avatar';
function linkifyContent(content) {
return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
}
function RoomIntro({ function RoomIntro({
roomId, avatarSrc, name, heading, desc, time, roomId, avatarSrc, name, heading, desc, time,
}) { }) {
@@ -19,8 +15,8 @@ function RoomIntro({
<div className="room-intro"> <div className="room-intro">
<Avatar imageSrc={avatarSrc} text={name} bgColor={colorMXID(roomId)} size="large" /> <Avatar imageSrc={avatarSrc} text={name} bgColor={colorMXID(roomId)} size="large" />
<div className="room-intro__content"> <div className="room-intro__content">
<Text className="room-intro__name" variant="h1">{heading}</Text> <Text className="room-intro__name" variant="h1" weight="medium" primary>{twemojify(heading)}</Text>
<Text className="room-intro__desc" variant="b1">{linkifyContent(desc)}</Text> <Text className="room-intro__desc" variant="b1">{twemojify(desc, undefined, true)}</Text>
{ time !== null && <Text className="room-intro__time" variant="b3">{time}</Text>} { time !== null && <Text className="room-intro__time" variant="b3">{time}</Text>}
</div> </div>
</div> </div>

View File

@@ -1,19 +1,14 @@
@use '../../partials/dir';
.room-intro { .room-intro {
margin-top: calc(2 * var(--sp-extra-loose)); margin-top: calc(2 * var(--sp-extra-loose));
margin-bottom: var(--sp-extra-loose); margin-bottom: var(--sp-extra-loose);
padding-left: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight)); --left-pad: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
padding-right: var(--sp-extra-tight); @include dir.side(padding, var(--left-pad), var(--sp-extra-tight));
[dir=rtl] & {
padding: {
left: var(--sp-extra-tight);
right: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight));
}
}
.room-intro__content { .room-intro__content {
margin-top: var(--sp-extra-loose); margin-top: var(--sp-extra-loose);
max-width: 640px; width: calc(100% - 88px);
} }
&__name { &__name {
color: var(--tc-surface-high); color: var(--tc-surface-high);

View File

@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './RoomSelector.scss'; import './RoomSelector.scss';
import { twemojify } from '../../../util/twemojify';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
@@ -40,7 +41,7 @@ RoomSelectorWrapper.propTypes = {
}; };
function RoomSelector({ function RoomSelector({
name, roomId, imageSrc, iconSrc, name, parentName, roomId, imageSrc, iconSrc,
isSelected, isUnread, notificationCount, isAlert, isSelected, isUnread, notificationCount, isAlert,
options, onClick, options, onClick,
}) { }) {
@@ -54,10 +55,19 @@ function RoomSelector({
text={name} text={name}
bgColor={colorMXID(roomId)} bgColor={colorMXID(roomId)}
imageSrc={imageSrc} imageSrc={imageSrc}
iconColor="var(--ic-surface-low)"
iconSrc={iconSrc} iconSrc={iconSrc}
size="extra-small" size="extra-small"
/> />
<Text variant="b1">{name}</Text> <Text variant="b1" weight={isUnread ? 'medium' : 'normal'}>
{twemojify(name)}
{parentName && (
<Text variant="b3" span>
{' — '}
{twemojify(parentName)}
</Text>
)}
</Text>
{ isUnread && ( { isUnread && (
<NotificationBadge <NotificationBadge
alert={isAlert} alert={isAlert}
@@ -72,6 +82,7 @@ function RoomSelector({
); );
} }
RoomSelector.defaultProps = { RoomSelector.defaultProps = {
parentName: null,
isSelected: false, isSelected: false,
imageSrc: null, imageSrc: null,
iconSrc: null, iconSrc: null,
@@ -79,6 +90,7 @@ RoomSelector.defaultProps = {
}; };
RoomSelector.propTypes = { RoomSelector.propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
parentName: PropTypes.string,
roomId: PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
imageSrc: PropTypes.string, imageSrc: PropTypes.string,
iconSrc: PropTypes.string, iconSrc: PropTypes.string,

View File

@@ -1,15 +1,9 @@
.room-selector-flex { @use '../../partials/flex';
display: flex; @use '../../partials/text';
align-items: center; @use '../../partials/dir';
}
.room-selector-flexItem {
flex: 1;
min-width: 0;
min-height: 0;
}
.room-selector { .room-selector {
@extend .room-selector-flex; @extend .cp-fx__row--s-c;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--bo-radius); border-radius: var(--bo-radius);
@@ -17,7 +11,6 @@
&--unread { &--unread {
.room-selector__content > .text { .room-selector__content > .text {
font-weight: 500;
color: var(--tc-surface-high); color: var(--tc-surface-high);
} }
} }
@@ -56,35 +49,28 @@
} }
.room-selector__content { .room-selector__content {
@extend .room-selector-flexItem; @extend .cp-fx__item-one;
@extend .room-selector-flex; @extend .cp-fx__row--s-c;
padding: 0 var(--sp-extra-tight); padding: 0 var(--sp-extra-tight);
min-height: 40px; min-height: 40px;
cursor: inherit; cursor: inherit;
& > .avatar-container .avatar__bordered { & > .avatar-container .avatar__border--active {
box-shadow: none; box-shadow: none;
} }
& > .text { & > .text {
@extend .room-selector-flexItem; @extend .cp-fx__item-one;
@extend .cp-txt__ellipsis;
margin: 0 var(--sp-extra-tight); margin: 0 var(--sp-extra-tight);
color: var(--tc-surface-normal-low); color: var(--tc-surface-normal-low);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
} }
} }
.room-selector__options { .room-selector__options {
@extend .room-selector-flex; @extend .cp-fx__row--s-c;
@include dir.side(margin, 0, var(--sp-ultra-tight));
display: none; display: none;
margin-right: var(--sp-ultra-tight);
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-ultra-tight);
}
&:empty { &:empty {
margin: 0 !important; margin: 0 !important;

View File

@@ -2,16 +2,14 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './RoomTile.scss'; import './RoomTile.scss';
import Linkify from 'linkify-react'; import { twemojify } from '../../../util/twemojify';
import { sanitizeText } from '../../../util/sanitize';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Avatar from '../../atoms/avatar/Avatar'; import Avatar from '../../atoms/avatar/Avatar';
function linkifyContent(content) {
return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
}
function RoomTile({ function RoomTile({
avatarSrc, name, id, avatarSrc, name, id,
inviterName, memberCount, desc, options, inviterName, memberCount, desc, options,
@@ -26,7 +24,7 @@ function RoomTile({
/> />
</div> </div>
<div className="room-tile__content"> <div className="room-tile__content">
<Text variant="s1">{name}</Text> <Text variant="s1">{twemojify(name)}</Text>
<Text variant="b3"> <Text variant="b3">
{ {
inviterName !== null inviterName !== null
@@ -36,7 +34,7 @@ function RoomTile({
</Text> </Text>
{ {
desc !== null && (typeof desc === 'string') desc !== null && (typeof desc === 'string')
? <Text className="room-tile__content__desc" variant="b2">{linkifyContent(desc)}</Text> ? <Text className="room-tile__content__desc" variant="b2">{twemojify(desc, undefined, true)}</Text>
: desc : desc
} }
</div> </div>

View File

@@ -1,3 +1,5 @@
@use '../../partials/dir';
.setting-tile { .setting-tile {
&__title__wrapper { &__title__wrapper {
display: flex; display: flex;
@@ -6,11 +8,6 @@
&__title { &__title {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin-right: var(--sp-normal); @include dir.side(margin, 0, var(--sp-normal));
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-normal);
}
} }
} }

View File

@@ -2,6 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './SidebarAvatar.scss'; import './SidebarAvatar.scss';
import { twemojify } from '../../../util/twemojify';
import Avatar from '../../atoms/avatar/Avatar'; import Avatar from '../../atoms/avatar/Avatar';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Tooltip from '../../atoms/tooltip/Tooltip'; import Tooltip from '../../atoms/tooltip/Tooltip';
@@ -16,7 +18,7 @@ const SidebarAvatar = React.forwardRef(({
if (active) activeClass = ' sidebar-avatar--active'; if (active) activeClass = ' sidebar-avatar--active';
return ( return (
<Tooltip <Tooltip
content={<Text variant="b1">{tooltip}</Text>} content={<Text variant="b1">{twemojify(tooltip)}</Text>}
placement="right" placement="right"
> >
<button <button

View File

@@ -1,3 +1,5 @@
@use '../../partials/dir';
.sidebar-avatar { .sidebar-avatar {
position: relative; position: relative;
display: flex; display: flex;
@@ -7,13 +9,26 @@
& .notification-badge { & .notification-badge {
position: absolute; position: absolute;
right: 0; @include dir.prop(left, unset, 0);
@include dir.prop(right, 0, unset);
top: 0; top: 0;
box-shadow: 0 0 0 2px var(--bg-surface-low); box-shadow: 0 0 0 2px var(--bg-surface-low);
transform: translate(20%, -20%); @include dir.prop(transform, translate(20%, -20%), translate(-20%, -20%));
margin: 0 !important; margin: 0 !important;
} }
& .avatar-container,
& .notification-badge {
transition: transform 200ms var(--fluid-push);
}
&:hover .avatar-container {
@include dir.prop(transform, translateX(4px), translateX(-4px));
}
&:hover .notification-badge {
--ltr: translate(calc(20% + 4px), -20%);
--rtl: translate(calc(-20% - 4px), -20%);
@include dir.prop(transform, var(--ltr), var(--rtl));
}
&:focus { &:focus {
outline: none; outline: none;
} }
@@ -27,21 +42,16 @@
content: ""; content: "";
display: block; display: block;
position: absolute; position: absolute;
left: -11px; @include dir.prop(left, -11px, unset);
@include dir.prop(right, unset, -11px);
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 3px; width: 3px;
height: 12px; height: 12px;
background-color: var(--ic-surface-normal); background-color: var(--tc-surface-high);
border-radius: 0 4px 4px 0; @include dir.prop(border-radius, 0 4px 4px 0, 4px 0 0 4px);
transition: height 200ms linear; transition: height 200ms linear;
[dir=rtl] & {
left: unset;
right: -11px;
border-radius: 4px 0 0 4px;
}
} }
&--active:hover::before, &--active:hover::before,
&--active:focus::before, &--active:focus::before,

View File

@@ -1,8 +1,9 @@
import React, { useState, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './CreateRoom.scss'; import './CreateRoom.scss';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import { isRoomAliasAvailable } from '../../../util/matrixUtil'; import { isRoomAliasAvailable } from '../../../util/matrixUtil';
import * as roomActions from '../../../client/action/room'; import * as roomActions from '../../../client/action/room';
import { selectRoom } from '../../../client/action/navigation'; import { selectRoom } from '../../../client/action/navigation';
@@ -51,6 +52,20 @@ function CreateRoom({ isOpen, onRequestClose }) {
setRoleIndex(0); setRoleIndex(0);
} }
const onCreated = (roomId) => {
resetForm();
selectRoom(roomId);
onRequestClose();
};
useEffect(() => {
const { roomList } = initMatrix;
roomList.on(cons.events.roomList.ROOM_CREATED, onCreated);
return () => {
roomList.removeListener(cons.events.roomList.ROOM_CREATED, onCreated);
};
}, []);
async function createRoom() { async function createRoom() {
if (isCreatingRoom) return; if (isCreatingRoom) return;
updateIsCreatingRoom(true); updateIsCreatingRoom(true);
@@ -67,13 +82,9 @@ function CreateRoom({ isOpen, onRequestClose }) {
const powerLevel = roleIndex === 1 ? 101 : undefined; const powerLevel = roleIndex === 1 ? 101 : undefined;
try { try {
const result = await roomActions.create({ await roomActions.create({
name, topic, isPublic, roomAlias, isEncrypted, powerLevel, name, topic, isPublic, roomAlias, isEncrypted, powerLevel,
}); });
resetForm();
selectRoom(result.room_id);
onRequestClose();
} catch (e) { } catch (e) {
if (e.message === 'M_UNKNOWN: Invalid characters in room alias') { if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
updateCreatingError('ERROR: Invalid characters in room address'); updateCreatingError('ERROR: Invalid characters in room address');
@@ -82,8 +93,8 @@ function CreateRoom({ isOpen, onRequestClose }) {
updateCreatingError('ERROR: Room address is already in use'); updateCreatingError('ERROR: Room address is already in use');
updateIsValidAddress(false); updateIsValidAddress(false);
} else updateCreatingError(e.message); } else updateCreatingError(e.message);
updateIsCreatingRoom(false);
} }
updateIsCreatingRoom(false);
} }
function validateAddress(e) { function validateAddress(e) {

View File

@@ -1,6 +1,8 @@
@use '../../partials/flex';
@use '../../partials/dir';
.create-room { .create-room {
margin: 0 var(--sp-normal); @include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
margin-right: var(--sp-extra-tight);
&__form > * { &__form > * {
margin-top: var(--sp-normal); margin-top: var(--sp-normal);
@@ -23,12 +25,8 @@
margin-bottom: var(--sp-ultra-tight); margin-bottom: var(--sp-ultra-tight);
} }
&__tip { &__tip {
margin-left: 46px;
margin-top: var(--sp-ultra-tight); margin-top: var(--sp-ultra-tight);
[dir=rtl] & { @include dir.side(margin, 46px, 0);
margin-left: 0;
margin-right: 46px;
}
} }
& .text { & .text {
display: flex; display: flex;
@@ -46,24 +44,20 @@
} }
} }
& .text:first-child { & .text:first-child {
border-right-width: 0; @include dir.prop(border-width, 1px 0 1px 1px, 1px 1px 1px 0);
border-radius: var(--bo-radius) 0 0 var(--bo-radius); @include dir.prop(
border-radius,
var(--bo-radius) 0 0 var(--bo-radius),
0 var(--bo-radius) var(--bo-radius) 0,
);
} }
& .text:last-child { & .text:last-child {
border-left-width: 0; @include dir.prop(border-width, 1px 1px 1px 0, 1px 0 1px 1px);
border-radius: 0 var(--bo-radius) var(--bo-radius) 0; @include dir.prop(
} border-radius,
[dir=rtl] & { 0 var(--bo-radius) var(--bo-radius) 0,
& .text:first-child { var(--bo-radius) 0 0 var(--bo-radius),
border-left-width: 0; );
border-right-width: 1px;
border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
}
& .text:last-child {
border-right-width: 0;
border-left-width: 1px;
border-radius: var(--bo-radius) 0 0 var(--bo-radius);
}
} }
} }
@@ -74,11 +68,7 @@
& .input-container { & .input-container {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin-right: var(--sp-normal); @include dir.side(margin, 0, var(--sp-normal));
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-normal);
}
} }
& .btn-primary { & .btn-primary {
padding-top: 11px; padding-top: 11px;
@@ -87,24 +77,13 @@
} }
&__loading { &__loading {
display: flex; @extend .cp-fx__row--c-c;
justify-content: center;
align-items: center;
& .text { & .text {
margin-left: var(--sp-normal); @include dir.side(margin, var(--sp-normal), 0);
[dir=rtl] & {
margin-left: 0;
margin-right: var(--sp-normal);
}
} }
} }
&__error { &__error {
text-align: center; text-align: center;
color: var(--bg-danger) !important; color: var(--bg-danger) !important;
} }
[dir=rtl] & {
margin-right: var(--sp-normal);
margin-left: var(--sp-extra-tight);
}
} }

View File

@@ -61,7 +61,7 @@ function EmojiGroup({ name, groupEmojis }) {
return ( return (
<div className="emoji-group"> <div className="emoji-group">
<Text className="emoji-group__header" variant="b2">{name}</Text> <Text className="emoji-group__header" variant="b2" weight="bold">{name}</Text>
{groupEmojis.length !== 0 && <div className="emoji-set">{getEmojiBoard()}</div>} {groupEmojis.length !== 0 && <div className="emoji-set">{getEmojiBoard()}</div>}
</div> </div>
); );

View File

@@ -1,32 +1,22 @@
.emoji-board-flexBoxV { @use '../../partials/flex';
display: flex; @use '../../partials/text';
flex-direction: column; @use '../../partials/dir';
}
.emoji-board-flexItem {
flex: 1;
min-height: 0;
min-width: 0;
}
.emoji-board { .emoji-board {
display: flex; display: flex;
&__content { &__content {
@extend .emoji-board-flexItem; @extend .cp-fx__item-one;
@extend .emoji-board-flexBoxV; @extend .cp-fx__column;
height: 400px; height: 400px;
width: 286px; width: 286px;
} }
&__nav { &__nav {
@extend .emoji-board-flexBoxV; @extend .cp-fx__column;
justify-content: center; justify-content: center;
padding: 4px 6px; padding: 4px 6px;
background-color: var(--bg-surface-low); background-color: var(--bg-surface-low);
border-left: 1px solid var(--bg-surface-border); @include dir.side(border, 1px solid var(--bg-surface-border), none);
[dir=rtl] & {
border-left: none;
border-right: 1px solid var(--bg-surface-border);
}
& > .ic-btn-surface { & > .ic-btn-surface {
margin: calc(var(--sp-ultra-tight) / 2) 0; margin: calc(var(--sp-ultra-tight) / 2) 0;
@@ -41,13 +31,10 @@
& .ic-raw { & .ic-raw {
position: absolute; position: absolute;
left: var(--sp-normal); @include dir.prop(left, var(--sp-normal), unset);
@include dir.prop(right, unset, var(--sp-normal));
top: var(--sp-normal); top: var(--sp-normal);
transform: translateY(1px); transform: translateY(1px);
[dir=rtl] & {
left: unset;
right: var(--sp-normal);
}
} }
& .input-container { & .input-container {
@@ -60,8 +47,8 @@
} }
} }
.emoji-board__content__emojis { .emoji-board__content__emojis {
@extend .emoji-board-flexItem; @extend .cp-fx__item-one;
@extend .emoji-board-flexBoxV; @extend .cp-fx__column;
} }
.emoji-board__content__info { .emoji-board__content__info {
margin: 0 var(--sp-extra-tight); margin: 0 var(--sp-extra-tight);
@@ -79,11 +66,9 @@
} }
} }
& > p:last-child { & > p:last-child {
@extend .emoji-board-flexItem; @extend .cp-fx__item-one;
@extend .cp-txt__ellipsis;
margin: 0 var(--sp-tight); margin: 0 var(--sp-tight);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
} }
} }
@@ -98,24 +83,17 @@
z-index: 99; z-index: 99;
background-color: var(--bg-surface); background-color: var(--bg-surface);
margin-left: var(--sp-extra-tight); @include dir.side(margin, var(--sp-extra-tight), 0);
padding: var(--sp-extra-tight) var(--sp-ultra-tight); padding: var(--sp-extra-tight) var(--sp-ultra-tight);
text-transform: uppercase; text-transform: uppercase;
font-weight: 600;
box-shadow: 0 -4px 0 0 var(--bg-surface); box-shadow: 0 -4px 0 0 var(--bg-surface);
border-bottom: 1px solid var(--bg-surface-border); border-bottom: 1px solid var(--bg-surface-border);
[dir=rtl] & {
margin-left: 0;
margin-right: var(--sp-extra-tight);
}
} }
& .emoji-set { & .emoji-set {
margin: var(--sp-extra-tight) calc(var(--sp-normal) - var(--emoji-padding)); --left-margin: calc(var(--sp-normal) - var(--emoji-padding));
margin-right: calc(var(--sp-extra-tight) - var(--emoji-padding)); --right-margin: calc(var(--sp-extra-tight) - var(--emoji-padding));
[dir=rtl] & { margin: var(--sp-extra-tight);
margin-right: calc(var(--sp-normal) - var(--emoji-padding)); @include dir.side(margin, var(--left-margin), var(--right-margin));
margin-left: calc(var(--sp-extra-tight) - var(--emoji-padding));
}
} }
& .emoji { & .emoji {
width: 38px; width: 38px;

View File

@@ -5,7 +5,7 @@ import './InviteList.scss';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import * as roomActions from '../../../client/action/room'; import * as roomActions from '../../../client/action/room';
import { selectRoom, selectSpace } from '../../../client/action/navigation'; import { selectRoom, selectTab } from '../../../client/action/navigation';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button'; import Button from '../../atoms/button/Button';
@@ -38,7 +38,7 @@ function InviteList({ isOpen, onRequestClose }) {
const room = initMatrix.matrixClient.getRoom(roomId); const room = initMatrix.matrixClient.getRoom(roomId);
const isRejected = room === null || room?.getMyMembership() !== 'join'; const isRejected = room === null || room?.getMyMembership() !== 'join';
if (!isRejected) { if (!isRejected) {
if (room.isSpaceRoom()) selectSpace(roomId); if (room.isSpaceRoom()) selectTab(roomId);
else selectRoom(roomId); else selectRoom(roomId);
onRequestClose(); onRequestClose();
} }
@@ -89,7 +89,7 @@ function InviteList({ isOpen, onRequestClose }) {
<div className="invites-content"> <div className="invites-content">
{ initMatrix.roomList.inviteDirects.size !== 0 && ( { initMatrix.roomList.inviteDirects.size !== 0 && (
<div className="invites-content__subheading"> <div className="invites-content__subheading">
<Text variant="b3">Direct Messages</Text> <Text variant="b3" weight="bold">Direct Messages</Text>
</div> </div>
)} )}
{ {
@@ -117,14 +117,14 @@ function InviteList({ isOpen, onRequestClose }) {
} }
{ initMatrix.roomList.inviteSpaces.size !== 0 && ( { initMatrix.roomList.inviteSpaces.size !== 0 && (
<div className="invites-content__subheading"> <div className="invites-content__subheading">
<Text variant="b3">Spaces</Text> <Text variant="b3" weight="bold">Spaces</Text>
</div> </div>
)} )}
{ Array.from(initMatrix.roomList.inviteSpaces).map(renderRoomTile) } { Array.from(initMatrix.roomList.inviteSpaces).map(renderRoomTile) }
{ initMatrix.roomList.inviteRooms.size !== 0 && ( { initMatrix.roomList.inviteRooms.size !== 0 && (
<div className="invites-content__subheading"> <div className="invites-content__subheading">
<Text variant="b3">Rooms</Text> <Text variant="b3" weight="bold">Rooms</Text>
</div> </div>
)} )}
{ Array.from(initMatrix.roomList.inviteRooms).map(renderRoomTile) } { Array.from(initMatrix.roomList.inviteRooms).map(renderRoomTile) }

View File

@@ -1,13 +1,13 @@
@use '../../partials/dir';
.invites-content { .invites-content {
margin: 0 var(--sp-normal); @include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
margin-right: var(--sp-extra-tight);
&__subheading { &__subheading {
margin-top: var(--sp-extra-loose); margin-top: var(--sp-extra-loose);
& .text { & .text {
text-transform: uppercase; text-transform: uppercase;
font-weight: 600;
} }
&:first-child { &:first-child {
margin-top: var(--sp-tight); margin-top: var(--sp-tight);
@@ -21,19 +21,6 @@
} }
} }
& .invite-btn__container .btn-surface { & .invite-btn__container .btn-surface {
margin-right: var(--sp-normal); @include dir.side(margin, 0, var(--sp-normal));
[dir=rtl] & {
margin: {
right: 0;
left: var(--sp-normal);
}
}
}
[dir=rtl] & {
margin: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
} }
} }

View File

@@ -1,7 +1,8 @@
@use '../../partials/dir';
.invite-user { .invite-user {
margin: 0 var(--sp-normal);
margin-right: var(--sp-extra-tight);
margin-top: var(--sp-extra-tight); margin-top: var(--sp-extra-tight);
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
&__form { &__form {
display: flex; display: flex;
@@ -10,11 +11,7 @@
& .input-container { & .input-container {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin-right: var(--sp-normal); @include dir.side(margin, 0, var(--sp-normal));
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-normal);
}
} }
& .btn-primary { & .btn-primary {
@@ -45,11 +42,4 @@
align-self: flex-end; align-self: flex-end;
} }
} }
[dir=rtl] & {
margin: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
} }

View File

@@ -1,37 +1,24 @@
.drawer-flexBox { @use '../../partials/flex';
display: flex; @use '../../partials/dir';
flex-direction: column;
}
.drawer-flexItem {
flex: 1;
min-height: 0;
}
.drawer { .drawer {
@extend .drawer-flexItem; @extend .cp-fx__column;
@extend .drawer-flexBox; @extend .cp-fx__item-one;
min-width: 0; min-width: 0;
border-right: 1px solid var(--bg-surface-border); @include dir.side(border,
none,
[dir=rtl] & { 1px solid var(--bg-surface-border),
border-right: none; );
border-left: 1px solid var(--bg-surface-border);
}
& .header__title-wrapper .text {
font-weight: 500;
}
&__content-wrapper { &__content-wrapper {
@extend .drawer-flexItem; @extend .cp-fx__item-one;
@extend .drawer-flexBox; @extend .cp-fx__column;
} }
&__state { &__state {
padding: var(--sp-extra-tight); padding: var(--sp-extra-tight);
border-top: 1px solid var(--bg-surface-border); border-top: 1px solid var(--bg-surface-border);
display: flex; @extend .cp-fx__row--c-c;
justify-content: center;
& .text { & .text {
color: var(--tc-danger-high); color: var(--tc-danger-high);
@@ -39,7 +26,7 @@
} }
} }
.rooms__wrapper { .rooms__wrapper {
@extend .drawer-flexItem; @extend .cp-fx__item-one;
position: relative; position: relative;
} }
@@ -49,7 +36,7 @@
&::before { &::before {
position: absolute; position: absolute;
top: 0; top: 0;
z-index: 99;
content: ''; content: '';
display: inline-block; display: inline-block;
width: 100%; width: 100%;
@@ -62,13 +49,7 @@
& > .room-selector { & > .room-selector {
width: calc(100% - var(--sp-extra-tight)); width: calc(100% - var(--sp-extra-tight));
margin-left: auto; @include dir.side(margin, auto, 0);
[dir=rtl] & {
margin-left: 0;
margin-right: auto;
}
} }
& > .room-selector:first-child { & > .room-selector:first-child {
@@ -79,6 +60,5 @@
margin: var(--sp-normal); margin: var(--sp-normal);
margin-bottom: var(--sp-extra-tight); margin-bottom: var(--sp-extra-tight);
text-transform: uppercase; text-transform: uppercase;
font-weight: 600;
} }
} }

View File

@@ -2,6 +2,8 @@ import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './DrawerBreadcrumb.scss'; import './DrawerBreadcrumb.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import { selectSpace } from '../../../client/action/navigation'; import { selectSpace } from '../../../client/action/navigation';
@@ -101,7 +103,7 @@ function DrawerBreadcrumb({ spaceId }) {
className={index === spacePath.length - 1 ? 'breadcrumb__btn--selected' : ''} className={index === spacePath.length - 1 ? 'breadcrumb__btn--selected' : ''}
onClick={() => selectSpace(id)} onClick={() => selectSpace(id)}
> >
<Text variant="b2">{id === cons.tabs.HOME ? 'Home' : mx.getRoom(id).name}</Text> <Text variant="b2">{id === cons.tabs.HOME ? 'Home' : twemojify(mx.getRoom(id).name)}</Text>
{ noti !== null && ( { noti !== null && (
<NotificationBadge <NotificationBadge
alert={noti.highlight !== 0} alert={noti.highlight !== 0}

View File

@@ -1,3 +1,6 @@
@use '../../partials/text';
@use '../../partials/dir';
.breadcrumb__wrapper { .breadcrumb__wrapper {
height: var(--header-height); height: var(--header-height);
position: relative; position: relative;
@@ -47,17 +50,12 @@
white-space: nowrap; white-space: nowrap;
box-shadow: none; box-shadow: none;
& p { & p {
@extend .cp-txt__ellipsis;
max-width: 86px; max-width: 86px;
overflow: hidden;
text-overflow: ellipsis;
} }
& .notification-badge { & .notification-badge {
margin-left: var(--sp-extra-tight); @include dir.side(margin, var(--sp-extra-tight), 0);
[dir=rtl] & {
margin-left: 0;
margin-right: var(--sp-extra-tight);
}
} }
} }

View File

@@ -1,6 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import { import {
@@ -30,7 +32,7 @@ function DrawerHeader({ selectedTab, spaceId }) {
return ( return (
<Header> <Header>
<TitleWrapper> <TitleWrapper>
<Text variant="s1">{spaceName || tabName}</Text> <Text variant="s1" weight="medium" primary>{twemojify(spaceName) || tabName}</Text>
</TitleWrapper> </TitleWrapper>
{spaceName && ( {spaceName && (
<IconButton <IconButton

View File

@@ -72,7 +72,7 @@ function Home({ spaceId }) {
return ( return (
<> <>
{ spaceIds.length !== 0 && <Text className="cat-header" variant="b3">Spaces</Text> } { spaceIds.length !== 0 && <Text className="cat-header" variant="b3" weight="bold">Spaces</Text> }
{ spaceIds.map((id) => ( { spaceIds.map((id) => (
<Selector <Selector
key={id} key={id}
@@ -83,7 +83,7 @@ function Home({ spaceId }) {
/> />
))} ))}
{ roomIds.length !== 0 && <Text className="cat-header" variant="b3">Rooms</Text> } { roomIds.length !== 0 && <Text className="cat-header" variant="b3" weight="bold">Rooms</Text> }
{ roomIds.map((id) => ( { roomIds.map((id) => (
<Selector <Selector
key={id} key={id}
@@ -94,7 +94,7 @@ function Home({ spaceId }) {
/> />
)) } )) }
{ directIds.length !== 0 && <Text className="cat-header" variant="b3">People</Text> } { directIds.length !== 0 && <Text className="cat-header" variant="b3" weight="bold">People</Text> }
{ directIds.map((id) => ( { directIds.map((id) => (
<Selector <Selector
key={id} key={id}

View File

@@ -4,23 +4,19 @@ import './SideBar.scss';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import logout from '../../../client/action/logout';
import { import {
selectTab, openInviteList, openPublicRooms, openSettings, selectTab, openInviteList, openSearch, openSettings,
} from '../../../client/action/navigation'; } from '../../../client/action/navigation';
import navigation from '../../../client/state/navigation'; import navigation from '../../../client/state/navigation';
import { abbreviateNumber } from '../../../util/common'; import { abbreviateNumber } from '../../../util/common';
import ScrollView from '../../atoms/scroll/ScrollView'; import ScrollView from '../../atoms/scroll/ScrollView';
import SidebarAvatar from '../../molecules/sidebar-avatar/SidebarAvatar'; import SidebarAvatar from '../../molecules/sidebar-avatar/SidebarAvatar';
import ContextMenu, { MenuItem, MenuHeader, MenuBorder } from '../../atoms/context-menu/ContextMenu';
import HomeIC from '../../../../public/res/ic/outlined/home.svg'; import HomeIC from '../../../../public/res/ic/outlined/home.svg';
import UserIC from '../../../../public/res/ic/outlined/user.svg'; import UserIC from '../../../../public/res/ic/outlined/user.svg';
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg'; import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import InviteIC from '../../../../public/res/ic/outlined/invite.svg'; import InviteIC from '../../../../public/res/ic/outlined/invite.svg';
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
import PowerIC from '../../../../public/res/ic/outlined/power.svg';
function ProfileAvatarMenu() { function ProfileAvatarMenu() {
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
@@ -48,31 +44,12 @@ function ProfileAvatarMenu() {
}, []); }, []);
return ( return (
<ContextMenu <SidebarAvatar
content={(hideMenu) => ( onClick={openSettings}
<> tooltip={profile.displayName}
<MenuHeader>{mx.getUserId()}</MenuHeader> imageSrc={profile.avatarUrl !== null ? mx.mxcUrlToHttp(profile.avatarUrl, 42, 42, 'crop') : null}
{/* <MenuItem iconSrc={UserIC} onClick={() => ''}>Profile</MenuItem> */} bgColor={colorMXID(mx.getUserId())}
{/* <MenuItem iconSrc={BellIC} onClick={() => ''}>Notification settings</MenuItem> */} text={profile.displayName}
<MenuItem
iconSrc={SettingsIC}
onClick={() => { hideMenu(); openSettings(); }}
>
Settings
</MenuItem>
<MenuBorder />
<MenuItem iconSrc={PowerIC} variant="danger" onClick={logout}>Logout</MenuItem>
</>
)}
render={(toggleMenu) => (
<SidebarAvatar
onClick={toggleMenu}
tooltip={profile.displayName}
imageSrc={profile.avatarUrl !== null ? mx.mxcUrlToHttp(profile.avatarUrl, 42, 42, 'crop') : null}
bgColor={colorMXID(mx.getUserId())}
text={profile.displayName}
/>
)}
/> />
); );
} }
@@ -175,7 +152,6 @@ function SideBar() {
notificationCount={dmsNoti !== null ? abbreviateNumber(dmsNoti.total) : 0} notificationCount={dmsNoti !== null ? abbreviateNumber(dmsNoti.total) : 0}
isAlert={dmsNoti?.highlight > 0} isAlert={dmsNoti?.highlight > 0}
/> />
<SidebarAvatar onClick={() => openPublicRooms()} tooltip="Public rooms" iconSrc={HashSearchIC} />
</div> </div>
<div className="sidebar-divider" /> <div className="sidebar-divider" />
<div className="space-container"> <div className="space-container">
@@ -206,6 +182,11 @@ function SideBar() {
<div className="sidebar__sticky"> <div className="sidebar__sticky">
<div className="sidebar-divider" /> <div className="sidebar-divider" />
<div className="sticky-container"> <div className="sticky-container">
<SidebarAvatar
onClick={() => openSearch()}
tooltip="Search"
iconSrc={SearchIC}
/>
{ totalInvites !== 0 && ( { totalInvites !== 0 && (
<SidebarAvatar <SidebarAvatar
isUnread isUnread

View File

@@ -1,20 +1,16 @@
.sidebar__flexBox { @use '../../partials/flex';
display: flex; @use '../../partials/dir';
flex-direction: column;
justify-content: flex-start;
align-items: center;
}
.sidebar { .sidebar {
@extend .sidebar__flexBox; @extend .cp-fx__column;
width: var(--navigation-sidebar-width); width: var(--navigation-sidebar-width);
height: 100%; height: 100%;
border-right: 1px solid var(--bg-surface-border); background-color: var(--bg-surface-extra-low);
@include dir.side(border,
[dir=rtl] & { none,
border-right: none; 1px solid var(--bg-surface-border),
border-left: 1px solid var(--bg-surface-border); );
}
&__scrollable, &__scrollable,
&__sticky { &__sticky {
@@ -22,12 +18,7 @@
} }
&__scrollable { &__scrollable {
flex: 1; @extend .cp-fx__item-one;
min-height: 0px;
}
&__sticky {
align-items: center;
} }
} }
@@ -41,8 +32,8 @@
background: transparent; background: transparent;
background-image: linear-gradient( background-image: linear-gradient(
to top, to top,
var(--bg-surface-low), var(--bg-surface-extra-low),
var(--bg-surface-low-transparent)); var(--bg-surface-extra-low-transparent));
position: sticky; position: sticky;
bottom: -1px; bottom: -1px;
left: 0; left: 0;
@@ -52,7 +43,7 @@
.featured-container, .featured-container,
.space-container, .space-container,
.sticky-container { .sticky-container {
@extend .sidebar__flexBox; @extend .cp-fx__column--c-c;
padding: var(--sp-ultra-tight) 0; padding: var(--sp-ultra-tight) 0;

View File

@@ -1,3 +1,5 @@
@use '../../partials/dir';
.profile-editor { .profile-editor {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -21,10 +23,6 @@
} }
& > * { & > * {
margin-left: var(--sp-normal); @include dir.side(margin, var(--sp-normal), 0);
[dir=rtl] & {
margin-left: 0;
margin-right: var(--sp-normal);
}
} }
} }

View File

@@ -2,6 +2,8 @@ import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './ProfileViewer.scss'; import './ProfileViewer.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation'; import navigation from '../../../client/state/navigation';
@@ -93,10 +95,23 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
const [isInvited, setIsInvited] = useState(member?.membership === 'invite'); const [isInvited, setIsInvited] = useState(member?.membership === 'invite');
const myPowerlevel = room.getMember(mx.getUserId()).powerLevel; const myPowerlevel = room.getMember(mx.getUserId()).powerLevel;
const canIKick = room.currentState.hasSufficientPowerLevelFor('kick', myPowerlevel); const userPL = room.getMember(userId)?.powerLevel || 0;
const canIKick = room.currentState.hasSufficientPowerLevelFor('kick', myPowerlevel) && userPL < myPowerlevel;
useEffect(() => () => { const onCreated = (dmRoomId) => {
isMountedRef.current = false; if (isMountedRef.current === false) return;
setIsCreatingDM(false);
selectRoom(dmRoomId);
onRequestClose();
};
useEffect(() => {
const { roomList } = initMatrix;
roomList.on(cons.events.roomList.ROOM_CREATED, onCreated);
return () => {
isMountedRef.current = false;
roomList.removeListener(cons.events.roomList.ROOM_CREATED, onCreated);
};
}, []); }, []);
useEffect(() => { useEffect(() => {
setIsUserIgnored(initMatrix.matrixClient.isUserIgnored(userId)); setIsUserIgnored(initMatrix.matrixClient.isUserIgnored(userId));
@@ -111,7 +126,7 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
for (let i = 0; i < directIds.length; i += 1) { for (let i = 0; i < directIds.length; i += 1) {
const dRoom = mx.getRoom(directIds[i]); const dRoom = mx.getRoom(directIds[i]);
const roomMembers = dRoom.getMembers(); const roomMembers = dRoom.getMembers();
if (roomMembers.length <= 2 && dRoom.currentState.members[userId]) { if (roomMembers.length <= 2 && dRoom.getMember(userId)) {
selectRoom(directIds[i]); selectRoom(directIds[i]);
onRequestClose(); onRequestClose();
return; return;
@@ -121,17 +136,13 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
// Create new DM // Create new DM
try { try {
setIsCreatingDM(true); setIsCreatingDM(true);
const result = await roomActions.create({ await roomActions.create({
isEncrypted: true, isEncrypted: true,
isDirect: true, isDirect: true,
invite: [userId], invite: [userId],
}); });
if (isMountedRef.current === false) return;
setIsCreatingDM(false);
selectRoom(result.room_id);
onRequestClose();
} catch { } catch {
if (isMountedRef.current === false) return;
setIsCreatingDM(false); setIsCreatingDM(false);
} }
} }
@@ -242,11 +253,10 @@ function ProfileViewer() {
}; };
}, []); }, []);
useEffect(() => { const handleAfterClose = () => {
if (isOpen) return;
setUserId(null); setUserId(null);
setRoomId(null); setRoomId(null);
}, [isOpen]); };
function renderProfile() { function renderProfile() {
const member = room.getMember(userId) || mx.getUser(userId) || {}; const member = room.getMember(userId) || mx.getUser(userId) || {};
@@ -262,8 +272,8 @@ function ProfileViewer() {
size="large" size="large"
/> />
<div className="profile-viewer__user__info"> <div className="profile-viewer__user__info">
<Text variant="s1">{username}</Text> <Text variant="s1" weight="medium">{twemojify(username)}</Text>
<Text variant="b2">{userId}</Text> <Text variant="b2">{twemojify(userId)}</Text>
</div> </div>
<div className="profile-viewer__user__role"> <div className="profile-viewer__user__role">
<Text variant="b3">Role</Text> <Text variant="b3">Role</Text>
@@ -287,10 +297,11 @@ function ProfileViewer() {
className="profile-viewer__dialog" className="profile-viewer__dialog"
isOpen={isOpen} isOpen={isOpen}
title={`${username} in ${room?.name ?? ''}`} title={`${username} in ${room?.name ?? ''}`}
onAfterClose={handleAfterClose}
onRequestClose={() => setIsOpen(false)} onRequestClose={() => setIsOpen(false)}
contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />} contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />}
> >
{isOpen && renderProfile()} {roomId ? renderProfile() : <div />}
</Dialog> </Dialog>
); );
} }

View File

@@ -1,15 +1,13 @@
@use '../../partials/dir';
.profile-viewer__dialog { .profile-viewer__dialog {
& .dialog__content__wrapper { & .dialog__content__wrapper {
position: relative; position: relative;
} }
& .dialog__content-container { & .dialog__content-container {
padding: var(--sp-normal); padding-top: var(--sp-normal);
padding-bottom: 89px; padding-bottom: 89px;
padding-right: var(--sp-extra-tight); @include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
[dir=rtl] & {
padding-right: var(--sp-normal);
padding-left: var(--sp-extra-tight);
}
} }
} }
@@ -20,22 +18,19 @@
border-bottom: 1px solid var(--bg-surface-border); border-bottom: 1px solid var(--bg-surface-border);
&__info { &__info {
align-self: end; align-self: flex-end;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin: 0 var(--sp-normal); margin: 0 var(--sp-normal);
& .text-s1 {
font-weight: 500;
}
& .text { & .text {
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
} }
&__role { &__role {
align-self: end; align-self: flex-end;
& > .text { & > .text {
margin-bottom: var(--sp-ultra-tight); margin-bottom: var(--sp-ultra-tight);
} }
@@ -61,11 +56,7 @@
margin: 0 var(--sp-normal) margin: 0 var(--sp-normal)
} }
& > *:last-child { & > *:last-child {
margin-left: auto; @include dir.side(margin, auto, 0);
[dir=rtl] & {
margin-left: 0;
margin-right: auto;
}
} }
} }
} }
@@ -77,13 +68,8 @@
&__chips { &__chips {
padding-top: var(--sp-ultra-tight); padding-top: var(--sp-ultra-tight);
& .chip { & .chip {
margin: { margin-top: var(--sp-extra-tight);
top: var(--sp-extra-tight); @include dir.side(margin, 0, var(--sp-extra-tight));
right: var(--sp-extra-tight);
}
[dir=rtl] & {
margin: 0 0 var(--sp-extra-tight) var(--sp-extra-tight);
}
} }
} }
} }

View File

@@ -1,6 +1,7 @@
@use '../../partials/dir';
.public-rooms { .public-rooms {
margin: 0 var(--sp-normal); @include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
margin-right: var(--sp-extra-tight);
margin-top: var(--sp-extra-tight); margin-top: var(--sp-extra-tight);
&__form { &__form {
@@ -19,33 +20,28 @@
min-width: 0; min-width: 0;
display: flex; display: flex;
margin-right: var(--sp-normal); @include dir.side(margin, 0, var(--sp-normal));
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-normal);
}
& > div:first-child { & > div:first-child {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
& .input { & .input {
border-radius: var(--bo-radius) 0 0 var(--bo-radius); @include dir.prop(border-radius,
[dir=rtl] & { var(--bo-radius) 0 0 var(--bo-radius),
border-radius: 0 var(--bo-radius) var(--bo-radius) 0; 0 var(--bo-radius) var(--bo-radius) 0,
} );
} }
} }
& > div:last-child .input { & > div:last-child .input {
width: 120px; width: 120px;
border-left-width: 0; @include dir.prop(border-left-width, 0, 1px);
border-radius: 0 var(--bo-radius) var(--bo-radius) 0; @include dir.prop(border-right-width, 1px, 0);
[dir=rtl] & { @include dir.prop(border-radius,
border-left-width: 1px; 0 var(--bo-radius) var(--bo-radius) 0,
border-right-width: 0; var(--bo-radius) 0 0 var(--bo-radius),
border-radius: var(--bo-radius) 0 0 var(--bo-radius); );
}
} }
} }
@@ -68,11 +64,7 @@
} }
&__view-more { &__view-more {
margin-top: var(--sp-loose); margin-top: var(--sp-loose);
margin-left: calc(var(--av-normal) + var(--sp-normal)); @include dir.side(margin, calc(var(--av-normal) + var(--sp-normal)), 0);
[dir=rtl] & {
margin-left: 0;
margin-right: calc(var(--av-normal) + var(--sp-normal));
}
} }
& .room-tile { & .room-tile {
@@ -81,13 +73,6 @@
align-self: flex-end; align-self: flex-end;
} }
} }
[dir=rtl] & {
margin: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
} }
.try-join-with-alias { .try-join-with-alias {

View File

@@ -2,12 +2,14 @@ import React from 'react';
import ReadReceipts from '../read-receipts/ReadReceipts'; import ReadReceipts from '../read-receipts/ReadReceipts';
import ProfileViewer from '../profile-viewer/ProfileViewer'; import ProfileViewer from '../profile-viewer/ProfileViewer';
import Search from '../search/Search';
function Dialogs() { function Dialogs() {
return ( return (
<> <>
<ReadReceipts /> <ReadReceipts />
<ProfileViewer /> <ProfileViewer />
<Search />
</> </>
); );
} }

View File

@@ -15,57 +15,43 @@ import { openProfileViewer } from '../../../client/action/navigation';
function ReadReceipts() { function ReadReceipts() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [readers, setReaders] = useState([]);
const [roomId, setRoomId] = useState(null); const [roomId, setRoomId] = useState(null);
const [readReceipts, setReadReceipts] = useState([]);
function loadReadReceipts(myRoomId, eventId) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(myRoomId);
const { timeline } = room;
const myReadReceipts = [];
const myEventIndex = timeline.findIndex((mEvent) => mEvent.getId() === eventId);
for (let eventIndex = myEventIndex; eventIndex < timeline.length; eventIndex += 1) {
myReadReceipts.push(...room.getReceiptsForEvent(timeline[eventIndex]));
}
setReadReceipts(myReadReceipts);
setRoomId(myRoomId);
setIsOpen(true);
}
useEffect(() => { useEffect(() => {
const loadReadReceipts = (rId, userIds) => {
setReaders(userIds);
setRoomId(rId);
setIsOpen(true);
};
navigation.on(cons.events.navigation.READRECEIPTS_OPENED, loadReadReceipts); navigation.on(cons.events.navigation.READRECEIPTS_OPENED, loadReadReceipts);
return () => { return () => {
navigation.removeListener(cons.events.navigation.READRECEIPTS_OPENED, loadReadReceipts); navigation.removeListener(cons.events.navigation.READRECEIPTS_OPENED, loadReadReceipts);
}; };
}, []); }, []);
useEffect(() => { const handleAfterClose = () => {
if (isOpen === false) { setReaders([]);
setRoomId(null); setRoomId(null);
setReadReceipts([]); };
}
}, [isOpen]);
function renderPeople(receipt) { function renderPeople(userId) {
const room = initMatrix.matrixClient.getRoom(roomId); const room = initMatrix.matrixClient.getRoom(roomId);
const member = room.getMember(receipt.userId); const member = room.getMember(userId);
const getUserDisplayName = (userId) => { const getUserDisplayName = () => {
if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId)); if (room?.getMember(userId)) return getUsernameOfRoomMember(room.getMember(userId));
return getUsername(userId); return getUsername(userId);
}; };
return ( return (
<PeopleSelector <PeopleSelector
key={receipt.userId} key={userId}
onClick={() => { onClick={() => {
setIsOpen(false); setIsOpen(false);
openProfileViewer(receipt.userId, roomId); openProfileViewer(userId, roomId);
}} }}
avatarSrc={member?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')} avatarSrc={member?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')}
name={getUserDisplayName(receipt.userId)} name={getUserDisplayName(userId)}
color={colorMXID(receipt.userId)} color={colorMXID(userId)}
/> />
); );
} }
@@ -74,11 +60,12 @@ function ReadReceipts() {
<Dialog <Dialog
isOpen={isOpen} isOpen={isOpen}
title="Seen by" title="Seen by"
onAfterClose={handleAfterClose}
onRequestClose={() => setIsOpen(false)} onRequestClose={() => setIsOpen(false)}
contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />} contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />}
> >
{ {
readReceipts.map(renderPeople) readers.map(renderPeople)
} }
</Dialog> </Dialog>
); );

View File

@@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import './RoomOptions.scss'; import './RoomOptions.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation'; import navigation from '../../../client/state/navigation';
@@ -9,6 +11,7 @@ import * as roomActions from '../../../client/action/room';
import ContextMenu, { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu'; import ContextMenu, { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import BellIC from '../../../../public/res/ic/outlined/bell.svg'; import BellIC from '../../../../public/res/ic/outlined/bell.svg';
import BellRingIC from '../../../../public/res/ic/outlined/bell-ring.svg'; import BellRingIC from '../../../../public/res/ic/outlined/bell-ring.svg';
import BellPingIC from '../../../../public/res/ic/outlined/bell-ping.svg'; import BellPingIC from '../../../../public/res/ic/outlined/bell-ping.svg';
@@ -146,9 +149,20 @@ function RoomOptions() {
}; };
}, []); }, []);
const handleMarkAsRead = () => {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
if (!room) return;
const events = room.getLiveTimeline().getEvents();
mx.sendReadReceipt(events[events.length - 1]);
};
const handleInviteClick = () => openInviteUser(roomId); const handleInviteClick = () => openInviteUser(roomId);
const handleLeaveClick = () => { const handleLeaveClick = (toggleMenu) => {
if (confirm('Are you really want to leave this room?')) roomActions.leave(roomId); if (confirm('Are you really want to leave this room?')) {
roomActions.leave(roomId);
toggleMenu();
}
}; };
function setNotif(nState, currentNState) { function setNotif(nState, currentNState) {
@@ -163,7 +177,15 @@ function RoomOptions() {
maxWidth={298} maxWidth={298}
content={(toggleMenu) => ( content={(toggleMenu) => (
<> <>
<MenuHeader>{`Options for ${initMatrix.matrixClient.getRoom(roomId)?.name}`}</MenuHeader> <MenuHeader>{twemojify(`Options for ${initMatrix.matrixClient.getRoom(roomId)?.name}`)}</MenuHeader>
<MenuItem
iconSrc={TickMarkIC}
onClick={() => {
handleMarkAsRead(); toggleMenu();
}}
>
Mark as read
</MenuItem>
<MenuItem <MenuItem
iconSrc={AddUserIC} iconSrc={AddUserIC}
onClick={() => { onClick={() => {
@@ -172,7 +194,7 @@ function RoomOptions() {
> >
Invite Invite
</MenuItem> </MenuItem>
<MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={handleLeaveClick}>Leave</MenuItem> <MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={() => handleLeaveClick(toggleMenu)}>Leave</MenuItem>
<MenuHeader>Notification</MenuHeader> <MenuHeader>Notification</MenuHeader>
<MenuItem <MenuItem
variant={notifState === cons.notifs.DEFAULT ? 'positive' : 'surface'} variant={notifState === cons.notifs.DEFAULT ? 'positive' : 'surface'}

View File

@@ -1,3 +1,5 @@
@use '../../partials/dir';
.context-menu__item { .context-menu__item {
position: relative; position: relative;
} }
@@ -8,13 +10,12 @@
width: 3px; width: 3px;
height: 12px; height: 12px;
background: var(--bg-positive); background: var(--bg-positive);
border-radius: 0 4px 4px 0; @include dir.prop(
border-radius,
0 4px 4px 0,
4px 0 0 4px,
);
position: absolute; position: absolute;
left: 0; @include dir.prop(left, 0, unset);
@include dir.prop(right, unset, 0);
[dir=rtl] & {
left: unset;
right: 0;
border-radius: 4px 0 0 4px;
}
} }

View File

@@ -61,7 +61,6 @@ function PeopleDrawer({ roomId }) {
const PER_PAGE_MEMBER = 50; const PER_PAGE_MEMBER = 50;
const mx = initMatrix.matrixClient; const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
let isRoomChanged = false;
const [itemCount, setItemCount] = useState(PER_PAGE_MEMBER); const [itemCount, setItemCount] = useState(PER_PAGE_MEMBER);
const [membership, setMembership] = useState('join'); const [membership, setMembership] = useState('join');
@@ -104,9 +103,9 @@ function PeopleDrawer({ roomId }) {
useEffect(() => { useEffect(() => {
let isGettingMembers = true; let isGettingMembers = true;
let isRoomChanged = false;
const updateMemberList = (event) => { const updateMemberList = (event) => {
if (isGettingMembers) return; if (isGettingMembers) return;
console.log(event?.event?.room_id);
if (event && event?.event?.room_id !== roomId) return; if (event && event?.event?.room_id !== roomId) return;
setMemberList( setMemberList(
simplyfiMembers( simplyfiMembers(
@@ -144,7 +143,7 @@ function PeopleDrawer({ roomId }) {
<div className="people-drawer"> <div className="people-drawer">
<Header> <Header>
<TitleWrapper> <TitleWrapper>
<Text variant="s1"> <Text variant="s1" primary>
People People
<Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text> <Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text>
</Text> </Text>

View File

@@ -1,38 +1,23 @@
.people-drawer-flexBox { @use '../../partials/flex';
display: flex; @use '../../partials/dir';
flex-direction: column;
}
.people-drawer-flexItem {
flex: 1;
min-height: 0;
min-width: 0;
}
.people-drawer { .people-drawer {
@extend .people-drawer-flexBox; @extend .cp-fx__column;
width: var(--people-drawer-width); width: var(--people-drawer-width);
background-color: var(--bg-surface-low); background-color: var(--bg-surface-low);
border-left: 1px solid var(--bg-surface-border); @include dir.side(border, 1px solid var(--bg-surface-border), none);
[dir=rtl] & {
border: {
left: none;
right: 1px solid var(--bg-surface-hover);
}
}
&__member-count { &__member-count {
color: var(--tc-surface-low); color: var(--tc-surface-low);
} }
&__content-wrapper { &__content-wrapper {
@extend .people-drawer-flexItem; @extend .cp-fx__item-one;
@extend .people-drawer-flexBox; @extend .cp-fx__column;
} }
&__scrollable { &__scrollable {
@extend .people-drawer-flexItem; @extend .cp-fx__item-one;
} }
&__noresult { &__noresult {
@@ -58,18 +43,12 @@
z-index: 99; z-index: 99;
} }
& > .ic-raw { & > .ic-raw {
left: var(--sp-tight); @include dir.prop(left, var(--sp-tight), unset);
[dir=rtl] & { @include dir.prop(right, unset, var(--sp-tight));
right: var(--sp-tight);
left: unset;
}
} }
& > .ic-btn { & > .ic-btn {
right: 2px; @include dir.prop(right, 2px, unset);
[dir=rtl] & { @include dir.prop(left, unset, 2px);
left: 2px;
right: unset;
}
} }
& .input-container { & .input-container {
flex: 1; flex: 1;
@@ -89,11 +68,7 @@
& .segmented-controls { & .segmented-controls {
display: flex; display: flex;
margin-bottom: var(--sp-extra-tight); margin-bottom: var(--sp-extra-tight);
margin-left: var(--sp-extra-tight); @include dir.side(margin, var(--sp-extra-tight), 0);
[dir=rtl] & {
margin-left: unset;
margin-right: var(--sp-extra-tight);
}
} }
& .segment-btn { & .segment-btn {
flex: 1; flex: 1;
@@ -101,16 +76,8 @@
} }
} }
.people-drawer__load-more { .people-drawer__load-more {
padding: var(--sp-normal); padding: var(--sp-normal) 0 0;
padding: { @include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
bottom: 0;
right: var(--sp-extra-tight);
}
[dir=rtl] & {
padding-right: var(--sp-normal);
padding-left: var(--sp-extra-tight);
}
& .btn-surface { & .btn-surface {
width: 100%; width: 100%;

View File

@@ -1,38 +1,50 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import './Room.scss'; import './Room.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation'; import navigation from '../../../client/state/navigation';
import settings from '../../../client/state/settings';
import RoomTimeline from '../../../client/state/RoomTimeline';
import Welcome from '../welcome/Welcome'; import Welcome from '../welcome/Welcome';
import RoomView from './RoomView'; import RoomView from './RoomView';
import PeopleDrawer from './PeopleDrawer'; import PeopleDrawer from './PeopleDrawer';
function Room() { function Room() {
const [selectedRoomId, changeSelectedRoomId] = useState(null); const [roomTimeline, setRoomTimeline] = useState(null);
const [isDrawerVisible, toggleDrawerVisiblity] = useState(navigation.isPeopleDrawerVisible); const [eventId, setEventId] = useState(null);
useEffect(() => { const [isDrawer, setIsDrawer] = useState(settings.isPeopleDrawer);
const handleRoomSelected = (roomId) => {
changeSelectedRoomId(roomId);
};
const handleDrawerToggling = (visiblity) => {
toggleDrawerVisiblity(visiblity);
};
navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
navigation.on(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
const mx = initMatrix.matrixClient;
const handleRoomSelected = (rId, pRoomId, eId) => {
if (mx.getRoom(rId)) {
setRoomTimeline(new RoomTimeline(rId, initMatrix.notifications));
setEventId(eId);
} else {
// TODO: add ability to join room if roomId is invalid
setRoomTimeline(null);
setEventId(null);
}
};
const handleDrawerToggling = (visiblity) => setIsDrawer(visiblity);
useEffect(() => {
navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
settings.on(cons.events.settings.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
return () => { return () => {
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected); navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
navigation.removeListener(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling); settings.removeListener(cons.events.settings.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling);
roomTimeline?.removeInternalListeners();
}; };
}, []); }, []);
if (selectedRoomId === null) return <Welcome />; if (roomTimeline === null) return <Welcome />;
return ( return (
<div className="room-container"> <div className="room-container">
<RoomView roomId={selectedRoomId} /> <RoomView roomTimeline={roomTimeline} eventId={eventId} />
{ isDrawerVisible && <PeopleDrawer roomId={selectedRoomId} />} { isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
</div> </div>
); );
} }

View File

@@ -1,150 +1,58 @@
import React, { useState, useEffect, useRef } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './RoomView.scss'; import './RoomView.scss';
import EventEmitter from 'events'; import EventEmitter from 'events';
import RoomTimeline from '../../../client/state/RoomTimeline';
import ScrollView from '../../atoms/scroll/ScrollView';
import RoomViewHeader from './RoomViewHeader'; import RoomViewHeader from './RoomViewHeader';
import RoomViewContent from './RoomViewContent'; import RoomViewContent from './RoomViewContent';
import RoomViewFloating from './RoomViewFloating'; import RoomViewFloating from './RoomViewFloating';
import RoomViewInput from './RoomViewInput'; import RoomViewInput from './RoomViewInput';
import RoomViewCmdBar from './RoomViewCmdBar'; import RoomViewCmdBar from './RoomViewCmdBar';
import { scrollToBottom, isAtBottom, autoScrollToBottom } from './common';
const viewEvent = new EventEmitter(); const viewEvent = new EventEmitter();
let lastScrollTop = 0; function RoomView({ roomTimeline, eventId }) {
let lastScrollHeight = 0; // eslint-disable-next-line react/prop-types
let isReachedBottom = true; const { roomId } = roomTimeline;
let isReachedTop = false;
function RoomView({ roomId }) {
const [roomTimeline, updateRoomTimeline] = useState(null);
const timelineSVRef = useRef(null);
useEffect(() => {
roomTimeline?.removeInternalListeners();
updateRoomTimeline(new RoomTimeline(roomId));
isReachedBottom = true;
isReachedTop = false;
}, [roomId]);
const timelineScroll = {
reachBottom() {
scrollToBottom(timelineSVRef);
},
autoReachBottom() {
autoScrollToBottom(timelineSVRef);
},
tryRestoringScroll() {
const sv = timelineSVRef.current;
const { scrollHeight } = sv;
if (lastScrollHeight === scrollHeight) return;
if (lastScrollHeight < scrollHeight) {
sv.scrollTop = lastScrollTop + (scrollHeight - lastScrollHeight);
} else {
timelineScroll.reachBottom();
}
},
enableSmoothScroll() {
timelineSVRef.current.style.scrollBehavior = 'smooth';
},
disableSmoothScroll() {
timelineSVRef.current.style.scrollBehavior = 'auto';
},
isScrollable() {
const oHeight = timelineSVRef.current.offsetHeight;
const sHeight = timelineSVRef.current.scrollHeight;
if (sHeight > oHeight) return true;
return false;
},
};
function onTimelineScroll(e) {
const { scrollTop, scrollHeight, offsetHeight } = e.target;
const scrollBottom = scrollTop + offsetHeight;
lastScrollTop = scrollTop;
lastScrollHeight = scrollHeight;
const PLACEHOLDER_HEIGHT = 96;
const PLACEHOLDER_COUNT = 3;
const topPagKeyPoint = PLACEHOLDER_COUNT * PLACEHOLDER_HEIGHT;
const bottomPagKeyPoint = scrollHeight - (offsetHeight / 2);
if (!isReachedBottom && isAtBottom(timelineSVRef)) {
isReachedBottom = true;
viewEvent.emit('toggle-reached-bottom', true);
}
if (isReachedBottom && !isAtBottom(timelineSVRef)) {
isReachedBottom = false;
viewEvent.emit('toggle-reached-bottom', false);
}
// TOP of timeline
if (scrollTop < topPagKeyPoint && isReachedTop === false) {
isReachedTop = true;
viewEvent.emit('reached-top');
return;
}
isReachedTop = false;
// BOTTOM of timeline
if (scrollBottom > bottomPagKeyPoint) {
// TODO:
}
}
return ( return (
<div className="room-view"> <div className="room-view">
<RoomViewHeader roomId={roomId} /> <RoomViewHeader roomId={roomId} />
<div className="room-view__content-wrapper"> <div className="room-view__content-wrapper">
<div className="room-view__scrollable"> <div className="room-view__scrollable">
<ScrollView onScroll={onTimelineScroll} ref={timelineSVRef} autoHide> <RoomViewContent
{roomTimeline !== null && ( eventId={eventId}
<RoomViewContent roomTimeline={roomTimeline}
roomId={roomId} />
roomTimeline={roomTimeline} <RoomViewFloating
timelineScroll={timelineScroll} roomId={roomId}
viewEvent={viewEvent} roomTimeline={roomTimeline}
/> />
)} </div>
</ScrollView> <div className="room-view__sticky">
{roomTimeline !== null && ( <RoomViewInput
<RoomViewFloating roomId={roomId}
roomId={roomId} roomTimeline={roomTimeline}
roomTimeline={roomTimeline} viewEvent={viewEvent}
timelineScroll={timelineScroll} />
viewEvent={viewEvent} <RoomViewCmdBar
/> roomId={roomId}
)} roomTimeline={roomTimeline}
viewEvent={viewEvent}
/>
</div> </div>
{roomTimeline !== null && (
<div className="room-view__sticky">
<RoomViewInput
roomId={roomId}
roomTimeline={roomTimeline}
timelineScroll={timelineScroll}
viewEvent={viewEvent}
/>
<RoomViewCmdBar
roomId={roomId}
roomTimeline={roomTimeline}
viewEvent={viewEvent}
/>
</div>
)}
</div> </div>
</div> </div>
); );
} }
RoomView.defaultProps = {
eventId: null,
};
RoomView.propTypes = { RoomView.propTypes = {
roomId: PropTypes.string.isRequired, roomTimeline: PropTypes.shape({}).isRequired,
eventId: PropTypes.string,
}; };
export default RoomView; export default RoomView;

View File

@@ -1,24 +1,16 @@
.room-view-flexBox { @use '../../partials/flex';
display: flex;
flex-direction: column;
}
.room-view-flexItem {
flex: 1;
min-height: 0;
min-width: 0;
}
.room-view { .room-view {
@extend .room-view-flexItem; @extend .cp-fx__item-one;
@extend .room-view-flexBox; @extend .cp-fx__column;
&__content-wrapper { &__content-wrapper {
@extend .room-view-flexItem; @extend .cp-fx__item-one;
@extend .room-view-flexBox; @extend .cp-fx__column;
} }
&__scrollable { &__scrollable {
@extend .room-view-flexItem; @extend .cp-fx__item-one;
position: relative; position: relative;
} }

View File

@@ -6,31 +6,19 @@ import parse from 'html-react-parser';
import twemoji from 'twemoji'; import twemoji from 'twemoji';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import { toggleMarkdown } from '../../../client/action/settings'; import { toggleMarkdown } from '../../../client/action/settings';
import * as roomActions from '../../../client/action/room'; import * as roomActions from '../../../client/action/room';
import { import {
selectTab,
selectRoom,
openCreateRoom, openCreateRoom,
openPublicRooms, openPublicRooms,
openInviteUser, openInviteUser,
openReadReceipts,
} from '../../../client/action/navigation'; } from '../../../client/action/navigation';
import { emojis } from '../emoji-board/emoji'; import { emojis } from '../emoji-board/emoji';
import AsyncSearch from '../../../util/AsyncSearch'; import AsyncSearch from '../../../util/AsyncSearch';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import ContextMenu, { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import ScrollView from '../../atoms/scroll/ScrollView'; import ScrollView from '../../atoms/scroll/ScrollView';
import SettingTile from '../../molecules/setting-tile/SettingTile'; import FollowingMembers from '../../molecules/following-members/FollowingMembers';
import TimelineChange from '../../molecules/message/TimelineChange';
import CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
import { getUsersActionJsx } from './common';
const commands = [{ const commands = [{
name: 'markdown', name: 'markdown',
@@ -61,128 +49,6 @@ const commands = [{
exe: (roomId, searchTerm) => openInviteUser(roomId, searchTerm), exe: (roomId, searchTerm) => openInviteUser(roomId, searchTerm),
}]; }];
function CmdHelp() {
return (
<ContextMenu
placement="top"
content={(
<>
<MenuHeader>General command</MenuHeader>
<Text variant="b2">/command_name</Text>
<MenuHeader>Go-to commands</MenuHeader>
<Text variant="b2">{'>*space_name'}</Text>
<Text variant="b2">{'>#room_name'}</Text>
<Text variant="b2">{'>@people_name'}</Text>
<MenuHeader>Autofill commands</MenuHeader>
<Text variant="b2">:emoji_name</Text>
<Text variant="b2">@name</Text>
</>
)}
render={(toggleMenu) => (
<IconButton
src={CmdIC}
size="extra-small"
onClick={toggleMenu}
tooltip="Commands"
/>
)}
/>
);
}
function ViewCmd() {
function renderAllCmds() {
return commands.map((command) => (
<SettingTile
key={command.name}
title={command.name}
content={(<Text variant="b3">{command.description}</Text>)}
/>
));
}
return (
<ContextMenu
maxWidth={250}
placement="top"
content={(
<>
<MenuHeader>General commands</MenuHeader>
{renderAllCmds()}
</>
)}
render={(toggleMenu) => (
<span>
<Button onClick={toggleMenu}><span className="text text-b3">View all</span></Button>
</span>
)}
/>
);
}
function FollowingMembers({ roomId, roomTimeline, viewEvent }) {
const [followingMembers, setFollowingMembers] = useState([]);
const mx = initMatrix.matrixClient;
function handleOnMessageSent() {
setFollowingMembers([]);
}
function updateFollowingMembers() {
const room = mx.getRoom(roomId);
const { timeline } = room;
const userIds = room.getUsersReadUpTo(timeline[timeline.length - 1]);
const myUserId = mx.getUserId();
setFollowingMembers(userIds.filter((userId) => userId !== myUserId));
}
useEffect(() => updateFollowingMembers(), [roomId]);
useEffect(() => {
roomTimeline.on(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
viewEvent.on('message_sent', handleOnMessageSent);
return () => {
roomTimeline.removeListener(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers);
viewEvent.removeListener('message_sent', handleOnMessageSent);
};
}, [roomTimeline]);
const lastMEvent = roomTimeline.timeline[roomTimeline.timeline.length - 1];
return followingMembers.length !== 0 && (
<TimelineChange
variant="follow"
content={getUsersActionJsx(roomId, followingMembers, 'following the conversation.')}
time=""
onClick={() => openReadReceipts(roomId, lastMEvent.getId())}
/>
);
}
FollowingMembers.propTypes = {
roomId: PropTypes.string.isRequired,
roomTimeline: PropTypes.shape({}).isRequired,
viewEvent: PropTypes.shape({}).isRequired,
};
function getCmdActivationMessage(prefix) {
function genMessage(prime, secondary) {
return (
<>
<span>{prime}</span>
<span>{secondary}</span>
</>
);
}
const cmd = {
'/': () => genMessage('General command mode activated. ', 'Type command name for suggestions.'),
'>*': () => genMessage('Go-to command mode activated. ', 'Type space name for suggestions.'),
'>#': () => genMessage('Go-to command mode activated. ', 'Type room name for suggestions.'),
'>@': () => genMessage('Go-to command mode activated. ', 'Type people name for suggestions.'),
':': () => genMessage('Emoji autofill command mode activated. ', 'Type emoji shortcut for suggestions.'),
'@': () => genMessage('Name autofill command mode activated. ', 'Type name for suggestions.'),
};
return cmd[prefix]?.();
}
function CmdItem({ onClick, children }) { function CmdItem({ onClick, children }) {
return ( return (
<button className="cmd-item" onClick={onClick} type="button"> <button className="cmd-item" onClick={onClick} type="button">
@@ -195,8 +61,8 @@ CmdItem.propTypes = {
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
}; };
function getCmdSuggestions({ prefix, option, suggestions }, fireCmd) { function renderSuggestions({ prefix, option, suggestions }, fireCmd) {
function getGenCmdSuggestions(cmdPrefix, cmds) { function renderCmdSuggestions(cmdPrefix, cmds) {
const cmdOptString = (typeof option === 'string') ? `/${option}` : '/?'; const cmdOptString = (typeof option === 'string') ? `/${option}` : '/?';
return cmds.map((cmd) => ( return cmds.map((cmd) => (
<CmdItem <CmdItem
@@ -214,23 +80,7 @@ function getCmdSuggestions({ prefix, option, suggestions }, fireCmd) {
)); ));
} }
function getRoomsSuggestion(cmdPrefix, rooms) { function renderEmojiSuggestion(emPrefix, emos) {
return rooms.map((room) => (
<CmdItem
key={room.roomId}
onClick={() => {
fireCmd({
prefix: cmdPrefix,
result: room,
});
}}
>
<Text variant="b2">{room.name}</Text>
</CmdItem>
));
}
function getEmojiSuggestion(emPrefix, emos) {
return emos.map((emoji) => ( return emos.map((emoji) => (
<CmdItem <CmdItem
key={emoji.hexcode} key={emoji.hexcode}
@@ -255,7 +105,7 @@ function getCmdSuggestions({ prefix, option, suggestions }, fireCmd) {
)); ));
} }
function getNameSuggestion(namePrefix, members) { function renderNameSuggestion(namePrefix, members) {
return members.map((member) => ( return members.map((member) => (
<CmdItem <CmdItem
key={member.userId} key={member.userId}
@@ -272,12 +122,9 @@ function getCmdSuggestions({ prefix, option, suggestions }, fireCmd) {
} }
const cmd = { const cmd = {
'/': (cmds) => getGenCmdSuggestions(prefix, cmds), '/': (cmds) => renderCmdSuggestions(prefix, cmds),
'>*': (spaces) => getRoomsSuggestion(prefix, spaces), ':': (emos) => renderEmojiSuggestion(prefix, emos),
'>#': (rooms) => getRoomsSuggestion(prefix, rooms), '@': (members) => renderNameSuggestion(prefix, members),
'>@': (peoples) => getRoomsSuggestion(prefix, peoples),
':': (emos) => getEmojiSuggestion(prefix, emos),
'@': (members) => getNameSuggestion(prefix, members),
}; };
return cmd[prefix]?.(suggestions); return cmd[prefix]?.(suggestions);
} }
@@ -326,29 +173,28 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
asyncSearch.search(searchTerm); asyncSearch.search(searchTerm);
} }
function activateCmd(prefix) { function activateCmd(prefix) {
setCmd({ prefix });
cmdPrefix = prefix; cmdPrefix = prefix;
cmdPrefix = undefined;
const { roomList, matrixClient } = initMatrix; const mx = initMatrix.matrixClient;
function getRooms(roomIds) {
return roomIds.map((rId) => {
const room = matrixClient.getRoom(rId);
return {
name: room.name,
roomId: room.roomId,
};
});
}
const setupSearch = { const setupSearch = {
'/': () => asyncSearch.setup(commands, { keys: ['name'], isContain: true }), '/': () => {
'>*': () => asyncSearch.setup(getRooms([...roomList.spaces]), { keys: ['name'], limit: 20 }), asyncSearch.setup(commands, { keys: ['name'], isContain: true });
'>#': () => asyncSearch.setup(getRooms([...roomList.rooms]), { keys: ['name'], limit: 20 }), setCmd({ prefix, suggestions: commands });
'>@': () => asyncSearch.setup(getRooms([...roomList.directs]), { keys: ['name'], limit: 20 }), },
':': () => asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 }), ':': () => {
'@': () => asyncSearch.setup(matrixClient.getRoom(roomId).getJoinedMembers().map((member) => ({ asyncSearch.setup(emojis, { keys: ['shortcode'], isContain: true, limit: 20 });
name: member.name, setCmd({ prefix, suggestions: emojis.slice(26, 46) });
userId: member.userId.slice(1), },
})), { keys: ['name', 'userId'], limit: 20 }), '@': () => {
const members = mx.getRoom(roomId).getJoinedMembers().map((member) => ({
name: member.name,
userId: member.userId.slice(1),
}));
asyncSearch.setup(members, { keys: ['name', 'userId'], limit: 20 });
const endIndex = members.length > 20 ? 20 : members.length;
setCmd({ prefix, suggestions: members.slice(0, endIndex) });
},
}; };
setupSearch[prefix]?.(); setupSearch[prefix]?.();
} }
@@ -358,11 +204,6 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
cmdPrefix = undefined; cmdPrefix = undefined;
} }
function fireCmd(myCmd) { function fireCmd(myCmd) {
if (myCmd.prefix.match(/^>[*#@]$/)) {
if (cmd.prefix === '>*') selectTab(myCmd.result.roomId);
else selectRoom(myCmd.result.roomId);
viewEvent.emit('cmd_fired');
}
if (myCmd.prefix === '/') { if (myCmd.prefix === '/') {
myCmd.result.exe(roomId, myCmd.option); myCmd.result.exe(roomId, myCmd.option);
viewEvent.emit('cmd_fired'); viewEvent.emit('cmd_fired');
@@ -379,14 +220,6 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
} }
deactivateCmd(); deactivateCmd();
} }
function executeCmd() {
if (cmd.suggestions.length === 0) return;
fireCmd({
prefix: cmd.prefix,
option: cmd.option,
result: cmd.suggestions[0],
});
}
function listenKeyboard(event) { function listenKeyboard(event) {
const { activeElement } = document; const { activeElement } = document;
@@ -417,26 +250,20 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
useEffect(() => { useEffect(() => {
if (cmd !== null) document.body.addEventListener('keydown', listenKeyboard); if (cmd !== null) document.body.addEventListener('keydown', listenKeyboard);
viewEvent.on('cmd_process', processCmd); viewEvent.on('cmd_process', processCmd);
viewEvent.on('cmd_exe', executeCmd);
asyncSearch.on(asyncSearch.RESULT_SENT, displaySuggestions); asyncSearch.on(asyncSearch.RESULT_SENT, displaySuggestions);
return () => { return () => {
if (cmd !== null) document.body.removeEventListener('keydown', listenKeyboard); if (cmd !== null) document.body.removeEventListener('keydown', listenKeyboard);
viewEvent.removeListener('cmd_process', processCmd); viewEvent.removeListener('cmd_process', processCmd);
viewEvent.removeListener('cmd_exe', executeCmd);
asyncSearch.removeListener(asyncSearch.RESULT_SENT, displaySuggestions); asyncSearch.removeListener(asyncSearch.RESULT_SENT, displaySuggestions);
}; };
}, [cmd]); }, [cmd]);
if (typeof cmd?.error === 'string') { const isError = typeof cmd?.error === 'string';
if (cmd === null || isError) {
return ( return (
<div className="cmd-bar"> <div className="cmd-bar">
<div className="cmd-bar__info"> <FollowingMembers roomTimeline={roomTimeline} />
<div className="cmd-bar__info-indicator--error" />
</div>
<div className="cmd-bar__content">
<Text className="cmd-bar__content-error" variant="b2">{cmd.error}</Text>
</div>
</div> </div>
); );
} }
@@ -444,27 +271,14 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
return ( return (
<div className="cmd-bar"> <div className="cmd-bar">
<div className="cmd-bar__info"> <div className="cmd-bar__info">
{cmd === null && <CmdHelp />} <Text variant="b3">TAB</Text>
{cmd !== null && typeof cmd.suggestions === 'undefined' && <div className="cmd-bar__info-indicator" /> }
{cmd !== null && typeof cmd.suggestions !== 'undefined' && <Text variant="b3">TAB</Text>}
</div> </div>
<div className="cmd-bar__content"> <div className="cmd-bar__content">
{cmd === null && ( <ScrollView horizontal vertical={false} invisible>
<FollowingMembers <div className="cmd-bar__content-suggestions">
roomId={roomId} { renderSuggestions(cmd, fireCmd) }
roomTimeline={roomTimeline} </div>
viewEvent={viewEvent} </ScrollView>
/>
)}
{cmd !== null && typeof cmd.suggestions === 'undefined' && <Text className="cmd-bar__content-help" variant="b2">{getCmdActivationMessage(cmd.prefix)}</Text>}
{cmd !== null && typeof cmd.suggestions !== 'undefined' && (
<ScrollView horizontal vertical={false} invisible>
<div className="cmd-bar__content__suggestions">{getCmdSuggestions(cmd, fireCmd)}</div>
</ScrollView>
)}
</div>
<div className="cmd-bar__more">
{cmd !== null && cmd.prefix === '/' && <ViewCmd />}
</div> </div>
</div> </div>
); );

View File

@@ -1,8 +1,6 @@
.overflow-ellipsis { @use '../../partials/flex';
overflow: hidden; @use '../../partials/text';
white-space: nowrap; @use '../../partials/dir';
text-overflow: ellipsis;
}
.cmd-bar { .cmd-bar {
--cmd-bar-height: 28px; --cmd-bar-height: 28px;
@@ -11,96 +9,26 @@
&__info { &__info {
display: flex; display: flex;
width: calc(2 * var(--sp-extra-loose)); width: 40px;
padding-left: var(--sp-ultra-tight); @include dir.side(margin, 10px, 14px);
[dir=rtl] & {
padding-left: 0;
padding-right: var(--sp-ultra-tight);
}
& > * { & > * {
margin: auto; margin: auto;
} }
& .ic-btn-surface {
padding: 0;
& .ic-raw {
background-color: var(--tc-surface-low);
}
}
& .context-menu .text-b2 {
margin: var(--sp-extra-tight) var(--sp-tight);
}
&-indicator,
&-indicator--error {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--bg-positive);
}
&-indicator--error {
background-color: var(--bg-danger);
}
} }
&__content { &__content {
min-width: 0; @extend .cp-fx__item-one;
flex: 1;
display: flex; display: flex;
&-help, &-suggestions {
&-error {
@extend .overflow-ellipsis;
align-self: center;
span {
color: var(--tc-surface-low);
&:first-child {
color: var(--tc-surface-normal)
}
}
}
&-error {
color: var(--bg-danger);
}
&__suggestions {
display: flex;
height: 100%; height: 100%;
white-space: nowrap; white-space: nowrap;
} display: flex;
} align-items: center;
&__more {
display: flex;
& button {
min-width: 0;
height: 100%;
margin: 0 var(--sp-normal);
padding: 0 var(--sp-extra-tight);
box-shadow: none;
border-radius: var(--bo-radius) var(--bo-radius) 0 0;
& .text {
color: var(--tc-surface-normal);
}
}
& .setting-tile {
margin: var(--sp-tight);
}
}
& .timeline-change {
width: 100%;
justify-content: flex-end;
padding: var(--sp-ultra-tight) var(--sp-normal);
border-radius: var(--bo-radius) var(--bo-radius) 0 0;
&__content {
margin: 0;
flex: unset;
& > .text { & > .text {
@extend .overflow-ellipsis; @extend .cp-txt__ellipsis;
& b {
color: var(--tc-surface-normal);
}
} }
} }
} }
@@ -111,7 +39,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
margin-right: var(--sp-extra-tight); @include dir.side(margin, 0, var(--sp-extra-tight));
padding: 0 var(--sp-extra-tight); padding: 0 var(--sp-extra-tight);
height: 100%; height: 100%;
border-radius: var(--bo-radius) var(--bo-radius) 0 0; border-radius: var(--bo-radius) var(--bo-radius) 0 0;
@@ -120,7 +48,7 @@
& .emoji { & .emoji {
width: 20px; width: 20px;
height: 20px; height: 20px;
margin-right: var(--sp-ultra-tight); @include dir.side(margin, 0, var(--sp-ultra-tight));
} }
&:hover { &:hover {
@@ -132,13 +60,4 @@
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
outline: none; outline: none;
} }
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-extra-tight);
& .emoji {
margin-right: 0;
margin-left: var(--sp-ultra-tight);
}
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
@use '../../partials/dir';
.room-view__content { .room-view__content {
min-height: 100%; min-height: 100%;
display: flex; display: flex;
@@ -9,5 +11,20 @@
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
padding-bottom: var(--typing-noti-height); padding-bottom: var(--typing-noti-height);
& .message,
& .ph-msg,
& .timeline-change {
@include dir.prop(border-radius,
0 var(--bo-radius) var(--bo-radius) 0,
var(--bo-radius) 0 0 var(--bo-radius),
);
}
& > .divider {
margin: var(--sp-extra-tight);
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
@include dir.side(padding, calc(var(--av-small) + var(--sp-tight)), 0);
}
} }
} }

View File

@@ -7,63 +7,114 @@ import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import Text from '../../atoms/text/Text'; import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton'; import IconButton from '../../atoms/button/IconButton';
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg'; import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import { getUsersActionJsx } from './common'; import { getUsersActionJsx } from './common';
function RoomViewFloating({ function useJumpToEvent(roomTimeline) {
roomId, roomTimeline, timelineScroll, viewEvent, const [eventId, setEventId] = useState(null);
}) {
const [reachedBottom, setReachedBottom] = useState(true); const jumpToEvent = () => {
roomTimeline.loadEventTimeline(eventId);
};
const cancelJumpToEvent = () => {
roomTimeline.markAllAsRead();
setEventId(null);
};
useEffect(() => {
const readEventId = roomTimeline.getReadUpToEventId();
// we only show "Jump to unread" btn only if the event is not in timeline.
// if event is in timeline
// we will automatically open the timeline from that event position
if (!readEventId?.startsWith('~') && !roomTimeline.hasEventInTimeline(readEventId)) {
setEventId(readEventId);
}
const handleMarkAsRead = () => setEventId(null);
roomTimeline.on(cons.events.roomTimeline.MARKED_AS_READ, handleMarkAsRead);
return () => {
roomTimeline.removeListener(cons.events.roomTimeline.MARKED_AS_READ, handleMarkAsRead);
setEventId(null);
};
}, [roomTimeline]);
return [!!eventId, jumpToEvent, cancelJumpToEvent];
}
function useTypingMembers(roomTimeline) {
const [typingMembers, setTypingMembers] = useState(new Set()); const [typingMembers, setTypingMembers] = useState(new Set());
const mx = initMatrix.matrixClient;
function isSomeoneTyping(members) { const updateTyping = (members) => {
const m = members; const mx = initMatrix.matrixClient;
m.delete(mx.getUserId()); members.delete(mx.getUserId());
if (m.size === 0) return false;
return true;
}
function getTypingMessage(members) {
const userIds = members;
userIds.delete(mx.getUserId());
return getUsersActionJsx(roomId, [...userIds], 'typing...');
}
function updateTyping(members) {
setTypingMembers(members); setTypingMembers(members);
} };
useEffect(() => { useEffect(() => {
setReachedBottom(true);
setTypingMembers(new Set()); setTypingMembers(new Set());
viewEvent.on('toggle-reached-bottom', setReachedBottom);
return () => viewEvent.removeListener('toggle-reached-bottom', setReachedBottom);
}, [roomId]);
useEffect(() => {
roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping); roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
return () => { return () => {
roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping); roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping);
}; };
}, [roomTimeline]); }, [roomTimeline]);
return [typingMembers];
}
function useScrollToBottom(roomTimeline) {
const [isAtBottom, setIsAtBottom] = useState(true);
const handleAtBottom = (atBottom) => setIsAtBottom(atBottom);
useEffect(() => {
setIsAtBottom(true);
roomTimeline.on(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
return () => roomTimeline.removeListener(cons.events.roomTimeline.AT_BOTTOM, handleAtBottom);
}, [roomTimeline]);
return [isAtBottom, setIsAtBottom];
}
function RoomViewFloating({
roomId, roomTimeline,
}) {
const [isJumpToEvent, jumpToEvent, cancelJumpToEvent] = useJumpToEvent(roomTimeline);
const [typingMembers] = useTypingMembers(roomTimeline);
const [isAtBottom, setIsAtBottom] = useScrollToBottom(roomTimeline);
const handleScrollToBottom = () => {
roomTimeline.emit(cons.events.roomTimeline.SCROLL_TO_LIVE);
setIsAtBottom(true);
};
return ( return (
<> <>
<div className={`room-view__typing${isSomeoneTyping(typingMembers) ? ' room-view__typing--open' : ''}`}> <div className={`room-view__unread ${isJumpToEvent ? 'room-view__unread--open' : ''}`}>
<div className="bouncing-loader"><div /></div> <Button onClick={jumpToEvent} variant="primary">
<Text variant="b2">{getTypingMessage(typingMembers)}</Text> <Text variant="b2">Jump to unread</Text>
</div> </Button>
<div className={`room-view__STB${reachedBottom ? '' : ' room-view__STB--open'}`}>
<IconButton <IconButton
onClick={() => { onClick={cancelJumpToEvent}
timelineScroll.enableSmoothScroll(); variant="primary"
timelineScroll.reachBottom(); size="extra-small"
timelineScroll.disableSmoothScroll(); src={TickMarkIC}
}} tooltipPlacement="bottom"
tooltip="Mark as read"
/>
</div>
<div className={`room-view__typing${typingMembers.size > 0 ? ' room-view__typing--open' : ''}`}>
<div className="bouncing-loader"><div /></div>
<Text variant="b2">{getUsersActionJsx(roomId, [...typingMembers], 'typing...')}</Text>
</div>
<div className={`room-view__STB${isAtBottom ? '' : ' room-view__STB--open'}`}>
<IconButton
onClick={handleScrollToBottom}
src={ChevronBottomIC} src={ChevronBottomIC}
tooltip="Scroll to Bottom" tooltip="Scroll to Bottom"
/> />
@@ -74,10 +125,6 @@ function RoomViewFloating({
RoomViewFloating.propTypes = { RoomViewFloating.propTypes = {
roomId: PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
roomTimeline: PropTypes.shape({}).isRequired, roomTimeline: PropTypes.shape({}).isRequired,
timelineScroll: PropTypes.shape({
reachBottom: PropTypes.func,
}).isRequired,
viewEvent: PropTypes.shape({}).isRequired,
}; };
export default RoomViewFloating; export default RoomViewFloating;

View File

@@ -1,3 +1,7 @@
@use '../../partials/flex';
@use '../../partials/text';
@use '../../partials/dir';
.room-view { .room-view {
&__typing { &__typing {
display: flex; display: flex;
@@ -10,12 +14,9 @@
} }
& .text { & .text {
flex: 1; @extend .cp-txt__ellipsis;
min-width: 0; @extend .cp-fx__item-one;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin: 0 var(--sp-tight); margin: 0 var(--sp-tight);
} }
@@ -72,20 +73,49 @@
&__STB { &__STB {
position: absolute; position: absolute;
right: var(--sp-normal); @include dir.prop(right, var(--sp-normal), unset);
@include dir.prop(left, unset, var(--sp-normal));
bottom: 0; bottom: 0;
border-radius: var(--bo-radius); border-radius: var(--bo-radius);
box-shadow: var(--bs-surface-border); box-shadow: var(--bs-surface-border);
background-color: var(--bg-surface-low); background-color: var(--bg-surface-low);
transition: transform 200ms ease-in-out; transition: transform 200ms ease-in-out;
transform: translateY(100%) scale(0); transform: translateY(100%) scale(0);
[dir=rtl] & {
right: unset;
left: var(--sp-normal);
}
&--open { &--open {
transform: translateY(-28px) scale(1); transform: translateY(-28px) scale(1);
} }
} }
&__unread {
position: absolute;
top: var(--sp-extra-tight);
@include dir.prop(right, var(--sp-extra-tight), unset);
@include dir.prop(left, unset, var(--sp-extra-tight));
z-index: 999;
display: none;
background-color: var(--bg-surface);
border-radius: var(--bo-radius);
box-shadow: var(--bs-primary-border);
overflow: hidden;
&--open {
display: flex;
}
& .ic-btn {
padding: 6px var(--sp-extra-tight);
border-radius: 0;
}
& .btn-primary {
@extend .cp-fx__item-one;
@include dir.side(margin, 0, 1px);
border-radius: 0;
padding: 0 var(--sp-tight);
&:focus {
background-color: var(--bg-primary-hover);
}
}
}
} }

View File

@@ -1,8 +1,11 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import { togglePeopleDrawer, openRoomOptions } from '../../../client/action/navigation'; import { openRoomOptions } from '../../../client/action/navigation';
import { togglePeopleDrawer } from '../../../client/action/settings';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
import { getEventCords } from '../../../util/common'; import { getEventCords } from '../../../util/common';
@@ -26,8 +29,8 @@ function RoomViewHeader({ roomId }) {
<Header> <Header>
<Avatar imageSrc={avatarSrc} text={roomName} bgColor={colorMXID(roomId)} size="small" /> <Avatar imageSrc={avatarSrc} text={roomName} bgColor={colorMXID(roomId)} size="small" />
<TitleWrapper> <TitleWrapper>
<Text variant="h2">{roomName}</Text> <Text variant="h2" weight="medium" primary>{twemojify(roomName)}</Text>
{ typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{roomTopic}</p>} { typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{twemojify(roomTopic)}</p>}
</TitleWrapper> </TitleWrapper>
<IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} /> <IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
<IconButton <IconButton

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