IKHSAN
EN Read in EnglishID

opendesk

Publik
Branch
main
Commit terakhir
08d9f3328 Oktober 2026

TicketTimeline.vue

Buka raw
<template>
  <div class="relative flex min-h-0 flex-1 flex-col">
    <!-- floats over the feed, so pins coming and going never move the rows -->
    <PinnedCommentsBar
      class="absolute inset-x-0 top-0 z-10"
      :comments="pinnedComments"
      :jump="goToPinned"
    />
    <TimelineContainer ref="timelineContainer">
      <ActivityTimeline
        ref="timelineRef"
        class="px-5 pb-6"
        :class="pinnedComments.length ? 'scroll-pt-14 pt-14' : 'pt-5'"
        :activities="filtered"
        :loading="_loading"
        :paginate="paginate"
      >
        <template #empty>
          <div
            class="flex h-full flex-col items-center justify-center gap-2 text-ink-gray-4"
          >
            <component :is="emptyIcon" class="size-6" />
            <p class="text-p-base text-ink-gray-5">
              {{ __("No {0} yet", [tabLabel]) }}
            </p>
          </div>
        </template>

        <!-- reuse the framework row, bolding tag names alongside the actor -->
        <template #item-log="{ activity }">
          <LogItem :activity="withTagNamesBold(activity)" />
        </template>

        <template #item-comment="{ activity }">
          <TimelineCommentRow
            :activity="activity"
            :extras="extrasFor(activity)"
            :pinned="pinnedNames.has(activity.data.name)"
            :pin-count="pinnedNames.size"
            @update="refresh"
          />
        </template>

        <template #item-email="{ activity }">
          <EmailItem :email="withEmailFixups(activity)">
            <template #actions>
              <Button
                variant="ghost"
                :tooltip="__('Reply')"
                @click="reply(activity)"
              >
                <template #icon><ReplyIcon class="text-ink-gray-7" /></template>
              </Button>
              <Button
                variant="ghost"
                :tooltip="__('Reply All')"
                @click="replyAll(activity)"
              >
                <template #icon
                  ><ReplyAllIcon class="text-ink-gray-7"
                /></template>
              </Button>
              <Dropdown align="end" :options="emailOptions(activity)">
                <Button
                  icon="lucide-more-horizontal"
                  class="!text-ink-gray-7"
                  variant="ghost"
                />
              </Dropdown>
            </template>
          </EmailItem>
        </template>

        <template #item-call="{ activity }">
          <CallArea :activity="activity.data" />
        </template>
        <template #icon-call="{ activity }">
          <LucidePhoneIncoming
            v-if="activity.data.call_type === 'Incoming'"
            class="size-4 text-ink-gray-5"
          />
          <LucidePhoneOutgoing v-else class="size-4 text-ink-gray-5" />
        </template>
      </ActivityTimeline>
    </TimelineContainer>
  </div>
  <TicketSplitModal
    :modelValue="Boolean(splitCommunication)"
    @update:modelValue="splitCommunication = ''"
    :ticket_id="ticketId"
    :communication_id="splitCommunication"
  />
</template>

<script lang="ts">
import type { VisibleTypes } from "@framework/ui/ActivityTimeline";

// the only field changes worth a feed row; everything else is noise the old
// feed never showed
const VERSION_FIELDS = [
  "status",
  "priority",
  "agent_group",
  "ticket_type",
  "sla",
];

// one key for every tab: the composable caches resources per visibleTypes, so
// all tab instances share a single fetch; tabs filter client-side
// live rows already patched with their files; module-level so force-mounted
// tab instances don't refetch the same row
const enrichedRows = new Set<string>();

// clicking the pinned bar scrolls the feed to that comment over this long
const PIN_JUMP_MS = 300;

export const SHARED_VISIBLE_TYPES: VisibleTypes = [
  "email",
  "comment",
  "log",
  { version: VERSION_FIELDS },
];
</script>

