Compare commits
35 Commits
v4.7.1
...
imporve-th
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f2d1684f | ||
|
|
ccf10fc20c | ||
|
|
31942b1114 | ||
|
|
d8d4714370 | ||
|
|
9183fd66b2 | ||
|
|
67b05eeb09 | ||
|
|
7d4b0dd133 | ||
|
|
9073dee986 | ||
|
|
3cdb5c2fe6 | ||
|
|
acc7d4ff56 | ||
|
|
50cc78788f | ||
|
|
c462a3b8d5 | ||
|
|
c30c142653 | ||
|
|
fbd7e0a14b | ||
|
|
6b81401e2d | ||
|
|
c757b8967f | ||
|
|
d0a7ef31bc | ||
|
|
3fd8a18157 | ||
|
|
54ba1096d7 | ||
|
|
87fc490c3b | ||
|
|
ebe5beba1d | ||
|
|
77ab37f637 | ||
|
|
461e730c34 | ||
|
|
05e83eabef | ||
|
|
ba72925d53 | ||
|
|
87ce209050 | ||
|
|
3ed8260877 | ||
|
|
44347db6e4 | ||
|
|
91632aa193 | ||
|
|
e6f4eeca8e | ||
|
|
a23279e633 | ||
|
|
83057ebbd4 | ||
|
|
c51ba9670e | ||
|
|
59a007419f | ||
|
|
206ed33516 |
2
.github/workflows/build-pull-request.yml
vendored
2
.github/workflows/build-pull-request.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.0
|
uses: actions/checkout@v4.2.0
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4.3.0
|
uses: actions/setup-node@v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.12.2
|
node-version: 20.12.2
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
4
.github/workflows/deploy-pull-request.yml
vendored
4
.github/workflows/deploy-pull-request.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download pr number
|
- name: Download pr number
|
||||||
uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295
|
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
|
||||||
with:
|
with:
|
||||||
workflow: ${{ github.event.workflow.id }}
|
workflow: ${{ github.event.workflow.id }}
|
||||||
run_id: ${{ github.event.workflow_run.id }}
|
run_id: ${{ github.event.workflow_run.id }}
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
id: pr
|
id: pr
|
||||||
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
|
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
|
||||||
- name: Download artifact
|
- name: Download artifact
|
||||||
uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295
|
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
|
||||||
with:
|
with:
|
||||||
workflow: ${{ github.event.workflow.id }}
|
workflow: ${{ github.event.workflow.id }}
|
||||||
run_id: ${{ github.event.workflow_run.id }}
|
run_id: ${{ github.event.workflow_run.id }}
|
||||||
|
|||||||
2
.github/workflows/docker-pr.yml
vendored
2
.github/workflows/docker-pr.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.0
|
uses: actions/checkout@v4.2.0
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@v6.15.0
|
uses: docker/build-push-action@v6.18.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
|
|||||||
2
.github/workflows/netlify-dev.yml
vendored
2
.github/workflows/netlify-dev.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.0
|
uses: actions/checkout@v4.2.0
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4.3.0
|
uses: actions/setup-node@v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.12.2
|
node-version: 20.12.2
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|||||||
6
.github/workflows/prod-deploy.yml
vendored
6
.github/workflows/prod-deploy.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.0
|
uses: actions/checkout@v4.2.0
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4.3.0
|
uses: actions/setup-node@v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.12.2
|
node-version: 20.12.2
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
gpg --export | xxd -p
|
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
|
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@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda
|
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||||
@@ -90,7 +90,7 @@ jobs:
|
|||||||
${{ secrets.DOCKER_USERNAME }}/cinny
|
${{ secrets.DOCKER_USERNAME }}/cinny
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6.15.0
|
uses: docker/build-push-action@v6.18.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ RUN npm run build
|
|||||||
|
|
||||||
|
|
||||||
## App
|
## App
|
||||||
FROM nginx:1.27.4-alpine
|
FROM nginx:1.29.0-alpine
|
||||||
|
|
||||||
COPY --from=builder /src/dist /app
|
COPY --from=builder /src/dist /app
|
||||||
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
|
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
38
package-lock.json
generated
38
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.7.1",
|
"version": "4.8.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.7.1",
|
"version": "4.8.1",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"@vanilla-extract/recipes": "0.3.0",
|
"@vanilla-extract/recipes": "0.3.0",
|
||||||
"@vanilla-extract/vite-plugin": "3.7.1",
|
"@vanilla-extract/vite-plugin": "3.7.1",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
|
"badwords-list": "2.0.1-4",
|
||||||
"blurhash": "2.0.4",
|
"blurhash": "2.0.4",
|
||||||
"browser-encrypt-attachment": "0.3.0",
|
"browser-encrypt-attachment": "0.3.0",
|
||||||
"chroma-js": "3.1.2",
|
"chroma-js": "3.1.2",
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"flux": "4.0.3",
|
"flux": "4.0.3",
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
"folds": "2.1.0",
|
"folds": "2.2.0",
|
||||||
"formik": "2.4.6",
|
"formik": "2.4.6",
|
||||||
"html-dom-parser": "4.0.0",
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
"prettier": "2.8.1",
|
"prettier": "2.8.1",
|
||||||
"sass": "1.56.2",
|
"sass": "1.56.2",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "5.4.15",
|
"vite": "5.4.19",
|
||||||
"vite-plugin-pwa": "0.20.5",
|
"vite-plugin-pwa": "0.20.5",
|
||||||
"vite-plugin-static-copy": "1.0.4",
|
"vite-plugin-static-copy": "1.0.4",
|
||||||
"vite-plugin-top-level-await": "1.4.4"
|
"vite-plugin-top-level-await": "1.4.4"
|
||||||
@@ -5436,6 +5437,12 @@
|
|||||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/badwords-list": {
|
||||||
|
"version": "2.0.1-4",
|
||||||
|
"resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-2.0.1-4.tgz",
|
||||||
|
"integrity": "sha512-FxfZUp7B9yCnesNtFQS9v6PvZdxTYa14Q60JR6vhjdQdWI4naTjJIyx22JzoER8ooeT8SAAKoHLjKfCV7XgYUQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -7258,15 +7265,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/folds": {
|
"node_modules/folds": {
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/folds/-/folds-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/folds/-/folds-2.2.0.tgz",
|
||||||
"integrity": "sha512-KwAG8bH3jsyZ9FKPMg+6ABV2YOcpp4nL0cCelsalnaPeRThkc5fgG1Xj5mhmdffYKjEXpEbERi5qmGbepgJryg==",
|
"integrity": "sha512-uOfck5eWEIK11rhOAEdSoPIvMXwv+D1Go03pxSAKezWVb+uRoBdmE6LEqiLOF+ac4DGmZRMPvpdDsXCg7EVNIg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@vanilla-extract/css": "^1.9.2",
|
"@vanilla-extract/css": "1.9.2",
|
||||||
"@vanilla-extract/recipes": "^0.3.0",
|
"@vanilla-extract/recipes": "0.3.0",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "2.3.2",
|
||||||
"react": "^17.0.0",
|
"react": "17.0.0",
|
||||||
"react-dom": "^17.0.0"
|
"react-dom": "17.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
@@ -11323,9 +11331,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.15",
|
"version": "5.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
|
||||||
"integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==",
|
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.7.1",
|
"version": "4.8.1",
|
||||||
"description": "Yet another matrix client",
|
"description": "Yet another matrix client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"@vanilla-extract/recipes": "0.3.0",
|
"@vanilla-extract/recipes": "0.3.0",
|
||||||
"@vanilla-extract/vite-plugin": "3.7.1",
|
"@vanilla-extract/vite-plugin": "3.7.1",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
|
"badwords-list": "2.0.1-4",
|
||||||
"blurhash": "2.0.4",
|
"blurhash": "2.0.4",
|
||||||
"browser-encrypt-attachment": "0.3.0",
|
"browser-encrypt-attachment": "0.3.0",
|
||||||
"chroma-js": "3.1.2",
|
"chroma-js": "3.1.2",
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"flux": "4.0.3",
|
"flux": "4.0.3",
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
"folds": "2.1.0",
|
"folds": "2.2.0",
|
||||||
"formik": "2.4.6",
|
"formik": "2.4.6",
|
||||||
"html-dom-parser": "4.0.0",
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
@@ -109,7 +110,7 @@
|
|||||||
"prettier": "2.8.1",
|
"prettier": "2.8.1",
|
||||||
"sass": "1.56.2",
|
"sass": "1.56.2",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "5.4.15",
|
"vite": "5.4.19",
|
||||||
"vite-plugin-pwa": "0.20.5",
|
"vite-plugin-pwa": "0.20.5",
|
||||||
"vite-plugin-static-copy": "1.0.4",
|
"vite-plugin-static-copy": "1.0.4",
|
||||||
"vite-plugin-top-level-await": "1.4.4"
|
"vite-plugin-top-level-await": "1.4.4"
|
||||||
|
|||||||
@@ -4,10 +4,25 @@ import PropTypes from 'prop-types';
|
|||||||
import dateFormat from 'dateformat';
|
import dateFormat from 'dateformat';
|
||||||
import { isInSameDay } from '../../../util/common';
|
import { isInSameDay } from '../../../util/common';
|
||||||
|
|
||||||
function Time({ timestamp, fullTime }) {
|
/**
|
||||||
|
* Renders a formatted timestamp.
|
||||||
|
*
|
||||||
|
* Displays the time in hour:minute format if the message is from today or yesterday, unless `fullTime` is true.
|
||||||
|
* For older messages, it shows the date and time.
|
||||||
|
*
|
||||||
|
* @param {number} timestamp - The timestamp to display.
|
||||||
|
* @param {boolean} [fullTime=false] - If true, always show the full date and time.
|
||||||
|
* @param {boolean} hour24Clock - Whether to use 24-hour time format.
|
||||||
|
* @param {string} dateFormatString - Format string for the date part.
|
||||||
|
* @returns {JSX.Element} A <time> element with the formatted date/time.
|
||||||
|
*/
|
||||||
|
function Time({ timestamp, fullTime, hour24Clock, dateFormatString }) {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
|
|
||||||
const formattedFullTime = dateFormat(date, 'dd mmmm yyyy, hh:MM TT');
|
const formattedFullTime = dateFormat(
|
||||||
|
date,
|
||||||
|
hour24Clock ? 'dd mmmm yyyy, HH:MM' : 'dd mmmm yyyy, hh:MM TT'
|
||||||
|
);
|
||||||
let formattedDate = formattedFullTime;
|
let formattedDate = formattedFullTime;
|
||||||
|
|
||||||
if (!fullTime) {
|
if (!fullTime) {
|
||||||
@@ -16,17 +31,19 @@ function Time({ timestamp, fullTime }) {
|
|||||||
compareDate.setDate(compareDate.getDate() - 1);
|
compareDate.setDate(compareDate.getDate() - 1);
|
||||||
const isYesterday = isInSameDay(date, compareDate);
|
const isYesterday = isInSameDay(date, compareDate);
|
||||||
|
|
||||||
formattedDate = dateFormat(date, isToday || isYesterday ? 'hh:MM TT' : 'dd/mm/yyyy');
|
const timeFormat = hour24Clock ? 'HH:MM' : 'hh:MM TT';
|
||||||
|
|
||||||
|
formattedDate = dateFormat(
|
||||||
|
date,
|
||||||
|
isToday || isYesterday ? timeFormat : dateFormatString.toLowerCase()
|
||||||
|
);
|
||||||
if (isYesterday) {
|
if (isYesterday) {
|
||||||
formattedDate = `Yesterday, ${formattedDate}`;
|
formattedDate = `Yesterday, ${formattedDate}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<time
|
<time dateTime={date.toISOString()} title={formattedFullTime}>
|
||||||
dateTime={date.toISOString()}
|
|
||||||
title={formattedFullTime}
|
|
||||||
>
|
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
</time>
|
</time>
|
||||||
);
|
);
|
||||||
@@ -39,6 +56,8 @@ Time.defaultProps = {
|
|||||||
Time.propTypes = {
|
Time.propTypes = {
|
||||||
timestamp: PropTypes.number.isRequired,
|
timestamp: PropTypes.number.isRequired,
|
||||||
fullTime: PropTypes.bool,
|
fullTime: PropTypes.bool,
|
||||||
|
hour24Clock: PropTypes.bool.isRequired,
|
||||||
|
dateFormatString: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Time;
|
export default Time;
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { ReactNode, useCallback, useEffect } from 'react';
|
|
||||||
import { Capabilities } from 'matrix-js-sdk';
|
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
|
||||||
import { MediaConfig } from '../hooks/useMediaConfig';
|
|
||||||
import { promiseFulfilledResult } from '../utils/common';
|
|
||||||
|
|
||||||
type CapabilitiesAndMediaConfigLoaderProps = {
|
|
||||||
children: (capabilities?: Capabilities, mediaConfig?: MediaConfig) => ReactNode;
|
|
||||||
};
|
|
||||||
export function CapabilitiesAndMediaConfigLoader({
|
|
||||||
children,
|
|
||||||
}: CapabilitiesAndMediaConfigLoaderProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
|
|
||||||
const [state, load] = useAsyncCallback<
|
|
||||||
[Capabilities | undefined, MediaConfig | undefined],
|
|
||||||
unknown,
|
|
||||||
[]
|
|
||||||
>(
|
|
||||||
useCallback(async () => {
|
|
||||||
const result = await Promise.allSettled([mx.getCapabilities(), mx.getMediaConfig()]);
|
|
||||||
const capabilities = promiseFulfilledResult(result[0]);
|
|
||||||
const mediaConfig = promiseFulfilledResult(result[1]);
|
|
||||||
return [capabilities, mediaConfig];
|
|
||||||
}, [mx])
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const [capabilities, mediaConfig] =
|
|
||||||
state.status === AsyncStatus.Success ? state.data : [undefined, undefined];
|
|
||||||
return children(capabilities, mediaConfig);
|
|
||||||
}
|
|
||||||
52
src/app/components/ServerConfigsLoader.tsx
Normal file
52
src/app/components/ServerConfigsLoader.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { ReactNode, useCallback, useMemo } from 'react';
|
||||||
|
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||||
|
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
|
||||||
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
|
import { MediaConfig } from '../hooks/useMediaConfig';
|
||||||
|
import { promiseFulfilledResult } from '../utils/common';
|
||||||
|
|
||||||
|
export type ServerConfigs = {
|
||||||
|
capabilities?: Capabilities;
|
||||||
|
mediaConfig?: MediaConfig;
|
||||||
|
authMetadata?: ValidatedAuthMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ServerConfigsLoaderProps = {
|
||||||
|
children: (configs: ServerConfigs) => ReactNode;
|
||||||
|
};
|
||||||
|
export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const fallbackConfigs = useMemo(() => ({}), []);
|
||||||
|
|
||||||
|
const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>(
|
||||||
|
useCallback(async () => {
|
||||||
|
const result = await Promise.allSettled([
|
||||||
|
mx.getCapabilities(),
|
||||||
|
mx.getMediaConfig(),
|
||||||
|
mx.getAuthMetadata(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const capabilities = promiseFulfilledResult(result[0]);
|
||||||
|
const mediaConfig = promiseFulfilledResult(result[1]);
|
||||||
|
const authMetadata = promiseFulfilledResult(result[2]);
|
||||||
|
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
capabilities,
|
||||||
|
mediaConfig,
|
||||||
|
authMetadata: validatedAuthMetadata,
|
||||||
|
};
|
||||||
|
}, [mx])
|
||||||
|
);
|
||||||
|
|
||||||
|
const configs: ServerConfigs =
|
||||||
|
configsState.status === AsyncStatus.Success ? configsState.data : fallbackConfigs;
|
||||||
|
|
||||||
|
return children(configs);
|
||||||
|
}
|
||||||
@@ -157,7 +157,7 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
|
|||||||
<Text as="pre" className={css.CodeBlock} {...attributes}>
|
<Text as="pre" className={css.CodeBlock} {...attributes}>
|
||||||
<Scroll
|
<Scroll
|
||||||
direction="Horizontal"
|
direction="Horizontal"
|
||||||
variant="Secondary"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
size="300"
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
hideTrack
|
hideTrack
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { getDirectRoomAvatarUrl } from '../../../utils/room';
|
|||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { AutocompleteQuery } from './autocompleteQuery';
|
import { AutocompleteQuery } from './autocompleteQuery';
|
||||||
import { AutocompleteMenu } from './AutocompleteMenu';
|
import { AutocompleteMenu } from './AutocompleteMenu';
|
||||||
import { getMxIdServer, validMxId } from '../../../utils/matrix';
|
import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
|
||||||
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
|
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
|
||||||
import { onTabPress } from '../../../utils/keyboard';
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
@@ -22,7 +22,7 @@ import { getViaServers } from '../../../plugins/via-servers';
|
|||||||
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
|
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
|
||||||
|
|
||||||
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
|
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
|
||||||
validMxId(`#${text}`)
|
isRoomAlias(`#${text}`)
|
||||||
? `#${text}`
|
? `#${text}`
|
||||||
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { onTabPress } from '../../../utils/keyboard';
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
|
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
|
||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
|
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
|
||||||
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
|
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
|
||||||
import { UserAvatar } from '../../user-avatar';
|
import { UserAvatar } from '../../user-avatar';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
@@ -24,7 +24,7 @@ import { Membership } from '../../../../types/matrix/room';
|
|||||||
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
||||||
|
|
||||||
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
|
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
|
||||||
validMxId(`@${text}`)
|
isUserId(`@${text}`)
|
||||||
? `@${text}`
|
? `@${text}`
|
||||||
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export const ReplyBend = style({
|
|||||||
|
|
||||||
export const ThreadIndicator = style({
|
export const ThreadIndicator = style({
|
||||||
opacity: config.opacity.P300,
|
opacity: config.opacity.P300,
|
||||||
gap: toRem(2),
|
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'button&': {
|
'button&': {
|
||||||
@@ -19,11 +18,6 @@ export const ThreadIndicator = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ThreadIndicatorIcon = style({
|
|
||||||
width: toRem(14),
|
|
||||||
height: toRem(14),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Reply = style({
|
export const Reply = style({
|
||||||
marginBottom: toRem(1),
|
marginBottom: toRem(1),
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
|||||||
@@ -38,9 +38,16 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
||||||
<Box className={css.ThreadIndicator} alignItems="Center" {...props} ref={ref}>
|
<Box
|
||||||
<Icon className={css.ThreadIndicatorIcon} src={Icons.Message} />
|
shrink="No"
|
||||||
<Text size="T200">Threaded reply</Text>
|
className={css.ThreadIndicator}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Thread} />
|
||||||
|
<Text size="L400">Thread</Text>
|
||||||
</Box>
|
</Box>
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -97,7 +104,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
|
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" alignItems="Start" {...props} ref={ref}>
|
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
||||||
{threadRootId && (
|
{threadRootId && (
|
||||||
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,19 +5,35 @@ import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/ti
|
|||||||
export type TimeProps = {
|
export type TimeProps = {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
ts: number;
|
ts: number;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a formatted timestamp, supporting compact and full display modes.
|
||||||
|
*
|
||||||
|
* Displays the time in hour:minute format if the message is from today, yesterday, or if `compact` is true.
|
||||||
|
* For older messages, it shows the date and time.
|
||||||
|
*
|
||||||
|
* @param {number} ts - The timestamp to display.
|
||||||
|
* @param {boolean} [compact=false] - If true, always show only the time.
|
||||||
|
* @param {boolean} hour24Clock - Whether to use 24-hour time format.
|
||||||
|
* @param {string} dateFormatString - Format string for the date part.
|
||||||
|
* @returns {React.ReactElement} A <Text as="time"> element with the formatted date/time.
|
||||||
|
*/
|
||||||
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
|
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
|
||||||
({ compact, ts, ...props }, ref) => {
|
({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => {
|
||||||
|
const formattedTime = timeHourMinute(ts, hour24Clock);
|
||||||
|
|
||||||
let time = '';
|
let time = '';
|
||||||
if (compact) {
|
if (compact) {
|
||||||
time = timeHourMinute(ts);
|
time = formattedTime;
|
||||||
} else if (today(ts)) {
|
} else if (today(ts)) {
|
||||||
time = timeHourMinute(ts);
|
time = formattedTime;
|
||||||
} else if (yesterday(ts)) {
|
} else if (yesterday(ts)) {
|
||||||
time = `Yesterday ${timeHourMinute(ts)}`;
|
time = `Yesterday ${formattedTime}`;
|
||||||
} else {
|
} else {
|
||||||
time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
|
time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const AbsoluteContainer = style([
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
zIndex: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -105,6 +105,20 @@ export const PageContent = as<'div'>(({ className, ...props }, ref) => (
|
|||||||
<div className={classNames(css.PageContent, className)} {...props} ref={ref} />
|
<div className={classNames(css.PageContent, className)} {...props} ref={ref} />
|
||||||
));
|
));
|
||||||
|
|
||||||
|
export function PageHeroEmpty({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={classNames(ContainerColor({ variant: 'SurfaceVariant' }), css.PageHeroEmpty)}
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
gap="200"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
|
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -92,6 +92,15 @@ export const PageContent = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const PageHeroEmpty = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
padding: config.space.S400,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
minHeight: toRem(450),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const PageHeroSection = style([
|
export const PageHeroSection = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { nameInitials } from '../../utils/common';
|
|||||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
|
||||||
export type RoomIntroProps = {
|
export type RoomIntroProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -43,6 +45,8 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||||||
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
|
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -67,7 +71,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
{'Created by '}
|
{'Created by '}
|
||||||
<b>@{creatorName}</b>
|
<b>@{creatorName}</b>
|
||||||
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts)}`}
|
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
129
src/app/components/time-date/DatePicker.tsx
Normal file
129
src/app/components/time-date/DatePicker.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { Menu, Box, Text, Chip } from 'folds';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { PickerColumn } from './PickerColumn';
|
||||||
|
import { dateFor, daysInMonth, daysToMs } from '../../utils/time';
|
||||||
|
|
||||||
|
type DatePickerProps = {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
};
|
||||||
|
export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
||||||
|
({ min, max, value, onChange }, ref) => {
|
||||||
|
const selectedYear = dayjs(value).year();
|
||||||
|
const selectedMonth = dayjs(value).month() + 1;
|
||||||
|
const selectedDay = dayjs(value).date();
|
||||||
|
|
||||||
|
const handleSubmit = (newValue: number) => {
|
||||||
|
onChange(Math.min(Math.max(min, newValue), max));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDay = (day: number) => {
|
||||||
|
const seconds = daysToMs(day);
|
||||||
|
const lastSeconds = daysToMs(selectedDay);
|
||||||
|
const newValue = value + (seconds - lastSeconds);
|
||||||
|
handleSubmit(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMonthAndYear = (month: number, year: number) => {
|
||||||
|
const mDays = daysInMonth(month, year);
|
||||||
|
const currentDate = dateFor(selectedYear, selectedMonth, selectedDay);
|
||||||
|
const time = value - currentDate;
|
||||||
|
|
||||||
|
const newDate = dateFor(year, month, mDays < selectedDay ? mDays : selectedDay);
|
||||||
|
|
||||||
|
const newValue = newDate + time;
|
||||||
|
handleSubmit(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMonth = (month: number) => {
|
||||||
|
handleMonthAndYear(month, selectedYear);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleYear = (year: number) => {
|
||||||
|
handleMonthAndYear(selectedMonth, year);
|
||||||
|
};
|
||||||
|
|
||||||
|
const minYear = dayjs(min).year();
|
||||||
|
const maxYear = dayjs(max).year();
|
||||||
|
const yearsRange = maxYear - minYear + 1;
|
||||||
|
|
||||||
|
const minMonth = dayjs(min).month() + 1;
|
||||||
|
const maxMonth = dayjs(max).month() + 1;
|
||||||
|
|
||||||
|
const minDay = dayjs(min).date();
|
||||||
|
const maxDay = dayjs(max).date();
|
||||||
|
return (
|
||||||
|
<Menu className={css.PickerMenu} ref={ref}>
|
||||||
|
<Box direction="Row" gap="200" className={css.PickerContainer}>
|
||||||
|
<PickerColumn title="Day">
|
||||||
|
{Array.from(Array(daysInMonth(selectedMonth, selectedYear)).keys())
|
||||||
|
.map((i) => i + 1)
|
||||||
|
.map((day) => (
|
||||||
|
<Chip
|
||||||
|
key={day}
|
||||||
|
size="500"
|
||||||
|
variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={selectedDay === day}
|
||||||
|
onClick={() => handleDay(day)}
|
||||||
|
disabled={
|
||||||
|
(selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
|
||||||
|
(selectedYear === maxYear && selectedMonth === maxMonth && day > maxDay)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">{day}</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</PickerColumn>
|
||||||
|
<PickerColumn title="Month">
|
||||||
|
{Array.from(Array(12).keys())
|
||||||
|
.map((i) => i + 1)
|
||||||
|
.map((month) => (
|
||||||
|
<Chip
|
||||||
|
key={month}
|
||||||
|
size="500"
|
||||||
|
variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={selectedMonth === month}
|
||||||
|
onClick={() => handleMonth(month)}
|
||||||
|
disabled={
|
||||||
|
(selectedYear === minYear && month < minMonth) ||
|
||||||
|
(selectedYear === maxYear && month > maxMonth)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">
|
||||||
|
{dayjs()
|
||||||
|
.month(month - 1)
|
||||||
|
.format('MMM')}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</PickerColumn>
|
||||||
|
<PickerColumn title="Year">
|
||||||
|
{Array.from(Array(yearsRange).keys())
|
||||||
|
.map((i) => minYear + i)
|
||||||
|
.map((year) => (
|
||||||
|
<Chip
|
||||||
|
key={year}
|
||||||
|
size="500"
|
||||||
|
variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={selectedYear === year}
|
||||||
|
onClick={() => handleYear(year)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{year}</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</PickerColumn>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
23
src/app/components/time-date/PickerColumn.tsx
Normal file
23
src/app/components/time-date/PickerColumn.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { Box, Text, Scroll } from 'folds';
|
||||||
|
import { CutoutCard } from '../cutout-card';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
|
||||||
|
export function PickerColumn({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text className={css.PickerColumnLabel} size="L400">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<CutoutCard variant="Background">
|
||||||
|
<Scroll variant="Background" size="300" hideTrack>
|
||||||
|
<Box className={css.PickerColumnContent} direction="Column" gap="100">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</CutoutCard>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/app/components/time-date/TimePicker.tsx
Normal file
153
src/app/components/time-date/TimePicker.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { Menu, Box, Text, Chip } from 'folds';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import { PickerColumn } from './PickerColumn';
|
||||||
|
import { hour12to24, hour24to12, hoursToMs, inSameDay, minutesToMs } from '../../utils/time';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
|
||||||
|
type TimePickerProps = {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
};
|
||||||
|
export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||||
|
({ min, max, value, onChange }, ref) => {
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
|
const hour24 = dayjs(value).hour();
|
||||||
|
|
||||||
|
const selectedHour = hour24Clock ? hour24 : hour24to12(hour24);
|
||||||
|
const selectedMinute = dayjs(value).minute();
|
||||||
|
const selectedPM = hour24 >= 12;
|
||||||
|
|
||||||
|
const handleSubmit = (newValue: number) => {
|
||||||
|
onChange(Math.min(Math.max(min, newValue), max));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHour = (hour: number) => {
|
||||||
|
const seconds = hoursToMs(hour24Clock ? hour : hour12to24(hour, selectedPM));
|
||||||
|
const lastSeconds = hoursToMs(hour24);
|
||||||
|
const newValue = value + (seconds - lastSeconds);
|
||||||
|
handleSubmit(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinute = (minute: number) => {
|
||||||
|
const seconds = minutesToMs(minute);
|
||||||
|
const lastSeconds = minutesToMs(selectedMinute);
|
||||||
|
const newValue = value + (seconds - lastSeconds);
|
||||||
|
handleSubmit(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePeriod = (pm: boolean) => {
|
||||||
|
const seconds = hoursToMs(hour12to24(selectedHour, pm));
|
||||||
|
const lastSeconds = hoursToMs(hour24);
|
||||||
|
const newValue = value + (seconds - lastSeconds);
|
||||||
|
handleSubmit(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const minHour24 = dayjs(min).hour();
|
||||||
|
const maxHour24 = dayjs(max).hour();
|
||||||
|
|
||||||
|
const minMinute = dayjs(min).minute();
|
||||||
|
const maxMinute = dayjs(max).minute();
|
||||||
|
const minPM = minHour24 >= 12;
|
||||||
|
const maxPM = maxHour24 >= 12;
|
||||||
|
|
||||||
|
const minDay = inSameDay(min, value);
|
||||||
|
const maxDay = inSameDay(max, value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu className={css.PickerMenu} ref={ref}>
|
||||||
|
<Box direction="Row" gap="200" className={css.PickerContainer}>
|
||||||
|
<PickerColumn title="Hour">
|
||||||
|
{hour24Clock
|
||||||
|
? Array.from(Array(24).keys()).map((hour) => (
|
||||||
|
<Chip
|
||||||
|
key={hour}
|
||||||
|
size="500"
|
||||||
|
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={hour === selectedHour}
|
||||||
|
onClick={() => handleHour(hour)}
|
||||||
|
disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{hour < 10 ? `0${hour}` : hour}</Text>
|
||||||
|
</Chip>
|
||||||
|
))
|
||||||
|
: Array.from(Array(12).keys())
|
||||||
|
.map((i) => {
|
||||||
|
if (i === 0) return 12;
|
||||||
|
return i;
|
||||||
|
})
|
||||||
|
.map((hour) => (
|
||||||
|
<Chip
|
||||||
|
key={hour}
|
||||||
|
size="500"
|
||||||
|
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={hour === selectedHour}
|
||||||
|
onClick={() => handleHour(hour)}
|
||||||
|
disabled={
|
||||||
|
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
|
||||||
|
(maxDay && hour12to24(hour, selectedPM) > maxHour24)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">{hour < 10 ? `0${hour}` : hour}</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</PickerColumn>
|
||||||
|
<PickerColumn title="Minutes">
|
||||||
|
{Array.from(Array(60).keys()).map((minute) => (
|
||||||
|
<Chip
|
||||||
|
key={minute}
|
||||||
|
size="500"
|
||||||
|
variant={minute === selectedMinute ? 'Primary' : 'Background'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={minute === selectedMinute}
|
||||||
|
onClick={() => handleMinute(minute)}
|
||||||
|
disabled={
|
||||||
|
(minDay && hour24 === minHour24 && minute < minMinute) ||
|
||||||
|
(maxDay && hour24 === maxHour24 && minute > maxMinute)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">{minute < 10 ? `0${minute}` : minute}</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</PickerColumn>
|
||||||
|
{!hour24Clock && (
|
||||||
|
<PickerColumn title="Period">
|
||||||
|
<Chip
|
||||||
|
size="500"
|
||||||
|
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={!selectedPM}
|
||||||
|
onClick={() => handlePeriod(false)}
|
||||||
|
disabled={minDay && minPM}
|
||||||
|
>
|
||||||
|
<Text size="T300">AM</Text>
|
||||||
|
</Chip>
|
||||||
|
<Chip
|
||||||
|
size="500"
|
||||||
|
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
aria-selected={selectedPM}
|
||||||
|
onClick={() => handlePeriod(true)}
|
||||||
|
disabled={maxDay && !maxPM}
|
||||||
|
>
|
||||||
|
<Text size="T300">PM</Text>
|
||||||
|
</Chip>
|
||||||
|
</PickerColumn>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
2
src/app/components/time-date/index.ts
Normal file
2
src/app/components/time-date/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './TimePicker';
|
||||||
|
export * from './DatePicker';
|
||||||
16
src/app/components/time-date/styles.css.ts
Normal file
16
src/app/components/time-date/styles.css.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const PickerMenu = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
});
|
||||||
|
export const PickerContainer = style({
|
||||||
|
maxHeight: toRem(250),
|
||||||
|
});
|
||||||
|
export const PickerColumnLabel = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
});
|
||||||
|
export const PickerColumnContent = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
paddingRight: 0,
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react';
|
|||||||
import { color, Text } from 'folds';
|
import { color, Text } from 'folds';
|
||||||
import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk';
|
import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk';
|
||||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
|
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
|
||||||
import {
|
import {
|
||||||
ExtendedJoinRules,
|
ExtendedJoinRules,
|
||||||
@@ -20,6 +21,12 @@ import { useStateEvent } from '../../../hooks/useStateEvent';
|
|||||||
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { getStateEvents } from '../../../utils/room';
|
import { getStateEvents } from '../../../utils/room';
|
||||||
|
import {
|
||||||
|
useRecursiveChildSpaceScopeFactory,
|
||||||
|
useSpaceChildren,
|
||||||
|
} from '../../../state/hooks/roomList';
|
||||||
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
|
|
||||||
type RestrictedRoomAllowContent = {
|
type RestrictedRoomAllowContent = {
|
||||||
room_id: string;
|
room_id: string;
|
||||||
@@ -36,7 +43,11 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
|
|||||||
const allowKnockRestricted = roomVersion >= 10;
|
const allowKnockRestricted = roomVersion >= 10;
|
||||||
const allowRestricted = roomVersion >= 8;
|
const allowRestricted = roomVersion >= 8;
|
||||||
const allowKnock = roomVersion >= 7;
|
const allowKnock = roomVersion >= 7;
|
||||||
|
|
||||||
|
const roomIdToParents = useAtomValue(roomToParentsAtom);
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
|
const subspacesScope = useRecursiveChildSpaceScopeFactory(mx, roomIdToParents);
|
||||||
|
const subspaces = useSpaceChildren(allRoomsAtom, space?.roomId ?? '', subspacesScope);
|
||||||
|
|
||||||
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
|
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
|
||||||
const canEdit = powerLevelAPI.canSendStateEvent(
|
const canEdit = powerLevelAPI.canSendStateEvent(
|
||||||
@@ -74,9 +85,22 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
|
|||||||
async (joinRule: ExtendedJoinRules) => {
|
async (joinRule: ExtendedJoinRules) => {
|
||||||
const allow: RestrictedRoomAllowContent[] = [];
|
const allow: RestrictedRoomAllowContent[] = [];
|
||||||
if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') {
|
if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') {
|
||||||
const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) =>
|
const roomParents = roomIdToParents.get(room.roomId);
|
||||||
event.getStateKey()
|
|
||||||
);
|
const parents = getStateEvents(room, StateEvent.SpaceParent)
|
||||||
|
.map((event) => event.getStateKey())
|
||||||
|
.filter((parentId) => typeof parentId === 'string')
|
||||||
|
.filter((parentId) => roomParents?.has(parentId));
|
||||||
|
|
||||||
|
if (parents.length === 0 && space && roomParents) {
|
||||||
|
// if no m.space.parent found
|
||||||
|
// find parent in current space
|
||||||
|
const selectedParents = subspaces.filter((rId) => roomParents.has(rId));
|
||||||
|
if (roomParents.has(space.roomId)) {
|
||||||
|
selectedParents.push(space.roomId);
|
||||||
|
}
|
||||||
|
selectedParents.forEach((pId) => parents.push(pId));
|
||||||
|
}
|
||||||
parents.forEach((parentRoomId) => {
|
parents.forEach((parentRoomId) => {
|
||||||
if (!parentRoomId) return;
|
if (!parentRoomId) return;
|
||||||
allow.push({
|
allow.push({
|
||||||
@@ -92,7 +116,7 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
|
|||||||
if (allow.length > 0) c.allow = allow;
|
if (allow.length > 0) c.allow = allow;
|
||||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c);
|
await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c);
|
||||||
},
|
},
|
||||||
[mx, room]
|
[mx, room, space, subspaces, roomIdToParents]
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
|
import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
|
import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -36,7 +36,7 @@ import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
|
|||||||
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
|
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
import { getCanonicalAliasOrRoomId, rateLimitedActions } from '../../utils/matrix';
|
||||||
import { getSpaceRoomPath } from '../../pages/pathUtils';
|
import { getSpaceRoomPath } from '../../pages/pathUtils';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { CanDropCallback, useDnDMonitor } from './DnD';
|
import { CanDropCallback, useDnDMonitor } from './DnD';
|
||||||
@@ -53,6 +53,95 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|||||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||||
import { SpaceHierarchy } from './SpaceHierarchy';
|
import { SpaceHierarchy } from './SpaceHierarchy';
|
||||||
|
import { useGetRoom } from '../../hooks/useGetRoom';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
|
||||||
|
const useCanDropLobbyItem = (
|
||||||
|
space: Room,
|
||||||
|
roomsPowerLevels: Map<string, IPowerLevels>,
|
||||||
|
getRoom: (roomId: string) => Room | undefined,
|
||||||
|
canEditSpaceChild: (powerLevels: IPowerLevels) => boolean
|
||||||
|
): CanDropCallback => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
const canDropSpace: CanDropCallback = useCallback(
|
||||||
|
(item, container) => {
|
||||||
|
if (!('space' in container.item)) {
|
||||||
|
// can not drop around rooms.
|
||||||
|
// space can only be drop around other spaces
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerSpaceId = space.roomId;
|
||||||
|
|
||||||
|
if (
|
||||||
|
getRoom(containerSpaceId) === undefined ||
|
||||||
|
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[space, roomsPowerLevels, getRoom, canEditSpaceChild]
|
||||||
|
);
|
||||||
|
|
||||||
|
const canDropRoom: CanDropCallback = useCallback(
|
||||||
|
(item, container) => {
|
||||||
|
const containerSpaceId =
|
||||||
|
'space' in container.item ? container.item.roomId : container.item.parentId;
|
||||||
|
|
||||||
|
const draggingOutsideSpace = item.parentId !== containerSpaceId;
|
||||||
|
const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
|
||||||
|
|
||||||
|
// check and do not allow restricted room to be dragged outside
|
||||||
|
// current space if can't change `m.room.join_rules` `content.allow`
|
||||||
|
if (draggingOutsideSpace && restrictedItem) {
|
||||||
|
const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
|
||||||
|
const userPLInItem = powerLevelAPI.getPowerLevel(
|
||||||
|
itemPowerLevel,
|
||||||
|
mx.getUserId() ?? undefined
|
||||||
|
);
|
||||||
|
const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
|
||||||
|
itemPowerLevel,
|
||||||
|
StateEvent.RoomJoinRules,
|
||||||
|
userPLInItem
|
||||||
|
);
|
||||||
|
if (!canChangeJoinRuleAllow) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
getRoom(containerSpaceId) === undefined ||
|
||||||
|
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[mx, getRoom, canEditSpaceChild, roomsPowerLevels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const canDrop: CanDropCallback = useCallback(
|
||||||
|
(item, container): boolean => {
|
||||||
|
if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
|
||||||
|
// can not drop before or after itself
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we are dragging a space
|
||||||
|
if ('space' in item) {
|
||||||
|
return canDropSpace(item, container);
|
||||||
|
}
|
||||||
|
|
||||||
|
return canDropRoom(item, container);
|
||||||
|
},
|
||||||
|
[canDropSpace, canDropRoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
return canDrop;
|
||||||
|
};
|
||||||
|
|
||||||
export function Lobby() {
|
export function Lobby() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -92,15 +181,7 @@ export function Lobby() {
|
|||||||
useCallback((w, height) => setHeroSectionHeight(height), [])
|
useCallback((w, height) => setHeroSectionHeight(height), [])
|
||||||
);
|
);
|
||||||
|
|
||||||
const getRoom = useCallback(
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
(rId: string) => {
|
|
||||||
if (allJoinedRooms.has(rId)) {
|
|
||||||
return mx.getRoom(rId) ?? undefined;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
[mx, allJoinedRooms]
|
|
||||||
);
|
|
||||||
|
|
||||||
const canEditSpaceChild = useCallback(
|
const canEditSpaceChild = useCallback(
|
||||||
(powerLevels: IPowerLevels) =>
|
(powerLevels: IPowerLevels) =>
|
||||||
@@ -150,180 +231,155 @@ export function Lobby() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const canDrop: CanDropCallback = useCallback(
|
const canDrop: CanDropCallback = useCanDropLobbyItem(
|
||||||
(item, container): boolean => {
|
space,
|
||||||
const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
|
roomsPowerLevels,
|
||||||
if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
|
getRoom,
|
||||||
// can not drop before or after itself
|
canEditSpaceChild
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('space' in item) {
|
|
||||||
if (!('space' in container.item)) return false;
|
|
||||||
const containerSpaceId = space.roomId;
|
|
||||||
|
|
||||||
if (
|
|
||||||
getRoom(containerSpaceId) === undefined ||
|
|
||||||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerSpaceId =
|
|
||||||
'space' in container.item ? container.item.roomId : container.item.parentId;
|
|
||||||
|
|
||||||
const dropOutsideSpace = item.parentId !== containerSpaceId;
|
|
||||||
|
|
||||||
if (dropOutsideSpace && restrictedItem) {
|
|
||||||
// do not allow restricted room to drop outside
|
|
||||||
// current space if can't change join rule allow
|
|
||||||
const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
|
|
||||||
const userPLInItem = powerLevelAPI.getPowerLevel(
|
|
||||||
itemPowerLevel,
|
|
||||||
mx.getUserId() ?? undefined
|
|
||||||
);
|
|
||||||
const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
|
|
||||||
itemPowerLevel,
|
|
||||||
StateEvent.RoomJoinRules,
|
|
||||||
userPLInItem
|
|
||||||
);
|
|
||||||
if (!canChangeJoinRuleAllow) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
getRoom(containerSpaceId) === undefined ||
|
|
||||||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[getRoom, space.roomId, roomsPowerLevels, canEditSpaceChild, mx]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const reorderSpace = useCallback(
|
const [reorderSpaceState, reorderSpace] = useAsyncCallback(
|
||||||
(item: HierarchyItemSpace, containerItem: HierarchyItem) => {
|
useCallback(
|
||||||
if (!item.parentId) return;
|
async (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
|
||||||
|
if (!item.parentId) return;
|
||||||
|
|
||||||
const itemSpaces: HierarchyItemSpace[] = hierarchy
|
const itemSpaces: HierarchyItemSpace[] = hierarchy
|
||||||
.map((i) => i.space)
|
.map((i) => i.space)
|
||||||
.filter((i) => i.roomId !== item.roomId);
|
.filter((i) => i.roomId !== item.roomId);
|
||||||
|
|
||||||
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
|
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
|
||||||
const insertIndex = beforeIndex + 1;
|
const insertIndex = beforeIndex + 1;
|
||||||
|
|
||||||
itemSpaces.splice(insertIndex, 0, {
|
itemSpaces.splice(insertIndex, 0, {
|
||||||
...item,
|
...item,
|
||||||
content: { ...item.content, order: undefined },
|
content: { ...item.content, order: undefined },
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentOrders = itemSpaces.map((i) => {
|
const currentOrders = itemSpaces.map((i) => {
|
||||||
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
|
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
|
||||||
return i.content.order;
|
return i.content.order;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
const newOrders = orderKeys(lex, currentOrders);
|
const newOrders = orderKeys(lex, currentOrders);
|
||||||
|
|
||||||
newOrders?.forEach((orderKey, index) => {
|
const reorders = newOrders
|
||||||
const itm = itemSpaces[index];
|
?.map((orderKey, index) => ({
|
||||||
if (!itm || !itm.parentId) return;
|
item: itemSpaces[index],
|
||||||
const parentPL = roomsPowerLevels.get(itm.parentId);
|
orderKey,
|
||||||
const canEdit = parentPL && canEditSpaceChild(parentPL);
|
}))
|
||||||
if (canEdit && orderKey !== currentOrders[index]) {
|
.filter((reorder, index) => {
|
||||||
mx.sendStateEvent(
|
if (!reorder.item.parentId) return false;
|
||||||
itm.parentId,
|
const parentPL = roomsPowerLevels.get(reorder.item.parentId);
|
||||||
StateEvent.SpaceChild as any,
|
const canEdit = parentPL && canEditSpaceChild(parentPL);
|
||||||
{ ...itm.content, order: orderKey },
|
return canEdit && reorder.orderKey !== currentOrders[index];
|
||||||
itm.roomId
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
|
|
||||||
);
|
|
||||||
|
|
||||||
const reorderRoom = useCallback(
|
if (reorders) {
|
||||||
(item: HierarchyItem, containerItem: HierarchyItem): void => {
|
await rateLimitedActions(reorders, async (reorder) => {
|
||||||
const itemRoom = mx.getRoom(item.roomId);
|
if (!reorder.item.parentId) return;
|
||||||
if (!item.parentId) {
|
await mx.sendStateEvent(
|
||||||
return;
|
reorder.item.parentId,
|
||||||
}
|
StateEvent.SpaceChild as any,
|
||||||
const containerParentId: string =
|
{ ...reorder.item.content, order: reorder.orderKey },
|
||||||
'space' in containerItem ? containerItem.roomId : containerItem.parentId;
|
reorder.item.roomId
|
||||||
const itemContent = item.content;
|
);
|
||||||
|
|
||||||
if (item.parentId !== containerParentId) {
|
|
||||||
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
itemRoom &&
|
|
||||||
itemRoom.getJoinRule() === JoinRule.Restricted &&
|
|
||||||
item.parentId !== containerParentId
|
|
||||||
) {
|
|
||||||
// change join rule allow parameter when dragging
|
|
||||||
// restricted room from one space to another
|
|
||||||
const joinRuleContent = getStateEvent(
|
|
||||||
itemRoom,
|
|
||||||
StateEvent.RoomJoinRules
|
|
||||||
)?.getContent<RoomJoinRulesEventContent>();
|
|
||||||
|
|
||||||
if (joinRuleContent) {
|
|
||||||
const allow =
|
|
||||||
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
|
|
||||||
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
|
|
||||||
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
|
|
||||||
...joinRuleContent,
|
|
||||||
allow,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
|
||||||
const itemSpaces = Array.from(
|
)
|
||||||
hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
|
|
||||||
);
|
|
||||||
|
|
||||||
const beforeItem: HierarchyItem | undefined =
|
|
||||||
'space' in containerItem ? undefined : containerItem;
|
|
||||||
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
|
|
||||||
const insertIndex = beforeIndex + 1;
|
|
||||||
|
|
||||||
itemSpaces.splice(insertIndex, 0, {
|
|
||||||
...item,
|
|
||||||
parentId: containerParentId,
|
|
||||||
content: { ...itemContent, order: undefined },
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentOrders = itemSpaces.map((i) => {
|
|
||||||
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
|
|
||||||
return i.content.order;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const newOrders = orderKeys(lex, currentOrders);
|
|
||||||
|
|
||||||
newOrders?.forEach((orderKey, index) => {
|
|
||||||
const itm = itemSpaces[index];
|
|
||||||
if (itm && orderKey !== currentOrders[index]) {
|
|
||||||
mx.sendStateEvent(
|
|
||||||
containerParentId,
|
|
||||||
StateEvent.SpaceChild as any,
|
|
||||||
{ ...itm.content, order: orderKey },
|
|
||||||
itm.roomId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[mx, hierarchy, lex]
|
|
||||||
);
|
);
|
||||||
|
const reorderingSpace = reorderSpaceState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const [reorderRoomState, reorderRoom] = useAsyncCallback(
|
||||||
|
useCallback(
|
||||||
|
async (item: HierarchyItem, containerItem: HierarchyItem) => {
|
||||||
|
const itemRoom = mx.getRoom(item.roomId);
|
||||||
|
if (!item.parentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const containerParentId: string =
|
||||||
|
'space' in containerItem ? containerItem.roomId : containerItem.parentId;
|
||||||
|
const itemContent = item.content;
|
||||||
|
|
||||||
|
// remove from current space
|
||||||
|
if (item.parentId !== containerParentId) {
|
||||||
|
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
itemRoom &&
|
||||||
|
itemRoom.getJoinRule() === JoinRule.Restricted &&
|
||||||
|
item.parentId !== containerParentId
|
||||||
|
) {
|
||||||
|
// change join rule allow parameter when dragging
|
||||||
|
// restricted room from one space to another
|
||||||
|
const joinRuleContent = getStateEvent(
|
||||||
|
itemRoom,
|
||||||
|
StateEvent.RoomJoinRules
|
||||||
|
)?.getContent<RoomJoinRulesEventContent>();
|
||||||
|
|
||||||
|
if (joinRuleContent) {
|
||||||
|
const allow =
|
||||||
|
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ??
|
||||||
|
[];
|
||||||
|
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
|
||||||
|
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
|
||||||
|
...joinRuleContent,
|
||||||
|
allow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemSpaces = Array.from(
|
||||||
|
hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
const beforeItem: HierarchyItem | undefined =
|
||||||
|
'space' in containerItem ? undefined : containerItem;
|
||||||
|
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
|
||||||
|
const insertIndex = beforeIndex + 1;
|
||||||
|
|
||||||
|
itemSpaces.splice(insertIndex, 0, {
|
||||||
|
...item,
|
||||||
|
parentId: containerParentId,
|
||||||
|
content: { ...itemContent, order: undefined },
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentOrders = itemSpaces.map((i) => {
|
||||||
|
if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
|
||||||
|
return i.content.order;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const newOrders = orderKeys(lex, currentOrders);
|
||||||
|
|
||||||
|
const reorders = newOrders
|
||||||
|
?.map((orderKey, index) => ({
|
||||||
|
item: itemSpaces[index],
|
||||||
|
orderKey,
|
||||||
|
}))
|
||||||
|
.filter((reorder, index) => reorder.item && reorder.orderKey !== currentOrders[index]);
|
||||||
|
|
||||||
|
if (reorders) {
|
||||||
|
await rateLimitedActions(reorders, async (reorder) => {
|
||||||
|
await mx.sendStateEvent(
|
||||||
|
containerParentId,
|
||||||
|
StateEvent.SpaceChild as any,
|
||||||
|
{ ...reorder.item.content, order: reorder.orderKey },
|
||||||
|
reorder.item.roomId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx, hierarchy, lex]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const reorderingRoom = reorderRoomState.status === AsyncStatus.Loading;
|
||||||
|
const reordering = reorderingRoom || reorderingSpace;
|
||||||
|
|
||||||
useDnDMonitor(
|
useDnDMonitor(
|
||||||
scrollRef,
|
scrollRef,
|
||||||
@@ -449,6 +505,7 @@ export function Lobby() {
|
|||||||
draggingItem={draggingItem}
|
draggingItem={draggingItem}
|
||||||
onDragging={setDraggingItem}
|
onDragging={setDraggingItem}
|
||||||
canDrop={canDrop}
|
canDrop={canDrop}
|
||||||
|
disabledReorder={reordering}
|
||||||
nextSpaceId={nextSpaceId}
|
nextSpaceId={nextSpaceId}
|
||||||
getRoom={getRoom}
|
getRoom={getRoom}
|
||||||
pinned={sidebarSpaces.has(item.space.roomId)}
|
pinned={sidebarSpaces.has(item.space.roomId)}
|
||||||
@@ -460,6 +517,28 @@ export function Lobby() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{reordering && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: config.space.S400,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
justifyContent="Center"
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
outlined
|
||||||
|
radii="Pill"
|
||||||
|
before={<Spinner variant="Secondary" fill="Soft" size="100" />}
|
||||||
|
>
|
||||||
|
<Text size="L400">Reordering</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</PageContentCenter>
|
</PageContentCenter>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type SpaceHierarchyProps = {
|
|||||||
draggingItem?: HierarchyItem;
|
draggingItem?: HierarchyItem;
|
||||||
onDragging: (item?: HierarchyItem) => void;
|
onDragging: (item?: HierarchyItem) => void;
|
||||||
canDrop: CanDropCallback;
|
canDrop: CanDropCallback;
|
||||||
|
disabledReorder?: boolean;
|
||||||
nextSpaceId?: string;
|
nextSpaceId?: string;
|
||||||
getRoom: (roomId: string) => Room | undefined;
|
getRoom: (roomId: string) => Room | undefined;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
@@ -54,6 +55,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
|||||||
draggingItem,
|
draggingItem,
|
||||||
onDragging,
|
onDragging,
|
||||||
canDrop,
|
canDrop,
|
||||||
|
disabledReorder,
|
||||||
nextSpaceId,
|
nextSpaceId,
|
||||||
getRoom,
|
getRoom,
|
||||||
pinned,
|
pinned,
|
||||||
@@ -116,7 +118,9 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
|||||||
handleClose={handleClose}
|
handleClose={handleClose}
|
||||||
getRoom={getRoom}
|
getRoom={getRoom}
|
||||||
canEditChild={canEditSpaceChild(spacePowerLevels)}
|
canEditChild={canEditSpaceChild(spacePowerLevels)}
|
||||||
canReorder={parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false}
|
canReorder={
|
||||||
|
parentPowerLevels && !disabledReorder ? canEditSpaceChild(parentPowerLevels) : false
|
||||||
|
}
|
||||||
options={
|
options={
|
||||||
parentId &&
|
parentId &&
|
||||||
parentPowerLevels && (
|
parentPowerLevels && (
|
||||||
@@ -174,7 +178,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
|||||||
dm={mDirects.has(roomItem.roomId)}
|
dm={mDirects.has(roomItem.roomId)}
|
||||||
onOpen={onOpenRoom}
|
onOpen={onOpenRoom}
|
||||||
getRoom={getRoom}
|
getRoom={getRoom}
|
||||||
canReorder={canEditSpaceChild(spacePowerLevels)}
|
canReorder={canEditSpaceChild(spacePowerLevels) && !disabledReorder}
|
||||||
options={
|
options={
|
||||||
<HierarchyItemMenu
|
<HierarchyItemMenu
|
||||||
item={roomItem}
|
item={roomItem}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
|||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { SearchOrderBy } from 'matrix-js-sdk';
|
import { SearchOrderBy } from 'matrix-js-sdk';
|
||||||
import { PageHero, PageHeroSection } from '../../components/page';
|
import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { _SearchPathSearchParams } from '../../pages/paths';
|
import { _SearchPathSearchParams } from '../../pages/paths';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
@@ -57,6 +57,9 @@ export function MessageSearch({
|
|||||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
|
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -222,18 +225,7 @@ export function MessageSearch({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!msgSearchParams.term && status === 'pending' && (
|
{!msgSearchParams.term && status === 'pending' && (
|
||||||
<Box
|
<PageHeroEmpty>
|
||||||
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
|
||||||
style={{
|
|
||||||
padding: config.space.S400,
|
|
||||||
borderRadius: config.radii.R400,
|
|
||||||
minHeight: toRem(450),
|
|
||||||
}}
|
|
||||||
direction="Column"
|
|
||||||
alignItems="Center"
|
|
||||||
justifyContent="Center"
|
|
||||||
gap="200"
|
|
||||||
>
|
|
||||||
<PageHeroSection>
|
<PageHeroSection>
|
||||||
<PageHero
|
<PageHero
|
||||||
icon={<Icon size="600" src={Icons.Message} />}
|
icon={<Icon size="600" src={Icons.Message} />}
|
||||||
@@ -241,7 +233,7 @@ export function MessageSearch({
|
|||||||
subTitle="Find helpful messages in your community by searching with related keywords."
|
subTitle="Find helpful messages in your community by searching with related keywords."
|
||||||
/>
|
/>
|
||||||
</PageHeroSection>
|
</PageHeroSection>
|
||||||
</Box>
|
</PageHeroEmpty>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{msgSearchParams.term && groups.length === 0 && status === 'success' && (
|
{msgSearchParams.term && groups.length === 0 && status === 'success' && (
|
||||||
@@ -300,6 +292,8 @@ export function MessageSearch({
|
|||||||
urlPreview={urlPreview}
|
urlPreview={urlPreview}
|
||||||
onOpen={navigateRoom}
|
onOpen={navigateRoom}
|
||||||
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
|
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
</VirtualTile>
|
</VirtualTile>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export function SearchInput({ active, loading, searchInputRef, onSearch, onReset
|
|||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
style={{ paddingRight: config.space.S300 }}
|
style={{ paddingRight: config.space.S300 }}
|
||||||
name="searchInput"
|
name="searchInput"
|
||||||
|
autoFocus
|
||||||
size="500"
|
size="500"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
placeholder="Search for keyword"
|
placeholder="Search for keyword"
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ type SearchResultGroupProps = {
|
|||||||
urlPreview?: boolean;
|
urlPreview?: boolean;
|
||||||
onOpen: (roomId: string, eventId: string) => void;
|
onOpen: (roomId: string, eventId: string) => void;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
export function SearchResultGroup({
|
export function SearchResultGroup({
|
||||||
room,
|
room,
|
||||||
@@ -66,6 +68,8 @@ export function SearchResultGroup({
|
|||||||
urlPreview,
|
urlPreview,
|
||||||
onOpen,
|
onOpen,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
}: SearchResultGroupProps) {
|
}: SearchResultGroupProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
@@ -275,7 +279,11 @@ export function SearchResultGroup({
|
|||||||
</Username>
|
</Username>
|
||||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||||
</Box>
|
</Box>
|
||||||
<Time ts={event.origin_server_ts} />
|
<Time
|
||||||
|
ts={event.origin_server_ts}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" gap="200" alignItems="Center">
|
<Box shrink="No" gap="200" alignItems="Center">
|
||||||
<Chip
|
<Chip
|
||||||
|
|||||||
@@ -543,7 +543,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
>
|
>
|
||||||
<Icon src={Icons.Cross} size="50" />
|
<Icon src={Icons.Cross} size="50" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Box direction="Column">
|
<Box direction="Row" gap="200" alignItems="Center">
|
||||||
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
||||||
<ReplyLayout
|
<ReplyLayout
|
||||||
userColor={replyUsernameColor}
|
userColor={replyUsernameColor}
|
||||||
|
|||||||
@@ -448,6 +448,10 @@ export function RoomTimeline({
|
|||||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||||
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
const ignoredUsersList = useIgnoredUsers();
|
const ignoredUsersList = useIgnoredUsers();
|
||||||
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
||||||
@@ -932,7 +936,7 @@ export function RoomTimeline({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
(evt) => {
|
(evt, startThread = false) => {
|
||||||
const replyId = evt.currentTarget.getAttribute('data-event-id');
|
const replyId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
if (!replyId) {
|
if (!replyId) {
|
||||||
console.warn('Button should have "data-event-id" attribute!');
|
console.warn('Button should have "data-event-id" attribute!');
|
||||||
@@ -943,7 +947,9 @@ export function RoomTimeline({
|
|||||||
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
||||||
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||||
const { body, formatted_body: formattedBody } = content;
|
const { body, formatted_body: formattedBody } = content;
|
||||||
const { 'm.relates_to': relation } = replyEvt.getWireContent();
|
const { 'm.relates_to': relation } = startThread
|
||||||
|
? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
|
||||||
|
: replyEvt.getWireContent();
|
||||||
const senderId = replyEvt.getSender();
|
const senderId = replyEvt.getSender();
|
||||||
if (senderId && typeof body === 'string') {
|
if (senderId && typeof body === 'string') {
|
||||||
setReplyDraft({
|
setReplyDraft({
|
||||||
@@ -1065,9 +1071,12 @@ export function RoomTimeline({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
||||||
accessibleTagColors={accessibleTagColors}
|
accessibleTagColors={accessibleTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
>
|
>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
@@ -1146,9 +1155,12 @@ export function RoomTimeline({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
||||||
accessibleTagColors={accessibleTagColors}
|
accessibleTagColors={accessibleTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
>
|
>
|
||||||
<EncryptedContent mEvent={mEvent}>
|
<EncryptedContent mEvent={mEvent}>
|
||||||
{() => {
|
{() => {
|
||||||
@@ -1247,9 +1259,12 @@ export function RoomTimeline({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
||||||
accessibleTagColors={accessibleTagColors}
|
accessibleTagColors={accessibleTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
>
|
>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
@@ -1278,7 +1293,12 @@ export function RoomTimeline({
|
|||||||
const parsed = parseMemberEvent(mEvent);
|
const parsed = parseMemberEvent(mEvent);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1292,6 +1312,7 @@ export function RoomTimeline({
|
|||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
@@ -1314,7 +1335,12 @@ export function RoomTimeline({
|
|||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1328,6 +1354,7 @@ export function RoomTimeline({
|
|||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
@@ -1351,7 +1378,12 @@ export function RoomTimeline({
|
|||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1365,6 +1397,7 @@ export function RoomTimeline({
|
|||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
@@ -1388,7 +1421,12 @@ export function RoomTimeline({
|
|||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1402,6 +1440,7 @@ export function RoomTimeline({
|
|||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
@@ -1427,7 +1466,12 @@ export function RoomTimeline({
|
|||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1441,6 +1485,7 @@ export function RoomTimeline({
|
|||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
@@ -1471,7 +1516,12 @@ export function RoomTimeline({
|
|||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1485,6 +1535,7 @@ export function RoomTimeline({
|
|||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ import {
|
|||||||
getRoomNotificationModeIcon,
|
getRoomNotificationModeIcon,
|
||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
|
import { JumpToTime } from './jump-to-time';
|
||||||
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
|
||||||
type RoomMenuProps = {
|
type RoomMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -79,6 +81,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||||||
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
|
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
markAsRead(mx, room.roomId, hideActivity);
|
||||||
@@ -175,6 +178,33 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||||||
Room Settings
|
Room Settings
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<UseStateProvider initial={false}>
|
||||||
|
{(promptJump, setPromptJump) => (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => setPromptJump(true)}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.RecentClock} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={promptJump}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Jump to Time
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
{promptJump && (
|
||||||
|
<JumpToTime
|
||||||
|
onSubmit={(eventId) => {
|
||||||
|
setPromptJump(false);
|
||||||
|
navigateRoom(room.roomId, eventId);
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
onCancel={() => setPromptJump(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</UseStateProvider>
|
||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<Line variant="Surface" size="300" />
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
|||||||
260
src/app/features/room/jump-to-time/JumpToTime.tsx
Normal file
260
src/app/features/room/jump-to-time/JumpToTime.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
Overlay,
|
||||||
|
OverlayCenter,
|
||||||
|
OverlayBackdrop,
|
||||||
|
Header,
|
||||||
|
config,
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
IconButton,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
color,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
Chip,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
} from 'folds';
|
||||||
|
import { Direction, MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time';
|
||||||
|
import { DatePicker, TimePicker } from '../../../components/time-date';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
|
||||||
|
type JumpToTimeProps = {
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (eventId: string) => void;
|
||||||
|
};
|
||||||
|
export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
const alive = useAlive();
|
||||||
|
const createStateEvent = useStateEvent(room, StateEvent.RoomCreate);
|
||||||
|
|
||||||
|
const todayTs = getToday();
|
||||||
|
const yesterdayTs = getYesterday();
|
||||||
|
const createTs = useMemo(() => createStateEvent?.getTs() ?? 0, [createStateEvent]);
|
||||||
|
const [ts, setTs] = useState(() => Date.now());
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
|
const [timePickerCords, setTimePickerCords] = useState<RectCords>();
|
||||||
|
const [datePickerCords, setDatePickerCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleTimePicker: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setTimePickerCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
const handleDatePicker: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setDatePickerCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToday = () => {
|
||||||
|
setTs(todayTs < createTs ? createTs : todayTs);
|
||||||
|
};
|
||||||
|
const handleYesterday = () => {
|
||||||
|
setTs(yesterdayTs < createTs ? createTs : yesterdayTs);
|
||||||
|
};
|
||||||
|
const handleBeginning = () => setTs(createTs);
|
||||||
|
|
||||||
|
const [timestampState, timestampToEvent] = useAsyncCallback<string, MatrixError, [number]>(
|
||||||
|
useCallback(
|
||||||
|
async (newTs) => {
|
||||||
|
const result = await mx.timestampToEvent(room.roomId, newTs, Direction.Forward);
|
||||||
|
return result.event_id;
|
||||||
|
},
|
||||||
|
[mx, room]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
timestampToEvent(ts).then((eventId) => {
|
||||||
|
if (alive()) {
|
||||||
|
onSubmit(eventId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onCancel,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface">
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">Jump to Time</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="500">
|
||||||
|
<Box direction="Row" gap="300">
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400" priority="400">
|
||||||
|
Time
|
||||||
|
</Text>
|
||||||
|
<Box gap="100" alignItems="Center">
|
||||||
|
<Chip
|
||||||
|
size="500"
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
outlined
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={!!timePickerCords}
|
||||||
|
after={<Icon size="50" src={Icons.ChevronBottom} />}
|
||||||
|
onClick={handleTimePicker}
|
||||||
|
>
|
||||||
|
<Text size="B300">{timeHourMinute(ts, hour24Clock)}</Text>
|
||||||
|
</Chip>
|
||||||
|
<PopOut
|
||||||
|
anchor={timePickerCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="Center"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setTimePickerCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TimePicker min={createTs} max={Date.now()} value={ts} onChange={setTs} />
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400" priority="400">
|
||||||
|
Date
|
||||||
|
</Text>
|
||||||
|
<Box gap="100" alignItems="Center">
|
||||||
|
<Chip
|
||||||
|
size="500"
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
outlined
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={!!datePickerCords}
|
||||||
|
after={<Icon size="50" src={Icons.ChevronBottom} />}
|
||||||
|
onClick={handleDatePicker}
|
||||||
|
>
|
||||||
|
<Text size="B300">{timeDayMonthYear(ts)}</Text>
|
||||||
|
</Chip>
|
||||||
|
<PopOut
|
||||||
|
anchor={datePickerCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="Center"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setDatePickerCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatePicker min={createTs} max={Date.now()} value={ts} onChange={setTs} />
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Preset</Text>
|
||||||
|
<Box gap="200">
|
||||||
|
{createTs < todayTs && (
|
||||||
|
<Chip
|
||||||
|
variant={ts === todayTs ? 'Success' : 'SurfaceVariant'}
|
||||||
|
radii="Pill"
|
||||||
|
aria-pressed={ts === todayTs}
|
||||||
|
onClick={handleToday}
|
||||||
|
>
|
||||||
|
<Text size="B300">Today</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
{createTs < yesterdayTs && (
|
||||||
|
<Chip
|
||||||
|
variant={ts === yesterdayTs ? 'Success' : 'SurfaceVariant'}
|
||||||
|
radii="Pill"
|
||||||
|
aria-pressed={ts === yesterdayTs}
|
||||||
|
onClick={handleYesterday}
|
||||||
|
>
|
||||||
|
<Text size="B300">Yesterday</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
variant={ts === createTs ? 'Success' : 'SurfaceVariant'}
|
||||||
|
radii="Pill"
|
||||||
|
aria-pressed={ts === createTs}
|
||||||
|
onClick={handleBeginning}
|
||||||
|
>
|
||||||
|
<Text size="B300">Beginning</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{timestampState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
{timestampState.error.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="Primary"
|
||||||
|
before={
|
||||||
|
timestampState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner fill="Solid" variant="Primary" size="200" />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
aria-disabled={
|
||||||
|
timestampState.status === AsyncStatus.Loading ||
|
||||||
|
timestampState.status === AsyncStatus.Success
|
||||||
|
}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
<Text size="B400">Open Timeline</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/features/room/jump-to-time/index.ts
Normal file
1
src/app/features/room/jump-to-time/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './JumpToTime';
|
||||||
@@ -669,15 +669,21 @@ export type MessageProps = {
|
|||||||
messageSpacing: MessageSpacing;
|
messageSpacing: MessageSpacing;
|
||||||
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
onReplyClick: MouseEventHandler<HTMLButtonElement>;
|
onReplyClick: (
|
||||||
|
ev: Parameters<MouseEventHandler<HTMLButtonElement>>[0],
|
||||||
|
startThread?: boolean
|
||||||
|
) => void;
|
||||||
onEditId?: (eventId?: string) => void;
|
onEditId?: (eventId?: string) => void;
|
||||||
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
|
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
|
||||||
reply?: ReactNode;
|
reply?: ReactNode;
|
||||||
reactions?: ReactNode;
|
reactions?: ReactNode;
|
||||||
hideReadReceipts?: boolean;
|
hideReadReceipts?: boolean;
|
||||||
|
showDeveloperTools?: boolean;
|
||||||
powerLevelTag?: PowerLevelTag;
|
powerLevelTag?: PowerLevelTag;
|
||||||
accessibleTagColors?: Map<string, string>;
|
accessibleTagColors?: Map<string, string>;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
export const Message = as<'div', MessageProps>(
|
export const Message = as<'div', MessageProps>(
|
||||||
(
|
(
|
||||||
@@ -703,9 +709,12 @@ export const Message = as<'div', MessageProps>(
|
|||||||
reply,
|
reply,
|
||||||
reactions,
|
reactions,
|
||||||
hideReadReceipts,
|
hideReadReceipts,
|
||||||
|
showDeveloperTools,
|
||||||
powerLevelTag,
|
powerLevelTag,
|
||||||
accessibleTagColors,
|
accessibleTagColors,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
@@ -770,7 +779,12 @@ export const Message = as<'div', MessageProps>(
|
|||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -857,6 +871,8 @@ export const Message = as<'div', MessageProps>(
|
|||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isThreadedMessage = mEvent.threadRootId !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBase
|
<MessageBase
|
||||||
className={classNames(css.MessageBase, className)}
|
className={classNames(css.MessageBase, className)}
|
||||||
@@ -919,6 +935,17 @@ export const Message = as<'div', MessageProps>(
|
|||||||
>
|
>
|
||||||
<Icon src={Icons.ReplyArrow} size="100" />
|
<Icon src={Icons.ReplyArrow} size="100" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{!isThreadedMessage && (
|
||||||
|
<IconButton
|
||||||
|
onClick={(ev) => onReplyClick(ev, true)}
|
||||||
|
data-event-id={mEvent.getId()}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.ThreadPlus} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
{canEditEvent(mx, mEvent) && onEditId && (
|
{canEditEvent(mx, mEvent) && onEditId && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => onEditId(mEvent.getId())}
|
onClick={() => onEditId(mEvent.getId())}
|
||||||
@@ -998,6 +1025,27 @@ export const Message = as<'div', MessageProps>(
|
|||||||
Reply
|
Reply
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{!isThreadedMessage && (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
after={<Icon src={Icons.ThreadPlus} size="100" />}
|
||||||
|
radii="300"
|
||||||
|
data-event-id={mEvent.getId()}
|
||||||
|
onClick={(evt: any) => {
|
||||||
|
onReplyClick(evt, true);
|
||||||
|
closeMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={css.MessageMenuItemText}
|
||||||
|
as="span"
|
||||||
|
size="T300"
|
||||||
|
truncate
|
||||||
|
>
|
||||||
|
Reply in Thread
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{canEditEvent(mx, mEvent) && onEditId && (
|
{canEditEvent(mx, mEvent) && onEditId && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
@@ -1026,7 +1074,13 @@ export const Message = as<'div', MessageProps>(
|
|||||||
onClose={closeMenu}
|
onClose={closeMenu}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
{showDeveloperTools && (
|
||||||
|
<MessageSourceCodeItem
|
||||||
|
room={room}
|
||||||
|
mEvent={mEvent}
|
||||||
|
onClose={closeMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
{canPinEvent && (
|
{canPinEvent && (
|
||||||
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
@@ -1101,6 +1155,7 @@ export type EventProps = {
|
|||||||
canDelete?: boolean;
|
canDelete?: boolean;
|
||||||
messageSpacing: MessageSpacing;
|
messageSpacing: MessageSpacing;
|
||||||
hideReadReceipts?: boolean;
|
hideReadReceipts?: boolean;
|
||||||
|
showDeveloperTools?: boolean;
|
||||||
};
|
};
|
||||||
export const Event = as<'div', EventProps>(
|
export const Event = as<'div', EventProps>(
|
||||||
(
|
(
|
||||||
@@ -1112,6 +1167,7 @@ export const Event = as<'div', EventProps>(
|
|||||||
canDelete,
|
canDelete,
|
||||||
messageSpacing,
|
messageSpacing,
|
||||||
hideReadReceipts,
|
hideReadReceipts,
|
||||||
|
showDeveloperTools,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
@@ -1188,7 +1244,13 @@ export const Event = as<'div', EventProps>(
|
|||||||
onClose={closeMenu}
|
onClose={closeMenu}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
{showDeveloperTools && (
|
||||||
|
<MessageSourceCodeItem
|
||||||
|
room={room}
|
||||||
|
mEvent={mEvent}
|
||||||
|
onClose={closeMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
</Box>
|
</Box>
|
||||||
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
|
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
|
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
const [unpinState, unpin] = useAsyncCallback(
|
const [unpinState, unpin] = useAsyncCallback(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
const pinEvent = getStateEvent(room, StateEvent.RoomPinnedEvents);
|
const pinEvent = getStateEvent(room, StateEvent.RoomPinnedEvents);
|
||||||
@@ -205,7 +208,11 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
|
|||||||
</Username>
|
</Username>
|
||||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||||
</Box>
|
</Box>
|
||||||
<Time ts={pinnedEvent.getTs()} />
|
<Time
|
||||||
|
ts={pinnedEvent.getTs()}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{renderOptions()}
|
{renderOptions()}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const useSettingsMenuItems = (): SettingsMenuItem[] =>
|
|||||||
{
|
{
|
||||||
page: SettingsPages.DevicesPage,
|
page: SettingsPages.DevicesPage,
|
||||||
name: 'Devices',
|
name: 'Devices',
|
||||||
icon: Icons.Category,
|
icon: Icons.Monitor,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
page: SettingsPages.EmojisStickersPage,
|
page: SettingsPages.EmojisStickersPage,
|
||||||
|
|||||||
@@ -1,396 +1,10 @@
|
|||||||
import React, {
|
import React from 'react';
|
||||||
ChangeEventHandler,
|
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
||||||
FormEventHandler,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Text,
|
|
||||||
IconButton,
|
|
||||||
Icon,
|
|
||||||
Icons,
|
|
||||||
Scroll,
|
|
||||||
Input,
|
|
||||||
Avatar,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
Overlay,
|
|
||||||
OverlayBackdrop,
|
|
||||||
OverlayCenter,
|
|
||||||
Modal,
|
|
||||||
Dialog,
|
|
||||||
Header,
|
|
||||||
config,
|
|
||||||
Spinner,
|
|
||||||
} from 'folds';
|
|
||||||
import FocusTrap from 'focus-trap-react';
|
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { MatrixId } from './MatrixId';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { Profile } from './Profile';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { ContactInformation } from './ContactInfo';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { IgnoredUserList } from './IgnoredUserList';
|
||||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
|
||||||
import { UserAvatar } from '../../../components/user-avatar';
|
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|
||||||
import { nameInitials } from '../../../utils/common';
|
|
||||||
import { copyToClipboard } from '../../../utils/dom';
|
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|
||||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
|
||||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
|
||||||
import { ImageEditor } from '../../../components/image-editor';
|
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
|
||||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
|
||||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
|
||||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
|
||||||
|
|
||||||
function MatrixId() {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const userId = mx.getUserId()!;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box direction="Column" gap="100">
|
|
||||||
<Text size="L400">Matrix ID</Text>
|
|
||||||
<SequenceCard
|
|
||||||
className={SequenceCardStyle}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
direction="Column"
|
|
||||||
gap="400"
|
|
||||||
>
|
|
||||||
<SettingTile
|
|
||||||
title={userId}
|
|
||||||
after={
|
|
||||||
<Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
|
|
||||||
<Text size="T200">Copy</Text>
|
|
||||||
</Chip>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SequenceCard>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileProps = {
|
|
||||||
profile: UserProfile;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
function ProfileAvatar({ profile, userId }: ProfileProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const capabilities = useCapabilities();
|
|
||||||
const [alertRemove, setAlertRemove] = useState(false);
|
|
||||||
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
|
|
||||||
|
|
||||||
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
|
||||||
const avatarUrl = profile.avatarUrl
|
|
||||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const [imageFile, setImageFile] = useState<File>();
|
|
||||||
const imageFileURL = useObjectURL(imageFile);
|
|
||||||
const uploadAtom = useMemo(() => {
|
|
||||||
if (imageFile) return createUploadAtom(imageFile);
|
|
||||||
return undefined;
|
|
||||||
}, [imageFile]);
|
|
||||||
|
|
||||||
const pickFile = useFilePicker(setImageFile, false);
|
|
||||||
|
|
||||||
const handleRemoveUpload = useCallback(() => {
|
|
||||||
setImageFile(undefined);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleUploaded = useCallback(
|
|
||||||
(upload: UploadSuccess) => {
|
|
||||||
const { mxc } = upload;
|
|
||||||
mx.setAvatarUrl(mxc);
|
|
||||||
handleRemoveUpload();
|
|
||||||
},
|
|
||||||
[mx, handleRemoveUpload]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRemoveAvatar = () => {
|
|
||||||
mx.setAvatarUrl('');
|
|
||||||
setAlertRemove(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingTile
|
|
||||||
title={
|
|
||||||
<Text as="span" size="L400">
|
|
||||||
Avatar
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
after={
|
|
||||||
<Avatar size="500" radii="300">
|
|
||||||
<UserAvatar
|
|
||||||
userId={userId}
|
|
||||||
src={avatarUrl}
|
|
||||||
renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
|
|
||||||
/>
|
|
||||||
</Avatar>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{uploadAtom ? (
|
|
||||||
<Box gap="200" direction="Column">
|
|
||||||
<CompactUploadCardRenderer
|
|
||||||
uploadAtom={uploadAtom}
|
|
||||||
onRemove={handleRemoveUpload}
|
|
||||||
onComplete={handleUploaded}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Box gap="200">
|
|
||||||
<Button
|
|
||||||
onClick={() => pickFile('image/*')}
|
|
||||||
size="300"
|
|
||||||
variant="Secondary"
|
|
||||||
fill="Soft"
|
|
||||||
outlined
|
|
||||||
radii="300"
|
|
||||||
disabled={disableSetAvatar}
|
|
||||||
>
|
|
||||||
<Text size="B300">Upload</Text>
|
|
||||||
</Button>
|
|
||||||
{avatarUrl && (
|
|
||||||
<Button
|
|
||||||
size="300"
|
|
||||||
variant="Critical"
|
|
||||||
fill="None"
|
|
||||||
radii="300"
|
|
||||||
disabled={disableSetAvatar}
|
|
||||||
onClick={() => setAlertRemove(true)}
|
|
||||||
>
|
|
||||||
<Text size="B300">Remove</Text>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{imageFileURL && (
|
|
||||||
<Overlay open={false} backdrop={<OverlayBackdrop />}>
|
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: handleRemoveUpload,
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Modal className={ModalWide} variant="Surface" size="500">
|
|
||||||
<ImageEditor
|
|
||||||
name={imageFile?.name ?? 'Unnamed'}
|
|
||||||
url={imageFileURL}
|
|
||||||
requestClose={handleRemoveUpload}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
|
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setAlertRemove(false),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Dialog variant="Surface">
|
|
||||||
<Header
|
|
||||||
style={{
|
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
|
||||||
borderBottomWidth: config.borderWidth.B300,
|
|
||||||
}}
|
|
||||||
variant="Surface"
|
|
||||||
size="500"
|
|
||||||
>
|
|
||||||
<Box grow="Yes">
|
|
||||||
<Text size="H4">Remove Avatar</Text>
|
|
||||||
</Box>
|
|
||||||
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
|
|
||||||
<Icon src={Icons.Cross} />
|
|
||||||
</IconButton>
|
|
||||||
</Header>
|
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
|
||||||
<Box direction="Column" gap="200">
|
|
||||||
<Text priority="400">Are you sure you want to remove profile avatar?</Text>
|
|
||||||
</Box>
|
|
||||||
<Button variant="Critical" onClick={handleRemoveAvatar}>
|
|
||||||
<Text size="B400">Remove</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Dialog>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
|
||||||
</SettingTile>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const capabilities = useCapabilities();
|
|
||||||
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
|
|
||||||
|
|
||||||
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
|
||||||
const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
|
|
||||||
|
|
||||||
const [changeState, changeDisplayName] = useAsyncCallback(
|
|
||||||
useCallback((name: string) => mx.setDisplayName(name), [mx])
|
|
||||||
);
|
|
||||||
const changingDisplayName = changeState.status === AsyncStatus.Loading;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDisplayName(defaultDisplayName);
|
|
||||||
}, [defaultDisplayName]);
|
|
||||||
|
|
||||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
|
||||||
const name = evt.currentTarget.value;
|
|
||||||
setDisplayName(name);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setDisplayName(defaultDisplayName);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
if (changingDisplayName) return;
|
|
||||||
|
|
||||||
const target = evt.target as HTMLFormElement | undefined;
|
|
||||||
const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
|
|
||||||
const name = displayNameInput?.value;
|
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
changeDisplayName(name);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasChanges = displayName !== defaultDisplayName;
|
|
||||||
return (
|
|
||||||
<SettingTile
|
|
||||||
title={
|
|
||||||
<Text as="span" size="L400">
|
|
||||||
Display Name
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box direction="Column" grow="Yes" gap="100">
|
|
||||||
<Box
|
|
||||||
as="form"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
gap="200"
|
|
||||||
aria-disabled={changingDisplayName || disableSetDisplayname}
|
|
||||||
>
|
|
||||||
<Box grow="Yes" direction="Column">
|
|
||||||
<Input
|
|
||||||
required
|
|
||||||
name="displayNameInput"
|
|
||||||
value={displayName}
|
|
||||||
onChange={handleChange}
|
|
||||||
variant="Secondary"
|
|
||||||
radii="300"
|
|
||||||
style={{ paddingRight: config.space.S200 }}
|
|
||||||
readOnly={changingDisplayName || disableSetDisplayname}
|
|
||||||
after={
|
|
||||||
hasChanges &&
|
|
||||||
!changingDisplayName && (
|
|
||||||
<IconButton
|
|
||||||
type="reset"
|
|
||||||
onClick={handleReset}
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
variant="Secondary"
|
|
||||||
>
|
|
||||||
<Icon src={Icons.Cross} size="100" />
|
|
||||||
</IconButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
size="400"
|
|
||||||
variant={hasChanges ? 'Success' : 'Secondary'}
|
|
||||||
fill={hasChanges ? 'Solid' : 'Soft'}
|
|
||||||
outlined
|
|
||||||
radii="300"
|
|
||||||
disabled={!hasChanges || changingDisplayName}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
|
|
||||||
<Text size="B400">Save</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</SettingTile>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Profile() {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const userId = mx.getUserId()!;
|
|
||||||
const profile = useUserProfile(userId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box direction="Column" gap="100">
|
|
||||||
<Text size="L400">Profile</Text>
|
|
||||||
<SequenceCard
|
|
||||||
className={SequenceCardStyle}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
direction="Column"
|
|
||||||
gap="400"
|
|
||||||
>
|
|
||||||
<ProfileAvatar userId={userId} profile={profile} />
|
|
||||||
<ProfileDisplayName userId={userId} profile={profile} />
|
|
||||||
</SequenceCard>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContactInformation() {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const [threePIdsState, loadThreePIds] = useAsyncCallback(
|
|
||||||
useCallback(() => mx.getThreePids(), [mx])
|
|
||||||
);
|
|
||||||
const threePIds =
|
|
||||||
threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
|
|
||||||
|
|
||||||
const emailIds = threePIds?.filter((id) => id.medium === 'email');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadThreePIds();
|
|
||||||
}, [loadThreePIds]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box direction="Column" gap="100">
|
|
||||||
<Text size="L400">Contact Information</Text>
|
|
||||||
<SequenceCard
|
|
||||||
className={SequenceCardStyle}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
direction="Column"
|
|
||||||
gap="400"
|
|
||||||
>
|
|
||||||
<SettingTile title="Email Address" description="Email address attached to your account.">
|
|
||||||
<Box>
|
|
||||||
{emailIds?.map((email) => (
|
|
||||||
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
|
|
||||||
<Text size="T200">{email.address}</Text>
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
{/* <Input defaultValue="" variant="Secondary" radii="300" /> */}
|
|
||||||
</SettingTile>
|
|
||||||
</SequenceCard>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccountProps = {
|
type AccountProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -419,6 +33,7 @@ export function Account({ requestClose }: AccountProps) {
|
|||||||
<Profile />
|
<Profile />
|
||||||
<MatrixId />
|
<MatrixId />
|
||||||
<ContactInformation />
|
<ContactInformation />
|
||||||
|
<IgnoredUserList />
|
||||||
</Box>
|
</Box>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
45
src/app/features/settings/account/ContactInfo.tsx
Normal file
45
src/app/features/settings/account/ContactInfo.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { Box, Text, Chip } from 'folds';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
|
||||||
|
export function ContactInformation() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [threePIdsState, loadThreePIds] = useAsyncCallback(
|
||||||
|
useCallback(() => mx.getThreePids(), [mx])
|
||||||
|
);
|
||||||
|
const threePIds =
|
||||||
|
threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
|
||||||
|
|
||||||
|
const emailIds = threePIds?.filter((id) => id.medium === 'email');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadThreePIds();
|
||||||
|
}, [loadThreePIds]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Contact Information</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile title="Email Address" description="Email address attached to your account.">
|
||||||
|
<Box>
|
||||||
|
{emailIds?.map((email) => (
|
||||||
|
<Chip key={email.address} as="span" variant="Secondary" radii="Pill">
|
||||||
|
<Text size="T200">{email.address}</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
{/* <Input defaultValue="" variant="Secondary" radii="300" /> */}
|
||||||
|
</SettingTile>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,16 +7,17 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { isUserId } from '../../../utils/matrix';
|
import { isUserId } from '../../../utils/matrix';
|
||||||
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
||||||
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
|
|
||||||
function IgnoreUserInput({ userList }: { userList: string[] }) {
|
function IgnoreUserInput({ userList }: { userList: string[] }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [userId, setUserId] = useState<string>('');
|
const [userId, setUserId] = useState<string>('');
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
const [ignoreState, ignore] = useAsyncCallback(
|
const [ignoreState, ignore] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (uId: string) => {
|
async (uId: string) => {
|
||||||
mx.setIgnoredUsers([...userList, uId]);
|
await mx.setIgnoredUsers([...userList, uId]);
|
||||||
setUserId('');
|
|
||||||
},
|
},
|
||||||
[mx, userList]
|
[mx, userList]
|
||||||
)
|
)
|
||||||
@@ -43,7 +44,11 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
|
|||||||
|
|
||||||
if (!isUserId(uId)) return;
|
if (!isUserId(uId)) return;
|
||||||
|
|
||||||
ignore(uId);
|
ignore(uId).then(() => {
|
||||||
|
if (alive()) {
|
||||||
|
setUserId('');
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,7 +134,7 @@ export function IgnoredUserList() {
|
|||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
<Text size="L400">Block Messages</Text>
|
<Text size="L400">Blocked Users</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
@@ -139,13 +144,13 @@ export function IgnoredUserList() {
|
|||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Select User"
|
title="Select User"
|
||||||
description="Prevent receiving message by adding userId into blocklist."
|
description="Prevent receiving messages or invites from user by adding their userId."
|
||||||
>
|
>
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
<IgnoreUserInput userList={ignoredUsers} />
|
<IgnoreUserInput userList={ignoredUsers} />
|
||||||
{ignoredUsers.length > 0 && (
|
{ignoredUsers.length > 0 && (
|
||||||
<Box direction="Inherit" gap="100">
|
<Box direction="Inherit" gap="100">
|
||||||
<Text size="L400">Blocklist</Text>
|
<Text size="L400">Users</Text>
|
||||||
<Box wrap="Wrap" gap="200">
|
<Box wrap="Wrap" gap="200">
|
||||||
{ignoredUsers.map((userId) => (
|
{ignoredUsers.map((userId) => (
|
||||||
<IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />
|
<IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />
|
||||||
33
src/app/features/settings/account/MatrixId.tsx
Normal file
33
src/app/features/settings/account/MatrixId.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, Chip } from 'folds';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { copyToClipboard } from '../../../../util/common';
|
||||||
|
|
||||||
|
export function MatrixId() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const userId = mx.getUserId()!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Matrix ID</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title={userId}
|
||||||
|
after={
|
||||||
|
<Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
|
||||||
|
<Text size="T200">Copy</Text>
|
||||||
|
</Chip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
325
src/app/features/settings/account/Profile.tsx
Normal file
325
src/app/features/settings/account/Profile.tsx
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import React, {
|
||||||
|
ChangeEventHandler,
|
||||||
|
FormEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
IconButton,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Modal,
|
||||||
|
Dialog,
|
||||||
|
Header,
|
||||||
|
config,
|
||||||
|
Spinner,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
|
import { UserAvatar } from '../../../components/user-avatar';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { nameInitials } from '../../../utils/common';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||||
|
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||||
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { ImageEditor } from '../../../components/image-editor';
|
||||||
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
|
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||||
|
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||||
|
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||||
|
|
||||||
|
type ProfileProps = {
|
||||||
|
profile: UserProfile;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
function ProfileAvatar({ profile, userId }: ProfileProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const capabilities = useCapabilities();
|
||||||
|
const [alertRemove, setAlertRemove] = useState(false);
|
||||||
|
const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
|
||||||
|
|
||||||
|
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
|
const avatarUrl = profile.avatarUrl
|
||||||
|
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const [imageFile, setImageFile] = useState<File>();
|
||||||
|
const imageFileURL = useObjectURL(imageFile);
|
||||||
|
const uploadAtom = useMemo(() => {
|
||||||
|
if (imageFile) return createUploadAtom(imageFile);
|
||||||
|
return undefined;
|
||||||
|
}, [imageFile]);
|
||||||
|
|
||||||
|
const pickFile = useFilePicker(setImageFile, false);
|
||||||
|
|
||||||
|
const handleRemoveUpload = useCallback(() => {
|
||||||
|
setImageFile(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUploaded = useCallback(
|
||||||
|
(upload: UploadSuccess) => {
|
||||||
|
const { mxc } = upload;
|
||||||
|
mx.setAvatarUrl(mxc);
|
||||||
|
handleRemoveUpload();
|
||||||
|
},
|
||||||
|
[mx, handleRemoveUpload]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveAvatar = () => {
|
||||||
|
mx.setAvatarUrl('');
|
||||||
|
setAlertRemove(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingTile
|
||||||
|
title={
|
||||||
|
<Text as="span" size="L400">
|
||||||
|
Avatar
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
after={
|
||||||
|
<Avatar size="500" radii="300">
|
||||||
|
<UserAvatar
|
||||||
|
userId={userId}
|
||||||
|
src={avatarUrl}
|
||||||
|
renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{uploadAtom ? (
|
||||||
|
<Box gap="200" direction="Column">
|
||||||
|
<CompactUploadCardRenderer
|
||||||
|
uploadAtom={uploadAtom}
|
||||||
|
onRemove={handleRemoveUpload}
|
||||||
|
onComplete={handleUploaded}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
onClick={() => pickFile('image/*')}
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
outlined
|
||||||
|
radii="300"
|
||||||
|
disabled={disableSetAvatar}
|
||||||
|
>
|
||||||
|
<Text size="B300">Upload</Text>
|
||||||
|
</Button>
|
||||||
|
{avatarUrl && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
disabled={disableSetAvatar}
|
||||||
|
onClick={() => setAlertRemove(true)}
|
||||||
|
>
|
||||||
|
<Text size="B300">Remove</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imageFileURL && (
|
||||||
|
<Overlay open={false} backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: handleRemoveUpload,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal className={ModalWide} variant="Surface" size="500">
|
||||||
|
<ImageEditor
|
||||||
|
name={imageFile?.name ?? 'Unnamed'}
|
||||||
|
url={imageFileURL}
|
||||||
|
requestClose={handleRemoveUpload}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setAlertRemove(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface">
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">Remove Avatar</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text priority="400">Are you sure you want to remove profile avatar?</Text>
|
||||||
|
</Box>
|
||||||
|
<Button variant="Critical" onClick={handleRemoveAvatar}>
|
||||||
|
<Text size="B400">Remove</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
</SettingTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const capabilities = useCapabilities();
|
||||||
|
const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
|
||||||
|
|
||||||
|
const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
|
const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
|
||||||
|
|
||||||
|
const [changeState, changeDisplayName] = useAsyncCallback(
|
||||||
|
useCallback((name: string) => mx.setDisplayName(name), [mx])
|
||||||
|
);
|
||||||
|
const changingDisplayName = changeState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDisplayName(defaultDisplayName);
|
||||||
|
}, [defaultDisplayName]);
|
||||||
|
|
||||||
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const name = evt.currentTarget.value;
|
||||||
|
setDisplayName(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setDisplayName(defaultDisplayName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (changingDisplayName) return;
|
||||||
|
|
||||||
|
const target = evt.target as HTMLFormElement | undefined;
|
||||||
|
const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
|
||||||
|
const name = displayNameInput?.value;
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
changeDisplayName(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges = displayName !== defaultDisplayName;
|
||||||
|
return (
|
||||||
|
<SettingTile
|
||||||
|
title={
|
||||||
|
<Text as="span" size="L400">
|
||||||
|
Display Name
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box direction="Column" grow="Yes" gap="100">
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
gap="200"
|
||||||
|
aria-disabled={changingDisplayName || disableSetDisplayname}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
name="displayNameInput"
|
||||||
|
value={displayName}
|
||||||
|
onChange={handleChange}
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
style={{ paddingRight: config.space.S200 }}
|
||||||
|
readOnly={changingDisplayName || disableSetDisplayname}
|
||||||
|
after={
|
||||||
|
hasChanges &&
|
||||||
|
!changingDisplayName && (
|
||||||
|
<IconButton
|
||||||
|
type="reset"
|
||||||
|
onClick={handleReset}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="400"
|
||||||
|
variant={hasChanges ? 'Success' : 'Secondary'}
|
||||||
|
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||||
|
outlined
|
||||||
|
radii="300"
|
||||||
|
disabled={!hasChanges || changingDisplayName}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
|
||||||
|
<Text size="B400">Save</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Profile() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const userId = mx.getUserId()!;
|
||||||
|
const profile = useUserProfile(userId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Profile</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<ProfileAvatar userId={userId} profile={profile} />
|
||||||
|
<ProfileDisplayName userId={userId} profile={profile} />
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ import { SequenceCard } from '../../../components/sequence-card';
|
|||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { LogoutDialog } from '../../../components/LogoutDialog';
|
import { LogoutDialog } from '../../../components/LogoutDialog';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
|
||||||
export function DeviceTilePlaceholder() {
|
export function DeviceTilePlaceholder() {
|
||||||
return (
|
return (
|
||||||
@@ -41,6 +43,9 @@ export function DeviceTilePlaceholder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DeviceActiveTime({ ts }: { ts: number }) {
|
function DeviceActiveTime({ ts }: { ts: number }) {
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text className={BreakWord} size="T200">
|
<Text className={BreakWord} size="T200">
|
||||||
<Text size="Inherit" as="span" priority="300">
|
<Text size="Inherit" as="span" priority="300">
|
||||||
@@ -49,7 +54,8 @@ function DeviceActiveTime({ ts }: { ts: number }) {
|
|||||||
<>
|
<>
|
||||||
{today(ts) && 'Today'}
|
{today(ts) && 'Today'}
|
||||||
{yesterday(ts) && 'Yesterday'}
|
{yesterday(ts) && 'Yesterday'}
|
||||||
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts)} {timeHourMinute(ts)}
|
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts, dateFormatString)}{' '}
|
||||||
|
{timeHourMinute(ts, hour24Clock)}
|
||||||
</>
|
</>
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { useUIAMatrixError } from '../../../hooks/useUIAFlows';
|
|||||||
import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus';
|
import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus';
|
||||||
import { VerifyOtherDeviceTile } from './Verification';
|
import { VerifyOtherDeviceTile } from './Verification';
|
||||||
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
|
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
|
||||||
|
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
||||||
|
import { withSearchParam } from '../../../pages/pathUtils';
|
||||||
|
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
|
||||||
type OtherDevicesProps = {
|
type OtherDevicesProps = {
|
||||||
devices: IMyDevice[];
|
devices: IMyDevice[];
|
||||||
@@ -20,8 +24,39 @@ type OtherDevicesProps = {
|
|||||||
export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) {
|
export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const crypto = mx.getCrypto();
|
const crypto = mx.getCrypto();
|
||||||
|
const authMetadata = useAuthMetadata();
|
||||||
|
const accountManagementActions = useAccountManagementActions();
|
||||||
|
|
||||||
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const handleDashboardOIDC = useCallback(() => {
|
||||||
|
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
|
||||||
|
if (!authUrl) return;
|
||||||
|
|
||||||
|
window.open(
|
||||||
|
withSearchParam(authUrl, {
|
||||||
|
action: accountManagementActions.sessionsList,
|
||||||
|
}),
|
||||||
|
'_blank'
|
||||||
|
);
|
||||||
|
}, [authMetadata, accountManagementActions]);
|
||||||
|
|
||||||
|
const handleDeleteOIDC = useCallback(
|
||||||
|
(deviceId: string) => {
|
||||||
|
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
|
||||||
|
if (!authUrl) return;
|
||||||
|
|
||||||
|
window.open(
|
||||||
|
withSearchParam(authUrl, {
|
||||||
|
action: accountManagementActions.sessionEnd,
|
||||||
|
device_id: deviceId,
|
||||||
|
}),
|
||||||
|
'_blank'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[authMetadata, accountManagementActions]
|
||||||
|
);
|
||||||
|
|
||||||
const handleToggleDelete = useCallback((deviceId: string) => {
|
const handleToggleDelete = useCallback((deviceId: string) => {
|
||||||
setDeleted((deviceIds) => {
|
setDeleted((deviceIds) => {
|
||||||
const newIds = new Set(deviceIds);
|
const newIds = new Set(deviceIds);
|
||||||
@@ -70,6 +105,31 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
|||||||
<>
|
<>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Others</Text>
|
<Text size="L400">Others</Text>
|
||||||
|
{authMetadata && (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Device Dashboard"
|
||||||
|
description="Manage your devices on OIDC dashboard."
|
||||||
|
after={
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
onClick={handleDashboardOIDC}
|
||||||
|
>
|
||||||
|
<Text size="B300">Open</Text>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
)}
|
||||||
{devices
|
{devices
|
||||||
.sort((d1, d2) => {
|
.sort((d1, d2) => {
|
||||||
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
|
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
|
||||||
@@ -89,12 +149,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
|||||||
refreshDeviceList={refreshDeviceList}
|
refreshDeviceList={refreshDeviceList}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
options={
|
options={
|
||||||
<DeviceDeleteBtn
|
authMetadata ? (
|
||||||
deviceId={device.device_id}
|
<DeviceDeleteBtn
|
||||||
deleted={deleted.has(device.device_id)}
|
deviceId={device.device_id}
|
||||||
onDeleteToggle={handleToggleDelete}
|
deleted={false}
|
||||||
disabled={deleting}
|
onDeleteToggle={handleDeleteOIDC}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<DeviceDeleteBtn
|
||||||
|
deviceId={device.device_id}
|
||||||
|
deleted={deleted.has(device.device_id)}
|
||||||
|
onDeleteToggle={handleToggleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{showVerification && crypto && (
|
{showVerification && crypto && (
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import {
|
|||||||
DeviceVerificationSetup,
|
DeviceVerificationSetup,
|
||||||
} from '../../../components/DeviceVerificationSetup';
|
} from '../../../components/DeviceVerificationSetup';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
||||||
|
import { withSearchParam } from '../../../pages/pathUtils';
|
||||||
|
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
||||||
|
|
||||||
type VerificationStatusBadgeProps = {
|
type VerificationStatusBadgeProps = {
|
||||||
verificationStatus: VerificationStatus;
|
verificationStatus: VerificationStatus;
|
||||||
@@ -252,6 +255,8 @@ export function EnableVerification({ visible }: EnableVerificationProps) {
|
|||||||
|
|
||||||
export function DeviceVerificationOptions() {
|
export function DeviceVerificationOptions() {
|
||||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
const authMetadata = useAuthMetadata();
|
||||||
|
const accountManagementActions = useAccountManagementActions();
|
||||||
|
|
||||||
const [reset, setReset] = useState(false);
|
const [reset, setReset] = useState(false);
|
||||||
|
|
||||||
@@ -265,6 +270,18 @@ export function DeviceVerificationOptions() {
|
|||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setMenuCords(undefined);
|
setMenuCords(undefined);
|
||||||
|
|
||||||
|
if (authMetadata) {
|
||||||
|
const authUrl = authMetadata.account_management_uri ?? authMetadata.issuer;
|
||||||
|
window.open(
|
||||||
|
withSearchParam(authUrl, {
|
||||||
|
action: accountManagementActions.crossSigningReset,
|
||||||
|
}),
|
||||||
|
'_blank'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setReset(true);
|
setReset(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import React, {
|
import React, {
|
||||||
ChangeEventHandler,
|
ChangeEventHandler,
|
||||||
|
FormEventHandler,
|
||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
as,
|
as,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
config,
|
config,
|
||||||
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
@@ -28,7 +32,7 @@ import FocusTrap from 'focus-trap-react';
|
|||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
import { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { KeySymbol } from '../../../utils/key-symbol';
|
import { KeySymbol } from '../../../utils/key-symbol';
|
||||||
import { isMacOS } from '../../../utils/user-agent';
|
import { isMacOS } from '../../../utils/user-agent';
|
||||||
@@ -44,6 +48,7 @@ import {
|
|||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||||
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
|
||||||
type ThemeSelectorProps = {
|
type ThemeSelectorProps = {
|
||||||
@@ -341,6 +346,359 @@ function Appearance() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DateHintProps = {
|
||||||
|
hasChanges: boolean;
|
||||||
|
handleReset: () => void;
|
||||||
|
};
|
||||||
|
function DateHint({ hasChanges, handleReset }: DateHintProps) {
|
||||||
|
const [anchor, setAnchor] = useState<RectCords>();
|
||||||
|
const categoryPadding = { padding: config.space.S200, paddingTop: 0 };
|
||||||
|
|
||||||
|
const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
|
setAnchor(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={anchor}
|
||||||
|
position="Top"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setAnchor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ maxHeight: '85vh', overflowY: 'auto' }}>
|
||||||
|
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
|
||||||
|
<Text size="L400">Formatting</Text>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Box direction="Column">
|
||||||
|
<Box style={categoryPadding} direction="Column">
|
||||||
|
<Header size="300">
|
||||||
|
<Text size="L400">Year</Text>
|
||||||
|
</Header>
|
||||||
|
<Box direction="Column" tabIndex={0} gap="100">
|
||||||
|
<Text size="T300">
|
||||||
|
YY
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}
|
||||||
|
Two-digit year
|
||||||
|
</Text>{' '}
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
YYYY
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Four-digit year
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={categoryPadding} direction="Column">
|
||||||
|
<Header size="300">
|
||||||
|
<Text size="L400">Month</Text>
|
||||||
|
</Header>
|
||||||
|
<Box direction="Column" tabIndex={0} gap="100">
|
||||||
|
<Text size="T300">
|
||||||
|
M
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}The month
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
MM
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Two-digit month
|
||||||
|
</Text>{' '}
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
MMM
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Short month name
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
MMMM
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Full month name
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={categoryPadding} direction="Column">
|
||||||
|
<Header size="300">
|
||||||
|
<Text size="L400">Day of the Month</Text>
|
||||||
|
</Header>
|
||||||
|
<Box direction="Column" tabIndex={0} gap="100">
|
||||||
|
<Text size="T300">
|
||||||
|
D
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Day of the month
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
DD
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Two-digit day of the month
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box style={categoryPadding} direction="Column">
|
||||||
|
<Header size="300">
|
||||||
|
<Text size="L400">Day of the Week</Text>
|
||||||
|
</Header>
|
||||||
|
<Box direction="Column" tabIndex={0} gap="100">
|
||||||
|
<Text size="T300">
|
||||||
|
d
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Day of the week (Sunday = 0)
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
dd
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Two-letter day name
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
ddd
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Short day name
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
dddd
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
{': '}Full day name
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasChanges ? (
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleReset}
|
||||||
|
type="reset"
|
||||||
|
variant="Secondary"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
type="button"
|
||||||
|
variant="Secondary"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={!!anchor}
|
||||||
|
>
|
||||||
|
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomDateFormatProps = {
|
||||||
|
value: string;
|
||||||
|
onChange: (format: string) => void;
|
||||||
|
};
|
||||||
|
function CustomDateFormat({ value, onChange }: CustomDateFormatProps) {
|
||||||
|
const [dateFormatCustom, setDateFormatCustom] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDateFormatCustom(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const format = evt.currentTarget.value;
|
||||||
|
setDateFormatCustom(format);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setDateFormatCustom(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
|
||||||
|
const target = evt.target as HTMLFormElement | undefined;
|
||||||
|
const customDateFormatInput = target?.customDateFormatInput as HTMLInputElement | undefined;
|
||||||
|
const format = customDateFormatInput?.value;
|
||||||
|
if (!format) return;
|
||||||
|
|
||||||
|
onChange(format);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges = dateFormatCustom !== value;
|
||||||
|
return (
|
||||||
|
<SettingTile>
|
||||||
|
<Box as="form" onSubmit={handleSubmit} gap="200">
|
||||||
|
<Box grow="Yes" direction="Column">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
name="customDateFormatInput"
|
||||||
|
value={dateFormatCustom}
|
||||||
|
onChange={handleChange}
|
||||||
|
maxLength={16}
|
||||||
|
autoComplete="off"
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
style={{ paddingRight: config.space.S200 }}
|
||||||
|
after={<DateHint hasChanges={hasChanges} handleReset={handleReset} />}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
size="400"
|
||||||
|
variant={hasChanges ? 'Success' : 'Secondary'}
|
||||||
|
fill={hasChanges ? 'Solid' : 'Soft'}
|
||||||
|
outlined
|
||||||
|
radii="300"
|
||||||
|
disabled={!hasChanges}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<Text size="B400">Save</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PresetDateFormatProps = {
|
||||||
|
value: string;
|
||||||
|
onChange: (format: string) => void;
|
||||||
|
};
|
||||||
|
function PresetDateFormat({ value, onChange }: PresetDateFormatProps) {
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
const dateFormatItems = useDateFormatItems();
|
||||||
|
|
||||||
|
const getDisplayDate = (format: string): string =>
|
||||||
|
format !== '' ? dayjs().format(format) : 'Custom';
|
||||||
|
|
||||||
|
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (format: DateFormat) => {
|
||||||
|
onChange(format);
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
outlined
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||||
|
onClick={handleMenu}
|
||||||
|
>
|
||||||
|
<Text size="T300">
|
||||||
|
{getDisplayDate(dateFormatItems.find((i) => i.format === value)?.format ?? value)}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setMenuCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{dateFormatItems.map((item) => (
|
||||||
|
<MenuItem
|
||||||
|
key={item.format}
|
||||||
|
size="300"
|
||||||
|
variant={value === item.format ? 'Primary' : 'Surface'}
|
||||||
|
radii="300"
|
||||||
|
onClick={() => handleSelect(item.format)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{getDisplayDate(item.format)}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectDateFormat() {
|
||||||
|
const [dateFormatString, setDateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
const [selectedDateFormat, setSelectedDateFormat] = useState(dateFormatString);
|
||||||
|
const customDateFormat = selectedDateFormat === '';
|
||||||
|
|
||||||
|
const handlePresetChange = (format: string) => {
|
||||||
|
setSelectedDateFormat(format);
|
||||||
|
if (format !== '') {
|
||||||
|
setDateFormatString(format);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingTile
|
||||||
|
title="Date Format"
|
||||||
|
description={customDateFormat ? dayjs().format(dateFormatString) : ''}
|
||||||
|
after={<PresetDateFormat value={selectedDateFormat} onChange={handlePresetChange} />}
|
||||||
|
/>
|
||||||
|
{customDateFormat && (
|
||||||
|
<CustomDateFormat value={dateFormatString} onChange={setDateFormatString} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateAndTime() {
|
||||||
|
const [hour24Clock, setHour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Date & Time</Text>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="24-Hour Time Format"
|
||||||
|
after={<Switch variant="Primary" value={hour24Clock} onChange={setHour24Clock} />}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SelectDateFormat />
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Editor() {
|
function Editor() {
|
||||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
@@ -637,6 +995,7 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<PageContent>
|
<PageContent>
|
||||||
<Box direction="Column" gap="700">
|
<Box direction="Column" gap="700">
|
||||||
<Appearance />
|
<Appearance />
|
||||||
|
<DateAndTime />
|
||||||
<Editor />
|
<Editor />
|
||||||
<Messages />
|
<Messages />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { SystemNotification } from './SystemNotification';
|
|||||||
import { AllMessagesNotifications } from './AllMessages';
|
import { AllMessagesNotifications } from './AllMessages';
|
||||||
import { SpecialMessagesNotifications } from './SpecialMessages';
|
import { SpecialMessagesNotifications } from './SpecialMessages';
|
||||||
import { KeywordMessagesNotifications } from './KeywordMessages';
|
import { KeywordMessagesNotifications } from './KeywordMessages';
|
||||||
import { IgnoredUserList } from './IgnoredUserList';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
|
||||||
type NotificationsProps = {
|
type NotificationsProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -35,7 +37,19 @@ export function Notifications({ requestClose }: NotificationsProps) {
|
|||||||
<AllMessagesNotifications />
|
<AllMessagesNotifications />
|
||||||
<SpecialMessagesNotifications />
|
<SpecialMessagesNotifications />
|
||||||
<KeywordMessagesNotifications />
|
<KeywordMessagesNotifications />
|
||||||
<IgnoredUserList />
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Block Messages</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
description={'This option has been moved to "Account > Block Users" section.'}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
17
src/app/hooks/useAccountManagement.ts
Normal file
17
src/app/hooks/useAccountManagement.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export const useAccountManagementActions = () => {
|
||||||
|
const actions = useMemo(
|
||||||
|
() => ({
|
||||||
|
profile: 'org.matrix.profile',
|
||||||
|
sessionsList: 'org.matrix.sessions_list',
|
||||||
|
sessionView: 'org.matrix.session_view',
|
||||||
|
sessionEnd: 'org.matrix.session_end',
|
||||||
|
accountDeactivate: 'org.matrix.account_deactivate',
|
||||||
|
crossSigningReset: 'org.matrix.cross_signing_reset',
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
};
|
||||||
12
src/app/hooks/useAuthMetadata.ts
Normal file
12
src/app/hooks/useAuthMetadata.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
const AuthMetadataContext = createContext<ValidatedAuthMetadata | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthMetadataProvider = AuthMetadataContext.Provider;
|
||||||
|
|
||||||
|
export const useAuthMetadata = (): ValidatedAuthMetadata | undefined => {
|
||||||
|
const metadata = useContext(AuthMetadataContext);
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
};
|
||||||
34
src/app/hooks/useDateFormat.ts
Normal file
34
src/app/hooks/useDateFormat.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { DateFormat } from '../state/settings';
|
||||||
|
|
||||||
|
export type DateFormatItem = {
|
||||||
|
name: string;
|
||||||
|
format: DateFormat;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDateFormatItems = (): DateFormatItem[] =>
|
||||||
|
useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
format: 'D MMM YYYY',
|
||||||
|
name: 'D MMM YYYY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: 'DD/MM/YYYY',
|
||||||
|
name: 'DD/MM/YYYY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: 'MM/DD/YYYY',
|
||||||
|
name: 'MM/DD/YYYY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: 'YYYY/MM/DD',
|
||||||
|
name: 'YYYY/MM/DD',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: '',
|
||||||
|
name: 'Custom',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
10
src/app/hooks/useReportRoomSupported.ts
Normal file
10
src/app/hooks/useReportRoomSupported.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useSpecVersions } from './useSpecVersions';
|
||||||
|
|
||||||
|
export const useReportRoomSupported = (): boolean => {
|
||||||
|
const { versions, unstable_features: unstableFeatures } = useSpecVersions();
|
||||||
|
|
||||||
|
// report room is introduced in spec version 1.13
|
||||||
|
const supported = unstableFeatures?.['org.matrix.msc4151'] || versions.includes('v1.13');
|
||||||
|
|
||||||
|
return supported;
|
||||||
|
};
|
||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
getSpaceRoomPath,
|
getSpaceRoomPath,
|
||||||
} from '../pages/pathUtils';
|
} from '../pages/pathUtils';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { getOrphanParents } from '../utils/room';
|
import { getOrphanParents, guessPerfectParent } from '../utils/room';
|
||||||
import { roomToParentsAtom } from '../state/room/roomToParents';
|
import { roomToParentsAtom } from '../state/room/roomToParents';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
import { useSelectedSpace } from './router/useSelectedSpace';
|
import { useSelectedSpace } from './router/useSelectedSpace';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
|
||||||
export const useRoomNavigate = () => {
|
export const useRoomNavigate = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -20,6 +22,7 @@ export const useRoomNavigate = () => {
|
|||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const spaceSelectedId = useSelectedSpace();
|
const spaceSelectedId = useSelectedSpace();
|
||||||
|
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
|
||||||
const navigateSpace = useCallback(
|
const navigateSpace = useCallback(
|
||||||
(roomId: string) => {
|
(roomId: string) => {
|
||||||
@@ -32,16 +35,23 @@ export const useRoomNavigate = () => {
|
|||||||
const navigateRoom = useCallback(
|
const navigateRoom = useCallback(
|
||||||
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
||||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
||||||
|
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
|
||||||
|
|
||||||
const orphanParents = getOrphanParents(roomToParents, roomId);
|
const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
|
||||||
if (orphanParents.length > 0) {
|
if (orphanParents.length > 0) {
|
||||||
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(
|
let parentSpace: string;
|
||||||
mx,
|
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
|
||||||
spaceSelectedId && orphanParents.includes(spaceSelectedId)
|
parentSpace = spaceSelectedId;
|
||||||
? spaceSelectedId
|
} else {
|
||||||
: orphanParents[0]
|
parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
|
||||||
|
|
||||||
|
navigate(
|
||||||
|
getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId),
|
||||||
|
opts
|
||||||
);
|
);
|
||||||
navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +62,7 @@ export const useRoomNavigate = () => {
|
|||||||
|
|
||||||
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
||||||
},
|
},
|
||||||
[mx, navigate, spaceSelectedId, roomToParents, mDirects]
|
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
37
src/app/hooks/useTimeoutToggle.ts
Normal file
37
src/app/hooks/useTimeoutToggle.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporarily sets a boolean state.
|
||||||
|
*
|
||||||
|
* @param duration - Duration in milliseconds before resetting (default: 1500)
|
||||||
|
* @param initial - Initial value (default: false)
|
||||||
|
*/
|
||||||
|
export function useTimeoutToggle(duration = 1500, initial = false): [boolean, () => void] {
|
||||||
|
const [active, setActive] = useState(initial);
|
||||||
|
const timeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
if (timeoutRef.current !== null) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = useCallback(() => {
|
||||||
|
setActive(!initial);
|
||||||
|
clear();
|
||||||
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
|
setActive(initial);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}, duration);
|
||||||
|
}, [duration, initial]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
clear();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [active, trigger];
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import './SpaceAddExisting.scss';
|
|||||||
|
|
||||||
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 { joinRuleToIconSrc, getIdServer } from '../../../util/matrixUtil';
|
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
||||||
import { Debounce } from '../../../util/common';
|
import { Debounce } from '../../../util/common';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
@@ -21,16 +21,17 @@ import Dialog from '../dialog/Dialog';
|
|||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||||
|
|
||||||
import { useStore } from '../../hooks/useStore';
|
|
||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
|
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
|
||||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
|
import { rateLimitedActions } from '../../utils/matrix';
|
||||||
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
|
|
||||||
function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
|
function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
|
||||||
const mountStore = useStore(roomId);
|
const alive = useAlive();
|
||||||
const [debounce] = useState(new Debounce());
|
const [debounce] = useState(new Debounce());
|
||||||
const [process, setProcess] = useState(null);
|
const [process, setProcess] = useState(null);
|
||||||
const [allRoomIds, setAllRoomIds] = useState([]);
|
const [allRoomIds, setAllRoomIds] = useState([]);
|
||||||
@@ -68,14 +69,11 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
|
|||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
setProcess(`Adding ${selected.length} items...`);
|
setProcess(`Adding ${selected.length} items...`);
|
||||||
|
|
||||||
const promises = selected.map((rId) => {
|
await rateLimitedActions(selected, async (rId) => {
|
||||||
const room = mx.getRoom(rId);
|
const room = mx.getRoom(rId);
|
||||||
const via = getViaServers(room);
|
const via = getViaServers(room);
|
||||||
if (via.length === 0) {
|
|
||||||
via.push(getIdServer(rId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return mx.sendStateEvent(
|
await mx.sendStateEvent(
|
||||||
roomId,
|
roomId,
|
||||||
'm.space.child',
|
'm.space.child',
|
||||||
{
|
{
|
||||||
@@ -87,9 +85,7 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
mountStore.setItem(true);
|
if (!alive()) return;
|
||||||
await Promise.allSettled(promises);
|
|
||||||
if (mountStore.getItem() !== true) return;
|
|
||||||
|
|
||||||
const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
|
const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
|
||||||
const allIds = roomIds.filter(
|
const allIds = roomIds.filter(
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ function InviteUser({ isOpen, roomId, searchTerm, onRequestClose }) {
|
|||||||
searchUser(usernameRef.current.value);
|
searchUser(usernameRef.current.value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input value={searchTerm} forwardRef={usernameRef} label="Name or userId" />
|
<Input value={searchTerm} forwardRef={usernameRef} label="Name or userId" autoFocus />
|
||||||
<Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit">
|
<Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit">
|
||||||
Search
|
Search
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function JoinAliasContent({ term, requestClose }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="join-alias" onSubmit={handleSubmit}>
|
<form className="join-alias" onSubmit={handleSubmit}>
|
||||||
<Input label="Address" value={term} name="alias" required />
|
<Input label="Address" value={term} name="alias" required autoFocus />
|
||||||
{error && (
|
{error && (
|
||||||
<Text className="join-alias__error" variant="b3">
|
<Text className="join-alias__error" variant="b3">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function AuthFooter() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
v4.7.1
|
v4.8.1
|
||||||
</Text>
|
</Text>
|
||||||
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
|
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
|
||||||
Twitter
|
Twitter
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { Avatar, AvatarImage, Box, Button, Text } from 'folds';
|
import { Avatar, AvatarImage, Box, Button, Text } from 'folds';
|
||||||
import { IIdentityProvider, createClient } from 'matrix-js-sdk';
|
import { IIdentityProvider, SSOAction, createClient } from 'matrix-js-sdk';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
||||||
|
|
||||||
type SSOLoginProps = {
|
type SSOLoginProps = {
|
||||||
providers?: IIdentityProvider[];
|
providers?: IIdentityProvider[];
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
|
action?: SSOAction;
|
||||||
saveScreenSpace?: boolean;
|
saveScreenSpace?: boolean;
|
||||||
};
|
};
|
||||||
export function SSOLogin({ providers, redirectUrl, saveScreenSpace }: SSOLoginProps) {
|
export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SSOLoginProps) {
|
||||||
const discovery = useAutoDiscoveryInfo();
|
const discovery = useAutoDiscoveryInfo();
|
||||||
const baseUrl = discovery['m.homeserver'].base_url;
|
const baseUrl = discovery['m.homeserver'].base_url;
|
||||||
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
|
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
|
||||||
|
|
||||||
const getSSOIdUrl = (ssoId?: string): string => mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId);
|
const getSSOIdUrl = (ssoId?: string): string =>
|
||||||
|
mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId, action);
|
||||||
|
|
||||||
const withoutIcon = providers
|
const withoutIcon = providers
|
||||||
? providers.find(
|
? providers.find(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Box, Text, color } from 'folds';
|
import { Box, Text, color } from 'folds';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { SSOAction } from 'matrix-js-sdk';
|
||||||
import { useAuthFlows } from '../../../hooks/useAuthFlows';
|
import { useAuthFlows } from '../../../hooks/useAuthFlows';
|
||||||
import { useAuthServer } from '../../../hooks/useAuthServer';
|
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||||
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
||||||
@@ -76,6 +77,7 @@ export function Login() {
|
|||||||
<SSOLogin
|
<SSOLogin
|
||||||
providers={parsedFlows.sso.identity_providers}
|
providers={parsedFlows.sso.identity_providers}
|
||||||
redirectUrl={ssoRedirectUrl}
|
redirectUrl={ssoRedirectUrl}
|
||||||
|
action={SSOAction.LOGIN}
|
||||||
saveScreenSpace={parsedFlows.password !== undefined}
|
saveScreenSpace={parsedFlows.password !== undefined}
|
||||||
/>
|
/>
|
||||||
<span data-spacing-node />
|
<span data-spacing-node />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Box, Text, color } from 'folds';
|
import { Box, Text, color } from 'folds';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { SSOAction } from 'matrix-js-sdk';
|
||||||
import { useAuthServer } from '../../../hooks/useAuthServer';
|
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||||
import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows';
|
import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows';
|
||||||
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
||||||
@@ -83,6 +84,7 @@ export function Register() {
|
|||||||
<SSOLogin
|
<SSOLogin
|
||||||
providers={sso.identity_providers}
|
providers={sso.identity_providers}
|
||||||
redirectUrl={ssoRedirectUrl}
|
redirectUrl={ssoRedirectUrl}
|
||||||
|
action={SSOAction.REGISTER}
|
||||||
saveScreenSpace={registerFlows.status === RegisterFlowStatus.FlowRequired}
|
saveScreenSpace={registerFlows.status === RegisterFlowStatus.FlowRequired}
|
||||||
/>
|
/>
|
||||||
<span data-spacing-node />
|
<span data-spacing-node />
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from '../../../client/initMatrix';
|
} from '../../../client/initMatrix';
|
||||||
import { getSecret } from '../../../client/state/auth';
|
import { getSecret } from '../../../client/state/auth';
|
||||||
import { SplashScreen } from '../../components/splash-screen';
|
import { SplashScreen } from '../../components/splash-screen';
|
||||||
import { CapabilitiesAndMediaConfigLoader } from '../../components/CapabilitiesAndMediaConfigLoader';
|
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
||||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
||||||
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
|
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
|
||||||
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
|
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
|
||||||
@@ -37,6 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|||||||
import { useSyncState } from '../../hooks/useSyncState';
|
import { useSyncState } from '../../hooks/useSyncState';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { SyncStatus } from './SyncStatus';
|
import { SyncStatus } from './SyncStatus';
|
||||||
|
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||||
|
|
||||||
function ClientRootLoading() {
|
function ClientRootLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -207,18 +208,20 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||||||
<ClientRootLoading />
|
<ClientRootLoading />
|
||||||
) : (
|
) : (
|
||||||
<MatrixClientProvider value={mx}>
|
<MatrixClientProvider value={mx}>
|
||||||
<CapabilitiesAndMediaConfigLoader>
|
<ServerConfigsLoader>
|
||||||
{(capabilities, mediaConfig) => (
|
{(serverConfigs) => (
|
||||||
<CapabilitiesProvider value={capabilities ?? {}}>
|
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
|
||||||
<MediaConfigProvider value={mediaConfig ?? {}}>
|
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
|
||||||
{children}
|
<AuthMetadataProvider value={serverConfigs.authMetadata}>
|
||||||
<Windows />
|
{children}
|
||||||
<Dialogs />
|
<Windows />
|
||||||
<ReusableContextMenu />
|
<Dialogs />
|
||||||
|
<ReusableContextMenu />
|
||||||
|
</AuthMetadataProvider>
|
||||||
</MediaConfigProvider>
|
</MediaConfigProvider>
|
||||||
</CapabilitiesProvider>
|
</CapabilitiesProvider>
|
||||||
)}
|
)}
|
||||||
</CapabilitiesAndMediaConfigLoader>
|
</ServerConfigsLoader>
|
||||||
</MatrixClientProvider>
|
</MatrixClientProvider>
|
||||||
)}
|
)}
|
||||||
</SpecVersions>
|
</SpecVersions>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function WelcomePage() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
v4.7.1
|
v4.8.1
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ export function Explore() {
|
|||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
<Avatar size="200" radii="400">
|
<Avatar size="200" radii="400">
|
||||||
<Icon
|
<Icon
|
||||||
src={Icons.Category}
|
src={Icons.Server}
|
||||||
size="100"
|
size="100"
|
||||||
filled={selectedServer === userServer}
|
filled={selectedServer === userServer}
|
||||||
/>
|
/>
|
||||||
@@ -243,11 +243,7 @@ export function Explore() {
|
|||||||
<NavItemContent>
|
<NavItemContent>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
<Avatar size="200" radii="400">
|
<Avatar size="200" radii="400">
|
||||||
<Icon
|
<Icon src={Icons.Server} size="100" filled={server === selectedServer} />
|
||||||
src={Icons.Category}
|
|
||||||
size="100"
|
|
||||||
filled={server === selectedServer}
|
|
||||||
/>
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box as="span" grow="Yes">
|
<Box as="span" grow="Yes">
|
||||||
<Text as="span" size="Inherit" truncate>
|
<Text as="span" size="Inherit" truncate>
|
||||||
|
|||||||
@@ -507,7 +507,7 @@ export function PublicRooms() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
|
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
|
||||||
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Category} />}
|
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Server} />}
|
||||||
<Text size="H3" truncate>
|
<Text size="H3" truncate>
|
||||||
{server}
|
{server}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function InvitesNavItem() {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<Box as="span" grow="Yes">
|
<Box as="span" grow="Yes">
|
||||||
<Text as="span" size="Inherit" truncate>
|
<Text as="span" size="Inherit" truncate>
|
||||||
Invitations
|
Invites
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}
|
{inviteCount > 0 && <UnreadBadge highlight count={inviteCount} />}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React, { useCallback, useRef, useState } from 'react';
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Chip,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
@@ -16,56 +18,140 @@ import {
|
|||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { RoomTopicEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
import { MatrixClient, MatrixError, Room } from 'matrix-js-sdk';
|
||||||
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
|
import {
|
||||||
import { useDirectInvites, useRoomInvites, useSpaceInvites } from '../../../state/hooks/inviteList';
|
Page,
|
||||||
|
PageContent,
|
||||||
|
PageContentCenter,
|
||||||
|
PageHeader,
|
||||||
|
PageHero,
|
||||||
|
PageHeroEmpty,
|
||||||
|
PageHeroSection,
|
||||||
|
} from '../../../components/page';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { allInvitesAtom } from '../../../state/room-list/inviteList';
|
import { allInvitesAtom } from '../../../state/room-list/inviteList';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import {
|
import {
|
||||||
|
bannedInRooms,
|
||||||
|
getCommonRooms,
|
||||||
getDirectRoomAvatarUrl,
|
getDirectRoomAvatarUrl,
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
getRoomAvatarUrl,
|
getRoomAvatarUrl,
|
||||||
|
getStateEvent,
|
||||||
isDirectInvite,
|
isDirectInvite,
|
||||||
|
isSpace,
|
||||||
} from '../../../utils/room';
|
} from '../../../utils/room';
|
||||||
import { nameInitials } from '../../../utils/common';
|
import { nameInitials } from '../../../utils/common';
|
||||||
import { RoomAvatar } from '../../../components/room-avatar';
|
import { RoomAvatar } from '../../../components/room-avatar';
|
||||||
import { addRoomIdToMDirect, getMxIdLocalPart, guessDmRoomUserId } from '../../../utils/matrix';
|
import {
|
||||||
|
addRoomIdToMDirect,
|
||||||
|
getMxIdLocalPart,
|
||||||
|
guessDmRoomUserId,
|
||||||
|
rateLimitedActions,
|
||||||
|
} from '../../../utils/matrix';
|
||||||
import { Time } from '../../../components/message';
|
import { Time } from '../../../components/message';
|
||||||
import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
|
import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver';
|
||||||
import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard';
|
import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard';
|
||||||
import { RoomTopicViewer } from '../../../components/room-topic-viewer';
|
import { RoomTopicViewer } from '../../../components/room-topic-viewer';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
import { useRoomTopic } from '../../../hooks/useRoomMeta';
|
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||||
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { testBadWords } from '../../../plugins/bad-words';
|
||||||
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
|
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
||||||
|
import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
|
||||||
const COMPACT_CARD_WIDTH = 548;
|
const COMPACT_CARD_WIDTH = 548;
|
||||||
|
|
||||||
type InviteCardProps = {
|
type InviteData = {
|
||||||
room: Room;
|
room: Room;
|
||||||
userId: string;
|
roomId: string;
|
||||||
direct?: boolean;
|
roomName: string;
|
||||||
compact?: boolean;
|
roomAvatar?: string;
|
||||||
onNavigate: (roomId: string) => void;
|
roomTopic?: string;
|
||||||
|
roomAlias?: string;
|
||||||
|
|
||||||
|
senderId: string;
|
||||||
|
senderName: string;
|
||||||
|
inviteTs?: number;
|
||||||
|
|
||||||
|
isSpace: boolean;
|
||||||
|
isDirect: boolean;
|
||||||
|
isEncrypted: boolean;
|
||||||
};
|
};
|
||||||
function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardProps) {
|
|
||||||
const mx = useMatrixClient();
|
const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean): InviteData => {
|
||||||
const useAuthentication = useMediaAuthentication();
|
const userId = mx.getSafeUserId();
|
||||||
|
const direct = isDirectInvite(room, userId);
|
||||||
|
|
||||||
|
const roomAvatar = direct
|
||||||
|
? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||||
|
: getRoomAvatarUrl(mx, room, 96, useAuthentication);
|
||||||
const roomName = room.name || room.getCanonicalAlias() || room.roomId;
|
const roomName = room.name || room.getCanonicalAlias() || room.roomId;
|
||||||
|
const roomTopic =
|
||||||
|
getStateEvent(room, StateEvent.RoomTopic)?.getContent<RoomTopicEventContent>()?.topic ??
|
||||||
|
undefined;
|
||||||
|
|
||||||
const member = room.getMember(userId);
|
const member = room.getMember(userId);
|
||||||
const memberEvent = member?.events.member;
|
const memberEvent = member?.events.member;
|
||||||
const memberTs = memberEvent?.getTs() ?? 0;
|
|
||||||
const senderId = memberEvent?.getSender();
|
const senderId = memberEvent?.getSender();
|
||||||
const senderName = senderId
|
const senderName = senderId
|
||||||
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
|
? getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const inviteTs = memberEvent?.getTs() ?? 0;
|
||||||
|
|
||||||
const topic = useRoomTopic(room);
|
return {
|
||||||
|
room,
|
||||||
|
roomId: room.roomId,
|
||||||
|
roomAvatar,
|
||||||
|
roomName,
|
||||||
|
roomTopic,
|
||||||
|
roomAlias: room.getCanonicalAlias() ?? undefined,
|
||||||
|
|
||||||
|
senderId: senderId ?? 'Unknown',
|
||||||
|
senderName: senderName ?? 'Unknown',
|
||||||
|
inviteTs,
|
||||||
|
|
||||||
|
isSpace: isSpace(room),
|
||||||
|
isDirect: direct,
|
||||||
|
isEncrypted: !!getStateEvent(room, StateEvent.RoomEncryption),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasBadWords = (invite: InviteData): boolean =>
|
||||||
|
testBadWords(invite.roomName) ||
|
||||||
|
testBadWords(invite.roomTopic ?? '') ||
|
||||||
|
testBadWords(invite.senderName) ||
|
||||||
|
testBadWords(invite.senderId);
|
||||||
|
|
||||||
|
type NavigateHandler = (roomId: string, space: boolean) => void;
|
||||||
|
|
||||||
|
type InviteCardProps = {
|
||||||
|
invite: InviteData;
|
||||||
|
compact?: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
|
onNavigate: NavigateHandler;
|
||||||
|
hideAvatar: boolean;
|
||||||
|
};
|
||||||
|
function InviteCard({
|
||||||
|
invite,
|
||||||
|
compact,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
|
onNavigate,
|
||||||
|
hideAvatar,
|
||||||
|
}: InviteCardProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const userId = mx.getSafeUserId();
|
||||||
|
|
||||||
const [viewTopic, setViewTopic] = useState(false);
|
const [viewTopic, setViewTopic] = useState(false);
|
||||||
const closeTopic = () => setViewTopic(false);
|
const closeTopic = () => setViewTopic(false);
|
||||||
@@ -73,17 +159,19 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
|
|||||||
|
|
||||||
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
|
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const dmUserId = isDirectInvite(room, userId) ? guessDmRoomUserId(room, userId) : undefined;
|
const dmUserId = isDirectInvite(invite.room, userId)
|
||||||
|
? guessDmRoomUserId(invite.room, userId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
await mx.joinRoom(room.roomId);
|
await mx.joinRoom(invite.roomId);
|
||||||
if (dmUserId) {
|
if (dmUserId) {
|
||||||
await addRoomIdToMDirect(mx, room.roomId, dmUserId);
|
await addRoomIdToMDirect(mx, invite.roomId, dmUserId);
|
||||||
}
|
}
|
||||||
onNavigate(room.roomId);
|
onNavigate(invite.roomId, invite.isSpace);
|
||||||
}, [mx, room, userId, onNavigate])
|
}, [mx, invite, userId, onNavigate])
|
||||||
);
|
);
|
||||||
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
|
const [leaveState, leave] = useAsyncCallback<Record<string, never>, MatrixError, []>(
|
||||||
useCallback(() => mx.leave(room.roomId), [mx, room])
|
useCallback(() => mx.leave(invite.roomId), [mx, invite])
|
||||||
);
|
);
|
||||||
|
|
||||||
const joining =
|
const joining =
|
||||||
@@ -95,28 +183,43 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
|
|||||||
<SequenceCard
|
<SequenceCard
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="200"
|
gap="300"
|
||||||
style={{ padding: config.space.S400, paddingTop: config.space.S200 }}
|
style={{ padding: `${config.space.S400} ${config.space.S400} ${config.space.S200}` }}
|
||||||
>
|
>
|
||||||
<Box gap="200" alignItems="Baseline">
|
{(invite.isEncrypted || invite.isDirect || invite.isSpace) && (
|
||||||
<Box grow="Yes">
|
<Box gap="200" alignItems="Center">
|
||||||
<Text size="T200" priority="300" truncate>
|
{invite.isEncrypted && (
|
||||||
Invited by <b>{senderName}</b>
|
<Box shrink="No" alignItems="Center" justifyContent="Center">
|
||||||
</Text>
|
<Badge variant="Success" fill="Solid" size="400" radii="300">
|
||||||
|
<Text size="L400">Encrypted</Text>
|
||||||
|
</Badge>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{invite.isDirect && (
|
||||||
|
<Box shrink="No" alignItems="Center" justifyContent="Center">
|
||||||
|
<Badge variant="Primary" fill="Solid" size="400" radii="300">
|
||||||
|
<Text size="L400">Direct Message</Text>
|
||||||
|
</Badge>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{invite.isSpace && (
|
||||||
|
<Box shrink="No" alignItems="Center" justifyContent="Center">
|
||||||
|
<Badge variant="Secondary" fill="Soft" size="400" radii="300">
|
||||||
|
<Text size="L400">Space</Text>
|
||||||
|
</Badge>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
)}
|
||||||
<Time size="T200" ts={memberTs} priority="300" />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box gap="300">
|
<Box gap="300">
|
||||||
<Avatar size="300">
|
<Avatar size="300">
|
||||||
<RoomAvatar
|
<RoomAvatar
|
||||||
roomId={room.roomId}
|
roomId={invite.roomId}
|
||||||
src={direct ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication)}
|
src={hideAvatar ? undefined : invite.roomAvatar}
|
||||||
alt={roomName}
|
alt={invite.roomName}
|
||||||
renderFallback={() => (
|
renderFallback={() => (
|
||||||
<Text as="span" size="H6">
|
<Text as="span" size="H6">
|
||||||
{nameInitials(roomName)}
|
{nameInitials(hideAvatar && invite.roomAvatar ? undefined : invite.roomName)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -125,9 +228,9 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
|
|||||||
<Box grow="Yes" direction="Column" gap="200">
|
<Box grow="Yes" direction="Column" gap="200">
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
<b>{roomName}</b>
|
<b>{invite.roomName}</b>
|
||||||
</Text>
|
</Text>
|
||||||
{topic && (
|
{invite.roomTopic && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
onClick={openTopic}
|
onClick={openTopic}
|
||||||
@@ -135,7 +238,7 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
truncate
|
truncate
|
||||||
>
|
>
|
||||||
{topic}
|
{invite.roomTopic}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
|
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
|
||||||
@@ -149,8 +252,8 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RoomTopicViewer
|
<RoomTopicViewer
|
||||||
name={roomName}
|
name={invite.roomName}
|
||||||
topic={topic ?? ''}
|
topic={invite.roomTopic ?? ''}
|
||||||
requestClose={closeTopic}
|
requestClose={closeTopic}
|
||||||
/>
|
/>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
@@ -173,6 +276,7 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
|
|||||||
onClick={leave}
|
onClick={leave}
|
||||||
size="300"
|
size="300"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
disabled={joining || leaving}
|
disabled={joining || leaving}
|
||||||
before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
|
before={leaving ? <Spinner variant="Secondary" size="100" /> : undefined}
|
||||||
@@ -182,28 +286,430 @@ function InviteCard({ room, userId, direct, compact, onNavigate }: InviteCardPro
|
|||||||
<Button
|
<Button
|
||||||
onClick={join}
|
onClick={join}
|
||||||
size="300"
|
size="300"
|
||||||
variant="Primary"
|
variant="Success"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
outlined
|
outlined
|
||||||
disabled={joining || leaving}
|
disabled={joining || leaving}
|
||||||
before={joining ? <Spinner variant="Primary" fill="Soft" size="100" /> : undefined}
|
before={joining ? <Spinner variant="Success" fill="Soft" size="100" /> : undefined}
|
||||||
>
|
>
|
||||||
<Text size="B300">Accept</Text>
|
<Text size="B300">Accept</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box gap="200" alignItems="Baseline">
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
From: <b>{invite.senderId}</b>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{invite.inviteTs && (
|
||||||
|
<Box shrink="No">
|
||||||
|
<Time
|
||||||
|
size="T200"
|
||||||
|
ts={invite.inviteTs}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
priority="300"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum InviteFilter {
|
||||||
|
Known,
|
||||||
|
Unknown,
|
||||||
|
Spam,
|
||||||
|
}
|
||||||
|
type InviteFiltersProps = {
|
||||||
|
filter: InviteFilter;
|
||||||
|
onFilter: (filter: InviteFilter) => void;
|
||||||
|
knownInvites: InviteData[];
|
||||||
|
unknownInvites: InviteData[];
|
||||||
|
spamInvites: InviteData[];
|
||||||
|
};
|
||||||
|
function InviteFilters({
|
||||||
|
filter,
|
||||||
|
onFilter,
|
||||||
|
knownInvites,
|
||||||
|
unknownInvites,
|
||||||
|
spamInvites,
|
||||||
|
}: InviteFiltersProps) {
|
||||||
|
const isKnown = filter === InviteFilter.Known;
|
||||||
|
const isUnknown = filter === InviteFilter.Unknown;
|
||||||
|
const isSpam = filter === InviteFilter.Spam;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box gap="200">
|
||||||
|
<Chip
|
||||||
|
variant={isKnown ? 'Success' : 'Surface'}
|
||||||
|
aria-selected={isKnown}
|
||||||
|
outlined={!isKnown}
|
||||||
|
onClick={() => onFilter(InviteFilter.Known)}
|
||||||
|
before={isKnown && <Icon size="100" src={Icons.Check} />}
|
||||||
|
after={
|
||||||
|
knownInvites.length > 0 && (
|
||||||
|
<Badge variant={isKnown ? 'Success' : 'Secondary'} fill="Solid" radii="Pill">
|
||||||
|
<Text size="L400">{knownInvites.length}</Text>
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T200">Primary</Text>
|
||||||
|
</Chip>
|
||||||
|
<Chip
|
||||||
|
variant={isUnknown ? 'Warning' : 'Surface'}
|
||||||
|
aria-selected={isUnknown}
|
||||||
|
outlined={!isUnknown}
|
||||||
|
onClick={() => onFilter(InviteFilter.Unknown)}
|
||||||
|
before={isUnknown && <Icon size="100" src={Icons.Check} />}
|
||||||
|
after={
|
||||||
|
unknownInvites.length > 0 && (
|
||||||
|
<Badge variant={isUnknown ? 'Warning' : 'Secondary'} fill="Solid" radii="Pill">
|
||||||
|
<Text size="L400">{unknownInvites.length}</Text>
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T200">Public</Text>
|
||||||
|
</Chip>
|
||||||
|
<Chip
|
||||||
|
variant={isSpam ? 'Critical' : 'Surface'}
|
||||||
|
aria-selected={isSpam}
|
||||||
|
outlined={!isSpam}
|
||||||
|
onClick={() => onFilter(InviteFilter.Spam)}
|
||||||
|
before={isSpam && <Icon size="100" src={Icons.Check} />}
|
||||||
|
after={
|
||||||
|
spamInvites.length > 0 && (
|
||||||
|
<Badge variant={isSpam ? 'Critical' : 'Secondary'} fill="Solid" radii="Pill">
|
||||||
|
<Text size="L400">{spamInvites.length}</Text>
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T200">Spam</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnownInvitesProps = {
|
||||||
|
invites: InviteData[];
|
||||||
|
handleNavigate: NavigateHandler;
|
||||||
|
compact: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
|
};
|
||||||
|
function KnownInvites({
|
||||||
|
invites,
|
||||||
|
handleNavigate,
|
||||||
|
compact,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
|
}: KnownInvitesProps) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="H4">Primary</Text>
|
||||||
|
{invites.length > 0 ? (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<InviteCard
|
||||||
|
key={invite.roomId}
|
||||||
|
invite={invite}
|
||||||
|
compact={compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
hideAvatar={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<PageHeroEmpty>
|
||||||
|
<PageHeroSection>
|
||||||
|
<PageHero
|
||||||
|
icon={<Icon size="600" src={Icons.Mail} />}
|
||||||
|
title="No Invites"
|
||||||
|
subTitle="When someone you share a room with sends you an invite, it’ll show up here."
|
||||||
|
/>
|
||||||
|
</PageHeroSection>
|
||||||
|
</PageHeroEmpty>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnknownInvitesProps = {
|
||||||
|
invites: InviteData[];
|
||||||
|
handleNavigate: NavigateHandler;
|
||||||
|
compact: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
|
};
|
||||||
|
function UnknownInvites({
|
||||||
|
invites,
|
||||||
|
handleNavigate,
|
||||||
|
compact,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
|
}: UnknownInvitesProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
const [declineAllStatus, declineAll] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const roomIds = invites.map((invite) => invite.roomId);
|
||||||
|
|
||||||
|
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
|
||||||
|
}, [mx, invites])
|
||||||
|
);
|
||||||
|
|
||||||
|
const declining = declineAllStatus.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Box gap="200" justifyContent="SpaceBetween" alignItems="Center">
|
||||||
|
<Text size="H4">Public</Text>
|
||||||
|
<Box>
|
||||||
|
{invites.length > 0 && (
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
onClick={declineAll}
|
||||||
|
before={declining && <Spinner size="50" variant="Secondary" fill="Soft" />}
|
||||||
|
disabled={declining}
|
||||||
|
radii="Pill"
|
||||||
|
>
|
||||||
|
<Text size="T200">Decline All</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{invites.length > 0 ? (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<InviteCard
|
||||||
|
key={invite.roomId}
|
||||||
|
invite={invite}
|
||||||
|
compact={compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
hideAvatar
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<PageHeroEmpty>
|
||||||
|
<PageHeroSection>
|
||||||
|
<PageHero
|
||||||
|
icon={<Icon size="600" src={Icons.Info} />}
|
||||||
|
title="No Invites"
|
||||||
|
subTitle="Invites from people outside your rooms will appear here."
|
||||||
|
/>
|
||||||
|
</PageHeroSection>
|
||||||
|
</PageHeroEmpty>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpamInvitesProps = {
|
||||||
|
invites: InviteData[];
|
||||||
|
handleNavigate: NavigateHandler;
|
||||||
|
compact: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
|
};
|
||||||
|
function SpamInvites({
|
||||||
|
invites,
|
||||||
|
handleNavigate,
|
||||||
|
compact,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
|
}: SpamInvitesProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [showInvites, setShowInvites] = useState(false);
|
||||||
|
|
||||||
|
const reportRoomSupported = useReportRoomSupported();
|
||||||
|
|
||||||
|
const [declineAllStatus, declineAll] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const roomIds = invites.map((invite) => invite.roomId);
|
||||||
|
|
||||||
|
await rateLimitedActions(roomIds, (roomId) => mx.leave(roomId));
|
||||||
|
}, [mx, invites])
|
||||||
|
);
|
||||||
|
|
||||||
|
const [reportAllStatus, reportAll] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const roomIds = invites.map((invite) => invite.roomId);
|
||||||
|
|
||||||
|
await rateLimitedActions(roomIds, (roomId) => mx.reportRoom(roomId, 'Spam Invite'));
|
||||||
|
}, [mx, invites])
|
||||||
|
);
|
||||||
|
|
||||||
|
const ignoredUsers = useIgnoredUsers();
|
||||||
|
const unignoredUsers = Array.from(new Set(invites.map((invite) => invite.senderId))).filter(
|
||||||
|
(user) => !ignoredUsers.includes(user)
|
||||||
|
);
|
||||||
|
const [blockAllStatus, blockAll] = useAsyncCallback(
|
||||||
|
useCallback(
|
||||||
|
() => mx.setIgnoredUsers([...ignoredUsers, ...unignoredUsers]),
|
||||||
|
[mx, ignoredUsers, unignoredUsers]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const declining = declineAllStatus.status === AsyncStatus.Loading;
|
||||||
|
const reporting = reportAllStatus.status === AsyncStatus.Loading;
|
||||||
|
const blocking = blockAllStatus.status === AsyncStatus.Loading;
|
||||||
|
const loading = blocking || reporting || declining;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="H4">Spam</Text>
|
||||||
|
{invites.length > 0 ? (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<SequenceCard
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="300"
|
||||||
|
style={{ padding: `${config.space.S400} ${config.space.S400} 0` }}
|
||||||
|
>
|
||||||
|
<PageHeroSection>
|
||||||
|
<PageHero
|
||||||
|
icon={<Icon size="600" src={Icons.Warning} />}
|
||||||
|
title={`${invites.length} Spam Invites`}
|
||||||
|
subTitle="Some of the following invites may contain harmful content or have been sent by banned users."
|
||||||
|
>
|
||||||
|
<Box direction="Row" gap="200" justifyContent="Center" wrap="Wrap">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
onClick={declineAll}
|
||||||
|
before={declining && <Spinner size="100" variant="Critical" fill="Solid" />}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
Decline All
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
{reportRoomSupported && reportAllStatus.status !== AsyncStatus.Success && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
onClick={reportAll}
|
||||||
|
before={reporting && <Spinner size="100" variant="Secondary" fill="Solid" />}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
Report All
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{unignoredUsers.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={blockAll}
|
||||||
|
before={blocking && <Spinner size="100" variant="Secondary" fill="Solid" />}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
Block All
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<span data-spacing-node />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="Pill"
|
||||||
|
before={
|
||||||
|
<Icon size="100" src={showInvites ? Icons.ChevronTop : Icons.ChevronBottom} />
|
||||||
|
}
|
||||||
|
onClick={() => setShowInvites(!showInvites)}
|
||||||
|
>
|
||||||
|
<Text size="B300">{showInvites ? 'Hide All' : 'View All'}</Text>
|
||||||
|
</Button>
|
||||||
|
</PageHero>
|
||||||
|
</PageHeroSection>
|
||||||
|
</SequenceCard>
|
||||||
|
{showInvites &&
|
||||||
|
invites.map((invite) => (
|
||||||
|
<InviteCard
|
||||||
|
key={invite.roomId}
|
||||||
|
invite={invite}
|
||||||
|
compact={compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
hideAvatar
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<PageHeroEmpty>
|
||||||
|
<PageHeroSection>
|
||||||
|
<PageHero
|
||||||
|
icon={<Icon size="600" src={Icons.Warning} />}
|
||||||
|
title="No Spam Invites"
|
||||||
|
subTitle="Invites detected as spam appear here."
|
||||||
|
/>
|
||||||
|
</PageHeroSection>
|
||||||
|
</PageHeroEmpty>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Invites() {
|
export function Invites() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const userId = mx.getUserId()!;
|
const useAuthentication = useMediaAuthentication();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||||
const directInvites = useDirectInvites(mx, allInvitesAtom, mDirects);
|
const allRooms = useAtomValue(allRoomsAtom);
|
||||||
const spaceInvites = useSpaceInvites(mx, allInvitesAtom);
|
const allInviteIds = useAtomValue(allInvitesAtom);
|
||||||
const roomInvites = useRoomInvites(mx, allInvitesAtom, mDirects);
|
|
||||||
|
const [filter, setFilter] = useState(InviteFilter.Known);
|
||||||
|
|
||||||
|
const invitesData = allInviteIds
|
||||||
|
.map((inviteId) => mx.getRoom(inviteId))
|
||||||
|
.filter((inviteRoom) => !!inviteRoom)
|
||||||
|
.map((inviteRoom) => makeInviteData(mx, inviteRoom, useAuthentication));
|
||||||
|
|
||||||
|
const [knownInvites, unknownInvites, spamInvites] = useMemo(() => {
|
||||||
|
const known: InviteData[] = [];
|
||||||
|
const unknown: InviteData[] = [];
|
||||||
|
const spam: InviteData[] = [];
|
||||||
|
invitesData.forEach((invite) => {
|
||||||
|
if (hasBadWords(invite) || bannedInRooms(mx, allRooms, invite.senderId)) {
|
||||||
|
spam.push(invite);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getCommonRooms(mx, allRooms, invite.senderId).length === 0) {
|
||||||
|
unknown.push(invite);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
known.push(invite);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [known, unknown, spam];
|
||||||
|
}, [mx, allRooms, invitesData]);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH);
|
const [compact, setCompact] = useState(document.body.clientWidth <= COMPACT_CARD_WIDTH);
|
||||||
useElementSizeObserver(
|
useElementSizeObserver(
|
||||||
@@ -212,21 +718,15 @@ export function Invites() {
|
|||||||
);
|
);
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
|
|
||||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
const renderInvite = (roomId: string, direct: boolean, handleNavigate: (rId: string) => void) => {
|
const handleNavigate = (roomId: string, space: boolean) => {
|
||||||
const room = mx.getRoom(roomId);
|
if (space) {
|
||||||
if (!room) return null;
|
navigateSpace(roomId);
|
||||||
return (
|
return;
|
||||||
<InviteCard
|
}
|
||||||
key={roomId}
|
navigateRoom(roomId);
|
||||||
room={room}
|
|
||||||
userId={userId}
|
|
||||||
compact={compact}
|
|
||||||
direct={direct}
|
|
||||||
onNavigate={handleNavigate}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -247,7 +747,7 @@ export function Invites() {
|
|||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Mail} />}
|
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Mail} />}
|
||||||
<Text size="H3" truncate>
|
<Text size="H3" truncate>
|
||||||
Invitations
|
Invites
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box grow="Yes" basis="No" />
|
<Box grow="Yes" basis="No" />
|
||||||
@@ -258,47 +758,46 @@ export function Invites() {
|
|||||||
<PageContent>
|
<PageContent>
|
||||||
<PageContentCenter>
|
<PageContentCenter>
|
||||||
<Box ref={containerRef} direction="Column" gap="600">
|
<Box ref={containerRef} direction="Column" gap="600">
|
||||||
{directInvites.length > 0 && (
|
<Box direction="Column" gap="100">
|
||||||
<Box direction="Column" gap="200">
|
<span data-spacing-node />
|
||||||
<Text size="H4">Direct Messages</Text>
|
<Text size="L400">Filter</Text>
|
||||||
<Box direction="Column" gap="100">
|
<InviteFilters
|
||||||
{directInvites.map((roomId) => renderInvite(roomId, true, navigateRoom))}
|
filter={filter}
|
||||||
</Box>
|
onFilter={setFilter}
|
||||||
</Box>
|
knownInvites={knownInvites}
|
||||||
|
unknownInvites={unknownInvites}
|
||||||
|
spamInvites={spamInvites}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{filter === InviteFilter.Known && (
|
||||||
|
<KnownInvites
|
||||||
|
invites={knownInvites}
|
||||||
|
compact={compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
handleNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{spaceInvites.length > 0 && (
|
|
||||||
<Box direction="Column" gap="200">
|
{filter === InviteFilter.Unknown && (
|
||||||
<Text size="H4">Spaces</Text>
|
<UnknownInvites
|
||||||
<Box direction="Column" gap="100">
|
invites={unknownInvites}
|
||||||
{spaceInvites.map((roomId) => renderInvite(roomId, false, navigateSpace))}
|
compact={compact}
|
||||||
</Box>
|
hour24Clock={hour24Clock}
|
||||||
</Box>
|
dateFormatString={dateFormatString}
|
||||||
|
handleNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{roomInvites.length > 0 && (
|
|
||||||
<Box direction="Column" gap="200">
|
{filter === InviteFilter.Spam && (
|
||||||
<Text size="H4">Rooms</Text>
|
<SpamInvites
|
||||||
<Box direction="Column" gap="100">
|
invites={spamInvites}
|
||||||
{roomInvites.map((roomId) => renderInvite(roomId, false, navigateRoom))}
|
compact={compact}
|
||||||
</Box>
|
hour24Clock={hour24Clock}
|
||||||
</Box>
|
dateFormatString={dateFormatString}
|
||||||
|
handleNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{directInvites.length === 0 &&
|
|
||||||
spaceInvites.length === 0 &&
|
|
||||||
roomInvites.length === 0 && (
|
|
||||||
<div>
|
|
||||||
<SequenceCard
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
style={{ padding: config.space.S400 }}
|
|
||||||
direction="Column"
|
|
||||||
gap="200"
|
|
||||||
>
|
|
||||||
<Text>No Pending Invitations</Text>
|
|
||||||
<Text size="T200">
|
|
||||||
You don't have any new pending invitations to display yet.
|
|
||||||
</Text>
|
|
||||||
</SequenceCard>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</PageContentCenter>
|
</PageContentCenter>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ type RoomNotificationsGroupProps = {
|
|||||||
hideActivity: boolean;
|
hideActivity: boolean;
|
||||||
onOpen: (roomId: string, eventId: string) => void;
|
onOpen: (roomId: string, eventId: string) => void;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
function RoomNotificationsGroupComp({
|
function RoomNotificationsGroupComp({
|
||||||
room,
|
room,
|
||||||
@@ -214,6 +216,8 @@ function RoomNotificationsGroupComp({
|
|||||||
hideActivity,
|
hideActivity,
|
||||||
onOpen,
|
onOpen,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
}: RoomNotificationsGroupProps) {
|
}: RoomNotificationsGroupProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
@@ -496,7 +500,11 @@ function RoomNotificationsGroupComp({
|
|||||||
</Username>
|
</Username>
|
||||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||||
</Box>
|
</Box>
|
||||||
<Time ts={event.origin_server_ts} />
|
<Time
|
||||||
|
ts={event.origin_server_ts}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" gap="200" alignItems="Center">
|
<Box shrink="No" gap="200" alignItems="Center">
|
||||||
<Chip
|
<Chip
|
||||||
@@ -549,6 +557,8 @@ export function Notifications() {
|
|||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
|
||||||
@@ -713,6 +723,8 @@ export function Notifications() {
|
|||||||
legacyUsernameColor={
|
legacyUsernameColor={
|
||||||
legacyUsernameColor || mDirects.has(groupRoom.roomId)
|
legacyUsernameColor || mDirects.has(groupRoom.roomId)
|
||||||
}
|
}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
</VirtualTile>
|
</VirtualTile>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -744,13 +744,14 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
|
|||||||
const targetSpaceId = target.getAttribute('data-id');
|
const targetSpaceId = target.getAttribute('data-id');
|
||||||
if (!targetSpaceId) return;
|
if (!targetSpaceId) return;
|
||||||
|
|
||||||
|
const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId));
|
||||||
if (screenSize === ScreenSize.Mobile) {
|
if (screenSize === ScreenSize.Mobile) {
|
||||||
navigate(getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)));
|
navigate(spacePath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activePath = navToActivePath.get(targetSpaceId);
|
const activePath = navToActivePath.get(targetSpaceId);
|
||||||
if (activePath) {
|
if (activePath && activePath.pathname.startsWith(spacePath)) {
|
||||||
navigate(joinPathComponent(activePath));
|
navigate(joinPathComponent(activePath));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||||
import { useSpace } from '../../../hooks/useSpace';
|
import { useSpace } from '../../../hooks/useSpace';
|
||||||
import { getAllParents } from '../../../utils/room';
|
import { getAllParents, getSpaceChildren } from '../../../utils/room';
|
||||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
import { mDirectAtom } from '../../../state/mDirectList';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
|
||||||
export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const space = useSpace();
|
const space = useSpace();
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const allRooms = useAtomValue(allRoomsAtom);
|
const allRooms = useAtomValue(allRoomsAtom);
|
||||||
|
|
||||||
@@ -24,12 +27,36 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||||||
const roomId = useSelectedRoom();
|
const roomId = useSelectedRoom();
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
|
|
||||||
if (
|
if (!room || !allRooms.includes(room.roomId)) {
|
||||||
!room ||
|
// room is not joined
|
||||||
room.isSpaceRoom() ||
|
return (
|
||||||
!allRooms.includes(room.roomId) ||
|
<JoinBeforeNavigate
|
||||||
!getAllParents(roomToParents, room.roomId).has(space.roomId)
|
roomIdOrAlias={roomIdOrAlias!}
|
||||||
) {
|
eventId={eventId}
|
||||||
|
viaServers={viaServers}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (developerTools && room.isSpaceRoom() && room.roomId === space.roomId) {
|
||||||
|
// allow to view space timeline
|
||||||
|
return (
|
||||||
|
<RoomProvider key={room.roomId} value={room}>
|
||||||
|
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
|
||||||
|
</RoomProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
|
||||||
|
if (getSpaceChildren(space).includes(room.roomId)) {
|
||||||
|
// fill missing roomToParent mapping
|
||||||
|
setRoomToParents({
|
||||||
|
type: 'PUT',
|
||||||
|
parent: space.roomId,
|
||||||
|
children: [room.roomId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JoinBeforeNavigate
|
<JoinBeforeNavigate
|
||||||
roomIdOrAlias={roomIdOrAlias!}
|
roomIdOrAlias={roomIdOrAlias!}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import {
|
|||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||||
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
|
import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings';
|
||||||
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
|
|
||||||
type SpaceMenuProps = {
|
type SpaceMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -83,11 +84,13 @@ type SpaceMenuProps = {
|
|||||||
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
|
const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClose }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
|
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
|
const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
|
||||||
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
|
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
|
||||||
const openSpaceSettings = useOpenSpaceSettings();
|
const openSpaceSettings = useOpenSpaceSettings();
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
|
||||||
const allChild = useSpaceChildren(
|
const allChild = useSpaceChildren(
|
||||||
allRoomsAtom,
|
allRoomsAtom,
|
||||||
@@ -118,6 +121,11 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
|
|||||||
requestClose();
|
requestClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenTimeline = () => {
|
||||||
|
navigateRoom(room.roomId);
|
||||||
|
requestClose();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
@@ -168,6 +176,18 @@ const SpaceMenu = forwardRef<HTMLDivElement, SpaceMenuProps>(({ room, requestClo
|
|||||||
Space Settings
|
Space Settings
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{developerTools && (
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleOpenTimeline}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Terminal} />}
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Event Timeline
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<Line variant="Surface" size="300" />
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
|||||||
15
src/app/plugins/bad-words.ts
Normal file
15
src/app/plugins/bad-words.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import * as badWords from 'badwords-list';
|
||||||
|
import { sanitizeForRegex } from '../utils/regex';
|
||||||
|
|
||||||
|
const additionalBadWords: string[] = ['torture', 't0rture'];
|
||||||
|
|
||||||
|
const fullBadWordList = additionalBadWords.concat(
|
||||||
|
badWords.array.filter((word) => !additionalBadWords.includes(word))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const BAD_WORDS_REGEX = new RegExp(
|
||||||
|
`(\\b|_)(${fullBadWordList.map((word) => sanitizeForRegex(word)).join('|')})(\\b|_)`,
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const testBadWords = (str: string): boolean => !!str.toLowerCase().match(BAD_WORDS_REGEX);
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
/* eslint-disable jsx-a11y/alt-text */
|
/* eslint-disable jsx-a11y/alt-text */
|
||||||
import React, { ComponentPropsWithoutRef, ReactEventHandler, Suspense, lazy } from 'react';
|
import React, {
|
||||||
|
ComponentPropsWithoutRef,
|
||||||
|
ReactEventHandler,
|
||||||
|
Suspense,
|
||||||
|
lazy,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
Element,
|
Element,
|
||||||
Text as DOMText,
|
Text as DOMText,
|
||||||
@@ -9,10 +16,11 @@ import {
|
|||||||
} from 'html-react-parser';
|
} from 'html-react-parser';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Scroll, Text } from 'folds';
|
import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
|
||||||
import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
|
import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
|
||||||
import Linkify from 'linkify-react';
|
import Linkify from 'linkify-react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
|
import { ChildNode } from 'domhandler';
|
||||||
import * as css from '../styles/CustomHtml.css';
|
import * as css from '../styles/CustomHtml.css';
|
||||||
import {
|
import {
|
||||||
getMxIdLocalPart,
|
getMxIdLocalPart,
|
||||||
@@ -31,7 +39,8 @@ import {
|
|||||||
testMatrixTo,
|
testMatrixTo,
|
||||||
} from './matrix-to';
|
} from './matrix-to';
|
||||||
import { onEnterOrSpace } from '../utils/keyboard';
|
import { onEnterOrSpace } from '../utils/keyboard';
|
||||||
import { tryDecodeURIComponent } from '../utils/dom';
|
import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
|
||||||
|
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
|
||||||
|
|
||||||
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
|
||||||
|
|
||||||
@@ -195,6 +204,111 @@ export const highlightText = (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively extracts and concatenates all text content from an array of ChildNode objects.
|
||||||
|
*
|
||||||
|
* @param {ChildNode[]} nodes - An array of ChildNode objects to extract text from.
|
||||||
|
* @returns {string} The concatenated plain text content of all descendant text nodes.
|
||||||
|
*/
|
||||||
|
const extractTextFromChildren = (nodes: ChildNode[]): string => {
|
||||||
|
let text = '';
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
text += node.data;
|
||||||
|
} else if (node instanceof Element && node.children) {
|
||||||
|
text += extractTextFromChildren(node.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CodeBlock({
|
||||||
|
children,
|
||||||
|
opts,
|
||||||
|
}: {
|
||||||
|
children: ChildNode[];
|
||||||
|
opts: HTMLReactParserOptions;
|
||||||
|
}) {
|
||||||
|
const code = children[0];
|
||||||
|
const languageClass =
|
||||||
|
code instanceof Element && code.name === 'code' ? code.attribs.class : undefined;
|
||||||
|
const language =
|
||||||
|
languageClass && languageClass.startsWith('language-')
|
||||||
|
? languageClass.replace('language-', '')
|
||||||
|
: languageClass;
|
||||||
|
|
||||||
|
const LINE_LIMIT = 14;
|
||||||
|
const largeCodeBlock = useMemo(
|
||||||
|
() => extractTextFromChildren(children).split('\n').length > LINE_LIMIT,
|
||||||
|
[children]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [expanded, setExpand] = useState(false);
|
||||||
|
const [copied, setCopied] = useTimeoutToggle();
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
copyToClipboard(extractTextFromChildren(children));
|
||||||
|
setCopied();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = () => {
|
||||||
|
setExpand(!expanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text size="T300" as="pre" className={css.CodeBlock}>
|
||||||
|
<Header variant="Surface" size="400" className={css.CodeBlockHeader}>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="L400" truncate>
|
||||||
|
{language ?? 'Code'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" gap="200">
|
||||||
|
<Chip
|
||||||
|
variant={copied ? 'Success' : 'Surface'}
|
||||||
|
fill="None"
|
||||||
|
radii="Pill"
|
||||||
|
onClick={handleCopy}
|
||||||
|
before={copied && <Icon size="50" src={Icons.Check} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
|
||||||
|
</Chip>
|
||||||
|
{largeCodeBlock && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
outlined
|
||||||
|
radii="300"
|
||||||
|
onClick={toggleExpand}
|
||||||
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
<Scroll
|
||||||
|
style={{
|
||||||
|
maxHeight: largeCodeBlock && !expanded ? toRem(300) : undefined,
|
||||||
|
paddingBottom: largeCodeBlock ? config.space.S400 : undefined,
|
||||||
|
}}
|
||||||
|
direction="Both"
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
visibility="Hover"
|
||||||
|
hideTrack
|
||||||
|
>
|
||||||
|
<div id="code-block-content" className={css.CodeBlockInternal}>
|
||||||
|
{domToReact(children, opts)}
|
||||||
|
</div>
|
||||||
|
</Scroll>
|
||||||
|
{largeCodeBlock && !expanded && <Box className={css.CodeBlockBottomShadow} />}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const getReactCustomHtmlParser = (
|
export const getReactCustomHtmlParser = (
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
roomId: string | undefined,
|
roomId: string | undefined,
|
||||||
@@ -269,19 +383,7 @@ export const getReactCustomHtmlParser = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'pre') {
|
if (name === 'pre') {
|
||||||
return (
|
return <CodeBlock opts={opts}>{children}</CodeBlock>;
|
||||||
<Text {...props} as="pre" className={css.CodeBlock}>
|
|
||||||
<Scroll
|
|
||||||
direction="Horizontal"
|
|
||||||
variant="Secondary"
|
|
||||||
size="300"
|
|
||||||
visibility="Hover"
|
|
||||||
hideTrack
|
|
||||||
>
|
|
||||||
<div className={css.CodeBlockInternal}>{domToReact(children, opts)}</div>
|
|
||||||
</Scroll>
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === 'blockquote') {
|
if (name === 'blockquote') {
|
||||||
@@ -331,9 +433,9 @@ export const getReactCustomHtmlParser = (
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<code className={css.Code} {...props}>
|
<Text as="code" size="T300" className={css.Code} {...props}>
|
||||||
{domToReact(children, opts)}
|
{domToReact(children, opts)}
|
||||||
</code>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,307 @@ import React, { MutableRefObject, ReactNode, useEffect, useRef } from 'react';
|
|||||||
|
|
||||||
import Prism from 'prismjs';
|
import Prism from 'prismjs';
|
||||||
|
|
||||||
import 'prismjs/components/prism-json';
|
import 'prismjs/components/prism-abap.js';
|
||||||
import 'prismjs/components/prism-javascript';
|
import 'prismjs/components/prism-abnf.js';
|
||||||
import 'prismjs/components/prism-typescript';
|
import 'prismjs/components/prism-actionscript.js';
|
||||||
import 'prismjs/components/prism-css';
|
import 'prismjs/components/prism-ada.js';
|
||||||
import 'prismjs/components/prism-sass';
|
import 'prismjs/components/prism-agda.js';
|
||||||
import 'prismjs/components/prism-swift';
|
import 'prismjs/components/prism-al.js';
|
||||||
import 'prismjs/components/prism-rust';
|
import 'prismjs/components/prism-antlr4.js';
|
||||||
import 'prismjs/components/prism-go';
|
import 'prismjs/components/prism-apacheconf.js';
|
||||||
import 'prismjs/components/prism-c';
|
import 'prismjs/components/prism-apex.js';
|
||||||
import 'prismjs/components/prism-cpp';
|
import 'prismjs/components/prism-apl.js';
|
||||||
import 'prismjs/components/prism-java';
|
import 'prismjs/components/prism-applescript.js';
|
||||||
import 'prismjs/components/prism-python';
|
import 'prismjs/components/prism-aql.js';
|
||||||
|
import 'prismjs/components/prism-arff.js';
|
||||||
|
import 'prismjs/components/prism-armasm.js';
|
||||||
|
import 'prismjs/components/prism-arturo.js';
|
||||||
|
import 'prismjs/components/prism-asciidoc.js';
|
||||||
|
import 'prismjs/components/prism-asm6502.js';
|
||||||
|
import 'prismjs/components/prism-asmatmel.js';
|
||||||
|
import 'prismjs/components/prism-aspnet.js';
|
||||||
|
import 'prismjs/components/prism-autohotkey.js';
|
||||||
|
import 'prismjs/components/prism-autoit.js';
|
||||||
|
import 'prismjs/components/prism-avisynth.js';
|
||||||
|
import 'prismjs/components/prism-avro-idl.js';
|
||||||
|
import 'prismjs/components/prism-awk.js';
|
||||||
|
import 'prismjs/components/prism-bash.js';
|
||||||
|
import 'prismjs/components/prism-basic.js';
|
||||||
|
import 'prismjs/components/prism-batch.js';
|
||||||
|
import 'prismjs/components/prism-bbcode.js';
|
||||||
|
import 'prismjs/components/prism-bbj.js';
|
||||||
|
import 'prismjs/components/prism-bicep.js';
|
||||||
|
import 'prismjs/components/prism-birb.js';
|
||||||
|
import 'prismjs/components/prism-bnf.js';
|
||||||
|
import 'prismjs/components/prism-bqn.js';
|
||||||
|
import 'prismjs/components/prism-brainfuck.js';
|
||||||
|
import 'prismjs/components/prism-brightscript.js';
|
||||||
|
import 'prismjs/components/prism-bro.js';
|
||||||
|
import 'prismjs/components/prism-bsl.js';
|
||||||
|
import 'prismjs/components/prism-c.js';
|
||||||
|
import 'prismjs/components/prism-cfscript.js';
|
||||||
|
import 'prismjs/components/prism-cil.js';
|
||||||
|
import 'prismjs/components/prism-cilkc.js';
|
||||||
|
import 'prismjs/components/prism-cilkcpp.js';
|
||||||
|
import 'prismjs/components/prism-clike.js';
|
||||||
|
import 'prismjs/components/prism-clojure.js';
|
||||||
|
import 'prismjs/components/prism-cmake.js';
|
||||||
|
import 'prismjs/components/prism-cobol.js';
|
||||||
|
import 'prismjs/components/prism-coffeescript.js';
|
||||||
|
import 'prismjs/components/prism-concurnas.js';
|
||||||
|
import 'prismjs/components/prism-cooklang.js';
|
||||||
|
import 'prismjs/components/prism-coq.js';
|
||||||
|
import 'prismjs/components/prism-cpp.js';
|
||||||
|
import 'prismjs/components/prism-csharp.js';
|
||||||
|
import 'prismjs/components/prism-cshtml.js';
|
||||||
|
import 'prismjs/components/prism-csp.js';
|
||||||
|
import 'prismjs/components/prism-css-extras.js';
|
||||||
|
import 'prismjs/components/prism-css.js';
|
||||||
|
import 'prismjs/components/prism-csv.js';
|
||||||
|
import 'prismjs/components/prism-cue.js';
|
||||||
|
import 'prismjs/components/prism-cypher.js';
|
||||||
|
import 'prismjs/components/prism-d.js';
|
||||||
|
import 'prismjs/components/prism-dart.js';
|
||||||
|
import 'prismjs/components/prism-dataweave.js';
|
||||||
|
import 'prismjs/components/prism-dax.js';
|
||||||
|
import 'prismjs/components/prism-dhall.js';
|
||||||
|
import 'prismjs/components/prism-diff.js';
|
||||||
|
import 'prismjs/components/prism-dns-zone-file.js';
|
||||||
|
import 'prismjs/components/prism-docker.js';
|
||||||
|
import 'prismjs/components/prism-dot.js';
|
||||||
|
import 'prismjs/components/prism-ebnf.js';
|
||||||
|
import 'prismjs/components/prism-editorconfig.js';
|
||||||
|
import 'prismjs/components/prism-eiffel.js';
|
||||||
|
import 'prismjs/components/prism-ejs.js';
|
||||||
|
import 'prismjs/components/prism-elixir.js';
|
||||||
|
import 'prismjs/components/prism-elm.js';
|
||||||
|
import 'prismjs/components/prism-erb.js';
|
||||||
|
import 'prismjs/components/prism-erlang.js';
|
||||||
|
import 'prismjs/components/prism-etlua.js';
|
||||||
|
import 'prismjs/components/prism-excel-formula.js';
|
||||||
|
import 'prismjs/components/prism-factor.js';
|
||||||
|
import 'prismjs/components/prism-false.js';
|
||||||
|
import 'prismjs/components/prism-firestore-security-rules.js';
|
||||||
|
import 'prismjs/components/prism-flow.js';
|
||||||
|
import 'prismjs/components/prism-fortran.js';
|
||||||
|
import 'prismjs/components/prism-fsharp.js';
|
||||||
|
import 'prismjs/components/prism-ftl.js';
|
||||||
|
import 'prismjs/components/prism-gap.js';
|
||||||
|
import 'prismjs/components/prism-gcode.js';
|
||||||
|
import 'prismjs/components/prism-gdscript.js';
|
||||||
|
import 'prismjs/components/prism-gedcom.js';
|
||||||
|
import 'prismjs/components/prism-gettext.js';
|
||||||
|
import 'prismjs/components/prism-gherkin.js';
|
||||||
|
import 'prismjs/components/prism-git.js';
|
||||||
|
import 'prismjs/components/prism-glsl.js';
|
||||||
|
import 'prismjs/components/prism-gml.js';
|
||||||
|
import 'prismjs/components/prism-gn.js';
|
||||||
|
import 'prismjs/components/prism-go-module.js';
|
||||||
|
import 'prismjs/components/prism-go.js';
|
||||||
|
import 'prismjs/components/prism-gradle.js';
|
||||||
|
import 'prismjs/components/prism-graphql.js';
|
||||||
|
import 'prismjs/components/prism-groovy.js';
|
||||||
|
import 'prismjs/components/prism-haml.js';
|
||||||
|
import 'prismjs/components/prism-handlebars.js';
|
||||||
|
import 'prismjs/components/prism-haskell.js';
|
||||||
|
import 'prismjs/components/prism-haxe.js';
|
||||||
|
import 'prismjs/components/prism-hcl.js';
|
||||||
|
import 'prismjs/components/prism-hlsl.js';
|
||||||
|
import 'prismjs/components/prism-hoon.js';
|
||||||
|
import 'prismjs/components/prism-hpkp.js';
|
||||||
|
import 'prismjs/components/prism-hsts.js';
|
||||||
|
import 'prismjs/components/prism-http.js';
|
||||||
|
import 'prismjs/components/prism-ichigojam.js';
|
||||||
|
import 'prismjs/components/prism-icon.js';
|
||||||
|
import 'prismjs/components/prism-icu-message-format.js';
|
||||||
|
import 'prismjs/components/prism-idris.js';
|
||||||
|
import 'prismjs/components/prism-iecst.js';
|
||||||
|
import 'prismjs/components/prism-ignore.js';
|
||||||
|
import 'prismjs/components/prism-inform7.js';
|
||||||
|
import 'prismjs/components/prism-ini.js';
|
||||||
|
import 'prismjs/components/prism-io.js';
|
||||||
|
import 'prismjs/components/prism-j.js';
|
||||||
|
import 'prismjs/components/prism-java.js';
|
||||||
|
import 'prismjs/components/prism-javadoclike.js';
|
||||||
|
import 'prismjs/components/prism-javascript.js';
|
||||||
|
import 'prismjs/components/prism-javastacktrace.js';
|
||||||
|
import 'prismjs/components/prism-jexl.js';
|
||||||
|
import 'prismjs/components/prism-jolie.js';
|
||||||
|
import 'prismjs/components/prism-jq.js';
|
||||||
|
import 'prismjs/components/prism-js-extras.js';
|
||||||
|
import 'prismjs/components/prism-js-templates.js';
|
||||||
|
import 'prismjs/components/prism-json.js';
|
||||||
|
import 'prismjs/components/prism-json5.js';
|
||||||
|
import 'prismjs/components/prism-jsonp.js';
|
||||||
|
import 'prismjs/components/prism-jsstacktrace.js';
|
||||||
|
import 'prismjs/components/prism-jsx.js';
|
||||||
|
import 'prismjs/components/prism-julia.js';
|
||||||
|
import 'prismjs/components/prism-keepalived.js';
|
||||||
|
import 'prismjs/components/prism-keyman.js';
|
||||||
|
import 'prismjs/components/prism-kotlin.js';
|
||||||
|
import 'prismjs/components/prism-kumir.js';
|
||||||
|
import 'prismjs/components/prism-kusto.js';
|
||||||
|
import 'prismjs/components/prism-latex.js';
|
||||||
|
import 'prismjs/components/prism-latte.js';
|
||||||
|
import 'prismjs/components/prism-less.js';
|
||||||
|
import 'prismjs/components/prism-lilypond.js';
|
||||||
|
import 'prismjs/components/prism-linker-script.js';
|
||||||
|
import 'prismjs/components/prism-liquid.js';
|
||||||
|
import 'prismjs/components/prism-lisp.js';
|
||||||
|
import 'prismjs/components/prism-livescript.js';
|
||||||
|
import 'prismjs/components/prism-llvm.js';
|
||||||
|
import 'prismjs/components/prism-log.js';
|
||||||
|
import 'prismjs/components/prism-lolcode.js';
|
||||||
|
import 'prismjs/components/prism-lua.js';
|
||||||
|
import 'prismjs/components/prism-magma.js';
|
||||||
|
import 'prismjs/components/prism-makefile.js';
|
||||||
|
import 'prismjs/components/prism-markdown.js';
|
||||||
|
import 'prismjs/components/prism-markup-templating.js';
|
||||||
|
import 'prismjs/components/prism-markup.js';
|
||||||
|
import 'prismjs/components/prism-mata.js';
|
||||||
|
import 'prismjs/components/prism-matlab.js';
|
||||||
|
import 'prismjs/components/prism-maxscript.js';
|
||||||
|
import 'prismjs/components/prism-mel.js';
|
||||||
|
import 'prismjs/components/prism-mermaid.js';
|
||||||
|
import 'prismjs/components/prism-metafont.js';
|
||||||
|
import 'prismjs/components/prism-mizar.js';
|
||||||
|
import 'prismjs/components/prism-mongodb.js';
|
||||||
|
import 'prismjs/components/prism-monkey.js';
|
||||||
|
import 'prismjs/components/prism-moonscript.js';
|
||||||
|
import 'prismjs/components/prism-n1ql.js';
|
||||||
|
import 'prismjs/components/prism-n4js.js';
|
||||||
|
import 'prismjs/components/prism-nand2tetris-hdl.js';
|
||||||
|
import 'prismjs/components/prism-naniscript.js';
|
||||||
|
import 'prismjs/components/prism-nasm.js';
|
||||||
|
import 'prismjs/components/prism-neon.js';
|
||||||
|
import 'prismjs/components/prism-nevod.js';
|
||||||
|
import 'prismjs/components/prism-nginx.js';
|
||||||
|
import 'prismjs/components/prism-nim.js';
|
||||||
|
import 'prismjs/components/prism-nix.js';
|
||||||
|
import 'prismjs/components/prism-nsis.js';
|
||||||
|
import 'prismjs/components/prism-objectivec.js';
|
||||||
|
import 'prismjs/components/prism-ocaml.js';
|
||||||
|
import 'prismjs/components/prism-odin.js';
|
||||||
|
import 'prismjs/components/prism-opencl.js';
|
||||||
|
import 'prismjs/components/prism-openqasm.js';
|
||||||
|
import 'prismjs/components/prism-oz.js';
|
||||||
|
import 'prismjs/components/prism-parigp.js';
|
||||||
|
import 'prismjs/components/prism-parser.js';
|
||||||
|
import 'prismjs/components/prism-pascal.js';
|
||||||
|
import 'prismjs/components/prism-pascaligo.js';
|
||||||
|
import 'prismjs/components/prism-pcaxis.js';
|
||||||
|
import 'prismjs/components/prism-peoplecode.js';
|
||||||
|
import 'prismjs/components/prism-perl.js';
|
||||||
|
import 'prismjs/components/prism-php-extras.js';
|
||||||
|
import 'prismjs/components/prism-php.js';
|
||||||
|
import 'prismjs/components/prism-phpdoc.js';
|
||||||
|
import 'prismjs/components/prism-plant-uml.js';
|
||||||
|
import 'prismjs/components/prism-powerquery.js';
|
||||||
|
import 'prismjs/components/prism-powershell.js';
|
||||||
|
import 'prismjs/components/prism-processing.js';
|
||||||
|
import 'prismjs/components/prism-prolog.js';
|
||||||
|
import 'prismjs/components/prism-promql.js';
|
||||||
|
import 'prismjs/components/prism-properties.js';
|
||||||
|
import 'prismjs/components/prism-protobuf.js';
|
||||||
|
import 'prismjs/components/prism-psl.js';
|
||||||
|
import 'prismjs/components/prism-pug.js';
|
||||||
|
import 'prismjs/components/prism-puppet.js';
|
||||||
|
import 'prismjs/components/prism-pure.js';
|
||||||
|
import 'prismjs/components/prism-purebasic.js';
|
||||||
|
import 'prismjs/components/prism-purescript.js';
|
||||||
|
import 'prismjs/components/prism-python.js';
|
||||||
|
import 'prismjs/components/prism-q.js';
|
||||||
|
import 'prismjs/components/prism-qml.js';
|
||||||
|
import 'prismjs/components/prism-qore.js';
|
||||||
|
import 'prismjs/components/prism-qsharp.js';
|
||||||
|
import 'prismjs/components/prism-r.js';
|
||||||
|
import 'prismjs/components/prism-reason.js';
|
||||||
|
import 'prismjs/components/prism-regex.js';
|
||||||
|
import 'prismjs/components/prism-rego.js';
|
||||||
|
import 'prismjs/components/prism-renpy.js';
|
||||||
|
import 'prismjs/components/prism-rescript.js';
|
||||||
|
import 'prismjs/components/prism-rest.js';
|
||||||
|
import 'prismjs/components/prism-rip.js';
|
||||||
|
import 'prismjs/components/prism-roboconf.js';
|
||||||
|
import 'prismjs/components/prism-robotframework.js';
|
||||||
|
import 'prismjs/components/prism-ruby.js';
|
||||||
|
import 'prismjs/components/prism-rust.js';
|
||||||
|
import 'prismjs/components/prism-sas.js';
|
||||||
|
import 'prismjs/components/prism-sass.js';
|
||||||
|
import 'prismjs/components/prism-scala.js';
|
||||||
|
import 'prismjs/components/prism-scheme.js';
|
||||||
|
import 'prismjs/components/prism-scss.js';
|
||||||
|
import 'prismjs/components/prism-shell-session.js';
|
||||||
|
import 'prismjs/components/prism-smali.js';
|
||||||
|
import 'prismjs/components/prism-smalltalk.js';
|
||||||
|
import 'prismjs/components/prism-smarty.js';
|
||||||
|
import 'prismjs/components/prism-sml.js';
|
||||||
|
import 'prismjs/components/prism-solidity.js';
|
||||||
|
import 'prismjs/components/prism-solution-file.js';
|
||||||
|
import 'prismjs/components/prism-soy.js';
|
||||||
|
import 'prismjs/components/prism-splunk-spl.js';
|
||||||
|
import 'prismjs/components/prism-sqf.js';
|
||||||
|
import 'prismjs/components/prism-sql.js';
|
||||||
|
import 'prismjs/components/prism-squirrel.js';
|
||||||
|
import 'prismjs/components/prism-stan.js';
|
||||||
|
import 'prismjs/components/prism-stata.js';
|
||||||
|
import 'prismjs/components/prism-stylus.js';
|
||||||
|
import 'prismjs/components/prism-supercollider.js';
|
||||||
|
import 'prismjs/components/prism-swift.js';
|
||||||
|
import 'prismjs/components/prism-systemd.js';
|
||||||
|
import 'prismjs/components/prism-t4-templating.js';
|
||||||
|
import 'prismjs/components/prism-t4-vb.js';
|
||||||
|
import 'prismjs/components/prism-tap.js';
|
||||||
|
import 'prismjs/components/prism-tcl.js';
|
||||||
|
import 'prismjs/components/prism-textile.js';
|
||||||
|
import 'prismjs/components/prism-toml.js';
|
||||||
|
import 'prismjs/components/prism-tremor.js';
|
||||||
|
import 'prismjs/components/prism-tsx.js';
|
||||||
|
import 'prismjs/components/prism-tt2.js';
|
||||||
|
import 'prismjs/components/prism-turtle.js';
|
||||||
|
import 'prismjs/components/prism-twig.js';
|
||||||
|
import 'prismjs/components/prism-typescript.js';
|
||||||
|
import 'prismjs/components/prism-typoscript.js';
|
||||||
|
import 'prismjs/components/prism-unrealscript.js';
|
||||||
|
import 'prismjs/components/prism-uorazor.js';
|
||||||
|
import 'prismjs/components/prism-uri.js';
|
||||||
|
import 'prismjs/components/prism-v.js';
|
||||||
|
import 'prismjs/components/prism-vala.js';
|
||||||
|
import 'prismjs/components/prism-vbnet.js';
|
||||||
|
import 'prismjs/components/prism-velocity.js';
|
||||||
|
import 'prismjs/components/prism-verilog.js';
|
||||||
|
import 'prismjs/components/prism-vhdl.js';
|
||||||
|
import 'prismjs/components/prism-vim.js';
|
||||||
|
import 'prismjs/components/prism-visual-basic.js';
|
||||||
|
import 'prismjs/components/prism-warpscript.js';
|
||||||
|
import 'prismjs/components/prism-wasm.js';
|
||||||
|
import 'prismjs/components/prism-web-idl.js';
|
||||||
|
import 'prismjs/components/prism-wgsl.js';
|
||||||
|
import 'prismjs/components/prism-wiki.js';
|
||||||
|
import 'prismjs/components/prism-wolfram.js';
|
||||||
|
import 'prismjs/components/prism-wren.js';
|
||||||
|
import 'prismjs/components/prism-xeora.js';
|
||||||
|
import 'prismjs/components/prism-xml-doc.js';
|
||||||
|
import 'prismjs/components/prism-xojo.js';
|
||||||
|
import 'prismjs/components/prism-xquery.js';
|
||||||
|
import 'prismjs/components/prism-yaml.js';
|
||||||
|
import 'prismjs/components/prism-yang.js';
|
||||||
|
import 'prismjs/components/prism-zig.js';
|
||||||
|
import 'prismjs/components/prism-arduino.js';
|
||||||
|
|
||||||
|
// Broken:
|
||||||
|
//
|
||||||
|
// import 'prismjs/components/prism-bison.js';
|
||||||
|
// import 'prismjs/components/prism-chaiscript.js';
|
||||||
|
// import 'prismjs/components/prism-core.js';
|
||||||
|
// import 'prismjs/components/prism-crystal.js';
|
||||||
|
// import 'prismjs/components/prism-django.js';
|
||||||
|
// import 'prismjs/components/prism-javadoc.js';
|
||||||
|
// import 'prismjs/components/prism-jsdoc.js';
|
||||||
|
// import 'prismjs/components/prism-plsql.js';
|
||||||
|
// import 'prismjs/components/prism-racket.js';
|
||||||
|
// import 'prismjs/components/prism-sparql.js';
|
||||||
|
// import 'prismjs/components/prism-t4-cs.js';
|
||||||
|
|
||||||
import './ReactPrism.css';
|
import './ReactPrism.css';
|
||||||
// using classNames .prism-dark .prism-light from ReactPrism.css
|
// using classNames .prism-dark .prism-light from ReactPrism.css
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
|
|
||||||
const NAV_TO_ACTIVE_PATH = 'navToActivePath';
|
const NAV_TO_ACTIVE_PATH = 'navToActivePath';
|
||||||
|
|
||||||
|
const getStoreKey = (userId: string): string => `${NAV_TO_ACTIVE_PATH}${userId}`;
|
||||||
|
|
||||||
type NavToActivePath = Map<string, Path>;
|
type NavToActivePath = Map<string, Path>;
|
||||||
|
|
||||||
type NavToActivePathAction =
|
type NavToActivePathAction =
|
||||||
@@ -25,7 +27,7 @@ type NavToActivePathAction =
|
|||||||
export type NavToActivePathAtom = WritableAtom<NavToActivePath, [NavToActivePathAction], undefined>;
|
export type NavToActivePathAtom = WritableAtom<NavToActivePath, [NavToActivePathAction], undefined>;
|
||||||
|
|
||||||
export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom => {
|
export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom => {
|
||||||
const storeKey = `${NAV_TO_ACTIVE_PATH}${userId}`;
|
const storeKey = getStoreKey(userId);
|
||||||
|
|
||||||
const baseNavToActivePathAtom = atomWithLocalStorage<NavToActivePath>(
|
const baseNavToActivePathAtom = atomWithLocalStorage<NavToActivePath>(
|
||||||
storeKey,
|
storeKey,
|
||||||
@@ -64,3 +66,7 @@ export const makeNavToActivePathAtom = (userId: string): NavToActivePathAtom =>
|
|||||||
|
|
||||||
return navToActivePathAtom;
|
return navToActivePathAtom;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearNavToActivePathStore = (userId: string) => {
|
||||||
|
localStorage.removeItem(getStoreKey(userId));
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
const STORAGE_KEY = 'settings';
|
const STORAGE_KEY = 'settings';
|
||||||
|
export type DateFormat = 'D MMM YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY/MM/DD' | '';
|
||||||
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
|
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
|
||||||
export enum MessageLayout {
|
export enum MessageLayout {
|
||||||
Modern = 0,
|
Modern = 0,
|
||||||
@@ -35,6 +36,9 @@ export interface Settings {
|
|||||||
showNotifications: boolean;
|
showNotifications: boolean;
|
||||||
isNotificationSounds: boolean;
|
isNotificationSounds: boolean;
|
||||||
|
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
|
|
||||||
developerTools: boolean;
|
developerTools: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +69,9 @@ const defaultSettings: Settings = {
|
|||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
isNotificationSounds: true,
|
isNotificationSounds: true,
|
||||||
|
|
||||||
|
hour24Clock: false,
|
||||||
|
dateFormatString: 'D MMM YYYY',
|
||||||
|
|
||||||
developerTools: false,
|
developerTools: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -41,16 +41,19 @@ export const BlockQuote = style([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const BaseCode = style({
|
const BaseCode = style({
|
||||||
fontFamily: 'monospace',
|
color: color.SurfaceVariant.OnContainer,
|
||||||
color: color.Secondary.OnContainer,
|
background: color.SurfaceVariant.Container,
|
||||||
background: color.Secondary.Container,
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
border: `${config.borderWidth.B300} solid ${color.Secondary.ContainerLine}`,
|
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
});
|
});
|
||||||
|
const CodeFont = style({
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
});
|
||||||
|
|
||||||
export const Code = style([
|
export const Code = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
BaseCode,
|
BaseCode,
|
||||||
|
CodeFont,
|
||||||
{
|
{
|
||||||
padding: `0 ${config.space.S100}`,
|
padding: `0 ${config.space.S100}`,
|
||||||
},
|
},
|
||||||
@@ -85,10 +88,32 @@ export const CodeBlock = style([
|
|||||||
MarginSpaced,
|
MarginSpaced,
|
||||||
{
|
{
|
||||||
fontStyle: 'normal',
|
fontStyle: 'normal',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
export const CodeBlockInternal = style({
|
export const CodeBlockHeader = style({
|
||||||
padding: `${config.space.S200} ${config.space.S200} 0`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
gap: config.space.S200,
|
||||||
|
});
|
||||||
|
export const CodeBlockInternal = style([
|
||||||
|
CodeFont,
|
||||||
|
{
|
||||||
|
padding: `${config.space.S200} ${config.space.S200} 0`,
|
||||||
|
minWidth: toRem(200),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const CodeBlockBottomShadow = style({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
|
||||||
|
height: config.space.S400,
|
||||||
|
background: `linear-gradient(to top, #00000022, #00000000)`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const List = style([
|
export const List = style([
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/;
|
|||||||
|
|
||||||
export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName);
|
export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName);
|
||||||
|
|
||||||
export const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])(.+):(\S+)$/);
|
const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@$+#])(.+):(\S+)$/);
|
||||||
|
|
||||||
export const validMxId = (id: string): boolean => !!matchMxId(id);
|
const validMxId = (id: string): boolean => !!matchMxId(id);
|
||||||
|
|
||||||
export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3];
|
export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3];
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export const getMxIdLocalPart = (userId: string): string | undefined => matchMxI
|
|||||||
|
|
||||||
export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@');
|
export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@');
|
||||||
|
|
||||||
export const isRoomId = (id: string): boolean => validMxId(id) && id.startsWith('!');
|
export const isRoomId = (id: string): boolean => id.startsWith('!');
|
||||||
|
|
||||||
export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');
|
export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');
|
||||||
|
|
||||||
@@ -50,7 +50,11 @@ export const getCanonicalAliasOrRoomId = (mx: MatrixClient, roomId: string): str
|
|||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return roomId;
|
if (!room) return roomId;
|
||||||
if (getStateEvent(room, StateEvent.RoomTombstone) !== undefined) return roomId;
|
if (getStateEvent(room, StateEvent.RoomTombstone) !== undefined) return roomId;
|
||||||
return room.getCanonicalAlias() || roomId;
|
const alias = room.getCanonicalAlias();
|
||||||
|
if (alias && getCanonicalAliasRoomId(mx, alias) === roomId) {
|
||||||
|
return alias;
|
||||||
|
}
|
||||||
|
return roomId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => {
|
export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => {
|
||||||
@@ -300,25 +304,32 @@ export const downloadEncryptedMedia = async (
|
|||||||
|
|
||||||
export const rateLimitedActions = async <T, R = void>(
|
export const rateLimitedActions = async <T, R = void>(
|
||||||
data: T[],
|
data: T[],
|
||||||
callback: (item: T) => Promise<R>,
|
callback: (item: T, index: number) => Promise<R>,
|
||||||
maxRetryCount?: number
|
maxRetryCount?: number
|
||||||
) => {
|
) => {
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
const performAction = async (dataItem: T) => {
|
|
||||||
const [err] = await to<R, MatrixError>(callback(dataItem));
|
let actionInterval = 0;
|
||||||
|
|
||||||
|
const sleepForMs = (ms: number) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
const performAction = async (dataItem: T, index: number) => {
|
||||||
|
const [err] = await to<R, MatrixError>(callback(dataItem, index));
|
||||||
|
|
||||||
if (err?.httpStatus === 429) {
|
if (err?.httpStatus === 429) {
|
||||||
if (retryCount === maxRetryCount) {
|
if (retryCount === maxRetryCount) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const waitMS = err.getRetryAfterMs() ?? 200;
|
const waitMS = err.getRetryAfterMs() ?? 3000;
|
||||||
await new Promise((resolve) => {
|
actionInterval = waitMS * 1.5;
|
||||||
setTimeout(resolve, waitMS);
|
await sleepForMs(waitMS);
|
||||||
});
|
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
|
|
||||||
await performAction(dataItem);
|
await performAction(dataItem, index);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -326,6 +337,10 @@ export const rateLimitedActions = async <T, R = void>(
|
|||||||
const dataItem = data[i];
|
const dataItem = data[i];
|
||||||
retryCount = 0;
|
retryCount = 0;
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await performAction(dataItem);
|
await performAction(dataItem, i);
|
||||||
|
if (actionInterval > 0) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await sleepForMs(actionInterval);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
EventTimelineSet,
|
EventTimelineSet,
|
||||||
EventType,
|
EventType,
|
||||||
IMentions,
|
IMentions,
|
||||||
|
IPowerLevelsContent,
|
||||||
IPushRule,
|
IPushRule,
|
||||||
IPushRules,
|
IPushRules,
|
||||||
JoinRule,
|
JoinRule,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||||
import {
|
import {
|
||||||
|
Membership,
|
||||||
MessageEvent,
|
MessageEvent,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
RoomToParents,
|
RoomToParents,
|
||||||
@@ -171,7 +173,7 @@ export const getNotificationType = (mx: MatrixClient, roomId: string): Notificat
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!roomPushRule) {
|
if (!roomPushRule) {
|
||||||
const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
|
const overrideRules = mx.getAccountData(EventType.PushRules)?.getContent<IPushRules>()
|
||||||
?.global?.override;
|
?.global?.override;
|
||||||
if (!overrideRules) return NotificationType.Default;
|
if (!overrideRules) return NotificationType.Default;
|
||||||
|
|
||||||
@@ -292,9 +294,14 @@ export const getDirectRoomAvatarUrl = (
|
|||||||
useAuthentication = false
|
useAuthentication = false
|
||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl();
|
const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl();
|
||||||
return mxcUrl
|
|
||||||
? mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined
|
if (!mxcUrl) {
|
||||||
: undefined;
|
return getRoomAvatarUrl(mx, room, size, useAuthentication);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const trimReplyFromBody = (body: string): string => {
|
export const trimReplyFromBody = (body: string): string => {
|
||||||
@@ -443,3 +450,72 @@ export const getMentionContent = (userIds: string[], room: boolean): IMentions =
|
|||||||
|
|
||||||
return mMentions;
|
return mMentions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCommonRooms = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
rooms: string[],
|
||||||
|
otherUserId: string
|
||||||
|
): string[] => {
|
||||||
|
const commonRooms: string[] = [];
|
||||||
|
|
||||||
|
rooms.forEach((roomId) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room || room.getMyMembership() !== Membership.Join) return;
|
||||||
|
|
||||||
|
const common = room.hasMembershipState(otherUserId, Membership.Join);
|
||||||
|
if (common) {
|
||||||
|
commonRooms.push(roomId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return commonRooms;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bannedInRooms = (mx: MatrixClient, rooms: string[], otherUserId: string): boolean =>
|
||||||
|
rooms.some((roomId) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room || room.getMyMembership() !== Membership.Join) return false;
|
||||||
|
|
||||||
|
const banned = room.hasMembershipState(otherUserId, Membership.Ban);
|
||||||
|
return banned;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const guessPerfectParent = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
parents: string[]
|
||||||
|
): string | undefined => {
|
||||||
|
if (parents.length === 1) {
|
||||||
|
return parents[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSpecialUsers = (rId: string): string[] => {
|
||||||
|
const r = mx.getRoom(rId);
|
||||||
|
const powerLevels =
|
||||||
|
r && getStateEvent(r, StateEvent.RoomPowerLevels)?.getContent<IPowerLevelsContent>();
|
||||||
|
|
||||||
|
const { users_default: usersDefault, users } = powerLevels ?? {};
|
||||||
|
if (typeof users !== 'object') return [];
|
||||||
|
|
||||||
|
const defaultPower = typeof usersDefault === 'number' ? usersDefault : 0;
|
||||||
|
return Object.keys(users).filter((userId) => users[userId] > defaultPower);
|
||||||
|
};
|
||||||
|
|
||||||
|
let perfectParent: string | undefined;
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
const roomSpecialUsers = getSpecialUsers(roomId);
|
||||||
|
parents.forEach((parentId) => {
|
||||||
|
const parentSpecialUsers = getSpecialUsers(parentId);
|
||||||
|
const matchedUsersCount = parentSpecialUsers.filter((userId) =>
|
||||||
|
roomSpecialUsers.includes(userId)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (matchedUsersCount > score) {
|
||||||
|
score = matchedUsersCount;
|
||||||
|
perfectParent = parentId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return perfectParent;
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,12 +9,29 @@ export const today = (ts: number): boolean => dayjs(ts).isToday();
|
|||||||
|
|
||||||
export const yesterday = (ts: number): boolean => dayjs(ts).isYesterday();
|
export const yesterday = (ts: number): boolean => dayjs(ts).isYesterday();
|
||||||
|
|
||||||
export const timeHourMinute = (ts: number): string => dayjs(ts).format('hh:mm A');
|
export const timeHour = (ts: number, hour24Clock: boolean): string =>
|
||||||
|
dayjs(ts).format(hour24Clock ? 'HH' : 'hh');
|
||||||
|
export const timeMinute = (ts: number): string => dayjs(ts).format('mm');
|
||||||
|
export const timeAmPm = (ts: number): string => dayjs(ts).format('A');
|
||||||
|
export const timeDay = (ts: number): string => dayjs(ts).format('D');
|
||||||
|
export const timeMon = (ts: number): string => dayjs(ts).format('MMM');
|
||||||
|
export const timeMonth = (ts: number): string => dayjs(ts).format('MMMM');
|
||||||
|
export const timeYear = (ts: number): string => dayjs(ts).format('YYYY');
|
||||||
|
|
||||||
export const timeDayMonYear = (ts: number): string => dayjs(ts).format('D MMM YYYY');
|
export const timeHourMinute = (ts: number, hour24Clock: boolean): string =>
|
||||||
|
dayjs(ts).format(hour24Clock ? 'HH:mm' : 'hh:mm A');
|
||||||
|
|
||||||
|
export const timeDayMonYear = (ts: number, dateFormatString: string): string =>
|
||||||
|
dayjs(ts).format(dateFormatString);
|
||||||
|
|
||||||
export const timeDayMonthYear = (ts: number): string => dayjs(ts).format('D MMMM YYYY');
|
export const timeDayMonthYear = (ts: number): string => dayjs(ts).format('D MMMM YYYY');
|
||||||
|
|
||||||
|
export const daysInMonth = (month: number, year: number): number =>
|
||||||
|
dayjs(`${year}-${month}-1`).daysInMonth();
|
||||||
|
|
||||||
|
export const dateFor = (year: number, month: number, day: number): number =>
|
||||||
|
dayjs(`${year}-${month}-${day}`).valueOf();
|
||||||
|
|
||||||
export const inSameDay = (ts1: number, ts2: number): boolean => {
|
export const inSameDay = (ts1: number, ts2: number): boolean => {
|
||||||
const dt1 = new Date(ts1);
|
const dt1 = new Date(ts1);
|
||||||
const dt2 = new Date(ts2);
|
const dt2 = new Date(ts2);
|
||||||
@@ -33,3 +50,37 @@ export const minuteDifference = (ts1: number, ts2: number): number => {
|
|||||||
diff /= 60;
|
diff /= 60;
|
||||||
return Math.abs(Math.round(diff));
|
return Math.abs(Math.round(diff));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const hour24to12 = (hour24: number): number => {
|
||||||
|
const h = hour24 % 12;
|
||||||
|
|
||||||
|
if (h === 0) return 12;
|
||||||
|
return h;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hour12to24 = (hour: number, pm: boolean): number => {
|
||||||
|
if (hour === 12) {
|
||||||
|
return pm ? 12 : 0;
|
||||||
|
}
|
||||||
|
return pm ? hour + 12 : hour;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const secondsToMs = (seconds: number) => seconds * 1000;
|
||||||
|
|
||||||
|
export const minutesToMs = (minutes: number) => minutes * secondsToMs(60);
|
||||||
|
|
||||||
|
export const hoursToMs = (hour: number) => hour * minutesToMs(60);
|
||||||
|
|
||||||
|
export const daysToMs = (days: number) => days * hoursToMs(24);
|
||||||
|
|
||||||
|
export const getToday = () => {
|
||||||
|
const nowTs = Date.now();
|
||||||
|
const date = dayjs(nowTs);
|
||||||
|
return dateFor(date.year(), date.month() + 1, date.date());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getYesterday = () => {
|
||||||
|
const nowTs = Date.now() - daysToMs(1);
|
||||||
|
const date = dayjs(nowTs);
|
||||||
|
return dateFor(date.year(), date.month() + 1, date.date());
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function addRoomToMDirect(mx, roomId, userId) {
|
|||||||
const mDirectsEvent = mx.getAccountData('m.direct');
|
const mDirectsEvent = mx.getAccountData('m.direct');
|
||||||
let userIdToRoomIds = {};
|
let userIdToRoomIds = {};
|
||||||
|
|
||||||
if (typeof mDirectsEvent !== 'undefined') userIdToRoomIds = mDirectsEvent.getContent();
|
if (typeof mDirectsEvent !== 'undefined') userIdToRoomIds = structuredClone(mDirectsEvent.getContent());
|
||||||
|
|
||||||
// remove it from the lists of any others users
|
// remove it from the lists of any others users
|
||||||
// (it can only be a DM room for one person)
|
// (it can only be a DM room for one person)
|
||||||
@@ -93,11 +93,8 @@ function convertToRoom(mx, roomId) {
|
|||||||
* @param {string[]} via
|
* @param {string[]} via
|
||||||
*/
|
*/
|
||||||
async function join(mx, roomIdOrAlias, isDM = false, via = undefined) {
|
async function join(mx, roomIdOrAlias, isDM = false, via = undefined) {
|
||||||
const roomIdParts = roomIdOrAlias.split(':');
|
|
||||||
const viaServers = via || [roomIdParts[1]];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resultRoom = await mx.joinRoom(roomIdOrAlias, { viaServers });
|
const resultRoom = await mx.joinRoom(roomIdOrAlias, { viaServers: via });
|
||||||
|
|
||||||
if (isDM) {
|
if (isDM) {
|
||||||
const targetUserId = guessDMRoomTargetId(mx.getRoom(resultRoom.roomId), mx.getUserId());
|
const targetUserId = guessDMRoomTargetId(mx.getRoom(resultRoom.roomId), mx.getUserId());
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk';
|
import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk';
|
||||||
|
|
||||||
import { cryptoCallbacks } from './state/secretStorageKeys';
|
import { cryptoCallbacks } from './state/secretStorageKeys';
|
||||||
|
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
|
||||||
|
|
||||||
type Session = {
|
type Session = {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@@ -46,6 +47,7 @@ export const startClient = async (mx: MatrixClient) => {
|
|||||||
|
|
||||||
export const clearCacheAndReload = async (mx: MatrixClient) => {
|
export const clearCacheAndReload = async (mx: MatrixClient) => {
|
||||||
mx.stopClient();
|
mx.stopClient();
|
||||||
|
clearNavToActivePathStore(mx.getSafeUserId());
|
||||||
await mx.store.deleteAllData();
|
await mx.store.deleteAllData();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const cons = {
|
const cons = {
|
||||||
version: '4.7.1',
|
version: '4.8.1',
|
||||||
secretKey: {
|
secretKey: {
|
||||||
ACCESS_TOKEN: 'cinny_access_token',
|
ACCESS_TOKEN: 'cinny_access_token',
|
||||||
DEVICE_ID: 'cinny_device_id',
|
DEVICE_ID: 'cinny_device_id',
|
||||||
|
|||||||
Reference in New Issue
Block a user