Set up translation with i18next

This commit is contained in:
Robin Townsend 2022-10-10 09:19:10 -04:00
parent eca598e28f
commit 8524b9ecd6
55 changed files with 1470 additions and 326 deletions

20
i18next-parser.config.js Normal file
View File

@ -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,
};

View File

@ -1,5 +1,6 @@
{ {
"version": "0.0.0", "version": "0.0.0",
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
@ -10,7 +11,8 @@
"prettier:format": "prettier -w src", "prettier:format": "prettier -w src",
"lint": "yarn lint:types && yarn lint:js", "lint": "yarn lint:types && yarn lint:js",
"lint:js": "eslint --max-warnings 0 src", "lint:js": "eslint --max-warnings 0 src",
"lint:types": "tsc" "lint:types": "tsc",
"i18n": "node_modules/i18next-parser/bin/cli.js"
}, },
"dependencies": { "dependencies": {
"@juggle/resize-observer": "^3.3.1", "@juggle/resize-observer": "^3.3.1",
@ -38,6 +40,9 @@
"classnames": "^2.3.1", "classnames": "^2.3.1",
"color-hash": "^2.0.1", "color-hash": "^2.0.1",
"events": "^3.3.0", "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-js-sdk": "github:matrix-org/matrix-js-sdk#ce3b72c85031f188a092d1c39806ef7536e65bdd",
"matrix-widget-api": "^1.0.0", "matrix-widget-api": "^1.0.0",
"mermaid": "^8.13.8", "mermaid": "^8.13.8",
@ -47,6 +52,7 @@
"re-resizable": "^6.9.0", "re-resizable": "^6.9.0",
"react": "^17.0.0", "react": "^17.0.0",
"react-dom": "^17.0.0", "react-dom": "^17.0.0",
"react-i18next": "^11.18.6",
"react-json-view": "^1.21.3", "react-json-view": "^1.21.3",
"react-router": "6", "react-router": "6",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
@ -71,6 +77,7 @@
"eslint-plugin-matrix-org": "^0.4.0", "eslint-plugin-matrix-org": "^0.4.0",
"eslint-plugin-react": "^7.29.4", "eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.5.0", "eslint-plugin-react-hooks": "^4.5.0",
"i18next-parser": "^6.6.0",
"prettier": "^2.6.2", "prettier": "^2.6.2",
"sass": "^1.42.1", "sass": "^1.42.1",
"storybook-builder-vite": "^0.1.12", "storybook-builder-vite": "^0.1.12",

View File

@ -0,0 +1,4 @@
{
"Invite": "Einladen",
"Video call": "Videoanruf"
}

View File

@ -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?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>",
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Create an account</0> Or <2>Access as a guest</2>",
"<0>Oops, something's gone wrong.</0><1>Submitting debug logs will help us track down the problem.</1>": "<0>Oops, something's gone wrong.</0><1>Submitting debug logs will help us track down the problem.</1>",
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>",
"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</2>": "By clicking \"Go\", you agree to our <2>Terms and conditions</2>",
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>",
"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</1>": "Not registered yet? <1>Create an account</1>",
"Not registered yet? <2>Create an account</2>": "Not registered yet? <2>Create an account</2>",
"Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>": "Other users are trying to join this call from incompatible versions. These users should ensure that they have refreshed their browsers:<1>{userLis}</1>",
"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</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>",
"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"
}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { Suspense } from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { OverlayProvider } from "@react-aria/overlays"; import { OverlayProvider } from "@react-aria/overlays";
@ -43,6 +43,7 @@ export default function App({ history }: AppProps) {
return ( return (
<Router history={history}> <Router history={history}>
<Suspense fallback={null}>
<ClientProvider> <ClientProvider>
<InspectorContextProvider> <InspectorContextProvider>
<Sentry.ErrorBoundary fallback={errorPage}> <Sentry.ErrorBoundary fallback={errorPage}>
@ -71,6 +72,7 @@ export default function App({ history }: AppProps) {
</Sentry.ErrorBoundary> </Sentry.ErrorBoundary>
</InspectorContextProvider> </InspectorContextProvider>
</ClientProvider> </ClientProvider>
</Suspense>
</Router> </Router>
); );
} }

View File

@ -27,6 +27,7 @@ import { useHistory } from "react-router-dom";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client"; import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import { ErrorView } from "./FullScreenView"; import { ErrorView } from "./FullScreenView";
import { import {
@ -35,6 +36,7 @@ import {
CryptoStoreIntegrityError, CryptoStoreIntegrityError,
} from "./matrix-utils"; } from "./matrix-utils";
import { widget } from "./widget"; import { widget } from "./widget";
import { translatedError } from "./TranslatedError";
declare global { declare global {
interface Window { interface Window {
@ -267,6 +269,8 @@ export const ClientProvider: FC<Props> = ({ children }) => {
history.push("/"); history.push("/");
}, [history, client]); }, [history, client]);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
// To protect against multiple sessions writing to the same storage // To protect against multiple sessions writing to the same storage
// simultaneously, we send a to-device message that shuts down all other // simultaneously, we send a to-device message that shuts down all other
@ -287,8 +291,9 @@ export const ClientProvider: FC<Props> = ({ children }) => {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
error: new Error( error: translatedError(
"This application has been opened in another tab." "This application has been opened in another tab.",
t
), ),
})); }));
} }
@ -306,7 +311,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent); client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent);
}; };
} }
}, [client]); }, [client, t]);
const context = useMemo<ClientState>( const context = useMemo<ClientState>(
() => ({ () => ({

View File

@ -14,10 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { HTMLAttributes } from "react"; import React, { HTMLAttributes, useMemo } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { useTranslation } from "react-i18next";
import styles from "./Facepile.module.css"; import styles from "./Facepile.module.css";
import { Avatar, Size, sizes } from "./Avatar"; import { Avatar, Size, sizes } from "./Avatar";
@ -44,13 +45,25 @@ export function Facepile({
size = Size.XS, size = Size.XS,
...rest ...rest
}: Props) { }: Props) {
const { t } = useTranslation();
const _size = sizes.get(size); const _size = sizes.get(size);
const _overlap = overlapMap[size]; const _overlap = overlapMap[size];
const title = useMemo(() => {
return participants.reduce<string | null>(
(prev, curr) =>
prev === null
? curr.name
: t("{{names}}, {{name}}", { names: prev, name: curr.name }),
null
) as string;
}, [participants, t]);
return ( return (
<div <div
className={classNames(styles.facepile, styles[size], className)} className={classNames(styles.facepile, styles[size], className)}
title={participants.map((member) => member.name).join(", ")} title={title}
style={{ style={{
width: width:
Math.min(participants.length, max + 1) * (_size - _overlap) + Math.min(participants.length, max + 1) * (_size - _overlap) +

View File

@ -1,12 +1,14 @@
import React, { ReactNode, useCallback, useEffect } from "react"; import React, { ReactNode, useCallback, useEffect } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import classNames from "classnames"; import classNames from "classnames";
import { Trans, useTranslation } from "react-i18next";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header"; import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
import { LinkButton, Button } from "./button"; import { LinkButton, Button } from "./button";
import { useSubmitRageshake } from "./settings/submit-rageshake"; import { useSubmitRageshake } from "./settings/submit-rageshake";
import { ErrorMessage } from "./input/Input"; import { ErrorMessage } from "./input/Input";
import styles from "./FullScreenView.module.css"; import styles from "./FullScreenView.module.css";
import { translatedError, TranslatedError } from "./TranslatedError";
interface FullScreenViewProps { interface FullScreenViewProps {
className?: string; className?: string;
@ -35,6 +37,7 @@ interface ErrorViewProps {
export function ErrorView({ error }: ErrorViewProps) { export function ErrorView({ error }: ErrorViewProps) {
const location = useLocation(); const location = useLocation();
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
console.error(error); console.error(error);
@ -47,7 +50,11 @@ export function ErrorView({ error }: ErrorViewProps) {
return ( return (
<FullScreenView> <FullScreenView>
<h1>Error</h1> <h1>Error</h1>
<p>{error.message}</p> <p>
{error instanceof TranslatedError
? error.translatedMessage
: error.message}
</p>
{location.pathname === "/" ? ( {location.pathname === "/" ? (
<Button <Button
size="lg" size="lg"
@ -55,7 +62,7 @@ export function ErrorView({ error }: ErrorViewProps) {
className={styles.homeLink} className={styles.homeLink}
onPress={onReload} onPress={onReload}
> >
Return to home screen {t("Return to home screen")}
</Button> </Button>
) : ( ) : (
<LinkButton <LinkButton
@ -64,7 +71,7 @@ export function ErrorView({ error }: ErrorViewProps) {
className={styles.homeLink} className={styles.homeLink}
to="/" to="/"
> >
Return to home screen {t("Return to home screen")}
</LinkButton> </LinkButton>
)} )}
</FullScreenView> </FullScreenView>
@ -72,6 +79,7 @@ export function ErrorView({ error }: ErrorViewProps) {
} }
export function CrashView() { export function CrashView() {
const { t } = useTranslation();
const { submitRageshake, sending, sent, error } = useSubmitRageshake(); const { submitRageshake, sending, sent, error } = useSubmitRageshake();
const sendDebugLogs = useCallback(() => { const sendDebugLogs = useCallback(() => {
@ -85,11 +93,11 @@ export function CrashView() {
window.location.href = "/"; window.location.href = "/";
}, []); }, []);
let logsComponent; let logsComponent: JSX.Element | null = null;
if (sent) { if (sent) {
logsComponent = <div>Thanks! We'll get right on it.</div>; logsComponent = <div>{t("Thanks! We'll get right on it.")}</div>;
} else if (sending) { } else if (sending) {
logsComponent = <div>Sending...</div>; logsComponent = <div>{t("Sending…")}</div>;
} else { } else {
logsComponent = ( logsComponent = (
<Button <Button
@ -98,33 +106,39 @@ export function CrashView() {
onPress={sendDebugLogs} onPress={sendDebugLogs}
className={styles.wideButton} className={styles.wideButton}
> >
Send debug logs {t("Send debug logs")}
</Button> </Button>
); );
} }
return ( return (
<FullScreenView> <FullScreenView>
<Trans>
<h1>Oops, something's gone wrong.</h1> <h1>Oops, something's gone wrong.</h1>
<p>Submitting debug logs will help us track down the problem.</p> <p>Submitting debug logs will help us track down the problem.</p>
</Trans>
<div className={styles.sendLogsSection}>{logsComponent}</div> <div className={styles.sendLogsSection}>{logsComponent}</div>
{error && <ErrorMessage>Couldn't send debug logs!</ErrorMessage>} {error && (
<ErrorMessage error={translatedError("Couldn't send debug logs!", t)} />
)}
<Button <Button
size="lg" size="lg"
variant="default" variant="default"
className={styles.wideButton} className={styles.wideButton}
onPress={onReload} onPress={onReload}
> >
Return to home screen {t("Return to home screen")}
</Button> </Button>
</FullScreenView> </FullScreenView>
); );
} }
export function LoadingView() { export function LoadingView() {
const { t } = useTranslation();
return ( return (
<FullScreenView> <FullScreenView>
<h1>Loading...</h1> <h1>{t("Loading…")}</h1>
</FullScreenView> </FullScreenView>
); );
} }

View File

@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
import { useButton } from "@react-aria/button"; import { useButton } from "@react-aria/button";
import { AriaButtonProps } from "@react-types/button"; import { AriaButtonProps } from "@react-types/button";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { useTranslation } from "react-i18next";
import styles from "./Header.module.css"; import styles from "./Header.module.css";
import { useModalTriggerState } from "./Modal"; import { useModalTriggerState } from "./Modal";
@ -156,6 +157,7 @@ export function VersionMismatchWarning({
users, users,
room, room,
}: VersionMismatchWarningProps) { }: VersionMismatchWarningProps) {
const { t } = useTranslation();
const { modalState, modalProps } = useModalTriggerState(); const { modalState, modalProps } = useModalTriggerState();
const onDetailsClick = useCallback(() => { const onDetailsClick = useCallback(() => {
@ -166,9 +168,9 @@ export function VersionMismatchWarning({
return ( return (
<span className={styles.versionMismatchWarning}> <span className={styles.versionMismatchWarning}>
Incomaptible versions! {t("Incompatible versions!")}
<Button variant="link" onClick={onDetailsClick}> <Button variant="link" onClick={onDetailsClick}>
Details {t("Details")}
</Button> </Button>
{modalState.isOpen && ( {modalState.isOpen && (
<IncompatibleVersionModal userIds={users} room={room} {...modalProps} /> <IncompatibleVersionModal userIds={users} room={room} {...modalProps} />

View File

@ -15,7 +15,8 @@ limitations under the License.
*/ */
import { Room } from "matrix-js-sdk/src/models/room"; 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 { Modal, ModalContent } from "./Modal";
import { Body } from "./typography/Typography"; import { Body } from "./typography/Typography";
@ -30,17 +31,21 @@ export const IncompatibleVersionModal: React.FC<Props> = ({
room, room,
...rest ...rest
}) => { }) => {
const userLis = Array.from(userIds).map((u) => ( const { t } = useTranslation();
<li>{room.getMember(u).name}</li> const userLis = useMemo(
)); () => [...userIds].map((u) => <li>{room.getMember(u)?.name ?? u}</li>),
[userIds, room]
);
return ( return (
<Modal title="Incompatible Versions" isDismissable {...rest}> <Modal title={t("Incompatible versions")} isDismissable {...rest}>
<ModalContent> <ModalContent>
<Body> <Body>
<Trans>
Other users are trying to join this call from incompatible versions. Other users are trying to join this call from incompatible versions.
These users should ensure that they have refreshed their browsers: These users should ensure that they have refreshed their browsers:
<ul>{userLis}</ul> <ul>{userLis}</ul>
</Trans>
</Body> </Body>
</ModalContent> </ModalContent>
</Modal> </Modal>

View File

@ -33,6 +33,7 @@ import { FocusScope } from "@react-aria/focus";
import { ButtonAria, useButton } from "@react-aria/button"; import { ButtonAria, useButton } from "@react-aria/button";
import classNames from "classnames"; import classNames from "classnames";
import { AriaDialogProps } from "@react-types/dialog"; import { AriaDialogProps } from "@react-types/dialog";
import { useTranslation } from "react-i18next";
import { ReactComponent as CloseIcon } from "./icons/Close.svg"; import { ReactComponent as CloseIcon } from "./icons/Close.svg";
import styles from "./Modal.module.css"; import styles from "./Modal.module.css";
@ -53,6 +54,7 @@ export function Modal({
onClose, onClose,
...rest ...rest
}: ModalProps) { }: ModalProps) {
const { t } = useTranslation();
const modalRef = useRef(); const modalRef = useRef();
const { overlayProps, underlayProps } = useOverlay( const { overlayProps, underlayProps } = useOverlay(
{ ...rest, onClose }, { ...rest, onClose },
@ -90,6 +92,7 @@ export function Modal({
{...closeButtonProps} {...closeButtonProps}
ref={closeButtonRef} ref={closeButtonRef}
className={styles.closeButton} className={styles.closeButton}
title={t("Close")}
> >
<CloseIcon /> <CloseIcon />
</button> </button>

View File

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
SequenceDiagramViewer, SequenceDiagramViewer,
@ -30,7 +31,8 @@ interface DebugLog {
} }
export function SequenceDiagramViewerPage() { export function SequenceDiagramViewerPage() {
usePageTitle("Inspector"); const { t } = useTranslation();
usePageTitle(t("Inspector"));
const [debugLog, setDebugLog] = useState<DebugLog>(); const [debugLog, setDebugLog] = useState<DebugLog>();
const [selectedUserId, setSelectedUserId] = useState<string>(); const [selectedUserId, setSelectedUserId] = useState<string>();
@ -49,7 +51,7 @@ export function SequenceDiagramViewerPage() {
type="file" type="file"
id="debugLog" id="debugLog"
name="debugLog" name="debugLog"
label="Debug Log" label={t("Debug log")}
onChange={onChangeDebugLog} onChange={onChangeDebugLog}
/> />
</FieldRow> </FieldRow>

41
src/TranslatedError.ts Normal file
View File

@ -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);

View File

@ -17,7 +17,7 @@ limitations under the License.
import { useMemo } from "react"; import { useMemo } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
export interface RoomParams { export interface UrlParams {
roomAlias: string | null; roomAlias: string | null;
roomId: string | null; roomId: string | null;
viaServers: string[]; viaServers: string[];
@ -39,25 +39,27 @@ export interface RoomParams {
displayName: string | null; displayName: string | null;
// The device's ID (only used in Matroska mode) // The device's ID (only used in Matroska mode)
deviceId: string | null; 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. * Gets the app parameters for the current URL.
* @param {string} query The URL query string * @param query The URL query string
* @param {string} fragment The URL fragment string * @param fragment The URL fragment string
* @returns {RoomParams} The room parameters encoded in the URL * @returns The app parameters encoded in the URL
*/ */
export const getRoomParams = ( export const getUrlParams = (
query: string = window.location.search, query: string = window.location.search,
fragment: string = window.location.hash fragment: string = window.location.hash
): RoomParams => { ): UrlParams => {
const fragmentQueryStart = fragment.indexOf("?"); const fragmentQueryStart = fragment.indexOf("?");
const fragmentParams = new URLSearchParams( const fragmentParams = new URLSearchParams(
fragmentQueryStart === -1 ? "" : fragment.substring(fragmentQueryStart) fragmentQueryStart === -1 ? "" : fragment.substring(fragmentQueryStart)
); );
const queryParams = new URLSearchParams(query); 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 // leaking them to the server. However, we also check the normal query
// string for backwards compatibility with versions that only used that. // string for backwards compatibility with versions that only used that.
const hasParam = (name: string): boolean => const hasParam = (name: string): boolean =>
@ -87,14 +89,15 @@ export const getRoomParams = (
userId: getParam("userId"), userId: getParam("userId"),
displayName: getParam("displayName"), displayName: getParam("displayName"),
deviceId: getParam("deviceId"), deviceId: getParam("deviceId"),
lang: getParam("lang"),
}; };
}; };
/** /**
* Hook to simplify use of getRoomParams. * Hook to simplify use of getUrlParams.
* @returns {RoomParams} The room parameters for the current URL * @returns The app parameters for the current URL
*/ */
export const useRoomParams = (): RoomParams => { export const useUrlParams = (): UrlParams => {
const { hash, search } = useLocation(); const { hash, search } = useLocation();
return useMemo(() => getRoomParams(search, hash), [search, hash]); return useMemo(() => getUrlParams(search, hash), [search, hash]);
}; };

View File

@ -1,6 +1,7 @@
import React, { useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button, LinkButton } from "./button"; import { Button, LinkButton } from "./button";
import { PopoverMenuTrigger } from "./popover/PopoverMenu"; import { PopoverMenuTrigger } from "./popover/PopoverMenu";
@ -30,6 +31,7 @@ export function UserMenu({
avatarUrl, avatarUrl,
onAction, onAction,
}: UserMenuProps) { }: UserMenuProps) {
const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const items = useMemo(() => { const items = useMemo(() => {
@ -45,7 +47,7 @@ export function UserMenu({
if (isPasswordlessUser && !preventNavigation) { if (isPasswordlessUser && !preventNavigation) {
arr.push({ arr.push({
key: "login", key: "login",
label: "Sign In", label: t("Sign in"),
icon: LoginIcon, icon: LoginIcon,
}); });
} }
@ -53,14 +55,16 @@ export function UserMenu({
if (!isPasswordlessUser && !preventNavigation) { if (!isPasswordlessUser && !preventNavigation) {
arr.push({ arr.push({
key: "logout", key: "logout",
label: "Sign Out", label: t("Sign out"),
icon: LogoutIcon, icon: LogoutIcon,
}); });
} }
} }
return arr; return arr;
}, [isAuthenticated, isPasswordlessUser, displayName, preventNavigation]); }, [isAuthenticated, isPasswordlessUser, displayName, preventNavigation, t]);
const tooltip = useCallback(() => t("Profile"), [t]);
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
@ -72,7 +76,7 @@ export function UserMenu({
return ( return (
<PopoverMenuTrigger placement="bottom right"> <PopoverMenuTrigger placement="bottom right">
<TooltipTrigger tooltip={() => "Profile"} placement="bottom left"> <TooltipTrigger tooltip={tooltip} placement="bottom left">
<Button variant="icon" className={styles.userButton}> <Button variant="icon" className={styles.userButton}>
{isAuthenticated && (!isPasswordlessUser || avatarUrl) ? ( {isAuthenticated && (!isPasswordlessUser || avatarUrl) ? (
<Avatar <Avatar
@ -87,7 +91,7 @@ export function UserMenu({
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
{(props) => ( {(props) => (
<Menu {...props} label="User menu" onAction={onAction}> <Menu {...props} label={t("User menu")} onAction={onAction}>
{items.map(({ key, icon: Icon, label }) => ( {items.map(({ key, icon: Icon, label }) => (
<Item key={key} textValue={label}> <Item key={key} textValue={label}>
<Icon width={24} height={24} className={styles.menuIcon} /> <Icon width={24} height={24} className={styles.menuIcon} />

View File

@ -23,6 +23,7 @@ import React, {
useMemo, useMemo,
} from "react"; } from "react";
import { useHistory, useLocation, Link } from "react-router-dom"; import { useHistory, useLocation, Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { ReactComponent as Logo } from "../icons/LogoLarge.svg"; import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
import { useClient } from "../ClientContext"; import { useClient } from "../ClientContext";
@ -34,7 +35,8 @@ import { useInteractiveLogin } from "./useInteractiveLogin";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
export const LoginPage: FC = () => { export const LoginPage: FC = () => {
usePageTitle("Login"); const { t } = useTranslation();
usePageTitle(t("Login"));
const { setClient } = useClient(); const { setClient } = useClient();
const login = useInteractiveLogin(); const login = useInteractiveLogin();
@ -93,8 +95,8 @@ export const LoginPage: FC = () => {
<InputField <InputField
type="text" type="text"
ref={usernameRef} ref={usernameRef}
placeholder="Username" placeholder={t("Username")}
label="Username" label={t("Username")}
autoCorrect="off" autoCorrect="off"
autoCapitalize="none" autoCapitalize="none"
prefix="@" prefix="@"
@ -105,18 +107,18 @@ export const LoginPage: FC = () => {
<InputField <InputField
type="password" type="password"
ref={passwordRef} ref={passwordRef}
placeholder="Password" placeholder={t("Password")}
label="Password" label={t("Password")}
/> />
</FieldRow> </FieldRow>
{error && ( {error && (
<FieldRow> <FieldRow>
<ErrorMessage>{error.message}</ErrorMessage> <ErrorMessage error={error} />
</FieldRow> </FieldRow>
)} )}
<FieldRow> <FieldRow>
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading}>
{loading ? "Logging in..." : "Login"} {loading ? t("Logging in…") : t("Login")}
</Button> </Button>
</FieldRow> </FieldRow>
</form> </form>
@ -124,9 +126,11 @@ export const LoginPage: FC = () => {
<div className={styles.authLinks}> <div className={styles.authLinks}>
<p>Not registered yet?</p> <p>Not registered yet?</p>
<p> <p>
<Trans>
<Link to="/register">Create an account</Link> <Link to="/register">Create an account</Link>
{" Or "} {" Or "}
<Link to="/">Access as a guest</Link> <Link to="/">Access as a guest</Link>
</Trans>
</p> </p>
</div> </div>
</div> </div>

View File

@ -26,6 +26,7 @@ import React, {
import { useHistory, useLocation } from "react-router-dom"; import { useHistory, useLocation } from "react-router-dom";
import { captureException } from "@sentry/react"; import { captureException } from "@sentry/react";
import { sleep } from "matrix-js-sdk/src/utils"; import { sleep } from "matrix-js-sdk/src/utils";
import { Trans, useTranslation } from "react-i18next";
import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button"; import { Button } from "../button";
@ -40,7 +41,8 @@ import { Caption, Link } from "../typography/Typography";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
export const RegisterPage: FC = () => { export const RegisterPage: FC = () => {
usePageTitle("Register"); const { t } = useTranslation();
usePageTitle(t("Register"));
const { loading, isAuthenticated, isPasswordlessUser, client, setClient } = const { loading, isAuthenticated, isPasswordlessUser, client, setClient } =
useClient(); useClient();
@ -126,11 +128,11 @@ export const RegisterPage: FC = () => {
useEffect(() => { useEffect(() => {
if (password && passwordConfirmation && password !== passwordConfirmation) { if (password && passwordConfirmation && password !== passwordConfirmation) {
confirmPasswordRef.current?.setCustomValidity("Passwords must match"); confirmPasswordRef.current?.setCustomValidity(t("Passwords must match"));
} else { } else {
confirmPasswordRef.current?.setCustomValidity(""); confirmPasswordRef.current?.setCustomValidity("");
} }
}, [password, passwordConfirmation]); }, [password, passwordConfirmation, t]);
useEffect(() => { useEffect(() => {
if (!loading && isAuthenticated && !isPasswordlessUser && !registering) { if (!loading && isAuthenticated && !isPasswordlessUser && !registering) {
@ -154,8 +156,8 @@ export const RegisterPage: FC = () => {
<InputField <InputField
type="text" type="text"
name="userName" name="userName"
placeholder="Username" placeholder={t("Username")}
label="Username" label={t("Username")}
autoCorrect="off" autoCorrect="off"
autoCapitalize="none" autoCapitalize="none"
prefix="@" prefix="@"
@ -171,8 +173,8 @@ export const RegisterPage: FC = () => {
setPassword(e.target.value) setPassword(e.target.value)
} }
value={password} value={password}
placeholder="Password" placeholder={t("Password")}
label="Password" label={t("Password")}
/> />
</FieldRow> </FieldRow>
<FieldRow> <FieldRow>
@ -184,12 +186,13 @@ export const RegisterPage: FC = () => {
setPasswordConfirmation(e.target.value) setPasswordConfirmation(e.target.value)
} }
value={passwordConfirmation} value={passwordConfirmation}
placeholder="Confirm Password" placeholder={t("Confirm password")}
label="Confirm Password" label={t("Confirm password")}
ref={confirmPasswordRef} ref={confirmPasswordRef}
/> />
</FieldRow> </FieldRow>
<Caption> <Caption>
<Trans>
This site is protected by ReCAPTCHA and the Google{" "} This site is protected by ReCAPTCHA and the Google{" "}
<Link href="https://www.google.com/policies/privacy/"> <Link href="https://www.google.com/policies/privacy/">
Privacy Policy Privacy Policy
@ -202,27 +205,30 @@ export const RegisterPage: FC = () => {
<br /> <br />
By clicking "Register", you agree to our{" "} By clicking "Register", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link> <Link href={privacyPolicyUrl}>Terms and conditions</Link>
</Trans>
</Caption> </Caption>
{error && ( {error && (
<FieldRow> <FieldRow>
<ErrorMessage>{error.message}</ErrorMessage> <ErrorMessage error={error} />
</FieldRow> </FieldRow>
)} )}
<FieldRow> <FieldRow>
<Button type="submit" disabled={registering}> <Button type="submit" disabled={registering}>
{registering ? "Registering..." : "Register"} {registering ? t("Registering…") : t("Register")}
</Button> </Button>
</FieldRow> </FieldRow>
<div id={recaptchaId} /> <div id={recaptchaId} />
</form> </form>
</div> </div>
<div className={styles.authLinks}> <div className={styles.authLinks}>
<Trans>
<p>Already have an account?</p> <p>Already have an account?</p>
<p> <p>
<Link to="/login">Log in</Link> <Link to="/login">Log in</Link>
{" Or "} {" Or "}
<Link to="/">Access as a guest</Link> <Link to="/">Access as a guest</Link>
</p> </p>
</Trans>
</div> </div>
</div> </div>
</div> </div>

View File

@ -16,6 +16,9 @@ limitations under the License.
import { useEffect, useCallback, useRef, useState } from "react"; import { useEffect, useCallback, useRef, useState } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring"; import { randomString } from "matrix-js-sdk/src/randomstring";
import { useTranslation } from "react-i18next";
import { translatedError } from "../TranslatedError";
declare global { declare global {
interface Window { interface Window {
@ -32,6 +35,7 @@ interface RecaptchaPromiseRef {
} }
export const useRecaptcha = (sitekey: string) => { export const useRecaptcha = (sitekey: string) => {
const { t } = useTranslation();
const [recaptchaId] = useState(() => randomString(16)); const [recaptchaId] = useState(() => randomString(16));
const promiseRef = useRef<RecaptchaPromiseRef>(); const promiseRef = useRef<RecaptchaPromiseRef>();
@ -71,14 +75,14 @@ export const useRecaptcha = (sitekey: string) => {
if (!window.grecaptcha) { if (!window.grecaptcha) {
console.log("Recaptcha not loaded"); 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) => { return new Promise((resolve, reject) => {
const observer = new MutationObserver((mutationsList) => { const observer = new MutationObserver((mutationsList) => {
for (const item of mutationsList) { for (const item of mutationsList) {
if ((item.target as HTMLElement)?.style?.visibility !== "visible") { if ((item.target as HTMLElement)?.style?.visibility !== "visible") {
reject(new Error("Recaptcha dismissed")); reject(translatedError("Recaptcha dismissed", t));
observer.disconnect(); observer.disconnect();
return; return;
} }
@ -108,7 +112,7 @@ export const useRecaptcha = (sitekey: string) => {
}); });
} }
}); });
}, [sitekey]); }, [sitekey, t]);
const reset = useCallback(() => { const reset = useCallback(() => {
window.grecaptcha?.reset(); window.grecaptcha?.reset();

View File

@ -18,6 +18,7 @@ import { PressEvent } from "@react-types/shared";
import classNames from "classnames"; import classNames from "classnames";
import { useButton } from "@react-aria/button"; import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils"; import { mergeProps, useObjectRef } from "@react-aria/utils";
import { useTranslation } from "react-i18next";
import styles from "./Button.module.css"; import styles from "./Button.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg"; import { ReactComponent as MicIcon } from "../icons/Mic.svg";
@ -142,9 +143,11 @@ export function MicButton({
// TODO: add all props for <Button> // TODO: add all props for <Button>
[index: string]: unknown; [index: string]: unknown;
}) { }) {
const { t } = useTranslation();
return ( return (
<TooltipTrigger <TooltipTrigger
tooltip={() => (muted ? "Unmute microphone" : "Mute microphone")} tooltip={() => (muted ? t("Unmute microphone") : t("Mute microphone"))}
> >
<Button variant="toolbar" {...rest} off={muted}> <Button variant="toolbar" {...rest} off={muted}>
{muted ? <MuteMicIcon /> : <MicIcon />} {muted ? <MuteMicIcon /> : <MicIcon />}
@ -161,9 +164,11 @@ export function VideoButton({
// TODO: add all props for <Button> // TODO: add all props for <Button>
[index: string]: unknown; [index: string]: unknown;
}) { }) {
const { t } = useTranslation();
return ( return (
<TooltipTrigger <TooltipTrigger
tooltip={() => (muted ? "Turn on camera" : "Turn off camera")} tooltip={() => (muted ? t("Turn on camera") : t("Turn off camera"))}
> >
<Button variant="toolbar" {...rest} off={muted}> <Button variant="toolbar" {...rest} off={muted}>
{muted ? <DisableVideoIcon /> : <VideoIcon />} {muted ? <DisableVideoIcon /> : <VideoIcon />}
@ -182,9 +187,11 @@ export function ScreenshareButton({
// TODO: add all props for <Button> // TODO: add all props for <Button>
[index: string]: unknown; [index: string]: unknown;
}) { }) {
const { t } = useTranslation();
return ( return (
<TooltipTrigger <TooltipTrigger
tooltip={() => (enabled ? "Stop sharing screen" : "Share screen")} tooltip={() => (enabled ? t("Stop sharing screen") : t("Share screen"))}
> >
<Button variant="toolbarSecondary" {...rest} on={enabled}> <Button variant="toolbarSecondary" {...rest} on={enabled}>
<ScreenshareIcon /> <ScreenshareIcon />
@ -201,8 +208,11 @@ export function HangupButton({
// TODO: add all props for <Button> // TODO: add all props for <Button>
[index: string]: unknown; [index: string]: unknown;
}) { }) {
const { t } = useTranslation();
const tooltip = useCallback(() => t("Leave"), [t]);
return ( return (
<TooltipTrigger tooltip={() => "Leave"}> <TooltipTrigger tooltip={tooltip}>
<Button <Button
variant="toolbar" variant="toolbar"
className={classNames(styles.hangupButton, className)} className={classNames(styles.hangupButton, className)}
@ -222,8 +232,11 @@ export function SettingsButton({
// TODO: add all props for <Button> // TODO: add all props for <Button>
[index: string]: unknown; [index: string]: unknown;
}) { }) {
const { t } = useTranslation();
const tooltip = useCallback(() => t("Settings"), [t]);
return ( return (
<TooltipTrigger tooltip={() => "Settings"}> <TooltipTrigger tooltip={tooltip}>
<Button variant="toolbar" {...rest}> <Button variant="toolbar" {...rest}>
<SettingsIcon /> <SettingsIcon />
</Button> </Button>
@ -239,8 +252,11 @@ export function InviteButton({
// TODO: add all props for <Button> // TODO: add all props for <Button>
[index: string]: unknown; [index: string]: unknown;
}) { }) {
const { t } = useTranslation();
const tooltip = useCallback(() => t("Invite"), [t]);
return ( return (
<TooltipTrigger tooltip={() => "Invite"}> <TooltipTrigger tooltip={tooltip}>
<Button variant="toolbar" {...rest}> <Button variant="toolbar" {...rest}>
<AddUserIcon /> <AddUserIcon />
</Button> </Button>
@ -256,8 +272,11 @@ interface AudioButtonProps extends Omit<Props, "variant"> {
} }
export function AudioButton({ volume, ...rest }: AudioButtonProps) { export function AudioButton({ volume, ...rest }: AudioButtonProps) {
const { t } = useTranslation();
const tooltip = useCallback(() => t("Local volume"), [t]);
return ( return (
<TooltipTrigger tooltip={() => "Local volume"}> <TooltipTrigger tooltip={tooltip}>
<Button variant="icon" {...rest}> <Button variant="icon" {...rest}>
<VolumeIcon volume={volume} /> <VolumeIcon volume={volume} />
</Button> </Button>
@ -273,12 +292,13 @@ export function FullscreenButton({
fullscreen, fullscreen,
...rest ...rest
}: FullscreenButtonProps) { }: FullscreenButtonProps) {
const getTooltip = useCallback(() => { const { t } = useTranslation();
return fullscreen ? "Exit full screen" : "Full screen"; const tooltip = useCallback(() => {
}, [fullscreen]); return fullscreen ? t("Exit full screen") : t("Full screen");
}, [fullscreen, t]);
return ( return (
<TooltipTrigger tooltip={getTooltip}> <TooltipTrigger tooltip={tooltip}>
<Button variant="icon" {...rest}> <Button variant="icon" {...rest}>
{fullscreen ? <FullscreenExit /> : <Fullscreen />} {fullscreen ? <FullscreenExit /> : <Fullscreen />}
</Button> </Button>

View File

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import useClipboard from "react-use-clipboard"; import useClipboard from "react-use-clipboard";
import { ReactComponent as CheckIcon } from "../icons/Check.svg"; import { ReactComponent as CheckIcon } from "../icons/Check.svg";
@ -36,6 +37,7 @@ export function CopyButton({
copiedMessage, copiedMessage,
...rest ...rest
}: Props) { }: Props) {
const { t } = useTranslation();
const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 }); const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
return ( return (
@ -49,7 +51,7 @@ export function CopyButton({
> >
{isCopied ? ( {isCopied ? (
<> <>
{variant !== "icon" && <span>{copiedMessage || "Copied!"}</span>} {variant !== "icon" && <span>{copiedMessage || t("Copied!")}</span>}
<CheckIcon /> <CheckIcon />
</> </>
) : ( ) : (

View File

@ -16,6 +16,7 @@ limitations under the License.
import React, { FC } from "react"; import React, { FC } from "react";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { useTranslation } from "react-i18next";
import { Headline } from "../typography/Typography"; import { Headline } from "../typography/Typography";
import { Button } from "../button"; import { Button } from "../button";
@ -39,25 +40,29 @@ interface Props {
} }
export const CallTypeDropdown: FC<Props> = ({ callType, setCallType }) => { export const CallTypeDropdown: FC<Props> = ({ callType, setCallType }) => {
const { t } = useTranslation();
return ( return (
<PopoverMenuTrigger placement="bottom"> <PopoverMenuTrigger placement="bottom">
<Button variant="dropdown" className={commonStyles.headline}> <Button variant="dropdown" className={commonStyles.headline}>
<Headline className={styles.label}> <Headline className={styles.label}>
{callType === CallType.Video ? "Video call" : "Walkie-talkie call"} {callType === CallType.Video
? t("Video call")
: t("Walkie-talkie call")}
</Headline> </Headline>
</Button> </Button>
{(props: JSX.IntrinsicAttributes) => ( {(props: JSX.IntrinsicAttributes) => (
<Menu {...props} label="Call type menu" onAction={setCallType}> <Menu {...props} label={t("Call type menu")} onAction={setCallType}>
<Item key={CallType.Video} textValue="Video call"> <Item key={CallType.Video} textValue={t("Video call")}>
<VideoIcon /> <VideoIcon />
<span>Video call</span> <span>{t("Video call")}</span>
{callType === CallType.Video && ( {callType === CallType.Video && (
<CheckIcon className={menuStyles.checkIcon} /> <CheckIcon className={menuStyles.checkIcon} />
)} )}
</Item> </Item>
<Item key={CallType.Radio} textValue="Walkie-talkie call"> <Item key={CallType.Radio} textValue={t("Walkie-talkie call")}>
<MicIcon /> <MicIcon />
<span>Walkie-talkie call</span> <span>{t("Walkie-talkie call")}</span>
{callType === CallType.Radio && ( {callType === CallType.Radio && (
<CheckIcon className={menuStyles.checkIcon} /> <CheckIcon className={menuStyles.checkIcon} />
)} )}

View File

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { useClient } from "../ClientContext"; import { useClient } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView"; import { ErrorView, LoadingView } from "../FullScreenView";
@ -23,7 +24,8 @@ import { RegisteredView } from "./RegisteredView";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
export function HomePage() { export function HomePage() {
usePageTitle("Home"); const { t } = useTranslation();
usePageTitle(t("Home"));
const { isAuthenticated, isPasswordlessUser, loading, error, client } = const { isAuthenticated, isPasswordlessUser, loading, error, client } =
useClient(); useClient();

View File

@ -16,6 +16,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { PressEvent } from "@react-types/shared"; import { PressEvent } from "@react-types/shared";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent } from "../Modal"; import { Modal, ModalContent } from "../Modal";
import { Button } from "../button"; import { Button } from "../button";
@ -29,13 +30,15 @@ interface Props {
[index: string]: unknown; [index: string]: unknown;
} }
export function JoinExistingCallModal({ onJoin, onClose, ...rest }: Props) { export function JoinExistingCallModal({ onJoin, onClose, ...rest }: Props) {
const { t } = useTranslation();
return ( return (
<Modal title="Join existing call?" isDismissable {...rest}> <Modal title={t("Join existing call?")} isDismissable {...rest}>
<ModalContent> <ModalContent>
<p>This call already exists, would you like to join?</p> <p>{t("This call already exists, would you like to join?")}</p>
<FieldRow rightAlign className={styles.buttons}> <FieldRow rightAlign className={styles.buttons}>
<Button onPress={onClose}>No</Button> <Button onPress={onClose}>{t("No")}</Button>
<Button onPress={onJoin}>Yes, join call</Button> <Button onPress={onJoin}>{t("Yes, join call")}</Button>
</FieldRow> </FieldRow>
</ModalContent> </ModalContent>
</Modal> </Modal>

View File

@ -22,6 +22,7 @@ import React, {
} from "react"; } from "react";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils"; import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useGroupCallRooms } from "./useGroupCallRooms"; import { useGroupCallRooms } from "./useGroupCallRooms";
@ -48,6 +49,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const history = useHistory(); const history = useHistory();
const { t } = useTranslation();
const { modalState, modalProps } = useModalTriggerState(); const { modalState, modalProps } = useModalTriggerState();
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback( const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
@ -93,7 +95,9 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
}, [history, existingRoomId]); }, [history, existingRoomId]);
const callNameLabel = 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 ( return (
<> <>
@ -127,19 +131,19 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
className={styles.button} className={styles.button}
disabled={loading} disabled={loading}
> >
{loading ? "Loading..." : "Go"} {loading ? t("Loading…") : t("Go")}
</Button> </Button>
</FieldRow> </FieldRow>
{error && ( {error && (
<FieldRow className={styles.fieldRow}> <FieldRow className={styles.fieldRow}>
<ErrorMessage>{error.message}</ErrorMessage> <ErrorMessage error={error} />
</FieldRow> </FieldRow>
)} )}
</Form> </Form>
{recentRooms.length > 0 && ( {recentRooms.length > 0 && (
<> <>
<Title className={styles.recentCallsTitle}> <Title className={styles.recentCallsTitle}>
Your recent Calls {t("Your recent calls")}
</Title> </Title>
<CallList rooms={recentRooms} client={client} disableFacepile /> <CallList rooms={recentRooms} client={client} disableFacepile />
</> </>

View File

@ -17,6 +17,7 @@ limitations under the License.
import React, { FC, useCallback, useState, FormEventHandler } from "react"; import React, { FC, useCallback, useState, FormEventHandler } from "react";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { randomString } from "matrix-js-sdk/src/randomstring"; import { randomString } from "matrix-js-sdk/src/randomstring";
import { Trans, useTranslation } from "react-i18next";
import { useClient } from "../ClientContext"; import { useClient } from "../ClientContext";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
@ -47,6 +48,7 @@ export const UnauthenticatedView: FC = () => {
const { modalState, modalProps } = useModalTriggerState(); const { modalState, modalProps } = useModalTriggerState();
const [onFinished, setOnFinished] = useState<() => void>(); const [onFinished, setOnFinished] = useState<() => void>();
const history = useHistory(); const history = useHistory();
const { t } = useTranslation();
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback( const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
(e) => { (e) => {
@ -105,7 +107,9 @@ export const UnauthenticatedView: FC = () => {
); );
const callNameLabel = 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 ( return (
<> <>
@ -137,24 +141,26 @@ export const UnauthenticatedView: FC = () => {
<InputField <InputField
id="displayName" id="displayName"
name="displayName" name="displayName"
label="Display Name" label={t("Display name")}
placeholder="Display Name" placeholder={t("Display name")}
type="text" type="text"
required required
autoComplete="off" autoComplete="off"
/> />
</FieldRow> </FieldRow>
<Caption> <Caption>
<Trans>
By clicking "Go", you agree to our{" "} By clicking "Go", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link> <Link href={privacyPolicyUrl}>Terms and conditions</Link>
</Trans>
</Caption> </Caption>
{error && ( {error && (
<FieldRow> <FieldRow>
<ErrorMessage>{error.message}</ErrorMessage> <ErrorMessage error={error} />
</FieldRow> </FieldRow>
)} )}
<Button type="submit" size="lg" disabled={loading}> <Button type="submit" size="lg" disabled={loading}>
{loading ? "Loading..." : "Go"} {loading ? t("Loading…") : t("Go")}
</Button> </Button>
<div id={recaptchaId} /> <div id={recaptchaId} />
</Form> </Form>
@ -162,14 +168,16 @@ export const UnauthenticatedView: FC = () => {
<footer className={styles.footer}> <footer className={styles.footer}>
<Body className={styles.mobileLoginLink}> <Body className={styles.mobileLoginLink}>
<Link color="primary" to="/login"> <Link color="primary" to="/login">
Login to your account {t("Login to your account")}
</Link> </Link>
</Body> </Body>
<Body> <Body>
<Trans>
Not registered yet?{" "} Not registered yet?{" "}
<Link color="primary" to="/register"> <Link color="primary" to="/register">
Create an account Create an account
</Link> </Link>
</Trans>
</Body> </Body>
</footer> </footer>
</div> </div>

View File

@ -20,6 +20,7 @@ import { useCallback } from "react";
import { useState } from "react"; import { useState } from "react";
import { forwardRef } from "react"; import { forwardRef } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { Avatar, Size } from "../Avatar"; import { Avatar, Size } from "../Avatar";
import { Button } from "../button"; import { Button } from "../button";
@ -39,6 +40,8 @@ export const AvatarInputField = forwardRef<HTMLInputElement, Props>(
{ id, label, className, avatarUrl, displayName, onRemoveAvatar, ...rest }, { id, label, className, avatarUrl, displayName, onRemoveAvatar, ...rest },
ref ref
) => { ) => {
const { t } = useTranslation();
const [removed, setRemoved] = useState(false); const [removed, setRemoved] = useState(false);
const [objUrl, setObjUrl] = useState<string>(null); const [objUrl, setObjUrl] = useState<string>(null);
@ -97,7 +100,7 @@ export const AvatarInputField = forwardRef<HTMLInputElement, Props>(
variant="icon" variant="icon"
onPress={onPressRemoveAvatar} onPress={onPressRemoveAvatar}
> >
Remove {t("Remove")}
</Button> </Button>
)} )}
</div> </div>

View File

@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { ChangeEvent, forwardRef, ReactNode } from "react"; import React, { ChangeEvent, FC, forwardRef, ReactNode } from "react";
import classNames from "classnames"; import classNames from "classnames";
import styles from "./Input.module.css"; import styles from "./Input.module.css";
import { ReactComponent as CheckIcon } from "../icons/Check.svg"; import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import { TranslatedError } from "../TranslatedError";
interface FieldRowProps { interface FieldRowProps {
children: ReactNode; children: ReactNode;
@ -140,10 +141,12 @@ export const InputField = forwardRef<
} }
); );
export function ErrorMessage({ interface ErrorMessageProps {
children, error: Error;
}: {
children: ReactNode;
}): JSX.Element {
return <p className={styles.errorMessage}>{children}</p>;
} }
export const ErrorMessage: FC<ErrorMessageProps> = ({ error }) => (
<p className={styles.errorMessage}>
{error instanceof TranslatedError ? error.translatedMessage : error.message}
</p>
);

View File

@ -19,6 +19,7 @@ import { AriaSelectOptions, HiddenSelect, useSelect } from "@react-aria/select";
import { useButton } from "@react-aria/button"; import { useButton } from "@react-aria/button";
import { useSelectState } from "@react-stately/select"; import { useSelectState } from "@react-stately/select";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { Popover } from "../popover/Popover"; import { Popover } from "../popover/Popover";
import { ListBox } from "../ListBox"; import { ListBox } from "../ListBox";
@ -30,6 +31,7 @@ interface Props extends AriaSelectOptions<object> {
} }
export function SelectInput(props: Props): JSX.Element { export function SelectInput(props: Props): JSX.Element {
const { t } = useTranslation();
const state = useSelectState(props); const state = useSelectState(props);
const ref = useRef(); const ref = useRef();
@ -56,7 +58,7 @@ export function SelectInput(props: Props): JSX.Element {
<span {...valueProps} className={styles.selectedItem}> <span {...valueProps} className={styles.selectedItem}>
{state.selectedItem {state.selectedItem
? state.selectedItem.rendered ? state.selectedItem.rendered
: "Select an option"} : t("Select an option")}
</span> </span>
<ArrowDownIcon /> <ArrowDownIcon />
</button> </button>

View File

@ -25,10 +25,15 @@ import ReactDOM from "react-dom";
import { createBrowserHistory } from "history"; import { createBrowserHistory } from "history";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing"; 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 "./index.css";
import App from "./App"; import App from "./App";
import { init as initRageshake } from "./settings/rageshake"; import { init as initRageshake } from "./settings/rageshake";
import { getUrlParams } from "./UrlParams";
initRageshake(); initRageshake();
@ -104,6 +109,35 @@ Sentry.init({
tracesSampleRate: 1.0, 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( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
<App history={history} /> <App history={history} />

View File

@ -19,7 +19,7 @@ import {
import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room"; import type { Room } from "matrix-js-sdk/src/models/room";
import IndexedDBWorker from "./IndexedDBWorker?worker"; import IndexedDBWorker from "./IndexedDBWorker?worker";
import { getRoomParams } from "./room/useRoomParams"; import { getUrlParams } from "./UrlParams";
export const defaultHomeserver = export const defaultHomeserver =
(import.meta.env.VITE_DEFAULT_HOMESERVER as string) ?? (import.meta.env.VITE_DEFAULT_HOMESERVER as string) ??
@ -134,12 +134,12 @@ export async function initClient(
storeOpts.cryptoStore = new MemoryCryptoStore(); 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 // it would be much better to read them in one place and pass
// the values around, but we initialise the matrix client in // the values around, but we initialise the matrix client in
// many different places so we'd have to pass it into all of // many different places so we'd have to pass it into all of
// them. // them.
const { e2eEnabled } = getRoomParams(); const { e2eEnabled } = getUrlParams();
if (!e2eEnabled) { if (!e2eEnabled) {
logger.info("Disabling E2E: group call signalling will NOT be encrypted."); logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
} }

View File

@ -16,6 +16,7 @@ limitations under the License.
import React, { ChangeEvent, useCallback, useEffect, useState } from "react"; import React, { ChangeEvent, useCallback, useEffect, useState } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
import { Button } from "../button"; import { Button } from "../button";
import { useProfile } from "./useProfile"; import { useProfile } from "./useProfile";
@ -31,6 +32,7 @@ interface Props {
} }
export function ProfileModal({ client, ...rest }: Props) { export function ProfileModal({ client, ...rest }: Props) {
const { onClose } = rest; const { onClose } = rest;
const { t } = useTranslation();
const { const {
success, success,
error, error,
@ -83,14 +85,14 @@ export function ProfileModal({ client, ...rest }: Props) {
}, [success, onClose]); }, [success, onClose]);
return ( return (
<Modal title="Profile" isDismissable {...rest}> <Modal title={t("Profile")} isDismissable {...rest}>
<ModalContent> <ModalContent>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<FieldRow className={styles.avatarFieldRow}> <FieldRow className={styles.avatarFieldRow}>
<AvatarInputField <AvatarInputField
id="avatar" id="avatar"
name="avatar" name="avatar"
label="Avatar" label={t("Avatar")}
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
displayName={displayName} displayName={displayName}
onRemoveAvatar={onRemoveAvatar} onRemoveAvatar={onRemoveAvatar}
@ -100,7 +102,7 @@ export function ProfileModal({ client, ...rest }: Props) {
<InputField <InputField
id="userId" id="userId"
name="userId" name="userId"
label="User Id" label={t("User ID")}
type="text" type="text"
disabled disabled
value={client.getUserId()} value={client.getUserId()}
@ -110,18 +112,18 @@ export function ProfileModal({ client, ...rest }: Props) {
<InputField <InputField
id="displayName" id="displayName"
name="displayName" name="displayName"
label="Display Name" label={t("Display name")}
type="text" type="text"
required required
autoComplete="off" autoComplete="off"
placeholder="Display Name" placeholder={t("Display name")}
value={displayName} value={displayName}
onChange={onChangeDisplayName} onChange={onChangeDisplayName}
/> />
</FieldRow> </FieldRow>
{error && ( {error && (
<FieldRow> <FieldRow>
<ErrorMessage>{error.message}</ErrorMessage> <ErrorMessage error={error} />
</FieldRow> </FieldRow>
)} )}
<FieldRow rightAlign> <FieldRow rightAlign>
@ -129,7 +131,7 @@ export function ProfileModal({ client, ...rest }: Props) {
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading}>
{loading ? "Saving..." : "Save"} {loading ? t("Saving…") : t("Save")}
</Button> </Button>
</FieldRow> </FieldRow>
</form> </form>

View File

@ -17,6 +17,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall"; import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { useTranslation } from "react-i18next";
import styles from "./AudioPreview.module.css"; import styles from "./AudioPreview.module.css";
import { SelectInput } from "../input/SelectInput"; import { SelectInput } from "../input/SelectInput";
@ -43,24 +44,26 @@ export function AudioPreview({
audioOutputs, audioOutputs,
setAudioOutput, setAudioOutput,
}: Props) { }: Props) {
const { t } = useTranslation();
return ( return (
<> <>
<h1>{`${roomName} - Walkie-talkie call`}</h1> <h1>{t("{{roomName}} - Walkie-talkie call", { roomName })}</h1>
<div className={styles.preview}> <div className={styles.preview}>
{state === GroupCallState.LocalCallFeedUninitialized && ( {state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.microphonePermissions}> <Body fontWeight="semiBold" className={styles.microphonePermissions}>
Microphone permissions needed to join the call. {t("Microphone permissions needed to join the call.")}
</Body> </Body>
)} )}
{state === GroupCallState.InitializingLocalCallFeed && ( {state === GroupCallState.InitializingLocalCallFeed && (
<Body fontWeight="semiBold" className={styles.microphonePermissions}> <Body fontWeight="semiBold" className={styles.microphonePermissions}>
Accept microphone permissions to join the call. {t("Accept microphone permissions to join the call.")}
</Body> </Body>
)} )}
{state === GroupCallState.LocalCallFeedInitialized && ( {state === GroupCallState.LocalCallFeedInitialized && (
<> <>
<SelectInput <SelectInput
label="Microphone" label={t("Microphone")}
selectedKey={audioInput} selectedKey={audioInput}
onSelectionChange={setAudioInput} onSelectionChange={setAudioInput}
className={styles.inputField} className={styles.inputField}
@ -69,13 +72,13 @@ export function AudioPreview({
<Item key={deviceId}> <Item key={deviceId}>
{!!label && label.trim().length > 0 {!!label && label.trim().length > 0
? label ? label
: `Microphone ${index + 1}`} : t("Microphone {{n}}", { n: index + 1 })}
</Item> </Item>
))} ))}
</SelectInput> </SelectInput>
{audioOutputs.length > 0 && ( {audioOutputs.length > 0 && (
<SelectInput <SelectInput
label="Speaker" label={t("Speaker")}
selectedKey={audioOutput} selectedKey={audioOutput}
onSelectionChange={setAudioOutput} onSelectionChange={setAudioOutput}
className={styles.inputField} className={styles.inputField}
@ -84,7 +87,7 @@ export function AudioPreview({
<Item key={deviceId}> <Item key={deviceId}>
{!!label && label.trim().length > 0 {!!label && label.trim().length > 0
? label ? label
: `Speaker ${index + 1}`} : t("Speaker {{n}}", { n: index + 1 })}
</Item> </Item>
))} ))}
</SelectInput> </SelectInput>

View File

@ -16,6 +16,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { Trans, useTranslation } from "react-i18next";
import styles from "./CallEndedView.module.css"; import styles from "./CallEndedView.module.css";
import { LinkButton } from "../button"; import { LinkButton } from "../button";
@ -24,6 +25,7 @@ import { Subtitle, Body, Link, Headline } from "../typography/Typography";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
export function CallEndedView({ client }: { client: MatrixClient }) { export function CallEndedView({ client }: { client: MatrixClient }) {
const { t } = useTranslation();
const { displayName } = useProfile(client); const { displayName } = useProfile(client);
return ( return (
@ -37,9 +39,10 @@ export function CallEndedView({ client }: { client: MatrixClient }) {
<div className={styles.container}> <div className={styles.container}>
<main className={styles.main}> <main className={styles.main}>
<Headline className={styles.headline}> <Headline className={styles.headline}>
{displayName}, your call is now ended {t("{{displayName}}, your call is now ended", { displayName })}
</Headline> </Headline>
<div className={styles.callEndedContent}> <div className={styles.callEndedContent}>
<Trans>
<Subtitle> <Subtitle>
Why not finish by setting up a password to keep your account? Why not finish by setting up a password to keep your account?
</Subtitle> </Subtitle>
@ -47,19 +50,20 @@ export function CallEndedView({ client }: { client: MatrixClient }) {
You'll be able to keep your name and set an avatar for use on You'll be able to keep your name and set an avatar for use on
future calls future calls
</Subtitle> </Subtitle>
</Trans>
<LinkButton <LinkButton
className={styles.callEndedButton} className={styles.callEndedButton}
size="lg" size="lg"
variant="default" variant="default"
to="/register" to="/register"
> >
Create account {t("Create account")}
</LinkButton> </LinkButton>
</div> </div>
</main> </main>
<Body className={styles.footer}> <Body className={styles.footer}>
<Link color="primary" to="/"> <Link color="primary" to="/">
Not now, return to home screen {t("Not now, return to home screen")}
</Link> </Link>
</Body> </Body>
</div> </div>

View File

@ -16,6 +16,7 @@ limitations under the License.
import React, { useCallback, useEffect } from "react"; import React, { useCallback, useEffect } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring"; import { randomString } from "matrix-js-sdk/src/randomstring";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent } from "../Modal"; import { Modal, ModalContent } from "../Modal";
import { Button } from "../button"; import { Button } from "../button";
@ -25,6 +26,7 @@ import {
useRageshakeRequest, useRageshakeRequest,
} from "../settings/submit-rageshake"; } from "../settings/submit-rageshake";
import { Body } from "../typography/Typography"; import { Body } from "../typography/Typography";
interface Props { interface Props {
inCall: boolean; inCall: boolean;
roomId: string; roomId: string;
@ -32,7 +34,9 @@ interface Props {
// TODO: add all props for for <Modal> // TODO: add all props for for <Modal>
[index: string]: unknown; [index: string]: unknown;
} }
export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) { export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
const { t } = useTranslation();
const { submitRageshake, sending, sent, error } = useSubmitRageshake(); const { submitRageshake, sending, sent, error } = useSubmitRageshake();
const sendRageshakeRequest = useRageshakeRequest(); const sendRageshakeRequest = useRageshakeRequest();
@ -67,15 +71,20 @@ export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
}, [sent, onClose]); }, [sent, onClose]);
return ( return (
<Modal title="Submit Feedback" isDismissable onClose={onClose} {...rest}> <Modal
title={t("Submit feedback")}
isDismissable
onClose={onClose}
{...rest}
>
<ModalContent> <ModalContent>
<Body>Having trouble? Help us fix it.</Body> <Body>{t("Having trouble? Help us fix it.")}</Body>
<form onSubmit={onSubmitFeedback}> <form onSubmit={onSubmitFeedback}>
<FieldRow> <FieldRow>
<InputField <InputField
id="description" id="description"
name="description" name="description"
label="Description (optional)" label={t("Description (optional)")}
type="textarea" type="textarea"
/> />
</FieldRow> </FieldRow>
@ -83,19 +92,19 @@ export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
<InputField <InputField
id="sendLogs" id="sendLogs"
name="sendLogs" name="sendLogs"
label="Include Debug Logs" label={t("Include debug logs")}
type="checkbox" type="checkbox"
defaultChecked defaultChecked
/> />
</FieldRow> </FieldRow>
{error && ( {error && (
<FieldRow> <FieldRow>
<ErrorMessage>{error.message}</ErrorMessage> <ErrorMessage error={error} />
</FieldRow> </FieldRow>
)} )}
<FieldRow> <FieldRow>
<Button type="submit" disabled={sending}> <Button type="submit" disabled={sending}>
{sending ? "Submitting feedback..." : "Submit Feedback"} {sending ? t("Submitting feedback…") : t("Submit feedback")}
</Button> </Button>
</FieldRow> </FieldRow>
</form> </form>

View File

@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { useCallback } from "react";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { useTranslation } from "react-i18next";
import { Button } from "../button"; import { Button } from "../button";
import { PopoverMenuTrigger } from "../popover/PopoverMenu"; import { PopoverMenuTrigger } from "../popover/PopoverMenu";
@ -27,28 +28,33 @@ import { Menu } from "../Menu";
import { TooltipTrigger } from "../Tooltip"; import { TooltipTrigger } from "../Tooltip";
export type Layout = "freedom" | "spotlight"; export type Layout = "freedom" | "spotlight";
interface Props { interface Props {
layout: Layout; layout: Layout;
setLayout: (layout: Layout) => void; setLayout: (layout: Layout) => void;
} }
export function GridLayoutMenu({ layout, setLayout }: Props) { export function GridLayoutMenu({ layout, setLayout }: Props) {
const { t } = useTranslation();
const tooltip = useCallback(() => t("Change layout"), [t]);
return ( return (
<PopoverMenuTrigger placement="bottom right"> <PopoverMenuTrigger placement="bottom right">
<TooltipTrigger tooltip={() => "Layout Type"}> <TooltipTrigger tooltip={tooltip}>
<Button variant="icon"> <Button variant="icon">
{layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />} {layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
{(props: JSX.IntrinsicAttributes) => ( {(props: JSX.IntrinsicAttributes) => (
<Menu {...props} label="Grid layout menu" onAction={setLayout}> <Menu {...props} label={t("Grid layout menu")} onAction={setLayout}>
<Item key="freedom" textValue="Freedom"> <Item key="freedom" textValue={t("Freedom")}>
<FreedomIcon /> <FreedomIcon />
<span>Freedom</span> <span>Freedom</span>
{layout === "freedom" && ( {layout === "freedom" && (
<CheckIcon className={menuStyles.checkIcon} /> <CheckIcon className={menuStyles.checkIcon} />
)} )}
</Item> </Item>
<Item key="spotlight" textValue="Spotlight"> <Item key="spotlight" textValue={t("Spotlight")}>
<SpotlightIcon /> <SpotlightIcon />
<span>Spotlight</span> <span>Spotlight</span>
{layout === "spotlight" && ( {layout === "spotlight" && (

View File

@ -17,6 +17,7 @@ limitations under the License.
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { useTranslation } from "react-i18next";
import { useLoadGroupCall } from "./useLoadGroupCall"; import { useLoadGroupCall } from "./useLoadGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView"; import { ErrorView, FullScreenView } from "../FullScreenView";
@ -37,6 +38,7 @@ export function GroupCallLoader({
children, children,
createPtt, createPtt,
}: Props): JSX.Element { }: Props): JSX.Element {
const { t } = useTranslation();
const { loading, error, groupCall } = useLoadGroupCall( const { loading, error, groupCall } = useLoadGroupCall(
client, client,
roomIdOrAlias, roomIdOrAlias,
@ -44,12 +46,12 @@ export function GroupCallLoader({
createPtt createPtt
); );
usePageTitle(groupCall ? groupCall.room.name : "Loading..."); usePageTitle(groupCall ? groupCall.room.name : t("Loading…"));
if (loading) { if (loading) {
return ( return (
<FullScreenView> <FullScreenView>
<h1>Loading room...</h1> <h1>{t("Loading room…")}</h1>
</FullScreenView> </FullScreenView>
); );
} }

View File

@ -19,6 +19,7 @@ import { useHistory } from "react-router-dom";
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall"; import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import type { IWidgetApiRequest } from "matrix-widget-api"; import type { IWidgetApiRequest } from "matrix-widget-api";
import { widget, ElementWidgetActions, JoinCallData } from "../widget"; import { widget, ElementWidgetActions, JoinCallData } from "../widget";
@ -81,8 +82,8 @@ export function GroupCallView({
unencryptedEventsFromUsers, unencryptedEventsFromUsers,
} = useGroupCall(groupCall); } = useGroupCall(groupCall);
const { t } = useTranslation();
const { setAudioInput, setVideoInput } = useMediaHandler(); const { setAudioInput, setVideoInput } = useMediaHandler();
const avatarUrl = useRoomAvatar(groupCall.room); const avatarUrl = useRoomAvatar(groupCall.room);
useEffect(() => { useEffect(() => {
@ -240,7 +241,7 @@ export function GroupCallView({
} else if (state === GroupCallState.Entering) { } else if (state === GroupCallState.Entering) {
return ( return (
<FullScreenView> <FullScreenView>
<h1>Entering room...</h1> <h1>{t("Entering room…")}</h1>
</FullScreenView> </FullScreenView>
); );
} else if (left) { } else if (left) {
@ -257,7 +258,7 @@ export function GroupCallView({
} else if (isEmbedded) { } else if (isEmbedded) {
return ( return (
<FullScreenView> <FullScreenView>
<h1>Loading room...</h1> <h1>{t("Loading room…")}</h1>
</FullScreenView> </FullScreenView>
); );
} else { } else {

View File

@ -23,6 +23,7 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed"; import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next";
import type { IWidgetApiRequest } from "matrix-widget-api"; import type { IWidgetApiRequest } from "matrix-widget-api";
import styles from "./InCallView.module.css"; import styles from "./InCallView.module.css";
@ -112,6 +113,7 @@ export function InCallView({
unencryptedEventsFromUsers, unencryptedEventsFromUsers,
hideHeader, hideHeader,
}: Props) { }: Props) {
const { t } = useTranslation();
usePreventScroll(); usePreventScroll();
const containerRef1 = useRef<HTMLDivElement | null>(null); const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver }); const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
@ -247,7 +249,7 @@ export function InCallView({
if (items.length === 0) { if (items.length === 0) {
return ( return (
<div className={styles.centerMessage}> <div className={styles.centerMessage}>
<p>Waiting for other participants...</p> <p>{t("Waiting for other participants…")}</p>
</div> </div>
); );
} }

View File

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent, ModalProps } from "../Modal"; import { Modal, ModalContent, ModalProps } from "../Modal";
import { CopyButton } from "../button"; import { CopyButton } from "../button";
@ -25,15 +26,18 @@ interface Props extends Omit<ModalProps, "title" | "children"> {
roomIdOrAlias: string; roomIdOrAlias: string;
} }
export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => ( export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => {
const { t } = useTranslation();
return (
<Modal <Modal
title="Invite People" title={t("Invite people")}
isDismissable isDismissable
className={styles.inviteModal} className={styles.inviteModal}
{...rest} {...rest}
> >
<ModalContent> <ModalContent>
<p>Copy and share this meeting link</p> <p>{t("Copy and share this call link")}</p>
<CopyButton <CopyButton
className={styles.copyButton} className={styles.copyButton}
value={getRoomUrl(roomIdOrAlias)} value={getRoomUrl(roomIdOrAlias)}
@ -41,3 +45,4 @@ export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => (
</ModalContent> </ModalContent>
</Modal> </Modal>
); );
};

View File

@ -19,6 +19,7 @@ import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { PressEvent } from "@react-types/shared"; import { PressEvent } from "@react-types/shared";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed"; import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { useTranslation } from "react-i18next";
import styles from "./LobbyView.module.css"; import styles from "./LobbyView.module.css";
import { Button, CopyButton } from "../button"; import { Button, CopyButton } from "../button";
@ -66,6 +67,7 @@ export function LobbyView({
isEmbedded, isEmbedded,
hideHeader, hideHeader,
}: Props) { }: Props) {
const { t } = useTranslation();
const { stream } = useCallFeed(localCallFeed); const { stream } = useCallFeed(localCallFeed);
const { const {
audioInput, audioInput,
@ -142,15 +144,15 @@ export function LobbyView({
variant="secondaryCopy" variant="secondaryCopy"
value={getRoomUrl(roomIdOrAlias)} value={getRoomUrl(roomIdOrAlias)}
className={styles.copyButton} 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")}
</CopyButton> </CopyButton>
</div> </div>
{!isEmbedded && ( {!isEmbedded && (
<Body className={styles.joinRoomFooter}> <Body className={styles.joinRoomFooter}>
<Link color="primary" to="/"> <Link color="primary" to="/">
Take me Home {t("Take me Home")}
</Link> </Link>
</Body> </Body>
)} )}

View File

@ -18,6 +18,7 @@ import React, { useCallback } from "react";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { OverlayTriggerState } from "@react-stately/overlays"; import { OverlayTriggerState } from "@react-stately/overlays";
import { useTranslation } from "react-i18next";
import { Button } from "../button"; import { Button } from "../button";
import { Menu } from "../Menu"; import { Menu } from "../Menu";
@ -31,6 +32,7 @@ import { SettingsModal } from "../settings/SettingsModal";
import { InviteModal } from "./InviteModal"; import { InviteModal } from "./InviteModal";
import { TooltipTrigger } from "../Tooltip"; import { TooltipTrigger } from "../Tooltip";
import { FeedbackModal } from "./FeedbackModal"; import { FeedbackModal } from "./FeedbackModal";
interface Props { interface Props {
roomIdOrAlias: string; roomIdOrAlias: string;
inCall: boolean; inCall: boolean;
@ -42,6 +44,7 @@ interface Props {
onClose: () => void; onClose: () => void;
}; };
} }
export function OverflowMenu({ export function OverflowMenu({
roomIdOrAlias, roomIdOrAlias,
inCall, inCall,
@ -50,6 +53,8 @@ export function OverflowMenu({
feedbackModalState, feedbackModalState,
feedbackModalProps, feedbackModalProps,
}: Props) { }: Props) {
const { t } = useTranslation();
const { const {
modalState: inviteModalState, modalState: inviteModalState,
modalProps: inviteModalProps, modalProps: inviteModalProps,
@ -90,29 +95,31 @@ export function OverflowMenu({
[feedbackModalState, inviteModalState, settingsModalState] [feedbackModalState, inviteModalState, settingsModalState]
); );
const tooltip = useCallback(() => t("More"), [t]);
return ( return (
<> <>
<PopoverMenuTrigger disableOnState> <PopoverMenuTrigger disableOnState>
<TooltipTrigger tooltip={() => "More"} placement="top"> <TooltipTrigger tooltip={tooltip} placement="top">
<Button variant="toolbar"> <Button variant="toolbar">
<OverflowIcon /> <OverflowIcon />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
{(props: JSX.IntrinsicAttributes) => ( {(props: JSX.IntrinsicAttributes) => (
<Menu {...props} label="more menu" onAction={onAction}> <Menu {...props} label={t("More menu")} onAction={onAction}>
{showInvite && ( {showInvite && (
<Item key="invite" textValue="Invite people"> <Item key="invite" textValue={t("Invite people")}>
<AddUserIcon /> <AddUserIcon />
<span>Invite people</span> <span>{t("Invite people")}</span>
</Item> </Item>
)} )}
<Item key="settings" textValue="Settings"> <Item key="settings" textValue={t("Settings")}>
<SettingsIcon /> <SettingsIcon />
<span>Settings</span> <span>{t("Settings")}</span>
</Item> </Item>
<Item key="feedback" textValue="Submit Feedback"> <Item key="feedback" textValue={t("Submit feedback")}>
<FeedbackIcon /> <FeedbackIcon />
<span>Submit Feedback</span> <span>{t("Submit feedback")}</span>
</Item> </Item>
</Menu> </Menu>
)} )}

View File

@ -17,10 +17,12 @@ limitations under the License.
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer"; import { ResizeObserver } from "@juggle/resize-observer";
import i18n from "i18next";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed"; import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { useTranslation } from "react-i18next";
import { useDelayedState } from "../useDelayedState"; import { useDelayedState } from "../useDelayedState";
import { useModalTriggerState } from "../Modal"; import { useModalTriggerState } from "../Modal";
@ -50,40 +52,45 @@ function getPromptText(
talkOverEnabled: boolean, talkOverEnabled: boolean,
activeSpeakerUserId: string, activeSpeakerUserId: string,
activeSpeakerDisplayName: string, activeSpeakerDisplayName: string,
connected: boolean connected: boolean,
t: typeof i18n.t
): string { ): string {
if (!connected) return "Connection lost"; if (!connected) return t("Connection lost");
const isTouchScreen = Boolean(window.ontouchstart !== undefined); const isTouchScreen = Boolean(window.ontouchstart !== undefined);
if (networkWaiting) { if (networkWaiting) {
return "Waiting for network"; return t("Waiting for network");
} }
if (showTalkOverError) { 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 (pttButtonHeld && activeSpeakerIsLocalUser) {
if (isTouchScreen) { if (isTouchScreen) {
return "Release to stop"; return t("Release to stop");
} else { } else {
return "Release spacebar key to stop"; return t("Release spacebar key to stop");
} }
} }
if (talkOverEnabled && activeSpeakerUserId && !activeSpeakerIsLocalUser) { if (talkOverEnabled && activeSpeakerUserId && !activeSpeakerIsLocalUser) {
if (isTouchScreen) { if (isTouchScreen) {
return `Press and hold to talk over ${activeSpeakerDisplayName}`; return t("Press and hold to talk over {{name}}", {
name: activeSpeakerDisplayName,
});
} else { } else {
return `Press and hold spacebar to talk over ${activeSpeakerDisplayName}`; return t("Press and hold spacebar to talk over {{name}}", {
name: activeSpeakerDisplayName,
});
} }
} }
if (isTouchScreen) { if (isTouchScreen) {
return "Press and hold to talk"; return t("Press and hold to talk");
} else { } 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<Props> = ({
isEmbedded, isEmbedded,
hideHeader, hideHeader,
}) => { }) => {
const { t } = useTranslation();
const { modalState: inviteModalState, modalProps: inviteModalProps } = const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState(); useModalTriggerState();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } = const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
@ -195,9 +203,11 @@ export const PTTCallView: React.FC<Props> = ({
{showControls && ( {showControls && (
<> <>
<div className={styles.participants}> <div className={styles.participants}>
<p>{`${participants.length} ${ <p>
participants.length > 1 ? "people" : "person" {t("{{count}} people connected", {
} connected`}</p> count: participants.length,
})}
</p>
<Facepile <Facepile
size={facepileSize} size={facepileSize}
max={8} max={8}
@ -230,8 +240,10 @@ export const PTTCallView: React.FC<Props> = ({
<AudioIcon className={styles.speakerIcon} /> <AudioIcon className={styles.speakerIcon} />
)} )}
{activeSpeakerIsLocalUser {activeSpeakerIsLocalUser
? "Talking..." ? t("Talking…")
: `${activeSpeakerDisplayName} is talking...`} : t("{{name}} is talking…", {
name: activeSpeakerDisplayName,
})}
</h2> </h2>
<Timer value={activeSpeakerUserId} /> <Timer value={activeSpeakerUserId} />
</div> </div>
@ -263,7 +275,8 @@ export const PTTCallView: React.FC<Props> = ({
talkOverEnabled, talkOverEnabled,
activeSpeakerUserId, activeSpeakerUserId,
activeSpeakerDisplayName, activeSpeakerDisplayName,
connected connected,
t
)} )}
</p> </p>
)} )}
@ -278,7 +291,7 @@ export const PTTCallView: React.FC<Props> = ({
<Toggle <Toggle
isSelected={talkOverEnabled} isSelected={talkOverEnabled}
onChange={setTalkOverEnabled} onChange={setTalkOverEnabled}
label="Talk over speaker" label={t("Talk over speaker")}
id="talkOverEnabled" id="talkOverEnabled"
/> />
)} )}

View File

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React, { FC, useEffect } from "react"; import React, { FC, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent, ModalProps } from "../Modal"; import { Modal, ModalContent, ModalProps } from "../Modal";
import { Button } from "../button"; import { Button } from "../button";
@ -33,6 +34,7 @@ export const RageshakeRequestModal: FC<Props> = ({
roomIdOrAlias, roomIdOrAlias,
...rest ...rest
}) => { }) => {
const { t } = useTranslation();
const { submitRageshake, sending, sent, error } = useSubmitRageshake(); const { submitRageshake, sending, sent, error } = useSubmitRageshake();
useEffect(() => { useEffect(() => {
@ -42,11 +44,12 @@ export const RageshakeRequestModal: FC<Props> = ({
}, [sent, rest]); }, [sent, rest]);
return ( return (
<Modal title="Debug Log Request" isDismissable {...rest}> <Modal title={t("Debug log request")} isDismissable {...rest}>
<ModalContent> <ModalContent>
<Body> <Body>
Another user on this call is having an issue. In order to better {t(
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."
)}
</Body> </Body>
<FieldRow> <FieldRow>
<Button <Button
@ -59,12 +62,12 @@ export const RageshakeRequestModal: FC<Props> = ({
} }
disabled={sending} disabled={sending}
> >
{sending ? "Sending debug log..." : "Send debug log"} {sending ? t("Sending debug log…") : t("Send debug log")}
</Button> </Button>
</FieldRow> </FieldRow>
{error && ( {error && (
<FieldRow> <FieldRow>
<ErrorMessage>{error.message}</ErrorMessage> <ErrorMessage error={error} />
</FieldRow> </FieldRow>
)} )}
</ModalContent> </ModalContent>

View File

@ -16,6 +16,7 @@ limitations under the License.
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import styles from "./RoomAuthView.module.css"; import styles from "./RoomAuthView.module.css";
import { Button } from "../button"; import { Button } from "../button";
@ -50,6 +51,7 @@ export function RoomAuthView() {
[registerPasswordlessUser] [registerPasswordlessUser]
); );
const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
return ( return (
@ -64,35 +66,38 @@ export function RoomAuthView() {
</Header> </Header>
<div className={styles.container}> <div className={styles.container}>
<main className={styles.main}> <main className={styles.main}>
<Headline className={styles.headline}>Join Call</Headline> <Headline className={styles.headline}>{t("Join call")}</Headline>
<Form className={styles.form} onSubmit={onSubmit}> <Form className={styles.form} onSubmit={onSubmit}>
<FieldRow> <FieldRow>
<InputField <InputField
id="displayName" id="displayName"
name="displayName" name="displayName"
label="Display Name" label={t("Display name")}
placeholder="Display Name" placeholder={t("Display name")}
type="text" type="text"
required required
autoComplete="off" autoComplete="off"
/> />
</FieldRow> </FieldRow>
<Caption> <Caption>
<Trans>
By clicking "Join call now", you agree to our{" "} By clicking "Join call now", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link> <Link href={privacyPolicyUrl}>Terms and conditions</Link>
</Trans>
</Caption> </Caption>
{error && ( {error && (
<FieldRow> <FieldRow>
<ErrorMessage>{error.message}</ErrorMessage> <ErrorMessage error={error} />
</FieldRow> </FieldRow>
)} )}
<Button type="submit" size="lg" disabled={loading}> <Button type="submit" size="lg" disabled={loading}>
{loading ? "Loading..." : "Join call now"} {loading ? t("Loading…") : t("Join call now")}
</Button> </Button>
<div id={recaptchaId} /> <div id={recaptchaId} />
</Form> </Form>
</main> </main>
<Body className={styles.footer}> <Body className={styles.footer}>
<Trans>
{"Not registered yet? "} {"Not registered yet? "}
<Link <Link
color="primary" color="primary"
@ -100,6 +105,7 @@ export function RoomAuthView() {
> >
Create an account Create an account
</Link> </Link>
</Trans>
</Body> </Body>
</div> </div>
</> </>

View File

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React, { FC, useEffect, useState, useCallback } from "react"; import React, { FC, useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { useClient } from "../ClientContext"; import { useClient } from "../ClientContext";
@ -22,11 +23,13 @@ import { ErrorView, LoadingView } from "../FullScreenView";
import { RoomAuthView } from "./RoomAuthView"; import { RoomAuthView } from "./RoomAuthView";
import { GroupCallLoader } from "./GroupCallLoader"; import { GroupCallLoader } from "./GroupCallLoader";
import { GroupCallView } from "./GroupCallView"; import { GroupCallView } from "./GroupCallView";
import { useRoomParams } from "./useRoomParams"; import { useUrlParams } from "../UrlParams";
import { MediaHandlerProvider } from "../settings/useMediaHandler"; import { MediaHandlerProvider } from "../settings/useMediaHandler";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { translatedError } from "../TranslatedError";
export const RoomPage: FC = () => { export const RoomPage: FC = () => {
const { t } = useTranslation();
const { loading, isAuthenticated, error, client, isPasswordlessUser } = const { loading, isAuthenticated, error, client, isPasswordlessUser } =
useClient(); useClient();
@ -39,9 +42,9 @@ export const RoomPage: FC = () => {
hideHeader, hideHeader,
isPtt, isPtt,
displayName, displayName,
} = useRoomParams(); } = useUrlParams();
const roomIdOrAlias = roomId ?? roomAlias; const roomIdOrAlias = roomId ?? roomAlias;
if (!roomIdOrAlias) throw new Error("No room specified"); if (!roomIdOrAlias) throw translatedError("No room specified", t);
const { registerPasswordlessUser } = useRegisterPasswordlessUser(); const { registerPasswordlessUser } = useRegisterPasswordlessUser();
const [isRegistering, setIsRegistering] = useState(false); const [isRegistering, setIsRegistering] = useState(false);

View File

@ -19,6 +19,7 @@ import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer"; import { ResizeObserver } from "@juggle/resize-observer";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall"; import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
import { MicButton, VideoButton } from "../button"; import { MicButton, VideoButton } from "../button";
import { useMediaStream } from "../video-grid/useMediaStream"; import { useMediaStream } from "../video-grid/useMediaStream";
@ -40,6 +41,7 @@ interface Props {
audioOutput: string; audioOutput: string;
stream: MediaStream; stream: MediaStream;
} }
export function VideoPreview({ export function VideoPreview({
client, client,
state, state,
@ -51,6 +53,7 @@ export function VideoPreview({
audioOutput, audioOutput,
stream, stream,
}: Props) { }: Props) {
const { t } = useTranslation();
const videoRef = useMediaStream(stream, audioOutput, true); const videoRef = useMediaStream(stream, audioOutput, true);
const { displayName, avatarUrl } = useProfile(client); const { displayName, avatarUrl } = useProfile(client);
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver }); const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
@ -64,12 +67,12 @@ export function VideoPreview({
<video ref={videoRef} muted playsInline disablePictureInPicture /> <video ref={videoRef} muted playsInline disablePictureInPicture />
{state === GroupCallState.LocalCallFeedUninitialized && ( {state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.cameraPermissions}> <Body fontWeight="semiBold" className={styles.cameraPermissions}>
Camera/microphone permissions needed to join the call. {t("Camera/microphone permissions needed to join the call.")}
</Body> </Body>
)} )}
{state === GroupCallState.InitializingLocalCallFeed && ( {state === GroupCallState.InitializingLocalCallFeed && (
<Body fontWeight="semiBold" className={styles.cameraPermissions}> <Body fontWeight="semiBold" className={styles.cameraPermissions}>
Accept camera/microphone permissions to join the call. {t("Accept camera/microphone permissions to join the call.")}
</Body> </Body>
)} )}
{state === GroupCallState.LocalCallFeedInitialized && ( {state === GroupCallState.LocalCallFeedInitialized && (

View File

@ -26,8 +26,10 @@ import {
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed"; import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { useTranslation } from "react-i18next";
import { usePageUnload } from "./usePageUnload"; import { usePageUnload } from "./usePageUnload";
import { TranslatedError, translatedError } from "../TranslatedError";
export interface UseGroupCallReturnType { export interface UseGroupCallReturnType {
state: GroupCallState; state: GroupCallState;
@ -37,7 +39,7 @@ export interface UseGroupCallReturnType {
userMediaFeeds: CallFeed[]; userMediaFeeds: CallFeed[];
microphoneMuted: boolean; microphoneMuted: boolean;
localVideoMuted: boolean; localVideoMuted: boolean;
error: Error; error: TranslatedError | null;
initLocalCallFeed: () => void; initLocalCallFeed: () => void;
enter: () => void; enter: () => void;
leave: () => void; leave: () => void;
@ -60,7 +62,7 @@ interface State {
localCallFeed: CallFeed; localCallFeed: CallFeed;
activeSpeaker: string; activeSpeaker: string;
userMediaFeeds: CallFeed[]; userMediaFeeds: CallFeed[];
error: Error; error: TranslatedError | null;
microphoneMuted: boolean; microphoneMuted: boolean;
localVideoMuted: boolean; localVideoMuted: boolean;
screenshareFeeds: CallFeed[]; screenshareFeeds: CallFeed[];
@ -309,15 +311,18 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
}); });
}, [groupCall]); }, [groupCall]);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
if (window.RTCPeerConnection === undefined) { if (window.RTCPeerConnection === undefined) {
const error = new Error( const error = translatedError(
"WebRTC is not supported or is being blocked in this browser." "WebRTC is not supported or is being blocked in this browser.",
t
); );
console.error(error); console.error(error);
updateState({ error }); updateState({ error });
} }
}, []); }, [t]);
return { return {
state, state,

View File

@ -24,10 +24,12 @@ import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEv
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { SyncState } from "matrix-js-sdk/src/sync"; import { SyncState } from "matrix-js-sdk/src/sync";
import { useTranslation } from "react-i18next";
import type { Room } from "matrix-js-sdk/src/models/room"; import type { Room } from "matrix-js-sdk/src/models/room";
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils"; import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
import { translatedError } from "../TranslatedError";
export interface GroupCallLoadState { export interface GroupCallLoadState {
loading: boolean; loading: boolean;
@ -41,6 +43,7 @@ export const useLoadGroupCall = (
viaServers: string[], viaServers: string[],
createPtt: boolean createPtt: boolean
): GroupCallLoadState => { ): GroupCallLoadState => {
const { t } = useTranslation();
const [state, setState] = useState<GroupCallLoadState>({ loading: true }); const [state, setState] = useState<GroupCallLoadState>({ loading: true });
useEffect(() => { useEffect(() => {
@ -122,7 +125,7 @@ export const useLoadGroupCall = (
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
client.off(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming); client.off(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming);
reject(new Error("Fetching group call timed out.")); reject(translatedError("Fetching group call timed out.", t));
}, 30000); }, 30000);
}); });
}; };
@ -153,7 +156,7 @@ export const useLoadGroupCall = (
.catch((error) => .catch((error) =>
setState((prevState) => ({ ...prevState, loading: false, error })) setState((prevState) => ({ ...prevState, loading: false, error }))
); );
}, [client, roomIdOrAlias, viaServers, createPtt]); }, [client, roomIdOrAlias, viaServers, createPtt, t]);
return state; return state;
}; };

View File

@ -16,6 +16,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { Item } from "@react-stately/collections"; import { Item } from "@react-stately/collections";
import { useTranslation } from "react-i18next";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css"; import styles from "./SettingsModal.module.css";
@ -37,6 +38,7 @@ interface Props {
} }
export const SettingsModal = (props: Props) => { export const SettingsModal = (props: Props) => {
const { t } = useTranslation();
const { const {
audioInput, audioInput,
audioInputs, audioInputs,
@ -56,7 +58,7 @@ export const SettingsModal = (props: Props) => {
return ( return (
<Modal <Modal
title="Settings" title={t("Settings")}
isDismissable isDismissable
mobileFullScreen mobileFullScreen
className={styles.settingsModal} className={styles.settingsModal}
@ -67,12 +69,12 @@ export const SettingsModal = (props: Props) => {
title={ title={
<> <>
<AudioIcon width={16} height={16} /> <AudioIcon width={16} height={16} />
<span>Audio</span> <span>{t("Audio")}</span>
</> </>
} }
> >
<SelectInput <SelectInput
label="Microphone" label={t("Microphone")}
selectedKey={audioInput} selectedKey={audioInput}
onSelectionChange={setAudioInput} onSelectionChange={setAudioInput}
> >
@ -80,13 +82,13 @@ export const SettingsModal = (props: Props) => {
<Item key={deviceId}> <Item key={deviceId}>
{!!label && label.trim().length > 0 {!!label && label.trim().length > 0
? label ? label
: `Microphone ${index + 1}`} : t("Microphone {{n}}", { n: index + 1 })}
</Item> </Item>
))} ))}
</SelectInput> </SelectInput>
{audioOutputs.length > 0 && ( {audioOutputs.length > 0 && (
<SelectInput <SelectInput
label="Speaker" label={t("Speaker")}
selectedKey={audioOutput} selectedKey={audioOutput}
onSelectionChange={setAudioOutput} onSelectionChange={setAudioOutput}
> >
@ -94,7 +96,7 @@ export const SettingsModal = (props: Props) => {
<Item key={deviceId}> <Item key={deviceId}>
{!!label && label.trim().length > 0 {!!label && label.trim().length > 0
? label ? label
: `Speaker ${index + 1}`} : t("Speaker {{n}}", { n: index + 1 })}
</Item> </Item>
))} ))}
</SelectInput> </SelectInput>
@ -102,10 +104,12 @@ export const SettingsModal = (props: Props) => {
<FieldRow> <FieldRow>
<InputField <InputField
id="spatialAudio" id="spatialAudio"
label="Spatial audio" label={t("Spatial audio")}
type="checkbox" type="checkbox"
checked={spatialAudio} checked={spatialAudio}
description="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.)" description={t(
"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.)"
)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setSpatialAudio(event.target.checked) setSpatialAudio(event.target.checked)
} }
@ -116,12 +120,12 @@ export const SettingsModal = (props: Props) => {
title={ title={
<> <>
<VideoIcon width={16} height={16} /> <VideoIcon width={16} height={16} />
<span>Video</span> <span>{t("Video")}</span>
</> </>
} }
> >
<SelectInput <SelectInput
label="Camera" label={t("Camera")}
selectedKey={videoInput} selectedKey={videoInput}
onSelectionChange={setVideoInput} onSelectionChange={setVideoInput}
> >
@ -129,7 +133,7 @@ export const SettingsModal = (props: Props) => {
<Item key={deviceId}> <Item key={deviceId}>
{!!label && label.trim().length > 0 {!!label && label.trim().length > 0
? label ? label
: `Camera ${index + 1}`} : t("Camera {{n}}", { n: index + 1 })}
</Item> </Item>
))} ))}
</SelectInput> </SelectInput>
@ -138,20 +142,22 @@ export const SettingsModal = (props: Props) => {
title={ title={
<> <>
<DeveloperIcon width={16} height={16} /> <DeveloperIcon width={16} height={16} />
<span>Developer</span> <span>{t("Developer")}</span>
</> </>
} }
> >
<FieldRow> <FieldRow>
<Body className={styles.fieldRowText}> <Body className={styles.fieldRowText}>
Version: {import.meta.env.VITE_APP_VERSION || "dev"} {t("Version: {{version}}", {
version: import.meta.env.VITE_APP_VERSION || "dev",
})}
</Body> </Body>
</FieldRow> </FieldRow>
<FieldRow> <FieldRow>
<InputField <InputField
id="showInspector" id="showInspector"
name="inspector" name="inspector"
label="Show Call Inspector" label={t("Show call inspector")}
type="checkbox" type="checkbox"
checked={showInspector} checked={showInspector}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
@ -160,7 +166,9 @@ export const SettingsModal = (props: Props) => {
/> />
</FieldRow> </FieldRow>
<FieldRow> <FieldRow>
<Button onPress={downloadDebugLog}>Download Debug Logs</Button> <Button onPress={downloadDebugLog}>
{t("Download debug logs")}
</Button>
</FieldRow> </FieldRow>
</TabItem> </TabItem>
</TabContainer> </TabContainer>

View File

@ -17,6 +17,7 @@ limitations under the License.
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import { animated } from "@react-spring/web"; import { animated } from "@react-spring/web";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next";
import styles from "./VideoTile.module.css"; import styles from "./VideoTile.module.css";
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg"; import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
@ -66,6 +67,8 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
}, },
ref ref
) => { ) => {
const { t } = useTranslation();
const toolbarButtons: JSX.Element[] = []; const toolbarButtons: JSX.Element[] = [];
if (!isLocal) { if (!isLocal) {
toolbarButtons.push( toolbarButtons.push(
@ -111,7 +114,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
{!maximised && {!maximised &&
(screenshare ? ( (screenshare ? (
<div className={styles.presenterLabel}> <div className={styles.presenterLabel}>
<span>{`${name} is presenting`}</span> <span>{t("{{name}} is presenting", { name })}</span>
</div> </div>
) : ( ) : (
<div className={classNames(styles.infoBubble, styles.memberName)}> <div className={classNames(styles.infoBubble, styles.memberName)}>

View File

@ -16,6 +16,7 @@ limitations under the License.
import React, { ChangeEvent, useState } from "react"; import React, { ChangeEvent, useState } from "react";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed"; import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { useTranslation } from "react-i18next";
import { FieldRow } from "../input/Input"; import { FieldRow } from "../input/Input";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
@ -61,10 +62,12 @@ interface Props {
} }
export const VideoTileSettingsModal = ({ feed, ...rest }: Props) => { export const VideoTileSettingsModal = ({ feed, ...rest }: Props) => {
const { t } = useTranslation();
return ( return (
<Modal <Modal
className={styles.videoTileSettingsModal} className={styles.videoTileSettingsModal}
title="Local volume" title={t("Local volume")}
isDismissable isDismissable
mobileFullScreen mobileFullScreen
{...rest} {...rest}

View File

@ -22,7 +22,7 @@ import { WidgetApi, MatrixCapabilities } from "matrix-widget-api";
import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { IWidgetApiRequest } from "matrix-widget-api"; import type { IWidgetApiRequest } from "matrix-widget-api";
import { LazyEventEmitter } from "./LazyEventEmitter"; import { LazyEventEmitter } from "./LazyEventEmitter";
import { getRoomParams } from "./room/useRoomParams"; import { getUrlParams } from "./UrlParams";
// Subset of the actions in matrix-react-sdk // Subset of the actions in matrix-react-sdk
export enum ElementWidgetActions { export enum ElementWidgetActions {
@ -80,7 +80,7 @@ export const widget: WidgetHelpers | null = (() => {
// We need to do this now rather than later because it has capabilities to // We need to do this now rather than later because it has capabilities to
// request, and is responsible for starting the transport (should it be?) // request, and is responsible for starting the transport (should it be?)
const { roomId, userId, deviceId } = getRoomParams(); const { roomId, userId, deviceId } = getUrlParams();
if (!roomId) throw new Error("Room ID must be supplied"); if (!roomId) throw new Error("Room ID must be supplied");
if (!userId) throw new Error("User ID must be supplied"); if (!userId) throw new Error("User ID must be supplied");
if (!deviceId) throw new Error("Device ID must be supplied"); if (!deviceId) throw new Error("Device ID must be supplied");

View File

@ -29,7 +29,7 @@ export default defineConfig(({ mode }) => {
}, },
plugins: [ plugins: [
svgrPlugin(), svgrPlugin(),
htmlTemplate({ htmlTemplate.default({
data: { data: {
title: env.VITE_PRODUCT_NAME || "Matrix Video Chat", title: env.VITE_PRODUCT_NAME || "Matrix Video Chat",
}, },

731
yarn.lock

File diff suppressed because it is too large Load Diff