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

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.
*/
import React from "react";
import React, { Suspense } from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { OverlayProvider } from "@react-aria/overlays";
@ -43,34 +43,36 @@ export default function App({ history }: AppProps) {
return (
<Router history={history}>
<ClientProvider>
<InspectorContextProvider>
<Sentry.ErrorBoundary fallback={errorPage}>
<OverlayProvider>
<Switch>
<SentryRoute exact path="/">
<HomePage />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
</SentryRoute>
<SentryRoute exact path="/register">
<RegisterPage />
</SentryRoute>
<SentryRoute path="/room/:roomId?">
<RoomPage />
</SentryRoute>
<SentryRoute path="/inspector">
<SequenceDiagramViewerPage />
</SentryRoute>
<SentryRoute path="*">
<RoomRedirect />
</SentryRoute>
</Switch>
</OverlayProvider>
</Sentry.ErrorBoundary>
</InspectorContextProvider>
</ClientProvider>
<Suspense fallback={null}>
<ClientProvider>
<InspectorContextProvider>
<Sentry.ErrorBoundary fallback={errorPage}>
<OverlayProvider>
<Switch>
<SentryRoute exact path="/">
<HomePage />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
</SentryRoute>
<SentryRoute exact path="/register">
<RegisterPage />
</SentryRoute>
<SentryRoute path="/room/:roomId?">
<RoomPage />
</SentryRoute>
<SentryRoute path="/inspector">
<SequenceDiagramViewerPage />
</SentryRoute>
<SentryRoute path="*">
<RoomRedirect />
</SentryRoute>
</Switch>
</OverlayProvider>
</Sentry.ErrorBoundary>
</InspectorContextProvider>
</ClientProvider>
</Suspense>
</Router>
);
}

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
import { useButton } from "@react-aria/button";
import { AriaButtonProps } from "@react-types/button";
import { Room } from "matrix-js-sdk/src/models/room";
import { useTranslation } from "react-i18next";
import styles from "./Header.module.css";
import { useModalTriggerState } from "./Modal";
@ -156,6 +157,7 @@ export function VersionMismatchWarning({
users,
room,
}: VersionMismatchWarningProps) {
const { t } = useTranslation();
const { modalState, modalProps } = useModalTriggerState();
const onDetailsClick = useCallback(() => {
@ -166,9 +168,9 @@ export function VersionMismatchWarning({
return (
<span className={styles.versionMismatchWarning}>
Incomaptible versions!
{t("Incompatible versions!")}
<Button variant="link" onClick={onDetailsClick}>
Details
{t("Details")}
</Button>
{modalState.isOpen && (
<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 React from "react";
import React, { useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Modal, ModalContent } from "./Modal";
import { Body } from "./typography/Typography";
@ -30,17 +31,21 @@ export const IncompatibleVersionModal: React.FC<Props> = ({
room,
...rest
}) => {
const userLis = Array.from(userIds).map((u) => (
<li>{room.getMember(u).name}</li>
));
const { t } = useTranslation();
const userLis = useMemo(
() => [...userIds].map((u) => <li>{room.getMember(u)?.name ?? u}</li>),
[userIds, room]
);
return (
<Modal title="Incompatible Versions" isDismissable {...rest}>
<Modal title={t("Incompatible versions")} isDismissable {...rest}>
<ModalContent>
<Body>
Other users are trying to join this call from incompatible versions.
These users should ensure that they have refreshed their browsers:
<ul>{userLis}</ul>
<Trans>
Other users are trying to join this call from incompatible versions.
These users should ensure that they have refreshed their browsers:
<ul>{userLis}</ul>
</Trans>
</Body>
</ModalContent>
</Modal>

View File

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

View File

@ -15,6 +15,7 @@ limitations under the License.
*/
import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
SequenceDiagramViewer,
@ -30,7 +31,8 @@ interface DebugLog {
}
export function SequenceDiagramViewerPage() {
usePageTitle("Inspector");
const { t } = useTranslation();
usePageTitle(t("Inspector"));
const [debugLog, setDebugLog] = useState<DebugLog>();
const [selectedUserId, setSelectedUserId] = useState<string>();
@ -49,7 +51,7 @@ export function SequenceDiagramViewerPage() {
type="file"
id="debugLog"
name="debugLog"
label="Debug Log"
label={t("Debug log")}
onChange={onChangeDebugLog}
/>
</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 { useLocation } from "react-router-dom";
export interface RoomParams {
export interface UrlParams {
roomAlias: string | null;
roomId: string | null;
viaServers: string[];
@ -39,25 +39,27 @@ export interface RoomParams {
displayName: string | null;
// The device's ID (only used in Matroska mode)
deviceId: string | null;
// The BCP 47 code of the language the app should use
lang: string | null;
}
/**
* Gets the room parameters for the current URL.
* @param {string} query The URL query string
* @param {string} fragment The URL fragment string
* @returns {RoomParams} The room parameters encoded in the URL
* Gets the app parameters for the current URL.
* @param query The URL query string
* @param fragment The URL fragment string
* @returns The app parameters encoded in the URL
*/
export const getRoomParams = (
export const getUrlParams = (
query: string = window.location.search,
fragment: string = window.location.hash
): RoomParams => {
): UrlParams => {
const fragmentQueryStart = fragment.indexOf("?");
const fragmentParams = new URLSearchParams(
fragmentQueryStart === -1 ? "" : fragment.substring(fragmentQueryStart)
);
const queryParams = new URLSearchParams(query);
// Normally, room params should be encoded in the fragment so as to avoid
// Normally, URL params should be encoded in the fragment so as to avoid
// leaking them to the server. However, we also check the normal query
// string for backwards compatibility with versions that only used that.
const hasParam = (name: string): boolean =>
@ -87,14 +89,15 @@ export const getRoomParams = (
userId: getParam("userId"),
displayName: getParam("displayName"),
deviceId: getParam("deviceId"),
lang: getParam("lang"),
};
};
/**
* Hook to simplify use of getRoomParams.
* @returns {RoomParams} The room parameters for the current URL
* Hook to simplify use of getUrlParams.
* @returns The app parameters for the current URL
*/
export const useRoomParams = (): RoomParams => {
export const useUrlParams = (): UrlParams => {
const { hash, search } = useLocation();
return useMemo(() => getRoomParams(search, hash), [search, hash]);
return useMemo(() => getUrlParams(search, hash), [search, hash]);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,6 +22,7 @@ import React, {
} from "react";
import { useHistory } from "react-router-dom";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useGroupCallRooms } from "./useGroupCallRooms";
@ -48,6 +49,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const history = useHistory();
const { t } = useTranslation();
const { modalState, modalProps } = useModalTriggerState();
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
@ -93,7 +95,9 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
}, [history, existingRoomId]);
const callNameLabel =
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
callType === CallType.Video
? t("Video call name")
: t("Walkie-talkie call name");
return (
<>
@ -127,19 +131,19 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
className={styles.button}
disabled={loading}
>
{loading ? "Loading..." : "Go"}
{loading ? t("Loading…") : t("Go")}
</Button>
</FieldRow>
{error && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage>{error.message}</ErrorMessage>
<ErrorMessage error={error} />
</FieldRow>
)}
</Form>
{recentRooms.length > 0 && (
<>
<Title className={styles.recentCallsTitle}>
Your recent Calls
{t("Your recent calls")}
</Title>
<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 { useHistory } from "react-router-dom";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { Trans, useTranslation } from "react-i18next";
import { useClient } from "../ClientContext";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
@ -47,6 +48,7 @@ export const UnauthenticatedView: FC = () => {
const { modalState, modalProps } = useModalTriggerState();
const [onFinished, setOnFinished] = useState<() => void>();
const history = useHistory();
const { t } = useTranslation();
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
(e) => {
@ -105,7 +107,9 @@ export const UnauthenticatedView: FC = () => {
);
const callNameLabel =
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
callType === CallType.Video
? t("Video call name")
: t("Walkie-talkie call name");
return (
<>
@ -137,24 +141,26 @@ export const UnauthenticatedView: FC = () => {
<InputField
id="displayName"
name="displayName"
label="Display Name"
placeholder="Display Name"
label={t("Display name")}
placeholder={t("Display name")}
type="text"
required
autoComplete="off"
/>
</FieldRow>
<Caption>
By clicking "Go", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
<Trans>
By clicking "Go", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
</Trans>
</Caption>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
<ErrorMessage error={error} />
</FieldRow>
)}
<Button type="submit" size="lg" disabled={loading}>
{loading ? "Loading..." : "Go"}
{loading ? t("Loading…") : t("Go")}
</Button>
<div id={recaptchaId} />
</Form>
@ -162,14 +168,16 @@ export const UnauthenticatedView: FC = () => {
<footer className={styles.footer}>
<Body className={styles.mobileLoginLink}>
<Link color="primary" to="/login">
Login to your account
{t("Login to your account")}
</Link>
</Body>
<Body>
Not registered yet?{" "}
<Link color="primary" to="/register">
Create an account
</Link>
<Trans>
Not registered yet?{" "}
<Link color="primary" to="/register">
Create an account
</Link>
</Trans>
</Body>
</footer>
</div>

View File

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

View File

@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ChangeEvent, forwardRef, ReactNode } from "react";
import React, { ChangeEvent, FC, forwardRef, ReactNode } from "react";
import classNames from "classnames";
import styles from "./Input.module.css";
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import { TranslatedError } from "../TranslatedError";
interface FieldRowProps {
children: ReactNode;
@ -140,10 +141,12 @@ export const InputField = forwardRef<
}
);
export function ErrorMessage({
children,
}: {
children: ReactNode;
}): JSX.Element {
return <p className={styles.errorMessage}>{children}</p>;
interface ErrorMessageProps {
error: Error;
}
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 { useSelectState } from "@react-stately/select";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { Popover } from "../popover/Popover";
import { ListBox } from "../ListBox";
@ -30,6 +31,7 @@ interface Props extends AriaSelectOptions<object> {
}
export function SelectInput(props: Props): JSX.Element {
const { t } = useTranslation();
const state = useSelectState(props);
const ref = useRef();
@ -56,7 +58,7 @@ export function SelectInput(props: Props): JSX.Element {
<span {...valueProps} className={styles.selectedItem}>
{state.selectedItem
? state.selectedItem.rendered
: "Select an option"}
: t("Select an option")}
</span>
<ArrowDownIcon />
</button>

View File

@ -25,10 +25,15 @@ import ReactDOM from "react-dom";
import { createBrowserHistory } from "history";
import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";
import "./index.css";
import App from "./App";
import { init as initRageshake } from "./settings/rageshake";
import { getUrlParams } from "./UrlParams";
initRageshake();
@ -104,6 +109,35 @@ Sentry.init({
tracesSampleRate: 1.0,
});
const languageDetector = new LanguageDetector();
languageDetector.addDetector({
name: "urlFragment",
// Look for a language code in the URL's fragment
lookup: () => getUrlParams().lang ?? undefined,
});
i18n
.use(Backend)
.use(languageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en-GB",
defaultNS: "app",
keySeparator: false,
nsSeparator: false,
pluralSeparator: "|",
contextSeparator: "|",
interpolation: {
escapeValue: false, // React has built-in XSS protections
},
detection: {
// No localStorage detectors or caching here, since we don't have any way
// of letting the user manually select a language
order: ["urlFragment", "navigator"],
caches: [],
},
});
ReactDOM.render(
<React.StrictMode>
<App history={history} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,6 +19,7 @@ import { useHistory } from "react-router-dom";
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import type { IWidgetApiRequest } from "matrix-widget-api";
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
@ -81,8 +82,8 @@ export function GroupCallView({
unencryptedEventsFromUsers,
} = useGroupCall(groupCall);
const { t } = useTranslation();
const { setAudioInput, setVideoInput } = useMediaHandler();
const avatarUrl = useRoomAvatar(groupCall.room);
useEffect(() => {
@ -240,7 +241,7 @@ export function GroupCallView({
} else if (state === GroupCallState.Entering) {
return (
<FullScreenView>
<h1>Entering room...</h1>
<h1>{t("Entering room…")}</h1>
</FullScreenView>
);
} else if (left) {
@ -257,7 +258,7 @@ export function GroupCallView({
} else if (isEmbedded) {
return (
<FullScreenView>
<h1>Loading room...</h1>
<h1>{t("Loading room…")}</h1>
</FullScreenView>
);
} 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 { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import type { IWidgetApiRequest } from "matrix-widget-api";
import styles from "./InCallView.module.css";
@ -112,6 +113,7 @@ export function InCallView({
unencryptedEventsFromUsers,
hideHeader,
}: Props) {
const { t } = useTranslation();
usePreventScroll();
const containerRef1 = useRef<HTMLDivElement | null>(null);
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
@ -247,7 +249,7 @@ export function InCallView({
if (items.length === 0) {
return (
<div className={styles.centerMessage}>
<p>Waiting for other participants...</p>
<p>{t("Waiting for other participants…")}</p>
</div>
);
}

View File

@ -15,6 +15,7 @@ limitations under the License.
*/
import React, { FC } from "react";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent, ModalProps } from "../Modal";
import { CopyButton } from "../button";
@ -25,19 +26,23 @@ interface Props extends Omit<ModalProps, "title" | "children"> {
roomIdOrAlias: string;
}
export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => (
<Modal
title="Invite People"
isDismissable
className={styles.inviteModal}
{...rest}
>
<ModalContent>
<p>Copy and share this meeting link</p>
<CopyButton
className={styles.copyButton}
value={getRoomUrl(roomIdOrAlias)}
/>
</ModalContent>
</Modal>
);
export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => {
const { t } = useTranslation();
return (
<Modal
title={t("Invite people")}
isDismissable
className={styles.inviteModal}
{...rest}
>
<ModalContent>
<p>{t("Copy and share this call link")}</p>
<CopyButton
className={styles.copyButton}
value={getRoomUrl(roomIdOrAlias)}
/>
</ModalContent>
</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 { PressEvent } from "@react-types/shared";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { useTranslation } from "react-i18next";
import styles from "./LobbyView.module.css";
import { Button, CopyButton } from "../button";
@ -66,6 +67,7 @@ export function LobbyView({
isEmbedded,
hideHeader,
}: Props) {
const { t } = useTranslation();
const { stream } = useCallFeed(localCallFeed);
const {
audioInput,
@ -142,15 +144,15 @@ export function LobbyView({
variant="secondaryCopy"
value={getRoomUrl(roomIdOrAlias)}
className={styles.copyButton}
copiedMessage="Call link copied"
copiedMessage={t("Call link copied")}
>
Copy call link and join later
{t("Copy call link and join later")}
</CopyButton>
</div>
{!isEmbedded && (
<Body className={styles.joinRoomFooter}>
<Link color="primary" to="/">
Take me Home
{t("Take me Home")}
</Link>
</Body>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,8 +26,10 @@ import {
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { useTranslation } from "react-i18next";
import { usePageUnload } from "./usePageUnload";
import { TranslatedError, translatedError } from "../TranslatedError";
export interface UseGroupCallReturnType {
state: GroupCallState;
@ -37,7 +39,7 @@ export interface UseGroupCallReturnType {
userMediaFeeds: CallFeed[];
microphoneMuted: boolean;
localVideoMuted: boolean;
error: Error;
error: TranslatedError | null;
initLocalCallFeed: () => void;
enter: () => void;
leave: () => void;
@ -60,7 +62,7 @@ interface State {
localCallFeed: CallFeed;
activeSpeaker: string;
userMediaFeeds: CallFeed[];
error: Error;
error: TranslatedError | null;
microphoneMuted: boolean;
localVideoMuted: boolean;
screenshareFeeds: CallFeed[];
@ -309,15 +311,18 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
});
}, [groupCall]);
const { t } = useTranslation();
useEffect(() => {
if (window.RTCPeerConnection === undefined) {
const error = new Error(
"WebRTC is not supported or is being blocked in this browser."
const error = translatedError(
"WebRTC is not supported or is being blocked in this browser.",
t
);
console.error(error);
updateState({ error });
}
}, []);
}, [t]);
return {
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 { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
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 { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
import { translatedError } from "../TranslatedError";
export interface GroupCallLoadState {
loading: boolean;
@ -41,6 +43,7 @@ export const useLoadGroupCall = (
viaServers: string[],
createPtt: boolean
): GroupCallLoadState => {
const { t } = useTranslation();
const [state, setState] = useState<GroupCallLoadState>({ loading: true });
useEffect(() => {
@ -122,7 +125,7 @@ export const useLoadGroupCall = (
const timeout = setTimeout(() => {
client.off(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming);
reject(new Error("Fetching group call timed out."));
reject(translatedError("Fetching group call timed out.", t));
}, 30000);
});
};
@ -153,7 +156,7 @@ export const useLoadGroupCall = (
.catch((error) =>
setState((prevState) => ({ ...prevState, loading: false, error }))
);
}, [client, roomIdOrAlias, viaServers, createPtt]);
}, [client, roomIdOrAlias, viaServers, createPtt, t]);
return state;
};

View File

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

View File

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

View File

@ -16,6 +16,7 @@ limitations under the License.
import React, { ChangeEvent, useState } from "react";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { useTranslation } from "react-i18next";
import { FieldRow } from "../input/Input";
import { Modal } from "../Modal";
@ -61,10 +62,12 @@ interface Props {
}
export const VideoTileSettingsModal = ({ feed, ...rest }: Props) => {
const { t } = useTranslation();
return (
<Modal
className={styles.videoTileSettingsModal}
title="Local volume"
title={t("Local volume")}
isDismissable
mobileFullScreen
{...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 { IWidgetApiRequest } from "matrix-widget-api";
import { LazyEventEmitter } from "./LazyEventEmitter";
import { getRoomParams } from "./room/useRoomParams";
import { getUrlParams } from "./UrlParams";
// Subset of the actions in matrix-react-sdk
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
// 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 (!userId) throw new Error("User 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: [
svgrPlugin(),
htmlTemplate({
htmlTemplate.default({
data: {
title: env.VITE_PRODUCT_NAME || "Matrix Video Chat",
},

731
yarn.lock

File diff suppressed because it is too large Load Diff