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')
|
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()
|
||||||
|
|
|
@ -1,29 +1,97 @@
|
||||||
<div class="loading-footer {shown ? '' : 'hidden'}">
|
<div class="loading-footer {shown ? '' : 'hidden'}">
|
||||||
|
<div class="loading-wrapper {showLoading ? 'shown' : ''}"
|
||||||
|
aria-hidden={!showLoading}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
<LoadingSpinner size={48} />
|
<LoadingSpinner size={48} />
|
||||||
<span class="loading-footer-info">
|
<span class="loading-footer-info">
|
||||||
Loading more...
|
Loading more...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
<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
|
||||||
|
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
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 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) {
|
||||||
|
|
Loading…
Reference in a new issue