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
-11
View File
@@ -1739,17 +1739,6 @@
"count": 1 "count": 1
} }
}, },
"src/view/screens/Feeds.tsx": {
"typescript/no-floating-promises": {
"count": 3
},
"typescript/no-misused-promises": {
"count": 1
},
"typescript/no-unsafe-member-access": {
"count": 1
}
},
"src/view/screens/Home.tsx": { "src/view/screens/Home.tsx": {
"typescript/no-floating-promises": { "typescript/no-floating-promises": {
"count": 1 "count": 1
+21 -12
View File
@@ -4,6 +4,7 @@ import {
Linking, Linking,
type NativeSyntheticEvent, type NativeSyntheticEvent,
type TargetedEvent, type TargetedEvent,
type View,
} from 'react-native' } from 'react-native'
import {sanitizeUrl} from '@braintree/sanitize-url' import {sanitizeUrl} from '@braintree/sanitize-url'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
@@ -16,6 +17,7 @@ import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {useGroupChatJoinIntent} from '#/lib/hooks/useIntentHandler' import {useGroupChatJoinIntent} from '#/lib/hooks/useIntentHandler'
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped' import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {useOpenLink} from '#/lib/hooks/useOpenLink' import {useOpenLink} from '#/lib/hooks/useOpenLink'
import * as KeyboardActivation from '#/lib/hotkeys/KeyboardActivation'
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types' import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing' import {shareUrl} from '#/lib/sharing'
import { import {
@@ -144,8 +146,8 @@ export function useLink({
const groupChatJoinIntent = useGroupChatJoinIntent() const groupChatJoinIntent = useGroupChatJoinIntent()
const onPress = useCallback( const onPress = useCallback(
(e: GestureResponderEvent) => { (e?: GestureResponderEvent) => {
const exitEarlyIfFalse = outerOnPress?.(e) const exitEarlyIfFalse = outerOnPress?.(e as GestureResponderEvent)
if (exitEarlyIfFalse === false) return if (exitEarlyIfFalse === false) return
@@ -157,7 +159,7 @@ export function useLink({
) )
if (IS_WEB) { if (IS_WEB) {
e.preventDefault() e?.preventDefault()
} }
const chatInviteCode = getChatInviteCodeFromUrl(href) const chatInviteCode = getChatInviteCodeFromUrl(href)
@@ -175,7 +177,7 @@ export function useLink({
if (isExternal) { if (isExternal) {
void openLink(href, overridePresentation, shouldProxy) void openLink(href, overridePresentation, shouldProxy)
} else { } else {
const shouldOpenInNewTab = shouldClickOpenNewTab(e) const shouldOpenInNewTab = e ? shouldClickOpenNewTab(e) : false
if (isBskyDownloadUrl(href)) { if (isBskyDownloadUrl(href)) {
void shareUrl(BSKY_DOWNLOAD_URL) void shareUrl(BSKY_DOWNLOAD_URL)
@@ -300,6 +302,7 @@ export function useLink({
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> & export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
Omit<ButtonProps, 'onPress' | 'disabled'> & { Omit<ButtonProps, 'onPress' | 'disabled'> & {
overridePresentation?: boolean overridePresentation?: boolean
ref?: React.Ref<View>
} }
/** /**
@@ -320,6 +323,7 @@ export function Link({
shouldProxy, shouldProxy,
overridePresentation, overridePresentation,
peek, peek,
ref,
...rest ...rest
}: LinkProps) { }: LinkProps) {
const {href, isExternal, onPress, onLongPress, openExternally} = useLink({ const {href, isExternal, onPress, onLongPress, openExternally} = useLink({
@@ -331,12 +335,15 @@ export function Link({
shouldProxy: shouldProxy, shouldProxy: shouldProxy,
overridePresentation, overridePresentation,
}) })
const activate = useCallback(() => onPress(), [onPress])
KeyboardActivation.useRegistration(activate)
// Peek is iOS-only and only makes sense for external web links. // Peek is iOS-only and only makes sense for external web links.
const peekEnabled = Boolean(peek && IS_IOS && isExternal) const peekEnabled = Boolean(peek && IS_IOS && isExternal)
const button = ( const button = (
<Button <Button
ref={ref}
{...rest} {...rest}
style={[a.justify_start, rest.style]} style={[a.justify_start, rest.style]}
role="link" role="link"
@@ -368,17 +375,19 @@ export function Link({
// the peek animation clips to the same corners as the rendered card. // the peek animation clips to the same corners as the rendered card.
const borderRadius = flatten(rest.style)?.borderRadius const borderRadius = flatten(rest.style)?.borderRadius
return ( return (
<LinkPeek <KeyboardActivation.Isolation>
href={href} <LinkPeek
onPreviewPress={openExternally} href={href}
shouldProxy={shouldProxy} onPreviewPress={openExternally}
borderRadius={typeof borderRadius === 'number' ? borderRadius : 0}> shouldProxy={shouldProxy}
{button} borderRadius={typeof borderRadius === 'number' ? borderRadius : 0}>
</LinkPeek> {button}
</LinkPeek>
</KeyboardActivation.Isolation>
) )
} }
return button return <KeyboardActivation.Isolation>{button}</KeyboardActivation.Isolation>
} }
/** /**
+27
View File
@@ -0,0 +1,27 @@
import {createContext, useContext, useEffect} from 'react'
export type KeyboardActivationRegistrar = (activate: () => void) => () => void
const Context = createContext<KeyboardActivationRegistrar | undefined>(
undefined,
)
Context.displayName = 'KeyboardActivationContext'
export function Boundary({
register,
children,
}: React.PropsWithChildren<{register: KeyboardActivationRegistrar}>) {
return <Context.Provider value={register}>{children}</Context.Provider>
}
export function Isolation({children}: React.PropsWithChildren<unknown>) {
return <Context.Provider value={undefined}>{children}</Context.Provider>
}
export function useRegistration(activate: () => void) {
const register = useContext(Context)
useEffect(() => {
return register?.(activate)
}, [activate, register])
}
+17 -3
View File
@@ -1,17 +1,31 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {type FeedKeyboardNavOptions, type FeedKeyboardNavResult} from './types'
export function Provider({children}: {children: React.ReactNode}) { export function Provider({children}: {children: React.ReactNode}) {
return children return children
} }
const noop = (_scope: string) => {} const noop = () => {}
const noopScope = (_scope: string) => {}
export function useHotkeysContext() { export function useHotkeysContext() {
return useMemo( return useMemo(
() => ({ () => ({
enableScope: noop, enableScope: noopScope,
disableScope: noop, disableScope: noopScope,
}), }),
[], [],
) )
} }
export function useFeedKeyboardNav(
_options: FeedKeyboardNavOptions,
): FeedKeyboardNavResult {
return {
focusedIndex: -1,
setFocusedIndex: noop,
itemRef: _index => _el => {},
itemActivation: _index => _activate => noop,
}
}
+231 -2
View File
@@ -1,3 +1,5 @@
import {useCallback, useEffect, useRef, useState} from 'react'
import {type View} from 'react-native'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import { import {
HotkeysProvider, HotkeysProvider,
@@ -6,12 +8,39 @@ import {
} from 'react-hotkeys-hook' } from 'react-hotkeys-hook'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {emitFocusSearch} from '#/state/events' import {
emitFocusNextPost,
emitFocusPrevPost,
emitFocusSearch,
emitOpenFocusedPost,
listenFocusNextPost,
listenFocusPrevPost,
listenOpenFocusedPost,
} from '#/state/events'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {type FeedKeyboardNavOptions, type FeedKeyboardNavResult} from './types'
const FEED_SCROLL_DEBOUNCE_MS = 250
const activeScopeCounts = new Map<string, number>()
enum Hotkeys { enum Hotkeys {
OPEN_COMPOSER = 'n', OPEN_COMPOSER = 'n',
FOCUS_SEARCH = 'slash', FOCUS_SEARCH = 'slash',
PAGE_FORWARD = 'j',
PAGE_BACKWARD = 'k',
OPEN_POST = 'enter',
}
function isElementVisible(el: Element) {
const rect = el.getBoundingClientRect()
return (
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.top < window.innerHeight &&
rect.right > 0 &&
rect.left < window.innerWidth
)
} }
export function Provider({children}: React.PropsWithChildren<unknown>) { export function Provider({children}: React.PropsWithChildren<unknown>) {
@@ -24,6 +53,30 @@ export function Provider({children}: React.PropsWithChildren<unknown>) {
export {useHotkeysContext} export {useHotkeysContext}
function useHotkeyScope(scope: string, active: boolean) {
const {disableScope, enableScope} = useHotkeysContext()
useEffect(() => {
if (!active) return
const count = activeScopeCounts.get(scope) ?? 0
activeScopeCounts.set(scope, count + 1)
if (count === 0) {
enableScope(scope)
}
return () => {
const nextCount = (activeScopeCounts.get(scope) ?? 1) - 1
if (nextCount === 0) {
activeScopeCounts.delete(scope)
disableScope(scope)
} else {
activeScopeCounts.set(scope, nextCount)
}
}
}, [active, disableScope, enableScope, scope])
}
function KeyboardShortcuts({children}: React.PropsWithChildren<unknown>) { function KeyboardShortcuts({children}: React.PropsWithChildren<unknown>) {
useKeyboardShortcuts() useKeyboardShortcuts()
return children return children
@@ -38,7 +91,6 @@ function useKeyboardShortcuts() {
if (requiresSession && !hasSession) { if (requiresSession && !hasSession) {
return true return true
} }
return false return false
} }
@@ -52,6 +104,7 @@ function useKeyboardShortcuts() {
callback() callback()
} }
// Composer
useHotkeys( useHotkeys(
Hotkeys.OPEN_COMPOSER, Hotkeys.OPEN_COMPOSER,
() => () =>
@@ -67,10 +120,186 @@ function useKeyboardShortcuts() {
[openComposer], [openComposer],
) )
// Search
useHotkeys(Hotkeys.FOCUS_SEARCH, () => handleKey(emitFocusSearch), { useHotkeys(Hotkeys.FOCUS_SEARCH, () => handleKey(emitFocusSearch), {
scopes: ['global'], scopes: ['global'],
preventDefault: true, preventDefault: true,
description: l`Focus the search field`, description: l`Focus the search field`,
useKey: true, // Support international and alternate keyboard layouts useKey: true, // Support international and alternate keyboard layouts
}) })
// Feed nav
useHotkeys(Hotkeys.PAGE_FORWARD, () => handleKey(emitFocusNextPost), {
scopes: ['feed'],
description: l`Focus the next post`,
enableOnFormTags: false,
enableOnContentEditable: false,
})
useHotkeys(Hotkeys.PAGE_BACKWARD, () => handleKey(emitFocusPrevPost), {
scopes: ['feed'],
description: l`Focus the previous post`,
enableOnFormTags: false,
enableOnContentEditable: false,
})
useHotkeys(Hotkeys.OPEN_POST, () => handleKey(emitOpenFocusedPost), {
scopes: ['feed'],
description: l`Open this post`,
enableOnFormTags: false,
enableOnContentEditable: false,
})
}
/**
* Add keyboard navigation to a list of items.
*/
export function useFeedKeyboardNav({
focusableIndices,
active = true,
}: FeedKeyboardNavOptions): FeedKeyboardNavResult {
useHotkeyScope('feed', active)
const [focusedIndex, setFocusedIndex] = useState(-1)
const itemElsRef = useRef<Map<number, Element>>(new Map())
const itemRefCallbacksRef = useRef<Map<number, (el: View | null) => void>>(
new Map(),
)
const itemActivationCallbacksRef = useRef<Map<number, () => void>>(new Map())
const itemActivationRegistrarsRef = useRef<
Map<number, (activate: () => void) => () => void>
>(new Map())
const scrollingRef = useRef(false)
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const itemRef = useCallback((index: number) => {
let callback = itemRefCallbacksRef.current.get(index)
if (!callback) {
const newCallback = (el: View | null) => {
if (el) {
itemElsRef.current.set(index, el as unknown as Element)
} else {
itemElsRef.current.delete(index)
if (itemRefCallbacksRef.current.get(index) === newCallback) {
itemRefCallbacksRef.current.delete(index)
}
itemActivationCallbacksRef.current.delete(index)
itemActivationRegistrarsRef.current.delete(index)
}
}
itemRefCallbacksRef.current.set(index, newCallback)
callback = newCallback
}
return callback
}, [])
const itemActivation = useCallback((index: number) => {
let register = itemActivationRegistrarsRef.current.get(index)
if (!register) {
register = (activate: () => void) => {
itemActivationCallbacksRef.current.set(index, activate)
return () => {
if (itemActivationCallbacksRef.current.get(index) === activate) {
itemActivationCallbacksRef.current.delete(index)
}
}
}
itemActivationRegistrarsRef.current.set(index, register)
}
return register
}, [])
useEffect(() => {
setFocusedIndex(prev => {
if (!active || (prev !== -1 && !focusableIndices.includes(prev))) {
return -1
}
return prev
})
}, [active, focusableIndices])
const findTopVisibleIndex = useCallback(() => {
if (!active) return -1
for (const idx of focusableIndices) {
const el = itemElsRef.current.get(idx)
if (el && isElementVisible(el)) {
return idx
}
}
return -1
}, [active, focusableIndices])
const isFocusedItemVisible = useCallback((index: number) => {
if (index < 0) return false
const el = itemElsRef.current.get(index)
if (!el) return false
return isElementVisible(el)
}, [])
// Scroll focused item into view
useEffect(() => {
if (!active || focusedIndex < 0) return
const el = itemElsRef.current.get(focusedIndex)
el?.scrollIntoView({behavior: 'smooth', block: 'center'})
scrollingRef.current = true
clearTimeout(scrollTimeoutRef.current)
scrollTimeoutRef.current = setTimeout(() => {
scrollingRef.current = false
}, FEED_SCROLL_DEBOUNCE_MS)
return () => clearTimeout(scrollTimeoutRef.current)
}, [active, focusedIndex])
// Listen for keyboard events
useEffect(() => {
const unlistenNext = listenFocusNextPost(() => {
if (!active) return
if (scrollingRef.current) return
setFocusedIndex(prev => {
if (prev === -1 || !isFocusedItemVisible(prev)) {
return findTopVisibleIndex()
}
const currentPos = focusableIndices.indexOf(prev)
if (currentPos === -1) {
return findTopVisibleIndex()
}
if (currentPos < focusableIndices.length - 1) {
return focusableIndices[currentPos + 1]
}
return prev
})
})
const unlistenPrev = listenFocusPrevPost(() => {
if (!active) return
if (scrollingRef.current) return
setFocusedIndex(prev => {
if (prev === -1 || !isFocusedItemVisible(prev)) {
return findTopVisibleIndex()
}
const currentPos = focusableIndices.indexOf(prev)
if (currentPos === -1) {
return findTopVisibleIndex()
}
if (currentPos > 0) {
return focusableIndices[currentPos - 1]
}
return prev
})
})
const unlistenOpen = listenOpenFocusedPost(() => {
if (!active) return
if (focusedIndex < 0) return
itemActivationCallbacksRef.current.get(focusedIndex)?.()
})
return () => {
unlistenNext()
unlistenPrev()
unlistenOpen()
}
}, [
active,
findTopVisibleIndex,
focusableIndices,
focusedIndex,
isFocusedItemVisible,
])
return {focusedIndex, setFocusedIndex, itemRef, itemActivation}
} }
+22
View File
@@ -0,0 +1,22 @@
import {type Dispatch, type SetStateAction} from 'react'
import {type View} from 'react-native'
import {type KeyboardActivationRegistrar} from './KeyboardActivation'
export type FeedKeyboardNavOptions = {
/**
* Compute this based on item types (e.g. only root posts).
*/
focusableIndices: number[]
/**
* Pass false when the list is in an inactive tab.
*/
active?: boolean
}
export type FeedKeyboardNavResult = {
focusedIndex: number
setFocusedIndex: Dispatch<SetStateAction<number>>
itemRef: (index: number) => (el: View | null) => void
itemActivation: (index: number) => KeyboardActivationRegistrar
}
+126 -56
View File
@@ -6,9 +6,12 @@ import {
type AppBskyGraphDefs, type AppBskyGraphDefs,
} from '@atproto/api' } from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useIsFocused} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import * as bcp47Match from 'bcp-47-match' 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 {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
@@ -220,6 +223,8 @@ export function Explore({
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const [selectedInterest, setSelectedInterest] = useState<string | null>(null) const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
const isScreenFocused = useIsFocused()
/* /*
* Begin special language handling * Begin special language handling
*/ */
@@ -712,6 +717,7 @@ export function Explore({
] ]
}, [showInterestsNux]) }, [showInterestsNux])
// Keyboard nav: Keep track of focused elements
const items = useMemo<ExploreScreenItems[]>(() => { const items = useMemo<ExploreScreenItems[]>(() => {
const i: ExploreScreenItems[] = [] const i: ExploreScreenItems[] = []
@@ -744,6 +750,30 @@ export function Explore({
useFullExperience, 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( const renderItem = useCallback(
({item, index}: {item: ExploreScreenItems; index: number}) => { ({item, index}: {item: ExploreScreenItems; index: number}) => {
const handleOnPressRetry = () => { const handleOnPressRetry = () => {
@@ -801,12 +831,17 @@ export function Explore({
} }
case 'profile': { case 'profile': {
return ( return (
<SuggestedProfileCard <View ref={feedItemRef(index)}>
profile={item.profile} <SubtleHover hover={index === focusedFeedItemIndex} />
moderationOpts={moderationOpts!} <KeyboardActivation.Boundary register={feedItemActivation(index)}>
recId={item.recId} <SuggestedProfileCard
position={index} profile={item.profile}
/> moderationOpts={moderationOpts!}
recId={item.recId}
position={index}
/>
</KeyboardActivation.Boundary>
</View>
) )
} }
case 'profileEmpty': { case 'profileEmpty': {
@@ -832,25 +867,36 @@ export function Explore({
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
a.px_lg, a.px_lg,
a.py_lg, a.py_lg,
]}> a.relative,
<FeedCard.Default ]}
view={item.feed} ref={feedItemRef(index)}>
onPress={() => { <SubtleHover hover={index === focusedFeedItemIndex} />
if (!useFullExperience) { <KeyboardActivation.Boundary register={feedItemActivation(index)}>
return <FeedCard.Default
} view={item.feed}
ax.metric('feed:suggestion:press', { onPress={() => {
feedUrl: item.feed.uri, if (!useFullExperience) {
}) return
}} }
/> ax.metric('feed:suggestion:press', {
feedUrl: item.feed.uri,
})
}}
/>
</KeyboardActivation.Boundary>
</View> </View>
) )
} }
case 'starterPack': { case 'starterPack': {
return ( return (
<View style={[a.px_lg, a.pb_lg]}> <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> </View>
) )
} }
@@ -947,24 +993,38 @@ export function Explore({
} }
case 'preview:header': { case 'preview:header': {
return ( return (
<ModuleHeader.Container style={[a.pt_xs]} bottomBorder> <View ref={feedItemRef(index)}>
{/* Very non-scientific way to avoid small gap on scroll */} <ModuleHeader.Container style={[a.pt_xs]} bottomBorder>
<View style={[a.absolute, a.inset_0, t.atoms.bg, {top: -2}]} /> {/* Very non-scientific way to avoid small gap on scroll */}
<ModuleHeader.FeedLink feed={item.feed}> <View style={[a.absolute, a.inset_0, t.atoms.bg, {top: -2}]} />
<ModuleHeader.FeedAvatar feed={item.feed} /> <View
<View style={[a.flex_1, a.gap_2xs]}> style={[
<ModuleHeader.TitleText style={[a.text_lg]}> a.relative,
{item.feed.displayName} a.flex_1,
</ModuleHeader.TitleText> a.rounded_md,
<ModuleHeader.SubtitleText> a.overflow_hidden,
<Trans> ]}>
By {sanitizeHandle(item.feed.creator.handle, '@')} <SubtleHover hover={index === focusedFeedItemIndex} />
</Trans> <KeyboardActivation.Boundary
</ModuleHeader.SubtitleText> 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> </View>
</ModuleHeader.FeedLink> <ModuleHeader.PinButton feed={item.feed} />
<ModuleHeader.PinButton feed={item.feed} /> </ModuleHeader.Container>
</ModuleHeader.Container> </View>
) )
} }
case 'preview:footer': { case 'preview:footer': {
@@ -984,26 +1044,33 @@ export function Explore({
const indexInSlice = item.indexInSlice const indexInSlice = item.indexInSlice
const subItem = slice.items[indexInSlice] const subItem = slice.items[indexInSlice]
return ( return (
<PostFeedItem <KeyboardActivation.Boundary register={feedItemActivation(index)}>
post={subItem.post} <PostFeedItem
record={subItem.record} post={subItem.post}
reason={indexInSlice === 0 ? slice.reason : undefined} record={subItem.record}
feedContext={slice.feedContext} reason={indexInSlice === 0 ? slice.reason : undefined}
reqId={slice.reqId} feedContext={slice.feedContext}
moderation={subItem.moderation} reqId={slice.reqId}
parentAuthor={subItem.parentAuthor} moderation={subItem.moderation}
showReplyTo={item.showReplyTo} parentAuthor={subItem.parentAuthor}
isThreadParent={isThreadParentAt(slice.items, indexInSlice)} showReplyTo={item.showReplyTo}
isThreadChild={isThreadChildAt(slice.items, indexInSlice)} isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
isThreadLastChild={ isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
isThreadChildAt(slice.items, indexInSlice) && isThreadLastChild={
slice.items.length === indexInSlice + 1 isThreadChildAt(slice.items, indexInSlice) &&
} slice.items.length === indexInSlice + 1
isParentBlocked={subItem.isParentBlocked} }
isParentNotFound={subItem.isParentNotFound} isParentBlocked={subItem.isParentBlocked}
hideTopBorder={item.hideTopBorder} isParentNotFound={subItem.isParentNotFound}
rootPost={slice.items[0].post} 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': { case 'preview:sliceViewFullThread': {
@@ -1039,6 +1106,9 @@ export function Explore({
useFullExperience, useFullExperience,
l, l,
fetchNextPageFeedPreviews, 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 {ActivityIndicator, View} from 'react-native'
import {type AppBskyFeedDefs, type AppBskyGraphDefs} from '@atproto/api' import {type AppBskyFeedDefs, type AppBskyGraphDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useIsFocused} from '@react-navigation/native'
import {urls} from '#/lib/constants' import {urls} from '#/lib/constants'
import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking'
import {useFeedKeyboardNav} from '#/lib/hotkeys'
import * as KeyboardActivation from '#/lib/hotkeys/KeyboardActivation'
import {useCallOnce} from '#/lib/once' import {useCallOnce} from '#/lib/once'
import { import {
cleanError, cleanError,
@@ -35,6 +38,7 @@ import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {ListFooter} from '#/components/Lists' import {ListFooter} from '#/components/Lists'
import {SearchError} from '#/components/SearchError' import {SearchError} from '#/components/SearchError'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {type Metrics, useAnalytics} from '#/analytics' import {type Metrics, useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
@@ -66,10 +70,10 @@ let SearchResults = ({
const hasPostFilters = hasPostOnlyFilters(filters) || fromMe const hasPostFilters = hasPostOnlyFilters(filters) || fromMe
const activePage = hasPostFilters && activeTab > 1 ? 0 : activeTab const activePage = hasPostFilters && activeTab > 1 ? 0 : activeTab
const tabShape = hasPostFilters ? 'filtered' : 'plain' const tabShape = hasPostFilters ? 'filtered' : 'plain'
const isStarterPacksEnabled = ax.features.enabled( const isStarterPacksEnabled = ax.features.enabled(
ax.features.SearchStarterPacksV2Enable, ax.features.SearchStarterPacksV2Enable,
) )
const isScreenFocused = useIsFocused()
const sections = useMemo(() => { const sections = useMemo(() => {
if (!query && !hasFilters) return [] if (!query && !hasFilters) return []
@@ -87,7 +91,7 @@ let SearchResults = ({
query={query} query={query}
filters={filters} filters={filters}
sort="top" sort="top"
active={activePage === 0} active={isScreenFocused && activePage === 0}
/> />
), ),
}, },
@@ -99,20 +103,26 @@ let SearchResults = ({
query={query} query={query}
filters={filters} filters={filters}
sort="latest" sort="latest"
active={activePage === 1} active={isScreenFocused && activePage === 1}
/> />
), ),
}, },
noFilters && { noFilters && {
title: l`People`, title: l`People`,
component: ( component: (
<SearchScreenUserResults query={query} active={activePage === 2} /> <SearchScreenUserResults
query={query}
active={isScreenFocused && activePage === 2}
/>
), ),
}, },
noFilters && { noFilters && {
title: l`Feeds`, title: l`Feeds`,
component: ( component: (
<SearchScreenFeedsResults query={query} active={activePage === 3} /> <SearchScreenFeedsResults
query={query}
active={isScreenFocused && activePage === 3}
/>
), ),
}, },
noFilters && noFilters &&
@@ -121,7 +131,7 @@ let SearchResults = ({
component: ( component: (
<SearchScreenStarterPackResults <SearchScreenStarterPackResults
query={query} query={query}
active={activePage === 4} active={isScreenFocused && activePage === 4}
/> />
), ),
}, },
@@ -137,6 +147,7 @@ let SearchResults = ({
hasPostFilters, hasPostFilters,
activePage, activePage,
isStarterPacksEnabled, isStarterPacksEnabled,
isScreenFocused,
]) ])
// There may be fewer tabs after changing the search options. // There may be fewer tabs after changing the search options.
@@ -405,6 +416,20 @@ let SearchScreenPostResults = ({
requestSwitchToAccount({requestedAccount: 'new'}) 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) { if (!hasSession) {
return ( return (
<SearchError title={l`Search is currently unavailable when logged out`}> <SearchError title={l`Search is currently unavailable when logged out`}>
@@ -456,7 +481,18 @@ let SearchScreenPostResults = ({
}) => { }) => {
if (item.type === 'post') { if (item.type === 'post') {
return ( 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 { } else {
return null return null
@@ -501,10 +537,12 @@ function SearchPost({
from, from,
position, position,
post, post,
ref,
}: { }: {
from: Metrics['search:result:press']['tab'] from: Metrics['search:result:press']['tab']
position: Metrics['search:result:press']['position'] position: Metrics['search:result:press']['position']
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
ref?: React.Ref<View>
}) { }) {
const ax = useAnalytics() const ax = useAnalytics()
@@ -517,7 +555,7 @@ function SearchPost({
}) })
}, [ax, from, position, post]) }, [ax, from, position, post])
return <Post post={post} onBeforePress={onBeforePress} /> return <Post post={post} onBeforePress={onBeforePress} ref={ref} />
} }
let SearchScreenUserResults = ({ let SearchScreenUserResults = ({
@@ -571,6 +609,14 @@ let SearchScreenUserResults = ({
fireTracking() fireTracking()
} }
const focusableIndices = useMemo(() => {
return profiles.map((_: bsky.profile.AnyProfileView, i: number) => i)
}, [profiles])
const {focusedIndex, itemRef, itemActivation} = useFeedKeyboardNav({
focusableIndices,
active,
})
if (error) { if (error) {
return ( return (
<EmptyState <EmptyState
@@ -595,7 +641,14 @@ let SearchScreenUserResults = ({
}: { }: {
item: bsky.profile.AnyProfileView item: bsky.profile.AnyProfileView
index: number 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} keyExtractor={(item: bsky.profile.AnyProfileView) => item.did}
refreshing={isPTR} refreshing={isPTR}
onRefresh={() => void onPullToRefresh()} onRefresh={() => void onPullToRefresh()}
@@ -663,6 +716,16 @@ let SearchScreenFeedsResults = ({
fireTracking() fireTracking()
} }
const focusableIndices = useMemo(() => {
return (results ?? []).map(
(_: AppBskyFeedDefs.GeneratorView, i: number) => i,
)
}, [results])
const {focusedIndex, itemRef, itemActivation} = useFeedKeyboardNav({
focusableIndices,
active,
})
return isFetched && results ? ( return isFetched && results ? (
<> <>
{results.length ? ( {results.length ? (
@@ -676,13 +739,18 @@ let SearchScreenFeedsResults = ({
index: number index: number
}) => ( }) => (
<View <View
ref={itemRef(index)}
style={[ style={[
a.border_t, a.border_t,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
a.px_lg, a.px_lg,
a.py_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> </View>
)} )}
keyExtractor={(item: AppBskyFeedDefs.GeneratorView) => item.uri} keyExtractor={(item: AppBskyFeedDefs.GeneratorView) => item.uri}
@@ -19,13 +19,16 @@ export function Container({
style, style,
children, children,
bottomBorder, bottomBorder,
ref,
}: { }: {
children: React.ReactNode children: React.ReactNode
bottomBorder?: boolean bottomBorder?: boolean
ref?: React.Ref<View | null>
} & ViewStyleProp) { } & ViewStyleProp) {
const t = useTheme() const t = useTheme()
return ( return (
<View <View
ref={ref}
style={[ style={[
a.flex_row, a.flex_row,
a.align_center, a.align_center,
@@ -48,14 +48,9 @@ export function ExploreInterestsCard() {
onConfirm={onConfirmClose} onConfirm={onConfirmClose}
/> />
<View style={[a.pb_2xs]}> <View>
<View <View
style={[ style={[a.p_lg, a.border_b, a.gap_md, t.atoms.border_contrast_low]}>
a.p_lg,
a.border_b,
a.gap_md,
t.atoms.border_contrast_medium,
]}>
<View style={[a.flex_row, a.gap_sm, a.align_center]}> <View style={[a.flex_row, a.gap_sm, a.align_center]}>
<ShapesIcon fill={t.atoms.text.color} /> <ShapesIcon fill={t.atoms.text.color} />
<Text style={[a.text_lg, a.font_semi_bold]}> <Text style={[a.text_lg, a.font_semi_bold]}>
+24
View File
@@ -53,3 +53,27 @@ export function listenFocusSearch(fn: () => void): UnlistenFn {
emitter.on('focus-search', fn) emitter.on('focus-search', fn)
return () => emitter.off('focus-search', fn) return () => emitter.off('focus-search', fn)
} }
export function emitFocusNextPost() {
emitter.emit('focus-next-post')
}
export function listenFocusNextPost(fn: () => void): UnlistenFn {
emitter.on('focus-next-post', fn)
return () => emitter.off('focus-next-post', fn)
}
export function emitFocusPrevPost() {
emitter.emit('focus-prev-post')
}
export function listenFocusPrevPost(fn: () => void): UnlistenFn {
emitter.on('focus-prev-post', fn)
return () => emitter.off('focus-prev-post', fn)
}
export function emitOpenFocusedPost() {
emitter.emit('open-focused-post')
}
export function listenOpenFocusedPost(fn: () => void): UnlistenFn {
emitter.on('open-focused-post', fn)
return () => emitter.off('open-focused-post', fn)
}
+1
View File
@@ -147,6 +147,7 @@ export function FeedPage({
<PostFeed <PostFeed
testID={testID ? `${testID}-feed` : undefined} testID={testID ? `${testID}-feed` : undefined}
enabled={isPageFocused || shouldPrefetch} enabled={isPageFocused || shouldPrefetch}
keyboardNavActive={isPageFocused}
feed={feed} feed={feed}
feedParams={feedParams} feedParams={feedParams}
pollInterval={POLL_FREQ} pollInterval={POLL_FREQ}
+6
View File
@@ -47,12 +47,14 @@ export function Post({
hideTopBorder, hideTopBorder,
style, style,
onBeforePress, onBeforePress,
ref,
}: { }: {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
showReplyLine?: boolean showReplyLine?: boolean
hideTopBorder?: boolean hideTopBorder?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
onBeforePress?: () => void onBeforePress?: () => void
ref?: React.Ref<View>
}) { }) {
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const record = useMemo<AppBskyFeedPost.Record | undefined>( const record = useMemo<AppBskyFeedPost.Record | undefined>(
@@ -92,6 +94,7 @@ export function Post({
hideTopBorder={hideTopBorder} hideTopBorder={hideTopBorder}
style={style} style={style}
onBeforePress={onBeforePress} onBeforePress={onBeforePress}
ref={ref}
/> />
</ReportDialogMetadataContext.Provider> </ReportDialogMetadataContext.Provider>
) )
@@ -108,6 +111,7 @@ function PostInner({
hideTopBorder, hideTopBorder,
style, style,
onBeforePress: outerOnBeforePress, onBeforePress: outerOnBeforePress,
ref,
}: { }: {
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
@@ -117,6 +121,7 @@ function PostInner({
hideTopBorder?: boolean hideTopBorder?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
onBeforePress?: () => void onBeforePress?: () => void
ref?: React.Ref<View>
}) { }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const t = useTheme() const t = useTheme()
@@ -161,6 +166,7 @@ function PostInner({
return ( return (
<GalleryBleed> <GalleryBleed>
<Link <Link
ref={ref}
href={itemHref} href={itemHref}
style={[ style={[
styles.outer, styles.outer,
+66 -22
View File
@@ -28,12 +28,15 @@ import {
type RichText as RichTextType, type RichText as RichTextType,
} from '@atproto/api' } from '@atproto/api'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {useIsFocused} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useFeedKeyboardNav} from '#/lib/hotkeys'
import * as KeyboardActivation from '#/lib/hotkeys/KeyboardActivation'
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow' import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow'
@@ -236,6 +239,7 @@ let PostFeed = ({
initialNumToRender: initialNumToRenderOverride, initialNumToRender: initialNumToRenderOverride,
isVideoFeed = false, isVideoFeed = false,
ref, ref,
keyboardNavActive = true,
}: { }: {
feed: FeedDescriptor feed: FeedDescriptor
description?: RichTextType description?: RichTextType
@@ -259,6 +263,7 @@ let PostFeed = ({
savedFeedConfig?: AppBskyActorDefs.SavedFeed savedFeedConfig?: AppBskyActorDefs.SavedFeed
initialNumToRender?: number initialNumToRender?: number
isVideoFeed?: boolean isVideoFeed?: boolean
keyboardNavActive?: boolean
lastFetchDate?: () => number lastFetchDate?: () => number
ref?: React.Ref<PostFeedRef> ref?: React.Ref<PostFeedRef>
}): React.ReactNode => { }): React.ReactNode => {
@@ -276,7 +281,6 @@ let PostFeed = ({
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {rightNavVisible} = useLayoutBreakpoints() const {rightNavVisible} = useLayoutBreakpoints()
const areVideoFeedsEnabled = IS_NATIVE const areVideoFeedsEnabled = IS_NATIVE
const trendingIndices = ax.features.getValue( const trendingIndices = ax.features.getValue(
ax.features.TrendingDiscoverValues, ax.features.TrendingDiscoverValues,
{ {
@@ -285,6 +289,7 @@ let PostFeed = ({
videos: 30, videos: 30,
}, },
) )
const isScreenFocused = useIsFocused()
const [hasPressedShowLessUris, setHasPressedShowLessUris] = useState( const [hasPressedShowLessUris, setHasPressedShowLessUris] = useState(
() => new Set<string>(), () => new Set<string>(),
@@ -749,6 +754,33 @@ let PostFeed = ({
trendingIndices, trendingIndices,
]) ])
// Keyboard nav: Indices within feedItems that are focusable.
const focusableIndices = useMemo(() => {
const indices: number[] = []
for (let i = 0; i < feedItems.length; i++) {
const row = feedItems[i]
if (row.type === 'sliceItem' && row.indexInSlice === 0) {
indices.push(i)
}
}
return indices
}, [feedItems])
const {
focusedIndex: focusedFeedItemIndex,
setFocusedIndex: setFocusedFeedItemIndex,
itemRef: feedItemRef,
itemActivation: feedItemActivation,
} = useFeedKeyboardNav({
focusableIndices,
active: isScreenFocused && enabled !== false && keyboardNavActive,
})
// Keyboard nav: Reset keyboard focus when feed data changes.
useEffect(() => {
setFocusedFeedItemIndex(-1)
}, [lastFetchedAt, setFocusedFeedItemIndex])
// events // events
// = // =
// //
@@ -880,27 +912,36 @@ let PostFeed = ({
const indexInSlice = row.indexInSlice const indexInSlice = row.indexInSlice
const item = slice.items[indexInSlice] const item = slice.items[indexInSlice]
return ( return (
<PostFeedItem <KeyboardActivation.Boundary register={feedItemActivation(rowIndex)}>
post={item.post} <PostFeedItem
record={item.record} post={item.post}
reason={indexInSlice === 0 ? slice.reason : undefined} record={item.record}
feedContext={slice.feedContext} reason={indexInSlice === 0 ? slice.reason : undefined}
reqId={slice.reqId} feedContext={slice.feedContext}
moderation={item.moderation} reqId={slice.reqId}
parentAuthor={item.parentAuthor} moderation={item.moderation}
showReplyTo={row.showReplyTo} parentAuthor={item.parentAuthor}
isThreadParent={isThreadParentAt(slice.items, indexInSlice)} showReplyTo={row.showReplyTo}
isThreadChild={isThreadChildAt(slice.items, indexInSlice)} isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
isThreadLastChild={ isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
isThreadChildAt(slice.items, indexInSlice) && isThreadLastChild={
slice.items.length === indexInSlice + 1 isThreadChildAt(slice.items, indexInSlice) &&
} slice.items.length === indexInSlice + 1
isParentBlocked={item.isParentBlocked} }
isParentNotFound={item.isParentNotFound} isParentBlocked={item.isParentBlocked}
hideTopBorder={rowIndex === 0 && indexInSlice === 0} isParentNotFound={item.isParentNotFound}
rootPost={slice.items[0].post} hideTopBorder={rowIndex === 0 && indexInSlice === 0}
onShowLess={onPressShowLess} rootPost={slice.items[0].post}
/> onShowLess={onPressShowLess}
feedItemIndex={indexInSlice === 0 ? rowIndex : undefined}
feedItemRef={
indexInSlice === 0 ? feedItemRef(rowIndex) : undefined
}
isFocused={
indexInSlice === 0 && rowIndex === focusedFeedItemIndex
}
/>
</KeyboardActivation.Boundary>
) )
} else if (row.type === 'sliceViewFullThread') { } else if (row.type === 'sliceViewFullThread') {
return <ViewFullThread uri={row.uri} /> return <ViewFullThread uri={row.uri} />
@@ -954,6 +995,9 @@ let PostFeed = ({
feedCacheKey, feedCacheKey,
onPressShowLess, onPressShowLess,
t, t,
feedItemRef,
feedItemActivation,
focusedFeedItemIndex,
], ],
) )
+18 -2
View File
@@ -94,10 +94,16 @@ export function PostFeedItem({
isParentNotFound, isParentNotFound,
rootPost, rootPost,
onShowLess, onShowLess,
feedItemIndex,
feedItemRef,
isFocused,
}: FeedItemProps & { }: FeedItemProps & {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
rootPost: AppBskyFeedDefs.PostView rootPost: AppBskyFeedDefs.PostView
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
feedItemIndex?: number
feedItemRef?: React.Ref<View>
isFocused?: boolean
}): React.ReactNode { }): React.ReactNode {
const postShadowed = usePostShadow(post) const postShadowed = usePostShadow(post)
const richText = useMemo( const richText = useMemo(
@@ -132,6 +138,9 @@ export function PostFeedItem({
isParentNotFound={isParentNotFound} isParentNotFound={isParentNotFound}
rootPost={rootPost} rootPost={rootPost}
onShowLess={onShowLess} onShowLess={onShowLess}
feedItemIndex={feedItemIndex}
feedItemRef={feedItemRef}
isFocused={isFocused}
/> />
</ReportDialogMetadataContext.Provider> </ReportDialogMetadataContext.Provider>
) )
@@ -157,11 +166,17 @@ let FeedItemInner = ({
isParentNotFound, isParentNotFound,
rootPost, rootPost,
onShowLess, onShowLess,
feedItemIndex,
feedItemRef,
isFocused,
}: FeedItemProps & { }: FeedItemProps & {
richText: RichTextAPI richText: RichTextAPI
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
rootPost: AppBskyFeedDefs.PostView rootPost: AppBskyFeedDefs.PostView
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
feedItemIndex?: number
feedItemRef?: React.Ref<View>
isFocused?: boolean
}): React.ReactNode => { }): React.ReactNode => {
const ax = useAnalytics() const ax = useAnalytics()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -326,20 +341,21 @@ let FeedItemInner = ({
return ( return (
<GalleryBleed> <GalleryBleed>
<Link <Link
ref={feedItemRef}
testID={`feedItem-by-${post.author.handle}`} testID={`feedItem-by-${post.author.handle}`}
style={outerStyles} style={outerStyles}
href={href} href={href}
noFeedback noFeedback
accessible={false} accessible={false}
onBeforePress={onBeforePress} onBeforePress={onBeforePress}
dataSet={{feedContext}} dataSet={{feedContext, feedItemIndex: feedItemIndex?.toString()}}
onPointerEnter={() => { onPointerEnter={() => {
setHover(true) setHover(true)
}} }}
onPointerLeave={() => { onPointerLeave={() => {
setHover(false) setHover(false)
}}> }}>
<SubtleHover hover={hover} /> <SubtleHover hover={hover || !!isFocused} />
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}> <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
<View style={{width: 42}}> <View style={{width: 42}}>
{isThreadChild && ( {isThreadChild && (
+53 -42
View File
@@ -19,6 +19,7 @@ import {
useNavigationDeduped, useNavigationDeduped,
} from '#/lib/hooks/useNavigationDeduped' } from '#/lib/hooks/useNavigationDeduped'
import {useOpenLink} from '#/lib/hooks/useOpenLink' import {useOpenLink} from '#/lib/hooks/useOpenLink'
import * as KeyboardActivation from '#/lib/hotkeys/KeyboardActivation'
import {getTabState, TabState} from '#/lib/routes/helpers' import {getTabState, TabState} from '#/lib/routes/helpers'
import { import {
convertBskyAppUrlIfNeeded, convertBskyAppUrlIfNeeded,
@@ -55,6 +56,7 @@ interface Props extends React.ComponentProps<typeof TouchableOpacity> {
onPointerEnter?: () => void onPointerEnter?: () => void
onPointerLeave?: () => void onPointerLeave?: () => void
onBeforePress?: () => void onBeforePress?: () => void
ref?: React.Ref<View>
} }
/** /**
@@ -75,6 +77,7 @@ export const Link = memo(function Link({
accessibilityActions, accessibilityActions,
onAccessibilityAction, onAccessibilityAction,
dataSet: dataSetProp, dataSet: dataSetProp,
ref,
...props ...props
}: Props) { }: Props) {
const t = useTheme() const t = useTheme()
@@ -106,6 +109,8 @@ export const Link = memo(function Link({
groupChatJoinIntent, groupChatJoinIntent,
], ],
) )
const activate = useCallback(() => onPress(), [onPress])
KeyboardActivation.useRegistration(activate)
const accessibilityActionsWithActivate = [ const accessibilityActionsWithActivate = [
...(accessibilityActions || []), ...(accessibilityActions || []),
@@ -118,51 +123,57 @@ export const Link = memo(function Link({
if (noFeedback) { if (noFeedback) {
return ( return (
<WebAuxClickWrapper> <KeyboardActivation.Isolation>
<Pressable <WebAuxClickWrapper>
testID={testID} <Pressable
onPress={onPress} ref={ref}
accessible={accessible} testID={testID}
accessibilityRole="link" onPress={onPress}
accessibilityActions={accessibilityActionsWithActivate} accessible={accessible}
onAccessibilityAction={e => { accessibilityRole="link"
if (e.nativeEvent.actionName === 'activate') { accessibilityActions={accessibilityActionsWithActivate}
onPress() onAccessibilityAction={e => {
} else { if (e.nativeEvent.actionName === 'activate') {
onAccessibilityAction?.(e) onPress()
} } else {
}} onAccessibilityAction?.(e)
// @ts-ignore web only -sfn }
dataSet={dataSet} }}
{...props} // @ts-ignore web only -sfn
android_ripple={{ dataSet={dataSet}
color: t.atoms.bg_contrast_25.backgroundColor, {...props}
}}> android_ripple={{
{/* @ts-ignore web only -prf */} color: t.atoms.bg_contrast_25.backgroundColor,
<View style={style} href={anchorHref}> }}>
{children ? children : <Text>{title || 'link'}</Text>} {/* @ts-ignore web only -prf */}
</View> <View style={style} href={anchorHref}>
</Pressable> {children ? children : <Text>{title || 'link'}</Text>}
</WebAuxClickWrapper> </View>
</Pressable>
</WebAuxClickWrapper>
</KeyboardActivation.Isolation>
) )
} }
const Com = props.hoverStyle ? PressableWithHover : Pressable const Com = props.hoverStyle ? PressableWithHover : Pressable
return ( return (
<Com <KeyboardActivation.Isolation>
testID={testID} <Com
style={style} ref={ref}
onPress={onPress} testID={testID}
accessible={accessible} style={style}
accessibilityRole="link" onPress={onPress}
accessibilityLabel={props.accessibilityLabel ?? title} accessible={accessible}
accessibilityHint={props.accessibilityHint} accessibilityRole="link"
// @ts-ignore web only -prf accessibilityLabel={props.accessibilityLabel ?? title}
href={anchorHref} accessibilityHint={props.accessibilityHint}
dataSet={dataSet} // @ts-ignore web only -prf
{...props}> href={anchorHref}
{children ? children : <Text>{title || 'link'}</Text>} dataSet={dataSet}
</Com> {...props}>
{children ? children : <Text>{title || 'link'}</Text>}
</Com>
</KeyboardActivation.Isolation>
) )
}) })
@@ -379,7 +390,7 @@ function onPressInner(
navigation: DebouncedNavigationProp, navigation: DebouncedNavigationProp,
href: string, href: string,
navigationAction: 'push' | 'replace' | 'navigate' = 'push', navigationAction: 'push' | 'replace' | 'navigate' = 'push',
openLink: (href: string) => void, openLink: (href: string) => void | Promise<void>,
groupChatJoinIntent: (code: string, uri?: string) => void, groupChatJoinIntent: (code: string, uri?: string) => void,
e?: Event, e?: Event,
) { ) {
@@ -419,7 +430,7 @@ function onPressInner(
href.startsWith('mailto') || href.startsWith('mailto') ||
EXEMPT_PATHS.some(path => href.startsWith(path)) EXEMPT_PATHS.some(path => href.startsWith(path))
) { ) {
openLink(href) void openLink(href)
} else { } else {
const [routeName, params] = router.matchPath(href) const [routeName, params] = router.matchPath(href)
if (navigationAction === 'push') { if (navigationAction === 'push') {
+60 -23
View File
@@ -1,14 +1,15 @@
import {useCallback, useMemo, useRef, useState} from 'react' import {useCallback, useMemo, useRef, useState} from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native' import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {type AppBskyFeedDefs} from '@atproto/api' import {type AppBskyFeedDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react' import {useIsFocused} from '@react-navigation/native'
import {Trans} from '@lingui/react/macro'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {useFeedKeyboardNav} from '#/lib/hotkeys'
import * as KeyboardActivation from '#/lib/hotkeys/KeyboardActivation'
import { import {
type CommonNavigatorParams, type CommonNavigatorParams,
type NativeStackScreenProps, type NativeStackScreenProps,
@@ -44,6 +45,7 @@ import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import * as ListCard from '#/components/ListCard' import * as ListCard from '#/components/ListCard'
import {SubtleHover} from '#/components/SubtleHover'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
@@ -124,7 +126,7 @@ export function FeedsScreen(_props: Props) {
isFetchingNextPage: isPopularFeedsFetchingNextPage, isFetchingNextPage: isPopularFeedsFetchingNextPage,
hasNextPage: hasNextPopularFeedsPage, hasNextPage: hasNextPopularFeedsPage,
} = useGetPopularFeedsQuery() } = useGetPopularFeedsQuery()
const {_} = useLingui() const {t: l} = useLingui()
const { const {
data: searchResults, data: searchResults,
mutate: search, mutate: search,
@@ -134,6 +136,7 @@ export function FeedsScreen(_props: Props) {
} = useSearchPopularFeedsMutation() } = useSearchPopularFeedsMutation()
const {hasSession} = useSession() const {hasSession} = useSession()
const listRef = useRef<ListMethods>(null) const listRef = useRef<ListMethods>(null)
const isScreenFocused = useIsFocused()
/** /**
* A search query is present. We may not have search results yet. * A search query is present. We may not have search results yet.
@@ -152,7 +155,7 @@ export function FeedsScreen(_props: Props) {
if (text.length > 1) { if (text.length > 1) {
debouncedSearch(text) debouncedSearch(text)
} else { } else {
refetchPopularFeeds() void refetchPopularFeeds()
resetSearch() resetSearch()
} }
}, },
@@ -160,7 +163,7 @@ export function FeedsScreen(_props: Props) {
) )
const onPressCancelSearch = useCallback(() => { const onPressCancelSearch = useCallback(() => {
setQuery('') setQuery('')
refetchPopularFeeds() void refetchPopularFeeds()
resetSearch() resetSearch()
}, [refetchPopularFeeds, setQuery, resetSearch]) }, [refetchPopularFeeds, setQuery, resetSearch])
const onSubmitQuery = useCallback(() => { const onSubmitQuery = useCallback(() => {
@@ -182,7 +185,7 @@ export function FeedsScreen(_props: Props) {
popularFeedsError popularFeedsError
) )
return return
fetchNextPopularFeedsPage() void fetchNextPopularFeedsPage()
}, [ }, [
isPopularFeedsFetching, isPopularFeedsFetching,
isUserSearching, isUserSearching,
@@ -371,6 +374,26 @@ export function FeedsScreen(_props: Props) {
isUserSearching, isUserSearching,
]) ])
const focusableIndices = useMemo(() => {
const indices: number[] = []
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (
(item.type === 'savedFeed' && item.savedFeed.type !== 'timeline') ||
item.type === 'popularFeed'
) {
indices.push(i)
}
}
return indices
}, [items])
const {
focusedIndex: focusedFeedItemIndex,
itemRef: feedItemRef,
itemActivation: feedItemActivation,
} = useFeedKeyboardNav({focusableIndices, active: isScreenFocused})
const searchBarIndex = items.findIndex( const searchBarIndex = items.findIndex(
item => item.type === 'popularFeedsHeader', item => item.type === 'popularFeedsHeader',
) )
@@ -401,7 +424,7 @@ export function FeedsScreen(_props: Props) {
) )
const renderItem = useCallback( const renderItem = useCallback(
({item}: {item: FlatlistSlice}) => { ({item, index}: {item: FlatlistSlice; index: number}) => {
if (item.type === 'error') { if (item.type === 'error') {
return <ErrorMessage message={item.error} /> return <ErrorMessage message={item.error} />
} else if (item.type === 'popularFeedsLoadingMore') { } else if (item.type === 'popularFeedsLoadingMore') {
@@ -427,14 +450,21 @@ export function FeedsScreen(_props: Props) {
} else if (item.type === 'savedFeedPlaceholder') { } else if (item.type === 'savedFeedPlaceholder') {
return <SavedFeedPlaceholder /> return <SavedFeedPlaceholder />
} else if (item.type === 'savedFeed') { } else if (item.type === 'savedFeed') {
return <FeedOrFollowing savedFeed={item.savedFeed} /> return (
<View ref={feedItemRef(index)} style={[a.relative]}>
<SubtleHover hover={index === focusedFeedItemIndex} />
<KeyboardActivation.Boundary register={feedItemActivation(index)}>
<FeedOrFollowing savedFeed={item.savedFeed} />
</KeyboardActivation.Boundary>
</View>
)
} else if (item.type === 'popularFeedsHeader') { } else if (item.type === 'popularFeedsHeader') {
return ( return (
<> <>
<FeedsAboutHeader /> <FeedsAboutHeader />
<View style={{paddingHorizontal: 12, paddingBottom: 4}}> <View style={{paddingHorizontal: 12, paddingBottom: 4}}>
<SearchInput <SearchInput
placeholder={_(msg`Search feeds`)} placeholder={l`Search feeds`}
value={query} value={query}
onChangeText={onChangeQuery} onChangeText={onChangeQuery}
onClearText={onPressCancelSearch} onClearText={onPressCancelSearch}
@@ -449,8 +479,13 @@ export function FeedsScreen(_props: Props) {
return <FeedFeedLoadingPlaceholder /> return <FeedFeedLoadingPlaceholder />
} else if (item.type === 'popularFeed') { } else if (item.type === 'popularFeed') {
return ( return (
<View style={[a.px_lg, a.pt_lg, a.gap_lg]}> <View
<FeedCard.Default view={item.feed} /> style={[a.px_lg, a.pt_lg, a.gap_lg, a.relative]}
ref={feedItemRef(index)}>
<SubtleHover hover={index === focusedFeedItemIndex} />
<KeyboardActivation.Boundary register={feedItemActivation(index)}>
<FeedCard.Default view={item.feed} />
</KeyboardActivation.Boundary>
<Divider /> <Divider />
</View> </View>
) )
@@ -483,7 +518,7 @@ export function FeedsScreen(_props: Props) {
return null return null
}, },
[ [
_, l,
pal.border, pal.border,
pal.textLight, pal.textLight,
query, query,
@@ -491,6 +526,9 @@ export function FeedsScreen(_props: Props) {
onPressCancelSearch, onPressCancelSearch,
onSubmitQuery, onSubmitQuery,
onChangeSearchFocus, onChangeSearchFocus,
feedItemRef,
feedItemActivation,
focusedFeedItemIndex,
], ],
) )
@@ -508,7 +546,7 @@ export function FeedsScreen(_props: Props) {
<Link <Link
testID="editFeedsBtn" testID="editFeedsBtn"
to="/settings/saved-feeds" to="/settings/saved-feeds"
label={_(msg`Edit My Feeds`)} label={l`Edit my feeds`}
size="small" size="small"
variant="ghost" variant="ghost"
color="secondary" color="secondary"
@@ -522,11 +560,11 @@ export function FeedsScreen(_props: Props) {
<List <List
ref={listRef} ref={listRef}
data={items} data={items}
keyExtractor={item => item.key} keyExtractor={(item: FlatlistSlice) => item.key}
contentContainerStyle={styles.contentContainer} contentContainerStyle={styles.contentContainer}
renderItem={renderItem} renderItem={renderItem}
refreshing={isPTR} refreshing={isPTR}
onRefresh={isUserSearching ? undefined : onPullToRefresh} onRefresh={isUserSearching ? undefined : () => void onPullToRefresh()}
initialNumToRender={10} initialNumToRender={10}
onEndReached={onEndReached} onEndReached={onEndReached}
desktopFixedHeight desktopFixedHeight
@@ -535,14 +573,13 @@ export function FeedsScreen(_props: Props) {
sideBorders={false} sideBorders={false}
/> />
</Layout.Center> </Layout.Center>
{hasSession && ( {hasSession && (
<FAB <FAB
testID="composeFAB" testID="composeFAB"
onPress={onPressCompose} onPress={onPressCompose}
icon={<EditBigIcon size="lg" fill={t.palette.white} />} icon={<EditBigIcon size="lg" fill={t.palette.white} />}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`New post`)} accessibilityLabel={l`New post`}
accessibilityHint="" accessibilityHint=""
/> />
)} )}
@@ -560,7 +597,7 @@ function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) {
function FollowingFeed() { function FollowingFeed() {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
return ( return (
<View <View
style={[ style={[
@@ -593,7 +630,7 @@ function FollowingFeed() {
/> />
</View> </View>
<FeedCard.TitleAndByline <FeedCard.TitleAndByline
title={_(msg({message: 'Following', context: 'feed-name'}))} title={l({message: 'Following', context: 'feed-name'})}
/> />
</FeedCard.Header> </FeedCard.Header>
</View> </View>
@@ -694,10 +731,10 @@ function FeedsSavedHeader() {
<IconCircle icon={ListSparkle_Stroke2_Corner0_Rounded} size="lg" /> <IconCircle icon={ListSparkle_Stroke2_Corner0_Rounded} size="lg" />
<View style={[a.flex_1, a.gap_xs]}> <View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}> <Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
<Trans>My Feeds</Trans> <Trans>My feeds</Trans>
</Text> </Text>
<Text style={[t.atoms.text_contrast_high]}> <Text style={[t.atoms.text_contrast_high]}>
<Trans>All the feeds you've saved, right in one place.</Trans> <Trans>All the feeds youve saved, right in one place.</Trans>
</Text> </Text>
</View> </View>
</View> </View>
@@ -720,7 +757,7 @@ function FeedsAboutHeader() {
/> />
<View style={[a.flex_1, a.gap_sm]}> <View style={[a.flex_1, a.gap_sm]}>
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}> <Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
<Trans>Discover New Feeds</Trans> <Trans>Discover new feeds</Trans>
</Text> </Text>
<Text style={[t.atoms.text_contrast_high]}> <Text style={[t.atoms.text_contrast_high]}>
<Trans> <Trans>