feat: realtime typing indicator
This commit is contained in:
@@ -105,12 +105,12 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
} from "frappe-ui";
|
} from "frappe-ui";
|
||||||
import { useOnboarding } from "frappe-ui/frappe";
|
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 { AttachmentItem } from "@/components/";
|
||||||
import { AttachmentIcon } from "@/components/icons/";
|
import { AttachmentIcon } from "@/components/icons/";
|
||||||
|
import { useTyping } from "@/composables/realtime";
|
||||||
import { useAgentStore } from "@/stores/agent";
|
import { useAgentStore } from "@/stores/agent";
|
||||||
|
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { PreserveVideoControls } from "@/tiptap-extensions";
|
import { PreserveVideoControls } from "@/tiptap-extensions";
|
||||||
import {
|
import {
|
||||||
@@ -163,12 +163,27 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(["submit", "discard"]);
|
const emit = defineEmits(["submit", "discard"]);
|
||||||
|
|
||||||
const newComment = useStorage("commentBoxContent" + props.ticketId, null);
|
const newComment = useStorage("commentBoxContent" + props.ticketId, null);
|
||||||
|
|
||||||
|
// Initialize typing composable
|
||||||
|
const { onUserType, cleanup } = useTyping(props.ticketId);
|
||||||
|
|
||||||
const attachments = ref([]);
|
const attachments = ref([]);
|
||||||
const commentEmpty = computed(() => {
|
const commentEmpty = computed(() => {
|
||||||
return isContentEmpty(newComment.value);
|
return isContentEmpty(newComment.value);
|
||||||
});
|
});
|
||||||
const loading = ref(false);
|
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(() => {
|
const label = computed(() => {
|
||||||
return loading.value ? "Sending..." : props.label;
|
return loading.value ? "Sending..." : props.label;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div
|
<div
|
||||||
class="flex justify-between gap-3 border-t px-6 md:px-10 py-4 md:py-2.5"
|
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
|
<Button
|
||||||
ref="sendEmailRef"
|
ref="sendEmailRef"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
<CommentIcon class="h-4" />
|
<CommentIcon class="h-4" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
|
<TypingIndicator :ticketId="ticketId" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -92,7 +93,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CommentTextEditor, EmailEditor } from "@/components";
|
import { CommentTextEditor, EmailEditor, TypingIndicator } from "@/components";
|
||||||
import { CommentIcon, EmailIcon } from "@/components/icons/";
|
import { CommentIcon, EmailIcon } from "@/components/icons/";
|
||||||
import { useDevice } from "@/composables";
|
import { useDevice } from "@/composables";
|
||||||
import { useScreenSize } from "@/composables/screen";
|
import { useScreenSize } from "@/composables/screen";
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ import {
|
|||||||
MultiSelectInput,
|
MultiSelectInput,
|
||||||
} from "@/components";
|
} from "@/components";
|
||||||
import { AttachmentIcon, EmailIcon } from "@/components/icons";
|
import { AttachmentIcon, EmailIcon } from "@/components/icons";
|
||||||
|
import { useTyping } from "@/composables/realtime";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { PreserveVideoControls } from "@/tiptap-extensions";
|
import { PreserveVideoControls } from "@/tiptap-extensions";
|
||||||
import {
|
import {
|
||||||
@@ -184,7 +185,7 @@ import {
|
|||||||
toast,
|
toast,
|
||||||
} from "frappe-ui";
|
} from "frappe-ui";
|
||||||
import { useOnboarding } from "frappe-ui/frappe";
|
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 editorRef = ref(null);
|
||||||
const showCannedResponseSelectorModal = ref(false);
|
const showCannedResponseSelectorModal = ref(false);
|
||||||
@@ -234,11 +235,25 @@ const newEmail = useStorage("emailBoxContent" + props.ticketId, null);
|
|||||||
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
||||||
const { isManager } = useAuthStore();
|
const { isManager } = useAuthStore();
|
||||||
|
|
||||||
|
// Initialize typing composable
|
||||||
|
const { onUserType, cleanup } = useTyping(props.ticketId);
|
||||||
|
|
||||||
const attachments = ref([]);
|
const attachments = ref([]);
|
||||||
const emailEmpty = computed(() => {
|
const emailEmpty = computed(() => {
|
||||||
return isContentEmpty(newEmail.value);
|
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 toEmailsClone = ref([...props.toEmails]);
|
||||||
const ccEmailsClone = ref([...props.ccEmails]);
|
const ccEmailsClone = ref([...props.ccEmails]);
|
||||||
const bccEmailsClone = ref([...props.bccEmails]);
|
const bccEmailsClone = ref([...props.bccEmails]);
|
||||||
|
|||||||
108
desk/src/components/TypingIndicator.vue
Normal file
108
desk/src/components/TypingIndicator.vue
Normal 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>
|
||||||
@@ -1,29 +1,30 @@
|
|||||||
|
export { default as AssignmentModal } from "./AssignmentModal.vue";
|
||||||
export { default as AttachmentItem } from "./AttachmentItem.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 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 NestedPopover } from "./NestedPopover.vue";
|
||||||
export { default as Notifications } from "./notifications/Notifications.vue";
|
export { default as Notifications } from "./notifications/Notifications.vue";
|
||||||
export { default as PageTitle } from "./PageTitle.vue";
|
export { default as PageTitle } from "./PageTitle.vue";
|
||||||
export { default as SearchComplete } from "./SearchComplete.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 SidebarLink } from "./SidebarLink.vue";
|
||||||
export { default as StarRating } from "./StarRating.vue";
|
export { default as StarRating } from "./StarRating.vue";
|
||||||
export { default as TextEditor } from "./TextEditor.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 UniInput } from "./UniInput.vue";
|
||||||
export { default as UserAvatar } from "./UserAvatar.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"
|
:hide-name="true"
|
||||||
/>
|
/>
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<TicketNavigation />
|
<TicketNavigation :key="ticket.name" />
|
||||||
<!-- Custom Actions -->
|
<!-- Custom Actions -->
|
||||||
<div v-if="normalActions.length" class="flex gap-2">
|
<div v-if="normalActions.length" class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -114,6 +114,7 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import LucideMerge from "~icons/lucide/merge";
|
import LucideMerge from "~icons/lucide/merge";
|
||||||
import LucideTicket from "~icons/lucide/ticket";
|
import LucideTicket from "~icons/lucide/ticket";
|
||||||
import { IndicatorIcon } from "../icons";
|
import { IndicatorIcon } from "../icons";
|
||||||
|
import TicketNavigation from "./TicketNavigation.vue";
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
viewers: {
|
viewers: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { globalStore } from "@/stores/globalStore";
|
import { globalStore } from "@/stores/globalStore";
|
||||||
import { reactive } from "vue";
|
import { reactive, ref, watch } from "vue";
|
||||||
|
|
||||||
const currentViewers = reactive<Record<string, string[]> | null>({});
|
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
|
// how to check if $socket is already listening to avoid multiple listeners
|
||||||
return { notifyTicketUpdate };
|
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) {
|
function scroll(id: string) {
|
||||||
const e = document.getElementById(id);
|
const e = document.getElementById(id);
|
||||||
if (!isElementInViewport(e)) {
|
if (!isElementInViewport(e)) {
|
||||||
e.scrollIntoView();
|
e.scrollIntoViewIfNeeded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,29 @@ function helpdesk_handlers(socket) {
|
|||||||
value,
|
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) {
|
function notify_ticket_viewers(args) {
|
||||||
@@ -84,5 +107,6 @@ function notify_ticket_viewers(args) {
|
|||||||
const open_doc_room = (doctype, docname) =>
|
const open_doc_room = (doctype, docname) =>
|
||||||
"open_doc:" + doctype + "/" + docname;
|
"open_doc:" + doctype + "/" + docname;
|
||||||
const user_room = (user) => "user:" + user;
|
const user_room = (user) => "user:" + user;
|
||||||
|
const ticket_response_room = (ticket_id) => "ticket_response:" + ticket_id;
|
||||||
|
|
||||||
module.exports = helpdesk_handlers;
|
module.exports = helpdesk_handlers;
|
||||||
|
|||||||
Reference in New Issue
Block a user