diff --git a/.eslintrc.js b/.eslintrc.cjs similarity index 100% rename from .eslintrc.js rename to .eslintrc.cjs diff --git a/i18next-parser.config.js b/i18next-parser.config.js new file mode 100644 index 00000000..b2d58428 --- /dev/null +++ b/i18next-parser.config.js @@ -0,0 +1,20 @@ +export default { + keySeparator: false, + namespaceSeparator: false, + contextSeparator: "|", + pluralSeparator: "|", + createOldCatalogs: false, + defaultNamespace: "app", + lexers: { + ts: [{ + lexer: "JavascriptLexer", + functions: ["t", "translatedError"], + functionsNamespace: ["useTranslation", "withTranslation"], + }], + }, + locales: ["en-GB"], + output: "public/locales/$LOCALE/$NAMESPACE.json", + input: ["src/**/*.{ts,tsx}"], + sort: true, + useKeysAsDefaultValue: true, +}; diff --git a/package.json b/package.json index 7cda11b2..ab778355 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "version": "0.0.0", + "type": "module", "scripts": { "dev": "vite", "build": "vite build", @@ -10,7 +11,8 @@ "prettier:format": "prettier -w src", "lint": "yarn lint:types && yarn lint:js", "lint:js": "eslint --max-warnings 0 src", - "lint:types": "tsc" + "lint:types": "tsc", + "i18n": "node_modules/i18next-parser/bin/cli.js" }, "dependencies": { "@juggle/resize-observer": "^3.3.1", @@ -38,6 +40,9 @@ "classnames": "^2.3.1", "color-hash": "^2.0.1", "events": "^3.3.0", + "i18next": "^21.10.0", + "i18next-browser-languagedetector": "^6.1.8", + "i18next-http-backend": "^1.4.4", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#ce3b72c85031f188a092d1c39806ef7536e65bdd", "matrix-widget-api": "^1.0.0", "mermaid": "^8.13.8", @@ -47,6 +52,7 @@ "re-resizable": "^6.9.0", "react": "^17.0.0", "react-dom": "^17.0.0", + "react-i18next": "^11.18.6", "react-json-view": "^1.21.3", "react-router": "6", "react-router-dom": "^5.2.0", @@ -71,6 +77,7 @@ "eslint-plugin-matrix-org": "^0.4.0", "eslint-plugin-react": "^7.29.4", "eslint-plugin-react-hooks": "^4.5.0", + "i18next-parser": "^6.6.0", "prettier": "^2.6.2", "sass": "^1.42.1", "storybook-builder-vite": "^0.1.12", diff --git a/public/locales/de-DE/app.json b/public/locales/de-DE/app.json new file mode 100644 index 00000000..63a49fa4 --- /dev/null +++ b/public/locales/de-DE/app.json @@ -0,0 +1,4 @@ +{ + "Invite": "Einladen", + "Video call": "Videoanruf" +} diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json new file mode 100644 index 00000000..e9c03b03 --- /dev/null +++ b/public/locales/en-GB/app.json @@ -0,0 +1,135 @@ +{ + "{{count}} people connected|one": "{{count}} person connected", + "{{count}} people connected|other": "{{count}} people connected", + "{{displayName}}, your call is now ended": "{{displayName}}, your call is now ended", + "{{name}} is presenting": "{{name}} is presenting", + "{{name}} is talking…": "{{name}} is talking…", + "{{names}}, {{name}}": "{{names}}, {{name}}", + "{{roomName}} - Walkie-talkie call": "{{roomName}} - Walkie-talkie call", + "<0>Already have an account?<1><0>Log in Or <2>Access as a guest": "<0>Already have an account?<1><0>Log in Or <2>Access as a guest", + "<0>Create an account Or <2>Access as a guest": "<0>Create an account Or <2>Access as a guest", + "<0>Oops, something's gone wrong.<1>Submitting debug logs will help us track down the problem.": "<0>Oops, something's gone wrong.<1>Submitting debug logs will help us track down the problem.", + "<0>Why not finish by setting up a password to keep your account?<1>You'll be able to keep your name and set an avatar for use on future calls": "<0>Why not finish by setting up a password to keep your account?<1>You'll be able to keep your name and set an avatar for use on future calls", + "Accept camera/microphone permissions to join the call.": "Accept camera/microphone permissions to join the call.", + "Accept microphone permissions to join the call.": "Accept microphone permissions to join the call.", + "Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.", + "Audio": "Audio", + "Avatar": "Avatar", + "By clicking \"Go\", you agree to our <2>Terms and conditions": "By clicking \"Go\", you agree to our <2>Terms and conditions", + "By clicking \"Join call now\", you agree to our <2>Terms and conditions": "By clicking \"Join call now\", you agree to our <2>Terms and conditions", + "Call link copied": "Call link copied", + "Call type menu": "Call type menu", + "Camera": "Camera", + "Camera {{n}}": "Camera {{n}}", + "Camera/microphone permissions needed to join the call.": "Camera/microphone permissions needed to join the call.", + "Change layout": "Change layout", + "Close": "Close", + "Confirm password": "Confirm password", + "Connection lost": "Connection lost", + "Copied!": "Copied!", + "Copy and share this call link": "Copy and share this call link", + "Copy call link and join later": "Copy call link and join later", + "Create account": "Create account", + "Debug log": "Debug log", + "Debug log request": "Debug log request", + "Description (optional)": "Description (optional)", + "Details": "Details", + "Developer": "Developer", + "Display name": "Display name", + "Download debug logs": "Download debug logs", + "Entering room…": "Entering room…", + "Exit full screen": "Exit full screen", + "Fetching group call timed out.": "Fetching group call timed out.", + "Freedom": "Freedom", + "Full screen": "Full screen", + "Go": "Go", + "Grid layout menu": "Grid layout menu", + "Having trouble? Help us fix it.": "Having trouble? Help us fix it.", + "Home": "Home", + "Include debug logs": "Include debug logs", + "Incompatible versions": "Incompatible versions", + "Incompatible versions!": "Incompatible versions!", + "Inspector": "Inspector", + "Invite": "Invite", + "Invite people": "Invite people", + "Join call": "Join call", + "Join call now": "Join call now", + "Join existing call?": "Join existing call?", + "Leave": "Leave", + "Loading room…": "Loading room…", + "Loading…": "Loading…", + "Local volume": "Local volume", + "Logging in…": "Logging in…", + "Login": "Login", + "Login to your account": "Login to your account", + "Microphone": "Microphone", + "Microphone {{n}}": "Microphone {{n}}", + "Microphone permissions needed to join the call.": "Microphone permissions needed to join the call.", + "More": "More", + "More menu": "More menu", + "Mute microphone": "Mute microphone", + "No": "No", + "Not now, return to home screen": "Not now, return to home screen", + "Not registered yet? <1>Create an account": "Not registered yet? <1>Create an account", + "Not registered yet? <2>Create an account": "Not registered yet? <2>Create an account", + "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}": "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}", + "Password": "Password", + "Passwords must match": "Passwords must match", + "Press and hold spacebar to talk": "Press and hold spacebar to talk", + "Press and hold spacebar to talk over {{name}}": "Press and hold spacebar to talk over {{name}}", + "Press and hold to talk": "Press and hold to talk", + "Press and hold to talk over {{name}}": "Press and hold to talk over {{name}}", + "Profile": "Profile", + "Recaptcha dismissed": "Recaptcha dismissed", + "Recaptcha not loaded": "Recaptcha not loaded", + "Register": "Register", + "Registering…": "Registering…", + "Release spacebar key to stop": "Release spacebar key to stop", + "Release to stop": "Release to stop", + "Remove": "Remove", + "Return to home screen": "Return to home screen", + "Save": "Save", + "Saving…": "Saving…", + "Select an option": "Select an option", + "Send debug log": "Send debug log", + "Send debug logs": "Send debug logs", + "Sending debug log…": "Sending debug log…", + "Sending…": "Sending…", + "Settings": "Settings", + "Share screen": "Share screen", + "Show call inspector": "Show call inspector", + "Sign in": "Sign in", + "Sign out": "Sign out", + "Spatial audio": "Spatial audio", + "Speaker": "Speaker", + "Speaker {{n}}": "Speaker {{n}}", + "Spotlight": "Spotlight", + "Stop sharing screen": "Stop sharing screen", + "Submit feedback": "Submit feedback", + "Submitting feedback…": "Submitting feedback…", + "Take me Home": "Take me Home", + "Talk over speaker": "Talk over speaker", + "Talking…": "Talking…", + "Thanks! We'll get right on it.": "Thanks! We'll get right on it.", + "This call already exists, would you like to join?": "This call already exists, would you like to join?", + "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy and <6>Terms of Service apply.<9>By clicking \"Register\", you agree to our <12>Terms and conditions": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy and <6>Terms of Service apply.<9>By clicking \"Register\", you agree to our <12>Terms and conditions", + "This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)": "This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)", + "Turn off camera": "Turn off camera", + "Turn on camera": "Turn on camera", + "Unmute microphone": "Unmute microphone", + "User ID": "User ID", + "User menu": "User menu", + "Username": "Username", + "Version: {{version}}": "Version: {{version}}", + "Video": "Video", + "Video call": "Video call", + "Video call name": "Video call name", + "Waiting for network": "Waiting for network", + "Waiting for other participants…": "Waiting for other participants…", + "Walkie-talkie call": "Walkie-talkie call", + "Walkie-talkie call name": "Walkie-talkie call name", + "WebRTC is not supported or is being blocked in this browser.": "WebRTC is not supported or is being blocked in this browser.", + "Yes, join call": "Yes, join call", + "You can't talk at the same time": "You can't talk at the same time", + "Your recent calls": "Your recent calls" +} diff --git a/src/App.tsx b/src/App.tsx index b41271b5..02997349 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { Suspense } from "react"; import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { OverlayProvider } from "@react-aria/overlays"; @@ -43,34 +43,36 @@ export default function App({ history }: AppProps) { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index c026781f..3e9e6baf 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -27,6 +27,7 @@ import { useHistory } from "react-router-dom"; import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { logger } from "matrix-js-sdk/src/logger"; +import { useTranslation } from "react-i18next"; import { ErrorView } from "./FullScreenView"; import { @@ -35,6 +36,7 @@ import { CryptoStoreIntegrityError, } from "./matrix-utils"; import { widget } from "./widget"; +import { translatedError } from "./TranslatedError"; declare global { interface Window { @@ -267,6 +269,8 @@ export const ClientProvider: FC = ({ children }) => { history.push("/"); }, [history, client]); + const { t } = useTranslation(); + useEffect(() => { // To protect against multiple sessions writing to the same storage // simultaneously, we send a to-device message that shuts down all other @@ -287,8 +291,9 @@ export const ClientProvider: FC = ({ children }) => { setState((prev) => ({ ...prev, - error: new Error( - "This application has been opened in another tab." + error: translatedError( + "This application has been opened in another tab.", + t ), })); } @@ -306,7 +311,7 @@ export const ClientProvider: FC = ({ children }) => { client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent); }; } - }, [client]); + }, [client, t]); const context = useMemo( () => ({ diff --git a/src/Facepile.tsx b/src/Facepile.tsx index bd3c9c66..c3f5b290 100644 --- a/src/Facepile.tsx +++ b/src/Facepile.tsx @@ -14,10 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { HTMLAttributes } from "react"; +import React, { HTMLAttributes, useMemo } from "react"; import classNames from "classnames"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { useTranslation } from "react-i18next"; import styles from "./Facepile.module.css"; import { Avatar, Size, sizes } from "./Avatar"; @@ -44,13 +45,25 @@ export function Facepile({ size = Size.XS, ...rest }: Props) { + const { t } = useTranslation(); + const _size = sizes.get(size); const _overlap = overlapMap[size]; + const title = useMemo(() => { + return participants.reduce( + (prev, curr) => + prev === null + ? curr.name + : t("{{names}}, {{name}}", { names: prev, name: curr.name }), + null + ) as string; + }, [participants, t]); + return (
member.name).join(", ")} + title={title} style={{ width: Math.min(participants.length, max + 1) * (_size - _overlap) + diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx index 91821116..4d4739c4 100644 --- a/src/FullScreenView.tsx +++ b/src/FullScreenView.tsx @@ -1,12 +1,14 @@ import React, { ReactNode, useCallback, useEffect } from "react"; import { useLocation } from "react-router-dom"; import classNames from "classnames"; +import { Trans, useTranslation } from "react-i18next"; import { Header, HeaderLogo, LeftNav, RightNav } from "./Header"; import { LinkButton, Button } from "./button"; import { useSubmitRageshake } from "./settings/submit-rageshake"; import { ErrorMessage } from "./input/Input"; import styles from "./FullScreenView.module.css"; +import { translatedError, TranslatedError } from "./TranslatedError"; interface FullScreenViewProps { className?: string; @@ -35,6 +37,7 @@ interface ErrorViewProps { export function ErrorView({ error }: ErrorViewProps) { const location = useLocation(); + const { t } = useTranslation(); useEffect(() => { console.error(error); @@ -47,7 +50,11 @@ export function ErrorView({ error }: ErrorViewProps) { return (

Error

-

{error.message}

+

+ {error instanceof TranslatedError + ? error.translatedMessage + : error.message} +

{location.pathname === "/" ? ( ) : ( - Return to home screen + {t("Return to home screen")} )}
@@ -72,6 +79,7 @@ export function ErrorView({ error }: ErrorViewProps) { } export function CrashView() { + const { t } = useTranslation(); const { submitRageshake, sending, sent, error } = useSubmitRageshake(); const sendDebugLogs = useCallback(() => { @@ -85,11 +93,11 @@ export function CrashView() { window.location.href = "/"; }, []); - let logsComponent; + let logsComponent: JSX.Element | null = null; if (sent) { - logsComponent =
Thanks! We'll get right on it.
; + logsComponent =
{t("Thanks! We'll get right on it.")}
; } else if (sending) { - logsComponent =
Sending...
; + logsComponent =
{t("Sending…")}
; } else { logsComponent = ( ); } return ( -

Oops, something's gone wrong.

-

Submitting debug logs will help us track down the problem.

+ +

Oops, something's gone wrong.

+

Submitting debug logs will help us track down the problem.

+
{logsComponent}
- {error && Couldn't send debug logs!} + {error && ( + + )}
); } export function LoadingView() { + const { t } = useTranslation(); + return ( -

Loading...

+

{t("Loading…")}

); } diff --git a/src/Header.tsx b/src/Header.tsx index acad072f..9e4af882 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -4,6 +4,7 @@ import { Link } from "react-router-dom"; import { useButton } from "@react-aria/button"; import { AriaButtonProps } from "@react-types/button"; import { Room } from "matrix-js-sdk/src/models/room"; +import { useTranslation } from "react-i18next"; import styles from "./Header.module.css"; import { useModalTriggerState } from "./Modal"; @@ -156,6 +157,7 @@ export function VersionMismatchWarning({ users, room, }: VersionMismatchWarningProps) { + const { t } = useTranslation(); const { modalState, modalProps } = useModalTriggerState(); const onDetailsClick = useCallback(() => { @@ -166,9 +168,9 @@ export function VersionMismatchWarning({ return ( - Incomaptible versions! + {t("Incompatible versions!")} {modalState.isOpen && ( diff --git a/src/IncompatibleVersionModal.tsx b/src/IncompatibleVersionModal.tsx index 637859e3..29abfb9d 100644 --- a/src/IncompatibleVersionModal.tsx +++ b/src/IncompatibleVersionModal.tsx @@ -15,7 +15,8 @@ limitations under the License. */ import { Room } from "matrix-js-sdk/src/models/room"; -import React from "react"; +import React, { useMemo } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { Modal, ModalContent } from "./Modal"; import { Body } from "./typography/Typography"; @@ -30,17 +31,21 @@ export const IncompatibleVersionModal: React.FC = ({ room, ...rest }) => { - const userLis = Array.from(userIds).map((u) => ( -
  • {room.getMember(u).name}
  • - )); + const { t } = useTranslation(); + const userLis = useMemo( + () => [...userIds].map((u) =>
  • {room.getMember(u)?.name ?? u}
  • ), + [userIds, room] + ); return ( - + - Other users are trying to join this call from incompatible versions. - These users should ensure that they have refreshed their browsers: -
      {userLis}
    + + Other users are trying to join this call from incompatible versions. + These users should ensure that they have refreshed their browsers: +
      {userLis}
    +
    diff --git a/src/Modal.tsx b/src/Modal.tsx index 686234a0..8a41fbc8 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -33,6 +33,7 @@ import { FocusScope } from "@react-aria/focus"; import { ButtonAria, useButton } from "@react-aria/button"; import classNames from "classnames"; import { AriaDialogProps } from "@react-types/dialog"; +import { useTranslation } from "react-i18next"; import { ReactComponent as CloseIcon } from "./icons/Close.svg"; import styles from "./Modal.module.css"; @@ -53,6 +54,7 @@ export function Modal({ onClose, ...rest }: ModalProps) { + const { t } = useTranslation(); const modalRef = useRef(); const { overlayProps, underlayProps } = useOverlay( { ...rest, onClose }, @@ -90,6 +92,7 @@ export function Modal({ {...closeButtonProps} ref={closeButtonRef} className={styles.closeButton} + title={t("Close")} > diff --git a/src/SequenceDiagramViewerPage.tsx b/src/SequenceDiagramViewerPage.tsx index a6473ccc..e04837db 100644 --- a/src/SequenceDiagramViewerPage.tsx +++ b/src/SequenceDiagramViewerPage.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React, { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; import { SequenceDiagramViewer, @@ -30,7 +31,8 @@ interface DebugLog { } export function SequenceDiagramViewerPage() { - usePageTitle("Inspector"); + const { t } = useTranslation(); + usePageTitle(t("Inspector")); const [debugLog, setDebugLog] = useState(); const [selectedUserId, setSelectedUserId] = useState(); @@ -49,7 +51,7 @@ export function SequenceDiagramViewerPage() { type="file" id="debugLog" name="debugLog" - label="Debug Log" + label={t("Debug log")} onChange={onChangeDebugLog} /> diff --git a/src/TranslatedError.ts b/src/TranslatedError.ts new file mode 100644 index 00000000..62960f04 --- /dev/null +++ b/src/TranslatedError.ts @@ -0,0 +1,41 @@ +/* +Copyright 2022 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import i18n from "i18next"; + +/** + * An error with messages in both English and the user's preferred language. + */ +// Abstract to force consumers to use the function below rather than calling the +// constructor directly +export abstract class TranslatedError extends Error { + /** + * The error message in the user's preferred language. + */ + public readonly translatedMessage: string; + + public constructor(messageKey: string, translationFn: typeof i18n.t) { + super(translationFn(messageKey, { lng: "en-GB" })); + this.translatedMessage = translationFn(messageKey); + } +} + +class TranslatedErrorImpl extends TranslatedError {} + +// i18next-parser can't detect calls to a constructor, so we expose a bare +// function instead +export const translatedError = (messageKey: string, t: typeof i18n.t) => + new TranslatedErrorImpl(messageKey, t); diff --git a/src/room/useRoomParams.ts b/src/UrlParams.ts similarity index 82% rename from src/room/useRoomParams.ts rename to src/UrlParams.ts index 095bf5fd..84e2312f 100644 --- a/src/room/useRoomParams.ts +++ b/src/UrlParams.ts @@ -17,7 +17,7 @@ limitations under the License. import { useMemo } from "react"; import { useLocation } from "react-router-dom"; -export interface RoomParams { +export interface UrlParams { roomAlias: string | null; roomId: string | null; viaServers: string[]; @@ -39,25 +39,27 @@ export interface RoomParams { displayName: string | null; // The device's ID (only used in Matroska mode) deviceId: string | null; + // The BCP 47 code of the language the app should use + lang: string | null; } /** - * Gets the room parameters for the current URL. - * @param {string} query The URL query string - * @param {string} fragment The URL fragment string - * @returns {RoomParams} The room parameters encoded in the URL + * Gets the app parameters for the current URL. + * @param query The URL query string + * @param fragment The URL fragment string + * @returns The app parameters encoded in the URL */ -export const getRoomParams = ( +export const getUrlParams = ( query: string = window.location.search, fragment: string = window.location.hash -): RoomParams => { +): UrlParams => { const fragmentQueryStart = fragment.indexOf("?"); const fragmentParams = new URLSearchParams( fragmentQueryStart === -1 ? "" : fragment.substring(fragmentQueryStart) ); const queryParams = new URLSearchParams(query); - // Normally, room params should be encoded in the fragment so as to avoid + // Normally, URL params should be encoded in the fragment so as to avoid // leaking them to the server. However, we also check the normal query // string for backwards compatibility with versions that only used that. const hasParam = (name: string): boolean => @@ -87,14 +89,15 @@ export const getRoomParams = ( userId: getParam("userId"), displayName: getParam("displayName"), deviceId: getParam("deviceId"), + lang: getParam("lang"), }; }; /** - * Hook to simplify use of getRoomParams. - * @returns {RoomParams} The room parameters for the current URL + * Hook to simplify use of getUrlParams. + * @returns The app parameters for the current URL */ -export const useRoomParams = (): RoomParams => { +export const useUrlParams = (): UrlParams => { const { hash, search } = useLocation(); - return useMemo(() => getRoomParams(search, hash), [search, hash]); + return useMemo(() => getUrlParams(search, hash), [search, hash]); }; diff --git a/src/UserMenu.tsx b/src/UserMenu.tsx index 83da187a..fac4a867 100644 --- a/src/UserMenu.tsx +++ b/src/UserMenu.tsx @@ -1,6 +1,7 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useMemo } from "react"; import { Item } from "@react-stately/collections"; import { useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { Button, LinkButton } from "./button"; import { PopoverMenuTrigger } from "./popover/PopoverMenu"; @@ -30,6 +31,7 @@ export function UserMenu({ avatarUrl, onAction, }: UserMenuProps) { + const { t } = useTranslation(); const location = useLocation(); const items = useMemo(() => { @@ -45,7 +47,7 @@ export function UserMenu({ if (isPasswordlessUser && !preventNavigation) { arr.push({ key: "login", - label: "Sign In", + label: t("Sign in"), icon: LoginIcon, }); } @@ -53,14 +55,16 @@ export function UserMenu({ if (!isPasswordlessUser && !preventNavigation) { arr.push({ key: "logout", - label: "Sign Out", + label: t("Sign out"), icon: LogoutIcon, }); } } return arr; - }, [isAuthenticated, isPasswordlessUser, displayName, preventNavigation]); + }, [isAuthenticated, isPasswordlessUser, displayName, preventNavigation, t]); + + const tooltip = useCallback(() => t("Profile"), [t]); if (!isAuthenticated) { return ( @@ -72,7 +76,7 @@ export function UserMenu({ return ( - "Profile"} placement="bottom left"> + @@ -124,9 +126,11 @@ export const LoginPage: FC = () => {

    Not registered yet?

    - Create an account - {" Or "} - Access as a guest + + Create an account + {" Or "} + Access as a guest +

    diff --git a/src/auth/RegisterPage.tsx b/src/auth/RegisterPage.tsx index 0f908beb..f8583b74 100644 --- a/src/auth/RegisterPage.tsx +++ b/src/auth/RegisterPage.tsx @@ -26,6 +26,7 @@ import React, { import { useHistory, useLocation } from "react-router-dom"; import { captureException } from "@sentry/react"; import { sleep } from "matrix-js-sdk/src/utils"; +import { Trans, useTranslation } from "react-i18next"; import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { Button } from "../button"; @@ -40,7 +41,8 @@ import { Caption, Link } from "../typography/Typography"; import { usePageTitle } from "../usePageTitle"; export const RegisterPage: FC = () => { - usePageTitle("Register"); + const { t } = useTranslation(); + usePageTitle(t("Register")); const { loading, isAuthenticated, isPasswordlessUser, client, setClient } = useClient(); @@ -126,11 +128,11 @@ export const RegisterPage: FC = () => { useEffect(() => { if (password && passwordConfirmation && password !== passwordConfirmation) { - confirmPasswordRef.current?.setCustomValidity("Passwords must match"); + confirmPasswordRef.current?.setCustomValidity(t("Passwords must match")); } else { confirmPasswordRef.current?.setCustomValidity(""); } - }, [password, passwordConfirmation]); + }, [password, passwordConfirmation, t]); useEffect(() => { if (!loading && isAuthenticated && !isPasswordlessUser && !registering) { @@ -154,8 +156,8 @@ export const RegisterPage: FC = () => { { setPassword(e.target.value) } value={password} - placeholder="Password" - label="Password" + placeholder={t("Password")} + label={t("Password")} /> @@ -184,45 +186,49 @@ export const RegisterPage: FC = () => { setPasswordConfirmation(e.target.value) } value={passwordConfirmation} - placeholder="Confirm Password" - label="Confirm Password" + placeholder={t("Confirm password")} + label={t("Confirm password")} ref={confirmPasswordRef} /> - This site is protected by ReCAPTCHA and the Google{" "} - - Privacy Policy - {" "} - and{" "} - - Terms of Service - {" "} - apply. -
    - By clicking "Register", you agree to our{" "} - Terms and conditions + + This site is protected by ReCAPTCHA and the Google{" "} + + Privacy Policy + {" "} + and{" "} + + Terms of Service + {" "} + apply. +
    + By clicking "Register", you agree to our{" "} + Terms and conditions +
    {error && ( - {error.message} + )}
    -

    Already have an account?

    -

    - Log in - {" Or "} - Access as a guest -

    + +

    Already have an account?

    +

    + Log in + {" Or "} + Access as a guest +

    +
    diff --git a/src/auth/useRecaptcha.ts b/src/auth/useRecaptcha.ts index 76856a23..50d23c8c 100644 --- a/src/auth/useRecaptcha.ts +++ b/src/auth/useRecaptcha.ts @@ -16,6 +16,9 @@ limitations under the License. import { useEffect, useCallback, useRef, useState } from "react"; import { randomString } from "matrix-js-sdk/src/randomstring"; +import { useTranslation } from "react-i18next"; + +import { translatedError } from "../TranslatedError"; declare global { interface Window { @@ -32,6 +35,7 @@ interface RecaptchaPromiseRef { } export const useRecaptcha = (sitekey: string) => { + const { t } = useTranslation(); const [recaptchaId] = useState(() => randomString(16)); const promiseRef = useRef(); @@ -71,14 +75,14 @@ export const useRecaptcha = (sitekey: string) => { if (!window.grecaptcha) { console.log("Recaptcha not loaded"); - return Promise.reject(new Error("Recaptcha not loaded")); + return Promise.reject(translatedError("Recaptcha not loaded", t)); } return new Promise((resolve, reject) => { const observer = new MutationObserver((mutationsList) => { for (const item of mutationsList) { if ((item.target as HTMLElement)?.style?.visibility !== "visible") { - reject(new Error("Recaptcha dismissed")); + reject(translatedError("Recaptcha dismissed", t)); observer.disconnect(); return; } @@ -108,7 +112,7 @@ export const useRecaptcha = (sitekey: string) => { }); } }); - }, [sitekey]); + }, [sitekey, t]); const reset = useCallback(() => { window.grecaptcha?.reset(); diff --git a/src/button/Button.tsx b/src/button/Button.tsx index c29b5f24..80220ff8 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -18,6 +18,7 @@ import { PressEvent } from "@react-types/shared"; import classNames from "classnames"; import { useButton } from "@react-aria/button"; import { mergeProps, useObjectRef } from "@react-aria/utils"; +import { useTranslation } from "react-i18next"; import styles from "./Button.module.css"; import { ReactComponent as MicIcon } from "../icons/Mic.svg"; @@ -142,9 +143,11 @@ export function MicButton({ // TODO: add all props for @@ -239,8 +252,11 @@ export function InviteButton({ // TODO: add all props for @@ -256,8 +272,11 @@ interface AudioButtonProps extends Omit { } export function AudioButton({ volume, ...rest }: AudioButtonProps) { + const { t } = useTranslation(); + const tooltip = useCallback(() => t("Local volume"), [t]); + return ( - "Local volume"}> + @@ -273,12 +292,13 @@ export function FullscreenButton({ fullscreen, ...rest }: FullscreenButtonProps) { - const getTooltip = useCallback(() => { - return fullscreen ? "Exit full screen" : "Full screen"; - }, [fullscreen]); + const { t } = useTranslation(); + const tooltip = useCallback(() => { + return fullscreen ? t("Exit full screen") : t("Full screen"); + }, [fullscreen, t]); return ( - + diff --git a/src/button/CopyButton.tsx b/src/button/CopyButton.tsx index d6f159ea..5453f0af 100644 --- a/src/button/CopyButton.tsx +++ b/src/button/CopyButton.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React from "react"; +import { useTranslation } from "react-i18next"; import useClipboard from "react-use-clipboard"; import { ReactComponent as CheckIcon } from "../icons/Check.svg"; @@ -36,6 +37,7 @@ export function CopyButton({ copiedMessage, ...rest }: Props) { + const { t } = useTranslation(); const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 }); return ( @@ -49,7 +51,7 @@ export function CopyButton({ > {isCopied ? ( <> - {variant !== "icon" && {copiedMessage || "Copied!"}} + {variant !== "icon" && {copiedMessage || t("Copied!")}} ) : ( diff --git a/src/home/CallTypeDropdown.tsx b/src/home/CallTypeDropdown.tsx index 2713e4d0..5ee4ec24 100644 --- a/src/home/CallTypeDropdown.tsx +++ b/src/home/CallTypeDropdown.tsx @@ -16,6 +16,7 @@ limitations under the License. import React, { FC } from "react"; import { Item } from "@react-stately/collections"; +import { useTranslation } from "react-i18next"; import { Headline } from "../typography/Typography"; import { Button } from "../button"; @@ -39,25 +40,29 @@ interface Props { } export const CallTypeDropdown: FC = ({ callType, setCallType }) => { + const { t } = useTranslation(); + return ( {(props: JSX.IntrinsicAttributes) => ( - - + + - Video call + {t("Video call")} {callType === CallType.Video && ( )} - + - Walkie-talkie call + {t("Walkie-talkie call")} {callType === CallType.Radio && ( )} diff --git a/src/home/HomePage.tsx b/src/home/HomePage.tsx index 00f770fd..7ff6efcc 100644 --- a/src/home/HomePage.tsx +++ b/src/home/HomePage.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React from "react"; +import { useTranslation } from "react-i18next"; import { useClient } from "../ClientContext"; import { ErrorView, LoadingView } from "../FullScreenView"; @@ -23,7 +24,8 @@ import { RegisteredView } from "./RegisteredView"; import { usePageTitle } from "../usePageTitle"; export function HomePage() { - usePageTitle("Home"); + const { t } = useTranslation(); + usePageTitle(t("Home")); const { isAuthenticated, isPasswordlessUser, loading, error, client } = useClient(); diff --git a/src/home/JoinExistingCallModal.tsx b/src/home/JoinExistingCallModal.tsx index b26c45d9..8f4f7d7f 100644 --- a/src/home/JoinExistingCallModal.tsx +++ b/src/home/JoinExistingCallModal.tsx @@ -16,6 +16,7 @@ limitations under the License. import React from "react"; import { PressEvent } from "@react-types/shared"; +import { useTranslation } from "react-i18next"; import { Modal, ModalContent } from "../Modal"; import { Button } from "../button"; @@ -29,13 +30,15 @@ interface Props { [index: string]: unknown; } export function JoinExistingCallModal({ onJoin, onClose, ...rest }: Props) { + const { t } = useTranslation(); + return ( - + -

    This call already exists, would you like to join?

    +

    {t("This call already exists, would you like to join?")}

    - - + +
    diff --git a/src/home/RegisteredView.tsx b/src/home/RegisteredView.tsx index 71abb281..af72e319 100644 --- a/src/home/RegisteredView.tsx +++ b/src/home/RegisteredView.tsx @@ -22,6 +22,7 @@ import React, { } from "react"; import { useHistory } from "react-router-dom"; import { MatrixClient } from "matrix-js-sdk/src/client"; +import { useTranslation } from "react-i18next"; import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils"; import { useGroupCallRooms } from "./useGroupCallRooms"; @@ -48,6 +49,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) { const [loading, setLoading] = useState(false); const [error, setError] = useState(); const history = useHistory(); + const { t } = useTranslation(); const { modalState, modalProps } = useModalTriggerState(); const onSubmit: FormEventHandler = useCallback( @@ -93,7 +95,9 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) { }, [history, existingRoomId]); const callNameLabel = - callType === CallType.Video ? "Video call name" : "Walkie-talkie call name"; + callType === CallType.Video + ? t("Video call name") + : t("Walkie-talkie call name"); return ( <> @@ -127,19 +131,19 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) { className={styles.button} disabled={loading} > - {loading ? "Loading..." : "Go"} + {loading ? t("Loading…") : t("Go")} {error && ( - {error.message} + )} {recentRooms.length > 0 && ( <> - Your recent Calls + {t("Your recent calls")} diff --git a/src/home/UnauthenticatedView.tsx b/src/home/UnauthenticatedView.tsx index 2cf09d3c..a84a7b2b 100644 --- a/src/home/UnauthenticatedView.tsx +++ b/src/home/UnauthenticatedView.tsx @@ -17,6 +17,7 @@ limitations under the License. import React, { FC, useCallback, useState, FormEventHandler } from "react"; import { useHistory } from "react-router-dom"; import { randomString } from "matrix-js-sdk/src/randomstring"; +import { Trans, useTranslation } from "react-i18next"; import { useClient } from "../ClientContext"; import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; @@ -47,6 +48,7 @@ export const UnauthenticatedView: FC = () => { const { modalState, modalProps } = useModalTriggerState(); const [onFinished, setOnFinished] = useState<() => void>(); const history = useHistory(); + const { t } = useTranslation(); const onSubmit: FormEventHandler = useCallback( (e) => { @@ -105,7 +107,9 @@ export const UnauthenticatedView: FC = () => { ); const callNameLabel = - callType === CallType.Video ? "Video call name" : "Walkie-talkie call name"; + callType === CallType.Video + ? t("Video call name") + : t("Walkie-talkie call name"); return ( <> @@ -137,24 +141,26 @@ export const UnauthenticatedView: FC = () => { - By clicking "Go", you agree to our{" "} - Terms and conditions + + By clicking "Go", you agree to our{" "} + Terms and conditions + {error && ( - {error.message} + )}
    @@ -162,14 +168,16 @@ export const UnauthenticatedView: FC = () => {
    - Login to your account + {t("Login to your account")} - Not registered yet?{" "} - - Create an account - + + Not registered yet?{" "} + + Create an account + +
    diff --git a/src/input/AvatarInputField.tsx b/src/input/AvatarInputField.tsx index 8f0aa4d8..a9778396 100644 --- a/src/input/AvatarInputField.tsx +++ b/src/input/AvatarInputField.tsx @@ -20,6 +20,7 @@ import { useCallback } from "react"; import { useState } from "react"; import { forwardRef } from "react"; import classNames from "classnames"; +import { useTranslation } from "react-i18next"; import { Avatar, Size } from "../Avatar"; import { Button } from "../button"; @@ -39,6 +40,8 @@ export const AvatarInputField = forwardRef( { id, label, className, avatarUrl, displayName, onRemoveAvatar, ...rest }, ref ) => { + const { t } = useTranslation(); + const [removed, setRemoved] = useState(false); const [objUrl, setObjUrl] = useState(null); @@ -97,7 +100,7 @@ export const AvatarInputField = forwardRef( variant="icon" onPress={onPressRemoveAvatar} > - Remove + {t("Remove")} )} diff --git a/src/input/Input.tsx b/src/input/Input.tsx index 5b6ec665..cd7603fa 100644 --- a/src/input/Input.tsx +++ b/src/input/Input.tsx @@ -14,11 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ChangeEvent, forwardRef, ReactNode } from "react"; +import React, { ChangeEvent, FC, forwardRef, ReactNode } from "react"; import classNames from "classnames"; import styles from "./Input.module.css"; import { ReactComponent as CheckIcon } from "../icons/Check.svg"; +import { TranslatedError } from "../TranslatedError"; interface FieldRowProps { children: ReactNode; @@ -140,10 +141,12 @@ export const InputField = forwardRef< } ); -export function ErrorMessage({ - children, -}: { - children: ReactNode; -}): JSX.Element { - return

    {children}

    ; +interface ErrorMessageProps { + error: Error; } + +export const ErrorMessage: FC = ({ error }) => ( +

    + {error instanceof TranslatedError ? error.translatedMessage : error.message} +

    +); diff --git a/src/input/SelectInput.tsx b/src/input/SelectInput.tsx index c31d9c34..e3c47741 100644 --- a/src/input/SelectInput.tsx +++ b/src/input/SelectInput.tsx @@ -19,6 +19,7 @@ import { AriaSelectOptions, HiddenSelect, useSelect } from "@react-aria/select"; import { useButton } from "@react-aria/button"; import { useSelectState } from "@react-stately/select"; import classNames from "classnames"; +import { useTranslation } from "react-i18next"; import { Popover } from "../popover/Popover"; import { ListBox } from "../ListBox"; @@ -30,6 +31,7 @@ interface Props extends AriaSelectOptions { } export function SelectInput(props: Props): JSX.Element { + const { t } = useTranslation(); const state = useSelectState(props); const ref = useRef(); @@ -56,7 +58,7 @@ export function SelectInput(props: Props): JSX.Element { {state.selectedItem ? state.selectedItem.rendered - : "Select an option"} + : t("Select an option")} diff --git a/src/main.tsx b/src/main.tsx index a1739c7b..4b4f0809 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -25,10 +25,15 @@ import ReactDOM from "react-dom"; import { createBrowserHistory } from "history"; import * as Sentry from "@sentry/react"; import { Integrations } from "@sentry/tracing"; +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import Backend from "i18next-http-backend"; +import LanguageDetector from "i18next-browser-languagedetector"; import "./index.css"; import App from "./App"; import { init as initRageshake } from "./settings/rageshake"; +import { getUrlParams } from "./UrlParams"; initRageshake(); @@ -104,6 +109,35 @@ Sentry.init({ tracesSampleRate: 1.0, }); +const languageDetector = new LanguageDetector(); +languageDetector.addDetector({ + name: "urlFragment", + // Look for a language code in the URL's fragment + lookup: () => getUrlParams().lang ?? undefined, +}); + +i18n + .use(Backend) + .use(languageDetector) + .use(initReactI18next) + .init({ + fallbackLng: "en-GB", + defaultNS: "app", + keySeparator: false, + nsSeparator: false, + pluralSeparator: "|", + contextSeparator: "|", + interpolation: { + escapeValue: false, // React has built-in XSS protections + }, + detection: { + // No localStorage detectors or caching here, since we don't have any way + // of letting the user manually select a language + order: ["urlFragment", "navigator"], + caches: [], + }, + }); + ReactDOM.render( diff --git a/src/matrix-utils.ts b/src/matrix-utils.ts index 3d93a339..282e1b75 100644 --- a/src/matrix-utils.ts +++ b/src/matrix-utils.ts @@ -19,7 +19,7 @@ import { import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { Room } from "matrix-js-sdk/src/models/room"; import IndexedDBWorker from "./IndexedDBWorker?worker"; -import { getRoomParams } from "./room/useRoomParams"; +import { getUrlParams } from "./UrlParams"; export const defaultHomeserver = (import.meta.env.VITE_DEFAULT_HOMESERVER as string) ?? @@ -134,12 +134,12 @@ export async function initClient( storeOpts.cryptoStore = new MemoryCryptoStore(); } - // XXX: we read from the room params in RoomPage too: + // XXX: we read from the URL params in RoomPage too: // it would be much better to read them in one place and pass // the values around, but we initialise the matrix client in // many different places so we'd have to pass it into all of // them. - const { e2eEnabled } = getRoomParams(); + const { e2eEnabled } = getUrlParams(); if (!e2eEnabled) { logger.info("Disabling E2E: group call signalling will NOT be encrypted."); } diff --git a/src/profile/ProfileModal.tsx b/src/profile/ProfileModal.tsx index 89f967b8..28fc9977 100644 --- a/src/profile/ProfileModal.tsx +++ b/src/profile/ProfileModal.tsx @@ -16,6 +16,7 @@ limitations under the License. import React, { ChangeEvent, useCallback, useEffect, useState } from "react"; import { MatrixClient } from "matrix-js-sdk/src/client"; +import { useTranslation } from "react-i18next"; import { Button } from "../button"; import { useProfile } from "./useProfile"; @@ -31,6 +32,7 @@ interface Props { } export function ProfileModal({ client, ...rest }: Props) { const { onClose } = rest; + const { t } = useTranslation(); const { success, error, @@ -83,14 +85,14 @@ export function ProfileModal({ client, ...rest }: Props) { }, [success, onClose]); return ( - +
    {error && ( - {error.message} + )} @@ -129,7 +131,7 @@ export function ProfileModal({ client, ...rest }: Props) { Cancel
    diff --git a/src/room/AudioPreview.tsx b/src/room/AudioPreview.tsx index c0e2e8b4..8d6f164d 100644 --- a/src/room/AudioPreview.tsx +++ b/src/room/AudioPreview.tsx @@ -17,6 +17,7 @@ limitations under the License. import React from "react"; import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall"; import { Item } from "@react-stately/collections"; +import { useTranslation } from "react-i18next"; import styles from "./AudioPreview.module.css"; import { SelectInput } from "../input/SelectInput"; @@ -43,24 +44,26 @@ export function AudioPreview({ audioOutputs, setAudioOutput, }: Props) { + const { t } = useTranslation(); + return ( <> -

    {`${roomName} - Walkie-talkie call`}

    +

    {t("{{roomName}} - Walkie-talkie call", { roomName })}

    {state === GroupCallState.LocalCallFeedUninitialized && ( - Microphone permissions needed to join the call. + {t("Microphone permissions needed to join the call.")} )} {state === GroupCallState.InitializingLocalCallFeed && ( - Accept microphone permissions to join the call. + {t("Accept microphone permissions to join the call.")} )} {state === GroupCallState.LocalCallFeedInitialized && ( <> {!!label && label.trim().length > 0 ? label - : `Microphone ${index + 1}`} + : t("Microphone {{n}}", { n: index + 1 })} ))} {audioOutputs.length > 0 && ( {!!label && label.trim().length > 0 ? label - : `Speaker ${index + 1}`} + : t("Speaker {{n}}", { n: index + 1 })} ))} diff --git a/src/room/CallEndedView.tsx b/src/room/CallEndedView.tsx index c7371d66..b78b800d 100644 --- a/src/room/CallEndedView.tsx +++ b/src/room/CallEndedView.tsx @@ -16,6 +16,7 @@ limitations under the License. import React from "react"; import { MatrixClient } from "matrix-js-sdk/src/client"; +import { Trans, useTranslation } from "react-i18next"; import styles from "./CallEndedView.module.css"; import { LinkButton } from "../button"; @@ -24,6 +25,7 @@ import { Subtitle, Body, Link, Headline } from "../typography/Typography"; import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; export function CallEndedView({ client }: { client: MatrixClient }) { + const { t } = useTranslation(); const { displayName } = useProfile(client); return ( @@ -37,29 +39,31 @@ export function CallEndedView({ client }: { client: MatrixClient }) {
    - {displayName}, your call is now ended + {t("{{displayName}}, your call is now ended", { displayName })}
    - - Why not finish by setting up a password to keep your account? - - - You'll be able to keep your name and set an avatar for use on - future calls - + + + Why not finish by setting up a password to keep your account? + + + You'll be able to keep your name and set an avatar for use on + future calls + + - Create account + {t("Create account")}
    - Not now, return to home screen + {t("Not now, return to home screen")}
    diff --git a/src/room/FeedbackModal.tsx b/src/room/FeedbackModal.tsx index 7b452d85..db0f34f6 100644 --- a/src/room/FeedbackModal.tsx +++ b/src/room/FeedbackModal.tsx @@ -16,6 +16,7 @@ limitations under the License. import React, { useCallback, useEffect } from "react"; import { randomString } from "matrix-js-sdk/src/randomstring"; +import { useTranslation } from "react-i18next"; import { Modal, ModalContent } from "../Modal"; import { Button } from "../button"; @@ -25,6 +26,7 @@ import { useRageshakeRequest, } from "../settings/submit-rageshake"; import { Body } from "../typography/Typography"; + interface Props { inCall: boolean; roomId: string; @@ -32,7 +34,9 @@ interface Props { // TODO: add all props for for [index: string]: unknown; } + export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) { + const { t } = useTranslation(); const { submitRageshake, sending, sent, error } = useSubmitRageshake(); const sendRageshakeRequest = useRageshakeRequest(); @@ -67,15 +71,20 @@ export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) { }, [sent, onClose]); return ( - + - Having trouble? Help us fix it. + {t("Having trouble? Help us fix it.")}
    @@ -83,19 +92,19 @@ export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) { {error && ( - {error.message} + )} diff --git a/src/room/GridLayoutMenu.tsx b/src/room/GridLayoutMenu.tsx index 6b00c44e..081af4e4 100644 --- a/src/room/GridLayoutMenu.tsx +++ b/src/room/GridLayoutMenu.tsx @@ -14,8 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { useCallback } from "react"; import { Item } from "@react-stately/collections"; +import { useTranslation } from "react-i18next"; import { Button } from "../button"; import { PopoverMenuTrigger } from "../popover/PopoverMenu"; @@ -27,28 +28,33 @@ import { Menu } from "../Menu"; import { TooltipTrigger } from "../Tooltip"; export type Layout = "freedom" | "spotlight"; + interface Props { layout: Layout; setLayout: (layout: Layout) => void; } + export function GridLayoutMenu({ layout, setLayout }: Props) { + const { t } = useTranslation(); + const tooltip = useCallback(() => t("Change layout"), [t]); + return ( - "Layout Type"}> + {(props: JSX.IntrinsicAttributes) => ( - - + + Freedom {layout === "freedom" && ( )} - + Spotlight {layout === "spotlight" && ( diff --git a/src/room/GroupCallLoader.tsx b/src/room/GroupCallLoader.tsx index 8c13626d..f0ee28f3 100644 --- a/src/room/GroupCallLoader.tsx +++ b/src/room/GroupCallLoader.tsx @@ -17,6 +17,7 @@ limitations under the License. import React, { ReactNode } from "react"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; +import { useTranslation } from "react-i18next"; import { useLoadGroupCall } from "./useLoadGroupCall"; import { ErrorView, FullScreenView } from "../FullScreenView"; @@ -37,6 +38,7 @@ export function GroupCallLoader({ children, createPtt, }: Props): JSX.Element { + const { t } = useTranslation(); const { loading, error, groupCall } = useLoadGroupCall( client, roomIdOrAlias, @@ -44,12 +46,12 @@ export function GroupCallLoader({ createPtt ); - usePageTitle(groupCall ? groupCall.room.name : "Loading..."); + usePageTitle(groupCall ? groupCall.room.name : t("Loading…")); if (loading) { return ( -

    Loading room...

    +

    {t("Loading room…")}

    ); } diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index b777444e..8bf6a132 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -19,6 +19,7 @@ import { useHistory } from "react-router-dom"; import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; +import { useTranslation } from "react-i18next"; import type { IWidgetApiRequest } from "matrix-widget-api"; import { widget, ElementWidgetActions, JoinCallData } from "../widget"; @@ -81,8 +82,8 @@ export function GroupCallView({ unencryptedEventsFromUsers, } = useGroupCall(groupCall); + const { t } = useTranslation(); const { setAudioInput, setVideoInput } = useMediaHandler(); - const avatarUrl = useRoomAvatar(groupCall.room); useEffect(() => { @@ -240,7 +241,7 @@ export function GroupCallView({ } else if (state === GroupCallState.Entering) { return ( -

    Entering room...

    +

    {t("Entering room…")}

    ); } else if (left) { @@ -257,7 +258,7 @@ export function GroupCallView({ } else if (isEmbedded) { return ( -

    Loading room...

    +

    {t("Loading room…")}

    ); } else { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index fd987e6b..03c95fd2 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -23,6 +23,7 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed"; import classNames from "classnames"; +import { useTranslation } from "react-i18next"; import type { IWidgetApiRequest } from "matrix-widget-api"; import styles from "./InCallView.module.css"; @@ -112,6 +113,7 @@ export function InCallView({ unencryptedEventsFromUsers, hideHeader, }: Props) { + const { t } = useTranslation(); usePreventScroll(); const containerRef1 = useRef(null); const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver }); @@ -247,7 +249,7 @@ export function InCallView({ if (items.length === 0) { return (
    -

    Waiting for other participants...

    +

    {t("Waiting for other participants…")}

    ); } diff --git a/src/room/InviteModal.tsx b/src/room/InviteModal.tsx index 7c0e7591..75ea5acb 100644 --- a/src/room/InviteModal.tsx +++ b/src/room/InviteModal.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; import { Modal, ModalContent, ModalProps } from "../Modal"; import { CopyButton } from "../button"; @@ -25,19 +26,23 @@ interface Props extends Omit { roomIdOrAlias: string; } -export const InviteModal: FC = ({ roomIdOrAlias, ...rest }) => ( - - -

    Copy and share this meeting link

    - -
    -
    -); +export const InviteModal: FC = ({ roomIdOrAlias, ...rest }) => { + const { t } = useTranslation(); + + return ( + + +

    {t("Copy and share this call link")}

    + +
    +
    + ); +}; diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index 72a0c040..b246d0c0 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -19,6 +19,7 @@ import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { PressEvent } from "@react-types/shared"; import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed"; +import { useTranslation } from "react-i18next"; import styles from "./LobbyView.module.css"; import { Button, CopyButton } from "../button"; @@ -66,6 +67,7 @@ export function LobbyView({ isEmbedded, hideHeader, }: Props) { + const { t } = useTranslation(); const { stream } = useCallFeed(localCallFeed); const { audioInput, @@ -142,15 +144,15 @@ export function LobbyView({ variant="secondaryCopy" value={getRoomUrl(roomIdOrAlias)} className={styles.copyButton} - copiedMessage="Call link copied" + copiedMessage={t("Call link copied")} > - Copy call link and join later + {t("Copy call link and join later")}
    {!isEmbedded && ( - Take me Home + {t("Take me Home")} )} diff --git a/src/room/OverflowMenu.tsx b/src/room/OverflowMenu.tsx index f973f8be..fc726b63 100644 --- a/src/room/OverflowMenu.tsx +++ b/src/room/OverflowMenu.tsx @@ -18,6 +18,7 @@ import React, { useCallback } from "react"; import { Item } from "@react-stately/collections"; import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import { OverlayTriggerState } from "@react-stately/overlays"; +import { useTranslation } from "react-i18next"; import { Button } from "../button"; import { Menu } from "../Menu"; @@ -31,6 +32,7 @@ import { SettingsModal } from "../settings/SettingsModal"; import { InviteModal } from "./InviteModal"; import { TooltipTrigger } from "../Tooltip"; import { FeedbackModal } from "./FeedbackModal"; + interface Props { roomIdOrAlias: string; inCall: boolean; @@ -42,6 +44,7 @@ interface Props { onClose: () => void; }; } + export function OverflowMenu({ roomIdOrAlias, inCall, @@ -50,6 +53,8 @@ export function OverflowMenu({ feedbackModalState, feedbackModalProps, }: Props) { + const { t } = useTranslation(); + const { modalState: inviteModalState, modalProps: inviteModalProps, @@ -90,29 +95,31 @@ export function OverflowMenu({ [feedbackModalState, inviteModalState, settingsModalState] ); + const tooltip = useCallback(() => t("More"), [t]); + return ( <> - "More"} placement="top"> + {(props: JSX.IntrinsicAttributes) => ( - + {showInvite && ( - + - Invite people + {t("Invite people")} )} - + - Settings + {t("Settings")} - + - Submit Feedback + {t("Submit feedback")} )} diff --git a/src/room/PTTCallView.tsx b/src/room/PTTCallView.tsx index eed22770..922bd148 100644 --- a/src/room/PTTCallView.tsx +++ b/src/room/PTTCallView.tsx @@ -17,10 +17,12 @@ limitations under the License. import React, { useEffect } from "react"; import useMeasure from "react-use-measure"; import { ResizeObserver } from "@juggle/resize-observer"; +import i18n from "i18next"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed"; +import { useTranslation } from "react-i18next"; import { useDelayedState } from "../useDelayedState"; import { useModalTriggerState } from "../Modal"; @@ -50,40 +52,45 @@ function getPromptText( talkOverEnabled: boolean, activeSpeakerUserId: string, activeSpeakerDisplayName: string, - connected: boolean + connected: boolean, + t: typeof i18n.t ): string { - if (!connected) return "Connection lost"; + if (!connected) return t("Connection lost"); const isTouchScreen = Boolean(window.ontouchstart !== undefined); if (networkWaiting) { - return "Waiting for network"; + return t("Waiting for network"); } if (showTalkOverError) { - return "You can't talk at the same time"; + return t("You can't talk at the same time"); } if (pttButtonHeld && activeSpeakerIsLocalUser) { if (isTouchScreen) { - return "Release to stop"; + return t("Release to stop"); } else { - return "Release spacebar key to stop"; + return t("Release spacebar key to stop"); } } if (talkOverEnabled && activeSpeakerUserId && !activeSpeakerIsLocalUser) { if (isTouchScreen) { - return `Press and hold to talk over ${activeSpeakerDisplayName}`; + return t("Press and hold to talk over {{name}}", { + name: activeSpeakerDisplayName, + }); } else { - return `Press and hold spacebar to talk over ${activeSpeakerDisplayName}`; + return t("Press and hold spacebar to talk over {{name}}", { + name: activeSpeakerDisplayName, + }); } } if (isTouchScreen) { - return "Press and hold to talk"; + return t("Press and hold to talk"); } else { - return "Press and hold spacebar to talk"; + return t("Press and hold spacebar to talk"); } } @@ -112,6 +119,7 @@ export const PTTCallView: React.FC = ({ isEmbedded, hideHeader, }) => { + const { t } = useTranslation(); const { modalState: inviteModalState, modalProps: inviteModalProps } = useModalTriggerState(); const { modalState: feedbackModalState, modalProps: feedbackModalProps } = @@ -195,9 +203,11 @@ export const PTTCallView: React.FC = ({ {showControls && ( <>
    -

    {`${participants.length} ${ - participants.length > 1 ? "people" : "person" - } connected`}

    +

    + {t("{{count}} people connected", { + count: participants.length, + })} +

    = ({ )} {activeSpeakerIsLocalUser - ? "Talking..." - : `${activeSpeakerDisplayName} is talking...`} + ? t("Talking…") + : t("{{name}} is talking…", { + name: activeSpeakerDisplayName, + })}
    @@ -263,7 +275,8 @@ export const PTTCallView: React.FC = ({ talkOverEnabled, activeSpeakerUserId, activeSpeakerDisplayName, - connected + connected, + t )}

    )} @@ -278,7 +291,7 @@ export const PTTCallView: React.FC = ({ )} diff --git a/src/room/RageshakeRequestModal.tsx b/src/room/RageshakeRequestModal.tsx index 59b74b3a..d1220b5d 100644 --- a/src/room/RageshakeRequestModal.tsx +++ b/src/room/RageshakeRequestModal.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React, { FC, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { Modal, ModalContent, ModalProps } from "../Modal"; import { Button } from "../button"; @@ -33,6 +34,7 @@ export const RageshakeRequestModal: FC = ({ roomIdOrAlias, ...rest }) => { + const { t } = useTranslation(); const { submitRageshake, sending, sent, error } = useSubmitRageshake(); useEffect(() => { @@ -42,11 +44,12 @@ export const RageshakeRequestModal: FC = ({ }, [sent, rest]); return ( - + - Another user on this call is having an issue. In order to better - diagnose these issues we'd like to collect a debug log. + {t( + "Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log." + )} {error && ( - {error.message} + )} diff --git a/src/room/RoomAuthView.tsx b/src/room/RoomAuthView.tsx index ff2e8f04..d38f05b7 100644 --- a/src/room/RoomAuthView.tsx +++ b/src/room/RoomAuthView.tsx @@ -16,6 +16,7 @@ limitations under the License. import React, { useCallback, useState } from "react"; import { useLocation } from "react-router-dom"; +import { Trans, useTranslation } from "react-i18next"; import styles from "./RoomAuthView.module.css"; import { Button } from "../button"; @@ -50,6 +51,7 @@ export function RoomAuthView() { [registerPasswordlessUser] ); + const { t } = useTranslation(); const location = useLocation(); return ( @@ -64,42 +66,46 @@ export function RoomAuthView() {
    - Join Call + {t("Join call")}
    - By clicking "Join call now", you agree to our{" "} - Terms and conditions + + By clicking "Join call now", you agree to our{" "} + Terms and conditions + {error && ( - {error.message} + )}
    - {"Not registered yet? "} - - Create an account - + + {"Not registered yet? "} + + Create an account + +
    diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 1adff571..e8b3fa9a 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React, { FC, useEffect, useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import { useClient } from "../ClientContext"; @@ -22,11 +23,13 @@ import { ErrorView, LoadingView } from "../FullScreenView"; import { RoomAuthView } from "./RoomAuthView"; import { GroupCallLoader } from "./GroupCallLoader"; import { GroupCallView } from "./GroupCallView"; -import { useRoomParams } from "./useRoomParams"; +import { useUrlParams } from "../UrlParams"; import { MediaHandlerProvider } from "../settings/useMediaHandler"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; +import { translatedError } from "../TranslatedError"; export const RoomPage: FC = () => { + const { t } = useTranslation(); const { loading, isAuthenticated, error, client, isPasswordlessUser } = useClient(); @@ -39,9 +42,9 @@ export const RoomPage: FC = () => { hideHeader, isPtt, displayName, - } = useRoomParams(); + } = useUrlParams(); const roomIdOrAlias = roomId ?? roomAlias; - if (!roomIdOrAlias) throw new Error("No room specified"); + if (!roomIdOrAlias) throw translatedError("No room specified", t); const { registerPasswordlessUser } = useRegisterPasswordlessUser(); const [isRegistering, setIsRegistering] = useState(false); diff --git a/src/room/VideoPreview.tsx b/src/room/VideoPreview.tsx index 21b559d1..07aa95e8 100644 --- a/src/room/VideoPreview.tsx +++ b/src/room/VideoPreview.tsx @@ -19,6 +19,7 @@ import useMeasure from "react-use-measure"; import { ResizeObserver } from "@juggle/resize-observer"; import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall"; import { MatrixClient } from "matrix-js-sdk/src/client"; +import { useTranslation } from "react-i18next"; import { MicButton, VideoButton } from "../button"; import { useMediaStream } from "../video-grid/useMediaStream"; @@ -40,6 +41,7 @@ interface Props { audioOutput: string; stream: MediaStream; } + export function VideoPreview({ client, state, @@ -51,6 +53,7 @@ export function VideoPreview({ audioOutput, stream, }: Props) { + const { t } = useTranslation(); const videoRef = useMediaStream(stream, audioOutput, true); const { displayName, avatarUrl } = useProfile(client); const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver }); @@ -64,12 +67,12 @@ export function VideoPreview({