chinese localization!!

This commit is contained in:
2026-09-03 16:21:01 +08:00
parent 34f35ef208
commit b731ec50d0
49 changed files with 1404 additions and 295 deletions
+10 -1
View File
@@ -89,6 +89,12 @@ async completeOnboarding() : Promise<null> {
async requestAccessibilityPermission() : Promise<boolean> {
return await TAURI_INVOKE("request_accessibility_permission");
},
async getLocaleSettings() : Promise<LocaleChanged> {
return await TAURI_INVOKE("get_locale_settings");
},
async setLocalePreference(preference: string) : Promise<LocaleChanged> {
return await TAURI_INVOKE("set_locale_preference", { preference });
},
async openActionWindow(name: string, title: string, pageUrl: string) : Promise<null> {
return await TAURI_INVOKE("open_action_window", { name, title, pageUrl });
},
@@ -107,6 +113,7 @@ friendForegroundAppChanged: FriendForegroundAppChanged,
friendInteractionReceived: FriendInteractionReceived,
friendStatusesChanged: FriendStatusesChanged,
friendsChanged: FriendsChanged,
localeChanged: LocaleChanged,
networkStatusChanged: NetworkStatusChanged,
onboardingStatus: OnboardingStatus,
profileChanged: ProfileChanged,
@@ -120,6 +127,7 @@ friendForegroundAppChanged: "friend-foreground-app-changed",
friendInteractionReceived: "friend-interaction-received",
friendStatusesChanged: "friend-statuses-changed",
friendsChanged: "friends-changed",
localeChanged: "locale-changed",
networkStatusChanged: "network-status-changed",
onboardingStatus: "onboarding-status",
profileChanged: "profile-changed",
@@ -163,6 +171,7 @@ export type FriendStatusesChanged = { friendIds: string[] }
export type FriendsChanged = { friends: Friend[] }
export type InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string }
export type LiveDataSnapshot = { cursorPositions: Partial<{ [key in string]: CursorPositions }>; foregroundApps: Partial<{ [key in string]: AppMeta }> }
export type LocaleChanged = { preference: string; locale: string }
export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
export type OnboardingStatus = { onboardingDone: boolean; macosAccessibilityPermissionGranted: boolean; requiresAccessibilityPermission: boolean }
export type ProfileChanged = { profile: User }
@@ -179,7 +188,7 @@ export type SceneHitbox = { x: number; y: number; width: number; height: number
* Public user identity exchanged with peers and remote servers. `id` is the
* user's Ed25519 public key and is not persisted as local profile metadata.
*/
export type User = { id: string; displayName: string; skinHash: string | null }
export type User = { id: string; displayName: string; displayNameConfigured: boolean; skinHash: string | null }
/** tauri-specta globals **/
@@ -13,6 +13,7 @@
import PanelMessage from "./panel-message.svelte";
import type { RegisterPanel } from "./types";
import Info from "$lib/icons/info.svelte";
import { errorMessage, messages } from "$lib/i18n";
let { register }: { register: RegisterPanel } = $props();
@@ -44,11 +45,11 @@
try {
await commands.openActionWindow(
"friend",
"Add Friend",
$messages.action_add_friend_title(),
"/control-panel/add/friend",
);
} catch (cause) {
error = String(cause);
error = errorMessage(cause);
} finally {
busy = false;
}
@@ -57,7 +58,7 @@
async function apply() {
if (mode === "browse") return true;
if (mode === "remove" && !selected) {
error = "The selected friend no longer exists.";
error = $messages.error_friend_missing();
return false;
}
@@ -71,7 +72,7 @@
reset();
return true;
} catch (cause) {
error = String(cause);
error = errorMessage(cause);
return false;
} finally {
busy = false;
@@ -82,20 +83,20 @@
<div class="flex h-full min-h-0 flex-col gap-2">
{#if error || $friendsListenerError || $friendStatusesListenerError}
<PanelMessage kind="error"
>{error ||
$friendsListenerError ||
$friendStatusesListenerError}</PanelMessage
>{errorMessage(
error || $friendsListenerError || $friendStatusesListenerError,
)}</PanelMessage
>
{/if}
{#if mode === "remove" && selected}
<PanelMessage kind="warning">
Applying will remove <strong
>{friendName(selected, "Unknown friend")}</strong
>. They will immediately go offline and stop receiving your activity.
{$messages.friends_remove_prefix()}<strong
>{friendName(selected, $messages.common_unknown_friend())}</strong
>{$messages.friends_remove_suffix()}
</PanelMessage>
<button class="btn w-fit" type="button" onclick={() => (mode = "browse")}
>Keep friend</button
>{$messages.friends_keep()}</button
>
{:else}
<div
@@ -104,20 +105,22 @@
<div
class="grid grid-cols-[1fr_4.5rem] border-b border-base-300 bg-base-200 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-base-content/60"
>
<span>Name</span><span>Status</span>
<span>{$messages.common_name()}</span><span
>{$messages.common_status()}</span
>
</div>
<div
class="min-h-0 flex-1 overflow-y-auto"
role="listbox"
aria-label="Friends"
aria-label={$messages.friends_list_label()}
>
{#if $friends.length === 0}
<div
class="grid h-full min-h-36 place-content-center px-8 text-center"
>
<p class="text-xs font-bold">No friends yet</p>
<p class="text-xs font-bold">{$messages.friends_empty()}</p>
<p class="mt-1 text-xs text-base-content/60">
Add someone using their public key.
{$messages.friends_empty_help()}
</p>
</div>
{:else}
@@ -136,12 +139,15 @@
<strong
class:italic={!friend.displayName}
class="block truncate"
>{friendName(friend, "Unknown friend")}</strong
>{friendName(
friend,
$messages.common_unknown_friend(),
)}</strong
>
{#if !friend.displayName}
<div
class="tooltip tooltip-primary"
data-tip={"Will be resolved when they're online"}
data-tip={$messages.friends_resolve_online()}
>
<div class="*:size-3 opacity-50">
<Info />
@@ -149,14 +155,16 @@
</div>
{/if}
</div>
<span class="block truncate font-mono text-[9px] opacity-65"
<span class="block truncate text-[9px] opacity-65"
>{friend.id}</span
>
</span>
<span class="flex items-center gap-1.5">
<span class:status-success={online} class="status shadow-none"
></span>
{online ? "Online" : "Offline"}
{online
? $messages.common_online()
: $messages.common_offline()}
</span>
</button>
{/each}
@@ -169,13 +177,13 @@
class="btn"
type="button"
disabled={busy}
onclick={openAddActionWindow}>Add…</button
onclick={openAddActionWindow}>{$messages.common_add()}</button
>
<button
class="btn"
type="button"
disabled={!selected}
onclick={() => (mode = "remove")}>Remove</button
onclick={() => (mode = "remove")}>{$messages.common_remove()}</button
>
</div>
{/if}
@@ -5,6 +5,13 @@
import PanelMessage from "./panel-message.svelte";
import type { RegisterPanel } from "./types";
import Info from "$lib/icons/info.svelte";
import {
errorMessage,
languageOptions,
localePreference,
messages,
setLanguagePreference,
} from "$lib/i18n";
let { register }: { register: RegisterPanel } = $props();
@@ -12,6 +19,21 @@
let dirty = $state(false);
let busy = $state(false);
let error = $state("");
let languageBusy = $state(false);
async function changeLanguage(event: Event) {
languageBusy = true;
error = "";
try {
await setLanguagePreference(
(event.currentTarget as HTMLSelectElement).value,
);
} catch (cause) {
error = errorMessage(cause);
} finally {
languageBusy = false;
}
}
$effect(() => {
if (!dirty) displayName = $profile?.displayName ?? "";
@@ -35,7 +57,7 @@
const nextName = displayName.trim();
if (!dirty) return true;
if (!nextName) {
error = "Display name cannot be empty.";
error = $messages.error_display_name_empty();
return false;
}
@@ -47,7 +69,7 @@
dirty = false;
return true;
} catch (cause) {
error = String(cause);
error = errorMessage(cause);
return false;
} finally {
busy = false;
@@ -57,13 +79,17 @@
<div class="space-y-3">
{#if error || $profileListenerError}
<PanelMessage kind="error">{error || $profileListenerError}</PanelMessage>
<PanelMessage kind="error"
>{errorMessage(error || $profileListenerError)}</PanelMessage
>
{/if}
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
<legend class="fieldset-legend px-1">Identity</legend>
<legend class="fieldset-legend px-1">{$messages.account_identity()}</legend>
<label class="fieldset-label" for="display-name">Display name</label>
<label class="fieldset-label" for="display-name"
>{$messages.common_display_name()}</label
>
<input
id="display-name"
class="input w-full"
@@ -75,14 +101,14 @@
aria-describedby="display-name-help"
/>
<p id="display-name-help" class="fieldset-label">
This name can be changed at any time.
{$messages.account_name_help()}
</p>
<label class="fieldset-label mt-2" for="public-key"
>Identification Key
>{$messages.common_identification_key()}
<div
class="tooltip tooltip-primary"
data-tip="Share only with people you trust."
data-tip={$messages.account_key_trust()}
>
<div class="*:size-3">
<Info />
@@ -93,27 +119,48 @@
<div class="join w-full">
<input
id="public-key"
class="input join-item min-w-0 flex-1 font-mono text-[10px]"
class="input join-item min-w-0 flex-1 text-[10px]"
value={$profile.id}
readonly
aria-label="Public key"
aria-label={$messages.account_public_key()}
/>
<button
class="btn join-item"
type="button"
onclick={() => navigator.clipboard.writeText($profile?.id ?? "")}
title="Copy public key">Copy</button
title={$messages.account_copy_key()}>{$messages.common_copy()}</button
>
</div>
{:else if $profileListenerError}
<input
id="public-key"
class="input w-full"
value="Unavailable"
value={$messages.common_unavailable()}
disabled
/>
{:else}
<div class="skeleton h-7 w-full" aria-label="Loading public key"></div>
<div
class="skeleton h-7 w-full"
aria-label={$messages.account_loading_key()}
></div>
{/if}
</fieldset>
<fieldset class="fieldset border border-base-300 bg-base-100 p-3">
<legend class="fieldset-legend px-1">{$messages.account_ui_label()}</legend>
<label class="fieldset-label" for="language"
>{$messages.language_label()}</label
>
<select
id="language"
class="select w-full"
value={$localePreference}
disabled={languageBusy}
onchange={changeLanguage}
>
{#each languageOptions as option (option.value)}
<option value={option.value}>{option.label($messages)}</option>
{/each}
</select>
</fieldset>
</div>
@@ -8,6 +8,7 @@
import { onMount } from "svelte";
import PanelMessage from "./panel-message.svelte";
import type { RegisterPanel } from "./types";
import { errorMessage, messages } from "$lib/i18n";
let { register }: { register: RegisterPanel } = $props();
@@ -41,11 +42,11 @@
try {
await commands.openActionWindow(
"network",
"Add Server",
$messages.action_add_server_title(),
"/control-panel/add/network",
);
} catch (cause) {
error = String(cause);
error = errorMessage(cause);
} finally {
busy = false;
}
@@ -57,11 +58,11 @@
try {
await commands.openActionWindow(
`network-edit-${id}`,
"Edit Server",
$messages.action_edit_server_title(),
`/control-panel/edit/network/${encodeURIComponent(id)}`,
);
} catch (cause) {
error = String(cause);
error = errorMessage(cause);
} finally {
busy = false;
}
@@ -79,7 +80,7 @@
try {
await commands.reorderRemotes(ordered.map((remote) => remote.id));
} catch (cause) {
error = String(cause);
error = errorMessage(cause);
} finally {
busy = false;
}
@@ -93,7 +94,7 @@
async function apply() {
if (mode === "browse") return true;
if (mode === "remove" && !selected) {
error = "The selected server no longer exists.";
error = $messages.error_server_missing();
return false;
}
@@ -107,7 +108,7 @@
reset();
return true;
} catch (cause) {
error = String(cause);
error = errorMessage(cause);
return false;
} finally {
busy = false;
@@ -118,19 +119,20 @@
<div class="flex h-full min-h-0 flex-col gap-2">
{#if error || $remotesListenerError || $connectionStatusesListenerError}
<PanelMessage kind="error"
>{error ||
$remotesListenerError ||
$connectionStatusesListenerError}</PanelMessage
>{errorMessage(
error || $remotesListenerError || $connectionStatusesListenerError,
)}</PanelMessage
>
{/if}
{#if mode === "remove" && selected}
<PanelMessage kind="warning">
Applying will remove <strong>{selected.name ?? selected.address}</strong>.
Existing connections through this server may stop.
{$messages.network_remove_prefix()}<strong
>{selected.name ?? selected.address}</strong
>{$messages.network_existing_connections_stop()}
</PanelMessage>
<button class="btn w-fit" type="button" onclick={() => (mode = "browse")}
>Keep server</button
>{$messages.network_keep()}</button
>
{:else}
<div
@@ -139,20 +141,22 @@
<div
class="grid grid-cols-[1fr_5.5rem_3.5rem] border-b border-base-300 bg-base-200 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-base-content/60"
>
<span>Server</span><span>Connection</span><span>Priority</span>
<span>{$messages.network_server()}</span><span
>{$messages.network_connection()}</span
><span>{$messages.network_priority()}</span>
</div>
<div
class="min-h-0 flex-1 overflow-y-auto"
role="group"
aria-label="Configured servers"
aria-label={$messages.network_configured_servers()}
>
{#if $remotes.length === 0}
<div
class="grid h-full min-h-36 place-content-center px-8 text-center"
>
<p class="text-xs font-bold">No servers configured</p>
<p class="text-xs font-bold">{$messages.network_no_servers()}</p>
<p class="mt-1 text-xs text-base-content/60">
Add a server to connect with friends.
{$messages.network_empty_help()}
</p>
</div>
{:else}
@@ -185,22 +189,26 @@
class:status-warning={state === "connecting"}
class="status shadow-none"
></span>
{state}
{state === "connected"
? $messages.network_connected()
: state === "connecting"
? $messages.network_connecting()
: $messages.network_disconnected()}
</span>
<span class="flex justify-end gap-0.5">
<button
class="btn btn-xs px-1"
type="button"
aria-label={`Increase priority for ${remote.name ?? remote.address}`}
title="Move up"
aria-label={`${$messages.network_move_up()}: ${remote.name ?? remote.address}`}
title={$messages.network_move_up()}
disabled={busy || index === 0}
onclick={() => moveRemote(remote.id, -1)}>↑</button
>
<button
class="btn btn-xs px-1"
type="button"
aria-label={`Decrease priority for ${remote.name ?? remote.address}`}
title="Move down"
aria-label={`${$messages.network_move_down()}: ${remote.name ?? remote.address}`}
title={$messages.network_move_down()}
disabled={busy || index === $remotes.length - 1}
onclick={() => moveRemote(remote.id, 1)}>↓</button
>
@@ -216,20 +224,20 @@
class="btn"
type="button"
disabled={busy}
onclick={openAddActionWindow}>Add…</button
onclick={openAddActionWindow}>{$messages.common_add()}</button
>
<div class="flex flex-row gap-1.5">
<button
class="btn join-item"
type="button"
disabled={!selected || busy}
onclick={() => selected && openEditWindow(selected.id)}>Edit…</button
onclick={() => selected && openEditWindow(selected.id)}>{$messages.common_edit()}</button
>
<button
class="btn join-item"
type="button"
disabled={!selected}
onclick={() => (mode = "remove")}>Remove</button
onclick={() => (mode = "remove")}>{$messages.common_remove()}</button
>
</div>
</div>
@@ -9,6 +9,7 @@
import PuppetPreview from "../../../routes/scene/components/renderer/puppet/preview.svelte";
import PanelMessage from "./panel-message.svelte";
import type { RegisterPanel } from "./types";
import { errorMessage, messages } from "$lib/i18n";
let { register }: { register: RegisterPanel } = $props();
@@ -114,7 +115,7 @@
} catch (cause) {
dirty =
hasConfigurationChanges() || skinFile !== null || pendingSkinReset;
error = String(cause);
error = errorMessage(cause);
return false;
} finally {
busy = false;
@@ -125,7 +126,9 @@
<div class="space-y-1">
{#if error || $sceneConfigurationListenerError || $profileListenerError}
<PanelMessage kind="error">
{error || $sceneConfigurationListenerError || $profileListenerError}
{errorMessage(
error || $sceneConfigurationListenerError || $profileListenerError,
)}
</PanelMessage>
{/if}
@@ -142,8 +145,8 @@
class="flex flex-row size-full items-end justify-between text-[10px] text-primary p-1"
>
<div class="text-start flex flex-col">
<p>Scale</p>
<p>Opacity</p>
<p>{$messages.scene_scale()}</p>
<p>{$messages.scene_opacity()}</p>
</div>
<div class="text-end flex flex-col">
<p>{(puppetScale * 100).toFixed(0)}%</p>
@@ -164,7 +167,7 @@
{:else}
<div
class="skeleton aspect-square size-full"
aria-label="Loading puppet preview"
aria-label={$messages.scene_loading_preview()}
></div>
{/if}
</div>
@@ -172,9 +175,13 @@
<div class="min-w-0 space-y-2">
<fieldset class="fieldset border border-base-300 bg-base-100 p-3 pt-1">
<legend class="fieldset-legend px-1">All puppets</legend>
<legend class="fieldset-legend px-1"
>{$messages.scene_all_puppets()}</legend
>
<div>
<label class="fieldset-label" for="puppet-scale">Scale</label>
<label class="fieldset-label" for="puppet-scale"
>{$messages.scene_scale()}</label
>
<input
id="puppet-scale"
class="range range-sm"
@@ -190,7 +197,9 @@
</div>
<div>
<label class="fieldset-label" for="puppet-opacity">Opacity</label>
<label class="fieldset-label" for="puppet-opacity"
>{$messages.scene_opacity()}</label
>
<input
id="puppet-opacity"
class="range range-sm"
@@ -212,7 +221,7 @@
disabled={!$profile || busy}
onclick={() => skinInput?.click()}
>
Choose a skin
{$messages.scene_choose_skin()}
</button>
<button
class="btn"
@@ -220,7 +229,7 @@
disabled={!$profile || busy || (!$profile.skinHash && !skinFile)}
onclick={resetSkin}
>
Reset
{$messages.common_reset()}
</button>
<input
id="skin-file"
@@ -235,7 +244,7 @@
</div>
</div>
<fieldset class="fieldset border border-base-300 bg-base-100 p-3 pt-1">
<legend class="fieldset-legend px-1">Puppets reposition</legend>
<legend class="fieldset-legend px-1">{$messages.puppet_movement()}</legend>
<div class="grid grid-cols-2 gap-2">
<label
class:border-primary={puppetMovementMode === "free"}
@@ -243,7 +252,7 @@
>
<img
src="/puppet-movement-free.svg"
alt="Puppets moving freely across the full viewport"
alt={$messages.puppet_free_alt()}
class="w-full object-cover bg-linear-to-b from-base-100 to-base-300"
class:from-primary-content={puppetMovementMode === "free"}
/>
@@ -252,7 +261,7 @@
class:bg-primary-content={puppetMovementMode === "free"}
class:text-primary={puppetMovementMode === "free"}
>
<span class="text-xs font-medium">Freeroam around</span>
<span class="text-xs font-medium">{$messages.puppet_free()}</span>
<input
class="hidden"
type="radio"
@@ -275,7 +284,7 @@
>
<img
src="/puppet-movement-bottom.svg"
alt="Puppets moving along the bottom of the viewport"
alt={$messages.puppet_bottom_alt()}
class="w-full object-cover bg-linear-to-b from-base-100 to-base-300"
class:from-primary-content={puppetMovementMode === "bottom"}
/>
@@ -283,7 +292,7 @@
class="card-body items-center gap-2 p-2 text-center"
class:bg-primary-content={puppetMovementMode === "bottom"}
>
<span class="text-xs font-medium">Stay on the ground</span>
<span class="text-xs font-medium">{$messages.puppet_bottom()}</span>
<input
class="hidden"
type="radio"
+138
View File
@@ -0,0 +1,138 @@
import { derived, writable } from "svelte/store";
import { commands, events, type LocaleChanged } from "$lib/bindings";
import * as compiledMessages from "../../paraglide/messages.js";
import {
baseLocale,
getLocale,
isLocale,
setLocale,
type Locale,
} from "../../paraglide/runtime.js";
export type MessageCatalog = typeof compiledMessages;
export const locale = writable<Locale>(baseLocale);
export const localePreference = writable("system");
// A new object is emitted for each locale so Svelte reevaluates every message
// call without requiring a provider in every Tauri window.
export const messages = derived(locale, () => ({ ...compiledMessages }));
export const languageOptions = [
{
value: "system",
label: (catalog: MessageCatalog) => catalog.language_system(),
},
{
value: "en",
label: (catalog: MessageCatalog) => catalog.language_english(),
},
{
value: "zh-CN",
label: (catalog: MessageCatalog) => catalog.language_chinese(),
},
] as const;
async function applyLocaleSettings(settings: LocaleChanged) {
const nextLocale = isLocale(settings.locale) ? settings.locale : baseLocale;
await setLocale(nextLocale, { reload: false });
localePreference.set(settings.preference);
locale.set(nextLocale);
document.documentElement.lang = nextLocale;
}
export async function initLocalization(): Promise<() => void> {
const unlisten = await events.localeChanged.listen((event) => {
void applyLocaleSettings(event.payload);
});
try {
await applyLocaleSettings(await commands.getLocaleSettings());
return unlisten;
} catch (error) {
unlisten();
const fallback = getLocale();
locale.set(fallback);
document.documentElement.lang = fallback;
throw error;
}
}
export async function setLanguagePreference(preference: string) {
await applyLocaleSettings(await commands.setLocalePreference(preference));
}
export function errorMessage(cause: unknown): string {
const value = cause instanceof Error ? cause.message : String(cause);
switch (value) {
case "Display name cannot be empty.":
return compiledMessages.error_display_name_empty();
case "Server address is required.":
return compiledMessages.error_server_address_required();
case "Port must be a whole number from 1 to 65535.":
return compiledMessages.error_port_invalid();
case "Identification key is required.":
return compiledMessages.error_friend_key_required();
case "No server was selected.":
return compiledMessages.error_server_not_selected();
case "This server no longer exists.":
return compiledMessages.error_server_missing();
case "The selected friend no longer exists.":
return compiledMessages.error_friend_missing();
case "Your profile is still loading.":
return compiledMessages.error_profile_loading();
case "Choose a 64×64 PNG skin before continuing.":
return compiledMessages.error_skin_required();
case "Turn on Friendolls in macOS Accessibility settings before continuing.":
return compiledMessages.error_accessibility_required();
case "Identification key is not valid base64url.":
return compiledMessages.error_key_invalid_base64();
case "Identification key must encode a 32-byte public key.":
return compiledMessages.error_key_invalid_length();
case "Identification key is not a valid Ed25519 public key.":
return compiledMessages.error_key_invalid_ed25519();
case "You cannot add your own identification key.":
return compiledMessages.error_own_key();
case "The selected image is not a local file":
return compiledMessages.error_image_not_local();
case "The selected image could not be read":
return compiledMessages.error_image_unreadable();
case "Choose an image file":
return compiledMessages.error_image_choose();
case "The selected image could not be decoded":
return compiledMessages.error_image_decode();
case "Image compression failed":
return compiledMessages.error_image_compression();
case "Image is still too detailed after compression":
return compiledMessages.error_image_too_detailed();
case "No connected relay can currently reach this friend":
return compiledMessages.error_friend_unreachable();
case "Friend is busy; try again":
return compiledMessages.error_friend_busy();
case "Relay rejected the interaction":
return compiledMessages.error_interaction_rejected();
case "Friend is no longer available":
return compiledMessages.error_friend_unavailable();
case "Skin must be a valid PNG image":
return compiledMessages.error_skin_invalid_png();
default:
{
const imageLimit =
/^The selected image must be at most (\d+) MiB$/u.exec(value);
if (imageLimit) {
return compiledMessages.error_image_too_large({
max: Number(imageLimit[1]),
});
}
const skinDimensions = /^Skin must be (\d+)×(\d+) pixels$/u.exec(value);
if (skinDimensions) {
return compiledMessages.error_skin_dimensions({
width: Number(skinDimensions[1]),
height: Number(skinDimensions[2]),
});
}
}
if (getLocale() !== "en" && /[\u3400-\u9fff]/u.test(value)) return value;
return getLocale() === "en" ? value : compiledMessages.error_unexpected();
}
}