feat(desktop): integrate AvatarGroup component for enhanced user read history display

- Added AvatarGroup component to encapsulate user avatars in EntryReadHistory for improved layout and interaction.
- Refactored EntryReadHistory to utilize AvatarGroup, simplifying the rendering of user avatars.
- Updated EntryUser component to enhance user profile interaction and tooltip display.

These changes enhance the visual presentation and user experience of the read history feature.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-05-15 00:04:29 +08:00
parent 74f7d52277
commit aff493f38f
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
4 changed files with 118 additions and 129 deletions

View File

@ -1,11 +1,4 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import {
HoverCard,
HoverCardContent,
HoverCardPortal,
HoverCardTrigger,
} from "@radix-ui/react-hover-card"
import clsx from "clsx"
import { AvatarGroup } from "@follow/components/ui/avatar-group/index.js"
import { useWhoami } from "~/atoms/user"
import { useAuthQuery } from "~/hooks/common"
@ -13,7 +6,7 @@ import { useAppLayoutGridContainerWidth } from "~/providers/app-grid-layout-cont
import { Queries } from "~/queries"
import { useEntryReadHistory } from "~/store/entry"
import { EntryUser, EntryUserRow, getLimit } from "./EntryReadHistory.shared"
import { EntryUser, getLimit } from "./EntryReadHistory.shared"
export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) => {
const me = useWhoami()
@ -37,64 +30,27 @@ export const EntryReadHistory: Component<{ entryId: string }> = ({ entryId }) =>
className="animate-in fade-in @md:flex hidden items-center duration-200"
data-hide-in-print
>
{entryHistory.userIds
.filter((id) => id !== me?.id)
.slice(0, LIMIT)
<AvatarGroup>
{entryHistory.userIds
.filter((id) => id !== me?.id)
.slice(0, LIMIT)
.map((userId, i) => (
<EntryUser userId={userId} i={i} key={userId} />
))}
.map((userId) => (
<EntryUser userId={userId} key={userId} />
))}
</AvatarGroup>
{!!totalCount && totalCount > LIMIT && (
<HoverCard>
<HoverCardTrigger asChild>
<button
type="button"
style={{
marginLeft: `-${LIMIT * 8}px`,
zIndex: LIMIT + 1,
}}
className="no-drag-region border-border bg-material-opaque ring-background relative flex size-7 items-center justify-center rounded-full border ring-2"
>
<span className="text-text-secondary text-[10px] font-medium tabular-nums">
+{Math.min(totalCount - LIMIT, 99)}
</span>
</button>
</HoverCardTrigger>
{totalCount > LIMIT && (
<HoverCardPortal>
<HoverCardContent
sideOffset={12}
align="start"
side="right"
className={clsx(
"shadow-menu bg-material-medium shadow-context-menu backdrop-blur-background rounded-md border",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:slide-out-to-left-5 data-[state=open]:slide-in-from-left-5",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"transition-all duration-200 ease-in-out",
)}
>
<ScrollArea.ScrollArea
rootClassName="max-h-[300px] max-w-[20ch] flex flex-col"
flex
>
<ul>
{entryHistory.userIds
.filter((id) => id !== me?.id)
.slice(LIMIT)
.map((userId) => (
<EntryUserRow userId={userId} key={userId} />
))}
</ul>
</ScrollArea.ScrollArea>
</HoverCardContent>
</HoverCardPortal>
)}
</HoverCard>
)}
<div
style={{
margin: "-8px",
zIndex: LIMIT + 1,
}}
className="no-drag-region border-border bg-material-opaque ring-background relative flex size-7 items-center justify-center rounded-full border ring-2"
>
<span className="text-text-secondary text-[10px] font-medium tabular-nums">
+{Math.min(totalCount - LIMIT, 99)}
</span>
</div>
</div>
)
}

View File

