- MessageInput: paperclip button (team-only, controlled by teamUserIds from ThreadDetail) uploads through the media service and hands the URL to the WebSocket; Enter now inserts a newline — sending is button-only since accidental Enter-sends were noisy for staff replies. - MessageBubble: renders image messages inline (tap to open), shows the coworker's username at the top of team-side bubbles so staff can tell who typed what. - MessageList / ThreadChatPage: any message from a store team member (owner or active staff) renders on the "our" side of the bubble even for a viewer who isn't the sender, using team_user_ids from ThreadDetail. Delete option stays gated to the actual sender. - ThreadsPage: inbox now shows smart-relative timestamps (today HH:MM / دیروز HH:MM / N روز پیش / Persian date), plus a reply-state icon per row (red bell = customer waiting, blue double-check = you replied and they've seen it, nothing = you replied not yet seen). Image-only last messages preview as "📷 تصویر". - Push notifications: on mount we re-POST the current PushSubscription so a browser-rotated sub or a backend row deleted after a 410 auto-heals. - useWebSocket: SendMessagePayload and NewMessageEvent carry image_url so the server relay can round-trip images. - API types: MessageList.imageUrl, LastMessage.imageUrl, ThreadList.lastMessageFromTeam/otherPartySeenLast, ThreadDetail.teamUserIds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
185 lines
4.8 KiB
TypeScript
185 lines
4.8 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
|
|
import { useAuthToken } from "./use-root-data";
|
|
import { getSocketBaseUrl } from "~/utils/api-config";
|
|
|
|
const useWebSocket = (threadId: string, userId: string | undefined) => {
|
|
const [socket, setSocket] = useState<WebSocket | null>(null);
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
const [newMessage, setNewMessage] = useState<NewMessageEvent | null>(null);
|
|
const [readReceipt, setReadReceipt] = useState<ReadReceiptEvent | null>(null);
|
|
const [messageDeleted, setMessageDeleted] =
|
|
useState<MessageDeletedEvent | null>(null);
|
|
|
|
const token = useAuthToken();
|
|
const baseSocketPath = getSocketBaseUrl();
|
|
// Initialize WebSocket connection
|
|
useEffect(() => {
|
|
if (!threadId || !userId || !token) return;
|
|
|
|
// Use wss in production
|
|
const wsUrl = `${baseSocketPath}/ws/chat/${threadId}/?token=${token}`;
|
|
|
|
const ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = () => {
|
|
console.log("WebSocket connection established");
|
|
setIsConnected(true);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data) as WebSocketMessage;
|
|
|
|
if (data.type === "new_message") {
|
|
setNewMessage(data as NewMessageEvent);
|
|
} else if (data.type === "read_receipt") {
|
|
setReadReceipt(data as ReadReceiptEvent);
|
|
} else if (data.type === "message_deleted") {
|
|
setMessageDeleted(data as MessageDeletedEvent);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error parsing WebSocket message:", error);
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
console.log("WebSocket connection closed");
|
|
setIsConnected(false);
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
console.error("WebSocket error:", error);
|
|
};
|
|
|
|
setSocket(ws);
|
|
|
|
// Clean up on unmount
|
|
return () => {
|
|
if (
|
|
ws.readyState === WebSocket.OPEN ||
|
|
ws.readyState === WebSocket.CONNECTING
|
|
) {
|
|
ws.close();
|
|
}
|
|
};
|
|
}, [threadId, userId, token]);
|
|
|
|
// Send a message through WebSocket
|
|
const sendWebSocketMessage = (
|
|
content: string,
|
|
replyToId?: string | null,
|
|
replyContent?: string | null,
|
|
imageUrl?: string | null,
|
|
) => {
|
|
if (!socket || socket.readyState !== WebSocket.OPEN || !userId) {
|
|
console.error("WebSocket is not connected");
|
|
return false;
|
|
}
|
|
|
|
const message: SendMessagePayload = {
|
|
type: "new_message",
|
|
content,
|
|
...(replyToId && { reply_to_id: replyToId }),
|
|
...(replyContent && { reply_content: replyContent }),
|
|
...(imageUrl && { image_url: imageUrl }),
|
|
};
|
|
|
|
socket.send(JSON.stringify(message));
|
|
return true;
|
|
};
|
|
|
|
// Mark messages as read
|
|
const markMessagesAsRead = () => {
|
|
if (!socket || socket.readyState !== WebSocket.OPEN || !userId) {
|
|
console.error("WebSocket is not connected");
|
|
return false;
|
|
}
|
|
|
|
const message: MarkReadPayload = {
|
|
type: "mark_read",
|
|
// reader_id: userId, // Remove this as it's not needed in client->server communication
|
|
};
|
|
|
|
socket.send(JSON.stringify(message));
|
|
return true;
|
|
};
|
|
|
|
// Delete a message
|
|
const deleteMessage = (messageId: string) => {
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
console.error("WebSocket is not connected");
|
|
return false;
|
|
}
|
|
|
|
const message: DeleteMessagePayload = {
|
|
type: "delete_message",
|
|
message_id: messageId,
|
|
};
|
|
|
|
socket.send(JSON.stringify(message));
|
|
return true;
|
|
};
|
|
|
|
return {
|
|
isConnected,
|
|
sendWebSocketMessage,
|
|
markMessagesAsRead,
|
|
deleteMessage,
|
|
newMessage,
|
|
readReceipt,
|
|
messageDeleted,
|
|
// Reset message notification after handling
|
|
resetNewMessage: () => setNewMessage(null),
|
|
resetReadReceipt: () => setReadReceipt(null),
|
|
resetMessageDeleted: () => setMessageDeleted(null),
|
|
};
|
|
};
|
|
|
|
// WebSocket message types
|
|
interface WebSocketMessage {
|
|
type: string;
|
|
[key: string]: string | number | boolean | string[] | null | undefined;
|
|
}
|
|
|
|
interface SendMessagePayload extends WebSocketMessage {
|
|
type: "new_message";
|
|
content: string;
|
|
reply_to_id?: string | null;
|
|
reply_content?: string | null;
|
|
image_url?: string | null;
|
|
}
|
|
|
|
interface MarkReadPayload extends WebSocketMessage {
|
|
type: "mark_read";
|
|
}
|
|
|
|
interface NewMessageEvent extends WebSocketMessage {
|
|
type: "new_message";
|
|
message_id: string;
|
|
content: string;
|
|
sender_id: string;
|
|
created_at: string;
|
|
reply_to_id?: string | null;
|
|
reply_content?: string | null;
|
|
image_url?: string | null;
|
|
}
|
|
|
|
interface ReadReceiptEvent extends WebSocketMessage {
|
|
type: "read_receipt";
|
|
reader_username: string;
|
|
read_at: string;
|
|
}
|
|
|
|
interface DeleteMessagePayload extends WebSocketMessage {
|
|
type: "delete_message";
|
|
message_id: string;
|
|
}
|
|
|
|
interface MessageDeletedEvent extends WebSocketMessage {
|
|
type: "message_deleted";
|
|
message_id: string;
|
|
}
|
|
|
|
export default useWebSocket;
|