Compare commits
247 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17fabcaf02 | ||
|
|
44875d0de0 | ||
|
|
214d49f1d9 | ||
|
|
c4e36a1f97 | ||
|
|
5de6a1bea6 | ||
|
|
74a20a0e14 | ||
|
|
820d08017a | ||
|
|
258afec391 | ||
|
|
0cf5aac591 | ||
|
|
59fd34a4b4 | ||
|
|
1da3d252e8 | ||
|
|
3c1cc59d59 | ||
|
|
1692098d5d | ||
|
|
fbab53af22 | ||
|
|
ce1e263d57 | ||
|
|
9f99320fda | ||
|
|
20e1df43d0 | ||
|
|
728c5434bb | ||
|
|
542ac4f4e1 | ||
|
|
d23fc228d7 | ||
|
|
4ff3e47d54 | ||
|
|
96b22eb557 | ||
|
|
9187107751 | ||
|
|
c6812b5b11 | ||
|
|
adb584623e | ||
|
|
120e8de9d1 | ||
|
|
21726b63f8 | ||
|
|
04f910ee03 | ||
|
|
edace32213 | ||
|
|
5e527e434a | ||
|
|
1d90f7588b | ||
|
|
f8b8a35152 | ||
|
|
fa4c95a9b6 | ||
|
|
a478fc4805 | ||
|
|
febb28e9c4 | ||
|
|
c78a39af50 | ||
|
|
21e6049c16 | ||
|
|
6e418337cc | ||
|
|
48f34053ab | ||
|
|
abfe263750 | ||
|
|
9ba003b16d | ||
|
|
48793f3a95 | ||
|
|
d8cf98fd64 | ||
|
|
78e12d5bee | ||
|
|
bdb8bdf76c | ||
|
|
88b79eb3a5 | ||
|
|
b6428197ac | ||
|
|
a46138c8b9 | ||
|
|
1211ca277b | ||
|
|
e6f395c643 | ||
|
|
1979646b4b | ||
|
|
5c0eb20cb4 | ||
|
|
009966a5c7 | ||
|
|
4427b3b291 | ||
|
|
3dda4d6540 | ||
|
|
c9df0be874 | ||
|
|
ca2627d3cf | ||
|
|
47e6527b0e | ||
|
|
7decbb6eef | ||
|
|
68da1d0551 | ||
|
|
a6f21b6606 | ||
|
|
06a4e0c93b | ||
|
|
0ca1df24ed | ||
|
|
1d12a906d4 | ||
|
|
7bd7518963 | ||
|
|
a9c5765be5 | ||
|
|
2292f63fb6 | ||
|
|
db92b9f5ff | ||
|
|
f538639882 | ||
|
|
56bc8c2890 | ||
|
|
1cba4d3fa7 | ||
|
|
4c7820ceac | ||
|
|
118dcd8fa0 | ||
|
|
a142ade923 | ||
|
|
57ab10a87c | ||
|
|
8c1c3cd634 | ||
|
|
2d3634d6bf | ||
|
|
217f29f068 | ||
|
|
58c3eee153 | ||
|
|
d9e1fb620b | ||
|
|
a3f5b92484 | ||
|
|
8b96e0ab98 | ||
|
|
eef2d451b7 | ||
|
|
371e66a6df | ||
|
|
0d12144744 | ||
|
|
ba39724813 | ||
|
|
af6e6bfc67 | ||
|
|
315b5a1048 | ||
|
|
c410d4e9f5 | ||
|
|
299d976622 | ||
|
|
e8587f99c9 | ||
|
|
63ab96b71b | ||
|
|
e998438135 | ||
|
|
5fd7d64d21 | ||
|
|
f05037c7d6 | ||
|
|
d0fd654bf7 | ||
|
|
7165bd91cd | ||
|
|
d3431a5d53 | ||
|
|
fa6c865000 | ||
|
|
fd680a93e0 | ||
|
|
38b604ad41 | ||
|
|
2ca67bb61a | ||
|
|
95b814b751 | ||
|
|
9963f3f988 | ||
|
|
fde7d4a25a | ||
|
|
895b2c4f19 | ||
|
|
427ea9baab | ||
|
|
df718e4498 | ||
|
|
00956f5bba | ||
|
|
e48d216d79 | ||
|
|
489f178c7c | ||
|
|
3bd4eda789 | ||
|
|
fc6c7b8dc6 | ||
|
|
deef1f2c8a | ||
|
|
38bd38a487 | ||
|
|
40de64078a | ||
|
|
780bd5e65a | ||
|
|
2cd74b4ea9 | ||
|
|
0cd3df391e | ||
|
|
854d2b4c27 | ||
|
|
7227fc7c43 | ||
|
|
73dcb44121 | ||
|
|
54fd394ef5 | ||
|
|
fda71166df | ||
|
|
69b6055353 | ||
|
|
1bdd0449e0 | ||
|
|
a6fdf9010b | ||
|
|
941dae0625 | ||
|
|
4a715bfd17 | ||
|
|
0b70c7e490 | ||
|
|
0539836714 | ||
|
|
c08b0e654b | ||
|
|
b3cb48319a | ||
|
|
44553cc375 | ||
|
|
fbe287a702 | ||
|
|
5863dcdf67 | ||
|
|
f77bee25ef | ||
|
|
c11328a064 | ||
|
|
d04de2fba0 | ||
|
|
d2b435618c | ||
|
|
7525bb78e5 | ||
|
|
2075a572fe | ||
|
|
73723ba6ba | ||
|
|
0791820a6c | ||
|
|
931f352873 | ||
|
|
7c7d2e0fa4 | ||
|
|
3372fb6f74 | ||
|
|
bc856269ff | ||
|
|
06bae231ef | ||
|
|
65a0edc3a6 | ||
|
|
b7c322d473 | ||
|
|
0776a04362 | ||
|
|
e51fc5a585 | ||
|
|
3afc068a02 | ||
|
|
5cdad44abf | ||
|
|
43762df998 | ||
|
|
95228c6dd9 | ||
|
|
205fcf8487 | ||
|
|
336e8921ee | ||
|
|
ef149b9fcf | ||
|
|
766b4c13c3 | ||
|
|
f5605258e3 | ||
|
|
2ba4d2f2b7 | ||
|
|
2e050c066e | ||
|
|
3f83514427 | ||
|
|
8c227843c9 | ||
|
|
ba084c0a10 | ||
|
|
3fdd42706d | ||
|
|
b49b51a671 | ||
|
|
e5bb386dd2 | ||
|
|
2867bb3bc3 | ||
|
|
416fd02069 | ||
|
|
581963cfb4 | ||
|
|
3b14543e66 | ||
|
|
5dd2194eba | ||
|
|
5b7880f817 | ||
|
|
bafe1c5781 | ||
|
|
d760be58c3 | ||
|
|
3da9b70632 | ||
|
|
b7c5902f67 | ||
|
|
9a22b25564 | ||
|
|
44c3dec9dc | ||
|
|
87f3afd8fd | ||
|
|
53f1129242 | ||
|
|
74216f75e2 | ||
|
|
ed8eca0c1d | ||
|
|
dc8e6e53c7 | ||
|
|
989ab5a432 | ||
|
|
ec26c03d58 | ||
|
|
3b1b3387e7 | ||
|
|
62c03d1334 | ||
|
|
51e12184d7 | ||
|
|
8c01eb9c00 | ||
|
|
bf264d5add | ||
|
|
8b3bd38bad | ||
|
|
ba0de8800a | ||
|
|
cd5ae4cb7f | ||
|
|
6575542281 | ||
|
|
bf2559da80 | ||
|
|
fedc207de2 | ||
|
|
0d58478a73 | ||
|
|
abf24d1942 | ||
|
|
afe3f2f3f3 | ||
|
|
1d1cb567da | ||
|
|
699bbee544 | ||
|
|
9c54915e73 | ||
|
|
ed4390b99d | ||
|
|
039d9bae68 | ||
|
|
93ab48ac9a | ||
|
|
c95e312acb | ||
|
|
9279bc7060 | ||
|
|
fe61576dcd | ||
|
|
3f6e3074f2 | ||
|
|
53a8e2aa57 | ||
|
|
44ab6f181c | ||
|
|
3a3a830706 | ||
|
|
1a6e3e73c5 | ||
|
|
7d508e5a7d | ||
|
|
370c224d3a | ||
|
|
7bce501069 | ||
|
|
9d15445eba | ||
|
|
16ee13f1f7 | ||
|
|
8d25eb0acd | ||
|
|
9cb13a91cd | ||
|
|
30f8930773 | ||
|
|
05eaa8d3e0 | ||
|
|
a32ffdf6d4 | ||
|
|
0f97de1b09 | ||
|
|
e8d6ccec9a | ||
|
|
005434f79b | ||
|
|
fe997d8b01 | ||
|
|
7291932a0b | ||
|
|
49ade03a9a | ||
|
|
dd6dbd25da | ||
|
|
50bf90fada | ||
|
|
abb81b6390 | ||
|
|
36da3d3cba | ||
|
|
ae71a99aa4 | ||
|
|
a7034d6351 | ||
|
|
6010b4c252 | ||
|
|
8330f4fba9 | ||
|
|
dc6e153b92 | ||
|
|
a2655ee6a5 | ||
|
|
13248962af | ||
|
|
b698982186 | ||
|
|
5a299b21c5 | ||
|
|
bb90f11ec8 |
3
.github/FUNDING.yml
vendored
@@ -1,2 +1,3 @@
|
|||||||
open_collective: cinny
|
github: ajbura
|
||||||
liberapay: ajbura
|
liberapay: ajbura
|
||||||
|
open_collective: cinny
|
||||||
|
|||||||
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Describe the bug
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
#### To Reproduce
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
#### Expected behavior
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
#### Screenshots
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
#### Desktop (please complete the following information):
|
|
||||||
- OS: [e.g. Windows, MacOS]
|
|
||||||
- Browser: [e.g. chrome, firefox]
|
|
||||||
- Version: [e.g. 3.22]
|
|
||||||
- Matrix homeserver: [e.g. matrix.org]
|
|
||||||
|
|
||||||
#### Additional context
|
|
||||||
Add any other context about the problem here.
|
|
||||||
57
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
name: 🐞 Bug Report
|
||||||
|
description: Report a bug
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## First of all
|
||||||
|
1. Please search for [existing issues](https://github.com/ajbura/cinny/issues?q=is%3Aissue) about this problem first.
|
||||||
|
2. Make sure Cinny is up to date.
|
||||||
|
3. Make sure it's an issue with Cinny and not something else you are using.
|
||||||
|
4. Remember to be friendly.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Describe the bug
|
||||||
|
description: A clear description of what the bug is. Include screenshots if applicable.
|
||||||
|
placeholder: Bug description
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction
|
||||||
|
attributes:
|
||||||
|
label: Reproduction
|
||||||
|
description: Steps to reproduce the behavior.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to ...
|
||||||
|
2. Click on ...
|
||||||
|
3. See error
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: A clear description of what you expected to happen.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: info
|
||||||
|
attributes:
|
||||||
|
label: Platform and versions
|
||||||
|
description: "Provide OS, browser and Cinny version with your Homeserver."
|
||||||
|
placeholder: |
|
||||||
|
1. OS: [e.g. Windows 10, MacOS]
|
||||||
|
2. Browser: [e.g. chrome 99.5, firefox 97.2]
|
||||||
|
3. Cinny version: [e.g. 1.8.1 (app.cinny.in)]
|
||||||
|
4. Matrix homeserver: [e.g. matrix.org]
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add any other context about the problem here.
|
||||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
contact_links:
|
||||||
|
- name: 💬 Matrix Chat
|
||||||
|
url: https://matrix.to/#/#cinny:matrix.org
|
||||||
|
about: Ask questions and talk to other Cinny users and the maintainers
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Is your feature request related to a problem? Please describe.
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
#### Describe the solution you'd like
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
#### Describe alternatives you've considered
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
#### Additional context
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
||||||
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: 💡 Feature Request
|
||||||
|
description: Suggest an idea
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Describe the problem
|
||||||
|
description: A clear description of the problem this feature would solve
|
||||||
|
placeholder: "I'm always frustrated when..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: "Describe the solution you'd like"
|
||||||
|
description: A clear description of what change you would like
|
||||||
|
placeholder: "I would like to..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
|
description: "Any alternative solutions you've considered"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add any other context about the problem here.
|
||||||
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,15 +1,13 @@
|
|||||||
<!-- Please read https://github.com/ajbura/cinny/CONTRIBUTING.md before submitting your pull request -->
|
<!-- Please read https://github.com/ajbura/cinny/blob/dev/CONTRIBUTING.md before submitting your pull request -->
|
||||||
|
|
||||||
### Description
|
### Description
|
||||||
|
<!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||||
|
|
||||||
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
|
|
||||||
|
|
||||||
Fixes # (issue)
|
Fixes #
|
||||||
|
|
||||||
#### Type of change
|
#### Type of change
|
||||||
|
|
||||||
Please delete options that are not relevant.
|
|
||||||
|
|
||||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||||
- [ ] New feature (non-breaking change which adds functionality)
|
- [ ] New feature (non-breaking change which adds functionality)
|
||||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||||
|
|||||||
20
.github/dependabot.yml
vendored
@@ -1,8 +1,16 @@
|
|||||||
# Docs: <https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/customizing-dependency-updates>
|
# Docs: <https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/customizing-dependency-updates>
|
||||||
|
|
||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
updates:
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: "tuesday"
|
||||||
|
time: "01:00"
|
||||||
|
timezone: "Asia/Kolkata"
|
||||||
|
open-pull-requests-limit: 15
|
||||||
|
|
||||||
- package-ecosystem: github-actions
|
- package-ecosystem: github-actions
|
||||||
directory: /
|
directory: /
|
||||||
schedule:
|
schedule:
|
||||||
@@ -10,6 +18,7 @@ updates:
|
|||||||
day: "tuesday"
|
day: "tuesday"
|
||||||
time: "01:00"
|
time: "01:00"
|
||||||
timezone: "Asia/Kolkata"
|
timezone: "Asia/Kolkata"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
|
||||||
- package-ecosystem: docker
|
- package-ecosystem: docker
|
||||||
directory: /
|
directory: /
|
||||||
@@ -18,11 +27,4 @@ updates:
|
|||||||
day: "tuesday"
|
day: "tuesday"
|
||||||
time: "01:00"
|
time: "01:00"
|
||||||
timezone: "Asia/Kolkata"
|
timezone: "Asia/Kolkata"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: "tuesday"
|
|
||||||
time: "01:00"
|
|
||||||
timezone: "Asia/Kolkata"
|
|
||||||
|
|||||||
19
.github/workflows/build-pull-request.yml
vendored
@@ -10,30 +10,29 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PR_NUMBER: ${{github.event.number}}
|
PR_NUMBER: ${{github.event.number}}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.0.0
|
uses: actions/checkout@v3.0.2
|
||||||
|
- name: Setup node
|
||||||
|
uses: actions/setup-node@v3.4.1
|
||||||
|
with:
|
||||||
|
node-version: 17.9.0
|
||||||
- name: Build app
|
- name: Build app
|
||||||
run: npm ci && npm run build
|
run: npm ci && npm run build
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3.0.0
|
uses: actions/upload-artifact@v3.1.0
|
||||||
with:
|
with:
|
||||||
name: previewbuild
|
name: previewbuild
|
||||||
path: dist
|
path: dist
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
- name: Get PR info
|
- name: Get PR info
|
||||||
uses: actions/github-script@v6.0.0
|
uses: actions/github-script@v6.1.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
var fs = require('fs');
|
var fs = require('fs');
|
||||||
fs.writeFileSync('${{github.workspace}}/pr.json', JSON.stringify(context.payload.pull_request));
|
fs.writeFileSync('${{github.workspace}}/pr.json', JSON.stringify(context.payload.pull_request));
|
||||||
- name: Upload PR Info
|
- name: Upload PR Info
|
||||||
uses: actions/upload-artifact@v3.0.0
|
uses: actions/upload-artifact@v3.1.0
|
||||||
with:
|
with:
|
||||||
name: pr.json
|
name: pr.json
|
||||||
path: pr.json
|
path: pr.json
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
- name: Build Docker image
|
|
||||||
uses: docker/build-push-action@v2.10.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: false
|
|
||||||
|
|||||||
15
.github/workflows/deploy-pull-request.yml
vendored
@@ -6,6 +6,9 @@ on:
|
|||||||
- completed
|
- completed
|
||||||
jobs:
|
jobs:
|
||||||
get-build-and-deploy:
|
get-build-and-deploy:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: >
|
if: >
|
||||||
${{ github.event.workflow_run.conclusion == 'success' }}
|
${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
@@ -13,8 +16,8 @@ jobs:
|
|||||||
# There's a 'download artifact' action but it hasn't been updated for the
|
# 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)
|
# workflow_run action (https://github.com/actions/download-artifact/issues/60)
|
||||||
# so instead we get this mess:
|
# so instead we get this mess:
|
||||||
- name: 'Download artifact'
|
- name: Download artifact
|
||||||
uses: actions/github-script@v6.0.0
|
uses: actions/github-script@v6.1.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
var artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
var artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||||
@@ -46,9 +49,9 @@ jobs:
|
|||||||
fs.writeFileSync('${{github.workspace}}/pr.json.zip', Buffer.from(download.data));
|
fs.writeFileSync('${{github.workspace}}/pr.json.zip', Buffer.from(download.data));
|
||||||
- name: Extract Artifacts
|
- name: Extract Artifacts
|
||||||
run: unzip -d dist previewbuild.zip && rm previewbuild.zip && unzip pr.json.zip && rm pr.json.zip
|
run: unzip -d dist previewbuild.zip && rm previewbuild.zip && unzip pr.json.zip && rm pr.json.zip
|
||||||
- name: 'Read PR Info'
|
- name: Read PR Info
|
||||||
id: readctx
|
id: readctx
|
||||||
uses: actions/github-script@v6.0.0
|
uses: actions/github-script@v6.1.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
var fs = require('fs');
|
var fs = require('fs');
|
||||||
@@ -56,7 +59,7 @@ jobs:
|
|||||||
console.log(`::set-output name=prnumber::${pr.number}`);
|
console.log(`::set-output name=prnumber::${pr.number}`);
|
||||||
- name: Deploy to Netlify
|
- name: Deploy to Netlify
|
||||||
id: netlify
|
id: netlify
|
||||||
uses: nwtgck/actions-netlify@v1.2.3
|
uses: nwtgck/actions-netlify@b7c1504e00c6b8a249d1848cc1b522a4865eed99
|
||||||
with:
|
with:
|
||||||
publish-dir: dist
|
publish-dir: dist
|
||||||
deploy-message: "Deploy from GitHub Actions"
|
deploy-message: "Deploy from GitHub Actions"
|
||||||
@@ -68,7 +71,7 @@ jobs:
|
|||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE3_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE3_ID }}
|
||||||
timeout-minutes: 1
|
timeout-minutes: 1
|
||||||
- name: Edit PR Description
|
- name: Edit PR Description
|
||||||
uses: velas/pr-description@v1.0.1
|
uses: Beakyn/gha-comment-pull-request@2167a7aee24f9e61ce76a23039f322e49a990409
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
21
.github/workflows/docker-pr.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: 'Docker check'
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile'
|
||||||
|
- '.github/workflows/docker-pr.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
PR_NUMBER: ${{github.event.number}}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3.0.2
|
||||||
|
- name: Build Docker image
|
||||||
|
uses: docker/build-push-action@v3.1.1
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
@@ -6,13 +6,20 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy-to-netlify:
|
||||||
name: 'Deploy'
|
name: 'Deploy'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3.0.0
|
- name: Checkout repository
|
||||||
- uses: jsmrcaga/action-netlify-deploy@v1.7.2
|
uses: actions/checkout@v3.0.2
|
||||||
|
- name: Setup node
|
||||||
|
uses: actions/setup-node@v3.4.1
|
||||||
|
with:
|
||||||
|
node-version: 17.9.0
|
||||||
|
- name: Build and deploy to Netlify
|
||||||
|
uses: jsmrcaga/action-netlify-deploy@53de32e559b0b3833615b9788c7a090cd2fddb03
|
||||||
with:
|
with:
|
||||||
install_command: "npm ci"
|
install_command: "npm ci"
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
56
.github/workflows/prod-deploy.yaml
vendored
@@ -1,56 +0,0 @@
|
|||||||
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 }}
|
|
||||||
95
.github/workflows/prod-deploy.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
name: 'Production deploy'
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-release-tar:
|
||||||
|
name: 'Create release tar'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3.0.2
|
||||||
|
- name: Setup node
|
||||||
|
uses: actions/setup-node@v3.4.1
|
||||||
|
with:
|
||||||
|
node-version: 17.9.0
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
- 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: Sign tar.gz
|
||||||
|
run: |
|
||||||
|
echo '${{ secrets.GNUPG_KEY }}' | gpg --batch --import
|
||||||
|
# Sadly a few lines in the private key match a few lines in the public key,
|
||||||
|
# As a result just --export --armor gives us a few lines replaced with ***
|
||||||
|
# making it useless for importing the signing key. Instead, we dump it as
|
||||||
|
# non-armored and hex-encode it so that its printable.
|
||||||
|
echo "PGP Signing key, in raw PGP format in hex. Import with cat ... | xxd -r -p - | gpg --import"
|
||||||
|
gpg --export | xxd -p
|
||||||
|
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||||
|
- name: Upload tagged release
|
||||||
|
uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||||
|
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
|
||||||
|
|
||||||
|
deploy-to-netlify:
|
||||||
|
name: 'Deploy to Netlify'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3.0.2
|
||||||
|
- name: Setup node
|
||||||
|
uses: actions/setup-node@v3.4.1
|
||||||
|
with:
|
||||||
|
node-version: 17.9.0
|
||||||
|
- name: Build and deploy to Netlify
|
||||||
|
uses: jsmrcaga/action-netlify-deploy@53de32e559b0b3833615b9788c7a090cd2fddb03
|
||||||
|
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
|
||||||
|
|
||||||
|
push-to-dockerhub:
|
||||||
|
name: Push Docker image to Docker Hub
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3.0.2
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2.0.0
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2.0.0
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v2.0.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v4.0.1
|
||||||
|
with:
|
||||||
|
images: ajbura/cinny
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v3.1.1
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
## Builder
|
## Builder
|
||||||
FROM node:17.7.1-alpine3.15 as builder
|
FROM node:17.9.0-alpine3.15 as builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ RUN npm run build
|
|||||||
|
|
||||||
|
|
||||||
## App
|
## App
|
||||||
FROM nginx:1.21.6-alpine
|
FROM nginx:1.23.1-alpine
|
||||||
|
|
||||||
COPY --from=builder /src/dist /app
|
COPY --from=builder /src/dist /app
|
||||||
|
|
||||||
|
|||||||
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2021 Ajay Bura (ajbura) and other contributors
|
Copyright (c) 2021 Ajay Bura (ajbura)
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
83
README.md
@@ -1,18 +1,27 @@
|
|||||||
# Cinny
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/ajbura/cinny/dev/public/res/svg/cinny.svg?sanitize=true"
|
||||||
|
height="16">
|
||||||
|
<span><b>Cinny</b></span>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/ajbura/cinny/releases">
|
||||||
|
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/ajbura/cinny/total?logo=github&style=social"></a>
|
||||||
|
<a href="https://hub.docker.com/r/ajbura/cinny">
|
||||||
|
<img alt="DockerHub downloads" src="https://img.shields.io/docker/pulls/ajbura/cinny?logo=docker&style=social"></a>
|
||||||
|
<a href="https://fosstodon.org/@cinnyapp">
|
||||||
|
<img alt="Follow on Mastodon" src="https://img.shields.io/mastodon/follow/106845779685925461?domain=https%3A%2F%2Ffosstodon.org&logo=mastodon&style=social"></a>
|
||||||
|
<a href="https://twitter.com/intent/follow?screen_name=cinnyapp">
|
||||||
|
<img alt="Follow on Twitter" src="https://img.shields.io/twitter/follow/cinnyapp?logo=twitter&style=social"></a>
|
||||||
|
<a href="https://cinny.in/#sponsor">
|
||||||
|
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Table of Contents
|
**Cinny** is a Matrix client focusing primarily on simple, elegant and secure interface. The main goal is to have a client that is easy on end user
|
||||||
|
and feels a modern chat application.
|
||||||
|
|
||||||
- [About](#about)
|
|
||||||
- [Getting Started](https://cinny.in)
|
|
||||||
- [Contributing](./CONTRIBUTING.md)
|
- [Contributing](./CONTRIBUTING.md)
|
||||||
- [Roadmap](https://github.com/ajbura/cinny/projects/11)
|
- [Roadmap](https://github.com/ajbura/cinny/projects/11)
|
||||||
|
|
||||||
## About <a name = "about"></a>
|
|
||||||
|
|
||||||
Cinny is a [Matrix](https://matrix.org) client focusing primarily on simple, elegant and secure interface.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Building and Running
|
## Building and Running
|
||||||
|
|
||||||
### Running pre-compiled
|
### Running pre-compiled
|
||||||
@@ -20,7 +29,57 @@ Cinny is a [Matrix](https://matrix.org) client focusing primarily on simple, ele
|
|||||||
A tarball of pre-compiled version of the app is provided with each [release](https://github.com/ajbura/cinny/releases).
|
A tarball of pre-compiled version of the app is provided with each [release](https://github.com/ajbura/cinny/releases).
|
||||||
You can serve the application with a webserver of your choosing by simply copying `dist/` directory to the webroot.
|
You can serve the application with a webserver of your choosing by simply copying `dist/` directory to the webroot.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>PGP Public Key to verify pre-compiled tarball</summary>
|
||||||
|
|
||||||
|
```
|
||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
mQGNBGJw/g0BDAC8qQeLqDMzYzfPyOmRlHVEoguVTo+eo1aVdQH2X7OELdjjBlyj
|
||||||
|
6d6c1adv/uF2g83NNMoQY7GEeHjRnXE4m8kYSaarb840pxrYUagDc0dAbJOGaCBY
|
||||||
|
FKTo7U1Kvg0vdiaRuus0pvc1NVdXSxRNQbFXBSwduD+zn66TI3HfcEHNN62FG1cE
|
||||||
|
K1jWDwLAU0P3kKmj8+CAc3h9ZklPu0k/+t5bf/LJkvdBJAUzGZpehbPL5f3u3BZ0
|
||||||
|
leZLIrR8uV7PiV5jKFahxlKR5KQHld8qQm+qVhYbUzpuMBGmh419I6UvTzxuRcvU
|
||||||
|
Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb
|
||||||
|
Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm
|
||||||
|
jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG
|
||||||
|
4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J
|
||||||
|
AdQEEwEIAD4WIQSRri2MHidaaZv+vvuUMwx6UK/M8wUCYnD+DQIbAwUJA8JnAAUL
|
||||||
|
CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCUMwx6UK/M88ApC/9HAdbum1lYBC0s
|
||||||
|
1k7GwP2A7B4sQtBWjy771BzybWlHeaeG+BGJwg4YiuowXZMm5dubFJFoI/CfeY07
|
||||||
|
B5aK40/bmT6Xcfkp0VA74c1wUpubBUEJN7tH5HG/OGd9BKeq9E/HHtVaJLVT1k3w
|
||||||
|
Rhv9VuHO6nR30EEp7IDthftotl5S4lio3+W0pKk4TAKV8vjaCNp3y/lAHzoP1BU9
|
||||||
|
bUSao+7GXVeArKBjuqxN+t1uuiaxPH4L0oe2pMVjTig04zGJM5fTVoly859MEcC/
|
||||||
|
R7Taq9RWGfXFmgCXy8Dviz3eOD90vqpCzhX4+ypK0cp2X0UwhMH4dpKUzExmdbhl
|
||||||
|
eBO5GcHB4VxvloRBNf9/Lr7YOTgWejMUw+MlhZE2RE8unfW1LnM/cjL4dhXzO/XB
|
||||||
|
FUHHNq8d6d4e02rfWqw7mZo2/NVJgFRcvzw2rgx7w7CKtCNwF4lNjUetB2waZzDb
|
||||||
|
fAE0kwhK4Iuwvy12JOBzL0Yy9MxANtwUryr/LQz9AmdT4Rwnp0S5AY0EYnD+DQEM
|
||||||
|
ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+
|
||||||
|
5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0
|
||||||
|
Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq
|
||||||
|
QPqfGDpowBwRkkOsGz/XVcesJ1Pzg4bKivTS9kZjZSyT9RRSY8As0sVUN57AwYul
|
||||||
|
s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH
|
||||||
|
vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c
|
||||||
|
LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx
|
||||||
|
H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx
|
||||||
|
NwARAQABiQG8BBgBCAAmFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmJw/g0CGwwF
|
||||||
|
CQPCZwAACgkQlDMMelCvzPPT7Qv8CjXUEhphZFLwpBfaNOzRNfIXJST9aDit8zHW
|
||||||
|
IMmfSpORVfpU71IyIB3o/DtTUPwCeb8nvNJs7aj1QT1ZUSsqFa3yY2S16V/g8+WN
|
||||||
|
sHca6oDSc1J+A0eEpEL1HbG1b5OPBC0AeGvvMOoqrbqThBZVKg1Jc/0SD3cvKElv
|
||||||
|
aHeCZCNNmfcZ2Ib4HYhhc8//ZtC9TeI+5J/YesctY1M12EoWMxMrc27Y3P5Pa0BI
|
||||||
|
Uc3qxWggPq1vOFYsEshL0w99HyJvREJmQA7Fa0crV+rICxyrBxJeNnEvjH/0KCBU
|
||||||
|
LCkEonLY1QwrxyeeV3VpxGE3zHHE3azOdAjTIoAdzX5f/qhbgYlM68GL2f8xdDkp
|
||||||
|
O0igSGHWhO4F8BfmE7IOTx1Bi7daczp8nCFxh73cKpKB0RUsd9xxrqYpovjmEAlo
|
||||||
|
w7aHpdzt64NQcsrbK10OSVDF3gFa9Vz20/NQvdUrp8jGmAb/8+nYqI94Jsc28H36
|
||||||
|
UeGsouhyuITLwEhScounZDqop+Dx
|
||||||
|
=Zg+6
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
### Building from source
|
### Building from source
|
||||||
|
> We recommend using a version manager as versions change very quickly. You will likely need to switch
|
||||||
|
between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Also recommended nodejs version is 16.15.0 LTS.
|
||||||
|
|
||||||
Execute the following commands to compile the app from its source code:
|
Execute the following commands to compile the app from its source code:
|
||||||
|
|
||||||
@@ -31,7 +90,7 @@ npm run build # Compiles the app into the dist/ directory
|
|||||||
|
|
||||||
You can then copy the files to a webserver's webroot of your choice.
|
You can then copy the files to a webserver's webroot of your choice.
|
||||||
|
|
||||||
To serve a development version of the app locally for testing, you may also use the command `npm start`.
|
To serve a development version of the app locally for testing, you need to use the command `npm start`.
|
||||||
|
|
||||||
### Running with Docker
|
### Running with Docker
|
||||||
|
|
||||||
@@ -59,7 +118,7 @@ To set default Homeserver on login and register page, place a customized [`confi
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright (c) 2021 Ajay Bura (ajbura) and contributors
|
Copyright (c) 2021 Ajay Bura (ajbura)
|
||||||
|
|
||||||
Code licensed under the MIT License: <http://opensource.org/licenses/MIT>
|
Code licensed under the MIT License: <http://opensource.org/licenses/MIT>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"defaultHomeserver": 4,
|
"defaultHomeserver": 3,
|
||||||
"homeserverList": [
|
"homeserverList": [
|
||||||
"converser.eu",
|
|
||||||
"envs.net",
|
"envs.net",
|
||||||
"halogen.city",
|
"halogen.city",
|
||||||
"kde.org",
|
"kde.org",
|
||||||
"matrix.org",
|
"matrix.org",
|
||||||
"chat.mozilla.org"
|
"mozilla.org"
|
||||||
]
|
],
|
||||||
|
"allowCustomHomeservers": true
|
||||||
}
|
}
|
||||||
6368
package-lock.json
generated
69
package.json
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "1.8.2",
|
"version": "2.1.3",
|
||||||
"description": "Yet another matrix client",
|
"description": "Yet another matrix client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
"npm": ">=6.14.11",
|
"npm": ">=6.14.8 <=8.5.5",
|
||||||
"node": ">=14.6.0"
|
"node": ">=14.15.0 <=17.9.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "webpack serve --config ./webpack.dev.js --open",
|
"start": "webpack serve --config ./webpack.dev.js --open",
|
||||||
@@ -15,71 +15,76 @@
|
|||||||
"author": "Ajay Bura",
|
"author": "Ajay Bura",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^4.5.5",
|
"@fontsource/inter": "^4.5.12",
|
||||||
"@fontsource/roboto": "^4.5.3",
|
"@fontsource/roboto": "^4.5.8",
|
||||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
|
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.12.tgz",
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"babel-polyfill": "^6.26.0",
|
"babel-polyfill": "^6.26.0",
|
||||||
|
"blurhash": "^1.1.5",
|
||||||
"browser-encrypt-attachment": "^0.3.0",
|
"browser-encrypt-attachment": "^0.3.0",
|
||||||
"dateformat": "^5.0.3",
|
"dateformat": "^5.0.3",
|
||||||
"emojibase-data": "^7.0.1",
|
"emojibase-data": "^7.0.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"flux": "^4.0.3",
|
"flux": "^4.0.3",
|
||||||
"formik": "^2.2.9",
|
"formik": "^2.2.9",
|
||||||
"html-react-parser": "^1.4.8",
|
"html-react-parser": "^3.0.1",
|
||||||
"linkifyjs": "^2.1.9",
|
"katex": "^0.16.0",
|
||||||
"matrix-js-sdk": "^15.6.0",
|
"linkify-html": "^4.0.0-beta.5",
|
||||||
|
"linkifyjs": "^4.0.0-beta.5",
|
||||||
|
"matrix-js-sdk": "^19.4.0",
|
||||||
"micromark": "^3.0.10",
|
"micromark": "^3.0.10",
|
||||||
"micromark-extension-gfm": "^2.0.1",
|
"micromark-extension-gfm": "^2.0.1",
|
||||||
|
"micromark-extension-math": "^2.0.2",
|
||||||
"micromark-util-chunked": "^1.0.0",
|
"micromark-util-chunked": "^1.0.0",
|
||||||
"micromark-util-resolve-all": "^1.0.0",
|
"micromark-util-resolve-all": "^1.0.0",
|
||||||
"micromark-util-symbol": "^1.0.1",
|
"micromark-util-symbol": "^1.0.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-autosize-textarea": "^7.1.0",
|
"react-autosize-textarea": "^7.1.0",
|
||||||
"react-dnd": "^15.1.1",
|
"react-blurhash": "^0.1.3",
|
||||||
"react-dnd-html5-backend": "^15.1.2",
|
"react-dnd": "^15.1.2",
|
||||||
|
"react-dnd-html5-backend": "^15.1.3",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-google-recaptcha": "^2.1.0",
|
"react-google-recaptcha": "^2.1.0",
|
||||||
"react-modal": "^3.14.4",
|
"react-modal": "^3.15.1",
|
||||||
"sanitize-html": "^2.7.0",
|
"sanitize-html": "^2.7.1",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"twemoji": "^14.0.1"
|
"twemoji": "^14.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.17.7",
|
"@babel/core": "^7.18.10",
|
||||||
"@babel/preset-env": "^7.16.11",
|
"@babel/preset-env": "^7.18.10",
|
||||||
"@babel/preset-react": "^7.16.7",
|
"@babel/preset-react": "^7.18.6",
|
||||||
"assert": "^2.0.0",
|
"assert": "^2.0.0",
|
||||||
"babel-loader": "^8.2.2",
|
"babel-loader": "^8.2.5",
|
||||||
"browserify-fs": "^1.0.0",
|
"browserify-fs": "^1.0.0",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"clean-webpack-plugin": "^4.0.0",
|
"clean-webpack-plugin": "^4.0.0",
|
||||||
"copy-webpack-plugin": "^10.2.4",
|
"copy-webpack-plugin": "^11.0.0",
|
||||||
"crypto-browserify": "^3.12.0",
|
"crypto-browserify": "^3.12.0",
|
||||||
"css-loader": "^6.7.1",
|
"css-loader": "^6.7.1",
|
||||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
"css-minimizer-webpack-plugin": "^4.0.0",
|
||||||
"eslint": "^8.11.0",
|
"eslint": "^8.21.0",
|
||||||
"eslint-config-airbnb": "^19.0.4",
|
"eslint-config-airbnb": "^19.0.4",
|
||||||
"eslint-plugin-import": "^2.22.1",
|
"eslint-plugin-import": "^2.26.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
"eslint-plugin-jsx-a11y": "^6.6.1",
|
||||||
"eslint-plugin-react": "^7.29.4",
|
"eslint-plugin-react": "^7.30.1",
|
||||||
"eslint-plugin-react-hooks": "^4.2.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"favicons": "^6.2.2",
|
"favicons": "^6.2.2",
|
||||||
"favicons-webpack-plugin": "^5.0.2",
|
"favicons-webpack-plugin": "^5.0.2",
|
||||||
"html-loader": "^3.1.0",
|
"html-loader": "^4.1.0",
|
||||||
"html-webpack-plugin": "^5.3.1",
|
"html-webpack-plugin": "^5.3.1",
|
||||||
"mini-css-extract-plugin": "^2.6.0",
|
"mini-css-extract-plugin": "^2.6.1",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"sass": "^1.49.9",
|
"sass": "^1.54.3",
|
||||||
"sass-loader": "^12.6.0",
|
"sass-loader": "^13.0.2",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"style-loader": "^3.3.1",
|
"style-loader": "^3.3.1",
|
||||||
"url": "^0.11.0",
|
"url": "^0.11.0",
|
||||||
"util": "^0.12.4",
|
"util": "^0.12.4",
|
||||||
"webpack": "^5.70.0",
|
"webpack": "^5.74.0",
|
||||||
"webpack-cli": "^4.9.2",
|
"webpack-cli": "^4.10.0",
|
||||||
"webpack-dev-server": "^4.7.4",
|
"webpack-dev-server": "^4.9.3",
|
||||||
"webpack-merge": "^5.7.3"
|
"webpack-merge": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,11 @@
|
|||||||
</head>
|
</head>
|
||||||
<body id="appBody">
|
<body id="appBody">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<audio id="notificationSound">
|
||||||
|
<source src="./sound/notification.ogg" type="audio/ogg" />
|
||||||
|
</audio>
|
||||||
|
<audio id="inviteSound">
|
||||||
|
<source src="./sound/invite.ogg" type="audio/ogg" />
|
||||||
|
</audio>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
4
public/res/ic/outlined/eye-blind.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.92896 3.51471L3.51474 4.92892L5.97515 7.38933C4.46742 8.5776 3.32116 9.93994 2.7 10.8C2.1 11.5 2.1 12.5 2.7 13.2C4 15 7.6 19 12 19C13.5709 19 15.0398 18.4902 16.3384 17.7526L19.0711 20.4853L20.4853 19.0711L4.92896 3.51471ZM4.2 12C4.68291 11.3561 5.85678 9.9637 7.39721 8.81139L9.29238 10.7066C9.10496 11.0982 9 11.5368 9 12C9 13.6569 10.3431 15 12 15C12.4632 15 12.9018 14.895 13.2934 14.7076L14.8573 16.2715C13.9566 16.7128 12.9896 17 12 17C8.4 17 5.1 13.2 4.2 12Z" fill="black"/>
|
||||||
|
<path d="M9.6226 5.37995L11.2906 7.04797C11.5254 7.01661 11.762 7 12 7C15.6 7 18.9 10.8 19.8 12C19.493 12.4094 18.9066 13.1213 18.1244 13.8817L19.5194 15.2768C20.2973 14.4974 20.9049 13.7471 21.3 13.2C21.9 12.5 21.9 11.5 21.3 10.8C20 9 16.4 5 12 5C11.1762 5 10.3805 5.14021 9.6226 5.37995Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 943 B |
@@ -1,13 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<path d="M12 19C7.6 19 4 15 2.7 13.2C2.1 12.5 2.1 11.5 2.7 10.8C4 9 7.6 5 12 5C16.4 5 20 9 21.3 10.8C21.9 11.5 21.9 12.5 21.3 13.2C20 15 16.4 19 12 19ZM12 7C8.4 7 5.1 10.8 4.2 12C5.1 13.2 8.4 17 12 17C15.6 17 18.9 13.2 19.8 12C18.9 10.8 15.6 7 12 7Z" fill="black"/>
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z" fill="black"/>
|
||||||
<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>
|
|
||||||
<g>
|
|
||||||
<path d="M12,19c-4.4,0-8-4-9.3-5.8c-0.6-0.7-0.6-1.7,0-2.4C4,9,7.6,5,12,5s8,4,9.3,5.8c0.6,0.7,0.6,1.7,0,2.4C20,15,16.4,19,12,19
|
|
||||||
z M12,7c-3.6,0-6.9,3.8-7.8,5c0.9,1.2,4.2,5,7.8,5s6.9-3.8,7.8-5C18.9,10.8,15.6,7,12,7z"/>
|
|
||||||
</g>
|
|
||||||
<circle cx="12" cy="12" r="3"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 718 B After Width: | Height: | Size: 508 B |
15
public/res/ic/outlined/message-unread.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>
|
||||||
|
<rect x="7" y="12" fill="#010101" width="10" height="2"/>
|
||||||
|
<g>
|
||||||
|
<circle fill="#010101" cx="19" cy="6" r="4"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#010101" d="M13.3,8H7v2h7.5C14,9.4,13.6,8.7,13.3,8z"/>
|
||||||
|
<path fill="#010101" d="M19,12v5.6l-2.4-1.3L16.1,16h-0.5H5V6h8c0-0.7,0.1-1.4,0.3-2H4.8C3.8,4,3,4.9,3,6v10c0,1.1,0.8,2,1.8,2
|
||||||
|
h10.8l5.4,3v-9.3C20.4,11.9,19.7,12,19,12z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 811 B |
12
public/res/ic/outlined/message.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 fill="#010101" d="M19,6v11.6l-2.4-1.3L16.1,16h-0.5H5V6H19 M19.2,4H4.8C3.8,4,3,4.9,3,6v10c0,1.1,0.8,2,1.8,2h10.8l5.4,3V6
|
||||||
|
C21,4.9,20.2,4,19.2,4L19.2,4z"/>
|
||||||
|
<rect x="7" y="8" fill="#010101" width="10" height="2"/>
|
||||||
|
<rect x="7" y="12" fill="#010101" width="10" height="2"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 732 B |
@@ -4,13 +4,14 @@
|
|||||||
<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"
|
<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">
|
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||||
<g>
|
<g>
|
||||||
<polygon fill="#010101" points="11,6 11,12 15.2,16.2 16.7,14.8 13,11.2 13,6 "/>
|
<polygon points="11,7 11,13 14.5,16.5 15.9,15.1 13,12.2 13,7 "/>
|
||||||
<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
|
<path d="M12,2C8.7,2,5.8,3.6,4,6V4H2.5v5h5V7.5H5.4C6.9,5.4,9.3,4,12,4c4.4,0,8,3.6,8,8s-3.6,8-8,8s-8-3.6-8-8H2
|
||||||
l-1.2,1.6C7.4,21.2,9.6,22,12,22c5.5,0,10-4.5,10-10S17.5,2,12,2z"/>
|
c0,5.5,4.5,10,10,10s10-4.5,10-10S17.5,2,12,2z"/>
|
||||||
</g>
|
</g>
|
||||||
<g>
|
<g>
|
||||||
<polygon fill="#010101" points="49,44 49,50 53.2,54.2 54.7,52.8 51,49.2 51,44 "/>
|
<polygon 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
|
<polygon points="45.5,47 40.5,47 40.5,42 42,42 42,45.5 45.5,45.5 "/>
|
||||||
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"/>
|
<path d="M50,40c-4.1,0-7.6,2.5-9.2,6h2.2c1.4-2.4,4-4,6.9-4c4.4,0,8,3.6,8,8s-3.6,8-8,8s-8-3.6-8-8h-2c0,5.5,4.5,10,10,10
|
||||||
|
s10-4.5,10-10S55.5,40,50,40z"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 985 B |
4
public/res/ic/outlined/sticker.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 3L21 8V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3H16ZM19 9H17C15.8954 9 15 8.10457 15 7V5H5V19H19V9Z" fill="black"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12H17C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12H9Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 501 B |
BIN
public/sound/invite.ogg
Executable file
BIN
public/sound/notification.ogg
Executable file
@@ -26,10 +26,10 @@
|
|||||||
&--icon {
|
&--icon {
|
||||||
@include dir.side(padding, var(--sp-tight), var(--sp-loose));
|
@include dir.side(padding, var(--sp-tight), var(--sp-loose));
|
||||||
|
|
||||||
.ic-raw {
|
}
|
||||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
.ic-raw {
|
||||||
flex-shrink: 0;
|
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||||
}
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ const IconButton = React.forwardRef(({
|
|||||||
variant, size, type,
|
variant, size, type,
|
||||||
tooltip, tooltipPlacement, src,
|
tooltip, tooltipPlacement, src,
|
||||||
onClick, tabIndex, disabled, isImage,
|
onClick, tabIndex, disabled, isImage,
|
||||||
|
className,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const btn = (
|
const btn = (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`ic-btn ic-btn-${variant}`}
|
className={`ic-btn ic-btn-${variant} ${className}`}
|
||||||
onMouseUp={(e) => blurOnBubbling(e, `.ic-btn-${variant}`)}
|
onMouseUp={(e) => blurOnBubbling(e, `.ic-btn-${variant}`)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
// eslint-disable-next-line react/button-has-type
|
// eslint-disable-next-line react/button-has-type
|
||||||
@@ -47,6 +48,7 @@ IconButton.defaultProps = {
|
|||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
isImage: false,
|
isImage: false,
|
||||||
|
className: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
IconButton.propTypes = {
|
IconButton.propTypes = {
|
||||||
@@ -60,6 +62,7 @@ IconButton.propTypes = {
|
|||||||
tabIndex: PropTypes.number,
|
tabIndex: PropTypes.number,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
isImage: PropTypes.bool,
|
isImage: PropTypes.bool,
|
||||||
|
className: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default IconButton;
|
export default IconButton;
|
||||||
|
|||||||
59
src/app/atoms/card/InfoCard.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './InfoCard.scss';
|
||||||
|
|
||||||
|
import Text from '../text/Text';
|
||||||
|
import RawIcon from '../system-icons/RawIcon';
|
||||||
|
import IconButton from '../button/IconButton';
|
||||||
|
|
||||||
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
|
||||||
|
function InfoCard({
|
||||||
|
className, style,
|
||||||
|
variant, iconSrc,
|
||||||
|
title, content,
|
||||||
|
rounded, requestClose,
|
||||||
|
}) {
|
||||||
|
const classes = [`info-card info-card--${variant}`];
|
||||||
|
if (rounded) classes.push('info-card--rounded');
|
||||||
|
if (className) classes.push(className);
|
||||||
|
return (
|
||||||
|
<div className={classes.join(' ')} style={style}>
|
||||||
|
{iconSrc && (
|
||||||
|
<div className="info-card__icon">
|
||||||
|
<RawIcon color={`var(--ic-${variant}-high)`} src={iconSrc} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="info-card__content">
|
||||||
|
<Text>{title}</Text>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
{requestClose && (
|
||||||
|
<IconButton src={CrossIC} variant={variant} onClick={requestClose} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InfoCard.defaultProps = {
|
||||||
|
className: null,
|
||||||
|
style: null,
|
||||||
|
variant: 'surface',
|
||||||
|
iconSrc: null,
|
||||||
|
content: null,
|
||||||
|
rounded: false,
|
||||||
|
requestClose: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
InfoCard.propTypes = {
|
||||||
|
className: PropTypes.string,
|
||||||
|
style: PropTypes.shape({}),
|
||||||
|
variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
|
||||||
|
iconSrc: PropTypes.string,
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
content: PropTypes.node,
|
||||||
|
rounded: PropTypes.bool,
|
||||||
|
requestClose: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfoCard;
|
||||||
79
src/app/atoms/card/InfoCard.scss
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
@use '.././../partials/flex';
|
||||||
|
@use '.././../partials/dir';
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
line-height: 0;
|
||||||
|
padding: var(--sp-tight);
|
||||||
|
@include dir.prop(border-left, 4px solid transparent, none);
|
||||||
|
@include dir.prop(border-right, none, 4px solid transparent);
|
||||||
|
|
||||||
|
& > .ic-btn {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
margin: 0 var(--sp-tight);
|
||||||
|
@extend .cp-fx__item-one;
|
||||||
|
|
||||||
|
& > *:nth-child(2) {
|
||||||
|
margin-top: var(--sp-ultra-tight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--rounded {
|
||||||
|
@include dir.prop(
|
||||||
|
border-radius,
|
||||||
|
0 var(--bo-radius) var(--bo-radius) 0,
|
||||||
|
var(--bo-radius) 0 0 var(--bo-radius)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--surface {
|
||||||
|
border-color: var(--bg-surface-border);
|
||||||
|
background-color: var(--bg-surface-hover);
|
||||||
|
|
||||||
|
}
|
||||||
|
&--primary {
|
||||||
|
border-color: var(--bg-primary);
|
||||||
|
background-color: var(--bg-primary-hover);
|
||||||
|
& .text {
|
||||||
|
color: var(--tc-primary-high);
|
||||||
|
&-b3 {
|
||||||
|
color: var(--tc-primary-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&--positive {
|
||||||
|
border-color: var(--bg-positive-border);
|
||||||
|
background-color: var(--bg-positive-hover);
|
||||||
|
& .text {
|
||||||
|
color: var(--tc-positive-high);
|
||||||
|
&-b3 {
|
||||||
|
color: var(--tc-positive-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&--caution {
|
||||||
|
border-color: var(--bg-caution-border);
|
||||||
|
background-color: var(--bg-caution-hover);
|
||||||
|
& .text {
|
||||||
|
color: var(--tc-caution-high);
|
||||||
|
&-b3 {
|
||||||
|
color: var(--tc-caution-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&--danger {
|
||||||
|
border-color: var(--bg-danger-border);
|
||||||
|
background-color: var(--bg-danger-hover);
|
||||||
|
& .text {
|
||||||
|
color: var(--tc-danger-high);
|
||||||
|
&-b3 {
|
||||||
|
color: var(--tc-danger-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ function Input({
|
|||||||
{ resizable
|
{ resizable
|
||||||
? (
|
? (
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
|
dir="auto"
|
||||||
style={{ minHeight: `${minHeight}px` }}
|
style={{ minHeight: `${minHeight}px` }}
|
||||||
name={name}
|
name={name}
|
||||||
id={id}
|
id={id}
|
||||||
@@ -34,6 +35,7 @@ function Input({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
|
dir="auto"
|
||||||
ref={forwardRef}
|
ref={forwardRef}
|
||||||
id={id}
|
id={id}
|
||||||
name={name}
|
name={name}
|
||||||
|
|||||||
32
src/app/atoms/math/Math.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import katex from 'katex';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
|
||||||
|
import 'katex/dist/contrib/copy-tex';
|
||||||
|
|
||||||
|
const Math = React.memo(({
|
||||||
|
content, throwOnError, errorColor, displayMode,
|
||||||
|
}) => {
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
katex.render(content, ref.current, { throwOnError, errorColor, displayMode });
|
||||||
|
}, [content, throwOnError, errorColor, displayMode]);
|
||||||
|
|
||||||
|
return <span ref={ref} />;
|
||||||
|
});
|
||||||
|
Math.defaultProps = {
|
||||||
|
throwOnError: null,
|
||||||
|
errorColor: null,
|
||||||
|
displayMode: null,
|
||||||
|
};
|
||||||
|
Math.propTypes = {
|
||||||
|
content: PropTypes.string.isRequired,
|
||||||
|
throwOnError: PropTypes.bool,
|
||||||
|
errorColor: PropTypes.string,
|
||||||
|
displayMode: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Math;
|
||||||
@@ -74,7 +74,7 @@ Tabs.defaultProps = {
|
|||||||
|
|
||||||
Tabs.propTypes = {
|
Tabs.propTypes = {
|
||||||
items: PropTypes.arrayOf(
|
items: PropTypes.arrayOf(
|
||||||
PropTypes.exact({
|
PropTypes.shape({
|
||||||
iconSrc: PropTypes.string,
|
iconSrc: PropTypes.string,
|
||||||
text: PropTypes.string,
|
text: PropTypes.string,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
@@ -84,4 +84,4 @@ Tabs.propTypes = {
|
|||||||
onSelect: PropTypes.func.isRequired,
|
onSelect: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { Tabs as default };
|
export default Tabs;
|
||||||
|
|||||||
44
src/app/atoms/time/Time.jsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import dateFormat from 'dateformat';
|
||||||
|
import { isInSameDay } from '../../../util/common';
|
||||||
|
|
||||||
|
function Time({ timestamp, fullTime }) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
|
||||||
|
const formattedFullTime = dateFormat(date, 'dd mmmm yyyy, hh:MM TT');
|
||||||
|
let formattedDate = formattedFullTime;
|
||||||
|
|
||||||
|
if (!fullTime) {
|
||||||
|
const compareDate = new Date();
|
||||||
|
const isToday = isInSameDay(date, compareDate);
|
||||||
|
compareDate.setDate(compareDate.getDate() - 1);
|
||||||
|
const isYesterday = isInSameDay(date, compareDate);
|
||||||
|
|
||||||
|
formattedDate = dateFormat(date, isToday || isYesterday ? 'hh:MM TT' : 'dd/mm/yyyy');
|
||||||
|
if (isYesterday) {
|
||||||
|
formattedDate = `Yesterday, ${formattedDate}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<time
|
||||||
|
dateTime={date.toISOString()}
|
||||||
|
title={formattedFullTime}
|
||||||
|
>
|
||||||
|
{formattedDate}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Time.defaultProps = {
|
||||||
|
fullTime: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
Time.propTypes = {
|
||||||
|
timestamp: PropTypes.number.isRequired,
|
||||||
|
fullTime: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Time;
|
||||||
25
src/app/hooks/useCrossSigningStatus.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import initMatrix from '../../client/initMatrix';
|
||||||
|
import { hasCrossSigningAccountData } from '../../util/matrixUtil';
|
||||||
|
|
||||||
|
export function useCrossSigningStatus() {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const [isCSEnabled, setIsCSEnabled] = useState(hasCrossSigningAccountData());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCSEnabled) return null;
|
||||||
|
const handleAccountData = (event) => {
|
||||||
|
if (event.getType() === 'm.cross_signing.master') {
|
||||||
|
setIsCSEnabled(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on('accountData', handleAccountData);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener('accountData', handleAccountData);
|
||||||
|
};
|
||||||
|
}, [isCSEnabled === false]);
|
||||||
|
return isCSEnabled;
|
||||||
|
}
|
||||||
32
src/app/hooks/useDeviceList.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import initMatrix from '../../client/initMatrix';
|
||||||
|
|
||||||
|
export function useDeviceList() {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const [deviceList, setDeviceList] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const updateDevices = () => mx.getDevices().then((data) => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setDeviceList(data.devices || []);
|
||||||
|
});
|
||||||
|
updateDevices();
|
||||||
|
|
||||||
|
const handleDevicesUpdate = (users) => {
|
||||||
|
if (users.includes(mx.getUserId())) {
|
||||||
|
updateDevices();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on('crypto.devicesUpdated', handleDevicesUpdate);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener('crypto.devicesUpdated', handleDevicesUpdate);
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return deviceList;
|
||||||
|
}
|
||||||
58
src/app/molecules/confirm-dialog/ConfirmDialog.jsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './ConfirmDialog.scss';
|
||||||
|
|
||||||
|
import { openReusableDialog } from '../../../client/action/navigation';
|
||||||
|
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import Button from '../../atoms/button/Button';
|
||||||
|
|
||||||
|
function ConfirmDialog({
|
||||||
|
desc, actionTitle, actionType, onComplete,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="confirm-dialog">
|
||||||
|
<Text>{desc}</Text>
|
||||||
|
<div className="confirm-dialog__btn">
|
||||||
|
<Button variant={actionType} onClick={() => onComplete(true)}>{actionTitle}</Button>
|
||||||
|
<Button onClick={() => onComplete(false)}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ConfirmDialog.propTypes = {
|
||||||
|
desc: PropTypes.string.isRequired,
|
||||||
|
actionTitle: PropTypes.string.isRequired,
|
||||||
|
actionType: PropTypes.oneOf(['primary', 'positive', 'danger', 'caution']).isRequired,
|
||||||
|
onComplete: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} title title of confirm dialog
|
||||||
|
* @param {string} desc description of confirm dialog
|
||||||
|
* @param {string} actionTitle title of main action to take
|
||||||
|
* @param {'primary' | 'positive' | 'danger' | 'caution'} actionType type of action. default=primary
|
||||||
|
* @return {Promise<boolean>} does it get's confirmed or not
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line import/prefer-default-export
|
||||||
|
export const confirmDialog = (title, desc, actionTitle, actionType = 'primary') => new Promise((resolve) => {
|
||||||
|
let isCompleted = false;
|
||||||
|
openReusableDialog(
|
||||||
|
<Text variant="s1" weight="medium">{title}</Text>,
|
||||||
|
(requestClose) => (
|
||||||
|
<ConfirmDialog
|
||||||
|
desc={desc}
|
||||||
|
actionTitle={actionTitle}
|
||||||
|
actionType={actionType}
|
||||||
|
onComplete={(isConfirmed) => {
|
||||||
|
isCompleted = true;
|
||||||
|
resolve(isConfirmed);
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
() => {
|
||||||
|
if (!isCompleted) resolve(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
11
src/app/molecules/confirm-dialog/ConfirmDialog.scss
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.confirm-dialog {
|
||||||
|
padding: var(--sp-normal);
|
||||||
|
|
||||||
|
& > .text {
|
||||||
|
padding-bottom: var(--sp-normal);
|
||||||
|
}
|
||||||
|
&__btn {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,11 @@ import RawModal from '../../atoms/modal/RawModal';
|
|||||||
function Dialog({
|
function Dialog({
|
||||||
className, isOpen, title, onAfterOpen, onAfterClose,
|
className, isOpen, title, onAfterOpen, onAfterClose,
|
||||||
contentOptions, onRequestClose, closeFromOutside, children,
|
contentOptions, onRequestClose, closeFromOutside, children,
|
||||||
|
invisibleScroll,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<RawModal
|
<RawModal
|
||||||
className={`${className === null ? '' : `${className} `}dialog-model`}
|
className={`${className === null ? '' : `${className} `}dialog-modal`}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onAfterOpen={onAfterOpen}
|
onAfterOpen={onAfterOpen}
|
||||||
onAfterClose={onAfterClose}
|
onAfterClose={onAfterClose}
|
||||||
@@ -36,7 +37,7 @@ function Dialog({
|
|||||||
{contentOptions}
|
{contentOptions}
|
||||||
</Header>
|
</Header>
|
||||||
<div className="dialog__content__wrapper">
|
<div className="dialog__content__wrapper">
|
||||||
<ScrollView autoHide>
|
<ScrollView autoHide={!invisibleScroll} invisible={invisibleScroll}>
|
||||||
<div className="dialog__content-container">
|
<div className="dialog__content-container">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +56,7 @@ Dialog.defaultProps = {
|
|||||||
onAfterClose: null,
|
onAfterClose: null,
|
||||||
onRequestClose: null,
|
onRequestClose: null,
|
||||||
closeFromOutside: true,
|
closeFromOutside: true,
|
||||||
|
invisibleScroll: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
Dialog.propTypes = {
|
Dialog.propTypes = {
|
||||||
@@ -67,6 +69,7 @@ Dialog.propTypes = {
|
|||||||
onRequestClose: PropTypes.func,
|
onRequestClose: PropTypes.func,
|
||||||
closeFromOutside: PropTypes.bool,
|
closeFromOutside: PropTypes.bool,
|
||||||
children: PropTypes.node.isRequired,
|
children: PropTypes.node.isRequired,
|
||||||
|
invisibleScroll: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Dialog;
|
export default Dialog;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.dialog-model {
|
.dialog-modal {
|
||||||
--modal-height: 656px;
|
--modal-height: 656px;
|
||||||
max-height: min(100%, var(--modal-height));
|
max-height: min(100%, var(--modal-height));
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -21,8 +21,3 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog__content-container {
|
|
||||||
padding-top: var(--sp-extra-tight);
|
|
||||||
padding-bottom: var(--sp-extra-loose);
|
|
||||||
}
|
|
||||||
49
src/app/molecules/dialog/ReusableDialog.jsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import cons from '../../../client/state/cons';
|
||||||
|
|
||||||
|
import navigation from '../../../client/state/navigation';
|
||||||
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
|
import Dialog from './Dialog';
|
||||||
|
|
||||||
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
|
||||||
|
function ReusableDialog() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOpen = (title, render, afterClose) => {
|
||||||
|
setIsOpen(true);
|
||||||
|
setData({ title, render, afterClose });
|
||||||
|
};
|
||||||
|
navigation.on(cons.events.navigation.REUSABLE_DIALOG_OPENED, handleOpen);
|
||||||
|
return () => {
|
||||||
|
navigation.removeListener(cons.events.navigation.REUSABLE_DIALOG_OPENED, handleOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAfterClose = () => {
|
||||||
|
data.afterClose?.();
|
||||||
|
setData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestClose = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={isOpen}
|
||||||
|
title={data?.title || ''}
|
||||||
|
onAfterClose={handleAfterClose}
|
||||||
|
onRequestClose={handleRequestClose}
|
||||||
|
contentOptions={<IconButton src={CrossIC} onClick={handleRequestClose} tooltip="Close" />}
|
||||||
|
invisibleScroll
|
||||||
|
>
|
||||||
|
{data?.render(handleRequestClose) || <div />}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReusableDialog;
|
||||||
469
src/app/molecules/image-pack/ImagePack.jsx
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import React, {
|
||||||
|
useState, useMemo, useReducer, useEffect,
|
||||||
|
} from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './ImagePack.scss';
|
||||||
|
|
||||||
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import { openReusableDialog } from '../../../client/action/navigation';
|
||||||
|
import { suffixRename } from '../../../util/common';
|
||||||
|
|
||||||
|
import Button from '../../atoms/button/Button';
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import Input from '../../atoms/input/Input';
|
||||||
|
import Checkbox from '../../atoms/button/Checkbox';
|
||||||
|
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||||
|
|
||||||
|
import { ImagePack as ImagePackBuilder } from '../../organisms/emoji-board/custom-emoji';
|
||||||
|
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||||
|
import ImagePackProfile from './ImagePackProfile';
|
||||||
|
import ImagePackItem from './ImagePackItem';
|
||||||
|
import ImagePackUpload from './ImagePackUpload';
|
||||||
|
|
||||||
|
const renameImagePackItem = (shortcode) => new Promise((resolve) => {
|
||||||
|
let isCompleted = false;
|
||||||
|
|
||||||
|
openReusableDialog(
|
||||||
|
<Text variant="s1" weight="medium">Rename</Text>,
|
||||||
|
(requestClose) => (
|
||||||
|
<div style={{ padding: 'var(--sp-normal)' }}>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const sc = e.target.shortcode.value;
|
||||||
|
if (sc.trim() === '') return;
|
||||||
|
isCompleted = true;
|
||||||
|
resolve(sc.trim());
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={shortcode}
|
||||||
|
name="shortcode"
|
||||||
|
label="Shortcode"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div style={{ height: 'var(--sp-normal)' }} />
|
||||||
|
<Button variant="primary" type="submit">Rename</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
() => {
|
||||||
|
if (!isCompleted) resolve(null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getUsage(usage) {
|
||||||
|
if (usage.includes('emoticon') && usage.includes('sticker')) return 'both';
|
||||||
|
if (usage.includes('emoticon')) return 'emoticon';
|
||||||
|
if (usage.includes('sticker')) return 'sticker';
|
||||||
|
|
||||||
|
return 'both';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGlobalPack(roomId, stateKey) {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
|
||||||
|
if (typeof globalContent !== 'object') return false;
|
||||||
|
|
||||||
|
const { rooms } = globalContent;
|
||||||
|
if (typeof rooms !== 'object') return false;
|
||||||
|
|
||||||
|
return rooms[roomId]?.[stateKey] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRoomImagePack(roomId, stateKey) {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
|
||||||
|
const packEvent = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
|
||||||
|
const pack = useMemo(() => (
|
||||||
|
ImagePackBuilder.parsePack(packEvent.getId(), packEvent.getContent())
|
||||||
|
), [room, stateKey]);
|
||||||
|
|
||||||
|
const sendPackContent = (content) => {
|
||||||
|
mx.sendStateEvent(roomId, 'im.ponies.room_emotes', content, stateKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pack,
|
||||||
|
sendPackContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useUserImagePack() {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const packEvent = mx.getAccountData('im.ponies.user_emotes');
|
||||||
|
const pack = useMemo(() => (
|
||||||
|
ImagePackBuilder.parsePack(mx.getUserId(), packEvent?.getContent() ?? {
|
||||||
|
pack: { display_name: 'Personal' },
|
||||||
|
images: {},
|
||||||
|
})
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const sendPackContent = (content) => {
|
||||||
|
mx.setAccountData('im.ponies.user_emotes', content);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pack,
|
||||||
|
sendPackContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useImagePackHandles(pack, sendPackContent) {
|
||||||
|
const [, forceUpdate] = useReducer((count) => count + 1, 0);
|
||||||
|
|
||||||
|
const getNewKey = (key) => {
|
||||||
|
if (typeof key !== 'string') return undefined;
|
||||||
|
let newKey = key?.replace(/\s/g, '_');
|
||||||
|
if (pack.getImages().get(newKey)) {
|
||||||
|
newKey = suffixRename(
|
||||||
|
newKey,
|
||||||
|
(suffixedKey) => pack.getImages().get(suffixedKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return newKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarChange = (url) => {
|
||||||
|
pack.setAvatarUrl(url);
|
||||||
|
sendPackContent(pack.getContent());
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
const handleEditProfile = (name, attribution) => {
|
||||||
|
pack.setDisplayName(name);
|
||||||
|
pack.setAttribution(attribution);
|
||||||
|
sendPackContent(pack.getContent());
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
const handleUsageChange = (newUsage) => {
|
||||||
|
const usage = [];
|
||||||
|
if (newUsage === 'emoticon' || newUsage === 'both') usage.push('emoticon');
|
||||||
|
if (newUsage === 'sticker' || newUsage === 'both') usage.push('sticker');
|
||||||
|
pack.setUsage(usage);
|
||||||
|
pack.getImages().forEach((img) => pack.setImageUsage(img.shortcode, undefined));
|
||||||
|
|
||||||
|
sendPackContent(pack.getContent());
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameItem = async (key) => {
|
||||||
|
const newKey = getNewKey(await renameImagePackItem(key));
|
||||||
|
|
||||||
|
if (!newKey || newKey === key) return;
|
||||||
|
pack.updateImageKey(key, newKey);
|
||||||
|
|
||||||
|
sendPackContent(pack.getContent());
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
const handleDeleteItem = async (key) => {
|
||||||
|
const isConfirmed = await confirmDialog(
|
||||||
|
'Delete',
|
||||||
|
`Are you sure that you want to delete "${key}"?`,
|
||||||
|
'Delete',
|
||||||
|
'danger',
|
||||||
|
);
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
pack.removeImage(key);
|
||||||
|
|
||||||
|
sendPackContent(pack.getContent());
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
const handleUsageItem = (key, newUsage) => {
|
||||||
|
const usage = [];
|
||||||
|
if (newUsage === 'emoticon' || newUsage === 'both') usage.push('emoticon');
|
||||||
|
if (newUsage === 'sticker' || newUsage === 'both') usage.push('sticker');
|
||||||
|
pack.setImageUsage(key, usage);
|
||||||
|
|
||||||
|
sendPackContent(pack.getContent());
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
const handleAddItem = (key, url) => {
|
||||||
|
const newKey = getNewKey(key);
|
||||||
|
if (!newKey || !url) return;
|
||||||
|
|
||||||
|
pack.addImage(newKey, {
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
sendPackContent(pack.getContent());
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleAvatarChange,
|
||||||
|
handleEditProfile,
|
||||||
|
handleUsageChange,
|
||||||
|
handleRenameItem,
|
||||||
|
handleDeleteItem,
|
||||||
|
handleUsageItem,
|
||||||
|
handleAddItem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGlobalImagePack(mx, roomId, stateKey) {
|
||||||
|
const content = mx.getAccountData('im.ponies.emote_rooms')?.getContent() ?? {};
|
||||||
|
if (!content.rooms) content.rooms = {};
|
||||||
|
if (!content.rooms[roomId]) content.rooms[roomId] = {};
|
||||||
|
content.rooms[roomId][stateKey] = {};
|
||||||
|
return mx.setAccountData('im.ponies.emote_rooms', content);
|
||||||
|
}
|
||||||
|
function removeGlobalImagePack(mx, roomId, stateKey) {
|
||||||
|
const content = mx.getAccountData('im.ponies.emote_rooms')?.getContent() ?? {};
|
||||||
|
if (!content.rooms) return Promise.resolve();
|
||||||
|
if (!content.rooms[roomId]) return Promise.resolve();
|
||||||
|
delete content.rooms[roomId][stateKey];
|
||||||
|
if (Object.keys(content.rooms[roomId]).length === 0) {
|
||||||
|
delete content.rooms[roomId];
|
||||||
|
}
|
||||||
|
return mx.setAccountData('im.ponies.emote_rooms', content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImagePack({ roomId, stateKey, handlePackDelete }) {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
const [viewMore, setViewMore] = useState(false);
|
||||||
|
const [isGlobal, setIsGlobal] = useState(isGlobalPack(roomId, stateKey));
|
||||||
|
|
||||||
|
const { pack, sendPackContent } = useRoomImagePack(roomId, stateKey);
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleAvatarChange,
|
||||||
|
handleEditProfile,
|
||||||
|
handleUsageChange,
|
||||||
|
handleRenameItem,
|
||||||
|
handleDeleteItem,
|
||||||
|
handleUsageItem,
|
||||||
|
handleAddItem,
|
||||||
|
} = useImagePackHandles(pack, sendPackContent);
|
||||||
|
|
||||||
|
const handleGlobalChange = (isG) => {
|
||||||
|
setIsGlobal(isG);
|
||||||
|
if (isG) addGlobalImagePack(mx, roomId, stateKey);
|
||||||
|
else removeGlobalImagePack(mx, roomId, stateKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||||
|
const canChange = room.currentState.hasSufficientPowerLevelFor('state_default', myPowerlevel);
|
||||||
|
|
||||||
|
const handleDeletePack = async () => {
|
||||||
|
const isConfirmed = await confirmDialog(
|
||||||
|
'Delete Pack',
|
||||||
|
`Are you sure that you want to delete "${pack.displayName}"?`,
|
||||||
|
'Delete',
|
||||||
|
'danger',
|
||||||
|
);
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
handlePackDelete(stateKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const images = [...pack.images].slice(0, viewMore ? pack.images.size : 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="image-pack">
|
||||||
|
<ImagePackProfile
|
||||||
|
avatarUrl={pack.avatarUrl ? mx.mxcUrlToHttp(pack.avatarUrl, 42, 42, 'crop') : null}
|
||||||
|
displayName={pack.displayName ?? 'Unknown'}
|
||||||
|
attribution={pack.attribution}
|
||||||
|
usage={getUsage(pack.usage)}
|
||||||
|
onUsageChange={canChange ? handleUsageChange : null}
|
||||||
|
onAvatarChange={canChange ? handleAvatarChange : null}
|
||||||
|
onEditProfile={canChange ? handleEditProfile : null}
|
||||||
|
/>
|
||||||
|
{ canChange && (
|
||||||
|
<ImagePackUpload onUpload={handleAddItem} />
|
||||||
|
)}
|
||||||
|
{ images.length === 0 ? null : (
|
||||||
|
<div>
|
||||||
|
<div className="image-pack__header">
|
||||||
|
<Text variant="b3">Image</Text>
|
||||||
|
<Text variant="b3">Shortcode</Text>
|
||||||
|
<Text variant="b3">Usage</Text>
|
||||||
|
</div>
|
||||||
|
{images.map(([shortcode, image]) => (
|
||||||
|
<ImagePackItem
|
||||||
|
key={shortcode}
|
||||||
|
url={mx.mxcUrlToHttp(image.mxc)}
|
||||||
|
shortcode={shortcode}
|
||||||
|
usage={getUsage(image.usage)}
|
||||||
|
onUsageChange={canChange ? handleUsageItem : undefined}
|
||||||
|
onDelete={canChange ? handleDeleteItem : undefined}
|
||||||
|
onRename={canChange ? handleRenameItem : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(pack.images.size > 2 || handlePackDelete) && (
|
||||||
|
<div className="image-pack__footer">
|
||||||
|
{pack.images.size > 2 && (
|
||||||
|
<Button onClick={() => setViewMore(!viewMore)}>
|
||||||
|
{
|
||||||
|
viewMore
|
||||||
|
? 'View less'
|
||||||
|
: `View ${pack.images.size - 2} more`
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{ handlePackDelete && <Button variant="danger" onClick={handleDeletePack}>Delete Pack</Button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="image-pack__global">
|
||||||
|
<Checkbox variant="positive" onToggle={handleGlobalChange} isActive={isGlobal} />
|
||||||
|
<div>
|
||||||
|
<Text variant="b2">Use globally</Text>
|
||||||
|
<Text variant="b3">Add this pack to your account to use in all rooms.</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImagePack.defaultProps = {
|
||||||
|
handlePackDelete: null,
|
||||||
|
};
|
||||||
|
ImagePack.propTypes = {
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
stateKey: PropTypes.string.isRequired,
|
||||||
|
handlePackDelete: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
function ImagePackUser() {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const [viewMore, setViewMore] = useState(false);
|
||||||
|
|
||||||
|
const { pack, sendPackContent } = useUserImagePack();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleAvatarChange,
|
||||||
|
handleEditProfile,
|
||||||
|
handleUsageChange,
|
||||||
|
handleRenameItem,
|
||||||
|
handleDeleteItem,
|
||||||
|
handleUsageItem,
|
||||||
|
handleAddItem,
|
||||||
|
} = useImagePackHandles(pack, sendPackContent);
|
||||||
|
|
||||||
|
const images = [...pack.images].slice(0, viewMore ? pack.images.size : 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="image-pack">
|
||||||
|
<ImagePackProfile
|
||||||
|
avatarUrl={pack.avatarUrl ? mx.mxcUrlToHttp(pack.avatarUrl, 42, 42, 'crop') : null}
|
||||||
|
displayName={pack.displayName ?? 'Personal'}
|
||||||
|
attribution={pack.attribution}
|
||||||
|
usage={getUsage(pack.usage)}
|
||||||
|
onUsageChange={handleUsageChange}
|
||||||
|
onAvatarChange={handleAvatarChange}
|
||||||
|
onEditProfile={handleEditProfile}
|
||||||
|
/>
|
||||||
|
<ImagePackUpload onUpload={handleAddItem} />
|
||||||
|
{ images.length === 0 ? null : (
|
||||||
|
<div>
|
||||||
|
<div className="image-pack__header">
|
||||||
|
<Text variant="b3">Image</Text>
|
||||||
|
<Text variant="b3">Shortcode</Text>
|
||||||
|
<Text variant="b3">Usage</Text>
|
||||||
|
</div>
|
||||||
|
{images.map(([shortcode, image]) => (
|
||||||
|
<ImagePackItem
|
||||||
|
key={shortcode}
|
||||||
|
url={mx.mxcUrlToHttp(image.mxc)}
|
||||||
|
shortcode={shortcode}
|
||||||
|
usage={getUsage(image.usage)}
|
||||||
|
onUsageChange={handleUsageItem}
|
||||||
|
onDelete={handleDeleteItem}
|
||||||
|
onRename={handleRenameItem}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(pack.images.size > 2) && (
|
||||||
|
<div className="image-pack__footer">
|
||||||
|
<Button onClick={() => setViewMore(!viewMore)}>
|
||||||
|
{
|
||||||
|
viewMore
|
||||||
|
? 'View less'
|
||||||
|
: `View ${pack.images.size - 2} more`
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGlobalImagePack() {
|
||||||
|
const [, forceUpdate] = useReducer((count) => count + 1, 0);
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
|
||||||
|
const roomIdToStateKeys = new Map();
|
||||||
|
const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent() ?? { rooms: {} };
|
||||||
|
const { rooms } = globalContent;
|
||||||
|
|
||||||
|
Object.keys(rooms).forEach((roomId) => {
|
||||||
|
if (typeof rooms[roomId] !== 'object') return;
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
const stateKeys = Object.keys(rooms[roomId]);
|
||||||
|
if (!room || stateKeys.length === 0) return;
|
||||||
|
roomIdToStateKeys.set(roomId, stateKeys);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEvent = (event) => {
|
||||||
|
if (event.getType() === 'im.ponies.emote_rooms') forceUpdate();
|
||||||
|
};
|
||||||
|
mx.addListener('accountData', handleEvent);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener('accountData', handleEvent);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return roomIdToStateKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImagePackGlobal() {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const roomIdToStateKeys = useGlobalImagePack();
|
||||||
|
|
||||||
|
const handleChange = (roomId, stateKey) => {
|
||||||
|
removeGlobalImagePack(mx, roomId, stateKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="image-pack-global">
|
||||||
|
<MenuHeader>Global packs</MenuHeader>
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
roomIdToStateKeys.size > 0
|
||||||
|
? [...roomIdToStateKeys].map(([roomId, stateKeys]) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
return (
|
||||||
|
stateKeys.map((stateKey) => {
|
||||||
|
const data = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
|
||||||
|
const pack = ImagePackBuilder.parsePack(data?.getId(), data?.getContent());
|
||||||
|
if (!pack) return null;
|
||||||
|
return (
|
||||||
|
<div className="image-pack__global" key={pack.id}>
|
||||||
|
<Checkbox variant="positive" onToggle={() => handleChange(roomId, stateKey)} isActive />
|
||||||
|
<div>
|
||||||
|
<Text variant="b2">{pack.displayName ?? 'Unknown'}</Text>
|
||||||
|
<Text variant="b3">{room.name}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: <div className="image-pack-global__empty"><Text>No global packs</Text></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImagePack;
|
||||||
|
|
||||||
|
export { ImagePackUser, ImagePackGlobal };
|
||||||
47
src/app/molecules/image-pack/ImagePack.scss
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
@use '../../partials/flex';
|
||||||
|
|
||||||
|
.image-pack {
|
||||||
|
&-item {
|
||||||
|
border-top: 1px solid var(--bg-surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
padding: var(--sp-extra-tight) var(--sp-normal);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-normal);
|
||||||
|
|
||||||
|
& > *:nth-child(2) {
|
||||||
|
@extend .cp-fx__item-one;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
padding: var(--sp-normal);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__global {
|
||||||
|
padding: var(--sp-normal);
|
||||||
|
padding-top: var(--sp-tight);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-pack-global {
|
||||||
|
&__empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--sp-extra-loose) var(--sp-normal);
|
||||||
|
}
|
||||||
|
& .image-pack__global {
|
||||||
|
padding: 0 var(--sp-normal);
|
||||||
|
padding-bottom: var(--sp-normal);
|
||||||
|
&:first-child {
|
||||||
|
padding-top: var(--sp-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/app/molecules/image-pack/ImagePackItem.jsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './ImagePackItem.scss';
|
||||||
|
|
||||||
|
import { openReusableContextMenu } from '../../../client/action/navigation';
|
||||||
|
import { getEventCords } from '../../../util/common';
|
||||||
|
|
||||||
|
import Avatar from '../../atoms/avatar/Avatar';
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import Button from '../../atoms/button/Button';
|
||||||
|
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||||
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
|
import ImagePackUsageSelector from './ImagePackUsageSelector';
|
||||||
|
|
||||||
|
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||||
|
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
||||||
|
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
||||||
|
|
||||||
|
function ImagePackItem({
|
||||||
|
url, shortcode, usage, onUsageChange, onDelete, onRename,
|
||||||
|
}) {
|
||||||
|
const handleUsageSelect = (event) => {
|
||||||
|
openReusableContextMenu(
|
||||||
|
'bottom',
|
||||||
|
getEventCords(event, '.btn-surface'),
|
||||||
|
(closeMenu) => (
|
||||||
|
<ImagePackUsageSelector
|
||||||
|
usage={usage}
|
||||||
|
onSelect={(newUsage) => {
|
||||||
|
onUsageChange(shortcode, newUsage);
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="image-pack-item">
|
||||||
|
<Avatar imageSrc={url} size="extra-small" text={shortcode} bgColor="black" />
|
||||||
|
<div className="image-pack-item__content">
|
||||||
|
<Text>{shortcode}</Text>
|
||||||
|
</div>
|
||||||
|
<div className="image-pack-item__usage">
|
||||||
|
<div className="image-pack-item__btn">
|
||||||
|
{onRename && <IconButton tooltip="Rename" size="extra-small" src={PencilIC} onClick={() => onRename(shortcode)} />}
|
||||||
|
{onDelete && <IconButton tooltip="Delete" size="extra-small" src={BinIC} onClick={() => onDelete(shortcode)} />}
|
||||||
|
</div>
|
||||||
|
<Button onClick={onUsageChange ? handleUsageSelect : undefined}>
|
||||||
|
{onUsageChange && <RawIcon src={ChevronBottomIC} size="extra-small" />}
|
||||||
|
<Text variant="b2">
|
||||||
|
{usage === 'emoticon' && 'Emoji'}
|
||||||
|
{usage === 'sticker' && 'Sticker'}
|
||||||
|
{usage === 'both' && 'Both'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImagePackItem.defaultProps = {
|
||||||
|
onUsageChange: null,
|
||||||
|
onDelete: null,
|
||||||
|
onRename: null,
|
||||||
|
};
|
||||||
|
ImagePackItem.propTypes = {
|
||||||
|
url: PropTypes.string.isRequired,
|
||||||
|
shortcode: PropTypes.string.isRequired,
|
||||||
|
usage: PropTypes.oneOf(['emoticon', 'sticker', 'both']).isRequired,
|
||||||
|
onUsageChange: PropTypes.func,
|
||||||
|
onDelete: PropTypes.func,
|
||||||
|
onRename: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImagePackItem;
|
||||||
43
src/app/molecules/image-pack/ImagePackItem.scss
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
@use '../../partials/flex';
|
||||||
|
@use '../../partials/dir';
|
||||||
|
|
||||||
|
.image-pack-item {
|
||||||
|
margin: 0 var(--sp-normal);
|
||||||
|
padding: var(--sp-tight) 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-normal);
|
||||||
|
|
||||||
|
& .avatar-container img {
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
@extend .cp-fx__item-one;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__usage {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-ultra-tight);
|
||||||
|
& button {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
& > button.btn-surface {
|
||||||
|
padding: 6px var(--sp-tight);
|
||||||
|
min-width: 0;
|
||||||
|
@include dir.side(margin, var(--sp-ultra-tight), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&:hover,
|
||||||
|
&:focus-within {
|
||||||
|
.image-pack-item__btn {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-ultra-tight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/app/molecules/image-pack/ImagePackProfile.jsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './ImagePackProfile.scss';
|
||||||
|
|
||||||
|
import { openReusableContextMenu } from '../../../client/action/navigation';
|
||||||
|
import { getEventCords } from '../../../util/common';
|
||||||
|
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import Avatar from '../../atoms/avatar/Avatar';
|
||||||
|
import Button from '../../atoms/button/Button';
|
||||||
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
|
import Input from '../../atoms/input/Input';
|
||||||
|
import ImageUpload from '../image-upload/ImageUpload';
|
||||||
|
import ImagePackUsageSelector from './ImagePackUsageSelector';
|
||||||
|
|
||||||
|
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||||
|
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
||||||
|
|
||||||
|
function ImagePackProfile({
|
||||||
|
avatarUrl, displayName, attribution, usage,
|
||||||
|
onUsageChange, onAvatarChange, onEditProfile,
|
||||||
|
}) {
|
||||||
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const { nameInput, attributionInput } = e.target;
|
||||||
|
const name = nameInput.value.trim() || undefined;
|
||||||
|
const att = attributionInput.value.trim() || undefined;
|
||||||
|
|
||||||
|
onEditProfile(name, att);
|
||||||
|
setIsEdit(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUsageSelect = (event) => {
|
||||||
|
openReusableContextMenu(
|
||||||
|
'bottom',
|
||||||
|
getEventCords(event, '.btn-surface'),
|
||||||
|
(closeMenu) => (
|
||||||
|
<ImagePackUsageSelector
|
||||||
|
usage={usage}
|
||||||
|
onSelect={(newUsage) => {
|
||||||
|
onUsageChange(newUsage);
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="image-pack-profile">
|
||||||
|
{
|
||||||
|
onAvatarChange
|
||||||
|
? (
|
||||||
|
<ImageUpload
|
||||||
|
bgColor="#555"
|
||||||
|
text={displayName}
|
||||||
|
imageSrc={avatarUrl}
|
||||||
|
size="normal"
|
||||||
|
onUpload={onAvatarChange}
|
||||||
|
onRequestRemove={() => onAvatarChange(undefined)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: <Avatar bgColor="#555" text={displayName} imageSrc={avatarUrl} size="normal" />
|
||||||
|
}
|
||||||
|
<div className="image-pack-profile__content">
|
||||||
|
{
|
||||||
|
isEdit
|
||||||
|
? (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Input name="nameInput" label="Name" value={displayName} required />
|
||||||
|
<Input name="attributionInput" label="Attribution" value={attribution} resizable />
|
||||||
|
<div>
|
||||||
|
<Button variant="primary" type="submit">Save</Button>
|
||||||
|
<Button onClick={() => setIsEdit(false)}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Text>{displayName}</Text>
|
||||||
|
{onEditProfile && <IconButton size="extra-small" onClick={() => setIsEdit(true)} src={PencilIC} tooltip="Edit" />}
|
||||||
|
</div>
|
||||||
|
{attribution && <Text variant="b3">{attribution}</Text>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="image-pack-profile__usage">
|
||||||
|
<Text variant="b3">Pack usage</Text>
|
||||||
|
<Button
|
||||||
|
onClick={onUsageChange ? handleUsageSelect : undefined}
|
||||||
|
iconSrc={onUsageChange ? ChevronBottomIC : null}
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
{usage === 'emoticon' && 'Emoji'}
|
||||||
|
{usage === 'sticker' && 'Sticker'}
|
||||||
|
{usage === 'both' && 'Both'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImagePackProfile.defaultProps = {
|
||||||
|
avatarUrl: null,
|
||||||
|
attribution: null,
|
||||||
|
onUsageChange: null,
|
||||||
|
onAvatarChange: null,
|
||||||
|
onEditProfile: null,
|
||||||
|
};
|
||||||
|
ImagePackProfile.propTypes = {
|
||||||
|
avatarUrl: PropTypes.string,
|
||||||
|
displayName: PropTypes.string.isRequired,
|
||||||
|
attribution: PropTypes.string,
|
||||||
|
usage: PropTypes.oneOf(['emoticon', 'sticker', 'both']).isRequired,
|
||||||
|
onUsageChange: PropTypes.func,
|
||||||
|
onAvatarChange: PropTypes.func,
|
||||||
|
onEditProfile: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImagePackProfile;
|
||||||
37
src/app/molecules/image-pack/ImagePackProfile.scss
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
@use '../../partials/flex';
|
||||||
|
|
||||||
|
.image-pack-profile {
|
||||||
|
padding: var(--sp-normal);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--sp-tight);
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
@extend .cp-fx__item-one;
|
||||||
|
|
||||||
|
& > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-extra-tight);
|
||||||
|
|
||||||
|
& .ic-btn {
|
||||||
|
padding: var(--sp-ultra-tight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& > form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-extra-tight);
|
||||||
|
& > div:last-child {
|
||||||
|
margin: var(--sp-extra-tight) 0;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-tight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__usage {
|
||||||
|
& > *:first-child {
|
||||||
|
margin-bottom: var(--sp-ultra-tight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/molecules/image-pack/ImagePackUpload.jsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './ImagePackUpload.scss';
|
||||||
|
|
||||||
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import { scaleDownImage } from '../../../util/common';
|
||||||
|
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import Button from '../../atoms/button/Button';
|
||||||
|
import Input from '../../atoms/input/Input';
|
||||||
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
|
import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg';
|
||||||
|
|
||||||
|
function ImagePackUpload({ onUpload }) {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const shortcodeRef = useRef(null);
|
||||||
|
const [imgFile, setImgFile] = useState(null);
|
||||||
|
const [progress, setProgress] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (!imgFile) return;
|
||||||
|
const { shortcodeInput } = evt.target;
|
||||||
|
const shortcode = shortcodeInput.value.trim();
|
||||||
|
if (shortcode === '') return;
|
||||||
|
|
||||||
|
setProgress(true);
|
||||||
|
const image = await scaleDownImage(imgFile, 512, 512);
|
||||||
|
const url = await mx.uploadContent(image, {
|
||||||
|
onlyContentUri: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
onUpload(shortcode, url);
|
||||||
|
setProgress(false);
|
||||||
|
setImgFile(null);
|
||||||
|
shortcodeRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (evt) => {
|
||||||
|
const img = evt.target.files[0];
|
||||||
|
if (!img) return;
|
||||||
|
setImgFile(img);
|
||||||
|
shortcodeRef.current.value = img.name.slice(0, img.name.indexOf('.'));
|
||||||
|
shortcodeRef.current.focus();
|
||||||
|
};
|
||||||
|
const handleRemove = () => {
|
||||||
|
setImgFile(null);
|
||||||
|
inputRef.current.value = null;
|
||||||
|
shortcodeRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="image-pack-upload">
|
||||||
|
<input ref={inputRef} onChange={handleFileChange} style={{ display: 'none' }} type="file" accept=".png, .gif, .webp" required />
|
||||||
|
{
|
||||||
|
imgFile
|
||||||
|
? (
|
||||||
|
<div className="image-pack-upload__file">
|
||||||
|
<IconButton onClick={handleRemove} src={CirclePlusIC} tooltip="Remove file" />
|
||||||
|
<Text>{imgFile.name}</Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: <Button onClick={() => inputRef.current.click()}>Import image</Button>
|
||||||
|
}
|
||||||
|
<Input forwardRef={shortcodeRef} name="shortcodeInput" placeholder="shortcode" required />
|
||||||
|
<Button disabled={progress} variant="primary" type="submit">{progress ? 'Uploading...' : 'Upload'}</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ImagePackUpload.propTypes = {
|
||||||
|
onUpload: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImagePackUpload;
|
||||||
43
src/app/molecules/image-pack/ImagePackUpload.scss
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
@use '../../partials/dir';
|
||||||
|
@use '../../partials/text';
|
||||||
|
|
||||||
|
.image-pack-upload {
|
||||||
|
padding: var(--sp-normal);
|
||||||
|
padding-top: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-tight);
|
||||||
|
|
||||||
|
& > .input-container {
|
||||||
|
flex-grow: 1;
|
||||||
|
input {
|
||||||
|
padding: 9px var(--sp-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__file {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--bg-surface-low);
|
||||||
|
border-radius: var(--bo-radius);
|
||||||
|
box-shadow: var(--bs-surface-border);
|
||||||
|
|
||||||
|
& button {
|
||||||
|
--parent-height: 40px;
|
||||||
|
width: var(--parent-height);
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .ic-raw {
|
||||||
|
background-color: var(--bg-caution);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .text {
|
||||||
|
@extend .cp-txt__ellipsis;
|
||||||
|
@include dir.side(margin, var(--sp-ultra-tight), var(--sp-normal));
|
||||||
|
max-width: 86px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/app/molecules/image-pack/ImagePackUsageSelector.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
|
||||||
|
import CheckIC from '../../../../public/res/ic/outlined/check.svg';
|
||||||
|
|
||||||
|
function ImagePackUsageSelector({ usage, onSelect }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<MenuHeader>Usage</MenuHeader>
|
||||||
|
<MenuItem
|
||||||
|
iconSrc={usage === 'emoticon' ? CheckIC : undefined}
|
||||||
|
variant={usage === 'emoticon' ? 'positive' : 'surface'}
|
||||||
|
onClick={() => onSelect('emoticon')}
|
||||||
|
>
|
||||||
|
Emoji
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
iconSrc={usage === 'sticker' ? CheckIC : undefined}
|
||||||
|
variant={usage === 'sticker' ? 'positive' : 'surface'}
|
||||||
|
onClick={() => onSelect('sticker')}
|
||||||
|
>
|
||||||
|
Sticker
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
iconSrc={usage === 'both' ? CheckIC : undefined}
|
||||||
|
variant={usage === 'both' ? 'positive' : 'surface'}
|
||||||
|
onClick={() => onSelect('both')}
|
||||||
|
>
|
||||||
|
Both
|
||||||
|
</MenuItem>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImagePackUsageSelector.propTypes = {
|
||||||
|
usage: PropTypes.oneOf(['emoticon', 'sticker', 'both']).isRequired,
|
||||||
|
onSelect: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImagePackUsageSelector;
|
||||||
@@ -7,9 +7,13 @@ import initMatrix from '../../../client/initMatrix';
|
|||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Avatar from '../../atoms/avatar/Avatar';
|
import Avatar from '../../atoms/avatar/Avatar';
|
||||||
import Spinner from '../../atoms/spinner/Spinner';
|
import Spinner from '../../atoms/spinner/Spinner';
|
||||||
|
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||||
|
|
||||||
|
import PlusIC from '../../../../public/res/ic/outlined/plus.svg';
|
||||||
|
|
||||||
function ImageUpload({
|
function ImageUpload({
|
||||||
text, bgColor, imageSrc, onUpload, onRequestRemove,
|
text, bgColor, imageSrc, onUpload, onRequestRemove,
|
||||||
|
size,
|
||||||
}) {
|
}) {
|
||||||
const [uploadPromise, setUploadPromise] = useState(null);
|
const [uploadPromise, setUploadPromise] = useState(null);
|
||||||
const uploadImageRef = useRef(null);
|
const uploadImageRef = useRef(null);
|
||||||
@@ -50,10 +54,14 @@ function ImageUpload({
|
|||||||
imageSrc={imageSrc}
|
imageSrc={imageSrc}
|
||||||
text={text}
|
text={text}
|
||||||
bgColor={bgColor}
|
bgColor={bgColor}
|
||||||
size="large"
|
size={size}
|
||||||
/>
|
/>
|
||||||
<div className={`img-upload__process ${uploadPromise === null ? ' img-upload__process--stopped' : ''}`}>
|
<div className={`img-upload__process ${uploadPromise === null ? ' img-upload__process--stopped' : ''}`}>
|
||||||
{uploadPromise === null && <Text variant="b3" weight="bold">Upload</Text>}
|
{uploadPromise === null && (
|
||||||
|
size === 'large'
|
||||||
|
? <Text variant="b3" weight="bold">Upload</Text>
|
||||||
|
: <RawIcon src={PlusIC} color="white" />
|
||||||
|
)}
|
||||||
{uploadPromise !== null && <Spinner size="small" />}
|
{uploadPromise !== null && <Spinner size="small" />}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -75,6 +83,7 @@ ImageUpload.defaultProps = {
|
|||||||
text: null,
|
text: null,
|
||||||
bgColor: 'transparent',
|
bgColor: 'transparent',
|
||||||
imageSrc: null,
|
imageSrc: null,
|
||||||
|
size: 'large',
|
||||||
};
|
};
|
||||||
|
|
||||||
ImageUpload.propTypes = {
|
ImageUpload.propTypes = {
|
||||||
@@ -83,6 +92,7 @@ ImageUpload.propTypes = {
|
|||||||
imageSrc: PropTypes.string,
|
imageSrc: PropTypes.string,
|
||||||
onUpload: PropTypes.func.isRequired,
|
onUpload: PropTypes.func.isRequired,
|
||||||
onRequestRemove: PropTypes.func.isRequired,
|
onRequestRemove: PropTypes.func.isRequired,
|
||||||
|
size: PropTypes.oneOf(['large', 'normal']),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ImageUpload;
|
export default ImageUpload;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import './Media.scss';
|
|||||||
|
|
||||||
import encrypt from 'browser-encrypt-attachment';
|
import encrypt from 'browser-encrypt-attachment';
|
||||||
|
|
||||||
|
import { BlurhashCanvas } from 'react-blurhash';
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
import Spinner from '../../atoms/spinner/Spinner';
|
import Spinner from '../../atoms/spinner/Spinner';
|
||||||
@@ -12,15 +13,19 @@ import DownloadSVG from '../../../../public/res/ic/outlined/download.svg';
|
|||||||
import ExternalSVG from '../../../../public/res/ic/outlined/external.svg';
|
import ExternalSVG from '../../../../public/res/ic/outlined/external.svg';
|
||||||
import PlaySVG from '../../../../public/res/ic/outlined/play.svg';
|
import PlaySVG from '../../../../public/res/ic/outlined/play.svg';
|
||||||
|
|
||||||
// https://github.com/matrix-org/matrix-react-sdk/blob/a9e28db33058d1893d964ec96cd247ecc3d92fc3/src/utils/blobs.ts#L73
|
// https://github.com/matrix-org/matrix-react-sdk/blob/cd15e08fc285da42134817cce50de8011809cd53/src/utils/blobs.ts#L73
|
||||||
const ALLOWED_BLOB_MIMETYPES = [
|
const ALLOWED_BLOB_MIMETYPES = [
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/gif',
|
'image/gif',
|
||||||
'image/png',
|
'image/png',
|
||||||
|
'image/apng',
|
||||||
|
'image/webp',
|
||||||
|
'image/avif',
|
||||||
|
|
||||||
'video/mp4',
|
'video/mp4',
|
||||||
'video/webm',
|
'video/webm',
|
||||||
'video/ogg',
|
'video/ogg',
|
||||||
|
'video/quicktime',
|
||||||
|
|
||||||
'audio/mp4',
|
'audio/mp4',
|
||||||
'audio/webm',
|
'audio/webm',
|
||||||
@@ -38,6 +43,10 @@ function getBlobSafeMimeType(mimetype) {
|
|||||||
if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) {
|
if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) {
|
||||||
return 'application/octet-stream';
|
return 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
// Required for Chromium browsers
|
||||||
|
if (mimetype === 'video/quicktime') {
|
||||||
|
return 'video/mp4';
|
||||||
|
}
|
||||||
return mimetype;
|
return mimetype;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,9 +70,8 @@ async function getUrl(link, type, decryptData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNativeHeight(width, height) {
|
function getNativeHeight(width, height, maxWidth = 296) {
|
||||||
const MEDIA_MAX_WIDTH = 296;
|
const scale = maxWidth / width;
|
||||||
const scale = MEDIA_MAX_WIDTH / width;
|
|
||||||
return scale * height;
|
return scale * height;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,9 +155,10 @@ File.propTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function Image({
|
function Image({
|
||||||
name, width, height, link, file, type,
|
name, width, height, link, file, type, blurhash,
|
||||||
}) {
|
}) {
|
||||||
const [url, setUrl] = useState(null);
|
const [url, setUrl] = useState(null);
|
||||||
|
const [blur, setBlur] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unmounted = false;
|
let unmounted = false;
|
||||||
@@ -168,7 +177,8 @@ function Image({
|
|||||||
<div className="file-container">
|
<div className="file-container">
|
||||||
<FileHeader name={name} link={url || link} type={type} external />
|
<FileHeader name={name} link={url || link} type={type} external />
|
||||||
<div style={{ height: width !== null ? getNativeHeight(width, height) : 'unset' }} className="image-container">
|
<div style={{ height: width !== null ? getNativeHeight(width, height) : 'unset' }} className="image-container">
|
||||||
{ url !== null && <img src={url || link} alt={name} />}
|
{ blurhash && blur && <BlurhashCanvas hash={blurhash} punch={1} />}
|
||||||
|
{ url !== null && <img style={{ display: blur ? 'none' : 'unset' }} onLoad={() => setBlur(false)} src={url || link} alt={name} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -178,6 +188,7 @@ Image.defaultProps = {
|
|||||||
width: null,
|
width: null,
|
||||||
height: null,
|
height: null,
|
||||||
type: '',
|
type: '',
|
||||||
|
blurhash: '',
|
||||||
};
|
};
|
||||||
Image.propTypes = {
|
Image.propTypes = {
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
@@ -186,6 +197,46 @@ Image.propTypes = {
|
|||||||
link: PropTypes.string.isRequired,
|
link: PropTypes.string.isRequired,
|
||||||
file: PropTypes.shape({}),
|
file: PropTypes.shape({}),
|
||||||
type: PropTypes.string,
|
type: PropTypes.string,
|
||||||
|
blurhash: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Sticker({
|
||||||
|
name, height, width, link, file, type,
|
||||||
|
}) {
|
||||||
|
const [url, setUrl] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unmounted = false;
|
||||||
|
async function fetchUrl() {
|
||||||
|
const myUrl = await getUrl(link, type, file);
|
||||||
|
if (unmounted) return;
|
||||||
|
setUrl(myUrl);
|
||||||
|
}
|
||||||
|
fetchUrl();
|
||||||
|
return () => {
|
||||||
|
unmounted = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticker-container" style={{ height: width !== null ? getNativeHeight(width, height, 128) : 'unset' }}>
|
||||||
|
{ url !== null && <img src={url || link} title={name} alt={name} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Sticker.defaultProps = {
|
||||||
|
file: null,
|
||||||
|
type: '',
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
};
|
||||||
|
Sticker.propTypes = {
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
width: PropTypes.number,
|
||||||
|
height: PropTypes.number,
|
||||||
|
link: PropTypes.string.isRequired,
|
||||||
|
file: PropTypes.shape({}),
|
||||||
|
type: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
function Audio({
|
function Audio({
|
||||||
@@ -232,12 +283,13 @@ Audio.propTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function Video({
|
function Video({
|
||||||
name, link, thumbnail,
|
name, link, thumbnail, thumbnailFile, thumbnailType,
|
||||||
width, height, file, type, thumbnailFile, thumbnailType,
|
width, height, file, type, blurhash,
|
||||||
}) {
|
}) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [url, setUrl] = useState(null);
|
const [url, setUrl] = useState(null);
|
||||||
const [thumbUrl, setThumbUrl] = useState(null);
|
const [thumbUrl, setThumbUrl] = useState(null);
|
||||||
|
const [blur, setBlur] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unmounted = false;
|
let unmounted = false;
|
||||||
@@ -252,16 +304,16 @@ function Video({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function loadVideo() {
|
const loadVideo = async () => {
|
||||||
const myUrl = await getUrl(link, type, file);
|
const myUrl = await getUrl(link, type, file);
|
||||||
setUrl(myUrl);
|
setUrl(myUrl);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
function handlePlayVideo() {
|
const handlePlayVideo = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
loadVideo();
|
loadVideo();
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="file-container">
|
<div className="file-container">
|
||||||
@@ -269,14 +321,20 @@ function Video({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: width !== null ? getNativeHeight(width, height) : 'unset',
|
height: width !== null ? getNativeHeight(width, height) : 'unset',
|
||||||
backgroundImage: thumbUrl === null ? 'none' : `url(${thumbUrl}`,
|
|
||||||
}}
|
}}
|
||||||
className="video-container"
|
className="video-container"
|
||||||
>
|
>
|
||||||
{ url === null && isLoading && <Spinner size="small" /> }
|
{ url === null ? (
|
||||||
{ url === null && !isLoading && <IconButton onClick={handlePlayVideo} tooltip="Play video" src={PlaySVG} />}
|
<>
|
||||||
{ url !== null && (
|
{ blurhash && blur && <BlurhashCanvas hash={blurhash} punch={1} />}
|
||||||
/* eslint-disable-next-line jsx-a11y/media-has-caption */
|
{ thumbUrl !== null && (
|
||||||
|
<img style={{ display: blur ? 'none' : 'unset' }} src={thumbUrl} onLoad={() => setBlur(false)} alt={name} />
|
||||||
|
)}
|
||||||
|
{isLoading && <Spinner size="small" />}
|
||||||
|
{!isLoading && <IconButton onClick={handlePlayVideo} tooltip="Play video" src={PlaySVG} />}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* eslint-disable-next-line jsx-a11y/media-has-caption */
|
||||||
<video autoPlay controls poster={thumbUrl}>
|
<video autoPlay controls poster={thumbUrl}>
|
||||||
<source src={url} type={getBlobSafeMimeType(type)} />
|
<source src={url} type={getBlobSafeMimeType(type)} />
|
||||||
</video>
|
</video>
|
||||||
@@ -290,22 +348,24 @@ Video.defaultProps = {
|
|||||||
height: null,
|
height: null,
|
||||||
file: null,
|
file: null,
|
||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
type: '',
|
|
||||||
thumbnailType: null,
|
thumbnailType: null,
|
||||||
thumbnailFile: null,
|
thumbnailFile: null,
|
||||||
|
type: '',
|
||||||
|
blurhash: null,
|
||||||
};
|
};
|
||||||
Video.propTypes = {
|
Video.propTypes = {
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
link: PropTypes.string.isRequired,
|
link: PropTypes.string.isRequired,
|
||||||
thumbnail: PropTypes.string,
|
thumbnail: PropTypes.string,
|
||||||
|
thumbnailFile: PropTypes.shape({}),
|
||||||
|
thumbnailType: PropTypes.string,
|
||||||
width: PropTypes.number,
|
width: PropTypes.number,
|
||||||
height: PropTypes.number,
|
height: PropTypes.number,
|
||||||
file: PropTypes.shape({}),
|
file: PropTypes.shape({}),
|
||||||
type: PropTypes.string,
|
type: PropTypes.string,
|
||||||
thumbnailFile: PropTypes.shape({}),
|
blurhash: PropTypes.string,
|
||||||
thumbnailType: PropTypes.string,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
File, Image, Audio, Video,
|
File, Image, Sticker, Audio, Video,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,15 @@
|
|||||||
white-space: initial;
|
white-space: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sticker-container {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 128px;
|
||||||
|
width: 100%;
|
||||||
|
& img {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.image-container,
|
.image-container,
|
||||||
.video-container,
|
.video-container,
|
||||||
.audio-container {
|
.audio-container {
|
||||||
@@ -42,25 +51,33 @@
|
|||||||
background-size: cover;
|
background-size: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-container {
|
.image-container,
|
||||||
& img {
|
.video-container {
|
||||||
|
& img,
|
||||||
|
& canvas {
|
||||||
max-width: unset !important;
|
max-width: unset !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
height: 100%;
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-container {
|
.video-container {
|
||||||
|
position: relative;
|
||||||
& .ic-btn-surface {
|
& .ic-btn-surface {
|
||||||
background-color: var(--bg-surface-low);
|
background-color: var(--bg-surface-low);
|
||||||
}
|
}
|
||||||
|
& .ic-btn-surface,
|
||||||
|
& .donut-spinner {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
video {
|
video {
|
||||||
width: 100%
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.audio-container {
|
.audio-container {
|
||||||
audio {
|
audio {
|
||||||
width: 100%
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,6 @@ import React, {
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './Message.scss';
|
import './Message.scss';
|
||||||
|
|
||||||
import { getShortcodeToCustomEmoji } from '../../organisms/emoji-board/custom-emoji';
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
import { twemojify } from '../../../util/twemojify';
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
@@ -25,6 +24,7 @@ import Tooltip from '../../atoms/tooltip/Tooltip';
|
|||||||
import Input from '../../atoms/input/Input';
|
import Input from '../../atoms/input/Input';
|
||||||
import Avatar from '../../atoms/avatar/Avatar';
|
import Avatar from '../../atoms/avatar/Avatar';
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
|
import Time from '../../atoms/time/Time';
|
||||||
import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu';
|
import ContextMenu, { MenuHeader, MenuItem, MenuBorder } from '../../atoms/context-menu/ContextMenu';
|
||||||
import * as Media from '../media/Media';
|
import * as Media from '../media/Media';
|
||||||
|
|
||||||
@@ -36,6 +36,8 @@ import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
|||||||
import CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
|
import CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
|
||||||
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
||||||
|
|
||||||
|
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||||
|
|
||||||
function PlaceholderMessage() {
|
function PlaceholderMessage() {
|
||||||
return (
|
return (
|
||||||
<div className="ph-msg">
|
<div className="ph-msg">
|
||||||
@@ -66,7 +68,7 @@ const MessageAvatar = React.memo(({
|
|||||||
));
|
));
|
||||||
|
|
||||||
const MessageHeader = React.memo(({
|
const MessageHeader = React.memo(({
|
||||||
userId, username, time,
|
userId, username, timestamp, fullTime,
|
||||||
}) => (
|
}) => (
|
||||||
<div className="message__header">
|
<div className="message__header">
|
||||||
<Text
|
<Text
|
||||||
@@ -80,14 +82,20 @@ const MessageHeader = React.memo(({
|
|||||||
<span>{twemojify(userId)}</span>
|
<span>{twemojify(userId)}</span>
|
||||||
</Text>
|
</Text>
|
||||||
<div className="message__time">
|
<div className="message__time">
|
||||||
<Text variant="b3">{time}</Text>
|
<Text variant="b3">
|
||||||
|
<Time timestamp={timestamp} fullTime={fullTime} />
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
|
MessageHeader.defaultProps = {
|
||||||
|
fullTime: false,
|
||||||
|
};
|
||||||
MessageHeader.propTypes = {
|
MessageHeader.propTypes = {
|
||||||
userId: PropTypes.string.isRequired,
|
userId: PropTypes.string.isRequired,
|
||||||
username: PropTypes.string.isRequired,
|
username: PropTypes.string.isRequired,
|
||||||
time: PropTypes.string.isRequired,
|
timestamp: PropTypes.number.isRequired,
|
||||||
|
fullTime: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
function MessageReply({ name, color, body }) {
|
function MessageReply({ name, color, body }) {
|
||||||
@@ -121,17 +129,26 @@ const MessageReplyWrapper = React.memo(({ roomTimeline, eventId }) => {
|
|||||||
const eTimeline = await mx.getEventTimeline(timelineSet, eventId);
|
const eTimeline = await mx.getEventTimeline(timelineSet, eventId);
|
||||||
await roomTimeline.decryptAllEventsOfTimeline(eTimeline);
|
await roomTimeline.decryptAllEventsOfTimeline(eTimeline);
|
||||||
|
|
||||||
const mEvent = eTimeline.getTimelineSet().findEventById(eventId);
|
let mEvent = eTimeline.getTimelineSet().findEventById(eventId);
|
||||||
|
const editedList = roomTimeline.editedTimeline.get(mEvent.getId());
|
||||||
|
if (editedList) {
|
||||||
|
mEvent = editedList[editedList.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
const rawBody = mEvent.getContent().body;
|
const rawBody = mEvent.getContent().body;
|
||||||
const username = getUsernameOfRoomMember(mEvent.sender);
|
const username = getUsernameOfRoomMember(mEvent.sender);
|
||||||
|
|
||||||
if (isMountedRef.current === false) return;
|
if (isMountedRef.current === false) return;
|
||||||
const fallbackBody = mEvent.isRedacted() ? '*** This message has been deleted ***' : '*** Unable to load reply ***';
|
const fallbackBody = mEvent.isRedacted() ? '*** This message has been deleted ***' : '*** Unable to load reply ***';
|
||||||
|
let parsedBody = parseReply(rawBody)?.body ?? rawBody ?? fallbackBody;
|
||||||
|
if (editedList && parsedBody.startsWith(' * ')) {
|
||||||
|
parsedBody = parsedBody.slice(3);
|
||||||
|
}
|
||||||
|
|
||||||
setReply({
|
setReply({
|
||||||
to: username,
|
to: username,
|
||||||
color: colorMXID(mEvent.getSender()),
|
color: colorMXID(mEvent.getSender()),
|
||||||
body: parseReply(rawBody)?.body ?? rawBody ?? fallbackBody,
|
body: parsedBody,
|
||||||
event: mEvent,
|
event: mEvent,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -151,8 +168,8 @@ const MessageReplyWrapper = React.memo(({ roomTimeline, eventId }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const focusReply = (ev) => {
|
const focusReply = (ev) => {
|
||||||
if (!ev.keyCode || ev.keyCode === 32 || ev.keyCode === 13) {
|
if (!ev.key || ev.key === ' ' || ev.key === 'Enter') {
|
||||||
if (ev.keyCode) ev.preventDefault();
|
if (ev.key) ev.preventDefault();
|
||||||
if (reply?.event === null) return;
|
if (reply?.event === null) return;
|
||||||
if (reply?.event.isRedacted()) return;
|
if (reply?.event.isRedacted()) return;
|
||||||
roomTimeline.loadEventTimeline(eventId);
|
roomTimeline.loadEventTimeline(eventId);
|
||||||
@@ -186,9 +203,23 @@ const MessageBody = React.memo(({
|
|||||||
// if body is not string it is a React element.
|
// if body is not string it is a React element.
|
||||||
if (typeof body !== 'string') return <div className="message__body">{body}</div>;
|
if (typeof body !== 'string') return <div className="message__body">{body}</div>;
|
||||||
|
|
||||||
let content = isCustomHTML
|
let content = null;
|
||||||
? twemojify(sanitizeCustomHtml(body), undefined, true, false)
|
if (isCustomHTML) {
|
||||||
: twemojify(body, undefined, true);
|
try {
|
||||||
|
content = twemojify(
|
||||||
|
sanitizeCustomHtml(initMatrix.matrixClient, body),
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
console.error('Malformed custom html: ', body);
|
||||||
|
content = twemojify(body, undefined);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content = twemojify(body, undefined, true);
|
||||||
|
}
|
||||||
|
|
||||||
// Determine if this message should render with large emojis
|
// Determine if this message should render with large emojis
|
||||||
// Criteria:
|
// Criteria:
|
||||||
@@ -221,7 +252,7 @@ const MessageBody = React.memo(({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message__body">
|
<div className="message__body">
|
||||||
<div className={`text ${emojiOnly ? 'text-h1' : 'text-b1'}`}>
|
<div dir="auto" className={`text ${emojiOnly ? 'text-h1' : 'text-b1'}`}>
|
||||||
{ msgType === 'm.emote' && (
|
{ msgType === 'm.emote' && (
|
||||||
<>
|
<>
|
||||||
{'* '}
|
{'* '}
|
||||||
@@ -258,7 +289,7 @@ function MessageEdit({ body, onSave, onCancel }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
if (e.keyCode === 13 && e.shiftKey === false) {
|
if (e.key === 'Enter' && e.shiftKey === false) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSave(editInputRef.current.value);
|
onSave(editInputRef.current.value);
|
||||||
}
|
}
|
||||||
@@ -303,7 +334,7 @@ function getMyEmojiEvent(emojiKey, eventId, roomTimeline) {
|
|||||||
return rEvent;
|
return rEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
|
function toggleEmoji(roomId, eventId, emojiKey, shortcode, roomTimeline) {
|
||||||
const myAlreadyReactEvent = getMyEmojiEvent(emojiKey, eventId, roomTimeline);
|
const myAlreadyReactEvent = getMyEmojiEvent(emojiKey, eventId, roomTimeline);
|
||||||
if (myAlreadyReactEvent) {
|
if (myAlreadyReactEvent) {
|
||||||
const rId = myAlreadyReactEvent.getId();
|
const rId = myAlreadyReactEvent.getId();
|
||||||
@@ -311,17 +342,17 @@ function toggleEmoji(roomId, eventId, emojiKey, roomTimeline) {
|
|||||||
redactEvent(roomId, rId);
|
redactEvent(roomId, rId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendReaction(roomId, eventId, emojiKey);
|
sendReaction(roomId, eventId, emojiKey, shortcode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickEmoji(e, roomId, eventId, roomTimeline) {
|
function pickEmoji(e, roomId, eventId, roomTimeline) {
|
||||||
openEmojiBoard(getEventCords(e), (emoji) => {
|
openEmojiBoard(getEventCords(e), (emoji) => {
|
||||||
toggleEmoji(roomId, eventId, emoji.unicode, roomTimeline);
|
toggleEmoji(roomId, eventId, emoji.mxc ?? emoji.unicode, emoji.shortcodes[0], roomTimeline);
|
||||||
e.target.click();
|
e.target.click();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function genReactionMsg(userIds, reaction) {
|
function genReactionMsg(userIds, reaction, shortcode) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{userIds.map((userId, index) => (
|
{userIds.map((userId, index) => (
|
||||||
@@ -335,24 +366,22 @@ function genReactionMsg(userIds, reaction) {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
<span style={{ opacity: '.6' }}>{' reacted with '}</span>
|
<span style={{ opacity: '.6' }}>{' reacted with '}</span>
|
||||||
{twemojify(reaction, { className: 'react-emoji' })}
|
{twemojify(shortcode ? `:${shortcode}:` : reaction, { className: 'react-emoji' })}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageReaction({
|
function MessageReaction({
|
||||||
shortcodeToEmoji, reaction, count, users, isActive, onClick,
|
reaction, shortcode, count, users, isActive, onClick,
|
||||||
}) {
|
}) {
|
||||||
const customEmojiMatch = reaction.match(/^:(\S+):$/);
|
|
||||||
let customEmojiUrl = null;
|
let customEmojiUrl = null;
|
||||||
if (customEmojiMatch) {
|
if (reaction.match(/^mxc:\/\/\S+$/)) {
|
||||||
const customEmoji = shortcodeToEmoji.get(customEmojiMatch[1]);
|
customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(reaction);
|
||||||
customEmojiUrl = initMatrix.matrixClient.mxcUrlToHttp(customEmoji?.mxc);
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
className="msg__reaction-tooltip"
|
className="msg__reaction-tooltip"
|
||||||
content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction) : 'Unable to load who has reacted'}</Text>}
|
content={<Text variant="b2">{users.length > 0 ? genReactionMsg(users, reaction, shortcode) : 'Unable to load who has reacted'}</Text>}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -361,7 +390,7 @@ function MessageReaction({
|
|||||||
>
|
>
|
||||||
{
|
{
|
||||||
customEmojiUrl
|
customEmojiUrl
|
||||||
? <img className="react-emoji" draggable="false" alt={reaction} src={customEmojiUrl} />
|
? <img className="react-emoji" draggable="false" alt={shortcode ?? reaction} src={customEmojiUrl} />
|
||||||
: twemojify(reaction, { className: 'react-emoji' })
|
: twemojify(reaction, { className: 'react-emoji' })
|
||||||
}
|
}
|
||||||
<Text variant="b3" className="msg__reaction-count">{count}</Text>
|
<Text variant="b3" className="msg__reaction-count">{count}</Text>
|
||||||
@@ -369,9 +398,12 @@ function MessageReaction({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
MessageReaction.defaultProps = {
|
||||||
|
shortcode: undefined,
|
||||||
|
};
|
||||||
MessageReaction.propTypes = {
|
MessageReaction.propTypes = {
|
||||||
shortcodeToEmoji: PropTypes.shape({}).isRequired,
|
|
||||||
reaction: PropTypes.node.isRequired,
|
reaction: PropTypes.node.isRequired,
|
||||||
|
shortcode: PropTypes.string,
|
||||||
count: PropTypes.number.isRequired,
|
count: PropTypes.number.isRequired,
|
||||||
users: PropTypes.arrayOf(PropTypes.string).isRequired,
|
users: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||||
isActive: PropTypes.bool.isRequired,
|
isActive: PropTypes.bool.isRequired,
|
||||||
@@ -382,11 +414,10 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
|
|||||||
const { roomId, room, reactionTimeline } = roomTimeline;
|
const { roomId, room, reactionTimeline } = roomTimeline;
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const reactions = {};
|
const reactions = {};
|
||||||
const shortcodeToEmoji = getShortcodeToCustomEmoji(room);
|
|
||||||
const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId());
|
const canSendReaction = room.currentState.maySendEvent('m.reaction', mx.getUserId());
|
||||||
|
|
||||||
const eventReactions = reactionTimeline.get(mEvent.getId());
|
const eventReactions = reactionTimeline.get(mEvent.getId());
|
||||||
const addReaction = (key, count, senderId, isActive) => {
|
const addReaction = (key, shortcode, count, senderId, isActive) => {
|
||||||
let reaction = reactions[key];
|
let reaction = reactions[key];
|
||||||
if (reaction === undefined) {
|
if (reaction === undefined) {
|
||||||
reaction = {
|
reaction = {
|
||||||
@@ -395,6 +426,7 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
|
|||||||
isActive: false,
|
isActive: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (shortcode) reaction.shortcode = shortcode;
|
||||||
if (count) {
|
if (count) {
|
||||||
reaction.count = count;
|
reaction.count = count;
|
||||||
} else {
|
} else {
|
||||||
@@ -410,9 +442,10 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
|
|||||||
if (rEvent.getRelation() === null) return;
|
if (rEvent.getRelation() === null) return;
|
||||||
const reaction = rEvent.getRelation();
|
const reaction = rEvent.getRelation();
|
||||||
const senderId = rEvent.getSender();
|
const senderId = rEvent.getSender();
|
||||||
|
const { shortcode } = rEvent.getContent();
|
||||||
const isActive = senderId === mx.getUserId();
|
const isActive = senderId === mx.getUserId();
|
||||||
|
|
||||||
addReaction(reaction.key, undefined, senderId, isActive);
|
addReaction(reaction.key, shortcode, undefined, senderId, isActive);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Use aggregated reactions
|
// Use aggregated reactions
|
||||||
@@ -420,7 +453,7 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
|
|||||||
if (!aggregatedReaction) return null;
|
if (!aggregatedReaction) return null;
|
||||||
aggregatedReaction.forEach((reaction) => {
|
aggregatedReaction.forEach((reaction) => {
|
||||||
if (reaction.type !== 'm.reaction') return;
|
if (reaction.type !== 'm.reaction') return;
|
||||||
addReaction(reaction.key, reaction.count, undefined, false);
|
addReaction(reaction.key, undefined, reaction.count, undefined, false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,13 +463,13 @@ function MessageReactionGroup({ roomTimeline, mEvent }) {
|
|||||||
Object.keys(reactions).map((key) => (
|
Object.keys(reactions).map((key) => (
|
||||||
<MessageReaction
|
<MessageReaction
|
||||||
key={key}
|
key={key}
|
||||||
shortcodeToEmoji={shortcodeToEmoji}
|
|
||||||
reaction={key}
|
reaction={key}
|
||||||
|
shortcode={reactions[key].shortcode}
|
||||||
count={reactions[key].count}
|
count={reactions[key].count}
|
||||||
users={reactions[key].users}
|
users={reactions[key].users}
|
||||||
isActive={reactions[key].isActive}
|
isActive={reactions[key].isActive}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
toggleEmoji(roomId, mEvent.getId(), key, roomTimeline);
|
toggleEmoji(roomId, mEvent.getId(), key, reactions[key].shortcode, roomTimeline);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -538,10 +571,15 @@ const MessageOptions = React.memo(({
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
variant="danger"
|
variant="danger"
|
||||||
iconSrc={BinIC}
|
iconSrc={BinIC}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (window.confirm('Are you sure you want to delete this event')) {
|
const isConfirmed = await confirmDialog(
|
||||||
redactEvent(roomId, mEvent.getId());
|
'Delete message',
|
||||||
}
|
'Are you sure that you want to delete this message?',
|
||||||
|
'Delete',
|
||||||
|
'danger',
|
||||||
|
);
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
redactEvent(roomId, mEvent.getId());
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -583,7 +621,9 @@ function genMediaContent(mE) {
|
|||||||
if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
|
if (typeof mediaMXC === 'undefined' || mediaMXC === '') return <span style={{ color: 'var(--bg-danger)' }}>Malformed event</span>;
|
||||||
|
|
||||||
let msgType = mE.getContent()?.msgtype;
|
let msgType = mE.getContent()?.msgtype;
|
||||||
if (mE.getType() === 'm.sticker') msgType = 'm.image';
|
if (mE.getType() === 'm.sticker') msgType = 'm.sticker';
|
||||||
|
|
||||||
|
const blurhash = mContent?.info?.['xyz.amorgan.blurhash'];
|
||||||
|
|
||||||
switch (msgType) {
|
switch (msgType) {
|
||||||
case 'm.file':
|
case 'm.file':
|
||||||
@@ -604,6 +644,18 @@ function genMediaContent(mE) {
|
|||||||
link={mx.mxcUrlToHttp(mediaMXC)}
|
link={mx.mxcUrlToHttp(mediaMXC)}
|
||||||
file={isEncryptedFile ? mContent.file : null}
|
file={isEncryptedFile ? mContent.file : null}
|
||||||
type={mContent.info?.mimetype}
|
type={mContent.info?.mimetype}
|
||||||
|
blurhash={blurhash}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'm.sticker':
|
||||||
|
return (
|
||||||
|
<Media.Sticker
|
||||||
|
name={mContent.body}
|
||||||
|
width={typeof mContent.info?.w === 'number' ? mContent.info?.w : null}
|
||||||
|
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
|
||||||
|
link={mx.mxcUrlToHttp(mediaMXC)}
|
||||||
|
file={isEncryptedFile ? mContent.file : null}
|
||||||
|
type={mContent.info?.mimetype}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'm.audio':
|
case 'm.audio':
|
||||||
@@ -630,6 +682,7 @@ function genMediaContent(mE) {
|
|||||||
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
|
height={typeof mContent.info?.h === 'number' ? mContent.info?.h : null}
|
||||||
file={isEncryptedFile ? mContent.file : null}
|
file={isEncryptedFile ? mContent.file : null}
|
||||||
type={mContent.info?.mimetype}
|
type={mContent.info?.mimetype}
|
||||||
|
blurhash={blurhash}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -650,7 +703,7 @@ function getEditedBody(editedMEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Message({
|
function Message({
|
||||||
mEvent, isBodyOnly, roomTimeline, focus, time,
|
mEvent, isBodyOnly, roomTimeline, focus, fullTime,
|
||||||
}) {
|
}) {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const roomId = mEvent.getRoomId();
|
const roomId = mEvent.getRoomId();
|
||||||
@@ -711,7 +764,12 @@ function Message({
|
|||||||
}
|
}
|
||||||
<div className="message__main-container">
|
<div className="message__main-container">
|
||||||
{!isBodyOnly && (
|
{!isBodyOnly && (
|
||||||
<MessageHeader userId={senderId} username={username} time={time} />
|
<MessageHeader
|
||||||
|
userId={senderId}
|
||||||
|
username={username}
|
||||||
|
timestamp={mEvent.getTs()}
|
||||||
|
fullTime={fullTime}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{roomTimeline && isReply && (
|
{roomTimeline && isReply && (
|
||||||
<MessageReplyWrapper
|
<MessageReplyWrapper
|
||||||
@@ -759,13 +817,14 @@ Message.defaultProps = {
|
|||||||
isBodyOnly: false,
|
isBodyOnly: false,
|
||||||
focus: false,
|
focus: false,
|
||||||
roomTimeline: null,
|
roomTimeline: null,
|
||||||
|
fullTime: false,
|
||||||
};
|
};
|
||||||
Message.propTypes = {
|
Message.propTypes = {
|
||||||
mEvent: PropTypes.shape({}).isRequired,
|
mEvent: PropTypes.shape({}).isRequired,
|
||||||
isBodyOnly: PropTypes.bool,
|
isBodyOnly: PropTypes.bool,
|
||||||
roomTimeline: PropTypes.shape({}),
|
roomTimeline: PropTypes.shape({}),
|
||||||
focus: PropTypes.bool,
|
focus: PropTypes.bool,
|
||||||
time: PropTypes.string.isRequired,
|
fullTime: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { Message, MessageReply, PlaceholderMessage };
|
export { Message, MessageReply, PlaceholderMessage };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@use '../../atoms/scroll/scrollbar';
|
@use '../../atoms/scroll/scrollbar';
|
||||||
@use '../../partials/text';
|
@use '../../partials/text';
|
||||||
@use '../../partials/dir';
|
@use '../../partials/dir';
|
||||||
|
@use '../../partials/screen';
|
||||||
|
|
||||||
.message,
|
.message,
|
||||||
.ph-msg {
|
.ph-msg {
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
.message__reactions {
|
.message__reactions {
|
||||||
max-width: calc(100% - 88px);
|
max-width: calc(100% - 88px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@media (max-width: 1124px) {
|
@include screen.smallerThan(tabletBreakpoint) {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,7 +250,6 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
& .react-emoji {
|
& .react-emoji {
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import './TimelineChange.scss';
|
|||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||||
|
import Time from '../../atoms/time/Time';
|
||||||
|
|
||||||
import JoinArraowIC from '../../../../public/res/ic/outlined/join-arrow.svg';
|
import JoinArraowIC from '../../../../public/res/ic/outlined/join-arrow.svg';
|
||||||
import LeaveArraowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
import LeaveArraowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
||||||
@@ -12,7 +13,7 @@ import InviteCancelArraowIC from '../../../../public/res/ic/outlined/invite-canc
|
|||||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||||
|
|
||||||
function TimelineChange({
|
function TimelineChange({
|
||||||
variant, content, time, onClick,
|
variant, content, timestamp, onClick,
|
||||||
}) {
|
}) {
|
||||||
let iconSrc;
|
let iconSrc;
|
||||||
|
|
||||||
@@ -48,7 +49,9 @@ function TimelineChange({
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className="timeline-change__time">
|
<div className="timeline-change__time">
|
||||||
<Text variant="b3">{time}</Text>
|
<Text variant="b3">
|
||||||
|
<Time timestamp={timestamp} />
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -68,7 +71,7 @@ TimelineChange.propTypes = {
|
|||||||
PropTypes.string,
|
PropTypes.string,
|
||||||
PropTypes.node,
|
PropTypes.node,
|
||||||
]).isRequired,
|
]).isRequired,
|
||||||
time: PropTypes.string.isRequired,
|
timestamp: PropTypes.number.isRequired,
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ function PopupWindow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RawModal
|
<RawModal
|
||||||
className={`${className === null ? '' : `${className} `}pw-model`}
|
className={`${className === null ? '' : `${className} `}pw-modal`}
|
||||||
|
overlayClassName="pw-modal__overlay"
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onAfterClose={onAfterClose}
|
onAfterClose={onAfterClose}
|
||||||
onRequestClose={onRequestClose}
|
onRequestClose={onRequestClose}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
@use '../../partials/dir';
|
@use '../../partials/dir';
|
||||||
|
@use '../../partials/screen';
|
||||||
|
|
||||||
.pw-model {
|
.pw-modal {
|
||||||
--modal-height: 656px;
|
--modal-height: 774px;
|
||||||
max-height: var(--modal-height) !important;
|
max-height: var(--modal-height) !important;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
|
@include screen.smallerThan(mobileBreakpoint) {
|
||||||
|
--modal-height: 100%;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
&__overlay {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pw {
|
.pw {
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ function RoomAliases({ roomId }) {
|
|||||||
const loadLocalAliases = async () => {
|
const loadLocalAliases = async () => {
|
||||||
let local = [];
|
let local = [];
|
||||||
try {
|
try {
|
||||||
const result = await mx.unstableGetLocalAliases(roomId);
|
const result = await mx.getLocalAliases(roomId);
|
||||||
local = result.aliases.filter((alias) => !aliases.published.includes(alias));
|
local = result.aliases.filter((alias) => !aliases.published.includes(alias));
|
||||||
} catch {
|
} catch {
|
||||||
local = [];
|
local = [];
|
||||||
|
|||||||
130
src/app/molecules/room-emojis/RoomEmojis.jsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { useReducer, useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './RoomEmojis.scss';
|
||||||
|
|
||||||
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import { suffixRename } from '../../../util/common';
|
||||||
|
|
||||||
|
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import Input from '../../atoms/input/Input';
|
||||||
|
import Button from '../../atoms/button/Button';
|
||||||
|
import ImagePack from '../image-pack/ImagePack';
|
||||||
|
|
||||||
|
function useRoomPacks(room) {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const [, forceUpdate] = useReducer((count) => count + 1, 0);
|
||||||
|
|
||||||
|
const packEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
|
||||||
|
const unUsablePacks = [];
|
||||||
|
const usablePacks = packEvents.filter((mEvent) => {
|
||||||
|
if (typeof mEvent.getContent()?.images !== 'object') {
|
||||||
|
unUsablePacks.push(mEvent);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEvent = (event, state, prevEvent) => {
|
||||||
|
if (event.getRoomId() !== room.roomId) return;
|
||||||
|
if (event.getType() !== 'im.ponies.room_emotes') return;
|
||||||
|
if (!prevEvent?.getContent()?.images || !event.getContent().images) {
|
||||||
|
forceUpdate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on('RoomState.events', handleEvent);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener('RoomState.events', handleEvent);
|
||||||
|
};
|
||||||
|
}, [room, mx]);
|
||||||
|
|
||||||
|
const isStateKeyAvailable = (key) => !room.currentState.getStateEvents('im.ponies.room_emotes', key);
|
||||||
|
|
||||||
|
const createPack = async (name) => {
|
||||||
|
const packContent = {
|
||||||
|
pack: { display_name: name },
|
||||||
|
images: {},
|
||||||
|
};
|
||||||
|
let stateKey = '';
|
||||||
|
if (unUsablePacks.length > 0) {
|
||||||
|
const mEvent = unUsablePacks[0];
|
||||||
|
stateKey = mEvent.getStateKey();
|
||||||
|
} else {
|
||||||
|
stateKey = packContent.pack.display_name.replace(/\s/g, '-');
|
||||||
|
if (!isStateKeyAvailable(stateKey)) {
|
||||||
|
stateKey = suffixRename(
|
||||||
|
stateKey,
|
||||||
|
isStateKeyAvailable,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await mx.sendStateEvent(room.roomId, 'im.ponies.room_emotes', packContent, stateKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePack = async (stateKey) => {
|
||||||
|
await mx.sendStateEvent(room.roomId, 'im.ponies.room_emotes', {}, stateKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
usablePacks,
|
||||||
|
createPack,
|
||||||
|
deletePack,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoomEmojis({ roomId }) {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
|
||||||
|
const { usablePacks, createPack, deletePack } = useRoomPacks(room);
|
||||||
|
|
||||||
|
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||||
|
const canChange = room.currentState.hasSufficientPowerLevelFor('state_default', myPowerlevel);
|
||||||
|
|
||||||
|
const handlePackCreate = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const { nameInput } = e.target;
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (name === '') return;
|
||||||
|
nameInput.value = '';
|
||||||
|
|
||||||
|
createPack(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="room-emojis">
|
||||||
|
{ canChange && (
|
||||||
|
<div className="room-emojis__add-pack">
|
||||||
|
<MenuHeader>Create Pack</MenuHeader>
|
||||||
|
<form onSubmit={handlePackCreate}>
|
||||||
|
<Input name="nameInput" placeholder="Pack Name" required />
|
||||||
|
<Button variant="primary" type="submit">Create pack</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{
|
||||||
|
usablePacks.length > 0
|
||||||
|
? usablePacks.reverse().map((mEvent) => (
|
||||||
|
<ImagePack
|
||||||
|
key={mEvent.getId()}
|
||||||
|
roomId={roomId}
|
||||||
|
stateKey={mEvent.getStateKey()}
|
||||||
|
handlePackDelete={canChange ? deletePack : undefined}
|
||||||
|
/>
|
||||||
|
)) : (
|
||||||
|
<div className="room-emojis__empty">
|
||||||
|
<Text>No emoji or sticker pack.</Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RoomEmojis.propTypes = {
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoomEmojis;
|
||||||
29
src/app/molecules/room-emojis/RoomEmojis.scss
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
.room-emojis {
|
||||||
|
.image-pack,
|
||||||
|
.room-emojis__add-pack,
|
||||||
|
.room-emojis__empty {
|
||||||
|
margin: var(--sp-normal) 0;
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border-radius: var(--bo-radius);
|
||||||
|
box-shadow: var(--bs-surface-border);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
& > .context-menu__header:first-child {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__add-pack {
|
||||||
|
& form {
|
||||||
|
margin: var(--sp-normal);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-normal);
|
||||||
|
& .input-container {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__empty {
|
||||||
|
padding: var(--sp-extra-loose) var(--sp-normal);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import Text from '../../atoms/text/Text';
|
|||||||
import Toggle from '../../atoms/button/Toggle';
|
import Toggle from '../../atoms/button/Toggle';
|
||||||
import SettingTile from '../setting-tile/SettingTile';
|
import SettingTile from '../setting-tile/SettingTile';
|
||||||
|
|
||||||
|
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||||
|
|
||||||
function RoomEncryption({ roomId }) {
|
function RoomEncryption({ roomId }) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
@@ -15,17 +17,20 @@ function RoomEncryption({ roomId }) {
|
|||||||
const [isEncrypted, setIsEncrypted] = useState(encryptionEvents.length > 0);
|
const [isEncrypted, setIsEncrypted] = useState(encryptionEvents.length > 0);
|
||||||
const canEnableEncryption = room.currentState.maySendStateEvent('m.room.encryption', mx.getUserId());
|
const canEnableEncryption = room.currentState.maySendStateEvent('m.room.encryption', mx.getUserId());
|
||||||
|
|
||||||
const handleEncryptionEnable = () => {
|
const handleEncryptionEnable = async () => {
|
||||||
const joinRule = room.getJoinRule();
|
const joinRule = room.getJoinRule();
|
||||||
const confirmMsg1 = 'It is not recommended to add encryption in public room. Anyone can find and join public rooms, so anyone can read messages in them.';
|
const confirmMsg1 = 'It is not recommended to add encryption in public room. Anyone can find and join public rooms, so anyone can read messages in them.';
|
||||||
const confirmMsg2 = 'Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly';
|
const confirmMsg2 = 'Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly';
|
||||||
if (joinRule === 'public' ? confirm(confirmMsg1) : true) {
|
|
||||||
if (confirm(confirmMsg2)) {
|
const isConfirmed1 = (joinRule === 'public')
|
||||||
setIsEncrypted(true);
|
? await confirmDialog('Enable encryption', confirmMsg1, 'Continue', 'caution')
|
||||||
mx.sendStateEvent(roomId, 'm.room.encryption', {
|
: true;
|
||||||
algorithm: 'm.megolm.v1.aes-sha2',
|
if (!isConfirmed1) return;
|
||||||
});
|
if (await confirmDialog('Enable encryption', confirmMsg2, 'Enable', 'caution')) {
|
||||||
}
|
setIsEncrypted(true);
|
||||||
|
mx.sendStateEvent(roomId, 'm.room.encryption', {
|
||||||
|
algorithm: 'm.megolm.v1.aes-sha2',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,19 +17,19 @@ const visibility = {
|
|||||||
|
|
||||||
const items = [{
|
const items = [{
|
||||||
iconSrc: null,
|
iconSrc: null,
|
||||||
text: 'World readable (anyone can read)',
|
text: 'Anyone (including guests)',
|
||||||
type: visibility.WORLD_READABLE,
|
type: visibility.WORLD_READABLE,
|
||||||
}, {
|
}, {
|
||||||
iconSrc: null,
|
iconSrc: null,
|
||||||
text: 'Member shared (since the point in time of selecting this option)',
|
text: 'Members (all messages)',
|
||||||
type: visibility.SHARED,
|
type: visibility.SHARED,
|
||||||
}, {
|
}, {
|
||||||
iconSrc: null,
|
iconSrc: null,
|
||||||
text: 'Member invited (since they were invited)',
|
text: 'Members (messages after invite)',
|
||||||
type: visibility.INVITED,
|
type: visibility.INVITED,
|
||||||
}, {
|
}, {
|
||||||
iconSrc: null,
|
iconSrc: null,
|
||||||
text: 'Member joined (since they joined)',
|
text: 'Members (messages after join)',
|
||||||
type: visibility.JOINED,
|
type: visibility.JOINED,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ function RoomHistoryVisibility({ roomId }) {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
<Text variant="b3">Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.</Text>
|
<Text variant="b3">Changes to history visibility will only apply to future messages. The visibility of existing history will have no effect.</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './RoomIntro.scss';
|
import './RoomIntro.scss';
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
@@ -15,8 +14,8 @@ function RoomIntro({
|
|||||||
<div className="room-intro">
|
<div className="room-intro">
|
||||||
<Avatar imageSrc={avatarSrc} text={name} bgColor={colorMXID(roomId)} size="large" />
|
<Avatar imageSrc={avatarSrc} text={name} bgColor={colorMXID(roomId)} size="large" />
|
||||||
<div className="room-intro__content">
|
<div className="room-intro__content">
|
||||||
<Text className="room-intro__name" variant="h1" weight="medium" primary>{twemojify(heading)}</Text>
|
<Text className="room-intro__name" variant="h1" weight="medium" primary>{heading}</Text>
|
||||||
<Text className="room-intro__desc" variant="b1">{twemojify(desc, undefined, true)}</Text>
|
<Text className="room-intro__desc" variant="b1">{desc}</Text>
|
||||||
{ time !== null && <Text className="room-intro__time" variant="b3">{time}</Text>}
|
{ time !== null && <Text className="room-intro__time" variant="b3">{time}</Text>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,9 +34,9 @@ RoomIntro.propTypes = {
|
|||||||
PropTypes.bool,
|
PropTypes.bool,
|
||||||
]),
|
]),
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
heading: PropTypes.string.isRequired,
|
heading: PropTypes.node.isRequired,
|
||||||
desc: PropTypes.string.isRequired,
|
desc: PropTypes.node.isRequired,
|
||||||
time: PropTypes.string,
|
time: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RoomIntro;
|
export default RoomIntro;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import colorMXID from '../../../util/colorMXID';
|
|||||||
import { openProfileViewer } from '../../../client/action/navigation';
|
import { openProfileViewer } from '../../../client/action/navigation';
|
||||||
import { getUsernameOfRoomMember, getPowerLabel } from '../../../util/matrixUtil';
|
import { getUsernameOfRoomMember, getPowerLabel } from '../../../util/matrixUtil';
|
||||||
import AsyncSearch from '../../../util/AsyncSearch';
|
import AsyncSearch from '../../../util/AsyncSearch';
|
||||||
|
import { memberByAtoZ, memberByPowerLevel } from '../../../util/sort';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Button from '../../atoms/button/Button';
|
import Button from '../../atoms/button/Button';
|
||||||
@@ -19,26 +20,6 @@ import PeopleSelector from '../people-selector/PeopleSelector';
|
|||||||
|
|
||||||
const PER_PAGE_MEMBER = 50;
|
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) {
|
function normalizeMembers(members) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
return members.map((member) => ({
|
return members.map((member) => ({
|
||||||
@@ -65,7 +46,7 @@ function useMemberOfMembership(roomId, membership) {
|
|||||||
if (event && event?.getRoomId() !== roomId) return;
|
if (event && event?.getRoomId() !== roomId) return;
|
||||||
const memberOfMembership = normalizeMembers(
|
const memberOfMembership = normalizeMembers(
|
||||||
room.getMembersWithMembership(membership)
|
room.getMembersWithMembership(membership)
|
||||||
.sort(AtoZ).sort(sortByPowerLevel),
|
.sort(memberByAtoZ).sort(memberByPowerLevel),
|
||||||
);
|
);
|
||||||
setMembers(memberOfMembership);
|
setMembers(memberOfMembership);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const items = [{
|
|||||||
type: cons.notifs.DEFAULT,
|
type: cons.notifs.DEFAULT,
|
||||||
}, {
|
}, {
|
||||||
iconSrc: BellRingIC,
|
iconSrc: BellRingIC,
|
||||||
text: 'All message',
|
text: 'All messages',
|
||||||
type: cons.notifs.ALL_MESSAGES,
|
type: cons.notifs.ALL_MESSAGES,
|
||||||
}, {
|
}, {
|
||||||
iconSrc: BellPingIC,
|
iconSrc: BellPingIC,
|
||||||
@@ -35,7 +35,12 @@ const items = [{
|
|||||||
function setRoomNotifType(roomId, newType) {
|
function setRoomNotifType(roomId, newType) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const { notifications } = initMatrix;
|
const { notifications } = initMatrix;
|
||||||
const roomPushRule = mx.getRoomPushRule('global', roomId);
|
let roomPushRule;
|
||||||
|
try {
|
||||||
|
roomPushRule = mx.getRoomPushRule('global', roomId);
|
||||||
|
} catch {
|
||||||
|
roomPushRule = undefined;
|
||||||
|
}
|
||||||
const promises = [];
|
const promises = [];
|
||||||
|
|
||||||
if (newType === cons.notifs.MUTE) {
|
if (newType === cons.notifs.MUTE) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { twemojify } from '../../../util/twemojify';
|
|||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import { openInviteUser } from '../../../client/action/navigation';
|
import { openInviteUser } from '../../../client/action/navigation';
|
||||||
import * as roomActions from '../../../client/action/room';
|
import * as roomActions from '../../../client/action/room';
|
||||||
|
import { markAsRead } from '../../../client/action/notifications';
|
||||||
|
|
||||||
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
|
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
|
||||||
import RoomNotification from '../room-notification/RoomNotification';
|
import RoomNotification from '../room-notification/RoomNotification';
|
||||||
@@ -14,27 +15,32 @@ import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
|||||||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
||||||
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
||||||
|
|
||||||
|
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||||
|
|
||||||
function RoomOptions({ roomId, afterOptionSelect }) {
|
function RoomOptions({ roomId, afterOptionSelect }) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
const canInvite = room?.canInvite(mx.getUserId());
|
const canInvite = room?.canInvite(mx.getUserId());
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
|
markAsRead(roomId);
|
||||||
afterOptionSelect();
|
afterOptionSelect();
|
||||||
if (!room) return;
|
|
||||||
const events = room.getLiveTimeline().getEvents();
|
|
||||||
mx.sendReadReceipt(events[events.length - 1]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInviteClick = () => {
|
const handleInviteClick = () => {
|
||||||
openInviteUser(roomId);
|
openInviteUser(roomId);
|
||||||
afterOptionSelect();
|
afterOptionSelect();
|
||||||
};
|
};
|
||||||
const handleLeaveClick = () => {
|
const handleLeaveClick = async () => {
|
||||||
if (confirm('Are you really want to leave this room?')) {
|
afterOptionSelect();
|
||||||
roomActions.leave(roomId);
|
const isConfirmed = await confirmDialog(
|
||||||
afterOptionSelect();
|
'Leave room',
|
||||||
}
|
`Are you sure that you want to leave "${room.name}" room?`,
|
||||||
|
'Leave',
|
||||||
|
'danger',
|
||||||
|
);
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
roomActions.leave(roomId);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ function RoomPermissions({ roomId }) {
|
|||||||
const pLEvent = room.currentState.getStateEvents('m.room.power_levels')[0];
|
const pLEvent = room.currentState.getStateEvents('m.room.power_levels')[0];
|
||||||
const permissions = pLEvent.getContent();
|
const permissions = pLEvent.getContent();
|
||||||
const canChangePermission = room.currentState.maySendStateEvent('m.room.power_levels', mx.getUserId());
|
const canChangePermission = room.currentState.maySendStateEvent('m.room.power_levels', mx.getUserId());
|
||||||
|
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel ?? 100;
|
||||||
|
|
||||||
const handlePowerSelector = (e, permKey, parentKey, powerLevel) => {
|
const handlePowerSelector = (e, permKey, parentKey, powerLevel) => {
|
||||||
const handlePowerLevelChange = (newPowerLevel) => {
|
const handlePowerLevelChange = (newPowerLevel) => {
|
||||||
@@ -208,7 +209,7 @@ function RoomPermissions({ roomId }) {
|
|||||||
(closeMenu) => (
|
(closeMenu) => (
|
||||||
<PowerLevelSelector
|
<PowerLevelSelector
|
||||||
value={powerLevel}
|
value={powerLevel}
|
||||||
max={100}
|
max={myPowerLevel}
|
||||||
onSelect={(pl) => {
|
onSelect={(pl) => {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
handlePowerLevelChange(pl);
|
handlePowerLevelChange(pl);
|
||||||
@@ -236,12 +237,12 @@ function RoomPermissions({ roomId }) {
|
|||||||
? permissions[permInfo.parent]?.[permKey]
|
? permissions[permInfo.parent]?.[permKey]
|
||||||
: permissions[permKey];
|
: permissions[permKey];
|
||||||
|
|
||||||
if (!permValue) permValue = permInfo.default;
|
if (permValue === undefined) permValue = permInfo.default;
|
||||||
|
|
||||||
if (typeof permValue === 'number') {
|
if (typeof permValue === 'number') {
|
||||||
powerLevel = permValue;
|
powerLevel = permValue;
|
||||||
} else if (permKey === 'notifications') {
|
} else if (permKey === 'notifications') {
|
||||||
powerLevel = permValue.room || 50;
|
powerLevel = permValue.room ?? 50;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<SettingTile
|
<SettingTile
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
|||||||
|
|
||||||
import { useStore } from '../../hooks/useStore';
|
import { useStore } from '../../hooks/useStore';
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||||
|
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||||
|
|
||||||
function RoomProfile({ roomId }) {
|
function RoomProfile({ roomId }) {
|
||||||
const isMountStore = useStore();
|
const isMountStore = useStore();
|
||||||
@@ -117,7 +118,13 @@ function RoomProfile({ roomId }) {
|
|||||||
|
|
||||||
const handleAvatarUpload = async (url) => {
|
const handleAvatarUpload = async (url) => {
|
||||||
if (url === null) {
|
if (url === null) {
|
||||||
if (confirm('Are you sure you want to remove avatar?')) {
|
const isConfirmed = await confirmDialog(
|
||||||
|
'Remove avatar',
|
||||||
|
'Are you sure that you want to remove room avatar?',
|
||||||
|
'Remove',
|
||||||
|
'caution',
|
||||||
|
);
|
||||||
|
if (isConfirmed) {
|
||||||
await mx.sendStateEvent(roomId, 'm.room.avatar', { url }, '');
|
await mx.sendStateEvent(roomId, 'm.room.avatar', { url }, '');
|
||||||
}
|
}
|
||||||
} else await mx.sendStateEvent(roomId, 'm.room.avatar', { url }, '');
|
} else await mx.sendStateEvent(roomId, 'm.room.avatar', { url }, '');
|
||||||
@@ -125,7 +132,7 @@ function RoomProfile({ roomId }) {
|
|||||||
|
|
||||||
const renderEditNameAndTopic = () => (
|
const renderEditNameAndTopic = () => (
|
||||||
<form className="room-profile__edit-form" onSubmit={handleOnSubmit}>
|
<form className="room-profile__edit-form" onSubmit={handleOnSubmit}>
|
||||||
{canChangeName && <Input value={roomName} name="room-name" disabled={status.type === cons.status.IN_FLIGHT} label="Name" required />}
|
{canChangeName && <Input value={roomName} name="room-name" disabled={status.type === cons.status.IN_FLIGHT} label="Name" />}
|
||||||
{canChangeTopic && <Input value={roomTopic} name="room-topic" disabled={status.type === cons.status.IN_FLIGHT} minHeight={100} resizable label="Topic" />}
|
{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.isSpaceRoom() ? 'space' : '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.IN_FLIGHT && <Text variant="b2">{status.msg}</Text>}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './RoomSearch.scss';
|
import './RoomSearch.scss';
|
||||||
|
|
||||||
import dateFormat from 'dateformat';
|
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import { selectRoom } from '../../../client/action/navigation';
|
import { selectRoom } from '../../../client/action/navigation';
|
||||||
@@ -120,14 +118,13 @@ function RoomSearch({ roomId }) {
|
|||||||
const renderTimeline = (timeline) => (
|
const renderTimeline = (timeline) => (
|
||||||
<div className="room-search__result-item" key={timeline[0].getId()}>
|
<div className="room-search__result-item" key={timeline[0].getId()}>
|
||||||
{ timeline.map((mEvent) => {
|
{ timeline.map((mEvent) => {
|
||||||
const time = dateFormat(mEvent.getDate(), 'dd/mm/yyyy - hh:MM TT');
|
|
||||||
const id = mEvent.getId();
|
const id = mEvent.getId();
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={id}>
|
<React.Fragment key={id}>
|
||||||
<Message
|
<Message
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
isBodyOnly={false}
|
isBodyOnly={false}
|
||||||
time={time}
|
fullTime
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => selectRoom(roomId, id)}>View</Button>
|
<Button onClick={() => selectRoom(roomId, id)}>View</Button>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
|
|||||||
import './RoomTile.scss';
|
import './RoomTile.scss';
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
import { twemojify } from '../../../util/twemojify';
|
||||||
import { sanitizeText } from '../../../util/sanitize';
|
|
||||||
|
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ function RoomVisibility({ roomId }) {
|
|||||||
|
|
||||||
const noSpaceParent = currentState.getStateEvents('m.space.parent').length === 0;
|
const noSpaceParent = currentState.getStateEvents('m.space.parent').length === 0;
|
||||||
const mCreate = currentState.getStateEvents('m.room.create')[0]?.getContent();
|
const mCreate = currentState.getStateEvents('m.room.create')[0]?.getContent();
|
||||||
const roomVersion = Number(mCreate.room_version);
|
const roomVersion = Number(mCreate?.room_version ?? 0);
|
||||||
|
|
||||||
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||||
const canChange = room.currentState.hasSufficientPowerLevelFor('state_default', myPowerlevel);
|
const canChange = room.currentState.hasSufficientPowerLevelFor('state_default', myPowerlevel);
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ function SettingTile({ title, options, content }) {
|
|||||||
<div className="setting-tile">
|
<div className="setting-tile">
|
||||||
<div className="setting-tile__content">
|
<div className="setting-tile__content">
|
||||||
<div className="setting-tile__title">
|
<div className="setting-tile__title">
|
||||||
<Text variant="b1">{title}</Text>
|
{
|
||||||
|
typeof title === 'string'
|
||||||
|
? <Text variant="b1">{title}</Text>
|
||||||
|
: title
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
@@ -24,7 +28,7 @@ SettingTile.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
SettingTile.propTypes = {
|
SettingTile.propTypes = {
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.node.isRequired,
|
||||||
options: PropTypes.node,
|
options: PropTypes.node,
|
||||||
content: PropTypes.node,
|
content: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import Tooltip from '../../atoms/tooltip/Tooltip';
|
|||||||
import { blurOnBubbling } from '../../atoms/button/script';
|
import { blurOnBubbling } from '../../atoms/button/script';
|
||||||
|
|
||||||
const SidebarAvatar = React.forwardRef(({
|
const SidebarAvatar = React.forwardRef(({
|
||||||
tooltip, active, onClick, onContextMenu,
|
className, tooltip, active, onClick,
|
||||||
avatar, notificationBadge,
|
onContextMenu, avatar, notificationBadge,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
let activeClass = '';
|
const classes = ['sidebar-avatar'];
|
||||||
if (active) activeClass = ' sidebar-avatar--active';
|
if (active) classes.push('sidebar-avatar--active');
|
||||||
|
if (className) classes.push(className);
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={<Text variant="b1">{twemojify(tooltip)}</Text>}
|
content={<Text variant="b1">{twemojify(tooltip)}</Text>}
|
||||||
@@ -21,7 +22,7 @@ const SidebarAvatar = React.forwardRef(({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`sidebar-avatar${activeClass}`}
|
className={classes.join(' ')}
|
||||||
type="button"
|
type="button"
|
||||||
onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
|
onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -34,6 +35,7 @@ const SidebarAvatar = React.forwardRef(({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
SidebarAvatar.defaultProps = {
|
SidebarAvatar.defaultProps = {
|
||||||
|
className: null,
|
||||||
active: false,
|
active: false,
|
||||||
onClick: null,
|
onClick: null,
|
||||||
onContextMenu: null,
|
onContextMenu: null,
|
||||||
@@ -41,6 +43,7 @@ SidebarAvatar.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
SidebarAvatar.propTypes = {
|
SidebarAvatar.propTypes = {
|
||||||
|
className: PropTypes.string,
|
||||||
tooltip: PropTypes.string.isRequired,
|
tooltip: PropTypes.string.isRequired,
|
||||||
active: PropTypes.bool,
|
active: PropTypes.bool,
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function SpaceAddExistingContent({ roomId }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = (ev) => {
|
const handleSearch = (ev) => {
|
||||||
const term = ev.target.value.toLocaleLowerCase().replaceAll(' ', '');
|
const term = ev.target.value.toLocaleLowerCase().replace(/\s/g, '');
|
||||||
if (term === '') {
|
if (term === '') {
|
||||||
setSearchIds(null);
|
setSearchIds(null);
|
||||||
return;
|
return;
|
||||||
@@ -100,7 +100,7 @@ function SpaceAddExistingContent({ roomId }) {
|
|||||||
if (!name) return false;
|
if (!name) return false;
|
||||||
name = name.normalize('NFKC')
|
name = name.normalize('NFKC')
|
||||||
.toLocaleLowerCase()
|
.toLocaleLowerCase()
|
||||||
.replaceAll(' ', '');
|
.replace(/\s/g, '');
|
||||||
return name.includes(term);
|
return name.includes(term);
|
||||||
});
|
});
|
||||||
setSearchIds(searchedIds);
|
setSearchIds(searchedIds);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { twemojify } from '../../../util/twemojify';
|
|||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import { openSpaceSettings, openSpaceManage, openInviteUser } from '../../../client/action/navigation';
|
import { openSpaceSettings, openSpaceManage, openInviteUser } from '../../../client/action/navigation';
|
||||||
|
import { markAsRead } from '../../../client/action/notifications';
|
||||||
import { leave } from '../../../client/action/room';
|
import { leave } from '../../../client/action/room';
|
||||||
import {
|
import {
|
||||||
createSpaceShortcut,
|
createSpaceShortcut,
|
||||||
@@ -17,6 +18,7 @@ import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
|
|||||||
|
|
||||||
import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
|
import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
|
||||||
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
|
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
|
||||||
|
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
||||||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
||||||
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
|
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
|
||||||
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
|
import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg';
|
||||||
@@ -24,13 +26,25 @@ import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
|||||||
import PinIC from '../../../../public/res/ic/outlined/pin.svg';
|
import PinIC from '../../../../public/res/ic/outlined/pin.svg';
|
||||||
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
||||||
|
|
||||||
|
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||||
|
|
||||||
function SpaceOptions({ roomId, afterOptionSelect }) {
|
function SpaceOptions({ roomId, afterOptionSelect }) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
|
const { roomList } = initMatrix;
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
const canInvite = room?.canInvite(mx.getUserId());
|
const canInvite = room?.canInvite(mx.getUserId());
|
||||||
const isPinned = initMatrix.accountData.spaceShortcut.has(roomId);
|
const isPinned = initMatrix.accountData.spaceShortcut.has(roomId);
|
||||||
const isCategorized = initMatrix.accountData.categorizedSpaces.has(roomId);
|
const isCategorized = initMatrix.accountData.categorizedSpaces.has(roomId);
|
||||||
|
|
||||||
|
const handleMarkAsRead = () => {
|
||||||
|
const spaceChildren = roomList.getCategorizedSpaces([roomId]);
|
||||||
|
spaceChildren?.forEach((childIds, spaceId) => {
|
||||||
|
childIds?.forEach((childId) => {
|
||||||
|
markAsRead(childId);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
afterOptionSelect();
|
||||||
|
};
|
||||||
const handleInviteClick = () => {
|
const handleInviteClick = () => {
|
||||||
openInviteUser(roomId);
|
openInviteUser(roomId);
|
||||||
afterOptionSelect();
|
afterOptionSelect();
|
||||||
@@ -54,16 +68,22 @@ function SpaceOptions({ roomId, afterOptionSelect }) {
|
|||||||
afterOptionSelect();
|
afterOptionSelect();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLeaveClick = () => {
|
const handleLeaveClick = async () => {
|
||||||
if (confirm('Are you really want to leave this space?')) {
|
afterOptionSelect();
|
||||||
leave(roomId);
|
const isConfirmed = await confirmDialog(
|
||||||
afterOptionSelect();
|
'Leave space',
|
||||||
}
|
`Are you sure that you want to leave "${room.name}" space?`,
|
||||||
|
'Leave',
|
||||||
|
'danger',
|
||||||
|
);
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
leave(roomId);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 'calc(var(--navigation-drawer-width) - var(--sp-normal))' }}>
|
<div style={{ maxWidth: 'calc(var(--navigation-drawer-width) - var(--sp-normal))' }}>
|
||||||
<MenuHeader>{twemojify(`Options for ${initMatrix.matrixClient.getRoom(roomId)?.name}`)}</MenuHeader>
|
<MenuHeader>{twemojify(`Options for ${initMatrix.matrixClient.getRoom(roomId)?.name}`)}</MenuHeader>
|
||||||
|
<MenuItem iconSrc={TickMarkIC} onClick={handleMarkAsRead}>Mark as read</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleCategorizeClick}
|
onClick={handleCategorizeClick}
|
||||||
iconSrc={isCategorized ? CategoryFilledIC : CategoryIC}
|
iconSrc={isCategorized ? CategoryFilledIC : CategoryIC}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
content={(
|
content={(
|
||||||
<Text variant="b3">Override the default (100) power level.</Text>
|
<Text variant="b3">Selecting Admin sets 100 power level whereas Founder sets 101.</Text>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Input name="topic" minHeight={174} resizable label="Topic (optional)" />
|
<Input name="topic" minHeight={174} resizable label="Topic (optional)" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@use '../../partials/dir';
|
@use '../../partials/dir';
|
||||||
|
|
||||||
.create-room {
|
.create-room {
|
||||||
|
margin: var(--sp-normal);
|
||||||
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
||||||
|
|
||||||
&__form > * {
|
&__form > * {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Text from '../../atoms/text/Text';
|
|||||||
function DragDrop({ isOpen }) {
|
function DragDrop({ isOpen }) {
|
||||||
return (
|
return (
|
||||||
<RawModal
|
<RawModal
|
||||||
className="drag-drop__model"
|
className="drag-drop__modal"
|
||||||
overlayClassName="drag-drop__overlay"
|
overlayClassName="drag-drop__overlay"
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.drag-drop__model {
|
.drag-drop__modal {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import initMatrix from '../../../client/initMatrix';
|
|||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import AsyncSearch from '../../../util/AsyncSearch';
|
import AsyncSearch from '../../../util/AsyncSearch';
|
||||||
|
import { addRecentEmoji, getRecentEmojis } from './recent';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||||
@@ -20,6 +21,7 @@ import Input from '../../atoms/input/Input';
|
|||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||||
|
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||||
|
import RecentClockIC from '../../../../public/res/ic/outlined/recent-clock.svg';
|
||||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
||||||
import DogIC from '../../../../public/res/ic/outlined/dog.svg';
|
import DogIC from '../../../../public/res/ic/outlined/dog.svg';
|
||||||
import CupIC from '../../../../public/res/ic/outlined/cup.svg';
|
import CupIC from '../../../../public/res/ic/outlined/cup.svg';
|
||||||
@@ -29,10 +31,11 @@ import BulbIC from '../../../../public/res/ic/outlined/bulb.svg';
|
|||||||
import PeaceIC from '../../../../public/res/ic/outlined/peace.svg';
|
import PeaceIC from '../../../../public/res/ic/outlined/peace.svg';
|
||||||
import FlagIC from '../../../../public/res/ic/outlined/flag.svg';
|
import FlagIC from '../../../../public/res/ic/outlined/flag.svg';
|
||||||
|
|
||||||
|
const ROW_EMOJIS_COUNT = 7;
|
||||||
|
|
||||||
const EmojiGroup = React.memo(({ name, groupEmojis }) => {
|
const EmojiGroup = React.memo(({ name, groupEmojis }) => {
|
||||||
function getEmojiBoard() {
|
function getEmojiBoard() {
|
||||||
const emojiBoard = [];
|
const emojiBoard = [];
|
||||||
const ROW_EMOJIS_COUNT = 7;
|
|
||||||
const totalEmojis = groupEmojis.length;
|
const totalEmojis = groupEmojis.length;
|
||||||
|
|
||||||
for (let r = 0; r < totalEmojis; r += ROW_EMOJIS_COUNT) {
|
for (let r = 0; r < totalEmojis; r += ROW_EMOJIS_COUNT) {
|
||||||
@@ -67,7 +70,7 @@ const EmojiGroup = React.memo(({ name, groupEmojis }) => {
|
|||||||
unicode={`:${emoji.shortcode}:`}
|
unicode={`:${emoji.shortcode}:`}
|
||||||
shortcodes={emoji.shortcode}
|
shortcodes={emoji.shortcode}
|
||||||
src={initMatrix.matrixClient.mxcUrlToHttp(emoji.mxc)}
|
src={initMatrix.matrixClient.mxcUrlToHttp(emoji.mxc)}
|
||||||
data-mx-emoticon
|
data-mx-emoticon={emoji.mxc}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -138,17 +141,21 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
function getEmojiDataFromTarget(target) {
|
function getEmojiDataFromTarget(target) {
|
||||||
const unicode = target.getAttribute('unicode');
|
const unicode = target.getAttribute('unicode');
|
||||||
const hexcode = target.getAttribute('hexcode');
|
const hexcode = target.getAttribute('hexcode');
|
||||||
|
const mxc = target.getAttribute('data-mx-emoticon');
|
||||||
let shortcodes = target.getAttribute('shortcodes');
|
let shortcodes = target.getAttribute('shortcodes');
|
||||||
if (typeof shortcodes === 'undefined') shortcodes = undefined;
|
if (typeof shortcodes === 'undefined') shortcodes = undefined;
|
||||||
else shortcodes = shortcodes.split(',');
|
else shortcodes = shortcodes.split(',');
|
||||||
return { unicode, hexcode, shortcodes };
|
return {
|
||||||
|
unicode, hexcode, shortcodes, mxc,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectEmoji(e) {
|
function selectEmoji(e) {
|
||||||
if (isTargetNotEmoji(e.target)) return;
|
if (isTargetNotEmoji(e.target)) return;
|
||||||
|
|
||||||
const emoji = e.target;
|
const emoji = getEmojiDataFromTarget(e.target);
|
||||||
onSelect(getEmojiDataFromTarget(emoji));
|
onSelect(emoji);
|
||||||
|
if (emoji.hexcode) addRecentEmoji(emoji.unicode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setEmojiInfo(emoji) {
|
function setEmojiInfo(emoji) {
|
||||||
@@ -188,6 +195,9 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [availableEmojis, setAvailableEmojis] = useState([]);
|
const [availableEmojis, setAvailableEmojis] = useState([]);
|
||||||
|
const [recentEmojis, setRecentEmojis] = useState([]);
|
||||||
|
|
||||||
|
const recentOffset = recentEmojis.length > 0 ? 1 : 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateAvailableEmoji = (selectedRoomId) => {
|
const updateAvailableEmoji = (selectedRoomId) => {
|
||||||
@@ -195,26 +205,31 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
setAvailableEmojis([]);
|
setAvailableEmojis([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Retrieve the packs for the new room
|
|
||||||
// Remove packs that aren't marked as emoji packs
|
|
||||||
// Remove packs without emojis
|
|
||||||
const packs = getRelevantPacks(
|
|
||||||
initMatrix.matrixClient.getRoom(selectedRoomId),
|
|
||||||
)
|
|
||||||
.filter((pack) => pack.usage.indexOf('emoticon') !== -1)
|
|
||||||
.filter((pack) => pack.getEmojis().length !== 0);
|
|
||||||
|
|
||||||
// Set an index for each pack so that we know where to jump when the user uses the nav
|
const mx = initMatrix.matrixClient;
|
||||||
for (let i = 0; i < packs.length; i += 1) {
|
const room = mx.getRoom(selectedRoomId);
|
||||||
packs[i].packIndex = i;
|
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
|
||||||
|
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
||||||
|
if (room) {
|
||||||
|
const packs = getRelevantPacks(
|
||||||
|
room.client,
|
||||||
|
[room, ...parentRooms],
|
||||||
|
).filter((pack) => pack.getEmojis().length !== 0);
|
||||||
|
|
||||||
|
// Set an index for each pack so that we know where to jump when the user uses the nav
|
||||||
|
for (let i = 0; i < packs.length; i += 1) {
|
||||||
|
packs[i].packIndex = i;
|
||||||
|
}
|
||||||
|
setAvailableEmojis(packs);
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvailableEmojis(packs);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOpen = () => {
|
const onOpen = () => {
|
||||||
searchRef.current.value = '';
|
searchRef.current.value = '';
|
||||||
handleSearchChange();
|
handleSearchChange();
|
||||||
|
|
||||||
|
// only update when board is getting opened to prevent shifting UI
|
||||||
|
setRecentEmojis(getRecentEmojis(3 * ROW_EMOJIS_COUNT));
|
||||||
};
|
};
|
||||||
|
|
||||||
navigation.on(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
|
navigation.on(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
|
||||||
@@ -230,58 +245,35 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
const $emojiContent = scrollEmojisRef.current.firstElementChild;
|
const $emojiContent = scrollEmojisRef.current.firstElementChild;
|
||||||
const groupCount = $emojiContent.childElementCount;
|
const groupCount = $emojiContent.childElementCount;
|
||||||
if (groupCount > emojiGroups.length) {
|
if (groupCount > emojiGroups.length) {
|
||||||
tabIndex += groupCount - emojiGroups.length - availableEmojis.length;
|
tabIndex += groupCount - emojiGroups.length - availableEmojis.length - recentOffset;
|
||||||
}
|
}
|
||||||
$emojiContent.children[tabIndex].scrollIntoView();
|
$emojiContent.children[tabIndex].scrollIntoView();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="emoji-board" className="emoji-board">
|
<div id="emoji-board" className="emoji-board">
|
||||||
<div className="emoji-board__content">
|
|
||||||
<div className="emoji-board__content__search">
|
|
||||||
<RawIcon size="small" src={SearchIC} />
|
|
||||||
<Input onChange={handleSearchChange} forwardRef={searchRef} placeholder="Search" />
|
|
||||||
</div>
|
|
||||||
<div className="emoji-board__content__emojis">
|
|
||||||
<ScrollView ref={scrollEmojisRef} autoHide>
|
|
||||||
<div onMouseMove={hoverEmoji} onClick={selectEmoji}>
|
|
||||||
<SearchedEmoji />
|
|
||||||
{
|
|
||||||
availableEmojis.map((pack) => (
|
|
||||||
<EmojiGroup
|
|
||||||
name={pack.displayName}
|
|
||||||
key={pack.packIndex}
|
|
||||||
groupEmojis={pack.getEmojis()}
|
|
||||||
className="custom-emoji-group"
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
{
|
|
||||||
emojiGroups.map((group) => (
|
|
||||||
<EmojiGroup key={group.name} name={group.name} groupEmojis={group.emojis} />
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</ScrollView>
|
|
||||||
</div>
|
|
||||||
<div ref={emojiInfo} className="emoji-board__content__info">
|
|
||||||
<div>{ parse(twemoji.parse('🙂')) }</div>
|
|
||||||
<Text>:slight_smile:</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ScrollView invisible>
|
<ScrollView invisible>
|
||||||
<div className="emoji-board__nav">
|
<div className="emoji-board__nav">
|
||||||
|
{recentEmojis.length > 0 && (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => openGroup(0)}
|
||||||
|
src={RecentClockIC}
|
||||||
|
tooltip="Recent"
|
||||||
|
tooltipPlacement="left"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="emoji-board__nav-custom">
|
<div className="emoji-board__nav-custom">
|
||||||
{
|
{
|
||||||
availableEmojis.map((pack) => {
|
availableEmojis.map((pack) => {
|
||||||
const src = initMatrix.matrixClient.mxcUrlToHttp(pack.avatar ?? pack.images[0].mxc);
|
const src = initMatrix.matrixClient
|
||||||
|
.mxcUrlToHttp(pack.avatarUrl ?? pack.getEmojis()[0].mxc);
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => openGroup(pack.packIndex)}
|
onClick={() => openGroup(recentOffset + pack.packIndex)}
|
||||||
src={src}
|
src={src}
|
||||||
key={pack.packIndex}
|
key={pack.packIndex}
|
||||||
tooltip={pack.displayName}
|
tooltip={pack.displayName ?? 'Unknown'}
|
||||||
tooltipPlacement="right"
|
tooltipPlacement="left"
|
||||||
isImage
|
isImage
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -301,17 +293,50 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
[7, FlagIC, 'Flags'],
|
[7, FlagIC, 'Flags'],
|
||||||
].map(([indx, ico, name]) => (
|
].map(([indx, ico, name]) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => openGroup(availableEmojis.length + indx)}
|
onClick={() => openGroup(recentOffset + availableEmojis.length + indx)}
|
||||||
key={indx}
|
key={indx}
|
||||||
src={ico}
|
src={ico}
|
||||||
tooltip={name}
|
tooltip={name}
|
||||||
tooltipPlacement="right"
|
tooltipPlacement="left"
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
<div className="emoji-board__content">
|
||||||
|
<div className="emoji-board__content__search">
|
||||||
|
<RawIcon size="small" src={SearchIC} />
|
||||||
|
<Input onChange={handleSearchChange} forwardRef={searchRef} placeholder="Search" />
|
||||||
|
</div>
|
||||||
|
<div className="emoji-board__content__emojis">
|
||||||
|
<ScrollView ref={scrollEmojisRef} autoHide>
|
||||||
|
<div onMouseMove={hoverEmoji} onClick={selectEmoji}>
|
||||||
|
<SearchedEmoji />
|
||||||
|
{recentEmojis.length > 0 && <EmojiGroup name="Recently used" groupEmojis={recentEmojis} />}
|
||||||
|
{
|
||||||
|
availableEmojis.map((pack) => (
|
||||||
|
<EmojiGroup
|
||||||
|
name={pack.displayName ?? 'Unknown'}
|
||||||
|
key={pack.packIndex}
|
||||||
|
groupEmojis={pack.getEmojis()}
|
||||||
|
className="custom-emoji-group"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
{
|
||||||
|
emojiGroups.map((group) => (
|
||||||
|
<EmojiGroup key={group.name} name={group.name} groupEmojis={group.emojis} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ScrollView>
|
||||||
|
</div>
|
||||||
|
<div ref={emojiInfo} className="emoji-board__content__info">
|
||||||
|
<div>{ parse(twemoji.parse('🙂')) }</div>
|
||||||
|
<Text>:slight_smile:</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
--emoji-board-height: 390px;
|
--emoji-board-height: 390px;
|
||||||
--emoji-board-width: 286px;
|
--emoji-board-width: 286px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
@extend .cp-fx__item-one;
|
@extend .cp-fx__item-one;
|
||||||
@@ -23,8 +25,7 @@
|
|||||||
|
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
background-color: var(--bg-surface-low);
|
@include dir.side(border, none, 1px solid var(--bg-surface-border));
|
||||||
@include dir.side(border, 1px solid var(--bg-surface-border), none);
|
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@
|
|||||||
.emoji {
|
.emoji {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
& > p:last-child {
|
& > p:last-child {
|
||||||
@@ -91,6 +93,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emoji-row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.emoji-group {
|
.emoji-group {
|
||||||
--emoji-padding: 6px;
|
--emoji-padding: 6px;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -115,8 +121,12 @@
|
|||||||
@include dir.side(margin, var(--left-margin), var(--right-margin));
|
@include dir.side(margin, var(--left-margin), var(--right-margin));
|
||||||
}
|
}
|
||||||
& .emoji {
|
& .emoji {
|
||||||
width: 38px;
|
max-width: 38px;
|
||||||
height: 38px;
|
max-height: 38px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
object-fit: contain;
|
||||||
padding: var(--emoji-padding);
|
padding: var(--emoji-padding);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
@@ -1,135 +1,224 @@
|
|||||||
import { emojis } from './emoji';
|
import { emojis } from './emoji';
|
||||||
|
|
||||||
// Custom emoji are stored in one of three places:
|
// https://github.com/Sorunome/matrix-doc/blob/soru/emotes/proposals/2545-emotes.md
|
||||||
// - User emojis, which are stored in account data
|
|
||||||
// - Room emojis, which are stored in state events in a room
|
|
||||||
// - Emoji packs, which are rooms of emojis referenced in the account data or in a room's
|
|
||||||
// cannonical space
|
|
||||||
//
|
|
||||||
// Emojis and packs referenced from within a user's account data should be available
|
|
||||||
// globally, while emojis and packs in rooms and spaces should only be available within
|
|
||||||
// those spaces and rooms
|
|
||||||
|
|
||||||
class ImagePack {
|
class ImagePack {
|
||||||
// Convert a raw image pack into a more maliable format
|
static parsePack(eventId, packContent) {
|
||||||
//
|
if (!eventId || typeof packContent?.images !== 'object') {
|
||||||
// Takes an image pack as per MSC 2545 (e.g. as in the Matrix spec), and converts it to a
|
|
||||||
// format used here, while filling in defaults.
|
|
||||||
//
|
|
||||||
// The room argument is the room the pack exists in, which is used as a fallback for
|
|
||||||
// missing properties
|
|
||||||
//
|
|
||||||
// Returns `null` if the rawPack is not a properly formatted image pack, although there
|
|
||||||
// is still a fair amount of tolerance for malformed packs.
|
|
||||||
static parsePack(rawPack, room) {
|
|
||||||
if (typeof rawPack.images === 'undefined') {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pack = rawPack.pack ?? {};
|
return new ImagePack(eventId, packContent);
|
||||||
|
}
|
||||||
|
|
||||||
const displayName = pack.display_name ?? (room ? room.name : undefined);
|
constructor(eventId, content) {
|
||||||
const avatar = pack.avatar_url ?? (room ? room.getMxcAvatarUrl() : undefined);
|
this.id = eventId;
|
||||||
const usage = pack.usage ?? ['emoticon', 'sticker'];
|
this.content = JSON.parse(JSON.stringify(content));
|
||||||
const { attribution } = pack;
|
|
||||||
const images = Object.entries(rawPack.images).flatMap((e) => {
|
this.applyPack(content);
|
||||||
const data = e[1];
|
this.applyImages(content);
|
||||||
const shortcode = e[0];
|
}
|
||||||
|
|
||||||
|
applyPack(content) {
|
||||||
|
const pack = content.pack ?? {};
|
||||||
|
|
||||||
|
this.displayName = pack.display_name;
|
||||||
|
this.avatarUrl = pack.avatar_url;
|
||||||
|
this.usage = pack.usage ?? ['emoticon', 'sticker'];
|
||||||
|
this.attribution = pack.attribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyImages(content) {
|
||||||
|
this.images = new Map();
|
||||||
|
this.emoticons = [];
|
||||||
|
this.stickers = [];
|
||||||
|
|
||||||
|
Object.entries(content.images).forEach(([shortcode, data]) => {
|
||||||
const mxc = data.url;
|
const mxc = data.url;
|
||||||
const body = data.body ?? shortcode;
|
const body = data.body ?? shortcode;
|
||||||
|
const usage = data.usage ?? this.usage;
|
||||||
const { info } = data;
|
const { info } = data;
|
||||||
const usage_ = data.usage ?? usage;
|
|
||||||
|
|
||||||
if (mxc) {
|
if (!mxc) return;
|
||||||
return [{
|
const image = {
|
||||||
shortcode, mxc, body, info, usage: usage_,
|
shortcode, mxc, body, usage, info,
|
||||||
}];
|
};
|
||||||
|
|
||||||
|
this.images.set(shortcode, image);
|
||||||
|
if (usage.includes('emoticon')) {
|
||||||
|
this.emoticons.push(image);
|
||||||
|
}
|
||||||
|
if (usage.includes('sticker')) {
|
||||||
|
this.stickers.push(image);
|
||||||
}
|
}
|
||||||
return [];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return new ImagePack(displayName, avatar, usage, attribution, images);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(displayName, avatar, usage, attribution, images) {
|
getImages() {
|
||||||
this.displayName = displayName;
|
return this.images;
|
||||||
this.avatar = avatar;
|
|
||||||
this.usage = usage;
|
|
||||||
this.attribution = attribution;
|
|
||||||
this.images = images;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produce a list of emoji in this image pack
|
|
||||||
getEmojis() {
|
getEmojis() {
|
||||||
return this.images.filter((i) => i.usage.indexOf('emoticon') !== -1);
|
return this.emoticons;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produce a list of stickers in this image pack
|
|
||||||
getStickers() {
|
getStickers() {
|
||||||
return this.images.filter((i) => i.usage.indexOf('sticker') !== -1);
|
return this.stickers;
|
||||||
|
}
|
||||||
|
|
||||||
|
getContent() {
|
||||||
|
return this.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updatePackProperty(property, value) {
|
||||||
|
if (this.content.pack === undefined) {
|
||||||
|
this.content.pack = {};
|
||||||
|
}
|
||||||
|
this.content.pack[property] = value;
|
||||||
|
this.applyPack(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvatarUrl(avatarUrl) {
|
||||||
|
this._updatePackProperty('avatar_url', avatarUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisplayName(displayName) {
|
||||||
|
this._updatePackProperty('display_name', displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribution(attribution) {
|
||||||
|
this._updatePackProperty('attribution', attribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsage(usage) {
|
||||||
|
this._updatePackProperty('usage', usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
addImage(key, imgContent) {
|
||||||
|
this.content.images = {
|
||||||
|
[key]: imgContent,
|
||||||
|
...this.content.images,
|
||||||
|
};
|
||||||
|
this.applyImages(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeImage(key) {
|
||||||
|
if (this.content.images[key] === undefined) return;
|
||||||
|
delete this.content.images[key];
|
||||||
|
this.applyImages(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateImageKey(key, newKey) {
|
||||||
|
if (this.content.images[key] === undefined) return;
|
||||||
|
const copyImages = {};
|
||||||
|
Object.keys(this.content.images).forEach((imgKey) => {
|
||||||
|
copyImages[imgKey === key ? newKey : imgKey] = this.content.images[imgKey];
|
||||||
|
});
|
||||||
|
this.content.images = copyImages;
|
||||||
|
this.applyImages(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateImageProperty(key, property, value) {
|
||||||
|
if (this.content.images[key] === undefined) return;
|
||||||
|
this.content.images[key][property] = value;
|
||||||
|
this.applyImages(this.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageUrl(key, url) {
|
||||||
|
this._updateImageProperty(key, 'url', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageBody(key, body) {
|
||||||
|
this._updateImageProperty(key, 'body', body);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageInfo(key, info) {
|
||||||
|
this._updateImageProperty(key, 'info', info);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageUsage(key, usage) {
|
||||||
|
this._updateImageProperty(key, 'usage', usage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve a list of user emojis
|
function getGlobalImagePacks(mx) {
|
||||||
//
|
const globalContent = mx.getAccountData('im.ponies.emote_rooms')?.getContent();
|
||||||
// Result is an ImagePack, or null if the user hasn't set up or has deleted their personal
|
if (typeof globalContent !== 'object') return [];
|
||||||
// image pack.
|
|
||||||
//
|
const { rooms } = globalContent;
|
||||||
// Accepts a reference to a matrix client as the only argument
|
if (typeof rooms !== 'object') return [];
|
||||||
|
|
||||||
|
const roomIds = Object.keys(rooms);
|
||||||
|
|
||||||
|
const packs = roomIds.flatMap((roomId) => {
|
||||||
|
if (typeof rooms[roomId] !== 'object') return [];
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return [];
|
||||||
|
const stateKeys = Object.keys(rooms[roomId]);
|
||||||
|
|
||||||
|
return stateKeys.map((stateKey) => {
|
||||||
|
const data = room.currentState.getStateEvents('im.ponies.room_emotes', stateKey);
|
||||||
|
const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
|
||||||
|
if (pack) {
|
||||||
|
pack.displayName ??= room.name;
|
||||||
|
pack.avatarUrl ??= room.getMxcAvatarUrl();
|
||||||
|
}
|
||||||
|
return pack;
|
||||||
|
}).filter((pack) => pack !== null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return packs;
|
||||||
|
}
|
||||||
|
|
||||||
function getUserImagePack(mx) {
|
function getUserImagePack(mx) {
|
||||||
const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
|
const accountDataEmoji = mx.getAccountData('im.ponies.user_emotes');
|
||||||
if (!accountDataEmoji) {
|
if (!accountDataEmoji) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userImagePack = ImagePack.parsePack(accountDataEmoji.event.content);
|
const userImagePack = ImagePack.parsePack(mx.getUserId(), accountDataEmoji.event.content);
|
||||||
if (userImagePack) userImagePack.displayName ??= 'Your Emoji';
|
userImagePack.displayName ??= 'Personal Emoji';
|
||||||
return userImagePack;
|
return userImagePack;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produces a list of all of the emoji packs in a room
|
function getRoomImagePacks(room) {
|
||||||
//
|
const dataEvents = room.currentState.getStateEvents('im.ponies.room_emotes');
|
||||||
// Returns a list of `ImagePack`s. This does not include packs in spaces that contain
|
|
||||||
// this room.
|
|
||||||
function getPacksInRoom(room) {
|
|
||||||
const packs = room.currentState.getStateEvents('im.ponies.room_emotes');
|
|
||||||
|
|
||||||
return packs
|
return dataEvents
|
||||||
.map((p) => ImagePack.parsePack(p.event.content, room))
|
.map((data) => {
|
||||||
.filter((p) => p !== null);
|
const pack = ImagePack.parsePack(data?.getId(), data?.getContent());
|
||||||
|
if (pack) {
|
||||||
|
pack.displayName ??= room.name;
|
||||||
|
pack.avatarUrl ??= room.getMxcAvatarUrl();
|
||||||
|
}
|
||||||
|
return pack;
|
||||||
|
})
|
||||||
|
.filter((pack) => pack !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produce a list of all image packs which should be shown for a given room
|
/**
|
||||||
//
|
* @param {MatrixClient} mx Provide if you want to include user personal/global pack
|
||||||
// This includes packs in that room, the user's personal images, and will eventually
|
* @param {Room[]} rooms Provide rooms if you want to include rooms pack
|
||||||
// include the user's enabled global image packs and space-level packs.
|
* @returns {ImagePack[]} packs
|
||||||
//
|
*/
|
||||||
// This differs from getPacksInRoom, as the former only returns packs that are directly in
|
function getRelevantPacks(mx, rooms) {
|
||||||
// a room, whereas this function returns all packs which should be shown to the user while
|
const userPack = mx ? getUserImagePack(mx) : [];
|
||||||
// they are in this room.
|
const globalPacks = mx ? getGlobalImagePacks(mx) : [];
|
||||||
//
|
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
|
||||||
// Packs will be returned in the order that shortcode conflicts should be resolved, with
|
const roomsPack = rooms?.flatMap(getRoomImagePacks) ?? [];
|
||||||
// higher priority packs coming first.
|
|
||||||
function getRelevantPacks(room) {
|
|
||||||
return [].concat(
|
return [].concat(
|
||||||
getUserImagePack(room.client) ?? [],
|
userPack ?? [],
|
||||||
getPacksInRoom(room),
|
globalPacks,
|
||||||
|
roomsPack.filter((pack) => !globalPackIds.has(pack.id)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns all user+room emojis and all standard unicode emojis
|
function getShortcodeToEmoji(mx, rooms) {
|
||||||
//
|
|
||||||
// Accepts a reference to a matrix client as the only argument
|
|
||||||
//
|
|
||||||
// Result is a map from shortcode to the corresponding emoji. If two emoji share a
|
|
||||||
// shortcode, only one will be presented, with priority given to custom emoji.
|
|
||||||
//
|
|
||||||
// Will eventually be expanded to include all emojis revelant to a room and the user
|
|
||||||
function getShortcodeToEmoji(room) {
|
|
||||||
const allEmoji = new Map();
|
const allEmoji = new Map();
|
||||||
|
|
||||||
emojis.forEach((emoji) => {
|
emojis.forEach((emoji) => {
|
||||||
if (emoji.shortcodes.constructor.name === 'Array') {
|
if (Array.isArray(emoji.shortcodes)) {
|
||||||
emoji.shortcodes.forEach((shortcode) => {
|
emoji.shortcodes.forEach((shortcode) => {
|
||||||
allEmoji.set(shortcode, emoji);
|
allEmoji.set(shortcode, emoji);
|
||||||
});
|
});
|
||||||
@@ -138,7 +227,7 @@ function getShortcodeToEmoji(room) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
getRelevantPacks(room).reverse()
|
getRelevantPacks(mx, rooms)
|
||||||
.flatMap((pack) => pack.getEmojis())
|
.flatMap((pack) => pack.getEmojis())
|
||||||
.forEach((emoji) => {
|
.forEach((emoji) => {
|
||||||
allEmoji.set(emoji.shortcode, emoji);
|
allEmoji.set(emoji.shortcode, emoji);
|
||||||
@@ -150,7 +239,7 @@ function getShortcodeToEmoji(room) {
|
|||||||
function getShortcodeToCustomEmoji(room) {
|
function getShortcodeToCustomEmoji(room) {
|
||||||
const allEmoji = new Map();
|
const allEmoji = new Map();
|
||||||
|
|
||||||
getRelevantPacks(room).reverse()
|
getRelevantPacks(room.client, [room])
|
||||||
.flatMap((pack) => pack.getEmojis())
|
.flatMap((pack) => pack.getEmojis())
|
||||||
.forEach((emoji) => {
|
.forEach((emoji) => {
|
||||||
allEmoji.set(emoji.shortcode, emoji);
|
allEmoji.set(emoji.shortcode, emoji);
|
||||||
@@ -159,27 +248,20 @@ function getShortcodeToCustomEmoji(room) {
|
|||||||
return allEmoji;
|
return allEmoji;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produces a special list of emoji specifically for auto-completion
|
function getEmojiForCompletion(mx, rooms) {
|
||||||
//
|
|
||||||
// This list contains each emoji once, with all emoji being deduplicated by shortcode.
|
|
||||||
// However, the order of the standard emoji will have been preserved, and alternate
|
|
||||||
// shortcodes for the standard emoji will not be considered.
|
|
||||||
//
|
|
||||||
// Standard emoji are guaranteed to be earlier in the list than custom emoji
|
|
||||||
function getEmojiForCompletion(room) {
|
|
||||||
const allEmoji = new Map();
|
const allEmoji = new Map();
|
||||||
getRelevantPacks(room).reverse()
|
getRelevantPacks(mx, rooms)
|
||||||
.flatMap((pack) => pack.getEmojis())
|
.flatMap((pack) => pack.getEmojis())
|
||||||
.forEach((emoji) => {
|
.forEach((emoji) => {
|
||||||
allEmoji.set(emoji.shortcode, emoji);
|
allEmoji.set(emoji.shortcode, emoji);
|
||||||
});
|
});
|
||||||
|
|
||||||
return emojis.filter((e) => !allEmoji.has(e.shortcode))
|
return Array.from(allEmoji.values()).concat(emojis.filter((e) => !allEmoji.has(e.shortcode)));
|
||||||
.concat(Array.from(allEmoji.values()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getUserImagePack,
|
ImagePack,
|
||||||
|
getUserImagePack, getGlobalImagePacks, getRoomImagePacks,
|
||||||
getShortcodeToEmoji, getShortcodeToCustomEmoji,
|
getShortcodeToEmoji, getShortcodeToCustomEmoji,
|
||||||
getRelevantPacks, getEmojiForCompletion,
|
getRelevantPacks, getEmojiForCompletion,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import emojisData from 'emojibase-data/en/compact.json';
|
import emojisData from 'emojibase-data/en/compact.json';
|
||||||
import shortcodes from 'emojibase-data/en/shortcodes/joypixels.json';
|
import joypixels from 'emojibase-data/en/shortcodes/joypixels.json';
|
||||||
|
import emojibase from 'emojibase-data/en/shortcodes/emojibase.json';
|
||||||
|
|
||||||
const emojiGroups = [{
|
const emojiGroups = [{
|
||||||
name: 'Smileys & people',
|
name: 'Smileys & people',
|
||||||
@@ -52,7 +53,7 @@ function addToGroup(emoji) {
|
|||||||
|
|
||||||
const emojis = [];
|
const emojis = [];
|
||||||
emojisData.forEach((emoji) => {
|
emojisData.forEach((emoji) => {
|
||||||
const myShortCodes = shortcodes[emoji.hexcode];
|
const myShortCodes = joypixels[emoji.hexcode] || emojibase[emoji.hexcode];
|
||||||
if (!myShortCodes) return;
|
if (!myShortCodes) return;
|
||||||
const em = {
|
const em = {
|
||||||
...emoji,
|
...emoji,
|
||||||
|
|||||||
36
src/app/organisms/emoji-board/recent.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import { emojis } from './emoji';
|
||||||
|
|
||||||
|
const eventType = 'io.element.recent_emoji';
|
||||||
|
|
||||||
|
function getRecentEmojisRaw() {
|
||||||
|
return initMatrix.matrixClient.getAccountData(eventType)?.getContent().recent_emoji ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentEmojis(limit) {
|
||||||
|
const res = [];
|
||||||
|
getRecentEmojisRaw()
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.find(([unicode]) => {
|
||||||
|
const emoji = emojis.find((e) => e.unicode === unicode);
|
||||||
|
if (emoji) return res.push(emoji) >= limit;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addRecentEmoji(unicode) {
|
||||||
|
const recent = getRecentEmojisRaw();
|
||||||
|
const i = recent.findIndex(([u]) => u === unicode);
|
||||||
|
let entry;
|
||||||
|
if (i < 0) {
|
||||||
|
entry = [unicode, 1];
|
||||||
|
} else {
|
||||||
|
[entry] = recent.splice(i, 1);
|
||||||
|
entry[1] += 1;
|
||||||
|
}
|
||||||
|
recent.unshift(entry);
|
||||||
|
initMatrix.matrixClient.setAccountData(eventType, {
|
||||||
|
recent_emoji: recent.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
200
src/app/organisms/emoji-verification/EmojiVerification.jsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/* eslint-disable react/prop-types */
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './EmojiVerification.scss';
|
||||||
|
import { twemojify } from '../../../util/twemojify';
|
||||||
|
|
||||||
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import cons from '../../../client/state/cons';
|
||||||
|
import navigation from '../../../client/state/navigation';
|
||||||
|
import { hasPrivateKey } from '../../../client/state/secretStorageKeys';
|
||||||
|
import { getDefaultSSKey, isCrossVerified } from '../../../util/matrixUtil';
|
||||||
|
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
|
import Button from '../../atoms/button/Button';
|
||||||
|
import Spinner from '../../atoms/spinner/Spinner';
|
||||||
|
import Dialog from '../../molecules/dialog/Dialog';
|
||||||
|
|
||||||
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
import { useStore } from '../../hooks/useStore';
|
||||||
|
import { accessSecretStorage } from '../settings/SecretStorageAccess';
|
||||||
|
|
||||||
|
function EmojiVerificationContent({ data, requestClose }) {
|
||||||
|
const [sas, setSas] = useState(null);
|
||||||
|
const [process, setProcess] = useState(false);
|
||||||
|
const { request, targetDevice } = data;
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const mountStore = useStore();
|
||||||
|
const beginStore = useStore();
|
||||||
|
|
||||||
|
const beginVerification = async () => {
|
||||||
|
if (
|
||||||
|
isCrossVerified(mx.deviceId)
|
||||||
|
&& (mx.getCrossSigningId() === null || await mx.crypto.crossSigningInfo.isStoredInKeyCache('self_signing') === false)
|
||||||
|
) {
|
||||||
|
if (!hasPrivateKey(getDefaultSSKey())) {
|
||||||
|
const keyData = await accessSecretStorage('Emoji verification');
|
||||||
|
if (!keyData) {
|
||||||
|
request.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await mx.checkOwnCrossSigningTrust();
|
||||||
|
}
|
||||||
|
setProcess(true);
|
||||||
|
await request.accept();
|
||||||
|
|
||||||
|
const verifier = request.beginKeyVerification('m.sas.v1', targetDevice);
|
||||||
|
|
||||||
|
const handleVerifier = (sasData) => {
|
||||||
|
verifier.off('show_sas', handleVerifier);
|
||||||
|
if (!mountStore.getItem()) return;
|
||||||
|
setSas(sasData);
|
||||||
|
setProcess(false);
|
||||||
|
};
|
||||||
|
verifier.on('show_sas', handleVerifier);
|
||||||
|
await verifier.verify();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sasMismatch = () => {
|
||||||
|
sas.mismatch();
|
||||||
|
setProcess(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sasConfirm = () => {
|
||||||
|
sas.confirm();
|
||||||
|
setProcess(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountStore.setItem(true);
|
||||||
|
const handleChange = () => {
|
||||||
|
if (request.done || request.cancelled) {
|
||||||
|
requestClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetDevice && !beginStore.getItem()) {
|
||||||
|
beginStore.setItem(true);
|
||||||
|
beginVerification();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request === null) return null;
|
||||||
|
const req = request;
|
||||||
|
req.on('change', handleChange);
|
||||||
|
return () => {
|
||||||
|
req.off('change', handleChange);
|
||||||
|
if (req.cancelled === false && req.done === false) {
|
||||||
|
req.cancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
const renderWait = () => (
|
||||||
|
<>
|
||||||
|
<Spinner size="small" />
|
||||||
|
<Text>Waiting for response from other device...</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sas !== null) {
|
||||||
|
return (
|
||||||
|
<div className="emoji-verification__content">
|
||||||
|
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
|
||||||
|
<div className="emoji-verification__emojis">
|
||||||
|
{sas.sas.emoji.map((emoji, i) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<div className="emoji-verification__emoji-block" key={`${emoji[1]}-${i}`}>
|
||||||
|
<Text variant="h1">{twemojify(emoji[0])}</Text>
|
||||||
|
<Text>{emoji[1]}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="emoji-verification__buttons">
|
||||||
|
{process ? renderWait() : (
|
||||||
|
<>
|
||||||
|
<Button variant="primary" onClick={sasConfirm}>They match</Button>
|
||||||
|
<Button onClick={sasMismatch}>{'They don\'t match'}</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetDevice) {
|
||||||
|
return (
|
||||||
|
<div className="emoji-verification__content">
|
||||||
|
<Text>Please accept the request from other device.</Text>
|
||||||
|
<div className="emoji-verification__buttons">
|
||||||
|
{renderWait()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="emoji-verification__content">
|
||||||
|
<Text>Click accept to start the verification process.</Text>
|
||||||
|
<div className="emoji-verification__buttons">
|
||||||
|
{
|
||||||
|
process
|
||||||
|
? renderWait()
|
||||||
|
: <Button variant="primary" onClick={beginVerification}>Accept</Button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
EmojiVerificationContent.propTypes = {
|
||||||
|
data: PropTypes.shape({}).isRequired,
|
||||||
|
requestClose: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
function useVisibilityToggle() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOpen = (request, targetDevice) => {
|
||||||
|
setData({ request, targetDevice });
|
||||||
|
};
|
||||||
|
navigation.on(cons.events.navigation.EMOJI_VERIFICATION_OPENED, handleOpen);
|
||||||
|
mx.on('crypto.verification.request', handleOpen);
|
||||||
|
return () => {
|
||||||
|
navigation.removeListener(cons.events.navigation.EMOJI_VERIFICATION_OPENED, handleOpen);
|
||||||
|
mx.removeListener('crypto.verification.request', handleOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestClose = () => setData(null);
|
||||||
|
|
||||||
|
return [data, requestClose];
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmojiVerification() {
|
||||||
|
const [data, requestClose] = useVisibilityToggle();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={data !== null}
|
||||||
|
className="emoji-verification"
|
||||||
|
title={(
|
||||||
|
<Text variant="s1" weight="medium" primary>
|
||||||
|
Emoji verification
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||||
|
onRequestClose={requestClose}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
data !== null
|
||||||
|
? <EmojiVerificationContent data={data} requestClose={requestClose} />
|
||||||
|
: <div />
|
||||||
|
}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmojiVerification;
|
||||||
35
src/app/organisms/emoji-verification/EmojiVerification.scss
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@use '../../partials/flex';
|
||||||
|
@use '../../partials/dir';
|
||||||
|
|
||||||
|
.emoji-verification {
|
||||||
|
&__content {
|
||||||
|
padding: var(--sp-normal);
|
||||||
|
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__emojis {
|
||||||
|
margin: var(--sp-loose) 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: var(--sp-extra-tight);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__emoji-block {
|
||||||
|
@extend .cp-fx__column;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-extra-tight);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,17 +54,20 @@ function InviteList({ isOpen, onRequestClose }) {
|
|||||||
}, [procInvite]);
|
}, [procInvite]);
|
||||||
|
|
||||||
function renderRoomTile(roomId) {
|
function renderRoomTile(roomId) {
|
||||||
const myRoom = initMatrix.matrixClient.getRoom(roomId);
|
const mx = initMatrix.matrixClient;
|
||||||
|
const myRoom = mx.getRoom(roomId);
|
||||||
|
if (!myRoom) return null;
|
||||||
const roomName = myRoom.name;
|
const roomName = myRoom.name;
|
||||||
let roomAlias = myRoom.getCanonicalAlias();
|
let roomAlias = myRoom.getCanonicalAlias();
|
||||||
if (roomAlias === null) roomAlias = myRoom.roomId;
|
if (!roomAlias) roomAlias = myRoom.roomId;
|
||||||
|
const inviterName = myRoom.getMember(mx.getUserId())?.events?.member?.getSender?.() ?? '';
|
||||||
return (
|
return (
|
||||||
<RoomTile
|
<RoomTile
|
||||||
key={myRoom.roomId}
|
key={myRoom.roomId}
|
||||||
name={roomName}
|
name={roomName}
|
||||||
avatarSrc={initMatrix.matrixClient.getRoom(roomId).getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop')}
|
avatarSrc={initMatrix.matrixClient.getRoom(roomId).getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop')}
|
||||||
id={roomAlias}
|
id={roomAlias}
|
||||||
inviterName={myRoom.getJoinedMembers()[0].userId}
|
inviterName={inviterName}
|
||||||
options={
|
options={
|
||||||
procInvite.has(myRoom.roomId)
|
procInvite.has(myRoom.roomId)
|
||||||
? (<Spinner size="small" />)
|
? (<Spinner size="small" />)
|
||||||
@@ -95,12 +98,13 @@ function InviteList({ isOpen, onRequestClose }) {
|
|||||||
{
|
{
|
||||||
Array.from(initMatrix.roomList.inviteDirects).map((roomId) => {
|
Array.from(initMatrix.roomList.inviteDirects).map((roomId) => {
|
||||||
const myRoom = initMatrix.matrixClient.getRoom(roomId);
|
const myRoom = initMatrix.matrixClient.getRoom(roomId);
|
||||||
|
if (myRoom === null) return null;
|
||||||
const roomName = myRoom.name;
|
const roomName = myRoom.name;
|
||||||
return (
|
return (
|
||||||
<RoomTile
|
<RoomTile
|
||||||
key={myRoom.roomId}
|
key={myRoom.roomId}
|
||||||
name={roomName}
|
name={roomName}
|
||||||
id={myRoom.getDMInviter()}
|
id={myRoom.getDMInviter() || roomId}
|
||||||
options={
|
options={
|
||||||
procInvite.has(myRoom.roomId)
|
procInvite.has(myRoom.roomId)
|
||||||
? (<Spinner size="small" />)
|
? (<Spinner size="small" />)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import initMatrix from '../../../client/initMatrix';
|
|||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import * as roomActions from '../../../client/action/room';
|
import * as roomActions from '../../../client/action/room';
|
||||||
import { selectRoom } from '../../../client/action/navigation';
|
import { selectRoom } from '../../../client/action/navigation';
|
||||||
import { hasDMWith } from '../../../util/matrixUtil';
|
import { hasDMWith, hasDevices } from '../../../util/matrixUtil';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Button from '../../atoms/button/Button';
|
import Button from '../../atoms/button/Button';
|
||||||
@@ -117,7 +117,7 @@ function InviteUser({
|
|||||||
procUserError.delete(userId);
|
procUserError.delete(userId);
|
||||||
updateUserProcError(getMapCopy(procUserError));
|
updateUserProcError(getMapCopy(procUserError));
|
||||||
|
|
||||||
const result = await roomActions.createDM(userId);
|
const result = await roomActions.createDM(userId, await hasDevices(userId));
|
||||||
roomIdToUserId.set(result.room_id, userId);
|
roomIdToUserId.set(result.room_id, userId);
|
||||||
updateRoomIdToUserId(getMapCopy(roomIdToUserId));
|
updateRoomIdToUserId(getMapCopy(roomIdToUserId));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
155
src/app/organisms/join-alias/JoinAlias.jsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './JoinAlias.scss';
|
||||||
|
|
||||||
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import cons from '../../../client/state/cons';
|
||||||
|
import navigation from '../../../client/state/navigation';
|
||||||
|
import { join } from '../../../client/action/room';
|
||||||
|
import { selectRoom, selectSpace } from '../../../client/action/navigation';
|
||||||
|
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
|
import Button from '../../atoms/button/Button';
|
||||||
|
import Input from '../../atoms/input/Input';
|
||||||
|
import Spinner from '../../atoms/spinner/Spinner';
|
||||||
|
import Dialog from '../../molecules/dialog/Dialog';
|
||||||
|
|
||||||
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
|
||||||
|
import { useStore } from '../../hooks/useStore';
|
||||||
|
|
||||||
|
const ALIAS_OR_ID_REG = /^[#|!].+:.+\..+$/;
|
||||||
|
|
||||||
|
function JoinAliasContent({ term, requestClose }) {
|
||||||
|
const [process, setProcess] = useState(false);
|
||||||
|
const [error, setError] = useState(undefined);
|
||||||
|
const [lastJoinId, setLastJoinId] = useState(undefined);
|
||||||
|
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const mountStore = useStore();
|
||||||
|
|
||||||
|
const openRoom = (roomId) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return;
|
||||||
|
if (room.isSpaceRoom()) selectSpace(roomId);
|
||||||
|
else selectRoom(roomId);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleJoin = (roomId) => {
|
||||||
|
if (lastJoinId !== roomId) return;
|
||||||
|
openRoom(roomId);
|
||||||
|
};
|
||||||
|
initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleJoin);
|
||||||
|
return () => {
|
||||||
|
initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleJoin);
|
||||||
|
};
|
||||||
|
}, [lastJoinId]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mountStore.setItem(true);
|
||||||
|
const alias = e.target.alias.value;
|
||||||
|
if (alias?.trim() === '') return;
|
||||||
|
if (alias.match(ALIAS_OR_ID_REG) === null) {
|
||||||
|
setError('Invalid address.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProcess('Looking for address...');
|
||||||
|
setError(undefined);
|
||||||
|
let via;
|
||||||
|
if (alias.startsWith('#')) {
|
||||||
|
try {
|
||||||
|
const aliasData = await mx.resolveRoomAlias(alias);
|
||||||
|
via = aliasData?.servers.slice(0, 3) || [];
|
||||||
|
if (mountStore.getItem()) {
|
||||||
|
setProcess(`Joining ${alias}...`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!mountStore.getItem()) return;
|
||||||
|
setProcess(false);
|
||||||
|
setError(`Unable to find room/space with ${alias}. Either room/space is private or doesn't exist.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const roomId = await join(alias, false, via);
|
||||||
|
if (!mountStore.getItem()) return;
|
||||||
|
setLastJoinId(roomId);
|
||||||
|
openRoom(roomId);
|
||||||
|
} catch {
|
||||||
|
if (!mountStore.getItem()) return;
|
||||||
|
setProcess(false);
|
||||||
|
setError(`Unable to join ${alias}. Either room/space is private or doesn't exist.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="join-alias" onSubmit={handleSubmit}>
|
||||||
|
<Input
|
||||||
|
label="Address"
|
||||||
|
value={term}
|
||||||
|
name="alias"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{error && <Text className="join-alias__error" variant="b3">{error}</Text>}
|
||||||
|
<div className="join-alias__btn">
|
||||||
|
{
|
||||||
|
process
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<Spinner size="small" />
|
||||||
|
<Text>{process}</Text>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: <Button variant="primary" type="submit">Join</Button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
JoinAliasContent.defaultProps = {
|
||||||
|
term: undefined,
|
||||||
|
};
|
||||||
|
JoinAliasContent.propTypes = {
|
||||||
|
term: PropTypes.string,
|
||||||
|
requestClose: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
function useWindowToggle() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOpen = (term) => {
|
||||||
|
setData({ term });
|
||||||
|
};
|
||||||
|
navigation.on(cons.events.navigation.JOIN_ALIAS_OPENED, handleOpen);
|
||||||
|
return () => {
|
||||||
|
navigation.removeListener(cons.events.navigation.JOIN_ALIAS_OPENED, handleOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onRequestClose = () => setData(null);
|
||||||
|
|
||||||
|
return [data, onRequestClose];
|
||||||
|
}
|
||||||
|
|
||||||
|
function JoinAlias() {
|
||||||
|
const [data, requestClose] = useWindowToggle();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={data !== null}
|
||||||
|
title={(
|
||||||
|
<Text variant="s1" weight="medium" primary>Join with address</Text>
|
||||||
|
)}
|
||||||
|
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||||
|
onRequestClose={requestClose}
|
||||||
|
>
|
||||||
|
{ data ? <JoinAliasContent term={data.term} requestClose={requestClose} /> : <div /> }
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JoinAlias;
|
||||||
20
src/app/organisms/join-alias/JoinAlias.scss
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
@use '../../partials/dir';
|
||||||
|
|
||||||
|
.join-alias {
|
||||||
|
padding: var(--sp-normal);
|
||||||
|
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||||
|
|
||||||
|
& > *:not(:first-child) {
|
||||||
|
margin-top: var(--sp-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__error {
|
||||||
|
color: var(--tc-danger-high);
|
||||||
|
margin-top: var(--sp-extra-tight) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,39 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import Postie from '../../../util/Postie';
|
import Postie from '../../../util/Postie';
|
||||||
|
import { roomIdByActivity } from '../../../util/sort';
|
||||||
|
|
||||||
import RoomsCategory from './RoomsCategory';
|
import RoomsCategory from './RoomsCategory';
|
||||||
|
|
||||||
import { AtoZ } from './common';
|
|
||||||
|
|
||||||
const drawerPostie = new Postie();
|
const drawerPostie = new Postie();
|
||||||
function Directs() {
|
function Directs({ size }) {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
const { roomList, notifications } = initMatrix;
|
const { roomList, notifications } = initMatrix;
|
||||||
const directIds = [...roomList.directs].sort(AtoZ);
|
const [directIds, setDirectIds] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => setDirectIds([...roomList.directs].sort(roomIdByActivity)), [size]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTimeline = (event, room, toStartOfTimeline, removed, data) => {
|
||||||
|
if (!roomList.directs.has(room.roomId)) return;
|
||||||
|
if (!data.liveEvent) return;
|
||||||
|
if (directIds[0] === room.roomId) return;
|
||||||
|
const newDirectIds = [room.roomId];
|
||||||
|
directIds.forEach((id) => {
|
||||||
|
if (id === room.roomId) return;
|
||||||
|
newDirectIds.push(id);
|
||||||
|
});
|
||||||
|
setDirectIds(newDirectIds);
|
||||||
|
};
|
||||||
|
mx.on('Room.timeline', handleTimeline);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener('Room.timeline', handleTimeline);
|
||||||
|
};
|
||||||
|
}, [directIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectorChanged = (selectedRoomId, prevSelectedRoomId) => {
|
const selectorChanged = (selectedRoomId, prevSelectedRoomId) => {
|
||||||
@@ -43,5 +64,8 @@ function Directs() {
|
|||||||
|
|
||||||
return <RoomsCategory name="People" hideHeader roomIds={directIds} drawerPostie={drawerPostie} />;
|
return <RoomsCategory name="People" hideHeader roomIds={directIds} drawerPostie={drawerPostie} />;
|
||||||
}
|
}
|
||||||
|
Directs.propTypes = {
|
||||||
|
size: PropTypes.number.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
export default Directs;
|
export default Directs;
|
||||||
|
|||||||
@@ -42,12 +42,15 @@ function Drawer() {
|
|||||||
const [spaceId] = useSelectedSpace();
|
const [spaceId] = useSelectedSpace();
|
||||||
const [, forceUpdate] = useForceUpdate();
|
const [, forceUpdate] = useForceUpdate();
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
|
const { roomList } = initMatrix;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { roomList } = initMatrix;
|
const handleUpdate = () => {
|
||||||
roomList.on(cons.events.roomList.ROOMLIST_UPDATED, forceUpdate);
|
forceUpdate();
|
||||||
|
};
|
||||||
|
roomList.on(cons.events.roomList.ROOMLIST_UPDATED, handleUpdate);
|
||||||
return () => {
|
return () => {
|
||||||
roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, forceUpdate);
|
roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, handleUpdate);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -61,14 +64,16 @@ function Drawer() {
|
|||||||
<div className="drawer">
|
<div className="drawer">
|
||||||
<DrawerHeader selectedTab={selectedTab} spaceId={spaceId} />
|
<DrawerHeader selectedTab={selectedTab} spaceId={spaceId} />
|
||||||
<div className="drawer__content-wrapper">
|
<div className="drawer__content-wrapper">
|
||||||
{navigation.selectedSpacePath.length > 1 && <DrawerBreadcrumb spaceId={spaceId} />}
|
{navigation.selectedSpacePath.length > 1 && selectedTab !== cons.tabs.DIRECTS && (
|
||||||
|
<DrawerBreadcrumb spaceId={spaceId} />
|
||||||
|
)}
|
||||||
<div className="rooms__wrapper">
|
<div className="rooms__wrapper">
|
||||||
<ScrollView ref={scrollRef} autoHide>
|
<ScrollView ref={scrollRef} autoHide>
|
||||||
<div className="rooms-container">
|
<div className="rooms-container">
|
||||||
{
|
{
|
||||||
selectedTab !== cons.tabs.DIRECTS
|
selectedTab !== cons.tabs.DIRECTS
|
||||||
? <Home spaceId={spaceId} />
|
? <Home spaceId={spaceId} />
|
||||||
: <Directs />
|
: <Directs size={roomList.directs.size} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { twemojify } from '../../../util/twemojify';
|
|||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import {
|
import {
|
||||||
openPublicRooms, openCreateRoom, openSpaceManage,
|
openPublicRooms, openCreateRoom, openSpaceManage, openJoinAlias,
|
||||||
openSpaceAddExisting, openInviteUser, openReusableContextMenu,
|
openSpaceAddExisting, openInviteUser, openReusableContextMenu,
|
||||||
} from '../../../client/action/navigation';
|
} from '../../../client/action/navigation';
|
||||||
import { getEventCords } from '../../../util/common';
|
import { getEventCords } from '../../../util/common';
|
||||||
@@ -60,6 +60,14 @@ export function HomeSpaceOptions({ spaceId, afterOptionSelect }) {
|
|||||||
Join public room
|
Join public room
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
{ !spaceId && (
|
||||||
|
<MenuItem
|
||||||
|
iconSrc={PlusIC}
|
||||||
|
onClick={() => { afterOptionSelect(); openJoinAlias(); }}
|
||||||
|
>
|
||||||
|
Join with address
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{ spaceId && (
|
{ spaceId && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
iconSrc={PlusIC}
|
iconSrc={PlusIC}
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import initMatrix from '../../../client/initMatrix';
|
|||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import Postie from '../../../util/Postie';
|
import Postie from '../../../util/Postie';
|
||||||
|
import { roomIdByActivity, roomIdByAtoZ } from '../../../util/sort';
|
||||||
|
|
||||||
import RoomsCategory from './RoomsCategory';
|
import RoomsCategory from './RoomsCategory';
|
||||||
|
|
||||||
import { useCategorizedSpaces } from '../../hooks/useCategorizedSpaces';
|
import { useCategorizedSpaces } from '../../hooks/useCategorizedSpaces';
|
||||||
import { AtoZ, RoomToDM } from './common';
|
|
||||||
|
|
||||||
const drawerPostie = new Postie();
|
const drawerPostie = new Postie();
|
||||||
function Home({ spaceId }) {
|
function Home({ spaceId }) {
|
||||||
@@ -34,10 +34,6 @@ function Home({ spaceId }) {
|
|||||||
roomIds = roomList.getOrphanRooms();
|
roomIds = roomList.getOrphanRooms();
|
||||||
}
|
}
|
||||||
|
|
||||||
spaceIds.sort(AtoZ);
|
|
||||||
roomIds.sort(AtoZ);
|
|
||||||
directIds.sort(AtoZ);
|
|
||||||
|
|
||||||
if (isCategorized) {
|
if (isCategorized) {
|
||||||
categories = roomList.getCategorizedSpaces(spaceIds);
|
categories = roomList.getCategorizedSpaces(spaceIds);
|
||||||
categories.delete(spaceId);
|
categories.delete(spaceId);
|
||||||
@@ -73,26 +69,36 @@ function Home({ spaceId }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{ !isCategorized && spaceIds.length !== 0 && (
|
{ !isCategorized && spaceIds.length !== 0 && (
|
||||||
<RoomsCategory name="Spaces" roomIds={spaceIds} drawerPostie={drawerPostie} />
|
<RoomsCategory name="Spaces" roomIds={spaceIds.sort(roomIdByAtoZ)} drawerPostie={drawerPostie} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ roomIds.length !== 0 && (
|
{ roomIds.length !== 0 && (
|
||||||
<RoomsCategory name="Rooms" roomIds={roomIds} drawerPostie={drawerPostie} />
|
<RoomsCategory name="Rooms" roomIds={roomIds.sort(roomIdByAtoZ)} drawerPostie={drawerPostie} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ directIds.length !== 0 && (
|
{ directIds.length !== 0 && (
|
||||||
<RoomsCategory name="People" roomIds={directIds} drawerPostie={drawerPostie} />
|
<RoomsCategory name="People" roomIds={directIds.sort(roomIdByActivity)} drawerPostie={drawerPostie} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ isCategorized && [...categories].map(([catId, childIds]) => (
|
{ isCategorized && [...categories].map(([catId, childIds]) => {
|
||||||
<RoomsCategory
|
const rms = [];
|
||||||
key={catId}
|
const dms = [];
|
||||||
spaceId={catId}
|
childIds.forEach((id) => {
|
||||||
name={mx.getRoom(catId).name}
|
if (directs.has(id)) dms.push(id);
|
||||||
roomIds={[...childIds].sort(AtoZ).sort(RoomToDM)}
|
else rms.push(id);
|
||||||
drawerPostie={drawerPostie}
|
});
|
||||||
/>
|
rms.sort(roomIdByAtoZ);
|
||||||
))}
|
dms.sort(roomIdByActivity);
|
||||||
|
return (
|
||||||
|
<RoomsCategory
|
||||||
|
key={catId}
|
||||||
|
spaceId={catId}
|
||||||
|
name={mx.getRoom(catId).name}
|
||||||
|
roomIds={rms.concat(dms)}
|
||||||
|
drawerPostie={drawerPostie}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../../../client/action/navigation';
|
} from '../../../client/action/navigation';
|
||||||
import { moveSpaceShortcut } from '../../../client/action/accountData';
|
import { moveSpaceShortcut } from '../../../client/action/accountData';
|
||||||
import { abbreviateNumber, getEventCords } from '../../../util/common';
|
import { abbreviateNumber, getEventCords } from '../../../util/common';
|
||||||
|
import { isCrossVerified } from '../../../util/matrixUtil';
|
||||||
|
|
||||||
import Avatar from '../../atoms/avatar/Avatar';
|
import Avatar from '../../atoms/avatar/Avatar';
|
||||||
import NotificationBadge from '../../atoms/badge/NotificationBadge';
|
import NotificationBadge from '../../atoms/badge/NotificationBadge';
|
||||||
@@ -26,8 +27,12 @@ import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
|||||||
import AddPinIC from '../../../../public/res/ic/outlined/add-pin.svg';
|
import AddPinIC from '../../../../public/res/ic/outlined/add-pin.svg';
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||||
import InviteIC from '../../../../public/res/ic/outlined/invite.svg';
|
import InviteIC from '../../../../public/res/ic/outlined/invite.svg';
|
||||||
|
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
|
||||||
|
|
||||||
import { useSelectedTab } from '../../hooks/useSelectedTab';
|
import { useSelectedTab } from '../../hooks/useSelectedTab';
|
||||||
|
import { useDeviceList } from '../../hooks/useDeviceList';
|
||||||
|
|
||||||
|
import { tabText as settingTabText } from '../settings/Settings';
|
||||||
|
|
||||||
function useNotificationUpdate() {
|
function useNotificationUpdate() {
|
||||||
const { notifications } = initMatrix;
|
const { notifications } = initMatrix;
|
||||||
@@ -72,7 +77,7 @@ function ProfileAvatarMenu() {
|
|||||||
return (
|
return (
|
||||||
<SidebarAvatar
|
<SidebarAvatar
|
||||||
onClick={openSettings}
|
onClick={openSettings}
|
||||||
tooltip={profile.displayName}
|
tooltip="Settings"
|
||||||
avatar={(
|
avatar={(
|
||||||
<Avatar
|
<Avatar
|
||||||
text={profile.displayName}
|
text={profile.displayName}
|
||||||
@@ -85,6 +90,22 @@ function ProfileAvatarMenu() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CrossSigninAlert() {
|
||||||
|
const deviceList = useDeviceList();
|
||||||
|
const unverified = deviceList?.filter((device) => isCrossVerified(device.device_id) === false);
|
||||||
|
|
||||||
|
if (!unverified?.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarAvatar
|
||||||
|
className="sidebar__cross-signin-alert"
|
||||||
|
tooltip={`${unverified.length} unverified sessions`}
|
||||||
|
onClick={() => openSettings(settingTabText.SECURITY)}
|
||||||
|
avatar={<Avatar iconSrc={ShieldUserIC} iconColor="var(--ic-danger-normal)" size="normal" />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FeaturedTab() {
|
function FeaturedTab() {
|
||||||
const { roomList, accountData, notifications } = initMatrix;
|
const { roomList, accountData, notifications } = initMatrix;
|
||||||
const [selectedTab] = useSelectedTab();
|
const [selectedTab] = useSelectedTab();
|
||||||
@@ -358,6 +379,7 @@ function SideBar() {
|
|||||||
notificationBadge={<NotificationBadge alert content={totalInvites} />}
|
notificationBadge={<NotificationBadge alert content={totalInvites} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<CrossSigninAlert />
|
||||||
<ProfileAvatarMenu />
|
<ProfileAvatarMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||