@ -1,11 +1,5 @@
import { Avatar, AvatarFallback, AvatarImage } from "@follow/components/ui/avatar/index.jsx"
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.jsx"
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
import { TooltipContent, TooltipPortal } from "@follow/components/ui/tooltip/index.jsx"
import { FeedViewType } from "@follow/constants"
import { getNameInitials } from "@follow/utils/cjk"
import { m } from "motion/react"
@ -36,74 +30,37 @@ export const getLimit = (width: number): number => {
return 5
}
export const EntryUserRow: Component<{ userId: string }> = memo(({ userId }) => {
const user = useUserById(userId)
const presentUserProfile = usePresentUserProfileModal("drawer")
if (!user) return null
return (
<li
onClick={() => {
presentUserProfile(userId)
}}
role="button"
tabIndex={0}
className="cursor-button hover:bg-theme-selection-hover hover:text-theme-selection-foreground relative flex min-w-0 max-w-[50ch] shrink-0 items-center gap-2 truncate rounded-md p-1 px-2"
>
<Avatar className="border-border ring-background block aspect-square size-7 overflow-hidden rounded-full border ring-1">
<AvatarImage src={replaceImgUrlIfNeed(user?.image || undefined)} />
<AvatarFallback>{getNameInitials(user.name || "")}</AvatarFallback>
</Avatar>
{user.name && (
<EllipsisHorizontalTextWithTooltip className="pr-8 text-xs font-medium">
<span>{user.name}</span>
</EllipsisHorizontalTextWithTooltip>
)}
</li>
)
})
export const EntryUser: Component<{
userId: string
i: number
}> = memo(({ userId, i }) => {
ref?: React.Ref<HTMLDivElement>
}> = memo(({ userId, ref }) => {
const user = useUserById(userId)
const { t } = useTranslation()
const presentUserProfile = usePresentUserProfileModal("drawer")
if (!user) return null
return (
<Tooltip>
<TooltipTrigger
className="no-drag-region relative cursor-pointer"
style={{
right: `${i * 8}px`,
zIndex: i,
<div className="no-drag-region relative cursor-pointer hover:!z-[99999]" ref={ref}>
<m.button
layout="position"
layoutId={userId}
type="button"
onClick={() => {
presentUserProfile(userId)
}}
asChild
>
<m.button
layout="position"
layoutId={userId}
type="button"
onClick={() => {
presentUserProfile(userId)
}}
>
<Avatar className="border-border ring-background aspect-square size-7 border ring-1">
<AvatarImage
src={replaceImgUrlIfNeed(user?.image || undefined)}
className="bg-material-ultra-thick"
/>
<AvatarFallback>{getNameInitials(user.name || "")}</AvatarFallback>
</Avatar>
</m.button>
</TooltipTrigger>
<Avatar className="border-border ring-background aspect-square size-7 border ring-1">
<AvatarImage
src={replaceImgUrlIfNeed(user?.image || undefined)}
className="bg-material-ultra-thick"
/>
<AvatarFallback>{getNameInitials(user.name || "")}</AvatarFallback>
</Avatar>
</m.button>
<TooltipPortal>
<TooltipContent side="top">
{t("entry_actions.recent_reader")} {user.name}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
)
})

View File

@ -148,6 +148,7 @@ export const EntryContent: Component<EntryContentProps> = ({
/>
<EntryTimelineSidebar entryId={entry.entries.id} />
<EntryScrollArea className={className} scrollerRef={scrollerRef}>
{/* Indicator for the entry */}
<div
className="animate-in fade-in slide-in-from-bottom-24 f-motion-reduce:fade-in-0 f-motion-reduce:slide-in-from-bottom-0 select-text duration-200 ease-in-out"
key={entry.entries.id}
@ -156,7 +157,8 @@ export const EntryContent: Component<EntryContentProps> = ({
<>
<div className="absolute inset-y-0 left-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
<MotionButtonBase
className="absolute left-0 shrink-0 cursor-pointer"
// -12 Visual center point
className="absolute left-0 shrink-0 !-translate-y-12 cursor-pointer"
onClick={() => {
EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)
}}
@ -167,7 +169,7 @@ export const EntryContent: Component<EntryContentProps> = ({
<div className="absolute inset-y-0 right-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
<MotionButtonBase
className="absolute right-0 shrink-0 cursor-pointer"
className="absolute right-0 shrink-0 !-translate-y-12 cursor-pointer"
onClick={() => {
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
}}

View File

@ -0,0 +1,74 @@
import { Spring } from "@follow/components/constants/spring"
import { cn } from "@follow/utils/utils"
import type { Transition } from "motion/react"
import { m as motion } from "motion/react"
import * as React from "react"
import { Tooltip, TooltipTrigger } from "../tooltip"
type AvatarProps = {
children: React.ReactNode
zIndex: number
transition: Transition
translate: string | number
}
const AvatarContainer = React.memo(({ children, zIndex, transition, translate }: AvatarProps) => {
return (
<TooltipTrigger>
<motion.div
data-slot="avatar-container"
initial="initial"
whileHover="hover"
whileTap="hover"
className="relative"
style={{ zIndex }}
>
<motion.div
variants={{
initial: { translateY: 0 },
hover: { translateY: translate },
}}
transition={transition}
>
{children}
</motion.div>
</motion.div>
</TooltipTrigger>
)
})
type AvatarGroupProps = Omit<React.ComponentProps<"div">, "translate"> & {
children: React.ReactElement[]
transition?: Transition
translate?: string | number
}
function AvatarGroup({
ref,
children,
className,
transition = Spring.presets.smooth,
translate = "-30%",
...props
}: AvatarGroupProps) {
return (
<div
ref={ref}
data-slot="avatar-group"
className={cn("flex h-8 flex-row items-center -space-x-2", className)}
{...props}
>
{children?.map((child, index) => (
<Tooltip delayDuration={0} key={index}>
<AvatarContainer zIndex={index} transition={transition} translate={translate}>
{child}
</AvatarContainer>
</Tooltip>
))}
</div>
)
}
export { AvatarGroup, type AvatarGroupProps }