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 { store } from '../_store/store'
import { toast } from '../_components/toast/toast' import { toast } from '../_components/toast/toast'
@ -9,6 +9,17 @@ export async function getPoll (pollId) {
return poll return poll
} catch (e) { } catch (e) {
console.error(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' import { auth, basename } from './utils'
export async function getPoll (instanceName, accessToken, pollId) { export async function getPoll (instanceName, accessToken, pollId) {
let url = `${basename(instanceName)}/api/v1/polls/${pollId}` let url = `${basename(instanceName)}/api/v1/polls/${pollId}`
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT }) 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 { createStatusOrNotificationUuid } from '../../_utils/createStatusOrNotificationUuid'
import { statusHtmlToPlainText } from '../../_utils/statusHtmlToPlainText' 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 isUserInputElement = node => INPUT_TAGS.has(node.localName)
const isToolbar = node => node.classList.contains('status-toolbar') const isToolbar = node => node.classList.contains('status-toolbar')
const isStatusArticle = node => node.classList.contains('status-article') const isStatusArticle = node => node.classList.contains('status-article')

View file

@ -1,16 +1,35 @@
<div class={computedClass} aria-busy={refreshing} > <div class={computedClass} aria-busy={loading} >
<ul class="options" aria-label="Poll results"> {#if voted || expired }
{#each options as option} <ul class="options" aria-label="Poll results">
<li class="option"> {#each options as option}
<div class="option-text"> <li class="option">
<strong>{option.share}%</strong> {option.title} <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> </div>
<svg aria-hidden="true"> {/each}
<line x1="0" y1="0" x2="{option.share}%" y2="0" /> <button disabled={formDisabled} type="submit">Vote</button>
</svg> </form>
</li> {/if}
{/each}
</ul>
<div class="poll-details"> <div class="poll-details">
<div class="poll-stat"> <div class="poll-stat">
<SvgIcon className="poll-icon" href="#fa-bar-chart" /> <SvgIcon className="poll-icon" href="#fa-bar-chart" />
@ -37,7 +56,7 @@
.poll { .poll {
grid-area: poll; grid-area: poll;
margin: 10px 10px 10px 5px; margin: 10px 10px 10px 5px;
padding: 10px 20px; padding: 20px;
border: 1px solid var(--main-border); border: 1px solid var(--main-border);
border-radius: 2px; border-radius: 2px;
transition: opacity 0.2s linear; transition: opacity 0.2s linear;
@ -47,7 +66,7 @@
padding: 20px; padding: 20px;
} }
.poll.poll-refreshing { .poll.poll-loading {
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
} }
@ -152,9 +171,24 @@
min-width: 18px; 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) { @media (max-width: 479px) {
.poll { .poll {
padding: 5px; padding: 10px 5px;
} }
.poll.status-in-own-thread { .poll.status-in-own-thread {
padding: 10px; padding: 10px;
@ -173,10 +207,32 @@
import { absoluteDateFormatter } from '../../_utils/formatters' import { absoluteDateFormatter } from '../../_utils/formatters'
import { registerClickDelegate } from '../../_utils/delegate' import { registerClickDelegate } from '../../_utils/delegate'
import { classname } from '../../_utils/classname' import { classname } from '../../_utils/classname'
import { getPoll } from '../../_actions/polls' import { getPoll, voteOnPoll } from '../../_actions/polls'
const REFRESH_MIN_DELAY = 1000 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 { export default {
oncreate () { oncreate () {
this.onRefreshClick = this.onRefreshClick.bind(this) this.onRefreshClick = this.onRefreshClick.bind(this)
@ -184,18 +240,20 @@
registerClickDelegate(this, refreshElementId, this.onRefreshClick) registerClickDelegate(this, refreshElementId, this.onRefreshClick)
}, },
data: () => ({ data: () => ({
refreshedPoll: null, loading: false,
refreshing: false choices: []
}), }),
store: () => store, store: () => store,
computed: { computed: {
poll: ({ originalStatus, refreshedPoll }) => refreshedPoll || originalStatus.poll, pollId: ({ originalStatus }) => originalStatus.poll.id,
pollId: ({ poll }) => poll.id, poll: ({ originalStatus, $polls, pollId }) => $polls[pollId] || originalStatus.poll,
options: ({ poll }) => poll.options.map(({ title, votes_count: votesCount }) => ({ options: ({ poll }) => poll.options.map(({ title, votes_count: votesCount }) => ({
title, title,
share: poll.votes_count ? Math.round(votesCount / poll.votes_count * 100) : 0 share: poll.votes_count ? Math.round(votesCount / poll.votes_count * 100) : 0
})), })),
votesCount: ({ poll }) => poll.votes_count, votesCount: ({ poll }) => poll.votes_count,
voted: ({ poll }) => poll.voted,
multiple: ({ poll }) => poll.multiple,
expired: ({ poll }) => poll.expired, expired: ({ poll }) => poll.expired,
expiresAt: ({ poll }) => poll.expires_at, expiresAt: ({ poll }) => poll.expires_at,
expiresAtTS: ({ expiresAt }) => new Date(expiresAt).getTime(), expiresAtTS: ({ expiresAt }) => new Date(expiresAt).getTime(),
@ -206,12 +264,13 @@
expiryText: ({ expired }) => expired ? 'Ended' : 'Ends', expiryText: ({ expired }) => expired ? 'Ended' : 'Ends',
refreshElementId: ({ uuid }) => `poll-refresh-${uuid}`, refreshElementId: ({ uuid }) => `poll-refresh-${uuid}`,
useNarrowSize: ({ $isMobileSize, expired }) => $isMobileSize && !expired, useNarrowSize: ({ $isMobileSize, expired }) => $isMobileSize && !expired,
computedClass: ({ isStatusInNotification, isStatusInOwnThread, refreshing }) => ( formDisabled: ({ choices }) => !choices.length,
computedClass: ({ isStatusInNotification, isStatusInOwnThread, loading }) => (
classname( classname(
'poll', 'poll',
isStatusInNotification && 'status-in-notification', isStatusInNotification && 'status-in-notification',
isStatusInOwnThread && 'status-in-own-thread', isStatusInOwnThread && 'status-in-own-thread',
refreshing && 'poll-refreshing' loading && 'poll-loading'
) )
) )
}, },
@ -220,22 +279,42 @@
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
let { pollId } = this.get() let { pollId } = this.get()
this.set({ refreshing: true }) this.set({ loading: true })
try { try {
let start = Date.now() let poll = await doAsyncActionWithDelay(() => getPoll(pollId))
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))
}
if (poll) { if (poll) {
this.set({ refreshedPoll: poll }) let { polls } = this.store.get()
polls[pollId] = poll
this.store.set({ polls })
} }
} finally { } 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: { components: {

View file

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