250 lines
7.8 KiB
TypeScript
250 lines
7.8 KiB
TypeScript
import { create } from "zustand";
|
|
import { api } from "../lib/api.ts";
|
|
import { type Reaction } from "./message.ts";
|
|
|
|
export interface ConversationMember {
|
|
id: string;
|
|
username: string;
|
|
display_name: string;
|
|
avatar: string;
|
|
}
|
|
|
|
export interface Conversation {
|
|
id: string;
|
|
type: "dm" | "group_dm";
|
|
name: string;
|
|
members: ConversationMember[];
|
|
created_at: string;
|
|
}
|
|
|
|
export interface ConversationMessage {
|
|
id: string;
|
|
conversation_id: string;
|
|
author_id: string;
|
|
author_username: string;
|
|
author_display_name: string | null;
|
|
content: string;
|
|
created_at: string;
|
|
edited_at: string | null;
|
|
reactions?: Reaction[];
|
|
}
|
|
|
|
interface ConversationState {
|
|
conversations: Conversation[];
|
|
activeConversationId: string | null;
|
|
messagesByConversation: Record<string, ConversationMessage[]>;
|
|
isLoading: boolean;
|
|
isLoadingOlder: boolean;
|
|
hasMoreByConversation: Record<string, boolean>;
|
|
error: string | null;
|
|
fetchConversations: () => Promise<void>;
|
|
createConversation: (userIds: string[]) => Promise<Conversation>;
|
|
setActiveConversation: (id: string | null) => void;
|
|
fetchMessages: (conversationId: string, before?: string) => Promise<void>;
|
|
fetchOlderMessages: (conversationId: string) => Promise<void>;
|
|
sendMessage: (conversationId: string, content: string) => Promise<ConversationMessage>;
|
|
addMessage: (message: ConversationMessage) => void;
|
|
updateMessage: (message: ConversationMessage) => void;
|
|
deleteMessage: (conversationId: string, messageId: string) => void;
|
|
addReaction: (conversationId: string, messageId: string, emoji: string, userId: string) => void;
|
|
removeReaction: (conversationId: string, messageId: string, emoji: string, userId: string) => void;
|
|
}
|
|
|
|
export const useConversationStore = create<ConversationState>((set, get) => ({
|
|
conversations: [],
|
|
activeConversationId: null,
|
|
messagesByConversation: {},
|
|
isLoading: false,
|
|
isLoadingOlder: false,
|
|
hasMoreByConversation: {},
|
|
error: null,
|
|
|
|
fetchConversations: async () => {
|
|
try {
|
|
const conversations = await api.get<Conversation[]>("/conversations");
|
|
set({ conversations: Array.isArray(conversations) ? conversations : [] });
|
|
} catch (error) {
|
|
set({
|
|
error: error instanceof Error ? error.message : "Failed to fetch conversations",
|
|
});
|
|
}
|
|
},
|
|
|
|
createConversation: async (userIds) => {
|
|
const conversation = await api.post<Conversation>("/conversations", { user_ids: userIds });
|
|
set((state) => ({
|
|
conversations: [conversation, ...state.conversations],
|
|
activeConversationId: conversation.id,
|
|
}));
|
|
return conversation;
|
|
},
|
|
|
|
setActiveConversation: (id) => set({ activeConversationId: id }),
|
|
|
|
fetchMessages: async (conversationId, before) => {
|
|
set({ isLoading: true });
|
|
try {
|
|
const params = before ? "?before=" + encodeURIComponent(before) : "";
|
|
const messages = await api.get<ConversationMessage[]>(
|
|
`/conversations/${conversationId}/messages${params}`,
|
|
);
|
|
const list = Array.isArray(messages) ? messages : [];
|
|
set((state) => ({
|
|
messagesByConversation: {
|
|
...state.messagesByConversation,
|
|
[conversationId]: list,
|
|
},
|
|
hasMoreByConversation: {
|
|
...state.hasMoreByConversation,
|
|
[conversationId]: list.length >= 50,
|
|
},
|
|
isLoading: false,
|
|
}));
|
|
} catch (error) {
|
|
set({ isLoading: false, error: error instanceof Error ? error.message : "Failed" });
|
|
}
|
|
},
|
|
|
|
fetchOlderMessages: async (conversationId) => {
|
|
const state = get();
|
|
if (state.isLoadingOlder || state.hasMoreByConversation[conversationId] === false) return;
|
|
const existing = state.messagesByConversation[conversationId] || [];
|
|
if (existing.length === 0) return;
|
|
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
|
|
const oldestId = existing[0].id;
|
|
set({ isLoadingOlder: true });
|
|
try {
|
|
const older = await api.get<ConversationMessage[]>(
|
|
`/conversations/${conversationId}/messages?before=${encodeURIComponent(oldestId)}`,
|
|
);
|
|
const list = Array.isArray(older) ? older : [];
|
|
set((state) => ({
|
|
messagesByConversation: {
|
|
...state.messagesByConversation,
|
|
[conversationId]: [...list, ...existing],
|
|
},
|
|
hasMoreByConversation: {
|
|
...state.hasMoreByConversation,
|
|
[conversationId]: list.length >= 50,
|
|
},
|
|
isLoadingOlder: false,
|
|
}));
|
|
} catch {
|
|
set({ isLoadingOlder: false });
|
|
}
|
|
},
|
|
|
|
sendMessage: async (conversationId, content) => {
|
|
// Add locally so the message appears immediately even if WS lags.
|
|
// addMessage dedupes, so a late WS event won't double it.
|
|
const message = await api.post<ConversationMessage>(
|
|
`/conversations/${conversationId}/messages`,
|
|
{ content },
|
|
);
|
|
// ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves
|
|
get().addMessage(message);
|
|
return message;
|
|
},
|
|
|
|
addMessage: (message) => {
|
|
set((state) => {
|
|
const existing = state.messagesByConversation[message.conversation_id] || [];
|
|
if (existing.some((m) => m.id === message.id)) {
|
|
return state;
|
|
}
|
|
return {
|
|
messagesByConversation: {
|
|
...state.messagesByConversation,
|
|
[message.conversation_id]: [...existing, message]
|
|
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
|
|
},
|
|
};
|
|
});
|
|
},
|
|
updateMessage: (message) => {
|
|
set((state) => {
|
|
const convMsgs = state.messagesByConversation[message.conversation_id] || [];
|
|
return {
|
|
messagesByConversation: {
|
|
...state.messagesByConversation,
|
|
[message.conversation_id]: convMsgs.map((m) => (m.id === message.id ? message : m)),
|
|
},
|
|
};
|
|
});
|
|
},
|
|
deleteMessage: (conversationId, messageId) => {
|
|
set((state) => {
|
|
const convMsgs = state.messagesByConversation[conversationId] || [];
|
|
return {
|
|
messagesByConversation: {
|
|
...state.messagesByConversation,
|
|
[conversationId]: convMsgs.filter((m) => m.id !== messageId),
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
addReaction: (conversationId, messageId, emoji, userId) => {
|
|
set((state) => {
|
|
const messages = state.messagesByConversation[conversationId];
|
|
if (!messages) return state;
|
|
|
|
const newMessages = messages.map((m) => {
|
|
if (m.id !== messageId) return m;
|
|
|
|
const reactions = [...(m.reactions || [])];
|
|
const existing = reactions.find((r) => r.emoji === emoji);
|
|
|
|
if (existing) {
|
|
if (!existing.users.includes(userId)) {
|
|
existing.users.push(userId);
|
|
existing.count++;
|
|
}
|
|
} else {
|
|
reactions.push({ emoji, count: 1, users: [userId] });
|
|
}
|
|
|
|
return { ...m, reactions };
|
|
});
|
|
|
|
return {
|
|
messagesByConversation: {
|
|
...state.messagesByConversation,
|
|
[conversationId]: newMessages,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
removeReaction: (conversationId, messageId, emoji, userId) => {
|
|
set((state) => {
|
|
const messages = state.messagesByConversation[conversationId];
|
|
if (!messages) return state;
|
|
|
|
const newMessages = messages.map((m) => {
|
|
if (m.id !== messageId) return m;
|
|
|
|
let reactions = [...(m.reactions || [])];
|
|
const existing = reactions.find((r) => r.emoji === emoji);
|
|
|
|
if (existing) {
|
|
existing.users = existing.users.filter((id) => id !== userId);
|
|
existing.count--;
|
|
if (existing.count <= 0) {
|
|
reactions = reactions.filter((r) => r.emoji !== emoji);
|
|
}
|
|
}
|
|
|
|
return { ...m, reactions };
|
|
});
|
|
|
|
return {
|
|
messagesByConversation: {
|
|
...state.messagesByConversation,
|
|
[conversationId]: newMessages,
|
|
},
|
|
};
|
|
});
|
|
},
|
|
}));
|