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?: {
|
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[];
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user