Implement message sending timeout, failed state, retry

This commit is contained in:
2025-09-28 16:54:42 +03:00
Unverified
parent 2f31b15dea
commit 607ac91e5a
9 changed files with 368 additions and 25 deletions
+9
View File
@@ -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[];
};
};
}
}
+27
View File
@@ -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;
}
}
}
}
@@ -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 (
<>
<div className="chat-messages" id="chat-messages">
@@ -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}
+23 -10
View File
@@ -78,7 +78,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
}
}, [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) {
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}
>
<div className="message-inner">
{/* Add profile picture for received messages */}
{!isAuthor && !isDm && (
<div className="message-profile-pic">
<img
@@ -312,7 +311,6 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
</div>
)}
{/* Add reply preview if this is a reply */}
{message.reply_to && (
<Quote className="reply-preview contextual-content" background={isAuthor ? "primaryContainer" : "surfaceContainer"}>
<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 imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined;
const isDownloading = downloadingPaths.has(file.path);
const isSending = message.runtimeData?.sendingState?.status === 'sending';
return (
<div className="attachment" key={idx}>
@@ -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) && (
<div className="loading-overlay">
<mdui-circular-progress />
</div>
@@ -380,6 +379,20 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
{isAuthor && message.is_read && (
<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>
@@ -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",
@@ -75,9 +75,15 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
// Cleanup function
return () => {
if (panel && panel.onStateChange) {
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)}
>
<div ref={messagesEndRef} />
</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;
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<void> {
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);
}
+229 -5
View File
@@ -23,6 +23,7 @@ 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,
@@ -43,7 +44,7 @@ export abstract class MessagePanel {
abstract activate(): Promise<void>;
abstract deactivate(): 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;
// 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 {
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<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
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<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;
+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;
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<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 },