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
This commit is contained in:
Nolan Lawson 2019-05-28 22:46:01 -07:00 committed by GitHub
parent 44a87dcd9a
commit 45630c185f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 189 additions and 21 deletions

View File

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

View File

@ -1,32 +1,100 @@
<div class="loading-footer {shown ? '' : 'hidden'}"> <div class="loading-footer {shown ? '' : 'hidden'}">
<LoadingSpinner size={48} /> <div class="loading-wrapper {showLoading ? 'shown' : ''}"
<span class="loading-footer-info"> aria-hidden={!showLoading}
Loading more... role="alert"
</span> >
<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> </div>
<style> <style>
.loading-footer { .loading-footer {
padding: 20px 0 10px; padding: 20px 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 { .loading-footer-info {
margin-left: 20px; margin-left: 20px;
font-size: 1.3em; 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> </style>
<script> <script>
import LoadingSpinner from '../LoadingSpinner.html' import LoadingSpinner from '../LoadingSpinner.html'
import { store } from '../../_store/store' import { store } from '../../_store/store'
import { fetchMoreItemsAtBottomOfTimeline } from '../../_actions/timeline'
export default { export default {
store: () => store, store: () => store,
computed: { 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: { components: {
LoadingSpinner LoadingSpinner
} }
} }
</script> </script>

View File

@ -45,7 +45,7 @@
} from '../../_utils/asyncModules' } from '../../_utils/asyncModules'
import { timelines } from '../../_static/timelines' import { timelines } from '../../_static/timelines'
import { import {
fetchTimelineItemsOnScrollToBottom, fetchMoreItemsAtBottomOfTimeline,
setupTimeline, setupTimeline,
showMoreItemsForTimeline, showMoreItemsForTimeline,
showMoreItemsForThread, showMoreItemsForThread,
@ -165,18 +165,16 @@
}, },
onScrollToBottom () { onScrollToBottom () {
let { timelineType } = this.get() let { timelineType } = this.get()
let { timelineInitialized, runningUpdate } = this.store.get() let { timelineInitialized, runningUpdate, disableInfiniteScroll } = this.store.get()
if (!timelineInitialized || if (!timelineInitialized ||
runningUpdate || runningUpdate ||
disableInfiniteScroll ||
timelineType === 'status') { // for status contexts, we've already fetched the whole thread timelineType === 'status') { // for status contexts, we've already fetched the whole thread
return return
} }
let { currentInstance } = this.store.get() let { currentInstance } = this.store.get()
let { timeline } = this.get() let { timeline } = this.get()
fetchTimelineItemsOnScrollToBottom( /* no await */ fetchMoreItemsAtBottomOfTimeline(currentInstance, timeline)
currentInstance,
timeline
)
}, },
onScrollToTop () { onScrollToTop () {
let { shouldShowHeader } = this.store.get() let { shouldShowHeader } = this.store.get()
@ -188,7 +186,7 @@
} }
}, },
setupStreaming () { setupStreaming () {
let { currentInstance } = this.store.get() let { currentInstance, disableInfiniteScroll } = this.store.get()
let { timeline, timelineType } = this.get() let { timeline, timelineType } = this.get()
let handleItemIdsToAdd = () => { let handleItemIdsToAdd = () => {
let { itemIdsToAdd } = this.get() let { itemIdsToAdd } = this.get()
@ -204,13 +202,17 @@
if (timelineType === 'status') { if (timelineType === 'status') {
// this is a thread, just insert the statuses already // this is a thread, just insert the statuses already
showMoreItemsForThread(currentInstance, timeline) 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 // 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" // just insert the statuses. this is "chat room mode"
showMoreItemsForTimeline(currentInstance, timeline) showMoreItemsForTimeline(currentInstance, timeline)
} else { } else {
// user hasn't scrolled to the top, show a header instead // user hasn't scrolled to the top, show a header instead
this.store.setForTimeline(currentInstance, timeline, { shouldShowHeader: true }) 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') stop('handleItemIdsToAdd')
} }

View File

@ -32,6 +32,19 @@
bind:checked="$disableCustomScrollbars" on:change="onChange(event)"> bind:checked="$disableCustomScrollbars" on:change="onChange(event)">
<label for="choice-disable-custom-scrollbars">Disable custom scrollbars</label> <label for="choice-disable-custom-scrollbars">Disable custom scrollbars</label>
</div> </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"> <div class="setting-group">
<input type="checkbox" id="choice-hide-cards" <input type="checkbox" id="choice-hide-cards"
bind:checked="$hideCards" on:change="onChange(event)"> bind:checked="$hideCards" on:change="onChange(event)">
@ -89,11 +102,13 @@
import SettingsLayout from '../../_components/settings/SettingsLayout.html' import SettingsLayout from '../../_components/settings/SettingsLayout.html'
import ThemeSettings from '../../_components/settings/instance/ThemeSettings.html' import ThemeSettings from '../../_components/settings/instance/ThemeSettings.html'
import { store } from '../../_store/store' import { store } from '../../_store/store'
import Tooltip from '../../_components/Tooltip.html'
export default { export default {
components: { components: {
SettingsLayout, SettingsLayout,
ThemeSettings ThemeSettings,
Tooltip
}, },
methods: { methods: {
onChange (event) { onChange (event) {

View File

@ -13,6 +13,7 @@ const persistedState = {
// we disable scrollbars by default on iOS // we disable scrollbars by default on iOS
disableCustomScrollbars: process.browser && /iP(?:hone|ad|od)/.test(navigator.userAgent), disableCustomScrollbars: process.browser && /iP(?:hone|ad|od)/.test(navigator.userAgent),
disableHotkeys: false, disableHotkeys: false,
disableInfiniteScroll: false,
disableLongAriaLabels: false, disableLongAriaLabels: false,
disableTapOnStatus: false, disableTapOnStatus: false,
hideCards: false, hideCards: false,

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

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

@ -48,11 +48,14 @@ export const generalSettingsButton = $('a[href="/settings/general"]')
export const markMediaSensitiveInput = $('#choice-mark-media-sensitive') export const markMediaSensitiveInput = $('#choice-mark-media-sensitive')
export const neverMarkMediaSensitiveInput = $('#choice-never-mark-media-sensitive') export const neverMarkMediaSensitiveInput = $('#choice-never-mark-media-sensitive')
export const removeEmojiFromDisplayNamesInput = $('#choice-omit-emoji-in-display-names') export const removeEmojiFromDisplayNamesInput = $('#choice-omit-emoji-in-display-names')
export const disableInfiniteScroll = $('#choice-disable-infinite-scroll')
export const dialogOptionsOption = $(`.modal-dialog button`) export const dialogOptionsOption = $(`.modal-dialog button`)
export const emojiSearchInput = $('.emoji-mart-search input') export const emojiSearchInput = $('.emoji-mart-search input')
export const confirmationDialogOKButton = $('.confirmation-dialog-form-flex button:nth-child(1)') export const confirmationDialogOKButton = $('.confirmation-dialog-form-flex button:nth-child(1)')
export const confirmationDialogCancelButton = $('.confirmation-dialog-form-flex button:nth-child(2)') 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 composeModalInput = $('.modal-dialog .compose-box-input')
export const composeModalComposeButton = $('.modal-dialog .compose-box-button') export const composeModalComposeButton = $('.modal-dialog .compose-box-button')
export const composeModalContentWarningInput = $('.modal-dialog .content-warning-input') export const composeModalContentWarningInput = $('.modal-dialog .content-warning-input')
@ -119,6 +122,10 @@ export const getActiveElementRectTop = exec(() => (
(document.activeElement && document.activeElement.getBoundingClientRect().top) || -1 (document.activeElement && document.activeElement.getBoundingClientRect().top) || -1
)) ))
export const getActiveElementAriaPosInSet = exec(() => (
(document.activeElement && document.activeElement.getAttribute('aria-posinset')) || ''
))
export const getActiveElementInsideNthStatus = exec(() => { export const getActiveElementInsideNthStatus = exec(() => {
let element = document.activeElement let element = document.activeElement
while (element) { while (element) {
@ -428,16 +435,20 @@ export async function validateTimeline (t, timeline) {
} }
export async function scrollToStatus (t, n) { export async function scrollToStatus (t, n) {
return scrollFromStatusToStatus(t, 1, n)
}
export async function scrollFromStatusToStatus (t, start, end) {
let timeout = 20000 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 }) await t.expect(getNthStatus(i).exists).ok({ timeout })
.hover(getNthStatus(i)) .hover(getNthStatus(i))
.expect($('.loading-footer').exist).notOk({ timeout })
.expect($(`${getNthStatusSelector(i)} .status-toolbar`).exists).ok({ timeout }) .expect($(`${getNthStatusSelector(i)} .status-toolbar`).exists).ok({ timeout })
.hover($(`${getNthStatusSelector(i)} .status-toolbar`)) .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) { export async function clickToNotificationsAndBackHome (t) {