All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- 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.
350 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
}
|