<div class="timeline" role="feed" aria-label="{{label}}" on:focusWithCapture="saveFocus(event)" on:blurWithCapture="clearFocus(event)" > {{#if !$initialized}} <LoadingPage /> {{/if}} {{#if virtual}} <VirtualList component="{{VirtualListComponent}}" realm="{{$currentInstance + '/' + timeline}}" containerQuery=".container" :makeProps items="{{$timelineItemIds}}" shown="{{$initialized}}" showFooter="{{$initialized && $runningUpdate}}" footerComponent="{{LoadingFooter}}" showHeader="{{$showHeader}}" headerComponent="{{MoreHeaderVirtualWrapper}}" :headerProps on:scrollToBottom="onScrollToBottom()" on:scrollToTop="onScrollToTop()" on:scrollTopChanged="onScrollTopChanged(event)" on:initializedVisibleItems="initialize()" /> {{else}} {{#await importPseudoVirtualList}} {{then PseudoVirtualList}} <!-- if this is a status thread, it's easier to just render the whole thing rather than use a virtual list --> <:Component {PseudoVirtualList} component="{{VirtualListComponent}}" realm="{{$currentInstance + '/' + timeline}}" containerQuery=".container" :makeProps items="{{$timelineItemIds}}" shown="{{$initialized}}" scrollToItem="{{scrollToItem}}" on:initializedVisibleItems="initialize()" /> {{catch error}} <div>Component failed to load. Try refreshing! {{error}}</div> {{/await}} {{/if}} </div> <style> .timeline { position: relative; } </style> <script> import { store } from '../../_store/store' import StatusVirtualListItem from './StatusVirtualListItem.html' import NotificationVirtualListItem from './NotificationVirtualListItem.html' import Status from '../status/Status.html' import LoadingFooter from './LoadingFooter.html' import MoreHeaderVirtualWrapper from './MoreHeaderVirtualWrapper.html' import VirtualList from '../virtualList/VirtualList.html' import { timelines } from '../../_static/timelines' import { database } from '../../_database/database' import { initializeTimeline, fetchTimelineItemsOnScrollToBottom, setupTimeline, showMoreItemsForTimeline, showMoreItemsForThread, showMoreItemsForCurrentTimeline } from '../../_actions/timeline' import LoadingPage from '../LoadingPage.html' import { focusWithCapture, blurWithCapture } from '../../_utils/events' import { scheduleIdleTask } from '../../_utils/scheduleIdleTask' import { mark, stop } from '../../_utils/marks' import { importPseudoVirtualList } from '../../_utils/asyncModules' import isEqual from 'lodash/isEqual' export default { oncreate() { console.log('timeline oncreate()') this.setupFocus() setupTimeline() this.restoreFocus() this.setupStreaming() }, ondestroy() { console.log('ondestroy') this.teardownFocus() }, data: () => ({ LoadingFooter, MoreHeaderVirtualWrapper, Status, scrollTop: 0 }), computed: { importPseudoVirtualList: (virtual) => { return !virtual && importPseudoVirtualList() }, VirtualListComponent: (timelineType) => { return timelineType === 'notifications' ? NotificationVirtualListItem : StatusVirtualListItem }, makeProps: ($currentInstance, timelineType, timelineValue) => async (itemId) => { let res = { timelineType, timelineValue } if (timelineType === 'notifications') { res.notification = await database.getNotification($currentInstance, itemId) } else { res.status = await database.getStatus($currentInstance, itemId) } return res }, label: (timeline, $currentInstance, timelineType, timelineValue) => { if (timelines[timeline]) { return `${timelines[timeline].label} timeline for ${$currentInstance}` } switch (timelineType) { case 'tag': return `#${timelineValue} timeline for ${$currentInstance}` case 'status': return 'Status context' case 'account': return `Account #${timelineValue} on ${$currentInstance}` case 'list': return `List #${timelineValue} on ${$currentInstance}` } }, timelineType: (timeline) => { return timeline.split('/')[0] }, timelineValue: (timeline) => { return timeline.split('/').slice(-1)[0] }, // for threads, it's simpler to just render all items as a pseudo-virtual list // due to need to scroll to the right item and thus calculate all item heights up-front virtual: (timelineType) => timelineType !=='status', scrollToItem: (timelineType, timelineValue, $firstTimelineItemId) => { // Scroll to the first item if this is a "status in own thread" timeline. // Don't scroll to the first item because it obscures the "back" button. return timelineType === 'status' && $firstTimelineItemId && timelineValue !== $firstTimelineItemId && timelineValue }, itemIdsToAdd: ($itemIdsToAdd) => $itemIdsToAdd, headerProps: (itemIdsToAdd) => { return { count: itemIdsToAdd ? itemIdsToAdd.length : 0, onClick: showMoreItemsForCurrentTimeline } } }, store: () => store, components: { VirtualList, LoadingPage }, events: { focusWithCapture, blurWithCapture }, methods: { initialize() { if (this.get('initializeStarted')) { return } this.set({initializeStarted: true}) console.log('timeline initialize()') initializeTimeline() }, onScrollTopChanged(scrollTop) { this.set({scrollTop: scrollTop}) }, onScrollToBottom() { if (!this.store.get('initialized') || this.store.get('runningUpdate') || this.get('timelineType') === 'status') { // for status contexts, we've already fetched the whole thread return } fetchTimelineItemsOnScrollToBottom( this.store.get('currentInstance'), this.get('timeline') ) }, onScrollToTop() { if (this.store.get('shouldShowHeader')) { this.store.setForCurrentTimeline({ showHeader: true, shouldShowHeader: false }) } }, setupStreaming() { let instanceName = this.store.get('currentInstance') let timelineName = this.get('timeline') let handleItemIdsToAdd = () => { let itemIdsToAdd = this.get('itemIdsToAdd') if (!itemIdsToAdd || !itemIdsToAdd.length) { return } mark('handleItemIdsToAdd') let scrollTop = this.get('scrollTop') let shouldShowHeader = this.store.get('shouldShowHeader') let showHeader = this.store.get('showHeader') if (timelineName.startsWith('status/')) { // this is a thread, just insert the statuses already showMoreItemsForThread(instanceName, timelineName) } else if (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(instanceName, timelineName) } else { // user hasn't scrolled to the top, show a header instead this.store.setForTimeline(instanceName, timelineName, {shouldShowHeader: true}) } stop('handleItemIdsToAdd') } this.observe('itemIdsToAdd', (newItemIdsToAdd, oldItemIdsToAdd) => { if (!newItemIdsToAdd || !newItemIdsToAdd.length || isEqual(newItemIdsToAdd, oldItemIdsToAdd)) { return } scheduleIdleTask(handleItemIdsToAdd) }) }, setupFocus() { this.onPushState = this.onPushState.bind(this) this.store.setForCurrentTimeline({ ignoreBlurEvents: false }) window.addEventListener('pushState', this.onPushState) }, teardownFocus() { window.removeEventListener('pushState', this.onPushState) }, onPushState() { this.store.setForCurrentTimeline({ ignoreBlurEvents: true }) }, saveFocus(e) { try { let instanceName = this.store.get('currentInstance') let timelineName = this.get('timeline') let lastFocusedElementSelector let activeElement = e.target if (activeElement) { let focusKey = activeElement.getAttribute('focus-key') if (focusKey) { lastFocusedElementSelector = `[focus-key=${JSON.stringify(focusKey)}]` } } console.log('saving focus to ', lastFocusedElementSelector) this.store.setForTimeline(instanceName, timelineName, { lastFocusedElementSelector }) } catch (err) { console.error('unable to save focus', err) } }, clearFocus() { try { if (this.store.get('ignoreBlurEvents')) { return } console.log('clearing focus') let instanceName = this.store.get('currentInstance') let timelineName = this.get('timeline') this.store.setForTimeline(instanceName, timelineName, { lastFocusedElementSelector: null }) } catch (err) { console.error('unable to clear focus', err) } }, restoreFocus() { let lastFocusedElementSelector = this.store.get('lastFocusedElementSelector') if (!lastFocusedElementSelector) { return } console.log('restoreFocus', lastFocusedElementSelector) requestAnimationFrame(() => { requestAnimationFrame(() => { let element = document.querySelector(lastFocusedElementSelector) if (element) { element.focus() } }) }) }, } } </script>