542 lines
18 KiB
TypeScript
542 lines
18 KiB
TypeScript
import { create } from "zustand";
|
|
import { getChats, getMessages, updateMessageStatus } from "../api/chats";
|
|
import type { Chat, DeliveryStatus, Message, MessageType } from "../chat/types";
|
|
|
|
const DRAFTS_STORAGE_KEY = "bm_drafts_v1";
|
|
|
|
function loadDraftsFromStorage(): Record<number, string> {
|
|
if (typeof window === "undefined") {
|
|
return {};
|
|
}
|
|
try {
|
|
const raw = window.localStorage.getItem(DRAFTS_STORAGE_KEY);
|
|
if (!raw) {
|
|
return {};
|
|
}
|
|
const parsed = JSON.parse(raw) as Record<string, string>;
|
|
const result: Record<number, string> = {};
|
|
for (const [key, value] of Object.entries(parsed)) {
|
|
const chatId = Number(key);
|
|
if (Number.isFinite(chatId) && typeof value === "string") {
|
|
result[chatId] = value;
|
|
}
|
|
}
|
|
return result;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function saveDraftsToStorage(drafts: Record<number, string>): void {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
try {
|
|
window.localStorage.setItem(DRAFTS_STORAGE_KEY, JSON.stringify(drafts));
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
|
|
function mergeDeliveryStatus(
|
|
incoming: DeliveryStatus | undefined,
|
|
existing: DeliveryStatus | undefined
|
|
): DeliveryStatus | undefined {
|
|
const rank: Record<DeliveryStatus, number> = {
|
|
sending: 1,
|
|
sent: 2,
|
|
delivered: 3,
|
|
read: 4
|
|
};
|
|
const incomingRank = incoming ? rank[incoming] : 0;
|
|
const existingRank = existing ? rank[existing] : 0;
|
|
return incomingRank >= existingRank ? incoming : existing;
|
|
}
|
|
|
|
interface ChatState {
|
|
chats: Chat[];
|
|
activeChatId: number | null;
|
|
messagesByChat: Record<number, Message[]>;
|
|
draftsByChat: Record<number, string>;
|
|
hasMoreByChat: Record<number, boolean>;
|
|
loadingMoreByChat: Record<number, boolean>;
|
|
typingByChat: Record<number, number[]>;
|
|
recordingVoiceByChat: Record<number, number[]>;
|
|
recordingVideoByChat: Record<number, number[]>;
|
|
replyToByChat: Record<number, Message | null>;
|
|
editingByChat: Record<number, Message | null>;
|
|
unreadBoundaryByChat: Record<number, number>;
|
|
focusedMessageIdByChat: Record<number, number | null>;
|
|
loadChats: (query?: string) => Promise<void>;
|
|
setActiveChatId: (chatId: number | null) => void;
|
|
loadMessages: (chatId: number, options?: { markRead?: boolean }) => Promise<void>;
|
|
loadMoreMessages: (chatId: number) => Promise<void>;
|
|
prependMessage: (chatId: number, message: Message) => boolean;
|
|
addOptimisticMessage: (params: {
|
|
chatId: number;
|
|
senderId: number;
|
|
type: MessageType;
|
|
text: string | null;
|
|
clientMessageId: string;
|
|
}) => void;
|
|
confirmMessageByClientId: (chatId: number, clientMessageId: string, message: Message) => void;
|
|
removeOptimisticMessage: (chatId: number, clientMessageId: string) => void;
|
|
setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void;
|
|
setMessageDeliveryStatusUpTo: (
|
|
chatId: number,
|
|
maxMessageId: number,
|
|
status: DeliveryStatus,
|
|
senderId: number
|
|
) => void;
|
|
upsertMessage: (chatId: number, message: Message) => void;
|
|
removeMessage: (chatId: number, messageId: number) => void;
|
|
restoreMessages: (chatId: number, messages: Message[]) => void;
|
|
clearChatMessages: (chatId: number) => void;
|
|
incrementUnread: (chatId: number, hasMention?: boolean) => void;
|
|
clearUnread: (chatId: number) => void;
|
|
setTypingUsers: (chatId: number, userIds: number[]) => void;
|
|
setRecordingUsers: (chatId: number, kind: "voice" | "video", userIds: number[]) => void;
|
|
setReplyToMessage: (chatId: number, message: Message | null) => void;
|
|
setEditingMessage: (chatId: number, message: Message | null) => void;
|
|
updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void;
|
|
updateChatMuted: (chatId: number, muted: boolean) => void;
|
|
applyPresenceEvent: (chatId: number, userId: number, isOnline: boolean, lastSeenAt?: string) => void;
|
|
removeChat: (chatId: number) => void;
|
|
setDraft: (chatId: number, text: string) => void;
|
|
clearDraft: (chatId: number) => void;
|
|
setFocusedMessage: (chatId: number, messageId: number | null) => void;
|
|
reset: () => void;
|
|
}
|
|
|
|
export const useChatStore = create<ChatState>((set, get) => ({
|
|
chats: [],
|
|
activeChatId: null,
|
|
messagesByChat: {},
|
|
draftsByChat: loadDraftsFromStorage(),
|
|
hasMoreByChat: {},
|
|
loadingMoreByChat: {},
|
|
typingByChat: {},
|
|
recordingVoiceByChat: {},
|
|
recordingVideoByChat: {},
|
|
replyToByChat: {},
|
|
editingByChat: {},
|
|
unreadBoundaryByChat: {},
|
|
focusedMessageIdByChat: {},
|
|
loadChats: async (query) => {
|
|
const chats = await getChats(query);
|
|
const currentActive = get().activeChatId;
|
|
const nextActive = chats.some((chat) => chat.id === currentActive) ? currentActive : (chats[0]?.id ?? null);
|
|
set({ chats, activeChatId: nextActive });
|
|
},
|
|
setActiveChatId: (chatId) => set({ activeChatId: chatId }),
|
|
loadMessages: async (chatId, options) => {
|
|
const markRead = options?.markRead === true;
|
|
const unreadCount = get().chats.find((c) => c.id === chatId)?.unread_count ?? 0;
|
|
const messages = await getMessages(chatId);
|
|
const sorted = [...messages].reverse();
|
|
const existingById = new Map((get().messagesByChat[chatId] ?? []).map((message) => [message.id, message]));
|
|
const normalized = sorted.map((message) => {
|
|
const existing = existingById.get(message.id);
|
|
const deliveryStatus = mergeDeliveryStatus(message.delivery_status, existing?.delivery_status);
|
|
return deliveryStatus ? { ...message, delivery_status: deliveryStatus } : message;
|
|
});
|
|
set((state) => ({
|
|
messagesByChat: {
|
|
...state.messagesByChat,
|
|
[chatId]: normalized
|
|
},
|
|
unreadBoundaryByChat: {
|
|
...state.unreadBoundaryByChat,
|
|
[chatId]: unreadCount
|
|
},
|
|
hasMoreByChat: {
|
|
...state.hasMoreByChat,
|
|
[chatId]: messages.length >= 50
|
|
},
|
|
loadingMoreByChat: {
|
|
...state.loadingMoreByChat,
|
|
[chatId]: false
|
|
},
|
|
chats: markRead
|
|
? state.chats.map((chat) =>
|
|
chat.id === chatId ? { ...chat, unread_count: 0, unread_mentions_count: 0 } : chat
|
|
)
|
|
: state.chats
|
|
}));
|
|
const lastMessage = normalized[normalized.length - 1];
|
|
if (markRead && lastMessage) {
|
|
void updateMessageStatus(chatId, lastMessage.id, "message_read");
|
|
}
|
|
},
|
|
loadMoreMessages: async (chatId) => {
|
|
if (get().loadingMoreByChat[chatId]) {
|
|
return;
|
|
}
|
|
const existing = get().messagesByChat[chatId] ?? [];
|
|
if (!existing.length) {
|
|
await get().loadMessages(chatId);
|
|
return;
|
|
}
|
|
const oldestId = existing[0]?.id;
|
|
if (!oldestId) {
|
|
return;
|
|
}
|
|
set((state) => ({
|
|
loadingMoreByChat: { ...state.loadingMoreByChat, [chatId]: true }
|
|
}));
|
|
try {
|
|
const older = await getMessages(chatId, oldestId);
|
|
const olderSorted = [...older].reverse();
|
|
const knownIds = new Set(existing.map((m) => m.id));
|
|
const merged = [...olderSorted.filter((m) => !knownIds.has(m.id)), ...existing];
|
|
set((state) => ({
|
|
messagesByChat: {
|
|
...state.messagesByChat,
|
|
[chatId]: merged
|
|
},
|
|
hasMoreByChat: {
|
|
...state.hasMoreByChat,
|
|
[chatId]: older.length >= 50
|
|
}
|
|
}));
|
|
} finally {
|
|
set((state) => ({
|
|
loadingMoreByChat: { ...state.loadingMoreByChat, [chatId]: false }
|
|
}));
|
|
}
|
|
},
|
|
prependMessage: (chatId, message) => {
|
|
const old = get().messagesByChat[chatId] ?? [];
|
|
if (old.some((m) => m.id === message.id || (message.client_message_id && m.client_message_id === message.client_message_id))) {
|
|
return false;
|
|
}
|
|
set((state) => ({
|
|
messagesByChat: { ...state.messagesByChat, [chatId]: [...old, message] }
|
|
}));
|
|
return true;
|
|
},
|
|
addOptimisticMessage: ({ chatId, senderId, type, text, clientMessageId }) => {
|
|
const old = get().messagesByChat[chatId] ?? [];
|
|
if (old.some((m) => m.client_message_id === clientMessageId)) {
|
|
return;
|
|
}
|
|
const now = new Date().toISOString();
|
|
const optimistic: Message = {
|
|
id: -(Date.now() + Math.floor(Math.random() * 10000)),
|
|
chat_id: chatId,
|
|
sender_id: senderId,
|
|
type,
|
|
text,
|
|
created_at: now,
|
|
updated_at: now,
|
|
client_message_id: clientMessageId,
|
|
delivery_status: "sending",
|
|
is_pending: true
|
|
};
|
|
set((state) => ({
|
|
messagesByChat: { ...state.messagesByChat, [chatId]: [...old, optimistic] }
|
|
}));
|
|
},
|
|
confirmMessageByClientId: (chatId, clientMessageId, message) => {
|
|
const old = get().messagesByChat[chatId] ?? [];
|
|
const idx = old.findIndex((m) => m.client_message_id === clientMessageId);
|
|
if (idx === -1) {
|
|
if (!old.some((m) => m.id === message.id)) {
|
|
set((state) => ({
|
|
messagesByChat: {
|
|
...state.messagesByChat,
|
|
[chatId]: [...old, { ...message, client_message_id: clientMessageId, delivery_status: "sent", is_pending: false }]
|
|
}
|
|
}));
|
|
}
|
|
return;
|
|
}
|
|
const next = [...old];
|
|
next[idx] = {
|
|
...message,
|
|
client_message_id: clientMessageId,
|
|
delivery_status: "sent",
|
|
is_pending: false
|
|
};
|
|
set((state) => ({
|
|
messagesByChat: { ...state.messagesByChat, [chatId]: next }
|
|
}));
|
|
},
|
|
removeOptimisticMessage: (chatId, clientMessageId) => {
|
|
const old = get().messagesByChat[chatId] ?? [];
|
|
set((state) => ({
|
|
messagesByChat: {
|
|
...state.messagesByChat,
|
|
[chatId]: old.filter((m) => m.client_message_id !== clientMessageId)
|
|
}
|
|
}));
|
|
},
|
|
setMessageDeliveryStatus: (chatId, messageId, status) => {
|
|
const old = get().messagesByChat[chatId] ?? [];
|
|
const idx = old.findIndex((m) => m.id === messageId);
|
|
if (idx === -1) {
|
|
return;
|
|
}
|
|
const current = old[idx];
|
|
const order: Record<DeliveryStatus, number> = { sending: 1, sent: 2, delivered: 3, read: 4 };
|
|
const currentStatus = current.delivery_status ?? "sent";
|
|
if (order[status] <= order[currentStatus]) {
|
|
return;
|
|
}
|
|
const next = [...old];
|
|
next[idx] = { ...current, delivery_status: status, is_pending: false };
|
|
set((state) => ({
|
|
messagesByChat: { ...state.messagesByChat, [chatId]: next }
|
|
}));
|
|
},
|
|
setMessageDeliveryStatusUpTo: (chatId, maxMessageId, status, senderId) => {
|
|
const old = get().messagesByChat[chatId] ?? [];
|
|
if (!old.length) {
|
|
return;
|
|
}
|
|
const order: Record<DeliveryStatus, number> = { sending: 1, sent: 2, delivered: 3, read: 4 };
|
|
let changed = false;
|
|
const next = old.map((message) => {
|
|
if (message.sender_id !== senderId || message.id <= 0 || message.id > maxMessageId) {
|
|
return message;
|
|
}
|
|
const currentStatus = message.delivery_status ?? "sent";
|
|
if (order[status] <= order[currentStatus]) {
|
|
return message;
|
|
}
|
|
changed = true;
|
|
return { ...message, delivery_status: status, is_pending: false };
|
|
});
|
|
if (!changed) {
|
|
return;
|
|
}
|
|
set((state) => ({
|
|
messagesByChat: { ...state.messagesByChat, [chatId]: next }
|
|
}));
|
|
},
|
|
upsertMessage: (chatId, message) => {
|
|
const old = get().messagesByChat[chatId] ?? [];
|
|
if (!old.length) {
|
|
set((state) => ({
|
|
messagesByChat: { ...state.messagesByChat, [chatId]: [message] }
|
|
}));
|
|
return;
|
|
}
|
|
const idx = old.findIndex((m) => m.id === message.id);
|
|
if (idx === -1) {
|
|
const next = [...old, message].sort((a, b) => a.id - b.id);
|
|
set((state) => ({
|
|
messagesByChat: { ...state.messagesByChat, [chatId]: next }
|
|
}));
|
|
return;
|
|
}
|
|
const next = [...old];
|
|
const existing = next[idx];
|
|
const deliveryStatus = mergeDeliveryStatus(message.delivery_status, existing.delivery_status);
|
|
next[idx] = deliveryStatus ? { ...message, delivery_status: deliveryStatus } : message;
|
|
set((state) => ({
|
|
messagesByChat: { ...state.messagesByChat, [chatId]: next }
|
|
}));
|
|
},
|
|
removeMessage: (chatId, messageId) => {
|
|
const old = get().messagesByChat[chatId] ?? [];
|
|
set((state) => ({
|
|
messagesByChat: {
|
|
...state.messagesByChat,
|
|
[chatId]: old.filter((m) => m.id !== messageId)
|
|
}
|
|
}));
|
|
},
|
|
restoreMessages: (chatId, messages) => {
|
|
if (!messages.length) {
|
|
return;
|
|
}
|
|
const old = get().messagesByChat[chatId] ?? [];
|
|
const byId = new Map<number, Message>();
|
|
for (const message of old) {
|
|
byId.set(message.id, message);
|
|
}
|
|
for (const message of messages) {
|
|
byId.set(message.id, message);
|
|
}
|
|
const merged = [...byId.values()].sort((a, b) => a.id - b.id);
|
|
set((state) => ({
|
|
messagesByChat: {
|
|
...state.messagesByChat,
|
|
[chatId]: merged
|
|
}
|
|
}));
|
|
},
|
|
clearChatMessages: (chatId) =>
|
|
set((state) => ({
|
|
messagesByChat: {
|
|
...state.messagesByChat,
|
|
[chatId]: []
|
|
},
|
|
unreadBoundaryByChat: {
|
|
...state.unreadBoundaryByChat,
|
|
[chatId]: 0
|
|
},
|
|
chats: state.chats.map((chat) =>
|
|
chat.id === chatId ? { ...chat, unread_count: 0, unread_mentions_count: 0 } : chat
|
|
)
|
|
})),
|
|
incrementUnread: (chatId, hasMention = false) =>
|
|
set((state) => ({
|
|
chats: state.chats.map((chat) =>
|
|
chat.id === chatId
|
|
? {
|
|
...chat,
|
|
unread_count: (chat.unread_count ?? 0) + 1,
|
|
unread_mentions_count: hasMention
|
|
? (chat.unread_mentions_count ?? 0) + 1
|
|
: (chat.unread_mentions_count ?? 0)
|
|
}
|
|
: chat
|
|
)
|
|
})),
|
|
clearUnread: (chatId) =>
|
|
set((state) => ({
|
|
chats: state.chats.map((chat) =>
|
|
chat.id === chatId ? { ...chat, unread_count: 0, unread_mentions_count: 0 } : chat
|
|
),
|
|
unreadBoundaryByChat: {
|
|
...state.unreadBoundaryByChat,
|
|
[chatId]: 0
|
|
}
|
|
})),
|
|
setTypingUsers: (chatId, userIds) =>
|
|
set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } })),
|
|
setRecordingUsers: (chatId, kind, userIds) =>
|
|
set((state) =>
|
|
kind === "voice"
|
|
? { recordingVoiceByChat: { ...state.recordingVoiceByChat, [chatId]: userIds } }
|
|
: { recordingVideoByChat: { ...state.recordingVideoByChat, [chatId]: userIds } }
|
|
),
|
|
setReplyToMessage: (chatId, message) =>
|
|
set((state) => ({ replyToByChat: { ...state.replyToByChat, [chatId]: message } })),
|
|
setEditingMessage: (chatId, message) =>
|
|
set((state) => ({ editingByChat: { ...state.editingByChat, [chatId]: message } })),
|
|
updateChatPinnedMessage: (chatId, pinnedMessageId) =>
|
|
set((state) => ({
|
|
chats: state.chats.map((chat) => (chat.id === chatId ? { ...chat, pinned_message_id: pinnedMessageId } : chat))
|
|
})),
|
|
updateChatMuted: (chatId, muted) =>
|
|
set((state) => ({
|
|
chats: state.chats.map((chat) => (chat.id === chatId ? { ...chat, muted } : chat))
|
|
})),
|
|
applyPresenceEvent: (chatId, userId, isOnline, lastSeenAt) =>
|
|
set((state) => ({
|
|
chats: state.chats.map((chat) => {
|
|
if (chat.id !== chatId) {
|
|
return chat;
|
|
}
|
|
if (chat.type === "private" && chat.counterpart_user_id === userId) {
|
|
return {
|
|
...chat,
|
|
counterpart_is_online: isOnline,
|
|
counterpart_last_seen_at: isOnline ? chat.counterpart_last_seen_at : (lastSeenAt ?? new Date().toISOString())
|
|
};
|
|
}
|
|
if (chat.type === "group") {
|
|
const currentOnline = chat.online_count ?? 0;
|
|
const membersCount = chat.members_count ?? currentOnline;
|
|
const nextOnline = isOnline
|
|
? Math.min(membersCount, currentOnline + 1)
|
|
: Math.max(0, currentOnline - 1);
|
|
return {
|
|
...chat,
|
|
online_count: nextOnline
|
|
};
|
|
}
|
|
return chat;
|
|
})
|
|
})),
|
|
removeChat: (chatId) =>
|
|
set((state) => {
|
|
const nextMessagesByChat = { ...state.messagesByChat };
|
|
const nextHasMoreByChat = { ...state.hasMoreByChat };
|
|
const nextLoadingMoreByChat = { ...state.loadingMoreByChat };
|
|
const nextTypingByChat = { ...state.typingByChat };
|
|
const nextRecordingVoiceByChat = { ...state.recordingVoiceByChat };
|
|
const nextRecordingVideoByChat = { ...state.recordingVideoByChat };
|
|
const nextReplyToByChat = { ...state.replyToByChat };
|
|
const nextEditingByChat = { ...state.editingByChat };
|
|
const nextUnreadBoundaryByChat = { ...state.unreadBoundaryByChat };
|
|
const nextFocusedMessageByChat = { ...state.focusedMessageIdByChat };
|
|
const nextDraftsByChat = { ...state.draftsByChat };
|
|
delete nextMessagesByChat[chatId];
|
|
delete nextHasMoreByChat[chatId];
|
|
delete nextLoadingMoreByChat[chatId];
|
|
delete nextTypingByChat[chatId];
|
|
delete nextRecordingVoiceByChat[chatId];
|
|
delete nextRecordingVideoByChat[chatId];
|
|
delete nextReplyToByChat[chatId];
|
|
delete nextEditingByChat[chatId];
|
|
delete nextUnreadBoundaryByChat[chatId];
|
|
delete nextFocusedMessageByChat[chatId];
|
|
delete nextDraftsByChat[chatId];
|
|
saveDraftsToStorage(nextDraftsByChat);
|
|
const nextChats = state.chats.filter((chat) => chat.id !== chatId);
|
|
return {
|
|
chats: nextChats,
|
|
activeChatId: state.activeChatId === chatId ? (nextChats[0]?.id ?? null) : state.activeChatId,
|
|
messagesByChat: nextMessagesByChat,
|
|
hasMoreByChat: nextHasMoreByChat,
|
|
loadingMoreByChat: nextLoadingMoreByChat,
|
|
typingByChat: nextTypingByChat,
|
|
recordingVoiceByChat: nextRecordingVoiceByChat,
|
|
recordingVideoByChat: nextRecordingVideoByChat,
|
|
replyToByChat: nextReplyToByChat,
|
|
editingByChat: nextEditingByChat,
|
|
unreadBoundaryByChat: nextUnreadBoundaryByChat,
|
|
focusedMessageIdByChat: nextFocusedMessageByChat,
|
|
draftsByChat: nextDraftsByChat,
|
|
};
|
|
}),
|
|
setDraft: (chatId, text) =>
|
|
set((state) => {
|
|
const nextDrafts = {
|
|
...state.draftsByChat,
|
|
[chatId]: text
|
|
};
|
|
saveDraftsToStorage(nextDrafts);
|
|
return { draftsByChat: nextDrafts };
|
|
}),
|
|
clearDraft: (chatId) =>
|
|
set((state) => {
|
|
if (!(chatId in state.draftsByChat)) {
|
|
return state;
|
|
}
|
|
const next = { ...state.draftsByChat };
|
|
delete next[chatId];
|
|
saveDraftsToStorage(next);
|
|
return { draftsByChat: next };
|
|
}),
|
|
setFocusedMessage: (chatId, messageId) =>
|
|
set((state) => ({
|
|
focusedMessageIdByChat: {
|
|
...state.focusedMessageIdByChat,
|
|
[chatId]: messageId
|
|
}
|
|
})),
|
|
reset: () => {
|
|
saveDraftsToStorage({});
|
|
set({
|
|
chats: [],
|
|
activeChatId: null,
|
|
messagesByChat: {},
|
|
draftsByChat: {},
|
|
hasMoreByChat: {},
|
|
loadingMoreByChat: {},
|
|
typingByChat: {},
|
|
recordingVoiceByChat: {},
|
|
recordingVideoByChat: {},
|
|
replyToByChat: {},
|
|
editingByChat: {},
|
|
unreadBoundaryByChat: {},
|
|
focusedMessageIdByChat: {}
|
|
});
|
|
}
|
|
}));
|