Implement real-time online status and typing indicator

This commit is contained in:
2025-10-18 22:50:13 +03:00
Unverified
parent 0ecf324028
commit 1e86b9fc84
20 changed files with 1191 additions and 57 deletions
+152
View File
@@ -0,0 +1,152 @@
/**
* @fileoverview Online status manager for real-time user status tracking
* @description Handles subscription to user online statuses via WebSocket
* @author Cursor
* @version 1.0.0
*/
import { request } from "./websocket";
import type {
StatusUpdateWebSocketMessage,
SubscribeStatusWebSocketMessage,
UnsubscribeStatusWebSocketMessage
} from "./types";
import { useAppState } from "@/pages/chat/state";
export interface UserStatus {
online: boolean;
lastSeen: string;
}
/**
* Manages online status subscriptions and updates
*/
export class OnlineStatusManager {
private subscribedUsers: Set<number> = new Set();
private statusCache: Map<number, UserStatus> = new Map();
private authToken: string | null = null;
/**
* Set the authentication token for WebSocket requests
*/
setAuthToken(token: string | null): void {
this.authToken = token;
}
/**
* Subscribe to a user's online status
*/
async subscribe(userId: number): Promise<void> {
if (!this.authToken || this.subscribedUsers.has(userId)) {
return;
}
try {
const message: SubscribeStatusWebSocketMessage = {
type: "subscribeStatus",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
userId
}
};
await request(message);
this.subscribedUsers.add(userId);
} catch (error) {
console.error(`Failed to subscribe to user ${userId} status:`, error);
}
}
/**
* Unsubscribe from a user's online status
*/
async unsubscribe(userId: number): Promise<void> {
if (!this.authToken || !this.subscribedUsers.has(userId)) {
return;
}
try {
const message: UnsubscribeStatusWebSocketMessage = {
type: "unsubscribeStatus",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
userId
}
};
await request(message);
this.subscribedUsers.delete(userId);
this.statusCache.delete(userId);
} catch (error) {
console.error(`Failed to unsubscribe from user ${userId} status:`, error);
}
}
/**
* Handle incoming status update from WebSocket
*/
handleStatusUpdate(message: StatusUpdateWebSocketMessage): void {
const { userId, online, lastSeen } = message.data;
this.statusCache.set(userId, { online, lastSeen });
// Update the global state
const { updateOnlineStatus } = useAppState.getState();
updateOnlineStatus(userId, online, lastSeen);
}
/**
* Get cached status for a user
*/
getStatus(userId: number): UserStatus | undefined {
return this.statusCache.get(userId);
}
/**
* Get all cached statuses
*/
getAllStatuses(): Map<number, UserStatus> {
return new Map(this.statusCache);
}
/**
* Check if subscribed to a user's status
*/
isSubscribed(userId: number): boolean {
return this.subscribedUsers.has(userId);
}
/**
* Get all subscribed user IDs
*/
getSubscribedUsers(): Set<number> {
return new Set(this.subscribedUsers);
}
/**
* Unsubscribe from all users and clear cache
*/
async unsubscribeAll(): Promise<void> {
const unsubscribePromises = Array.from(this.subscribedUsers).map(userId =>
this.unsubscribe(userId)
);
await Promise.all(unsubscribePromises);
this.subscribedUsers.clear();
this.statusCache.clear();
}
/**
* Cleanup when component unmounts
*/
cleanup(): void {
this.unsubscribeAll();
}
}
// Global instance
export const onlineStatusManager = new OnlineStatusManager();
+90
View File
@@ -530,4 +530,94 @@ export interface CallVideoToggleMessage extends CallSignalingMessage {
export interface CallScreenShareToggleMessage extends CallSignalingMessage {
type: "call_screen_share_toggle";
data: CallScreenShareToggleData;
}
// -----------
// Online Status & Typing WebSocket Messages
// -----------
export interface StatusUpdateWebSocketMessage extends WebSocketMessage {
type: "statusUpdate";
data: {
userId: number;
online: boolean;
lastSeen: string;
};
}
export interface SubscribeStatusWebSocketMessage extends WebSocketMessage {
type: "subscribeStatus";
credentials: WebSocketCredentials;
data: {
userId: number;
};
}
export interface UnsubscribeStatusWebSocketMessage extends WebSocketMessage {
type: "unsubscribeStatus";
credentials: WebSocketCredentials;
data: {
userId: number;
};
}
export interface TypingWebSocketMessage extends WebSocketMessage {
type: "typing";
data: {
userId: number;
username: string;
};
}
export interface StopTypingWebSocketMessage extends WebSocketMessage {
type: "stopTyping";
data: {
userId: number;
username: string;
};
}
export interface DmTypingWebSocketMessage extends WebSocketMessage {
type: "dmTyping";
data: {
userId: number;
username: string;
};
}
export interface StopDmTypingWebSocketMessage extends WebSocketMessage {
type: "stopDmTyping";
data: {
userId: number;
username: string;
};
}
// Request types for sending typing/status messages
export interface TypingRequest extends WebSocketMessage {
type: "typing";
credentials: WebSocketCredentials;
data: {};
}
export interface StopTypingRequest extends WebSocketMessage {
type: "stopTyping";
credentials: WebSocketCredentials;
data: {};
}
export interface DmTypingRequest extends WebSocketMessage {
type: "dmTyping";
credentials: WebSocketCredentials;
data: {
recipientId: number;
};
}
export interface StopDmTypingRequest extends WebSocketMessage {
type: "stopDmTyping";
credentials: WebSocketCredentials;
data: {
recipientId: number;
};
}
+216
View File
@@ -0,0 +1,216 @@
/**
* @fileoverview Typing indicator manager for real-time typing status
* @description Handles typing indicators for public chat and DMs via WebSocket
* @author Cursor
* @version 1.0.0
*/
import { request } from "./websocket";
import type {
TypingWebSocketMessage,
StopTypingWebSocketMessage,
DmTypingWebSocketMessage,
StopDmTypingWebSocketMessage,
TypingRequest,
StopTypingRequest,
DmTypingRequest,
StopDmTypingRequest
} from "./types";
import { useAppState } from "@/pages/chat/state";
/**
* Manages typing indicators for public chat and DMs
*/
export class TypingManager {
private authToken: string | null = null;
private typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
private readonly TYPING_TIMEOUT = 3000; // 3 seconds
/**
* Set the authentication token for WebSocket requests
*/
setAuthToken(token: string | null): void {
this.authToken = token;
}
/**
* Send typing indicator for public chat
*/
async sendTyping(): Promise<void> {
if (!this.authToken) return;
try {
const message: TypingRequest = {
type: "typing",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {}
};
await request(message);
this.scheduleStopTyping("public");
} catch (error) {
console.error("Failed to send typing indicator:", error);
}
}
/**
* Send stop typing indicator for public chat
*/
async sendStopTyping(): Promise<void> {
if (!this.authToken) return;
try {
const message: StopTypingRequest = {
type: "stopTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {}
};
await request(message);
this.clearStopTypingTimeout("public");
} catch (error) {
console.error("Failed to send stop typing indicator:", error);
}
}
/**
* Send typing indicator for DM
*/
async sendDmTyping(recipientId: number): Promise<void> {
if (!this.authToken) return;
try {
const message: DmTypingRequest = {
type: "dmTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
recipientId
}
};
await request(message);
this.scheduleStopDmTyping(recipientId);
} catch (error) {
console.error("Failed to send DM typing indicator:", error);
}
}
/**
* Send stop typing indicator for DM
*/
async sendStopDmTyping(recipientId: number): Promise<void> {
if (!this.authToken) return;
try {
const message: StopDmTypingRequest = {
type: "stopDmTyping",
credentials: {
scheme: "Bearer",
credentials: this.authToken
},
data: {
recipientId
}
};
await request(message);
this.clearStopTypingTimeout(`dm_${recipientId}`);
} catch (error) {
console.error("Failed to send stop DM typing indicator:", error);
}
}
/**
* Handle incoming typing indicator from WebSocket
*/
handleTyping(message: TypingWebSocketMessage): void {
const { addTypingUser } = useAppState.getState();
addTypingUser(message.data.userId, message.data.username);
}
/**
* Handle incoming stop typing indicator from WebSocket
*/
handleStopTyping(message: StopTypingWebSocketMessage): void {
const { removeTypingUser } = useAppState.getState();
removeTypingUser(message.data.userId);
}
/**
* Handle incoming DM typing indicator from WebSocket
*/
handleDmTyping(message: DmTypingWebSocketMessage): void {
const { setDmTypingUser } = useAppState.getState();
setDmTypingUser(message.data.userId, true);
}
/**
* Handle incoming stop DM typing indicator from WebSocket
*/
handleStopDmTyping(message: StopDmTypingWebSocketMessage): void {
const { setDmTypingUser } = useAppState.getState();
setDmTypingUser(message.data.userId, false);
}
/**
* Schedule automatic stop typing after timeout
*/
private scheduleStopTyping(context: string): void {
this.clearStopTypingTimeout(context);
const timeout = setTimeout(async () => {
if (context === "public") {
await this.sendStopTyping();
}
this.typingTimeouts.delete(context);
}, this.TYPING_TIMEOUT);
this.typingTimeouts.set(context, timeout);
}
/**
* Schedule automatic stop DM typing after timeout
*/
private scheduleStopDmTyping(recipientId: number): void {
const context = `dm_${recipientId}`;
this.clearStopTypingTimeout(context);
const timeout = setTimeout(async () => {
await this.sendStopDmTyping(recipientId);
this.typingTimeouts.delete(context);
}, this.TYPING_TIMEOUT);
this.typingTimeouts.set(context, timeout);
}
/**
* Clear stop typing timeout
*/
private clearStopTypingTimeout(context: string): void {
const timeout = this.typingTimeouts.get(context);
if (timeout) {
clearTimeout(timeout);
this.typingTimeouts.delete(context);
}
}
/**
* Cleanup all timeouts
*/
cleanup(): void {
this.typingTimeouts.forEach(timeout => clearTimeout(timeout));
this.typingTimeouts.clear();
}
}
// Global instance
export const typingManager = new TypingManager();
+15
View File
@@ -9,6 +9,8 @@ import { API_WS_BASE_URL } from "./config";
import type { WebSocketMessage } from "./types";
import { delay } from "@/utils/utils";
import { CallSignalingHandler } from "./calls/signaling";
import { onlineStatusManager } from "./onlineStatusManager";
import { typingManager } from "./typingManager";
/**
* Creates a new WebSocket connection to the chat server
@@ -116,6 +118,19 @@ websocket.addEventListener("message", (e) => {
callSignalingHandler.handleWebSocketMessage(response.data);
}
// Handle status and typing messages
if (response.type === "statusUpdate") {
onlineStatusManager.handleStatusUpdate(response as any);
} else if (response.type === "typing") {
typingManager.handleTyping(response as any);
} else if (response.type === "stopTyping") {
typingManager.handleStopTyping(response as any);
} else if (response.type === "dmTyping") {
typingManager.handleDmTyping(response as any);
} else if (response.type === "stopDmTyping") {
typingManager.handleStopDmTyping(response as any);
}
// Route message to global handler if set
if (globalMessageHandler) {
globalMessageHandler(response);
@@ -115,6 +115,8 @@
}
}
// Typing indicator styles
// Emoji Menu Styles
.emoji-menu {
$transition: cubic-bezier(0.4, 0, 0.2, 1);
@@ -156,7 +156,6 @@
height: 45px;
border-radius: 20%;
object-fit: cover;
margin-right: 1rem;
}
mdui-tabs {
@@ -43,15 +43,6 @@
}
}
.online-status {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: $success;
margin-right: 5px;
}
a {
display: flex;
flex-direction: row;
@@ -0,0 +1,109 @@
@use "../../../css/colors" as *;
@use "../../../css/material" as *;
@use "sass:color";
// Unified typing indicator styles (used for both public chat and DMs)
.typing-indicator {
display: flex;
align-items: center;
gap: 8px;
color: $color-dark-primary;
.typing-dots {
display: flex;
gap: 2px;
span {
width: 4px;
height: 4px;
border-radius: 50%;
background: $color-dark-primary;
animation: typing-dot 1.4s infinite ease-in-out;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
}
}
.typing-text {
font-size: 0.875rem;
font-weight: 500;
}
}
// Online status display (used in DMs when not typing)
.online-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: $color-dark-on-surface-variant;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&.online {
background: #4caf50;
box-shadow: 0 0 6px rgba(76, 175, 80, 0.4);
}
&.offline {
background: $color-dark-on-surface-variant;
opacity: 0.6;
}
}
.status-text {
font-weight: 500;
font-size: 0.75rem;
opacity: 0.8;
}
}
// Online indicator for profile pictures (positioned at bottom right)
.online-indicator {
position: absolute;
bottom: 0px;
right: 0px;
z-index: 10;
pointer-events: none;
transform: none;
.indicator-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid $color-dark-surface;
box-sizing: border-box;
display: block;
background: #4caf50;
position: relative;
transform: none;
}
}
// Ensure the icon container allows absolute positioning
mdui-list-item [slot="icon"] {
position: relative;
display: inline-block;
}
// Typing dot animation
@keyframes typing-dot {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
+2 -1
View File
@@ -9,4 +9,5 @@
@use "settings-dialog";
@use "animations";
@use "callWindow";
@use "profile-dialog";
@use "profile-dialog";
@use "typing-indicators";
+71 -2
View File
@@ -9,6 +9,8 @@ import { restoreKeys } from "@/core/api/authApi";
import { API_BASE_URL } from "@/core/config";
import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import { onlineStatusManager } from "@/core/onlineStatusManager";
import { typingManager } from "@/core/typingManager";
export type ChatTabs = "chats" | "channels" | "contacts";
@@ -61,6 +63,9 @@ interface ChatState {
pendingPanel?: MessagePanel | null;
call: CallState;
profileDialog: ProfileDialogData | null;
onlineStatuses: Map<number, {online: boolean, lastSeen: string}>;
typingUsers: Map<number, string>; // userId -> username
dmTypingUsers: Map<number, boolean>;
}
export interface UserState {
@@ -109,6 +114,12 @@ interface AppState {
// Profile dialog state
setProfileDialog: (data: ProfileDialogData | null) => void;
closeProfileDialog: () => void;
// Online status and typing state
updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => void;
addTypingUser: (userId: number, username: string) => void;
removeTypingUser: (userId: number) => void;
setDmTypingUser: (userId: number, isTyping: boolean) => void;
}
export const useAppState = create<AppState>((set, get) => ({
@@ -146,7 +157,10 @@ export const useAppState = create<AppState>((set, get) => ({
isRemoteVideoEnabled: false,
isSharingScreen: false,
isRemoteScreenSharing: false
}
},
onlineStatuses: new Map(),
typingUsers: new Map(),
dmTypingUsers: new Map()
},
addMessage: (message: Message) => set((state) => {
// Check if message already exists to prevent duplicates
@@ -220,6 +234,10 @@ export const useAppState = create<AppState>((set, get) => ({
}
}));
// Initialize managers with auth token
onlineStatusManager.setAuthToken(token);
typingManager.setAuthToken(token);
// Store credentials in localStorage
try {
localStorage.setItem('authToken', token);
@@ -250,6 +268,12 @@ export const useAppState = create<AppState>((set, get) => ({
console.error('Failed to clear localStorage:', error);
}
// Cleanup managers
onlineStatusManager.setAuthToken(null);
typingManager.setAuthToken(null);
onlineStatusManager.cleanup();
typingManager.cleanup();
set(() => ({
user: {
currentUser: null,
@@ -277,6 +301,10 @@ export const useAppState = create<AppState>((set, get) => ({
}
}));
// Initialize managers with auth token
onlineStatusManager.setAuthToken(token);
typingManager.setAuthToken(token);
try {
request({
type: "ping",
@@ -608,5 +636,46 @@ export const useAppState = create<AppState>((set, get) => ({
...state.chat,
profileDialog: null
}
}))
})),
// Online status and typing state management
updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => set((state) => ({
chat: {
...state.chat,
onlineStatuses: new Map(state.chat.onlineStatuses).set(userId, { online, lastSeen })
}
})),
addTypingUser: (userId: number, username: string) => set((state) => ({
chat: {
...state.chat,
typingUsers: new Map(state.chat.typingUsers).set(userId, username)
}
})),
removeTypingUser: (userId: number) => set((state) => {
const newTypingUsers = new Map(state.chat.typingUsers);
newTypingUsers.delete(userId);
return {
chat: {
...state.chat,
typingUsers: newTypingUsers
}
};
}),
setDmTypingUser: (userId: number, isTyping: boolean) => set((state) => {
const newDmTypingUsers = new Map(state.chat.dmTypingUsers);
if (isTyping) {
newDmTypingUsers.set(userId, true);
} else {
newDmTypingUsers.delete(userId);
}
return {
chat: {
...state.chat,
dmTypingUsers: newDmTypingUsers
}
};
})
}));
+19 -5
View File
@@ -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 {