import $ from "jquery"; import _ from "lodash"; import render_compose_notification from "../templates/compose_notification.hbs"; import * as alert_words from "./alert_words"; import * as blueslip from "./blueslip"; import * as channel from "./channel"; import * as favicon from "./favicon"; import {i18n} from "./i18n"; import * as message_lists from "./message_lists"; import * as message_store from "./message_store"; import * as muting from "./muting"; import * as narrow from "./narrow"; import * as narrow_state from "./narrow_state"; import * as navigate from "./navigate"; import {page_params} from "./page_params"; import * as people from "./people"; import * as settings_config from "./settings_config"; import * as spoilers from "./spoilers"; import * as stream_data from "./stream_data"; import * as stream_ui_updates from "./stream_ui_updates"; import * as ui from "./ui"; import * as unread from "./unread"; import * as unread_ops from "./unread_ops"; const notice_memory = new Map(); // When you start Zulip, window_focused should be true, but it might not be the // case after a server-initiated reload. let window_focused = document.hasFocus && document.hasFocus(); let NotificationAPI; export function set_notification_api(n) { NotificationAPI = n; } if (window.electron_bridge && window.electron_bridge.new_notification) { class ElectronBridgeNotification extends EventTarget { constructor(title, options) { super(); Object.assign( this, window.electron_bridge.new_notification(title, options, (type, eventInit) => this.dispatchEvent(new Event(type, eventInit)), ), ); } static get permission() { return Notification.permission; } static async requestPermission(callback) { if (callback) { callback(await Promise.resolve(Notification.permission)); } return Notification.permission; } } NotificationAPI = ElectronBridgeNotification; } else if (window.Notification) { NotificationAPI = window.Notification; } export function get_notifications() { return notice_memory; } export function initialize() { $(window) .on("focus", () => { window_focused = true; for (const notice_mem_entry of notice_memory.values()) { notice_mem_entry.obj.close(); } notice_memory.clear(); // Update many places on the DOM to reflect unread // counts. unread_ops.process_visible(); }) .on("blur", () => { window_focused = false; }); update_notification_sound_source(); } function update_notification_sound_source() { const audio_file_without_extension = "/static/audio/notification_sounds/" + page_params.notification_sound; $("#notification-sound-source-ogg").attr("src", `${audio_file_without_extension}.ogg`); $("#notification-sound-source-mp3").attr("src", `${audio_file_without_extension}.mp3`); // Load it so that it is ready to be played; without this the old sound // is played. $("#notification-sound-audio")[0].load(); } export function permission_state() { if (NotificationAPI === undefined) { // act like notifications are blocked if they do not have access to // the notification API. return "denied"; } return NotificationAPI.permission; } let unread_count = 0; let pm_count = 0; export function redraw_title() { // Update window title to reflect unread messages in current view const new_title = (unread_count ? "(" + unread_count + ") " : "") + narrow.narrow_title + " - " + page_params.realm_name + " - " + "Zulip"; document.title = new_title; } export function update_unread_counts(new_unread_count, new_pm_count) { if (new_unread_count === unread_count && new_pm_count === pm_count) { return; } unread_count = new_unread_count; pm_count = new_pm_count; // Indicate the message count in the favicon favicon.update_favicon(unread_count, pm_count); // Notify the current desktop app's UI about the new unread count. if (window.electron_bridge !== undefined) { window.electron_bridge.send_event("total_unread_count", unread_count); } // TODO: Add a `window.electron_bridge.updatePMCount(new_pm_count);` call? redraw_title(); } export function is_window_focused() { return window_focused; } export function notify_above_composebox(note, link_class, link_msg_id, link_text) { const notification_html = $( render_compose_notification({ note, link_class, link_msg_id, link_text, }), ); clear_compose_notifications(); $("#out-of-view-notification").append(notification_html); $("#out-of-view-notification").show(); } if (window.electron_bridge !== undefined) { // The code below is for sending a message received from notification reply which // is often referred to as inline reply feature. This is done so desktop app doesn't // have to depend on channel.post for setting crsf_token and narrow.by_topic // to narrow to the message being sent. if (window.electron_bridge.set_send_notification_reply_message_supported !== undefined) { window.electron_bridge.set_send_notification_reply_message_supported(true); } window.electron_bridge.on_event("send_notification_reply_message", (message_id, reply) => { const message = message_store.get(message_id); const data = { type: message.type, content: reply, to: message.type === "private" ? message.reply_to : message.stream, topic: message.topic, }; function success() { if (message.type === "stream") { narrow.by_topic(message_id, {trigger: "desktop_notification_reply"}); } else { narrow.by_recipient(message_id, {trigger: "desktop_notification_reply"}); } } function error(error) { window.electron_bridge.send_event("send_notification_reply_message_failed", { data, message_id, error, }); } channel.post({ url: "/json/messages", data, success, error, }); }); } export function process_notification(notification) { let i; let notification_object; let key; let content; let other_recipients; const message = notification.message; let title = message.sender_full_name; let msg_count = 1; let notification_source; // Convert the content to plain text, replacing emoji with their alt text content = $("
").html(message.content); ui.replace_emoji_with_text(content); spoilers.hide_spoilers_in_notification(content); content = content.text(); const topic = message.topic; if (message.is_me_message) { content = message.sender_full_name + content.slice(3); } if (message.type === "private" || message.type === "test-notification") { if ( page_params.pm_content_in_desktop_notifications !== undefined && !page_params.pm_content_in_desktop_notifications ) { content = "New private message from " + message.sender_full_name; } key = message.display_reply_to; other_recipients = message.display_reply_to; // Remove the sender from the list of other recipients other_recipients = other_recipients.replace(", " + message.sender_full_name, ""); other_recipients = other_recipients.replace(message.sender_full_name + ", ", ""); notification_source = "pm"; } else { key = message.sender_full_name + " to " + message.stream + " > " + topic; if (message.mentioned) { notification_source = "mention"; } else if (message.alerted) { notification_source = "alert"; } else { notification_source = "stream"; } } blueslip.debug("Desktop notification from source " + notification_source); if (content.length > 150) { // Truncate content at a word boundary for (i = 150; i > 0; i -= 1) { if (content[i] === " ") { break; } } content = content.slice(0, i); content += " [...]"; } if (notice_memory.has(key)) { msg_count = notice_memory.get(key).msg_count + 1; title = msg_count + " messages from " + title; notification_object = notice_memory.get(key).obj; notification_object.close(); } if (message.type === "private") { if (message.display_recipient.length > 2) { // If the message has too many recipients to list them all... if (content.length + title.length + other_recipients.length > 230) { // Then count how many people are in the conversation and summarize // by saying the conversation is with "you and [number] other people" other_recipients = other_recipients.replace(/[^,]/g, "").length + " other people"; } title += " (to you and " + other_recipients + ")"; } else { title += " (to you)"; } } if (message.type === "stream") { title += " (to " + message.stream + " > " + topic + ")"; } if (notification.desktop_notify) { const icon_url = people.small_avatar_url(message); notification_object = new NotificationAPI(title, { icon: icon_url, body: content, tag: message.id, }); notice_memory.set(key, { obj: notification_object, msg_count, message_id: message.id, }); if (_.isFunction(notification_object.addEventListener)) { // Sadly, some third-party Electron apps like Franz/Ferdi // misimplement the Notification API not inheriting from // EventTarget. This results in addEventListener being // unavailable for them. notification_object.addEventListener("click", () => { notification_object.close(); if (message.type !== "test-notification") { narrow.by_topic(message.id, {trigger: "notification"}); } window.focus(); }); notification_object.addEventListener("close", () => { notice_memory.delete(key); }); } } } export function close_notification(message) { for (const [key, notice_mem_entry] of notice_memory) { if (notice_mem_entry.message_id === message.id) { notice_mem_entry.obj.close(); notice_memory.delete(key); } } } export function message_is_notifiable(message) { // Independent of the user's notification settings, are there // properties of the message that unconditionally mean we // shouldn't notify about it. if (message.sent_by_me) { return false; } // If a message is edited multiple times, we want to err on the side of // not spamming notifications. if (message.notification_sent) { return false; } // @-