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:
parent
2eb7cc5183
commit
14688afdeb
10 changed files with 84 additions and 23 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
13
apps/main/src/locales/en/chat.json
Normal file
13
apps/main/src/locales/en/chat.json
Normal 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"
|
||||
}
|
||||
13
apps/main/src/locales/fr/chat.json
Normal file
13
apps/main/src/locales/fr/chat.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ export type { FileValidationResult } from "./security"
|
|||
// Types
|
||||
export type {
|
||||
ChatUser,
|
||||
ChatLabels,
|
||||
ChatMessageData,
|
||||
ChatConfig,
|
||||
MessageGroup,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue