diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 67ae5f1..ae1097b 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -25,7 +25,7 @@ jobs: NODE_OPTIONS: '--max_old_space_size=4096' run: npm run build - name: Upload artifact - uses: actions/upload-artifact@v4.3.4 + uses: actions/upload-artifact@v4.3.6 with: name: preview path: dist @@ -33,7 +33,7 @@ jobs: - name: Save pr number run: echo ${PR_NUMBER} > ./pr.txt - name: Upload pr number - uses: actions/upload-artifact@v4.3.4 + uses: actions/upload-artifact@v4.3.6 with: name: pr path: ./pr.txt diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 7b1e2ab..827ca66 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4.1.7 - name: Build Docker image - uses: docker/build-push-action@v6.5.0 + uses: docker/build-push-action@v6.6.1 with: context: . push: false diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index a0a70d1..d340cb7 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -70,7 +70,7 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v3.2.0 - 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 uses: docker/login-action@v3.3.0 with: @@ -90,7 +90,7 @@ jobs: ${{ secrets.DOCKER_USERNAME }}/cinny ghcr.io/${{ github.repository }} - name: Build and push Docker image - uses: docker/build-push-action@v6.5.0 + uses: docker/build-push-action@v6.6.1 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/README.md b/README.md index 5b54f7a..5cee6fa 100644 --- a/README.md +++ b/README.md @@ -19,22 +19,24 @@ A Matrix client focusing primarily on simple, elegant and secure interface. The ## 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. -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: -``` -docker pull ajbura/cinny -``` -or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by: -``` -docker pull ghcr.io/cinnyapp/cinny:latest -``` +* Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by: + ``` + docker pull ajbura/cinny + ``` + or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by: + ``` + docker pull ghcr.io/cinnyapp/cinny:latest + ```
PGP Public Key to verify tarball diff --git a/package-lock.json b/package-lock.json index adfcb29..97577ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.0.3", + "version": "4.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.0.3", + "version": "4.1.0", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", @@ -37,6 +37,9 @@ "formik": "2.4.6", "html-dom-parser": "4.0.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", "is-hotkey": "0.2.0", "jotai": "2.6.0", @@ -54,6 +57,7 @@ "react-dom": "18.2.0", "react-error-boundary": "4.0.13", "react-google-recaptcha": "2.1.0", + "react-i18next": "15.0.0", "react-modal": "3.16.1", "react-range": "1.8.14", "react-router-dom": "6.20.0", @@ -438,11 +442,12 @@ } }, "node_modules/@babel/runtime": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", - "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz", + "integrity": "sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==", + "license": "MIT", "dependencies": { - "regenerator-runtime": "^0.13.11" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" @@ -467,11 +472,6 @@ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", "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": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", @@ -6146,6 +6146,15 @@ "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": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-4.2.0.tgz", @@ -6191,6 +6200,76 @@ "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -7717,6 +7796,28 @@ "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": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -7824,6 +7925,12 @@ "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": { "version": "1.5.2", "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" } }, + "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": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", diff --git a/package.json b/package.json index 2f0c486..dd21cf2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.0.3", + "version": "4.1.0", "description": "Yet another matrix client", "main": "index.js", "type": "module", @@ -48,6 +48,9 @@ "formik": "2.4.6", "html-dom-parser": "4.0.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", "is-hotkey": "0.2.0", "jotai": "2.6.0", @@ -65,6 +68,7 @@ "react-dom": "18.2.0", "react-error-boundary": "4.0.13", "react-google-recaptcha": "2.1.0", + "react-i18next": "15.0.0", "react-modal": "3.16.1", "react-range": "1.8.14", "react-router-dom": "6.20.0", diff --git a/public/locales/de.json b/public/locales/de.json new file mode 100644 index 0000000..43a3716 --- /dev/null +++ b/public/locales/de.json @@ -0,0 +1,7 @@ +{ + "Organisms": { + "RoomCommon": { + "changed_room_name": " hat den Raum Name geƤndert" + } + } +} diff --git a/public/locales/en.json b/public/locales/en.json new file mode 100644 index 0000000..7a2534b --- /dev/null +++ b/public/locales/en.json @@ -0,0 +1,7 @@ +{ + "Organisms": { + "RoomCommon": { + "changed_room_name": " changed room name" + } + } +} diff --git a/src/app/components/BackRouteHandler.tsx b/src/app/components/BackRouteHandler.tsx new file mode 100644 index 0000000..fa3d759 --- /dev/null +++ b/src/app/components/BackRouteHandler.tsx @@ -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); +} diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts index 29e5bd6..20c56ed 100644 --- a/src/app/components/editor/input.ts +++ b/src/app/components/editor/input.ts @@ -25,6 +25,7 @@ import { parseMatrixToUser, testMatrixTo, } from '../../plugins/matrix-to'; +import { tryDecodeURIComponent } from '../../utils/dom'; const markNodeToType: Record = { b: MarkType.Bold, @@ -73,7 +74,7 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | return createEmoticonElement(src, alt || 'Unknown Emoji'); } if (node.name === 'a') { - const href = decodeURIComponent(node.attribs.href); + const href = tryDecodeURIComponent(node.attribs.href); if (typeof href !== 'string') return undefined; if (testMatrixTo(href)) { const userMention = parseMatrixToUser(href); diff --git a/src/app/components/message/Reply.css.ts b/src/app/components/message/Reply.css.ts index 014a284..0679939 100644 --- a/src/app/components/message/Reply.css.ts +++ b/src/app/components/message/Reply.css.ts @@ -5,6 +5,25 @@ export const ReplyBend = style({ 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({ marginBottom: toRem(1), minWidth: 0, diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 85383cd..82a9d91 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -1,7 +1,7 @@ import { Box, Icon, Icons, Text, as, color, toRem } from 'folds'; import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk'; 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 classNames from 'classnames'; import colorMXID from '../../../util/colorMXID'; @@ -22,6 +22,7 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>( ( ) ); +export const ThreadIndicator = as<'div'>(({ ...props }, ref) => ( + + + Threaded reply + +)); + type ReplyProps = { mx: MatrixClient; room: Room; - timelineSet?: EventTimelineSet; - eventId: string; + timelineSet?: EventTimelineSet | undefined; + 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( - timelineSet?.findEventById(eventId) + timelineSet?.findEventById(replyEventId) ); const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []); @@ -62,7 +73,7 @@ export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, .. useEffect(() => { let disposed = false; 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); if (disposed) return; if (err) { @@ -78,37 +89,43 @@ export const Reply = as<'div', ReplyProps>(({ mx, room, timelineSet, eventId, .. return () => { disposed = true; }; - }, [replyEvent, mx, room, eventId]); + }, [replyEvent, mx, room, replyEventId]); const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted'; const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; return ( - - {getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)} - - ) - } - {...props} - ref={ref} - > - {replyEvent !== undefined ? ( - - {badEncryption ? : bodyJSX} - - ) : ( - + + {threadRootId && ( + )} - + + {getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)} + + ) + } + data-event-id={replyEventId} + onClick={onClick} + > + {replyEvent !== undefined ? ( + + {badEncryption ? : bodyJSX} + + ) : ( + + )} + + ); }); diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 4ccb1ec..a8b9ea0 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -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) => ( +
+ ) +); export const PageContent = as<'div'>(({ className, ...props }, ref) => (
diff --git a/src/app/components/page/style.css.ts b/src/app/components/page/style.css.ts index 4807a22..23f2da4 100644 --- a/src/app/components/page/style.css.ts +++ b/src/app/components/page/style.css.ts @@ -1,4 +1,5 @@ import { style } from '@vanilla-extract/css'; +import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; export const PageNav = style({ @@ -33,11 +34,21 @@ export const PageNavContent = style({ paddingBottom: config.space.S700, }); -export const PageHeader = style({ - paddingLeft: config.space.S400, - paddingRight: config.space.S200, - borderBottomWidth: config.borderWidth.B300, +export const PageHeader = recipe({ + base: { + paddingLeft: config.space.S400, + paddingRight: config.space.S200, + borderBottomWidth: config.borderWidth.B300, + }, + variants: { + balance: { + true: { + paddingLeft: config.space.S200, + }, + }, + }, }); +export type PageHeaderVariants = RecipeVariants; export const PageContent = style([ DefaultReset, diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index fc9229f..07c25f8 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -9,6 +9,7 @@ import { useIntersectionObserver, } from '../../hooks/useIntersectionObserver'; import * as css from './UrlPreviewCard.css'; +import { tryDecodeURIComponent } from '../../utils/dom'; const linkStyles = { color: color.Success.Main }; @@ -43,7 +44,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>( priority="300" > {typeof prev['og:site_name'] === 'string' && `${prev['og:site_name']} | `} - {decodeURIComponent(url)} + {tryDecodeURIComponent(url)} {prev['og:title']} diff --git a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx index 1cec659..028cd56 100644 --- a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx +++ b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx @@ -1,5 +1,5 @@ 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 { RoomCard } from '../../components/room-card'; import { RoomTopicViewer } from '../../components/room-topic-viewer'; @@ -8,6 +8,8 @@ import { RoomSummaryLoader } from '../../components/RoomSummaryLoader'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useMatrixClient } from '../../hooks/useMatrixClient'; 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[] }; export function JoinBeforeNavigate({ @@ -18,6 +20,7 @@ export function JoinBeforeNavigate({ const mx = useMatrixClient(); const allRooms = useAtomValue(allRoomsAtom); const { navigateRoom, navigateSpace } = useRoomNavigate(); + const screenSize = useScreenSizeContext(); const handleView = (roomId: string) => { if (mx.getRoom(roomId)?.isSpaceRoom()) { @@ -29,11 +32,24 @@ export function JoinBeforeNavigate({ return ( - - - - {roomIdOrAlias} - + + + + {screenSize === ScreenSize.Mobile && ( + + {(onBack) => ( + + + + )} + + )} + + + + {roomIdOrAlias} + + diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx index e01d3ad..fa415bd 100644 --- a/src/app/features/lobby/LobbyHeader.tsx +++ b/src/app/features/lobby/LobbyHeader.tsx @@ -31,6 +31,8 @@ import { IPowerLevels, usePowerLevelsAPI } from '../../hooks/usePowerLevels'; import { UseStateProvider } from '../../components/UseStateProvider'; import { LeaveSpacePrompt } from '../../components/leave-space-prompt'; import { stopPropagation } from '../../utils/keyboard'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { BackRouteHandler } from '../../components/BackRouteHandler'; type LobbyMenuProps = { roomId: string; @@ -123,6 +125,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { const space = useSpace(); const setPeopleDrawer = useSetSetting(settingsAtom, 'isPeopleDrawer'); const [menuAnchor, setMenuAnchor] = useState(); + const screenSize = useScreenSizeContext(); const name = useRoomName(space); const avatarMxc = useRoomAvatar(space); @@ -133,42 +136,72 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { }; return ( - + - - - {showProfile && ( - <> - - {nameInitials(name)}} - /> - - - {name} - - + {screenSize === ScreenSize.Mobile ? ( + <> + + + {(onBack) => ( + + + + )} + + + + {showProfile && ( + + {name} + + )} + + + ) : ( + <> + + + {showProfile && ( + <> + + {nameInitials(name)}} + /> + + + {name} + + + )} + + + )} + + {screenSize !== ScreenSize.Mobile && ( + + Members + + } + > + {(triggerRef) => ( + setPeopleDrawer((drawer) => !drawer)}> + + + )} + )} - - - - Members - - } - > - {(triggerRef) => ( - setPeopleDrawer((drawer) => !drawer)}> - - - )} - = (evt) => { + const handleOpenClick: MouseEventHandler = (evt) => { const eventId = evt.currentTarget.getAttribute('data-event-id'); if (!eventId) return; onOpen(room.roomId, eventId); @@ -183,15 +183,16 @@ export function SearchResultGroup({ event.sender; const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); + const relation = event.content['m.relates_to']; const mainEventId = - event.content['m.relates_to']?.rel_type === RelationType.Replace - ? event.content['m.relates_to'].event_id - : event.event_id; + relation?.rel_type === RelationType.Replace ? relation.event_id : event.event_id; const getContent = (() => 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 ( {replyEventId && ( )} diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index ee3e702..a4ec716 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -13,6 +13,7 @@ import { useKeyDown } from '../../hooks/useKeyDown'; import { markAsRead } from '../../../client/action/notifications'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomMembers } from '../../hooks/useRoomMembers'; +import { editableActiveElement } from '../../utils/dom'; export function Room() { const { eventId } = useParams(); @@ -28,7 +29,7 @@ export function Room() { window, useCallback( (evt) => { - if (isKeyHotkey('escape', evt)) { + if (isKeyHotkey('escape', evt) && !editableActiveElement()) { markAsRead(mx, room.roomId); } }, diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index e697efd..6c78c9f 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -10,7 +10,7 @@ import React, { } from 'react'; import { useAtom, useAtomValue } from 'jotai'; 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 { Transforms, Editor } from 'slate'; import { @@ -106,7 +106,7 @@ import { CommandAutocomplete } from './CommandAutocomplete'; import { Command, SHRUG, useCommands } from '../../hooks/useCommands'; import { mobileOrTablet } from '../../utils/user-agent'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; -import { ReplyLayout } from '../../components/message'; +import { ReplyLayout, ThreadIndicator } from '../../components/message'; import { roomToParentsAtom } from '../../state/room/roomToParents'; interface RoomInputProps { @@ -186,9 +186,8 @@ export const RoomInput = forwardRef( Transforms.insertFragment(editor, msgDraft); }, [editor, msgDraft]); - useEffect(() => { - if (!mobileOrTablet()) ReactEditor.focus(editor); - return () => { + useEffect( + () => () => { if (!isEmptyEditor(editor)) { const parsedDraft = JSON.parse(JSON.stringify(editor.children)); setMsgDraft(parsedDraft); @@ -197,8 +196,9 @@ export const RoomInput = forwardRef( } resetEditor(editor); resetEditorHistory(editor); - }; - }, [roomId, editor, setMsgDraft]); + }, + [roomId, editor, setMsgDraft] + ); const handleRemoveUpload = useCallback( (upload: TUploadContent | TUploadContent[]) => { @@ -312,6 +312,11 @@ export const RoomInput = forwardRef( 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); resetEditor(editor); @@ -491,22 +496,25 @@ export const RoomInput = forwardRef( > - + {replyDraft.relation?.rel_type === RelationType.Thread && } + + + {getMemberDisplayName(room, replyDraft.userId) ?? + getMxIdLocalPart(replyDraft.userId) ?? + replyDraft.userId} + + + } + > - - {getMemberDisplayName(room, replyDraft.userId) ?? - getMxIdLocalPart(replyDraft.userId) ?? - replyDraft.userId} - + {trimReplyFromBody(replyDraft.body)} - } - > - - {trimReplyFromBody(replyDraft.body)} - - + +
) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 6e50370..01ba14f 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -16,6 +16,7 @@ import { EventTimeline, EventTimelineSet, EventTimelineSetHandlerMap, + IContent, IEncryptedFile, MatrixClient, MatrixEvent, @@ -46,6 +47,7 @@ import { } from 'folds'; import { isKeyHotkey } from 'is-hotkey'; import { Opts as LinkifyOpts } from 'linkifyjs'; +import { useTranslation } from 'react-i18next'; import { decryptFile, eventWithShortcode, @@ -836,13 +838,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli markAsRead(mx, room.roomId); }; - const handleOpenReply: MouseEventHandler = useCallback( + const handleOpenReply: MouseEventHandler = useCallback( async (evt) => { - const replyId = evt.currentTarget.getAttribute('data-reply-id'); - if (typeof replyId !== 'string') return; - const replyTimeline = getEventTimeline(room, replyId); + const targetId = evt.currentTarget.getAttribute('data-event-id'); + if (!targetId) return; + const replyTimeline = getEventTimeline(room, targetId); const absoluteIndex = - replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, replyId); + replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, targetId); if (typeof absoluteIndex === 'number') { scrollToItem(absoluteIndex, { @@ -857,7 +859,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli }); } else { setTimeline(getEmptyTimeline()); - loadEventTimeline(replyId); + loadEventTimeline(targetId); } }, [room, timeline, scrollToItem, loadEventTimeline] @@ -908,8 +910,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const replyEvt = room.findEventById(replyId); if (!replyEvt) return; const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); - const { body, formatted_body: formattedBody }: Record = - editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); + const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); + const { body, formatted_body: formattedBody } = content; + const { 'm.relates_to': relation } = replyEvt.getOriginalContent(); const senderId = replyEvt.getSender(); if (senderId && typeof body === 'string') { setReplyDraft({ @@ -917,6 +920,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli eventId: replyId, body, formattedBody, + relation, }); setTimeout(() => ReactEditor.focus(editor), 100); } @@ -958,6 +962,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli }, [editor] ); + const { t } = useTranslation(); const renderMatrixEvent = useMatrixEventRenderer< [string, MatrixEvent, number, EventTimelineSet, boolean] @@ -967,7 +972,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const hasReactions = reactions && reactions.length > 0; - const { replyEventId } = mEvent; + const { replyEventId, threadRootId } = mEvent; const highlighted = focusItem?.index === item && focusItem.highlight; const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); @@ -1002,12 +1007,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli reply={ replyEventId && ( ) @@ -1048,7 +1052,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const hasReactions = reactions && reactions.length > 0; - const { replyEventId } = mEvent; + const { replyEventId, threadRootId } = mEvent; const highlighted = focusItem?.index === item && focusItem.highlight; return ( @@ -1075,12 +1079,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli reply={ replyEventId && ( ) @@ -1273,7 +1276,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli {senderName} - {' changed room name'} + {t('Organisms.RoomCommon.changed_room_name')} } diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 3eabd52..250afc9 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -25,6 +25,7 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { if (evt.metaKey || evt.altKey || evt.ctrlKey) { return false; } + // do not focus on F keys if (/^F\d+$/.test(code)) return false; @@ -36,6 +37,9 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { code.startsWith('Alt') || code.startsWith('Control') || code.startsWith('Arrow') || + code.startsWith('Page') || + code.startsWith('End') || + code.startsWith('Home') || code === 'Tab' || code === 'Space' || code === 'Enter' || diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 709f66c..0b8ef74 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -52,6 +52,7 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { stopPropagation } from '../../utils/keyboard'; import { getMatrixToRoom } from '../../plugins/matrix-to'; import { getViaServers } from '../../plugins/via-servers'; +import { BackRouteHandler } from '../../components/BackRouteHandler'; type RoomMenuProps = { room: Room; @@ -203,19 +204,36 @@ export function RoomViewHeader() { }; return ( - + + {screenSize === ScreenSize.Mobile && ( + + {(onBack) => ( + + + + + + )} + + )} - - ( - - )} - /> - + {screenSize !== ScreenSize.Mobile && ( + + ( + + )} + /> + + )} {name} diff --git a/src/app/hooks/useDeviceList.js b/src/app/hooks/useDeviceList.js deleted file mode 100644 index 7daaad1..0000000 --- a/src/app/hooks/useDeviceList.js +++ /dev/null @@ -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; -} diff --git a/src/app/hooks/useDeviceList.ts b/src/app/hooks/useDeviceList.ts new file mode 100644 index 0000000..daec7cb --- /dev/null +++ b/src/app/hooks/useDeviceList.ts @@ -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(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; +} diff --git a/src/app/i18n.ts b/src/app/i18n.ts new file mode 100644 index 0000000..9e83805 --- /dev/null +++ b/src/app/i18n.ts @@ -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({ + 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; diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 4483ec1..b6e94d2 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.0.3 + v4.1.0 Twitter diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index 2ea9414..3943f42 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -29,6 +29,7 @@ import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo'; import { AuthFlowsLoader } from '../../components/AuthFlowsLoader'; import { AuthFlowsProvider } from '../../hooks/useAuthFlows'; import { AuthServerProvider } from '../../hooks/useAuthServer'; +import { tryDecodeURIComponent } from '../../utils/dom'; const currentAuthPath = (pathname: string): string => { if (matchPath(LOGIN_PATH, pathname)) { @@ -72,7 +73,7 @@ export function AuthLayout() { const clientConfig = useClientConfig(); const defaultServer = clientDefaultServer(clientConfig); - let server: string = urlEncodedServer ? decodeURIComponent(urlEncodedServer) : defaultServer; + let server: string = urlEncodedServer ? tryDecodeURIComponent(urlEncodedServer) : defaultServer; if (!clientAllowedServer(clientConfig, server)) { server = defaultServer; @@ -94,7 +95,7 @@ export function AuthLayout() { // if server is mismatches with path server, update path useEffect(() => { - if (!urlEncodedServer || decodeURIComponent(urlEncodedServer) !== server) { + if (!urlEncodedServer || tryDecodeURIComponent(urlEncodedServer) !== server) { navigate( generatePath(currentAuthPath(location.pathname), { server: encodeURIComponent(server), diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 845fceb..ffe33e9 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -183,17 +183,17 @@ function MessageNotifications() { removed, data ) => { + if (mx.getSyncState() !== 'SYNCING') return; + if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return; if ( - mx.getSyncState() !== 'SYNCING' || - selectedRoomId === room?.roomId || - notificationSelected || !room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent) || getNotificationType(mx, room.roomId) === NotificationType.Mute - ) + ) { return; + } const sender = mEvent.getSender(); const eventId = mEvent.getId(); diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index fb6bd74..110e469 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -10,7 +10,15 @@ import { SidebarItemTooltip, SidebarItem, } 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'; export function SidebarNav() { @@ -65,6 +73,8 @@ export function SidebarNav() { + + diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index cdd0910..87447ef 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.0.3 + v4.1.0 } diff --git a/src/app/pages/client/explore/Featured.tsx b/src/app/pages/client/explore/Featured.tsx index 4838127..f056cbb 100644 --- a/src/app/pages/client/explore/Featured.tsx +++ b/src/app/pages/client/explore/Featured.tsx @@ -1,5 +1,5 @@ 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 { useClientConfig } from '../../../hooks/useClientConfig'; import { RoomCard, RoomCardGrid } from '../../../components/room-card'; @@ -9,21 +9,38 @@ import { Page, PageContent, PageContentCenter, + PageHeader, PageHero, PageHeroSection, } from '../../../components/page'; import { RoomTopicViewer } from '../../../components/room-topic-viewer'; import * as css from './style.css'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { BackRouteHandler } from '../../../components/BackRouteHandler'; export function FeaturedRooms() { const { featuredCommunities } = useClientConfig(); const { rooms, spaces } = featuredCommunities ?? {}; const allRooms = useAtomValue(allRoomsAtom); + const screenSize = useScreenSizeContext(); const { navigateSpace, navigateRoom } = useRoomNavigate(); return ( + {screenSize === ScreenSize.Mobile && ( + + + + {(onBack) => ( + + + + )} + + + + )} diff --git a/src/app/pages/client/explore/Server.tsx b/src/app/pages/client/explore/Server.tsx index 1a81c22..1f493df 100644 --- a/src/app/pages/client/explore/Server.tsx +++ b/src/app/pages/client/explore/Server.tsx @@ -13,6 +13,7 @@ import { Button, Chip, Icon, + IconButton, Icons, Input, Line, @@ -42,6 +43,8 @@ import { allRoomsAtom } from '../../../state/room-list/roomList'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { getMxIdServer } from '../../../utils/matrix'; import { stopPropagation } from '../../../utils/keyboard'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { BackRouteHandler } from '../../../components/BackRouteHandler'; const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams => useMemo( @@ -344,6 +347,7 @@ export function PublicRooms() { const userServer = userId && getMxIdServer(userId); const allRooms = useAtomValue(allRoomsAtom); const { navigateSpace, navigateRoom } = useRoomNavigate(); + const screenSize = useScreenSizeContext(); const [searchParams] = useSearchParams(); const serverSearchParams = useServerSearchParams(searchParams); @@ -466,7 +470,7 @@ export function PublicRooms() { return ( - + {isSearch ? ( <> @@ -482,20 +486,34 @@ export function PublicRooms() { - + {screenSize !== ScreenSize.Mobile && } Search - + ) : ( - - - - {server} - - + <> + + {screenSize === ScreenSize.Mobile && ( + + {(onBack) => ( + + + + )} + + )} + + + {screenSize !== ScreenSize.Mobile && } + + {server} + + + + )} diff --git a/src/app/pages/client/home/Search.tsx b/src/app/pages/client/home/Search.tsx index af7b1eb..d5ddfb7 100644 --- a/src/app/pages/client/home/Search.tsx +++ b/src/app/pages/client/home/Search.tsx @@ -1,21 +1,38 @@ 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 { MessageSearch } from '../../../features/message-search'; import { useHomeRooms } from './useHomeRooms'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { BackRouteHandler } from '../../../components/BackRouteHandler'; export function HomeSearch() { const scrollRef = useRef(null); const rooms = useHomeRooms(); + const screenSize = useScreenSizeContext(); return ( - - - - - Message Search - + + + + {screenSize === ScreenSize.Mobile && ( + + {(onBack) => ( + + + + )} + + )} + + + {screenSize !== ScreenSize.Mobile && } + + Message Search + + + diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index 06e5f6c..1899308 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -4,6 +4,7 @@ import { Box, Button, Icon, + IconButton, Icons, Overlay, OverlayBackdrop, @@ -39,6 +40,8 @@ import { RoomTopicViewer } from '../../../components/room-topic-viewer'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { useRoomTopic } from '../../../hooks/useRoomMeta'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { BackRouteHandler } from '../../../components/BackRouteHandler'; const COMPACT_CARD_WIDTH = 548; @@ -205,6 +208,7 @@ export function Invites() { useCallback(() => containerRef.current, []), useCallback((width) => setCompact(width <= COMPACT_CARD_WIDTH), []) ); + const screenSize = useScreenSizeContext(); const { navigateRoom, navigateSpace } = useRoomNavigate(); @@ -225,12 +229,26 @@ export function Invites() { return ( - - - - - Invitations - + + + + {screenSize === ScreenSize.Mobile && ( + + {(onBack) => ( + + + + )} + + )} + + + {screenSize !== ScreenSize.Mobile && } + + Invitations + + + diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 3425b51..aa87821 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -20,6 +20,7 @@ import { IRoomEvent, JoinRule, Method, + RelationType, Room, } from 'matrix-js-sdk'; import { useVirtualizer } from '@tanstack/react-virtual'; @@ -78,6 +79,8 @@ import { UserAvatar } from '../../../components/user-avatar'; import { EncryptedContent } from '../../../features/room/message'; import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { BackRouteHandler } from '../../../components/BackRouteHandler'; type RoomNotificationsGroup = { roomId: string; @@ -350,7 +353,7 @@ function RoomNotificationsGroupComp({ } ); - const handleOpenClick: MouseEventHandler = (evt) => { + const handleOpenClick: MouseEventHandler = (evt) => { const eventId = evt.currentTarget.getAttribute('data-event-id'); if (!eventId) return; onOpen(room.roomId, eventId); @@ -401,7 +404,10 @@ function RoomNotificationsGroupComp({ const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); 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 ( {replyEventId && ( )} @@ -484,6 +489,7 @@ export function Notifications() { const mx = useMatrixClient(); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const screenSize = useScreenSizeContext(); const { navigateRoom } = useRoomNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -549,12 +555,26 @@ export function Notifications() { return ( - - - - - Notification Messages - + + + + {screenSize === ScreenSize.Mobile && ( + + {(onBack) => ( + + + + )} + + )} + + + {screenSize !== ScreenSize.Mobile && } + + Notification Messages + + + diff --git a/src/app/pages/client/sidebar/UnverifiedTab.css.ts b/src/app/pages/client/sidebar/UnverifiedTab.css.ts new file mode 100644 index 0000000..e8fe8c5 --- /dev/null +++ b/src/app/pages/client/sidebar/UnverifiedTab.css.ts @@ -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, +}); diff --git a/src/app/pages/client/sidebar/UnverifiedTab.tsx b/src/app/pages/client/sidebar/UnverifiedTab.tsx new file mode 100644 index 0000000..919c4a7 --- /dev/null +++ b/src/app/pages/client/sidebar/UnverifiedTab.tsx @@ -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 ( + + + {(triggerRef) => ( + openSettings(tabText.SECURITY)} + > + + + )} + + + + + {unverified.length} + + + + + ); +} diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index 63c5d4b..5da8780 100644 --- a/src/app/pages/client/sidebar/index.ts +++ b/src/app/pages/client/sidebar/index.ts @@ -4,3 +4,4 @@ export * from './SpaceTabs'; export * from './InboxTab'; export * from './ExploreTab'; export * from './UserTab'; +export * from './UnverifiedTab'; diff --git a/src/app/pages/client/space/Search.tsx b/src/app/pages/client/space/Search.tsx index 6e7ac57..017262b 100644 --- a/src/app/pages/client/space/Search.tsx +++ b/src/app/pages/client/space/Search.tsx @@ -1,5 +1,5 @@ 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 { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page'; import { MessageSearch } from '../../../features/message-search'; @@ -9,11 +9,14 @@ import { allRoomsAtom } from '../../../state/room-list/roomList'; import { mDirectAtom } from '../../../state/mDirectList'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { BackRouteHandler } from '../../../components/BackRouteHandler'; export function SpaceSearch() { const mx = useMatrixClient(); const scrollRef = useRef(null); const space = useSpace(); + const screenSize = useScreenSizeContext(); const mDirects = useAtomValue(mDirectAtom); const roomToParents = useAtomValue(roomToParentsAtom); @@ -25,12 +28,26 @@ export function SpaceSearch() { return ( - - - - - Message Search - + + + + {screenSize === ScreenSize.Mobile && ( + + {(onBack) => ( + + + + )} + + )} + + + {screenSize !== ScreenSize.Mobile && } + + Message Search + + + diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index ee21a11..b853ded 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -26,6 +26,7 @@ import { testMatrixTo, } from './matrix-to'; import { onEnterOrSpace } from '../utils/keyboard'; +import { tryDecodeURIComponent } from '../utils/dom'; const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); @@ -134,8 +135,8 @@ export const factoryRenderLinkifyWithMention = ( attributes, content, }) => { - if (tagName === 'a' && testMatrixTo(decodeURIComponent(attributes.href))) { - const mention = mentionRender(decodeURIComponent(attributes.href)); + if (tagName === 'a' && testMatrixTo(tryDecodeURIComponent(attributes.href))) { + const mention = mentionRender(tryDecodeURIComponent(attributes.href)); 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( mx, roomId, - decodeURIComponent(props.href), + tryDecodeURIComponent(props.href), makeMentionCustomProps(params.handleMentionClick) ); if (mention) return mention; diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index 60b42fd..33bd060 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -2,6 +2,7 @@ import { atom } from 'jotai'; import { atomFamily } from 'jotai/utils'; import { Descendant } from 'slate'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; +import { IEventRelation } from 'matrix-js-sdk'; import { TListAtom, createListAtom } from '../list'; import { createUploadAtomFamily } from '../upload'; import { TUploadContent } from '../../utils/matrix'; @@ -39,7 +40,8 @@ export type IReplyDraft = { userId: string; eventId: string; body: string; - formattedBody?: string; + formattedBody?: string | undefined; + relation?: IEventRelation | undefined; }; const createReplyDraftAtom = () => atom(undefined); export type TReplyDraftAtom = ReturnType; diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index 1aea675..ab4b8e6 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -196,3 +196,11 @@ export const setFavicon = (url: string): void => { if (!favicon) return; favicon.setAttribute('href', url); }; + +export const tryDecodeURIComponent = (encodedURIComponent: string): string => { + try { + return decodeURIComponent(encodedURIComponent); + } catch { + return encodedURIComponent; + } +}; diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 750dd6c..8cf33a8 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -389,13 +389,18 @@ export const getEditedEvent = ( return edits && getLatestEdit(mEvent, edits.getRelations()); }; -export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) => - mEvent.getSender() === mx.getUserId() && - !mEvent.isRelation() && - mEvent.getType() === MessageEvent.RoomMessage && - (mEvent.getContent().msgtype === MsgType.Text || - mEvent.getContent().msgtype === MsgType.Emote || - mEvent.getContent().msgtype === MsgType.Notice); +export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) => { + const content = mEvent.getContent(); + const relationType = content['m.relates_to']?.rel_type; + return ( + mEvent.getSender() === mx.getUserId() && + (!relationType || relationType === RelationType.Thread) && + mEvent.getType() === MessageEvent.RoomMessage && + (content.msgtype === MsgType.Text || + content.msgtype === MsgType.Emote || + content.msgtype === MsgType.Notice) + ); +}; export const getLatestEditableEvt = ( timeline: EventTimeline, diff --git a/src/client/state/cons.js b/src/client/state/cons.js index 157d4a6..f7f65db 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -1,5 +1,5 @@ const cons = { - version: '4.0.3', + version: '4.1.0', secretKey: { ACCESS_TOKEN: 'cinny_access_token', DEVICE_ID: 'cinny_device_id', diff --git a/src/index.tsx b/src/index.tsx index 1d86420..a289ed1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,9 @@ import settings from './client/state/settings'; import App from './app/pages/App'; +// import i18n (needs to be bundled ;)) +import './app/i18n'; + document.body.classList.add(configClass, varsClass); settings.applyTheme(); diff --git a/src/util/matrixUtil.js b/src/util/matrixUtil.js index 9f1d942..7664c3a 100644 --- a/src/util/matrixUtil.js +++ b/src/util/matrixUtil.js @@ -106,7 +106,7 @@ export function isCrossVerified(mx, deviceId) { const deviceInfo = mx.getStoredDevice(mx.getUserId(), deviceId); const deviceTrust = crossSignInfo.checkDeviceTrust(crossSignInfo, deviceInfo, false, true); return deviceTrust.isCrossSigningVerified(); - } catch { + } catch (e) { // device does not support encryption return null; } diff --git a/vite.config.js b/vite.config.js index 0d14067..d44e2bc 100644 --- a/vite.config.js +++ b/vite.config.js @@ -35,6 +35,10 @@ const copyFiles = { src: 'public/res/android', dest: 'public/', }, + { + src: 'public/locales', + dest: 'public/', + }, { src: 'twemoji/assets/svg/*', dest: 'twemoji/',