feat: ability to create polls (#1235)

* feat: ability to create polls

fixes #1130

* fix adds and deletes

* fix tests

* fix tests again
This commit is contained in:
Nolan Lawson 2019-05-27 00:24:47 -07:00 committed by GitHub
parent 2c1de66592
commit 0878275ab9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 348 additions and 15 deletions

View File

@ -42,6 +42,7 @@ module.exports = [
{ id: 'fa-circle-o', src: 'src/thirdparty/font-awesome-svg-png/white/svg/circle-o.svg' }, { 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-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-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-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-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-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-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-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-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' }
] ]

View File

@ -22,7 +22,7 @@ export async function insertHandleForReply (statusId) {
export async function postStatus (realm, text, inReplyToId, mediaIds, export async function postStatus (realm, text, inReplyToId, mediaIds,
sensitive, spoilerText, visibility, sensitive, spoilerText, visibility,
mediaDescriptions, inReplyToUuid) { mediaDescriptions, inReplyToUuid, poll) {
let { currentInstance, accessToken, online } = store.get() let { currentInstance, accessToken, online } = store.get()
if (!online) { if (!online) {
@ -41,7 +41,7 @@ export async function postStatus (realm, text, inReplyToId, mediaIds,
return description && putMediaDescription(currentInstance, accessToken, mediaIds[i], description) return description && putMediaDescription(currentInstance, accessToken, mediaIds[i], description)
})) }))
let status = await postStatusToServer(currentInstance, accessToken, text, let status = await postStatusToServer(currentInstance, accessToken, text,
inReplyToId, mediaIds, sensitive, spoilerText, visibility) inReplyToId, mediaIds, sensitive, spoilerText, visibility, poll)
addStatusOrNotification(currentInstance, 'home', status) addStatusOrNotification(currentInstance, 'home', status)
store.clearComposeData(realm) store.clearComposeData(realm)
emit('postedStatus', realm, inReplyToUuid) emit('postedStatus', realm, inReplyToUuid)

View File

@ -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
})
}

View File

