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