Wire up i18n for the React frontend (#1962)

Co-authored-by: Quentin Gliech <quenting@element.io>
This commit is contained in:
Michael Telatynski
2023-10-19 13:41:38 +01:00
committed by GitHub
parent 7207ebdc63
commit af1a960c2f
47 changed files with 2176 additions and 167 deletions

10
.editorconfig Normal file
View File

@@ -0,0 +1,10 @@
root = true
[*]
charset=utf-8
end_of_line = lf
[*.{ts,tsx}]
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

View File

@@ -54,6 +54,7 @@ enum FileType {
Stylesheet, Stylesheet,
Woff, Woff,
Woff2, Woff2,
Json,
} }
impl FileType { impl FileType {
@@ -63,6 +64,7 @@ impl FileType {
Some("js") => Some(Self::Script), Some("js") => Some(Self::Script),
Some("woff") => Some(Self::Woff), Some("woff") => Some(Self::Woff),
Some("woff2") => Some(Self::Woff2), Some("woff2") => Some(Self::Woff2),
Some("json") => Some(Self::Json),
_ => None, _ => None,
} }
} }
@@ -122,6 +124,9 @@ impl<'a> Asset<'a> {
FileType::Woff | FileType::Woff2 => { FileType::Woff | FileType::Woff2 => {
format!(r#"<link rel="preload" href="{href}" as="font" crossorigin {integrity}/>"#,) format!(r#"<link rel="preload" href="{href}" as="font" crossorigin {integrity}/>"#,)
} }
FileType::Json => {
format!(r#"<link rel="preload" href="{href}" as="fetch" crossorigin {integrity}/>"#,)
}
} }
} }
@@ -140,9 +145,33 @@ impl<'a> Asset<'a> {
FileType::Script => Some(format!( FileType::Script => Some(format!(
r#"<script type="module" src="{src}" crossorigin {integrity}></script>"# r#"<script type="module" src="{src}" crossorigin {integrity}></script>"#
)), )),
FileType::Woff | FileType::Woff2 => None, FileType::Woff | FileType::Woff2 | FileType::Json => None,
} }
} }
/// Returns `true` if the asset type is a script
#[must_use]
pub fn is_script(&self) -> bool {
self.file_type == FileType::Script
}
/// Returns `true` if the asset type is a stylesheet
#[must_use]
pub fn is_stylesheet(&self) -> bool {
self.file_type == FileType::Stylesheet
}
/// Returns `true` if the asset type is JSON
#[must_use]
pub fn is_json(&self) -> bool {
self.file_type == FileType::Json
}
/// Returns `true` if the asset type is a font
#[must_use]
pub fn is_font(&self) -> bool {
self.file_type == FileType::Woff || self.file_type == FileType::Woff2
}
} }
impl Manifest { impl Manifest {

View File

@@ -362,15 +362,17 @@ impl Object for IncludeAsset {
BTreeSet::new() BTreeSet::new()
}; };
let tags: Vec<String> = preloads let preloads = preloads
.iter() .iter()
.map(|asset| asset.preload_tag(self.url_builder.assets_base().into())) // Only preload scripts and stylesheets for now
.chain( .filter(|asset| asset.is_script() || asset.is_stylesheet())
assets .map(|asset| asset.preload_tag(self.url_builder.assets_base().into()));
.iter()
.filter_map(|asset| asset.include_tag(self.url_builder.assets_base().into())), let assets = assets
) .iter()
.collect(); .filter_map(|asset| asset.include_tag(self.url_builder.assets_base().into()));
let tags: Vec<String> = preloads.chain(assets).collect();
Ok(Value::from_safe_string(tags.join("\n"))) Ok(Value::from_safe_string(tags.join("\n")))
} }

View File

@@ -0,0 +1,36 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// 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 type { UserConfig } from "i18next-parser";
const config: UserConfig = {
keySeparator: ".",
pluralSeparator: ":",
defaultNamespace: "frontend",
lexers: {
ts: [
{
lexer: "JavascriptLexer",
functions: ["t", "translatedError"],
namespaceFunctions: ["useTranslation", "withTranslation"],
},
],
},
locales: ["en"],
output: "locales/$LOCALE.json",
input: ["src/**/*.{ts,tsx}"],
sort: true,
};
export default config;

140
frontend/locales/en.json Normal file
View File

