From 8d591f20937bb54bf6e8d7ca1817bb2ab1b9b573 Mon Sep 17 00:00:00 2001 From: eJosR-Coding Date: Sat, 12 Sep 2026 02:17:00 -0500 Subject: [PATCH] feat(tray): unread badge until the window is looked at Notices that arrive while the window isn't visible and focused count as unread: the tray swaps to the logo with a red dot and the tooltip shows the count. Opening the window from the tray, or it gaining focus, clears it. The bell animation ends on whichever resting icon applies, so the badge survives a ring. Rust owns the state; the frontend only reports how many notices landed. --- src-tauri/icons/tray/unread.png | Bin 0 -> 2875 bytes src-tauri/src/lib.rs | 8 +++-- src-tauri/src/tray.rs | 52 ++++++++++++++++++++++++++++++-- src/core/poller.ts | 3 +- src/main.ts | 2 ++ src/platform/notify.ts | 5 +++ 6 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 src-tauri/icons/tray/unread.png diff --git a/src-tauri/icons/tray/unread.png b/src-tauri/icons/tray/unread.png new file mode 100644 index 0000000000000000000000000000000000000000..0959b6c8d7d6bb104900185849a5c330802f9e50 GIT binary patch literal 2875 zcmZ`*do&Xc8(uN|hFl^`?u6K~g>p+ja+hmkrJ>OhhGOP2BDrKX!f%98kw{cpQ|sck zLb;W+xo5(VTVWfDPv<-5`~CHu_wtU2H|?{*n+C1>l*zy*t!Fob4Ih1vCz078r@y0l*R1k)u$UDHIMz zz)j7JkD9^Y`Y@Oo4CZLl{^z8B1z`~vf-lDXe}T#Vq{SV9?*ANc!C{yv96IdsznUWm z*fFy|3Hz_f{@%`R004Br(Z&jKhDk0YkVfsJ+fNv-5Jm|6#j!VIYeTOF>e;<6zQyB4 zkyP3xsgra{wP{Lq;ap#&wiHgh1+NaEWY zw6qlP8WSwqR<2jA2M}=w$P1sx_xx15%Z)yR{tr>D?k`)3D?gxZ;|K_(8bv7T+>|xyLI2}K_ z7*^WT_wBwrW60xw|O_L9vsX{^r&drLyhnSAoJWC;Cx51EDyMjE91~99k~ND`QR+#X!C31ai*V+DHyK zGb&UWdTDxRpECoG`MzHmW}%x1DH9{I=0a1-r@vXiX%4SSWJLDKyg?A;LM7UCnuM&KfqsLUQn1Gl=cZ9Xec;IliW9ah@LmK@t|9nSyIh< zG|tu8&R9x#{{FOqiDeYu&Nn2$zjVP>RD(#b9U;66Iq}n|(^O#ieIRKnK5y!(@wE>`8*3`du#qNEXOy)P5K-Yd(z~`btZ~NZ%v}k96`J{S$$x2uRq`c zWt`MPaS{?G!B5xJc)yF6&#N8Re`Uvr3ppN4v%fWUbNbA8hisLDU!PGfbsl|Ylw2pt zjA_gaHST!LfnMW`?iG5C)q@BJo+@U?O*BeU#qm!BOmT~(*Y8jILszX&V;R^gU(_QN z7z~3zAi@kfOMUqO!X+RU3ZrcR|Pp1A42D$pDJ}n zQuRElh4=IiX0eS__G@pnI6IUDeBMKf09?61-gAE74MTY4uXppn^2(q)pEx}^zP%A6 zyrVCDb6)kwZY)F*{63m})%b8ktNT-Jb7mh8nDpu%^(}{Lzw*pqHb~d+xa-gLjQ4Iz zRX@1=)^*dy!LkIfv8yUc9mBpaOMax&T$~lr2A2w&9wq^I zh3d&RQ~y|30yE+88K__?baz=?{Izz~ zQVjb=RdWT)+kfR@`z2E?kaCsQ_-s+! z)q0T#9nMUVt)0D{nCiAhuk!J>L>+G2IwgPJxxl1vZy9##9}@a?p1W&2WKbFj52`X5?Z=sm{UVUMd%YGU#SisNGwv6Y z47lj^FB=NBR(0N7VrDmv_78Umx`pw(MLjzmSi)Z33XvEk@g=+k59sICnXV;i#HWL; z9OY;{cnbTxonw=ENVgy6sj5xtL}UoPRgjBw$_@H$7^Fm$RU;}sVZj>0H8XjP5u3>w z#}Bg*?r>1Si}-TaQF|0pQZNe@BE={nrQ`)=+_vVG3fERLS9V{&rvVbIQd{3NAy~c> zh`dzPlV;Emu*LWu%zN3UV0)X?RynsT?NDf)ca3q0_Fr+id(E7`>`SN^M4zIB(a3ER+vJc@E)BCGwD;k>^2q9RBT65_%n zyzam?35yUlsz$@zCxK&Jh}{08OLtj6_b=TH&o+P6wfF<=#HfHe$#cGwQ>D5wwI$V2 z-{dP^$J)i>HKuh2X?x-4E>bGJ4e?h{vnT8{itir}C8@i_UkEP5_XSp4BuhG5r&-~#V;>glhT)YUHr{;ap0SRLAdFd|k@VUbV{oX<(k#AMmv}?k z7EWm~zIT?06lB%fR}@Aqpt2(NE$4U!1Uz#p7Vquu`V?|3F1~yjk z+8;byMw@lRHFB{|=Ci-Qth0k$O`E6A{`6Er_S_I8>dDsOxL#xo4ui5}XMIKah?4ke z!E(SHt?VM}L1}Os6>X-l7%KORXWg?N&X1Vp`fZkGP2jxL*&+1zkQLRna)Nw1Y+q;> z2DGwW!bm7DeEXDk_(mtiN2xHqR6JlQ%df1e*GDtP-y~_eqo#KosB4K7z)~;le7yjG NqphpWBkS`i{{df%TOa@c literal 0 HcmV?d00001 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0ae4364..8dacbb3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,6 +45,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ tray::ring_tray, + tray::mark_unread, notify::notify, secrets::get_token, secrets::set_token, @@ -77,11 +78,14 @@ pub fn run() { }) // "X" on the window means "go to tray", not "kill the app". // Quitting for real only happens from the tray menu. - .on_window_event(|window, event| { - if let WindowEvent::CloseRequested { api, .. } = event { + .on_window_event(|window, event| match event { + WindowEvent::CloseRequested { api, .. } => { api.prevent_close(); let _ = window.hide(); } + // looking at the window clears the tray badge + WindowEvent::Focused(true) => tray::clear_unread(window.app_handle()), + _ => {} }) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 814af3d..72050f8 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,7 +1,7 @@ //! Tray icon: the "real" UI of the app. The window is just settings, the //! tray is what lives all day. -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::Duration; use crate::i18n::tr; @@ -26,12 +26,57 @@ const FRAMES: [&[u8]; 8] = [ include_bytes!("../icons/tray/frame-6.png"), include_bytes!("../icons/tray/frame-7.png"), ]; +/// Base icon with a red dot: notices arrived while you weren't looking. +const UNREAD: &[u8] = include_bytes!("../icons/tray/unread.png"); const FRAME_MS: u64 = 90; const RING_LOOPS: usize = 2; /// True while an animation is playing, so a burst of notices doesn't /// spawn five overlapping threads fighting over the icon. static RINGING: AtomicBool = AtomicBool::new(false); +/// Notices the user hasn't seen yet. Cleared when the window gets focus. +static UNREAD_COUNT: AtomicUsize = AtomicUsize::new(0); + +/// The icon that belongs on the tray right now: badge if unread, else logo. +fn resting_icon(app: &AppHandle) -> Option> { + if UNREAD_COUNT.load(Ordering::Relaxed) > 0 { + Image::from_bytes(UNREAD).ok() + } else { + app.default_window_icon().cloned() + } +} + +fn apply_resting(app: &AppHandle) { + if let Some(tray) = app.tray_by_id(TRAY_ID) { + let _ = tray.set_icon(resting_icon(app)); + let n = UNREAD_COUNT.load(Ordering::Relaxed); + let tooltip = if n == 0 { "GitBell".to_string() } else { format!("GitBell ยท {n}") }; + let _ = tray.set_tooltip(Some(tooltip)); + } +} + +/// Frontend calls this with each batch of notices. Only counts as unread +/// when the window isn't in front of the user. +#[tauri::command] +pub fn mark_unread(app: AppHandle, count: usize) { + let looking = crate::main_window(&app) + .map(|w| w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false)) + .unwrap_or(false); + if looking { + return; + } + UNREAD_COUNT.fetch_add(count, Ordering::Relaxed); + if !RINGING.load(Ordering::Relaxed) { + apply_resting(&app); + } +} + +/// Window got focus: the user saw what there was to see. +pub fn clear_unread(app: &AppHandle) { + if UNREAD_COUNT.swap(0, Ordering::Relaxed) > 0 && !RINGING.load(Ordering::Relaxed) { + apply_resting(app); + } +} /// Bring the main window back. Used by the tray, by single-instance and /// by the "open" menu item. No-op if the window somehow doesn't exist. @@ -40,6 +85,9 @@ pub fn show_main_window(app: &AppHandle) { let _ = win.show(); let _ = win.unminimize(); let _ = win.set_focus(); + // opening it on purpose counts as having looked, even if the + // compositor's focus-stealing prevention swallows set_focus + clear_unread(app); request_glass(&win); } } @@ -135,7 +183,7 @@ pub fn ring_tray(app: AppHandle) { std::thread::sleep(Duration::from_millis(FRAME_MS)); } } - let _ = tray.set_icon(app.default_window_icon().cloned()); RINGING.store(false, Ordering::SeqCst); + apply_resting(&app); }); } diff --git a/src/core/poller.ts b/src/core/poller.ts index df5b9e1..64a8033 100644 --- a/src/core/poller.ts +++ b/src/core/poller.ts @@ -9,7 +9,7 @@ import { endpointFor, fetchEvents, GhError } from "./github"; import { t } from "./i18n"; import { toNotice } from "./format"; -import { ringTray, toast } from "../platform/notify"; +import { markUnread, ringTray, toast } from "../platform/notify"; import { playSound } from "../platform/sounds"; import { loadLastSeen, saveLastSeen } from "../platform/settings"; import type { Notice, PollStatus, Settings } from "./types"; @@ -168,6 +168,7 @@ export class Poller { private async fire(notices: Notice[]): Promise { const { soundsEnabled, sounds, volume } = this.settings; ringTray(); + markUnread(notices.length); const head = notices.slice(0, MAX_TOASTS_PER_CYCLE); for (const n of head) { await toast(n.title, n.body, n.url); diff --git a/src/main.ts b/src/main.ts index 7ec4473..fd63ddc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import { listen } from "@tauri-apps/api/event"; import { initLang } from "./core/i18n"; import { Poller } from "./core/poller"; import type { Notice } from "./core/types"; +import { markUnread } from "./platform/notify"; import { playSound } from "./platform/sounds"; import { getToken } from "./platform/secrets"; import { loadRecent, loadSettings, saveRecent, saveSettings } from "./platform/settings"; @@ -103,6 +104,7 @@ async function boot(): Promise { }; const s = ui.currentSettings(); if (s.events.external) { + markUnread(1); ui.pushNotices([n]); void saveRecent(ui.getRecent()); if (s.soundsEnabled) void playSound(s.sounds.external, s.volume); diff --git a/src/platform/notify.ts b/src/platform/notify.ts index d2c960a..6000cf4 100644 --- a/src/platform/notify.ts +++ b/src/platform/notify.ts @@ -17,3 +17,8 @@ export async function toast(title: string, body: string, url?: string): Promise< export function ringTray(): void { invoke("ring_tray").catch((e) => console.warn("ring_tray failed", e)); } + +/** Tell the tray how many notices just arrived; Rust decides if they're unread. */ +export function markUnread(count: number): void { + invoke("mark_unread", { count }).catch((e) => console.warn("mark_unread failed", e)); +}