1<template>
2 <CommentItem
3 :comment="activity"
4 :editable="editing"
5 @save="saveContent"
6 @discard="editing = false"
7 >
8 <!-- the framework header has no spot beside the name, so the row draws its own -->
9 <template #header>
10 <div class="flex h-10 items-center justify-between gap-2">
11 <div class="flex min-w-0 items-center gap-2 leading-6">
12 <span class="truncate text-base font-medium text-ink-gray-6">
13 {{ activity.author.fullname }}
14 </span>
15 <Badge v-if="pinned" theme="gray" :label="__('Pinned')">
16 <template #prefix><PinIcon class="size-3" /></template>
17 </Badge>
18 </div>
19 <div class="flex items-center gap-2">
20 <Tooltip :text="postedAt.format(TOOLTIP_DATE_FORMAT)">
21 <span class="whitespace-nowrap text-sm leading-6 text-ink-gray-5">
22 {{ postedAt.fromNow() }}
23 </span>
24 </Tooltip>
25 <!-- -me-1.5 matches the tighter edge the framework gives a header with actions -->
26 <div v-if="!editing" class="-me-1.5">
27 <Dropdown
28 align="end"
29 :options="kebabOptions"
30 @update:open="(open) => open && (isConfirmingDelete = false)"
31 >
32 <Button
33 icon="lucide-more-horizontal"
34 class="text-ink-gray-5"
35 variant="ghost"
36 />
37 </Dropdown>
38 </div>
39 </div>
40 </div>
41 </template>
42 <template #footer>
43 <div v-if="extras.attachments.length" class="flex flex-wrap gap-2 mt-2">
44 <AttachmentChip
45 v-for="file in extras.attachments"
46 :key="file.file_url"
47 :label="file.file_name"
48 :url="file.file_url"
49 />
50 </div>
51 <TimelineReactions
52 v-if="reactionsEnabled"
53 :reactions="extras.reactions"
54 @toggle="toggleReaction"
55 />
56 </template>
57 </CommentItem>
58</template>
59
60<script setup lang="ts">
61import { PinIcon, UnpinIcon } from "@/components/icons";
62import { useAuthStore } from "@/stores/auth";
63import { useConfigStore } from "@/stores/config";
64import { useUserStore } from "@/stores/user";
65import { __ } from "@/translation";
66import { ConfirmDelete, copyActivityLink, isContentEmpty } from "@/utils";
67import {
68 AttachmentChip,
69 CommentItem,
70 type CommentActivity,
71} from "@framework/ui/ActivityTimeline";
72import {
73 Badge,
74 Button,
75 Dropdown,
76 Tooltip,
77 createResource,
78 dayjsLocal,
79 toast,
80} from "frappe-ui";
81import { storeToRefs } from "pinia";
82import { computed, h, ref } from "vue";
83import TimelineReactions from "./TimelineReactions.vue";
84
85export interface CommentExtras {
86 reactions: InstanceType<typeof TimelineReactions>["$props"]["reactions"];
87 attachments: Array<{ file_name: string; file_url: string }>;
88}
89
90// mirrors MAX_PINNED_COMMENTS in opendesk/extends/comment.py, which enforces it
91const MAX_PINNED_COMMENTS = 5;
92// same as the framework's timeline, so every row's time reads alike
93const TOOLTIP_DATE_FORMAT = "ddd, MMM D, YYYY h:mm A";
94
95const props = defineProps<{
96 activity: CommentActivity;
97 extras: CommentExtras;
98 pinned: boolean;
99 pinCount: number;
100}>();
101const emit = defineEmits<{ update: [] }>();
102
103const authStore = useAuthStore();
104const { getUser } = useUserStore();
105const { enableCommentReactions: reactionsEnabled } = storeToRefs(
106 useConfigStore()
107);
108
109const editing = ref(false);
110const isConfirmingDelete = ref(false);
111
112// author.email is the resolved address; userId may be the raw session user
113// ("Administrator"), so match either form
114const isOwner = computed(() => {
115 const authorEmail = props.activity.author?.email;
116 return (
117 authStore.userId === authorEmail ||
118 getUser(authStore.userId)?.email === authorEmail
119 );
120});
121const isMergeMarker = computed(() =>
122 /has been merged with ticket #\d+/.test(props.activity.data.content)
123);
124const postedAt = computed(() => dayjsLocal(props.activity.timestamp));
125const pinLimitReached = computed(
126 () => !props.pinned && props.pinCount >= MAX_PINNED_COMMENTS
127);
128
129const kebabOptions = computed(() => [
130 {
131 label: __("Copy link"),
132 icon: "lucide-link",
133 onClick: () => copyActivityLink("comment", props.activity.data.name),
134 },
135 {
136 label: props.pinned ? __("Unpin") : __("Pin"),
137 icon: h(props.pinned ? UnpinIcon : PinIcon, { class: "size-4" }),
138 onClick: togglePin,
139 ...(pinLimitReached.value && {
140 disabled: true,
141 slots: { label: pinLimitLabel },
142 }),
143 },
144 // only the author edits or deletes; anyone may share the link
145 ...(isOwner.value && !isMergeMarker.value
146 ? [
147 {
148 label: __("Edit"),
149 icon: "lucide-edit-2",
150 onClick: () => (editing.value = true),
151 },
152 ]
153 : []),
154 ...(isOwner.value
155 ? ConfirmDelete({
156 onConfirmDelete: () =>
157 toast.promise(deleteComment.submit(), {
158 loading: __("Deleting comment"),
159 success: __("Comment deleted"),
160 error: __("Could not delete the comment"),
161 }),
162 isConfirmingDelete,
163 })
164 : []),
165]);
166
167const saveComment = createResource({ url: "frappe.client.set_value" });
168const deleteComment = createResource({
169 url: "frappe.client.delete",
170 makeParams: () => ({ doctype: "Comment", name: props.activity.data.name }),
171 onSuccess: () => emit("update"),
172 // the toast below reports the failure; this keeps the global handler off it
173 onError: () => {},
174});
175const reaction = createResource({
176 url: "opendesk.api.comment.toggle_reaction",
177 onSuccess: () => emit("update"),
178});
179
180function saveContent(content: string) {
181 if (isContentEmpty(content)) {
182 toast.error(__("Comment cannot be empty."));
183 return;
184 }
185 saveComment.submit(
186 {
187 doctype: "Comment",
188 name: props.activity.data.name,
189 fieldname: "content",
190 value: content,
191 },
192 {
193 onSuccess: () => {
194 editing.value = false;
195 emit("update");
196 toast.success(__("Comment updated successfully."));
197 },
198 }
199 );
200}
201
202// disabled menu items drop pointer events, so the label opts back in for the tooltip
203function pinLimitLabel() {
204 const reason = __("{0} pinned already. Unpin one to pin this.", [
205 MAX_PINNED_COMMENTS,
206 ]);
207 return h(Tooltip, { text: reason, side: "right" }, () =>
208 h("div", { class: "pointer-events-auto truncate" }, __("Pin"))
209 );
210}
211
212// any agent may pin; the server caps pins per ticket and says so on failure
213function togglePin() {
214 saveComment.submit(
215 {
216 doctype: "Comment",
217 name: props.activity.data.name,
218 fieldname: "is_pinned",
219 value: props.pinned ? 0 : 1,
220 },
221 {
222 onSuccess: () => emit("update"),
223 onError: (error: { messages?: string[] }) =>
224 toast.error(error.messages?.[0] ?? __("Could not pin the comment")),
225 }
226 );
227}
228
229function toggleReaction(emoji: string) {
230 reaction.submit({ comment: props.activity.data.name, emoji });
231}
232</script>
233