import type { Locale as DateFnsLocale } from "date-fns"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { format, formatDistanceToNow, isSameYear } from "date-fns"; import { useEffect, useRef, useState } from "react"; import { HiOutlineArrowLeft, HiOutlineArrowRight, HiOutlineCheckCircle, HiOutlineClock, HiOutlinePaperClip, HiOutlinePencil, HiOutlinePlus, HiOutlineTag, HiOutlineTrash, HiOutlineUserMinus, HiOutlineUserPlus, } from "react-icons/hi2"; import type { GetCardActivitiesOutput, GetCardByIdOutput, } from "@kan/api/types"; import { authClient } from "@kan/auth/client"; import Avatar from "~/components/Avatar"; import { useLocalisation } from "~/hooks/useLocalisation"; import { api } from "~/utils/api"; import { getAvatarUrl } from "~/utils/helpers"; import Comment from "./Comment"; type ActivityType = NonNullable["activities"][number]["type"]; type ActivityWithMergedLabels = GetCardActivitiesOutput["activities"][number] & { mergedLabels?: string[]; attachment?: { publicId: string; filename: string; originalFilename: string; } | null; }; const truncate = (value: string | null, maxLength = 50) => { if (!value) return value; return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; }; const getUserDisplayName = ( user: { name?: string | null; email?: string | null } | null | undefined, ): string => { if (user?.name?.trim()) return user.name; if (user?.email) return user.email; return t`Member`; }; const getActivityText = ({ type, toTitle, fromList, toList, memberName, memberEmail, isSelf, label, fromTitle, toDueDate, dateLocale, mergedLabels, attachmentName, }: { type: ActivityType; toTitle: string | null; fromList: string | null; toList: string | null; memberName: string | null; memberEmail: string | null; isSelf: boolean; label: string | null; fromTitle?: string | null; fromDueDate?: Date | null; toDueDate?: Date | null; dateLocale: DateFnsLocale; mergedLabels?: string[]; attachmentName?: string | null; }) => { const displayName = memberName ?? memberEmail ?? t`Member`; const TextHighlight = ({ children }: { children: React.ReactNode }) => ( {children} ); if ( type === "card.updated.label.added" && mergedLabels && mergedLabels.length > 1 ) { const labelList = mergedLabels.join(", "); return ( added {mergedLabels.length} labels:{" "} {labelList} ); } if ( type === "card.updated.label.removed" && mergedLabels && mergedLabels.length > 1 ) { const labelList = mergedLabels.join(", "); return ( removed {mergedLabels.length} labels:{" "} {labelList} ); } const ACTIVITY_TYPE_MAP = { "card.created": t`created the card`, "card.updated.title": t`updated the title`, "card.updated.description": t`updated the description`, "card.updated.list": t`moved the card to another list`, "card.updated.label.added": t`added a label to the card`, "card.updated.label.removed": t`removed a label from the card`, "card.updated.member.added": t`added a member to the card`, "card.updated.member.removed": t`removed a member from the card`, "card.updated.checklist.added": t`added a checklist`, "card.updated.checklist.renamed": t`renamed a checklist`, "card.updated.checklist.deleted": t`deleted a checklist`, "card.updated.checklist.item.added": t`added a checklist item`, "card.updated.checklist.item.updated": t`updated a checklist item`, "card.updated.checklist.item.completed": t`completed a checklist item`, "card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`, "card.updated.checklist.item.deleted": t`deleted a checklist item`, "card.updated.attachment.added": t`added an attachment`, "card.updated.attachment.removed": t`removed an attachment`, "card.updated.dueDate.added": t`set the due date`, "card.updated.dueDate.updated": t`updated the due date`, "card.updated.dueDate.removed": t`removed the due date`, } as const; if (!(type in ACTIVITY_TYPE_MAP)) return null; const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP]; if (type === "card.updated.title" && toTitle) { return ( updated the title to {truncate(toTitle)} ); } if (type === "card.updated.list" && fromList && toList) { return ( moved the card from {truncate(fromList)}{" "} to {truncate(toList)} ); } if (type === "card.updated.member.added" && displayName) { if (isSelf) return self-assigned the card; return ( assigned {truncate(displayName)} to the card ); } if (type === "card.updated.member.removed" && displayName) { if (isSelf) return unassigned themselves from the card; return ( unassigned {truncate(displayName)} from the card ); } if (type === "card.updated.label.added" && label) { return ( added label {truncate(label)} ); } if (type === "card.updated.label.removed" && label) { return ( removed label {truncate(label)} ); } if (type === "card.updated.checklist.added" && toTitle) { return ( added checklist {truncate(toTitle)} ); } if (type === "card.updated.checklist.renamed" && toTitle) { return ( renamed checklist {truncate(toTitle)} ); } if (type === "card.updated.checklist.deleted" && fromTitle) { return ( deleted checklist {truncate(fromTitle)} ); } if (type === "card.updated.checklist.item.added" && toTitle) { return ( added checklist item {truncate(toTitle)} ); } if (type === "card.updated.checklist.item.updated" && toTitle) { return ( renamed checklist item to{" "} {truncate(toTitle)} ); } if (type === "card.updated.checklist.item.completed" && toTitle) { return ( completed checklist item{" "} {truncate(toTitle)} ); } if (type === "card.updated.checklist.item.uncompleted" && toTitle) { return ( marked checklist item {truncate(toTitle)}{" "} as incomplete ); } if (type === "card.updated.checklist.item.deleted" && fromTitle) { return ( deleted checklist item{" "} {truncate(fromTitle)} ); } if (type === "card.updated.attachment.added") { const filename = attachmentName ?? toTitle; if (!filename) return baseText; return ( added an attachment {truncate(filename)} ); } if (type === "card.updated.attachment.removed") { const filename = attachmentName ?? fromTitle; if (!filename) return baseText; return ( removed an attachment{" "} {truncate(filename)} ); } if (type === "card.updated.dueDate.added" && toDueDate) { const showYear = !isSameYear(toDueDate, new Date()); const formattedDate = format( toDueDate, showYear ? "do MMM yyyy" : "do MMM", { locale: dateLocale }, ); return ( changed the due date to {formattedDate} ); } if (type === "card.updated.dueDate.updated" && toDueDate) { const showYear = !isSameYear(toDueDate, new Date()); const formattedDate = format( toDueDate, showYear ? "do MMM yyyy" : "do MMM", { locale: dateLocale }, ); return ( changed the due date to {formattedDate} ); } if (type === "card.updated.dueDate.removed") { return removed the due date; } return baseText; }; const ACTIVITY_ICON_MAP: Partial> = { "card.created": , "card.updated.title": , "card.updated.description": , "card.updated.label.added": , "card.updated.label.removed": , "card.updated.member.added": , "card.updated.member.removed": , "card.updated.checklist.added": , "card.updated.checklist.renamed": , "card.updated.checklist.deleted": , "card.updated.checklist.item.added": , "card.updated.checklist.item.updated": , "card.updated.checklist.item.completed": , "card.updated.checklist.item.uncompleted": , "card.updated.checklist.item.deleted": , "card.updated.attachment.added": , "card.updated.attachment.removed": , "card.updated.dueDate.added": , "card.updated.dueDate.updated": , "card.updated.dueDate.removed": , } as const; const getActivityIcon = ( type: ActivityType, fromIndex?: number | null, toIndex?: number | null, ): React.ReactNode | null => { if (type === "card.updated.list" && fromIndex != null && toIndex != null) { return fromIndex > toIndex ? ( ) : ( ); } return ACTIVITY_ICON_MAP[type] ?? null; }; const ACTIVITIES_PAGE_SIZE = 20; const ActivityList = ({ cardPublicId, isLoading: cardIsLoading, isAdmin, isViewOnly, }: { cardPublicId: string; isLoading: boolean; isAdmin?: boolean; isViewOnly?: boolean; }) => { const { dateLocale } = useLocalisation(); const { data: sessionData } = authClient.useSession(); const utils = api.useUtils(); const [allActivities, setAllActivities] = useState< GetCardActivitiesOutput["activities"] >([]); const [hasMore, setHasMore] = useState(true); const [isLoadingMore, setIsLoadingMore] = useState(false); const isFullyExpandedRef = useRef(false); const lastDataUpdatedAtRef = useRef(null); const { data: firstPageData, isFetching: isFetchingFirst, dataUpdatedAt, } = api.card.getActivities.useQuery( { cardPublicId, limit: ACTIVITIES_PAGE_SIZE, }, { enabled: !!cardPublicId && cardPublicId.length >= 12, }, ); useEffect(() => { if (firstPageData && dataUpdatedAt !== lastDataUpdatedAtRef.current) { lastDataUpdatedAtRef.current = dataUpdatedAt; if (isFullyExpandedRef.current && firstPageData.hasMore) { setAllActivities(firstPageData.activities); setHasMore(firstPageData.hasMore); const fetchAllRemaining = async () => { let currentActivities = [...firstPageData.activities]; let currentHasMore = firstPageData.hasMore; while (currentHasMore) { const lastActivity = currentActivities[currentActivities.length - 1]; if (!lastActivity) break; const nextCursor = new Date(lastActivity.createdAt).toISOString(); const nextPage = await utils.card.getActivities.fetch({ cardPublicId, limit: ACTIVITIES_PAGE_SIZE, cursor: nextCursor, }); const existingIds = new Set( currentActivities.map((a) => a.publicId), ); const newActivities = nextPage.activities.filter( (a: { publicId: string }) => !existingIds.has(a.publicId), ); currentActivities = [...currentActivities, ...newActivities]; currentHasMore = nextPage.hasMore; } setAllActivities(currentActivities); setHasMore(false); }; void fetchAllRemaining(); } else { setAllActivities(firstPageData.activities); setHasMore(firstPageData.hasMore); if (!firstPageData.hasMore) { isFullyExpandedRef.current = true; } } } }, [firstPageData, dataUpdatedAt, cardPublicId, utils.card.getActivities]); const handleLoadMore = async () => { if (isLoadingMore || !hasMore || allActivities.length === 0) return; const lastActivity = allActivities[allActivities.length - 1]; if (!lastActivity) return; setIsLoadingMore(true); try { const nextCursor = new Date(lastActivity.createdAt).toISOString(); const nextPage = await utils.card.getActivities.fetch({ cardPublicId, limit: ACTIVITIES_PAGE_SIZE, cursor: nextCursor, }); const existingIds = new Set(allActivities.map((a) => a.publicId)); const newActivities = nextPage.activities.filter( (a: { publicId: string }) => !existingIds.has(a.publicId), ); setAllActivities((prev) => [...prev, ...newActivities]); setHasMore(nextPage.hasMore); if (!nextPage.hasMore) { isFullyExpandedRef.current = true; } } finally { setIsLoadingMore(false); } }; const isFetching = isFetchingFirst || isLoadingMore; const isLoading = cardIsLoading || (isFetchingFirst && allActivities.length === 0); return (
{allActivities.map((activity, index) => { const activityText = getActivityText({ type: activity.type, toTitle: activity.toTitle, fromList: activity.fromList?.name ?? null, toList: activity.toList?.name ?? null, memberName: activity.member?.user?.name ?? null, memberEmail: activity.member?.user?.email ?? null, isSelf: activity.member?.user?.id === sessionData?.user.id, label: activity.label?.name ?? null, fromTitle: activity.fromTitle ?? null, fromDueDate: activity.fromDueDate ?? null, toDueDate: activity.toDueDate ?? null, dateLocale: dateLocale, mergedLabels: (activity as ActivityWithMergedLabels).mergedLabels, attachmentName: (activity as ActivityWithMergedLabels).attachment?.originalFilename ?? null, }); if (activity.type === "card.updated.comment.added") return ( ); if (!activityText) return null; return (
{index !== allActivities.length - 1 && (
)}

{`${getUserDisplayName(activity.user)} `} {activityText} · {formatDistanceToNow(new Date(activity.createdAt), { addSuffix: true, locale: dateLocale, })}

); })} {hasMore && (
)}
); }; export default ActivityList;