@@ -0,0 +1,140 @@
{
"action": {
"cancel": "Cancel",
"continue": "Continue",
"save": "Save"
},
"common": {
"add": "Add",
"error": "Error",
"loading": "Loading…",
"next": "Next",
"previous": "Previous"
},
"frontend": {
"add_email_form": {
"email_denied_alert": {
"text": "The entered email is not allowed by the server policy.",
"title": "Email denied by policy"
},
"email_exists_alert": {
"text": "The entered email is already added to this account",
"title": "Email already exists"
},
"email_field_label": "Add email",
"email_invalid_alert": {
"text": "The entered email is invalid",
"title": "Invalid email"
}
},
"app_sessions_list": {
"error": "Failed to load app sessions",
"heading": "Apps"
},
"browser_session_details": {
"current_badge": "Current",
"session_details_title": "Session"
},
"browser_sessions_overview": {
"body:one": "{{count}} active session",
"body:other": "{{count}} active sessions",
"heading": "Browsers",
"view_all_button": "View all"
},
"compat_session_detail": {
"client_details_title": "Client",
"name": "Name",
"session_details_title": "Session"
},
"device_type_icon_label": {
"desktop": "Desktop",
"mobile": "Mobile",
"unknown": "Unknown device type",
"web": "Web"
},
"end_session_button": {
"confirmation_modal_title": "Are you sure you want to end this session?",
"text": "End session"
},
"error_boundary_title": "Something went wrong",
"last_active": {
"active_date": "Active {{relativeDate}}",
"active_now": "Active now",
"inactive_90_days": "Inactive for 90+ days"
},
"nav": {
"profile": "Profile",
"sessions": "Sessions"
},
"not_found_alert_title": "Not found.",
"not_logged_in_alert": "You're not logged in.",
"oauth2_client_detail": {
"details_title": "Client",
"id": "Client ID",
"name": "Name",
"policy": "Policy",
"terms": "Terms of service"
},
"oauth2_session_detail": {
"client_details_name": "Name",
"client_title": "Client",
"session_details_title": "Session"
},
"pagination_controls": {
"total": "Total: {{totalCount}}"
},
"selectable_session": {
"label": "Select session"
},
"session": {
"current_badge": "Current",
"finished_date": "Finished <1/>",
"signed_in_date": "Signed in <1/>"
},
"session_detail": {
"alert": {
"button": "Go back",
"text": "This session does not exist, or is no longer active.",
"title": "Cannot find session: {{deviceId}}"
}
},
"unknown_route": "Unknown route {{route}}",
"unverified_email_alert": {
"button": "Review and verify",
"text:one": "You have {{count}} unverified email address.",
"text:other": "You have {{count}} unverified email addresses.",
"title": "Unverified email"
},
"user_email": {
"delete_button_confirmation_modal": {
"body": "Are you sure you want to remove this email?"
},
"delete_button_title": "Remove email address",
"email": "Email",
"make_primary_button": "Make primary",
"primary_email": "Primary email",
"retry_button": "Retry verification",
"unverified": "Unverified"
},
"user_email_list": {
"heading": "Emails",
"no_primary_email_alert": "No primary email address"
},
"user_greeting": {
"error": "Failed to load user"
},
"user_name": {
"display_name_field_label": "Display Name"
},
"user_sessions_overview": {
"heading": "Where you're signed in"
},
"verify_email": {
"code_field_label": "6-digit code",
"enter_code_prompt": "Enter the 6-digit code sent to <1>{{email}</1>",
"heading": "Verify your email",
"invalid_code_alert": "Invalid code",
"unknown_email": "Unknown email"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,14 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"generate": "graphql-codegen && eslint --fix .", "generate": "graphql-codegen && eslint --fix .",
"lint": "graphql-codegen && eslint . && tsc", "lint": "graphql-codegen && eslint . && tsc && i18next --fail-on-warnings --fail-on-update",
"build": "rimraf ./dist/ && vite build", "build": "rimraf ./dist/ && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
"coverage": "vitest run --coverage", "coverage": "vitest run --coverage",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build" "build-storybook": "storybook build",
"i18n": "i18next"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
@@ -29,12 +30,16 @@
"classnames": "^2.3.2", "classnames": "^2.3.2",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"graphql": "^16.8.1", "graphql": "^16.8.1",
"i18next": "^23.5.1",
"i18next-browser-languagedetector": "^7.1.0",
"i18next-http-backend": "^2.2.2",
"jotai": "^2.4.3", "jotai": "^2.4.3",
"jotai-devtools": "^0.6.3", "jotai-devtools": "^0.6.3",
"jotai-location": "^0.5.1", "jotai-location": "^0.5.1",
"jotai-urql": "^0.7.1", "jotai-urql": "^0.7.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^13.3.0",
"ua-parser-js": "^1.0.36" "ua-parser-js": "^1.0.36"
}, },
"devDependencies": { "devDependencies": {
@@ -61,6 +66,7 @@
"eslint-plugin-matrix-org": "^1.2.1", "eslint-plugin-matrix-org": "^1.2.1",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.0.0",
"happy-dom": "^12.9.1", "happy-dom": "^12.9.1",
"i18next-parser": "^8.9.0",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"prettier": "3.0.3", "prettier": "3.0.3",
"react-test-renderer": "^18.2.0", "react-test-renderer": "^18.2.0",

27
frontend/src/@types/i18next.d.ts vendored Normal file
View File

@@ -0,0 +1,27 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// 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 "i18next";
import type frontend from "../../public/locales/en.json";
declare module "i18next" {
interface CustomTypeOptions {
keySeparator: ".";
pluralSeparator: ":";
defaultNS: "frontend";
resources: {
frontend: typeof frontend;
};
}
}

View File

@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
import { H3 } from "@vector-im/compound-web"; import { H3 } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { FragmentType, useFragment } from "../../gql"; import { FragmentType, useFragment } from "../../gql";
import { graphql } from "../../gql/gql"; import { graphql } from "../../gql/gql";
@@ -52,16 +53,20 @@ const FriendlyExternalLink: React.FC<{ uri?: string }> = ({ uri }) => {
const OAuth2ClientDetail: React.FC<Props> = ({ client }) => { const OAuth2ClientDetail: React.FC<Props> = ({ client }) => {
const data = useFragment(OAUTH2_CLIENT_FRAGMENT, client); const data = useFragment(OAUTH2_CLIENT_FRAGMENT, client);
const { t } = useTranslation();
const details = [ const details = [
{ label: "Name", value: data.clientName }, { label: t("frontend.oauth2_client_detail.name"), value: data.clientName },
{ label: "Client ID", value: <code>{data.clientId}</code> },
{ {
label: "Terms of service", label: t("frontend.oauth2_client_detail.id"),
value: <code>{data.clientId}</code>,
},
{
label: t("frontend.oauth2_client_detail.terms"),
value: data.tosUri && <FriendlyExternalLink uri={data.tosUri} />, value: data.tosUri && <FriendlyExternalLink uri={data.tosUri} />,
}, },
{ {
label: "Policy", label: t("frontend.oauth2_client_detail.policy"),
value: data.policyUri && <FriendlyExternalLink uri={data.policyUri} />, value: data.policyUri && <FriendlyExternalLink uri={data.policyUri} />,
}, },
].filter(({ value }) => !!value); ].filter(({ value }) => !!value);
@@ -76,7 +81,10 @@ const OAuth2ClientDetail: React.FC<Props> = ({ client }) => {
/> />
<H3>{data.clientName}</H3> <H3>{data.clientName}</H3>
</header> </header>
<SessionDetails title="Client" details={details} /> <SessionDetails
title={t("frontend.oauth2_client_detail.details_title")}
details={details}
/>
</BlockList> </BlockList>
); );
}; };

View File

@@ -26,6 +26,7 @@ import {
import { Button } from "@vector-im/compound-web"; import { Button } from "@vector-im/compound-web";
import classNames from "classnames"; import classNames from "classnames";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { Translation } from "react-i18next";
import styles from "./ConfirmationModal.module.css"; import styles from "./ConfirmationModal.module.css";
@@ -51,40 +52,44 @@ const ConfirmationModal: React.FC<React.PropsWithChildren<Props>> = ({
trigger, trigger,
title, title,
}) => ( }) => (
<Root> <Translation>
<Trigger asChild>{trigger}</Trigger> {(t): ReactNode => (
<Portal> <Root>
<Overlay className={styles.overlay} /> <Trigger asChild>{trigger}</Trigger>
<Content <Portal>
className={classNames(styles.content, className)} <Overlay className={styles.overlay} />
onEscapeKeyDown={(event): void => { <Content
if (onDeny) { className={classNames(styles.content, className)}
onDeny(); onEscapeKeyDown={(event): void => {
} else { if (onDeny) {
// if there is no deny callback, we should prevent the escape key from closing the modal onDeny();
event.preventDefault(); } else {
} // if there is no deny callback, we should prevent the escape key from closing the modal
}} event.preventDefault();
> }
<Title>{title}</Title> }}
<Description>{children}</Description> >
<div className={styles.buttons}> <Title>{title}</Title>
{onDeny && ( <Description>{children}</Description>
<Cancel asChild> <div className={styles.buttons}>
<Button kind="tertiary" size="sm" onClick={onDeny}> {onDeny && (
Cancel <Cancel asChild>
</Button> <Button kind="tertiary" size="sm" onClick={onDeny}>
</Cancel> {t("action.cancel")}
)} </Button>
<Action asChild> </Cancel>
<Button kind="destructive" size="sm" onClick={onConfirm}> )}
Continue <Action asChild>
</Button> <Button kind="destructive" size="sm" onClick={onConfirm}>
</Action> {t("action.continue")}
</div> </Button>
</Content> </Action>
</Portal> </div>
</Root> </Content>
</Portal>
</Root>
)}
</Translation>
); );
export default ConfirmationModal; export default ConfirmationModal;

View File

@@ -15,6 +15,7 @@
import { CombinedError } from "@urql/core"; import { CombinedError } from "@urql/core";
import { Alert } from "@vector-im/compound-web"; import { Alert } from "@vector-im/compound-web";
import { ErrorInfo, ReactNode, PureComponent } from "react"; import { ErrorInfo, ReactNode, PureComponent } from "react";
import { Translation } from "react-i18next";
import GraphQLError from "./GraphQLError"; import GraphQLError from "./GraphQLError";
@@ -61,9 +62,13 @@ export default class ErrorBoundary extends PureComponent<Props, IState> {
} }
return ( return (
<Alert type="critical" title="Something went wrong"> <Translation>
{this.state.error.message} {(t): ReactNode => (
</Alert> <Alert type="critical" title={t("frontend.error_boundary_title")}>
{this.state.error!.message}
</Alert>
)}
</Translation>
); );
} }

View File

@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { currentUserIdAtom } from "../atoms"; import { currentUserIdAtom } from "../atoms";
import { isErr, unwrapErr, unwrapOk } from "../result"; import { isErr, unwrapErr, unwrapOk } from "../result";
@@ -28,6 +29,8 @@ import UserGreeting from "./UserGreeting";
const Layout: React.FC<{ children?: React.ReactNode }> = ({ children }) => { const Layout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const route = useAtomValue(routeAtom); const route = useAtomValue(routeAtom);
const result = useAtomValue(currentUserIdAtom); const result = useAtomValue(currentUserIdAtom);
const { t } = useTranslation();
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />; if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
// Hide the nav bar & user greeting on the verify-email page // Hide the nav bar & user greeting on the verify-email page
@@ -48,8 +51,12 @@ const Layout: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
<UserGreeting userId={userId} /> <UserGreeting userId={userId} />
<NavBar> <NavBar>
<NavItem route={{ type: "profile" }}>Profile</NavItem> <NavItem route={{ type: "profile" }}>
<NavItem route={{ type: "sessions-overview" }}>Sessions</NavItem> {t("frontend.nav.profile")}
</NavItem>
<NavItem route={{ type: "sessions-overview" }}>
{t("frontend.nav.sessions")}
</NavItem>
</NavBar> </NavBar>
</> </>
)} )}

View File

