Enable scrolling via J and K keys
This commit is contained in:
+21
-12
@@ -4,6 +4,7 @@ import {
|
||||
Linking,
|
||||
type NativeSyntheticEvent,
|
||||
type TargetedEvent,
|
||||
type View,
|
||||
} from 'react-native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
@@ -16,6 +17,7 @@ import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
|
||||
import {useGroupChatJoinIntent} from '#/lib/hooks/useIntentHandler'
|
||||
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import * as KeyboardActivation from '#/lib/hotkeys/KeyboardActivation'
|
||||
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {
|
||||
@@ -144,8 +146,8 @@ export function useLink({
|
||||
const groupChatJoinIntent = useGroupChatJoinIntent()
|
||||
|
||||
const onPress = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
const exitEarlyIfFalse = outerOnPress?.(e)
|
||||
(e?: GestureResponderEvent) => {
|
||||
const exitEarlyIfFalse = outerOnPress?.(e as GestureResponderEvent)
|
||||
|
||||
if (exitEarlyIfFalse === false) return
|
||||
|
||||
@@ -157,7 +159,7 @@ export function useLink({
|
||||
)
|
||||
|
||||
if (IS_WEB) {
|
||||
e.preventDefault()
|
||||
e?.preventDefault()
|
||||
}
|
||||
|
||||
const chatInviteCode = getChatInviteCodeFromUrl(href)
|
||||
@@ -175,7 +177,7 @@ export function useLink({
|
||||
if (isExternal) {
|
||||
void openLink(href, overridePresentation, shouldProxy)
|
||||
} else {
|
||||
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
|
||||
const shouldOpenInNewTab = e ? shouldClickOpenNewTab(e) : false
|
||||
|
||||
if (isBskyDownloadUrl(href)) {
|
||||
void shareUrl(BSKY_DOWNLOAD_URL)
|
||||
@@ -300,6 +302,7 @@ export function useLink({
|
||||
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
|
||||
Omit<ButtonProps, 'onPress' | 'disabled'> & {
|
||||
overridePresentation?: boolean
|
||||
ref?: React.Ref<View>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,6 +323,7 @@ export function Link({
|
||||
shouldProxy,
|
||||
overridePresentation,
|
||||
peek,
|
||||
ref,
|
||||
...rest
|
||||
}: LinkProps) {
|
||||
const {href, isExternal, onPress, onLongPress, openExternally} = useLink({
|
||||
@@ -331,12 +335,15 @@ export function Link({
|
||||
shouldProxy: shouldProxy,
|
||||
overridePresentation,
|
||||
})
|
||||
const activate = useCallback(() => onPress(), [onPress])
|
||||
KeyboardActivation.useRegistration(activate)
|
||||
|
||||
// Peek is iOS-only and only makes sense for external web links.
|
||||
const peekEnabled = Boolean(peek && IS_IOS && isExternal)
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
ref={ref}
|
||||
{...rest}
|
||||
style={[a.justify_start, rest.style]}
|
||||
role="link"
|
||||
@@ -368,17 +375,19 @@ export function Link({
|
||||
// the peek animation clips to the same corners as the rendered card.
|
||||
const borderRadius = flatten(rest.style)?.borderRadius
|
||||
return (
|
||||
<LinkPeek
|
||||
href={href}
|
||||
onPreviewPress={openExternally}
|
||||
shouldProxy={shouldProxy}
|
||||
borderRadius={typeof borderRadius === 'number' ? borderRadius : 0}>
|
||||
{button}
|
||||
</LinkPeek>
|
||||
<KeyboardActivation.Isolation>
|
||||
<LinkPeek
|
||||
href={href}
|
||||
onPreviewPress={openExternally}
|
||||
shouldProxy={shouldProxy}
|
||||
borderRadius={typeof borderRadius === 'number' ? borderRadius : 0}>
|
||||
{button}
|
||||
</LinkPeek>
|
||||
</KeyboardActivation.Isolation>
|
||||
)
|
||||
}
|
||||
|
||||
return button
|
||||
return <KeyboardActivation.Isolation>{button}</KeyboardActivation.Isolation>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
import {useMemo} from 'react'
|
||||
|
||||
import {type FeedKeyboardNavOptions, type FeedKeyboardNavResult} from './types'
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
return children
|
||||
}
|
||||
|
||||
const noop = (_scope: string) => {}
|
||||
const noop = () => {}
|
||||
const noopScope = (_scope: string) => {}
|
||||
|
||||
export function useHotkeysContext() {
|
||||
return useMemo(
|
||||
() => ({
|
||||
enableScope: noop,
|
||||
disableScope: noop,
|
||||
enableScope: noopScope,
|
||||
disableScope: noopScope,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
export function useFeedKeyboardNav(
|
||||
_options: FeedKeyboardNavOptions,
|
||||
): FeedKeyboardNavResult {
|
||||
return {
|
||||
focusedIndex: -1,
|
||||
setFocusedIndex: noop,
|
||||
itemRef: _index => _el => {},
|
||||
itemActivation: _index => _activate => noop,
|
||||
}
|
||||
}
|
||||
|
||||
+231
-2
@@ -1,3 +1,5 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {type View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {
|
||||
HotkeysProvider,
|
||||
@@ -6,12 +8,39 @@ import {
|
||||
} from 'react-hotkeys-hook'
|
||||
|
||||
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 {type FeedKeyboardNavOptions, type FeedKeyboardNavResult} from './types'
|
||||
|
||||
const FEED_SCROLL_DEBOUNCE_MS = 250
|
||||
const activeScopeCounts = new Map<string, number>()
|
||||
|
||||
enum Hotkeys {
|
||||
OPEN_COMPOSER = 'n',
|
||||
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>) {
|
||||
@@ -24,6 +53,30 @@ export function Provider({children}: React.PropsWithChildren<unknown>) {
|
||||
|
||||
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>) {
|
||||
useKeyboardShortcuts()
|
||||
return children
|
||||
@@ -38,7 +91,6 @@ function useKeyboardShortcuts() {
|
||||
if (requiresSession && !hasSession) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -52,6 +104,7 @@ function useKeyboardShortcuts() {
|
||||
callback()
|
||||
}
|
||||
|
||||
// Composer
|
||||
useHotkeys(
|
||||
Hotkeys.OPEN_COMPOSER,
|
||||
() =>
|
||||
@@ -67,10 +120,186 @@ function useKeyboardShortcuts() {
|
||||
[openComposer],
|
||||
)
|
||||
|
||||
// Search
|
||||
useHotkeys(Hotkeys.FOCUS_SEARCH, () => handleKey(emitFocusSearch), {
|
||||
scopes: ['global'],
|
||||
preventDefault: true,
|
||||
description: l`Focus the search field`,
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]}>
|
||||
|
||||
@@ -53,3 +53,27 @@ export function listenFocusSearch(fn: () => void): UnlistenFn {
|
||||
emitter.on('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)
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ export function FeedPage({
|
||||
<PostFeed
|
||||
testID={testID ? `${testID}-feed` : undefined}
|
||||
enabled={isPageFocused || shouldPrefetch}
|
||||
keyboardNavActive={isPageFocused}
|
||||
feed={feed}
|
||||
feedParams={feedParams}
|
||||
pollInterval={POLL_FREQ}
|
||||
|
||||
@@ -47,12 +47,14 @@ export function Post({
|
||||
hideTopBorder,
|
||||
style,
|
||||
onBeforePress,
|
||||
ref,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
showReplyLine?: boolean
|
||||
hideTopBorder?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
onBeforePress?: () => void
|
||||
ref?: React.Ref<View>
|
||||
}) {
|
||||
const moderationOpts = useModerationOpts()
|
||||
const record = useMemo<AppBskyFeedPost.Record | undefined>(
|
||||
@@ -92,6 +94,7 @@ export function Post({
|
||||
hideTopBorder={hideTopBorder}
|
||||
style={style}
|
||||
onBeforePress={onBeforePress}
|
||||
ref={ref}
|
||||
/>
|
||||
</ReportDialogMetadataContext.Provider>
|
||||
)
|
||||
@@ -108,6 +111,7 @@ function PostInner({
|
||||
hideTopBorder,
|
||||
style,
|
||||
onBeforePress: outerOnBeforePress,
|
||||
ref,
|
||||
}: {
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
record: AppBskyFeedPost.Record
|
||||
@@ -117,6 +121,7 @@ function PostInner({
|
||||
hideTopBorder?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
onBeforePress?: () => void
|
||||
ref?: React.Ref<View>
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const t = useTheme()
|
||||
@@ -161,6 +166,7 @@ function PostInner({
|
||||
return (
|
||||
<GalleryBleed>
|
||||
<Link
|
||||
ref={ref}
|
||||
href={itemHref}
|
||||
style={[
|
||||
styles.outer,
|
||||
|
||||
@@ -28,12 +28,15 @@ import {
|
||||
type RichText as RichTextType,
|
||||
} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
|
||||
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
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 {logger} from '#/logger'
|
||||
import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow'
|
||||
@@ -236,6 +239,7 @@ let PostFeed = ({
|
||||
initialNumToRender: initialNumToRenderOverride,
|
||||
isVideoFeed = false,
|
||||
ref,
|
||||
keyboardNavActive = true,
|
||||
}: {
|
||||
feed: FeedDescriptor
|
||||
description?: RichTextType
|
||||
@@ -259,6 +263,7 @@ let PostFeed = ({
|
||||
savedFeedConfig?: AppBskyActorDefs.SavedFeed
|
||||
initialNumToRender?: number
|
||||
isVideoFeed?: boolean
|
||||
keyboardNavActive?: boolean
|
||||
lastFetchDate?: () => number
|
||||
ref?: React.Ref<PostFeedRef>
|
||||
}): React.ReactNode => {
|
||||
@@ -276,7 +281,6 @@ let PostFeed = ({
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {rightNavVisible} = useLayoutBreakpoints()
|
||||
const areVideoFeedsEnabled = IS_NATIVE
|
||||
|
||||
const trendingIndices = ax.features.getValue(
|
||||
ax.features.TrendingDiscoverValues,
|
||||
{
|
||||
@@ -285,6 +289,7 @@ let PostFeed = ({
|
||||
videos: 30,
|
||||
},
|
||||
)
|
||||
const isScreenFocused = useIsFocused()
|
||||
|
||||
const [hasPressedShowLessUris, setHasPressedShowLessUris] = useState(
|
||||
() => new Set<string>(),
|
||||
@@ -749,6 +754,33 @@ let PostFeed = ({
|
||||
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
|
||||
// =
|
||||
//
|
||||
@@ -880,27 +912,36 @@ let PostFeed = ({
|
||||
const indexInSlice = row.indexInSlice
|
||||
const item = slice.items[indexInSlice]
|
||||
return (
|
||||
<PostFeedItem
|
||||
post={item.post}
|
||||
record={item.record}
|
||||
reason={indexInSlice === 0 ? slice.reason : undefined}
|
||||
feedContext={slice.feedContext}
|
||||
reqId={slice.reqId}
|
||||
moderation={item.moderation}
|
||||
parentAuthor={item.parentAuthor}
|
||||
showReplyTo={row.showReplyTo}
|
||||
isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
|
||||
isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
|
||||
isThreadLastChild={
|
||||
isThreadChildAt(slice.items, indexInSlice) &&
|
||||
slice.items.length === indexInSlice + 1
|
||||
}
|
||||
isParentBlocked={item.isParentBlocked}
|
||||
isParentNotFound={item.isParentNotFound}
|
||||
hideTopBorder={rowIndex === 0 && indexInSlice === 0}
|
||||
rootPost={slice.items[0].post}
|
||||
onShowLess={onPressShowLess}
|
||||
/>
|
||||
<KeyboardActivation.Boundary register={feedItemActivation(rowIndex)}>
|
||||
<PostFeedItem
|
||||
post={item.post}
|
||||
record={item.record}
|
||||
reason={indexInSlice === 0 ? slice.reason : undefined}
|
||||
feedContext={slice.feedContext}
|
||||
reqId={slice.reqId}
|
||||
moderation={item.moderation}
|
||||
parentAuthor={item.parentAuthor}
|
||||
showReplyTo={row.showReplyTo}
|
||||
isThreadParent={isThreadParentAt(slice.items, indexInSlice)}
|
||||
isThreadChild={isThreadChildAt(slice.items, indexInSlice)}
|
||||
isThreadLastChild={
|
||||
isThreadChildAt(slice.items, indexInSlice) &&
|
||||
slice.items.length === indexInSlice + 1
|
||||
}
|
||||
isParentBlocked={item.isParentBlocked}
|
||||
isParentNotFound={item.isParentNotFound}
|
||||
hideTopBorder={rowIndex === 0 && indexInSlice === 0}
|
||||
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') {
|
||||
return <ViewFullThread uri={row.uri} />
|
||||
@@ -954,6 +995,9 @@ let PostFeed = ({
|
||||
feedCacheKey,
|
||||
onPressShowLess,
|
||||
t,
|
||||
feedItemRef,
|
||||
feedItemActivation,
|
||||
focusedFeedItemIndex,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -94,10 +94,16 @@ export function PostFeedItem({
|
||||
isParentNotFound,
|
||||
rootPost,
|
||||
onShowLess,
|
||||
feedItemIndex,
|
||||
feedItemRef,
|
||||
isFocused,
|
||||
}: FeedItemProps & {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
rootPost: AppBskyFeedDefs.PostView
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
feedItemIndex?: number
|
||||
feedItemRef?: React.Ref<View>
|
||||
isFocused?: boolean
|
||||
}): React.ReactNode {
|
||||
const postShadowed = usePostShadow(post)
|
||||
const richText = useMemo(
|
||||
@@ -132,6 +138,9 @@ export function PostFeedItem({
|
||||
isParentNotFound={isParentNotFound}
|
||||
rootPost={rootPost}
|
||||
onShowLess={onShowLess}
|
||||
feedItemIndex={feedItemIndex}
|
||||
feedItemRef={feedItemRef}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
</ReportDialogMetadataContext.Provider>
|
||||
)
|
||||
@@ -157,11 +166,17 @@ let FeedItemInner = ({
|
||||
isParentNotFound,
|
||||
rootPost,
|
||||
onShowLess,
|
||||
feedItemIndex,
|
||||
feedItemRef,
|
||||
isFocused,
|
||||
}: FeedItemProps & {
|
||||
richText: RichTextAPI
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
rootPost: AppBskyFeedDefs.PostView
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
feedItemIndex?: number
|
||||
feedItemRef?: React.Ref<View>
|
||||
isFocused?: boolean
|
||||
}): React.ReactNode => {
|
||||
const ax = useAnalytics()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -326,20 +341,21 @@ let FeedItemInner = ({
|
||||
return (
|
||||
<GalleryBleed>
|
||||
<Link
|
||||
ref={feedItemRef}
|
||||
testID={`feedItem-by-${post.author.handle}`}
|
||||
style={outerStyles}
|
||||
href={href}
|
||||
noFeedback
|
||||
accessible={false}
|
||||
onBeforePress={onBeforePress}
|
||||
dataSet={{feedContext}}
|
||||
dataSet={{feedContext, feedItemIndex: feedItemIndex?.toString()}}
|
||||
onPointerEnter={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHover(false)
|
||||
}}>
|
||||
<SubtleHover hover={hover} />
|
||||
<SubtleHover hover={hover || !!isFocused} />
|
||||
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
|
||||
<View style={{width: 42}}>
|
||||
{isThreadChild && (
|
||||
|
||||
+53
-42
@@ -19,6 +19,7 @@ import {
|
||||
useNavigationDeduped,
|
||||
} from '#/lib/hooks/useNavigationDeduped'
|
||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||
import * as KeyboardActivation from '#/lib/hotkeys/KeyboardActivation'
|
||||
import {getTabState, TabState} from '#/lib/routes/helpers'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
@@ -55,6 +56,7 @@ interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
onPointerEnter?: () => void
|
||||
onPointerLeave?: () => void
|
||||
onBeforePress?: () => void
|
||||
ref?: React.Ref<View>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +77,7 @@ export const Link = memo(function Link({
|
||||
accessibilityActions,
|
||||
onAccessibilityAction,
|
||||
dataSet: dataSetProp,
|
||||
ref,
|
||||
...props
|
||||
}: Props) {
|
||||
const t = useTheme()
|
||||
@@ -106,6 +109,8 @@ export const Link = memo(function Link({
|
||||
groupChatJoinIntent,
|
||||
],
|
||||
)
|
||||
const activate = useCallback(() => onPress(), [onPress])
|
||||
KeyboardActivation.useRegistration(activate)
|
||||
|
||||
const accessibilityActionsWithActivate = [
|
||||
...(accessibilityActions || []),
|
||||
@@ -118,51 +123,57 @@ export const Link = memo(function Link({
|
||||
|
||||
if (noFeedback) {
|
||||
return (
|
||||
<WebAuxClickWrapper>
|
||||
<Pressable
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
accessible={accessible}
|
||||
accessibilityRole="link"
|
||||
accessibilityActions={accessibilityActionsWithActivate}
|
||||
onAccessibilityAction={e => {
|
||||
if (e.nativeEvent.actionName === 'activate') {
|
||||
onPress()
|
||||
} else {
|
||||
onAccessibilityAction?.(e)
|
||||
}
|
||||
}}
|
||||
// @ts-ignore web only -sfn
|
||||
dataSet={dataSet}
|
||||
{...props}
|
||||
android_ripple={{
|
||||
color: t.atoms.bg_contrast_25.backgroundColor,
|
||||
}}>
|
||||
{/* @ts-ignore web only -prf */}
|
||||
<View style={style} href={anchorHref}>
|
||||
{children ? children : <Text>{title || 'link'}</Text>}
|
||||
</View>
|
||||
</Pressable>
|
||||
</WebAuxClickWrapper>
|
||||
<KeyboardActivation.Isolation>
|
||||
<WebAuxClickWrapper>
|
||||
<Pressable
|
||||
ref={ref}
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
accessible={accessible}
|
||||
accessibilityRole="link"
|
||||
accessibilityActions={accessibilityActionsWithActivate}
|
||||
onAccessibilityAction={e => {
|
||||
if (e.nativeEvent.actionName === 'activate') {
|
||||
onPress()
|
||||
} else {
|
||||
onAccessibilityAction?.(e)
|
||||
}
|
||||
}}
|
||||
// @ts-ignore web only -sfn
|
||||
dataSet={dataSet}
|
||||
{...props}
|
||||
android_ripple={{
|
||||
color: t.atoms.bg_contrast_25.backgroundColor,
|
||||
}}>
|
||||
{/* @ts-ignore web only -prf */}
|
||||
<View style={style} href={anchorHref}>
|
||||
{children ? children : <Text>{title || 'link'}</Text>}
|
||||
</View>
|
||||
</Pressable>
|
||||
</WebAuxClickWrapper>
|
||||
</KeyboardActivation.Isolation>
|
||||
)
|
||||
}
|
||||
|
||||
const Com = props.hoverStyle ? PressableWithHover : Pressable
|
||||
return (
|
||||
<Com
|
||||
testID={testID}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
accessible={accessible}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={props.accessibilityLabel ?? title}
|
||||
accessibilityHint={props.accessibilityHint}
|
||||
// @ts-ignore web only -prf
|
||||
href={anchorHref}
|
||||
dataSet={dataSet}
|
||||
{...props}>
|
||||
{children ? children : <Text>{title || 'link'}</Text>}
|
||||
</Com>
|
||||
<KeyboardActivation.Isolation>
|
||||
<Com
|
||||
ref={ref}
|
||||
testID={testID}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
accessible={accessible}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={props.accessibilityLabel ?? title}
|
||||
accessibilityHint={props.accessibilityHint}
|
||||
// @ts-ignore web only -prf
|
||||
href={anchorHref}
|
||||
dataSet={dataSet}
|
||||
{...props}>
|
||||
{children ? children : <Text>{title || 'link'}</Text>}
|
||||
</Com>
|
||||
</KeyboardActivation.Isolation>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -379,7 +390,7 @@ function onPressInner(
|
||||
navigation: DebouncedNavigationProp,
|
||||
href: string,
|
||||
navigationAction: 'push' | 'replace' | 'navigate' = 'push',
|
||||
openLink: (href: string) => void,
|
||||
openLink: (href: string) => void | Promise<void>,
|
||||
groupChatJoinIntent: (code: string, uri?: string) => void,
|
||||
e?: Event,
|
||||
) {
|
||||
@@ -419,7 +430,7 @@ function onPressInner(
|
||||
href.startsWith('mailto') ||
|
||||
EXEMPT_PATHS.some(path => href.startsWith(path))
|
||||
) {
|
||||
openLink(href)
|
||||
void openLink(href)
|
||||
} else {
|
||||
const [routeName, params] = router.matchPath(href)
|
||||
if (navigationAction === 'push') {
|
||||
|
||||
+60
-23
@@ -1,14 +1,15 @@
|
||||
import {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
import debounce from 'lodash.debounce'
|
||||
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {useFeedKeyboardNav} from '#/lib/hotkeys'
|
||||
import * as KeyboardActivation from '#/lib/hotkeys/KeyboardActivation'
|
||||
import {
|
||||
type CommonNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
@@ -44,6 +45,7 @@ import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Link} from '#/components/Link'
|
||||
import * as ListCard from '#/components/ListCard'
|
||||
import {SubtleHover} from '#/components/SubtleHover'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'>
|
||||
@@ -124,7 +126,7 @@ export function FeedsScreen(_props: Props) {
|
||||
isFetchingNextPage: isPopularFeedsFetchingNextPage,
|
||||
hasNextPage: hasNextPopularFeedsPage,
|
||||
} = useGetPopularFeedsQuery()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {
|
||||
data: searchResults,
|
||||
mutate: search,
|
||||
@@ -134,6 +136,7 @@ export function FeedsScreen(_props: Props) {
|
||||
} = useSearchPopularFeedsMutation()
|
||||
const {hasSession} = useSession()
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const isScreenFocused = useIsFocused()
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
debouncedSearch(text)
|
||||
} else {
|
||||
refetchPopularFeeds()
|
||||
void refetchPopularFeeds()
|
||||
resetSearch()
|
||||
}
|
||||
},
|
||||
@@ -160,7 +163,7 @@ export function FeedsScreen(_props: Props) {
|
||||
)
|
||||
const onPressCancelSearch = useCallback(() => {
|
||||
setQuery('')
|
||||
refetchPopularFeeds()
|
||||
void refetchPopularFeeds()
|
||||
resetSearch()
|
||||
}, [refetchPopularFeeds, setQuery, resetSearch])
|
||||
const onSubmitQuery = useCallback(() => {
|
||||
@@ -182,7 +185,7 @@ export function FeedsScreen(_props: Props) {
|
||||
popularFeedsError
|
||||
)
|
||||
return
|
||||
fetchNextPopularFeedsPage()
|
||||
void fetchNextPopularFeedsPage()
|
||||
}, [
|
||||
isPopularFeedsFetching,
|
||||
isUserSearching,
|
||||
@@ -371,6 +374,26 @@ export function FeedsScreen(_props: Props) {
|
||||
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(
|
||||
item => item.type === 'popularFeedsHeader',
|
||||
)
|
||||
@@ -401,7 +424,7 @@ export function FeedsScreen(_props: Props) {
|
||||
)
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item}: {item: FlatlistSlice}) => {
|
||||
({item, index}: {item: FlatlistSlice; index: number}) => {
|
||||
if (item.type === 'error') {
|
||||
return <ErrorMessage message={item.error} />
|
||||
} else if (item.type === 'popularFeedsLoadingMore') {
|
||||
@@ -427,14 +450,21 @@ export function FeedsScreen(_props: Props) {
|
||||
} else if (item.type === 'savedFeedPlaceholder') {
|
||||
return <SavedFeedPlaceholder />
|
||||
} 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') {
|
||||
return (
|
||||
<>
|
||||
<FeedsAboutHeader />
|
||||
<View style={{paddingHorizontal: 12, paddingBottom: 4}}>
|
||||
<SearchInput
|
||||
placeholder={_(msg`Search feeds`)}
|
||||
placeholder={l`Search feeds`}
|
||||
value={query}
|
||||
onChangeText={onChangeQuery}
|
||||
onClearText={onPressCancelSearch}
|
||||
@@ -449,8 +479,13 @@ export function FeedsScreen(_props: Props) {
|
||||
return <FeedFeedLoadingPlaceholder />
|
||||
} else if (item.type === 'popularFeed') {
|
||||
return (
|
||||
<View style={[a.px_lg, a.pt_lg, a.gap_lg]}>
|
||||
<FeedCard.Default view={item.feed} />
|
||||
<View
|
||||
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 />
|
||||
</View>
|
||||
)
|
||||
@@ -483,7 +518,7 @@ export function FeedsScreen(_props: Props) {
|
||||
return null
|
||||
},
|
||||
[
|
||||
_,
|
||||
l,
|
||||
pal.border,
|
||||
pal.textLight,
|
||||
query,
|
||||
@@ -491,6 +526,9 @@ export function FeedsScreen(_props: Props) {
|
||||
onPressCancelSearch,
|
||||
onSubmitQuery,
|
||||
onChangeSearchFocus,
|
||||
feedItemRef,
|
||||
feedItemActivation,
|
||||
focusedFeedItemIndex,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -508,7 +546,7 @@ export function FeedsScreen(_props: Props) {
|
||||
<Link
|
||||
testID="editFeedsBtn"
|
||||
to="/settings/saved-feeds"
|
||||
label={_(msg`Edit My Feeds`)}
|
||||
label={l`Edit my feeds`}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
@@ -522,11 +560,11 @@ export function FeedsScreen(_props: Props) {
|
||||
<List
|
||||
ref={listRef}
|
||||
data={items}
|
||||
keyExtractor={item => item.key}
|
||||
keyExtractor={(item: FlatlistSlice) => item.key}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
renderItem={renderItem}
|
||||
refreshing={isPTR}
|
||||
onRefresh={isUserSearching ? undefined : onPullToRefresh}
|
||||
onRefresh={isUserSearching ? undefined : () => void onPullToRefresh()}
|
||||
initialNumToRender={10}
|
||||
onEndReached={onEndReached}
|
||||
desktopFixedHeight
|
||||
@@ -535,14 +573,13 @@ export function FeedsScreen(_props: Props) {
|
||||
sideBorders={false}
|
||||
/>
|
||||
</Layout.Center>
|
||||
|
||||
{hasSession && (
|
||||
<FAB
|
||||
testID="composeFAB"
|
||||
onPress={onPressCompose}
|
||||
icon={<EditBigIcon size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New post`)}
|
||||
accessibilityLabel={l`New post`}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
)}
|
||||
@@ -560,7 +597,7 @@ function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) {
|
||||
|
||||
function FollowingFeed() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -593,7 +630,7 @@ function FollowingFeed() {
|
||||
/>
|
||||
</View>
|
||||
<FeedCard.TitleAndByline
|
||||
title={_(msg({message: 'Following', context: 'feed-name'}))}
|
||||
title={l({message: 'Following', context: 'feed-name'})}
|
||||
/>
|
||||
</FeedCard.Header>
|
||||
</View>
|
||||
@@ -694,10 +731,10 @@ function FeedsSavedHeader() {
|
||||
<IconCircle icon={ListSparkle_Stroke2_Corner0_Rounded} size="lg" />
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}>
|
||||
<Trans>My Feeds</Trans>
|
||||
<Trans>My feeds</Trans>
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_high]}>
|
||||
<Trans>All the feeds you've saved, right in one place.</Trans>
|
||||
<Trans>All the feeds you’ve saved, right in one place.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -720,7 +757,7 @@ function FeedsAboutHeader() {
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_sm]}>
|
||||
<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 style={[t.atoms.text_contrast_high]}>
|
||||
<Trans>
|
||||
|
||||
Reference in New Issue
Block a user