Compare commits

..

15 commits

Author SHA1 Message Date
6c5b8a1d14
Merge remote-tracking branch 'upstream/dev' 2024-08-20 11:03:13 +08:00
greentore
830d05e217
Add basic m.thread support (#1349)
* Add basic `m.thread` support

* Fix types

* Update to v4

* Fix auto formatting mess

* Add threaded reply indicators

* Fix reply overflow

* Fix replying to edited threaded replies

* Add thread indicator to room input

* Fix editing encrypted events

* Use `toRem` function for converting units

---------

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2024-08-15 20:22:32 +05:30
dependabot[bot]
7e7bee8f48
Bump actions/upload-artifact from 4.3.4 to 4.3.6 (#1890)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.3.4 to 4.3.6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.3.4...v4.3.6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-14 23:38:35 +10:00
aceArt-GmbH
ac1797344c
Add translation support using i18next (#1576)
Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
2024-08-14 18:59:34 +05:30
dependabot[bot]
b4ce8a7cab
Bump docker/build-push-action from 6.5.0 to 6.6.1 (#1891)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.5.0 to 6.6.1.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6.5.0...v6.6.1)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-14 23:21:11 +10:00
Krishan
e68c56b334
Release v4.1.0 (#1867) 2024-08-04 20:15:10 +10:00
Ajay Bura
cabfdd47b5
fix type to focus not working after room switch (#1866) 2024-08-04 16:04:11 +10:00
dependabot[bot]
cfe893f358
Bump docker/setup-buildx-action from 3.5.0 to 3.6.1 (#1850)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.5.0 to 3.6.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.5.0...v3.6.1)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-04 15:38:47 +10:00
Ajay Bura
581211f13e
fix crash when decoding malformed urls (#1865) 2024-08-04 15:38:20 +10:00
Ajay Bura
8ed78d48fb
fix notification not working for selected room (#1864) 2024-08-04 15:37:28 +10:00
Ajay Bura
96222de5bc
fix page up/down button not working (#1863) 2024-08-04 15:36:42 +10:00
Ajay Bura
681287c46a
show unverified tab indicator on sidebar (#1862) 2024-08-04 14:19:37 +10:00
Ajay Bura
9cb5c70d51
add back btn for mobile view (#1861) 2024-08-03 23:47:53 +10:00
Krishan
c62050445b
Fix typo in readme 2024-08-01 23:45:22 +10:00
Krishan
a8f5a6c2f4
update self deploy instructions after react router (#1859)
* update self deploy instructions after react router

* List the alternative

* docs to deploy on subdir
2024-08-01 19:12:45 +05:30
48 changed files with 875 additions and 269 deletions

View file

@ -25,7 +25,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096' NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build run: npm run build
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4.3.4 uses: actions/upload-artifact@v4.3.6
with: with:
name: preview name: preview
path: dist path: dist
@ -33,7 +33,7 @@ jobs:
- name: Save pr number - name: Save pr number
run: echo ${PR_NUMBER} > ./pr.txt run: echo ${PR_NUMBER} > ./pr.txt
- name: Upload pr number - name: Upload pr number
uses: actions/upload-artifact@v4.3.4 uses: actions/upload-artifact@v4.3.6
with: with:
name: pr name: pr
path: ./pr.txt path: ./pr.txt

View file

@ -13,7 +13,7 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@v6.5.0 uses: docker/build-push-action@v6.6.1
with: with:
context: . context: .
push: false push: false

View file

@ -70,7 +70,7 @@ jobs:
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0 uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.5.0 uses: docker/setup-buildx-action@v3.6.1
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3.3.0 uses: docker/login-action@v3.3.0
with: with:
@ -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.5.0 uses: docker/build-push-action@v6.6.1
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View file

@ -19,22 +19,24 @@ A Matrix client focusing primarily on simple, elegant and secure interface. The
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380"> <img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
## Getting started ## Getting started
Web app is available at https://app.cinny.in and gets updated on each new release. The `dev` branch is continuously deployed at https://dev.cinny.in but keep in mind that it could have things broken. * Web app is available at https://app.cinny.in and gets updated on each new release. The `dev` branch is continuously deployed at https://dev.cinny.in but keep in mind that it could have things broken.
You can also download our desktop app from [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop). * You can also download our desktop app from [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
To host Cinny on your own, download tarball of the app from [GitHub release](https://github.com/cinnyapp/cinny/releases/latest). * To host Cinny on your own, download tarball of the app from [GitHub release](https://github.com/cinnyapp/cinny/releases/latest).
You can serve the application with a webserver of your choice by simply copying `dist/` directory to the webroot. You can serve the application with a webserver of your choice by simply copying `dist/` directory to the webroot.
To set default Homeserver on login and register page, place a customized [`config.json`](config.json) in webroot of your choice. To set default Homeserver on login, register and Explore Community page, place a customized [`config.json`](config.json) in webroot of your choice.
You will also need to setup redirects to serve the assests. An example setting of redirects for netlify is done in [`netlify.toml`](netlify.toml). You can also set `hashRouter.enabled = true` in [`config.json`](config.json) if you have trouble setting redirects.
To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts). For example, if you want to deploy on `https://cinny.in/app`, then change `base: '/app'`.
Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by: * Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by:
``` ```
docker pull ajbura/cinny docker pull ajbura/cinny
``` ```
or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by: or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by:
``` ```
docker pull ghcr.io/cinnyapp/cinny:latest docker pull ghcr.io/cinnyapp/cinny:latest
``` ```
<details> <details>
<summary>PGP Public Key to verify tarball</summary> <summary>PGP Public Key to verify tarball</summary>

138
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "cinny", "name": "cinny",
"version": "4.0.3", "version": "4.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cinny", "name": "cinny",
"version": "4.0.3", "version": "4.1.0",
"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",
@ -37,6 +37,9 @@
"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",
"i18next": "23.12.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-http-backend": "2.5.2",
"immer": "9.0.16", "immer": "9.0.16",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.6.0", "jotai": "2.6.0",
@ -54,6 +57,7 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-error-boundary": "4.0.13", "react-error-boundary": "4.0.13",
"react-google-recaptcha": "2.1.0", "react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0",
"react-modal": "3.16.1", "react-modal": "3.16.1",
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.20.0", "react-router-dom": "6.20.0",
@ -438,11 +442,12 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.20.6", "version": "7.25.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz",
"integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", "integrity": "sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==",
"license": "MIT",
"dependencies": { "dependencies": {
"regenerator-runtime": "^0.13.11" "regenerator-runtime": "^0.14.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -467,11 +472,6 @@
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"dev": true "dev": true
}, },
"node_modules/@babel/runtime/node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.22.15", "version": "7.22.15",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
@ -6146,6 +6146,15 @@
"entities": "^4.5.0" "entities": "^4.5.0"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html-react-parser": { "node_modules/html-react-parser": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-4.2.0.tgz", "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-4.2.0.tgz",
@ -6191,6 +6200,76 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/i18next": {
"version": "23.12.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.12.2.tgz",
"integrity": "sha512-XIeh5V+bi8SJSWGL3jqbTEBW5oD6rbP5L+E7dVQh1MNTxxYef0x15rhJVcRb7oiuq4jLtgy2SD8eFlf6P2cmqg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.0.tgz",
"integrity": "sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-http-backend": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.5.2.tgz",
"integrity": "sha512-+K8HbDfrvc1/2X8jpb7RLhI9ZxBDpx3xogYkQwGKlWAUXLSEGXzgdt3EcUjLlBCdMwdQY+K+EUF6oh8oB6rwHw==",
"license": "MIT",
"dependencies": {
"cross-fetch": "4.0.0"
}
},
"node_modules/i18next-http-backend/node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/i18next-http-backend/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@ -7717,6 +7796,28 @@
"react": ">=16.4.1" "react": ">=16.4.1"
} }
}, },
"node_modules/react-i18next": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.0.0.tgz",
"integrity": "sha512-2O3IgF4zivg57Q6p6i+ChDgJ371IDcEWbuWC6gvoh5NbkDMs0Q+O7RPr4v61+Se32E0V+LmtwePAeqWZW0bi6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.24.8",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -7824,6 +7925,12 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": { "node_modules/regexp.prototype.flags": {
"version": "1.5.2", "version": "1.5.2",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz",
@ -9294,6 +9401,15 @@
"@esbuild/win32-x64": "0.19.12" "@esbuild/win32-x64": "0.19.12"
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/warning": { "node_modules/warning": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "cinny", "name": "cinny",
"version": "4.0.3", "version": "4.1.0",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@ -48,6 +48,9 @@
"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",
"i18next": "23.12.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-http-backend": "2.5.2",
"immer": "9.0.16", "immer": "9.0.16",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.6.0", "jotai": "2.6.0",
@ -65,6 +68,7 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-error-boundary": "4.0.13", "react-error-boundary": "4.0.13",
"react-google-recaptcha": "2.1.0", "react-google-recaptcha": "2.1.0",
"react-i18next": "15.0.0",
"react-modal": "3.16.1", "react-modal": "3.16.1",
"react-range": "1.8.14", "react-range": "1.8.14",
"react-router-dom": "6.20.0", "react-router-dom": "6.20.0",

7
public/locales/de.json Normal file
View file

@ -0,0 +1,7 @@
{
"Organisms": {
"RoomCommon": {
"changed_room_name": " hat den Raum Name geändert"
}
}
}

7
public/locales/en.json Normal file
View file

@ -0,0 +1,7 @@
{
"Organisms": {
"RoomCommon": {
"changed_room_name": " changed room name"
}
}
}

View file

@ -0,0 +1,86 @@
import { ReactNode, useCallback } from 'react';
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
import {
getDirectPath,
getExplorePath,
getHomePath,
getInboxPath,
getSpacePath,
} from '../pages/pathUtils';
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from '../pages/paths';
type BackRouteHandlerProps = {
children: (onBack: () => void) => ReactNode;
};
export function BackRouteHandler({ children }: BackRouteHandlerProps) {
const navigate = useNavigate();
const location = useLocation();
const goBack = useCallback(() => {
if (
matchPath(
{
path: HOME_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getHomePath());
return;
}
if (
matchPath(
{
path: DIRECT_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getDirectPath());
return;
}
const spaceMatch = matchPath(
{
path: SPACE_PATH,
caseSensitive: true,
end: false,
},
location.pathname
);
if (spaceMatch?.params.spaceIdOrAlias) {
navigate(getSpacePath(spaceMatch.params.spaceIdOrAlias));
return;
}
if (
matchPath(
{
path: EXPLORE_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getExplorePath());
return;
}
if (
matchPath(
{
path: INBOX_PATH,
caseSensitive: true,
end: false,
},
location.pathname
)
) {
navigate(getInboxPath());
}
}, [navigate, location]);
return children(goBack);
}

View file

@ -25,6 +25,7 @@ import {
parseMatrixToUser, parseMatrixToUser,
testMatrixTo, testMatrixTo,
} from '../../plugins/matrix-to'; } from '../../plugins/matrix-to';
import { tryDecodeURIComponent } from '../../utils/dom';
const markNodeToType: Record<string, MarkType> = { const markNodeToType: Record<string, MarkType> = {
b: MarkType.Bold, b: MarkType.Bold,
@ -73,7 +74,7 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
return createEmoticonElement(src, alt || 'Unknown Emoji'); return createEmoticonElement(src, alt || 'Unknown Emoji');
} }
if (node.name === 'a') { if (node.name === 'a') {
const href = decodeURIComponent(node.attribs.href); const href = tryDecodeURIComponent(node.attribs.href);
if (typeof href !== 'string') return undefined; if (typeof href !== 'string') return undefined;
if (testMatrixTo(href)) { if (testMatrixTo(href)) {
const userMention = parseMatrixToUser(href); const userMention = parseMatrixToUser(href);

View file

@ -5,6 +5,25 @@ export const ReplyBend = style({
flexShrink: 0, flexShrink: 0,
}); });
export const ThreadIndicator = style({
opacity: config.opacity.P300,
gap: toRem(2),
selectors: {
'button&': {
cursor: 'pointer',
},
':hover&': {
opacity: config.opacity.P500,
},
},
});
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,

View file

@ -1,7 +1,7 @@
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds'; import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import React, { ReactNode, useEffect, useMemo, useState } from 'react'; import React, { MouseEventHandler, ReactNode, useEffect, useMemo, useState } from 'react';
import to from 'await-to-js'; import to from 'await-to-js';
import classNames from 'classnames'; import classNames from 'classnames';
import colorMXID from '../../../util/colorMXID'; import colorMXID from '../../../util/colorMXID';
@ -22,6 +22,7 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
<Box <Box
className={classNames(css.Reply, className)} className={classNames(css.Reply, className)}
alignItems="Center" alignItems="Center"
alignSelf="Start"
gap="100" gap="100"
{...props} {...props}
ref={ref} ref={ref}
@ -37,16 +38,26 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
) )
); );
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
<Box className={css.ThreadIndicator} alignItems="Center" alignSelf="Start" {...props} ref={ref}>
<Icon className={css.ThreadIndicatorIcon} src={Icons.Message} />
<Text size="T200">Threaded reply</Text>
</Box>
));
type ReplyProps = { type ReplyProps = {
mx: MatrixClient; mx: MatrixClient;
room: Room; room: Room;
timelineSet?: EventTimelineSet; timelineSet?: EventTimelineSet | undefined;
eventId: string; replyEventId: string;
threadRootId?: string | undefined;
onClick?: MouseEventHandler | undefined;
}; };
export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ...props }, ref) => { export const Reply = as<'div', ReplyProps>((_, ref) => {
const { mx, room, timelineSet, replyEventId, threadRootId, onClick, ...props } = _;
const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>( const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
timelineSet?.findEventById(eventId) timelineSet?.findEventById(replyEventId)
); );
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []); const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
@ -62,7 +73,7 @@ export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ..
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
const loadEvent = async () => { const loadEvent = async () => {
const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId)); const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, replyEventId));
const mEvent = new MatrixEvent(evt); const mEvent = new MatrixEvent(evt);
if (disposed) return; if (disposed) return;
if (err) { if (err) {
@ -78,13 +89,18 @@ export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ..
return () => { return () => {
disposed = true; disposed = true;
}; };
}, [replyEvent, mx, room, eventId]); }, [replyEvent, mx, room, replyEventId]);
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted'; const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
return ( return (
<Box direction="Column" {...props} ref={ref}>
{threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
)}
<ReplyLayout <ReplyLayout
as="button"
userColor={sender ? colorMXID(sender) : undefined} userColor={sender ? colorMXID(sender) : undefined}
username={ username={
sender && ( sender && (
@ -93,8 +109,8 @@ export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ..
</Text> </Text>
) )
} }
{...props} data-event-id={replyEventId}
ref={ref} onClick={onClick}
> >
{replyEvent !== undefined ? ( {replyEvent !== undefined ? (
<Text size="T300" truncate> <Text size="T300" truncate>
@ -110,5 +126,6 @@ export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, ..
/> />
)} )}
</ReplyLayout> </ReplyLayout>
</Box>
); );
}); });

