1<template>
2 <div class="relative flex min-h-0 flex-1 flex-col">
3 <!-- floats over the feed, so pins coming and going never move the rows -->
4 <PinnedCommentsBar
5 class="absolute inset-x-0 top-0 z-10"
6 :comments="pinnedComments"
7 :jump="goToPinned"
8 />
9 <TimelineContainer ref="timelineContainer">
10 <ActivityTimeline
11 ref="timelineRef"
12 class="px-5 pb-6"
13 :class="pinnedComments.length ? 'scroll-pt-14 pt-14' : 'pt-5'"
14 :activities="filtered"
15 :loading="_loading"
16 :paginate="paginate"
17 >
18 <template #empty>
19 <div
20 class="flex h-full flex-col items-center justify-center gap-2 text-ink-gray-4"
21 >
22 <component :is="emptyIcon" class="size-6" />
23 <p class="text-p-base text-ink-gray-5">
24 {{ __("No {0} yet", [tabLabel]) }}
25 </p>
26 </div>
27 </template>
28
29 <!-- reuse the framework row, bolding tag names alongside the actor -->
30 <template #item-log="{ activity }">
31 <LogItem :activity="withTagNamesBold(activity)" />
32 </template>
33
34 <template #item-comment="{ activity }">
35 <TimelineCommentRow
36 :activity="activity"
37 :extras="extrasFor(activity)"
38 :pinned="pinnedNames.has(activity.data.name)"
39 :pin-count="pinnedNames.size"
40 @update="refresh"
41 />
42 </template>
43
44 <template #item-email="{ activity }">
45 <EmailItem :email="withEmailFixups(activity)">
46 <template #actions>
47 <Button
48 variant="ghost"
49 :tooltip="__('Reply')"
50 @click="reply(activity)"
51 >
52 <template #icon><ReplyIcon class="text-ink-gray-7" /></template>
53 </Button>
54 <Button
55 variant="ghost"
56 :tooltip="__('Reply All')"
57 @click="replyAll(activity)"
58 >
59 <template #icon
60 ><ReplyAllIcon class="text-ink-gray-7"
61 /></template>
62 </Button>
63 <Dropdown align="end" :options="emailOptions(activity)">
64 <Button
65 icon="lucide-more-horizontal"
66 class="!text-ink-gray-7"
67 variant="ghost"
68 />
69 </Dropdown>
70 </template>
71 </EmailItem>
72 </template>
73
74 <template #item-call="{ activity }">
75 <CallArea :activity="activity.data" />
76 </template>
77 <template #icon-call="{ activity }">
78 <LucidePhoneIncoming
79 v-if="activity.data.call_type === 'Incoming'"
80 class="size-4 text-ink-gray-5"
81 />
82 <LucidePhoneOutgoing v-else class="size-4 text-ink-gray-5" />
83 </template>
84 </ActivityTimeline>
85 </TimelineContainer>
86 </div>
87 <TicketSplitModal
88 :modelValue="Boolean(splitCommunication)"
89 @update:modelValue="splitCommunication = ''"
90 :ticket_id="ticketId"
91 :communication_id="splitCommunication"
92 />
93</template>
94
95<script lang="ts">
96import type { VisibleTypes } from "@framework/ui/ActivityTimeline";
97
98// the only field changes worth a feed row; everything else is noise the old
99// feed never showed
100const VERSION_FIELDS = [
101 "status",
102 "priority",
103 "agent_group",
104 "ticket_type",
105 "sla",
106];
107
108// one key for every tab: the composable caches resources per visibleTypes, so
109// all tab instances share a single fetch; tabs filter client-side
110// live rows already patched with their files; module-level so force-mounted
111// tab instances don't refetch the same row
112const enrichedRows = new Set<string>();
113
114// clicking the pinned bar scrolls the feed to that comment over this long
115const PIN_JUMP_MS = 300;
116
117export const SHARED_VISIBLE_TYPES: VisibleTypes = [
118 "email",
119 "comment",
120 "log",
121 { version: VERSION_FIELDS },
122];
123</script>
124
125<script setup lang="ts">
126import CallArea from "@/components/CallArea.vue";
127import {
128 ActivityIcon,
129 CommentIcon,
130 EmailIcon,
131 PhoneIcon,
132 ReplyAllIcon,
133 ReplyIcon,
134} from "@/components/icons";
135import TicketSplitModal from "@/components/ticket/TicketSplitModal.vue";
136import {
137 registerTicketFeed,
138 useTicket,
139 type PinnedComment,
140} from "@/composables/useTicket";
141import { useAuthStore } from "@/stores/auth";
142import { globalStore } from "@/stores/globalStore";
143import { useUserStore } from "@/stores/user";
144import { __ } from "@/translation";
145import { TicketSymbol } from "@/types";
146import { copyActivityLink } from "@/utils";
147import { plainTextToHtml } from "@opendesk/shared/utils";
148import {
149 ActivityTimeline,
150 EmailItem,
151 LogItem,
152 TimelineContainer,
153 useActivityTimeline,
154 type Activity,
155 type CommentActivity,
156 type CustomActivity,
157 type EmailActivity,
158 type LogActivity,
159} from "@framework/ui/ActivityTimeline";
160import { Button, Dropdown, call } from "frappe-ui";
161import {
162 computed,
163 inject,
164 nextTick,
165 onBeforeUnmount,
166 onMounted,
167 ref,
168 useTemplateRef,
169 watch,
170} from "vue";
171import { useRoute, useRouter } from "vue-router";
172import LucideSplit from "~icons/lucide/split";
173import { glideToRow } from "./glideToRow";
174import PinnedCommentsBar from "./PinnedCommentsBar.vue";
175import TimelineCommentRow, { CommentExtras } from "./TimelineCommentRow.vue";
176
177const props = defineProps<{
178 ticketId: string;
179 tab: string;
180 tabLabel: string;
181}>();
182const emit = defineEmits<{ "email:reply": [payload: object] }>();
183
184const route = useRoute();
185const router = useRouter();
186const authStore = useAuthStore();
187const { getUser } = useUserStore();
188const { $socket } = globalStore();
189const ticket = inject(TicketSymbol)!;
190
191const splitCommunication = ref("");
192const timelineRef =
193 useTemplateRef<InstanceType<typeof ActivityTimeline>>("timelineRef");
194const timelineContainer =
195 useTemplateRef<InstanceType<typeof TimelineContainer>>("timelineContainer");
196
197const { activities, loading, paginate, reload } = useActivityTimeline(
198 "OD Ticket",
199 props.ticketId,
200 SHARED_VISIBLE_TYPES
201);
202
203const { calls, commentExtras: extras } = useTicket(props.ticketId);
204const pinnedNames = computed(() => new Set(extras.data?.pinned_comments ?? []));
205// the feed already holds every comment, so the bar reads their text from it
206const pinnedComments = computed<PinnedComment[]>(() => {
207 const contents = new Map(
208 activities.value
209 .filter(
210 (activity): activity is CommentActivity => activity.type === "comment"
211 )
212 .map((activity) => [activity.data.name, activity.data.content])
213 );
214 return (extras.data?.pinned_comments ?? [])
215 .filter((name) => contents.has(name))
216 .map((name) => ({ name, content: contents.get(name) as string }));
217});
218
219const _loading = computed(() => loading.value || calls.loading);
220
221const feed = computed<Array<Activity | CustomActivity>>(() => {
222 const callRows: CustomActivity[] = (calls.data ?? []).map((call: any) => ({
223 type: "call",
224 key: `call:${call.name}`,
225 timestamp: call.creation,
226 data: { ...call, call_type: call.type },
227 }));
228 return [...activities.value, ...callRows].sort((a, b) =>
229 (a.timestamp ?? "").localeCompare(b.timestamp ?? "")
230 );
231});
232
233// version fieldnames and noisy types are trimmed server-side (shared fetch);
234// per-tab type selection and the created/edited log cut happen here
235const filtered = computed(() => {
236 const rows = feed.value.filter(
237 (a) =>
238 !(a.type === "log" && ["edited", "created"].includes(a.data?.subtype))
239 );
240 if (props.tab === "activity") return rows;
241 return rows.filter((a) => a.type === props.tab);
242});
243
244const emptyIcon = computed(
245 () =>
246 ({
247 email: EmailIcon,
248 comment: CommentIcon,
249 call: PhoneIcon,
250 }[props.tab] ?? ActivityIcon)
251);
252
253function emailOptions(activity: EmailActivity) {
254 return [
255 {
256 label: __("Copy link"),
257 icon: "lucide-link",
258 onClick: () => copyActivityLink("communication", activity.data.name),
259 },
260 ...(showSplitOption(activity)
261 ? [
262 {
263 label: __("Split Ticket"),
264 icon: LucideSplit,
265 onClick: () => (splitCommunication.value = activity.data.name),
266 },
267 ]
268 : []),
269 ];
270}
271
272function extrasFor(activity: CommentActivity): CommentExtras {
273 // a pending comment has no name yet, so its attachments ride on the row
274 if (activity.pending)
275 return { reactions: [], attachments: activity.data.attachments ?? [] };
276 return (
277 extras.data?.comments[activity.data.name] ?? {
278 reactions: [],
279 attachments: [],
280 }
281 );
282}
283
284function refresh() {
285 reload();
286 extras.reload();
287}
288
289// Emails and Calls feeds hold no comments, so the jump lands on Comments
290// resolves once the row is on screen, so the pinned bar advances after the jump
291async function goToPinned(name: string) {
292 const scroller = feedScroller();
293 // Emails and Calls feeds hold no comments; the Comments tab takes the deep link
294 if (!["activity", "comment"].includes(props.tab) || !scroller) {
295 router.replace({
296 query: { ...route.query, highlight: `comment-${name}` },
297 hash: "#comment",
298 });
299 return;
300 }
301 await glideToRow(scroller, `comment:${name}`, PIN_JUMP_MS);
302 timelineRef.value?.scrollToRow(`comment:${name}`);
303}
304
305function feedScroller() {
306 const container = timelineContainer.value?.$el as HTMLElement | undefined;
307 return container?.querySelector<HTMLElement>(".activity-timeline");
308}
309
310// after send/comment the newest row belongs on screen (old feed parity)
311async function refreshAndScroll() {
312 await Promise.allSettled([reload(), extras.reload()]);
313 nextTick(() => timelineRef.value?.scrollToLatest());
314}
315
316// "added tags a, b & removed tag c" -> names into assignees, which splitBold
317// renders dark like the actor
318function withTagNamesBold(activity: LogActivity): LogActivity {
319 if (activity.data?.subtype !== "info") return activity;
320 const text = activity.data.text ?? "";
321 const names = [...text.matchAll(/(?:added|removed) tags? ([^&]+)/g)]
322 .flatMap((m) => m[1].split(","))
323 .map((s) => s.trim())
324 .filter(Boolean);
325 if (!names.length) return activity;
326 return {
327 ...activity,
328 data: {
329 ...activity.data,
330 assignees: [...(activity.data.assignees ?? []), ...names],
331 },
332 };
333}
334
335function withEmailFixups(activity: EmailActivity): EmailActivity {
336 const data = { ...activity.data };
337 // portal tickets have no real delivery pipeline; hide the badge
338 if (ticket.value?.doc?.via_customer_portal) delete data.deliveryStatus;
339 data.content = plainTextToHtml(data.content);
340 return { ...activity, data };
341}
342
343function isFirstEmail(activity: EmailActivity): boolean {
344 const emails = feed.value.filter((a) => a.type === "email");
345 return emails[0]?.key === activity.key;
346}
347
348function showSplitOption(activity: EmailActivity): boolean {
349 return !isFirstEmail(activity) && ticket.value?.doc?.status !== "Closed";
350}
351
352// sender is an email address; userId may be the raw session user
353function isSelf(email: string): boolean {
354 return (
355 authStore.userId === email || getUser(authStore.userId)?.email === email
356 );
357}
358
359function reply(activity: EmailActivity) {
360 const { sender, content } = activity.data;
361 emit("email:reply", {
362 content,
363 to: isSelf(sender) ? activity.data.to : sender,
364 });
365}
366
367function replyAll(activity: EmailActivity) {
368 const { sender, to, cc, bcc, content } = activity.data;
369 const exclude = [authStore.userId, getUser(authStore.userId)?.email, sender];
370 const filteredTo = splitRecipients(to, exclude);
371 const filteredCc = splitRecipients(cc, exclude);
372 const filteredBcc = splitRecipients(bcc, exclude);
373 const isSender = isSelf(sender);
374 emit("email:reply", {
375 content,
376 to: isSender ? filteredTo.join(", ") : sender,
377 cc: isSender ? filteredCc : [...filteredTo, ...filteredCc],
378 bcc: filteredBcc,
379 });
380}
381
382// comma split that respects quoted display names: '"Kapoor, Riya" <r@x>'
383function splitRecipients(
384 field: string | undefined,
385 exclude: string[]
386): string[] {
387 const parts: string[] = [];
388 let current = "";
389 let inQuotes = false;
390 for (const char of field ?? "") {
391 if (char === '"') {
392 inQuotes = !inQuotes;
393 current += char;
394 } else if (char === "," && !inQuotes) {
395 parts.push(current.trim());
396 current = "";
397 } else {
398 current += char;
399 }
400 }
401 if (current) parts.push(current.trim());
402 return parts.filter(Boolean).filter((item) => !exclude.includes(item));
403}
404
405// deep links arrive as ?highlight=comment-<name> / communication-<name>;
406// framework row ids are comment:<name> / email:<name>
407let highlightTimer: ReturnType<typeof setTimeout> | undefined;
408
409// The row can reach the DOM a few renders after the feed resolves, so a single
410// attempt lands only if it happens to be the last one. Keep trying instead; the
411// link stays in the URL until it does, which is also what stops the retries.
412function scrollToHighlight(attemptsLeft = 20) {
413 clearTimeout(highlightTimer);
414 const raw = route.query.highlight;
415 if (typeof raw !== "string") return;
416 const rowId = raw
417 .replace(/^comment-/, "comment:")
418 .replace(/^communication-/, "email:");
419 if (timelineRef.value?.scrollToRow(rowId)) {
420 const query = { ...route.query };
421 delete query.highlight;
422 router.replace({ query, hash: route.hash });
423 return;
424 }
425 if (attemptsLeft)
426 highlightTimer = setTimeout(() => scrollToHighlight(attemptsLeft - 1), 100);
427}
428
429// opening at the newest row is the timeline's own job (useTimelineScroll).
430// immediate + post: a deep link that is already settled on arrival still fires,
431// and a fire that follows a render sees the rows it produced.
432watch(
433 () => [route.query.highlight, _loading.value, filtered.value.length],
434 () => {
435 if (_loading.value || !route.query.highlight) return;
436 scrollToHighlight();
437 },
438 { flush: "post", immediate: true }
439);
440
441onBeforeUnmount(() => clearTimeout(highlightTimer));
442
443// the socket payload can't carry attachments (they live on File, joined
444// server-side), so a live comment renders bare; fetch its files and patch
445// extras, where our footer renders from. One keyed lookup, deduped across
446// tab instances.
447async function enrichLiveComment(payload: unknown) {
448 const { doc, key, action } = (payload ?? {}) as {
449 doc?: Record<string, unknown>;
450 key?: string;
451 action?: string;
452 };
453 if (key !== "comments" || action !== "add") return;
454 if (
455 doc?.reference_doctype !== "OD Ticket" ||
456 doc?.reference_name !== props.ticketId
457 )
458 return;
459 const name = doc.name as string;
460 if (enrichedRows.has(name)) return;
461 enrichedRows.add(name);
462 const files = await call("frappe.client.get_list", {
463 doctype: "File",
464 filters: { attached_to_doctype: "Comment", attached_to_name: name },
465 fields: ["file_name", "file_url", "is_private"],
466 });
467 // an inline image is attached so readers can load it, but it is not a chip
468 const content = String(doc.content ?? "");
469 const attachments = files?.filter(
470 (f: { file_url: string }) => !content.includes(f.file_url)
471 );
472 if (!attachments?.length || !extras.data) return;
473 extras.data.comments[name] = {
474 ...(extras.data.comments[name] ?? { reactions: [] }),
475 attachments,
476 };
477}
478
479// another agent pinned, unpinned, edited or deleted a pinned comment
480function reloadPinsOnChange(payload: unknown) {
481 const { doc, key, action } = (payload ?? {}) as {
482 doc?: Record<string, unknown>;
483 key?: string;
484 action?: string;
485 };
486 if (key !== "comments" || action === "add") return;
487 if (doc?.reference_name !== props.ticketId) return;
488 if (doc.is_pinned || pinnedNames.value.has(doc.name as string))
489 extras.reload();
490}
491
492function onReactionUpdate(data: { ticket_id: string }) {
493 if (data.ticket_id === props.ticketId) extras.reload();
494}
495
496let unregisterFeed: () => void;
497
498onMounted(() => {
499 unregisterFeed = registerTicketFeed(props.ticketId, refreshAndScroll);
500 $socket.on("opendesk:comment-reaction-update", onReactionUpdate);
501 $socket.on("docinfo_update", enrichLiveComment);
502 $socket.on("docinfo_update", reloadPinsOnChange);
503});
504
505onBeforeUnmount(() => {
506 unregisterFeed?.();
507 $socket.off("opendesk:comment-reaction-update", onReactionUpdate);
508 $socket.off("docinfo_update", enrichLiveComment);
509 $socket.off("docinfo_update", reloadPinsOnChange);
510});
511
512defineExpose({ reload: refreshAndScroll });
513</script>
514