mirror of
https://github.com/vector-im/element-call.git
synced 2024-11-21 00:28:08 +08:00
Set up translation with i18next
This commit is contained in:
parent
eca598e28f
commit
8524b9ecd6
20
i18next-parser.config.js
Normal file
20
i18next-parser.config.js
Normal 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,
|
||||
};
|
@ -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",
|
||||
|
4
public/locales/de-DE/app.json
Normal file
4
public/locales/de-DE/app.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"Invite": "Einladen",
|
||||
"Video call": "Videoanruf"
|
||||
}
|
135
public/locales/en-GB/app.json
Normal file
135
public/locales/en-GB/app.json
Normal 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"
|
||||
}
|
60
src/App.tsx
60
src/App.tsx
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { Suspense } from "react";
|
||||
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { OverlayProvider } from "@react-aria/overlays";
|
||||
@ -43,34 +43,36 @@ export default function App({ history }: AppProps) {
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -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>(
|
||||
() => ({
|
||||
|
@ -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) +
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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} />
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
41
src/TranslatedError.ts
Normal 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);
|
@ -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]);
|
||||
};
|
@ -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} />
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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 />
|
||||
</>
|
||||
) : (
|
||||
|
@ -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} />
|
||||
)}
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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 />
|
||||
</>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
34
src/main.tsx
34
src/main.tsx
@ -25,10 +25,15 @@ import ReactDOM from "react-dom";
|
||||
import { createBrowserHistory } from "history";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { Integrations } from "@sentry/tracing";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import Backend from "i18next-http-backend";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { init as initRageshake } from "./settings/rageshake";
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
|
||||
initRageshake();
|
||||
|
||||
@ -104,6 +109,35 @@ Sentry.init({
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.addDetector({
|
||||
name: "urlFragment",
|
||||
// Look for a language code in the URL's fragment
|
||||
lookup: () => getUrlParams().lang ?? undefined,
|
||||
});
|
||||
|
||||
i18n
|
||||
.use(Backend)
|
||||
.use(languageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: "en-GB",
|
||||
defaultNS: "app",
|
||||
keySeparator: false,
|
||||
nsSeparator: false,
|
||||
pluralSeparator: "|",
|
||||
contextSeparator: "|",
|
||||
interpolation: {
|
||||
escapeValue: false, // React has built-in XSS protections
|
||||
},
|
||||
detection: {
|
||||
// No localStorage detectors or caching here, since we don't have any way
|
||||
// of letting the user manually select a language
|
||||
order: ["urlFragment", "navigator"],
|
||||
caches: [],
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App history={history} />
|
||||
|
@ -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.");
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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" && (
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -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);
|
||||
|
@ -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 && (
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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)}>
|
||||
|
@ -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}
|
||||
|
@ -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");
|
||||
|
@ -29,7 +29,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
plugins: [
|
||||
svgrPlugin(),
|
||||
htmlTemplate({
|
||||
htmlTemplate.default({
|
||||
data: {
|
||||
title: env.VITE_PRODUCT_NAME || "Matrix Video Chat",
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user