Compare commits

..

26 Commits

Author SHA1 Message Date
Ajay Bura
253e3eb855 v1.4.0: SSO login and profile viewer
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
54dd5a4edb Fix reaction selector doesn't focus msg input (#62)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
0bd22bee13 Fix message menu placement on large screen (#113)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
6703614c78 Fix wildcard matching in emojisearch (#121)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
32923a1c34 Fix profile picture inconsistency (#104, #147)
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
1dc0f9288a UI improvement in SSO
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
2e62f103f2 SegmentedControl bug fixed
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
d0e9aea788 Add option to filter PeopleDrawer
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
e12a75e431 Enhanced people search UX
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
5ece37fffe Added button reset type.
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
e6f228b63e Fixed inconsistent search in emojiboard.
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
5018df11f1 Add search in People drawer
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
5f6667debf SSO login bug fixed
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
5b1d5d0326 Fix redirect on SSO login (#142), #27, #94
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
James Julich
012e933e43 Address 301 redirect issue and Safari regex issue. (#143)
* Address 301 redirect issue and Safari regex issue.

* Restored login redirect

as this doesn't not fix the sso redirect #143.

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2021-10-25 17:59:57 +05:30
Gero Gerke
825db633a0 Implement Profile Viewer (#130)
* Implement Profile Viewer

Fixes #111

* Make user avatar in chat clickable

* design progress

* Refactored code

* progress

* Updated chip comp

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* Refactored ProfileViewer comp

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* Added msg functionality in ProfileViewer

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* Added Ignore functionality in ProfileViewer

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* Fixed Ignore btn bug

Signed-off-by: Ajay Bura <ajbura@gmail.com>

* Refectored ProfileViewer comp

Signed-off-by: Ajay Bura <ajbura@gmail.com>

Co-authored-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
4dfe40333e Update pull-request.yml 2021-10-25 17:59:57 +05:30
Ajay Bura
a34c35fbe3 Update pull-request.yml 2021-10-25 17:59:57 +05:30
Ajay Bura
8b4beccf82 Fixed deploy on PR 2021-10-25 17:59:57 +05:30
Ajay Bura
d067fe776d Update pull-request.yml 2021-10-25 17:59:57 +05:30
Ajay Bura
9fad8a1098 added action for pull request previews 2021-10-25 17:59:57 +05:30
jamesjulich
8a1b749667 Add support for SSO login. 2021-10-25 17:59:57 +05:30
kfiven
7b67e4a6e6 Fix unable to send msg in DM from IRC users (#135) 2021-10-25 17:59:57 +05:30
Ajay Bura
824a7f2095 Fix make both user admin on DM create
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Ajay Bura
b4d4cefe23 Updated support link
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2021-10-25 17:59:57 +05:30
Krishan
bdb94d7145 Merge pull request #131 from ajbura/dev
v1.3.2
2021-10-06 13:22:18 +05:30
151 changed files with 9328 additions and 24772 deletions

View File

@@ -1,32 +0,0 @@
name: 'Build PR'
on:
pull_request:
types: ['opened', 'synchronize']
jobs:
build:
runs-on: ubuntu-latest
env:
PR_NUMBER: ${{github.event.number}}
steps:
- uses: actions/checkout@v2
- name: Build
run: npm install && npm run build
- name: Upload Artifact
uses: actions/upload-artifact@v2
with:
name: previewbuild
path: dist
retention-days: 1
- uses: actions/github-script@v3.1.0
with:
script: |
var fs = require('fs');
fs.writeFileSync('${{github.workspace}}/pr.json', JSON.stringify(context.payload.pull_request));
- name: Upload PR Info
uses: actions/upload-artifact@v2
with:
name: pr.json
path: pr.json
retention-days: 1

View File

@@ -1,78 +0,0 @@
name: Upload Preview Build to Netlify
on:
workflow_run:
workflows: ["Build PR"]
types:
- completed
jobs:
build:
runs-on: ubuntu-latest
if: >
${{ github.event.workflow_run.conclusion == 'success' }}
steps:
# There's a 'download artifact' action but it hasn't been updated for the
# workflow_run action (https://github.com/actions/download-artifact/issues/60)
# so instead we get this mess:
- name: 'Download artifact'
uses: actions/github-script@v3.1.0
with:
script: |
var artifacts = await github.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{github.event.workflow_run.id }},
});
var matchArtifact = artifacts.data.artifacts.filter((artifact) => {
return artifact.name == "previewbuild"
})[0];
var download = await github.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
var fs = require('fs');
fs.writeFileSync('${{github.workspace}}/previewbuild.zip', Buffer.from(download.data));
var prInfoArtifact = artifacts.data.artifacts.filter((artifact) => {
return artifact.name == "pr.json"
})[0];
var download = await github.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: prInfoArtifact.id,
archive_format: 'zip',
});
var fs = require('fs');
fs.writeFileSync('${{github.workspace}}/pr.json.zip', Buffer.from(download.data));
- name: Extract Artifacts
run: unzip -d dist previewbuild.zip && rm previewbuild.zip && unzip pr.json.zip && rm pr.json.zip
- name: 'Read PR Info'
id: readctx
uses: actions/github-script@v3.1.0
with:
script: |
var fs = require('fs');
var pr = JSON.parse(fs.readFileSync('${{github.workspace}}/pr.json'));
console.log(`::set-output name=prnumber::${pr.number}`);
- name: Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@v1.2
with:
publish-dir: dist
deploy-message: "Deploy from GitHub Actions"
# These don't work because we're in workflow_run
enable-pull-request-comment: false
enable-commit-comment: false
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE3_ID }}
timeout-minutes: 1
- name: Edit PR Description
uses: velas/pr-description@v1.0.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
pull-request-number: ${{ steps.readctx.outputs.prnumber }}
description-message: |
Preview: ${{ steps.netlify.outputs.deploy-url }}
⚠️ Do you trust the author of this PR? Maybe this build will steal your keys or give you malware. Exercise caution. Use test accounts.

View File

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

View File

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

