40f8d193ff
- /poll command opens creation modal (2-10 options) - PollDisplay with vote bars, percentages, live WS updates - Backend: polls/poll_options/poll_votes tables, Create/Get/Vote endpoints - attachPolls enriches message list responses - POLL_UPDATE broadcast on vote for real-time sync
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import { create } from "zustand";
|
|
import { api } from "../lib/api.ts";
|
|
|
|
export interface MessageEmbed {
|
|
id?: string;
|
|
url: string;
|
|
title?: string;
|
|
description?: string;
|
|
image_url?: string;
|
|
site_name?: string;
|
|
}
|
|
|
|
export interface Reaction {
|
|
emoji: string;
|
|
count: number;
|
|
users: string[];
|
|
}
|
|
|
|
export interface PollOption {
|
|
id: string;
|
|
text: string;
|
|
position: number;
|
|
votes: number;
|
|
voters: string[];
|
|
}
|
|
|
|
export interface Poll {
|
|
id: string;
|
|
message_id: string;
|
|
question: string;
|
|
options: PollOption[];
|
|
created_at: string;
|
|
}
|
|
|
|
export interface Message {
|
|
id: string;
|
|
channel_id: string;
|
|
author_id: string;
|
|
author_username: string;
|
|
author_display_name: string | null;
|
|
content: string;
|
|
reply_to?: string | null;
|
|
embeds?: MessageEmbed[];
|
|
pinned: boolean;
|
|
reactions?: Reaction[];
|
|
poll?: Poll | null;
|
|
created_at: string;
|
|
edited_at: string | null;
|
|
}
|
|
|
|
export interface SearchResultMessage extends Message {
|
|
channel_name?: string;
|
|
}
|
|
|
|
export interface MessageState {
|
|
messagesByChannel: Record<string, Message[]>;
|
|
pinnedMessagesByChannel: Record<string, Message[]>;
|
|
searchResultsByChannel: Record<string, SearchResultMessage[]>;
|
|
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection
|
|
isLoading: boolean;
|
|
isLoadingOlder: boolean;
|
|
hasMoreByChannel: Record<string, boolean>;
|
|
error: string | null;
|
|
fetchMessages: (channelId: string, before?: string) => Promise<void>;
|
|
fetchOlderMessages: (channelId: string) => Promise<void>;
|
|
sendMessage: (channelId: string, content: string, replyTo?: string) => Promise<Message>;
|
|
searchMessages: (channelId: string, query: string) => Promise<SearchResultMessage[]>;
|
|
pinMessage: (channelId: string, messageId: string) => Promise<void>;
|
|
unpinMessage: (channelId: string, messageId: string) => Promise<void>;
|
|
fetchPinnedMessages: (channelId: string) => Promise<Message[]>;
|
|
addMessage: (message: Message) => void;
|
|
updateMessage: (message: Message) => void;
|
|
removeMessage: (channelId: string, messageId: string) => void;
|
|
addReaction: (channelId: string, messageId: string, emoji: string, userId: string) => void;
|
|
removeReaction: (channelId: string, messageId: string, emoji: string, userId: string) => void;
|
|
bulkDeleteMessages: (channelId: string, messageIds: string[]) => Promise<{ deleted: number }>;
|
|
toggleSelectedMessage: (channelId: string, messageId: string) => void;
|
|
clearSelectedMessages: (channelId: string) => void;
|
|
createPoll: (channelId: string, question: string, options: string[]) => Promise<Poll>;
|
|
votePoll: (pollId: string, optionId: string) => Promise<void>;
|
|
updatePoll: (channelId: string, poll: Poll) => void;
|
|
}
|
|
|
|
export const useMessageStore = create<MessageState>((set, get) => ({
|
|
messagesByChannel: {},
|
|
pinnedMessagesByChannel: {},
|
|
searchResultsByChannel: {},
|
|
selectedMessageIds: {},
|
|
isLoading: false,
|
|
isLoadingOlder: false,
|
|
hasMoreByChannel: {},
|
|
error: null,
|
|
|
|
fetchMessages: async (channelId, before) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const params = before ? `?before=${encodeURIComponent(before)}` : "";
|
|
const messages = await api.get<Message[]>(
|
|
`/channels/${channelId}/messages${params}`,
|
|
);
|
|
const list = Array.isArray(messages) ? messages : [];
|
|
set((state) => ({
|
|
messagesByChannel: {
|
|
...state.messagesByChannel,
|
|
[channelId]: list,
|
|
},
|
|
hasMoreByChannel: {
|
|
...state.hasMoreByChannel,
|
|
[channelId]: list.length >= 50,
|
|
},
|
|
isLoading: false,
|
|
}));
|
|
} catch (error) {
|
|
set({
|
|
isLoading: false,
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to fetch messages",
|
|
});
|
|
}
|
|
},
|
|
|
|
fetchOlderMessages: async (channelId) => {
|
|
const state = get();
|
|
if (state.isLoadingOlder || state.hasMoreByChannel[channelId] === false) return;
|
|
const existing = state.messagesByChannel[channelId] || [];
|
|
if (existing.length === 0) return;
|
|
const oldestId = existing[0].id;
|
|
set({ isLoadingOlder: true });
|
|
try {
|
|
const older = await api.get<Message[]>(
|
|
`/channels/${channelId}/messages?before=${encodeURIComponent(oldestId)}`,
|
|
);
|
|
const list = Array.isArray(older) ? older : [];
|
|
set((state) => ({
|
|
messagesByChannel: {
|
|
...state.messagesByChannel,
|
|
[channelId]: [...list, ...existing],
|
|
},
|
|
hasMoreByChannel: {
|
|
...state.hasMoreByChannel,
|
|
[channelId]: list.length >= 50,
|
|
},
|
|
isLoadingOlder: false,
|
|
}));
|
|
} catch {
|
|
set({ isLoadingOlder: false });
|
|
}
|
|
},
|
|
|
|
searchMessages: async (channelId, query) => {
|
|
const results = await api.get<SearchResultMessage[]>(
|
|
`/channels/${channelId}/messages/search?q=${encodeURIComponent(query)}`,
|
|
);
|
|
set((state) => ({
|
|
searchResultsByChannel: {
|
|
...state.searchResultsByChannel,
|
|
[channelId]: Array.isArray(results) ? results : [],
|
|
},
|
|
}));
|
|
return Array.isArray(results) ? results : [];
|
|
},
|
|
|
|
sendMessage: async (channelId, content, replyTo) => {
|
|
const body: { content: string; reply_to?: string } = { content };
|
|
if (replyTo) body.reply_to = replyTo;
|
|
// ponytail: don't append here — WS MESSAGE_CREATE broadcast is the single source of truth.
|
|
// Appending in both places caused double messages when the WS event arrived before the HTTP response.
|
|
const message = await api.post<Message>(
|
|
`/channels/${channelId}/messages`,
|
|
body,
|
|
);
|
|
return message;
|
|
},
|
|
|
|
pinMessage: async (channelId, messageId) => {
|
|
const updated = await api.put<Message>(`/channels/${channelId}/messages/${messageId}/pin`, {});
|
|
get().updateMessage(updated);
|
|
},
|
|
|
|
unpinMessage: async (channelId, messageId) => {
|
|
const updated = await api.delete<Message>(`/channels/${channelId}/messages/${messageId}/pin`);
|
|
get().updateMessage(updated);
|
|
},
|
|
|
|
fetchPinnedMessages: async (channelId) => {
|
|
const pinned = await api.get<Message[]>(`/channels/${channelId}/messages/pinned`);
|
|
const list = Array.isArray(pinned) ? pinned : [];
|
|
set((state) => ({
|
|
pinnedMessagesByChannel: {
|
|
...state.pinnedMessagesByChannel,
|
|
[channelId]: list,
|
|
},
|
|
}));
|
|
return list;
|
|
},
|
|
|
|
addMessage: (message) =>
|
|
set((state) => {
|
|
const list = state.messagesByChannel[message.channel_id] || [];
|
|
if (list.some((m) => m.id === message.id)) {
|
|
return state;
|
|
}
|
|
return {
|
|
messagesByChannel: {
|
|
...state.messagesByChannel,
|
|
[message.channel_id]: [...list, message],
|
|
},
|
|
};
|
|
}),
|
|
|
|
updateMessage: (message) =>
|
|
set((state) => {
|
|
const list = state.messagesByChannel[message.channel_id] || [];
|
|
const updatedList = list.map((m) =>
|
|
m.id === message.id ? message : m,
|
|
);
|
|
|
|
const pinnedList = state.pinnedMessagesByChannel[message.channel_id] || [];
|
|
let updatedPinned = [...pinnedList];
|
|
if (message.pinned) {
|
|
if (!pinnedList.some((m) => m.id === message.id)) {
|
|
updatedPinned = [message, ...pinnedList].sort(
|
|
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
|
);
|
|
} else {
|
|
updatedPinned = pinnedList.map((m) => (m.id === message.id ? message : m));
|
|
}
|
|
} else {
|
|
updatedPinned = pinnedList.filter((m) => m.id !== message.id);
|
|
}
|
|
if (updatedPinned.length > 5) {
|
|
updatedPinned = updatedPinned.slice(0, 5);
|
|
}
|
|
|
|
return {
|
|
messagesByChannel: {
|
|
...state.messagesByChannel,
|
|
[message.channel_id]: updatedList,
|
|
},
|
|
pinnedMessagesByChannel: {
|
|
...state.pinnedMessagesByChannel,
|
|
[message.channel_id]: updatedPinned,
|
|
},
|
|
};
|
|
}),
|
|
|
|
removeMessage: (channelId, messageId) =>
|
|
set((state) => {
|
|
const list = state.messagesByChannel[channelId] || [];
|
|
const pinnedList = state.pinnedMessagesByChannel[channelId] || [];
|
|
return {
|
|
messagesByChannel: {
|
|
...state.messagesByChannel,
|
|
[channelId]: list.filter((m) => m.id !== messageId),
|
|
},
|
|
pinnedMessagesByChannel: {
|
|
...state.pinnedMessagesByChannel,
|
|
[channelId]: pinnedList.filter((m) => m.id !== messageId),
|
|
},
|
|
};
|
|
}),
|
|
|
|
addReaction: (channelId, messageId, emoji, userId) =>
|
|
set((state) => {
|
|
const list = state.messagesByChannel[channelId] || [];
|
|
const updatedList = list.map((m) => {
|
|
if (m.id !== messageId) return m;
|
|
const reactions = m.reactions ? [...m.reactions] : [];
|
|
const existing = reactions.find((r) => r.emoji === emoji);
|
|
if (existing) {
|
|
if (!existing.users.includes(userId)) {
|
|
existing.users = [...existing.users, userId];
|
|
existing.count = existing.users.length;
|
|
}
|
|
} else {
|
|
reactions.push({ emoji, count: 1, users: [userId] });
|
|
}
|
|
return { ...m, reactions };
|
|
});
|
|
return {
|
|
messagesByChannel: {
|
|
...state.messagesByChannel,
|
|
[channelId]: updatedList,
|
|
},
|
|
};
|
|
}),
|
|
|
|
removeReaction: (channelId, messageId, emoji, userId) =>
|
|
set((state) => {
|
|
const list = state.messagesByChannel[channelId] || [];
|
|
const updatedList = list.map((m) => {
|
|
if (m.id !== messageId) return m;
|
|
if (!m.reactions) return m;
|
|
const reactions = m.reactions
|
|
.map((r) => {
|
|
if (r.emoji !== emoji) return r;
|
|
const users = r.users.filter((id) => id !== userId);
|
|
return { ...r, users, count: users.length };
|
|
})
|
|
.filter((r) => r.count > 0);
|
|
return { ...m, reactions };
|
|
});
|
|
return {
|
|
messagesByChannel: {
|
|
...state.messagesByChannel,
|
|
[channelId]: updatedList,
|
|
},
|
|
};
|
|
}),
|
|
|
|
bulkDeleteMessages: async (channelId, messageIds) => {
|
|
const res = await api.post<{ deleted: number }>(
|
|
`/channels/${channelId}/messages/bulk-delete`,
|
|
{ messages: messageIds },
|
|
);
|
|
set((state) => {
|
|
const list = state.messagesByChannel[channelId] || [];
|
|
const ids = new Set(messageIds);
|
|
const selected = { ...state.selectedMessageIds };
|
|
delete selected[channelId];
|
|
return {
|
|
messagesByChannel: {
|
|
...state.messagesByChannel,
|
|
[channelId]: list.filter((m) => !ids.has(m.id)),
|
|
},
|
|
selectedMessageIds: selected,
|
|
};
|
|
});
|
|
return res;
|
|
},
|
|
|
|
toggleSelectedMessage: (channelId, messageId) =>
|
|
set((state) => {
|
|
const current = state.selectedMessageIds[channelId] || new Set<string>();
|
|
const next = new Set(current);
|
|
if (next.has(messageId)) {
|
|
next.delete(messageId);
|
|
} else {
|
|
next.add(messageId);
|
|
}
|
|
return {
|
|
selectedMessageIds: {
|
|
...state.selectedMessageIds,
|
|
[channelId]: next,
|
|
},
|
|
};
|
|
}),
|
|
|
|
clearSelectedMessages: (channelId) =>
|
|
set((state) => {
|
|
const next = { ...state.selectedMessageIds };
|
|
delete next[channelId];
|
|
return { selectedMessageIds: next };
|
|
}),
|
|
|
|
createPoll: async (channelId, question, options) => {
|
|
const resp = await api.post<Poll>("/polls", {
|
|
channel_id: channelId,
|
|
question,
|
|
options,
|
|
});
|
|
return resp;
|
|
},
|
|
|
|
votePoll: async (pollId, optionId) => {
|
|
await api.post(`/polls/${pollId}/vote`, { option_id: optionId });
|
|
},
|
|
|
|
updatePoll: (channelId, poll) =>
|
|
set((state) => {
|
|
const messages = state.messagesByChannel[channelId];
|
|
if (!messages) return state;
|
|
const updated = messages.map((m) =>
|
|
m.poll?.id === poll.id ? { ...m, poll } : m,
|
|
);
|
|
return {
|
|
messagesByChannel: {
|
|
...state.messagesByChannel,
|
|
[channelId]: updated,
|
|
},
|
|
};
|
|
}),
|
|
}));
|