Compare commits

...

36 commits

Author SHA1 Message Date
lewisakura a1ef2c4010
Merge branch 'dev' into feat/translation 2024-07-05 21:07:34 +01:00
lewisakura 946738e069
chore: cloud settings i18n 2024-07-01 13:16:36 +01:00
lewisakura 77d059dd8f
chore: fix i18n ally 2024-07-01 13:13:16 +01:00
lewisakura d216bdb6bb
chore: add reauthorise key 2024-07-01 13:09:21 +01:00
lewisakura 3f3d5379cf
Merge branch 'dev' into feat/translation 2024-07-01 13:08:29 +01:00
Lewis Crichton 70cc39b5c6
chore: remove unnecessary assertions 2024-06-24 19:01:57 +01:00
Lewis Crichton 2cd94221cd
chore: even better if condition 2024-06-24 19:00:54 +01:00
Lewis Crichton faea1c8224
chore: remove unnecessary if 2024-06-24 19:00:26 +01:00
Lewis Crichton e55c6f99e2
fix: objects counted as plural incorrectly 2024-06-23 21:18:43 +01:00
Lewis Crichton cc7675eccb
chore: boop 2024-06-23 21:14:58 +01:00
Lewis Crichton fcc86370f9
Merge branch 'feat/translation' of ssh://github.com/Vendicated/Vencord into feat/translation 2024-06-23 21:11:46 +01:00
Lewis Crichton cc22c15bcb
merge: dev 2024-06-23 21:11:31 +01:00
lewisakura bbd0729ed6
Merge branch 'dev' into feat/translation 2024-06-19 08:43:47 +01:00
Lewis Crichton 51770e96f2
feat: translate clienttheme plugin as test 2024-06-11 23:12:13 +01:00
Lewis Crichton b92a21ac7d
chore: BIKESHEDDING IS OVER, T PREVAILS 2024-06-11 22:31:06 +01:00
Lewis Crichton 8c4aed699d
chore: better path getter func 2024-06-11 22:25:09 +01:00
Lewis Crichton 26c21c2de8
ci: parse $t calls as valid descriptions 2024-06-11 22:21:19 +01:00
Lewis Crichton 15394e106a
chore: fix showConnections?? 2024-06-11 22:03:21 +01:00
lewisakura 9cc9c57f11
Merge branch 'dev' into feat/translation 2024-06-11 22:01:41 +01:00
Lewis Crichton 38624a8661
chore: use Object.create for better semantics 2024-06-09 21:57:54 +01:00
Lewis Crichton ec5f9f78d3
feat: testing top levels 2024-06-09 21:50:18 +01:00
Lewis Crichton ef028edc0d
feat: top level hax 2024-06-09 21:47:03 +01:00
Lewis Crichton a4d4d981e0
chore: more localisation 2024-06-09 10:59:03 +01:00
Lewis Crichton 22e5396684
chore: restructure locale keys 2024-06-08 19:51:56 +01:00
Lewis Crichton d1242633e5
feat: cached translations, use translation coremod, add terrible german 2024-06-08 19:23:56 +01:00
Lewis Crichton becf4a4c4f
Merge branch 'feat/translation' of ssh://github.com/Vendicated/Vencord into feat/translation 2024-06-08 19:03:53 +01:00
lewisakura c5c0732ffe
Merge branch 'dev' into feat/translation 2024-06-08 19:03:48 +01:00
Lewis Crichton f1e1f9cd44
Merge branch 'dev' of ssh://github.com/Vendicated/Vencord into feat/translation 2024-06-08 19:02:50 +01:00
Lewis Crichton d349689c6a
feat: 0 always uses 'zero' plural rule 2024-06-08 18:21:02 +01:00
Lewis Crichton 16549695d1
feat: initial implementation of translation component 2024-06-08 18:20:12 +01:00
Masterjoona caf1779be3
fix showconnections in new profiles (#2567)
Co-authored-by: Vendicated <vendicated@riseup.net>
2024-06-08 16:33:58 +00:00
Lewis Crichton 7ed73b49e5
feat: start translating 2024-06-03 18:31:56 +01:00
Lewis Crichton 9c9a02f9bf
feat: plurality 2024-06-03 17:39:17 +01:00
Lewis Crichton c0111169b8
feat: translation v2 2024-06-01 11:50:18 +01:00
Lewis Crichton 2dc0c20462
Merge branch 'dev' of ssh://github.com/Vendicated/Vencord into dev 2024-06-01 10:37:03 +01:00
Lewis Crichton 42307ccc0e
fix: Vencord_cloudSecret check (#2077)
finally got around to fixing it - `null` is never a valid return value,
it's `undefined` 🤦
2023-12-28 02:02:49 +00:00
30 changed files with 679 additions and 188 deletions

View file

@ -4,6 +4,7 @@
"EditorConfig.EditorConfig",
"GregorBiswanger.json2ts",
"stylelint.vscode-stylelint",
"Vendicated.vencord-companion"
"Vendicated.vencord-companion",
"lokalise.i18n-ally"
]
}

16
.vscode/i18n-ally-custom-framework.yml vendored Normal file
View file

@ -0,0 +1,16 @@
languageIds:
- javascript
- typescript
- javascriptreact
- typescriptreact
usageMatchRegex:
- "[^\\w\\d]t\\(['\"`]({key})['\"`]"
- "<Translate ?.* i18nKey=\\{?['\"`]({key})['\"`]"
refactorTemplates:
- "t(\"$1\")"
- "{t(\"$1\")}"
- "<Translate i18nKey=\"$1\" />"
monopoly: true

View file

@ -21,5 +21,11 @@
"domain": "codeberg.org",
"type": "Gitea"
}
]
],
"i18n-ally.namespace": true,
"i18n-ally.localesPaths": ["./translations"],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.extract.keygenStyle": "camelCase",
"i18n-ally.sortKeys": true,
"i18n-ally.keystyle": "nested"
}

View file

