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 },