Merge branch 'master' into leftie
This commit is contained in:
commit
77aead72fb
|
@ -42,9 +42,14 @@ 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' },
|
||||
{ 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' }
|
||||
]
|
||||
|
|
|
@ -90,7 +90,7 @@
|
|||
"rollup-plugin-replace": "^2.2.0",
|
||||
"rollup-plugin-terser": "^5.0.0",
|
||||
"sapper": "nolanlawson/sapper#for-pinafore-14",
|
||||
"stringz": "^1.0.0",
|
||||
"stringz": "^2.0.0",
|
||||
"svelte": "^2.16.1",
|
||||
"svelte-extras": "^2.0.2",
|
||||
"svelte-loader": "^2.13.3",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
|
@ -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 || ''))
|
||||
}
|
||||
}
|
|
@ -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 })
|
||||
}
|
|
@ -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)) {
|
||||
|
|
|
@ -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>
|
|
@ -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 { slide } from '../../_transitions/slide'
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,18 +1,22 @@
|
|||
{#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}
|
||||
<ComposeMediaItem {realm} {mediaItem} {index} {media} />
|
||||
{/each}
|
||||
</div>
|
||||
</ul>
|
||||
{/if}
|
||||
<style>
|
||||
.compose-media-container {
|
||||
grid-area: media;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-column-gap: 5px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
margin: 10px 0 0 0;
|
||||
background: var(--form-bg);
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
|
|
|
@ -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} />
|
||||
<div class="compose-media-delete">
|
||||
<button class="compose-media-delete-button"
|
||||
|
@ -8,19 +8,21 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="compose-media-alt">
|
||||
<input id="compose-media-input-{uuid}"
|
||||
type="text"
|
||||
<textarea id="compose-media-input-{uuid}"
|
||||
class="compose-media-alt-input"
|
||||
placeholder="Description"
|
||||
placeholder="Describe for the visually impaired"
|
||||
ref:textarea
|
||||
bind:value=rawText
|
||||
>
|
||||
></textarea>
|
||||
<label for="compose-media-input-{uuid}" class="sr-only">
|
||||
Describe {shortName} for the visually impaired
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<style>
|
||||
.compose-media {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
|
@ -49,6 +51,9 @@
|
|||
font-size: 1.2em;
|
||||
background: var(--alt-input-bg);
|
||||
color: var(--body-text-color);
|
||||
max-height: 100px;
|
||||
border: 1px solid var(--input-border);
|
||||
resize: none;
|
||||
}
|
||||
.compose-media-alt-input:focus {
|
||||
background: var(--main-bg);
|
||||
|
@ -64,12 +69,15 @@
|
|||
margin: 2px;
|
||||
}
|
||||
.compose-media-delete-button {
|
||||
padding: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 7px 10px 5px;
|
||||
background: var(--floating-button-bg);
|
||||
border: 1px solid var(--button-border);
|
||||
}
|
||||
.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) {
|
||||
fill: var(--button-text);
|
||||
|
@ -85,6 +93,9 @@
|
|||
.compose-media-realm-dialog {
|
||||
max-height: 15vh;
|
||||
}
|
||||
.compose-media-alt-input {
|
||||
max-height: 7vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
|
@ -94,11 +105,16 @@
|
|||
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
|
||||
import { observe } from 'svelte-extras'
|
||||
import SvgIcon from '../SvgIcon.html'
|
||||
import { autosize } from '../../_thirdparty/autosize/autosize'
|
||||
|
||||
export default {
|
||||
oncreate () {
|
||||
this.setupSyncFromStore()
|
||||
this.setupSyncToStore()
|
||||
this.setupAutosize()
|
||||
},
|
||||
ondestroy () {
|
||||
this.teardownAutosize()
|
||||
},
|
||||
data: () => ({
|
||||
rawText: ''
|
||||
|
@ -139,6 +155,12 @@
|
|||
saveStore()
|
||||
}, { init: false })
|
||||
},
|
||||
setupAutosize () {
|
||||
autosize(this.refs.textarea)
|
||||
},
|
||||
teardownAutosize () {
|
||||
autosize.destroy(this.refs.textarea)
|
||||
},
|
||||
onDeleteMedia () {
|
||||
let {
|
||||
realm,
|
||||
|
|
|
@ -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>
|
|
@ -12,6 +12,13 @@
|
|||
on:click="onMediaClick()"
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,11 +13,11 @@
|
|||
<StatusAuthorName {...params} />
|
||||
<StatusAuthorHandle {...params} />
|
||||
{#if !isStatusInOwnThread}
|
||||
<StatusRelativeDate {...params} />
|
||||
<StatusRelativeDate {...params} {...timestampParams} />
|
||||
{/if}
|
||||
<StatusSidebar {...params} />
|
||||
{#if spoilerText}
|
||||
<StatusSpoiler {...params} on:recalculateHeight />
|
||||
<StatusSpoiler {...params} {spoilerShown} on:recalculateHeight />
|
||||
{/if}
|
||||
{#if !showContent}
|
||||
<StatusMentions {...params} />
|
||||
|
@ -35,9 +35,9 @@
|
|||
<StatusPoll {...params} />
|
||||
{/if}
|
||||
{#if isStatusInOwnThread}
|
||||
<StatusDetails {...params} />
|
||||
<StatusDetails {...params} {...timestampParams} />
|
||||
{/if}
|
||||
<StatusToolbar {...params} on:recalculateHeight />
|
||||
<StatusToolbar {...params} {replyShown} on:recalculateHeight />
|
||||
{#if replyShown}
|
||||
<StatusComposeBox {...params} on:recalculateHeight />
|
||||
{/if}
|
||||
|
@ -144,7 +144,7 @@
|
|||
import { createStatusOrNotificationUuid } from '../../_utils/createStatusOrNotificationUuid'
|
||||
import { statusHtmlToPlainText } from '../../_utils/statusHtmlToPlainText'
|
||||
|
||||
const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea'])
|
||||
const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea', 'label'])
|
||||
const isUserInputElement = node => INPUT_TAGS.has(node.localName)
|
||||
const isToolbar = node => node.classList.contains('status-toolbar')
|
||||
const isStatusArticle = node => node.classList.contains('status-article')
|
||||
|
@ -268,8 +268,8 @@
|
|||
originalStatus.card &&
|
||||
originalStatus.card.title
|
||||
),
|
||||
showPoll: ({ originalStatus, isStatusInNotification }) => (
|
||||
!isStatusInNotification && originalStatus.poll
|
||||
showPoll: ({ originalStatus }) => (
|
||||
originalStatus.poll
|
||||
),
|
||||
showMedia: ({ originalStatus, isStatusInNotification }) => (
|
||||
!isStatusInNotification &&
|
||||
|
@ -277,13 +277,15 @@
|
|||
originalStatus.media_attachments.length
|
||||
),
|
||||
originalAccountEmojis: ({ originalAccount }) => (originalAccount.emojis || []),
|
||||
originalStatusEmojis: ({ originalStatus }) => (originalStatus.emojis || []),
|
||||
originalAccountDisplayName: ({ originalAccount }) => (originalAccount.display_name || originalAccount.username),
|
||||
originalAccountAccessibleName: ({ originalAccount, $omitEmojiInDisplayNames }) => {
|
||||
return getAccountAccessibleName(originalAccount, $omitEmojiInDisplayNames)
|
||||
},
|
||||
createdAtDate: ({ originalStatus }) => originalStatus.created_at,
|
||||
absoluteFormattedDate: ({ createdAtDate }) => absoluteDateFormatter.format(new Date(createdAtDate)),
|
||||
timeagoFormattedDate: ({ createdAtDate }) => formatTimeagoDate(createdAtDate),
|
||||
createdAtDateTS: ({ createdAtDate }) => new Date(createdAtDate).getTime(),
|
||||
absoluteFormattedDate: ({ createdAtDateTS }) => absoluteDateFormatter.format(createdAtDateTS),
|
||||
timeagoFormattedDate: ({ createdAtDateTS, $now }) => formatTimeagoDate(createdAtDateTS, $now),
|
||||
reblog: ({ status }) => status.reblog,
|
||||
ariaLabel: ({ originalAccount, account, plainTextContent, timeagoFormattedDate, spoilerText,
|
||||
showContent, reblog, notification, visibility, $omitEmojiInDisplayNames, $disableLongAriaLabels }) => (
|
||||
|
@ -292,7 +294,7 @@
|
|||
reblog, notification, visibility, $omitEmojiInDisplayNames, $disableLongAriaLabels)
|
||||
),
|
||||
showHeader: ({ notification, status, timelineType }) => (
|
||||
(notification && (notification.type === 'reblog' || notification.type === 'favourite')) ||
|
||||
(notification && ['reblog', 'favourite', 'poll'].includes(notification.type)) ||
|
||||
status.reblog ||
|
||||
timelineType === 'pinned'
|
||||
),
|
||||
|
@ -307,11 +309,22 @@
|
|||
)),
|
||||
content: ({ originalStatus }) => originalStatus.content || '',
|
||||
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,
|
||||
account, accountId, uuid, isStatusInNotification, isStatusInOwnThread,
|
||||
originalAccount, originalAccountId, spoilerShown, visibility, replyShown,
|
||||
originalAccount, originalAccountId, visibility,
|
||||
replyVisibility, spoilerText, originalStatus, originalStatusId, inReplyToId,
|
||||
createdAtDate, timeagoFormattedDate, enableShortcuts, absoluteFormattedDate, shortcutScope }) => ({
|
||||
enableShortcuts, shortcutScope, originalStatusEmojis }) => ({
|
||||
notification,
|
||||
notificationId,
|
||||
status,
|
||||
|
@ -324,19 +337,15 @@
|
|||
isStatusInOwnThread,
|
||||
originalAccount,
|
||||
originalAccountId,
|
||||
spoilerShown,
|
||||
visibility,
|
||||
replyShown,
|
||||
replyVisibility,
|
||||
spoilerText,
|
||||
originalStatus,
|
||||
originalStatusId,
|
||||
inReplyToId,
|
||||
createdAtDate,
|
||||
timeagoFormattedDate,
|
||||
enableShortcuts,
|
||||
absoluteFormattedDate,
|
||||
shortcutScope
|
||||
shortcutScope,
|
||||
originalStatusEmojis
|
||||
})
|
||||
},
|
||||
events: {
|
||||
|
|
|
@ -76,8 +76,9 @@
|
|||
)
|
||||
},
|
||||
content: ({ originalStatus }) => (originalStatus.content || ''),
|
||||
emojis: ({ originalStatus }) => originalStatus.emojis,
|
||||
massagedContent: ({ content, emojis, $autoplayGifs }) => massageUserText(content, emojis, $autoplayGifs)
|
||||
massagedContent: ({ content, originalStatusEmojis, $autoplayGifs }) => (
|
||||
massageUserText(content, originalStatusEmojis, $autoplayGifs)
|
||||
)
|
||||
},
|
||||
methods: {
|
||||
hydrateContent () {
|
||||
|
|
|
@ -158,7 +158,6 @@
|
|||
application: ({ originalStatus }) => originalStatus.application,
|
||||
applicationName: ({ application }) => (application && application.name),
|
||||
applicationWebsite: ({ application }) => (application && application.website),
|
||||
createdAtDate: ({ originalStatus }) => originalStatus.created_at,
|
||||
numReblogs: ({ overrideNumReblogs, originalStatus }) => {
|
||||
if (typeof overrideNumReblogs === 'number') {
|
||||
return overrideNumReblogs
|
||||
|
@ -171,8 +170,8 @@
|
|||
}
|
||||
return originalStatus.favourites_count || 0
|
||||
},
|
||||
displayAbsoluteFormattedDate: ({ createdAtDate, $isMobileSize }) => (
|
||||
$isMobileSize ? shortAbsoluteDateFormatter : absoluteDateFormatter).format(new Date(createdAtDate)
|
||||
displayAbsoluteFormattedDate: ({ createdAtDateTS, $isMobileSize }) => (
|
||||
($isMobileSize ? shortAbsoluteDateFormatter : absoluteDateFormatter).format(createdAtDateTS)
|
||||
),
|
||||
reblogsLabel: ({ numReblogs }) => {
|
||||
// TODO: intl
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<div class="status-header {isStatusInNotification ? 'status-in-notification' : ''} {notification && notification.type === 'follow' ? 'header-is-follow' : ''}">
|
||||
<div class="status-header-avatar {timelineType === 'pinned' ? 'hidden' : ''}">
|
||||
<div class="status-header {isStatusInNotification ? 'status-in-notification' : ''} {notificationType === 'follow' ? 'header-is-follow' : ''}">
|
||||
<div class="status-header-avatar {timelineType === 'pinned' || notificationType === 'poll' ? 'hidden' : ''}">
|
||||
<Avatar {account} size="extra-small"/>
|
||||
</div>
|
||||
<SvgIcon className="status-header-svg" href={icon} />
|
||||
|
@ -9,7 +9,7 @@
|
|||
<span class="status-header-author">
|
||||
Pinned toot
|
||||
</span>
|
||||
{:else}
|
||||
{:elseif notificationType !== 'poll'}
|
||||
<a id={elementId}
|
||||
href="/accounts/{accountId}"
|
||||
rel="prefetch"
|
||||
|
@ -20,17 +20,7 @@
|
|||
</a>
|
||||
{/if}
|
||||
|
||||
<span class="status-header-action">
|
||||
{#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>
|
||||
<span class="status-header-action">{actionText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
|
@ -105,6 +95,7 @@
|
|||
import Avatar from '../Avatar.html'
|
||||
import AccountDisplayName from '../profile/AccountDisplayName.html'
|
||||
import SvgIcon from '../SvgIcon.html'
|
||||
import { store } from '../../_store/store'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
@ -112,17 +103,40 @@
|
|||
AccountDisplayName,
|
||||
SvgIcon
|
||||
},
|
||||
store: () => store,
|
||||
computed: {
|
||||
elementId: ({ uuid }) => `status-header-${uuid}`,
|
||||
icon: ({ notification, status, timelineType }) => {
|
||||
notificationType: ({ notification }) => notification && notification.type,
|
||||
icon: ({ notificationType, status, timelineType }) => {
|
||||
if (timelineType === 'pinned') {
|
||||
return '#fa-thumb-tack'
|
||||
} else if ((notification && notification.type === 'reblog') || (status && status.reblog)) {
|
||||
} else if ((notificationType === 'reblog') || (status && status.reblog)) {
|
||||
return '#fa-retweet'
|
||||
} else if (notification && notification.type === 'follow') {
|
||||
} else if (notificationType === 'follow') {
|
||||
return '#fa-user-plus'
|
||||
} else if (notificationType === 'poll') {
|
||||
return '#fa-bar-chart'
|
||||
}
|
||||
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 ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,38 +1,96 @@
|
|||
<div class="poll" >
|
||||
<ul class="options" aria-label="Poll results">
|
||||
<div class={computedClass} aria-busy={loading} >
|
||||
{#if voted || expired }
|
||||
<ul aria-label="Poll results">
|
||||
{#each options as option}
|
||||
<li class="option">
|
||||
<div class="option-text">{option.title} ({option.share}%)</div>
|
||||
<div class="option-text">
|
||||
<strong>{option.share}%</strong> {option.title}
|
||||
</div>
|
||||
<svg aria-hidden="true">
|
||||
<line x1="0" y1="0" x2="{option.share}%" y2="0" />
|
||||
</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>
|
||||
<style>
|
||||
.poll {
|
||||
grid-area: poll;
|
||||
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;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li.option {
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.option {
|
||||
margin: 0 0 10px 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
stroke: var(--svg-fill);
|
||||
stroke-width: 5px;
|
||||
}
|
||||
|
||||
li.option:last-child {
|
||||
margin: 0;
|
||||
stroke-width: 10px;
|
||||
}
|
||||
|
||||
.option-text {
|
||||
|
@ -42,20 +100,228 @@
|
|||
}
|
||||
|
||||
svg {
|
||||
height: 2px;
|
||||
height: 10px;
|
||||
width: 100%;
|
||||
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>
|
||||
<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 {
|
||||
oncreate () {
|
||||
this.onRefreshClick = this.onRefreshClick.bind(this)
|
||||
let { refreshElementId } = this.get()
|
||||
registerClickDelegate(this, refreshElementId, this.onRefreshClick)
|
||||
},
|
||||
data: () => ({
|
||||
loading: false,
|
||||
choices: []
|
||||
}),
|
||||
store: () => store,
|
||||
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 }) => ({
|
||||
title,
|
||||
share: poll.votes_count ? Math.round(votesCount / poll.votes_count * 100) : 0
|
||||
}))
|
||||
})),
|
||||
votesCount: ({ poll }) => poll.votes_count,
|
||||
voted: ({ poll }) => poll.voted,
|
||||
multiple: ({ poll }) => poll.multiple,
|
||||
expired: ({ poll }) => poll.expired,
|
||||
expiresAt: ({ poll }) => poll.expires_at,
|
||||
expiresAtTS: ({ expiresAt }) => new Date(expiresAt).getTime(),
|
||||
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>
|
||||
|
|
|
@ -64,10 +64,9 @@
|
|||
Shortcut
|
||||
},
|
||||
computed: {
|
||||
emojis: ({ originalStatus }) => originalStatus.emojis,
|
||||
massagedSpoilerText: ({ spoilerText, emojis, $autoplayGifs }) => {
|
||||
massagedSpoilerText: ({ spoilerText, originalStatusEmojis, $autoplayGifs }) => {
|
||||
spoilerText = escapeHtml(spoilerText)
|
||||
return emojifyText(spoilerText, emojis, $autoplayGifs)
|
||||
return emojifyText(spoilerText, originalStatusEmojis, $autoplayGifs)
|
||||
},
|
||||
elementId: ({ uuid }) => `spoiler-${uuid}`
|
||||
},
|
||||
|
|
|
@ -59,22 +59,6 @@
|
|||
import { observe } from 'svelte-extras'
|
||||
import { createMakeProps } from '../../_actions/createMakeProps'
|
||||
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 {
|
||||
oncreate () {
|
||||
|
@ -143,59 +127,10 @@
|
|||
timelineValue !== $firstTimelineItemId &&
|
||||
timelineValue
|
||||
),
|
||||
currentInstanceSettings: ({ $currentInstance, $instanceSettings }) => (
|
||||
$instanceSettings[$currentInstance] || {}
|
||||
),
|
||||
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)
|
||||
itemIds: ({ $filteredTimelineItemSummaries }) => (
|
||||
$filteredTimelineItemSummaries && $filteredTimelineItemSummaries.map(_ => _.id)
|
||||
),
|
||||
itemIdsToAdd: ({ $timelineItemSummariesToAdd }) => (
|
||||
// TODO: filter
|
||||
$timelineItemSummariesToAdd && $timelineItemSummariesToAdd.map(_ => _.id)
|
||||
),
|
||||
headerProps: ({ itemIdsToAdd }) => {
|
||||
|
|
|
@ -1,9 +1,20 @@
|
|||
import { format } from '../_thirdparty/timeago/timeago'
|
||||
import { mark, stop } from '../_utils/marks'
|
||||
|
||||
export function formatTimeagoDate (date) {
|
||||
// Format a date in the past
|
||||
export function formatTimeagoDate (date, now) {
|
||||
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')
|
||||
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
|
||||
}
|
||||
|
|
|
@ -6,10 +6,3 @@ export const NOTIFICATION_FAVORITES = 'notificationFavs'
|
|||
export const NOTIFICATION_FOLLOWS = 'notificationFollows'
|
||||
export const NOTIFICATION_MENTIONS = 'notificationMentions'
|
||||
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'
|
||||
|
|
|
@ -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
|
|
@ -1,5 +1,14 @@
|
|||
import { get } from '../../_utils/lodash-lite'
|
||||
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) {
|
||||
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) {
|
||||
computeForTimeline(store, 'timelineItemSummaries', null)
|
||||
computeForTimeline(store, 'timelineItemSummariesToAdd', null)
|
||||
|
@ -41,11 +75,93 @@ export function timelineComputations (store) {
|
|||
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',
|
||||
[`timelineData_timelineItemSummariesToAdd`, 'currentInstance'],
|
||||
(root, currentInstance) => (
|
||||
(root && root[currentInstance] && root[currentInstance].notifications &&
|
||||
root[currentInstance].notifications.length) || 0
|
||||
['filteredTimelineNotificationItemSummaries'],
|
||||
(filteredTimelineNotificationItemSummaries) => (
|
||||
filteredTimelineNotificationItemSummaries ? filteredTimelineNotificationItemSummaries.length : 0
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { onlineObservers } from './onlineObservers'
|
||||
import { nowObservers } from './nowObservers'
|
||||
import { navObservers } from './navObservers'
|
||||
import { pageVisibilityObservers } from './pageVisibilityObservers'
|
||||
import { resizeObservers } from './resizeObservers'
|
||||
|
@ -8,6 +9,7 @@ import { touchObservers } from './touchObservers'
|
|||
|
||||
export function observers (store) {
|
||||
onlineObservers(store)
|
||||
nowObservers(store)
|
||||
navObservers(store)
|
||||
pageVisibilityObservers(store)
|
||||
resizeObservers(store)
|
||||
|
|
|
@ -39,6 +39,7 @@ const nonPersistedState = {
|
|||
instanceLists: {},
|
||||
online: !process.browser || navigator.onLine,
|
||||
pinnedStatuses: {},
|
||||
polls: {},
|
||||
pushNotificationsSupport:
|
||||
process.browser &&
|
||||
('serviceWorker' in navigator &&
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* 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]
|
||||
|
||||
/**
|
||||
|
@ -63,16 +63,14 @@ function formatDiff (diff) {
|
|||
* @param nowDate
|
||||
* @returns {number}
|
||||
*/
|
||||
function diffSec (date) {
|
||||
var nowDate = new Date()
|
||||
var otherDate = new Date(date)
|
||||
return (nowDate - otherDate) / 1000
|
||||
function diffSec (date, now) {
|
||||
return (now - date) / 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Created by hustcc on 18/5/20.
|
||||
* Contract: i@hust.cc
|
||||
*/
|
||||
export function format (date) {
|
||||
return formatDiff(diffSec(date))
|
||||
export function format (date, now) {
|
||||
return formatDiff(diffSec(date, now))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -87,7 +87,7 @@
|
|||
--status-direct-background: #{darken($body-bg-color, 5%)};
|
||||
--main-theme-color: #{$main-theme-color};
|
||||
--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-bg: #{transparent};
|
||||
|
@ -112,4 +112,8 @@
|
|||
|
||||
--tooltip-bg: rgba(30, 30, 30, 0.9);
|
||||
--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%)};
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
--status-direct-background: #{darken($body-bg-color, 5%)};
|
||||
--main-theme-color: #{$main-theme-color};
|
||||
--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-focus: #{#999};
|
||||
|
|
|
@ -193,10 +193,9 @@ const cloneNotification = notification => {
|
|||
|
||||
// Object.assign() does not work with notifications
|
||||
for (let k in notification) {
|
||||
if (notification.hasOwnProperty(k)) {
|
||||
// intentionally not doing a hasOwnProperty check
|
||||
clone[k] = notification[k]
|
||||
}
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@ import { followAccount, unfollowAccount } from '../src/routes/_api/follow'
|
|||
import { updateCredentials } from '../src/routes/_api/updateCredentials'
|
||||
import { reblogStatus } from '../src/routes/_api/reblog'
|
||||
import { submitMedia } from './submitMedia'
|
||||
import { voteOnPoll } from '../src/routes/_api/polls'
|
||||
import { POLL_EXPIRY_DEFAULT } from '../src/routes/_static/polls'
|
||||
|
||||
global.fetch = fetch
|
||||
global.File = FileApi.File
|
||||
|
@ -68,3 +70,15 @@ export async function unfollowAs (username, userToFollow) {
|
|||
export async function updateUserDisplayNameAs (username, displayName) {
|
||||
return updateCredentials(instanceName, users[username].accessToken, { display_name: displayName })
|
||||
}
|
||||
|
||||
export async function createPollAs (username, content, options, multiple) {
|
||||
return postStatus(instanceName, users[username].accessToken, content, null, null, false, null, 'public', {
|
||||
options,
|
||||
multiple,
|
||||
expires_in: POLL_EXPIRY_DEFAULT
|
||||
})
|
||||
}
|
||||
|
||||
export async function voteOnPollAs (username, pollId, choices) {
|
||||
return voteOnPoll(instanceName, users[username].accessToken, pollId, choices.map(_ => _.toString()))
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
})
|
|
@ -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')
|
||||
})
|
|
@ -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')
|
||||
})
|
|
@ -22,8 +22,9 @@ 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 pollButton = $('.compose-box-toolbar button:nth-child(3)')
|
||||
export const postPrivacyButton = $('.compose-box-toolbar button:nth-child(4)')
|
||||
export const contentWarningButton = $('.compose-box-toolbar button:nth-child(5)')
|
||||
export const emailInput = $('input#user_email')
|
||||
export const passwordInput = $('input#user_password')
|
||||
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 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 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')
|
||||
|
||||
|
@ -73,8 +79,10 @@ export const instanceSettingNotificationFavs = $('#instance-option-notificationF
|
|||
export const instanceSettingNotificationReblogs = $('#instance-option-notificationReblogs')
|
||||
export const instanceSettingNotificationMentions = $('#instance-option-notificationMentions')
|
||||
|
||||
export const notificationBadge = $('#main-nav li:nth-child(2) .nav-link-badge')
|
||||
|
||||
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) {
|
||||
|
@ -203,7 +211,7 @@ export const getScrollTop = exec(() => {
|
|||
})
|
||||
|
||||
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) {
|
||||
|
@ -215,7 +223,39 @@ 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 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) {
|
||||
|
@ -299,11 +339,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) {
|
||||
|
|
|
@ -6972,10 +6972,10 @@ string_decoder@~1.1.1:
|
|||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
stringz@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/stringz/-/stringz-1.0.0.tgz#d2acba994e4ce3c725ee15c86fff4281280d2025"
|
||||
integrity sha512-oaqFaIAmw1MJmdPNiBqocHHrC0VzJTL3CI1z5uXm3NQSE3AyDU152ZPTSJSOKk+9z1Cm3LZzgLFjCTb8SXZvag==
|
||||
stringz@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/stringz/-/stringz-2.0.0.tgz#0a092bc64ed9b42eff2936d0401d2398393d54e9"
|
||||
integrity sha512-pRWc5RGpedKEDvQ/ukYs8kS8tKj+cKu5ayOoyOvsavbpiLBcm1dGX6p1o5IagaN11cbfN8tKGpgQ4fHdEq5LBA==
|
||||
dependencies:
|
||||
unicode-astral-regex "^1.0.1"
|
||||
|
||||
|
|
Loading…
Reference in New Issue