Enable scrolling via J and K keys

This commit is contained in:
DS Boyce
2026-03-18 16:33:39 -07:00
parent b801a24ef2
commit c22f6cb7a9
17 changed files with 755 additions and 190 deletions
+126 -56
View File
@@ -6,9 +6,12 @@ import {
type AppBskyGraphDefs,
} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useIsFocused} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import * as bcp47Match from 'bcp-47-match'
import {useFeedKeyboardNav} from '#/lib/hotkeys'
import * as KeyboardActivation from '#/lib/hotkeys/KeyboardActivation'
import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -220,6 +223,8 @@ export function Explore({
const moderationOpts = useModerationOpts()
const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
const isScreenFocused = useIsFocused()
/*
* Begin special language handling
*/
@@ -712,6 +717,7 @@ export function Explore({
]
}, [showInterestsNux])
// Keyboard nav: Keep track of focused elements
const items = useMemo<ExploreScreenItems[]>(() => {
const i: ExploreScreenItems[] = []
@@ -744,6 +750,30 @@ export function Explore({
useFullExperience,
])
// Keyboard nav: Indices within items that are focusable
const focusableIndices = useMemo(() => {
const indices: number[] = []
for (let i = 0; i < items.length; i++) {
const row = items[i]
if (
row.type === 'profile' ||
row.type === 'feed' ||
row.type === 'starterPack' ||
row.type === 'preview:header' ||
(row.type === 'preview:sliceItem' && row.indexInSlice === 0)
) {
indices.push(i)
}
}
return indices
}, [items])
const {
focusedIndex: focusedFeedItemIndex,
itemRef: feedItemRef,
itemActivation: feedItemActivation,
} = useFeedKeyboardNav({focusableIndices, active: isScreenFocused})
const renderItem = useCallback(
({item, index}: {item: ExploreScreenItems; index: number}) => {
const handleOnPressRetry = () => {
@@ -801,12 +831,17 @@ export function Explore({
}
case 'profile': {
return (
<SuggestedProfileCard
profile={item.profile}
moderationOpts={moderationOpts!}
recId={item.recId}
position={index}
/>
<View ref={feedItemRef(index)}>
<SubtleHover hover={index === focusedFeedItemIndex} />
<KeyboardActivation.Boundary register={feedItemActivation(index)}>
<SuggestedProfileCard
profile={item.profile}
moderationOpts={moderationOpts!}
recId={item.recId}
position={index}
/>
</KeyboardActivation.Boundary>
</View>
)
}
case 'profileEmpty': {
@@ -832,25 +867,36 @@ export function Explore({
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default
view={item.feed}
onPress={() => {
if (!useFullExperience) {
return
}
ax.metric('feed:suggestion:press', {
feedUrl: item.feed.uri,
})
}}
/>
a.relative,
]}
ref={feedItemRef(index)}>
<SubtleHover hover={index === focusedFeedItemIndex} />
<KeyboardActivation.Boundary register={feedItemActivation(index)}>
<FeedCard.Default
view={item.feed}
onPress={() => {
if (!useFullExperience) {
return
}
ax.metric('feed:suggestion:press', {
feedUrl: item.feed.uri,
})
}}
/>
</KeyboardActivation.Boundary>
</View>
)
}
case 'starterPack': {
return (
<View style={[a.px_lg, a.pb_lg]}>
<StarterPackCard view={item.view} />
<View style={[a.relative]} ref={feedItemRef(index)}>
<SubtleHover hover={index === focusedFeedItemIndex} />
<KeyboardActivation.Boundary
register={feedItemActivation(index)}>
<StarterPackCard view={item.view} />
</KeyboardActivation.Boundary>
</View>
</View>
)
}
@@ -947,24 +993,38 @@ export function Explore({
}
case 'preview:header': {
return (
<ModuleHeader.Container style={[a.pt_xs]} bottomBorder>
{/* Very non-scientific way to avoid small gap on scroll */}
<View style={[a.absolute, a.inset_0, t.atoms.bg, {top: -2}]} />
<ModuleHeader.FeedLink feed={item.feed}>
<ModuleHeader.FeedAvatar feed={item.feed} />
<View style={[a.flex_1, a.gap_2xs]}>
<ModuleHeader.TitleText style={[a.text_lg]}>
{item.feed.displayName}
</ModuleHeader.TitleText>
<ModuleHeader.SubtitleText>
<Trans>
By {sanitizeHandle(item.feed.creator.handle, '@')}
</Trans>
</ModuleHeader.SubtitleText>
<View ref={feedItemRef(index)}>
<ModuleHeader.Container style={[a.pt_xs]} bottomBorder>
{/* Very non-scientific way to avoid small gap on scroll */}
<View style={[a.absolute, a.inset_0, t.atoms.bg, {top: -2}]} />
<View
style={[
a.relative,
a.flex_1,
a.rounded_md,
a.overflow_hidden,
]}>
<SubtleHover hover={index === focusedFeedItemIndex} />
<KeyboardActivation.Boundary
register={feedItemActivation(index)}>
<ModuleHeader.FeedLink feed={item.feed}>
<ModuleHeader.FeedAvatar feed={item.feed} />
<View style={[a.flex_1, a.gap_2xs]}>
<ModuleHeader.TitleText style={[a.text_lg]}>
{item.feed.displayName}
</ModuleHeader.TitleText>
<ModuleHeader.SubtitleText>
<Trans>
By {sanitizeHandle(item.feed.creator.handle, '@')}
</Trans>
</ModuleHeader.SubtitleText>
</View>
</ModuleHeader.FeedLink>
</KeyboardActivation.Boundary>
</View>
</ModuleHeader.FeedLink>
<ModuleHeader.PinButton feed={item.feed} />
</ModuleHeader.Container>
<ModuleHeader.PinButton feed={item.feed} />
</ModuleHeader.Container>
</View>
)
}
case 'preview:footer': {
@@ -984,26 +1044,33 @@ export function Explore({
const indexInSlice = item.indexInSlice
const subItem = slice.items[indexInSlice]
return (
<PostFeedItem
post={subItem.post}
record={subItem.record}
reason={indexInSlice === 0 ? slice.reason : undefined}
feedContext={slice.feedContext}
reqId={slice.reqId}
moderation={subItem.moderation}
parentAuthor={subItem.parentAuthor}
showReplyTo={item.showReplyTo}
isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
isThreadLastChild={
isThreadChildAt(slice.items, indexInSlice) &&
slice.items.length === indexInSlice + 1
}
isParentBlocked={subItem.isParentBlocked}
isParentNotFound={subItem.isParentNotFound}
hideTopBorder={item.hideTopBorder}
rootPost={slice.items[0].post}
/>
<KeyboardActivation.Boundary register={feedItemActivation(index)}>
<PostFeedItem
post={subItem.post}
record={subItem.record}
reason={indexInSlice === 0 ? slice.reason : undefined}
feedContext={slice.feedContext}
reqId={slice.reqId}
moderation={subItem.moderation}
parentAuthor={subItem.parentAuthor}
showReplyTo={item.showReplyTo}
isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
isThreadLastChild={
isThreadChildAt(slice.items, indexInSlice) &&
slice.items.length === indexInSlice + 1
}
isParentBlocked={subItem.isParentBlocked}
isParentNotFound={subItem.isParentNotFound}
hideTopBorder={item.hideTopBorder}
rootPost={slice.items[0].post}
feedItemIndex={indexInSlice === 0 ? index : undefined}
feedItemRef={
indexInSlice === 0 ? feedItemRef(index) : undefined
}
isFocused={indexInSlice === 0 && index === focusedFeedItemIndex}
/>
</KeyboardActivation.Boundary>
)
}
case 'preview:sliceViewFullThread': {
@@ -1039,6 +1106,9 @@ export function Explore({
useFullExperience,
l,
fetchNextPageFeedPreviews,
feedItemRef,
feedItemActivation,
focusedFeedItemIndex,
],
)
+78 -10
View File
@@ -2,9 +2,12 @@ import {memo, useCallback, useMemo, useState} from 'react'
import {ActivityIndicator, View} from 'react-native'
import {type AppBskyFeedDefs, type AppBskyGraphDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useIsFocused} from '@react-navigation/native'
import {urls} from '#/lib/constants'
import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking'
import {useFeedKeyboardNav} from '#/lib/hotkeys'
import * as KeyboardActivation from '#/lib/hotkeys/KeyboardActivation'
import {useCallOnce} from '#/lib/once'
import {
cleanError,
@@ -35,6 +38,7 @@ import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {SearchError} from '#/components/SearchError'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import {type Metrics, useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
@@ -66,10 +70,10 @@ let SearchResults = ({
const hasPostFilters = hasPostOnlyFilters(filters) || fromMe
const activePage = hasPostFilters && activeTab > 1 ? 0 : activeTab
const tabShape = hasPostFilters ? 'filtered' : 'plain'
const isStarterPacksEnabled = ax.features.enabled(
ax.features.SearchStarterPacksV2Enable,
)
const isScreenFocused = useIsFocused()
const sections = useMemo(() => {
if (!query && !hasFilters) return []
@@ -87,7 +91,7 @@ let SearchResults = ({
query={query}
filters={filters}
sort="top"
active={activePage === 0}
active={isScreenFocused && activePage === 0}
/>
),
},
@@ -99,20 +103,26 @@ let SearchResults = ({
query={query}
filters={filters}
sort="latest"
active={activePage === 1}
active={isScreenFocused && activePage === 1}
/>
),
},
noFilters && {
title: l`People`,
component: (
<SearchScreenUserResults query={query} active={activePage === 2} />
<SearchScreenUserResults
query={query}
active={isScreenFocused && activePage === 2}
/>
),
},
noFilters && {
title: l`Feeds`,
component: (
<SearchScreenFeedsResults query={query} active={activePage === 3} />
<SearchScreenFeedsResults
query={query}
active={isScreenFocused && activePage === 3}
/>
),
},
noFilters &&
@@ -121,7 +131,7 @@ let SearchResults = ({
component: (
<SearchScreenStarterPackResults
query={query}
active={activePage === 4}
active={isScreenFocused && activePage === 4}
/>
),
},
@@ -137,6 +147,7 @@ let SearchResults = ({
hasPostFilters,
activePage,
isStarterPacksEnabled,
isScreenFocused,
])
// There may be fewer tabs after changing the search options.
@@ -405,6 +416,20 @@ let SearchScreenPostResults = ({
requestSwitchToAccount({requestedAccount: 'new'})
}
const focusableIndices = useMemo(() => {
const indices: number[] = []
for (let i = 0; i < items.length; i++) {
if (items[i].type === 'post') {
indices.push(i)
}
}
return indices
}, [items])
const {focusedIndex, itemRef, itemActivation} = useFeedKeyboardNav({
focusableIndices,
active,
})
if (!hasSession) {
return (
<SearchError title={l`Search is currently unavailable when logged out`}>
@@ -456,7 +481,18 @@ let SearchScreenPostResults = ({
}) => {
if (item.type === 'post') {
return (
<SearchPost from={sort} position={index} post={item.post} />
<View>
<SubtleHover hover={index === focusedIndex} />
<KeyboardActivation.Boundary
register={itemActivation(index)}>
<SearchPost
from={sort}
ref={itemRef(index)}
position={index}
post={item.post}
/>
</KeyboardActivation.Boundary>
</View>
)
} else {
return null
@@ -501,10 +537,12 @@ function SearchPost({
from,
position,
post,
ref,
}: {
from: Metrics['search:result:press']['tab']
position: Metrics['search:result:press']['position']
post: AppBskyFeedDefs.PostView
ref?: React.Ref<View>
}) {
const ax = useAnalytics()
@@ -517,7 +555,7 @@ function SearchPost({
})
}, [ax, from, position, post])
return <Post post={post} onBeforePress={onBeforePress} />
return <Post post={post} onBeforePress={onBeforePress} ref={ref} />
}
let SearchScreenUserResults = ({
@@ -571,6 +609,14 @@ let SearchScreenUserResults = ({
fireTracking()
}
const focusableIndices = useMemo(() => {
return profiles.map((_: bsky.profile.AnyProfileView, i: number) => i)
}, [profiles])
const {focusedIndex, itemRef, itemActivation} = useFeedKeyboardNav({
focusableIndices,
active,
})
if (error) {
return (
<EmptyState
@@ -595,7 +641,14 @@ let SearchScreenUserResults = ({
}: {
item: bsky.profile.AnyProfileView
index: number
}) => <SearchScreenProfileButton position={index} profile={item} />}
}) => (
<View ref={itemRef(index)}>
<SubtleHover hover={index === focusedIndex} />
<KeyboardActivation.Boundary register={itemActivation(index)}>
<SearchScreenProfileButton position={index} profile={item} />
</KeyboardActivation.Boundary>
</View>
)}
keyExtractor={(item: bsky.profile.AnyProfileView) => item.did}
refreshing={isPTR}
onRefresh={() => void onPullToRefresh()}
@@ -663,6 +716,16 @@ let SearchScreenFeedsResults = ({
fireTracking()
}
const focusableIndices = useMemo(() => {
return (results ?? []).map(
(_: AppBskyFeedDefs.GeneratorView, i: number) => i,
)
}, [results])
const {focusedIndex, itemRef, itemActivation} = useFeedKeyboardNav({
focusableIndices,
active,
})
return isFetched && results ? (
<>
{results.length ? (
@@ -676,13 +739,18 @@ let SearchScreenFeedsResults = ({
index: number
}) => (
<View
ref={itemRef(index)}
style={[
a.border_t,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
a.relative,
]}>
<SearchFeedCard position={index} view={item} />
<SubtleHover hover={index === focusedIndex} />
<KeyboardActivation.Boundary register={itemActivation(index)}>
<SearchFeedCard position={index} view={item} />
</KeyboardActivation.Boundary>
</View>
)}
keyExtractor={(item: AppBskyFeedDefs.GeneratorView) => item.uri}
@@ -19,13 +19,16 @@ export function Container({
style,
children,
bottomBorder,
ref,
}: {
children: React.ReactNode
bottomBorder?: boolean
ref?: React.Ref<View | null>
} & ViewStyleProp) {
const t = useTheme()
return (
<View
ref={ref}
style={[
a.flex_row,
a.align_center,
@@ -48,14 +48,9 @@ export function ExploreInterestsCard() {
onConfirm={onConfirmClose}
/>
<View style={[a.pb_2xs]}>
<View>
<View
style={[
a.p_lg,
a.border_b,
a.gap_md,
t.atoms.border_contrast_medium,
]}>
style={[a.p_lg, a.border_b, a.gap_md, t.atoms.border_contrast_low]}>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<ShapesIcon fill={t.atoms.text.color} />
<Text style={[a.text_lg, a.font_semi_bold]}>