Compare commits
213 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91a6916f4c | ||
|
|
78374d0301 | ||
|
|
9185ee0cf9 | ||
|
|
e2de51832c | ||
|
|
759f16d5b9 | ||
|
|
d0ddedc2b2 | ||
|
|
4291005161 | ||
|
|
ee144ccb2b | ||
|
|
7fea21f221 | ||
|
|
9a4b40f242 | ||
|
|
c2d353b973 | ||
|
|
8b6cecdbf8 | ||
|
|
83d8f2821a | ||
|
|
0225204b4d | ||
|
|
478957803d | ||
|
|
0181cb26d2 | ||
|
|
2ddb3595c7 | ||
|
|
76e49d1bd0 | ||
|
|
91f1ee748e | ||
|
|
9bddf64510 | ||
|
|
2200ae143e | ||
|
|
80d9a2ca7d | ||
|
|
cbd1bf35c6 | ||
|
|
c1be57b205 | ||
|
|
4848bef0dd | ||
|
|
a8f374dd43 | ||
|
|
d43e41e1ba | ||
|
|
437ac4c59b | ||
|
|
de218409ab | ||
|
|
060ed859d4 | ||
|
|
0783c43169 | ||
|
|
b5a317e021 | ||
|
|
15c1f6dadf | ||
|
|
efda9991f2 | ||
|
|
9fb651a04e | ||
|
|
55c652a02a | ||
|
|
2be706fb3f | ||
|
|
3e2cc8bfae | ||
|
|
a6c339e13a | ||
|
|
b0174f3acc | ||
|
|
a343d9999e | ||
|
|
0a2cca6e40 | ||
|
|
bca00f46a9 | ||
|
|
5848c02d50 | ||
|
|
29ddcfa1f9 | ||
|
|
831bb83f4e | ||
|
|
b8a8babc88 | ||
|
|
4a35aa7272 | ||
|
|
2ded7d9b1f | ||
|
|
19f674cf5f | ||
|
|
790bf5eac3 | ||
|
|
e1adc6a3bc | ||
|
|
bcedab5113 | ||
|
|
adca914d67 | ||
|
|
8583ab19f0 | ||
|
|
1aa16a43ee | ||
|
|
e6661d3b0d | ||
|
|
678e0dc6ac | ||
|
|
bdc10fb729 | ||
|
|
584fa87bbd | ||
|
|
ac155bbf4c | ||
|
|
0f6f65045d | ||
|
|
843b9ddffd | ||
|
|
e283d4cd21 | ||
|
|
4fe02d27c8 | ||
|
|
5a269c68ae | ||
|
|
570a1c85af | ||
|
|
f7ff1ef2bd | ||
|
|
04530a801c | ||
|
|
756cd0921b | ||
|
|
292eee33b9 | ||
|
|
583c834ba8 | ||
|
|
33949dbdb1 | ||
|
|
fd79ea4b9b | ||
|
|
b3bff6b43f | ||
|
|
80aa55b706 | ||
|
|
76c16ce294 | ||
|
|
715e6648ae | ||
|
|
984fad811c | ||
|
|
eafd2c8a13 | ||
|
|
82fb243ffe | ||
|
|
6cbf1b2eac | ||
|
|
043e411a98 | ||
|
|
f93b666bbf | ||
|
|
7e28aa1474 | ||
|
|
1deef51df0 | ||
|
|
6f7934badc | ||
|
|
2f2680be3c | ||
|
|
4cd8f4a94c | ||
|
|
a417980a81 | ||
|
|
af69955801 | ||
|
|
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 |
3
.github/FUNDING.yml
vendored
@@ -1,2 +1,3 @@
|
|||||||
open_collective: cinny
|
github: ajbura
|
||||||
liberapay: ajbura
|
liberapay: ajbura
|
||||||
|
open_collective: cinny
|
||||||
|
|||||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,4 +1,4 @@
|
|||||||
<!-- 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. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||||
|
|||||||
15
.github/renovate.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:base",
|
||||||
|
":dependencyDashboardApproval"
|
||||||
|
],
|
||||||
|
"labels": [ "Dependencies" ],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": [ "lockFileMaintenance" ]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lockFileMaintenance": { "enabled": true },
|
||||||
|
"dependencyDashboard": true
|
||||||
|
}
|
||||||
34
.github/workflows/build-pull-request.yml
vendored
@@ -6,29 +6,33 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-pull-request:
|
build-pull-request:
|
||||||
|
name: 'Build pull request'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
PR_NUMBER: ${{github.event.number}}
|
PR_NUMBER: ${{github.event.number}}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.0.2
|
uses: actions/checkout@v3.0.2
|
||||||
- name: Build app
|
- name: Setup node
|
||||||
run: npm ci && npm run build
|
uses: actions/setup-node@v3.4.1
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v3.0.0
|
|
||||||
with:
|
with:
|
||||||
name: previewbuild
|
node-version: 17.9.0
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v3.1.0
|
||||||
|
with:
|
||||||
|
name: preview
|
||||||
path: dist
|
path: dist
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
- name: Get PR info
|
- name: Save pr number
|
||||||
uses: actions/github-script@v6.0.0
|
run: echo ${PR_NUMBER} > ./pr.txt
|
||||||
|
- name: Upload pr number
|
||||||
|
uses: actions/upload-artifact@v3.1.0
|
||||||
with:
|
with:
|
||||||
script: |
|
name: pr
|
||||||
var fs = require('fs');
|
path: ./pr.txt
|
||||||
fs.writeFileSync('${{github.workspace}}/pr.json', JSON.stringify(context.payload.pull_request));
|
|
||||||
- name: Upload PR Info
|
|
||||||
uses: actions/upload-artifact@v3.0.0
|
|
||||||
with:
|
|
||||||
name: pr.json
|
|
||||||
path: pr.json
|
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
75
.github/workflows/deploy-pull-request.yml
vendored
@@ -1,65 +1,40 @@
|
|||||||
name: Upload Preview Build to Netlify
|
name: Deploy PR to Netlify
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Build pull request"]
|
workflows: ["Build pull request"]
|
||||||
types:
|
types: [completed]
|
||||||
- completed
|
|
||||||
jobs:
|
jobs:
|
||||||
get-build-and-deploy:
|
deploy-pull-request:
|
||||||
|
name: 'Deploy pull request'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: >
|
permissions:
|
||||||
${{ github.event.workflow_run.conclusion == 'success' }}
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
steps:
|
steps:
|
||||||
# There's a 'download artifact' action but it hasn't been updated for the
|
- name: Download pr number
|
||||||
# workflow_run action (https://github.com/actions/download-artifact/issues/60)
|
uses: dawidd6/action-download-artifact@7847792dd435a50521b8e3bd3576dae7459d1fa8
|
||||||
# so instead we get this mess:
|
with:
|
||||||
|
workflow: ${{ github.event.workflow.id }}
|
||||||
|
run_id: ${{ github.event.workflow_run.id }}
|
||||||
|
name: pr
|
||||||
|
- name: Output pr number
|
||||||
|
id: pr
|
||||||
|
run: echo "::set-output name=id::$(<pr.txt)"
|
||||||
- name: Download artifact
|
- name: Download artifact
|
||||||
uses: actions/github-script@v6.0.0
|
uses: dawidd6/action-download-artifact@7847792dd435a50521b8e3bd3576dae7459d1fa8
|
||||||
with:
|
with:
|
||||||
script: |
|
workflow: ${{ github.event.workflow.id }}
|
||||||
var artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
run_id: ${{ github.event.workflow_run.id }}
|
||||||
owner: context.repo.owner,
|
name: preview
|
||||||
repo: context.repo.repo,
|
path: dist
|
||||||
run_id: ${{github.event.workflow_run.id }},
|
|
||||||
});
|
|
||||||
var matchArtifact = artifacts.data.artifacts.filter((artifact) => {
|
|
||||||
return artifact.name == "previewbuild"
|
|
||||||
})[0];
|
|
||||||
var download = await github.rest.actions.downloadArtifact({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
artifact_id: matchArtifact.id,
|
|
||||||
archive_format: 'zip',
|
|
||||||
});
|
|
||||||
var fs = require('fs');
|
|
||||||
fs.writeFileSync('${{github.workspace}}/previewbuild.zip', Buffer.from(download.data));
|
|
||||||
var prInfoArtifact = artifacts.data.artifacts.filter((artifact) => {
|
|
||||||
return artifact.name == "pr.json"
|
|
||||||
})[0];
|
|
||||||
var download = await github.rest.actions.downloadArtifact({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
artifact_id: prInfoArtifact.id,
|
|
||||||
archive_format: 'zip',
|
|
||||||
});
|
|
||||||
var fs = require('fs');
|
|
||||||
fs.writeFileSync('${{github.workspace}}/pr.json.zip', Buffer.from(download.data));
|
|
||||||
- name: Extract Artifacts
|
|
||||||
run: unzip -d dist previewbuild.zip && rm previewbuild.zip && unzip pr.json.zip && rm pr.json.zip
|
|
||||||
- name: Read PR Info
|
|
||||||
id: readctx
|
|
||||||
uses: actions/github-script@v6.0.0
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
var fs = require('fs');
|
|
||||||
var pr = JSON.parse(fs.readFileSync('${{github.workspace}}/pr.json'));
|
|
||||||
console.log(`::set-output name=prnumber::${pr.number}`);
|
|
||||||
- name: Deploy to Netlify
|
- name: Deploy to Netlify
|
||||||
id: netlify
|
id: netlify
|
||||||
uses: nwtgck/actions-netlify@b7c1504e00c6b8a249d1848cc1b522a4865eed99
|
uses: nwtgck/actions-netlify@b7c1504e00c6b8a249d1848cc1b522a4865eed99
|
||||||
with:
|
with:
|
||||||
publish-dir: dist
|
publish-dir: dist
|
||||||
deploy-message: "Deploy from GitHub Actions"
|
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
|
||||||
|
alias: ${{ steps.pr.outputs.id }}
|
||||||
# These don't work because we're in workflow_run
|
# These don't work because we're in workflow_run
|
||||||
enable-pull-request-comment: false
|
enable-pull-request-comment: false
|
||||||
enable-commit-comment: false
|
enable-commit-comment: false
|
||||||
@@ -72,7 +47,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
pull-request-number: ${{ steps.readctx.outputs.prnumber }}
|
pull-request-number: ${{ steps.pr.outputs.id }}
|
||||||
description-message: |
|
description-message: |
|
||||||
Preview: ${{ steps.netlify.outputs.deploy-url }}
|
Preview: ${{ steps.netlify.outputs.deploy-url }}
|
||||||
⚠️ Exercise caution. Use test accounts. ⚠️
|
⚠️ Exercise caution. Use test accounts. ⚠️
|
||||||
4
.github/workflows/docker-pr.yml
vendored
@@ -9,13 +9,11 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
docker-build:
|
docker-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
PR_NUMBER: ${{github.event.number}}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.0.2
|
uses: actions/checkout@v3.0.2
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@v2.10.0
|
uses: docker/build-push-action@v3.1.1
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
|
|||||||
26
.github/workflows/lockfile.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: NPM Lockfile Changes
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'package-lock.json'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lockfile_changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Permission overwrite is required for Dependabot PRs, see "Common issues" below.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3.0.2
|
||||||
|
- name: NPM Lockfile Changes
|
||||||
|
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# Optional inputs, can be deleted safely if you are happy with default values.
|
||||||
|
collapsibleThreshold: 25
|
||||||
|
failOnDowngrade: false
|
||||||
|
path: package-lock.json
|
||||||
|
updateComment: true
|
||||||
29
.github/workflows/netlify-dev.yml
vendored
@@ -7,18 +7,31 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-to-netlify:
|
deploy-to-netlify:
|
||||||
name: 'Deploy'
|
name: 'Deploy to Netlify'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.0.2
|
uses: actions/checkout@v3.0.2
|
||||||
- name: Build and deploy to Netlify
|
- name: Setup node
|
||||||
uses: jsmrcaga/action-netlify-deploy@fb6a5f936a4b06a8f7793e69fc5a022ffe39807a
|
uses: actions/setup-node@v3.4.1
|
||||||
with:
|
with:
|
||||||
install_command: "npm ci"
|
node-version: 17.9.0
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
- name: Deploy to Netlify
|
||||||
|
uses: nwtgck/actions-netlify@b7c1504e00c6b8a249d1848cc1b522a4865eed99
|
||||||
|
with:
|
||||||
|
publish-dir: dist
|
||||||
|
deploy-message: "Dev deploy ${{ github.sha }}"
|
||||||
|
enable-commit-comment: false
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
production-deploy: true
|
||||||
|
github-deployment-environment: nightly
|
||||||
|
github-deployment-description: 'Nightly deployment on each commit to dev branch'
|
||||||
|
env:
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE2_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE2_ID }}
|
||||||
BUILD_DIRECTORY: "dist"
|
timeout-minutes: 1
|
||||||
NETLIFY_DEPLOY_MESSAGE: "Dev deploy v${{ github.ref }}"
|
|
||||||
NETLIFY_DEPLOY_TO_PROD: true
|
|
||||||
|
|||||||
73
.github/workflows/prod-deploy.yml
vendored
@@ -5,33 +5,50 @@ on:
|
|||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-to-netlify:
|
deploy-and-tarball:
|
||||||
name: 'Deploy to Netlify'
|
name: 'Netlify deploy and tarball'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.0.2
|
uses: actions/checkout@v3.0.2
|
||||||
- name: Build and deploy to Netlify
|
- name: Setup node
|
||||||
uses: jsmrcaga/action-netlify-deploy@fb6a5f936a4b06a8f7793e69fc5a022ffe39807a
|
uses: actions/setup-node@v3.4.1
|
||||||
with:
|
with:
|
||||||
install_command: "npm ci"
|
node-version: 17.9.0
|
||||||
|
cache: 'npm'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Build app
|
||||||
|
run: npm run build
|
||||||
|
- name: Deploy to Netlify
|
||||||
|
uses: nwtgck/actions-netlify@b7c1504e00c6b8a249d1848cc1b522a4865eed99
|
||||||
|
with:
|
||||||
|
publish-dir: dist
|
||||||
|
deploy-message: "Prod deploy ${{ github.ref_name }}"
|
||||||
|
enable-commit-comment: false
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
production-deploy: true
|
||||||
|
github-deployment-environment: stable
|
||||||
|
github-deployment-description: 'Stable deployment on each release'
|
||||||
|
env:
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
BUILD_DIRECTORY: "dist"
|
timeout-minutes: 1
|
||||||
NETLIFY_DEPLOY_MESSAGE: "Prod deploy v${{ github.ref }}"
|
|
||||||
NETLIFY_DEPLOY_TO_PROD: true
|
|
||||||
- name: Get version from tag
|
- name: Get version from tag
|
||||||
id: vars
|
id: vars
|
||||||
run: echo ::set-output name=tag::${GITHUB_REF#refs/*/}
|
run: echo ::set-output name=tag::${GITHUB_REF#refs/*/}
|
||||||
- name: Create tar.gz
|
- name: Create tar.gz
|
||||||
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
|
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
|
||||||
- name: Sign tar.gz
|
- name: Sign tar.gz
|
||||||
uses: actionhippie/gpgsign@4e28208b142cae93e1582401dcda1cf79e4f72c0
|
run: |
|
||||||
with:
|
echo '${{ secrets.GNUPG_KEY }}' | gpg --batch --import
|
||||||
private_key: ${{ secrets.GNUPG_KEY }}
|
# Sadly a few lines in the private key match a few lines in the public key,
|
||||||
passphrase: ${{ secrets.GNUPG_PASSPHRASE }}
|
# As a result just --export --armor gives us a few lines replaced with ***
|
||||||
detach_sign: true
|
# making it useless for importing the signing key. Instead, we dump it as
|
||||||
files: cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
# 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
|
- name: Upload tagged release
|
||||||
uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5
|
uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5
|
||||||
with:
|
with:
|
||||||
@@ -39,26 +56,42 @@ jobs:
|
|||||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
|
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
|
||||||
|
|
||||||
push_to_dockerhub:
|
publish-image:
|
||||||
name: Push Docker image to Docker Hub
|
name: Push Docker image to Docker Hub, ghcr
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.0.2
|
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
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v1.14.1
|
uses: docker/login-action@v2.0.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Login to the Container registry
|
||||||
|
uses: docker/login-action@v2.0.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v3.8.0
|
uses: docker/metadata-action@v4.0.1
|
||||||
with:
|
with:
|
||||||
images: ajbura/cinny
|
images: |
|
||||||
|
${{ secrets.DOCKER_USERNAME }}/cinny
|
||||||
|
ghcr.io/${{ github.repository }}
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v2.10.0
|
uses: docker/build-push-action@v3.1.1
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
legacy-peer-deps=true
|
||||||
|
save-exact=true
|
||||||
|
@matrix-org:registry=https://gitlab.matrix.org/api/v4/projects/27/packages/npm/
|
||||||
@@ -37,3 +37,8 @@ It is not always possible to phrase every change in such a manner, but it is des
|
|||||||
Also, we use [ESLint](https://eslint.org/) for clean and stylistically consistent code syntax, so make sure your pull request follow it.
|
Also, we use [ESLint](https://eslint.org/) for clean and stylistically consistent code syntax, so make sure your pull request follow it.
|
||||||
|
|
||||||
**For any query or design discussion, join our [Matrix room](https://matrix.to/#/#cinny:matrix.org).**
|
**For any query or design discussion, join our [Matrix room](https://matrix.to/#/#cinny:matrix.org).**
|
||||||
|
|
||||||
|
## Helpful links
|
||||||
|
- [BEM methodology](http://getbem.com/introduction/)
|
||||||
|
- [Atomic design](https://bradfrost.com/blog/post/atomic-web-design/)
|
||||||
|
- [Matrix JavaScript SDK documentation](https://matrix-org.github.io/matrix-js-sdk/index.html)
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ FROM node:17.9.0-alpine3.15 as builder
|
|||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY package.json package-lock.json /src/
|
COPY .npmrc package.json package-lock.json /src/
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . /src/
|
COPY . /src/
|
||||||
RUN npm run build
|
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)
|
Copyright (c) 2021-present 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
|
||||||
|
|||||||
118
README.md
@@ -1,70 +1,120 @@
|
|||||||
# Cinny
|
# Cinny
|
||||||
|
<p>
|
||||||
|
<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>
|
||||||
|
|
||||||
[](https://github.com/ajbura/cinny/tree/dev)
|
A Matrix client focusing primarily on simple, elegant and secure interface. The main goal is to have an instant messaging application that is easy on people and has a modern touch.
|
||||||
[](https://matrix.to/#/#cinny:matrix.org)
|
|
||||||
[](https://twitter.com/@cinnyapp)
|
|
||||||
[](https://opencollective.com/cinny)
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
- [About](#about)
|
|
||||||
- [Getting Started](https://cinny.in)
|
|
||||||
- [Contributing](./CONTRIBUTING.md)
|
|
||||||
- [Roadmap](https://github.com/ajbura/cinny/projects/11)
|
- [Roadmap](https://github.com/ajbura/cinny/projects/11)
|
||||||
|
- [Contributing](./CONTRIBUTING.md)
|
||||||
|
|
||||||
## About <a name = "about"></a>
|
## Getting started
|
||||||
|
Web app is available at https://app.cinny.in and gets updated on each new release. The `dev` branch is continuously deployed at https://dev.cinny.in but keep in mind that it could have things broken.
|
||||||
|
|
||||||
Cinny is a [Matrix](https://matrix.org) client focusing primarily on simple, elegant and secure interface.
|
You can also download our desktop app from [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
|
||||||
|
|
||||||

|
To host Cinny on your own, download tarball of the app from [GitHub release](https://github.com/cinnyapp/cinny/releases/latest).
|
||||||
|
You can serve the application with a webserver of your choice by simply copying `dist/` directory to the webroot.
|
||||||
|
To set default Homeserver on login and register page, place a customized [`config.json`](config.json) in webroot of your choice.
|
||||||
|
|
||||||
## Building and Running
|
Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by:
|
||||||
|
```
|
||||||
|
docker pull ajbura/cinny
|
||||||
|
```
|
||||||
|
or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by:
|
||||||
|
```
|
||||||
|
docker pull ghcr.io/cinnyapp/cinny:latest
|
||||||
|
```
|
||||||
|
|
||||||
### Running pre-compiled
|
<details>
|
||||||
|
<summary>PGP Public Key to verify tarball</summary>
|
||||||
|
|
||||||
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.
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
### Building from source
|
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>
|
||||||
|
|
||||||
Execute the following commands to compile the app from its source code:
|
## Local development
|
||||||
|
> 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 start a development server:
|
||||||
```sh
|
```sh
|
||||||
npm ci # Installs all dependencies
|
npm ci # Installs all dependencies
|
||||||
|
npm start # Serve a development version
|
||||||
|
```
|
||||||
|
|
||||||
|
To build the app:
|
||||||
|
```sh
|
||||||
npm run build # Compiles the app into the dist/ directory
|
npm run build # Compiles the app into the dist/ directory
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
### Running with Docker
|
### Running with Docker
|
||||||
|
|
||||||
This repository includes a Dockerfile, which builds the application from source and serves it with Nginx on port 80. To
|
This repository includes a Dockerfile, which builds the application from source and serves it with Nginx on port 80. To
|
||||||
use this locally, you can build the container like so:
|
use this locally, you can build the container like so:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker build -t cinny:latest .
|
docker build -t cinny:latest .
|
||||||
```
|
```
|
||||||
|
|
||||||
You can then run the container you've built with a command similar to this:
|
You can then run the container you've built with a command similar to this:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker run -p 8080:80 cinny:latest
|
docker run -p 8080:80 cinny:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
This will forward your `localhost` port 8080 to the container's port 80. You can visit the app in your browser by
|
This will forward your `localhost` port 8080 to the container's port 80. You can visit the app in your browser by navigating to `http://localhost:8080`.
|
||||||
navigating to `http://localhost:8080`.
|
|
||||||
|
|
||||||
Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by `docker pull ajbura/cinny`.
|
|
||||||
|
|
||||||
### Configuring default Homeserver
|
|
||||||
|
|
||||||
To set default Homeserver on login and register page, place a customized [`config.json`](config.json) in webroot of your choice.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright (c) 2021 Ajay Bura (ajbura)
|
Copyright (c) 2021-present 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,12 +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
|
"allowCustomHomeservers": true
|
||||||
}
|
}
|
||||||
13142
package-lock.json
generated
134
package.json
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "2.0.3",
|
"version": "2.2.2",
|
||||||
"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",
|
||||||
"node": ">=14.6.0"
|
"node": ">=14.15.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "webpack serve --config ./webpack.dev.js --open",
|
"start": "webpack serve --config ./webpack.dev.js --open",
|
||||||
@@ -15,73 +15,69 @@
|
|||||||
"author": "Ajay Bura",
|
"author": "Ajay Bura",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^4.5.10",
|
"@fontsource/inter": "4.5.12",
|
||||||
"@fontsource/roboto": "^4.5.5",
|
"@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",
|
"@khanacademy/simple-markdown": "0.8.4",
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@matrix-org/olm": "3.2.12",
|
||||||
"babel-polyfill": "^6.26.0",
|
"@tippyjs/react": "4.2.6",
|
||||||
"browser-encrypt-attachment": "^0.3.0",
|
"babel-polyfill": "6.26.0",
|
||||||
"dateformat": "^5.0.3",
|
"blurhash": "2.0.0",
|
||||||
"emojibase-data": "^7.0.1",
|
"browser-encrypt-attachment": "0.3.0",
|
||||||
"file-saver": "^2.0.5",
|
"dateformat": "5.0.3",
|
||||||
"flux": "^4.0.3",
|
"emojibase-data": "7.0.1",
|
||||||
"formik": "^2.2.9",
|
"file-saver": "2.0.5",
|
||||||
"html-react-parser": "^1.4.12",
|
"flux": "4.0.3",
|
||||||
"katex": "^0.15.3",
|
"formik": "2.2.9",
|
||||||
"linkifyjs": "^2.1.9",
|
"html-react-parser": "3.0.4",
|
||||||
"matrix-js-sdk": "^17.1.0",
|
"katex": "0.16.2",
|
||||||
"micromark": "^3.0.10",
|
"linkify-html": "4.0.0",
|
||||||
"micromark-extension-gfm": "^2.0.1",
|
"linkifyjs": "4.0.0",
|
||||||
"micromark-extension-math": "^2.0.2",
|
"matrix-js-sdk": "20.0.0",
|
||||||
"micromark-util-chunked": "^1.0.0",
|
"prop-types": "15.8.1",
|
||||||
"micromark-util-resolve-all": "^1.0.0",
|
"react": "17.0.2",
|
||||||
"micromark-util-symbol": "^1.0.1",
|
"react-autosize-textarea": "7.1.0",
|
||||||
"prop-types": "^15.8.1",
|
"react-blurhash": "0.1.3",
|
||||||
"react": "^17.0.2",
|
"react-dnd": "15.1.2",
|
||||||
"react-autosize-textarea": "^7.1.0",
|
"react-dnd-html5-backend": "15.1.3",
|
||||||
"react-dnd": "^15.1.2",
|
"react-dom": "17.0.2",
|
||||||
"react-dnd-html5-backend": "^15.1.3",
|
"react-google-recaptcha": "2.1.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-modal": "3.15.1",
|
||||||
"react-google-recaptcha": "^2.1.0",
|
"sanitize-html": "2.7.2",
|
||||||
"react-modal": "^3.15.1",
|
"tippy.js": "6.3.7",
|
||||||
"sanitize-html": "^2.7.0",
|
"twemoji": "14.0.2"
|
||||||
"tippy.js": "^6.3.7",
|
|
||||||
"twemoji": "^14.0.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.17.10",
|
"@babel/core": "7.19.1",
|
||||||
"@babel/preset-env": "^7.17.10",
|
"@babel/preset-env": "7.19.1",
|
||||||
"@babel/preset-react": "^7.16.7",
|
"@babel/preset-react": "7.18.6",
|
||||||
"assert": "^2.0.0",
|
"assert": "2.0.0",
|
||||||
"babel-loader": "^8.2.5",
|
"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.1.0",
|
||||||
"eslint": "^8.14.0",
|
"eslint": "8.24.0",
|
||||||
"eslint-config-airbnb": "^19.0.4",
|
"eslint-config-airbnb": "19.0.4",
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"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.31.8",
|
||||||
"eslint-plugin-react-hooks": "^4.5.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"favicons": "^6.2.2",
|
"html-loader": "4.2.0",
|
||||||
"favicons-webpack-plugin": "^5.0.2",
|
"html-webpack-plugin": "5.5.0",
|
||||||
"html-loader": "^3.1.0",
|
"mini-css-extract-plugin": "2.6.1",
|
||||||
"html-webpack-plugin": "^5.3.1",
|
"path-browserify": "1.0.1",
|
||||||
"mini-css-extract-plugin": "^2.6.0",
|
"sass": "1.55.0",
|
||||||
"path-browserify": "^1.0.1",
|
"sass-loader": "13.0.2",
|
||||||
"sass": "^1.51.0",
|
"stream-browserify": "3.0.0",
|
||||||
"sass-loader": "^12.6.0",
|
"style-loader": "3.3.1",
|
||||||
"stream-browserify": "^3.0.0",
|
"url": "0.11.0",
|
||||||
"style-loader": "^3.3.1",
|
"util": "0.12.4",
|
||||||
"url": "^0.11.0",
|
"webpack": "5.74.0",
|
||||||
"util": "^0.12.4",
|
"webpack-cli": "4.10.0",
|
||||||
"webpack": "^5.72.0",
|
"webpack-dev-server": "4.11.1",
|
||||||
"webpack-cli": "^4.9.2",
|
"webpack-merge": "5.8.0"
|
||||||
"webpack-dev-server": "^4.9.0",
|
|
||||||
"webpack-merge": "^5.7.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 32 KiB |
@@ -15,6 +15,28 @@
|
|||||||
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png">
|
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png">
|
||||||
<meta property="og:description" content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source.">
|
<meta property="og:description" content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source.">
|
||||||
<meta name="theme-color" content="#000000">
|
<meta name="theme-color" content="#000000">
|
||||||
|
|
||||||
|
|
||||||
|
<link id="favicon" rel="shortcut icon" href="./favicon.ico" />
|
||||||
|
|
||||||
|
<link rel="manifest" href="./manifest.json" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="application-name" content="Cinny" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Cinny" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
|
||||||
|
<link rel="apple-touch-icon" sizes="57x57" href="./res/apple/apple-touch-icon-57x57.png"/>
|
||||||
|
<link rel="apple-touch-icon" sizes="60x60" href="./res/apple/apple-touch-icon-60x60.png"/>
|
||||||
|
<link rel="apple-touch-icon" sizes="72x72" href="./res/apple/apple-touch-icon-72x72.png"/>
|
||||||
|
<link rel="apple-touch-icon" sizes="76x76" href="./res/apple/apple-touch-icon-76x76.png"/>
|
||||||
|
<link rel="apple-touch-icon" sizes="114x114" href="./res/apple/apple-touch-icon-114x114.png"/>
|
||||||
|
<link rel="apple-touch-icon" sizes="120x120" href="./res/apple/apple-touch-icon-120x120.png"/>
|
||||||
|
<link rel="apple-touch-icon" sizes="144x144" href="./res/apple/apple-touch-icon-144x144.png"/>
|
||||||
|
<link rel="apple-touch-icon" sizes="152x152" href="./res/apple/apple-touch-icon-152x152.png"/>
|
||||||
|
<link rel="apple-touch-icon" sizes="167x167" href="./res/apple/apple-touch-icon-167x167.png"/>
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="./res/apple/apple-touch-icon-180x180.png"/>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body id="appBody">
|
<body id="appBody">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
59
public/manifest.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "Cinny",
|
||||||
|
"short_name": "Cinny",
|
||||||
|
"description": "Yet another matrix client",
|
||||||
|
"dir": "auto",
|
||||||
|
"lang": "en-US",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"start_url": "/",
|
||||||
|
"background_color": "#fff",
|
||||||
|
"theme_color": "#fff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "android-chrome-36x36.png",
|
||||||
|
"sizes": "36x36",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "android-chrome-48x48.png",
|
||||||
|
"sizes": "48x48",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "android-chrome-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "android-chrome-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "android-chrome-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "android-chrome-256x256.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "android-chrome-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
Graphics (c) by Ajay Bura (ajbura)
|
Copyright (c) 2021-present Ajay Bura (ajbura)
|
||||||
|
|
||||||
Graphic content is licensed under a
|
Graphic content is licensed under a
|
||||||
Creative Commons Attribution 4.0 International License.
|
Creative Commons Attribution 4.0 International License.
|
||||||
|
|||||||
BIN
public/res/android/android-chrome-144x144.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/res/android/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
public/res/android/android-chrome-256x256.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
public/res/android/android-chrome-36x36.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/res/android/android-chrome-384x384.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/res/android/android-chrome-48x48.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/res/android/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/res/android/android-chrome-72x72.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/res/android/android-chrome-96x96.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/res/apple/apple-touch-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/res/apple/apple-touch-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/res/apple/apple-touch-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
public/res/apple/apple-touch-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
public/res/apple/apple-touch-icon-167x167.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
public/res/apple/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
public/res/apple/apple-touch-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/res/apple/apple-touch-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/res/apple/apple-touch-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/res/apple/apple-touch-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
@@ -1,12 +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="M14 3V5H17.8L12.9 9.9L14.3 11.3L19 6.6V10.2H21V3H14Z" fill="black"/>
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
<path d="M5 5H10V3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V14.2H19V19H5V5Z" 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>
|
|
||||||
<polygon points="14,3 14,5 17.8,5 12.9,9.9 14.3,11.3 19,6.6 19,10.2 21,10.2 21,3 "/>
|
|
||||||
<path d="M3,10.2h2V5h5V3H5C3.9,3,3,3.9,3,5V10.2z"/>
|
|
||||||
<path d="M5,14.2H3V19c0,1.1,0.9,2,2,2h5v-2H5V14.2z"/>
|
|
||||||
<path d="M19,19h-5v2h5c1.1,0,2-0.9,2-2v-4.8h-2V19z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 703 B After Width: | Height: | Size: 297 B |
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 |
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 |
13
public/res/svg/cinny-highlight.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_2707_1961)">
|
||||||
|
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
||||||
|
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
||||||
|
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#45B83B"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_2707_1961">
|
||||||
|
<rect width="18" height="18" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
13
public/res/svg/cinny-unread.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_2707_2015)">
|
||||||
|
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
||||||
|
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
||||||
|
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#989898"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_2707_2015">
|
||||||
|
<rect width="18" height="18" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1,8 +1,4 @@
|
|||||||
import { avatarInitials } from '../../../util/common';
|
import { avatarInitials, cssVar } from '../../../util/common';
|
||||||
|
|
||||||
function cssVar(name) {
|
|
||||||
return getComputedStyle(document.body).getPropertyValue(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// renders the avatar and returns it as an URL
|
// renders the avatar and returns it as an URL
|
||||||
export default async function renderAvatar({
|
export default async function renderAvatar({
|
||||||
|
|||||||
@@ -26,11 +26,11 @@
|
|||||||
&--icon {
|
&--icon {
|
||||||
@include dir.side(padding, var(--sp-tight), var(--sp-loose));
|
@include dir.side(padding, var(--sp-tight), var(--sp-loose));
|
||||||
|
|
||||||
|
}
|
||||||
.ic-raw {
|
.ic-raw {
|
||||||
@include dir.side(margin, 0, var(--sp-extra-tight));
|
@include dir.side(margin, 0, var(--sp-extra-tight));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin color($textColor, $iconColor) {
|
@mixin color($textColor, $iconColor) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import './Math.scss';
|
||||||
|
|
||||||
import katex from 'katex';
|
import katex from 'katex';
|
||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
|
|
||||||
import 'katex/dist/contrib/copy-tex';
|
import 'katex/dist/contrib/copy-tex';
|
||||||
import 'katex/dist/contrib/copy-tex.css';
|
|
||||||
|
|
||||||
const Math = React.memo(({
|
const Math = React.memo(({
|
||||||
content, throwOnError, errorColor, displayMode,
|
content, throwOnError, errorColor, displayMode,
|
||||||
|
|||||||
3
src/app/atoms/math/Math.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.katex-display {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
@@ -42,7 +42,6 @@ function RawModal({
|
|||||||
shouldCloseOnEsc={closeFromOutside}
|
shouldCloseOnEsc={closeFromOutside}
|
||||||
shouldCloseOnOverlayClick={closeFromOutside}
|
shouldCloseOnOverlayClick={closeFromOutside}
|
||||||
shouldReturnFocusAfterClose={false}
|
shouldReturnFocusAfterClose={false}
|
||||||
closeTimeoutMS={300}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,33 +1,9 @@
|
|||||||
.ReactModal__Overlay {
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 200ms var(--fluid-slide-up);
|
|
||||||
}
|
|
||||||
.ReactModal__Overlay--after-open{
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.ReactModal__Overlay--before-close{
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ReactModal__Content {
|
|
||||||
transform: translateY(100%);
|
|
||||||
transition: transform 200ms var(--fluid-slide-up);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ReactModal__Content--after-open{
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ReactModal__Content--before-close{
|
|
||||||
transform: translateY(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.raw-modal {
|
.raw-modal {
|
||||||
--small-modal-width: 525px;
|
--small-modal-width: 525px;
|
||||||
--medium-modal-width: 712px;
|
--medium-modal-width: 712px;
|
||||||
--large-modal-width: 1024px;
|
--large-modal-width: 1024px;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
border-radius: var(--bo-radius);
|
border-radius: var(--bo-radius);
|
||||||
@@ -61,3 +37,30 @@
|
|||||||
background-color: var(--bg-overlay);
|
background-color: var(--bg-overlay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ReactModal__Overlay {
|
||||||
|
animation: raw-modal--overlay 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ReactModal__Content {
|
||||||
|
animation: raw-modal--content 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes raw-modal--content {
|
||||||
|
0% {
|
||||||
|
transform: translateY(100px);
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes raw-modal--overlay {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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;
|
||||||
22
src/app/hooks/useAccountData.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import initMatrix from '../../client/initMatrix';
|
||||||
|
|
||||||
|
export function useAccountData(eventType) {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const [event, setEvent] = useState(mx.getAccountData(eventType));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleChange = (mEvent) => {
|
||||||
|
if (mEvent.getType() !== eventType) return;
|
||||||
|
setEvent(mEvent);
|
||||||
|
};
|
||||||
|
mx.on('accountData', handleChange);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener('accountData', handleChange);
|
||||||
|
};
|
||||||
|
}, [eventType]);
|
||||||
|
|
||||||
|
return event;
|
||||||
|
}
|
||||||
174
src/app/molecules/global-notification/GlobalNotification.jsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import { openReusableContextMenu } from '../../../client/action/navigation';
|
||||||
|
import { getEventCords } from '../../../util/common';
|
||||||
|
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import Button from '../../atoms/button/Button';
|
||||||
|
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||||
|
import SettingTile from '../setting-tile/SettingTile';
|
||||||
|
|
||||||
|
import NotificationSelector from './NotificationSelector';
|
||||||
|
|
||||||
|
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||||
|
|
||||||
|
import { useAccountData } from '../../hooks/useAccountData';
|
||||||
|
|
||||||
|
export const notifType = {
|
||||||
|
ON: 'on',
|
||||||
|
OFF: 'off',
|
||||||
|
NOISY: 'noisy',
|
||||||
|
};
|
||||||
|
export const typeToLabel = {
|
||||||
|
[notifType.ON]: 'On',
|
||||||
|
[notifType.OFF]: 'Off',
|
||||||
|
[notifType.NOISY]: 'Noisy',
|
||||||
|
};
|
||||||
|
Object.freeze(notifType);
|
||||||
|
|
||||||
|
const DM = '.m.rule.room_one_to_one';
|
||||||
|
const ENC_DM = '.m.rule.encrypted_room_one_to_one';
|
||||||
|
const ROOM = '.m.rule.message';
|
||||||
|
const ENC_ROOM = '.m.rule.encrypted';
|
||||||
|
|
||||||
|
export function getActionType(rule) {
|
||||||
|
const { actions } = rule;
|
||||||
|
if (actions.find((action) => action?.set_tweak === 'sound')) return notifType.NOISY;
|
||||||
|
if (actions.find((action) => action?.set_tweak === 'highlight')) return notifType.ON;
|
||||||
|
if (actions.find((action) => action === 'dont_notify')) return notifType.OFF;
|
||||||
|
return notifType.OFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTypeActions(type, highlightValue = false) {
|
||||||
|
if (type === notifType.OFF) return ['dont_notify'];
|
||||||
|
|
||||||
|
const highlight = { set_tweak: 'highlight' };
|
||||||
|
if (typeof highlightValue === 'boolean') highlight.value = highlightValue;
|
||||||
|
if (type === notifType.ON) return ['notify', highlight];
|
||||||
|
|
||||||
|
const sound = { set_tweak: 'sound', value: 'default' };
|
||||||
|
return ['notify', sound, highlight];
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGlobalNotif() {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const pushRules = useAccountData('m.push_rules')?.getContent();
|
||||||
|
const underride = pushRules?.global?.underride ?? [];
|
||||||
|
const rulesToType = {
|
||||||
|
[DM]: notifType.ON,
|
||||||
|
[ENC_DM]: notifType.ON,
|
||||||
|
[ROOM]: notifType.NOISY,
|
||||||
|
[ENC_ROOM]: notifType.NOISY,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRuleCondition = (rule) => {
|
||||||
|
const condition = [];
|
||||||
|
if (rule === DM || rule === ENC_DM) {
|
||||||
|
condition.push({ kind: 'room_member_count', is: '2' });
|
||||||
|
}
|
||||||
|
condition.push({
|
||||||
|
kind: 'event_match',
|
||||||
|
key: 'type',
|
||||||
|
pattern: [ENC_DM, ENC_ROOM].includes(rule) ? 'm.room.encrypted' : 'm.room.message',
|
||||||
|
});
|
||||||
|
return condition;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRule = (rule, type) => {
|
||||||
|
const content = pushRules ?? {};
|
||||||
|
if (!content.global) content.global = {};
|
||||||
|
if (!content.global.underride) content.global.underride = [];
|
||||||
|
const ur = content.global.underride;
|
||||||
|
let ruleContent = ur.find((action) => action?.rule_id === rule);
|
||||||
|
if (!ruleContent) {
|
||||||
|
ruleContent = {
|
||||||
|
conditions: getRuleCondition(type),
|
||||||
|
actions: [],
|
||||||
|
rule_id: rule,
|
||||||
|
default: true,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
ur.push(ruleContent);
|
||||||
|
}
|
||||||
|
ruleContent.actions = getTypeActions(type);
|
||||||
|
|
||||||
|
mx.setAccountData('m.push_rules', content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dmRule = underride.find((rule) => rule.rule_id === DM);
|
||||||
|
const encDmRule = underride.find((rule) => rule.rule_id === ENC_DM);
|
||||||
|
const roomRule = underride.find((rule) => rule.rule_id === ROOM);
|
||||||
|
const encRoomRule = underride.find((rule) => rule.rule_id === ENC_ROOM);
|
||||||
|
|
||||||
|
if (dmRule) rulesToType[DM] = getActionType(dmRule);
|
||||||
|
if (encDmRule) rulesToType[ENC_DM] = getActionType(encDmRule);
|
||||||
|
if (roomRule) rulesToType[ROOM] = getActionType(roomRule);
|
||||||
|
if (encRoomRule) rulesToType[ENC_ROOM] = getActionType(encRoomRule);
|
||||||
|
|
||||||
|
return [rulesToType, setRule];
|
||||||
|
}
|
||||||
|
|
||||||
|
function GlobalNotification() {
|
||||||
|
const [rulesToType, setRule] = useGlobalNotif();
|
||||||
|
|
||||||
|
const onSelect = (evt, rule) => {
|
||||||
|
openReusableContextMenu(
|
||||||
|
'bottom',
|
||||||
|
getEventCords(evt, '.btn-surface'),
|
||||||
|
(requestClose) => (
|
||||||
|
<NotificationSelector
|
||||||
|
value={rulesToType[rule]}
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (rulesToType[rule] !== value) setRule(rule, value);
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="global-notification">
|
||||||
|
<MenuHeader>Global Notifications</MenuHeader>
|
||||||
|
<SettingTile
|
||||||
|
title="Direct messages"
|
||||||
|
options={(
|
||||||
|
<Button onClick={(evt) => onSelect(evt, DM)} iconSrc={ChevronBottomIC}>
|
||||||
|
{ typeToLabel[rulesToType[DM]] }
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
content={<Text variant="b3">Default notification settings for all direct message.</Text>}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Encrypted direct messages"
|
||||||
|
options={(
|
||||||
|
<Button onClick={(evt) => onSelect(evt, ENC_DM)} iconSrc={ChevronBottomIC}>
|
||||||
|
{typeToLabel[rulesToType[ENC_DM]]}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
content={<Text variant="b3">Default notification settings for all encrypted direct message.</Text>}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Rooms messages"
|
||||||
|
options={(
|
||||||
|
<Button onClick={(evt) => onSelect(evt, ROOM)} iconSrc={ChevronBottomIC}>
|
||||||
|
{typeToLabel[rulesToType[ROOM]]}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
content={<Text variant="b3">Default notification settings for all room message.</Text>}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Encrypted rooms messages"
|
||||||
|
options={(
|
||||||
|
<Button onClick={(evt) => onSelect(evt, ENC_ROOM)} iconSrc={ChevronBottomIC}>
|
||||||
|
{typeToLabel[rulesToType[ENC_ROOM]]}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
content={<Text variant="b3">Default notification settings for all encrypted room message.</Text>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalNotification;
|
||||||
64
src/app/molecules/global-notification/IgnoreUserList.jsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './IgnoreUserList.scss';
|
||||||
|
|
||||||
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import * as roomActions from '../../../client/action/room';
|
||||||
|
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import Chip from '../../atoms/chip/Chip';
|
||||||
|
import Input from '../../atoms/input/Input';
|
||||||
|
import Button from '../../atoms/button/Button';
|
||||||
|
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||||
|
import SettingTile from '../setting-tile/SettingTile';
|
||||||
|
|
||||||
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
|
||||||
|
import { useAccountData } from '../../hooks/useAccountData';
|
||||||
|
|
||||||
|
function IgnoreUserList() {
|
||||||
|
useAccountData('m.ignored_user_list');
|
||||||
|
const ignoredUsers = initMatrix.matrixClient.getIgnoredUsers();
|
||||||
|
|
||||||
|
const handleSubmit = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const { ignoreInput } = evt.target.elements;
|
||||||
|
const value = ignoreInput.value.trim();
|
||||||
|
const userIds = value.split(' ').filter((v) => v.match(/^@\S+:\S+$/));
|
||||||
|
if (userIds.length === 0) return;
|
||||||
|
ignoreInput.value = '';
|
||||||
|
roomActions.ignore(userIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ignore-user-list">
|
||||||
|
<MenuHeader>Ignored users</MenuHeader>
|
||||||
|
<SettingTile
|
||||||
|
title="Ignore user"
|
||||||
|
content={(
|
||||||
|
<div className="ignore-user-list__users">
|
||||||
|
<Text variant="b3">Ignore userId if you do not want to receive their messages or invites.</Text>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Input name="ignoreInput" required />
|
||||||
|
<Button variant="primary" type="submit">Ignore</Button>
|
||||||
|
</form>
|
||||||
|
{ignoredUsers.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{ignoredUsers.map((uId) => (
|
||||||
|
<Chip
|
||||||
|
iconSrc={CrossIC}
|
||||||
|
key={uId}
|
||||||
|
text={uId}
|
||||||
|
iconColor={CrossIC}
|
||||||
|
onClick={() => roomActions.unignore([uId])}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IgnoreUserList;
|
||||||
17
src/app/molecules/global-notification/IgnoreUserList.scss
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.ignore-user-list {
|
||||||
|
&__users {
|
||||||
|
& form,
|
||||||
|
& > div:last-child {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
& form {
|
||||||
|
margin: var(--sp-extra-tight) 0 var(--sp-normal);
|
||||||
|
.input-container {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
239
src/app/molecules/global-notification/KeywordNotification.jsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './KeywordNotification.scss';
|
||||||
|
|
||||||
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import { openReusableContextMenu } from '../../../client/action/navigation';
|
||||||
|
import { getEventCords } from '../../../util/common';
|
||||||
|
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import Chip from '../../atoms/chip/Chip';
|
||||||
|
import Input from '../../atoms/input/Input';
|
||||||
|
import Button from '../../atoms/button/Button';
|
||||||
|
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||||
|
import SettingTile from '../setting-tile/SettingTile';
|
||||||
|
|
||||||
|
import NotificationSelector from './NotificationSelector';
|
||||||
|
|
||||||
|
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
||||||
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
|
||||||
|
import { useAccountData } from '../../hooks/useAccountData';
|
||||||
|
import {
|
||||||
|
notifType, typeToLabel, getActionType, getTypeActions,
|
||||||
|
} from './GlobalNotification';
|
||||||
|
|
||||||
|
const DISPLAY_NAME = '.m.rule.contains_display_name';
|
||||||
|
const ROOM_PING = '.m.rule.roomnotif';
|
||||||
|
const USERNAME = '.m.rule.contains_user_name';
|
||||||
|
const KEYWORD = 'keyword';
|
||||||
|
|
||||||
|
function useKeywordNotif() {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
|
const pushRules = useAccountData('m.push_rules')?.getContent();
|
||||||
|
const override = pushRules?.global?.override ?? [];
|
||||||
|
const content = pushRules?.global?.content ?? [];
|
||||||
|
|
||||||
|
const rulesToType = {
|
||||||
|
[DISPLAY_NAME]: notifType.NOISY,
|
||||||
|
[ROOM_PING]: notifType.NOISY,
|
||||||
|
[USERNAME]: notifType.NOISY,
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRule = (rule, type) => {
|
||||||
|
const evtContent = pushRules ?? {};
|
||||||
|
if (!evtContent.global) evtContent.global = {};
|
||||||
|
if (!evtContent.global.override) evtContent.global.override = [];
|
||||||
|
if (!evtContent.global.content) evtContent.global.content = [];
|
||||||
|
const or = evtContent.global.override;
|
||||||
|
const ct = evtContent.global.content;
|
||||||
|
|
||||||
|
if (rule === DISPLAY_NAME || rule === ROOM_PING) {
|
||||||
|
let orRule = or.find((r) => r?.rule_id === rule);
|
||||||
|
if (!orRule) {
|
||||||
|
orRule = {
|
||||||
|
conditions: [],
|
||||||
|
actions: [],
|
||||||
|
rule_id: rule,
|
||||||
|
default: true,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
or.push(orRule);
|
||||||
|
}
|
||||||
|
if (rule === DISPLAY_NAME) {
|
||||||
|
orRule.conditions = [{ kind: 'contains_display_name' }];
|
||||||
|
orRule.actions = getTypeActions(type, true);
|
||||||
|
} else {
|
||||||
|
orRule.conditions = [
|
||||||
|
{ kind: 'event_match', key: 'content.body', pattern: '@room' },
|
||||||
|
{ kind: 'sender_notification_permission', key: 'room' },
|
||||||
|
];
|
||||||
|
orRule.actions = getTypeActions(type, true);
|
||||||
|
}
|
||||||
|
} else if (rule === USERNAME) {
|
||||||
|
let usernameRule = ct.find((r) => r?.rule_id === rule);
|
||||||
|
if (!usernameRule) {
|
||||||
|
const userId = mx.getUserId();
|
||||||
|
const username = userId.match(/^@?(\S+):(\S+)$/)?.[1] ?? userId;
|
||||||
|
usernameRule = {
|
||||||
|
actions: [],
|
||||||
|
default: true,
|
||||||
|
enabled: true,
|
||||||
|
pattern: username,
|
||||||
|
rule_id: rule,
|
||||||
|
};
|
||||||
|
ct.push(usernameRule);
|
||||||
|
}
|
||||||
|
usernameRule.actions = getTypeActions(type, true);
|
||||||
|
} else {
|
||||||
|
const keyRules = ct.filter((r) => r.rule_id !== USERNAME);
|
||||||
|
keyRules.forEach((r) => {
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
r.actions = getTypeActions(type, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mx.setAccountData('m.push_rules', evtContent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addKeyword = (keyword) => {
|
||||||
|
if (content.find((r) => r.rule_id === keyword)) return;
|
||||||
|
content.push({
|
||||||
|
rule_id: keyword,
|
||||||
|
pattern: keyword,
|
||||||
|
enabled: true,
|
||||||
|
default: false,
|
||||||
|
actions: getTypeActions(rulesToType[KEYWORD] ?? notifType.NOISY, true),
|
||||||
|
});
|
||||||
|
mx.setAccountData('m.push_rules', pushRules);
|
||||||
|
};
|
||||||
|
const removeKeyword = (rule) => {
|
||||||
|
pushRules.global.content = content.filter((r) => r.rule_id !== rule.rule_id);
|
||||||
|
mx.setAccountData('m.push_rules', pushRules);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dsRule = override.find((rule) => rule.rule_id === DISPLAY_NAME);
|
||||||
|
const roomRule = override.find((rule) => rule.rule_id === ROOM_PING);
|
||||||
|
const usernameRule = content.find((rule) => rule.rule_id === USERNAME);
|
||||||
|
const keywordRule = content.find((rule) => rule.rule_id !== USERNAME);
|
||||||
|
|
||||||
|
if (dsRule) rulesToType[DISPLAY_NAME] = getActionType(dsRule);
|
||||||
|
if (roomRule) rulesToType[ROOM_PING] = getActionType(roomRule);
|
||||||
|
if (usernameRule) rulesToType[USERNAME] = getActionType(usernameRule);
|
||||||
|
if (keywordRule) rulesToType[KEYWORD] = getActionType(keywordRule);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rulesToType,
|
||||||
|
pushRules,
|
||||||
|
setRule,
|
||||||
|
addKeyword,
|
||||||
|
removeKeyword,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function GlobalNotification() {
|
||||||
|
const {
|
||||||
|
rulesToType,
|
||||||
|
pushRules,
|
||||||
|
setRule,
|
||||||
|
addKeyword,
|
||||||
|
removeKeyword,
|
||||||
|
} = useKeywordNotif();
|
||||||
|
|
||||||
|
const keywordRules = pushRules?.global?.content.filter((r) => r.rule_id !== USERNAME) ?? [];
|
||||||
|
|
||||||
|
const onSelect = (evt, rule) => {
|
||||||
|
openReusableContextMenu(
|
||||||
|
'bottom',
|
||||||
|
getEventCords(evt, '.btn-surface'),
|
||||||
|
(requestClose) => (
|
||||||
|
<NotificationSelector
|
||||||
|
value={rulesToType[rule]}
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (rulesToType[rule] !== value) setRule(rule, value);
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const { keywordInput } = evt.target.elements;
|
||||||
|
const value = keywordInput.value.trim();
|
||||||
|
if (value === '') return;
|
||||||
|
addKeyword(value);
|
||||||
|
keywordInput.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="keyword-notification">
|
||||||
|
<MenuHeader>Mentions & keywords</MenuHeader>
|
||||||
|
<SettingTile
|
||||||
|
title="Message containing my display name"
|
||||||
|
options={(
|
||||||
|
<Button onClick={(evt) => onSelect(evt, DISPLAY_NAME)} iconSrc={ChevronBottomIC}>
|
||||||
|
{ typeToLabel[rulesToType[DISPLAY_NAME]] }
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
content={<Text variant="b3">Default notification settings for all message containing your display name.</Text>}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Message containing my username"
|
||||||
|
options={(
|
||||||
|
<Button onClick={(evt) => onSelect(evt, USERNAME)} iconSrc={ChevronBottomIC}>
|
||||||
|
{ typeToLabel[rulesToType[USERNAME]] }
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
content={<Text variant="b3">Default notification settings for all message containing your username.</Text>}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Message containing @room"
|
||||||
|
options={(
|
||||||
|
<Button onClick={(evt) => onSelect(evt, ROOM_PING)} iconSrc={ChevronBottomIC}>
|
||||||
|
{typeToLabel[rulesToType[ROOM_PING]]}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
content={<Text variant="b3">Default notification settings for all messages containing @room.</Text>}
|
||||||
|
/>
|
||||||
|
{ rulesToType[KEYWORD] && (
|
||||||
|
<SettingTile
|
||||||
|
title="Message containing keywords"
|
||||||
|
options={(
|
||||||
|
<Button onClick={(evt) => onSelect(evt, KEYWORD)} iconSrc={ChevronBottomIC}>
|
||||||
|
{typeToLabel[rulesToType[KEYWORD]]}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
content={<Text variant="b3">Default notification settings for all message containing keywords.</Text>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SettingTile
|
||||||
|
title="Keywords"
|
||||||
|
content={(
|
||||||
|
<div className="keyword-notification__keyword">
|
||||||
|
<Text variant="b3">Get notification when a message contains keyword.</Text>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Input name="keywordInput" required />
|
||||||
|
<Button variant="primary" type="submit">Add</Button>
|
||||||
|
</form>
|
||||||
|
{keywordRules.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{keywordRules.map((rule) => (
|
||||||
|
<Chip
|
||||||
|
iconSrc={CrossIC}
|
||||||
|
key={rule.rule_id}
|
||||||
|
text={rule.pattern}
|
||||||
|
iconColor={CrossIC}
|
||||||
|
onClick={() => removeKeyword(rule)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalNotification;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.keyword-notification {
|
||||||
|
&__keyword {
|
||||||
|
& form,
|
||||||
|
& > div:last-child {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
& form {
|
||||||
|
margin: var(--sp-extra-tight) 0 var(--sp-normal);
|
||||||
|
.input-container {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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 NotificationSelector({
|
||||||
|
value, onSelect,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<MenuHeader>Notification</MenuHeader>
|
||||||
|
<MenuItem iconSrc={value === 'off' ? CheckIC : null} variant={value === 'off' ? 'positive' : 'surface'} onClick={() => onSelect('off')}>Off</MenuItem>
|
||||||
|
<MenuItem iconSrc={value === 'on' ? CheckIC : null} variant={value === 'on' ? 'positive' : 'surface'} onClick={() => onSelect('on')}>On</MenuItem>
|
||||||
|
<MenuItem iconSrc={value === 'noisy' ? CheckIC : null} variant={value === 'noisy' ? 'positive' : 'surface'} onClick={() => onSelect('noisy')}>Noisy</MenuItem>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationSelector.propTypes = {
|
||||||
|
value: PropTypes.oneOf(['off', 'on', 'noisy']).isRequired,
|
||||||
|
onSelect: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationSelector;
|
||||||
47
src/app/molecules/image-lightbox/ImageLightbox.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './ImageLightbox.scss';
|
||||||
|
import FileSaver from 'file-saver';
|
||||||
|
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import RawModal from '../../atoms/modal/RawModal';
|
||||||
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
|
|
||||||
|
import DownloadSVG from '../../../../public/res/ic/outlined/download.svg';
|
||||||
|
import ExternalSVG from '../../../../public/res/ic/outlined/external.svg';
|
||||||
|
|
||||||
|
function ImageLightbox({
|
||||||
|
url, alt, isOpen, onRequestClose,
|
||||||
|
}) {
|
||||||
|
const handleDownload = () => {
|
||||||
|
FileSaver.saveAs(url, alt);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RawModal
|
||||||
|
className="image-lightbox__modal"
|
||||||
|
overlayClassName="image-lightbox__overlay"
|
||||||
|
isOpen={isOpen}
|
||||||
|
onRequestClose={onRequestClose}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<div className="image-lightbox__header">
|
||||||
|
<Text variant="b2" weight="medium">{alt}</Text>
|
||||||
|
<IconButton onClick={() => window.open(url)} size="small" src={ExternalSVG} />
|
||||||
|
<IconButton onClick={handleDownload} size="small" src={DownloadSVG} />
|
||||||
|
</div>
|
||||||
|
<div className="image-lightbox__content">
|
||||||
|
<img src={url} alt={alt} />
|
||||||
|
</div>
|
||||||
|
</RawModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageLightbox.propTypes = {
|
||||||
|
url: PropTypes.string.isRequired,
|
||||||
|
alt: PropTypes.string.isRequired,
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onRequestClose: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageLightbox;
|
||||||
50
src/app/molecules/image-lightbox/ImageLightbox.scss
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
@use '../../partials/flex';
|
||||||
|
@use '../../partials/text';
|
||||||
|
|
||||||
|
.image-lightbox__modal {
|
||||||
|
box-shadow: none;
|
||||||
|
width: unset;
|
||||||
|
gap: var(--sp-normal);
|
||||||
|
|
||||||
|
border-radius: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
& .text {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
& .ic-raw {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-lightbox__overlay {
|
||||||
|
background-color: var(--bg-overlay-low);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.image-lightbox__header > *,
|
||||||
|
.image-lightbox__content > * {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
.image-lightbox__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
& > .text {
|
||||||
|
@extend .cp-fx__item-one;
|
||||||
|
@extend .cp-txt__ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.image-lightbox__content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
max-height: 80vh;
|
||||||
|
|
||||||
|
& img {
|
||||||
|
background-color: var(--bg-surface-low);
|
||||||
|
object-fit: contain;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
border-radius: var(--bo-radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
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,42 +4,17 @@ 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';
|
||||||
|
import ImageLightbox from '../image-lightbox/ImageLightbox';
|
||||||
|
|
||||||
import DownloadSVG from '../../../../public/res/ic/outlined/download.svg';
|
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
|
import { getBlobSafeMimeType } from '../../../util/mimetypes';
|
||||||
const ALLOWED_BLOB_MIMETYPES = [
|
|
||||||
'image/jpeg',
|
|
||||||
'image/gif',
|
|
||||||
'image/png',
|
|
||||||
|
|
||||||
'video/mp4',
|
|
||||||
'video/webm',
|
|
||||||
'video/ogg',
|
|
||||||
|
|
||||||
'audio/mp4',
|
|
||||||
'audio/webm',
|
|
||||||
'audio/aac',
|
|
||||||
'audio/mpeg',
|
|
||||||
'audio/ogg',
|
|
||||||
'audio/wave',
|
|
||||||
'audio/wav',
|
|
||||||
'audio/x-wav',
|
|
||||||
'audio/x-pn-wav',
|
|
||||||
'audio/flac',
|
|
||||||
'audio/x-flac',
|
|
||||||
];
|
|
||||||
function getBlobSafeMimeType(mimetype) {
|
|
||||||
if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) {
|
|
||||||
return 'application/octet-stream';
|
|
||||||
}
|
|
||||||
return mimetype;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getDecryptedBlob(response, type, decryptData) {
|
async function getDecryptedBlob(response, type, decryptData) {
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
@@ -61,9 +36,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,7 +121,82 @@ 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 [blur, setBlur] = useState(true);
|
||||||
|
const [lightbox, setLightbox] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unmounted = false;
|
||||||
|
async function fetchUrl() {
|
||||||
|
const myUrl = await getUrl(link, type, file);
|
||||||
|
if (unmounted) return;
|
||||||
|
setUrl(myUrl);
|
||||||
|
}
|
||||||
|
fetchUrl();
|
||||||
|
return () => {
|
||||||
|
unmounted = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleLightbox = () => {
|
||||||
|
if (!url) return;
|
||||||
|
setLightbox(!lightbox);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="file-container">
|
||||||
|
<div
|
||||||
|
style={{ height: width !== null ? getNativeHeight(width, height) : 'unset' }}
|
||||||
|
className="image-container"
|
||||||
|
role="button"
|
||||||
|
tabIndex="0"
|
||||||
|
onClick={toggleLightbox}
|
||||||
|
onKeyDown={toggleLightbox}
|
||||||
|
>
|
||||||
|
{ 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>
|
||||||
|
{url && (
|
||||||
|
<ImageLightbox
|
||||||
|
url={url}
|
||||||
|
alt={name}
|
||||||
|
isOpen={lightbox}
|
||||||
|
onRequestClose={toggleLightbox}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Image.defaultProps = {
|
||||||
|
file: null,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
type: '',
|
||||||
|
blurhash: '',
|
||||||
|
};
|
||||||
|
Image.propTypes = {
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
width: PropTypes.number,
|
||||||
|
height: PropTypes.number,
|
||||||
|
link: PropTypes.string.isRequired,
|
||||||
|
file: PropTypes.shape({}),
|
||||||
|
type: PropTypes.string,
|
||||||
|
blurhash: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Sticker({
|
||||||
|
name, height, width, link, file, type,
|
||||||
}) {
|
}) {
|
||||||
const [url, setUrl] = useState(null);
|
const [url, setUrl] = useState(null);
|
||||||
|
|
||||||
@@ -165,21 +214,18 @@ function Image({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="file-container">
|
<div className="sticker-container" style={{ height: width !== null ? getNativeHeight(width, height, 128) : 'unset' }}>
|
||||||
<FileHeader name={name} link={url || link} type={type} external />
|
{ url !== null && <img src={url || link} title={name} alt={name} />}
|
||||||
<div style={{ height: width !== null ? getNativeHeight(width, height) : 'unset' }} className="image-container">
|
|
||||||
{ url !== null && <img src={url || link} alt={name} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Image.defaultProps = {
|
Sticker.defaultProps = {
|
||||||
file: null,
|
file: null,
|
||||||
|
type: '',
|
||||||
width: null,
|
width: null,
|
||||||
height: null,
|
height: null,
|
||||||
type: '',
|
|
||||||
};
|
};
|
||||||
Image.propTypes = {
|
Sticker.propTypes = {
|
||||||
name: PropTypes.string.isRequired,
|
name: PropTypes.string.isRequired,
|
||||||
width: PropTypes.number,
|
width: PropTypes.number,
|
||||||
height: PropTypes.number,
|
height: PropTypes.number,
|
||||||
@@ -232,12 +278,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 +299,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,13 +316,19 @@ 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} />}
|
||||||
|
{ 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 */
|
/* 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)} />
|
||||||
@@ -290,22 +343,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,40 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.image-container {
|
||||||
|
max-height: 460px;
|
||||||
|
img {
|
||||||
|
cursor: pointer;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.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,11 +5,12 @@ 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';
|
||||||
import { getUsername, getUsernameOfRoomMember, parseReply } from '../../../util/matrixUtil';
|
import {
|
||||||
|
getUsername, getUsernameOfRoomMember, parseReply, trimHTMLReply,
|
||||||
|
} from '../../../util/matrixUtil';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { getEventCords } from '../../../util/common';
|
import { getEventCords } from '../../../util/common';
|
||||||
import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
|
import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
|
||||||
@@ -25,6 +26,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';
|
||||||
|
|
||||||
@@ -37,6 +39,8 @@ 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';
|
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||||
|
import { getBlobSafeMimeType } from '../../../util/mimetypes';
|
||||||
|
import { html, plain } from '../../../util/markdown';
|
||||||
|
|
||||||
function PlaceholderMessage() {
|
function PlaceholderMessage() {
|
||||||
return (
|
return (
|
||||||
@@ -68,7 +72,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
|
||||||
@@ -82,14 +86,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 }) {
|
||||||
@@ -162,8 +172,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);
|
||||||
@@ -200,7 +210,13 @@ const MessageBody = React.memo(({
|
|||||||
let content = null;
|
let content = null;
|
||||||
if (isCustomHTML) {
|
if (isCustomHTML) {
|
||||||
try {
|
try {
|
||||||
content = twemojify(sanitizeCustomHtml(body), undefined, true, false, true);
|
content = twemojify(
|
||||||
|
sanitizeCustomHtml(initMatrix.matrixClient, body),
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
console.error('Malformed custom html: ', body);
|
console.error('Malformed custom html: ', body);
|
||||||
content = twemojify(body, undefined);
|
content = twemojify(body, undefined);
|
||||||
@@ -235,12 +251,12 @@ const MessageBody = React.memo(({
|
|||||||
if (!isCustomHTML) {
|
if (!isCustomHTML) {
|
||||||
// If this is a plaintext message, wrap it in a <p> element (automatically applying
|
// If this is a plaintext message, wrap it in a <p> element (automatically applying
|
||||||
// white-space: pre-wrap) in order to preserve newlines
|
// white-space: pre-wrap) in order to preserve newlines
|
||||||
content = (<p>{content}</p>);
|
content = (<p className="message__body-plain">{content}</p>);
|
||||||
}
|
}
|
||||||
|
|
||||||
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' && (
|
||||||
<>
|
<>
|
||||||
{'* '}
|
{'* '}
|
||||||
@@ -277,14 +293,19 @@ function MessageEdit({ body, onSave, onCancel }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
if (e.keyCode === 13 && e.shiftKey === false) {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSave(editInputRef.current.value);
|
onCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && e.shiftKey === false) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave(editInputRef.current.value, body);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="message__edit" onSubmit={(e) => { e.preventDefault(); onSave(editInputRef.current.value); }}>
|
<form className="message__edit" onSubmit={(e) => { e.preventDefault(); onSave(editInputRef.current.value, body); }}>
|
||||||
<Input
|
<Input
|
||||||
forwardRef={editInputRef}
|
forwardRef={editInputRef}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -322,7 +343,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();
|
||||||
@@ -330,17 +351,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) => (
|
||||||
@@ -354,24 +375,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}
|
||||||
@@ -380,7 +399,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>
|
||||||
@@ -388,9 +407,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,
|
||||||
@@ -401,11 +423,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 = {
|
||||||
@@ -414,6 +435,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 {
|
||||||
@@ -429,9 +451,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
|
||||||
@@ -439,7 +462,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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,13 +472,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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -607,7 +630,14 @@ 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';
|
const safeMimetype = getBlobSafeMimeType(mContent.info?.mimetype);
|
||||||
|
if (mE.getType() === 'm.sticker') {
|
||||||
|
msgType = 'm.sticker';
|
||||||
|
} else if (safeMimetype === 'application/octet-stream') {
|
||||||
|
msgType = 'm.file';
|
||||||
|
}
|
||||||
|
|
||||||
|
const blurhash = mContent?.info?.['xyz.amorgan.blurhash'];
|
||||||
|
|
||||||
switch (msgType) {
|
switch (msgType) {
|
||||||
case 'm.file':
|
case 'm.file':
|
||||||
@@ -628,6 +658,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':
|
||||||
@@ -654,6 +696,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:
|
||||||
@@ -674,9 +717,9 @@ function getEditedBody(editedMEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Message({
|
function Message({
|
||||||
mEvent, isBodyOnly, roomTimeline, focus, time,
|
mEvent, isBodyOnly, roomTimeline,
|
||||||
|
focus, fullTime, isEdit, setEdit, cancelEdit,
|
||||||
}) {
|
}) {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const roomId = mEvent.getRoomId();
|
const roomId = mEvent.getRoomId();
|
||||||
const { editedTimeline, reactionTimeline } = roomTimeline ?? {};
|
const { editedTimeline, reactionTimeline } = roomTimeline ?? {};
|
||||||
|
|
||||||
@@ -689,36 +732,37 @@ function Message({
|
|||||||
let { body } = content;
|
let { body } = content;
|
||||||
const username = mEvent.sender ? getUsernameOfRoomMember(mEvent.sender) : getUsername(senderId);
|
const username = mEvent.sender ? getUsernameOfRoomMember(mEvent.sender) : getUsername(senderId);
|
||||||
const avatarSrc = mEvent.sender?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop') ?? null;
|
const avatarSrc = mEvent.sender?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop') ?? null;
|
||||||
|
let isCustomHTML = content.format === 'org.matrix.custom.html';
|
||||||
|
let customHTML = isCustomHTML ? content.formatted_body : null;
|
||||||
|
|
||||||
const edit = useCallback(() => {
|
const edit = useCallback(() => {
|
||||||
setIsEditing(true);
|
setEdit(eventId);
|
||||||
}, []);
|
}, []);
|
||||||
const reply = useCallback(() => {
|
const reply = useCallback(() => {
|
||||||
replyTo(senderId, mEvent.getId(), body);
|
replyTo(senderId, mEvent.getId(), body, customHTML);
|
||||||
}, [body]);
|
}, [body, customHTML]);
|
||||||
|
|
||||||
if (body === undefined) return null;
|
|
||||||
if (msgType === 'm.emote') className.push('message--type-emote');
|
if (msgType === 'm.emote') className.push('message--type-emote');
|
||||||
|
|
||||||
let isCustomHTML = content.format === 'org.matrix.custom.html';
|
|
||||||
const isEdited = roomTimeline ? editedTimeline.has(eventId) : false;
|
const isEdited = roomTimeline ? editedTimeline.has(eventId) : false;
|
||||||
const haveReactions = roomTimeline
|
const haveReactions = roomTimeline
|
||||||
? reactionTimeline.has(eventId) || !!mEvent.getServerAggregatedRelation('m.annotation')
|
? reactionTimeline.has(eventId) || !!mEvent.getServerAggregatedRelation('m.annotation')
|
||||||
: false;
|
: false;
|
||||||
const isReply = !!mEvent.replyEventId;
|
const isReply = !!mEvent.replyEventId;
|
||||||
let customHTML = isCustomHTML ? content.formatted_body : null;
|
|
||||||
|
|
||||||
if (isEdited) {
|
if (isEdited) {
|
||||||
const editedList = editedTimeline.get(eventId);
|
const editedList = editedTimeline.get(eventId);
|
||||||
const editedMEvent = editedList[editedList.length - 1];
|
const editedMEvent = editedList[editedList.length - 1];
|
||||||
[body, isCustomHTML, customHTML] = getEditedBody(editedMEvent);
|
[body, isCustomHTML, customHTML] = getEditedBody(editedMEvent);
|
||||||
if (typeof body !== 'string') return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isReply) {
|
if (isReply) {
|
||||||
body = parseReply(body)?.body ?? body;
|
body = parseReply(body)?.body ?? body;
|
||||||
|
customHTML = trimHTMLReply(customHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof body !== 'string') body = '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className.join(' ')}>
|
<div className={className.join(' ')}>
|
||||||
{
|
{
|
||||||
@@ -735,7 +779,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
|
||||||
@@ -743,7 +792,7 @@ function Message({
|
|||||||
eventId={mEvent.replyEventId}
|
eventId={mEvent.replyEventId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isEditing && (
|
{!isEdit && (
|
||||||
<MessageBody
|
<MessageBody
|
||||||
senderName={username}
|
senderName={username}
|
||||||
isCustomHTML={isCustomHTML}
|
isCustomHTML={isCustomHTML}
|
||||||
@@ -752,22 +801,24 @@ function Message({
|
|||||||
isEdited={isEdited}
|
isEdited={isEdited}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isEditing && (
|
{isEdit && (
|
||||||
<MessageEdit
|
<MessageEdit
|
||||||
body={body}
|
body={(customHTML
|
||||||
onSave={(newBody) => {
|
? html(customHTML, { kind: 'edit', onlyPlain: true }).plain
|
||||||
if (newBody !== body) {
|
: plain(body, { kind: 'edit', onlyPlain: true }).plain)}
|
||||||
|
onSave={(newBody, oldBody) => {
|
||||||
|
if (newBody !== oldBody) {
|
||||||
initMatrix.roomsInput.sendEditedMessage(roomId, mEvent, newBody);
|
initMatrix.roomsInput.sendEditedMessage(roomId, mEvent, newBody);
|
||||||
}
|
}
|
||||||
setIsEditing(false);
|
cancelEdit();
|
||||||
}}
|
}}
|
||||||
onCancel={() => setIsEditing(false)}
|
onCancel={cancelEdit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{haveReactions && (
|
{haveReactions && (
|
||||||
<MessageReactionGroup roomTimeline={roomTimeline} mEvent={mEvent} />
|
<MessageReactionGroup roomTimeline={roomTimeline} mEvent={mEvent} />
|
||||||
)}
|
)}
|
||||||
{roomTimeline && !isEditing && (
|
{roomTimeline && !isEdit && (
|
||||||
<MessageOptions
|
<MessageOptions
|
||||||
roomTimeline={roomTimeline}
|
roomTimeline={roomTimeline}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
@@ -783,13 +834,20 @@ Message.defaultProps = {
|
|||||||
isBodyOnly: false,
|
isBodyOnly: false,
|
||||||
focus: false,
|
focus: false,
|
||||||
roomTimeline: null,
|
roomTimeline: null,
|
||||||
|
fullTime: false,
|
||||||
|
isEdit: false,
|
||||||
|
setEdit: null,
|
||||||
|
cancelEdit: null,
|
||||||
};
|
};
|
||||||
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,
|
||||||
|
isEdit: PropTypes.bool,
|
||||||
|
setEdit: PropTypes.func,
|
||||||
|
cancelEdit: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { Message, MessageReply, PlaceholderMessage };
|
export { Message, MessageReply, PlaceholderMessage };
|
||||||
|
|||||||
@@ -101,7 +101,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.message__header {
|
.message__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -115,10 +114,16 @@
|
|||||||
@extend .cp-txt__ellipsis;
|
@extend .cp-txt__ellipsis;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
& > span:last-child { display: none; }
|
& > span:last-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
&:hover {
|
&:hover {
|
||||||
& > span:first-child { display: none; }
|
& > span:first-child {
|
||||||
& > span:last-child { display: block; }
|
display: none;
|
||||||
|
}
|
||||||
|
& > span:last-child {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +163,7 @@
|
|||||||
.message__body {
|
.message__body {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|
||||||
& > .text > * {
|
& > .text > .message__body-plain {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +174,10 @@
|
|||||||
white-space: initial !important;
|
white-space: initial !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& > .text > p + p {
|
||||||
|
margin-top: var(--sp-normal);
|
||||||
|
}
|
||||||
|
|
||||||
& span[data-mx-pill] {
|
& span[data-mx-pill] {
|
||||||
background-color: hsla(0, 0%, 64%, 0.15);
|
background-color: hsla(0, 0%, 64%, 0.15);
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
@@ -191,7 +200,7 @@
|
|||||||
& span[data-mx-spoiler] {
|
& span[data-mx-spoiler] {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: rgba(124, 124, 124, 0.5);
|
background-color: rgba(124, 124, 124, 0.5);
|
||||||
color:transparent;
|
color: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
@@ -220,6 +229,8 @@
|
|||||||
padding: var(--sp-extra-tight) 0;
|
padding: var(--sp-extra-tight) 0;
|
||||||
&-btns button {
|
&-btns button {
|
||||||
margin: var(--sp-tight) 0 0 0;
|
margin: var(--sp-tight) 0 0 0;
|
||||||
|
padding: var(--sp-ultra-tight) var(--sp-tight);
|
||||||
|
min-width: 0;
|
||||||
@include dir.side(margin, 0, var(--sp-tight));
|
@include dir.side(margin, 0, var(--sp-tight));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,13 +261,12 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
& .react-emoji {
|
& .react-emoji {
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
}
|
}
|
||||||
&-count {
|
&-count {
|
||||||
margin: 0 var(--sp-ultra-tight);
|
margin: 0 var(--sp-ultra-tight);
|
||||||
color: var(--tc-surface-normal)
|
color: var(--tc-surface-normal);
|
||||||
}
|
}
|
||||||
&-tooltip .react-emoji {
|
&-tooltip .react-emoji {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
@@ -271,7 +281,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
&:active {
|
&:active {
|
||||||
background-color: var(--bg-surface-active)
|
background-color: var(--bg-surface-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
@@ -283,7 +293,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
&:active {
|
&:active {
|
||||||
background-color: var(--bg-caution-active)
|
background-color: var(--bg-caution-active);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,7 +314,12 @@
|
|||||||
|
|
||||||
// markdown formating
|
// markdown formating
|
||||||
.message__body {
|
.message__body {
|
||||||
& h1, h2, h3, h4, h5, h6 {
|
& h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-bottom: var(--sp-ultra-tight);
|
margin-bottom: var(--sp-ultra-tight);
|
||||||
font-weight: var(--fw-medium);
|
font-weight: var(--fw-medium);
|
||||||
@@ -425,7 +440,8 @@
|
|||||||
@include scrollbar.scroll__h;
|
@include scrollbar.scroll__h;
|
||||||
@include scrollbar.scroll--auto-hide;
|
@include scrollbar.scroll--auto-hide;
|
||||||
|
|
||||||
& td, & th {
|
& td,
|
||||||
|
& th {
|
||||||
padding: var(--sp-extra-tight);
|
padding: var(--sp-extra-tight);
|
||||||
border: 1px solid var(--bg-surface-border);
|
border: 1px solid var(--bg-surface-border);
|
||||||
border-width: 0 1px 1px 0;
|
border-width: 0 1px 1px 0;
|
||||||
@@ -433,11 +449,11 @@
|
|||||||
&:last-child {
|
&:last-child {
|
||||||
border-width: 0;
|
border-width: 0;
|
||||||
border-bottom-width: 1px;
|
border-bottom-width: 1px;
|
||||||
[dir=rtl] & {
|
[dir='rtl'] & {
|
||||||
border-width: 0 1px 1px 0;
|
border-width: 0 1px 1px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
[dir=rtl] &:first-child {
|
[dir='rtl'] &:first-child {
|
||||||
border-width: 0;
|
border-width: 0;
|
||||||
border-bottom-width: 1px;
|
border-bottom-width: 1px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -237,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
|
||||||
|
|||||||
@@ -132,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>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -28,11 +30,21 @@ 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) => {
|
||||||
|
childIds?.forEach((childId) => {
|
||||||
|
markAsRead(childId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterOptionSelect();
|
||||||
|
};
|
||||||
const handleInviteClick = () => {
|
const handleInviteClick = () => {
|
||||||
openInviteUser(roomId);
|
openInviteUser(roomId);
|
||||||
afterOptionSelect();
|
afterOptionSelect();
|
||||||
@@ -71,6 +83,7 @@ function SpaceOptions({ roomId, afterOptionSelect }) {
|
|||||||
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}
|
||||||
|
|||||||
@@ -70,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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -141,10 +141,13 @@ 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) {
|
||||||
@@ -202,21 +205,23 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
setAvailableEmojis([]);
|
setAvailableEmojis([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Retrieve the packs for the new room
|
|
||||||
// Remove packs that aren't marked as emoji packs
|
const mx = initMatrix.matrixClient;
|
||||||
// Remove packs without emojis
|
const room = mx.getRoom(selectedRoomId);
|
||||||
|
const parentIds = initMatrix.roomList.getAllParentSpaces(room.roomId);
|
||||||
|
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
||||||
|
if (room) {
|
||||||
const packs = getRelevantPacks(
|
const packs = getRelevantPacks(
|
||||||
initMatrix.matrixClient.getRoom(selectedRoomId),
|
room.client,
|
||||||
)
|
[room, ...parentRooms],
|
||||||
.filter((pack) => pack.usage.indexOf('emoticon') !== -1)
|
).filter((pack) => pack.getEmojis().length !== 0);
|
||||||
.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
|
// 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) {
|
for (let i = 0; i < packs.length; i += 1) {
|
||||||
packs[i].packIndex = i;
|
packs[i].packIndex = i;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAvailableEmojis(packs);
|
setAvailableEmojis(packs);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOpen = () => {
|
const onOpen = () => {
|
||||||
@@ -247,39 +252,6 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
|
|
||||||
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 />
|
|
||||||
{recentEmojis.length > 0 && <EmojiGroup name="Recently used" groupEmojis={recentEmojis} />}
|
|
||||||
{
|
|
||||||
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 && (
|
{recentEmojis.length > 0 && (
|
||||||
@@ -287,20 +259,21 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
onClick={() => openGroup(0)}
|
onClick={() => openGroup(0)}
|
||||||
src={RecentClockIC}
|
src={RecentClockIC}
|
||||||
tooltip="Recent"
|
tooltip="Recent"
|
||||||
tooltipPlacement="right"
|
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(recentOffset + 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
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -324,13 +297,46 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,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;
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@
|
|||||||
@extend .cp-fx__column;
|
@extend .cp-fx__column;
|
||||||
}
|
}
|
||||||
&__nav-twemoji {
|
&__nav-twemoji {
|
||||||
background: inherit;
|
background-color: var(--bg-surface);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: -70%;
|
bottom: -70%;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
@@ -84,6 +83,7 @@
|
|||||||
.emoji {
|
.emoji {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
& > p:last-child {
|
& > p:last-child {
|
||||||
@@ -121,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';
|
if (userImagePack) 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,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { emojis } from './emoji';
|
|||||||
const eventType = 'io.element.recent_emoji';
|
const eventType = 'io.element.recent_emoji';
|
||||||
|
|
||||||
function getRecentEmojisRaw() {
|
function getRecentEmojisRaw() {
|
||||||
return initMatrix.matrixClient.getAccountData(eventType).getContent().recent_emoji ?? [];
|
return initMatrix.matrixClient.getAccountData(eventType)?.getContent().recent_emoji ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRecentEmojis(limit) {
|
export function getRecentEmojis(limit) {
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -103,18 +103,6 @@ function InviteUser({
|
|||||||
updateIsSearching(false);
|
updateIsSearching(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hasDevices(userId) {
|
|
||||||
try {
|
|
||||||
const usersDeviceMap = await mx.downloadKeys([userId, mx.getUserId()]);
|
|
||||||
return Object.values(usersDeviceMap).every((userDevices) =>
|
|
||||||
Object.keys(userDevices).length > 0,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error determining if it's possible to encrypt to all users: ", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createDM(userId) {
|
async function createDM(userId) {
|
||||||
if (mx.getUserId() === userId) return;
|
if (mx.getUserId() === userId) return;
|
||||||
const dmRoomId = hasDMWith(userId);
|
const dmRoomId = hasDMWith(userId);
|
||||||
|
|||||||
@@ -6,7 +6,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 { join } from '../../../client/action/room';
|
import { join } from '../../../client/action/room';
|
||||||
import { selectRoom, selectSpace } from '../../../client/action/navigation';
|
import { selectRoom, selectTab } from '../../../client/action/navigation';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
@@ -32,7 +32,7 @@ function JoinAliasContent({ term, requestClose }) {
|
|||||||
const openRoom = (roomId) => {
|
const openRoom = (roomId) => {
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
if (room.isSpaceRoom()) selectSpace(roomId);
|
if (room.isSpaceRoom()) selectTab(roomId);
|
||||||
else selectRoom(roomId);
|
else selectRoom(roomId);
|
||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ function Drawer() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
scrollRef.current.scrollTop = 0;
|
scrollRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [selectedTab]);
|
}, [selectedTab]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,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 { selectSpace } from '../../../client/action/navigation';
|
import { selectTab, selectSpace } from '../../../client/action/navigation';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import { abbreviateNumber } from '../../../util/common';
|
import { abbreviateNumber } from '../../../util/common';
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ import ChevronRightIC from '../../../../public/res/ic/outlined/chevron-right.svg
|
|||||||
function DrawerBreadcrumb({ spaceId }) {
|
function DrawerBreadcrumb({ spaceId }) {
|
||||||
const [, forceUpdate] = useState({});
|
const [, forceUpdate] = useState({});
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
const { roomList, notifications } = initMatrix;
|
const { roomList, notifications, accountData } = initMatrix;
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const spacePath = navigation.selectedSpacePath;
|
const spacePath = navigation.selectedSpacePath;
|
||||||
|
|
||||||
@@ -49,9 +49,9 @@ function DrawerBreadcrumb({ spaceId }) {
|
|||||||
}, [spaceId]);
|
}, [spaceId]);
|
||||||
|
|
||||||
function getHomeNotiExcept(childId) {
|
function getHomeNotiExcept(childId) {
|
||||||
const orphans = roomList.getOrphans();
|
const orphans = roomList.getOrphans()
|
||||||
const childIndex = orphans.indexOf(childId);
|
.filter((id) => (id !== childId))
|
||||||
if (childId !== -1) orphans.splice(childIndex, 1);
|
.filter((id) => !accountData.spaceShortcut.has(id));
|
||||||
|
|
||||||
let noti = null;
|
let noti = null;
|
||||||
|
|
||||||
@@ -107,7 +107,10 @@ function DrawerBreadcrumb({ spaceId }) {
|
|||||||
{ index !== 0 && <RawIcon size="extra-small" src={ChevronRightIC} />}
|
{ index !== 0 && <RawIcon size="extra-small" src={ChevronRightIC} />}
|
||||||
<Button
|
<Button
|
||||||
className={index === spacePath.length - 1 ? 'drawer-breadcrumb__btn--selected' : ''}
|
className={index === spacePath.length - 1 ? 'drawer-breadcrumb__btn--selected' : ''}
|
||||||
onClick={() => selectSpace(id)}
|
onClick={() => {
|
||||||
|
if (id === cons.tabs.HOME) selectTab(id);
|
||||||
|
else selectSpace(id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text variant="b2">{id === cons.tabs.HOME ? 'Home' : twemojify(mx.getRoom(id).name)}</Text>
|
<Text variant="b2">{id === cons.tabs.HOME ? 'Home' : twemojify(mx.getRoom(id).name)}</Text>
|
||||||
{ noti !== null && (
|
{ noti !== null && (
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function HomeSpaceOptions({ spaceId, afterOptionSelect }) {
|
|||||||
iconSrc={HashGlobeIC}
|
iconSrc={HashGlobeIC}
|
||||||
onClick={() => { afterOptionSelect(); openPublicRooms(); }}
|
onClick={() => { afterOptionSelect(); openPublicRooms(); }}
|
||||||
>
|
>
|
||||||
Join public room
|
Explore public rooms
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{ !spaceId && (
|
{ !spaceId && (
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ function Home({ spaceId }) {
|
|||||||
let directIds = [];
|
let directIds = [];
|
||||||
|
|
||||||
if (spaceId) {
|
if (spaceId) {
|
||||||
const spaceChildIds = roomList.getSpaceChildren(spaceId);
|
const spaceChildIds = roomList.getSpaceChildren(spaceId) ?? [];
|
||||||
spaceIds = spaceChildIds.filter((roomId) => spaces.has(roomId));
|
spaceIds = spaceChildIds.filter((roomId) => spaces.has(roomId));
|
||||||
roomIds = spaceChildIds.filter((roomId) => rooms.has(roomId));
|
roomIds = spaceChildIds.filter((roomId) => rooms.has(roomId));
|
||||||
directIds = spaceChildIds.filter((roomId) => directs.has(roomId));
|
directIds = spaceChildIds.filter((roomId) => directs.has(roomId));
|
||||||
} else {
|
} else {
|
||||||
spaceIds = roomList.getOrphanSpaces();
|
spaceIds = roomList.getOrphanSpaces().filter((id) => !accountData.spaceShortcut.has(id));
|
||||||
roomIds = roomList.getOrphanRooms();
|
roomIds = roomList.getOrphanRooms();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +80,10 @@ function Home({ spaceId }) {
|
|||||||
<RoomsCategory name="People" roomIds={directIds.sort(roomIdByActivity)} drawerPostie={drawerPostie} />
|
<RoomsCategory name="People" roomIds={directIds.sort(roomIdByActivity)} drawerPostie={drawerPostie} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ isCategorized && [...categories].map(([catId, childIds]) => {
|
{ isCategorized && [...categories.keys()].sort(roomIdByAtoZ).map((catId) => {
|
||||||
const rms = [];
|
const rms = [];
|
||||||
const dms = [];
|
const dms = [];
|
||||||
childIds.forEach((id) => {
|
categories.get(catId).forEach((id) => {
|
||||||
if (directs.has(id)) dms.push(id);
|
if (directs.has(id)) dms.push(id);
|
||||||
else rms.push(id);
|
else rms.push(id);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
|||||||
|
|
||||||
import './ProfileEditor.scss';
|
import './ProfileEditor.scss';
|
||||||
|
|
||||||
// TODO Fix bug that prevents 'Save' button from enabling up until second changed.
|
|
||||||
function ProfileEditor({ userId }) {
|
function ProfileEditor({ userId }) {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
@@ -96,7 +95,7 @@ function ProfileEditor({ userId }) {
|
|||||||
const renderInfo = () => (
|
const renderInfo = () => (
|
||||||
<div className="profile-editor__info" style={{ marginBottom: avatarSrc ? '24px' : '0' }}>
|
<div className="profile-editor__info" style={{ marginBottom: avatarSrc ? '24px' : '0' }}>
|
||||||
<div>
|
<div>
|
||||||
<Text variant="h2" primary weight="medium">{twemojify(username)}</Text>
|
<Text variant="h2" primary weight="medium">{twemojify(username) ?? userId}</Text>
|
||||||
<IconButton
|
<IconButton
|
||||||
src={PencilIC}
|
src={PencilIC}
|
||||||
size="extra-small"
|
size="extra-small"
|
||||||
@@ -111,7 +110,7 @@ function ProfileEditor({ userId }) {
|
|||||||
return (
|
return (
|
||||||
<div className="profile-editor">
|
<div className="profile-editor">
|
||||||
<ImageUpload
|
<ImageUpload
|
||||||
text={username}
|
text={username ?? userId}
|
||||||
bgColor={colorMXID(userId)}
|
bgColor={colorMXID(userId)}
|
||||||
imageSrc={avatarSrc}
|
imageSrc={avatarSrc}
|
||||||
onUpload={handleAvatarUpload}
|
onUpload={handleAvatarUpload}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { selectRoom, openReusableContextMenu } from '../../../client/action/navi
|
|||||||
import * as roomActions from '../../../client/action/room';
|
import * as roomActions from '../../../client/action/room';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getUsername, getUsernameOfRoomMember, getPowerLabel, hasDMWith
|
getUsername, getUsernameOfRoomMember, getPowerLabel, hasDMWith, hasDevices,
|
||||||
} from '../../../util/matrixUtil';
|
} from '../../../util/matrixUtil';
|
||||||
import { getEventCords } from '../../../util/common';
|
import { getEventCords } from '../../../util/common';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
@@ -201,7 +201,7 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
|||||||
// Create new DM
|
// Create new DM
|
||||||
try {
|
try {
|
||||||
setIsCreatingDM(true);
|
setIsCreatingDM(true);
|
||||||
await roomActions.createDM(userId);
|
await roomActions.createDM(userId, await hasDevices(userId));
|
||||||
} catch {
|
} catch {
|
||||||
if (isMountedRef.current === false) return;
|
if (isMountedRef.current === false) return;
|
||||||
setIsCreatingDM(false);
|
setIsCreatingDM(false);
|
||||||
@@ -209,19 +209,18 @@ function ProfileFooter({ roomId, userId, onRequestClose }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleIgnore = async () => {
|
const toggleIgnore = async () => {
|
||||||
const ignoredUsers = mx.getIgnoredUsers();
|
const isIgnored = mx.getIgnoredUsers().includes(userId);
|
||||||
const uIndex = ignoredUsers.indexOf(userId);
|
|
||||||
if (uIndex >= 0) {
|
|
||||||
if (uIndex === -1) return;
|
|
||||||
ignoredUsers.splice(uIndex, 1);
|
|
||||||
} else ignoredUsers.push(userId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsIgnoring(true);
|
setIsIgnoring(true);
|
||||||
await mx.setIgnoredUsers(ignoredUsers);
|
if (isIgnored) {
|
||||||
|
await roomActions.unignore([userId]);
|
||||||
|
} else {
|
||||||
|
await roomActions.ignore([userId]);
|
||||||
|
}
|
||||||
|
|
||||||
if (isMountedRef.current === false) return;
|
if (isMountedRef.current === false) return;
|
||||||
setIsUserIgnored(uIndex < 0);
|
setIsUserIgnored(!isIgnored);
|
||||||
setIsIgnoring(false);
|
setIsIgnoring(false);
|
||||||
} catch {
|
} catch {
|
||||||
setIsIgnoring(false);
|
setIsIgnoring(false);
|
||||||
|
|||||||