From 979bb4815f86949b2f288689e3a7ec976fcf22d9 Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Sun, 26 May 2019 09:37:11 -0700 Subject: [PATCH 01/14] =?UTF-8?q?chore:=20Update=20stringz=20to=20the=20la?= =?UTF-8?q?test=20version=20=F0=9F=9A=80=20(#1228)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(package): update stringz to version 2.0.0 * chore(package): update lockfile yarn.lock --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index af810fe..85ffc12 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "rollup-plugin-replace": "^2.2.0", "rollup-plugin-terser": "^5.0.0", "sapper": "nolanlawson/sapper#for-pinafore-14", - "stringz": "^1.0.0", + "stringz": "^2.0.0", "svelte": "^2.16.1", "svelte-extras": "^2.0.2", "svelte-loader": "^2.13.3", diff --git a/yarn.lock b/yarn.lock index dd0b82f..3868aff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6972,10 +6972,10 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -stringz@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/stringz/-/stringz-1.0.0.tgz#d2acba994e4ce3c725ee15c86fff4281280d2025" - integrity sha512-oaqFaIAmw1MJmdPNiBqocHHrC0VzJTL3CI1z5uXm3NQSE3AyDU152ZPTSJSOKk+9z1Cm3LZzgLFjCTb8SXZvag== +stringz@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/stringz/-/stringz-2.0.0.tgz#0a092bc64ed9b42eff2936d0401d2398393d54e9" + integrity sha512-pRWc5RGpedKEDvQ/ukYs8kS8tKj+cKu5ayOoyOvsavbpiLBcm1dGX6p1o5IagaN11cbfN8tKGpgQ4fHdEq5LBA== dependencies: unicode-astral-regex "^1.0.1" From 8f477eeccb706d57dd2ec418e8b81a0001c5f4a3 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 26 May 2019 09:54:35 -0700 Subject: [PATCH 02/14] feat: add poll notifications (#1229) more work on #1130 --- bin/svgs.js | 3 +- src/routes/_components/status/Status.html | 6 +-- .../_components/status/StatusHeader.html | 48 ++++++++++++------- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/bin/svgs.js b/bin/svgs.js index 4f322b6..af69d1b 100644 --- a/bin/svgs.js +++ b/bin/svgs.js @@ -46,5 +46,6 @@ module.exports = [ { id: 'fa-search-plus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/search-plus.svg' }, { id: 'fa-share-square-o', src: 'src/thirdparty/font-awesome-svg-png/white/svg/share-square-o.svg' }, { id: 'fa-flag', src: 'src/thirdparty/font-awesome-svg-png/white/svg/flag.svg' }, - { id: 'fa-suitcase', src: 'src/thirdparty/font-awesome-svg-png/white/svg/suitcase.svg' } + { id: 'fa-suitcase', src: 'src/thirdparty/font-awesome-svg-png/white/svg/suitcase.svg' }, + { id: 'fa-bar-chart', src: 'src/thirdparty/font-awesome-svg-png/white/svg/bar-chart.svg' } ] diff --git a/src/routes/_components/status/Status.html b/src/routes/_components/status/Status.html index c9c4f75..10e53f4 100644 --- a/src/routes/_components/status/Status.html +++ b/src/routes/_components/status/Status.html @@ -268,8 +268,8 @@ originalStatus.card && originalStatus.card.title ), - showPoll: ({ originalStatus, isStatusInNotification }) => ( - !isStatusInNotification && originalStatus.poll + showPoll: ({ originalStatus }) => ( + originalStatus.poll ), showMedia: ({ originalStatus, isStatusInNotification }) => ( !isStatusInNotification && @@ -292,7 +292,7 @@ reblog, notification, visibility, $omitEmojiInDisplayNames, $disableLongAriaLabels) ), showHeader: ({ notification, status, timelineType }) => ( - (notification && (notification.type === 'reblog' || notification.type === 'favourite')) || + (notification && ['reblog', 'favourite', 'poll'].includes(notification.type)) || status.reblog || timelineType === 'pinned' ), diff --git a/src/routes/_components/status/StatusHeader.html b/src/routes/_components/status/StatusHeader.html index fabea6e..2708556 100644 --- a/src/routes/_components/status/StatusHeader.html +++ b/src/routes/_components/status/StatusHeader.html @@ -1,5 +1,5 @@ -
- diff --git a/src/routes/_intl/formatTimeagoDate.js b/src/routes/_intl/formatTimeagoDate.js index 0756e4b..d0ef1df 100644 --- a/src/routes/_intl/formatTimeagoDate.js +++ b/src/routes/_intl/formatTimeagoDate.js @@ -1,6 +1,7 @@ import { format } from '../_thirdparty/timeago/timeago' import { mark, stop } from '../_utils/marks' +// Format a date in the past export function formatTimeagoDate (date, now) { mark('formatTimeagoDate') // use Math.max() to avoid things like "in 10 seconds" when the timestamps are slightly off @@ -8,3 +9,12 @@ export function formatTimeagoDate (date, now) { stop('formatTimeagoDate') return res } + +// Format a date in the future +export function formatTimeagoFutureDate (date, now) { + mark('formatTimeagoFutureDate') + // use Math.min() for same reason as above + let res = format(date, Math.min(now, date)) + stop('formatTimeagoFutureDate') + return res +} From 2c1de665925dbc5d61edd7b77492ed871d53c7af Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 26 May 2019 20:45:42 -0700 Subject: [PATCH 06/14] 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 612b877..b419581 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 5610666..f3cd5ae 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 d892912..853ff8f 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 67f7dea..b1a8acc 100644 --- a/src/routes/_components/status/StatusPoll.html +++ b/src/routes/_components/status/StatusPoll.html @@ -1,16 +1,35 @@ -
-
    - {#each options as option} -
  • -
    - {option.share}% {option.title} +
    + {#if voted || expired } +
      + {#each options as option} +
    • +
      + {option.share}% {option.title} +
      + +
    • + {/each} +
    + {:else} +
    + {#each options as option, i} +
    + +
    - -
  • - {/each} -
+ {/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 60e28c3..eaba1c4 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 && From 0878275ab91c0e14a15d8f3a45ce7e7c59233f32 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Mon, 27 May 2019 00:24:47 -0700 Subject: [PATCH 07/14] feat: ability to create polls (#1235) * feat: ability to create polls fixes #1130 * fix adds and deletes * fix tests * fix tests again --- bin/svgs.js | 4 +- src/routes/_actions/compose.js | 4 +- src/routes/_actions/composePoll.js | 18 ++ src/routes/_api/statuses.js | 5 +- src/routes/_components/Select.html | 76 +++++++++ .../_components/compose/ComposeBox.html | 39 ++++- .../_components/compose/ComposePoll.html | 157 ++++++++++++++++++ .../_components/compose/ComposeToolbar.html | 16 ++ src/routes/_static/polls.js | 32 ++++ tests/utils.js | 12 +- 10 files changed, 348 insertions(+), 15 deletions(-) create mode 100644 src/routes/_actions/composePoll.js create mode 100644 src/routes/_components/Select.html create mode 100644 src/routes/_components/compose/ComposePoll.html create mode 100644 src/routes/_static/polls.js diff --git a/bin/svgs.js b/bin/svgs.js index 4570348..04a78d7 100644 --- a/bin/svgs.js +++ b/bin/svgs.js @@ -42,6 +42,7 @@ module.exports = [ { id: 'fa-circle-o', src: 'src/thirdparty/font-awesome-svg-png/white/svg/circle-o.svg' }, { id: 'fa-angle-left', src: 'src/thirdparty/font-awesome-svg-png/white/svg/angle-left.svg' }, { id: 'fa-angle-right', src: 'src/thirdparty/font-awesome-svg-png/white/svg/angle-right.svg' }, + { id: 'fa-angle-down', src: 'src/thirdparty/font-awesome-svg-png/white/svg/angle-down.svg' }, { id: 'fa-search-minus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/search-minus.svg' }, { id: 'fa-search-plus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/search-plus.svg' }, { id: 'fa-share-square-o', src: 'src/thirdparty/font-awesome-svg-png/white/svg/share-square-o.svg' }, @@ -49,5 +50,6 @@ module.exports = [ { id: 'fa-suitcase', src: 'src/thirdparty/font-awesome-svg-png/white/svg/suitcase.svg' }, { id: 'fa-bar-chart', src: 'src/thirdparty/font-awesome-svg-png/white/svg/bar-chart.svg' }, { id: 'fa-clock', src: 'src/thirdparty/font-awesome-svg-png/white/svg/clock-o.svg' }, - { id: 'fa-refresh', src: 'src/thirdparty/font-awesome-svg-png/white/svg/refresh.svg' } + { id: 'fa-refresh', src: 'src/thirdparty/font-awesome-svg-png/white/svg/refresh.svg' }, + { id: 'fa-plus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/plus.svg' } ] diff --git a/src/routes/_actions/compose.js b/src/routes/_actions/compose.js index 6074e5c..a97b874 100644 --- a/src/routes/_actions/compose.js +++ b/src/routes/_actions/compose.js @@ -22,7 +22,7 @@ export async function insertHandleForReply (statusId) { export async function postStatus (realm, text, inReplyToId, mediaIds, sensitive, spoilerText, visibility, - mediaDescriptions, inReplyToUuid) { + mediaDescriptions, inReplyToUuid, poll) { let { currentInstance, accessToken, online } = store.get() if (!online) { @@ -41,7 +41,7 @@ export async function postStatus (realm, text, inReplyToId, mediaIds, return description && putMediaDescription(currentInstance, accessToken, mediaIds[i], description) })) let status = await postStatusToServer(currentInstance, accessToken, text, - inReplyToId, mediaIds, sensitive, spoilerText, visibility) + inReplyToId, mediaIds, sensitive, spoilerText, visibility, poll) addStatusOrNotification(currentInstance, 'home', status) store.clearComposeData(realm) emit('postedStatus', realm, inReplyToUuid) diff --git a/src/routes/_actions/composePoll.js b/src/routes/_actions/composePoll.js new file mode 100644 index 0000000..020fe5a --- /dev/null +++ b/src/routes/_actions/composePoll.js @@ -0,0 +1,18 @@ +import { store } from '../_store/store' + +export function enablePoll (realm) { + store.setComposeData(realm, { + poll: { + options: [ + '', + '' + ] + } + }) +} + +export function disablePoll (realm) { + store.setComposeData(realm, { + poll: null + }) +} diff --git a/src/routes/_api/statuses.js b/src/routes/_api/statuses.js index 506ca13..13a5649 100644 --- a/src/routes/_api/statuses.js +++ b/src/routes/_api/statuses.js @@ -2,7 +2,7 @@ import { auth, basename } from './utils' import { DEFAULT_TIMEOUT, get, post, WRITE_TIMEOUT } from '../_utils/ajax' export async function postStatus (instanceName, accessToken, text, inReplyToId, mediaIds, - sensitive, spoilerText, visibility) { + sensitive, spoilerText, visibility, poll) { let url = `${basename(instanceName)}/api/v1/statuses` let body = { @@ -11,7 +11,8 @@ export async function postStatus (instanceName, accessToken, text, inReplyToId, media_ids: mediaIds, sensitive: sensitive, spoiler_text: spoilerText, - visibility: visibility + visibility: visibility, + poll: poll } for (let key of Object.keys(body)) { diff --git a/src/routes/_components/Select.html b/src/routes/_components/Select.html new file mode 100644 index 0000000..14d5a87 --- /dev/null +++ b/src/routes/_components/Select.html @@ -0,0 +1,76 @@ +
+ +
+ +
+
+ + diff --git a/src/routes/_components/compose/ComposeBox.html b/src/routes/_components/compose/ComposeBox.html index 167e0bf..b1a3c05 100644 --- a/src/routes/_components/compose/ComposeBox.html +++ b/src/routes/_components/compose/ComposeBox.html @@ -13,7 +13,13 @@ - + {#if poll && poll.options && poll.options.length} +
+ +
+ {/if} +
@@ -38,6 +44,7 @@ "avatar input input input" "avatar gauge gauge gauge" "avatar autosuggest autosuggest autosuggest" + "avatar poll poll poll" "avatar toolbar toolbar length" "avatar media media media"; grid-template-columns: min-content minmax(0, max-content) 1fr 1fr; @@ -62,6 +69,10 @@ grid-area: cw; } + .compose-poll-wrapper { + grid-area: poll; + } + @media (max-width: 767px) { .compose-box { padding: 10px 10px 0 10px; @@ -83,12 +94,14 @@ import ComposeContentWarning from './ComposeContentWarning.html' import ComposeFileDrop from './ComposeFileDrop.html' import ComposeAutosuggest from './ComposeAutosuggest.html' + import ComposePoll from './ComposePoll.html' import { measureText } from '../../_utils/measureText' import { POST_PRIVACY_OPTIONS } from '../../_static/statuses' import { store } from '../../_store/store' import { slide } from 'svelte-transitions' import { postStatus, insertHandleForReply, setReplySpoiler, setReplyVisibility } from '../../_actions/compose' import { classname } from '../../_utils/classname' + import { POLL_EXPIRY_DEFAULT } from '../../_static/polls' export default { oncreate () { @@ -118,7 +131,8 @@ ComposeMedia, ComposeContentWarning, ComposeFileDrop, - ComposeAutosuggest + ComposeAutosuggest, + ComposePoll }, data: () => ({ size: void 0, @@ -144,6 +158,7 @@ composeData: ({ $currentComposeData, realm }) => $currentComposeData[realm] || {}, text: ({ composeData }) => composeData.text || '', media: ({ composeData }) => composeData.media || [], + poll: ({ composeData }) => composeData.poll, inReplyToId: ({ composeData }) => composeData.inReplyToId, postPrivacy: ({ postPrivacyKey }) => POST_PRIVACY_OPTIONS.find(_ => _.key === postPrivacyKey), defaultPostPrivacyKey: ({ $currentVerifyCredentials }) => ( @@ -172,7 +187,8 @@ realm, overLimit, inReplyToUuid, // typical replies, using Pinafore-specific uuid - inReplyToId // delete-and-redraft replies, using standard id + inReplyToId, // delete-and-redraft replies, using standard id + poll } = this.get() let sensitive = media.length && !!contentWarning let mediaIds = media.map(_ => _.data.id) @@ -183,10 +199,25 @@ return // do nothing if invalid } + let hasPoll = poll && poll.options && poll.options.length + if (hasPoll) { + // validate poll + if (poll.options.length < 2 || !poll.options.every(Boolean)) { + return + } + } + + // convert internal poll format to the format Mastodon's REST API uses + let pollToPost = hasPoll && { + expires_in: (poll.expiry || POLL_EXPIRY_DEFAULT).toString(), + multiple: !!poll.multiple, + options: poll.options + } + /* no await */ postStatus(realm, text, inReplyTo, mediaIds, sensitive, contentWarning, postPrivacyKey, - mediaDescriptions, inReplyToUuid) + mediaDescriptions, inReplyToUuid, pollToPost) } } } diff --git a/src/routes/_components/compose/ComposePoll.html b/src/routes/_components/compose/ComposePoll.html new file mode 100644 index 0000000..75f6684 --- /dev/null +++ b/src/routes/_components/compose/ComposePoll.html @@ -0,0 +1,157 @@ +
+ {#each poll.options as option, i} + + + {/each} +
+ + + - -
- {/each} +
    + {#each options as option, i} +
  • + + +
  • + {/each} +
{/if} @@ -71,13 +73,18 @@ pointer-events: none; } - ul.options { + ul { list-style: none; margin: 0; padding: 0; } - li.option { + li { + margin: 0; + padding: 0; + } + + .option { margin: 0 0 10px 0; padding: 0; display: flex; @@ -86,10 +93,6 @@ stroke-width: 10px; } - li.option:last-child { - margin: 0; - } - .option-text { word-wrap: break-word; white-space: pre-wrap; diff --git a/tests/serverActions.js b/tests/serverActions.js index 34bc7d3..c55e24d 100644 --- a/tests/serverActions.js +++ b/tests/serverActions.js @@ -9,6 +9,8 @@ import { followAccount, unfollowAccount } from '../src/routes/_api/follow' import { updateCredentials } from '../src/routes/_api/updateCredentials' import { reblogStatus } from '../src/routes/_api/reblog' import { submitMedia } from './submitMedia' +import { voteOnPoll } from '../src/routes/_api/polls' +import { POLL_EXPIRY_DEFAULT } from '../src/routes/_static/polls' global.fetch = fetch global.File = FileApi.File @@ -68,3 +70,15 @@ export async function unfollowAs (username, userToFollow) { export async function updateUserDisplayNameAs (username, displayName) { return updateCredentials(instanceName, users[username].accessToken, { display_name: displayName }) } + +export async function createPollAs (username, content, options, multiple) { + return postStatus(instanceName, users[username].accessToken, content, null, null, false, null, 'public', { + options, + multiple, + expires_in: POLL_EXPIRY_DEFAULT + }) +} + +export async function voteOnPollAs (username, pollId, choices) { + return voteOnPoll(instanceName, users[username].accessToken, pollId, choices.map(_ => _.toString())) +} diff --git a/tests/spec/126-polls.js b/tests/spec/126-polls.js new file mode 100644 index 0000000..5df6267 --- /dev/null +++ b/tests/spec/126-polls.js @@ -0,0 +1,94 @@ +import { + getNthStatusContent, + getNthStatusPollOption, + getNthStatusPollVoteButton, + getNthStatusPollForm, + getNthStatusPollResult, + sleep, + getNthStatusPollRefreshButton, + getNthStatusPollVoteCount, + getNthStatusRelativeDate, getUrl, goBack +} from '../utils' +import { loginAsFoobar } from '../roles' +import { createPollAs, voteOnPollAs } from '../serverActions' + +fixture`126-polls.js` + .page`http://localhost:4002` + +test('Can vote on polls', async t => { + await loginAsFoobar(t) + await createPollAs('admin', 'vote on my cool poll', ['yes', 'no'], false) + await t + .expect(getNthStatusContent(1).innerText).contains('vote on my cool poll') + .expect(getNthStatusPollVoteCount(1).innerText).eql('0 votes') + .click(getNthStatusPollOption(1, 2)) + .click(getNthStatusPollVoteButton(1)) + .expect(getNthStatusPollForm(1).exists).notOk({ timeout: 20000 }) + .expect(getNthStatusPollResult(1, 1).innerText).eql('0% yes') + .expect(getNthStatusPollResult(1, 2).innerText).eql('100% no') + .expect(getNthStatusPollVoteCount(1).innerText).eql('1 vote') +}) + +test('Can vote on multiple-choice polls', async t => { + await loginAsFoobar(t) + await createPollAs('admin', 'vote on my other poll', ['yes', 'no', 'maybe'], true) + await t + .expect(getNthStatusContent(1).innerText).contains('vote on my other poll') + .click(getNthStatusPollOption(1, 1)) + .click(getNthStatusPollOption(1, 3)) + .click(getNthStatusPollVoteButton(1)) + .expect(getNthStatusPollForm(1).exists).notOk({ timeout: 20000 }) + .expect(getNthStatusPollResult(1, 1).innerText).eql('50% yes') + .expect(getNthStatusPollResult(1, 2).innerText).eql('0% no') + .expect(getNthStatusPollResult(1, 3).innerText).eql('50% maybe') + .expect(getNthStatusPollVoteCount(1).innerText).eql('2 votes') +}) + +test('Can update poll results', async t => { + const { poll } = await createPollAs('admin', 'vote on this poll', ['yes', 'no', 'maybe'], false) + const { id: pollId } = poll + await voteOnPollAs('baz', pollId, [1]) + await voteOnPollAs('ExternalLinks', pollId, [1]) + await voteOnPollAs('foobar', pollId, [2]) + await sleep(1000) + await loginAsFoobar(t) + await t + .expect(getNthStatusContent(1).innerText).contains('vote on this poll') + .expect(getNthStatusPollForm(1).exists).notOk() + .expect(getNthStatusPollResult(1, 1).innerText).eql('0% yes') + .expect(getNthStatusPollResult(1, 2).innerText).eql('67% no') + .expect(getNthStatusPollResult(1, 3).innerText).eql('33% maybe') + .expect(getNthStatusPollVoteCount(1).innerText).eql('3 votes') + await sleep(1000) + await voteOnPollAs('quux', pollId, [0]) + await sleep(1000) + await t + .click(getNthStatusPollRefreshButton(1)) + .expect(getNthStatusPollResult(1, 1).innerText).eql('25% yes', { timeout: 20000 }) + .expect(getNthStatusPollResult(1, 2).innerText).eql('50% no') + .expect(getNthStatusPollResult(1, 3).innerText).eql('25% maybe') + .expect(getNthStatusPollVoteCount(1).innerText).eql('4 votes') +}) + +test('Poll results refresh everywhere', async t => { + await loginAsFoobar(t) + await createPollAs('admin', 'another poll', ['yes', 'no'], false) + await t + .expect(getNthStatusContent(1).innerText).contains('another poll') + .click(getNthStatusRelativeDate(1)) + .expect(getUrl()).contains('/statuses') + .expect(getNthStatusContent(1).innerText).contains('another poll') + .click(getNthStatusPollOption(1, 1)) + .click(getNthStatusPollVoteButton(1)) + .expect(getNthStatusPollForm(1).exists).notOk({ timeout: 20000 }) + .expect(getNthStatusPollResult(1, 1).innerText).eql('100% yes') + .expect(getNthStatusPollResult(1, 2).innerText).eql('0% no') + .expect(getNthStatusPollVoteCount(1).innerText).eql('1 vote') + await goBack() + await t + .expect(getUrl()).eql('http://localhost:4002/') + .expect(getNthStatusPollForm(1).exists).notOk({ timeout: 20000 }) + .expect(getNthStatusPollResult(1, 1).innerText).eql('100% yes') + .expect(getNthStatusPollResult(1, 2).innerText).eql('0% no') + .expect(getNthStatusPollVoteCount(1).innerText).eql('1 vote') +}) diff --git a/tests/spec/127-compose-polls.js b/tests/spec/127-compose-polls.js new file mode 100644 index 0000000..785009d --- /dev/null +++ b/tests/spec/127-compose-polls.js @@ -0,0 +1,67 @@ +import { + getNthStatusContent, + getNthStatusPollForm, + getNthStatusPollResult, + getNthStatusPollVoteCount, + pollButton, + getComposePollNthInput, + composePoll, + composePollMultipleChoice, + composePollExpiry, composePollAddButton, getComposePollRemoveNthButton, postStatusButton, composeInput +} from '../utils' +import { loginAsFoobar } from '../roles' +import { POLL_EXPIRY_DEFAULT } from '../../src/routes/_static/polls' + +fixture`127-compose-polls.js` + .page`http://localhost:4002` + +test('Can add and remove poll', async t => { + await loginAsFoobar(t) + await t + .expect(composePoll.exists).notOk() + .expect(pollButton.getAttribute('aria-label')).eql('Add poll') + .click(pollButton) + .expect(composePoll.exists).ok() + .expect(getComposePollNthInput(1).value).eql('') + .expect(getComposePollNthInput(2).value).eql('') + .expect(getComposePollNthInput(3).exists).notOk() + .expect(getComposePollNthInput(4).exists).notOk() + .expect(composePollMultipleChoice.checked).notOk() + .expect(composePollExpiry.value).eql(POLL_EXPIRY_DEFAULT.toString()) + .expect(pollButton.getAttribute('aria-label')).eql('Remove poll') + .click(pollButton) + .expect(composePoll.exists).notOk() +}) + +test('Can add and remove poll options', async t => { + await loginAsFoobar(t) + await t + .expect(composePoll.exists).notOk() + .expect(pollButton.getAttribute('aria-label')).eql('Add poll') + .click(pollButton) + .expect(composePoll.exists).ok() + .typeText(getComposePollNthInput(1), 'first', { paste: true }) + .typeText(getComposePollNthInput(2), 'second', { paste: true }) + .click(composePollAddButton) + .typeText(getComposePollNthInput(3), 'third', { paste: true }) + .expect(getComposePollNthInput(1).value).eql('first') + .expect(getComposePollNthInput(2).value).eql('second') + .expect(getComposePollNthInput(3).value).eql('third') + .expect(getComposePollNthInput(4).exists).notOk() + .click(getComposePollRemoveNthButton(2)) + .expect(getComposePollNthInput(1).value).eql('first') + .expect(getComposePollNthInput(2).value).eql('third') + .expect(getComposePollNthInput(3).exists).notOk() + .expect(getComposePollNthInput(4).exists).notOk() + .click(composePollAddButton) + .typeText(getComposePollNthInput(3), 'fourth', { paste: true }) + .typeText(composeInput, 'Vote on my poll!!!', { paste: true }) + .click(postStatusButton) + .expect(getNthStatusContent(1).innerText).contains('Vote on my poll!!!') + .expect(getNthStatusPollForm(1).exists).notOk() + .expect(getNthStatusPollResult(1, 1).innerText).eql('0% first') + .expect(getNthStatusPollResult(1, 2).innerText).eql('0% third') + .expect(getNthStatusPollResult(1, 3).innerText).eql('0% fourth') + .expect(getNthStatusPollResult(1, 4).exists).notOk() + .expect(getNthStatusPollVoteCount(1).innerText).eql('0 votes') +}) diff --git a/tests/utils.js b/tests/utils.js index 43e7878..8dc14cc 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -22,6 +22,7 @@ export const composeButton = $('.compose-box-button') export const composeLengthIndicator = $('.compose-box-length') export const emojiButton = $('.compose-box-toolbar button:first-child') export const mediaButton = $('.compose-box-toolbar button:nth-child(2)') +export const pollButton = $('.compose-box-toolbar button:nth-child(3)') export const postPrivacyButton = $('.compose-box-toolbar button:nth-child(4)') export const contentWarningButton = $('.compose-box-toolbar button:nth-child(5)') export const emailInput = $('input#user_email') @@ -58,6 +59,11 @@ export const composeModalContentWarningInput = $('.modal-dialog .content-warning export const composeModalEmojiButton = $('.modal-dialog .compose-box-toolbar button:nth-child(1)') export const composeModalPostPrivacyButton = $('.modal-dialog .compose-box-toolbar button:nth-child(4)') +export const composePoll = $('.compose-poll') +export const composePollMultipleChoice = $('.compose-poll input[type="checkbox"]') +export const composePollExpiry = $('.compose-poll select') +export const composePollAddButton = $('.compose-poll button:last-of-type') + export const postPrivacyDialogButtonUnlisted = $('[aria-label="Post privacy dialog"] li:nth-child(2) button') export const accountProfileFilterStatuses = $('.account-profile-filters li:nth-child(1)') @@ -220,6 +226,38 @@ export function getNthPostPrivacyButton (n) { return $(`${getNthStatusSelector(n)} .compose-box-toolbar button:nth-child(4)`) } +export function getNthStatusPollOption (n, i) { + return $(`${getNthStatusSelector(n)} .poll li:nth-child(${i}) input`) +} + +export function getNthStatusPollVoteButton (n) { + return $(`${getNthStatusSelector(n)} .poll button`) +} + +export function getNthStatusPollForm (n) { + return $(`${getNthStatusSelector(n)} .poll form`) +} + +export function getNthStatusPollResult (n, i) { + return $(`${getNthStatusSelector(n)} .poll li:nth-child(${i})`) +} + +export function getNthStatusPollRefreshButton (n) { + return $(`${getNthStatusSelector(n)} button.poll-stat`) +} + +export function getNthStatusPollVoteCount (n) { + return $(`${getNthStatusSelector(n)} .poll .poll-stat:nth-child(1) .poll-stat-text`) +} + +export function getComposePollNthInput (n) { + return $(`.compose-poll input[type="text"]:nth-of-type(${n})`) +} + +export function getComposePollRemoveNthButton (n) { + return $(`.compose-poll button:nth-of-type(${n})`) +} + export function getNthAutosuggestionResult (n) { return $(`.compose-autosuggest-list-item:nth-child(${n})`) } From 482ee3d3bbdf5491867d01dca453d3ae2e94aa96 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Mon, 27 May 2019 12:31:42 -0700 Subject: [PATCH 11/14] fix: improve media upload a11y (#1240) use ul/li instead of divs here --- src/routes/_components/compose/ComposeMedia.html | 10 +++++++--- src/routes/_components/compose/ComposeMediaItem.html | 6 ++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/routes/_components/compose/ComposeMedia.html b/src/routes/_components/compose/ComposeMedia.html index 421c2e1..df163f2 100644 --- a/src/routes/_components/compose/ComposeMedia.html +++ b/src/routes/_components/compose/ComposeMedia.html @@ -1,18 +1,22 @@ {#if media.length} -
+
    {#each media as mediaItem, index} {/each} -
+ {/if}