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:
parent
44a87dcd9a
commit
45630c185f
|
@ -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()
|
||||
|
|
|
@ -1,29 +1,97 @@
|
|||
<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
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
} from '../../_utils/asyncModules'
|
||||
import { timelines } from '../../_static/timelines'
|
||||
import {
|
||||
fetchTimelineItemsOnScrollToBottom,
|
||||
fetchMoreItemsAtBottomOfTimeline,
|
||||
setupTimeline,
|
||||
showMoreItemsForTimeline,
|
||||
showMoreItemsForThread,
|
||||
|
@ -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')
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -13,6 +13,7 @@ const persistedState = {
|
|||
// we disable scrollbars by default on iOS
|
||||
disableCustomScrollbars: process.browser && /iP(?:hone|ad|od)/.test(navigator.userAgent),
|
||||
disableHotkeys: false,
|
||||
disableInfiniteScroll: false,
|
||||
disableLongAriaLabels: false,
|
||||
disableTapOnStatus: false,
|
||||
hideCards: false,
|
||||
|
|
39
tests/spec/036-disable-infinite-load.js
Normal file
39
tests/spec/036-disable-infinite-load.js
Normal 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')
|
||||
})
|
32
tests/spec/128-disable-infinite-load.js
Normal file
32
tests/spec/128-disable-infinite-load.js
Normal 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')
|
||||
})
|
|
@ -48,11 +48,14 @@ 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 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 +122,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 +435,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 a new issue