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",
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@ -10,7 +11,8 @@
|
|||||||
"prettier:format": "prettier -w src",
|
"prettier:format": "prettier -w src",
|
||||||
"lint": "yarn lint:types && yarn lint:js",
|
"lint": "yarn lint:types && yarn lint:js",
|
||||||
"lint:js": "eslint --max-warnings 0 src",
|
"lint:js": "eslint --max-warnings 0 src",
|
||||||
"lint:types": "tsc"
|
"lint:types": "tsc",
|
||||||
|
"i18n": "node_modules/i18next-parser/bin/cli.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@juggle/resize-observer": "^3.3.1",
|
"@juggle/resize-observer": "^3.3.1",
|
||||||
@ -38,6 +40,9 @@
|
|||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"color-hash": "^2.0.1",
|
"color-hash": "^2.0.1",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
|
"i18next": "^21.10.0",
|
||||||
|
"i18next-browser-languagedetector": "^6.1.8",
|
||||||
|
"i18next-http-backend": "^1.4.4",
|
||||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#ce3b72c85031f188a092d1c39806ef7536e65bdd",
|
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#ce3b72c85031f188a092d1c39806ef7536e65bdd",
|
||||||
"matrix-widget-api": "^1.0.0",
|
"matrix-widget-api": "^1.0.0",
|
||||||
"mermaid": "^8.13.8",
|
"mermaid": "^8.13.8",
|
||||||
@ -47,6 +52,7 @@
|
|||||||
"re-resizable": "^6.9.0",
|
"re-resizable": "^6.9.0",
|
||||||
"react": "^17.0.0",
|
"react": "^17.0.0",
|
||||||
"react-dom": "^17.0.0",
|
"react-dom": "^17.0.0",
|
||||||
|
"react-i18next": "^11.18.6",
|
||||||
"react-json-view": "^1.21.3",
|
"react-json-view": "^1.21.3",
|
||||||
"react-router": "6",
|
"react-router": "6",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
@ -71,6 +77,7 @@
|
|||||||
"eslint-plugin-matrix-org": "^0.4.0",
|
"eslint-plugin-matrix-org": "^0.4.0",
|
||||||
"eslint-plugin-react": "^7.29.4",
|
"eslint-plugin-react": "^7.29.4",
|
||||||
"eslint-plugin-react-hooks": "^4.5.0",
|
"eslint-plugin-react-hooks": "^4.5.0",
|
||||||
|
"i18next-parser": "^6.6.0",
|
||||||
"prettier": "^2.6.2",
|
"prettier": "^2.6.2",
|
||||||
"sass": "^1.42.1",
|
"sass": "^1.42.1",
|
||||||
"storybook-builder-vite": "^0.1.12",
|
"storybook-builder-vite": "^0.1.12",
|
||||||
|
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.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React, { Suspense } from "react";
|
||||||
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
|
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
|
||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
import { OverlayProvider } from "@react-aria/overlays";
|
import { OverlayProvider } from "@react-aria/overlays";
|
||||||
@ -43,34 +43,36 @@ export default function App({ history }: AppProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Router history={history}>
|
<Router history={history}>
|
||||||
<ClientProvider>
|
<Suspense fallback={null}>
|
||||||
<InspectorContextProvider>
|
<ClientProvider>
|
||||||
<Sentry.ErrorBoundary fallback={errorPage}>
|
<InspectorContextProvider>
|
||||||
<OverlayProvider>
|
<Sentry.ErrorBoundary fallback={errorPage}>
|
||||||
<Switch>
|
<OverlayProvider>
|
||||||
<SentryRoute exact path="/">
|
<Switch>
|
||||||
<HomePage />
|
<SentryRoute exact path="/">
|
||||||
</SentryRoute>
|
<HomePage />
|
||||||
<SentryRoute exact path="/login">
|
</SentryRoute>
|
||||||
<LoginPage />
|
<SentryRoute exact path="/login">
|
||||||
</SentryRoute>
|
<LoginPage />
|
||||||
<SentryRoute exact path="/register">
|
</SentryRoute>
|
||||||
<RegisterPage />
|
<SentryRoute exact path="/register">
|
||||||
</SentryRoute>
|
<RegisterPage />
|
||||||
<SentryRoute path="/room/:roomId?">
|
</SentryRoute>
|
||||||
<RoomPage />
|
<SentryRoute path="/room/:roomId?">
|
||||||
</SentryRoute>
|
<RoomPage />
|
||||||
<SentryRoute path="/inspector">
|
</SentryRoute>
|
||||||
<SequenceDiagramViewerPage />
|
<SentryRoute path="/inspector">
|
||||||
</SentryRoute>
|
<SequenceDiagramViewerPage />
|
||||||
<SentryRoute path="*">
|
</SentryRoute>
|
||||||
<RoomRedirect />
|
<SentryRoute path="*">
|
||||||
</SentryRoute>
|
<RoomRedirect />
|
||||||
</Switch>
|
</SentryRoute>
|
||||||
</OverlayProvider>
|
</Switch>
|
||||||
</Sentry.ErrorBoundary>
|
</OverlayProvider>
|
||||||
</InspectorContextProvider>
|
</Sentry.ErrorBoundary>
|
||||||
</ClientProvider>
|
</InspectorContextProvider>
|
||||||
|
</ClientProvider>
|
||||||
|
</Suspense>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -27,6 +27,7 @@ import { useHistory } from "react-router-dom";
|
|||||||
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
|
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
|
||||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ErrorView } from "./FullScreenView";
|
import { ErrorView } from "./FullScreenView";
|
||||||
import {
|
import {
|
||||||
@ -35,6 +36,7 @@ import {
|
|||||||
CryptoStoreIntegrityError,
|
CryptoStoreIntegrityError,
|
||||||
} from "./matrix-utils";
|
} from "./matrix-utils";
|
||||||
import { widget } from "./widget";
|
import { widget } from "./widget";
|
||||||
|
import { translatedError } from "./TranslatedError";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -267,6 +269,8 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
history.push("/");
|
history.push("/");
|
||||||
}, [history, client]);
|
}, [history, client]);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// To protect against multiple sessions writing to the same storage
|
// To protect against multiple sessions writing to the same storage
|
||||||
// simultaneously, we send a to-device message that shuts down all other
|
// simultaneously, we send a to-device message that shuts down all other
|
||||||
@ -287,8 +291,9 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
|
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
error: new Error(
|
error: translatedError(
|
||||||
"This application has been opened in another tab."
|
"This application has been opened in another tab.",
|
||||||
|
t
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@ -306,7 +311,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent);
|
client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [client]);
|
}, [client, t]);
|
||||||
|
|
||||||
const context = useMemo<ClientState>(
|
const context = useMemo<ClientState>(
|
||||||
() => ({
|
() => ({
|
||||||
|
@ -14,10 +14,11 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { HTMLAttributes } from "react";
|
import React, { HTMLAttributes, useMemo } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import styles from "./Facepile.module.css";
|
import styles from "./Facepile.module.css";
|
||||||
import { Avatar, Size, sizes } from "./Avatar";
|
import { Avatar, Size, sizes } from "./Avatar";
|
||||||
@ -44,13 +45,25 @@ export function Facepile({
|
|||||||
size = Size.XS,
|
size = Size.XS,
|
||||||
...rest
|
...rest
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const _size = sizes.get(size);
|
const _size = sizes.get(size);
|
||||||
const _overlap = overlapMap[size];
|
const _overlap = overlapMap[size];
|
||||||
|
|
||||||
|
const title = useMemo(() => {
|
||||||
|
return participants.reduce<string | null>(
|
||||||
|
(prev, curr) =>
|
||||||
|
prev === null
|
||||||
|
? curr.name
|
||||||
|
: t("{{names}}, {{name}}", { names: prev, name: curr.name }),
|
||||||
|
null
|
||||||
|
) as string;
|
||||||
|
}, [participants, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(styles.facepile, styles[size], className)}
|
className={classNames(styles.facepile, styles[size], className)}
|
||||||
title={participants.map((member) => member.name).join(", ")}
|
title={title}
|
||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
Math.min(participants.length, max + 1) * (_size - _overlap) +
|
Math.min(participants.length, max + 1) * (_size - _overlap) +
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect } from "react";
|
import React, { ReactNode, useCallback, useEffect } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
|
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
|
||||||
import { LinkButton, Button } from "./button";
|
import { LinkButton, Button } from "./button";
|
||||||
import { useSubmitRageshake } from "./settings/submit-rageshake";
|
import { useSubmitRageshake } from "./settings/submit-rageshake";
|
||||||
import { ErrorMessage } from "./input/Input";
|
import { ErrorMessage } from "./input/Input";
|
||||||
import styles from "./FullScreenView.module.css";
|
import styles from "./FullScreenView.module.css";
|
||||||
|
import { translatedError, TranslatedError } from "./TranslatedError";
|
||||||
|
|
||||||
interface FullScreenViewProps {
|
interface FullScreenViewProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -35,6 +37,7 @@ interface ErrorViewProps {
|
|||||||
|
|
||||||
export function ErrorView({ error }: ErrorViewProps) {
|
export function ErrorView({ error }: ErrorViewProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -47,7 +50,11 @@ export function ErrorView({ error }: ErrorViewProps) {
|
|||||||
return (
|
return (
|
||||||
<FullScreenView>
|
<FullScreenView>
|
||||||
<h1>Error</h1>
|
<h1>Error</h1>
|
||||||
<p>{error.message}</p>
|
<p>
|
||||||
|
{error instanceof TranslatedError
|
||||||
|
? error.translatedMessage
|
||||||
|
: error.message}
|
||||||
|
</p>
|
||||||
{location.pathname === "/" ? (
|
{location.pathname === "/" ? (
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
@ -55,7 +62,7 @@ export function ErrorView({ error }: ErrorViewProps) {
|
|||||||
className={styles.homeLink}
|
className={styles.homeLink}
|
||||||
onPress={onReload}
|
onPress={onReload}
|
||||||
>
|
>
|
||||||
Return to home screen
|
{t("Return to home screen")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<LinkButton
|
<LinkButton
|
||||||
@ -64,7 +71,7 @@ export function ErrorView({ error }: ErrorViewProps) {
|
|||||||
className={styles.homeLink}
|
className={styles.homeLink}
|
||||||
to="/"
|
to="/"
|
||||||
>
|
>
|
||||||
Return to home screen
|
{t("Return to home screen")}
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
)}
|
)}
|
||||||
</FullScreenView>
|
</FullScreenView>
|
||||||
@ -72,6 +79,7 @@ export function ErrorView({ error }: ErrorViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CrashView() {
|
export function CrashView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
|
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
|
||||||
|
|
||||||
const sendDebugLogs = useCallback(() => {
|
const sendDebugLogs = useCallback(() => {
|
||||||
@ -85,11 +93,11 @@ export function CrashView() {
|
|||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
let logsComponent;
|
let logsComponent: JSX.Element | null = null;
|
||||||
if (sent) {
|
if (sent) {
|
||||||
logsComponent = <div>Thanks! We'll get right on it.</div>;
|
logsComponent = <div>{t("Thanks! We'll get right on it.")}</div>;
|
||||||
} else if (sending) {
|
} else if (sending) {
|
||||||
logsComponent = <div>Sending...</div>;
|
logsComponent = <div>{t("Sending…")}</div>;
|
||||||
} else {
|
} else {
|
||||||
logsComponent = (
|
logsComponent = (
|
||||||
<Button
|
<Button
|
||||||
@ -98,33 +106,39 @@ export function CrashView() {
|
|||||||
onPress={sendDebugLogs}
|
onPress={sendDebugLogs}
|
||||||
className={styles.wideButton}
|
className={styles.wideButton}
|
||||||
>
|
>
|
||||||
Send debug logs
|
{t("Send debug logs")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FullScreenView>
|
<FullScreenView>
|
||||||
<h1>Oops, something's gone wrong.</h1>
|
<Trans>
|
||||||
<p>Submitting debug logs will help us track down the problem.</p>
|
<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>
|
<div className={styles.sendLogsSection}>{logsComponent}</div>
|
||||||
{error && <ErrorMessage>Couldn't send debug logs!</ErrorMessage>}
|
{error && (
|
||||||
|
<ErrorMessage error={translatedError("Couldn't send debug logs!", t)} />
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="default"
|
variant="default"
|
||||||
className={styles.wideButton}
|
className={styles.wideButton}
|
||||||
onPress={onReload}
|
onPress={onReload}
|
||||||
>
|
>
|
||||||
Return to home screen
|
{t("Return to home screen")}
|
||||||
</Button>
|
</Button>
|
||||||
</FullScreenView>
|
</FullScreenView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoadingView() {
|
export function LoadingView() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FullScreenView>
|
<FullScreenView>
|
||||||
<h1>Loading...</h1>
|
<h1>{t("Loading…")}</h1>
|
||||||
</FullScreenView>
|
</FullScreenView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
|
|||||||
import { useButton } from "@react-aria/button";
|
import { useButton } from "@react-aria/button";
|
||||||
import { AriaButtonProps } from "@react-types/button";
|
import { AriaButtonProps } from "@react-types/button";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import styles from "./Header.module.css";
|
import styles from "./Header.module.css";
|
||||||
import { useModalTriggerState } from "./Modal";
|
import { useModalTriggerState } from "./Modal";
|
||||||
@ -156,6 +157,7 @@ export function VersionMismatchWarning({
|
|||||||
users,
|
users,
|
||||||
room,
|
room,
|
||||||
}: VersionMismatchWarningProps) {
|
}: VersionMismatchWarningProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { modalState, modalProps } = useModalTriggerState();
|
const { modalState, modalProps } = useModalTriggerState();
|
||||||
|
|
||||||
const onDetailsClick = useCallback(() => {
|
const onDetailsClick = useCallback(() => {
|
||||||
@ -166,9 +168,9 @@ export function VersionMismatchWarning({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={styles.versionMismatchWarning}>
|
<span className={styles.versionMismatchWarning}>
|
||||||
Incomaptible versions!
|
{t("Incompatible versions!")}
|
||||||
<Button variant="link" onClick={onDetailsClick}>
|
<Button variant="link" onClick={onDetailsClick}>
|
||||||
Details
|
{t("Details")}
|
||||||
</Button>
|
</Button>
|
||||||
{modalState.isOpen && (
|
{modalState.isOpen && (
|
||||||
<IncompatibleVersionModal userIds={users} room={room} {...modalProps} />
|
<IncompatibleVersionModal userIds={users} room={room} {...modalProps} />
|
||||||
|
@ -15,7 +15,8 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Modal, ModalContent } from "./Modal";
|
import { Modal, ModalContent } from "./Modal";
|
||||||
import { Body } from "./typography/Typography";
|
import { Body } from "./typography/Typography";
|
||||||
@ -30,17 +31,21 @@ export const IncompatibleVersionModal: React.FC<Props> = ({
|
|||||||
room,
|
room,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const userLis = Array.from(userIds).map((u) => (
|
const { t } = useTranslation();
|
||||||
<li>{room.getMember(u).name}</li>
|
const userLis = useMemo(
|
||||||
));
|
() => [...userIds].map((u) => <li>{room.getMember(u)?.name ?? u}</li>),
|
||||||
|
[userIds, room]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Incompatible Versions" isDismissable {...rest}>
|
<Modal title={t("Incompatible versions")} isDismissable {...rest}>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<Body>
|
<Body>
|
||||||
Other users are trying to join this call from incompatible versions.
|
<Trans>
|
||||||
These users should ensure that they have refreshed their browsers:
|
Other users are trying to join this call from incompatible versions.
|
||||||
<ul>{userLis}</ul>
|
These users should ensure that they have refreshed their browsers:
|
||||||
|
<ul>{userLis}</ul>
|
||||||
|
</Trans>
|
||||||
</Body>
|
</Body>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@ -33,6 +33,7 @@ import { FocusScope } from "@react-aria/focus";
|
|||||||
import { ButtonAria, useButton } from "@react-aria/button";
|
import { ButtonAria, useButton } from "@react-aria/button";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { AriaDialogProps } from "@react-types/dialog";
|
import { AriaDialogProps } from "@react-types/dialog";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ReactComponent as CloseIcon } from "./icons/Close.svg";
|
import { ReactComponent as CloseIcon } from "./icons/Close.svg";
|
||||||
import styles from "./Modal.module.css";
|
import styles from "./Modal.module.css";
|
||||||
@ -53,6 +54,7 @@ export function Modal({
|
|||||||
onClose,
|
onClose,
|
||||||
...rest
|
...rest
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const modalRef = useRef();
|
const modalRef = useRef();
|
||||||
const { overlayProps, underlayProps } = useOverlay(
|
const { overlayProps, underlayProps } = useOverlay(
|
||||||
{ ...rest, onClose },
|
{ ...rest, onClose },
|
||||||
@ -90,6 +92,7 @@ export function Modal({
|
|||||||
{...closeButtonProps}
|
{...closeButtonProps}
|
||||||
ref={closeButtonRef}
|
ref={closeButtonRef}
|
||||||
className={styles.closeButton}
|
className={styles.closeButton}
|
||||||
|
title={t("Close")}
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</button>
|
||||||
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SequenceDiagramViewer,
|
SequenceDiagramViewer,
|
||||||
@ -30,7 +31,8 @@ interface DebugLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SequenceDiagramViewerPage() {
|
export function SequenceDiagramViewerPage() {
|
||||||
usePageTitle("Inspector");
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t("Inspector"));
|
||||||
|
|
||||||
const [debugLog, setDebugLog] = useState<DebugLog>();
|
const [debugLog, setDebugLog] = useState<DebugLog>();
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>();
|
const [selectedUserId, setSelectedUserId] = useState<string>();
|
||||||
@ -49,7 +51,7 @@ export function SequenceDiagramViewerPage() {
|
|||||||
type="file"
|
type="file"
|
||||||
id="debugLog"
|
id="debugLog"
|
||||||
name="debugLog"
|
name="debugLog"
|
||||||
label="Debug Log"
|
label={t("Debug log")}
|
||||||
onChange={onChangeDebugLog}
|
onChange={onChangeDebugLog}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
|
41
src/TranslatedError.ts
Normal file
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 { useMemo } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
export interface RoomParams {
|
export interface UrlParams {
|
||||||
roomAlias: string | null;
|
roomAlias: string | null;
|
||||||
roomId: string | null;
|
roomId: string | null;
|
||||||
viaServers: string[];
|
viaServers: string[];
|
||||||
@ -39,25 +39,27 @@ export interface RoomParams {
|
|||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
// The device's ID (only used in Matroska mode)
|
// The device's ID (only used in Matroska mode)
|
||||||
deviceId: string | null;
|
deviceId: string | null;
|
||||||
|
// The BCP 47 code of the language the app should use
|
||||||
|
lang: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the room parameters for the current URL.
|
* Gets the app parameters for the current URL.
|
||||||
* @param {string} query The URL query string
|
* @param query The URL query string
|
||||||
* @param {string} fragment The URL fragment string
|
* @param fragment The URL fragment string
|
||||||
* @returns {RoomParams} The room parameters encoded in the URL
|
* @returns The app parameters encoded in the URL
|
||||||
*/
|
*/
|
||||||
export const getRoomParams = (
|
export const getUrlParams = (
|
||||||
query: string = window.location.search,
|
query: string = window.location.search,
|
||||||
fragment: string = window.location.hash
|
fragment: string = window.location.hash
|
||||||
): RoomParams => {
|
): UrlParams => {
|
||||||
const fragmentQueryStart = fragment.indexOf("?");
|
const fragmentQueryStart = fragment.indexOf("?");
|
||||||
const fragmentParams = new URLSearchParams(
|
const fragmentParams = new URLSearchParams(
|
||||||
fragmentQueryStart === -1 ? "" : fragment.substring(fragmentQueryStart)
|
fragmentQueryStart === -1 ? "" : fragment.substring(fragmentQueryStart)
|
||||||
);
|
);
|
||||||
const queryParams = new URLSearchParams(query);
|
const queryParams = new URLSearchParams(query);
|
||||||
|
|
||||||
// Normally, room params should be encoded in the fragment so as to avoid
|
// Normally, URL params should be encoded in the fragment so as to avoid
|
||||||
// leaking them to the server. However, we also check the normal query
|
// leaking them to the server. However, we also check the normal query
|
||||||
// string for backwards compatibility with versions that only used that.
|
// string for backwards compatibility with versions that only used that.
|
||||||
const hasParam = (name: string): boolean =>
|
const hasParam = (name: string): boolean =>
|
||||||
@ -87,14 +89,15 @@ export const getRoomParams = (
|
|||||||
userId: getParam("userId"),
|
userId: getParam("userId"),
|
||||||
displayName: getParam("displayName"),
|
displayName: getParam("displayName"),
|
||||||
deviceId: getParam("deviceId"),
|
deviceId: getParam("deviceId"),
|
||||||
|
lang: getParam("lang"),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to simplify use of getRoomParams.
|
* Hook to simplify use of getUrlParams.
|
||||||
* @returns {RoomParams} The room parameters for the current URL
|
* @returns The app parameters for the current URL
|
||||||
*/
|
*/
|
||||||
export const useRoomParams = (): RoomParams => {
|
export const useUrlParams = (): UrlParams => {
|
||||||
const { hash, search } = useLocation();
|
const { hash, search } = useLocation();
|
||||||
return useMemo(() => getRoomParams(search, hash), [search, hash]);
|
return useMemo(() => getUrlParams(search, hash), [search, hash]);
|
||||||
};
|
};
|
@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { Item } from "@react-stately/collections";
|
import { Item } from "@react-stately/collections";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button, LinkButton } from "./button";
|
import { Button, LinkButton } from "./button";
|
||||||
import { PopoverMenuTrigger } from "./popover/PopoverMenu";
|
import { PopoverMenuTrigger } from "./popover/PopoverMenu";
|
||||||
@ -30,6 +31,7 @@ export function UserMenu({
|
|||||||
avatarUrl,
|
avatarUrl,
|
||||||
onAction,
|
onAction,
|
||||||
}: UserMenuProps) {
|
}: UserMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
@ -45,7 +47,7 @@ export function UserMenu({
|
|||||||
if (isPasswordlessUser && !preventNavigation) {
|
if (isPasswordlessUser && !preventNavigation) {
|
||||||
arr.push({
|
arr.push({
|
||||||
key: "login",
|
key: "login",
|
||||||
label: "Sign In",
|
label: t("Sign in"),
|
||||||
icon: LoginIcon,
|
icon: LoginIcon,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -53,14 +55,16 @@ export function UserMenu({
|
|||||||
if (!isPasswordlessUser && !preventNavigation) {
|
if (!isPasswordlessUser && !preventNavigation) {
|
||||||
arr.push({
|
arr.push({
|
||||||
key: "logout",
|
key: "logout",
|
||||||
label: "Sign Out",
|
label: t("Sign out"),
|
||||||
icon: LogoutIcon,
|
icon: LogoutIcon,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return arr;
|
return arr;
|
||||||
}, [isAuthenticated, isPasswordlessUser, displayName, preventNavigation]);
|
}, [isAuthenticated, isPasswordlessUser, displayName, preventNavigation, t]);
|
||||||
|
|
||||||
|
const tooltip = useCallback(() => t("Profile"), [t]);
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
@ -72,7 +76,7 @@ export function UserMenu({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PopoverMenuTrigger placement="bottom right">
|
<PopoverMenuTrigger placement="bottom right">
|
||||||
<TooltipTrigger tooltip={() => "Profile"} placement="bottom left">
|
<TooltipTrigger tooltip={tooltip} placement="bottom left">
|
||||||
<Button variant="icon" className={styles.userButton}>
|
<Button variant="icon" className={styles.userButton}>
|
||||||
{isAuthenticated && (!isPasswordlessUser || avatarUrl) ? (
|
{isAuthenticated && (!isPasswordlessUser || avatarUrl) ? (
|
||||||
<Avatar
|
<Avatar
|
||||||
@ -87,7 +91,7 @@ export function UserMenu({
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{(props) => (
|
{(props) => (
|
||||||
<Menu {...props} label="User menu" onAction={onAction}>
|
<Menu {...props} label={t("User menu")} onAction={onAction}>
|
||||||
{items.map(({ key, icon: Icon, label }) => (
|
{items.map(({ key, icon: Icon, label }) => (
|
||||||
<Item key={key} textValue={label}>
|
<Item key={key} textValue={label}>
|
||||||
<Icon width={24} height={24} className={styles.menuIcon} />
|
<Icon width={24} height={24} className={styles.menuIcon} />
|
||||||
|
@ -23,6 +23,7 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useHistory, useLocation, Link } from "react-router-dom";
|
import { useHistory, useLocation, Link } from "react-router-dom";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
|
import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
|
||||||
import { useClient } from "../ClientContext";
|
import { useClient } from "../ClientContext";
|
||||||
@ -34,7 +35,8 @@ import { useInteractiveLogin } from "./useInteractiveLogin";
|
|||||||
import { usePageTitle } from "../usePageTitle";
|
import { usePageTitle } from "../usePageTitle";
|
||||||
|
|
||||||
export const LoginPage: FC = () => {
|
export const LoginPage: FC = () => {
|
||||||
usePageTitle("Login");
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t("Login"));
|
||||||
|
|
||||||
const { setClient } = useClient();
|
const { setClient } = useClient();
|
||||||
const login = useInteractiveLogin();
|
const login = useInteractiveLogin();
|
||||||
@ -93,8 +95,8 @@ export const LoginPage: FC = () => {
|
|||||||
<InputField
|
<InputField
|
||||||
type="text"
|
type="text"
|
||||||
ref={usernameRef}
|
ref={usernameRef}
|
||||||
placeholder="Username"
|
placeholder={t("Username")}
|
||||||
label="Username"
|
label={t("Username")}
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
prefix="@"
|
prefix="@"
|
||||||
@ -105,18 +107,18 @@ export const LoginPage: FC = () => {
|
|||||||
<InputField
|
<InputField
|
||||||
type="password"
|
type="password"
|
||||||
ref={passwordRef}
|
ref={passwordRef}
|
||||||
placeholder="Password"
|
placeholder={t("Password")}
|
||||||
label="Password"
|
label={t("Password")}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
{error && (
|
{error && (
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<ErrorMessage>{error.message}</ErrorMessage>
|
<ErrorMessage error={error} />
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
{loading ? "Logging in..." : "Login"}
|
{loading ? t("Logging in…") : t("Login")}
|
||||||
</Button>
|
</Button>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
</form>
|
</form>
|
||||||
@ -124,9 +126,11 @@ export const LoginPage: FC = () => {
|
|||||||
<div className={styles.authLinks}>
|
<div className={styles.authLinks}>
|
||||||
<p>Not registered yet?</p>
|
<p>Not registered yet?</p>
|
||||||
<p>
|
<p>
|
||||||
<Link to="/register">Create an account</Link>
|
<Trans>
|
||||||
{" Or "}
|
<Link to="/register">Create an account</Link>
|
||||||
<Link to="/">Access as a guest</Link>
|
{" Or "}
|
||||||
|
<Link to="/">Access as a guest</Link>
|
||||||
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -26,6 +26,7 @@ import React, {
|
|||||||
import { useHistory, useLocation } from "react-router-dom";
|
import { useHistory, useLocation } from "react-router-dom";
|
||||||
import { captureException } from "@sentry/react";
|
import { captureException } from "@sentry/react";
|
||||||
import { sleep } from "matrix-js-sdk/src/utils";
|
import { sleep } from "matrix-js-sdk/src/utils";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
@ -40,7 +41,8 @@ import { Caption, Link } from "../typography/Typography";
|
|||||||
import { usePageTitle } from "../usePageTitle";
|
import { usePageTitle } from "../usePageTitle";
|
||||||
|
|
||||||
export const RegisterPage: FC = () => {
|
export const RegisterPage: FC = () => {
|
||||||
usePageTitle("Register");
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t("Register"));
|
||||||
|
|
||||||
const { loading, isAuthenticated, isPasswordlessUser, client, setClient } =
|
const { loading, isAuthenticated, isPasswordlessUser, client, setClient } =
|
||||||
useClient();
|
useClient();
|
||||||
@ -126,11 +128,11 @@ export const RegisterPage: FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (password && passwordConfirmation && password !== passwordConfirmation) {
|
if (password && passwordConfirmation && password !== passwordConfirmation) {
|
||||||
confirmPasswordRef.current?.setCustomValidity("Passwords must match");
|
confirmPasswordRef.current?.setCustomValidity(t("Passwords must match"));
|
||||||
} else {
|
} else {
|
||||||
confirmPasswordRef.current?.setCustomValidity("");
|
confirmPasswordRef.current?.setCustomValidity("");
|
||||||
}
|
}
|
||||||
}, [password, passwordConfirmation]);
|
}, [password, passwordConfirmation, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && isAuthenticated && !isPasswordlessUser && !registering) {
|
if (!loading && isAuthenticated && !isPasswordlessUser && !registering) {
|
||||||
@ -154,8 +156,8 @@ export const RegisterPage: FC = () => {
|
|||||||
<InputField
|
<InputField
|
||||||
type="text"
|
type="text"
|
||||||
name="userName"
|
name="userName"
|
||||||
placeholder="Username"
|
placeholder={t("Username")}
|
||||||
label="Username"
|
label={t("Username")}
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
prefix="@"
|
prefix="@"
|
||||||
@ -171,8 +173,8 @@ export const RegisterPage: FC = () => {
|
|||||||
setPassword(e.target.value)
|
setPassword(e.target.value)
|
||||||
}
|
}
|
||||||
value={password}
|
value={password}
|
||||||
placeholder="Password"
|
placeholder={t("Password")}
|
||||||
label="Password"
|
label={t("Password")}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
@ -184,45 +186,49 @@ export const RegisterPage: FC = () => {
|
|||||||
setPasswordConfirmation(e.target.value)
|
setPasswordConfirmation(e.target.value)
|
||||||
}
|
}
|
||||||
value={passwordConfirmation}
|
value={passwordConfirmation}
|
||||||
placeholder="Confirm Password"
|
placeholder={t("Confirm password")}
|
||||||
label="Confirm Password"
|
label={t("Confirm password")}
|
||||||
ref={confirmPasswordRef}
|
ref={confirmPasswordRef}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<Caption>
|
<Caption>
|
||||||
This site is protected by ReCAPTCHA and the Google{" "}
|
<Trans>
|
||||||
<Link href="https://www.google.com/policies/privacy/">
|
This site is protected by ReCAPTCHA and the Google{" "}
|
||||||
Privacy Policy
|
<Link href="https://www.google.com/policies/privacy/">
|
||||||
</Link>{" "}
|
Privacy Policy
|
||||||
and{" "}
|
</Link>{" "}
|
||||||
<Link href="https://policies.google.com/terms">
|
and{" "}
|
||||||
Terms of Service
|
<Link href="https://policies.google.com/terms">
|
||||||
</Link>{" "}
|
Terms of Service
|
||||||
apply.
|
</Link>{" "}
|
||||||
<br />
|
apply.
|
||||||
By clicking "Register", you agree to our{" "}
|
<br />
|
||||||
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
By clicking "Register", you agree to our{" "}
|
||||||
|
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
||||||
|
</Trans>
|
||||||
</Caption>
|
</Caption>
|
||||||
{error && (
|
{error && (
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<ErrorMessage>{error.message}</ErrorMessage>
|
<ErrorMessage error={error} />
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Button type="submit" disabled={registering}>
|
<Button type="submit" disabled={registering}>
|
||||||
{registering ? "Registering..." : "Register"}
|
{registering ? t("Registering…") : t("Register")}
|
||||||
</Button>
|
</Button>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<div id={recaptchaId} />
|
<div id={recaptchaId} />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.authLinks}>
|
<div className={styles.authLinks}>
|
||||||
<p>Already have an account?</p>
|
<Trans>
|
||||||
<p>
|
<p>Already have an account?</p>
|
||||||
<Link to="/login">Log in</Link>
|
<p>
|
||||||
{" Or "}
|
<Link to="/login">Log in</Link>
|
||||||
<Link to="/">Access as a guest</Link>
|
{" Or "}
|
||||||
</p>
|
<Link to="/">Access as a guest</Link>
|
||||||
|
</p>
|
||||||
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -16,6 +16,9 @@ limitations under the License.
|
|||||||
|
|
||||||
import { useEffect, useCallback, useRef, useState } from "react";
|
import { useEffect, useCallback, useRef, useState } from "react";
|
||||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { translatedError } from "../TranslatedError";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@ -32,6 +35,7 @@ interface RecaptchaPromiseRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useRecaptcha = (sitekey: string) => {
|
export const useRecaptcha = (sitekey: string) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [recaptchaId] = useState(() => randomString(16));
|
const [recaptchaId] = useState(() => randomString(16));
|
||||||
const promiseRef = useRef<RecaptchaPromiseRef>();
|
const promiseRef = useRef<RecaptchaPromiseRef>();
|
||||||
|
|
||||||
@ -71,14 +75,14 @@ export const useRecaptcha = (sitekey: string) => {
|
|||||||
|
|
||||||
if (!window.grecaptcha) {
|
if (!window.grecaptcha) {
|
||||||
console.log("Recaptcha not loaded");
|
console.log("Recaptcha not loaded");
|
||||||
return Promise.reject(new Error("Recaptcha not loaded"));
|
return Promise.reject(translatedError("Recaptcha not loaded", t));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const observer = new MutationObserver((mutationsList) => {
|
const observer = new MutationObserver((mutationsList) => {
|
||||||
for (const item of mutationsList) {
|
for (const item of mutationsList) {
|
||||||
if ((item.target as HTMLElement)?.style?.visibility !== "visible") {
|
if ((item.target as HTMLElement)?.style?.visibility !== "visible") {
|
||||||
reject(new Error("Recaptcha dismissed"));
|
reject(translatedError("Recaptcha dismissed", t));
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -108,7 +112,7 @@ export const useRecaptcha = (sitekey: string) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [sitekey]);
|
}, [sitekey, t]);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
window.grecaptcha?.reset();
|
window.grecaptcha?.reset();
|
||||||
|
@ -18,6 +18,7 @@ import { PressEvent } from "@react-types/shared";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useButton } from "@react-aria/button";
|
import { useButton } from "@react-aria/button";
|
||||||
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import styles from "./Button.module.css";
|
import styles from "./Button.module.css";
|
||||||
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
||||||
@ -142,9 +143,11 @@ export function MicButton({
|
|||||||
// TODO: add all props for <Button>
|
// TODO: add all props for <Button>
|
||||||
[index: string]: unknown;
|
[index: string]: unknown;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
tooltip={() => (muted ? "Unmute microphone" : "Mute microphone")}
|
tooltip={() => (muted ? t("Unmute microphone") : t("Mute microphone"))}
|
||||||
>
|
>
|
||||||
<Button variant="toolbar" {...rest} off={muted}>
|
<Button variant="toolbar" {...rest} off={muted}>
|
||||||
{muted ? <MuteMicIcon /> : <MicIcon />}
|
{muted ? <MuteMicIcon /> : <MicIcon />}
|
||||||
@ -161,9 +164,11 @@ export function VideoButton({
|
|||||||
// TODO: add all props for <Button>
|
// TODO: add all props for <Button>
|
||||||
[index: string]: unknown;
|
[index: string]: unknown;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
tooltip={() => (muted ? "Turn on camera" : "Turn off camera")}
|
tooltip={() => (muted ? t("Turn on camera") : t("Turn off camera"))}
|
||||||
>
|
>
|
||||||
<Button variant="toolbar" {...rest} off={muted}>
|
<Button variant="toolbar" {...rest} off={muted}>
|
||||||
{muted ? <DisableVideoIcon /> : <VideoIcon />}
|
{muted ? <DisableVideoIcon /> : <VideoIcon />}
|
||||||
@ -182,9 +187,11 @@ export function ScreenshareButton({
|
|||||||
// TODO: add all props for <Button>
|
// TODO: add all props for <Button>
|
||||||
[index: string]: unknown;
|
[index: string]: unknown;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
tooltip={() => (enabled ? "Stop sharing screen" : "Share screen")}
|
tooltip={() => (enabled ? t("Stop sharing screen") : t("Share screen"))}
|
||||||
>
|
>
|
||||||
<Button variant="toolbarSecondary" {...rest} on={enabled}>
|
<Button variant="toolbarSecondary" {...rest} on={enabled}>
|
||||||
<ScreenshareIcon />
|
<ScreenshareIcon />
|
||||||
@ -201,8 +208,11 @@ export function HangupButton({
|
|||||||
// TODO: add all props for <Button>
|
// TODO: add all props for <Button>
|
||||||
[index: string]: unknown;
|
[index: string]: unknown;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tooltip = useCallback(() => t("Leave"), [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger tooltip={() => "Leave"}>
|
<TooltipTrigger tooltip={tooltip}>
|
||||||
<Button
|
<Button
|
||||||
variant="toolbar"
|
variant="toolbar"
|
||||||
className={classNames(styles.hangupButton, className)}
|
className={classNames(styles.hangupButton, className)}
|
||||||
@ -222,8 +232,11 @@ export function SettingsButton({
|
|||||||
// TODO: add all props for <Button>
|
// TODO: add all props for <Button>
|
||||||
[index: string]: unknown;
|
[index: string]: unknown;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tooltip = useCallback(() => t("Settings"), [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger tooltip={() => "Settings"}>
|
<TooltipTrigger tooltip={tooltip}>
|
||||||
<Button variant="toolbar" {...rest}>
|
<Button variant="toolbar" {...rest}>
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
</Button>
|
</Button>
|
||||||
@ -239,8 +252,11 @@ export function InviteButton({
|
|||||||
// TODO: add all props for <Button>
|
// TODO: add all props for <Button>
|
||||||
[index: string]: unknown;
|
[index: string]: unknown;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tooltip = useCallback(() => t("Invite"), [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger tooltip={() => "Invite"}>
|
<TooltipTrigger tooltip={tooltip}>
|
||||||
<Button variant="toolbar" {...rest}>
|
<Button variant="toolbar" {...rest}>
|
||||||
<AddUserIcon />
|
<AddUserIcon />
|
||||||
</Button>
|
</Button>
|
||||||
@ -256,8 +272,11 @@ interface AudioButtonProps extends Omit<Props, "variant"> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AudioButton({ volume, ...rest }: AudioButtonProps) {
|
export function AudioButton({ volume, ...rest }: AudioButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tooltip = useCallback(() => t("Local volume"), [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger tooltip={() => "Local volume"}>
|
<TooltipTrigger tooltip={tooltip}>
|
||||||
<Button variant="icon" {...rest}>
|
<Button variant="icon" {...rest}>
|
||||||
<VolumeIcon volume={volume} />
|
<VolumeIcon volume={volume} />
|
||||||
</Button>
|
</Button>
|
||||||
@ -273,12 +292,13 @@ export function FullscreenButton({
|
|||||||
fullscreen,
|
fullscreen,
|
||||||
...rest
|
...rest
|
||||||
}: FullscreenButtonProps) {
|
}: FullscreenButtonProps) {
|
||||||
const getTooltip = useCallback(() => {
|
const { t } = useTranslation();
|
||||||
return fullscreen ? "Exit full screen" : "Full screen";
|
const tooltip = useCallback(() => {
|
||||||
}, [fullscreen]);
|
return fullscreen ? t("Exit full screen") : t("Full screen");
|
||||||
|
}, [fullscreen, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger tooltip={getTooltip}>
|
<TooltipTrigger tooltip={tooltip}>
|
||||||
<Button variant="icon" {...rest}>
|
<Button variant="icon" {...rest}>
|
||||||
{fullscreen ? <FullscreenExit /> : <Fullscreen />}
|
{fullscreen ? <FullscreenExit /> : <Fullscreen />}
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import useClipboard from "react-use-clipboard";
|
import useClipboard from "react-use-clipboard";
|
||||||
|
|
||||||
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
|
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
|
||||||
@ -36,6 +37,7 @@ export function CopyButton({
|
|||||||
copiedMessage,
|
copiedMessage,
|
||||||
...rest
|
...rest
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
|
const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -49,7 +51,7 @@ export function CopyButton({
|
|||||||
>
|
>
|
||||||
{isCopied ? (
|
{isCopied ? (
|
||||||
<>
|
<>
|
||||||
{variant !== "icon" && <span>{copiedMessage || "Copied!"}</span>}
|
{variant !== "icon" && <span>{copiedMessage || t("Copied!")}</span>}
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import React, { FC } from "react";
|
import React, { FC } from "react";
|
||||||
import { Item } from "@react-stately/collections";
|
import { Item } from "@react-stately/collections";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Headline } from "../typography/Typography";
|
import { Headline } from "../typography/Typography";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
@ -39,25 +40,29 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CallTypeDropdown: FC<Props> = ({ callType, setCallType }) => {
|
export const CallTypeDropdown: FC<Props> = ({ callType, setCallType }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopoverMenuTrigger placement="bottom">
|
<PopoverMenuTrigger placement="bottom">
|
||||||
<Button variant="dropdown" className={commonStyles.headline}>
|
<Button variant="dropdown" className={commonStyles.headline}>
|
||||||
<Headline className={styles.label}>
|
<Headline className={styles.label}>
|
||||||
{callType === CallType.Video ? "Video call" : "Walkie-talkie call"}
|
{callType === CallType.Video
|
||||||
|
? t("Video call")
|
||||||
|
: t("Walkie-talkie call")}
|
||||||
</Headline>
|
</Headline>
|
||||||
</Button>
|
</Button>
|
||||||
{(props: JSX.IntrinsicAttributes) => (
|
{(props: JSX.IntrinsicAttributes) => (
|
||||||
<Menu {...props} label="Call type menu" onAction={setCallType}>
|
<Menu {...props} label={t("Call type menu")} onAction={setCallType}>
|
||||||
<Item key={CallType.Video} textValue="Video call">
|
<Item key={CallType.Video} textValue={t("Video call")}>
|
||||||
<VideoIcon />
|
<VideoIcon />
|
||||||
<span>Video call</span>
|
<span>{t("Video call")}</span>
|
||||||
{callType === CallType.Video && (
|
{callType === CallType.Video && (
|
||||||
<CheckIcon className={menuStyles.checkIcon} />
|
<CheckIcon className={menuStyles.checkIcon} />
|
||||||
)}
|
)}
|
||||||
</Item>
|
</Item>
|
||||||
<Item key={CallType.Radio} textValue="Walkie-talkie call">
|
<Item key={CallType.Radio} textValue={t("Walkie-talkie call")}>
|
||||||
<MicIcon />
|
<MicIcon />
|
||||||
<span>Walkie-talkie call</span>
|
<span>{t("Walkie-talkie call")}</span>
|
||||||
{callType === CallType.Radio && (
|
{callType === CallType.Radio && (
|
||||||
<CheckIcon className={menuStyles.checkIcon} />
|
<CheckIcon className={menuStyles.checkIcon} />
|
||||||
)}
|
)}
|
||||||
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useClient } from "../ClientContext";
|
import { useClient } from "../ClientContext";
|
||||||
import { ErrorView, LoadingView } from "../FullScreenView";
|
import { ErrorView, LoadingView } from "../FullScreenView";
|
||||||
@ -23,7 +24,8 @@ import { RegisteredView } from "./RegisteredView";
|
|||||||
import { usePageTitle } from "../usePageTitle";
|
import { usePageTitle } from "../usePageTitle";
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
usePageTitle("Home");
|
const { t } = useTranslation();
|
||||||
|
usePageTitle(t("Home"));
|
||||||
|
|
||||||
const { isAuthenticated, isPasswordlessUser, loading, error, client } =
|
const { isAuthenticated, isPasswordlessUser, loading, error, client } =
|
||||||
useClient();
|
useClient();
|
||||||
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { PressEvent } from "@react-types/shared";
|
import { PressEvent } from "@react-types/shared";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Modal, ModalContent } from "../Modal";
|
import { Modal, ModalContent } from "../Modal";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
@ -29,13 +30,15 @@ interface Props {
|
|||||||
[index: string]: unknown;
|
[index: string]: unknown;
|
||||||
}
|
}
|
||||||
export function JoinExistingCallModal({ onJoin, onClose, ...rest }: Props) {
|
export function JoinExistingCallModal({ onJoin, onClose, ...rest }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Join existing call?" isDismissable {...rest}>
|
<Modal title={t("Join existing call?")} isDismissable {...rest}>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<p>This call already exists, would you like to join?</p>
|
<p>{t("This call already exists, would you like to join?")}</p>
|
||||||
<FieldRow rightAlign className={styles.buttons}>
|
<FieldRow rightAlign className={styles.buttons}>
|
||||||
<Button onPress={onClose}>No</Button>
|
<Button onPress={onClose}>{t("No")}</Button>
|
||||||
<Button onPress={onJoin}>Yes, join call</Button>
|
<Button onPress={onJoin}>{t("Yes, join call")}</Button>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@ -22,6 +22,7 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
|
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
|
||||||
import { useGroupCallRooms } from "./useGroupCallRooms";
|
import { useGroupCallRooms } from "./useGroupCallRooms";
|
||||||
@ -48,6 +49,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<Error>();
|
const [error, setError] = useState<Error>();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
const { t } = useTranslation();
|
||||||
const { modalState, modalProps } = useModalTriggerState();
|
const { modalState, modalProps } = useModalTriggerState();
|
||||||
|
|
||||||
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||||
@ -93,7 +95,9 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
|||||||
}, [history, existingRoomId]);
|
}, [history, existingRoomId]);
|
||||||
|
|
||||||
const callNameLabel =
|
const callNameLabel =
|
||||||
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
|
callType === CallType.Video
|
||||||
|
? t("Video call name")
|
||||||
|
: t("Walkie-talkie call name");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -127,19 +131,19 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
|||||||
className={styles.button}
|
className={styles.button}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? "Loading..." : "Go"}
|
{loading ? t("Loading…") : t("Go")}
|
||||||
</Button>
|
</Button>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
{error && (
|
{error && (
|
||||||
<FieldRow className={styles.fieldRow}>
|
<FieldRow className={styles.fieldRow}>
|
||||||
<ErrorMessage>{error.message}</ErrorMessage>
|
<ErrorMessage error={error} />
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
{recentRooms.length > 0 && (
|
{recentRooms.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Title className={styles.recentCallsTitle}>
|
<Title className={styles.recentCallsTitle}>
|
||||||
Your recent Calls
|
{t("Your recent calls")}
|
||||||
</Title>
|
</Title>
|
||||||
<CallList rooms={recentRooms} client={client} disableFacepile />
|
<CallList rooms={recentRooms} client={client} disableFacepile />
|
||||||
</>
|
</>
|
||||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||||||
import React, { FC, useCallback, useState, FormEventHandler } from "react";
|
import React, { FC, useCallback, useState, FormEventHandler } from "react";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useClient } from "../ClientContext";
|
import { useClient } from "../ClientContext";
|
||||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||||
@ -47,6 +48,7 @@ export const UnauthenticatedView: FC = () => {
|
|||||||
const { modalState, modalProps } = useModalTriggerState();
|
const { modalState, modalProps } = useModalTriggerState();
|
||||||
const [onFinished, setOnFinished] = useState<() => void>();
|
const [onFinished, setOnFinished] = useState<() => void>();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
@ -105,7 +107,9 @@ export const UnauthenticatedView: FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const callNameLabel =
|
const callNameLabel =
|
||||||
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
|
callType === CallType.Video
|
||||||
|
? t("Video call name")
|
||||||
|
: t("Walkie-talkie call name");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -137,24 +141,26 @@ export const UnauthenticatedView: FC = () => {
|
|||||||
<InputField
|
<InputField
|
||||||
id="displayName"
|
id="displayName"
|
||||||
name="displayName"
|
name="displayName"
|
||||||
label="Display Name"
|
label={t("Display name")}
|
||||||
placeholder="Display Name"
|
placeholder={t("Display name")}
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<Caption>
|
<Caption>
|
||||||
By clicking "Go", you agree to our{" "}
|
<Trans>
|
||||||
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
By clicking "Go", you agree to our{" "}
|
||||||
|
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
||||||
|
</Trans>
|
||||||
</Caption>
|
</Caption>
|
||||||
{error && (
|
{error && (
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<ErrorMessage>{error.message}</ErrorMessage>
|
<ErrorMessage error={error} />
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" size="lg" disabled={loading}>
|
<Button type="submit" size="lg" disabled={loading}>
|
||||||
{loading ? "Loading..." : "Go"}
|
{loading ? t("Loading…") : t("Go")}
|
||||||
</Button>
|
</Button>
|
||||||
<div id={recaptchaId} />
|
<div id={recaptchaId} />
|
||||||
</Form>
|
</Form>
|
||||||
@ -162,14 +168,16 @@ export const UnauthenticatedView: FC = () => {
|
|||||||
<footer className={styles.footer}>
|
<footer className={styles.footer}>
|
||||||
<Body className={styles.mobileLoginLink}>
|
<Body className={styles.mobileLoginLink}>
|
||||||
<Link color="primary" to="/login">
|
<Link color="primary" to="/login">
|
||||||
Login to your account
|
{t("Login to your account")}
|
||||||
</Link>
|
</Link>
|
||||||
</Body>
|
</Body>
|
||||||
<Body>
|
<Body>
|
||||||
Not registered yet?{" "}
|
<Trans>
|
||||||
<Link color="primary" to="/register">
|
Not registered yet?{" "}
|
||||||
Create an account
|
<Link color="primary" to="/register">
|
||||||
</Link>
|
Create an account
|
||||||
|
</Link>
|
||||||
|
</Trans>
|
||||||
</Body>
|
</Body>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
@ -20,6 +20,7 @@ import { useCallback } from "react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Avatar, Size } from "../Avatar";
|
import { Avatar, Size } from "../Avatar";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
@ -39,6 +40,8 @@ export const AvatarInputField = forwardRef<HTMLInputElement, Props>(
|
|||||||
{ id, label, className, avatarUrl, displayName, onRemoveAvatar, ...rest },
|
{ id, label, className, avatarUrl, displayName, onRemoveAvatar, ...rest },
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [removed, setRemoved] = useState(false);
|
const [removed, setRemoved] = useState(false);
|
||||||
const [objUrl, setObjUrl] = useState<string>(null);
|
const [objUrl, setObjUrl] = useState<string>(null);
|
||||||
|
|
||||||
@ -97,7 +100,7 @@ export const AvatarInputField = forwardRef<HTMLInputElement, Props>(
|
|||||||
variant="icon"
|
variant="icon"
|
||||||
onPress={onPressRemoveAvatar}
|
onPress={onPressRemoveAvatar}
|
||||||
>
|
>
|
||||||
Remove
|
{t("Remove")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { ChangeEvent, forwardRef, ReactNode } from "react";
|
import React, { ChangeEvent, FC, forwardRef, ReactNode } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import styles from "./Input.module.css";
|
import styles from "./Input.module.css";
|
||||||
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
|
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
|
||||||
|
import { TranslatedError } from "../TranslatedError";
|
||||||
|
|
||||||
interface FieldRowProps {
|
interface FieldRowProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@ -140,10 +141,12 @@ export const InputField = forwardRef<
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export function ErrorMessage({
|
interface ErrorMessageProps {
|
||||||
children,
|
error: Error;
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}): JSX.Element {
|
|
||||||
return <p className={styles.errorMessage}>{children}</p>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ErrorMessage: FC<ErrorMessageProps> = ({ error }) => (
|
||||||
|
<p className={styles.errorMessage}>
|
||||||
|
{error instanceof TranslatedError ? error.translatedMessage : error.message}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
@ -19,6 +19,7 @@ import { AriaSelectOptions, HiddenSelect, useSelect } from "@react-aria/select";
|
|||||||
import { useButton } from "@react-aria/button";
|
import { useButton } from "@react-aria/button";
|
||||||
import { useSelectState } from "@react-stately/select";
|
import { useSelectState } from "@react-stately/select";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Popover } from "../popover/Popover";
|
import { Popover } from "../popover/Popover";
|
||||||
import { ListBox } from "../ListBox";
|
import { ListBox } from "../ListBox";
|
||||||
@ -30,6 +31,7 @@ interface Props extends AriaSelectOptions<object> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SelectInput(props: Props): JSX.Element {
|
export function SelectInput(props: Props): JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
const state = useSelectState(props);
|
const state = useSelectState(props);
|
||||||
|
|
||||||
const ref = useRef();
|
const ref = useRef();
|
||||||
@ -56,7 +58,7 @@ export function SelectInput(props: Props): JSX.Element {
|
|||||||
<span {...valueProps} className={styles.selectedItem}>
|
<span {...valueProps} className={styles.selectedItem}>
|
||||||
{state.selectedItem
|
{state.selectedItem
|
||||||
? state.selectedItem.rendered
|
? state.selectedItem.rendered
|
||||||
: "Select an option"}
|
: t("Select an option")}
|
||||||
</span>
|
</span>
|
||||||
<ArrowDownIcon />
|
<ArrowDownIcon />
|
||||||
</button>
|
</button>
|
||||||
|
34
src/main.tsx
34
src/main.tsx
@ -25,10 +25,15 @@ import ReactDOM from "react-dom";
|
|||||||
import { createBrowserHistory } from "history";
|
import { createBrowserHistory } from "history";
|
||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
import { Integrations } from "@sentry/tracing";
|
import { Integrations } from "@sentry/tracing";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import Backend from "i18next-http-backend";
|
||||||
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { init as initRageshake } from "./settings/rageshake";
|
import { init as initRageshake } from "./settings/rageshake";
|
||||||
|
import { getUrlParams } from "./UrlParams";
|
||||||
|
|
||||||
initRageshake();
|
initRageshake();
|
||||||
|
|
||||||
@ -104,6 +109,35 @@ Sentry.init({
|
|||||||
tracesSampleRate: 1.0,
|
tracesSampleRate: 1.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const languageDetector = new LanguageDetector();
|
||||||
|
languageDetector.addDetector({
|
||||||
|
name: "urlFragment",
|
||||||
|
// Look for a language code in the URL's fragment
|
||||||
|
lookup: () => getUrlParams().lang ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(Backend)
|
||||||
|
.use(languageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
fallbackLng: "en-GB",
|
||||||
|
defaultNS: "app",
|
||||||
|
keySeparator: false,
|
||||||
|
nsSeparator: false,
|
||||||
|
pluralSeparator: "|",
|
||||||
|
contextSeparator: "|",
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false, // React has built-in XSS protections
|
||||||
|
},
|
||||||
|
detection: {
|
||||||
|
// No localStorage detectors or caching here, since we don't have any way
|
||||||
|
// of letting the user manually select a language
|
||||||
|
order: ["urlFragment", "navigator"],
|
||||||
|
caches: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App history={history} />
|
<App history={history} />
|
||||||
|
@ -19,7 +19,7 @@ import {
|
|||||||
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import IndexedDBWorker from "./IndexedDBWorker?worker";
|
import IndexedDBWorker from "./IndexedDBWorker?worker";
|
||||||
import { getRoomParams } from "./room/useRoomParams";
|
import { getUrlParams } from "./UrlParams";
|
||||||
|
|
||||||
export const defaultHomeserver =
|
export const defaultHomeserver =
|
||||||
(import.meta.env.VITE_DEFAULT_HOMESERVER as string) ??
|
(import.meta.env.VITE_DEFAULT_HOMESERVER as string) ??
|
||||||
@ -134,12 +134,12 @@ export async function initClient(
|
|||||||
storeOpts.cryptoStore = new MemoryCryptoStore();
|
storeOpts.cryptoStore = new MemoryCryptoStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX: we read from the room params in RoomPage too:
|
// XXX: we read from the URL params in RoomPage too:
|
||||||
// it would be much better to read them in one place and pass
|
// it would be much better to read them in one place and pass
|
||||||
// the values around, but we initialise the matrix client in
|
// the values around, but we initialise the matrix client in
|
||||||
// many different places so we'd have to pass it into all of
|
// many different places so we'd have to pass it into all of
|
||||||
// them.
|
// them.
|
||||||
const { e2eEnabled } = getRoomParams();
|
const { e2eEnabled } = getUrlParams();
|
||||||
if (!e2eEnabled) {
|
if (!e2eEnabled) {
|
||||||
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
|
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import React, { ChangeEvent, useCallback, useEffect, useState } from "react";
|
import React, { ChangeEvent, useCallback, useEffect, useState } from "react";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import { useProfile } from "./useProfile";
|
import { useProfile } from "./useProfile";
|
||||||
@ -31,6 +32,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export function ProfileModal({ client, ...rest }: Props) {
|
export function ProfileModal({ client, ...rest }: Props) {
|
||||||
const { onClose } = rest;
|
const { onClose } = rest;
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
success,
|
success,
|
||||||
error,
|
error,
|
||||||
@ -83,14 +85,14 @@ export function ProfileModal({ client, ...rest }: Props) {
|
|||||||
}, [success, onClose]);
|
}, [success, onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Profile" isDismissable {...rest}>
|
<Modal title={t("Profile")} isDismissable {...rest}>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<FieldRow className={styles.avatarFieldRow}>
|
<FieldRow className={styles.avatarFieldRow}>
|
||||||
<AvatarInputField
|
<AvatarInputField
|
||||||
id="avatar"
|
id="avatar"
|
||||||
name="avatar"
|
name="avatar"
|
||||||
label="Avatar"
|
label={t("Avatar")}
|
||||||
avatarUrl={avatarUrl}
|
avatarUrl={avatarUrl}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
onRemoveAvatar={onRemoveAvatar}
|
onRemoveAvatar={onRemoveAvatar}
|
||||||
@ -100,7 +102,7 @@ export function ProfileModal({ client, ...rest }: Props) {
|
|||||||
<InputField
|
<InputField
|
||||||
id="userId"
|
id="userId"
|
||||||
name="userId"
|
name="userId"
|
||||||
label="User Id"
|
label={t("User ID")}
|
||||||
type="text"
|
type="text"
|
||||||
disabled
|
disabled
|
||||||
value={client.getUserId()}
|
value={client.getUserId()}
|
||||||
@ -110,18 +112,18 @@ export function ProfileModal({ client, ...rest }: Props) {
|
|||||||
<InputField
|
<InputField
|
||||||
id="displayName"
|
id="displayName"
|
||||||
name="displayName"
|
name="displayName"
|
||||||
label="Display Name"
|
label={t("Display name")}
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder="Display Name"
|
placeholder={t("Display name")}
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={onChangeDisplayName}
|
onChange={onChangeDisplayName}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
{error && (
|
{error && (
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<ErrorMessage>{error.message}</ErrorMessage>
|
<ErrorMessage error={error} />
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
<FieldRow rightAlign>
|
<FieldRow rightAlign>
|
||||||
@ -129,7 +131,7 @@ export function ProfileModal({ client, ...rest }: Props) {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
{loading ? "Saving..." : "Save"}
|
{loading ? t("Saving…") : t("Save")}
|
||||||
</Button>
|
</Button>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
</form>
|
</form>
|
||||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
import { Item } from "@react-stately/collections";
|
import { Item } from "@react-stately/collections";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import styles from "./AudioPreview.module.css";
|
import styles from "./AudioPreview.module.css";
|
||||||
import { SelectInput } from "../input/SelectInput";
|
import { SelectInput } from "../input/SelectInput";
|
||||||
@ -43,24 +44,26 @@ export function AudioPreview({
|
|||||||
audioOutputs,
|
audioOutputs,
|
||||||
setAudioOutput,
|
setAudioOutput,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1>{`${roomName} - Walkie-talkie call`}</h1>
|
<h1>{t("{{roomName}} - Walkie-talkie call", { roomName })}</h1>
|
||||||
<div className={styles.preview}>
|
<div className={styles.preview}>
|
||||||
{state === GroupCallState.LocalCallFeedUninitialized && (
|
{state === GroupCallState.LocalCallFeedUninitialized && (
|
||||||
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
|
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
|
||||||
Microphone permissions needed to join the call.
|
{t("Microphone permissions needed to join the call.")}
|
||||||
</Body>
|
</Body>
|
||||||
)}
|
)}
|
||||||
{state === GroupCallState.InitializingLocalCallFeed && (
|
{state === GroupCallState.InitializingLocalCallFeed && (
|
||||||
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
|
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
|
||||||
Accept microphone permissions to join the call.
|
{t("Accept microphone permissions to join the call.")}
|
||||||
</Body>
|
</Body>
|
||||||
)}
|
)}
|
||||||
{state === GroupCallState.LocalCallFeedInitialized && (
|
{state === GroupCallState.LocalCallFeedInitialized && (
|
||||||
<>
|
<>
|
||||||
<SelectInput
|
<SelectInput
|
||||||
label="Microphone"
|
label={t("Microphone")}
|
||||||
selectedKey={audioInput}
|
selectedKey={audioInput}
|
||||||
onSelectionChange={setAudioInput}
|
onSelectionChange={setAudioInput}
|
||||||
className={styles.inputField}
|
className={styles.inputField}
|
||||||
@ -69,13 +72,13 @@ export function AudioPreview({
|
|||||||
<Item key={deviceId}>
|
<Item key={deviceId}>
|
||||||
{!!label && label.trim().length > 0
|
{!!label && label.trim().length > 0
|
||||||
? label
|
? label
|
||||||
: `Microphone ${index + 1}`}
|
: t("Microphone {{n}}", { n: index + 1 })}
|
||||||
</Item>
|
</Item>
|
||||||
))}
|
))}
|
||||||
</SelectInput>
|
</SelectInput>
|
||||||
{audioOutputs.length > 0 && (
|
{audioOutputs.length > 0 && (
|
||||||
<SelectInput
|
<SelectInput
|
||||||
label="Speaker"
|
label={t("Speaker")}
|
||||||
selectedKey={audioOutput}
|
selectedKey={audioOutput}
|
||||||
onSelectionChange={setAudioOutput}
|
onSelectionChange={setAudioOutput}
|
||||||
className={styles.inputField}
|
className={styles.inputField}
|
||||||
@ -84,7 +87,7 @@ export function AudioPreview({
|
|||||||
<Item key={deviceId}>
|
<Item key={deviceId}>
|
||||||
{!!label && label.trim().length > 0
|
{!!label && label.trim().length > 0
|
||||||
? label
|
? label
|
||||||
: `Speaker ${index + 1}`}
|
: t("Speaker {{n}}", { n: index + 1 })}
|
||||||
</Item>
|
</Item>
|
||||||
))}
|
))}
|
||||||
</SelectInput>
|
</SelectInput>
|
||||||
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import styles from "./CallEndedView.module.css";
|
import styles from "./CallEndedView.module.css";
|
||||||
import { LinkButton } from "../button";
|
import { LinkButton } from "../button";
|
||||||
@ -24,6 +25,7 @@ import { Subtitle, Body, Link, Headline } from "../typography/Typography";
|
|||||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||||
|
|
||||||
export function CallEndedView({ client }: { client: MatrixClient }) {
|
export function CallEndedView({ client }: { client: MatrixClient }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { displayName } = useProfile(client);
|
const { displayName } = useProfile(client);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -37,29 +39,31 @@ export function CallEndedView({ client }: { client: MatrixClient }) {
|
|||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<Headline className={styles.headline}>
|
<Headline className={styles.headline}>
|
||||||
{displayName}, your call is now ended
|
{t("{{displayName}}, your call is now ended", { displayName })}
|
||||||
</Headline>
|
</Headline>
|
||||||
<div className={styles.callEndedContent}>
|
<div className={styles.callEndedContent}>
|
||||||
<Subtitle>
|
<Trans>
|
||||||
Why not finish by setting up a password to keep your account?
|
<Subtitle>
|
||||||
</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
|
<Subtitle>
|
||||||
future calls
|
You'll be able to keep your name and set an avatar for use on
|
||||||
</Subtitle>
|
future calls
|
||||||
|
</Subtitle>
|
||||||
|
</Trans>
|
||||||
<LinkButton
|
<LinkButton
|
||||||
className={styles.callEndedButton}
|
className={styles.callEndedButton}
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="default"
|
variant="default"
|
||||||
to="/register"
|
to="/register"
|
||||||
>
|
>
|
||||||
Create account
|
{t("Create account")}
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Body className={styles.footer}>
|
<Body className={styles.footer}>
|
||||||
<Link color="primary" to="/">
|
<Link color="primary" to="/">
|
||||||
Not now, return to home screen
|
{t("Not now, return to home screen")}
|
||||||
</Link>
|
</Link>
|
||||||
</Body>
|
</Body>
|
||||||
</div>
|
</div>
|
||||||
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import React, { useCallback, useEffect } from "react";
|
import React, { useCallback, useEffect } from "react";
|
||||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Modal, ModalContent } from "../Modal";
|
import { Modal, ModalContent } from "../Modal";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
@ -25,6 +26,7 @@ import {
|
|||||||
useRageshakeRequest,
|
useRageshakeRequest,
|
||||||
} from "../settings/submit-rageshake";
|
} from "../settings/submit-rageshake";
|
||||||
import { Body } from "../typography/Typography";
|
import { Body } from "../typography/Typography";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
inCall: boolean;
|
inCall: boolean;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@ -32,7 +34,9 @@ interface Props {
|
|||||||
// TODO: add all props for for <Modal>
|
// TODO: add all props for for <Modal>
|
||||||
[index: string]: unknown;
|
[index: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
|
export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
|
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
|
||||||
const sendRageshakeRequest = useRageshakeRequest();
|
const sendRageshakeRequest = useRageshakeRequest();
|
||||||
|
|
||||||
@ -67,15 +71,20 @@ export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
|
|||||||
}, [sent, onClose]);
|
}, [sent, onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Submit Feedback" isDismissable onClose={onClose} {...rest}>
|
<Modal
|
||||||
|
title={t("Submit feedback")}
|
||||||
|
isDismissable
|
||||||
|
onClose={onClose}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<Body>Having trouble? Help us fix it.</Body>
|
<Body>{t("Having trouble? Help us fix it.")}</Body>
|
||||||
<form onSubmit={onSubmitFeedback}>
|
<form onSubmit={onSubmitFeedback}>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<InputField
|
<InputField
|
||||||
id="description"
|
id="description"
|
||||||
name="description"
|
name="description"
|
||||||
label="Description (optional)"
|
label={t("Description (optional)")}
|
||||||
type="textarea"
|
type="textarea"
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
@ -83,19 +92,19 @@ export function FeedbackModal({ inCall, roomId, onClose, ...rest }: Props) {
|
|||||||
<InputField
|
<InputField
|
||||||
id="sendLogs"
|
id="sendLogs"
|
||||||
name="sendLogs"
|
name="sendLogs"
|
||||||
label="Include Debug Logs"
|
label={t("Include debug logs")}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
defaultChecked
|
defaultChecked
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
{error && (
|
{error && (
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<ErrorMessage>{error.message}</ErrorMessage>
|
<ErrorMessage error={error} />
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Button type="submit" disabled={sending}>
|
<Button type="submit" disabled={sending}>
|
||||||
{sending ? "Submitting feedback..." : "Submit Feedback"}
|
{sending ? t("Submitting feedback…") : t("Submit feedback")}
|
||||||
</Button>
|
</Button>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
</form>
|
</form>
|
||||||
|
@ -14,8 +14,9 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React, { useCallback } from "react";
|
||||||
import { Item } from "@react-stately/collections";
|
import { Item } from "@react-stately/collections";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
|
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
|
||||||
@ -27,28 +28,33 @@ import { Menu } from "../Menu";
|
|||||||
import { TooltipTrigger } from "../Tooltip";
|
import { TooltipTrigger } from "../Tooltip";
|
||||||
|
|
||||||
export type Layout = "freedom" | "spotlight";
|
export type Layout = "freedom" | "spotlight";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
layout: Layout;
|
layout: Layout;
|
||||||
setLayout: (layout: Layout) => void;
|
setLayout: (layout: Layout) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GridLayoutMenu({ layout, setLayout }: Props) {
|
export function GridLayoutMenu({ layout, setLayout }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tooltip = useCallback(() => t("Change layout"), [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopoverMenuTrigger placement="bottom right">
|
<PopoverMenuTrigger placement="bottom right">
|
||||||
<TooltipTrigger tooltip={() => "Layout Type"}>
|
<TooltipTrigger tooltip={tooltip}>
|
||||||
<Button variant="icon">
|
<Button variant="icon">
|
||||||
{layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />}
|
{layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{(props: JSX.IntrinsicAttributes) => (
|
{(props: JSX.IntrinsicAttributes) => (
|
||||||
<Menu {...props} label="Grid layout menu" onAction={setLayout}>
|
<Menu {...props} label={t("Grid layout menu")} onAction={setLayout}>
|
||||||
<Item key="freedom" textValue="Freedom">
|
<Item key="freedom" textValue={t("Freedom")}>
|
||||||
<FreedomIcon />
|
<FreedomIcon />
|
||||||
<span>Freedom</span>
|
<span>Freedom</span>
|
||||||
{layout === "freedom" && (
|
{layout === "freedom" && (
|
||||||
<CheckIcon className={menuStyles.checkIcon} />
|
<CheckIcon className={menuStyles.checkIcon} />
|
||||||
)}
|
)}
|
||||||
</Item>
|
</Item>
|
||||||
<Item key="spotlight" textValue="Spotlight">
|
<Item key="spotlight" textValue={t("Spotlight")}>
|
||||||
<SpotlightIcon />
|
<SpotlightIcon />
|
||||||
<span>Spotlight</span>
|
<span>Spotlight</span>
|
||||||
{layout === "spotlight" && (
|
{layout === "spotlight" && (
|
||||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useLoadGroupCall } from "./useLoadGroupCall";
|
import { useLoadGroupCall } from "./useLoadGroupCall";
|
||||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||||
@ -37,6 +38,7 @@ export function GroupCallLoader({
|
|||||||
children,
|
children,
|
||||||
createPtt,
|
createPtt,
|
||||||
}: Props): JSX.Element {
|
}: Props): JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { loading, error, groupCall } = useLoadGroupCall(
|
const { loading, error, groupCall } = useLoadGroupCall(
|
||||||
client,
|
client,
|
||||||
roomIdOrAlias,
|
roomIdOrAlias,
|
||||||
@ -44,12 +46,12 @@ export function GroupCallLoader({
|
|||||||
createPtt
|
createPtt
|
||||||
);
|
);
|
||||||
|
|
||||||
usePageTitle(groupCall ? groupCall.room.name : "Loading...");
|
usePageTitle(groupCall ? groupCall.room.name : t("Loading…"));
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<FullScreenView>
|
<FullScreenView>
|
||||||
<h1>Loading room...</h1>
|
<h1>{t("Loading room…")}</h1>
|
||||||
</FullScreenView>
|
</FullScreenView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ import { useHistory } from "react-router-dom";
|
|||||||
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||||
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
|
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
|
||||||
@ -81,8 +82,8 @@ export function GroupCallView({
|
|||||||
unencryptedEventsFromUsers,
|
unencryptedEventsFromUsers,
|
||||||
} = useGroupCall(groupCall);
|
} = useGroupCall(groupCall);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
const { setAudioInput, setVideoInput } = useMediaHandler();
|
const { setAudioInput, setVideoInput } = useMediaHandler();
|
||||||
|
|
||||||
const avatarUrl = useRoomAvatar(groupCall.room);
|
const avatarUrl = useRoomAvatar(groupCall.room);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -240,7 +241,7 @@ export function GroupCallView({
|
|||||||
} else if (state === GroupCallState.Entering) {
|
} else if (state === GroupCallState.Entering) {
|
||||||
return (
|
return (
|
||||||
<FullScreenView>
|
<FullScreenView>
|
||||||
<h1>Entering room...</h1>
|
<h1>{t("Entering room…")}</h1>
|
||||||
</FullScreenView>
|
</FullScreenView>
|
||||||
);
|
);
|
||||||
} else if (left) {
|
} else if (left) {
|
||||||
@ -257,7 +258,7 @@ export function GroupCallView({
|
|||||||
} else if (isEmbedded) {
|
} else if (isEmbedded) {
|
||||||
return (
|
return (
|
||||||
<FullScreenView>
|
<FullScreenView>
|
||||||
<h1>Loading room...</h1>
|
<h1>{t("Loading room…")}</h1>
|
||||||
</FullScreenView>
|
</FullScreenView>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
@ -23,6 +23,7 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
|||||||
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||||
import styles from "./InCallView.module.css";
|
import styles from "./InCallView.module.css";
|
||||||
@ -112,6 +113,7 @@ export function InCallView({
|
|||||||
unencryptedEventsFromUsers,
|
unencryptedEventsFromUsers,
|
||||||
hideHeader,
|
hideHeader,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
usePreventScroll();
|
usePreventScroll();
|
||||||
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
||||||
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
|
const [containerRef2, bounds] = useMeasure({ polyfill: ResizeObserver });
|
||||||
@ -247,7 +249,7 @@ export function InCallView({
|
|||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.centerMessage}>
|
<div className={styles.centerMessage}>
|
||||||
<p>Waiting for other participants...</p>
|
<p>{t("Waiting for other participants…")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { FC } from "react";
|
import React, { FC } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Modal, ModalContent, ModalProps } from "../Modal";
|
import { Modal, ModalContent, ModalProps } from "../Modal";
|
||||||
import { CopyButton } from "../button";
|
import { CopyButton } from "../button";
|
||||||
@ -25,19 +26,23 @@ interface Props extends Omit<ModalProps, "title" | "children"> {
|
|||||||
roomIdOrAlias: string;
|
roomIdOrAlias: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => (
|
export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => {
|
||||||
<Modal
|
const { t } = useTranslation();
|
||||||
title="Invite People"
|
|
||||||
isDismissable
|
return (
|
||||||
className={styles.inviteModal}
|
<Modal
|
||||||
{...rest}
|
title={t("Invite people")}
|
||||||
>
|
isDismissable
|
||||||
<ModalContent>
|
className={styles.inviteModal}
|
||||||
<p>Copy and share this meeting link</p>
|
{...rest}
|
||||||
<CopyButton
|
>
|
||||||
className={styles.copyButton}
|
<ModalContent>
|
||||||
value={getRoomUrl(roomIdOrAlias)}
|
<p>{t("Copy and share this call link")}</p>
|
||||||
/>
|
<CopyButton
|
||||||
</ModalContent>
|
className={styles.copyButton}
|
||||||
</Modal>
|
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 { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { PressEvent } from "@react-types/shared";
|
import { PressEvent } from "@react-types/shared";
|
||||||
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import styles from "./LobbyView.module.css";
|
import styles from "./LobbyView.module.css";
|
||||||
import { Button, CopyButton } from "../button";
|
import { Button, CopyButton } from "../button";
|
||||||
@ -66,6 +67,7 @@ export function LobbyView({
|
|||||||
isEmbedded,
|
isEmbedded,
|
||||||
hideHeader,
|
hideHeader,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { stream } = useCallFeed(localCallFeed);
|
const { stream } = useCallFeed(localCallFeed);
|
||||||
const {
|
const {
|
||||||
audioInput,
|
audioInput,
|
||||||
@ -142,15 +144,15 @@ export function LobbyView({
|
|||||||
variant="secondaryCopy"
|
variant="secondaryCopy"
|
||||||
value={getRoomUrl(roomIdOrAlias)}
|
value={getRoomUrl(roomIdOrAlias)}
|
||||||
className={styles.copyButton}
|
className={styles.copyButton}
|
||||||
copiedMessage="Call link copied"
|
copiedMessage={t("Call link copied")}
|
||||||
>
|
>
|
||||||
Copy call link and join later
|
{t("Copy call link and join later")}
|
||||||
</CopyButton>
|
</CopyButton>
|
||||||
</div>
|
</div>
|
||||||
{!isEmbedded && (
|
{!isEmbedded && (
|
||||||
<Body className={styles.joinRoomFooter}>
|
<Body className={styles.joinRoomFooter}>
|
||||||
<Link color="primary" to="/">
|
<Link color="primary" to="/">
|
||||||
Take me Home
|
{t("Take me Home")}
|
||||||
</Link>
|
</Link>
|
||||||
</Body>
|
</Body>
|
||||||
)}
|
)}
|
||||||
|
@ -18,6 +18,7 @@ import React, { useCallback } from "react";
|
|||||||
import { Item } from "@react-stately/collections";
|
import { Item } from "@react-stately/collections";
|
||||||
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
import { OverlayTriggerState } from "@react-stately/overlays";
|
import { OverlayTriggerState } from "@react-stately/overlays";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import { Menu } from "../Menu";
|
import { Menu } from "../Menu";
|
||||||
@ -31,6 +32,7 @@ import { SettingsModal } from "../settings/SettingsModal";
|
|||||||
import { InviteModal } from "./InviteModal";
|
import { InviteModal } from "./InviteModal";
|
||||||
import { TooltipTrigger } from "../Tooltip";
|
import { TooltipTrigger } from "../Tooltip";
|
||||||
import { FeedbackModal } from "./FeedbackModal";
|
import { FeedbackModal } from "./FeedbackModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
roomIdOrAlias: string;
|
roomIdOrAlias: string;
|
||||||
inCall: boolean;
|
inCall: boolean;
|
||||||
@ -42,6 +44,7 @@ interface Props {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OverflowMenu({
|
export function OverflowMenu({
|
||||||
roomIdOrAlias,
|
roomIdOrAlias,
|
||||||
inCall,
|
inCall,
|
||||||
@ -50,6 +53,8 @@ export function OverflowMenu({
|
|||||||
feedbackModalState,
|
feedbackModalState,
|
||||||
feedbackModalProps,
|
feedbackModalProps,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
modalState: inviteModalState,
|
modalState: inviteModalState,
|
||||||
modalProps: inviteModalProps,
|
modalProps: inviteModalProps,
|
||||||
@ -90,29 +95,31 @@ export function OverflowMenu({
|
|||||||
[feedbackModalState, inviteModalState, settingsModalState]
|
[feedbackModalState, inviteModalState, settingsModalState]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tooltip = useCallback(() => t("More"), [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PopoverMenuTrigger disableOnState>
|
<PopoverMenuTrigger disableOnState>
|
||||||
<TooltipTrigger tooltip={() => "More"} placement="top">
|
<TooltipTrigger tooltip={tooltip} placement="top">
|
||||||
<Button variant="toolbar">
|
<Button variant="toolbar">
|
||||||
<OverflowIcon />
|
<OverflowIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{(props: JSX.IntrinsicAttributes) => (
|
{(props: JSX.IntrinsicAttributes) => (
|
||||||
<Menu {...props} label="more menu" onAction={onAction}>
|
<Menu {...props} label={t("More menu")} onAction={onAction}>
|
||||||
{showInvite && (
|
{showInvite && (
|
||||||
<Item key="invite" textValue="Invite people">
|
<Item key="invite" textValue={t("Invite people")}>
|
||||||
<AddUserIcon />
|
<AddUserIcon />
|
||||||
<span>Invite people</span>
|
<span>{t("Invite people")}</span>
|
||||||
</Item>
|
</Item>
|
||||||
)}
|
)}
|
||||||
<Item key="settings" textValue="Settings">
|
<Item key="settings" textValue={t("Settings")}>
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
<span>Settings</span>
|
<span>{t("Settings")}</span>
|
||||||
</Item>
|
</Item>
|
||||||
<Item key="feedback" textValue="Submit Feedback">
|
<Item key="feedback" textValue={t("Submit feedback")}>
|
||||||
<FeedbackIcon />
|
<FeedbackIcon />
|
||||||
<span>Submit Feedback</span>
|
<span>{t("Submit feedback")}</span>
|
||||||
</Item>
|
</Item>
|
||||||
</Menu>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
|
@ -17,10 +17,12 @@ limitations under the License.
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { ResizeObserver } from "@juggle/resize-observer";
|
import { ResizeObserver } from "@juggle/resize-observer";
|
||||||
|
import i18n from "i18next";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||||
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useDelayedState } from "../useDelayedState";
|
import { useDelayedState } from "../useDelayedState";
|
||||||
import { useModalTriggerState } from "../Modal";
|
import { useModalTriggerState } from "../Modal";
|
||||||
@ -50,40 +52,45 @@ function getPromptText(
|
|||||||
talkOverEnabled: boolean,
|
talkOverEnabled: boolean,
|
||||||
activeSpeakerUserId: string,
|
activeSpeakerUserId: string,
|
||||||
activeSpeakerDisplayName: string,
|
activeSpeakerDisplayName: string,
|
||||||
connected: boolean
|
connected: boolean,
|
||||||
|
t: typeof i18n.t
|
||||||
): string {
|
): string {
|
||||||
if (!connected) return "Connection lost";
|
if (!connected) return t("Connection lost");
|
||||||
|
|
||||||
const isTouchScreen = Boolean(window.ontouchstart !== undefined);
|
const isTouchScreen = Boolean(window.ontouchstart !== undefined);
|
||||||
|
|
||||||
if (networkWaiting) {
|
if (networkWaiting) {
|
||||||
return "Waiting for network";
|
return t("Waiting for network");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTalkOverError) {
|
if (showTalkOverError) {
|
||||||
return "You can't talk at the same time";
|
return t("You can't talk at the same time");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pttButtonHeld && activeSpeakerIsLocalUser) {
|
if (pttButtonHeld && activeSpeakerIsLocalUser) {
|
||||||
if (isTouchScreen) {
|
if (isTouchScreen) {
|
||||||
return "Release to stop";
|
return t("Release to stop");
|
||||||
} else {
|
} else {
|
||||||
return "Release spacebar key to stop";
|
return t("Release spacebar key to stop");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (talkOverEnabled && activeSpeakerUserId && !activeSpeakerIsLocalUser) {
|
if (talkOverEnabled && activeSpeakerUserId && !activeSpeakerIsLocalUser) {
|
||||||
if (isTouchScreen) {
|
if (isTouchScreen) {
|
||||||
return `Press and hold to talk over ${activeSpeakerDisplayName}`;
|
return t("Press and hold to talk over {{name}}", {
|
||||||
|
name: activeSpeakerDisplayName,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
return `Press and hold spacebar to talk over ${activeSpeakerDisplayName}`;
|
return t("Press and hold spacebar to talk over {{name}}", {
|
||||||
|
name: activeSpeakerDisplayName,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTouchScreen) {
|
if (isTouchScreen) {
|
||||||
return "Press and hold to talk";
|
return t("Press and hold to talk");
|
||||||
} else {
|
} else {
|
||||||
return "Press and hold spacebar to talk";
|
return t("Press and hold spacebar to talk");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,6 +119,7 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
isEmbedded,
|
isEmbedded,
|
||||||
hideHeader,
|
hideHeader,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { modalState: inviteModalState, modalProps: inviteModalProps } =
|
const { modalState: inviteModalState, modalProps: inviteModalProps } =
|
||||||
useModalTriggerState();
|
useModalTriggerState();
|
||||||
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
|
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
|
||||||
@ -195,9 +203,11 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
{showControls && (
|
{showControls && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.participants}>
|
<div className={styles.participants}>
|
||||||
<p>{`${participants.length} ${
|
<p>
|
||||||
participants.length > 1 ? "people" : "person"
|
{t("{{count}} people connected", {
|
||||||
} connected`}</p>
|
count: participants.length,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
<Facepile
|
<Facepile
|
||||||
size={facepileSize}
|
size={facepileSize}
|
||||||
max={8}
|
max={8}
|
||||||
@ -230,8 +240,10 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
<AudioIcon className={styles.speakerIcon} />
|
<AudioIcon className={styles.speakerIcon} />
|
||||||
)}
|
)}
|
||||||
{activeSpeakerIsLocalUser
|
{activeSpeakerIsLocalUser
|
||||||
? "Talking..."
|
? t("Talking…")
|
||||||
: `${activeSpeakerDisplayName} is talking...`}
|
: t("{{name}} is talking…", {
|
||||||
|
name: activeSpeakerDisplayName,
|
||||||
|
})}
|
||||||
</h2>
|
</h2>
|
||||||
<Timer value={activeSpeakerUserId} />
|
<Timer value={activeSpeakerUserId} />
|
||||||
</div>
|
</div>
|
||||||
@ -263,7 +275,8 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
talkOverEnabled,
|
talkOverEnabled,
|
||||||
activeSpeakerUserId,
|
activeSpeakerUserId,
|
||||||
activeSpeakerDisplayName,
|
activeSpeakerDisplayName,
|
||||||
connected
|
connected,
|
||||||
|
t
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -278,7 +291,7 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
<Toggle
|
<Toggle
|
||||||
isSelected={talkOverEnabled}
|
isSelected={talkOverEnabled}
|
||||||
onChange={setTalkOverEnabled}
|
onChange={setTalkOverEnabled}
|
||||||
label="Talk over speaker"
|
label={t("Talk over speaker")}
|
||||||
id="talkOverEnabled"
|
id="talkOverEnabled"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { FC, useEffect } from "react";
|
import React, { FC, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Modal, ModalContent, ModalProps } from "../Modal";
|
import { Modal, ModalContent, ModalProps } from "../Modal";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
@ -33,6 +34,7 @@ export const RageshakeRequestModal: FC<Props> = ({
|
|||||||
roomIdOrAlias,
|
roomIdOrAlias,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
|
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -42,11 +44,12 @@ export const RageshakeRequestModal: FC<Props> = ({
|
|||||||
}, [sent, rest]);
|
}, [sent, rest]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Debug Log Request" isDismissable {...rest}>
|
<Modal title={t("Debug log request")} isDismissable {...rest}>
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<Body>
|
<Body>
|
||||||
Another user on this call is having an issue. In order to better
|
{t(
|
||||||
diagnose these issues we'd like to collect a debug log.
|
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log."
|
||||||
|
)}
|
||||||
</Body>
|
</Body>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Button
|
<Button
|
||||||
@ -59,12 +62,12 @@ export const RageshakeRequestModal: FC<Props> = ({
|
|||||||
}
|
}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
>
|
>
|
||||||
{sending ? "Sending debug log..." : "Send debug log"}
|
{sending ? t("Sending debug log…") : t("Send debug log")}
|
||||||
</Button>
|
</Button>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
{error && (
|
{error && (
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<ErrorMessage>{error.message}</ErrorMessage>
|
<ErrorMessage error={error} />
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import styles from "./RoomAuthView.module.css";
|
import styles from "./RoomAuthView.module.css";
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
@ -50,6 +51,7 @@ export function RoomAuthView() {
|
|||||||
[registerPasswordlessUser]
|
[registerPasswordlessUser]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -64,42 +66,46 @@ export function RoomAuthView() {
|
|||||||
</Header>
|
</Header>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<main className={styles.main}>
|
<main className={styles.main}>
|
||||||
<Headline className={styles.headline}>Join Call</Headline>
|
<Headline className={styles.headline}>{t("Join call")}</Headline>
|
||||||
<Form className={styles.form} onSubmit={onSubmit}>
|
<Form className={styles.form} onSubmit={onSubmit}>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<InputField
|
<InputField
|
||||||
id="displayName"
|
id="displayName"
|
||||||
name="displayName"
|
name="displayName"
|
||||||
label="Display Name"
|
label={t("Display name")}
|
||||||
placeholder="Display Name"
|
placeholder={t("Display name")}
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<Caption>
|
<Caption>
|
||||||
By clicking "Join call now", you agree to our{" "}
|
<Trans>
|
||||||
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
By clicking "Join call now", you agree to our{" "}
|
||||||
|
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
|
||||||
|
</Trans>
|
||||||
</Caption>
|
</Caption>
|
||||||
{error && (
|
{error && (
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<ErrorMessage>{error.message}</ErrorMessage>
|
<ErrorMessage error={error} />
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" size="lg" disabled={loading}>
|
<Button type="submit" size="lg" disabled={loading}>
|
||||||
{loading ? "Loading..." : "Join call now"}
|
{loading ? t("Loading…") : t("Join call now")}
|
||||||
</Button>
|
</Button>
|
||||||
<div id={recaptchaId} />
|
<div id={recaptchaId} />
|
||||||
</Form>
|
</Form>
|
||||||
</main>
|
</main>
|
||||||
<Body className={styles.footer}>
|
<Body className={styles.footer}>
|
||||||
{"Not registered yet? "}
|
<Trans>
|
||||||
<Link
|
{"Not registered yet? "}
|
||||||
color="primary"
|
<Link
|
||||||
to={{ pathname: "/login", state: { from: location } }}
|
color="primary"
|
||||||
>
|
to={{ pathname: "/login", state: { from: location } }}
|
||||||
Create an account
|
>
|
||||||
</Link>
|
Create an account
|
||||||
|
</Link>
|
||||||
|
</Trans>
|
||||||
</Body>
|
</Body>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -15,6 +15,7 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { FC, useEffect, useState, useCallback } from "react";
|
import React, { FC, useEffect, useState, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
import { useClient } from "../ClientContext";
|
import { useClient } from "../ClientContext";
|
||||||
@ -22,11 +23,13 @@ import { ErrorView, LoadingView } from "../FullScreenView";
|
|||||||
import { RoomAuthView } from "./RoomAuthView";
|
import { RoomAuthView } from "./RoomAuthView";
|
||||||
import { GroupCallLoader } from "./GroupCallLoader";
|
import { GroupCallLoader } from "./GroupCallLoader";
|
||||||
import { GroupCallView } from "./GroupCallView";
|
import { GroupCallView } from "./GroupCallView";
|
||||||
import { useRoomParams } from "./useRoomParams";
|
import { useUrlParams } from "../UrlParams";
|
||||||
import { MediaHandlerProvider } from "../settings/useMediaHandler";
|
import { MediaHandlerProvider } from "../settings/useMediaHandler";
|
||||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||||
|
import { translatedError } from "../TranslatedError";
|
||||||
|
|
||||||
export const RoomPage: FC = () => {
|
export const RoomPage: FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { loading, isAuthenticated, error, client, isPasswordlessUser } =
|
const { loading, isAuthenticated, error, client, isPasswordlessUser } =
|
||||||
useClient();
|
useClient();
|
||||||
|
|
||||||
@ -39,9 +42,9 @@ export const RoomPage: FC = () => {
|
|||||||
hideHeader,
|
hideHeader,
|
||||||
isPtt,
|
isPtt,
|
||||||
displayName,
|
displayName,
|
||||||
} = useRoomParams();
|
} = useUrlParams();
|
||||||
const roomIdOrAlias = roomId ?? roomAlias;
|
const roomIdOrAlias = roomId ?? roomAlias;
|
||||||
if (!roomIdOrAlias) throw new Error("No room specified");
|
if (!roomIdOrAlias) throw translatedError("No room specified", t);
|
||||||
|
|
||||||
const { registerPasswordlessUser } = useRegisterPasswordlessUser();
|
const { registerPasswordlessUser } = useRegisterPasswordlessUser();
|
||||||
const [isRegistering, setIsRegistering] = useState(false);
|
const [isRegistering, setIsRegistering] = useState(false);
|
||||||
|
@ -19,6 +19,7 @@ import useMeasure from "react-use-measure";
|
|||||||
import { ResizeObserver } from "@juggle/resize-observer";
|
import { ResizeObserver } from "@juggle/resize-observer";
|
||||||
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { MicButton, VideoButton } from "../button";
|
import { MicButton, VideoButton } from "../button";
|
||||||
import { useMediaStream } from "../video-grid/useMediaStream";
|
import { useMediaStream } from "../video-grid/useMediaStream";
|
||||||
@ -40,6 +41,7 @@ interface Props {
|
|||||||
audioOutput: string;
|
audioOutput: string;
|
||||||
stream: MediaStream;
|
stream: MediaStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoPreview({
|
export function VideoPreview({
|
||||||
client,
|
client,
|
||||||
state,
|
state,
|
||||||
@ -51,6 +53,7 @@ export function VideoPreview({
|
|||||||
audioOutput,
|
audioOutput,
|
||||||
stream,
|
stream,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const videoRef = useMediaStream(stream, audioOutput, true);
|
const videoRef = useMediaStream(stream, audioOutput, true);
|
||||||
const { displayName, avatarUrl } = useProfile(client);
|
const { displayName, avatarUrl } = useProfile(client);
|
||||||
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
|
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
|
||||||
@ -64,12 +67,12 @@ export function VideoPreview({
|
|||||||
<video ref={videoRef} muted playsInline disablePictureInPicture />
|
<video ref={videoRef} muted playsInline disablePictureInPicture />
|
||||||
{state === GroupCallState.LocalCallFeedUninitialized && (
|
{state === GroupCallState.LocalCallFeedUninitialized && (
|
||||||
<Body fontWeight="semiBold" className={styles.cameraPermissions}>
|
<Body fontWeight="semiBold" className={styles.cameraPermissions}>
|
||||||
Camera/microphone permissions needed to join the call.
|
{t("Camera/microphone permissions needed to join the call.")}
|
||||||
</Body>
|
</Body>
|
||||||
)}
|
)}
|
||||||
{state === GroupCallState.InitializingLocalCallFeed && (
|
{state === GroupCallState.InitializingLocalCallFeed && (
|
||||||
<Body fontWeight="semiBold" className={styles.cameraPermissions}>
|
<Body fontWeight="semiBold" className={styles.cameraPermissions}>
|
||||||
Accept camera/microphone permissions to join the call.
|
{t("Accept camera/microphone permissions to join the call.")}
|
||||||
</Body>
|
</Body>
|
||||||
)}
|
)}
|
||||||
{state === GroupCallState.LocalCallFeedInitialized && (
|
{state === GroupCallState.LocalCallFeedInitialized && (
|
||||||
|
@ -26,8 +26,10 @@ import {
|
|||||||
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||||
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
||||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { usePageUnload } from "./usePageUnload";
|
import { usePageUnload } from "./usePageUnload";
|
||||||
|
import { TranslatedError, translatedError } from "../TranslatedError";
|
||||||
|
|
||||||
export interface UseGroupCallReturnType {
|
export interface UseGroupCallReturnType {
|
||||||
state: GroupCallState;
|
state: GroupCallState;
|
||||||
@ -37,7 +39,7 @@ export interface UseGroupCallReturnType {
|
|||||||
userMediaFeeds: CallFeed[];
|
userMediaFeeds: CallFeed[];
|
||||||
microphoneMuted: boolean;
|
microphoneMuted: boolean;
|
||||||
localVideoMuted: boolean;
|
localVideoMuted: boolean;
|
||||||
error: Error;
|
error: TranslatedError | null;
|
||||||
initLocalCallFeed: () => void;
|
initLocalCallFeed: () => void;
|
||||||
enter: () => void;
|
enter: () => void;
|
||||||
leave: () => void;
|
leave: () => void;
|
||||||
@ -60,7 +62,7 @@ interface State {
|
|||||||
localCallFeed: CallFeed;
|
localCallFeed: CallFeed;
|
||||||
activeSpeaker: string;
|
activeSpeaker: string;
|
||||||
userMediaFeeds: CallFeed[];
|
userMediaFeeds: CallFeed[];
|
||||||
error: Error;
|
error: TranslatedError | null;
|
||||||
microphoneMuted: boolean;
|
microphoneMuted: boolean;
|
||||||
localVideoMuted: boolean;
|
localVideoMuted: boolean;
|
||||||
screenshareFeeds: CallFeed[];
|
screenshareFeeds: CallFeed[];
|
||||||
@ -309,15 +311,18 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
|
|||||||
});
|
});
|
||||||
}, [groupCall]);
|
}, [groupCall]);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.RTCPeerConnection === undefined) {
|
if (window.RTCPeerConnection === undefined) {
|
||||||
const error = new Error(
|
const error = translatedError(
|
||||||
"WebRTC is not supported or is being blocked in this browser."
|
"WebRTC is not supported or is being blocked in this browser.",
|
||||||
|
t
|
||||||
);
|
);
|
||||||
console.error(error);
|
console.error(error);
|
||||||
updateState({ error });
|
updateState({ error });
|
||||||
}
|
}
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
|
@ -24,10 +24,12 @@ import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEv
|
|||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
|
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { SyncState } from "matrix-js-sdk/src/sync";
|
import { SyncState } from "matrix-js-sdk/src/sync";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
|
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
|
||||||
|
import { translatedError } from "../TranslatedError";
|
||||||
|
|
||||||
export interface GroupCallLoadState {
|
export interface GroupCallLoadState {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@ -41,6 +43,7 @@ export const useLoadGroupCall = (
|
|||||||
viaServers: string[],
|
viaServers: string[],
|
||||||
createPtt: boolean
|
createPtt: boolean
|
||||||
): GroupCallLoadState => {
|
): GroupCallLoadState => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<GroupCallLoadState>({ loading: true });
|
const [state, setState] = useState<GroupCallLoadState>({ loading: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -122,7 +125,7 @@ export const useLoadGroupCall = (
|
|||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
client.off(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming);
|
client.off(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming);
|
||||||
reject(new Error("Fetching group call timed out."));
|
reject(translatedError("Fetching group call timed out.", t));
|
||||||
}, 30000);
|
}, 30000);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -153,7 +156,7 @@ export const useLoadGroupCall = (
|
|||||||
.catch((error) =>
|
.catch((error) =>
|
||||||
setState((prevState) => ({ ...prevState, loading: false, error }))
|
setState((prevState) => ({ ...prevState, loading: false, error }))
|
||||||
);
|
);
|
||||||
}, [client, roomIdOrAlias, viaServers, createPtt]);
|
}, [client, roomIdOrAlias, viaServers, createPtt, t]);
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Item } from "@react-stately/collections";
|
import { Item } from "@react-stately/collections";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Modal } from "../Modal";
|
import { Modal } from "../Modal";
|
||||||
import styles from "./SettingsModal.module.css";
|
import styles from "./SettingsModal.module.css";
|
||||||
@ -37,6 +38,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsModal = (props: Props) => {
|
export const SettingsModal = (props: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
audioInput,
|
audioInput,
|
||||||
audioInputs,
|
audioInputs,
|
||||||
@ -56,7 +58,7 @@ export const SettingsModal = (props: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="Settings"
|
title={t("Settings")}
|
||||||
isDismissable
|
isDismissable
|
||||||
mobileFullScreen
|
mobileFullScreen
|
||||||
className={styles.settingsModal}
|
className={styles.settingsModal}
|
||||||
@ -67,12 +69,12 @@ export const SettingsModal = (props: Props) => {
|
|||||||
title={
|
title={
|
||||||
<>
|
<>
|
||||||
<AudioIcon width={16} height={16} />
|
<AudioIcon width={16} height={16} />
|
||||||
<span>Audio</span>
|
<span>{t("Audio")}</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectInput
|
<SelectInput
|
||||||
label="Microphone"
|
label={t("Microphone")}
|
||||||
selectedKey={audioInput}
|
selectedKey={audioInput}
|
||||||
onSelectionChange={setAudioInput}
|
onSelectionChange={setAudioInput}
|
||||||
>
|
>
|
||||||
@ -80,13 +82,13 @@ export const SettingsModal = (props: Props) => {
|
|||||||
<Item key={deviceId}>
|
<Item key={deviceId}>
|
||||||
{!!label && label.trim().length > 0
|
{!!label && label.trim().length > 0
|
||||||
? label
|
? label
|
||||||
: `Microphone ${index + 1}`}
|
: t("Microphone {{n}}", { n: index + 1 })}
|
||||||
</Item>
|
</Item>
|
||||||
))}
|
))}
|
||||||
</SelectInput>
|
</SelectInput>
|
||||||
{audioOutputs.length > 0 && (
|
{audioOutputs.length > 0 && (
|
||||||
<SelectInput
|
<SelectInput
|
||||||
label="Speaker"
|
label={t("Speaker")}
|
||||||
selectedKey={audioOutput}
|
selectedKey={audioOutput}
|
||||||
onSelectionChange={setAudioOutput}
|
onSelectionChange={setAudioOutput}
|
||||||
>
|
>
|
||||||
@ -94,7 +96,7 @@ export const SettingsModal = (props: Props) => {
|
|||||||
<Item key={deviceId}>
|
<Item key={deviceId}>
|
||||||
{!!label && label.trim().length > 0
|
{!!label && label.trim().length > 0
|
||||||
? label
|
? label
|
||||||
: `Speaker ${index + 1}`}
|
: t("Speaker {{n}}", { n: index + 1 })}
|
||||||
</Item>
|
</Item>
|
||||||
))}
|
))}
|
||||||
</SelectInput>
|
</SelectInput>
|
||||||
@ -102,10 +104,12 @@ export const SettingsModal = (props: Props) => {
|
|||||||
<FieldRow>
|
<FieldRow>
|
||||||
<InputField
|
<InputField
|
||||||
id="spatialAudio"
|
id="spatialAudio"
|
||||||
label="Spatial audio"
|
label={t("Spatial audio")}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={spatialAudio}
|
checked={spatialAudio}
|
||||||
description="This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)"
|
description={t(
|
||||||
|
"This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)"
|
||||||
|
)}
|
||||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
setSpatialAudio(event.target.checked)
|
setSpatialAudio(event.target.checked)
|
||||||
}
|
}
|
||||||
@ -116,12 +120,12 @@ export const SettingsModal = (props: Props) => {
|
|||||||
title={
|
title={
|
||||||
<>
|
<>
|
||||||
<VideoIcon width={16} height={16} />
|
<VideoIcon width={16} height={16} />
|
||||||
<span>Video</span>
|
<span>{t("Video")}</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectInput
|
<SelectInput
|
||||||
label="Camera"
|
label={t("Camera")}
|
||||||
selectedKey={videoInput}
|
selectedKey={videoInput}
|
||||||
onSelectionChange={setVideoInput}
|
onSelectionChange={setVideoInput}
|
||||||
>
|
>
|
||||||
@ -129,7 +133,7 @@ export const SettingsModal = (props: Props) => {
|
|||||||
<Item key={deviceId}>
|
<Item key={deviceId}>
|
||||||
{!!label && label.trim().length > 0
|
{!!label && label.trim().length > 0
|
||||||
? label
|
? label
|
||||||
: `Camera ${index + 1}`}
|
: t("Camera {{n}}", { n: index + 1 })}
|
||||||
</Item>
|
</Item>
|
||||||
))}
|
))}
|
||||||
</SelectInput>
|
</SelectInput>
|
||||||
@ -138,20 +142,22 @@ export const SettingsModal = (props: Props) => {
|
|||||||
title={
|
title={
|
||||||
<>
|
<>
|
||||||
<DeveloperIcon width={16} height={16} />
|
<DeveloperIcon width={16} height={16} />
|
||||||
<span>Developer</span>
|
<span>{t("Developer")}</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Body className={styles.fieldRowText}>
|
<Body className={styles.fieldRowText}>
|
||||||
Version: {import.meta.env.VITE_APP_VERSION || "dev"}
|
{t("Version: {{version}}", {
|
||||||
|
version: import.meta.env.VITE_APP_VERSION || "dev",
|
||||||
|
})}
|
||||||
</Body>
|
</Body>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<InputField
|
<InputField
|
||||||
id="showInspector"
|
id="showInspector"
|
||||||
name="inspector"
|
name="inspector"
|
||||||
label="Show Call Inspector"
|
label={t("Show call inspector")}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showInspector}
|
checked={showInspector}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
@ -160,7 +166,9 @@ export const SettingsModal = (props: Props) => {
|
|||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Button onPress={downloadDebugLog}>Download Debug Logs</Button>
|
<Button onPress={downloadDebugLog}>
|
||||||
|
{t("Download debug logs")}
|
||||||
|
</Button>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
</TabContainer>
|
</TabContainer>
|
||||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
import { animated } from "@react-spring/web";
|
import { animated } from "@react-spring/web";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import styles from "./VideoTile.module.css";
|
import styles from "./VideoTile.module.css";
|
||||||
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
|
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
|
||||||
@ -66,6 +67,8 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
|||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const toolbarButtons: JSX.Element[] = [];
|
const toolbarButtons: JSX.Element[] = [];
|
||||||
if (!isLocal) {
|
if (!isLocal) {
|
||||||
toolbarButtons.push(
|
toolbarButtons.push(
|
||||||
@ -111,7 +114,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
|||||||
{!maximised &&
|
{!maximised &&
|
||||||
(screenshare ? (
|
(screenshare ? (
|
||||||
<div className={styles.presenterLabel}>
|
<div className={styles.presenterLabel}>
|
||||||
<span>{`${name} is presenting`}</span>
|
<span>{t("{{name}} is presenting", { name })}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={classNames(styles.infoBubble, styles.memberName)}>
|
<div className={classNames(styles.infoBubble, styles.memberName)}>
|
||||||
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||||||
|
|
||||||
import React, { ChangeEvent, useState } from "react";
|
import React, { ChangeEvent, useState } from "react";
|
||||||
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { FieldRow } from "../input/Input";
|
import { FieldRow } from "../input/Input";
|
||||||
import { Modal } from "../Modal";
|
import { Modal } from "../Modal";
|
||||||
@ -61,10 +62,12 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const VideoTileSettingsModal = ({ feed, ...rest }: Props) => {
|
export const VideoTileSettingsModal = ({ feed, ...rest }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
className={styles.videoTileSettingsModal}
|
className={styles.videoTileSettingsModal}
|
||||||
title="Local volume"
|
title={t("Local volume")}
|
||||||
isDismissable
|
isDismissable
|
||||||
mobileFullScreen
|
mobileFullScreen
|
||||||
{...rest}
|
{...rest}
|
||||||
|
@ -22,7 +22,7 @@ import { WidgetApi, MatrixCapabilities } from "matrix-widget-api";
|
|||||||
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||||
import { LazyEventEmitter } from "./LazyEventEmitter";
|
import { LazyEventEmitter } from "./LazyEventEmitter";
|
||||||
import { getRoomParams } from "./room/useRoomParams";
|
import { getUrlParams } from "./UrlParams";
|
||||||
|
|
||||||
// Subset of the actions in matrix-react-sdk
|
// Subset of the actions in matrix-react-sdk
|
||||||
export enum ElementWidgetActions {
|
export enum ElementWidgetActions {
|
||||||
@ -80,7 +80,7 @@ export const widget: WidgetHelpers | null = (() => {
|
|||||||
// We need to do this now rather than later because it has capabilities to
|
// We need to do this now rather than later because it has capabilities to
|
||||||
// request, and is responsible for starting the transport (should it be?)
|
// request, and is responsible for starting the transport (should it be?)
|
||||||
|
|
||||||
const { roomId, userId, deviceId } = getRoomParams();
|
const { roomId, userId, deviceId } = getUrlParams();
|
||||||
if (!roomId) throw new Error("Room ID must be supplied");
|
if (!roomId) throw new Error("Room ID must be supplied");
|
||||||
if (!userId) throw new Error("User ID must be supplied");
|
if (!userId) throw new Error("User ID must be supplied");
|
||||||
if (!deviceId) throw new Error("Device ID must be supplied");
|
if (!deviceId) throw new Error("Device ID must be supplied");
|
||||||
|
@ -29,7 +29,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
svgrPlugin(),
|
svgrPlugin(),
|
||||||
htmlTemplate({
|
htmlTemplate.default({
|
||||||
data: {
|
data: {
|
||||||
title: env.VITE_PRODUCT_NAME || "Matrix Video Chat",
|
title: env.VITE_PRODUCT_NAME || "Matrix Video Chat",
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user