feat(desktop): i18n for tauri side
This commit is contained in:
1
bun.lock
1
bun.lock
@@ -186,6 +186,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "~2",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "~2",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { message } from "@tauri-apps/plugin-dialog"
|
import { message } from "@tauri-apps/plugin-dialog"
|
||||||
|
|
||||||
|
import { initI18n, t } from "./i18n"
|
||||||
|
|
||||||
export async function installCli(): Promise<void> {
|
export async function installCli(): Promise<void> {
|
||||||
|
await initI18n()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const path = await invoke<string>("install_cli")
|
const path = await invoke<string>("install_cli")
|
||||||
await message(`CLI installed to ${path}\n\nRestart your terminal to use the 'opencode' command.`, {
|
await message(t("desktop.cli.installed.message", { path }), { title: t("desktop.cli.installed.title") })
|
||||||
title: "CLI Installed",
|
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await message(`Failed to install CLI: ${e}`, { title: "Installation Failed" })
|
await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
packages/desktop/src/i18n/en.ts
Normal file
31
packages/desktop/src/i18n/en.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export const dict = {
|
||||||
|
"desktop.menu.checkForUpdates": "Check for Updates...",
|
||||||
|
"desktop.menu.installCli": "Install CLI...",
|
||||||
|
"desktop.menu.reloadWebview": "Reload Webview",
|
||||||
|
"desktop.menu.restart": "Restart",
|
||||||
|
|
||||||
|
"desktop.dialog.chooseFolder": "Choose a folder",
|
||||||
|
"desktop.dialog.chooseFile": "Choose a file",
|
||||||
|
"desktop.dialog.saveFile": "Save file",
|
||||||
|
|
||||||
|
"desktop.updater.checkFailed.title": "Update Check Failed",
|
||||||
|
"desktop.updater.checkFailed.message": "Failed to check for updates",
|
||||||
|
"desktop.updater.none.title": "No Update Available",
|
||||||
|
"desktop.updater.none.message": "You are already using the latest version of OpenCode",
|
||||||
|
"desktop.updater.downloadFailed.title": "Update Failed",
|
||||||
|
"desktop.updater.downloadFailed.message": "Failed to download update",
|
||||||
|
"desktop.updater.downloaded.title": "Update Downloaded",
|
||||||
|
"desktop.updater.downloaded.prompt":
|
||||||
|
"Version {{version}} of OpenCode has been downloaded, would you like to install it and relaunch?",
|
||||||
|
"desktop.updater.installFailed.title": "Update Failed",
|
||||||
|
"desktop.updater.installFailed.message": "Failed to install update",
|
||||||
|
|
||||||
|
"desktop.cli.installed.title": "CLI Installed",
|
||||||
|
"desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode' command.",
|
||||||
|
"desktop.cli.failed.title": "Installation Failed",
|
||||||
|
"desktop.cli.failed.message": "Failed to install CLI: {{error}}",
|
||||||
|
|
||||||
|
"desktop.error.serverStartFailed.title": "OpenCode failed to start",
|
||||||
|
"desktop.error.serverStartFailed.description":
|
||||||
|
"The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy) and try again.",
|
||||||
|
} as const
|
||||||
134
packages/desktop/src/i18n/index.ts
Normal file
134
packages/desktop/src/i18n/index.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import * as i18n from "@solid-primitives/i18n"
|
||||||
|
import { Store } from "@tauri-apps/plugin-store"
|
||||||
|
|
||||||
|
import { dict as desktopEn } from "./en"
|
||||||
|
|
||||||
|
import { dict as appEn } from "../../../app/src/i18n/en"
|
||||||
|
import { dict as appZh } from "../../../app/src/i18n/zh"
|
||||||
|
import { dict as appZht } from "../../../app/src/i18n/zht"
|
||||||
|
import { dict as appKo } from "../../../app/src/i18n/ko"
|
||||||
|
import { dict as appDe } from "../../../app/src/i18n/de"
|
||||||
|
import { dict as appEs } from "../../../app/src/i18n/es"
|
||||||
|
import { dict as appFr } from "../../../app/src/i18n/fr"
|
||||||
|
import { dict as appDa } from "../../../app/src/i18n/da"
|
||||||
|
import { dict as appJa } from "../../../app/src/i18n/ja"
|
||||||
|
import { dict as appPl } from "../../../app/src/i18n/pl"
|
||||||
|
import { dict as appRu } from "../../../app/src/i18n/ru"
|
||||||
|
import { dict as appAr } from "../../../app/src/i18n/ar"
|
||||||
|
import { dict as appNo } from "../../../app/src/i18n/no"
|
||||||
|
import { dict as appBr } from "../../../app/src/i18n/br"
|
||||||
|
|
||||||
|
export type Locale = "en" | "zh" | "zht" | "ko" | "de" | "es" | "fr" | "da" | "ja" | "pl" | "ru" | "ar" | "no" | "br"
|
||||||
|
|
||||||
|
type RawDictionary = typeof appEn & typeof desktopEn
|
||||||
|
type Dictionary = i18n.Flatten<RawDictionary>
|
||||||
|
|
||||||
|
const LOCALES: readonly Locale[] = ["en", "zh", "zht", "ko", "de", "es", "fr", "da", "ja", "pl", "ru", "ar", "no", "br"]
|
||||||
|
|
||||||
|
function detectLocale(): Locale {
|
||||||
|
if (typeof navigator !== "object") return "en"
|
||||||
|
|
||||||
|
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
||||||
|
for (const language of languages) {
|
||||||
|
if (!language) continue
|
||||||
|
if (language.toLowerCase().startsWith("zh")) {
|
||||||
|
if (language.toLowerCase().includes("hant")) return "zht"
|
||||||
|
return "zh"
|
||||||
|
}
|
||||||
|
if (language.toLowerCase().startsWith("ko")) return "ko"
|
||||||
|
if (language.toLowerCase().startsWith("de")) return "de"
|
||||||
|
if (language.toLowerCase().startsWith("es")) return "es"
|
||||||
|
if (language.toLowerCase().startsWith("fr")) return "fr"
|
||||||
|
if (language.toLowerCase().startsWith("da")) return "da"
|
||||||
|
if (language.toLowerCase().startsWith("ja")) return "ja"
|
||||||
|
if (language.toLowerCase().startsWith("pl")) return "pl"
|
||||||
|
if (language.toLowerCase().startsWith("ru")) return "ru"
|
||||||
|
if (language.toLowerCase().startsWith("ar")) return "ar"
|
||||||
|
if (
|
||||||
|
language.toLowerCase().startsWith("no") ||
|
||||||
|
language.toLowerCase().startsWith("nb") ||
|
||||||
|
language.toLowerCase().startsWith("nn")
|
||||||
|
)
|
||||||
|
return "no"
|
||||||
|
if (language.toLowerCase().startsWith("pt")) return "br"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocale(value: unknown): Locale | null {
|
||||||
|
if (!value) return null
|
||||||
|
if (typeof value !== "string") return null
|
||||||
|
if ((LOCALES as readonly string[]).includes(value)) return value as Locale
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRecord(value: unknown) {
|
||||||
|
if (!value || typeof value !== "object") return null
|
||||||
|
if (Array.isArray(value)) return null
|
||||||
|
return value as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLocale(value: unknown): Locale | null {
|
||||||
|
const direct = parseLocale(value)
|
||||||
|
if (direct) return direct
|
||||||
|
|
||||||
|
const record = parseRecord(value)
|
||||||
|
if (!record) return null
|
||||||
|
|
||||||
|
return parseLocale(record.locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = i18n.flatten({ ...appEn, ...desktopEn })
|
||||||
|
|
||||||
|
function build(locale: Locale): Dictionary {
|
||||||
|
if (locale === "en") return base
|
||||||
|
if (locale === "zh") return { ...base, ...i18n.flatten(appZh) }
|
||||||
|
if (locale === "zht") return { ...base, ...i18n.flatten(appZht) }
|
||||||
|
if (locale === "de") return { ...base, ...i18n.flatten(appDe) }
|
||||||
|
if (locale === "es") return { ...base, ...i18n.flatten(appEs) }
|
||||||
|
if (locale === "fr") return { ...base, ...i18n.flatten(appFr) }
|
||||||
|
if (locale === "da") return { ...base, ...i18n.flatten(appDa) }
|
||||||
|
if (locale === "ja") return { ...base, ...i18n.flatten(appJa) }
|
||||||
|
if (locale === "pl") return { ...base, ...i18n.flatten(appPl) }
|
||||||
|
if (locale === "ru") return { ...base, ...i18n.flatten(appRu) }
|
||||||
|
if (locale === "ar") return { ...base, ...i18n.flatten(appAr) }
|
||||||
|
if (locale === "no") return { ...base, ...i18n.flatten(appNo) }
|
||||||
|
if (locale === "br") return { ...base, ...i18n.flatten(appBr) }
|
||||||
|
return { ...base, ...i18n.flatten(appKo) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
locale: detectLocale(),
|
||||||
|
dict: base as Dictionary,
|
||||||
|
init: undefined as Promise<Locale> | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
state.dict = build(state.locale)
|
||||||
|
|
||||||
|
const translate = i18n.translator(() => state.dict, i18n.resolveTemplate)
|
||||||
|
|
||||||
|
export function t(key: keyof Dictionary, params?: Record<string, string | number>) {
|
||||||
|
return translate(key, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initI18n(): Promise<Locale> {
|
||||||
|
const cached = state.init
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
const store = await Store.load("opencode.global.dat").catch(() => null)
|
||||||
|
if (!store) return state.locale
|
||||||
|
|
||||||
|
const raw = await store.get("language").catch(() => null)
|
||||||
|
const value = typeof raw === "string" ? JSON.parse(raw) : raw
|
||||||
|
const next = pickLocale(value) ?? state.locale
|
||||||
|
|
||||||
|
state.locale = next
|
||||||
|
state.dict = build(next)
|
||||||
|
return next
|
||||||
|
})().catch(() => state.locale)
|
||||||
|
|
||||||
|
state.init = promise
|
||||||
|
return promise
|
||||||
|
}
|
||||||
@@ -18,16 +18,17 @@ import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup }
|
|||||||
|
|
||||||
import { UPDATER_ENABLED } from "./updater"
|
import { UPDATER_ENABLED } from "./updater"
|
||||||
import { createMenu } from "./menu"
|
import { createMenu } from "./menu"
|
||||||
|
import { initI18n, t } from "./i18n"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
import "./styles.css"
|
import "./styles.css"
|
||||||
|
|
||||||
const root = document.getElementById("root")
|
const root = document.getElementById("root")
|
||||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||||
throw new Error(
|
throw new Error(t("error.dev.rootNotFound"))
|
||||||
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void initI18n()
|
||||||
|
|
||||||
// Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements).
|
// Floating UI can call getComputedStyle with non-elements (e.g., null refs, virtual elements).
|
||||||
// This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows.
|
// This happens on all platforms (WebView2 on Windows, WKWebView on macOS), not just Windows.
|
||||||
const originalGetComputedStyle = window.getComputedStyle
|
const originalGetComputedStyle = window.getComputedStyle
|
||||||
@@ -54,7 +55,7 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
|
|||||||
const result = await open({
|
const result = await open({
|
||||||
directory: true,
|
directory: true,
|
||||||
multiple: opts?.multiple ?? false,
|
multiple: opts?.multiple ?? false,
|
||||||
title: opts?.title ?? "Choose a folder",
|
title: opts?.title ?? t("desktop.dialog.chooseFolder"),
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
@@ -63,14 +64,14 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
|
|||||||
const result = await open({
|
const result = await open({
|
||||||
directory: false,
|
directory: false,
|
||||||
multiple: opts?.multiple ?? false,
|
multiple: opts?.multiple ?? false,
|
||||||
title: opts?.title ?? "Choose a file",
|
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|
||||||
async saveFilePickerDialog(opts) {
|
async saveFilePickerDialog(opts) {
|
||||||
const result = await save({
|
const result = await save({
|
||||||
title: opts?.title ?? "Save file",
|
title: opts?.title ?? t("desktop.dialog.saveFile"),
|
||||||
defaultPath: opts?.defaultPath,
|
defaultPath: opts?.defaultPath,
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
@@ -380,7 +381,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
|
|||||||
|
|
||||||
const errorMessage = () => {
|
const errorMessage = () => {
|
||||||
const error = serverData.error
|
const error = serverData.error
|
||||||
if (!error) return "Unknown error"
|
if (!error) return t("error.chain.unknown")
|
||||||
if (typeof error === "string") return error
|
if (typeof error === "string") return error
|
||||||
if (error instanceof Error) return error.message
|
if (error instanceof Error) return error.message
|
||||||
return String(error)
|
return String(error)
|
||||||
@@ -410,16 +411,15 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base gap-4 px-6">
|
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base gap-4 px-6">
|
||||||
<div class="text-16-semibold">OpenCode failed to start</div>
|
<div class="text-16-semibold">{t("desktop.error.serverStartFailed.title")}</div>
|
||||||
<div class="text-12-regular opacity-70 text-center max-w-xl">
|
<div class="text-12-regular opacity-70 text-center max-w-xl">
|
||||||
The local OpenCode server could not be started. Restart the app, or check your network settings (VPN/proxy)
|
{t("desktop.error.serverStartFailed.description")}
|
||||||
and try again.
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full max-w-3xl rounded border border-border bg-background-base overflow-auto max-h-64">
|
<div class="w-full max-w-3xl rounded border border-border bg-background-base overflow-auto max-h-64">
|
||||||
<pre class="p-3 whitespace-pre-wrap break-words text-11-regular">{errorMessage()}</pre>
|
<pre class="p-3 whitespace-pre-wrap break-words text-11-regular">{errorMessage()}</pre>
|
||||||
</div>
|
</div>
|
||||||
<button class="px-3 py-2 rounded bg-primary text-primary-foreground" onClick={() => void restartApp()}>
|
<button class="px-3 py-2 rounded bg-primary text-primary-foreground" onClick={() => void restartApp()}>
|
||||||
Restart App
|
{t("error.page.action.restart")}
|
||||||
</button>
|
</button>
|
||||||
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
|
<div data-tauri-decorum-tb class="flex flex-row absolute top-0 right-0 z-10 h-10" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import { relaunch } from "@tauri-apps/plugin-process"
|
|||||||
|
|
||||||
import { runUpdater, UPDATER_ENABLED } from "./updater"
|
import { runUpdater, UPDATER_ENABLED } from "./updater"
|
||||||
import { installCli } from "./cli"
|
import { installCli } from "./cli"
|
||||||
|
import { initI18n, t } from "./i18n"
|
||||||
|
|
||||||
export async function createMenu() {
|
export async function createMenu() {
|
||||||
if (ostype() !== "macos") return
|
if (ostype() !== "macos") return
|
||||||
|
|
||||||
|
await initI18n()
|
||||||
|
|
||||||
const menu = await Menu.new({
|
const menu = await Menu.new({
|
||||||
items: [
|
items: [
|
||||||
await Submenu.new({
|
await Submenu.new({
|
||||||
@@ -20,22 +23,22 @@ export async function createMenu() {
|
|||||||
await MenuItem.new({
|
await MenuItem.new({
|
||||||
enabled: UPDATER_ENABLED,
|
enabled: UPDATER_ENABLED,
|
||||||
action: () => runUpdater({ alertOnFail: true }),
|
action: () => runUpdater({ alertOnFail: true }),
|
||||||
text: "Check For Updates...",
|
text: t("desktop.menu.checkForUpdates"),
|
||||||
}),
|
}),
|
||||||
await MenuItem.new({
|
await MenuItem.new({
|
||||||
action: () => installCli(),
|
action: () => installCli(),
|
||||||
text: "Install CLI...",
|
text: t("desktop.menu.installCli"),
|
||||||
}),
|
}),
|
||||||
await MenuItem.new({
|
await MenuItem.new({
|
||||||
action: async () => window.location.reload(),
|
action: async () => window.location.reload(),
|
||||||
text: "Reload Webview",
|
text: t("desktop.menu.reloadWebview"),
|
||||||
}),
|
}),
|
||||||
await MenuItem.new({
|
await MenuItem.new({
|
||||||
action: async () => {
|
action: async () => {
|
||||||
await invoke("kill_sidecar").catch(() => undefined)
|
await invoke("kill_sidecar").catch(() => undefined)
|
||||||
await relaunch().catch(() => undefined)
|
await relaunch().catch(() => undefined)
|
||||||
},
|
},
|
||||||
text: "Restart",
|
text: t("desktop.menu.restart"),
|
||||||
}),
|
}),
|
||||||
await PredefinedMenuItem.new({
|
await PredefinedMenuItem.new({
|
||||||
item: "Separator",
|
item: "Separator",
|
||||||
|
|||||||
@@ -4,41 +4,45 @@ import { ask, message } from "@tauri-apps/plugin-dialog"
|
|||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||||
|
|
||||||
|
import { initI18n, t } from "./i18n"
|
||||||
|
|
||||||
export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false
|
export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false
|
||||||
|
|
||||||
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
|
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
|
||||||
|
await initI18n()
|
||||||
|
|
||||||
let update
|
let update
|
||||||
try {
|
try {
|
||||||
update = await check()
|
update = await check()
|
||||||
} catch {
|
} catch {
|
||||||
if (alertOnFail) await message("Failed to check for updates", { title: "Update Check Failed" })
|
if (alertOnFail)
|
||||||
|
await message(t("desktop.updater.checkFailed.message"), { title: t("desktop.updater.checkFailed.title") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!update) {
|
if (!update) {
|
||||||
if (alertOnFail)
|
if (alertOnFail) await message(t("desktop.updater.none.message"), { title: t("desktop.updater.none.title") })
|
||||||
await message("You are already using the latest version of OpenCode", { title: "No Update Available" })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await update.download()
|
await update.download()
|
||||||
} catch {
|
} catch {
|
||||||
if (alertOnFail) await message("Failed to download update", { title: "Update Failed" })
|
if (alertOnFail)
|
||||||
|
await message(t("desktop.updater.downloadFailed.message"), { title: t("desktop.updater.downloadFailed.title") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldUpdate = await ask(
|
const shouldUpdate = await ask(t("desktop.updater.downloaded.prompt", { version: update.version }), {
|
||||||
`Version ${update.version} of OpenCode has been downloaded, would you like to install it and relaunch?`,
|
title: t("desktop.updater.downloaded.title"),
|
||||||
{ title: "Update Downloaded" },
|
})
|
||||||
)
|
|
||||||
if (!shouldUpdate) return
|
if (!shouldUpdate) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (ostype() === "windows") await invoke("kill_sidecar")
|
if (ostype() === "windows") await invoke("kill_sidecar")
|
||||||
await update.install()
|
await update.install()
|
||||||
} catch {
|
} catch {
|
||||||
await message("Failed to install update", { title: "Update Failed" })
|
await message(t("desktop.updater.installFailed.message"), { title: t("desktop.updater.installFailed.title") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user