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
+6
View File
@@ -226,6 +226,12 @@ func main() {
message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker).RegisterRoutes(r) 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 // Per-channel notification settings
r.Route("/channels/{channelID}/notifications", func(r chi.Router) { r.Route("/channels/{channelID}/notifications", func(r chi.Router) {
notification.NewHandler(database.DB, logger).RegisterRoutes(r) notification.NewHandler(database.DB, logger).RegisterRoutes(r)
+25
View File
@@ -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); 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; 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);
` `
+106
View File
@@ -166,6 +166,7 @@ type messageResponse struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
Embeds []embedResponse `json:"embeds"` Embeds []embedResponse `json:"embeds"`
Reactions []emojiGroup `json:"reactions"` Reactions []emojiGroup `json:"reactions"`
Poll *pollResponse `json:"poll,omitempty"`
} }
// isMember checks whether the given user is a member of the given server. // 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.attachEmbeds(r.Context(), messages)
messages = h.attachReactions(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). // 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 { 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 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. // Search searches messages in a channel using PostgreSQL full-text search.
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID") channelID := chi.URLParam(r, "channelID")
+324
View File
@@ -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
}
+16
View File
@@ -10,6 +10,7 @@ import { useThreadStore } from "../stores/thread.ts";
import { GiphyPicker, type Gif } from "./GiphyPicker"; import { GiphyPicker, type Gif } from "./GiphyPicker";
import { CommandDropdown } from "./CommandDropdown"; import { CommandDropdown } from "./CommandDropdown";
import { findCommand } from "../lib/slashCommands"; import { findCommand } from "../lib/slashCommands";
import { PollDisplay, CreatePollModal } from "./Poll.tsx";
import { MentionDropdown } from "./MentionDropdown"; import { MentionDropdown } from "./MentionDropdown";
import { useReadStatesStore } from "../stores/readStates.ts"; import { useReadStatesStore } from "../stores/readStates.ts";
import { MessageSearch } from "./MessageSearch"; import { MessageSearch } from "./MessageSearch";
@@ -217,6 +218,7 @@ const MessageItem = memo(({
</span>{" "} </span>{" "}
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span> <span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
{renderEmbeds(message.embeds)} {renderEmbeds(message.embeds)}
{message.poll && <PollDisplay poll={message.poll} channelId={message.channel_id} />}
{/* Message reactions */} {/* Message reactions */}
<ReactionBar <ReactionBar
@@ -272,6 +274,7 @@ export function ChatArea() {
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null); const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [commandQuery, setCommandQuery] = useState<string | null>(null); const [commandQuery, setCommandQuery] = useState<string | null>(null);
const [showPollModal, setShowPollModal] = useState(false);
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0); const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
const [selectMode, setSelectMode] = useState(false); const [selectMode, setSelectMode] = useState(false);
const [showThreads, setShowThreads] = useState(false); const [showThreads, setShowThreads] = useState(false);
@@ -494,6 +497,13 @@ export function ChatArea() {
const spaceIdx = messageText.indexOf(' '); const spaceIdx = messageText.indexOf(' ');
const cmdName = spaceIdx === -1 ? messageText.slice(1) : messageText.slice(1, spaceIdx); const cmdName = spaceIdx === -1 ? messageText.slice(1) : messageText.slice(1, spaceIdx);
const args = spaceIdx === -1 ? '' : messageText.slice(spaceIdx + 1).trim(); 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); const cmd = findCommand(cmdName);
if (cmd) { if (cmd) {
messageText = cmd.transform(args, currentUser?.username || 'user'); messageText = cmd.transform(args, currentUser?.username || 'user');
@@ -782,6 +792,12 @@ export function ChatArea() {
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} /> <UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
)} )}
{MenuPortal} {MenuPortal}
{showPollModal && activeChannelId && (
<CreatePollModal
channelId={activeChannelId}
onClose={() => setShowPollModal(false)}
/>
)}
</div> </div>
); );
} }
+205
View File
@@ -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<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<form
onSubmit={handleSubmit}
className="bg-gb-bg border border-gb-bg-t p-4 w-[420px] max-h-[80vh] overflow-y-auto"
>
<h3 className="text-gb-orange font-mono text-sm mb-3 uppercase tracking-wide">
Create Poll
</h3>
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="Question..."
maxLength={300}
className="terminal-input w-full mb-3"
autoFocus
/>
<div className="space-y-2 mb-3">
{options.map((opt, i) => (
<div key={i} className="flex gap-2">
<input
type="text"
value={opt}
onChange={(e) => updateOption(i, e.target.value)}
placeholder={`Option ${i + 1}`}
maxLength={150}
className="terminal-input flex-1"
/>
{options.length > 2 && (
<button
type="button"
onClick={() => removeOption(i)}
className="text-gb-fg hover:text-gb-red px-2 font-mono"
>
x
</button>
)}
</div>
))}
</div>
{options.length < 10 && (
<button
type="button"
onClick={addOption}
className="text-gb-green hover:text-gb-fg font-mono text-xs mb-3"
>
+ add option
</button>
)}
{error && <div className="text-gb-red text-xs font-mono mb-2">{error}</div>}
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-3 py-1 text-xs font-mono text-gb-fg hover:text-gb-fg"
>
cancel
</button>
<button type="submit" className="btn-primary px-3 py-1 text-xs font-mono">
create poll
</button>
</div>
</form>
</div>
);
}
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 (
<div className="border border-gb-bg-t bg-gb-bg-s p-3 mt-1 max-w-[400px]">
<div className="text-gb-orange font-mono text-xs mb-2 uppercase tracking-wide">
Poll
</div>
<div className="text-gb-fg font-mono text-sm mb-3">{poll.question}</div>
<div className="space-y-2">
{poll.options.map((opt) => {
const pct = totalVotes > 0 ? Math.round((opt.votes / totalVotes) * 100) : 0;
const isSelected = opt.voters.includes(currentUserId);
return (
<button
key={opt.id}
type="button"
onClick={() => handleVote(opt.id)}
className="w-full text-left relative group"
>
<div className="relative flex items-center justify-between px-2 py-1.5 border border-gb-bg-t hover:border-gb-orange transition-colors">
{/* progress bar background */}
<div
className={`absolute inset-0 ${isSelected ? "bg-gb-green/15" : "bg-gb-bg-t/40"}`}
style={{ width: `${pct}%` }}
/>
<span className="relative font-mono text-xs text-gb-fg flex-1">
{isSelected && <span className="text-gb-green mr-1">{'>'}</span>}
{opt.text}
</span>
<span className="relative font-mono text-xs text-gb-fg-s ml-2">
{opt.votes} ({pct}%)
</span>
</div>
</button>
);
})}
</div>
<div className="text-gb-fg-f font-mono text-xs mt-2">
{totalVotes} vote{totalVotes !== 1 ? "s" : ""}
{userVotedOption && (
<span className="text-gb-green ml-2">
{'('}you voted: {userVotedOption.text}{')'}
</span>
)}
</div>
</div>
);
}
+1
View File
@@ -34,6 +34,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [
{ name: 'greet', description: 'Friendly wave hello', transform: (a) => a ? GREET + ' ' + a : GREET + ' Hello!' }, { 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: 'me', description: 'Send an action message', transform: (a, u) => '*' + u + ' ' + a + '*' },
{ name: 'spoiler', description: 'Hide text as a spoiler', transform: (a) => '||' + 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 { export function findCommand(name: string): SlashCommand | undefined {
+48
View File
@@ -16,6 +16,22 @@ export interface Reaction {
users: string[]; 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 { export interface Message {
id: string; id: string;
channel_id: string; channel_id: string;
@@ -27,6 +43,7 @@ export interface Message {
embeds?: MessageEmbed[]; embeds?: MessageEmbed[];
pinned: boolean; pinned: boolean;
reactions?: Reaction[]; reactions?: Reaction[];
poll?: Poll | null;
created_at: string; created_at: string;
edited_at: string | null; edited_at: string | null;
} }
@@ -59,6 +76,9 @@ export interface MessageState {
bulkDeleteMessages: (channelId: string, messageIds: string[]) => Promise<{ deleted: number }>; bulkDeleteMessages: (channelId: string, messageIds: string[]) => Promise<{ deleted: number }>;
toggleSelectedMessage: (channelId: string, messageId: string) => void; toggleSelectedMessage: (channelId: string, messageId: string) => void;
clearSelectedMessages: (channelId: 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) => ({ export const useMessageStore = create<MessageState>((set, get) => ({
@@ -334,4 +354,32 @@ export const useMessageStore = create<MessageState>((set, get) => ({
delete next[channelId]; delete next[channelId];
return { selectedMessageIds: next }; 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; 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': { case 'CHANNEL_CREATE': {
if (isRecord(payload)) addChannel(payload as unknown as Channel); if (isRecord(payload)) addChannel(payload as unknown as Channel);
break; break;
+1 -1
View File
@@ -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"} {"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"}