Files
dumpsterChat/web/src/stores/conversation.ts
T
hobokenchicken 478e90d305
Release Desktop Apps / build-linux (push) Successful in 2m49s
Release Desktop Apps / build-windows (push) Successful in 17m47s
Release Desktop Apps / release (push) Successful in 9s
fix: append messages locally on send, don't rely solely on WS
2026-07-20 11:07:57 -04:00

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,
},
};
});
},
}));