@ -34,6 +34,7 @@
"testTsc": "tsc --noEmit"
},
"dependencies": {
"@fluent/langneg": "^0.7.0",
"@sapphi-red/web-noise-suppressor": "0.3.3",
"@vap/core": "0.0.12",
"@vap/shiki": "0.10.5",

View file

@ -16,6 +16,9 @@ importers:
.:
dependencies:
'@fluent/langneg':
specifier: ^0.7.0
version: 0.7.0
'@sapphi-red/web-noise-suppressor':
specifier: 0.3.3
version: 0.3.3
@ -385,6 +388,10 @@ packages:
resolution: {integrity: sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
'@fluent/langneg@0.7.0':
resolution: {integrity: sha512-StAM0vgsD1QK+nFikaKs9Rxe3JGNipiXrpmemNGwM4gWERBXPe9gjzsBoKjgBgq1Vyiy+xy/C652QIWY+MPyYw==}
engines: {node: '>=14.0.0', npm: '>=7.0.0'}
'@humanwhocodes/config-array@0.11.10':
resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
engines: {node: '>=10.10.0'}
@ -2665,6 +2672,8 @@ snapshots:
'@eslint/js@8.46.0': {}
'@fluent/langneg@0.7.0': {}
'@humanwhocodes/config-array@0.11.10':
dependencies:
'@humanwhocodes/object-schema': 1.2.1

View file

@ -292,6 +292,39 @@ export const stylePlugin = {
}
};
/**
* @type {import("esbuild").Plugin}
*/
export const translationPlugin = {
name: "translation-plugin",
setup: ({ onResolve, onLoad }) => {
const filter = /^~translations$/;
onResolve({ filter }, ({ path }) => ({
namespace: "translations", path
}));
onLoad({ filter, namespace: "translations" }, async () => {
const translations = {};
const locales = await readdir("./translations");
for (const locale of locales) {
const translationBundles = await readdir(`./translations/${locale}`);
for (const bundle of translationBundles) {
const name = bundle.replace(/\.json$/, "");
translations[locale] ??= {};
translations[locale][name] = JSON.parse(await readFile(`./translations/${locale}/${bundle}`, "utf-8"));
}
}
return {
contents: `export default ${JSON.stringify(translations)}`,
};
});
}
};
/**
* @type {import("esbuild").BuildOptions}
*/
@ -303,8 +336,8 @@ export const commonOpts = {
sourcemap: watch ? "inline" : "",
legalComments: "linked",
banner,
plugins: [fileUrlPlugin, gitHashPlugin, gitRemotePlugin, stylePlugin],
external: ["~plugins", "~git-hash", "~git-remote", "/assets/*"],
plugins: [fileUrlPlugin, gitHashPlugin, gitRemotePlugin, stylePlugin, translationPlugin],
external: ["~plugins", "~git-hash", "~git-remote", "~translations", "/assets/*"],
inject: ["./scripts/build/inject/react.mjs"],
jsxFactory: "VencordCreateElement",
jsxFragment: "VencordFragment",

View file

@ -20,7 +20,7 @@ import { Dirent, readdirSync, readFileSync, writeFileSync } from "fs";
import { access, readFile } from "fs/promises";
import { join, sep } from "path";
import { normalize as posixNormalize, sep as posixSep } from "path/posix";
import { BigIntLiteral, createSourceFile, Identifier, isArrayLiteralExpression, isCallExpression, isExportAssignment, isIdentifier, isObjectLiteralExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, isVariableStatement, NamedDeclaration, NodeArray, ObjectLiteralExpression, ScriptTarget, StringLiteral, SyntaxKind } from "typescript";
import { BigIntLiteral, CallExpression, createSourceFile, Identifier, isArrayLiteralExpression, isCallExpression, isExportAssignment, isIdentifier, isObjectLiteralExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, isVariableStatement, LiteralExpression, NamedDeclaration, Node, NodeArray, ObjectLiteralExpression, ScriptTarget, StringLiteral, SyntaxKind } from "typescript";
import { getPluginTarget } from "./utils.mjs";
@ -90,6 +90,38 @@ function parseDevs() {
throw new Error("Could not find Devs constant");
}
function isTranslationExpression(node: Node): node is CallExpression {
if (!isCallExpression(node)) return false;
const literal = node.expression as LiteralExpression;
if (literal.text !== "t") return false;
return true;
}
async function getTranslation(node: Node): Promise<string | null> {
if (!isTranslationExpression(node)) return null;
const translationString = node.arguments[0];
if (!isStringLiteral(translationString)) return null;
const splitPath = translationString.text.split(".");
const namespace = splitPath.shift();
const path = splitPath.join(".");
// load the namespace
const bundle = JSON.parse(
await readFile(`./translations/en/${namespace}.json`, "utf-8")
);
const dotProp = (key: string, object: any) =>
key.split(".").reduce((obj, key) => obj?.[key], object);
return dotProp(path, bundle);
}
async function parseFile(fileName: string) {
const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest);
@ -120,10 +152,16 @@ async function parseFile(fileName: string) {
switch (key) {
case "name":
case "description":
if (!isStringLiteral(value)) throw fail(`${key} is not a string literal`);
data[key] = value.text;
break;
case "description":
if (isStringLiteral(value))
data[key] = value.text;
else if (isTranslationExpression(value))
data[key] = (await getTranslation(value))!;
else throw fail(`${key} is not a string literal or a translation function call`);
break;
case "patches":
data.hasPatches = true;
break;

View file

@ -38,6 +38,7 @@ import { patches, PMLogger, startAllPlugins } from "./plugins";
import { localStorage } from "./utils/localStorage";
import { relaunch } from "./utils/native";
import { getCloudSettings, putCloudSettings } from "./utils/settingsSync";
import { t } from "./utils/translation";
import { checkForUpdates, update, UpdateLogger } from "./utils/updater";
import { onceReady } from "./webpack";
import { SettingsRouter } from "./webpack/common";
@ -54,9 +55,8 @@ async function syncSettings() {
) {
// show a notification letting them know and tell them how to fix it
showNotification({
title: "Cloud Integrations",
body: "We've noticed you have cloud integrations enabled in another client! Due to limitations, you will " +
"need to re-authenticate to continue using them. Click here to go to the settings page to do so!",
title: t("vencord.cloudIntegrations"),
body: t("vencord.cloud.integrations.reauthenticate"),
color: "var(--yellow-360)",
onClick: () => SettingsRouter.open("VencordCloud")
});
@ -76,8 +76,8 @@ async function syncSettings() {
// there was an error to notify the user, but besides that we only want to show one notification instead of all
// of the possible ones it has (such as when your settings are newer).
showNotification({
title: "Cloud Settings",
body: "Your settings have been updated! Click here to restart to fully apply changes!",
title: t("vencord.cloudSettings"),
body: t("vencord.cloud.settings.updated"),
color: "var(--green-360)",
onClick: relaunch
});
@ -100,8 +100,8 @@ async function init() {
await update();
if (Settings.autoUpdateNotification)
setTimeout(() => showNotification({
title: "Vencord has been updated!",
body: "Click here to restart",
title: t("vencord.update.updated"),
body: t("vencord.update.clickToRestart"),
permanent: true,
noPersist: true,
onClick: relaunch
@ -110,8 +110,8 @@ async function init() {
}
setTimeout(() => showNotification({
title: "A Vencord update is available!",
body: "Click here to view the update",
title: t("vencord.update.available"),
body: t("vencord.update.clickToView"),
permanent: true,
noPersist: true,
onClick: openUpdaterModal!

View file

@ -19,6 +19,7 @@
import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins";
import { LazyComponent } from "@utils/react";
import { t } from "@utils/translation";
import { React } from "@webpack/common";
import { ErrorCard } from "./ErrorCard";
@ -85,11 +86,11 @@ const ErrorBoundary = LazyComponent(() => {
{...this.state}
/>;
const msg = this.props.message || "An error occurred while rendering this Component. More info can be found below and in your console.";
const msg = this.props.message || t("vencord.errorBoundaryDescription");
return (
<ErrorCard style={{ overflow: "hidden" }}>
<h1>Oh no!</h1>
<h1>{t("vencord.ohNo")}</h1>
<p>{msg}</p>
<code>
{this.state.message}

View file

@ -12,8 +12,9 @@ import ErrorBoundary from "@components/ErrorBoundary";
import { Link } from "@components/Link";
import { DevsById } from "@utils/constants";
import { fetchUserProfile } from "@utils/discord";
import { classes, pluralise } from "@utils/misc";
import { classes } from "@utils/misc";
import { ModalContent, ModalRoot, openModal } from "@utils/modal";
import { t, Translate } from "@utils/translation";
import { Forms, showToast, useEffect, useMemo, UserProfileStore, useStateFromStores } from "@webpack/common";
import { User } from "discord-types/general";
@ -60,8 +61,6 @@ function ContributorModal({ user }: { user: User; }) {
.sort((a, b) => Number(a.required ?? false) - Number(b.required ?? false));
}, [user.id, user.username]);
const ContributedHyperLink = <Link href="https://vencord.dev/source">contributed</Link>;
return (
<>
<div className={cl("header")}>
@ -88,15 +87,11 @@ function ContributorModal({ user }: { user: User; }) {
</div>
</div>
{plugins.length ? (
<Forms.FormText>
This person has {ContributedHyperLink} to {pluralise(plugins.length, "plugin")}!
<Translate i18nKey="vencord.pluginContributed" variables={{ count: plugins.length }}>
<Link href="https://vencord.dev/source" />
</Translate>
</Forms.FormText>
) : (
<Forms.FormText>
This person has not made any plugins. They likely {ContributedHyperLink} to Vencord in other ways!
</Forms.FormText>
)}
{!!plugins.length && (
<div className={cl("plugins")}>
@ -105,7 +100,7 @@ function ContributorModal({ user }: { user: User; }) {
key={p.name}
plugin={p}
disabled={p.required ?? false}
onRestartNeeded={() => showToast("Restart to apply changes!")}
onRestartNeeded={() => showToast(t("vencord.pluginRestart"))}
/>
)}
</div>

View file

@ -28,6 +28,7 @@ import { proxyLazy } from "@utils/lazy";
import { Margins } from "@utils/margins";
import { classes, isObjectEmpty } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { t } from "@utils/translation";
import { OptionType, Plugin } from "@utils/types";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { Button, Clickable, FluxDispatcher, Forms, React, Text, Tooltip, UserStore, UserUtils } from "@webpack/common";
@ -138,7 +139,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
function renderSettings() {
if (!hasSettings || !plugin.options) {
return <Forms.FormText>There are no settings for this plugin.</Forms.FormText>;
return <Forms.FormText>{t("vencord.noSettings")}</Forms.FormText>;
} else {
const options = Object.entries(plugin.options).map(([key, setting]) => {
if (setting.hidden) return null;
@ -274,7 +275,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
</div>
)}
<Forms.FormSection className={Margins.bottom16}>
<Forms.FormTitle tag="h3">Settings</Forms.FormTitle>
<Forms.FormTitle tag="h3">{t("vencord.settings")}</Forms.FormTitle>
{renderSettings()}
</Forms.FormSection>
</ModalContent>
@ -289,7 +290,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
>
Cancel
</Button>
<Tooltip text="You must fix all errors before saving" shouldShow={!canSubmit()}>
<Tooltip text={t("vencord.settingsErrors")} shouldShow={!canSubmit()}>
{({ onMouseEnter, onMouseLeave }) => (
<Button
size={Button.Sizes.SMALL}
@ -299,12 +300,12 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
onMouseLeave={onMouseLeave}
disabled={!canSubmit()}
>
Save & Close
{t("vencord.saveAndClose")}
</Button>
)}
</Tooltip>
</Flex>
{saveError && <Text variant="text-md/semibold" style={{ color: "var(--text-danger)" }}>Error while saving: {saveError}</Text>}
{saveError && <Text variant="text-md/semibold" style={{ color: "var(--text-danger)" }}>{t("vencord.settingsSaveError", { saveError })}</Text>}
</Flex>
</ModalFooter>}
</ModalRoot>

View file

@ -32,6 +32,7 @@ import { Logger } from "@utils/Logger";
import { Margins } from "@utils/margins";
import { classes, isObjectEmpty } from "@utils/misc";
import { useAwaiter } from "@utils/react";
import { t } from "@utils/translation";
import { Plugin } from "@utils/types";
import { findByPropsLazy } from "@webpack";
import { Alerts, Button, Card, Forms, lodash, Parser, React, Select, Text, TextInput, Toasts, Tooltip, useMemo } from "@webpack/common";
@ -64,19 +65,19 @@ function ReloadRequiredCard({ required }: { required: boolean; }) {
<Card className={cl("info-card", { "restart-card": required })}>
{required ? (
<>
<Forms.FormTitle tag="h5">Restart required!</Forms.FormTitle>
<Forms.FormTitle tag="h5">{t("vencord.pluginHeader.reloadHeader")}</Forms.FormTitle>
<Forms.FormText className={cl("dep-text")}>
Restart now to apply new plugins and their settings
{t("vencord.pluginHeader.reloadDescription")}
</Forms.FormText>
<Button onClick={() => location.reload()}>
Restart
{t("vencord.pluginHeader.restart")}
</Button>
</>
) : (
<>
<Forms.FormTitle tag="h5">Plugin Management</Forms.FormTitle>
<Forms.FormText>Press the cog wheel or info icon to get more info on a plugin</Forms.FormText>
<Forms.FormText>Plugins with a cog wheel have settings you can modify!</Forms.FormText>
<Forms.FormTitle tag="h5">{t("vencord.pluginHeader.managementHeader")}</Forms.FormTitle>
<Forms.FormText>{t("vencord.pluginHeader.iconInformation")}</Forms.FormText>
<Forms.FormText>{t("vencord.pluginHeader.cogWheel")}</Forms.FormText>
</>
)}
</Card>
@ -209,10 +210,10 @@ export default function PluginSettings() {
React.useEffect(() => {
return () => void (changes.hasChanges && Alerts.show({
title: "Restart required",
title: t("vencord.restartRequired"),
body: (
<>
<p>The following plugins require a restart:</p>
<p>$t("vencord.pluginsNeedRestart")</p>
<div>{changes.map((s, i) => (
<>
{i > 0 && ", "}
@ -221,8 +222,8 @@ export default function PluginSettings() {
))}</div>
</>
),
confirmText: "Restart now",
cancelText: "Later!",
confirmText: t("vencord.restartNow"),
cancelText: t("vencord.restartLater"),
onConfirm: () => location.reload()
}));
}, []);
@ -296,7 +297,7 @@ export default function PluginSettings() {
if (isRequired) {
const tooltipText = p.required
? "This plugin is required for Vencord to function."
? t("vencord.requiredPlugin")
: makeDependencyList(depMap[p.name]?.filter(d => settings.plugins[d].enabled));
requiredPlugins.push(
@ -331,18 +332,18 @@ export default function PluginSettings() {
<ReloadRequiredCard required={changes.hasChanges} />
<Forms.FormTitle tag="h5" className={classes(Margins.top20, Margins.bottom8)}>
Filters
{t("vencord.pluginFilters")}
</Forms.FormTitle>
<div className={classes(Margins.bottom20, cl("filter-controls"))}>
<TextInput autoFocus value={searchValue.value} placeholder="Search for a plugin..." onChange={onSearch} />
<TextInput autoFocus value={searchValue.value} placeholder={t("vencord.search.placeholder")} onChange={onSearch} />
<div className={InputStyles.inputWrapper}>
<Select
options={[
{ label: "Show All", value: SearchStatus.ALL, default: true },
{ label: "Show Enabled", value: SearchStatus.ENABLED },
{ label: "Show Disabled", value: SearchStatus.DISABLED },
{ label: "Show New", value: SearchStatus.NEW }
{ label: t("vencord.search.all"), value: SearchStatus.ALL, default: true },
{ label: t("vencord.search.enabled"), value: SearchStatus.ENABLED },
{ label: t("vencord.search.disabled"), value: SearchStatus.DISABLED },
{ label: t("vencord.search.new"), value: SearchStatus.NEW }
]}
serialize={String}
select={onStatusChange}
@ -353,7 +354,7 @@ export default function PluginSettings() {
</div>
</div>
<Forms.FormTitle className={Margins.top20}>Plugins</Forms.FormTitle>
<Forms.FormTitle className={Margins.top20}>{t("vencord.plugins")}</Forms.FormTitle>
{plugins.length || requiredPlugins.length
? (
@ -371,7 +372,7 @@ export default function PluginSettings() {
<Forms.FormDivider className={Margins.top20} />
<Forms.FormTitle tag="h5" className={classes(Margins.top20, Margins.bottom8)}>
Required Plugins
{t("vencord.requiredPlugins")}
</Forms.FormTitle>
<div className={cl("grid")}>
{requiredPlugins.length
@ -386,7 +387,7 @@ export default function PluginSettings() {
function makeDependencyList(deps: string[]) {
return (
<React.Fragment>
<Forms.FormText>This plugin is required by:</Forms.FormText>
<Forms.FormText>{t("vencord.pluginRequiredBy")}</Forms.FormText>
{deps.map((dep: string) => <Forms.FormText className={cl("dep-text")}>{dep}</Forms.FormText>)}
</React.Fragment>
);

View file

@ -21,6 +21,7 @@ import "./addonCard.css";
import { classNameFactory } from "@api/Styles";
import { Badge } from "@components/Badge";
import { Switch } from "@components/Switch";
import { t } from "@utils/translation";
import { Text, useRef } from "@webpack/common";
import type { MouseEventHandler, ReactNode } from "react";
@ -67,7 +68,7 @@ export function AddonCard({ disabled, isNew, name, infoButton, footer, author, e
>
{name}
</div>
</div>{isNew && <Badge text="NEW" color="#ED4245" />}
</div>{isNew && <Badge text={t("vencord.new")} color="#ED4245" />}
</Text>
{!!author && (
<Text variant="text-md/normal" className={cl("author")}>

View file

@ -20,6 +20,7 @@ import { Flex } from "@components/Flex";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { downloadSettingsBackup, uploadSettingsBackup } from "@utils/settingsSync";
import { t } from "@utils/translation";
import { Button, Card, Text } from "@webpack/common";
import { SettingsTab, wrapTab } from "./shared";
@ -29,21 +30,19 @@ function BackupRestoreTab() {
<SettingsTab title="Backup & Restore">
<Card className={classes("vc-settings-card", "vc-backup-restore-card")}>
<Flex flexDirection="column">
<strong>Warning</strong>
<span>Importing a settings file will overwrite your current settings.</span>
<strong>{t("vencord.warning")}</strong>
<span>{t("vencord.backupAndRestore.importWarning")}</span>
</Flex>
</Card>
<Text variant="text-md/normal" className={Margins.bottom8}>
You can import and export your Vencord settings as a JSON file.
This allows you to easily transfer your settings to another device,
or recover your settings after reinstalling Vencord or Discord.
{t("vencord.backupAndRestore.description")}
</Text>
<Text variant="text-md/normal" className={Margins.bottom8}>
Settings Export contains:
{t("vencord.backupAndRestore.exportContains")}
<ul>
<li>&mdash; Custom QuickCSS</li>
<li>&mdash; Theme Links</li>
<li>&mdash; Plugin Settings</li>
<li>&mdash; {t("vencord.backupAndRestore.customQuickcss")}</li>
<li>&mdash; {t("vencord.backupAndRestore.themeLinks")}</li>
<li>&mdash; {t("vencord.backupAndRestore.pluginSettings")}</li>
</ul>
</Text>
<Flex>
@ -51,13 +50,13 @@ function BackupRestoreTab() {
onClick={() => uploadSettingsBackup()}
size={Button.Sizes.SMALL}
>
Import Settings
{t("vencord.backupAndRestore.importSettings")}
</Button>
<Button
onClick={downloadSettingsBackup}
size={Button.Sizes.SMALL}
>
Export Settings
{t("vencord.backupAndRestore.exportSettings")}
</Button>
</Flex>
</SettingsTab>

View file

@ -24,6 +24,7 @@ import { Link } from "@components/Link";
import { authorizeCloud, cloudLogger, deauthorizeCloud, getCloudAuth, getCloudUrl } from "@utils/cloud";
import { Margins } from "@utils/margins";
import { deleteCloudSettings, getCloudSettings, putCloudSettings } from "@utils/settingsSync";
import { t, Translate } from "@utils/translation";
import { Alerts, Button, Forms, Switch, Tooltip } from "@webpack/common";
import { SettingsTab, wrapTab } from "./shared";
@ -46,8 +47,8 @@ async function eraseAllData() {
if (!res.ok) {
cloudLogger.error(`Failed to erase data, API returned ${res.status}`);
showNotification({
title: "Cloud Integrations",
body: `Could not erase all data (API returned ${res.status}), please contact support.`,
title: t("vencord.cloudIntegrations"),
body: t("vencord.cloud.integrations.eraseError", { status: res.status }),
color: "var(--red-360)"
});
return;
@ -57,8 +58,8 @@ async function eraseAllData() {
await deauthorizeCloud();
showNotification({
title: "Cloud Integrations",
body: "Successfully erased all data.",
title: t("vencord.cloudIntegrations"),
body: t("vencord.cloud.integrations.eraseSuccess"),
color: "var(--green-360)"
});
}
@ -70,8 +71,7 @@ function SettingsSyncSection() {
return (
<Forms.FormSection title="Settings Sync" className={Margins.top16}>
<Forms.FormText variant="text-md/normal" className={Margins.bottom20}>
Synchronize your settings to the cloud. This allows easy synchronization across multiple devices with
minimal effort.
{t("vencord.cloud.settings.description")}
</Forms.FormText>
<Switch
key="cloud-sync"
@ -79,7 +79,7 @@ function SettingsSyncSection() {
value={cloud.settingsSync}
onChange={v => { cloud.settingsSync = v; }}
>
Settings Sync
{t("vencord.settingsSync")}
</Switch>
<div className="vc-cloud-settings-sync-grid">
<Button
@ -87,9 +87,9 @@ function SettingsSyncSection() {
disabled={!sectionEnabled}
onClick={() => putCloudSettings(true)}
>
Sync to Cloud
{t("vencord.cloud.settings.syncToCloud")}
</Button>
<Tooltip text="This will overwrite your local settings with the ones on the cloud. Use wisely!">
<Tooltip text={t("vencord.cloud.settings.overwriteWarning")}>
{({ onMouseLeave, onMouseEnter }) => (
<Button
onMouseLeave={onMouseLeave}
@ -99,7 +99,7 @@ function SettingsSyncSection() {
disabled={!sectionEnabled}
onClick={() => getCloudSettings(true, true)}
>
Sync from Cloud
{t("vencord.cloud.settings.syncFromCloud")}
</Button>
)}
</Tooltip>
@ -109,7 +109,7 @@ function SettingsSyncSection() {
disabled={!sectionEnabled}
onClick={() => deleteCloudSettings()}
>
Delete Cloud Settings
{t("vencord.cloud.settings.deleteCloudSettings")}
</Button>
</div>
</Forms.FormSection>
@ -121,12 +121,12 @@ function CloudTab() {
return (
<SettingsTab title="Vencord Cloud">
<Forms.FormSection title="Cloud Settings" className={Margins.top16}>
<Forms.FormSection title={t("vencord.cloudSettings")} className={Margins.top16}>
<Forms.FormText variant="text-md/normal" className={Margins.bottom20}>
Vencord comes with a cloud integration that adds goodies like settings sync across devices.
It <Link href="https://vencord.dev/cloud/privacy">respects your privacy</Link>, and
the <Link href="https://github.com/Vencord/Backend">source code</Link> is AGPL 3.0 licensed so you
can host it yourself.
<Translate i18nKey="vencord.cloud.integrations.description">
<Link href="https://vencord.dev/cloud/privacy" />
<Link href="https://github.com/Vencord/Backend" />
</Translate>
</Forms.FormText>
<Switch
key="backend"
@ -137,13 +137,13 @@ function CloudTab() {
else
settings.cloud.authenticated = v;
}}
note="This will request authorization if you have not yet set up cloud integrations."
note={t("vencord.cloud.integrations.authorizationNote")}
>
Enable Cloud Integrations
{t("vencord.cloud.integrations.enable")}
</Switch>
<Forms.FormTitle tag="h5">Backend URL</Forms.FormTitle>
<Forms.FormTitle tag="h5">{t("vencord.cloud.integrations.backendUrl")}</Forms.FormTitle>
<Forms.FormText className={Margins.bottom8}>
Which backend to use when using cloud integrations.
{t("vencord.cloud.integrations.backendNote")}
</Forms.FormText>
<CheckedTextInput
key="backendUrl"
@ -166,25 +166,24 @@ function CloudTab() {
await authorizeCloud();
}}
>
Reauthorise
{t("vencord.reauthorise")}
</Button>
<Button
size={Button.Sizes.MEDIUM}
color={Button.Colors.RED}
disabled={!settings.cloud.authenticated}
onClick={() => Alerts.show({
title: "Are you sure?",
body: "Once your data is erased, we cannot recover it. There's no going back!",
title: t("vencord.areYouSure"),
body: t("vencord.cloud.integrations.eraseWarning"),
onConfirm: eraseAllData,
confirmText: "Erase it!",
confirmText: t("vencord.cloud.integrations.eraseIt"),
confirmColor: "vc-cloud-erase-data-danger-btn",
cancelText: "Nevermind"
cancelText: t("vencord.nevermind")
})}
>
Erase All Data
</Button>
</Grid>
<Forms.FormDivider className={Margins.top16} />
</Forms.FormSection >
<SettingsSyncSection />

View file

@ -28,6 +28,7 @@ import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { showItemInFolder } from "@utils/native";
import { useAwaiter } from "@utils/react";
import { t } from "@utils/translation";
import { findByPropsLazy, findLazy } from "@webpack";
import { Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common";
import type { ComponentType, Ref, SyntheticEvent } from "react";
@ -202,60 +203,68 @@ function ThemesTab() {
return (
<>
<Card className="vc-settings-card">
<Forms.FormTitle tag="h5">Find Themes:</Forms.FormTitle>
<Forms.FormTitle tag="h5">{t("vencord.themes.findThemes")}</Forms.FormTitle>
<div style={{ marginBottom: ".5em", display: "flex", flexDirection: "column" }}>
<Link style={{ marginRight: ".5em" }} href="https://betterdiscord.app/themes">
BetterDiscord Themes
{t("vencord.themes.betterDiscord")}
</Link>
<Link href="https://github.com/search?q=discord+theme">GitHub</Link>
</div>
<Forms.FormText>If using the BD site, click on "Download" and place the downloaded .theme.css file into your themes folder.</Forms.FormText>
<Forms.FormText>{t("vencord.themes.betterDiscordNote")}</Forms.FormText>
</Card>
<Forms.FormSection title="Local Themes">
<QuickActionCard>
<Forms.FormSection title={t("vencord.themes.local")}>
<Card className="vc-settings-quick-actions-card">
<>
{IS_WEB ?
(
<QuickAction
text={
<span style={{ position: "relative" }}>
Upload Theme
<Button
size={Button.Sizes.SMALL}
disabled={themeDirPending}
>
{t("vencord.themes.upload")}
<FileInput
ref={fileInputRef}
onChange={onFileUpload}
multiple={true}
filters={[{ extensions: ["css"] }]}
/>
</span>
}
Icon={PlusIcon}
/>
</Button>
) : (
<QuickAction
text="Open Themes Folder"
text={t("vencord.themes.openFolder")}
action={() => showItemInFolder(themeDir!)}
disabled={themeDirPending}
Icon={FolderIcon}
/>
>
{t("vencord.themes.openFolder")}
</Button>
)}
<QuickAction
text="Load missing Themes"
action={refreshLocalThemes}
Icon={RestartIcon}
/>
<QuickAction
text="Edit QuickCSS"
action={() => VencordNative.quickCss.openEditor()}
Icon={PaintbrushIcon}
/>
<Button
onClick={refreshLocalThemes}
size={Button.Sizes.SMALL}
>
{t("vencord.themes.loadMissing")}
</Button>
<Button
onClick={() => VencordNative.quickCss.openEditor()}
size={Button.Sizes.SMALL}
>
{t("vencord.themes.editQuickCss")}
</Button>
{Vencord.Settings.plugins.ClientTheme.enabled && (
<QuickAction
text="Edit ClientTheme"
action={() => openPluginModal(Vencord.Plugins.plugins.ClientTheme)}
Icon={PencilIcon}
<Button
onClick={() => openModal(modalProps => (
<PluginModal
{...modalProps}
plugin={Vencord.Plugins.plugins.ClientTheme}
onRestartNeeded={() => { }}
/>
))}
size={Button.Sizes.SMALL}
>
{t("clientTheme.edit")}
</Button>
)}
</>
</QuickActionCard>
@ -295,12 +304,12 @@ function ThemesTab() {
return (
<>
<Card className="vc-settings-card vc-text-selectable">
<Forms.FormTitle tag="h5">Paste links to css files here</Forms.FormTitle>
<Forms.FormText>One link per line</Forms.FormText>
<Forms.FormText>Make sure to use direct links to files (raw or github.io)!</Forms.FormText>
<Forms.FormTitle tag="h5">{t("vencord.themes.pasteLinks")}</Forms.FormTitle>
<Forms.FormText>{t("vencord.themes.oneLinkPerLine")}</Forms.FormText>
<Forms.FormText>{t("vencord.themes.useDirect")}</Forms.FormText>
</Card>
<Forms.FormSection title="Online Themes" tag="h5">
<Forms.FormSection title={t("vencord.themes.online")} tag="h5">
<TextArea
value={themeText}
onChange={setThemeText}
@ -329,13 +338,13 @@ function ThemesTab() {
className="vc-settings-tab-bar-item"
id={ThemeTab.LOCAL}
>
Local Themes
{t("vencord.themes.local")}
</TabBar.Item>
<TabBar.Item
className="vc-settings-tab-bar-item"
id={ThemeTab.ONLINE}
>
Online Themes
{t("vencord.themes.online")}
</TabBar.Item>
</TabBar>

View file

@ -16,12 +16,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { t } from "@utils/translation";
import { maybePromptToUpdate } from "@utils/updater";
export function handleComponentFailed() {
maybePromptToUpdate(
"Uh Oh! Failed to render this Page." +
" However, there is an update available that might fix it." +
" Would you like to update and restart now?"
t("vencord.failureUpdate")
);
}

5
src/modules.d.ts vendored
View file

@ -43,6 +43,11 @@ declare module "~git-remote" {
export default remote;
}
declare module "~translations" {
const translations: Record<string, Record<string, any>>;
export default translations;
}
declare module "file://*" {
const content: string;
export default content;

View file

@ -0,0 +1,27 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Devs } from "@utils/constants";
import { setLocale } from "@utils/translation";
import definePlugin from "@utils/types";
import { i18n } from "@webpack/common";
export default definePlugin({
name: "Translation",
required: true,
description: "Assists with translating Vencord",
authors: [Devs.lewisakura],
flux: {
USER_SETTINGS_PROTO_UPDATE({ settings }) {
setLocale(settings.proto.localization.locale.value);
}
},
start() {
setLocale(i18n.getLocale());
}
});

View file

@ -10,6 +10,7 @@ import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { t } from "@utils/translation";
import definePlugin, { OptionType, StartAt } from "@utils/types";
import { findByCodeLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack";
import { Button, Forms, useStateFromStores } from "@webpack/common";
@ -64,8 +65,8 @@ function ThemeSettings() {
<div className="client-theme-settings">
<div className="client-theme-container">
<div className="client-theme-settings-labels">
<Forms.FormTitle tag="h3">Theme Color</Forms.FormTitle>
<Forms.FormText>Add a color to your Discord client theme</Forms.FormText>
<Forms.FormTitle tag="h3">{t("clientTheme.settingsTitle")}</Forms.FormTitle>
<Forms.FormText>{t("clientTheme.settingsDescription")}</Forms.FormText>
</div>
<ColorPicker
color={parseInt(settings.store.color, 16)}
@ -78,12 +79,12 @@ function ThemeSettings() {
<Forms.FormDivider className={classes(Margins.top8, Margins.bottom8)} />
<div className={`client-theme-contrast-warning ${contrastWarning ? (isLightTheme ? "theme-dark" : "theme-light") : ""}`}>
<div className="client-theme-warning">
<Forms.FormText>Warning, your theme won't look good:</Forms.FormText>
{contrastWarning && <Forms.FormText>Selected color won't contrast well with text</Forms.FormText>}
{nitroThemeEnabled && <Forms.FormText>Nitro themes aren't supported</Forms.FormText>}
<Forms.FormText>{t("clientTheme.warningTitle")}</Forms.FormText>
{contrastWarning && <Forms.FormText>{t("clientTheme.warnings.badContrast")}</Forms.FormText>}
{nitroThemeEnabled && <Forms.FormText>{t("clientTheme.warnings.nitro")}</Forms.FormText>}
</div>
{(contrastWarning && fixableContrast) && <Button onClick={() => setTheme(oppositeTheme)} color={Button.Colors.RED}>Switch to {oppositeTheme} mode</Button>}
{(nitroThemeEnabled) && <Button onClick={() => setTheme(theme)} color={Button.Colors.RED}>Disable Nitro Theme</Button>}
{(contrastWarning && fixableContrast) && <Button onClick={() => setTheme(oppositeTheme)} color={Button.Colors.RED}>{t(`clientTheme.switchToOpposite.${oppositeTheme}`)}</Button>}
{(nitroThemeEnabled) && <Button onClick={() => setTheme(theme)} color={Button.Colors.RED}>{t("clientTheme.disableNitroTheme")}</Button>}
</div>
</>)}
</div>
@ -92,18 +93,18 @@ function ThemeSettings() {
const settings = definePluginSettings({
color: {
description: "Color your Discord client theme will be based around. Light mode isn't supported",
description: t("clientTheme.colorDescription"),
type: OptionType.COMPONENT,
default: "313338",
component: () => <ThemeSettings />
},
resetColor: {
description: "Reset Theme Color",
description: t("clientTheme.resetColorDescription"),
type: OptionType.COMPONENT,
default: "313338",
component: () => (
<Button onClick={() => onPickColor(0x313338)}>
Reset Theme Color
{t("clientTheme.resetButton")}
</Button>
)
}
@ -112,7 +113,7 @@ const settings = definePluginSettings({
export default definePlugin({
name: "ClientTheme",
authors: [Devs.F53, Devs.Nuckyz],
description: "Recreation of the old client theme experiment. Add a color to your Discord client theme",
description: t("clientTheme.description"),
settings,
startAt: StartAt.DOMContentLoaded,

View file

@ -23,6 +23,7 @@ import { OAuth2AuthorizeModal, UserStore } from "@webpack/common";
import { Logger } from "./Logger";
import { openModal } from "./modal";
import { t } from "./translation";
export const cloudLogger = new Logger("Cloud", "#39b7e0");
export const getCloudUrl = () => new URL(Settings.cloud.url);
@ -83,8 +84,8 @@ export async function authorizeCloud() {
var { clientId, redirectUri } = await oauthConfiguration.json();
} catch {
showNotification({
title: "Cloud Integration",
body: "Setup failed (couldn't retrieve OAuth configuration)."
title: t("vencord.cloudIntegrations"),
body: t("vencord.cloud.integrations.setupFailure.oauth")
});
Settings.cloud.authenticated = false;
return;
@ -113,22 +114,22 @@ export async function authorizeCloud() {
cloudLogger.info("Authorized with secret");
await setAuthorization(secret);
showNotification({
title: "Cloud Integration",
body: "Cloud integrations enabled!"
title: t("vencord.cloudIntegrations"),
body: t("vencord.cloud.integrations.enabled")
});
Settings.cloud.authenticated = true;
} else {
showNotification({
title: "Cloud Integration",
body: "Setup failed (no secret returned?)."
title: t("vencord.cloudIntegrations"),
body: t("vencord.cloud.integrations.setupFailure.missingSecret")
});
Settings.cloud.authenticated = false;
}
} catch (e: any) {
cloudLogger.error("Failed to authorize", e);
showNotification({
title: "Cloud Integration",
body: `Setup failed (${e.toString()}).`
title: t("vencord.cloudIntegrations"),
body: t("vencord.cloud.integrations.setupFailure.generic", { error: e.toString() })
});
Settings.cloud.authenticated = false;
}

View file

@ -19,6 +19,7 @@
import { Clipboard, Toasts } from "@webpack/common";
import { DevsById } from "./constants";
import { t } from "./translation";
/**
* Calls .join(" ") on the arguments
@ -35,11 +36,13 @@ export function sleep(ms: number): Promise<void> {
return new Promise(r => setTimeout(r, ms));
}
export function copyWithToast(text: string, toastMessage = "Copied to clipboard!") {
export function copyWithToast(text: string, toastMessage?: string) {
toastMessage ??= t("vencord.copiedToClipboard");
if (Clipboard.SUPPORTS_COPY) {
Clipboard.copy(text);
} else {
toastMessage = "Your browser does not support copying to clipboard";
toastMessage = t("vencord.clipboardNotSupported");
}
Toasts.show({
message: toastMessage,

View file

@ -24,6 +24,7 @@ import { deflateSync, inflateSync } from "fflate";
import { getCloudAuth, getCloudUrl } from "./cloud";
import { Logger } from "./Logger";
import { relaunch } from "./native";
import { t } from "./translation";
import { chooseFile, saveFile } from "./web";
export async function importSettings(data: string) {
@ -68,10 +69,10 @@ const toast = (type: number, message: string) =>
});
const toastSuccess = () =>
toast(Toasts.Type.SUCCESS, "Settings successfully imported. Restart to apply changes!");
toast(Toasts.Type.SUCCESS, t("vencord.importedSettings"));
const toastFailure = (err: any) =>
toast(Toasts.Type.FAILURE, `Failed to import settings: ${String(err)}`);
toast(Toasts.Type.FAILURE, t("vencord.failedToImport", { error: String(err) }));
export async function uploadSettingsBackup(showToast = true): Promise<void> {
if (IS_DISCORD_DESKTOP) {
@ -128,8 +129,8 @@ export async function putCloudSettings(manual?: boolean) {
if (!res.ok) {
cloudSettingsLogger.error(`Failed to sync up, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to cloud (API returned ${res.status}).`,
title: t("vencord.cloudSettings"),
body: t("vencord.cloud.settings.syncErrorUp.api", { status: res.status.toString() }),
color: "var(--red-360)"
});
return;
@ -143,16 +144,16 @@ export async function putCloudSettings(manual?: boolean) {
if (manual) {
showNotification({
title: "Cloud Settings",
body: "Synchronized settings to the cloud!",
title: t("vencord.cloudSettings"),
body: t("vencord.cloud.settings.syncSuccess"),
noPersist: true,
});
}
} catch (e: any) {
cloudSettingsLogger.error("Failed to sync up", e);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings to the cloud (${e.toString()}).`,
title: t("vencord.cloudSettings"),
body: t("vencord.cloud.settings.syncErrorUp.generic", { error: e.toString() }),
color: "var(--red-360)"
});
}
@ -173,8 +174,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
cloudSettingsLogger.info("No settings on the cloud");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "There are no settings in the cloud.",
title: t("vencord.cloudSettings"),
body: t("vencord.cloud.settings.nothingOnline"),
noPersist: true
});
return false;
@ -184,8 +185,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
cloudSettingsLogger.info("Settings up to date");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings are up to date.",
title: t("vencord.cloudSettings"),
body: t("vencord.cloud.settings.upToDate"),
noPersist: true
});
return false;
@ -194,8 +195,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
if (!res.ok) {
cloudSettingsLogger.error(`Failed to sync down, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (API returned ${res.status}).`,
title: t("vencord.cloudSettings"),
body: t("vencord.cloud.settings.syncErrorDown.api", { status: res.status.toString() }),
color: "var(--red-360)"
});
return false;
@ -208,8 +209,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
if (!force && written < localWritten) {
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your local settings are newer than the cloud ones.",
title: t("vencord.cloudSettings"),
body: t("vencord.cloud.settings.localNewer"),
noPersist: true,
});
return;
@ -227,8 +228,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
cloudSettingsLogger.info("Settings loaded from cloud successfully");
if (shouldNotify)
showNotification({
title: "Cloud Settings",
body: "Your settings have been updated! Click here to restart to fully apply changes!",
title: t("vencord.cloudSettings"),
body: t("vencord.cloud.settings.updated"),
color: "var(--green-360)",
onClick: IS_WEB ? () => location.reload() : relaunch,
noPersist: true
@ -238,8 +239,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
} catch (e: any) {
cloudSettingsLogger.error("Failed to sync down", e);
showNotification({
title: "Cloud Settings",
body: `Could not synchronize settings from the cloud (${e.toString()}).`,
title: t("vencord.cloudSettings"),
body: t("vencord.cloud.settings.syncErrorDown.generic", { error: e.toString() }),
color: "var(--red-360)"
});
@ -257,8 +258,8 @@ export async function deleteCloudSettings() {
if (!res.ok) {
cloudSettingsLogger.error(`Failed to delete, API returned ${res.status}`);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (API returned ${res.status}).`,
title: t("vencord.cloudSettings"),
body: t("vencord.cloud.settings.deleteError.api", { error: res.status.toString() }),
color: "var(--red-360)"
});
return;
@ -266,15 +267,15 @@ export async function deleteCloudSettings() {
cloudSettingsLogger.info("Settings deleted from cloud successfully");
showNotification({
title: "Cloud Settings",
body: "Settings deleted from cloud!",
title: t("vencord.cloudSettings"),
body: t("vencord.cloud.settings.deleted"),
color: "var(--green-360)"
});
} catch (e: any) {
cloudSettingsLogger.error("Failed to delete", e);
showNotification({
title: "Cloud Settings",
body: `Could not delete settings (${e.toString()}).`,
title: t("vencord.cloudSettings"),
body: t("vencord.cloud.settings.deleteError.generic", { error: e.toString() }),
color: "var(--red-360)"
});
}

179
src/utils/translation.tsx Normal file
View file

@ -0,0 +1,179 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { negotiateLanguages } from "@fluent/langneg";
import { React } from "@webpack/common";
import translations from "~translations";
import { localStorage } from "./localStorage";
import { Logger } from "./Logger";
const logger = new Logger("Translations", "#7bc876");
let loadedLocale: Record<string, any>;
let lastDiscordLocale: string = localStorage.getItem("vcLocale")!;
let bestLocale: string;
export function setLocale(locale: string) {
if (locale === lastDiscordLocale) return;
localStorage.setItem("vcLocale", locale);
lastDiscordLocale = locale;
reloadLocale();
}
if (lastDiscordLocale) reloadLocale();
function reloadLocale() {
// finds the best locale based on the available ones
bestLocale = negotiateLanguages(
[lastDiscordLocale],
Object.keys(translations),
{
defaultLocale: "en",
strategy: "lookup",
}
)[0];
loadedLocale = translations[bestLocale];
logger.info("Changed locale to", bestLocale);
}
// derived from stackoverflow's string formatting function
function format(source: string, variables: Record<string, any>) {
for (const key in variables) {
let formatted: string;
switch (typeof variables[key]) {
case "number": {
formatted = new Intl.NumberFormat(bestLocale).format(variables[key]);
break;
}
default: {
formatted = variables[key].toString();
break;
}
}
source = source.replace(
new RegExp(`\\{${key}\\}`, "gi"),
formatted
);
}
return source;
}
// converts a dot-notation path to an object value
const dotProp = (key: string, object: any) =>
key.split(".").reduce((obj, key) => obj?.[key], object);
type Translation = string | ({ [rule in Intl.LDMLPluralRule]?: string } & { other: string; });
// translation retrieval function
function _t(key: string, bundle: any): Translation {
const translation = dotProp(key, bundle);
if (!translation) {
if (bundle !== translations.en) {
return _t(key, translations.en);
} else {
return key;
}
}
return translation;
}
/**
* Translates a key. Soft-fails and returns the key if it is not valid.
* @param key The key to translate.
* @param variables The variables to interpolate into the resultant string. If dealing with plurals, `count` must be set.
* @returns A translated string.
*/
export function t(key: string, variables?: Record<string, any>): string {
const getter = (): string => {
const translation = _t(key, loadedLocale);
if (typeof translation !== "string") {
if (!variables?.count)
throw new Error(`translation key ${key} is an object (is it a plural?)`);
const pluralTag: Intl.LDMLPluralRule = variables.count === 0 ? "zero" :
new Intl.PluralRules(bestLocale).select(variables.count);
if (translation[pluralTag]) {
return format(translation[pluralTag]!, variables);
} else if (translation.other) {
return format(translation.other, variables);
} else {
throw new Error(`translation key ${key} is an object and doesn't seem to be plural`);
}
}
if (!variables) return translation;
return format(translation, variables);
};
// top level support hax (thank you vee!!)
// tl;dr: this lets you use $t at the top level in objects by simulating a string, a la:
// {
// description: t("clientTheme.description")
// }
// and any future accesses of the description prop will result in an up to date translation
const descriptor = {
configurable: true,
enumerable: false,
writable: false,
value: getter
};
return Object.create(String.prototype, {
toString: descriptor,
valueOf: descriptor
});
}
interface TranslateProps {
/** The key to translate. */
i18nKey: string;
/** The variables to interpolate into the resultant string. If dealing with plurals, `count` must be set. */
variables?: Record<string, any>;
/** The component(s) to interpolate into the resultant string. */
children: JSX.Element | JSX.Element[];
}
/**
* A translation component. Follows the same rules as {@link t}, but lets you add components to strings.
* @param param0 Component props.
*/
export function Translate({ i18nKey, variables, children: trueChildren }: TranslateProps): JSX.Element {
const children = [trueChildren].flat();
const translation = t(i18nKey, variables);
const parts = translation.split(/(<\d+>.*?<\/\d+>)/g);
const finalChildren = parts.map((part, index) => {
const match = part.match(/<(\d+)>(.*?)<\/\d+>/);
if (match) {
const childIndex = parseInt(match[1], 10);
return React.cloneElement(children[childIndex], { key: index }, match[2]);
}
return part;
});
return <>{finalChildren}</>;
}

View file

@ -20,6 +20,7 @@ import gitHash from "~git-hash";
import { Logger } from "./Logger";
import { relaunch } from "./native";
import { t } from "./translation";
import { IpcRes } from "./types";
export const UpdateLogger = /* #__PURE__*/ new Logger("Updater", "white");
@ -70,7 +71,7 @@ export async function maybePromptToUpdate(confirmMessage: string, checkForDev =
const isOutdated = await checkForUpdates();
if (isOutdated) {
const wantsUpdate = confirm(confirmMessage);
if (wantsUpdate && isNewer) return alert("Your local copy has more recent commits. Please stash or reset them.");
if (wantsUpdate && isNewer) return alert(t("vencord.gitCopyNewer"));
if (wantsUpdate) {
await update();
relaunch();
@ -78,6 +79,6 @@ export async function maybePromptToUpdate(confirmMessage: string, checkForDev =
}
} catch (err) {
UpdateLogger.error(err);
alert("That also failed :( Try updating or re-installing with the installer!");
alert(t("vencord.updaterRepeatFailed"));
}
}

View file

@ -0,0 +1,4 @@
{
"description": "Plugin description but in German. Ja!",
"edit": "Edit ClientTheme"
}

View file

@ -0,0 +1,7 @@
{
"pluginContributed": {
"zero": "Diese Person hat keine Plugins erstellt. Sie haben wahrscheinlich auf andere Weise <0>zu Vencord beigetragen</0>!",
"one": "Diese Person hat zu einem Plugin <0>beigetragen</0>!",
"other": "Diese Person hat zu {count} Plugins <0>beigetragen</0>!"
}
}

View file

@ -0,0 +1,19 @@
{
"colorDescription": "Color your Discord client theme will be based around. Light mode isn't supported",
"description": "Recreation of the old client theme experiment. Add a color to your Discord client theme",
"disableNitroTheme": "Disable Nitro Theme",
"edit": "Edit ClientTheme",
"resetButton": "Reset Theme Color",
"resetColorDescription": "Reset Theme Color",
"settingsDescription": "Add a color to your Discord client theme",
"settingsTitle": "Theme Color",
"switchToOpposite": {
"dark": "Switch to dark mode",
"light": "Switch to light mode"
},
"warningTitle": "Warning, your theme won't look good:",
"warnings": {
"badContrast": "Selected color won't contrast well with text",
"nitro": "Nitro themes aren't supported"
}
}

View file

@ -0,0 +1,3 @@
{
"description": "Assists with translating Vencord"
}

View file

@ -0,0 +1,131 @@
{
"areYouSure": "Are you sure?",
"backupAndRestore": {
"customQuickcss": "Custom QuickCSS",
"description": "You can import and export your Vencord settings as a JSON file. This allows you to easily transfer your settings to another device, or recover your settings after reinstalling Vencord or Discord.",
"exportContains": "Settings Export contains:",
"exportSettings": "Export Settings",
"importSettings": "Import Settings",
"importWarning": "Importing a settings file will overwrite your current settings.",
"pluginSettings": "Plugin Settings",
"themeLinks": "Theme Links"
},
"clipboardNotSupported": "Your browser does not support copying to clipboard",
"cloud": {
"integrations": {
"authorizationNote": "This will request authorization if you have not yet set up cloud integrations.",
"backendNote": "Which backend to use when using cloud integrations.",
"backendUrl": "Backend URL",
"description": "Vencord comes with a cloud integration that adds goodies like settings sync across devices. It <0>respects your privacy</0>, and the <1>source code</1> is AGPL 3.0 licensed so you can host it yourself.",
"enable": "Enable Cloud Integrations",
"enabled": "Cloud integrations enabled!",
"eraseAllData": "Erase All Data",
"eraseError": "Could not erase all data (API returned {status}), please contact support.",
"eraseIt": "Erase it!",
"eraseSuccess": "Successfully erased all data.",
"eraseWarning": "Once your data is erased, we cannot recover it. There's no going back!",
"reauthenticate": "We've noticed you have cloud integrations enabled in another client! Due to limitations, you will need to re-authenticate to continue using them. Click here to go to the settings page to do so!",
"setupFailure": {
"generic": "Setup failed ({error}).",
"missingSecret": "Setup failed (no secret returned?).",
"oauth": "Setup failed (couldn't retrieve OAuth configuration)."
}
},
"settings": {
"deleteCloudSettings": "Delete Cloud Settings",
"deleteError": {
"api": "Could not delete settings (API returned {status}).",
"generic": "Could not delete settings ({error})."
},
"deleted": "Settings deleted from cloud!",
"description": "Synchronize your settings to the cloud. This allows easy synchronization across multiple devices with minimal effort.",
"localNewer": "Your local settings are newer than the cloud ones.",
"nothingOnline": "There are no settings in the cloud.",
"overwriteWarning": "This will overwrite your local settings with the ones on the cloud. Use wisely!",
"syncErrorDown": {
"api": "Could not synchronize settings from the cloud (API returned {status}).",
"generic": "Could not synchronize settings from the cloud ({error})."
},
"syncErrorUp": {
"api": "Could not synchronize settings to the cloud (API returned {status}).",
"generic": "Could not synchronize settings to the cloud ({error})."
},
"syncFromCloud": "Sync from Cloud",
"syncSuccess": "Synchronized settings to the cloud!",
"syncToCloud": "Sync to Cloud",
"upToDate": "Your settings are up to date.",
"updated": "Your settings have been updated! Click here to restart to fully apply changes!"
}
},
"cloudIntegrations": "Cloud Integrations",
"cloudSettings": "Cloud Settings",
"copiedToClipboard": "Copied to clipboard!",
"errorBoundaryDescription": "An error occurred while rendering this Component. More info can be found below and in your console.",
"failedToImport": "Failed to import settings: {error}",
"failureUpdate": "Uh Oh! Failed to render this Page. However, there is an update available that might fix it. Would you like to update and restart now?",
"gitCopyNewer": "Your local copy has more recent commits. Please stash or reset them.",
"importedSettings": "Settings successfully imported. Restart to apply changes!",
"nevermind": "Nevermind",
"new": "NEW",
"noSearchResults": "No plugins meet search criteria.",
"noSettings": "There are no settings for this plugin.",
"ohNo": "Oh no!",
"pluginContributed": {
"one": "This person has <0>contributed</0> to one plugin!",
"other": "This person has <0>contributed</0> to {count} plugins!",
"zero": "This person has not made any plugins. They likely <0>contributed</0> to Vencord in other ways!"
},
"pluginFilters": "Filters",
"pluginHeader": {
"cogWheel": "Plugins with a cog wheel have settings you can modify!",
"iconInformation": "Press the cog wheel or info icon to get more info on a plugin.",
"managementHeader": "Plugin Management",
"reloadDescription": "Restart now to apply new plugins and their settings",
"reloadHeader": "Restart required!",
"restart": "Restart"
},
"pluginRequiredBy": "This plugin is required by:",
"pluginRestart": "Restart to apply changes!",
"plugins": "Plugins",
"pluginsNeedRestart": "The following plugins require a restart:",
"reauthorise": "Reauthorise",
"requiredPlugin": "This plugin is required for Vencord to function.",
"requiredPlugins": "Required Plugins",
"restartLater": "Later!",
"restartNow": "Restart now",
"restartRequired": "Restart required",
"saveAndClose": "Save & Close",
"search": {
"all": "Show All",
"disabled": "Show Disabled",
"enabled": "Show Enabled",
"new": "Show New",
"placeholder": "Search for a plugin..."
},
"settings": "Settings",
"settingsErrors": "You must fix all errors before saving",
"settingsSaveError": "Error while saving: {saveError}",
"settingsSync": "Settings Sync",
"themes": {
"betterDiscord": "BetterDiscord Themes",
"betterDiscordNote": "If using the BD site, click on \"Download\" and place the downloaded .theme.css file into your themes folder.",
"editQuickCss": "Edit QuickCSS",
"findThemes": "Find Themes:",
"loadMissing": "Load missing Themes",
"local": "Local Themes",
"oneLinkPerLine": "One link per line.",
"online": "Online Themes",
"openFolder": "Open Themes Folder",
"pasteLinks": "Paste links to css files here",
"upload": "Upload Theme",
"useDirect": "Make sure to use direct links to files (raw or github.io)!"
},
"update": {
"available": "A Vencord update is available!",
"clickToRestart": "Click here to restart",
"clickToView": "Click here to view the update",
"updated": "Vencord has been updated!"
},
"updaterRepeatFailed": "That also failed :( Try updating or re-installing with the installer!",
"warning": "Warning"
}