veza/apps/web/src/features/chat/components/ChatRoom.tsx

289 lines
10 KiB
TypeScript
Raw Normal View History

import React, { useEffect, useRef, useState } from 'react';
2025-12-13 02:34:34 +00:00
import { useChatStore } from '../store/chatStore';
import { ChatMessageComponent } from './ChatMessage';
import { useChat } from '../hooks/useChat';
2026-02-22 02:46:10 +00:00
import { useWebRTC } from '../hooks/useWebRTC';
import { MessageSearch } from './MessageSearch';
import { TypingIndicator } from './TypingIndicator';
2026-02-22 02:46:10 +00:00
import { CallButton } from './CallButton';
import { IncomingCallModal } from './IncomingCallModal';
import { ActiveCallBar } from './ActiveCallBar';
import {
2026-03-06 17:52:08 +00:00
Search,
X,
MessageSquare,
UserPlus,
Users,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
2026-03-06 17:52:08 +00:00
import { InviteRoomModal } from './InviteRoomModal';
import { RoomMembersModal } from './RoomMembersModal';
import { useUser } from '@/features/auth/hooks/useUser';
import { adminService } from '@/services/adminService';
interface ChatRoomProps {
conversationId: string;
}
export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
2026-02-22 02:46:10 +00:00
const { messages, conversations, userId, incomingCall, activeCall } =
useChatStore();
const { fetchHistory, sendRawMessage, wsStatus } = useChat();
const { data: _user } = useUser();
2026-02-22 02:46:10 +00:00
const webrtc = useWebRTC({ sendMessage: sendRawMessage });
const messagesEndRef = useRef<HTMLDivElement>(null);
const [showSearch, setShowSearch] = useState(false);
2026-03-06 17:52:08 +00:00
const [showInviteModal, setShowInviteModal] = useState(false);
const [showMembersModal, setShowMembersModal] = useState(false);
const [webrtcEnabled, setWebrtcEnabled] = useState(true);
const [highlightedMessageId, setHighlightedMessageId] = useState<
string | null
>(null);
const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
adminService.getClientFeatureFlags().then((flags) => {
if (cancelled) return;
const webrtc = flags.find((f) => f.name === 'WEBRTC_CALLS');
setWebrtcEnabled(webrtc?.enabled ?? true);
}).catch(() => { if (!cancelled) setWebrtcEnabled(true); });
return () => { cancelled = true; };
}, []);
// Cleanup highlight timeout on unmount
useEffect(() => {
return () => {
if (highlightTimeoutRef.current) {
clearTimeout(highlightTimeoutRef.current);
}
};
}, []);
const currentMessages = messages[conversationId] || [];
const fetchingRef = useRef<{ [key: string]: boolean }>({});
useEffect(() => {
if (
conversationId &&
!messages[conversationId] &&
!fetchingRef.current[conversationId]
) {
fetchingRef.current[conversationId] = true;
fetchHistory(conversationId).finally(() => {
// Fetch complete
});
}
}, [conversationId, messages[conversationId], fetchHistory]);
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [currentMessages.length, conversationId]); // Scroll on new messages or channel switch
const handleMessageSelect = (messageId: string) => {
setHighlightedMessageId(messageId);
const messageElement = document.getElementById(`message-${messageId}`);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (highlightTimeoutRef.current) {
clearTimeout(highlightTimeoutRef.current);
}
highlightTimeoutRef.current = setTimeout(() => setHighlightedMessageId(null), 3000);
}
};
2026-02-22 02:46:10 +00:00
const conversation = conversations.find((c) => c.id === conversationId);
const isDM =
conversation?.type === 'direct' && conversation.participants.length === 2;
2026-03-06 17:52:08 +00:00
const isGroupRoom = conversation && conversation.type !== 'direct';
2026-02-22 02:46:10 +00:00
const targetUserId =
isDM && userId
? conversation.participants.find((p) => p !== userId) ?? null
: null;
const remoteUserName =
conversation?.name && conversation.name !== 'direct'
? conversation.name
: 'Utilisateur';
if (!conversationId) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground space-y-4 animate-empty-state-in">
<div className="w-24 h-24 rounded-full bg-muted flex items-center justify-center">
<MessageSquare className="w-10 h-10 text-muted-foreground" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-foreground mb-1">
No conversation selected
</p>
<p className="text-xs text-muted-foreground">
Pick a channel from the sidebar to start chatting.
</p>
</div>
</div>
);
}
return (
2026-02-22 02:46:10 +00:00
<div className="flex-1 flex flex-col h-full overflow-hidden relative">
<IncomingCallModal
open={!!incomingCall}
callerName={remoteUserName}
onAccept={() =>
incomingCall &&
webrtc.acceptCall(
incomingCall.conversationId,
incomingCall.callerUserId,
incomingCall.sdp,
)
}
onReject={() =>
incomingCall &&
webrtc.rejectCall(
incomingCall.conversationId,
incomingCall.callerUserId,
)
}
/>
{activeCall && (
<div className="absolute bottom-0 left-0 right-0 z-30">
<ActiveCallBar
remoteUserName={remoteUserName}
isMuted={webrtc.isMuted}
onToggleMute={webrtc.toggleMute}
onHangup={() =>
webrtc.hangup(activeCall.conversationId, activeCall.remoteUserId)
}
/>
</div>
)}
{/* Search Header Overlay */}
<div
className={cn(
'absolute top-0 left-0 right-0 z-20 px-4 py-2 transition-all duration-[var(--sumi-duration-normal)]',
showSearch
? 'bg-card/90 backdrop-blur-md border-b border-border'
: 'bg-transparent pointer-events-none',
)}
>
{showSearch ? (
<div className="flex items-center gap-2 max-w-2xl mx-auto">
<div className="flex-1">
<MessageSearch
conversationId={conversationId}
onMessageSelect={handleMessageSelect}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowSearch(false)}
className="hover:bg-muted/50"
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
2026-02-22 02:46:10 +00:00
<div className="flex justify-end items-center gap-2 pointer-events-auto">
2026-03-06 17:52:08 +00:00
{isGroupRoom && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setShowInviteModal(true)}
fix: stabilize builds, tests, and lint across all stacks Complete stabilization pass bringing all 3 stacks to green: Frontend (apps/web/): - Fix TypeScript nullability in useSeason.ts, useTimeOfDay.ts hooks - Disable no-undef in ESLint config (TypeScript handles it; JSX misidentified) - Rename 306 story imports from @storybook/react to @storybook/react-vite - Fix conditional hook call in useMediaQuery.ts useIsTablet - Move useQuery to top of LoginPage.tsx component - Remove useless try/catch in GearFormModal.tsx - Fix stale closure in ResetPasswordPage.tsx handleChange - Make Storybook decorators (withRouter, withQueryClient, withToast, withAudio) no-ops since global StorybookDecorator already provides these — prevents nested Router / duplicate provider crashes in vitest-browser - Fix nested MemoryRouter in 3 page stories (TrackDetail, PlaylistDetail, UserProfile) - Update i18n initialization in test setup (await init before changeLanguage) - Update ~30 test assertions from English to French to match i18n translations - Update test assertions to match SUMI V3 design changes (shadow vs border) - Fix remaining story type errors (PlayerError, PlaylistBatchActions, TrackFilters, VirtualizedChatMessages) Backend (veza-backend-api/): - Fix response_test.go RespondWithAppError signature (2 args, not 3) - Fix TestErrorContractAuthEndpoints expected error codes (ErrCodeUnauthorized vs ErrCodeInvalidCredentials) - Fix TestTrackHandler_GetTrackLikes_Success missing auth middleware setup - Fix TestPlaybackAnalyticsService_GetTrackStats k-anonymity threshold (needs 5 unique users, not 1) - Replace NOW() PostgreSQL function with time.Now() parameter in marketplace service for SQLite test compatibility - Add missing AutoMigrate entries in marketplace_test.go (ProductImage, ProductPreview, ProductLicense, ProductReview) Results: - Frontend TypeCheck: 617 errors -> 0 errors - Frontend ESLint: 349 errors -> 0 errors - Frontend Vitest: 196 failing tests -> 1 skipped (3396/3397 passing) - Backend go vet: 1 error -> 0 errors - Backend tests: 5 failing -> all 13 packages passing - Rust: 150/150 tests passing (unchanged) - Storybook audit: 0 errors across 1244 stories Triage report: docs/TRIAGE_REPORT.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:48:07 +00:00
className="text-muted-foreground/50 hover:text-foreground hover:bg-muted/50 bg-muted/30 backdrop-blur-sm rounded-full h-8 px-4 shadow-[0_0_8px_rgba(26,26,30,0.05)]"
2026-03-06 17:52:08 +00:00
>
<UserPlus className="h-3 w-3 mr-2" />
<span className="text-xs font-mono uppercase">Invite</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowMembersModal(true)}
fix: stabilize builds, tests, and lint across all stacks Complete stabilization pass bringing all 3 stacks to green: Frontend (apps/web/): - Fix TypeScript nullability in useSeason.ts, useTimeOfDay.ts hooks - Disable no-undef in ESLint config (TypeScript handles it; JSX misidentified) - Rename 306 story imports from @storybook/react to @storybook/react-vite - Fix conditional hook call in useMediaQuery.ts useIsTablet - Move useQuery to top of LoginPage.tsx component - Remove useless try/catch in GearFormModal.tsx - Fix stale closure in ResetPasswordPage.tsx handleChange - Make Storybook decorators (withRouter, withQueryClient, withToast, withAudio) no-ops since global StorybookDecorator already provides these — prevents nested Router / duplicate provider crashes in vitest-browser - Fix nested MemoryRouter in 3 page stories (TrackDetail, PlaylistDetail, UserProfile) - Update i18n initialization in test setup (await init before changeLanguage) - Update ~30 test assertions from English to French to match i18n translations - Update test assertions to match SUMI V3 design changes (shadow vs border) - Fix remaining story type errors (PlayerError, PlaylistBatchActions, TrackFilters, VirtualizedChatMessages) Backend (veza-backend-api/): - Fix response_test.go RespondWithAppError signature (2 args, not 3) - Fix TestErrorContractAuthEndpoints expected error codes (ErrCodeUnauthorized vs ErrCodeInvalidCredentials) - Fix TestTrackHandler_GetTrackLikes_Success missing auth middleware setup - Fix TestPlaybackAnalyticsService_GetTrackStats k-anonymity threshold (needs 5 unique users, not 1) - Replace NOW() PostgreSQL function with time.Now() parameter in marketplace service for SQLite test compatibility - Add missing AutoMigrate entries in marketplace_test.go (ProductImage, ProductPreview, ProductLicense, ProductReview) Results: - Frontend TypeCheck: 617 errors -> 0 errors - Frontend ESLint: 349 errors -> 0 errors - Frontend Vitest: 196 failing tests -> 1 skipped (3396/3397 passing) - Backend go vet: 1 error -> 0 errors - Backend tests: 5 failing -> all 13 packages passing - Rust: 150/150 tests passing (unchanged) - Storybook audit: 0 errors across 1244 stories Triage report: docs/TRIAGE_REPORT.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:48:07 +00:00
className="text-muted-foreground/50 hover:text-foreground hover:bg-muted/50 bg-muted/30 backdrop-blur-sm rounded-full h-8 px-4 shadow-[0_0_8px_rgba(26,26,30,0.05)]"
2026-03-06 17:52:08 +00:00
>
<Users className="h-3 w-3 mr-2" />
<span className="text-xs font-mono uppercase">Members</span>
</Button>
</>
)}
{isDM && targetUserId && webrtcEnabled && (
2026-02-22 02:46:10 +00:00
<CallButton
conversationId={conversationId}
targetUserId={targetUserId}
onCall={() =>
webrtc.startCall(conversationId, targetUserId, 'audio')
}
disabled={wsStatus !== 'connected'}
/>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setShowSearch(true)}
fix: stabilize builds, tests, and lint across all stacks Complete stabilization pass bringing all 3 stacks to green: Frontend (apps/web/): - Fix TypeScript nullability in useSeason.ts, useTimeOfDay.ts hooks - Disable no-undef in ESLint config (TypeScript handles it; JSX misidentified) - Rename 306 story imports from @storybook/react to @storybook/react-vite - Fix conditional hook call in useMediaQuery.ts useIsTablet - Move useQuery to top of LoginPage.tsx component - Remove useless try/catch in GearFormModal.tsx - Fix stale closure in ResetPasswordPage.tsx handleChange - Make Storybook decorators (withRouter, withQueryClient, withToast, withAudio) no-ops since global StorybookDecorator already provides these — prevents nested Router / duplicate provider crashes in vitest-browser - Fix nested MemoryRouter in 3 page stories (TrackDetail, PlaylistDetail, UserProfile) - Update i18n initialization in test setup (await init before changeLanguage) - Update ~30 test assertions from English to French to match i18n translations - Update test assertions to match SUMI V3 design changes (shadow vs border) - Fix remaining story type errors (PlayerError, PlaylistBatchActions, TrackFilters, VirtualizedChatMessages) Backend (veza-backend-api/): - Fix response_test.go RespondWithAppError signature (2 args, not 3) - Fix TestErrorContractAuthEndpoints expected error codes (ErrCodeUnauthorized vs ErrCodeInvalidCredentials) - Fix TestTrackHandler_GetTrackLikes_Success missing auth middleware setup - Fix TestPlaybackAnalyticsService_GetTrackStats k-anonymity threshold (needs 5 unique users, not 1) - Replace NOW() PostgreSQL function with time.Now() parameter in marketplace service for SQLite test compatibility - Add missing AutoMigrate entries in marketplace_test.go (ProductImage, ProductPreview, ProductLicense, ProductReview) Results: - Frontend TypeCheck: 617 errors -> 0 errors - Frontend ESLint: 349 errors -> 0 errors - Frontend Vitest: 196 failing tests -> 1 skipped (3396/3397 passing) - Backend go vet: 1 error -> 0 errors - Backend tests: 5 failing -> all 13 packages passing - Rust: 150/150 tests passing (unchanged) - Storybook audit: 0 errors across 1244 stories Triage report: docs/TRIAGE_REPORT.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:48:07 +00:00
className="text-muted-foreground/50 hover:text-foreground hover:bg-muted/50 bg-muted/30 backdrop-blur-sm rounded-full h-8 px-4 shadow-[0_0_8px_rgba(26,26,30,0.05)]"
>
<Search className="h-3 w-3 mr-2" />
<span className="text-xs font-mono uppercase">Search Log</span>
</Button>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 scroll-smooth">
{/* Welcome Message for Empty Room */}
{currentMessages.length === 0 && (
<div className="flex flex-col items-center justify-center h-layout-lyrics-sm text-center space-y-4 animate-empty-state-in">
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center">
<MessageSquare className="w-7 h-7 text-muted-foreground" />
</div>
<div>
<p className="text-foreground font-medium">No messages yet</p>
<p className="text-sm text-muted-foreground mt-1">
Send the first message to start the conversation.
</p>
</div>
</div>
)}
{/* Message Stream */}
{currentMessages.map((msg) => {
return (
<div
key={msg.id}
id={`message-${msg.id}`}
className={cn(
'transition-all duration-[var(--sumi-duration-slow)] animate-slideUp',
highlightedMessageId === msg.id &&
'bg-muted/10 rounded-xl -mx-4 px-4 py-2 ring-1 ring-border/30',
)}
>
<ChatMessageComponent message={msg} />
</div>
);
})}
<TypingIndicator conversationId={conversationId} />
<div ref={messagesEndRef} className="h-4" />
</div>
2026-03-06 17:52:08 +00:00
{isGroupRoom && (
<>
<InviteRoomModal
open={showInviteModal}
onOpenChange={setShowInviteModal}
conversationId={conversationId}
/>
<RoomMembersModal
open={showMembersModal}
onOpenChange={setShowMembersModal}
conversationId={conversationId}
/>
</>
)}
</div>
);
};