mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement real-time online status and typing indicator
This commit is contained in:
@@ -6,6 +6,8 @@ import defaultAvatar from "@/images/default-avatar.png";
|
||||
import { confirm } from "mdui/functions/confirm";
|
||||
import { updateProfile, uploadProfilePicture, fetchUserProfile } from "@/core/api/profileApi";
|
||||
import { RichTextArea } from "@/core/components/RichTextArea";
|
||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
import { OnlineStatus } from "./right/OnlineStatus";
|
||||
|
||||
export function ProfileDialog() {
|
||||
const { chat, user, closeProfileDialog } = useAppState();
|
||||
@@ -100,6 +102,21 @@ export function ProfileDialog() {
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Subscribe to user's online status when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen && currentData?.userId && !currentData.isOwnProfile) {
|
||||
// Subscribe to the user's status
|
||||
onlineStatusManager.subscribe(currentData.userId);
|
||||
|
||||
// Cleanup function to unsubscribe when dialog closes
|
||||
return () => {
|
||||
if (currentData.userId) {
|
||||
onlineStatusManager.unsubscribe(currentData.userId);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [isOpen, currentData?.userId, currentData?.isOwnProfile]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!originalData || !currentData) return false;
|
||||
|
||||
@@ -279,12 +296,9 @@ export function ProfileDialog() {
|
||||
)}
|
||||
|
||||
{/* Online Status */}
|
||||
{currentData.online !== undefined && (
|
||||
{currentData.userId && !currentData.isOwnProfile && (
|
||||
<div className="online-status-section">
|
||||
<span className={`online-indicator ${currentData.online ? "" : "offline"}`} />
|
||||
<span className="status-text">
|
||||
{currentData.online ? "Онлайн" : "Оффлайн"}
|
||||
</span>
|
||||
<OnlineStatus userId={currentData.userId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { getAuthHeaders } from "@/core/api/authApi";
|
||||
import { fetchUserPublicKey } from "@/core/api/dmApi";
|
||||
import type { Message } from "@/core/types";
|
||||
import { websocket } from "@/core/websocket";
|
||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
import { OnlineIndicator } from "../right/OnlineIndicator";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
|
||||
interface PublicChat {
|
||||
@@ -153,6 +155,23 @@ export function UnifiedChatsList() {
|
||||
return () => websocket.removeEventListener("message", handleWebSocketMessage);
|
||||
}, [publicChats, loadLastMessages]);
|
||||
|
||||
// Subscribe to online status for all DM users
|
||||
useEffect(() => {
|
||||
const dmUsers = allChats.filter(chat => chat.type === "dm") as DMConversation[];
|
||||
|
||||
// Subscribe to all DM users
|
||||
dmUsers.forEach(dmUser => {
|
||||
onlineStatusManager.subscribe(dmUser.id);
|
||||
});
|
||||
|
||||
// Cleanup function to unsubscribe from all users
|
||||
return () => {
|
||||
dmUsers.forEach(dmUser => {
|
||||
onlineStatusManager.unsubscribe(dmUser.id);
|
||||
});
|
||||
};
|
||||
}, [allChats]);
|
||||
|
||||
const formatPublicChatMessage = (chatId: string): string => {
|
||||
const lastMessage = lastMessages[chatId];
|
||||
if (!lastMessage) {
|
||||
@@ -244,20 +263,23 @@ export function UnifiedChatsList() {
|
||||
<span slot="description" className="list-description">
|
||||
{chat.lastMessage || "Нет сообщений"}
|
||||
</span>
|
||||
<img
|
||||
src={chat.profile_picture || defaultAvatar}
|
||||
alt={chat.username}
|
||||
slot="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
||||
<img
|
||||
src={chat.profile_picture || defaultAvatar}
|
||||
alt={chat.username}
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
display: "block"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<OnlineIndicator userId={chat.id} />
|
||||
</div>
|
||||
{chat.unreadCount > 0 && (
|
||||
<mdui-badge slot="end-icon">
|
||||
{chat.unreadCount}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from "react";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi";
|
||||
import type { User } from "@/core/types";
|
||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
import { OnlineIndicator } from "../right/OnlineIndicator";
|
||||
import defaultAvatar from "@/images/default-avatar.png";
|
||||
import SearchBar from "@/core/components/SearchBar";
|
||||
|
||||
@@ -51,6 +53,21 @@ export function UsernameSearch() {
|
||||
};
|
||||
}, [searchQuery, user.authToken]);
|
||||
|
||||
// Subscribe to online status for all search results
|
||||
useEffect(() => {
|
||||
// Subscribe to all search results
|
||||
searchResults.forEach(searchUser => {
|
||||
onlineStatusManager.subscribe(searchUser.id);
|
||||
});
|
||||
|
||||
// Cleanup function to unsubscribe from all users
|
||||
return () => {
|
||||
searchResults.forEach(searchUser => {
|
||||
onlineStatusManager.unsubscribe(searchUser.id);
|
||||
});
|
||||
};
|
||||
}, [searchResults]);
|
||||
|
||||
async function handleUserClick(searchUser: SearchUser) {
|
||||
if (!user.authToken) return;
|
||||
|
||||
@@ -133,17 +150,23 @@ export function UsernameSearch() {
|
||||
onClick={() => handleUserClick(searchUser)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<span slot="description" className="list-description">
|
||||
{searchUser.online ? "В сети" : "Не в сети"}
|
||||
</span>
|
||||
<img
|
||||
src={searchUser.profile_picture || defaultAvatar}
|
||||
alt={searchUser.username}
|
||||
slot="icon"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<div slot="icon" style={{ position: "relative", width: "40px", height: "40px", display: "inline-block" }}>
|
||||
<img
|
||||
src={searchUser.profile_picture || defaultAvatar}
|
||||
alt={searchUser.username}
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
display: "block"
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = defaultAvatar;
|
||||
}}
|
||||
/>
|
||||
<OnlineIndicator userId={searchUser.id} />
|
||||
</div>
|
||||
</mdui-list-item>
|
||||
))}
|
||||
</mdui-list>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface ChatInputWrapperProps {
|
||||
onCloseEdit?: () => void;
|
||||
onProvideFileAdder?: (adder: (files: File[]) => void) => void;
|
||||
messagePanelRef?: React.RefObject<HTMLDivElement | null>;
|
||||
onTyping?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputWrapper(
|
||||
@@ -35,7 +36,8 @@ export function ChatInputWrapper(
|
||||
onClearEdit,
|
||||
onCloseEdit,
|
||||
onProvideFileAdder,
|
||||
messagePanelRef
|
||||
messagePanelRef,
|
||||
onTyping
|
||||
}: ChatInputWrapperProps
|
||||
) {
|
||||
const [message, setMessage] = useState("");
|
||||
@@ -91,6 +93,17 @@ export function ChatInputWrapper(
|
||||
setMessage(prev => prev + emoji);
|
||||
};
|
||||
|
||||
function handleTyping() {
|
||||
if (onTyping) {
|
||||
onTyping();
|
||||
}
|
||||
};
|
||||
|
||||
function handleMessageChange(value: string) {
|
||||
setMessage(value);
|
||||
handleTyping();
|
||||
};
|
||||
|
||||
async function handleSubmit(e: React.FormEvent | Event) {
|
||||
e.preventDefault();
|
||||
const hasText = Boolean(message.trim());
|
||||
@@ -196,7 +209,7 @@ export function ChatInputWrapper(
|
||||
autoComplete="off"
|
||||
text={message}
|
||||
rows={1}
|
||||
onTextChange={(value) => setMessage(value)}
|
||||
onTextChange={handleMessageChange}
|
||||
onEnter={handleSubmit} />
|
||||
<div className="buttons">
|
||||
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
|
||||
|
||||
@@ -1,20 +1,49 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useMemo, type ReactNode } from "react";
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||
import { ProfileDialog } from "../ProfileDialog";
|
||||
import { ProfileDialog } from "@/pages/chat/ui/ProfileDialog";
|
||||
import { setGlobalMessageHandler } from "@/core/websocket";
|
||||
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 { DMPanel } from "./panels/DMPanel";
|
||||
import useCall from "@/pages/chat/hooks/useCall";
|
||||
import { TypingIndicator } from "./TypingIndicator";
|
||||
import { OnlineStatus } from "./OnlineStatus";
|
||||
import { typingManager } from "@/core/typingManager";
|
||||
import { PublicChatPanel } from "./panels/PublicChatPanel";
|
||||
|
||||
interface MessagePanelRendererProps {
|
||||
panel: MessagePanel | null;
|
||||
}
|
||||
|
||||
function ChatHeaderText({ panel }: { panel: MessagePanel | null }) {
|
||||
const { chat, user } = useAppState();
|
||||
const otherTypingUsers = useMemo(() => {
|
||||
return Array
|
||||
.from(chat.typingUsers.entries())
|
||||
.filter(([userId, username]) => userId !== user.currentUser?.id && username)
|
||||
.map(([, username]) => username!);
|
||||
}, [chat.typingUsers, user.currentUser?.id]);
|
||||
|
||||
let content: ReactNode;
|
||||
|
||||
if (panel instanceof DMPanel) {
|
||||
const recipientId = panel.getRecipientId()!;
|
||||
const isTyping = chat.dmTypingUsers.get(recipientId);
|
||||
|
||||
content = isTyping ? <TypingIndicator typingUsers={[]} /> : <OnlineStatus userId={recipientId} />;
|
||||
} else if (panel instanceof PublicChatPanel && otherTypingUsers.length > 0) {
|
||||
content = <TypingIndicator typingUsers={otherTypingUsers} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div>{content}</div>;
|
||||
}
|
||||
|
||||
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
const { applyPendingPanel, chat, setProfileDialog } = useAppState();
|
||||
const messagePanelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -233,14 +262,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
<div className="chat-header-info">
|
||||
<div className="info-chat">
|
||||
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
|
||||
<p>
|
||||
<span className={`online-status ${panelState?.online ? "online" : ""}`}></span>
|
||||
{panelState ? (
|
||||
panelState.online ? "Online" : "Offline"
|
||||
) : (
|
||||
"Выберите чат, чтобы начать переписку"
|
||||
)}
|
||||
</p>
|
||||
<ChatHeaderText panel={panel} />
|
||||
</div>
|
||||
{panel?.isDm() && (
|
||||
<mdui-button-icon onClick={handleCallClick} icon="call--filled" />
|
||||
@@ -315,6 +337,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
</div>
|
||||
</AnimatedOpacity>
|
||||
|
||||
|
||||
<ChatInputWrapper
|
||||
onSendMessage={(text, files) => {
|
||||
panel.handleSendMessage(text, replyTo?.id, files);
|
||||
@@ -354,6 +377,14 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||
}}
|
||||
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
|
||||
messagePanelRef={messagePanelRef}
|
||||
onTyping={() => {
|
||||
if (panel.isDm()) {
|
||||
const dmPanel = panel as DMPanel;
|
||||
dmPanel.handleTyping();
|
||||
} else {
|
||||
typingManager.sendTyping();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @fileoverview Online indicator component for profile pictures
|
||||
* @description Shows a small dot at the bottom right of profile pictures to indicate online status
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
|
||||
interface OnlineIndicatorProps {
|
||||
userId: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OnlineIndicator({ userId, className = "" }: OnlineIndicatorProps) {
|
||||
const { chat } = useAppState();
|
||||
const status = chat.onlineStatuses.get(userId);
|
||||
|
||||
// Only show indicator when user is online
|
||||
if (!status || !status.online) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`online-indicator ${className}`}>
|
||||
<div className="indicator-dot online"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @fileoverview Online status component for showing user online status
|
||||
* @description Displays online/offline status with last seen timestamp
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { useAppState } from "@/pages/chat/state";
|
||||
|
||||
interface OnlineStatusProps {
|
||||
userId: number;
|
||||
className?: string;
|
||||
showLastSeen?: boolean;
|
||||
}
|
||||
|
||||
export function OnlineStatus({ userId, className = "", showLastSeen = false }: OnlineStatusProps) {
|
||||
const { chat } = useAppState();
|
||||
const status = chat.onlineStatuses.get(userId);
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatLastSeen = (lastSeen: string): string => {
|
||||
const date = new Date(lastSeen);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) {
|
||||
return "только что";
|
||||
} else if (diffMins < 60) {
|
||||
return `${diffMins} мин. назад`;
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours} ч. назад`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} дн. назад`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`online-status ${className}`}>
|
||||
<div className={`status-dot ${status.online ? "online" : "offline"}`}></div>
|
||||
<span className="status-text">
|
||||
{status.online ? "В сети" : "Не в сети"}
|
||||
</span>
|
||||
{showLastSeen && !status.online && (
|
||||
<span className="last-seen">
|
||||
{formatLastSeen(status.lastSeen)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @fileoverview Typing indicator component for showing who is typing
|
||||
* @description Displays a list of users who are currently typing
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
typingUsers: string[]; // Array of usernames who are typing
|
||||
}
|
||||
|
||||
export function TypingIndicator({ typingUsers }: TypingIndicatorProps) {
|
||||
// Format the typing text based on number of users
|
||||
const typingText = useMemo(() => {
|
||||
switch (typingUsers.length) {
|
||||
case 0: return "печатает...";
|
||||
case 1: return `${typingUsers[0]} печатает...`;
|
||||
case 2: return `${typingUsers[0]} и ${typingUsers[1]} печатают...`;
|
||||
default: return `${typingUsers[0]}, ${typingUsers[1]} и еще ${typingUsers.length - 2} печатают...`;
|
||||
}
|
||||
}, [typingUsers]);
|
||||
|
||||
return (
|
||||
<div className="typing-indicator">
|
||||
<div className="typing-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<span className="typing-text">{typingText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { fetchUserProfile } from "@/core/api/profileApi";
|
||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
||||
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
||||
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
||||
import { onlineStatusManager } from "@/core/onlineStatusManager";
|
||||
import { typingManager } from "@/core/typingManager";
|
||||
|
||||
export interface DMPanelData {
|
||||
userId: number;
|
||||
@@ -34,13 +36,25 @@ export class DMPanel extends MessagePanel {
|
||||
return true;
|
||||
}
|
||||
|
||||
getRecipientId(): number | null {
|
||||
return this.dmData?.userId || null;
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
// Don't load messages immediately during activation to prevent animation freeze
|
||||
// Messages will be loaded after the animation completes
|
||||
|
||||
// Subscribe to recipient's online status
|
||||
if (this.dmData?.userId) {
|
||||
onlineStatusManager.subscribe(this.dmData.userId);
|
||||
}
|
||||
}
|
||||
|
||||
deactivate(): void {
|
||||
// DM doesn't need special cleanup
|
||||
// Unsubscribe from recipient's online status
|
||||
if (this.dmData?.userId) {
|
||||
onlineStatusManager.unsubscribe(this.dmData.userId);
|
||||
}
|
||||
}
|
||||
|
||||
clearMessages(): void {
|
||||
@@ -254,6 +268,11 @@ export class DMPanel extends MessagePanel {
|
||||
|
||||
// Reset for DM switching
|
||||
reset(): void {
|
||||
// Unsubscribe from current recipient's status before switching
|
||||
if (this.dmData?.userId) {
|
||||
onlineStatusManager.unsubscribe(this.dmData.userId);
|
||||
}
|
||||
|
||||
this.dmData = null;
|
||||
this.messagesLoaded = false;
|
||||
this.clearMessages();
|
||||
@@ -280,6 +299,13 @@ export class DMPanel extends MessagePanel {
|
||||
return this.dmData?.username || null;
|
||||
}
|
||||
|
||||
// Handle typing in DM
|
||||
handleTyping(): void {
|
||||
if (this.dmData?.userId) {
|
||||
typingManager.sendDmTyping(this.dmData.userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for localStorage
|
||||
private getLastReadId(userId: number): number {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user