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

This commit is contained in:
2026-06-05 17:13:03 +02:00
parent 8ecfc9a1fe
commit ee293d7daf
301 changed files with 8247 additions and 2218 deletions

View File

@@ -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`.

View File

@@ -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

View File

@@ -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)

View File

@@ -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>

View File

@@ -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'

View File

@@ -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>

View File

@@ -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]

View 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"
}
}
}

View 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."
}
}
}

View 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"
}
}
}

View 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."
}
}
}

View 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"
}
}
}

View 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"
}
}
}

View 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"
}
}

View 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"
}
}
}

View 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."
}
}
}

View File

@@ -0,0 +1,10 @@
{
"experimental": {
"vlcPlayer": {
"download": "Download",
"close": "Close",
"loading": "Loading experimental player...",
"retry": "Retry"
}
}
}

View 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"
}
}
}

View 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}}"
}
}
}

View 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"
}
}
}

View 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"
}
}
}
}

View 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."
}
}
}

View 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..."
}
}
}

View 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"
}
}

View 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"
}
}
}

View 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"
}
}
}

View 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"
}
}
}
}

View 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"
}
}
}

View 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"
}
}
}

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View File

@@ -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>

View File

@@ -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;

View 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);
});
});

View 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();
}

View 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');
});
});

View 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;
}

View 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');
});
});

View 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);
}
}

View 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();
}

View 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;

View 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';

View File

@@ -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[] {

View File

@@ -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');
});
});
});

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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();
}

View File

@@ -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';

View File

@@ -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>

View File

@@ -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'));
}
});
}

View File

@@ -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>

View File

@@ -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'));
}
});
}

View File

@@ -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>
}

View File

@@ -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({

View File

@@ -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

View File

@@ -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');
}
}

View File

@@ -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 {

View File

@@ -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');
});
});

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -7,10 +7,7 @@ export function getMessageTimestamp(msg: Message): number {
/** Computes the most recent timestamp across a batch of messages. */
export function getLatestTimestamp(messages: Message[]): number {
return messages.reduce(
(max, msg) => Math.max(max, getMessageTimestamp(msg)),
0
);
return messages.reduce((max, msg) => Math.max(max, getMessageTimestamp(msg)), 0);
}
/** Strips sensitive content from a deleted message. */

View File

@@ -63,7 +63,7 @@
(keydown.space)="closeKlipyGifPicker()"
tabindex="0"
role="button"
aria-label="Close GIF picker"
[attr.aria-label]="'chat.gifPicker.closeOverlayAria' | translate"
style="-webkit-app-region: no-drag"
></div>

View File

@@ -29,6 +29,7 @@ import {
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { Message } from '../../../../shared-kernel';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ThemeNodeDirective } from '../../../theme';
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
@@ -56,7 +57,8 @@ import {
ChatMessageListComponent,
ChatMessageOverlaysComponent,
BottomSheetComponent,
ThemeNodeDirective
ThemeNodeDirective,
...APP_TRANSLATE_IMPORTS
],
templateUrl: './chat-messages.component.html',
styleUrl: './chat-messages.component.scss'
@@ -87,9 +89,7 @@ export class ChatMessagesComponent {
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
readonly conversationExhausted = toSignal(
toObservable(this.conversationKey).pipe(
switchMap((key) => this.store.select(selectConversationExhausted(key)))
),
toObservable(this.conversationKey).pipe(switchMap((key) => this.store.select(selectConversationExhausted(key)))),
{ initialValue: false }
);
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));

View File

@@ -7,6 +7,7 @@ import {
import {
BUILT_IN_SLASH_COMMANDS,
BUILT_IN_SLASH_COMMAND_SOURCE,
BUILT_IN_SLASH_COMMAND_SOURCE_KEY,
buildBuiltInSlashCommandEntries
} from './chat-builtin-slash-commands.rules';
@@ -18,28 +19,34 @@ describe('built-in slash commands', () => {
});
it('adapts definitions to global slash command entries tagged as built-in', () => {
const entries = buildBuiltInSlashCommandEntries(() => {});
const entries = buildBuiltInSlashCommandEntries(
() => {},
(key) => `i18n:${key}`
);
const lenny = entries.find((entry) => entry.contribution.name === 'lenny');
expect(lenny?.pluginId).toBe(BUILT_IN_SLASH_COMMAND_SOURCE);
expect(lenny?.pluginId).toBe(`i18n:${BUILT_IN_SLASH_COMMAND_SOURCE_KEY}`);
expect(lenny?.id).toBe(`${BUILT_IN_SLASH_COMMAND_SOURCE}:lenny`);
expect(lenny?.contribution.scope).toBe('global');
expect(lenny?.contribution.description).toBe('i18n:chat.slashCommand.lennyDescription');
});
it('runs the command by sending its text', () => {
const sendText = vi.fn();
const entries = buildBuiltInSlashCommandEntries(sendText);
entries.find((entry) => entry.contribution.name === 'lenny')?.contribution.run({
args: {},
command: 'lenny',
rawArgs: '',
server: null,
source: 'slashCommand',
textChannel: null,
user: null,
voiceChannel: null
});
entries
.find((entry) => entry.contribution.name === 'lenny')
?.contribution.run({
args: {},
command: 'lenny',
rawArgs: '',
server: null,
source: 'slashCommand',
textChannel: null,
user: null,
voiceChannel: null
});
expect(sendText).toHaveBeenCalledWith('( ͡° ͜ʖ ͡°)');
});

View File

@@ -1,11 +1,14 @@
import type { SlashCommandEntry } from '../../../../../plugins';
/** Source label shown for built-in commands in the slash command menu. */
export const BUILT_IN_SLASH_COMMAND_SOURCE = 'Built-in';
/** Plugin id tag for built-in commands in the slash command menu. */
export const BUILT_IN_SLASH_COMMAND_SOURCE = 'built-in';
/** i18n key for the built-in command source badge. */
export const BUILT_IN_SLASH_COMMAND_SOURCE_KEY = 'chat.slashCommand.builtInSource';
/** A first-party slash command that inserts fixed text into the chat as a message. */
export interface BuiltInSlashCommand {
description: string;
descriptionKey: string;
icon?: string;
name: string;
text: string;
@@ -18,7 +21,7 @@ export interface BuiltInSlashCommand {
export const BUILT_IN_SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
{
name: 'lenny',
description: 'Send the Lenny face ( ͡° ͜ʖ ͡°)',
descriptionKey: 'chat.slashCommand.lennyDescription',
text: '( ͡° ͜ʖ ͡°)'
}
];
@@ -28,16 +31,19 @@ export const BUILT_IN_SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
* by the composer menu. Each entry's `run` sends the command's text through the
* provided callback so it posts as a normal chat message.
*/
export function buildBuiltInSlashCommandEntries(sendText: (text: string) => void): SlashCommandEntry[] {
export function buildBuiltInSlashCommandEntries(
sendText: (text: string) => void,
instant: (key: string) => string = (key) => key
): SlashCommandEntry[] {
return BUILT_IN_SLASH_COMMANDS.map((command) => ({
contribution: {
description: command.description,
description: instant(command.descriptionKey),
icon: command.icon,
name: command.name,
run: () => sendText(command.text),
scope: 'global'
},
id: `${BUILT_IN_SLASH_COMMAND_SOURCE}:${command.name}`,
pluginId: BUILT_IN_SLASH_COMMAND_SOURCE
pluginId: instant(BUILT_IN_SLASH_COMMAND_SOURCE_KEY)
}));
}

View File

@@ -24,7 +24,7 @@
class="h-4 w-4 text-muted-foreground"
/>
<span class="flex-1 text-sm text-muted-foreground">
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
{{ 'chat.composer.replyingTo' | translate: { name: replyTo()?.senderName } }}
</span>
<button
(click)="clearReply()"
@@ -98,43 +98,43 @@
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyPrefix('> ')"
>
Quote
{{ 'chat.composer.toolbar.quote' | translate }}
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyPrefix('- ')"
>
• List
{{ 'chat.composer.toolbar.bulletList' | translate }}
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyOrderedList()"
>
1. List
{{ 'chat.composer.toolbar.orderedList' | translate }}
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyCodeBlock()"
>
Code
{{ 'chat.composer.toolbar.code' | translate }}
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyLink()"
>
Link
{{ 'chat.composer.toolbar.link' | translate }}
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyImage()"
>
Image
{{ 'chat.composer.toolbar.image' | translate }}
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHorizontalRule()"
>
HR
{{ 'chat.composer.toolbar.horizontalRule' | translate }}
</button>
</div>
</div>
@@ -176,8 +176,8 @@
[class.opacity-100]="inputHovered() || showComposerMediaMenu() || showEmojiPicker() || showKlipyGifPicker()"
[class.opacity-70]="!inputHovered() && !showComposerMediaMenu() && !showEmojiPicker() && !showKlipyGifPicker()"
[class.text-primary]="showComposerMediaMenu() || showEmojiPicker() || showKlipyGifPicker()"
aria-label="Add attachment, GIF, or emoji"
title="Add attachment, GIF, or emoji"
[attr.aria-label]="'chat.composer.addAttachmentGifEmoji' | translate"
[title]="'chat.composer.addAttachmentGifEmoji' | translate"
>
<ng-icon
name="lucidePlus"
@@ -187,8 +187,8 @@
@if (showComposerMediaMenu()) {
<app-bottom-sheet
title="Add to message"
ariaLabel="Add to message"
[title]="'chat.composer.addToMessage' | translate"
[ariaLabel]="'chat.composer.addToMessage' | translate"
(dismissed)="closeComposerMediaMenu()"
>
<div class="flex flex-col py-1">
@@ -218,7 +218,7 @@
/>
}
}
<span>{{ option.label }}</span>
<span>{{ option.labelKey | translate }}</span>
</button>
}
</div>
@@ -227,8 +227,8 @@
@if (showEmojiPicker()) {
<app-bottom-sheet
title="Emoji"
ariaLabel="Emoji picker"
[title]="'chat.composer.emoji' | translate"
[ariaLabel]="'chat.composer.emojiPickerAria' | translate"
(dismissed)="closeEmojiPicker()"
>
<app-custom-emoji-picker
@@ -249,8 +249,8 @@
class="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-border/70 bg-secondary/55 text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground md:h-10 md:w-10"
[class.opacity-100]="inputHovered()"
[class.opacity-70]="!inputHovered()"
aria-label="Attach files"
title="Attach files"
[attr.aria-label]="'chat.composer.attachFiles' | translate"
[title]="'chat.composer.attachFiles' | translate"
>
<ng-icon
name="lucidePaperclip"
@@ -270,14 +270,14 @@
[class.opacity-70]="!inputHovered() && !showKlipyGifPicker()"
[class.shadow-none]="!inputHovered() && !showKlipyGifPicker()"
[class.text-primary]="showKlipyGifPicker()"
aria-label="Search KLIPY GIFs"
title="Search KLIPY GIFs"
[attr.aria-label]="'chat.composer.searchKlipyGifs' | translate"
[title]="'chat.composer.searchKlipyGifs' | translate"
>
<ng-icon
name="lucideImage"
class="h-4 w-4"
/>
<span class="hidden sm:inline">GIF</span>
<span class="hidden sm:inline">{{ 'chat.composer.gif' | translate }}</span>
</button>
}
@@ -290,8 +290,8 @@
[class.opacity-100]="inputHovered() || showEmojiPicker()"
[class.opacity-60]="!inputHovered() && !showEmojiPicker()"
[class.grayscale-0]="showEmojiPicker()"
aria-label="Open emoji selector"
title="Open emoji selector"
[attr.aria-label]="'chat.composer.openEmojiSelector' | translate"
[title]="'chat.composer.openEmojiSelector' | translate"
>
{{ emojiButton() }}
</button>
@@ -314,8 +314,8 @@
(click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
class="send-btn visible inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-1 ring-primary/20 transition-all duration-200 hover:-translate-y-0.5 hover:bg-primary/90 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground disabled:shadow-none disabled:ring-0"
aria-label="Send message"
title="Send message"
[attr.aria-label]="'chat.composer.sendMessage' | translate"
[title]="'chat.composer.sendMessage' | translate"
>
<ng-icon
name="lucideSend"
@@ -339,7 +339,7 @@
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message..."
[placeholder]="'chat.composer.placeholder' | translate"
class="chat-textarea w-full min-w-0 border-0 pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none"
[class.chat-textarea-expanded]="textareaExpanded()"
[class.ctrl-resize]="ctrlHeld()"
@@ -348,7 +348,7 @@
@if (dragActive()) {
<div class="pointer-events-none absolute inset-0 flex items-center justify-center border-2 border-dashed border-primary bg-primary/5">
<div class="text-sm text-muted-foreground">Drop files to attach</div>
<div class="text-sm text-muted-foreground">{{ 'chat.composer.dropFilesToAttach' | translate }}</div>
</div>
}
@@ -359,20 +359,20 @@
<img
[appChatImageProxyFallback]="pendingKlipyGif()!.previewUrl || pendingKlipyGif()!.url"
[signalSource]="klipySignalSource()"
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
[alt]="pendingKlipyGif()!.title || ('chat.composer.klipyGif' | translate)"
class="h-full w-full object-cover"
loading="lazy"
/>
<span
class="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.18em] text-white/90"
>
KLIPY
{{ 'chat.composer.klipy' | translate }}
</span>
</div>
<div class="min-w-0">
<div class="text-xs font-medium text-foreground">GIF ready to send</div>
<div class="text-xs font-medium text-foreground">{{ 'chat.composer.gifReadyToSend' | translate }}</div>
<div class="max-w-[12rem] truncate text-[10px] text-muted-foreground">
{{ pendingKlipyGif()!.title || 'KLIPY GIF' }}
{{ pendingKlipyGif()!.title || ('chat.composer.klipyGif' | translate) }}
</div>
</div>
<button
@@ -380,7 +380,7 @@
(click)="removePendingKlipyGif()"
class="rounded px-2 py-1 text-[10px] text-destructive transition-colors hover:bg-destructive/10"
>
Remove
{{ 'chat.composer.remove' | translate }}
</button>
</div>
</div>
@@ -396,7 +396,7 @@
(click)="removePendingFile(file)"
class="rounded bg-destructive/20 px-1 py-0.5 text-[10px] text-destructive opacity-70 group-hover:opacity-100"
>
Remove
{{ 'chat.composer.remove' | translate }}
</button>
</div>
}

View File

@@ -54,6 +54,7 @@ import {
} from '../../../../../custom-emoji';
import { annotateLocalFilePath } from '../../../../../attachment';
import { BottomSheetComponent } from '../../../../../../shared';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { ViewportService } from '../../../../../../core/platform/viewport.service';
import { MobileMediaService, MobilePlatformService } from '../../../../../../infrastructure/mobile';
import {
@@ -83,7 +84,8 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
TypingIndicatorComponent,
ThemeNodeDirective,
BottomSheetComponent,
ChatSlashCommandMenuComponent
ChatSlashCommandMenuComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -132,13 +134,12 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
private readonly mobilePlatform = inject(MobilePlatformService);
private readonly mobileMedia = inject(MobileMediaService);
private readonly viewport = inject(ViewportService);
private readonly appI18n = inject(AppI18nService);
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
readonly shouldShowAttachmentButton = this.mobilePlatform.shouldShowAttachmentButton;
readonly mergeComposerMediaActions = computed(() => shouldMergeComposerMediaActions(this.viewport.isMobile()));
readonly composerMediaMenuOptions = computed(() =>
buildComposerMediaMenuOptions(this.shouldShowAttachmentButton(), this.klipyEnabled())
);
readonly composerMediaMenuOptions = computed(() => buildComposerMediaMenuOptions(this.shouldShowAttachmentButton(), this.klipyEnabled()));
readonly composerTextareaPaddingClass = computed(() =>
resolveComposerTextareaPaddingClass({
isMobileViewport: this.viewport.isMobile(),
@@ -152,12 +153,12 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
readonly slashQuery = signal<string | null>(null);
readonly slashActiveIndex = signal(0);
private readonly builtInSlashEntries = buildBuiltInSlashCommandEntries((text) => this.sendBuiltInSlashText(text));
private readonly builtInSlashEntries = buildBuiltInSlashCommandEntries(
(text) => this.sendBuiltInSlashText(text),
(key) => this.appI18n.instant(key)
);
readonly availableSlashCommands = computed(() =>
selectAvailableSlashCommands(
[...this.builtInSlashEntries, ...this.pluginUi.slashCommandRecords()],
this.commandSurface()
)
selectAvailableSlashCommands([...this.builtInSlashEntries, ...this.pluginUi.slashCommandRecords()], this.commandSurface())
);
readonly slashCommandResults = computed(() => {
const query = this.slashQuery();
@@ -377,8 +378,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
}
runPluginComposerAction(action: PluginApiActionContribution): void {
void Promise.resolve()
.then(() => action.run(this.pluginApi.createActionContext('composerAction')));
void Promise.resolve().then(() => action.run(this.pluginApi.createActionContext('composerAction')));
}
updateSlashCommandMenu(): void {
@@ -542,22 +542,22 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
const unitKeys = [
'chat.units.b',
'chat.units.kb',
'chat.units.mb',
'chat.units.gb'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
while (size >= 1024 && unitIndex < unitKeys.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
return `${size.toFixed(1)} ${this.appI18n.instant(unitKeys[unitIndex])}`;
}
removePendingFile(file: File): void {
@@ -880,9 +880,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
const payloadPath = payload.path;
return annotateLocalFilePath(file, {
getPathForFile: payloadPath
? () => payloadPath
: this.electronBridge.getApi()?.getPathForFile
getPathForFile: payloadPath ? () => payloadPath : this.electronBridge.getApi()?.getPathForFile
});
}

View File

@@ -2,10 +2,10 @@
<div
class="overflow-hidden rounded-2xl border border-border bg-card/95 shadow-2xl shadow-black/30 backdrop-blur-xl"
role="listbox"
aria-label="Slash commands"
[attr.aria-label]="'chat.slashCommand.ariaLabel' | translate"
>
<div class="flex items-center justify-between border-b border-border/60 px-4 py-2">
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Commands</span>
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">{{ 'chat.slashCommand.commands' | translate }}</span>
<span class="text-[11px] text-muted-foreground">{{ commands().length }}</span>
</div>

View File

@@ -1,4 +1,5 @@
import { CommonModule } from '@angular/common';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import {
Component,
ElementRef,
@@ -13,7 +14,7 @@ import type { SlashCommandEntry } from '../../../../../plugins';
@Component({
selector: 'app-chat-slash-command-menu',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, ...APP_TRANSLATE_IMPORTS],
templateUrl: './chat-slash-command-menu.component.html',
host: {
class: 'block'
@@ -42,9 +43,7 @@ export class ChatSlashCommandMenuComponent {
}
usage(entry: SlashCommandEntry): string {
return (entry.contribution.options ?? [])
.map((option) => this.formatOption(option))
.join(' ');
return (entry.contribution.options ?? []).map((option) => this.formatOption(option)).join(' ');
}
pick(entry: SlashCommandEntry): void {

View File

@@ -18,14 +18,17 @@ describe('composer-media-menu.rules', () => {
describe('buildComposerMediaMenuOptions', () => {
it('includes attachment, gif, and emoji when all are available', () => {
expect(buildComposerMediaMenuOptions(true, true)).toEqual([
{ action: 'attachment', label: 'Attach files' },
{ action: 'gif', label: 'GIF' },
{ action: 'emoji', label: 'Emoji' }
{ action: 'attachment', labelKey: 'chat.mediaMenu.attachFiles' },
{ action: 'gif', labelKey: 'chat.mediaMenu.gif' },
{ action: 'emoji', labelKey: 'chat.mediaMenu.emoji' }
]);
});
it('omits attachment when the picker is unavailable', () => {
expect(buildComposerMediaMenuOptions(false, true)).toEqual([{ action: 'gif', label: 'GIF' }, { action: 'emoji', label: 'Emoji' }]);
const gifOption = { action: 'gif' as const, labelKey: 'chat.mediaMenu.gif' };
const emojiOption = { action: 'emoji' as const, labelKey: 'chat.mediaMenu.emoji' };
expect(buildComposerMediaMenuOptions(false, true)).toEqual([gifOption, emojiOption]);
});
it('omits gif when klipy is disabled', () => {
@@ -35,7 +38,7 @@ describe('composer-media-menu.rules', () => {
});
it('always includes emoji even when attachment and gif are unavailable', () => {
expect(buildComposerMediaMenuOptions(false, false)).toEqual([{ action: 'emoji', label: 'Emoji' }]);
expect(buildComposerMediaMenuOptions(false, false)).toEqual([{ action: 'emoji', labelKey: 'chat.mediaMenu.emoji' }]);
});
});

View File

@@ -2,7 +2,7 @@ export type ComposerMediaMenuAction = 'attachment' | 'gif' | 'emoji';
export interface ComposerMediaMenuOption {
action: ComposerMediaMenuAction;
label: string;
labelKey: string;
}
export interface ComposerTextareaPaddingInput {
@@ -17,21 +17,18 @@ export function shouldMergeComposerMediaActions(isMobileViewport: boolean): bool
}
/** Build the actions shown in the merged mobile composer media menu. */
export function buildComposerMediaMenuOptions(
showAttachment: boolean,
klipyEnabled: boolean
): ComposerMediaMenuOption[] {
export function buildComposerMediaMenuOptions(showAttachment: boolean, klipyEnabled: boolean): ComposerMediaMenuOption[] {
const options: ComposerMediaMenuOption[] = [];
if (showAttachment) {
options.push({ action: 'attachment', label: 'Attach files' });
options.push({ action: 'attachment', labelKey: 'chat.mediaMenu.attachFiles' });
}
if (klipyEnabled) {
options.push({ action: 'gif', label: 'GIF' });
options.push({ action: 'gif', labelKey: 'chat.mediaMenu.gif' });
}
options.push({ action: 'emoji', label: 'Emoji' });
options.push({ action: 'emoji', labelKey: 'chat.mediaMenu.emoji' });
return options;
}

View File

@@ -18,7 +18,7 @@
@if (meta.imageUrl) {
<img
[src]="meta.imageUrl"
[alt]="meta.title || 'Link preview'"
[alt]="meta.title || ('chat.linkEmbed.previewAlt' | translate)"
class="hidden h-auto w-28 flex-shrink-0 object-cover sm:block"
loading="lazy"
referrerpolicy="no-referrer"

View File

@@ -3,6 +3,7 @@ import {
input,
output
} from '@angular/core';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../../core/i18n';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideX } from '@ng-icons/lucide';
import { LinkMetadata } from '../../../../../../../shared-kernel';
@@ -10,7 +11,7 @@ import { LinkMetadata } from '../../../../../../../shared-kernel';
@Component({
selector: 'app-chat-link-embed',
standalone: true,
imports: [NgIcon],
imports: [NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [provideIcons({ lucideX })],
templateUrl: './chat-link-embed.component.html'
})

View File

@@ -57,7 +57,7 @@
<span class="font-medium">{{ reply.senderName }}</span>
<span class="max-w-[200px] truncate">{{ reply.isDeleted ? deletedMessageContent : formatMessagePreview(reply.content) }}</span>
} @else {
<span class="italic">Original message not found</span>
<span class="italic">{{ 'chat.message.originalNotFound' | translate }}</span>
}
</div>
}
@@ -70,7 +70,7 @@
>
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
@if (msg.editedAt && !msg.isDeleted) {
<span class="text-xs text-muted-foreground">(edited)</span>
<span class="text-xs text-muted-foreground">{{ 'chat.message.edited' | translate }}</span>
}
</div>
@@ -126,13 +126,13 @@
@if (missingPluginEmbed(); as missingEmbed) {
<article class="mt-2 max-w-lg rounded-md border border-border bg-secondary/30 p-3 text-sm text-muted-foreground">
Required plugin is not installed to view this content, visit the
{{ 'chat.message.missingPluginPrefix' | translate }}
<button
type="button"
class="font-semibold text-primary underline-offset-4 hover:underline"
(click)="openMissingPluginStore(missingEmbed)"
>
store</button
{{ 'chat.message.store' | translate }}</button
>.
</article>
}
@@ -213,7 +213,7 @@
class="chat-image-grid-retry"
(click)="retryImageRequest(gridImage)"
>
Retry
{{ 'chat.message.retry' | translate }}
</button>
</div>
}
@@ -222,7 +222,7 @@
<button
type="button"
class="chat-image-grid-cell chat-image-grid-overflow"
[attr.aria-label]="'View all ' + displayableImages().length + ' images'"
[attr.aria-label]="viewAllImagesAriaLabel(displayableImages().length)"
(click)="openImageGallery()"
>
<span class="chat-image-grid-overflow-label">{{ imageOverflowLabel(cell.hiddenCount) }}</span>
@@ -250,7 +250,7 @@
<button
(click)="openLightbox(att); $event.stopPropagation()"
class="grid h-7 w-7 place-items-center rounded-md bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="View full size"
[title]="'chat.message.viewFullSize' | translate"
>
<ng-icon
name="lucideExpand"
@@ -260,7 +260,7 @@
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="grid h-7 w-7 place-items-center rounded-md bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
[title]="'chat.message.download' | translate"
>
<ng-icon
name="lucideDownload"
@@ -316,7 +316,7 @@
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.requestError || 'Waiting for image source...' }}
{{ att.requestError || ('chat.message.waitingForImage' | translate) }}
</div>
</div>
</div>
@@ -324,7 +324,7 @@
(click)="retryImageRequest(att)"
class="mt-2 w-full rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
>
Retry
{{ 'chat.message.retry' | translate }}
</button>
</div>
}
@@ -359,7 +359,7 @@
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
{{ 'chat.message.cancel' | translate }}
</button>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
@@ -432,14 +432,14 @@
class="rounded bg-secondary px-2 py-1 text-xs text-foreground"
(click)="requestAttachment(att)"
>
{{ att.requestError ? 'Retry' : 'Request' }}
{{ attachmentRequestLabel(!!att.requestError) }}
</button>
} @else {
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
{{ 'chat.message.cancel' | translate }}
</button>
}
} @else {
@@ -452,7 +452,7 @@
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Open
{{ 'chat.message.open' | translate }}
</button>
}
@if (att.canUseExperimentalPlayer) {
@@ -464,18 +464,18 @@
name="lucidePlay"
class="h-3.5 w-3.5"
/>
Play
{{ 'chat.message.play' | translate }}
</button>
}
<button
class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
(click)="downloadAttachment(att)"
>
Download
{{ 'chat.message.download' | translate }}
</button>
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
<div class="text-xs text-muted-foreground">{{ 'chat.message.sharedFromDevice' | translate }}</div>
@if (att.canOpenExternally) {
<button
class="inline-flex items-center gap-1.5 rounded bg-secondary px-2 py-1 text-xs text-foreground transition-colors hover:bg-secondary/80"
@@ -485,7 +485,7 @@
name="lucideExternalLink"
class="h-3.5 w-3.5"
/>
Open
{{ 'chat.message.open' | translate }}
</button>
}
@if (att.canUseExperimentalPlayer) {
@@ -497,7 +497,7 @@
name="lucidePlay"
class="h-3.5 w-3.5"
/>
Play
{{ 'chat.message.play' | translate }}
</button>
}
}
@@ -523,7 +523,7 @@
/>
} @loading {
<div class="mt-2 max-w-xl rounded-md border border-border bg-secondary/20 p-3 text-xs text-muted-foreground">
Loading experimental player...
{{ 'chat.message.loadingExperimentalPlayer' | translate }}
</div>
}
}
@@ -550,7 +550,7 @@
@if (isCustomEmojiToken(reaction.emoji) && customEmojiUrl(reaction.emoji); as reactionEmojiUrl) {
<img
[src]="reactionEmojiUrl"
alt="Custom emoji"
[alt]="'chat.message.customEmojiAlt' | translate"
class="h-6 w-6 object-contain"
/>
} @else {
@@ -628,13 +628,13 @@
<ng-template #mobileSheetTpl>
<app-bottom-sheet
title="Message"
ariaLabel="Message actions"
[title]="'chat.message.mobileSheetTitle' | translate"
[ariaLabel]="'chat.message.mobileSheetAria' | translate"
(dismissed)="closeMobileActions()"
>
<div class="flex flex-col py-1">
<div class="px-3 pb-2 pt-1">
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">React</p>
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ 'chat.message.react' | translate }}</p>
<div class="mt-2 grid grid-cols-8 gap-1">
@for (entry of emojiShortcuts(); track entry.key) {
<button
@@ -669,7 +669,7 @@
name="lucideReply"
class="h-5 w-5 text-muted-foreground"
/>
<span>Reply</span>
<span>{{ 'chat.message.reply' | translate }}</span>
</button>
<button
@@ -681,7 +681,7 @@
name="lucideCopy"
class="h-5 w-5 text-muted-foreground"
/>
<span>Copy message content</span>
<span>{{ 'chat.message.copyContent' | translate }}</span>
</button>
@if (isOwnMessage()) {
@@ -694,7 +694,7 @@
name="lucideEdit"
class="h-5 w-5 text-muted-foreground"
/>
<span>Edit</span>
<span>{{ 'chat.message.edit' | translate }}</span>
</button>
}
@@ -708,7 +708,7 @@
name="lucideTrash2"
class="h-5 w-5"
/>
<span>Delete</span>
<span>{{ 'chat.message.delete' | translate }}</span>
</button>
}
</div>

View File

@@ -41,17 +41,29 @@
}
}
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0.5em 0 0.25em;
color: hsl(var(--foreground));
font-weight: 700;
}
h1 { font-size: 1.5em; }
h2 { font-size: 1.3em; }
h3 { font-size: 1.15em; }
h1 {
font-size: 1.5em;
}
h2 {
font-size: 1.3em;
}
h3 {
font-size: 1.15em;
}
ul, ol {
ul,
ol {
margin: 0.25em 0;
padding-left: 1.5em;
}
@@ -143,7 +155,8 @@
font-size: 0.875em;
}
th, td {
th,
td {
border: 1px solid hsl(var(--border));
padding: 0.35em 0.75em;
text-align: left;

View File

@@ -52,11 +52,8 @@ import { ExperimentalMediaSettingsService } from '../../../../../experimental-me
import { ExperimentalVlcPlayerComponent } from '../../../../../experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component';
import { KlipyService } from '../../../../application/services/klipy.service';
import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules';
import {
DELETED_MESSAGE_CONTENT,
Message,
User
} from '../../../../../../shared-kernel';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { Message, User } from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme';
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
import { PluginRequirementStateService, PluginUiRegistryService } from '../../../../../plugins';
@@ -142,7 +139,8 @@ interface MissingPluginEmbedFallback {
PluginRenderHostComponent,
ExperimentalVlcPlayerComponent,
ThemeNodeDirective,
BottomSheetComponent
BottomSheetComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -184,6 +182,7 @@ export class ChatMessageItemComponent implements OnDestroy {
private readonly viewport = inject(ViewportService);
private readonly overlay = inject(Overlay);
private readonly viewContainerRef = inject(ViewContainerRef);
private readonly appI18n = inject(AppI18nService);
private mobileSheetOverlayRef: OverlayRef | null = null;
private longPressTimer: number | null = null;
readonly isMobile = this.viewport.isMobile;
@@ -211,7 +210,7 @@ export class ChatMessageItemComponent implements OnDestroy {
readonly embedRemoved = output<ChatMessageEmbedRemoveEvent>();
readonly emojiShortcuts = this.customEmoji.shortcutEntries;
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
readonly deletedMessageContent = this.appI18n.instant('chat.message.deleted');
readonly pluginEmbedToken = computed(() => parsePluginEmbedToken(this.message().content));
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.pluginEmbedToken()));
readonly missingPluginEmbed = computed(() => this.resolveMissingPluginEmbed());
@@ -252,16 +251,10 @@ export class ChatMessageItemComponent implements OnDestroy {
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment));
});
readonly imageAttachments = computed(() =>
dedupeImageAttachmentsForDisplay(
this.attachmentViewModels().filter((attachment) => isImageAttachment(attachment))
)
);
readonly displayableImages = computed(() =>
this.imageAttachments().filter((attachment) => isInlineDisplayableImage(attachment))
);
readonly nonImageAttachments = computed(() =>
this.attachmentViewModels().filter((attachment) => !attachment.isImage)
dedupeImageAttachmentsForDisplay(this.attachmentViewModels().filter((attachment) => isImageAttachment(attachment)))
);
readonly displayableImages = computed(() => this.imageAttachments().filter((attachment) => isInlineDisplayableImage(attachment)));
readonly nonImageAttachments = computed(() => this.attachmentViewModels().filter((attachment) => !attachment.isImage));
readonly imageGridLayout = computed(() => buildChatMessageImageGridLayout(this.imageAttachments().length));
private readonly hydrateMessageImages = effect(() => {
const messageId = this.message().id;
@@ -315,7 +308,8 @@ export class ChatMessageItemComponent implements OnDestroy {
const payload = parseEmbedPayload(token.payloadText);
return this.pluginUi.embedRecords()
return this.pluginUi
.embedRecords()
.filter((record) => record.contribution.embedType === token.embedType)
.map((record) => ({
...record,
@@ -330,11 +324,12 @@ export class ChatMessageItemComponent implements OnDestroy {
return null;
}
const missingRequirement = this.pluginRequirements.missingRequiredRequirements()
.find((requirement) => requirement.pluginId === token.embedType || requirement.manifest?.id === token.embedType)
?? this.pluginRequirements.missingRequiredRequirements()
.find((requirement) => requirement.manifest?.capabilities?.includes('ui.embeds'))
?? this.pluginRequirements.missingRequiredRequirements()[0];
const missingRequirement =
this.pluginRequirements
.missingRequiredRequirements()
.find((requirement) => requirement.pluginId === token.embedType || requirement.manifest?.id === token.embedType) ??
this.pluginRequirements.missingRequiredRequirements().find((requirement) => requirement.manifest?.capabilities?.includes('ui.embeds')) ??
this.pluginRequirements.missingRequiredRequirements()[0];
const pluginName = missingRequirement?.manifest?.title ?? missingRequirement?.pluginId ?? pluginNameFromEmbedType(token.embedType);
return {
@@ -613,7 +608,7 @@ export class ChatMessageItemComponent implements OnDestroy {
return time;
if (dayDiff === 1)
return 'Yesterday ' + time;
return this.appI18n.instant('chat.message.timestampYesterday', { time });
if (dayDiff < 7) {
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
@@ -630,8 +625,7 @@ export class ChatMessageItemComponent implements OnDestroy {
}
requiresRichMarkdown(content: string): boolean {
return isSingleUnicodeEmojiOnlyMessage(content)
|| RICH_MARKDOWN_PATTERNS.some((pattern) => pattern.test(content));
return isSingleUnicodeEmojiOnlyMessage(content) || RICH_MARKDOWN_PATTERNS.some((pattern) => pattern.test(content));
}
formatMessagePreview(content: string): string {
@@ -639,44 +633,44 @@ export class ChatMessageItemComponent implements OnDestroy {
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
const unitKeys = [
'chat.units.b',
'chat.units.kb',
'chat.units.mb',
'chat.units.gb'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
while (size >= 1024 && unitIndex < unitKeys.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
return `${size.toFixed(1)} ${this.appI18n.instant(unitKeys[unitIndex])}`;
}
formatSpeed(bytesPerSecond?: number): string {
if (!bytesPerSecond || bytesPerSecond <= 0)
return '0 B/s';
return `0 ${this.appI18n.instant('chat.units.bPerSec')}`;
const units = [
'B/s',
'KB/s',
'MB/s',
'GB/s'
const unitKeys = [
'chat.units.bPerSec',
'chat.units.kbPerSec',
'chat.units.mbPerSec',
'chat.units.gbPerSec'
];
let speed = bytesPerSecond;
let unitIndex = 0;
while (speed >= 1024 && unitIndex < units.length - 1) {
while (speed >= 1024 && unitIndex < unitKeys.length - 1) {
speed /= 1024;
unitIndex++;
}
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[unitIndex]}`;
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${this.appI18n.instant(unitKeys[unitIndex])}`;
}
isVideoAttachment(attachment: Attachment): boolean {
@@ -696,20 +690,20 @@ export class ChatMessageItemComponent implements OnDestroy {
return attachment.requestError;
if (this.requiresMediaDownloadAcceptance(attachment)) {
return this.isVideoAttachment(attachment)
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.';
return this.isVideoAttachment(attachment) ? this.appI18n.instant('chat.message.largeVideo') : this.appI18n.instant('chat.message.largeAudio');
}
return this.isVideoAttachment(attachment) ? 'Waiting for video source...' : 'Waiting for audio source...';
return this.isVideoAttachment(attachment)
? this.appI18n.instant('chat.message.waitingForVideo')
: this.appI18n.instant('chat.message.waitingForAudio');
}
getMediaAttachmentActionLabel(attachment: Attachment): string {
if (this.requiresMediaDownloadAcceptance(attachment)) {
return attachment.requestError ? 'Retry download' : 'Accept download';
return attachment.requestError ? this.appI18n.instant('chat.message.retryDownload') : this.appI18n.instant('chat.message.acceptDownload');
}
return attachment.requestError ? 'Retry' : 'Request';
return attachment.requestError ? this.appI18n.instant('chat.message.retry') : this.appI18n.instant('chat.message.request');
}
isUploader(attachment: Attachment): boolean {
@@ -793,6 +787,14 @@ export class ChatMessageItemComponent implements OnDestroy {
return formatChatMessageImageOverflowLabel(hiddenCount);
}
viewAllImagesAriaLabel(count: number): string {
return this.appI18n.instant('chat.message.viewAllImages', { count });
}
attachmentRequestLabel(hasError: boolean): string {
return hasError ? this.appI18n.instant('chat.message.retry') : this.appI18n.instant('chat.message.request');
}
openImageContextMenu(event: MouseEvent, attachment: Attachment): void {
event.preventDefault();
event.stopPropagation();
@@ -835,13 +837,13 @@ export class ChatMessageItemComponent implements OnDestroy {
const isRawAudio = this.isAudioAttachment(attachment);
const isRawPlayableMedia = isRawVideo || isRawAudio;
const isNativePlayableMedia = this.canPlayMediaType(attachment.mime);
const shouldUseDefaultFileInterface = isRawPlayableMedia &&
(!isNativePlayableMedia ||
(this.platform.isBrowser && attachment.size > MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES));
const shouldUseDefaultFileInterface =
isRawPlayableMedia && (!isNativePlayableMedia || (this.platform.isBrowser && attachment.size > MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES));
const isVideo = isRawVideo && !shouldUseDefaultFileInterface;
const isAudio = isRawAudio && !shouldUseDefaultFileInterface;
const requiresMediaDownloadAcceptance = (isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
const canUseExperimentalPlayer = this.experimentalMedia.vlcJsPlaybackEnabled() &&
const canUseExperimentalPlayer =
this.experimentalMedia.vlcJsPlaybackEnabled() &&
shouldUseDefaultFileInterface &&
isRawPlayableMedia &&
attachment.available &&
@@ -857,20 +859,20 @@ export class ChatMessageItemComponent implements OnDestroy {
isVideo,
mediaActionLabel: requiresMediaDownloadAcceptance
? attachment.requestError
? 'Retry download'
: 'Accept download'
? this.appI18n.instant('chat.message.retryDownload')
: this.appI18n.instant('chat.message.acceptDownload')
: attachment.requestError
? 'Retry'
: 'Request',
? this.appI18n.instant('chat.message.retry')
: this.appI18n.instant('chat.message.request'),
mediaStatusText: attachment.requestError
? attachment.requestError
: requiresMediaDownloadAcceptance
? isVideo
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.'
? this.appI18n.instant('chat.message.largeVideo')
: this.appI18n.instant('chat.message.largeAudio')
: isVideo
? 'Waiting for video source...'
: 'Waiting for audio source...',
? this.appI18n.instant('chat.message.waitingForVideo')
: this.appI18n.instant('chat.message.waitingForAudio'),
progressPercent: attachment.size > 0 ? ((attachment.receivedBytes || 0) * 100) / attachment.size : 0
};
}
@@ -919,7 +921,8 @@ function parsePluginEmbedToken(content: string): PluginEmbedToken | null {
function pluginNameFromEmbedType(embedType: string): string {
const parts = embedType.split(/[.:_-]/).filter(Boolean);
const pluginParts = parts.length > 2 ? parts.slice(0, -1) : parts;
const label = pluginParts.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
const label = pluginParts
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
.trim();

View File

@@ -29,7 +29,7 @@
>
<img
[appChatImageProxyFallback]="node.url"
[alt]="getCustomEmojiAlt(node.alt) || 'Shared image'"
[alt]="getCustomEmojiAlt(node.alt) || ('chat.markdown.sharedImage' | translate)"
class="block object-contain"
[class.chat-custom-emoji-image]="isCustomEmojiDataUrl(node.url)"
[style.height]="isCustomEmojiDataUrl(node.url) ? (largeCustomEmoji() ? '46px' : '1.2em') : null"
@@ -50,7 +50,7 @@
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
{{ 'chat.markdown.klipy' | translate }}
</span>
}
</div>

View File

@@ -16,6 +16,7 @@ import {
isSpotifyUrl,
isYoutubeUrl
} from '../../../../../domain/rules/link-embed.rules';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../../core/i18n';
import { ChatImageProxyFallbackDirective } from '../../../../chat-image-proxy-fallback.directive';
import { ChatSoundcloudEmbedComponent } from '../chat-soundcloud-embed/chat-soundcloud-embed.component';
import { ChatSpotifyEmbedComponent } from '../chat-spotify-embed/chat-spotify-embed.component';
@@ -50,8 +51,7 @@ const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
};
const KLIPY_MEDIA_URL_PATTERN = /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i;
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
const REMARK_PROCESSOR = unified()
.use(remarkParse)
const REMARK_PROCESSOR = unified().use(remarkParse)
.use(remarkGfm)
.use(remarkBreaks);
@@ -65,7 +65,8 @@ const REMARK_PROCESSOR = unified()
ChatImageProxyFallbackDirective,
ChatSpotifyEmbedComponent,
ChatSoundcloudEmbedComponent,
ChatYoutubeEmbedComponent
ChatYoutubeEmbedComponent,
...APP_TRANSLATE_IMPORTS
],
templateUrl: './chat-message-markdown.component.html'
})

View File

@@ -5,7 +5,7 @@
[style.height.px]="embedHeight()"
class="w-full border-0"
loading="lazy"
title="SoundCloud player"
[title]="'chat.embeds.soundcloudPlayer' | translate"
></iframe>
</div>
}

View File

@@ -5,11 +5,13 @@ import {
input
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../../core/i18n';
import { extractSoundcloudResource } from '../../../../../domain/rules/link-embed.rules';
@Component({
selector: 'app-chat-soundcloud-embed',
standalone: true,
imports: [...APP_TRANSLATE_IMPORTS],
templateUrl: './chat-soundcloud-embed.component.html'
})
export class ChatSoundcloudEmbedComponent {
@@ -17,7 +19,7 @@ export class ChatSoundcloudEmbedComponent {
readonly resource = computed(() => extractSoundcloudResource(this.url()));
readonly embedHeight = computed(() => this.resource()?.type === 'playlist' ? 352 : 166);
readonly embedHeight = computed(() => (this.resource()?.type === 'playlist' ? 352 : 166));
readonly embedUrl = computed(() => {
const resource = this.resource();

View File

@@ -5,7 +5,7 @@
[style.height.px]="embedHeight()"
class="w-full border-0"
loading="lazy"
title="Spotify player"
[title]="'chat.embeds.spotifyPlayer' | translate"
allowfullscreen
></iframe>
</div>

View File

@@ -5,11 +5,13 @@ import {
input
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../../core/i18n';
import { extractSpotifyResource } from '../../../../../domain/rules/link-embed.rules';
@Component({
selector: 'app-chat-spotify-embed',
standalone: true,
imports: [...APP_TRANSLATE_IMPORTS],
templateUrl: './chat-spotify-embed.component.html'
})
export class ChatSpotifyEmbedComponent {

View File

@@ -17,9 +17,7 @@ function resolveYoutubeClientOrigin(): string {
const origin = window.location.origin;
return /^https?:\/\//.test(origin)
? origin
: YOUTUBE_EMBED_FALLBACK_ORIGIN;
return /^https?:\/\//.test(origin) ? origin : YOUTUBE_EMBED_FALLBACK_ORIGIN;
}
@Component({
@@ -44,9 +42,7 @@ export class ChatYoutubeEmbedComponent {
embedUrl.searchParams.set('origin', clientOrigin);
embedUrl.searchParams.set('widget_referrer', clientOrigin);
return this.sanitizer.bypassSecurityTrustResourceUrl(
embedUrl.toString()
);
return this.sanitizer.bypassSecurityTrustResourceUrl(embedUrl.toString());
});
private readonly sanitizer = inject(DomSanitizer);

View File

@@ -8,13 +8,15 @@
@if (syncing() && !loading()) {
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
<div class="h-3 w-3 animate-spin rounded-full border-b-2 border-primary"></div>
<span>Syncing messages...</span>
<span>{{ 'chat.messageList.syncing' | translate }}</span>
</div>
}
@if (refreshLoading()) {
<div class="pointer-events-none sticky top-0 z-10 flex justify-center py-1">
<div class="rounded-full border border-border bg-background/85 px-2.5 py-1 text-[11px] text-muted-foreground shadow-sm">Loading...</div>
<div class="rounded-full border border-border bg-background/85 px-2.5 py-1 text-[11px] text-muted-foreground shadow-sm">
{{ 'chat.messageList.loading' | translate }}
</div>
</div>
}
@@ -24,8 +26,8 @@
</div>
} @else if (messages().length === 0) {
<div class="flex h-full flex-col items-center justify-center text-muted-foreground">
<p class="text-lg">No messages yet</p>
<p class="text-sm">Be the first to say something!</p>
<p class="text-lg">{{ 'chat.messageList.emptyTitle' | translate }}</p>
<p class="text-sm">{{ 'chat.messageList.emptySubtitle' | translate }}</p>
</div>
} @else {
<div
@@ -42,7 +44,7 @@
(click)="loadMore()"
class="rounded-md px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
Load older messages
{{ 'chat.messageList.loadOlder' | translate }}
</button>
}
</div>
@@ -90,13 +92,13 @@
appThemeNode="chatNewMessagesBar"
class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow"
>
<span class="text-sm text-muted-foreground">New messages</span>
<span class="text-sm text-muted-foreground">{{ 'chat.messageList.newMessages' | translate }}</span>
<button
type="button"
(click)="readLatest()"
class="rounded bg-primary px-2 py-1 text-sm text-primary-foreground hover:bg-primary/90"
>
Read latest
{{ 'chat.messageList.readLatest' | translate }}
</button>
</div>
</div>

View File

@@ -34,6 +34,7 @@ import {
ChatMessageReplyEvent
} from '../../models/chat-messages.model';
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
import { APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { ThemeNodeDirective } from '../../../../../theme';
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
@@ -53,7 +54,8 @@ declare global {
imports: [
CommonModule,
ChatMessageItemComponent,
ThemeNodeDirective
ThemeNodeDirective,
...APP_TRANSLATE_IMPORTS
],
templateUrl: './chat-message-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -378,10 +380,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
if (element.scrollTop < 150 && !this.loadingMore()) {
const canFetchOlderFromDb =
!this.hasMoreMessages()
&& !this.conversationExhausted()
&& !this.loadingOlder()
&& this.channelMessages().length > 0;
!this.hasMoreMessages() && !this.conversationExhausted() && !this.loadingOlder() && this.channelMessages().length > 0;
if (this.hasMoreMessages() || canFetchOlderFromDb) {
this.loadMore();

View File

@@ -2,7 +2,7 @@
@if (galleryAttachments()) {
<app-modal-backdrop
[zIndex]="100"
ariaLabel="Close image gallery"
[ariaLabel]="'chat.overlays.closeGalleryAria' | translate"
(dismissed)="closeGallery()"
/>
<div class="pointer-events-none fixed inset-0 z-[101] flex items-center justify-center p-4">
@@ -12,15 +12,17 @@
>
<div class="flex items-center justify-between border-b border-border px-4 py-3">
<div>
<h2 class="text-sm font-semibold text-foreground">View images</h2>
<p class="text-xs text-muted-foreground">{{ galleryAttachments()!.length }} images</p>
<h2 class="text-sm font-semibold text-foreground">{{ 'chat.overlays.viewImages' | translate }}</h2>
<p class="text-xs text-muted-foreground">
{{ 'chat.overlays.imageCount' | translate: { count: galleryAttachments()!.length } }}
</p>
</div>
<button
type="button"
(click)="closeGallery()"
class="grid h-8 w-8 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Close"
aria-label="Close image gallery"
[title]="'chat.overlays.close' | translate"
[attr.aria-label]="'chat.overlays.closeGalleryAria' | translate"
>
<ng-icon
name="lucideX"
@@ -34,7 +36,7 @@
<button
type="button"
class="group/gallery relative aspect-square overflow-hidden rounded-md bg-secondary/40"
[attr.aria-label]="'Open ' + attachment.filename"
[attr.aria-label]="openImageAriaLabel(attachment.filename)"
(click)="openGalleryImage(attachment)"
(contextmenu)="openImageContextMenu($event, attachment)"
>
@@ -55,7 +57,7 @@
@if (lightboxAttachment()) {
<app-modal-backdrop
[zIndex]="109"
ariaLabel="Close image preview"
[ariaLabel]="'chat.overlays.closePreviewAria' | translate"
(dismissed)="closeLightbox()"
/>
<div class="pointer-events-none fixed inset-0 z-[110] flex items-center justify-center p-4">
@@ -70,8 +72,8 @@
type="button"
(click)="showPreviousLightboxImage()"
class="lightbox-chrome absolute left-0 top-1/2 z-10 grid h-10 w-10 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Previous image"
aria-label="Previous image"
[title]="'chat.overlays.previousImage' | translate"
[attr.aria-label]="'chat.overlays.previousImage' | translate"
>
<ng-icon
name="lucideChevronLeft"
@@ -92,8 +94,8 @@
type="button"
(click)="showNextLightboxImage()"
class="lightbox-chrome absolute right-0 top-1/2 z-10 grid h-10 w-10 -translate-y-1/2 translate-x-1/2 place-items-center rounded-full bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Next image"
aria-label="Next image"
[title]="'chat.overlays.nextImage' | translate"
[attr.aria-label]="'chat.overlays.nextImage' | translate"
>
<ng-icon
name="lucideChevronRight"
@@ -107,7 +109,7 @@
type="button"
(click)="downloadAttachment(lightboxAttachment()!)"
class="grid h-9 w-9 place-items-center rounded-lg bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
[title]="'chat.overlays.download' | translate"
>
<ng-icon
name="lucideDownload"
@@ -118,8 +120,8 @@
type="button"
(click)="closeLightbox()"
class="grid h-9 w-9 place-items-center rounded-lg bg-black/60 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Close"
aria-label="Close image preview"
[title]="'chat.overlays.close' | translate"
[attr.aria-label]="'chat.overlays.closePreviewAria' | translate"
>
<ng-icon
name="lucideX"
@@ -156,7 +158,7 @@
name="lucideCopy"
class="h-4 w-4 text-muted-foreground"
/>
Copy Image
{{ 'chat.overlays.copyImage' | translate }}
</button>
<button
(click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()"
@@ -166,7 +168,7 @@
name="lucideDownload"
class="h-4 w-4 text-muted-foreground"
/>
Save Image
{{ 'chat.overlays.saveImage' | translate }}
</button>
</app-context-menu>
}

View File

@@ -5,6 +5,7 @@ import {
OnDestroy,
computed,
effect,
inject,
input,
output,
signal
@@ -19,6 +20,7 @@ import {
} from '@ng-icons/lucide';
import { Attachment } from '../../../../../attachment';
import { canStepLightbox } from '../../../../domain/rules/chat-message-lightbox.rules';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { ContextMenuComponent, ModalBackdropComponent } from '../../../../../../shared';
import {
ChatLightboxState,
@@ -34,7 +36,8 @@ import {
CommonModule,
NgIcon,
ContextMenuComponent,
ModalBackdropComponent
ModalBackdropComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -106,6 +109,7 @@ export class ChatMessageOverlaysComponent implements OnDestroy {
return `${state.index + 1} / ${state.attachments.length}`;
});
private readonly appI18n = inject(AppI18nService);
private readonly LIGHTBOX_CONTROLS_IDLE_MS = 2200;
private lightboxControlsHideTimer: ReturnType<typeof setTimeout> | null = null;
@@ -219,22 +223,26 @@ export class ChatMessageOverlaysComponent implements OnDestroy {
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
const unitKeys = [
'chat.units.b',
'chat.units.kb',
'chat.units.mb',
'chat.units.gb'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
while (size >= 1024 && unitIndex < unitKeys.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
return `${size.toFixed(1)} ${this.appI18n.instant(unitKeys[unitIndex])}`;
}
openImageAriaLabel(filename: string): string {
return this.appI18n.instant('chat.overlays.openImage', { filename });
}
ngOnDestroy(): void {

View File

@@ -19,13 +19,9 @@ export class ChatMarkdownService {
const selected = content.slice(start, end);
const after = content.slice(end);
const newText = `${before}${token}${selected}${token}${after}`;
const cursor = selected.length === 0
? before.length + token.length
: before.length + token.length + selected.length + token.length;
const cursor = selected.length === 0 ? before.length + token.length : before.length + token.length + selected.length + token.length;
return { text: newText,
selectionStart: cursor,
selectionEnd: cursor };
return { text: newText, selectionStart: cursor, selectionEnd: cursor };
}
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
@@ -33,14 +29,12 @@ export class ChatMarkdownService {
const before = content.slice(0, start);
const selected = content.slice(start, end);
const after = content.slice(end);
const lines = selected.split('\n').map(line => `${prefix}${line}`);
const lines = selected.split('\n').map((line) => `${prefix}${line}`);
const newSelected = lines.join('\n');
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult {
@@ -55,9 +49,7 @@ export class ChatMarkdownService {
const text = `${before}${block}${after}`;
const cursor = before.length + block.length - (needsTrailingNewline ? 1 : 0);
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
@@ -70,9 +62,7 @@ export class ChatMarkdownService {
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
@@ -80,17 +70,11 @@ export class ChatMarkdownService {
const before = content.slice(0, start);
const selected = content.slice(start, end);
const after = content.slice(end);
const fenced = selected.length === 0
? '```\n\n```\n\n'
: `\`\`\`\n${selected}\n\`\`\`\n\n`;
const fenced = selected.length === 0 ? '```\n\n```\n\n' : `\`\`\`\n${selected}\n\`\`\`\n\n`;
const text = `${before}${fenced}${after}`;
const cursor = selected.length === 0
? before.length + 4
: before.length + fenced.length;
const cursor = selected.length === 0 ? before.length + 4 : before.length + fenced.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyLink(content: string, selection: SelectionRange): ComposeResult {
@@ -100,13 +84,9 @@ export class ChatMarkdownService {
const after = content.slice(end);
const link = `[${selected}]()`;
const text = `${before}${link}${after}`;
const cursor = selected.length === 0
? before.length + 1
: before.length + 1 + selected.length + 2;
const cursor = selected.length === 0 ? before.length + 1 : before.length + 1 + selected.length + 2;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyImage(content: string, selection: SelectionRange): ComposeResult {
@@ -116,13 +96,9 @@ export class ChatMarkdownService {
const after = content.slice(end);
const img = `![${selected}]()`;
const text = `${before}${img}${after}`;
const cursor = selected.length === 0
? before.length + 2
: before.length + 2 + selected.length + 2;
const cursor = selected.length === 0 ? before.length + 2 : before.length + 2 + selected.length + 2;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
@@ -133,13 +109,11 @@ export class ChatMarkdownService {
const text = `${before}${hr}${after}`;
const cursor = before.length + hr.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
return { text, selectionStart: cursor, selectionEnd: cursor };
}
appendImageMarkdown(content: string): string {
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/gi;
const urls = new Set<string>();
let match: RegExpExecArray | null;

View File

@@ -1,16 +1,16 @@
<div
class="flex h-[min(70vh,42rem)] w-full flex-col overflow-hidden rounded-[1.65rem] border border-border/80 shadow-2xl ring-1 ring-white/5"
role="dialog"
aria-label="KLIPY GIF picker"
[attr.aria-label]="'chat.gifPicker.ariaLabel' | translate"
style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)"
>
@if (!isMobile()) {
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">{{ 'chat.gifPicker.klipy' | translate }}</div>
<h3 class="mt-1 text-lg font-semibold text-foreground">{{ 'chat.gifPicker.chooseGif' | translate }}</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
{{ searchQuery.trim() ? ('chat.gifPicker.searchResults' | translate) : ('chat.gifPicker.trending' | translate) }}
</p>
</div>
@@ -18,7 +18,7 @@
type="button"
(click)="close()"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 hover:text-foreground"
aria-label="Close GIF picker"
[attr.aria-label]="'chat.gifPicker.closeAria' | translate"
>
<ng-icon
name="lucideX"
@@ -39,7 +39,7 @@
type="text"
[ngModel]="searchQuery"
(ngModelChange)="onSearchQueryChanged($event)"
[placeholder]="isMobile() ? 'Search KLIPY and add a gif to the chat' : 'Search KLIPY'"
[placeholder]="isMobile() ? ('chat.gifPicker.searchMobile' | translate) : ('chat.gifPicker.searchDesktop' | translate)"
class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</label>
@@ -56,7 +56,7 @@
(click)="retry()"
class="rounded-lg bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
Retry
{{ 'chat.gifPicker.retry' | translate }}
</button>
</div>
}
@@ -64,7 +64,7 @@
@if (loading() && results().length === 0) {
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
<p class="text-sm">Loading GIFs from KLIPY...</p>
<p class="text-sm">{{ 'chat.gifPicker.loading' | translate }}</p>
</div>
} @else if (results().length === 0) {
<div
@@ -77,8 +77,8 @@
/>
</div>
<div>
<p class="text-sm font-medium text-foreground">No GIFs found</p>
<p class="mt-1 text-sm">Try another search term or clear the search to browse trending GIFs.</p>
<p class="text-sm font-medium text-foreground">{{ 'chat.gifPicker.noGifsFound' | translate }}</p>
<p class="mt-1 text-sm">{{ 'chat.gifPicker.noGifsHint' | translate }}</p>
</div>
</div>
} @else {
@@ -101,22 +101,22 @@
<img
[appChatImageProxyFallback]="gif.previewUrl || gif.url"
[signalSource]="signalSource()"
[alt]="gif.title || 'KLIPY GIF'"
[alt]="gif.title || ('chat.gifPicker.klipyGif' | translate)"
class="h-full w-full object-contain p-1.5 transition-transform duration-200 group-hover:scale-[1.03]"
loading="lazy"
/>
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
{{ 'chat.gifPicker.klipy' | translate }}
</span>
</div>
@if (!isMobile()) {
<div class="px-3 py-2">
<p class="truncate text-xs font-medium text-foreground">
{{ gif.title || 'KLIPY GIF' }}
{{ gif.title || ('chat.gifPicker.klipyGif' | translate) }}
</p>
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">{{ 'chat.gifPicker.clickToSelect' | translate }}</p>
</div>
}
</button>
@@ -130,7 +130,7 @@
(click)="loadMore()"
[disabled]="loading()"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-border/80 bg-background/60 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
[attr.aria-label]="loading() ? 'Loading more GIFs' : 'Load more GIFs'"
[attr.aria-label]="loading() ? ('chat.gifPicker.loadingMoreAria' | translate) : ('chat.gifPicker.loadMoreAria' | translate)"
>
@if (loading()) {
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
@@ -148,7 +148,7 @@
@if (!isMobile()) {
<div class="flex items-center justify-between gap-4 border-t border-border/70 bg-secondary/10 px-5 py-4">
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
<p class="text-xs text-muted-foreground">{{ 'chat.gifPicker.footer' | translate }}</p>
@if (hasNext()) {
<button
@@ -157,7 +157,7 @@
[disabled]="loading()"
class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{{ loading() ? 'Loading...' : 'Load more' }}
{{ loading() ? ('chat.gifPicker.loadingMore' | translate) : ('chat.gifPicker.loadMore' | translate) }}
</button>
}
</div>

View File

@@ -25,6 +25,7 @@ import {
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import type { RoomSignalSourceInput } from '../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { ViewportService } from '../../../../core/platform';
const KLIPY_CARD_MIN_WIDTH = 140;
@@ -40,7 +41,8 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
CommonModule,
FormsModule,
NgIcon,
ChatImageProxyFallbackDirective
ChatImageProxyFallbackDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -62,6 +64,7 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
private readonly klipy = inject(KlipyService);
private readonly viewport = inject(ViewportService);
private readonly appI18n = inject(AppI18nService);
readonly isMobile = this.viewport.isMobile;
private currentPage = 1;
private searchTimer: ReturnType<typeof setTimeout> | null = null;
@@ -136,29 +139,19 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
this.errorMessage.set('');
try {
const response = await firstValueFrom(
this.klipy.searchGifs(this.searchQuery, this.currentPage, undefined, this.signalSource())
);
const response = await firstValueFrom(this.klipy.searchGifs(this.searchQuery, this.currentPage, undefined, this.signalSource()));
if (requestId !== this.requestId)
return;
this.results.set(
reset
? response.results
: this.mergeResults(this.results(), response.results)
);
this.results.set(reset ? response.results : this.mergeResults(this.results(), response.results));
this.hasNext.set(response.hasNext);
} catch (error) {
if (requestId !== this.requestId)
return;
this.errorMessage.set(
error instanceof Error
? error.message
: 'Failed to load GIFs from KLIPY.'
);
this.errorMessage.set(error instanceof Error ? error.message : this.appI18n.instant('chat.gifPicker.loadFailed'));
if (reset) {
this.results.set([]);
@@ -202,17 +195,9 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
};
}
const maxScale = Math.min(
KLIPY_CARD_MAX_WIDTH / gif.width,
KLIPY_CARD_MAX_HEIGHT / gif.height
);
const minScale = Math.max(
KLIPY_CARD_MIN_WIDTH / gif.width,
KLIPY_CARD_MIN_HEIGHT / gif.height
);
const scale = minScale <= maxScale
? Math.min(maxScale, Math.max(minScale, 1))
: maxScale;
const maxScale = Math.min(KLIPY_CARD_MAX_WIDTH / gif.width, KLIPY_CARD_MAX_HEIGHT / gif.height);
const minScale = Math.max(KLIPY_CARD_MIN_WIDTH / gif.width, KLIPY_CARD_MIN_HEIGHT / gif.height);
const scale = minScale <= maxScale ? Math.min(maxScale, Math.max(minScale, 1)) : maxScale;
const scaledWidth = Math.round(gif.width * scale);
const scaledHeight = Math.round(gif.height * scale);

View File

@@ -1,12 +1,7 @@
@if (typingDisplay().length > 0) {
@if (typingLabel()) {
<div class="px-4 py-2 backdrop-blur-sm bg-background/60">
<span class="inline-block px-3 py-1 rounded-full text-sm text-muted-foreground">
{{ typingDisplay().join(', ') }}
@if (typingOthersCount() > 0) {
and {{ typingOthersCount() }} others are typing...
} @else {
{{ typingDisplay().length === 1 ? 'is' : 'are' }} typing...
}
{{ typingLabel() }}
</span>
</div>
}

View File

@@ -1,11 +1,13 @@
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, */
import {
Component,
computed,
inject,
signal,
DestroyRef,
effect
} from '@angular/core';
import { AppI18nService } from '../../../../core/i18n';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { RealtimeSessionFacade } from '../../../../core/realtime';
@@ -36,8 +38,8 @@ interface TypingSignalingMessage {
standalone: true,
templateUrl: './typing-indicator.component.html',
host: {
'class': 'block',
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));'
class: 'block',
style: 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));'
}
})
export class TypingIndicatorComponent {
@@ -50,23 +52,44 @@ export class TypingIndicatorComponent {
typingDisplay = signal<string[]>([]);
typingOthersCount = signal<number>(0);
private readonly appI18n = inject(AppI18nService);
readonly typingLabel = computed(() => {
const names = this.typingDisplay();
const others = this.typingOthersCount();
if (names.length === 0) {
return '';
}
const namesText = names.join(', ');
if (others > 0) {
return this.appI18n.instant('chat.typing.andOthers', {
names: namesText,
count: others
});
}
if (names.length === 1) {
return this.appI18n.instant('chat.typing.one', { names: namesText });
}
return this.appI18n.instant('chat.typing.many', { names: namesText });
});
constructor() {
const webrtc = inject(RealtimeSessionFacade);
const destroyRef = inject(DestroyRef);
const typing$ = webrtc.onSignalingMessage.pipe(
filter((msg): msg is TypingSignalingMessage =>
msg?.type === 'user_typing' &&
typeof msg.displayName === 'string' &&
typeof msg.oderId === 'string' &&
typeof msg.serverId === 'string'
filter(
(msg): msg is TypingSignalingMessage =>
msg?.type === 'user_typing' && typeof msg.displayName === 'string' && typeof msg.oderId === 'string' && typeof msg.serverId === 'string'
),
filter((msg) => msg.serverId === this.currentRoom()?.id),
tap((msg) => {
const now = Date.now();
const channelId = typeof msg.channelId === 'string' && msg.channelId.trim()
? msg.channelId.trim()
: 'general';
const channelId = typeof msg.channelId === 'string' && msg.channelId.trim() ? msg.channelId.trim() : 'general';
const typingKey = `${channelId}:${msg.oderId}`;
if (msg.isTyping === false) {

View File

@@ -1,7 +1,9 @@
<!-- Header -->
<div class="p-4 border-b border-border">
<h3 class="font-semibold text-foreground">Members</h3>
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
<h3 class="font-semibold text-foreground">{{ 'chat.userList.members' | translate }}</h3>
<p class="text-xs text-muted-foreground">
{{ 'chat.userList.onlineInVoice' | translate: { online: onlineUsers().length, inVoice: voiceUsers().length } }}
</p>
@if (voiceUsers().length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (v of voiceUsers(); track v.id) {
@@ -62,7 +64,7 @@
[class.text-red-500]="user.status === 'busy'"
[class.text-muted-foreground]="user.status === 'offline'"
>
{{ user.status === 'busy' ? 'Do Not Disturb' : (user.status | titlecase) }}
{{ statusLabel(user.status) | translate }}
</span>
}
</div>
@@ -73,7 +75,7 @@
type="button"
class="grid h-7 w-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-card hover:text-foreground"
[class.hidden]="isCurrentUser(user)"
title="Message"
[title]="'chat.userList.message' | translate"
(click)="$event.stopPropagation(); openDirectMessage(user)"
>
<ng-icon
@@ -127,13 +129,13 @@
name="lucideVolume2"
class="w-4 h-4"
/>
<span>Unmute</span>
<span>{{ 'chat.userList.unmute' | translate }}</span>
} @else {
<ng-icon
name="lucideVolumeX"
class="w-4 h-4"
/>
<span>Mute</span>
<span>{{ 'chat.userList.mute' | translate }}</span>
}
</button>
}
@@ -146,7 +148,7 @@
name="lucideUserX"
class="w-4 h-4"
/>
<span>Kick</span>
<span>{{ 'chat.userList.kick' | translate }}</span>
</button>
<button
type="button"
@@ -157,7 +159,7 @@
name="lucideBan"
class="w-4 h-4"
/>
<span>Ban</span>
<span>{{ 'chat.userList.ban' | translate }}</span>
</button>
</div>
}
@@ -165,35 +167,34 @@
}
@if (onlineUsers().length === 0) {
<div class="text-center py-8 text-muted-foreground text-sm">No users online</div>
<div class="text-center py-8 text-muted-foreground text-sm">{{ 'chat.userList.noUsersOnline' | translate }}</div>
}
</div>
<!-- Ban Dialog -->
@if (showBanDialog()) {
<app-confirm-dialog
title="Ban User"
confirmLabel="Ban User"
[title]="'chat.userList.banUserTitle' | translate"
[confirmLabel]="'chat.userList.banUserConfirm' | translate"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="confirmBan()"
(cancelled)="closeBanDialog()"
>
<p class="mb-4">
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span
>?
{{ 'chat.userList.banConfirmMessage' | translate: { name: userToBan()?.displayName } }}
</p>
<div class="mb-4">
<label
for="ban-reason-input"
class="block text-sm font-medium text-foreground mb-1"
>Reason (optional)</label
>{{ 'chat.userList.banReasonLabel' | translate }}</label
>
<input
type="text"
[(ngModel)]="banReason"
placeholder="Enter ban reason..."
[placeholder]="'chat.userList.banReasonPlaceholder' | translate"
id="ban-reason-input"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
@@ -203,18 +204,18 @@
<label
for="ban-duration-select"
class="block text-sm font-medium text-foreground mb-1"
>Duration</label
>{{ 'chat.userList.banDurationLabel' | translate }}</label
>
<select
[(ngModel)]="banDuration"
id="ban-duration-select"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="3600000">1 hour</option>
<option value="86400000">1 day</option>
<option value="604800000">1 week</option>
<option value="2592000000">30 days</option>
<option value="0">Permanent</option>
<option value="3600000">{{ 'chat.userList.banDuration1Hour' | translate }}</option>
<option value="86400000">{{ 'chat.userList.banDuration1Day' | translate }}</option>
<option value="604800000">{{ 'chat.userList.banDuration1Week' | translate }}</option>
<option value="2592000000">{{ 'chat.userList.banDuration30Days' | translate }}</option>
<option value="0">{{ 'chat.userList.banDurationPermanent' | translate }}</option>
</select>
</div>
</app-confirm-dialog>

View File

@@ -32,6 +32,7 @@ import {
} from '../../../../store/users/users.selectors';
import { User } from '../../../../shared-kernel';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared';
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
import { DirectMessageService } from '../../../direct-message';
@Component({
@@ -42,7 +43,8 @@ import { DirectMessageService } from '../../../direct-message';
FormsModule,
NgIcon,
UserAvatarComponent,
ConfirmDialogComponent
ConfirmDialogComponent,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
provideIcons({
@@ -80,6 +82,19 @@ export class UserListComponent {
banReason = '';
banDuration = '86400000'; // Default 1 day
statusLabel(status: User['status']): string {
switch (status) {
case 'busy':
return 'chat.userList.statusBusy';
case 'away':
return 'chat.userList.statusAway';
case 'offline':
return 'chat.userList.statusOffline';
default:
return 'chat.userList.statusAway';
}
}
/** Toggle the context menu for a specific user. */
toggleUserMenu(userId: string): void {
this.showUserMenu.update((current) => (current === userId ? null : userId));

View File

@@ -23,8 +23,8 @@
<button
type="button"
class="grid h-10 w-10 place-items-center rounded text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Open emoji selector"
title="Open emoji selector"
aria-label="{{ 'emoji.picker.openAria' | translate }}"
title="{{ 'emoji.picker.openAria' | translate }}"
(click)="openModal()"
>
<ng-icon
@@ -48,7 +48,7 @@
>
@if (!inline()) {
<div class="mb-2 flex items-center justify-between gap-2">
<p class="text-sm font-semibold text-foreground">Emoji</p>
<p class="text-sm font-semibold text-foreground">{{ 'emoji.picker.title' | translate }}</p>
@if (compact()) {
<button
type="button"
@@ -73,8 +73,8 @@
<input
type="search"
class="w-full rounded-md border border-border bg-background py-2 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Search emoji"
aria-label="Search emoji"
placeholder="{{ 'emoji.picker.searchPlaceholder' | translate }}"
aria-label="{{ 'emoji.picker.searchAria' | translate }}"
[value]="searchQuery()"
(input)="onSearchInput($event)"
/>
@@ -87,7 +87,7 @@
name="lucideUpload"
class="h-4 w-4"
/>
<span>{{ uploading() ? 'Uploading...' : 'Upload emoji' }}</span>
<span>{{ uploading() ? ('emoji.picker.uploading' | translate) : ('emoji.picker.upload' | translate) }}</span>
<input
type="file"
class="hidden"
@@ -105,7 +105,7 @@
@if (showEmptySearchState()) {
<div class="mb-3 rounded-md border border-border/70 bg-secondary/20 px-3 py-4 text-center text-xs text-muted-foreground">
No emoji match your search.
{{ 'emoji.picker.emptySearch' | translate }}
</div>
}

View File

@@ -8,6 +8,7 @@ import {
} from '@angular/core';
import { CustomEmoji } from '../../../../shared-kernel';
import { CustomEmojiService } from '../../application/custom-emoji.service';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import { CustomEmojiPickerComponent } from './custom-emoji-picker.component';
const savedEmojis: CustomEmoji[] = [
@@ -67,6 +68,7 @@ describe('CustomEmojiPickerComponent', () => {
): CustomEmojiPickerComponent {
const injector = Injector.create({
providers: [
...provideAppI18nForTests(),
CustomEmojiPickerComponent,
{
provide: ChangeDetectionScheduler,
@@ -92,6 +94,7 @@ describe('CustomEmojiPickerComponent', () => {
}
]
});
initializeAppI18nForTests(injector);
return runInInjectionContext(injector, () => injector.get(CustomEmojiPickerComponent));
}

View File

@@ -21,6 +21,7 @@ import {
} from '@ng-icons/lucide';
import { CustomEmoji, EmojiShortcutEntry } from '../../../../shared-kernel';
import { CustomEmojiService } from '../../application/custom-emoji.service';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
import {
CUSTOM_EMOJI_ACCEPT_ATTRIBUTE,
UNICODE_EMOJI_PICKER_ENTRIES,
@@ -33,12 +34,13 @@ import {
@Component({
selector: 'app-custom-emoji-picker',
standalone: true,
imports: [CommonModule, NgIcon],
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
viewProviders: [provideIcons({ lucidePlus, lucideSearch, lucideSmile, lucideUpload, lucideX })],
templateUrl: './custom-emoji-picker.component.html'
})
export class CustomEmojiPickerComponent {
private readonly customEmoji = inject(CustomEmojiService);
private readonly appI18n = inject(AppI18nService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
readonly currentUserId = input<string | null>(null);
@@ -154,7 +156,7 @@ export class CustomEmojiPickerComponent {
this.selectCustom(emoji);
} catch (error) {
this.uploadError.set(error instanceof Error ? error.message : 'Unable to upload emoji.');
this.uploadError.set(error instanceof Error ? error.message : this.appI18n.instant('emoji.picker.uploadFailed'));
} finally {
this.uploading.set(false);
}

View File

@@ -9,7 +9,8 @@ import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { MobileCallSessionService, MobileNotificationsService } from '../../../../infrastructure/mobile';
import { MobileCallSessionService, MobileMediaService, MobileNotificationsService } from '../../../../infrastructure/mobile';
import { initializeAppI18nForTests, provideAppI18nForTests } from '../../../../core/i18n/app-i18n.testing';
import { ViewportService } from '../../../../core/platform';
import {
VoiceActivityService,
@@ -564,9 +565,17 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
startActiveCall: vi.fn(async () => undefined),
endActiveCall: vi.fn(async () => undefined)
}
}
},
{
provide: MobileMediaService,
useValue: {
setSpeakerphoneEnabled: vi.fn(async () => undefined)
}
},
...provideAppI18nForTests()
]
});
initializeAppI18nForTests(injector);
return {
audio,

View File

@@ -8,6 +8,7 @@ import {
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { AppI18nService } from '../../../../core/i18n';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { ViewportService } from '../../../../core/platform';
import {
@@ -48,6 +49,7 @@ export class DirectCallService {
private readonly mobileNotifications = inject(MobileNotificationsService);
private readonly mobileCallSession = inject(MobileCallSessionService);
private readonly mobileMedia = inject(MobileMediaService);
private readonly i18n = inject(AppI18nService);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly users = this.store.selectSignal(selectAllUsers);
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
@@ -229,7 +231,7 @@ export class DirectCallService {
const peerId = conversation.participants.find((participantId) => participantId !== meId);
if (!peerId) {
throw new Error('Direct message conversation has no recipient to call.');
throw new Error(this.i18n.instant('call.errors.noRecipient'));
}
const peer = this.userForParticipant(peerId) ?? participantToUser(this.participantFromConversation(conversation, peerId));
@@ -992,7 +994,7 @@ export class DirectCallService {
return remoteNames.join(', ');
}
return 'Call in progress';
return this.i18n.instant('call.notifications.inProgress');
}
private uniqueParticipants(participants: DirectMessageParticipant[]): DirectMessageParticipant[] {
@@ -1026,7 +1028,7 @@ export class DirectCallService {
const user = this.currentUser();
if (!user) {
throw new Error('Cannot use calls without a current user.');
throw new Error(this.i18n.instant('call.errors.noCurrentUser'));
}
return user;

Some files were not shown because too many files have changed in this diff Show More