30
.github/workflows/pull-request.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: 'Netlify Preview Deploy'
on:
pull_request_target:
types: ['opened', 'synchronize']
jobs:
deploy:
name: "Deploy to Netlify"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: jsmrcaga/action-netlify-deploy@master
with:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE3_ID }}
BUILD_DIRECTORY: "dist"
- name: Post on PR
uses: nwtgck/actions-netlify@v1.1
with:
publish-dir: "dist"
github-token: ${{ secrets.GITHUB_TOKEN }}
deploy-message: "Deploy from GitHub Actions"
enable-pull-request-comment: true
enable-commit-comment: false
overwrites-pull-request-comment: true
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE3_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)
> - Refer this project in your project's readme
> - Mention the project at local meetups and tell your friends/colleagues
> - [Donate to us](https://cinny.in/#sponsor)
> - [Donate to us](https://liberapay.com/ajbura/donate)
<!-- omit in toc -->
## Table of Contents

View File

@@ -51,13 +51,9 @@ navigating to `http://localhost:8080`.
Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by `docker pull ajbura/cinny`.
### Configuring default Homeserver
To set default Homeserver on login and register page, place a customized [`config.json`](config.json) in webroot of your choice.
## License
Copyright (c) 2021 Ajay Bura (ajbura) and contributors
Copyright (c) 2021 Ajay Bura (ajbura) and other contributors
Code licensed under the MIT License: <http://opensource.org/licenses/MIT>

View File

@@ -1,12 +0,0 @@
{
"defaultHomeserver": 5,
"homeserverList": [
"boba.best",
"converser.eu",
"envs.net",
"halogen.city",
"kde.org",
"matrix.org",
"mozilla.modular.im"
]
}

BIN
olm.wasm

Binary file not shown.

25261
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "cinny",
"version": "1.6.1",
"version": "1.4.0",
"description": "Yet another matrix client",
"main": "index.js",
"engines": {
@@ -15,18 +15,16 @@
"author": "Ajay Bura",
"license": "MIT",
"dependencies": {
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.4.tgz",
"@tippyjs/react": "^4.2.5",
"babel-polyfill": "^6.26.0",
"browser-encrypt-attachment": "^0.3.0",
"dateformat": "^4.5.1",
"emojibase-data": "^6.2.0",
"file-saver": "^2.0.5",
"flux": "^4.0.1",
"formik": "^2.2.9",
"html-react-parser": "^1.2.7",
"linkifyjs": "^2.1.9",
"matrix-js-sdk": "^15.2.1",
"linkifyjs": "^3.0.0-beta.3",
"matrix-js-sdk": "^12.4.1",
"micromark": "^3.0.3",
"micromark-extension-gfm": "^1.0.0",
"prop-types": "^15.7.2",
@@ -34,8 +32,11 @@
"react-autosize-textarea": "^7.1.0",
"react-dom": "^17.0.2",
"react-google-recaptcha": "^2.1.0",
"react-markdown": "^6.0.1",
"react-modal": "^3.13.1",
"sanitize-html": "^2.5.3",
"react-router-dom": "^5.2.0",
"react-syntax-highlighter": "^15.4.3",
"remark-gfm": "^1.0.0",
"tippy.js": "^6.3.1",
"twemoji": "^13.1.0"
},
@@ -70,9 +71,9 @@
"stream-browserify": "^3.0.0",
"style-loader": "^2.0.0",
"util": "^0.12.4",
"webpack": "^5.62.1",
"webpack-cli": "^4.9.1",
"webpack-dev-server": "^4.4.0",
"webpack": "^5.28.0",
"webpack-cli": "^4.5.0",
"webpack-dev-server": "^3.11.2",
"webpack-merge": "^5.7.3"
}
}

View File

@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8">
<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">
<link href="https://api.fontshare.com/css?f[]=supreme@300,301,400,401,500,501,700,701&display=swap" rel="stylesheet">
<title>Cinny</title>
<meta name="name" content="Cinny">
<meta name="author" content="Ajay Bura">

View File

@@ -1,202 +0,0 @@
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.

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,14 +6,13 @@ import Text from '../text/Text';
import RawIcon from '../system-icons/RawIcon';
import { blurOnBubbling } from './script';
const Button = React.forwardRef(({
function Button({
id, className, variant, iconSrc,
type, onClick, children, disabled,
}, ref) => {
}) {
const iconClass = (iconSrc === null) ? '' : `btn-${variant}--icon`;
return (
<button
ref={ref}
id={id === '' ? undefined : id}
className={`${className ? `${className} ` : ''}btn-${variant} ${iconClass} noselect`}
onMouseUp={(e) => blurOnBubbling(e, `.btn-${variant}`)}
@@ -27,7 +26,7 @@ const Button = React.forwardRef(({
{typeof children !== 'string' && children }
</button>
);
});
}
Button.defaultProps = {
id: '',

View File

@@ -1,5 +1,4 @@
@use 'state';
@use '../../partials/dir';
.btn-surface,
.btn-primary,
@@ -19,10 +18,27 @@
@include state.disabled;
&--icon {
@include dir.side(padding, var(--sp-tight), var(--sp-loose));
padding: {
left: var(--sp-tight);
right: var(--sp-loose);
}
[dir=rtl] & {
padding: {
left: var(--sp-loose);
right: var(--sp-tight);
}
}
.ic-raw {
@include dir.side(margin, 0, var(--sp-extra-tight));
margin-right: 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(({
variant, size, type,
tooltip, tooltipPlacement, src, onClick, tabIndex,
tooltip, tooltipPlacement, src, onClick,
}, ref) => {
const btn = (
<button
@@ -19,7 +19,6 @@ const IconButton = React.forwardRef(({
onClick={onClick}
// eslint-disable-next-line react/button-has-type
type={type}
tabIndex={tabIndex}
>
<RawIcon size={size} src={src} />
</button>
@@ -42,18 +41,16 @@ IconButton.defaultProps = {
tooltip: null,
tooltipPlacement: 'top',
onClick: null,
tabIndex: 0,
};
IconButton.propTypes = {
variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
variant: PropTypes.oneOf(['surface', 'positive', 'caution', 'danger']),
size: PropTypes.oneOf(['normal', 'small', 'extra-small']),
type: PropTypes.oneOf(['button', 'submit', 'reset']),
tooltip: PropTypes.string,
tooltipPlacement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
src: PropTypes.string.isRequired,
onClick: PropTypes.func,
tabIndex: PropTypes.number,
};
export default IconButton;

View File

@@ -29,13 +29,6 @@
@include focus(var(--bg-surface-hover));
@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 {
@include color(var(--ic-positive-normal));
@include state.hover(var(--bg-positive-hover));

View File

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

View File

@@ -1,5 +1,3 @@
@use '../../partials/dir';
.chip {
padding: var(--sp-ultra-tight) var(--sp-extra-tight);
@@ -26,6 +24,10 @@
& > .ic-raw {
width: 16px;
height: 16px;
@include dir.side(margin, 0, var(--sp-ultra-tight));
margin-right: 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 = {
children: PropTypes.node.isRequired,
children: PropTypes.string.isRequired,
};
function MenuItem({

View File

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

View File

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

View File

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

View File

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

@@ -5,7 +5,7 @@ import './Input.scss';
import TextareaAutosize from 'react-autosize-textarea';
function Input({
id, label, name, value, placeholder,
id, label, value, placeholder,
required, type, onChange, forwardRef,
resizable, minHeight, onResize, state,
onKeyDown,
@@ -17,7 +17,6 @@ function Input({
? (
<TextareaAutosize
style={{ minHeight: `${minHeight}px` }}
name={name}
id={id}
className={`input input--resizable${state !== 'normal' ? ` input--${state}` : ''}`}
ref={forwardRef}
@@ -34,7 +33,6 @@ function Input({
<input
ref={forwardRef}
id={id}
name={name}
className={`input ${state !== 'normal' ? ` input--${state}` : ''}`}
type={type}
placeholder={placeholder}
@@ -51,7 +49,6 @@ function Input({
Input.defaultProps = {
id: null,
name: '',
label: '',
value: '',
placeholder: '',
@@ -68,7 +65,6 @@ Input.defaultProps = {
Input.propTypes = {
id: PropTypes.string,
name: PropTypes.string,
label: PropTypes.string,
value: PropTypes.string,
placeholder: PropTypes.string,

View File

@@ -2,7 +2,6 @@
display: block;
width: 100%;
min-width: 0px;
margin: 0;
padding: var(--sp-tight) var(--sp-normal);
background-color: var(--bg-surface-low);
color: var(--tc-surface-normal);

View File

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

View File

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

View File

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

View File

@@ -3,39 +3,25 @@ import PropTypes from 'prop-types';
import './Text.scss';
function Text({
className, style, variant, weight,
primary, span, children,
id, className, variant, children,
}) {
const classes = [];
if (className) classes.push(className);
classes.push(`text text-${variant} text-${weight}`);
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>;
const cName = className !== '' ? `${className} ` : '';
if (variant === 'h1') return <h1 id={id === '' ? undefined : id} className={`${cName}text text-h1`}>{ children }</h1>;
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>;
return <p id={id === '' ? undefined : id} className={`${cName}text text-${variant}`}>{ children }</p>;
}
Text.defaultProps = {
className: null,
style: null,
id: '',
className: '',
variant: 'b1',
weight: 'normal',
primary: false,
span: false,
};
Text.propTypes = {
id: PropTypes.string,
className: PropTypes.string,
style: PropTypes.shape({}),
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,
};

View File

@@ -1,60 +1,41 @@
@mixin font($type) {
@mixin font($type, $weight) {
font-size: var(--fs-#{$type});
font-weight: $weight;
letter-spacing: var(--ls-#{$type});
line-height: var(--lh-#{$type});
& img.emoji,
& img[data-mx-emoticon] {
height: var(--fs-#{$type});
}
}
.text {
%text {
margin: 0;
padding: 0;
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 {
@include font(h1);
@extend %text;
@include font(h1, 500);
}
.text-h2 {
@include font(h2);
@extend %text;
@include font(h2, 500);
}
.text-s1 {
@include font(s1);
@extend %text;
@include font(s1, 400);
}
.text-b1 {
@include font(b1);
@extend %text;
@include font(b1, 400);
color: var(--tc-surface-normal);
}
.text-b2 {
@include font(b2);
@extend %text;
@include font(b2, 400);
color: var(--tc-surface-normal);
}
.text-b3 {
@include font(b3);
@extend %text;
@include font(b3, 400);
color: var(--tc-surface-low);
}

View File

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

View File

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

View File

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

View File

@@ -1,59 +0,0 @@
/* 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

@@ -1,31 +0,0 @@
@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

@@ -48,12 +48,12 @@ function ImageUpload({
>
<Avatar
imageSrc={imageSrc}
text={text}
text={text.slice(0, 1)}
bgColor={bgColor}
size="large"
/>
<div className={`img-upload__process ${uploadPromise === null ? ' img-upload__process--stopped' : ''}`}>
{uploadPromise === null && <Text variant="b3" weight="bold">Upload</Text>}
{uploadPromise === null && <Text variant="b3">Upload</Text>}
{uploadPromise !== null && <Spinner size="small" />}
</div>
</button>

View File

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

View File

@@ -0,0 +1,108 @@
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

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

View File

@@ -1,100 +0,0 @@
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

@@ -1,28 +0,0 @@
.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

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

View File

@@ -1,36 +1,51 @@
/* eslint-disable react/prop-types */
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import './Message.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import { getUsername, getUsernameOfRoomMember, parseReply } from '../../../util/matrixUtil';
import colorMXID from '../../../util/colorMXID';
import { getEventCords } from '../../../util/common';
import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
import {
openEmojiBoard, openProfileViewer, openReadReceipts, replyTo,
} from '../../../client/action/navigation';
import { sanitizeCustomHtml } from '../../../util/sanitize';
import Linkify from 'linkifyjs/react';
import ReactMarkdown from 'react-markdown';
import gfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { coy } from 'react-syntax-highlighter/dist/esm/styles/prism';
import parse from 'html-react-parser';
import twemoji from 'twemoji';
import { getUsername } from '../../../util/matrixUtil';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
import Button from '../../atoms/button/Button';
import Tooltip from '../../atoms/tooltip/Tooltip';
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 EmojiAddIC from '../../../../public/res/ic/outlined/emoji-add.svg';
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
const components = {
code({
// eslint-disable-next-line react/prop-types
inline, className, children,
}) {
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() {
return (
@@ -40,7 +55,7 @@ function PlaceholderMessage() {
</div>
<div className="ph-msg__main-container">
<div className="ph-msg__header" />
<div className="ph-msg__body">
<div className="ph-msg__content">
<div />
<div />
<div />
@@ -51,52 +66,35 @@ function PlaceholderMessage() {
);
}
const MessageAvatar = React.memo(({
roomId, mEvent, userId, username,
}) => {
const avatarSrc = mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop');
function MessageHeader({
userId, name, color, time,
}) {
return (
<div className="message__avatar-container">
<button type="button" onClick={() => openProfileViewer(userId, roomId)}>
<Avatar imageSrc={avatarSrc} text={username} bgColor={colorMXID(userId)} size="small" />
</button>
<div className="message__header">
<div style={{ color }} className="message__profile">
<Text variant="b1">{name}</Text>
<Text variant="b1">{userId}</Text>
</div>
<div className="message__time">
<Text variant="b3">{time}</Text>
</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 = {
userId: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
time: PropTypes.string.isRequired,
};
function MessageReply({ name, color, body }) {
function MessageReply({ name, color, content }) {
return (
<div className="message__reply">
<Text variant="b2">
<RawIcon color={color} size="extra-small" src={ReplyArrowIC} />
<span style={{ color }}>{twemojify(name)}</span>
{' '}
{twemojify(body)}
<span style={{ color }}>{name}</span>
<>{` ${content}`}</>
</Text>
</div>
);
@@ -105,122 +103,45 @@ function MessageReply({ name, color, body }) {
MessageReply.propTypes = {
name: PropTypes.string.isRequired,
color: PropTypes.string.isRequired,
body: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
};
const MessageReplyWrapper = React.memo(({ roomTimeline, eventId }) => {
const [reply, setReply] = useState(null);
const isMountedRef = useRef(true);
useEffect(() => {
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);
};
function MessageContent({ content, isMarkdown, isEdited }) {
return (
<div
className="message__reply-wrapper"
onClick={focusReply}
onKeyDown={focusReply}
role="button"
tabIndex="0"
>
{reply !== null && <MessageReply name={reply.to} color={reply.color} body={reply.body} />}
</div>
);
});
MessageReplyWrapper.propTypes = {
roomTimeline: PropTypes.shape({}).isRequired,
eventId: PropTypes.string.isRequired,
};
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="message__content">
<div className="text text-b1">
{ msgType === 'm.emote' && (
<>
{'* '}
{twemojify(senderName)}
{' '}
</>
)}
{ content }
{ isMarkdown ? genMarkdown(content) : linkifyContent(content) }
</div>
{ isEdited && <Text className="message__body-edited" variant="b3">(edited)</Text>}
{ isEdited && <Text className="message__content-edited" variant="b3">(edited)</Text>}
</div>
);
});
MessageBody.defaultProps = {
isCustomHTML: false,
}
MessageContent.defaultProps = {
isMarkdown: false,
isEdited: false,
msgType: null,
};
MessageBody.propTypes = {
senderName: PropTypes.string.isRequired,
body: PropTypes.node.isRequired,
isCustomHTML: PropTypes.bool,
MessageContent.propTypes = {
content: PropTypes.node.isRequired,
isMarkdown: PropTypes.bool,
isEdited: PropTypes.bool,
msgType: PropTypes.string,
};
function MessageEdit({ body, onSave, onCancel }) {
function MessageEdit({ content, onSave, onCancel }) {
const editInputRef = useRef(null);
const handleKeyDown = (e) => {
function handleKeyDown(e) {
if (e.keyCode === 13 && e.shiftKey === false) {
e.preventDefault();
onSave(editInputRef.current.value);
}
};
}
return (
<form className="message__edit" onSubmit={(e) => { e.preventDefault(); onSave(editInputRef.current.value); }}>
<Input
forwardRef={editInputRef}
onKeyDown={handleKeyDown}
value={body}
value={content}
placeholder="Edit message"
required
resizable
@@ -233,43 +154,21 @@ function MessageEdit({ body, onSave, onCancel }) {
);
}
MessageEdit.propTypes = {
body: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
onSave: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
};
function getMyEmojiEvent(emojiKey, eventId, roomTimeline) {
const mx = initMatrix.matrixClient;
const rEvents = roomTimeline.reactionTimeline.get(eventId);
let rEvent = null;
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();
});
function MessageReactionGroup({ children }) {
return (
<div className="message__reactions text text-b3 noselect">
{ children }
</div>
);
}
MessageReactionGroup.propTypes = {
children: PropTypes.node.isRequired,
};
function genReactionMsg(userIds, reaction) {
const genLessContText = (text) => <span style={{ opacity: '.6' }}>{text}</span>;
@@ -285,390 +184,95 @@ function genReactionMsg(userIds, reaction) {
<>
{msg}
{genLessContText(' reacted with')}
{twemojify(reaction, { className: 'react-emoji' })}
{parse(twemoji.parse(reaction))}
</>
);
}
function MessageReaction({
reaction, count, users, isActive, onClick,
reaction, users, isActive, onClick,
}) {
return (
<Tooltip
className="msg__reaction-tooltip"
content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction) : 'Unable to load who has reacted'}</Text>}
content={<Text variant="b2">{genReactionMsg(users, reaction)}</Text>}
>
<button
onClick={onClick}
type="button"
className={`msg__reaction${isActive ? ' msg__reaction--active' : ''}`}
>
{ twemojify(reaction, { className: 'react-emoji' }) }
<Text variant="b3" className="msg__reaction-count">{count}</Text>
{ parse(twemoji.parse(reaction)) }
<Text variant="b3" className="msg__reaction-count">{users.length}</Text>
</button>
</Tooltip>
);
}
MessageReaction.propTypes = {
reaction: PropTypes.node.isRequired,
count: PropTypes.number.isRequired,
users: PropTypes.arrayOf(PropTypes.string).isRequired,
isActive: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
};
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 (
<div className="message__reactions text text-b3 noselect">
{
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>
);
}
MessageReactionGroup.propTypes = {
roomTimeline: PropTypes.shape({}).isRequired,
mEvent: PropTypes.shape({}).isRequired,
};
function isMedia(mE) {
return (
mE.getContent()?.msgtype === 'm.file'
|| mE.getContent()?.msgtype === 'm.image'
|| mE.getContent()?.msgtype === 'm.audio'
|| mE.getContent()?.msgtype === 'm.video'
|| 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);
function MessageOptions({ children }) {
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"
/>
)}
/>
{children}
</div>
);
});
}
MessageOptions.propTypes = {
roomTimeline: PropTypes.shape({}).isRequired,
mEvent: PropTypes.shape({}).isRequired,
edit: PropTypes.func.isRequired,
reply: PropTypes.func.isRequired,
children: PropTypes.node.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) {
case 'm.file':
return (
<Media.File
name={mContent.body}
link={mx.mxcUrlToHttp(mediaMXC)}
type={mContent.info?.mimetype}
file={mContent.file || null}
/>
);
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:
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,
avatar, header, reply, content, editContent, reactions, options,
}) {
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;
}
const msgClass = header === null ? ' message--content-only' : ' message--full';
return (
<div className={className.join(' ')}>
{
isBodyOnly
? <div className="message__avatar-container" />
: <MessageAvatar roomId={roomId} mEvent={mEvent} userId={senderId} username={username} />
}
<div className={`message${msgClass}`}>
<div className="message__avatar-container">
{avatar !== null && avatar}
</div>
<div className="message__main-container">
{!isBodyOnly && (
<MessageHeader userId={senderId} username={username} time={time} />
)}
{isReply && (
<MessageReplyWrapper
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}
/>
)}
{header !== null && header}
{reply !== null && reply}
{content !== null && content}
{editContent !== null && editContent}
{reactions !== null && reactions}
{options !== null && options}
</div>
</div>
);
}
Message.defaultProps = {
isBodyOnly: false,
focus: false,
avatar: null,
header: null,
reply: null,
content: null,
editContent: null,
reactions: null,
options: null,
};
Message.propTypes = {
mEvent: PropTypes.shape({}).isRequired,
isBodyOnly: PropTypes.bool,
roomTimeline: PropTypes.shape({}).isRequired,
focus: PropTypes.bool,
time: PropTypes.string.isRequired,
avatar: PropTypes.node,
header: PropTypes.node,
reply: PropTypes.node,
content: PropTypes.node,
editContent: PropTypes.node,
reactions: PropTypes.node,
options: PropTypes.node,
};
export { Message, MessageReply, PlaceholderMessage };
export {
Message,
MessageHeader,
MessageReply,
MessageContent,
MessageEdit,
MessageReactionGroup,
MessageReaction,
MessageOptions,
PlaceholderMessage,
};

View File

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

View File

@@ -2,6 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import './TimelineChange.scss';
// import Linkify from 'linkifyjs/react';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
@@ -10,10 +12,9 @@ import LeaveArraowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
import InviteArraowIC from '../../../../public/res/ic/outlined/invite-arrow.svg';
import InviteCancelArraowIC from '../../../../public/res/ic/outlined/invite-cancel-arrow.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;
switch (variant) {
@@ -32,6 +33,9 @@ function TimelineChange({
case 'avatar':
iconSrc = UserIC;
break;
case 'follow':
iconSrc = TickMarkIC;
break;
default:
iconSrc = JoinArraowIC;
break;
@@ -45,6 +49,7 @@ function TimelineChange({
<div className="timeline-change__content">
<Text variant="b2">
{content}
{/* <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify> */}
</Text>
</div>
<div className="timeline-change__time">
@@ -63,6 +68,7 @@ TimelineChange.propTypes = {
variant: PropTypes.oneOf([
'join', 'leave', 'invite',
'invite-cancel', 'avatar', 'other',
'follow',
]),
content: PropTypes.oneOfType([
PropTypes.string,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,41 +1,98 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import './SSOButtons.scss';
import { createTemporaryClient, startSsoLogin } from '../../../client/action/auth';
import { createTemporaryClient, getLoginFlows, startSsoLogin } from '../../../client/action/auth';
import Button from '../../atoms/button/Button';
import Text from '../../atoms/text/Text';
function SSOButtons({ homeserver }) {
const [identityProviders, setIdentityProviders] = useState([]);
useEffect(() => {
// If the homeserver passed in is not a fully-qualified domain name, do not update.
if (!homeserver.match('^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\\.[a-zA-Z]{2,})+$')) {
return;
}
// TODO Check that there is a Matrix server at homename before making requests.
// This will prevent the CORS errors that happen when a user changes their homeserver.
createTemporaryClient(homeserver).then((client) => {
const providers = [];
getLoginFlows(client).then((flows) => {
if (flows.flows !== undefined) {
const ssoFlows = flows.flows.filter((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas');
ssoFlows.forEach((flow) => {
if (flow.identity_providers !== undefined) {
const type = flow.type.substring(8);
flow.identity_providers.forEach((idp) => {
const imageSrc = client.mxcUrlToHttp(idp.icon);
providers.push({
homeserver, id: idp.id, name: idp.name, type, imageSrc,
});
});
}
});
}
setIdentityProviders(providers);
}).catch(() => {});
}).catch(() => {
setIdentityProviders([]);
});
}, [homeserver]);
if (identityProviders.length === 0) return <></>;
function SSOButtons({ type, identityProviders, baseUrl }) {
const tempClient = createTemporaryClient(baseUrl);
function handleClick(id) {
startSsoLogin(baseUrl, type, id);
}
return (
<div className="sso-buttons">
{identityProviders
.sort((idp, idp2) => {
if (typeof idp.icon !== 'string') return -1;
return idp.name.toLowerCase() > idp2.name.toLowerCase() ? 1 : -1;
})
.map((idp) => (
idp.icon
? (
<button key={idp.id} type="button" className="sso-btn" onClick={() => handleClick(idp.id)}>
<img className="sso-btn__img" src={tempClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
</button>
) : <Button key={idp.id} className="sso-btn__text-only" onClick={() => handleClick(idp.id)}>{`Login with ${idp.name}`}</Button>
<div className="sso-buttons__divider">
<Text>OR</Text>
</div>
<div className="sso-buttons__container">
{identityProviders.sort((idp) => !!idp.imageSrc).map((idp) => (
<SSOButton
key={idp.id}
homeserver={idp.homeserver}
id={idp.id}
name={idp.name}
type={idp.type}
imageSrc={idp.imageSrc}
/>
))}
</div>
</div>
);
}
function SSOButton({
homeserver, id, name, type, imageSrc,
}) {
const isImageAvail = !!imageSrc;
function handleClick() {
startSsoLogin(homeserver, type, id);
}
return (
<button
type="button"
className={`sso-btn${!isImageAvail ? ' sso-btn__text-only' : ''}`}
onClick={handleClick}
>
{isImageAvail && <img className="sso-btn__img" src={imageSrc} alt={name} />}
{!isImageAvail && <Text>{`Login with ${name}`}</Text>}
</button>
);
}
SSOButtons.propTypes = {
identityProviders: PropTypes.arrayOf(
PropTypes.shape({}),
).isRequired,
baseUrl: PropTypes.string.isRequired,
type: PropTypes.oneOf(['sso', 'cas']).isRequired,
homeserver: PropTypes.string.isRequired,
};
SSOButton.propTypes = {
homeserver: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
imageSrc: PropTypes.string.isRequired,
};
export default SSOButtons;

View File

@@ -1,7 +1,22 @@
.sso-buttons {
display: flex;
justify-content: center;
flex-wrap: wrap;
&__divider {
display: flex;
align-items: center;
&::before,
&::after {
flex: 1;
content: '';
margin: var(--sp-tight);
border-bottom: 1px solid var(--bg-surface-border);
}
}
&__container {
margin-bottom: var(--sp-extra-loose);
display: flex;
justify-content: center;
flex-wrap: wrap;
}
}
.sso-btn {
@@ -16,8 +31,11 @@
width: var(--av-small);
}
&__text-only {
margin-top: var(--sp-normal);
flex-basis: 100%;
text-align: center;
margin: var(--sp-tight) 0px;
cursor: pointer;
& .text {
color: var(--tc-link);
}

View File

@@ -1,12 +1,10 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import './CreateRoom.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import { isRoomAliasAvailable } from '../../../util/matrixUtil';
import * as roomActions from '../../../client/action/room';
import { selectRoom } from '../../../client/action/navigation';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
@@ -14,7 +12,6 @@ import Toggle from '../../atoms/button/Toggle';
import IconButton from '../../atoms/button/IconButton';
import Input from '../../atoms/input/Input';
import Spinner from '../../atoms/spinner/Spinner';
import SegmentControl from '../../atoms/segmented-controls/SegmentedControls';
import PopupWindow from '../../molecules/popup-window/PopupWindow';
import SettingTile from '../../molecules/setting-tile/SettingTile';
@@ -31,7 +28,6 @@ function CreateRoom({ isOpen, onRequestClose }) {
const [titleValue, updateTitleValue] = useState(undefined);
const [topicValue, updateTopicValue] = useState(undefined);
const [addressValue, updateAddressValue] = useState(undefined);
const [roleIndex, setRoleIndex] = useState(0);
const addressRef = useRef(null);
const topicRef = useRef(null);
@@ -49,23 +45,8 @@ function CreateRoom({ isOpen, onRequestClose }) {
updateTitleValue(undefined);
updateTopicValue(undefined);
updateAddressValue(undefined);
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() {
if (isCreatingRoom) return;
updateIsCreatingRoom(true);
@@ -79,12 +60,13 @@ function CreateRoom({ isOpen, onRequestClose }) {
if (roomAlias.trim() === '') roomAlias = undefined;
}
const powerLevel = roleIndex === 1 ? 101 : undefined;
try {
await roomActions.create({
name, topic, isPublic, roomAlias, isEncrypted, powerLevel,
name, topic, isPublic, roomAlias, isEncrypted,
});
resetForm();
onRequestClose();
} catch (e) {
if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
updateCreatingError('ERROR: Invalid characters in room address');
@@ -93,8 +75,8 @@ function CreateRoom({ isOpen, onRequestClose }) {
updateCreatingError('ERROR: Room address is already in use');
updateIsValidAddress(false);
} else updateCreatingError(e.message);
updateIsCreatingRoom(false);
}
updateIsCreatingRoom(false);
}
function validateAddress(e) {
@@ -157,19 +139,6 @@ function CreateRoom({ isOpen, onRequestClose }) {
content={<Text variant="b3">You cant disable this later. Bridges & most bots wont work yet.</Text>}
/>
)}
<SettingTile
title="Select your role"
options={(
<SegmentControl
selected={roleIndex}
segments={[{ text: 'Admin' }, { text: 'Founder' }]}
onSelect={setRoleIndex}
/>
)}
content={(
<Text variant="b3">Override the default (100) power level.</Text>
)}
/>
<Input value={topicValue} onChange={handleTopicChange} forwardRef={topicRef} minHeight={174} resizable label="Topic (optional)" />
<div className="create-room__name-wrapper">
<Input value={titleValue} onChange={handleTitleChange} forwardRef={nameRef} label="Room name" required />

View File

@@ -1,8 +1,6 @@
@use '../../partials/flex';
@use '../../partials/dir';
.create-room {
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
margin: 0 var(--sp-normal);
margin-right: var(--sp-extra-tight);
&__form > * {
margin-top: var(--sp-normal);
@@ -11,13 +9,6 @@
}
}
& .segment-btn {
padding: var(--sp-ultra-tight) 0;
&__base {
padding: 0 var(--sp-tight);
}
}
&__address {
display: flex;
&__label {
@@ -25,8 +16,12 @@
margin-bottom: var(--sp-ultra-tight);
}
&__tip {
margin-left: 46px;
margin-top: var(--sp-ultra-tight);
@include dir.side(margin, 46px, 0);
[dir=rtl] & {
margin-left: 0;
margin-right: 46px;
}
}
& .text {
display: flex;
@@ -44,20 +39,24 @@
}
}
& .text:first-child {
@include dir.prop(border-width, 1px 0 1px 1px, 1px 1px 1px 0);
@include dir.prop(
border-radius,
var(--bo-radius) 0 0 var(--bo-radius),
0 var(--bo-radius) var(--bo-radius) 0,
);
border-right-width: 0;
border-radius: var(--bo-radius) 0 0 var(--bo-radius);
}
& .text:last-child {
@include dir.prop(border-width, 1px 1px 1px 0, 1px 0 1px 1px);
@include dir.prop(
border-radius,
0 var(--bo-radius) var(--bo-radius) 0,
var(--bo-radius) 0 0 var(--bo-radius),
);
border-left-width: 0;
border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
}
[dir=rtl] & {
& .text:first-child {
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);
}
}
}
@@ -68,7 +67,11 @@
& .input-container {
flex: 1;
min-width: 0;
@include dir.side(margin, 0, var(--sp-normal));
margin-right: var(--sp-normal);
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-normal);
}
}
& .btn-primary {
padding-top: 11px;
@@ -77,13 +80,24 @@
}
&__loading {
@extend .cp-fx__row--c-c;
display: flex;
justify-content: center;
align-items: center;
& .text {
@include dir.side(margin, var(--sp-normal), 0);
margin-left: var(--sp-normal);
[dir=rtl] & {
margin-left: 0;
margin-right: var(--sp-normal);
}
}
}
&__error {
text-align: center;
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 (
<div className="emoji-group">
<Text className="emoji-group__header" variant="b2" weight="bold">{name}</Text>
<Text className="emoji-group__header" variant="b2">{name}</Text>
{groupEmojis.length !== 0 && <div className="emoji-set">{getEmojiBoard()}</div>}
</div>
);

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
@use '../../partials/dir';
.invites-content {
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
margin: 0 var(--sp-normal);
margin-right: var(--sp-extra-tight);
&__subheading {
margin-top: var(--sp-extra-loose);
& .text {
text-transform: uppercase;
font-weight: 600;
}
&:first-child {
margin-top: var(--sp-tight);
@@ -21,6 +21,19 @@
}
}
& .invite-btn__container .btn-surface {
@include dir.side(margin, 0, var(--sp-normal));
margin-right: 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,8 +1,7 @@
@use '../../partials/dir';
.invite-user {
margin: 0 var(--sp-normal);
margin-right: var(--sp-extra-tight);
margin-top: var(--sp-extra-tight);
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
&__form {
display: flex;
@@ -11,7 +10,11 @@
& .input-container {
flex: 1;
min-width: 0;
@include dir.side(margin, 0, var(--sp-normal));
margin-right: var(--sp-normal);
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-normal);
}
}
& .btn-primary {
@@ -42,4 +45,11 @@
align-self: flex-end;
}
}
[dir=rtl] & {
margin: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,19 +4,23 @@ import './SideBar.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import colorMXID from '../../../util/colorMXID';
import logout from '../../../client/action/logout';
import {
selectTab, openInviteList, openSearch, openSettings,
selectTab, openInviteList, openPublicRooms, openSettings,
} from '../../../client/action/navigation';
import navigation from '../../../client/state/navigation';
import { abbreviateNumber } from '../../../util/common';
import ScrollView from '../../atoms/scroll/ScrollView';
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 UserIC from '../../../../public/res/ic/outlined/user.svg';
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.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() {
const mx = initMatrix.matrixClient;
@@ -44,12 +48,31 @@ function ProfileAvatarMenu() {
}, []);
return (
<SidebarAvatar
onClick={openSettings}
tooltip={profile.displayName}
imageSrc={profile.avatarUrl !== null ? mx.mxcUrlToHttp(profile.avatarUrl, 42, 42, 'crop') : null}
bgColor={colorMXID(mx.getUserId())}
text={profile.displayName}
<ContextMenu
content={(hideMenu) => (
<>
<MenuHeader>{mx.getUserId()}</MenuHeader>
{/* <MenuItem iconSrc={UserIC} onClick={() => ''}>Profile</MenuItem> */}
{/* <MenuItem iconSrc={BellIC} onClick={() => ''}>Notification settings</MenuItem> */}
<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.slice(0, 1)}
/>
)}
/>
);
}
@@ -98,7 +121,6 @@ function SideBar() {
let noti = null;
orphans.forEach((roomId) => {
if (roomList.spaceShortcut.has(roomId)) return;
if (!notifications.hasNoti(roomId)) return;
if (noti === null) noti = { total: 0, highlight: 0 };
const childNoti = notifications.getNoti(roomId);
@@ -152,6 +174,7 @@ function SideBar() {
notificationCount={dmsNoti !== null ? abbreviateNumber(dmsNoti.total) : 0}
isAlert={dmsNoti?.highlight > 0}
/>
<SidebarAvatar onClick={() => openPublicRooms()} tooltip="Public rooms" iconSrc={HashSearchIC} />
</div>
<div className="sidebar-divider" />
<div className="space-container">
@@ -166,7 +189,7 @@ function SideBar() {
tooltip={room.name}
bgColor={colorMXID(room.roomId)}
imageSrc={room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop') || null}
text={room.name}
text={room.name.slice(0, 1)}
isUnread={notifications.hasNoti(sRoomId)}
notificationCount={abbreviateNumber(notifications.getTotalNoti(sRoomId))}
isAlert={notifications.getHighlightNoti(sRoomId) !== 0}
@@ -182,11 +205,6 @@ function SideBar() {
<div className="sidebar__sticky">
<div className="sidebar-divider" />
<div className="sticky-container">
<SidebarAvatar
onClick={() => openSearch()}
tooltip="Search"
iconSrc={SearchIC}
/>
{ totalInvites !== 0 && (
<SidebarAvatar
isUnread

View File

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

View File

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

View File

@@ -2,8 +2,6 @@ import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import './ProfileViewer.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
@@ -80,43 +78,19 @@ SessionInfo.propTypes = {
userId: PropTypes.string.isRequired,
};
function ProfileFooter({ roomId, userId, onRequestClose }) {
function ProfileFooter({ userId, onRequestClose }) {
const [isCreatingDM, setIsCreatingDM] = useState(false);
const [isIgnoring, setIsIgnoring] = useState(false);
const [isUserIgnored, setIsUserIgnored] = useState(initMatrix.matrixClient.isUserIgnored(userId));
const isMountedRef = useRef(true);
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
const member = room.getMember(userId);
const isInvitable = member?.membership !== 'join' && member?.membership !== 'ban';
const isMountedRef = useRef(true);
const [isInviting, setIsInviting] = useState(false);
const [isInvited, setIsInvited] = useState(member?.membership === 'invite');
const myPowerlevel = room.getMember(mx.getUserId()).powerLevel;
const userPL = room.getMember(userId)?.powerLevel || 0;
const canIKick = room.currentState.hasSufficientPowerLevelFor('kick', myPowerlevel) && userPL < myPowerlevel;
const onCreated = (dmRoomId) => {
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(() => () => {
isMountedRef.current = false;
}, []);
useEffect(() => {
setIsUserIgnored(initMatrix.matrixClient.isUserIgnored(userId));
setIsIgnoring(false);
setIsInviting(false);
}, [userId]);
async function openDM() {
@@ -126,7 +100,7 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
for (let i = 0; i < directIds.length; i += 1) {
const dRoom = mx.getRoom(directIds[i]);
const roomMembers = dRoom.getMembers();
if (roomMembers.length <= 2 && dRoom.getMember(userId)) {
if (roomMembers.length <= 2 && dRoom.currentState.members[userId]) {
selectRoom(directIds[i]);
onRequestClose();
return;
@@ -136,14 +110,18 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
// Create new DM
try {
setIsCreatingDM(true);
await roomActions.create({
const result = await roomActions.create({
isEncrypted: true,
isDirect: true,
invite: [userId],
});
} catch {
if (isMountedRef.current === false) return;
setIsCreatingDM(false);
selectRoom(result.room_id);
onRequestClose();
} catch {
setIsCreatingDM(false);
}
}
@@ -166,24 +144,6 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
setIsIgnoring(false);
}
}
async function toggleInvite() {
try {
setIsInviting(true);
let isInviteSent = false;
if (isInvited) await roomActions.kick(roomId, userId);
else {
await roomActions.invite(roomId, userId);
isInviteSent = true;
}
if (isMountedRef.current === false) return;
setIsInvited(isInviteSent);
setIsInviting(false);
} catch {
setIsInviting(false);
}
}
return (
<div className="profile-viewer__buttons">
<Button
@@ -193,19 +153,7 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
>
{isCreatingDM ? 'Creating room...' : 'Message'}
</Button>
{ member?.membership === 'join' && <Button>Mention</Button>}
{ (isInvited ? canIKick : room.canInvite(mx.getUserId())) && isInvitable && (
<Button
onClick={toggleInvite}
disabled={isInviting}
>
{
isInvited
? `${isInviting ? 'Disinviting...' : 'Disinvite'}`
: `${isInviting ? 'Inviting...' : 'Invite'}`
}
</Button>
)}
<Button>Mention</Button>
<Button
variant={isUserIgnored ? 'positive' : 'danger'}
onClick={toggleIgnore}
@@ -221,7 +169,6 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
);
}
ProfileFooter.propTypes = {
roomId: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired,
onRequestClose: PropTypes.func.isRequired,
};
@@ -253,27 +200,28 @@ function ProfileViewer() {
};
}, []);
const handleAfterClose = () => {
useEffect(() => {
if (isOpen) return;
setUserId(null);
setRoomId(null);
};
}, [isOpen]);
function renderProfile() {
const member = room.getMember(userId) || mx.getUser(userId) || {};
const avatarMxc = member.getMxcAvatarUrl?.() || member.avatarUrl;
const member = room.getMember(userId) || mx.getUser(userId);
const avatarMxc = member.getMxcAvatarUrl() || member.avatarUrl;
return (
<div className="profile-viewer">
<div className="profile-viewer__user">
<Avatar
imageSrc={!avatarMxc ? null : mx.mxcUrlToHttp(avatarMxc, 80, 80, 'crop')}
text={username}
text={username.slice(0, 1)}
bgColor={colorMXID(userId)}
size="large"
/>
<div className="profile-viewer__user__info">
<Text variant="s1" weight="medium">{twemojify(username)}</Text>
<Text variant="b2">{twemojify(userId)}</Text>
<Text variant="s1">{username}</Text>
<Text variant="b2">{userId}</Text>
</div>
<div className="profile-viewer__user__role">
<Text variant="b3">Role</Text>
@@ -283,7 +231,6 @@ function ProfileViewer() {
<SessionInfo userId={userId} />
{ userId !== mx.getUserId() && (
<ProfileFooter
roomId={roomId}
userId={userId}
onRequestClose={() => setIsOpen(false)}
/>
@@ -297,11 +244,10 @@ function ProfileViewer() {
className="profile-viewer__dialog"
isOpen={isOpen}
title={`${username} in ${room?.name ?? ''}`}
onAfterClose={handleAfterClose}
onRequestClose={() => setIsOpen(false)}
contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />}
>
{roomId ? renderProfile() : <div />}
{isOpen && renderProfile()}
</Dialog>
);
}

View File

@@ -1,13 +1,15 @@
@use '../../partials/dir';
.profile-viewer__dialog {
& .dialog__content__wrapper {
position: relative;
}
& .dialog__content-container {
padding-top: var(--sp-normal);
padding: var(--sp-normal);
padding-bottom: 89px;
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
padding-right: var(--sp-extra-tight);
[dir=rtl] & {
padding-right: var(--sp-normal);
padding-left: var(--sp-extra-tight);
}
}
}
@@ -18,19 +20,22 @@
border-bottom: 1px solid var(--bg-surface-border);
&__info {
align-self: flex-end;
align-self: end;
flex: 1;
min-width: 0;
margin: 0 var(--sp-normal);
& .text-s1 {
font-weight: 500;
}
& .text {
white-space: pre-wrap;
word-break: break-word;
}
}
&__role {
align-self: flex-end;
align-self: end;
& > .text {
margin-bottom: var(--sp-ultra-tight);
}
@@ -56,7 +61,11 @@
margin: 0 var(--sp-normal)
}
& > *:last-child {
@include dir.side(margin, auto, 0);
margin-left: auto;
[dir=rtl] & {
margin-left: 0;
margin-right: auto;
}
}
}
}
@@ -68,8 +77,13 @@
&__chips {
padding-top: var(--sp-ultra-tight);
& .chip {
margin-top: var(--sp-extra-tight);
@include dir.side(margin, 0, var(--sp-extra-tight));
margin: {
top: 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,7 +1,6 @@
@use '../../partials/dir';
.public-rooms {
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
margin: 0 var(--sp-normal);
margin-right: var(--sp-extra-tight);
margin-top: var(--sp-extra-tight);
&__form {
@@ -20,28 +19,33 @@
min-width: 0;
display: flex;
@include dir.side(margin, 0, var(--sp-normal));
margin-right: var(--sp-normal);
[dir=rtl] & {
margin-right: 0;
margin-left: var(--sp-normal);
}
& > div:first-child {
flex: 1;
min-width: 0;
& .input {
@include dir.prop(border-radius,
var(--bo-radius) 0 0 var(--bo-radius),
0 var(--bo-radius) var(--bo-radius) 0,
);
border-radius: var(--bo-radius) 0 0 var(--bo-radius);
[dir=rtl] & {
border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
}
}
}
& > div:last-child .input {
width: 120px;
@include dir.prop(border-left-width, 0, 1px);
@include dir.prop(border-right-width, 1px, 0);
@include dir.prop(border-radius,
0 var(--bo-radius) var(--bo-radius) 0,
var(--bo-radius) 0 0 var(--bo-radius),
);
border-left-width: 0;
border-radius: 0 var(--bo-radius) var(--bo-radius) 0;
[dir=rtl] & {
border-left-width: 1px;
border-right-width: 0;
border-radius: var(--bo-radius) 0 0 var(--bo-radius);
}
}
}
@@ -64,7 +68,11 @@
}
&__view-more {
margin-top: var(--sp-loose);
@include dir.side(margin, calc(var(--av-normal) + var(--sp-normal)), 0);
margin-left: calc(var(--av-normal) + var(--sp-normal));
[dir=rtl] & {
margin-left: 0;
margin-right: calc(var(--av-normal) + var(--sp-normal));
}
}
& .room-tile {
@@ -73,6 +81,13 @@
align-self: flex-end;
}
}
[dir=rtl] & {
margin: {
left: var(--sp-extra-tight);
right: var(--sp-normal);
}
}
}
.try-join-with-alias {

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,6 +61,7 @@ function PeopleDrawer({ roomId }) {
const PER_PAGE_MEMBER = 50;
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
let isRoomChanged = false;
const [itemCount, setItemCount] = useState(PER_PAGE_MEMBER);
const [membership, setMembership] = useState('join');
@@ -102,35 +103,30 @@ function PeopleDrawer({ roomId }) {
}, [memberList]);
useEffect(() => {
let isGettingMembers = true;
let isRoomChanged = false;
const updateMemberList = (event) => {
if (isGettingMembers) return;
if (event && event?.event?.room_id !== roomId) return;
searchRef.current.value = '';
setMemberList(
simplyfiMembers(
getMembersWithMembership(membership)
.sort(AtoZ).sort(sortByPowerLevel),
),
);
room.loadMembersIfNeeded().then(() => {
if (isRoomChanged) return;
setMemberList(
simplyfiMembers(
getMembersWithMembership(membership)
.sort(AtoZ).sort(sortByPowerLevel),
),
);
};
searchRef.current.value = '';
updateMemberList();
room.loadMembersIfNeeded().then(() => {
isGettingMembers = false;
if (isRoomChanged) return;
updateMemberList();
});
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchData);
mx.on('RoomMember.membership', updateMemberList);
return () => {
isRoomChanged = true;
setMemberList([]);
setSearchedMembers(null);
setItemCount(PER_PAGE_MEMBER);
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchData);
mx.removeListener('RoomMember.membership', updateMemberList);
};
}, [roomId, membership]);
@@ -143,7 +139,7 @@ function PeopleDrawer({ roomId }) {
<div className="people-drawer">
<Header>
<TitleWrapper>
<Text variant="s1" primary>
<Text variant="s1">
People
<Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text>
</Text>

View File

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

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