🆔 feat: Built-in Build Metadata for Support Triage (#12756)

* 🏗️ refactor: Derive App Version from Root package.json + Add buildInfo Schema

The hardcoded `Constants.VERSION` in `data-provider` is now replaced at
rollup build time via `@rollup/plugin-replace`, sourcing from the root
`package.json` so version bumps are a single-file change.

Adds the shape needed by the rest of the series:
- `interface.buildInfo` boolean flag (default `true`) — lets self-hosters
  opt out of exposing commit/branch/date.
- `buildInfo` on `TStartupConfig` — commit/commitShort/branch/buildDate.
- `SettingsTabValues.ABOUT` — new settings tab enum value.

Ref: https://github.com/danny-avila/LibreChat/issues/12406

* 🛠️ feat: Add Build Metadata Resolver and Expose via /api/config

Adds `resolveBuildInfo()` in `@librechat/api` that surfaces commit SHA,
branch, and build date from (in order) `BUILD_*` env vars, then local git
metadata. Result is cached per-process.

`/api/config` includes a `buildInfo` field on both authenticated and
anonymous responses when `interface.buildInfo !== false` and at least one
resolver field is populated. Omitted entirely otherwise.

Designed so pre-built Docker images carry metadata via build-arg while
source installs pick it up from `.git` — no manual version tracking.

Ref: https://github.com/danny-avila/LibreChat/issues/12406

* ℹ️ feat: Add Settings → About Panel with Diagnostics Copy

New Settings tab that renders the running build's version, commit (short
SHA), branch, and build date in a monospaced block alongside a "Copy
diagnostics" button that emits a preformatted text blob for pasting into
support issues.

Tab is hidden when `interface.buildInfo` is set to `false`. Reads from
`startupConfig.buildInfo` provided by `/api/config`.

Ref: https://github.com/danny-avila/LibreChat/issues/12406

* 🐳 ci: Inject BUILD_COMMIT/BRANCH/DATE into Docker Images

Adds optional `BUILD_COMMIT`, `BUILD_BRANCH`, `BUILD_DATE` ARGs to both
`Dockerfile` and `Dockerfile.multi`, wired as `ENV` vars in the runtime
stage so the backend's `resolveBuildInfo` picks them up.

All image-publishing workflows (`tag`, `main`, `dev`, `dev-branch`,
`dev-staging`) now compute `${github.sha}`, `${github.ref_name}`, and a
UTC timestamp, then pass them to `docker/build-push-action` as
`build-args`.

Defaults are empty — non-CI builds (local `docker build`) still work,
and the backend falls back to local `.git` metadata if ARGs aren't set.

Ref: https://github.com/danny-avila/LibreChat/issues/12406

* 📝 docs: Direct Bug Reporters to Settings → About for Version Info

The previous instructions (`docker images | grep librechat`,
`git rev-parse HEAD`) only worked for a subset of deployments and
rarely produced a commit SHA for users pulling pre-built images.

Point users to the new in-app Settings → About panel's
"Copy diagnostics" button, which captures version, commit, branch,
build date, and user agent in a single preformatted block. Fallback
instructions preserved for older installs.

Ref: https://github.com/danny-avila/LibreChat/issues/12406

* 🐳 fix: Move BUILD_* ENV to End of Docker Stages to Preserve Layer Cache

Per-commit BUILD_COMMIT/BUILD_DATE changes were being promoted to ENV
before `npm ci` / `npm run frontend` (single-stage) and before
`npm ci --omit=dev` (multi-stage api-build), which invalidated the cache
for every subsequent layer on every CI run.

Move the ARG/ENV block below the heavy install and build steps in both
Dockerfiles. Metadata is still available in the runtime image but no
longer busts layer reuse.

Addresses codex review on #12756.

* 🔧 fix: Propagate interface.buildInfo=false to Unauthenticated /api/config

