Web: Show red notification dot on favicon when there are unread messages
This commit is contained in:
@@ -1685,6 +1685,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
|
* Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting)
|
||||||
* Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting)
|
* Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting)
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
**Bug fixes + maintenance:**
|
||||||
@@ -1700,4 +1701,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
* Refactor: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan))
|
* Refactor: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan))
|
||||||
* Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing)
|
* Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing)
|
||||||
* Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths))
|
* Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths))
|
||||||
* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting)
|
* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting)
|
||||||
@@ -8,6 +8,7 @@ import pop from "../sounds/pop.mp3";
|
|||||||
import popSwoosh from "../sounds/pop-swoosh.mp3";
|
import popSwoosh from "../sounds/pop-swoosh.mp3";
|
||||||
import config from "./config";
|
import config from "./config";
|
||||||
import emojisMapped from "./emojisMapped";
|
import emojisMapped from "./emojisMapped";
|
||||||
|
import { THEME } from "./Prefs";
|
||||||
|
|
||||||
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
|
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
|
||||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||||
@@ -274,6 +275,84 @@ export const urlB64ToUint8Array = (base64String) => {
|
|||||||
return outputArray;
|
return outputArray;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const darkModeEnabled = (prefersDarkMode, themePreference) => {
|
||||||
|
switch (themePreference) {
|
||||||
|
case THEME.DARK:
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case THEME.LIGHT:
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case THEME.SYSTEM:
|
||||||
|
default:
|
||||||
|
return prefersDarkMode;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Canvas-based favicon with a red notification dot when there are unread messages
|
||||||
|
let faviconCanvas;
|
||||||
|
let faviconOriginalIcon;
|
||||||
|
|
||||||
|
const loadFaviconIcon = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
if (faviconOriginalIcon) {
|
||||||
|
resolve(faviconOriginalIcon);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
faviconOriginalIcon = img;
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = () => resolve(null);
|
||||||
|
// Use PNG instead of ICO — .ico files can't be reliably drawn to canvas in all browsers
|
||||||
|
img.src = "/static/images/ntfy.png";
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateFavicon = async (count) => {
|
||||||
|
const size = 32;
|
||||||
|
const img = await loadFaviconIcon();
|
||||||
|
if (!img) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!faviconCanvas) {
|
||||||
|
faviconCanvas = document.createElement("canvas");
|
||||||
|
faviconCanvas.width = size;
|
||||||
|
faviconCanvas.height = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = faviconCanvas.getContext("2d");
|
||||||
|
ctx.clearRect(0, 0, size, size);
|
||||||
|
ctx.drawImage(img, 0, 0, size, size);
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
const dotRadius = 5;
|
||||||
|
const borderWidth = 2;
|
||||||
|
const dotX = size - dotRadius - borderWidth + 1;
|
||||||
|
const dotY = size - dotRadius - borderWidth + 1;
|
||||||
|
|
||||||
|
// Transparent border: erase a ring around the dot so the icon doesn't bleed into it
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalCompositeOperation = "destination-out";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(dotX, dotY, dotRadius + borderWidth, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Red dot
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(dotX, dotY, dotRadius, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = "#dc3545";
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.querySelector("link[rel='icon']");
|
||||||
|
if (link) {
|
||||||
|
link.href = faviconCanvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const copyToClipboard = (text) => {
|
export const copyToClipboard = (text) => {
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
return navigator.clipboard.writeText(text);
|
return navigator.clipboard.writeText(text);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import ActionBar from "./ActionBar";
|
|||||||
import Preferences from "./Preferences";
|
import Preferences from "./Preferences";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import userManager from "../app/UserManager";
|
import userManager from "../app/UserManager";
|
||||||
import { expandUrl, getKebabCaseLangStr } from "../app/utils";
|
import { expandUrl, getKebabCaseLangStr, darkModeEnabled, updateFavicon } from "../app/utils";
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks";
|
import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks";
|
||||||
@@ -21,7 +21,7 @@ import Login from "./Login";
|
|||||||
import Signup from "./Signup";
|
import Signup from "./Signup";
|
||||||
import Account from "./Account";
|
import Account from "./Account";
|
||||||
import initI18n from "../app/i18n"; // Translations!
|
import initI18n from "../app/i18n"; // Translations!
|
||||||
import prefs, { THEME } from "../app/Prefs";
|
import prefs from "../app/Prefs";
|
||||||
import RTLCacheProvider from "./RTLCacheProvider";
|
import RTLCacheProvider from "./RTLCacheProvider";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
|
|
||||||
@@ -29,20 +29,6 @@ initI18n();
|
|||||||
|
|
||||||
export const AccountContext = createContext(null);
|
export const AccountContext = createContext(null);
|
||||||
|
|
||||||
const darkModeEnabled = (prefersDarkMode, themePreference) => {
|
|
||||||
switch (themePreference) {
|
|
||||||
case THEME.DARK:
|
|
||||||
return true;
|
|
||||||
|
|
||||||
case THEME.LIGHT:
|
|
||||||
return false;
|
|
||||||
|
|
||||||
case THEME.SYSTEM:
|
|
||||||
default:
|
|
||||||
return prefersDarkMode;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const languageDir = i18n.dir();
|
const languageDir = i18n.dir();
|
||||||
@@ -97,6 +83,7 @@ const App = () => {
|
|||||||
const updateTitle = (newNotificationsCount) => {
|
const updateTitle = (newNotificationsCount) => {
|
||||||
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
|
document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
|
||||||
window.navigator.setAppBadge?.(newNotificationsCount);
|
window.navigator.setAppBadge?.(newNotificationsCount);
|
||||||
|
updateFavicon(newNotificationsCount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user