Compare commits
189 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d59a4de48 | ||
|
|
73c74d0477 | ||
|
|
70ffd7ded8 | ||
|
|
92a3a8d6fa | ||
|
|
6e9cd02b2b | ||
|
|
d0f90af251 | ||
|
|
c53eb27c6f | ||
|
|
38773e89ff | ||
|
|
19cb30d360 | ||
|
|
50a734b977 | ||
|
|
b988758ac2 | ||
|
|
2567328e8b | ||
|
|
a7568fcbbf | ||
|
|
27a06ae90c | ||
|
|
211fd19031 | ||
|
|
fe18611b4b | ||
|
|
5e9b45ad5f | ||
|
|
af833daee4 | ||
|
|
1ad5317d6e | ||
|
|
7cf5df80ce | ||
|
|
d6b880d110 | ||
|
|
cf58a4376e | ||
|
|
a76dcb289a | ||
|
|
48ec6224e7 | ||
|
|
127dd8baf4 | ||
|
|
d25c3ff4fc | ||
|
|
f0e9de4cf9 | ||
|
|
92607a788e | ||
|
|
82948c1f55 | ||
|
|
714929c72f | ||
|
|
98f0d384cb | ||
|
|
e3c87ecf65 | ||
|
|
1fb9e98eeb | ||
|
|
01d7c8ec36 | ||
|
|
22d8d5a0b8 | ||
|
|
631ed997ba | ||
|
|
01930ab0cf | ||
|
|
b8fe4c937e | ||
|
|
a7a5b08ad8 | ||
|
|
faaac72b81 | ||
|
|
8f41139076 | ||
|
|
e1a67acde1 | ||
|
|
4ab2af51a5 | ||
|
|
9ffc4eaa40 | ||
|
|
5eda2581f4 | ||
|
|
21ceb4fdc4 | ||
|
|
edcf43efba | ||
|
|
eb1ef16b5e | ||
|
|
6b9c8b7a87 | ||
|
|
a05b96e9a0 | ||
|
|
699f67aa75 | ||
|
|
76e469e444 | ||
|
|
8df630ee0c | ||
|
|
b0c4c53880 | ||
|
|
116a9c9de0 | ||
|
|
e63f8ab8b8 | ||
|
|
424245df18 | ||
|
|
094a11ec64 | ||
|
|
6b03c9c99f | ||
|
|
3d116af011 | ||
|
|
cbd965d8e5 | ||
|
|
c9d972fc12 | ||
|
|
da9979494e | ||
|
|
f5c907af33 | ||
|
|
906fc2dd3d | ||
|
|
a62df536dd | ||
|
|
7db674b65d | ||
|
|
784deaa6ea | ||
|
|
8bc41c2c32 | ||
|
|
79afc7649d | ||
|
|
2eee3736df | ||
|
|
a8ba38ef1e | ||
|
|
d3ddbc0c72 | ||
|
|
6c4085398e | ||
|
|
2308578622 | ||
|
|
47cd87e653 | ||
|
|
87c9684c71 | ||
|
|
0b66d73178 | ||
|
|
24cd238eaf | ||
|
|
72bb9b80ad | ||
|
|
3d42ac8e44 | ||
|
|
205ab3b16d | ||
|
|
bb25da274d | ||
|
|
b56de35664 | ||
|
|
0655ac82f2 | ||
|
|
166dbfa491 | ||
|
|
9348977548 | ||
|
|
769c3eea9b | ||
|
|
e7095af5b8 | ||
|
|
fd42d72423 | ||
|
|
1382c455a2 | ||
|
|
1d7fbc841e | ||
|
|
a8a168b0a7 | ||
|
|
dbd1d02485 | ||
|
|
1c307daa03 | ||
|
|
7a31f84d34 | ||
|
|
a2a4dd9862 | ||
|
|
3d8e0f6c23 | ||
|
|
146da2d072 | ||
|
|
6a9964e889 | ||
|
|
318e7c7458 | ||
|
|
f8e2d27bb0 | ||
|
|
44544f3289 | ||
|
|
76a5c99b08 | ||
|
|
70f0f91a17 | ||
|
|
f1394239a3 | ||
|
|
9e810502c2 | ||
|
|
c67a1752c5 | ||
|
|
d87e40ada5 | ||
|
|
86bbaa437e | ||
|
|
96b6b56d95 | ||
|
|
1f6e5e71ef | ||
|
|
a55f1df17f | ||
|
|
e654226e60 | ||
|
|
189dc93a6e | ||
|
|
76cb52878c | ||
|
|
8d3f0a9f4d | ||
|
|
fe674ef2ea | ||
|
|
33b165518e | ||
|
|
ff9d509137 | ||
|
|
278fd5bd59 | ||
|
|
8ddbc24dd4 | ||
|
|
e93f429906 | ||
|
|
1dccb1bb64 | ||
|
|
6d9e67b9f2 | ||
|
|
4803d48ec7 | ||
|
|
7c6a12ea8e | ||
|
|
a17d5d01a7 | ||
|
|
cdd909f2dd | ||
|
|
d0e9728c26 | ||
|
|
c8ae428df8 | ||
|
|
a9692f7db4 | ||
|
|
d05a426d00 | ||
|
|
a842b4c4d9 | ||
|
|
101abaeda1 | ||
|
|
bb99167433 | ||
|
|
e5b57e5ff9 | ||
|
|
2bd598f7f8 | ||
|
|
b008c5f07f | ||
|
|
a59aa2c30f | ||
|
|
93de0d4bf3 | ||
|
|
7a0ac2077c | ||
|
|
952f459e70 | ||
|
|
4702196c6e | ||
|
|
f77e064f4e | ||
|
|
c74a9a30bf | ||
|
|
1d43b36380 | ||
|
|
736f4edfbd | ||
|
|
c7e2540f6f | ||
|
|
f796296286 | ||
|
|
eb206d2abb | ||
|
|
bff62fb105 | ||
|
|
5fc83d7615 | ||
|
|
552a324e08 | ||
|
|
b155d7d1ba | ||
|
|
e4571bf668 | ||
|
|
53cc929a72 | ||
|
|
20f5a4cc91 | ||
|
|
2aabe559bf | ||
|
|
dcf0902c5b | ||
|
|
0e8219b200 | ||
|
|
5f7fa0557f | ||
|
|
63fb4d57e2 | ||
|
|
b5d6f44f4c | ||
|
|
b21c8f8c3a | ||
|
|
fe7c7660d3 | ||
|
|
56de33c821 | ||
|
|
1d442cd0e2 | ||
|
|
c79d7957f6 | ||
|
|
1b9216b341 | ||
|
|
6d97e5fa5d | ||
|
|
162af35254 | ||
|
|
abe03811f1 | ||
|
|
e85a869733 | ||
|
|
0c32d5302c | ||
|
|
f310196937 | ||
|
|
c828dfd596 | ||
|
|
d0b4e092b3 | ||
|
|
91d7d78621 | ||
|
|
2bc21f13d4 | ||
|
|
833edc9568 | ||
|
|
9d8efce26d | ||
|
|
6d778ca223 | ||
|
|
1e739e94e2 | ||
|
|
8c013aa2a9 | ||
|
|
fb0a0b0dc2 | ||
|
|
ab3d9ddb13 | ||
|
|
a67b32b3ce | ||
|
|
e15e690c5d |
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.git/
|
||||
@@ -20,5 +20,9 @@ module.exports = {
|
||||
rules: {
|
||||
'linebreak-style': 0,
|
||||
'no-underscore-dangle': 0,
|
||||
'react/no-unstable-nested-components': [
|
||||
'error',
|
||||
{ allowAsProps: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
28
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Docs: <https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/customizing-dependency-updates>
|
||||
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: "tuesday"
|
||||
time: "01:00"
|
||||
timezone: "Asia/Kolkata"
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: "tuesday"
|
||||
time: "01:00"
|
||||
timezone: "Asia/Kolkata"
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: "tuesday"
|
||||
time: "01:00"
|
||||
timezone: "Asia/Kolkata"
|
||||
59
.github/workflows/build-pull-request.yml
vendored
@@ -1,32 +1,39 @@
|
||||
name: 'Build PR'
|
||||
name: 'Build pull request'
|
||||
|
||||
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
|
||||
build-pull-request:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PR_NUMBER: ${{github.event.number}}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3.0.0
|
||||
- name: Build app
|
||||
run: npm ci && npm run build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3.0.0
|
||||
with:
|
||||
name: previewbuild
|
||||
path: dist
|
||||
retention-days: 1
|
||||
- name: Get PR info
|
||||
uses: actions/github-script@v6.0.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@v3.0.0
|
||||
with:
|
||||
name: pr.json
|
||||
path: pr.json
|
||||
retention-days: 1
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v2.10.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
|
||||
150
.github/workflows/deploy-pull-request.yml
vendored
@@ -1,78 +1,78 @@
|
||||
name: Upload Preview Build to Netlify
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build PR"]
|
||||
types:
|
||||
- completed
|
||||
workflow_run:
|
||||
workflows: ["Build pull request"]
|
||||
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.
|
||||
get-build-and-deploy:
|
||||
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@v6.0.0
|
||||
with:
|
||||
script: |
|
||||
var artifacts = await github.rest.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.rest.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.rest.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@v6.0.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.3
|
||||
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 }}
|
||||
⚠️ Exercise caution. Use test accounts. ⚠️
|
||||
|
||||
34
.github/workflows/docker.yaml
vendored
@@ -1,34 +0,0 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: ajbura/cinny
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
7
.github/workflows/netlify-dev.yaml
vendored
@@ -11,11 +11,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: jsmrcaga/action-netlify-deploy@9cc40dcd499dd1511b3cc99912444f8970411cc6
|
||||
- uses: actions/checkout@v3.0.0
|
||||
- uses: jsmrcaga/action-netlify-deploy@v1.7.2
|
||||
with:
|
||||
install_command: "npm ci"
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE2_ID }}
|
||||
BUILD_DIRECTORY: "dist"
|
||||
NETLIFY_DEPLOY_MESSAGE: "Dev deploy v${{ github.ref }}"
|
||||
NETLIFY_DEPLOY_TO_PROD: true
|
||||
NETLIFY_DEPLOY_TO_PROD: true
|
||||
|
||||
20
.github/workflows/netlify-prod.yaml
vendored
@@ -1,20 +0,0 @@
|
||||
name: 'Deploy to Netlify (prod)'
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: 'Deploy'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: jsmrcaga/action-netlify-deploy@9cc40dcd499dd1511b3cc99912444f8970411cc6
|
||||
with:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
BUILD_DIRECTORY: "dist"
|
||||
NETLIFY_DEPLOY_MESSAGE: "Prod deploy v${{ github.ref }}"
|
||||
NETLIFY_DEPLOY_TO_PROD: true
|
||||
56
.github/workflows/prod-deploy.yaml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: 'Production deploy'
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
deploy-to-netlify:
|
||||
name: 'Deploy to Netlify'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3.0.0
|
||||
- name: Build and deploy to Netlify
|
||||
uses: jsmrcaga/action-netlify-deploy@v1.7.2
|
||||
with:
|
||||
install_command: "npm ci"
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
BUILD_DIRECTORY: "dist"
|
||||
NETLIFY_DEPLOY_MESSAGE: "Prod deploy v${{ github.ref }}"
|
||||
NETLIFY_DEPLOY_TO_PROD: true
|
||||
- name: Get version from tag
|
||||
id: vars
|
||||
run: echo ::set-output name=tag::${GITHUB_REF#refs/*/}
|
||||
- name: Create tar.gz
|
||||
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
|
||||
- name: Upload tagged release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
|
||||
push_to_dockerhub:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1.14.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3.6.2
|
||||
with:
|
||||
images: ajbura/cinny
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v2.10.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
14
Dockerfile
@@ -1,20 +1,18 @@
|
||||
## Builder
|
||||
FROM node:14-alpine as builder
|
||||
FROM node:17.7.1-alpine3.15 as builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY . /src
|
||||
RUN npm install \
|
||||
&& npm run build
|
||||
COPY package.json package-lock.json /src/
|
||||
RUN npm ci
|
||||
COPY . /src/
|
||||
RUN npm run build
|
||||
|
||||
|
||||
## App
|
||||
FROM nginx:alpine
|
||||
FROM nginx:1.21.6-alpine
|
||||
|
||||
COPY --from=builder /src/dist /app
|
||||
|
||||
# Insert wasm type into Nginx mime.types file so they load correctly.
|
||||
RUN sed -i '3i\ \ \ \ application/wasm wasm\;' /etc/nginx/mime.types
|
||||
|
||||
RUN rm -rf /usr/share/nginx/html \
|
||||
&& ln -s /app /usr/share/nginx/html
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
- [About](#about)
|
||||
- [Getting Started](https://cinny.in)
|
||||
- [Contributing](./CONTRIBUTING.md)
|
||||
- [Roadmap](https://github.com/ajbura/cinny/projects/11)
|
||||
|
||||
## About <a name = "about"></a>
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"defaultHomeserver": 5,
|
||||
"defaultHomeserver": 4,
|
||||
"homeserverList": [
|
||||
"boba.best",
|
||||
"converser.eu",
|
||||
"envs.net",
|
||||
"halogen.city",
|
||||
"kde.org",
|
||||
"matrix.org",
|
||||
"mozilla.modular.im"
|
||||
"chat.mozilla.org"
|
||||
]
|
||||
}
|
||||
10716
package-lock.json
generated
75
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cinny",
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.1",
|
||||
"description": "Yet another matrix client",
|
||||
"main": "index.js",
|
||||
"engines": {
|
||||
@@ -15,64 +15,71 @@
|
||||
"author": "Ajay Bura",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^4.5.5",
|
||||
"@fontsource/roboto": "^4.5.3",
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
|
||||
"@tippyjs/react": "^4.2.5",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"browser-encrypt-attachment": "^0.3.0",
|
||||
"dateformat": "^4.5.1",
|
||||
"emojibase-data": "^6.2.0",
|
||||
"dateformat": "^5.0.3",
|
||||
"emojibase-data": "^7.0.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"flux": "^4.0.1",
|
||||
"flux": "^4.0.3",
|
||||
"formik": "^2.2.9",
|
||||
"html-react-parser": "^1.2.7",
|
||||
"html-react-parser": "^1.4.8",
|
||||
"linkifyjs": "^2.1.9",
|
||||
"matrix-js-sdk": "^15.2.1",
|
||||
"micromark": "^3.0.3",
|
||||
"micromark-extension-gfm": "^1.0.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"matrix-js-sdk": "^15.6.0",
|
||||
"micromark": "^3.0.10",
|
||||
"micromark-extension-gfm": "^2.0.1",
|
||||
"micromark-util-chunked": "^1.0.0",
|
||||
"micromark-util-resolve-all": "^1.0.0",
|
||||
"micromark-util-symbol": "^1.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^17.0.2",
|
||||
"react-autosize-textarea": "^7.1.0",
|
||||
"react-dnd": "^15.1.1",
|
||||
"react-dnd-html5-backend": "^15.1.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-google-recaptcha": "^2.1.0",
|
||||
"react-modal": "^3.13.1",
|
||||
"sanitize-html": "^2.5.3",
|
||||
"tippy.js": "^6.3.1",
|
||||
"twemoji": "^13.1.0"
|
||||
"react-modal": "^3.14.4",
|
||||
"sanitize-html": "^2.7.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"twemoji": "^14.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.15.5",
|
||||
"@babel/preset-env": "^7.13.12",
|
||||
"@babel/preset-react": "^7.13.13",
|
||||
"@babel/core": "^7.17.7",
|
||||
"@babel/preset-env": "^7.16.11",
|
||||
"@babel/preset-react": "^7.16.7",
|
||||
"assert": "^2.0.0",
|
||||
"babel-loader": "^8.2.2",
|
||||
"browserify-fs": "^1.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"copy-webpack-plugin": "^9.0.1",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"copy-webpack-plugin": "^10.2.4",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"css-loader": "^5.2.0",
|
||||
"css-minimizer-webpack-plugin": "^1.3.0",
|
||||
"eslint": "^7.23.0",
|
||||
"eslint-config-airbnb": "^18.2.1",
|
||||
"css-loader": "^6.7.1",
|
||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||
"eslint": "^8.11.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-react": "^7.23.1",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"favicons": "^6.2.1",
|
||||
"favicons": "^6.2.2",
|
||||
"favicons-webpack-plugin": "^5.0.2",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^2.1.2",
|
||||
"html-loader": "^3.1.0",
|
||||
"html-webpack-plugin": "^5.3.1",
|
||||
"mini-css-extract-plugin": "^1.4.0",
|
||||
"mini-css-extract-plugin": "^2.6.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"sass": "^1.32.8",
|
||||
"sass-loader": "^11.0.1",
|
||||
"sass": "^1.49.9",
|
||||
"sass-loader": "^12.6.0",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"style-loader": "^2.0.0",
|
||||
"style-loader": "^3.3.1",
|
||||
"url": "^0.11.0",
|
||||
"util": "^0.12.4",
|
||||
"webpack": "^5.62.1",
|
||||
"webpack-cli": "^4.9.1",
|
||||
"webpack-dev-server": "^4.4.0",
|
||||
"webpack": "^5.70.0",
|
||||
"webpack-cli": "^4.9.2",
|
||||
"webpack-dev-server": "^4.7.4",
|
||||
"webpack-merge": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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">
|
||||
<title>Cinny</title>
|
||||
<meta name="name" content="Cinny">
|
||||
<meta name="author" content="Ajay Bura">
|
||||
|
||||
395
public/res/LICENSE
Normal file
@@ -0,0 +1,395 @@
|
||||
Attribution 4.0 International
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||
does not provide legal services or legal advice. Distribution of
|
||||
Creative Commons public licenses does not create a lawyer-client or
|
||||
other relationship. Creative Commons makes its licenses and related
|
||||
information available on an "as-is" basis. Creative Commons gives no
|
||||
warranties regarding its licenses, any material licensed under their
|
||||
terms and conditions, or any related information. Creative Commons
|
||||
disclaims all liability for damages resulting from their use to the
|
||||
fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and
|
||||
conditions that creators and other rights holders may use to share
|
||||
original works of authorship and other material subject to copyright
|
||||
and certain other rights specified in the public license below. The
|
||||
following considerations are for informational purposes only, are not
|
||||
exhaustive, and do not form part of our licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are
|
||||
intended for use by those authorized to give the public
|
||||
permission to use material in ways otherwise restricted by
|
||||
copyright and certain other rights. Our licenses are
|
||||
irrevocable. Licensors should read and understand the terms
|
||||
and conditions of the license they choose before applying it.
|
||||
Licensors should also secure all rights necessary before
|
||||
applying our licenses so that the public can reuse the
|
||||
material as expected. Licensors should clearly mark any
|
||||
material not subject to the license. This includes other CC-
|
||||
licensed material, or material used under an exception or
|
||||
limitation to copyright. More considerations for licensors:
|
||||
wiki.creativecommons.org/Considerations_for_licensors
|
||||
|
||||
Considerations for the public: By using one of our public
|
||||
licenses, a licensor grants the public permission to use the
|
||||
licensed material under specified terms and conditions. If
|
||||
the licensor's permission is not necessary for any reason--for
|
||||
example, because of any applicable exception or limitation to
|
||||
copyright--then that use is not regulated by the license. Our
|
||||
licenses grant only permissions under copyright and certain
|
||||
other rights that a licensor has authority to grant. Use of
|
||||
the licensed material may still be restricted for other
|
||||
reasons, including because others have copyright or other
|
||||
rights in the material. A licensor may make special requests,
|
||||
such as asking that all changes be marked or described.
|
||||
Although not required by our licenses, you are encouraged to
|
||||
respect those requests where reasonable. More considerations
|
||||
for the public:
|
||||
wiki.creativecommons.org/Considerations_for_licensees
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Attribution 4.0 International Public License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution 4.0 International Public License ("Public License"). To the
|
||||
extent this Public License may be interpreted as a contract, You are
|
||||
granted the Licensed Rights in consideration of Your acceptance of
|
||||
these terms and conditions, and the Licensor grants You such rights in
|
||||
consideration of benefits the Licensor receives from making the
|
||||
Licensed Material available under these terms and conditions.
|
||||
|
||||
|
||||
Section 1 -- Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
performance, broadcast, sound recording, and Sui Generis Database
|
||||
Rights, without regard to how the rights are labeled or
|
||||
categorized. For purposes of this Public License, the rights
|
||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||
Rights.
|
||||
|
||||
d. Effective Technological Measures means those measures that, in the
|
||||
absence of proper authority, may not be circumvented under laws
|
||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||
Treaty adopted on December 20, 1996, and/or similar international
|
||||
agreements.
|
||||
|
||||
e. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||
any other exception or limitation to Copyright and Similar Rights
|
||||
that applies to Your use of the Licensed Material.
|
||||
|
||||
f. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
g. Licensed Rights means the rights granted to You subject to the
|
||||
terms and conditions of this Public License, which are limited to
|
||||
all Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
h. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
i. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
j. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and of
|
||||
the Council of 11 March 1996 on the legal protection of databases,
|
||||
as amended and/or succeeded, as well as other essentially
|
||||
equivalent rights anywhere in the world.
|
||||
|
||||
k. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
|
||||
Section 2 -- Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
a. reproduce and Share the Licensed Material, in whole or
|
||||
in part; and
|
||||
|
||||
b. produce, reproduce, and Share Adapted Material.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||
Exceptions and Limitations apply to Your use, this Public
|
||||
License does not apply, and You do not need to comply with
|
||||
its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section
|
||||
6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
a. Offer from the Licensor -- Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
b. No downstream restrictions. You may not offer or impose
|
||||
any additional or different terms or conditions on, or
|
||||
apply any Effective Technological Measures to, the
|
||||
Licensed Material if doing so restricts exercise of the
|
||||
Licensed Rights by any recipient of the Licensed
|
||||
Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not
|
||||
licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to
|
||||
the extent possible, the Licensor waives and/or agrees not to
|
||||
assert any such rights held by the Licensor to the limited
|
||||
extent necessary to allow You to exercise the Licensed
|
||||
Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this
|
||||
Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to
|
||||
collect royalties from You for the exercise of the Licensed
|
||||
Rights, whether directly or through a collecting society
|
||||
under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly
|
||||
reserves any right to collect such royalties.
|
||||
|
||||
|
||||
Section 3 -- License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the
|
||||
following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
a. retain the following if it is supplied by the Licensor
|
||||
with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed
|
||||
Material and any others designated to receive
|
||||
attribution, in any reasonable manner requested by
|
||||
the Licensor (including by pseudonym if
|
||||
designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the
|
||||
extent reasonably practicable;
|
||||
|
||||
b. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
c. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI or
|
||||
hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may be
|
||||
reasonable to satisfy the conditions by providing a URI or
|
||||
hyperlink to a resource that includes the required
|
||||
information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
4. If You Share Adapted Material You produce, the Adapter's
|
||||
License You apply must not prevent recipients of the Adapted
|
||||
Material from complying with this Public License.
|
||||
|
||||
|
||||
Section 4 -- Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material; and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights include other Copyright and Similar Rights.
|
||||
|
||||
|
||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||
|
||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
|
||||
Section 6 -- Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply with
|
||||
this Public License, then Your rights under this Public License
|
||||
terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under
|
||||
Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided
|
||||
it is cured within 30 days of Your discovery of the
|
||||
violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
|
||||
Section 7 -- Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
|
||||
Section 8 -- Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is
|
||||
deemed unenforceable, it shall be automatically reformed to the
|
||||
minimum extent necessary to make it enforceable. If the provision
|
||||
cannot be reformed, it shall be severed from this Public License
|
||||
without affecting the enforceability of the remaining terms and
|
||||
conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no
|
||||
failure to comply consented to unless expressly agreed to by the
|
||||
Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons is not a party to its public
|
||||
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||
its public licenses to material it publishes and in those instances
|
||||
will be considered the “Licensor.” The text of the Creative Commons
|
||||
public licenses is dedicated to the public domain under the CC0 Public
|
||||
Domain Dedication. Except for the limited purpose of indicating that
|
||||
material is shared under a Creative Commons public license or as
|
||||
otherwise permitted by the Creative Commons policies published at
|
||||
creativecommons.org/policies, Creative Commons does not authorize the
|
||||
use of the trademark "Creative Commons" or any other trademark or logo
|
||||
of Creative Commons without its prior written consent including,
|
||||
without limitation, in connection with any unauthorized modifications
|
||||
to any of its public licenses or any other arrangements,
|
||||
understandings, or agreements concerning use of licensed material. For
|
||||
the avoidance of doubt, this paragraph does not form part of the
|
||||
public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
||||
7
public/res/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
Graphics (c) by Ajay Bura (ajbura)
|
||||
|
||||
Graphic content is licensed under a
|
||||
Creative Commons Attribution 4.0 International License.
|
||||
|
||||
You should have received a copy of the license along with this
|
||||
work. If not, see <http://creativecommons.org/licenses/by/4.0/>;.
|
||||
@@ -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.
|
||||
18
public/res/ic/filled/category.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||
<g>
|
||||
<path fill="#231F20" d="M9,11H5c-1.1,0-2-0.9-2-2V5c0-1.1,0.9-2,2-2h4c1.1,0,2,0.9,2,2v4C11,10.1,10.1,11,9,11z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#231F20" d="M19,11h-4c-1.1,0-2-0.9-2-2V5c0-1.1,0.9-2,2-2h4c1.1,0,2,0.9,2,2v4C21,10.1,20.1,11,19,11z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#231F20" d="M9,21H5c-1.1,0-2-0.9-2-2v-4c0-1.1,0.9-2,2-2h4c1.1,0,2,0.9,2,2v4C11,20.1,10.1,21,9,21z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#231F20" d="M19,21h-4c-1.1,0-2-0.9-2-2v-4c0-1.1,0.9-2,2-2h4c1.1,0,2,0.9,2,2v4C21,20.1,20.1,21,19,21z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 940 B |
11
public/res/ic/outlined/add-pin.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M13.8,4.7l0.7,0.7l-3.4,3.4L7.7,10l-1-1l-1.4,1.4l3.5,3.5l-5.7,5.7L4.6,21l5.7-5.7l3.5,3.5l1.4-1.4l-1-1l1.1-3.4l3.4-3.4
|
||||
l0.7,0.7L20.9,9l-5.7-5.7L13.8,4.7z M13.7,12l-1,2.9l-3.4-3.4l2.9-1l3.7-3.7l1.4,1.4L13.7,12z"/>
|
||||
<polygon points="10,3.3 7.8,3.3 7.8,1 6.3,1 6.3,3.3 4,3.3 4,4.8 6.3,4.8 6.3,7 7.8,7 7.8,4.8 10,4.8 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 781 B |
15
public/res/ic/outlined/category.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||
<g>
|
||||
<path fill="#221F1F" d="M9,5v4H5V5H9 M9,3H5C3.9,3,3,3.9,3,5v4c0,1.1,0.9,2,2,2h4c1.1,0,2-0.9,2-2V5C11,3.9,10.1,3,9,3L9,3z"/>
|
||||
<path fill="#221F1F" d="M19,5v4h-4V5H19 M19,3h-4c-1.1,0-2,0.9-2,2v4c0,1.1,0.9,2,2,2h4c1.1,0,2-0.9,2-2V5C21,3.9,20.1,3,19,3L19,3
|
||||
z"/>
|
||||
<path fill="#221F1F" d="M9,15v4H5v-4H9 M9,13H5c-1.1,0-2,0.9-2,2v4c0,1.1,0.9,2,2,2h4c1.1,0,2-0.9,2-2v-4C11,13.9,10.1,13,9,13
|
||||
L9,13z"/>
|
||||
<path fill="#221F1F" d="M19,15v4h-4v-4H19 M19,13h-4c-1.1,0-2,0.9-2,2v4c0,1.1,0.9,2,2,2h4c1.1,0,2-0.9,2-2v-4
|
||||
C21,13.9,20.1,13,19,13L19,13z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 995 B |
11
public/res/ic/outlined/horizontal-menu.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||
<g>
|
||||
<circle cx="5" cy="12" r="2"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<circle cx="19" cy="12" r="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 551 B |
16
public/res/ic/outlined/recent-clock.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||
<g>
|
||||
<polygon fill="#010101" points="11,6 11,12 15.2,16.2 16.7,14.8 13,11.2 13,6 "/>
|
||||
<path fill="#010101" d="M12,2C6.5,2,2,6.5,2,12H0.2L3,14.8L5.8,12H4c0-4.4,3.6-8,8-8s8,3.6,8,8s-3.6,8-8,8c-1.9,0-3.7-0.7-5-1.8
|
||||
l-1.2,1.6C7.4,21.2,9.6,22,12,22c5.5,0,10-4.5,10-10S17.5,2,12,2z"/>
|
||||
</g>
|
||||
<g>
|
||||
<polygon fill="#010101" points="49,44 49,50 53.2,54.2 54.7,52.8 51,49.2 51,44 "/>
|
||||
<path fill="#010101" d="M50,40c-5.5,0-10,4.5-10,10h-1.8l2.8,2.8l2.8-2.8H42c0-4.4,3.6-8,8-8s8,3.6,8,8s-3.6,8-8,8
|
||||
c-1.9,0-3.7-0.7-5-1.8l-1.2,1.6c1.7,1.4,3.9,2.2,6.3,2.2c5.5,0,10-4.5,10-10S55.5,40,50,40z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
12
public/res/ic/outlined/space-plus.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||
<g>
|
||||
<path d="M15.4,13.7L14,14l-0.2,1.4c-0.4,2.8-1.3,4.2-1.7,4.6c-0.4-0.3-1.3-1.8-1.7-4.6L10,14l-1.4-0.2c-2.8-0.4-4.2-1.3-4.6-1.7
|
||||
c0.3-0.4,1.8-1.3,4.6-1.7L10,10l0.2-1.4c0.4-2.8,1.3-4.2,1.7-4.6V2c-1.7,0-3.1,2.6-3.7,6.3C4.6,8.9,2,10.3,2,12s2.6,3.1,6.3,3.7
|
||||
c0.6,3.7,2,6.3,3.7,6.3s3.1-2.6,3.7-6.3c3.7-0.6,6.3-2,6.3-3.7h-2.1C19.6,12.4,18.2,13.3,15.4,13.7z"/>
|
||||
<path d="M19,0c-2.8,0-5,2.2-5,5s2.2,5,5,5s5-2.2,5-5S21.8,0,19,0z M22,5.8h-2.3V8h-1.5V5.8H16V4.3h2.3V2h1.5v2.3H22V5.8z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 930 B |
@@ -1,49 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 13.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 14576) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="789.322px" height="336.807px" viewBox="0 0 789.322 336.807" enable-background="new 0 0 789.322 336.807"
|
||||
xml:space="preserve">
|
||||
<path d="M8.876,7.71v321.386h23.13v7.711H0V0h32.006v7.71H8.876z"/>
|
||||
<path d="M97.989,109.594v16.264h0.463c4.338-6.191,9.563-10.998,15.684-14.406c6.117-3.402,13.129-5.11,21.027-5.11
|
||||
c7.588,0,14.521,1.475,20.793,4.415c6.274,2.945,11.038,8.131,14.291,15.567c3.56-5.265,8.4-9.913,14.521-13.94
|
||||
c6.117-4.025,13.358-6.042,21.724-6.042c6.351,0,12.234,0.776,17.66,2.325c5.418,1.549,10.065,4.027,13.938,7.434
|
||||
c3.869,3.41,6.889,7.863,9.062,13.357c2.167,5.504,3.253,12.122,3.253,19.869v80.385h-32.993v-68.074
|
||||
c0-4.025-0.154-7.82-0.465-11.385c-0.313-3.56-1.161-6.656-2.555-9.293c-1.395-2.631-3.45-4.724-6.157-6.274
|
||||
c-2.711-1.543-6.391-2.322-11.037-2.322s-8.403,0.896-11.269,2.671c-2.868,1.784-5.112,4.109-6.737,6.971
|
||||
c-1.626,2.869-2.711,6.12-3.252,9.762c-0.545,3.638-0.814,7.318-0.814,11.035v66.91h-32.991v-67.375c0-3.562-0.081-7.087-0.23-10.57
|
||||
c-0.158-3.487-0.814-6.7-1.978-9.645c-1.162-2.94-3.099-5.304-5.809-7.088c-2.711-1.775-6.699-2.671-11.965-2.671
|
||||
c-1.551,0-3.603,0.349-6.156,1.048c-2.556,0.697-5.036,2.016-7.435,3.949c-2.404,1.938-4.454,4.726-6.158,8.363
|
||||
c-1.705,3.642-2.556,8.402-2.556,14.287v69.701h-32.99V109.594H97.989z"/>
|
||||
<path d="M271.545,127.254c3.405-5.113,7.744-9.215,13.012-12.316c5.264-3.097,11.186-5.303,17.771-6.621
|
||||
c6.582-1.315,13.205-1.976,19.865-1.976c6.042,0,12.158,0.428,18.354,1.277c6.195,0.855,11.85,2.522,16.962,4.997
|
||||
c5.111,2.477,9.292,5.926,12.546,10.338c3.253,4.414,4.879,10.262,4.879,17.543v62.494c0,5.428,0.31,10.611,0.931,15.567
|
||||
c0.615,4.959,1.701,8.676,3.251,11.153h-33.455c-0.621-1.86-1.126-3.755-1.511-5.693c-0.39-1.933-0.661-3.908-0.813-5.923
|
||||
c-5.267,5.422-11.465,9.217-18.585,11.386c-7.127,2.163-14.407,3.251-21.842,3.251c-5.733,0-11.077-0.698-16.033-2.09
|
||||
c-4.958-1.395-9.293-3.562-13.01-6.51c-3.718-2.938-6.622-6.656-8.713-11.147s-3.138-9.84-3.138-16.033
|
||||
c0-6.813,1.199-12.43,3.604-16.84c2.399-4.417,5.495-7.939,9.295-10.575c3.793-2.632,8.129-4.607,13.01-5.923
|
||||
c4.878-1.315,9.795-2.358,14.752-3.137c4.957-0.772,9.835-1.393,14.638-1.857c4.801-0.466,9.062-1.164,12.779-2.093
|
||||
c3.718-0.929,6.658-2.282,8.829-4.065c2.165-1.781,3.172-4.375,3.02-7.785c0-3.56-0.58-6.389-1.742-8.479
|
||||
c-1.161-2.09-2.711-3.719-4.646-4.88c-1.937-1.161-4.183-1.936-6.737-2.325c-2.557-0.382-5.309-0.58-8.248-0.58
|
||||
c-6.506,0-11.617,1.395-15.335,4.183c-3.716,2.788-5.889,7.437-6.506,13.94h-32.991C266.2,138.793,268.133,132.362,271.545,127.254z
|
||||
M336.714,173.837c-2.09,0.696-4.337,1.275-6.736,1.741c-2.402,0.465-4.918,0.853-7.551,1.161c-2.635,0.313-5.268,0.698-7.899,1.163
|
||||
c-2.48,0.461-4.919,1.086-7.317,1.857c-2.404,0.779-4.495,1.822-6.274,3.138c-1.784,1.317-3.216,2.985-4.3,4.994
|
||||
c-1.085,2.014-1.626,4.571-1.626,7.668c0,2.94,0.541,5.422,1.626,7.431c1.084,2.017,2.558,3.604,4.416,4.765
|
||||
s4.025,1.976,6.507,2.438c2.475,0.466,5.031,0.698,7.665,0.698c6.505,0,11.537-1.082,15.103-3.253
|
||||
c3.561-2.166,6.192-4.762,7.899-7.785c1.702-3.019,2.749-6.072,3.137-9.174c0.384-3.097,0.58-5.576,0.58-7.434V170.93
|
||||
C340.548,172.172,338.806,173.139,336.714,173.837z"/>
|
||||
<path d="M461.826,109.594v22.072h-24.161v59.479c0,5.573,0.928,9.292,2.788,11.149c1.856,1.859,5.576,2.788,11.152,2.788
|
||||
c1.859,0,3.638-0.076,5.343-0.232c1.703-0.152,3.33-0.388,4.878-0.696v25.557c-2.788,0.465-5.887,0.773-9.293,0.931
|
||||
c-3.407,0.149-6.737,0.23-9.99,0.23c-5.111,0-9.953-0.35-14.521-1.048c-4.571-0.695-8.597-2.047-12.081-4.063
|
||||
c-3.486-2.011-6.236-4.88-8.248-8.597c-2.016-3.714-3.021-8.595-3.021-14.639v-70.859h-19.98v-22.072h19.98V73.582h32.992v36.012
|
||||
H461.826z"/>
|
||||
<path d="M508.989,109.594v22.306h0.465c1.546-3.72,3.636-7.163,6.272-10.341c2.634-3.172,5.652-5.885,9.06-8.131
|
||||
c3.405-2.242,7.047-3.985,10.923-5.228c3.868-1.237,7.898-1.859,12.081-1.859c2.168,0,4.566,0.39,7.202,1.163v30.67
|
||||
c-1.551-0.312-3.41-0.584-5.576-0.814c-2.17-0.233-4.26-0.35-6.274-0.35c-6.041,0-11.152,1.01-15.332,3.021
|
||||
c-4.182,2.014-7.55,4.761-10.107,8.247c-2.555,3.487-4.379,7.55-5.462,12.198c-1.083,4.645-1.625,9.682-1.625,15.102v54.133h-32.991
|
||||
V109.594H508.989z"/>
|
||||
<path d="M568.931,91.006V63.823h32.994v27.183H568.931z M601.925,109.594v120.117h-32.994V109.594H601.925z"/>
|
||||
<path d="M619.116,109.594h37.637l21.144,31.365l20.911-31.365h36.476l-39.496,56.226l44.377,63.892h-37.64l-25.093-37.87
|
||||
l-25.094,37.87H615.4l43.213-63.193L619.116,109.594z"/>
|
||||
<path d="M780.444,329.096V7.71h-23.13V0h32.008v336.807h-32.008v-7.711H780.444z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -8,20 +8,29 @@ import Text from '../text/Text';
|
||||
import RawIcon from '../system-icons/RawIcon';
|
||||
|
||||
import ImageBrokenSVG from '../../../../public/res/svg/image-broken.svg';
|
||||
import { avatarInitials } from '../../../util/common';
|
||||
|
||||
function Avatar({
|
||||
const Avatar = React.forwardRef(({
|
||||
text, bgColor, iconSrc, iconColor, imageSrc, size,
|
||||
}) {
|
||||
}, ref) => {
|
||||
let textSize = 's1';
|
||||
if (size === 'large') textSize = 'h1';
|
||||
if (size === 'small') textSize = 'b1';
|
||||
if (size === 'extra-small') textSize = 'b3';
|
||||
|
||||
return (
|
||||
<div className={`avatar-container avatar-container__${size} noselect`}>
|
||||
<div ref={ref} className={`avatar-container avatar-container__${size} noselect`}>
|
||||
{
|
||||
imageSrc !== null
|
||||
? <img draggable="false" src={imageSrc} onError={(e) => { e.target.src = ImageBrokenSVG; }} alt="avatar" />
|
||||
? (
|
||||
<img
|
||||
draggable="false"
|
||||
src={imageSrc}
|
||||
onLoad={(e) => { e.target.style.backgroundColor = 'transparent'; }}
|
||||
onError={(e) => { e.target.src = ImageBrokenSVG; }}
|
||||
alt=""
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<span
|
||||
style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }}
|
||||
@@ -32,7 +41,7 @@ function Avatar({
|
||||
? <RawIcon size={size} src={iconSrc} color={iconColor} />
|
||||
: text !== null && (
|
||||
<Text variant={textSize} primary>
|
||||
{twemojify([...text][0])}
|
||||
{twemojify(avatarInitials(text))}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -41,7 +50,7 @@ function Avatar({
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Avatar.defaultProps = {
|
||||
text: null,
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
background-color: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
.avatar__border {
|
||||
|
||||
61
src/app/atoms/avatar/render.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { avatarInitials } from '../../../util/common';
|
||||
|
||||
function cssVar(name) {
|
||||
return getComputedStyle(document.body).getPropertyValue(name);
|
||||
}
|
||||
|
||||
// renders the avatar and returns it as an URL
|
||||
export default async function renderAvatar({
|
||||
text, bgColor, imageSrc, size, borderRadius, scale,
|
||||
}) {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size * scale;
|
||||
canvas.height = size * scale;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
// rounded corners
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(size, size);
|
||||
ctx.arcTo(0, size, 0, 0, borderRadius);
|
||||
ctx.arcTo(0, 0, size, 0, borderRadius);
|
||||
ctx.arcTo(size, 0, size, size, borderRadius);
|
||||
ctx.arcTo(size, size, 0, size, borderRadius);
|
||||
|
||||
if (imageSrc) {
|
||||
// clip corners of image
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
img.onerror = reject;
|
||||
img.onload = resolve;
|
||||
});
|
||||
img.src = imageSrc;
|
||||
await promise;
|
||||
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
} else {
|
||||
// colored background
|
||||
ctx.fillStyle = cssVar(bgColor);
|
||||
ctx.fill();
|
||||
|
||||
// centered letter
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = `${cssVar('--fs-s1')} ${cssVar('--font-primary')}`;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(avatarInitials(text), size / 2, size / 2);
|
||||
}
|
||||
|
||||
return canvas.toDataURL();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return imageSrc;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@use 'state';
|
||||
@use '../../partials/dir';
|
||||
@use '../../partials/text';
|
||||
|
||||
.btn-surface,
|
||||
.btn-primary,
|
||||
@@ -18,11 +19,16 @@
|
||||
cursor: pointer;
|
||||
@include state.disabled;
|
||||
|
||||
& .text {
|
||||
@extend .cp-txt__ellipsis;
|
||||
}
|
||||
|
||||
&--icon {
|
||||
@include dir.side(padding, var(--sp-tight), var(--sp-loose));
|
||||
|
||||
.ic-raw {
|
||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import PropTypes from 'prop-types';
|
||||
import './Checkbox.scss';
|
||||
|
||||
function Checkbox({
|
||||
variant, isActive, onToggle, disabled,
|
||||
variant, isActive, onToggle,
|
||||
disabled, tabIndex,
|
||||
}) {
|
||||
const className = `checkbox checkbox-${variant}${isActive ? ' checkbox--active' : ''}`;
|
||||
if (onToggle === null) return <span className={className} />;
|
||||
@@ -14,6 +15,7 @@ function Checkbox({
|
||||
className={className}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +25,7 @@ Checkbox.defaultProps = {
|
||||
isActive: false,
|
||||
onToggle: null,
|
||||
disabled: false,
|
||||
tabIndex: 0,
|
||||
};
|
||||
|
||||
Checkbox.propTypes = {
|
||||
@@ -30,6 +33,7 @@ Checkbox.propTypes = {
|
||||
isActive: PropTypes.bool,
|
||||
onToggle: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
tabIndex: PropTypes.number,
|
||||
};
|
||||
|
||||
export default Checkbox;
|
||||
|
||||
@@ -8,7 +8,7 @@ function Input({
|
||||
id, label, name, value, placeholder,
|
||||
required, type, onChange, forwardRef,
|
||||
resizable, minHeight, onResize, state,
|
||||
onKeyDown, disabled,
|
||||
onKeyDown, disabled, autoFocus,
|
||||
}) {
|
||||
return (
|
||||
<div className="input-container">
|
||||
@@ -30,6 +30,7 @@ function Input({
|
||||
onResize={onResize}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -45,6 +46,8 @@ function Input({
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -67,6 +70,7 @@ Input.defaultProps = {
|
||||
state: 'normal',
|
||||
onKeyDown: null,
|
||||
disabled: false,
|
||||
autoFocus: false,
|
||||
};
|
||||
|
||||
Input.propTypes = {
|
||||
@@ -85,6 +89,7 @@ Input.propTypes = {
|
||||
state: PropTypes.oneOf(['normal', 'success', 'error']),
|
||||
onKeyDown: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
autoFocus: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Input;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RawModal.scss';
|
||||
|
||||
@@ -26,7 +26,9 @@ function RawModal({
|
||||
modalClass += 'raw-modal__small ';
|
||||
}
|
||||
|
||||
navigation.setIsRawModalVisible(isOpen);
|
||||
useEffect(() => {
|
||||
navigation.setIsRawModalVisible(isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
const modalOverlayClass = (overlayClassName !== null) ? `${overlayClassName} ` : '';
|
||||
return (
|
||||
|
||||
25
src/app/hooks/useCategorizedSpaces.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import initMatrix from '../../client/initMatrix';
|
||||
import cons from '../../client/state/cons';
|
||||
|
||||
export function useCategorizedSpaces() {
|
||||
const { accountData } = initMatrix;
|
||||
const [categorizedSpaces, setCategorizedSpaces] = useState([...accountData.categorizedSpaces]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCategorizedSpaces = () => {
|
||||
setCategorizedSpaces([...accountData.categorizedSpaces]);
|
||||
};
|
||||
accountData.on(cons.events.accountData.CATEGORIZE_SPACE_UPDATED, handleCategorizedSpaces);
|
||||
return () => {
|
||||
accountData.removeListener(
|
||||
cons.events.accountData.CATEGORIZE_SPACE_UPDATED,
|
||||
handleCategorizedSpaces,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [categorizedSpaces];
|
||||
}
|
||||
28
src/app/hooks/usePermission.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function usePermission(name, initial) {
|
||||
const [state, setState] = useState(initial);
|
||||
|
||||
useEffect(() => {
|
||||
let descriptor;
|
||||
|
||||
const update = () => setState(descriptor.state);
|
||||
|
||||
if (navigator.permissions?.query) {
|
||||
navigator.permissions.query({ name }).then((_descriptor) => {
|
||||
descriptor = _descriptor;
|
||||
|
||||
update();
|
||||
descriptor.addEventListener('change', update);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (descriptor) descriptor.removeEventListener('change', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [state, setState];
|
||||
}
|
||||
@@ -5,16 +5,19 @@ import initMatrix from '../../client/initMatrix';
|
||||
import cons from '../../client/state/cons';
|
||||
|
||||
export function useSpaceShortcut() {
|
||||
const { roomList } = initMatrix;
|
||||
const [spaceShortcut, setSpaceShortcut] = useState([...roomList.spaceShortcut]);
|
||||
const { accountData } = initMatrix;
|
||||
const [spaceShortcut, setSpaceShortcut] = useState([...accountData.spaceShortcut]);
|
||||
|
||||
useEffect(() => {
|
||||
const onSpaceShortcutUpdated = () => {
|
||||
setSpaceShortcut([...roomList.spaceShortcut]);
|
||||
setSpaceShortcut([...accountData.spaceShortcut]);
|
||||
};
|
||||
roomList.on(cons.events.roomList.SPACE_SHORTCUT_UPDATED, onSpaceShortcutUpdated);
|
||||
accountData.on(cons.events.accountData.SPACE_SHORTCUT_UPDATED, onSpaceShortcutUpdated);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.SPACE_SHORTCUT_UPDATED, onSpaceShortcutUpdated);
|
||||
accountData.removeListener(
|
||||
cons.events.accountData.SPACE_SHORTCUT_UPDATED,
|
||||
onSpaceShortcutUpdated,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -27,7 +27,11 @@ function Dialog({
|
||||
<div className="dialog__content">
|
||||
<Header>
|
||||
<TitleWrapper>
|
||||
<Text variant="h2" weight="medium" primary>{twemojify(title)}</Text>
|
||||
{
|
||||
typeof title === 'string'
|
||||
? <Text variant="h2" weight="medium" primary>{twemojify(title)}</Text>
|
||||
: title
|
||||
}
|
||||
</TitleWrapper>
|
||||
{contentOptions}
|
||||
</Header>
|
||||
@@ -56,7 +60,7 @@ Dialog.defaultProps = {
|
||||
Dialog.propTypes = {
|
||||
className: PropTypes.string,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
contentOptions: PropTypes.node,
|
||||
onAfterOpen: PropTypes.func,
|
||||
onAfterClose: PropTypes.func,
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
.dialog-model {
|
||||
--modal-height: 656px;
|
||||
max-height: var(--modal-height) !important;
|
||||
max-height: min(100%, var(--modal-height));
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog,
|
||||
.dialog__content,
|
||||
.dialog__content__wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: 100%;
|
||||
max-height: inherit;
|
||||
background-color: var(--bg-surface);
|
||||
display: flex;
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.dialog__content-container {
|
||||
padding-top: var(--sp-extra-tight);
|
||||
padding-bottom: var(--sp-extra-loose);
|
||||
}
|
||||
.dialog__content__wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, {
|
||||
useState, useEffect, useCallback, useRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Message.scss';
|
||||
|
||||
@@ -12,7 +14,7 @@ import colorMXID from '../../../util/colorMXID';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
|
||||
import {
|
||||
openEmojiBoard, openProfileViewer, openReadReceipts, replyTo,
|
||||
openEmojiBoard, openProfileViewer, openReadReceipts, openViewSource, replyTo,
|
||||
} from '../../../client/action/navigation';
|
||||
import { sanitizeCustomHtml } from '../../../util/sanitize';
|
||||
|
||||
@@ -31,6 +33,7 @@ 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 CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
|
||||
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
||||
|
||||
function PlaceholderMessage() {
|
||||
@@ -114,22 +117,31 @@ const MessageReplyWrapper = React.memo(({ roomTimeline, eventId }) => {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const timelineSet = roomTimeline.getUnfilteredTimelineSet();
|
||||
const loadReply = async () => {
|
||||
const eTimeline = await mx.getEventTimeline(timelineSet, eventId);
|
||||
await roomTimeline.decryptAllEventsOfTimeline(eTimeline);
|
||||
try {
|
||||
const eTimeline = await mx.getEventTimeline(timelineSet, eventId);
|
||||
await roomTimeline.decryptAllEventsOfTimeline(eTimeline);
|
||||
|
||||
const mEvent = eTimeline.getTimelineSet().findEventById(eventId);
|
||||
const mEvent = eTimeline.getTimelineSet().findEventById(eventId);
|
||||
|
||||
const rawBody = mEvent.getContent().body;
|
||||
const username = getUsernameOfRoomMember(mEvent.sender);
|
||||
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,
|
||||
});
|
||||
if (isMountedRef.current === false) return;
|
||||
const fallbackBody = mEvent.isRedacted() ? '*** This message has been deleted ***' : '*** Unable to load reply ***';
|
||||
setReply({
|
||||
to: username,
|
||||
color: colorMXID(mEvent.getSender()),
|
||||
body: parseReply(rawBody)?.body ?? rawBody ?? fallbackBody,
|
||||
event: mEvent,
|
||||
});
|
||||
} catch {
|
||||
setReply({
|
||||
to: '** Unknown user **',
|
||||
color: 'var(--tc-danger-normal)',
|
||||
body: '*** Unable to load reply ***',
|
||||
event: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
loadReply();
|
||||
|
||||
@@ -138,9 +150,13 @@ const MessageReplyWrapper = React.memo(({ roomTimeline, eventId }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const focusReply = () => {
|
||||
if (reply?.event.isRedacted()) return;
|
||||
roomTimeline.loadEventTimeline(eventId);
|
||||
const focusReply = (ev) => {
|
||||
if (!ev.keyCode || ev.keyCode === 32 || ev.keyCode === 13) {
|
||||
if (ev.keyCode) ev.preventDefault();
|
||||
if (reply?.event === null) return;
|
||||
if (reply?.event.isRedacted()) return;
|
||||
roomTimeline.loadEventTimeline(eventId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -188,10 +204,10 @@ const MessageBody = React.memo(({
|
||||
// Count the number of emojis
|
||||
const nEmojis = content.filter((e) => e.type === 'img').length;
|
||||
|
||||
// Make sure there's no text besides whitespace
|
||||
// Make sure there's no text besides whitespace and variation selector U+FE0F
|
||||
if (nEmojis <= 10 && content.every((element) => (
|
||||
(typeof element === 'object' && element.type === 'img')
|
||||
|| (typeof element === 'string' && /^\s*$/g.test(element))
|
||||
|| (typeof element === 'string' && /^[\s\ufe0f]*$/g.test(element))
|
||||
))) {
|
||||
emojiOnly = true;
|
||||
}
|
||||
@@ -235,6 +251,12 @@ MessageBody.propTypes = {
|
||||
function MessageEdit({ body, onSave, onCancel }) {
|
||||
const editInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// makes the cursor end up at the end of the line instead of the beginning
|
||||
editInputRef.current.value = '';
|
||||
editInputRef.current.value = body;
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.keyCode === 13 && e.shiftKey === false) {
|
||||
e.preventDefault();
|
||||
@@ -251,6 +273,7 @@ function MessageEdit({ body, onSave, onCancel }) {
|
||||
placeholder="Edit message"
|
||||
required
|
||||
resizable
|
||||
autoFocus
|
||||
/>
|
||||
<div className="message__edit-btns">
|
||||
<Button type="submit" variant="primary">Save</Button>
|
||||
@@ -304,9 +327,11 @@ function genReactionMsg(userIds, reaction) {
|
||||
{userIds.map((userId, index) => (
|
||||
<React.Fragment key={userId}>
|
||||
{twemojify(getUsername(userId))}
|
||||
<span style={{ opacity: '.6' }}>
|
||||
{index === userIds.length - 1 ? ' and ' : ', '}
|
||||
</span>
|
||||
{index < userIds.length - 1 && (
|
||||
<span style={{ opacity: '.6' }}>
|
||||
{index === userIds.length - 2 ? ' and ' : ', '}
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span style={{ opacity: '.6' }}>{' reacted with '}</span>
|
||||
@@ -355,13 +380,12 @@ MessageReaction.propTypes = {
|
||||
|
||||
function MessageReactionGroup({ roomTimeline, mEvent }) {
|
||||
const { roomId, room, reactionTimeline } = roomTimeline;
|
||||
const eventId = mEvent.getId();
|
||||
const mx = initMatrix.matrixClient;
|
||||
const reactions = {};
|
||||
const shortcodeToEmoji = getShortcodeToCustomEmoji(room);
|
||||
const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId());
|
||||
|
||||
const eventReactions = reactionTimeline.get(eventId);
|
||||
const eventReactions = reactionTimeline.get(mEvent.getId());
|
||||
const addReaction = (key, count, senderId, isActive) => {
|
||||
let reaction = reactions[key];
|
||||
if (reaction === undefined) {
|
||||
@@ -412,7 +436,7 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
|
||||
users={reactions[key].users}
|
||||
isActive={reactions[key].isActive}
|
||||
onClick={() => {
|
||||
toggleEmoji(roomId, eventId, key, roomTimeline);
|
||||
toggleEmoji(roomId, mEvent.getId(), key, roomTimeline);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
@@ -420,7 +444,7 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
|
||||
{canSendReaction && (
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
pickEmoji(e, roomId, eventId, roomTimeline);
|
||||
pickEmoji(e, roomId, mEvent.getId(), roomTimeline);
|
||||
}}
|
||||
src={EmojiAddIC}
|
||||
size="extra-small"
|
||||
@@ -445,12 +469,23 @@ function isMedia(mE) {
|
||||
);
|
||||
}
|
||||
|
||||
// if editedTimeline has mEventId then pass editedMEvent else pass mEvent to openViewSource
|
||||
function handleOpenViewSource(mEvent, roomTimeline) {
|
||||
const eventId = mEvent.getId();
|
||||
const { editedTimeline } = roomTimeline ?? {};
|
||||
let editedMEvent;
|
||||
if (editedTimeline?.has(eventId)) {
|
||||
const editedList = editedTimeline.get(eventId);
|
||||
editedMEvent = editedList[editedList.length - 1];
|
||||
}
|
||||
openViewSource(editedMEvent !== undefined ? editedMEvent : mEvent);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -461,7 +496,7 @@ const MessageOptions = React.memo(({
|
||||
<div className="message__options">
|
||||
{canSendReaction && (
|
||||
<IconButton
|
||||
onClick={(e) => pickEmoji(e, roomId, eventId, roomTimeline)}
|
||||
onClick={(e) => pickEmoji(e, roomId, mEvent.getId(), roomTimeline)}
|
||||
src={EmojiAddIC}
|
||||
size="extra-small"
|
||||
tooltip="Add reaction"
|
||||
@@ -491,6 +526,12 @@ const MessageOptions = React.memo(({
|
||||
>
|
||||
Read receipts
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
iconSrc={CmdIC}
|
||||
onClick={() => handleOpenViewSource(mEvent, roomTimeline)}
|
||||
>
|
||||
View source
|
||||
</MenuItem>
|
||||
{(canIRedact || senderId === mx.getUserId()) && (
|
||||
<>
|
||||
<MenuBorder />
|
||||
@@ -499,7 +540,7 @@ const MessageOptions = React.memo(({
|
||||
iconSrc={BinIC}
|
||||
onClick={() => {
|
||||
if (window.confirm('Are you sure you want to delete this event')) {
|
||||
redactEvent(roomId, eventId);
|
||||
redactEvent(roomId, mEvent.getId());
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -629,7 +670,7 @@ function Message({
|
||||
setIsEditing(true);
|
||||
}, []);
|
||||
const reply = useCallback(() => {
|
||||
replyTo(senderId, eventId, body);
|
||||
replyTo(senderId, mEvent.getId(), body);
|
||||
}, [body]);
|
||||
|
||||
if (body === undefined) return null;
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
@include dir.prop(left, unset, 60px);
|
||||
|
||||
z-index: 99;
|
||||
transform: translateY(-50%);
|
||||
transform: translateY(-100%);
|
||||
|
||||
border-radius: var(--bo-radius);
|
||||
box-shadow: var(--bs-surface-border);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
@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: var(--sp-extra-tight) var(--sp-normal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
&__container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
|
||||
@@ -51,14 +51,16 @@ PWContentSelector.propTypes = {
|
||||
function PopupWindow({
|
||||
className, isOpen, title, contentTitle,
|
||||
drawer, drawerOptions, contentOptions,
|
||||
onRequestClose, children,
|
||||
onAfterClose, onRequestClose, children,
|
||||
}) {
|
||||
const haveDrawer = drawer !== null;
|
||||
const cTitle = contentTitle !== null ? contentTitle : title;
|
||||
|
||||
return (
|
||||
<RawModal
|
||||
className={`${className === null ? '' : `${className} `}pw-model`}
|
||||
isOpen={isOpen}
|
||||
onAfterClose={onAfterClose}
|
||||
onRequestClose={onRequestClose}
|
||||
size={haveDrawer ? 'large' : 'medium'}
|
||||
>
|
||||
@@ -68,7 +70,11 @@ function PopupWindow({
|
||||
<Header>
|
||||
<IconButton size="small" src={ChevronLeftIC} onClick={onRequestClose} tooltip="Back" />
|
||||
<TitleWrapper>
|
||||
<Text variant="s1" weight="medium" primary>{twemojify(title)}</Text>
|
||||
{
|
||||
typeof title === 'string'
|
||||
? <Text variant="s1" weight="medium" primary>{twemojify(title)}</Text>
|
||||
: title
|
||||
}
|
||||
</TitleWrapper>
|
||||
{drawerOptions}
|
||||
</Header>
|
||||
@@ -84,7 +90,11 @@ function PopupWindow({
|
||||
<div className="pw__content">
|
||||
<Header>
|
||||
<TitleWrapper>
|
||||
<Text variant="h2" weight="medium" primary>{twemojify(contentTitle !== null ? contentTitle : title)}</Text>
|
||||
{
|
||||
typeof cTitle === 'string'
|
||||
? <Text variant="h2" weight="medium" primary>{twemojify(cTitle)}</Text>
|
||||
: cTitle
|
||||
}
|
||||
</TitleWrapper>
|
||||
{contentOptions}
|
||||
</Header>
|
||||
@@ -107,17 +117,19 @@ PopupWindow.defaultProps = {
|
||||
contentTitle: null,
|
||||
drawerOptions: null,
|
||||
contentOptions: null,
|
||||
onAfterClose: null,
|
||||
onRequestClose: null,
|
||||
};
|
||||
|
||||
PopupWindow.propTypes = {
|
||||
className: PropTypes.string,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
contentTitle: PropTypes.string,
|
||||
title: PropTypes.node.isRequired,
|
||||
contentTitle: PropTypes.node,
|
||||
drawer: PropTypes.node,
|
||||
drawerOptions: PropTypes.node,
|
||||
contentOptions: PropTypes.node,
|
||||
onAfterClose: PropTypes.func,
|
||||
onRequestClose: PropTypes.func,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
@@ -293,7 +293,7 @@ function RoomAliases({ roomId }) {
|
||||
<div className="room-aliases">
|
||||
<SettingTile
|
||||
title="Publish to room directory"
|
||||
content={<Text variant="b3">{`Publish this room to the ${hsString}'s public room directory?`}</Text>}
|
||||
content={<Text variant="b3">{`Publish this ${room.isSpaceRoom() ? 'space' : 'room'} to the ${hsString}'s public room directory?`}</Text>}
|
||||
options={(
|
||||
<Toggle
|
||||
isActive={isPublic}
|
||||
@@ -308,14 +308,18 @@ function RoomAliases({ roomId }) {
|
||||
{(aliases.published.length === 0) && <Text className="room-aliases__message">No published addresses</Text>}
|
||||
{(aliases.published.length > 0 && !aliases.main) && <Text className="room-aliases__message">No Main address (select one from below)</Text>}
|
||||
{aliases.published.map(renderAlias)}
|
||||
<Text className="room-aliases__message" variant="b3">Published addresses can be used by anyone on any server to join your room. To publish an address, it needs to be set as a local address first.</Text>
|
||||
<Text className="room-aliases__message" variant="b3">
|
||||
{`Published addresses can be used by anyone on any server to join your ${room.isSpaceRoom() ? 'space' : 'room'}. To publish an address, it needs to be set as a local address first.`}
|
||||
</Text>
|
||||
</div>
|
||||
{ isLocalVisible && (
|
||||
<div className="room-aliases__content">
|
||||
<MenuHeader>Local addresses</MenuHeader>
|
||||
{(aliases.local.length === 0) && <Text className="room-aliases__message">No local addresses</Text>}
|
||||
{aliases.local.map(renderAlias)}
|
||||
<Text className="room-aliases__message" variant="b3">Set local addresses for this room so users can find this room through your homeserver.</Text>
|
||||
<Text className="room-aliases__message" variant="b3">
|
||||
{`Set local addresses for this ${room.isSpaceRoom() ? 'space' : 'room'} so users can find this ${room.isSpaceRoom() ? 'space' : 'room'} through your homeserver.`}
|
||||
</Text>
|
||||
|
||||
<Text className="room-aliases__form-label" variant="b2">Add local address</Text>
|
||||
<form className="room-aliases__form" onSubmit={handleAliasSubmit}>
|
||||
@@ -324,7 +328,7 @@ function RoomAliases({ roomId }) {
|
||||
name="alias-input"
|
||||
state={inputState}
|
||||
onChange={handleAliasChange}
|
||||
placeholder="my_room_address"
|
||||
placeholder={`my_${room.isSpaceRoom() ? 'space' : 'room'}_address`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
204
src/app/molecules/room-members/RoomMembers.jsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, {
|
||||
useState, useEffect, useCallback,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomMembers.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { openProfileViewer } from '../../../client/action/navigation';
|
||||
import { getUsernameOfRoomMember, getPowerLabel } from '../../../util/matrixUtil';
|
||||
import AsyncSearch from '../../../util/AsyncSearch';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||
import SegmentedControls from '../../atoms/segmented-controls/SegmentedControls';
|
||||
import PeopleSelector from '../people-selector/PeopleSelector';
|
||||
|
||||
const PER_PAGE_MEMBER = 50;
|
||||
|
||||
function AtoZ(m1, m2) {
|
||||
const aName = m1.name;
|
||||
const bName = m2.name;
|
||||
|
||||
if (aName.toLowerCase() < bName.toLowerCase()) {
|
||||
return -1;
|
||||
}
|
||||
if (aName.toLowerCase() > bName.toLowerCase()) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
function sortByPowerLevel(m1, m2) {
|
||||
const pl1 = m1.powerLevel;
|
||||
const pl2 = m2.powerLevel;
|
||||
|
||||
if (pl1 > pl2) return -1;
|
||||
if (pl1 < pl2) return 1;
|
||||
return 0;
|
||||
}
|
||||
function normalizeMembers(members) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
return members.map((member) => ({
|
||||
userId: member.userId,
|
||||
name: getUsernameOfRoomMember(member),
|
||||
username: member.userId.slice(1, member.userId.indexOf(':')),
|
||||
avatarSrc: member.getAvatarUrl(mx.baseUrl, 24, 24, 'crop'),
|
||||
peopleRole: getPowerLabel(member.powerLevel),
|
||||
powerLevel: members.powerLevel,
|
||||
}));
|
||||
}
|
||||
|
||||
function useMemberOfMembership(roomId, membership) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(roomId);
|
||||
const [members, setMembers] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
let isLoadingMembers = false;
|
||||
|
||||
const updateMemberList = (event) => {
|
||||
if (isLoadingMembers) return;
|
||||
if (event && event?.getRoomId() !== roomId) return;
|
||||
const memberOfMembership = normalizeMembers(
|
||||
room.getMembersWithMembership(membership)
|
||||
.sort(AtoZ).sort(sortByPowerLevel),
|
||||
);
|
||||
setMembers(memberOfMembership);
|
||||
};
|
||||
|
||||
updateMemberList();
|
||||
isLoadingMembers = true;
|
||||
room.loadMembersIfNeeded().then(() => {
|
||||
isLoadingMembers = false;
|
||||
if (!isMounted) return;
|
||||
updateMemberList();
|
||||
});
|
||||
|
||||
mx.on('RoomMember.membership', updateMemberList);
|
||||
mx.on('RoomMember.powerLevel', updateMemberList);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
mx.removeListener('RoomMember.membership', updateMemberList);
|
||||
mx.removeListener('RoomMember.powerLevel', updateMemberList);
|
||||
};
|
||||
}, [membership]);
|
||||
|
||||
return [members];
|
||||
}
|
||||
|
||||
function useSearchMembers(members) {
|
||||
const [searchMembers, setSearchMembers] = useState(null);
|
||||
const [asyncSearch] = useState(new AsyncSearch());
|
||||
|
||||
const reSearch = useCallback(() => {
|
||||
if (searchMembers) {
|
||||
asyncSearch.search(searchMembers.term);
|
||||
}
|
||||
}, [searchMembers, asyncSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
asyncSearch.setup(members, {
|
||||
keys: ['name', 'username', 'userId'],
|
||||
limit: PER_PAGE_MEMBER,
|
||||
});
|
||||
reSearch();
|
||||
}, [members, asyncSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSearchData = (data, term) => setSearchMembers({ data, term });
|
||||
asyncSearch.on(asyncSearch.RESULT_SENT, handleSearchData);
|
||||
return () => {
|
||||
asyncSearch.removeListener(asyncSearch.RESULT_SENT, handleSearchData);
|
||||
};
|
||||
}, [asyncSearch]);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
const term = e.target.value;
|
||||
if (term === '' || term === undefined) {
|
||||
setSearchMembers(null);
|
||||
} else asyncSearch.search(term);
|
||||
};
|
||||
|
||||
return [searchMembers, handleSearch];
|
||||
}
|
||||
|
||||
function RoomMembers({ roomId }) {
|
||||
const [itemCount, setItemCount] = useState(PER_PAGE_MEMBER);
|
||||
const [membership, setMembership] = useState('join');
|
||||
const [members] = useMemberOfMembership(roomId, membership);
|
||||
const [searchMembers, handleSearch] = useSearchMembers(members);
|
||||
|
||||
useEffect(() => {
|
||||
setItemCount(PER_PAGE_MEMBER);
|
||||
}, [searchMembers]);
|
||||
|
||||
const loadMorePeople = () => {
|
||||
setItemCount(itemCount + PER_PAGE_MEMBER);
|
||||
};
|
||||
|
||||
const mList = searchMembers ? searchMembers.data : members.slice(0, itemCount);
|
||||
return (
|
||||
<div className="room-members">
|
||||
<MenuHeader>Search member</MenuHeader>
|
||||
<Input
|
||||
onChange={handleSearch}
|
||||
placeholder="Search for name"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="room-members__header">
|
||||
<MenuHeader>{`${searchMembers ? `Found — ${mList.length}` : members.length} members`}</MenuHeader>
|
||||
<SegmentedControls
|
||||
selected={
|
||||
(() => {
|
||||
const getSegmentIndex = { join: 0, invite: 1, ban: 2 };
|
||||
return getSegmentIndex[membership];
|
||||
})()
|
||||
}
|
||||
segments={[{ text: 'Joined' }, { text: 'Invited' }, { text: 'Banned' }]}
|
||||
onSelect={(index) => {
|
||||
const memberships = ['join', 'invite', 'ban'];
|
||||
setMembership(memberships[index]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="room-members__list">
|
||||
{mList.map((member) => (
|
||||
<PeopleSelector
|
||||
key={member.userId}
|
||||
onClick={() => openProfileViewer(member.userId, roomId)}
|
||||
avatarSrc={member.avatarSrc}
|
||||
name={member.name}
|
||||
color={colorMXID(member.userId)}
|
||||
peopleRole={member.peopleRole}
|
||||
/>
|
||||
))}
|
||||
{
|
||||
(searchMembers?.data.length === 0 || members.length === 0)
|
||||
&& (
|
||||
<div className="room-members__status">
|
||||
<Text variant="b2">
|
||||
{searchMembers ? `No results found for "${searchMembers.term}"` : 'No members to display'}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
mList.length !== 0
|
||||
&& members.length > itemCount
|
||||
&& searchMembers === null
|
||||
&& <Button onClick={loadMorePeople}>View more</Button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
RoomMembers.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default RoomMembers;
|
||||
39
src/app/molecules/room-members/RoomMembers.scss
Normal file
@@ -0,0 +1,39 @@
|
||||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.room-members {
|
||||
& .input-container {
|
||||
margin: var(--sp-normal);
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
& .context-menu__header {
|
||||
@extend .cp-fx__item-one;
|
||||
margin-top: 14px;
|
||||
border-top: 1px solid var(--bg-surface-border);
|
||||
border-bottom: none;
|
||||
}
|
||||
& .segmented-controls {
|
||||
@include dir.side(margin, 0, var(--sp-normal));
|
||||
& > button {
|
||||
padding: var(--sp-ultra-tight) 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__list {
|
||||
|
||||
& .people-selector__container:last-child {
|
||||
margin-bottom: var(--sp-extra-tight);
|
||||
}
|
||||
& > .btn-surface {
|
||||
width: calc(100% - 32px);
|
||||
margin: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
margin: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
@@ -32,28 +32,9 @@ const items = [{
|
||||
type: cons.notifs.MUTE,
|
||||
}];
|
||||
|
||||
function getNotifType(roomId) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const pushRule = mx.getRoomPushRule('global', roomId);
|
||||
|
||||
if (typeof pushRule === 'undefined') {
|
||||
const overridePushRules = mx.getAccountData('m.push_rules')?.getContent()?.global?.override;
|
||||
if (typeof overridePushRules === 'undefined') return 0;
|
||||
|
||||
const isMuteOverride = overridePushRules.find((rule) => (
|
||||
rule.rule_id === roomId
|
||||
&& rule.actions[0] === 'dont_notify'
|
||||
&& rule.conditions[0].kind === 'event_match'
|
||||
));
|
||||
|
||||
return isMuteOverride ? cons.notifs.MUTE : cons.notifs.DEFAULT;
|
||||
}
|
||||
if (pushRule.actions[0] === 'notify') return cons.notifs.ALL_MESSAGES;
|
||||
return cons.notifs.MENTIONS_AND_KEYWORDS;
|
||||
}
|
||||
|
||||
function setRoomNotifType(roomId, newType) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { notifications } = initMatrix;
|
||||
const roomPushRule = mx.getRoomPushRule('global', roomId);
|
||||
const promises = [];
|
||||
|
||||
@@ -76,7 +57,7 @@ function setRoomNotifType(roomId, newType) {
|
||||
return promises;
|
||||
}
|
||||
|
||||
const oldState = getNotifType(roomId);
|
||||
const oldState = notifications.getNotiType(roomId);
|
||||
if (oldState === cons.notifs.MUTE) {
|
||||
promises.push(mx.deletePushRule('global', 'override', roomId));
|
||||
}
|
||||
@@ -115,8 +96,9 @@ function setRoomNotifType(roomId, newType) {
|
||||
}
|
||||
|
||||
function useNotifications(roomId) {
|
||||
const [activeType, setActiveType] = useState(getNotifType(roomId));
|
||||
useEffect(() => setActiveType(getNotifType(roomId)), [roomId]);
|
||||
const { notifications } = initMatrix;
|
||||
const [activeType, setActiveType] = useState(notifications.getNotiType(roomId));
|
||||
useEffect(() => setActiveType(notifications.getNotiType(roomId)), [roomId]);
|
||||
|
||||
const setNotification = useCallback((item) => {
|
||||
if (item.type === activeType.type) return;
|
||||
|
||||
@@ -38,7 +38,7 @@ function RoomOptions({ roomId, afterOptionSelect }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ maxWidth: '256px' }}>
|
||||
<MenuHeader>{twemojify(`Options for ${initMatrix.matrixClient.getRoom(roomId)?.name}`)}</MenuHeader>
|
||||
<MenuItem iconSrc={TickMarkIC} onClick={handleMarkAsRead}>Mark as read</MenuItem>
|
||||
<MenuItem
|
||||
@@ -51,7 +51,7 @@ function RoomOptions({ roomId, afterOptionSelect }) {
|
||||
<MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={handleLeaveClick}>Leave</MenuItem>
|
||||
<MenuHeader>Notification</MenuHeader>
|
||||
<RoomNotification roomId={roomId} />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -218,11 +218,12 @@ function RoomPermissions({ roomId }) {
|
||||
);
|
||||
};
|
||||
|
||||
const permsGroups = room.isSpaceRoom() ? spacePermsGroups : roomPermsGroups;
|
||||
return (
|
||||
<div className="room-permissions">
|
||||
{
|
||||
Object.keys(roomPermsGroups).map((groupKey) => {
|
||||
const groupedPermKeys = roomPermsGroups[groupKey];
|
||||
Object.keys(permsGroups).map((groupKey) => {
|
||||
const groupedPermKeys = permsGroups[groupKey];
|
||||
return (
|
||||
<div className="room-permissions__card" key={groupKey}>
|
||||
<MenuHeader>{groupKey}</MenuHeader>
|
||||
@@ -232,7 +233,7 @@ function RoomPermissions({ roomId }) {
|
||||
|
||||
let powerLevel = 0;
|
||||
let permValue = permInfo.parent
|
||||
? permissions[permInfo.parent][permKey]
|
||||
? permissions[permInfo.parent]?.[permKey]
|
||||
: permissions[permKey];
|
||||
|
||||
if (!permValue) permValue = permInfo.default;
|
||||
|
||||
@@ -125,9 +125,9 @@ function RoomProfile({ roomId }) {
|
||||
|
||||
const renderEditNameAndTopic = () => (
|
||||
<form className="room-profile__edit-form" onSubmit={handleOnSubmit}>
|
||||
{canChangeName && <Input value={roomName} name="room-name" disabled={status.type === cons.status.IN_FLIGHT} label="Room name" required />}
|
||||
{canChangeName && <Input value={roomName} name="room-name" disabled={status.type === cons.status.IN_FLIGHT} label="Name" required />}
|
||||
{canChangeTopic && <Input value={roomTopic} name="room-topic" disabled={status.type === cons.status.IN_FLIGHT} minHeight={100} resizable label="Topic" />}
|
||||
{(!canChangeName || !canChangeTopic) && <Text variant="b3">{`You have permission to change room ${canChangeName ? 'name' : 'topic'} only.`}</Text>}
|
||||
{(!canChangeName || !canChangeTopic) && <Text variant="b3">{`You have permission to change ${room.isSpaceRoom() ? 'space' : 'room'} ${canChangeName ? 'name' : 'topic'} only.`}</Text>}
|
||||
{ status.type === cons.status.IN_FLIGHT && <Text variant="b2">{status.msg}</Text>}
|
||||
{ status.type === cons.status.SUCCESS && <Text style={{ color: 'var(--tc-positive-high)' }} variant="b2">{status.msg}</Text>}
|
||||
{ status.type === cons.status.ERROR && <Text style={{ color: 'var(--tc-danger-high)' }} variant="b2">{status.msg}</Text>}
|
||||
@@ -148,7 +148,7 @@ function RoomProfile({ roomId }) {
|
||||
<IconButton
|
||||
src={PencilIC}
|
||||
size="extra-small"
|
||||
tooltip="Edit room name and topic"
|
||||
tooltip="Edit"
|
||||
onClick={() => setIsEditing(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -145,6 +145,7 @@ function RoomSearch({ roomId }) {
|
||||
placeholder="Search for keywords"
|
||||
name="room-search-input"
|
||||
disabled={isRoomEncrypted}
|
||||
autoFocus
|
||||
/>
|
||||
<Button iconSrc={SearchIC} variant="primary" type="submit">Search</Button>
|
||||
</div>
|
||||
@@ -163,7 +164,7 @@ function RoomSearch({ roomId }) {
|
||||
|
||||
{!isRoomEncrypted && searchData?.results.length === 0 && (
|
||||
<div className="room-search__help">
|
||||
<Text>No result found</Text>
|
||||
<Text>No results found</Text>
|
||||
</div>
|
||||
)}
|
||||
{isRoomEncrypted && (
|
||||
|
||||
@@ -11,13 +11,16 @@ import NotificationBadge from '../../atoms/badge/NotificationBadge';
|
||||
import { blurOnBubbling } from '../../atoms/button/script';
|
||||
|
||||
function RoomSelectorWrapper({
|
||||
isSelected, isUnread, onClick,
|
||||
isSelected, isMuted, isUnread, onClick,
|
||||
content, options, onContextMenu,
|
||||
}) {
|
||||
let myClass = isUnread ? ' room-selector--unread' : '';
|
||||
myClass += isSelected ? ' room-selector--selected' : '';
|
||||
const classes = ['room-selector'];
|
||||
if (isMuted) classes.push('room-selector--muted');
|
||||
if (isUnread) classes.push('room-selector--unread');
|
||||
if (isSelected) classes.push('room-selector--selected');
|
||||
|
||||
return (
|
||||
<div className={`room-selector${myClass}`}>
|
||||
<div className={classes.join(' ')}>
|
||||
<button
|
||||
className="room-selector__content"
|
||||
type="button"
|
||||
@@ -32,11 +35,13 @@ function RoomSelectorWrapper({
|
||||
);
|
||||
}
|
||||
RoomSelectorWrapper.defaultProps = {
|
||||
isMuted: false,
|
||||
options: null,
|
||||
onContextMenu: null,
|
||||
};
|
||||
RoomSelectorWrapper.propTypes = {
|
||||
isSelected: PropTypes.bool.isRequired,
|
||||
isMuted: PropTypes.bool,
|
||||
isUnread: PropTypes.bool.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
content: PropTypes.node.isRequired,
|
||||
@@ -46,12 +51,13 @@ RoomSelectorWrapper.propTypes = {
|
||||
|
||||
function RoomSelector({
|
||||
name, parentName, roomId, imageSrc, iconSrc,
|
||||
isSelected, isUnread, notificationCount, isAlert,
|
||||
isSelected, isMuted, isUnread, notificationCount, isAlert,
|
||||
options, onClick, onContextMenu,
|
||||
}) {
|
||||
return (
|
||||
<RoomSelectorWrapper
|
||||
isSelected={isSelected}
|
||||
isMuted={isMuted}
|
||||
isUnread={isUnread}
|
||||
content={(
|
||||
<>
|
||||
@@ -91,6 +97,7 @@ RoomSelector.defaultProps = {
|
||||
isSelected: false,
|
||||
imageSrc: null,
|
||||
iconSrc: null,
|
||||
isMuted: false,
|
||||
options: null,
|
||||
onContextMenu: null,
|
||||
};
|
||||
@@ -101,6 +108,7 @@ RoomSelector.propTypes = {
|
||||
imageSrc: PropTypes.string,
|
||||
iconSrc: PropTypes.string,
|
||||
isSelected: PropTypes.bool,
|
||||
isMuted: PropTypes.bool,
|
||||
isUnread: PropTypes.bool.isRequired,
|
||||
notificationCount: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
border-radius: var(--bo-radius);
|
||||
cursor: pointer;
|
||||
|
||||
&--muted {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&--unread {
|
||||
.room-selector__content > .text {
|
||||
color: var(--tc-surface-high);
|
||||
|
||||
@@ -4,15 +4,13 @@ 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';
|
||||
import NotificationBadge from '../../atoms/badge/NotificationBadge';
|
||||
import { blurOnBubbling } from '../../atoms/button/script';
|
||||
|
||||
const SidebarAvatar = React.forwardRef(({
|
||||
tooltip, text, bgColor, imageSrc,
|
||||
iconSrc, active, onClick, isUnread, notificationCount, isAlert,
|
||||
tooltip, active, onClick, onContextMenu,
|
||||
avatar, notificationBadge,
|
||||
}, ref) => {
|
||||
let activeClass = '';
|
||||
if (active) activeClass = ' sidebar-avatar--active';
|
||||
@@ -27,50 +25,28 @@ const SidebarAvatar = React.forwardRef(({
|
||||
type="button"
|
||||
onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<Avatar
|
||||
text={text}
|
||||
bgColor={bgColor}
|
||||
imageSrc={imageSrc}
|
||||
iconSrc={iconSrc}
|
||||
size="normal"
|
||||
/>
|
||||
{ isUnread && (
|
||||
<NotificationBadge
|
||||
alert={isAlert}
|
||||
content={notificationCount !== 0 ? notificationCount : null}
|
||||
/>
|
||||
)}
|
||||
{avatar}
|
||||
{notificationBadge}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
SidebarAvatar.defaultProps = {
|
||||
text: null,
|
||||
bgColor: 'transparent',
|
||||
iconSrc: null,
|
||||
imageSrc: null,
|
||||
active: false,
|
||||
onClick: null,
|
||||
isUnread: false,
|
||||
notificationCount: 0,
|
||||
isAlert: false,
|
||||
onContextMenu: null,
|
||||
notificationBadge: null,
|
||||
};
|
||||
|
||||
SidebarAvatar.propTypes = {
|
||||
tooltip: PropTypes.string.isRequired,
|
||||
text: PropTypes.string,
|
||||
bgColor: PropTypes.string,
|
||||
imageSrc: PropTypes.string,
|
||||
iconSrc: PropTypes.string,
|
||||
active: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
isUnread: PropTypes.bool,
|
||||
notificationCount: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
]),
|
||||
isAlert: PropTypes.bool,
|
||||
onContextMenu: PropTypes.func,
|
||||
avatar: PropTypes.node.isRequired,
|
||||
notificationBadge: PropTypes.node,
|
||||
};
|
||||
|
||||
export default SidebarAvatar;
|
||||
|
||||
230
src/app/molecules/space-add-existing/SpaceAddExisting.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './SpaceAddExisting.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { joinRuleToIconSrc, getIdServer, genRoomVia } from '../../../util/matrixUtil';
|
||||
import { Debounce } from '../../../util/common';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Checkbox from '../../atoms/button/Checkbox';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import RoomSelector from '../room-selector/RoomSelector';
|
||||
import Dialog from '../dialog/Dialog';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
|
||||
function SpaceAddExistingContent({ roomId }) {
|
||||
const mountStore = useStore(roomId);
|
||||
const [debounce] = useState(new Debounce());
|
||||
const [process, setProcess] = useState(null);
|
||||
const [allRoomIds, setAllRoomIds] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [searchIds, setSearchIds] = useState(null);
|
||||
const mx = initMatrix.matrixClient;
|
||||
const {
|
||||
spaces, rooms, directs, roomIdToParents,
|
||||
} = initMatrix.roomList;
|
||||
|
||||
useEffect(() => {
|
||||
const allIds = [...spaces, ...rooms, ...directs].filter((rId) => (
|
||||
rId !== roomId && !roomIdToParents.get(rId)?.has(roomId)
|
||||
));
|
||||
setAllRoomIds(allIds);
|
||||
}, [roomId]);
|
||||
|
||||
const toggleSelection = (rId) => {
|
||||
if (process !== null) return;
|
||||
const newSelected = [...selected];
|
||||
const selectedIndex = newSelected.indexOf(rId);
|
||||
|
||||
if (selectedIndex > -1) {
|
||||
newSelected.splice(selectedIndex, 1);
|
||||
setSelected(newSelected);
|
||||
return;
|
||||
}
|
||||
newSelected.push(rId);
|
||||
setSelected(newSelected);
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
setProcess(`Adding ${selected.length} items...`);
|
||||
|
||||
const promises = selected.map((rId) => {
|
||||
const room = mx.getRoom(rId);
|
||||
const via = genRoomVia(room);
|
||||
if (via.length === 0) {
|
||||
via.push(getIdServer(rId));
|
||||
}
|
||||
|
||||
return mx.sendStateEvent(roomId, 'm.space.child', {
|
||||
auto_join: false,
|
||||
suggested: false,
|
||||
via,
|
||||
}, rId);
|
||||
});
|
||||
|
||||
mountStore.setItem(true);
|
||||
await Promise.allSettled(promises);
|
||||
if (mountStore.getItem() !== true) return;
|
||||
|
||||
const allIds = [...spaces, ...rooms, ...directs].filter((rId) => (
|
||||
rId !== roomId && !roomIdToParents.get(rId)?.has(roomId) && !selected.includes(rId)
|
||||
));
|
||||
setAllRoomIds(allIds);
|
||||
setProcess(null);
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
const handleSearch = (ev) => {
|
||||
const term = ev.target.value.toLocaleLowerCase().replaceAll(' ', '');
|
||||
if (term === '') {
|
||||
setSearchIds(null);
|
||||
return;
|
||||
}
|
||||
|
||||
debounce._(() => {
|
||||
const searchedIds = allRoomIds.filter((rId) => {
|
||||
let name = mx.getRoom(rId)?.name;
|
||||
if (!name) return false;
|
||||
name = name.normalize('NFKC')
|
||||
.toLocaleLowerCase()
|
||||
.replaceAll(' ', '');
|
||||
return name.includes(term);
|
||||
});
|
||||
setSearchIds(searchedIds);
|
||||
}, 200)();
|
||||
};
|
||||
const handleSearchClear = (ev) => {
|
||||
const btn = ev.currentTarget;
|
||||
btn.parentElement.searchInput.value = '';
|
||||
setSearchIds(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={(ev) => { ev.preventDefault(); }}>
|
||||
<RawIcon size="small" src={SearchIC} />
|
||||
<Input
|
||||
name="searchInput"
|
||||
onChange={handleSearch}
|
||||
placeholder="Search room"
|
||||
autoFocus
|
||||
/>
|
||||
<IconButton size="small" type="button" onClick={handleSearchClear} src={CrossIC} />
|
||||
</form>
|
||||
{searchIds?.length === 0 && <Text>No results found</Text>}
|
||||
{
|
||||
(searchIds || allRoomIds).map((rId) => {
|
||||
const room = mx.getRoom(rId);
|
||||
let imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
|
||||
const parentSet = roomIdToParents.get(rId);
|
||||
const parentNames = parentSet
|
||||
? [...parentSet].map((parentId) => mx.getRoom(parentId).name)
|
||||
: undefined;
|
||||
const parents = parentNames ? parentNames.join(', ') : null;
|
||||
|
||||
const handleSelect = () => toggleSelection(rId);
|
||||
|
||||
return (
|
||||
<RoomSelector
|
||||
key={rId}
|
||||
name={room.name}
|
||||
parentName={parents}
|
||||
roomId={rId}
|
||||
imageSrc={directs.has(rId) ? imageSrc : null}
|
||||
iconSrc={
|
||||
directs.has(rId)
|
||||
? null
|
||||
: joinRuleToIconSrc(room.getJoinRule(), room.isSpaceRoom())
|
||||
}
|
||||
isUnread={false}
|
||||
notificationCount={0}
|
||||
isAlert={false}
|
||||
onClick={handleSelect}
|
||||
options={(
|
||||
<Checkbox
|
||||
isActive={selected.includes(rId)}
|
||||
variant="positive"
|
||||
onToggle={handleSelect}
|
||||
tabIndex={-1}
|
||||
disabled={process !== null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
{selected.length !== 0 && (
|
||||
<div className="space-add-existing__footer">
|
||||
{process && <Spinner size="small" />}
|
||||
<Text weight="medium">{process || `${selected.length} item selected`}</Text>
|
||||
{ !process && (
|
||||
<Button onClick={handleAdd} variant="primary">Add</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
SpaceAddExistingContent.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function useVisibilityToggle() {
|
||||
const [roomId, setRoomId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (rId) => setRoomId(rId);
|
||||
navigation.on(cons.events.navigation.SPACE_ADDEXISTING_OPENED, handleOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.SPACE_ADDEXISTING_OPENED, handleOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const requestClose = () => setRoomId(null);
|
||||
|
||||
return [roomId, requestClose];
|
||||
}
|
||||
|
||||
function SpaceAddExisting() {
|
||||
const [roomId, requestClose] = useVisibilityToggle();
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={roomId !== null}
|
||||
className="space-add-existing"
|
||||
title={(
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
{roomId && twemojify(room.name)}
|
||||
<span style={{ color: 'var(--tc-surface-low)' }}> — add existing rooms</span>
|
||||
</Text>
|
||||
)}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
{
|
||||
roomId
|
||||
? <SpaceAddExistingContent roomId={roomId} />
|
||||
: <div />
|
||||
}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpaceAddExisting;
|
||||
77
src/app/molecules/space-add-existing/SpaceAddExisting.scss
Normal file
@@ -0,0 +1,77 @@
|
||||
@use '../../partials/dir';
|
||||
@use '../../partials/flex';
|
||||
|
||||
.space-add-existing {
|
||||
height: 100%;
|
||||
|
||||
.dialog__content-container {
|
||||
padding: 0;
|
||||
padding-bottom: 80px;
|
||||
@include dir.side(padding, var(--sp-extra-tight), 0);
|
||||
|
||||
& > .text {
|
||||
margin: var(--sp-loose) var(--sp-normal);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
& form {
|
||||
@extend .cp-fx__row--s-c;
|
||||
padding: var(--sp-extra-tight);
|
||||
padding-top: var(--sp-normal);
|
||||
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
background-color: var(--bg-surface);
|
||||
|
||||
& > .ic-raw,
|
||||
& > .ic-btn {
|
||||
position: absolute;
|
||||
}
|
||||
& > .ic-raw {
|
||||
margin: 0 var(--sp-tight);
|
||||
}
|
||||
& > .ic-btn {
|
||||
border-radius: calc(var(--bo-radius) / 2);
|
||||
@include dir.prop(right, var(--sp-tight), unset);
|
||||
@include dir.prop(left, unset, var(--sp-tight));
|
||||
}
|
||||
& input {
|
||||
padding: var(--sp-tight) 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-container {
|
||||
@extend .cp-fx__item-one;
|
||||
}
|
||||
|
||||
.room-selector {
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
}
|
||||
.room-selector__options {
|
||||
display: flex;
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.space-add-existing__footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: var(--sp-normal);
|
||||
background-color: var(--bg-surface);
|
||||
border-top: 1px solid var(--bg-surface-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > .text {
|
||||
@extend .cp-fx__item-one;
|
||||
padding: 0 var(--sp-tight);
|
||||
}
|
||||
|
||||
& > button {
|
||||
@include dir.side(margin, var(--sp-normal), 0);
|
||||
}
|
||||
}
|
||||
108
src/app/molecules/space-options/SpaceOptions.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { openSpaceSettings, openSpaceManage, openInviteUser } from '../../../client/action/navigation';
|
||||
import { leave } from '../../../client/action/room';
|
||||
import {
|
||||
createSpaceShortcut,
|
||||
deleteSpaceShortcut,
|
||||
categorizeSpace,
|
||||
unCategorizeSpace,
|
||||
} from '../../../client/action/accountData';
|
||||
|
||||
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
|
||||
|
||||
import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
|
||||
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
|
||||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
||||
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
|
||||
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
|
||||
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
||||
import PinIC from '../../../../public/res/ic/outlined/pin.svg';
|
||||
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
||||
|
||||
function SpaceOptions({ roomId, afterOptionSelect }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(roomId);
|
||||
const canInvite = room?.canInvite(mx.getUserId());
|
||||
const isPinned = initMatrix.accountData.spaceShortcut.has(roomId);
|
||||
const isCategorized = initMatrix.accountData.categorizedSpaces.has(roomId);
|
||||
|
||||
const handleInviteClick = () => {
|
||||
openInviteUser(roomId);
|
||||
afterOptionSelect();
|
||||
};
|
||||
const handlePinClick = () => {
|
||||
if (isPinned) deleteSpaceShortcut(roomId);
|
||||
else createSpaceShortcut(roomId);
|
||||
afterOptionSelect();
|
||||
};
|
||||
const handleCategorizeClick = () => {
|
||||
if (isCategorized) unCategorizeSpace(roomId);
|
||||
else categorizeSpace(roomId);
|
||||
afterOptionSelect();
|
||||
};
|
||||
const handleSettingsClick = () => {
|
||||
openSpaceSettings(roomId);
|
||||
afterOptionSelect();
|
||||
};
|
||||
const handleManageRoom = () => {
|
||||
openSpaceManage(roomId);
|
||||
afterOptionSelect();
|
||||
};
|
||||
|
||||
const handleLeaveClick = () => {
|
||||
if (confirm('Are you really want to leave this space?')) {
|
||||
leave(roomId);
|
||||
afterOptionSelect();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 'calc(var(--navigation-drawer-width) - var(--sp-normal))' }}>
|
||||
<MenuHeader>{twemojify(`Options for ${initMatrix.matrixClient.getRoom(roomId)?.name}`)}</MenuHeader>
|
||||
<MenuItem
|
||||
onClick={handleCategorizeClick}
|
||||
iconSrc={isCategorized ? CategoryFilledIC : CategoryIC}
|
||||
>
|
||||
{isCategorized ? 'Uncategorize subspaces' : 'Categorize subspaces'}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handlePinClick}
|
||||
iconSrc={isPinned ? PinFilledIC : PinIC}
|
||||
>
|
||||
{isPinned ? 'Unpin from sidebar' : 'Pin to sidebar'}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
iconSrc={AddUserIC}
|
||||
onClick={handleInviteClick}
|
||||
disabled={!canInvite}
|
||||
>
|
||||
Invite
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleManageRoom} iconSrc={HashSearchIC}>Manage rooms</MenuItem>
|
||||
<MenuItem onClick={handleSettingsClick} iconSrc={SettingsIC}>Settings</MenuItem>
|
||||
<MenuItem
|
||||
variant="danger"
|
||||
onClick={handleLeaveClick}
|
||||
iconSrc={LeaveArrowIC}
|
||||
>
|
||||
Leave
|
||||
</MenuItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SpaceOptions.defaultProps = {
|
||||
afterOptionSelect: null,
|
||||
};
|
||||
|
||||
SpaceOptions.propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
afterOptionSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
export default SpaceOptions;
|
||||
@@ -2,79 +2,84 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './CreateRoom.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import { isRoomAliasAvailable } from '../../../util/matrixUtil';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { selectRoom, openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
import { selectRoom } from '../../../client/action/navigation';
|
||||
import { isRoomAliasAvailable, getIdServer } from '../../../util/matrixUtil';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Toggle from '../../atoms/button/Toggle';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
|
||||
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 Dialog from '../../molecules/dialog/Dialog';
|
||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||
|
||||
import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg';
|
||||
import SpacePlusIC from '../../../../public/res/ic/outlined/space-plus.svg';
|
||||
import HashIC from '../../../../public/res/ic/outlined/hash.svg';
|
||||
import HashLockIC from '../../../../public/res/ic/outlined/hash-lock.svg';
|
||||
import HashGlobeIC from '../../../../public/res/ic/outlined/hash-globe.svg';
|
||||
import SpaceIC from '../../../../public/res/ic/outlined/space.svg';
|
||||
import SpaceLockIC from '../../../../public/res/ic/outlined/space-lock.svg';
|
||||
import SpaceGlobeIC from '../../../../public/res/ic/outlined/space-globe.svg';
|
||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
function CreateRoom({ isOpen, onRequestClose }) {
|
||||
const [isPublic, togglePublic] = useState(false);
|
||||
const [isEncrypted, toggleEncrypted] = useState(true);
|
||||
const [isValidAddress, updateIsValidAddress] = useState(null);
|
||||
const [isCreatingRoom, updateIsCreatingRoom] = useState(false);
|
||||
const [creatingError, updateCreatingError] = useState(null);
|
||||
function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
||||
const [joinRule, setJoinRule] = useState(parentId ? 'restricted' : 'invite');
|
||||
const [isEncrypted, setIsEncrypted] = useState(true);
|
||||
const [isCreatingRoom, setIsCreatingRoom] = useState(false);
|
||||
const [creatingError, setCreatingError] = useState(null);
|
||||
|
||||
const [titleValue, updateTitleValue] = useState(undefined);
|
||||
const [topicValue, updateTopicValue] = useState(undefined);
|
||||
const [addressValue, updateAddressValue] = useState(undefined);
|
||||
const [isValidAddress, setIsValidAddress] = useState(null);
|
||||
const [addressValue, setAddressValue] = useState(undefined);
|
||||
const [roleIndex, setRoleIndex] = useState(0);
|
||||
|
||||
const addressRef = useRef(null);
|
||||
const topicRef = useRef(null);
|
||||
const nameRef = useRef(null);
|
||||
|
||||
const userId = initMatrix.matrixClient.getUserId();
|
||||
const hsString = userId.slice(userId.indexOf(':'));
|
||||
|
||||
function resetForm() {
|
||||
togglePublic(false);
|
||||
toggleEncrypted(true);
|
||||
updateIsValidAddress(null);
|
||||
updateIsCreatingRoom(false);
|
||||
updateCreatingError(null);
|
||||
updateTitleValue(undefined);
|
||||
updateTopicValue(undefined);
|
||||
updateAddressValue(undefined);
|
||||
setRoleIndex(0);
|
||||
}
|
||||
|
||||
const onCreated = (roomId) => {
|
||||
resetForm();
|
||||
selectRoom(roomId);
|
||||
onRequestClose();
|
||||
};
|
||||
const mx = initMatrix.matrixClient;
|
||||
const userHs = getIdServer(mx.getUserId());
|
||||
|
||||
useEffect(() => {
|
||||
const { roomList } = initMatrix;
|
||||
const onCreated = (roomId) => {
|
||||
setIsCreatingRoom(false);
|
||||
setCreatingError(null);
|
||||
setIsValidAddress(null);
|
||||
setAddressValue(undefined);
|
||||
|
||||
if (!mx.getRoom(roomId)?.isSpaceRoom()) {
|
||||
selectRoom(roomId);
|
||||
}
|
||||
onRequestClose();
|
||||
};
|
||||
roomList.on(cons.events.roomList.ROOM_CREATED, onCreated);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.ROOM_CREATED, onCreated);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function createRoom() {
|
||||
const handleSubmit = async (evt) => {
|
||||
evt.preventDefault();
|
||||
const { target } = evt;
|
||||
|
||||
if (isCreatingRoom) return;
|
||||
updateIsCreatingRoom(true);
|
||||
updateCreatingError(null);
|
||||
const name = nameRef.current.value;
|
||||
let topic = topicRef.current.value;
|
||||
setIsCreatingRoom(true);
|
||||
setCreatingError(null);
|
||||
|
||||
const name = target.name.value;
|
||||
let topic = target.topic.value;
|
||||
if (topic.trim() === '') topic = undefined;
|
||||
let roomAlias;
|
||||
if (isPublic) {
|
||||
if (joinRule === 'public') {
|
||||
roomAlias = addressRef?.current?.value;
|
||||
if (roomAlias.trim() === '') roomAlias = undefined;
|
||||
}
|
||||
@@ -82,115 +87,217 @@ function CreateRoom({ isOpen, onRequestClose }) {
|
||||
const powerLevel = roleIndex === 1 ? 101 : undefined;
|
||||
|
||||
try {
|
||||
await roomActions.create({
|
||||
name, topic, isPublic, roomAlias, isEncrypted, powerLevel,
|
||||
await roomActions.createRoom({
|
||||
name,
|
||||
topic,
|
||||
joinRule,
|
||||
alias: roomAlias,
|
||||
isEncrypted: (isSpace || joinRule === 'public') ? false : isEncrypted,
|
||||
powerLevel,
|
||||
isSpace,
|
||||
parentId,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.message === 'M_UNKNOWN: Invalid characters in room alias') {
|
||||
updateCreatingError('ERROR: Invalid characters in room address');
|
||||
updateIsValidAddress(false);
|
||||
setCreatingError('ERROR: Invalid characters in address');
|
||||
setIsValidAddress(false);
|
||||
} else if (e.message === 'M_ROOM_IN_USE: Room alias already taken') {
|
||||
updateCreatingError('ERROR: Room address is already in use');
|
||||
updateIsValidAddress(false);
|
||||
} else updateCreatingError(e.message);
|
||||
updateIsCreatingRoom(false);
|
||||
setCreatingError('ERROR: This address is already in use');
|
||||
setIsValidAddress(false);
|
||||
} else setCreatingError(e.message);
|
||||
setIsCreatingRoom(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function validateAddress(e) {
|
||||
const validateAddress = (e) => {
|
||||
const myAddress = e.target.value;
|
||||
updateIsValidAddress(null);
|
||||
updateAddressValue(e.target.value);
|
||||
updateCreatingError(null);
|
||||
setIsValidAddress(null);
|
||||
setAddressValue(e.target.value);
|
||||
setCreatingError(null);
|
||||
|
||||
setTimeout(async () => {
|
||||
if (myAddress !== addressRef.current.value) return;
|
||||
const roomAlias = addressRef.current.value;
|
||||
if (roomAlias === '') return;
|
||||
const roomAddress = `#${roomAlias}${hsString}`;
|
||||
const roomAddress = `#${roomAlias}:${userHs}`;
|
||||
|
||||
if (await isRoomAliasAvailable(roomAddress)) {
|
||||
updateIsValidAddress(true);
|
||||
setIsValidAddress(true);
|
||||
} else {
|
||||
updateIsValidAddress(false);
|
||||
setIsValidAddress(false);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
function handleTitleChange(e) {
|
||||
if (e.target.value.trim() === '') updateTitleValue(undefined);
|
||||
updateTitleValue(e.target.value);
|
||||
}
|
||||
function handleTopicChange(e) {
|
||||
if (e.target.value.trim() === '') updateTopicValue(undefined);
|
||||
updateTopicValue(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const joinRules = ['invite', 'restricted', 'public'];
|
||||
const joinRuleShortText = ['Private', 'Restricted', 'Public'];
|
||||
const joinRuleText = ['Private (invite only)', 'Restricted (space member can join)', 'Public (anyone can join)'];
|
||||
const jrRoomIC = [HashLockIC, HashIC, HashGlobeIC];
|
||||
const jrSpaceIC = [SpaceLockIC, SpaceIC, SpaceGlobeIC];
|
||||
const handleJoinRule = (evt) => {
|
||||
openReusableContextMenu(
|
||||
'bottom',
|
||||
getEventCords(evt, '.btn-surface'),
|
||||
(closeMenu) => (
|
||||
<>
|
||||
<MenuHeader>Visibility (who can join)</MenuHeader>
|
||||
{
|
||||
joinRules.map((rule) => (
|
||||
<MenuItem
|
||||
key={rule}
|
||||
variant={rule === joinRule ? 'positive' : 'surface'}
|
||||
iconSrc={
|
||||
isSpace
|
||||
? jrSpaceIC[joinRules.indexOf(rule)]
|
||||
: jrRoomIC[joinRules.indexOf(rule)]
|
||||
}
|
||||
onClick={() => { closeMenu(); setJoinRule(rule); }}
|
||||
disabled={!parentId && rule === 'restricted'}
|
||||
>
|
||||
{ joinRuleText[joinRules.indexOf(rule)] }
|
||||
</MenuItem>
|
||||
))
|
||||
}
|
||||
</>
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PopupWindow
|
||||
isOpen={isOpen}
|
||||
title="Create room"
|
||||
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
||||
onRequestClose={onRequestClose}
|
||||
>
|
||||
<div className="create-room">
|
||||
<form className="create-room__form" onSubmit={(e) => { e.preventDefault(); createRoom(); }}>
|
||||
<SettingTile
|
||||
title="Make room public"
|
||||
options={<Toggle isActive={isPublic} onToggle={togglePublic} />}
|
||||
content={<Text variant="b3">Public room can be joined by anyone.</Text>}
|
||||
/>
|
||||
{isPublic && (
|
||||
<div>
|
||||
<Text className="create-room__address__label" variant="b2">Room address</Text>
|
||||
<div className="create-room__address">
|
||||
<Text variant="b1">#</Text>
|
||||
<Input value={addressValue} onChange={validateAddress} state={(isValidAddress === false) ? 'error' : 'normal'} forwardRef={addressRef} placeholder="my_room" required />
|
||||
<Text variant="b1">{hsString}</Text>
|
||||
</div>
|
||||
{isValidAddress === false && <Text className="create-room__address__tip" variant="b3"><span style={{ color: 'var(--bg-danger)' }}>{`#${addressValue}${hsString} is already in use`}</span></Text>}
|
||||
</div>
|
||||
<div className="create-room">
|
||||
<form className="create-room__form" onSubmit={handleSubmit}>
|
||||
<SettingTile
|
||||
title="Visibility"
|
||||
options={(
|
||||
<Button onClick={handleJoinRule} iconSrc={ChevronBottomIC}>
|
||||
{joinRuleShortText[joinRules.indexOf(joinRule)]}
|
||||
</Button>
|
||||
)}
|
||||
{!isPublic && (
|
||||
<SettingTile
|
||||
title="Enable end-to-end encryption"
|
||||
options={<Toggle isActive={isEncrypted} onToggle={toggleEncrypted} />}
|
||||
content={<Text variant="b3">You can’t disable this later. Bridges & most bots won’t work yet.</Text>}
|
||||
content={<Text variant="b3">{`Select who can join this ${isSpace ? 'space' : 'room'}.`}</Text>}
|
||||
/>
|
||||
{joinRule === 'public' && (
|
||||
<div>
|
||||
<Text className="create-room__address__label" variant="b2">{isSpace ? 'Space address' : 'Room address'}</Text>
|
||||
<div className="create-room__address">
|
||||
<Text variant="b1">#</Text>
|
||||
<Input
|
||||
value={addressValue}
|
||||
onChange={validateAddress}
|
||||
state={(isValidAddress === false) ? 'error' : 'normal'}
|
||||
forwardRef={addressRef}
|
||||
placeholder="my_address"
|
||||
required
|
||||
/>
|
||||
<Text variant="b1">{`:${userHs}`}</Text>
|
||||
</div>
|
||||
{isValidAddress === false && <Text className="create-room__address__tip" variant="b3"><span style={{ color: 'var(--bg-danger)' }}>{`#${addressValue}:${userHs} is already in use`}</span></Text>}
|
||||
</div>
|
||||
)}
|
||||
{!isSpace && joinRule !== 'public' && (
|
||||
<SettingTile
|
||||
title="Enable end-to-end encryption"
|
||||
options={<Toggle isActive={isEncrypted} onToggle={setIsEncrypted} />}
|
||||
content={<Text variant="b3">You can’t disable this later. Bridges & most bots won’t work yet.</Text>}
|
||||
/>
|
||||
)}
|
||||
<SettingTile
|
||||
title="Select your role"
|
||||
options={(
|
||||
<SegmentControl
|
||||
selected={roleIndex}
|
||||
segments={[{ text: 'Admin' }, { text: 'Founder' }]}
|
||||
onSelect={setRoleIndex}
|
||||
/>
|
||||
)}
|
||||
<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 />
|
||||
<Button disabled={isValidAddress === false || isCreatingRoom} iconSrc={HashPlusIC} type="submit" variant="primary">Create</Button>
|
||||
</div>
|
||||
{isCreatingRoom && (
|
||||
<div className="create-room__loading">
|
||||
<Spinner size="small" />
|
||||
<Text>Creating room...</Text>
|
||||
</div>
|
||||
content={(
|
||||
<Text variant="b3">Override the default (100) power level.</Text>
|
||||
)}
|
||||
{typeof creatingError === 'string' && <Text className="create-room__error" variant="b3">{creatingError}</Text>}
|
||||
</form>
|
||||
</div>
|
||||
</PopupWindow>
|
||||
/>
|
||||
<Input name="topic" minHeight={174} resizable label="Topic (optional)" />
|
||||
<div className="create-room__name-wrapper">
|
||||
<Input name="name" label={`${isSpace ? 'Space' : 'Room'} name`} required />
|
||||
<Button
|
||||
disabled={isValidAddress === false || isCreatingRoom}
|
||||
iconSrc={isSpace ? SpacePlusIC : HashPlusIC}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
{isCreatingRoom && (
|
||||
<div className="create-room__loading">
|
||||
<Spinner size="small" />
|
||||
<Text>{`Creating ${isSpace ? 'space' : 'room'}...`}</Text>
|
||||
</div>
|
||||
)}
|
||||
{typeof creatingError === 'string' && <Text className="create-room__error" variant="b3">{creatingError}</Text>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CreateRoom.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
CreateRoomContent.defaultProps = {
|
||||
parentId: null,
|
||||
};
|
||||
CreateRoomContent.propTypes = {
|
||||
isSpace: PropTypes.bool.isRequired,
|
||||
parentId: PropTypes.string,
|
||||
onRequestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function useWindowToggle() {
|
||||
const [create, setCreate] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (isSpace, parentId) => {
|
||||
setCreate({
|
||||
isSpace,
|
||||
parentId,
|
||||
});
|
||||
};
|
||||
navigation.on(cons.events.navigation.CREATE_ROOM_OPENED, handleOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.CREATE_ROOM_OPENED, handleOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onRequestClose = () => setCreate(null);
|
||||
|
||||
return [create, onRequestClose];
|
||||
}
|
||||
|
||||
function CreateRoom() {
|
||||
const [create, onRequestClose] = useWindowToggle();
|
||||
const { isSpace, parentId } = create ?? {};
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(parentId);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={create !== null}
|
||||
title={(
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
{parentId ? twemojify(room.name) : 'Home'}
|
||||
<span style={{ color: 'var(--tc-surface-low)' }}>
|
||||
{` — create ${isSpace ? 'space' : 'room'}`}
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
||||
onRequestClose={onRequestClose}
|
||||
>
|
||||
{
|
||||
create
|
||||
? (
|
||||
<CreateRoomContent
|
||||
isSpace={isSpace}
|
||||
parentId={parentId}
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
) : <div />
|
||||
}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateRoom;
|
||||
|
||||
24
src/app/organisms/drag-drop/DragDrop.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './DragDrop.scss';
|
||||
|
||||
import RawModal from '../../atoms/modal/RawModal';
|
||||
import Text from '../../atoms/text/Text';
|
||||
|
||||
function DragDrop({ isOpen }) {
|
||||
return (
|
||||
<RawModal
|
||||
className="drag-drop__model"
|
||||
overlayClassName="drag-drop__overlay"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<Text variant="h2" weight="medium">Drop file to upload</Text>
|
||||
</RawModal>
|
||||
);
|
||||
}
|
||||
|
||||
DragDrop.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default DragDrop;
|
||||
12
src/app/organisms/drag-drop/DragDrop.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
.drag-drop__model {
|
||||
box-shadow: none;
|
||||
text-align: center;
|
||||
|
||||
.text {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.drag-drop__overlay {
|
||||
background-color: var(--bg-overlay-low);
|
||||
}
|
||||
@@ -82,7 +82,7 @@ const EmojiGroup = React.memo(({ name, groupEmojis }) => {
|
||||
return (
|
||||
<div className="emoji-group">
|
||||
<Text className="emoji-group__header" variant="b2" weight="bold">{name}</Text>
|
||||
{groupEmojis.length !== 0 && <div className="emoji-set">{getEmojiBoard()}</div>}
|
||||
{groupEmojis.length !== 0 && <div className="emoji-set noselect">{getEmojiBoard()}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -128,8 +128,7 @@ function SearchedEmoji() {
|
||||
return <EmojiGroup key="-1" name={searchedEmojis.emojis.length === 0 ? 'No search result found' : 'Search results'} groupEmojis={searchedEmojis.emojis} />;
|
||||
}
|
||||
|
||||
function EmojiBoard({ onSelect }) {
|
||||
const searchRef = useRef(null);
|
||||
function EmojiBoard({ onSelect, searchRef }) {
|
||||
const scrollEmojisRef = useRef(null);
|
||||
const emojiInfo = useRef(null);
|
||||
|
||||
@@ -182,8 +181,8 @@ function EmojiBoard({ onSelect }) {
|
||||
setEmojiInfo({ shortcode: shortcodes[0], src, unicode });
|
||||
}
|
||||
|
||||
function handleSearchChange(e) {
|
||||
const term = e.target.value;
|
||||
function handleSearchChange() {
|
||||
const term = searchRef.current.value;
|
||||
asyncSearch.search(term);
|
||||
scrollEmojisRef.current.scrollTop = 0;
|
||||
}
|
||||
@@ -213,9 +212,16 @@ function EmojiBoard({ onSelect }) {
|
||||
setAvailableEmojis(packs);
|
||||
};
|
||||
|
||||
const onOpen = () => {
|
||||
searchRef.current.value = '';
|
||||
handleSearchChange();
|
||||
};
|
||||
|
||||
navigation.on(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
|
||||
navigation.on(cons.events.navigation.EMOJIBOARD_OPENED, onOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
|
||||
navigation.removeListener(cons.events.navigation.EMOJIBOARD_OPENED, onOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -312,6 +318,7 @@ function EmojiBoard({ onSelect }) {
|
||||
|
||||
EmojiBoard.propTypes = {
|
||||
onSelect: PropTypes.func.isRequired,
|
||||
searchRef: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default EmojiBoard;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import settings from '../../../client/state/settings';
|
||||
|
||||
import ContextMenu from '../../atoms/context-menu/ContextMenu';
|
||||
import EmojiBoard from './EmojiBoard';
|
||||
@@ -10,6 +11,7 @@ let requestCallback = null;
|
||||
let isEmojiBoardVisible = false;
|
||||
function EmojiBoardOpener() {
|
||||
const openerRef = useRef(null);
|
||||
const searchRef = useRef(null);
|
||||
|
||||
function openEmojiBoard(cords, requestEmojiCallback) {
|
||||
if (requestCallback !== null || isEmojiBoardVisible) {
|
||||
@@ -25,7 +27,9 @@ function EmojiBoardOpener() {
|
||||
|
||||
function afterEmojiBoardToggle(isVisible) {
|
||||
isEmojiBoardVisible = isVisible;
|
||||
if (!isVisible) {
|
||||
if (isVisible) {
|
||||
if (!settings.isTouchScreenDevice) searchRef.current.focus();
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
if (!isEmojiBoardVisible) requestCallback = null;
|
||||
}, 500);
|
||||
@@ -46,7 +50,7 @@ function EmojiBoardOpener() {
|
||||
return (
|
||||
<ContextMenu
|
||||
content={(
|
||||
<EmojiBoard onSelect={addEmoji} />
|
||||
<EmojiBoard onSelect={addEmoji} searchRef={searchRef} />
|
||||
)}
|
||||
afterToggle={afterEmojiBoardToggle}
|
||||
render={(toggleMenu) => (
|
||||
|
||||
@@ -53,6 +53,7 @@ function addToGroup(emoji) {
|
||||
const emojis = [];
|
||||
emojisData.forEach((emoji) => {
|
||||
const myShortCodes = shortcodes[emoji.hexcode];
|
||||
if (!myShortCodes) return;
|
||||
const em = {
|
||||
...emoji,
|
||||
shortcode: Array.isArray(myShortCodes) ? myShortCodes[0] : myShortCodes,
|
||||
|
||||
@@ -6,6 +6,7 @@ import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
import { selectRoom } from '../../../client/action/navigation';
|
||||
import { hasDMWith } from '../../../util/matrixUtil';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
@@ -104,17 +105,19 @@ function InviteUser({
|
||||
|
||||
async function createDM(userId) {
|
||||
if (mx.getUserId() === userId) return;
|
||||
const dmRoomId = hasDMWith(userId);
|
||||
if (dmRoomId) {
|
||||
selectRoom(dmRoomId);
|
||||
onRequestClose();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
addUserToProc(userId);
|
||||
procUserError.delete(userId);
|
||||
updateUserProcError(getMapCopy(procUserError));
|
||||
|
||||
const result = await roomActions.create({
|
||||
isPublic: false,
|
||||
isEncrypted: true,
|
||||
isDirect: true,
|
||||
invite: [userId],
|
||||
});
|
||||
const result = await roomActions.createDM(userId);
|
||||
roomIdToUserId.set(result.room_id, userId);
|
||||
updateRoomIdToUserId(getMapCopy(roomIdToUserId));
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { selectRoom } from '../../../client/action/navigation';
|
||||
import Postie from '../../../util/Postie';
|
||||
|
||||
import Selector from './Selector';
|
||||
import RoomsCategory from './RoomsCategory';
|
||||
|
||||
import { AtoZ } from './common';
|
||||
|
||||
@@ -15,55 +14,34 @@ function Directs() {
|
||||
const { roomList, notifications } = initMatrix;
|
||||
const directIds = [...roomList.directs].sort(AtoZ);
|
||||
|
||||
const [, forceUpdate] = useState({});
|
||||
|
||||
function selectorChanged(selectedRoomId, prevSelectedRoomId) {
|
||||
if (!drawerPostie.hasTopic('selector-change')) return;
|
||||
const addresses = [];
|
||||
if (drawerPostie.hasSubscriber('selector-change', selectedRoomId)) addresses.push(selectedRoomId);
|
||||
if (drawerPostie.hasSubscriber('selector-change', prevSelectedRoomId)) addresses.push(prevSelectedRoomId);
|
||||
if (addresses.length === 0) return;
|
||||
drawerPostie.post('selector-change', addresses, selectedRoomId);
|
||||
}
|
||||
|
||||
function notiChanged(roomId, total, prevTotal) {
|
||||
if (total === prevTotal) return;
|
||||
if (drawerPostie.hasTopicAndSubscriber('unread-change', roomId)) {
|
||||
drawerPostie.post('unread-change', roomId);
|
||||
}
|
||||
}
|
||||
|
||||
function roomListUpdated() {
|
||||
const { spaces, rooms, directs } = initMatrix.roomList;
|
||||
if (!(
|
||||
spaces.has(navigation.selectedRoomId)
|
||||
|| rooms.has(navigation.selectedRoomId)
|
||||
|| directs.has(navigation.selectedRoomId))
|
||||
) {
|
||||
selectRoom(null);
|
||||
}
|
||||
forceUpdate({});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
roomList.on(cons.events.roomList.ROOMLIST_UPDATED, roomListUpdated);
|
||||
const selectorChanged = (selectedRoomId, prevSelectedRoomId) => {
|
||||
if (!drawerPostie.hasTopic('selector-change')) return;
|
||||
const addresses = [];
|
||||
if (drawerPostie.hasSubscriber('selector-change', selectedRoomId)) addresses.push(selectedRoomId);
|
||||
if (drawerPostie.hasSubscriber('selector-change', prevSelectedRoomId)) addresses.push(prevSelectedRoomId);
|
||||
if (addresses.length === 0) return;
|
||||
drawerPostie.post('selector-change', addresses, selectedRoomId);
|
||||
};
|
||||
|
||||
const notiChanged = (roomId, total, prevTotal) => {
|
||||
if (total === prevTotal) return;
|
||||
if (drawerPostie.hasTopicAndSubscriber('unread-change', roomId)) {
|
||||
drawerPostie.post('unread-change', roomId);
|
||||
}
|
||||
};
|
||||
|
||||
navigation.on(cons.events.navigation.ROOM_SELECTED, selectorChanged);
|
||||
notifications.on(cons.events.notifications.NOTI_CHANGED, notiChanged);
|
||||
notifications.on(cons.events.notifications.MUTE_TOGGLED, notiChanged);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, roomListUpdated);
|
||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, selectorChanged);
|
||||
notifications.removeListener(cons.events.notifications.NOTI_CHANGED, notiChanged);
|
||||
notifications.removeListener(cons.events.notifications.MUTE_TOGGLED, notiChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return directIds.map((id) => (
|
||||
<Selector
|
||||
key={id}
|
||||
roomId={id}
|
||||
drawerPostie={drawerPostie}
|
||||
onClick={() => selectRoom(id)}
|
||||
/>
|
||||
));
|
||||
return <RoomsCategory name="People" hideHeader roomIds={directIds} drawerPostie={drawerPostie} />;
|
||||
}
|
||||
|
||||
export default Directs;
|
||||
|
||||
@@ -4,7 +4,6 @@ import './Drawer.scss';
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { selectTab, selectSpace } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
@@ -14,6 +13,7 @@ import DrawerBreadcrumb from './DrawerBreadcrumb';
|
||||
import Home from './Home';
|
||||
import Directs from './Directs';
|
||||
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
import { useSelectedTab } from '../../hooks/useSelectedTab';
|
||||
import { useSelectedSpace } from '../../hooks/useSelectedSpace';
|
||||
|
||||
@@ -40,8 +40,17 @@ function Drawer() {
|
||||
const [systemState] = useSystemState();
|
||||
const [selectedTab] = useSelectedTab();
|
||||
const [spaceId] = useSelectedSpace();
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const { roomList } = initMatrix;
|
||||
roomList.on(cons.events.roomList.ROOMLIST_UPDATED, forceUpdate);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, forceUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
@@ -52,7 +61,7 @@ function Drawer() {
|
||||
<div className="drawer">
|
||||
<DrawerHeader selectedTab={selectedTab} spaceId={spaceId} />
|
||||
<div className="drawer__content-wrapper">
|
||||
{selectedTab !== cons.tabs.DIRECTS && <DrawerBreadcrumb spaceId={spaceId} />}
|
||||
{navigation.selectedSpacePath.length > 1 && <DrawerBreadcrumb spaceId={spaceId} />}
|
||||
<div className="rooms__wrapper">
|
||||
<ScrollView ref={scrollRef} autoHide>
|
||||
<div className="rooms-container">
|
||||
|
||||
@@ -10,6 +10,13 @@
|
||||
1px solid var(--bg-surface-border),
|
||||
);
|
||||
|
||||
& .header {
|
||||
padding: var(--sp-extra-tight);
|
||||
& > .header__title-wrapper {
|
||||
@include dir.side(margin, var(--sp-ultra-tight), 0);
|
||||
}
|
||||
}
|
||||
|
||||
&__content-wrapper {
|
||||
@extend .cp-fx__item-one;
|
||||
@extend .cp-fx__column;
|
||||
@@ -46,19 +53,4 @@
|
||||
var(--bg-surface-low),
|
||||
var(--bg-surface-low-transparent));
|
||||
}
|
||||
|
||||
& > .room-selector {
|
||||
width: calc(100% - var(--sp-extra-tight));
|
||||
@include dir.side(margin, auto, 0);
|
||||
}
|
||||
|
||||
& > .room-selector:first-child {
|
||||
margin-top: var(--sp-extra-tight);
|
||||
}
|
||||
|
||||
& .cat-header {
|
||||
margin: var(--sp-normal);
|
||||
margin-bottom: var(--sp-extra-tight);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +48,6 @@ function DrawerBreadcrumb({ spaceId }) {
|
||||
};
|
||||
}, [spaceId]);
|
||||
|
||||
if (spacePath.length === 1) return null;
|
||||
|
||||
function getHomeNotiExcept(childId) {
|
||||
const orphans = roomList.getOrphans();
|
||||
const childIndex = orphans.indexOf(childId);
|
||||
@@ -74,20 +72,28 @@ function DrawerBreadcrumb({ spaceId }) {
|
||||
const noti = notifications.getNoti(roomId);
|
||||
if (!notifications.hasNoti(childId)) return noti;
|
||||
if (noti.from === null) return noti;
|
||||
if (noti.from.has(childId) && noti.from.size === 1) return null;
|
||||
|
||||
const childNoti = notifications.getNoti(childId);
|
||||
|
||||
return {
|
||||
total: noti.total - childNoti.total,
|
||||
highlight: noti.highlight - childNoti.highlight,
|
||||
};
|
||||
let noOther = true;
|
||||
let total = 0;
|
||||
let highlight = 0;
|
||||
noti.from.forEach((fromId) => {
|
||||
if (childNoti.from.has(fromId)) return;
|
||||
noOther = false;
|
||||
const fromNoti = notifications.getNoti(fromId);
|
||||
total += fromNoti.total;
|
||||
highlight += fromNoti.highlight;
|
||||
});
|
||||
|
||||
if (noOther) return null;
|
||||
return { total, highlight };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="breadcrumb__wrapper">
|
||||
<div className="drawer-breadcrumb__wrapper">
|
||||
<ScrollView ref={scrollRef} horizontal vertical={false} invisible>
|
||||
<div className="breadcrumb">
|
||||
<div className="drawer-breadcrumb">
|
||||
{
|
||||
spacePath.map((id, index) => {
|
||||
const noti = (id !== cons.tabs.HOME && index < spacePath.length)
|
||||
@@ -100,7 +106,7 @@ function DrawerBreadcrumb({ spaceId }) {
|
||||
>
|
||||
{ index !== 0 && <RawIcon size="extra-small" src={ChevronRightIC} />}
|
||||
<Button
|
||||
className={index === spacePath.length - 1 ? 'breadcrumb__btn--selected' : ''}
|
||||
className={index === spacePath.length - 1 ? 'drawer-breadcrumb__btn--selected' : ''}
|
||||
onClick={() => selectSpace(id)}
|
||||
>
|
||||
<Text variant="b2">{id === cons.tabs.HOME ? 'Home' : twemojify(mx.getRoom(id).name)}</Text>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
@use '../../partials/text';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.breadcrumb__wrapper {
|
||||
.drawer-breadcrumb__wrapper {
|
||||
height: var(--header-height);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
.drawer-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
|
||||
@@ -1,78 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './DrawerHeader.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import {
|
||||
openPublicRooms, openCreateRoom, openInviteUser,
|
||||
openPublicRooms, openCreateRoom, openSpaceManage,
|
||||
openSpaceAddExisting, openInviteUser, openReusableContextMenu,
|
||||
} from '../../../client/action/navigation';
|
||||
import { createSpaceShortcut, deleteSpaceShortcut } from '../../../client/action/room';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
|
||||
import { blurOnBubbling } from '../../atoms/button/script';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||
import { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||
import SpaceOptions from '../../molecules/space-options/SpaceOptions';
|
||||
|
||||
import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
|
||||
import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg';
|
||||
import HashGlobeIC from '../../../../public/res/ic/outlined/hash-globe.svg';
|
||||
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
|
||||
import PinIC from '../../../../public/res/ic/outlined/pin.svg';
|
||||
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
||||
import SpacePlusIC from '../../../../public/res/ic/outlined/space-plus.svg';
|
||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||
|
||||
export function HomeSpaceOptions({ spaceId, afterOptionSelect }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(spaceId);
|
||||
const canManage = room
|
||||
? room.currentState.maySendStateEvent('m.space.child', mx.getUserId())
|
||||
: true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuHeader>Add rooms or spaces</MenuHeader>
|
||||
<MenuItem
|
||||
iconSrc={SpacePlusIC}
|
||||
onClick={() => { afterOptionSelect(); openCreateRoom(true, spaceId); }}
|
||||
disabled={!canManage}
|
||||
>
|
||||
Create new space
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
iconSrc={HashPlusIC}
|
||||
onClick={() => { afterOptionSelect(); openCreateRoom(false, spaceId); }}
|
||||
disabled={!canManage}
|
||||
>
|
||||
Create new room
|
||||
</MenuItem>
|
||||
{ !spaceId && (
|
||||
<MenuItem
|
||||
iconSrc={HashGlobeIC}
|
||||
onClick={() => { afterOptionSelect(); openPublicRooms(); }}
|
||||
>
|
||||
Join public room
|
||||
</MenuItem>
|
||||
)}
|
||||
{ spaceId && (
|
||||
<MenuItem
|
||||
iconSrc={PlusIC}
|
||||
onClick={() => { afterOptionSelect(); openSpaceAddExisting(spaceId); }}
|
||||
disabled={!canManage}
|
||||
>
|
||||
Add existing
|
||||
</MenuItem>
|
||||
)}
|
||||
{ spaceId && (
|
||||
<MenuItem
|
||||
onClick={() => { afterOptionSelect(); openSpaceManage(spaceId); }}
|
||||
iconSrc={HashSearchIC}
|
||||
>
|
||||
Manage rooms
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
HomeSpaceOptions.defaultProps = {
|
||||
spaceId: null,
|
||||
};
|
||||
HomeSpaceOptions.propTypes = {
|
||||
spaceId: PropTypes.string,
|
||||
afterOptionSelect: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function DrawerHeader({ selectedTab, spaceId }) {
|
||||
const [, forceUpdate] = useState({});
|
||||
const mx = initMatrix.matrixClient;
|
||||
const tabName = selectedTab !== cons.tabs.DIRECTS ? 'Home' : 'Direct messages';
|
||||
|
||||
const isDMTab = selectedTab === cons.tabs.DIRECTS;
|
||||
const room = mx.getRoom(spaceId);
|
||||
const spaceName = selectedTab === cons.tabs.DIRECTS ? null : (room?.name || null);
|
||||
const spaceName = isDMTab ? null : (room?.name || null);
|
||||
|
||||
const openSpaceOptions = (e) => {
|
||||
e.preventDefault();
|
||||
openReusableContextMenu(
|
||||
'bottom',
|
||||
getEventCords(e, '.header'),
|
||||
(closeMenu) => <SpaceOptions roomId={spaceId} afterOptionSelect={closeMenu} />,
|
||||
);
|
||||
};
|
||||
|
||||
const openHomeSpaceOptions = (e) => {
|
||||
e.preventDefault();
|
||||
openReusableContextMenu(
|
||||
'right',
|
||||
getEventCords(e, '.ic-btn'),
|
||||
(closeMenu) => <HomeSpaceOptions spaceId={spaceId} afterOptionSelect={closeMenu} />,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<TitleWrapper>
|
||||
<Text variant="s1" weight="medium" primary>{twemojify(spaceName) || tabName}</Text>
|
||||
</TitleWrapper>
|
||||
{spaceName && (
|
||||
<IconButton
|
||||
size="extra-small"
|
||||
variant="surface"
|
||||
tooltip={initMatrix.roomList.spaceShortcut.has(spaceId) ? 'Unpin' : 'Pin to sidebar'}
|
||||
src={initMatrix.roomList.spaceShortcut.has(spaceId) ? PinFilledIC : PinIC}
|
||||
onClick={() => {
|
||||
if (initMatrix.roomList.spaceShortcut.has(spaceId)) deleteSpaceShortcut(spaceId);
|
||||
else createSpaceShortcut(spaceId);
|
||||
forceUpdate({});
|
||||
}}
|
||||
/>
|
||||
{spaceName ? (
|
||||
<button
|
||||
className="drawer-header__btn"
|
||||
onClick={openSpaceOptions}
|
||||
type="button"
|
||||
onMouseUp={(e) => blurOnBubbling(e, '.drawer-header__btn')}
|
||||
>
|
||||
<TitleWrapper>
|
||||
<Text variant="s1" weight="medium" primary>{twemojify(spaceName)}</Text>
|
||||
</TitleWrapper>
|
||||
<RawIcon size="small" src={ChevronBottomIC} />
|
||||
</button>
|
||||
) : (
|
||||
<TitleWrapper>
|
||||
<Text variant="s1" weight="medium" primary>{tabName}</Text>
|
||||
</TitleWrapper>
|
||||
)}
|
||||
{ selectedTab === cons.tabs.DIRECTS && <IconButton onClick={() => openInviteUser()} tooltip="Start DM" src={PlusIC} size="normal" /> }
|
||||
{ selectedTab !== cons.tabs.DIRECTS && !spaceName && (
|
||||
<>
|
||||
<ContextMenu
|
||||
content={(hideMenu) => (
|
||||
<>
|
||||
<MenuHeader>Add room</MenuHeader>
|
||||
<MenuItem
|
||||
iconSrc={HashPlusIC}
|
||||
onClick={() => { hideMenu(); openCreateRoom(); }}
|
||||
>
|
||||
Create new room
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
iconSrc={HashSearchIC}
|
||||
onClick={() => { hideMenu(); openPublicRooms(); }}
|
||||
>
|
||||
Add public room
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
render={(toggleMenu) => (<IconButton onClick={toggleMenu} tooltip="Add room" src={PlusIC} size="normal" />)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* <IconButton onClick={() => ''} tooltip="Menu" src={VerticalMenuIC} size="normal" /> */}
|
||||
|
||||
{ isDMTab && <IconButton onClick={() => openInviteUser()} tooltip="Start DM" src={PlusIC} size="small" /> }
|
||||
{ !isDMTab && <IconButton onClick={openHomeSpaceOptions} tooltip="Add rooms/spaces" src={PlusIC} size="small" /> }
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
|
||||
28
src/app/organisms/navigation/DrawerHeader.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.drawer-header__btn {
|
||||
min-width: 0;
|
||||
@extend .cp-fx__row--s-c;
|
||||
@include dir.side(margin, 0, auto);
|
||||
padding: var(--sp-ultra-tight);
|
||||
border-radius: calc(var(--bo-radius) / 2);
|
||||
cursor: pointer;
|
||||
|
||||
& .header__title-wrapper {
|
||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||
}
|
||||
|
||||
@media (hover:hover) {
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
box-shadow: var(--bs-surface-outline);
|
||||
}
|
||||
}
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: var(--bg-surface-active);
|
||||
box-shadow: var(--bs-surface-outline);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
@@ -1,106 +1,96 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { selectSpace, selectRoom } from '../../../client/action/navigation';
|
||||
import Postie from '../../../util/Postie';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Selector from './Selector';
|
||||
import RoomsCategory from './RoomsCategory';
|
||||
|
||||
import { AtoZ } from './common';
|
||||
import { useCategorizedSpaces } from '../../hooks/useCategorizedSpaces';
|
||||
import { AtoZ, RoomToDM } from './common';
|
||||
|
||||
const drawerPostie = new Postie();
|
||||
function Home({ spaceId }) {
|
||||
const [, forceUpdate] = useState({});
|
||||
const { roomList, notifications } = initMatrix;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { roomList, notifications, accountData } = initMatrix;
|
||||
const { spaces, rooms, directs } = roomList;
|
||||
useCategorizedSpaces();
|
||||
const isCategorized = accountData.categorizedSpaces.has(spaceId);
|
||||
|
||||
let categories = null;
|
||||
let spaceIds = [];
|
||||
let roomIds = [];
|
||||
let directIds = [];
|
||||
|
||||
const spaceChildIds = roomList.getSpaceChildren(spaceId);
|
||||
if (spaceChildIds) {
|
||||
spaceIds = spaceChildIds.filter((roomId) => roomList.spaces.has(roomId)).sort(AtoZ);
|
||||
roomIds = spaceChildIds.filter((roomId) => roomList.rooms.has(roomId)).sort(AtoZ);
|
||||
directIds = spaceChildIds.filter((roomId) => roomList.directs.has(roomId)).sort(AtoZ);
|
||||
if (spaceId) {
|
||||
const spaceChildIds = roomList.getSpaceChildren(spaceId);
|
||||
spaceIds = spaceChildIds.filter((roomId) => spaces.has(roomId));
|
||||
roomIds = spaceChildIds.filter((roomId) => rooms.has(roomId));
|
||||
directIds = spaceChildIds.filter((roomId) => directs.has(roomId));
|
||||
} else {
|
||||
spaceIds = [...roomList.spaces]
|
||||
.filter((roomId) => !roomList.roomIdToParents.has(roomId)).sort(AtoZ);
|
||||
roomIds = [...roomList.rooms]
|
||||
.filter((roomId) => !roomList.roomIdToParents.has(roomId)).sort(AtoZ);
|
||||
spaceIds = roomList.getOrphanSpaces();
|
||||
roomIds = roomList.getOrphanRooms();
|
||||
}
|
||||
|
||||
function selectorChanged(selectedRoomId, prevSelectedRoomId) {
|
||||
if (!drawerPostie.hasTopic('selector-change')) return;
|
||||
const addresses = [];
|
||||
if (drawerPostie.hasSubscriber('selector-change', selectedRoomId)) addresses.push(selectedRoomId);
|
||||
if (drawerPostie.hasSubscriber('selector-change', prevSelectedRoomId)) addresses.push(prevSelectedRoomId);
|
||||
if (addresses.length === 0) return;
|
||||
drawerPostie.post('selector-change', addresses, selectedRoomId);
|
||||
}
|
||||
function notiChanged(roomId, total, prevTotal) {
|
||||
if (total === prevTotal) return;
|
||||
if (drawerPostie.hasTopicAndSubscriber('unread-change', roomId)) {
|
||||
drawerPostie.post('unread-change', roomId);
|
||||
}
|
||||
}
|
||||
spaceIds.sort(AtoZ);
|
||||
roomIds.sort(AtoZ);
|
||||
directIds.sort(AtoZ);
|
||||
|
||||
function roomListUpdated() {
|
||||
const { spaces, rooms, directs } = initMatrix.roomList;
|
||||
if (!(
|
||||
spaces.has(navigation.selectedRoomId)
|
||||
|| rooms.has(navigation.selectedRoomId)
|
||||
|| directs.has(navigation.selectedRoomId))
|
||||
) {
|
||||
selectRoom(null);
|
||||
}
|
||||
forceUpdate({});
|
||||
if (isCategorized) {
|
||||
categories = roomList.getCategorizedSpaces(spaceIds);
|
||||
categories.delete(spaceId);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
roomList.on(cons.events.roomList.ROOMLIST_UPDATED, roomListUpdated);
|
||||
const selectorChanged = (selectedRoomId, prevSelectedRoomId) => {
|
||||
if (!drawerPostie.hasTopic('selector-change')) return;
|
||||
const addresses = [];
|
||||
if (drawerPostie.hasSubscriber('selector-change', selectedRoomId)) addresses.push(selectedRoomId);
|
||||
if (drawerPostie.hasSubscriber('selector-change', prevSelectedRoomId)) addresses.push(prevSelectedRoomId);
|
||||
if (addresses.length === 0) return;
|
||||
drawerPostie.post('selector-change', addresses, selectedRoomId);
|
||||
};
|
||||
|
||||
const notiChanged = (roomId, total, prevTotal) => {
|
||||
if (total === prevTotal) return;
|
||||
if (drawerPostie.hasTopicAndSubscriber('unread-change', roomId)) {
|
||||
drawerPostie.post('unread-change', roomId);
|
||||
}
|
||||
};
|
||||
|
||||
navigation.on(cons.events.navigation.ROOM_SELECTED, selectorChanged);
|
||||
notifications.on(cons.events.notifications.NOTI_CHANGED, notiChanged);
|
||||
notifications.on(cons.events.notifications.MUTE_TOGGLED, notiChanged);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, roomListUpdated);
|
||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, selectorChanged);
|
||||
notifications.removeListener(cons.events.notifications.NOTI_CHANGED, notiChanged);
|
||||
notifications.removeListener(cons.events.notifications.MUTE_TOGGLED, notiChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ spaceIds.length !== 0 && <Text className="cat-header" variant="b3" weight="bold">Spaces</Text> }
|
||||
{ spaceIds.map((id) => (
|
||||
<Selector
|
||||
key={id}
|
||||
roomId={id}
|
||||
isDM={false}
|
||||
drawerPostie={drawerPostie}
|
||||
onClick={() => selectSpace(id)}
|
||||
/>
|
||||
))}
|
||||
{ !isCategorized && spaceIds.length !== 0 && (
|
||||
<RoomsCategory name="Spaces" roomIds={spaceIds} drawerPostie={drawerPostie} />
|
||||
)}
|
||||
|
||||
{ roomIds.length !== 0 && <Text className="cat-header" variant="b3" weight="bold">Rooms</Text> }
|
||||
{ roomIds.map((id) => (
|
||||
<Selector
|
||||
key={id}
|
||||
roomId={id}
|
||||
isDM={false}
|
||||
drawerPostie={drawerPostie}
|
||||
onClick={() => selectRoom(id)}
|
||||
/>
|
||||
)) }
|
||||
{ roomIds.length !== 0 && (
|
||||
<RoomsCategory name="Rooms" roomIds={roomIds} drawerPostie={drawerPostie} />
|
||||
)}
|
||||
|
||||
{ directIds.length !== 0 && <Text className="cat-header" variant="b3" weight="bold">People</Text> }
|
||||
{ directIds.map((id) => (
|
||||
<Selector
|
||||
key={id}
|
||||
roomId={id}
|
||||
{ directIds.length !== 0 && (
|
||||
<RoomsCategory name="People" roomIds={directIds} drawerPostie={drawerPostie} />
|
||||
)}
|
||||
|
||||
{ isCategorized && [...categories].map(([catId, childIds]) => (
|
||||
<RoomsCategory
|
||||
key={catId}
|
||||
spaceId={catId}
|
||||
name={mx.getRoom(catId).name}
|
||||
roomIds={[...childIds].sort(AtoZ).sort(RoomToDM)}
|
||||
drawerPostie={drawerPostie}
|
||||
onClick={() => selectRoom(id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
92
src/app/organisms/navigation/RoomsCategory.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomsCategory.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { selectSpace, selectRoom, openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Selector from './Selector';
|
||||
import SpaceOptions from '../../molecules/space-options/SpaceOptions';
|
||||
import { HomeSpaceOptions } from './DrawerHeader';
|
||||
|
||||
import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
|
||||
import HorizontalMenuIC from '../../../../public/res/ic/outlined/horizontal-menu.svg';
|
||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||
import ChevronRightIC from '../../../../public/res/ic/outlined/chevron-right.svg';
|
||||
|
||||
function RoomsCategory({
|
||||
spaceId, name, hideHeader, roomIds, drawerPostie,
|
||||
}) {
|
||||
const { spaces, directs } = initMatrix.roomList;
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const openSpaceOptions = (e) => {
|
||||
e.preventDefault();
|
||||
openReusableContextMenu(
|
||||
'bottom',
|
||||
getEventCords(e, '.header'),
|
||||
(closeMenu) => <SpaceOptions roomId={spaceId} afterOptionSelect={closeMenu} />,
|
||||
);
|
||||
};
|
||||
|
||||
const openHomeSpaceOptions = (e) => {
|
||||
e.preventDefault();
|
||||
openReusableContextMenu(
|
||||
'right',
|
||||
getEventCords(e, '.ic-btn'),
|
||||
(closeMenu) => <HomeSpaceOptions spaceId={spaceId} afterOptionSelect={closeMenu} />,
|
||||
);
|
||||
};
|
||||
|
||||
const renderSelector = (roomId) => {
|
||||
const isSpace = spaces.has(roomId);
|
||||
const isDM = directs.has(roomId);
|
||||
|
||||
return (
|
||||
<Selector
|
||||
key={roomId}
|
||||
roomId={roomId}
|
||||
isDM={isDM}
|
||||
drawerPostie={drawerPostie}
|
||||
onClick={() => (isSpace ? selectSpace(roomId) : selectRoom(roomId))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="room-category">
|
||||
{!hideHeader && (
|
||||
<div className="room-category__header">
|
||||
<button className="room-category__toggle" onClick={() => setIsOpen(!isOpen)} type="button">
|
||||
<RawIcon src={isOpen ? ChevronBottomIC : ChevronRightIC} size="extra-small" />
|
||||
<Text className="cat-header" variant="b3" weight="medium">{name}</Text>
|
||||
</button>
|
||||
{spaceId && <IconButton onClick={openSpaceOptions} tooltip="Space options" src={HorizontalMenuIC} size="extra-small" />}
|
||||
{spaceId && <IconButton onClick={openHomeSpaceOptions} tooltip="Add rooms/spaces" src={PlusIC} size="extra-small" />}
|
||||
</div>
|
||||
)}
|
||||
{(isOpen || hideHeader) && (
|
||||
<div className="room-category__content">
|
||||
{roomIds.map(renderSelector)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
RoomsCategory.defaultProps = {
|
||||
spaceId: null,
|
||||
hideHeader: false,
|
||||
};
|
||||
RoomsCategory.propTypes = {
|
||||
spaceId: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
hideHeader: PropTypes.bool,
|
||||
roomIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
drawerPostie: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
export default RoomsCategory;
|
||||
54
src/app/organisms/navigation/RoomsCategory.scss
Normal file
@@ -0,0 +1,54 @@
|
||||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
@use '../../partials/text';
|
||||
|
||||
.room-category {
|
||||
&__header,
|
||||
&__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
&__header {
|
||||
margin-top: var(--sp-extra-tight);
|
||||
|
||||
& .ic-btn {
|
||||
padding: var(--sp-ultra-tight);
|
||||
border-radius: 4px;
|
||||
@include dir.side(margin, 0, 5px);
|
||||
& .ic-raw {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--ic-surface-low);
|
||||
}
|
||||
}
|
||||
}
|
||||
&__toggle {
|
||||
@extend .cp-fx__item-one;
|
||||
padding: var(--sp-extra-tight) var(--sp-tight);
|
||||
cursor: pointer;
|
||||
|
||||
& .ic-raw {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--ic-surface-low);
|
||||
@include dir.side(margin, 0, var(--sp-ultra-tight));
|
||||
}
|
||||
& .text {
|
||||
text-transform: uppercase;
|
||||
@extend .cp-txt__ellipsis;
|
||||
}
|
||||
&:hover .text {
|
||||
color: var(--tc-surface-normal);
|
||||
}
|
||||
}
|
||||
|
||||
&__content:first-child {
|
||||
margin-top: var(--sp-extra-tight);
|
||||
}
|
||||
|
||||
& .room-selector {
|
||||
width: calc(100% - var(--sp-extra-tight));
|
||||
@include dir.side(margin, auto, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,119 +1,78 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import { createSpaceShortcut, deleteSpaceShortcut } from '../../../client/action/room';
|
||||
import { getEventCords, abbreviateNumber } from '../../../util/common';
|
||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
||||
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import RoomSelector from '../../molecules/room-selector/RoomSelector';
|
||||
import RoomOptions from '../../molecules/room-options/RoomOptions';
|
||||
import SpaceOptions from '../../molecules/space-options/SpaceOptions';
|
||||
|
||||
import HashIC from '../../../../public/res/ic/outlined/hash.svg';
|
||||
import HashGlobeIC from '../../../../public/res/ic/outlined/hash-globe.svg';
|
||||
import HashLockIC from '../../../../public/res/ic/outlined/hash-lock.svg';
|
||||
import SpaceIC from '../../../../public/res/ic/outlined/space.svg';
|
||||
import SpaceGlobeIC from '../../../../public/res/ic/outlined/space-globe.svg';
|
||||
import SpaceLockIC from '../../../../public/res/ic/outlined/space-lock.svg';
|
||||
import PinIC from '../../../../public/res/ic/outlined/pin.svg';
|
||||
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
||||
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
|
||||
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
|
||||
function Selector({
|
||||
roomId, isDM, drawerPostie, onClick,
|
||||
}) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const noti = initMatrix.notifications;
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
let imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
if (imageSrc === null) imageSrc = room.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
|
||||
const [isSelected, setIsSelected] = useState(navigation.selectedRoomId === roomId);
|
||||
const [, forceUpdate] = useState({});
|
||||
const isMuted = noti.getNotiType(roomId) === cons.notifs.MUTE;
|
||||
|
||||
function selectorChanged(selectedRoomId) {
|
||||
setIsSelected(selectedRoomId === roomId);
|
||||
}
|
||||
function changeNotificationBadge() {
|
||||
forceUpdate({});
|
||||
}
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
|
||||
useEffect(() => {
|
||||
drawerPostie.subscribe('selector-change', roomId, selectorChanged);
|
||||
drawerPostie.subscribe('unread-change', roomId, changeNotificationBadge);
|
||||
const unSub1 = drawerPostie.subscribe('selector-change', roomId, forceUpdate);
|
||||
const unSub2 = drawerPostie.subscribe('unread-change', roomId, forceUpdate);
|
||||
return () => {
|
||||
drawerPostie.unsubscribe('selector-change', roomId);
|
||||
drawerPostie.unsubscribe('unread-change', roomId);
|
||||
unSub1();
|
||||
unSub2();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openRoomOptions = (e) => {
|
||||
const openOptions = (e) => {
|
||||
e.preventDefault();
|
||||
openReusableContextMenu(
|
||||
'right',
|
||||
getEventCords(e, '.room-selector'),
|
||||
(closeMenu) => <RoomOptions roomId={roomId} afterOptionSelect={closeMenu} />,
|
||||
room.isSpaceRoom()
|
||||
? (closeMenu) => <SpaceOptions roomId={roomId} afterOptionSelect={closeMenu} />
|
||||
: (closeMenu) => <RoomOptions roomId={roomId} afterOptionSelect={closeMenu} />,
|
||||
);
|
||||
};
|
||||
|
||||
const joinRuleToIconSrc = (joinRule) => ({
|
||||
restricted: () => (room.isSpaceRoom() ? SpaceIC : HashIC),
|
||||
invite: () => (room.isSpaceRoom() ? SpaceLockIC : HashLockIC),
|
||||
public: () => (room.isSpaceRoom() ? SpaceGlobeIC : HashGlobeIC),
|
||||
}[joinRule]?.() || null);
|
||||
|
||||
if (room.isSpaceRoom()) {
|
||||
return (
|
||||
<RoomSelector
|
||||
key={roomId}
|
||||
name={room.name}
|
||||
roomId={roomId}
|
||||
iconSrc={joinRuleToIconSrc(room.getJoinRule())}
|
||||
isUnread={noti.hasNoti(roomId)}
|
||||
notificationCount={abbreviateNumber(noti.getTotalNoti(roomId))}
|
||||
isAlert={noti.getHighlightNoti(roomId) !== 0}
|
||||
onClick={onClick}
|
||||
options={(
|
||||
<IconButton
|
||||
size="extra-small"
|
||||
variant="surface"
|
||||
tooltip={initMatrix.roomList.spaceShortcut.has(roomId) ? 'Unpin' : 'Pin to sidebar'}
|
||||
tooltipPlacement="right"
|
||||
src={initMatrix.roomList.spaceShortcut.has(roomId) ? PinFilledIC : PinIC}
|
||||
onClick={() => {
|
||||
if (initMatrix.roomList.spaceShortcut.has(roomId)) deleteSpaceShortcut(roomId);
|
||||
else createSpaceShortcut(roomId);
|
||||
forceUpdate({});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RoomSelector
|
||||
key={roomId}
|
||||
name={room.name}
|
||||
roomId={roomId}
|
||||
imageSrc={isDM ? imageSrc : null}
|
||||
iconSrc={isDM ? null : joinRuleToIconSrc(room.getJoinRule())}
|
||||
isSelected={isSelected}
|
||||
isUnread={noti.hasNoti(roomId)}
|
||||
iconSrc={isDM ? null : joinRuleToIconSrc(room.getJoinRule(), room.isSpaceRoom())}
|
||||
isSelected={navigation.selectedRoomId === roomId}
|
||||
isMuted={isMuted}
|
||||
isUnread={!isMuted && noti.hasNoti(roomId)}
|
||||
notificationCount={abbreviateNumber(noti.getTotalNoti(roomId))}
|
||||
isAlert={noti.getHighlightNoti(roomId) !== 0}
|
||||
onClick={onClick}
|
||||
onContextMenu={openRoomOptions}
|
||||
onContextMenu={openOptions}
|
||||
options={(
|
||||
<IconButton
|
||||
size="extra-small"
|
||||
tooltip="Options"
|
||||
tooltipPlacement="right"
|
||||
src={VerticalMenuIC}
|
||||
onClick={openRoomOptions}
|
||||
onClick={openOptions}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,24 +1,48 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './SideBar.scss';
|
||||
|
||||
import { DndProvider, useDrag, useDrop } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import {
|
||||
selectTab, openInviteList, openSearch, openSettings,
|
||||
selectTab, openShortcutSpaces, openInviteList,
|
||||
openSearch, openSettings, openReusableContextMenu,
|
||||
} from '../../../client/action/navigation';
|
||||
import { abbreviateNumber } from '../../../util/common';
|
||||
import { moveSpaceShortcut } from '../../../client/action/accountData';
|
||||
import { abbreviateNumber, getEventCords } from '../../../util/common';
|
||||
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
import NotificationBadge from '../../atoms/badge/NotificationBadge';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import SidebarAvatar from '../../molecules/sidebar-avatar/SidebarAvatar';
|
||||
import SpaceOptions from '../../molecules/space-options/SpaceOptions';
|
||||
|
||||
import HomeIC from '../../../../public/res/ic/outlined/home.svg';
|
||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||
import AddPinIC from '../../../../public/res/ic/outlined/add-pin.svg';
|
||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
import InviteIC from '../../../../public/res/ic/outlined/invite.svg';
|
||||
|
||||
import { useSelectedTab } from '../../hooks/useSelectedTab';
|
||||
import { useSpaceShortcut } from '../../hooks/useSpaceShortcut';
|
||||
|
||||
function useNotificationUpdate() {
|
||||
const { notifications } = initMatrix;
|
||||
const [, forceUpdate] = useState({});
|
||||
useEffect(() => {
|
||||
function onNotificationChanged(roomId, total, prevTotal) {
|
||||
if (total === prevTotal) return;
|
||||
forceUpdate({});
|
||||
}
|
||||
notifications.on(cons.events.notifications.NOTI_CHANGED, onNotificationChanged);
|
||||
return () => {
|
||||
notifications.removeListener(cons.events.notifications.NOTI_CHANGED, onNotificationChanged);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
function ProfileAvatarMenu() {
|
||||
const mx = initMatrix.matrixClient;
|
||||
@@ -49,59 +73,29 @@ function ProfileAvatarMenu() {
|
||||
<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}
|
||||
avatar={(
|
||||
<Avatar
|
||||
text={profile.displayName}
|
||||
bgColor={colorMXID(mx.getUserId())}
|
||||
size="normal"
|
||||
imageSrc={profile.avatarUrl !== null ? mx.mxcUrlToHttp(profile.avatarUrl, 42, 42, 'crop') : null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useTotalInvites() {
|
||||
const { roomList } = initMatrix;
|
||||
const totalInviteCount = () => roomList.inviteRooms.size
|
||||
+ roomList.inviteSpaces.size
|
||||
+ roomList.inviteDirects.size;
|
||||
const [totalInvites, updateTotalInvites] = useState(totalInviteCount());
|
||||
|
||||
useEffect(() => {
|
||||
const onInviteListChange = () => {
|
||||
updateTotalInvites(totalInviteCount());
|
||||
};
|
||||
roomList.on(cons.events.roomList.INVITELIST_UPDATED, onInviteListChange);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.INVITELIST_UPDATED, onInviteListChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [totalInvites];
|
||||
}
|
||||
|
||||
function SideBar() {
|
||||
const { roomList, notifications } = initMatrix;
|
||||
const mx = initMatrix.matrixClient;
|
||||
|
||||
function FeaturedTab() {
|
||||
const { roomList, accountData, notifications } = initMatrix;
|
||||
const [selectedTab] = useSelectedTab();
|
||||
const [spaceShortcut] = useSpaceShortcut();
|
||||
const [totalInvites] = useTotalInvites();
|
||||
const [, forceUpdate] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
function onNotificationChanged(roomId, total, prevTotal) {
|
||||
if (total === prevTotal) return;
|
||||
forceUpdate({});
|
||||
}
|
||||
notifications.on(cons.events.notifications.NOTI_CHANGED, onNotificationChanged);
|
||||
return () => {
|
||||
notifications.removeListener(cons.events.notifications.NOTI_CHANGED, onNotificationChanged);
|
||||
};
|
||||
}, []);
|
||||
useNotificationUpdate();
|
||||
|
||||
function getHomeNoti() {
|
||||
const orphans = roomList.getOrphans();
|
||||
let noti = null;
|
||||
|
||||
orphans.forEach((roomId) => {
|
||||
if (roomList.spaceShortcut.has(roomId)) return;
|
||||
if (accountData.spaceShortcut.has(roomId)) return;
|
||||
if (!notifications.hasNoti(roomId)) return;
|
||||
if (noti === null) noti = { total: 0, highlight: 0 };
|
||||
const childNoti = notifications.getNoti(roomId);
|
||||
@@ -126,58 +120,224 @@ function SideBar() {
|
||||
return noti;
|
||||
}
|
||||
|
||||
// TODO: bellow operations are heavy.
|
||||
// refactor this component into more smaller components.
|
||||
const dmsNoti = getDMsNoti();
|
||||
const homeNoti = getHomeNoti();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarAvatar
|
||||
tooltip="Home"
|
||||
active={selectedTab === cons.tabs.HOME}
|
||||
onClick={() => selectTab(cons.tabs.HOME)}
|
||||
avatar={<Avatar iconSrc={HomeIC} size="normal" />}
|
||||
notificationBadge={homeNoti ? (
|
||||
<NotificationBadge
|
||||
alert={homeNoti?.highlight > 0}
|
||||
content={abbreviateNumber(homeNoti.total) || null}
|
||||
/>
|
||||
) : null}
|
||||
/>
|
||||
<SidebarAvatar
|
||||
tooltip="People"
|
||||
active={selectedTab === cons.tabs.DIRECTS}
|
||||
onClick={() => selectTab(cons.tabs.DIRECTS)}
|
||||
avatar={<Avatar iconSrc={UserIC} size="normal" />}
|
||||
notificationBadge={dmsNoti ? (
|
||||
<NotificationBadge
|
||||
alert={dmsNoti?.highlight > 0}
|
||||
content={abbreviateNumber(dmsNoti.total) || null}
|
||||
/>
|
||||
) : null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggableSpaceShortcut({
|
||||
isActive, spaceId, index, moveShortcut, onDrop,
|
||||
}) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { notifications } = initMatrix;
|
||||
const room = mx.getRoom(spaceId);
|
||||
const shortcutRef = useRef(null);
|
||||
const avatarRef = useRef(null);
|
||||
|
||||
const openSpaceOptions = (e, sId) => {
|
||||
e.preventDefault();
|
||||
openReusableContextMenu(
|
||||
'right',
|
||||
getEventCords(e, '.sidebar-avatar'),
|
||||
(closeMenu) => <SpaceOptions roomId={sId} afterOptionSelect={closeMenu} />,
|
||||
);
|
||||
};
|
||||
|
||||
const [, drop] = useDrop({
|
||||
accept: 'SPACE_SHORTCUT',
|
||||
collect(monitor) {
|
||||
return {
|
||||
handlerId: monitor.getHandlerId(),
|
||||
};
|
||||
},
|
||||
drop(item) {
|
||||
onDrop(item.index, item.spaceId);
|
||||
},
|
||||
hover(item, monitor) {
|
||||
if (!shortcutRef.current) return;
|
||||
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
if (dragIndex === hoverIndex) return;
|
||||
|
||||
const hoverBoundingRect = shortcutRef.current?.getBoundingClientRect();
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
||||
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
|
||||
return;
|
||||
}
|
||||
moveShortcut(dragIndex, hoverIndex);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: 'SPACE_SHORTCUT',
|
||||
item: () => ({ spaceId, index }),
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
drag(avatarRef);
|
||||
drop(shortcutRef);
|
||||
|
||||
if (shortcutRef.current) {
|
||||
if (isDragging) shortcutRef.current.style.opacity = 0;
|
||||
else shortcutRef.current.style.opacity = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarAvatar
|
||||
ref={shortcutRef}
|
||||
active={isActive}
|
||||
tooltip={room.name}
|
||||
onClick={() => selectTab(spaceId)}
|
||||
onContextMenu={(e) => openSpaceOptions(e, spaceId)}
|
||||
avatar={(
|
||||
<Avatar
|
||||
ref={avatarRef}
|
||||
text={room.name}
|
||||
bgColor={colorMXID(room.roomId)}
|
||||
size="normal"
|
||||
imageSrc={room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop') || null}
|
||||
/>
|
||||
)}
|
||||
notificationBadge={notifications.hasNoti(spaceId) ? (
|
||||
<NotificationBadge
|
||||
alert={notifications.getHighlightNoti(spaceId) > 0}
|
||||
content={abbreviateNumber(notifications.getTotalNoti(spaceId)) || null}
|
||||
/>
|
||||
) : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
DraggableSpaceShortcut.propTypes = {
|
||||
spaceId: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
moveShortcut: PropTypes.func.isRequired,
|
||||
onDrop: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function SpaceShortcut() {
|
||||
const { accountData } = initMatrix;
|
||||
const [selectedTab] = useSelectedTab();
|
||||
useNotificationUpdate();
|
||||
const [spaceShortcut, setSpaceShortcut] = useState([...accountData.spaceShortcut]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleShortcut = () => setSpaceShortcut([...accountData.spaceShortcut]);
|
||||
accountData.on(cons.events.accountData.SPACE_SHORTCUT_UPDATED, handleShortcut);
|
||||
return () => {
|
||||
accountData.removeListener(cons.events.accountData.SPACE_SHORTCUT_UPDATED, handleShortcut);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const moveShortcut = (dragIndex, hoverIndex) => {
|
||||
const dragSpaceId = spaceShortcut[dragIndex];
|
||||
const newShortcuts = [...spaceShortcut];
|
||||
newShortcuts.splice(dragIndex, 1);
|
||||
newShortcuts.splice(hoverIndex, 0, dragSpaceId);
|
||||
setSpaceShortcut(newShortcuts);
|
||||
};
|
||||
|
||||
const handleDrop = (dragIndex, dragSpaceId) => {
|
||||
if ([...accountData.spaceShortcut][dragIndex] === dragSpaceId) return;
|
||||
moveSpaceShortcut(dragSpaceId, dragIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
{
|
||||
spaceShortcut.map((shortcut, index) => (
|
||||
<DraggableSpaceShortcut
|
||||
key={shortcut}
|
||||
index={index}
|
||||
spaceId={shortcut}
|
||||
isActive={selectedTab === shortcut}
|
||||
moveShortcut={moveShortcut}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function useTotalInvites() {
|
||||
const { roomList } = initMatrix;
|
||||
const totalInviteCount = () => roomList.inviteRooms.size
|
||||
+ roomList.inviteSpaces.size
|
||||
+ roomList.inviteDirects.size;
|
||||
const [totalInvites, updateTotalInvites] = useState(totalInviteCount());
|
||||
|
||||
useEffect(() => {
|
||||
const onInviteListChange = () => {
|
||||
updateTotalInvites(totalInviteCount());
|
||||
};
|
||||
roomList.on(cons.events.roomList.INVITELIST_UPDATED, onInviteListChange);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.INVITELIST_UPDATED, onInviteListChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return [totalInvites];
|
||||
}
|
||||
|
||||
function SideBar() {
|
||||
const [totalInvites] = useTotalInvites();
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sidebar__scrollable">
|
||||
<ScrollView invisible>
|
||||
<div className="scrollable-content">
|
||||
<div className="featured-container">
|
||||
<SidebarAvatar
|
||||
active={selectedTab === cons.tabs.HOME}
|
||||
onClick={() => selectTab(cons.tabs.HOME)}
|
||||
tooltip="Home"
|
||||
iconSrc={HomeIC}
|
||||
isUnread={homeNoti !== null}
|
||||
notificationCount={homeNoti !== null ? abbreviateNumber(homeNoti.total) : 0}
|
||||
isAlert={homeNoti?.highlight > 0}
|
||||
/>
|
||||
<SidebarAvatar
|
||||
active={selectedTab === cons.tabs.DIRECTS}
|
||||
onClick={() => selectTab(cons.tabs.DIRECTS)}
|
||||
tooltip="People"
|
||||
iconSrc={UserIC}
|
||||
isUnread={dmsNoti !== null}
|
||||
notificationCount={dmsNoti !== null ? abbreviateNumber(dmsNoti.total) : 0}
|
||||
isAlert={dmsNoti?.highlight > 0}
|
||||
/>
|
||||
<FeaturedTab />
|
||||
</div>
|
||||
<div className="sidebar-divider" />
|
||||
<div className="space-container">
|
||||
{
|
||||
spaceShortcut.map((shortcut) => {
|
||||
const sRoomId = shortcut;
|
||||
const room = mx.getRoom(sRoomId);
|
||||
return (
|
||||
<SidebarAvatar
|
||||
active={selectedTab === sRoomId}
|
||||
key={sRoomId}
|
||||
tooltip={room.name}
|
||||
bgColor={colorMXID(room.roomId)}
|
||||
imageSrc={room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop') || null}
|
||||
text={room.name}
|
||||
isUnread={notifications.hasNoti(sRoomId)}
|
||||
notificationCount={abbreviateNumber(notifications.getTotalNoti(sRoomId))}
|
||||
isAlert={notifications.getHighlightNoti(sRoomId) !== 0}
|
||||
onClick={() => selectTab(shortcut)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
<SpaceShortcut />
|
||||
<SidebarAvatar
|
||||
tooltip="Pin spaces"
|
||||
onClick={() => openShortcutSpaces()}
|
||||
avatar={<Avatar iconSrc={AddPinIC} size="normal" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
@@ -186,18 +346,16 @@ function SideBar() {
|
||||
<div className="sidebar-divider" />
|
||||
<div className="sticky-container">
|
||||
<SidebarAvatar
|
||||
onClick={() => openSearch()}
|
||||
tooltip="Search"
|
||||
iconSrc={SearchIC}
|
||||
onClick={() => openSearch()}
|
||||
avatar={<Avatar iconSrc={SearchIC} size="normal" />}
|
||||
/>
|
||||
{ totalInvites !== 0 && (
|
||||
<SidebarAvatar
|
||||
isUnread
|
||||
notificationCount={totalInvites}
|
||||
isAlert
|
||||
onClick={() => openInviteList()}
|
||||
tooltip="Invites"
|
||||
iconSrc={InviteIC}
|
||||
onClick={() => openInviteList()}
|
||||
avatar={<Avatar iconSrc={InviteIC} size="normal" />}
|
||||
notificationBadge={<NotificationBadge alert content={totalInvites} />}
|
||||
/>
|
||||
)}
|
||||
<ProfileAvatarMenu />
|
||||
|
||||
@@ -18,4 +18,13 @@ function AtoZ(aId, bId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
export { AtoZ };
|
||||
const RoomToDM = (aId, bId) => {
|
||||
const { directs } = initMatrix.roomList;
|
||||
const aIsDm = directs.has(aId);
|
||||
const bIsDm = directs.has(bId);
|
||||
if (aIsDm && !bIsDm) return 1;
|
||||
if (!aIsDm && bIsDm) return -1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
export { AtoZ, RoomToDM };
|
||||
|
||||
@@ -10,7 +10,9 @@ import navigation from '../../../client/state/navigation';
|
||||
import { selectRoom, openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
|
||||
import { getUsername, getUsernameOfRoomMember, getPowerLabel } from '../../../util/matrixUtil';
|
||||
import {
|
||||
getUsername, getUsernameOfRoomMember, getPowerLabel, hasDMWith
|
||||
} from '../../../util/matrixUtil';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
||||
@@ -187,27 +189,18 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
||||
}, [userId]);
|
||||
|
||||
const openDM = async () => {
|
||||
const directIds = [...initMatrix.roomList.directs];
|
||||
|
||||
// Check and open if user already have a DM with userId.
|
||||
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)) {
|
||||
selectRoom(directIds[i]);
|
||||
onRequestClose();
|
||||
return;
|
||||
}
|
||||
const dmRoomId = hasDMWith(userId);
|
||||
if (dmRoomId) {
|
||||
selectRoom(dmRoomId);
|
||||
onRequestClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new DM
|
||||
try {
|
||||
setIsCreatingDM(true);
|
||||
await roomActions.create({
|
||||
isEncrypted: true,
|
||||
isDirect: true,
|
||||
invite: [userId],
|
||||
});
|
||||
await roomActions.createDM(userId);
|
||||
} catch {
|
||||
if (isMountedRef.current === false) return;
|
||||
setIsCreatingDM(false);
|
||||
@@ -371,10 +364,16 @@ function ProfileViewer() {
|
||||
|
||||
const handleChangePowerLevel = (newPowerLevel) => {
|
||||
if (newPowerLevel === powerLevel) return;
|
||||
if (newPowerLevel === myPowerLevel
|
||||
? confirm('You will not be able to undo this change as you are promoting the user to have the same power level as yourself. Are you sure?')
|
||||
: true
|
||||
) {
|
||||
const SHARED_POWER_MSG = 'You will not be able to undo this change as you are promoting the user to have the same power level as yourself. Are you sure?';
|
||||
const DEMOTING_MYSELF_MSG = 'You will not be able to undo this change as you are demoting yourself. Are you sure?';
|
||||
|
||||
const isSharedPower = newPowerLevel === myPowerLevel;
|
||||
const isDemotingMyself = userId === mx.getUserId();
|
||||
if (isSharedPower || isDemotingMyself) {
|
||||
if (confirm(isSharedPower ? SHARED_POWER_MSG : DEMOTING_MYSELF_MSG)) {
|
||||
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
|
||||
}
|
||||
} else {
|
||||
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,13 +2,21 @@ import React from 'react';
|
||||
|
||||
import ReadReceipts from '../read-receipts/ReadReceipts';
|
||||
import ProfileViewer from '../profile-viewer/ProfileViewer';
|
||||
import ShortcutSpaces from '../shortcut-spaces/ShortcutSpaces';
|
||||
import SpaceAddExisting from '../../molecules/space-add-existing/SpaceAddExisting';
|
||||
import Search from '../search/Search';
|
||||
import ViewSource from '../view-source/ViewSource';
|
||||
import CreateRoom from '../create-room/CreateRoom';
|
||||
|
||||
function Dialogs() {
|
||||
return (
|
||||
<>
|
||||
<ReadReceipts />
|
||||
<ViewSource />
|
||||
<ProfileViewer />
|
||||
<ShortcutSpaces />
|
||||
<CreateRoom />
|
||||
<SpaceAddExisting />
|
||||
<Search />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,16 +5,16 @@ import navigation from '../../../client/state/navigation';
|
||||
|
||||
import InviteList from '../invite-list/InviteList';
|
||||
import PublicRooms from '../public-rooms/PublicRooms';
|
||||
import CreateRoom from '../create-room/CreateRoom';
|
||||
import InviteUser from '../invite-user/InviteUser';
|
||||
import Settings from '../settings/Settings';
|
||||
import SpaceSettings from '../space-settings/SpaceSettings';
|
||||
import SpaceManage from '../space-manage/SpaceManage';
|
||||
|
||||
function Windows() {
|
||||
const [isInviteList, changeInviteList] = useState(false);
|
||||
const [publicRooms, changePublicRooms] = useState({
|
||||
isOpen: false, searchTerm: undefined,
|
||||
});
|
||||
const [isCreateRoom, changeCreateRoom] = useState(false);
|
||||
const [inviteUser, changeInviteUser] = useState({
|
||||
isOpen: false, roomId: undefined, term: undefined,
|
||||
});
|
||||
@@ -29,9 +29,6 @@ function Windows() {
|
||||
searchTerm,
|
||||
});
|
||||
}
|
||||
function openCreateRoom() {
|
||||
changeCreateRoom(true);
|
||||
}
|
||||
function openInviteUser(roomId, searchTerm) {
|
||||
changeInviteUser({
|
||||
isOpen: true,
|
||||
@@ -46,13 +43,11 @@ function Windows() {
|
||||
useEffect(() => {
|
||||
navigation.on(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
|
||||
navigation.on(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
|
||||
navigation.on(cons.events.navigation.CREATE_ROOM_OPENED, openCreateRoom);
|
||||
navigation.on(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
||||
navigation.on(cons.events.navigation.SETTINGS_OPENED, openSettings);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
|
||||
navigation.removeListener(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
|
||||
navigation.removeListener(cons.events.navigation.CREATE_ROOM_OPENED, openCreateRoom);
|
||||
navigation.removeListener(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
||||
navigation.removeListener(cons.events.navigation.SETTINGS_OPENED, openSettings);
|
||||
};
|
||||
@@ -69,10 +64,6 @@ function Windows() {
|
||||
searchTerm={publicRooms.searchTerm}
|
||||
onRequestClose={() => changePublicRooms({ isOpen: false, searchTerm: undefined })}
|
||||
/>
|
||||
<CreateRoom
|
||||
isOpen={isCreateRoom}
|
||||
onRequestClose={() => changeCreateRoom(false)}
|
||||
/>
|
||||
<InviteUser
|
||||
isOpen={inviteUser.isOpen}
|
||||
roomId={inviteUser.roomId}
|
||||
@@ -83,6 +74,8 @@ function Windows() {
|
||||
isOpen={settings}
|
||||
onRequestClose={() => changeSettings(false)}
|
||||
/>
|
||||
<SpaceSettings />
|
||||
<SpaceManage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
35
src/app/organisms/room/EventLimit.js
Normal file
@@ -0,0 +1,35 @@
|
||||
class EventLimit {
|
||||
constructor() {
|
||||
this._from = 0;
|
||||
|
||||
this.SMALLEST_EVT_HEIGHT = 32;
|
||||
this.PAGES_COUNT = 4;
|
||||
}
|
||||
|
||||
get maxEvents() {
|
||||
return Math.round(document.body.clientHeight / this.SMALLEST_EVT_HEIGHT) * this.PAGES_COUNT;
|
||||
}
|
||||
|
||||
get from() {
|
||||
return this._from;
|
||||
}
|
||||
|
||||
get length() {
|
||||
return this._from + this.maxEvents;
|
||||
}
|
||||
|
||||
setFrom(from) {
|
||||
this._from = from < 0 ? 0 : from;
|
||||
}
|
||||
|
||||
paginate(backwards, limit, timelineLength) {
|
||||
this._from = backwards ? this._from - limit : this._from + limit;
|
||||
|
||||
if (!backwards && this.length > timelineLength) {
|
||||
this._from = timelineLength - this.maxEvents;
|
||||
}
|
||||
if (this._from < 0) this._from = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default EventLimit;
|
||||
@@ -103,10 +103,10 @@ function PeopleDrawer({ roomId }) {
|
||||
}, [memberList]);
|
||||
|
||||
useEffect(() => {
|
||||
let isGettingMembers = true;
|
||||
let isLoadingMembers = false;
|
||||
let isRoomChanged = false;
|
||||
const updateMemberList = (event) => {
|
||||
if (isGettingMembers) return;
|
||||
if (isLoadingMembers) return;
|
||||
if (event && event?.getRoomId() !== roomId) return;
|
||||
setMemberList(
|
||||
simplyfiMembers(
|
||||
@@ -117,8 +117,9 @@ function PeopleDrawer({ roomId }) {
|
||||
};
|
||||
searchRef.current.value = '';
|
||||
updateMemberList();
|
||||
isLoadingMembers = true;
|
||||
room.loadMembersIfNeeded().then(() => {
|
||||
isGettingMembers = false;
|
||||
isLoadingMembers = false;
|
||||
if (isRoomChanged) return;
|
||||
updateMemberList();
|
||||
});
|
||||
@@ -194,7 +195,7 @@ function PeopleDrawer({ roomId }) {
|
||||
(searchedMembers?.data.length === 0 || memberList.length === 0)
|
||||
&& (
|
||||
<div className="people-drawer__noresult">
|
||||
<Text variant="b2">No result found!</Text>
|
||||
<Text variant="b2">No results found!</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
--search-input-height: 40px;
|
||||
min-height: var(--search-input-height);
|
||||
|
||||
margin: 0 var(--sp-normal);
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
|
||||
position: relative;
|
||||
bottom: var(--sp-normal);
|
||||
@@ -54,7 +54,7 @@
|
||||
flex: 1;
|
||||
}
|
||||
& .input {
|
||||
padding: 0 calc(var(--sp-loose) + var(--sp-normal));
|
||||
padding: 0 44px;
|
||||
height: var(--search-input-height);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,14 @@
|
||||
padding-top: var(--sp-extra-tight);
|
||||
padding-bottom: calc(2 * var(--sp-normal));
|
||||
|
||||
& .people-selector {
|
||||
padding: var(--sp-extra-tight);
|
||||
border-radius: var(--bo-radius);
|
||||
&__container {
|
||||
@include dir.side(margin, var(--sp-extra-tight), 0);
|
||||
}
|
||||
}
|
||||
|
||||
& .segmented-controls {
|
||||
display: flex;
|
||||
margin-bottom: var(--sp-extra-tight);
|
||||
|
||||
@@ -2,13 +2,16 @@ import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomSettings.scss';
|
||||
|
||||
import { blurOnBubbling } from '../../atoms/button/script';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { openInviteUser } from '../../../client/action/navigation';
|
||||
import { openInviteUser, toggleRoomSettings } from '../../../client/action/navigation';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import Tabs from '../../atoms/tabs/Tabs';
|
||||
@@ -21,19 +24,23 @@ import RoomAliases from '../../molecules/room-aliases/RoomAliases';
|
||||
import RoomHistoryVisibility from '../../molecules/room-history-visibility/RoomHistoryVisibility';
|
||||
import RoomEncryption from '../../molecules/room-encryption/RoomEncryption';
|
||||
import RoomPermissions from '../../molecules/room-permissions/RoomPermissions';
|
||||
import RoomMembers from '../../molecules/room-members/RoomMembers';
|
||||
|
||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
|
||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
|
||||
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
|
||||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
||||
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
||||
import ChevronTopIC from '../../../../public/res/ic/outlined/chevron-top.svg';
|
||||
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
|
||||
const tabText = {
|
||||
GENERAL: 'General',
|
||||
SEARCH: 'Search',
|
||||
MEMBERS: 'Members',
|
||||
PERMISSIONS: 'Permissions',
|
||||
SECURITY: 'Security',
|
||||
};
|
||||
@@ -46,6 +53,10 @@ const tabItems = [{
|
||||
iconSrc: SearchIC,
|
||||
text: tabText.SEARCH,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: UserIC,
|
||||
text: tabText.MEMBERS,
|
||||
disabled: false,
|
||||
}, {
|
||||
iconSrc: ShieldUserIC,
|
||||
text: tabText.PERMISSIONS,
|
||||
@@ -64,6 +75,7 @@ function GeneralSettings({ roomId }) {
|
||||
return (
|
||||
<>
|
||||
<div className="room-settings__card">
|
||||
<MenuHeader>Options</MenuHeader>
|
||||
<MenuItem
|
||||
disabled={!canInvite}
|
||||
onClick={() => openInviteUser(roomId)}
|
||||
@@ -124,6 +136,7 @@ SecuritySettings.propTypes = {
|
||||
function RoomSettings({ roomId }) {
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
const [selectedTab, setSelectedTab] = useState(tabItems[0]);
|
||||
const room = initMatrix.matrixClient.getRoom(roomId);
|
||||
|
||||
const handleTabChange = (tabItem) => {
|
||||
setSelectedTab(tabItem);
|
||||
@@ -153,9 +166,20 @@ function RoomSettings({ roomId }) {
|
||||
<ScrollView autoHide>
|
||||
<div className="room-settings__content">
|
||||
<Header>
|
||||
<TitleWrapper>
|
||||
<Text variant="s1" weight="medium" primary>Room settings</Text>
|
||||
</TitleWrapper>
|
||||
<button
|
||||
className="room-settings__header-btn"
|
||||
onClick={() => toggleRoomSettings()}
|
||||
type="button"
|
||||
onMouseUp={(e) => blurOnBubbling(e, '.room-settings__header-btn')}
|
||||
>
|
||||
<TitleWrapper>
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
{`${room.name}`}
|
||||
<span style={{ color: 'var(--tc-surface-low)' }}> — room settings</span>
|
||||
</Text>
|
||||
</TitleWrapper>
|
||||
<RawIcon size="small" src={ChevronTopIC} />
|
||||
</button>
|
||||
</Header>
|
||||
<RoomProfile roomId={roomId} />
|
||||
<Tabs
|
||||
@@ -166,6 +190,7 @@ function RoomSettings({ roomId }) {
|
||||
<div className="room-settings__cards-wrapper">
|
||||
{selectedTab.text === tabText.GENERAL && <GeneralSettings roomId={roomId} />}
|
||||
{selectedTab.text === tabText.SEARCH && <RoomSearch roomId={roomId} />}
|
||||
{selectedTab.text === tabText.MEMBERS && <RoomMembers roomId={roomId} />}
|
||||
{selectedTab.text === tabText.PERMISSIONS && <RoomPermissions roomId={roomId} />}
|
||||
{selectedTab.text === tabText.SECURITY && <SecuritySettings roomId={roomId} />}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@use '../../partials/dir';
|
||||
@use '../../partials/flex';
|
||||
|
||||
.room-settings {
|
||||
height: 100%;
|
||||
@@ -6,6 +7,32 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
& .header {
|
||||
padding: 0 var(--sp-extra-tight);
|
||||
}
|
||||
|
||||
&__header-btn {
|
||||
min-width: 0;
|
||||
@extend .cp-fx__row--s-c;
|
||||
@include dir.side(margin, 0, auto);
|
||||
padding: var(--sp-ultra-tight) var(--sp-extra-tight);
|
||||
border-radius: calc(var(--bo-radius) / 2);
|
||||
cursor: pointer;
|
||||
|
||||
@media (hover:hover) {
|
||||
&:hover {
|
||||
background-color: var(--bg-surface-hover);
|
||||
box-shadow: var(--bs-surface-outline);
|
||||
}
|
||||
}
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: var(--bg-surface-active);
|
||||
box-shadow: var(--bs-surface-outline);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-bottom: calc(2 * var(--sp-extra-loose));
|
||||
|
||||
@@ -48,6 +75,7 @@
|
||||
|
||||
.room-settings .room-permissions__card,
|
||||
.room-settings .room-search__form,
|
||||
.room-settings .room-search__result-item {
|
||||
.room-settings .room-search__result-item ,
|
||||
.room-settings .room-members {
|
||||
@extend .room-settings__card;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import './RoomViewCmdBar.scss';
|
||||
import parse from 'html-react-parser';
|
||||
import twemoji from 'twemoji';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { toggleMarkdown } from '../../../client/action/settings';
|
||||
import * as roomActions from '../../../client/action/room';
|
||||
@@ -124,9 +126,7 @@ function renderSuggestions({ prefix, option, suggestions }, fireCmd) {
|
||||
result: emoji,
|
||||
})}
|
||||
>
|
||||
{
|
||||
renderEmoji(emoji)
|
||||
}
|
||||
<Text variant="b1">{renderEmoji(emoji)}</Text>
|
||||
<Text variant="b2">{`:${emoji.shortcode}:`}</Text>
|
||||
</CmdItem>
|
||||
));
|
||||
@@ -143,7 +143,7 @@ function renderSuggestions({ prefix, option, suggestions }, fireCmd) {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text variant="b2">{member.name}</Text>
|
||||
<Text variant="b2">{twemojify(member.name)}</Text>
|
||||
</CmdItem>
|
||||
));
|
||||
}
|
||||
|
||||
@@ -36,20 +36,14 @@
|
||||
|
||||
.cmd-item {
|
||||
--cmd-item-bar: inset 0 -2px 0 0 var(--bg-caution);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||
padding: 0 var(--sp-extra-tight);
|
||||
height: 100%;
|
||||
border-radius: var(--bo-radius) var(--bo-radius) 0 0;
|
||||
cursor: pointer;
|
||||
|
||||
& .emoji {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@include dir.side(margin, 0, var(--sp-ultra-tight));
|
||||
}
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-caution-hover);
|
||||
|
||||
@@ -7,16 +7,13 @@ import React, {
|
||||
import PropTypes from 'prop-types';
|
||||
import './RoomViewContent.scss';
|
||||
|
||||
import EventEmitter from 'events';
|
||||
import dateFormat from 'dateformat';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { openProfileViewer } from '../../../client/action/navigation';
|
||||
import {
|
||||
diffMinutes, isInSameDay, Throttle, getScrollInfo,
|
||||
} from '../../../util/common';
|
||||
import { diffMinutes, isInSameDay, Throttle } from '../../../util/common';
|
||||
|
||||
import Divider from '../../atoms/divider/Divider';
|
||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
@@ -27,17 +24,15 @@ import TimelineChange from '../../molecules/message/TimelineChange';
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
import { parseTimelineChange } from './common';
|
||||
import TimelineScroll from './TimelineScroll';
|
||||
import EventLimit from './EventLimit';
|
||||
|
||||
const DEFAULT_MAX_EVENTS = 50;
|
||||
const PAG_LIMIT = 30;
|
||||
const MAX_MSG_DIFF_MINUTES = 5;
|
||||
const PLACEHOLDER_COUNT = 2;
|
||||
const PLACEHOLDERS_HEIGHT = 96 * PLACEHOLDER_COUNT;
|
||||
const SCROLL_TRIGGER_POS = PLACEHOLDERS_HEIGHT * 4;
|
||||
|
||||
const SMALLEST_MSG_HEIGHT = 32;
|
||||
const PAGES_COUNT = 4;
|
||||
|
||||
function loadingMsgPlaceholders(key, count = 2) {
|
||||
const pl = [];
|
||||
const genPlaceholders = () => {
|
||||
@@ -124,178 +119,7 @@ function renderEvent(roomTimeline, mEvent, prevMEvent, isFocus = false) {
|
||||
);
|
||||
}
|
||||
|
||||
class TimelineScroll extends EventEmitter {
|
||||
constructor(target) {
|
||||
super();
|
||||
if (target === null) {
|
||||
throw new Error('Can not initialize TimelineScroll, target HTMLElement in null');
|
||||
}
|
||||
this.scroll = target;
|
||||
|
||||
this.backwards = false;
|
||||
this.inTopHalf = false;
|
||||
this.maxEvents = DEFAULT_MAX_EVENTS;
|
||||
|
||||
this.isScrollable = false;
|
||||
this.top = 0;
|
||||
this.bottom = 0;
|
||||
this.height = 0;
|
||||
this.viewHeight = 0;
|
||||
|
||||
this.topMsg = null;
|
||||
this.bottomMsg = null;
|
||||
this.diff = 0;
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
const scrollInfo = getScrollInfo(this.scroll);
|
||||
const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
|
||||
|
||||
this._scrollTo(scrollInfo, maxScrollTop);
|
||||
}
|
||||
|
||||
// restore scroll using previous calc by this._updateTopBottomMsg() and this._calcDiff.
|
||||
tryRestoringScroll() {
|
||||
const scrollInfo = getScrollInfo(this.scroll);
|
||||
|
||||
let scrollTop = 0;
|
||||
const ot = this.inTopHalf ? this.topMsg?.offsetTop : this.bottomMsg?.offsetTop;
|
||||
if (!ot) scrollTop = Math.round(this.height - this.viewHeight);
|
||||
else scrollTop = ot - this.diff;
|
||||
|
||||
this._scrollTo(scrollInfo, scrollTop);
|
||||
}
|
||||
|
||||
scrollToIndex(index, offset = 0) {
|
||||
const scrollInfo = getScrollInfo(this.scroll);
|
||||
const msgs = this.scroll.lastElementChild.lastElementChild.children;
|
||||
const offsetTop = msgs[index]?.offsetTop;
|
||||
|
||||
if (offsetTop === undefined) return;
|
||||
// if msg is already in visible are we don't need to scroll to that
|
||||
if (offsetTop > scrollInfo.top && offsetTop < (scrollInfo.top + scrollInfo.viewHeight)) return;
|
||||
const to = offsetTop - offset;
|
||||
|
||||
this._scrollTo(scrollInfo, to);
|
||||
}
|
||||
|
||||
_scrollTo(scrollInfo, scrollTop) {
|
||||
this.scroll.scrollTop = scrollTop;
|
||||
|
||||
// browser emit 'onscroll' event only if the 'element.scrollTop' value changes.
|
||||
// so here we flag that the upcoming 'onscroll' event is
|
||||
// emitted as side effect of assigning 'this.scroll.scrollTop' above
|
||||
// only if it's changes.
|
||||
// by doing so we prevent this._updateCalc() from calc again.
|
||||
if (scrollTop !== this.top) {
|
||||
this.scrolledByCode = true;
|
||||
}
|
||||
const sInfo = { ...scrollInfo };
|
||||
|
||||
const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
|
||||
|
||||
sInfo.top = (scrollTop > maxScrollTop) ? maxScrollTop : scrollTop;
|
||||
this._updateCalc(sInfo);
|
||||
}
|
||||
|
||||
// we maintain reference of top and bottom messages
|
||||
// to restore the scroll position when
|
||||
// messages gets removed from either end and added to other.
|
||||
_updateTopBottomMsg() {
|
||||
const msgs = this.scroll.lastElementChild.lastElementChild.children;
|
||||
const lMsgIndex = msgs.length - 1;
|
||||
|
||||
this.topMsg = msgs[0]?.className === 'ph-msg'
|
||||
? msgs[PLACEHOLDER_COUNT]
|
||||
: msgs[0];
|
||||
this.bottomMsg = msgs[lMsgIndex]?.className === 'ph-msg'
|
||||
? msgs[lMsgIndex - PLACEHOLDER_COUNT]
|
||||
: msgs[lMsgIndex];
|
||||
}
|
||||
|
||||
// we calculate the difference between first/last message and current scrollTop.
|
||||
// if we are going above we calc diff between first and scrollTop
|
||||
// else otherwise.
|
||||
// NOTE: This will help to restore the scroll when msgs get's removed
|
||||
// from one end and added to other end
|
||||
_calcDiff(scrollInfo) {
|
||||
if (!this.topMsg || !this.bottomMsg) return 0;
|
||||
if (this.inTopHalf) {
|
||||
return this.topMsg.offsetTop - scrollInfo.top;
|
||||
}
|
||||
return this.bottomMsg.offsetTop - scrollInfo.top;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
_calcMaxEvents(scrollInfo) {
|
||||
return Math.round(scrollInfo.viewHeight / SMALLEST_MSG_HEIGHT) * PAGES_COUNT;
|
||||
}
|
||||
|
||||
_updateCalc(scrollInfo) {
|
||||
const halfViewHeight = Math.round(scrollInfo.viewHeight / 2);
|
||||
const scrollMiddle = scrollInfo.top + halfViewHeight;
|
||||
const lastMiddle = this.top + halfViewHeight;
|
||||
|
||||
this.backwards = scrollMiddle < lastMiddle;
|
||||
this.inTopHalf = scrollMiddle < scrollInfo.height / 2;
|
||||
|
||||
this.isScrollable = scrollInfo.isScrollable;
|
||||
this.top = scrollInfo.top;
|
||||
this.bottom = scrollInfo.height - (scrollInfo.top + scrollInfo.viewHeight);
|
||||
this.height = scrollInfo.height;
|
||||
|
||||
// only calculate maxEvents if viewHeight change
|
||||
if (this.viewHeight !== scrollInfo.viewHeight) {
|
||||
this.maxEvents = this._calcMaxEvents(scrollInfo);
|
||||
this.viewHeight = scrollInfo.viewHeight;
|
||||
}
|
||||
|
||||
this._updateTopBottomMsg();
|
||||
this.diff = this._calcDiff(scrollInfo);
|
||||
}
|
||||
|
||||
calcScroll() {
|
||||
if (this.scrolledByCode) {
|
||||
this.scrolledByCode = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollInfo = getScrollInfo(this.scroll);
|
||||
this._updateCalc(scrollInfo);
|
||||
|
||||
this.emit('scroll', this.backwards);
|
||||
}
|
||||
}
|
||||
|
||||
let timelineScroll = null;
|
||||
let jumpToItemIndex = -1;
|
||||
const throttle = new Throttle();
|
||||
const limit = {
|
||||
from: 0,
|
||||
getMaxEvents() {
|
||||
return timelineScroll?.maxEvents ?? DEFAULT_MAX_EVENTS;
|
||||
},
|
||||
getEndIndex() {
|
||||
return this.from + this.getMaxEvents();
|
||||
},
|
||||
calcNextFrom(backwards, tLength) {
|
||||
let newFrom = backwards ? this.from - PAG_LIMIT : this.from + PAG_LIMIT;
|
||||
if (!backwards && newFrom + this.getMaxEvents() > tLength) {
|
||||
newFrom = tLength - this.getMaxEvents();
|
||||
}
|
||||
if (newFrom < 0) newFrom = 0;
|
||||
return newFrom;
|
||||
},
|
||||
setFrom(from) {
|
||||
if (from < 0) {
|
||||
this.from = 0;
|
||||
return;
|
||||
}
|
||||
this.from = from;
|
||||
},
|
||||
};
|
||||
|
||||
function useTimeline(roomTimeline, eventId, readEventStore) {
|
||||
function useTimeline(roomTimeline, eventId, readUptoEvtStore, eventLimitRef) {
|
||||
const [timelineInfo, setTimelineInfo] = useState(null);
|
||||
|
||||
const setEventTimeline = async (eId) => {
|
||||
@@ -309,6 +133,7 @@ function useTimeline(roomTimeline, eventId, readEventStore) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const limit = eventLimitRef.current;
|
||||
const initTimeline = (eId) => {
|
||||
// NOTICE: eId can be id of readUpto, reply or specific event.
|
||||
// readUpTo: when user click jump to unread message button.
|
||||
@@ -320,20 +145,20 @@ function useTimeline(roomTimeline, eventId, readEventStore) {
|
||||
|
||||
if (isSpecificEvent) {
|
||||
focusEventIndex = roomTimeline.getEventIndex(eId);
|
||||
} else if (!readEventStore.getItem()) {
|
||||
} else if (!readUptoEvtStore.getItem()) {
|
||||
// either opening live timeline or jump to unread.
|
||||
focusEventIndex = roomTimeline.getUnreadEventIndex(readUpToId);
|
||||
if (roomTimeline.hasEventInTimeline(readUpToId)) {
|
||||
readEventStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
||||
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
||||
}
|
||||
} else {
|
||||
focusEventIndex = roomTimeline.getUnreadEventIndex(readEventStore.getItem().getId());
|
||||
focusEventIndex = roomTimeline.getUnreadEventIndex(readUptoEvtStore.getItem().getId());
|
||||
}
|
||||
|
||||
if (focusEventIndex > -1) {
|
||||
limit.setFrom(focusEventIndex - Math.round(limit.getMaxEvents() / 2));
|
||||
limit.setFrom(focusEventIndex - Math.round(limit.maxEvents / 2));
|
||||
} else {
|
||||
limit.setFrom(roomTimeline.timeline.length - limit.getMaxEvents());
|
||||
limit.setFrom(roomTimeline.timeline.length - limit.maxEvents);
|
||||
}
|
||||
setTimelineInfo({ focusEventId: isSpecificEvent ? eId : null });
|
||||
};
|
||||
@@ -350,36 +175,45 @@ function useTimeline(roomTimeline, eventId, readEventStore) {
|
||||
return timelineInfo;
|
||||
}
|
||||
|
||||
function usePaginate(roomTimeline, readEventStore, forceUpdateLimit) {
|
||||
function usePaginate(
|
||||
roomTimeline,
|
||||
readUptoEvtStore,
|
||||
forceUpdateLimit,
|
||||
timelineScrollRef,
|
||||
eventLimitRef,
|
||||
) {
|
||||
const [info, setInfo] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnPagination = (backwards, loaded) => {
|
||||
const handlePaginatedFromServer = (backwards, loaded) => {
|
||||
const limit = eventLimitRef.current;
|
||||
if (loaded === 0) return;
|
||||
if (!readEventStore.getItem()) {
|
||||
if (!readUptoEvtStore.getItem()) {
|
||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
||||
readEventStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
||||
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
||||
}
|
||||
limit.setFrom(limit.calcNextFrom(backwards, roomTimeline.timeline.length));
|
||||
limit.paginate(backwards, PAG_LIMIT, roomTimeline.timeline.length);
|
||||
setTimeout(() => setInfo({
|
||||
backwards,
|
||||
loaded,
|
||||
}));
|
||||
};
|
||||
roomTimeline.on(cons.events.roomTimeline.PAGINATED, handleOnPagination);
|
||||
roomTimeline.on(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
|
||||
return () => {
|
||||
roomTimeline.on(cons.events.roomTimeline.PAGINATED, handleOnPagination);
|
||||
roomTimeline.on(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
|
||||
};
|
||||
}, [roomTimeline]);
|
||||
|
||||
const autoPaginate = useCallback(async () => {
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
const limit = eventLimitRef.current;
|
||||
if (roomTimeline.isOngoingPagination) return;
|
||||
const tLength = roomTimeline.timeline.length;
|
||||
|
||||
if (timelineScroll.bottom < SCROLL_TRIGGER_POS) {
|
||||
if (limit.getEndIndex() < tLength) {
|
||||
if (limit.length < tLength) {
|
||||
// paginate from memory
|
||||
limit.setFrom(limit.calcNextFrom(false, tLength));
|
||||
limit.paginate(false, PAG_LIMIT, tLength);
|
||||
forceUpdateLimit();
|
||||
} else if (roomTimeline.canPaginateForward()) {
|
||||
// paginate from server.
|
||||
@@ -390,7 +224,7 @@ function usePaginate(roomTimeline, readEventStore, forceUpdateLimit) {
|
||||
if (timelineScroll.top < SCROLL_TRIGGER_POS) {
|
||||
if (limit.from > 0) {
|
||||
// paginate from memory
|
||||
limit.setFrom(limit.calcNextFrom(true, tLength));
|
||||
limit.paginate(true, PAG_LIMIT, tLength);
|
||||
forceUpdateLimit();
|
||||
} else if (roomTimeline.canPaginateBackward()) {
|
||||
// paginate from server.
|
||||
@@ -402,16 +236,25 @@ function usePaginate(roomTimeline, readEventStore, forceUpdateLimit) {
|
||||
return [info, autoPaginate];
|
||||
}
|
||||
|
||||
function useHandleScroll(roomTimeline, autoPaginate, readEventStore, forceUpdateLimit) {
|
||||
function useHandleScroll(
|
||||
roomTimeline,
|
||||
autoPaginate,
|
||||
readUptoEvtStore,
|
||||
forceUpdateLimit,
|
||||
timelineScrollRef,
|
||||
eventLimitRef,
|
||||
) {
|
||||
const handleScroll = useCallback(() => {
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
const limit = eventLimitRef.current;
|
||||
requestAnimationFrame(() => {
|
||||
// emit event to toggle scrollToBottom button visibility
|
||||
const isAtBottom = (
|
||||
timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()
|
||||
&& limit.getEndIndex() >= roomTimeline.timeline.length
|
||||
&& limit.length >= roomTimeline.timeline.length
|
||||
);
|
||||
roomTimeline.emit(cons.events.roomTimeline.AT_BOTTOM, isAtBottom);
|
||||
if (isAtBottom && readEventStore.getItem()) {
|
||||
if (isAtBottom && readUptoEvtStore.getItem()) {
|
||||
requestAnimationFrame(() => roomTimeline.markAllAsRead());
|
||||
}
|
||||
});
|
||||
@@ -419,11 +262,13 @@ function useHandleScroll(roomTimeline, autoPaginate, readEventStore, forceUpdate
|
||||
}, [roomTimeline]);
|
||||
|
||||
const handleScrollToLive = useCallback(() => {
|
||||
if (readEventStore.getItem()) {
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
const limit = eventLimitRef.current;
|
||||
if (readUptoEvtStore.getItem()) {
|
||||
requestAnimationFrame(() => roomTimeline.markAllAsRead());
|
||||
}
|
||||
if (roomTimeline.isServingLiveTimeline()) {
|
||||
limit.setFrom(roomTimeline.timeline.length - limit.getMaxEvents());
|
||||
limit.setFrom(roomTimeline.timeline.length - limit.maxEvents);
|
||||
timelineScroll.scrollToBottom();
|
||||
forceUpdateLimit();
|
||||
return;
|
||||
@@ -434,29 +279,32 @@ function useHandleScroll(roomTimeline, autoPaginate, readEventStore, forceUpdate
|
||||
return [handleScroll, handleScrollToLive];
|
||||
}
|
||||
|
||||
function useEventArrive(roomTimeline, readEventStore) {
|
||||
function useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, eventLimitRef) {
|
||||
const myUserId = initMatrix.matrixClient.getUserId();
|
||||
const [newEvent, setEvent] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
const limit = eventLimitRef.current;
|
||||
const sendReadReceipt = (event) => {
|
||||
if (event.isSending()) return;
|
||||
if (myUserId === event.getSender()) {
|
||||
roomTimeline.markAllAsRead();
|
||||
return;
|
||||
}
|
||||
const readUpToEvent = readEventStore.getItem();
|
||||
const readUpToEvent = readUptoEvtStore.getItem();
|
||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
||||
const isUnread = readUpToEvent?.getId() === readUpToId;
|
||||
|
||||
// if user doesn't have focus on app don't mark messages as read.
|
||||
if (document.visibilityState === 'hidden' || timelineScroll.bottom >= 16) {
|
||||
if (readUpToEvent === readUpToId) return;
|
||||
readEventStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
||||
if (isUnread) return;
|
||||
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
||||
return;
|
||||
}
|
||||
|
||||
// user has not mark room as read
|
||||
const isUnreadMsg = readUpToEvent?.getId() === readUpToId;
|
||||
if (!isUnreadMsg) {
|
||||
if (!isUnread) {
|
||||
roomTimeline.markAllAsRead();
|
||||
}
|
||||
const { timeline } = roomTimeline;
|
||||
@@ -470,11 +318,11 @@ function useEventArrive(roomTimeline, readEventStore) {
|
||||
const tLength = roomTimeline.timeline.length;
|
||||
const isUserViewingLive = (
|
||||
roomTimeline.isServingLiveTimeline()
|
||||
&& limit.getEndIndex() >= tLength - 1
|
||||
&& limit.length >= tLength - 1
|
||||
&& timelineScroll.bottom < SCROLL_TRIGGER_POS
|
||||
);
|
||||
if (isUserViewingLive) {
|
||||
limit.setFrom(tLength - limit.getMaxEvents());
|
||||
limit.setFrom(tLength - limit.maxEvents);
|
||||
sendReadReceipt(event);
|
||||
setEvent(event);
|
||||
return;
|
||||
@@ -486,7 +334,7 @@ function useEventArrive(roomTimeline, readEventStore) {
|
||||
}
|
||||
const isUserDitchedLive = (
|
||||
roomTimeline.isServingLiveTimeline()
|
||||
&& limit.getEndIndex() >= tLength - 1
|
||||
&& limit.length >= tLength - 1
|
||||
);
|
||||
if (isUserDitchedLive) {
|
||||
// This stateUpdate will help to put the
|
||||
@@ -506,6 +354,7 @@ function useEventArrive(roomTimeline, readEventStore) {
|
||||
}, [roomTimeline]);
|
||||
|
||||
useEffect(() => {
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
if (!roomTimeline.initialized) return;
|
||||
if (timelineScroll.bottom < 16
|
||||
&& !roomTimeline.canPaginateForward()
|
||||
@@ -517,27 +366,49 @@ function useEventArrive(roomTimeline, readEventStore) {
|
||||
}, [newEvent, roomTimeline]);
|
||||
}
|
||||
|
||||
let jumpToItemIndex = -1;
|
||||
|
||||
function RoomViewContent({ eventId, roomTimeline }) {
|
||||
const [throttle] = useState(new Throttle());
|
||||
|
||||
const timelineSVRef = useRef(null);
|
||||
const readEventStore = useStore(roomTimeline);
|
||||
const timelineInfo = useTimeline(roomTimeline, eventId, readEventStore);
|
||||
const timelineScrollRef = useRef(null);
|
||||
const eventLimitRef = useRef(null);
|
||||
|
||||
const readUptoEvtStore = useStore(roomTimeline);
|
||||
const [onLimitUpdate, forceUpdateLimit] = useForceUpdate();
|
||||
const [paginateInfo, autoPaginate] = usePaginate(roomTimeline, readEventStore, forceUpdateLimit);
|
||||
const [handleScroll, handleScrollToLive] = useHandleScroll(
|
||||
roomTimeline, autoPaginate, readEventStore, forceUpdateLimit,
|
||||
|
||||
const timelineInfo = useTimeline(roomTimeline, eventId, readUptoEvtStore, eventLimitRef);
|
||||
const [paginateInfo, autoPaginate] = usePaginate(
|
||||
roomTimeline,
|
||||
readUptoEvtStore,
|
||||
forceUpdateLimit,
|
||||
timelineScrollRef,
|
||||
eventLimitRef,
|
||||
);
|
||||
useEventArrive(roomTimeline, readEventStore);
|
||||
const [handleScroll, handleScrollToLive] = useHandleScroll(
|
||||
roomTimeline,
|
||||
autoPaginate,
|
||||
readUptoEvtStore,
|
||||
forceUpdateLimit,
|
||||
timelineScrollRef,
|
||||
eventLimitRef,
|
||||
);
|
||||
useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, eventLimitRef);
|
||||
|
||||
const { timeline } = roomTimeline;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!roomTimeline.initialized) {
|
||||
timelineScroll = new TimelineScroll(timelineSVRef.current);
|
||||
timelineScrollRef.current = new TimelineScroll(timelineSVRef.current);
|
||||
eventLimitRef.current = new EventLimit();
|
||||
}
|
||||
});
|
||||
|
||||
// when active timeline changes
|
||||
useEffect(() => {
|
||||
if (!roomTimeline.initialized) return undefined;
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
|
||||
if (timeline.length > 0) {
|
||||
if (jumpToItemIndex === -1) {
|
||||
@@ -547,7 +418,7 @@ function RoomViewContent({ eventId, roomTimeline }) {
|
||||
}
|
||||
if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()) {
|
||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
||||
if (readEventStore.getItem()?.getId() === readUpToId || readUpToId === null) {
|
||||
if (readUptoEvtStore.getItem()?.getId() === readUpToId || readUpToId === null) {
|
||||
requestAnimationFrame(() => roomTimeline.markAllAsRead());
|
||||
}
|
||||
}
|
||||
@@ -555,11 +426,9 @@ function RoomViewContent({ eventId, roomTimeline }) {
|
||||
}
|
||||
autoPaginate();
|
||||
|
||||
timelineScroll.on('scroll', handleScroll);
|
||||
roomTimeline.on(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
|
||||
return () => {
|
||||
if (timelineSVRef.current === null) return;
|
||||
timelineScroll.removeListener('scroll', handleScroll);
|
||||
roomTimeline.removeListener(cons.events.roomTimeline.SCROLL_TO_LIVE, handleScrollToLive);
|
||||
};
|
||||
}, [timelineInfo]);
|
||||
@@ -567,6 +436,7 @@ function RoomViewContent({ eventId, roomTimeline }) {
|
||||
// when paginating from server
|
||||
useEffect(() => {
|
||||
if (!roomTimeline.initialized) return;
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
timelineScroll.tryRestoringScroll();
|
||||
autoPaginate();
|
||||
}, [paginateInfo]);
|
||||
@@ -574,28 +444,35 @@ function RoomViewContent({ eventId, roomTimeline }) {
|
||||
// when paginating locally
|
||||
useEffect(() => {
|
||||
if (!roomTimeline.initialized) return;
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
timelineScroll.tryRestoringScroll();
|
||||
}, [onLimitUpdate]);
|
||||
|
||||
const handleTimelineScroll = (event) => {
|
||||
const { target } = event;
|
||||
if (!target) return;
|
||||
throttle._(() => timelineScroll?.calcScroll(), 400)(target);
|
||||
const timelineScroll = timelineScrollRef.current;
|
||||
if (!event.target) return;
|
||||
|
||||
throttle._(() => {
|
||||
const backwards = timelineScroll?.calcScroll();
|
||||
if (typeof backwards !== 'boolean') return;
|
||||
handleScroll(backwards);
|
||||
}, 200)();
|
||||
};
|
||||
|
||||
const renderTimeline = () => {
|
||||
const tl = [];
|
||||
const limit = eventLimitRef.current;
|
||||
|
||||
let itemCountIndex = 0;
|
||||
jumpToItemIndex = -1;
|
||||
const readEvent = readEventStore.getItem();
|
||||
const readUptoEvent = readUptoEvtStore.getItem();
|
||||
let unreadDivider = false;
|
||||
|
||||
if (roomTimeline.canPaginateBackward() || limit.from > 0) {
|
||||
tl.push(loadingMsgPlaceholders(1, PLACEHOLDER_COUNT));
|
||||
itemCountIndex += PLACEHOLDER_COUNT;
|
||||
}
|
||||
for (let i = limit.from; i < limit.getEndIndex(); i += 1) {
|
||||
for (let i = limit.from; i < limit.length; i += 1) {
|
||||
if (i >= timeline.length) break;
|
||||
const mEvent = timeline[i];
|
||||
const prevMEvent = timeline[i - 1] ?? null;
|
||||
@@ -614,9 +491,9 @@ function RoomViewContent({ eventId, roomTimeline }) {
|
||||
|
||||
let isNewEvent = false;
|
||||
if (!unreadDivider) {
|
||||
unreadDivider = (readEvent
|
||||
&& prevMEvent?.getTs() <= readEvent.getTs()
|
||||
&& readEvent.getTs() < mEvent.getTs());
|
||||
unreadDivider = (readUptoEvent
|
||||
&& prevMEvent?.getTs() <= readUptoEvent.getTs()
|
||||
&& readUptoEvent.getTs() < mEvent.getTs());
|
||||
if (unreadDivider) {
|
||||
isNewEvent = true;
|
||||
tl.push(<Divider key={`new-${mEvent.getId()}`} variant="positive" text="New messages" />);
|
||||
@@ -637,7 +514,7 @@ function RoomViewContent({ eventId, roomTimeline }) {
|
||||
tl.push(renderEvent(roomTimeline, mEvent, isNewEvent ? null : prevMEvent, isFocus));
|
||||
itemCountIndex += 1;
|
||||
}
|
||||
if (roomTimeline.canPaginateForward() || limit.getEndIndex() < timeline.length) {
|
||||
if (roomTimeline.canPaginateForward() || limit.length < timeline.length) {
|
||||
tl.push(loadingMsgPlaceholders(2, PLACEHOLDER_COUNT));
|
||||
}
|
||||
|
||||
|
||||
@@ -58,9 +58,11 @@ function RoomViewInput({
|
||||
|
||||
useEffect(() => {
|
||||
settings.on(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
|
||||
roomsInput.on(cons.events.roomsInput.ATTACHMENT_SET, setAttachment);
|
||||
viewEvent.on('focus_msg_input', requestFocusInput);
|
||||
return () => {
|
||||
settings.removeListener(cons.events.settings.MARKDOWN_TOGGLED, setIsMarkdown);
|
||||
roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_SET, setAttachment);
|
||||
viewEvent.removeListener('focus_msg_input', requestFocusInput);
|
||||
};
|
||||
}, []);
|
||||
@@ -301,12 +303,18 @@ function RoomViewInput({
|
||||
if (file !== null) roomsInput.setAttachment(roomId, file);
|
||||
}
|
||||
|
||||
const canISend = roomTimeline.room.currentState.maySendMessage(mx.getUserId());
|
||||
|
||||
function renderInputs() {
|
||||
if (!canISend) {
|
||||
const canISend = roomTimeline.room.currentState.maySendMessage(mx.getUserId());
|
||||
const tombstoneEvent = roomTimeline.room.currentState.getStateEvents('m.room.tombstone')[0];
|
||||
if (!canISend || tombstoneEvent) {
|
||||
return (
|
||||
<Text className="room-input__alert">You do not have permission to post to this room</Text>
|
||||
<Text className="room-input__alert">
|
||||
{
|
||||
tombstoneEvent
|
||||
? tombstoneEvent.getContent()?.body ?? 'This room has been replaced and is no longer active.'
|
||||
: 'You do not have permission to post to this room'
|
||||
}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
136
src/app/organisms/room/TimelineScroll.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { getScrollInfo } from '../../../util/common';
|
||||
|
||||
class TimelineScroll {
|
||||
constructor(target) {
|
||||
if (target === null) {
|
||||
throw new Error('Can not initialize TimelineScroll, target HTMLElement in null');
|
||||
}
|
||||
this.scroll = target;
|
||||
|
||||
this.backwards = false;
|
||||
this.inTopHalf = false;
|
||||
|
||||
this.isScrollable = false;
|
||||
this.top = 0;
|
||||
this.bottom = 0;
|
||||
this.height = 0;
|
||||
this.viewHeight = 0;
|
||||
|
||||
this.topMsg = null;
|
||||
this.bottomMsg = null;
|
||||
this.diff = 0;
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
const scrollInfo = getScrollInfo(this.scroll);
|
||||
const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
|
||||
|
||||
this._scrollTo(scrollInfo, maxScrollTop);
|
||||
}
|
||||
|
||||
// use previous calc by this._updateTopBottomMsg() & this._calcDiff.
|
||||
tryRestoringScroll() {
|
||||
const scrollInfo = getScrollInfo(this.scroll);
|
||||
|
||||
let scrollTop = 0;
|
||||
const ot = this.inTopHalf ? this.topMsg?.offsetTop : this.bottomMsg?.offsetTop;
|
||||
if (!ot) scrollTop = Math.round(this.height - this.viewHeight);
|
||||
else scrollTop = ot - this.diff;
|
||||
|
||||
this._scrollTo(scrollInfo, scrollTop);
|
||||
}
|
||||
|
||||
scrollToIndex(index, offset = 0) {
|
||||
const scrollInfo = getScrollInfo(this.scroll);
|
||||
const msgs = this.scroll.lastElementChild.lastElementChild.children;
|
||||
const offsetTop = msgs[index]?.offsetTop;
|
||||
|
||||
if (offsetTop === undefined) return;
|
||||
// if msg is already in visible are we don't need to scroll to that
|
||||
if (offsetTop > scrollInfo.top && offsetTop < (scrollInfo.top + scrollInfo.viewHeight)) return;
|
||||
const to = offsetTop - offset;
|
||||
|
||||
this._scrollTo(scrollInfo, to);
|
||||
}
|
||||
|
||||
_scrollTo(scrollInfo, scrollTop) {
|
||||
this.scroll.scrollTop = scrollTop;
|
||||
|
||||
// browser emit 'onscroll' event only if the 'element.scrollTop' value changes.
|
||||
// so here we flag that the upcoming 'onscroll' event is
|
||||
// emitted as side effect of assigning 'this.scroll.scrollTop' above
|
||||
// only if it's changes.
|
||||
// by doing so we prevent this._updateCalc() from calc again.
|
||||
if (scrollTop !== this.top) {
|
||||
this.scrolledByCode = true;
|
||||
}
|
||||
const sInfo = { ...scrollInfo };
|
||||
|
||||
const maxScrollTop = scrollInfo.height - scrollInfo.viewHeight;
|
||||
|
||||
sInfo.top = (scrollTop > maxScrollTop) ? maxScrollTop : scrollTop;
|
||||
this._updateCalc(sInfo);
|
||||
}
|
||||
|
||||
// we maintain reference of top and bottom messages
|
||||
// to restore the scroll position when
|
||||
// messages gets removed from either end and added to other.
|
||||
_updateTopBottomMsg() {
|
||||
const msgs = this.scroll.lastElementChild.lastElementChild.children;
|
||||
const lMsgIndex = msgs.length - 1;
|
||||
|
||||
// TODO: classname 'ph-msg' prevent this class from being used
|
||||
const PLACEHOLDER_COUNT = 2;
|
||||
this.topMsg = msgs[0]?.className === 'ph-msg'
|
||||
? msgs[PLACEHOLDER_COUNT]
|
||||
: msgs[0];
|
||||
this.bottomMsg = msgs[lMsgIndex]?.className === 'ph-msg'
|
||||
? msgs[lMsgIndex - PLACEHOLDER_COUNT]
|
||||
: msgs[lMsgIndex];
|
||||
}
|
||||
|
||||
// we calculate the difference between first/last message and current scrollTop.
|
||||
// if we are going above we calc diff between first and scrollTop
|
||||
// else otherwise.
|
||||
// NOTE: This will help to restore the scroll when msgs get's removed
|
||||
// from one end and added to other end
|
||||
_calcDiff(scrollInfo) {
|
||||
if (!this.topMsg || !this.bottomMsg) return 0;
|
||||
if (this.inTopHalf) {
|
||||
return this.topMsg.offsetTop - scrollInfo.top;
|
||||
}
|
||||
return this.bottomMsg.offsetTop - scrollInfo.top;
|
||||
}
|
||||
|
||||
_updateCalc(scrollInfo) {
|
||||
const halfViewHeight = Math.round(scrollInfo.viewHeight / 2);
|
||||
const scrollMiddle = scrollInfo.top + halfViewHeight;
|
||||
const lastMiddle = this.top + halfViewHeight;
|
||||
|
||||
this.backwards = scrollMiddle < lastMiddle;
|
||||
this.inTopHalf = scrollMiddle < scrollInfo.height / 2;
|
||||
|
||||
this.isScrollable = scrollInfo.isScrollable;
|
||||
this.top = scrollInfo.top;
|
||||
this.bottom = scrollInfo.height - (scrollInfo.top + scrollInfo.viewHeight);
|
||||
this.height = scrollInfo.height;
|
||||
this.viewHeight = scrollInfo.viewHeight;
|
||||
|
||||
this._updateTopBottomMsg();
|
||||
this.diff = this._calcDiff(scrollInfo);
|
||||
}
|
||||
|
||||
calcScroll() {
|
||||
if (this.scrolledByCode) {
|
||||
this.scrolledByCode = false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scrollInfo = getScrollInfo(this.scroll);
|
||||
this._updateCalc(scrollInfo);
|
||||
|
||||
return this.backwards;
|
||||
}
|
||||
}
|
||||
|
||||
export default TimelineScroll;
|
||||
@@ -87,7 +87,7 @@ function getTimelineJSXMessages() {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' set the avatar'}
|
||||
{' set a avatar'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -95,7 +95,7 @@ function getTimelineJSXMessages() {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' changed the avatar'}
|
||||
{' changed their avatar'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -103,7 +103,7 @@ function getTimelineJSXMessages() {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' removed the avatar'}
|
||||
{' removed their avatar'}
|
||||
</>
|
||||
);
|
||||
},
|
||||
@@ -111,7 +111,7 @@ function getTimelineJSXMessages() {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' set the display name to '}
|
||||
{' set display name to '}
|
||||
<b>{twemojify(newName)}</b>
|
||||
</>
|
||||
);
|
||||
@@ -120,7 +120,7 @@ function getTimelineJSXMessages() {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' changed the display name to '}
|
||||
{' changed their display name to '}
|
||||
<b>{twemojify(newName)}</b>
|
||||
</>
|
||||
);
|
||||
@@ -129,7 +129,7 @@ function getTimelineJSXMessages() {
|
||||
return (
|
||||
<>
|
||||
<b>{twemojify(user)}</b>
|
||||
{' removed the display name '}
|
||||
{' removed their display name '}
|
||||
<b>{twemojify(lastName)}</b>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import AsyncSearch from '../../../util/AsyncSearch';
|
||||
import { selectRoom, selectTab } from '../../../client/action/navigation';
|
||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||
@@ -16,12 +17,6 @@ import ScrollView from '../../atoms/scroll/ScrollView';
|
||||
import RoomSelector from '../../molecules/room-selector/RoomSelector';
|
||||
|
||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
import HashIC from '../../../../public/res/ic/outlined/hash.svg';
|
||||
import HashGlobeIC from '../../../../public/res/ic/outlined/hash-globe.svg';
|
||||
import HashLockIC from '../../../../public/res/ic/outlined/hash-lock.svg';
|
||||
import SpaceIC from '../../../../public/res/ic/outlined/space.svg';
|
||||
import SpaceGlobeIC from '../../../../public/res/ic/outlined/space-globe.svg';
|
||||
import SpaceLockIC from '../../../../public/res/ic/outlined/space-lock.svg';
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
function useVisiblityToggle(setResult) {
|
||||
@@ -183,12 +178,7 @@ function Search() {
|
||||
if (item.type === 'direct') {
|
||||
imageSrc = item.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop') || null;
|
||||
} else {
|
||||
const joinRuleToIconSrc = (joinRule) => ({
|
||||
restricted: () => (item.type === 'space' ? SpaceIC : HashIC),
|
||||
invite: () => (item.type === 'space' ? SpaceLockIC : HashLockIC),
|
||||
public: () => (item.type === 'space' ? SpaceGlobeIC : HashGlobeIC),
|
||||
}[joinRule]?.() || null);
|
||||
iconSrc = joinRuleToIconSrc(item.room.getJoinRule());
|
||||
iconSrc = joinRuleToIconSrc(item.room.getJoinRule(), item.type === 'space');
|
||||
}
|
||||
|
||||
const isUnread = notifs.hasNoti(item.roomId);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&__input {
|
||||
padding: var(--sp-normal);
|
||||
|
||||