Skip to content

Commit

Permalink
feat(mobile): enhance entry header with dynamic title and read history
Browse files Browse the repository at this point in the 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>
  • Loading branch information
Innei committed Feb 26, 2025
1 parent 7d092cd commit 458cbf7
Show file tree
Hide file tree
Showing 7 changed files with 159 additions and 45 deletions.
69 changes: 38 additions & 31 deletions apps/mobile/src/components/layouts/header/NavigationHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -29,6 +28,7 @@ export interface NavigationHeaderRawProps {
canGoBack: boolean
}>
headerTitle?: FC<React.ComponentProps<typeof HeaderTitle>> | ReactNode
headerTitleAbsolute?: boolean
headerRight?: FC<{
canGoBack: boolean
}>
Expand Down Expand Up @@ -131,6 +131,7 @@ export const NavigationHeader = ({
modal = false,
hideableBottom,
hideableBottomHeight,
headerTitleAbsolute,
...rest
}: NavigationHeaderProps) => {
const insets = useSafeAreaInsets()
Expand Down Expand Up @@ -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 && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -134,6 +136,7 @@ export const NavigationBlurEffectHeader = ({
headerLeft={options.headerLeft}
hideableBottom={hideableBottom}
hideableBottomHeight={headerHideableBottomHeight}
headerTitleAbsolute={headerTitleAbsolute}
// @ts-expect-error
headerTitle={options.headerTitle}
/>
Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/src/components/ui/avatar/UserAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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={{
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/components/ui/portal/PortalHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const HeaderRightActionsImpl = ({
{!isHeaderTitleVisible && (
<View style={{ width: extraActionContainerWidth }} pointerEvents="none" />
)}

<Animated.View
onLayout={(e) => {
setExtraActionContainerWidth(e.nativeEvent.layout.width)
Expand Down
114 changes: 107 additions & 7 deletions apps/mobile/src/modules/entry-content/EntryTitle.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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}>
Expand All @@ -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}
Expand All @@ -69,7 +97,8 @@ export const EntryTitle = ({ title, entryId }: { title: string; entryId: string
{title}
</Animated.Text>
</View>
</Portal>
),
[headerBarWidth, opacityAnimatedValue, title],
)}
/>
<View
Expand Down Expand Up @@ -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>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>
)}
Expand Down

0 comments on commit 458cbf7

Please sign in to comment.