mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Merge branch 'feature/message-sending-timeout/2'
This commit is contained in:
Vendored
+9
@@ -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[];
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -109,26 +111,7 @@ export function MessageContextMenu({
|
||||
};
|
||||
}, [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);
|
||||
// Set appropriate closing animation based on opening animation
|
||||
const closingAnimation = animationClass.replace('entering', 'closing');
|
||||
@@ -140,7 +123,60 @@ export function MessageContextMenu({
|
||||
setIsClosing(false);
|
||||
setAnimationClass('entering'); // Reset for next opening
|
||||
}, 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 && (
|
||||
<div
|
||||
@@ -153,22 +189,18 @@ export function MessageContextMenu({
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div className="context-menu-item" onClick={() => handleAction("reply")}>
|
||||
<span className="material-symbols">reply</span>
|
||||
Ответить
|
||||
</div>
|
||||
{isAuthor && (
|
||||
<>
|
||||
<div className="context-menu-item" onClick={() => handleAction("edit")}>
|
||||
<span className="material-symbols">edit</span>
|
||||
Редактировать
|
||||
{actions.map((action, i) => (
|
||||
action.show && (
|
||||
<div
|
||||
className="context-menu-item"
|
||||
onClick={action.onClick}
|
||||
key={i}
|
||||
>
|
||||
<span className="material-symbols">{action.icon}</span>
|
||||
{action.label}
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={() => handleAction("delete")}>
|
||||
<span className="material-symbols">delete</span>
|
||||
Удалить
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
<div ref={messagesEndRef} />
|
||||
</ChatMessages>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,230 @@ 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
|
||||
// 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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user