Enable scrolling via J and K keys
This commit is contained in:
@@ -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
@@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 {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
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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]}>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 you’ve 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user