Compare commits

...

15 Commits

Author SHA1 Message Date
'leftie 77aead72fb Merge branch 'master' into leftie 2019-05-27 15:40:30 -04:00
Nolan Lawson 164768e6c9
fix: fix bug when faving/boosting push notification (#1244) 2019-05-27 12:32:06 -07:00
Nolan Lawson 3a7d6d3552
fix: add <select> aria-label, remove unnecessary aria-labelledby (#1242) 2019-05-27 12:31:59 -07:00
Nolan Lawson 12179505e1
fix: improve UI/a11y of media upload (#1241) 2019-05-27 12:31:49 -07:00
Nolan Lawson 482ee3d3bb
fix: improve media upload a11y (#1240)
use ul/li instead of divs here
2019-05-27 12:31:42 -07:00
Nolan Lawson 37d3cac7d2
fix: add tests for polls, improve a11y of poll form (#1239) 2019-05-27 12:31:35 -07:00
Nolan Lawson b45868bbfd
fix: poll button label is backwards (#1238) 2019-05-27 01:05:55 -07:00
Nolan Lawson 6efc28aac8
fix: fix reduceMotion of svelte slide transition (#1237)
fixes #1236
2019-05-27 00:24:57 -07:00
Nolan Lawson 0878275ab9
feat: ability to create polls (#1235)
* feat: ability to create polls

fixes #1130

* fix adds and deletes

* fix tests

* fix tests again
2019-05-27 00:24:47 -07:00
Nolan Lawson 2c1de66592
feat: vote on polls (#1234)
more work on #1130
2019-05-26 20:45:42 -07:00
Nolan Lawson 45441d3a9e
fix: show poll results, time remaining, allow refresh (#1233)
more work towards #1130
2019-05-26 18:48:04 -07:00
Nolan Lawson dac4b493c8
fix: poll for updates to timeago displays (#1232)
* fix: poll for updates to timeago displays

* code cleanup

* avoid some recomputes

* avoid costly recomputes
2019-05-26 16:01:14 -07:00
Nolan Lawson bf640b9b0f
fix: fix unread notifications badge for filters (#1231)
fixes #1230
2019-05-26 16:01:06 -07:00
Nolan Lawson 8f477eeccb
feat: add poll notifications (#1229)
more work on #1130
2019-05-26 09:54:35 -07:00
greenkeeper[bot] 979bb4815f chore: Update stringz to the latest version 🚀 (#1228)
* fix(package): update stringz to version 2.0.0

* chore(package): update lockfile yarn.lock
2019-05-26 09:37:11 -07:00
39 changed files with 1311 additions and 193 deletions

View File

@ -42,9 +42,14 @@ 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' },
{ id: 'fa-flag', src: 'src/thirdparty/font-awesome-svg-png/white/svg/flag.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' },
{ 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-plus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/plus.svg' }
] ]

View File

@ -90,7 +90,7 @@
"rollup-plugin-replace": "^2.2.0", "rollup-plugin-replace": "^2.2.0",
"rollup-plugin-terser": "^5.0.0", "rollup-plugin-terser": "^5.0.0",
"sapper": "nolanlawson/sapper#for-pinafore-14", "sapper": "nolanlawson/sapper#for-pinafore-14",
"stringz": "^1.0.0", "stringz": "^2.0.0",
"svelte": "^2.16.1", "svelte": "^2.16.1",
"svelte-extras": "^2.0.2", "svelte-extras": "^2.0.2",
"svelte-loader": "^2.13.3", "svelte-loader": "^2.13.3",

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

@ -0,0 +1,25 @@
import { getPoll as getPollApi, voteOnPoll as voteOnPollApi } from '../_api/polls'
import { store } from '../_store/store'
import { toast } from '../_components/toast/toast'
export async function getPoll (pollId) {
let { currentInstance, accessToken } = store.get()
try {
let poll = await getPollApi(currentInstance, accessToken, pollId)
return poll
} catch (e) {
console.error(e)
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 || ''))
}
}

12
src/routes/_api/polls.js Normal file
View File

@ -0,0 +1,12 @@
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 })
}

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 aria-label={label}>
{#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 '../../_transitions/slide'
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

@ -1,18 +1,22 @@
{#if media.length} {#if media.length}
<div class="compose-media-container" style="grid-template-columns: repeat({media.length}, 1fr);"> <ul class="compose-media-container"
aria-label="Media uploads"
style="grid-template-columns: repeat({media.length}, 1fr);"
>
{#each media as mediaItem, index} {#each media as mediaItem, index}
<ComposeMediaItem {realm} {mediaItem} {index} {media} /> <ComposeMediaItem {realm} {mediaItem} {index} {media} />
{/each} {/each}
</div> </ul>
{/if} {/if}
<style> <style>
.compose-media-container { .compose-media-container {
grid-area: media; grid-area: media;
list-style: none;
display: grid; display: grid;
grid-column-gap: 5px; grid-column-gap: 5px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 10px; margin: 10px 0 0 0;
background: var(--form-bg); background: var(--form-bg);
padding: 5px; padding: 5px;
border-radius: 4px; border-radius: 4px;

View File

@ -1,4 +1,4 @@
<div class="compose-media compose-media-realm-{realm}"> <li class="compose-media compose-media-realm-{realm}">
<img src={mediaItem.data.preview_url} {alt} /> <img src={mediaItem.data.preview_url} {alt} />
<div class="compose-media-delete"> <div class="compose-media-delete">
<button class="compose-media-delete-button" <button class="compose-media-delete-button"
@ -8,19 +8,21 @@
</button> </button>
</div> </div>
<div class="compose-media-alt"> <div class="compose-media-alt">
<input id="compose-media-input-{uuid}" <textarea id="compose-media-input-{uuid}"
type="text"
class="compose-media-alt-input" class="compose-media-alt-input"
placeholder="Description" placeholder="Describe for the visually impaired"
ref:textarea
bind:value=rawText bind:value=rawText
> ></textarea>
<label for="compose-media-input-{uuid}" class="sr-only"> <label for="compose-media-input-{uuid}" class="sr-only">
Describe {shortName} for the visually impaired Describe {shortName} for the visually impaired
</label> </label>
</div> </div>
</div> </li>
<style> <style>
.compose-media { .compose-media {
margin: 0;
padding: 0;
height: 200px; height: 200px;
overflow: hidden; overflow: hidden;
flex-direction: column; flex-direction: column;
@ -49,6 +51,9 @@
font-size: 1.2em; font-size: 1.2em;
background: var(--alt-input-bg); background: var(--alt-input-bg);
color: var(--body-text-color); color: var(--body-text-color);
max-height: 100px;
border: 1px solid var(--input-border);
resize: none;
} }
.compose-media-alt-input:focus { .compose-media-alt-input:focus {
background: var(--main-bg); background: var(--main-bg);
@ -64,12 +69,15 @@
margin: 2px; margin: 2px;
} }
.compose-media-delete-button { .compose-media-delete-button {
padding: 10px; padding: 7px 10px 5px;
background: none; background: var(--floating-button-bg);
border: none; border: 1px solid var(--button-border);
} }
.compose-media-delete-button:hover { .compose-media-delete-button:hover {
background: var(--toast-border); background: var(--floating-button-bg-hover);
}
.compose-media-delete-button:active {
background: var(--floating-button-bg-active);
} }
:global(.compose-media-delete-button-svg) { :global(.compose-media-delete-button-svg) {
fill: var(--button-text); fill: var(--button-text);
@ -85,6 +93,9 @@
.compose-media-realm-dialog { .compose-media-realm-dialog {
max-height: 15vh; max-height: 15vh;
} }
.compose-media-alt-input {
max-height: 7vh;
}
} }
</style> </style>
<script> <script>
@ -94,11 +105,16 @@
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask' import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import { observe } from 'svelte-extras' import { observe } from 'svelte-extras'
import SvgIcon from '../SvgIcon.html' import SvgIcon from '../SvgIcon.html'
import { autosize } from '../../_thirdparty/autosize/autosize'
export default { export default {
oncreate () { oncreate () {
this.setupSyncFromStore() this.setupSyncFromStore()
this.setupSyncToStore() this.setupSyncToStore()
this.setupAutosize()
},
ondestroy () {
this.teardownAutosize()
}, },
data: () => ({ data: () => ({
rawText: '' rawText: ''
@ -139,6 +155,12 @@
saveStore() saveStore()
}, { init: false }) }, { init: false })
}, },
setupAutosize () {
autosize(this.refs.textarea)
},
teardownAutosize () {
autosize.destroy(this.refs.textarea)
},
onDeleteMedia () { onDeleteMedia () {
let { let {
realm, realm,

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}"
>
<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)"
label="Poll duration"
/>
</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 ? 'Remove poll' : 'Add 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

@ -29,4 +29,4 @@
} }
} }
} }
</script> </script>

View File

@ -13,11 +13,11 @@
<StatusAuthorName {...params} /> <StatusAuthorName {...params} />
<StatusAuthorHandle {...params} /> <StatusAuthorHandle {...params} />
{#if !isStatusInOwnThread} {#if !isStatusInOwnThread}
<StatusRelativeDate {...params} /> <StatusRelativeDate {...params} {...timestampParams} />
{/if} {/if}
<StatusSidebar {...params} /> <StatusSidebar {...params} />
{#if spoilerText} {#if spoilerText}
<StatusSpoiler {...params} on:recalculateHeight /> <StatusSpoiler {...params} {spoilerShown} on:recalculateHeight />
{/if} {/if}
{#if !showContent} {#if !showContent}
<StatusMentions {...params} /> <StatusMentions {...params} />
@ -35,9 +35,9 @@
<StatusPoll {...params} /> <StatusPoll {...params} />
{/if} {/if}
{#if isStatusInOwnThread} {#if isStatusInOwnThread}
<StatusDetails {...params} /> <StatusDetails {...params} {...timestampParams} />
{/if} {/if}
<StatusToolbar {...params} on:recalculateHeight /> <StatusToolbar {...params} {replyShown} on:recalculateHeight />
{#if replyShown} {#if replyShown}
<StatusComposeBox {...params} on:recalculateHeight /> <StatusComposeBox {...params} on:recalculateHeight />
{/if} {/if}
@ -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')
@ -268,8 +268,8 @@
originalStatus.card && originalStatus.card &&
originalStatus.card.title originalStatus.card.title
), ),
showPoll: ({ originalStatus, isStatusInNotification }) => ( showPoll: ({ originalStatus }) => (
!isStatusInNotification && originalStatus.poll originalStatus.poll
), ),
showMedia: ({ originalStatus, isStatusInNotification }) => ( showMedia: ({ originalStatus, isStatusInNotification }) => (
!isStatusInNotification && !isStatusInNotification &&
@ -277,13 +277,15 @@
originalStatus.media_attachments.length originalStatus.media_attachments.length
), ),
originalAccountEmojis: ({ originalAccount }) => (originalAccount.emojis || []), originalAccountEmojis: ({ originalAccount }) => (originalAccount.emojis || []),
originalStatusEmojis: ({ originalStatus }) => (originalStatus.emojis || []),
originalAccountDisplayName: ({ originalAccount }) => (originalAccount.display_name || originalAccount.username), originalAccountDisplayName: ({ originalAccount }) => (originalAccount.display_name || originalAccount.username),
originalAccountAccessibleName: ({ originalAccount, $omitEmojiInDisplayNames }) => { originalAccountAccessibleName: ({ originalAccount, $omitEmojiInDisplayNames }) => {
return getAccountAccessibleName(originalAccount, $omitEmojiInDisplayNames) return getAccountAccessibleName(originalAccount, $omitEmojiInDisplayNames)
}, },
createdAtDate: ({ originalStatus }) => originalStatus.created_at, createdAtDate: ({ originalStatus }) => originalStatus.created_at,
absoluteFormattedDate: ({ createdAtDate }) => absoluteDateFormatter.format(new Date(createdAtDate)), createdAtDateTS: ({ createdAtDate }) => new Date(createdAtDate).getTime(),
timeagoFormattedDate: ({ createdAtDate }) => formatTimeagoDate(createdAtDate), absoluteFormattedDate: ({ createdAtDateTS }) => absoluteDateFormatter.format(createdAtDateTS),
timeagoFormattedDate: ({ createdAtDateTS, $now }) => formatTimeagoDate(createdAtDateTS, $now),
reblog: ({ status }) => status.reblog, reblog: ({ status }) => status.reblog,
ariaLabel: ({ originalAccount, account, plainTextContent, timeagoFormattedDate, spoilerText, ariaLabel: ({ originalAccount, account, plainTextContent, timeagoFormattedDate, spoilerText,
showContent, reblog, notification, visibility, $omitEmojiInDisplayNames, $disableLongAriaLabels }) => ( showContent, reblog, notification, visibility, $omitEmojiInDisplayNames, $disableLongAriaLabels }) => (
@ -292,7 +294,7 @@
reblog, notification, visibility, $omitEmojiInDisplayNames, $disableLongAriaLabels) reblog, notification, visibility, $omitEmojiInDisplayNames, $disableLongAriaLabels)
), ),
showHeader: ({ notification, status, timelineType }) => ( showHeader: ({ notification, status, timelineType }) => (
(notification && (notification.type === 'reblog' || notification.type === 'favourite')) || (notification && ['reblog', 'favourite', 'poll'].includes(notification.type)) ||
status.reblog || status.reblog ||
timelineType === 'pinned' timelineType === 'pinned'
), ),
@ -307,11 +309,22 @@
)), )),
content: ({ originalStatus }) => originalStatus.content || '', content: ({ originalStatus }) => originalStatus.content || '',
showContent: ({ spoilerText, spoilerShown }) => !spoilerText || spoilerShown, showContent: ({ spoilerText, spoilerShown }) => !spoilerText || spoilerShown,
// These timestamp params may change every 10 seconds due to now() polling, so keep them
// separate from the generic `params` list to avoid costly recomputes.
timestampParams: ({ createdAtDate, createdAtDateTS, timeagoFormattedDate, absoluteFormattedDate }) => ({
createdAtDate,
createdAtDateTS,
timeagoFormattedDate,
absoluteFormattedDate
}),
// This params list deliberately does *not* include `spoilersShown` or `replyShown`, because these
// change frequently and would therefore cause costly recomputes if included here.
// The main goal here is to avoid typing by passing as many params as possible to child components.
params: ({ notification, notificationId, status, statusId, timelineType, params: ({ notification, notificationId, status, statusId, timelineType,
account, accountId, uuid, isStatusInNotification, isStatusInOwnThread, account, accountId, uuid, isStatusInNotification, isStatusInOwnThread,
originalAccount, originalAccountId, spoilerShown, visibility, replyShown, originalAccount, originalAccountId, visibility,
replyVisibility, spoilerText, originalStatus, originalStatusId, inReplyToId, replyVisibility, spoilerText, originalStatus, originalStatusId, inReplyToId,
createdAtDate, timeagoFormattedDate, enableShortcuts, absoluteFormattedDate, shortcutScope }) => ({ enableShortcuts, shortcutScope, originalStatusEmojis }) => ({
notification, notification,
notificationId, notificationId,
status, status,
@ -324,19 +337,15 @@
isStatusInOwnThread, isStatusInOwnThread,
originalAccount, originalAccount,
originalAccountId, originalAccountId,
spoilerShown,
visibility, visibility,
replyShown,
replyVisibility, replyVisibility,
spoilerText, spoilerText,
originalStatus, originalStatus,
originalStatusId, originalStatusId,
inReplyToId, inReplyToId,
createdAtDate,
timeagoFormattedDate,
enableShortcuts, enableShortcuts,
absoluteFormattedDate, shortcutScope,
shortcutScope originalStatusEmojis
}) })
}, },
events: { events: {

View File

@ -76,8 +76,9 @@
) )
}, },
content: ({ originalStatus }) => (originalStatus.content || ''), content: ({ originalStatus }) => (originalStatus.content || ''),
emojis: ({ originalStatus }) => originalStatus.emojis, massagedContent: ({ content, originalStatusEmojis, $autoplayGifs }) => (
massagedContent: ({ content, emojis, $autoplayGifs }) => massageUserText(content, emojis, $autoplayGifs) massageUserText(content, originalStatusEmojis, $autoplayGifs)
)
}, },
methods: { methods: {
hydrateContent () { hydrateContent () {

View File

@ -158,7 +158,6 @@
application: ({ originalStatus }) => originalStatus.application, application: ({ originalStatus }) => originalStatus.application,
applicationName: ({ application }) => (application && application.name), applicationName: ({ application }) => (application && application.name),
applicationWebsite: ({ application }) => (application && application.website), applicationWebsite: ({ application }) => (application && application.website),
createdAtDate: ({ originalStatus }) => originalStatus.created_at,
numReblogs: ({ overrideNumReblogs, originalStatus }) => { numReblogs: ({ overrideNumReblogs, originalStatus }) => {
if (typeof overrideNumReblogs === 'number') { if (typeof overrideNumReblogs === 'number') {
return overrideNumReblogs return overrideNumReblogs
@ -171,8 +170,8 @@
} }
return originalStatus.favourites_count || 0 return originalStatus.favourites_count || 0
}, },
displayAbsoluteFormattedDate: ({ createdAtDate, $isMobileSize }) => ( displayAbsoluteFormattedDate: ({ createdAtDateTS, $isMobileSize }) => (
$isMobileSize ? shortAbsoluteDateFormatter : absoluteDateFormatter).format(new Date(createdAtDate) ($isMobileSize ? shortAbsoluteDateFormatter : absoluteDateFormatter).format(createdAtDateTS)
), ),
reblogsLabel: ({ numReblogs }) => { reblogsLabel: ({ numReblogs }) => {
// TODO: intl // TODO: intl

View File

@ -1,5 +1,5 @@
<div class="status-header {isStatusInNotification ? 'status-in-notification' : ''} {notification && notification.type === 'follow' ? 'header-is-follow' : ''}"> <div class="status-header {isStatusInNotification ? 'status-in-notification' : ''} {notificationType === 'follow' ? 'header-is-follow' : ''}">
<div class="status-header-avatar {timelineType === 'pinned' ? 'hidden' : ''}"> <div class="status-header-avatar {timelineType === 'pinned' || notificationType === 'poll' ? 'hidden' : ''}">
<Avatar {account} size="extra-small"/> <Avatar {account} size="extra-small"/>
</div> </div>
<SvgIcon className="status-header-svg" href={icon} /> <SvgIcon className="status-header-svg" href={icon} />
@ -9,7 +9,7 @@
<span class="status-header-author"> <span class="status-header-author">
Pinned toot Pinned toot
</span> </span>
{:else} {:elseif notificationType !== 'poll'}
<a id={elementId} <a id={elementId}
href="/accounts/{accountId}" href="/accounts/{accountId}"
rel="prefetch" rel="prefetch"
@ -20,17 +20,7 @@
</a> </a>
{/if} {/if}
<span class="status-header-action"> <span class="status-header-action">{actionText}</span>
{#if notification && notification.type === 'reblog'}
boosted your status
{:elseif notification && notification.type === 'favourite'}
favorited your status
{:elseif notification && notification.type === 'follow'}
followed you
{:elseif status && status.reblog}
boosted
{/if}
</span>
</div> </div>
</div> </div>
<style> <style>
@ -105,6 +95,7 @@
import Avatar from '../Avatar.html' import Avatar from '../Avatar.html'
import AccountDisplayName from '../profile/AccountDisplayName.html' import AccountDisplayName from '../profile/AccountDisplayName.html'
import SvgIcon from '../SvgIcon.html' import SvgIcon from '../SvgIcon.html'
import { store } from '../../_store/store'
export default { export default {
components: { components: {
@ -112,17 +103,40 @@
AccountDisplayName, AccountDisplayName,
SvgIcon SvgIcon
}, },
store: () => store,
computed: { computed: {
elementId: ({ uuid }) => `status-header-${uuid}`, elementId: ({ uuid }) => `status-header-${uuid}`,
icon: ({ notification, status, timelineType }) => { notificationType: ({ notification }) => notification && notification.type,
icon: ({ notificationType, status, timelineType }) => {
if (timelineType === 'pinned') { if (timelineType === 'pinned') {
return '#fa-thumb-tack' return '#fa-thumb-tack'
} else if ((notification && notification.type === 'reblog') || (status && status.reblog)) { } else if ((notificationType === 'reblog') || (status && status.reblog)) {
return '#fa-retweet' return '#fa-retweet'
} else if (notification && notification.type === 'follow') { } else if (notificationType === 'follow') {
return '#fa-user-plus' return '#fa-user-plus'
} else if (notificationType === 'poll') {
return '#fa-bar-chart'
} }
return '#fa-star' return '#fa-star'
},
actionText: ({ notificationType, status, $currentVerifyCredentials }) => {
if (notificationType === 'reblog') {
return 'boosted your status'
} else if (notificationType === 'favourite') {
return 'favorited your status'
} else if (notificationType === 'follow') {
return 'followed you'
} else if (notificationType === 'poll') {
if ($currentVerifyCredentials && status && $currentVerifyCredentials.id === status.account.id) {
return 'A poll you created has ended'
} else {
return 'A poll you voted on has ended'
}
} else if (status && status.reblog) {
return 'boosted'
} else {
return ''
}
} }
} }
} }

View File

@ -1,38 +1,96 @@
<div class="poll" > <div class={computedClass} aria-busy={loading} >
<ul class="options" aria-label="Poll results"> {#if voted || expired }
{#each options as option} <ul aria-label="Poll results">
<li class="option"> {#each options as option}
<div class="option-text">{option.title} ({option.share}%)</div> <li class="option">
<svg aria-hidden="true"> <div class="option-text">
<line x1="0" y1="0" x2="{option.share}%" y2="0" /> <strong>{option.share}%</strong> {option.title}
</svg> </div>
</li> <svg aria-hidden="true">
{/each} <line x1="0" y1="0" x2="{option.share}%" y2="0" />
</ul> </svg>
</li>
{/each}
</ul>
{:else}
<form class="poll-form" aria-label="Vote on poll" on:submit="onSubmit(event)" ref:form>
<ul aria-label="Poll choices">
{#each options as option, i}
<li 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>
</li>
{/each}
</ul>
<button disabled={formDisabled} type="submit">Vote</button>
</form>
{/if}
<div class="poll-details">
<div class="poll-stat">
<SvgIcon className="poll-icon" href="#fa-bar-chart" />
<span class="poll-stat-text">{votesCount} {votesCount === 1 ? 'vote' : 'votes'}</span>
</div>
<div class="poll-stat">
<SvgIcon className="poll-icon" href="#fa-clock" />
<span class="poll-stat-text poll-stat-expiry">
<span class="{useNarrowSize ? 'sr-only' : ''}">{expiryText}</span>
<time datetime={expiresAt} title={expiresAtAbsoluteFormatted}>
{expiresAtTimeagoFormatted}
</time>
</span>
</div>
<button class="poll-stat {expired ? 'poll-expired' : ''}" id={refreshElementId}>
<SvgIcon className="poll-icon" href="#fa-refresh" />
<span class="poll-stat-text">
Refresh
</span>
</button>
</div>
</div> </div>
<style> <style>
.poll { .poll {
grid-area: poll; grid-area: poll;
margin: 10px 10px 10px 5px; margin: 10px 10px 10px 5px;
padding: 20px;
border: 1px solid var(--main-border);
border-radius: 2px;
transition: opacity 0.2s linear;
} }
ul.options { .poll.status-in-own-thread {
padding: 20px;
}
.poll.poll-loading {
opacity: 0.5;
pointer-events: none;
}
ul {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
li.option { li {
margin: 0;
padding: 0;
}
.option {
margin: 0 0 10px 0; margin: 0 0 10px 0;
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
stroke: var(--svg-fill); stroke: var(--svg-fill);
stroke-width: 5px; stroke-width: 10px;
}
li.option:last-child {
margin: 0;
} }
.option-text { .option-text {
@ -42,20 +100,228 @@
} }
svg { svg {
height: 2px; height: 10px;
width: 100%; width: 100%;
margin-top: 5px; margin-top: 5px;
} }
.status-in-notification .option-text {
color: var(--very-deemphasized-text-color);
}
.status-in-notification svg {
opacity: 0.5;
}
.status-in-own-thread .option-text {
font-size: 1.2em;
}
.poll-details {
display: grid;
grid-template-columns: max-content minmax(0, max-content) max-content;
grid-gap: 20px;
align-items: center;
justify-content: left;
margin-top: 10px;
}
button.poll-stat {
/* reset button styles */
background: none;
box-shadow: none;
border: none;
border-spacing: 0;
margin: 0;
padding: 0;
font-size: inherit;
font-weight: normal;
text-align: left;
text-decoration: none;
text-indent: 0;
}
button.poll-stat:hover {
text-decoration: underline;
}
.poll-stat {
display: flex;
flex-direction: row;
align-items: center;
color: var(--deemphasized-text-color);
}
.poll-stat.poll-expired {
display: none;
}
.poll-stat-text {
margin-left: 5px;
}
.poll-stat-expiry {
word-wrap: break-word;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
:global(.poll-icon) {
fill: var(--deemphasized-text-color);
width: 18px;
height: 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) {
.poll {
padding: 10px 5px;
}
.poll.status-in-own-thread {
padding: 10px;
}
.poll-details {
grid-gap: 5px;
justify-content: space-between;
}
}
</style> </style>
<script> <script>
import SvgIcon from '../SvgIcon.html'
import { store } from '../../_store/store'
import { formatTimeagoFutureDate, formatTimeagoDate } from '../../_intl/formatTimeagoDate'
import { absoluteDateFormatter } from '../../_utils/formatters'
import { registerClickDelegate } from '../../_utils/delegate'
import { classname } from '../../_utils/classname'
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 { export default {
oncreate () {
this.onRefreshClick = this.onRefreshClick.bind(this)
let { refreshElementId } = this.get()
registerClickDelegate(this, refreshElementId, this.onRefreshClick)
},
data: () => ({
loading: false,
choices: []
}),
store: () => store,
computed: { computed: {
poll: ({ originalStatus }) => originalStatus.poll, pollId: ({ originalStatus }) => originalStatus.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,
voted: ({ poll }) => poll.voted,
multiple: ({ poll }) => poll.multiple,
expired: ({ poll }) => poll.expired,
expiresAt: ({ poll }) => poll.expires_at,
expiresAtTS: ({ expiresAt }) => new Date(expiresAt).getTime(),
expiresAtTimeagoFormatted: ({ expiresAtTS, expired, $now }) => (
expired ? formatTimeagoDate(expiresAtTS, $now) : formatTimeagoFutureDate(expiresAtTS, $now)
),
expiresAtAbsoluteFormatted: ({ expiresAtTS }) => absoluteDateFormatter.format(expiresAtTS),
expiryText: ({ expired }) => expired ? 'Ended' : 'Ends',
refreshElementId: ({ uuid }) => `poll-refresh-${uuid}`,
useNarrowSize: ({ $isMobileSize, expired }) => $isMobileSize && !expired,
formDisabled: ({ choices }) => !choices.length,
computedClass: ({ isStatusInNotification, isStatusInOwnThread, loading }) => (
classname(
'poll',
isStatusInNotification && 'status-in-notification',
isStatusInOwnThread && 'status-in-own-thread',
loading && 'poll-loading'
)
)
},
methods: {
async onRefreshClick (e) {
e.preventDefault()
e.stopPropagation()
let { pollId } = this.get()
this.set({ loading: true })
try {
let poll = await doAsyncActionWithDelay(() => getPoll(pollId))
if (poll) {
let { polls } = this.store.get()
polls[pollId] = poll
this.store.set({ polls })
}
} finally {
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: {
SvgIcon
} }
} }
</script> </script>

View File

@ -64,10 +64,9 @@
Shortcut Shortcut
}, },
computed: { computed: {
emojis: ({ originalStatus }) => originalStatus.emojis, massagedSpoilerText: ({ spoilerText, originalStatusEmojis, $autoplayGifs }) => {
massagedSpoilerText: ({ spoilerText, emojis, $autoplayGifs }) => {
spoilerText = escapeHtml(spoilerText) spoilerText = escapeHtml(spoilerText)
return emojifyText(spoilerText, emojis, $autoplayGifs) return emojifyText(spoilerText, originalStatusEmojis, $autoplayGifs)
}, },
elementId: ({ uuid }) => `spoiler-${uuid}` elementId: ({ uuid }) => `spoiler-${uuid}`
}, },

View File

@ -59,22 +59,6 @@
import { observe } from 'svelte-extras' import { observe } from 'svelte-extras'
import { createMakeProps } from '../../_actions/createMakeProps' import { createMakeProps } from '../../_actions/createMakeProps'
import { showMoreAndScrollToTop } from '../../_actions/showMoreAndScrollToTop' import { showMoreAndScrollToTop } from '../../_actions/showMoreAndScrollToTop'
import { get } from '../../_utils/lodash-lite'
import {
HOME_REBLOGS,
HOME_REPLIES,
NOTIFICATION_REBLOGS,
NOTIFICATION_FOLLOWS,
NOTIFICATION_FAVORITES,
NOTIFICATION_POLLS,
NOTIFICATION_MENTIONS,
FILTER_FAVORITE,
FILTER_FOLLOW,
FILTER_MENTION,
FILTER_POLL,
FILTER_REBLOG,
FILTER_REPLY
} from '../../_static/instanceSettings'
export default { export default {
oncreate () { oncreate () {
@ -143,59 +127,10 @@
timelineValue !== $firstTimelineItemId && timelineValue !== $firstTimelineItemId &&
timelineValue timelineValue
), ),
currentInstanceSettings: ({ $currentInstance, $instanceSettings }) => ( itemIds: ({ $filteredTimelineItemSummaries }) => (
$instanceSettings[$currentInstance] || {} $filteredTimelineItemSummaries && $filteredTimelineItemSummaries.map(_ => _.id)
),
timelineFilters: ({ currentInstanceSettings, timeline }) => {
if (timeline === 'home') {
return {
[FILTER_REBLOG]: get(currentInstanceSettings, [HOME_REBLOGS], true),
[FILTER_REPLY]: get(currentInstanceSettings, [HOME_REPLIES], true)
}
} else if (timeline === 'notifications') {
return {
[FILTER_REBLOG]: get(currentInstanceSettings, [NOTIFICATION_REBLOGS], true),
[FILTER_FOLLOW]: get(currentInstanceSettings, [NOTIFICATION_FOLLOWS], true),
[FILTER_FAVORITE]: get(currentInstanceSettings, [NOTIFICATION_FAVORITES], true),
[FILTER_MENTION]: get(currentInstanceSettings, [NOTIFICATION_MENTIONS], true),
[FILTER_POLL]: get(currentInstanceSettings, [NOTIFICATION_POLLS], true)
}
}
},
showReblogs: ({ timelineFilters }) => get(timelineFilters, [FILTER_REBLOG], true),
showReplies: ({ timelineFilters }) => get(timelineFilters, [FILTER_REPLY], true),
showFollows: ({ timelineFilters }) => get(timelineFilters, [FILTER_FOLLOW], true),
showMentions: ({ timelineFilters }) => get(timelineFilters, [FILTER_MENTION], true),
showPolls: ({ timelineFilters }) => get(timelineFilters, [FILTER_POLL], true),
showFavs: ({ timelineFilters }) => get(timelineFilters, [FILTER_FAVORITE], true),
itemIds: ({
$timelineItemSummaries, showReblogs, showReplies, showFollows, showMentions,
showPolls, showFavs
}) => (
$timelineItemSummaries && $timelineItemSummaries.filter(item => {
switch (item.type) {
case 'poll':
return showPolls
case 'favourite':
return showFavs
case 'reblog':
return showReblogs
case 'mention':
return showMentions
case 'follow':
return showFollows
}
if (item.reblogId) {
return showReblogs
} else if (item.replyId) {
return showReplies
} else {
return true
}
}).map(_ => _.id)
), ),
itemIdsToAdd: ({ $timelineItemSummariesToAdd }) => ( itemIdsToAdd: ({ $timelineItemSummariesToAdd }) => (
// TODO: filter
$timelineItemSummariesToAdd && $timelineItemSummariesToAdd.map(_ => _.id) $timelineItemSummariesToAdd && $timelineItemSummariesToAdd.map(_ => _.id)
), ),
headerProps: ({ itemIdsToAdd }) => { headerProps: ({ itemIdsToAdd }) => {

View File

@ -1,9 +1,20 @@
import { format } from '../_thirdparty/timeago/timeago' import { format } from '../_thirdparty/timeago/timeago'
import { mark, stop } from '../_utils/marks' import { mark, stop } from '../_utils/marks'
export function formatTimeagoDate (date) { // Format a date in the past
export function formatTimeagoDate (date, now) {
mark('formatTimeagoDate') mark('formatTimeagoDate')
let res = format(date) // use Math.max() to avoid things like "in 10 seconds" when the timestamps are slightly off
let res = format(date, Math.max(now, date))
stop('formatTimeagoDate') stop('formatTimeagoDate')
return res 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
}

View File

@ -6,10 +6,3 @@ export const NOTIFICATION_FAVORITES = 'notificationFavs'
export const NOTIFICATION_FOLLOWS = 'notificationFollows' export const NOTIFICATION_FOLLOWS = 'notificationFollows'
export const NOTIFICATION_MENTIONS = 'notificationMentions' export const NOTIFICATION_MENTIONS = 'notificationMentions'
export const NOTIFICATION_POLLS = 'notificationPolls' export const NOTIFICATION_POLLS = 'notificationPolls'
export const FILTER_REBLOG = 'reblog'
export const FILTER_REPLY = 'reply'
export const FILTER_MENTION = 'mention'
export const FILTER_FOLLOW = 'follow'
export const FILTER_FAVORITE = 'fav'
export const FILTER_POLL = 'poll'

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

@ -1,5 +1,14 @@
import { get } from '../../_utils/lodash-lite' import { get } from '../../_utils/lodash-lite'
import { getFirstIdFromItemSummaries, getLastIdFromItemSummaries } from '../../_utils/getIdFromItemSummaries' import { getFirstIdFromItemSummaries, getLastIdFromItemSummaries } from '../../_utils/getIdFromItemSummaries'
import {
HOME_REBLOGS,
HOME_REPLIES,
NOTIFICATION_REBLOGS,
NOTIFICATION_FOLLOWS,
NOTIFICATION_FAVORITES,
NOTIFICATION_POLLS,
NOTIFICATION_MENTIONS
} from '../../_static/instanceSettings'
function computeForTimeline (store, key, defaultValue) { function computeForTimeline (store, key, defaultValue) {
store.compute(key, store.compute(key,
@ -10,6 +19,31 @@ function computeForTimeline (store, key, defaultValue) {
) )
} }
// Compute just the boolean, e.g. 'showPolls', so that we can use that boolean as
// the input to the timelineFilterFunction computations. This should reduce the need to
// re-compute the timelineFilterFunction over and over.
function computeTimelineFilter (store, computationName, timelinesToSettingsKeys) {
store.compute(
computationName,
['currentInstance', 'instanceSettings', 'currentTimeline'],
(currentInstance, instanceSettings, currentTimeline) => {
let settingsKey = timelinesToSettingsKeys[currentTimeline]
return settingsKey ? get(instanceSettings, [currentInstance, settingsKey], true) : true
}
)
}
// Ditto for notifications, which we always have to keep track of due to the notification count.
function computeNotificationFilter (store, computationName, key) {
store.compute(
computationName,
['currentInstance', 'instanceSettings'],
(currentInstance, instanceSettings) => {
return get(instanceSettings, [currentInstance, key], true)
}
)
}
export function timelineComputations (store) { export function timelineComputations (store) {
computeForTimeline(store, 'timelineItemSummaries', null) computeForTimeline(store, 'timelineItemSummaries', null)
computeForTimeline(store, 'timelineItemSummariesToAdd', null) computeForTimeline(store, 'timelineItemSummariesToAdd', null)
@ -41,11 +75,93 @@ export function timelineComputations (store) {
getLastIdFromItemSummaries(timelineItemSummaries) getLastIdFromItemSummaries(timelineItemSummaries)
)) ))
computeTimelineFilter(store, 'timelineShowReblogs', { home: HOME_REBLOGS, notifications: NOTIFICATION_REBLOGS })
computeTimelineFilter(store, 'timelineShowReplies', { home: HOME_REPLIES })
computeTimelineFilter(store, 'timelineShowFollows', { notifications: NOTIFICATION_FOLLOWS })
computeTimelineFilter(store, 'timelineShowFavs', { notifications: NOTIFICATION_FAVORITES })
computeTimelineFilter(store, 'timelineShowMentions', { notifications: NOTIFICATION_MENTIONS })
computeTimelineFilter(store, 'timelineShowPolls', { notifications: NOTIFICATION_POLLS })
computeNotificationFilter(store, 'timelineNotificationShowReblogs', NOTIFICATION_REBLOGS)
computeNotificationFilter(store, 'timelineNotificationShowFollows', NOTIFICATION_FOLLOWS)
computeNotificationFilter(store, 'timelineNotificationShowFavs', NOTIFICATION_FAVORITES)
computeNotificationFilter(store, 'timelineNotificationShowMentions', NOTIFICATION_MENTIONS)
computeNotificationFilter(store, 'timelineNotificationShowPolls', NOTIFICATION_POLLS)
function createFilterFunction (showReblogs, showReplies, showFollows, showFavs, showMentions, showPolls) {
return item => {
switch (item.type) {
case 'poll':
return showPolls
case 'favourite':
return showFavs
case 'reblog':
return showReblogs
case 'mention':
return showMentions
case 'follow':
return showFollows
}
if (item.reblogId) {
return showReblogs
} else if (item.replyId) {
return showReplies
} else {
return true
}
}
}
store.compute(
'timelineFilterFunction',
[
'timelineShowReblogs', 'timelineShowReplies', 'timelineShowFollows',
'timelineShowFavs', 'timelineShowMentions', 'timelineShowPolls'
],
(showReblogs, showReplies, showFollows, showFavs, showMentions, showPolls) => (
createFilterFunction(showReblogs, showReplies, showFollows, showFavs, showMentions, showPolls)
)
)
store.compute(
'timelineNotificationFilterFunction',
[
'timelineNotificationShowReblogs', 'timelineNotificationShowFollows',
'timelineNotificationShowFavs', 'timelineNotificationShowMentions',
'timelineNotificationShowPolls'
],
(showReblogs, showFollows, showFavs, showMentions, showPolls) => (
createFilterFunction(showReblogs, true, showFollows, showFavs, showMentions, showPolls)
)
)
store.compute(
'filteredTimelineItemSummaries',
['timelineItemSummaries', 'timelineFilterFunction'],
(timelineItemSummaries, timelineFilterFunction) => {
return timelineItemSummaries && timelineItemSummaries.filter(timelineFilterFunction)
}
)
store.compute('timelineNotificationItemSummaries',
[`timelineData_timelineItemSummariesToAdd`, 'timelineFilterFunction', 'currentInstance'],
(root, timelineFilterFunction, currentInstance) => (
get(root, [currentInstance, 'notifications'])
)
)
store.compute(
'filteredTimelineNotificationItemSummaries',
['timelineNotificationItemSummaries', 'timelineNotificationFilterFunction'],
(timelineNotificationItemSummaries, timelineNotificationFilterFunction) => (
timelineNotificationItemSummaries && timelineNotificationItemSummaries.filter(timelineNotificationFilterFunction)
)
)
store.compute('numberOfNotifications', store.compute('numberOfNotifications',
[`timelineData_timelineItemSummariesToAdd`, 'currentInstance'], ['filteredTimelineNotificationItemSummaries'],
(root, currentInstance) => ( (filteredTimelineNotificationItemSummaries) => (
(root && root[currentInstance] && root[currentInstance].notifications && filteredTimelineNotificationItemSummaries ? filteredTimelineNotificationItemSummaries.length : 0
root[currentInstance].notifications.length) || 0
) )
) )

View File

@ -0,0 +1,47 @@
// For convenience, periodically re-compute the current time. This ensures freshness of
// displays like "x minutes ago" without having to jump through a lot of hoops.
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import lifecycle from 'page-lifecycle/dist/lifecycle.mjs'
const POLL_INTERVAL = 10000
export function nowObservers (store) {
let interval
function updateNow () {
store.set({ now: Date.now() })
}
function startPolling () {
interval = setInterval(() => scheduleIdleTask(updateNow), POLL_INTERVAL)
}
function stopPolling () {
if (interval) {
clearInterval(interval)
interval = null
}
}
function restartPolling () {
stopPolling()
scheduleIdleTask(updateNow)
startPolling()
}
updateNow()
if (process.browser) {
startPolling()
lifecycle.addEventListener('statechange', e => {
if (e.newState === 'passive') {
console.log('stopping Date.now() observer...')
stopPolling()
} else if (e.newState === 'active') {
console.log('restarting Date.now() observer...')
restartPolling()
}
})
}
}

View File

@ -1,4 +1,5 @@
import { onlineObservers } from './onlineObservers' import { onlineObservers } from './onlineObservers'
import { nowObservers } from './nowObservers'
import { navObservers } from './navObservers' import { navObservers } from './navObservers'
import { pageVisibilityObservers } from './pageVisibilityObservers' import { pageVisibilityObservers } from './pageVisibilityObservers'
import { resizeObservers } from './resizeObservers' import { resizeObservers } from './resizeObservers'
@ -8,6 +9,7 @@ import { touchObservers } from './touchObservers'
export function observers (store) { export function observers (store) {
onlineObservers(store) onlineObservers(store)
nowObservers(store)
navObservers(store) navObservers(store)
pageVisibilityObservers(store) pageVisibilityObservers(store)
resizeObservers(store) resizeObservers(store)

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 &&

View File

@ -4,7 +4,7 @@
* Contract: i@hust.cc * Contract: i@hust.cc
*/ */
var IndexMapEn = 'second_minute_hour_day_week_month_year'.split('_') var IndexMapEn = ['second', 'minute', 'hour', 'day', 'week', 'month', 'year']
var SEC_ARRAY = [60, 60, 24, 7, 365 / 7 / 12, 12] var SEC_ARRAY = [60, 60, 24, 7, 365 / 7 / 12, 12]
/** /**
@ -63,16 +63,14 @@ function formatDiff (diff) {
* @param nowDate * @param nowDate
* @returns {number} * @returns {number}
*/ */
function diffSec (date) { function diffSec (date, now) {
var nowDate = new Date() return (now - date) / 1000
var otherDate = new Date(date)
return (nowDate - otherDate) / 1000
} }
/** /**
* Created by hustcc on 18/5/20. * Created by hustcc on 18/5/20.
* Contract: i@hust.cc * Contract: i@hust.cc
*/ */
export function format (date) { export function format (date, now) {
return formatDiff(diffSec(date)) return formatDiff(diffSec(date, now))
} }

View File

@ -0,0 +1,17 @@
import { slide as svelteSlide } from 'svelte-transitions'
import { store } from '../_store/store'
import noop from 'lodash-es/noop'
// same as svelte-transitions, but respecting reduceMotion
export function slide (node, ref) {
let { reduceMotion } = store.get()
if (reduceMotion) {
return {
delay: 0,
duration: 1, // setting to 0 causes some kind of built-in duration
easing: _ => _,
css: noop
}
}
return svelteSlide(node, ref)
}

View File

@ -87,7 +87,7 @@
--status-direct-background: #{darken($body-bg-color, 5%)}; --status-direct-background: #{darken($body-bg-color, 5%)};
--main-theme-color: #{$main-theme-color}; --main-theme-color: #{$main-theme-color};
--warning-color: #{#e01f19}; --warning-color: #{#e01f19};
--alt-input-bg: #{rgba($main-bg-color, 0.7)}; --alt-input-bg: #{rgba($main-bg-color, 0.9)};
--muted-modal-text: #{$secondary-text-color}; --muted-modal-text: #{$secondary-text-color};
--muted-modal-bg: #{transparent}; --muted-modal-bg: #{transparent};
@ -112,4 +112,8 @@
--tooltip-bg: rgba(30, 30, 30, 0.9); --tooltip-bg: rgba(30, 30, 30, 0.9);
--tooltip-color: white; --tooltip-color: white;
--floating-button-bg: #{rgba($main-bg-color, 0.8)};
--floating-button-bg-hover: #{darken(rgba($main-bg-color, 0.9), 5%)};
--floating-button-bg-active: #{darken(rgba($main-bg-color, 0.9), 10%)};
} }

View File

@ -18,7 +18,7 @@
--status-direct-background: #{darken($body-bg-color, 5%)}; --status-direct-background: #{darken($body-bg-color, 5%)};
--main-theme-color: #{$main-theme-color}; --main-theme-color: #{$main-theme-color};
--warning-color: #{#c7423d}; --warning-color: #{#c7423d};
--alt-input-bg: #{rgba($main-bg-color, 0.7)}; --alt-input-bg: #{rgba($main-bg-color, 0.9)};
--muted-modal-bg: #{transparent}; --muted-modal-bg: #{transparent};
--muted-modal-focus: #{#999}; --muted-modal-focus: #{#999};

View File

@ -193,9 +193,8 @@ const cloneNotification = notification => {
// Object.assign() does not work with notifications // Object.assign() does not work with notifications
for (let k in notification) { for (let k in notification) {
if (notification.hasOwnProperty(k)) { // intentionally not doing a hasOwnProperty check
clone[k] = notification[k] clone[k] = notification[k]
}
} }
return clone return clone

View File

@ -9,6 +9,8 @@ import { followAccount, unfollowAccount } from '../src/routes/_api/follow'
import { updateCredentials } from '../src/routes/_api/updateCredentials' import { updateCredentials } from '../src/routes/_api/updateCredentials'
import { reblogStatus } from '../src/routes/_api/reblog' import { reblogStatus } from '../src/routes/_api/reblog'
import { submitMedia } from './submitMedia' import { submitMedia } from './submitMedia'
import { voteOnPoll } from '../src/routes/_api/polls'
import { POLL_EXPIRY_DEFAULT } from '../src/routes/_static/polls'
global.fetch = fetch global.fetch = fetch
global.File = FileApi.File global.File = FileApi.File
@ -68,3 +70,15 @@ export async function unfollowAs (username, userToFollow) {
export async function updateUserDisplayNameAs (username, displayName) { export async function updateUserDisplayNameAs (username, displayName) {
return updateCredentials(instanceName, users[username].accessToken, { display_name: 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()))
}

View File

@ -0,0 +1,93 @@
import {
settingsNavButton,
getNthStatusContent,
instanceSettingNotificationReblogs,
notificationBadge,
instanceSettingNotificationFavs,
instanceSettingNotificationMentions, instanceSettingNotificationFollows
} from '../utils'
import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'
import { favoriteStatusAs, followAs, postAs, reblogStatusAs, unfollowAs } from '../serverActions'
fixture`125-notification-timeline-filters.js`
.page`http://localhost:4002`
test('Notification timeline filters correctly affect counts - boosts', async t => {
let timeout = 20000
let { id: statusId } = await postAs('foobar', 'I do not care if you boost this')
await loginAsFoobar(t)
await t
.expect(getNthStatusContent(1).innerText).contains('I do not care if you boost this')
await reblogStatusAs('admin', statusId)
await t
.expect(notificationBadge.innerText).eql('1', { timeout })
.click(settingsNavButton)
.click($('a').withText('Instances'))
.click($('a').withText('localhost:3000'))
.click(instanceSettingNotificationReblogs)
.expect(instanceSettingNotificationReblogs.checked).notOk()
.expect(notificationBadge.exists).notOk({ timeout })
.click(instanceSettingNotificationReblogs)
.expect(instanceSettingNotificationReblogs.checked).ok()
.expect(notificationBadge.innerText).eql('1', { timeout })
})
test('Notification timeline filters correctly affect counts - favs', async t => {
let timeout = 20000
let { id: statusId } = await postAs('foobar', 'I do not care if you fav this')
await loginAsFoobar(t)
await t
.expect(getNthStatusContent(1).innerText).contains('I do not care if you fav this')
await favoriteStatusAs('admin', statusId)
await t
.expect(notificationBadge.innerText).eql('1', { timeout })
.click(settingsNavButton)
.click($('a').withText('Instances'))
.click($('a').withText('localhost:3000'))
.click(instanceSettingNotificationFavs)
.expect(instanceSettingNotificationFavs.checked).notOk()
.expect(notificationBadge.exists).notOk({ timeout })
.click(instanceSettingNotificationFavs)
.expect(instanceSettingNotificationFavs.checked).ok()
.expect(notificationBadge.innerText).eql('1', { timeout })
})
test('Notification timeline filters correctly affect counts - favs', async t => {
let timeout = 20000
await loginAsFoobar(t)
await t
.expect(getNthStatusContent(1).exists).ok()
await postAs('admin', 'hey yo @foobar')
await t
.expect(notificationBadge.innerText).eql('1', { timeout })
.click(settingsNavButton)
.click($('a').withText('Instances'))
.click($('a').withText('localhost:3000'))
.click(instanceSettingNotificationMentions)
.expect(instanceSettingNotificationMentions.checked).notOk()
.expect(notificationBadge.exists).notOk({ timeout })
.click(instanceSettingNotificationMentions)
.expect(instanceSettingNotificationMentions.checked).ok()
.expect(notificationBadge.innerText).eql('1', { timeout })
})
test('Notification timeline filters correctly affect counts - follows', async t => {
let timeout = 20000
await loginAsFoobar(t)
await t
.expect(getNthStatusContent(1).exists).ok()
await followAs('ExternalLinks', 'foobar')
await t
.expect(notificationBadge.innerText).eql('1', { timeout })
.click(settingsNavButton)
.click($('a').withText('Instances'))
.click($('a').withText('localhost:3000'))
.click(instanceSettingNotificationFollows)
.expect(instanceSettingNotificationFollows.checked).notOk()
.expect(notificationBadge.exists).notOk({ timeout })
.click(instanceSettingNotificationFollows)
.expect(instanceSettingNotificationMentions.checked).ok()
.expect(notificationBadge.innerText).eql('1', { timeout })
await unfollowAs('ExternalLinks', 'foobar')
})

94
tests/spec/126-polls.js Normal file
View File

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

View File

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

View File

@ -22,8 +22,9 @@ 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 pollButton = $('.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 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 +57,12 @@ 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 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 postPrivacyDialogButtonUnlisted = $('[aria-label="Post privacy dialog"] li:nth-child(2) button')
@ -73,8 +79,10 @@ export const instanceSettingNotificationFavs = $('#instance-option-notificationF
export const instanceSettingNotificationReblogs = $('#instance-option-notificationReblogs') export const instanceSettingNotificationReblogs = $('#instance-option-notificationReblogs')
export const instanceSettingNotificationMentions = $('#instance-option-notificationMentions') export const instanceSettingNotificationMentions = $('#instance-option-notificationMentions')
export const notificationBadge = $('#main-nav li:nth-child(2) .nav-link-badge')
export function getComposeModalNthMediaAltInput (n) { export function getComposeModalNthMediaAltInput (n) {
return $(`.modal-dialog .compose-media:nth-child(${n}) .compose-media-alt input`) return $(`.modal-dialog .compose-media:nth-child(${n}) .compose-media-alt textarea`)
} }
export function getComposeModalNthMediaImg (n) { export function getComposeModalNthMediaImg (n) {
@ -203,7 +211,7 @@ export const getScrollTop = exec(() => {
}) })
export function getNthMediaAltInput (n) { export function getNthMediaAltInput (n) {
return $(`.compose-box .compose-media:nth-child(${n}) .compose-media-alt input`) return $(`.compose-box .compose-media:nth-child(${n}) .compose-media-alt textarea`)
} }
export function getNthComposeReplyInput (n) { export function getNthComposeReplyInput (n) {
@ -215,7 +223,39 @@ 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 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) { export function getNthAutosuggestionResult (n) {
@ -299,11 +339,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) {

View File

@ -6972,10 +6972,10 @@ string_decoder@~1.1.1:
dependencies: dependencies:
safe-buffer "~5.1.0" safe-buffer "~5.1.0"
stringz@^1.0.0: stringz@^2.0.0:
version "1.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/stringz/-/stringz-1.0.0.tgz#d2acba994e4ce3c725ee15c86fff4281280d2025" resolved "https://registry.yarnpkg.com/stringz/-/stringz-2.0.0.tgz#0a092bc64ed9b42eff2936d0401d2398393d54e9"
integrity sha512-oaqFaIAmw1MJmdPNiBqocHHrC0VzJTL3CI1z5uXm3NQSE3AyDU152ZPTSJSOKk+9z1Cm3LZzgLFjCTb8SXZvag== integrity sha512-pRWc5RGpedKEDvQ/ukYs8kS8tKj+cKu5ayOoyOvsavbpiLBcm1dGX6p1o5IagaN11cbfN8tKGpgQ4fHdEq5LBA==
dependencies: dependencies:
unicode-astral-regex "^1.0.1" unicode-astral-regex "^1.0.1"