@ciridae/ui 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/blocks/comment-thread.js +2 -25
- package/dist/components/blocks/feedback-center-comments.d.ts +3 -2
- package/dist/components/blocks/feedback-center-comments.js +48 -6
- package/dist/components/blocks/feedback-center-sheet-parts.d.ts +3 -2
- package/dist/components/blocks/feedback-center-sheet-parts.js +8 -8
- package/dist/components/blocks/feedback-center-sheet.d.ts +2 -1
- package/dist/components/blocks/feedback-center-sheet.js +1 -1
- package/dist/components/blocks/feedback-detail-sheet.js +2 -2
- package/dist/components/blocks/feedback-metrics.js +1 -1
- package/dist/components/blocks/feedback-tracking-types.d.ts +6 -1
- package/dist/components/blocks/feedback-tracking-types.js +2 -2
- package/dist/components/blocks/feedback-user-detail.js +7 -8
- package/dist/components/blocks/feedback-user-list.js +4 -4
- package/dist/components/blocks/timestamp-format.js +3 -4
- package/dist/components/blocks/use-reveal-thread-end.d.ts +11 -0
- package/dist/components/blocks/use-reveal-thread-end.js +25 -0
- package/package.json +2 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import * as React from "react";
|
|
3
2
|
import { MessageSquareTextIcon } from "lucide-react";
|
|
4
3
|
import { CommentComposer } from "./comment-composer.js";
|
|
5
4
|
import { CommentItem } from "./comment-item.js";
|
|
5
|
+
import { useRevealThreadEnd } from "./use-reveal-thread-end.js";
|
|
6
6
|
import { Skeleton } from "../ui/skeleton.js";
|
|
7
7
|
function CommentThread({ comments, composerAuthor, composerPlaceholder, composerSubmitLabel, emptyDescription = "Start the conversation with a short update or question.", emptyTitle = "No comments yet", isLoading = false, isSubmitting = false, onSubmit, readOnly = false, renderCommentActions, showHeader = true, title = "Comments", }) {
|
|
8
8
|
const { armReveal, endRef } = useRevealThreadEnd(comments.length);
|
|
@@ -14,29 +14,6 @@ function CommentThread({ comments, composerAuthor, composerPlaceholder, composer
|
|
|
14
14
|
: undefined;
|
|
15
15
|
return (_jsxs("section", { className: "relative grid w-full gap-(--panel-gap)", "aria-label": title, children: [showHeader ? _jsx(CommentThreadHeader, { count: comments.length, title: title }) : null, _jsx(CommentThreadBody, { comments: comments, emptyDescription: emptyDescription, emptyTitle: emptyTitle, isLoading: isLoading, renderCommentActions: renderCommentActions }), submitAndReveal ? (_jsx(CommentComposer, { author: composerAuthor, isSubmitting: isSubmitting, onSubmit: submitAndReveal, placeholder: composerPlaceholder, submitLabel: composerSubmitLabel })) : null, _jsx("div", { "aria-hidden": true, className: "absolute bottom-0", ref: endRef })] }));
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
18
|
-
* After a send, the thread follows its own tail: scroll once when the submit
|
|
19
|
-
* resolves, and again when the posted comment actually lands in `comments` —
|
|
20
|
-
* the app may only append it after a refetch.
|
|
21
|
-
*/
|
|
22
|
-
function useRevealThreadEnd(commentCount) {
|
|
23
|
-
const endRef = React.useRef(null);
|
|
24
|
-
// A deadline, not a flag: the follow-up scroll only chases the send it
|
|
25
|
-
// belongs to, so a comment arriving minutes later cannot yank a reader
|
|
26
|
-
// who has scrolled away.
|
|
27
|
-
const armedUntil = React.useRef(0);
|
|
28
|
-
React.useEffect(() => {
|
|
29
|
-
if (armedUntil.current === 0 || Date.now() > armedUntil.current)
|
|
30
|
-
return;
|
|
31
|
-
armedUntil.current = 0;
|
|
32
|
-
endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
33
|
-
}, [commentCount]);
|
|
34
|
-
const armReveal = React.useCallback(() => {
|
|
35
|
-
armedUntil.current = Date.now() + 5000;
|
|
36
|
-
endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
37
|
-
}, []);
|
|
38
|
-
return { armReveal, endRef };
|
|
39
|
-
}
|
|
40
17
|
function CommentThreadHeader({ count, title }) {
|
|
41
18
|
return (_jsxs("header", { className: "flex items-center justify-between gap-3", children: [_jsx("h3", { className: "text-sm font-medium text-foreground", children: title }), _jsx("span", { className: "font-mono text-xs text-muted-foreground tabular-nums", children: count })] }));
|
|
42
19
|
}
|
|
@@ -55,6 +32,6 @@ function CommentSkeletonRow() {
|
|
|
55
32
|
return (_jsxs("div", { className: "flex gap-3 px-1 py-2", children: [_jsx(Skeleton, { className: "size-6 rounded-full" }), _jsxs("div", { className: "grid flex-1 gap-2", children: [_jsx(Skeleton, { className: "h-3 w-40" }), _jsx(Skeleton, { className: "h-3 w-full" }), _jsx(Skeleton, { className: "h-3 w-4/5" })] })] }));
|
|
56
33
|
}
|
|
57
34
|
function CommentThreadEmpty({ description, title }) {
|
|
58
|
-
return (_jsxs("div", { className: "flex min-w-0 items-start gap-2
|
|
35
|
+
return (_jsxs("div", { className: "flex min-w-0 items-start gap-2 py-2", children: [_jsx(MessageSquareTextIcon, { className: "mt-0.5 size-4 shrink-0 text-muted-foreground" }), _jsxs("div", { className: "grid min-w-0 gap-0.5", children: [_jsx("div", { className: "text-sm font-medium text-foreground", children: title }), _jsx("p", { className: "text-sm text-muted-foreground", children: description })] })] }));
|
|
59
36
|
}
|
|
60
37
|
export { CommentThread };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CommentItemData } from "./comment-types.js";
|
|
2
|
-
import type { UserFeedbackItem } from "./feedback-tracking-types.js";
|
|
2
|
+
import type { UserFeedbackItem, UserFeedbackStatusEvent } from "./feedback-tracking-types.js";
|
|
3
3
|
type FeedbackCommentSubmitHandler = (feedback: UserFeedbackItem, body: string) => void | Promise<void>;
|
|
4
4
|
type FeedbackCenterCommentThreadProps = {
|
|
5
5
|
comments: CommentItemData[];
|
|
@@ -9,6 +9,7 @@ type FeedbackCenterCommentThreadProps = {
|
|
|
9
9
|
isCommentsLoading: boolean;
|
|
10
10
|
onRetry?: () => void;
|
|
11
11
|
onSubmitComment?: FeedbackCommentSubmitHandler;
|
|
12
|
+
statusEvents: UserFeedbackStatusEvent[];
|
|
12
13
|
};
|
|
13
|
-
declare function FeedbackCenterCommentThread({ comments, error, feedback, isCommentSubmitting, isCommentsLoading, onRetry, onSubmitComment, }: FeedbackCenterCommentThreadProps): import("@types/react").JSX.Element;
|
|
14
|
+
declare function FeedbackCenterCommentThread({ comments, error, feedback, isCommentSubmitting, isCommentsLoading, onRetry, onSubmitComment, statusEvents, }: FeedbackCenterCommentThreadProps): import("@types/react").JSX.Element;
|
|
14
15
|
export { FeedbackCenterCommentThread, type FeedbackCommentSubmitHandler };
|
|
@@ -1,18 +1,60 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { AlertCircleIcon } from "lucide-react";
|
|
3
|
-
import {
|
|
2
|
+
import { AlertCircleIcon, MessageSquareTextIcon } from "lucide-react";
|
|
3
|
+
import { CommentComposer } from "./comment-composer.js";
|
|
4
|
+
import { CommentItem } from "./comment-item.js";
|
|
5
|
+
import { formatFeedbackTimestamp, formatFeedbackTimestampFull, getFeedbackTimestampDateTime, } from "./feedback-date-format.js";
|
|
6
|
+
import { FeedbackStatusBadge } from "./feedback-status-badge.js";
|
|
7
|
+
import { useRevealThreadEnd } from "./use-reveal-thread-end.js";
|
|
4
8
|
import { Alert, AlertDescription, AlertTitle } from "../ui/alert.js";
|
|
5
9
|
import { Button } from "../ui/button.js";
|
|
6
|
-
|
|
10
|
+
import { Skeleton } from "../ui/skeleton.js";
|
|
11
|
+
function FeedbackCenterCommentThread({ comments, error, feedback, isCommentSubmitting, isCommentsLoading, onRetry, onSubmitComment, statusEvents, }) {
|
|
12
|
+
const activity = mergeFeedbackActivity(statusEvents, comments);
|
|
13
|
+
const { armReveal, endRef } = useRevealThreadEnd(activity.length);
|
|
7
14
|
// A retry in flight shows the thread's loading state, not the stale error.
|
|
8
15
|
if (error && !isCommentsLoading) {
|
|
9
16
|
return (_jsxs(Alert, { variant: "destructive", children: [_jsx(AlertCircleIcon, {}), _jsx(AlertTitle, { children: "Conversation could not be loaded" }), _jsxs(AlertDescription, { className: "grid gap-3", children: [_jsx("span", { children: error.message }), onRetry ? (_jsx(Button, { className: "justify-self-start", onClick: onRetry, size: "sm", type: "button", variant: "outline", children: "Try again" })) : null] })] }));
|
|
10
17
|
}
|
|
11
|
-
|
|
18
|
+
const submitComment = getCommentSubmitHandler(feedback, onSubmitComment, armReveal);
|
|
19
|
+
return (_jsxs("section", { "aria-label": "Activity", className: "relative grid gap-(--panel-gap)", children: [_jsxs("div", { className: "grid gap-1", children: [activity.length === 0 && !isCommentsLoading ? _jsx(FeedbackActivityEmpty, {}) : null, activity.map((entry) => entry.kind === "comment" ? (_jsx(CommentItem, { comment: entry.comment }, entry.comment.id)) : (_jsx(FeedbackStatusActivity, { event: entry.event }, entry.event.id))), isCommentsLoading ? _jsx(FeedbackActivityLoading, {}) : null] }), submitComment ? (_jsx(CommentComposer, { isSubmitting: isCommentSubmitting, onSubmit: submitComment, placeholder: "Write a reply...", submitLabel: "Reply" })) : null, _jsx("div", { "aria-hidden": true, className: "absolute bottom-0", ref: endRef })] }));
|
|
12
20
|
}
|
|
13
|
-
function
|
|
21
|
+
function FeedbackActivityEmpty() {
|
|
22
|
+
return (_jsxs("div", { className: "flex items-start gap-2 py-2 text-sm text-muted-foreground", children: [_jsx(MessageSquareTextIcon, { className: "mt-0.5 size-4 shrink-0" }), _jsx("span", { children: "No activity yet." })] }));
|
|
23
|
+
}
|
|
24
|
+
function mergeFeedbackActivity(statusEvents, comments) {
|
|
25
|
+
return [
|
|
26
|
+
...statusEvents.map((event) => ({
|
|
27
|
+
createdAt: event.createdAt,
|
|
28
|
+
event,
|
|
29
|
+
kind: "status",
|
|
30
|
+
})),
|
|
31
|
+
...comments.map((comment) => ({
|
|
32
|
+
comment,
|
|
33
|
+
createdAt: comment.createdAt,
|
|
34
|
+
kind: "comment",
|
|
35
|
+
})),
|
|
36
|
+
].sort(compareActivityEntries);
|
|
37
|
+
}
|
|
38
|
+
function compareActivityEntries(left, right) {
|
|
39
|
+
const leftTimestamp = getFeedbackTimestampDateTime(left.createdAt) ?? left.createdAt;
|
|
40
|
+
const rightTimestamp = getFeedbackTimestampDateTime(right.createdAt) ?? right.createdAt;
|
|
41
|
+
const timestampOrder = leftTimestamp.localeCompare(rightTimestamp);
|
|
42
|
+
if (timestampOrder !== 0)
|
|
43
|
+
return timestampOrder;
|
|
44
|
+
return left.kind === right.kind ? 0 : left.kind === "status" ? -1 : 1;
|
|
45
|
+
}
|
|
46
|
+
function FeedbackStatusActivity({ event }) {
|
|
47
|
+
return (_jsxs("div", { className: "flex min-w-0 items-center gap-2 py-2 text-xs text-muted-foreground", children: [_jsx("span", { "aria-hidden": true, className: "size-1.5 shrink-0 rounded-full bg-border" }), _jsx("span", { children: "Status changed to" }), _jsx(FeedbackStatusBadge, { status: event.status }), _jsx("time", { className: "ml-auto shrink-0 font-mono tabular-nums", dateTime: getFeedbackTimestampDateTime(event.createdAt), title: formatFeedbackTimestampFull(event.createdAt), children: formatFeedbackTimestamp(event.createdAt) })] }));
|
|
48
|
+
}
|
|
49
|
+
function FeedbackActivityLoading() {
|
|
50
|
+
return (_jsxs("div", { "aria-label": "Loading activity", className: "flex gap-3 px-1 py-2", role: "status", children: [_jsx(Skeleton, { className: "size-6 rounded-full" }), _jsxs("div", { className: "grid flex-1 gap-2", children: [_jsx(Skeleton, { className: "h-3 w-32" }), _jsx(Skeleton, { className: "h-3 w-4/5" })] })] }));
|
|
51
|
+
}
|
|
52
|
+
function getCommentSubmitHandler(feedback, onSubmitComment, armReveal) {
|
|
14
53
|
if (!feedback || !onSubmitComment)
|
|
15
54
|
return undefined;
|
|
16
|
-
return (body) =>
|
|
55
|
+
return async (body) => {
|
|
56
|
+
await onSubmitComment(feedback, body);
|
|
57
|
+
armReveal();
|
|
58
|
+
};
|
|
17
59
|
}
|
|
18
60
|
export { FeedbackCenterCommentThread };
|
|
@@ -3,7 +3,7 @@ import type { CommentItemData } from "./comment-types.js";
|
|
|
3
3
|
import { type FeedbackCommentSubmitHandler } from "./feedback-center-comments.js";
|
|
4
4
|
import { type FeedbackComposerProps } from "./feedback-composer.js";
|
|
5
5
|
import type { FeedbackUrgency } from "./feedback-composer-types.js";
|
|
6
|
-
import type { UserFeedbackItem } from "./feedback-tracking-types.js";
|
|
6
|
+
import type { UserFeedbackItem, UserFeedbackStatusEvent } from "./feedback-tracking-types.js";
|
|
7
7
|
type FeedbackCenterTab = "mine" | "submit";
|
|
8
8
|
type FeedbackCenterView = "detail" | "list";
|
|
9
9
|
type FeedbackCenterComposerProps = Omit<FeedbackComposerProps, "actions" | "formId" | "onCancel" | "onDirtyChange" | "onSubmittedChange" | "onUrgencyChange">;
|
|
@@ -29,10 +29,11 @@ type FeedbackCenterTabsProps = {
|
|
|
29
29
|
onSubmitComment?: FeedbackCommentSubmitHandler;
|
|
30
30
|
selectedFeedback?: UserFeedbackItem;
|
|
31
31
|
renderEvidence?: (item: UserFeedbackItem) => React.ReactNode;
|
|
32
|
+
statusEventsByFeedbackId: Record<string, UserFeedbackStatusEvent[]>;
|
|
32
33
|
view: FeedbackCenterView;
|
|
33
34
|
};
|
|
34
35
|
declare function FeedbackCenterSheetHeader({ unreadReplyCount }: {
|
|
35
36
|
unreadReplyCount: number;
|
|
36
37
|
}): React.JSX.Element;
|
|
37
|
-
declare function FeedbackCenterTabs({ canComment, composerProps, commentsByFeedbackId, commentsError, formId, onDirtyChange, onUrgencyChange, isCommentSubmitting, isCommentsLoading, isLoading, items, itemsError, onBack, onCancel, onSelectFeedback, onRetryComments, onRetryItems, onSubmittedChange, onSubmitComment, selectedFeedback, renderEvidence, view, }: FeedbackCenterTabsProps): React.JSX.Element;
|
|
38
|
+
declare function FeedbackCenterTabs({ canComment, composerProps, commentsByFeedbackId, commentsError, formId, onDirtyChange, onUrgencyChange, isCommentSubmitting, isCommentsLoading, isLoading, items, itemsError, onBack, onCancel, onSelectFeedback, onRetryComments, onRetryItems, onSubmittedChange, onSubmitComment, selectedFeedback, renderEvidence, statusEventsByFeedbackId, view, }: FeedbackCenterTabsProps): React.JSX.Element;
|
|
38
39
|
export { FeedbackCenterSheetHeader, FeedbackCenterTabs, type FeedbackCenterTab, type FeedbackCenterView, };
|
|
@@ -9,8 +9,8 @@ import { TabsContent, TabsList, TabsTrigger } from "../ui/tabs.js";
|
|
|
9
9
|
function FeedbackCenterSheetHeader({ unreadReplyCount }) {
|
|
10
10
|
return (_jsxs(SheetHeader, { className: "gap-3", children: [_jsxs("div", { className: "grid gap-1.5 pr-12", children: [_jsx(SheetTitle, { children: "Feedback" }), _jsx(SheetDescription, { children: "Send feedback and track your submissions in one place." })] }), _jsx(FeedbackCenterTabsList, { unreadReplyCount: unreadReplyCount })] }));
|
|
11
11
|
}
|
|
12
|
-
function FeedbackCenterTabs({ canComment, composerProps, commentsByFeedbackId, commentsError, formId, onDirtyChange, onUrgencyChange, isCommentSubmitting, isCommentsLoading, isLoading, items, itemsError, onBack, onCancel, onSelectFeedback, onRetryComments, onRetryItems, onSubmittedChange, onSubmitComment, selectedFeedback, renderEvidence, view, }) {
|
|
13
|
-
return (_jsxs(_Fragment, { children: [_jsx(FeedbackCenterSubmitTab, { composerProps: composerProps, formId: formId, onCancel: onCancel, onDirtyChange: onDirtyChange, onSubmittedChange: onSubmittedChange, onUrgencyChange: onUrgencyChange }), _jsx(FeedbackCenterMineContent, { commentsByFeedbackId: commentsByFeedbackId, canComment: canComment, commentsError: commentsError, isCommentSubmitting: isCommentSubmitting, isCommentsLoading: isCommentsLoading, isLoading: isLoading, items: items, itemsError: itemsError, onBack: onBack, onSelectFeedback: onSelectFeedback, onRetryComments: onRetryComments, onRetryItems: onRetryItems, onSubmitComment: onSubmitComment, selectedFeedback: selectedFeedback, renderEvidence: renderEvidence, view: view })] }));
|
|
12
|
+
function FeedbackCenterTabs({ canComment, composerProps, commentsByFeedbackId, commentsError, formId, onDirtyChange, onUrgencyChange, isCommentSubmitting, isCommentsLoading, isLoading, items, itemsError, onBack, onCancel, onSelectFeedback, onRetryComments, onRetryItems, onSubmittedChange, onSubmitComment, selectedFeedback, renderEvidence, statusEventsByFeedbackId, view, }) {
|
|
13
|
+
return (_jsxs(_Fragment, { children: [_jsx(FeedbackCenterSubmitTab, { composerProps: composerProps, formId: formId, onCancel: onCancel, onDirtyChange: onDirtyChange, onSubmittedChange: onSubmittedChange, onUrgencyChange: onUrgencyChange }), _jsx(FeedbackCenterMineContent, { commentsByFeedbackId: commentsByFeedbackId, canComment: canComment, commentsError: commentsError, isCommentSubmitting: isCommentSubmitting, isCommentsLoading: isCommentsLoading, isLoading: isLoading, items: items, itemsError: itemsError, onBack: onBack, onSelectFeedback: onSelectFeedback, onRetryComments: onRetryComments, onRetryItems: onRetryItems, onSubmitComment: onSubmitComment, selectedFeedback: selectedFeedback, renderEvidence: renderEvidence, statusEventsByFeedbackId: statusEventsByFeedbackId, view: view })] }));
|
|
14
14
|
}
|
|
15
15
|
function FeedbackCenterTabsList({ unreadReplyCount }) {
|
|
16
16
|
return (_jsxs(TabsList, { className: "w-full", children: [_jsx(TabsTrigger, { value: "submit", children: "Send feedback" }), _jsxs(TabsTrigger, { value: "mine", children: ["My feedback", unreadReplyCount > 0 ? _jsx(Badge, { variant: "secondary", children: unreadReplyCount }) : null] })] }));
|
|
@@ -22,18 +22,18 @@ function FeedbackCenterSubmitTab({ composerProps, formId, onCancel, onDirtyChang
|
|
|
22
22
|
function FeedbackCenterMineContent(props) {
|
|
23
23
|
return (_jsx(TabsContent, { className: "mt-0", value: "mine", children: _jsx(FeedbackCenterMineTab, { ...props }) }));
|
|
24
24
|
}
|
|
25
|
-
function FeedbackCenterMineTab({ canComment, isLoading, commentsByFeedbackId, commentsError, isCommentSubmitting, isCommentsLoading, items, itemsError, onBack, onSelectFeedback, onRetryComments, onRetryItems, onSubmitComment, selectedFeedback, renderEvidence, view, }) {
|
|
25
|
+
function FeedbackCenterMineTab({ canComment, isLoading, commentsByFeedbackId, commentsError, isCommentSubmitting, isCommentsLoading, items, itemsError, onBack, onSelectFeedback, onRetryComments, onRetryItems, onSubmitComment, selectedFeedback, renderEvidence, statusEventsByFeedbackId, view, }) {
|
|
26
26
|
if (view === "detail") {
|
|
27
|
-
return (_jsx(FeedbackCenterDetailPane, { comments:
|
|
27
|
+
return (_jsx(FeedbackCenterDetailPane, { comments: getFeedbackScopedItems(commentsByFeedbackId, selectedFeedback), canComment: canComment, commentsError: commentsError, feedback: selectedFeedback, isCommentSubmitting: isCommentSubmitting, isCommentsLoading: isCommentsLoading, onBack: onBack, onRetryComments: onRetryComments, onSubmitComment: onSubmitComment, renderEvidence: renderEvidence, statusEvents: getFeedbackScopedItems(statusEventsByFeedbackId, selectedFeedback) }));
|
|
28
28
|
}
|
|
29
29
|
return (_jsx(FeedbackUserList, { error: itemsError, isLoading: isLoading, items: items, onSelectFeedback: onSelectFeedback, onRetry: onRetryItems, selectedId: selectedFeedback?.id }));
|
|
30
30
|
}
|
|
31
|
-
function FeedbackCenterDetailPane({ canComment, comments, commentsError, feedback, isCommentSubmitting, isCommentsLoading, onBack, onRetryComments, onSubmitComment, renderEvidence, }) {
|
|
32
|
-
return (_jsx(FeedbackUserDetail, { activityLabel: "
|
|
31
|
+
function FeedbackCenterDetailPane({ canComment, comments, commentsError, feedback, isCommentSubmitting, isCommentsLoading, onBack, onRetryComments, onSubmitComment, renderEvidence, statusEvents, }) {
|
|
32
|
+
return (_jsx(FeedbackUserDetail, { activityLabel: "Activity", activitySlot: _jsx(FeedbackCenterCommentThread, { comments: comments, error: commentsError, feedback: feedback, isCommentSubmitting: isCommentSubmitting, isCommentsLoading: isCommentsLoading, onSubmitComment: feedback && canComment?.(feedback) === false ? undefined : onSubmitComment, onRetry: onRetryComments, statusEvents: statusEvents }), evidenceSlot: feedback ? renderEvidence?.(feedback) : undefined, feedback: feedback, onBack: onBack }));
|
|
33
33
|
}
|
|
34
|
-
function
|
|
34
|
+
function getFeedbackScopedItems(itemsByFeedbackId, feedback) {
|
|
35
35
|
if (!feedback)
|
|
36
36
|
return [];
|
|
37
|
-
return
|
|
37
|
+
return itemsByFeedbackId[feedback.id] ?? [];
|
|
38
38
|
}
|
|
39
39
|
export { FeedbackCenterSheetHeader, FeedbackCenterTabs, };
|
|
@@ -3,7 +3,7 @@ import type { CommentItemData } from "./comment-types.js";
|
|
|
3
3
|
import type { FeedbackCommentSubmitHandler } from "./feedback-center-comments.js";
|
|
4
4
|
import { type FeedbackCenterTab } from "./feedback-center-sheet-parts.js";
|
|
5
5
|
import type { FeedbackComposerProps } from "./feedback-composer.js";
|
|
6
|
-
import type { UserFeedbackItem } from "./feedback-tracking-types.js";
|
|
6
|
+
import type { UserFeedbackItem, UserFeedbackStatusEvent } from "./feedback-tracking-types.js";
|
|
7
7
|
type FeedbackCenterSheetProps = Omit<FeedbackComposerProps, "actions" | "formId" | "onCancel" | "onDirtyChange" | "onSubmittedChange" | "onUrgencyChange"> & {
|
|
8
8
|
canComment?: (item: UserFeedbackItem) => boolean;
|
|
9
9
|
commentsByFeedbackId?: Record<string, CommentItemData[]>;
|
|
@@ -21,6 +21,7 @@ type FeedbackCenterSheetProps = Omit<FeedbackComposerProps, "actions" | "formId"
|
|
|
21
21
|
onOpenChange: (open: boolean) => void;
|
|
22
22
|
open: boolean;
|
|
23
23
|
renderEvidence?: (item: UserFeedbackItem) => React.ReactNode;
|
|
24
|
+
statusEventsByFeedbackId?: Record<string, UserFeedbackStatusEvent[]>;
|
|
24
25
|
};
|
|
25
26
|
declare function FeedbackCenterSheet(props: FeedbackCenterSheetProps): React.JSX.Element;
|
|
26
27
|
export { FeedbackCenterSheet, type FeedbackCenterSheetProps, type FeedbackCenterTab };
|
|
@@ -11,7 +11,7 @@ function FeedbackCenterSheet(props) {
|
|
|
11
11
|
}
|
|
12
12
|
function FeedbackCenterSheetContent({ props, state, }) {
|
|
13
13
|
const { contentProps, requestClose } = useFormSheetActions();
|
|
14
|
-
return (_jsx(SheetContent, { size: "md", ...contentProps, children: _jsxs(Tabs, { className: "min-h-0 flex-1", onValueChange: setFeedbackCenterTab(state.handleTabChange), value: state.activeTab, children: [_jsx(FeedbackCenterSheetHeader, { unreadReplyCount: state.unreadReplyCount }), _jsx(SheetBody, { className: "pb-(--panel-padding)", children: _jsx(FeedbackCenterTabs, { canComment: props.canComment, composerProps: getFeedbackComposerProps(props), commentsByFeedbackId: props.commentsByFeedbackId ?? {}, commentsError: props.commentsError, formId: state.formId, isCommentSubmitting: props.isCommentSubmitting ?? false, isCommentsLoading: props.isCommentsLoading ?? false, isLoading: props.isLoading ?? false, items: props.items, itemsError: props.itemsError, onBack: state.handleBack, onCancel: requestClose, onDirtyChange: state.setIsDirty, onRetryComments: props.onRetryComments, onRetryItems: props.onRetryItems, onSelectFeedback: state.handleSelectFeedback, onSubmitComment: props.onSubmitComment, onSubmittedChange: state.setIsSubmitted, onUrgencyChange: state.setUrgency, renderEvidence: props.renderEvidence, selectedFeedback: state.selectedFeedback, view: state.view }) }), _jsx(FeedbackCenterFooter, { activeTab: state.activeTab, formId: state.formId, isSubmitted: state.isSubmitted, isSubmitting: props.isSubmitting ?? false, onCancel: requestClose, urgency: state.urgency })] }) }));
|
|
14
|
+
return (_jsx(SheetContent, { size: "md", ...contentProps, children: _jsxs(Tabs, { className: "min-h-0 flex-1", onValueChange: setFeedbackCenterTab(state.handleTabChange), value: state.activeTab, children: [_jsx(FeedbackCenterSheetHeader, { unreadReplyCount: state.unreadReplyCount }), _jsx(SheetBody, { className: "pb-(--panel-padding)", children: _jsx(FeedbackCenterTabs, { canComment: props.canComment, composerProps: getFeedbackComposerProps(props), commentsByFeedbackId: props.commentsByFeedbackId ?? {}, commentsError: props.commentsError, formId: state.formId, isCommentSubmitting: props.isCommentSubmitting ?? false, isCommentsLoading: props.isCommentsLoading ?? false, isLoading: props.isLoading ?? false, items: props.items, itemsError: props.itemsError, onBack: state.handleBack, onCancel: requestClose, onDirtyChange: state.setIsDirty, onRetryComments: props.onRetryComments, onRetryItems: props.onRetryItems, onSelectFeedback: state.handleSelectFeedback, onSubmitComment: props.onSubmitComment, onSubmittedChange: state.setIsSubmitted, onUrgencyChange: state.setUrgency, renderEvidence: props.renderEvidence, selectedFeedback: state.selectedFeedback, statusEventsByFeedbackId: props.statusEventsByFeedbackId ?? {}, view: state.view }) }), _jsx(FeedbackCenterFooter, { activeTab: state.activeTab, formId: state.formId, isSubmitted: state.isSubmitted, isSubmitting: props.isSubmitting ?? false, onCancel: requestClose, urgency: state.urgency })] }) }));
|
|
15
15
|
}
|
|
16
16
|
function useFeedbackCenterSheetState(props) {
|
|
17
17
|
const defaultTab = props.defaultTab ?? "submit";
|
|
@@ -27,11 +27,11 @@ function FeedbackDetailContent({ feedback, onStatusChange, pendingStatus, }) {
|
|
|
27
27
|
*/
|
|
28
28
|
function FeedbackDetailStatusSection({ onStatusChange, pendingStatus, status, }) {
|
|
29
29
|
const selectId = React.useId();
|
|
30
|
-
return (_jsxs("div", { className: "grid gap-2", children: [_jsx("label", { className: "text-meta text-muted-foreground", htmlFor: selectId, children: "Status" }), _jsxs(Select, { disabled: pendingStatus !== undefined, onValueChange: (value) => {
|
|
30
|
+
return (_jsxs("div", { className: "grid gap-2", children: [_jsx("label", { className: "text-meta text-muted-foreground", htmlFor: selectId, children: "Status" }), _jsxs(Select, { disabled: status === "withdrawn" || pendingStatus !== undefined, onValueChange: (value) => {
|
|
31
31
|
const next = feedbackTriageStatusValues.find((candidate) => candidate === value);
|
|
32
32
|
if (next && next !== status)
|
|
33
33
|
onStatusChange(next);
|
|
34
|
-
}, value: pendingStatus ?? status, children: [_jsx(SelectTrigger, { className: "w-full", id: selectId, children: _jsx(SelectValue, {}) }),
|
|
34
|
+
}, value: pendingStatus ?? status, children: [_jsx(SelectTrigger, { className: "w-full", id: selectId, children: _jsx(SelectValue, {}) }), _jsxs(SelectContent, { children: [status === "withdrawn" ? (_jsx(SelectItem, { value: "withdrawn", children: feedbackStatusLabels.withdrawn })) : null, feedbackTriageStatusValues.map((value) => (_jsx(SelectItem, { value: value, children: feedbackStatusLabels[value] }, value)))] })] })] }));
|
|
35
35
|
}
|
|
36
36
|
function FeedbackDetailSummary({ feedback }) {
|
|
37
37
|
const submitter = feedback.submittedBy;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { MetricsStrip, MetricsStripDetail, MetricsStripItem, MetricsStripLabel, MetricsStripValue, } from "../ui/metrics-strip.js";
|
|
3
3
|
function FeedbackMetricsStrip({ metrics }) {
|
|
4
|
-
return (_jsxs(MetricsStrip, { className: "md:grid-cols-3 xl:grid-cols-5", children: [_jsx(FeedbackMetricItem, { detail: "Visible tickets", label: "Total", tone: "neutral", value: metrics.total }), _jsx(FeedbackMetricItem, { detail: "Awaiting triage", label: "Received", tone: "warning", value: metrics.received }), _jsx(FeedbackMetricItem, { detail: "Active work", label: "In progress", tone: "info", value: metrics.inProgress }), _jsx(FeedbackMetricItem, { detail: "Needs a decision", label: "Discussion required", tone: "warning", value: metrics.discussionRequired }), _jsx(FeedbackMetricItem, { detail: "
|
|
4
|
+
return (_jsxs(MetricsStrip, { className: "md:grid-cols-3 xl:grid-cols-5", children: [_jsx(FeedbackMetricItem, { detail: "Visible tickets", label: "Total", tone: "neutral", value: metrics.total }), _jsx(FeedbackMetricItem, { detail: "Awaiting triage", label: "Received", tone: "warning", value: metrics.received }), _jsx(FeedbackMetricItem, { detail: "Active work", label: "In progress", tone: "info", value: metrics.inProgress }), _jsx(FeedbackMetricItem, { detail: "Needs a decision", label: "Discussion required", tone: "warning", value: metrics.discussionRequired }), _jsx(FeedbackMetricItem, { detail: "Resolved", label: "Closed", tone: "success", value: metrics.completed })] }));
|
|
5
5
|
}
|
|
6
6
|
function FeedbackMetricItem({ detail, label, tone, value, }) {
|
|
7
7
|
return (_jsxs(MetricsStripItem, { children: [_jsx(MetricsStripLabel, { children: label }), _jsx(MetricsStripValue, { children: value }), _jsx(MetricsStripDetail, { className: feedbackMetricToneClasses[tone], children: detail })] }));
|
|
@@ -43,6 +43,11 @@ type UserFeedbackItem = {
|
|
|
43
43
|
updatedAt?: string;
|
|
44
44
|
urgency?: FeedbackUrgency;
|
|
45
45
|
};
|
|
46
|
+
type UserFeedbackStatusEvent = {
|
|
47
|
+
createdAt: string;
|
|
48
|
+
id: string;
|
|
49
|
+
status: FeedbackStatus;
|
|
50
|
+
};
|
|
46
51
|
type FeedbackMetrics = {
|
|
47
52
|
completed: number;
|
|
48
53
|
discussionRequired: number;
|
|
@@ -94,4 +99,4 @@ declare const feedbackTypeOptions: readonly [{
|
|
|
94
99
|
readonly value: "general";
|
|
95
100
|
}];
|
|
96
101
|
declare function getFeedbackInitials(name: string, email: string | undefined): string;
|
|
97
|
-
export { feedbackStatusLabels, feedbackStatusOptions, feedbackTriageStatusValues, feedbackStatusValues, feedbackTypeLabels, feedbackTypeOptions, getFeedbackInitials, type FeedbackAffectedScope, type FeedbackAttachment, type FeedbackMetrics, type FeedbackStatus, type FeedbackSubmittedBy, type FeedbackTimelineEvent, type FeedbackUrgency, type StakeholderFeedbackItem, type StakeholderFeedbackType, type UserFeedbackItem, };
|
|
102
|
+
export { feedbackStatusLabels, feedbackStatusOptions, feedbackTriageStatusValues, feedbackStatusValues, feedbackTypeLabels, feedbackTypeOptions, getFeedbackInitials, type FeedbackAffectedScope, type FeedbackAttachment, type FeedbackMetrics, type FeedbackStatus, type FeedbackSubmittedBy, type FeedbackTimelineEvent, type FeedbackUrgency, type StakeholderFeedbackItem, type StakeholderFeedbackType, type UserFeedbackItem, type UserFeedbackStatusEvent, };
|
|
@@ -15,12 +15,12 @@ const feedbackTriageStatusValues = [
|
|
|
15
15
|
"no_action",
|
|
16
16
|
];
|
|
17
17
|
const feedbackStatusLabels = {
|
|
18
|
-
completed: "
|
|
18
|
+
completed: "Closed",
|
|
19
19
|
discussion_required: "Discussion required",
|
|
20
20
|
in_progress: "In progress",
|
|
21
21
|
no_action: "No action",
|
|
22
22
|
received: "Received",
|
|
23
|
-
withdrawn: "
|
|
23
|
+
withdrawn: "Canceled",
|
|
24
24
|
};
|
|
25
25
|
const feedbackTypeLabels = {
|
|
26
26
|
bug: "Bug",
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { AlertTriangleIcon, ArrowLeftIcon,
|
|
3
|
-
import { formatFeedbackTimestampFull, getFeedbackTimestampDateTime } from "./feedback-date-format.js";
|
|
2
|
+
import { AlertTriangleIcon, ArrowLeftIcon, MessageSquareTextIcon, PaperclipIcon, } from "lucide-react";
|
|
3
|
+
import { formatFeedbackTimestamp, formatFeedbackTimestampFull, getFeedbackTimestampDateTime, } from "./feedback-date-format.js";
|
|
4
4
|
import { FeedbackStatusBadge } from "./feedback-status-badge.js";
|
|
5
5
|
import { FeedbackTypeBadge } from "./feedback-type-badge.js";
|
|
6
6
|
import { Badge } from "../ui/badge.js";
|
|
7
7
|
import { Button } from "../ui/button.js";
|
|
8
|
-
import { Card } from "../ui/card.js";
|
|
9
8
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty.js";
|
|
10
9
|
import { Item, ItemContent, ItemGroup, ItemGroupItem, ItemMedia, ItemTitle } from "../ui/item.js";
|
|
11
10
|
import { PropertyList, PropertyListItem, PropertyListLabel, PropertyListValue, } from "../ui/property-list.js";
|
|
@@ -24,16 +23,16 @@ function FeedbackUserDetailBackButton({ onBack }) {
|
|
|
24
23
|
return (_jsx(Button, { "aria-label": "Back to my feedback", className: "-ml-2 shrink-0", onClick: onBack, size: "icon", type: "button", variant: "ghost", children: _jsx(ArrowLeftIcon, {}) }));
|
|
25
24
|
}
|
|
26
25
|
function FeedbackUserDetailMeta({ feedback }) {
|
|
27
|
-
return (_jsxs(PropertyList, { children: [_jsx(FeedbackProperty, { label: "Submitted", children: _jsx(FeedbackUserDateValue, { value: feedback.submittedAt }) }),
|
|
26
|
+
return (_jsxs(PropertyList, { children: [_jsx(FeedbackProperty, { label: "Submitted", children: _jsx(FeedbackUserDateValue, { value: feedback.submittedAt }) }), feedback.urgency === "emergency" && feedback.affectedScope ? (_jsx(FeedbackProperty, { label: "Affected scope", children: _jsx("span", { className: "text-xs", children: affectedScopeLabels[feedback.affectedScope] }) })) : null] }));
|
|
28
27
|
}
|
|
29
28
|
function FeedbackUserDetailBody({ feedback }) {
|
|
30
|
-
return (_jsxs(
|
|
29
|
+
return (_jsxs("section", { className: "grid gap-2 border-t pt-4", children: [_jsx("div", { className: "text-meta text-muted-foreground", children: "Feedback" }), _jsx("p", { className: "text-sm whitespace-pre-wrap text-foreground", children: feedback.description })] }));
|
|
31
30
|
}
|
|
32
31
|
function FeedbackProperty({ children, label }) {
|
|
33
32
|
return (_jsxs(PropertyListItem, { children: [_jsx(PropertyListLabel, { children: label }), _jsx(PropertyListValue, { children: children })] }));
|
|
34
33
|
}
|
|
35
34
|
function FeedbackUserDateValue({ value }) {
|
|
36
|
-
return (_jsx("time", { className: "font-mono text-xs tabular-nums", dateTime: getFeedbackTimestampDateTime(value),
|
|
35
|
+
return (_jsx("time", { className: "font-mono text-xs tabular-nums", dateTime: getFeedbackTimestampDateTime(value), title: formatFeedbackTimestampFull(value), children: formatFeedbackTimestamp(value) }));
|
|
37
36
|
}
|
|
38
37
|
function FeedbackUserAttachments({ feedback }) {
|
|
39
38
|
const attachments = feedback.attachments ?? [];
|
|
@@ -50,7 +49,7 @@ function getAttachmentTitle(attachment) {
|
|
|
50
49
|
return (_jsx("a", { className: "underline-offset-4 hover:underline", href: attachment.url, children: attachment.name }));
|
|
51
50
|
}
|
|
52
51
|
function FeedbackUserActivity({ activityLabel, activitySlot, }) {
|
|
53
|
-
return (_jsxs(
|
|
52
|
+
return (_jsxs("section", { className: "grid gap-3 border-t pt-4", children: [_jsxs("div", { className: "grid gap-0.5", children: [_jsx("div", { className: "text-sm font-medium text-foreground", children: activityLabel }), _jsx("p", { className: "text-xs text-muted-foreground", children: "Messages here are visible to you and the support team." })] }), activitySlot] }));
|
|
54
53
|
}
|
|
55
54
|
const affectedScopeLabels = {
|
|
56
55
|
everyone: "Everyone",
|
|
@@ -59,6 +58,6 @@ const affectedScopeLabels = {
|
|
|
59
58
|
unknown: "Not sure",
|
|
60
59
|
};
|
|
61
60
|
function FeedbackUserDetailEmpty() {
|
|
62
|
-
return (_jsx(Empty, { className: "min-h-72
|
|
61
|
+
return (_jsx(Empty, { className: "min-h-72", children: _jsxs(EmptyHeader, { children: [_jsx(EmptyMedia, { variant: "icon", children: _jsx(MessageSquareTextIcon, {}) }), _jsx(EmptyTitle, { children: "Select feedback" }), _jsx(EmptyDescription, { children: "Choose a submission to see its progress and details." })] }) }));
|
|
63
62
|
}
|
|
64
63
|
export { FeedbackUserDetail };
|
|
@@ -17,13 +17,13 @@ function FeedbackUserList({ error, isLoading = false, items, onRetry, onSelectFe
|
|
|
17
17
|
return _jsx(FeedbackUserListError, { error: error, onRetry: onRetry });
|
|
18
18
|
if (items.length === 0)
|
|
19
19
|
return _jsx(FeedbackUserListEmpty, {});
|
|
20
|
-
return (_jsx(ItemGroup, { className: "
|
|
20
|
+
return (_jsx(ItemGroup, { className: "divide-y divide-border", children: items.map((item) => (_jsx(FeedbackUserListItem, { item: item, onSelectFeedback: onSelectFeedback, selected: item.id === selectedId }, item.id))) }));
|
|
21
21
|
}
|
|
22
22
|
function FeedbackUserListError({ error, onRetry }) {
|
|
23
23
|
return (_jsxs(Alert, { variant: "destructive", children: [_jsx(AlertCircleIcon, {}), _jsx(AlertTitle, { children: "Feedback could not be loaded" }), _jsxs(AlertDescription, { className: "grid gap-3", children: [_jsx("span", { children: error.message }), onRetry ? (_jsx(Button, { className: "justify-self-start", onClick: onRetry, size: "sm", type: "button", variant: "outline", children: "Try again" })) : null] })] }));
|
|
24
24
|
}
|
|
25
25
|
function FeedbackUserListItem({ item, onSelectFeedback, selected, }) {
|
|
26
|
-
return (_jsx(ItemGroupItem, { children: _jsx(Item, { "aria-current": selected ? "true" : undefined, asChild: true, className: cn("w-full cursor-pointer text-left", selected && "
|
|
26
|
+
return (_jsx(ItemGroupItem, { children: _jsx(Item, { "aria-current": selected ? "true" : undefined, asChild: true, className: cn("w-full cursor-pointer rounded-none px-0 text-left", selected && "bg-muted/50"), size: "sm", variant: "default", children: _jsx("button", { onClick: () => onSelectFeedback?.(item), type: "button", children: _jsx(FeedbackUserListItemContent, { item: item }) }) }) }));
|
|
27
27
|
}
|
|
28
28
|
function FeedbackUserListItemContent({ item }) {
|
|
29
29
|
return (_jsxs(_Fragment, { children: [_jsxs(ItemContent, { className: "min-w-0", children: [_jsx(ItemTitle, { className: "max-w-full truncate", children: item.subject }), _jsx(ItemDescription, { className: "line-clamp-1", children: item.description })] }), _jsxs(ItemActions, { className: "shrink-0", children: [item.hasUnreadReply ? _jsx(Badge, { variant: "secondary", children: "New activity" }) : null, item.urgency === "emergency" ? (_jsxs(Badge, { variant: "destructive", children: [_jsx(AlertTriangleIcon, {}), "Emergency"] })) : null, _jsx(FeedbackTypeBadge, { type: item.type }), _jsx(FeedbackStatusBadge, { status: item.status })] }), _jsxs(ItemFooter, { children: [_jsx(FeedbackListDate, { label: "Submitted", value: item.submittedAt }), item.updatedAt ? _jsx(FeedbackListDate, { label: "Updated", value: item.updatedAt }) : null] })] }));
|
|
@@ -32,10 +32,10 @@ function FeedbackListDate({ label, value }) {
|
|
|
32
32
|
return (_jsxs("span", { className: "font-mono text-xs text-muted-foreground tabular-nums", children: [label, " ", formatFeedbackTimestamp(value)] }));
|
|
33
33
|
}
|
|
34
34
|
function FeedbackUserListLoading() {
|
|
35
|
-
return (_jsx("div", { "aria-label": "Loading feedback", className: "grid gap-2", role: "status", children: loadingItems.map((item) => (_jsxs("div", { className: "
|
|
35
|
+
return (_jsx("div", { "aria-label": "Loading feedback", className: "grid gap-2", role: "status", children: loadingItems.map((item) => (_jsxs("div", { className: "py-(--panel-padding)", children: [_jsx(Skeleton, { className: "mb-2 h-4 w-2/3" }), _jsx(Skeleton, { className: "mb-4 h-3 w-full" }), _jsx(Skeleton, { className: "h-3 w-1/2" })] }, item))) }));
|
|
36
36
|
}
|
|
37
37
|
function FeedbackUserListEmpty() {
|
|
38
|
-
return (_jsx(Empty, { className: "min-h-72
|
|
38
|
+
return (_jsx(Empty, { className: "min-h-72", children: _jsxs(EmptyHeader, { children: [_jsx(EmptyMedia, { variant: "icon", children: _jsx(InboxIcon, {}) }), _jsx(EmptyTitle, { children: "No feedback yet" }), _jsx(EmptyDescription, { children: "Feedback you submit will appear here with its current progress." })] }) }));
|
|
39
39
|
}
|
|
40
40
|
const loadingItems = ["first", "second", "third"];
|
|
41
41
|
export { FeedbackUserList };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isValid, parseISO } from "date-fns";
|
|
1
2
|
function formatTimestamp(value, options = {}) {
|
|
2
3
|
const date = parseTimestamp(value);
|
|
3
4
|
if (!date)
|
|
@@ -39,10 +40,8 @@ function toUtcTimestamp(value) {
|
|
|
39
40
|
function parseTimestamp(value) {
|
|
40
41
|
if (!value)
|
|
41
42
|
return undefined;
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
return undefined;
|
|
45
|
-
return new Date(timestamp);
|
|
43
|
+
const date = parseISO(toUtcTimestamp(value));
|
|
44
|
+
return isValid(date) ? date : undefined;
|
|
46
45
|
}
|
|
47
46
|
function getTimestampFallback(value, fallback = "No update") {
|
|
48
47
|
return value?.trim() || fallback;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* After a send, a thread follows its own tail: scroll once when the submit
|
|
4
|
+
* resolves, and again when the posted item actually lands in the thread. The
|
|
5
|
+
* app may only append it after a refetch.
|
|
6
|
+
*/
|
|
7
|
+
declare function useRevealThreadEnd(itemCount: number): {
|
|
8
|
+
armReveal: () => void;
|
|
9
|
+
endRef: React.RefObject<HTMLDivElement | null>;
|
|
10
|
+
};
|
|
11
|
+
export { useRevealThreadEnd };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* After a send, a thread follows its own tail: scroll once when the submit
|
|
4
|
+
* resolves, and again when the posted item actually lands in the thread. The
|
|
5
|
+
* app may only append it after a refetch.
|
|
6
|
+
*/
|
|
7
|
+
function useRevealThreadEnd(itemCount) {
|
|
8
|
+
const endRef = React.useRef(null);
|
|
9
|
+
// A deadline, not a flag: the follow-up scroll only chases the send it
|
|
10
|
+
// belongs to, so a comment arriving minutes later cannot yank a reader
|
|
11
|
+
// who has scrolled away.
|
|
12
|
+
const armedUntil = React.useRef(0);
|
|
13
|
+
React.useEffect(() => {
|
|
14
|
+
if (armedUntil.current === 0 || Date.now() > armedUntil.current)
|
|
15
|
+
return;
|
|
16
|
+
armedUntil.current = 0;
|
|
17
|
+
endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
18
|
+
}, [itemCount]);
|
|
19
|
+
const armReveal = React.useCallback(() => {
|
|
20
|
+
armedUntil.current = Date.now() + 5000;
|
|
21
|
+
endRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
22
|
+
}, []);
|
|
23
|
+
return { armReveal, endRef };
|
|
24
|
+
}
|
|
25
|
+
export { useRevealThreadEnd };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ciridae/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"engines": {
|
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
"class-variance-authority": "^0.7.1",
|
|
88
88
|
"clsx": "^2.1.1",
|
|
89
89
|
"cmdk": "^1.1.1",
|
|
90
|
+
"date-fns": "^4.4.0",
|
|
90
91
|
"embla-carousel-react": "^8.6.0",
|
|
91
92
|
"input-otp": "^1.4.2",
|
|
92
93
|
"lucide-react": "^1.23.0",
|