import { format, formatDistance } from "date-fns"; import { AlertCircle, Bookmark, BookOpen, CheckCircle, ChevronDown, Clock, FileEdit, Heart, Layers, MessageSquare, MoreHorizontal, PenSquare, RefreshCw, Star, Tag, UserPlus, Users, } from "lucide-react"; import { useState } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { Timeline, TimelineItem } from "@/components/ui/timeline"; import { cn } from "@/lib/utils"; /** * Activity Feed component for displaying user activity timelines * * @example * ```tsx * * ``` */ export interface Activity { /** * Unique ID for the activity */ id: string | number; /** * Type of activity */ type: string; /** * User who performed the activity */ user: { id: string | number; name: string; avatar?: string; role?: string; }; /** * Timestamp when the activity occurred */ timestamp: string | Date; /** * Associated entity of the activity (work, comment, etc) */ entity?: { id: string | number; type: string; name: string; url?: string; }; /** * Secondary entity involved in the activity (e.g., the tag that was added) */ secondaryEntity?: { id: string | number; type: string; name: string; url?: string; }; /** * Additional metadata specific to the activity type */ meta?: Record; /** * Optional group identifier for grouping related activities */ groupId?: string | number; } export interface ActivityFeedProps { /** * Array of activity items to display */ activities: Activity[]; /** * Whether activities are currently loading */ isLoading?: boolean; /** * Callback to load more activities */ onLoadMore?: () => void; /** * Whether there are more activities to load */ hasMore?: boolean; /** * Callback when an activity is clicked */ onActivityClick?: (activity: Activity) => void; /** * Maximum number of activities to display before scrolling */ maxItems?: number; /** * Maximum height of the feed */ maxHeight?: string | number; /** * Whether to group activities by date */ groupByDate?: boolean; /** * Whether to use a compact layout */ compact?: boolean; /** * Optional filter by activity type */ filter?: string[]; /** * Whether to hide the filter controls */ hideFilters?: boolean; /** * Additional CSS classes */ className?: string; } interface ActivityGroup { date: string; activities: Activity[]; } /** * Get icon for activity type */ const getActivityIcon = (type: string): React.ReactNode => { const icons: Record = { comment: , like: , create: , edit: , join: , bookmark: , rate: , alert: , complete: , schedule: , read: , follow: , tag: , collection: , }; return icons[type] || ; }; /** * Get color variant for activity type */ const getActivityVariant = (type: string): string => { const variants: Record = { comment: "bg-blue-100 dark:bg-blue-950/30 text-blue-700 dark:text-blue-400", like: "bg-pink-100 dark:bg-pink-950/30 text-pink-700 dark:text-pink-400", create: "bg-green-100 dark:bg-green-950/30 text-green-700 dark:text-green-400", edit: "bg-amber-100 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400", join: "bg-indigo-100 dark:bg-indigo-950/30 text-indigo-700 dark:text-indigo-400", bookmark: "bg-purple-100 dark:bg-purple-950/30 text-purple-700 dark:text-purple-400", rate: "bg-yellow-100 dark:bg-yellow-950/30 text-yellow-700 dark:text-yellow-400", alert: "bg-red-100 dark:bg-red-950/30 text-red-700 dark:text-red-400", complete: "bg-emerald-100 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400", schedule: "bg-cyan-100 dark:bg-cyan-950/30 text-cyan-700 dark:text-cyan-400", read: "bg-violet-100 dark:bg-violet-950/30 text-violet-700 dark:text-violet-400", follow: "bg-indigo-100 dark:bg-indigo-950/30 text-indigo-700 dark:text-indigo-400", tag: "bg-orange-100 dark:bg-orange-950/30 text-orange-700 dark:text-orange-400", collection: "bg-teal-100 dark:bg-teal-950/30 text-teal-700 dark:text-teal-400", }; return ( variants[type] || "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-400" ); }; /** * Get formatted activity message */ const getActivityMessage = (activity: Activity): string => { const { type, entity, secondaryEntity, meta } = activity; const entityName = entity?.name || "item"; const secondaryName = secondaryEntity?.name || ""; const messages: Record = { comment: `commented on ${entityName}`, like: `liked ${entityName}`, create: `created ${entityName}`, edit: `edited ${entityName}`, join: `joined the platform`, bookmark: `bookmarked ${entityName}`, rate: `rated ${entityName}`, read: `read ${entityName}`, follow: `followed ${entityName}`, tag: `tagged ${entityName} with ${secondaryName}`, collection: `added ${entityName} to collection ${secondaryName}`, }; return messages[type] || `performed action on ${entityName}`; }; /** * Function to group activities by date */ const groupActivitiesByDate = (activities: Activity[]): ActivityGroup[] => { const groups: Record = {}; activities.forEach((activity) => { const date = new Date(activity.timestamp); const dateKey = format(date, "yyyy-MM-dd"); if (!groups[dateKey]) { groups[dateKey] = []; } groups[dateKey].push(activity); }); return Object.entries(groups) .map(([date, activities]) => ({ date, activities, })) .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); }; /** * Format date for group headers */ const formatGroupDate = (dateString: string): string => { const date = new Date(dateString); const today = new Date(); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); if (format(date, "yyyy-MM-dd") === format(today, "yyyy-MM-dd")) { return "Today"; } else if (format(date, "yyyy-MM-dd") === format(yesterday, "yyyy-MM-dd")) { return "Yesterday"; } else { return format(date, "MMMM d, yyyy"); } }; export function ActivityFeed({ activities, isLoading = false, onLoadMore, hasMore = false, onActivityClick, maxItems = 50, maxHeight = "500px", groupByDate = true, compact = false, filter = [], hideFilters = false, className, }: ActivityFeedProps) { const [activeFilters, setActiveFilters] = useState(filter); const [expanded, setExpanded] = useState>({}); // Apply filters to activities const filteredActivities = activeFilters.length > 0 ? activities.filter((activity) => activeFilters.includes(activity.type)) : activities; // Group activities by date if needed const groupedActivities = groupByDate ? groupActivitiesByDate(filteredActivities) : [{ date: "all", activities: filteredActivities }]; // Function to handle toggling activity expansion const toggleExpanded = (id: string | number) => { setExpanded((prev) => ({ ...prev, [id.toString()]: !prev[id.toString()], })); }; // Toggle a filter const toggleFilter = (type: string) => { setActiveFilters((prev) => prev.includes(type) ? prev.filter((t) => t !== type) : [...prev, type], ); }; // Generate list of available types from activities const availableTypes = Array.from( new Set(activities.map((activity) => activity.type)), ); return (
{/* Filter controls */} {!hideFilters && availableTypes.length > 0 && (
{availableTypes.map((type) => ( toggleFilter(type)} > {getActivityIcon(type)} {type} ))} {activeFilters.length > 0 && ( )}
)} {/* Activity list */} {groupedActivities.length === 0 || filteredActivities.length === 0 ? (
No activities to display
) : (
{groupedActivities.map((group) => (
{/* Date header (if grouping by date) */} {groupByDate && (

{formatGroupDate(group.date)}

)} {/* Activities list */} {group.activities.map((activity) => { const timestamp = new Date(activity.timestamp); const isExpanded = expanded[activity.id.toString()]; return (
{activity.user.name.charAt(0)} {activity.user.name}
{formatDistance(timestamp, new Date(), { addSuffix: true, })}
} description={
{getActivityIcon(activity.type)}
{getActivityMessage(activity)}
{activity.entity && (
toggleExpanded(activity.id)} >
{activity.entity.name}
{isExpanded && activity.meta && (
{activity.meta.excerpt && (
"{activity.meta.excerpt}"
)} {activity.meta.details && (
{activity.meta.details}
)}
)}
)}
} icon={ activity.meta?.important ? AlertCircle : undefined } active={activity.meta?.active} variant={ activity.meta?.highlighted ? "highlight" : undefined } /> ); })}
))}
)} {/* Load more button */} {hasMore && (
)} ); } export default ActivityFeed;