From 40f8d193ff7381cf0380c7edc147533d021ed793 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 2 Jul 2026 12:35:05 -0400 Subject: [PATCH] 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 --- cmd/server/main.go | 6 + internal/db/db.go | 25 +++ internal/message/handlers.go | 106 +++++++++++ internal/message/poll.go | 324 ++++++++++++++++++++++++++++++++ web/src/components/ChatArea.tsx | 16 ++ web/src/components/Poll.tsx | 205 ++++++++++++++++++++ web/src/lib/slashCommands.ts | 1 + web/src/stores/message.ts | 48 +++++ web/src/stores/ws.ts | 9 + web/tsconfig.app.tsbuildinfo | 2 +- 10 files changed, 741 insertions(+), 1 deletion(-) create mode 100644 internal/message/poll.go create mode 100644 web/src/components/Poll.tsx diff --git a/cmd/server/main.go b/cmd/server/main.go index 01e5a46..da52bb7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -226,6 +226,12 @@ func main() { message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker).RegisterRoutes(r) }) + // Polls + pollHandler := message.NewPollHandler(database.DB, hub, permissionsChecker) + r.Route("/polls", func(r chi.Router) { + pollHandler.RegisterRoutes(r) + }) + // Per-channel notification settings r.Route("/channels/{channelID}/notifications", func(r chi.Router) { notification.NewHandler(database.DB, logger).RegisterRoutes(r) diff --git a/internal/db/db.go b/internal/db/db.go index bde312c..47602f5 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -509,5 +509,30 @@ CREATE TABLE IF NOT EXISTS server_groups ( ); CREATE INDEX IF NOT EXISTS idx_server_groups_server ON server_groups(server_id); ALTER TABLE channels ADD COLUMN IF NOT EXISTS group_id UUID REFERENCES server_groups(id) ON DELETE SET NULL; + +CREATE TABLE IF NOT EXISTS polls ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + question TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_polls_message ON polls(message_id); + +CREATE TABLE IF NOT EXISTS poll_options ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + poll_id UUID NOT NULL REFERENCES polls(id) ON DELETE CASCADE, + text TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_poll_options_poll ON poll_options(poll_id); + +CREATE TABLE IF NOT EXISTS poll_votes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + option_id UUID NOT NULL REFERENCES poll_options(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (option_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_poll_votes_option ON poll_votes(option_id); ` diff --git a/internal/message/handlers.go b/internal/message/handlers.go index f314e9c..a27083a 100644 --- a/internal/message/handlers.go +++ b/internal/message/handlers.go @@ -166,6 +166,7 @@ type messageResponse struct { CreatedAt string `json:"created_at"` Embeds []embedResponse `json:"embeds"` Reactions []emojiGroup `json:"reactions"` + Poll *pollResponse `json:"poll,omitempty"` } // isMember checks whether the given user is a member of the given server. @@ -549,6 +550,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { messages = h.attachEmbeds(r.Context(), messages) messages = h.attachReactions(r.Context(), messages) + messages = h.attachPolls(r.Context(), messages) // Reverse: query returns DESC (newest first), client expects ASC (oldest first). for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { @@ -672,6 +674,110 @@ func (h *Handler) attachReactions(ctx context.Context, messages []messageRespons return messages } +// attachPolls loads poll data for messages that have polls. +func (h *Handler) attachPolls(ctx context.Context, messages []messageResponse) []messageResponse { + if len(messages) == 0 { + return messages + } + + msgIDs := make([]string, len(messages)) + for i, m := range messages { + msgIDs[i] = m.ID + } + + placeholders := make([]string, len(msgIDs)) + args := make([]interface{}, len(msgIDs)) + for i, id := range msgIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + + query := fmt.Sprintf(` + SELECT p.id, p.message_id, p.question, p.created_at::text + FROM polls p + WHERE p.message_id IN (%s) + `, strings.Join(placeholders, ", ")) + + rows, err := h.db.QueryContext(ctx, query, args...) + if err != nil { + return messages + } + defer rows.Close() + + msgMap := make(map[string]int) + for i, m := range messages { + msgMap[m.ID] = i + } + + type pollRow struct { + ID string + MessageID string + Question string + CreatedAt string + } + pollRows := make([]pollRow, 0) + for rows.Next() { + var p pollRow + if err := rows.Scan(&p.ID, &p.MessageID, &p.Question, &p.CreatedAt); err != nil { + continue + } + pollRows = append(pollRows, p) + } + + for _, pr := range pollRows { + idx, ok := msgMap[pr.MessageID] + if !ok { + continue + } + + poll := &pollResponse{ + ID: pr.ID, + MessageID: pr.MessageID, + Question: pr.Question, + CreatedAt: pr.CreatedAt, + Options: make([]pollOptionResponse, 0), + } + + optRows, err := h.db.QueryContext(ctx, ` + SELECT po.id, po.text, po.position, COUNT(pv.id) as votes + FROM poll_options po + LEFT JOIN poll_votes pv ON pv.option_id = po.id + WHERE po.poll_id = $1 + GROUP BY po.id, po.text, po.position + ORDER BY po.position + `, pr.ID) + if err == nil { + for optRows.Next() { + var opt pollOptionResponse + if err := optRows.Scan(&opt.ID, &opt.Text, &opt.Position, &opt.Votes); err != nil { + continue + } + opt.Voters = []string{} + poll.Options = append(poll.Options, opt) + } + optRows.Close() + + for i, opt := range poll.Options { + voterRows, err := h.db.QueryContext(ctx, `SELECT user_id FROM poll_votes WHERE option_id = $1`, opt.ID) + if err != nil { + continue + } + for voterRows.Next() { + var voterID string + if voterRows.Scan(&voterID) == nil { + poll.Options[i].Voters = append(poll.Options[i].Voters, voterID) + } + } + voterRows.Close() + } + } + + messages[idx].Poll = poll + } + + return messages +} + // Search searches messages in a channel using PostgreSQL full-text search. func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { channelID := chi.URLParam(r, "channelID") diff --git a/internal/message/poll.go b/internal/message/poll.go new file mode 100644 index 0000000..774568b --- /dev/null +++ b/internal/message/poll.go @@ -0,0 +1,324 @@ +package message + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" + "github.com/go-chi/chi/v5" +) + +type PollHandler struct { + db *sql.DB + hub *gateway.Hub + checker *permissions.Checker +} + +func NewPollHandler(db *sql.DB, hub *gateway.Hub, checker *permissions.Checker) *PollHandler { + return &PollHandler{db: db, hub: hub, checker: checker} +} + +func (ph *PollHandler) RegisterRoutes(r chi.Router) { + r.Post("/", ph.Create) + r.Get("/{pollID}", ph.Get) + r.Post("/{pollID}/vote", ph.Vote) +} + +type createPollRequest struct { + ChannelID string `json:"channel_id"` + Question string `json:"question"` + Options []string `json:"options"` +} + +type pollOptionResponse struct { + ID string `json:"id"` + Text string `json:"text"` + Position int `json:"position"` + Votes int `json:"votes"` + Voters []string `json:"voters"` +} + +type pollResponse struct { + ID string `json:"id"` + MessageID string `json:"message_id"` + Question string `json:"question"` + Options []pollOptionResponse `json:"options"` + CreatedAt string `json:"created_at"` +} + +func (ph *PollHandler) Create(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + var req createPollRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + if req.Question == "" || len(req.Options) < 2 || len(req.Options) > 10 { + http.Error(w, `{"error":"question required, 2-10 options"}`, http.StatusBadRequest) + return + } + + var serverID string + err := ph.db.QueryRowContext(r.Context(), ` + SELECT server_id FROM channels WHERE id = $1 + `, req.ChannelID).Scan(&serverID) + if err != nil { + http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) + return + } + + tx, err := ph.db.BeginTx(r.Context(), nil) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer tx.Rollback() + + var msgID string + err = tx.QueryRowContext(r.Context(), ` + INSERT INTO messages (channel_id, author_id, content) + VALUES ($1, $2, $3) + RETURNING id + `, req.ChannelID, userID, req.Question).Scan(&msgID) + if err != nil { + http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError) + return + } + + var pollID string + err = tx.QueryRowContext(r.Context(), ` + INSERT INTO polls (message_id, question) + VALUES ($1, $2) + RETURNING id + `, msgID, req.Question).Scan(&pollID) + if err != nil { + http.Error(w, `{"error":"failed to create poll"}`, http.StatusInternalServerError) + return + } + + options := make([]pollOptionResponse, len(req.Options)) + for i, text := range req.Options { + var optID string + err = tx.QueryRowContext(r.Context(), ` + INSERT INTO poll_options (poll_id, text, position) + VALUES ($1, $2, $3) + RETURNING id + `, pollID, text, i).Scan(&optID) + if err != nil { + http.Error(w, `{"error":"failed to create option"}`, http.StatusInternalServerError) + return + } + options[i] = pollOptionResponse{ + ID: optID, + Text: text, + Position: i, + Votes: 0, + Voters: []string{}, + } + } + + if err := tx.Commit(); err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + var authorName string + var displayName sql.NullString + _ = ph.db.QueryRowContext(r.Context(), ` + SELECT username, display_name FROM users WHERE id = $1 + `, userID).Scan(&authorName, &displayName) + + var createdAt string + _ = ph.db.QueryRowContext(r.Context(), `SELECT created_at::text FROM messages WHERE id = $1`, msgID).Scan(&createdAt) + + poll := pollResponse{ + ID: pollID, + MessageID: msgID, + Question: req.Question, + Options: options, + CreatedAt: createdAt, + } + + broadcastMsg := map[string]interface{}{ + "id": msgID, + "channel_id": req.ChannelID, + "author_id": userID, + "author_username": authorName, + "author_display_name": displayName.String, + "content": req.Question, + "pinned": false, + "created_at": createdAt, + "reactions": []interface{}{}, + "embeds": []interface{}{}, + "poll": poll, + } + ph.hub.BroadcastToServer(serverID, gateway.Event{ + Type: gateway.EventMessageCreate, + Data: broadcastMsg, + }) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(poll) +} + +func (ph *PollHandler) Get(w http.ResponseWriter, r *http.Request) { + pollID := chi.URLParam(r, "pollID") + + poll, err := ph.fetchPoll(r.Context(), pollID) + if err != nil { + http.Error(w, `{"error":"poll not found"}`, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(poll) +} + +type voteRequest struct { + OptionID string `json:"option_id"` +} + +func (ph *PollHandler) Vote(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + pollID := chi.URLParam(r, "pollID") + + var req voteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + var optPollID string + err := ph.db.QueryRowContext(r.Context(), ` + SELECT poll_id FROM poll_options WHERE id = $1 + `, req.OptionID).Scan(&optPollID) + if err != nil || optPollID != pollID { + http.Error(w, `{"error":"invalid option"}`, http.StatusBadRequest) + return + } + + tx, err := ph.db.BeginTx(r.Context(), nil) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer tx.Rollback() + + _, err = tx.ExecContext(r.Context(), ` + DELETE FROM poll_votes + WHERE user_id = $1 + AND option_id IN (SELECT id FROM poll_options WHERE poll_id = $2) + `, userID, pollID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + _, err = tx.ExecContext(r.Context(), ` + INSERT INTO poll_votes (option_id, user_id) + VALUES ($1, $2) + `, req.OptionID, userID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + if err := tx.Commit(); err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + + // Get server for broadcast + var msgID, channelID, serverID string + _ = ph.db.QueryRowContext(r.Context(), ` + SELECT p.message_id, m.channel_id, c.server_id + FROM polls p + JOIN messages m ON m.id = p.message_id + JOIN channels c ON c.id = m.channel_id + WHERE p.id = $1 + `, pollID).Scan(&msgID, &channelID, &serverID) + + pollResp, _ := ph.fetchPoll(r.Context(), pollID) + if pollResp != nil { + ph.hub.BroadcastToServer(serverID, gateway.Event{ + Type: "POLL_UPDATE", + Data: map[string]interface{}{ + "poll": pollResp, + "message_id": msgID, + "channel_id": channelID, + }, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (ph *PollHandler) fetchPoll(ctx context.Context, pollID string) (*pollResponse, error) { + var poll pollResponse + var createdAt sql.NullString + err := ph.db.QueryRowContext(ctx, ` + SELECT id, message_id, question, created_at::text FROM polls WHERE id = $1 + `, pollID).Scan(&poll.ID, &poll.MessageID, &poll.Question, &createdAt) + if err != nil { + return nil, err + } + if createdAt.Valid { + poll.CreatedAt = createdAt.String + } + + rows, err := ph.db.QueryContext(ctx, ` + SELECT po.id, po.text, po.position, COUNT(pv.id) as votes + FROM poll_options po + LEFT JOIN poll_votes pv ON pv.option_id = po.id + WHERE po.poll_id = $1 + GROUP BY po.id, po.text, po.position + ORDER BY po.position + `, pollID) + if err != nil { + return nil, err + } + defer rows.Close() + + poll.Options = make([]pollOptionResponse, 0) + for rows.Next() { + var opt pollOptionResponse + if err := rows.Scan(&opt.ID, &opt.Text, &opt.Position, &opt.Votes); err != nil { + continue + } + opt.Voters = []string{} + poll.Options = append(poll.Options, opt) + } + + for i, opt := range poll.Options { + voterRows, err := ph.db.QueryContext(ctx, `SELECT user_id FROM poll_votes WHERE option_id = $1`, opt.ID) + if err != nil { + continue + } + for voterRows.Next() { + var voterID string + if voterRows.Scan(&voterID) == nil { + poll.Options[i].Voters = append(poll.Options[i].Voters, voterID) + } + } + voterRows.Close() + } + + return &poll, nil +} diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index 75a0617..0c3e2f2 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -10,6 +10,7 @@ import { useThreadStore } from "../stores/thread.ts"; import { GiphyPicker, type Gif } from "./GiphyPicker"; import { CommandDropdown } from "./CommandDropdown"; import { findCommand } from "../lib/slashCommands"; +import { PollDisplay, CreatePollModal } from "./Poll.tsx"; import { MentionDropdown } from "./MentionDropdown"; import { useReadStatesStore } from "../stores/readStates.ts"; import { MessageSearch } from "./MessageSearch"; @@ -217,6 +218,7 @@ const MessageItem = memo(({ {" "} {renderContent(message.content, memberUsernames)} {renderEmbeds(message.embeds)} + {message.poll && } {/* Message reactions */} (null); const [commandQuery, setCommandQuery] = useState(null); + const [showPollModal, setShowPollModal] = useState(false); const [slowmodeRemaining, setSlowmodeRemaining] = useState(0); const [selectMode, setSelectMode] = useState(false); const [showThreads, setShowThreads] = useState(false); @@ -494,6 +497,13 @@ export function ChatArea() { const spaceIdx = messageText.indexOf(' '); const cmdName = spaceIdx === -1 ? messageText.slice(1) : messageText.slice(1, spaceIdx); const args = spaceIdx === -1 ? '' : messageText.slice(spaceIdx + 1).trim(); + // /poll opens the poll creation modal + if (cmdName === 'poll') { + setShowPollModal(true); + setInput(''); + setCommandQuery(null); + return; + } const cmd = findCommand(cmdName); if (cmd) { messageText = cmd.transform(args, currentUser?.username || 'user'); @@ -782,6 +792,12 @@ export function ChatArea() { setProfileUserId(null)} /> )} {MenuPortal} + {showPollModal && activeChannelId && ( + setShowPollModal(false)} + /> + )} ); } diff --git a/web/src/components/Poll.tsx b/web/src/components/Poll.tsx new file mode 100644 index 0000000..e05d6e8 --- /dev/null +++ b/web/src/components/Poll.tsx @@ -0,0 +1,205 @@ +import { useState } from "react"; +import { useMessageStore, type Poll } from "../stores/message.ts"; + +interface CreatePollModalProps { + channelId: string; + onClose: () => void; +} + +export function CreatePollModal({ channelId, onClose }: CreatePollModalProps) { + const [question, setQuestion] = useState(""); + const [options, setOptions] = useState(["", ""]); + const [error, setError] = useState(null); + const createPoll = useMessageStore((s) => s.createPoll); + + const addOption = () => { + if (options.length < 10) setOptions([...options, ""]); + }; + + const removeOption = (idx: number) => { + if (options.length > 2) setOptions(options.filter((_, i) => i !== idx)); + }; + + const updateOption = (idx: number, value: string) => { + const next = [...options]; + next[idx] = value; + setOptions(next); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const filled = options.filter((o) => o.trim()); + if (!question.trim() || filled.length < 2) { + setError("Need a question and at least 2 options"); + return; + } + try { + await createPoll(channelId, question.trim(), filled.map((o) => o.trim())); + onClose(); + } catch { + setError("Failed to create poll"); + } + }; + + return ( +
+
+

