diff --git a/bin/svgs.js b/bin/svgs.js index 4f322b6..04a78d7 100644 --- a/bin/svgs.js +++ b/bin/svgs.js @@ -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' } ] diff --git a/package.json b/package.json index af810fe..85ffc12 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/routes/_actions/compose.js b/src/routes/_actions/compose.js index 6074e5c..a97b874 100644 --- a/src/routes/_actions/compose.js +++ b/src/routes/_actions/compose.js @@ -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) diff --git a/src/routes/_actions/composePoll.js b/src/routes/_actions/composePoll.js new file mode 100644 index 0000000..020fe5a --- /dev/null +++ b/src/routes/_actions/composePoll.js @@ -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 + }) +} diff --git a/src/routes/_actions/polls.js b/src/routes/_actions/polls.js new file mode 100644 index 0000000..b419581 --- /dev/null +++ b/src/routes/_actions/polls.js @@ -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 || '')) + } +} diff --git a/src/routes/_api/polls.js b/src/routes/_api/polls.js new file mode 100644 index 0000000..f3cd5ae --- /dev/null +++ b/src/routes/_api/polls.js @@ -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 }) +} diff --git a/src/routes/_api/statuses.js b/src/routes/_api/statuses.js index 506ca13..13a5649 100644 --- a/src/routes/_api/statuses.js +++ b/src/routes/_api/statuses.js @@ -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)) { diff --git a/src/routes/_components/Select.html b/src/routes/_components/Select.html new file mode 100644 index 0000000..22d9006 --- /dev/null +++ b/src/routes/_components/Select.html @@ -0,0 +1,76 @@ +
+ +
+ +
+
+ + diff --git a/src/routes/_components/compose/ComposeBox.html b/src/routes/_components/compose/ComposeBox.html index 167e0bf..d5a3883 100644 --- a/src/routes/_components/compose/ComposeBox.html +++ b/src/routes/_components/compose/ComposeBox.html @@ -13,7 +13,13 @@ - + {#if poll && poll.options && poll.options.length} +
+ +
+ {/if} + @@ -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) } } } diff --git a/src/routes/_components/compose/ComposeMedia.html b/src/routes/_components/compose/ComposeMedia.html index 421c2e1..df163f2 100644 --- a/src/routes/_components/compose/ComposeMedia.html +++ b/src/routes/_components/compose/ComposeMedia.html @@ -1,18 +1,22 @@ {#if media.length} -
+
    {#each media as mediaItem, index} {/each} -
+ {/if} diff --git a/src/routes/_components/compose/ComposeToolbar.html b/src/routes/_components/compose/ComposeToolbar.html index fd0b447..68b1b7f 100644 --- a/src/routes/_components/compose/ComposeToolbar.html +++ b/src/routes/_components/compose/ComposeToolbar.html @@ -12,6 +12,13 @@ on:click="onMediaClick()" disabled={$uploadingMedia || (media.length === 4)} /> + \ No newline at end of file + diff --git a/src/routes/_components/status/Status.html b/src/routes/_components/status/Status.html index c9c4f75..853ff8f 100644 --- a/src/routes/_components/status/Status.html +++ b/src/routes/_components/status/Status.html @@ -13,11 +13,11 @@ {#if !isStatusInOwnThread} - + {/if} {#if spoilerText} - + {/if} {#if !showContent} @@ -35,9 +35,9 @@ {/if} {#if isStatusInOwnThread} - + {/if} - + {#if replyShown} {/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: { diff --git a/src/routes/_components/status/StatusContent.html b/src/routes/_components/status/StatusContent.html index 1f2b121..043c6da 100644 --- a/src/routes/_components/status/StatusContent.html +++ b/src/routes/_components/status/StatusContent.html @@ -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 () { diff --git a/src/routes/_components/status/StatusDetails.html b/src/routes/_components/status/StatusDetails.html index cc9cf95..d3c4b2a 100644 --- a/src/routes/_components/status/StatusDetails.html +++ b/src/routes/_components/status/StatusDetails.html @@ -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 diff --git a/src/routes/_components/status/StatusHeader.html b/src/routes/_components/status/StatusHeader.html index fabea6e..2708556 100644 --- a/src/routes/_components/status/StatusHeader.html +++ b/src/routes/_components/status/StatusHeader.html @@ -1,5 +1,5 @@ -
- diff --git a/src/routes/_components/status/StatusSpoiler.html b/src/routes/_components/status/StatusSpoiler.html index 3d54621..acabf18 100644 --- a/src/routes/_components/status/StatusSpoiler.html +++ b/src/routes/_components/status/StatusSpoiler.html @@ -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}` }, diff --git a/src/routes/_components/timeline/Timeline.html b/src/routes/_components/timeline/Timeline.html index 7efd9cb..c33a4ff 100644 --- a/src/routes/_components/timeline/Timeline.html +++ b/src/routes/_components/timeline/Timeline.html @@ -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 }) => { diff --git a/src/routes/_intl/formatTimeagoDate.js b/src/routes/_intl/formatTimeagoDate.js index ec63b7c..d0ef1df 100644 --- a/src/routes/_intl/formatTimeagoDate.js +++ b/src/routes/_intl/formatTimeagoDate.js @@ -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 +} diff --git a/src/routes/_static/instanceSettings.js b/src/routes/_static/instanceSettings.js index db45398..83787f8 100644 --- a/src/routes/_static/instanceSettings.js +++ b/src/routes/_static/instanceSettings.js @@ -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' diff --git a/src/routes/_static/polls.js b/src/routes/_static/polls.js new file mode 100644 index 0000000..2b5bc74 --- /dev/null +++ b/src/routes/_static/polls.js @@ -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 diff --git a/src/routes/_store/computations/timelineComputations.js b/src/routes/_store/computations/timelineComputations.js index 35afbe3..2678519 100644 --- a/src/routes/_store/computations/timelineComputations.js +++ b/src/routes/_store/computations/timelineComputations.js @@ -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 ) ) diff --git a/src/routes/_store/observers/nowObservers.js b/src/routes/_store/observers/nowObservers.js new file mode 100644 index 0000000..df055e6 --- /dev/null +++ b/src/routes/_store/observers/nowObservers.js @@ -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() + } + }) + } +} diff --git a/src/routes/_store/observers/observers.js b/src/routes/_store/observers/observers.js index fe278cc..ff8f6b1 100644 --- a/src/routes/_store/observers/observers.js +++ b/src/routes/_store/observers/observers.js @@ -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) diff --git a/src/routes/_store/store.js b/src/routes/_store/store.js index 60e28c3..eaba1c4 100644 --- a/src/routes/_store/store.js +++ b/src/routes/_store/store.js @@ -39,6 +39,7 @@ const nonPersistedState = { instanceLists: {}, online: !process.browser || navigator.onLine, pinnedStatuses: {}, + polls: {}, pushNotificationsSupport: process.browser && ('serviceWorker' in navigator && diff --git a/src/routes/_thirdparty/timeago/timeago.js b/src/routes/_thirdparty/timeago/timeago.js index db8fddd..6a1ff1e 100644 --- a/src/routes/_thirdparty/timeago/timeago.js +++ b/src/routes/_thirdparty/timeago/timeago.js @@ -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)) } diff --git a/src/routes/_transitions/slide.js b/src/routes/_transitions/slide.js new file mode 100644 index 0000000..7f5bfae --- /dev/null +++ b/src/routes/_transitions/slide.js @@ -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) +} diff --git a/src/scss/themes/_base.scss b/src/scss/themes/_base.scss index 03c8cc1..398fa7f 100644 --- a/src/scss/themes/_base.scss +++ b/src/scss/themes/_base.scss @@ -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%)}; } diff --git a/src/scss/themes/_dark.scss b/src/scss/themes/_dark.scss index 1cb0e0a..cfee0c2 100644 --- a/src/scss/themes/_dark.scss +++ b/src/scss/themes/_dark.scss @@ -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}; diff --git a/src/service-worker.js b/src/service-worker.js index e8de784..b1fbed4 100644 --- a/src/service-worker.js +++ b/src/service-worker.js @@ -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 diff --git a/tests/serverActions.js b/tests/serverActions.js index 34bc7d3..c55e24d 100644 --- a/tests/serverActions.js +++ b/tests/serverActions.js @@ -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())) +} diff --git a/tests/spec/125-notification-timeline-filters.js b/tests/spec/125-notification-timeline-filters.js new file mode 100644 index 0000000..a43e502 --- /dev/null +++ b/tests/spec/125-notification-timeline-filters.js @@ -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') +}) diff --git a/tests/spec/126-polls.js b/tests/spec/126-polls.js new file mode 100644 index 0000000..5df6267 --- /dev/null +++ b/tests/spec/126-polls.js @@ -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') +}) diff --git a/tests/spec/127-compose-polls.js b/tests/spec/127-compose-polls.js new file mode 100644 index 0000000..785009d --- /dev/null +++ b/tests/spec/127-compose-polls.js @@ -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') +}) diff --git a/tests/utils.js b/tests/utils.js index 601c90e..2e84d43 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -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) { diff --git a/yarn.lock b/yarn.lock index dd0b82f..3868aff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"