feat: polls with live voting via WebSocket

- /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
This commit is contained in:
2026-07-02 12:35:05 -04:00
parent d8b4defaff
commit 40f8d193ff
10 changed files with 741 additions and 1 deletions
+48
View File
@@ -16,6 +16,22 @@ export interface Reaction {
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;
@@ -27,6 +43,7 @@ export interface Message {
embeds?: MessageEmbed[];
pinned: boolean;
reactions?: Reaction[];
poll?: Poll | null;
created_at: string;
edited_at: string | null;
}
@@ -59,6 +76,9 @@ export interface MessageState {
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) => ({
@@ -334,4 +354,32 @@ export const useMessageStore = create<MessageState>((set, get) => ({
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,
},
};
}),
}));
+9
View File
@@ -188,6 +188,15 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
}
break;
}
case 'POLL_UPDATE': {
const channelId = typeof payload.channel_id === 'string' ? payload.channel_id : '';
const poll = isRecord(payload.poll) ? payload.poll : null;
if (channelId && poll) {
const updatePoll = useMessageStore.getState().updatePoll;
updatePoll(channelId, poll as unknown as import('./message.ts').Poll);
}
break;
}
case 'CHANNEL_CREATE': {
if (isRecord(payload)) addChannel(payload as unknown as Channel);
break;