feat: realtime typing indicator

This commit is contained in:
Ritvik Sardana
2025-09-08 01:49:53 +05:30
parent edcf8cb43d
commit 61b9ee294a
9 changed files with 286 additions and 26 deletions
+17 -2
View File
@@ -105,12 +105,12 @@ import {
createResource,
} from "frappe-ui";
import { useOnboarding } from "frappe-ui/frappe";
import { computed, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { AttachmentItem } from "@/components/";
import { AttachmentIcon } from "@/components/icons/";
import { useTyping } from "@/composables/realtime";
import { useAgentStore } from "@/stores/agent";
import { useAuthStore } from "@/stores/auth";
import { PreserveVideoControls } from "@/tiptap-extensions";
import {
@@ -163,12 +163,27 @@ const props = defineProps({
const emit = defineEmits(["submit", "discard"]);
const newComment = useStorage("commentBoxContent" + props.ticketId, null);
// Initialize typing composable
const { onUserType, cleanup } = useTyping(props.ticketId);
const attachments = ref([]);
const commentEmpty = computed(() => {
return isContentEmpty(newComment.value);
});
const loading = ref(false);
// Watch for changes in comment content to trigger typing events
watch(newComment, (newValue, oldValue) => {
if (newValue !== oldValue && newValue) {
onUserType();
}
});
onBeforeUnmount(() => {
cleanup();
});
const label = computed(() => {
return loading.value ? "Sending..." : props.label;
});
+3 -2
View File
@@ -3,7 +3,7 @@
<div
class="flex justify-between gap-3 border-t px-6 md:px-10 py-4 md:py-2.5"
>
<div class="flex gap-1.5">
<div class="flex gap-1.5 items-center">
<Button
ref="sendEmailRef"
variant="ghost"
@@ -25,6 +25,7 @@
<CommentIcon class="h-4" />
</template>
</Button>
<TypingIndicator :ticketId="ticketId" />
</div>
</div>
<div
@@ -92,7 +93,7 @@
</template>
<script setup lang="ts">
import { CommentTextEditor, EmailEditor } from "@/components";
import { CommentTextEditor, EmailEditor, TypingIndicator } from "@/components";
import { CommentIcon, EmailIcon } from "@/components/icons/";
import { useDevice } from "@/composables";
import { useScreenSize } from "@/composables/screen";
+16 -1
View File
@@ -164,6 +164,7 @@ import {
MultiSelectInput,
} from "@/components";
import { AttachmentIcon, EmailIcon } from "@/components/icons";
import { useTyping } from "@/composables/realtime";
import { useAuthStore } from "@/stores/auth";
import { PreserveVideoControls } from "@/tiptap-extensions";
import {
@@ -184,7 +185,7 @@ import {
toast,
} from "frappe-ui";
import { useOnboarding } from "frappe-ui/frappe";
import { computed, nextTick, ref } from "vue";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
const editorRef = ref(null);
const showCannedResponseSelectorModal = ref(false);
@@ -234,11 +235,25 @@ const newEmail = useStorage("emailBoxContent" + props.ticketId, null);
const { updateOnboardingStep } = useOnboarding("helpdesk");
const { isManager } = useAuthStore();
// Initialize typing composable
const { onUserType, cleanup } = useTyping(props.ticketId);
const attachments = ref([]);
const emailEmpty = computed(() => {
return isContentEmpty(newEmail.value);
});
// Watch for changes in email content to trigger typing events
watch(newEmail, (newValue, oldValue) => {
if (newValue !== oldValue && newValue) {
onUserType();
}
});
onBeforeUnmount(() => {
cleanup();
});
const toEmailsClone = ref([...props.toEmails]);
const ccEmailsClone = ref([...props.ccEmails]);
const bccEmailsClone = ref([...props.bccEmails]);
+108
View File
@@ -0,0 +1,108 @@
<template>
<div v-if="typingUsers.length > 0" class="px-4.5">
<div class="flex items-center gap-2 text-sm text-gray-600">
<div class="flex items-center gap-1">
<div class="typing-dots">
<span></span>
<span></span>
<span></span>
</div>
<component :is="typingMessage" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useTyping } from "@/composables/realtime";
import { useUserStore } from "@/stores/user";
import { computed, h, onBeforeUnmount } from "vue";
import UserAvatar from "./UserAvatar.vue";
const props = defineProps({
ticketId: {
type: String,
required: true,
},
});
const { typingUsers, cleanup } = useTyping(props.ticketId);
const typingMessage = computed(() => {
const count = typingUsers.length;
if (count === 0) return null;
const { getUser } = useUserStore();
let firstUser = getUser(typingUsers[0])?.full_name || typingUsers[0];
if (count === 1) {
return h("div", { class: "flex items-center gap-1" }, [
h(UserAvatar, { name: firstUser, size: "sm" }),
h("span", { class: "text-ink-gray-6 font-medium" }, firstUser),
h("span", { class: "text-ink-gray-5" }, " is typing..."),
]);
} else if (count === 2) {
return h("div", { class: "flex items-center gap-1" }, [
h("span", { class: "text-ink-gray-6 font-medium" }, firstUser),
h("span", { class: "text-ink-gray-5" }, " and "),
h(
"span",
{ class: "text-ink-gray-6 font-medium" },
getUser(typingUsers[1])?.full_name
),
h("span", { class: "text-ink-gray-5" }, " are typing..."),
]);
} else {
return h("div", { class: "flex items-center gap-1" }, [
h("span", { class: "text-ink-gray-6 font-medium" }, firstUser),
h("span", { class: "text-ink-gray-5" }, " and "),
h(
"span",
{ class: "text-ink-gray-6 font-medium" },
`${count - 1} others`
),
h("span", { class: "text-ink-gray-5" }, " are typing..."),
]);
}
});
onBeforeUnmount(() => {
cleanup();
});
</script>
<style scoped>
.typing-dots {
display: inline-flex;
gap: 2px;
align-items: center;
}
.typing-dots span {
height: 4px;
width: 4px;
background-color: #6b7280;
border-radius: 50%;
animation: typing 1.4s infinite ease-in-out;
}
.typing-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.typing-dots span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes typing {
0%,
80%,
100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
</style>
+19 -18
View File
@@ -1,29 +1,30 @@
export { default as AssignmentModal } from "./AssignmentModal.vue";
export { default as AttachmentItem } from "./AttachmentItem.vue";
export { default as Autocomplete } from "./Autocomplete.vue";
export { default as CannedResponseSelectorModal } from "./CannedResponseSelectorModal.vue";
export { default as CommandPalette } from "./command-palette/CP.vue";
export { default as CommentBox } from "./CommentBox.vue";
export { default as CommentTextEditor } from "./CommentTextEditor.vue";
export { default as CommunicationArea } from "./CommunicationArea.vue";
export { default as EmailArea } from "./EmailArea.vue";
export { default as EmailEditor } from "./EmailEditor.vue";
export { default as FadedScrollableDiv } from "./FadedScrollableDiv.vue";
export { default as AutocompleteNew } from "./frappe-ui/Autocomplete.vue";
export { default as Link } from "./frappe-ui/Link.vue";
export { default as HistoryBox } from "./HistoryBox.vue";
export { default as Icon } from "./Icon.vue";
export { default as LayoutHeader } from "./LayoutHeader.vue";
export { default as ListViewBuilder } from "./ListViewBuilder.vue";
export { default as MultipleAvatar } from "./MultipleAvatar.vue";
export { default as MultiSelectInput } from "./MultiSelectInput.vue";
export { default as NestedPopover } from "./NestedPopover.vue";
export { default as Notifications } from "./notifications/Notifications.vue";
export { default as PageTitle } from "./PageTitle.vue";
export { default as SearchComplete } from "./SearchComplete.vue";
export { default as Section } from "./Section.vue";
export { default as SidebarLink } from "./SidebarLink.vue";
export { default as StarRating } from "./StarRating.vue";
export { default as TextEditor } from "./TextEditor.vue";
export { default as TypingIndicator } from "./TypingIndicator.vue";
export { default as UniInput } from "./UniInput.vue";
export { default as UserAvatar } from "./UserAvatar.vue";
export { default as LayoutHeader } from "./LayoutHeader.vue";
export { default as MultipleAvatar } from "./MultipleAvatar.vue";
export { default as EmailEditor } from "./EmailEditor.vue";
export { default as CommentTextEditor } from "./CommentTextEditor.vue";
export { default as MultiSelectInput } from "./MultiSelectInput.vue";
export { default as CommunicationArea } from "./CommunicationArea.vue";
export { default as EmailArea } from "./EmailArea.vue";
export { default as CommentBox } from "./CommentBox.vue";
export { default as HistoryBox } from "./HistoryBox.vue";
export { default as AssignmentModal } from "./AssignmentModal.vue";
export { default as Autocomplete } from "./Autocomplete.vue";
export { default as CannedResponseSelectorModal } from "./CannedResponseSelectorModal.vue";
export { default as FadedScrollableDiv } from "./FadedScrollableDiv.vue";
export { default as AutocompleteNew } from "./frappe-ui/Autocomplete.vue";
export { default as Link } from "./frappe-ui/Link.vue";
export { default as ListViewBuilder } from "./ListViewBuilder.vue";
export { default as Section } from "./Section.vue";
export { default as Icon } from "./Icon.vue";
@@ -22,7 +22,7 @@
:hide-name="true"
/>
<!-- Navigation -->
<TicketNavigation />
<TicketNavigation :key="ticket.name" />
<!-- Custom Actions -->
<div v-if="normalActions.length" class="flex gap-2">
<Button
@@ -114,6 +114,7 @@ import { useRoute, useRouter } from "vue-router";
import LucideMerge from "~icons/lucide/merge";
import LucideTicket from "~icons/lucide/ticket";
import { IndicatorIcon } from "../icons";
import TicketNavigation from "./TicketNavigation.vue";
defineProps({
viewers: {
+96 -1
View File
@@ -1,6 +1,6 @@
import { useAuthStore } from "@/stores/auth";
import { globalStore } from "@/stores/globalStore";
import { reactive } from "vue";
import { reactive, ref, watch } from "vue";
const currentViewers = reactive<Record<string, string[]> | null>({});
@@ -50,3 +50,98 @@ export function useNotifyTicketUpdate(ticketId: string) {
// how to check if $socket is already listening to avoid multiple listeners
return { notifyTicketUpdate };
}
export function useTyping(ticketId: string) {
const { $socket } = globalStore();
const { userId } = useAuthStore();
const typingCounter = ref(0);
const isTyping = ref(false);
const typingUsers = reactive<string[]>([]);
let timeout: any = null;
// Listen for typing events from other users
$socket.on(
"helpdesk_ticket_typing",
(data: { ticket_id: string; user: string }) => {
if (data.ticket_id === ticketId && data.user !== userId) {
// Add user to typing list if not already present
if (!typingUsers.includes(data.user)) {
typingUsers.push(data.user);
}
}
}
);
// Listen for typing stopped events
$socket.on(
"helpdesk_ticket_typing_stopped",
(data: { ticket_id: string; user: string }) => {
if (data.ticket_id === ticketId) {
// Remove user from typing list
const index = typingUsers.indexOf(data.user);
if (index > -1) {
typingUsers.splice(index, 1);
}
}
}
);
const emitStartTyping = () => {
$socket?.emit("helpdesk_ticket_typing", ticketId);
};
const emitStopTyping = () => {
$socket?.emit("helpdesk_ticket_typing_stopped", ticketId);
};
const onUserType = () => {
if (!isTyping.value) {
isTyping.value = true;
typingCounter.value = typingCounter.value + 1;
emitStartTyping();
}
};
// Reset the typing state after 10 seconds of inactivity
// If agent is not typing for 10 seconds, we assume they have stopped typing
watch(typingCounter, (newVal) => {
if (timeout) {
clearTimeout(timeout);
}
if (newVal > 0) {
timeout = setTimeout(() => {
isTyping.value = false;
typingCounter.value = 0;
emitStopTyping();
}, 10000);
}
});
const stopTyping = () => {
emitStopTyping();
isTyping.value = false;
typingCounter.value = 0;
if (timeout) {
clearTimeout(timeout);
}
};
const cleanup = () => {
stopTyping();
if (timeout) {
clearTimeout(timeout);
}
// Remove socket listeners
$socket.off("helpdesk_ticket_typing");
$socket.off("helpdesk_ticket_typing_stopped");
};
return {
stopTyping,
onUserType,
typingUsers,
cleanup,
};
}
+1 -1
View File
@@ -69,7 +69,7 @@ const communications = computed(() => {
function scroll(id: string) {
const e = document.getElementById(id);
if (!isElementInViewport(e)) {
e.scrollIntoView();
e.scrollIntoViewIfNeeded();
}
}
+24
View File
@@ -47,6 +47,29 @@ function helpdesk_handlers(socket) {
value,
});
});
// Typing indicators
socket.on("helpdesk_ticket_typing", (ticket_id) => {
if (!ticket_id) return;
const ticket_room = open_doc_room("HD Ticket", ticket_id);
// Broadcast to all other users in the ticket room that this user is typing
socket.to(ticket_room).emit("helpdesk_ticket_typing", {
ticket_id,
user: socket.user,
});
});
socket.on("helpdesk_ticket_typing_stopped", (ticket_id) => {
if (!ticket_id) return;
const ticket_room = open_doc_room("HD Ticket", ticket_id);
// Broadcast to all other users in the ticket room that this user stopped typing
socket.to(ticket_room).emit("helpdesk_ticket_typing_stopped", {
ticket_id,
user: socket.user,
});
});
}
function notify_ticket_viewers(args) {
@@ -84,5 +107,6 @@ function notify_ticket_viewers(args) {
const open_doc_room = (doctype, docname) =>
"open_doc:" + doctype + "/" + docname;
const user_room = (user) => "user:" + user;
const ticket_response_room = (ticket_id) => "ticket_response:" + ticket_id;
module.exports = helpdesk_handlers;