Wire up i18n for the React frontend (#1962)
Co-authored-by: Quentin Gliech <quenting@element.io>
This commit is contained in:
committed by
GitHub
parent
7207ebdc63
commit
af1a960c2f
10
.editorconfig
Normal file
10
.editorconfig
Normal 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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")))
|
||||||
}
|
}
|
||||||
|
|||||||
36
frontend/i18next-parser.config.ts
Normal file
36
frontend/i18next-parser.config.ts
Normal 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
140
frontend/locales/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1471
frontend/package-lock.json
generated
1471
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
27
frontend/src/@types/i18next.d.ts
vendored
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
67
frontend/src/i18n.ts
Normal 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;
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/"],
|
||||||
|
|||||||
36
frontend/vitest.i18n-setup.ts
Normal file
36
frontend/vitest.i18n-setup.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user