Building a project showcase is easier than it used to be. Building a community where people can understand and critique that work across languages is still difficult.
That distinction matters. A polished chat demo may translate one sample message correctly, but a real community must answer harder questions:
- What happens when translation fails?
- Can a late result overwrite a newer message?
- Does the reader still have access to the author's words?
- Are images, files, or other unsupported content offered a control that cannot work?
- Can users report the source message rather than an altered rendering?
The durable engineering skill here is not producing another chat screen. It is preserving authorship and user control while asynchronous features succeed, fail, and race.
In this tutorial, we will build the application-side state for a multilingual portfolio and project-critique community using Tencent RTC Social Messaging and TUIChat's on-demand message translation. Tencent RTC's Social Messaging solution covers scenarios including group discussions, large communities, rich media, and interest-based social experiences: Social Messaging solution.
The translation-specific implementation reference is TUIChat message translation. That documentation defines the supported content types, languages, and applicable edition limits. Check those current constraints for your target platform instead of hard-coding assumptions from this article.
The user contract comes before the translation call
Consider a community member reviewing a developer's project:
- The author posts a text description in a group.
- A reader chooses Translate and a target language.
- The UI shows progress without hiding the source.
- The translated text appears as a secondary view.
- The reader can reveal the original, retry a failure, or report the source message.
- If the message changes while translation is running, the old result is discarded.
The central invariant is:
A translation is a reader-specific rendering of a message, never a replacement for the canonical message.
That gives us a practical state model:
| State | Visible behavior | Allowed next actions |
|---|---|---|
idle |
Original message and Translate control | Request translation |
loading |
Original remains visible; progress is announced | Wait or change target language |
ready |
Original plus labeled translation | Show original, change language |
failed |
Original plus a non-destructive error | Retry or continue with original |
unsupported |
Original plus an explanation | No retry until content or capability changes |
Notice what is absent: there is no state in which the original disappears.
Create the reproducible TypeScript project
The coordinator below is SDK-independent, so we can test races without a network connection or production community.
mkdir community-translation-state
cd community-translation-state
npm init -y
npm install --save-dev typescript vitest @types/node
npm pkg set scripts.test="vitest run"
mkdir src
Add src/translation.ts:
export type CommunityMessage = {
id: string;
revision: number;
authorId: string;
kind: "text" | "image" | "file" | "custom";
text?: string;
};
export type TranslationView =
| { status: "idle" }
| { status: "loading"; requestId: number }
| { status: "ready"; translatedText: string }
| { status: "failed"; reason: FailureReason }
| { status: "unsupported"; reason: string };
export type FailureReason =
| "temporarily-unavailable"
| "invalid-response"
| "not-entitled";
export type TranslationInput = {
messageId: string;
sourceRevision: number;
sourceText: string;
targetLanguage: string;
};
// This is an application-owned port, not a Tencent RTC API name.
export interface TranslationGateway {
translate(input: TranslationInput): Promise<{ text: string }>;
}
export type TranslationPolicy = {
enabled: boolean;
supportedTargets: ReadonlySet<string>;
};
function cacheKey(message: CommunityMessage, target: string): string {
return `${message.id}:${message.revision}:${target}`;
}
export class TranslationCoordinator {
private readonly entries = new Map<string, TranslationView>();
private readonly latestRevision = new Map<string, number>();
private nextRequestId = 1;
constructor(
private readonly gateway: TranslationGateway,
private readonly policy: TranslationPolicy
) {}
sourceChanged(messageId: string, revision: number): void {
this.latestRevision.set(messageId, revision);
}
view(message: CommunityMessage, target: string): TranslationView {
return this.entries.get(cacheKey(message, target)) ?? { status: "idle" };
}
async request(message: CommunityMessage, target: string): Promise<void> {
this.sourceChanged(message.id, message.revision);
const key = cacheKey(message, target);
if (message.kind !== "text" || !message.text?.trim()) {
this.entries.set(key, {
status: "unsupported",
reason: "Only an eligible text message can be translated."
});
return;
}
if (!this.policy.enabled) {
this.entries.set(key, {
status: "unsupported",
reason: "Translation is not enabled for this deployment."
});
return;
}
if (!this.policy.supportedTargets.has(target)) {
this.entries.set(key, {
status: "unsupported",
reason: "That target language is not enabled."
});
return;
}
const existing = this.entries.get(key);
if (existing?.status === "loading" || existing?.status === "ready") {
return;
}
const requestId = this.nextRequestId++;
this.entries.set(key, { status: "loading", requestId });
try {
const result = await this.gateway.translate({
messageId: message.id,
sourceRevision: message.revision,
sourceText: message.text,
targetLanguage: target
});
const current = this.entries.get(key);
const revisionIsCurrent =
this.latestRevision.get(message.id) === message.revision;
if (
current?.status !== "loading" ||
current.requestId !== requestId ||
!revisionIsCurrent
) {
return;
}
if (!result.text.trim()) {
this.entries.set(key, {
status: "failed",
reason: "invalid-response"
});
return;
}
this.entries.set(key, {
status: "ready",
translatedText: result.text
});
} catch (error) {
const current = this.entries.get(key);
if (
current?.status !== "loading" ||
current.requestId !== requestId
) {
return;
}
this.entries.set(key, {
status: "failed",
reason:
error instanceof TranslationEntitlementError
? "not-entitled"
: "temporarily-unavailable"
});
}
}
}
export class TranslationEntitlementError extends Error {}
The coordinator deliberately stores translations by message ID, source revision, and target language. A result for revision 3 cannot be presented as the translation of revision 4.
It also deduplicates requests that are already loading or ready. This prevents repeated button presses from creating parallel work for the same view.
Prove the dangerous paths first
Create src/translation.test.ts:
import { describe, expect, it } from "vitest";
import {
TranslationCoordinator,
type TranslationGateway
} from "./translation";
const policy = {
enabled: true,
// Test identifiers only. Configure production values from the official docs.
supportedTargets: new Set(["target-a"])
};
const textMessage = {
id: "message-42",
revision: 1,
authorId: "author-7",
kind: "text" as const,
text: "Please review the keyboard navigation in my project."
};
describe("TranslationCoordinator", () => {
it("keeps a successful translation scoped to its source revision", async () => {
let finish!: (value: { text: string }) => void;
const gateway: TranslationGateway = {
translate: () => new Promise(resolve => {
finish = resolve;
})
};
const coordinator = new TranslationCoordinator(gateway, policy);
const pending = coordinator.request(textMessage, "target-a");
const edited = {
...textMessage,
revision: 2,
text: "Please review keyboard and screen-reader navigation."
};
coordinator.sourceChanged(edited.id, edited.revision);
finish({ text: "late translation of revision one" });
await pending;
expect(coordinator.view(edited, "target-a")).toEqual({
status: "idle"
});
});
it("marks non-text content unsupported without calling the gateway", async () => {
let calls = 0;
const gateway: TranslationGateway = {
async translate() {
calls += 1;
return { text: "unexpected" };
}
};
const coordinator = new TranslationCoordinator(gateway, policy);
const image = {
...textMessage,
kind: "image" as const,
text: undefined
};
await coordinator.request(image, "target-a");
expect(calls).toBe(0);
expect(coordinator.view(image, "target-a").status).toBe("unsupported");
});
it("exposes a retryable failure instead of removing the original", async () => {
const gateway: TranslationGateway = {
async translate() {
throw new Error("network unavailable");
}
};
const coordinator = new TranslationCoordinator(gateway, policy);
await coordinator.request(textMessage, "target-a");
expect(coordinator.view(textMessage, "target-a")).toEqual({
status: "failed",
reason: "temporarily-unavailable"
});
// The source is owned by the message view, not the translation state.
expect(textMessage.text).toContain("keyboard navigation");
});
it("does not request a target language outside deployment policy", async () => {
let calls = 0;
const gateway: TranslationGateway = {
async translate() {
calls += 1;
return { text: "unexpected" };
}
};
const coordinator = new TranslationCoordinator(gateway, policy);
await coordinator.request(textMessage, "unconfigured-target");
expect(calls).toBe(0);
expect(
coordinator.view(textMessage, "unconfigured-target").status
).toBe("unsupported");
});
});
Run the suite:
npm test
These tests verify application behavior, not translation quality. Language quality needs a separate review process involving fluent speakers and community-specific vocabulary.
Connect the state to TUIChat
There are two reasonable integration paths.
Path A: Use TUIChat's provided translation experience
Choose this when its supported UI and behavior fit your product. Follow the platform-specific setup in the official TUIChat message translation documentation, including its current content-type, language, and edition requirements.
You should still apply the product rules from this tutorial around the component:
- retain the canonical source message;
- keep reporting and moderation actions attached to the source message ID;
- avoid presenting translation as the author's exact wording;
- disable or omit controls for unsupported content;
- test behavior when the capability is unavailable.
Path B: Put a custom community UI around the capability
Choose this when your project showcase needs its own message cards, critique workflow, accessibility treatment, or translation cache policy.
Implement TranslationGateway in the platform integration layer. The exact Tencent RTC call and callback shape must come from the current documentation for your target platform; the interface below is intentionally application-owned rather than a claimed SDK API:
class TUIChatTranslationGateway implements TranslationGateway {
async translate(input: TranslationInput): Promise<{ text: string }> {
// 1. Invoke the documented TUIChat translation integration.
// 2. Correlate the response with input.messageId and input.sourceRevision.
// 3. Convert the documented result into { text }.
// 4. Convert entitlement/configuration failures into
// TranslationEntitlementError.
throw new Error("Wire this adapter using the official platform guide");
}
}
Keeping this adapter narrow is useful. Tencent RTC integration details remain at the boundary, while stale-result rejection, retries, and UI state stay deterministic and locally testable.
Do not populate supportedTargets from guesses. Build deployment configuration from the currently documented language list and the capability enabled for your Tencent RTC setup. If that configuration cannot be verified, hide the control rather than offering a button that predictably fails.
Render translations as annotations, not replacements
A message card should continue rendering message.text in every translation state. The translation block is additive:
<article aria-labelledby={`author-${message.id}`}>
<header id={`author-${message.id}`}>{authorDisplayName}</header>
<p lang={sourceLanguage}>{message.text}</p>
{state.status === "loading" && (
<p role="status">Translating… The original remains available.</p>
)}
{state.status === "ready" && (
<section aria-label="Translation">
<p className="translation-label">Translated text</p>
<p lang={targetLanguage}>{state.translatedText}</p>
</section>
)}
{state.status === "failed" && (
<div role="status">
Translation is unavailable. You can retry or read the original.
<button onClick={retry}>Retry translation</button>
</div>
)}
{state.status === "idle" && (
<button
onClick={requestTranslation}
aria-label={`Translate message from ${authorDisplayName}`}
>
Translate
</button>
)}
</article>
For bidirectional text, derive direction from reviewed locale metadata and set dir on the relevant source and translation containers. Do not infer direction from the characters in a single message.
Also avoid announcing an entire translated paragraph through a live region. Announce the status change, then let the reader navigate to the translation normally. Otherwise a long critique may interrupt a screen-reader user's current task.
Failure behavior is part of the community design
A message is edited while translation is loading
Advance the application's message revision and call sourceChanged. The late translation is ignored. Offer translation again for the new revision.
If your messaging layer does not expose revisions, maintain an application-side content version when processing message events. A content hash is another option, but do not use raw message text in logs or analytics merely to support correlation.
A message is deleted while translation is loading
Remove the message card and invalidate its local state. A late result must not recreate deleted content. Server-side deletion and retention policy remain authoritative.
Translation is unavailable for the deployment
Treat this as a capability/configuration state, not an endless network retry. Hide or disable the control with a useful explanation for operators. Confirm current edition limits in the official documentation rather than assuming every deployment has the same feature set.
The content type cannot be translated
Do not send image captions, file metadata, or custom message payloads through a text path unless the official capability explicitly supports that content and your privacy policy permits it. An absent control is clearer than a failing one.
The result is fluent but misleading
A successful API response is not proof of semantic accuracy. Keep the original one action away, label the translation, and provide a correction or reporting path suitable for your community.
For moderation, retain the source message ID, author ID, and source content under your normal access controls. A moderator may need both source and translated views, but the translated rendering should not silently become evidence of what the author literally wrote.
The reader changes target language rapidly
Use a separate cache key per target language and render only the currently selected key. Consider limiting concurrent requests in the UI, but do not erase a previously successful translation merely because another target failed.
When to use built-in UI and when to own more state
Use the provided TUIChat experience when:
- its supported message types and languages match the community;
- the standard interaction fits the product;
- minimizing custom maintenance is more important than bespoke presentation.
Add an application coordinator when:
- messages can be edited while asynchronous work is pending;
- your UI supports several target-language views;
- accessibility requirements need custom announcements or layout;
- you need explicit retry, telemetry, or cache policy;
- project critique, reporting, or moderation must remain tied to canonical content.
The trade-off is straightforward: custom state gives you clearer product guarantees, but your team becomes responsible for lifecycle testing and accessible rendering. It does not make the translation engine more accurate.
Verification checklist
Before enabling translation in a community, verify all of the following:
- [ ] The deployment's supported content types, languages, and edition requirements were checked against the current official documentation.
- [ ] Translation is initiated by a visible user action rather than silently replacing every message.
- [ ] The source message remains available during loading, success, and failure.
- [ ] A result from an old message revision cannot appear under a newer revision.
- [ ] Deleted messages cannot reappear through late translation callbacks.
- [ ] Duplicate clicks do not create duplicate requests for the same revision and target.
- [ ] Unsupported content does not reach the translation integration.
- [ ] Empty or malformed results are treated as failures.
- [ ] Reporting and moderation retain the canonical message ID.
- [ ] Source and target language containers have reviewed
langanddirmetadata. - [ ] Loading and failure state changes are accessible without announcing entire messages unexpectedly.
- [ ] Logs contain IDs, state transitions, and error categories—not private message bodies.
- [ ] Fluent speakers have reviewed representative community vocabulary and failure copy.
A multilingual feature does not need to pretend that language differences have disappeared. It needs to make participation easier without blurring who said what. That is the difference between a convincing showcase and community infrastructure people can actually trust.
Discussion
If you were adding translation to a developer community, would you cache successful translations per message revision, or always request a fresh rendering? What retention and correction rules would make that decision acceptable for your users?
Relationship disclosure: I prepared this article in connection with Tencent RTC developer content. Official Tencent RTC Social Messaging and TUIChat documentation was used as the implementation reference.
Top comments (0)