feat(mobile): enhance entry header with dynamic title and read history
- Add headerTitleAbsolute prop to NavigationHeader for flexible title positioning - Implement EntryReadHistory component to show recent readers - Improve header left group with back button and read history avatars - Optimize header title rendering and opacity animation - Update UserAvatar and EntryNormalItem styling Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
7d092cdd2d
commit
458cbf7a5f
|
|
@ -20,7 +20,6 @@ import { useColor } from "react-native-uikit-colors"
|
|||
import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line"
|
||||
|
||||
import { ThemedBlurView } from "../../common/ThemedBlurView"
|
||||
import { PortalHost } from "../../ui/portal"
|
||||
import { NavigationContext } from "../views/NavigationContext"
|
||||
import { SetNavigationHeaderHeightContext } from "../views/NavigationHeaderContext"
|
||||
|
||||
|
|
@ -29,6 +28,7 @@ export interface NavigationHeaderRawProps {
|
|||
canGoBack: boolean
|
||||
}>
|
||||
headerTitle?: FC<React.ComponentProps<typeof HeaderTitle>> | ReactNode
|
||||
headerTitleAbsolute?: boolean
|
||||
headerRight?: FC<{
|
||||
canGoBack: boolean
|
||||
}>
|
||||
|
|
@ -131,6 +131,7 @@ export const NavigationHeader = ({
|
|||
modal = false,
|
||||
hideableBottom,
|
||||
hideableBottomHeight,
|
||||
headerTitleAbsolute,
|
||||
...rest
|
||||
}: NavigationHeaderProps) => {
|
||||
const insets = useSafeAreaInsets()
|
||||
|
|
@ -256,36 +257,42 @@ export const NavigationHeader = ({
|
|||
}}
|
||||
pointerEvents={"box-none"}
|
||||
>
|
||||
<PortalHost>
|
||||
{/* Left */}
|
||||
<View
|
||||
className="min-w-6 flex-row items-center justify-start"
|
||||
pointerEvents={"box-none"}
|
||||
onLayout={useCallback((e: LayoutChangeEvent) => {
|
||||
setHeaderLeftWidth(e.nativeEvent.layout.width)
|
||||
}, [])}
|
||||
>
|
||||
<HeaderLeft canGoBack={canBack} />
|
||||
</View>
|
||||
{/* Center */}
|
||||
<Animated.View
|
||||
onLayout={useCallback((e: LayoutChangeEvent) => {
|
||||
setTitleWidth(e.nativeEvent.layout.width)
|
||||
}, [])}
|
||||
className="flex-1 items-center justify-center"
|
||||
pointerEvents={"box-none"}
|
||||
style={{
|
||||
marginHorizontal: titleMarginHorizontal,
|
||||
transform: [{ translateX: titleTransformX }],
|
||||
}}
|
||||
>
|
||||
{headerTitle}
|
||||
</Animated.View>
|
||||
{/* Right */}
|
||||
<View className="min-w-6 flex-row items-center justify-end" pointerEvents={"box-none"}>
|
||||
<RightButton canGoBack={canBack} />
|
||||
</View>
|
||||
</PortalHost>
|
||||
{/* Left */}
|
||||
<View
|
||||
className="min-w-6 flex-row items-center justify-start"
|
||||
pointerEvents={"box-none"}
|
||||
onLayout={useCallback((e: LayoutChangeEvent) => {
|
||||
setHeaderLeftWidth(e.nativeEvent.layout.width)
|
||||
}, [])}
|
||||
>
|
||||
<HeaderLeft canGoBack={canBack} />
|
||||
</View>
|
||||
{/* Center */}
|
||||
|
||||
<Animated.View
|
||||
onLayout={(e: LayoutChangeEvent) => {
|
||||
setTitleWidth(e.nativeEvent.layout.width)
|
||||
}}
|
||||
className="flex-1 items-center justify-center"
|
||||
pointerEvents={"box-none"}
|
||||
style={{
|
||||
marginHorizontal: titleMarginHorizontal,
|
||||
transform: [{ translateX: titleTransformX }],
|
||||
}}
|
||||
>
|
||||
{headerTitleAbsolute ? <View /> : headerTitle}
|
||||
</Animated.View>
|
||||
|
||||
{/* Right */}
|
||||
<View className="min-w-6 flex-row items-center justify-end" pointerEvents={"box-none"}>
|
||||
<RightButton canGoBack={canBack} />
|
||||
</View>
|
||||
<View
|
||||
className="absolute inset-0 flex-row items-center justify-center"
|
||||
pointerEvents={"box-none"}
|
||||
>
|
||||
{headerTitleAbsolute && headerTitle}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!!hideableBottom && (
|
||||
|
|
|
|||
|
|
@ -90,11 +90,13 @@ export const SafeNavigationScrollView: FC<SafeNavigationScrollViewProps> = ({
|
|||
export const NavigationBlurEffectHeader = ({
|
||||
headerHideableBottom,
|
||||
headerHideableBottomHeight,
|
||||
headerTitleAbsolute,
|
||||
...props
|
||||
}: NativeStackNavigationOptions & {
|
||||
blurThreshold?: number
|
||||
headerHideableBottomHeight?: number
|
||||
headerHideableBottom?: () => React.ReactNode
|
||||
headerTitleAbsolute?: boolean
|
||||
}) => {
|
||||
const label = useColor("label")
|
||||
|
||||
|
|
@ -134,6 +136,7 @@ export const NavigationBlurEffectHeader = ({
|
|||
headerLeft={options.headerLeft}
|
||||
hideableBottom={hideableBottom}
|
||||
hideableBottomHeight={headerHideableBottomHeight}
|
||||
headerTitleAbsolute={headerTitleAbsolute}
|
||||
// @ts-expect-error
|
||||
headerTitle={options.headerTitle}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ export const UserAvatar = ({ image, size = 24, name, className }: UserAvatarProp
|
|||
if (!image) {
|
||||
return (
|
||||
<View
|
||||
className="bg-secondary-system-background items-center justify-center rounded-full"
|
||||
className={cn(
|
||||
"bg-secondary-system-background items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<Text className="text-secondary-label text-xs">{name.slice(0, 2)}</Text>
|
||||
|
|
@ -24,7 +27,7 @@ export const UserAvatar = ({ image, size = 24, name, className }: UserAvatarProp
|
|||
return (
|
||||
<ProxiedImage
|
||||
source={{ uri: image }}
|
||||
className={cn("rounded", className)}
|
||||
className={cn("rounded-full", className)}
|
||||
style={{ width: size, height: size }}
|
||||
resizeMode="cover"
|
||||
proxy={{
|
||||
|
|
|
|||
|
|
@ -11,15 +11,15 @@ interface PortalHostProps {
|
|||
|
||||
export function PortalHost(props: PortalHostProps) {
|
||||
const id = React.useId()
|
||||
const nextKey = useRef(`${id}-0`)
|
||||
const nextKey = useRef(0)
|
||||
const queue = useRef<Operation[]>([])
|
||||
const manager = useRef<IPortalManager>()
|
||||
|
||||
const mount = useCallback((children: React.ReactNode, _key?: PortalKey) => {
|
||||
let key = _key
|
||||
if (!key) {
|
||||
key = nextKey.current
|
||||
nextKey.current = `${id}-${Number.parseInt(key) + 1}`
|
||||
nextKey.current++
|
||||
key = `${id}-${nextKey.current}`
|
||||
}
|
||||
|
||||
if (manager.current) {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ const HeaderRightActionsImpl = ({
|
|||
{!isHeaderTitleVisible && (
|
||||
<View style={{ width: extraActionContainerWidth }} pointerEvents="none" />
|
||||
)}
|
||||
|
||||
<Animated.View
|
||||
onLayout={(e) => {
|
||||
setExtraActionContainerWidth(e.nativeEvent.layout.width)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
import { useTypeScriptHappyCallback } from "@follow/hooks"
|
||||
import { useHeaderHeight } from "@react-navigation/elements"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { router } from "expo-router"
|
||||
import { useCallback, useContext, useEffect, useState } from "react"
|
||||
import { Text, View } from "react-native"
|
||||
import Animated, { useSharedValue, withTiming } from "react-native-reanimated"
|
||||
import { Text, TouchableOpacity, useWindowDimensions, View } from "react-native"
|
||||
import type { SharedValue } from "react-native-reanimated"
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated"
|
||||
import { useColor } from "react-native-uikit-colors"
|
||||
|
||||
import { useUISettingKey } from "@/src/atoms/settings/ui"
|
||||
import { NavigationContext } from "@/src/components/layouts/views/NavigationContext"
|
||||
import { NavigationBlurEffectHeader } from "@/src/components/layouts/views/SafeNavigationScrollView"
|
||||
import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar"
|
||||
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
|
||||
import { Portal } from "@/src/components/ui/portal/Portal"
|
||||
import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line"
|
||||
import { apiClient } from "@/src/lib/api-fetch"
|
||||
import { EntryContentContext, useEntryContentContext } from "@/src/modules/entry-content/ctx"
|
||||
import { EntryContentHeaderRightActions } from "@/src/modules/entry-content/EntryContentHeaderRightActions"
|
||||
import { useEntry } from "@/src/store/entry/hooks"
|
||||
|
|
@ -41,11 +53,23 @@ export const EntryTitle = ({ title, entryId }: { title: string; entryId: string
|
|||
}, [scrollY, title, titleHeight, headerHeight, opacityAnimatedValue])
|
||||
|
||||
const ctxValue = useEntryContentContext()
|
||||
const headerBarWidth = useWindowDimensions().width
|
||||
return (
|
||||
<>
|
||||
<NavigationBlurEffectHeader
|
||||
headerShown
|
||||
headerTitleAbsolute
|
||||
title={title}
|
||||
headerLeft={useTypeScriptHappyCallback(
|
||||
({ canGoBack }) => (
|
||||
<EntryLeftGroup
|
||||
canGoBack={canGoBack ?? false}
|
||||
entryId={entryId}
|
||||
titleOpacityShareValue={opacityAnimatedValue}
|
||||
/>
|
||||
),
|
||||
[entryId],
|
||||
)}
|
||||
headerRight={useCallback(
|
||||
() => (
|
||||
<EntryContentContext.Provider value={ctxValue}>
|
||||
|
|
@ -58,9 +82,13 @@ export const EntryTitle = ({ title, entryId }: { title: string; entryId: string
|
|||
),
|
||||
[ctxValue, entryId, opacityAnimatedValue, isHeaderTitleVisible],
|
||||
)}
|
||||
headerTitle={() => (
|
||||
<Portal>
|
||||
<View className="absolute inset-x-12 inset-y-0 flex-row items-center justify-center">
|
||||
headerTitle={useCallback(
|
||||
() => (
|
||||
<View
|
||||
className="flex-row items-center justify-center"
|
||||
pointerEvents="none"
|
||||
style={{ width: headerBarWidth - 80 }}
|
||||
>
|
||||
<Animated.Text
|
||||
className={"text-label text-center text-[17px] font-semibold"}
|
||||
numberOfLines={1}
|
||||
|
|
@ -69,7 +97,8 @@ export const EntryTitle = ({ title, entryId }: { title: string; entryId: string
|
|||
{title}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
</Portal>
|
||||
),
|
||||
[headerBarWidth, opacityAnimatedValue, title],
|
||||
)}
|
||||
/>
|
||||
<View
|
||||
|
|
@ -160,3 +189,74 @@ export const EntrySocialTitle = ({ entryId }: { entryId: string }) => {
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface EntryLeftGroupProps {
|
||||
canGoBack: boolean
|
||||
entryId: string
|
||||
titleOpacityShareValue: SharedValue<number>
|
||||
}
|
||||
|
||||
const EntryLeftGroup = ({ canGoBack, entryId, titleOpacityShareValue }: EntryLeftGroupProps) => {
|
||||
const label = useColor("label")
|
||||
|
||||
const hideRecentReader = useUISettingKey("hideRecentReader")
|
||||
const animatedOpacity = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(titleOpacityShareValue.value, [0, 1], [1, 0]),
|
||||
}
|
||||
})
|
||||
return (
|
||||
<View className="flex-row items-center justify-center">
|
||||
<TouchableOpacity hitSlop={10} onPress={() => router.back()}>
|
||||
{canGoBack && <MingcuteLeftLineIcon height={20} width={20} color={label} />}
|
||||
</TouchableOpacity>
|
||||
|
||||
{!hideRecentReader && (
|
||||
<Animated.View style={animatedOpacity} className="absolute left-[32px] z-10 flex-row gap-2">
|
||||
<EntryReadHistory entryId={entryId} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const EntryReadHistory = ({ entryId }: { entryId: string }) => {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["entry-read-history", entryId],
|
||||
queryFn: () => {
|
||||
return apiClient.entries["read-histories"][":id"].$get({
|
||||
param: {
|
||||
id: entryId,
|
||||
},
|
||||
query: {
|
||||
size: 6,
|
||||
},
|
||||
})
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
if (!data?.data.entryReadHistories) return null
|
||||
return (
|
||||
<View className="flex-row items-center justify-center">
|
||||
{data?.data.entryReadHistories.userIds.map((userId, index) => {
|
||||
const user = data.data.users[userId]
|
||||
if (!user) return null
|
||||
return (
|
||||
<View
|
||||
className="border-system-background bg-tertiary-system-background overflow-hidden rounded-full border-2"
|
||||
key={userId}
|
||||
style={{
|
||||
transform: [
|
||||
{
|
||||
translateX: index * -10,
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<UserAvatar size={25} name={user.name!} image={user.image} />
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,11 +86,11 @@ export function EntryNormalItem({ entryId, extraData }: { entryId: string; extra
|
|||
postfixText="ago"
|
||||
/>
|
||||
</View>
|
||||
<Text numberOfLines={2} className="text-label text-lg font-semibold leading-tight">
|
||||
<Text numberOfLines={2} className="text-label text-lg font-semibold">
|
||||
{title}
|
||||
</Text>
|
||||
{view !== FeedViewType.Notifications && (
|
||||
<Text numberOfLines={2} className="text-secondary-label mt-1 text-base leading-tight">
|
||||
<Text numberOfLines={2} className="text-secondary-label text-base">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue