Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
116 changes: 116 additions & 0 deletions desk/src/composables/usePushNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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 {
// Require an explicit open-status filter. Without one, another filter
// (e.g. the "My Feedback" view needs feedback set) can still hide a freshly
// assigned open ticket, so we must not assume the view shows it.
const status = filters.status_category ?? filters.status;
return status != null && 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