@@ -21,7 +21,7 @@ exports[`LoadingScreen > render <LoadingScreen /> 1`] = `
<span <span
className="sr-only" className="sr-only"
> >
Loading... Loading
</span> </span>
</div> </div>
</main> </main>

View File

@@ -12,6 +12,9 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { ReactNode } from "react";
import { Translation } from "react-i18next";
import styles from "./LoadingSpinner.module.css"; import styles from "./LoadingSpinner.module.css";
const LoadingSpinner: React.FC<{ inline?: boolean }> = ({ inline }) => ( const LoadingSpinner: React.FC<{ inline?: boolean }> = ({ inline }) => (
@@ -27,7 +30,9 @@ const LoadingSpinner: React.FC<{ inline?: boolean }> = ({ inline }) => (
fill="currentFill" fill="currentFill"
/> />
</svg> </svg>
<span className="sr-only">Loading...</span> <span className="sr-only">
<Translation>{(t): ReactNode => t("common.loading")}</Translation>
</span>
</div> </div>
); );

View File

@@ -13,7 +13,15 @@
// limitations under the License. // limitations under the License.
import { Alert } from "@vector-im/compound-web"; import { Alert } from "@vector-im/compound-web";
import { ReactNode } from "react";
import { Translation } from "react-i18next";
const NotFound: React.FC = () => <Alert type="critical" title="Not found." />; const NotFound: React.FC = () => (
<Translation>
{(t): ReactNode => (
<Alert type="critical" title={t("frontend.not_found_alert_title")} />
)}
</Translation>
);
export default NotFound; export default NotFound;

View File

@@ -13,9 +13,15 @@
// limitations under the License. // limitations under the License.
import { Alert } from "@vector-im/compound-web"; import { Alert } from "@vector-im/compound-web";
import { ReactNode } from "react";
import { Translation } from "react-i18next";
const NotLoggedIn: React.FC = () => ( const NotLoggedIn: React.FC = () => (
<Alert type="critical" title="You're not logged in." /> <Translation>
{(t): ReactNode => (
<Alert type="critical" title={t("frontend.not_logged_in_alert")} />
)}
</Translation>
); );
export default NotLoggedIn; export default NotLoggedIn;

View File

@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
import { Button } from "@vector-im/compound-web"; import { Button } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
type Props = { type Props = {
onNext: (() => void) | null; onNext: (() => void) | null;
@@ -30,6 +31,8 @@ const PaginationControls: React.FC<Props> = ({
count, count,
disabled, disabled,
}) => { }) => {
const { t } = useTranslation();
if (autoHide && !onNext && !onPrev) { if (autoHide && !onNext && !onPrev) {
return null; return null;
} }
@@ -41,10 +44,12 @@ const PaginationControls: React.FC<Props> = ({
disabled={disabled || !onPrev} disabled={disabled || !onPrev}
onClick={(): void => onPrev?.()} onClick={(): void => onPrev?.()}
> >
Previous {t("common.previous")}
</Button> </Button>
<div className="text-center"> <div className="text-center">
{count !== undefined ? <>Total: {count}</> : null} {count !== undefined ? (
<>{t("frontend.pagination_controls.total", { totalCount: count })}</>
) : null}
</div> </div>
<Button <Button
kind="secondary" kind="secondary"
@@ -52,7 +57,7 @@ const PaginationControls: React.FC<Props> = ({
disabled={disabled || !onNext} disabled={disabled || !onNext}
onClick={(): void => onNext?.()} onClick={(): void => onNext?.()}
> >
Next {t("common.next")}
</Button> </Button>
</div> </div>
); );

View File

@@ -17,6 +17,7 @@ import IconMobile from "@vector-im/compound-design-tokens/icons/mobile.svg?react
import IconUnknown from "@vector-im/compound-design-tokens/icons/unknown.svg?react"; import IconUnknown from "@vector-im/compound-design-tokens/icons/unknown.svg?react";
import IconBrowser from "@vector-im/compound-design-tokens/icons/web-browser.svg?react"; import IconBrowser from "@vector-im/compound-design-tokens/icons/web-browser.svg?react";
import { FunctionComponent, SVGProps } from "react"; import { FunctionComponent, SVGProps } from "react";
import { useTranslation } from "react-i18next";
import { DeviceType } from "../../utils/parseUserAgent"; import { DeviceType } from "../../utils/parseUserAgent";
@@ -32,17 +33,20 @@ const deviceTypeToIcon: Record<
[DeviceType.Web]: IconBrowser, [DeviceType.Web]: IconBrowser,
}; };
const deviceTypeToLabel: Record<DeviceType, string> = {
[DeviceType.Unknown]: "Unknown device type",
[DeviceType.Desktop]: "Desktop",
[DeviceType.Mobile]: "Mobile",
[DeviceType.Web]: "Web",
};
const DeviceTypeIcon: React.FC<{ deviceType: DeviceType }> = ({ const DeviceTypeIcon: React.FC<{ deviceType: DeviceType }> = ({
deviceType, deviceType,
}) => { }) => {
const { t } = useTranslation();
const Icon = deviceTypeToIcon[deviceType]; const Icon = deviceTypeToIcon[deviceType];
const deviceTypeToLabel: Record<DeviceType, string> = {
[DeviceType.Unknown]: t("frontend.device_type_icon_label.unknown"),
[DeviceType.Desktop]: t("frontend.device_type_icon_label.desktop"),
[DeviceType.Mobile]: t("frontend.device_type_icon_label.mobile"),
[DeviceType.Web]: t("frontend.device_type_icon_label.web"),
};
const label = deviceTypeToLabel[deviceType]; const label = deviceTypeToLabel[deviceType];
return <Icon className={styles.icon} aria-label={label} />; return <Icon className={styles.icon} aria-label={label} />;

View File

@@ -14,6 +14,7 @@
import { Button } from "@vector-im/compound-web"; import { Button } from "@vector-im/compound-web";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import ConfirmationModal from "../ConfirmationModal/ConfirmationModal"; import ConfirmationModal from "../ConfirmationModal/ConfirmationModal";
import LoadingSpinner from "../LoadingSpinner/LoadingSpinner"; import LoadingSpinner from "../LoadingSpinner/LoadingSpinner";
@@ -26,6 +27,7 @@ const EndSessionButton: React.FC<{ endSession: () => Promise<void> }> = ({
endSession, endSession,
}) => { }) => {
const [inProgress, setInProgress] = useState(false); const [inProgress, setInProgress] = useState(false);
const { t } = useTranslation();
const onConfirm = async (): Promise<void> => { const onConfirm = async (): Promise<void> => {
setInProgress(true); setInProgress(true);
@@ -45,10 +47,11 @@ const EndSessionButton: React.FC<{ endSession: () => Promise<void> }> = ({
<ConfirmationModal <ConfirmationModal
onDeny={onDeny} onDeny={onDeny}
onConfirm={onConfirm} onConfirm={onConfirm}
title="Are you sure you want to end this session?" title={t("frontend.end_session_button.confirmation_modal_title")}
trigger={ trigger={
<Button kind="destructive" size="sm" disabled={inProgress}> <Button kind="destructive" size="sm" disabled={inProgress}>
{inProgress && <LoadingSpinner inline />}End session {inProgress && <LoadingSpinner inline />}
{t("frontend.end_session_button.text")}
</Button> </Button>
} }
/> />

View File

@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
import { differenceInSeconds, parseISO } from "date-fns"; import { differenceInSeconds, parseISO } from "date-fns";
import { useTranslation } from "react-i18next";
import { formatDate, formatReadableDate } from "../DateTime"; import { formatDate, formatReadableDate } from "../DateTime";
@@ -27,6 +28,8 @@ const LastActive: React.FC<{
lastActive: Date | string; lastActive: Date | string;
now?: Date | string; now?: Date | string;
}> = ({ lastActive: lastActiveProps, now: nowProps }) => { }> = ({ lastActive: lastActiveProps, now: nowProps }) => {
const { t } = useTranslation();
const lastActive = const lastActive =
typeof lastActiveProps === "string" typeof lastActiveProps === "string"
? parseISO(lastActiveProps) ? parseISO(lastActiveProps)
@@ -42,15 +45,23 @@ const LastActive: React.FC<{
if (differenceInSeconds(now, lastActive) <= ACTIVE_NOW_MAX_AGE) { if (differenceInSeconds(now, lastActive) <= ACTIVE_NOW_MAX_AGE) {
return ( return (
<span title={formattedDate} className={styles.active}> <span title={formattedDate} className={styles.active}>
Active now {t("frontend.last_active.active_now")}
</span> </span>
); );
} }
if (differenceInSeconds(now, lastActive) > INACTIVE_MIN_AGE) { if (differenceInSeconds(now, lastActive) > INACTIVE_MIN_AGE) {
return <span title={formattedDate}>Inactive for 90+ days</span>; return (
<span title={formattedDate}>
{t("frontend.last_active.inactive_90_days")}
</span>
);
} }
const relativeDate = formatReadableDate(lastActive, now); const relativeDate = formatReadableDate(lastActive, now);
return <span title={formattedDate}>{`Active ${relativeDate}`}</span>; return (
<span title={formattedDate}>
{t("frontend.last_active.active_date", { relativeDate })}
</span>
);
}; };
export default LastActive; export default LastActive;

View File

@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
import { Checkbox } from "@vector-im/compound-web"; import { Checkbox } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import styles from "./SelectableSession.module.css"; import styles from "./SelectableSession.module.css";
@@ -29,13 +30,14 @@ const SelectableSession: React.FC<React.PropsWithChildren<Props>> = ({
onSelect, onSelect,
children, children,
}) => { }) => {
const { t } = useTranslation();
return ( return (
<div className={styles.selectableSession}> <div className={styles.selectableSession}>
<Checkbox <Checkbox
className={styles.checkbox} className={styles.checkbox}
kind="primary" kind="primary"
onChange={onSelect} onChange={onSelect}
aria-label="Select session" aria-label={t("frontend.selectable_session.label")}
checked={isSelected} checked={isSelected}
/> />
{children} {children}

View File

@@ -14,6 +14,7 @@
import { H6, Body, Badge } from "@vector-im/compound-web"; import { H6, Body, Badge } from "@vector-im/compound-web";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { Trans, useTranslation } from "react-i18next";
import { DeviceType } from "../../utils/parseUserAgent"; import { DeviceType } from "../../utils/parseUserAgent";
import Block from "../Block"; import Block from "../Block";
@@ -53,20 +54,28 @@ const Session: React.FC<React.PropsWithChildren<SessionProps>> = ({
children, children,
deviceType, deviceType,
}) => { }) => {
const { t } = useTranslation();
return ( return (
<Block className={styles.session}> <Block className={styles.session}>
<DeviceTypeIcon deviceType={deviceType || DeviceType.Unknown} /> <DeviceTypeIcon deviceType={deviceType || DeviceType.Unknown} />
<div className={styles.container}> <div className={styles.container}>
{isCurrent && <Badge kind="success">Current</Badge>} {isCurrent && (
<Badge kind="success">{t("frontend.session.current_badge")}</Badge>
)}
<H6 className={styles.sessionName} title={id}> <H6 className={styles.sessionName} title={id}>
{name || id} {name || id}
</H6> </H6>
<SessionMetadata weight="semibold"> <SessionMetadata weight="semibold">
Signed in <DateTime datetime={createdAt} /> <Trans i18nKey="frontend.session.signed_in_date">
Signed in <DateTime datetime={createdAt} />
</Trans>
</SessionMetadata> </SessionMetadata>
{!!finishedAt && ( {!!finishedAt && (
<SessionMetadata weight="semibold" data-finished={true}> <SessionMetadata weight="semibold" data-finished={true}>
Finished <DateTime datetime={finishedAt} /> <Trans i18nKey="frontend.session.finished_date">
Finished <DateTime datetime={finishedAt} />
</Trans>
</SessionMetadata> </SessionMetadata>
)} )}
{!!lastActiveAt && ( {!!lastActiveAt && (

View File

@@ -14,6 +14,7 @@
import { Badge } from "@vector-im/compound-web"; import { Badge } from "@vector-im/compound-web";
import { parseISO } from "date-fns"; import { parseISO } from "date-fns";
import { useTranslation } from "react-i18next";
import { FragmentType, graphql, useFragment } from "../../gql"; import { FragmentType, graphql, useFragment } from "../../gql";
import { import {
@@ -57,6 +58,7 @@ type Props = {
const BrowserSessionDetail: React.FC<Props> = ({ session }) => { const BrowserSessionDetail: React.FC<Props> = ({ session }) => {
const data = useFragment(FRAGMENT, session); const data = useFragment(FRAGMENT, session);
const currentBrowserSessionId = useCurrentBrowserSessionId(); const currentBrowserSessionId = useCurrentBrowserSessionId();
const { t } = useTranslation();
const isCurrent = currentBrowserSessionId === data.id; const isCurrent = currentBrowserSessionId === data.id;
const onSessionEnd = useEndBrowserSession(data.id, isCurrent); const onSessionEnd = useEndBrowserSession(data.id, isCurrent);
@@ -113,13 +115,16 @@ const BrowserSessionDetail: React.FC<Props> = ({ session }) => {
<BlockList> <BlockList>
{isCurrent && ( {isCurrent && (
<Badge className={styles.currentBadge} kind="success"> <Badge className={styles.currentBadge} kind="success">
Current {t("frontend.browser_session_details.current_badge")}
</Badge> </Badge>
)} )}
<SessionHeader backToRoute={{ type: "browser-session-list" }}> <SessionHeader backToRoute={{ type: "browser-session-list" }}>
{sessionName} {sessionName}
</SessionHeader> </SessionHeader>
<SessionDetails title="Session" details={sessionDetails} /> <SessionDetails
title={t("frontend.browser_session_details.session_details_title")}
details={sessionDetails}
/>
{!data.finishedAt && <EndSessionButton endSession={onSessionEnd} />} {!data.finishedAt && <EndSessionButton endSession={onSessionEnd} />}
</BlockList> </BlockList>
); );

View File

@@ -14,6 +14,7 @@
import { parseISO } from "date-fns"; import { parseISO } from "date-fns";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { FragmentType, graphql, useFragment } from "../../gql"; import { FragmentType, graphql, useFragment } from "../../gql";
import BlockList from "../BlockList/BlockList"; import BlockList from "../BlockList/BlockList";
@@ -48,6 +49,7 @@ type Props = {
const CompatSessionDetail: React.FC<Props> = ({ session }) => { const CompatSessionDetail: React.FC<Props> = ({ session }) => {
const data = useFragment(FRAGMENT, session); const data = useFragment(FRAGMENT, session);
const endSession = useSetAtom(endCompatSessionFamily(data.id)); const endSession = useSetAtom(endCompatSessionFamily(data.id));
const { t } = useTranslation();
const onSessionEnd = async (): Promise<void> => { const onSessionEnd = async (): Promise<void> => {
await endSession(); await endSession();
@@ -91,7 +93,7 @@ const CompatSessionDetail: React.FC<Props> = ({ session }) => {
if (data.ssoLogin?.redirectUri) { if (data.ssoLogin?.redirectUri) {
clientDetails.push({ clientDetails.push({
label: "Name", label: t("frontend.compat_session_detail.name"),
value: simplifyUrl(data.ssoLogin.redirectUri), value: simplifyUrl(data.ssoLogin.redirectUri),
}); });
clientDetails.push({ clientDetails.push({
@@ -113,9 +115,15 @@ const CompatSessionDetail: React.FC<Props> = ({ session }) => {
> >
{data.deviceId || data.id} {data.deviceId || data.id}
</SessionHeader> </SessionHeader>
<SessionDetails title="Session" details={sessionDetails} /> <SessionDetails
title={t("frontend.compat_session_detail.session_details_title")}
details={sessionDetails}
/>
{clientDetails.length > 0 ? ( {clientDetails.length > 0 ? (
<SessionDetails title="Client" details={clientDetails} /> <SessionDetails
title={t("frontend.compat_session_detail.client_details_title")}
details={clientDetails}
/>
) : null} ) : null}
{!data.finishedAt && <EndSessionButton endSession={onSessionEnd} />} {!data.finishedAt && <EndSessionButton endSession={onSessionEnd} />}
</BlockList> </BlockList>

View File

@@ -14,6 +14,7 @@
import { parseISO } from "date-fns"; import { parseISO } from "date-fns";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { FragmentType, graphql, useFragment } from "../../gql"; import { FragmentType, graphql, useFragment } from "../../gql";
import { Link } from "../../routing"; import { Link } from "../../routing";
@@ -53,6 +54,7 @@ type Props = {
const OAuth2SessionDetail: React.FC<Props> = ({ session }) => { const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
const data = useFragment(FRAGMENT, session); const data = useFragment(FRAGMENT, session);
const endSession = useSetAtom(endSessionFamily(data.id)); const endSession = useSetAtom(endSessionFamily(data.id));
const { t } = useTranslation();
const onSessionEnd = async (): Promise<void> => { const onSessionEnd = async (): Promise<void> => {
await endSession(); await endSession();
@@ -104,11 +106,13 @@ const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
]; ];
const clientTitle = ( const clientTitle = (
<Link route={{ type: "client", id: data.client.id }}>Client</Link> <Link route={{ type: "client", id: data.client.id }}>
{t("frontend.oauth2_session_detail.client_title")}
</Link>
); );
const clientDetails = [ const clientDetails = [
{ {
label: "Name", label: t("frontend.oauth2_session_detail.client_details_name"),
value: ( value: (
<> <>
<ClientAvatar <ClientAvatar
@@ -140,7 +144,10 @@ const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
> >
{deviceId || data.id} {deviceId || data.id}
</SessionHeader> </SessionHeader>
<SessionDetails title="Session" details={sessionDetails} /> <SessionDetails
title={t("frontend.oauth2_session_detail.session_details_title")}
details={sessionDetails}
/>
<SessionDetails title={clientTitle} details={clientDetails} /> <SessionDetails title={clientTitle} details={clientDetails} />
{!data.finishedAt && <EndSessionButton endSession={onSessionEnd} />} {!data.finishedAt && <EndSessionButton endSession={onSessionEnd} />}
</BlockList> </BlockList>

View File

@@ -17,6 +17,7 @@ import { useAtomValue } from "jotai";
import { atomFamily } from "jotai/utils"; import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql"; import { atomWithQuery } from "jotai-urql";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { graphql } from "../../gql"; import { graphql } from "../../gql";
import { Link } from "../../routing"; import { Link } from "../../routing";
@@ -59,15 +60,19 @@ const SessionDetail: React.FC<{
[deviceId, userId], [deviceId, userId],
); );
const result = useAtomValue(sessionFamilyAtomWithProps); const result = useAtomValue(sessionFamilyAtomWithProps);
const { t } = useTranslation();
const session = result.data?.session; const session = result.data?.session;
if (!session) { if (!session) {
return ( return (
<Alert type="critical" title={`Cannot find session: ${deviceId}`}> <Alert
This session does not exist, or is no longer active. type="critical"
title={t("frontend.session_detail.alert.title", { deviceId })}
>
{t("frontend.session_detail.alert.text")}
<Link kind="button" route={{ type: "sessions-overview" }}> <Link kind="button" route={{ type: "sessions-overview" }}>
Go back {t("frontend.session_detail.alert.button")}
</Link> </Link>
</Alert> </Alert>
); );

View File

@@ -14,6 +14,7 @@
import { Alert } from "@vector-im/compound-web"; import { Alert } from "@vector-im/compound-web";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { FragmentType, useFragment, graphql } from "../../gql"; import { FragmentType, useFragment, graphql } from "../../gql";
import { Link } from "../../routing"; import { Link } from "../../routing";
@@ -34,6 +35,7 @@ const UnverifiedEmailAlert: React.FC<{
}> = ({ user }) => { }> = ({ user }) => {
const data = useFragment(UNVERIFIED_EMAILS_FRAGMENT, user); const data = useFragment(UNVERIFIED_EMAILS_FRAGMENT, user);
const [dismiss, setDismiss] = useState(false); const [dismiss, setDismiss] = useState(false);
const { t } = useTranslation();
const doDismiss = (): void => setDismiss(true); const doDismiss = (): void => setDismiss(true);
@@ -48,13 +50,15 @@ const UnverifiedEmailAlert: React.FC<{
return ( return (
<Alert <Alert
type="critical" type="critical"
title="Unverified email" title={t("frontend.unverified_email_alert.title")}
onClose={doDismiss} onClose={doDismiss}
className={styles.alert} className={styles.alert}
> >
You have {data.unverifiedEmails.totalCount} unverified email address(es).{" "} {t("frontend.unverified_email_alert.text", {
count: data.unverifiedEmails.totalCount,
})}{" "}
<Link kind="button" route={{ type: "profile" }}> <Link kind="button" route={{ type: "profile" }}>
Review and verify {t("frontend.unverified_email_alert.button")}
</Link> </Link>
</Alert> </Alert>
); );

View File

@@ -29,9 +29,7 @@ exports[`<UnverifiedEmailAlert /> > renders a warning when there are unverified
Unverified email Unverified email
</p> </p>
<p> <p>
You have You have 2 unverified email addresses.
2
unverified email address(es).
<a <a
class="_linkButton_b80ad8" class="_linkButton_b80ad8"

