commit 8b92a97692a787f166e9cb3b8f38c3fcffd83ae9
parent 1839c60b236d407db9f3bc5d1803000fc8ef75c8
Author: SageAzakaela <106701693+SageAzakaela@users.noreply.github.com>
Date: Tue, 24 Feb 2026 15:57:08 -0700
Improved Notification system
Diffstat:
4 files changed, 188 insertions(+), 16 deletions(-)
diff --git a/package.json b/package.json
@@ -20,6 +20,7 @@
"build:plugin:directory-server": "node scripts/build-directory-server-plugin.js",
"build:plugin:directory-publisher": "node scripts/build-directory-publisher-plugin.js",
"build:plugin:godot": "node scripts/build-godot-plugin.js",
+ "build:plugin:snaketroid": "node scripts/build-snaketroid-plugin.js",
"multi:init": "node scripts/multi-instance-init.js",
"multi:update": "node scripts/multi-instance-update.js",
"instances:scan": "node scripts/bzl-instances-update.js",
diff --git a/public/app.js b/public/app.js
@@ -24,6 +24,11 @@ const mobileMoreListEl = document.getElementById("mobileMoreList");
const mobileScreenHostEl = document.getElementById("mobileScreenHost");
const enableNotifsBtn = document.getElementById("enableNotifs");
const notifStatus = document.getElementById("notifStatus");
+const notifSoundToggleEl = document.getElementById("notifSoundToggle");
+const notifNewHiveToggleEl = document.getElementById("notifNewHiveToggle");
+const notifReplyPingToggleEl = document.getElementById("notifReplyPingToggle");
+const notifMyHiveChatsToggleEl = document.getElementById("notifMyHiveChatsToggle");
+const notifRecentHiveChatsToggleEl = document.getElementById("notifRecentHiveChatsToggle");
const toggleReactionsEl = document.getElementById("toggleReactions");
const hivesViewModeEl = document.getElementById("hivesViewMode");
const toggleRackLayoutEl = document.getElementById("toggleRackLayout");
@@ -262,6 +267,7 @@ const chatByPost = new Map();
const unreadByPostId = new Map();
/** @type {Map<string, Set<string>>} */
const typingUsersByPostId = new Map();
+const ownRecentChatByPostId = new Map();
/** @type {Set<string>} */
const myReacts = new Set();
/** @type {Map<string, number>} */
@@ -3949,6 +3955,72 @@ function readStayConnectedPref() {
function writeStayConnectedPref(on) {
writeBoolPref(STAY_CONNECTED_KEY, Boolean(on));
}
+const NOTIF_SOUND_KEY = "bzl_notif_sound";
+const NOTIF_NEW_HIVE_KEY = "bzl_notif_new_hive";
+const NOTIF_REPLY_PING_KEY = "bzl_notif_reply_ping";
+const NOTIF_MY_HIVE_CHAT_KEY = "bzl_notif_my_hive_chat";
+const NOTIF_RECENT_HIVE_CHAT_KEY = "bzl_notif_recent_hive_chat";
+const RECENT_HIVE_CHAT_WINDOW_MS = 6 * 60 * 60 * 1000;
+function readNotifSoundPref() {
+ return readBoolPref(NOTIF_SOUND_KEY, true);
+}
+function writeNotifSoundPref(on) {
+ writeBoolPref(NOTIF_SOUND_KEY, Boolean(on));
+}
+function readNotifNewHivePref() {
+ return readBoolPref(NOTIF_NEW_HIVE_KEY, true);
+}
+function writeNotifNewHivePref(on) {
+ writeBoolPref(NOTIF_NEW_HIVE_KEY, Boolean(on));
+}
+function readNotifReplyPingPref() {
+ return readBoolPref(NOTIF_REPLY_PING_KEY, true);
+}
+function writeNotifReplyPingPref(on) {
+ writeBoolPref(NOTIF_REPLY_PING_KEY, Boolean(on));
+}
+function readNotifMyHiveChatPref() {
+ return readBoolPref(NOTIF_MY_HIVE_CHAT_KEY, true);
+}
+function writeNotifMyHiveChatPref(on) {
+ writeBoolPref(NOTIF_MY_HIVE_CHAT_KEY, Boolean(on));
+}
+function readNotifRecentHiveChatPref() {
+ return readBoolPref(NOTIF_RECENT_HIVE_CHAT_KEY, true);
+}
+function writeNotifRecentHiveChatPref(on) {
+ writeBoolPref(NOTIF_RECENT_HIVE_CHAT_KEY, Boolean(on));
+}
+function noteOwnRecentChat(postId, atMs) {
+ const id = String(postId || "").trim();
+ if (!id) return;
+ const at = Number(atMs || Date.now());
+ ownRecentChatByPostId.set(id, Number.isFinite(at) ? at : Date.now());
+}
+function hasOwnRecentChat(postId) {
+ const id = String(postId || "").trim();
+ if (!id) return false;
+ const at = Number(ownRecentChatByPostId.get(id) || 0);
+ if (!at) return false;
+ if (Date.now() - at > RECENT_HIVE_CHAT_WINDOW_MS) {
+ ownRecentChatByPostId.delete(id);
+ return false;
+ }
+ return true;
+}
+function hydrateOwnRecentChatFromHistory(postId, messages) {
+ const id = String(postId || "").trim();
+ if (!id || !Array.isArray(messages) || !messages.length) return;
+ const selfLower = String(loggedInUser || "").toLowerCase();
+ if (!selfLower) return;
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
+ const m = messages[i];
+ const fromLower = String(m?.fromUser || "").toLowerCase();
+ if (!fromLower || fromLower !== selfLower) continue;
+ noteOwnRecentChat(id, m?.createdAt);
+ return;
+ }
+}
const ENABLE_HINTS_KEY = "bzl_enableHints";
const CHAT_ENTER_MODE_KEY = "bzl_chatEnterMode"; // "ctrlEnter" | "enter"
function readHintsEnabledPref() {
@@ -4288,6 +4360,7 @@ function groupedThemePresetOptionsHtml() {
const SFX = {
open: "/assets/sfx/Select_B7.wav",
post: "/assets/sfx/Select_B7.wav",
+ notif: "/assets/sfx/Select_B7.wav",
ping: "/assets/sfx/Select_C3.wav",
};
const sfxCache = new Map();
@@ -7302,24 +7375,34 @@ function updateNotifUi() {
const state = notifState();
const secure = location.protocol === "https:";
const hint = secure ? "" : " (requires HTTPS: use tunnel)";
+ const activeRules = [];
+ if (readNotifReplyPingPref()) activeRules.push("replies/pings");
+ if (readNotifMyHiveChatPref()) activeRules.push("my hive chat");
+ if (readNotifRecentHiveChatPref()) activeRules.push("recent hive chat");
+ if (readNotifNewHivePref()) activeRules.push("new hives");
+ const rulesLabel = activeRules.length ? activeRules.join(", ") : "no active rules";
if (state === "unsupported") {
enableNotifsBtn.classList.add("hidden");
- notifStatus.textContent = "Notifications not supported in this browser.";
+ notifStatus.textContent = `Browser notifications not supported. Alerts: ${rulesLabel}.`;
return;
}
enableNotifsBtn.classList.remove("hidden");
if (!secure) {
enableNotifsBtn.disabled = true;
- notifStatus.textContent = `Notifications disabled on HTTP${hint}.`;
+ notifStatus.textContent = `Browser notifications disabled on HTTP${hint}. Alerts: ${rulesLabel}.`;
return;
}
enableNotifsBtn.disabled = state === "granted";
enableNotifsBtn.textContent = state === "granted" ? "Notifications enabled" : "Enable notifications";
notifStatus.textContent =
- state === "granted" ? "You'll get pings when activity happens." : state === "denied" ? "Blocked in browser settings." : "";
+ state === "granted"
+ ? `Browser alerts enabled. Active rules: ${rulesLabel}.`
+ : state === "denied"
+ ? `Browser alerts blocked in settings. Active rules: ${rulesLabel}.`
+ : `Active rules: ${rulesLabel}.`;
}
function maybeNotify(title, body, data) {
@@ -13961,7 +14044,8 @@ function onWsMessage(evt) {
if (author && loggedInUser && author === loggedInUser) {
playSfx("post", { volume: 0.36 });
}
- if (author && author !== loggedInUser && !(authorLower && authorLower !== selfLower && ignoreUserSet.has(authorLower))) {
+ if (author && author !== loggedInUser && readNotifNewHivePref() && !(authorLower && authorLower !== selfLower && ignoreUserSet.has(authorLower))) {
+ if (readNotifSoundPref()) playSfx("notif", { volume: 0.5 });
if (!windowFocused || document.hidden) {
maybeNotify(`Bzl: ${title}`, `New post by @${author}`, { postId: msg.post.id });
} else {
@@ -14292,7 +14376,10 @@ function onWsMessage(evt) {
if (!postId || !msg.post) return;
posts.set(postId, msg.post);
streamLiveByPostId.set(postId, Boolean(msg.post.streamLive));
- if (Array.isArray(msg.messages)) chatByPost.set(postId, msg.messages);
+ if (Array.isArray(msg.messages)) {
+ chatByPost.set(postId, msg.messages);
+ hydrateOwnRecentChatFromHistory(postId, msg.messages);
+ }
renderFeed();
renderChatPanel();
renderTypingIndicator();
@@ -14306,7 +14393,9 @@ function onWsMessage(evt) {
}
if (msg.type === "chatHistory") {
- chatByPost.set(msg.postId, Array.isArray(msg.messages) ? msg.messages : []);
+ const history = Array.isArray(msg.messages) ? msg.messages : [];
+ chatByPost.set(msg.postId, history);
+ hydrateOwnRecentChatFromHistory(msg.postId, history);
markRead(msg.postId);
renderChatPanel(true);
renderTypingIndicator();
@@ -14428,6 +14517,7 @@ function onWsMessage(evt) {
}
}
const isFromYou = Boolean(sender && loggedInUser && sender === loggedInUser);
+ if (isFromYou) noteOwnRecentChat(msg.postId, msg.message?.createdAt);
const senderLower = String(sender || "").toLowerCase();
const selfLower = String(loggedInUser || "").toLowerCase();
const ignoreUserSet = new Set(
@@ -14438,15 +14528,30 @@ function onWsMessage(evt) {
renderChatInstancesForPost(msg.postId);
return;
}
+ const p = posts.get(msg.postId);
+ const postOwnerLower = String(p?.author || "").toLowerCase();
+ const isOnYourHive = Boolean(selfLower && postOwnerLower && selfLower === postOwnerLower);
const mentions = Array.isArray(msg.message?.mentions) ? msg.message.mentions.map((u) => String(u || "").toLowerCase()) : [];
- const mentionsYou = Boolean(loggedInUser && mentions.includes(loggedInUser) && !isFromYou);
- if (mentionsYou) playSfx("ping", { volume: 0.42 });
+ const mentionsYou = Boolean(selfLower && mentions.includes(selfLower) && !isFromYou);
+ const replyToUserLower = String(msg.message?.replyTo?.fromUser || "").toLowerCase();
+ const repliesYou = Boolean(selfLower && replyToUserLower && replyToUserLower === selfLower && !isFromYou);
+ const isRecentHiveChat = hasOwnRecentChat(msg.postId);
+ const shouldAlertReplies = readNotifReplyPingPref() && (mentionsYou || repliesYou);
+ const shouldAlertMyHive = readNotifMyHiveChatPref() && isOnYourHive;
+ const shouldAlertRecent = readNotifRecentHiveChatPref() && isRecentHiveChat;
+ const shouldAlert = Boolean(!isFromYou && (shouldAlertReplies || shouldAlertMyHive || shouldAlertRecent));
+ if (shouldAlert && readNotifSoundPref()) playSfx("notif", { volume: 0.5 });
+ const title = p ? postTitle(p) : "Chat";
+ const body = sender ? `@${sender}: ${msg.message?.text || ""}` : msg.message?.text || "";
+ const notifyTitle = mentionsYou || repliesYou ? `Bzl: Reply in ${title}` : `Bzl: ${title}`;
+ const toastTitle = mentionsYou || repliesYou ? "Reply ping" : title;
+ const toastBody = mentionsYou || repliesYou ? `@${sender} replied/mentioned you` : body.slice(0, 120);
if (activeChatPostId === msg.postId && windowFocused && !document.hidden) {
markRead(msg.postId);
if (!appendPostChatMessageToDom(msg.postId, msg.message)) renderChatPanel();
pulseChatMessage(msg.message?.id);
renderTypingIndicator();
- if (mentionsYou) toast("Mentioned", `@${sender} mentioned you.`);
+ if (shouldAlert) toast(toastTitle, toastBody);
} else {
if (!buzzTimers.has(msg.postId)) {
const t = window.setTimeout(() => {
@@ -14464,16 +14569,10 @@ function onWsMessage(evt) {
}
bumpUnread(msg.postId);
renderFeed();
- const p = posts.get(msg.postId);
- const title = p ? postTitle(p) : "Chat";
- const body = sender ? `@${sender}: ${msg.message?.text || ""}` : msg.message?.text || "";
- if (!isFromYou) {
+ if (shouldAlert) {
if (!windowFocused || document.hidden) {
- const notifyTitle = mentionsYou ? `Bzl: Mention in ${title}` : `Bzl: ${title}`;
maybeNotify(notifyTitle, body.slice(0, 160), { postId: msg.postId });
} else {
- const toastTitle = mentionsYou ? "Mentioned" : title;
- const toastBody = mentionsYou ? `@${sender} mentioned you` : body.slice(0, 120);
toast(toastTitle, toastBody);
}
}
@@ -14512,6 +14611,41 @@ if (enableHintsEl) {
writeHintsEnabledPref(Boolean(enableHintsEl.checked));
});
}
+if (notifSoundToggleEl) {
+ notifSoundToggleEl.checked = readNotifSoundPref();
+ notifSoundToggleEl.addEventListener("change", () => {
+ writeNotifSoundPref(Boolean(notifSoundToggleEl.checked));
+ updateNotifUi();
+ });
+}
+if (notifNewHiveToggleEl) {
+ notifNewHiveToggleEl.checked = readNotifNewHivePref();
+ notifNewHiveToggleEl.addEventListener("change", () => {
+ writeNotifNewHivePref(Boolean(notifNewHiveToggleEl.checked));
+ updateNotifUi();
+ });
+}
+if (notifReplyPingToggleEl) {
+ notifReplyPingToggleEl.checked = readNotifReplyPingPref();
+ notifReplyPingToggleEl.addEventListener("change", () => {
+ writeNotifReplyPingPref(Boolean(notifReplyPingToggleEl.checked));
+ updateNotifUi();
+ });
+}
+if (notifMyHiveChatsToggleEl) {
+ notifMyHiveChatsToggleEl.checked = readNotifMyHiveChatPref();
+ notifMyHiveChatsToggleEl.addEventListener("change", () => {
+ writeNotifMyHiveChatPref(Boolean(notifMyHiveChatsToggleEl.checked));
+ updateNotifUi();
+ });
+}
+if (notifRecentHiveChatsToggleEl) {
+ notifRecentHiveChatsToggleEl.checked = readNotifRecentHiveChatPref();
+ notifRecentHiveChatsToggleEl.addEventListener("change", () => {
+ writeNotifRecentHiveChatPref(Boolean(notifRecentHiveChatsToggleEl.checked));
+ updateNotifUi();
+ });
+}
if (chatEnterModeEl) {
chatEnterModeEl.value = readChatEnterModePref();
chatEnterModeEl.addEventListener("change", () => {
diff --git a/public/index.html b/public/index.html
@@ -37,6 +37,28 @@
<button id="enableNotifs" class="ghost smallBtn grow" type="button">Enable notifications</button>
</div>
<div id="notifStatus" class="small muted"></div>
+ <div class="notifOptions">
+ <label class="checkRow notifCheckRow">
+ <span>Alert sound (B7)</span>
+ <input id="notifSoundToggle" type="checkbox" />
+ </label>
+ <label class="checkRow notifCheckRow">
+ <span>Any new hive</span>
+ <input id="notifNewHiveToggle" type="checkbox" />
+ </label>
+ <label class="checkRow notifCheckRow">
+ <span>Replies and pings</span>
+ <input id="notifReplyPingToggle" type="checkbox" />
+ </label>
+ <label class="checkRow notifCheckRow">
+ <span>Chats on my hives</span>
+ <input id="notifMyHiveChatsToggle" type="checkbox" />
+ </label>
+ <label class="checkRow notifCheckRow">
+ <span>Chats where I posted recently</span>
+ <input id="notifRecentHiveChatsToggle" type="checkbox" />
+ </label>
+ </div>
</div>
<section id="viewPanel" class="panel">
diff --git a/public/styles.css b/public/styles.css
@@ -1715,6 +1715,21 @@ a.poweredByTile:hover {
margin-top: 4px;
}
+.notifOptions {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.notifCheckRow {
+ padding: 6px 10px;
+ border-radius: 10px;
+}
+
+.notifCheckRow span {
+ font-size: 12px;
+}
+
.toastHost {
position: fixed;
right: 16px;