<script setup lang="ts">
import CallArea from "@/components/CallArea.vue";
import {
  ActivityIcon,
  CommentIcon,
  EmailIcon,
  PhoneIcon,
  ReplyAllIcon,
  ReplyIcon,
} from "@/components/icons";
import TicketSplitModal from "@/components/ticket/TicketSplitModal.vue";
import {
  registerTicketFeed,
  useTicket,
  type PinnedComment,
} from "@/composables/useTicket";
import { useAuthStore } from "@/stores/auth";
import { globalStore } from "@/stores/globalStore";
import { useUserStore } from "@/stores/user";
import { __ } from "@/translation";
import { TicketSymbol } from "@/types";
import { copyActivityLink } from "@/utils";
import { plainTextToHtml } from "@opendesk/shared/utils";
import {
  ActivityTimeline,
  EmailItem,
  LogItem,
  TimelineContainer,
  useActivityTimeline,
  type Activity,
  type CommentActivity,
  type CustomActivity,
  type EmailActivity,
  type LogActivity,
} from "@framework/ui/ActivityTimeline";
import { Button, Dropdown, call } from "frappe-ui";
import {
  computed,
  inject,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  useTemplateRef,
  watch,
} from "vue";
import { useRoute, useRouter } from "vue-router";
import LucideSplit from "~icons/lucide/split";
import { glideToRow } from "./glideToRow";
import PinnedCommentsBar from "./PinnedCommentsBar.vue";
import TimelineCommentRow, { CommentExtras } from "./TimelineCommentRow.vue";

const props = defineProps<{
  ticketId: string;
  tab: string;
  tabLabel: string;
}>();
const emit = defineEmits<{ "email:reply": [payload: object] }>();

const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const { getUser } = useUserStore();
const { $socket } = globalStore();
const ticket = inject(TicketSymbol)!;

const splitCommunication = ref("");
const timelineRef =
  useTemplateRef<InstanceType<typeof ActivityTimeline>>("timelineRef");
const timelineContainer =
  useTemplateRef<InstanceType<typeof TimelineContainer>>("timelineContainer");

const { activities, loading, paginate, reload } = useActivityTimeline(
  "OD Ticket",
  props.ticketId,
  SHARED_VISIBLE_TYPES
);

const { calls, commentExtras: extras } = useTicket(props.ticketId);
const pinnedNames = computed(() => new Set(extras.data?.pinned_comments ?? []));
// the feed already holds every comment, so the bar reads their text from it
const pinnedComments = computed<PinnedComment[]>(() => {
  const contents = new Map(
    activities.value
      .filter(
        (activity): activity is CommentActivity => activity.type === "comment"
      )
      .map((activity) => [activity.data.name, activity.data.content])
  );
  return (extras.data?.pinned_comments ?? [])
    .filter((name) => contents.has(name))
    .map((name) => ({ name, content: contents.get(name) as string }));
});

const _loading = computed(() => loading.value || calls.loading);

const feed = computed<Array<Activity | CustomActivity>>(() => {
  const callRows: CustomActivity[] = (calls.data ?? []).map((call: any) => ({
    type: "call",
    key: `call:${call.name}`,
    timestamp: call.creation,
    data: { ...call, call_type: call.type },
  }));
  return [...activities.value, ...callRows].sort((a, b) =>
    (a.timestamp ?? "").localeCompare(b.timestamp ?? "")
  );
});

// version fieldnames and noisy types are trimmed server-side (shared fetch);
// per-tab type selection and the created/edited log cut happen here
const filtered = computed(() => {
  const rows = feed.value.filter(
    (a) =>
      !(a.type === "log" && ["edited", "created"].includes(a.data?.subtype))
  );
  if (props.tab === "activity") return rows;
  return rows.filter((a) => a.type === props.tab);
});

const emptyIcon = computed(
  () =>
    ({
      email: EmailIcon,
      comment: CommentIcon,
      call: PhoneIcon,
    }[props.tab] ?? ActivityIcon)
);

function emailOptions(activity: EmailActivity) {
  return [
    {
      label: __("Copy link"),
      icon: "lucide-link",
      onClick: () => copyActivityLink("communication", activity.data.name),
    },
    ...(showSplitOption(activity)
      ? [
          {
            label: __("Split Ticket"),
            icon: LucideSplit,
            onClick: () => (splitCommunication.value = activity.data.name),
          },
        ]
      : []),
  ];
}

