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-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' }
]

View File

@ -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",

View File

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

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'
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)) {

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()" />
<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)
}
}
}

View File

@ -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;

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} />
<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,

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()"
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)
}
}
}
}

View File

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

View File

@ -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: {

View File

@ -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 () {

View File

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

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

View File

@ -1,38 +1,96 @@
<div class="poll" >
<ul class="options" aria-label="Poll results">
{#each options as option}
<li class="option">
<div class="option-text">{option.title} ({option.share}%)</div>
<svg aria-hidden="true">
<line x1="0" y1="0" x2="{option.share}%" y2="0" />
</svg>
</li>
{/each}
</ul>
<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">
<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>

View File

@ -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}`
},

View File

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

View File

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

View File

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

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 { 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
)
)

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 { 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)

View File

@ -39,6 +39,7 @@ const nonPersistedState = {
instanceLists: {},
online: !process.browser || navigator.onLine,
pinnedStatuses: {},
polls: {},
pushNotificationsSupport:
process.browser &&
('serviceWorker' in navigator &&

View File

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

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%)};
--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%)};
}

View File

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

View File

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

View File

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

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 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) {

View File

@ -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"