The unauthenticated branch of `/api/config` was emitting an `interface`
object only when `privacyPolicy` or `termsOfService` was set, which
meant an admin's explicit `interface.buildInfo: false` opt-out was never
visible to anonymous/guest clients. `Settings.tsx` gates the About tab
on `startupConfig?.interface?.buildInfo !== false`, so a missing field
fell through as "enabled" for those clients.

Include `interface.buildInfo: false` in the unauth payload whenever it's
explicitly disabled. Keep the implicit default (true) absent to preserve
the minimal-unauth-payload convention.

Addresses codex review on #12756.

* 🔀 ci: Trigger Dev Image Workflows on Root package.json + Dockerfile Changes

The baked `Constants.VERSION` now reads from the root `package.json` via
rollup-plugin-replace, but the `dev-images.yml` and `dev-branch-images.yml`
path filters only matched `api/**`, `client/**`, `packages/**`. A release
commit that only bumps root `package.json` would not trigger a rebuild,
leaving `latest` dev images with stale Footer/About version metadata.

Include `package.json`, `package-lock.json`, and both Dockerfiles in the
path filters so dependency changes (lockfile rebuilds) and image build
tweaks also rebuild dev images.

Addresses codex review on #12756.

* 🧽 fix: Harden About Panel Lifecycle, A11y, and Loading Gate

Review follow-ups on #12756:

- #1 timer leak: stash the copy-state `setTimeout` in a ref and clear it
  from a `useEffect` cleanup so unmounting the Settings dialog mid-toast
  doesn't fire `setCopied(false)` on an unmounted component.
- #3 flash of About tab: gate `aboutEnabled` on `startupConfig != null`
  so the tab stays hidden until `/api/config` returns. For admins who
  disabled `interface.buildInfo`, the tab no longer briefly appears and
  vanishes on page load.
- #6 aria-live placement: move the live region off the interactive
  button onto a dedicated `<span role="status" aria-live="polite">` so
  screen readers announce the copied state, not the full button content
  on every re-render.
- #2 missing coverage: add `About.spec.tsx` exercising populated/empty
  buildInfo rendering, invalid-date handling, diagnostics clipboard
  payload, copy-state toggling, unmount cleanup, and the live region.

* ⚡ perf: Eagerly Resolve Build Info at Module Load

Review follow-up #4 on #12756: `resolveBuildInfo()` calls `execFileSync`
with a 2s timeout on source installs without `BUILD_*` env vars. Paying
this cost on the first HTTP request blocks the event loop mid-flight.

Call `resolveBuildInfo()` once at config route module load so the
resolver's cache is warm before any request arrives. Docker images with
the BUILD_* env vars set sidestep the git path entirely, so this only
affects the edge case of source installs.

* 📝 docs: Document rollup Version Placeholder Contract

Review follow-ups #5 / #8 on #12756. The `__LIBRECHAT_VERSION__`
placeholder relies on a substring replacement rule that only works
because the token appears inside a string literal, and the substitution
only runs during `npm run build:data-provider`.

- Expand the `Constants.VERSION` JSDoc to spell out that consumers read
  the placeholder through the built dist bundle; source-level test
  imports would see the raw placeholder.
- Add a NOTE above the rollup `replace` config warning future
  contributors not to repurpose the token as a bare identifier without
  switching to a quoted replacement value.

Non-functional; prevents future contributors from stepping on a subtle
constraint.

* 🪪 fix: Only Toast "Copied" When Clipboard Copy Actually Succeeds

Codex R5 on #12756. `copy-to-clipboard` returns a boolean indicating
whether the underlying `execCommand('copy')` / fallback prompt actually
wrote to the clipboard. The previous handler flipped to the "Copied"
state unconditionally, which in hardened browsers or when the
permission prompt is dismissed would mislead users into filing bug
reports without the diagnostics blob attached.

Gate the state/timer/live-region on the boolean return; silently no-op
on failure rather than showing a false positive. Adds a test asserting
the button label stays at "Copy diagnostics" when the clipboard call
fails.

