Compare commits

...

63 Commits

Author SHA1 Message Date
'leftie 268a7ce3b6 fix: remove typo introduced in merge 2019-06-02 13:32:46 -04:00
'leftie d9fba6d79d Merge branch 'master' into leftie
# Conflicts:
#	src/routes/_static/themes.js
2019-06-02 13:30:33 -04:00
Nolan Lawson 155cb05e39 1.9.0 2019-06-02 09:26:27 -07:00
Nolan Lawson 5d0e95e759
perf: don't interate through all of localStorage in inline script (#1264) 2019-06-02 09:07:45 -07:00
Nolan Lawson 58a8772edc
perf: lazy-load the ComposeBox (#1262) 2019-06-01 17:01:50 -07:00
Nolan Lawson d75507bbce
fix: fix disableNotificationsBadge aria-label (#1260) 2019-06-01 15:51:53 -07:00
Nolan Lawson 604471a158
fix: fix grayscale in firefox (#1261) 2019-06-01 15:51:46 -07:00
Nolan Lawson f5c7bc790f
fix: fix compose toolbar on iphone 4 again (#1259) 2019-06-01 14:27:50 -07:00
Nolan Lawson 74230cfe8e
fix: fix service worker for real (#1258)
fixes #1243
2019-06-01 13:07:38 -07:00
Nolan Lawson a35f5ee2d9
feat: implement wellness settings (#1256)
* implement wellness settings

fixes #1192

Adds
- grayscale mode (as well as separate grayscale/dark grayscale
themes)
- disable follower/boost/fav counts (follower counts capped at 10)
- disable unread notification count (red dot)

* fix lint

* fix crawler
2019-06-01 13:07:31 -07:00
Nolan Lawson 27864fc47f
fix: Revert "fix: no need for double reload of SW in Chrome (#1251)" (#1257)
This reverts commit fa2eb8fe52.
2019-06-01 12:17:12 -07:00
'leftie 210e942398 Merge branch 'rio-theme' into leftie 2019-06-01 01:35:19 -04:00
'leftie d1a3f007bc Merge branch 'sam-theme' into leftie 2019-06-01 01:35:19 -04:00
'leftie 913ce6eaaa fix: typo in grayscale theme 2019-06-01 01:35:02 -04:00
'leftie 3183bb4cb1 Merge branch 'rio-theme' into leftie 2019-06-01 01:29:55 -04:00
'leftie 3bc8d0d32e Merge branch 'sam-theme' into leftie 2019-06-01 01:29:46 -04:00
'leftie dcedf2c8d6 fix: handle link styles on text selection, and on focus 2019-06-01 01:29:09 -04:00
'leftie d4703e7c9d fix: simplify grayscale theme 2019-06-01 01:11:32 -04:00
Nolan Lawson fcf64c2169
fix: fix "Show more" button in Notifications timeline when filtered (#1255) 2019-05-29 18:48:59 -07:00
Nolan Lawson 45630c185f
feat: add option to disable infinite scroll (#1253)
* feat: add option to disable infinite scroll

fixes #391 and fixes #270. Also makes me less nervous about #1251 because now keyboard users can disable infinite load and easily access the "reload" button in the snackbar footer.

* fix test
2019-05-28 22:46:01 -07:00
Nolan Lawson 44a87dcd9a
fix: fix compose button toolbar style on small devices (#1254) 2019-05-28 22:24:22 -07:00
Nolan Lawson 8672ade314
fix: unescape html in card titles/descriptions (#1252) 2019-05-28 22:24:16 -07:00
Nolan Lawson fa2eb8fe52
fix: no need for double reload of SW in Chrome (#1251)
fixes #1243
2019-05-28 08:18:11 -07:00
'leftie f8ed3ff292 Merge branch 'master' into leftie 2019-05-27 21:34:00 -04:00
Nolan Lawson 0de6c3a09f 1.8.0 2019-05-27 18:00:56 -07:00
Nolan Lawson 34e82cbaf2
fix: statuses in own thread should not have cursor:pointer (#1250) 2019-05-27 17:38:59 -07:00
Nolan Lawson f1857cb86e
fix: improve color contrast of dark themes (#1249) 2019-05-27 17:01:53 -07:00
Nolan Lawson 3453b10ffb
chore: update deps (#1247)
* chore: update deps

* chore: actually update all deps
2019-05-27 15:15:47 -07:00
'leftie c41d5908b8 Merge branch 'sam-theme' into leftie 2019-05-27 18:10:47 -04:00
'leftie 7406cf326c Merge branch 'rio-theme' into leftie 2019-05-27 18:01:48 -04:00
'leftie cde4cd0a61 feat: plus variation on rio theme with grayscaled images 2019-05-27 18:01:22 -04:00
Nolan Lawson 8c74d0c7c8
fix: add push notification badge (#1246) 2019-05-27 14:25:45 -07:00
'leftie ccf77b83eb fix: in about page, indicate being forked from dev branch 2019-05-27 17:09:01 -04:00
Nolan Lawson 3a2c56f0fa
fix: various push notification fixes (#1245) 2019-05-27 14:01:02 -07:00
'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
'leftie 8bf2a6d956 Merge branch 'master' into leftie 2019-05-25 20:52:34 -04:00
Nolan Lawson 12c5b732ae
feat: add poll result push notifications (#1227)
fixes one of the sub-tasks in #1130.

I also went ahead and removed the reply feature, because I cannot get it to work in Android 6.0.1 and I can't find any documentation for it in W3C/WHATWG, so I'm not sure how it is supposed to work.
2019-05-25 15:20:09 -07:00
Nolan Lawson a17948cf99
feat: add home/notification filter settings (#1226)
Fixes #1223
Fixes #1224
2019-05-25 13:21:36 -07:00
Nolan Lawson 92bff6caaa
fix: minor tweaks to PushNotificationSettings (#1222) 2019-05-25 13:21:17 -07:00
Nolan Lawson 02689bec93
fix: change wording in show/hide sensitive media (#1221)
fixes #1215
2019-05-25 13:20:52 -07:00
Nolan Lawson c18168d913
fix: tweak poll results style and fix a11y (#1220) 2019-05-25 13:20:45 -07:00
sgenoud af955492e8 feat: Add poll results to a status (#1219) 2019-05-25 08:36:44 -07:00
Nolan Lawson 692e8b57c3
fix: separate "inline theme" from "default theme" (#1216) 2019-05-25 08:19:11 -07:00
Nolan Lawson d92bd2e94b
chore: update to esm 3.2.25 (#1217)
* chore(package): update esm to version 3.2.25

* chore(package): update lockfile yarn.lock
2019-05-25 08:19:05 -07:00
greenkeeper[bot] 5178650e78 chore: Update rollup-plugin-terser to the latest version 🚀 (#1218)
* fix(package): update rollup-plugin-terser to version 5.0.0

* chore(package): update lockfile yarn.lock
2019-05-25 08:16:27 -07:00
'leftie 5a2be30d4d Merge branch 'rio-theme' into leftie 2019-05-24 20:46:32 -04:00
'leftie 3136c5cd98 feat: add rio-like theme 2019-05-24 20:45:19 -04:00
greenkeeper[bot] 9862858b2e chore: Update assert to the latest version 🚀 (#1212)
* chore(package): update assert to version 2.0.0

* chore(package): update lockfile yarn.lock
2019-05-19 08:07:27 -07:00
Cătălin Mariș cdade05315 fix: use only one 180x180px touch icon (#1213)
* Include just one 180x180px touch icon`.

  Over time as Apple released different size displays for their
  devices, the requirements¹ for the size of the touch icon have
  changed quite a bit:

   * 57×57px – iPhone with @1x display and iPod Touch
   * 72×72px – iPad and iPad mini with @1x display running iOS ≤ 6
   * 76×76px – iPad and iPad mini with @1x display running iOS ≥ 7
   * 114×114px – iPhone with @2x display running iOS ≤ 6
   * 120×120px – iPhone with @2x and @3x display running iOS ≥ 7
   * 144×144px – iPad and iPad mini with @2x display running iOS ≤ 6
   * 152×152px – iPad and iPad mini with @2x display running iOS 7
   * 180×180px – iPad and iPad mini with @2x display running iOS 8+

  However, most iOS users will be on the latest 2 versions² of iOS
  and using newer devices, so nowadays, one 180x180px touch icon is
  enough.

  Also, if needed, the icon will be automatically³ downscaled by
  Safari, and the result of the scaling is generally ok.

* Remove unneeded `sizes` attribute.

  When using only one touch icon there is no need to use the `sizes`
  attribute.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

¹ https://github.com/h5bp/html5-boilerplate/pull/1599#issuecomment-56384135
² https://developer.apple.com/support/app-store/
³ https://realfavicongenerator.net/blog/how-ios-scales-the-apple-touch-icon/

See also: https://mathiasbynens.be/notes/touch-icons
2019-05-13 21:45:38 -07:00
92 changed files with 3245 additions and 1011 deletions

View File

@ -11,7 +11,6 @@ const render = promisify(sass.render.bind(sass))
const globalScss = path.join(__dirname, '../src/scss/global.scss')
const defaultThemeScss = path.join(__dirname, '../src/scss/themes/_default.scss')
const offlineThemeScss = path.join(__dirname, '../src/scss/themes/_offline.scss')
const customScrollbarScss = path.join(__dirname, '../src/scss/custom-scrollbars.scss')
const themesScssDir = path.join(__dirname, '../src/scss/themes')
const assetsDir = path.join(__dirname, '../static')
@ -22,11 +21,9 @@ async function renderCss (file) {
async function compileGlobalSass () {
let mainStyle = (await Promise.all([defaultThemeScss, globalScss].map(renderCss))).join('')
let offlineStyle = (await renderCss(offlineThemeScss))
let scrollbarStyle = (await renderCss(customScrollbarScss))
return `<style>\n${mainStyle}</style>\n` +
`<style media="only x" id="theOfflineStyle">\n${offlineStyle}</style>\n` +
`<style media="all" id="theScrollbarStyle">\n${scrollbarStyle}</style>\n`
}

View File

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

View File

@ -1,7 +1,7 @@
{
"name": "pinafore",
"description": "Alternative web client for Mastodon",
"version": "1.7.0",
"version": "1.9.0",
"scripts": {
"lint": "standard && standard --plugin html 'src/routes/**/*.html'",
"lint-fix": "standard --fix && standard --fix --plugin html 'src/routes/**/*.html'",
@ -43,10 +43,10 @@
"build-now-json": "node -r esm ./bin/build-now-json.js"
},
"dependencies": {
"@babel/core": "^7.4.4",
"@babel/core": "^7.4.5",
"@gamestdio/websocket": "^0.3.2",
"@webcomponents/custom-elements": "^1.2.4",
"babel-loader": "^8.0.5",
"babel-loader": "^8.0.6",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"cheerio": "^1.0.0-rc.2",
"child-process-promise": "^2.2.1",
@ -61,9 +61,9 @@
"emoji-regex": "^8.0.0",
"encoding": "^0.1.12",
"escape-html": "^1.0.3",
"esm": "^3.2.22",
"esm": "^3.2.25",
"events-light": "^1.0.5",
"express": "^4.16.4",
"express": "^4.17.1",
"file-api": "^0.10.4",
"file-drop-element": "0.2.0",
"form-data": "^2.3.3",
@ -74,7 +74,7 @@
"lodash-es": "^4.17.11",
"lodash-webpack-plugin": "^0.11.5",
"mkdirp": "^0.5.1",
"node-fetch": "^2.5.0",
"node-fetch": "^2.6.0",
"node-sass": "^4.12.0",
"npm-run-all": "^4.1.5",
"p-any": "^2.1.0",
@ -86,28 +86,28 @@
"quick-lru": "^4.0.0",
"remount": "^0.11.0",
"requestidlecallback": "^0.3.0",
"rollup": "^1.11.3",
"rollup": "^1.12.4",
"rollup-plugin-replace": "^2.2.0",
"rollup-plugin-terser": "^4.0.4",
"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",
"svelte-loader": "^2.13.4",
"svelte-transitions": "^1.2.0",
"svgo": "^1.2.2",
"terser-webpack-plugin": "^1.2.3",
"terser-webpack-plugin": "^1.3.0",
"text-encoding": "^0.7.0",
"tiny-queue": "^0.2.1",
"webpack": "^4.31.0",
"webpack": "^4.32.2",
"webpack-bundle-analyzer": "^3.3.2"
},
"devDependencies": {
"assert": "^1.5.0",
"eslint-plugin-html": "^5.0.3",
"assert": "^2.0.0",
"eslint-plugin-html": "^5.0.5",
"fake-indexeddb": "^2.1.0",
"mocha": "^6.1.4",
"now": "^15.2.0",
"now": "^15.3.0",
"standard": "^12.0.1",
"testcafe": "^1.1.4"
},

View File

@ -10,14 +10,24 @@
<link id='theManifest' rel='manifest' href='/manifest.json' >
<link id='theFavicon' rel='icon' type='image/png' href='/favicon.png' >
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120.png" >
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180.png" >
<link rel="apple-touch-icon" href="/apple-icon.png" >
<meta name="mobile-web-app-capable" content="yes" >
<meta name="apple-mobile-web-app-title" content="Pinafore" >
<meta name="apple-mobile-web-app-status-bar-style" content="white" >
<!-- inline CSS -->
<style id="theGrayscaleStyle" media="only x">
/* Firefox doesn't seem to like applying filter: grayscale() to
* the entire body, so we apply individually.
*/
img, svg, video,
input[type="checkbox"], input[type="radio"],
.inline-emoji, .theme-preview, .emoji-mart-emoji, .emoji-mart-skin {
filter: grayscale(100%);
}
</style>
<noscript>
<style>
.hidden-from-ssr {

View File

@ -3,16 +3,21 @@
// To allow CSP to work correctly, we also calculate a sha256 hash during
// the build process and write it to checksum.js.
import { testHasLocalStorageOnce } from '../routes/_utils/testStorage'
import { DEFAULT_LIGHT_THEME, DEFAULT_THEME, switchToTheme } from '../routes/_utils/themeEngine'
import { INLINE_THEME, DEFAULT_THEME, switchToTheme } from '../routes/_utils/themeEngine'
import { basename } from '../routes/_api/utils'
import { onUserIsLoggedOut } from '../routes/_actions/onUserIsLoggedOut'
import { storeLite } from '../routes/_store/storeLite'
window.__themeColors = process.env.THEME_COLORS
const safeParse = str => (typeof str === 'undefined' || str === 'undefined') ? undefined : JSON.parse(str)
const hasLocalStorage = testHasLocalStorageOnce()
const currentInstance = hasLocalStorage && safeParse(localStorage.store_currentInstance)
const {
currentInstance,
instanceThemes,
disableCustomScrollbars,
enableGrayscale
} = storeLite.get()
const theme = (instanceThemes && instanceThemes[currentInstance]) || DEFAULT_THEME
if (currentInstance) {
// Do prefetch if we're logged in, so we can connect faster to the other origin.
@ -26,24 +31,24 @@ if (currentInstance) {
document.head.appendChild(link)
}
let theme = (currentInstance &&
localStorage.store_instanceThemes &&
safeParse(localStorage.store_instanceThemes)[safeParse(localStorage.store_currentInstance)]) ||
DEFAULT_THEME
if (theme !== DEFAULT_LIGHT_THEME) {
if (theme !== INLINE_THEME) {
// switch theme ASAP to minimize flash of default theme
switchToTheme(theme)
switchToTheme(theme, enableGrayscale)
}
if (!hasLocalStorage || !currentInstance) {
if (enableGrayscale) {
document.getElementById('theGrayscaleStyle')
.setAttribute('media', 'all') // enables the style
}
if (!currentInstance) {
// if not logged in, show all these 'hidden-from-ssr' elements
onUserIsLoggedOut()
}
if (hasLocalStorage && localStorage.store_disableCustomScrollbars === 'true') {
// if user has disabled custom scrollbars, remove this style
let theScrollbarStyle = document.getElementById('theScrollbarStyle')
theScrollbarStyle.setAttribute('media', 'only x') // disables the style
if (disableCustomScrollbars) {
document.getElementById('theScrollbarStyle')
.setAttribute('media', 'only x') // disables the style
}
// hack to make the scrollbars rounded only on macOS

View File

@ -84,7 +84,8 @@ async function registerNewInstance (code) {
instanceThemes: instanceThemes
})
store.save()
switchToTheme(DEFAULT_THEME)
let { enableGrayscale } = store.get()
switchToTheme(DEFAULT_THEME, enableGrayscale)
// fire off these requests so they're cached
/* no await */ updateVerifyCredentialsForInstance(currentRegisteredInstanceName)
/* no await */ updateCustomEmojiForInstance(currentRegisteredInstanceName)

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

@ -1,6 +1,6 @@
import { getVerifyCredentials } from '../_api/user'
import { store } from '../_store/store'
import { DEFAULT_THEME, switchToTheme } from '../_utils/themeEngine'
import { switchToTheme } from '../_utils/themeEngine'
import { toast } from '../_components/toast/toast'
import { goto } from '../../../__sapper__/client'
import { cacheFirstUpdateAfter } from '../_utils/sync'
@ -14,7 +14,8 @@ export function changeTheme (instanceName, newTheme) {
store.save()
let { currentInstance } = store.get()
if (instanceName === currentInstance) {
switchToTheme(newTheme)
let { enableGrayscale } = store.get()
switchToTheme(newTheme, enableGrayscale)
}
}
@ -26,7 +27,8 @@ export function switchToInstance (instanceName) {
queryInSearch: ''
})
store.save()
switchToTheme(instanceThemes[instanceName])
let { enableGrayscale } = store.get()
switchToTheme(instanceThemes[instanceName], enableGrayscale)
}
export async function logOutOfInstance (instanceName) {
@ -55,7 +57,8 @@ export async function logOutOfInstance (instanceName) {
})
store.save()
toast.say(`Logged out of ${instanceName}`)
switchToTheme(instanceThemes[newInstance] || DEFAULT_THEME)
let { enableGrayscale } = store.get()
switchToTheme(instanceThemes[newInstance], enableGrayscale)
/* no await */ database.clearDatabaseForInstance(instanceName)
goto('/settings/instances')
}

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

View File

@ -114,7 +114,7 @@ export async function setupTimeline () {
stop('setupTimeline')
}
export async function fetchTimelineItemsOnScrollToBottom (instanceName, timelineName) {
export async function fetchMoreItemsAtBottomOfTimeline (instanceName, timelineName) {
console.log('setting runningUpdate: true')
store.setForTimeline(instanceName, timelineName, { runningUpdate: true })
await fetchTimelineItemsAndPossiblyFallBack()

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

@ -7,7 +7,7 @@
{#if hidePage}
<LoadingPage />
{/if}
<ComposeBox realm="home" hidden={hidePage}/>
<LazyComposeBox realm="home" hidden={hidePage}/>
<div class="timeline-home-anchor-container">
{#if !hidePage && hideTimeline}
<LoadingPage />
@ -29,7 +29,7 @@
import LazyTimeline from './timeline/LazyTimeline.html'
import { store } from '../_store/store.js'
import LoadingPage from './LoadingPage.html'
import ComposeBox from './compose/ComposeBox.html'
import LazyComposeBox from './compose/LazyComposeBox.html'
export default {
oncreate () {
@ -44,9 +44,9 @@
},
store: () => store,
components: {
LazyComposeBox,
LazyTimeline,
LoadingPage,
ComposeBox
LoadingPage
}
}
</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

@ -6,7 +6,7 @@
.compose-box-length {
grid-area: length;
justify-self: right;
color: var(--main-theme-color);
color: var(--length-indicator-color);
font-size: 1.3em;
align-self: center;
}
@ -53,4 +53,4 @@
observe
}
}
</script>
</script>

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

@ -1,11 +1,13 @@
<div class="compose-box-toolbar">
<div class="compose-box-toolbar-items">
<IconButton
className="compose-toolbar-button"
label="Insert emoji"
href="#fa-smile"
on:click="onEmojiClick()"
/>
<IconButton
className="compose-toolbar-button"
svgClassName={$uploadingMedia ? 'spin' : ''}
label="Add media"
href={$uploadingMedia ? '#fa-spinner' : '#fa-camera'}
@ -13,11 +15,21 @@
disabled={$uploadingMedia || (media.length === 4)}
/>
<IconButton
className="compose-toolbar-button"
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
className="compose-toolbar-button"
label="Adjust privacy (currently {postPrivacy.label})"
href={postPrivacy.icon}
on:click="onPostPrivacyClick()"
/>
<IconButton
className="compose-toolbar-button"
label={contentWarningShown ? 'Remove content warning' : 'Add content warning'}
href="#fa-exclamation-triangle"
on:click="onContentWarningClick()"
@ -40,6 +52,13 @@
display: flex;
align-items: center;
}
@media (max-width: 320px) {
:global(button.icon-button.compose-toolbar-button) {
padding-left: 5px;
padding-right: 5px;
}
}
</style>
<script>
import IconButton from '../IconButton.html'
@ -48,6 +67,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 +99,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

@ -0,0 +1,16 @@
{#await importComposeBox}
<!-- awaiting promise -->
{:then ComposeBox}
<svelte:component this={ComposeBox} {realm} {hidden} />
{:catch error}
<div>Component failed to load. Try refreshing! {error}</div>
{/await}
<script>
import { importComposeBox } from '../../_utils/asyncModules'
export default {
data: () => ({
importComposeBox: importComposeBox()
})
}
</script>

View File

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

View File

@ -127,7 +127,12 @@
numFollowers: ({ account }) => account.followers_count || 0,
numStatusesDisplay: ({ numStatuses }) => numberFormat.format(numStatuses),
numFollowingDisplay: ({ numFollowing }) => numberFormat.format(numFollowing),
numFollowersDisplay: ({ numFollowers }) => numberFormat.format(numFollowers),
numFollowersDisplay: ({ numFollowers, $disableFollowerCounts }) => {
if ($disableFollowerCounts && numFollowers >= 10) {
return '10+'
}
return numberFormat.format(numFollowers)
},
followersLabel: ({ numFollowers }) => `Followed by ${numFollowers}`,
followingLabel: ({ numFollowing }) => `Follows ${numFollowing}`
},

View File

@ -0,0 +1,48 @@
<div class="generic-instance-settings">
<form aria-label={label} ref:form>
{#each options as option, i (option.key) }
{#if i > 0}
<br>
{/if}
<input type="checkbox"
id="instance-option-{option.key}"
name="{option.key}"
on:change="onChange(event)"
>
<label for="instance-option-{option.key}">
{option.label}
</label>
{/each}
</form>
</div>
<style>
.generic-instance-settings {
background: var(--form-bg);
border: 1px solid var(--main-border);
border-radius: 4px;
display: block;
padding: 20px;
line-height: 2em;
}
</style>
<script>
import { store } from '../../../_store/store'
export default {
oncreate () {
let { instanceName, options } = this.get()
let { form } = this.refs
for (let { key, defaultValue } of options) {
form.elements[key].checked = this.store.getInstanceSetting(instanceName, key, defaultValue)
}
},
methods: {
onChange (event) {
let { instanceName } = this.get()
let { target } = event
this.store.setInstanceSetting(instanceName, target.name, target.checked)
}
},
store: () => store
}
</script>

View File

@ -0,0 +1,29 @@
<GenericInstanceSettings
{instanceName}
{options}
label="Home timeline filter settings"
/>
<script>
import GenericInstanceSettings from './GenericInstanceSettings.html'
import { HOME_REBLOGS, HOME_REPLIES } from '../../../_static/instanceSettings'
export default {
data: () => ({
options: [
{
key: HOME_REBLOGS,
label: 'Show boosts',
defaultValue: true
},
{
key: HOME_REPLIES,
label: 'Show replies',
defaultValue: true
}
]
}),
components: {
GenericInstanceSettings
}
}
</script>

View File

@ -0,0 +1,50 @@
<GenericInstanceSettings
{instanceName}
{options}
label="Notification filter settings"
/>
<script>
import GenericInstanceSettings from './GenericInstanceSettings.html'
import {
NOTIFICATION_REBLOGS,
NOTIFICATION_FAVORITES,
NOTIFICATION_FOLLOWS,
NOTIFICATION_MENTIONS,
NOTIFICATION_POLLS
} from '../../../_static/instanceSettings'
export default {
data: () => ({
options: [
{
key: NOTIFICATION_FOLLOWS,
label: 'New followers',
defaultValue: true
},
{
key: NOTIFICATION_FAVORITES,
label: 'Favorites',
defaultValue: true
},
{
key: NOTIFICATION_REBLOGS,
label: 'Boosts',
defaultValue: true
},
{
key: NOTIFICATION_MENTIONS,
label: 'Mentions',
defaultValue: true
},
{
key: NOTIFICATION_POLLS,
label: 'Poll results',
defaultValue: true
}
]
}),
components: {
GenericInstanceSettings
}
}
</script>

View File

@ -4,18 +4,21 @@
{:elseif $notificationPermission === "denied"}
<p role="alert">You have denied permission to show notifications.</p>
{/if}
<form id="push-notification-settings" disabled="{!pushNotificationsSupport}" ref:pushNotificationsForm aria-label="Push notification settings">
<input type="checkbox" id="push-notifications-follow" name="follow" disabled="{!pushNotificationsSupport}" on:change="onPushSettingsChange(event)">
<label for="push-notifications-follow">New followers</label>
<br>
<input type="checkbox" id="push-notifications-favourite" name="favourite" disabled="{!pushNotificationsSupport}" on:change="onPushSettingsChange(event)">
<label for="push-notifications-favourite">Favourites</label>
<br>
<input type="checkbox" id="push-notifications-reblog" name="reblog" disabled="{!pushNotificationsSupport}" on:change="onPushSettingsChange(event)">
<label for="push-notifications-reblog">Boosts</label>
<br>
<input type="checkbox" id="push-notifications-mention" name="mention" disabled="{!pushNotificationsSupport}" on:change="onPushSettingsChange(event)">
<label for="push-notifications-mention">Mentions</label>
<form id="push-notification-settings"
disabled="{!pushNotificationsSupport}"
ref:form
aria-label="Push notification settings">
{#each options as option, i (option.key)}
{#if i > 0}
<br>
{/if}
<input type="checkbox"
id="push-notifications-{option.key}"
name="{option.key}"
disabled="{!pushNotificationsSupport}"
on:change="onPushSettingsChange(event)">
<label for="push-notifications-{option.key}">{option.label}</label>
{/each}
</form>
</div>
<style>
@ -27,11 +30,11 @@
padding: 20px;
line-height: 2em;
}
.push-notifications form[disabled="true"] {
form[disabled="true"] {
opacity: 0.5;
}
.push-notifications p {
margin: 0;
p {
margin: 0 0 10px 0;
}
</style>
<script>
@ -40,33 +43,56 @@
import { logOutOfInstance } from '../../../_actions/instances'
import { updatePushSubscriptionForInstance, updateAlerts } from '../../../_actions/pushSubscription'
import { toast } from '../../toast/toast'
import { get } from '../../../_utils/lodash-lite'
export default {
async oncreate () {
let { instanceName } = this.get()
let { instanceName, options } = this.get()
await updatePushSubscriptionForInstance(instanceName)
const form = this.refs.pushNotificationsForm
const { form } = this.refs
const { pushSubscription } = this.store.get()
form.elements.follow.checked = pushSubscription && pushSubscription.alerts && pushSubscription.alerts.follow
form.elements.favourite.checked = pushSubscription && pushSubscription.alerts && pushSubscription.alerts.favourite
form.elements.reblog.checked = pushSubscription && pushSubscription.alerts && pushSubscription.alerts.reblog
form.elements.mention.checked = pushSubscription && pushSubscription.alerts && pushSubscription.alerts.mention
for (let { key } of options) {
form.elements[key].checked = get(pushSubscription, ['alerts', key])
}
},
store: () => store,
data: () => ({
options: [
{
key: 'follow',
label: 'New Followers'
},
{
key: 'favourite',
label: 'Favorites'
},
{
key: 'reblog',
label: 'Boosts'
},
{
key: 'mention',
label: 'Mentions'
},
{
key: 'poll',
label: 'Poll results'
}
]
}),
computed: {
pushNotificationsSupport: ({ $pushNotificationsSupport }) => $pushNotificationsSupport
},
methods: {
async onPushSettingsChange (e) {
const { instanceName } = this.get()
const form = this.refs.pushNotificationsForm
const alerts = {
follow: form.elements.follow.checked,
favourite: form.elements.favourite.checked,
reblog: form.elements.reblog.checked,
mention: form.elements.mention.checked
const { instanceName, options } = this.get()
const { form } = this.refs
const alerts = {}
for (let { key } of options) {
alerts[key] = form.elements[key].checked
}
try {

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} />
@ -31,10 +31,13 @@
{#if showMedia }
<StatusMediaAttachments {...params} on:recalculateHeight />
{/if}
{#if isStatusInOwnThread}
<StatusDetails {...params} />
{#if showPoll}
<StatusPoll {...params} />
{/if}
<StatusToolbar {...params} on:recalculateHeight />
{#if isStatusInOwnThread}
<StatusDetails {...params} {...timestampParams} />
{/if}
<StatusToolbar {...params} {replyShown} on:recalculateHeight />
{#if replyShown}
<StatusComposeBox {...params} on:recalculateHeight />
{/if}
@ -58,6 +61,7 @@
"sidebar content content content"
"sidebar card card card"
"sidebar media-grp media-grp media-grp"
"sidebar poll poll poll"
"media media media media"
"....... toolbar toolbar toolbar"
"compose compose compose compose";
@ -92,6 +96,7 @@
"card card"
"media-grp media-grp"
"media media"
"poll poll"
"details details"
"toolbar toolbar"
"compose compose";
@ -119,6 +124,7 @@
import StatusSpoiler from './StatusSpoiler.html'
import StatusComposeBox from './StatusComposeBox.html'
import StatusMentions from './StatusMentions.html'
import StatusPoll from './StatusPoll.html'
import Shortcut from '../shortcut/Shortcut.html'
import { store } from '../../_store/store'
import { goto } from '../../../../__sapper__/client'
@ -138,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')
@ -170,6 +176,7 @@
StatusMediaAttachments,
StatusContent,
StatusCard,
StatusPoll,
StatusSpoiler,
StatusComposeBox,
StatusMentions,
@ -261,19 +268,24 @@
originalStatus.card &&
originalStatus.card.title
),
showPoll: ({ originalStatus }) => (
originalStatus.poll
),
showMedia: ({ originalStatus, isStatusInNotification }) => (
!isStatusInNotification &&
originalStatus.media_attachments &&
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 }) => (
@ -282,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'
),
@ -293,15 +305,26 @@
timelineType !== 'search' && 'status-in-timeline',
isStatusInOwnThread && 'status-in-own-thread',
$underlineLinks && 'underline-links',
!$disableTapOnStatus && 'tap-on-status'
!$disableTapOnStatus && !isStatusInOwnThread && 'tap-on-status'
)),
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,
@ -314,19 +337,15 @@
isStatusInOwnThread,
originalAccount,
originalAccountId,
spoilerShown,
visibility,
replyShown,
replyVisibility,
spoilerText,
originalStatus,
originalStatusId,
inReplyToId,
createdAtDate,
timeagoFormattedDate,
enableShortcuts,
absoluteFormattedDate,
shortcutScope
shortcutScope,
originalStatusEmojis
})
},
events: {

View File

@ -1,6 +1,6 @@
<a ref:cardlink href={url} class="status-card" target="_blank" rel="noopener noreferrer">
<strong class="card-title">
{title}
{unescapedTitle}
</strong>
{#if description}
<div class="card-content">
@ -10,7 +10,7 @@
</div>
{/if}
<span class="card-description">
{description}
{unescapedDescription}
</span>
</div>
{/if}
@ -87,6 +87,7 @@
<script>
import LazyImage from '../LazyImage.html'
import Shortcut from '../shortcut/Shortcut.html'
import { unescape } from '../../_thirdparty/unescape/unescape'
export default {
components: {
@ -96,9 +97,11 @@
computed: {
card: ({ originalStatus }) => originalStatus.card,
title: ({ card }) => card.title,
unescapedTitle: ({ title }) => title && unescape(title),
url: ({ card }) => card.url,
hostname: ({ url }) => window.URL ? new window.URL(url).hostname : '',
description: ({ card, hostname }) => card.description || card.provider_name || hostname,
unescapedDescription: ({ description }) => description && unescape(description),
imageUrl: ({ card }) => card.image
},
methods: {

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,29 +158,40 @@
application: ({ originalStatus }) => originalStatus.application,
applicationName: ({ application }) => (application && application.name),
applicationWebsite: ({ application }) => (application && application.website),
createdAtDate: ({ originalStatus }) => originalStatus.created_at,
numReblogs: ({ overrideNumReblogs, originalStatus }) => {
numReblogs: ({ $disableReblogCounts, overrideNumReblogs, originalStatus }) => {
if ($disableReblogCounts) {
return 0
}
if (typeof overrideNumReblogs === 'number') {
return overrideNumReblogs
}
return originalStatus.reblogs_count || 0
},
numFavs: ({ overrideNumFavs, originalStatus }) => {
numFavs: ({ $disableFavCounts, overrideNumFavs, originalStatus }) => {
if ($disableFavCounts) {
return 0
}
if (typeof overrideNumFavs === 'number') {
return overrideNumFavs
}
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 }) => {
reblogsLabel: ({ $disableReblogCounts, numReblogs }) => {
if ($disableReblogCounts) {
return 'Boost counts hidden'
}
// TODO: intl
return numReblogs === 1
? `Boosted ${numReblogs} time`
: `Boosted ${numReblogs} times`
},
favoritesLabel: ({ numFavs }) => {
favoritesLabel: ({ $disableFavCounts, numFavs }) => {
if ($disableFavCounts) {
return 'Favorite counts hidden'
}
// TODO: intl
return numFavs === 1
? `Favorited ${numFavs} time`

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

@ -0,0 +1,327 @@
<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;
}
.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 {
margin: 0;
padding: 0;
}
.option {
margin: 0 0 10px 0;
padding: 0;
display: flex;
flex-direction: column;
stroke: var(--svg-fill);
stroke-width: 10px;
}
.option-text {
word-wrap: break-word;
white-space: pre-wrap;
font-size: 1.1em;
}
svg {
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: {
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

@ -1,32 +1,100 @@
<div class="loading-footer {shown ? '' : 'hidden'}">
<LoadingSpinner size={48} />
<span class="loading-footer-info">
Loading more...
</span>
<div class="loading-wrapper {showLoading ? 'shown' : ''}"
aria-hidden={!showLoading}
role="alert"
>
<LoadingSpinner size={48} />
<span class="loading-footer-info">
Loading more...
</span>
</div>
<div class="button-wrapper {showLoadButton ? 'shown' : ''}"
aria-hidden={!showLoadButton}
>
<button type="button"
class="primary"
on:click="onClickLoadMore(event)">
Load more
</button>
</div>
</div>
<style>
.loading-footer {
padding: 20px 0 10px;
padding: 20px 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.loading-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s linear;
}
.loading-wrapper.shown {
opacity: 1;
pointer-events: auto;
}
.loading-footer-info {
margin-left: 20px;
font-size: 1.3em;
}
.button-wrapper {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: none;
}
.button-wrapper.shown {
opacity: 1;
pointer-events: auto;
transition: opacity 0.2s linear 0.2s;
}
</style>
<script>
import LoadingSpinner from '../LoadingSpinner.html'
import { store } from '../../_store/store'
import { fetchMoreItemsAtBottomOfTimeline } from '../../_actions/timeline'
export default {
store: () => store,
computed: {
shown: ({ $timelineInitialized, $runningUpdate }) => ($timelineInitialized && $runningUpdate)
shown: ({ $timelineInitialized, $runningUpdate, $disableInfiniteScroll }) => (
$timelineInitialized && ($disableInfiniteScroll || $runningUpdate)
),
showLoading: ({ $runningUpdate }) => $runningUpdate,
showLoadButton: ({ $runningUpdate, $disableInfiniteScroll }) => !$runningUpdate && $disableInfiniteScroll
},
methods: {
onClickLoadMore (e) {
e.preventDefault()
e.stopPropagation()
let { currentInstance, currentTimeline } = this.store.get()
/* no await */ fetchMoreItemsAtBottomOfTimeline(currentInstance, currentTimeline)
// focus the last item in the timeline; it makes the most sense to me since the button disappears
try {
// TODO: should probably expose this as an API on the virtual list instead of reaching into the DOM
let virtualListItems = document.querySelector('.virtual-list').children
let lastItem = virtualListItems[virtualListItems.length - 2] // -2 because the footer is last
lastItem.querySelector('article').focus()
} catch (e) {
console.error(e)
}
}
},
components: {
LoadingSpinner
}
}
</script>
</script>

View File

@ -45,7 +45,7 @@
} from '../../_utils/asyncModules'
import { timelines } from '../../_static/timelines'
import {
fetchTimelineItemsOnScrollToBottom,
fetchMoreItemsAtBottomOfTimeline,
setupTimeline,
showMoreItemsForTimeline,
showMoreItemsForThread,
@ -127,13 +127,11 @@
timelineValue !== $firstTimelineItemId &&
timelineValue
),
itemIds: ({ $timelineItemSummaries }) => (
// TODO: filter
$timelineItemSummaries && $timelineItemSummaries.map(_ => _.id)
itemIds: ({ $filteredTimelineItemSummaries }) => (
$filteredTimelineItemSummaries && $filteredTimelineItemSummaries.map(_ => _.id)
),
itemIdsToAdd: ({ $timelineItemSummariesToAdd }) => (
// TODO: filter
$timelineItemSummariesToAdd && $timelineItemSummariesToAdd.map(_ => _.id)
itemIdsToAdd: ({ $filteredTimelineItemSummariesToAdd }) => (
$filteredTimelineItemSummariesToAdd && $filteredTimelineItemSummariesToAdd.map(_ => _.id)
),
headerProps: ({ itemIdsToAdd }) => {
return {
@ -167,18 +165,16 @@
},
onScrollToBottom () {
let { timelineType } = this.get()
let { timelineInitialized, runningUpdate } = this.store.get()
let { timelineInitialized, runningUpdate, disableInfiniteScroll } = this.store.get()
if (!timelineInitialized ||
runningUpdate ||
disableInfiniteScroll ||
timelineType === 'status') { // for status contexts, we've already fetched the whole thread
return
}
let { currentInstance } = this.store.get()
let { timeline } = this.get()
fetchTimelineItemsOnScrollToBottom(
currentInstance,
timeline
)
/* no await */ fetchMoreItemsAtBottomOfTimeline(currentInstance, timeline)
},
onScrollToTop () {
let { shouldShowHeader } = this.store.get()
@ -190,7 +186,7 @@
}
},
setupStreaming () {
let { currentInstance } = this.store.get()
let { currentInstance, disableInfiniteScroll } = this.store.get()
let { timeline, timelineType } = this.get()
let handleItemIdsToAdd = () => {
let { itemIdsToAdd } = this.get()
@ -206,13 +202,17 @@
if (timelineType === 'status') {
// this is a thread, just insert the statuses already
showMoreItemsForThread(currentInstance, timeline)
} else if (scrollTop === 0 && !shouldShowHeader && !showHeader) {
} else if (!disableInfiniteScroll && scrollTop === 0 && !shouldShowHeader && !showHeader) {
// if the user is scrolled to the top and we're not showing the header, then
// just insert the statuses. this is "chat room mode"
showMoreItemsForTimeline(currentInstance, timeline)
} else {
// user hasn't scrolled to the top, show a header instead
this.store.setForTimeline(currentInstance, timeline, { shouldShowHeader: true })
// unless the user has disabled infinite scroll entirely
if (disableInfiniteScroll) {
this.store.setForTimeline(currentInstance, timeline, { showHeader: true })
}
}
stop('handleItemIdsToAdd')
}

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

@ -1,7 +1,7 @@
<SettingsLayout page='settings/about' label="About Pinafore">
<h1>About Pinafore</h1>
<h2>Version {version}</h2>
<h2>Version {version}-dev-leftie</h2>
<p>
Pinafore is <ExternalLink href="https://github.com/nolanlawson/pinafore">free and open-source software</ExternalLink>

View File

@ -3,15 +3,15 @@
<h2>Media</h2>
<form class="ui-settings">
<div class="setting-group">
<input type="checkbox" id="choice-mark-media-sensitive"
bind:checked="$markMediaAsSensitive" on:change="onChange(event)">
<label for="choice-mark-media-sensitive">Always mark media as sensitive</label>
</div>
<div class="setting-group">
<input type="checkbox" id="choice-never-mark-media-sensitive"
bind:checked="$neverMarkMediaAsSensitive" on:change="onChange(event)">
<label for="choice-never-mark-media-sensitive">Never mark media as sensitive</label>
<label for="choice-never-mark-media-sensitive">Show sensitive media by default</label>
</div>
<div class="setting-group">
<input type="checkbox" id="choice-mark-media-sensitive"
bind:checked="$markMediaAsSensitive" on:change="onChange(event)">
<label for="choice-mark-media-sensitive">Hide all media by default</label>
</div>
<div class="setting-group">
<input type="checkbox" id="choice-large-inline-media"
@ -32,6 +32,19 @@
bind:checked="$disableCustomScrollbars" on:change="onChange(event)">
<label for="choice-disable-custom-scrollbars">Disable custom scrollbars</label>
</div>
<div class="setting-group">
<input type="checkbox" id="choice-disable-infinite-scroll"
bind:checked="$disableInfiniteScroll" on:change="onChange(event)">
<label for="choice-disable-infinite-scroll">Disable
<Tooltip
text="infinite scroll"
tooltipText={
"When infinite scroll is disabled, new toots will not automatically appear at the bottom " +
"or top of the timeline. Instead, buttons will allow you to load more content on demand."
}
/>
</label>
</div>
<div class="setting-group">
<input type="checkbox" id="choice-hide-cards"
bind:checked="$hideCards" on:change="onChange(event)">
@ -89,11 +102,13 @@
import SettingsLayout from '../../_components/settings/SettingsLayout.html'
import ThemeSettings from '../../_components/settings/instance/ThemeSettings.html'
import { store } from '../../_store/store'
import Tooltip from '../../_components/Tooltip.html'
export default {
components: {
SettingsLayout,
ThemeSettings
ThemeSettings,
Tooltip
},
methods: {
onChange (event) {

View File

@ -8,6 +8,9 @@
<SettingsListRow>
<SettingsListButton href="/settings/instances" label="Instances"/>
</SettingsListRow>
<SettingsListRow>
<SettingsListButton href="/settings/wellness" label="Wellness"/>
</SettingsListRow>
<SettingsListRow>
<SettingsListButton href="/settings/hotkeys" label="Hotkeys"/>
</SettingsListRow>

View File

@ -4,9 +4,13 @@
{#if verifyCredentials}
<h2>Logged in as:</h2>
<InstanceUserProfile {verifyCredentials} />
<h2>Push notifications:</h2>
<h2>Home timeline filters</h2>
<HomeTimelineFilterSettings {instanceName} />
<h2>Notification filters</h2>
<NotificationFilterSettings {instanceName} />
<h2>Push notifications</h2>
<PushNotificationSettings {instanceName} />
<h2>Theme:</h2>
<h2>Theme</h2>
<ThemeSettings {instanceName} />
<InstanceActions {instanceName} />
@ -23,6 +27,8 @@
import { store } from '../../../_store/store'
import SettingsLayout from '../../../_components/settings/SettingsLayout.html'
import InstanceUserProfile from '../../../_components/settings/instance/InstanceUserProfile.html'
import HomeTimelineFilterSettings from '../../../_components/settings/instance/HomeTimelineFilterSettings.html'
import NotificationFilterSettings from '../../../_components/settings/instance/NotificationFilterSettings.html'
import PushNotificationSettings from '../../../_components/settings/instance/PushNotificationSettings.html'
import ThemeSettings from '../../../_components/settings/instance/ThemeSettings.html'
import InstanceActions from '../../../_components/settings/instance/InstanceActions.html'
@ -43,7 +49,9 @@
InstanceUserProfile,
PushNotificationSettings,
ThemeSettings,
InstanceActions
InstanceActions,
HomeTimelineFilterSettings,
NotificationFilterSettings
}
}
</script>
</script>

View File

@ -0,0 +1,154 @@
<SettingsLayout page='settings/general' label="General">
<h1>Wellness Settings</h1>
<p>
Wellness settings are designed to reduce the addictive or anxiety-inducing aspects of social media.
Choose any options that work well for you.
</p>
<form class="ui-settings">
<div class="setting-group">
<input type="checkbox" id="choice-check-all"
on:change="onCheckAllChange(event)">
<label for="choice-check-all">Enable all</label>
</div>
</form>
<h2>Metrics</h2>
<form class="ui-settings">
<div class="setting-group">
<input type="checkbox" id="choice-disable-follower-counts"
bind:checked="$disableFollowerCounts" on:change="onChange(event)">
<label for="choice-disable-follower-counts">
Hide follower counts (capped at 10)
</label>
</div>
<div class="setting-group">
<input type="checkbox" id="choice-disable-reblog-counts"
bind:checked="$disableReblogCounts" on:change="onChange(event)">
<label for="choice-disable-reblog-counts">Hide boost counts</label>
</div>
<div class="setting-group">
<input type="checkbox" id="choice-disable-fav-counts"
bind:checked="$disableFavCounts" on:change="onChange(event)">
<label for="choice-disable-fav-counts">Hide favorite counts</label>
</div>
</form>
<h2>Notifications</h2>
<form class="ui-settings">
<div class="setting-group">
<input type="checkbox" id="choice-disable-unread-notification-counts"
bind:checked="$disableNotificationBadge" on:change="onChange(event)">
<label for="choice-disable-unread-notification-counts">
Hide unread notifications count (i.e. the red dot)
</label>
</div>
</form>
<aside>
<SvgIcon href="#fa-info-circle" className="aside-icon" />
<span>
You can filter or disable notifications in the
<a rel="prefetch" href="/settings/instances{$currentInstance ? '/' + $currentInstance : ''}">instance settings</a>.
</span>
</aside>
<h2>UI</h2>
<form class="ui-settings">
<div class="setting-group">
<input type="checkbox" id="choice-grayscale"
bind:checked="$enableGrayscale" on:change="onChange(event)">
<label for="choice-grayscale">Grayscale mode</label>
</div>
</form>
<p>
These settings are partly based on guidelines from the
<ExternalLink href="https://humanetech.com">Center for Humane Technology</ExternalLink>.
</p>
</SettingsLayout>
<style>
.ui-settings {
background: var(--form-bg);
border: 1px solid var(--main-border);
border-radius: 4px;
padding: 20px;
line-height: 2em;
}
.setting-group {
padding: 5px 0;
}
aside {
font-size: 1.2em;
margin: 20px 10px 0px 10px;
color: var(--deemphasized-text-color);
display: flex;
align-items: center;
}
aside a {
text-decoration: underline;
color: var(--deemphasized-text-color);
}
aside span {
flex: 1;
}
:global(.aside-icon) {
fill: var(--deemphasized-text-color);
width: 18px;
height: 18px;
margin: 0 10px 0 5px;
min-width: 18px;
}
</style>
<script>
import SettingsLayout from '../../_components/settings/SettingsLayout.html'
import { store } from '../../_store/store'
import ExternalLink from '../../_components/ExternalLink.html'
import SvgIcon from '../../_components/SvgIcon.html'
export default {
oncreate () {
this.flushChangesToCheckAll()
},
components: {
SettingsLayout,
ExternalLink,
SvgIcon
},
methods: {
flushChangesToCheckAll () {
const {
disableFollowerCounts,
disableReblogCounts,
disableFavCounts,
disableNotificationBadge,
enableGrayscale
} = this.store.get()
document.querySelector('#choice-check-all').checked = disableFollowerCounts &&
disableReblogCounts &&
disableFavCounts &&
disableNotificationBadge &&
enableGrayscale
},
onCheckAllChange (e) {
let { checked } = e.target
this.store.set({
disableFollowerCounts: checked,
disableReblogCounts: checked,
disableFavCounts: checked,
disableNotificationBadge: checked,
enableGrayscale: checked
})
this.store.save()
},
onChange () {
this.flushChangesToCheckAll()
this.store.save()
}
},
store: () => store
}
</script>

View File

@ -0,0 +1,8 @@
export const HOME_REBLOGS = 'homeReblogs'
export const HOME_REPLIES = 'homeReplies'
export const NOTIFICATION_REBLOGS = 'notificationReblogs'
export const NOTIFICATION_FAVORITES = 'notificationFavs'
export const NOTIFICATION_FOLLOWS = 'notificationFollows'
export const NOTIFICATION_MENTIONS = 'notificationMentions'
export const NOTIFICATION_POLLS = 'notificationPolls'

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

@ -41,6 +41,12 @@ const themes = [
dark: false,
color: '#4ab92f'
},
{
name: 'grayscale',
label: 'Grayscale',
dark: false,
color: '#999999'
},
{
name: 'sam',
label: 'Sam',
@ -53,6 +59,18 @@ const themes = [
dark: false,
color: '#ffffea',
},
{
name: 'rio',
label: 'rio',
dark: false,
color: '#55aaaa'
},
{
name: 'rio-grayscale',
label: 'rio (grayscale)',
dark: false,
color: '#55aaaa'
},
{
name: 'ozark',
label: 'Ozark',
@ -100,6 +118,12 @@ const themes = [
label: 'Pitch Black',
dark: true,
color: '#000'
},
{
name: 'dark-grayscale',
label: 'Dark Grayscale',
dark: true,
color: '#666'
}
]

View File

@ -1,10 +1,7 @@
import { Store } from 'svelte/store'
import { safeLocalStorage as LS } from '../_utils/safeLocalStorage'
import lifecycle from 'page-lifecycle/dist/lifecycle.mjs'
function safeParse (str) {
return !str ? undefined : (str === 'undefined' ? undefined : JSON.parse(str))
}
import { safeParse } from './safeParse'
export class LocalStorageStore extends Store {
constructor (state, keysToWatch) {

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,16 +75,110 @@ 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(
'filteredTimelineItemSummariesToAdd',
['timelineItemSummariesToAdd', 'timelineFilterFunction'],
(timelineItemSummariesToAdd, timelineFilterFunction) => {
return timelineItemSummariesToAdd && timelineItemSummariesToAdd.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', 'disableNotificationBadge'],
(filteredTimelineNotificationItemSummaries, disableNotificationBadge) => (
(!disableNotificationBadge && filteredTimelineNotificationItemSummaries)
? filteredTimelineNotificationItemSummaries.length
: 0
)
)
store.compute('hasNotifications',
['numberOfNotifications', 'currentPage'],
(numberOfNotifications, currentPage) => currentPage !== 'notifications' && !!numberOfNotifications
(numberOfNotifications, currentPage) => (
currentPage !== 'notifications' && !!numberOfNotifications
)
)
}

View File

@ -1,3 +1,5 @@
import { get } from '../../_utils/lodash-lite'
export function instanceMixins (Store) {
Store.prototype.setComposeData = function (realm, obj) {
let { composeData, currentInstance } = this.get()
@ -20,4 +22,18 @@ export function instanceMixins (Store) {
}
this.set({ composeData })
}
Store.prototype.getInstanceSetting = function (instanceName, settingName, defaultValue) {
let { instanceSettings } = this.get()
return get(instanceSettings, [instanceName, settingName], defaultValue)
}
Store.prototype.setInstanceSetting = function (instanceName, settingName, value) {
let { instanceSettings } = this.get()
if (!instanceSettings[instanceName]) {
instanceSettings[instanceName] = {}
}
instanceSettings[instanceName][settingName] = value
this.set({ instanceSettings })
}
}

View File

@ -0,0 +1,16 @@
import { switchToTheme } from '../../_utils/themeEngine'
const style = process.browser && document.getElementById('theGrayscaleStyle')
export function grayscaleObservers (store) {
if (!process.browser) {
return
}
store.observe('enableGrayscale', enableGrayscale => {
const { instanceThemes, currentInstance } = store.get()
const theme = instanceThemes && instanceThemes[currentInstance]
style.setAttribute('media', enableGrayscale ? 'all' : 'only x') // disable or enable the style
switchToTheme(theme, enableGrayscale)
})
}

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,17 +1,21 @@
import { onlineObservers } from './onlineObservers'
import { nowObservers } from './nowObservers'
import { navObservers } from './navObservers'
import { pageVisibilityObservers } from './pageVisibilityObservers'
import { resizeObservers } from './resizeObservers'
import { setupLoggedInObservers } from './setupLoggedInObservers'
import { logOutObservers } from './logOutObservers'
import { touchObservers } from './touchObservers'
import { grayscaleObservers } from './grayscaleObservers'
export function observers (store) {
onlineObservers(store)
nowObservers(store)
navObservers(store)
pageVisibilityObservers(store)
resizeObservers(store)
touchObservers(store)
logOutObservers(store)
grayscaleObservers(store)
setupLoggedInObservers(store)
}

View File

@ -6,8 +6,6 @@ const NOTIFY_OFFLINE_LIMIT = 1
let notifyCount = 0
let offlineStyle = process.browser && document.getElementById('theOfflineStyle')
// debounce to avoid notifying for a short connection issue
const notifyOffline = debounce(() => {
if (process.browser && !navigator.onLine && ++notifyCount <= NOTIFY_OFFLINE_LIMIT) {
@ -19,20 +17,9 @@ export function onlineObservers (store) {
if (!process.browser) {
return
}
let meta = document.getElementById('theThemeColor')
let oldTheme = meta.content
store.observe('online', online => {
// "only x" ensures the <style> tag does not have any effect
offlineStyle.setAttribute('media', online ? 'only x' : 'all')
if (online) {
meta.content = oldTheme
} else {
let offlineThemeColor = window.__themeColors.offline
if (meta.content !== offlineThemeColor) {
oldTheme = meta.content
}
meta.content = offlineThemeColor
if (!online) {
notifyOffline()
}
})

View File

@ -0,0 +1,3 @@
export function safeParse (str) {
return !str ? undefined : (str === 'undefined' ? undefined : JSON.parse(str))
}

View File

@ -12,13 +12,20 @@ const persistedState = {
currentRegisteredInstance: undefined,
// we disable scrollbars by default on iOS
disableCustomScrollbars: process.browser && /iP(?:hone|ad|od)/.test(navigator.userAgent),
disableFavCounts: false,
disableFollowerCounts: false,
disableHotkeys: false,
disableInfiniteScroll: false,
disableLongAriaLabels: false,
disableNotificationBadge: false,
disableReblogCounts: false,
disableTapOnStatus: false,
enableGrayscale: false,
hideCards: false,
largeInlineMedia: false,
instanceNameInSearch: '',
instanceThemes: {},
instanceSettings: {},
loggedInInstances: {},
loggedInInstancesInOrder: [],
markMediaAsSensitive: false,
@ -38,6 +45,7 @@ const nonPersistedState = {
instanceLists: {},
online: !process.browser || navigator.onLine,
pinnedStatuses: {},
polls: {},
pushNotificationsSupport:
process.browser &&
('serviceWorker' in navigator &&

View File

@ -0,0 +1,20 @@
// "lite" version of the store used in the inline script. Purely read-only,
// does not implement non-LocalStorage store features.
import { safeParse } from './safeParse'
import { testHasLocalStorageOnce } from '../_utils/testStorage'
const hasLocalStorage = testHasLocalStorageOnce()
export const storeLite = {
get () {
return new Proxy({}, {
get: function (obj, prop) {
if (!(prop in obj)) {
obj[prop] = hasLocalStorage && safeParse(localStorage.getItem(`store_${prop}`))
}
return obj[prop]
}
})
}
}

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

21
src/routes/_thirdparty/unescape/LICENSE vendored Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014, 2016-2017, Jon Schlinkert
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,63 @@
// via https://github.com/jonschlinkert/unescape/blob/98d1e52/index.js
const chars = {
'&quot;': '"',
'&#34;': '"',
'&apos;': '\'',
'&#39;': '\'',
'&amp;': '&',
'&#38;': '&',
'&gt;': '>',
'&#62;': '>',
'&lt;': '<',
'&#60;': '<',
'&cent;': '¢',
'&#162;': '¢',
'&copy;': '©',
'&#169;': '©',
'&euro;': '€',
'&#8364;': '€',
'&pound;': '£',
'&#163;': '£',
'&reg;': '®',
'&#174;': '®',
'&yen;': '¥',
'&#165;': '¥',
'&nbsp;': ' '
}
let regex
/**
* Convert HTML entities to HTML characters.
*
* @param {String} `str` String with HTML entities to un-escape.
* @return {String}
*/
function unescape (str) {
regex = regex || toRegex(chars)
return str.replace(regex, m => chars[m])
}
function toRegex (chars) {
var keys = Object.keys(chars).join('|')
return new RegExp('(' + keys + ')', 'g')
}
/**
* Expose `unescape`
*/
export { unescape }

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

@ -43,3 +43,7 @@ export const importToast = () => import(
export const importSnackbar = () => import(
/* webpackChunkName: 'Snackbar.html' */ '../_components/snackbar/Snackbar.html'
).then(getDefault)
export const importComposeBox = () => import(
/* webpackChunkName: 'ComposeBox.html' */ '../_components/compose/ComposeBox.html'
).then(getDefault)

View File

@ -1,11 +1,22 @@
import { snackbar } from '../_components/snackbar/snackbar'
async function skipWaiting () {
const reg = await navigator.serviceWorker.getRegistration()
if (!reg || !reg.waiting) {
return
}
reg.waiting.postMessage('skip-waiting')
}
function onUpdateFound (registration) {
const newWorker = registration.installing
newWorker.addEventListener('statechange', async () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
snackbar.announce('App update available.', 'Reload', () => document.location.reload(true))
snackbar.announce('App update available.', 'Reload', async () => {
await skipWaiting()
document.location.reload(true)
})
}
})
}

View File

@ -1,9 +1,9 @@
let meta = process.browser && document.getElementById('theThemeColor')
let offlineStyle = process.browser && document.getElementById('theOfflineStyle')
let prefersDarkTheme = process.browser && window.matchMedia('(prefers-color-scheme: dark)').matches
const prefersDarkTheme = process.browser && window.matchMedia('(prefers-color-scheme: dark)').matches
const meta = process.browser && document.getElementById('theThemeColor')
export const DEFAULT_LIGHT_THEME = 'default'
export const DEFAULT_DARK_THEME = 'ozark'
export const INLINE_THEME = 'default' // theme that does not require external CSS
export const DEFAULT_LIGHT_THEME = 'default' // theme that is shown by default
export const DEFAULT_DARK_THEME = 'ozark' // theme that is shown for prefers-color-scheme:dark
export const DEFAULT_THEME = prefersDarkTheme ? DEFAULT_DARK_THEME : DEFAULT_LIGHT_THEME
function getExistingThemeLink () {
@ -31,14 +31,16 @@ function loadCSS (href) {
}
})
// inserting before the offline <style> ensures that the offline style wins when offline
document.head.insertBefore(link, offlineStyle)
document.head.appendChild(link)
}
export function switchToTheme (themeName = DEFAULT_THEME) {
export function switchToTheme (themeName = DEFAULT_THEME, enableGrayscale) {
if (enableGrayscale) {
themeName = prefersDarkTheme ? 'grayscale-dark' : 'grayscale'
}
let themeColor = window.__themeColors[themeName]
meta.content = themeColor || window.__themeColors[DEFAULT_THEME]
if (themeName !== DEFAULT_LIGHT_THEME) {
if (themeName !== INLINE_THEME) {
loadCSS(`/theme-${themeName}.css`)
} else {
resetExistingTheme()

View File

@ -0,0 +1,20 @@
<Title name="Wellness Settings" settingsPage={true} />
<LazyPage {pageComponent} {params} />
<script>
import Title from '../_components/Title.html'
import LazyPage from '../_components/LazyPage.html'
import pageComponent from '../_pages/settings/wellness.html'
export default {
components: {
Title,
LazyPage
},
data: () => ({
pageComponent
})
}
</script>

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,10 @@
--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%)};
--length-indicator-color: #{$main-theme-color};
}

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};
@ -46,4 +46,6 @@
--tab-bg-hover-non-selected: #{darken($main-bg-color, 1%)};
--toast-anchor-color: #{$anchor-color};
--length-indicator-color: var(--action-button-fill-color);
}

View File

@ -17,7 +17,11 @@ $compose-background: lighten($main-theme-color, 32%);
:root {
--settings-list-item-text: #{$main-text-color};
--settings-list-item-text-hover: #{$main-text-color};
--action-button-fill-color: #{lighten($main-theme-color, 30%)};
--action-button-fill-color-hover: #{lighten($main-theme-color, 35%)};
--action-button-fill-color-active: #{lighten($main-theme-color, 40%)};
--action-button-fill-color-pressed: #{$anchor-color};
--action-button-fill-color-pressed-hover: #{darken($anchor-color, 2%)};
--action-button-fill-color-pressed-active: #{darken($anchor-color, 15%)};
@ -30,4 +34,4 @@ $compose-background: lighten($main-theme-color, 32%);
--nav-text-color-hover: #{$main-text-color};
--nav-a-selected-border: #{$anchor-color};
--nav-a-selected-border-hover: #{$anchor-color};
}
}

View File

@ -0,0 +1,16 @@
$main-theme-color: #444;
$main-bg-color: #202020;
$body-bg-color: darken($main-bg-color, 5%);
$anchor-color: #999;
$main-text-color: #FFF;
$border-color: lighten($body-bg-color, 10%);
$secondary-text-color: white;
$toast-border: #fafafa;
$toast-bg: #333;
$focus-outline: lighten($main-theme-color, 50%);
$compose-background: lighten($main-theme-color, 52%);
@import "_base.scss";
@import "_dark.scss";
@import "_dark_navbar.scss";
@import "_dark_scrollbars.scss";

View File

@ -1,6 +1,6 @@
$main-theme-color: #999999;
$main-theme-color: #666;
$body-bg-color: lighten($main-theme-color, 38%);
$anchor-color: $main-theme-color;
$anchor-color: lighten($main-theme-color, 5%);
$main-text-color: #333;
$border-color: #dadada;
$main-bg-color: white;
@ -11,4 +11,4 @@ $focus-outline: lighten($main-theme-color, 15%);
$compose-background: lighten($main-theme-color, 17%);
@import "_base.scss";
@import "_light_scrollbars.scss";
@import "_light_scrollbars.scss";

View File

@ -29,9 +29,9 @@ $compose-background: darken($main-theme-color, 12%);
--form-border: #{darken($border-color, 10%)};
--action-button-fill-color: #{$main-theme-color};
--action-button-fill-color-hover: #{lighten($main-theme-color, 4%)};
--action-button-fill-color-active: #{darken($main-theme-color, 13%)};
--action-button-fill-color: #{lighten($main-theme-color, 5%)};
--action-button-fill-color-hover: #{lighten($main-theme-color, 12%)};
--action-button-fill-color-active: #{darken($main-theme-color, 15%)};
--action-button-fill-color-pressed: #{lighten($main-theme-color, 20%)};
--action-button-fill-color-pressed-hover: #{lighten($main-theme-color, 24%)};
--action-button-fill-color-pressed-active: #{lighten($main-theme-color, 7%)};

View File

@ -0,0 +1,5 @@
@import "rio.scss";
img {
filter: grayscale(100%);
}

51
src/scss/themes/rio.scss Normal file
View File

@ -0,0 +1,51 @@
$main-theme-color: #999999;
$body-bg-color: #4D4D4D;
$anchor-color: #2A5858;
$main-text-color: #000000;
$border-color: #9EEEEE;
$main-bg-color: #FFFFFF;
$secondary-text-color: #444444;
$toast-border: #9EEEEE;
$toast-bg: #999999;
$focus-outline: #55AAAA;
$compose-background: lighten($main-theme-color, 32%);
@import "_base.scss";
$scrollbar-face: #FFFFFF !default;
$scrollbar-face-hover: #FFFFFF !default;
$scrollbar-face-active: #FFFFFF !default;
$scrollbar-track: #999999 !default;
@import "_scrollbars.scss";
:root {
/* idea is navbar as rio's right-click menu */
--nav-bg: #E9FFE9;
--nav-border: #88CC88;
--nav-active-bg: #448844;
--nav-a-selected-bg: #448844;
--nav-a-selected-active-bg: #448844;
--nav-text-color: #000000;
--nav-text-color-hover: #FFFFFF;
--nav-a-bg-hover: #448844;
--nav-a-selected-border: #88CCCC;
--nav-a-border-hover: #88CCCC;
--settings-list-item-text: #{$anchor-color};
}
a, a.main-nav-link.svelte-my25xk, .settings-list-item {
text-decoration: underline;
}
a.mention.u-url, a:hover, a:active, a:focus, ::selection {
background: #CCCCCC;
}
a.mention.u-url:hover, a.mention.u-url:active, a.mention.u-url:focus, a::selection, a ::selection {
background: #2A5858;
color: #CCCCCC;
}
a.status-author-name, a.status-header-author {
text-decoration: none;
border-bottom: solid 1px #000000;
}

View File

@ -35,6 +35,9 @@ self.addEventListener('install', event => {
caches.open(WEBPACK_ASSETS).then(cache => cache.addAll(webpackAssets)),
caches.open(ASSETS).then(cache => cache.addAll(assets))
])
// We shouldn't have to do this, but the previous page could be an old one,
// which would not send us a postMessage to skipWaiting().
// See https://github.com/nolanlawson/pinafore/issues/1243
self.skipWaiting()
})())
})
@ -46,7 +49,7 @@ self.addEventListener('activate', event => {
// delete old asset/ondemand caches
for (let key of keys) {
if (key !== ASSETS &&
!key.startsWith('webpack_assets_')) {
!key.startsWith('webpack_assets_')) {
await caches.delete(key)
}
}
@ -131,86 +134,78 @@ async function showSimpleNotification (data) {
}
async function showRichNotification (data, notification) {
const { origin } = new URL(data.icon)
const { icon, body } = data
const tag = notification.id
const { origin } = self.location
const badge = '/icon-push-badge.png'
switch (notification.type) {
case 'follow': {
await self.registration.showNotification(data.title, {
icon: data.icon,
body: data.body,
tag: notification.id,
badge,
icon,
body,
tag,
data: {
url: `${self.location.origin}/accounts/${notification.account.id}`
url: `${origin}/accounts/${notification.account.id}`
}
})
break
}
case 'mention': {
const actions = [{
action: 'favourite',
title: 'Favourite'
}]
if ('reply' in NotificationEvent.prototype) {
actions.splice(0, 0, {
action: 'reply',
type: 'text',
title: 'Reply'
})
}
if (['public', 'unlisted'].includes(notification.status.visibility)) {
actions.push({
case 'reblog':
case 'favourite':
case 'poll':
await self.registration.showNotification(data.title, {
badge,
icon,
body,
tag,
data: {
url: `${origin}/statuses/${notification.status.id}`
}
})
break
case 'mention':
const isPublic = ['public', 'unlisted'].includes(notification.status.visibility)
const actions = [
isPublic && {
action: 'reblog',
icon: '/icon-push-fa-retweet.png', // generated manually from font-awesome-svg
title: 'Boost'
})
}
},
{
action: 'favourite',
icon: '/icon-push-fa-star.png', // generated manually from font-awesome-svg
title: 'Favorite'
}
].filter(Boolean)
await self.registration.showNotification(data.title, {
icon: data.icon,
body: data.body,
tag: notification.id,
badge,
icon,
body,
tag,
data: {
instance: origin,
instance: new URL(data.icon).origin,
status_id: notification.status.id,
access_token: data.access_token,
url: `${self.location.origin}/statuses/${notification.status.id}`
url: `${origin}/statuses/${notification.status.id}`
},
actions
})
break
}
case 'reblog': {
await self.registration.showNotification(data.title, {
icon: data.icon,
body: data.body,
tag: notification.id,
data: {
url: `${self.location.origin}/statuses/${notification.status.id}`
}
})
break
}
case 'favourite': {
await self.registration.showNotification(data.title, {
icon: data.icon,
body: data.body,
tag: notification.id,
data: {
url: `${self.location.origin}/statuses/${notification.status.id}`
}
})
break
}
}
}
const cloneNotification = notification => {
const clone = { }
const clone = {}
// Object.assign() does not work with notifications
for (let k in notification) {
clone[k] = notification[k]
// deliberately not doing a hasOwnProperty check, but skipping
// functions and null props like onclick and onshow and showTrigger
if (typeof notification[k] !== 'function' && notification[k] !== null) {
clone[k] = notification[k]
}
}
return clone
@ -227,21 +222,19 @@ const updateNotificationWithoutAction = (notification, action) => {
self.addEventListener('notificationclick', event => {
event.waitUntil((async () => {
switch (event.action) {
case 'reply': {
await post(`${event.notification.data.instance}/api/v1/statuses/`, {
status: event.reply,
in_reply_to_id: event.notification.data.status_id
}, { 'Authorization': `Bearer ${event.notification.data.access_token}` })
await updateNotificationWithoutAction(event.notification, 'reply')
break
}
case 'reblog': {
await post(`${event.notification.data.instance}/api/v1/statuses/${event.notification.data.status_id}/reblog`, null, { 'Authorization': `Bearer ${event.notification.data.access_token}` })
const url = `${event.notification.data.instance}/api/v1/statuses/${event.notification.data.status_id}/reblog`
await post(url, null, {
'Authorization': `Bearer ${event.notification.data.access_token}`
})
await updateNotificationWithoutAction(event.notification, 'reblog')
break
}
case 'favourite': {
await post(`${event.notification.data.instance}/api/v1/statuses/${event.notification.data.status_id}/favourite`, null, { 'Authorization': `Bearer ${event.notification.data.access_token}` })
const url = `${event.notification.data.instance}/api/v1/statuses/${event.notification.data.status_id}/favourite`
await post(url, null, {
'Authorization': `Bearer ${event.notification.data.access_token}`
})
await updateNotificationWithoutAction(event.notification, 'favourite')
break
}
@ -253,3 +246,11 @@ self.addEventListener('notificationclick', event => {
}
})())
})
self.addEventListener('message', (event) => {
switch (event.data) {
case 'skip-waiting':
self.skipWaiting()
break
}
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
static/icon-push-badge.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 905 B

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

@ -1,22 +1,22 @@
import {
getUrl, notificationFiltersAll, notificationFiltersMention,
getUrl, notificationsTabAll, notificationsTabMentions,
notificationsNavButton, validateTimeline
} from '../utils'
import { loginAsFoobar } from '../roles'
import { notificationsMentions, notifications } from '../fixtures'
fixture`033-notification-filters.js`
fixture`033-notification-mentions.js`
.page`http://localhost:4002`
test('Shows notification filters', async t => {
test('Shows notification mentions', async t => {
await loginAsFoobar(t)
await t
.click(notificationsNavButton)
.expect(getUrl()).match(/\/notifications$/)
.click(notificationFiltersMention)
.click(notificationsTabMentions)
.expect(getUrl()).match(/\/notifications\/mentions$/)
await validateTimeline(t, notificationsMentions)
await t.click(notificationFiltersAll)
await t.click(notificationsTabAll)
.expect(getUrl()).match(/\/notifications$/)
await validateTimeline(t, notifications)
})

View File

@ -0,0 +1,23 @@
import {
validateTimeline, settingsNavButton, instanceSettingHomeReblogs, homeNavButton
} from '../utils'
import { loginAsFoobar } from '../roles'
import { homeTimeline } from '../fixtures'
import { Selector as $ } from 'testcafe'
fixture`034-home-timeline-filters.js`
.page`http://localhost:4002`
test('Filters reblogs from home timeline', async t => {
await loginAsFoobar(t)
await t
.click(settingsNavButton)
.click($('a').withText('Instances'))
.click($('a').withText('localhost:3000'))
.click(instanceSettingHomeReblogs)
.expect(instanceSettingHomeReblogs.checked).notOk()
.click(homeNavButton)
await validateTimeline(t, homeTimeline.filter(({ content }) => {
return content !== 'pinned toot 1'
}))
})

View File

@ -0,0 +1,48 @@
import {
validateTimeline,
settingsNavButton,
instanceSettingNotificationReblogs,
notificationsNavButton,
instanceSettingNotificationFavs,
instanceSettingNotificationFollows,
instanceSettingNotificationMentions
} from '../utils'
import { loginAsFoobar } from '../roles'
import { notifications } from '../fixtures'
import { Selector as $ } from 'testcafe'
fixture`035-notification-timeline-filters.js`
.page`http://localhost:4002`
function setSettingAndGoToNotifications (t, setting) {
return t.click(settingsNavButton)
.click($('a').withText('Instances'))
.click($('a').withText('localhost:3000'))
.click(setting)
.expect(setting.checked).notOk()
.click(notificationsNavButton)
}
test('Filters reblogs from notification timeline', async t => {
await loginAsFoobar(t)
await setSettingAndGoToNotifications(t, instanceSettingNotificationReblogs)
await validateTimeline(t, notifications.filter(_ => !_.rebloggedBy))
})
test('Filters favs from notification timeline', async t => {
await loginAsFoobar(t)
await setSettingAndGoToNotifications(t, instanceSettingNotificationFavs)
await validateTimeline(t, notifications.filter(_ => !_.favoritedBy))
})
test('Filters follows from notification timeline', async t => {
await loginAsFoobar(t)
await setSettingAndGoToNotifications(t, instanceSettingNotificationFollows)
await validateTimeline(t, notifications.filter(_ => !_.followedBy))
})
test('Filters mentions from notification timeline', async t => {
await loginAsFoobar(t)
await setSettingAndGoToNotifications(t, instanceSettingNotificationMentions)
await validateTimeline(t, notifications.filter(_ => !_.content))
})

View File

@ -0,0 +1,39 @@
import {
settingsNavButton,
homeNavButton,
disableInfiniteScroll,
scrollToStatus,
loadMoreButton, getFirstVisibleStatus, scrollFromStatusToStatus, sleep, getActiveElementAriaPosInSet
} from '../utils'
import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'
fixture`036-disable-infinite-load.js`
.page`http://localhost:4002`
test('Can disable loading items at bottom of timeline', async t => {
await loginAsFoobar(t)
await t.click(settingsNavButton)
.click($('a').withText('General'))
.click(disableInfiniteScroll)
.expect(disableInfiniteScroll.checked).ok()
.click(homeNavButton)
.expect(getFirstVisibleStatus().getAttribute('aria-setsize')).eql('20')
await scrollToStatus(t, 20)
await t
.click(loadMoreButton)
.expect(getActiveElementAriaPosInSet()).eql('20')
.expect(getFirstVisibleStatus().getAttribute('aria-setsize')).eql('40')
await scrollFromStatusToStatus(t, 20, 40)
await t
.click(loadMoreButton)
.expect(getActiveElementAriaPosInSet()).eql('40')
.expect(getFirstVisibleStatus().getAttribute('aria-setsize')).eql('47')
await scrollFromStatusToStatus(t, 40, 47)
await t
.click(loadMoreButton)
await sleep(1000)
await t
.expect(loadMoreButton.exists).ok()
.expect(getFirstVisibleStatus().getAttribute('aria-setsize')).eql('47')
})

View File

@ -1,12 +1,12 @@
import {
getNthStatusContent,
getUrl, notificationFiltersAll, notificationFiltersMention,
getUrl, notificationsTabAll, notificationsTabMentions,
notificationsNavButton, sleep
} from '../utils'
import { loginAsFoobar } from '../roles'
import { favoriteStatusAs, postAs } from '../serverActions'
fixture`123-notification-filters.js`
fixture`123-notification-mentions.js`
.page`http://localhost:4002`
// maybe in the "mentions" view it should prevent the notification icon from showing (1), (2) etc
@ -18,7 +18,7 @@ test('Handles incoming notifications that are mentions', async t => {
await t
.click(notificationsNavButton)
.expect(getUrl()).match(/\/notifications$/)
.click(notificationFiltersMention)
.click(notificationsTabMentions)
.expect(getUrl()).match(/\/notifications\/mentions$/)
await sleep(2000)
await postAs('admin', 'hey @foobar I am mentioning you')
@ -27,7 +27,7 @@ test('Handles incoming notifications that are mentions', async t => {
timeout
})
.expect(getNthStatusContent(1).innerText).contains('hey @foobar I am mentioning you')
.click(notificationFiltersAll)
.click(notificationsTabAll)
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications (current page)', { timeout })
})
@ -39,7 +39,7 @@ test('Handles incoming notifications that are not mentions', async t => {
await t
.click(notificationsNavButton)
.expect(getUrl()).match(/\/notifications$/)
.click(notificationFiltersMention)
.click(notificationsTabMentions)
.expect(getUrl()).match(/\/notifications\/mentions$/)
await sleep(2000)
await postAs('admin', 'woot I am mentioning you again @foobar')
@ -57,7 +57,7 @@ test('Handles incoming notifications that are not mentions', async t => {
await sleep(2000)
await t
.expect(getNthStatusContent(1).innerText).contains('woot I am mentioning you again @foobar')
.click(notificationFiltersAll)
.click(notificationsTabAll)
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications (current page)', { timeout })
await t
.expect(getNthStatusContent(1).innerText).contains('this is a post that I hope somebody will favorite')

View File

@ -0,0 +1,29 @@
import {
settingsNavButton, instanceSettingHomeReblogs, homeNavButton, sleep, getNthStatusContent
} from '../utils'
import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'
import { postAs, reblogStatusAs } from '../serverActions'
fixture`124-home-timeline-filters.js`
.page`http://localhost:4002`
test('Filters favs from home timeline', async t => {
await postAs('foobar', 'Nobody should boost this')
await sleep(1000)
let { id: statusId } = await postAs('quux', 'I hope someone cool boosts this')
await reblogStatusAs('admin', statusId)
await sleep(2000)
await loginAsFoobar(t)
await t
.expect(getNthStatusContent(1).innerText).contains('I hope someone cool boosts this')
.expect(getNthStatusContent(2).innerText).contains('Nobody should boost this')
.click(settingsNavButton)
.click($('a').withText('Instances'))
.click($('a').withText('localhost:3000'))
.click(instanceSettingHomeReblogs)
.expect(instanceSettingHomeReblogs.checked).notOk()
.click(homeNavButton)
await t
.expect(getNthStatusContent(1).innerText).contains('Nobody should boost this')
})

View File

@ -0,0 +1,127 @@
import {
settingsNavButton,
getNthStatusContent,
instanceSettingNotificationReblogs,
notificationBadge,
instanceSettingNotificationFavs,
instanceSettingNotificationMentions,
instanceSettingNotificationFollows,
notificationsNavButton,
getUrl,
sleep, showMoreButton, scrollToBottom, scrollToTop
} 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')
})
test('Notification timeline filters correctly show "show more" button', async t => {
await loginAsFoobar(t)
await t
.click(settingsNavButton)
.click($('a').withText('Instances'))
.click($('a').withText('localhost:3000'))
.click(instanceSettingNotificationMentions)
.expect(instanceSettingNotificationMentions.checked).notOk()
.click(notificationsNavButton)
.expect(getUrl()).contains('/notifications')
.expect(getNthStatusContent(1).exists).ok()
await scrollToBottom()
await sleep(1000)
await postAs('admin', 'hey @foobar you should ignore this')
await sleep(1000)
await scrollToTop()
await sleep(1000)
await t
.expect(showMoreButton.innerText).contains('Show 0 more') // not shown
await scrollToBottom()
await sleep(1000)
await followAs('ExternalLinks', 'foobar')
await sleep(1000)
await scrollToTop()
await sleep(1000)
await t
.expect(showMoreButton.innerText).contains('Show 1 more', { timeout: 20000 })
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

@ -0,0 +1,32 @@
import {
settingsNavButton,
homeNavButton,
disableInfiniteScroll,
getFirstVisibleStatus,
getUrl,
showMoreButton, getNthStatusContent
} from '../utils'
import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'
import { postAs } from '../serverActions'
fixture`128-disable-infinite-load.js`
.page`http://localhost:4002`
test('Can disable loading items at top of timeline', async t => {
await loginAsFoobar(t)
await t.click(settingsNavButton)
.click($('a').withText('General'))
.click(disableInfiniteScroll)
.expect(disableInfiniteScroll.checked).ok()
.click(homeNavButton)
.expect(getUrl()).eql('http://localhost:4002/')
.expect(getFirstVisibleStatus().exists).ok()
await postAs('admin', 'hey hey hey this is new')
await t
.expect(showMoreButton.innerText).contains('Show 1 more', {
timeout: 20000
})
.click(showMoreButton)
.expect(getNthStatusContent(1).innerText).contains('hey hey hey this is new')
})

View File

@ -0,0 +1,36 @@
import {
settingsNavButton,
homeNavButton,
disableUnreadNotifications,
getFirstVisibleStatus,
getUrl,
notificationsNavButton, getTitleText, sleep
} from '../utils'
import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'
import { postAs } from '../serverActions'
fixture`129-wellness.js`
.page`http://localhost:4002`
test('Can disable unread notification counts', async t => {
await loginAsFoobar(t)
await t.click(settingsNavButton)
.click($('a').withText('Wellness'))
.click(disableUnreadNotifications)
.expect(disableUnreadNotifications.checked).ok()
.click(homeNavButton)
.expect(getUrl()).eql('http://localhost:4002/')
.expect(getFirstVisibleStatus().exists).ok()
await postAs('admin', 'hey @foobar')
await sleep(2000)
await t
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications')
.expect(getTitleText()).notContains('(1)')
.click(settingsNavButton)
.click($('a').withText('Wellness'))
.click(disableUnreadNotifications)
.expect(disableUnreadNotifications.checked).notOk()
.expect(notificationsNavButton.getAttribute('aria-label')).eql('Notifications (1 notification)')
.expect(getTitleText()).contains('(1)')
})

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)')
@ -47,16 +48,25 @@ export const generalSettingsButton = $('a[href="/settings/general"]')
export const markMediaSensitiveInput = $('#choice-mark-media-sensitive')
export const neverMarkMediaSensitiveInput = $('#choice-never-mark-media-sensitive')
export const removeEmojiFromDisplayNamesInput = $('#choice-omit-emoji-in-display-names')
export const disableInfiniteScroll = $('#choice-disable-infinite-scroll')
export const disableUnreadNotifications = $('#choice-disable-unread-notification-counts')
export const dialogOptionsOption = $(`.modal-dialog button`)
export const emojiSearchInput = $('.emoji-mart-search input')
export const confirmationDialogOKButton = $('.confirmation-dialog-form-flex button:nth-child(1)')
export const confirmationDialogCancelButton = $('.confirmation-dialog-form-flex button:nth-child(2)')
export const loadMoreButton = $('.loading-footer button')
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')
@ -64,11 +74,19 @@ export const accountProfileFilterStatuses = $('.account-profile-filters li:nth-c
export const accountProfileFilterStatusesAndReplies = $('.account-profile-filters li:nth-child(2)')
export const accountProfileFilterMedia = $('.account-profile-filters li:nth-child(3)')
export const notificationFiltersAll = $('.notification-filters li:nth-child(1)')
export const notificationFiltersMention = $('.notification-filters li:nth-child(2)')
export const notificationsTabAll = $('.notification-filters li:nth-child(1)')
export const notificationsTabMentions = $('.notification-filters li:nth-child(2)')
export const instanceSettingHomeReblogs = $('#instance-option-homeReblogs')
export const instanceSettingNotificationFollows = $('#instance-option-notificationFollows')
export const instanceSettingNotificationFavs = $('#instance-option-notificationFavs')
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) {
@ -105,6 +123,10 @@ export const getActiveElementRectTop = exec(() => (
(document.activeElement && document.activeElement.getBoundingClientRect().top) || -1
))
export const getActiveElementAriaPosInSet = exec(() => (
(document.activeElement && document.activeElement.getAttribute('aria-posinset')) || ''
))
export const getActiveElementInsideNthStatus = exec(() => {
let element = document.activeElement
while (element) {
@ -197,7 +219,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) {
@ -209,7 +231,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) {
@ -293,11 +347,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) {
@ -382,16 +436,20 @@ export async function validateTimeline (t, timeline) {
}
export async function scrollToStatus (t, n) {
return scrollFromStatusToStatus(t, 1, n)
}
export async function scrollFromStatusToStatus (t, start, end) {
let timeout = 20000
for (let i = 1; i < n; i++) {
for (let i = start; i < end; i++) {
await t.expect(getNthStatus(i).exists).ok({ timeout })
.hover(getNthStatus(i))
.expect($('.loading-footer').exist).notOk({ timeout })
.expect($(`${getNthStatusSelector(i)} .status-toolbar`).exists).ok({ timeout })
.hover($(`${getNthStatusSelector(i)} .status-toolbar`))
.expect($('.loading-footer').exist).notOk({ timeout })
}
await t.hover(getNthStatus(n))
await t
.expect(getNthStatus(end).exists).ok({ timeout })
.hover(getNthStatus(end))
}
export async function clickToNotificationsAndBackHome (t) {

1325
yarn.lock

File diff suppressed because it is too large Load Diff