diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index b14d215..00d2ecc 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -64,6 +64,15 @@ export interface Message { runtimeData?: { dmEnvelope?: DmEnvelope; + sendingState?: { + status: 'sending' | 'sent' | 'failed'; + tempId?: string; // Temporary ID for tracking until server confirms + retryData?: { + content: string; + replyToId?: number; + files?: File[]; + }; + }; } } diff --git a/frontend/src/resources/css/_chat.scss b/frontend/src/resources/css/_chat.scss index c4d30f7..19bb6ab 100644 --- a/frontend/src/resources/css/_chat.scss +++ b/frontend/src/resources/css/_chat.scss @@ -256,6 +256,7 @@ &:last-child { margin-bottom: 0; } + &.loading { filter: blur(10px); transition: filter 200ms ease; @@ -306,6 +307,32 @@ text-align: right; user-select: none; 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; + } + } } } diff --git a/frontend/src/ui/components/chat/ChatMessages.tsx b/frontend/src/ui/components/chat/ChatMessages.tsx index c53dd38..94c4ee8 100644 --- a/frontend/src/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/ui/components/chat/ChatMessages.tsx @@ -17,10 +17,11 @@ interface ChatMessagesProps { onReplySelect?: (message: MessageType) => void; onEditSelect?: (message: MessageType) => void; onDelete?: (id: number) => void; + onRetryMessage?: (messageId: number) => void; 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 { user } = useAppState(); @@ -117,6 +118,12 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o setDeleteDialogOpen(true); } + function handleRetry(message: MessageType) { + if (onRetryMessage) { + onRetryMessage(message.id); + } + } + return ( <>
@@ -162,6 +169,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o onEdit={handleEdit} onReply={handleReply} onDelete={handleDelete} + onRetry={handleRetry} position={contextMenu.position} isOpen={contextMenu.isOpen} onOpenChange={handleContextMenuOpenChange} diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index 0e27b5c..5750f01 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -78,7 +78,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo } }, [message.files, isDm, decryptedFiles]); - const decryptFile = async (file: Attachment): Promise => { + async function decryptFile(file: Attachment): Promise { if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) { debugger; 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 const decryptedUrl = decryptedFiles.get(file.path); 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 viewportHeight = window.innerHeight; const maxWidth = Math.floor(viewportWidth * 0.9); @@ -165,7 +165,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo 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 startRect = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; const tempImg = new Image(); @@ -186,7 +186,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo }; }; - const closeFullscreen = () => { + function closeFullscreen() { // Reverse animation setIsAnimatingOpen(false); // Wait for transition to finish @@ -198,7 +198,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo }, 300); }; - const downloadImage = async () => { + async function downloadImage() { if (!fullscreenImage) return; const { src, name } = fullscreenImage; try { @@ -232,7 +232,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo } }; - const downloadFile = async (file: Attachment) => { + async function downloadFile(file: Attachment) { try { updateDownloadingPaths(draft => { draft.add(file.path); @@ -286,7 +286,6 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo onContextMenu={handleContextMenu} >
- {/* Add profile picture for received messages */} {!isAuthor && !isDm && (
)} - {/* Add reply preview if this is a reply */} {message.reply_to && ( {message.reply_to.username} @@ -330,6 +328,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo const decryptedUrl = decryptedFiles.get(file.path); const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined; const isDownloading = downloadingPaths.has(file.path); + const isSending = message.runtimeData?.sendingState?.status === 'sending'; return (
@@ -345,7 +344,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })} className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`} /> - {!loadedImages.has(file.path) && ( + {(!loadedImages.has(file.path) || isSending) && (
@@ -380,6 +379,20 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo {isAuthor && message.is_read && ( )} + + {isAuthor && message.runtimeData?.sendingState && ( + + {message.runtimeData.sendingState.status === 'sending' && ( + + )} + {message.runtimeData.sendingState.status === 'failed' && ( + error + )} + {message.runtimeData.sendingState.status === 'sent' && ( + check + )} + + )}
diff --git a/frontend/src/ui/components/chat/MessageContextMenu.tsx b/frontend/src/ui/components/chat/MessageContextMenu.tsx index fb3476f..58f7f89 100644 --- a/frontend/src/ui/components/chat/MessageContextMenu.tsx +++ b/frontend/src/ui/components/chat/MessageContextMenu.tsx @@ -7,6 +7,7 @@ interface MessageContextMenuProps { onEdit: (message: Message) => void; onReply: (message: Message) => void; onDelete: (message: Message) => void; + onRetry?: (message: Message) => void; position: Size2D; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; @@ -24,6 +25,7 @@ export function MessageContextMenu({ onEdit, onReply, onDelete, + onRetry, position, isOpen, onOpenChange @@ -130,6 +132,11 @@ export function MessageContextMenu({ 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", @@ -138,7 +145,7 @@ export function MessageContextMenu({ onReply(message); handleClose(); }, - show: true + show: !isSendingOrFailed }, { label: "Edit", @@ -147,7 +154,18 @@ export function MessageContextMenu({ onEdit(message); handleClose(); }, - show: isAuthor + show: isAuthor && !isSendingOrFailed + }, + { + label: "Retry", + icon: "refresh", + onClick: () => { + if (onRetry) { + onRetry(message); + } + handleClose(); + }, + show: isAuthor && isFailed && !!onRetry }, { label: "Delete", diff --git a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx index cde7c07..0b4a38a 100644 --- a/frontend/src/ui/components/chat/MessagePanelRenderer.tsx +++ b/frontend/src/ui/components/chat/MessagePanelRenderer.tsx @@ -75,8 +75,14 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen // Cleanup function return () => { - if (panel && panel.onStateChange) { - panel.onStateChange = null; + if (panel) { + if (panel.onStateChange) { + panel.onStateChange = null; + } + // Call destroy to clean up pending timeouts + if (typeof panel.destroy === 'function') { + panel.destroy(); + } } }; }, [panel]); @@ -221,6 +227,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen } }} onDelete={(id) => panel.handleDeleteMessage(id)} + onRetryMessage={(id) => panel.retryMessage(id)} >
diff --git a/frontend/src/ui/panels/DMPanel.ts b/frontend/src/ui/panels/DMPanel.ts index dbaed1b..a100de7 100644 --- a/frontend/src/ui/panels/DMPanel.ts +++ b/frontend/src/ui/panels/DMPanel.ts @@ -117,7 +117,7 @@ export class DMPanel extends MessagePanel { } } - async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise { + protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise { if (!this.currentUser.authToken || !this.dmData || !content.trim()) return; try { @@ -172,6 +172,20 @@ export class DMPanel extends MessagePanel { 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 @@ -258,7 +272,11 @@ export class DMPanel extends MessagePanel { async handleDeleteMessage(messageId: number): Promise { 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); } diff --git a/frontend/src/ui/panels/MessagePanel.ts b/frontend/src/ui/panels/MessagePanel.ts index b1e74b0..3f24cd7 100644 --- a/frontend/src/ui/panels/MessagePanel.ts +++ b/frontend/src/ui/panels/MessagePanel.ts @@ -23,6 +23,7 @@ export abstract class MessagePanel { protected state: MessagePanelState; public onStateChange: ((state: MessagePanelState) => void) | null = () => {}; protected readonly currentUser: UserState; + private pendingMessages: Map = new Map(); constructor( id: string, @@ -43,7 +44,7 @@ export abstract class MessagePanel { abstract activate(): Promise; abstract deactivate(): void; abstract loadMessages(): Promise; - abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise; + protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise; abstract isDm(): boolean; // Optional WebSocket message handler (can be overridden by subclasses) @@ -68,9 +69,16 @@ export abstract class MessagePanel { protected updateMessage(messageId: number, updates: Partial): void { this.updateState({ - messages: this.state.messages.map(msg => - msg.id === messageId ? { ...msg, ...updates } : msg - ) + 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; + }) }); } @@ -109,10 +117,226 @@ export abstract class MessagePanel { return [...this.state.messages]; } + // ========== PUBLIC API ========== + // Event handlers handleSendMessage(content: string, replyToId?: number, files: File[] = []): void { - this.sendMessage(content, replyToId, files); - }; + this.sendMessageWithImmediateDisplay(content, replyToId, files); + } + + async retryMessage(messageId: number): Promise { + 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 + 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, + 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 { + 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; abstract handleDeleteMessage(messageId: number): Promise; abstract handleProfileClick(): void; diff --git a/frontend/src/ui/panels/PublicChatPanel.ts b/frontend/src/ui/panels/PublicChatPanel.ts index ca9a403..2170d20 100644 --- a/frontend/src/ui/panels/PublicChatPanel.ts +++ b/frontend/src/ui/panels/PublicChatPanel.ts @@ -59,7 +59,7 @@ export class PublicChatPanel extends MessagePanel { } } - async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise { + protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise { if (!this.currentUser.authToken || !content.trim()) return; try { @@ -114,7 +114,22 @@ export class PublicChatPanel extends MessagePanel { break; case 'newMessage': 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; } @@ -159,6 +174,10 @@ export class PublicChatPanel extends MessagePanel { } async handleDeleteMessage(id: number): Promise { + // Remove message immediately from UI + this.deleteMessageImmediately(id); + + // Fire and forget server deletion; UI already updated await request({ type: "deleteMessage", data: { message_id: id },