feat(chat): add i18n support and filter self typing indicator

Add a `chat` i18n namespace (FR/EN) and replace all hardcoded strings
in chat components with useTranslation calls. Introduce a `labels` prop
on ChatProvider so chat-ui renders translated typing indicators,
placeholders, and aria labels. Also filter out typing events from the
current user on the client side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-14 14:15:00 +02:00
parent 2eb7cc5183
commit 14688afdeb
No known key found for this signature in database
10 changed files with 84 additions and 23 deletions

View file

@ -1,10 +1,11 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
ChatProvider,
ChatMessages as ChatMessageList,
ChatComposer,
} from "@xtablo/chat-ui";
import type { ChatMessageData, ChatUser, TypingUser } from "@xtablo/chat-ui";
import type { ChatLabels, ChatMessageData, ChatUser, TypingUser } from "@xtablo/chat-ui";
interface ChatMessage {
id: string;
@ -45,8 +46,9 @@ export function ChatMessages({
onLoadMore: _onLoadMore,
onSend,
onTyping,
placeholder = "Envoyer un message...",
placeholder,
}: ChatMessagesProps) {
const { t } = useTranslation("chat");
const membersById = useMemo(() => {
const map = new Map<string, Member>();
for (const m of members) {
@ -58,10 +60,10 @@ export function ChatMessages({
const currentUser = useMemo<ChatUser>(
() => ({
id: currentUserId,
name: membersById.get(currentUserId)?.name ?? "Moi",
name: membersById.get(currentUserId)?.name ?? t("currentUserName"),
avatar: membersById.get(currentUserId)?.avatar_url ?? undefined,
}),
[currentUserId, membersById],
[currentUserId, membersById, t],
);
const chatMessages = useMemo<ChatMessageData[]>(
@ -73,29 +75,41 @@ export function ChatMessages({
return {
id: msg.id,
senderId: userId,
senderName: member?.name ?? "Utilisateur",
senderName: member?.name ?? t("defaultUserName"),
senderAvatar: member?.avatar_url ?? undefined,
text: msg.text,
timestamp: new Date(createdAt),
status: msg.optimistic ? "sending" : undefined,
};
}),
[messages, membersById],
[messages, membersById, t],
);
const chatTypingUsers = useMemo<TypingUser[]>(
() =>
typingUsers.map((userId) => ({
id: userId,
name: membersById.get(userId)?.name ?? "Utilisateur",
name: membersById.get(userId)?.name ?? t("defaultUserName"),
avatar: membersById.get(userId)?.avatar_url ?? undefined,
})),
[typingUsers, membersById],
[typingUsers, membersById, t],
);
const chatLabels = useMemo<ChatLabels>(
() => ({
composerPlaceholder: t("placeholder"),
typingOne: t("typingOne"),
typingTwo: t("typingTwo"),
typingMany: t("typingMany"),
scrollToBottom: t("scrollToBottom"),
}),
[t],
);
return (
<ChatProvider
currentUser={currentUser}
labels={chatLabels}
className="flex h-full flex-col"
>
<ChatMessageList
@ -107,7 +121,7 @@ export function ChatMessages({
onTyping={(_isTyping) => {
if (_isTyping) onTyping();
}}
placeholder={placeholder}
placeholder={placeholder ?? t("placeholder")}
/>
</ChatProvider>
);

View file

@ -1,5 +1,6 @@
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useChat } from "../hooks/useChat";
import { useTabloMembers } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
@ -12,6 +13,7 @@ interface TabloDiscussionSectionProps {
}
export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectionProps) => {
const { t } = useTranslation("chat");
const user = useUser();
const {
messages,
@ -36,8 +38,8 @@ export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectio
<div className="flex flex-col h-full overflow-hidden">
<div className="flex justify-between items-start mb-4 shrink-0">
<div>
<h1 className="text-3xl font-bold text-foreground">Discussion</h1>
<p className="text-muted-foreground mt-1">Conversations liées à ce tablo</p>
<h1 className="text-3xl font-bold text-foreground">{t("discussionTitle")}</h1>
<p className="text-muted-foreground mt-1">{t("discussionSubtitle")}</p>
</div>
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
</div>

View file

@ -107,6 +107,7 @@ export function useChat(channelId: string | undefined) {
break;
case "typing":
if (msg.userId === session?.user?.id) break;
setTypingUsers((prev) =>
msg.isTyping
? prev.includes(msg.userId) ? prev : [...prev, msg.userId]

View file

@ -3,6 +3,7 @@ import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import authEn from "./locales/en/auth.json";
import availabilitiesEn from "./locales/en/availabilities.json";
import chatEn from "./locales/en/chat.json";
import commonEn from "./locales/en/common.json";
import componentsEn from "./locales/en/components.json";
import modalsEn from "./locales/en/modals.json";
@ -15,6 +16,7 @@ import settingsEn from "./locales/en/settings.json";
import tabloEn from "./locales/en/tablo.json";
import authFr from "./locales/fr/auth.json";
import availabilitiesFr from "./locales/fr/availabilities.json";
import chatFr from "./locales/fr/chat.json";
// Import translation files
import commonFr from "./locales/fr/common.json";
import componentsFr from "./locales/fr/components.json";
@ -45,6 +47,7 @@ i18n
notes: notesFr,
tablo: tabloFr,
onboarding: onboardingFr,
chat: chatFr,
},
en: {
common: commonEn,
@ -59,6 +62,7 @@ i18n
notes: notesEn,
tablo: tabloEn,
onboarding: onboardingEn,
chat: chatEn,
},
},
lng: "fr",

View file

@ -0,0 +1,13 @@
{
"pageTitle": "Discussions",
"placeholder": "Type a message...",
"selectConversation": "Select a conversation to start chatting",
"defaultUserName": "User",
"currentUserName": "Me",
"discussionTitle": "Discussion",
"discussionSubtitle": "Conversations related to this tablo",
"typingOne": "{{name}} is typing",
"typingTwo": "{{name1}} and {{name2}} are typing",
"typingMany": "Several people are typing",
"scrollToBottom": "Scroll to bottom"
}

View file

@ -0,0 +1,13 @@
{
"pageTitle": "Discussions",
"placeholder": "Envoyer un message...",
"selectConversation": "Sélectionnez une conversation pour commencer",
"defaultUserName": "Utilisateur",
"currentUserName": "Moi",
"discussionTitle": "Discussion",
"discussionSubtitle": "Conversations liées à ce tablo",
"typingOne": "{{name}} est en train d'écrire",
"typingTwo": "{{name1}} et {{name2}} sont en train d'écrire",
"typingMany": "Plusieurs personnes sont en train d'écrire",
"scrollToBottom": "Aller en bas"
}

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { ChatChannelPreview } from "../components/ChatChannelPreview";
import { ChatHeader } from "../components/ChatHeader";
@ -9,6 +10,7 @@ import { useTablosList, useTabloMembers } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
export function ChatPage() {
const { t } = useTranslation("chat");
const user = useUser();
const { channelId } = useParams();
const navigate = useNavigate();
@ -44,7 +46,7 @@ export function ChatPage() {
return (
<div className="flex flex-col h-[calc(100vh-75px)] bg-gray-50 dark:bg-background">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/40 shrink-0">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Discussions</h1>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t("pageTitle")}</h1>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Channel list sidebar */}
@ -87,13 +89,13 @@ export function ChatPage() {
onLoadMore={loadMoreMessages}
onSend={sendMessage}
onTyping={sendTyping}
placeholder="Type a message..."
placeholder={t("placeholder")}
/>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
Select a conversation to start chatting
{t("selectConversation")}
</div>
)}
</div>

View file

@ -27,6 +27,7 @@ import { createPortal } from "react-dom"
import type {
ChatUser,
ChatConfig,
ChatLabels,
ChatMessageData,
MessageGroup,
TypingUser,
@ -56,6 +57,7 @@ interface ChatProviderProps {
currentUser: ChatUser
dateFormat?: "relative" | "absolute" | "time-only"
messageGroupingInterval?: number
labels?: ChatLabels
onReactionAdd?: (messageId: string, emoji: string) => void
onReactionRemove?: (messageId: string, emoji: string) => void
onReply?: (message: ChatMessageData) => void
@ -71,6 +73,7 @@ function ChatProvider({
currentUser,
dateFormat = "relative",
messageGroupingInterval = 120,
labels,
onReactionAdd,
onReactionRemove,
onReply,
@ -86,6 +89,7 @@ function ChatProvider({
currentUser,
dateFormat,
messageGroupingInterval,
labels,
onReactionAdd,
onReactionRemove,
onReply,
@ -93,7 +97,7 @@ function ChatProvider({
onDelete,
onPin,
}),
[currentUser, dateFormat, messageGroupingInterval, onReactionAdd, onReactionRemove, onReply, onEdit, onDelete, onPin]
[currentUser, dateFormat, messageGroupingInterval, labels, onReactionAdd, onReactionRemove, onReply, onEdit, onDelete, onPin]
)
return (
@ -879,14 +883,15 @@ interface ChatTypingIndicatorProps {
}
function ChatTypingIndicator({ users, className }: ChatTypingIndicatorProps) {
const { labels } = useChatContext()
if (users.length === 0) return null
const label =
users.length === 1
? `${users[0]!.name} is typing`
? (labels?.typingOne?.replace("{{name}}", users[0]!.name) ?? `${users[0]!.name} is typing`)
: users.length === 2
? `${users[0]!.name} and ${users[1]!.name} are typing`
: "Several people are typing"
? (labels?.typingTwo?.replace("{{name1}}", users[0]!.name).replace("{{name2}}", users[1]!.name) ?? `${users[0]!.name} and ${users[1]!.name} are typing`)
: (labels?.typingMany ?? "Several people are typing")
return (
<div
@ -989,7 +994,7 @@ function ChatMessages({
typingUsers = [],
className,
}: ChatMessagesProps) {
const { currentUser, messageGroupingInterval } = useChatContext()
const { currentUser, messageGroupingInterval, labels } = useChatContext()
const { containerRef, scrollToBottom, isAtBottom, unseenCount } =
useAutoScroll(messages)
@ -1055,10 +1060,7 @@ function ChatMessages({
? "pointer-events-none translate-y-2 opacity-0"
: "translate-y-0 opacity-100"
)}
aria-label={
unseenCount > 0
? `${unseenCount} new messages, scroll to bottom`
: "Scroll to bottom"
aria-label={labels?.scrollToBottom ?? "Scroll to bottom"
}
>
<ChevronDown className="size-[18px] text-muted-foreground" />

View file

@ -95,6 +95,7 @@ export type { FileValidationResult } from "./security"
// Types
export type {
ChatUser,
ChatLabels,
ChatMessageData,
ChatConfig,
MessageGroup,

View file

@ -38,10 +38,19 @@ export interface ChatMessageData {
readBy?: { userId: string; name: string; avatar?: string }[]
}
export interface ChatLabels {
composerPlaceholder?: string
typingOne?: string // e.g. "{{name}} is typing"
typingTwo?: string // e.g. "{{name1}} and {{name2}} are typing"
typingMany?: string // e.g. "Several people are typing"
scrollToBottom?: string
}
export interface ChatConfig {
currentUser: ChatUser
dateFormat?: "relative" | "absolute" | "time-only"
messageGroupingInterval?: number // seconds, default 120
labels?: ChatLabels
// Callbacks
onReactionAdd?: (messageId: string, emoji: string) => void