Compare commits
63 Commits
Author | SHA1 | Date |
---|---|---|
'leftie | 268a7ce3b6 | |
'leftie | d9fba6d79d | |
Nolan Lawson | 155cb05e39 | |
Nolan Lawson | 5d0e95e759 | |
Nolan Lawson | 58a8772edc | |
Nolan Lawson | d75507bbce | |
Nolan Lawson | 604471a158 | |
Nolan Lawson | f5c7bc790f | |
Nolan Lawson | 74230cfe8e | |
Nolan Lawson | a35f5ee2d9 | |
Nolan Lawson | 27864fc47f | |
'leftie | 210e942398 | |
'leftie | d1a3f007bc | |
'leftie | 913ce6eaaa | |
'leftie | 3183bb4cb1 | |
'leftie | 3bc8d0d32e | |
'leftie | dcedf2c8d6 | |
'leftie | d4703e7c9d | |
Nolan Lawson | fcf64c2169 | |
Nolan Lawson | 45630c185f | |
Nolan Lawson | 44a87dcd9a | |
Nolan Lawson | 8672ade314 | |
Nolan Lawson | fa2eb8fe52 | |
'leftie | f8ed3ff292 | |
Nolan Lawson | 0de6c3a09f | |
Nolan Lawson | 34e82cbaf2 | |
Nolan Lawson | f1857cb86e | |
Nolan Lawson | 3453b10ffb | |
'leftie | c41d5908b8 | |
'leftie | 7406cf326c | |
'leftie | cde4cd0a61 | |
Nolan Lawson | 8c74d0c7c8 | |
'leftie | ccf77b83eb | |
Nolan Lawson | 3a2c56f0fa | |
'leftie | 77aead72fb | |
Nolan Lawson | 164768e6c9 | |
Nolan Lawson | 3a7d6d3552 | |
Nolan Lawson | 12179505e1 | |
Nolan Lawson | 482ee3d3bb | |
Nolan Lawson | 37d3cac7d2 | |
Nolan Lawson | b45868bbfd | |
Nolan Lawson | 6efc28aac8 | |
Nolan Lawson | 0878275ab9 | |
Nolan Lawson | 2c1de66592 | |
Nolan Lawson | 45441d3a9e | |
Nolan Lawson | dac4b493c8 | |
Nolan Lawson | bf640b9b0f | |
Nolan Lawson | 8f477eeccb | |
greenkeeper[bot] | 979bb4815f | |
'leftie | 8bf2a6d956 | |
Nolan Lawson | 12c5b732ae | |
Nolan Lawson | a17948cf99 | |
Nolan Lawson | 92bff6caaa | |
Nolan Lawson | 02689bec93 | |
Nolan Lawson | c18168d913 | |
sgenoud | af955492e8 | |
Nolan Lawson | 692e8b57c3 | |
Nolan Lawson | d92bd2e94b | |
greenkeeper[bot] | 5178650e78 | |
'leftie | 5a2be30d4d | |
'leftie | 3136c5cd98 | |
greenkeeper[bot] | 9862858b2e | |
Cătălin Mariș | cdade05315 |
|
@ -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`
|
||||
}
|
||||
|
||||
|
|
|
@ -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' }
|
||||
]
|
||||
|
|
30
package.json
30
package.json
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -22,7 +22,7 @@ export async function insertHandleForReply (statusId) {
|
|||
|
||||
export async function postStatus (realm, text, inReplyToId, mediaIds,
|
||||
sensitive, spoilerText, visibility,
|
||||
mediaDescriptions, inReplyToUuid) {
|
||||
mediaDescriptions, inReplyToUuid, poll) {
|
||||
let { currentInstance, accessToken, online } = store.get()
|
||||
|
||||
if (!online) {
|
||||
|
@ -41,7 +41,7 @@ export async function postStatus (realm, text, inReplyToId, mediaIds,
|
|||
return description && putMediaDescription(currentInstance, accessToken, mediaIds[i], description)
|
||||
}))
|
||||
let status = await postStatusToServer(currentInstance, accessToken, text,
|
||||
inReplyToId, mediaIds, sensitive, spoilerText, visibility)
|
||||
inReplyToId, mediaIds, sensitive, spoilerText, visibility, poll)
|
||||
addStatusOrNotification(currentInstance, 'home', status)
|
||||
store.clearComposeData(realm)
|
||||
emit('postedStatus', realm, inReplyToUuid)
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import { store } from '../_store/store'
|
||||
|
||||
export function enablePoll (realm) {
|
||||
store.setComposeData(realm, {
|
||||
poll: {
|
||||
options: [
|
||||
'',
|
||||
''
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function disablePoll (realm) {
|
||||
store.setComposeData(realm, {
|
||||
poll: null
|
||||
})
|
||||
}
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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 || ''))
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import { get, post, DEFAULT_TIMEOUT, WRITE_TIMEOUT } from '../_utils/ajax'
|
||||
import { auth, basename } from './utils'
|
||||
|
||||
export async function getPoll (instanceName, accessToken, pollId) {
|
||||
let url = `${basename(instanceName)}/api/v1/polls/${pollId}`
|
||||
return get(url, auth(accessToken), { timeout: DEFAULT_TIMEOUT })
|
||||
}
|
||||
|
||||
export async function voteOnPoll (instanceName, accessToken, pollId, choices) {
|
||||
let url = `${basename(instanceName)}/api/v1/polls/${pollId}/votes`
|
||||
return post(url, { choices }, auth(accessToken), { timeout: WRITE_TIMEOUT })
|
||||
}
|
|
@ -2,7 +2,7 @@ import { auth, basename } from './utils'
|
|||
import { DEFAULT_TIMEOUT, get, post, WRITE_TIMEOUT } from '../_utils/ajax'
|
||||
|
||||
export async function postStatus (instanceName, accessToken, text, inReplyToId, mediaIds,
|
||||
sensitive, spoilerText, visibility) {
|
||||
sensitive, spoilerText, visibility, poll) {
|
||||
let url = `${basename(instanceName)}/api/v1/statuses`
|
||||
|
||||
let body = {
|
||||
|
@ -11,7 +11,8 @@ export async function postStatus (instanceName, accessToken, text, inReplyToId,
|
|||
media_ids: mediaIds,
|
||||
sensitive: sensitive,
|
||||
spoiler_text: spoilerText,
|
||||
visibility: visibility
|
||||
visibility: visibility,
|
||||
poll: poll
|
||||
}
|
||||
|
||||
for (let key of Object.keys(body)) {
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
<div class="select-wrapper {className || ''}">
|
||||
<select on:change aria-label={label}>
|
||||
{#each options as option (option.value)}
|
||||
<option value="{option.value}" selected="{option.value === defaultValue ? 'selected' : ''}">
|
||||
{option.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="select-dropdown-icon-wrapper">
|
||||
<SvgIcon href="#fa-angle-down" className="select-dropdown-icon"/>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.select-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.select-dropdown-icon-wrapper {
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
:global(.select-dropdown-icon) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-width: 18px;
|
||||
fill: var(--action-button-deemphasized-fill-color);
|
||||
}
|
||||
select {
|
||||
display: inline-block;
|
||||
padding: 5px 35px 5px 15px;
|
||||
margin: 0;
|
||||
font-size: 1.3em;
|
||||
color: var(--body-text-color);
|
||||
line-height: 1.1;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--main-border);
|
||||
border-radius: 10px;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: var(--input-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
select:hover {
|
||||
background-color: var(--button-bg-hover);
|
||||
}
|
||||
select:active {
|
||||
background-color: var(--button-bg-active);
|
||||
}
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
select:-moz-focusring {
|
||||
color: transparent;
|
||||
text-shadow: 0 0 0 var(--body-text-color);
|
||||
}
|
||||
select option {
|
||||
font-weight:normal;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
import SvgIcon from './SvgIcon.html'
|
||||
export default {
|
||||
data: () => ({
|
||||
defaultValue: '',
|
||||
className: ''
|
||||
}),
|
||||
components: {
|
||||
SvgIcon
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,18 +1,22 @@
|
|||
{#if media.length}
|
||||
<div class="compose-media-container" style="grid-template-columns: repeat({media.length}, 1fr);">
|
||||
<ul class="compose-media-container"
|
||||
aria-label="Media uploads"
|
||||
style="grid-template-columns: repeat({media.length}, 1fr);"
|
||||
>
|
||||
{#each media as mediaItem, index}
|
||||
<ComposeMediaItem {realm} {mediaItem} {index} {media} />
|
||||
{/each}
|
||||
</div>
|
||||
</ul>
|
||||
{/if}
|
||||
<style>
|
||||
.compose-media-container {
|
||||
grid-area: media;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-column-gap: 5px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
margin: 10px 0 0 0;
|
||||
background: var(--form-bg);
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<div class="compose-media compose-media-realm-{realm}">
|
||||
<li class="compose-media compose-media-realm-{realm}">
|
||||
<img src={mediaItem.data.preview_url} {alt} />
|
||||
<div class="compose-media-delete">
|
||||
<button class="compose-media-delete-button"
|
||||
|
@ -8,19 +8,21 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="compose-media-alt">
|
||||
<input id="compose-media-input-{uuid}"
|
||||
type="text"
|
||||
<textarea id="compose-media-input-{uuid}"
|
||||
class="compose-media-alt-input"
|
||||
placeholder="Description"
|
||||
placeholder="Describe for the visually impaired"
|
||||
ref:textarea
|
||||
bind:value=rawText
|
||||
>
|
||||
></textarea>
|
||||
<label for="compose-media-input-{uuid}" class="sr-only">
|
||||
Describe {shortName} for the visually impaired
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<style>
|
||||
.compose-media {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
|
@ -49,6 +51,9 @@
|
|||
font-size: 1.2em;
|
||||
background: var(--alt-input-bg);
|
||||
color: var(--body-text-color);
|
||||
max-height: 100px;
|
||||
border: 1px solid var(--input-border);
|
||||
resize: none;
|
||||
}
|
||||
.compose-media-alt-input:focus {
|
||||
background: var(--main-bg);
|
||||
|
@ -64,12 +69,15 @@
|
|||
margin: 2px;
|
||||
}
|
||||
.compose-media-delete-button {
|
||||
padding: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 7px 10px 5px;
|
||||
background: var(--floating-button-bg);
|
||||
border: 1px solid var(--button-border);
|
||||
}
|
||||
.compose-media-delete-button:hover {
|
||||
background: var(--toast-border);
|
||||
background: var(--floating-button-bg-hover);
|
||||
}
|
||||
.compose-media-delete-button:active {
|
||||
background: var(--floating-button-bg-active);
|
||||
}
|
||||
:global(.compose-media-delete-button-svg) {
|
||||
fill: var(--button-text);
|
||||
|
@ -85,6 +93,9 @@
|
|||
.compose-media-realm-dialog {
|
||||
max-height: 15vh;
|
||||
}
|
||||
.compose-media-alt-input {
|
||||
max-height: 7vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
|
@ -94,11 +105,16 @@
|
|||
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
|
||||
import { observe } from 'svelte-extras'
|
||||
import SvgIcon from '../SvgIcon.html'
|
||||
import { autosize } from '../../_thirdparty/autosize/autosize'
|
||||
|
||||
export default {
|
||||
oncreate () {
|
||||
this.setupSyncFromStore()
|
||||
this.setupSyncToStore()
|
||||
this.setupAutosize()
|
||||
},
|
||||
ondestroy () {
|
||||
this.teardownAutosize()
|
||||
},
|
||||
data: () => ({
|
||||
rawText: ''
|
||||
|
@ -139,6 +155,12 @@
|
|||
saveStore()
|
||||
}, { init: false })
|
||||
},
|
||||
setupAutosize () {
|
||||
autosize(this.refs.textarea)
|
||||
},
|
||||
teardownAutosize () {
|
||||
autosize.destroy(this.refs.textarea)
|
||||
},
|
||||
onDeleteMedia () {
|
||||
let {
|
||||
realm,
|
||||
|
|
|
@ -0,0 +1,157 @@
|
|||
<section class="compose-poll" aria-label="Create poll">
|
||||
{#each poll.options as option, i}
|
||||
<input id="poll-option-{realm}-{i}"
|
||||
type="text"
|
||||
maxlength="25"
|
||||
on:change="onChange(i)"
|
||||
placeholder="Choice {i + 1}"
|
||||
|
||||
>
|
||||
<IconButton
|
||||
label="Remove choice {i + 1}"
|
||||
href="#fa-times"
|
||||
muted={true}
|
||||
on:click="onDeleteClick(i)"
|
||||
/>
|
||||
{/each}
|
||||
<div>
|
||||
<input type="checkbox"
|
||||
id="poll-option-multiple-{realm}"
|
||||
on:change="onMultipleChange()"
|
||||
>
|
||||
<label class="multiple-choice-label"
|
||||
for="poll-option-multiple-{realm}">
|
||||
Multiple choice
|
||||
</label>
|
||||
<Select className="poll-expiry-select"
|
||||
options={pollExpiryOptions}
|
||||
defaultValue={pollExpiryDefaultValue}
|
||||
on:change="onExpiryChange(event)"
|
||||
label="Poll duration"
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
className="add-poll-choice-button"
|
||||
label="Add choice"
|
||||
href="#fa-plus"
|
||||
muted={true}
|
||||
disabled={poll.options.length === 4}
|
||||
on:click="onAddClick()"
|
||||
/>
|
||||
{#each poll.options as option, i}
|
||||
<label id="poll-option-label-{realm}-{i}"
|
||||
class="sr-only"
|
||||
for="poll-option-{realm}-{i}">
|
||||
Choice {i + 1}
|
||||
</label>
|
||||
{/each}
|
||||
</section>
|
||||
<style>
|
||||
.compose-poll {
|
||||
margin: 10px 0 10px 5px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, max-content) max-content;
|
||||
grid-row-gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.poll-expiry-select) {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.multiple-choice-label {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
:global(.poll-expiry-select) {
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
margin-top: 10px;
|
||||
}
|
||||
:global(.add-poll-choice-button) {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
import IconButton from '../IconButton.html'
|
||||
import Select from '../Select.html'
|
||||
import { store } from '../../_store/store'
|
||||
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
|
||||
import { POLL_EXPIRY_DEFAULT, POLL_EXPIRY_OPTIONS } from '../../_static/polls'
|
||||
|
||||
function flushPollOptionsToDom (poll, realm) {
|
||||
for (let i = 0; i < poll.options.length; i++) {
|
||||
let element = document.getElementById(`poll-option-${realm}-${i}`)
|
||||
element.value = poll.options[i]
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
oncreate () {
|
||||
let { realm } = this.get()
|
||||
let poll = this.store.getComposeData(realm, 'poll')
|
||||
flushPollOptionsToDom(poll, realm)
|
||||
document.getElementById(`poll-option-multiple-${realm}`).checked = !!poll.multiple
|
||||
this.set({ pollExpiryDefaultValue: poll.expiry || POLL_EXPIRY_DEFAULT })
|
||||
},
|
||||
data: () => ({
|
||||
pollExpiryOptions: POLL_EXPIRY_OPTIONS,
|
||||
pollExpiryDefaultValue: POLL_EXPIRY_DEFAULT
|
||||
}),
|
||||
store: () => store,
|
||||
methods: {
|
||||
onChange (i) {
|
||||
scheduleIdleTask(() => {
|
||||
let { realm } = this.get()
|
||||
let element = document.getElementById(`poll-option-${realm}-${i}`)
|
||||
let poll = this.store.getComposeData(realm, 'poll')
|
||||
poll.options[i] = element.value
|
||||
this.store.setComposeData(realm, { poll })
|
||||
})
|
||||
},
|
||||
onMultipleChange () {
|
||||
requestAnimationFrame(() => {
|
||||
let { realm } = this.get()
|
||||
let element = document.getElementById(`poll-option-multiple-${realm}`)
|
||||
let poll = this.store.getComposeData(realm, 'poll')
|
||||
poll.multiple = !!element.checked
|
||||
this.store.setComposeData(realm, { poll })
|
||||
})
|
||||
},
|
||||
onDeleteClick (i) {
|
||||
requestAnimationFrame(() => {
|
||||
let { realm } = this.get()
|
||||
let poll = this.store.getComposeData(realm, 'poll')
|
||||
poll.options.splice(i, 1)
|
||||
this.store.setComposeData(realm, { poll })
|
||||
flushPollOptionsToDom(poll, realm)
|
||||
})
|
||||
},
|
||||
onAddClick () {
|
||||
requestAnimationFrame(() => {
|
||||
let { realm } = this.get()
|
||||
let poll = this.store.getComposeData(realm, 'poll')
|
||||
if (!poll.options.length !== 4) {
|
||||
poll.options.push('')
|
||||
}
|
||||
this.store.setComposeData(realm, { poll })
|
||||
})
|
||||
},
|
||||
onExpiryChange (e) {
|
||||
requestAnimationFrame(() => {
|
||||
let { realm } = this.get()
|
||||
let { value } = e.target
|
||||
let poll = this.store.getComposeData(realm, 'poll')
|
||||
poll.expiry = parseInt(value, 10)
|
||||
this.store.setComposeData(realm, { poll })
|
||||
})
|
||||
}
|
||||
},
|
||||
components: {
|
||||
IconButton,
|
||||
Select
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -29,4 +29,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -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}`
|
||||
},
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -76,8 +76,9 @@
|
|||
)
|
||||
},
|
||||
content: ({ originalStatus }) => (originalStatus.content || ''),
|
||||
emojis: ({ originalStatus }) => originalStatus.emojis,
|
||||
massagedContent: ({ content, emojis, $autoplayGifs }) => massageUserText(content, emojis, $autoplayGifs)
|
||||
massagedContent: ({ content, originalStatusEmojis, $autoplayGifs }) => (
|
||||
massageUserText(content, originalStatusEmojis, $autoplayGifs)
|
||||
)
|
||||
},
|
||||
methods: {
|
||||
hydrateContent () {
|
||||
|
|
|
@ -158,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`
|
||||
|
|
|
@ -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 ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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}`
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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'
|
|
@ -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
|
|
@ -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'
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
// For convenience, periodically re-compute the current time. This ensures freshness of
|
||||
// displays like "x minutes ago" without having to jump through a lot of hoops.
|
||||
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
|
||||
import lifecycle from 'page-lifecycle/dist/lifecycle.mjs'
|
||||
|
||||
const POLL_INTERVAL = 10000
|
||||
|
||||
export function nowObservers (store) {
|
||||
let interval
|
||||
|
||||
function updateNow () {
|
||||
store.set({ now: Date.now() })
|
||||
}
|
||||
|
||||
function startPolling () {
|
||||
interval = setInterval(() => scheduleIdleTask(updateNow), POLL_INTERVAL)
|
||||
}
|
||||
|
||||
function stopPolling () {
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
}
|
||||
|
||||
function restartPolling () {
|
||||
stopPolling()
|
||||
scheduleIdleTask(updateNow)
|
||||
startPolling()
|
||||
}
|
||||
|
||||
updateNow()
|
||||
|
||||
if (process.browser) {
|
||||
startPolling()
|
||||
|
||||
lifecycle.addEventListener('statechange', e => {
|
||||
if (e.newState === 'passive') {
|
||||
console.log('stopping Date.now() observer...')
|
||||
stopPolling()
|
||||
} else if (e.newState === 'active') {
|
||||
console.log('restarting Date.now() observer...')
|
||||
restartPolling()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export function safeParse (str) {
|
||||
return !str ? undefined : (str === 'undefined' ? undefined : JSON.parse(str))
|
||||
}
|
|
@ -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 &&
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
* Contract: i@hust.cc
|
||||
*/
|
||||
|
||||
var IndexMapEn = 'second_minute_hour_day_week_month_year'.split('_')
|
||||
var IndexMapEn = ['second', 'minute', 'hour', 'day', 'week', 'month', 'year']
|
||||
var SEC_ARRAY = [60, 60, 24, 7, 365 / 7 / 12, 12]
|
||||
|
||||
/**
|
||||
|
@ -63,16 +63,14 @@ function formatDiff (diff) {
|
|||
* @param nowDate
|
||||
* @returns {number}
|
||||
*/
|
||||
function diffSec (date) {
|
||||
var nowDate = new Date()
|
||||
var otherDate = new Date(date)
|
||||
return (nowDate - otherDate) / 1000
|
||||
function diffSec (date, now) {
|
||||
return (now - date) / 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Created by hustcc on 18/5/20.
|
||||
* Contract: i@hust.cc
|
||||
*/
|
||||
export function format (date) {
|
||||
return formatDiff(diffSec(date))
|
||||
export function format (date, now) {
|
||||
return formatDiff(diffSec(date, now))
|
||||
}
|
||||
|
|
|
@ -0,0 +1,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.
|
|
@ -0,0 +1,63 @@
|
|||
// via https://github.com/jonschlinkert/unescape/blob/98d1e52/index.js
|
||||
|
||||
const chars = {
|
||||
'"': '"',
|
||||
'"': '"',
|
||||
|
||||
''': '\'',
|
||||
''': '\'',
|
||||
|
||||
'&': '&',
|
||||
'&': '&',
|
||||
|
||||
'>': '>',
|
||||
'>': '>',
|
||||
|
||||
'<': '<',
|
||||
'<': '<',
|
||||
|
||||
'¢': '¢',
|
||||
'¢': '¢',
|
||||
|
||||
'©': '©',
|
||||
'©': '©',
|
||||
|
||||
'€': '€',
|
||||
'€': '€',
|
||||
|
||||
'£': '£',
|
||||
'£': '£',
|
||||
|
||||
'®': '®',
|
||||
'®': '®',
|
||||
|
||||
'¥': '¥',
|
||||
'¥': '¥',
|
||||
|
||||
' ': ' '
|
||||
}
|
||||
|
||||
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 }
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
|
@ -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};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
|
@ -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";
|
|
@ -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%)};
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
@import "rio.scss";
|
||||
|
||||
img {
|
||||
filter: grayscale(100%);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 |
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
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 |
|
@ -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()))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
|
@ -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'
|
||||
}))
|
||||
})
|
|
@ -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))
|
||||
})
|
|
@ -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')
|
||||
})
|
|
@ -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')
|
|
@ -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')
|
||||
})
|
|
@ -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')
|
||||
})
|
|
@ -0,0 +1,94 @@
|
|||
import {
|
||||
getNthStatusContent,
|
||||
getNthStatusPollOption,
|
||||
getNthStatusPollVoteButton,
|
||||
getNthStatusPollForm,
|
||||
getNthStatusPollResult,
|
||||
sleep,
|
||||
getNthStatusPollRefreshButton,
|
||||
getNthStatusPollVoteCount,
|
||||
getNthStatusRelativeDate, getUrl, goBack
|
||||
} from '../utils'
|
||||
import { loginAsFoobar } from '../roles'
|
||||
import { createPollAs, voteOnPollAs } from '../serverActions'
|
||||
|
||||
fixture`126-polls.js`
|
||||
.page`http://localhost:4002`
|
||||
|
||||
test('Can vote on polls', async t => {
|
||||
await loginAsFoobar(t)
|
||||
await createPollAs('admin', 'vote on my cool poll', ['yes', 'no'], false)
|
||||
await t
|
||||
.expect(getNthStatusContent(1).innerText).contains('vote on my cool poll')
|
||||
.expect(getNthStatusPollVoteCount(1).innerText).eql('0 votes')
|
||||
.click(getNthStatusPollOption(1, 2))
|
||||
.click(getNthStatusPollVoteButton(1))
|
||||
.expect(getNthStatusPollForm(1).exists).notOk({ timeout: 20000 })
|
||||
.expect(getNthStatusPollResult(1, 1).innerText).eql('0% yes')
|
||||
.expect(getNthStatusPollResult(1, 2).innerText).eql('100% no')
|
||||
.expect(getNthStatusPollVoteCount(1).innerText).eql('1 vote')
|
||||
})
|
||||
|
||||
test('Can vote on multiple-choice polls', async t => {
|
||||
await loginAsFoobar(t)
|
||||
await createPollAs('admin', 'vote on my other poll', ['yes', 'no', 'maybe'], true)
|
||||
await t
|
||||
.expect(getNthStatusContent(1).innerText).contains('vote on my other poll')
|
||||
.click(getNthStatusPollOption(1, 1))
|
||||
.click(getNthStatusPollOption(1, 3))
|
||||
.click(getNthStatusPollVoteButton(1))
|
||||
.expect(getNthStatusPollForm(1).exists).notOk({ timeout: 20000 })
|
||||
.expect(getNthStatusPollResult(1, 1).innerText).eql('50% yes')
|
||||
.expect(getNthStatusPollResult(1, 2).innerText).eql('0% no')
|
||||
.expect(getNthStatusPollResult(1, 3).innerText).eql('50% maybe')
|
||||
.expect(getNthStatusPollVoteCount(1).innerText).eql('2 votes')
|
||||
})
|
||||
|
||||
test('Can update poll results', async t => {
|
||||
const { poll } = await createPollAs('admin', 'vote on this poll', ['yes', 'no', 'maybe'], false)
|
||||
const { id: pollId } = poll
|
||||
await voteOnPollAs('baz', pollId, [1])
|
||||
await voteOnPollAs('ExternalLinks', pollId, [1])
|
||||
await voteOnPollAs('foobar', pollId, [2])
|
||||
await sleep(1000)
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.expect(getNthStatusContent(1).innerText).contains('vote on this poll')
|
||||
.expect(getNthStatusPollForm(1).exists).notOk()
|
||||
.expect(getNthStatusPollResult(1, 1).innerText).eql('0% yes')
|
||||
.expect(getNthStatusPollResult(1, 2).innerText).eql('67% no')
|
||||
.expect(getNthStatusPollResult(1, 3).innerText).eql('33% maybe')
|
||||
.expect(getNthStatusPollVoteCount(1).innerText).eql('3 votes')
|
||||
await sleep(1000)
|
||||
await voteOnPollAs('quux', pollId, [0])
|
||||
await sleep(1000)
|
||||
await t
|
||||
.click(getNthStatusPollRefreshButton(1))
|
||||
.expect(getNthStatusPollResult(1, 1).innerText).eql('25% yes', { timeout: 20000 })
|
||||
.expect(getNthStatusPollResult(1, 2).innerText).eql('50% no')
|
||||
.expect(getNthStatusPollResult(1, 3).innerText).eql('25% maybe')
|
||||
.expect(getNthStatusPollVoteCount(1).innerText).eql('4 votes')
|
||||
})
|
||||
|
||||
test('Poll results refresh everywhere', async t => {
|
||||
await loginAsFoobar(t)
|
||||
await createPollAs('admin', 'another poll', ['yes', 'no'], false)
|
||||
await t
|
||||
.expect(getNthStatusContent(1).innerText).contains('another poll')
|
||||
.click(getNthStatusRelativeDate(1))
|
||||
.expect(getUrl()).contains('/statuses')
|
||||
.expect(getNthStatusContent(1).innerText).contains('another poll')
|
||||
.click(getNthStatusPollOption(1, 1))
|
||||
.click(getNthStatusPollVoteButton(1))
|
||||
.expect(getNthStatusPollForm(1).exists).notOk({ timeout: 20000 })
|
||||
.expect(getNthStatusPollResult(1, 1).innerText).eql('100% yes')
|
||||
.expect(getNthStatusPollResult(1, 2).innerText).eql('0% no')
|
||||
.expect(getNthStatusPollVoteCount(1).innerText).eql('1 vote')
|
||||
await goBack()
|
||||
await t
|
||||
.expect(getUrl()).eql('http://localhost:4002/')
|
||||
.expect(getNthStatusPollForm(1).exists).notOk({ timeout: 20000 })
|
||||
.expect(getNthStatusPollResult(1, 1).innerText).eql('100% yes')
|
||||
.expect(getNthStatusPollResult(1, 2).innerText).eql('0% no')
|
||||
.expect(getNthStatusPollVoteCount(1).innerText).eql('1 vote')
|
||||
})
|
|
@ -0,0 +1,67 @@
|
|||
import {
|
||||
getNthStatusContent,
|
||||
getNthStatusPollForm,
|
||||
getNthStatusPollResult,
|
||||
getNthStatusPollVoteCount,
|
||||
pollButton,
|
||||
getComposePollNthInput,
|
||||
composePoll,
|
||||
composePollMultipleChoice,
|
||||
composePollExpiry, composePollAddButton, getComposePollRemoveNthButton, postStatusButton, composeInput
|
||||
} from '../utils'
|
||||
import { loginAsFoobar } from '../roles'
|
||||
import { POLL_EXPIRY_DEFAULT } from '../../src/routes/_static/polls'
|
||||
|
||||
fixture`127-compose-polls.js`
|
||||
.page`http://localhost:4002`
|
||||
|
||||
test('Can add and remove poll', async t => {
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.expect(composePoll.exists).notOk()
|
||||
.expect(pollButton.getAttribute('aria-label')).eql('Add poll')
|
||||
.click(pollButton)
|
||||
.expect(composePoll.exists).ok()
|
||||
.expect(getComposePollNthInput(1).value).eql('')
|
||||
.expect(getComposePollNthInput(2).value).eql('')
|
||||
.expect(getComposePollNthInput(3).exists).notOk()
|
||||
.expect(getComposePollNthInput(4).exists).notOk()
|
||||
.expect(composePollMultipleChoice.checked).notOk()
|
||||
.expect(composePollExpiry.value).eql(POLL_EXPIRY_DEFAULT.toString())
|
||||
.expect(pollButton.getAttribute('aria-label')).eql('Remove poll')
|
||||
.click(pollButton)
|
||||
.expect(composePoll.exists).notOk()
|
||||
})
|
||||
|
||||
test('Can add and remove poll options', async t => {
|
||||
await loginAsFoobar(t)
|
||||
await t
|
||||
.expect(composePoll.exists).notOk()
|
||||
.expect(pollButton.getAttribute('aria-label')).eql('Add poll')
|
||||
.click(pollButton)
|
||||
.expect(composePoll.exists).ok()
|
||||
.typeText(getComposePollNthInput(1), 'first', { paste: true })
|
||||
.typeText(getComposePollNthInput(2), 'second', { paste: true })
|
||||
.click(composePollAddButton)
|
||||
.typeText(getComposePollNthInput(3), 'third', { paste: true })
|
||||
.expect(getComposePollNthInput(1).value).eql('first')
|
||||
.expect(getComposePollNthInput(2).value).eql('second')
|
||||
.expect(getComposePollNthInput(3).value).eql('third')
|
||||
.expect(getComposePollNthInput(4).exists).notOk()
|
||||
.click(getComposePollRemoveNthButton(2))
|
||||
.expect(getComposePollNthInput(1).value).eql('first')
|
||||
.expect(getComposePollNthInput(2).value).eql('third')
|
||||
.expect(getComposePollNthInput(3).exists).notOk()
|
||||
.expect(getComposePollNthInput(4).exists).notOk()
|
||||
.click(composePollAddButton)
|
||||
.typeText(getComposePollNthInput(3), 'fourth', { paste: true })
|
||||
.typeText(composeInput, 'Vote on my poll!!!', { paste: true })
|
||||
.click(postStatusButton)
|
||||
.expect(getNthStatusContent(1).innerText).contains('Vote on my poll!!!')
|
||||
.expect(getNthStatusPollForm(1).exists).notOk()
|
||||
.expect(getNthStatusPollResult(1, 1).innerText).eql('0% first')
|
||||
.expect(getNthStatusPollResult(1, 2).innerText).eql('0% third')
|
||||
.expect(getNthStatusPollResult(1, 3).innerText).eql('0% fourth')
|
||||
.expect(getNthStatusPollResult(1, 4).exists).notOk()
|
||||
.expect(getNthStatusPollVoteCount(1).innerText).eql('0 votes')
|
||||
})
|
|
@ -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')
|
||||
})
|
|
@ -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)')
|
||||
})
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in New Issue