@ -2,7 +2,7 @@ import { auth, basename } from './utils'
import { DEFAULT_TIMEOUT, get, post, WRITE_TIMEOUT } from '../_utils/ajax' import { DEFAULT_TIMEOUT, get, post, WRITE_TIMEOUT } from '../_utils/ajax'
export async function postStatus (instanceName, accessToken, text, inReplyToId, mediaIds, export async function postStatus (instanceName, accessToken, text, inReplyToId, mediaIds,
sensitive, spoilerText, visibility) { sensitive, spoilerText, visibility, poll) {
let url = `${basename(instanceName)}/api/v1/statuses` let url = `${basename(instanceName)}/api/v1/statuses`
let body = { let body = {
@ -11,7 +11,8 @@ export async function postStatus (instanceName, accessToken, text, inReplyToId,
media_ids: mediaIds, media_ids: mediaIds,
sensitive: sensitive, sensitive: sensitive,
spoiler_text: spoilerText, spoiler_text: spoilerText,
visibility: visibility visibility: visibility,
poll: poll
} }
for (let key of Object.keys(body)) { for (let key of Object.keys(body)) {

View File

@ -0,0 +1,76 @@
<div class="select-wrapper {className || ''}">
<select on:change>
{#each options as option (option.value)}
<option value="{option.value}" selected="{option.value === defaultValue ? 'selected' : ''}">
{option.label}
</option>
{/each}
</select>
<div class="select-dropdown-icon-wrapper">
<SvgIcon href="#fa-angle-down" className="select-dropdown-icon"/>
</div>
</div>
<style>
.select-wrapper {
position: relative;
display: inline-block;
}
.select-dropdown-icon-wrapper {
position: absolute;
right: 15px;
top: 0;
bottom: 0;
display: flex;
align-items: center;
pointer-events: none;
}
:global(.select-dropdown-icon) {
width: 18px;
height: 18px;
min-width: 18px;
fill: var(--action-button-deemphasized-fill-color);
}
select {
display: inline-block;
padding: 5px 35px 5px 15px;
margin: 0;
font-size: 1.3em;
color: var(--body-text-color);
line-height: 1.1;
box-sizing: border-box;
border: 1px solid var(--main-border);
border-radius: 10px;
-moz-appearance: none;
-webkit-appearance: none;
background-color: var(--input-bg);
cursor: pointer;
}
select:hover {
background-color: var(--button-bg-hover);
}
select:active {
background-color: var(--button-bg-active);
}
select::-ms-expand {
display: none;
}
select:-moz-focusring {
color: transparent;
text-shadow: 0 0 0 var(--body-text-color);
}
select option {
font-weight:normal;
}
</style>
<script>
import SvgIcon from './SvgIcon.html'
export default {
data: () => ({
defaultValue: '',
className: ''
}),
components: {
SvgIcon
}
}
</script>

View File

@ -13,7 +13,13 @@
<ComposeInput {realm} {text} {autoFocus} on:postAction="doPostStatus()" /> <ComposeInput {realm} {text} {autoFocus} on:postAction="doPostStatus()" />
<ComposeLengthGauge {length} {overLimit} /> <ComposeLengthGauge {length} {overLimit} />
<ComposeAutosuggest {realm} {text} /> <ComposeAutosuggest {realm} {text} />
<ComposeToolbar {realm} {postPrivacy} {media} {contentWarningShown} {text} /> {#if poll && poll.options && poll.options.length}
<div class="compose-poll-wrapper"
transition:slide="{duration: 333}">
<ComposePoll {realm} {poll} />
</div>
{/if}
<ComposeToolbar {realm} {postPrivacy} {media} {contentWarningShown} {text} {poll} />
<ComposeLengthIndicator {length} {overLimit} /> <ComposeLengthIndicator {length} {overLimit} />
<ComposeMedia {realm} {media} /> <ComposeMedia {realm} {media} />
</div> </div>
@ -38,6 +44,7 @@
"avatar input input input" "avatar input input input"
"avatar gauge gauge gauge" "avatar gauge gauge gauge"
"avatar autosuggest autosuggest autosuggest" "avatar autosuggest autosuggest autosuggest"
"avatar poll poll poll"
"avatar toolbar toolbar length" "avatar toolbar toolbar length"
"avatar media media media"; "avatar media media media";
grid-template-columns: min-content minmax(0, max-content) 1fr 1fr; grid-template-columns: min-content minmax(0, max-content) 1fr 1fr;
@ -62,6 +69,10 @@
grid-area: cw; grid-area: cw;
} }
.compose-poll-wrapper {
grid-area: poll;
}
@media (max-width: 767px) { @media (max-width: 767px) {
.compose-box { .compose-box {
padding: 10px 10px 0 10px; padding: 10px 10px 0 10px;
@ -83,12 +94,14 @@
import ComposeContentWarning from './ComposeContentWarning.html' import ComposeContentWarning from './ComposeContentWarning.html'
import ComposeFileDrop from './ComposeFileDrop.html' import ComposeFileDrop from './ComposeFileDrop.html'
import ComposeAutosuggest from './ComposeAutosuggest.html' import ComposeAutosuggest from './ComposeAutosuggest.html'
import ComposePoll from './ComposePoll.html'
import { measureText } from '../../_utils/measureText' import { measureText } from '../../_utils/measureText'
import { POST_PRIVACY_OPTIONS } from '../../_static/statuses' import { POST_PRIVACY_OPTIONS } from '../../_static/statuses'
import { store } from '../../_store/store' import { store } from '../../_store/store'
import { slide } from 'svelte-transitions' import { slide } from 'svelte-transitions'
import { postStatus, insertHandleForReply, setReplySpoiler, setReplyVisibility } from '../../_actions/compose' import { postStatus, insertHandleForReply, setReplySpoiler, setReplyVisibility } from '../../_actions/compose'
import { classname } from '../../_utils/classname' import { classname } from '../../_utils/classname'
import { POLL_EXPIRY_DEFAULT } from '../../_static/polls'
export default { export default {
oncreate () { oncreate () {
@ -118,7 +131,8 @@
ComposeMedia, ComposeMedia,
ComposeContentWarning, ComposeContentWarning,
ComposeFileDrop, ComposeFileDrop,
ComposeAutosuggest ComposeAutosuggest,
ComposePoll
}, },
data: () => ({ data: () => ({
size: void 0, size: void 0,
@ -144,6 +158,7 @@
composeData: ({ $currentComposeData, realm }) => $currentComposeData[realm] || {}, composeData: ({ $currentComposeData, realm }) => $currentComposeData[realm] || {},
text: ({ composeData }) => composeData.text || '', text: ({ composeData }) => composeData.text || '',
media: ({ composeData }) => composeData.media || [], media: ({ composeData }) => composeData.media || [],
poll: ({ composeData }) => composeData.poll,
inReplyToId: ({ composeData }) => composeData.inReplyToId, inReplyToId: ({ composeData }) => composeData.inReplyToId,
postPrivacy: ({ postPrivacyKey }) => POST_PRIVACY_OPTIONS.find(_ => _.key === postPrivacyKey), postPrivacy: ({ postPrivacyKey }) => POST_PRIVACY_OPTIONS.find(_ => _.key === postPrivacyKey),
defaultPostPrivacyKey: ({ $currentVerifyCredentials }) => ( defaultPostPrivacyKey: ({ $currentVerifyCredentials }) => (
@ -172,7 +187,8 @@
realm, realm,
overLimit, overLimit,
inReplyToUuid, // typical replies, using Pinafore-specific uuid 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() } = this.get()
let sensitive = media.length && !!contentWarning let sensitive = media.length && !!contentWarning
let mediaIds = media.map(_ => _.data.id) let mediaIds = media.map(_ => _.data.id)
@ -183,10 +199,25 @@
return // do nothing if invalid 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 */ /* no await */
postStatus(realm, text, inReplyTo, mediaIds, postStatus(realm, text, inReplyTo, mediaIds,
sensitive, contentWarning, postPrivacyKey, sensitive, contentWarning, postPrivacyKey,
mediaDescriptions, inReplyToUuid) mediaDescriptions, inReplyToUuid, pollToPost)
} }
} }
} }

View File

@ -0,0 +1,157 @@
<section class="compose-poll" aria-label="Create poll">
{#each poll.options as option, i}
<input id="poll-option-{realm}-{i}"
type="text"
maxlength="25"
on:change="onChange(i)"
placeholder="Choice {i + 1}"
aria-labelledby="poll-option-label-{realm}-{i}"
>
<IconButton
label="Remove choice {i + 1}"
href="#fa-times"
muted={true}
on:click="onDeleteClick(i)"
/>
{/each}
<div>
<input type="checkbox"
id="poll-option-multiple-{realm}"
on:change="onMultipleChange()"
>
<label class="multiple-choice-label"
for="poll-option-multiple-{realm}">
Multiple choice
</label>
<Select className="poll-expiry-select"
options={pollExpiryOptions}
defaultValue={pollExpiryDefaultValue}
on:change="onExpiryChange(event)"
/>
</div>
<IconButton
className="add-poll-choice-button"
label="Add choice"
href="#fa-plus"
muted={true}
disabled={poll.options.length === 4}
on:click="onAddClick()"
/>
{#each poll.options as option, i}
<label id="poll-option-label-{realm}-{i}"
class="sr-only"
for="poll-option-{realm}-{i}">
Choice {i + 1}
</label>
{/each}
</section>
<style>
.compose-poll {
margin: 10px 0 10px 5px;
display: grid;
grid-template-columns: minmax(0, max-content) max-content;
grid-row-gap: 10px;
align-items: center;
}
:global(.poll-expiry-select) {
margin-left: 10px;
}
.multiple-choice-label {
margin-left: 5px;
}
@media (max-width: 767px) {
:global(.poll-expiry-select) {
display: block;
margin-left: 0;
margin-top: 10px;
}
:global(.add-poll-choice-button) {
align-self: flex-start;
}
}
</style>
<script>
import IconButton from '../IconButton.html'
import Select from '../Select.html'
import { store } from '../../_store/store'
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import { POLL_EXPIRY_DEFAULT, POLL_EXPIRY_OPTIONS } from '../../_static/polls'
function flushPollOptionsToDom (poll, realm) {
for (let i = 0; i < poll.options.length; i++) {
let element = document.getElementById(`poll-option-${realm}-${i}`)
element.value = poll.options[i]
}
}
export default {
oncreate () {
let { realm } = this.get()
let poll = this.store.getComposeData(realm, 'poll')
flushPollOptionsToDom(poll, realm)
document.getElementById(`poll-option-multiple-${realm}`).checked = !!poll.multiple
this.set({ pollExpiryDefaultValue: poll.expiry || POLL_EXPIRY_DEFAULT })
},
data: () => ({
pollExpiryOptions: POLL_EXPIRY_OPTIONS,
pollExpiryDefaultValue: POLL_EXPIRY_DEFAULT
}),
store: () => store,
methods: {
onChange (i) {
scheduleIdleTask(() => {
let { realm } = this.get()
let element = document.getElementById(`poll-option-${realm}-${i}`)
let poll = this.store.getComposeData(realm, 'poll')
poll.options[i] = element.value
this.store.setComposeData(realm, { poll })
})
},
onMultipleChange () {
requestAnimationFrame(() => {
let { realm } = this.get()
let element = document.getElementById(`poll-option-multiple-${realm}`)
let poll = this.store.getComposeData(realm, 'poll')
poll.multiple = !!element.checked
this.store.setComposeData(realm, { poll })
})
},
onDeleteClick (i) {
requestAnimationFrame(() => {
let { realm } = this.get()
let poll = this.store.getComposeData(realm, 'poll')
poll.options.splice(i, 1)
this.store.setComposeData(realm, { poll })
flushPollOptionsToDom(poll, realm)
})
},
onAddClick () {
requestAnimationFrame(() => {
let { realm } = this.get()
let poll = this.store.getComposeData(realm, 'poll')
if (!poll.options.length !== 4) {
poll.options.push('')
}
this.store.setComposeData(realm, { poll })
})
},
onExpiryChange (e) {
requestAnimationFrame(() => {
let { realm } = this.get()
let { value } = e.target
let poll = this.store.getComposeData(realm, 'poll')
poll.expiry = parseInt(value, 10)
this.store.setComposeData(realm, { poll })
})
}
},
components: {
IconButton,
Select
}
}
</script>

View File

@ -12,6 +12,13 @@
on:click="onMediaClick()" on:click="onMediaClick()"
disabled={$uploadingMedia || (media.length === 4)} disabled={$uploadingMedia || (media.length === 4)}
/> />
<IconButton
label="{poll && poll.options && poll.options.length ? 'Add poll' : 'Remove poll'}"
href="#fa-bar-chart"
on:click="onPollClick()"
pressable="true"
pressed={poll && poll.options && poll.options.length}
/>
<IconButton <IconButton
label="Adjust privacy (currently {postPrivacy.label})" label="Adjust privacy (currently {postPrivacy.label})"
href={postPrivacy.icon} href={postPrivacy.icon}
@ -48,6 +55,7 @@
import { doMediaUpload } from '../../_actions/media' import { doMediaUpload } from '../../_actions/media'
import { toggleContentWarningShown } from '../../_actions/contentWarnings' import { toggleContentWarningShown } from '../../_actions/contentWarnings'
import { mediaAccept } from '../../_static/media' import { mediaAccept } from '../../_static/media'
import { enablePoll, disablePoll } from '../../_actions/composePoll'
export default { export default {
components: { components: {
@ -79,6 +87,14 @@
onContentWarningClick () { onContentWarningClick () {
let { realm } = this.get() let { realm } = this.get()
toggleContentWarningShown(realm) toggleContentWarningShown(realm)
},
onPollClick () {
let { poll, realm } = this.get()
if (poll && poll.options && poll.options.length) {
disablePoll(realm)
} else {
enablePoll(realm)
}
} }
} }
} }

View File

@ -0,0 +1,32 @@
export const POLL_EXPIRY_OPTIONS = [
{
'value': 300,
'label': '5 minutes'
},
{
'value': 1800,
'label': '30 minutes'
},
{
'value': 3600,
'label': '1 hour'
},
{
'value': 21600,
'label': '6 hours'
},
{
'value': 86400,
'label': '1 day'
},
{
'value': 259200,
'label': '3 days'
},
{
'value': 604800,
'label': '7 days'
}
]
export const POLL_EXPIRY_DEFAULT = 86400

View File

@ -22,8 +22,8 @@ export const composeButton = $('.compose-box-button')
export const composeLengthIndicator = $('.compose-box-length') export const composeLengthIndicator = $('.compose-box-length')
export const emojiButton = $('.compose-box-toolbar button:first-child') export const emojiButton = $('.compose-box-toolbar button:first-child')
export const mediaButton = $('.compose-box-toolbar button:nth-child(2)') export const mediaButton = $('.compose-box-toolbar button:nth-child(2)')
export const postPrivacyButton = $('.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(4)') export const contentWarningButton = $('.compose-box-toolbar button:nth-child(5)')
export const emailInput = $('input#user_email') export const emailInput = $('input#user_email')
export const passwordInput = $('input#user_password') export const passwordInput = $('input#user_password')
export const authorizeInput = $('button[type=submit]:not(.negative)') export const authorizeInput = $('button[type=submit]:not(.negative)')
@ -56,7 +56,7 @@ export const composeModalInput = $('.modal-dialog .compose-box-input')
export const composeModalComposeButton = $('.modal-dialog .compose-box-button') export const composeModalComposeButton = $('.modal-dialog .compose-box-button')
export const composeModalContentWarningInput = $('.modal-dialog .content-warning-input') export const composeModalContentWarningInput = $('.modal-dialog .content-warning-input')
export const composeModalEmojiButton = $('.modal-dialog .compose-box-toolbar button:nth-child(1)') export const composeModalEmojiButton = $('.modal-dialog .compose-box-toolbar button:nth-child(1)')
export const composeModalPostPrivacyButton = $('.modal-dialog .compose-box-toolbar button:nth-child(3)') export const composeModalPostPrivacyButton = $('.modal-dialog .compose-box-toolbar button:nth-child(4)')
export const postPrivacyDialogButtonUnlisted = $('[aria-label="Post privacy dialog"] li:nth-child(2) button') export const postPrivacyDialogButtonUnlisted = $('[aria-label="Post privacy dialog"] li:nth-child(2) button')
@ -217,7 +217,7 @@ export function getNthComposeReplyButton (n) {
} }
export function getNthPostPrivacyButton (n) { export function getNthPostPrivacyButton (n) {
return $(`${getNthStatusSelector(n)} .compose-box-toolbar button:nth-child(3)`) return $(`${getNthStatusSelector(n)} .compose-box-toolbar button:nth-child(4)`)
} }
export function getNthAutosuggestionResult (n) { export function getNthAutosuggestionResult (n) {
@ -301,11 +301,11 @@ export function getNthReplyContentWarningInput (n) {
} }
export function getNthReplyContentWarningButton (n) { export function getNthReplyContentWarningButton (n) {
return $(`${getNthStatusSelector(n)} .compose-box-toolbar button:nth-child(4)`) return $(`${getNthStatusSelector(n)} .compose-box-toolbar button:nth-child(5)`)
} }
export function getNthReplyPostPrivacyButton (n) { export function getNthReplyPostPrivacyButton (n) {
return $(`${getNthStatusSelector(n)} .compose-box-toolbar button:nth-child(3)`) return $(`${getNthStatusSelector(n)} .compose-box-toolbar button:nth-child(4)`)
} }
export function getNthPostPrivacyOptionInDialog (n) { export function getNthPostPrivacyOptionInDialog (n) {