feat: vote on polls (#1234)

more work on #1130
This commit is contained in:
Nolan Lawson 2019-05-26 20:45:42 -07:00 committed by GitHub
parent 45441d3a9e
commit 2c1de66592
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 133 additions and 37 deletions

View file

@ -1,4 +1,4 @@
import { getPoll as getPollApi } from '../_api/polls'
import { getPoll as getPollApi, voteOnPoll as voteOnPollApi } from '../_api/polls'
import { store } from '../_store/store'
import { toast } from '../_components/toast/toast'
@ -9,6 +9,17 @@ export async function getPoll (pollId) {
return poll
} catch (e) {
console.error(e)
toast.say(`Unable to refresh poll`)
toast.say('Unable to refresh poll: ' + (e.message || ''))
}
}
export async function voteOnPoll (pollId, choices) {
let { currentInstance, accessToken } = store.get()
try {
let poll = await voteOnPollApi(currentInstance, accessToken, pollId, choices.map(_ => _.toString()))
return poll
} catch (e) {
console.error(e)
toast.say('Unable to vote in poll: ' + (e.message || ''))
}
}

View file

@ -1,7 +1,12 @@
import { get, DEFAULT_TIMEOUT } from '../_utils/ajax'
import { get, post, DEFAULT_TIMEOUT, WRITE_TIMEOUT } from '../_utils/ajax'
import { auth, basename } from './utils'
export async function getPoll (instanceName, accessToken, pollId) {
let url = `${basename(instanceName)}/api/v1/polls/${pollId}`
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
}
export async function voteOnPoll (instanceName, accessToken, pollId, choices) {
let url = `${basename(instanceName)}/api/v1/polls/${pollId}/votes`
return post(url, { choices }, auth(accessToken), { timeout: WRITE_TIMEOUT })
}

View file

@ -144,7 +144,7 @@
import { createStatusOrNotificationUuid } from '../../_utils/createStatusOrNotificationUuid'
import { statusHtmlToPlainText } from '../../_utils/statusHtmlToPlainText'
const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea'])
const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea', 'label'])
const isUserInputElement = node => INPUT_TAGS.has(node.localName)
const isToolbar = node => node.classList.contains('status-toolbar')
const isStatusArticle = node => node.classList.contains('status-article')

View file

