Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desk/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

<script setup lang="ts">
import { Dialogs } from "@/components/dialogs";
import { usePushNotifications } from "@/composables/usePushNotifications";
import { useConfigStore } from "@/stores/config";
import { useFavicon } from "@vueuse/core";
import { FrappeUIProvider, setConfig, toast, useTheme } from "frappe-ui";
Expand All @@ -21,6 +22,7 @@ const configStore = useConfigStore();
const { favicon } = storeToRefs(configStore);

useFavicon(favicon);
usePushNotifications();

if (!localStorage.getItem("theme")) {
localStorage.setItem("theme", "light");
Expand Down
14 changes: 14 additions & 0 deletions desk/src/components/notifications/Notifications.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@
>
<span class="text-lg-medium">{{ __("Notifications") }}</span>
<div>
<Tooltip :text="__('Enable browser notifications')">
<Button
v-if="isSupported && permission === 'default'"
theme="gray"
variant="ghost"
@click="enable"
>
<template #icon>
<LucideBellRing class="h-4 w-4" />
</template>
</Button>
</Tooltip>
<Button
theme="blue"
variant="ghost"
Expand Down Expand Up @@ -96,6 +108,7 @@

<script setup lang="ts">
import { UserAvatar } from "@/components";
import { usePushNotifications } from "@/composables/usePushNotifications";
import { dayjs } from "frappe-ui";
import { useNotificationStore } from "@/stores/notification";
import { useSidebarStore } from "@/stores/sidebar";
Expand All @@ -105,6 +118,7 @@ import { ref } from "vue";

const notificationStore = useNotificationStore();
const sidebarStore = useSidebarStore();
const { isSupported, permission, enable } = usePushNotifications();
const target = ref(null);
onClickOutside(
target,
Expand Down
114 changes: 114 additions & 0 deletions desk/src/composables/usePushNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { views } from "@/composables/useView";
import { useAuthStore } from "@/stores/auth";
import { useConfigStore } from "@/stores/config";
import { globalStore } from "@/stores/globalStore";
import { __ } from "@/translation";
import { isCustomerPortal } from "@/utils";
import { ref } from "vue";
import { useRouter } from "vue-router";

type PushPayload = {
notification_type: "Assignment" | "Mention" | "Reaction";
user_from: string;
reference_ticket?: string;
};

const isSupported = typeof window !== "undefined" && "Notification" in window;
const permission = ref<NotificationPermission>(
isSupported ? Notification.permission : "denied"
);

// Module-level guard so the socket handler is registered only once.
let listening = false;

export function usePushNotifications() {
const router = useRouter();
const auth = useAuthStore();
const { $socket } = globalStore();
const configStore = useConfigStore();

function enable() {
if (!isSupported) return;
Notification.requestPermission().then((result) => {
permission.value = result;
});
}

function show(payload: PushPayload) {
if (permission.value !== "granted" || isCustomerPortal.value) return;
// Assignment/reopen land in the agent's "my open tickets" list, so skip the
// popup while they're already looking at such a view. Mentions always fire.
if (payload.notification_type !== "Mention" && onMyOpenTicketsView()) return;

const notification = new Notification(getTitle(payload), {
body: payload.reference_ticket
? `${__("Ticket")} #${payload.reference_ticket}`
: "",
icon: configStore.favicon,
// tag collapses repeats for the same ticket; omit it when absent
// (exactOptionalPropertyTypes disallows an explicit undefined).
...(payload.reference_ticket ? { tag: payload.reference_ticket } : {}),
});

notification.onclick = () => {
window.focus();
notification.close();
if (payload.reference_ticket) {
router.push({
name: "TicketAgent",
params: { ticketId: payload.reference_ticket },
});
}
};
}

// True when the current route is a HD Ticket list whose active view already
// surfaces the agent's freshly assigned/reopened tickets (assignee = me + open).
function onMyOpenTicketsView(): boolean {
const route = router.currentRoute.value;
if (route.name !== "TicketsAgent") return false;

const viewName = route.query.view as string | undefined;
const view = viewName
? views.data?.find((v: any) => v.name === viewName)
: views.data?.find(
(v: any) =>
v.is_default && v.user === auth.userId && v.dt === "HD Ticket"
);

const filters = view?.filters;
return !!filters && assignedToMe(filters) && showsOpen(filters);
}

function assignedToMe(filters: Record<string, any>): boolean {
if (!filters._assign) return false;
const value = JSON.stringify(filters._assign);
return value.includes("@me") || value.includes(auth.userId);
}

function showsOpen(filters: Record<string, any>): boolean {
const status = filters.status_category ?? filters.status;
if (status == null) return true; // no status filter -> open tickets show too
return JSON.stringify(status).includes("Open");
}

if (isSupported && !listening) {
listening = true;
$socket.on("helpdesk:new-notification", show);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

return { isSupported, permission, enable };
}

function getTitle(payload: PushPayload): string {
switch (payload.notification_type) {
case "Mention":
return `${payload.user_from} ${__("mentioned you in a ticket")}`;
case "Assignment":
return `${payload.user_from} ${__("assigned you a ticket")}`;
case "Reaction":
return `${payload.user_from} ${__("reopened a ticket")}`;
default:
return __("New notification");
}
}
51 changes: 38 additions & 13 deletions helpdesk/helpdesk/doctype/hd_notification/hd_notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,43 @@ def get_args(self):
}

def after_insert(self):
if self.notification_type == "Mention":
skip_email_workflow = frappe.db.get_single_value(
"HD Settings", "skip_email_workflow"
)
self.notify_via_email()
self.notify_via_push()

def notify_via_email(self):
if self.notification_type != "Mention":
return

if skip_email_workflow:
return
if frappe.db.get_single_value("HD Settings", "skip_email_workflow"):
return

frappe.sendmail(
recipients=self.user_to,
subject="New notification",
message=self.format_message(),
template="notification",
args=self.get_args(),
)

def notify_via_push(self):
if not self.should_push():
return

# Browser push cue so the agent is alerted even when Helpdesk is not focused.
frappe.publish_realtime(
"helpdesk:new-notification",
message={
"notification_type": self.notification_type,
"user_from": self.get_from() or self.user_from,
"reference_ticket": self.reference_ticket,
},
user=self.user_to,
after_commit=True,
)

frappe.sendmail(
recipients=self.user_to,
subject="New notification",
message=self.format_message(),
template="notification",
args=self.get_args(),
)
def should_push(self):
if self.notification_type in ("Assignment", "Mention"):
return True
# "Reaction" covers both comment emoji-reactions and ticket reopens.
# Only a reopen (no linked comment) is worth a push.
return self.notification_type == "Reaction" and not self.reference_comment
16 changes: 14 additions & 2 deletions helpdesk/helpdesk/doctype/hd_notification/test_hd_notification.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
# Copyright (c) 2022, Frappe Technologies and Contributors
# See license.txt

# import frappe
import frappe
from frappe.tests.utils import FrappeTestCase


class TestHDNotification(FrappeTestCase):
pass
def test_should_push(self):
# Push assignment, mention and ticket reopen; skip comment emoji-reactions.
cases = [
("Assignment", None, True),
("Mention", "comment-1", True),
("Reaction", None, True), # ticket reopen
("Reaction", "comment-1", False), # emoji reaction on a comment
]
for notification_type, reference_comment, expected in cases:
doc = frappe.new_doc("HD Notification")
doc.notification_type = notification_type
doc.reference_comment = reference_comment
self.assertEqual(doc.should_push(), expected, msg=notification_type)
Loading