mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 03:25:07 +03:00
354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
import type { Message, WebSocketMessage } from "@/core/types";
|
|
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
|
|
|
export interface MessagePanelState {
|
|
id: string;
|
|
title: string;
|
|
profilePicture?: string;
|
|
online: boolean;
|
|
messages: Message[];
|
|
isLoading: boolean;
|
|
isTyping: boolean;
|
|
}
|
|
|
|
export interface MessagePanelCallbacks {
|
|
onSendMessage: (content: string, files: File[]) => void;
|
|
onEditMessage: (messageId: number, content: string) => void;
|
|
onDeleteMessage: (messageId: number) => void;
|
|
onReplyToMessage: (messageId: number, content: string) => void;
|
|
onProfileClick: () => void;
|
|
}
|
|
|
|
export abstract class MessagePanel {
|
|
protected state: MessagePanelState;
|
|
public onStateChange: ((state: MessagePanelState) => void) | null = () => {};
|
|
protected readonly currentUser: UserState;
|
|
private pendingMessages: Map<string, { timeoutId: NodeJS.Timeout; message: Message }> = new Map();
|
|
|
|
constructor(
|
|
id: string,
|
|
currentUser: UserState,
|
|
) {
|
|
this.state = {
|
|
id,
|
|
title: "",
|
|
online: false,
|
|
messages: [],
|
|
isLoading: false,
|
|
isTyping: false
|
|
};
|
|
this.currentUser = currentUser;
|
|
}
|
|
|
|
// Abstract methods that must be implemented by subclasses
|
|
abstract activate(): Promise<void>;
|
|
abstract deactivate(): void;
|
|
abstract loadMessages(): Promise<void>;
|
|
protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
|
|
abstract isDm(): boolean;
|
|
abstract handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void>;
|
|
abstract getProfile(): Promise<ProfileDialogData | null>;
|
|
|
|
// Common methods
|
|
protected updateState(updates: Partial<MessagePanelState>): void {
|
|
this.state = { ...this.state, ...updates };
|
|
if (this.onStateChange) {
|
|
this.onStateChange(this.state);
|
|
}
|
|
}
|
|
|
|
protected addMessage(message: Message): void {
|
|
const messageExists = this.state.messages.some(msg => msg.id === message.id);
|
|
if (!messageExists) {
|
|
this.updateState({
|
|
messages: [...this.state.messages, message]
|
|
});
|
|
}
|
|
}
|
|
|
|
protected updateMessage(messageId: number, updates: Partial<Message>): void {
|
|
this.updateState({
|
|
messages: this.state.messages.map(msg => {
|
|
// Handle temporary messages (negative IDs) by matching temp ID
|
|
if (messageId === -1 && msg.runtimeData?.sendingState?.tempId) {
|
|
const pending = this.pendingMessages.get(msg.runtimeData.sendingState.tempId);
|
|
if (pending) {
|
|
return { ...pending.message, ...updates };
|
|
}
|
|
}
|
|
return msg.id === messageId ? { ...msg, ...updates } : msg;
|
|
})
|
|
});
|
|
}
|
|
|
|
protected removeMessage(messageId: number): void {
|
|
this.updateState({
|
|
messages: this.state.messages.filter(msg => msg.id !== messageId)
|
|
});
|
|
}
|
|
|
|
protected updateMessageReactions(messageId: number, reactions: any[]): void {
|
|
this.updateState({
|
|
messages: this.state.messages.map(msg =>
|
|
msg.id === messageId ? { ...msg, reactions } : msg
|
|
)
|
|
});
|
|
}
|
|
|
|
protected clearMessages(): void {
|
|
this.updateState({ messages: [] });
|
|
}
|
|
|
|
protected setLoading(loading: boolean): void {
|
|
this.updateState({ isLoading: loading });
|
|
}
|
|
|
|
protected setTyping(typing: boolean): void {
|
|
this.updateState({ isTyping: typing });
|
|
}
|
|
|
|
// Getters
|
|
getState(): MessagePanelState {
|
|
return { ...this.state };
|
|
}
|
|
|
|
getId(): string {
|
|
return this.state.id;
|
|
}
|
|
|
|
getTitle(): string {
|
|
return this.state.title;
|
|
}
|
|
|
|
getMessages(): Message[] {
|
|
return [...this.state.messages];
|
|
}
|
|
|
|
// ========== PUBLIC API ==========
|
|
|
|
// Event handlers
|
|
handleSendMessage(content: string, replyToId?: number, files: File[] = []): void {
|
|
this.sendMessageWithImmediateDisplay(content, replyToId, files);
|
|
}
|
|
|
|
async retryMessage(messageId: number): Promise<void> {
|
|
const message = this.getMessages().find(m => m.id === messageId);
|
|
if (!message?.runtimeData?.sendingState?.retryData) return;
|
|
|
|
const { content, replyToId, files } = message.runtimeData.sendingState.retryData;
|
|
|
|
// Create new temp ID for retry
|
|
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
|
|
// Update status back to sending and create new temp message
|
|
const retryMessage: Message = {
|
|
...message,
|
|
id: -1, // Temporary ID
|
|
// Preserve existing files (which may have blob URLs for display)
|
|
files: message.files,
|
|
runtimeData: {
|
|
...message.runtimeData,
|
|
sendingState: {
|
|
status: 'sending',
|
|
tempId,
|
|
retryData: {
|
|
content,
|
|
replyToId,
|
|
files: files || []
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Update the existing message to sending state
|
|
this.updateState({
|
|
messages: this.state.messages.map(msg => {
|
|
if (msg.id === messageId) {
|
|
return retryMessage;
|
|
}
|
|
return msg;
|
|
})
|
|
});
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
this.handleMessageTimeout(tempId);
|
|
}, 10000);
|
|
|
|
this.pendingMessages.set(tempId, { timeoutId, message: retryMessage });
|
|
|
|
try {
|
|
await this.sendMessage(content, replyToId, files || []);
|
|
// Note: Success will be handled by WebSocket confirmation
|
|
} catch (error) {
|
|
console.error("Failed to retry message:", error);
|
|
// Clear the timeout since we're handling the failure immediately
|
|
clearTimeout(timeoutId);
|
|
this.pendingMessages.delete(tempId);
|
|
|
|
// Update message to failed state directly
|
|
this.updateState({
|
|
messages: this.state.messages.map(msg => {
|
|
if (msg.runtimeData?.sendingState?.tempId === tempId) {
|
|
return {
|
|
...msg,
|
|
runtimeData: {
|
|
...msg.runtimeData,
|
|
sendingState: {
|
|
...msg.runtimeData.sendingState,
|
|
status: 'failed'
|
|
}
|
|
}
|
|
};
|
|
}
|
|
return msg;
|
|
})
|
|
});
|
|
}
|
|
}
|
|
|
|
handleMessageConfirmed(tempId: string, confirmedMessage: Message): void {
|
|
const pending = this.pendingMessages.get(tempId);
|
|
if (pending) {
|
|
clearTimeout(pending.timeoutId);
|
|
this.pendingMessages.delete(tempId);
|
|
|
|
// Replace temporary message with confirmed one
|
|
this.updateState({
|
|
messages: this.state.messages.map(msg => {
|
|
if (msg.runtimeData?.sendingState?.tempId === tempId) {
|
|
return {
|
|
...confirmedMessage,
|
|
// Preserve files from the temporary message (which have blob URLs for immediate display)
|
|
files: msg.files,
|
|
runtimeData: {
|
|
...confirmedMessage.runtimeData,
|
|
sendingState: {
|
|
status: 'sent'
|
|
}
|
|
}
|
|
};
|
|
}
|
|
return msg;
|
|
})
|
|
});
|
|
}
|
|
}
|
|
|
|
protected deleteMessageImmediately(messageId: number): void {
|
|
this.updateState({
|
|
messages: this.state.messages.filter(msg => msg.id !== messageId)
|
|
});
|
|
}
|
|
|
|
destroy(): void {
|
|
// Clear all pending timeouts
|
|
this.pendingMessages.forEach(({ timeoutId }) => {
|
|
clearTimeout(timeoutId);
|
|
});
|
|
this.pendingMessages.clear();
|
|
}
|
|
|
|
// ========== PRIVATE METHODS ==========
|
|
|
|
// Create and display message immediately with sending state
|
|
private async sendMessageWithImmediateDisplay(content: string, replyToId?: number, files: File[] = []): Promise<void> {
|
|
if (!content.trim() && files.length === 0) return;
|
|
|
|
// Create temporary message for immediate display
|
|
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
const tempMessage: Message = {
|
|
id: -1, // Temporary negative ID
|
|
username: this.currentUser.currentUser?.username ?? "You",
|
|
content: content.trim(),
|
|
is_read: false,
|
|
is_edited: false,
|
|
timestamp: new Date().toISOString(),
|
|
files: files.map(file => ({
|
|
name: file.name,
|
|
path: URL.createObjectURL(file),
|
|
encrypted: false
|
|
})),
|
|
runtimeData: {
|
|
sendingState: {
|
|
status: 'sending',
|
|
tempId,
|
|
retryData: {
|
|
content: content.trim(),
|
|
replyToId,
|
|
files: [...files]
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Add reply reference if present
|
|
if (replyToId) {
|
|
const referencedMessage = this.getMessages().find(m => m.id === replyToId);
|
|
if (referencedMessage) {
|
|
tempMessage.reply_to = referencedMessage;
|
|
}
|
|
}
|
|
|
|
// Add message immediately
|
|
this.addMessage(tempMessage);
|
|
|
|
// Set up timeout for failure
|
|
const timeoutId = setTimeout(() => {
|
|
this.handleMessageTimeout(tempId);
|
|
}, 10000); // 10 seconds timeout
|
|
|
|
// Store pending message
|
|
this.pendingMessages.set(tempId, { timeoutId, message: tempMessage });
|
|
|
|
// Actually send the message
|
|
try {
|
|
await this.sendMessage(content, replyToId, files);
|
|
// Message sent successfully - will be updated when WebSocket confirms
|
|
} catch (error) {
|
|
console.error("Failed to send message:", error);
|
|
this.handleMessageFailed(tempId);
|
|
}
|
|
}
|
|
|
|
// Handle message timeout (10 seconds)
|
|
private handleMessageTimeout(tempId: string): void {
|
|
this.updateMessageToFailed(tempId);
|
|
}
|
|
|
|
// Handle message send failure
|
|
private handleMessageFailed(tempId: string): void {
|
|
this.updateMessageToFailed(tempId);
|
|
}
|
|
|
|
// Helper method to update message to failed state
|
|
private updateMessageToFailed(tempId: string): void {
|
|
const pending = this.pendingMessages.get(tempId);
|
|
if (pending) {
|
|
clearTimeout(pending.timeoutId);
|
|
this.pendingMessages.delete(tempId);
|
|
|
|
// Update message to failed state
|
|
this.updateState({
|
|
messages: this.state.messages.map(msg => {
|
|
if (msg.runtimeData?.sendingState?.tempId === tempId) {
|
|
return {
|
|
...msg,
|
|
runtimeData: {
|
|
...msg.runtimeData,
|
|
sendingState: {
|
|
...msg.runtimeData.sendingState,
|
|
status: 'failed'
|
|
}
|
|
}
|
|
};
|
|
}
|
|
return msg;
|
|
})
|
|
});
|
|
}
|
|
}
|
|
|
|
abstract handleEditMessage(messageId: number, content: string): Promise<void>;
|
|
abstract handleDeleteMessage(messageId: number): Promise<void>;
|
|
}
|