function extrasFor(activity: CommentActivity): CommentExtras {
  // a pending comment has no name yet, so its attachments ride on the row
  if (activity.pending)
    return { reactions: [], attachments: activity.data.attachments ?? [] };
  return (
    extras.data?.comments[activity.data.name] ?? {
      reactions: [],
      attachments: [],
    }
  );
}

function refresh() {
  reload();
  extras.reload();
}

// Emails and Calls feeds hold no comments, so the jump lands on Comments
// resolves once the row is on screen, so the pinned bar advances after the jump
async function goToPinned(name: string) {
  const scroller = feedScroller();
  // Emails and Calls feeds hold no comments; the Comments tab takes the deep link
  if (!["activity", "comment"].includes(props.tab) || !scroller) {
    router.replace({
      query: { ...route.query, highlight: `comment-${name}` },
      hash: "#comment",
    });
    return;
  }
  await glideToRow(scroller, `comment:${name}`, PIN_JUMP_MS);
  timelineRef.value?.scrollToRow(`comment:${name}`);
}

function feedScroller() {
  const container = timelineContainer.value?.$el as HTMLElement | undefined;
  return container?.querySelector<HTMLElement>(".activity-timeline");
}

// after send/comment the newest row belongs on screen (old feed parity)
async function refreshAndScroll() {
  await Promise.allSettled([reload(), extras.reload()]);
  nextTick(() => timelineRef.value?.scrollToLatest());
}

// "added tags a, b & removed tag c" -> names into assignees, which splitBold
// renders dark like the actor
function withTagNamesBold(activity: LogActivity): LogActivity {
  if (activity.data?.subtype !== "info") return activity;
  const text = activity.data.text ?? "";
  const names = [...text.matchAll(/(?:added|removed) tags? ([^&]+)/g)]
    .flatMap((m) => m[1].split(","))
    .map((s) => s.trim())
    .filter(Boolean);
  if (!names.length) return activity;
  return {
    ...activity,
    data: {
      ...activity.data,
      assignees: [...(activity.data.assignees ?? []), ...names],
    },
  };
}

function withEmailFixups(activity: EmailActivity): EmailActivity {
  const data = { ...activity.data };
  // portal tickets have no real delivery pipeline; hide the badge
  if (ticket.value?.doc?.via_customer_portal) delete data.deliveryStatus;
  data.content = plainTextToHtml(data.content);
  return { ...activity, data };
}

function isFirstEmail(activity: EmailActivity): boolean {
  const emails = feed.value.filter((a) => a.type === "email");
  return emails[0]?.key === activity.key;
}

function showSplitOption(activity: EmailActivity): boolean {
  return !isFirstEmail(activity) && ticket.value?.doc?.status !== "Closed";
}

// sender is an email address; userId may be the raw session user
function isSelf(email: string): boolean {
  return (
    authStore.userId === email || getUser(authStore.userId)?.email === email
  );
}

function reply(activity: EmailActivity) {
  const { sender, content } = activity.data;
  emit("email:reply", {
    content,
    to: isSelf(sender) ? activity.data.to : sender,
  });
}

function replyAll(activity: EmailActivity) {
  const { sender, to, cc, bcc, content } = activity.data;
  const exclude = [authStore.userId, getUser(authStore.userId)?.email, sender];
  const filteredTo = splitRecipients(to, exclude);
  const filteredCc = splitRecipients(cc, exclude);
  const filteredBcc = splitRecipients(bcc, exclude);
  const isSender = isSelf(sender);
  emit("email:reply", {
    content,
    to: isSender ? filteredTo.join(", ") : sender,
    cc: isSender ? filteredCc : [...filteredTo, ...filteredCc],
    bcc: filteredBcc,
  });
}

// comma split that respects quoted display names: '"Kapoor, Riya" <r@x>'
function splitRecipients(
  field: string | undefined,
  exclude: string[]
): string[] {
  const parts: string[] = [];
  let current = "";
  let inQuotes = false;
  for (const char of field ?? "") {
    if (char === '"') {
      inQuotes = !inQuotes;
      current += char;
    } else if (char === "," && !inQuotes) {
      parts.push(current.trim());
      current = "";
    } else {
      current += char;
    }
  }
  if (current) parts.push(current.trim());
  return parts.filter(Boolean).filter((item) => !exclude.includes(item));
}

