From 2c1de665925dbc5d61edd7b77492ed871d53c7af Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 26 May 2019 20:45:42 -0700 Subject: [PATCH] feat: vote on polls (#1234) more work on #1130 --- src/routes/_actions/polls.js | 15 +- src/routes/_api/polls.js | 7 +- src/routes/_components/status/Status.html | 2 +- src/routes/_components/status/StatusPoll.html | 145 ++++++++++++++---- src/routes/_store/store.js | 1 + 5 files changed, 133 insertions(+), 37 deletions(-) diff --git a/src/routes/_actions/polls.js b/src/routes/_actions/polls.js index 612b8778..b4195815 100644 --- a/src/routes/_actions/polls.js +++ b/src/routes/_actions/polls.js @@ -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 || '')) } } diff --git a/src/routes/_api/polls.js b/src/routes/_api/polls.js index 5610666d..f3cd5aed 100644 --- a/src/routes/_api/polls.js +++ b/src/routes/_api/polls.js @@ -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 }) +} diff --git a/src/routes/_components/status/Status.html b/src/routes/_components/status/Status.html index d8929120..853ff8f6 100644 --- a/src/routes/_components/status/Status.html +++ b/src/routes/_components/status/Status.html @@ -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') diff --git a/src/routes/_components/status/StatusPoll.html b/src/routes/_components/status/StatusPoll.html index 67f7dea6..b1a8acca 100644 --- a/src/routes/_components/status/StatusPoll.html +++ b/src/routes/_components/status/StatusPoll.html @@ -1,16 +1,35 @@ -
- + {/each} + + + {/if}
@@ -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: { diff --git a/src/routes/_store/store.js b/src/routes/_store/store.js index 60e28c3d..eaba1c4b 100644 --- a/src/routes/_store/store.js +++ b/src/routes/_store/store.js @@ -39,6 +39,7 @@ const nonPersistedState = { instanceLists: {}, online: !process.browser || navigator.onLine, pinnedStatuses: {}, + polls: {}, pushNotificationsSupport: process.browser && ('serviceWorker' in navigator &&