+ Create Poll +

+ + setQuestion(e.target.value)} + placeholder="Question..." + maxLength={300} + className="terminal-input w-full mb-3" + autoFocus + /> + +
+ {options.map((opt, i) => ( +
+ updateOption(i, e.target.value)} + placeholder={`Option ${i + 1}`} + maxLength={150} + className="terminal-input flex-1" + /> + {options.length > 2 && ( + + )} +
+ ))} +
+ + {options.length < 10 && ( + + )} + + {error &&
{error}
} + +
+ + +
+
+
+ ); +} + +interface PollDisplayProps { + poll: Poll; + channelId: string; +} + +export function PollDisplay({ poll, channelId }: PollDisplayProps) { + const votePoll = useMessageStore((s) => s.votePoll); + const updatePoll = useMessageStore((s) => s.updatePoll); + const currentUserId = localStorage.getItem("userId") || ""; + + const totalVotes = poll.options.reduce((sum, o) => sum + o.votes, 0); + const userVotedOption = poll.options.find((o) => + o.voters.includes(currentUserId), + ); + + const handleVote = async (optionId: string) => { + try { + await votePoll(poll.id, optionId); + // Optimistic: update local state + const updated = { + ...poll, + options: poll.options.map((o) => { + const wasVoted = o.voters.includes(currentUserId); + const isTarget = o.id === optionId; + let voters = o.voters.filter((v) => v !== currentUserId); + let votes = o.votes; + if (wasVoted && o.id !== optionId) votes--; + if (isTarget) { + voters = [...voters, currentUserId]; + votes = o.votes + (wasVoted ? 0 : 1); + } + return { ...o, votes, voters }; + }), + }; + updatePoll(channelId, updated); + } catch { + // ignore + } + }; + + return ( +
+
+ Poll +
+
{poll.question}
+ +
+ {poll.options.map((opt) => { + const pct = totalVotes > 0 ? Math.round((opt.votes / totalVotes) * 100) : 0; + const isSelected = opt.voters.includes(currentUserId); + + return ( + + ); + })} +
+ +
+ {totalVotes} vote{totalVotes !== 1 ? "s" : ""} + {userVotedOption && ( + + {'('}you voted: {userVotedOption.text}{')'} + + )} +
+
+ ); +} diff --git a/web/src/lib/slashCommands.ts b/web/src/lib/slashCommands.ts index 1663f8b..3a615f9 100644 --- a/web/src/lib/slashCommands.ts +++ b/web/src/lib/slashCommands.ts @@ -34,6 +34,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ { name: 'greet', description: 'Friendly wave hello', transform: (a) => a ? GREET + ' ' + a : GREET + ' Hello!' }, { name: 'me', description: 'Send an action message', transform: (a, u) => '*' + u + ' ' + a + '*' }, { name: 'spoiler', description: 'Hide text as a spoiler', transform: (a) => '||' + a + '||' }, + { name: 'poll', description: 'Create a poll', transform: (a) => a }, ]; export function findCommand(name: string): SlashCommand | undefined { diff --git a/web/src/stores/message.ts b/web/src/stores/message.ts index ec9ce68..20806eb 100644 --- a/web/src/stores/message.ts +++ b/web/src/stores/message.ts @@ -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; + votePoll: (pollId: string, optionId: string) => Promise; + updatePoll: (channelId: string, poll: Poll) => void; } export const useMessageStore = create((set, get) => ({ @@ -334,4 +354,32 @@ export const useMessageStore = create((set, get) => ({ delete next[channelId]; return { selectedMessageIds: next }; }), + + createPoll: async (channelId, question, options) => { + const resp = await api.post("/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, + }, + }; + }), })); diff --git a/web/src/stores/ws.ts b/web/src/stores/ws.ts index 67a62ab..03b7bbe 100644 --- a/web/src/stores/ws.ts +++ b/web/src/stores/ws.ts @@ -188,6 +188,15 @@ export const useWebSocketStore = create((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; diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index 2a750ca..e05685e 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/PinnedMessages.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandDropdown.tsx","./src/components/CommandManager.tsx","./src/components/ContextMenu.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/DocsView.tsx","./src/components/EmojiPicker.tsx","./src/components/ForgotPasswordPage.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/ListView.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/PinnedMessages.tsx","./src/components/Poll.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ResetPasswordPage.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserProfileModal.tsx","./src/components/UserSettings.tsx","./src/components/VideoGrid.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/kaomojiData.ts","./src/lib/slashCommands.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/notificationSettings.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/readStates.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file