feat: realtime typing indicator
This commit is contained in:
@@ -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,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";
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ const communications = computed(() => {
|
||||
function scroll(id: string) {
|
||||
const e = document.getElementById(id);
|
||||
if (!isElementInViewport(e)) {
|
||||
e.scrollIntoView();
|
||||
e.scrollIntoViewIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user