feat: update date mention and mention information (#4578)

This commit is contained in:
Stephen Zhou 2025-10-20 17:34:39 +08:00 committed by GitHub
parent e9de114ebe
commit 86980e71f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 228 additions and 86 deletions

View File

@ -1,4 +1,3 @@
import { getCategoryFeedIds } from "@follow/store/subscription/getter"
import i18next from "i18next"
import type {
DOMConversionMap,
@ -14,13 +13,11 @@ import type {
import { $applyNodeReplacement, DecoratorNode } from "lexical"
import * as React from "react"
import { ROUTE_FEED_IN_FOLDER } from "~/constants"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { MentionComponent } from "./components/MentionComponent"
import { RANGE_WITH_LABEL_KEY } from "./hooks/dateMentionConfig"
import { getDateMentionDisplayName } from "./hooks/dateMentionUtils"
import type { MentionData } from "./types"
import { getMentionTextValue } from "./utils/mentionTextValue"
export type SerializedMentionNode = Spread<
{
@ -45,6 +42,15 @@ export class MentionNode extends DecoratorNode<React.JSX.Element> {
this.__mentionData = mentionData
}
getMentionData(): MentionData {
return this.__mentionData
}
setMentionData(mentionData: MentionData): void {
const writable = this.getWritable()
writable.__mentionData = mentionData
}
override createDOM(config: EditorConfig): HTMLElement {
const dom = document.createElement("span")
dom.className = config.theme.mention || "mention-node"
@ -100,28 +106,24 @@ export class MentionNode extends DecoratorNode<React.JSX.Element> {
* For export markdown conversion
*/
override getTextContent(): string {
const { type, value } = this.__mentionData
if (type === "date" && value) {
return value as string
}
if (
type === "category" &&
typeof value === "string" &&
value.startsWith(ROUTE_FEED_IN_FOLDER)
) {
const { view } = getRouteParams()
const ids = getCategoryFeedIds(value.slice(ROUTE_FEED_IN_FOLDER.length), view)
return `<mention-feed ids=${JSON.stringify(ids)}></mention-feed>`
}
return `<mention-${type} id="${value}"></mention-${type}>`
return getMentionTextValue(this.__mentionData)
}
override decorate(_editor: LexicalEditor): React.JSX.Element {
override decorate(editor: LexicalEditor): React.JSX.Element {
// Use a combination of key and value to ensure re-render when mention data changes
const dataKey =
typeof this.__mentionData.value === "string"
? this.__mentionData.value
: String(this.__mentionData.value)
return (
<React.Suspense fallback={null}>
<MentionComponent mentionData={this.__mentionData} />
<MentionComponent
mentionData={this.__mentionData}
nodeKey={this.__key}
editor={editor}
key={`${this.__key}-${dataKey}`}
/>
</React.Suspense>
)
}

View File

@ -1,3 +1,4 @@
import { DateTimePicker } from "@follow/components/ui/input/index.js"
import {
Tooltip,
TooltipContent,
@ -5,44 +6,76 @@ import {
TooltipRoot,
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.js"
import { getView } from "@follow/constants"
import { cn } from "@follow/utils"
import dayjs from "dayjs"
import type { LexicalEditor } from "lexical"
import { $getNodeByKey } from "lexical"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { RANGE_WITH_LABEL_KEY } from "../hooks/dateMentionConfig"
import { getDateMentionDisplayName } from "../hooks/dateMentionUtils"
import {
createDateMentionData,
getDateMentionDisplayName,
parseRangeValue,
} from "../hooks/dateMentionUtils"
import { $isMentionNode } from "../MentionNode"
import type { MentionData } from "../types"
import { getMentionTextValue } from "../utils/mentionTextValue"
import { MentionTypeIcon } from "./shared/MentionTypeIcon"
interface MentionComponentProps {
mentionData: MentionData
className?: string
nodeKey?: string
editor?: LexicalEditor
}
const MentionTooltipContent = ({
mentionData,
displayName,
}: {
mentionData: MentionData
displayName: string
}) => (
<div className="flex items-center gap-2 p-1">
<div
className={cn(
"flex size-5 items-center justify-center rounded text-white",
mentionData.type === "entry" && "bg-blue",
mentionData.type === "feed" && "bg-orange",
mentionData.type === "date" && "bg-purple",
mentionData.type === "shortcut" && "bg-amber-700",
)}
>
<MentionTypeIcon type={mentionData.type} value={mentionData.value} className="size-3" />
</div>
<span className="text-text text-sm">{displayName}</span>
</div>
)
const MentionTooltipContent = ({ mentionData }: { mentionData: MentionData }) => {
const displayValue = getMentionTextValue(mentionData)
const getMentionStyles = (type: MentionData["type"]) => {
const getIconBgColor = () => {
if (mentionData.type === "view" && typeof mentionData.value === "number") {
const viewDef = getView(mentionData.value)
if (viewDef?.backgroundClassName) {
return viewDef.backgroundClassName
}
}
switch (mentionData.type) {
case "entry": {
return "bg-blue"
}
case "feed": {
return "bg-orange"
}
case "date": {
return "bg-purple"
}
case "shortcut": {
return "bg-amber-700"
}
}
}
return (
<div className="flex items-center gap-2 p-1">
<div
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded text-white",
getIconBgColor(),
)}
>
<MentionTypeIcon type={mentionData.type} value={mentionData.value} className="size-3" />
</div>
<span className="text-text text-sm">{displayValue}</span>
</div>
)
}
const getMentionStyles = (mentionData: MentionData) => {
const { type, value } = mentionData
const baseStyles = tw`
inline items-center gap-1 px-2 py-0.5 rounded-md
font-medium text-sm cursor-pointer select-none
@ -78,14 +111,21 @@ const getMentionStyles = (type: MentionData["type"]) => {
)
}
case "view": {
return cn(baseStyles)
const viewDef = getView(value as number)
return cn(baseStyles, viewDef!.mentionClassName)
}
case "shortcut": {
return cn(baseStyles, "text-amber-700 border-amber-700/20", "hover:border-amber-700/30")
}
}
}
export const MentionComponent: React.FC<MentionComponentProps> = ({ mentionData, className }) => {
export const MentionComponent: React.FC<MentionComponentProps> = ({
mentionData,
className,
nodeKey,
editor,
}) => {
const { t, i18n } = useTranslation("ai")
const language = i18n.language || i18n.resolvedLanguage || "en"
@ -100,28 +140,77 @@ export const MentionComponent: React.FC<MentionComponentProps> = ({ mentionData,
}
}, [mentionData, t, language])
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
// Handle mention click - could navigate to user profile, topic page, etc.
// TODO: Implement navigation logic for mentions
}
const handleDateRangeChange = React.useCallback(
(value: { start?: string; end?: string }) => {
if (!nodeKey || !value.start || !value.end || !editor) return
const startDate = dayjs(value.start).startOf("day")
const endDate = dayjs(value.end).startOf("day")
const range = { start: startDate, end: endDate }
const newMentionData = createDateMentionData({
range,
translate: t,
locale: language,
withRangeKey: RANGE_WITH_LABEL_KEY,
})
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if ($isMentionNode(node)) {
node.setMentionData(newMentionData)
}
})
},
[nodeKey, editor, t, language],
)
const currentDateRange = React.useMemo(() => {
if (mentionData.type !== "date" || typeof mentionData.value !== "string") {
return
}
const range = parseRangeValue(mentionData.value)
if (!range) return
return {
start: range.start.toISOString(),
end: range.end.toISOString(),
}
}, [mentionData])
const mentionSpan = (
<TooltipTrigger asChild>
<span className={cn(getMentionStyles(mentionData), className)}>
<MentionTypeIcon
type={mentionData.type}
value={mentionData.value}
className="mr-1 translate-y-[2px]"
/>
<span>{displayName}</span>
</span>
</TooltipTrigger>
)
const isEditableDateMention = mentionData.type === "date" && nodeKey && editor
return (
<Tooltip>
<TooltipRoot>
<TooltipTrigger asChild>
<span className={cn(getMentionStyles(mentionData.type), className)} onClick={handleClick}>
<MentionTypeIcon
type={mentionData.type}
value={mentionData.value}
className="mr-1 translate-y-[2px]"
/>
<span>{displayName}</span>
</span>
</TooltipTrigger>
{isEditableDateMention ? (
<DateTimePicker
mode="range"
rangeValue={currentDateRange}
onRangeChange={handleDateRangeChange}
minDate={dayjs().subtract(1, "month").toISOString()}
>
{mentionSpan}
</DateTimePicker>
) : (
mentionSpan
)}
<TooltipPortal>
<TooltipContent side="top" className="max-w-[300px]">
<MentionTooltipContent mentionData={mentionData} displayName={displayName} />
<MentionTooltipContent mentionData={mentionData} />
</TooltipContent>
</TooltipPortal>
</TooltipRoot>

View File

@ -31,9 +31,7 @@ export const MentionTypeIcon: React.FC<MentionTypeIconProps> = ({
if (typeof value === "number") {
const viewDef = getView(value)
if (viewDef?.icon?.props?.className) {
return (
<i className={`${viewDef.icon.props.className} ${viewDef.className} ${className}`} />
)
return <i className={`${viewDef.icon.props.className} ${className}`} />
}
}
return <i className={`i-mgc-grid-cute-re ${className}`} />

View File

@ -151,7 +151,11 @@ export const createDateMentionData = ({
}
export const parseRangeValue = (value: string): DateRange | null => {
const [startIso, endIsoExclusive] = value.split("..", 2)
// Parse XML format: <mention-date start="YYYY-MM-DD" end="YYYY-MM-DD"></mention-date>
const match = value.match(/start="([^"]+)"\s+end="([^"]+)"/)
if (!match) return null
const [, startIso, endIsoExclusive] = match
if (!startIso || !endIsoExclusive) return null
const start = dayjs(startIso, MENTION_DATE_VALUE_FORMAT, true)

View File

@ -0,0 +1,22 @@
import { getCategoryFeedIds } from "@follow/store/subscription/getter"
import { ROUTE_FEED_IN_FOLDER } from "~/constants"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import type { MentionData } from "../types"
export function getMentionTextValue(mentionData: MentionData): string {
const { type, value } = mentionData
if (type === "date" && value) {
return value as string
}
if (type === "category" && typeof value === "string" && value.startsWith(ROUTE_FEED_IN_FOLDER)) {
const { view } = getRouteParams()
const ids = getCategoryFeedIds(value.slice(ROUTE_FEED_IN_FOLDER.length), view)
return `<mention-feed ids=${JSON.stringify(ids)}></mention-feed>`
}
return `<mention-${type} id="${value}"></mention-${type}>`
}

View File

@ -29,6 +29,8 @@ export interface DateTimePickerProps {
rangePlaceholder?: string
/** Class name for the content */
contentClassName?: string
/** Custom trigger element. If provided, replaces the default button */
children?: React.ReactNode
}
/**
@ -48,6 +50,7 @@ export const DateTimePicker = memo<DateTimePickerProps>(
onRangeChange,
rangePlaceholder = "Select date range",
contentClassName,
children,
}) => {
const [isOpen, setIsOpen] = useState(false)
const [viewMode, setViewMode] = useState<"days" | "months" | "years">("days")
@ -161,24 +164,26 @@ export const DateTimePicker = memo<DateTimePickerProps>(
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled}
buttonClassName={cn(
"w-full justify-start text-left font-normal px-2.5",
isRangeMode
? !rangeStart && !rangeEnd && "text-text-tertiary"
: !value && "text-text-tertiary",
className,
)}
>
<i className="i-mgc-calendar-time-add-cute-re mr-2 size-4" />
{isRangeMode
? formatRangeButtonLabel()
: value
? currentDateTime.format("MMM DD, YYYY HH:mm")
: placeholder}
</Button>
{children || (
<Button
variant="outline"
disabled={disabled}
buttonClassName={cn(
"w-full justify-start text-left font-normal px-2.5",
isRangeMode
? !rangeStart && !rangeEnd && "text-text-tertiary"
: !value && "text-text-tertiary",
className,
)}
>
<i className="i-mgc-calendar-time-add-cute-re mr-2 size-4" />
{isRangeMode
? formatRangeButtonLabel()
: value
? currentDateTime.format("MMM DD, YYYY HH:mm")
: placeholder}
</Button>
)}
</PopoverTrigger>
<PopoverContent

View File

@ -14,6 +14,8 @@ export interface ViewDefinition {
icon: React.JSX.Element
className: string
peerClassName: string
mentionClassName: string
backgroundClassName: string
translation: string
view: FeedViewType
wideMode?: boolean
@ -28,6 +30,8 @@ const viewAll: ViewDefinition = {
icon: <i className="i-mgc-bubble-cute-fi" />,
className: "text-folo",
peerClassName: "peer-checked:text-folo dark:peer-checked:text-folo",
mentionClassName: "bg-folo/10 text-folo border-folo/20 hover:bg-folo/20 hover:border-folo/30",
backgroundClassName: "bg-folo",
translation: "title,description,content",
view: FeedViewType.All,
activeColor: "#FF5C00",
@ -43,6 +47,9 @@ const views: ViewDefinition[] = [
icon: <i className="i-mgc-paper-cute-fi" />,
className: "text-lime-600 dark:text-lime-500",
peerClassName: "peer-checked:text-lime-600 dark:peer-checked:text-lime-500",
mentionClassName:
"bg-lime-600/10 text-lime-600 border-lime-600/20 hover:bg-lime-600/20 hover:border-lime-600/30",
backgroundClassName: "bg-lime-600",
translation: "title,description",
view: FeedViewType.Articles,
activeColor: "#FF5C00",
@ -53,6 +60,9 @@ const views: ViewDefinition[] = [
icon: <i className="i-mgc-thought-cute-fi" />,
className: "text-sky-600 dark:text-sky-500",
peerClassName: "peer-checked:text-sky-600 peer-checked:dark:text-sky-500",
mentionClassName:
"bg-sky-600/10 text-sky-600 border-sky-600/20 hover:bg-sky-600/20 hover:border-sky-600/30",
backgroundClassName: "bg-sky-600",
wideMode: true,
translation: "content",
view: FeedViewType.SocialMedia,
@ -65,6 +75,9 @@ const views: ViewDefinition[] = [
icon: <i className="i-mgc-pic-cute-fi" />,
className: "text-green-600 dark:text-green-500",
peerClassName: "peer-checked:text-green-600 peer-checked:dark:text-green-500",
mentionClassName:
"bg-green-600/10 text-green-600 border-green-600/20 hover:bg-green-600/20 hover:border-green-600/30",
backgroundClassName: "bg-green-600",
gridMode: true,
wideMode: true,
translation: "title",
@ -78,6 +91,9 @@ const views: ViewDefinition[] = [
icon: <i className="i-mgc-video-cute-fi" />,
className: "text-red-600 dark:text-red-500",
peerClassName: "peer-checked:text-red-600 peer-checked:dark:text-red-500",
mentionClassName:
"bg-red-600/10 text-red-600 border-red-600/20 hover:bg-red-600/20 hover:border-red-600/30",
backgroundClassName: "bg-red-600",
gridMode: true,
wideMode: true,
translation: "title",
@ -91,6 +107,9 @@ const views: ViewDefinition[] = [
icon: <i className="i-mgc-mic-cute-fi" />,
className: "text-purple-600 dark:text-purple-500",
peerClassName: "peer-checked:text-purple-600 peer-checked:dark:text-purple-500",
mentionClassName:
"bg-purple-600/10 text-purple-600 border-purple-600/20 hover:bg-purple-600/20 hover:border-purple-600/30",
backgroundClassName: "bg-purple-600",
translation: "title",
view: FeedViewType.Audios,
// purple-500
@ -102,6 +121,9 @@ const views: ViewDefinition[] = [
icon: <i className="i-mgc-announcement-cute-fi" />,
className: "text-yellow-600 dark:text-yellow-500",
peerClassName: "peer-checked:text-yellow-600 peer-checked:dark:text-yellow-500",
mentionClassName:
"bg-yellow-600/10 text-yellow-600 border-yellow-600/20 hover:bg-yellow-600/20 hover:border-yellow-600/30",
backgroundClassName: "bg-yellow-600",
translation: "title",
view: FeedViewType.Notifications,
// yellow-500