Files
DiscordClone/packages/shared/src/components/channel/PollCard.tsx
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
2026-04-14 09:02:14 -05:00

350 lines
9.6 KiB
TypeScript

/**
* PollCard — renders a Convex-backed poll inline in the message
* timeline. Single- and multi-selection polls are distinguished by
* `poll.allowMultiple` (radio vs checkbox). Disclosed polls show the
* live tally; undisclosed polls hide counts until the poll is closed.
*
* Clicks send (or clear) the viewer's vote via `api.polls.vote` /
* `api.polls.clearVote`. Because the parent `useQuery(api.polls.get)`
* is reactive, the tally updates live for every viewer whenever
* anyone votes.
*/
import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useMutation, useQuery } from 'convex/react';
import { Smiley } from '@phosphor-icons/react';
import { Modal, Button } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { EmojiPicker, type EmojiPickerValue } from './EmojiPicker';
import { TwemojiImg } from './TwemojiImg';
import { resolveReactionKeyToUnicode } from '../../utils/emojiLookup';
import styles from './PollCard.module.css';
interface PollCardProps {
pollId: Id<'polls'>;
}
export function PollCard({ pollId }: PollCardProps) {
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const result = useQuery(api.polls.get, {
pollId,
userId: (myUserId ?? undefined) as Id<'userProfiles'> | undefined,
});
const voteMutation = useMutation(api.polls.vote);
const clearVoteMutation = useMutation(api.polls.clearVote);
const closeMutation = useMutation(api.polls.close);
const addReactionMutation = useMutation(api.polls.addReaction);
const removeReactionMutation = useMutation(api.polls.removeReaction);
// Reaction picker — anchored off the Add Reaction button. `null`
// means the picker is closed.
const addReactionButtonRef = useRef<HTMLButtonElement | null>(null);
const [reactPickerPos, setReactPickerPos] = useState<
{ top: number; left: number } | null
>(null);
const [endConfirmOpen, setEndConfirmOpen] = useState(false);
const [ending, setEnding] = useState(false);
const [endError, setEndError] = useState<string | null>(null);
if (!result) {
return <div className={styles.card}>Loading poll</div>;
}
const { poll, totals, totalVotes, myVote, reactions } = result;
const isEnded = poll.closed || (poll.closesAt != null && poll.closesAt < Date.now());
const isMultiple = poll.allowMultiple;
const countsVisible = poll.disclosed || isEnded;
const mySelectionSet = new Set<string>(myVote ?? []);
const handleToggleReaction = (emoji: string, me: boolean) => {
if (!myUserId) return;
if (me) {
void removeReactionMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
emoji,
});
} else {
void addReactionMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
emoji,
});
}
};
const openReactPicker = () => {
const btn = addReactionButtonRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
setReactPickerPos({
// Anchor the picker so its bottom edge sits just above the
// button — matches the message reaction picker placement.
top: Math.max(8, rect.top - 440),
left: Math.max(8, rect.left - 240),
});
};
const handlePickReaction = (value: EmojiPickerValue) => {
if (!myUserId) return;
// GIF picks are meaningless as reactions — silently drop them.
if (value.kind === 'gif') {
setReactPickerPos(null);
return;
}
const emojiKey =
value.kind === 'custom' ? value.shortcode : value.surrogates;
// If the viewer already reacted with this emoji, toggle it off
// instead of inserting a duplicate row (mutation is idempotent
// either way, but this matches chat behaviour).
const already = reactions?.some((r) => r.emoji === emojiKey && r.me);
if (already) {
void removeReactionMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
emoji: emojiKey,
});
} else {
void addReactionMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
emoji: emojiKey,
});
}
setReactPickerPos(null);
};
const handleClick = (optionId: string) => {
if (isEnded) return;
if (!myUserId) return;
let next: string[];
if (isMultiple) {
const current = new Set(mySelectionSet);
if (current.has(optionId)) current.delete(optionId);
else current.add(optionId);
next = Array.from(current);
} else {
next = mySelectionSet.has(optionId) ? [] : [optionId];
}
if (next.length === 0) {
void clearVoteMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
});
} else {
void voteMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
optionIds: next,
});
}
};
// Bars scale to the leading answer so the winner reaches 100% and
// the rest scale proportionally — matches the new UI's visuals.
let maxCount = 0;
for (const opt of poll.options) {
const c = totals[opt.id] ?? 0;
if (c > maxCount) maxCount = c;
}
return (
<div className={styles.card}>
<div className={styles.question}>{poll.question || 'Poll'}</div>
{!countsVisible && !isEnded && (
<div className={styles.meta}>Results hidden until the poll ends</div>
)}
{isEnded && (
<div className={`${styles.meta} ${styles.metaEnded}`}>Poll ended</div>
)}
<div className={styles.answers}>
{poll.options.map((option) => {
const count = totals[option.id] ?? 0;
const selected = mySelectionSet.has(option.id);
const pct = !countsVisible
? 0
: maxCount === 0
? 0
: (count / maxCount) * 100;
return (
<button
type="button"
key={option.id}
className={[
styles.answer,
selected ? styles.answerSelected : '',
isEnded ? styles.answerDisabled : '',
]
.filter(Boolean)
.join(' ')}
style={{ ['--pct' as any]: `${pct}%` }}
onClick={() => handleClick(option.id)}
disabled={isEnded}
aria-pressed={selected}
>
<span
className={[
styles.radio,
isMultiple ? styles.radioCheckbox : '',
selected ? styles.radioChecked : '',
]
.filter(Boolean)
.join(' ')}
/>
<span className={styles.label}>{option.text}</span>
{countsVisible && (
<span className={styles.count}>
{count} {count === 1 ? 'vote' : 'votes'}
</span>
)}
</button>
);
})}
</div>
<div className={styles.totalRow}>
<span>
{totalVotes} {totalVotes === 1 ? 'voter' : 'voters'}
{isMultiple ? ' · multi-select' : ''}
</span>
{myUserId === poll.createdBy && !isEnded && (
<button
type="button"
className={styles.endButton}
onClick={() => {
setEndError(null);
setEndConfirmOpen(true);
}}
>
End Poll
</button>
)}
</div>
{(reactions.length > 0 || myUserId) && (
<div className={styles.reactions}>
{reactions.map((r) => (
<button
key={r.emoji}
type="button"
className={`${styles.reactionChip} ${r.me ? styles.reactionMe : ''}`}
onClick={() => handleToggleReaction(r.emoji, r.me)}
>
<TwemojiImg
emoji={resolveReactionKeyToUnicode(r.emoji)}
size={16}
/>
<span className={styles.reactionCount}>{r.count}</span>
</button>
))}
{myUserId && (
<button
ref={addReactionButtonRef}
type="button"
className={styles.addReactionChip}
onClick={openReactPicker}
aria-label="Add reaction"
title="Add reaction"
>
<Smiley size={14} weight="regular" />
</button>
)}
</div>
)}
{reactPickerPos &&
createPortal(
<div
style={{
position: 'fixed',
top: reactPickerPos.top,
left: reactPickerPos.left,
zIndex: 15000,
}}
onClick={(e) => e.stopPropagation()}
>
<EmojiPicker
onSelect={handlePickReaction}
onClose={() => setReactPickerPos(null)}
/>
</div>,
document.body,
)}
<Modal.Root
isOpen={endConfirmOpen}
onClose={() => {
if (ending) return;
setEndConfirmOpen(false);
}}
size="small"
>
<Modal.Header
title="End poll?"
onClose={() => {
if (ending) return;
setEndConfirmOpen(false);
}}
/>
<Modal.Content>
<div className={styles.endModalBody}>
<p className={styles.endModalDescription}>
Voting will be locked and the results will be finalised.
{!poll.disclosed && (
<> Hidden vote counts will become visible to everyone.</>
)}
</p>
<div className={styles.endModalQuestion}>
{poll.question || 'Poll'}
</div>
{endError && <div className={styles.endModalError}>{endError}</div>}
<div className={styles.endModalActions}>
<Button
variant="secondary"
size="sm"
onClick={() => setEndConfirmOpen(false)}
disabled={ending}
>
Cancel
</Button>
<Button
variant="danger"
size="sm"
loading={ending}
onClick={async () => {
if (!myUserId) return;
setEnding(true);
setEndError(null);
try {
await closeMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
});
setEndConfirmOpen(false);
} catch (err: any) {
setEndError(err?.message || 'Failed to end the poll.');
} finally {
setEnding(false);
}
}}
>
End Poll
</Button>
</div>
</div>
</Modal.Content>
</Modal.Root>
</div>
);
}