View file

@ -87,15 +87,17 @@ export const Page = as<'div'>(({ className, ...props }, ref) => (
/> />
)); ));
export const PageHeader = as<'div'>(({ className, ...props }, ref) => ( export const PageHeader = as<'div', css.PageHeaderVariants>(
({ className, balance, ...props }, ref) => (
<Header <Header
as="header" as="header"
size="600" size="600"
className={classNames(css.PageHeader, className)} className={classNames(css.PageHeader({ balance }), className)}
{...props} {...props}
ref={ref} ref={ref}
/> />
)); )
);
export const PageContent = as<'div'>(({ className, ...props }, ref) => ( 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} />

View file

@ -1,4 +1,5 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds'; import { DefaultReset, color, config, toRem } from 'folds';
export const PageNav = style({ export const PageNav = style({
@ -33,11 +34,21 @@ export const PageNavContent = style({
paddingBottom: config.space.S700, paddingBottom: config.space.S700,
}); });
export const PageHeader = style({ export const PageHeader = recipe({
base: {
paddingLeft: config.space.S400, paddingLeft: config.space.S400,
paddingRight: config.space.S200, paddingRight: config.space.S200,
borderBottomWidth: config.borderWidth.B300, borderBottomWidth: config.borderWidth.B300,
},
variants: {
balance: {
true: {
paddingLeft: config.space.S200,
},
},
},
}); });
export type PageHeaderVariants = RecipeVariants<typeof PageHeader>;
export const PageContent = style([ export const PageContent = style([
DefaultReset, DefaultReset,

View file

@ -9,6 +9,7 @@ import {
useIntersectionObserver, useIntersectionObserver,
} from '../../hooks/useIntersectionObserver'; } from '../../hooks/useIntersectionObserver';
import * as css from './UrlPreviewCard.css'; import * as css from './UrlPreviewCard.css';
import { tryDecodeURIComponent } from '../../utils/dom';
const linkStyles = { color: color.Success.Main }; const linkStyles = { color: color.Success.Main };
@ -43,7 +44,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
priority="300" priority="300"
> >
{typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `} {typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `}
{decodeURIComponent(url)} {tryDecodeURIComponent(url)}
</Text> </Text>
<Text truncate priority="400"> <Text truncate priority="400">
<b>{prev['og:title']}</b> <b>{prev['og:title']}</b>

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Box, Scroll, Text, toRem } from 'folds'; import { Box, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { RoomCard } from '../../components/room-card'; import { RoomCard } from '../../components/room-card';
import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { RoomTopicViewer } from '../../components/room-topic-viewer';
@ -8,6 +8,8 @@ import { RoomSummaryLoader } from '../../components/RoomSummaryLoader';
import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../hooks/useRoomNavigate';
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 { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { BackRouteHandler } from '../../components/BackRouteHandler';
type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] }; type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
export function JoinBeforeNavigate({ export function JoinBeforeNavigate({
@ -18,6 +20,7 @@ export function JoinBeforeNavigate({
const mx = useMatrixClient(); const mx = useMatrixClient();
const allRooms = useAtomValue(allRoomsAtom); const allRooms = useAtomValue(allRoomsAtom);
const { navigateRoom, navigateSpace } = useRoomNavigate(); const { navigateRoom, navigateSpace } = useRoomNavigate();
const screenSize = useScreenSizeContext();
const handleView = (roomId: string) => { const handleView = (roomId: string) => {
if (mx.getRoom(roomId)?.isSpaceRoom()) { if (mx.getRoom(roomId)?.isSpaceRoom()) {
@ -29,12 +32,25 @@ export function JoinBeforeNavigate({
return ( return (
<Page> <Page>
<PageHeader> <PageHeader balance>
<Box grow="Yes" gap="200">
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200"> <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<Text size="H3" truncate> <Text size="H3" truncate>
{roomIdOrAlias} {roomIdOrAlias}
</Text> </Text>
</Box> </Box>
</Box>
</PageHeader> </PageHeader>
<Box grow="Yes"> <Box grow="Yes">
<Scroll hideTrack visibility="Hover" size="0"> <Scroll hideTrack visibility="Hover" size="0">

View file

@ -31,6 +31,8 @@ import { IPowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt'; import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { BackRouteHandler } from '../../components/BackRouteHandler';
type LobbyMenuProps = { type LobbyMenuProps = {
roomId: string; roomId: string;
@ -123,6 +125,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
const space = useSpace(); const space = useSpace();
const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer');
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const screenSize = useScreenSizeContext();
const name = useRoomName(space); const name = useRoomName(space);
const avatarMxc = useRoomAvatar(space); const avatarMxc = useRoomAvatar(space);
@ -133,8 +136,29 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
}; };
return ( return (
<PageHeader className={showProfile ? undefined : css.Header}> <PageHeader className={showProfile ? undefined : css.Header} balance>
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
{screenSize === ScreenSize.Mobile ? (
<>
<Box shrink="No">
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
</Box>
<Box grow="Yes" justifyContent="Center">
{showProfile && (
<Text size="H3" truncate>
{name}
</Text>
)}
</Box>
</>
) : (
<>
<Box grow="Yes" basis="No" /> <Box grow="Yes" basis="No" />
<Box justifyContent="Center" alignItems="Center" gap="300"> <Box justifyContent="Center" alignItems="Center" gap="300">
{showProfile && ( {showProfile && (
@ -153,7 +177,15 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
</> </>
)} )}
</Box> </Box>
<Box shrink="No" grow="Yes" basis="No" justifyContent="End"> </>
)}
<Box
shrink="No"
grow={screenSize === ScreenSize.Mobile ? 'No' : 'Yes'}
basis={screenSize === ScreenSize.Mobile ? 'Yes' : 'No'}
justifyContent="End"
>
{screenSize !== ScreenSize.Mobile && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"
offset={4} offset={4}
@ -169,6 +201,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
</IconButton> </IconButton>
)} )}
</TooltipProvider> </TooltipProvider>
)}
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"
align="End" align="End"

View file

@ -148,7 +148,7 @@ export function SearchResultGroup({
} }
); );
const handleOpenClick: MouseEventHandler<HTMLButtonElement> = (evt) => { const handleOpenClick: MouseEventHandler = (evt) => {
const eventId = evt.currentTarget.getAttribute('data-event-id'); const eventId = evt.currentTarget.getAttribute('data-event-id');
if (!eventId) return; if (!eventId) return;
onOpen(room.roomId, eventId); onOpen(room.roomId, eventId);
@ -183,15 +183,16 @@ export function SearchResultGroup({
event.sender; event.sender;
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
const relation = event.content['m.relates_to'];
const mainEventId = const mainEventId =
event.content['m.relates_to']?.rel_type === RelationType.Replace relation?.rel_type === RelationType.Replace ? relation.event_id : event.event_id;
? event.content['m.relates_to'].event_id
: event.event_id;
const getContent = (() => const getContent = (() =>
event.content['m.new_content'] ?? event.content) as GetContentCallback; event.content['m.new_content'] ?? event.content) as GetContentCallback;
const replyEventId = event.content['m.relates_to']?.['m.in_reply_to']?.event_id; const replyEventId = relation?.['m.in_reply_to']?.event_id;
const threadRootId =
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
return ( return (
<SequenceCard <SequenceCard
@ -240,11 +241,10 @@ export function SearchResultGroup({
</Box> </Box>
{replyEventId && ( {replyEventId && (
<Reply <Reply
as="button"
mx={mx} mx={mx}
room={room} room={room}
eventId={replyEventId} replyEventId={replyEventId}
data-event-id={replyEventId} threadRootId={threadRootId}
onClick={handleOpenClick} onClick={handleOpenClick}
/> />
)} )}

View file

@ -13,6 +13,7 @@ import { useKeyDown } from '../../hooks/useKeyDown';
import { markAsRead } from '../../../client/action/notifications'; import { markAsRead } from '../../../client/action/notifications';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers'; import { useRoomMembers } from '../../hooks/useRoomMembers';
import { editableActiveElement } from '../../utils/dom';
export function Room() { export function Room() {
const { eventId } = useParams(); const { eventId } = useParams();
@ -28,7 +29,7 @@ export function Room() {
window, window,
useCallback( useCallback(
(evt) => { (evt) => {
if (isKeyHotkey('escape', evt)) { if (isKeyHotkey('escape', evt) && !editableActiveElement()) {
markAsRead(mx, room.roomId); markAsRead(mx, room.roomId);
} }
}, },

View file

@ -10,7 +10,7 @@ import React, {
} from 'react'; } from 'react';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk'; import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate'; import { Transforms, Editor } from 'slate';
import { import {
@ -106,7 +106,7 @@ import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, useCommands } from '../../hooks/useCommands'; import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent'; import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { ReplyLayout } from '../../components/message'; import { ReplyLayout, ThreadIndicator } from '../../components/message';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
interface RoomInputProps { interface RoomInputProps {
@ -186,9 +186,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
Transforms.insertFragment(editor, msgDraft); Transforms.insertFragment(editor, msgDraft);
}, [editor, msgDraft]); }, [editor, msgDraft]);
useEffect(() => { useEffect(
if (!mobileOrTablet()) ReactEditor.focus(editor); () => () => {
return () => {
if (!isEmptyEditor(editor)) { if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children)); const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft); setMsgDraft(parsedDraft);
@ -197,8 +196,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} }
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
}; },
}, [roomId, editor, setMsgDraft]); [roomId, editor, setMsgDraft]
);
const handleRemoveUpload = useCallback( const handleRemoveUpload = useCallback(
(upload: TUploadContent | TUploadContent[]) => { (upload: TUploadContent | TUploadContent[]) => {
@ -312,6 +312,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
event_id: replyDraft.eventId, event_id: replyDraft.eventId,
}, },
}; };
if (replyDraft.relation?.rel_type === RelationType.Thread) {
content['m.relates_to'].event_id = replyDraft.relation.event_id;
content['m.relates_to'].rel_type = RelationType.Thread;
content['m.relates_to'].is_falling_back = false;
}
} }
await mx.sendMessage(roomId, content); await mx.sendMessage(roomId, content);
resetEditor(editor); resetEditor(editor);
@ -491,6 +496,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
> >
<Icon src={Icons.Cross} size="50" /> <Icon src={Icons.Cross} size="50" />
</IconButton> </IconButton>
<Box direction="Column">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout <ReplyLayout
userColor={colorMXID(replyDraft.userId)} userColor={colorMXID(replyDraft.userId)}
username={ username={
@ -508,6 +515,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Text> </Text>
</ReplyLayout> </ReplyLayout>
</Box> </Box>
</Box>
</div> </div>
) )
} }

View file

@ -16,6 +16,7 @@ import {
EventTimeline, EventTimeline,
EventTimelineSet, EventTimelineSet,
EventTimelineSetHandlerMap, EventTimelineSetHandlerMap,
IContent,
IEncryptedFile, IEncryptedFile,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
@ -46,6 +47,7 @@ import {
} from 'folds'; } from 'folds';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs'; import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next';
import { import {
decryptFile, decryptFile,
eventWithShortcode, eventWithShortcode,
@ -836,13 +838,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
markAsRead(mx, room.roomId); markAsRead(mx, room.roomId);
}; };
const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback( const handleOpenReply: MouseEventHandler = useCallback(
async (evt) => { async (evt) => {
const replyId = evt.currentTarget.getAttribute('data-reply-id'); const targetId = evt.currentTarget.getAttribute('data-event-id');
if (typeof replyId !== 'string') return; if (!targetId) return;
const replyTimeline = getEventTimeline(room, replyId); const replyTimeline = getEventTimeline(room, targetId);
const absoluteIndex = const absoluteIndex =
replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, replyId); replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, targetId);
if (typeof absoluteIndex === 'number') { if (typeof absoluteIndex === 'number') {
scrollToItem(absoluteIndex, { scrollToItem(absoluteIndex, {
@ -857,7 +859,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}); });
} else { } else {
setTimeline(getEmptyTimeline()); setTimeline(getEmptyTimeline());
loadEventTimeline(replyId); loadEventTimeline(targetId);
} }
}, },
[room, timeline, scrollToItem, loadEventTimeline] [room, timeline, scrollToItem, loadEventTimeline]
@ -908,8 +910,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const replyEvt = room.findEventById(replyId); const replyEvt = room.findEventById(replyId);
if (!replyEvt) return; if (!replyEvt) return;
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
const { body, formatted_body: formattedBody }: Record<string, string> = const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); const { body, formatted_body: formattedBody } = content;
const { 'm.relates_to': relation } = replyEvt.getOriginalContent();
const senderId = replyEvt.getSender(); const senderId = replyEvt.getSender();
if (senderId && typeof body === 'string') { if (senderId && typeof body === 'string') {
setReplyDraft({ setReplyDraft({
@ -917,6 +920,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
eventId: replyId, eventId: replyId,
body, body,
formattedBody, formattedBody,
relation,
}); });
setTimeout(() => ReactEditor.focus(editor), 100); setTimeout(() => ReactEditor.focus(editor), 100);
} }
@ -958,6 +962,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
}, },
[editor] [editor]
); );
const { t } = useTranslation();
const renderMatrixEvent = useMatrixEventRenderer< const renderMatrixEvent = useMatrixEventRenderer<
[string, MatrixEvent, number, EventTimelineSet, boolean] [string, MatrixEvent, number, EventTimelineSet, boolean]
@ -967,7 +972,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const reactionRelations = getEventReactions(timelineSet, mEventId); const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0; const hasReactions = reactions && reactions.length > 0;
const { replyEventId } = mEvent; const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
@ -1002,12 +1007,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={ reply={
replyEventId && ( replyEventId && (
<Reply <Reply
as="button"
mx={mx} mx={mx}
room={room} room={room}
timelineSet={timelineSet} timelineSet={timelineSet}
eventId={replyEventId} replyEventId={replyEventId}
data-reply-id={replyEventId} threadRootId={threadRootId}
onClick={handleOpenReply} onClick={handleOpenReply}
/> />
) )
@ -1048,7 +1052,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const reactionRelations = getEventReactions(timelineSet, mEventId); const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
const hasReactions = reactions && reactions.length > 0; const hasReactions = reactions && reactions.length > 0;
const { replyEventId } = mEvent; const { replyEventId, threadRootId } = mEvent;
const highlighted = focusItem?.index === item && focusItem.highlight; const highlighted = focusItem?.index === item && focusItem.highlight;
return ( return (
@ -1075,12 +1079,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
reply={ reply={
replyEventId && ( replyEventId && (
<Reply <Reply
as="button"
mx={mx} mx={mx}
room={room} room={room}
timelineSet={timelineSet} timelineSet={timelineSet}
eventId={replyEventId} replyEventId={replyEventId}
data-reply-id={replyEventId} threadRootId={threadRootId}
onClick={handleOpenReply} onClick={handleOpenReply}
/> />
) )
@ -1273,7 +1276,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<Text size="T300" priority="300"> <Text size="T300" priority="300">
<b>{senderName}</b> <b>{senderName}</b>
{' changed room name'} {t('Organisms.RoomCommon.changed_room_name')}
</Text> </Text>
</Box> </Box>
} }

View file

@ -25,6 +25,7 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
if (evt.metaKey || evt.altKey || evt.ctrlKey) { if (evt.metaKey || evt.altKey || evt.ctrlKey) {
return false; return false;
} }
// do not focus on F keys // do not focus on F keys
if (/^F\d+$/.test(code)) return false; if (/^F\d+$/.test(code)) return false;
@ -36,6 +37,9 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
code.startsWith('Alt') || code.startsWith('Alt') ||
code.startsWith('Control') || code.startsWith('Control') ||
code.startsWith('Arrow') || code.startsWith('Arrow') ||
code.startsWith('Page') ||
code.startsWith('End') ||
code.startsWith('Home') ||
code === 'Tab' || code === 'Tab' ||
code === 'Space' || code === 'Space' ||
code === 'Enter' || code === 'Enter' ||

View file

@ -52,6 +52,7 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to'; import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { BackRouteHandler } from '../../components/BackRouteHandler';
type RoomMenuProps = { type RoomMenuProps = {
room: Room; room: Room;
@ -203,19 +204,36 @@ export function RoomViewHeader() {
}; };
return ( return (
<PageHeader> <PageHeader balance={screenSize === ScreenSize.Mobile}>
<Box grow="Yes" gap="300"> <Box grow="Yes" gap="300">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<Box shrink="No" alignItems="Center">
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
</Box>
)}
</BackRouteHandler>
)}
<Box grow="Yes" alignItems="Center" gap="300"> <Box grow="Yes" alignItems="Center" gap="300">
{screenSize !== ScreenSize.Mobile && (
<Avatar size="300"> <Avatar size="300">
<RoomAvatar <RoomAvatar
roomId={room.roomId} roomId={room.roomId}
src={avatarUrl} src={avatarUrl}
alt={name} alt={name}
renderFallback={() => ( renderFallback={() => (
<RoomIcon size="200" joinRule={room.getJoinRule() ?? JoinRule.Restricted} filled /> <RoomIcon
size="200"
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
filled
/>
)} )}
/> />
</Avatar> </Avatar>
)}
<Box direction="Column"> <Box direction="Column">
<Text size={topic ? 'H5' : 'H3'} truncate> <Text size={topic ? 'H5' : 'H3'} truncate>
{name} {name}

View file

@ -1,31 +0,0 @@
/* eslint-disable import/prefer-default-export */
import { useState, useEffect } from 'react';
import { useMatrixClient } from './useMatrixClient';
export function useDeviceList() {
const mx = useMatrixClient();
const [deviceList, setDeviceList] = useState(null);
useEffect(() => {
let isMounted = true;
const updateDevices = () => mx.getDevices().then((data) => {
if (!isMounted) return;
setDeviceList(data.devices || []);
});
updateDevices();
const handleDevicesUpdate = (users) => {
if (users.includes(mx.getUserId())) {
updateDevices();
}
};
mx.on('crypto.devicesUpdated', handleDevicesUpdate);
return () => {
mx.removeListener('crypto.devicesUpdated', handleDevicesUpdate);
isMounted = false;
};
}, [mx]);
return deviceList;
}

View file

@ -0,0 +1,35 @@
/* eslint-disable import/prefer-default-export */
import { useState, useEffect } from 'react';
import { CryptoEvent, IMyDevice } from 'matrix-js-sdk';
import { CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto';
import { useMatrixClient } from './useMatrixClient';
export function useDeviceList() {
const mx = useMatrixClient();
const [deviceList, setDeviceList] = useState<IMyDevice[] | null>(null);
useEffect(() => {
let isMounted = true;
const updateDevices = () =>
mx.getDevices().then((data) => {
if (!isMounted) return;
setDeviceList(data.devices || []);
});
updateDevices();
const handleDevicesUpdate: CryptoEventHandlerMap[CryptoEvent.DevicesUpdated] = (users) => {
const userId = mx.getUserId();
if (userId && users.includes(userId)) {
updateDevices();
}
};
mx.on(CryptoEvent.DevicesUpdated, handleDevicesUpdate);
return () => {
mx.removeListener(CryptoEvent.DevicesUpdated, handleDevicesUpdate);
isMounted = false;
};
}, [mx]);
return deviceList;
}

31
src/app/i18n.ts Normal file
View file

@ -0,0 +1,31 @@
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend, { HttpBackendOptions } from 'i18next-http-backend';
import { initReactI18next } from 'react-i18next';
import { trimTrailingSlash } from './utils/common';
i18n
// i18next-http-backend
// loads translations from your server
// https://github.com/i18next/i18next-http-backend
.use(Backend)
// detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector
.use(LanguageDetector)
// pass the i18n instance to react-i18next.
.use(initReactI18next)
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init<HttpBackendOptions>({
debug: false,
fallbackLng: 'en',
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
load: 'languageOnly',
backend: {
loadPath: `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/locales/{{lng}}.json`,
},
});
export default i18n;

View file

@ -15,7 +15,7 @@ export function AuthFooter() {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
v4.0.3 v4.1.0
</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

View file

@ -29,6 +29,7 @@ import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo';
import { AuthFlowsLoader } from '../../components/AuthFlowsLoader'; import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
import { AuthFlowsProvider } from '../../hooks/useAuthFlows'; import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { AuthServerProvider } from '../../hooks/useAuthServer'; import { AuthServerProvider } from '../../hooks/useAuthServer';
import { tryDecodeURIComponent } from '../../utils/dom';
const currentAuthPath = (pathname: string): string => { const currentAuthPath = (pathname: string): string => {
if (matchPath(LOGIN_PATH, pathname)) { if (matchPath(LOGIN_PATH, pathname)) {
@ -72,7 +73,7 @@ export function AuthLayout() {
const clientConfig = useClientConfig(); const clientConfig = useClientConfig();
const defaultServer = clientDefaultServer(clientConfig); const defaultServer = clientDefaultServer(clientConfig);
let server: string = urlEncodedServer ? decodeURIComponent(urlEncodedServer) : defaultServer; let server: string = urlEncodedServer ? tryDecodeURIComponent(urlEncodedServer) : defaultServer;
if (!clientAllowedServer(clientConfig, server)) { if (!clientAllowedServer(clientConfig, server)) {
server = defaultServer; server = defaultServer;
@ -94,7 +95,7 @@ export function AuthLayout() {
// if server is mismatches with path server, update path // if server is mismatches with path server, update path
useEffect(() => { useEffect(() => {
if (!urlEncodedServer || decodeURIComponent(urlEncodedServer) !== server) { if (!urlEncodedServer || tryDecodeURIComponent(urlEncodedServer) !== server) {
navigate( navigate(
generatePath(currentAuthPath(location.pathname), { generatePath(currentAuthPath(location.pathname), {
server: encodeURIComponent(server), server: encodeURIComponent(server),

View file

@ -183,17 +183,17 @@ function MessageNotifications() {
removed, removed,
data data
) => { ) => {
if (mx.getSyncState() !== 'SYNCING') return;
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
if ( if (
mx.getSyncState() !== 'SYNCING' ||
selectedRoomId === room?.roomId ||
notificationSelected ||
!room || !room ||
!data.liveEvent || !data.liveEvent ||
room.isSpaceRoom() || room.isSpaceRoom() ||
!isNotificationEvent(mEvent) || !isNotificationEvent(mEvent) ||
getNotificationType(mx, room.roomId) === NotificationType.Mute getNotificationType(mx, room.roomId) === NotificationType.Mute
) ) {
return; return;
}
const sender = mEvent.getSender(); const sender = mEvent.getSender();
const eventId = mEvent.getId(); const eventId = mEvent.getId();

View file

@ -10,7 +10,15 @@ import {
SidebarItemTooltip, SidebarItemTooltip,
SidebarItem, SidebarItem,
} from '../../components/sidebar'; } from '../../components/sidebar';
import { DirectTab, HomeTab, SpaceTabs, InboxTab, ExploreTab, UserTab } from './sidebar'; import {
DirectTab,
HomeTab,
SpaceTabs,
InboxTab,
ExploreTab,
UserTab,
UnverifiedTab,
} from './sidebar';
import { openCreateRoom, openSearch } from '../../../client/action/navigation'; import { openCreateRoom, openSearch } from '../../../client/action/navigation';
export function SidebarNav() { export function SidebarNav() {
@ -65,6 +73,8 @@ export function SidebarNav() {
</SidebarItemTooltip> </SidebarItemTooltip>
</SidebarItem> </SidebarItem>
<UnverifiedTab />
<InboxTab /> <InboxTab />
<UserTab /> <UserTab />
</SidebarStack> </SidebarStack>

View file

@ -24,7 +24,7 @@ export function WelcomePage() {
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
v4.0.3 v4.1.0
</a> </a>
</span> </span>
} }

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Box, Icon, Icons, Scroll, Text } from 'folds'; import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useClientConfig } from '../../../hooks/useClientConfig'; import { useClientConfig } from '../../../hooks/useClientConfig';
import { RoomCard, RoomCardGrid } from '../../../components/room-card'; import { RoomCard, RoomCardGrid } from '../../../components/room-card';
@ -9,21 +9,38 @@ import {
Page, Page,
PageContent, PageContent,
PageContentCenter, PageContentCenter,
PageHeader,
PageHero, PageHero,
PageHeroSection, PageHeroSection,
} from '../../../components/page'; } from '../../../components/page';
import { RoomTopicViewer } from '../../../components/room-topic-viewer'; import { RoomTopicViewer } from '../../../components/room-topic-viewer';
import * as css from './style.css'; import * as css from './style.css';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
export function FeaturedRooms() { export function FeaturedRooms() {
const { featuredCommunities } = useClientConfig(); const { featuredCommunities } = useClientConfig();
const { rooms, spaces } = featuredCommunities ?? {}; const { rooms, spaces } = featuredCommunities ?? {};
const allRooms = useAtomValue(allRoomsAtom); const allRooms = useAtomValue(allRoomsAtom);
const screenSize = useScreenSizeContext();
const { navigateSpace, navigateRoom } = useRoomNavigate(); const { navigateSpace, navigateRoom } = useRoomNavigate();
return ( return (
<Page> <Page>
{screenSize === ScreenSize.Mobile && (
<PageHeader>
<Box shrink="No">
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
</Box>
</PageHeader>
)}
<Box grow="Yes"> <Box grow="Yes">
<Scroll hideTrack visibility="Hover"> <Scroll hideTrack visibility="Hover">
<PageContent> <PageContent>

View file

@ -13,6 +13,7 @@ import {
Button, Button,
Chip, Chip,
Icon, Icon,
IconButton,
Icons, Icons,
Input, Input,
Line, Line,
@ -42,6 +43,8 @@ import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { getMxIdServer } from '../../../utils/matrix'; import { getMxIdServer } from '../../../utils/matrix';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams => const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams =>
useMemo( useMemo(
@ -344,6 +347,7 @@ export function PublicRooms() {
const userServer = userId && getMxIdServer(userId); const userServer = userId && getMxIdServer(userId);
const allRooms = useAtomValue(allRoomsAtom); const allRooms = useAtomValue(allRoomsAtom);
const { navigateSpace, navigateRoom } = useRoomNavigate(); const { navigateSpace, navigateRoom } = useRoomNavigate();
const screenSize = useScreenSizeContext();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const serverSearchParams = useServerSearchParams(searchParams); const serverSearchParams = useServerSearchParams(searchParams);
@ -466,7 +470,7 @@ export function PublicRooms() {
return ( return (
<Page> <Page>
<PageHeader> <PageHeader balance>
{isSearch ? ( {isSearch ? (
<> <>
<Box grow="Yes" basis="No"> <Box grow="Yes" basis="No">
@ -482,20 +486,34 @@ export function PublicRooms() {
</Box> </Box>
<Box grow="No" justifyContent="Center" alignItems="Center" gap="200"> <Box grow="No" justifyContent="Center" alignItems="Center" gap="200">
<Icon size="400" src={Icons.Search} /> {screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Search} />}
<Text size="H3" truncate> <Text size="H3" truncate>
Search Search
</Text> </Text>
</Box> </Box>
<Box grow="Yes" /> <Box grow="Yes" basis="No" />
</> </>
) : ( ) : (
<>
<Box grow="Yes" basis="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200"> <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<Icon size="400" src={Icons.Category} /> {screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Category} />}
<Text size="H3" truncate> <Text size="H3" truncate>
{server} {server}
</Text> </Text>
</Box> </Box>
<Box grow="Yes" basis="No" />
</>
)} )}
</PageHeader> </PageHeader>
<Box grow="Yes"> <Box grow="Yes">

View file

@ -1,22 +1,39 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { Box, Icon, Icons, Text, Scroll } from 'folds'; import { Box, Icon, Icons, Text, Scroll, IconButton } from 'folds';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page'; import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { MessageSearch } from '../../../features/message-search'; import { MessageSearch } from '../../../features/message-search';
import { useHomeRooms } from './useHomeRooms'; import { useHomeRooms } from './useHomeRooms';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
export function HomeSearch() { export function HomeSearch() {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const rooms = useHomeRooms(); const rooms = useHomeRooms();
const screenSize = useScreenSizeContext();
return ( return (
<Page> <Page>
<PageHeader> <PageHeader balance>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
<Icon size="400" src={Icons.Search} /> <Box grow="Yes" basis="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box justifyContent="Center" alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Search} />}
<Text size="H3" truncate> <Text size="H3" truncate>
Message Search Message Search
</Text> </Text>
</Box> </Box>
<Box grow="Yes" basis="No" />
</Box>
</PageHeader> </PageHeader>
<Box style={{ position: 'relative' }} grow="Yes"> <Box style={{ position: 'relative' }} grow="Yes">
<Scroll ref={scrollRef} hideTrack visibility="Hover"> <Scroll ref={scrollRef} hideTrack visibility="Hover">

View file

@ -4,6 +4,7 @@ import {
Box, Box,
Button, Button,
Icon, Icon,
IconButton,
Icons, Icons,
Overlay, Overlay,
OverlayBackdrop, OverlayBackdrop,
@ -39,6 +40,8 @@ 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 { useRoomTopic } from '../../../hooks/useRoomMeta';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
const COMPACT_CARD_WIDTH = 548; const COMPACT_CARD_WIDTH = 548;
@ -205,6 +208,7 @@ export function Invites() {
useCallback(() => containerRef.current, []), useCallback(() => containerRef.current, []),
useCallback((width) => setCompact(width <= COMPACT_CARD_WIDTH), []) useCallback((width) => setCompact(width <= COMPACT_CARD_WIDTH), [])
); );
const screenSize = useScreenSizeContext();
const { navigateRoom, navigateSpace } = useRoomNavigate(); const { navigateRoom, navigateSpace } = useRoomNavigate();
@ -225,13 +229,27 @@ export function Invites() {
return ( return (
<Page> <Page>
<PageHeader> <PageHeader balance>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200"> <Box grow="Yes" gap="200">
<Icon size="400" src={Icons.Mail} /> <Box grow="Yes" basis="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Mail} />}
<Text size="H3" truncate> <Text size="H3" truncate>
Invitations Invitations
</Text> </Text>
</Box> </Box>
<Box grow="Yes" basis="No" />
</Box>
</PageHeader> </PageHeader>
<Box grow="Yes"> <Box grow="Yes">
<Scroll hideTrack visibility="Hover"> <Scroll hideTrack visibility="Hover">

View file

@ -20,6 +20,7 @@ import {
IRoomEvent, IRoomEvent,
JoinRule, JoinRule,
Method, Method,
RelationType,
Room, Room,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
@ -78,6 +79,8 @@ import { UserAvatar } from '../../../components/user-avatar';
import { EncryptedContent } from '../../../features/room/message'; import { EncryptedContent } from '../../../features/room/message';
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler'; import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler'; import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
type RoomNotificationsGroup = { type RoomNotificationsGroup = {
roomId: string; roomId: string;
@ -350,7 +353,7 @@ function RoomNotificationsGroupComp({
} }
); );
const handleOpenClick: MouseEventHandler<HTMLButtonElement> = (evt) => { const handleOpenClick: MouseEventHandler = (evt) => {
const eventId = evt.currentTarget.getAttribute('data-event-id'); const eventId = evt.currentTarget.getAttribute('data-event-id');
if (!eventId) return; if (!eventId) return;
onOpen(room.roomId, eventId); onOpen(room.roomId, eventId);
@ -401,7 +404,10 @@ function RoomNotificationsGroupComp({
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
const getContent = (() => event.content) as GetContentCallback; const getContent = (() => event.content) as GetContentCallback;
const replyEventId = event.content['m.relates_to']?.['m.in_reply_to']?.event_id; const relation = event.content['m.relates_to'];
const replyEventId = relation?.['m.in_reply_to']?.event_id;
const threadRootId =
relation?.rel_type === RelationType.Thread ? relation.event_id : undefined;
return ( return (
<SequenceCard <SequenceCard
@ -450,11 +456,10 @@ function RoomNotificationsGroupComp({
</Box> </Box>
{replyEventId && ( {replyEventId && (
<Reply <Reply
as="button"
mx={mx} mx={mx}
room={room} room={room}
eventId={replyEventId} replyEventId={replyEventId}
data-event-id={replyEventId} threadRootId={threadRootId}
onClick={handleOpenClick} onClick={handleOpenClick}
/> />
)} )}
@ -484,6 +489,7 @@ export function Notifications() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const screenSize = useScreenSizeContext();
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -549,13 +555,27 @@ export function Notifications() {
return ( return (
<Page> <Page>
<PageHeader> <PageHeader balance>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200"> <Box grow="Yes" gap="200">
<Icon size="400" src={Icons.Message} /> <Box grow="Yes" basis="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Message} />}
<Text size="H3" truncate> <Text size="H3" truncate>
Notification Messages Notification Messages
</Text> </Text>
</Box> </Box>
<Box grow="Yes" basis="No" />
</Box>
</PageHeader> </PageHeader>
<Box style={{ position: 'relative' }} grow="Yes"> <Box style={{ position: 'relative' }} grow="Yes">

View file

@ -0,0 +1,24 @@
import { keyframes, style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
const pushRight = keyframes({
from: {
transform: `translateX(${toRem(2)}) scale(1)`,
},
to: {
transform: 'translateX(0) scale(1)',
},
});
export const UnverifiedTab = style({
animationName: pushRight,
animationDuration: '400ms',
animationIterationCount: 30,
animationDirection: 'alternate',
});
export const UnverifiedAvatar = style({
backgroundColor: color.Critical.Container,
color: color.Critical.OnContainer,
borderColor: color.Critical.ContainerLine,
});

View file

@ -0,0 +1,49 @@
import React from 'react';
import { Badge, color, Icon, Icons, Text } from 'folds';
import { openSettings } from '../../../../client/action/navigation';
import { isCrossVerified } from '../../../../util/matrixUtil';
import {
SidebarAvatar,
SidebarItem,
SidebarItemBadge,
SidebarItemTooltip,
} from '../../../components/sidebar';
import { useDeviceList } from '../../../hooks/useDeviceList';
import { tabText } from '../../../organisms/settings/Settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import * as css from './UnverifiedTab.css';
export function UnverifiedTab() {
const mx = useMatrixClient();
const deviceList = useDeviceList();
const unverified = deviceList?.filter(
(device) => isCrossVerified(mx, device.device_id) === false
);
if (!unverified?.length) return null;
return (
<SidebarItem className={css.UnverifiedTab}>
<SidebarItemTooltip tooltip="Unverified Sessions">
{(triggerRef) => (
<SidebarAvatar
className={css.UnverifiedAvatar}
as="button"
ref={triggerRef}
outlined
onClick={() => openSettings(tabText.SECURITY)}
>
<Icon style={{ color: color.Critical.Main }} src={Icons.ShieldUser} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
<SidebarItemBadge hasCount>
<Badge variant="Critical" size="400" fill="Solid" radii="Pill" outlined={false}>
<Text as="span" size="L400">
{unverified.length}
</Text>
</Badge>
</SidebarItemBadge>
</SidebarItem>
);
}

View file

@ -4,3 +4,4 @@ export * from './SpaceTabs';
export * from './InboxTab'; export * from './InboxTab';
export * from './ExploreTab'; export * from './ExploreTab';
export * from './UserTab'; export * from './UserTab';
export * from './UnverifiedTab';

View file

@ -1,5 +1,5 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { Box, Icon, Icons, Text, Scroll } from 'folds'; import { Box, Icon, Icons, Text, Scroll, IconButton } from 'folds';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page'; import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { MessageSearch } from '../../../features/message-search'; import { MessageSearch } from '../../../features/message-search';
@ -9,11 +9,14 @@ import { allRoomsAtom } from '../../../state/room-list/roomList';
import { mDirectAtom } from '../../../state/mDirectList'; import { mDirectAtom } from '../../../state/mDirectList';
import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
export function SpaceSearch() { export function SpaceSearch() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const space = useSpace(); const space = useSpace();
const screenSize = useScreenSizeContext();
const mDirects = useAtomValue(mDirectAtom); const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
@ -25,13 +28,27 @@ export function SpaceSearch() {
return ( return (
<Page> <Page>
<PageHeader> <PageHeader balance>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
<Icon size="400" src={Icons.Search} /> <Box grow="Yes" basis="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box justifyContent="Center" alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Search} />}
<Text size="H3" truncate> <Text size="H3" truncate>
Message Search Message Search
</Text> </Text>
</Box> </Box>
<Box grow="Yes" basis="No" />
</Box>
</PageHeader> </PageHeader>
<Box style={{ position: 'relative' }} grow="Yes"> <Box style={{ position: 'relative' }} grow="Yes">
<Scroll ref={scrollRef} hideTrack visibility="Hover"> <Scroll ref={scrollRef} hideTrack visibility="Hover">

View file

@ -26,6 +26,7 @@ import {
testMatrixTo, testMatrixTo,
} from './matrix-to'; } from './matrix-to';
import { onEnterOrSpace } from '../utils/keyboard'; import { onEnterOrSpace } from '../utils/keyboard';
import { tryDecodeURIComponent } from '../utils/dom';
const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
@ -134,8 +135,8 @@ export const factoryRenderLinkifyWithMention = (
attributes, attributes,
content, content,
}) => { }) => {
if (tagName === 'a' && testMatrixTo(decodeURIComponent(attributes.href))) { if (tagName === 'a' && testMatrixTo(tryDecodeURIComponent(attributes.href))) {
const mention = mentionRender(decodeURIComponent(attributes.href)); const mention = mentionRender(tryDecodeURIComponent(attributes.href));
if (mention) return mention; if (mention) return mention;
} }
@ -334,11 +335,11 @@ export const getReactCustomHtmlParser = (
} }
} }
if (name === 'a' && testMatrixTo(decodeURIComponent(props.href))) { if (name === 'a' && testMatrixTo(tryDecodeURIComponent(props.href))) {
const mention = renderMatrixMention( const mention = renderMatrixMention(
mx, mx,
roomId, roomId,
decodeURIComponent(props.href), tryDecodeURIComponent(props.href),
makeMentionCustomProps(params.handleMentionClick) makeMentionCustomProps(params.handleMentionClick)
); );
if (mention) return mention; if (mention) return mention;

View file

@ -2,6 +2,7 @@ import { atom } from 'jotai';
import { atomFamily } from 'jotai/utils'; import { atomFamily } from 'jotai/utils';
import { Descendant } from 'slate'; import { Descendant } from 'slate';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { IEventRelation } from 'matrix-js-sdk';
import { TListAtom, createListAtom } from '../list'; import { TListAtom, createListAtom } from '../list';
import { createUploadAtomFamily } from '../upload'; import { createUploadAtomFamily } from '../upload';
import { TUploadContent } from '../../utils/matrix'; import { TUploadContent } from '../../utils/matrix';
@ -39,7 +40,8 @@ export type IReplyDraft = {
userId: string; userId: string;
eventId: string; eventId: string;
body: string; body: string;
formattedBody?: string; formattedBody?: string | undefined;
relation?: IEventRelation | undefined;
}; };
const createReplyDraftAtom = () => atom<IReplyDraft | undefined>(undefined); const createReplyDraftAtom = () => atom<IReplyDraft | undefined>(undefined);
export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>; export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>;

View file

@ -196,3 +196,11 @@ export const setFavicon = (url: string): void => {
if (!favicon) return; if (!favicon) return;
favicon.setAttribute('href', url); favicon.setAttribute('href', url);
}; };
export const tryDecodeURIComponent = (encodedURIComponent: string): string => {
try {
return decodeURIComponent(encodedURIComponent);
} catch {
return encodedURIComponent;
}
};

View file

@ -389,13 +389,18 @@ export const getEditedEvent = (
return edits && getLatestEdit(mEvent, edits.getRelations()); return edits && getLatestEdit(mEvent, edits.getRelations());
}; };
export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) => export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) => {
const content = mEvent.getContent();
const relationType = content['m.relates_to']?.rel_type;
return (
mEvent.getSender() === mx.getUserId() && mEvent.getSender() === mx.getUserId() &&
!mEvent.isRelation() && (!relationType || relationType === RelationType.Thread) &&
mEvent.getType() === MessageEvent.RoomMessage && mEvent.getType() === MessageEvent.RoomMessage &&
(mEvent.getContent().msgtype === MsgType.Text || (content.msgtype === MsgType.Text ||
mEvent.getContent().msgtype === MsgType.Emote || content.msgtype === MsgType.Emote ||
mEvent.getContent().msgtype === MsgType.Notice); content.msgtype === MsgType.Notice)
);
};
export const getLatestEditableEvt = ( export const getLatestEditableEvt = (
timeline: EventTimeline, timeline: EventTimeline,

View file

@ -1,5 +1,5 @@
const cons = { const cons = {
version: '4.0.3', version: '4.1.0',
secretKey: { secretKey: {
ACCESS_TOKEN: 'cinny_access_token', ACCESS_TOKEN: 'cinny_access_token',
DEVICE_ID: 'cinny_device_id', DEVICE_ID: 'cinny_device_id',

View file

@ -14,6 +14,9 @@ import settings from './client/state/settings';
import App from './app/pages/App'; import App from './app/pages/App';
// import i18n (needs to be bundled ;))
import './app/i18n';
document.body.classList.add(configClass, varsClass); document.body.classList.add(configClass, varsClass);
settings.applyTheme(); settings.applyTheme();

View file

@ -106,7 +106,7 @@ export function isCrossVerified(mx, deviceId) {
const deviceInfo = mx.getStoredDevice(mx.getUserId(), deviceId); const deviceInfo = mx.getStoredDevice(mx.getUserId(), deviceId);
const deviceTrust = crossSignInfo.checkDeviceTrust(crossSignInfo, deviceInfo, false, true); const deviceTrust = crossSignInfo.checkDeviceTrust(crossSignInfo, deviceInfo, false, true);
return deviceTrust.isCrossSigningVerified(); return deviceTrust.isCrossSigningVerified();
} catch { } catch (e) {
// device does not support encryption // device does not support encryption
return null; return null;
} }

View file

@ -35,6 +35,10 @@ const copyFiles = {
src: 'public/res/android', src: 'public/res/android',
dest: 'public/', dest: 'public/',
}, },
{
src: 'public/locales',
dest: 'public/',
},
{ {
src: 'twemoji/assets/svg/*', src: 'twemoji/assets/svg/*',
dest: 'twemoji/', dest: 'twemoji/',