View File

@@ -17,7 +17,8 @@ import { Body } from "@vector-im/compound-web";
import { atom, useSetAtom } from "jotai"; import { atom, useSetAtom } from "jotai";
import { atomFamily } from "jotai/utils"; import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql"; import { atomWithMutation } from "jotai-urql";
import { useTransition, ComponentProps } from "react"; import { useTransition, ComponentProps, ReactNode } from "react";
import { Translation, useTranslation } from "react-i18next";
import { FragmentType, graphql, useFragment } from "../../gql"; import { FragmentType, graphql, useFragment } from "../../gql";
import { Link } from "../../routing"; import { Link } from "../../routing";
@@ -90,19 +91,24 @@ const DeleteButton: React.FC<{ disabled?: boolean; onClick?: () => void }> = ({
disabled, disabled,
onClick, onClick,
}) => ( }) => (
<button <Translation>
disabled={disabled} {(t): ReactNode => (
onClick={onClick} <button
className={styles.userEmailDelete} disabled={disabled}
title="Remove email address" onClick={onClick}
> className={styles.userEmailDelete}
<IconDelete className={styles.userEmailDeleteIcon} /> title={t("frontend.user_email.delete_button_title")}
</button> >
<IconDelete className={styles.userEmailDeleteIcon} />
</button>
)}
</Translation>
); );
const DeleteButtonWithConfirmation: React.FC< const DeleteButtonWithConfirmation: React.FC<
ComponentProps<typeof DeleteButton> ComponentProps<typeof DeleteButton>
> = ({ onClick, ...rest }) => { > = ({ onClick, ...rest }) => {
const { t } = useTranslation();
const onConfirm = (): void => { const onConfirm = (): void => {
onClick?.(); onClick?.();
}; };
@@ -117,7 +123,9 @@ const DeleteButtonWithConfirmation: React.FC<
onDeny={onDeny} onDeny={onDeny}
onConfirm={onConfirm} onConfirm={onConfirm}
> >
<Body>Are you sure you want to remove this email?</Body> <Body>
{t("frontend.user_email.delete_button_confirmation_modal.body")}
</Body>
</ConfirmationModal> </ConfirmationModal>
</> </>
); );
@@ -134,6 +142,7 @@ const UserEmail: React.FC<{
const data = useFragment(FRAGMENT, email); const data = useFragment(FRAGMENT, email);
const setPrimaryEmail = useSetAtom(setPrimaryEmailFamily(data.id)); const setPrimaryEmail = useSetAtom(setPrimaryEmailFamily(data.id));
const removeEmail = useSetAtom(removeEmailFamily(data.id)); const removeEmail = useSetAtom(removeEmailFamily(data.id));
const { t } = useTranslation();
const onRemoveClick = (): void => { const onRemoveClick = (): void => {
startTransition(() => { startTransition(() => {
@@ -155,7 +164,11 @@ const UserEmail: React.FC<{
return ( return (
<div className={styles.userEmail}> <div className={styles.userEmail}>
{isPrimary ? <Body>Primary email</Body> : <Body>Email</Body>} {isPrimary ? (
<Body>{t("frontend.user_email.primary_email")}</Body>
) : (
<Body>{t("frontend.user_email.email")}</Body>
)}
<div className={styles.userEmailLine}> <div className={styles.userEmailLine}>
<div className={styles.userEmailField}>{data.email}</div> <div className={styles.userEmailField}>{data.email}</div>
@@ -170,14 +183,17 @@ const UserEmail: React.FC<{
disabled={pending} disabled={pending}
onClick={onSetPrimaryClick} onClick={onSetPrimaryClick}
> >
Make primary {t("frontend.user_email.make_primary_button")}
</button> </button>
)} )}
{!data.confirmedAt && ( {!data.confirmedAt && (
<div> <div>
<span className={styles.userEmailUnverified}>Unverified</span> |{" "} <span className={styles.userEmailUnverified}>
{t("frontend.user_email.unverified")}
</span>{" "}
|{" "}
<Link kind="button" route={{ type: "verify-email", id: data.id }}> <Link kind="button" route={{ type: "verify-email", id: data.id }}>
Retry verification {t("frontend.user_email.retry_button")}
</Link> </Link>
</div> </div>
)} )}

View File

@@ -16,6 +16,7 @@ import { Heading, Body, Avatar } from "@vector-im/compound-web";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { atomFamily } from "jotai/utils"; import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql"; import { atomWithQuery } from "jotai-urql";
import { useTranslation } from "react-i18next";
import { graphql } from "../gql"; import { graphql } from "../gql";
@@ -48,6 +49,7 @@ export const userGreetingFamily = atomFamily((userId: string) => {
const UserGreeting: React.FC<{ userId: string }> = ({ userId }) => { const UserGreeting: React.FC<{ userId: string }> = ({ userId }) => {
const result = useAtomValue(userGreetingFamily(userId)); const result = useAtomValue(userGreetingFamily(userId));
const { t } = useTranslation();
if (result.data?.user) { if (result.data?.user) {
const user = result.data.user; const user = result.data.user;
@@ -71,7 +73,7 @@ const UserGreeting: React.FC<{ userId: string }> = ({ userId }) => {
); );
} }
return <>Failed to load user</>; return <>{t("frontend.user_greeting.error")}</>;
}; };
export default UserGreeting; export default UserGreeting;

View File

@@ -23,6 +23,7 @@ import {
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { atomWithMutation } from "jotai-urql"; import { atomWithMutation } from "jotai-urql";
import { useRef, useTransition } from "react"; import { useRef, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { graphql } from "../../gql"; import { graphql } from "../../gql";
@@ -49,6 +50,7 @@ const AddEmailForm: React.FC<{
const fieldRef = useRef<HTMLInputElement>(null); const fieldRef = useRef<HTMLInputElement>(null);
const [addEmailResult, addEmail] = useAtom(addUserEmailAtom); const [addEmailResult, addEmail] = useAtom(addUserEmailAtom);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const { t } = useTranslation();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => { const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault(); e.preventDefault();
@@ -87,20 +89,29 @@ const AddEmailForm: React.FC<{
<> <>
<Root ref={formRef} onSubmit={handleSubmit}> <Root ref={formRef} onSubmit={handleSubmit}>
{emailExists && ( {emailExists && (
<Alert type="info" title="Email already exists"> <Alert
The entered email is already added to this account type="info"
title={t("frontend.add_email_form.email_exists_alert.title")}
>
{t("frontend.add_email_form.email_exists_alert.text")}
</Alert> </Alert>
)} )}
{emailInvalid && ( {emailInvalid && (
<Alert type="critical" title="Invalid email"> <Alert
The entered email is invalid type="critical"
title={t("frontend.add_email_form.email_invalid_alert.title")}
>
{t("frontend.add_email_form.email_invalid_alert.text")}
</Alert> </Alert>
)} )}
{emailDenied && ( {emailDenied && (
<Alert type="critical" title="Email denied by policy"> <Alert
The entered email is not allowed by the server policy. type="critical"
title={t("frontend.add_email_form.email_denied_alert.title")}
>
{t("frontend.add_email_form.email_denied_alert.text")}
<ul> <ul>
{violations.map((violation, index) => ( {violations.map((violation, index) => (
<li key={index}> {violation}</li> <li key={index}> {violation}</li>
@@ -110,11 +121,11 @@ const AddEmailForm: React.FC<{
)} )}
<Field name="email" className="my-2"> <Field name="email" className="my-2">
<Label>Add email</Label> <Label>{t("frontend.add_email_form.email_field_label")}</Label>
<Control disabled={pending} inputMode="email" ref={fieldRef} /> <Control disabled={pending} inputMode="email" ref={fieldRef} />
</Field> </Field>
<Submit size="sm" disabled={pending}> <Submit size="sm" disabled={pending}>
Add {t("common.add")}
</Submit> </Submit>
</Root> </Root>
</> </>

View File

@@ -17,6 +17,7 @@ import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { atomFamily } from "jotai/utils"; import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql"; import { atomWithQuery } from "jotai-urql";
import { useTransition } from "react"; import { useTransition } from "react";
import { useTranslation } from "react-i18next";
import { graphql } from "../../gql"; import { graphql } from "../../gql";
import { PageInfo } from "../../gql/graphql"; import { PageInfo } from "../../gql/graphql";
@@ -135,6 +136,7 @@ const UserEmailList: React.FC<{
const [primaryEmailId, refreshPrimaryEmailId] = useAtom( const [primaryEmailId, refreshPrimaryEmailId] = useAtom(
primaryEmailIdFamily(userId), primaryEmailIdFamily(userId),
); );
const { t } = useTranslation();
const paginate = (pagination: Pagination): void => { const paginate = (pagination: Pagination): void => {
startTransition(() => { startTransition(() => {
@@ -159,9 +161,12 @@ const UserEmailList: React.FC<{
return ( return (
<BlockList> <BlockList>
<H3>Emails</H3> <H3>{t("frontend.user_email_list.heading")}</H3>
{showNoPrimaryEmailAlert && ( {showNoPrimaryEmailAlert && (
<Alert type="critical" title="No primary email address" /> <Alert
type="critical"
title={t("frontend.user_email_list.no_primary_email_alert")}
/>
)} )}
{result.data?.user?.emails?.edges?.map((edge) => ( {result.data?.user?.emails?.edges?.map((edge) => (
<UserEmail <UserEmail

View File

@@ -24,6 +24,7 @@ import { useAtomValue, useAtom, useSetAtom, atom } from "jotai";
import { atomFamily } from "jotai/utils"; import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql"; import { atomWithMutation } from "jotai-urql";
import { useState, useEffect, ChangeEventHandler } from "react"; import { useState, useEffect, ChangeEventHandler } from "react";
import { useTranslation } from "react-i18next";
import { graphql } from "../../gql"; import { graphql } from "../../gql";
import LoadingSpinner from "../LoadingSpinner/LoadingSpinner"; import LoadingSpinner from "../LoadingSpinner/LoadingSpinner";
@@ -83,6 +84,7 @@ const UserName: React.FC<{ userId: string }> = ({ userId }) => {
const [fieldValue, setFieldValue] = useState(displayName); const [fieldValue, setFieldValue] = useState(displayName);
const userGreeting = useSetAtom(userGreetingFamily(userId)); const userGreeting = useSetAtom(userGreetingFamily(userId));
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
setFieldValue(displayName); setFieldValue(displayName);
@@ -131,7 +133,7 @@ const UserName: React.FC<{ userId: string }> = ({ userId }) => {
return ( return (
<Root onSubmit={onSubmit} className={styles.form}> <Root onSubmit={onSubmit} className={styles.form}>
<Field name="displayname"> <Field name="displayname">
<Label>Display Name</Label> <Label>{t("frontend.user_name.display_name_field_label")}</Label>
<Control <Control
value={fieldValue} value={fieldValue}
onChange={onChange} onChange={onChange}
@@ -140,7 +142,7 @@ const UserName: React.FC<{ userId: string }> = ({ userId }) => {
/> />
</Field> </Field>
{!inProgress && errorMessage && ( {!inProgress && errorMessage && (
<Alert type="critical" title="Error"> <Alert type="critical" title={t("common.error")}>
{errorMessage} {errorMessage}
</Alert> </Alert>
)} )}
@@ -152,7 +154,8 @@ const UserName: React.FC<{ userId: string }> = ({ userId }) => {
size="sm" size="sm"
type="submit" type="submit"
> >
{!!inProgress && <LoadingSpinner inline />}Save {!!inProgress && <LoadingSpinner inline />}
{t("action.save")}
</Button> </Button>
</Root> </Root>
); );

View File

@@ -17,6 +17,7 @@ import { atom, useAtomValue, useSetAtom } from "jotai";
import { atomFamily } from "jotai/utils"; import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql"; import { atomWithQuery } from "jotai-urql";
import { useTransition } from "react"; import { useTransition } from "react";
import { useTranslation } from "react-i18next";
import { mapQueryAtom } from "../../atoms"; import { mapQueryAtom } from "../../atoms";
import { graphql } from "../../gql"; import { graphql } from "../../gql";
@@ -120,9 +121,10 @@ const AppSessionsList: React.FC<{ userId: string }> = ({ userId }) => {
const result = useAtomValue(appSessionListFamily(userId)); const result = useAtomValue(appSessionListFamily(userId));
const setPagination = useSetAtom(currentPaginationAtom); const setPagination = useSetAtom(currentPaginationAtom);
const [prevPage, nextPage] = useAtomValue(paginationFamily(userId)); const [prevPage, nextPage] = useAtomValue(paginationFamily(userId));
const { t } = useTranslation();
const appSessions = unwrap(result); const appSessions = unwrap(result);
if (!appSessions) return <>Failed to load app sessions</>; if (!appSessions) return <>{t("frontend.app_sessions_list.error")}</>;
const paginate = (pagination: Pagination): void => { const paginate = (pagination: Pagination): void => {
startTransition(() => { startTransition(() => {
@@ -133,7 +135,7 @@ const AppSessionsList: React.FC<{ userId: string }> = ({ userId }) => {
return ( return (
<BlockList> <BlockList>
<header> <header>
<H5>Apps</H5> <H5>{t("frontend.app_sessions_list.heading")}</H5>
</header> </header>
{appSessions.edges.map((session) => { {appSessions.edges.map((session) => {
const type = session.node.__typename; const type = session.node.__typename;

View File

@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
import { Body, H5 } from "@vector-im/compound-web"; import { Body, H5 } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { FragmentType, graphql, useFragment } from "../../gql"; import { FragmentType, graphql, useFragment } from "../../gql";
import { Link } from "../../routing"; import { Link } from "../../routing";
@@ -34,22 +35,20 @@ const BrowserSessionsOverview: React.FC<{
user: FragmentType<typeof FRAGMENT>; user: FragmentType<typeof FRAGMENT>;
}> = ({ user }) => { }> = ({ user }) => {
const data = useFragment(FRAGMENT, user); const data = useFragment(FRAGMENT, user);
const { t } = useTranslation();
// allow this until we get i18n
const pluraliseSession = (count: number): string =>
count === 1 ? "session" : "sessions";
return ( return (
<Block className={styles.sessionListBlock}> <Block className={styles.sessionListBlock}>
<div className={styles.sessionListBlockInfo}> <div className={styles.sessionListBlockInfo}>
<H5>Browsers</H5> <H5>{t("frontend.browser_sessions_overview.heading")}</H5>
<Body> <Body>
{data.browserSessions.totalCount} active{" "} {t("frontend.browser_sessions_overview.body", {
{pluraliseSession(data.browserSessions.totalCount)} count: data.browserSessions.totalCount,
})}
</Body> </Body>
</div> </div>
<Link kind="button" route={{ type: "browser-session-list" }}> <Link kind="button" route={{ type: "browser-session-list" }}>
View all {t("frontend.browser_sessions_overview.view_all_button")}
</Link> </Link>
</Block> </Block>
); );

View File

@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
import { H3 } from "@vector-im/compound-web"; import { H3 } from "@vector-im/compound-web";
import { useTranslation } from "react-i18next";
import { FragmentType, useFragment } from "../../gql"; import { FragmentType, useFragment } from "../../gql";
import BlockList from "../BlockList"; import BlockList from "../BlockList";
@@ -24,10 +25,11 @@ const UserSessionsOverview: React.FC<{
user: FragmentType<typeof FRAGMENT>; user: FragmentType<typeof FRAGMENT>;
}> = ({ user }) => { }> = ({ user }) => {
const data = useFragment(FRAGMENT, user); const data = useFragment(FRAGMENT, user);
const { t } = useTranslation();
return ( return (
<BlockList> <BlockList>
<H3>Where you're signed in</H3> <H3>{t("frontend.user_sessions_overview.heading")}</H3>
<BrowserSessionsOverview user={user} /> <BrowserSessionsOverview user={user} />
<AppSessionsList userId={data.id} /> <AppSessionsList userId={data.id} />
</BlockList> </BlockList>

View File

@@ -16,10 +16,7 @@ exports[`BrowserSessionsOverview > renders with no browser sessions 1`] = `
<p <p
class="_font-body-md-regular_1jx6b_59" class="_font-body-md-regular_1jx6b_59"
> >
0 0 active sessions
active
sessions
</p> </p>
</div> </div>
<a <a
@@ -48,10 +45,7 @@ exports[`BrowserSessionsOverview > renders with sessions 1`] = `
<p <p
class="_font-body-md-regular_1jx6b_59" class="_font-body-md-regular_1jx6b_59"
> >
2 2 active sessions
active
sessions
</p> </p>
</div> </div>
<a <a

View File

@@ -29,6 +29,7 @@ import { useSetAtom, atom, useAtom } from "jotai";
import { atomFamily } from "jotai/utils"; import { atomFamily } from "jotai/utils";
import { atomWithMutation } from "jotai-urql"; import { atomWithMutation } from "jotai-urql";
import { useEffect, useRef, useTransition } from "react"; import { useEffect, useRef, useTransition } from "react";
import { Trans, useTranslation } from "react-i18next";
import { FragmentType, graphql, useFragment } from "../../gql"; import { FragmentType, graphql, useFragment } from "../../gql";
import { routeAtom, useNavigationLink } from "../../routing"; import { routeAtom, useNavigationLink } from "../../routing";
@@ -137,6 +138,7 @@ const VerifyEmail: React.FC<{
); );
const setRoute = useSetAtom(routeAtom); const setRoute = useSetAtom(routeAtom);
const fieldRef = useRef<HTMLInputElement>(null); const fieldRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>): void => { const onFormSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault(); e.preventDefault();
@@ -174,24 +176,32 @@ const VerifyEmail: React.FC<{
resendVerificationEmailResult.data?.sendVerificationEmail.status === "SENT"; resendVerificationEmailResult.data?.sendVerificationEmail.status === "SENT";
const invalidCode = const invalidCode =
verifyEmailResult.data?.verifyEmail.status === "INVALID_CODE"; verifyEmailResult.data?.verifyEmail.status === "INVALID_CODE";
const { email: codeEmail } = data;
return ( return (
<div className={styles.block}> <div className={styles.block}>
<header className={styles.header}> <header className={styles.header}>
<IconSend className={styles.icon} /> <IconSend className={styles.icon} />
<H1>Verify your email</H1> <H1>{t("frontend.verify_email.heading")}</H1>
<Text size="lg" className={styles.tagline}> <Text size="lg" className={styles.tagline}>
Enter the 6-digit code sent to{" "} <Trans i18nKey="frontend.verify_email.enter_code_prompt">
<Text as="span" size="lg" weight="semibold"> Enter the 6-digit code sent to{" "}
{data.email} <Text as="span" size="lg" weight="semibold">
</Text> {{ codeEmail }}
</Text>
</Trans>
</Text> </Text>
</header> </header>
<Form onSubmit={onFormSubmit} className={styles.form}> <Form onSubmit={onFormSubmit} className={styles.form}>
{invalidCode && <Alert type="critical" title="Invalid code" />} {invalidCode && (
<Alert
type="critical"
title={t("frontend.verify_email.invalid_code_alert")}
/>
)}
<Field name="code" serverInvalid={invalidCode}> <Field name="code" serverInvalid={invalidCode}>
<Label>6-digit code</Label> <Label>{t("frontend.verify_email.code_field_label")}</Label>
<Control <Control
ref={fieldRef} ref={fieldRef}
placeholder="xxxxxx" placeholder="xxxxxx"
@@ -205,7 +215,7 @@ const VerifyEmail: React.FC<{
disabled={pending} disabled={pending}
className={styles.submitButton} className={styles.submitButton}
> >
Continue {t("action.continue")}
</Submit> </Submit>
<Button <Button
kind="secondary" kind="secondary"

67
frontend/src/i18n.ts Normal file
View File

@@ -0,0 +1,67 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// 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 * as i18n from "i18next";
import { InitOptions } from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import I18NextHttpBackend, { HttpBackendOptions } from "i18next-http-backend";
import { initReactI18next } from "react-i18next";
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
// {
// "../locales/en.json": "/whatever/assets/root/locales/en-aabbcc.json",
// ...
// }
const locales = import.meta.glob("../locales/*.json", {
as: "url",
eager: true,
});
const getLocaleUrl = (name: string): string =>
locales[`../locales/${name}.json`];
const supportedLngs = Object.keys(locales).map(
(url) => url.match(/\/([^/]+)\.json$/)![1],
);
i18n
.use(I18NextHttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en",
keySeparator: ".",
pluralSeparator: ":",
supportedLngs,
interpolation: {
escapeValue: false, // React has built-in XSS protections
},
backend: {
crossDomain: true,
loadPath(lngs: string[], _ns: string[]): string {
return getLocaleUrl(lngs[0]);
},
requestOptions: {
credentials: "same-origin",
},
},
} satisfies InitOptions<HttpBackendOptions>);
import.meta.hot?.on("locales-update", () => {
i18n.reloadResources().then(() => {
i18n.changeLanguage(i18n.default.language);
});
});
export default i18n;

View File

@@ -22,6 +22,7 @@ import Layout from "./components/Layout";
import LoadingScreen from "./components/LoadingScreen"; import LoadingScreen from "./components/LoadingScreen";
import LoadingSpinner from "./components/LoadingSpinner"; import LoadingSpinner from "./components/LoadingSpinner";
import { Router } from "./routing"; import { Router } from "./routing";
import "./i18n";
import "./main.css"; import "./main.css";
createRoot(document.getElementById("root") as HTMLElement).render( createRoot(document.getElementById("root") as HTMLElement).render(

View File

@@ -15,6 +15,7 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { atomFamily } from "jotai/utils"; import { atomFamily } from "jotai/utils";
import { atomWithQuery } from "jotai-urql"; import { atomWithQuery } from "jotai-urql";
import { useTranslation } from "react-i18next";
import { mapQueryAtom } from "../atoms"; import { mapQueryAtom } from "../atoms";
import ErrorBoundary from "../components/ErrorBoundary"; import ErrorBoundary from "../components/ErrorBoundary";
@@ -47,10 +48,12 @@ const verifyEmailFamily = atomFamily((id: string) => {
const VerifyEmail: React.FC<{ id: string }> = ({ id }) => { const VerifyEmail: React.FC<{ id: string }> = ({ id }) => {
const result = useAtomValue(verifyEmailFamily(id)); const result = useAtomValue(verifyEmailFamily(id));
const { t } = useTranslation();
if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />; if (isErr(result)) return <GraphQLError error={unwrapErr(result)} />;
const email = unwrapOk(result); const email = unwrapOk(result);
if (email == null) return <>Unknown email</>; if (email == null) return <>{t("frontend.verify_email.unknown_email")}</>;
return ( return (
<ErrorBoundary> <ErrorBoundary>

View File

@@ -14,6 +14,7 @@
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import LoadingSpinner from "../components/LoadingSpinner"; import LoadingSpinner from "../components/LoadingSpinner";
import BrowserSession from "../pages/BrowserSession"; import BrowserSession from "../pages/BrowserSession";
@@ -56,6 +57,7 @@ const unknownRoute = (route: never): never => {
const Router: React.FC = () => { const Router: React.FC = () => {
const [route, redirecting] = useRouteWithRedirect(); const [route, redirecting] = useRouteWithRedirect();
const { t } = useTranslation();
if (redirecting) { if (redirecting) {
return <LoadingSpinner />; return <LoadingSpinner />;
@@ -77,7 +79,13 @@ const Router: React.FC = () => {
case "verify-email": case "verify-email":
return <VerifyEmail id={route.id} />; return <VerifyEmail id={route.id} />;
case "unknown": case "unknown":
return <>Unknown route {JSON.stringify(route.segments)}</>; return (
<>
{t("frontend.unknown_route", {
route: JSON.stringify(route.segments),
})}
</>
);
default: default:
unknownRoute(route); unknownRoute(route);
} }

View File

@@ -7,6 +7,7 @@
"DOM.Iterable", "DOM.Iterable",
"ESNext" "ESNext"
], ],
"types": ["vite/client"],
"allowJs": false, "allowJs": false,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": false, "esModuleInterop": false,
@@ -22,6 +23,7 @@
}, },
"include": [ "include": [
"src", "src",
"locales",
".storybook/preview.tsx" ".storybook/preview.tsx"
], ],
"references": [ "references": [

View File

@@ -4,16 +4,19 @@
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Node", "moduleResolution": "Node",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"allowJs": true "allowJs": true,
"resolveJsonModule": true
}, },
"include": [ "include": [
".storybook/main.ts", ".storybook/main.ts",
"vite.config.ts", "vite.config.ts",
"vitest.global-setup.ts", "vitest.global-setup.ts",
"vitest.i18n-setup.ts",
".eslintrc.cjs", ".eslintrc.cjs",
"postcss.config.cjs", "postcss.config.cjs",
"tailwind.config.cjs", "tailwind.config.cjs",
"tailwind.templates.config.cjs", "tailwind.templates.config.cjs",
"codegen.ts" "codegen.ts",
"i18next-parser.config.ts"
] ]
} }

View File

@@ -15,12 +15,27 @@
import { resolve } from "path"; import { resolve } from "path";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { PluginOption } from "vite";
import compression from "vite-plugin-compression"; import compression from "vite-plugin-compression";
import codegen from "vite-plugin-graphql-codegen"; import codegen from "vite-plugin-graphql-codegen";
import manifestSRI from "vite-plugin-manifest-sri"; import manifestSRI from "vite-plugin-manifest-sri";
import svgr from "vite-plugin-svgr"; import svgr from "vite-plugin-svgr";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
function i18nHotReload(): PluginOption {
return {
name: "i18n-hot-reload",
handleHotUpdate({ file, server }): void {
if (file.includes("locales") && file.endsWith(".json")) {
console.log("Locale file updated");
server.ws.send({
type: "custom",
event: "locales-update",
});
}
},
};
}
export default defineConfig((env) => ({ export default defineConfig((env) => ({
base: "./", base: "./",
@@ -113,6 +128,8 @@ export default defineConfig((env) => ({
algorithm: "deflate", algorithm: "deflate",
ext: ".zz", ext: ".zz",
}), }),
i18nHotReload(),
], ],
server: { server: {
@@ -126,6 +143,7 @@ export default defineConfig((env) => ({
test: { test: {
globalSetup: "./vitest.global-setup.ts", globalSetup: "./vitest.global-setup.ts",
setupFiles: "./vitest.i18n-setup.ts",
coverage: { coverage: {
provider: "v8", provider: "v8",
src: ["./src/"], src: ["./src/"],

View File

@@ -0,0 +1,36 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// 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 * as i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { beforeEach } from "vitest";
import EN from "./locales/en.json";
beforeEach(() => {
i18n.use(initReactI18next).init({
fallbackLng: "en",
keySeparator: ".",
pluralSeparator: ":",
interpolation: {
escapeValue: false, // React has built-in XSS protections
},
lng: "en",
resources: {
en: {
translation: EN,
},
},
});
});

View File

@@ -3,13 +3,26 @@
"readKey": "a7633943728394577700-c0f9f1df124fbdbe76b2c7dfcbfe574476d56509e0da6180e2a321dbbe056c40", "readKey": "a7633943728394577700-c0f9f1df124fbdbe76b2c7dfcbfe574476d56509e0da6180e2a321dbbe056c40",
"upload": { "upload": {
"type": "json", "type": "json",
"files": "translations/en.json", "files": [{
"features": [ "file": "file.json",
"arb_metadata", "pattern": "translations/en.json",
"plural_object" "features": [
] "arb_metadata",
"plural_object"
]
}, {
"file": "frontend.json",
"pattern": "frontend/locales/en.json",
"features": ["plural_postfix_dd"]
}]
}, },
"download": { "download": {
"files": "translations/${lang}.json" "files": [{
"conditions": "equals: ${file}, file.json",
"output": "translations/${lang}.json"
}, {
"conditions": "equals: ${file}, frontend.json",
"output": "frontend/locales/${lang}.json"
}]
} }
} }