393 lines
11 KiB
HTML
393 lines
11 KiB
HTML
<div class={computedClass} aria-busy={loading} >
|
|
{#if voted || expired }
|
|
<ul class="poll-choices" aria-label="{intl.pollResults}">
|
|
{#each options as option}
|
|
<li class="poll-choice option">
|
|
<div class="option-text">
|
|
<strong>{option.share}%</strong> <span>{@html cleanTitle(option.title)}</span>
|
|
</div>
|
|
<svg aria-hidden="true">
|
|
<line x1="0" y1="0" x2="{option.share}%" y2="0" />
|
|
</svg>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{:else}
|
|
<form class="poll-form" aria-label="{intl.voteOnPoll}" on:submit="onSubmit(event)" ref:form>
|
|
<ul class="poll-choices" aria-label="{intl.pollChoices}">
|
|
{#each options as option, i}
|
|
<li class="poll-choice poll-form-option">
|
|
<label>
|
|
<input type="{multiple ? 'checkbox' : 'radio'}"
|
|
name="poll-choice-{uuid}"
|
|
value="{i}"
|
|
on:change="onChange()"
|
|
>
|
|
<span>{@html cleanTitle(option.title)}</span>
|
|
</label>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
<button disabled={formDisabled} type="submit">{intl.vote}</button>
|
|
</form>
|
|
{/if}
|
|
<ul class="poll-details" aria-label="{intl.pollDetails}">
|
|
<li class="poll-stat {notification ? 'is-notification' : ''}">
|
|
<SvgIcon className="poll-icon" href="#fa-bar-chart" />
|
|
<span class="poll-stat-text">{votesText}</span>
|
|
</li>
|
|
<li class="poll-stat {notification ? 'is-notification' : ''}">
|
|
<SvgIcon className="poll-icon" href="#fa-clock" />
|
|
<span class="poll-stat-text poll-stat-expiry">
|
|
<span class="{useNarrowSize ? 'sr-only' : ''}">{expiryText}</span>
|
|
<time datetime={expiresAt} title={expiresAtAbsoluteFormatted}>
|
|
{expiresAtTimeagoFormatted}
|
|
</time>
|
|
</span>
|
|
</li>
|
|
<li class="poll-stat {notification ? 'is-notification' : ''} {expired ? 'poll-expired' : ''}">
|
|
<button id={refreshElementId}
|
|
class="focus-fix"
|
|
aria-label="{intl.refresh}">
|
|
<SvgIcon className="poll-icon" href="#fa-refresh" />
|
|
<span class="poll-stat-text poll-stat-text-refresh" aria-hidden="true">
|
|
{intl.refresh}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<style>
|
|
.poll {
|
|
grid-area: poll;
|
|
margin: 10px 10px 10px 5px;
|
|
padding: 20px;
|
|
border: 1px solid var(--main-border);
|
|
border-radius: 2px;
|
|
transition: opacity 0.2s linear;
|
|
display: none;
|
|
}
|
|
|
|
.poll.shown {
|
|
display: block;
|
|
}
|
|
|
|
.poll.status-in-own-thread {
|
|
padding: 20px;
|
|
}
|
|
|
|
.poll.poll-loading {
|
|
opacity: 0.5;
|
|
pointer-events: none;
|
|
}
|
|
|
|
ul.poll-choices {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
li.poll-choice {
|
|
margin: 10px 0;
|
|
padding: 0;
|
|
}
|
|
|
|
li.poll-choice:first-child {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.option {
|
|
margin: 0 0 10px 0;
|
|
padding: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
stroke: var(--svg-fill);
|
|
stroke-width: 10px;
|
|
}
|
|
|
|
.option-text {
|
|
word-wrap: break-word;
|
|
white-space: pre-wrap;
|
|
font-size: 1.1em;
|
|
}
|
|
|
|
svg {
|
|
height: 10px;
|
|
width: 100%;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.status-in-notification .option-text {
|
|
color: var(--very-deemphasized-text-color);
|
|
}
|
|
|
|
.status-in-notification svg {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.status-in-own-thread .option-text {
|
|
font-size: 1.2em;
|
|
}
|
|
|
|
ul.poll-details {
|
|
display: grid;
|
|
grid-template-columns: max-content minmax(0, max-content) max-content;
|
|
grid-gap: 20px;
|
|
align-items: center;
|
|
justify-content: left;
|
|
margin: 10px 0 0 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.poll-stat button {
|
|
/* reset button styles */
|
|
background: none;
|
|
box-shadow: none;
|
|
border: none;
|
|
border-spacing: 0;
|
|
margin: 0;
|
|
padding: 0;
|
|
font-size: inherit;
|
|
font-weight: normal;
|
|
text-align: left;
|
|
text-decoration: none;
|
|
text-indent: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.poll-stat button:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
li.poll-stat {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
color: var(--deemphasized-text-color);
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.poll-stat.is-notification, .poll-stat.is-notification .poll-stat-text {
|
|
color: var(--very-deemphasized-text-color);
|
|
}
|
|
|
|
:global(.poll-stat.is-notification .poll-icon) {
|
|
fill: var(--very-deemphasized-text-color);
|
|
}
|
|
|
|
.poll-stat.poll-expired {
|
|
display: none;
|
|
}
|
|
|
|
.poll-stat-text {
|
|
margin-left: 5px;
|
|
color: var(--deemphasized-text-color);
|
|
}
|
|
|
|
.poll-stat-expiry {
|
|
word-wrap: break-word;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
:global(.poll-icon) {
|
|
fill: var(--deemphasized-text-color);
|
|
width: 18px;
|
|
height: 18px;
|
|
min-width: 18px;
|
|
}
|
|
|
|
.poll-form-option {
|
|
padding-bottom: 10px;
|
|
}
|
|
|
|
.poll-form label span {
|
|
text-overflow: ellipsis;
|
|
overflow: hidden;
|
|
word-break: break-word;
|
|
white-space: pre-wrap;
|
|
padding-left: 5px;
|
|
}
|
|
|
|
@media (max-width: 479px) {
|
|
.poll {
|
|
padding: 10px 5px;
|
|
}
|
|
.poll.status-in-own-thread {
|
|
padding: 10px;
|
|
}
|
|
ul.poll-details {
|
|
grid-gap: 5px;
|
|
justify-content: space-between;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 320px) {
|
|
.poll-stat-text-refresh {
|
|
display: none; /* takes up too much space on small devices */
|
|
}
|
|
ul.poll-details {
|
|
grid-gap: 2px;
|
|
}
|
|
.poll-stat-text {
|
|
margin-left: 2px;
|
|
}
|
|
li.poll-choice {
|
|
margin: 5px 0;
|
|
}
|
|
}
|
|
</style>
|
|
<script>
|
|
import SvgIcon from '../SvgIcon.html'
|
|
import { store } from '../../_store/store.js'
|
|
import { formatTimeagoFutureDate, formatTimeagoDate } from '../../_intl/formatTimeagoDate.js'
|
|
import { absoluteDateFormatter } from '../../_utils/formatters.js'
|
|
import { registerClickDelegate } from '../../_utils/delegate.js'
|
|
import { classname } from '../../_utils/classname.js'
|
|
import { getPoll, voteOnPoll } from '../../_actions/polls.js'
|
|
import escapeHtml from 'escape-html'
|
|
import { emojifyText } from '../../_utils/emojifyText.js'
|
|
import { formatIntl } from '../../_utils/formatIntl.js'
|
|
|
|
const REFRESH_MIN_DELAY = 1000
|
|
|
|
async function doAsyncActionWithDelay (func) {
|
|
const start = Date.now()
|
|
const res = await func()
|
|
const timeElapsed = Date.now() - start
|
|
if (timeElapsed < REFRESH_MIN_DELAY) {
|
|
// If less than five seconds, then continue to show the loading animation
|
|
// so it's clear that something happened.
|
|
await new Promise(resolve => setTimeout(resolve, REFRESH_MIN_DELAY - timeElapsed))
|
|
}
|
|
return res
|
|
}
|
|
|
|
function getChoices (form, options) {
|
|
const res = []
|
|
for (let i = 0; i < options.length; i++) {
|
|
if (form.elements[i].checked) {
|
|
res.push(i)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
export default {
|
|
oncreate () {
|
|
this.onRefreshClick = this.onRefreshClick.bind(this)
|
|
const { refreshElementId } = this.get()
|
|
registerClickDelegate(this, refreshElementId, this.onRefreshClick)
|
|
},
|
|
data: () => ({
|
|
loading: false,
|
|
choices: []
|
|
}),
|
|
store: () => store,
|
|
computed: {
|
|
pollId: ({ originalStatus }) => originalStatus.poll.id,
|
|
poll: ({ originalStatus, $polls, pollId }) => $polls[pollId] || originalStatus.poll,
|
|
options: ({ poll, originalStatusEmojis, $autoplayGifs, votersOrVotesCount }) => (
|
|
poll.options.map(({ title, votes_count: optionsVotesCount }) => ({
|
|
title: emojifyText(escapeHtml(title), originalStatusEmojis, $autoplayGifs),
|
|
share: votersOrVotesCount ? Math.round(optionsVotesCount / votersOrVotesCount * 100) : 0
|
|
}))
|
|
),
|
|
votersCount: ({ poll }) => poll.voters_count,
|
|
votesCount: ({ poll }) => poll.votes_count,
|
|
// Misskey reports the "voters_count" as null, so just use the "votes_count"
|
|
votersOrVotesCount: ({ votersCount, votesCount }) => typeof votersCount === 'number' ? votersCount : votesCount,
|
|
voted: ({ poll }) => poll.voted,
|
|
multiple: ({ poll }) => poll.multiple,
|
|
expired: ({ poll }) => poll.expired,
|
|
expiresAt: ({ poll }) => poll.expires_at,
|
|
// Misskey can have polls that never end. These give expiresAt as null
|
|
expiresAtTS: ({ expiresAt }) => typeof expiresAt === 'number' ? new Date(expiresAt).getTime() : null,
|
|
expiresAtTimeagoFormatted: ({ expiresAtTS, expired, $now }) => (
|
|
expired ? formatTimeagoDate(expiresAtTS, $now) : formatTimeagoFutureDate(expiresAtTS, $now)
|
|
),
|
|
expiresAtAbsoluteFormatted: ({ expiresAtTS }) => absoluteDateFormatter().format(expiresAtTS),
|
|
expiryText: ({ expired }) => expired ? 'intl.expired' : 'intl.expires',
|
|
refreshElementId: ({ uuid }) => `poll-refresh-${uuid}`,
|
|
useNarrowSize: ({ $isMobileSize, expired, isStatusInOwnThread }) => (
|
|
!isStatusInOwnThread && $isMobileSize && !expired
|
|
),
|
|
formDisabled: ({ choices }) => !choices.length,
|
|
votesText: ({ votersOrVotesCount }) => (
|
|
formatIntl('intl.voteCount', { count: votersOrVotesCount })
|
|
),
|
|
computedClass: ({ isStatusInNotification, isStatusInOwnThread, loading, shown }) => (
|
|
classname(
|
|
'poll',
|
|
isStatusInNotification && 'status-in-notification',
|
|
isStatusInOwnThread && 'status-in-own-thread',
|
|
loading && 'poll-loading',
|
|
shown && 'shown'
|
|
)
|
|
)
|
|
},
|
|
methods: {
|
|
onRefreshClick () {
|
|
(async () => {
|
|
const { pollId } = this.get()
|
|
this.set({ loading: true })
|
|
try {
|
|
const poll = await doAsyncActionWithDelay(() => getPoll(pollId))
|
|
if (poll) {
|
|
const { polls } = this.store.get()
|
|
polls[pollId] = poll
|
|
this.store.set({ polls })
|
|
}
|
|
} finally {
|
|
this.set({ loading: false })
|
|
}
|
|
})()
|
|
return true
|
|
},
|
|
async onSubmit (e) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const { pollId, options, formDisabled } = this.get()
|
|
if (formDisabled) {
|
|
return
|
|
}
|
|
const choices = getChoices(this.refs.form, options)
|
|
this.set({ loading: true })
|
|
try {
|
|
const poll = await doAsyncActionWithDelay(() => voteOnPoll(pollId, choices))
|
|
if (poll) {
|
|
const { polls } = this.store.get()
|
|
polls[pollId] = poll
|
|
this.store.set({ polls })
|
|
// the height of the status changes after you vote on the poll
|
|
requestAnimationFrame(() => this.fire('recalculateHeight'))
|
|
}
|
|
} finally {
|
|
this.set({ loading: false })
|
|
}
|
|
},
|
|
onChange () {
|
|
const { options } = this.get()
|
|
const choices = getChoices(this.refs.form, options)
|
|
this.set({ choices })
|
|
}
|
|
},
|
|
helpers: {
|
|
cleanTitle (title) {
|
|
// Remove newlines and tabs.
|
|
// Mastodon UI doesn't care because in CSS it's formatted to be single-line, but we care
|
|
// if people somehow insert newlines, because it can really mess up the formatting.
|
|
return (title && title.replace(/[\n\t]+/g, ' ')) || ''
|
|
}
|
|
},
|
|
components: {
|
|
SvgIcon
|
|
}
|
|
}
|
|
</script>
|