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:
parent
2c1de66592
commit
0878275ab9
|
@ -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' }
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
|
|
18
src/routes/_actions/composePoll.js
Normal file
18
src/routes/_actions/composePoll.js
Normal 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
|
||||
})
|
||||
}
|
|
@ -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)) {
|
||||
|
|
76
src/routes/_components/Select.html
Normal file
76
src/routes/_components/Select.html
Normal 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>
|
|
@ -13,7 +13,13 @@
|
|||
<ComposeInput {realm} {text} {autoFocus} on:postAction="doPostStatus()" />
|
||||
<ComposeLengthGauge {length} {overLimit} />
|
||||
<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} />
|
||||
<ComposeMedia {realm} {media} />
|
||||
</div>
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
157
src/routes/_components/compose/ComposePoll.html
Normal file
157
src/routes/_components/compose/ComposePoll.html
Normal 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>
|
|
@ -12,6 +12,13 @@
|
|||
on:click="onMediaClick()"
|
||||
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
|
||||
label="Adjust privacy (currently {postPrivacy.label})"
|
||||
href={postPrivacy.icon}
|
||||
|
@ -48,6 +55,7 @@
|
|||
import { doMediaUpload } from '../../_actions/media'
|
||||
import { toggleContentWarningShown } from '../../_actions/contentWarnings'
|
||||
import { mediaAccept } from '../../_static/media'
|
||||
import { enablePoll, disablePoll } from '../../_actions/composePoll'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -79,6 +87,14 @@
|
|||
onContentWarningClick () {
|
||||
let { realm } = this.get()
|
||||
toggleContentWarningShown(realm)
|
||||
},
|
||||
onPollClick () {
|
||||
let { poll, realm } = this.get()
|
||||
if (poll && poll.options && poll.options.length) {
|
||||
disablePoll(realm)
|
||||
} else {
|
||||
enablePoll(realm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
32
src/routes/_static/polls.js
Normal file
32
src/routes/_static/polls.js
Normal 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
|
|
@ -22,8 +22,8 @@ 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 postPrivacyButton = $('.compose-box-toolbar button:nth-child(3)')
|
||||
export const contentWarningButton = $('.compose-box-toolbar button:nth-child(4)')
|
||||
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')
|
||||
export const passwordInput = $('input#user_password')
|
||||
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 composeModalContentWarningInput = $('.modal-dialog .content-warning-input')
|
||||
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')
|
||||
|
||||
|
@ -217,7 +217,7 @@ export function getNthComposeReplyButton (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) {
|
||||
|
@ -301,11 +301,11 @@ export function getNthReplyContentWarningInput (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) {
|
||||
return $(`${getNthStatusSelector(n)} .compose-box-toolbar button:nth-child(3)`)
|
||||
return $(`${getNthStatusSelector(n)} .compose-box-toolbar button:nth-child(4)`)
|
||||
}
|
||||
|
||||
export function getNthPostPrivacyOptionInDialog (n) {
|
||||
|
|
Loading…
Reference in a new issue