Enable scrolling via J and K keys
This commit is contained in:
+126
-56
@@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
Reference in New Issue
Block a user