@ -1,16 +1,35 @@
<div class={computedClass} aria-busy={refreshing} >
<ul class="options" aria-label="Poll results">
{#each options as option}
<li class="option">
<div class="option-text">
<strong>{option.share}%</strong> {option.title}
<div class={computedClass} aria-busy={loading} >
{#if voted || expired }
<ul class="options" aria-label="Poll results">
{#each options as option}
<li class="option">
<div class="option-text">
<strong>{option.share}%</strong> {option.title}
</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="Vote on poll" on:submit="onSubmit(event)" ref:form>
{#each options as option, i}
<div class="poll-form-option">
<input type="{multiple ? 'checkbox' : 'radio'}"
id="poll-choice-{uuid}-{i}"
name="poll-choice-{uuid}"
value="{i}"
on:change="onChange()"
>
<label for="poll-choice-{uuid}-{i}">
{option.title}
</label>
</div>
<svg aria-hidden="true">
<line x1="0" y1="0" x2="{option.share}%" y2="0" />
</svg>
</li>
{/each}
</ul>
{/each}
<button disabled={formDisabled} type="submit">Vote</button>
</form>
{/if}
<div class="poll-details">
<div class="poll-stat">
<SvgIcon className="poll-icon" href="#fa-bar-chart" />
@ -37,7 +56,7 @@
.poll {
grid-area: poll;
margin: 10px 10px 10px 5px;
padding: 10px 20px;
padding: 20px;
border: 1px solid var(--main-border);
border-radius: 2px;
transition: opacity 0.2s linear;
@ -47,7 +66,7 @@
padding: 20px;
}
.poll.poll-refreshing {
.poll.poll-loading {
opacity: 0.5;
pointer-events: none;
}
@ -152,9 +171,24 @@
min-width: 18px;
}
.poll-form-option {
padding-bottom: 10px;
}
.poll-form button {
}
.poll-form label {
text-overflow: ellipsis;
overflow: hidden;
word-break: break-word;
white-space: pre-wrap;
padding-left: 5px;
}
@media (max-width: 479px) {
.poll {
padding: 5px;
padding: 10px 5px;
}
.poll.status-in-own-thread {
padding: 10px;
@ -173,10 +207,32 @@
import { absoluteDateFormatter } from '../../_utils/formatters'
import { registerClickDelegate } from '../../_utils/delegate'
import { classname } from '../../_utils/classname'
import { getPoll } from '../../_actions/polls'
import { getPoll, voteOnPoll } from '../../_actions/polls'
const REFRESH_MIN_DELAY = 1000
async function doAsyncActionWithDelay (func) {
let start = Date.now()
let res = await func()
let 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) {
let 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)
@ -184,18 +240,20 @@
registerClickDelegate(this, refreshElementId, this.onRefreshClick)
},
data: () => ({
refreshedPoll: null,
refreshing: false
loading: false,
choices: []
}),
store: () => store,
computed: {
poll: ({ originalStatus, refreshedPoll }) => refreshedPoll || originalStatus.poll,
pollId: ({ poll }) => poll.id,
pollId: ({ originalStatus }) => originalStatus.poll.id,
poll: ({ originalStatus, $polls, pollId }) => $polls[pollId] || originalStatus.poll,
options: ({ poll }) => poll.options.map(({ title, votes_count: votesCount }) => ({
title,
share: poll.votes_count ? Math.round(votesCount / poll.votes_count * 100) : 0
})),
votesCount: ({ poll }) => poll.votes_count,
voted: ({ poll }) => poll.voted,
multiple: ({ poll }) => poll.multiple,
expired: ({ poll }) => poll.expired,
expiresAt: ({ poll }) => poll.expires_at,
expiresAtTS: ({ expiresAt }) => new Date(expiresAt).getTime(),
@ -206,12 +264,13 @@
expiryText: ({ expired }) => expired ? 'Ended' : 'Ends',
refreshElementId: ({ uuid }) => `poll-refresh-${uuid}`,
useNarrowSize: ({ $isMobileSize, expired }) => $isMobileSize && !expired,
computedClass: ({ isStatusInNotification, isStatusInOwnThread, refreshing }) => (
formDisabled: ({ choices }) => !choices.length,
computedClass: ({ isStatusInNotification, isStatusInOwnThread, loading }) => (
classname(
'poll',
isStatusInNotification && 'status-in-notification',
isStatusInOwnThread && 'status-in-own-thread',
refreshing && 'poll-refreshing'
loading && 'poll-loading'
)
)
},
@ -220,22 +279,42 @@
e.preventDefault()
e.stopPropagation()
let { pollId } = this.get()
this.set({ refreshing: true })
this.set({ loading: true })
try {
let start = Date.now()
let poll = await getPoll(pollId)
let timeElapsed = Date.now() - start
if (timeElapsed < REFRESH_MIN_DELAY) {
// If less than five seconds, then continue to show the refreshing animation
// so it's clear that something happened.
await new Promise(resolve => setTimeout(resolve, REFRESH_MIN_DELAY - timeElapsed))
}
let poll = await doAsyncActionWithDelay(() => getPoll(pollId))
if (poll) {
this.set({ refreshedPoll: poll })
let { polls } = this.store.get()
polls[pollId] = poll
this.store.set({ polls })
}
} finally {
this.set({ refreshing: false })
this.set({ loading: false })
}
},
async onSubmit (e) {
e.preventDefault()
e.stopPropagation()
let { pollId, options, formDisabled } = this.get()
if (formDisabled) {
return
}
let choices = getChoices(this.refs.form, options)
this.set({ loading: true })
try {
let poll = await doAsyncActionWithDelay(() => voteOnPoll(pollId, choices))
if (poll) {
let { polls } = this.store.get()
polls[pollId] = poll
this.store.set({ polls })
}
} finally {
this.set({ loading: false })
}
},
onChange () {
let { options } = this.get()
let choices = getChoices(this.refs.form, options)
this.set({ choices: choices })
}
},
components: {

View file

@ -39,6 +39,7 @@ const nonPersistedState = {
instanceLists: {},
online: !process.browser || navigator.onLine,
pinnedStatuses: {},
polls: {},
pushNotificationsSupport:
process.browser &&
('serviceWorker' in navigator &&