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:
@@ -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(({
|
||||
</span>{" "}
|
||||
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
||||
{renderEmbeds(message.embeds)}
|
||||
{message.poll && <PollDisplay poll={message.poll} channelId={message.channel_id} />}
|
||||
|
||||
{/* Message reactions */}
|
||||
<ReactionBar
|
||||
@@ -272,6 +274,7 @@ export function ChatArea() {
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const [commandQuery, setCommandQuery] = useState<string | null>(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() {
|
||||
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
|
||||
)}
|
||||
{MenuPortal}
|
||||
{showPollModal && activeChannelId && (
|
||||
<CreatePollModal
|
||||
channelId={activeChannelId}
|
||||
onClose={() => setShowPollModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user