// deep links arrive as ?highlight=comment-<name> / communication-<name>;
// framework row ids are comment:<name> / email:<name>
let highlightTimer: ReturnType<typeof setTimeout> | undefined;

// The row can reach the DOM a few renders after the feed resolves, so a single
// attempt lands only if it happens to be the last one. Keep trying instead; the
// link stays in the URL until it does, which is also what stops the retries.
function scrollToHighlight(attemptsLeft = 20) {
  clearTimeout(highlightTimer);
  const raw = route.query.highlight;
  if (typeof raw !== "string") return;
  const rowId = raw
    .replace(/^comment-/, "comment:")
    .replace(/^communication-/, "email:");
  if (timelineRef.value?.scrollToRow(rowId)) {
    const query = { ...route.query };
    delete query.highlight;
    router.replace({ query, hash: route.hash });
    return;
  }
  if (attemptsLeft)
    highlightTimer = setTimeout(() => scrollToHighlight(attemptsLeft - 1), 100);
}

// opening at the newest row is the timeline's own job (useTimelineScroll).
// immediate + post: a deep link that is already settled on arrival still fires,
// and a fire that follows a render sees the rows it produced.
watch(
  () => [route.query.highlight, _loading.value, filtered.value.length],
  () => {
    if (_loading.value || !route.query.highlight) return;
    scrollToHighlight();
  },
  { flush: "post", immediate: true }
);

onBeforeUnmount(() => clearTimeout(highlightTimer));

// the socket payload can't carry attachments (they live on File, joined
// server-side), so a live comment renders bare; fetch its files and patch
// extras, where our footer renders from. One keyed lookup, deduped across
// tab instances.
async function enrichLiveComment(payload: unknown) {
  const { doc, key, action } = (payload ?? {}) as {
    doc?: Record<string, unknown>;
    key?: string;
    action?: string;
  };
  if (key !== "comments" || action !== "add") return;
  if (
    doc?.reference_doctype !== "OD Ticket" ||
    doc?.reference_name !== props.ticketId
  )
    return;
  const name = doc.name as string;
  if (enrichedRows.has(name)) return;
  enrichedRows.add(name);
  const files = await call("frappe.client.get_list", {
    doctype: "File",
    filters: { attached_to_doctype: "Comment", attached_to_name: name },
    fields: ["file_name", "file_url", "is_private"],
  });
  // an inline image is attached so readers can load it, but it is not a chip
  const content = String(doc.content ?? "");
  const attachments = files?.filter(
    (f: { file_url: string }) => !content.includes(f.file_url)
  );
  if (!attachments?.length || !extras.data) return;
  extras.data.comments[name] = {
    ...(extras.data.comments[name] ?? { reactions: [] }),
    attachments,
  };
}

// another agent pinned, unpinned, edited or deleted a pinned comment
function reloadPinsOnChange(payload: unknown) {
  const { doc, key, action } = (payload ?? {}) as {
    doc?: Record<string, unknown>;
    key?: string;
    action?: string;
  };
  if (key !== "comments" || action === "add") return;
  if (doc?.reference_name !== props.ticketId) return;
  if (doc.is_pinned || pinnedNames.value.has(doc.name as string))
    extras.reload();
}

function onReactionUpdate(data: { ticket_id: string }) {
  if (data.ticket_id === props.ticketId) extras.reload();
}

let unregisterFeed: () => void;

onMounted(() => {
  unregisterFeed = registerTicketFeed(props.ticketId, refreshAndScroll);
  $socket.on("opendesk:comment-reaction-update", onReactionUpdate);
  $socket.on("docinfo_update", enrichLiveComment);
  $socket.on("docinfo_update", reloadPinsOnChange);
});

onBeforeUnmount(() => {
  unregisterFeed?.();
  $socket.off("opendesk:comment-reaction-update", onReactionUpdate);
  $socket.off("docinfo_update", enrichLiveComment);
  $socket.off("docinfo_update", reloadPinsOnChange);
});

defineExpose({ reload: refreshAndScroll });
</script>
byte
16.361
baris
514
Jenis
vue
Commit terakhir
28 Oktober 2026