2023-04-22 19:55:47 +03:00
|
|
|
import { useState } from 'preact/hooks';
|
|
|
|
|
|
|
|
import emojifyText from '../utils/emojify-text';
|
|
|
|
import shortenNumber from '../utils/shorten-number';
|
|
|
|
|
2023-04-23 06:27:18 +03:00
|
|
|
import Icon from './icon';
|
2023-04-22 19:55:47 +03:00
|
|
|
import RelativeTime from './relative-time';
|
|
|
|
|
|
|
|
export default function Poll({
|
|
|
|
poll,
|
|
|
|
lang,
|
|
|
|
readOnly,
|
|
|
|
refresh = () => {},
|
|
|
|
votePoll = () => {},
|
|
|
|
}) {
|
|
|
|
const [uiState, setUIState] = useState('default');
|
|
|
|
const {
|
|
|
|
expired,
|
|
|
|
expiresAt,
|
|
|
|
id,
|
|
|
|
multiple,
|
|
|
|
options,
|
|
|
|
ownVotes,
|
|
|
|
voted,
|
|
|
|
votersCount,
|
|
|
|
votesCount,
|
|
|
|
emojis,
|
|
|
|
} = poll;
|
|
|
|
const expiresAtDate = !!expiresAt && new Date(expiresAt); // Update poll at point of expiry
|
|
|
|
// NOTE: Disable this because setTimeout runs immediately if delay is too large
|
|
|
|
// https://stackoverflow.com/a/56718027/20838
|
|
|
|
// useEffect(() => {
|
|
|
|
// let timeout;
|
|
|
|
// if (!expired && expiresAtDate) {
|
|
|
|
// const ms = expiresAtDate.getTime() - Date.now() + 1; // +1 to give it a little buffer
|
|
|
|
// if (ms > 0) {
|
|
|
|
// timeout = setTimeout(() => {
|
|
|
|
// setUIState('loading');
|
|
|
|
// (async () => {
|
|
|
|
// // await refresh();
|
|
|
|
// setUIState('default');
|
|
|
|
// })();
|
|
|
|
// }, ms);
|
|
|
|
// }
|
|
|
|
// }
|
|
|
|
// return () => {
|
|
|
|
// clearTimeout(timeout);
|
|
|
|
// };
|
|
|
|
// }, [expired, expiresAtDate]);
|
|
|
|
|
|
|
|
const pollVotesCount = votersCount || votesCount;
|
|
|
|
let roundPrecision = 0;
|
|
|
|
|
|
|
|
if (pollVotesCount <= 1000) {
|
|
|
|
roundPrecision = 0;
|
|
|
|
} else if (pollVotesCount <= 10000) {
|
|
|
|
roundPrecision = 1;
|
|
|
|
} else if (pollVotesCount <= 100000) {
|
|
|
|
roundPrecision = 2;
|
|
|
|
}
|
|
|
|
|
|
|
|
const [showResults, setShowResults] = useState(false);
|
|
|
|
const optionsHaveVoteCounts = options.every((o) => o.votesCount !== null);
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
lang={lang}
|
|
|
|
dir="auto"
|
|
|
|
class={`poll ${readOnly ? 'read-only' : ''} ${
|
|
|
|
uiState === 'loading' ? 'loading' : ''
|
|
|
|
}`}
|
|
|
|
onDblClick={() => {
|
|
|
|
setShowResults(!showResults);
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{(showResults && optionsHaveVoteCounts) || voted || expired ? (
|
|
|
|
<div class="poll-options">
|
|
|
|
{options.map((option, i) => {
|
|
|
|
const { title, votesCount: optionVotesCount } = option;
|
|
|
|
const percentage = pollVotesCount
|
|
|
|
? ((optionVotesCount / pollVotesCount) * 100).toFixed(
|
|
|
|
roundPrecision,
|
|
|
|
)
|
|
|
|
: 0; // check if current poll choice is the leading one
|
|
|
|
|
|
|
|
const isLeading =
|
|
|
|
optionVotesCount > 0 &&
|
|
|
|
optionVotesCount ===
|
|
|
|
Math.max(...options.map((o) => o.votesCount));
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
key={`${i}-${title}-${optionVotesCount}`}
|
|
|
|
class={`poll-option poll-result ${
|
|
|
|
isLeading ? 'poll-option-leading' : ''
|
|
|
|
}`}
|
|
|
|
style={{
|
|
|
|
'--percentage': `${percentage}%`,
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<div class="poll-option-title">
|
|
|
|
<span
|
|
|
|
dangerouslySetInnerHTML={{
|
|
|
|
__html: emojifyText(title, emojis),
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
{voted && ownVotes.includes(i) && (
|
|
|
|
<>
|
|
|
|
{' '}
|
|
|
|
<Icon icon="check-circle" />
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
<div
|
|
|
|
class="poll-option-votes"
|
|
|
|
title={`${optionVotesCount} vote${
|
|
|
|
optionVotesCount === 1 ? '' : 's'
|
|
|
|
}`}
|
|
|
|
>
|
|
|
|
{percentage}%
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
</div>
|
|
|
|
) : (
|
|
|
|
<form
|
|
|
|
onSubmit={async (e) => {
|
|
|
|
e.preventDefault();
|
|
|
|
const form = e.target;
|
|
|
|
const formData = new FormData(form);
|
|
|
|
const choices = [];
|
|
|
|
formData.forEach((value, key) => {
|
|
|
|
if (key === 'poll') {
|
|
|
|
choices.push(value);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
if (!choices.length) return;
|
|
|
|
setUIState('loading');
|
|
|
|
await votePoll(choices);
|
|
|
|
setUIState('default');
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<div class="poll-options">
|
|
|
|
{options.map((option, i) => {
|
|
|
|
const { title } = option;
|
|
|
|
return (
|
|
|
|
<div class="poll-option">
|
|
|
|
<label class="poll-label">
|
|
|
|
<input
|
|
|
|
type={multiple ? 'checkbox' : 'radio'}
|
|
|
|
name="poll"
|
|
|
|
value={i}
|
|
|
|
disabled={uiState === 'loading'}
|
|
|
|
readOnly={readOnly}
|
|
|
|
/>
|
|
|
|
<span
|
|
|
|
class="poll-option-title"
|
|
|
|
dangerouslySetInnerHTML={{
|
|
|
|
__html: emojifyText(title, emojis),
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</label>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
</div>
|
|
|
|
{!readOnly && (
|
|
|
|
<button
|
|
|
|
class="poll-vote-button"
|
|
|
|
type="submit"
|
|
|
|
disabled={uiState === 'loading'}
|
|
|
|
>
|
|
|
|
Vote
|
|
|
|
</button>
|
|
|
|
)}
|
|
|
|
</form>
|
|
|
|
)}
|
|
|
|
{!readOnly && (
|
|
|
|
<p class="poll-meta">
|
|
|
|
{!expired && (
|
|
|
|
<>
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
class="textual"
|
|
|
|
disabled={uiState === 'loading'}
|
|
|
|
onClick={(e) => {
|
|
|
|
e.preventDefault();
|
|
|
|
setUIState('loading');
|
|
|
|
|
|
|
|
(async () => {
|
|
|
|
await refresh();
|
|
|
|
setUIState('default');
|
|
|
|
})();
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
Refresh
|
|
|
|
</button>{' '}
|
|
|
|
•{' '}
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
|
|
|
|
{votesCount === 1 ? '' : 's'}
|
|
|
|
{!!votersCount && votersCount !== votesCount && (
|
|
|
|
<>
|
|
|
|
{' '}
|
|
|
|
•{' '}
|
|
|
|
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
|
|
|
|
voter
|
|
|
|
{votersCount === 1 ? '' : 's'}
|
|
|
|
</>
|
|
|
|
)}{' '}
|
|
|
|
• {expired ? 'Ended' : 'Ending'}{' '}
|
|
|
|
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
|
|
|
|
</p>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|