Compare commits

...

14 Commits

Author SHA1 Message Date
Nolan Lawson 155cb05e39 1.9.0 2019-06-02 09:26:27 -07:00
Nolan Lawson 5d0e95e759
perf: don't interate through all of localStorage in inline script (#1264) 2019-06-02 09:07:45 -07:00
Nolan Lawson 58a8772edc
perf: lazy-load the ComposeBox (#1262) 2019-06-01 17:01:50 -07:00
Nolan Lawson d75507bbce
fix: fix disableNotificationsBadge aria-label (#1260) 2019-06-01 15:51:53 -07:00
Nolan Lawson 604471a158
fix: fix grayscale in firefox (#1261) 2019-06-01 15:51:46 -07:00
Nolan Lawson f5c7bc790f
fix: fix compose toolbar on iphone 4 again (#1259) 2019-06-01 14:27:50 -07:00
Nolan Lawson 74230cfe8e
fix: fix service worker for real (#1258)
fixes #1243
2019-06-01 13:07:38 -07:00
Nolan Lawson a35f5ee2d9
feat: implement wellness settings (#1256)
* implement wellness settings

fixes #1192

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

* fix lint

* fix crawler
2019-06-01 13:07:31 -07:00
Nolan Lawson 27864fc47f
fix: Revert "fix: no need for double reload of SW in Chrome (#1251)" (#1257)
This reverts commit fa2eb8fe52.
2019-06-01 12:17:12 -07:00
Nolan Lawson fcf64c2169
fix: fix "Show more" button in Notifications timeline when filtered (#1255) 2019-05-29 18:48:59 -07:00
Nolan Lawson 45630c185f
feat: add option to disable infinite scroll (#1253)
* feat: add option to disable infinite scroll

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

* fix test
2019-05-28 22:46:01 -07:00
Nolan Lawson 44a87dcd9a
fix: fix compose button toolbar style on small devices (#1254) 2019-05-28 22:24:22 -07:00
Nolan Lawson 8672ade314
fix: unescape html in card titles/descriptions (#1252) 2019-05-28 22:24:16 -07:00
Nolan Lawson fa2eb8fe52
fix: no need for double reload of SW in Chrome (#1251)
fixes #1243
2019-05-28 08:18:11 -07:00
42 changed files with 754 additions and 91 deletions

View File

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

View File

@ -51,5 +51,6 @@ module.exports = [
{ 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-plus', src: 'src/thirdparty/font-awesome-svg-png/white/svg/plus.svg' },
{ id: 'fa-info-circle', src: 'src/thirdparty/font-awesome-svg-png/white/svg/info-circle.svg' }
]

View File

@ -1,7 +1,7 @@
{
"name": "pinafore",
"description": "Alternative web client for Mastodon",
"version": "1.8.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'",

View File

@ -17,6 +17,17 @@
<!-- inline CSS -->
<style id="theGrayscaleStyle" media="only x">
/* Firefox doesn't seem to like applying filter: grayscale() to
* the entire body, so we apply individually.
*/
img, svg, video,
input[type="checkbox"], input[type="radio"],
.inline-emoji, .theme-preview, .emoji-mart-emoji, .emoji-mart-skin {
filter: grayscale(100%);
}
</style>
<noscript>
<style>
.hidden-from-ssr {

View File

@ -3,16 +3,21 @@
// To allow CSP to work correctly, we also calculate a sha256 hash during
// the build process and write it to checksum.js.
import { testHasLocalStorageOnce } from '../routes/_utils/testStorage'
import { 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 !== INLINE_THEME) {
// switch theme ASAP to minimize flash of default theme
switchToTheme(theme)
switchToTheme(theme, enableGrayscale)
}
if (!hasLocalStorage || !currentInstance) {
if (enableGrayscale) {
document.getElementById('theGrayscaleStyle')
.setAttribute('media', 'all') // enables the style
}
if (!currentInstance) {
// if not logged in, show all these 'hidden-from-ssr' elements
onUserIsLoggedOut()
}
if (hasLocalStorage && localStorage.store_disableCustomScrollbars === 'true') {
// if user has disabled custom scrollbars, remove this style
let theScrollbarStyle = document.getElementById('theScrollbarStyle')
theScrollbarStyle.setAttribute('media', 'only x') // disables the style
if (disableCustomScrollbars) {
document.getElementById('theScrollbarStyle')
.setAttribute('media', 'only x') // disables the style
}
// hack to make the scrollbars rounded only on macOS

View File

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

View File

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

View File

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

View File

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

View File

@ -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,6 +15,7 @@
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()"
@ -20,11 +23,13 @@
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()"
@ -47,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'

View File

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

View File

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

View File

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

View File

@ -158,13 +158,19 @@
application: ({ originalStatus }) => originalStatus.application,
applicationName: ({ application }) => (application && application.name),
applicationWebsite: ({ application }) => (application && application.website),
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
}
@ -173,13 +179,19 @@
displayAbsoluteFormattedDate: ({ createdAtDateTS, $isMobileSize }) => (
($isMobileSize ? shortAbsoluteDateFormatter : absoluteDateFormatter).format(createdAtDateTS)
),
reblogsLabel: ({ numReblogs }) => {
reblogsLabel: ({ $disableReblogCounts, numReblogs }) => {
if ($disableReblogCounts) {
return 'Boost counts hidden'
}
// TODO: intl
return numReblogs === 1
? `Boosted ${numReblogs} time`
: `Boosted ${numReblogs} times`
},
favoritesLabel: ({ numFavs }) => {
favoritesLabel: ({ $disableFavCounts, numFavs }) => {
if ($disableFavCounts) {
return 'Favorite counts hidden'
}
// TODO: intl
return numFavs === 1
? `Favorited ${numFavs} time`

View File

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

View File

@ -45,7 +45,7 @@
} from '../../_utils/asyncModules'
import { timelines } from '../../_static/timelines'
import {
fetchTimelineItemsOnScrollToBottom,
fetchMoreItemsAtBottomOfTimeline,
setupTimeline,
showMoreItemsForTimeline,
showMoreItemsForThread,
@ -130,8 +130,8 @@
itemIds: ({ $filteredTimelineItemSummaries }) => (
$filteredTimelineItemSummaries && $filteredTimelineItemSummaries.map(_ => _.id)
),
itemIdsToAdd: ({ $timelineItemSummariesToAdd }) => (
$timelineItemSummariesToAdd && $timelineItemSummariesToAdd.map(_ => _.id)
itemIdsToAdd: ({ $filteredTimelineItemSummariesToAdd }) => (
$filteredTimelineItemSummariesToAdd && $filteredTimelineItemSummariesToAdd.map(_ => _.id)
),
headerProps: ({ itemIdsToAdd }) => {
return {
@ -165,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()
@ -188,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()
@ -204,13 +202,17 @@
if (timelineType === 'status') {
// this is a thread, just insert the statuses already
showMoreItemsForThread(currentInstance, timeline)
} else if (scrollTop === 0 && !shouldShowHeader && !showHeader) {
} else if (!disableInfiniteScroll && scrollTop === 0 && !shouldShowHeader && !showHeader) {
// if the user is scrolled to the top and we're not showing the header, then
// just insert the statuses. this is "chat room mode"
showMoreItemsForTimeline(currentInstance, timeline)
} else {
// user hasn't scrolled to the top, show a header instead
this.store.setForTimeline(currentInstance, timeline, { shouldShowHeader: true })
// unless the user has disabled infinite scroll entirely
if (disableInfiniteScroll) {
this.store.setForTimeline(currentInstance, timeline, { showHeader: true })
}
}
stop('handleItemIdsToAdd')
}

View File

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

View File

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

View File

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

View File

@ -41,6 +41,12 @@ const themes = [
dark: false,
color: '#4ab92f'
},
{
name: 'grayscale',
label: 'Grayscale',
dark: false,
color: '#999999'
},
{
name: 'ozark',
label: 'Ozark',
@ -88,6 +94,12 @@ const themes = [
label: 'Pitch Black',
dark: true,
color: '#000'
},
{
name: 'dark-grayscale',
label: 'Dark Grayscale',
dark: true,
color: '#666'
}
]

View File

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

View File

@ -143,6 +143,14 @@ export function timelineComputations (store) {
}
)
store.compute(
'filteredTimelineItemSummariesToAdd',
['timelineItemSummariesToAdd', 'timelineFilterFunction'],
(timelineItemSummariesToAdd, timelineFilterFunction) => {
return timelineItemSummariesToAdd && timelineItemSummariesToAdd.filter(timelineFilterFunction)
}
)
store.compute('timelineNotificationItemSummaries',
[`timelineData_timelineItemSummariesToAdd`, 'timelineFilterFunction', 'currentInstance'],
(root, timelineFilterFunction, currentInstance) => (
@ -159,14 +167,18 @@ export function timelineComputations (store) {
)
store.compute('numberOfNotifications',
['filteredTimelineNotificationItemSummaries'],
(filteredTimelineNotificationItemSummaries) => (
filteredTimelineNotificationItemSummaries ? filteredTimelineNotificationItemSummaries.length : 0
['filteredTimelineNotificationItemSummaries', 'disableNotificationBadge'],
(filteredTimelineNotificationItemSummaries, disableNotificationBadge) => (
(!disableNotificationBadge && filteredTimelineNotificationItemSummaries)
? filteredTimelineNotificationItemSummaries.length
: 0
)
)
store.compute('hasNotifications',
['numberOfNotifications', 'currentPage'],
(numberOfNotifications, currentPage) => currentPage !== 'notifications' && !!numberOfNotifications
(numberOfNotifications, currentPage) => (
currentPage !== 'notifications' && !!numberOfNotifications
)
)
}

View File

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

View File

@ -6,6 +6,7 @@ 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)
@ -15,5 +16,6 @@ export function observers (store) {
resizeObservers(store)
touchObservers(store)
logOutObservers(store)
grayscaleObservers(store)
setupLoggedInObservers(store)
}

View File

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

View File

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

View File

@ -12,9 +12,15 @@ 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: '',

View File

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

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

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
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 INLINE_THEME = 'default' // theme that does not require external CSS
export const DEFAULT_LIGHT_THEME = 'default' // theme that is shown by default
@ -32,11 +31,13 @@ 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 !== INLINE_THEME) {

View File

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

View File

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

View File

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

View File

@ -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()
})())
})
@ -243,3 +246,11 @@ self.addEventListener('notificationclick', event => {
}
})())
})
self.addEventListener('message', (event) => {
switch (event.data) {
case 'skip-waiting':
self.skipWaiting()
break
}
})

View File

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

View File

@ -4,7 +4,11 @@ import {
instanceSettingNotificationReblogs,
notificationBadge,
instanceSettingNotificationFavs,
instanceSettingNotificationMentions, instanceSettingNotificationFollows
instanceSettingNotificationMentions,
instanceSettingNotificationFollows,
notificationsNavButton,
getUrl,
sleep, showMoreButton, scrollToBottom, scrollToTop
} from '../utils'
import { loginAsFoobar } from '../roles'
import { Selector as $ } from 'testcafe'
@ -91,3 +95,33 @@ test('Notification timeline filters correctly affect counts - follows', async t
.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')
})

View File

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

View File

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

View File

@ -48,11 +48,15 @@ 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')
@ -119,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) {
@ -428,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) {