* 🐳 fix: Derive main image metadata from checkout

* 🪪 fix: Keep About enabled until disabled

* ✅ test: Avoid literal Settings mock text

* 🧱 refactor: Rename Build Info Module
This commit is contained in:
Danny Avila 2026-05-23 09:41:13 -04:00 • committed by GitHub
parent 1746153c17
commit 6d6ea08da4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 852 additions and 18 deletions

View file

@ -0,0 +1,85 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Settings from './Settings';
const mockUseGetStartupConfig = jest.fn();
jest.mock('~/data-provider', () => ({
useGetStartupConfig: () => mockUseGetStartupConfig(),
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
jest.mock('~/hooks/usePersonalizationAccess', () => ({
__esModule: true,
default: () => ({
hasMemoryOptOut: false,
hasAnyPersonalizationFeature: false,
}),
}));
jest.mock('@librechat/client', () => ({
GearIcon: () => <span aria-hidden="true" />,
DataIcon: () => <span aria-hidden="true" />,
UserIcon: () => <span aria-hidden="true" />,
SpeechIcon: () => <span aria-hidden="true" />,
PersonalizationIcon: () => <span aria-hidden="true" />,
useMediaQuery: () => false,
}));
jest.mock('./SettingsTabs', () => ({
General: () => <div data-testid="general-panel" />,
Chat: () => <div data-testid="chat-panel" />,
Commands: () => <div data-testid="commands-panel" />,
Speech: () => <div data-testid="speech-panel" />,
Personalization: () => <div data-testid="personalization-panel" />,
Data: () => <div data-testid="data-panel" />,
Balance: () => <div data-testid="balance-panel" />,
Account: () => <div data-testid="account-panel" />,
About: () => <div data-testid="about-panel" />,
}));
function renderSettings() {
return render(<Settings open={true} onOpenChange={jest.fn()} />);
}
beforeEach(() => {
mockUseGetStartupConfig.mockReturnValue({ data: {} });
});
describe('Settings', () => {
it('shows the About tab while startup config is loading', () => {
mockUseGetStartupConfig.mockReturnValue({ data: undefined });
renderSettings();
expect(screen.getByText('com_nav_setting_about')).toBeInTheDocument();
});
it('hides the About tab only when buildInfo is explicitly disabled', () => {
mockUseGetStartupConfig.mockReturnValue({ data: { interface: { buildInfo: false } } });
renderSettings();
expect(screen.queryByText('com_nav_setting_about')).not.toBeInTheDocument();
});
it('resets the active tab when loaded config disables About', async () => {
const user = userEvent.setup();
const { rerender } = renderSettings();
await user.click(screen.getByText('com_nav_setting_about'));
expect(screen.getByTestId('about-panel')).toBeInTheDocument();
mockUseGetStartupConfig.mockReturnValue({ data: { interface: { buildInfo: false } } });
rerender(<Settings open={true} onOpenChange={jest.fn()} />);
await waitFor(() => {
expect(screen.queryByTestId('about-panel')).not.toBeInTheDocument();
});
expect(screen.getByTestId('general-panel')).toBeInTheDocument();
});
});

View file

@ -1,7 +1,7 @@
import React, { useState, useRef } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import * as Tabs from '@radix-ui/react-tabs';
import { SettingsTabValues } from 'librechat-data-provider';
import { MessageSquare, Command, DollarSign } from 'lucide-react';
import { MessageSquare, Command, DollarSign, Info } from 'lucide-react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import {
GearIcon,
@ -21,6 +21,7 @@ import {
Data,
Balance,
Account,
About,
} from './SettingsTabs';
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
import { useLocalize, TranslationKeys } from '~/hooks';
@ -34,6 +35,13 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL);
const tabRefs = useRef({});
const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess();
const aboutEnabled = startupConfig?.interface?.buildInfo !== false;
useEffect(() => {
if (!aboutEnabled && activeTab === SettingsTabValues.ABOUT) {
setActiveTab(SettingsTabValues.GENERAL);
}
}, [aboutEnabled, activeTab]);
const handleKeyDown = (event: React.KeyboardEvent) => {
const tabs: SettingsTabValues[] = [
@ -45,6 +53,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
SettingsTabValues.DATA,
...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []),
SettingsTabValues.ACCOUNT,
...(aboutEnabled ? [SettingsTabValues.ABOUT] : []),
];
const currentIndex = tabs.indexOf(activeTab);
@ -121,6 +130,15 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
icon: <UserIcon />,
label: 'com_nav_setting_account',
},
...(aboutEnabled
? [
{
value: SettingsTabValues.ABOUT,
icon: <Info className="icon-sm" aria-hidden="true" />,
label: 'com_nav_setting_about' as TranslationKeys,
},
]
: ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])),
];
const handleTabChange = (value: string) => {
@ -251,6 +269,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
<Account />
</Tabs.Content>
{aboutEnabled && (
<Tabs.Content value={SettingsTabValues.ABOUT} tabIndex={-1}>
<About />
</Tabs.Content>
)}
</div>
</Tabs.Root>
</div>

View file

@ -0,0 +1,170 @@
import React from 'react';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Constants } from 'librechat-data-provider';
import type { TStartupConfig } from 'librechat-data-provider';
import About from './About';
const mockCopy = jest.fn<boolean, unknown[]>();
jest.mock('copy-to-clipboard', () => ({
__esModule: true,
default: (...args: unknown[]) => mockCopy(...args),
}));
const mockUseGetStartupConfig = jest.fn();
jest.mock('~/data-provider', () => ({
useGetStartupConfig: () => mockUseGetStartupConfig(),
}));
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
}));
const populatedBuildInfo: NonNullable<TStartupConfig['buildInfo']> = {
commit: 'abcdef1234567890abcdef1234567890abcdef12',
commitShort: 'abcdef1',
branch: 'dev',
buildDate: '2026-04-20T12:00:00Z',
};
beforeEach(() => {
mockCopy.mockReset();
mockCopy.mockReturnValue(true);
mockUseGetStartupConfig.mockReturnValue({ data: { buildInfo: populatedBuildInfo } });
});
describe('About', () => {
describe('rendering', () => {
it('renders version, commit (short), branch, and build date when buildInfo is populated', () => {
render(<About />);
expect(screen.getByText(Constants.VERSION as string)).toBeInTheDocument();
expect(screen.getByText('abcdef1')).toBeInTheDocument();
expect(screen.getByText('dev')).toBeInTheDocument();
expect(screen.getByText('2026-04-20 12:00:00 UTC')).toBeInTheDocument();
});
it('renders em-dash placeholders when buildInfo is missing', () => {
mockUseGetStartupConfig.mockReturnValue({ data: {} });
render(<About />);
// version still populated from Constants.VERSION; the other three rows fall back to placeholder
const placeholders = screen.getAllByText('—');
expect(placeholders.length).toBeGreaterThanOrEqual(3);
});
it('renders em-dash placeholders when startupConfig is undefined (initial load)', () => {
mockUseGetStartupConfig.mockReturnValue({ data: undefined });
render(<About />);
const placeholders = screen.getAllByText('—');
expect(placeholders.length).toBeGreaterThanOrEqual(3);
});
it('shows the raw string when buildDate is not a valid ISO', () => {
mockUseGetStartupConfig.mockReturnValue({
data: { buildInfo: { ...populatedBuildInfo, buildDate: 'not-a-date' } },
});
render(<About />);
expect(screen.getByText('not-a-date')).toBeInTheDocument();
});
});
describe('copy diagnostics', () => {
it('writes a preformatted diagnostics blob to the clipboard on click', async () => {
const user = userEvent.setup();
render(<About />);
await user.click(screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }));
expect(mockCopy).toHaveBeenCalledTimes(1);
const [blob, options] = mockCopy.mock.calls[0] as [string, { format: string }];
expect(options).toEqual({ format: 'text/plain' });
expect(blob).toContain(`LibreChat version: ${Constants.VERSION}`);
expect(blob).toContain(`Commit: ${populatedBuildInfo.commit}`);
expect(blob).toContain(`Branch: ${populatedBuildInfo.branch}`);
expect(blob).toContain('Build date: 2026-04-20 12:00:00 UTC');
expect(blob).toContain('User agent: ');
});
it('writes em-dash placeholders into the blob when buildInfo is missing', async () => {
mockUseGetStartupConfig.mockReturnValue({ data: {} });
const user = userEvent.setup();
render(<About />);
await user.click(screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }));
const [blob] = mockCopy.mock.calls[0] as [string];
expect(blob).toContain('Commit: —');
expect(blob).toContain('Branch: —');
expect(blob).toContain('Build date: —');
});
it('does not flip to "Copied" when copy-to-clipboard reports failure', async () => {
mockCopy.mockReturnValue(false);
const user = userEvent.setup();
render(<About />);
await user.click(screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }));
expect(
screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }),
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /com_nav_about_diagnostics_copied/i }),
).not.toBeInTheDocument();
expect(screen.getByRole('status').textContent).toBe('');
});
it('toggles the button label to "Copied" after click and resets after the timer', async () => {
jest.useFakeTimers();
try {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(<About />);
await user.click(screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }));
expect(
screen.getByRole('button', { name: /com_nav_about_diagnostics_copied/i }),
).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(2000);
});
expect(
screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }),
).toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});
it('clears the pending reset timer on unmount (no state update on unmounted component)', async () => {
jest.useFakeTimers();
const clearSpy = jest.spyOn(globalThis, 'clearTimeout');
try {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const { unmount } = render(<About />);
await user.click(screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }));
clearSpy.mockClear();
unmount();
expect(clearSpy).toHaveBeenCalled();
} finally {
clearSpy.mockRestore();
jest.useRealTimers();
}
});
});
describe('status live region', () => {
it('announces the copied state via a dedicated sr-only live region', async () => {
const user = userEvent.setup();
render(<About />);
const status = screen.getByRole('status');
expect(status.textContent).toBe('');
await user.click(screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }));
expect(status.textContent).toMatch(/com_nav_about_diagnostics_copied/);
});
});
});

