Merge branch 'feature/message-sending-timeout/2'

This commit is contained in:
2025-09-28 17:20:14 +03:00
Unverified
9 changed files with 420 additions and 59 deletions
+9
View File
@@ -64,6 +64,15 @@ export interface Message {
runtimeData?: { runtimeData?: {
dmEnvelope?: DmEnvelope; dmEnvelope?: DmEnvelope;
sendingState?: {
status: 'sending' | 'sent' | 'failed';
tempId?: string; // Temporary ID for tracking until server confirms
retryData?: {
content: string;
replyToId?: number;
files?: File[];
};
};
} }
} }
+27
View File
@@ -256,6 +256,7 @@
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
&.loading { &.loading {
filter: blur(10px); filter: blur(10px);
transition: filter 200ms ease; transition: filter 200ms ease;
@@ -306,6 +307,32 @@
text-align: right; text-align: right;
user-select: none; user-select: none;
margin: 4px 8px 8px 8px; margin: 4px 8px 8px 8px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
.message-status-indicator {
display: flex;
align-items: center;
width: 16px;
height: 16px;
.error-icon {
color: #f44336;
font-size: 16px;
}
.success-icon {
color: #4caf50;
font-size: 16px;
}
mdui-circular-progress {
width: 16px;
height: 16px;
}
}
} }
} }
@@ -17,10 +17,11 @@ interface ChatMessagesProps {
onReplySelect?: (message: MessageType) => void; onReplySelect?: (message: MessageType) => void;
onEditSelect?: (message: MessageType) => void; onEditSelect?: (message: MessageType) => void;
onDelete?: (id: number) => void; onDelete?: (id: number) => void;
onRetryMessage?: (messageId: number) => void;
dmRecipientPublicKey?: string; dmRecipientPublicKey?: string;
} }
export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect, onDelete, dmRecipientPublicKey }: ChatMessagesProps) { export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) {
const { messages: hookMessages } = useChat(); const { messages: hookMessages } = useChat();
const { user } = useAppState(); const { user } = useAppState();
@@ -117,6 +118,12 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
} }
function handleRetry(message: MessageType) {
if (onRetryMessage) {
onRetryMessage(message.id);
}
}
return ( return (
<> <>
<div className="chat-messages" id="chat-messages"> <div className="chat-messages" id="chat-messages">
@@ -162,6 +169,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
onEdit={handleEdit} onEdit={handleEdit}
onReply={handleReply} onReply={handleReply}
onDelete={handleDelete} onDelete={handleDelete}
onRetry={handleRetry}
position={contextMenu.position} position={contextMenu.position}
isOpen={contextMenu.isOpen} isOpen={contextMenu.isOpen}
onOpenChange={handleContextMenuOpenChange} onOpenChange={handleContextMenuOpenChange}
+23 -10
View File
@@ -78,7 +78,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
} }
}, [message.files, isDm, decryptedFiles]); }, [message.files, isDm, decryptedFiles]);
const decryptFile = async (file: Attachment): Promise<string | null> => { async function decryptFile(file: Attachment): Promise<string | null> {
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) { if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) {
debugger; debugger;
console.warn("Conditions not met") console.warn("Conditions not met")
@@ -135,7 +135,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
} }
}; };
const handleImageClick = async (file: Attachment, imageElement: HTMLImageElement) => { async function handleImageClick(file: Attachment, imageElement: HTMLImageElement) {
// Use decrypted URL if available, otherwise decrypt first // Use decrypted URL if available, otherwise decrypt first
const decryptedUrl = decryptedFiles.get(file.path); const decryptedUrl = decryptedFiles.get(file.path);
if (decryptedUrl) { if (decryptedUrl) {
@@ -150,7 +150,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
} }
}; };
const computeEndRect = (naturalWidth: number, naturalHeight: number): Rect => { function computeEndRect(naturalWidth: number, naturalHeight: number): Rect {
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
const maxWidth = Math.floor(viewportWidth * 0.9); const maxWidth = Math.floor(viewportWidth * 0.9);
@@ -165,7 +165,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
return { left, top, width, height }; return { left, top, width, height };
}; };
const openFullscreenFromThumb = (imgEl: HTMLImageElement, src: string, name: string) => { function openFullscreenFromThumb(imgEl: HTMLImageElement, src: string, name: string) {
const rect = imgEl.getBoundingClientRect(); const rect = imgEl.getBoundingClientRect();
const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; const startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
const tempImg = new Image(); const tempImg = new Image();
@@ -186,7 +186,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
}; };
}; };
const closeFullscreen = () => { function closeFullscreen() {
// Reverse animation // Reverse animation
setIsAnimatingOpen(false); setIsAnimatingOpen(false);
// Wait for transition to finish // Wait for transition to finish
@@ -198,7 +198,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
}, 300); }, 300);
}; };
const downloadImage = async () => { async function downloadImage() {
if (!fullscreenImage) return; if (!fullscreenImage) return;
const { src, name } = fullscreenImage; const { src, name } = fullscreenImage;
try { try {
@@ -232,7 +232,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
} }
}; };
const downloadFile = async (file: Attachment) => { async function downloadFile(file: Attachment) {
try { try {
updateDownloadingPaths(draft => { updateDownloadingPaths(draft => {
draft.add(file.path); draft.add(file.path);
@@ -286,7 +286,6 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<div className="message-inner"> <div className="message-inner">
{/* Add profile picture for received messages */}
{!isAuthor && !isDm && ( {!isAuthor && !isDm && (
<div className="message-profile-pic"> <div className="message-profile-pic">
<img <img
@@ -312,7 +311,6 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
</div> </div>
)} )}
{/* Add reply preview if this is a reply */}
{message.reply_to && ( {message.reply_to && (
<Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}> <Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
<span className="reply-username">{message.reply_to.username}</span> <span className="reply-username">{message.reply_to.username}</span>
@@ -330,6 +328,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
const decryptedUrl = decryptedFiles.get(file.path); const decryptedUrl = decryptedFiles.get(file.path);
const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined; const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined;
const isDownloading = downloadingPaths.has(file.path); const isDownloading = downloadingPaths.has(file.path);
const isSending = message.runtimeData?.sendingState?.status === 'sending';
return ( return (
<div className="attachment" key={idx}> <div className="attachment" key={idx}>
@@ -345,7 +344,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })} onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`} className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`}
/> />
{!loadedImages.has(file.path) && ( {(!loadedImages.has(file.path) || isSending) && (
<div className="loading-overlay"> <div className="loading-overlay">
<mdui-circular-progress /> <mdui-circular-progress />
</div> </div>
@@ -380,6 +379,20 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
{isAuthor && message.is_read && ( {isAuthor && message.is_read && (
<span className="material-symbols outlined"></span> <span className="material-symbols outlined"></span>
)} )}
{isAuthor && message.runtimeData?.sendingState && (
<span className="message-status-indicator">
{message.runtimeData.sendingState.status === 'sending' && (
<mdui-circular-progress style={{ width: '16px', height: '16px' }} />
)}
{message.runtimeData.sendingState.status === 'failed' && (
<span className="material-symbols error-icon">error</span>
)}
{message.runtimeData.sendingState.status === 'sent' && (
<span className="material-symbols success-icon">check</span>
)}
</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -7,6 +7,7 @@ interface MessageContextMenuProps {
onEdit: (message: Message) => void; onEdit: (message: Message) => void;
onReply: (message: Message) => void; onReply: (message: Message) => void;
onDelete: (message: Message) => void; onDelete: (message: Message) => void;
onRetry?: (message: Message) => void;
position: Size2D; position: Size2D;
isOpen: boolean; isOpen: boolean;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
@@ -24,6 +25,7 @@ export function MessageContextMenu({
onEdit, onEdit,
onReply, onReply,
onDelete, onDelete,
onRetry,
position, position,
isOpen, isOpen,
onOpenChange onOpenChange
@@ -108,27 +110,8 @@ export function MessageContextMenu({
window.removeEventListener('blur', handleWindowBlur); window.removeEventListener('blur', handleWindowBlur);
}; };
}, [isOpen, isClosing]); }, [isOpen, isClosing]);
const handleAction = (action: string) => {
switch (action) {
case "reply":
onReply(message);
handleClose();
break;
case "edit":
if (isAuthor) onEdit(message);
break;
case "delete":
if (isAuthor) {
onDelete(message);
handleClose();
}
break;
}
onOpenChange(false);
};
const handleClose = () => { function handleClose() {
setIsClosing(true); setIsClosing(true);
// Set appropriate closing animation based on opening animation // Set appropriate closing animation based on opening animation
const closingAnimation = animationClass.replace('entering', 'closing'); const closingAnimation = animationClass.replace('entering', 'closing');
@@ -140,7 +123,60 @@ export function MessageContextMenu({
setIsClosing(false); setIsClosing(false);
setAnimationClass('entering'); // Reset for next opening setAnimationClass('entering'); // Reset for next opening
}, 200); // Match the animation duration from _animations.scss }, 200); // Match the animation duration from _animations.scss
}; }
interface Action {
label: string;
icon: string;
onClick: () => void;
show: boolean;
}
// Check if message is sending or failed
const isSending = message.runtimeData?.sendingState?.status === 'sending';
const isFailed = message.runtimeData?.sendingState?.status === 'failed';
const isSendingOrFailed = isSending || isFailed;
const actions: Action[] = [
{
label: "Reply",
icon: "reply",
onClick: () => {
onReply(message);
handleClose();
},
show: !isSendingOrFailed
},
{
label: "Edit",
icon: "edit",
onClick: () => {
onEdit(message);
handleClose();
},
show: isAuthor && !isSendingOrFailed
},
{
label: "Retry",
icon: "refresh",
onClick: () => {
if (onRetry) {
onRetry(message);
}
handleClose();
},
show: isAuthor && isFailed && !!onRetry
},
{
label: "Delete",
icon: "delete",
onClick: () => {
onDelete(message);
handleClose();
},
show: isAuthor
},
];
return isOpen && ( return isOpen && (
<div <div
@@ -153,22 +189,18 @@ export function MessageContextMenu({
zIndex: 1000 zIndex: 1000
}} }}
onClick={(e) => e.stopPropagation()}> onClick={(e) => e.stopPropagation()}>
<div className="context-menu-item" onClick={() => handleAction("reply")}> {actions.map((action, i) => (
<span className="material-symbols">reply</span> action.show && (
Ответить <div
</div> className="context-menu-item"
{isAuthor && ( onClick={action.onClick}
<> key={i}
<div className="context-menu-item" onClick={() => handleAction("edit")}> >
<span className="material-symbols">edit</span> <span className="material-symbols">{action.icon}</span>
Редактировать {action.label}
</div> </div>
<div className="context-menu-item" onClick={() => handleAction("delete")}> )
<span className="material-symbols">delete</span> ))}
Удалить
</div>
</>
)}
</div> </div>
) )
} }
@@ -75,8 +75,14 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
// Cleanup function // Cleanup function
return () => { return () => {
if (panel && panel.onStateChange) { if (panel) {
panel.onStateChange = null; if (panel.onStateChange) {
panel.onStateChange = null;
}
// Call destroy to clean up pending timeouts
if (typeof panel.destroy === 'function') {
panel.destroy();
}
} }
}; };
}, [panel]); }, [panel]);
@@ -221,6 +227,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
} }
}} }}
onDelete={(id) => panel.handleDeleteMessage(id)} onDelete={(id) => panel.handleDeleteMessage(id)}
onRetryMessage={(id) => panel.retryMessage(id)}
> >
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</ChatMessages> </ChatMessages>
+20 -2
View File
@@ -117,7 +117,7 @@ export class DMPanel extends MessagePanel {
} }
} }
async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> { protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !this.dmData || !content.trim()) return; if (!this.currentUser.authToken || !this.dmData || !content.trim()) return;
try { try {
@@ -172,6 +172,20 @@ export class DMPanel extends MessagePanel {
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) { if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
try { try {
const dmMsg = await this.parseTextPayload(envelope, this.getMessages()); 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); this.addMessage(dmMsg);
// Update last read if it's from the other user // Update last read if it's from the other user
@@ -258,7 +272,11 @@ export class DMPanel extends MessagePanel {
async handleDeleteMessage(messageId: number): Promise<void> { async handleDeleteMessage(messageId: number): Promise<void> {
if (!this.currentUser.authToken || !this.dmData) return; if (!this.currentUser.authToken || !this.dmData) return;
// Fire and forget; UI will update via dmDeleted
// 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); await deleteDmEnvelope(messageId, this.dmData.userId, this.currentUser.authToken);
} }
+234 -6
View File
@@ -23,6 +23,7 @@ export abstract class MessagePanel {
protected state: MessagePanelState; protected state: MessagePanelState;
public onStateChange: ((state: MessagePanelState) => void) | null = () => {}; public onStateChange: ((state: MessagePanelState) => void) | null = () => {};
protected readonly currentUser: UserState; protected readonly currentUser: UserState;
private pendingMessages: Map<string, { timeoutId: NodeJS.Timeout; message: Message }> = new Map();
constructor( constructor(
id: string, id: string,
@@ -43,7 +44,7 @@ export abstract class MessagePanel {
abstract activate(): Promise<void>; abstract activate(): Promise<void>;
abstract deactivate(): void; abstract deactivate(): void;
abstract loadMessages(): Promise<void>; abstract loadMessages(): Promise<void>;
abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>; protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
abstract isDm(): boolean; abstract isDm(): boolean;
// Optional WebSocket message handler (can be overridden by subclasses) // Optional WebSocket message handler (can be overridden by subclasses)
@@ -68,9 +69,16 @@ export abstract class MessagePanel {
protected updateMessage(messageId: number, updates: Partial<Message>): void { protected updateMessage(messageId: number, updates: Partial<Message>): void {
this.updateState({ this.updateState({
messages: this.state.messages.map(msg => messages: this.state.messages.map(msg => {
msg.id === messageId ? { ...msg, ...updates } : 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;
})
}); });
} }
@@ -109,10 +117,230 @@ export abstract class MessagePanel {
return [...this.state.messages]; return [...this.state.messages];
} }
// ========== PUBLIC API ==========
// Event handlers // Event handlers
handleSendMessage(content: string, replyToId?: number, files: File[] = []): void { handleSendMessage(content: string, replyToId?: number, files: File[] = []): void {
this.sendMessage(content, replyToId, files); 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 handleEditMessage(messageId: number, content: string): Promise<void>;
abstract handleDeleteMessage(messageId: number): Promise<void>; abstract handleDeleteMessage(messageId: number): Promise<void>;
abstract handleProfileClick(): void; abstract handleProfileClick(): void;
+21 -2
View File
@@ -59,7 +59,7 @@ export class PublicChatPanel extends MessagePanel {
} }
} }
async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> { protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise<void> {
if (!this.currentUser.authToken || !content.trim()) return; if (!this.currentUser.authToken || !content.trim()) return;
try { try {
@@ -114,7 +114,22 @@ export class PublicChatPanel extends MessagePanel {
break; break;
case 'newMessage': case 'newMessage':
if (response.data) { if (response.data) {
this.addMessage(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; break;
} }
@@ -159,6 +174,10 @@ export class PublicChatPanel extends MessagePanel {
} }
async handleDeleteMessage(id: number): Promise<void> { async handleDeleteMessage(id: number): Promise<void> {
// Remove message immediately from UI
this.deleteMessageImmediately(id);
// Fire and forget server deletion; UI already updated
await request({ await request({
type: "deleteMessage", type: "deleteMessage",
data: { message_id: id }, data: { message_id: id },