feat: Rename to Toju and add translation
Some checks failed
Deploy Web Apps / deploy (push) Successful in 5m52s
Build Android APK / build-android-apk (push) Failing after 23m15s
Queue Release Build / prepare (push) Successful in 1m42s
Queue Release Build / build-linux (push) Failing after 9m33s
Queue Release Build / build-windows (push) Successful in 26m5s
Queue Release Build / finalize (push) Has been skipped
Some checks failed
Deploy Web Apps / deploy (push) Successful in 5m52s
Build Android APK / build-android-apk (push) Failing after 23m15s
Queue Release Build / prepare (push) Successful in 1m42s
Queue Release Build / build-linux (push) Failing after 9m33s
Queue Release Build / build-windows (push) Successful in 26m5s
Queue Release Build / finalize (push) Has been skipped
This commit is contained in:
@@ -8,6 +8,7 @@ It must stay accurate as new features are introduced, renamed, merged, or remove
|
||||
|
||||
## Feature list (alphabetical)
|
||||
|
||||
- [App i18n](features/app-i18n.md) — `@ngx-translate/core` localization for the product client; English-only catalog today, same stack as the marketing website.
|
||||
- [Custom Emoji](features/custom-emoji.md) — peer-synced user-created emoji assets, chat reaction shortcuts, and composer emoji insertion.
|
||||
- [Mobile Capacitor](features/mobile-capacitor.md) — Capacitor native shell, mobile infrastructure facades, and phone-specific call/chat/media integrations.
|
||||
- [Server Discovery](features/server-discovery.md) — featured/trending public-server REST endpoints (server) consumed by the `/dashboard` and `/servers` client pages.
|
||||
|
||||
62
agents-docs/features/app-i18n.md
Normal file
62
agents-docs/features/app-i18n.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# App i18n
|
||||
|
||||
Client-side UI string localization for the product client (`toju-app`), using the same `@ngx-translate/core` stack as the marketing website.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Bundle locale JSON under `toju-app/public/i18n/`.
|
||||
- Bootstrap translations at app startup via `AppI18nService` (root `App` constructor).
|
||||
- Expose `APP_TRANSLATE_IMPORTS` for standalone components that use the `translate` pipe in templates.
|
||||
- Resolve the active locale through `resolveAppLocale()` in `app-i18n.rules.ts`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- **In scope:** user-visible UI copy in the Angular product client.
|
||||
- **Out of scope:** server error messages, plugin-authored strings, Electron IPC payloads, and marketing-site copy (`website/public/i18n/`).
|
||||
|
||||
## Key files
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `toju-app/public/i18n/en.json` | English translation catalog (only locale shipped today). |
|
||||
| `toju-app/src/app/core/i18n/app-i18n.rules.ts` | Supported locales and locale resolution. |
|
||||
| `toju-app/src/app/core/i18n/app-i18n.service.ts` | Loads bundled JSON into `TranslateService`. |
|
||||
| `toju-app/src/app/core/i18n/app-translate.imports.ts` | `TranslateModule` import bundle for standalone components. |
|
||||
| `toju-app/src/app/app.config.ts` | `provideTranslateService()` registration. |
|
||||
|
||||
## Usage
|
||||
|
||||
**Templates** — import `APP_TRANSLATE_IMPORTS` in the standalone component and use the pipe:
|
||||
|
||||
```html
|
||||
{{ 'common.brand' | translate }}
|
||||
```
|
||||
|
||||
**TypeScript** — inject `AppI18nService` (or `TranslateService`) and call `instant()`:
|
||||
|
||||
```ts
|
||||
this.appI18n.instant('common.brand');
|
||||
```
|
||||
|
||||
## Catalog workflow
|
||||
|
||||
User-visible strings live in fragment files under `toju-app/public/i18n/catalog/*.json`, merged into `toju-app/public/i18n/en.json` by:
|
||||
|
||||
```bash
|
||||
npm run i18n:sync
|
||||
```
|
||||
|
||||
The sync script also extracts `theme.registry.*` labels/descriptions from `theme-registry.logic.ts` and `permissions.*` from `access-control.constants.ts` so those large registries stay DRY. Extracted prefixes use dotted paths and are merged as nested JSON (e.g. `theme.registry.appShell.label`, not a flat `"theme.registry"` root key).
|
||||
|
||||
## Adding a locale later
|
||||
|
||||
1. Add `toju-app/public/i18n/catalog/*.json` fragments for the new locale (or mirror `en.json` structure).
|
||||
2. Register the locale in `SUPPORTED_APP_LOCALES`.
|
||||
3. Import and `setTranslation()` in `AppI18nService`.
|
||||
4. Wire user preference (e.g. general settings) to `AppI18nService.initialize(preferredLocale)`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `toju-app/src/app/core/i18n/app-i18n.rules.spec.ts`
|
||||
- `toju-app/src/app/core/i18n/app-i18n.service.spec.ts`
|
||||
- `toju-app/src/app/core/i18n/app-i18n.testing.ts` — `provideAppI18nForTests()` / `initializeAppI18nForTests()` for Vitest injectors
|
||||
@@ -15,7 +15,7 @@ Owns the Docusaurus-based application and plugin-author documentation. The build
|
||||
|
||||
| Term | Definition | Aliases to avoid |
|
||||
|------|------------|------------------|
|
||||
| **App docs** | End-user-facing documentation for the MetoYou desktop client. | "manual" |
|
||||
| **App docs** | End-user-facing documentation for the Toju desktop client. | "manual" |
|
||||
| **Plugin docs** | Developer-facing reference for the plugin runtime — manifest format, lifecycle hooks, host APIs. Authoritative source for the plugin contract surface. | "API docs" |
|
||||
| **Local API server** | The Electron in-process HTTP server that mounts `docs-site/build/` so the renderer can browse docs offline. Defined under `electron/api/`. | "embedded server" |
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ This avoids:
|
||||
1. Add trusted signaling server URLs in desktop settings.
|
||||
2. Start the Local API server.
|
||||
3. Call `POST /api/auth/login` with `username`, `password`, and `serverUrl`.
|
||||
4. MetoYou validates credentials through the signaling server.
|
||||
4. Toju validates credentials through the signaling server.
|
||||
5. The desktop app issues an opaque local bearer token.
|
||||
6. Use `Authorization: Bearer <token>` for protected routes.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ sidebar_position: 1
|
||||
|
||||
# Contributing
|
||||
|
||||
MetoYou is an npm-managed monorepo.
|
||||
Toju is an npm-managed monorepo.
|
||||
|
||||
## Packages
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ sidebar_position: 5
|
||||
|
||||
# LLM Plugin Builder Guide
|
||||
|
||||
Copy this page into an LLM prompt when you want it to build a MetoYou plugin. It is intentionally explicit about the app, communication model, visual structure, manifest format, runtime rules, API types, and examples so the model has fewer gaps to invent around.
|
||||
Copy this page into an LLM prompt when you want it to build a Toju plugin. It is intentionally explicit about the app, communication model, visual structure, manifest format, runtime rules, API types, and examples so the model has fewer gaps to invent around.
|
||||
|
||||
## Task For The LLM
|
||||
|
||||
Build a MetoYou client plugin: a browser-safe JavaScript ES module with a `toju-plugin.json` manifest, loaded by the Angular renderer, running inside the user's local MetoYou app, using only browser APIs and the provided `TojuClientPluginApi`.
|
||||
Build a Toju client plugin: a browser-safe JavaScript ES module with a `toju-plugin.json` manifest, loaded by the Angular renderer, running inside the user's local Toju app, using only browser APIs and the provided `TojuClientPluginApi`.
|
||||
|
||||
Return a plugin folder like this:
|
||||
|
||||
@@ -22,7 +22,7 @@ my-plugin/
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Do not modify MetoYou core unless the user explicitly asks for a core code change.
|
||||
- Do not modify Toju core unless the user explicitly asks for a core code change.
|
||||
- Use plain browser ESM in `main.js`. Do not use Node APIs, `require`, `fs`, `path`, `child_process`, or build tooling unless explicitly requested.
|
||||
- Use `toju-plugin.json` as the manifest name.
|
||||
- Put every disposable returned by plugin APIs in `context.subscriptions`.
|
||||
@@ -35,9 +35,9 @@ my-plugin/
|
||||
- Server-installed plugins are requirement metadata plus local client downloads. The signaling server never executes plugin entrypoints.
|
||||
- Every event used with `api.events.*` must be declared in the manifest `events` array.
|
||||
|
||||
## What MetoYou Is
|
||||
## What Toju Is
|
||||
|
||||
MetoYou is a Discord-like chat and voice app:
|
||||
Toju is a Discord-like chat and voice app:
|
||||
|
||||
- `toju-app/`: Angular renderer and plugin runtime.
|
||||
- `electron/`: Electron desktop shell, preload bridge, local database, local REST API, local docs host.
|
||||
@@ -178,7 +178,7 @@ Minimal manifest:
|
||||
"schemaVersion": 1,
|
||||
"id": "example.my-plugin",
|
||||
"title": "My Plugin",
|
||||
"description": "Adds a focused MetoYou feature.",
|
||||
"description": "Adds a focused Toju feature.",
|
||||
"version": "1.0.0",
|
||||
"kind": "client",
|
||||
"scope": "client",
|
||||
@@ -855,7 +855,7 @@ const currentUser = api.profile.getCurrent();
|
||||
|
||||
api.profile.update({
|
||||
displayName: 'Ludde the Builder',
|
||||
description: 'Building plugins for MetoYou.'
|
||||
description: 'Building plugins for Toju.'
|
||||
});
|
||||
|
||||
api.profile.updateAvatar({
|
||||
|
||||
@@ -4,7 +4,7 @@ sidebar_position: 4
|
||||
|
||||
# Local REST API
|
||||
|
||||
The MetoYou desktop app exposes an optional local HTTP API for scripts and tools. It is implemented in Electron and reads local desktop data.
|
||||
The Toju desktop app exposes an optional local HTTP API for scripts and tools. It is implemented in Electron and reads local desktop data.
|
||||
|
||||
## Enable the API
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ slug: /
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# MetoYou Documentation
|
||||
# Toju Documentation
|
||||
|
||||
MetoYou is a desktop-first chat app with text channels, voice channels, direct messages, plugins, local desktop storage, a local REST API, and a Docusaurus documentation site bundled into the app.
|
||||
Toju is a desktop-first chat app with text channels, voice channels, direct messages, plugins, local desktop storage, a local REST API, and a Docusaurus documentation site bundled into the app.
|
||||
|
||||
This site is split into three paths:
|
||||
|
||||
@@ -26,7 +26,7 @@ The Electron app can host this documentation locally. The docs endpoint is not a
|
||||
|
||||
## Runtime Boundaries
|
||||
|
||||
MetoYou keeps responsibilities split by package:
|
||||
Toju keeps responsibilities split by package:
|
||||
|
||||
- `toju-app/` is the Angular product client and plugin runtime.
|
||||
- `electron/` is the main process, preload bridge, IPC, local persistence, and local HTTP host.
|
||||
|
||||
@@ -4,7 +4,7 @@ sidebar_position: 12
|
||||
|
||||
# Slash Commands API
|
||||
|
||||
The Commands API lets plugins register `/` slash commands. When a user types `/` in the chat composer, MetoYou shows a Discord-style autocomplete menu of available commands. Selecting a command (click, `Enter`, or `Tab`) runs it — either immediately when it declares no options, or after the user types the requested arguments.
|
||||
The Commands API lets plugins register `/` slash commands. When a user types `/` in the chat composer, Toju shows a Discord-style autocomplete menu of available commands. Selecting a command (click, `Enter`, or `Tab`) runs it — either immediately when it declares no options, or after the user types the requested arguments.
|
||||
|
||||
## Required Capabilities
|
||||
|
||||
@@ -28,7 +28,7 @@ Use `global` for commands that work without a server context (e.g. `/help`, `/sh
|
||||
|
||||
## Options and Argument Parsing
|
||||
|
||||
Declare `options` to describe the arguments a command accepts. MetoYou parses what the user typed after the command name and passes the result to `run` as `context.args`, keyed by option name.
|
||||
Declare `options` to describe the arguments a command accepts. Toju parses what the user typed after the command name and passes the result to `run` as `context.args`, keyed by option name.
|
||||
|
||||
```ts
|
||||
interface PluginApiSlashCommandOption {
|
||||
@@ -104,7 +104,7 @@ Returns every slash command currently registered across all active plugins, incl
|
||||
|
||||
## Built-in Commands
|
||||
|
||||
MetoYou ships first-party commands that are always available without any plugin, such as `/lenny` (posts `( ͡° ͜ʖ ͡°)`). They appear in the same autocomplete menu tagged as **Built-in**. Plugin commands are listed alongside them; if a plugin registers a command with the same name as a built-in, both appear and the user can pick either.
|
||||
Toju ships first-party commands that are always available without any plugin, such as `/lenny` (posts `( ͡° ͜ʖ ͡°)`). They appear in the same autocomplete menu tagged as **Built-in**. Plugin commands are listed alongside them; if a plugin registers a command with the same name as a built-in, both appear and the user can pick either.
|
||||
|
||||
## How Input Is Handled
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ export function activate(context) {
|
||||
|
||||
Toolbar actions are command-style plugin entries shown in the server side panel's View plugins menu. Use them for small actions that should be easy to launch from a server, such as opening a plugin page, sending a status message, starting a timer, or toggling a plugin feature.
|
||||
|
||||
The View plugins link appears in `[data-testid="plugin-room-side-panel"]` when the plugin side-panel area is rendered. Opening it shows an overlay menu, positioned like profile-card overlays, with registered actions laid out as plugin icon tiles. The `icon` field can be short text such as `RH`, an emoji, or an image URL; when omitted, MetoYou falls back to initials from the plugin/action labels.
|
||||
The View plugins link appears in `[data-testid="plugin-room-side-panel"]` when the plugin side-panel area is rendered. Opening it shows an overlay menu, positioned like profile-card overlays, with registered actions laid out as plugin icon tiles. The `icon` field can be short text such as `RH`, an emoji, or an image URL; when omitted, Toju falls back to initials from the plugin/action labels.
|
||||
|
||||
Toolbar action callbacks receive an action context with `source: 'toolbarAction'`, the current user, current server, active text channel, and current voice channel when available.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ sidebar_position: 1
|
||||
|
||||
# Create a Plugin
|
||||
|
||||
MetoYou plugins are browser-safe ES modules loaded by the Angular renderer. A plugin receives a frozen `TojuClientPluginApi`, declares every privileged capability in its manifest, and registers cleanup work through disposables.
|
||||
Toju plugins are browser-safe ES modules loaded by the Angular renderer. A plugin receives a frozen `TojuClientPluginApi`, declares every privileged capability in its manifest, and registers cleanup work through disposables.
|
||||
|
||||
## Folder Layout
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ interface TojuPluginManifest {
|
||||
|
||||
`scope: "server"` marks a plugin as server-scoped. Server-scoped store entries can be installed to a chat server as requirements. Required server plugins are auto-installed for members when that server opens; optional requirements stay listed but do not auto-install.
|
||||
|
||||
When a user installs a server-scoped plugin into the server they are currently viewing, MetoYou enables that plugin id locally and activates the plugin immediately after the local manifest is registered. Installing a server-scoped plugin for another server records the activation preference so it activates when that server is opened.
|
||||
When a user installs a server-scoped plugin into the server they are currently viewing, Toju enables that plugin id locally and activates the plugin immediately after the local manifest is registered. Installing a server-scoped plugin for another server records the activation preference so it activates when that server is opened.
|
||||
|
||||
## Entrypoint and Bundle
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ sidebar_position: 1
|
||||
|
||||
# First Steps
|
||||
|
||||
MetoYou is a chat app for servers, text conversations, direct messages, and live voice. You do not need to understand the technical parts to use it.
|
||||
Toju is a chat app for servers, text conversations, direct messages, and live voice. You do not need to understand the technical parts to use it.
|
||||
|
||||
## Main Words
|
||||
|
||||
@@ -18,11 +18,11 @@ MetoYou is a chat app for servers, text conversations, direct messages, and live
|
||||
|
||||
## Sign In
|
||||
|
||||
1. Open MetoYou.
|
||||
1. Open Toju.
|
||||
2. Sign in with your username and password.
|
||||
3. If you use more than one signaling server, choose the server endpoint that owns your account.
|
||||
|
||||
A signaling server handles accounts, server discovery, membership, and connection setup. In normal use you can think of it as the place MetoYou checks when you log in and join servers.
|
||||
A signaling server handles accounts, server discovery, membership, and connection setup. In normal use you can think of it as the place Toju checks when you log in and join servers.
|
||||
|
||||
## Find a Server
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ sidebar_position: 5
|
||||
|
||||
# Plugins for Users
|
||||
|
||||
Plugins add features to MetoYou. They can add pages, buttons, panels, settings, sounds, message tools, custom embeds, or server-specific behavior.
|
||||
Plugins add features to Toju. They can add pages, buttons, panels, settings, sounds, message tools, custom embeds, or server-specific behavior.
|
||||
|
||||
## Types of Plugins
|
||||
|
||||
@@ -44,7 +44,7 @@ Desktop builds can discover local plugin folders from the app data plugins direc
|
||||
|
||||
## Server Plugin Prompts
|
||||
|
||||
When a server uses plugins, MetoYou may show a prompt.
|
||||
When a server uses plugins, Toju may show a prompt.
|
||||
|
||||
| Status | Meaning |
|
||||
| ------------ | --------------------------------------------------------------------------------- |
|
||||
@@ -85,4 +85,4 @@ The Plugin Manager lets you:
|
||||
|
||||
## Plugin Safety Notes
|
||||
|
||||
Plugins are browser-safe JavaScript modules loaded by the client. They do not run on the signaling server. A plugin can only call privileged MetoYou APIs when its manifest declares the capability and you grant it.
|
||||
Plugins are browser-safe JavaScript modules loaded by the client. They do not run on the signaling server. A plugin can only call privileged Toju APIs when its manifest declares the capability and you grant it.
|
||||
|
||||
@@ -4,7 +4,7 @@ sidebar_position: 2
|
||||
|
||||
# Servers and Channels
|
||||
|
||||
A server is the main shared space in MetoYou. Servers contain members, channels, permissions, optional plugins, and server settings.
|
||||
A server is the main shared space in Toju. Servers contain members, channels, permissions, optional plugins, and server settings.
|
||||
|
||||
## Server Rail
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Settings control the app, voice, plugins, servers, themes, updates, local APIs,
|
||||
|
||||
## Local Data
|
||||
|
||||
Desktop MetoYou stores local app data on your device. That can include rooms, messages, users, plugin data, settings, and metadata. The desktop settings include data import/export tools.
|
||||
Desktop Toju stores local app data on your device. That can include rooms, messages, users, plugin data, settings, and metadata. The desktop settings include data import/export tools.
|
||||
|
||||
## Local API and Documentation Hosting
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Type `/` at the start of the message box to open the slash command menu. It list
|
||||
- Press `Escape` to close the menu.
|
||||
- A command that needs extra text fills the box with `/name ` so you can type the rest, then send it.
|
||||
|
||||
MetoYou includes built-in commands such as `/lenny`, which posts `( ͡° ͜ʖ ͡°)`. Plugins can add their own commands, which appear in the same menu (tagged with the plugin name). Slash commands are available in both text channels and direct messages; some plugin commands only appear inside a server. Text that starts with `/` but matches no command is sent as a normal message.
|
||||
Toju includes built-in commands such as `/lenny`, which posts `( ͡° ͜ʖ ͡°)`. Plugins can add their own commands, which appear in the same menu (tagged with the plugin name). Slash commands are available in both text channels and direct messages; some plugin commands only appear inside a server. Text that starts with `/` but matches no command is sent as a normal message.
|
||||
|
||||
## Attachments and Media
|
||||
|
||||
@@ -42,8 +42,8 @@ Attachments can appear as files, images, audio, or video depending on the file t
|
||||
|
||||
## Message Sync
|
||||
|
||||
MetoYou stores messages locally and syncs recent messages with peers when connections are available. If you were offline, messages may appear after peers reconnect and exchange their recent message lists.
|
||||
Toju stores messages locally and syncs recent messages with peers when connections are available. If you were offline, messages may appear after peers reconnect and exchange their recent message lists.
|
||||
|
||||
## Plugin Messages
|
||||
|
||||
Some plugins can send messages, create bot-style plugin users, render custom embeds, or add composer buttons. MetoYou asks for plugin capability grants before plugins can use privileged message features.
|
||||
Some plugins can send messages, create bot-style plugin users, render custom embeds, or add composer buttons. Toju asks for plugin capability grants before plugins can use privileged message features.
|
||||
@@ -45,7 +45,7 @@ When someone shares camera or screen, the voice workspace can expand into a larg
|
||||
|
||||
## Floating Voice Controls
|
||||
|
||||
If you navigate away from the server while still connected to voice, MetoYou can show floating voice controls. Use them to return to the voice server or leave the call.
|
||||
If you navigate away from the server while still connected to voice, Toju can show floating voice controls. Use them to return to the voice server or leave the call.
|
||||
|
||||
## Voice Settings
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
# Using MetoYou
|
||||
# Using Toju
|
||||
|
||||
## Sign In
|
||||
|
||||
MetoYou signs in through a signaling server. The signaling server validates the user account, coordinates server membership, relays selected realtime messages, and helps peers establish WebRTC connections.
|
||||
Toju signs in through a signaling server. The signaling server validates the user account, coordinates server membership, relays selected realtime messages, and helps peers establish WebRTC connections.
|
||||
|
||||
For the desktop Local API, the same signaling server allow-list is used before local bearer tokens can be issued. This keeps local automation tied to servers you explicitly trust.
|
||||
|
||||
@@ -39,7 +39,7 @@ Desktop builds include platform integrations such as Linux display-server detect
|
||||
|
||||
Open the Plugin Store from the title bar package button or menu. The plugin manager separates global client plugins from server-scoped plugins. Installed plugins can be activated, reloaded, unloaded, disabled, inspected for logs, and granted capabilities.
|
||||
|
||||
Plugins are explicit runtime modules. MetoYou loads browser-safe ES modules, passes a frozen API object, and cleans up registered disposables when a plugin unloads.
|
||||
Plugins are explicit runtime modules. Toju loads browser-safe ES modules, passes a frozen API object, and cleans up registered disposables when a plugin unloads.
|
||||
|
||||
## Desktop Settings
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Config } from '@docusaurus/types';
|
||||
import type * as Preset from '@docusaurus/preset-classic';
|
||||
|
||||
const config: Config = {
|
||||
title: 'MetoYou Docs',
|
||||
title: 'Toju Docs',
|
||||
tagline: 'Desktop chat, local APIs, and plugin development',
|
||||
url: 'http://127.0.0.1',
|
||||
baseUrl: '/docusaurus/',
|
||||
@@ -31,7 +31,7 @@ const config: Config = {
|
||||
],
|
||||
themeConfig: {
|
||||
navbar: {
|
||||
title: 'MetoYou Docs',
|
||||
title: 'Toju Docs',
|
||||
items: [
|
||||
{ type: 'docSidebar', sidebarId: 'mainSidebar', position: 'left', label: 'Guides' },
|
||||
{ to: '/user-guide/first-steps', label: 'User Guide', position: 'left' },
|
||||
@@ -56,7 +56,7 @@ const config: Config = {
|
||||
]
|
||||
}
|
||||
],
|
||||
copyright: 'MetoYou local documentation. Built with Docusaurus.'
|
||||
copyright: 'Toju local documentation. Built with Docusaurus.'
|
||||
},
|
||||
prism: {
|
||||
additionalLanguages: [
|
||||
|
||||
@@ -13,7 +13,7 @@ const sidebars: SidebarsConfig = {
|
||||
'user-guide/voice-channels',
|
||||
'user-guide/plugins',
|
||||
'user-guide/settings',
|
||||
'using-metoyou'
|
||||
'using-toju'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -80,7 +80,7 @@ export function getDocsHtml(specUrl: string): string {
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="${contentSecurityPolicy}"
|
||||
/>
|
||||
<title>MetoYou Local API</title>
|
||||
<title>Toju Local API</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body {
|
||||
|
||||
@@ -18,10 +18,10 @@ export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
|
||||
return {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'MetoYou Local Desktop API',
|
||||
title: 'Toju Local Desktop API',
|
||||
version: appVersion,
|
||||
description:
|
||||
'Authenticated local HTTP API exposed by the MetoYou desktop app. '
|
||||
'Authenticated local HTTP API exposed by the Toju desktop app. '
|
||||
+ 'Authentication is performed against a configured signaling server. '
|
||||
+ 'Bearer tokens issued here are scoped to this device only.'
|
||||
},
|
||||
|
||||
@@ -3,21 +3,14 @@ import AutoLaunch from 'auto-launch';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
import { DESKTOP_APP_DISPLAY_NAME, patchLinuxAutostartDesktopEntryNameField } from './desktop-branding.rules';
|
||||
import { resolveLaunchPath } from './launch-path';
|
||||
|
||||
let autoLauncher: AutoLaunch | null = null;
|
||||
let autoLaunchPath = '';
|
||||
|
||||
const LINUX_AUTO_START_ARGUMENTS = ['--no-sandbox', '%U'];
|
||||
|
||||
function resolveLaunchPath(): string {
|
||||
// AppImage runs from a temporary mount; APPIMAGE points to the real file path.
|
||||
const appImagePath = process.platform === 'linux'
|
||||
? String(process.env['APPIMAGE'] || '').trim()
|
||||
: '';
|
||||
|
||||
return appImagePath || process.execPath;
|
||||
}
|
||||
|
||||
function escapeDesktopEntryExecArgument(argument: string): string {
|
||||
const escapedArgument = argument.replace(/(["\\$`])/g, '\\$1');
|
||||
|
||||
@@ -35,14 +28,12 @@ function buildLinuxAutoStartExecLine(launchPath: string): string {
|
||||
}
|
||||
|
||||
function buildLinuxAutoStartDesktopEntry(launchPath: string): string {
|
||||
const appName = path.basename(launchPath);
|
||||
|
||||
return [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Version=1.0',
|
||||
`Name=${appName}`,
|
||||
`Comment=${appName}startup script`,
|
||||
`Name=${DESKTOP_APP_DISPLAY_NAME}`,
|
||||
`Comment=${DESKTOP_APP_DISPLAY_NAME} startup script`,
|
||||
buildLinuxAutoStartExecLine(launchPath),
|
||||
'StartupNotify=false',
|
||||
'Terminal=false'
|
||||
@@ -65,11 +56,13 @@ async function synchronizeLinuxAutoStartDesktopEntry(launchPath: string): Promis
|
||||
// Create the desktop entry if auto-launch did not leave one behind.
|
||||
}
|
||||
|
||||
const nextDesktopEntry = currentDesktopEntry
|
||||
? /^Exec=.*$/m.test(currentDesktopEntry)
|
||||
? currentDesktopEntry.replace(/^Exec=.*$/m, execLine)
|
||||
: `${currentDesktopEntry.trimEnd()}\n${execLine}\n`
|
||||
: buildLinuxAutoStartDesktopEntry(launchPath);
|
||||
const nextDesktopEntry = patchLinuxAutostartDesktopEntryNameField(
|
||||
currentDesktopEntry
|
||||
? /^Exec=.*$/m.test(currentDesktopEntry)
|
||||
? currentDesktopEntry.replace(/^Exec=.*$/m, execLine)
|
||||
: `${currentDesktopEntry.trimEnd()}\n${execLine}\n`
|
||||
: buildLinuxAutoStartDesktopEntry(launchPath)
|
||||
);
|
||||
|
||||
if (nextDesktopEntry === currentDesktopEntry) {
|
||||
return;
|
||||
@@ -87,7 +80,7 @@ function getAutoLauncher(): AutoLaunch | null {
|
||||
if (!autoLauncher) {
|
||||
autoLaunchPath = resolveLaunchPath();
|
||||
autoLauncher = new AutoLaunch({
|
||||
name: app.getName(),
|
||||
name: DESKTOP_APP_DISPLAY_NAME,
|
||||
path: autoLaunchPath
|
||||
});
|
||||
}
|
||||
|
||||
88
electron/app/desktop-branding-migration.ts
Normal file
88
electron/app/desktop-branding-migration.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { app } from 'electron';
|
||||
import AutoLaunch from 'auto-launch';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
DESKTOP_APP_DISPLAY_NAME,
|
||||
isLegacyLinuxAutostartEntry,
|
||||
LEGACY_APP_REGISTRY_NAMES
|
||||
} from './desktop-branding.rules';
|
||||
import { resolveLaunchPath } from './launch-path';
|
||||
|
||||
function getLinuxAutoStartDirectory(): string {
|
||||
return path.join(app.getPath('home'), '.config', 'autostart');
|
||||
}
|
||||
|
||||
export function configureDesktopBranding(): void {
|
||||
if (!app.isPackaged) {
|
||||
return;
|
||||
}
|
||||
|
||||
app.setName(DESKTOP_APP_DISPLAY_NAME);
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.title = DESKTOP_APP_DISPLAY_NAME;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLegacyLinuxAutostartEntries(launchPath: string): Promise<void> {
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
const autostartDirectory = getLinuxAutoStartDirectory();
|
||||
const currentLaunchBaseName = path.basename(launchPath);
|
||||
|
||||
let fileNames: string[] = [];
|
||||
|
||||
try {
|
||||
fileNames = await fsp.readdir(autostartDirectory);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(fileNames.map(async (fileName) => {
|
||||
if (!fileName.endsWith('.desktop')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLegacyLinuxAutostartEntry(fileName, currentLaunchBaseName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fsp.unlink(path.join(autostartDirectory, fileName)).catch(() => {});
|
||||
}));
|
||||
}
|
||||
|
||||
async function disableLegacyWindowsAutoLaunchEntries(launchPath: string): Promise<void> {
|
||||
if (process.platform !== 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(LEGACY_APP_REGISTRY_NAMES.map(async (legacyName) => {
|
||||
const launcher = new AutoLaunch({
|
||||
name: legacyName,
|
||||
path: launchPath
|
||||
});
|
||||
|
||||
try {
|
||||
if (await launcher.isEnabled()) {
|
||||
await launcher.disable();
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup for renamed desktop binaries.
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export async function migrateLegacyDesktopBranding(): Promise<void> {
|
||||
if (!app.isPackaged) {
|
||||
return;
|
||||
}
|
||||
|
||||
const launchPath = resolveLaunchPath();
|
||||
|
||||
await removeLegacyLinuxAutostartEntries(launchPath);
|
||||
await disableLegacyWindowsAutoLaunchEntries(launchPath);
|
||||
}
|
||||
45
electron/app/desktop-branding.rules.spec.ts
Normal file
45
electron/app/desktop-branding.rules.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
DESKTOP_APP_DISPLAY_NAME,
|
||||
DESKTOP_EXECUTABLE_NAME,
|
||||
LEGACY_APP_REGISTRY_NAMES,
|
||||
isLegacyLinuxAutostartEntry,
|
||||
patchLinuxAutostartDesktopEntryNameField
|
||||
} from './desktop-branding.rules';
|
||||
|
||||
describe('desktop-branding.rules', () => {
|
||||
it('exposes the Toju desktop branding constants', () => {
|
||||
expect(DESKTOP_APP_DISPLAY_NAME).toBe('Toju');
|
||||
expect(DESKTOP_EXECUTABLE_NAME).toBe('toju');
|
||||
expect(LEGACY_APP_REGISTRY_NAMES).toContain('MetoYou');
|
||||
expect(LEGACY_APP_REGISTRY_NAMES).toContain('metoyou');
|
||||
});
|
||||
|
||||
it('treats legacy linux autostart entries as removable', () => {
|
||||
expect(isLegacyLinuxAutostartEntry('metoyou.desktop', 'toju')).toBe(true);
|
||||
expect(isLegacyLinuxAutostartEntry('MetoYou.desktop', 'toju')).toBe(true);
|
||||
expect(isLegacyLinuxAutostartEntry('MetoYou-1.0.0-x86_64.AppImage.desktop', 'Toju-1.1.0-x86_64.AppImage')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the current launch entry when it already uses the Toju binary', () => {
|
||||
expect(isLegacyLinuxAutostartEntry('toju.desktop', 'toju')).toBe(false);
|
||||
expect(isLegacyLinuxAutostartEntry('Toju-1.1.0-x86_64.AppImage.desktop', 'Toju-1.1.0-x86_64.AppImage')).toBe(false);
|
||||
});
|
||||
|
||||
it('rewrites the desktop entry display name to Toju', () => {
|
||||
const patched = patchLinuxAutostartDesktopEntryNameField([
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Name=metoyou',
|
||||
'Exec=/opt/Toju/toju --no-sandbox %U'
|
||||
].join('\n'));
|
||||
|
||||
expect(patched).toContain('Name=Toju');
|
||||
expect(patched).not.toContain('Name=metoyou');
|
||||
});
|
||||
});
|
||||
43
electron/app/desktop-branding.rules.ts
Normal file
43
electron/app/desktop-branding.rules.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export const DESKTOP_APP_DISPLAY_NAME = 'Toju';
|
||||
export const DESKTOP_EXECUTABLE_NAME = 'toju';
|
||||
|
||||
export const LEGACY_APP_REGISTRY_NAMES = [
|
||||
'MetoYou',
|
||||
'MeToYou',
|
||||
'metoyou'
|
||||
] as const;
|
||||
|
||||
function normalizeAutostartBaseName(fileName: string): string {
|
||||
return fileName.replace(/\.desktop$/iu, '').replace(/\.exe$/iu, '');
|
||||
}
|
||||
|
||||
export function isLegacyLinuxAutostartEntry(
|
||||
fileName: string,
|
||||
currentLaunchBaseName: string
|
||||
): boolean {
|
||||
const entryBaseName = normalizeAutostartBaseName(fileName);
|
||||
const currentBaseName = normalizeAutostartBaseName(currentLaunchBaseName);
|
||||
|
||||
if (entryBaseName === currentBaseName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedEntry = entryBaseName.toLowerCase();
|
||||
|
||||
if (LEGACY_APP_REGISTRY_NAMES.some((legacyName) => normalizedEntry === legacyName.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /^metoyou[-.]/iu.test(entryBaseName);
|
||||
}
|
||||
|
||||
export function patchLinuxAutostartDesktopEntryNameField(
|
||||
desktopEntry: string,
|
||||
displayName: string = DESKTOP_APP_DISPLAY_NAME
|
||||
): string {
|
||||
if (/^Name=.*$/m.test(desktopEntry)) {
|
||||
return desktopEntry.replace(/^Name=.*$/m, `Name=${displayName}`);
|
||||
}
|
||||
|
||||
return desktopEntry;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { app } from 'electron';
|
||||
import { configureDesktopBranding } from './desktop-branding-migration';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
export function configureAppFlags(): void {
|
||||
configureDesktopBranding();
|
||||
linuxSpecificFlags();
|
||||
networkFlags();
|
||||
setupGpuEncodingFlags();
|
||||
|
||||
8
electron/app/launch-path.ts
Normal file
8
electron/app/launch-path.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Resolves the packaged binary path used for auto-start and updater migration. */
|
||||
export function resolveLaunchPath(): string {
|
||||
const appImagePath = process.platform === 'linux'
|
||||
? String(process.env['APPIMAGE'] || '').trim()
|
||||
: '';
|
||||
|
||||
return appImagePath || process.execPath;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { app, BrowserWindow } from 'electron';
|
||||
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
||||
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
||||
import { synchronizeAutoStartSetting } from './auto-start';
|
||||
import { migrateLegacyDesktopBranding } from './desktop-branding-migration';
|
||||
import { applyLocalApiSettings, stopLocalApiServer } from '../api';
|
||||
import {
|
||||
initializeDatabase,
|
||||
@@ -41,6 +42,7 @@ export function registerAppLifecycle(): void {
|
||||
setupCqrsHandlers();
|
||||
setupWindowControlHandlers();
|
||||
setupSystemHandlers();
|
||||
await migrateLegacyDesktopBranding();
|
||||
await synchronizeAutoStartSetting();
|
||||
initializeDesktopUpdater();
|
||||
await createWindow();
|
||||
|
||||
@@ -47,8 +47,8 @@ export async function exportUserData(): Promise<ExportUserDataResult> {
|
||||
.slice(0, 10)}.dat`;
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
defaultPath: path.join(app.getPath('documents'), defaultFileName),
|
||||
filters: [{ extensions: ['dat'], name: 'MetoYou data archive' }],
|
||||
title: 'Export MetoYou data'
|
||||
filters: [{ extensions: ['dat'], name: 'Toju data archive' }],
|
||||
title: 'Export Toju data'
|
||||
});
|
||||
|
||||
if (canceled || !filePath) {
|
||||
@@ -87,9 +87,9 @@ export async function exportUserData(): Promise<ExportUserDataResult> {
|
||||
|
||||
export async function importUserData(): Promise<ImportUserDataResult> {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
filters: [{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }],
|
||||
filters: [{ extensions: ['dat', 'zip'], name: 'Toju data archive' }],
|
||||
properties: ['openFile'],
|
||||
title: 'Import MetoYou data'
|
||||
title: 'Import Toju data'
|
||||
});
|
||||
|
||||
if (canceled || filePaths.length === 0) {
|
||||
@@ -235,7 +235,7 @@ function validateArchiveManifest(entries: ZipArchiveEntry[]): void {
|
||||
const manifest = entries.find((entry) => entry.path === ARCHIVE_MANIFEST_PATH);
|
||||
|
||||
if (!manifest) {
|
||||
throw new Error('The selected file is missing a MetoYou data manifest.');
|
||||
throw new Error('The selected file is missing a Toju data manifest.');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(manifest.data.toString('utf8')) as { format?: string; version?: number };
|
||||
|
||||
@@ -105,6 +105,7 @@ export const HARDCODED_IGNORED_PROCESSES: ReadonlySet<string> = new Set([
|
||||
'logitechg',
|
||||
'login',
|
||||
'metoyou',
|
||||
'toju',
|
||||
'msedge',
|
||||
'msedgewebview2',
|
||||
'msteams',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app, net } from 'electron';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import { migrateLegacyDesktopBranding } from '../app/desktop-branding-migration';
|
||||
import { readDesktopSettings, type AutoUpdateMode } from '../desktop-settings';
|
||||
import { getMainWindow } from '../window/create-window';
|
||||
import {
|
||||
@@ -500,7 +501,7 @@ async function performUpdateCheck(
|
||||
setDesktopUpdateState({
|
||||
lastCheckedAt: Date.now(),
|
||||
status: 'checking',
|
||||
statusMessage: `Checking for MetoYou ${targetRelease.version}...`,
|
||||
statusMessage: `Checking for Toju ${targetRelease.version}...`,
|
||||
targetVersion: targetRelease.version
|
||||
});
|
||||
|
||||
@@ -641,7 +642,7 @@ async function refreshDesktopUpdater(
|
||||
|
||||
setDesktopUpdateState({
|
||||
status: 'target-older-than-installed',
|
||||
statusMessage: `MetoYou ${app.getVersion()} is newer than ${selectedRelease.version}. Downgrades are not applied automatically.`,
|
||||
statusMessage: `Toju ${app.getVersion()} is newer than ${selectedRelease.version}. Downgrades are not applied automatically.`,
|
||||
targetVersion: selectedRelease.version
|
||||
});
|
||||
|
||||
@@ -698,7 +699,7 @@ export function initializeDesktopUpdater(): void {
|
||||
setDesktopUpdateState({
|
||||
lastCheckedAt: Date.now(),
|
||||
status: 'downloading',
|
||||
statusMessage: `Downloading MetoYou ${nextVersion ?? 'update'}...`,
|
||||
statusMessage: `Downloading Toju ${nextVersion ?? 'update'}...`,
|
||||
targetVersion: nextVersion
|
||||
});
|
||||
});
|
||||
@@ -715,8 +716,8 @@ export function initializeDesktopUpdater(): void {
|
||||
lastCheckedAt: Date.now(),
|
||||
status: 'up-to-date',
|
||||
statusMessage: isPinnedVersion
|
||||
? `MetoYou ${desktopUpdateState.targetVersion} is already installed.`
|
||||
: 'MetoYou is up to date.'
|
||||
? `Toju ${desktopUpdateState.targetVersion} is already installed.`
|
||||
: 'Toju is up to date.'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -726,11 +727,13 @@ export function initializeDesktopUpdater(): void {
|
||||
const nextVersion = normalizeSemanticVersion(updateInfo.version)
|
||||
?? desktopUpdateState.targetVersion;
|
||||
|
||||
void migrateLegacyDesktopBranding();
|
||||
|
||||
setDesktopUpdateState({
|
||||
lastCheckedAt: Date.now(),
|
||||
restartRequired: true,
|
||||
status: 'restart-required',
|
||||
statusMessage: `MetoYou ${nextVersion ?? 'update'} is ready. Restart the app to finish installing it.`,
|
||||
statusMessage: `Toju ${nextVersion ?? 'update'} is ready. Restart the app to finish installing it.`,
|
||||
targetVersion: nextVersion
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { DESKTOP_APP_DISPLAY_NAME } from '../app/desktop-branding.rules';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
@@ -114,11 +115,11 @@ function ensureTray(): void {
|
||||
}
|
||||
|
||||
tray = new Tray(trayIconPath);
|
||||
tray.setToolTip('MetoYou');
|
||||
tray.setToolTip('Toju');
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open MetoYou',
|
||||
label: 'Open Toju',
|
||||
click: () => {
|
||||
void showMainWindow();
|
||||
}
|
||||
@@ -127,7 +128,7 @@ function ensureTray(): void {
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Close MetoYou',
|
||||
label: 'Close Toju',
|
||||
click: () => {
|
||||
requestAppQuit();
|
||||
}
|
||||
@@ -200,6 +201,7 @@ export async function createWindow(): Promise<void> {
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
frame: false,
|
||||
title: DESKTOP_APP_DISPLAY_NAME,
|
||||
titleBarStyle: 'hidden',
|
||||
backgroundColor: '#0a0a0f',
|
||||
...(windowIconPath ? { icon: windowIconPath } : {}),
|
||||
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -38,6 +38,7 @@
|
||||
"@ngrx/entity": "^21.0.1",
|
||||
"@ngrx/store": "^21.0.1",
|
||||
"@ngrx/store-devtools": "^21.0.1",
|
||||
"@ngx-translate/core": "^17.0.0",
|
||||
"@scalar/api-reference": "^1.53.1",
|
||||
"@spartan-ng/brain": "^0.0.1-alpha.589",
|
||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||
@@ -8657,6 +8658,19 @@
|
||||
"rxjs": "^6.5.3 || ^7.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngx-translate/core": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-17.0.0.tgz",
|
||||
"integrity": "sha512-Rft2D5ns2pq4orLZjEtx1uhNuEBerUdpFUG1IcqtGuipj6SavgB8SkxtNQALNDA+EVlvsNCCjC2ewZVtUeN6rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=16",
|
||||
"@angular/core": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"server:bundle:linux": "node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64",
|
||||
"server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe",
|
||||
"sort:props": "node tools/sort-template-properties.js",
|
||||
"i18n:sync": "node tools/sync-app-i18n-catalog.mjs",
|
||||
"test:e2e": "cd e2e && npx playwright test",
|
||||
"test:e2e:ui": "cd e2e && npx playwright test --ui",
|
||||
"test:e2e:debug": "cd e2e && npx playwright test --debug",
|
||||
@@ -95,6 +96,7 @@
|
||||
"@ngrx/entity": "^21.0.1",
|
||||
"@ngrx/store": "^21.0.1",
|
||||
"@ngrx/store-devtools": "^21.0.1",
|
||||
"@ngx-translate/core": "^17.0.0",
|
||||
"@scalar/api-reference": "^1.53.1",
|
||||
"@spartan-ng/brain": "^0.0.1-alpha.589",
|
||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||
@@ -161,7 +163,7 @@
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.metoyou.app",
|
||||
"productName": "MetoYou",
|
||||
"productName": "Toju",
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Toju Invite Links",
|
||||
@@ -231,6 +233,7 @@
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||
"executableName": "toju",
|
||||
"icon": "images/windows/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
@@ -243,7 +246,7 @@
|
||||
"deb"
|
||||
],
|
||||
"category": "Network;Chat",
|
||||
"executableName": "metoyou",
|
||||
"executableName": "toju",
|
||||
"executableArgs": [
|
||||
"--no-sandbox"
|
||||
],
|
||||
|
||||
@@ -209,7 +209,7 @@ router.get('/link-metadata', async (req, res) => {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'text/html',
|
||||
'User-Agent': 'MetoYou-LinkPreview/1.0'
|
||||
'User-Agent': 'Toju-LinkPreview/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ function createOpenApiDocument(baseUrl: string) {
|
||||
return {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'MetoYou Plugin Support API',
|
||||
title: 'Toju Plugin Support API',
|
||||
version: '1.0.0',
|
||||
description: 'Official HTTP endpoints for plugin install metadata and event definitions. '
|
||||
+ 'Plugin code is never executed by the signal server.'
|
||||
@@ -94,9 +94,9 @@ router.get('/docs', (_req, res) => {
|
||||
|
||||
res.type('html').send(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>MetoYou Plugin API Docs</title></head>
|
||||
<head><meta charset="utf-8"><title>Toju Plugin API Docs</title></head>
|
||||
<body style="font-family:system-ui;margin:2rem;line-height:1.5">
|
||||
<h1>MetoYou Plugin Support API</h1>
|
||||
<h1>Toju Plugin Support API</h1>
|
||||
<p>Plugin support endpoints are available at <a href="/api/openapi.json">/api/openapi.json</a>.</p>
|
||||
<p>The signal server stores plugin install metadata and event definitions only. It never executes plugin code or stores arbitrary plugin data.</p>
|
||||
</body>
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
const PACKAGED_DATA_DIRECTORY_NAME = 'MetoYou Server';
|
||||
const PACKAGED_DATA_DIRECTORY_NAME = 'Toju Server';
|
||||
|
||||
type PackagedProcess = NodeJS.Process & { pkg?: unknown };
|
||||
|
||||
|
||||
@@ -39,6 +39,13 @@ This package is the Angular 21 renderer for the Toju/MetoYou product client.
|
||||
- Context menus and modal dialogs auto-render as bottom sheets on mobile. `ContextMenuComponent` and `ConfirmDialogComponent` (in `src/app/shared/components/`) inject `ViewportService` and switch their templates between the desktop popover/centered modal and `BottomSheetComponent` (`src/app/shared/components/bottom-sheet/`) on phone-sized viewports. New menus/dialogs should reuse these components rather than rolling their own `fixed inset-0` overlay. For one-off bespoke surfaces, render `<app-bottom-sheet>` directly when `isMobile()`.
|
||||
- Tap targets on interactive controls should be at least 44px on mobile. Use `min-h-11` (or explicit `h-11 w-11`) for icon buttons that are tap-only on mobile; desktop sizes can remain smaller via `md:` overrides.
|
||||
|
||||
## i18n
|
||||
|
||||
- User-visible UI strings use `@ngx-translate/core` (same stack as `website/`). Edit fragment catalogs in `public/i18n/catalog/*.json`, then run `npm run i18n:sync` from the repo root to regenerate `public/i18n/en.json`. Only `en` ships today.
|
||||
- Bootstrap and locale rules: `src/app/core/i18n/`. Import `APP_TRANSLATE_IMPORTS` in standalone components that use the `translate` pipe; use `AppI18nService.instant()` in TypeScript.
|
||||
- Vitest harnesses: `provideAppI18nForTests()` from `src/app/core/i18n/app-i18n.testing.ts`.
|
||||
- See `agents-docs/features/app-i18n.md` for the full contract.
|
||||
|
||||
## Templates
|
||||
|
||||
- If you touch Angular HTML templates, run `npm run format`.
|
||||
|
||||
@@ -22,6 +22,8 @@ Owns the user-facing Angular 21 desktop chat experience: rendering and orchestra
|
||||
| **Capacitor SQLite backend** | Native persistence path selected by `DatabaseService` when `PlatformService.isCapacitor` is true; uses `@capacitor-community/sqlite` instead of IndexedDB. | "mobile database", "Capacitor DB" |
|
||||
| **Rules file** | A pure-function module suffixed `*.rules.ts` that encodes domain logic without Angular or NgRx dependencies — easy to unit-test. | "helpers", "utils" |
|
||||
| **Custom emoji** | User-created image emoji assets stored locally, synced peer-to-peer, and referenced from messages/reactions by stable `:emoji[id](name)` tokens. | "sticker", "emote" |
|
||||
| **App locale** | The active UI language for the product client, resolved by `resolveAppLocale()` in `core/i18n/`; only `en` is shipped today. | "language", "i18n locale" |
|
||||
| **Translation catalog** | JSON string tables under `public/i18n/catalog/*.json`, merged to `public/i18n/en.json` via `npm run i18n:sync`, loaded at startup by `AppI18nService`. | "locale file", "messages file" |
|
||||
|
||||
## Relationships
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ public class VoiceCallForegroundService extends Service {
|
||||
);
|
||||
|
||||
return new NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("MetoYou call in progress")
|
||||
.setContentTitle("Toju call in progress")
|
||||
.setContentText("Voice call is active")
|
||||
.setSmallIcon(android.R.drawable.stat_sys_phone_call)
|
||||
.setContentIntent(pendingIntent)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">MetoYou</string>
|
||||
<string name="title_activity_main">MetoYou</string>
|
||||
<string name="app_name">Toju</string>
|
||||
<string name="title_activity_main">Toju</string>
|
||||
<string name="package_name">com.metoyou.app</string>
|
||||
<string name="custom_url_scheme">com.metoyou.app</string>
|
||||
<string name="audio_capture_attribution">Voice calls and microphone access</string>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.metoyou.app',
|
||||
appName: 'MetoYou',
|
||||
appName: 'Toju',
|
||||
webDir: '../dist/client/browser',
|
||||
server: {
|
||||
androidScheme: 'https'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>MetoYou</string>
|
||||
<string>Toju</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
||||
@@ -17,7 +17,7 @@ public class MetoyouMobilePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
private var activeCallUuid: UUID?
|
||||
|
||||
public override init() {
|
||||
let configuration = CXProviderConfiguration(localizedName: "MetoYou")
|
||||
let configuration = CXProviderConfiguration(localizedName: "Toju")
|
||||
configuration.supportsVideo = true
|
||||
configuration.maximumCallsPerCallGroup = 1
|
||||
configuration.supportedHandleTypes = [.generic]
|
||||
|
||||
20
toju-app/public/i18n/catalog/app.json
Normal file
20
toju-app/public/i18n/catalog/app.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"app": {
|
||||
"themeStudio": {
|
||||
"loading": "Loading Theme Studio...",
|
||||
"title": "Theme Studio",
|
||||
"minimized": "Minimized",
|
||||
"minimize": "Minimize",
|
||||
"reopen": "Re-open"
|
||||
},
|
||||
"desktopUpdate": {
|
||||
"dismissAriaLabel": "Dismiss update notice",
|
||||
"readyBadge": "Update Ready",
|
||||
"restartTitle": "Restart to install {{version}}",
|
||||
"latestUpdateFallback": "the latest update",
|
||||
"downloadedMessage": "The update has already been downloaded. Restart the app when you're ready to finish applying it.",
|
||||
"updateSettings": "Update settings",
|
||||
"restartNow": "Restart now"
|
||||
}
|
||||
}
|
||||
}
|
||||
14
toju-app/public/i18n/catalog/attachment.json
Normal file
14
toju-app/public/i18n/catalog/attachment.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"attachment": {
|
||||
"errors": {
|
||||
"noConnectedPeers": "No connected peers are available to provide this file right now.",
|
||||
"fileNotFound": "The connected peers do not have this file right now.",
|
||||
"uploaderLocalMissing": "Your original upload could not be found on this device. Re-upload the file to restore playback.",
|
||||
"prepareDownloadFailed": "Could not prepare media download on disk.",
|
||||
"chunksOutOfOrder": "Received media chunks out of order. Retry the download.",
|
||||
"writeDownloadFailed": "Could not write media download to disk.",
|
||||
"openDownloadFailed": "Could not open completed media download from disk.",
|
||||
"downloadFailed": "Media download failed. Retry the download."
|
||||
}
|
||||
}
|
||||
}
|
||||
33
toju-app/public/i18n/catalog/auth.json
Normal file
33
toju-app/public/i18n/catalog/auth.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"serverApp": "Server App",
|
||||
"submit": "Login",
|
||||
"noAccount": "No account?",
|
||||
"registerLink": "Register",
|
||||
"failed": "Login failed"
|
||||
},
|
||||
"register": {
|
||||
"title": "Register",
|
||||
"username": "Username",
|
||||
"displayName": "Display Name",
|
||||
"password": "Password",
|
||||
"serverApp": "Server App",
|
||||
"submit": "Create Account",
|
||||
"haveAccount": "Have an account?",
|
||||
"loginLink": "Login",
|
||||
"failed": "Registration failed"
|
||||
},
|
||||
"userBar": {
|
||||
"login": "Login",
|
||||
"register": "Register"
|
||||
},
|
||||
"users": {
|
||||
"prepareStateFailed": "Failed to prepare local user state.",
|
||||
"noCurrentUser": "No current user"
|
||||
}
|
||||
}
|
||||
}
|
||||
48
toju-app/public/i18n/catalog/call.json
Normal file
48
toju-app/public/i18n/catalog/call.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"call": {
|
||||
"joinCall": "Join call",
|
||||
"leaveCall": "Leave call",
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"deafen": "Deafen",
|
||||
"undeafen": "Undeafen",
|
||||
"useEarpiece": "Use earpiece",
|
||||
"useSpeakerphone": "Use speakerphone",
|
||||
"turnCameraOn": "Turn camera on",
|
||||
"turnCameraOff": "Turn camera off",
|
||||
"shareScreen": "Share screen",
|
||||
"stopSharingScreen": "Stop sharing screen",
|
||||
"incoming": {
|
||||
"badge": "Incoming call",
|
||||
"callerCalling": "{{name}} is calling",
|
||||
"someone": "Someone",
|
||||
"decline": "Decline",
|
||||
"answer": "Answer",
|
||||
"directCall": "Direct call",
|
||||
"groupCall": "{{count}} person call"
|
||||
},
|
||||
"private": {
|
||||
"title": "Private Call",
|
||||
"participants": "{{count}} participants",
|
||||
"notFound": "Call not found",
|
||||
"minimize": "Minimize call",
|
||||
"addUserAria": "Add user to call",
|
||||
"addUser": "Add user",
|
||||
"addUserButton": "Add user",
|
||||
"showAllStreams": "Show all streams",
|
||||
"allStreams": "All streams",
|
||||
"noActiveCall": "No active call for this route.",
|
||||
"resizeChat": "Resize chat",
|
||||
"yourCamera": "Your camera",
|
||||
"yourScreen": "Your screen",
|
||||
"waiting": "Waiting"
|
||||
},
|
||||
"notifications": {
|
||||
"inProgress": "Call in progress"
|
||||
},
|
||||
"errors": {
|
||||
"noRecipient": "Direct message conversation has no recipient to call.",
|
||||
"noCurrentUser": "Cannot use calls without a current user."
|
||||
}
|
||||
}
|
||||
}
|
||||
178
toju-app/public/i18n/catalog/chat.json
Normal file
178
toju-app/public/i18n/catalog/chat.json
Normal file
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"chat": {
|
||||
"composer": {
|
||||
"replyingTo": "Replying to {{name}}",
|
||||
"toolbar": {
|
||||
"quote": "Quote",
|
||||
"bulletList": "• List",
|
||||
"orderedList": "1. List",
|
||||
"code": "Code",
|
||||
"link": "Link",
|
||||
"image": "Image",
|
||||
"horizontalRule": "HR"
|
||||
},
|
||||
"addAttachmentGifEmoji": "Add attachment, GIF, or emoji",
|
||||
"addToMessage": "Add to message",
|
||||
"emoji": "Emoji",
|
||||
"emojiPickerAria": "Emoji picker",
|
||||
"attachFiles": "Attach files",
|
||||
"searchKlipyGifs": "Search KLIPY GIFs",
|
||||
"gif": "GIF",
|
||||
"openEmojiSelector": "Open emoji selector",
|
||||
"sendMessage": "Send message",
|
||||
"placeholder": "Type a message...",
|
||||
"dropFilesToAttach": "Drop files to attach",
|
||||
"klipyGif": "KLIPY GIF",
|
||||
"klipy": "KLIPY",
|
||||
"gifReadyToSend": "GIF ready to send",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"mediaMenu": {
|
||||
"attachFiles": "Attach files",
|
||||
"gif": "GIF",
|
||||
"emoji": "Emoji"
|
||||
},
|
||||
"slashCommand": {
|
||||
"ariaLabel": "Slash commands",
|
||||
"commands": "Commands",
|
||||
"builtInSource": "Built-in",
|
||||
"lennyDescription": "Send the Lenny face ( ͡° ͜ʖ ͡°)"
|
||||
},
|
||||
"typing": {
|
||||
"one": "{{names}} is typing...",
|
||||
"many": "{{names}} are typing...",
|
||||
"andOthers": "{{names}} and {{count}} others are typing..."
|
||||
},
|
||||
"messageList": {
|
||||
"syncing": "Syncing messages...",
|
||||
"loading": "Loading...",
|
||||
"emptyTitle": "No messages yet",
|
||||
"emptySubtitle": "Be the first to say something!",
|
||||
"loadOlder": "Load older messages",
|
||||
"newMessages": "New messages",
|
||||
"readLatest": "Read latest"
|
||||
},
|
||||
"markdown": {
|
||||
"sharedImage": "Shared image",
|
||||
"klipy": "KLIPY"
|
||||
},
|
||||
"message": {
|
||||
"originalNotFound": "Original message not found",
|
||||
"edited": "(edited)",
|
||||
"deleted": "[Message deleted]",
|
||||
"missingPluginPrefix": "Required plugin is not installed to view this content, visit the",
|
||||
"store": "store",
|
||||
"waitingForImage": "Waiting for image source...",
|
||||
"retry": "Retry",
|
||||
"viewAllImages": "View all {{count}} images",
|
||||
"viewFullSize": "View full size",
|
||||
"download": "Download",
|
||||
"cancel": "Cancel",
|
||||
"request": "Request",
|
||||
"open": "Open",
|
||||
"play": "Play",
|
||||
"sharedFromDevice": "Shared from your device",
|
||||
"loadingExperimentalPlayer": "Loading experimental player...",
|
||||
"customEmojiAlt": "Custom emoji",
|
||||
"mobileSheetTitle": "Message",
|
||||
"mobileSheetAria": "Message actions",
|
||||
"react": "React",
|
||||
"reply": "Reply",
|
||||
"copyContent": "Copy message content",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"largeVideo": "Large video. Accept the download to watch it in chat.",
|
||||
"largeAudio": "Large audio file. Accept the download to play it in chat.",
|
||||
"waitingForVideo": "Waiting for video source...",
|
||||
"waitingForAudio": "Waiting for audio source...",
|
||||
"acceptDownload": "Accept download",
|
||||
"retryDownload": "Retry download",
|
||||
"timestampYesterday": "Yesterday {{time}}"
|
||||
},
|
||||
"gifPicker": {
|
||||
"ariaLabel": "KLIPY GIF picker",
|
||||
"chooseGif": "Choose a GIF",
|
||||
"searchResults": "Search results from KLIPY.",
|
||||
"trending": "Trending GIFs from KLIPY.",
|
||||
"closeAria": "Close GIF picker",
|
||||
"searchMobile": "Search KLIPY and add a gif to the chat",
|
||||
"searchDesktop": "Search KLIPY",
|
||||
"retry": "Retry",
|
||||
"loading": "Loading GIFs from KLIPY...",
|
||||
"noGifsFound": "No GIFs found",
|
||||
"noGifsHint": "Try another search term or clear the search to browse trending GIFs.",
|
||||
"klipyGif": "KLIPY GIF",
|
||||
"klipy": "KLIPY",
|
||||
"clickToSelect": "Click to select",
|
||||
"loadingMoreAria": "Loading more GIFs",
|
||||
"loadMoreAria": "Load more GIFs",
|
||||
"footer": "Click a GIF to select it. Powered by KLIPY.",
|
||||
"loadingMore": "Loading...",
|
||||
"loadMore": "Load more",
|
||||
"loadFailed": "Failed to load GIFs from KLIPY.",
|
||||
"closeOverlayAria": "Close GIF picker"
|
||||
},
|
||||
"userList": {
|
||||
"members": "Members",
|
||||
"onlineInVoice": "{{online}} online · {{inVoice}} in voice",
|
||||
"message": "Message",
|
||||
"statusAway": "Away",
|
||||
"statusBusy": "Do Not Disturb",
|
||||
"statusOffline": "Offline",
|
||||
"unmute": "Unmute",
|
||||
"mute": "Mute",
|
||||
"kick": "Kick",
|
||||
"ban": "Ban",
|
||||
"noUsersOnline": "No users online",
|
||||
"banUserTitle": "Ban User",
|
||||
"banUserConfirm": "Ban User",
|
||||
"banConfirmMessage": "Are you sure you want to ban {{name}}?",
|
||||
"banReasonLabel": "Reason (optional)",
|
||||
"banReasonPlaceholder": "Enter ban reason...",
|
||||
"banDurationLabel": "Duration",
|
||||
"banDuration1Hour": "1 hour",
|
||||
"banDuration1Day": "1 day",
|
||||
"banDuration1Week": "1 week",
|
||||
"banDuration30Days": "30 days",
|
||||
"banDurationPermanent": "Permanent"
|
||||
},
|
||||
"overlays": {
|
||||
"closeGalleryAria": "Close image gallery",
|
||||
"viewImages": "View images",
|
||||
"imageCount": "{{count}} images",
|
||||
"close": "Close",
|
||||
"openImage": "Open {{filename}}",
|
||||
"closePreviewAria": "Close image preview",
|
||||
"previousImage": "Previous image",
|
||||
"nextImage": "Next image",
|
||||
"download": "Download",
|
||||
"copyImage": "Copy Image",
|
||||
"saveImage": "Save Image"
|
||||
},
|
||||
"linkEmbed": {
|
||||
"previewAlt": "Link preview"
|
||||
},
|
||||
"embeds": {
|
||||
"spotifyPlayer": "Spotify player",
|
||||
"soundcloudPlayer": "SoundCloud player"
|
||||
},
|
||||
"units": {
|
||||
"b": "B",
|
||||
"kb": "KB",
|
||||
"mb": "MB",
|
||||
"gb": "GB",
|
||||
"bPerSec": "B/s",
|
||||
"kbPerSec": "KB/s",
|
||||
"mbPerSec": "MB/s",
|
||||
"gbPerSec": "GB/s"
|
||||
},
|
||||
"effects": {
|
||||
"notConnectedToRoom": "Not connected to a room",
|
||||
"notLoggedIn": "Not logged in",
|
||||
"messageNotFound": "Message not found",
|
||||
"cannotEditOthers": "Cannot edit others messages",
|
||||
"cannotDeleteOthers": "Cannot delete others messages",
|
||||
"permissionDenied": "Permission denied"
|
||||
}
|
||||
}
|
||||
}
|
||||
53
toju-app/public/i18n/catalog/common.json
Normal file
53
toju-app/public/i18n/catalog/common.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"common": {
|
||||
"brand": "Toju",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"close": "Close",
|
||||
"dismiss": "Dismiss",
|
||||
"you": "You",
|
||||
"live": "LIVE",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"unknown": "Unknown",
|
||||
"muted": "Muted",
|
||||
"off": "Off",
|
||||
"defaultUser": "User",
|
||||
"cut": "Cut",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"selectAll": "Select All",
|
||||
"copyLink": "Copy Link",
|
||||
"copyImage": "Copy Image",
|
||||
"settings": "Settings",
|
||||
"documentation": "Documentation",
|
||||
"logout": "Logout",
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize",
|
||||
"reject": "Reject",
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"required": "Required",
|
||||
"ok": "OK",
|
||||
"join": "Join",
|
||||
"clear": "Clear",
|
||||
"manage": "Manage",
|
||||
"seeAll": "See all",
|
||||
"viewAll": "View all",
|
||||
"thisServer": "this server",
|
||||
"actions": {
|
||||
"backToDashboard": "Back to dashboard",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"closeDialog": "Close dialog",
|
||||
"ok": "OK"
|
||||
},
|
||||
"labels": {
|
||||
"anonymous": "Anonymous",
|
||||
"loading": "Loading",
|
||||
"unknown": "Unknown",
|
||||
"user": "User",
|
||||
"you": "You"
|
||||
}
|
||||
}
|
||||
}
|
||||
46
toju-app/public/i18n/catalog/dashboard.json
Normal file
46
toju-app/public/i18n/catalog/dashboard.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"dashboard": {
|
||||
"welcomeBack": "Welcome back, {{name}}",
|
||||
"welcomeGuestFallback": "there",
|
||||
"welcomeTitle": "Welcome to Toju",
|
||||
"subtitle": "Find people, discover servers, or start your own community.",
|
||||
"searchAriaLabel": "Search people, servers, and invites",
|
||||
"searchPlaceholderMobile": "Search people, servers, invites...",
|
||||
"searchPlaceholderDesktop": "Search for people, servers, or paste an invite...",
|
||||
"searchShortcut": "Ctrl K",
|
||||
"recent": "Recent:",
|
||||
"removeRecent": "Remove {{term}}",
|
||||
"invite": "Invite",
|
||||
"openInvite": "Open invite",
|
||||
"servers": "Servers",
|
||||
"people": "People",
|
||||
"noResults": "No people, servers, or invites match",
|
||||
"findPeople": {
|
||||
"title": "Find People",
|
||||
"subtitle": "Connect with friends."
|
||||
},
|
||||
"findServers": {
|
||||
"title": "Find Servers",
|
||||
"subtitle": "Browse communities."
|
||||
},
|
||||
"createServer": {
|
||||
"title": "Create Server",
|
||||
"subtitle": "Start your own."
|
||||
},
|
||||
"getStarted": {
|
||||
"title": "Get started",
|
||||
"description": "You have not joined any servers yet. Find a community to join, or create your own to invite friends."
|
||||
},
|
||||
"peopleYouMightKnow": "People you might know",
|
||||
"noPeopleSuggestions": "No people to suggest yet.",
|
||||
"popularServers": "Popular Servers",
|
||||
"noPopularServers": "No popular servers right now.",
|
||||
"yourFriends": "Your Friends",
|
||||
"recentlyActiveServers": "Recently Active Servers",
|
||||
"serverMeta": {
|
||||
"member": "{{count}} member",
|
||||
"members": "{{count}} members"
|
||||
},
|
||||
"roomMembers": "{{count}} members"
|
||||
}
|
||||
}
|
||||
65
toju-app/public/i18n/catalog/dm.json
Normal file
65
toju-app/public/i18n/catalog/dm.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"dm": {
|
||||
"find": {
|
||||
"title": "Find people",
|
||||
"subtitle": "Search for people you share servers with.",
|
||||
"searchAriaLabel": "Search people",
|
||||
"searchPlaceholder": "Search people...",
|
||||
"emptyTitle": "No people to show yet",
|
||||
"emptyMessage": "Join servers to discover people with shared interests.",
|
||||
"findServers": "Find servers"
|
||||
},
|
||||
"search": {
|
||||
"peopleTitle": "People",
|
||||
"friendsTitle": "Friends",
|
||||
"othersTitle": "Others",
|
||||
"noUsersFound": "No users found",
|
||||
"callUser": "Call {{name}}",
|
||||
"messageUser": "Message {{name}}"
|
||||
},
|
||||
"friend": {
|
||||
"add": "Add friend",
|
||||
"remove": "Remove friend"
|
||||
},
|
||||
"conversations": {
|
||||
"title": "Direct Messages",
|
||||
"chatCount": "{{count}} chats",
|
||||
"empty": "No direct messages yet."
|
||||
},
|
||||
"workspace": {
|
||||
"backToConversations": "Back to conversations",
|
||||
"directMessages": "Direct messages",
|
||||
"returnToCall": "Return to call"
|
||||
},
|
||||
"rail": {
|
||||
"title": "Direct Messages",
|
||||
"ariaLabel": "Direct Messages",
|
||||
"forgetChat": "Forget chat",
|
||||
"leaveChat": "Leave chat"
|
||||
},
|
||||
"chat": {
|
||||
"directMessage": "Direct Message",
|
||||
"groupChat": "Group Chat",
|
||||
"openProfile": "Open profile for {{name}}",
|
||||
"callPeer": "Call {{name}}",
|
||||
"typingOne": "is typing...",
|
||||
"typingMany": "are typing...",
|
||||
"closeGifPicker": "Close GIF picker",
|
||||
"selectPrompt": "Select a direct message from the rail.",
|
||||
"forgetPeer": "Forget {{name}}",
|
||||
"defaultTitle": "Direct Message",
|
||||
"railFallback": "DM"
|
||||
},
|
||||
"previews": {
|
||||
"noMessages": "No messages yet",
|
||||
"deleted": "Message deleted",
|
||||
"gif": "Sent a GIF",
|
||||
"image": "Sent an image",
|
||||
"video": "Sent a video",
|
||||
"audio": "Sent audio",
|
||||
"attachment": "Attachment",
|
||||
"oneAttachment": "Sent an attachment",
|
||||
"manyAttachments": "Sent attachments"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
toju-app/public/i18n/catalog/emoji.json
Normal file
22
toju-app/public/i18n/catalog/emoji.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"emoji": {
|
||||
"picker": {
|
||||
"openAria": "Open emoji selector",
|
||||
"title": "Emoji",
|
||||
"closeAria": "Close emoji selector",
|
||||
"searchPlaceholder": "Search emoji",
|
||||
"searchAria": "Search emoji",
|
||||
"uploading": "Uploading...",
|
||||
"upload": "Upload emoji",
|
||||
"emptySearch": "No emoji match your search.",
|
||||
"uploadFailed": "Unable to upload emoji.",
|
||||
"customEmojiFallback": "Custom emoji"
|
||||
},
|
||||
"validation": {
|
||||
"maxSize": "Emoji images can be max 1 MB.",
|
||||
"invalidType": "Emoji images must be WebP, GIF, JPG, or JPEG.",
|
||||
"invalidImage": "Invalid emoji image.",
|
||||
"readFailed": "Unable to read emoji image."
|
||||
}
|
||||
}
|
||||
}
|
||||
10
toju-app/public/i18n/catalog/experimental.json
Normal file
10
toju-app/public/i18n/catalog/experimental.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"experimental": {
|
||||
"vlcPlayer": {
|
||||
"download": "Download",
|
||||
"close": "Close",
|
||||
"loading": "Loading experimental player...",
|
||||
"retry": "Retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
33
toju-app/public/i18n/catalog/mobile.json
Normal file
33
toju-app/public/i18n/catalog/mobile.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"mobile": {
|
||||
"update": {
|
||||
"statusLabels": {
|
||||
"checking": "Checking",
|
||||
"downloading": "Downloading",
|
||||
"updateAvailable": "Update available",
|
||||
"upToDate": "Up to date",
|
||||
"unsupported": "Unsupported",
|
||||
"error": "Error",
|
||||
"idle": "Idle"
|
||||
},
|
||||
"messages": {
|
||||
"waitingForCheck": "Waiting for the first store update check.",
|
||||
"unsupported": "Store updates are only available in the packaged Android or iOS app.",
|
||||
"checking": "Checking the app store for a newer release...",
|
||||
"downloading": "Downloading the update from the app store...",
|
||||
"updateAvailable": "Toju {{version}} is available in the app store.",
|
||||
"upToDate": "Toju {{version}} is up to date.",
|
||||
"storeInfoFailed": "The app store could not return release information. Confirm the app is published and try again.",
|
||||
"checkFailed": "Unable to check for mobile app updates."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"incomingCallsChannel": "Incoming calls",
|
||||
"activeCallsChannel": "Active calls",
|
||||
"answer": "Answer",
|
||||
"decline": "Decline",
|
||||
"mute": "Mute",
|
||||
"hangUp": "Hang up"
|
||||
}
|
||||
}
|
||||
}
|
||||
37
toju-app/public/i18n/catalog/notifications.json
Normal file
37
toju-app/public/i18n/catalog/notifications.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"notifications": {
|
||||
"delivery": {
|
||||
"title": "Delivery",
|
||||
"description": "Desktop alerts use the system notification center on Linux and request taskbar attention when the app is not focused. Maximized app window suppress system popups, and only play the configured notification sound while the app is in the background."
|
||||
},
|
||||
"enable": {
|
||||
"label": "Enable notifications",
|
||||
"description": "Mute every server and channel notification without affecting unread indicators."
|
||||
},
|
||||
"showPreview": {
|
||||
"label": "Show message preview",
|
||||
"description": "Include a short message preview in desktop notifications when content privacy allows it."
|
||||
},
|
||||
"respectBusy": {
|
||||
"label": "Respect busy status",
|
||||
"description": "Suppress desktop alerts while your user presence is set to busy."
|
||||
},
|
||||
"serverOverrides": {
|
||||
"title": "Server Overrides",
|
||||
"description": "Right-click actions mirror these switches. Muted servers and channels still collect unread badges so you can catch up later.",
|
||||
"empty": "Join a server to configure notification overrides.",
|
||||
"defaultRoomDescription": "Notifications for every text channel in this server.",
|
||||
"unread": "{{count}} unread"
|
||||
},
|
||||
"channels": {
|
||||
"title": "Channels",
|
||||
"mutedHint": "Unread badges remain visible even if muted."
|
||||
},
|
||||
"display": {
|
||||
"defaultServerName": "Server",
|
||||
"newMessageHidden": "{{sender}} sent a new message",
|
||||
"newMessageEmpty": "{{sender}} sent a new message",
|
||||
"preview": "{{sender}}: {{content}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
161
toju-app/public/i18n/catalog/plugins.json
Normal file
161
toju-app/public/i18n/catalog/plugins.json
Normal file
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"plugins": {
|
||||
"manager": {
|
||||
"backToSettings": "Back to settings",
|
||||
"serverTitle": "Server plugins",
|
||||
"clientTitle": "Client plugins",
|
||||
"serverDescription": "Plugins installed for the current chat server.",
|
||||
"clientDescription": "Global client plugins installed on this device.",
|
||||
"activateReady": "Activate ready plugins",
|
||||
"openStore": "Open Plugin Store",
|
||||
"sectionsAria": "Plugin manager sections",
|
||||
"tabs": {
|
||||
"installed": "Installed",
|
||||
"extensions": "Extension points",
|
||||
"requirements": "Requirements",
|
||||
"settings": "Settings",
|
||||
"docs": "Docs",
|
||||
"logs": "Logs"
|
||||
},
|
||||
"extensionCounts": {
|
||||
"settingsPages": "Settings pages",
|
||||
"appPages": "App pages",
|
||||
"sidePanels": "Side panels",
|
||||
"channelSections": "Channel sections",
|
||||
"composerActions": "Composer actions",
|
||||
"profileActions": "Profile actions",
|
||||
"toolbarActions": "Toolbar actions",
|
||||
"slashCommands": "Slash commands",
|
||||
"embedRenderers": "Embed renderers"
|
||||
},
|
||||
"conflicts": {
|
||||
"title": "Conflict diagnostics",
|
||||
"none": "No duplicate route, action, embed, channel, panel, or settings contribution ids detected.",
|
||||
"conflictsIn": "conflicts in {{plugins}}"
|
||||
},
|
||||
"requirements": {
|
||||
"empty": "No server plugin requirements for the current room.",
|
||||
"serverStatus": "Server status: {{status}}",
|
||||
"versionRange": "Version range: {{range}}"
|
||||
},
|
||||
"settings": {
|
||||
"settingsSuffix": "settings",
|
||||
"noSchema": "This plugin does not declare a settings schema."
|
||||
},
|
||||
"docs": {
|
||||
"readme": "Readme",
|
||||
"homepage": "Homepage",
|
||||
"changelog": "Changelog",
|
||||
"support": "Support"
|
||||
},
|
||||
"logs": {
|
||||
"noPlugins": "No plugins installed.",
|
||||
"noLogs": "No logs for selected plugin."
|
||||
},
|
||||
"installed": {
|
||||
"select": "Select",
|
||||
"disable": "Disable",
|
||||
"enable": "Enable",
|
||||
"activate": "Activate",
|
||||
"reload": "Reload",
|
||||
"unload": "Unload"
|
||||
},
|
||||
"capabilities": {
|
||||
"title": "Capabilities",
|
||||
"none": "Plugin requests no capabilities.",
|
||||
"grantAll": "Grant all requested",
|
||||
"missing": "Missing: {{capabilities}}"
|
||||
},
|
||||
"empty": {
|
||||
"serverTitle": "No server plugins installed.",
|
||||
"clientTitle": "No client plugins installed.",
|
||||
"serverBody": "Server-scoped plugins use scope: server in toju-plugin.json.",
|
||||
"clientBody": "Client-scoped plugins use scope: client or omit scope in toju-plugin.json."
|
||||
}
|
||||
},
|
||||
"store": {
|
||||
"title": "Plugin Store",
|
||||
"backToApp": "Back to app",
|
||||
"summary": "{{installed}} installed for {{scope}} · {{available}} available · {{sources}} sources",
|
||||
"manage": "Manage Plugins",
|
||||
"refresh": "Refresh",
|
||||
"sourcePlaceholder": "https://example.com/plugins.json or /home/me/plugins/source.json",
|
||||
"sourceAria": "Plugin source manifest URL",
|
||||
"addSource": "Add Source",
|
||||
"sourcesAndFiltersAria": "Plugin sources and filters",
|
||||
"sources": "Sources",
|
||||
"allSources": "All sources",
|
||||
"removeSource": "Remove source",
|
||||
"filters": "Filters",
|
||||
"installedOnly": "Installed only",
|
||||
"installServer": "Install server",
|
||||
"defaultEndpoint": "Default endpoint",
|
||||
"noServerForInstall": "No server is available for plugin installs. Owner or Manage Server access is required.",
|
||||
"availablePluginsAria": "Available plugins",
|
||||
"searchPlaceholder": "Search plugins, authors, ids",
|
||||
"searchAria": "Search plugins",
|
||||
"shown": "{{count}} shown",
|
||||
"unknownAuthor": "Unknown author",
|
||||
"updateBadge": "Update",
|
||||
"installedBadge": "Installed",
|
||||
"loadingReadme": "Loading",
|
||||
"readme": "Readme",
|
||||
"loadReadme": "Load readme",
|
||||
"openGitHub": "Open GitHub",
|
||||
"emptyTitle": "No plugins found",
|
||||
"emptyWithSources": "Adjust filters or add another source manifest.",
|
||||
"emptyNoSources": "Add a plugin source manifest URL to populate the catalog.",
|
||||
"readmePanelAria": "Plugin readme",
|
||||
"readmeLabel": "Readme",
|
||||
"parsed": "Parsed",
|
||||
"raw": "Raw",
|
||||
"closeReadme": "Close readme",
|
||||
"openSourceReadme": "Open source readme",
|
||||
"serverInstall": {
|
||||
"title": "Server plugin install",
|
||||
"cancelInstall": "Cancel install",
|
||||
"installToServer": "Install to server",
|
||||
"optionalForMembers": "Optional for server members",
|
||||
"capabilities": "Capabilities",
|
||||
"noCapabilities": "This plugin requests no capabilities.",
|
||||
"cancel": "Cancel",
|
||||
"installAndActivate": "Install and Activate"
|
||||
},
|
||||
"actions": {
|
||||
"install": "Install",
|
||||
"installToServer": "Install to Server",
|
||||
"removeFromServer": "Remove from Server",
|
||||
"uninstall": "Uninstall",
|
||||
"update": "Update",
|
||||
"updateServer": "Update Server"
|
||||
},
|
||||
"errors": {
|
||||
"addSource": "Unable to add plugin source",
|
||||
"removeSource": "Unable to remove plugin source",
|
||||
"refreshSources": "Unable to refresh plugin sources",
|
||||
"updateInstallation": "Unable to update plugin installation",
|
||||
"loadReadme": "Unable to load readme",
|
||||
"noServerAccess": "You need owner or Manage Server access on a chat server before installing server plugins",
|
||||
"prepareServerInstall": "Unable to prepare server plugin install",
|
||||
"installServerPlugin": "Unable to install server plugin",
|
||||
"requiresServerAccess": "Requires owner or Manage Server access on a chat server"
|
||||
}
|
||||
},
|
||||
"actionMenu": {
|
||||
"title": "Plugins",
|
||||
"availableActions": "{{count}} available actions",
|
||||
"closeAria": "Close plugin menu",
|
||||
"close": "Close",
|
||||
"empty": "No plugin actions available.",
|
||||
"menuAria": "Plugin actions"
|
||||
},
|
||||
"pageHost": {
|
||||
"back": "Back",
|
||||
"unavailableTitle": "Plugin page unavailable",
|
||||
"unavailableBody": "The plugin page is not registered or the plugin is not loaded."
|
||||
},
|
||||
"renderHost": {
|
||||
"failed": "Plugin contribution failed to render"
|
||||
}
|
||||
}
|
||||
}
|
||||
42
toju-app/public/i18n/catalog/profile.json
Normal file
42
toju-app/public/i18n/catalog/profile.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"profile": {
|
||||
"avatarEditor": {
|
||||
"closeAria": "Close profile image editor",
|
||||
"title": "Adjust profile picture",
|
||||
"animatedHint": "Animated GIF and WebP avatars keep their original animation and framing.",
|
||||
"staticHint": "Drag image to frame subject. Zoom until preview looks right. Final image saves as 256x256 WebP.",
|
||||
"animatedPreviewHint": "Animation and original framing are preserved.",
|
||||
"staticPreviewHint": "Preview matches saved crop.",
|
||||
"source": "Source",
|
||||
"zoom": "Zoom",
|
||||
"animatedZoomHint": "Animated avatars keep the original frame sequence.",
|
||||
"staticZoomHint": "Use wheel or slider.",
|
||||
"animatedDetected": "Animated upload detected.",
|
||||
"zoomPercent": "{{percent}}% zoom",
|
||||
"cancel": "Cancel",
|
||||
"saving": "Saving...",
|
||||
"apply": "Apply picture",
|
||||
"processFailed": "Failed to process profile image.",
|
||||
"invalidFileType": "Invalid file type. Use WebP, GIF, JPG, or JPEG."
|
||||
},
|
||||
"card": {
|
||||
"addDescription": "Add a description",
|
||||
"playing": "Playing",
|
||||
"playingActivity": "Playing {{name}}",
|
||||
"openImageFailed": "Failed to open selected image.",
|
||||
"saveImageFailed": "Failed to save profile image.",
|
||||
"registeredOn": "Registered on {{url}}",
|
||||
"startChat": "Start chat",
|
||||
"call": "Call",
|
||||
"addFriend": "Add friend",
|
||||
"removeFriend": "Remove friend",
|
||||
"status": {
|
||||
"online": "Online",
|
||||
"away": "Away",
|
||||
"busy": "Do Not Disturb",
|
||||
"offline": "Invisible",
|
||||
"disconnected": "Offline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
toju-app/public/i18n/catalog/room.json
Normal file
76
toju-app/public/i18n/catalog/room.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"room": {
|
||||
"mobile": {
|
||||
"backToChannels": "Back to channels",
|
||||
"backToChat": "Back to chat",
|
||||
"showMembers": "Show members",
|
||||
"returnToCall": "Return to call",
|
||||
"members": "Members"
|
||||
},
|
||||
"empty": {
|
||||
"noTextChannels": "No text channels",
|
||||
"noTextChannelsDescription": "There are no existing text channels currently.",
|
||||
"noRoomSelected": "No room selected",
|
||||
"noRoomSelectedDescription": "Select or create a room to start chatting"
|
||||
},
|
||||
"panel": {
|
||||
"serverFallback": "Server",
|
||||
"serverDescriptionFallback": "Choose a text channel or jump into voice.",
|
||||
"membersCount": "{{count}} members",
|
||||
"onlineNow": "{{count}} online right now",
|
||||
"textChannels": "Text Channels",
|
||||
"voiceChannels": "Voice Channels",
|
||||
"createTextChannel": "Create Text Channel",
|
||||
"createVoiceChannel": "Create Voice Channel",
|
||||
"voiceDisabled": "Voice is disabled by host",
|
||||
"openStreamWorkspace": "Open stream workspace",
|
||||
"joinVoiceChannel": "Join voice channel",
|
||||
"open": "Open",
|
||||
"view": "View",
|
||||
"connectionIssue": "Connection issue - this user may not hear all participants. Consider adding a TURN server in Settings -> Network.",
|
||||
"mutedByYou": "Muted by you",
|
||||
"measuringLatency": "Measuring...",
|
||||
"latencyMs": "{{ms}} ms",
|
||||
"playing": "Playing {{game}}",
|
||||
"inVoice": "In voice",
|
||||
"plugins": "Plugins",
|
||||
"viewPlugins": "View plugins",
|
||||
"you": "You",
|
||||
"online": "Online - {{count}}",
|
||||
"offline": "Offline - {{count}}",
|
||||
"roles": {
|
||||
"owner": "Owner",
|
||||
"admin": "Admin",
|
||||
"mod": "Mod"
|
||||
},
|
||||
"message": "Message",
|
||||
"messageUser": "Message {{name}}",
|
||||
"noOtherUsers": "No other users in this server",
|
||||
"connectivityWarning": "You may have connectivity issues. Adding a TURN server in Settings -> Network may help."
|
||||
},
|
||||
"channel": {
|
||||
"namePlaceholder": "Channel name",
|
||||
"nameRequired": "Channel name is required.",
|
||||
"nameUnique": "Channel names must be unique within text or voice channels.",
|
||||
"createText": "Create Text Channel",
|
||||
"createVoice": "Create Voice Channel",
|
||||
"resyncMessages": "Resync Messages",
|
||||
"muteNotifications": "Mute Notifications",
|
||||
"unmuteNotifications": "Unmute Notifications",
|
||||
"rename": "Rename Channel",
|
||||
"delete": "Delete Channel"
|
||||
},
|
||||
"userMenu": {
|
||||
"promoteModerator": "Promote to Moderator",
|
||||
"promoteAdmin": "Promote to Admin",
|
||||
"demoteMember": "Demote to Member",
|
||||
"kickUser": "Kick User",
|
||||
"noActions": "No actions available"
|
||||
},
|
||||
"voiceJoin": {
|
||||
"noActiveRoom": "No active room selected for voice join.",
|
||||
"noPermission": "You do not have permission to join this voice channel.",
|
||||
"failed": "Failed to join voice channel."
|
||||
}
|
||||
}
|
||||
}
|
||||
55
toju-app/public/i18n/catalog/screenShare.json
Normal file
55
toju-app/public/i18n/catalog/screenShare.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"screenShare": {
|
||||
"quality": {
|
||||
"performance": {
|
||||
"label": "Performance saver",
|
||||
"description": "720p / 30 FPS with lower CPU and bandwidth usage."
|
||||
},
|
||||
"balanced": {
|
||||
"label": "Balanced",
|
||||
"description": "1080p / 30 FPS for stable quality in most cases."
|
||||
},
|
||||
"high-fps": {
|
||||
"label": "High FPS",
|
||||
"description": "1080p / 60 FPS for games and fast motion."
|
||||
},
|
||||
"quality": {
|
||||
"label": "Sharp text",
|
||||
"description": "1440p / 30 FPS for detailed UI and text clarity."
|
||||
}
|
||||
},
|
||||
"qualityDialog": {
|
||||
"ariaClose": "Close screen share quality dialog",
|
||||
"title": "Choose screen share quality",
|
||||
"description": "Pick the profile that best matches what you are sharing. You can change the default later in Voice settings.",
|
||||
"systemAudioNote": "Computer audio will be shared. Toju audio is filtered when supported, and your microphone stays on its normal voice track.",
|
||||
"startSharing": "Start sharing"
|
||||
},
|
||||
"sourcePicker": {
|
||||
"ariaClose": "Close source picker",
|
||||
"title": "Choose what to share",
|
||||
"description": "Select a screen or window to start sharing.",
|
||||
"includeSystemAudio": "Include system audio",
|
||||
"includeSystemAudioDescription": "Share desktop sound with viewers.",
|
||||
"tabListAria": "Share source type",
|
||||
"entireScreen": "Entire screen",
|
||||
"windows": "Windows",
|
||||
"systemAudioNote": "Computer audio will be shared. Toju audio is filtered when supported, and your microphone stays on its normal voice track.",
|
||||
"sourceKindScreen": "Entire screen",
|
||||
"sourceKindWindow": "Window",
|
||||
"noScreens": "No screens available",
|
||||
"noWindows": "No windows available",
|
||||
"noScreensDescription": "No displays were reported by Electron right now.",
|
||||
"noWindowsDescription": "Restore the window you want to share and try again.",
|
||||
"startSharing": "Start sharing"
|
||||
},
|
||||
"viewer": {
|
||||
"userSharing": "{{name}} is sharing their screen",
|
||||
"someoneSharing": "Someone is sharing their screen",
|
||||
"volume": "Volume: {{volume}}%",
|
||||
"stopSharing": "Stop sharing",
|
||||
"stopWatching": "Stop watching",
|
||||
"waiting": "Waiting for screen share..."
|
||||
}
|
||||
}
|
||||
}
|
||||
26
toju-app/public/i18n/catalog/servers-rail.json
Normal file
26
toju-app/public/i18n/catalog/servers-rail.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"serversRail": {
|
||||
"dashboard": "Dashboard",
|
||||
"openPrivateCall": "Open private call",
|
||||
"createServer": "Create a server",
|
||||
"muteNotifications": "Mute Notifications",
|
||||
"unmuteNotifications": "Unmute Notifications",
|
||||
"leaveServer": "Leave Server",
|
||||
"banned": {
|
||||
"title": "Banned",
|
||||
"message": "You are banned from {{server}}."
|
||||
},
|
||||
"password": {
|
||||
"title": "Password required",
|
||||
"confirmLabel": "Join server",
|
||||
"prompt": "Enter the password to rejoin {{serverName}}.",
|
||||
"label": "Server password",
|
||||
"placeholder": "Enter password"
|
||||
},
|
||||
"voicePresence": {
|
||||
"oneUser": "{{count}} user in voice",
|
||||
"manyUsers": "{{count}} users in voice"
|
||||
},
|
||||
"joinFailed": "Failed to join server"
|
||||
}
|
||||
}
|
||||
158
toju-app/public/i18n/catalog/servers.json
Normal file
158
toju-app/public/i18n/catalog/servers.json
Normal file
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"servers": {
|
||||
"browser": {
|
||||
"card": {
|
||||
"banned": "Banned",
|
||||
"private": "Private",
|
||||
"password": "Password",
|
||||
"joined": "Joined",
|
||||
"join": "Join",
|
||||
"leave": "Leave",
|
||||
"owner": "Owner: {{name}}",
|
||||
"doubleClickOpen": "Double-click to open {{name}}",
|
||||
"doubleClickJoin": "Double-click to join {{name}}",
|
||||
"serverActions": "Server actions for {{name}}",
|
||||
"joinServer": "Join {{name}}"
|
||||
},
|
||||
"search": {
|
||||
"ariaLabel": "Search servers",
|
||||
"placeholder": "Search servers...",
|
||||
"myServers": "My Servers",
|
||||
"resultsTitle": "Search results",
|
||||
"resultsCount": "{{count}} found",
|
||||
"noResults": "No servers found"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No servers yet",
|
||||
"message": "Search to find a server to join."
|
||||
},
|
||||
"bannedDialog": {
|
||||
"title": "Banned",
|
||||
"message": "You are banned from {{name}}.",
|
||||
"thisServer": "this server"
|
||||
},
|
||||
"passwordDialog": {
|
||||
"title": "Password required",
|
||||
"confirm": "Join server",
|
||||
"message": "Enter the password to join {{name}}.",
|
||||
"label": "Server password",
|
||||
"placeholder": "Enter password"
|
||||
},
|
||||
"plugins": {
|
||||
"eyebrow": "Plugin downloads",
|
||||
"usesPlugins": "{{name}} uses plugins",
|
||||
"requiredTitle": "Required before joining",
|
||||
"optionalTitle": "Optional plugins",
|
||||
"requiredBadge": "Required",
|
||||
"capabilities": "Capabilities",
|
||||
"source": "Source",
|
||||
"readme": "Readme",
|
||||
"cancelJoin": "Cancel join",
|
||||
"downloading": "Downloading",
|
||||
"acceptAndJoin": "Accept and join",
|
||||
"join": "Join",
|
||||
"readmeEyebrow": "Plugin readme",
|
||||
"closeReadme": "Close readme"
|
||||
},
|
||||
"ownerUnknown": "Unknown owner"
|
||||
},
|
||||
"find": {
|
||||
"title": "Find servers",
|
||||
"subtitle": "Browse, search, and join communities.",
|
||||
"searchPlaceholder": "Search servers...",
|
||||
"emptyTitle": "No servers to show yet",
|
||||
"emptyMessage": "Search for a server above, or create your own to get started."
|
||||
},
|
||||
"discovery": {
|
||||
"recentTitle": "Recently active",
|
||||
"recentSubtitle": "Servers you have joined",
|
||||
"featuredTitle": "Featured servers",
|
||||
"featuredSubtitle": "The busiest communities right now",
|
||||
"trendingTitle": "Trending",
|
||||
"trendingSubtitle": "Recently active and gaining momentum"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create a server",
|
||||
"subtitle": "Your server is where you and your community hang out.",
|
||||
"pickCategory": "Pick a category",
|
||||
"serverName": "Server name",
|
||||
"namePlaceholder": "My Awesome Server",
|
||||
"descriptionOptional": "Description (optional)",
|
||||
"descriptionPlaceholder": "What's your server about?",
|
||||
"advancedSettings": "Advanced settings",
|
||||
"topicOptional": "Topic (optional)",
|
||||
"topicPlaceholder": "gaming, music, coding...",
|
||||
"signalEndpoint": "Signal server endpoint",
|
||||
"signalEndpointHint": "This endpoint handles all signaling for this server.",
|
||||
"privateServer": "Private server",
|
||||
"passwordOptional": "Password (optional)",
|
||||
"passwordPlaceholder": "Leave blank to allow joining without a password",
|
||||
"passwordHint": "Users who already joined keep access even if you change the password later.",
|
||||
"submit": "Create server",
|
||||
"categories": {
|
||||
"gaming": "Gaming",
|
||||
"music": "Music",
|
||||
"coding": "Coding",
|
||||
"community": "Community",
|
||||
"study": "Study"
|
||||
}
|
||||
},
|
||||
"invite": {
|
||||
"badge": "Invite link",
|
||||
"titleJoin": "Join {{name}}",
|
||||
"titleFallback": "Toju server invite",
|
||||
"status": {
|
||||
"redirecting": "Sign in to continue with this invite.",
|
||||
"joining": "We are connecting you to the invited server.",
|
||||
"error": "This invite could not be completed automatically.",
|
||||
"loading": "Loading invite details and preparing the correct signal server."
|
||||
},
|
||||
"statusSection": "Status",
|
||||
"serverSection": "Server",
|
||||
"privateBadge": "Private",
|
||||
"passwordBypassBadge": "Password bypassed by invite",
|
||||
"expiresBadge": "Expires {{date}}",
|
||||
"nextStepsTitle": "What happens next",
|
||||
"nextSteps": {
|
||||
"signalServer": "The linked signal server is added to your configured server list if needed.",
|
||||
"bypassRestrictions": "Invite links bypass private and password restrictions.",
|
||||
"bannedStillBlocked": "Banned users still cannot join through invites."
|
||||
},
|
||||
"backToSearch": "Back to server search",
|
||||
"signalServerName": "Signal Server",
|
||||
"messages": {
|
||||
"loading": "Loading invite...",
|
||||
"joining": "Joining {{name}}...",
|
||||
"redirectingLogin": "Redirecting to login...",
|
||||
"missingInfo": "This invite link is missing required server information.",
|
||||
"acceptFailed": "Unable to accept this invite.",
|
||||
"banned": "You are banned from this server and cannot accept this invite.",
|
||||
"expired": "This invite has expired. Ask for a fresh invite link."
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"eyebrow": "Plugin downloads",
|
||||
"usesPlugins": "{{name}} uses plugins",
|
||||
"requiredTitle": "Required before joining",
|
||||
"optionalTitle": "Optional plugins",
|
||||
"requiredBadge": "Required",
|
||||
"capabilities": "Capabilities",
|
||||
"source": "Source",
|
||||
"readme": "Readme",
|
||||
"cancelJoin": "Cancel join",
|
||||
"downloading": "Downloading",
|
||||
"acceptAndJoin": "Accept and join",
|
||||
"join": "Join",
|
||||
"readmeEyebrow": "Plugin readme",
|
||||
"closeReadme": "Close readme"
|
||||
},
|
||||
"errors": {
|
||||
"notLoggedIn": "Not logged in",
|
||||
"roomNotFound": "Room not found",
|
||||
"banned": "You are banned from this server",
|
||||
"joinFailed": "Failed to join server",
|
||||
"installPluginsFailed": "Unable to install server plugins",
|
||||
"loadPluginReadmeFailed": "Unable to load plugin readme"
|
||||
}
|
||||
}
|
||||
}
|
||||
564
toju-app/public/i18n/catalog/settings.json
Normal file
564
toju-app/public/i18n/catalog/settings.json
Normal file
@@ -0,0 +1,564 @@
|
||||
{
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"closeAria": "Close settings",
|
||||
"backToMenuAria": "Back to settings menu",
|
||||
"sections": {
|
||||
"general": "General",
|
||||
"server": "Server"
|
||||
},
|
||||
"nav": {
|
||||
"general": "General",
|
||||
"plugins": "Client plugins",
|
||||
"theme": "Theme Studio",
|
||||
"network": "Network",
|
||||
"notifications": "Notifications",
|
||||
"voice": "Voice & Audio",
|
||||
"updates": "Updates",
|
||||
"localApi": "Local API",
|
||||
"data": "Data",
|
||||
"debugging": "Debugging",
|
||||
"server": "Server",
|
||||
"serverPlugins": "Server plugins",
|
||||
"members": "Members",
|
||||
"bans": "Bans",
|
||||
"permissions": "Permissions"
|
||||
},
|
||||
"pages": {
|
||||
"general": "General",
|
||||
"clientPlugins": "Client Plugins",
|
||||
"network": "Network",
|
||||
"theme": "Theme Studio",
|
||||
"notifications": "Notifications",
|
||||
"voice": "Voice & Audio",
|
||||
"updates": "Updates",
|
||||
"localApi": "Local API",
|
||||
"data": "Data",
|
||||
"debugging": "Debugging",
|
||||
"server": "Server Settings",
|
||||
"serverPlugins": "Server Plugins",
|
||||
"members": "Members",
|
||||
"bans": "Bans",
|
||||
"permissions": "Permissions"
|
||||
},
|
||||
"selectServer": "Select a server...",
|
||||
"thirdPartyLicenses": {
|
||||
"link": "Third-party licenses",
|
||||
"title": "Third-party licenses",
|
||||
"description": "License information for bundled third-party libraries used by the app.",
|
||||
"closeAria": "Close third-party licenses",
|
||||
"viewLicense": "View license",
|
||||
"packages": "Packages",
|
||||
"licenseText": "License text"
|
||||
},
|
||||
"theme": {
|
||||
"activeTheme": "Active Theme",
|
||||
"description": "Launch Theme Studio to edit the live draft, inspect themeable regions, or switch to a saved theme.",
|
||||
"minimized": "Minimized",
|
||||
"savedTheme": "Saved Theme",
|
||||
"chooseSavedTheme": "Choose saved theme",
|
||||
"noSavedThemes": "No saved themes",
|
||||
"editInStudio": "Edit In Studio",
|
||||
"reopenStudio": "Re-open Theme Studio",
|
||||
"openStudio": "Open Theme Studio",
|
||||
"restoreDefault": "Restore Default"
|
||||
},
|
||||
"dataLoading": "Loading data settings...",
|
||||
"serverPlugins": {
|
||||
"title": "Open this server to manage plugins",
|
||||
"description": "Server plugin installs and activation are shown for the currently open chat server. Select or open {{serverName}} in the app, then return here.",
|
||||
"thisServer": "this server"
|
||||
},
|
||||
"standalone": {
|
||||
"goBack": "Go back",
|
||||
"pluginStore": "Plugin Store"
|
||||
},
|
||||
"network": {
|
||||
"serverEndpoints": {
|
||||
"title": "Server Endpoints",
|
||||
"restoreDefaults": "Restore Defaults",
|
||||
"testAll": "Test All",
|
||||
"description": "Active server endpoints stay enabled at the same time. You pick the endpoint when creating and registering a new server.",
|
||||
"descriptionModal": "Active server endpoints stay enabled at the same time. You pick the endpoint when creating a new server.",
|
||||
"active": "Active",
|
||||
"incompatible": "Update the client in order to connect to other users",
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
"remove": "Remove server",
|
||||
"removeShort": "Remove",
|
||||
"addNew": "Add New Server",
|
||||
"serverNamePlaceholder": "Server name (e.g., My Server)",
|
||||
"serverNamePlaceholderShort": "Server name",
|
||||
"serverUrlPlaceholder": "Server URL (e.g., http://localhost:3001)",
|
||||
"errors": {
|
||||
"invalidUrl": "Please enter a valid URL",
|
||||
"duplicateUrl": "This server URL already exists"
|
||||
}
|
||||
},
|
||||
"connection": {
|
||||
"title": "Connection Settings",
|
||||
"titleShort": "Connection",
|
||||
"autoReconnect": {
|
||||
"label": "Auto-reconnect",
|
||||
"description": "Automatically reconnect when connection is lost",
|
||||
"descriptionShort": "Reconnect when connection is lost"
|
||||
},
|
||||
"searchAllServers": {
|
||||
"label": "Search all servers",
|
||||
"description": "Search across all configured server directories",
|
||||
"descriptionShort": "Search across all server directories"
|
||||
}
|
||||
},
|
||||
"ice": {
|
||||
"title": "ICE Servers (STUN / TURN)",
|
||||
"restoreDefaults": "Restore Defaults",
|
||||
"description": "ICE servers are used for NAT traversal. STUN discovers your public address; TURN relays traffic when direct connections fail. Higher entries have priority.",
|
||||
"turnUser": "User: {{username}}",
|
||||
"moveUp": "Move up (higher priority)",
|
||||
"moveDown": "Move down (lower priority)",
|
||||
"empty": "No ICE servers configured. P2P connections may fail across networks.",
|
||||
"addTitle": "Add ICE Server",
|
||||
"stunPlaceholder": "stun:stun.example.com:19302",
|
||||
"turnPlaceholder": "turn:turn.example.com:3478",
|
||||
"username": "Username",
|
||||
"credential": "Credential",
|
||||
"addServer": "Add Server",
|
||||
"errors": {
|
||||
"urlRequired": "URL is required",
|
||||
"urlPrefixStun": "URL must start with stun:",
|
||||
"urlPrefixTurn": "URL must start with turn: or turns:",
|
||||
"usernameRequired": "Username is required for TURN servers",
|
||||
"credentialRequired": "Credential is required for TURN servers",
|
||||
"duplicateUrl": "This URL already exists"
|
||||
}
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"application": "Application",
|
||||
"reopenLastChat": {
|
||||
"label": "Reopen last chat on launch",
|
||||
"description": "Open the same server and text channel the next time Toju starts.",
|
||||
"aria": "Toggle reopen last chat on launch"
|
||||
},
|
||||
"autoStart": {
|
||||
"label": "Launch on system startup",
|
||||
"description": "Automatically start Toju when you sign in",
|
||||
"desktopOnly": "This setting is only available in the desktop app.",
|
||||
"aria": "Toggle launch on startup"
|
||||
},
|
||||
"closeToTray": {
|
||||
"label": "Minimize to tray on close",
|
||||
"description": "Keep Toju running in the tray when you click the X button",
|
||||
"desktopOnly": "This setting is only available in the desktop app.",
|
||||
"aria": "Toggle minimize to tray on close"
|
||||
},
|
||||
"experimentalVlc": {
|
||||
"label": "Experimental VLC.js playback",
|
||||
"checking": "Checking for a bundled VLC.js runtime...",
|
||||
"available": "Offer a manual player for unsupported downloaded audio and video files.",
|
||||
"unavailable": "No VLC.js runtime is bundled. Unsupported desktop media can be opened in the system player.",
|
||||
"aria": "Toggle experimental VLC.js playback"
|
||||
},
|
||||
"gameDetection": {
|
||||
"title": "Game detection",
|
||||
"description": "Toju prefers the currently focused window when detecting your game. Add process names here to permanently hide apps that get mistakenly identified as games (e.g. \"spotify\", \"obs64\"). Entries are matched case-insensitively against the executable name without its extension.",
|
||||
"processPlaceholder": "Process name (e.g. spotify)",
|
||||
"processAria": "Process name to ignore",
|
||||
"add": "Add",
|
||||
"empty": "No ignored processes yet.",
|
||||
"removeProcessAria": "Remove {{name}} from ignore list"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"devices": {
|
||||
"title": "Devices",
|
||||
"microphone": "Microphone",
|
||||
"speaker": "Speaker",
|
||||
"microphoneFallback": "Microphone {{index}}",
|
||||
"speakerFallback": "Speaker {{index}}"
|
||||
},
|
||||
"volume": {
|
||||
"title": "Volume",
|
||||
"input": "Input Volume: {{value}}%",
|
||||
"output": "Output Volume: {{value}}%",
|
||||
"notification": "Notification Volume: {{value}}%",
|
||||
"notificationHint": "Controls join, leave & notification sounds",
|
||||
"test": "Test",
|
||||
"previewAria": "Preview notification sound",
|
||||
"previewTitle": "Preview sound"
|
||||
},
|
||||
"quality": {
|
||||
"title": "Quality & Processing",
|
||||
"latencyProfile": "Latency Profile",
|
||||
"latencyLow": "Low (fast)",
|
||||
"latencyBalanced": "Balanced",
|
||||
"latencyHigh": "High (quality)",
|
||||
"audioBitrate": "Audio Bitrate: {{value}} kbps",
|
||||
"screenShareQuality": "Screen share quality",
|
||||
"askScreenShare": {
|
||||
"label": "Ask before screen sharing",
|
||||
"description": "Let the user confirm quality before each new screen share",
|
||||
"aria": "Toggle screen share quality prompt"
|
||||
},
|
||||
"noiseReduction": {
|
||||
"label": "Noise reduction",
|
||||
"description": "Suppress background noise using RNNoise",
|
||||
"descriptionLong": "Use RNNoise to suppress background noise from your microphone",
|
||||
"aria": "Toggle noise reduction"
|
||||
},
|
||||
"systemAudio": {
|
||||
"label": "Screen share system audio",
|
||||
"description": "Share other computer audio while filtering Toju audio when supported",
|
||||
"hint": "Your microphone stays on the normal voice channel. The shared screen audio should only contain desktop sound.",
|
||||
"aria": "Toggle system audio in screen share"
|
||||
}
|
||||
},
|
||||
"screenShareQuality": {
|
||||
"performance": {
|
||||
"label": "Performance saver",
|
||||
"description": "720p / 30 FPS with lower CPU and bandwidth usage."
|
||||
},
|
||||
"balanced": {
|
||||
"label": "Balanced",
|
||||
"description": "1080p / 30 FPS for stable quality in most cases."
|
||||
},
|
||||
"high-fps": {
|
||||
"label": "High FPS",
|
||||
"description": "1080p / 60 FPS for games and fast motion."
|
||||
},
|
||||
"quality": {
|
||||
"label": "Sharp text",
|
||||
"description": "1440p / 30 FPS for detailed UI and text clarity."
|
||||
}
|
||||
},
|
||||
"desktopPerformance": {
|
||||
"title": "Desktop Performance",
|
||||
"hardwareAcceleration": {
|
||||
"label": "Hardware acceleration",
|
||||
"description": "Use GPU acceleration for rendering and WebRTC when available",
|
||||
"aria": "Toggle hardware acceleration"
|
||||
},
|
||||
"restartRequired": {
|
||||
"title": "Restart required",
|
||||
"description": "Restart Toju to apply the new hardware acceleration setting.",
|
||||
"button": "Restart app"
|
||||
}
|
||||
},
|
||||
"standalone": {
|
||||
"title": "Voice Settings",
|
||||
"notificationVolume": {
|
||||
"label": "Notification volume",
|
||||
"description": "Volume for join, leave, and notification sounds"
|
||||
}
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"localData": {
|
||||
"title": "Local data",
|
||||
"description": "Manage the folder that contains local messages, rooms, attachments, avatars, saved themes, and desktop storage.",
|
||||
"restartApp": "Restart app"
|
||||
},
|
||||
"desktopOnly": "Data management is only available in the packaged Electron desktop app.",
|
||||
"currentFolder": {
|
||||
"title": "Current data folder",
|
||||
"resolving": "Resolving data folder..."
|
||||
},
|
||||
"openFolder": "Open folder",
|
||||
"opening": "Opening...",
|
||||
"export": {
|
||||
"title": "Export data",
|
||||
"description": "Create a portable .dat archive that can be imported on another client.",
|
||||
"button": "Export data",
|
||||
"exporting": "Exporting..."
|
||||
},
|
||||
"import": {
|
||||
"title": "Import all data",
|
||||
"description": "Restore a .dat archive. Existing local data is moved to a backup folder first.",
|
||||
"button": "Import data",
|
||||
"importing": "Importing...",
|
||||
"confirm": "Importing data replaces the current local data. Existing data will be moved to a backup folder first. Continue?"
|
||||
},
|
||||
"erase": {
|
||||
"title": "Erase user data",
|
||||
"description": "Remove local app data from this device and recreate an empty database.",
|
||||
"button": "Erase user data",
|
||||
"erasing": "Erasing...",
|
||||
"confirm": "Erase all local Toju data on this device? This cannot be undone."
|
||||
},
|
||||
"messages": {
|
||||
"openedFolder": "Opened the current data folder.",
|
||||
"couldNotOpenFolder": "Could not open the data folder.",
|
||||
"exportCancelled": "Export cancelled.",
|
||||
"exportedTo": "Exported data to {{path}}.",
|
||||
"exported": "Exported data.",
|
||||
"importCancelled": "Import cancelled.",
|
||||
"importedWithBackup": "Imported data. Previous data was backed up to {{path}}.",
|
||||
"imported": "Imported data.",
|
||||
"erased": "Local data erased. Restart the app to finish resetting the session.",
|
||||
"operationFailed": "Data operation failed."
|
||||
}
|
||||
},
|
||||
"updates": {
|
||||
"desktop": {
|
||||
"title": "Desktop app updates",
|
||||
"description": "Use a hosted release manifest to check for new packaged desktop builds and apply them after a restart."
|
||||
},
|
||||
"mobile": {
|
||||
"title": "Mobile app updates",
|
||||
"description": "Check the Play Store or App Store for newer native builds. Android can install in-app updates when Google Play allows it.",
|
||||
"unsupported": "Store updates are only available in the packaged Android or iOS app."
|
||||
},
|
||||
"unsupported": "Automatic updates are only available in the packaged Electron desktop app or native mobile app.",
|
||||
"installed": "Installed",
|
||||
"storeVersion": "Store version",
|
||||
"latestInManifest": "Latest in manifest",
|
||||
"targetVersion": "Target version",
|
||||
"lastChecked": "Last checked",
|
||||
"unknown": "Unknown",
|
||||
"automatic": "Automatic",
|
||||
"notCheckedYet": "Not checked yet",
|
||||
"status": "Status",
|
||||
"waitingMobile": "Waiting for the first store update check.",
|
||||
"waitingDesktop": "Waiting for release information from the active server.",
|
||||
"checkForUpdates": "Check for updates",
|
||||
"openAppStore": "Open app store",
|
||||
"installUpdate": "Install update",
|
||||
"restartToFinish": "Restart to finish update",
|
||||
"policy": {
|
||||
"title": "Update policy",
|
||||
"description": "Choose whether the app tracks the newest release, stays on a specific release, or turns updates off entirely.",
|
||||
"mode": "Mode",
|
||||
"modeAuto": "Newest release",
|
||||
"modeVersion": "Specific version",
|
||||
"modeOff": "Turn off auto updates",
|
||||
"pinnedVersion": "Pinned version",
|
||||
"chooseRelease": "Choose a release..."
|
||||
},
|
||||
"refreshReleaseInfo": "Refresh release info",
|
||||
"restartToUpdate": "Restart to update",
|
||||
"manifest": {
|
||||
"title": "Manifest URL priority",
|
||||
"description": "Add one manifest URL per line. The app tries them from top to bottom and falls back to the next URL when a manifest cannot be loaded or is invalid.",
|
||||
"usingDefaults": "Using connected server defaults",
|
||||
"usingSaved": "Using saved manifest URLs",
|
||||
"emptyHint": "When this list is empty, the app automatically uses manifest URLs reported by your configured servers.",
|
||||
"urlsLabel": "Manifest URLs",
|
||||
"placeholder": "https://example.com/releases/latest/download/release-manifest.json",
|
||||
"noServerManifest": "None of your configured servers currently report a manifest URL.",
|
||||
"save": "Save manifest URLs",
|
||||
"useDefaults": "Use connected server defaults"
|
||||
},
|
||||
"serverBlocked": {
|
||||
"title": "Server update required",
|
||||
"connectedServer": "Connected server",
|
||||
"requiredMinimum": "Required minimum",
|
||||
"notReported": "Not reported"
|
||||
},
|
||||
"resolvedManifest": {
|
||||
"title": "Resolved manifest URL",
|
||||
"empty": "No working manifest URL has been resolved yet."
|
||||
},
|
||||
"statusLabels": {
|
||||
"idle": "Idle",
|
||||
"checking": "Checking",
|
||||
"downloading": "Downloading",
|
||||
"restartRequired": "Restart required",
|
||||
"upToDate": "Up to date",
|
||||
"disabled": "Disabled",
|
||||
"unsupported": "Unsupported",
|
||||
"manifestMissing": "Manifest missing",
|
||||
"versionUnavailable": "Version unavailable",
|
||||
"pinnedBelowCurrent": "Pinned below current",
|
||||
"error": "Error"
|
||||
},
|
||||
"mobileStatusLabels": {
|
||||
"idle": "Idle",
|
||||
"checking": "Checking",
|
||||
"downloading": "Downloading",
|
||||
"upToDate": "Up to date",
|
||||
"updateAvailable": "Update available",
|
||||
"unsupported": "Unsupported",
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"localApi": {
|
||||
"title": "Local HTTP API",
|
||||
"description": "Expose your client to local automation tools and scripts. Authentication is verified against your signaling server, and access is off by default.",
|
||||
"desktopOnly": "The local API is only available in the packaged Electron desktop app.",
|
||||
"server": {
|
||||
"title": "Server",
|
||||
"description": "Enable to start a local HTTP server. By default it only listens on the loopback interface.",
|
||||
"run": "Run local API server",
|
||||
"runHint": "Start the HTTP server on this machine.",
|
||||
"docusaurus": "Serve Docusaurus documentation at /docusaurus",
|
||||
"docusaurusHint": "Hosts the built app and plugin documentation from local desktop resources.",
|
||||
"exposeLan": "Allow connections from your network",
|
||||
"exposeLanHint": "Bind to all interfaces (0.0.0.0). Other devices on your LAN will be able to reach the API. Only enable this on networks you trust.",
|
||||
"port": "Port",
|
||||
"portHint": "Change the listening port if 17878 is in use. Press save to apply.",
|
||||
"savePort": "Save port"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentication",
|
||||
"description": "Bearer tokens are issued only after a username/password is verified against one of the signaling servers below. Add the full URL (including https://) of every signaling server you trust.",
|
||||
"placeholder": "https://signaling.example.com",
|
||||
"save": "Save allowed servers"
|
||||
},
|
||||
"docs": {
|
||||
"title": "Documentation",
|
||||
"description": "Browse the API in a privacy-respecting locally hosted Scalar reference. No telemetry, no AI, no remote network calls.",
|
||||
"scalar": "Serve Scalar documentation at /docs",
|
||||
"scalarHint": "Loads from local app resources only. The OpenAPI document is always available at /api/openapi.json.",
|
||||
"openApi": "Open API docs in browser",
|
||||
"openAppDocs": "Open app docs in browser",
|
||||
"copyBaseUrl": "Copy base URL",
|
||||
"listeningAt": "Listening at"
|
||||
},
|
||||
"status": {
|
||||
"running": "Running at {{url}}",
|
||||
"starting": "Starting...",
|
||||
"error": "Error: {{message}}",
|
||||
"stopped": "Stopped",
|
||||
"unknown": "unknown"
|
||||
},
|
||||
"errors": {
|
||||
"invalidPort": "Port must be an integer between 1 and 65535",
|
||||
"couldNotOpenDocs": "Could not open documentation",
|
||||
"updateFailed": "Failed to update settings"
|
||||
}
|
||||
},
|
||||
"debugging": {
|
||||
"title": "App-wide debugging",
|
||||
"description": "Capture UI events, navigation activity, console output, and global runtime errors in a live debug console.",
|
||||
"processRam": "Process RAM",
|
||||
"ramHint": "Live total working set from Electron app metrics. Updates every 2 seconds.",
|
||||
"capturedEvents": "Captured events",
|
||||
"lastUpdate": "Last update: {{label}}",
|
||||
"noLogsYet": "No logs yet",
|
||||
"errors": "Errors",
|
||||
"errorsHint": "Unhandled runtime failures and rejected promises.",
|
||||
"warnings": "Warnings",
|
||||
"warningsHint": "Navigation cancellations, offline events, and other warnings.",
|
||||
"console": {
|
||||
"title": "Floating debug console",
|
||||
"description": "When debugging is enabled, a bug icon appears in the app so you can open the docked console without blocking the rest of the UI.",
|
||||
"open": "Open console",
|
||||
"openActive": "Console open",
|
||||
"clear": "Clear logs"
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"intro": "Roles now define who can moderate, manage channels, upload files, and join voice. Channel overrides are optional and apply on top of the base role permissions.",
|
||||
"readOnly": "You can inspect this server's access model, but only members with Manage Roles can edit it.",
|
||||
"roles": {
|
||||
"title": "Roles",
|
||||
"hint": "Higher roles appear first.",
|
||||
"add": "Role",
|
||||
"system": "System",
|
||||
"protected": "Protected role",
|
||||
"name": "Role Name",
|
||||
"color": "Color",
|
||||
"save": "Save Role",
|
||||
"moveUp": "Move Up",
|
||||
"moveDown": "Move Down",
|
||||
"delete": "Delete",
|
||||
"systemHint": "System roles can still have their permissions tuned, but their name, color, and membership in the base hierarchy stay fixed.",
|
||||
"editHint": "Edit the role metadata here, then tune its global permissions and per-channel overrides below."
|
||||
},
|
||||
"slowMode": {
|
||||
"title": "Slow Mode",
|
||||
"description": "Sets the minimum delay between messages for everyone in the server.",
|
||||
"off": "Off",
|
||||
"5s": "5 seconds",
|
||||
"10s": "10 seconds",
|
||||
"30s": "30 seconds",
|
||||
"1m": "1 minute",
|
||||
"2m": "2 minutes"
|
||||
},
|
||||
"basePermissions": {
|
||||
"title": "Base Permissions",
|
||||
"description": "These defaults apply everywhere unless a channel override changes them."
|
||||
},
|
||||
"channelOverrides": {
|
||||
"title": "Channel Overrides",
|
||||
"description": "Override the selected role inside a specific channel without changing the server-wide default.",
|
||||
"noChannels": "This server has no channels yet.",
|
||||
"channel": "Channel"
|
||||
},
|
||||
"states": {
|
||||
"inherit": "Inherit",
|
||||
"allow": "Allow",
|
||||
"deny": "Deny"
|
||||
},
|
||||
"newRole": "New Role",
|
||||
"selectServer": "Select a server from the sidebar to manage"
|
||||
},
|
||||
"server": {
|
||||
"title": "Room Settings",
|
||||
"readOnly": "You are viewing this server's details without server-management permission.",
|
||||
"image": {
|
||||
"title": "Server Image",
|
||||
"description": "Synced to members and shown in server discovery.",
|
||||
"upload": "Upload image",
|
||||
"uploadAria": "Upload server image",
|
||||
"remove": "Remove image",
|
||||
"removeAria": "Remove server image",
|
||||
"readError": "Could not read that image."
|
||||
},
|
||||
"roomName": "Room Name",
|
||||
"description": "Description",
|
||||
"private": {
|
||||
"label": "Private Room",
|
||||
"description": "Require approval to join",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
},
|
||||
"maxUsers": "Max Users (0 = unlimited)",
|
||||
"password": {
|
||||
"title": "Server Password",
|
||||
"whitelisted": "Joined members stay whitelisted until they are kicked or banned.",
|
||||
"addOptional": "Add an optional password so new members need it to join.",
|
||||
"remove": "Remove Password",
|
||||
"keep": "Keep Password",
|
||||
"enabled": "Password protection is currently enabled.",
|
||||
"willRemove": "Password protection will be removed when you save.",
|
||||
"disabled": "Password protection is currently disabled.",
|
||||
"setNew": "Set New Password",
|
||||
"set": "Set Password",
|
||||
"keepCurrentPlaceholder": "Leave blank to keep the current password",
|
||||
"optionalPlaceholder": "Optional password required for new joins",
|
||||
"willReplace": "The new password will replace the current one when you save.",
|
||||
"viewerHint": "Invite links bypass the password, but bans still apply.",
|
||||
"enabledShort": "Enabled",
|
||||
"disabledShort": "Disabled"
|
||||
},
|
||||
"save": "Save Settings",
|
||||
"saved": "Saved!",
|
||||
"dangerZone": "Danger Zone",
|
||||
"deleteRoom": "Delete Room",
|
||||
"deleteConfirm": {
|
||||
"title": "Delete Room",
|
||||
"confirm": "Delete Room",
|
||||
"message": "Are you sure you want to delete this room? This action cannot be undone."
|
||||
},
|
||||
"selectServer": "Select a server from the sidebar to manage"
|
||||
},
|
||||
"members": {
|
||||
"empty": "No other members found for this server",
|
||||
"online": "Online",
|
||||
"kick": "Kick",
|
||||
"ban": "Ban",
|
||||
"assignedRoles": "Assigned Roles",
|
||||
"readOnlyRoles": "You can view this member's roles, but you do not have permission to change them.",
|
||||
"selectServer": "Select a server from the sidebar to manage"
|
||||
},
|
||||
"bans": {
|
||||
"empty": "No banned users",
|
||||
"unknownUser": "Unknown User",
|
||||
"reason": "Reason: {{reason}}",
|
||||
"expires": "Expires: {{date}}",
|
||||
"permanent": "Permanent",
|
||||
"selectServer": "Select a server from the sidebar to manage"
|
||||
}
|
||||
}
|
||||
}
|
||||
192
toju-app/public/i18n/catalog/shared.json
Normal file
192
toju-app/public/i18n/catalog/shared.json
Normal file
@@ -0,0 +1,192 @@
|
||||
{
|
||||
"shared": {
|
||||
"dialog": {
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"closeDialogAria": "Close dialog",
|
||||
"closeMenuAria": "Close menu",
|
||||
"menuFallbackAria": "Menu"
|
||||
},
|
||||
"leaveServer": {
|
||||
"title": "Leave Server?",
|
||||
"removeFromList": "Leaving will remove",
|
||||
"fromMyServers": "from your My Servers list.",
|
||||
"ownerNotice": "You are the current owner of this server.",
|
||||
"ownerTransferHint": "You can optionally promote another member before leaving. If you skip this step, the server will continue without an owner.",
|
||||
"newOwner": "New owner",
|
||||
"skipOwnerTransfer": "Skip owner transfer",
|
||||
"noMembersToPromote": "No other known members are available to promote right now.",
|
||||
"leave": "Leave Server",
|
||||
"roles": {
|
||||
"owner": "Owner",
|
||||
"admin": "Admin",
|
||||
"moderator": "Moderator",
|
||||
"member": "Member"
|
||||
}
|
||||
},
|
||||
"mediaPlayer": {
|
||||
"saveAudioAria": "Save audio to folder",
|
||||
"saveVideoAria": "Save video to folder",
|
||||
"saveToFolder": "Save to folder",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"playAudioAria": "Play audio",
|
||||
"pauseAudioAria": "Pause audio",
|
||||
"playVideoAria": "Play video",
|
||||
"pauseVideoAria": "Pause video",
|
||||
"togglePlaybackAria": "Toggle video playback",
|
||||
"playVideoOverlay": "Play video",
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"muteAudioAria": "Mute audio",
|
||||
"unmuteAudioAria": "Unmute audio",
|
||||
"muteVideoAria": "Mute video",
|
||||
"unmuteVideoAria": "Unmute video",
|
||||
"seekAudioAria": "Seek audio",
|
||||
"seekVideoAria": "Seek video",
|
||||
"volumeAudioAria": "Audio volume",
|
||||
"volumeVideoAria": "Video volume",
|
||||
"showWaveform": "Show waveform",
|
||||
"hideWaveform": "Hide waveform",
|
||||
"showWaveformAria": "Show waveform",
|
||||
"hideWaveformAria": "Hide waveform",
|
||||
"loadingWaveform": "Loading waveform...",
|
||||
"waveformUnavailable": "Couldn't render a waveform preview for this file, but playback still works.",
|
||||
"fullscreen": "Fullscreen",
|
||||
"exitFullscreen": "Exit fullscreen",
|
||||
"enterFullscreenAria": "Enter fullscreen",
|
||||
"exitFullscreenAria": "Exit fullscreen"
|
||||
},
|
||||
"debugConsole": {
|
||||
"toggleAria": "Toggle debug console",
|
||||
"toggleTitle": "Toggle debug console",
|
||||
"resizeWidthAria": "Resize debug console width",
|
||||
"resizeWidthRightAria": "Resize debug console width from right",
|
||||
"resizeAria": "Resize debug console",
|
||||
"resizeHeightBottomAria": "Resize debug console height from bottom",
|
||||
"resizeCornerAria": "Resize debug console from corner",
|
||||
"moveAria": "Move debug console",
|
||||
"dragToMove": "Drag to move",
|
||||
"showingLatest": "Showing latest 500 of {{total}} entries",
|
||||
"showAll": "Show all",
|
||||
"title": "Debug Console",
|
||||
"visibleCount": "{{count}} visible",
|
||||
"networkSummary": "{{clients}} clients · {{links}} links",
|
||||
"logsDescription": "Search logs, filter by level or source, and inspect timestamps inline.",
|
||||
"networkDescription": "Visualize signaling, peer links, typing, speaking, streaming, and grouped traffic directly from captured debug data.",
|
||||
"dock": "Dock",
|
||||
"undock": "Undock",
|
||||
"pauseAutoScroll": "Pause auto-scroll",
|
||||
"resumeAutoScroll": "Resume auto-scroll",
|
||||
"export": "Export",
|
||||
"exportLogsTitle": "Export logs",
|
||||
"logsSection": "Logs",
|
||||
"exportCsv": "Export as CSV",
|
||||
"exportTxt": "Export as TXT",
|
||||
"exportJson": "Export as JSON",
|
||||
"networkSection": "Network",
|
||||
"exportNetworkJson": "Export network JSON",
|
||||
"clear": "Clear",
|
||||
"close": "Close",
|
||||
"logsTab": "Logs",
|
||||
"networkTab": "Network",
|
||||
"searchPlaceholder": "Search messages, payloads, timestamps, and sources",
|
||||
"searchLogsSrOnly": "Search logs",
|
||||
"filterBySourceSrOnly": "Filter by source",
|
||||
"allSources": "All sources",
|
||||
"levelsLabel": "Levels",
|
||||
"levels": {
|
||||
"event": "Events",
|
||||
"debug": "Debug",
|
||||
"info": "Info",
|
||||
"warn": "Warn",
|
||||
"error": "Error",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"networkTrafficHint": "Traffic is grouped by edge and message type to keep signaling, voice-state, and screen-state chatter readable.",
|
||||
"networkBadges": {
|
||||
"typing": "{{count}} typing",
|
||||
"speaking": "{{count}} speaking",
|
||||
"streaming": "{{count}} streaming",
|
||||
"memberships": "{{count}} memberships"
|
||||
},
|
||||
"entryList": {
|
||||
"noLogsMatch": "No logs match the current filters.",
|
||||
"noLogsHint": "Generate activity in the app or loosen the filters to see captured events.",
|
||||
"hideDetails": "Hide details",
|
||||
"showDetails": "Show details"
|
||||
},
|
||||
"networkMap": {
|
||||
"clients": "{{count}} clients",
|
||||
"servers": "{{count}} servers",
|
||||
"peerLinks": "{{count}} peer links",
|
||||
"groupedMessages": "{{count}} grouped messages",
|
||||
"localClient": "Local client",
|
||||
"remoteClient": "Remote client",
|
||||
"signaling": "Signaling",
|
||||
"server": "Server",
|
||||
"noActivityTitle": "No network activity captured yet.",
|
||||
"noActivityBody": "Enable debugging before connecting to signaling, joining a server, or opening peer channels to populate the live map.",
|
||||
"peerDetails": "Peer details",
|
||||
"updated": "Updated {{age}}",
|
||||
"peerDetailsEmpty": "Connected clients appear here with IDs, handshakes, text counts, streams, drops, and live download metrics.",
|
||||
"streams": "Streams",
|
||||
"text": "Text",
|
||||
"handshakes": "Handshakes",
|
||||
"downloadMbps": "Download Mbps",
|
||||
"ping": "Ping",
|
||||
"connectionDrops": "Connection drops",
|
||||
"connectionFlows": "Connection flows",
|
||||
"groupedByEdge": "Grouped by edge + message type",
|
||||
"flowsEmpty": "Once logs arrive, each edge will show grouped signaling or P2P message types with counts.",
|
||||
"pingMs": "Ping {{ms}} ms",
|
||||
"groupedMessagesOnEdge": "{{count}} grouped messages",
|
||||
"noGroupedMessagesOnEdge": "No grouped messages on this edge yet.",
|
||||
"moreCount": "+{{count}} more",
|
||||
"idPrefix": "ID {{id}}",
|
||||
"peerPrefix": "Peer {{identity}}",
|
||||
"streamsTooltip": "A = audio streams, V = video streams",
|
||||
"audioStreams": "Audio streams",
|
||||
"videoStreams": "Video streams",
|
||||
"textTooltip": "Up arrow = sent messages, down arrow = received messages",
|
||||
"sentMessages": "Sent messages",
|
||||
"receivedMessages": "Received messages",
|
||||
"handshakesTooltip": "Counts are shown as sent / received",
|
||||
"webrtcOffers": "WebRTC offers",
|
||||
"webrtcAnswers": "WebRTC answers",
|
||||
"iceCandidates": "ICE candidates",
|
||||
"downloadTooltip": "Down arrow = download rate. F = file, A = audio, V = video.",
|
||||
"downloadRate": "Download rate",
|
||||
"fileDownloadMbps": "File download Mbps",
|
||||
"audioDownloadMbps": "Audio download Mbps",
|
||||
"videoDownloadMbps": "Video download Mbps",
|
||||
"pingMsValue": "{{ms}} ms",
|
||||
"unavailable": "Unavailable"
|
||||
},
|
||||
"activity": {
|
||||
"speaking": "Speaking",
|
||||
"typing": "Typing",
|
||||
"streaming": "Streaming",
|
||||
"muted": "Muted",
|
||||
"active": "Active"
|
||||
},
|
||||
"edgeKind": {
|
||||
"membership": "Membership",
|
||||
"signaling": "Signaling",
|
||||
"peer": "Peer"
|
||||
},
|
||||
"age": {
|
||||
"justNow": "just now",
|
||||
"secondsAgo": "{{seconds}}s ago",
|
||||
"minutesAgo": "{{minutes}}m ago",
|
||||
"hoursAgo": "{{hours}}h ago"
|
||||
}
|
||||
},
|
||||
"accessControl": {
|
||||
"roles": {
|
||||
"everyone": "@everyone"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
toju-app/public/i18n/catalog/shell.json
Normal file
40
toju-app/public/i18n/catalog/shell.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"shell": {
|
||||
"titleBar": {
|
||||
"guest": "Guest",
|
||||
"noServer": "No Server",
|
||||
"noTextChannels": "No text channels",
|
||||
"voiceLounge": "Voice Lounge",
|
||||
"textChannelCount": "{{count}} text",
|
||||
"voiceChannelCount": "{{count}} voice",
|
||||
"reconnectingSignalServer": "Reconnecting to signal server...",
|
||||
"reconnecting": "Reconnecting...",
|
||||
"login": "Login",
|
||||
"serverPlugins": "Server plugins",
|
||||
"menu": "Menu",
|
||||
"creatingInviteLink": "Creating Invite Link...",
|
||||
"createInviteLink": "Create Invite Link",
|
||||
"leaveServer": "Leave Server",
|
||||
"pluginStore": "Plugin Store",
|
||||
"closeMenuOverlay": "Close menu overlay",
|
||||
"optionalPluginAvailable": "Optional server plugin available:",
|
||||
"morePlugins": "+{{count}} more",
|
||||
"dontShowAgain": "Don't show again",
|
||||
"requiredServerPlugins": "Required server plugins",
|
||||
"roomRequiresPluginUpdate": "{{roomName}} requires a plugin update",
|
||||
"requiredPluginsDescription": "An admin added required plugins for this server. Install them to keep using the server, or leave the server.",
|
||||
"installPlugins": "Install plugins",
|
||||
"creatingInvite": "Creating invite link...",
|
||||
"inviteCopied": "Invite link copied to clipboard.",
|
||||
"inviteCreateFailed": "Unable to create invite link.",
|
||||
"docsOpenFailed": "Unable to open documentation.",
|
||||
"pluginInstallFailed": "Unable to install server plugin",
|
||||
"copyInvitePrompt": "Copy this invite link"
|
||||
},
|
||||
"contextMenu": {
|
||||
"emoji": "Emoji",
|
||||
"addToEmojiLibrary": "Add to emoji library",
|
||||
"removeFromEmojiLibrary": "Remove from emoji library"
|
||||
}
|
||||
}
|
||||
}
|
||||
136
toju-app/public/i18n/catalog/theme.json
Normal file
136
toju-app/public/i18n/catalog/theme.json
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"theme": {
|
||||
"studio": {
|
||||
"badge": "Theme Studio",
|
||||
"pickElement": "Pick UI Element",
|
||||
"formatJson": "Format JSON",
|
||||
"openCss": "Open CSS",
|
||||
"copyLlmGuide": "Copy LLM Guide",
|
||||
"importFile": "Import File",
|
||||
"exportFile": "Export File",
|
||||
"applyCssTheme": "Apply CSS Theme",
|
||||
"applyDraft": "Apply Draft",
|
||||
"restoreDefault": "Restore Default",
|
||||
"workspace": "Workspace",
|
||||
"regions": "Regions",
|
||||
"draft": "Draft",
|
||||
"unsavedChanges": "Unsaved changes",
|
||||
"inSync": "In sync",
|
||||
"invalidDraft": "The draft is invalid. The last working theme is still active.",
|
||||
"workspaceAria": "Theme Studio workspace",
|
||||
"presetThemes": "Preset Themes",
|
||||
"builtInCount": "{{count}} built in",
|
||||
"defaultBadge": "Default",
|
||||
"savedThemes": "Saved Themes",
|
||||
"syncing": "Syncing",
|
||||
"savedCount": "{{count}} saved",
|
||||
"saveNew": "Save New",
|
||||
"saveSelected": "Save Selected",
|
||||
"use": "Use",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"refresh": "Refresh",
|
||||
"ready": "Ready",
|
||||
"invalid": "Invalid",
|
||||
"emptySavedThemes": "Save the current draft to create your first reusable Electron theme.",
|
||||
"explorer": "Explorer",
|
||||
"explorerShown": "{{count}} shown",
|
||||
"searchKeysPlaceholder": "Search theme keys",
|
||||
"mounted": "Mounted",
|
||||
"noKeysMatch": "No registered theme keys match this filter.",
|
||||
"openStylesJson": "Open styles in JSON",
|
||||
"openLayoutJson": "Open layout in JSON",
|
||||
"cssOnlyTheme": "CSS-Only Theme",
|
||||
"themeJson": "Theme JSON",
|
||||
"cssOnlyHint": "CSS here is applied over the built-in default JSON theme.",
|
||||
"lines": "{{count}} lines",
|
||||
"chars": "{{count}} chars",
|
||||
"errors": "{{count}} errors",
|
||||
"ideEditor": "IDE editor",
|
||||
"jsonTheme": "JSON Theme",
|
||||
"cssOnly": "CSS Only",
|
||||
"selection": "Selection",
|
||||
"pickLiveElement": "Pick live element",
|
||||
"mountedNow": "Mounted now",
|
||||
"editableAttributes": "Editable Attributes",
|
||||
"addFadeAnimation": "Add fade animation",
|
||||
"animationKeys": "Animation Keys",
|
||||
"noAnimationKeys": "No custom animation keys yet.",
|
||||
"layoutGrid": "Layout Grid",
|
||||
"resetContainer": "Reset Container",
|
||||
"workspaces": {
|
||||
"editor": {
|
||||
"label": "JSON Editor",
|
||||
"description": "Edit the raw theme document in a fixed-contrast code view."
|
||||
},
|
||||
"inspector": {
|
||||
"label": "Element Inspector",
|
||||
"description": "Browse themeable regions, supported overrides, and starter values."
|
||||
},
|
||||
"layout": {
|
||||
"label": "Layout Studio",
|
||||
"description": "Move shells around the grid without hunting through JSON."
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"layoutEditable": "Layout editable",
|
||||
"textOverride": "Text override",
|
||||
"safeExternalLink": "Safe external link",
|
||||
"iconSlot": "Icon slot"
|
||||
},
|
||||
"llmGuideCopied": "LLM guide copied.",
|
||||
"llmGuideManualCopy": "Manual copy opened.",
|
||||
"llmGuidePrompt": "Copy this LLM theme guide",
|
||||
"exported": "{{fileName}} exported.",
|
||||
"exportCancelled": "Theme export cancelled.",
|
||||
"fixJsonBeforeExport": "Fix JSON errors before exporting the theme."
|
||||
},
|
||||
"presets": {
|
||||
"toju-default-dark-11": {
|
||||
"name": "Toju Default Dark 11",
|
||||
"description": "Built-in dark glass theme for the full Toju app shell."
|
||||
},
|
||||
"toju-website-dark": {
|
||||
"name": "Toju Website Dark",
|
||||
"description": "Website-inspired dark app theme using the charcoal, green, and amber palette from the public Toju site."
|
||||
},
|
||||
"toju-default-dark": {
|
||||
"name": "Toju Default Dark",
|
||||
"description": "Built-in dark glass theme for the full Toju app shell."
|
||||
}
|
||||
},
|
||||
"editor": {
|
||||
"jsonAria": "Theme JSON editor",
|
||||
"cssAria": "Theme CSS editor",
|
||||
"cssSyntaxError": "CSS syntax error."
|
||||
},
|
||||
"status": {
|
||||
"fixJsonBeforeFormat": "Fix JSON errors before formatting the theme draft.",
|
||||
"draftFormatted": "Theme draft formatted.",
|
||||
"draftValidationErrors": "The current draft has validation errors. The previous working theme is still active.",
|
||||
"themeApplied": "Theme applied.",
|
||||
"cssApplied": "CSS applied over the JSON theme.",
|
||||
"couldNotLoad": "The {{source}} could not be loaded.",
|
||||
"resetByShortcut": "Theme reset to the default preset by shortcut.",
|
||||
"resetByButton": "Theme reset to the default preset.",
|
||||
"presetNotFound": "Built-in theme preset not found.",
|
||||
"presetCouldNotApply": "Built-in theme preset could not be applied.",
|
||||
"presetApplied": "{{name}} preset applied.",
|
||||
"fixJsonBeforeTools": "Fix JSON errors before using the structured theme tools.",
|
||||
"structuredChangeInvalid": "The structured change could not be validated.",
|
||||
"preparedElement": "Prepared {{key}} in the theme draft.",
|
||||
"preparedLayout": "Prepared {{key}} layout in the theme draft.",
|
||||
"elementUpdated": "{{key}} updated.",
|
||||
"animationUpdated": "Animation {{key}} updated.",
|
||||
"fixJsonBeforeCss": "Fix JSON errors before applying CSS over the theme draft.",
|
||||
"cssOnlyCouldNotApply": "The CSS-only theme could not be applied.",
|
||||
"importFailed": "Unable to import {{fileName}}."
|
||||
},
|
||||
"pickerOverlay": {
|
||||
"activeBadge": "Theme Picker Active",
|
||||
"instruction": "Click a highlighted area to inspect its theme key.",
|
||||
"hovering": "Hovering:",
|
||||
"hoverFallback": "Move over a themeable region"
|
||||
}
|
||||
}
|
||||
}
|
||||
87
toju-app/public/i18n/catalog/voice.json
Normal file
87
toju-app/public/i18n/catalog/voice.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"voice": {
|
||||
"controls": {
|
||||
"connectionError": "Connection error",
|
||||
"connectionErrorStatus": "Connection Error",
|
||||
"connected": "Connected",
|
||||
"retry": "Retry",
|
||||
"failedConnect": "Failed to connect voice session."
|
||||
},
|
||||
"floating": {
|
||||
"backToServer": "Back to {{server}}",
|
||||
"voiceFallback": "Voice",
|
||||
"toggleMute": "Toggle Mute",
|
||||
"toggleDeafen": "Toggle Deafen",
|
||||
"toggleScreenShare": "Toggle Screen Share",
|
||||
"disconnect": "Disconnect"
|
||||
},
|
||||
"workspace": {
|
||||
"streams": "Streams",
|
||||
"inVoice": "{{count}} in voice",
|
||||
"liveStreams": "{{count}} live streams",
|
||||
"liveStream": "{{count}} live stream",
|
||||
"localPreview": "Local preview",
|
||||
"focusedStream": "Focused stream",
|
||||
"yourCamera": "Your camera",
|
||||
"yourScreen": "Your screen",
|
||||
"focusedStreamFallback": "Focused stream",
|
||||
"unmuteStreamAudio": "Unmute stream audio",
|
||||
"muteStreamAudio": "Mute stream audio",
|
||||
"showAllStreams": "Show all streams",
|
||||
"allStreams": "All streams",
|
||||
"minimizeWorkspace": "Minimize stream workspace",
|
||||
"returnToChat": "Return to chat",
|
||||
"otherLiveStreams": "Other live streams",
|
||||
"noLiveStreams": "No live streams yet",
|
||||
"noLiveStreamsDescription": "Turn on your camera, click Screen Share below, or wait for someone in {{channel}} to go live.",
|
||||
"participantsReady": "{{count}} participants ready",
|
||||
"startScreenShare": "Start screen sharing",
|
||||
"expand": "Expand",
|
||||
"close": "Close",
|
||||
"waitingForStream": "Waiting for a live stream",
|
||||
"connectedTo": "Connected to {{server}}",
|
||||
"miniWindowHint": "{{count}} live streams · double-click to expand",
|
||||
"miniWindowHintSingle": "{{count}} live stream · double-click to expand",
|
||||
"voiceLounge": "Voice Lounge",
|
||||
"voiceServer": "Voice server",
|
||||
"voiceWorkspace": "Voice workspace"
|
||||
},
|
||||
"streamTile": {
|
||||
"cameraLive": "Camera live",
|
||||
"screenShareLive": "Screen share live",
|
||||
"focusAria": "Focus {{name}} {{badge}}",
|
||||
"openAria": "Open {{name}} {{badge}} in widescreen mode",
|
||||
"exitFullscreenTitle": "Double-click to exit fullscreen",
|
||||
"enterFullscreenTitle": "Double-click for fullscreen",
|
||||
"localCameraFullscreen": "Local camera preview in fullscreen",
|
||||
"localPreviewFullscreen": "Local preview in fullscreen",
|
||||
"cameraFullscreen": "Fullscreen camera view",
|
||||
"streamFullscreen": "Fullscreen stream view",
|
||||
"localCameraPreview": "Your camera preview never captures audio.",
|
||||
"localPreviewMuted": "Your preview stays muted locally to avoid audio feedback.",
|
||||
"streamVolume": "Stream volume",
|
||||
"screenShareVolume": "Screen share volume",
|
||||
"rotateLandscape": "Rotate to landscape",
|
||||
"exitFullscreen": "Exit fullscreen",
|
||||
"fullscreen": "Fullscreen",
|
||||
"noScreenAudio": "No screen audio",
|
||||
"viewingWidescreen": "Viewing in widescreen",
|
||||
"viewInWidescreen": "View in widescreen",
|
||||
"streamAudio": "Stream audio",
|
||||
"audioPercent": "{{volume}}% audio"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"signaling": {
|
||||
"connectTimeout": "Timed out connecting to signaling server",
|
||||
"connectionFailed": "Connection to signaling server failed",
|
||||
"disconnected": "Disconnected from signaling server",
|
||||
"healthCheckFailed": "Signaling server health check failed",
|
||||
"instanceChanged": "Signaling server instance changed; refreshing websocket",
|
||||
"recovered": "Signaling server recovered; refreshing websocket",
|
||||
"keepaliveTimeout": "Signaling keepalive acknowledgement timed out",
|
||||
"keepaliveSendFailed": "Failed to send signaling keepalive",
|
||||
"payloadSendFailed": "Failed to send signaling payload"
|
||||
}
|
||||
}
|
||||
}
|
||||
2344
toju-app/public/i18n/en.json
Normal file
2344
toju-app/public/i18n/en.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideTranslateService } from '@ngx-translate/core';
|
||||
import { provideStore } from '@ngrx/store';
|
||||
import { provideEffects } from '@ngrx/effects';
|
||||
import { provideStoreDevtools } from '@ngrx/store-devtools';
|
||||
@@ -24,6 +25,7 @@ import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects'
|
||||
import { RoomStateSyncEffects } from './store/rooms/room-state-sync.effects';
|
||||
import { RoomSettingsEffects } from './store/rooms/room-settings.effects';
|
||||
import { STORE_DEVTOOLS_MAX_AGE } from './core/constants';
|
||||
import { DEFAULT_APP_LOCALE } from './core/i18n';
|
||||
|
||||
/** Root application configuration providing routing, HTTP, NgRx store, and devtools. */
|
||||
export const appConfig: ApplicationConfig = {
|
||||
@@ -31,6 +33,10 @@ export const appConfig: ApplicationConfig = {
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
provideTranslateService({
|
||||
fallbackLang: DEFAULT_APP_LOCALE,
|
||||
lang: DEFAULT_APP_LOCALE
|
||||
}),
|
||||
provideStore({
|
||||
messages: messagesReducer,
|
||||
users: usersReducer,
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
@if (themeStudioFullscreenComponent()) {
|
||||
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()" />
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio...</div>
|
||||
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">{{ 'app.themeStudio.loading' | translate }}</div>
|
||||
}
|
||||
</div>
|
||||
} @else { @if (showDesktopUpdateNotice()) {
|
||||
@@ -45,7 +45,7 @@
|
||||
type="button"
|
||||
(click)="dismissDesktopUpdateNotice()"
|
||||
class="absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Dismiss update notice"
|
||||
[attr.aria-label]="'app.desktopUpdate.dismissAriaLabel' | translate"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
@@ -54,15 +54,15 @@
|
||||
</button>
|
||||
|
||||
<div class="pr-10">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">Update Ready</p>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">{{ 'app.desktopUpdate.readyBadge' | translate }}</p>
|
||||
<p class="mt-1 text-sm font-semibold text-foreground">
|
||||
Restart to install {{ desktopUpdateState().targetVersion || 'the latest update' }}
|
||||
@if (desktopUpdateState().targetVersion) { {{ 'app.desktopUpdate.restartTitle' | translate:{ version:
|
||||
desktopUpdateState().targetVersion } }} } @else { {{ 'app.desktopUpdate.restartTitle' | translate:{ version:
|
||||
('app.desktopUpdate.latestUpdateFallback' | translate) } }} }
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="mt-1 pr-10 text-xs leading-5 text-muted-foreground">
|
||||
The update has already been downloaded. Restart the app when you're ready to finish applying it.
|
||||
</p>
|
||||
<p class="mt-1 pr-10 text-xs leading-5 text-muted-foreground">{{ 'app.desktopUpdate.downloadedMessage' | translate }}</p>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -70,7 +70,7 @@
|
||||
(click)="openUpdatesSettings()"
|
||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Update settings
|
||||
{{ 'app.desktopUpdate.updateSettings' | translate }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -78,7 +78,7 @@
|
||||
(click)="restartToApplyUpdate()"
|
||||
class="inline-flex items-center rounded-lg bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Restart now
|
||||
{{ 'app.desktopUpdate.restartNow' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@
|
||||
[class.cursor-grabbing]="isDraggingThemeStudioControls()"
|
||||
(pointerdown)="startThemeStudioControlsDrag($event, themeStudioControlsRef)"
|
||||
>
|
||||
Theme Studio
|
||||
{{ 'app.themeStudio.title' | translate }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -122,7 +122,7 @@
|
||||
(click)="minimizeThemeStudio()"
|
||||
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Minimize
|
||||
{{ 'app.themeStudio.minimize' | translate }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -130,7 +130,7 @@
|
||||
(click)="closeThemeStudio()"
|
||||
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Close
|
||||
{{ 'common.close' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,8 +138,8 @@
|
||||
<div class="pointer-events-none absolute bottom-4 right-4 z-[80]">
|
||||
<div class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-3 shadow-lg backdrop-blur">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">Theme Studio</p>
|
||||
<p class="mt-1 text-sm font-medium text-foreground">Minimized</p>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">{{ 'app.themeStudio.title' | translate }}</p>
|
||||
<p class="mt-1 text-sm font-medium text-foreground">{{ 'app.themeStudio.minimized' | translate }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -147,7 +147,7 @@
|
||||
(click)="reopenThemeStudio()"
|
||||
class="rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Re-open
|
||||
{{ 'app.themeStudio.reopen' | translate }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -155,7 +155,7 @@
|
||||
(click)="dismissMinimizedThemeStudio()"
|
||||
class="rounded-md border border-border bg-secondary px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Dismiss
|
||||
{{ 'common.dismiss' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
ThemePickerOverlayComponent,
|
||||
ThemeService
|
||||
} from './domains/theme';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from './core/i18n';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -81,7 +82,8 @@ import {
|
||||
NativeContextMenuComponent,
|
||||
PrivateCallComponent,
|
||||
ThemeNodeDirective,
|
||||
ThemePickerOverlayComponent
|
||||
ThemePickerOverlayComponent,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -189,6 +191,7 @@ export class App implements OnInit, OnDestroy {
|
||||
};
|
||||
});
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private readonly mobilePersistence = inject(MobilePersistenceService);
|
||||
private readonly mobileLifecycle = inject(MobileAppLifecycleService);
|
||||
private readonly mobileUpdates = inject(MobileAppUpdateService);
|
||||
@@ -198,6 +201,8 @@ export class App implements OnInit, OnDestroy {
|
||||
private themeStudioControlsBounds: { width: number; height: number } | null = null;
|
||||
|
||||
constructor() {
|
||||
this.appI18n.initialize();
|
||||
|
||||
effect(() => {
|
||||
if (!this.isThemeStudioFullscreen() || this.themeStudioFullscreenComponent()) {
|
||||
return;
|
||||
|
||||
72
toju-app/src/app/core/i18n/app-i18n-catalog.rules.spec.ts
Normal file
72
toju-app/src/app/core/i18n/app-i18n-catalog.rules.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
statSync
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
extractTranslationKeysFromSource,
|
||||
findMissingCatalogKeys,
|
||||
flattenCatalogKeys
|
||||
} from './app-i18n-catalog.rules';
|
||||
|
||||
const APP_ROOT = join(import.meta.dirname, '../../..');
|
||||
const EN_CATALOG_PATH = join(APP_ROOT, '../public/i18n/en.json');
|
||||
|
||||
function listSourceFiles(directory: string): string[] {
|
||||
const entries = readdirSync(directory);
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(directory, entry);
|
||||
const stats = statSync(fullPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
files.push(...listSourceFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fullPath.endsWith('.html') || (fullPath.endsWith('.ts') && !fullPath.endsWith('.spec.ts'))) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
describe('app-i18n-catalog.rules', () => {
|
||||
it('defines every translation key referenced in app templates and instant() calls', () => {
|
||||
const catalog = JSON.parse(readFileSync(EN_CATALOG_PATH, 'utf8')) as Record<string, unknown>;
|
||||
const definedKeys = flattenCatalogKeys(catalog);
|
||||
const usedKeys = new Set<string>();
|
||||
|
||||
for (const filePath of listSourceFiles(join(APP_ROOT, 'app'))) {
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
|
||||
for (const key of extractTranslationKeysFromSource(source)) {
|
||||
usedKeys.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
const missingKeys = findMissingCatalogKeys(definedKeys, usedKeys);
|
||||
|
||||
expect(missingKeys, `Missing i18n keys: ${missingKeys.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('nests extracted theme registry labels under theme.registry', () => {
|
||||
const catalog = JSON.parse(readFileSync(EN_CATALOG_PATH, 'utf8')) as Record<string, unknown>;
|
||||
const theme = catalog['theme'] as Record<string, unknown> | undefined;
|
||||
const registry = theme?.['registry'] as Record<string, { label?: string }> | undefined;
|
||||
|
||||
expect(theme?.['registry']).toBeDefined();
|
||||
expect(catalog['theme.registry']).toBeUndefined();
|
||||
expect(registry?.['appRoot']?.label).toBe('App Root');
|
||||
expect(Object.keys(registry ?? {}).length).toBeGreaterThanOrEqual(60);
|
||||
});
|
||||
});
|
||||
47
toju-app/src/app/core/i18n/app-i18n-catalog.rules.ts
Normal file
47
toju-app/src/app/core/i18n/app-i18n-catalog.rules.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
const TRANSLATE_PIPE_KEY_PATTERN = /['"]([a-z][a-zA-Z0-9_.]*)['"]\s*\|\s*translate/g;
|
||||
const INSTANT_KEY_PATTERN = /\.instant\(\s*['"]([a-z][a-zA-Z0-9_.]*)['"]/g;
|
||||
|
||||
export function flattenCatalogKeys(
|
||||
catalog: Record<string, unknown>,
|
||||
prefix = ''
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
|
||||
for (const [key, value] of Object.entries(catalog)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
for (const nestedKey of flattenCatalogKeys(value as Record<string, unknown>, path)) {
|
||||
keys.add(nestedKey);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
keys.add(path);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function extractTranslationKeysFromSource(source: string): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
|
||||
for (const pattern of [TRANSLATE_PIPE_KEY_PATTERN, INSTANT_KEY_PATTERN]) {
|
||||
pattern.lastIndex = 0;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function findMissingCatalogKeys(definedKeys: Set<string>, usedKeys: Set<string>): string[] {
|
||||
return [...usedKeys]
|
||||
.filter((key) => !definedKeys.has(key))
|
||||
.sort();
|
||||
}
|
||||
29
toju-app/src/app/core/i18n/app-i18n.rules.spec.ts
Normal file
29
toju-app/src/app/core/i18n/app-i18n.rules.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_APP_LOCALE,
|
||||
SUPPORTED_APP_LOCALES,
|
||||
resolveAppLocale
|
||||
} from './app-i18n.rules';
|
||||
|
||||
describe('app-i18n.rules', () => {
|
||||
it('ships only English as the default locale', () => {
|
||||
expect(DEFAULT_APP_LOCALE).toBe('en');
|
||||
expect(SUPPORTED_APP_LOCALES).toEqual(['en']);
|
||||
});
|
||||
|
||||
it('resolves unknown locale candidates to the default locale', () => {
|
||||
expect(resolveAppLocale(null)).toBe('en');
|
||||
expect(resolveAppLocale(undefined)).toBe('en');
|
||||
expect(resolveAppLocale('')).toBe('en');
|
||||
expect(resolveAppLocale('fr')).toBe('en');
|
||||
});
|
||||
|
||||
it('accepts supported locale candidates', () => {
|
||||
expect(resolveAppLocale('en')).toBe('en');
|
||||
});
|
||||
});
|
||||
13
toju-app/src/app/core/i18n/app-i18n.rules.ts
Normal file
13
toju-app/src/app/core/i18n/app-i18n.rules.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const DEFAULT_APP_LOCALE = 'en' as const;
|
||||
|
||||
export const SUPPORTED_APP_LOCALES = [DEFAULT_APP_LOCALE] as const;
|
||||
|
||||
export type AppLocale = (typeof SUPPORTED_APP_LOCALES)[number];
|
||||
|
||||
export function resolveAppLocale(candidate: string | null | undefined): AppLocale {
|
||||
if (candidate && SUPPORTED_APP_LOCALES.includes(candidate as AppLocale)) {
|
||||
return candidate as AppLocale;
|
||||
}
|
||||
|
||||
return DEFAULT_APP_LOCALE;
|
||||
}
|
||||
40
toju-app/src/app/core/i18n/app-i18n.service.spec.ts
Normal file
40
toju-app/src/app/core/i18n/app-i18n.service.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createEnvironmentInjector } from '@angular/core';
|
||||
import { TranslateService, provideTranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { AppI18nService } from './app-i18n.service';
|
||||
|
||||
describe('AppI18nService', () => {
|
||||
let injector: ReturnType<typeof createEnvironmentInjector>;
|
||||
let service: AppI18nService;
|
||||
let translate: TranslateService;
|
||||
|
||||
beforeEach(() => {
|
||||
injector = createEnvironmentInjector([
|
||||
provideTranslateService({
|
||||
fallbackLang: 'en',
|
||||
lang: 'en'
|
||||
}),
|
||||
AppI18nService
|
||||
]);
|
||||
|
||||
service = injector.get(AppI18nService);
|
||||
translate = injector.get(TranslateService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
injector.destroy();
|
||||
});
|
||||
|
||||
it('loads bundled English translations on initialize', () => {
|
||||
service.initialize();
|
||||
|
||||
expect(translate.getCurrentLang()).toBe('en');
|
||||
expect(translate.instant('common.brand')).toBe('Toju');
|
||||
});
|
||||
|
||||
it('falls back to English for unsupported locale requests', () => {
|
||||
service.initialize('fr');
|
||||
|
||||
expect(translate.getCurrentLang()).toBe('en');
|
||||
});
|
||||
});
|
||||
22
toju-app/src/app/core/i18n/app-i18n.service.ts
Normal file
22
toju-app/src/app/core/i18n/app-i18n.service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import translationsEn from '../../../../public/i18n/en.json';
|
||||
import { DEFAULT_APP_LOCALE, resolveAppLocale } from './app-i18n.rules';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppI18nService {
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
initialize(locale?: string | null): void {
|
||||
const resolvedLocale = resolveAppLocale(locale);
|
||||
|
||||
this.translate.setTranslation(DEFAULT_APP_LOCALE, translationsEn);
|
||||
this.translate.setFallbackLang(DEFAULT_APP_LOCALE);
|
||||
this.translate.use(resolvedLocale);
|
||||
}
|
||||
|
||||
instant(key: string, params?: Record<string, unknown>): string {
|
||||
return this.translate.instant(key, params);
|
||||
}
|
||||
}
|
||||
19
toju-app/src/app/core/i18n/app-i18n.testing.ts
Normal file
19
toju-app/src/app/core/i18n/app-i18n.testing.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injector, Provider } from '@angular/core';
|
||||
import { provideTranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { AppI18nService } from './app-i18n.service';
|
||||
|
||||
/** Vitest/Injector harness providers for components and services that inject AppI18nService. */
|
||||
export function provideAppI18nForTests(): Provider[] {
|
||||
return [
|
||||
provideTranslateService({
|
||||
fallbackLang: 'en',
|
||||
lang: 'en'
|
||||
}),
|
||||
AppI18nService
|
||||
];
|
||||
}
|
||||
|
||||
export function initializeAppI18nForTests(injector: Injector): void {
|
||||
injector.get(AppI18nService).initialize();
|
||||
}
|
||||
4
toju-app/src/app/core/i18n/app-translate.imports.ts
Normal file
4
toju-app/src/app/core/i18n/app-translate.imports.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
/** Standalone component imports for templates using the `translate` pipe. */
|
||||
export const APP_TRANSLATE_IMPORTS = [TranslateModule] as const;
|
||||
5
toju-app/src/app/core/i18n/index.ts
Normal file
5
toju-app/src/app/core/i18n/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './app-i18n.rules';
|
||||
export * from './app-i18n-catalog.rules';
|
||||
export * from './app-i18n.service';
|
||||
export * from './app-translate.imports';
|
||||
export * from './app-i18n.testing';
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
RoomRoleAssignment
|
||||
} from '../../../../shared-kernel';
|
||||
import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
|
||||
import { localizeSystemRoleDisplayName } from './role-display.rules';
|
||||
import type { MemberIdentity } from '../models/access-control.model';
|
||||
import {
|
||||
buildRoleLookup,
|
||||
@@ -106,13 +107,19 @@ export function getAssignedRoleIds(assignments: readonly RoomRoleAssignment[] |
|
||||
return uniqueStrings(assignment?.roleIds ?? []);
|
||||
}
|
||||
|
||||
export function getDisplayRoleName(room: Room, member: MemberIdentity | null | undefined): string {
|
||||
export function getDisplayRoleName(
|
||||
room: Room,
|
||||
member: MemberIdentity | null | undefined,
|
||||
translate?: (key: string) => string
|
||||
): string {
|
||||
const translateOr = (key: string, fallback: string) => translate?.(key) ?? fallback;
|
||||
|
||||
if (!member) {
|
||||
return 'Member';
|
||||
return translateOr('shared.leaveServer.roles.member', 'Member');
|
||||
}
|
||||
|
||||
if (room.hostId === member.id || room.hostId === member.oderId) {
|
||||
return 'Owner';
|
||||
return translateOr('shared.leaveServer.roles.owner', 'Owner');
|
||||
}
|
||||
|
||||
const roles = normalizeRoomRoles(room.roles, room.permissions);
|
||||
@@ -121,8 +128,13 @@ export function getDisplayRoleName(room: Room, member: MemberIdentity | null | u
|
||||
.map((roleId) => roleLookup.get(roleId))
|
||||
.filter((role): role is RoomRole => !!role)
|
||||
.sort(roleSortDescending);
|
||||
const roleName = assignedRoles[0]?.name;
|
||||
|
||||
return assignedRoles[0]?.name || '@everyone';
|
||||
if (!roleName) {
|
||||
return translateOr('shared.accessControl.roles.everyone', '@everyone');
|
||||
}
|
||||
|
||||
return translate ? localizeSystemRoleDisplayName(roleName, translate) : roleName;
|
||||
}
|
||||
|
||||
export function getAssignedRoles(room: Room, identity: MemberIdentity | null | undefined): RoomRole[] {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { localizeSystemRoleDisplayName, resolveSystemRoleDisplayI18nKey } from './role-display.rules';
|
||||
|
||||
describe('role-display.rules', () => {
|
||||
describe('resolveSystemRoleDisplayI18nKey', () => {
|
||||
it('maps built-in role display names to i18n keys', () => {
|
||||
expect(resolveSystemRoleDisplayI18nKey('Member')).toBe('shared.leaveServer.roles.member');
|
||||
expect(resolveSystemRoleDisplayI18nKey('Owner')).toBe('shared.leaveServer.roles.owner');
|
||||
expect(resolveSystemRoleDisplayI18nKey('@everyone')).toBe('shared.accessControl.roles.everyone');
|
||||
expect(resolveSystemRoleDisplayI18nKey('Moderator')).toBe('shared.leaveServer.roles.moderator');
|
||||
expect(resolveSystemRoleDisplayI18nKey('Admin')).toBe('shared.leaveServer.roles.admin');
|
||||
});
|
||||
|
||||
it('returns null for custom role names', () => {
|
||||
expect(resolveSystemRoleDisplayI18nKey('Custom Role')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('localizeSystemRoleDisplayName', () => {
|
||||
it('translates known system role names', () => {
|
||||
const translate = (key: string) => `translated:${key}`;
|
||||
|
||||
expect(localizeSystemRoleDisplayName('Admin', translate)).toBe('translated:shared.leaveServer.roles.admin');
|
||||
});
|
||||
|
||||
it('returns custom role names unchanged', () => {
|
||||
const translate = (key: string) => `translated:${key}`;
|
||||
|
||||
expect(localizeSystemRoleDisplayName('Event Host', translate)).toBe('Event Host');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
const SYSTEM_ROLE_DISPLAY_I18N_KEYS: Readonly<Record<string, string>> = {
|
||||
Member: 'shared.leaveServer.roles.member',
|
||||
Owner: 'shared.leaveServer.roles.owner',
|
||||
'@everyone': 'shared.accessControl.roles.everyone',
|
||||
Moderator: 'shared.leaveServer.roles.moderator',
|
||||
Admin: 'shared.leaveServer.roles.admin'
|
||||
};
|
||||
|
||||
export function resolveSystemRoleDisplayI18nKey(displayName: string): string | null {
|
||||
return SYSTEM_ROLE_DISPLAY_I18N_KEYS[displayName] ?? null;
|
||||
}
|
||||
|
||||
export function localizeSystemRoleDisplayName(
|
||||
displayName: string,
|
||||
translate: (key: string) => string
|
||||
): string {
|
||||
const key = resolveSystemRoleDisplayI18nKey(displayName);
|
||||
|
||||
return key ? translate(key) : displayName;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from './domain/models/access-control.model';
|
||||
export * from './domain/constants/access-control.constants';
|
||||
export * from './domain/rules/role.rules';
|
||||
export * from './domain/rules/role-assignment.rules';
|
||||
export * from './domain/rules/role-display.rules';
|
||||
export * from './domain/rules/permission.rules';
|
||||
export * from './domain/rules/room.rules';
|
||||
export * from './domain/rules/ban.rules';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { take } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { recordDebugNetworkFileChunk } from '../../../../infrastructure/realtime/logging/debug-network-metrics';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { AppI18nService } from '../../../../core/i18n';
|
||||
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
|
||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
|
||||
@@ -13,9 +14,14 @@ import {
|
||||
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
|
||||
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
|
||||
DEFAULT_ATTACHMENT_MIME_TYPE,
|
||||
FILE_NOT_FOUND_REQUEST_ERROR,
|
||||
NO_CONNECTED_PEERS_REQUEST_ERROR,
|
||||
UPLOADER_LOCAL_FILE_MISSING_ERROR
|
||||
ATTACHMENT_DOWNLOAD_FAILED_KEY,
|
||||
ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY,
|
||||
ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY,
|
||||
ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY,
|
||||
ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY,
|
||||
FILE_NOT_FOUND_REQUEST_ERROR_KEY,
|
||||
NO_CONNECTED_PEERS_REQUEST_ERROR_KEY,
|
||||
UPLOADER_LOCAL_FILE_MISSING_ERROR_KEY
|
||||
} from '../../domain/constants/attachment-transfer.constants';
|
||||
import {
|
||||
type FileAnnounceEvent,
|
||||
@@ -53,6 +59,7 @@ interface ValidFileChunkPayload {
|
||||
export class AttachmentTransferService {
|
||||
private readonly ngrxStore = inject(Store);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
private readonly persistence = inject(AttachmentPersistenceService);
|
||||
@@ -147,8 +154,8 @@ export class AttachmentTransferService {
|
||||
|
||||
if (connectedPeers.length === 0) {
|
||||
attachment.requestError = isUploader
|
||||
? UPLOADER_LOCAL_FILE_MISSING_ERROR
|
||||
: NO_CONNECTED_PEERS_REQUEST_ERROR;
|
||||
? this.appI18n.instant(UPLOADER_LOCAL_FILE_MISSING_ERROR_KEY)
|
||||
: this.appI18n.instant(NO_CONNECTED_PEERS_REQUEST_ERROR_KEY);
|
||||
|
||||
this.runtimeStore.touch();
|
||||
console.warn('[Attachments] No connected peers to request file from');
|
||||
@@ -177,7 +184,7 @@ export class AttachmentTransferService {
|
||||
const didSendRequest = this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
|
||||
|
||||
if (!didSendRequest && attachment) {
|
||||
attachment.requestError = FILE_NOT_FOUND_REQUEST_ERROR;
|
||||
attachment.requestError = this.appI18n.instant(FILE_NOT_FOUND_REQUEST_ERROR_KEY);
|
||||
this.runtimeStore.touch();
|
||||
}
|
||||
}
|
||||
@@ -716,7 +723,7 @@ export class AttachmentTransferService {
|
||||
const assembly = await this.getOrCreateDiskReceiveAssembly(attachment, assemblyKey, payload.total);
|
||||
|
||||
if (!assembly) {
|
||||
throw new Error('Could not prepare media download on disk.');
|
||||
throw new Error(this.appI18n.instant(ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY));
|
||||
}
|
||||
|
||||
if (assembly.receivedIndexes.has(payload.index)) {
|
||||
@@ -724,13 +731,13 @@ export class AttachmentTransferService {
|
||||
}
|
||||
|
||||
if (payload.index !== assembly.receivedCount) {
|
||||
throw new Error('Received media chunks out of order. Retry the download.');
|
||||
throw new Error(this.appI18n.instant(ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY));
|
||||
}
|
||||
|
||||
const didAppend = await this.attachmentStorage.appendBase64(assembly.path, payload.data);
|
||||
|
||||
if (!didAppend) {
|
||||
throw new Error('Could not write media download to disk.');
|
||||
throw new Error(this.appI18n.instant(ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY));
|
||||
}
|
||||
|
||||
assembly.receivedIndexes.add(payload.index);
|
||||
@@ -747,7 +754,7 @@ export class AttachmentTransferService {
|
||||
const restoredForDisplay = await this.persistence.ensureInlineDisplayObjectUrl(attachment);
|
||||
|
||||
if (!restoredForDisplay) {
|
||||
throw new Error('Could not open completed media download from disk.');
|
||||
throw new Error(this.appI18n.instant(ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY));
|
||||
}
|
||||
|
||||
attachment.available = true;
|
||||
@@ -801,7 +808,7 @@ export class AttachmentTransferService {
|
||||
attachment.lastUpdateMs = undefined;
|
||||
attachment.requestError = error instanceof Error && error.message
|
||||
? error.message
|
||||
: 'Media download failed. Retry the download.';
|
||||
: this.appI18n.instant(ATTACHMENT_DOWNLOAD_FAILED_KEY);
|
||||
|
||||
this.runtimeStore.touch();
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@ export const DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream';
|
||||
/** localStorage key used by the legacy attachment store during migration. */
|
||||
export const LEGACY_ATTACHMENTS_STORAGE_KEY = 'metoyou_attachments';
|
||||
|
||||
/** User-facing error when no peers are available for a request. */
|
||||
export const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are available to provide this file right now.';
|
||||
|
||||
/** User-facing error when connected peers cannot provide a requested file. */
|
||||
export const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.';
|
||||
|
||||
/** User-facing error when the uploader's local copy cannot be restored. */
|
||||
export const UPLOADER_LOCAL_FILE_MISSING_ERROR =
|
||||
'Your original upload could not be found on this device. Re-upload the file to restore playback.';
|
||||
/** i18n keys for user-facing attachment transfer errors. */
|
||||
export const NO_CONNECTED_PEERS_REQUEST_ERROR_KEY = 'attachment.errors.noConnectedPeers';
|
||||
export const FILE_NOT_FOUND_REQUEST_ERROR_KEY = 'attachment.errors.fileNotFound';
|
||||
export const UPLOADER_LOCAL_FILE_MISSING_ERROR_KEY = 'attachment.errors.uploaderLocalMissing';
|
||||
export const ATTACHMENT_PREPARE_DOWNLOAD_FAILED_KEY = 'attachment.errors.prepareDownloadFailed';
|
||||
export const ATTACHMENT_CHUNKS_OUT_OF_ORDER_KEY = 'attachment.errors.chunksOutOfOrder';
|
||||
export const ATTACHMENT_WRITE_DOWNLOAD_FAILED_KEY = 'attachment.errors.writeDownloadFailed';
|
||||
export const ATTACHMENT_OPEN_DOWNLOAD_FAILED_KEY = 'attachment.errors.openDownloadFailed';
|
||||
export const ATTACHMENT_DOWNLOAD_FAILED_KEY = 'attachment.errors.downloadFailed';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
name="lucideLogIn"
|
||||
class="w-5 h-5 text-primary"
|
||||
/>
|
||||
<h1 class="text-lg font-semibold text-foreground">Login</h1>
|
||||
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.login.title' | translate }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
@@ -13,7 +13,7 @@
|
||||
<label
|
||||
for="login-username"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Username</label
|
||||
>{{ 'auth.login.username' | translate }}</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="username"
|
||||
@@ -26,7 +26,7 @@
|
||||
<label
|
||||
for="login-password"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Password</label
|
||||
>{{ 'auth.login.password' | translate }}</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="password"
|
||||
@@ -39,7 +39,7 @@
|
||||
<label
|
||||
for="login-server"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Server App</label
|
||||
>{{ 'auth.login.serverApp' | translate }}</label
|
||||
>
|
||||
<select
|
||||
[(ngModel)]="serverId"
|
||||
@@ -59,16 +59,16 @@
|
||||
type="button"
|
||||
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Login
|
||||
{{ 'auth.login.submit' | translate }}
|
||||
</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
No account?
|
||||
{{ 'auth.login.noAccount' | translate }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="goRegister()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
Register
|
||||
{{ 'auth.login.registerLink' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -25,7 +26,8 @@ import { User } from '../../../../shared-kernel';
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
NgIcon,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideLogIn })],
|
||||
templateUrl: './login.component.html'
|
||||
@@ -42,6 +44,7 @@ export class LoginComponent {
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private auth = inject(AuthenticationService);
|
||||
private actions$ = inject(Actions);
|
||||
private store = inject(Store);
|
||||
@@ -95,7 +98,7 @@ export class LoginComponent {
|
||||
await this.router.navigate(['/dashboard']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Login failed');
|
||||
this.error.set(err?.error?.error || this.appI18n.instant('auth.login.failed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
name="lucideUserPlus"
|
||||
class="w-5 h-5 text-primary"
|
||||
/>
|
||||
<h1 class="text-lg font-semibold text-foreground">Register</h1>
|
||||
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.register.title' | translate }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
@@ -13,7 +13,7 @@
|
||||
<label
|
||||
for="register-username"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Username</label
|
||||
>{{ 'auth.register.username' | translate }}</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="username"
|
||||
@@ -26,7 +26,7 @@
|
||||
<label
|
||||
for="register-display-name"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Display Name</label
|
||||
>{{ 'auth.register.displayName' | translate }}</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="displayName"
|
||||
@@ -39,7 +39,7 @@
|
||||
<label
|
||||
for="register-password"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Password</label
|
||||
>{{ 'auth.register.password' | translate }}</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="password"
|
||||
@@ -52,7 +52,7 @@
|
||||
<label
|
||||
for="register-server"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Server App</label
|
||||
>{{ 'auth.register.serverApp' | translate }}</label
|
||||
>
|
||||
<select
|
||||
[(ngModel)]="serverId"
|
||||
@@ -72,16 +72,16 @@
|
||||
type="button"
|
||||
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Create Account
|
||||
{{ 'auth.register.submit' | translate }}
|
||||
</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
Have an account?
|
||||
{{ 'auth.register.haveAccount' | translate }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="goLogin()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
Login
|
||||
{{ 'auth.register.loginLink' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
@@ -25,7 +26,8 @@ import { User } from '../../../../shared-kernel';
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
NgIcon,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||
templateUrl: './register.component.html'
|
||||
@@ -43,6 +45,7 @@ export class RegisterComponent {
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private auth = inject(AuthenticationService);
|
||||
private actions$ = inject(Actions);
|
||||
private store = inject(Store);
|
||||
@@ -97,7 +100,7 @@ export class RegisterComponent {
|
||||
await this.router.navigate(['/dashboard']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Registration failed');
|
||||
this.error.set(err?.error?.error || this.appI18n.instant('auth.register.failed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
name="lucideLogIn"
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
Login
|
||||
{{ 'auth.userBar.login' | translate }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -38,7 +38,7 @@
|
||||
name="lucideUserPlus"
|
||||
class="w-3 h-3"
|
||||
/>
|
||||
Register
|
||||
{{ 'auth.userBar.register' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ProfileCardService } from '../../../../shared/components/profile-card/profile-card.service';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-bar',
|
||||
@@ -14,7 +15,8 @@ import { UserAvatarComponent } from '../../../../shared';
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent
|
||||
UserAvatarComponent,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
|
||||
@@ -150,15 +150,15 @@ The composer renders a Discord-style autocomplete menu when the user types `/`.
|
||||
|
||||
## Domain rules
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `canEditMessage(msg, userId)` | Only the sender can edit their own message |
|
||||
| `normaliseDeletedMessage(msg)` | Strips content and reactions from deleted messages |
|
||||
| `getMessageTimestamp(msg)` | Returns `editedAt` if present, otherwise `timestamp` |
|
||||
| `getLatestTimestamp(msgs)` | Max timestamp across a batch, used for sync ordering |
|
||||
| `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer |
|
||||
| `findMissingIds(remote, local)` | Compares inventories and returns IDs to request |
|
||||
| `resolveAutoScrollBehavior(input)` | Decides `instant` / `smooth` / `none` when the message count changes |
|
||||
| Function | Purpose |
|
||||
| --------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `canEditMessage(msg, userId)` | Only the sender can edit their own message |
|
||||
| `normaliseDeletedMessage(msg)` | Strips content and reactions from deleted messages |
|
||||
| `getMessageTimestamp(msg)` | Returns `editedAt` if present, otherwise `timestamp` |
|
||||
| `getLatestTimestamp(msgs)` | Max timestamp across a batch, used for sync ordering |
|
||||
| `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer |
|
||||
| `findMissingIds(remote, local)` | Compares inventories and returns IDs to request |
|
||||
| `resolveAutoScrollBehavior(input)` | Decides `instant` / `smooth` / `none` when the message count changes |
|
||||
| `isStuckToBottom(distance, threshold?)` | True while the list is close enough to the bottom to keep auto-pinning (default 300px) |
|
||||
|
||||
## Auto-scroll
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
throwError
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
ServerDirectoryFacade,
|
||||
type RoomSignalSourceInput,
|
||||
@@ -48,6 +49,7 @@ interface KlipyAvailabilityState {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class KlipyService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly appI18n = inject(AppI18nService);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly availabilityByKey = signal<Record<string, KlipyAvailabilityState>>({});
|
||||
|
||||
@@ -63,32 +65,21 @@ export class KlipyService {
|
||||
const selector = this.getSourceSelector(source);
|
||||
const key = this.getAvailabilityKey(selector);
|
||||
|
||||
this.setAvailabilityState(key, { enabled: false,
|
||||
loading: true });
|
||||
this.setAvailabilityState(key, { enabled: false, loading: true });
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.http.get<KlipyAvailabilityResponse>(
|
||||
`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/config`
|
||||
)
|
||||
);
|
||||
const response = await firstValueFrom(this.http.get<KlipyAvailabilityResponse>(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/config`));
|
||||
|
||||
this.setAvailabilityState(key, {
|
||||
enabled: response.enabled === true,
|
||||
loading: false
|
||||
});
|
||||
} catch {
|
||||
this.setAvailabilityState(key, { enabled: false,
|
||||
loading: false });
|
||||
this.setAvailabilityState(key, { enabled: false, loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
searchGifs(
|
||||
query: string,
|
||||
page = 1,
|
||||
perPage = DEFAULT_PAGE_SIZE,
|
||||
source?: RoomSignalSourceInput | null
|
||||
): Observable<KlipyGifSearchResponse> {
|
||||
searchGifs(query: string, page = 1, perPage = DEFAULT_PAGE_SIZE, source?: RoomSignalSourceInput | null): Observable<KlipyGifSearchResponse> {
|
||||
const selector = this.getSourceSelector(source);
|
||||
|
||||
let params = new HttpParams()
|
||||
@@ -108,18 +99,14 @@ export class KlipyService {
|
||||
params = params.set('locale', locale);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/gifs`, { params })
|
||||
.pipe(
|
||||
map((response) => ({
|
||||
enabled: response.enabled !== false,
|
||||
results: Array.isArray(response.results) ? response.results : [],
|
||||
hasNext: response.hasNext === true
|
||||
})),
|
||||
catchError((error) =>
|
||||
throwError(() => new Error(this.extractErrorMessage(error)))
|
||||
)
|
||||
);
|
||||
return this.http.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/gifs`, { params }).pipe(
|
||||
map((response) => ({
|
||||
enabled: response.enabled !== false,
|
||||
results: Array.isArray(response.results) ? response.results : [],
|
||||
hasNext: response.hasNext === true
|
||||
})),
|
||||
catchError((error) => throwError(() => new Error(this.extractErrorMessage(error))))
|
||||
);
|
||||
}
|
||||
|
||||
normalizeMediaUrl(url: string): string {
|
||||
@@ -151,9 +138,7 @@ export class KlipyService {
|
||||
}
|
||||
|
||||
private getAvailabilityState(source?: RoomSignalSourceInput | null): KlipyAvailabilityState {
|
||||
return this.availabilityByKey()[this.getAvailabilityKey(this.getSourceSelector(source))]
|
||||
?? { enabled: false,
|
||||
loading: true };
|
||||
return this.availabilityByKey()[this.getAvailabilityKey(this.getSourceSelector(source))] ?? { enabled: false, loading: true };
|
||||
}
|
||||
|
||||
private setAvailabilityState(key: string, state: KlipyAvailabilityState): void {
|
||||
@@ -199,9 +184,8 @@ export class KlipyService {
|
||||
if (existing?.trim())
|
||||
return existing;
|
||||
|
||||
const created = window.crypto?.randomUUID?.()
|
||||
?? `klipy-${Date.now().toString(36)}-${Math.random().toString(36)
|
||||
.slice(2, 10)}`;
|
||||
const created = window.crypto?.randomUUID?.() ?? `klipy-${Date.now().toString(36)}-${Math.random().toString(36)
|
||||
.slice(2, 10)}`;
|
||||
|
||||
window.localStorage.setItem(KLIPY_CUSTOMER_ID_STORAGE_KEY, created);
|
||||
return created;
|
||||
@@ -228,6 +212,6 @@ export class KlipyService {
|
||||
if (typeof httpError?.message === 'string')
|
||||
return httpError.message;
|
||||
|
||||
return 'Failed to load GIFs from KLIPY.';
|
||||
return this.appI18n.instant('chat.gifPicker.loadFailed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,7 @@ export class LinkMetadataService {
|
||||
async fetchMetadata(url: string): Promise<LinkMetadata> {
|
||||
try {
|
||||
const apiBase = this.serverDirectory.getApiBaseUrl();
|
||||
const result = await firstValueFrom(
|
||||
this.http.get<Omit<LinkMetadata, 'url'>>(
|
||||
`${apiBase}/link-metadata`,
|
||||
{ params: { url } }
|
||||
)
|
||||
);
|
||||
const result = await firstValueFrom(this.http.get<Omit<LinkMetadata, 'url'>>(`${apiBase}/link-metadata`, { params: { url } }));
|
||||
|
||||
return { url, ...result };
|
||||
} catch {
|
||||
|
||||
@@ -14,38 +14,26 @@ describe('resolveAutoScrollBehavior', () => {
|
||||
|
||||
it('jumps instantly for the local user own send regardless of grace', () => {
|
||||
expect(resolveAutoScrollBehavior({ ...base, forceLocalSend: true })).toBe('instant');
|
||||
expect(
|
||||
resolveAutoScrollBehavior({ ...base, forceLocalSend: true, withinInitialGrace: true })
|
||||
).toBe('instant');
|
||||
expect(resolveAutoScrollBehavior({ ...base, forceLocalSend: true, withinInitialGrace: true })).toBe('instant');
|
||||
});
|
||||
|
||||
it('jumps instantly when near bottom while settling after a channel switch', () => {
|
||||
expect(
|
||||
resolveAutoScrollBehavior({ ...base, distanceFromBottom: 40, withinInitialGrace: true })
|
||||
).toBe('instant');
|
||||
expect(resolveAutoScrollBehavior({ ...base, distanceFromBottom: 40, withinInitialGrace: true })).toBe('instant');
|
||||
});
|
||||
|
||||
it('animates smoothly for live messages once settled and near bottom', () => {
|
||||
expect(
|
||||
resolveAutoScrollBehavior({ ...base, distanceFromBottom: 40, withinInitialGrace: false })
|
||||
).toBe('smooth');
|
||||
expect(resolveAutoScrollBehavior({ ...base, distanceFromBottom: 40, withinInitialGrace: false })).toBe('smooth');
|
||||
});
|
||||
|
||||
it('shows the indicator (no scroll) when far from the bottom', () => {
|
||||
expect(resolveAutoScrollBehavior({ ...base, distanceFromBottom: 800 })).toBe('none');
|
||||
expect(
|
||||
resolveAutoScrollBehavior({ ...base, distanceFromBottom: 800, withinInitialGrace: true })
|
||||
).toBe('none');
|
||||
expect(resolveAutoScrollBehavior({ ...base, distanceFromBottom: 800, withinInitialGrace: true })).toBe('none');
|
||||
});
|
||||
|
||||
it('honours a custom sticky threshold', () => {
|
||||
expect(
|
||||
resolveAutoScrollBehavior({ ...base, distanceFromBottom: 150, stickyThreshold: 100 })
|
||||
).toBe('none');
|
||||
expect(resolveAutoScrollBehavior({ ...base, distanceFromBottom: 150, stickyThreshold: 100 })).toBe('none');
|
||||
|
||||
expect(
|
||||
resolveAutoScrollBehavior({ ...base, distanceFromBottom: 80, stickyThreshold: 100 })
|
||||
).toBe('smooth');
|
||||
expect(resolveAutoScrollBehavior({ ...base, distanceFromBottom: 80, stickyThreshold: 100 })).toBe('smooth');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -54,9 +54,6 @@ export const STICKY_BOTTOM_THRESHOLD = 300;
|
||||
* This is the predicate the message list uses to decide whether a content
|
||||
* height change (late image/embed/plugin render) should re-pin to bottom.
|
||||
*/
|
||||
export function isStuckToBottom(
|
||||
distanceFromBottom: number,
|
||||
threshold: number = STICKY_BOTTOM_THRESHOLD
|
||||
): boolean {
|
||||
export function isStuckToBottom(distanceFromBottom: number, threshold: number = STICKY_BOTTOM_THRESHOLD): boolean {
|
||||
return distanceFromBottom <= threshold;
|
||||
}
|
||||
|
||||
@@ -51,12 +51,7 @@ export function findMissingIds(
|
||||
for (const item of remoteItems) {
|
||||
const local = localMap.get(item.id);
|
||||
|
||||
if (
|
||||
!local ||
|
||||
item.ts > local.ts ||
|
||||
(item.rc !== undefined && item.rc !== local.rc) ||
|
||||
(item.ac !== undefined && item.ac !== local.ac)
|
||||
) {
|
||||
if (!local || item.ts > local.ts || (item.rc !== undefined && item.rc !== local.rc) || (item.ac !== undefined && item.ac !== local.ac)) {
|
||||
missing.push(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user