Implement audio calls

This commit is contained in:
2025-09-21 19:09:39 +03:00
Unverified
parent 27902cf092
commit a853164b50
20 changed files with 1265 additions and 12 deletions
+2
View File
@@ -2,6 +2,7 @@ import { LeftPanel } from "./left/LeftPanel";
import { RightPanel } from "./right/RightPanel";
import "@/pages/chat/css/chat.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { CallWindow } from "./right/CallWindow";
export default function ChatPage() {
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
@@ -13,6 +14,7 @@ export default function ChatPage() {
<LeftPanel />
<RightPanel />
</div>
<CallWindow />
</div>
);
}
@@ -0,0 +1,120 @@
import { useState, useEffect } from "react";
import { useAppState } from "@/pages/chat/state";
import useAudioCall from "@/pages/chat/hooks/useAudioCall";
import defaultAvatar from "@/images/default-avatar.png";
export function CallWindow() {
const { chat, toggleMute } = useAppState();
const { call } = chat;
const { acceptCall, rejectCall, remoteAudioRef, endCall } = useAudioCall();
const [position, setPosition] = useState({ x: 100, y: 100 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [callDuration, setCallDuration] = useState(0);
useEffect(() => {
let interval: NodeJS.Timeout;
if (call.status === "active" && call.startTime) {
interval = setInterval(() => {
setCallDuration(Math.floor((Date.now() - call.startTime!) / 1000));
}, 1000);
} else {
setCallDuration(0);
}
return () => {
if (interval) clearInterval(interval);
};
}, [call.status, call.startTime]);
function formatDuration(seconds: number) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
function getStatusText() {
switch (call.status) {
case "calling":
return "Calling...";
case "connecting":
return "Connecting...";
case "active":
return formatDuration(callDuration);
default:
return "";
}
}
return (
<>
<audio
ref={remoteAudioRef}
className="remote-audio"
autoPlay
playsInline
controls />
{call.isActive && (
<div
className={`call-window ${isDragging ? 'dragging' : ''}`}
style={{
left: position.x,
top: position.y
}}
onMouseDown={(e) => {
setIsDragging(true);
setDragStart({
x: e.clientX - position.x,
y: e.clientY - position.y
});
}}
onMouseMove={(e) => {
if (isDragging) {
setPosition({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y
});
}
}}
onMouseUp={() => setIsDragging(false)}
onMouseLeave={() => setIsDragging(false)}
>
<div className="call-header">
<div className="user-info">
<img
src={defaultAvatar}
alt="Avatar"
className="avatar" />
<div className="user-details">
<h3 className="username">
{call.remoteUsername}
</h3>
<p className="status">
{getStatusText()}
</p>
</div>
</div>
</div>
<div className="call-controls">
{call.status === "calling" && !call.isInitiator ? (
<>
<mdui-button-icon onClick={acceptCall} icon="call" />
<mdui-button-icon onClick={rejectCall} icon="call_end" />
</>
) : (
<>
<mdui-button-icon onClick={toggleMute} icon={call.isMuted ? "mic_off" : "mic"} />
<mdui-button-icon onClick={endCall} icon="call_end" />
</>
)}
</div>
</div>
)}
</>
);
}
@@ -8,6 +8,7 @@ import type { Message, WebSocketMessage } from "@/core/types";
import defaultAvatar from "@/images/default-avatar.png";
import AnimatedOpacity from "@/core/components/animations/AnimatedOpacity";
import type { DMPanel } from "./panels/DMPanel";
import useAudioCall from "@/pages/chat/hooks/useAudioCall";
interface MessagePanelRendererProps {
panel: MessagePanel | null;
@@ -26,6 +27,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const [editMessage, setEditMessage] = useState<Message | null>(null);
const [editVisible, setEditVisible] = useState(Boolean(editMessage));
const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null);
const { initiateCall } = useAudioCall();
// Drag & drop
@@ -156,6 +158,18 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
previousMessageCountRef.current = currentMessageCount;
}, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]);
function handleCallClick() {
if (panel && panelState && panel.isDm()) {
const dmPanel = panel as DMPanel;
const userId = dmPanel.getDMUserId();
const username = dmPanel.getDMUsername();
if (userId && username) {
initiateCall(userId, username);
}
}
};
return (
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
<div
@@ -208,15 +222,15 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
<p>
<span className={`online-status ${panelState?.online ? "online" : ""}`}></span>
{panelState ? (
<>
{panelState.online ? "Online" : "Offline"}
{panelState.isTyping && " • Typing..."}
</>
panelState.online ? "Online" : "Offline"
) : (
"Выберите чат, чтобы начать переписку"
)}
</p>
</div>
{panel?.isDm() && (
<mdui-button-icon onClick={handleCallClick} icon="call--filled" />
)}
</div>
</div>
@@ -263,6 +263,16 @@ export class DMPanel extends MessagePanel {
this.currentUser.authToken = authToken;
}
// Get DM user ID for call functionality
getDMUserId(): number | null {
return this.dmData?.userId || null;
}
// Get DM username for call functionality
getDMUsername(): string | null {
return this.dmData?.username || null;
}
// Helper functions for localStorage
private getLastReadId(userId: number): number {
try {