feat: implement "." keyboard shortcut (#1105)

fixes #1052
This commit is contained in:
Nolan Lawson 2019-03-18 09:09:24 -07:00 committed by GitHub
parent 84b20a8fc2
commit f0af8178af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 116 additions and 31 deletions

View file

@ -0,0 +1,48 @@
import { showMoreItemsForCurrentTimeline } from './timeline'
import { scrollToTop } from '../_utils/scrollToTop'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
import { createStatusOrNotificationUuid } from '../_utils/createStatusOrNotificationUuid'
import { store } from '../_store/store'
const RETRIES = 5
const TIMEOUT = 50
export function showMoreAndScrollToTop () {
// Similar to Twitter, pressing "." will click the "show more" button and select
// the first toot.
showMoreItemsForCurrentTimeline()
let {
currentInstance,
timelineItemSummaries,
currentTimelineType,
currentTimelineValue
} = store.get()
let firstItemSummary = timelineItemSummaries && timelineItemSummaries[0]
if (!firstItemSummary) {
return
}
let notificationId = currentTimelineType === 'notifications' && firstItemSummary.id
let statusId = currentTimelineType !== 'notifications' && firstItemSummary.id
scrollToTop(/* smooth */ false)
// try 5 times to wait for the element to be rendered and then focus it
let count = 0
const tryToFocusElement = () => {
let uuid = createStatusOrNotificationUuid(
currentInstance, currentTimelineType,
currentTimelineValue, notificationId, statusId
)
let element = document.getElementById(uuid)
if (element) {
try {
element.focus({ preventScroll: true })
} catch (e) {
console.error(e)
}
} else {
if (++count <= RETRIES) {
setTimeout(() => scheduleIdleTask(tryToFocusElement), TIMEOUT)
}
}
}
scheduleIdleTask(tryToFocusElement)
}

View file

@ -124,7 +124,7 @@ export async function fetchTimelineItemsOnScrollToBottom (instanceName, timeline
export async function showMoreItemsForTimeline (instanceName, timelineName) { export async function showMoreItemsForTimeline (instanceName, timelineName) {
mark('showMoreItemsForTimeline') mark('showMoreItemsForTimeline')
let itemSummariesToAdd = store.getForTimeline(instanceName, timelineName, 'timelineItemSummariesToAdd') let itemSummariesToAdd = store.getForTimeline(instanceName, timelineName, 'timelineItemSummariesToAdd') || []
itemSummariesToAdd = itemSummariesToAdd.sort(compareTimelineItemSummaries).reverse() itemSummariesToAdd = itemSummariesToAdd.sort(compareTimelineItemSummaries).reverse()
addTimelineItemSummaries(instanceName, timelineName, itemSummariesToAdd, false) addTimelineItemSummaries(instanceName, timelineName, itemSummariesToAdd, false)
store.setForTimeline(instanceName, timelineName, { store.setForTimeline(instanceName, timelineName, {
@ -135,7 +135,7 @@ export async function showMoreItemsForTimeline (instanceName, timelineName) {
stop('showMoreItemsForTimeline') stop('showMoreItemsForTimeline')
} }
export async function showMoreItemsForCurrentTimeline () { export function showMoreItemsForCurrentTimeline () {
let { currentInstance, currentTimeline } = store.get() let { currentInstance, currentTimeline } = store.get()
return showMoreItemsForTimeline( return showMoreItemsForTimeline(
currentInstance, currentInstance,

View file

@ -105,11 +105,10 @@
<script> <script>
import NavItemIcon from './NavItemIcon.html' import NavItemIcon from './NavItemIcon.html'
import { store } from '../_store/store' import { store } from '../_store/store'
import { smoothScroll } from '../_utils/smoothScroll'
import { on, emit } from '../_utils/eventBus' import { on, emit } from '../_utils/eventBus'
import { mark, stop } from '../_utils/marks' import { mark, stop } from '../_utils/marks'
import { doubleRAF } from '../_utils/doubleRAF' import { doubleRAF } from '../_utils/doubleRAF'
import { getScrollContainer } from '../_utils/scrollContainer' import { scrollToTop } from '../_utils/scrollToTop'
export default { export default {
oncreate () { oncreate () {
@ -167,14 +166,10 @@
if (!selected) { if (!selected) {
return return
} }
let scroller = getScrollContainer() if (scrollToTop(/* smooth */ true)) {
let { scrollTop } = scroller e.preventDefault()
if (scrollTop === 0) { e.stopPropagation()
return
} }
e.preventDefault()
e.stopPropagation()
smoothScroll(scroller, 0)
} }
}, },
components: { components: {

View file

@ -17,10 +17,13 @@
<li><kbd>Backspace</kbd> to go back, close dialogs</li> <li><kbd>Backspace</kbd> to go back, close dialogs</li>
</ul> </ul>
</div> </div>
<h2>On an active toot</h2> <h2>Timeline</h2>
<div class="hotkey-group"> <div class="hotkey-group">
<ul> <ul>
<li><kbd>o</kbd> to open the thread</li> <li><kbd>j</kbd> or <kbd></kbd> to activate the next toot</li>
<li><kbd>k</kbd> or <kbd></kbd> to activate the previous toot</li>
<li><kbd>.</kbd> to show more and scroll to top</li>
<li><kbd>o</kbd> to open</li>
<li><kbd>f</kbd> to favorite</li> <li><kbd>f</kbd> to favorite</li>
<li><kbd>b</kbd> to boost</li> <li><kbd>b</kbd> to boost</li>
<li><kbd>r</kbd> to reply</li> <li><kbd>r</kbd> to reply</li>
@ -28,8 +31,6 @@
<li><kbd>p</kbd> to open the author's profile</li> <li><kbd>p</kbd> to open the author's profile</li>
<li><kbd>x</kbd> to show or hide text behind content warning</li> <li><kbd>x</kbd> to show or hide text behind content warning</li>
<li><kbd>y</kbd> to show or hide sensitive media</li> <li><kbd>y</kbd> to show or hide sensitive media</li>
<li><kbd>j</kbd> or <kbd></kbd> to activate the next toot</li>
<li><kbd>k</kbd> or <kbd></kbd> to activate the previous toot</li>
</ul> </ul>
</div> </div>
<h2>Media</h2> <h2>Media</h2>

View file

@ -48,6 +48,7 @@
import { classname } from '../../_utils/classname' import { classname } from '../../_utils/classname'
import { applyFocusStylesToParent } from '../../_utils/events' import { applyFocusStylesToParent } from '../../_utils/events'
import noop from 'lodash-es/noop' import noop from 'lodash-es/noop'
import { createStatusOrNotificationUuid } from '../../_utils/createStatusOrNotificationUuid'
export default { export default {
components: { components: {
@ -65,10 +66,10 @@
notificationId: ({ notification }) => notification.id, notificationId: ({ notification }) => notification.id,
status: ({ notification }) => notification.status, status: ({ notification }) => notification.status,
statusId: ({ status }) => status && status.id, statusId: ({ status }) => status && status.id,
uuid: ({ $currentInstance, timelineType, timelineValue, notificationId, statusId }) => { uuid: ({ $currentInstance, timelineType, timelineValue, notificationId, statusId }) => (
return `${$currentInstance}/${timelineType}/${timelineValue}/${notificationId}/${statusId || ''}` createStatusOrNotificationUuid($currentInstance, timelineType, timelineValue, notificationId, statusId)
}, ),
elementId: ({ uuid }) => `notification-${uuid}`, elementId: ({ uuid }) => uuid,
shortcutScope: ({ elementId }) => elementId, shortcutScope: ({ elementId }) => elementId,
ariaLabel: ({ status, account, $omitEmojiInDisplayNames }) => ( ariaLabel: ({ status, account, $omitEmojiInDisplayNames }) => (
!status && `${getAccountAccessibleName(account, $omitEmojiInDisplayNames)} followed you, @${account.acct}` !status && `${getAccountAccessibleName(account, $omitEmojiInDisplayNames)} followed you, @${account.acct}`

View file

@ -136,6 +136,7 @@
import { composeNewStatusMentioning } from '../../_actions/mention' import { composeNewStatusMentioning } from '../../_actions/mention'
import { applyFocusStylesToParent } from '../../_utils/events' import { applyFocusStylesToParent } from '../../_utils/events'
import noop from 'lodash-es/noop' import noop from 'lodash-es/noop'
import { createStatusOrNotificationUuid } from '../../_utils/createStatusOrNotificationUuid'
const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea']) const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea'])
const isUserInputElement = node => INPUT_TAGS.has(node.localName) const isUserInputElement = node => INPUT_TAGS.has(node.localName)
@ -238,9 +239,9 @@
), ),
inReplyToId: ({ originalStatus }) => originalStatus.in_reply_to_id, inReplyToId: ({ originalStatus }) => originalStatus.in_reply_to_id,
uuid: ({ $currentInstance, timelineType, timelineValue, notificationId, statusId }) => ( uuid: ({ $currentInstance, timelineType, timelineValue, notificationId, statusId }) => (
`${$currentInstance}/${timelineType}/${timelineValue}/${notificationId || ''}/${statusId}` createStatusOrNotificationUuid($currentInstance, timelineType, timelineValue, notificationId, statusId)
), ),
elementId: ({ uuid }) => `status-${uuid}`, elementId: ({ uuid }) => uuid,
shortcutScope: ({ elementId }) => elementId, shortcutScope: ({ elementId }) => elementId,
isStatusInOwnThread: ({ timelineType, timelineValue, originalStatusId }) => ( isStatusInOwnThread: ({ timelineType, timelineValue, originalStatusId }) => (
(timelineType === 'status' || timelineType === 'reply') && timelineValue === originalStatusId (timelineType === 'status' || timelineType === 'reply') && timelineValue === originalStatusId

View file

@ -28,6 +28,7 @@
<div>Error: component failed to load! Try reloading. {error}</div> <div>Error: component failed to load! Try reloading. {error}</div>
{/await} {/await}
</div> </div>
<Shortcut scope="global" key="." on:pressed="showMoreAndScrollToTop()" />
<ScrollListShortcuts /> <ScrollListShortcuts />
<script> <script>
import { store } from '../../_store/store' import { store } from '../../_store/store'
@ -35,6 +36,7 @@
import LoadingFooter from './LoadingFooter.html' import LoadingFooter from './LoadingFooter.html'
import MoreHeaderVirtualWrapper from './MoreHeaderVirtualWrapper.html' import MoreHeaderVirtualWrapper from './MoreHeaderVirtualWrapper.html'
import ScrollListShortcuts from '../shortcut/ScrollListShortcuts.html' import ScrollListShortcuts from '../shortcut/ScrollListShortcuts.html'
import Shortcut from '../shortcut/Shortcut.html'
import { import {
importVirtualList, importVirtualList,
importList, importList,
@ -56,6 +58,7 @@
import { doubleRAF } from '../../_utils/doubleRAF' import { doubleRAF } from '../../_utils/doubleRAF'
import { observe } from 'svelte-extras' import { observe } from 'svelte-extras'
import { createMakeProps } from '../../_actions/createMakeProps' import { createMakeProps } from '../../_actions/createMakeProps'
import { showMoreAndScrollToTop } from '../../_actions/showMoreAndScrollToTop'
export default { export default {
oncreate () { oncreate () {
@ -114,12 +117,8 @@
return `Notifications on ${$currentInstance}` return `Notifications on ${$currentInstance}`
} }
}, },
timelineType: ({ timeline }) => { timelineType: ({ $currentTimelineType }) => $currentTimelineType,
return timeline.split('/')[0] timelineValue: ({ $currentTimelineValue }) => $currentTimelineValue,
},
timelineValue: ({ timeline }) => {
return timeline.split('/').slice(-1)[0]
},
// Scroll to the first item if this is a "status in own thread" timeline. // 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. // Don't scroll to the first item because it obscures the "back" button.
scrollToItem: ({ timelineType, timelineValue, $firstTimelineItemId }) => ( scrollToItem: ({ timelineType, timelineValue, $firstTimelineItemId }) => (
@ -293,10 +292,12 @@
// where the scrollable content appears to jump around if we need to scroll it. // where the scrollable content appears to jump around if we need to scroll it.
console.log('timeline preinitialized') console.log('timeline preinitialized')
this.store.set({ timelinePreinitialized: true }) this.store.set({ timelinePreinitialized: true })
} },
showMoreAndScrollToTop
}, },
components: { components: {
ScrollListShortcuts ScrollListShortcuts,
Shortcut
} }
} }
</script> </script>

View file

@ -20,6 +20,12 @@ export function timelineComputations (store) {
computeForTimeline(store, 'shouldShowHeader', false) computeForTimeline(store, 'shouldShowHeader', false)
computeForTimeline(store, 'timelineItemSummariesAreStale', false) computeForTimeline(store, 'timelineItemSummariesAreStale', false)
store.compute('currentTimelineType', ['currentTimeline'], currentTimeline => (
currentTimeline && currentTimeline.split('/')[0])
)
store.compute('currentTimelineValue', ['currentTimeline'], currentTimeline => (
currentTimeline && currentTimeline.split('/').slice(-1)[0])
)
store.compute('firstTimelineItemId', ['timelineItemSummaries'], (timelineItemSummaries) => ( store.compute('firstTimelineItemId', ['timelineItemSummaries'], (timelineItemSummaries) => (
getFirstIdFromItemSummaries(timelineItemSummaries) getFirstIdFromItemSummaries(timelineItemSummaries)
)) ))

View file

@ -0,0 +1,3 @@
export function createStatusOrNotificationUuid (currentInstance, timelineType, timelineValue, notificationId, statusId) {
return `${currentInstance}/${timelineType}/${timelineValue}/${notificationId || ''}/${statusId || ''}`
}

View file

@ -0,0 +1,16 @@
import { getScrollContainer } from './scrollContainer'
import { smoothScroll } from './smoothScroll'
export function scrollToTop (smooth) {
let scroller = getScrollContainer()
let { scrollTop } = scroller
if (scrollTop === 0) {
return false
}
if (smooth) {
smoothScroll(scroller, 0)
} else {
scroller.scrollTop = 0
}
return true
}

View file

@ -1,7 +1,9 @@
import { import {
getUrl, getNthStatus,
getUrl, isNthStatusActive,
modalDialogContents, modalDialogContents,
notificationsNavButton } from '../utils' notificationsNavButton, scrollToStatus
} from '../utils'
import { loginAsFoobar } from '../roles' import { loginAsFoobar } from '../roles'
fixture`024-shortcuts-navigation.js` fixture`024-shortcuts-navigation.js`
@ -109,3 +111,14 @@ test('Shortcut 6 goes to the settings', async t => {
.pressKey('6') .pressKey('6')
.expect(getUrl()).contains('/settings') .expect(getUrl()).contains('/settings')
}) })
test('Shortcut . scrolls to top and focuses', async t => {
await loginAsFoobar(t)
await t
.expect(getUrl()).eql('http://localhost:4002/')
.hover(getNthStatus(1))
await scrollToStatus(t, 10)
await t
.pressKey('.')
.expect(isNthStatusActive(1)).ok()
})