mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-23 03:25:07 +03:00
Restructure
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
import { MessagePanel } from "./MessagePanel";
|
||||
import {
|
||||
fetchDMHistory,
|
||||
decryptDm,
|
||||
sendDMViaWebSocket,
|
||||
sendDmWithFiles,
|
||||
editDmEnvelope,
|
||||
deleteDmEnvelope
|
||||
} from "../../../../../core/api/dmApi";
|
||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../../../../core/types";
|
||||
import type { UserState } from "../../../state";
|
||||
|
||||
export interface DMPanelData {
|
||||
userId: number;
|
||||
username: string;
|
||||
publicKey: string;
|
||||
profilePicture?: string;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export class DMPanel extends MessagePanel {
|
||||
public dmData: DMPanelData | null = null;
|
||||
private messagesLoaded: boolean = false;
|
||||
|
||||
constructor(
|
||||
user: UserState
|
||||
) {
|
||||
super("dm", user);
|
||||
}
|
||||
|
||||
isDm(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
// Don't load messages immediately during activation to prevent animation freeze
|
||||
// Messages will be loaded after the animation completes
|
||||
}
|
||||
|
||||
deactivate(): void {
|
||||
// DM doesn't need special cleanup
|
||||
}
|
||||
|
||||
clearMessages(): void {
|
||||
super.clearMessages();
|
||||
this.messagesLoaded = false;
|
||||
}
|
||||
|
||||
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
|
||||
const plaintext = await decryptDm(env, this.dmData!.publicKey);
|
||||
const isAuthor = env.senderId !== this.dmData!.userId;
|
||||
const username = isAuthor ? this.currentUser.currentUser?.username ?? "You" : this.dmData!.username;
|
||||
|
||||
// Try parse JSON payload { type: "text", data: { content, files?, reply_to_id? } }
|
||||
let content = plaintext;
|
||||
let reply_to_id: number | undefined = undefined;
|
||||
try {
|
||||
const obj = JSON.parse(plaintext) as DmEncryptedJSON;
|
||||
if (obj && obj.type === "text" && obj.data) {
|
||||
content = obj.data.content;
|
||||
reply_to_id = Number(obj.data.reply_to_id) || undefined;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const dmMsg: Message = {
|
||||
id: env.id,
|
||||
content: content,
|
||||
username: username,
|
||||
timestamp: env.timestamp,
|
||||
is_read: false,
|
||||
is_edited: false,
|
||||
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
|
||||
reactions: env.reactions || [],
|
||||
|
||||
runtimeData: {
|
||||
dmEnvelope: env
|
||||
}
|
||||
};
|
||||
|
||||
if (reply_to_id) {
|
||||
const referenced = decryptedMessages.find(m => m.id === reply_to_id);
|
||||
if (referenced) dmMsg.reply_to = referenced;
|
||||
}
|
||||
|
||||
return dmMsg;
|
||||
}
|
||||
|
||||
async loadMessages(): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData || this.messagesLoaded) return;
|
||||
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const messages = await fetchDMHistory(this.dmData.userId, this.currentUser.authToken, 50);
|
||||
const decryptedMessages: Message[] = [];
|
||||
let maxIncomingId = 0;
|
||||
|
||||
for (const env of messages) {
|
||||
try {
|
||||
const dmMsg = await this.parseTextPayload(env, decryptedMessages);
|
||||
decryptedMessages.push(dmMsg);
|
||||
|
||||
if (env.senderId === this.dmData!.userId && env.id > maxIncomingId) {
|
||||
maxIncomingId = env.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error decrypting message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
this.clearMessages();
|
||||
decryptedMessages.forEach(msg => this.addMessage(msg));
|
||||
|
||||
// Update last read ID
|
||||
if (maxIncomingId > 0) {
|
||||
this.setLastReadId(this.dmData.userId, maxIncomingId);
|
||||
}
|
||||
this.messagesLoaded = true;
|
||||
} catch (error) {
|
||||
console.error("Failed to load DM history:", error);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
|
||||
|
||||
try {
|
||||
const payload: DmEncryptedJSON = {
|
||||
type: "text",
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? undefined
|
||||
}
|
||||
}
|
||||
const json = JSON.stringify(payload);
|
||||
|
||||
if (files.length === 0) {
|
||||
await sendDMViaWebSocket(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
json,
|
||||
this.currentUser.authToken
|
||||
);
|
||||
} else {
|
||||
await sendDmWithFiles(
|
||||
this.dmData.userId,
|
||||
this.dmData.publicKey,
|
||||
json,
|
||||
files,
|
||||
this.currentUser.authToken
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Set DM conversation data
|
||||
setDMData(dmData: DMPanelData): void {
|
||||
this.dmData = dmData;
|
||||
this.messagesLoaded = false;
|
||||
this.updateState({
|
||||
id: `dm-${dmData.userId}`,
|
||||
title: dmData.username,
|
||||
profilePicture: dmData.profilePicture,
|
||||
online: dmData.online
|
||||
});
|
||||
}
|
||||
|
||||
// Handle incoming WebSocket DM messages
|
||||
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
|
||||
if (response.type === "dmNew" && this.dmData) {
|
||||
const envelope = response.data;
|
||||
|
||||
// If this is for the active DM conversation
|
||||
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
|
||||
try {
|
||||
const dmMsg = await this.parseTextPayload(envelope, this.getMessages());
|
||||
|
||||
// Check if this is a confirmation of a message we sent
|
||||
const isOurMessage = envelope.senderId !== this.dmData.userId;
|
||||
if (isOurMessage) {
|
||||
// This is our message being confirmed, find the temp message and replace it
|
||||
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
||||
for (const tempMsg of tempMessages) {
|
||||
if (tempMsg.runtimeData?.sendingState?.retryData?.content === dmMsg.content) {
|
||||
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId!, dmMsg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.addMessage(dmMsg);
|
||||
|
||||
// Update last read if it's from the other user
|
||||
if (envelope.senderId === this.dmData.userId) {
|
||||
this.setLastReadId(this.dmData.userId, Math.max(this.getLastReadId(this.dmData.userId), envelope.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt incoming DM:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (response.type === "dmEdited" && this.dmData) {
|
||||
const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data;
|
||||
try {
|
||||
// Decrypt new content in-place
|
||||
const plaintext = await decryptDm(
|
||||
{
|
||||
id,
|
||||
senderId: 0,
|
||||
recipientId: 0,
|
||||
iv,
|
||||
ciphertext,
|
||||
salt,
|
||||
iv2,
|
||||
wrappedMk,
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
this.dmData.publicKey
|
||||
);
|
||||
let content = plaintext;
|
||||
let files: Message["files"] | undefined = undefined;
|
||||
try {
|
||||
const obj = JSON.parse(plaintext) as EncryptedMessageJson;
|
||||
if (obj.type === "text" && obj.data) {
|
||||
content = obj.data.content;
|
||||
files = obj.data.files;
|
||||
}
|
||||
} catch {}
|
||||
const updates: Partial<Message> = { content, is_edited: true, files };
|
||||
this.updateMessage(id, updates);
|
||||
} catch (e) {
|
||||
this.updateMessage(id, { is_edited: true });
|
||||
}
|
||||
}
|
||||
if (response.type === "dmDeleted" && this.dmData) {
|
||||
const { id } = response.data;
|
||||
this.removeMessage(id);
|
||||
}
|
||||
if (response.type === "dmReactionUpdate" && this.dmData) {
|
||||
const { dm_envelope_id, reactions } = response.data;
|
||||
this.updateMessageReactions(dm_envelope_id, reactions);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset for DM switching
|
||||
reset(): void {
|
||||
this.dmData = null;
|
||||
this.messagesLoaded = false;
|
||||
this.clearMessages();
|
||||
this.updateState({
|
||||
id: "dm",
|
||||
title: "Select a user",
|
||||
profilePicture: undefined,
|
||||
online: false
|
||||
});
|
||||
}
|
||||
|
||||
// Update auth token
|
||||
setAuthToken(authToken: string): void {
|
||||
this.currentUser.authToken = authToken;
|
||||
}
|
||||
|
||||
// Helper functions for localStorage
|
||||
private getLastReadId(userId: number): number {
|
||||
try {
|
||||
const v = localStorage.getItem(`dmLastRead:${userId}`);
|
||||
return v ? Number(v) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private setLastReadId(userId: number, id: number): void {
|
||||
try {
|
||||
localStorage.setItem(`dmLastRead:${userId}`, String(id));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async handleDeleteMessage(messageId: number): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData) return;
|
||||
|
||||
// Remove message immediately from UI
|
||||
this.deleteMessageImmediately(messageId);
|
||||
|
||||
// Fire and forget server deletion; UI already updated
|
||||
await deleteDmEnvelope(messageId, this.dmData.userId, this.currentUser.authToken);
|
||||
}
|
||||
|
||||
async handleEditMessage(messageId: number, content: string): Promise<void> {
|
||||
if (!this.currentUser.authToken || !this.dmData) return;
|
||||
const msg = this.getMessages().find(m => m.id === messageId);
|
||||
// Build encrypted JSON preserving files and reply_to if present
|
||||
const payload: EncryptedMessageJson = {
|
||||
type: "text",
|
||||
data: {
|
||||
content: content,
|
||||
files: msg?.files,
|
||||
reply_to_id: msg?.reply_to?.id ?? undefined
|
||||
}
|
||||
};
|
||||
editDmEnvelope(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => {
|
||||
console.error("Failed to edit DM:", e);
|
||||
});
|
||||
}
|
||||
|
||||
handleProfileClick(): void {}
|
||||
|
||||
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
|
||||
const messages = this.getMessages();
|
||||
const messageIndex = messages.findIndex(msg =>
|
||||
msg.runtimeData?.dmEnvelope?.id === dmEnvelopeId
|
||||
);
|
||||
|
||||
if (messageIndex !== -1) {
|
||||
const updatedMessage = { ...messages[messageIndex] };
|
||||
updatedMessage.reactions = reactions;
|
||||
this.updateMessage(updatedMessage.id, { reactions: reactions });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import type { Message, WebSocketMessage } from "../../../../../core/types";
|
||||
import type { UserState } from "../../../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>;
|
||||
|
||||
// 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>;
|
||||
abstract handleProfileClick(): void;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { MessagePanel } from "./MessagePanel";
|
||||
import { API_BASE_URL } from "../../../../../core/config";
|
||||
import { getAuthHeaders } from "../../../../../core/api/authApi";
|
||||
import { request } from "../../../../../core/websocket";
|
||||
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../../../../core/types";
|
||||
import type { UserState } from "../../../state";
|
||||
|
||||
export class PublicChatPanel extends MessagePanel {
|
||||
private messagesLoaded: boolean = false;
|
||||
|
||||
constructor(
|
||||
chatName: string,
|
||||
currentUser: UserState
|
||||
) {
|
||||
super(`public-${chatName}`, currentUser);
|
||||
this.updateState({
|
||||
title: chatName,
|
||||
online: true // Public chats are always "online"
|
||||
});
|
||||
}
|
||||
|
||||
isDm(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async activate(): Promise<void> {
|
||||
// Don't load messages immediately during activation to prevent animation freeze
|
||||
// Messages will be loaded after the animation completes
|
||||
}
|
||||
|
||||
deactivate(): void {
|
||||
// Public chat doesn't need special cleanup
|
||||
}
|
||||
|
||||
clearMessages(): void {
|
||||
super.clearMessages();
|
||||
this.messagesLoaded = false;
|
||||
}
|
||||
|
||||
async loadMessages(): Promise<void> {
|
||||
if (!this.currentUser.authToken || this.messagesLoaded) return;
|
||||
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/get_messages`, {
|
||||
headers: getAuthHeaders(this.currentUser.authToken)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
this.clearMessages();
|
||||
data.messages.forEach((msg: Message) => {
|
||||
this.addMessage(msg);
|
||||
});
|
||||
}
|
||||
}
|
||||
this.messagesLoaded = true;
|
||||
} catch (error) {
|
||||
console.error("Error loading public chat messages:", error);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
|
||||
if (!this.currentUser.authToken || !content.trim()) return;
|
||||
|
||||
try {
|
||||
if (files.length === 0) {
|
||||
const response = await request({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken
|
||||
},
|
||||
type: "sendMessage"
|
||||
} satisfies SendMessageRequest);
|
||||
if (response.error) {
|
||||
console.error("Error sending message:", response.error);
|
||||
}
|
||||
} else {
|
||||
const form = new FormData();
|
||||
form.append("payload", JSON.stringify({
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
} satisfies SendMessageRequest["data"]));
|
||||
for (const f of files) form.append("files", f, f.name);
|
||||
const res = await fetch(`${API_BASE_URL}/send_message`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(this.currentUser.authToken, false),
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error("Error sending message with files", await res.text());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming WebSocket messages
|
||||
async handleWebSocketMessage(response: ChatWebSocketMessage | ReactionUpdateWebSocketMessage): Promise<void> {
|
||||
switch (response.type) {
|
||||
case 'messageEdited':
|
||||
if (response.data) {
|
||||
this.updateMessage(response.data.id, response.data);
|
||||
}
|
||||
break;
|
||||
case 'messageDeleted':
|
||||
if (response.data && response.data.message_id) {
|
||||
this.removeMessage(response.data.message_id);
|
||||
}
|
||||
break;
|
||||
case 'newMessage':
|
||||
if (response.data) {
|
||||
const newMsg = response.data;
|
||||
|
||||
// Check if this is a confirmation of a message we sent
|
||||
const isOurMessage = newMsg.username === this.currentUser.currentUser?.username;
|
||||
if (isOurMessage) {
|
||||
// This is our message being confirmed, find the temp message and replace it
|
||||
const tempMessages = this.getMessages().filter(m => m.id === -1 && m.runtimeData?.sendingState?.tempId);
|
||||
for (const tempMsg of tempMessages) {
|
||||
if (tempMsg.runtimeData?.sendingState?.retryData?.content === newMsg.content) {
|
||||
this.handleMessageConfirmed(tempMsg.runtimeData.sendingState.tempId!, newMsg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.addMessage(newMsg);
|
||||
}
|
||||
break;
|
||||
case 'reactionUpdate':
|
||||
if (response.data) {
|
||||
this.updateMessageReactions(response.data.message_id, response.data.reactions);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Reset for chat switching
|
||||
reset(): void {
|
||||
this.messagesLoaded = false;
|
||||
this.clearMessages();
|
||||
}
|
||||
|
||||
// Update chat name
|
||||
setChatName(chatName: string): void {
|
||||
this.updateState({
|
||||
id: `public-${chatName}`,
|
||||
title: chatName
|
||||
});
|
||||
}
|
||||
|
||||
// Update auth token
|
||||
setAuthToken(authToken: string): void {
|
||||
this.currentUser.authToken = authToken;
|
||||
}
|
||||
|
||||
async handleEditMessage(messageId: number, content: string): Promise<void> {
|
||||
if (!this.currentUser.authToken) return;
|
||||
try {
|
||||
await request({
|
||||
type: "editMessage",
|
||||
data: {
|
||||
message_id: messageId,
|
||||
content: content
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to edit message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeleteMessage(id: number): Promise<void> {
|
||||
// Remove message immediately from UI
|
||||
this.deleteMessageImmediately(id);
|
||||
|
||||
// Fire and forget server deletion; UI already updated
|
||||
await request({
|
||||
type: "deleteMessage",
|
||||
data: { message_id: id },
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.currentUser.authToken!
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleProfileClick(): void {}
|
||||
}
|
||||
Reference in New Issue
Block a user