View file

@ -0,0 +1,138 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import copy from 'copy-to-clipboard';
import { Check, Copy } from 'lucide-react';
import { Constants } from 'librechat-data-provider';
import type { TStartupConfig } from 'librechat-data-provider';
import { useGetStartupConfig } from '~/data-provider';
import { useLocalize } from '~/hooks';
const UNKNOWN_PLACEHOLDER = '—';
function formatBuildDate(raw: string | null | undefined): string {
if (!raw) {
return UNKNOWN_PLACEHOLDER;
}
const date = new Date(raw);
if (Number.isNaN(date.getTime())) {
return raw;
}
return date
.toISOString()
.replace('T', ' ')
.replace(/\.\d{3}Z$/, ' UTC');
}
function buildDiagnosticsBlob(
version: string,
buildInfo: TStartupConfig['buildInfo'] | undefined,
): string {
const lines: string[] = [
`LibreChat version: ${version}`,
`Commit: ${buildInfo?.commit ?? UNKNOWN_PLACEHOLDER}`,
`Branch: ${buildInfo?.branch ?? UNKNOWN_PLACEHOLDER}`,
`Build date: ${formatBuildDate(buildInfo?.buildDate)}`,
`User agent: ${typeof navigator !== 'undefined' ? navigator.userAgent : UNKNOWN_PLACEHOLDER}`,
];
return lines.join('\n');
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 py-1.5">
<div className="text-text-secondary">{label}</div>
<div className="break-all text-right font-mono text-xs text-text-primary">{value}</div>
</div>
);
}
function About() {
const localize = useLocalize();
const { data: startupConfig } = useGetStartupConfig();
const [copied, setCopied] = useState(false);
const copyResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const buildInfo = startupConfig?.buildInfo;
const version: string = Constants.VERSION;
const diagnosticsBlob = useMemo(
() => buildDiagnosticsBlob(version, buildInfo),
[version, buildInfo],
);
useEffect(
() => () => {
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current);
}
},
[],
);
const onCopy = useCallback(() => {
const succeeded = copy(diagnosticsBlob, { format: 'text/plain' });
if (!succeeded) {
return;
}
setCopied(true);
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current);
}
copyResetTimerRef.current = setTimeout(() => setCopied(false), 2000);
}, [diagnosticsBlob]);
return (
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
<section aria-labelledby="about-version-heading" className="flex flex-col">
<h3 id="about-version-heading" className="mb-2 text-sm font-medium text-text-primary">
{localize('com_nav_about_version_heading')}
</h3>
<div className="rounded-lg border border-border-light bg-surface-secondary p-3">
<Row label={localize('com_nav_about_version')} value={version} />
<Row
label={localize('com_nav_about_commit')}
value={buildInfo?.commitShort ?? UNKNOWN_PLACEHOLDER}
/>
<Row
label={localize('com_nav_about_branch')}
value={buildInfo?.branch ?? UNKNOWN_PLACEHOLDER}
/>
<Row
label={localize('com_nav_about_build_date')}
value={formatBuildDate(buildInfo?.buildDate)}
/>
</div>
</section>
<section aria-labelledby="about-diagnostics-heading" className="flex flex-col">
<h3 id="about-diagnostics-heading" className="mb-2 text-sm font-medium text-text-primary">
{localize('com_nav_about_diagnostics_heading')}
</h3>
<p className="mb-2 text-xs text-text-secondary">
{localize('com_nav_about_diagnostics_description')}
</p>
<button
type="button"
onClick={onCopy}
className="inline-flex items-center justify-center gap-2 self-start rounded-md border border-border-light bg-surface-secondary px-3 py-1.5 text-xs font-medium text-text-primary transition-colors hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy"
>
{copied ? (
<>
<Check className="h-4 w-4" aria-hidden="true" />
{localize('com_nav_about_diagnostics_copied')}
</>
) : (
<>
<Copy className="h-4 w-4" aria-hidden="true" />
{localize('com_nav_about_diagnostics_copy')}
</>
)}
</button>
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">
{copied ? localize('com_nav_about_diagnostics_copied') : ''}
</span>
</section>
</div>
);
}
export default React.memo(About);

View file

@ -6,3 +6,4 @@ export { default as General } from './General/General';
export { default as Account } from './Account/Account';
export { default as Commands } from './Commands/Commands';
export { default as Personalization } from './Personalization';
export { default as About } from './About/About';

View file

@ -577,6 +577,16 @@
"com_nav_scroll_button": "Scroll to the end button",
"com_nav_search_placeholder": "Search messages",
"com_nav_send_message": "Send message",
"com_nav_about_branch": "Branch",
"com_nav_about_build_date": "Built",
"com_nav_about_commit": "Commit",
"com_nav_about_diagnostics_copied": "Copied to clipboard",
"com_nav_about_diagnostics_copy": "Copy diagnostics",
"com_nav_about_diagnostics_description": "Copy this block when opening a support issue so maintainers can identify the exact build you're running.",
"com_nav_about_diagnostics_heading": "Diagnostics",
"com_nav_about_version": "Version",
"com_nav_about_version_heading": "Build information",
"com_nav_setting_about": "About",
"com_nav_setting_account": "Account",
"com_nav_setting_balance": "Balance",
"com_nav_setting_chat": "Chat",